/// Info Panel pane - displays and edits properties of selected objects /// /// Shows context-sensitive property editors based on current focus: /// - Tool options (when a tool is active) /// - 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, LayerTrait}; use lightningbeam_core::selection::FocusSelection; use lightningbeam_core::shape::ShapeColor; use lightningbeam_core::tool::{SimplifyMode, Tool}; use super::{NodePath, PaneRenderer, SharedPaneState}; use uuid::Uuid; /// Info panel pane state pub struct InfopanelPane { /// Whether the tool options section is expanded tool_section_open: bool, /// Whether the shape properties section is expanded shape_section_open: bool, } impl InfopanelPane { pub fn new() -> Self { Self { tool_section_open: true, shape_section_open: true, } } } /// Aggregated info about the current DCEL selection struct SelectionInfo { /// True if nothing is selected is_empty: bool, /// Number of selected DCEL elements (edges + faces) dcel_count: usize, /// Layer ID of selected elements (assumes single layer selection for now) layer_id: Option, // Shape property values (None = mixed) fill_color: Option>, stroke_color: Option>, stroke_width: Option, } impl Default for SelectionInfo { fn default() -> Self { Self { is_empty: true, dcel_count: 0, layer_id: None, fill_color: None, stroke_color: None, stroke_width: None, } } } impl InfopanelPane { /// Gather info about the current DCEL selection fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo { let mut info = SelectionInfo::default(); let edge_count = shared.selection.selected_edges().len(); let face_count = shared.selection.selected_faces().len(); info.dcel_count = edge_count + face_count; info.is_empty = info.dcel_count == 0; if info.is_empty { return info; } let document = shared.action_executor.document(); let active_layer_id = *shared.active_layer_id; if let Some(layer_id) = active_layer_id { info.layer_id = Some(layer_id); if let Some(layer) = document.get_layer(&layer_id) { if let AnyLayer::Vector(vector_layer) = layer { if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) { // Gather stroke properties from selected edges let mut first_stroke_color: Option> = None; let mut first_stroke_width: Option = None; let mut stroke_color_mixed = false; let mut stroke_width_mixed = false; for &eid in shared.selection.selected_edges() { let edge = dcel.edge(eid); let sc = edge.stroke_color; let sw = edge.stroke_style.as_ref().map(|s| s.width); match first_stroke_color { None => first_stroke_color = Some(sc), Some(prev) if prev != sc => stroke_color_mixed = true, _ => {} } match (first_stroke_width, sw) { (None, _) => first_stroke_width = sw, (Some(prev), Some(cur)) if (prev - cur).abs() > 0.01 => stroke_width_mixed = true, _ => {} } } if !stroke_color_mixed { info.stroke_color = first_stroke_color; } if !stroke_width_mixed { info.stroke_width = first_stroke_width; } // Gather fill properties from selected faces let mut first_fill_color: Option> = None; let mut fill_color_mixed = false; for &fid in shared.selection.selected_faces() { let face = dcel.face(fid); let fc = face.fill_color; match first_fill_color { None => first_fill_color = Some(fc), Some(prev) if prev != fc => fill_color_mixed = true, _ => {} } } if !fill_color_mixed { info.fill_color = first_fill_color; } } } } } info } /// Render tool-specific options section fn render_tool_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState) { let tool = *shared.selected_tool; let active_is_raster = shared.active_layer_id .and_then(|id| shared.action_executor.document().get_layer(&id)) .map_or(false, |l| matches!(l, AnyLayer::Raster(_))); let is_raster_paint_tool = active_is_raster && matches!(tool, Tool::Draw | Tool::Erase | Tool::Smudge); // Only show tool options for tools that have options let is_vector_tool = !active_is_raster && matches!( tool, Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Polygon ); let has_options = is_vector_tool || is_raster_paint_tool || matches!( tool, Tool::PaintBucket | Tool::RegionSelect ); if !has_options { return; } let header_label = if is_raster_paint_tool { match tool { Tool::Erase => "Eraser", Tool::Smudge => "Smudge", _ => "Brush", } } else { "Tool Options" }; egui::CollapsingHeader::new(header_label) .id_salt(("tool_options", path)) .default_open(self.tool_section_open) .show(ui, |ui| { self.tool_section_open = true; ui.add_space(4.0); if is_vector_tool { ui.checkbox(shared.snap_enabled, "Snap to Geometry"); ui.add_space(2.0); } match tool { Tool::Draw if !is_raster_paint_tool => { // Stroke width ui.horizontal(|ui| { ui.label("Stroke Width:"); ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0)); }); // Simplify mode ui.horizontal(|ui| { ui.label("Simplify:"); egui::ComboBox::from_id_salt(("draw_simplify", path)) .selected_text(match shared.draw_simplify_mode { SimplifyMode::Corners => "Corners", SimplifyMode::Smooth => "Smooth", SimplifyMode::Verbatim => "Verbatim", }) .show_ui(ui, |ui| { ui.selectable_value( shared.draw_simplify_mode, SimplifyMode::Corners, "Corners", ); ui.selectable_value( shared.draw_simplify_mode, SimplifyMode::Smooth, "Smooth", ); ui.selectable_value( shared.draw_simplify_mode, SimplifyMode::Verbatim, "Verbatim", ); }); }); // Fill shape toggle ui.checkbox(shared.fill_enabled, "Fill Shape"); } Tool::Rectangle | Tool::Ellipse => { // Stroke width ui.horizontal(|ui| { ui.label("Stroke Width:"); ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0)); }); // Fill shape toggle ui.checkbox(shared.fill_enabled, "Fill Shape"); } Tool::PaintBucket => { // Gap tolerance ui.horizontal(|ui| { ui.label("Gap Tolerance:"); ui.add( DragValue::new(shared.paint_bucket_gap_tolerance) .speed(0.1) .range(0.0..=50.0), ); }); } Tool::Polygon => { // Number of sides ui.horizontal(|ui| { ui.label("Sides:"); let mut sides = *shared.polygon_sides as i32; if ui.add(DragValue::new(&mut sides).range(3..=20)).changed() { *shared.polygon_sides = sides.max(3) as u32; } }); // Stroke width ui.horizontal(|ui| { ui.label("Stroke Width:"); ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0)); }); // Fill shape toggle ui.checkbox(shared.fill_enabled, "Fill Shape"); } Tool::Line => { // Stroke width ui.horizontal(|ui| { ui.label("Stroke Width:"); ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0)); }); } Tool::RegionSelect => { use lightningbeam_core::tool::RegionSelectMode; ui.horizontal(|ui| { ui.label("Mode:"); if ui.selectable_label( *shared.region_select_mode == RegionSelectMode::Rectangle, "Rectangle", ).clicked() { *shared.region_select_mode = RegionSelectMode::Rectangle; } if ui.selectable_label( *shared.region_select_mode == RegionSelectMode::Lasso, "Lasso", ).clicked() { *shared.region_select_mode = RegionSelectMode::Lasso; } }); } // Raster paint tools Tool::Draw | Tool::Erase | Tool::Smudge if is_raster_paint_tool => { // Color source toggle (Draw tool only) if matches!(tool, Tool::Draw) { ui.horizontal(|ui| { ui.label("Color:"); ui.selectable_value(shared.brush_use_fg, true, "FG"); ui.selectable_value(shared.brush_use_fg, false, "BG"); }); } ui.horizontal(|ui| { ui.label("Size:"); ui.add( egui::Slider::new(shared.brush_radius, 1.0_f32..=200.0) .logarithmic(true) .suffix(" px"), ); }); if !matches!(tool, Tool::Smudge) { ui.horizontal(|ui| { ui.label("Opacity:"); ui.add( egui::Slider::new(shared.brush_opacity, 0.0_f32..=1.0) .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)), ); }); } ui.horizontal(|ui| { ui.label("Hardness:"); ui.add( egui::Slider::new(shared.brush_hardness, 0.0_f32..=1.0) .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)), ); }); ui.horizontal(|ui| { ui.label("Spacing:"); ui.add( egui::Slider::new(shared.brush_spacing, 0.01_f32..=1.0) .logarithmic(true) .custom_formatter(|v, _| format!("{:.0}%", v * 100.0)), ); }); } _ => {} } ui.add_space(4.0); }); } // Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms) /// Render shape properties section (fill/stroke) fn render_shape_section( &mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, info: &SelectionInfo, ) { // Clone IDs and values we need before borrowing shared mutably let layer_id = match info.layer_id { Some(id) => id, None => return, }; let time = *shared.playback_time; let face_ids: Vec<_> = shared.selection.selected_faces().iter().copied().collect(); let edge_ids: Vec<_> = shared.selection.selected_edges().iter().copied().collect(); egui::CollapsingHeader::new("Shape") .id_salt(("shape", path)) .default_open(self.shape_section_open) .show(ui, |ui| { self.shape_section_open = true; ui.add_space(4.0); // Fill color ui.horizontal(|ui| { ui.label("Fill:"); match info.fill_color { Some(Some(color)) => { let mut rgba = [color.r, color.g, color.b, color.a]; if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() { let new_color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]); let action = SetShapePropertiesAction::set_fill_color( layer_id, time, face_ids.clone(), Some(new_color), ); shared.pending_actions.push(Box::new(action)); } } Some(None) => { ui.label("None"); } None => { ui.label("--"); } } }); // Stroke color ui.horizontal(|ui| { ui.label("Stroke:"); match info.stroke_color { Some(Some(color)) => { let mut rgba = [color.r, color.g, color.b, color.a]; if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() { let new_color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]); let action = SetShapePropertiesAction::set_stroke_color( layer_id, time, edge_ids.clone(), Some(new_color), ); shared.pending_actions.push(Box::new(action)); } } Some(None) => { ui.label("None"); } None => { ui.label("--"); } } }); // Stroke width ui.horizontal(|ui| { ui.label("Stroke Width:"); match info.stroke_width { Some(mut width) => { if ui.add( DragValue::new(&mut width) .speed(0.1) .range(0.1..=100.0), ).changed() { let action = SetShapePropertiesAction::set_stroke_width( layer_id, time, edge_ids.clone(), width, ); shared.pending_actions.push(Box::new(action)); } } None => { ui.label("--"); } } }); ui.add_space(4.0); }); } /// 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)) .default_open(true) .show(ui, |ui| { ui.add_space(4.0); let document = shared.action_executor.document(); // Get current values for editing let mut width = document.width; let mut height = document.height; let mut duration = document.duration; let mut framerate = document.framerate; let layer_count = document.root.children.len(); // Canvas width ui.horizontal(|ui| { ui.label("Width:"); if ui .add(DragValue::new(&mut width).speed(1.0).range(1.0..=10000.0)) .changed() { let action = SetDocumentPropertiesAction::set_width(width); shared.pending_actions.push(Box::new(action)); } }); // Canvas height ui.horizontal(|ui| { ui.label("Height:"); if ui .add(DragValue::new(&mut height).speed(1.0).range(1.0..=10000.0)) .changed() { let action = SetDocumentPropertiesAction::set_height(height); shared.pending_actions.push(Box::new(action)); } }); // Duration ui.horizontal(|ui| { ui.label("Duration:"); if ui .add( DragValue::new(&mut duration) .speed(0.1) .range(0.1..=3600.0) .suffix("s"), ) .changed() { let action = SetDocumentPropertiesAction::set_duration(duration); shared.pending_actions.push(Box::new(action)); } }); // Framerate ui.horizontal(|ui| { ui.label("Framerate:"); if ui .add( DragValue::new(&mut framerate) .speed(1.0) .range(1.0..=120.0) .suffix(" fps"), ) .changed() { let action = SetDocumentPropertiesAction::set_framerate(framerate); shared.pending_actions.push(Box::new(action)); } }); // Background color (with alpha) ui.horizontal(|ui| { ui.label("Background:"); let bg = document.background_color; let mut color = [bg.r, bg.g, bg.b, bg.a]; if ui.color_edit_button_srgba_unmultiplied(&mut color).changed() { let action = SetDocumentPropertiesAction::set_background_color( ShapeColor::rgba(color[0], color[1], color[2], color[3]), ); shared.pending_actions.push(Box::new(action)); } }); // Layer count (read-only) ui.horizontal(|ui| { ui.label("Layers:"); ui.label(format!("{}", layer_count)); }); 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", AnyLayer::Raster(_) => "Raster", }; 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(_) => &[], AnyLayer::Raster(_) => &[], }; if let Some(ci) = instances.iter().find(|c| c.id == ci_id) { found = true; if let Some(name) = &ci.name { ui.horizontal(|ui| { ui.label("Name:"); ui.label(name.as_str()); }); } // Show clip name based on type let clip_name = document.get_vector_clip(&ci.clip_id).map(|c| c.name.as_str()) .or_else(|| document.get_video_clip(&ci.clip_id).map(|c| c.name.as_str())) .or_else(|| document.get_audio_clip(&ci.clip_id).map(|c| c.name.as_str())); if let Some(name) = clip_name { ui.horizontal(|ui| { ui.label("Clip:"); ui.label(name); }); } ui.horizontal(|ui| { ui.label("Start:"); ui.label(format!("{:.2}s", ci.effective_start())); }); let clip_dur = document.get_clip_duration(&ci.clip_id) .unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start); let total_dur = ci.total_duration(clip_dur); ui.horizontal(|ui| { ui.label("Duration:"); ui.label(format!("{:.2}s", total_dur)); }); if ci.trim_start > 0.0 { ui.horizontal(|ui| { ui.label("Trim Start:"); ui.label(format!("{:.2}s", ci.trim_start)); }); } if ci.playback_speed != 1.0 { ui.horizontal(|ui| { ui.label("Speed:"); ui.label(format!("{:.2}x", ci.playback_speed)); }); } break; } } if !found { ui.label("Clip instance not found"); } } else { ui.label(format!("{} clip instances selected", clip_ids.len())); } ui.add_space(4.0); }); } /// Render MIDI note info section fn render_notes_section( &self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, layer_id: Uuid, midi_clip_id: u32, indices: &[usize], ) { egui::CollapsingHeader::new("Notes") .id_salt(("notes_info", path)) .default_open(true) .show(ui, |ui| { ui.add_space(4.0); // Show layer name let document = shared.action_executor.document(); if let Some(layer) = document.get_layer(&layer_id) { ui.horizontal(|ui| { ui.label("Layer:"); ui.label(layer.name()); }); } if indices.len() == 1 { // Single note — show details if we can resolve from the event cache if let Some(events) = shared.midi_event_cache.get(&midi_clip_id) { // Events are (time, note, velocity, is_on) — resolve to notes let mut notes: Vec<(f64, u8, u8, f64)> = Vec::new(); // (time, note, vel, dur) let mut pending: std::collections::HashMap = std::collections::HashMap::new(); for &(time, note, vel, is_on) in events { if is_on { pending.insert(note, (time, vel)); } else if let Some((start, v)) = pending.remove(¬e) { notes.push((start, note, v, time - start)); } } notes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); let idx = indices[0]; if idx < notes.len() { let (time, note, vel, dur) = notes[idx]; let note_name = midi_note_name(note); ui.horizontal(|ui| { ui.label("Note:"); ui.label(format!("{} ({})", note_name, note)); }); ui.horizontal(|ui| { ui.label("Time:"); ui.label(format!("{:.3}s", time)); }); ui.horizontal(|ui| { ui.label("Duration:"); ui.label(format!("{:.3}s", dur)); }); ui.horizontal(|ui| { ui.label("Velocity:"); ui.label(format!("{}", vel)); }); } } } else { ui.label(format!("{} notes selected", indices.len())); } ui.add_space(4.0); }); } /// Render node graph info section fn render_nodes_section(&self, ui: &mut Ui, path: &NodePath, node_indices: &[u32]) { egui::CollapsingHeader::new("Nodes") .id_salt(("nodes_info", path)) .default_open(true) .show(ui, |ui| { ui.add_space(4.0); ui.label(format!( "{} node{} selected", node_indices.len(), if node_indices.len() == 1 { "" } else { "s" } )); ui.add_space(4.0); }); } /// Render asset info section fn render_asset_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, asset_ids: &[Uuid]) { let document = shared.action_executor.document(); egui::CollapsingHeader::new("Asset") .id_salt(("asset_info", path)) .default_open(true) .show(ui, |ui| { ui.add_space(4.0); if asset_ids.len() == 1 { let id = asset_ids[0]; if let Some(clip) = document.get_vector_clip(&id) { ui.horizontal(|ui| { ui.label("Name:"); ui.label(&clip.name); }); ui.horizontal(|ui| { ui.label("Type:"); ui.label("Vector"); }); ui.horizontal(|ui| { ui.label("Size:"); ui.label(format!("{:.0} x {:.0}", clip.width, clip.height)); }); ui.horizontal(|ui| { ui.label("Duration:"); ui.label(format!("{:.2}s", clip.duration)); }); } else if let Some(clip) = document.get_video_clip(&id) { ui.horizontal(|ui| { ui.label("Name:"); ui.label(&clip.name); }); ui.horizontal(|ui| { ui.label("Type:"); ui.label("Video"); }); ui.horizontal(|ui| { ui.label("Size:"); ui.label(format!("{:.0} x {:.0}", clip.width, clip.height)); }); ui.horizontal(|ui| { ui.label("Duration:"); ui.label(format!("{:.2}s", clip.duration)); }); ui.horizontal(|ui| { ui.label("Frame Rate:"); ui.label(format!("{:.1} fps", clip.frame_rate)); }); } else if let Some(clip) = document.get_audio_clip(&id) { ui.horizontal(|ui| { ui.label("Name:"); ui.label(&clip.name); }); let type_name = match &clip.clip_type { lightningbeam_core::clip::AudioClipType::Sampled { .. } => "Audio (Sampled)", lightningbeam_core::clip::AudioClipType::Midi { .. } => "Audio (MIDI)", lightningbeam_core::clip::AudioClipType::Recording => "Audio (Recording)", }; ui.horizontal(|ui| { ui.label("Type:"); ui.label(type_name); }); ui.horizontal(|ui| { ui.label("Duration:"); ui.label(format!("{:.2}s", clip.duration)); }); } else { // Could be an image asset or effect — show ID ui.label(format!("Asset {}", id)); } } else { ui.label(format!("{} assets selected", asset_ids.len())); } ui.add_space(4.0); }); } } /// Convert MIDI note number to note name (e.g. 60 -> "C4") fn midi_note_name(note: u8) -> String { const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; let octave = (note as i32 / 12) - 1; let name = NAMES[note as usize % 12]; format!("{}{}", name, octave) } impl PaneRenderer for InfopanelPane { fn render_content( &mut self, ui: &mut egui::Ui, rect: egui::Rect, path: &NodePath, shared: &mut SharedPaneState, ) { // Background ui.painter().rect_filled( rect, 0.0, egui::Color32::from_rgb(30, 35, 40), ); // Create scrollable area for content let content_rect = rect.shrink(8.0); let mut content_ui = ui.new_child( egui::UiBuilder::new() .max_rect(content_rect) .layout(egui::Layout::top_down(egui::Align::LEFT)), ); egui::ScrollArea::vertical() .id_salt(("infopanel_scroll", path)) .show(&mut content_ui, |ui| { ui.set_min_width(content_rect.width() - 16.0); // 1. Tool options section (always shown if tool has options) self.render_tool_section(ui, path, shared); // 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); } } } }); } fn name(&self) -> &str { "Info Panel" } }