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

826 lines
31 KiB
Rust

/// Info Panel pane - displays and edits properties of selected objects
///
/// Shows context-sensitive property editors based on current selection:
/// - Tool options (when a tool is active)
/// - Transform properties (when shapes are selected)
/// - Shape properties (fill/stroke for selected shapes)
/// - Document settings (when nothing is selected)
use eframe::egui::{self, DragValue, Sense, Ui};
use lightningbeam_core::actions::{
InstancePropertyChange, SetDocumentPropertiesAction, SetInstancePropertiesAction,
SetShapePropertiesAction,
};
use lightningbeam_core::layer::AnyLayer;
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 transform section is expanded
transform_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,
transform_section_open: true,
shape_section_open: true,
}
}
}
/// Aggregated info about the current selection
struct SelectionInfo {
/// True if nothing is selected
is_empty: bool,
/// Number of selected shape instances
shape_count: usize,
/// Layer ID of selected shapes (assumes single layer selection for now)
layer_id: Option<Uuid>,
/// Selected shape instance IDs
instance_ids: Vec<Uuid>,
/// Shape IDs referenced by selected instances
shape_ids: Vec<Uuid>,
// Transform values (None = mixed values across selection)
x: Option<f64>,
y: Option<f64>,
rotation: Option<f64>,
scale_x: Option<f64>,
scale_y: Option<f64>,
skew_x: Option<f64>,
skew_y: Option<f64>,
opacity: Option<f64>,
// 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,
shape_count: 0,
layer_id: None,
instance_ids: Vec::new(),
shape_ids: Vec::new(),
x: None,
y: None,
rotation: None,
scale_x: None,
scale_y: None,
skew_x: None,
skew_y: None,
opacity: None,
fill_color: None,
stroke_color: None,
stroke_width: None,
}
}
}
impl InfopanelPane {
/// Gather info about the current selection
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
let mut info = SelectionInfo::default();
let selected_instances = shared.selection.shape_instances();
info.shape_count = selected_instances.len();
info.is_empty = info.shape_count == 0;
if info.is_empty {
return info;
}
info.instance_ids = selected_instances.to_vec();
// Find the layer containing the selected instances
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 {
// Gather values from all selected instances
let mut first = true;
for instance_id in &info.instance_ids {
if let Some(instance) = vector_layer.get_object(instance_id) {
info.shape_ids.push(instance.shape_id);
if first {
// First instance - set initial values
info.x = Some(instance.transform.x);
info.y = Some(instance.transform.y);
info.rotation = Some(instance.transform.rotation);
info.scale_x = Some(instance.transform.scale_x);
info.scale_y = Some(instance.transform.scale_y);
info.skew_x = Some(instance.transform.skew_x);
info.skew_y = Some(instance.transform.skew_y);
info.opacity = Some(instance.opacity);
// Get shape properties
if let Some(shape) = vector_layer.shapes.get(&instance.shape_id) {
info.fill_color = Some(shape.fill_color);
info.stroke_color = Some(shape.stroke_color);
info.stroke_width = shape
.stroke_style
.as_ref()
.map(|s| Some(s.width))
.unwrap_or(Some(1.0));
}
first = false;
} else {
// Check if values differ (set to None if mixed)
if info.x != Some(instance.transform.x) {
info.x = None;
}
if info.y != Some(instance.transform.y) {
info.y = None;
}
if info.rotation != Some(instance.transform.rotation) {
info.rotation = None;
}
if info.scale_x != Some(instance.transform.scale_x) {
info.scale_x = None;
}
if info.scale_y != Some(instance.transform.scale_y) {
info.scale_y = None;
}
if info.skew_x != Some(instance.transform.skew_x) {
info.skew_x = None;
}
if info.skew_y != Some(instance.transform.skew_y) {
info.skew_y = None;
}
if info.opacity != Some(instance.opacity) {
info.opacity = None;
}
// Check shape properties
if let Some(shape) = vector_layer.shapes.get(&instance.shape_id) {
// Compare fill colors - set to None if mixed
if let Some(current_fill) = &info.fill_color {
if *current_fill != shape.fill_color {
info.fill_color = None;
}
}
// Compare stroke colors - set to None if mixed
if let Some(current_stroke) = &info.stroke_color {
if *current_stroke != shape.stroke_color {
info.stroke_color = None;
}
}
let stroke_w = shape
.stroke_style
.as_ref()
.map(|s| s.width)
.unwrap_or(1.0);
if info.stroke_width != Some(stroke_w) {
info.stroke_width = None;
}
}
}
}
}
}
}
}
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;
// Only show tool options for tools that have options
let has_options = matches!(
tool,
Tool::Draw | Tool::Rectangle | Tool::Ellipse | Tool::PaintBucket | Tool::Polygon | Tool::Line
);
if !has_options {
return;
}
egui::CollapsingHeader::new("Tool Options")
.id_salt(("tool_options", path))
.default_open(self.tool_section_open)
.show(ui, |ui| {
self.tool_section_open = true;
ui.add_space(4.0);
match tool {
Tool::Draw => {
// 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));
});
}
_ => {}
}
ui.add_space(4.0);
});
}
/// Render transform properties section
fn render_transform_section(
&mut self,
ui: &mut Ui,
path: &NodePath,
shared: &mut SharedPaneState,
info: &SelectionInfo,
) {
egui::CollapsingHeader::new("Transform")
.id_salt(("transform", path))
.default_open(self.transform_section_open)
.show(ui, |ui| {
self.transform_section_open = true;
ui.add_space(4.0);
let layer_id = match info.layer_id {
Some(id) => id,
None => return,
};
// Position X
self.render_transform_field(
ui,
"X:",
info.x,
1.0,
f64::NEG_INFINITY..=f64::INFINITY,
|value| InstancePropertyChange::X(value),
layer_id,
&info.instance_ids,
shared,
);
// Position Y
self.render_transform_field(
ui,
"Y:",
info.y,
1.0,
f64::NEG_INFINITY..=f64::INFINITY,
|value| InstancePropertyChange::Y(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Rotation
self.render_transform_field(
ui,
"Rotation:",
info.rotation,
1.0,
-360.0..=360.0,
|value| InstancePropertyChange::Rotation(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Scale X
self.render_transform_field(
ui,
"Scale X:",
info.scale_x,
0.01,
0.01..=100.0,
|value| InstancePropertyChange::ScaleX(value),
layer_id,
&info.instance_ids,
shared,
);
// Scale Y
self.render_transform_field(
ui,
"Scale Y:",
info.scale_y,
0.01,
0.01..=100.0,
|value| InstancePropertyChange::ScaleY(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Skew X
self.render_transform_field(
ui,
"Skew X:",
info.skew_x,
1.0,
-89.0..=89.0,
|value| InstancePropertyChange::SkewX(value),
layer_id,
&info.instance_ids,
shared,
);
// Skew Y
self.render_transform_field(
ui,
"Skew Y:",
info.skew_y,
1.0,
-89.0..=89.0,
|value| InstancePropertyChange::SkewY(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Opacity
self.render_transform_field(
ui,
"Opacity:",
info.opacity,
0.01,
0.0..=1.0,
|value| InstancePropertyChange::Opacity(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
});
}
/// Render a single transform property field with drag-to-adjust
fn render_transform_field<F>(
&self,
ui: &mut Ui,
label: &str,
value: Option<f64>,
speed: f64,
range: std::ops::RangeInclusive<f64>,
make_change: F,
layer_id: Uuid,
instance_ids: &[Uuid],
shared: &mut SharedPaneState,
) where
F: Fn(f64) -> InstancePropertyChange,
{
ui.horizontal(|ui| {
// Label with drag sense for drag-to-adjust
let label_response = ui.add(egui::Label::new(label).sense(Sense::drag()));
match value {
Some(mut v) => {
// Handle drag on label
if label_response.dragged() {
let delta = label_response.drag_delta().x as f64 * speed;
v = (v + delta).clamp(*range.start(), *range.end());
// Create action for each selected instance
for instance_id in instance_ids {
let action = SetInstancePropertiesAction::new(
layer_id,
*instance_id,
make_change(v),
);
shared.pending_actions.push(Box::new(action));
}
}
// DragValue widget
let response = ui.add(
DragValue::new(&mut v)
.speed(speed)
.range(range.clone()),
);
if response.changed() {
// Create action for each selected instance
for instance_id in instance_ids {
let action = SetInstancePropertiesAction::new(
layer_id,
*instance_id,
make_change(v),
);
shared.pending_actions.push(Box::new(action));
}
}
}
None => {
// Mixed values - show placeholder
ui.label("--");
}
}
});
}
/// Render shape properties section (fill/stroke)
fn render_shape_section(
&mut self,
ui: &mut Ui,
path: &NodePath,
shared: &mut SharedPaneState,
info: &SelectionInfo,
) {
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);
let layer_id = match info.layer_id {
Some(id) => id,
None => return,
};
// Fill color
ui.horizontal(|ui| {
ui.label("Fill:");
match info.fill_color {
Some(Some(color)) => {
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
color.r, color.g, color.b, color.a,
);
if ui.color_edit_button_srgba(&mut egui_color).changed() {
let new_color = Some(ShapeColor::new(
egui_color.r(),
egui_color.g(),
egui_color.b(),
egui_color.a(),
));
// Create action for each selected shape
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_fill_color(
layer_id,
*shape_id,
new_color,
);
shared.pending_actions.push(Box::new(action));
}
}
}
Some(None) => {
if ui.button("Add Fill").clicked() {
// Add default black fill
let default_fill = Some(ShapeColor::rgb(0, 0, 0));
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_fill_color(
layer_id,
*shape_id,
default_fill,
);
shared.pending_actions.push(Box::new(action));
}
}
}
None => {
ui.label("--");
}
}
});
// Stroke color
ui.horizontal(|ui| {
ui.label("Stroke:");
match info.stroke_color {
Some(Some(color)) => {
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
color.r, color.g, color.b, color.a,
);
if ui.color_edit_button_srgba(&mut egui_color).changed() {
let new_color = Some(ShapeColor::new(
egui_color.r(),
egui_color.g(),
egui_color.b(),
egui_color.a(),
));
// Create action for each selected shape
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_stroke_color(
layer_id,
*shape_id,
new_color,
);
shared.pending_actions.push(Box::new(action));
}
}
}
Some(None) => {
if ui.button("Add Stroke").clicked() {
// Add default black stroke
let default_stroke = Some(ShapeColor::rgb(0, 0, 0));
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_stroke_color(
layer_id,
*shape_id,
default_stroke,
);
shared.pending_actions.push(Box::new(action));
}
}
}
None => {
ui.label("--");
}
}
});
// Stroke width
ui.horizontal(|ui| {
ui.label("Stroke Width:");
match info.stroke_width {
Some(mut width) => {
let response = ui.add(
DragValue::new(&mut width)
.speed(0.1)
.range(0.1..=100.0),
);
if response.changed() {
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_stroke_width(
layer_id,
*shape_id,
width,
);
shared.pending_actions.push(Box::new(action));
}
}
}
None => {
ui.label("--");
}
}
});
ui.add_space(4.0);
});
}
/// Render document settings section (shown when nothing is selected)
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));
}
});
// Layer count (read-only)
ui.horizontal(|ui| {
ui.label("Layers:");
ui.label(format!("{}", layer_count));
});
ui.add_space(4.0);
});
}
}
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. Gather selection info
let info = self.gather_selection_info(shared);
// 3. Transform section (if shapes selected)
if info.shape_count > 0 {
self.render_transform_section(ui, path, shared, &info);
}
// 4. Shape properties section (if shapes selected)
if info.shape_count > 0 {
self.render_shape_section(ui, path, shared, &info);
}
// 5. Document settings (if nothing selected)
if info.is_empty {
self.render_document_section(ui, path, shared);
}
// Show selection count at bottom
if info.shape_count > 0 {
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
ui.label(format!(
"{} object{} selected",
info.shape_count,
if info.shape_count == 1 { "" } else { "s" }
));
}
});
}
fn name(&self) -> &str {
"Info Panel"
}
}