Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs

967 lines
40 KiB
Rust

/// 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<Uuid>,
// Shape property values (None = mixed)
fill_color: Option<Option<ShapeColor>>,
stroke_color: Option<Option<ShapeColor>>,
stroke_width: Option<f64>,
}
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<Option<ShapeColor>> = None;
let mut first_stroke_width: Option<f64> = 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<Option<ShapeColor>> = 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<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(&note) {
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"
}
}