From 38831948ac12ee834ea718c1b794ad9b57c91b53 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 1 Mar 2026 11:01:51 -0500 Subject: [PATCH] group layers manually --- .../src/actions/group_layers.rs | 121 +++++ .../lightningbeam-core/src/actions/mod.rs | 2 + .../lightningbeam-editor/src/main.rs | 468 ++++++------------ .../lightningbeam-editor/src/panes/mod.rs | 4 + .../src/panes/node_graph/mod.rs | 34 +- .../src/panes/timeline.rs | 84 +++- 6 files changed, 363 insertions(+), 350 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/group_layers.rs diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/group_layers.rs b/lightningbeam-ui/lightningbeam-core/src/actions/group_layers.rs new file mode 100644 index 0000000..c960ed4 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/group_layers.rs @@ -0,0 +1,121 @@ +//! Group layers action +//! +//! Creates a new GroupLayer containing the selected sibling layers. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::{AnyLayer, GroupLayer}; +use uuid::Uuid; + +/// Action that groups sibling layers into a new GroupLayer. +/// +/// All layers must share the same parent (root or a specific GroupLayer). +pub struct GroupLayersAction { + /// IDs of layers to group + layer_ids: Vec, + /// Parent group ID (None = document root) + parent_group_id: Option, + /// Pre-generated UUID for the new GroupLayer + group_id: Uuid, + /// Rollback: index where the group was inserted + insert_index: Option, + /// Rollback: (original_index, layer) pairs, sorted by index ascending + removed_layers: Vec<(usize, AnyLayer)>, +} + +impl GroupLayersAction { + pub fn new(layer_ids: Vec, parent_group_id: Option, group_id: Uuid) -> Self { + Self { + layer_ids, + parent_group_id, + group_id, + insert_index: None, + removed_layers: Vec::new(), + } + } +} + +/// Get a mutable reference to the children vec of the given parent. +fn get_parent_children<'a>( + document: &'a mut Document, + parent_group_id: Option, +) -> Result<&'a mut Vec, String> { + match parent_group_id { + None => Ok(&mut document.root.children), + Some(id) => { + let layer = document.root.get_child_mut(&id) + .ok_or_else(|| format!("Parent group {} not found", id))?; + match layer { + AnyLayer::Group(g) => Ok(&mut g.children), + _ => Err(format!("Layer {} is not a group", id)), + } + } + } +} + +impl Action for GroupLayersAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let children = get_parent_children(document, self.parent_group_id)?; + + // Find indices of all selected layers within the parent's children + let mut indices: Vec = Vec::new(); + for layer_id in &self.layer_ids { + if let Some(idx) = children.iter().position(|l| l.id() == *layer_id) { + indices.push(idx); + } else { + return Err(format!("Layer {} not found in parent", layer_id)); + } + } + indices.sort(); + + // Record the insert position (topmost selected layer) + let insert_index = indices[0]; + self.insert_index = Some(insert_index); + + // Remove layers back-to-front to preserve indices + self.removed_layers.clear(); + for &idx in indices.iter().rev() { + let layer = children.remove(idx); + self.removed_layers.push((idx, layer)); + } + // Reverse so removed_layers is sorted by index ascending + self.removed_layers.reverse(); + + // Build the new GroupLayer with children in their original order + let mut group = GroupLayer::new("Group"); + group.layer.id = self.group_id; + for (_, layer) in &self.removed_layers { + group.add_child(layer.clone()); + } + + // Insert the group at the topmost position + let children = get_parent_children(document, self.parent_group_id)?; + children.insert(insert_index, AnyLayer::Group(group)); + + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let Some(insert_index) = self.insert_index else { + return Err("Cannot rollback: action was not executed".to_string()); + }; + + // Remove the GroupLayer + let children = get_parent_children(document, self.parent_group_id)?; + children.remove(insert_index); + + // Re-insert original layers at their original indices (ascending order) + for (idx, layer) in &self.removed_layers { + let children = get_parent_children(document, self.parent_group_id)?; + children.insert(*idx, layer.clone()); + } + + self.insert_index = None; + + Ok(()) + } + + fn description(&self) -> String { + format!("Group {} layers", self.layer_ids.len()) + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index dd40da0..66cb4ff 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -30,6 +30,7 @@ pub mod group_shapes; pub mod convert_to_movie_clip; pub mod region_split; pub mod toggle_group_expansion; +pub mod group_layers; pub use add_clip_instance::AddClipInstanceAction; pub use add_effect::AddEffectAction; @@ -58,3 +59,4 @@ pub use group_shapes::GroupAction; pub use convert_to_movie_clip::ConvertToMovieClipAction; pub use region_split::RegionSplitAction; pub use toggle_group_expansion::ToggleGroupExpansionAction; +pub use group_layers::GroupLayersAction; diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index deb6c7e..7f7d37c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -11,7 +11,7 @@ use clap::Parser; use uuid::Uuid; mod panes; -use panes::{PaneInstance, PaneRenderer, SharedPaneState}; +use panes::{PaneInstance, PaneRenderer}; mod widgets; @@ -755,6 +755,10 @@ struct EditorApp { /// Count of in-flight graph preset loads — keeps the repaint loop alive /// until the audio thread sends GraphPresetLoaded events for all of them pending_graph_loads: std::sync::Arc, + /// Set by MenuAction::Group when focus is Nodes — consumed by node graph pane next frame + pending_node_group: bool, + /// Set by MenuAction::Ungroup when focus is Nodes — consumed by node graph pane next frame + pending_node_ungroup: bool, #[allow(dead_code)] // Stored for future export/recording configuration audio_sample_rate: u32, #[allow(dead_code)] @@ -1018,6 +1022,8 @@ impl EditorApp { audio_event_rx, audio_events_pending: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), pending_graph_loads: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)), + pending_node_group: false, + pending_node_ungroup: false, audio_sample_rate, audio_channels, video_manager: std::sync::Arc::new(std::sync::Mutex::new( @@ -2572,29 +2578,46 @@ impl EditorApp { // Modify menu MenuAction::Group => { - if let Some(layer_id) = self.active_layer_id { - if self.selection.has_dcel_selection() { - // TODO: DCEL group deferred to Phase 2 (extract subgraph) - } else { - let clip_ids: Vec = self.selection.clip_instances().to_vec(); - if clip_ids.len() >= 2 { - let instance_id = uuid::Uuid::new_v4(); - let action = lightningbeam_core::actions::GroupAction::new( - layer_id, - self.playback_time, - Vec::new(), - clip_ids, - instance_id, - ); - if let Err(e) = self.action_executor.execute(Box::new(action)) { - eprintln!("Failed to group: {}", e); - } else { - self.selection.clear(); - self.selection.add_clip_instance(instance_id); - } + match &self.focus { + lightningbeam_core::selection::FocusSelection::Layers(ids) if ids.len() >= 2 => { + let parent_group_id = find_parent_group_id(self.action_executor.document(), &ids[0]); + let group_id = uuid::Uuid::new_v4(); + let action = lightningbeam_core::actions::GroupLayersAction::new( + ids.clone(), parent_group_id, group_id, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to group layers: {}", e); + } else { + self.active_layer_id = Some(group_id); + self.focus = lightningbeam_core::selection::FocusSelection::Layers(vec![group_id]); + } + } + lightningbeam_core::selection::FocusSelection::Nodes(_) => { + self.pending_node_group = true; + } + _ => { + // Existing clip instance grouping fallback (stub) + if let Some(layer_id) = self.active_layer_id { + if self.selection.has_dcel_selection() { + // TODO: DCEL group deferred to Phase 2 + } else { + let clip_ids: Vec = self.selection.clip_instances().to_vec(); + if clip_ids.len() >= 2 { + let instance_id = uuid::Uuid::new_v4(); + let action = lightningbeam_core::actions::GroupAction::new( + layer_id, self.playback_time, Vec::new(), clip_ids, instance_id, + ); + if let Err(e) = self.action_executor.execute(Box::new(action)) { + eprintln!("Failed to group: {}", e); + } else { + self.selection.clear(); + self.selection.add_clip_instance(instance_id); + } + } + } + let _ = layer_id; } } - let _ = layer_id; } } MenuAction::ConvertToMovieClip => { @@ -4976,74 +4999,78 @@ impl eframe::App for EditorApp { // Create render context let mut ctx = RenderContext { - tool_icon_cache: &mut self.tool_icon_cache, - icon_cache: &mut self.icon_cache, - selected_tool: &mut self.selected_tool, - fill_color: &mut self.fill_color, - stroke_color: &mut self.stroke_color, - active_color_mode: &mut self.active_color_mode, + shared: panes::SharedPaneState { + tool_icon_cache: &mut self.tool_icon_cache, + icon_cache: &mut self.icon_cache, + selected_tool: &mut self.selected_tool, + fill_color: &mut self.fill_color, + stroke_color: &mut self.stroke_color, + active_color_mode: &mut self.active_color_mode, + pending_view_action: &mut self.pending_view_action, + fallback_pane_priority: &mut fallback_pane_priority, + pending_handlers: &mut pending_handlers, + 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(), + pending_enter_clip: &mut pending_enter_clip, + pending_exit_clip: &mut pending_exit_clip, + active_layer_id: &mut self.active_layer_id, + tool_state: &mut self.tool_state, + pending_actions: &mut pending_actions, + draw_simplify_mode: &mut self.draw_simplify_mode, + rdp_tolerance: &mut self.rdp_tolerance, + schneider_max_error: &mut self.schneider_max_error, + audio_controller: self.audio_controller.as_ref(), + video_manager: &self.video_manager, + playback_time: &mut self.playback_time, + is_playing: &mut self.is_playing, + is_recording: &mut self.is_recording, + recording_clips: &mut self.recording_clips, + recording_start_time: &mut self.recording_start_time, + recording_layer_id: &mut self.recording_layer_id, + dragging_asset: &mut self.dragging_asset, + stroke_width: &mut self.stroke_width, + fill_enabled: &mut self.fill_enabled, + snap_enabled: &mut self.snap_enabled, + paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance, + polygon_sides: &mut self.polygon_sides, + layer_to_track_map: &self.layer_to_track_map, + midi_event_cache: &mut self.midi_event_cache, + audio_pools_with_new_waveforms: &self.audio_pools_with_new_waveforms, + raw_audio_cache: &self.raw_audio_cache, + waveform_gpu_dirty: &mut self.waveform_gpu_dirty, + effect_to_load: &mut self.effect_to_load, + effect_thumbnail_requests: &mut effect_thumbnail_requests, + effect_thumbnail_cache: self.effect_thumbnail_generator.as_ref() + .map(|g| g.thumbnail_cache()) + .unwrap_or(&empty_thumbnail_cache), + effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate, + webcam_frame: self.webcam_frame.clone(), + webcam_record_command: &mut self.webcam_record_command, + target_format: self.target_format, + pending_menu_actions: &mut pending_menu_actions, + clipboard_manager: &mut self.clipboard_manager, + waveform_stereo: self.config.waveform_stereo, + project_generation: &mut self.project_generation, + script_to_edit: &mut self.script_to_edit, + script_saved: &mut self.script_saved, + region_selection: &mut self.region_selection, + region_select_mode: &mut self.region_select_mode, + pending_graph_loads: &self.pending_graph_loads, + clipboard_consumed: &mut clipboard_consumed, + keymap: &self.keymap, + pending_node_group: &mut self.pending_node_group, + pending_node_ungroup: &mut self.pending_node_ungroup, + #[cfg(debug_assertions)] + test_mode: &mut self.test_mode, + #[cfg(debug_assertions)] + synthetic_input: &mut synthetic_input_storage, + }, pane_instances: &mut self.pane_instances, - pending_view_action: &mut self.pending_view_action, - fallback_pane_priority: &mut fallback_pane_priority, - pending_handlers: &mut pending_handlers, - 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(), - pending_enter_clip: &mut pending_enter_clip, - pending_exit_clip: &mut pending_exit_clip, - active_layer_id: &mut self.active_layer_id, - tool_state: &mut self.tool_state, - pending_actions: &mut pending_actions, - draw_simplify_mode: &mut self.draw_simplify_mode, - rdp_tolerance: &mut self.rdp_tolerance, - schneider_max_error: &mut self.schneider_max_error, - audio_controller: self.audio_controller.as_ref(), - video_manager: &self.video_manager, - playback_time: &mut self.playback_time, - is_playing: &mut self.is_playing, - is_recording: &mut self.is_recording, - recording_clips: &mut self.recording_clips, - recording_start_time: &mut self.recording_start_time, - recording_layer_id: &mut self.recording_layer_id, - dragging_asset: &mut self.dragging_asset, - stroke_width: &mut self.stroke_width, - fill_enabled: &mut self.fill_enabled, - snap_enabled: &mut self.snap_enabled, - paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance, - polygon_sides: &mut self.polygon_sides, - layer_to_track_map: &self.layer_to_track_map, - midi_event_cache: &mut self.midi_event_cache, - audio_pools_with_new_waveforms: &self.audio_pools_with_new_waveforms, - raw_audio_cache: &self.raw_audio_cache, - waveform_gpu_dirty: &mut self.waveform_gpu_dirty, - effect_to_load: &mut self.effect_to_load, - effect_thumbnail_requests: &mut effect_thumbnail_requests, - effect_thumbnail_cache: self.effect_thumbnail_generator.as_ref() - .map(|g| g.thumbnail_cache()) - .unwrap_or(&empty_thumbnail_cache), - effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate, - webcam_frame: self.webcam_frame.clone(), - webcam_record_command: &mut self.webcam_record_command, - target_format: self.target_format, - pending_menu_actions: &mut pending_menu_actions, - clipboard_manager: &mut self.clipboard_manager, - waveform_stereo: self.config.waveform_stereo, - project_generation: &mut self.project_generation, - script_to_edit: &mut self.script_to_edit, - script_saved: &mut self.script_saved, - region_selection: &mut self.region_selection, - region_select_mode: &mut self.region_select_mode, - pending_graph_loads: &self.pending_graph_loads, - clipboard_consumed: &mut clipboard_consumed, - keymap: &self.keymap, - #[cfg(debug_assertions)] - test_mode: &mut self.test_mode, - #[cfg(debug_assertions)] - synthetic_input: &mut synthetic_input_storage, }; render_layout_node( @@ -5489,101 +5516,33 @@ impl eframe::App for EditorApp { } /// Context for rendering operations - bundles all mutable state needed during rendering -/// This avoids having 25+ individual parameters in rendering functions +/// Wraps SharedPaneState + pane_instances for layout rendering. +/// pane_instances is kept separate from SharedPaneState so we can borrow +/// a specific pane instance mutably while passing the rest as &mut SharedPaneState. struct RenderContext<'a> { - tool_icon_cache: &'a mut ToolIconCache, - icon_cache: &'a mut IconCache, - selected_tool: &'a mut Tool, - fill_color: &'a mut egui::Color32, - stroke_color: &'a mut egui::Color32, - active_color_mode: &'a mut panes::ColorMode, + shared: panes::SharedPaneState<'a>, pane_instances: &'a mut HashMap, - pending_view_action: &'a mut Option, - fallback_pane_priority: &'a mut Option, - pending_handlers: &'a mut Vec, - 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, - pending_enter_clip: &'a mut Option<(Uuid, Uuid, Uuid)>, - pending_exit_clip: &'a mut bool, - active_layer_id: &'a mut Option, - tool_state: &'a mut lightningbeam_core::tool::ToolState, - pending_actions: &'a mut Vec>, - draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode, - rdp_tolerance: &'a mut f64, - schneider_max_error: &'a mut f64, - audio_controller: Option<&'a std::sync::Arc>>, - video_manager: &'a std::sync::Arc>, - playback_time: &'a mut f64, - is_playing: &'a mut bool, - // Recording state - is_recording: &'a mut bool, - recording_clips: &'a mut HashMap, - recording_start_time: &'a mut f64, - recording_layer_id: &'a mut Option, - dragging_asset: &'a mut Option, - // Tool-specific options for infopanel - stroke_width: &'a mut f64, - fill_enabled: &'a mut bool, - snap_enabled: &'a mut bool, - paint_bucket_gap_tolerance: &'a mut f64, - polygon_sides: &'a mut u32, - /// Mapping from Document layer UUIDs to daw-backend TrackIds - layer_to_track_map: &'a std::collections::HashMap, - /// Cache of MIDI events for rendering (keyed by backend midi_clip_id) - midi_event_cache: &'a mut HashMap>, - /// Audio pool indices with new raw audio data this frame (for thumbnail invalidation) - audio_pools_with_new_waveforms: &'a HashSet, - /// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels)) - raw_audio_cache: &'a HashMap>, u32, u32)>, - /// Pool indices needing GPU texture upload - waveform_gpu_dirty: &'a mut HashSet, - /// Effect ID to load into shader editor (set by asset library, consumed by shader editor) - effect_to_load: &'a mut Option, - /// Queue for effect thumbnail requests - effect_thumbnail_requests: &'a mut Vec, - /// Cache of generated effect thumbnails - effect_thumbnail_cache: &'a HashMap>, - /// Effect IDs whose thumbnails should be invalidated - effect_thumbnails_to_invalidate: &'a mut Vec, - /// Latest webcam capture frame (None if no camera active) - webcam_frame: Option, - /// Pending webcam recording command - webcam_record_command: &'a mut Option, - /// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform) - target_format: wgpu::TextureFormat, - /// Menu actions queued by panes (e.g. context menus), processed after rendering - pending_menu_actions: &'a mut Vec, - /// Clipboard manager for paste availability checks - clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager, - /// Whether to show waveforms as stacked stereo - waveform_stereo: bool, - /// Project generation counter (incremented on load) - project_generation: &'a mut u64, - /// Script ID to open in the script editor (from node graph) - script_to_edit: &'a mut Option, - /// Script ID just saved (triggers auto-recompile of nodes using it) - script_saved: &'a mut Option, - /// Active region selection (temporary split state) - region_selection: &'a mut Option, - /// Region select mode (Rectangle or Lasso) - region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode, - /// Counter for in-flight graph preset loads (keeps repaint loop alive) - pending_graph_loads: &'a std::sync::Arc, - /// Set by panes when they handle Ctrl+C/X/V internally - clipboard_consumed: &'a mut bool, - /// Remappable keyboard shortcut manager - keymap: &'a KeymapManager, - /// Test mode state for event recording (debug builds only) - #[cfg(debug_assertions)] - test_mode: &'a mut test_mode::TestModeState, - /// Synthetic input from test mode replay (debug builds only) - #[cfg(debug_assertions)] - synthetic_input: &'a mut Option, +} + +/// Find which GroupLayer (if any) contains the given layer as a direct child. +/// Returns None if the layer is at document root level. +fn find_parent_group_id(doc: &lightningbeam_core::document::Document, layer_id: &uuid::Uuid) -> Option { + fn search_children(children: &[lightningbeam_core::layer::AnyLayer], target: &uuid::Uuid) -> Option { + for child in children { + if let lightningbeam_core::layer::AnyLayer::Group(g) = child { + // Check if target is a direct child of this group + if g.children.iter().any(|c| c.id() == *target) { + return Some(g.layer.id); + } + // Recurse into nested groups + if let Some(found) = search_children(&g.children, target) { + return Some(found); + } + } + } + None + } + search_children(&doc.root.children, layer_id) } /// Recursively render a layout node with drag support @@ -5923,7 +5882,7 @@ fn render_pane( // Load and render icon if available if let Some(pane_type) = pane_type { - if let Some(icon) = ctx.icon_cache.get_or_load(pane_type, ui.ctx()) { + if let Some(icon) = ctx.shared.icon_cache.get_or_load(pane_type, ui.ctx()) { let icon_texture_id = icon.id(); let icon_rect = icon_button_rect.shrink(2.0); // Small padding inside button ui.painter().image( @@ -5957,7 +5916,7 @@ fn render_pane( for pane_type_option in PaneType::all() { // Load icon for this pane type - if let Some(icon) = ctx.icon_cache.get_or_load(*pane_type_option, ui.ctx()) { + if let Some(icon) = ctx.shared.icon_cache.get_or_load(*pane_type_option, ui.ctx()) { ui.horizontal(|ui| { // Show icon let icon_texture_id = icon.id(); @@ -6020,74 +5979,7 @@ fn render_pane( if let Some(pane_instance) = ctx.pane_instances.get_mut(path) { let mut header_ui = ui.new_child(egui::UiBuilder::new().max_rect(header_controls_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); - let mut shared = panes::SharedPaneState { - tool_icon_cache: ctx.tool_icon_cache, - icon_cache: ctx.icon_cache, - selected_tool: ctx.selected_tool, - fill_color: ctx.fill_color, - stroke_color: ctx.stroke_color, - active_color_mode: ctx.active_color_mode, - pending_view_action: ctx.pending_view_action, - fallback_pane_priority: ctx.fallback_pane_priority, - theme: ctx.theme, - 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, - draw_simplify_mode: ctx.draw_simplify_mode, - rdp_tolerance: ctx.rdp_tolerance, - schneider_max_error: ctx.schneider_max_error, - audio_controller: ctx.audio_controller, - video_manager: ctx.video_manager, - layer_to_track_map: ctx.layer_to_track_map, - playback_time: ctx.playback_time, - is_playing: ctx.is_playing, - is_recording: ctx.is_recording, - recording_clips: ctx.recording_clips, - recording_start_time: ctx.recording_start_time, - recording_layer_id: ctx.recording_layer_id, - dragging_asset: ctx.dragging_asset, - stroke_width: ctx.stroke_width, - fill_enabled: ctx.fill_enabled, - snap_enabled: ctx.snap_enabled, - paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance, - polygon_sides: ctx.polygon_sides, - midi_event_cache: ctx.midi_event_cache, - audio_pools_with_new_waveforms: ctx.audio_pools_with_new_waveforms, - raw_audio_cache: ctx.raw_audio_cache, - waveform_gpu_dirty: ctx.waveform_gpu_dirty, - effect_to_load: ctx.effect_to_load, - effect_thumbnail_requests: ctx.effect_thumbnail_requests, - effect_thumbnail_cache: ctx.effect_thumbnail_cache, - effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, - webcam_frame: ctx.webcam_frame.clone(), - webcam_record_command: ctx.webcam_record_command, - target_format: ctx.target_format, - pending_menu_actions: ctx.pending_menu_actions, - clipboard_manager: ctx.clipboard_manager, - waveform_stereo: ctx.waveform_stereo, - project_generation: ctx.project_generation, - script_to_edit: ctx.script_to_edit, - script_saved: ctx.script_saved, - region_selection: ctx.region_selection, - region_select_mode: ctx.region_select_mode, - pending_graph_loads: ctx.pending_graph_loads, - clipboard_consumed: ctx.clipboard_consumed, - keymap: ctx.keymap, - editing_clip_id: ctx.editing_clip_id, - editing_instance_id: ctx.editing_instance_id, - editing_parent_layer_id: ctx.editing_parent_layer_id, - pending_enter_clip: ctx.pending_enter_clip, - pending_exit_clip: ctx.pending_exit_clip, - #[cfg(debug_assertions)] - test_mode: ctx.test_mode, - #[cfg(debug_assertions)] - synthetic_input: ctx.synthetic_input, - }; - pane_instance.render_header(&mut header_ui, &mut shared); + pane_instance.render_header(&mut header_ui, &mut ctx.shared); } } @@ -6110,77 +6002,7 @@ fn render_pane( // Get the pane instance and render its content if let Some(pane_instance) = ctx.pane_instances.get_mut(path) { - // Create shared state - let mut shared = SharedPaneState { - tool_icon_cache: ctx.tool_icon_cache, - icon_cache: ctx.icon_cache, - selected_tool: ctx.selected_tool, - fill_color: ctx.fill_color, - stroke_color: ctx.stroke_color, - active_color_mode: ctx.active_color_mode, - pending_view_action: ctx.pending_view_action, - fallback_pane_priority: ctx.fallback_pane_priority, - theme: ctx.theme, - 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, - draw_simplify_mode: ctx.draw_simplify_mode, - rdp_tolerance: ctx.rdp_tolerance, - schneider_max_error: ctx.schneider_max_error, - audio_controller: ctx.audio_controller, - video_manager: ctx.video_manager, - layer_to_track_map: ctx.layer_to_track_map, - playback_time: ctx.playback_time, - is_playing: ctx.is_playing, - is_recording: ctx.is_recording, - recording_clips: ctx.recording_clips, - recording_start_time: ctx.recording_start_time, - recording_layer_id: ctx.recording_layer_id, - dragging_asset: ctx.dragging_asset, - stroke_width: ctx.stroke_width, - fill_enabled: ctx.fill_enabled, - snap_enabled: ctx.snap_enabled, - paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance, - polygon_sides: ctx.polygon_sides, - midi_event_cache: ctx.midi_event_cache, - audio_pools_with_new_waveforms: ctx.audio_pools_with_new_waveforms, - raw_audio_cache: ctx.raw_audio_cache, - waveform_gpu_dirty: ctx.waveform_gpu_dirty, - effect_to_load: ctx.effect_to_load, - effect_thumbnail_requests: ctx.effect_thumbnail_requests, - effect_thumbnail_cache: ctx.effect_thumbnail_cache, - effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, - webcam_frame: ctx.webcam_frame.clone(), - webcam_record_command: ctx.webcam_record_command, - target_format: ctx.target_format, - pending_menu_actions: ctx.pending_menu_actions, - clipboard_manager: ctx.clipboard_manager, - waveform_stereo: ctx.waveform_stereo, - project_generation: ctx.project_generation, - script_to_edit: ctx.script_to_edit, - script_saved: ctx.script_saved, - region_selection: ctx.region_selection, - region_select_mode: ctx.region_select_mode, - pending_graph_loads: ctx.pending_graph_loads, - clipboard_consumed: ctx.clipboard_consumed, - keymap: ctx.keymap, - editing_clip_id: ctx.editing_clip_id, - editing_instance_id: ctx.editing_instance_id, - editing_parent_layer_id: ctx.editing_parent_layer_id, - pending_enter_clip: ctx.pending_enter_clip, - pending_exit_clip: ctx.pending_exit_clip, - #[cfg(debug_assertions)] - test_mode: ctx.test_mode, - #[cfg(debug_assertions)] - synthetic_input: ctx.synthetic_input, - }; - - // Render pane content (header was already rendered above) - pane_instance.render_content(ui, content_rect, path, &mut shared); + pane_instance.render_content(ui, content_rect, path, &mut ctx.shared); } } else { // Unknown pane type - draw placeholder diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 415f04e..3faa6d5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -263,6 +263,10 @@ pub struct SharedPaneState<'a> { pub clipboard_consumed: &'a mut bool, /// Remappable keyboard shortcut manager pub keymap: &'a crate::keymap::KeymapManager, + /// Set by MenuAction::Group when focus is Nodes — consumed by node graph pane + pub pending_node_group: &'a mut bool, + /// Set by MenuAction::Group (ungroup variant) when focus is Nodes — consumed by node graph pane + pub pending_node_ungroup: &'a mut bool, /// Test mode state for event recording (debug builds only) #[cfg(debug_assertions)] pub test_mode: &'a mut crate::test_mode::TestModeState, 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 9afebc5..263b18f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -3095,29 +3095,25 @@ impl crate::panes::PaneRenderer for NodeGraphPane { } } - // Handle pane-local keyboard shortcuts (only when pointer is over this pane) - if ui.rect_contains_pointer(rect) { - let ctrl_g = ui.input(|i| { - shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphGroup, i) - }); - if ctrl_g && !self.state.selected_nodes.is_empty() { + // Handle group/ungroup commands from global MenuAction dispatch + if *shared.pending_node_group { + *shared.pending_node_group = false; + if !self.state.selected_nodes.is_empty() { self.group_selected_nodes(shared); } - - // Ctrl+Shift+G to ungroup - let ctrl_shift_g = ui.input(|i| { - shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphUngroup, i) - }); - if ctrl_shift_g { - // Ungroup any selected group placeholders - let group_ids_to_ungroup: Vec = self.state.selected_nodes.iter() - .filter_map(|fid| self.group_placeholder_map.get(fid).copied()) - .collect(); - for gid in group_ids_to_ungroup { - self.ungroup(gid, shared); - } + } + if *shared.pending_node_ungroup { + *shared.pending_node_ungroup = false; + let group_ids_to_ungroup: Vec = self.state.selected_nodes.iter() + .filter_map(|fid| self.group_placeholder_map.get(fid).copied()) + .collect(); + for gid in group_ids_to_ungroup { + self.ungroup(gid, shared); } + } + // Handle pane-local keyboard shortcuts (only when pointer is over this pane) + if ui.rect_contains_pointer(rect) { // F2 to rename selected group let f2 = ui.input(|i| shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphRename, i)); if f2 && self.renaming_group.is_none() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 2308346..fd143d9 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -242,6 +242,14 @@ impl<'a> TimelineRow<'a> { TimelineRow::GroupChild { child, .. } => Some(child), } } + + /// Returns the parent group ID, or None if this row is at root level. + fn parent_id(&self) -> Option { + match self { + TimelineRow::GroupChild { group, .. } => Some(group.layer.id), + _ => None, + } + } } /// Build a flattened list of timeline rows from the reversed context_layers. @@ -299,6 +307,49 @@ fn flatten_layer<'a>( } } +/// Shift+click layer selection: toggle a layer in/out of the focus selection, +/// enforcing the sibling constraint (all selected layers must share the same parent). +fn shift_toggle_layer( + focus: &mut lightningbeam_core::selection::FocusSelection, + layer_id: uuid::Uuid, + clicked_parent: Option, + rows: &[TimelineRow], +) { + use lightningbeam_core::selection::FocusSelection; + + if let FocusSelection::Layers(ids) = focus { + // Check if existing selection shares the same parent as the clicked layer + let existing_parent = ids.first().and_then(|first_id| { + rows.iter() + .find(|r| r.layer_id() == *first_id) + .and_then(|r| r.parent_id()) + }); + // For root-level layers, existing_parent is None; for group children, it's Some(group_id) + // We need to compare them properly: both None means same parent (root) + let same_parent = if ids.is_empty() { + true + } else { + existing_parent == clicked_parent + }; + + if same_parent { + // Toggle the clicked layer in/out + if let Some(pos) = ids.iter().position(|id| *id == layer_id) { + ids.remove(pos); + if ids.is_empty() { + *focus = FocusSelection::None; + } + } else { + ids.push(layer_id); + } + return; + } + } + + // Different parent or focus wasn't Layers — start fresh + *focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]); +} + /// Collect all (layer_ref, clip_instances) tuples from context_layers, /// recursively descending into group children. /// Returns (&AnyLayer, &[ClipInstance]) so callers have access to both layer info and clips. @@ -1083,6 +1134,7 @@ impl TimelinePane { rect: egui::Rect, theme: &crate::theme::Theme, active_layer_id: &Option, + focus: &lightningbeam_core::selection::FocusSelection, pending_actions: &mut Vec>, _document: &lightningbeam_core::document::Document, context_layers: &[&lightningbeam_core::layer::AnyLayer], @@ -1168,7 +1220,11 @@ impl TimelinePane { // Active vs inactive background colors let is_active = active_layer_id.map_or(false, |id| id == layer_id); - let bg_color = if is_active { + let is_selected = match focus { + lightningbeam_core::selection::FocusSelection::Layers(ids) => ids.contains(&layer_id), + _ => false, + }; + let bg_color = if is_active || is_selected { active_color } else { inactive_color @@ -1513,6 +1569,7 @@ impl TimelinePane { theme: &crate::theme::Theme, document: &lightningbeam_core::document::Document, active_layer_id: &Option, + focus: &lightningbeam_core::selection::FocusSelection, selection: &lightningbeam_core::selection::Selection, midi_event_cache: &std::collections::HashMap>, raw_audio_cache: &std::collections::HashMap>, u32, u32)>, @@ -1565,7 +1622,11 @@ impl TimelinePane { // Active vs inactive background colors let is_active = active_layer_id.map_or(false, |id| id == row_layer_id); - let bg_color = if is_active { + let is_selected = match focus { + lightningbeam_core::selection::FocusSelection::Layers(ids) => ids.contains(&row_layer_id), + _ => false, + }; + let bg_color = if is_active || is_selected { active_color } else { inactive_color @@ -2868,8 +2929,13 @@ impl TimelinePane { let header_rows = build_timeline_rows(context_layers); if clicked_layer_index < header_rows.len() { let layer_id = header_rows[clicked_layer_index].layer_id(); + let clicked_parent = header_rows[clicked_layer_index].parent_id(); *active_layer_id = Some(layer_id); - *focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]); + if shift_held { + shift_toggle_layer(focus, layer_id, clicked_parent, &header_rows); + } else { + *focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]); + } } } } @@ -3266,12 +3332,14 @@ impl TimelinePane { let empty_click_rows = build_timeline_rows(context_layers); if clicked_layer_index < empty_click_rows.len() { let layer_id = empty_click_rows[clicked_layer_index].layer_id(); + let clicked_parent = empty_click_rows[clicked_layer_index].parent_id(); *active_layer_id = Some(layer_id); - // Clear clip instance selection when clicking on empty layer area - if !shift_held { + if shift_held { + shift_toggle_layer(focus, layer_id, clicked_parent, &empty_click_rows); + } else { selection.clear_clip_instances(); + *focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]); } - *focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]); } } } @@ -3715,7 +3783,7 @@ impl PaneRenderer for TimelinePane { // Render layer header column with clipping ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); - self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, &mut shared.pending_actions, document, &context_layers); + self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers); // Render time ruler (clip to ruler rect) ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); @@ -3723,7 +3791,7 @@ impl PaneRenderer for TimelinePane { // Render layer rows with clipping ui.set_clip_rect(content_rect.intersect(original_clip_rect)); - let video_clip_hovers = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager); + let video_clip_hovers = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager); // Render playhead on top (clip to timeline area) ui.set_clip_rect(timeline_rect.intersect(original_clip_rect));