group layers manually

This commit is contained in:
Skyler Lehmkuhl 2026-03-01 11:01:51 -05:00
parent 13840ee45f
commit 38831948ac
6 changed files with 363 additions and 350 deletions

View File

@ -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<Uuid>,
/// Parent group ID (None = document root)
parent_group_id: Option<Uuid>,
/// Pre-generated UUID for the new GroupLayer
group_id: Uuid,
/// Rollback: index where the group was inserted
insert_index: Option<usize>,
/// Rollback: (original_index, layer) pairs, sorted by index ascending
removed_layers: Vec<(usize, AnyLayer)>,
}
impl GroupLayersAction {
pub fn new(layer_ids: Vec<Uuid>, parent_group_id: Option<Uuid>, 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<Uuid>,
) -> Result<&'a mut Vec<AnyLayer>, 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<usize> = 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())
}
}

View File

@ -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;

View File

@ -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<std::sync::atomic::AtomicU32>,
/// 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<uuid::Uuid> = 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<uuid::Uuid> = 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<NodePath, PaneInstance>,
pending_view_action: &'a mut Option<MenuAction>,
fallback_pane_priority: &'a mut Option<u32>,
pending_handlers: &'a mut Vec<panes::ViewActionHandler>,
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<Uuid>,
editing_instance_id: Option<Uuid>,
editing_parent_layer_id: Option<Uuid>,
pending_enter_clip: &'a mut Option<(Uuid, Uuid, Uuid)>,
pending_exit_clip: &'a mut bool,
active_layer_id: &'a mut Option<Uuid>,
tool_state: &'a mut lightningbeam_core::tool::ToolState,
pending_actions: &'a mut Vec<Box<dyn lightningbeam_core::action::Action>>,
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<std::sync::Mutex<daw_backend::EngineController>>>,
video_manager: &'a std::sync::Arc<std::sync::Mutex<lightningbeam_core::video::VideoManager>>,
playback_time: &'a mut f64,
is_playing: &'a mut bool,
// Recording state
is_recording: &'a mut bool,
recording_clips: &'a mut HashMap<Uuid, u32>,
recording_start_time: &'a mut f64,
recording_layer_id: &'a mut Option<Uuid>,
dragging_asset: &'a mut Option<panes::DraggingAsset>,
// 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<Uuid, daw_backend::TrackId>,
/// Cache of MIDI events for rendering (keyed by backend midi_clip_id)
midi_event_cache: &'a mut HashMap<u32, Vec<(f64, u8, u8, bool)>>,
/// Audio pool indices with new raw audio data this frame (for thumbnail invalidation)
audio_pools_with_new_waveforms: &'a HashSet<usize>,
/// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels))
raw_audio_cache: &'a HashMap<usize, (Arc<Vec<f32>>, u32, u32)>,
/// Pool indices needing GPU texture upload
waveform_gpu_dirty: &'a mut HashSet<usize>,
/// Effect ID to load into shader editor (set by asset library, consumed by shader editor)
effect_to_load: &'a mut Option<Uuid>,
/// Queue for effect thumbnail requests
effect_thumbnail_requests: &'a mut Vec<Uuid>,
/// Cache of generated effect thumbnails
effect_thumbnail_cache: &'a HashMap<Uuid, Vec<u8>>,
/// Effect IDs whose thumbnails should be invalidated
effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>,
/// Latest webcam capture frame (None if no camera active)
webcam_frame: Option<lightningbeam_core::webcam::CaptureFrame>,
/// Pending webcam recording command
webcam_record_command: &'a mut Option<panes::WebcamRecordCommand>,
/// 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<MenuAction>,
/// 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<Uuid>,
/// Script ID just saved (triggers auto-recompile of nodes using it)
script_saved: &'a mut Option<Uuid>,
/// Active region selection (temporary split state)
region_selection: &'a mut Option<lightningbeam_core::selection::RegionSelection>,
/// 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<std::sync::atomic::AtomicU32>,
/// 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<test_mode::SyntheticInput>,
}
/// 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<uuid::Uuid> {
fn search_children(children: &[lightningbeam_core::layer::AnyLayer], target: &uuid::Uuid) -> Option<uuid::Uuid> {
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

View File

@ -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,

View File

@ -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<GroupId> = 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<GroupId> = 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() {

View File

@ -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<uuid::Uuid> {
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<uuid::Uuid>,
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<uuid::Uuid>,
focus: &lightningbeam_core::selection::FocusSelection,
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
_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<uuid::Uuid>,
focus: &lightningbeam_core::selection::FocusSelection,
selection: &lightningbeam_core::selection::Selection,
midi_event_cache: &std::collections::HashMap<u32, Vec<(f64, u8, u8, bool)>>,
raw_audio_cache: &std::collections::HashMap<usize, (std::sync::Arc<Vec<f32>>, 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));