From 13840ee45f53df14ccc052ca8eb5e85d048b6b18 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 1 Mar 2026 10:22:46 -0500 Subject: [PATCH] add top-level selection --- .../lightningbeam-core/src/selection.rs | 43 ++ .../lightningbeam-editor/src/main.rs | 7 + .../src/panes/asset_library.rs | 1 + .../src/panes/infopanel.rs | 419 ++++++++++++++++-- .../lightningbeam-editor/src/panes/mod.rs | 2 + .../src/panes/node_graph/mod.rs | 11 + .../src/panes/piano_roll.rs | 15 + .../lightningbeam-editor/src/panes/stage.rs | 29 ++ .../src/panes/timeline.rs | 15 +- 9 files changed, 509 insertions(+), 33 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index 1701ca8..b481e20 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -8,6 +8,49 @@ use std::collections::HashSet; use uuid::Uuid; use vello::kurbo::{Affine, BezPath}; +/// Tracks the most recently selected thing(s) across the entire document. +/// +/// Lightweight overlay on top of per-domain selection state. Tells consumers +/// "the user's attention is on this kind of thing" — for properties panels, +/// delete/copy/paste dispatch, group commands, etc. +#[derive(Clone, Debug, Default)] +pub enum FocusSelection { + #[default] + None, + /// One or more layers selected (by UUID) + Layers(Vec), + /// One or more clip instances selected (by UUID) + ClipInstances(Vec), + /// DCEL geometry selected on a specific layer at a specific time + Geometry { layer_id: Uuid, time: f64 }, + /// MIDI notes selected in piano roll + Notes { layer_id: Uuid, midi_clip_id: u32, indices: Vec }, + /// Node graph nodes selected (backend node indices) + Nodes(Vec), + /// Assets selected in asset library (by UUID) + Assets(Vec), +} + +impl FocusSelection { + pub fn is_none(&self) -> bool { + matches!(self, FocusSelection::None) + } + + pub fn layer_ids(&self) -> Option<&[Uuid]> { + match self { + FocusSelection::Layers(ids) => Some(ids), + _ => Option::None, + } + } + + pub fn clip_instance_ids(&self) -> Option<&[Uuid]> { + match self { + FocusSelection::ClipInstances(ids) => Some(ids), + _ => Option::None, + } + } +} + /// Selection state for the editor /// /// Maintains sets of selected DCEL elements and clip instances. diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index d150212..deb6c7e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -739,6 +739,7 @@ struct EditorApp { action_executor: lightningbeam_core::action::ActionExecutor, // Action system for undo/redo active_layer_id: Option, // Currently active layer for editing selection: lightningbeam_core::selection::Selection, // Current selection state + focus: lightningbeam_core::selection::FocusSelection, // Document-level focus tracking editing_context: EditingContext, // Which clip (or root) we're editing tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state // Draw tool configuration @@ -1006,6 +1007,7 @@ impl EditorApp { action_executor, active_layer_id: Some(layer_id), selection: lightningbeam_core::selection::Selection::new(), + focus: lightningbeam_core::selection::FocusSelection::None, editing_context: EditingContext::default(), tool_state: lightningbeam_core::tool::ToolState::default(), draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves @@ -1385,6 +1387,7 @@ impl EditorApp { // Reset selection and set active layer to the newly created one self.selection = lightningbeam_core::selection::Selection::new(); + self.focus = lightningbeam_core::selection::FocusSelection::None; self.active_layer_id = Some(layer_id); // For Music focus, sync the MIDI layer with daw-backend @@ -4986,6 +4989,7 @@ impl eframe::App for EditorApp { theme: &self.theme, action_executor: &mut self.action_executor, selection: &mut self.selection, + focus: &mut self.focus, editing_clip_id: self.editing_context.current_clip_id(), editing_instance_id: self.editing_context.current_instance_id(), editing_parent_layer_id: self.editing_context.current_parent_layer_id(), @@ -5500,6 +5504,7 @@ struct RenderContext<'a> { theme: &'a Theme, action_executor: &'a mut lightningbeam_core::action::ActionExecutor, selection: &'a mut lightningbeam_core::selection::Selection, + focus: &'a mut lightningbeam_core::selection::FocusSelection, editing_clip_id: Option, editing_instance_id: Option, editing_parent_layer_id: Option, @@ -6028,6 +6033,7 @@ fn render_pane( pending_handlers: ctx.pending_handlers, action_executor: ctx.action_executor, selection: ctx.selection, + focus: ctx.focus, active_layer_id: ctx.active_layer_id, tool_state: ctx.tool_state, pending_actions: ctx.pending_actions, @@ -6118,6 +6124,7 @@ fn render_pane( pending_handlers: ctx.pending_handlers, action_executor: ctx.action_executor, selection: ctx.selection, + focus: ctx.focus, active_layer_id: ctx.active_layer_id, tool_state: ctx.tool_state, pending_actions: ctx.pending_actions, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index b33fedb..32b47b4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -1858,6 +1858,7 @@ impl AssetLibraryPane { // Handle interactions if response.clicked() { self.selected_asset = Some(asset.id); + *shared.focus = lightningbeam_core::selection::FocusSelection::Assets(vec![asset.id]); } if response.secondary_clicked() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index fa92383..f280918 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -1,14 +1,19 @@ /// Info Panel pane - displays and edits properties of selected objects /// -/// Shows context-sensitive property editors based on current selection: +/// Shows context-sensitive property editors based on current focus: /// - Tool options (when a tool is active) -/// - Transform properties (when shapes are selected) -/// - Shape properties (fill/stroke for selected shapes) -/// - Document settings (when nothing is selected) +/// - Layer properties (when layers are focused) +/// - Clip instance properties (when clip instances are focused) +/// - Shape properties (fill/stroke for selected geometry) +/// - Note info (when piano roll notes are focused) +/// - Node info (when node graph nodes are focused) +/// - Asset info (when asset library items are focused) +/// - Document settings (when nothing is focused) use eframe::egui::{self, DragValue, Ui}; use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction}; -use lightningbeam_core::layer::AnyLayer; +use lightningbeam_core::layer::{AnyLayer, LayerTrait}; +use lightningbeam_core::selection::FocusSelection; use lightningbeam_core::shape::ShapeColor; use lightningbeam_core::tool::{SimplifyMode, Tool}; use super::{NodePath, PaneRenderer, SharedPaneState}; @@ -31,7 +36,7 @@ impl InfopanelPane { } } -/// Aggregated info about the current selection +/// Aggregated info about the current DCEL selection struct SelectionInfo { /// True if nothing is selected is_empty: bool, @@ -60,7 +65,7 @@ impl Default for SelectionInfo { } impl InfopanelPane { - /// Gather info about the current selection + /// Gather info about the current DCEL selection fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo { let mut info = SelectionInfo::default(); @@ -406,7 +411,7 @@ impl InfopanelPane { }); } - /// Render document settings section (shown when nothing is selected) + /// Render document settings section (shown when nothing is focused) fn render_document_section(&self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState) { egui::CollapsingHeader::new("Document") .id_salt(("document", path)) @@ -503,6 +508,328 @@ impl InfopanelPane { ui.add_space(4.0); }); } + + /// 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(); + + egui::CollapsingHeader::new("Layer") + .id_salt(("layer_info", path)) + .default_open(true) + .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()); + }); + + 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", + }, + AnyLayer::Video(_) => "Video", + AnyLayer::Effect(_) => "Effect", + AnyLayer::Group(_) => "Group", + }; + ui.horizontal(|ui| { + ui.label("Type:"); + ui.label(type_name); + }); + + 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"); + } + } + } else { + ui.label(format!("{} layers selected", layer_ids.len())); + } + + ui.add_space(4.0); + }); + } + + /// Render clip instance info section + fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) { + let document = shared.action_executor.document(); + + egui::CollapsingHeader::new("Clip Instance") + .id_salt(("clip_instance_info", path)) + .default_open(true) + .show(ui, |ui| { + ui.add_space(4.0); + + if clip_ids.len() == 1 { + // Find the clip instance across all layers + let ci_id = clip_ids[0]; + let mut found = false; + + for layer in document.all_layers() { + let instances: &[lightningbeam_core::clip::ClipInstance] = match layer { + AnyLayer::Vector(l) => &l.clip_instances, + AnyLayer::Audio(l) => &l.clip_instances, + AnyLayer::Video(l) => &l.clip_instances, + AnyLayer::Effect(l) => &l.clip_instances, + AnyLayer::Group(_) => &[], + }; + if let Some(ci) = instances.iter().find(|c| c.id == ci_id) { + found = true; + + if let Some(name) = &ci.name { + ui.horizontal(|ui| { + ui.label("Name:"); + ui.label(name.as_str()); + }); + } + + // Show clip name based on type + let clip_name = document.get_vector_clip(&ci.clip_id).map(|c| c.name.as_str()) + .or_else(|| document.get_video_clip(&ci.clip_id).map(|c| c.name.as_str())) + .or_else(|| document.get_audio_clip(&ci.clip_id).map(|c| c.name.as_str())); + if let Some(name) = clip_name { + ui.horizontal(|ui| { + ui.label("Clip:"); + ui.label(name); + }); + } + + ui.horizontal(|ui| { + ui.label("Start:"); + ui.label(format!("{:.2}s", ci.effective_start())); + }); + + let clip_dur = document.get_clip_duration(&ci.clip_id) + .unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start); + let total_dur = ci.total_duration(clip_dur); + ui.horizontal(|ui| { + ui.label("Duration:"); + ui.label(format!("{:.2}s", total_dur)); + }); + + if ci.trim_start > 0.0 { + ui.horizontal(|ui| { + ui.label("Trim Start:"); + ui.label(format!("{:.2}s", ci.trim_start)); + }); + } + + if ci.playback_speed != 1.0 { + ui.horizontal(|ui| { + ui.label("Speed:"); + ui.label(format!("{:.2}x", ci.playback_speed)); + }); + } + + break; + } + } + + if !found { + ui.label("Clip instance not found"); + } + } else { + ui.label(format!("{} clip instances selected", clip_ids.len())); + } + + ui.add_space(4.0); + }); + } + + /// Render MIDI note info section + fn render_notes_section( + &self, + ui: &mut Ui, + path: &NodePath, + shared: &SharedPaneState, + layer_id: Uuid, + midi_clip_id: u32, + indices: &[usize], + ) { + egui::CollapsingHeader::new("Notes") + .id_salt(("notes_info", path)) + .default_open(true) + .show(ui, |ui| { + ui.add_space(4.0); + + // Show layer name + let document = shared.action_executor.document(); + if let Some(layer) = document.get_layer(&layer_id) { + ui.horizontal(|ui| { + ui.label("Layer:"); + ui.label(layer.name()); + }); + } + + if indices.len() == 1 { + // Single note — show details if we can resolve from the event cache + if let Some(events) = shared.midi_event_cache.get(&midi_clip_id) { + // Events are (time, note, velocity, is_on) — resolve to notes + let mut notes: Vec<(f64, u8, u8, f64)> = Vec::new(); // (time, note, vel, dur) + let mut pending: std::collections::HashMap = std::collections::HashMap::new(); + for &(time, note, vel, is_on) in events { + if is_on { + pending.insert(note, (time, vel)); + } else if let Some((start, v)) = pending.remove(¬e) { + notes.push((start, note, v, time - start)); + } + } + notes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + + let idx = indices[0]; + if idx < notes.len() { + let (time, note, vel, dur) = notes[idx]; + let note_name = midi_note_name(note); + ui.horizontal(|ui| { + ui.label("Note:"); + ui.label(format!("{} ({})", note_name, note)); + }); + ui.horizontal(|ui| { + ui.label("Time:"); + ui.label(format!("{:.3}s", time)); + }); + ui.horizontal(|ui| { + ui.label("Duration:"); + ui.label(format!("{:.3}s", dur)); + }); + ui.horizontal(|ui| { + ui.label("Velocity:"); + ui.label(format!("{}", vel)); + }); + } + } + } else { + ui.label(format!("{} notes selected", indices.len())); + } + + ui.add_space(4.0); + }); + } + + /// Render node graph info section + fn render_nodes_section(&self, ui: &mut Ui, path: &NodePath, node_indices: &[u32]) { + egui::CollapsingHeader::new("Nodes") + .id_salt(("nodes_info", path)) + .default_open(true) + .show(ui, |ui| { + ui.add_space(4.0); + + ui.label(format!( + "{} node{} selected", + node_indices.len(), + if node_indices.len() == 1 { "" } else { "s" } + )); + + ui.add_space(4.0); + }); + } + + /// Render asset info section + fn render_asset_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, asset_ids: &[Uuid]) { + let document = shared.action_executor.document(); + + egui::CollapsingHeader::new("Asset") + .id_salt(("asset_info", path)) + .default_open(true) + .show(ui, |ui| { + ui.add_space(4.0); + + if asset_ids.len() == 1 { + let id = asset_ids[0]; + + if let Some(clip) = document.get_vector_clip(&id) { + ui.horizontal(|ui| { + ui.label("Name:"); + ui.label(&clip.name); + }); + ui.horizontal(|ui| { + ui.label("Type:"); + ui.label("Vector"); + }); + ui.horizontal(|ui| { + ui.label("Size:"); + ui.label(format!("{:.0} x {:.0}", clip.width, clip.height)); + }); + ui.horizontal(|ui| { + ui.label("Duration:"); + ui.label(format!("{:.2}s", clip.duration)); + }); + } else if let Some(clip) = document.get_video_clip(&id) { + ui.horizontal(|ui| { + ui.label("Name:"); + ui.label(&clip.name); + }); + ui.horizontal(|ui| { + ui.label("Type:"); + ui.label("Video"); + }); + ui.horizontal(|ui| { + ui.label("Size:"); + ui.label(format!("{:.0} x {:.0}", clip.width, clip.height)); + }); + ui.horizontal(|ui| { + ui.label("Duration:"); + ui.label(format!("{:.2}s", clip.duration)); + }); + ui.horizontal(|ui| { + ui.label("Frame Rate:"); + ui.label(format!("{:.1} fps", clip.frame_rate)); + }); + } else if let Some(clip) = document.get_audio_clip(&id) { + ui.horizontal(|ui| { + ui.label("Name:"); + ui.label(&clip.name); + }); + let type_name = match &clip.clip_type { + lightningbeam_core::clip::AudioClipType::Sampled { .. } => "Audio (Sampled)", + lightningbeam_core::clip::AudioClipType::Midi { .. } => "Audio (MIDI)", + lightningbeam_core::clip::AudioClipType::Recording => "Audio (Recording)", + }; + ui.horizontal(|ui| { + ui.label("Type:"); + ui.label(type_name); + }); + ui.horizontal(|ui| { + ui.label("Duration:"); + ui.label(format!("{:.2}s", clip.duration)); + }); + } else { + // Could be an image asset or effect — show ID + ui.label(format!("Asset {}", id)); + } + } else { + ui.label(format!("{} assets selected", asset_ids.len())); + } + + ui.add_space(4.0); + }); + } +} + +/// Convert MIDI note number to note name (e.g. 60 -> "C4") +fn midi_note_name(note: u8) -> String { + const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; + let octave = (note as i32 / 12) - 1; + let name = NAMES[note as usize % 12]; + format!("{}{}", name, octave) } impl PaneRenderer for InfopanelPane { @@ -536,29 +863,59 @@ impl PaneRenderer for InfopanelPane { // 1. Tool options section (always shown if tool has options) self.render_tool_section(ui, path, shared); - // 2. Gather selection info - let info = self.gather_selection_info(shared); - - // 3. Shape properties section (if DCEL elements selected) - if info.dcel_count > 0 { - self.render_shape_section(ui, path, shared, &info); - } - - // 5. Document settings (if nothing selected) - if info.is_empty { - self.render_document_section(ui, path, shared); - } - - // Show selection count at bottom - if info.dcel_count > 0 { - ui.add_space(8.0); - ui.separator(); - ui.add_space(4.0); - ui.label(format!( - "{} object{} selected", - info.dcel_count, - if info.dcel_count == 1 { "" } else { "s" } - )); + // 2. Focus-driven content + // Clone focus to avoid borrow issues with shared + let focus = shared.focus.clone(); + match &focus { + FocusSelection::Layers(ids) => { + self.render_layer_section(ui, path, shared, ids); + } + FocusSelection::ClipInstances(ids) => { + self.render_clip_instance_section(ui, path, shared, ids); + } + FocusSelection::Geometry { .. } => { + let info = self.gather_selection_info(shared); + if info.dcel_count > 0 { + self.render_shape_section(ui, path, shared, &info); + } + // Selection count + if info.dcel_count > 0 { + ui.add_space(8.0); + ui.separator(); + ui.add_space(4.0); + ui.label(format!( + "{} object{} selected", + info.dcel_count, + if info.dcel_count == 1 { "" } else { "s" } + )); + } + } + FocusSelection::Notes { layer_id, midi_clip_id, indices } => { + self.render_notes_section(ui, path, shared, *layer_id, *midi_clip_id, indices); + } + FocusSelection::Nodes(indices) => { + self.render_nodes_section(ui, path, indices); + } + FocusSelection::Assets(ids) => { + self.render_asset_section(ui, path, shared, ids); + } + FocusSelection::None => { + // Fallback: check if there's a DCEL selection even without focus + let info = self.gather_selection_info(shared); + if info.dcel_count > 0 { + self.render_shape_section(ui, path, shared, &info); + ui.add_space(8.0); + ui.separator(); + ui.add_space(4.0); + ui.label(format!( + "{} object{} selected", + info.dcel_count, + if info.dcel_count == 1 { "" } else { "s" } + )); + } else { + self.render_document_section(ui, path, shared); + } + } } }); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index db190d5..415f04e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -163,6 +163,8 @@ pub struct SharedPaneState<'a> { pub action_executor: &'a mut lightningbeam_core::action::ActionExecutor, /// Current selection state (mutable for tools to modify) pub selection: &'a mut lightningbeam_core::selection::Selection, + /// Document-level focus: tracks the most recently selected thing(s) of any type + pub focus: &'a mut lightningbeam_core::selection::FocusSelection, /// Which VectorClip is being edited (None = document root) pub editing_clip_id: Option, /// The clip instance ID being edited diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index a1f4f55..9afebc5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -2580,6 +2580,17 @@ impl crate::panes::PaneRenderer for NodeGraphPane { self.last_node_rects = graph_response.node_rects.clone(); self.handle_graph_response(graph_response, shared, graph_rect); + // Sync document-level focus with node graph selection + if !self.state.selected_nodes.is_empty() { + let node_indices: Vec = self.state.selected_nodes.iter() + .filter_map(|nid| self.node_id_map.get(nid)) + .map(|bid| bid.index()) + .collect(); + if !node_indices.is_empty() { + *shared.focus = lightningbeam_core::selection::FocusSelection::Nodes(node_indices); + } + } + // Handle pending sampler load requests from bottom_ui() if let Some(load) = self.user_state.pending_sampler_load.take() { self.handle_pending_sampler_load(load, shared); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index c6af4da..fb563db 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -864,6 +864,7 @@ impl PianoRollPane { self.selected_note_indices.clear(); } self.selected_note_indices.insert(note_idx); + self.update_focus(shared); self.drag_mode = Some(DragMode::MoveNotes { start_time_offset: 0.0, @@ -915,6 +916,7 @@ impl PianoRollPane { } else { // Start selection rectangle self.selected_note_indices.clear(); + self.update_focus(shared); self.selection_rect = Some((pos, pos)); self.drag_mode = Some(DragMode::SelectRect); } @@ -1007,6 +1009,7 @@ impl PianoRollPane { } Some(DragMode::SelectRect) => { self.selection_rect = None; + self.update_focus(shared); } None => {} } @@ -1086,6 +1089,18 @@ impl PianoRollPane { None } + fn update_focus(&self, shared: &mut SharedPaneState) { + if self.selected_note_indices.is_empty() { + *shared.focus = lightningbeam_core::selection::FocusSelection::None; + } else if let (Some(layer_id), Some(midi_clip_id)) = (*shared.active_layer_id, self.selected_clip_id) { + *shared.focus = lightningbeam_core::selection::FocusSelection::Notes { + layer_id, + midi_clip_id, + indices: self.selected_note_indices.iter().copied().collect(), + }; + } + } + fn update_selection_from_rect( &mut self, grid_rect: Rect, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index cf85014..dca5bd3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -2529,6 +2529,9 @@ impl StagePane { shared.selection.select_edge(edge_id, dcel); } } + if let Some(layer_id) = *shared.active_layer_id { + *shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time }; + } // DCEL element dragging deferred to Phase 3 } hit_test::HitResult::Face(face_id) => { @@ -2541,6 +2544,9 @@ impl StagePane { shared.selection.select_face(face_id, dcel); } } + if let Some(layer_id) = *shared.active_layer_id { + *shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time }; + } // DCEL element dragging deferred to Phase 3 } hit_test::HitResult::ClipInstance(clip_id) => { @@ -2554,6 +2560,7 @@ impl StagePane { shared.selection.select_only_clip_instance(clip_id); } } + *shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec()); // If clip instance is now selected, prepare for dragging if shared.selection.contains_clip_instance(&clip_id) { @@ -2582,6 +2589,7 @@ impl StagePane { // Nothing hit - start marquee selection if !shift_held { shared.selection.clear(); + *shared.focus = lightningbeam_core::selection::FocusSelection::None; } *shared.tool_state = ToolState::MarqueeSelecting { @@ -2644,6 +2652,9 @@ impl StagePane { } } } + if let Some(layer_id) = *shared.active_layer_id { + *shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time }; + } *shared.tool_state = ToolState::Idle; } ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. } => { @@ -2757,6 +2768,15 @@ impl StagePane { } } + // Update focus based on what was selected + if shared.selection.has_dcel_selection() { + if let Some(layer_id) = *shared.active_layer_id { + *shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time }; + } + } else if !shared.selection.clip_instances().is_empty() { + *shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec()); + } + // Reset tool state *shared.tool_state = ToolState::Idle; } @@ -6041,6 +6061,15 @@ impl StagePane { } } + // Update focus based on what was selected + if shared.selection.has_dcel_selection() { + if let Some(layer_id) = *shared.active_layer_id { + *shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time }; + } + } else if !shared.selection.clip_instances().is_empty() { + *shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec()); + } + *shared.tool_state = ToolState::Idle; } _ => {} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index b49fca9..2308346 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -2724,6 +2724,7 @@ impl TimelinePane { document: &lightningbeam_core::document::Document, active_layer_id: &mut Option, selection: &mut lightningbeam_core::selection::Selection, + focus: &mut lightningbeam_core::selection::FocusSelection, pending_actions: &mut Vec>, playback_time: &mut f64, _is_playing: &mut bool, @@ -2791,6 +2792,7 @@ impl TimelinePane { } } *active_layer_id = Some(click_row.layer_id()); + *focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec()); clicked_clip_instance = true; } } @@ -2838,6 +2840,7 @@ impl TimelinePane { } // Also set this layer as the active layer *active_layer_id = Some(layer.id()); + *focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec()); clicked_clip_instance = true; break; } @@ -2864,7 +2867,9 @@ impl TimelinePane { // Get the layer at this index (using virtual rows for group support) let header_rows = build_timeline_rows(context_layers); if clicked_layer_index < header_rows.len() { - *active_layer_id = Some(header_rows[clicked_layer_index].layer_id()); + let layer_id = header_rows[clicked_layer_index].layer_id(); + *active_layer_id = Some(layer_id); + *focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]); } } } @@ -2895,6 +2900,7 @@ impl TimelinePane { } else { selection.select_only_clip_instance(clip_id); } + *focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec()); } // Start dragging with the detected drag type @@ -2915,6 +2921,7 @@ impl TimelinePane { for id in &child_ids { selection.add_clip_instance(*id); } + *focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec()); self.clip_drag_state = Some(ClipDragType::Move); self.drag_offset = 0.0; } @@ -3258,11 +3265,13 @@ impl TimelinePane { // Get the layer at this index (using virtual rows for group support) let empty_click_rows = build_timeline_rows(context_layers); if clicked_layer_index < empty_click_rows.len() { - *active_layer_id = Some(empty_click_rows[clicked_layer_index].layer_id()); + let layer_id = empty_click_rows[clicked_layer_index].layer_id(); + *active_layer_id = Some(layer_id); // Clear clip instance selection when clicking on empty layer area if !shift_held { selection.clear_clip_instances(); } + *focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]); } } } @@ -3734,6 +3743,7 @@ impl PaneRenderer for TimelinePane { document, shared.active_layer_id, shared.selection, + shared.focus, shared.pending_actions, shared.playback_time, shared.is_playing, @@ -3753,6 +3763,7 @@ impl PaneRenderer for TimelinePane { if !shared.selection.contains_clip_instance(&clip_id) { shared.selection.select_only_clip_instance(clip_id); } + *shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec()); self.context_menu_clip = Some((Some(clip_id), pos)); } else { // Right-clicked on empty timeline space