Add mobile selection inspector sheet (P3); editable layer properties
- New mobile/inspector.rs: when something is selected/focused, a bottom sheet overlays the lower stack (no reflow) above the transport, showing the object's properties by reusing InfopanelPane full-bleed. Header shows a title + ✕ to deselect; jump-to chips (Timeline, Nodes) reframe the stack to that surface with the selection kept. Grab handle resizes the sheet. - Make the Infopanel Layer section editable (benefits desktop too): Name (text, commit on Enter/blur), Opacity and Volume sliders (live preview, single-undo on release), and Mute/Solo/Lock toggles. Backs the plan to reduce mobile layer headers to a minimal swatch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f2cb516e8a
commit
87263489c0
|
|
@ -0,0 +1,179 @@
|
||||||
|
//! Selection inspector bottom sheet. When something is selected/focused it rises above the
|
||||||
|
//! transport, showing the focused object's properties by reusing `InfopanelPane` full-bleed.
|
||||||
|
//! Jump-to chips slide the stack window to the related surface; the ✕ deselects.
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
use lightningbeam_core::pane::PaneType;
|
||||||
|
use lightningbeam_core::selection::FocusSelection;
|
||||||
|
|
||||||
|
use super::{surface, MobileState, MOBILE_NS};
|
||||||
|
use crate::panes::{NodePath, SharedPaneState};
|
||||||
|
use crate::RenderContext;
|
||||||
|
|
||||||
|
const C_SHEET: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37);
|
||||||
|
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
|
||||||
|
const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x95, 0xa1);
|
||||||
|
const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3);
|
||||||
|
const C_CYAN: egui::Color32 = egui::Color32::from_rgb(0x54, 0xc3, 0xe8);
|
||||||
|
|
||||||
|
const GRAB_H: f32 = 16.0;
|
||||||
|
const HEAD_H: f32 = 30.0;
|
||||||
|
const CHIP_H: f32 = 30.0;
|
||||||
|
|
||||||
|
fn inspector_path() -> NodePath {
|
||||||
|
vec![MOBILE_NS, 200]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether anything is selected/focused (i.e. the inspector should be shown).
|
||||||
|
pub fn is_active(shared: &SharedPaneState) -> bool {
|
||||||
|
!shared.focus.is_none() || !shared.selection.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short title describing what's selected.
|
||||||
|
fn title(shared: &SharedPaneState) -> String {
|
||||||
|
use lightningbeam_core::layer::{AnyLayer, AudioLayerType};
|
||||||
|
let plural = |n: usize, s: &str| {
|
||||||
|
if n == 1 {
|
||||||
|
format!("1 {s}")
|
||||||
|
} else {
|
||||||
|
format!("{n} {s}s")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match &*shared.focus {
|
||||||
|
FocusSelection::Layers(ids) => {
|
||||||
|
let doc = shared.action_executor.document();
|
||||||
|
match ids.len() {
|
||||||
|
0 => "Layer".to_string(),
|
||||||
|
1 => {
|
||||||
|
if let Some(l) = doc.get_layer(&ids[0]) {
|
||||||
|
let ty = match l {
|
||||||
|
AnyLayer::Vector(_) => "Vector",
|
||||||
|
AnyLayer::Audio(a) => match a.audio_layer_type {
|
||||||
|
AudioLayerType::Midi => "MIDI",
|
||||||
|
AudioLayerType::Sampled => "Audio",
|
||||||
|
},
|
||||||
|
AnyLayer::Video(_) => "Video",
|
||||||
|
AnyLayer::Effect(_) => "Effect",
|
||||||
|
AnyLayer::Group(_) => "Group",
|
||||||
|
AnyLayer::Raster(_) => "Raster",
|
||||||
|
AnyLayer::Text(_) => "Text",
|
||||||
|
};
|
||||||
|
format!("{} · {} layer", l.name(), ty)
|
||||||
|
} else {
|
||||||
|
"Layer".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n => plural(n, "layer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FocusSelection::ClipInstances(ids) => plural(ids.len(), "clip"),
|
||||||
|
FocusSelection::Notes { indices, .. } => plural(indices.len().max(1), "note"),
|
||||||
|
FocusSelection::Nodes(ids) => plural(ids.len(), "node"),
|
||||||
|
FocusSelection::Assets(ids) => plural(ids.len(), "asset"),
|
||||||
|
FocusSelection::Geometry { .. } | FocusSelection::None => "Selection".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A jump-to chip: its label and the stack window it brings into view (top, count). These reframe
|
||||||
|
/// to a related surface with the object still selected.
|
||||||
|
struct Chip {
|
||||||
|
label: &'static str,
|
||||||
|
window: (usize, usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHIPS: [Chip; 2] = [
|
||||||
|
Chip { label: "Timeline", window: (3, 1) }, // Timeline = STACK index 3
|
||||||
|
Chip { label: "Nodes", window: (6, 1) }, // Node/Instrument = STACK index 6
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn render(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
rect: egui::Rect,
|
||||||
|
region_h: f32,
|
||||||
|
rc: &mut RenderContext,
|
||||||
|
state: &mut MobileState,
|
||||||
|
) {
|
||||||
|
// Sheet background with rounded top.
|
||||||
|
ui.painter().rect_filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 },
|
||||||
|
C_SHEET,
|
||||||
|
);
|
||||||
|
ui.painter().hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, C_LINE));
|
||||||
|
|
||||||
|
// Grab handle — drag to resize the sheet.
|
||||||
|
let grab = egui::Rect::from_min_max(
|
||||||
|
rect.left_top(),
|
||||||
|
egui::pos2(rect.right(), rect.top() + GRAB_H),
|
||||||
|
);
|
||||||
|
let gresp = ui.interact(grab, ui.id().with("mobile_inspector_grab"), egui::Sense::drag());
|
||||||
|
let pill = egui::Rect::from_center_size(grab.center(), egui::vec2(34.0, 4.0));
|
||||||
|
ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { C_CYAN } else { C_LINE });
|
||||||
|
if gresp.dragged() && region_h > 1.0 {
|
||||||
|
state.inspector_frac = (state.inspector_frac - gresp.drag_delta().y / region_h).clamp(0.2, 0.85);
|
||||||
|
}
|
||||||
|
if gresp.hovered() || gresp.dragged() {
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header row: title + close.
|
||||||
|
let head_y = rect.top() + GRAB_H;
|
||||||
|
let head = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(rect.left(), head_y),
|
||||||
|
egui::pos2(rect.right(), head_y + HEAD_H),
|
||||||
|
);
|
||||||
|
ui.painter().text(
|
||||||
|
egui::pos2(head.left() + 14.0, head.center().y),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
title(&rc.shared),
|
||||||
|
egui::FontId::proportional(13.0),
|
||||||
|
C_BRIGHT,
|
||||||
|
);
|
||||||
|
let close = egui::Rect::from_min_size(egui::pos2(head.right() - 36.0, head.top()), egui::vec2(36.0, HEAD_H));
|
||||||
|
let cresp = ui.interact(close, ui.id().with("mobile_inspector_close"), egui::Sense::click());
|
||||||
|
ui.painter().text(
|
||||||
|
close.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
super::icons::X,
|
||||||
|
super::icons::font(16.0),
|
||||||
|
if cresp.hovered() { C_BRIGHT } else { C_DIM },
|
||||||
|
);
|
||||||
|
if cresp.clicked() {
|
||||||
|
rc.shared.selection.clear();
|
||||||
|
*rc.shared.focus = FocusSelection::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump-to chips (reframe to a related surface, keeping the selection).
|
||||||
|
let chip_y = head.bottom();
|
||||||
|
let mut cx = rect.left() + 12.0;
|
||||||
|
for (i, chip) in CHIPS.iter().enumerate() {
|
||||||
|
let galley = ui.painter().layout_no_wrap(
|
||||||
|
chip.label.to_string(),
|
||||||
|
egui::FontId::proportional(11.0),
|
||||||
|
C_BRIGHT,
|
||||||
|
);
|
||||||
|
let w = galley.size().x + 18.0;
|
||||||
|
let chip_rect = egui::Rect::from_min_size(egui::pos2(cx, chip_y + 4.0), egui::vec2(w, CHIP_H - 8.0));
|
||||||
|
let resp = ui.interact(chip_rect, ui.id().with(("mobile_inspector_chip", i)), egui::Sense::click());
|
||||||
|
ui.painter().rect_filled(chip_rect, 11.0, if resp.hovered() { C_LINE } else { C_SHEET });
|
||||||
|
ui.painter().rect_stroke(chip_rect, 11.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside);
|
||||||
|
ui.painter().galley(chip_rect.center() - galley.size() * 0.5, galley, C_BRIGHT);
|
||||||
|
if resp.clicked() {
|
||||||
|
let (top, count) = chip.window;
|
||||||
|
state.window_top = top;
|
||||||
|
state.window_count = count;
|
||||||
|
state.weights = [1.0, 1.0, 1.0];
|
||||||
|
state.anim = None;
|
||||||
|
}
|
||||||
|
cx += w + 6.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Properties content — reuse the Infopanel full-bleed.
|
||||||
|
let content = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(rect.left(), chip_y + CHIP_H),
|
||||||
|
rect.max,
|
||||||
|
);
|
||||||
|
if content.height() > 1.0 {
|
||||||
|
surface::render_surface_fullbleed(ui, content, &inspector_path(), PaneType::Infopanel, rc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ use crate::panes::NodePath;
|
||||||
use crate::RenderContext;
|
use crate::RenderContext;
|
||||||
|
|
||||||
pub mod icons;
|
pub mod icons;
|
||||||
|
mod inspector;
|
||||||
pub mod intent;
|
pub mod intent;
|
||||||
mod stack;
|
mod stack;
|
||||||
mod surface;
|
mod surface;
|
||||||
|
|
@ -145,6 +146,8 @@ pub struct MobileState {
|
||||||
pub weights: [f32; 3],
|
pub weights: [f32; 3],
|
||||||
/// Node/Instrument band: false = node editor, true = instrument/preset browser.
|
/// Node/Instrument band: false = node editor, true = instrument/preset browser.
|
||||||
pub show_instruments: bool,
|
pub show_instruments: bool,
|
||||||
|
/// Inspector sheet height as a fraction of the region above the transport.
|
||||||
|
pub inspector_frac: f32,
|
||||||
/// Active handle drag (transient).
|
/// Active handle drag (transient).
|
||||||
pub drag: Option<StackDrag>,
|
pub drag: Option<StackDrag>,
|
||||||
/// In-flight layout ease (transient).
|
/// In-flight layout ease (transient).
|
||||||
|
|
@ -159,6 +162,7 @@ impl Default for MobileState {
|
||||||
window_count: 2,
|
window_count: 2,
|
||||||
weights: [1.0, 1.0, 1.0],
|
weights: [1.0, 1.0, 1.0],
|
||||||
show_instruments: false,
|
show_instruments: false,
|
||||||
|
inspector_frac: 0.45,
|
||||||
drag: None,
|
drag: None,
|
||||||
anim: None,
|
anim: None,
|
||||||
}
|
}
|
||||||
|
|
@ -180,12 +184,23 @@ pub fn render_mobile_shell(
|
||||||
egui::pos2(available_rect.left(), available_rect.bottom() - TRANSPORT_H),
|
egui::pos2(available_rect.left(), available_rect.bottom() - TRANSPORT_H),
|
||||||
available_rect.max,
|
available_rect.max,
|
||||||
);
|
);
|
||||||
let stack_rect = egui::Rect::from_min_max(
|
// Region above the transport, shared between the stack and (when something is selected) the
|
||||||
|
// inspector sheet that rises above the transport.
|
||||||
|
let region = egui::Rect::from_min_max(
|
||||||
available_rect.min,
|
available_rect.min,
|
||||||
egui::pos2(available_rect.right(), transport_rect.top()),
|
egui::pos2(available_rect.right(), transport_rect.top()),
|
||||||
);
|
);
|
||||||
|
// The stack always fills the region; the inspector sheet overlays its lower part (no reflow).
|
||||||
|
stack::render(ui, region, rc, state);
|
||||||
|
|
||||||
stack::render(ui, stack_rect, rc, state);
|
if inspector::is_active(&rc.shared) {
|
||||||
|
let sheet_h = (region.height() * state.inspector_frac).clamp(120.0, region.height() - 60.0);
|
||||||
|
let sheet_rect = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(region.left(), region.bottom() - sheet_h),
|
||||||
|
region.max,
|
||||||
|
);
|
||||||
|
inspector::render(ui, sheet_rect, region.height(), rc, state);
|
||||||
|
}
|
||||||
|
|
||||||
// Transport floor: drawn last = always on top, the persistent spine.
|
// Transport floor: drawn last = always on top, the persistent spine.
|
||||||
transport::render(ui, transport_rect, &mut rc.shared);
|
transport::render(ui, transport_rect, &mut rc.shared);
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ pub struct InfopanelPane {
|
||||||
selected_tool_gradient_stop: Option<usize>,
|
selected_tool_gradient_stop: Option<usize>,
|
||||||
/// FPS value captured when a drag/focus-in starts (for single-undo-action on commit)
|
/// FPS value captured when a drag/focus-in starts (for single-undo-action on commit)
|
||||||
fps_drag_start: Option<f64>,
|
fps_drag_start: Option<f64>,
|
||||||
|
/// In-progress layer-name edit buffer, keyed by the layer being edited.
|
||||||
|
layer_name_edit: Option<(Uuid, String)>,
|
||||||
|
/// Layer opacity/volume captured when a slider drag starts (single-undo on commit).
|
||||||
|
layer_opacity_drag_start: Option<f64>,
|
||||||
|
layer_volume_drag_start: Option<f64>,
|
||||||
/// Resize mode for the active raster layer's "to document size" action (scale vs canvas).
|
/// Resize mode for the active raster layer's "to document size" action (scale vs canvas).
|
||||||
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode,
|
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode,
|
||||||
/// Font families already registered into egui for the family-picker previews
|
/// Font families already registered into egui for the family-picker previews
|
||||||
|
|
@ -69,6 +74,9 @@ impl InfopanelPane {
|
||||||
selected_shape_gradient_stop: None,
|
selected_shape_gradient_stop: None,
|
||||||
selected_tool_gradient_stop: None,
|
selected_tool_gradient_stop: None,
|
||||||
fps_drag_start: None,
|
fps_drag_start: None,
|
||||||
|
layer_name_edit: None,
|
||||||
|
layer_opacity_drag_start: None,
|
||||||
|
layer_volume_drag_start: None,
|
||||||
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode::Scale,
|
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode::Scale,
|
||||||
registered_preview_fonts: std::collections::HashSet::new(),
|
registered_preview_fonts: std::collections::HashSet::new(),
|
||||||
}
|
}
|
||||||
|
|
@ -1165,8 +1173,9 @@ impl InfopanelPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render layer info section
|
/// Render layer info section
|
||||||
fn render_layer_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, layer_ids: &[Uuid]) {
|
fn render_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_ids: &[Uuid]) {
|
||||||
let document = shared.action_executor.document();
|
use lightningbeam_core::actions::{set_layer_properties::LayerProperty, SetLayerPropertiesAction};
|
||||||
|
use lightningbeam_core::layer::AudioLayerType;
|
||||||
|
|
||||||
egui::CollapsingHeader::new("Layer")
|
egui::CollapsingHeader::new("Layer")
|
||||||
.id_salt(("layer_info", path))
|
.id_salt(("layer_info", path))
|
||||||
|
|
@ -1174,18 +1183,22 @@ impl InfopanelPane {
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
if layer_ids.len() == 1 {
|
if layer_ids.len() != 1 {
|
||||||
if let Some(layer) = document.get_layer(&layer_ids[0]) {
|
ui.label(format!("{} layers selected", layer_ids.len()));
|
||||||
ui.horizontal(|ui| {
|
ui.add_space(4.0);
|
||||||
ui.label("Name:");
|
return;
|
||||||
ui.label(layer.name());
|
}
|
||||||
});
|
let layer_id = layer_ids[0];
|
||||||
|
|
||||||
|
// Snapshot the current values, then drop the document borrow before mutating shared.
|
||||||
|
let Some((type_name, is_audio, name, opacity, volume, muted, soloed, locked)) = ({
|
||||||
|
let document = shared.action_executor.document();
|
||||||
|
document.get_layer(&layer_id).map(|layer| {
|
||||||
let type_name = match layer {
|
let type_name = match layer {
|
||||||
AnyLayer::Vector(_) => "Vector",
|
AnyLayer::Vector(_) => "Vector",
|
||||||
AnyLayer::Audio(a) => match a.audio_layer_type {
|
AnyLayer::Audio(a) => match a.audio_layer_type {
|
||||||
lightningbeam_core::layer::AudioLayerType::Midi => "MIDI",
|
AudioLayerType::Midi => "MIDI",
|
||||||
lightningbeam_core::layer::AudioLayerType::Sampled => "Audio",
|
AudioLayerType::Sampled => "Audio",
|
||||||
},
|
},
|
||||||
AnyLayer::Video(_) => "Video",
|
AnyLayer::Video(_) => "Video",
|
||||||
AnyLayer::Effect(_) => "Effect",
|
AnyLayer::Effect(_) => "Effect",
|
||||||
|
|
@ -1193,34 +1206,130 @@ impl InfopanelPane {
|
||||||
AnyLayer::Raster(_) => "Raster",
|
AnyLayer::Raster(_) => "Raster",
|
||||||
AnyLayer::Text(_) => "Text",
|
AnyLayer::Text(_) => "Text",
|
||||||
};
|
};
|
||||||
ui.horizontal(|ui| {
|
(
|
||||||
ui.label("Type:");
|
type_name,
|
||||||
ui.label(type_name);
|
matches!(layer, AnyLayer::Audio(_)),
|
||||||
});
|
layer.name().to_string(),
|
||||||
|
layer.opacity(),
|
||||||
|
layer.volume(),
|
||||||
|
layer.muted(),
|
||||||
|
layer.soloed(),
|
||||||
|
layer.locked(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
// Name (editable; commits on Enter / focus loss).
|
||||||
ui.label("Opacity:");
|
if self.layer_name_edit.as_ref().map(|(id, _)| *id) != Some(layer_id) {
|
||||||
ui.label(format!("{:.0}%", layer.opacity() * 100.0));
|
self.layer_name_edit = Some((layer_id, name.clone()));
|
||||||
});
|
}
|
||||||
|
ui.horizontal(|ui| {
|
||||||
if matches!(layer, AnyLayer::Audio(_)) {
|
ui.label("Name:");
|
||||||
ui.horizontal(|ui| {
|
let buf = &mut self.layer_name_edit.as_mut().unwrap().1;
|
||||||
ui.label("Volume:");
|
let resp = ui.text_edit_singleline(buf);
|
||||||
ui.label(format!("{:.0}%", layer.volume() * 100.0));
|
let commit = resp.lost_focus()
|
||||||
});
|
&& (ui.input(|i| i.key_pressed(egui::Key::Enter)) || !resp.has_focus());
|
||||||
}
|
if commit {
|
||||||
|
let new_name = buf.trim().to_string();
|
||||||
if layer.muted() {
|
if !new_name.is_empty() && new_name != name {
|
||||||
ui.label("Muted");
|
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||||||
}
|
layer_id,
|
||||||
if layer.locked() {
|
LayerProperty::Name(new_name),
|
||||||
ui.label("Locked");
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
ui.label(format!("{} layers selected", layer_ids.len()));
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Type:");
|
||||||
|
ui.label(type_name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opacity slider (single-undo: live-preview while dragging, commit on release).
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Opacity:");
|
||||||
|
let mut op = opacity;
|
||||||
|
let r = ui.add(egui::Slider::new(&mut op, 0.0..=1.0).show_value(false));
|
||||||
|
ui.label(format!("{:.0}%", op * 100.0));
|
||||||
|
if (r.drag_started() || r.gained_focus()) && self.layer_opacity_drag_start.is_none() {
|
||||||
|
self.layer_opacity_drag_start = Some(opacity);
|
||||||
|
}
|
||||||
|
if r.changed() {
|
||||||
|
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||||||
|
l.set_opacity(op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.drag_stopped() || r.lost_focus() {
|
||||||
|
if let Some(start) = self.layer_opacity_drag_start.take() {
|
||||||
|
if (start - op).abs() > 1e-6 {
|
||||||
|
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||||||
|
l.set_opacity(start); // revert so the action owns the change
|
||||||
|
}
|
||||||
|
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||||||
|
layer_id,
|
||||||
|
LayerProperty::Opacity(op),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_audio {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Volume:");
|
||||||
|
let mut vol = volume;
|
||||||
|
let r = ui.add(egui::Slider::new(&mut vol, 0.0..=1.0).show_value(false));
|
||||||
|
ui.label(format!("{:.0}%", vol * 100.0));
|
||||||
|
if (r.drag_started() || r.gained_focus()) && self.layer_volume_drag_start.is_none() {
|
||||||
|
self.layer_volume_drag_start = Some(volume);
|
||||||
|
}
|
||||||
|
if r.changed() {
|
||||||
|
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||||||
|
l.set_volume(vol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.drag_stopped() || r.lost_focus() {
|
||||||
|
if let Some(start) = self.layer_volume_drag_start.take() {
|
||||||
|
if (start - vol).abs() > 1e-6 {
|
||||||
|
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||||||
|
l.set_volume(start);
|
||||||
|
}
|
||||||
|
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||||||
|
layer_id,
|
||||||
|
LayerProperty::Volume(vol),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggles: mute / solo (audio) / lock.
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if is_audio {
|
||||||
|
if ui.selectable_label(muted, "Mute").clicked() {
|
||||||
|
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||||||
|
layer_id,
|
||||||
|
LayerProperty::Muted(!muted),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if ui.selectable_label(soloed, "Solo").clicked() {
|
||||||
|
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||||||
|
layer_id,
|
||||||
|
LayerProperty::Soloed(!soloed),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ui.selectable_label(locked, "Lock").clicked() {
|
||||||
|
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||||||
|
layer_id,
|
||||||
|
LayerProperty::Locked(!locked),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue