From 67724c944cff41a6e2c0ea77682ba8a29cfc3819 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 18 Nov 2025 00:22:28 -0500 Subject: [PATCH] Select and move shapes --- lightningbeam-ui/Cargo.lock | 1 + .../lightningbeam-core/src/action.rs | 277 +++ .../lightningbeam-core/src/actions/mod.rs | 8 + .../src/actions/move_objects.rs | 131 ++ .../lightningbeam-core/src/document.rs | 23 + .../lightningbeam-core/src/hit_test.rs | 255 +++ .../lightningbeam-core/src/layer.rs | 62 + .../lightningbeam-core/src/lib.rs | 4 + .../lightningbeam-core/src/object.rs | 15 + .../lightningbeam-core/src/selection.rs | 223 +++ .../lightningbeam-core/src/shape.rs | 8 + .../lightningbeam-core/src/tool.rs | 83 + .../lightningbeam-editor/Cargo.toml | 1 + .../TOOL_IMPLEMENTATION_PLAN.md | 759 ++++++++ .../lightningbeam-editor/src/main.rs | 82 +- .../lightningbeam-editor/src/main.rs.backup | 1568 +++++++++++++++++ .../lightningbeam-editor/src/panes/mod.rs | 12 +- .../lightningbeam-editor/src/panes/stage.rs | 375 +++- 18 files changed, 3868 insertions(+), 19 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/action.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/mod.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/hit_test.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/selection.rs create mode 100644 lightningbeam-ui/lightningbeam-editor/TOOL_IMPLEMENTATION_PLAN.md create mode 100644 lightningbeam-ui/lightningbeam-editor/src/main.rs.backup diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index 936283d..c611a23 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -2762,6 +2762,7 @@ dependencies = [ "resvg 0.42.0", "serde", "serde_json", + "uuid", "vello", "wgpu", "winit", diff --git a/lightningbeam-ui/lightningbeam-core/src/action.rs b/lightningbeam-ui/lightningbeam-core/src/action.rs new file mode 100644 index 0000000..d597006 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/action.rs @@ -0,0 +1,277 @@ +//! Action system for undo/redo functionality +//! +//! This module provides a type-safe action system that ensures document +//! mutations can only happen through actions, enforced by Rust's type system. +//! +//! ## Architecture +//! +//! - `Action` trait: Defines execute() and rollback() operations +//! - `ActionExecutor`: Wraps the document and manages undo/redo stacks +//! - Document mutations are only accessible via `pub(crate)` methods +//! - External code gets read-only access via `ActionExecutor::document()` + +use crate::document::Document; + +/// Action trait for undo/redo operations +/// +/// Each action must be able to execute (apply changes) and rollback (undo changes). +/// Actions are stored in the undo stack and can be re-executed from the redo stack. +pub trait Action: Send { + /// Apply this action to the document + fn execute(&mut self, document: &mut Document); + + /// Undo this action (rollback changes) + fn rollback(&mut self, document: &mut Document); + + /// Get a human-readable description of this action (for UI display) + fn description(&self) -> String; +} + +/// Action executor that wraps the document and manages undo/redo +/// +/// This is the only way to get mutable access to the document, ensuring +/// all mutations go through the action system. +pub struct ActionExecutor { + /// The document being edited + document: Document, + + /// Stack of executed actions (for undo) + undo_stack: Vec>, + + /// Stack of undone actions (for redo) + redo_stack: Vec>, + + /// Maximum number of actions to keep in undo stack + max_undo_depth: usize, +} + +impl ActionExecutor { + /// Create a new action executor with the given document + pub fn new(document: Document) -> Self { + Self { + document, + undo_stack: Vec::new(), + redo_stack: Vec::new(), + max_undo_depth: 100, // Default: keep last 100 actions + } + } + + /// Get read-only access to the document + /// + /// This is the public API for reading document state. + /// Mutations must go through execute() which requires an Action. + pub fn document(&self) -> &Document { + &self.document + } + + /// Execute an action and add it to the undo stack + /// + /// This clears the redo stack since we're creating a new timeline branch. + pub fn execute(&mut self, mut action: Box) { + // Apply the action + action.execute(&mut self.document); + + // Clear redo stack (new action invalidates redo history) + self.redo_stack.clear(); + + // Add to undo stack + self.undo_stack.push(action); + + // Limit undo stack size + if self.undo_stack.len() > self.max_undo_depth { + self.undo_stack.remove(0); + } + } + + /// Undo the last action + /// + /// Returns true if an action was undone, false if undo stack is empty. + pub fn undo(&mut self) -> bool { + if let Some(mut action) = self.undo_stack.pop() { + // Rollback the action + action.rollback(&mut self.document); + + // Move to redo stack + self.redo_stack.push(action); + + true + } else { + false + } + } + + /// Redo the last undone action + /// + /// Returns true if an action was redone, false if redo stack is empty. + pub fn redo(&mut self) -> bool { + if let Some(mut action) = self.redo_stack.pop() { + // Re-execute the action + action.execute(&mut self.document); + + // Move back to undo stack + self.undo_stack.push(action); + + true + } else { + false + } + } + + /// Check if undo is available + pub fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + /// Check if redo is available + pub fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + /// Get the description of the next action to undo + pub fn undo_description(&self) -> Option { + self.undo_stack.last().map(|a| a.description()) + } + + /// Get the description of the next action to redo + pub fn redo_description(&self) -> Option { + self.redo_stack.last().map(|a| a.description()) + } + + /// Get the number of actions in the undo stack + pub fn undo_depth(&self) -> usize { + self.undo_stack.len() + } + + /// Get the number of actions in the redo stack + pub fn redo_depth(&self) -> usize { + self.redo_stack.len() + } + + /// Clear all undo/redo history + pub fn clear_history(&mut self) { + self.undo_stack.clear(); + self.redo_stack.clear(); + } + + /// Set the maximum undo depth + pub fn set_max_undo_depth(&mut self, depth: usize) { + self.max_undo_depth = depth; + + // Trim undo stack if needed + if self.undo_stack.len() > depth { + let remove_count = self.undo_stack.len() - depth; + self.undo_stack.drain(0..remove_count); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test action that just tracks execute/rollback calls + struct TestAction { + description: String, + executed: bool, + } + + impl TestAction { + fn new(description: &str) -> Self { + Self { + description: description.to_string(), + executed: false, + } + } + } + + impl Action for TestAction { + fn execute(&mut self, _document: &mut Document) { + self.executed = true; + } + + fn rollback(&mut self, _document: &mut Document) { + self.executed = false; + } + + fn description(&self) -> String { + self.description.clone() + } + } + + #[test] + fn test_action_executor_basic() { + let document = Document::new("Test"); + let mut executor = ActionExecutor::new(document); + + assert!(!executor.can_undo()); + assert!(!executor.can_redo()); + + // Execute an action + let action = Box::new(TestAction::new("Test Action")); + executor.execute(action); + + assert!(executor.can_undo()); + assert!(!executor.can_redo()); + assert_eq!(executor.undo_depth(), 1); + + // Undo + assert!(executor.undo()); + assert!(!executor.can_undo()); + assert!(executor.can_redo()); + assert_eq!(executor.redo_depth(), 1); + + // Redo + assert!(executor.redo()); + assert!(executor.can_undo()); + assert!(!executor.can_redo()); + } + + #[test] + fn test_action_descriptions() { + let document = Document::new("Test"); + let mut executor = ActionExecutor::new(document); + + executor.execute(Box::new(TestAction::new("Action 1"))); + executor.execute(Box::new(TestAction::new("Action 2"))); + + assert_eq!(executor.undo_description(), Some("Action 2".to_string())); + + executor.undo(); + assert_eq!(executor.redo_description(), Some("Action 2".to_string())); + assert_eq!(executor.undo_description(), Some("Action 1".to_string())); + } + + #[test] + fn test_new_action_clears_redo() { + let document = Document::new("Test"); + let mut executor = ActionExecutor::new(document); + + executor.execute(Box::new(TestAction::new("Action 1"))); + executor.execute(Box::new(TestAction::new("Action 2"))); + executor.undo(); + + assert!(executor.can_redo()); + + // Execute new action should clear redo stack + executor.execute(Box::new(TestAction::new("Action 3"))); + + assert!(!executor.can_redo()); + assert_eq!(executor.undo_depth(), 2); + } + + #[test] + fn test_max_undo_depth() { + let document = Document::new("Test"); + let mut executor = ActionExecutor::new(document); + executor.set_max_undo_depth(3); + + executor.execute(Box::new(TestAction::new("Action 1"))); + executor.execute(Box::new(TestAction::new("Action 2"))); + executor.execute(Box::new(TestAction::new("Action 3"))); + executor.execute(Box::new(TestAction::new("Action 4"))); + + // Should only keep last 3 + assert_eq!(executor.undo_depth(), 3); + assert_eq!(executor.undo_description(), Some("Action 4".to_string())); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs new file mode 100644 index 0000000..dc1fcb1 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -0,0 +1,8 @@ +//! Action implementations for document editing +//! +//! This module contains all the concrete action types that can be executed +//! through the action system. + +pub mod move_objects; + +pub use move_objects::MoveObjectsAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs new file mode 100644 index 0000000..ca68335 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs @@ -0,0 +1,131 @@ +//! Move objects action +//! +//! Handles moving one or more objects to new positions. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use std::collections::HashMap; +use uuid::Uuid; +use vello::kurbo::Point; + +/// Action that moves objects to new positions +pub struct MoveObjectsAction { + /// Layer ID containing the objects + layer_id: Uuid, + + /// Map of object IDs to their old and new positions + object_positions: HashMap, // (old_pos, new_pos) +} + +impl MoveObjectsAction { + /// Create a new move objects action + /// + /// # Arguments + /// + /// * `layer_id` - The layer containing the objects + /// * `object_positions` - Map of object IDs to (old_position, new_position) + pub fn new(layer_id: Uuid, object_positions: HashMap) -> Self { + Self { + layer_id, + object_positions, + } + } +} + +impl Action for MoveObjectsAction { + fn execute(&mut self, document: &mut Document) { + let layer = match document.get_layer_mut(&self.layer_id) { + Some(l) => l, + None => return, + }; + + if let AnyLayer::Vector(vector_layer) = layer { + for (object_id, (_old, new)) in &self.object_positions { + vector_layer.modify_object_internal(object_id, |obj| { + obj.transform.x = new.x; + obj.transform.y = new.y; + }); + } + } + } + + fn rollback(&mut self, document: &mut Document) { + let layer = match document.get_layer_mut(&self.layer_id) { + Some(l) => l, + None => return, + }; + + if let AnyLayer::Vector(vector_layer) = layer { + for (object_id, (old, _new)) in &self.object_positions { + vector_layer.modify_object_internal(object_id, |obj| { + obj.transform.x = old.x; + obj.transform.y = old.y; + }); + } + } + } + + fn description(&self) -> String { + let count = self.object_positions.len(); + if count == 1 { + "Move object".to_string() + } else { + format!("Move {} objects", count) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layer::VectorLayer; + use crate::object::Object; + use crate::shape::Shape; + use vello::kurbo::{Circle, Shape as KurboShape}; + + #[test] + fn test_move_objects_action() { + // Create a document with a test object + let mut document = Document::new("Test"); + + let circle = Circle::new((100.0, 100.0), 50.0); + let path = circle.to_path(0.1); + let shape = Shape::new(path); + let object = Object::new(shape.id).with_position(50.0, 50.0); + + let mut vector_layer = VectorLayer::new("Layer 1"); + vector_layer.add_shape(shape); + let object_id = vector_layer.add_object(object); + let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); + + // Create move action + let mut positions = HashMap::new(); + positions.insert( + object_id, + (Point::new(50.0, 50.0), Point::new(150.0, 200.0)) + ); + + let mut action = MoveObjectsAction::new(layer_id, positions); + + // Execute + action.execute(&mut document); + + // Verify position changed + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + let obj = layer.get_object(&object_id).unwrap(); + assert_eq!(obj.transform.x, 150.0); + assert_eq!(obj.transform.y, 200.0); + } + + // Rollback + action.rollback(&mut document); + + // Verify position restored + if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) { + let obj = layer.get_object(&object_id).unwrap(); + assert_eq!(obj.transform.x, 50.0); + assert_eq!(obj.transform.y, 50.0); + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 79c8381..12aa14f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -169,6 +169,29 @@ impl Document { layer.visible && layer.contains_time(self.current_time) }) } + + /// Get a layer by ID + pub fn get_layer(&self, id: &Uuid) -> Option<&AnyLayer> { + self.root.get_child(id) + } + + // === MUTATION METHODS (pub(crate) - only accessible to action module) === + + /// Get mutable access to the root graphics object + /// + /// This method is intentionally `pub(crate)` to ensure mutations + /// only happen through the action system. + pub(crate) fn root_mut(&mut self) -> &mut GraphicsObject { + &mut self.root + } + + /// Get mutable access to a layer by ID + /// + /// This method is intentionally `pub(crate)` to ensure mutations + /// only happen through the action system. + pub(crate) fn get_layer_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> { + self.root.get_child_mut(id) + } } #[cfg(test)] diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs new file mode 100644 index 0000000..fb53b0c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -0,0 +1,255 @@ +//! Hit testing for selection and interaction +//! +//! Provides functions for testing if points or rectangles intersect with +//! shapes and objects, taking into account transform hierarchies. + +use crate::layer::VectorLayer; +use crate::object::Object; +use crate::shape::Shape; +use uuid::Uuid; +use vello::kurbo::{Affine, Point, Rect, Shape as KurboShape}; + +/// Hit test a layer at a specific point +/// +/// Tests objects in reverse order (front to back) and returns the first hit. +/// Combines parent_transform with object transforms for hierarchical testing. +/// +/// # Arguments +/// +/// * `layer` - The vector layer to test +/// * `point` - The point to test in screen/canvas space +/// * `tolerance` - Additional tolerance in pixels for stroke hit testing +/// * `parent_transform` - Transform from parent GraphicsObject(s) +/// +/// # Returns +/// +/// The UUID of the first object hit, or None if no hit +pub fn hit_test_layer( + layer: &VectorLayer, + point: Point, + tolerance: f64, + parent_transform: Affine, +) -> Option { + // Test objects in reverse order (back to front in Vec = front to back for hit testing) + for object in layer.objects.iter().rev() { + // Get the shape for this object + let shape = layer.get_shape(&object.shape_id)?; + + // Combine parent transform with object transform + let combined_transform = parent_transform * object.to_affine(); + + if hit_test_shape(shape, point, tolerance, combined_transform) { + return Some(object.id); + } + } + + None +} + +/// Hit test a single shape with a given transform +/// +/// Tests if a point hits the shape, considering both fill and stroke. +/// +/// # Arguments +/// +/// * `shape` - The shape to test +/// * `point` - The point to test in screen/canvas space +/// * `tolerance` - Additional tolerance in pixels for stroke hit testing +/// * `transform` - The combined transform to apply to the shape +/// +/// # Returns +/// +/// true if the point hits the shape, false otherwise +pub fn hit_test_shape( + shape: &Shape, + point: Point, + tolerance: f64, + transform: Affine, +) -> bool { + // Transform point to shape's local space + // We need the inverse transform to go from screen space to shape space + let inverse_transform = transform.inverse(); + let local_point = inverse_transform * point; + + // Check if point is inside filled path + if shape.fill_color.is_some() { + if shape.path().contains(local_point) { + return true; + } + } + + // Check stroke bounds if has stroke + if let Some(stroke_style) = &shape.stroke_style { + let stroke_tolerance = stroke_style.width / 2.0 + tolerance; + + // For stroke hit testing, we need to check if the point is within + // stroke_tolerance distance of the path + // kurbo's winding() method can be used, or we can check bounding box first + + // Quick bounding box check with stroke tolerance + let bbox = shape.path().bounding_box(); + let expanded_bbox = bbox.inflate(stroke_tolerance, stroke_tolerance); + + if !expanded_bbox.contains(local_point) { + return false; + } + + // For more accurate stroke hit testing, we would need to: + // 1. Stroke the path with the stroke width + // 2. Check if the point is contained in the stroked outline + // For now, we do a simpler bounding box check + // TODO: Implement accurate stroke hit testing using kurbo's stroke functionality + + // Simple approach: if within expanded bbox, consider it a hit for now + return true; + } + + false +} + +/// Hit test objects within a rectangle (for marquee selection) +/// +/// Returns all objects whose bounding boxes intersect with the given rectangle. +/// +/// # Arguments +/// +/// * `layer` - The vector layer to test +/// * `rect` - The selection rectangle in screen/canvas space +/// * `parent_transform` - Transform from parent GraphicsObject(s) +/// +/// # Returns +/// +/// Vector of UUIDs for all objects that intersect the rectangle +pub fn hit_test_objects_in_rect( + layer: &VectorLayer, + rect: Rect, + parent_transform: Affine, +) -> Vec { + let mut hits = Vec::new(); + + for object in &layer.objects { + // Get the shape for this object + if let Some(shape) = layer.get_shape(&object.shape_id) { + // Combine parent transform with object transform + let combined_transform = parent_transform * object.to_affine(); + + // Get shape bounding box in local space + let bbox = shape.path().bounding_box(); + + // Transform bounding box to screen space + let transformed_bbox = combined_transform.transform_rect_bbox(bbox); + + // Check if rectangles intersect + if rect.intersect(transformed_bbox).area() > 0.0 { + hits.push(object.id); + } + } + } + + hits +} + +/// Get the bounding box of an object in screen space +/// +/// # Arguments +/// +/// * `object` - The object to get bounds for +/// * `shape` - The shape definition +/// * `parent_transform` - Transform from parent GraphicsObject(s) +/// +/// # Returns +/// +/// The bounding box in screen/canvas space +pub fn get_object_bounds( + object: &Object, + shape: &Shape, + parent_transform: Affine, +) -> Rect { + let combined_transform = parent_transform * object.to_affine(); + let local_bbox = shape.path().bounding_box(); + combined_transform.transform_rect_bbox(local_bbox) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shape::ShapeColor; + use vello::kurbo::{Circle, Shape as KurboShape}; + + #[test] + fn test_hit_test_simple_circle() { + let mut layer = VectorLayer::new("Test Layer"); + + // Create a circle at (100, 100) with radius 50 + let circle = Circle::new((100.0, 100.0), 50.0); + let path = circle.to_path(0.1); + let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0)); + let object = Object::new(shape.id); + + layer.add_shape(shape); + layer.add_object(object); + + // Test hit inside circle + let hit = hit_test_layer(&layer, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY); + assert!(hit.is_some()); + + // Test miss outside circle + let miss = hit_test_layer(&layer, Point::new(200.0, 200.0), 0.0, Affine::IDENTITY); + assert!(miss.is_none()); + } + + #[test] + fn test_hit_test_with_transform() { + let mut layer = VectorLayer::new("Test Layer"); + + // Create a circle at origin + let circle = Circle::new((0.0, 0.0), 50.0); + let path = circle.to_path(0.1); + let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0)); + + // Create object with translation + let object = Object::new(shape.id).with_position(100.0, 100.0); + + layer.add_shape(shape); + layer.add_object(object); + + // Test hit at translated position + let hit = hit_test_layer(&layer, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY); + assert!(hit.is_some()); + + // Test miss at origin (where shape is defined, but object is translated) + let miss = hit_test_layer(&layer, Point::new(0.0, 0.0), 0.0, Affine::IDENTITY); + assert!(miss.is_none()); + } + + #[test] + fn test_marquee_selection() { + let mut layer = VectorLayer::new("Test Layer"); + + // Create two circles + let circle1 = Circle::new((50.0, 50.0), 20.0); + let path1 = circle1.to_path(0.1); + let shape1 = Shape::new(path1).with_fill(ShapeColor::rgb(255, 0, 0)); + let object1 = Object::new(shape1.id); + + let circle2 = Circle::new((150.0, 150.0), 20.0); + let path2 = circle2.to_path(0.1); + let shape2 = Shape::new(path2).with_fill(ShapeColor::rgb(0, 255, 0)); + let object2 = Object::new(shape2.id); + + layer.add_shape(shape1); + layer.add_object(object1); + layer.add_shape(shape2); + layer.add_object(object2); + + // Marquee that contains both circles + let rect = Rect::new(0.0, 0.0, 200.0, 200.0); + let hits = hit_test_objects_in_rect(&layer, rect, Affine::IDENTITY); + assert_eq!(hits.len(), 2); + + // Marquee that contains only first circle + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + let hits = hit_test_objects_in_rect(&layer, rect, Affine::IDENTITY); + assert_eq!(hits.len(), 1); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index 676150f..cfd9744 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -158,6 +158,68 @@ impl VectorLayer { pub fn get_object_mut(&mut self, id: &Uuid) -> Option<&mut Object> { self.objects.iter_mut().find(|o| &o.id == id) } + + // === MUTATION METHODS (pub(crate) - only accessible to action module) === + + /// Add a shape to this layer (internal, for actions only) + /// + /// This method is intentionally `pub(crate)` to ensure mutations + /// only happen through the action system. + pub(crate) fn add_shape_internal(&mut self, shape: Shape) -> Uuid { + let id = shape.id; + self.shapes.push(shape); + id + } + + /// Add an object to this layer (internal, for actions only) + /// + /// This method is intentionally `pub(crate)` to ensure mutations + /// only happen through the action system. + pub(crate) fn add_object_internal(&mut self, object: Object) -> Uuid { + let id = object.id; + self.objects.push(object); + id + } + + /// Remove a shape from this layer (internal, for actions only) + /// + /// Returns the removed shape if found. + /// This method is intentionally `pub(crate)` to ensure mutations + /// only happen through the action system. + pub(crate) fn remove_shape_internal(&mut self, id: &Uuid) -> Option { + if let Some(index) = self.shapes.iter().position(|s| &s.id == id) { + Some(self.shapes.remove(index)) + } else { + None + } + } + + /// Remove an object from this layer (internal, for actions only) + /// + /// Returns the removed object if found. + /// This method is intentionally `pub(crate)` to ensure mutations + /// only happen through the action system. + pub(crate) fn remove_object_internal(&mut self, id: &Uuid) -> Option { + if let Some(index) = self.objects.iter().position(|o| &o.id == id) { + Some(self.objects.remove(index)) + } else { + None + } + } + + /// Modify an object in place (internal, for actions only) + /// + /// Applies the given function to the object if found. + /// This method is intentionally `pub(crate)` to ensure mutations + /// only happen through the action system. + pub(crate) fn modify_object_internal(&mut self, id: &Uuid, f: F) + where + F: FnOnce(&mut Object), + { + if let Some(object) = self.get_object_mut(id) { + f(object); + } + } } /// Audio layer (placeholder for future implementation) diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 88a7970..7115301 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -11,3 +11,7 @@ pub mod object; pub mod layer; pub mod document; pub mod renderer; +pub mod action; +pub mod actions; +pub mod selection; +pub mod hit_test; diff --git a/lightningbeam-ui/lightningbeam-core/src/object.rs b/lightningbeam-ui/lightningbeam-core/src/object.rs index 33df32c..a20c25e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/object.rs +++ b/lightningbeam-ui/lightningbeam-core/src/object.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use vello::kurbo::Shape as KurboShape; /// 2D transform for an object #[derive(Clone, Debug, Serialize, Deserialize)] @@ -170,6 +171,20 @@ impl Object { self.transform.set_position(x, y); self } + + /// Convert object transform to affine matrix + pub fn to_affine(&self) -> kurbo::Affine { + self.transform.to_affine() + } + + /// Get the bounding box of this object given its shape + /// + /// Returns the bounding box in the object's parent coordinate space + /// (i.e., with the object's transform applied). + pub fn bounding_box(&self, shape: &crate::shape::Shape) -> kurbo::Rect { + let path_bbox = shape.path().bounding_box(); + self.to_affine().transform_rect_bbox(path_bbox) + } } #[cfg(test)] diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs new file mode 100644 index 0000000..85d2068 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -0,0 +1,223 @@ +//! Selection state management +//! +//! Tracks selected objects and shapes for editing operations. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Selection state for the editor +/// +/// Maintains sets of selected objects and shapes. +/// This is separate from the document to make it easy to +/// pass around for UI rendering without needing mutable access. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Selection { + /// Currently selected objects (instances) + selected_objects: Vec, + + /// Currently selected shapes (definitions) + selected_shapes: Vec, +} + +impl Selection { + /// Create a new empty selection + pub fn new() -> Self { + Self { + selected_objects: Vec::new(), + selected_shapes: Vec::new(), + } + } + + /// Add an object to the selection + pub fn add_object(&mut self, id: Uuid) { + if !self.selected_objects.contains(&id) { + self.selected_objects.push(id); + } + } + + /// Add a shape to the selection + pub fn add_shape(&mut self, id: Uuid) { + if !self.selected_shapes.contains(&id) { + self.selected_shapes.push(id); + } + } + + /// Remove an object from the selection + pub fn remove_object(&mut self, id: &Uuid) { + self.selected_objects.retain(|&x| x != *id); + } + + /// Remove a shape from the selection + pub fn remove_shape(&mut self, id: &Uuid) { + self.selected_shapes.retain(|&x| x != *id); + } + + /// Toggle an object's selection state + pub fn toggle_object(&mut self, id: Uuid) { + if self.contains_object(&id) { + self.remove_object(&id); + } else { + self.add_object(id); + } + } + + /// Toggle a shape's selection state + pub fn toggle_shape(&mut self, id: Uuid) { + if self.contains_shape(&id) { + self.remove_shape(&id); + } else { + self.add_shape(id); + } + } + + /// Clear all selections + pub fn clear(&mut self) { + self.selected_objects.clear(); + self.selected_shapes.clear(); + } + + /// Clear only object selections + pub fn clear_objects(&mut self) { + self.selected_objects.clear(); + } + + /// Clear only shape selections + pub fn clear_shapes(&mut self) { + self.selected_shapes.clear(); + } + + /// Check if an object is selected + pub fn contains_object(&self, id: &Uuid) -> bool { + self.selected_objects.contains(id) + } + + /// Check if a shape is selected + pub fn contains_shape(&self, id: &Uuid) -> bool { + self.selected_shapes.contains(id) + } + + /// Check if selection is empty + pub fn is_empty(&self) -> bool { + self.selected_objects.is_empty() && self.selected_shapes.is_empty() + } + + /// Get the selected objects + pub fn objects(&self) -> &[Uuid] { + &self.selected_objects + } + + /// Get the selected shapes + pub fn shapes(&self) -> &[Uuid] { + &self.selected_shapes + } + + /// Get the number of selected objects + pub fn object_count(&self) -> usize { + self.selected_objects.len() + } + + /// Get the number of selected shapes + pub fn shape_count(&self) -> usize { + self.selected_shapes.len() + } + + /// Set selection to a single object (clears previous selection) + pub fn select_only_object(&mut self, id: Uuid) { + self.clear(); + self.add_object(id); + } + + /// Set selection to a single shape (clears previous selection) + pub fn select_only_shape(&mut self, id: Uuid) { + self.clear(); + self.add_shape(id); + } + + /// Set selection to multiple objects (clears previous selection) + pub fn select_objects(&mut self, ids: &[Uuid]) { + self.clear_objects(); + for &id in ids { + self.add_object(id); + } + } + + /// Set selection to multiple shapes (clears previous selection) + pub fn select_shapes(&mut self, ids: &[Uuid]) { + self.clear_shapes(); + for &id in ids { + self.add_shape(id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_selection_creation() { + let selection = Selection::new(); + assert!(selection.is_empty()); + assert_eq!(selection.object_count(), 0); + assert_eq!(selection.shape_count(), 0); + } + + #[test] + fn test_add_remove_objects() { + let mut selection = Selection::new(); + let id1 = Uuid::new_v4(); + let id2 = Uuid::new_v4(); + + selection.add_object(id1); + assert_eq!(selection.object_count(), 1); + assert!(selection.contains_object(&id1)); + + selection.add_object(id2); + assert_eq!(selection.object_count(), 2); + + selection.remove_object(&id1); + assert_eq!(selection.object_count(), 1); + assert!(!selection.contains_object(&id1)); + assert!(selection.contains_object(&id2)); + } + + #[test] + fn test_toggle() { + let mut selection = Selection::new(); + let id = Uuid::new_v4(); + + selection.toggle_object(id); + assert!(selection.contains_object(&id)); + + selection.toggle_object(id); + assert!(!selection.contains_object(&id)); + } + + #[test] + fn test_select_only() { + let mut selection = Selection::new(); + let id1 = Uuid::new_v4(); + let id2 = Uuid::new_v4(); + + selection.add_object(id1); + selection.add_object(id2); + assert_eq!(selection.object_count(), 2); + + selection.select_only_object(id1); + assert_eq!(selection.object_count(), 1); + assert!(selection.contains_object(&id1)); + assert!(!selection.contains_object(&id2)); + } + + #[test] + fn test_clear() { + let mut selection = Selection::new(); + selection.add_object(Uuid::new_v4()); + selection.add_shape(Uuid::new_v4()); + + assert!(!selection.is_empty()); + + selection.clear(); + assert!(selection.is_empty()); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/shape.rs b/lightningbeam-ui/lightningbeam-core/src/shape.rs index d9cfe01..01380dd 100644 --- a/lightningbeam-ui/lightningbeam-core/src/shape.rs +++ b/lightningbeam-ui/lightningbeam-core/src/shape.rs @@ -299,6 +299,14 @@ impl Shape { self.fill_rule = rule; self } + + /// Get the base path (first version) for this shape + /// + /// This is useful for hit testing and bounding box calculations + /// when shape morphing is not being considered. + pub fn path(&self) -> &BezPath { + &self.versions[0].path + } } #[cfg(test)] diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index 31546b2..6e6b2ae 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -3,6 +3,9 @@ /// Defines the available drawing/editing tools use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; +use vello::kurbo::Point; /// Drawing and editing tools #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -24,6 +27,86 @@ pub enum Tool { Eyedropper, } +/// Tool state tracking for interactive operations +#[derive(Debug, Clone)] +pub enum ToolState { + /// Tool is idle (no operation in progress) + Idle, + + /// Drawing a freehand path + DrawingPath { + points: Vec, + simplify_mode: SimplifyMode, + }, + + /// Dragging selected objects + DraggingSelection { + start_pos: Point, + start_mouse: Point, + original_positions: HashMap, + }, + + /// Creating a marquee selection rectangle + MarqueeSelecting { + start: Point, + current: Point, + }, + + /// Creating a rectangle shape + CreatingRectangle { + start_corner: Point, + current_corner: Point, + }, + + /// Creating an ellipse shape + CreatingEllipse { + center: Point, + current_point: Point, + }, + + /// Transforming selected objects (scale, rotate) + Transforming { + mode: TransformMode, + original_transforms: HashMap, + pivot: Point, + }, +} + +/// Path simplification mode for the draw tool +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SimplifyMode { + /// Ramer-Douglas-Peucker corner detection + Corners, + /// Schneider curve fitting for smooth curves + Smooth, + /// No simplification (use raw points) + Verbatim, +} + +/// Transform mode for the transform tool +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TransformMode { + /// Scale from a corner + ScaleCorner { origin: Point }, + /// Scale along an edge + ScaleEdge { axis: Axis, origin: Point }, + /// Rotate around a pivot + Rotate { center: Point }, +} + +/// Axis for edge scaling +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Axis { + Horizontal, + Vertical, +} + +impl Default for ToolState { + fn default() -> Self { + Self::Idle + } +} + impl Tool { /// Get display name for the tool pub fn display_name(self) -> &'static str { diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index f6f9c83..e7e35d1 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -35,3 +35,4 @@ resvg = { workspace = true } pollster = { workspace = true } lightningcss = "1.0.0-alpha.68" clap = { version = "4.5", features = ["derive"] } +uuid = { version = "1.0", features = ["v4", "serde"] } diff --git a/lightningbeam-ui/lightningbeam-editor/TOOL_IMPLEMENTATION_PLAN.md b/lightningbeam-ui/lightningbeam-editor/TOOL_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..ed27cd0 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/TOOL_IMPLEMENTATION_PLAN.md @@ -0,0 +1,759 @@ +# Tool Integration Implementation Plan +*Updated with correct architecture patterns from JS codebase* + +## Architecture Overview + +**Type-Safe Action System**: Document mutations only through `Action` trait +- Read: Public via `ActionExecutor::document()` +- Write: Only via `pub(crate)` methods in action implementations +- Enforcement: Rust's module privacy system + +**Key Corrections**: +- ✅ GraphicsObject nesting (recursive hit testing) +- ✅ Shape tools create `Shape` + `Object`, add to active `VectorLayer` +- ✅ Tools only work on `VectorLayer` (check `active_layer.type`) +- ✅ Path fitting uses JS algorithms (RDP or Schneider) +- ✅ Paint bucket uses vector flood fill with quadtree + +--- + +## Phase 1: Action System Foundation + +### 1.1 Create Action System Core +**File: `lightningbeam-core/src/action.rs`** + +```rust +pub trait Action: Send { + fn execute(&mut self, document: &mut Document); + fn rollback(&mut self, document: &mut Document); + fn description(&self) -> String; +} + +pub struct ActionExecutor { + document: Document, + undo_stack: Vec>, + redo_stack: Vec>, +} +``` + +Methods: +- `document(&self) -> &Document` - Read-only access +- `execute(&mut self, Box)` - Execute + push to undo +- `undo(&mut self) -> bool` - Pop and rollback +- `redo(&mut self) -> bool` - Re-execute from redo stack + +### 1.2 Update Document for Controlled Access +**File: `lightningbeam-core/src/document.rs`** + +Add `pub(crate)` mutation methods: +- `root_mut() -> &mut GraphicsObject` +- `get_layer_mut(&self, id: &Uuid) -> Option<&mut AnyLayer>` +- Keep all fields private +- Keep existing public read methods + +### 1.3 Update Layer for Shape Operations +**File: `lightningbeam-core/src/layer.rs`** + +Add `pub(crate)` methods to `VectorLayer`: +- `add_shape_internal(&mut self, shape: Shape) -> Uuid` +- `add_object_internal(&mut self, object: Object) -> Uuid` +- `remove_shape_internal(&mut self, id: &Uuid) -> Option` +- `remove_object_internal(&mut self, id: &Uuid) -> Option` +- `modify_object_internal(&mut self, id: &Uuid, f: impl FnOnce(&mut Object))` + +### 1.4 Integrate ActionExecutor into EditorApp +**File: `lightningbeam-editor/src/main.rs`** + +- Replace `document: Document` with `action_executor: ActionExecutor` +- Add `active_layer_id: Option` to track current layer +- Update `SharedPaneState` to pass `document: &Document` (read-only) +- Add `execute_action(&mut self, action: Box)` method +- Wire Ctrl+Z / Ctrl+Shift+Z to undo/redo + +--- + +## Phase 2: Selection System + +### 2.1 Create Selection State +**File: `lightningbeam-core/src/selection.rs`** + +```rust +pub struct Selection { + selected_objects: Vec, + selected_shapes: Vec, +} +``` + +Methods: `add`, `remove`, `clear`, `contains`, `is_empty`, `objects()`, `shapes()` + +### 2.2 Add to Editor State +Add to `EditorApp`: +- `selection: Selection` +- Pass through `SharedPaneState` (read-only for rendering, mutable for tools) + +--- + +## Phase 3: Hit Testing Infrastructure + +### 3.1 Hit Test Module +**File: `lightningbeam-core/src/hit_test.rs`** + +**Recursive Hit Testing through GraphicsObject hierarchy:** + +```rust +pub fn hit_test_layer( + layer: &VectorLayer, + point: Point, + tolerance: f64, + parent_transform: Affine, +) -> Option { + // Hit test objects in this layer + for object in layer.objects.iter().rev() { // Back to front + let shape = layer.get_shape(&object.shape_id)?; + + // Combine parent transform with object transform + let combined_transform = parent_transform * object.to_affine(); + + if hit_test_shape(shape, point, tolerance, combined_transform) { + return Some(object.id); + } + } + None +} + +fn hit_test_shape( + shape: &Shape, + point: Point, + tolerance: f64, + transform: Affine, +) -> bool { + // Transform point to shape's local space + let inverse_transform = transform.inverse(); + let local_point = inverse_transform * point; + + // Check if point is inside path (kurbo's contains()) + if shape.path.contains(local_point) { + return true; + } + + // Check stroke bounds if has stroke + if shape.stroke_style.is_some() { + let stroke_tolerance = shape.stroke_style.unwrap().width / 2.0 + tolerance; + // Check distance to path + // Use kurbo path methods for nearest point + } + + false +} +``` + +**Rectangle Hit Testing:** +```rust +pub fn hit_test_objects_in_rect( + layer: &VectorLayer, + rect: Rect, + parent_transform: Affine, +) -> Vec { + let mut hits = Vec::new(); + + for object in &layer.objects { + let shape = layer.get_shape(&object.shape_id).unwrap(); + let combined_transform = parent_transform * object.to_affine(); + let bbox = shape.path.bounding_box(); + let transformed_bbox = combined_transform.transform_rect_bbox(bbox); + + if rect.intersect(transformed_bbox).area() > 0.0 { + hits.push(object.id); + } + } + + hits +} +``` + +### 3.2 Bounding Box Calculation +Add to `lightningbeam-core/src/object.rs`: + +```rust +impl Object { + pub fn bounding_box(&self, shape: &Shape) -> Rect { + let path_bbox = shape.path.bounding_box(); + self.to_affine().transform_rect_bbox(path_bbox) + } +} +``` + +--- + +## Phase 4: Tool State Management + +### 4.1 Tool State Enum +**File: `lightningbeam-core/src/tool.rs`** + +```rust +pub enum ToolState { + Idle, + + DrawingPath { + points: Vec, + simplify_mode: SimplifyMode, // "corners" | "smooth" | "verbatim" + }, + + DraggingSelection { + start_pos: Point, + start_mouse: Point, + original_transforms: HashMap, + }, + + MarqueeSelecting { + start: Point, + current: Point, + }, + + CreatingRectangle { + start_corner: Point, + current_corner: Point, + }, + + CreatingEllipse { + center: Point, + current_point: Point, + }, + + Transforming { + mode: TransformMode, + original_transforms: HashMap, + pivot: Point, + }, +} + +pub enum SimplifyMode { + Corners, // Ramer-Douglas-Peucker + Smooth, // Schneider curve fitting + Verbatim, // No simplification +} +``` + +Add to `EditorApp`: `tool_state: ToolState` + +--- + +## Phase 5: Select Tool + +### 5.1 Active Layer Validation +**All tools check:** +```rust +// In Stage.handle_tool_input() +let Some(active_layer_id) = shared.active_layer_id else { + return None; // No active layer +}; + +let active_layer = shared.document.get_layer(active_layer_id)?; + +// Only work on VectorLayer +let AnyLayer::Vector(vector_layer) = active_layer else { + return None; // Not a vector layer +}; +``` + +### 5.2 Click Selection +**Mouse Down:** +- Hit test at click position using recursive `hit_test_layer()` +- If object found: + - If Shift: toggle in selection + - Else: replace selection with clicked object + - If already selected: enter `DraggingSelection` state +- If nothing found: enter `MarqueeSelecting` state + +**Mouse Drag (when dragging selection):** +- Calculate delta from start_mouse +- Update object positions (temporary, for preview) +- Re-render with updated positions + +**Mouse Up:** +- If was dragging: create `MoveObjectsAction` +- If was marquee: select objects in rectangle + +### 5.3 Move Objects Action +**File: `lightningbeam-core/src/actions/move_objects.rs`** + +```rust +pub struct MoveObjectsAction { + layer_id: Uuid, + object_transforms: HashMap, // (old, new) +} + +impl Action for MoveObjectsAction { + fn execute(&mut self, document: &mut Document) { + let layer = document.get_layer_mut(&self.layer_id).unwrap(); + if let AnyLayer::Vector(vector_layer) = layer { + for (object_id, (_old, new)) in &self.object_transforms { + vector_layer.modify_object_internal(object_id, |obj| { + obj.transform = new.clone(); + }); + } + } + } + + fn rollback(&mut self, document: &mut Document) { + let layer = document.get_layer_mut(&self.layer_id).unwrap(); + if let AnyLayer::Vector(vector_layer) = layer { + for (object_id, (old, _new)) in &self.object_transforms { + vector_layer.modify_object_internal(object_id, |obj| { + obj.transform = old.clone(); + }); + } + } + } +} +``` + +### 5.4 Selection Rendering +In `VelloCallback::prepare()`: +- After rendering document +- For each selected object ID: + - Get object and its shape from active layer + - Calculate bounding box (with transform) + - Draw selection outline (blue, 2px stroke) + +--- + +## Phase 6: Rectangle & Ellipse Tools + +### 6.1 Add Shape Action +**File: `lightningbeam-core/src/actions/add_shape.rs`** + +```rust +pub struct AddShapeAction { + layer_id: Uuid, + shape: Shape, + object: Object, + created_shape_id: Option, + created_object_id: Option, +} + +impl Action for AddShapeAction { + fn execute(&mut self, document: &mut Document) { + let layer = document.get_layer_mut(&self.layer_id).unwrap(); + if let AnyLayer::Vector(vector_layer) = layer { + let shape_id = vector_layer.add_shape_internal(self.shape.clone()); + let object_id = vector_layer.add_object_internal(self.object.clone()); + self.created_shape_id = Some(shape_id); + self.created_object_id = Some(object_id); + } + } + + fn rollback(&mut self, document: &mut Document) { + if let (Some(shape_id), Some(object_id)) = (self.created_shape_id, self.created_object_id) { + let layer = document.get_layer_mut(&self.layer_id).unwrap(); + if let AnyLayer::Vector(vector_layer) = layer { + vector_layer.remove_object_internal(&object_id); + vector_layer.remove_shape_internal(&shape_id); + } + } + } +} +``` + +### 6.2 Rectangle Tool +**Mouse Down:** Enter `CreatingRectangle { start_corner, current_corner }` + +**Mouse Drag:** +- Update current_corner +- If Shift: constrain to square (equal width/height) +- Create preview path: `Rect::from_points(start, current).to_path()` +- Render preview with dashed stroke + +**Mouse Up:** +- Create `Shape` with rectangle path +- Create `Object` at (0, 0) with shape_id +- Return `AddShapeAction { layer_id, shape, object }` + +### 6.3 Ellipse Tool +**Mouse Down:** Enter `CreatingEllipse { center, current_point }` + +**Mouse Drag:** +- Calculate radii from center to current_point +- If Shift: constrain to circle (equal radii) +- Create preview: `Circle::new(center, radius).to_path()` +- Render preview + +**Mouse Up:** +- Create `Shape` with ellipse path +- Create `Object` with shape_id +- Return `AddShapeAction` + +--- + +## Phase 7: Draw/Pen Tool + +### 7.1 Path Fitting Module +**File: `lightningbeam-core/src/path_fitting.rs`** + +**Implement two algorithms from JS:** + +#### A. Ramer-Douglas-Peucker Simplification +```rust +pub fn simplify_rdp(points: &[Point], tolerance: f64) -> Vec { + // Port from /src/simplify.js + // 1. Radial distance filter first + // 2. Then Douglas-Peucker recursive simplification + // Tolerance: 10 (squared internally) +} +``` + +#### B. Schneider Curve Fitting +```rust +pub fn fit_bezier_curves(points: &[Point], max_error: f64) -> BezPath { + // Port from /src/fit-curve.js + // Based on Graphics Gems algorithm + // 1. Chord-length parameterization + // 2. Least-squares fit for control points + // 3. Newton-Raphson refinement (max 20 iterations) + // 4. Recursive split at max error point if needed + // max_error: 30 +} +``` + +### 7.2 Draw Tool Implementation +**Mouse Down:** Enter `DrawingPath { points: vec![start], simplify_mode }` + +**Mouse Drag:** +- Add point if distance from last point > threshold (2-5 pixels) +- Build preview path from points +- Render preview + +**Mouse Up:** +- Based on `simplify_mode`: + - **Corners**: Apply RDP simplification (tolerance=10), then create mid-point Beziers + - **Smooth**: Apply Schneider curve fitting (error=30) + - **Verbatim**: Use points as-is +- Create `Shape` with fitted path +- Create `Object` with shape_id +- Return `AddShapeAction` + +**Simplify Mode Setting:** +Add to `EditorApp`: `pen_simplify_mode: SimplifyMode` +Show in info panel / toolbar + +--- + +## Phase 8: Transform Tool + +### 8.1 Transform Handles +In `VelloCallback::prepare()` when `Tool::Transform` and selection non-empty: + +Calculate selection bbox (union of all selected object bboxes): +```rust +let mut bbox = Rect::ZERO; +for object_id in selection.objects() { + let object = get_object(object_id); + let shape = get_shape(object.shape_id); + bbox = bbox.union(object.bounding_box(shape)); +} +``` + +Render 8 handles + rotation handle: +- 4 corners (8x8 squares) → scale from opposite corner +- 4 edge midpoints → scale along axis +- 1 rotation handle (circle, 20px above top edge) +- Bounding box outline + +### 8.2 Handle Hit Testing +```rust +fn hit_test_transform_handle( + point: Point, + bbox: Rect, + tolerance: f64, +) -> Option { + // Check rotation handle first + let rotation_handle = Point::new(bbox.center().x, bbox.min_y() - 20.0); + if point.distance(rotation_handle) < tolerance { + return Some(TransformMode::Rotate { center: bbox.center() }); + } + + // Check corner handles + let corners = [bbox.origin(), /* ... */]; + for (i, corner) in corners.iter().enumerate() { + if point.distance(*corner) < tolerance { + let opposite = corners[(i + 2) % 4]; + return Some(TransformMode::ScaleCorner { origin: opposite }); + } + } + + // Check edge handles + // ... +} +``` + +### 8.3 Transform Interaction +**Mouse Down on handle:** +- Enter `Transforming { mode, original_transforms, pivot }` + +**Mouse Drag:** +- Calculate new transform based on mode: + - **ScaleCorner**: Compute scale from opposite corner + - **ScaleEdge**: Scale along one axis + - **Rotate**: Compute angle from pivot to cursor +- Apply to all selected objects (preview) + +**Mouse Up:** +- Create `TransformObjectsAction` +- Return for execution + +### 8.4 Transform Action +**File: `lightningbeam-core/src/actions/transform.rs`** + +```rust +pub struct TransformObjectsAction { + layer_id: Uuid, + object_transforms: HashMap, // (old, new) +} +``` + +Similar to MoveObjectsAction but updates full Transform struct. + +--- + +## Phase 9: Paint Bucket Tool + +### 9.1 Quadtree for Curve Indexing +**File: `lightningbeam-core/src/quadtree.rs`** + +Port from JS (`/src/utils.js`): +```rust +pub struct Quadtree { + bounds: Rect, + capacity: usize, + curves: Vec<(BezPath, usize)>, // (curve, index) + subdivided: bool, + // children: [Box; 4] +} + +impl Quadtree { + pub fn insert(&mut self, curve: BezPath, index: usize); + pub fn query(&self, range: Rect) -> Vec; // Return curve indices +} +``` + +### 9.2 Vector Flood Fill +**File: `lightningbeam-core/src/flood_fill.rs`** + +Port from JS (`/src/utils.js` lines 173-307): + +```rust +pub struct FloodFillRegion { + start_point: Point, + epsilon: f64, // Gap closing tolerance (default: 5) + canvas_bounds: Rect, +} + +impl FloodFillRegion { + pub fn fill( + &self, + shapes: &[Shape], // All visible shapes on layer + ) -> Result, String> { + // 1. Build quadtree for all curves in all shapes + // 2. Stack-based flood fill + // 3. For each point: + // - Check if near any curve (using quadtree query + projection) + // - If near curve (within epsilon): save projection point, stop expanding + // - If not near: expand to 4 neighbors + // 4. Return boundary points (projections on curves) + // 5. If < 10 points found, retry with epsilon=1 + } + + fn is_near_curve( + &self, + point: Point, + shape: &Shape, + quadtree: &Quadtree, + ) -> Option { + let query_bbox = Rect::new( + point.x - self.epsilon/2.0, + point.y - self.epsilon/2.0, + point.x + self.epsilon/2.0, + point.y + self.epsilon/2.0, + ); + + for curve_idx in quadtree.query(query_bbox) { + let curve = &shape.curves[curve_idx]; + let projection = curve.nearest(point, 0.1); // kurbo's nearest point + if projection.distance_sq < self.epsilon * self.epsilon { + return Some(projection.point); + } + } + None + } +} +``` + +### 9.3 Point Sorting +```rust +fn sort_points_by_proximity(points: Vec) -> Vec { + // Port from JS lines 276-307 + // Greedy nearest-neighbor sort to create coherent path +} +``` + +### 9.4 Paint Bucket Action +**File: `lightningbeam-core/src/actions/paint_bucket.rs`** + +```rust +pub struct PaintBucketAction { + layer_id: Uuid, + click_point: Point, + epsilon: f64, + created_shape_id: Option, + created_object_id: Option, +} + +impl Action for PaintBucketAction { + fn execute(&mut self, document: &mut Document) { + let layer = document.get_layer(&self.layer_id).unwrap(); + let AnyLayer::Vector(vector_layer) = layer else { return }; + + // Get all shapes in layer + let shapes: Vec<_> = vector_layer.shapes.clone(); + + // Perform flood fill + let fill_region = FloodFillRegion { + start_point: self.click_point, + epsilon: self.epsilon, + canvas_bounds: Rect::new(0.0, 0.0, document.width, document.height), + }; + + let boundary_points = fill_region.fill(&shapes)?; + + // Sort points by proximity + let sorted_points = sort_points_by_proximity(boundary_points); + + // Fit curve with very low error (1.0) for precision + let path = fit_bezier_curves(&sorted_points, 1.0); + + // Create filled shape + let shape = Shape::new(path) + .with_fill(/* current fill color */) + .without_stroke(); + + // Create object + let object = Object::new(shape.id); + + // Add to layer + let layer = document.get_layer_mut(&self.layer_id).unwrap(); + if let AnyLayer::Vector(vector_layer) = layer { + self.created_shape_id = Some(vector_layer.add_shape_internal(shape)); + self.created_object_id = Some(vector_layer.add_object_internal(object)); + } + } + + fn rollback(&mut self, document: &mut Document) { + // Remove created shape and object + } +} +``` + +### 9.5 Paint Bucket Tool Handler +In `handle_tool_input()` when `Tool::PaintBucket`: + +**Mouse Click:** +- Get click position +- Create `PaintBucketAction { click_point, epsilon: 5.0 }` +- Return action for execution +- Tool stays active for multiple fills + +--- + +## Phase 10: Eyedropper Tool + +### 10.1 Color Sampling +In `handle_tool_input()` when `Tool::Eyedropper`: + +**Mouse Click:** +- Hit test at cursor position +- If object found: + - Get object's shape + - Read shape's fill_color + - Update `fill_color` in EditorApp + - Show toast/feedback with sampled color +- Tool stays active + +**Visual Feedback:** +- Custom cursor showing crosshair +- Color preview circle at cursor +- Display hex value + +--- + +## Implementation Order + +### Sprint 1: Foundation (3-4 days) +- [ ] Action system (ActionExecutor, Action trait) +- [ ] Document controlled access (pub(crate) methods) +- [ ] Integrate ActionExecutor into EditorApp +- [ ] Undo/redo shortcuts (Ctrl+Z, Ctrl+Shift+Z) + +### Sprint 2: Selection (3-4 days) +- [ ] Selection state struct +- [ ] Recursive hit testing (through GraphicsObject hierarchy) +- [ ] Active layer tracking +- [ ] Selection rendering +- [ ] Click selection + +### Sprint 3: Select Tool (4-5 days) +- [ ] Tool state management +- [ ] Stage input handling refactor +- [ ] Layer type validation +- [ ] Drag-to-move (MoveObjectsAction) +- [ ] Marquee selection + +### Sprint 4: Shape Tools (4-5 days) +- [ ] AddShapeAction +- [ ] Rectangle tool (with Shift constraint) +- [ ] Ellipse tool (with Shift constraint) +- [ ] Preview rendering +- [ ] Integration with active layer + +### Sprint 5: Draw Tool (5-6 days) +- [ ] RDP simplification algorithm +- [ ] Schneider curve fitting algorithm +- [ ] Path fitting module +- [ ] Draw tool with mode selection +- [ ] Preview rendering + +### Sprint 6: Transform Tool (5-6 days) +- [ ] Transform handle rendering +- [ ] Handle hit testing +- [ ] Scale operations +- [ ] Rotate operation +- [ ] TransformObjectsAction + +### Sprint 7: Paint Bucket (6-7 days) +- [ ] Quadtree implementation +- [ ] Vector flood fill algorithm +- [ ] Point sorting +- [ ] Curve fitting integration +- [ ] PaintBucketAction + +### Sprint 8: Polish (2-3 days) +- [ ] Eyedropper tool +- [ ] Tool cursors +- [ ] Edge cases and bugs + +**Total: ~6-7 weeks** + +--- + +## Key Architectural Corrections + +✅ **GraphicsObject Nesting**: Hit testing uses recursive transform multiplication through parent hierarchy + +✅ **Shape Creation**: Tools create `Shape` instances, then `Object` instances pointing to them, add both to `VectorLayer` + +✅ **Layer Type Validation**: Check `active_layer` is `VectorLayer` before tool operations + +✅ **Path Fitting**: Port exact JS algorithms (RDP tolerance=10, Schneider error=30) + +✅ **Paint Bucket**: Vector-based flood fill with quadtree-accelerated curve projection + +✅ **Type Safety**: Compile-time enforcement that document mutations only through actions diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 98a5723..f901f68 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -4,6 +4,7 @@ use lightningbeam_core::pane::PaneType; use lightningbeam_core::tool::Tool; use std::collections::HashMap; use clap::Parser; +use uuid::Uuid; mod panes; use panes::{PaneInstance, PaneRenderer, SharedPaneState}; @@ -230,7 +231,10 @@ struct EditorApp { menu_system: Option, // Native menu system for event checking pending_view_action: Option, // Pending view action (zoom, recenter) to be handled by hovered pane theme: Theme, // Theme system for colors and dimensions - document: lightningbeam_core::document::Document, // Active document being edited + action_executor: lightningbeam_core::action::ActionExecutor, // Action system for undo/redo + active_layer_id: Option, // Currently active layer for editing + selection: lightningbeam_core::selection::Selection, // Current selection state + tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state } impl EditorApp { @@ -259,7 +263,10 @@ impl EditorApp { let mut vector_layer = VectorLayer::new("Layer 1"); vector_layer.add_shape(shape); vector_layer.add_object(object); - document.root.add_child(AnyLayer::Vector(vector_layer)); + let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); + + // Wrap document in ActionExecutor + let action_executor = lightningbeam_core::action::ActionExecutor::new(document); Self { layouts, @@ -278,7 +285,10 @@ impl EditorApp { menu_system, pending_view_action: None, theme, - document, + action_executor, + active_layer_id: Some(layer_id), + selection: lightningbeam_core::selection::Selection::new(), + tool_state: lightningbeam_core::tool::ToolState::default(), } } @@ -361,12 +371,18 @@ impl EditorApp { // Edit menu MenuAction::Undo => { - println!("Menu: Undo"); - // TODO: Implement undo + if self.action_executor.undo() { + println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()); + } else { + println!("Nothing to undo"); + } } MenuAction::Redo => { - println!("Menu: Redo"); - // TODO: Implement redo + if self.action_executor.redo() { + println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()); + } else { + println!("Nothing to redo"); + } } MenuAction::Cut => { println!("Menu: Cut"); @@ -571,6 +587,9 @@ impl eframe::App for EditorApp { // Registry for view action handlers (two-phase dispatch) let mut pending_handlers: Vec = Vec::new(); + // Registry for actions to execute after rendering (two-phase dispatch) + let mut pending_actions: Vec> = Vec::new(); + render_layout_node( ui, &mut self.current_layout, @@ -591,7 +610,11 @@ impl eframe::App for EditorApp { &mut fallback_pane_priority, &mut pending_handlers, &self.theme, - &mut self.document, + self.action_executor.document(), + &mut self.selection, + &self.active_layer_id, + &mut self.tool_state, + &mut pending_actions, ); // Execute action on the best handler (two-phase dispatch) @@ -611,6 +634,11 @@ impl eframe::App for EditorApp { self.pending_view_action = None; } + // Execute all pending actions (two-phase dispatch) + for action in pending_actions { + self.action_executor.execute(action); + } + // Set cursor based on hover state if let Some((_, is_horizontal)) = self.hovered_divider { if is_horizontal { @@ -669,11 +697,15 @@ fn render_layout_node( fallback_pane_priority: &mut Option, pending_handlers: &mut Vec, theme: &Theme, - document: &mut lightningbeam_core::document::Document, + document: &lightningbeam_core::document::Document, + selection: &mut lightningbeam_core::selection::Selection, + active_layer_id: &Option, + tool_state: &mut lightningbeam_core::tool::ToolState, + pending_actions: &mut Vec>, ) { match node { LayoutNode::Pane { name } => { - render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, document); + render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, document, selection, active_layer_id, tool_state, pending_actions); } LayoutNode::HorizontalGrid { percent, children } => { // Handle dragging @@ -718,6 +750,10 @@ fn render_layout_node( pending_handlers, theme, document, + selection, + active_layer_id, + tool_state, + pending_actions, ); let mut right_path = path.clone(); @@ -743,6 +779,10 @@ fn render_layout_node( pending_handlers, theme, document, + selection, + active_layer_id, + tool_state, + pending_actions, ); // Draw divider with interaction @@ -860,6 +900,10 @@ fn render_layout_node( pending_handlers, theme, document, + selection, + active_layer_id, + tool_state, + pending_actions, ); let mut bottom_path = path.clone(); @@ -885,6 +929,10 @@ fn render_layout_node( pending_handlers, theme, document, + selection, + active_layer_id, + tool_state, + pending_actions, ); // Draw divider with interaction @@ -981,7 +1029,11 @@ fn render_pane( fallback_pane_priority: &mut Option, pending_handlers: &mut Vec, theme: &Theme, - document: &mut lightningbeam_core::document::Document, + document: &lightningbeam_core::document::Document, + selection: &mut lightningbeam_core::selection::Selection, + active_layer_id: &Option, + tool_state: &mut lightningbeam_core::tool::ToolState, + pending_actions: &mut Vec>, ) { let pane_type = PaneType::from_name(pane_name); @@ -1157,6 +1209,10 @@ fn render_pane( theme, pending_handlers, document, + selection, + active_layer_id, + tool_state, + pending_actions, }; pane_instance.render_header(&mut header_ui, &mut shared); } @@ -1193,6 +1249,10 @@ fn render_pane( theme, pending_handlers, document, + selection, + active_layer_id, + tool_state, + pending_actions, }; // Render pane content (header was already rendered above) diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs.backup b/lightningbeam-ui/lightningbeam-editor/src/main.rs.backup new file mode 100644 index 0000000..3ffdea7 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs.backup @@ -0,0 +1,1568 @@ +use eframe::egui; +use lightningbeam_core::layout::{LayoutDefinition, LayoutNode}; +use lightningbeam_core::pane::PaneType; +use lightningbeam_core::tool::Tool; +use std::collections::HashMap; +use clap::Parser; +use uuid::Uuid; + +mod panes; +use panes::{PaneInstance, PaneRenderer, SharedPaneState}; + +mod menu; +use menu::{MenuAction, MenuSystem}; + +mod theme; +use theme::{Theme, ThemeMode}; + +/// Lightningbeam Editor - Animation and video editing software +#[derive(Parser, Debug)] +#[command(name = "Lightningbeam Editor")] +#[command(author, version, about, long_about = None)] +struct Args { + /// Use light theme + #[arg(long, conflicts_with = "dark")] + light: bool, + + /// Use dark theme + #[arg(long, conflicts_with = "light")] + dark: bool, +} + +fn main() -> eframe::Result { + println!("🚀 Starting Lightningbeam Editor..."); + + // Parse command line arguments + let args = Args::parse(); + + // Determine theme mode from arguments + let theme_mode = if args.light { + ThemeMode::Light + } else if args.dark { + ThemeMode::Dark + } else { + ThemeMode::System + }; + + // Load theme + let mut theme = Theme::load_default().expect("Failed to load theme"); + theme.set_mode(theme_mode); + println!("✅ Loaded theme with {} selectors (mode: {:?})", theme.len(), theme_mode); + + // Debug: print theme info + theme.debug_print(); + + // Load layouts from JSON + let layouts = load_layouts(); + println!("✅ Loaded {} layouts", layouts.len()); + for layout in &layouts { + println!(" - {}: {}", layout.name, layout.description); + } + + // Initialize native menus for macOS (app-wide, doesn't need window) + #[cfg(target_os = "macos")] + { + if let Ok(menu_system) = MenuSystem::new() { + menu_system.init_for_macos(); + println!("✅ Native macOS menus initialized"); + } + } + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([1920.0, 1080.0]) + .with_title("Lightningbeam Editor"), + ..Default::default() + }; + + eframe::run_native( + "Lightningbeam Editor", + options, + Box::new(move |cc| Ok(Box::new(EditorApp::new(cc, layouts, theme)))), + ) +} + +fn load_layouts() -> Vec { + let json = include_str!("../assets/layouts.json"); + serde_json::from_str(json).expect("Failed to parse layouts.json") +} + +/// Path to a node in the layout tree (indices of children) +type NodePath = Vec; + +#[derive(Default)] +struct DragState { + is_dragging: bool, + node_path: NodePath, + is_horizontal: bool, +} + +/// Action to perform on the layout tree +enum LayoutAction { + SplitHorizontal(NodePath, f32), // path, percent + SplitVertical(NodePath, f32), // path, percent + RemoveSplit(NodePath), + EnterSplitPreviewHorizontal, + EnterSplitPreviewVertical, +} + +#[derive(Default)] +enum SplitPreviewMode { + #[default] + None, + Active { + is_horizontal: bool, + hovered_pane: Option, + split_percent: f32, + }, +} + +/// Icon cache for pane type icons +struct IconCache { + icons: HashMap, + assets_path: std::path::PathBuf, +} + +impl IconCache { + fn new() -> Self { + let assets_path = std::path::PathBuf::from( + std::env::var("HOME").unwrap_or_else(|_| "/home/skyler".to_string()) + ).join("Dev/Lightningbeam-2/src/assets"); + + Self { + icons: HashMap::new(), + assets_path, + } + } + + fn get_or_load(&mut self, pane_type: PaneType) -> Option<&egui_extras::RetainedImage> { + if !self.icons.contains_key(&pane_type) { + // Load and cache the icon + let icon_path = self.assets_path.join(pane_type.icon_file()); + if let Ok(image) = egui_extras::RetainedImage::from_svg_bytes( + pane_type.icon_file(), + &std::fs::read(&icon_path).unwrap_or_default(), + ) { + self.icons.insert(pane_type, image); + } + } + self.icons.get(&pane_type) + } +} + +/// Icon cache for tool icons +struct ToolIconCache { + icons: HashMap, + assets_path: std::path::PathBuf, +} + +impl ToolIconCache { + fn new() -> Self { + let assets_path = std::path::PathBuf::from( + std::env::var("HOME").unwrap_or_else(|_| "/home/skyler".to_string()) + ).join("Dev/Lightningbeam-2/src/assets"); + + Self { + icons: HashMap::new(), + assets_path, + } + } + + fn get_or_load(&mut self, tool: Tool, ctx: &egui::Context) -> Option<&egui::TextureHandle> { + if !self.icons.contains_key(&tool) { + // Load SVG and rasterize at high resolution using resvg + let icon_path = self.assets_path.join(tool.icon_file()); + if let Ok(svg_data) = std::fs::read(&icon_path) { + // Rasterize at 3x size for crisp display (180px for 60px display) + let render_size = 180; + + if let Ok(tree) = resvg::usvg::Tree::from_data(&svg_data, &resvg::usvg::Options::default()) { + let pixmap_size = tree.size().to_int_size(); + let scale_x = render_size as f32 / pixmap_size.width() as f32; + let scale_y = render_size as f32 / pixmap_size.height() as f32; + let scale = scale_x.min(scale_y); + + let final_size = resvg::usvg::Size::from_wh( + pixmap_size.width() as f32 * scale, + pixmap_size.height() as f32 * scale, + ).unwrap_or(resvg::usvg::Size::from_wh(render_size as f32, render_size as f32).unwrap()); + + if let Some(mut pixmap) = resvg::tiny_skia::Pixmap::new( + final_size.width() as u32, + final_size.height() as u32, + ) { + let transform = resvg::tiny_skia::Transform::from_scale(scale, scale); + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + // Convert RGBA8 to egui ColorImage + let rgba_data = pixmap.data(); + let size = [pixmap.width() as usize, pixmap.height() as usize]; + let color_image = egui::ColorImage::from_rgba_unmultiplied(size, rgba_data); + + // Upload to GPU + let texture = ctx.load_texture( + tool.icon_file(), + color_image, + egui::TextureOptions::LINEAR, + ); + self.icons.insert(tool, texture); + } + } + } + } + self.icons.get(&tool) + } +} + +struct EditorApp { + layouts: Vec, + current_layout_index: usize, + current_layout: LayoutNode, // Mutable copy for editing + drag_state: DragState, + hovered_divider: Option<(NodePath, bool)>, // (path, is_horizontal) + selected_pane: Option, // Currently selected pane for editing + split_preview_mode: SplitPreviewMode, + icon_cache: IconCache, + tool_icon_cache: ToolIconCache, + selected_tool: Tool, // Currently selected drawing tool + fill_color: egui::Color32, // Fill color for drawing + stroke_color: egui::Color32, // Stroke color for drawing + pane_instances: HashMap, // Pane instances per path + menu_system: Option, // Native menu system for event checking + pending_view_action: Option, // Pending view action (zoom, recenter) to be handled by hovered pane + theme: Theme, // Theme system for colors and dimensions + action_executor: lightningbeam_core::action::ActionExecutor, // Action system for undo/redo + active_layer_id: Option, // Currently active layer for editing + selection: lightningbeam_core::selection::Selection, // Current selection state + tool_state: lightningbeam_core::tool::ToolState, // Current tool interaction state +} + +impl EditorApp { + fn new(cc: &eframe::CreationContext, layouts: Vec, theme: Theme) -> Self { + let current_layout = layouts[0].layout.clone(); + + // Initialize native menu system + let menu_system = MenuSystem::new().ok(); + + // Create default document with a simple test scene + let mut document = lightningbeam_core::document::Document::with_size("Untitled Animation", 1920.0, 1080.0) + .with_duration(10.0) + .with_framerate(60.0); + + // Add a test layer with a simple shape to visualize + use lightningbeam_core::layer::{AnyLayer, VectorLayer}; + use lightningbeam_core::object::Object; + use lightningbeam_core::shape::{Shape, ShapeColor}; + use vello::kurbo::{Circle, Shape as KurboShape}; + + let circle = Circle::new((200.0, 150.0), 50.0); + let path = circle.to_path(0.1); + let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250)); + let object = Object::new(shape.id); + + let mut vector_layer = VectorLayer::new("Layer 1"); + vector_layer.add_shape(shape); + vector_layer.add_object(object); + let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer)); + + // Wrap document in ActionExecutor + let action_executor = lightningbeam_core::action::ActionExecutor::new(document); + + Self { + layouts, + current_layout_index: 0, + current_layout, + drag_state: DragState::default(), + hovered_divider: None, + selected_pane: None, + split_preview_mode: SplitPreviewMode::default(), + icon_cache: IconCache::new(), + tool_icon_cache: ToolIconCache::new(), + selected_tool: Tool::Select, // Default tool + fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill + stroke_color: egui::Color32::from_rgb(0, 0, 0), // Default black stroke + pane_instances: HashMap::new(), // Initialize empty, panes created on-demand + menu_system, + pending_view_action: None, + theme, + action_executor, + active_layer_id: Some(layer_id), + selection: lightningbeam_core::selection::Selection::new(), + tool_state: lightningbeam_core::tool::ToolState::default(), + } + } + + fn switch_layout(&mut self, index: usize) { + self.current_layout_index = index; + self.current_layout = self.layouts[index].layout.clone(); + } + + fn current_layout_def(&self) -> &LayoutDefinition { + &self.layouts[self.current_layout_index] + } + + fn apply_layout_action(&mut self, action: LayoutAction) { + match action { + LayoutAction::SplitHorizontal(path, percent) => { + split_node(&mut self.current_layout, &path, true, percent); + } + LayoutAction::SplitVertical(path, percent) => { + split_node(&mut self.current_layout, &path, false, percent); + } + LayoutAction::RemoveSplit(path) => { + remove_split(&mut self.current_layout, &path); + } + LayoutAction::EnterSplitPreviewHorizontal => { + self.split_preview_mode = SplitPreviewMode::Active { + is_horizontal: false, // horizontal divider = vertical grid (top/bottom) + hovered_pane: None, + split_percent: 50.0, + }; + } + LayoutAction::EnterSplitPreviewVertical => { + self.split_preview_mode = SplitPreviewMode::Active { + is_horizontal: true, // vertical divider = horizontal grid (left/right) + hovered_pane: None, + split_percent: 50.0, + }; + } + } + } + + fn handle_menu_action(&mut self, action: MenuAction) { + match action { + // File menu + MenuAction::NewFile => { + println!("Menu: New File"); + // TODO: Implement new file + } + MenuAction::NewWindow => { + println!("Menu: New Window"); + // TODO: Implement new window + } + MenuAction::Save => { + println!("Menu: Save"); + // TODO: Implement save + } + MenuAction::SaveAs => { + println!("Menu: Save As"); + // TODO: Implement save as + } + MenuAction::OpenFile => { + println!("Menu: Open File"); + // TODO: Implement open file + } + MenuAction::Revert => { + println!("Menu: Revert"); + // TODO: Implement revert + } + MenuAction::Import => { + println!("Menu: Import"); + // TODO: Implement import + } + MenuAction::Export => { + println!("Menu: Export"); + // TODO: Implement export + } + MenuAction::Quit => { + println!("Menu: Quit"); + std::process::exit(0); + } + + // Edit menu + MenuAction::Undo => { + if self.action_executor.undo() { + println!("Undid: {}", self.action_executor.redo_description().unwrap_or_default()); + } else { + println!("Nothing to undo"); + } + } + MenuAction::Redo => { + if self.action_executor.redo() { + println!("Redid: {}", self.action_executor.undo_description().unwrap_or_default()); + } else { + println!("Nothing to redo"); + } + } + MenuAction::Cut => { + println!("Menu: Cut"); + // TODO: Implement cut + } + MenuAction::Copy => { + println!("Menu: Copy"); + // TODO: Implement copy + } + MenuAction::Paste => { + println!("Menu: Paste"); + // TODO: Implement paste + } + MenuAction::Delete => { + println!("Menu: Delete"); + // TODO: Implement delete + } + MenuAction::SelectAll => { + println!("Menu: Select All"); + // TODO: Implement select all + } + MenuAction::SelectNone => { + println!("Menu: Select None"); + // TODO: Implement select none + } + MenuAction::Preferences => { + println!("Menu: Preferences"); + // TODO: Implement preferences dialog + } + + // Modify menu + MenuAction::Group => { + println!("Menu: Group"); + // TODO: Implement group + } + MenuAction::SendToBack => { + println!("Menu: Send to Back"); + // TODO: Implement send to back + } + MenuAction::BringToFront => { + println!("Menu: Bring to Front"); + // TODO: Implement bring to front + } + + // Layer menu + MenuAction::AddLayer => { + println!("Menu: Add Layer"); + // TODO: Implement add layer + } + MenuAction::AddVideoLayer => { + println!("Menu: Add Video Layer"); + // TODO: Implement add video layer + } + MenuAction::AddAudioTrack => { + println!("Menu: Add Audio Track"); + // TODO: Implement add audio track + } + MenuAction::AddMidiTrack => { + println!("Menu: Add MIDI Track"); + // TODO: Implement add MIDI track + } + MenuAction::DeleteLayer => { + println!("Menu: Delete Layer"); + // TODO: Implement delete layer + } + MenuAction::ToggleLayerVisibility => { + println!("Menu: Toggle Layer Visibility"); + // TODO: Implement toggle layer visibility + } + + // Timeline menu + MenuAction::NewKeyframe => { + println!("Menu: New Keyframe"); + // TODO: Implement new keyframe + } + MenuAction::NewBlankKeyframe => { + println!("Menu: New Blank Keyframe"); + // TODO: Implement new blank keyframe + } + MenuAction::DeleteFrame => { + println!("Menu: Delete Frame"); + // TODO: Implement delete frame + } + MenuAction::DuplicateKeyframe => { + println!("Menu: Duplicate Keyframe"); + // TODO: Implement duplicate keyframe + } + MenuAction::AddKeyframeAtPlayhead => { + println!("Menu: Add Keyframe at Playhead"); + // TODO: Implement add keyframe at playhead + } + MenuAction::AddMotionTween => { + println!("Menu: Add Motion Tween"); + // TODO: Implement add motion tween + } + MenuAction::AddShapeTween => { + println!("Menu: Add Shape Tween"); + // TODO: Implement add shape tween + } + MenuAction::ReturnToStart => { + println!("Menu: Return to Start"); + // TODO: Implement return to start + } + MenuAction::Play => { + println!("Menu: Play"); + // TODO: Implement play/pause + } + + // View menu + MenuAction::ZoomIn => { + self.pending_view_action = Some(MenuAction::ZoomIn); + } + MenuAction::ZoomOut => { + self.pending_view_action = Some(MenuAction::ZoomOut); + } + MenuAction::ActualSize => { + self.pending_view_action = Some(MenuAction::ActualSize); + } + MenuAction::RecenterView => { + self.pending_view_action = Some(MenuAction::RecenterView); + } + MenuAction::NextLayout => { + println!("Menu: Next Layout"); + let next_index = (self.current_layout_index + 1) % self.layouts.len(); + self.switch_layout(next_index); + } + MenuAction::PreviousLayout => { + println!("Menu: Previous Layout"); + let prev_index = if self.current_layout_index == 0 { + self.layouts.len() - 1 + } else { + self.current_layout_index - 1 + }; + self.switch_layout(prev_index); + } + MenuAction::SwitchLayout(index) => { + println!("Menu: Switch to Layout {}", index); + if index < self.layouts.len() { + self.switch_layout(index); + } + } + + // Help menu + MenuAction::About => { + println!("Menu: About"); + // TODO: Implement about dialog + } + + // Lightningbeam menu (macOS) + MenuAction::Settings => { + println!("Menu: Settings"); + // TODO: Implement settings + } + MenuAction::CloseWindow => { + println!("Menu: Close Window"); + // TODO: Implement close window + } + } + } +} + +impl eframe::App for EditorApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Disable egui's built-in Ctrl+Plus/Minus zoom behavior + // We handle zoom ourselves for the Stage pane + ctx.options_mut(|o| { + o.zoom_with_keyboard = false; + }); + + // Check for native menu events (macOS) + if let Some(menu_system) = &self.menu_system { + if let Some(action) = menu_system.check_events() { + self.handle_menu_action(action); + } + } + + // Check keyboard shortcuts (works on all platforms) + ctx.input(|i| { + if let Some(action) = MenuSystem::check_shortcuts(i) { + self.handle_menu_action(action); + } + }); + + // Top menu bar (egui-rendered on all platforms) + egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { + if let Some(action) = MenuSystem::render_egui_menu_bar(ui) { + self.handle_menu_action(action); + } + }); + + // Main pane area + let mut layout_action: Option = None; + egui::CentralPanel::default().show(ctx, |ui| { + let available_rect = ui.available_rect_before_wrap(); + + // Reset hovered divider each frame + self.hovered_divider = None; + + // Track fallback pane priority for view actions (reset each frame) + let mut fallback_pane_priority: Option = None; + + // Registry for view action handlers (two-phase dispatch) + let mut pending_handlers: Vec = Vec::new(); + + render_layout_node( + ui, + &mut self.current_layout, + available_rect, + &mut self.drag_state, + &mut self.hovered_divider, + &mut self.selected_pane, + &mut layout_action, + &mut self.split_preview_mode, + &mut self.icon_cache, + &mut self.tool_icon_cache, + &mut self.selected_tool, + &mut self.fill_color, + &mut self.stroke_color, + &mut self.pane_instances, + &Vec::new(), // Root path + &mut self.pending_view_action, + &mut fallback_pane_priority, + &mut pending_handlers, + &self.theme, + self.action_executor.document(), + &mut self.selection, + &self.active_layer_id, + &mut self.tool_state, + ); + + // Execute action on the best handler (two-phase dispatch) + if let Some(action) = &self.pending_view_action { + if let Some(best_handler) = pending_handlers.iter().min_by_key(|h| h.priority) { + // Look up the pane instance and execute the action + if let Some(pane_instance) = self.pane_instances.get_mut(&best_handler.pane_path) { + match pane_instance { + panes::PaneInstance::Stage(stage_pane) => { + stage_pane.execute_view_action(action, best_handler.zoom_center); + } + _ => {} // Other pane types don't handle view actions yet + } + } + } + // Clear the pending action after execution + self.pending_view_action = None; + } + + // Set cursor based on hover state + if let Some((_, is_horizontal)) = self.hovered_divider { + if is_horizontal { + ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } else { + ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + } + }); + + // Handle ESC key and click-outside to cancel split preview + if let SplitPreviewMode::Active { hovered_pane, .. } = &self.split_preview_mode { + let should_cancel = ctx.input(|i| { + // Cancel on ESC key + if i.key_pressed(egui::Key::Escape) { + return true; + } + // Cancel on click outside any pane + if i.pointer.primary_clicked() && hovered_pane.is_none() { + return true; + } + false + }); + + if should_cancel { + self.split_preview_mode = SplitPreviewMode::None; + } + } + + // Apply layout action after rendering to avoid borrow issues + if let Some(action) = layout_action { + self.apply_layout_action(action); + } + } + +} + +/// Recursively render a layout node with drag support +fn render_layout_node( + ui: &mut egui::Ui, + node: &mut LayoutNode, + rect: egui::Rect, + drag_state: &mut DragState, + hovered_divider: &mut Option<(NodePath, bool)>, + selected_pane: &mut Option, + layout_action: &mut Option, + split_preview_mode: &mut SplitPreviewMode, + icon_cache: &mut IconCache, + tool_icon_cache: &mut ToolIconCache, + selected_tool: &mut Tool, + fill_color: &mut egui::Color32, + stroke_color: &mut egui::Color32, + pane_instances: &mut HashMap, + path: &NodePath, + pending_view_action: &mut Option, + fallback_pane_priority: &mut Option, + pending_handlers: &mut Vec, + theme: &Theme, + document: &lightningbeam_core::document::Document, + selection: &mut lightningbeam_core::selection::Selection, + active_layer_id: &Option, + tool_state: &mut lightningbeam_core::tool::ToolState, +) { + match node { + LayoutNode::Pane { name } => { + render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, document, selection, active_layer_id); + } + LayoutNode::HorizontalGrid { percent, children } => { + // Handle dragging + if drag_state.is_dragging && drag_state.node_path == *path { + if let Some(pointer_pos) = ui.input(|i| i.pointer.interact_pos()) { + // Calculate new percentage based on pointer position + let new_percent = ((pointer_pos.x - rect.left()) / rect.width() * 100.0) + .clamp(10.0, 90.0); // Clamp to prevent too small panes + *percent = new_percent; + } + } + + // Split horizontally (left | right) + let split_x = rect.left() + (rect.width() * *percent / 100.0); + + let left_rect = egui::Rect::from_min_max(rect.min, egui::pos2(split_x, rect.max.y)); + + let right_rect = + egui::Rect::from_min_max(egui::pos2(split_x, rect.min.y), rect.max); + + // Render children + let mut left_path = path.clone(); + left_path.push(0); + render_layout_node( + ui, + &mut children[0], + left_rect, + drag_state, + hovered_divider, + selected_pane, + layout_action, + split_preview_mode, + icon_cache, + tool_icon_cache, + selected_tool, + fill_color, + stroke_color, + pane_instances, + &left_path, + pending_view_action, + fallback_pane_priority, + pending_handlers, + theme, + document, + selection, + active_layer_id, + ); + + let mut right_path = path.clone(); + right_path.push(1); + render_layout_node( + ui, + &mut children[1], + right_rect, + drag_state, + hovered_divider, + selected_pane, + layout_action, + split_preview_mode, + icon_cache, + tool_icon_cache, + selected_tool, + fill_color, + stroke_color, + pane_instances, + &right_path, + pending_view_action, + fallback_pane_priority, + pending_handlers, + theme, + document, + selection, + active_layer_id, + ); + + // Draw divider with interaction + let divider_width = 8.0; + let divider_rect = egui::Rect::from_min_max( + egui::pos2(split_x - divider_width / 2.0, rect.min.y), + egui::pos2(split_x + divider_width / 2.0, rect.max.y), + ); + + let divider_id = ui.id().with(("divider", path)); + let response = ui.interact(divider_rect, divider_id, egui::Sense::click_and_drag()); + + // Check if pointer is over divider + if response.hovered() { + *hovered_divider = Some((path.clone(), true)); + } + + // Handle drag start + if response.drag_started() { + drag_state.is_dragging = true; + drag_state.node_path = path.clone(); + drag_state.is_horizontal = true; + } + + // Handle drag end + if response.drag_stopped() { + drag_state.is_dragging = false; + } + + // Context menu on right-click + response.context_menu(|ui| { + ui.set_min_width(180.0); + + if ui.button("Split Horizontal ->").clicked() { + *layout_action = Some(LayoutAction::EnterSplitPreviewHorizontal); + ui.close_menu(); + } + + if ui.button("Split Vertical |").clicked() { + *layout_action = Some(LayoutAction::EnterSplitPreviewVertical); + ui.close_menu(); + } + + ui.separator(); + + if ui.button("< Join Left").clicked() { + let mut path_keep_right = path.clone(); + path_keep_right.push(1); // Remove left, keep right child + *layout_action = Some(LayoutAction::RemoveSplit(path_keep_right)); + ui.close_menu(); + } + + if ui.button("Join Right >").clicked() { + let mut path_keep_left = path.clone(); + path_keep_left.push(0); // Remove right, keep left child + *layout_action = Some(LayoutAction::RemoveSplit(path_keep_left)); + ui.close_menu(); + } + + }); + + // Visual feedback + let divider_color = if response.hovered() || response.dragged() { + egui::Color32::from_gray(120) + } else { + egui::Color32::from_gray(60) + }; + + ui.painter().vline( + split_x, + rect.y_range(), + egui::Stroke::new(2.0, divider_color), + ); + } + LayoutNode::VerticalGrid { percent, children } => { + // Handle dragging + if drag_state.is_dragging && drag_state.node_path == *path { + if let Some(pointer_pos) = ui.input(|i| i.pointer.interact_pos()) { + // Calculate new percentage based on pointer position + let new_percent = ((pointer_pos.y - rect.top()) / rect.height() * 100.0) + .clamp(10.0, 90.0); // Clamp to prevent too small panes + *percent = new_percent; + } + } + + // Split vertically (top / bottom) + let split_y = rect.top() + (rect.height() * *percent / 100.0); + + let top_rect = egui::Rect::from_min_max(rect.min, egui::pos2(rect.max.x, split_y)); + + let bottom_rect = + egui::Rect::from_min_max(egui::pos2(rect.min.x, split_y), rect.max); + + // Render children + let mut top_path = path.clone(); + top_path.push(0); + render_layout_node( + ui, + &mut children[0], + top_rect, + drag_state, + hovered_divider, + selected_pane, + layout_action, + split_preview_mode, + icon_cache, + tool_icon_cache, + selected_tool, + fill_color, + stroke_color, + pane_instances, + &top_path, + pending_view_action, + fallback_pane_priority, + pending_handlers, + theme, + document, + selection, + active_layer_id, + ); + + let mut bottom_path = path.clone(); + bottom_path.push(1); + render_layout_node( + ui, + &mut children[1], + bottom_rect, + drag_state, + hovered_divider, + selected_pane, + layout_action, + split_preview_mode, + icon_cache, + tool_icon_cache, + selected_tool, + fill_color, + stroke_color, + pane_instances, + &bottom_path, + pending_view_action, + fallback_pane_priority, + pending_handlers, + theme, + document, + selection, + active_layer_id, + ); + + // Draw divider with interaction + let divider_height = 8.0; + let divider_rect = egui::Rect::from_min_max( + egui::pos2(rect.min.x, split_y - divider_height / 2.0), + egui::pos2(rect.max.x, split_y + divider_height / 2.0), + ); + + let divider_id = ui.id().with(("divider", path)); + let response = ui.interact(divider_rect, divider_id, egui::Sense::click_and_drag()); + + // Check if pointer is over divider + if response.hovered() { + *hovered_divider = Some((path.clone(), false)); + } + + // Handle drag start + if response.drag_started() { + drag_state.is_dragging = true; + drag_state.node_path = path.clone(); + drag_state.is_horizontal = false; + } + + // Handle drag end + if response.drag_stopped() { + drag_state.is_dragging = false; + } + + // Context menu on right-click + response.context_menu(|ui| { + ui.set_min_width(180.0); + + if ui.button("Split Horizontal ->").clicked() { + *layout_action = Some(LayoutAction::EnterSplitPreviewHorizontal); + ui.close_menu(); + } + + if ui.button("Split Vertical |").clicked() { + *layout_action = Some(LayoutAction::EnterSplitPreviewVertical); + ui.close_menu(); + } + + ui.separator(); + + if ui.button("^ Join Up").clicked() { + let mut path_keep_bottom = path.clone(); + path_keep_bottom.push(1); // Remove top, keep bottom child + *layout_action = Some(LayoutAction::RemoveSplit(path_keep_bottom)); + ui.close_menu(); + } + + if ui.button("Join Down v").clicked() { + let mut path_keep_top = path.clone(); + path_keep_top.push(0); // Remove bottom, keep top child + *layout_action = Some(LayoutAction::RemoveSplit(path_keep_top)); + ui.close_menu(); + } + + }); + + // Visual feedback + let divider_color = if response.hovered() || response.dragged() { + egui::Color32::from_gray(120) + } else { + egui::Color32::from_gray(60) + }; + + ui.painter().hline( + rect.x_range(), + split_y, + egui::Stroke::new(2.0, divider_color), + ); + } + } +} + +/// Render a single pane with its content +fn render_pane( + ui: &mut egui::Ui, + pane_name: &mut String, + rect: egui::Rect, + selected_pane: &mut Option, + layout_action: &mut Option, + split_preview_mode: &mut SplitPreviewMode, + icon_cache: &mut IconCache, + tool_icon_cache: &mut ToolIconCache, + selected_tool: &mut Tool, + fill_color: &mut egui::Color32, + stroke_color: &mut egui::Color32, + pane_instances: &mut HashMap, + path: &NodePath, + pending_view_action: &mut Option, + fallback_pane_priority: &mut Option, + pending_handlers: &mut Vec, + theme: &Theme, + document: &lightningbeam_core::document::Document, + selection: &mut lightningbeam_core::selection::Selection, + active_layer_id: &Option, +) { + let pane_type = PaneType::from_name(pane_name); + + // Define header and content areas + let header_height = 40.0; + let header_rect = egui::Rect::from_min_size( + rect.min, + egui::vec2(rect.width(), header_height), + ); + let content_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(0.0, header_height), + egui::vec2(rect.width(), rect.height() - header_height), + ); + + // Draw header background + ui.painter().rect_filled( + header_rect, + 0.0, + egui::Color32::from_rgb(35, 35, 35), + ); + + // Draw content background + let bg_color = if let Some(pane_type) = pane_type { + pane_color(pane_type) + } else { + egui::Color32::from_rgb(40, 40, 40) + }; + ui.painter().rect_filled(content_rect, 0.0, bg_color); + + // Draw border around entire pane + let border_color = egui::Color32::from_gray(80); + let border_width = 1.0; + ui.painter().rect_stroke( + rect, + 0.0, + egui::Stroke::new(border_width, border_color), + ); + + // Draw header separator line + ui.painter().hline( + rect.x_range(), + header_rect.max.y, + egui::Stroke::new(1.0, egui::Color32::from_gray(50)), + ); + + // Render icon button in header (left side) + let icon_size = 24.0; + let icon_padding = 8.0; + let icon_button_rect = egui::Rect::from_min_size( + header_rect.min + egui::vec2(icon_padding, icon_padding), + egui::vec2(icon_size, icon_size), + ); + + // Draw icon button background + ui.painter().rect_filled( + icon_button_rect, + 4.0, + egui::Color32::from_rgba_premultiplied(50, 50, 50, 200), + ); + + // Load and render icon if available + if let Some(pane_type) = pane_type { + if let Some(icon) = icon_cache.get_or_load(pane_type) { + let icon_texture_id = icon.texture_id(ui.ctx()); + let icon_rect = icon_button_rect.shrink(2.0); // Small padding inside button + ui.painter().image( + icon_texture_id, + icon_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + } + + // Make icon button interactive (show pane type menu on click) + let icon_button_id = ui.id().with(("icon_button", path)); + let icon_response = ui.interact(icon_button_rect, icon_button_id, egui::Sense::click()); + + if icon_response.hovered() { + ui.painter().rect_stroke( + icon_button_rect, + 4.0, + egui::Stroke::new(1.0, egui::Color32::from_gray(180)), + ); + } + + // Show pane type selector menu on left click + let menu_id = ui.id().with(("pane_type_menu", path)); + if icon_response.clicked() { + ui.memory_mut(|mem| mem.toggle_popup(menu_id)); + } + + egui::popup::popup_below_widget(ui, menu_id, &icon_response, egui::PopupCloseBehavior::CloseOnClickOutside, |ui| { + ui.set_min_width(200.0); + ui.label("Select Pane Type:"); + ui.separator(); + + for pane_type_option in PaneType::all() { + // Load icon for this pane type + if let Some(icon) = icon_cache.get_or_load(*pane_type_option) { + ui.horizontal(|ui| { + // Show icon + let icon_texture_id = icon.texture_id(ui.ctx()); + let icon_size = egui::vec2(16.0, 16.0); + ui.add(egui::Image::new((icon_texture_id, icon_size))); + + // Show label with selection + if ui.selectable_label( + pane_type == Some(*pane_type_option), + pane_type_option.display_name() + ).clicked() { + *pane_name = pane_type_option.to_name().to_string(); + ui.memory_mut(|mem| mem.close_popup()); + } + }); + } else { + // Fallback if icon fails to load + if ui.selectable_label( + pane_type == Some(*pane_type_option), + pane_type_option.display_name() + ).clicked() { + *pane_name = pane_type_option.to_name().to_string(); + ui.memory_mut(|mem| mem.close_popup()); + } + } + } + }); + + // Draw pane title in header + let title_text = if let Some(pane_type) = pane_type { + pane_type.display_name() + } else { + pane_name.as_str() + }; + let title_pos = header_rect.min + egui::vec2(icon_padding * 2.0 + icon_size + 8.0, header_height / 2.0); + ui.painter().text( + title_pos, + egui::Align2::LEFT_CENTER, + title_text, + egui::FontId::proportional(14.0), + egui::Color32::from_gray(220), + ); + + // Create header controls area (positioned after title) + let title_width = 150.0; // Approximate width for title + let header_controls_rect = egui::Rect::from_min_size( + header_rect.min + egui::vec2(icon_padding * 2.0 + icon_size + 8.0 + title_width, 0.0), + egui::vec2(header_rect.width() - (icon_padding * 2.0 + icon_size + 8.0 + title_width), header_height), + ); + + // Render pane-specific header controls (if pane has them) + if let Some(pane_type) = pane_type { + // Get or create pane instance for header rendering + let needs_new_instance = pane_instances + .get(path) + .map(|instance| instance.pane_type() != pane_type) + .unwrap_or(true); + + if needs_new_instance { + pane_instances.insert(path.clone(), panes::PaneInstance::new(pane_type)); + } + + if let Some(pane_instance) = pane_instances.get_mut(path) { + let mut header_ui = ui.new_child(egui::UiBuilder::new().max_rect(header_controls_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); + let mut shared = panes::SharedPaneState { + tool_icon_cache, + icon_cache, + selected_tool, + fill_color, + stroke_color, + pending_view_action, + fallback_pane_priority, + theme, + pending_handlers, + document, + selection, + active_layer_id, + }; + pane_instance.render_header(&mut header_ui, &mut shared); + } + } + + // Make pane content clickable (use full rect for split preview interaction) + let pane_id = ui.id().with(("pane", path)); + let response = ui.interact(rect, pane_id, egui::Sense::click()); + + // Render pane-specific content using trait-based system + if let Some(pane_type) = pane_type { + // Get or create pane instance for this path + // Check if we need a new instance (either doesn't exist or type changed) + let needs_new_instance = pane_instances + .get(path) + .map(|instance| instance.pane_type() != pane_type) + .unwrap_or(true); + + if needs_new_instance { + pane_instances.insert(path.clone(), PaneInstance::new(pane_type)); + } + + // Get the pane instance and render its content + if let Some(pane_instance) = pane_instances.get_mut(path) { + // Create shared state + let mut shared = SharedPaneState { + tool_icon_cache, + icon_cache, + selected_tool, + fill_color, + stroke_color, + pending_view_action, + fallback_pane_priority, + theme, + pending_handlers, + document, + selection, + active_layer_id, + }; + + // Render pane content (header was already rendered above) + pane_instance.render_content(ui, content_rect, path, &mut shared); + } + } else { + // Unknown pane type - draw placeholder + let content_text = "Unknown pane type"; + let text_pos = content_rect.center(); + ui.painter().text( + text_pos, + egui::Align2::CENTER_CENTER, + content_text, + egui::FontId::proportional(16.0), + egui::Color32::from_gray(150), + ); + } + + // Handle split preview mode (rendered AFTER pane content for proper z-ordering) + if let SplitPreviewMode::Active { + is_horizontal, + hovered_pane, + split_percent, + } = split_preview_mode + { + // Check if mouse is over this pane + if let Some(pointer_pos) = ui.input(|i| i.pointer.hover_pos()) { + if rect.contains(pointer_pos) { + // Update hovered pane + *hovered_pane = Some(path.clone()); + + // Calculate split percentage based on mouse position + *split_percent = if *is_horizontal { + ((pointer_pos.x - rect.left()) / rect.width() * 100.0).clamp(10.0, 90.0) + } else { + ((pointer_pos.y - rect.top()) / rect.height() * 100.0).clamp(10.0, 90.0) + }; + + // Render split preview overlay + let grey_overlay = egui::Color32::from_rgba_premultiplied(128, 128, 128, 30); + + if *is_horizontal { + let split_x = rect.left() + (rect.width() * *split_percent / 100.0); + + // First half + let first_rect = egui::Rect::from_min_max( + rect.min, + egui::pos2(split_x, rect.max.y), + ); + ui.painter().rect_filled(first_rect, 0.0, grey_overlay); + + // Second half + let second_rect = egui::Rect::from_min_max( + egui::pos2(split_x, rect.min.y), + rect.max, + ); + ui.painter().rect_filled(second_rect, 0.0, grey_overlay); + + // Divider line + ui.painter().vline( + split_x, + rect.y_range(), + egui::Stroke::new(2.0, egui::Color32::BLACK), + ); + } else { + let split_y = rect.top() + (rect.height() * *split_percent / 100.0); + + // First half + let first_rect = egui::Rect::from_min_max( + rect.min, + egui::pos2(rect.max.x, split_y), + ); + ui.painter().rect_filled(first_rect, 0.0, grey_overlay); + + // Second half + let second_rect = egui::Rect::from_min_max( + egui::pos2(rect.min.x, split_y), + rect.max, + ); + ui.painter().rect_filled(second_rect, 0.0, grey_overlay); + + // Divider line + ui.painter().hline( + rect.x_range(), + split_y, + egui::Stroke::new(2.0, egui::Color32::BLACK), + ); + } + + // Create a high-priority interaction for split preview (rendered last = highest priority) + let split_preview_id = ui.id().with(("split_preview", path)); + let split_response = ui.interact(rect, split_preview_id, egui::Sense::click()); + + // If clicked, perform the split + if split_response.clicked() { + if *is_horizontal { + *layout_action = Some(LayoutAction::SplitHorizontal(path.clone(), *split_percent)); + } else { + *layout_action = Some(LayoutAction::SplitVertical(path.clone(), *split_percent)); + } + // Exit preview mode + *split_preview_mode = SplitPreviewMode::None; + } + } + } + } else if response.clicked() { + *selected_pane = Some(path.clone()); + } +} + +/// Render toolbar with tool buttons +fn render_toolbar( + ui: &mut egui::Ui, + rect: egui::Rect, + tool_icon_cache: &mut ToolIconCache, + selected_tool: &mut Tool, + path: &NodePath, +) { + let button_size = 60.0; // 50% bigger (was 40.0) + let button_padding = 8.0; + let button_spacing = 4.0; + + // Calculate how many columns we can fit + let available_width = rect.width() - (button_padding * 2.0); + let columns = ((available_width + button_spacing) / (button_size + button_spacing)).floor() as usize; + let columns = columns.max(1); // At least 1 column + + let mut x = rect.left() + button_padding; + let mut y = rect.top() + button_padding; + let mut col = 0; + + for tool in Tool::all() { + let button_rect = egui::Rect::from_min_size( + egui::pos2(x, y), + egui::vec2(button_size, button_size), + ); + + // Check if this is the selected tool + let is_selected = *selected_tool == *tool; + + // Button background + let bg_color = if is_selected { + egui::Color32::from_rgb(70, 100, 150) // Highlighted blue + } else { + egui::Color32::from_rgb(50, 50, 50) + }; + ui.painter().rect_filled(button_rect, 4.0, bg_color); + + // Load and render tool icon + if let Some(icon) = tool_icon_cache.get_or_load(*tool, ui.ctx()) { + let icon_rect = button_rect.shrink(8.0); // Padding inside button + ui.painter().image( + icon.id(), + icon_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + + // Make button interactive (include path to ensure unique IDs across panes) + let button_id = ui.id().with(("tool_button", path, *tool as usize)); + let response = ui.interact(button_rect, button_id, egui::Sense::click()); + + // Check for click first + if response.clicked() { + *selected_tool = *tool; + } + + if response.hovered() { + ui.painter().rect_stroke( + button_rect, + 4.0, + egui::Stroke::new(2.0, egui::Color32::from_gray(180)), + ); + } + + // Show tooltip with tool name and shortcut (consumes response) + response.on_hover_text(format!("{} ({})", tool.display_name(), tool.shortcut_hint())); + + // Draw selection border + if is_selected { + ui.painter().rect_stroke( + button_rect, + 4.0, + egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 150, 255)), + ); + } + + // Move to next position in grid + col += 1; + if col >= columns { + // Move to next row + col = 0; + x = rect.left() + button_padding; + y += button_size + button_spacing; + } else { + // Move to next column + x += button_size + button_spacing; + } + } +} + +/// Get a color for each pane type for visualization +fn pane_color(pane_type: PaneType) -> egui::Color32 { + match pane_type { + PaneType::Stage => egui::Color32::from_rgb(30, 40, 50), + PaneType::Timeline => egui::Color32::from_rgb(40, 30, 50), + PaneType::Toolbar => egui::Color32::from_rgb(50, 40, 30), + PaneType::Infopanel => egui::Color32::from_rgb(30, 50, 40), + PaneType::Outliner => egui::Color32::from_rgb(40, 50, 30), + PaneType::PianoRoll => egui::Color32::from_rgb(55, 35, 45), + PaneType::NodeEditor => egui::Color32::from_rgb(30, 45, 50), + PaneType::PresetBrowser => egui::Color32::from_rgb(50, 45, 30), + } +} + +/// Split a pane node into a horizontal or vertical grid with two copies of the pane +fn split_node(root: &mut LayoutNode, path: &NodePath, is_horizontal: bool, percent: f32) { + if path.is_empty() { + // Split the root node + if let LayoutNode::Pane { name } = root { + let pane_name = name.clone(); + let new_node = if is_horizontal { + LayoutNode::HorizontalGrid { + percent, + children: [ + Box::new(LayoutNode::Pane { name: pane_name.clone() }), + Box::new(LayoutNode::Pane { name: pane_name }), + ], + } + } else { + LayoutNode::VerticalGrid { + percent, + children: [ + Box::new(LayoutNode::Pane { name: pane_name.clone() }), + Box::new(LayoutNode::Pane { name: pane_name }), + ], + } + }; + *root = new_node; + } + } else { + // Navigate to parent and split the child + navigate_to_node(root, &path[..path.len() - 1], &mut |node| { + let child_index = path[path.len() - 1]; + match node { + LayoutNode::HorizontalGrid { children, .. } + | LayoutNode::VerticalGrid { children, .. } => { + if let LayoutNode::Pane { name } = &*children[child_index] { + let pane_name = name.clone(); + let new_node = if is_horizontal { + LayoutNode::HorizontalGrid { + percent, + children: [ + Box::new(LayoutNode::Pane { name: pane_name.clone() }), + Box::new(LayoutNode::Pane { name: pane_name }), + ], + } + } else { + LayoutNode::VerticalGrid { + percent, + children: [ + Box::new(LayoutNode::Pane { name: pane_name.clone() }), + Box::new(LayoutNode::Pane { name: pane_name }), + ], + } + }; + children[child_index] = Box::new(new_node); + } + } + _ => {} + } + }); + } +} + +/// Remove a split by replacing it with one of its children +/// The path includes the split node path plus which child to keep (0 or 1 as last element) +fn remove_split(root: &mut LayoutNode, path: &NodePath) { + if path.is_empty() { + return; // Can't remove if path is empty + } + + // Last element indicates which child to keep (0 or 1) + let child_to_keep = path[path.len() - 1]; + + // Path to the split node is everything except the last element + let split_path = &path[..path.len() - 1]; + + if split_path.is_empty() { + // Removing root split - replace root with the chosen child + if let LayoutNode::HorizontalGrid { children, .. } + | LayoutNode::VerticalGrid { children, .. } = root + { + *root = (*children[child_to_keep]).clone(); + } + } else { + // Navigate to parent of the split node and replace it + let parent_path = &split_path[..split_path.len() - 1]; + let split_index = split_path[split_path.len() - 1]; + + navigate_to_node(root, parent_path, &mut |node| { + match node { + LayoutNode::HorizontalGrid { children, .. } + | LayoutNode::VerticalGrid { children, .. } => { + // Get the split node's chosen child + if let LayoutNode::HorizontalGrid { children: split_children, .. } + | LayoutNode::VerticalGrid { children: split_children, .. } = + &*children[split_index] + { + // Replace the split node with the chosen child + children[split_index] = split_children[child_to_keep].clone(); + } + } + _ => {} + } + }); + } +} + +/// Navigate to a node at the given path and apply a function to it +fn navigate_to_node(node: &mut LayoutNode, path: &[usize], f: &mut F) +where + F: FnMut(&mut LayoutNode), +{ + if path.is_empty() { + f(node); + } else { + match node { + LayoutNode::HorizontalGrid { children, .. } + | LayoutNode::VerticalGrid { children, .. } => { + navigate_to_node(&mut children[path[0]], &path[1..], f); + } + _ => {} + } + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 42fb252..9f960c9 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -43,8 +43,16 @@ pub struct SharedPaneState<'a> { /// Registry of handlers for the current pending action /// Panes register themselves here during render, execution happens after pub pending_handlers: &'a mut Vec, - /// Active document being edited - pub document: &'a mut lightningbeam_core::document::Document, + /// Active document being edited (read-only, mutations go through actions) + pub document: &'a lightningbeam_core::document::Document, + /// Current selection state (mutable for tools to modify) + pub selection: &'a mut lightningbeam_core::selection::Selection, + /// Currently active layer ID + pub active_layer_id: &'a Option, + /// Current tool interaction state (mutable for tools to modify) + pub tool_state: &'a mut lightningbeam_core::tool::ToolState, + /// Actions to execute after rendering completes (two-phase dispatch) + pub pending_actions: &'a mut Vec>, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index d13ffec..4648cf1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -197,11 +197,25 @@ struct VelloCallback { zoom: f32, instance_id: u64, document: lightningbeam_core::document::Document, + tool_state: lightningbeam_core::tool::ToolState, + active_layer_id: Option, + drag_delta: Option, // Delta for drag preview (world space) + selection: lightningbeam_core::selection::Selection, } impl VelloCallback { - fn new(rect: egui::Rect, pan_offset: egui::Vec2, zoom: f32, instance_id: u64, document: lightningbeam_core::document::Document) -> Self { - Self { rect, pan_offset, zoom, instance_id, document } + fn new( + rect: egui::Rect, + pan_offset: egui::Vec2, + zoom: f32, + instance_id: u64, + document: lightningbeam_core::document::Document, + tool_state: lightningbeam_core::tool::ToolState, + active_layer_id: Option, + drag_delta: Option, + selection: lightningbeam_core::selection::Selection, + ) -> Self { + Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection } } } @@ -260,6 +274,141 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Render the document to the scene with camera transform lightningbeam_core::renderer::render_document_with_transform(&self.document, &mut scene, camera_transform); + // Render drag preview objects with transparency + if let (Some(delta), Some(active_layer_id)) = (self.drag_delta, self.active_layer_id) { + if let Some(layer) = self.document.get_layer(&active_layer_id) { + if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer { + if let lightningbeam_core::tool::ToolState::DraggingSelection { ref original_positions, .. } = self.tool_state { + use vello::peniko::{Color, Fill, Brush}; + + // Render each object at its preview position (original + delta) + for (object_id, original_pos) in original_positions { + if let Some(_object) = vector_layer.get_object(object_id) { + if let Some(shape) = vector_layer.get_shape(&_object.shape_id) { + // New position = original + delta + let new_x = original_pos.x + delta.x; + let new_y = original_pos.y + delta.y; + + // Build transform for preview position + let object_transform = Affine::translate((new_x, new_y)); + let combined_transform = camera_transform * object_transform; + + // Render shape with semi-transparent fill (light blue, 40% opacity) + let alpha_color = Color::rgba8(100, 150, 255, 100); + scene.fill( + Fill::NonZero, + combined_transform, + &Brush::Solid(alpha_color), + None, + shape.path(), + ); + } + } + } + } + } + } + } + + // Render selection overlays (outlines, handles, marquee) + if let Some(active_layer_id) = self.active_layer_id { + if let Some(layer) = self.document.get_layer(&active_layer_id) { + if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer { + use vello::peniko::{Color, Fill}; + use vello::kurbo::{Circle, Rect as KurboRect, Shape as KurboShape, Stroke}; + + let selection_color = Color::rgb8(0, 120, 255); // Blue + let stroke_width = 2.0 / self.zoom.max(0.5) as f64; + + // 1. Draw selection outlines around selected objects + if !self.selection.is_empty() { + for &object_id in self.selection.objects() { + if let Some(object) = vector_layer.get_object(&object_id) { + if let Some(shape) = vector_layer.get_shape(&object.shape_id) { + // Get shape bounding box + let bbox = shape.path().bounding_box(); + + // Apply object transform and camera transform + let object_transform = Affine::translate((object.transform.x, object.transform.y)); + let combined_transform = camera_transform * object_transform; + + // Create selection rectangle + let selection_rect = KurboRect::new(bbox.x0, bbox.y0, bbox.x1, bbox.y1); + + // Draw selection outline + scene.stroke( + &Stroke::new(stroke_width), + combined_transform, + selection_color, + None, + &selection_rect, + ); + + // Draw corner handles (4 circles at corners) + let handle_radius = (6.0 / self.zoom.max(0.5) as f64).max(4.0); + let corners = [ + (bbox.x0, bbox.y0), + (bbox.x1, bbox.y0), + (bbox.x1, bbox.y1), + (bbox.x0, bbox.y1), + ]; + + for (x, y) in corners { + let corner_circle = Circle::new((x, y), handle_radius); + // Fill with blue + scene.fill( + Fill::NonZero, + combined_transform, + selection_color, + None, + &corner_circle, + ); + // White outline + scene.stroke( + &Stroke::new(1.0), + combined_transform, + Color::rgb8(255, 255, 255), + None, + &corner_circle, + ); + } + } + } + } + } + + // 2. Draw marquee selection rectangle + if let lightningbeam_core::tool::ToolState::MarqueeSelecting { ref start, ref current } = self.tool_state { + let marquee_rect = KurboRect::new( + start.x.min(current.x), + start.y.min(current.y), + start.x.max(current.x), + start.y.max(current.y), + ); + + // Semi-transparent fill + let marquee_fill = Color::rgba8(0, 120, 255, 100); + scene.fill( + Fill::NonZero, + camera_transform, + marquee_fill, + None, + &marquee_rect, + ); + + // Border stroke + scene.stroke( + &Stroke::new(1.0), + camera_transform, + selection_color, + None, + &marquee_rect, + ); + } + } + } + } + // Render scene to texture using shared renderer if let Some(texture_view) = &instance_resources.texture_view { let render_params = vello::RenderParams { @@ -391,7 +540,174 @@ impl StagePane { self.pan_offset = mouse_canvas_pos - (world_pos * new_zoom); } - fn handle_input(&mut self, ui: &mut egui::Ui, rect: egui::Rect) { + fn handle_select_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shift_held: bool, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::tool::ToolState; + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::hit_test; + use vello::kurbo::{Point, Rect as KurboRect, Affine}; + + // Check if we have an active vector layer + let active_layer_id = match shared.active_layer_id { + Some(id) => id, + None => return, // No active layer + }; + + let active_layer = match shared.document.get_layer(active_layer_id) { + Some(layer) => layer, + None => return, + }; + + // Only work on VectorLayer + let vector_layer = match active_layer { + AnyLayer::Vector(vl) => vl, + _ => return, // Not a vector layer + }; + + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + + // Mouse down: start interaction (use drag_started for immediate feedback) + if response.drag_started() || response.clicked() { + // Hit test at click position + let hit = hit_test::hit_test_layer(vector_layer, point, 5.0, Affine::IDENTITY); + + if let Some(object_id) = hit { + // Object was hit + if shift_held { + // Shift: toggle selection + shared.selection.toggle_object(object_id); + } else { + // No shift: replace selection + if !shared.selection.contains_object(&object_id) { + shared.selection.select_only_object(object_id); + } + } + + // If object is now selected, prepare for dragging + if shared.selection.contains_object(&object_id) { + // Store original positions of all selected objects + let mut original_positions = std::collections::HashMap::new(); + for &obj_id in shared.selection.objects() { + if let Some(obj) = vector_layer.get_object(&obj_id) { + original_positions.insert( + obj_id, + Point::new(obj.transform.x, obj.transform.y), + ); + } + } + + *shared.tool_state = ToolState::DraggingSelection { + start_pos: point, + start_mouse: point, + original_positions, + }; + } + } else { + // Nothing hit - start marquee selection + if !shift_held { + shared.selection.clear(); + } + + *shared.tool_state = ToolState::MarqueeSelecting { + start: point, + current: point, + }; + } + } + + // Mouse drag: update tool state + if response.dragged() { + match shared.tool_state { + ToolState::DraggingSelection { .. } => { + // Update current position (visual feedback only) + // Actual move happens on mouse up + } + ToolState::MarqueeSelecting { start, .. } => { + // Update marquee rectangle + *shared.tool_state = ToolState::MarqueeSelecting { + start: *start, + current: point, + }; + } + _ => {} + } + } + + // Mouse up: finish interaction + if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::DraggingSelection { .. } | ToolState::MarqueeSelecting { .. })) { + match shared.tool_state.clone() { + ToolState::DraggingSelection { start_mouse, original_positions, .. } => { + // Calculate total delta + let delta = point - start_mouse; + + if delta.x.abs() > 0.01 || delta.y.abs() > 0.01 { + // Create move action with new positions + use std::collections::HashMap; + let mut object_positions = HashMap::new(); + + for (object_id, original_pos) in original_positions { + let new_pos = Point::new( + original_pos.x + delta.x, + original_pos.y + delta.y, + ); + object_positions.insert(object_id, (original_pos, new_pos)); + } + + // Create and submit the action + use lightningbeam_core::actions::MoveObjectsAction; + let action = MoveObjectsAction::new(*active_layer_id, object_positions); + shared.pending_actions.push(Box::new(action)); + } + + // Reset tool state + *shared.tool_state = ToolState::Idle; + } + ToolState::MarqueeSelecting { start, current } => { + // Create selection rectangle + let min_x = start.x.min(current.x); + let min_y = start.y.min(current.y); + let max_x = start.x.max(current.x); + let max_y = start.y.max(current.y); + + let selection_rect = KurboRect::new(min_x, min_y, max_x, max_y); + + // Hit test all objects in rectangle + let hits = hit_test::hit_test_objects_in_rect( + vector_layer, + selection_rect, + Affine::IDENTITY, + ); + + // Add to selection + for obj_id in hits { + if shift_held { + shared.selection.add_object(obj_id); + } else { + // First hit replaces selection + if shared.selection.is_empty() { + shared.selection.add_object(obj_id); + } else { + // Subsequent hits add to selection + shared.selection.add_object(obj_id); + } + } + } + + // Reset tool state + *shared.tool_state = ToolState::Idle; + } + _ => {} + } + } + } + + fn handle_input(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) { let response = ui.allocate_rect(rect, egui::Sense::click_and_drag()); // Only process input if mouse is over the stage pane @@ -404,11 +720,29 @@ impl StagePane { let scroll_delta = ui.input(|i| i.smooth_scroll_delta); let alt_held = ui.input(|i| i.modifiers.alt); let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command); + let shift_held = ui.input(|i| i.modifiers.shift); // Get mouse position for zoom-to-cursor let mouse_pos = response.hover_pos().unwrap_or(rect.center()); let mouse_canvas_pos = mouse_pos - rect.min; + // Convert screen position to world position (accounting for pan and zoom) + let world_pos = (mouse_canvas_pos - self.pan_offset) / self.zoom; + + // Handle tool input (only if not using Alt modifier for panning) + if !alt_held { + use lightningbeam_core::tool::Tool; + + match *shared.selected_tool { + Tool::Select => { + self.handle_select_tool(ui, &response, world_pos, shift_held, shared); + } + _ => { + // Other tools not implemented yet + } + } + } + // Distinguish between mouse wheel (discrete) and trackpad (smooth) let mut handled = false; ui.input(|i| { @@ -463,6 +797,7 @@ impl StagePane { } } } + } impl PaneRenderer for StagePane { @@ -473,8 +808,8 @@ impl PaneRenderer for StagePane { _path: &NodePath, shared: &mut SharedPaneState, ) { - // Handle input for pan/zoom controls - self.handle_input(ui, rect); + // Handle input for pan/zoom and tool controls + self.handle_input(ui, rect, shared); // Register handler for pending view actions (two-phase dispatch) // Priority: Mouse-over (0-99) > Fallback Stage(1000) > Fallback Timeline(1001) etc. @@ -530,8 +865,36 @@ impl PaneRenderer for StagePane { } } + // Calculate drag delta for preview rendering (world space) + let drag_delta = if let lightningbeam_core::tool::ToolState::DraggingSelection { ref start_mouse, .. } = shared.tool_state { + // Get current mouse position in world coordinates + if let Some(mouse_pos) = ui.input(|i| i.pointer.hover_pos()) { + let mouse_canvas_pos = mouse_pos - rect.min; + let world_mouse = (mouse_canvas_pos - self.pan_offset) / self.zoom; + + let delta_x = world_mouse.x as f64 - start_mouse.x; + let delta_y = world_mouse.y as f64 - start_mouse.y; + + Some(vello::kurbo::Vec2::new(delta_x, delta_y)) + } else { + None + } + } else { + None + }; + // Use egui's custom painting callback for Vello - let callback = VelloCallback::new(rect, self.pan_offset, self.zoom, self.instance_id, shared.document.clone()); + let callback = VelloCallback::new( + rect, + self.pan_offset, + self.zoom, + self.instance_id, + shared.document.clone(), + shared.tool_state.clone(), + *shared.active_layer_id, + drag_delta, + shared.selection.clone(), + ); let cb = egui_wgpu::Callback::new_paint_callback( rect,