From 87263489c0fbee2a5e40ee93d76779fd1fa76194 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 30 Jun 2026 22:16:14 -0400 Subject: [PATCH] Add mobile selection inspector sheet (P3); editable layer properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/mobile/inspector.rs | 179 ++++++++++++++++++ .../lightningbeam-editor/src/mobile/mod.rs | 19 +- .../src/panes/infopanel.rs | 175 +++++++++++++---- 3 files changed, 338 insertions(+), 35 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs new file mode 100644 index 0000000..7145a91 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs @@ -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); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs index 2403af8..9d90bd4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -18,6 +18,7 @@ use crate::panes::NodePath; use crate::RenderContext; pub mod icons; +mod inspector; pub mod intent; mod stack; mod surface; @@ -145,6 +146,8 @@ pub struct MobileState { pub weights: [f32; 3], /// Node/Instrument band: false = node editor, true = instrument/preset browser. pub show_instruments: bool, + /// Inspector sheet height as a fraction of the region above the transport. + pub inspector_frac: f32, /// Active handle drag (transient). pub drag: Option, /// In-flight layout ease (transient). @@ -159,6 +162,7 @@ impl Default for MobileState { window_count: 2, weights: [1.0, 1.0, 1.0], show_instruments: false, + inspector_frac: 0.45, drag: None, anim: None, } @@ -180,12 +184,23 @@ pub fn render_mobile_shell( egui::pos2(available_rect.left(), available_rect.bottom() - TRANSPORT_H), 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, 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::render(ui, transport_rect, &mut rc.shared); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 0391dcd..f776dcf 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -44,6 +44,11 @@ pub struct InfopanelPane { selected_tool_gradient_stop: Option, /// FPS value captured when a drag/focus-in starts (for single-undo-action on commit) fps_drag_start: Option, + /// 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, + layer_volume_drag_start: Option, /// Resize mode for the active raster layer's "to document size" action (scale vs canvas). raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode, /// Font families already registered into egui for the family-picker previews @@ -69,6 +74,9 @@ impl InfopanelPane { selected_shape_gradient_stop: None, selected_tool_gradient_stop: 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, registered_preview_fonts: std::collections::HashSet::new(), } @@ -1165,8 +1173,9 @@ impl InfopanelPane { } /// Render layer info section - fn render_layer_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, layer_ids: &[Uuid]) { - let document = shared.action_executor.document(); + fn render_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_ids: &[Uuid]) { + use lightningbeam_core::actions::{set_layer_properties::LayerProperty, SetLayerPropertiesAction}; + use lightningbeam_core::layer::AudioLayerType; egui::CollapsingHeader::new("Layer") .id_salt(("layer_info", path)) @@ -1174,18 +1183,22 @@ impl InfopanelPane { .show(ui, |ui| { ui.add_space(4.0); - if layer_ids.len() == 1 { - if let Some(layer) = document.get_layer(&layer_ids[0]) { - ui.horizontal(|ui| { - ui.label("Name:"); - ui.label(layer.name()); - }); + if layer_ids.len() != 1 { + ui.label(format!("{} layers selected", layer_ids.len())); + ui.add_space(4.0); + return; + } + 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 { AnyLayer::Vector(_) => "Vector", AnyLayer::Audio(a) => match a.audio_layer_type { - lightningbeam_core::layer::AudioLayerType::Midi => "MIDI", - lightningbeam_core::layer::AudioLayerType::Sampled => "Audio", + AudioLayerType::Midi => "MIDI", + AudioLayerType::Sampled => "Audio", }, AnyLayer::Video(_) => "Video", AnyLayer::Effect(_) => "Effect", @@ -1193,34 +1206,130 @@ impl InfopanelPane { AnyLayer::Raster(_) => "Raster", AnyLayer::Text(_) => "Text", }; - ui.horizontal(|ui| { - ui.label("Type:"); - ui.label(type_name); - }); + ( + 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| { - ui.label("Opacity:"); - ui.label(format!("{:.0}%", layer.opacity() * 100.0)); - }); - - if matches!(layer, AnyLayer::Audio(_)) { - ui.horizontal(|ui| { - ui.label("Volume:"); - ui.label(format!("{:.0}%", layer.volume() * 100.0)); - }); - } - - if layer.muted() { - ui.label("Muted"); - } - if layer.locked() { - ui.label("Locked"); + // Name (editable; commits on Enter / focus loss). + if self.layer_name_edit.as_ref().map(|(id, _)| *id) != Some(layer_id) { + self.layer_name_edit = Some((layer_id, name.clone())); + } + ui.horizontal(|ui| { + ui.label("Name:"); + let buf = &mut self.layer_name_edit.as_mut().unwrap().1; + let resp = ui.text_edit_singleline(buf); + 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 !new_name.is_empty() && new_name != name { + shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new( + layer_id, + LayerProperty::Name(new_name), + ))); } } - } 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); }); }