add top-level selection
This commit is contained in:
parent
78577babb1
commit
13840ee45f
|
|
@ -8,6 +8,49 @@ use std::collections::HashSet;
|
|||
use uuid::Uuid;
|
||||
use vello::kurbo::{Affine, BezPath};
|
||||
|
||||
/// Tracks the most recently selected thing(s) across the entire document.
|
||||
///
|
||||
/// Lightweight overlay on top of per-domain selection state. Tells consumers
|
||||
/// "the user's attention is on this kind of thing" — for properties panels,
|
||||
/// delete/copy/paste dispatch, group commands, etc.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub enum FocusSelection {
|
||||
#[default]
|
||||
None,
|
||||
/// One or more layers selected (by UUID)
|
||||
Layers(Vec<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
|
||||
///
|
||||
/// Maintains sets of selected DCEL elements and clip instances.
|
||||
|
|
|
|||
|
|
@ -739,6 +739,7 @@ struct EditorApp {
|
|||
action_executor: lightningbeam_core::action::ActionExecutor, // Action system for undo/redo
|
||||
active_layer_id: Option<Uuid>, // Currently active layer for editing
|
||||
selection: lightningbeam_core::selection::Selection, // Current selection state
|
||||
focus: lightningbeam_core::selection::FocusSelection, // Document-level focus tracking
|
||||
editing_context: EditingContext, // Which clip (or root) we're editing
|
||||
tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state
|
||||
// Draw tool configuration
|
||||
|
|
@ -1006,6 +1007,7 @@ impl EditorApp {
|
|||
action_executor,
|
||||
active_layer_id: Some(layer_id),
|
||||
selection: lightningbeam_core::selection::Selection::new(),
|
||||
focus: lightningbeam_core::selection::FocusSelection::None,
|
||||
editing_context: EditingContext::default(),
|
||||
tool_state: lightningbeam_core::tool::ToolState::default(),
|
||||
draw_simplify_mode: lightningbeam_core::tool::SimplifyMode::Smooth, // Default to smooth curves
|
||||
|
|
@ -1385,6 +1387,7 @@ impl EditorApp {
|
|||
|
||||
// Reset selection and set active layer to the newly created one
|
||||
self.selection = lightningbeam_core::selection::Selection::new();
|
||||
self.focus = lightningbeam_core::selection::FocusSelection::None;
|
||||
self.active_layer_id = Some(layer_id);
|
||||
|
||||
// For Music focus, sync the MIDI layer with daw-backend
|
||||
|
|
@ -4986,6 +4989,7 @@ impl eframe::App for EditorApp {
|
|||
theme: &self.theme,
|
||||
action_executor: &mut self.action_executor,
|
||||
selection: &mut self.selection,
|
||||
focus: &mut self.focus,
|
||||
editing_clip_id: self.editing_context.current_clip_id(),
|
||||
editing_instance_id: self.editing_context.current_instance_id(),
|
||||
editing_parent_layer_id: self.editing_context.current_parent_layer_id(),
|
||||
|
|
@ -5500,6 +5504,7 @@ struct RenderContext<'a> {
|
|||
theme: &'a Theme,
|
||||
action_executor: &'a mut lightningbeam_core::action::ActionExecutor,
|
||||
selection: &'a mut lightningbeam_core::selection::Selection,
|
||||
focus: &'a mut lightningbeam_core::selection::FocusSelection,
|
||||
editing_clip_id: Option<Uuid>,
|
||||
editing_instance_id: Option<Uuid>,
|
||||
editing_parent_layer_id: Option<Uuid>,
|
||||
|
|
@ -6028,6 +6033,7 @@ fn render_pane(
|
|||
pending_handlers: ctx.pending_handlers,
|
||||
action_executor: ctx.action_executor,
|
||||
selection: ctx.selection,
|
||||
focus: ctx.focus,
|
||||
active_layer_id: ctx.active_layer_id,
|
||||
tool_state: ctx.tool_state,
|
||||
pending_actions: ctx.pending_actions,
|
||||
|
|
@ -6118,6 +6124,7 @@ fn render_pane(
|
|||
pending_handlers: ctx.pending_handlers,
|
||||
action_executor: ctx.action_executor,
|
||||
selection: ctx.selection,
|
||||
focus: ctx.focus,
|
||||
active_layer_id: ctx.active_layer_id,
|
||||
tool_state: ctx.tool_state,
|
||||
pending_actions: ctx.pending_actions,
|
||||
|
|
|
|||
|
|
@ -1858,6 +1858,7 @@ impl AssetLibraryPane {
|
|||
// Handle interactions
|
||||
if response.clicked() {
|
||||
self.selected_asset = Some(asset.id);
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::Assets(vec![asset.id]);
|
||||
}
|
||||
|
||||
if response.secondary_clicked() {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
/// Info Panel pane - displays and edits properties of selected objects
|
||||
///
|
||||
/// Shows context-sensitive property editors based on current selection:
|
||||
/// Shows context-sensitive property editors based on current focus:
|
||||
/// - Tool options (when a tool is active)
|
||||
/// - Transform properties (when shapes are selected)
|
||||
/// - Shape properties (fill/stroke for selected shapes)
|
||||
/// - Document settings (when nothing is selected)
|
||||
/// - Layer properties (when layers are focused)
|
||||
/// - Clip instance properties (when clip instances are focused)
|
||||
/// - Shape properties (fill/stroke for selected geometry)
|
||||
/// - Note info (when piano roll notes are focused)
|
||||
/// - Node info (when node graph nodes are focused)
|
||||
/// - Asset info (when asset library items are focused)
|
||||
/// - Document settings (when nothing is focused)
|
||||
|
||||
use eframe::egui::{self, DragValue, Ui};
|
||||
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction};
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use lightningbeam_core::layer::{AnyLayer, LayerTrait};
|
||||
use lightningbeam_core::selection::FocusSelection;
|
||||
use lightningbeam_core::shape::ShapeColor;
|
||||
use lightningbeam_core::tool::{SimplifyMode, Tool};
|
||||
use super::{NodePath, PaneRenderer, SharedPaneState};
|
||||
|
|
@ -31,7 +36,7 @@ impl InfopanelPane {
|
|||
}
|
||||
}
|
||||
|
||||
/// Aggregated info about the current selection
|
||||
/// Aggregated info about the current DCEL selection
|
||||
struct SelectionInfo {
|
||||
/// True if nothing is selected
|
||||
is_empty: bool,
|
||||
|
|
@ -60,7 +65,7 @@ impl Default for SelectionInfo {
|
|||
}
|
||||
|
||||
impl InfopanelPane {
|
||||
/// Gather info about the current selection
|
||||
/// Gather info about the current DCEL selection
|
||||
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
|
||||
let mut info = SelectionInfo::default();
|
||||
|
||||
|
|
@ -406,7 +411,7 @@ impl InfopanelPane {
|
|||
});
|
||||
}
|
||||
|
||||
/// Render document settings section (shown when nothing is selected)
|
||||
/// Render document settings section (shown when nothing is focused)
|
||||
fn render_document_section(&self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState) {
|
||||
egui::CollapsingHeader::new("Document")
|
||||
.id_salt(("document", path))
|
||||
|
|
@ -503,6 +508,328 @@ impl InfopanelPane {
|
|||
ui.add_space(4.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render layer info section
|
||||
fn render_layer_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, layer_ids: &[Uuid]) {
|
||||
let document = shared.action_executor.document();
|
||||
|
||||
egui::CollapsingHeader::new("Layer")
|
||||
.id_salt(("layer_info", path))
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(4.0);
|
||||
|
||||
if layer_ids.len() == 1 {
|
||||
if let Some(layer) = document.get_layer(&layer_ids[0]) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name:");
|
||||
ui.label(layer.name());
|
||||
});
|
||||
|
||||
let type_name = match layer {
|
||||
AnyLayer::Vector(_) => "Vector",
|
||||
AnyLayer::Audio(a) => match a.audio_layer_type {
|
||||
lightningbeam_core::layer::AudioLayerType::Midi => "MIDI",
|
||||
lightningbeam_core::layer::AudioLayerType::Sampled => "Audio",
|
||||
},
|
||||
AnyLayer::Video(_) => "Video",
|
||||
AnyLayer::Effect(_) => "Effect",
|
||||
AnyLayer::Group(_) => "Group",
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Type:");
|
||||
ui.label(type_name);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Opacity:");
|
||||
ui.label(format!("{:.0}%", layer.opacity() * 100.0));
|
||||
});
|
||||
|
||||
if matches!(layer, AnyLayer::Audio(_)) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Volume:");
|
||||
ui.label(format!("{:.0}%", layer.volume() * 100.0));
|
||||
});
|
||||
}
|
||||
|
||||
if layer.muted() {
|
||||
ui.label("Muted");
|
||||
}
|
||||
if layer.locked() {
|
||||
ui.label("Locked");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui.label(format!("{} layers selected", layer_ids.len()));
|
||||
}
|
||||
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render clip instance info section
|
||||
fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) {
|
||||
let document = shared.action_executor.document();
|
||||
|
||||
egui::CollapsingHeader::new("Clip Instance")
|
||||
.id_salt(("clip_instance_info", path))
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(4.0);
|
||||
|
||||
if clip_ids.len() == 1 {
|
||||
// Find the clip instance across all layers
|
||||
let ci_id = clip_ids[0];
|
||||
let mut found = false;
|
||||
|
||||
for layer in document.all_layers() {
|
||||
let instances: &[lightningbeam_core::clip::ClipInstance] = match layer {
|
||||
AnyLayer::Vector(l) => &l.clip_instances,
|
||||
AnyLayer::Audio(l) => &l.clip_instances,
|
||||
AnyLayer::Video(l) => &l.clip_instances,
|
||||
AnyLayer::Effect(l) => &l.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
if let Some(ci) = instances.iter().find(|c| c.id == ci_id) {
|
||||
found = true;
|
||||
|
||||
if let Some(name) = &ci.name {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name:");
|
||||
ui.label(name.as_str());
|
||||
});
|
||||
}
|
||||
|
||||
// Show clip name based on type
|
||||
let clip_name = document.get_vector_clip(&ci.clip_id).map(|c| c.name.as_str())
|
||||
.or_else(|| document.get_video_clip(&ci.clip_id).map(|c| c.name.as_str()))
|
||||
.or_else(|| document.get_audio_clip(&ci.clip_id).map(|c| c.name.as_str()));
|
||||
if let Some(name) = clip_name {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Clip:");
|
||||
ui.label(name);
|
||||
});
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Start:");
|
||||
ui.label(format!("{:.2}s", ci.effective_start()));
|
||||
});
|
||||
|
||||
let clip_dur = document.get_clip_duration(&ci.clip_id)
|
||||
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start);
|
||||
let total_dur = ci.total_duration(clip_dur);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Duration:");
|
||||
ui.label(format!("{:.2}s", total_dur));
|
||||
});
|
||||
|
||||
if ci.trim_start > 0.0 {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Trim Start:");
|
||||
ui.label(format!("{:.2}s", ci.trim_start));
|
||||
});
|
||||
}
|
||||
|
||||
if ci.playback_speed != 1.0 {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Speed:");
|
||||
ui.label(format!("{:.2}x", ci.playback_speed));
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
ui.label("Clip instance not found");
|
||||
}
|
||||
} else {
|
||||
ui.label(format!("{} clip instances selected", clip_ids.len()));
|
||||
}
|
||||
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render MIDI note info section
|
||||
fn render_notes_section(
|
||||
&self,
|
||||
ui: &mut Ui,
|
||||
path: &NodePath,
|
||||
shared: &SharedPaneState,
|
||||
layer_id: Uuid,
|
||||
midi_clip_id: u32,
|
||||
indices: &[usize],
|
||||
) {
|
||||
egui::CollapsingHeader::new("Notes")
|
||||
.id_salt(("notes_info", path))
|
||||
.default_open(true)
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Show layer name
|
||||
let document = shared.action_executor.document();
|
||||
if let Some(layer) = document.get_layer(&layer_id) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Layer:");
|
||||
ui.label(layer.name());
|
||||
});
|
||||
}
|
||||
|
||||
if indices.len() == 1 {
|
||||
// Single note — show details if we can resolve from the event cache
|
||||
if let Some(events) = shared.midi_event_cache.get(&midi_clip_id) {
|
||||
// Events are (time, note, velocity, is_on) — resolve to notes
|
||||
let mut notes: Vec<(f64, u8, u8, f64)> = Vec::new(); // (time, note, vel, dur)
|
||||
let mut pending: std::collections::HashMap<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 {
|
||||
|
|
@ -536,29 +863,59 @@ impl PaneRenderer for InfopanelPane {
|
|||
// 1. Tool options section (always shown if tool has options)
|
||||
self.render_tool_section(ui, path, shared);
|
||||
|
||||
// 2. Gather selection info
|
||||
let info = self.gather_selection_info(shared);
|
||||
|
||||
// 3. Shape properties section (if DCEL elements selected)
|
||||
if info.dcel_count > 0 {
|
||||
self.render_shape_section(ui, path, shared, &info);
|
||||
}
|
||||
|
||||
// 5. Document settings (if nothing selected)
|
||||
if info.is_empty {
|
||||
self.render_document_section(ui, path, shared);
|
||||
}
|
||||
|
||||
// Show selection count at bottom
|
||||
if info.dcel_count > 0 {
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
ui.label(format!(
|
||||
"{} object{} selected",
|
||||
info.dcel_count,
|
||||
if info.dcel_count == 1 { "" } else { "s" }
|
||||
));
|
||||
// 2. Focus-driven content
|
||||
// Clone focus to avoid borrow issues with shared
|
||||
let focus = shared.focus.clone();
|
||||
match &focus {
|
||||
FocusSelection::Layers(ids) => {
|
||||
self.render_layer_section(ui, path, shared, ids);
|
||||
}
|
||||
FocusSelection::ClipInstances(ids) => {
|
||||
self.render_clip_instance_section(ui, path, shared, ids);
|
||||
}
|
||||
FocusSelection::Geometry { .. } => {
|
||||
let info = self.gather_selection_info(shared);
|
||||
if info.dcel_count > 0 {
|
||||
self.render_shape_section(ui, path, shared, &info);
|
||||
}
|
||||
// Selection count
|
||||
if info.dcel_count > 0 {
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
ui.label(format!(
|
||||
"{} object{} selected",
|
||||
info.dcel_count,
|
||||
if info.dcel_count == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
}
|
||||
FocusSelection::Notes { layer_id, midi_clip_id, indices } => {
|
||||
self.render_notes_section(ui, path, shared, *layer_id, *midi_clip_id, indices);
|
||||
}
|
||||
FocusSelection::Nodes(indices) => {
|
||||
self.render_nodes_section(ui, path, indices);
|
||||
}
|
||||
FocusSelection::Assets(ids) => {
|
||||
self.render_asset_section(ui, path, shared, ids);
|
||||
}
|
||||
FocusSelection::None => {
|
||||
// Fallback: check if there's a DCEL selection even without focus
|
||||
let info = self.gather_selection_info(shared);
|
||||
if info.dcel_count > 0 {
|
||||
self.render_shape_section(ui, path, shared, &info);
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
ui.label(format!(
|
||||
"{} object{} selected",
|
||||
info.dcel_count,
|
||||
if info.dcel_count == 1 { "" } else { "s" }
|
||||
));
|
||||
} else {
|
||||
self.render_document_section(ui, path, shared);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ pub struct SharedPaneState<'a> {
|
|||
pub action_executor: &'a mut lightningbeam_core::action::ActionExecutor,
|
||||
/// Current selection state (mutable for tools to modify)
|
||||
pub selection: &'a mut lightningbeam_core::selection::Selection,
|
||||
/// Document-level focus: tracks the most recently selected thing(s) of any type
|
||||
pub focus: &'a mut lightningbeam_core::selection::FocusSelection,
|
||||
/// Which VectorClip is being edited (None = document root)
|
||||
pub editing_clip_id: Option<uuid::Uuid>,
|
||||
/// The clip instance ID being edited
|
||||
|
|
|
|||
|
|
@ -2580,6 +2580,17 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
self.last_node_rects = graph_response.node_rects.clone();
|
||||
self.handle_graph_response(graph_response, shared, graph_rect);
|
||||
|
||||
// Sync document-level focus with node graph selection
|
||||
if !self.state.selected_nodes.is_empty() {
|
||||
let node_indices: Vec<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()
|
||||
if let Some(load) = self.user_state.pending_sampler_load.take() {
|
||||
self.handle_pending_sampler_load(load, shared);
|
||||
|
|
|
|||
|
|
@ -864,6 +864,7 @@ impl PianoRollPane {
|
|||
self.selected_note_indices.clear();
|
||||
}
|
||||
self.selected_note_indices.insert(note_idx);
|
||||
self.update_focus(shared);
|
||||
|
||||
self.drag_mode = Some(DragMode::MoveNotes {
|
||||
start_time_offset: 0.0,
|
||||
|
|
@ -915,6 +916,7 @@ impl PianoRollPane {
|
|||
} else {
|
||||
// Start selection rectangle
|
||||
self.selected_note_indices.clear();
|
||||
self.update_focus(shared);
|
||||
self.selection_rect = Some((pos, pos));
|
||||
self.drag_mode = Some(DragMode::SelectRect);
|
||||
}
|
||||
|
|
@ -1007,6 +1009,7 @@ impl PianoRollPane {
|
|||
}
|
||||
Some(DragMode::SelectRect) => {
|
||||
self.selection_rect = None;
|
||||
self.update_focus(shared);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
|
@ -1086,6 +1089,18 @@ impl PianoRollPane {
|
|||
None
|
||||
}
|
||||
|
||||
fn update_focus(&self, shared: &mut SharedPaneState) {
|
||||
if self.selected_note_indices.is_empty() {
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::None;
|
||||
} else if let (Some(layer_id), Some(midi_clip_id)) = (*shared.active_layer_id, self.selected_clip_id) {
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::Notes {
|
||||
layer_id,
|
||||
midi_clip_id,
|
||||
indices: self.selected_note_indices.iter().copied().collect(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn update_selection_from_rect(
|
||||
&mut self,
|
||||
grid_rect: Rect,
|
||||
|
|
|
|||
|
|
@ -2529,6 +2529,9 @@ impl StagePane {
|
|||
shared.selection.select_edge(edge_id, dcel);
|
||||
}
|
||||
}
|
||||
if let Some(layer_id) = *shared.active_layer_id {
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time };
|
||||
}
|
||||
// DCEL element dragging deferred to Phase 3
|
||||
}
|
||||
hit_test::HitResult::Face(face_id) => {
|
||||
|
|
@ -2541,6 +2544,9 @@ impl StagePane {
|
|||
shared.selection.select_face(face_id, dcel);
|
||||
}
|
||||
}
|
||||
if let Some(layer_id) = *shared.active_layer_id {
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time };
|
||||
}
|
||||
// DCEL element dragging deferred to Phase 3
|
||||
}
|
||||
hit_test::HitResult::ClipInstance(clip_id) => {
|
||||
|
|
@ -2554,6 +2560,7 @@ impl StagePane {
|
|||
shared.selection.select_only_clip_instance(clip_id);
|
||||
}
|
||||
}
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec());
|
||||
|
||||
// If clip instance is now selected, prepare for dragging
|
||||
if shared.selection.contains_clip_instance(&clip_id) {
|
||||
|
|
@ -2582,6 +2589,7 @@ impl StagePane {
|
|||
// Nothing hit - start marquee selection
|
||||
if !shift_held {
|
||||
shared.selection.clear();
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::None;
|
||||
}
|
||||
|
||||
*shared.tool_state = ToolState::MarqueeSelecting {
|
||||
|
|
@ -2644,6 +2652,9 @@ impl StagePane {
|
|||
}
|
||||
}
|
||||
}
|
||||
if let Some(layer_id) = *shared.active_layer_id {
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time };
|
||||
}
|
||||
*shared.tool_state = ToolState::Idle;
|
||||
}
|
||||
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. } => {
|
||||
|
|
@ -2757,6 +2768,15 @@ impl StagePane {
|
|||
}
|
||||
}
|
||||
|
||||
// Update focus based on what was selected
|
||||
if shared.selection.has_dcel_selection() {
|
||||
if let Some(layer_id) = *shared.active_layer_id {
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time };
|
||||
}
|
||||
} else if !shared.selection.clip_instances().is_empty() {
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec());
|
||||
}
|
||||
|
||||
// Reset tool state
|
||||
*shared.tool_state = ToolState::Idle;
|
||||
}
|
||||
|
|
@ -6041,6 +6061,15 @@ impl StagePane {
|
|||
}
|
||||
}
|
||||
|
||||
// Update focus based on what was selected
|
||||
if shared.selection.has_dcel_selection() {
|
||||
if let Some(layer_id) = *shared.active_layer_id {
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::Geometry { layer_id, time: *shared.playback_time };
|
||||
}
|
||||
} else if !shared.selection.clip_instances().is_empty() {
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec());
|
||||
}
|
||||
|
||||
*shared.tool_state = ToolState::Idle;
|
||||
}
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -2724,6 +2724,7 @@ impl TimelinePane {
|
|||
document: &lightningbeam_core::document::Document,
|
||||
active_layer_id: &mut Option<uuid::Uuid>,
|
||||
selection: &mut lightningbeam_core::selection::Selection,
|
||||
focus: &mut lightningbeam_core::selection::FocusSelection,
|
||||
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||
playback_time: &mut f64,
|
||||
_is_playing: &mut bool,
|
||||
|
|
@ -2791,6 +2792,7 @@ impl TimelinePane {
|
|||
}
|
||||
}
|
||||
*active_layer_id = Some(click_row.layer_id());
|
||||
*focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec());
|
||||
clicked_clip_instance = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -2838,6 +2840,7 @@ impl TimelinePane {
|
|||
}
|
||||
// Also set this layer as the active layer
|
||||
*active_layer_id = Some(layer.id());
|
||||
*focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec());
|
||||
clicked_clip_instance = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -2864,7 +2867,9 @@ impl TimelinePane {
|
|||
// Get the layer at this index (using virtual rows for group support)
|
||||
let header_rows = build_timeline_rows(context_layers);
|
||||
if clicked_layer_index < header_rows.len() {
|
||||
*active_layer_id = Some(header_rows[clicked_layer_index].layer_id());
|
||||
let layer_id = header_rows[clicked_layer_index].layer_id();
|
||||
*active_layer_id = Some(layer_id);
|
||||
*focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2895,6 +2900,7 @@ impl TimelinePane {
|
|||
} else {
|
||||
selection.select_only_clip_instance(clip_id);
|
||||
}
|
||||
*focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec());
|
||||
}
|
||||
|
||||
// Start dragging with the detected drag type
|
||||
|
|
@ -2915,6 +2921,7 @@ impl TimelinePane {
|
|||
for id in &child_ids {
|
||||
selection.add_clip_instance(*id);
|
||||
}
|
||||
*focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec());
|
||||
self.clip_drag_state = Some(ClipDragType::Move);
|
||||
self.drag_offset = 0.0;
|
||||
}
|
||||
|
|
@ -3258,11 +3265,13 @@ impl TimelinePane {
|
|||
// Get the layer at this index (using virtual rows for group support)
|
||||
let empty_click_rows = build_timeline_rows(context_layers);
|
||||
if clicked_layer_index < empty_click_rows.len() {
|
||||
*active_layer_id = Some(empty_click_rows[clicked_layer_index].layer_id());
|
||||
let layer_id = empty_click_rows[clicked_layer_index].layer_id();
|
||||
*active_layer_id = Some(layer_id);
|
||||
// Clear clip instance selection when clicking on empty layer area
|
||||
if !shift_held {
|
||||
selection.clear_clip_instances();
|
||||
}
|
||||
*focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3734,6 +3743,7 @@ impl PaneRenderer for TimelinePane {
|
|||
document,
|
||||
shared.active_layer_id,
|
||||
shared.selection,
|
||||
shared.focus,
|
||||
shared.pending_actions,
|
||||
shared.playback_time,
|
||||
shared.is_playing,
|
||||
|
|
@ -3753,6 +3763,7 @@ impl PaneRenderer for TimelinePane {
|
|||
if !shared.selection.contains_clip_instance(&clip_id) {
|
||||
shared.selection.select_only_clip_instance(clip_id);
|
||||
}
|
||||
*shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec());
|
||||
self.context_menu_clip = Some((Some(clip_id), pos));
|
||||
} else {
|
||||
// Right-clicked on empty timeline space
|
||||
|
|
|
|||
Loading…
Reference in New Issue