Compare commits
4 Commits
4ab6fe0504
...
c60eef0c5a
| Author | SHA1 | Date |
|---|---|---|
|
|
c60eef0c5a | |
|
|
b7d72d2803 | |
|
|
38831948ac | |
|
|
13840ee45f |
|
|
@ -0,0 +1,127 @@
|
||||||
|
//! 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();
|
||||||
|
|
||||||
|
// The timeline displays layers in reverse order (highest index = visually on top).
|
||||||
|
// Insert the group at the highest selected index so it appears where the
|
||||||
|
// topmost visual layer was. After removing N layers before that position,
|
||||||
|
// the actual insert index shifts down by the count of removed layers below it.
|
||||||
|
let highest_index = *indices.last().unwrap();
|
||||||
|
let removals_before_highest = indices.iter().filter(|&&i| i < highest_index).count();
|
||||||
|
let insert_index = highest_index - removals_before_highest;
|
||||||
|
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;
|
||||||
|
group.expanded = false;
|
||||||
|
for (_, layer) in &self.removed_layers {
|
||||||
|
group.add_child(layer.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the group at the computed 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ pub mod group_shapes;
|
||||||
pub mod convert_to_movie_clip;
|
pub mod convert_to_movie_clip;
|
||||||
pub mod region_split;
|
pub mod region_split;
|
||||||
pub mod toggle_group_expansion;
|
pub mod toggle_group_expansion;
|
||||||
|
pub mod group_layers;
|
||||||
|
|
||||||
pub use add_clip_instance::AddClipInstanceAction;
|
pub use add_clip_instance::AddClipInstanceAction;
|
||||||
pub use add_effect::AddEffectAction;
|
pub use add_effect::AddEffectAction;
|
||||||
|
|
@ -58,3 +59,4 @@ pub use group_shapes::GroupAction;
|
||||||
pub use convert_to_movie_clip::ConvertToMovieClipAction;
|
pub use convert_to_movie_clip::ConvertToMovieClipAction;
|
||||||
pub use region_split::RegionSplitAction;
|
pub use region_split::RegionSplitAction;
|
||||||
pub use toggle_group_expansion::ToggleGroupExpansionAction;
|
pub use toggle_group_expansion::ToggleGroupExpansionAction;
|
||||||
|
pub use group_layers::GroupLayersAction;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,49 @@ use std::collections::HashSet;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use vello::kurbo::{Affine, BezPath};
|
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<Uuid>),
|
||||||
|
/// One or more clip instances selected (by UUID)
|
||||||
|
ClipInstances(Vec<Uuid>),
|
||||||
|
/// 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<usize> },
|
||||||
|
/// Node graph nodes selected (backend node indices)
|
||||||
|
Nodes(Vec<u32>),
|
||||||
|
/// Assets selected in asset library (by UUID)
|
||||||
|
Assets(Vec<Uuid>),
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
/// Selection state for the editor
|
||||||
///
|
///
|
||||||
/// Maintains sets of selected DCEL elements and clip instances.
|
/// Maintains sets of selected DCEL elements and clip instances.
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use clap::Parser;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
mod panes;
|
mod panes;
|
||||||
use panes::{PaneInstance, PaneRenderer, SharedPaneState};
|
use panes::{PaneInstance, PaneRenderer};
|
||||||
|
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|
||||||
|
|
@ -739,6 +739,7 @@ struct EditorApp {
|
||||||
action_executor: lightningbeam_core::action::ActionExecutor, // Action system for undo/redo
|
action_executor: lightningbeam_core::action::ActionExecutor, // Action system for undo/redo
|
||||||
active_layer_id: Option<Uuid>, // Currently active layer for editing
|
active_layer_id: Option<Uuid>, // Currently active layer for editing
|
||||||
selection: lightningbeam_core::selection::Selection, // Current selection state
|
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
|
editing_context: EditingContext, // Which clip (or root) we're editing
|
||||||
tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state
|
tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state
|
||||||
// Draw tool configuration
|
// Draw tool configuration
|
||||||
|
|
@ -754,6 +755,10 @@ struct EditorApp {
|
||||||
/// Count of in-flight graph preset loads — keeps the repaint loop alive
|
/// Count of in-flight graph preset loads — keeps the repaint loop alive
|
||||||
/// until the audio thread sends GraphPresetLoaded events for all of them
|
/// until the audio thread sends GraphPresetLoaded events for all of them
|
||||||
pending_graph_loads: std::sync::Arc<std::sync::atomic::AtomicU32>,
|
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
|
#[allow(dead_code)] // Stored for future export/recording configuration
|
||||||
audio_sample_rate: u32,
|
audio_sample_rate: u32,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|
@ -1006,6 +1011,7 @@ impl EditorApp {
|
||||||
action_executor,
|
action_executor,
|
||||||
active_layer_id: Some(layer_id),
|
active_layer_id: Some(layer_id),
|
||||||
selection: lightningbeam_core::selection::Selection::new(),
|
selection: lightningbeam_core::selection::Selection::new(),
|
||||||
|
focus: lightningbeam_core::selection::FocusSelection::None,
|
||||||
editing_context: EditingContext::default(),
|
editing_context: EditingContext::default(),
|
||||||
tool_state: lightningbeam_core::tool::ToolState::default(),
|
tool_state: lightningbeam_core::tool::ToolState::default(),
|
||||||
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves
|
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves
|
||||||
|
|
@ -1016,6 +1022,8 @@ impl EditorApp {
|
||||||
audio_event_rx,
|
audio_event_rx,
|
||||||
audio_events_pending: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
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_graph_loads: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||||
|
pending_node_group: false,
|
||||||
|
pending_node_ungroup: false,
|
||||||
audio_sample_rate,
|
audio_sample_rate,
|
||||||
audio_channels,
|
audio_channels,
|
||||||
video_manager: std::sync::Arc::new(std::sync::Mutex::new(
|
video_manager: std::sync::Arc::new(std::sync::Mutex::new(
|
||||||
|
|
@ -1385,6 +1393,7 @@ impl EditorApp {
|
||||||
|
|
||||||
// Reset selection and set active layer to the newly created one
|
// Reset selection and set active layer to the newly created one
|
||||||
self.selection = lightningbeam_core::selection::Selection::new();
|
self.selection = lightningbeam_core::selection::Selection::new();
|
||||||
|
self.focus = lightningbeam_core::selection::FocusSelection::None;
|
||||||
self.active_layer_id = Some(layer_id);
|
self.active_layer_id = Some(layer_id);
|
||||||
|
|
||||||
// For Music focus, sync the MIDI layer with daw-backend
|
// For Music focus, sync the MIDI layer with daw-backend
|
||||||
|
|
@ -2569,19 +2578,34 @@ impl EditorApp {
|
||||||
|
|
||||||
// Modify menu
|
// Modify menu
|
||||||
MenuAction::Group => {
|
MenuAction::Group => {
|
||||||
|
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 let Some(layer_id) = self.active_layer_id {
|
||||||
if self.selection.has_dcel_selection() {
|
if self.selection.has_dcel_selection() {
|
||||||
// TODO: DCEL group deferred to Phase 2 (extract subgraph)
|
// TODO: DCEL group deferred to Phase 2
|
||||||
} else {
|
} else {
|
||||||
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
|
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
|
||||||
if clip_ids.len() >= 2 {
|
if clip_ids.len() >= 2 {
|
||||||
let instance_id = uuid::Uuid::new_v4();
|
let instance_id = uuid::Uuid::new_v4();
|
||||||
let action = lightningbeam_core::actions::GroupAction::new(
|
let action = lightningbeam_core::actions::GroupAction::new(
|
||||||
layer_id,
|
layer_id, self.playback_time, Vec::new(), clip_ids, instance_id,
|
||||||
self.playback_time,
|
|
||||||
Vec::new(),
|
|
||||||
clip_ids,
|
|
||||||
instance_id,
|
|
||||||
);
|
);
|
||||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||||
eprintln!("Failed to group: {}", e);
|
eprintln!("Failed to group: {}", e);
|
||||||
|
|
@ -2594,6 +2618,8 @@ impl EditorApp {
|
||||||
let _ = layer_id;
|
let _ = layer_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
MenuAction::ConvertToMovieClip => {
|
MenuAction::ConvertToMovieClip => {
|
||||||
if let Some(layer_id) = self.active_layer_id {
|
if let Some(layer_id) = self.active_layer_id {
|
||||||
if self.selection.has_dcel_selection() {
|
if self.selection.has_dcel_selection() {
|
||||||
|
|
@ -4973,19 +4999,20 @@ impl eframe::App for EditorApp {
|
||||||
|
|
||||||
// Create render context
|
// Create render context
|
||||||
let mut ctx = RenderContext {
|
let mut ctx = RenderContext {
|
||||||
|
shared: panes::SharedPaneState {
|
||||||
tool_icon_cache: &mut self.tool_icon_cache,
|
tool_icon_cache: &mut self.tool_icon_cache,
|
||||||
icon_cache: &mut self.icon_cache,
|
icon_cache: &mut self.icon_cache,
|
||||||
selected_tool: &mut self.selected_tool,
|
selected_tool: &mut self.selected_tool,
|
||||||
fill_color: &mut self.fill_color,
|
fill_color: &mut self.fill_color,
|
||||||
stroke_color: &mut self.stroke_color,
|
stroke_color: &mut self.stroke_color,
|
||||||
active_color_mode: &mut self.active_color_mode,
|
active_color_mode: &mut self.active_color_mode,
|
||||||
pane_instances: &mut self.pane_instances,
|
|
||||||
pending_view_action: &mut self.pending_view_action,
|
pending_view_action: &mut self.pending_view_action,
|
||||||
fallback_pane_priority: &mut fallback_pane_priority,
|
fallback_pane_priority: &mut fallback_pane_priority,
|
||||||
pending_handlers: &mut pending_handlers,
|
pending_handlers: &mut pending_handlers,
|
||||||
theme: &self.theme,
|
theme: &self.theme,
|
||||||
action_executor: &mut self.action_executor,
|
action_executor: &mut self.action_executor,
|
||||||
selection: &mut self.selection,
|
selection: &mut self.selection,
|
||||||
|
focus: &mut self.focus,
|
||||||
editing_clip_id: self.editing_context.current_clip_id(),
|
editing_clip_id: self.editing_context.current_clip_id(),
|
||||||
editing_instance_id: self.editing_context.current_instance_id(),
|
editing_instance_id: self.editing_context.current_instance_id(),
|
||||||
editing_parent_layer_id: self.editing_context.current_parent_layer_id(),
|
editing_parent_layer_id: self.editing_context.current_parent_layer_id(),
|
||||||
|
|
@ -5036,10 +5063,14 @@ impl eframe::App for EditorApp {
|
||||||
pending_graph_loads: &self.pending_graph_loads,
|
pending_graph_loads: &self.pending_graph_loads,
|
||||||
clipboard_consumed: &mut clipboard_consumed,
|
clipboard_consumed: &mut clipboard_consumed,
|
||||||
keymap: &self.keymap,
|
keymap: &self.keymap,
|
||||||
|
pending_node_group: &mut self.pending_node_group,
|
||||||
|
pending_node_ungroup: &mut self.pending_node_ungroup,
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
test_mode: &mut self.test_mode,
|
test_mode: &mut self.test_mode,
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
synthetic_input: &mut synthetic_input_storage,
|
synthetic_input: &mut synthetic_input_storage,
|
||||||
|
},
|
||||||
|
pane_instances: &mut self.pane_instances,
|
||||||
};
|
};
|
||||||
|
|
||||||
render_layout_node(
|
render_layout_node(
|
||||||
|
|
@ -5485,100 +5516,33 @@ impl eframe::App for EditorApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Context for rendering operations - bundles all mutable state needed during rendering
|
/// 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> {
|
struct RenderContext<'a> {
|
||||||
tool_icon_cache: &'a mut ToolIconCache,
|
shared: panes::SharedPaneState<'a>,
|
||||||
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,
|
|
||||||
pane_instances: &'a mut HashMap<NodePath, PaneInstance>,
|
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>,
|
/// Find which GroupLayer (if any) contains the given layer as a direct child.
|
||||||
theme: &'a Theme,
|
/// Returns None if the layer is at document root level.
|
||||||
action_executor: &'a mut lightningbeam_core::action::ActionExecutor,
|
fn find_parent_group_id(doc: &lightningbeam_core::document::Document, layer_id: &uuid::Uuid) -> Option<uuid::Uuid> {
|
||||||
selection: &'a mut lightningbeam_core::selection::Selection,
|
fn search_children(children: &[lightningbeam_core::layer::AnyLayer], target: &uuid::Uuid) -> Option<uuid::Uuid> {
|
||||||
editing_clip_id: Option<Uuid>,
|
for child in children {
|
||||||
editing_instance_id: Option<Uuid>,
|
if let lightningbeam_core::layer::AnyLayer::Group(g) = child {
|
||||||
editing_parent_layer_id: Option<Uuid>,
|
// Check if target is a direct child of this group
|
||||||
pending_enter_clip: &'a mut Option<(Uuid, Uuid, Uuid)>,
|
if g.children.iter().any(|c| c.id() == *target) {
|
||||||
pending_exit_clip: &'a mut bool,
|
return Some(g.layer.id);
|
||||||
active_layer_id: &'a mut Option<Uuid>,
|
}
|
||||||
tool_state: &'a mut lightningbeam_core::tool::ToolState,
|
// Recurse into nested groups
|
||||||
pending_actions: &'a mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
if let Some(found) = search_children(&g.children, target) {
|
||||||
draw_simplify_mode: &'a mut lightningbeam_core::tool::SimplifyMode,
|
return Some(found);
|
||||||
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>>,
|
None
|
||||||
playback_time: &'a mut f64,
|
}
|
||||||
is_playing: &'a mut bool,
|
search_children(&doc.root.children, layer_id)
|
||||||
// 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>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively render a layout node with drag support
|
/// Recursively render a layout node with drag support
|
||||||
|
|
@ -5918,7 +5882,7 @@ fn render_pane(
|
||||||
|
|
||||||
// Load and render icon if available
|
// Load and render icon if available
|
||||||
if let Some(pane_type) = pane_type {
|
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_texture_id = icon.id();
|
||||||
let icon_rect = icon_button_rect.shrink(2.0); // Small padding inside button
|
let icon_rect = icon_button_rect.shrink(2.0); // Small padding inside button
|
||||||
ui.painter().image(
|
ui.painter().image(
|
||||||
|
|
@ -5952,7 +5916,7 @@ fn render_pane(
|
||||||
|
|
||||||
for pane_type_option in PaneType::all() {
|
for pane_type_option in PaneType::all() {
|
||||||
// Load icon for this pane type
|
// 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| {
|
ui.horizontal(|ui| {
|
||||||
// Show icon
|
// Show icon
|
||||||
let icon_texture_id = icon.id();
|
let icon_texture_id = icon.id();
|
||||||
|
|
@ -6130,73 +6094,7 @@ fn render_pane(
|
||||||
|
|
||||||
if let Some(pane_instance) = ctx.pane_instances.get_mut(path) {
|
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 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 {
|
pane_instance.render_header(&mut header_ui, &mut ctx.shared);
|
||||||
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,
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6219,76 +6117,7 @@ fn render_pane(
|
||||||
|
|
||||||
// Get the pane instance and render its content
|
// Get the pane instance and render its content
|
||||||
if let Some(pane_instance) = ctx.pane_instances.get_mut(path) {
|
if let Some(pane_instance) = ctx.pane_instances.get_mut(path) {
|
||||||
// Create shared state
|
pane_instance.render_content(ui, content_rect, path, &mut ctx.shared);
|
||||||
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,
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown pane type - draw placeholder
|
// Unknown pane type - draw placeholder
|
||||||
|
|
|
||||||
|
|
@ -1858,6 +1858,7 @@ impl AssetLibraryPane {
|
||||||
// Handle interactions
|
// Handle interactions
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
self.selected_asset = Some(asset.id);
|
self.selected_asset = Some(asset.id);
|
||||||
|
*shared.focus = lightningbeam_core::selection::FocusSelection::Assets(vec![asset.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.secondary_clicked() {
|
if response.secondary_clicked() {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
/// Info Panel pane - displays and edits properties of selected objects
|
/// 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)
|
/// - Tool options (when a tool is active)
|
||||||
/// - Transform properties (when shapes are selected)
|
/// - Layer properties (when layers are focused)
|
||||||
/// - Shape properties (fill/stroke for selected shapes)
|
/// - Clip instance properties (when clip instances are focused)
|
||||||
/// - Document settings (when nothing is selected)
|
/// - 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 eframe::egui::{self, DragValue, Ui};
|
||||||
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction};
|
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::shape::ShapeColor;
|
||||||
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
||||||
use super::{NodePath, PaneRenderer, SharedPaneState};
|
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 {
|
struct SelectionInfo {
|
||||||
/// True if nothing is selected
|
/// True if nothing is selected
|
||||||
is_empty: bool,
|
is_empty: bool,
|
||||||
|
|
@ -60,7 +65,7 @@ impl Default for SelectionInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InfopanelPane {
|
impl InfopanelPane {
|
||||||
/// Gather info about the current selection
|
/// Gather info about the current DCEL selection
|
||||||
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
|
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
|
||||||
let mut info = SelectionInfo::default();
|
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) {
|
fn render_document_section(&self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState) {
|
||||||
egui::CollapsingHeader::new("Document")
|
egui::CollapsingHeader::new("Document")
|
||||||
.id_salt(("document", path))
|
.id_salt(("document", path))
|
||||||
|
|
@ -503,6 +508,328 @@ impl InfopanelPane {
|
||||||
ui.add_space(4.0);
|
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<u8, (f64, u8)> = 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 {
|
impl PaneRenderer for InfopanelPane {
|
||||||
|
|
@ -536,20 +863,22 @@ impl PaneRenderer for InfopanelPane {
|
||||||
// 1. Tool options section (always shown if tool has options)
|
// 1. Tool options section (always shown if tool has options)
|
||||||
self.render_tool_section(ui, path, shared);
|
self.render_tool_section(ui, path, shared);
|
||||||
|
|
||||||
// 2. Gather selection info
|
// 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);
|
let info = self.gather_selection_info(shared);
|
||||||
|
|
||||||
// 3. Shape properties section (if DCEL elements selected)
|
|
||||||
if info.dcel_count > 0 {
|
if info.dcel_count > 0 {
|
||||||
self.render_shape_section(ui, path, shared, &info);
|
self.render_shape_section(ui, path, shared, &info);
|
||||||
}
|
}
|
||||||
|
// Selection count
|
||||||
// 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 {
|
if info.dcel_count > 0 {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
@ -560,6 +889,34 @@ impl PaneRenderer for InfopanelPane {
|
||||||
if info.dcel_count == 1 { "" } else { "s" }
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,8 @@ pub struct SharedPaneState<'a> {
|
||||||
pub action_executor: &'a mut lightningbeam_core::action::ActionExecutor,
|
pub action_executor: &'a mut lightningbeam_core::action::ActionExecutor,
|
||||||
/// Current selection state (mutable for tools to modify)
|
/// Current selection state (mutable for tools to modify)
|
||||||
pub selection: &'a mut lightningbeam_core::selection::Selection,
|
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)
|
/// Which VectorClip is being edited (None = document root)
|
||||||
pub editing_clip_id: Option<uuid::Uuid>,
|
pub editing_clip_id: Option<uuid::Uuid>,
|
||||||
/// The clip instance ID being edited
|
/// The clip instance ID being edited
|
||||||
|
|
@ -261,6 +263,10 @@ pub struct SharedPaneState<'a> {
|
||||||
pub clipboard_consumed: &'a mut bool,
|
pub clipboard_consumed: &'a mut bool,
|
||||||
/// Remappable keyboard shortcut manager
|
/// Remappable keyboard shortcut manager
|
||||||
pub keymap: &'a crate::keymap::KeymapManager,
|
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)
|
/// Test mode state for event recording (debug builds only)
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
pub test_mode: &'a mut crate::test_mode::TestModeState,
|
pub test_mode: &'a mut crate::test_mode::TestModeState,
|
||||||
|
|
|
||||||
|
|
@ -2580,6 +2580,17 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
self.last_node_rects = graph_response.node_rects.clone();
|
self.last_node_rects = graph_response.node_rects.clone();
|
||||||
self.handle_graph_response(graph_response, shared, graph_rect);
|
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<u32> = 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()
|
// Handle pending sampler load requests from bottom_ui()
|
||||||
if let Some(load) = self.user_state.pending_sampler_load.take() {
|
if let Some(load) = self.user_state.pending_sampler_load.take() {
|
||||||
self.handle_pending_sampler_load(load, shared);
|
self.handle_pending_sampler_load(load, shared);
|
||||||
|
|
@ -3084,21 +3095,15 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle pane-local keyboard shortcuts (only when pointer is over this pane)
|
// Handle group/ungroup commands from global MenuAction dispatch
|
||||||
if ui.rect_contains_pointer(rect) {
|
if *shared.pending_node_group {
|
||||||
let ctrl_g = ui.input(|i| {
|
*shared.pending_node_group = false;
|
||||||
shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphGroup, i)
|
if !self.state.selected_nodes.is_empty() {
|
||||||
});
|
|
||||||
if ctrl_g && !self.state.selected_nodes.is_empty() {
|
|
||||||
self.group_selected_nodes(shared);
|
self.group_selected_nodes(shared);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Ctrl+Shift+G to ungroup
|
if *shared.pending_node_ungroup {
|
||||||
let ctrl_shift_g = ui.input(|i| {
|
*shared.pending_node_ungroup = false;
|
||||||
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()
|
let group_ids_to_ungroup: Vec<GroupId> = self.state.selected_nodes.iter()
|
||||||
.filter_map(|fid| self.group_placeholder_map.get(fid).copied())
|
.filter_map(|fid| self.group_placeholder_map.get(fid).copied())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -3107,6 +3112,8 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle pane-local keyboard shortcuts (only when pointer is over this pane)
|
||||||
|
if ui.rect_contains_pointer(rect) {
|
||||||
// F2 to rename selected group
|
// F2 to rename selected group
|
||||||
let f2 = ui.input(|i| shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphRename, i));
|
let f2 = ui.input(|i| shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphRename, i));
|
||||||
if f2 && self.renaming_group.is_none() {
|
if f2 && self.renaming_group.is_none() {
|
||||||
|
|
|
||||||
|
|
@ -864,6 +864,7 @@ impl PianoRollPane {
|
||||||
self.selected_note_indices.clear();
|
self.selected_note_indices.clear();
|
||||||
}
|
}
|
||||||
self.selected_note_indices.insert(note_idx);
|
self.selected_note_indices.insert(note_idx);
|
||||||
|
self.update_focus(shared);
|
||||||
|
|
||||||
self.drag_mode = Some(DragMode::MoveNotes {
|
self.drag_mode = Some(DragMode::MoveNotes {
|
||||||
start_time_offset: 0.0,
|
start_time_offset: 0.0,
|
||||||
|
|
@ -915,6 +916,7 @@ impl PianoRollPane {
|
||||||
} else {
|
} else {
|
||||||
// Start selection rectangle
|
// Start selection rectangle
|
||||||
self.selected_note_indices.clear();
|
self.selected_note_indices.clear();
|
||||||
|
self.update_focus(shared);
|
||||||
self.selection_rect = Some((pos, pos));
|
self.selection_rect = Some((pos, pos));
|
||||||
self.drag_mode = Some(DragMode::SelectRect);
|
self.drag_mode = Some(DragMode::SelectRect);
|
||||||
}
|
}
|
||||||
|
|
@ -1007,6 +1009,7 @@ impl PianoRollPane {
|
||||||
}
|
}
|
||||||
Some(DragMode::SelectRect) => {
|
Some(DragMode::SelectRect) => {
|
||||||
self.selection_rect = None;
|
self.selection_rect = None;
|
||||||
|
self.update_focus(shared);
|
||||||
}
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
@ -1086,6 +1089,18 @@ impl PianoRollPane {
|
||||||
None
|
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(
|
fn update_selection_from_rect(
|
||||||
&mut self,
|
&mut self,
|
||||||
grid_rect: Rect,
|
grid_rect: Rect,
|
||||||
|
|
|
||||||
|
|
@ -2529,6 +2529,9 @@ impl StagePane {
|
||||||
shared.selection.select_edge(edge_id, dcel);
|
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
|
// DCEL element dragging deferred to Phase 3
|
||||||
}
|
}
|
||||||
hit_test::HitResult::Face(face_id) => {
|
hit_test::HitResult::Face(face_id) => {
|
||||||
|
|
@ -2541,6 +2544,9 @@ impl StagePane {
|
||||||
shared.selection.select_face(face_id, dcel);
|
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
|
// DCEL element dragging deferred to Phase 3
|
||||||
}
|
}
|
||||||
hit_test::HitResult::ClipInstance(clip_id) => {
|
hit_test::HitResult::ClipInstance(clip_id) => {
|
||||||
|
|
@ -2554,6 +2560,7 @@ impl StagePane {
|
||||||
shared.selection.select_only_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());
|
||||||
|
|
||||||
// If clip instance is now selected, prepare for dragging
|
// If clip instance is now selected, prepare for dragging
|
||||||
if shared.selection.contains_clip_instance(&clip_id) {
|
if shared.selection.contains_clip_instance(&clip_id) {
|
||||||
|
|
@ -2582,6 +2589,7 @@ impl StagePane {
|
||||||
// Nothing hit - start marquee selection
|
// Nothing hit - start marquee selection
|
||||||
if !shift_held {
|
if !shift_held {
|
||||||
shared.selection.clear();
|
shared.selection.clear();
|
||||||
|
*shared.focus = lightningbeam_core::selection::FocusSelection::None;
|
||||||
}
|
}
|
||||||
|
|
||||||
*shared.tool_state = ToolState::MarqueeSelecting {
|
*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;
|
*shared.tool_state = ToolState::Idle;
|
||||||
}
|
}
|
||||||
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. } => {
|
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
|
// Reset tool state
|
||||||
*shared.tool_state = ToolState::Idle;
|
*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;
|
*shared.tool_state = ToolState::Idle;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,14 @@ impl<'a> TimelineRow<'a> {
|
||||||
TimelineRow::GroupChild { child, .. } => Some(child),
|
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.
|
/// 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,
|
/// Collect all (layer_ref, clip_instances) tuples from context_layers,
|
||||||
/// recursively descending into group children.
|
/// recursively descending into group children.
|
||||||
/// Returns (&AnyLayer, &[ClipInstance]) so callers have access to both layer info and clips.
|
/// Returns (&AnyLayer, &[ClipInstance]) so callers have access to both layer info and clips.
|
||||||
|
|
@ -1083,6 +1134,7 @@ impl TimelinePane {
|
||||||
rect: egui::Rect,
|
rect: egui::Rect,
|
||||||
theme: &crate::theme::Theme,
|
theme: &crate::theme::Theme,
|
||||||
active_layer_id: &Option<uuid::Uuid>,
|
active_layer_id: &Option<uuid::Uuid>,
|
||||||
|
focus: &lightningbeam_core::selection::FocusSelection,
|
||||||
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||||
_document: &lightningbeam_core::document::Document,
|
_document: &lightningbeam_core::document::Document,
|
||||||
context_layers: &[&lightningbeam_core::layer::AnyLayer],
|
context_layers: &[&lightningbeam_core::layer::AnyLayer],
|
||||||
|
|
@ -1168,7 +1220,11 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Active vs inactive background colors
|
// Active vs inactive background colors
|
||||||
let is_active = active_layer_id.map_or(false, |id| id == layer_id);
|
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
|
active_color
|
||||||
} else {
|
} else {
|
||||||
inactive_color
|
inactive_color
|
||||||
|
|
@ -1513,6 +1569,7 @@ impl TimelinePane {
|
||||||
theme: &crate::theme::Theme,
|
theme: &crate::theme::Theme,
|
||||||
document: &lightningbeam_core::document::Document,
|
document: &lightningbeam_core::document::Document,
|
||||||
active_layer_id: &Option<uuid::Uuid>,
|
active_layer_id: &Option<uuid::Uuid>,
|
||||||
|
focus: &lightningbeam_core::selection::FocusSelection,
|
||||||
selection: &lightningbeam_core::selection::Selection,
|
selection: &lightningbeam_core::selection::Selection,
|
||||||
midi_event_cache: &std::collections::HashMap<u32, Vec<(f64, u8, u8, bool)>>,
|
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)>,
|
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
|
// Active vs inactive background colors
|
||||||
let is_active = active_layer_id.map_or(false, |id| id == row_layer_id);
|
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
|
active_color
|
||||||
} else {
|
} else {
|
||||||
inactive_color
|
inactive_color
|
||||||
|
|
@ -2724,6 +2785,7 @@ impl TimelinePane {
|
||||||
document: &lightningbeam_core::document::Document,
|
document: &lightningbeam_core::document::Document,
|
||||||
active_layer_id: &mut Option<uuid::Uuid>,
|
active_layer_id: &mut Option<uuid::Uuid>,
|
||||||
selection: &mut lightningbeam_core::selection::Selection,
|
selection: &mut lightningbeam_core::selection::Selection,
|
||||||
|
focus: &mut lightningbeam_core::selection::FocusSelection,
|
||||||
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||||
playback_time: &mut f64,
|
playback_time: &mut f64,
|
||||||
_is_playing: &mut bool,
|
_is_playing: &mut bool,
|
||||||
|
|
@ -2791,6 +2853,7 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*active_layer_id = Some(click_row.layer_id());
|
*active_layer_id = Some(click_row.layer_id());
|
||||||
|
*focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec());
|
||||||
clicked_clip_instance = true;
|
clicked_clip_instance = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2838,6 +2901,7 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
// Also set this layer as the active layer
|
// Also set this layer as the active layer
|
||||||
*active_layer_id = Some(layer.id());
|
*active_layer_id = Some(layer.id());
|
||||||
|
*focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec());
|
||||||
clicked_clip_instance = true;
|
clicked_clip_instance = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -2864,7 +2928,14 @@ impl TimelinePane {
|
||||||
// Get the layer at this index (using virtual rows for group support)
|
// Get the layer at this index (using virtual rows for group support)
|
||||||
let header_rows = build_timeline_rows(context_layers);
|
let header_rows = build_timeline_rows(context_layers);
|
||||||
if clicked_layer_index < header_rows.len() {
|
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();
|
||||||
|
let clicked_parent = header_rows[clicked_layer_index].parent_id();
|
||||||
|
*active_layer_id = Some(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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2895,6 +2966,7 @@ impl TimelinePane {
|
||||||
} else {
|
} else {
|
||||||
selection.select_only_clip_instance(clip_id);
|
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
|
// Start dragging with the detected drag type
|
||||||
|
|
@ -2915,6 +2987,7 @@ impl TimelinePane {
|
||||||
for id in &child_ids {
|
for id in &child_ids {
|
||||||
selection.add_clip_instance(*id);
|
selection.add_clip_instance(*id);
|
||||||
}
|
}
|
||||||
|
*focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec());
|
||||||
self.clip_drag_state = Some(ClipDragType::Move);
|
self.clip_drag_state = Some(ClipDragType::Move);
|
||||||
self.drag_offset = 0.0;
|
self.drag_offset = 0.0;
|
||||||
}
|
}
|
||||||
|
|
@ -3258,10 +3331,14 @@ impl TimelinePane {
|
||||||
// Get the layer at this index (using virtual rows for group support)
|
// Get the layer at this index (using virtual rows for group support)
|
||||||
let empty_click_rows = build_timeline_rows(context_layers);
|
let empty_click_rows = build_timeline_rows(context_layers);
|
||||||
if clicked_layer_index < empty_click_rows.len() {
|
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();
|
||||||
// Clear clip instance selection when clicking on empty layer area
|
let clicked_parent = empty_click_rows[clicked_layer_index].parent_id();
|
||||||
if !shift_held {
|
*active_layer_id = Some(layer_id);
|
||||||
|
if shift_held {
|
||||||
|
shift_toggle_layer(focus, layer_id, clicked_parent, &empty_click_rows);
|
||||||
|
} else {
|
||||||
selection.clear_clip_instances();
|
selection.clear_clip_instances();
|
||||||
|
*focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3706,7 +3783,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
// Render layer header column with clipping
|
// Render layer header column with clipping
|
||||||
ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect));
|
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)
|
// Render time ruler (clip to ruler rect)
|
||||||
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
|
||||||
|
|
@ -3714,7 +3791,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
// Render layer rows with clipping
|
// Render layer rows with clipping
|
||||||
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
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)
|
// Render playhead on top (clip to timeline area)
|
||||||
ui.set_clip_rect(timeline_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(timeline_rect.intersect(original_clip_rect));
|
||||||
|
|
@ -3734,6 +3811,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
document,
|
document,
|
||||||
shared.active_layer_id,
|
shared.active_layer_id,
|
||||||
shared.selection,
|
shared.selection,
|
||||||
|
shared.focus,
|
||||||
shared.pending_actions,
|
shared.pending_actions,
|
||||||
shared.playback_time,
|
shared.playback_time,
|
||||||
shared.is_playing,
|
shared.is_playing,
|
||||||
|
|
@ -3753,6 +3831,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
if !shared.selection.contains_clip_instance(&clip_id) {
|
if !shared.selection.contains_clip_instance(&clip_id) {
|
||||||
shared.selection.select_only_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));
|
self.context_menu_clip = Some((Some(clip_id), pos));
|
||||||
} else {
|
} else {
|
||||||
// Right-clicked on empty timeline space
|
// Right-clicked on empty timeline space
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue