1934 lines
89 KiB
Rust
1934 lines
89 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::brush_settings::bundled_brushes;
|
||
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction, SetImageFillAction};
|
||
use lightningbeam_core::gradient::ShapeGradient;
|
||
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 super::gradient_editor::gradient_stop_editor;
|
||
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,
|
||
/// Index of the selected paint brush preset (None = custom / unset)
|
||
selected_brush_preset: Option<usize>,
|
||
/// Whether the paint brush picker is expanded
|
||
brush_picker_expanded: bool,
|
||
/// Index of the selected eraser brush preset
|
||
selected_eraser_preset: Option<usize>,
|
||
/// Whether the eraser brush picker is expanded
|
||
eraser_picker_expanded: bool,
|
||
/// Cached preview textures, one per preset (populated lazily).
|
||
brush_preview_textures: Vec<egui::TextureHandle>,
|
||
/// Selected stop index for gradient editor in shape section.
|
||
selected_shape_gradient_stop: Option<usize>,
|
||
/// Selected stop index for gradient editor in tool section (gradient tool).
|
||
selected_tool_gradient_stop: Option<usize>,
|
||
/// FPS value captured when a drag/focus-in starts (for single-undo-action on commit)
|
||
fps_drag_start: Option<f64>,
|
||
/// In-progress layer-name edit buffer, keyed by the layer being edited.
|
||
layer_name_edit: Option<(Uuid, String)>,
|
||
/// Layer opacity/volume captured when a slider drag starts (single-undo on commit).
|
||
layer_opacity_drag_start: Option<f64>,
|
||
layer_volume_drag_start: Option<f64>,
|
||
/// Resize mode for the active raster layer's "to document size" action (scale vs canvas).
|
||
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode,
|
||
/// Font families already registered into egui for the family-picker previews
|
||
/// (rendered each entry in its own font), so we only load each once.
|
||
registered_preview_fonts: std::collections::HashSet<String>,
|
||
}
|
||
|
||
impl InfopanelPane {
|
||
pub fn new() -> Self {
|
||
// Kick off background loading of the font-picker fonts at startup so the dropdown
|
||
// is ready without a hitch by the time the user opens it.
|
||
lightningbeam_core::fonts::start_preload();
|
||
let presets = bundled_brushes();
|
||
let default_eraser_idx = presets.iter().position(|p| p.name == "Brush");
|
||
Self {
|
||
tool_section_open: true,
|
||
shape_section_open: true,
|
||
selected_brush_preset: None,
|
||
brush_picker_expanded: false,
|
||
selected_eraser_preset: default_eraser_idx,
|
||
eraser_picker_expanded: false,
|
||
brush_preview_textures: Vec::new(),
|
||
selected_shape_gradient_stop: None,
|
||
selected_tool_gradient_stop: None,
|
||
fps_drag_start: None,
|
||
layer_name_edit: None,
|
||
layer_opacity_drag_start: None,
|
||
layer_volume_drag_start: None,
|
||
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode::Scale,
|
||
registered_preview_fonts: std::collections::HashSet::new(),
|
||
}
|
||
}
|
||
|
||
/// Register pre-loaded `bytes` for `family` into egui under `FontFamily::Name(family)`
|
||
/// so a label can be rendered in that font. Cheap (no font IO); the add is idempotent
|
||
/// and batched into one atlas rebuild at frame end, so the preview appears next frame.
|
||
fn register_font_bytes(&mut self, ctx: &egui::Context, family: &str, bytes: Vec<u8>) {
|
||
if family.is_empty() || !self.registered_preview_fonts.insert(family.to_string()) {
|
||
return;
|
||
}
|
||
ctx.add_font(egui::epaint::text::FontInsert::new(
|
||
family,
|
||
egui::FontData::from_owned(bytes),
|
||
vec![egui::epaint::text::InsertFontFamily {
|
||
family: egui::FontFamily::Name(family.into()),
|
||
priority: egui::epaint::text::FontPriority::Highest,
|
||
}],
|
||
));
|
||
}
|
||
}
|
||
|
||
/// 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>>,
|
||
/// None = mixed across selection; Some(None) = no gradient; Some(Some(g)) = all same gradient
|
||
fill_gradient: Option<Option<ShapeGradient>>,
|
||
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,
|
||
fill_gradient: 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_fills().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(graph) = vector_layer.graph_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 = graph.edge(lightningbeam_core::vector_graph::EdgeId(eid.0));
|
||
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;
|
||
let mut first_fill_gradient: Option<Option<ShapeGradient>> = None;
|
||
let mut fill_gradient_mixed = false;
|
||
|
||
for &fid in shared.selection.selected_fills() {
|
||
let fill = graph.fill(fid);
|
||
let fc = fill.color;
|
||
let fg = fill.gradient_fill.clone();
|
||
|
||
match first_fill_color {
|
||
None => first_fill_color = Some(fc),
|
||
Some(prev) if prev != fc => fill_color_mixed = true,
|
||
_ => {}
|
||
}
|
||
match &first_fill_gradient {
|
||
None => first_fill_gradient = Some(fg),
|
||
Some(prev) if *prev != fg => fill_gradient_mixed = true,
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
if !fill_color_mixed {
|
||
info.fill_color = first_fill_color;
|
||
}
|
||
if !fill_gradient_mixed {
|
||
info.fill_gradient = first_fill_gradient;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 raster_tool_def = active_is_raster.then(|| crate::tools::raster_tool_def(&tool)).flatten();
|
||
let is_raster_paint_tool = raster_tool_def.is_some();
|
||
|
||
// 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 is_raster_transform = active_is_raster
|
||
&& matches!(tool, Tool::Transform)
|
||
&& shared.selection.raster_floating.is_some();
|
||
|
||
let is_raster_select = active_is_raster && matches!(tool, Tool::Select);
|
||
let is_raster_shape = active_is_raster && matches!(
|
||
tool,
|
||
Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Polygon
|
||
);
|
||
let has_options = is_vector_tool || is_raster_paint_tool || is_raster_transform
|
||
|| is_raster_select || is_raster_shape || matches!(
|
||
tool,
|
||
Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect
|
||
| Tool::Warp | Tool::Liquify | Tool::Gradient
|
||
);
|
||
|
||
if !has_options {
|
||
return;
|
||
}
|
||
|
||
let header_label = if is_raster_transform {
|
||
"Raster Transform"
|
||
} else {
|
||
raster_tool_def.map(|d| d.header_label()).unwrap_or("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);
|
||
}
|
||
|
||
// Raster transform tool hint.
|
||
if is_raster_transform {
|
||
ui.label("Drag handles to move, scale, or rotate.");
|
||
ui.add_space(4.0);
|
||
ui.label("Enter — apply Esc — cancel");
|
||
ui.add_space(4.0);
|
||
return;
|
||
}
|
||
|
||
// Raster paint tool: delegate to per-tool impl.
|
||
if let Some(def) = raster_tool_def {
|
||
def.render_ui(ui, shared.raster_settings);
|
||
if def.show_brush_preset_picker() {
|
||
self.render_raster_tool_options(ui, shared, def.is_eraser());
|
||
}
|
||
}
|
||
|
||
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 => {
|
||
if active_is_raster {
|
||
use crate::tools::FillThresholdMode;
|
||
ui.horizontal(|ui| {
|
||
ui.label("Threshold:");
|
||
ui.add(
|
||
egui::Slider::new(
|
||
&mut shared.raster_settings.fill_threshold,
|
||
0.0_f32..=255.0,
|
||
)
|
||
.step_by(1.0),
|
||
);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Softness:");
|
||
ui.add(
|
||
egui::Slider::new(
|
||
&mut shared.raster_settings.fill_softness,
|
||
0.0_f32..=100.0,
|
||
)
|
||
.custom_formatter(|v, _| format!("{:.0}%", v)),
|
||
);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Mode:");
|
||
ui.selectable_value(
|
||
&mut shared.raster_settings.fill_threshold_mode,
|
||
FillThresholdMode::Absolute,
|
||
"Absolute",
|
||
);
|
||
ui.selectable_value(
|
||
&mut shared.raster_settings.fill_threshold_mode,
|
||
FillThresholdMode::Relative,
|
||
"Relative",
|
||
);
|
||
});
|
||
} else {
|
||
// Vector: 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::Select if is_raster_select => {
|
||
use crate::tools::SelectionShape;
|
||
ui.horizontal(|ui| {
|
||
ui.label("Shape:");
|
||
ui.selectable_value(
|
||
&mut shared.raster_settings.select_shape,
|
||
SelectionShape::Rect,
|
||
"Rectangle",
|
||
);
|
||
ui.selectable_value(
|
||
&mut shared.raster_settings.select_shape,
|
||
SelectionShape::Ellipse,
|
||
"Ellipse",
|
||
);
|
||
});
|
||
}
|
||
|
||
Tool::MagicWand => {
|
||
use crate::tools::FillThresholdMode;
|
||
ui.horizontal(|ui| {
|
||
ui.label("Threshold:");
|
||
ui.add(
|
||
egui::Slider::new(
|
||
&mut shared.raster_settings.wand_threshold,
|
||
0.0_f32..=255.0,
|
||
)
|
||
.step_by(1.0),
|
||
);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Mode:");
|
||
ui.selectable_value(
|
||
&mut shared.raster_settings.wand_mode,
|
||
FillThresholdMode::Absolute,
|
||
"Absolute",
|
||
);
|
||
ui.selectable_value(
|
||
&mut shared.raster_settings.wand_mode,
|
||
FillThresholdMode::Relative,
|
||
"Relative",
|
||
);
|
||
});
|
||
ui.checkbox(&mut shared.raster_settings.wand_contiguous, "Contiguous");
|
||
}
|
||
|
||
Tool::QuickSelect => {
|
||
use crate::tools::FillThresholdMode;
|
||
ui.horizontal(|ui| {
|
||
ui.label("Radius:");
|
||
ui.add(
|
||
egui::Slider::new(
|
||
&mut shared.raster_settings.quick_select_radius,
|
||
1.0_f32..=200.0,
|
||
)
|
||
.step_by(1.0),
|
||
);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Threshold:");
|
||
ui.add(
|
||
egui::Slider::new(
|
||
&mut shared.raster_settings.wand_threshold,
|
||
0.0_f32..=255.0,
|
||
)
|
||
.step_by(1.0),
|
||
);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Mode:");
|
||
ui.selectable_value(
|
||
&mut shared.raster_settings.wand_mode,
|
||
FillThresholdMode::Absolute,
|
||
"Absolute",
|
||
);
|
||
ui.selectable_value(
|
||
&mut shared.raster_settings.wand_mode,
|
||
FillThresholdMode::Relative,
|
||
"Relative",
|
||
);
|
||
});
|
||
}
|
||
|
||
Tool::Warp => {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Grid:");
|
||
let cols = shared.raster_settings.warp_grid_cols;
|
||
let rows = shared.raster_settings.warp_grid_rows;
|
||
for (label, c, r) in [("3×3", 3u32, 3u32), ("4×4", 4, 4), ("5×5", 5, 5), ("8×8", 8, 8)] {
|
||
let selected = cols == c && rows == r;
|
||
if ui.selectable_label(selected, label).clicked() {
|
||
shared.raster_settings.warp_grid_cols = c;
|
||
shared.raster_settings.warp_grid_rows = r;
|
||
}
|
||
}
|
||
});
|
||
ui.small("Enter to commit · Escape to cancel");
|
||
}
|
||
|
||
Tool::Liquify => {
|
||
use crate::tools::LiquifyMode;
|
||
ui.horizontal(|ui| {
|
||
ui.label("Mode:");
|
||
for (label, mode) in [
|
||
("Push", LiquifyMode::Push),
|
||
("Pucker", LiquifyMode::Pucker),
|
||
("Bloat", LiquifyMode::Bloat),
|
||
("Smooth", LiquifyMode::Smooth),
|
||
("Reconstruct", LiquifyMode::Reconstruct),
|
||
] {
|
||
let selected = shared.raster_settings.liquify_mode == mode;
|
||
if ui.selectable_label(selected, label).clicked() {
|
||
shared.raster_settings.liquify_mode = mode;
|
||
}
|
||
}
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Radius:");
|
||
ui.add(
|
||
egui::Slider::new(
|
||
&mut shared.raster_settings.liquify_radius,
|
||
5.0_f32..=500.0,
|
||
)
|
||
.step_by(1.0),
|
||
);
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Strength:");
|
||
ui.add(
|
||
egui::Slider::new(
|
||
&mut shared.raster_settings.liquify_strength,
|
||
0.01_f32..=1.0,
|
||
)
|
||
.step_by(0.01),
|
||
);
|
||
});
|
||
ui.small("Enter to commit · Escape to cancel");
|
||
}
|
||
|
||
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;
|
||
}
|
||
});
|
||
}
|
||
|
||
Tool::Gradient => {
|
||
if active_is_raster {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Opacity:");
|
||
ui.add(egui::Slider::new(
|
||
&mut shared.raster_settings.gradient_opacity,
|
||
0.0_f32..=1.0,
|
||
).custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
||
});
|
||
ui.add_space(4.0);
|
||
}
|
||
gradient_stop_editor(
|
||
ui,
|
||
&mut shared.raster_settings.gradient,
|
||
&mut self.selected_tool_gradient_stop,
|
||
);
|
||
}
|
||
|
||
_ => {}
|
||
}
|
||
|
||
ui.add_space(4.0);
|
||
});
|
||
}
|
||
|
||
/// Render all options for a raster paint tool (brush picker + sliders).
|
||
/// `is_eraser` drives which shared state is read/written.
|
||
fn render_raster_tool_options(
|
||
&mut self,
|
||
ui: &mut Ui,
|
||
shared: &mut SharedPaneState,
|
||
is_eraser: bool,
|
||
) {
|
||
self.render_brush_preset_grid(ui, shared, is_eraser);
|
||
ui.add_space(2.0);
|
||
|
||
let rs = &mut shared.raster_settings;
|
||
|
||
if !is_eraser {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Color:");
|
||
ui.selectable_value(&mut rs.brush_use_fg, true, "FG");
|
||
ui.selectable_value(&mut rs.brush_use_fg, false, "BG");
|
||
});
|
||
}
|
||
|
||
macro_rules! field {
|
||
($eraser:ident, $brush:ident) => {
|
||
if is_eraser { &mut rs.$eraser } else { &mut rs.$brush }
|
||
}
|
||
}
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.label("Size:");
|
||
ui.add(egui::Slider::new(field!(eraser_radius, brush_radius), 1.0_f32..=200.0).logarithmic(true).suffix(" px"));
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Opacity:");
|
||
ui.add(egui::Slider::new(field!(eraser_opacity, 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(field!(eraser_hardness, 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(field!(eraser_spacing, brush_spacing), 0.01_f32..=1.0)
|
||
.logarithmic(true)
|
||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
||
});
|
||
|
||
let bs = if is_eraser { &rs.active_eraser_settings } else { &rs.active_brush_settings };
|
||
if bs.elliptical_dab_ratio > 1.001 {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Angle:");
|
||
ui.add(egui::Slider::new(&mut rs.brush_angle_offset, -180.0_f32..=180.0)
|
||
.suffix("°")
|
||
.custom_formatter(|v, _| format!("{:.0}°", v)));
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Render the brush preset thumbnail grid (collapsible).
|
||
/// `is_eraser` drives which picker state and which shared settings are updated.
|
||
fn render_brush_preset_grid(&mut self, ui: &mut Ui, shared: &mut SharedPaneState, is_eraser: bool) {
|
||
let presets = bundled_brushes();
|
||
if presets.is_empty() { return; }
|
||
|
||
// Build preview TextureHandles from GPU-rendered pixel data when available.
|
||
if self.brush_preview_textures.len() != presets.len() {
|
||
if let Ok(previews) = shared.brush_preview_pixels.try_lock() {
|
||
if previews.len() == presets.len() {
|
||
self.brush_preview_textures.clear();
|
||
for (idx, (w, h, pixels)) in previews.iter().enumerate() {
|
||
let image = egui::ColorImage::from_rgba_premultiplied(
|
||
[*w as usize, *h as usize],
|
||
pixels,
|
||
);
|
||
let handle = ui.ctx().load_texture(
|
||
format!("brush_preview_{}", presets[idx].name),
|
||
image,
|
||
egui::TextureOptions::LINEAR,
|
||
);
|
||
self.brush_preview_textures.push(handle);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Read picker state into locals to avoid multiple &mut self borrows.
|
||
let mut expanded = if is_eraser { self.eraser_picker_expanded } else { self.brush_picker_expanded };
|
||
let mut selected = if is_eraser { self.selected_eraser_preset } else { self.selected_brush_preset };
|
||
|
||
let gap = 3.0;
|
||
let cols = 2usize;
|
||
let avail_w = ui.available_width();
|
||
let cell_w = ((avail_w - gap * (cols as f32 - 1.0)) / cols as f32).max(50.0);
|
||
let cell_h = 80.0;
|
||
|
||
if !expanded {
|
||
// Collapsed: show just the currently selected preset as a single wide cell.
|
||
let show_idx = selected.unwrap_or(0);
|
||
if let Some(preset) = presets.get(show_idx) {
|
||
let full_w = avail_w.max(50.0);
|
||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(full_w, cell_h), egui::Sense::click());
|
||
let painter = ui.painter();
|
||
let bg = if resp.hovered() {
|
||
egui::Color32::from_rgb(50, 56, 70)
|
||
} else {
|
||
egui::Color32::from_rgb(45, 65, 95)
|
||
};
|
||
painter.rect_filled(rect, 4.0, bg);
|
||
painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 140, 220)), egui::StrokeKind::Middle);
|
||
let preview_rect = egui::Rect::from_min_size(
|
||
rect.min + egui::vec2(4.0, 4.0),
|
||
egui::vec2(rect.width() - 8.0, cell_h - 22.0),
|
||
);
|
||
if let Some(tex) = self.brush_preview_textures.get(show_idx) {
|
||
painter.image(tex.id(), preview_rect,
|
||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||
egui::Color32::WHITE);
|
||
}
|
||
painter.text(egui::pos2(rect.center().x, rect.max.y - 9.0),
|
||
egui::Align2::CENTER_CENTER, preset.name,
|
||
egui::FontId::proportional(9.5), egui::Color32::from_rgb(140, 190, 255));
|
||
if resp.clicked() { expanded = true; }
|
||
}
|
||
} else {
|
||
// Expanded: full grid; clicking a preset selects it and collapses.
|
||
for (row_idx, chunk) in presets.chunks(cols).enumerate() {
|
||
ui.horizontal(|ui| {
|
||
ui.spacing_mut().item_spacing.x = gap;
|
||
for (col_idx, preset) in chunk.iter().enumerate() {
|
||
let idx = row_idx * cols + col_idx;
|
||
let is_sel = selected == Some(idx);
|
||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(cell_w, cell_h), egui::Sense::click());
|
||
let painter = ui.painter();
|
||
let bg = if is_sel {
|
||
egui::Color32::from_rgb(45, 65, 95)
|
||
} else if resp.hovered() {
|
||
egui::Color32::from_rgb(45, 50, 62)
|
||
} else {
|
||
egui::Color32::from_rgb(32, 36, 44)
|
||
};
|
||
painter.rect_filled(rect, 4.0, bg);
|
||
if is_sel {
|
||
painter.rect_stroke(rect, 4.0, egui::Stroke::new(1.5, egui::Color32::from_rgb(80, 140, 220)), egui::StrokeKind::Middle);
|
||
}
|
||
let preview_rect = egui::Rect::from_min_size(
|
||
rect.min + egui::vec2(4.0, 4.0),
|
||
egui::vec2(cell_w - 8.0, cell_h - 22.0),
|
||
);
|
||
if let Some(tex) = self.brush_preview_textures.get(idx) {
|
||
painter.image(tex.id(), preview_rect,
|
||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||
egui::Color32::WHITE);
|
||
}
|
||
painter.text(egui::pos2(rect.center().x, rect.max.y - 9.0),
|
||
egui::Align2::CENTER_CENTER, preset.name,
|
||
egui::FontId::proportional(9.5),
|
||
if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) });
|
||
if resp.clicked() {
|
||
selected = Some(idx);
|
||
expanded = false;
|
||
let s = &preset.settings;
|
||
let rs = &mut shared.raster_settings;
|
||
if is_eraser {
|
||
rs.eraser_opacity = s.opaque.clamp(0.0, 1.0);
|
||
rs.eraser_hardness = s.hardness.clamp(0.0, 1.0);
|
||
rs.eraser_spacing = s.dabs_per_radius;
|
||
rs.active_eraser_settings = s.clone();
|
||
} else {
|
||
rs.brush_opacity = s.opaque.clamp(0.0, 1.0);
|
||
rs.brush_hardness = s.hardness.clamp(0.0, 1.0);
|
||
rs.brush_spacing = s.dabs_per_radius;
|
||
rs.active_brush_settings = s.clone();
|
||
// If the user was on a preset-backed tool (Pencil/Pen/Airbrush)
|
||
// and manually picked a different brush, revert to the generic tool.
|
||
if matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) {
|
||
*shared.selected_tool = Tool::Draw;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
ui.add_space(gap);
|
||
}
|
||
}
|
||
|
||
// Write back picker state.
|
||
if is_eraser {
|
||
self.eraser_picker_expanded = expanded;
|
||
self.selected_eraser_preset = selected;
|
||
} else {
|
||
self.brush_picker_expanded = expanded;
|
||
self.selected_brush_preset = selected;
|
||
}
|
||
}
|
||
|
||
// 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<lightningbeam_core::vector_graph::FillId> = shared.selection.selected_fills()
|
||
.iter().map(|fid| lightningbeam_core::vector_graph::FillId(fid.0)).collect();
|
||
let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> = shared.selection.selected_edges()
|
||
.iter().map(|eid| lightningbeam_core::vector_graph::EdgeId(eid.0)).collect();
|
||
|
||
// Image-fill state for the selected fills + the document's image assets, for
|
||
// the picker below. Gathered now (immutable read) before `shared` is borrowed mut.
|
||
let image_assets: Vec<(Uuid, String)> = {
|
||
let doc = shared.action_executor.document();
|
||
let mut v: Vec<(Uuid, String)> = doc.image_assets.iter()
|
||
.map(|(id, a)| (*id, a.name.clone())).collect();
|
||
v.sort_by(|a, b| a.1.cmp(&b.1));
|
||
v
|
||
};
|
||
let current_image_fill: Option<Uuid> = {
|
||
let doc = shared.action_executor.document();
|
||
match doc.get_layer(&layer_id) {
|
||
Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => vl
|
||
.graph_at_time(time)
|
||
.and_then(|g| face_ids.first().map(|&fid| g.fill(fid).image_fill))
|
||
.flatten(),
|
||
_ => None,
|
||
}
|
||
};
|
||
|
||
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 — determine current fill type. `image_fill` takes render priority,
|
||
// so when set it's the active type (overriding colour/gradient underneath);
|
||
// switching to None/Solid/Gradient clears it.
|
||
let has_image = current_image_fill.is_some();
|
||
let has_gradient = matches!(&info.fill_gradient, Some(Some(_)));
|
||
let has_solid = matches!(&info.fill_color, Some(Some(_)));
|
||
let fill_is_none = matches!(&info.fill_color, Some(None))
|
||
&& matches!(&info.fill_gradient, Some(None));
|
||
let fill_mixed = info.fill_color.is_none() && info.fill_gradient.is_none();
|
||
|
||
// Fill type toggle row: None | Solid | Gradient | Image
|
||
ui.horizontal(|ui| {
|
||
ui.label("Fill:");
|
||
if fill_mixed {
|
||
ui.label("--");
|
||
} else {
|
||
if ui.selectable_label(fill_is_none && !has_image, "None").clicked() {
|
||
if has_image {
|
||
shared.pending_actions.push(Box::new(
|
||
SetImageFillAction::new(layer_id, time, face_ids.clone(), None)));
|
||
}
|
||
shared.pending_actions.push(Box::new(
|
||
SetFillPaintAction::solid(layer_id, time, face_ids.clone(), None)));
|
||
}
|
||
if ui.selectable_label(has_solid && !has_image, "Solid").clicked() {
|
||
if has_image {
|
||
shared.pending_actions.push(Box::new(
|
||
SetImageFillAction::new(layer_id, time, face_ids.clone(), None)));
|
||
}
|
||
if !has_solid {
|
||
let color = info.fill_color.flatten()
|
||
.unwrap_or(ShapeColor::rgba(0, 0, 0, 255));
|
||
shared.pending_actions.push(Box::new(
|
||
SetFillPaintAction::solid(layer_id, time, face_ids.clone(), Some(color))));
|
||
}
|
||
}
|
||
if ui.selectable_label(has_gradient && !has_image, "Gradient").clicked() {
|
||
if has_image {
|
||
shared.pending_actions.push(Box::new(
|
||
SetImageFillAction::new(layer_id, time, face_ids.clone(), None)));
|
||
}
|
||
if !has_gradient {
|
||
let grad = info.fill_gradient.clone().flatten().unwrap_or_default();
|
||
shared.pending_actions.push(Box::new(
|
||
SetFillPaintAction::gradient(layer_id, time, face_ids.clone(), Some(grad))));
|
||
}
|
||
}
|
||
// Image tab — only offered if there are imported image assets.
|
||
if !image_assets.is_empty() || has_image {
|
||
if ui.selectable_label(has_image, "Image").clicked() && !has_image {
|
||
if let Some((aid, _)) = image_assets.first() {
|
||
shared.pending_actions.push(Box::new(
|
||
SetImageFillAction::new(layer_id, time, face_ids.clone(), Some(*aid))));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Solid fill color editor
|
||
if !fill_mixed && has_solid && !has_image {
|
||
if let Some(Some(color)) = info.fill_color {
|
||
ui.horizontal(|ui| {
|
||
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 = SetFillPaintAction::solid(
|
||
layer_id, time, face_ids.clone(), Some(new_color),
|
||
);
|
||
shared.pending_actions.push(Box::new(action));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Gradient fill editor
|
||
if !fill_mixed && has_gradient && !has_image {
|
||
if let Some(Some(mut grad)) = info.fill_gradient.clone() {
|
||
if gradient_stop_editor(ui, &mut grad, &mut self.selected_shape_gradient_stop) {
|
||
let action = SetFillPaintAction::gradient(
|
||
layer_id, time, face_ids.clone(), Some(grad),
|
||
);
|
||
shared.pending_actions.push(Box::new(action));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Image fill editor — pick which asset (active Image type).
|
||
if has_image {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Image:");
|
||
let selected_text = current_image_fill
|
||
.and_then(|id| image_assets.iter().find(|(aid, _)| *aid == id))
|
||
.map(|(_, n)| n.clone())
|
||
.unwrap_or_else(|| "(missing)".to_string());
|
||
egui::ComboBox::from_id_salt(("image_fill", path))
|
||
.selected_text(selected_text)
|
||
.show_ui(ui, |ui| {
|
||
for (aid, name) in &image_assets {
|
||
if ui.selectable_label(current_image_fill == Some(*aid), name).clicked() {
|
||
shared.pending_actions.push(Box::new(
|
||
SetImageFillAction::new(layer_id, time, face_ids.clone(), Some(*aid)),
|
||
));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// 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(&mut 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);
|
||
|
||
// Extract all needed values up front, then drop the borrow before closures
|
||
// that need mutable access to shared or self.
|
||
let (mut width, mut height, mut duration, mut framerate, layer_count, background_color, mut hdr_mode) = {
|
||
let document = shared.action_executor.document();
|
||
(document.width, document.height, document.duration, document.framerate,
|
||
document.root.children.len(), document.background_color, document.hdr_output_mode)
|
||
};
|
||
|
||
// 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:");
|
||
let fps_response = ui.add(
|
||
DragValue::new(&mut framerate)
|
||
.speed(1.0)
|
||
.range(1.0..=120.0)
|
||
.suffix(" fps"),
|
||
);
|
||
|
||
if fps_response.gained_focus() || fps_response.drag_started() {
|
||
if self.fps_drag_start.is_none() {
|
||
self.fps_drag_start = Some(framerate);
|
||
}
|
||
}
|
||
|
||
if fps_response.changed() {
|
||
// Live preview: update document directly
|
||
shared.action_executor.document_mut().framerate = framerate;
|
||
}
|
||
|
||
if fps_response.drag_stopped() || fps_response.lost_focus() {
|
||
if let Some(start_fps) = self.fps_drag_start.take() {
|
||
let new_fps = shared.action_executor.document().framerate;
|
||
let timeline_mode = shared.action_executor.document().timeline_mode;
|
||
if (start_fps - new_fps).abs() > 1e-9
|
||
&& timeline_mode == lightningbeam_core::document::TimelineMode::Frames
|
||
{
|
||
use lightningbeam_core::actions::ChangeFpsAction;
|
||
// Revert live-preview so the action owns it
|
||
shared.action_executor.document_mut().framerate = start_fps;
|
||
let action = ChangeFpsAction::new(
|
||
start_fps,
|
||
new_fps,
|
||
shared.action_executor.document(),
|
||
);
|
||
shared.pending_actions.push(Box::new(action));
|
||
} else if (start_fps - new_fps).abs() > 1e-9 {
|
||
// Not in Frames mode — use simple property action (no stretching)
|
||
shared.action_executor.document_mut().framerate = start_fps;
|
||
let action = SetDocumentPropertiesAction::set_framerate(new_fps);
|
||
shared.pending_actions.push(Box::new(action));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Background color (with alpha)
|
||
ui.horizontal(|ui| {
|
||
ui.label("Background:");
|
||
let bg = 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));
|
||
}
|
||
});
|
||
|
||
// HDR output mode (how super-white video highlights map to SDR output)
|
||
ui.horizontal(|ui| {
|
||
use lightningbeam_core::document::HdrOutputMode;
|
||
ui.label("HDR output:");
|
||
egui::ComboBox::from_id_salt(("hdr_output_mode", path))
|
||
.selected_text(hdr_mode.name())
|
||
.show_ui(ui, |ui| {
|
||
let mut changed = false;
|
||
changed |= ui.selectable_value(&mut hdr_mode, HdrOutputMode::Clip, HdrOutputMode::Clip.name()).changed();
|
||
changed |= ui.selectable_value(&mut hdr_mode, HdrOutputMode::HighlightRolloff, HdrOutputMode::HighlightRolloff.name()).changed();
|
||
if changed {
|
||
let action = SetDocumentPropertiesAction::set_hdr_output_mode(hdr_mode);
|
||
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 the onion-skinning view settings (global; not tied to selection).
|
||
fn render_onion_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState) {
|
||
egui::CollapsingHeader::new("Onion Skin")
|
||
.id_salt(("onion", path))
|
||
.default_open(shared.onion_skin.enabled)
|
||
.show(ui, |ui| {
|
||
ui.add_space(4.0);
|
||
ui.checkbox(&mut shared.onion_skin.enabled, "Enabled");
|
||
ui.add_enabled_ui(shared.onion_skin.enabled, |ui| {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Frames before:");
|
||
ui.add(DragValue::new(&mut shared.onion_skin.frames_before).range(0..=5));
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("Frames after:");
|
||
ui.add(DragValue::new(&mut shared.onion_skin.frames_after).range(0..=5));
|
||
});
|
||
ui.add(egui::Slider::new(&mut shared.onion_skin.opacity, 0.0..=1.0).text("Opacity"));
|
||
});
|
||
ui.add_space(4.0);
|
||
});
|
||
}
|
||
|
||
/// Render layer info section
|
||
fn render_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_ids: &[Uuid]) {
|
||
use lightningbeam_core::actions::{set_layer_properties::LayerProperty, SetLayerPropertiesAction};
|
||
use lightningbeam_core::layer::AudioLayerType;
|
||
|
||
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 {
|
||
ui.label(format!("{} layers selected", layer_ids.len()));
|
||
ui.add_space(4.0);
|
||
return;
|
||
}
|
||
let layer_id = layer_ids[0];
|
||
|
||
// Snapshot the current values, then drop the document borrow before mutating shared.
|
||
let Some((type_name, is_audio, name, opacity, volume, muted, soloed, locked)) = ({
|
||
let document = shared.action_executor.document();
|
||
document.get_layer(&layer_id).map(|layer| {
|
||
let type_name = match layer {
|
||
AnyLayer::Vector(_) => "Vector",
|
||
AnyLayer::Audio(a) => match a.audio_layer_type {
|
||
AudioLayerType::Midi => "MIDI",
|
||
AudioLayerType::Sampled => "Audio",
|
||
},
|
||
AnyLayer::Video(_) => "Video",
|
||
AnyLayer::Effect(_) => "Effect",
|
||
AnyLayer::Group(_) => "Group",
|
||
AnyLayer::Raster(_) => "Raster",
|
||
AnyLayer::Text(_) => "Text",
|
||
};
|
||
(
|
||
type_name,
|
||
matches!(layer, AnyLayer::Audio(_)),
|
||
layer.name().to_string(),
|
||
layer.opacity(),
|
||
layer.volume(),
|
||
layer.muted(),
|
||
layer.soloed(),
|
||
layer.locked(),
|
||
)
|
||
})
|
||
}) else {
|
||
return;
|
||
};
|
||
|
||
// Name (editable; commits on Enter / focus loss).
|
||
if self.layer_name_edit.as_ref().map(|(id, _)| *id) != Some(layer_id) {
|
||
self.layer_name_edit = Some((layer_id, name.clone()));
|
||
}
|
||
ui.horizontal(|ui| {
|
||
ui.label("Name:");
|
||
let buf = &mut self.layer_name_edit.as_mut().unwrap().1;
|
||
let resp = ui.text_edit_singleline(buf);
|
||
let commit = resp.lost_focus()
|
||
&& (ui.input(|i| i.key_pressed(egui::Key::Enter)) || !resp.has_focus());
|
||
if commit {
|
||
let new_name = buf.trim().to_string();
|
||
if !new_name.is_empty() && new_name != name {
|
||
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
LayerProperty::Name(new_name),
|
||
)));
|
||
}
|
||
}
|
||
});
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.label("Type:");
|
||
ui.label(type_name);
|
||
});
|
||
|
||
// Opacity slider (single-undo: live-preview while dragging, commit on release).
|
||
ui.horizontal(|ui| {
|
||
ui.label("Opacity:");
|
||
let mut op = opacity;
|
||
let r = ui.add(egui::Slider::new(&mut op, 0.0..=1.0).show_value(false));
|
||
ui.label(format!("{:.0}%", op * 100.0));
|
||
if (r.drag_started() || r.gained_focus()) && self.layer_opacity_drag_start.is_none() {
|
||
self.layer_opacity_drag_start = Some(opacity);
|
||
}
|
||
if r.changed() {
|
||
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||
l.set_opacity(op);
|
||
}
|
||
}
|
||
if r.drag_stopped() || r.lost_focus() {
|
||
if let Some(start) = self.layer_opacity_drag_start.take() {
|
||
if (start - op).abs() > 1e-6 {
|
||
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||
l.set_opacity(start); // revert so the action owns the change
|
||
}
|
||
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
LayerProperty::Opacity(op),
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
if is_audio {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Volume:");
|
||
let mut vol = volume;
|
||
let r = ui.add(egui::Slider::new(&mut vol, 0.0..=1.0).show_value(false));
|
||
ui.label(format!("{:.0}%", vol * 100.0));
|
||
if (r.drag_started() || r.gained_focus()) && self.layer_volume_drag_start.is_none() {
|
||
self.layer_volume_drag_start = Some(volume);
|
||
}
|
||
if r.changed() {
|
||
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||
l.set_volume(vol);
|
||
}
|
||
}
|
||
if r.drag_stopped() || r.lost_focus() {
|
||
if let Some(start) = self.layer_volume_drag_start.take() {
|
||
if (start - vol).abs() > 1e-6 {
|
||
if let Some(l) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||
l.set_volume(start);
|
||
}
|
||
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
LayerProperty::Volume(vol),
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Toggles: mute / solo (audio) / lock.
|
||
ui.horizontal(|ui| {
|
||
if is_audio {
|
||
if ui.selectable_label(muted, "Mute").clicked() {
|
||
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
LayerProperty::Muted(!muted),
|
||
)));
|
||
}
|
||
if ui.selectable_label(soloed, "Solo").clicked() {
|
||
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
LayerProperty::Soloed(!soloed),
|
||
)));
|
||
}
|
||
}
|
||
if ui.selectable_label(locked, "Lock").clicked() {
|
||
shared.pending_actions.push(Box::new(SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
LayerProperty::Locked(!locked),
|
||
)));
|
||
}
|
||
});
|
||
|
||
ui.add_space(4.0);
|
||
});
|
||
}
|
||
|
||
/// Render a raster-layer section: shows the active keyframe's canvas dimensions and, when they
|
||
/// differ from the document, a Scale/Expand-Crop mode toggle + a "Layer to document size" button.
|
||
/// Driven by the *active* layer (not selection focus), since painting doesn't focus the layer.
|
||
fn render_raster_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_id: Uuid) {
|
||
use lightningbeam_core::raster_layer::RasterResizeMode;
|
||
|
||
// Pull the values, then drop the document borrow before mutating `shared`.
|
||
let time = *shared.playback_time;
|
||
let dims = {
|
||
let document = shared.action_executor.document();
|
||
match document.get_layer(&layer_id) {
|
||
Some(AnyLayer::Raster(rl)) => rl
|
||
.keyframe_at(time)
|
||
.map(|kf| (kf.width, kf.height, document.width as u32, document.height as u32)),
|
||
_ => None,
|
||
}
|
||
};
|
||
let Some((kf_w, kf_h, doc_w, doc_h)) = dims else { return };
|
||
|
||
egui::CollapsingHeader::new("Raster Layer")
|
||
.id_salt(("raster_layer", path))
|
||
.default_open(true)
|
||
.show(ui, |ui| {
|
||
ui.add_space(4.0);
|
||
ui.horizontal(|ui| {
|
||
ui.label("Size:");
|
||
ui.label(format!("{} × {}", kf_w, kf_h));
|
||
});
|
||
|
||
if kf_w != doc_w || kf_h != doc_h {
|
||
ui.horizontal(|ui| {
|
||
ui.label("Mode:");
|
||
ui.selectable_value(&mut self.raster_resize_mode, RasterResizeMode::Scale, "Scale");
|
||
ui.selectable_value(&mut self.raster_resize_mode, RasterResizeMode::Canvas, "Expand/Crop");
|
||
});
|
||
if ui.button(format!("Layer to document size ({} × {})", doc_w, doc_h)).clicked() {
|
||
let store = lightningbeam_core::raster_store::RasterStore::new(shared.container_path.clone());
|
||
let action = lightningbeam_core::actions::ResizeRasterLayerAction::new(
|
||
layer_id, doc_w, doc_h, self.raster_resize_mode, store,
|
||
);
|
||
shared.pending_actions.push(Box::new(action));
|
||
}
|
||
}
|
||
ui.add_space(4.0);
|
||
});
|
||
}
|
||
|
||
/// Render a text-layer section: edit the text content, font, size, color,
|
||
/// alignment, and box size of the active text layer. Driven by the *active*
|
||
/// layer so it appears whenever a text layer is selected.
|
||
fn render_text_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_id: Uuid) {
|
||
use lightningbeam_core::text_layer::TextAlign;
|
||
use lightningbeam_core::actions::{ResizeTextBoxAction, SetTextContentAction};
|
||
|
||
// Snapshot current values, then drop the document borrow before mutating shared.
|
||
let snapshot = {
|
||
let document = shared.action_executor.document();
|
||
match document.get_layer(&layer_id) {
|
||
Some(AnyLayer::Text(tl)) => {
|
||
Some((tl.content.clone(), tl.box_origin, tl.box_width, tl.box_height))
|
||
}
|
||
_ => None,
|
||
}
|
||
};
|
||
let Some((content0, origin, w0, h0)) = snapshot else { return };
|
||
|
||
// Families egui has actually bound (added fonts take effect next frame). Using an
|
||
// unbound `FontFamily::Name` panics, so only preview-render families in this set.
|
||
let bound_families: std::collections::HashSet<egui::FontFamily> =
|
||
ui.ctx().fonts(|f| f.families().into_iter().collect());
|
||
// Set when the font dropdown popup is open this frame (its closure only runs then),
|
||
// so we can synchronously finish loading any not-yet-preloaded fonts the user sees.
|
||
let mut dropdown_open = false;
|
||
|
||
egui::CollapsingHeader::new("Text")
|
||
.id_salt(("text_layer", path))
|
||
.default_open(true)
|
||
.show(ui, |ui| {
|
||
ui.add_space(4.0);
|
||
let mut content = content0.clone();
|
||
let mut content_changed = false;
|
||
|
||
ui.label("Text:");
|
||
if ui
|
||
.add(egui::TextEdit::multiline(&mut content.text).desired_rows(2).desired_width(f32::INFINITY))
|
||
.changed()
|
||
{
|
||
content_changed = true;
|
||
}
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.label("Size:");
|
||
if ui
|
||
.add(egui::DragValue::new(&mut content.font_size).range(1.0..=2000.0).speed(0.5))
|
||
.changed()
|
||
{
|
||
content_changed = true;
|
||
}
|
||
});
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.label("Color:");
|
||
// content.color is sRGB-encoded RGBA in 0..1 (matches peniko Color::new).
|
||
let mut col = egui::Color32::from_rgba_unmultiplied(
|
||
(content.color[0] * 255.0).round() as u8,
|
||
(content.color[1] * 255.0).round() as u8,
|
||
(content.color[2] * 255.0).round() as u8,
|
||
(content.color[3] * 255.0).round() as u8,
|
||
);
|
||
if ui.color_edit_button_srgba(&mut col).changed() {
|
||
content.color = [
|
||
col.r() as f32 / 255.0,
|
||
col.g() as f32 / 255.0,
|
||
col.b() as f32 / 255.0,
|
||
col.a() as f32 / 255.0,
|
||
];
|
||
content_changed = true;
|
||
}
|
||
});
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.label("Align:");
|
||
for (label, a) in [("L", TextAlign::Left), ("C", TextAlign::Center), ("R", TextAlign::Right), ("J", TextAlign::Justify)] {
|
||
if ui.selectable_label(content.align == a, label).clicked() {
|
||
content.align = a;
|
||
content_changed = true;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Font family picker (bundled families first, then system; embedded fonts
|
||
// register under their own names on load).
|
||
ui.horizontal(|ui| {
|
||
ui.label("Font:");
|
||
let current_label = if content.font_family.is_empty() {
|
||
"(Default)".to_string()
|
||
} else {
|
||
content.font_family.clone()
|
||
};
|
||
egui::ComboBox::from_id_salt(("text_font", path))
|
||
.selected_text(current_label)
|
||
.show_ui(ui, |ui| {
|
||
dropdown_open = true;
|
||
if ui.selectable_label(content.font_family.is_empty(), "(Default)").clicked() {
|
||
content.font_family.clear();
|
||
content_changed = true;
|
||
}
|
||
// The popup closure only runs while the dropdown is open, so each
|
||
// entry is rendered in its own font and queued for registration.
|
||
for fam in lightningbeam_core::fonts::families() {
|
||
let named = egui::FontFamily::Name(fam.as_str().into());
|
||
// Render in its own font only once egui has bound it; otherwise
|
||
// plain (and queue it for registration so it previews next frame).
|
||
let label = if bound_families.contains(&named) {
|
||
egui::RichText::new(&fam).family(named)
|
||
} else {
|
||
egui::RichText::new(&fam)
|
||
};
|
||
if ui.selectable_label(content.font_family == fam, label).clicked() {
|
||
content.font_family = fam.clone();
|
||
content_changed = true;
|
||
}
|
||
}
|
||
});
|
||
// Missing-font indicator.
|
||
if !content.font_family.is_empty()
|
||
&& !lightningbeam_core::fonts::family_available(&content.font_family)
|
||
{
|
||
ui.colored_label(egui::Color32::from_rgb(230, 160, 60), "⚠ missing");
|
||
}
|
||
});
|
||
|
||
// Box size.
|
||
let mut bw = w0;
|
||
let mut bh = h0;
|
||
let mut box_changed = false;
|
||
ui.horizontal(|ui| {
|
||
ui.label("Box:");
|
||
if ui.add(egui::DragValue::new(&mut bw).range(1.0..=100000.0).speed(1.0)).changed() {
|
||
box_changed = true;
|
||
}
|
||
ui.label("×");
|
||
if ui.add(egui::DragValue::new(&mut bh).range(1.0..=100000.0).speed(1.0)).changed() {
|
||
box_changed = true;
|
||
}
|
||
});
|
||
|
||
ui.add_space(4.0);
|
||
|
||
if content_changed {
|
||
shared.pending_actions.push(Box::new(SetTextContentAction::new(layer_id, content)));
|
||
}
|
||
if box_changed {
|
||
shared.pending_actions.push(Box::new(ResizeTextBoxAction::new(layer_id, origin, bw, bh)));
|
||
}
|
||
});
|
||
|
||
// Register fonts the background preloader has finished (cheap: bytes already loaded),
|
||
// a few per frame so each atlas rebuild stays small. Only fall back to synchronous
|
||
// loading for families the user is actively viewing in the open dropdown that the
|
||
// background hasn't reached yet. Repaint while there's more to do or the preloader
|
||
// is still running, so registration keeps flowing without input.
|
||
let ctx = ui.ctx().clone();
|
||
const PER_FRAME: usize = 4;
|
||
let mut budget = PER_FRAME;
|
||
let mut more = false;
|
||
for fam in lightningbeam_core::fonts::families() {
|
||
if self.registered_preview_fonts.contains(&fam) {
|
||
continue;
|
||
}
|
||
let bytes = lightningbeam_core::fonts::take_preloaded(&fam).or_else(|| {
|
||
if dropdown_open { lightningbeam_core::fonts::family_font_bytes(&fam) } else { None }
|
||
});
|
||
if let Some(bytes) = bytes {
|
||
if budget == 0 {
|
||
more = true;
|
||
break;
|
||
}
|
||
self.register_font_bytes(&ctx, &fam, bytes);
|
||
budget -= 1;
|
||
}
|
||
}
|
||
if more || !lightningbeam_core::fonts::preload_done() {
|
||
ctx.request_repaint();
|
||
}
|
||
}
|
||
|
||
/// 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(_) => &[],
|
||
AnyLayer::Text(_) => &[],
|
||
};
|
||
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, document.tempo_map());
|
||
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) {
|
||
// Resolve note-on/off pairs to (time, note, vel, dur) tuples
|
||
let mut notes: Vec<(f64, u8, u8, f64)> = Vec::new();
|
||
let mut pending: std::collections::HashMap<u8, (f64, u8)> = std::collections::HashMap::new();
|
||
for event in events {
|
||
if event.is_note_on() {
|
||
pending.insert(event.data1, (event.timestamp.beats_to_f64(), event.data2));
|
||
} else if event.is_note_off() {
|
||
if let Some((start, v)) = pending.remove(&event.data1) {
|
||
notes.push((start, event.data1, v, event.timestamp.beats_to_f64() - 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
|
||
let bg = shared.theme.bg_color(&["#infopanel", ".pane-content"], ui.ctx(), egui::Color32::from_rgb(30, 35, 40));
|
||
ui.painter().rect_filled(rect, 0.0, bg);
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Active raster layer's size + "to document size" — shown whenever a raster layer is
|
||
// active (independent of selection focus, since painting doesn't focus the layer).
|
||
if let Some(active_id) = *shared.active_layer_id {
|
||
self.render_raster_layer_section(ui, path, shared, active_id);
|
||
self.render_text_layer_section(ui, path, shared, active_id);
|
||
}
|
||
|
||
// Onion-skinning view settings — always available, regardless of selection.
|
||
ui.add_space(8.0);
|
||
ui.separator();
|
||
ui.add_space(4.0);
|
||
self.render_onion_section(ui, path, shared);
|
||
});
|
||
}
|
||
|
||
fn name(&self) -> &str {
|
||
"Info Panel"
|
||
}
|
||
}
|