Add info panel

This commit is contained in:
Skyler Lehmkuhl 2025-11-30 10:01:10 -05:00
parent 4d1e052ee7
commit c943f7bfe6
15 changed files with 2876 additions and 119 deletions

View File

@ -82,25 +82,48 @@ impl AudioFile {
/// Generate a waveform overview with the specified number of peaks /// Generate a waveform overview with the specified number of peaks
/// This creates a downsampled representation suitable for timeline visualization /// This creates a downsampled representation suitable for timeline visualization
pub fn generate_waveform_overview(&self, target_peaks: usize) -> Vec<crate::io::WaveformPeak> { pub fn generate_waveform_overview(&self, target_peaks: usize) -> Vec<crate::io::WaveformPeak> {
self.generate_waveform_overview_range(0, self.frames as usize, target_peaks)
}
/// Generate a waveform overview for a specific range of frames
///
/// # Arguments
/// * `start_frame` - Starting frame index (0-based)
/// * `end_frame` - Ending frame index (exclusive)
/// * `target_peaks` - Desired number of peaks to generate
pub fn generate_waveform_overview_range(
&self,
start_frame: usize,
end_frame: usize,
target_peaks: usize,
) -> Vec<crate::io::WaveformPeak> {
if self.frames == 0 || target_peaks == 0 { if self.frames == 0 || target_peaks == 0 {
return Vec::new(); return Vec::new();
} }
let total_frames = self.frames as usize; let total_frames = self.frames as usize;
let frames_per_peak = (total_frames / target_peaks).max(1); let start_frame = start_frame.min(total_frames);
let actual_peaks = (total_frames + frames_per_peak - 1) / frames_per_peak; let end_frame = end_frame.min(total_frames);
if start_frame >= end_frame {
return Vec::new();
}
let range_frames = end_frame - start_frame;
let frames_per_peak = (range_frames / target_peaks).max(1);
let actual_peaks = (range_frames + frames_per_peak - 1) / frames_per_peak;
let mut peaks = Vec::with_capacity(actual_peaks); let mut peaks = Vec::with_capacity(actual_peaks);
for peak_idx in 0..actual_peaks { for peak_idx in 0..actual_peaks {
let start_frame = peak_idx * frames_per_peak; let peak_start = start_frame + peak_idx * frames_per_peak;
let end_frame = ((peak_idx + 1) * frames_per_peak).min(total_frames); let peak_end = (start_frame + (peak_idx + 1) * frames_per_peak).min(end_frame);
let mut min = 0.0f32; let mut min = 0.0f32;
let mut max = 0.0f32; let mut max = 0.0f32;
// Scan all samples in this window // Scan all samples in this window
for frame_idx in start_frame..end_frame { for frame_idx in peak_start..peak_end {
// For multi-channel audio, combine all channels // For multi-channel audio, combine all channels
for ch in 0..self.channels as usize { for ch in 0..self.channels as usize {
let sample_idx = frame_idx * self.channels as usize + ch; let sample_idx = frame_idx * self.channels as usize + ch;
@ -159,6 +182,25 @@ impl AudioClipPool {
}) })
} }
/// Generate waveform overview for a specific range of a file in the pool
///
/// # Arguments
/// * `pool_index` - Index of the file in the pool
/// * `start_frame` - Starting frame index (0-based)
/// * `end_frame` - Ending frame index (exclusive)
/// * `target_peaks` - Desired number of peaks to generate
pub fn generate_waveform_range(
&self,
pool_index: usize,
start_frame: usize,
end_frame: usize,
target_peaks: usize,
) -> Option<Vec<crate::io::WaveformPeak>> {
self.files.get(pool_index).map(|file| {
file.generate_waveform_overview_range(start_frame, end_frame, target_peaks)
})
}
/// Add an audio file to the pool and return its index /// Add an audio file to the pool and return its index
pub fn add_file(&mut self, file: AudioFile) -> usize { pub fn add_file(&mut self, file: AudioFile) -> usize {
let index = self.files.len(); let index = self.files.len();

View File

@ -136,25 +136,48 @@ impl AudioFile {
/// Generate a waveform overview with the specified number of peaks /// Generate a waveform overview with the specified number of peaks
/// This creates a downsampled representation suitable for timeline visualization /// This creates a downsampled representation suitable for timeline visualization
pub fn generate_waveform_overview(&self, target_peaks: usize) -> Vec<WaveformPeak> { pub fn generate_waveform_overview(&self, target_peaks: usize) -> Vec<WaveformPeak> {
self.generate_waveform_overview_range(0, self.frames as usize, target_peaks)
}
/// Generate a waveform overview for a specific range of frames
///
/// # Arguments
/// * `start_frame` - Starting frame index (0-based)
/// * `end_frame` - Ending frame index (exclusive)
/// * `target_peaks` - Desired number of peaks to generate
pub fn generate_waveform_overview_range(
&self,
start_frame: usize,
end_frame: usize,
target_peaks: usize,
) -> Vec<WaveformPeak> {
if self.frames == 0 || target_peaks == 0 { if self.frames == 0 || target_peaks == 0 {
return Vec::new(); return Vec::new();
} }
let total_frames = self.frames as usize; let total_frames = self.frames as usize;
let frames_per_peak = (total_frames / target_peaks).max(1); let start_frame = start_frame.min(total_frames);
let actual_peaks = (total_frames + frames_per_peak - 1) / frames_per_peak; let end_frame = end_frame.min(total_frames);
if start_frame >= end_frame {
return Vec::new();
}
let range_frames = end_frame - start_frame;
let frames_per_peak = (range_frames / target_peaks).max(1);
let actual_peaks = (range_frames + frames_per_peak - 1) / frames_per_peak;
let mut peaks = Vec::with_capacity(actual_peaks); let mut peaks = Vec::with_capacity(actual_peaks);
for peak_idx in 0..actual_peaks { for peak_idx in 0..actual_peaks {
let start_frame = peak_idx * frames_per_peak; let peak_start = start_frame + peak_idx * frames_per_peak;
let end_frame = ((peak_idx + 1) * frames_per_peak).min(total_frames); let peak_end = (start_frame + (peak_idx + 1) * frames_per_peak).min(end_frame);
let mut min = 0.0f32; let mut min = 0.0f32;
let mut max = 0.0f32; let mut max = 0.0f32;
// Scan all samples in this window // Scan all samples in this window
for frame_idx in start_frame..end_frame { for frame_idx in peak_start..peak_end {
// For multi-channel audio, combine all channels // For multi-channel audio, combine all channels
for ch in 0..self.channels as usize { for ch in 0..self.channels as usize {
let sample_idx = frame_idx * self.channels as usize + ch; let sample_idx = frame_idx * self.channels as usize + ch;

View File

@ -9,8 +9,16 @@
//! - `ActionExecutor`: Wraps the document and manages undo/redo stacks //! - `ActionExecutor`: Wraps the document and manages undo/redo stacks
//! - Document mutations are only accessible via `pub(crate)` methods //! - Document mutations are only accessible via `pub(crate)` methods
//! - External code gets read-only access via `ActionExecutor::document()` //! - External code gets read-only access via `ActionExecutor::document()`
//!
//! ## Memory Model
//!
//! The document is stored in an `Arc<Document>` for efficient cloning during
//! GPU render callbacks. When mutation is needed, `Arc::make_mut()` provides
//! copy-on-write semantics - if other Arc holders exist (e.g., in-flight render
//! callbacks), the document is cloned before mutation, preserving their snapshot.
use crate::document::Document; use crate::document::Document;
use std::sync::Arc;
/// Action trait for undo/redo operations /// Action trait for undo/redo operations
/// ///
@ -31,9 +39,12 @@ pub trait Action: Send {
/// ///
/// This is the only way to get mutable access to the document, ensuring /// This is the only way to get mutable access to the document, ensuring
/// all mutations go through the action system. /// all mutations go through the action system.
///
/// The document is stored in `Arc<Document>` for efficient sharing with
/// render callbacks. Use `document_arc()` for cheap cloning to GPU passes.
pub struct ActionExecutor { pub struct ActionExecutor {
/// The document being edited /// The document being edited (wrapped in Arc for cheap cloning)
document: Document, document: Arc<Document>,
/// Stack of executed actions (for undo) /// Stack of executed actions (for undo)
undo_stack: Vec<Box<dyn Action>>, undo_stack: Vec<Box<dyn Action>>,
@ -49,7 +60,7 @@ impl ActionExecutor {
/// Create a new action executor with the given document /// Create a new action executor with the given document
pub fn new(document: Document) -> Self { pub fn new(document: Document) -> Self {
Self { Self {
document, document: Arc::new(document),
undo_stack: Vec::new(), undo_stack: Vec::new(),
redo_stack: Vec::new(), redo_stack: Vec::new(),
max_undo_depth: 100, // Default: keep last 100 actions max_undo_depth: 100, // Default: keep last 100 actions
@ -64,19 +75,33 @@ impl ActionExecutor {
&self.document &self.document
} }
/// Get a cheap clone of the document Arc for render callbacks
///
/// Use this when passing the document to GPU render passes or other
/// contexts that need to own a reference. Cloning Arc is just a pointer
/// copy + atomic increment, not a deep clone.
pub fn document_arc(&self) -> Arc<Document> {
Arc::clone(&self.document)
}
/// Get mutable access to the document /// Get mutable access to the document
///
/// Uses copy-on-write semantics: if other Arc holders exist (e.g., in-flight
/// render callbacks), the document is cloned before mutation. Otherwise,
/// returns direct mutable access.
///
/// Note: This should only be used for live previews. Permanent changes /// Note: This should only be used for live previews. Permanent changes
/// should go through the execute() method to support undo/redo. /// should go through the execute() method to support undo/redo.
pub fn document_mut(&mut self) -> &mut Document { pub fn document_mut(&mut self) -> &mut Document {
&mut self.document Arc::make_mut(&mut self.document)
} }
/// Execute an action and add it to the undo stack /// Execute an action and add it to the undo stack
/// ///
/// This clears the redo stack since we're creating a new timeline branch. /// This clears the redo stack since we're creating a new timeline branch.
pub fn execute(&mut self, mut action: Box<dyn Action>) { pub fn execute(&mut self, mut action: Box<dyn Action>) {
// Apply the action // Apply the action (uses copy-on-write if other Arc holders exist)
action.execute(&mut self.document); action.execute(Arc::make_mut(&mut self.document));
// Clear redo stack (new action invalidates redo history) // Clear redo stack (new action invalidates redo history)
self.redo_stack.clear(); self.redo_stack.clear();
@ -95,8 +120,8 @@ impl ActionExecutor {
/// Returns true if an action was undone, false if undo stack is empty. /// Returns true if an action was undone, false if undo stack is empty.
pub fn undo(&mut self) -> bool { pub fn undo(&mut self) -> bool {
if let Some(mut action) = self.undo_stack.pop() { if let Some(mut action) = self.undo_stack.pop() {
// Rollback the action // Rollback the action (uses copy-on-write if other Arc holders exist)
action.rollback(&mut self.document); action.rollback(Arc::make_mut(&mut self.document));
// Move to redo stack // Move to redo stack
self.redo_stack.push(action); self.redo_stack.push(action);
@ -112,8 +137,8 @@ impl ActionExecutor {
/// Returns true if an action was redone, false if redo stack is empty. /// Returns true if an action was redone, false if redo stack is empty.
pub fn redo(&mut self) -> bool { pub fn redo(&mut self) -> bool {
if let Some(mut action) = self.redo_stack.pop() { if let Some(mut action) = self.redo_stack.pop() {
// Re-execute the action // Re-execute the action (uses copy-on-write if other Arc holders exist)
action.execute(&mut self.document); action.execute(Arc::make_mut(&mut self.document));
// Move back to undo stack // Move back to undo stack
self.undo_stack.push(action); self.undo_stack.push(action);

View File

@ -9,7 +9,10 @@ pub mod add_shape;
pub mod move_clip_instances; pub mod move_clip_instances;
pub mod move_objects; pub mod move_objects;
pub mod paint_bucket; pub mod paint_bucket;
pub mod set_document_properties;
pub mod set_instance_properties;
pub mod set_layer_properties; pub mod set_layer_properties;
pub mod set_shape_properties;
pub mod transform_clip_instances; pub mod transform_clip_instances;
pub mod transform_objects; pub mod transform_objects;
pub mod trim_clip_instances; pub mod trim_clip_instances;
@ -20,7 +23,10 @@ pub use add_shape::AddShapeAction;
pub use move_clip_instances::MoveClipInstancesAction; pub use move_clip_instances::MoveClipInstancesAction;
pub use move_objects::MoveShapeInstancesAction; pub use move_objects::MoveShapeInstancesAction;
pub use paint_bucket::PaintBucketAction; pub use paint_bucket::PaintBucketAction;
pub use set_document_properties::SetDocumentPropertiesAction;
pub use set_instance_properties::{InstancePropertyChange, SetInstancePropertiesAction};
pub use set_layer_properties::{LayerProperty, SetLayerPropertiesAction}; pub use set_layer_properties::{LayerProperty, SetLayerPropertiesAction};
pub use set_shape_properties::{SetShapePropertiesAction, ShapePropertyChange};
pub use transform_clip_instances::TransformClipInstancesAction; pub use transform_clip_instances::TransformClipInstancesAction;
pub use transform_objects::TransformShapeInstancesAction; pub use transform_objects::TransformShapeInstancesAction;
pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType}; pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType};

View File

@ -0,0 +1,183 @@
//! Set document properties action
//!
//! Handles changing document-level properties (width, height, duration, framerate)
//! with undo/redo support.
use crate::action::Action;
use crate::document::Document;
/// Individual property change for a document
#[derive(Clone, Debug)]
pub enum DocumentPropertyChange {
Width(f64),
Height(f64),
Duration(f64),
Framerate(f64),
}
impl DocumentPropertyChange {
/// Extract the f64 value from any variant
fn value(&self) -> f64 {
match self {
DocumentPropertyChange::Width(v) => *v,
DocumentPropertyChange::Height(v) => *v,
DocumentPropertyChange::Duration(v) => *v,
DocumentPropertyChange::Framerate(v) => *v,
}
}
}
/// Action that sets a property on the document
pub struct SetDocumentPropertiesAction {
/// The new property value
property: DocumentPropertyChange,
/// The old value for undo
old_value: Option<f64>,
}
impl SetDocumentPropertiesAction {
/// Create a new action to set width
pub fn set_width(width: f64) -> Self {
Self {
property: DocumentPropertyChange::Width(width),
old_value: None,
}
}
/// Create a new action to set height
pub fn set_height(height: f64) -> Self {
Self {
property: DocumentPropertyChange::Height(height),
old_value: None,
}
}
/// Create a new action to set duration
pub fn set_duration(duration: f64) -> Self {
Self {
property: DocumentPropertyChange::Duration(duration),
old_value: None,
}
}
/// Create a new action to set framerate
pub fn set_framerate(framerate: f64) -> Self {
Self {
property: DocumentPropertyChange::Framerate(framerate),
old_value: None,
}
}
fn get_current_value(&self, document: &Document) -> f64 {
match &self.property {
DocumentPropertyChange::Width(_) => document.width,
DocumentPropertyChange::Height(_) => document.height,
DocumentPropertyChange::Duration(_) => document.duration,
DocumentPropertyChange::Framerate(_) => document.framerate,
}
}
fn apply_value(&self, document: &mut Document, value: f64) {
match &self.property {
DocumentPropertyChange::Width(_) => document.width = value,
DocumentPropertyChange::Height(_) => document.height = value,
DocumentPropertyChange::Duration(_) => document.duration = value,
DocumentPropertyChange::Framerate(_) => document.framerate = value,
}
}
}
impl Action for SetDocumentPropertiesAction {
fn execute(&mut self, document: &mut Document) {
// Store old value if not already stored
if self.old_value.is_none() {
self.old_value = Some(self.get_current_value(document));
}
// Apply new value
let new_value = self.property.value();
self.apply_value(document, new_value);
}
fn rollback(&mut self, document: &mut Document) {
if let Some(old_value) = self.old_value {
self.apply_value(document, old_value);
}
}
fn description(&self) -> String {
let property_name = match &self.property {
DocumentPropertyChange::Width(_) => "canvas width",
DocumentPropertyChange::Height(_) => "canvas height",
DocumentPropertyChange::Duration(_) => "duration",
DocumentPropertyChange::Framerate(_) => "framerate",
};
format!("Set {}", property_name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_set_width() {
let mut document = Document::new("Test");
document.width = 1920.0;
let mut action = SetDocumentPropertiesAction::set_width(1280.0);
action.execute(&mut document);
assert_eq!(document.width, 1280.0);
action.rollback(&mut document);
assert_eq!(document.width, 1920.0);
}
#[test]
fn test_set_height() {
let mut document = Document::new("Test");
document.height = 1080.0;
let mut action = SetDocumentPropertiesAction::set_height(720.0);
action.execute(&mut document);
assert_eq!(document.height, 720.0);
action.rollback(&mut document);
assert_eq!(document.height, 1080.0);
}
#[test]
fn test_set_duration() {
let mut document = Document::new("Test");
document.duration = 10.0;
let mut action = SetDocumentPropertiesAction::set_duration(30.0);
action.execute(&mut document);
assert_eq!(document.duration, 30.0);
action.rollback(&mut document);
assert_eq!(document.duration, 10.0);
}
#[test]
fn test_set_framerate() {
let mut document = Document::new("Test");
document.framerate = 30.0;
let mut action = SetDocumentPropertiesAction::set_framerate(60.0);
action.execute(&mut document);
assert_eq!(document.framerate, 60.0);
action.rollback(&mut document);
assert_eq!(document.framerate, 30.0);
}
#[test]
fn test_description() {
let action = SetDocumentPropertiesAction::set_width(100.0);
assert_eq!(action.description(), "Set canvas width");
let action = SetDocumentPropertiesAction::set_duration(30.0);
assert_eq!(action.description(), "Set duration");
}
}

View File

@ -0,0 +1,361 @@
//! Set shape instance properties action
//!
//! Handles changing individual properties on shape instances (position, rotation, scale, etc.)
//! with undo/redo support.
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
/// Individual property change for a shape instance
#[derive(Clone, Debug)]
pub enum InstancePropertyChange {
X(f64),
Y(f64),
Rotation(f64),
ScaleX(f64),
ScaleY(f64),
SkewX(f64),
SkewY(f64),
Opacity(f64),
}
impl InstancePropertyChange {
/// Extract the f64 value from any variant
fn value(&self) -> f64 {
match self {
InstancePropertyChange::X(v) => *v,
InstancePropertyChange::Y(v) => *v,
InstancePropertyChange::Rotation(v) => *v,
InstancePropertyChange::ScaleX(v) => *v,
InstancePropertyChange::ScaleY(v) => *v,
InstancePropertyChange::SkewX(v) => *v,
InstancePropertyChange::SkewY(v) => *v,
InstancePropertyChange::Opacity(v) => *v,
}
}
}
/// Action that sets a property on one or more shape instances
pub struct SetInstancePropertiesAction {
/// Layer containing the instances
layer_id: Uuid,
/// Instance IDs to modify and their old values
instance_changes: Vec<(Uuid, Option<f64>)>,
/// Property to change
property: InstancePropertyChange,
}
impl SetInstancePropertiesAction {
/// Create a new action to set a property on a single instance
pub fn new(layer_id: Uuid, instance_id: Uuid, property: InstancePropertyChange) -> Self {
Self {
layer_id,
instance_changes: vec![(instance_id, None)],
property,
}
}
/// Create a new action to set a property on multiple instances
pub fn new_batch(layer_id: Uuid, instance_ids: Vec<Uuid>, property: InstancePropertyChange) -> Self {
Self {
layer_id,
instance_changes: instance_ids.into_iter().map(|id| (id, None)).collect(),
property,
}
}
fn get_instance_value(&self, document: &Document, instance_id: &Uuid) -> Option<f64> {
if let Some(layer) = document.get_layer(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(instance) = vector_layer.get_object(instance_id) {
return Some(match &self.property {
InstancePropertyChange::X(_) => instance.transform.x,
InstancePropertyChange::Y(_) => instance.transform.y,
InstancePropertyChange::Rotation(_) => instance.transform.rotation,
InstancePropertyChange::ScaleX(_) => instance.transform.scale_x,
InstancePropertyChange::ScaleY(_) => instance.transform.scale_y,
InstancePropertyChange::SkewX(_) => instance.transform.skew_x,
InstancePropertyChange::SkewY(_) => instance.transform.skew_y,
InstancePropertyChange::Opacity(_) => instance.opacity,
});
}
}
}
None
}
fn apply_to_instance(&self, document: &mut Document, instance_id: &Uuid, value: f64) {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
vector_layer.modify_object_internal(instance_id, |instance| {
match &self.property {
InstancePropertyChange::X(_) => instance.transform.x = value,
InstancePropertyChange::Y(_) => instance.transform.y = value,
InstancePropertyChange::Rotation(_) => instance.transform.rotation = value,
InstancePropertyChange::ScaleX(_) => instance.transform.scale_x = value,
InstancePropertyChange::ScaleY(_) => instance.transform.scale_y = value,
InstancePropertyChange::SkewX(_) => instance.transform.skew_x = value,
InstancePropertyChange::SkewY(_) => instance.transform.skew_y = value,
InstancePropertyChange::Opacity(_) => instance.opacity = value,
}
});
}
}
}
}
impl Action for SetInstancePropertiesAction {
fn execute(&mut self, document: &mut Document) {
let new_value = self.property.value();
let layer_id = self.layer_id;
// First pass: collect old values for instances that don't have them yet
for (instance_id, old_value) in &mut self.instance_changes {
if old_value.is_none() {
// Get old value inline to avoid borrow issues
if let Some(layer) = document.get_layer(&layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(instance) = vector_layer.get_object(instance_id) {
*old_value = Some(match &self.property {
InstancePropertyChange::X(_) => instance.transform.x,
InstancePropertyChange::Y(_) => instance.transform.y,
InstancePropertyChange::Rotation(_) => instance.transform.rotation,
InstancePropertyChange::ScaleX(_) => instance.transform.scale_x,
InstancePropertyChange::ScaleY(_) => instance.transform.scale_y,
InstancePropertyChange::SkewX(_) => instance.transform.skew_x,
InstancePropertyChange::SkewY(_) => instance.transform.skew_y,
InstancePropertyChange::Opacity(_) => instance.opacity,
});
}
}
}
}
}
// Second pass: apply new values
for (instance_id, _) in &self.instance_changes {
self.apply_to_instance(document, instance_id, new_value);
}
}
fn rollback(&mut self, document: &mut Document) {
for (instance_id, old_value) in &self.instance_changes {
if let Some(value) = old_value {
self.apply_to_instance(document, instance_id, *value);
}
}
}
fn description(&self) -> String {
let property_name = match &self.property {
InstancePropertyChange::X(_) => "X position",
InstancePropertyChange::Y(_) => "Y position",
InstancePropertyChange::Rotation(_) => "rotation",
InstancePropertyChange::ScaleX(_) => "scale X",
InstancePropertyChange::ScaleY(_) => "scale Y",
InstancePropertyChange::SkewX(_) => "skew X",
InstancePropertyChange::SkewY(_) => "skew Y",
InstancePropertyChange::Opacity(_) => "opacity",
};
if self.instance_changes.len() == 1 {
format!("Set {}", property_name)
} else {
format!("Set {} on {} objects", property_name, self.instance_changes.len())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::object::{ShapeInstance, Transform};
#[test]
fn test_set_x_position() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape_id = Uuid::new_v4();
let mut instance = ShapeInstance::new(shape_id);
let instance_id = instance.id;
instance.transform = Transform::with_position(10.0, 20.0);
layer.add_object(instance);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create and execute action
let mut action = SetInstancePropertiesAction::new(
layer_id,
instance_id,
InstancePropertyChange::X(50.0),
);
action.execute(&mut document);
// Verify position changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.transform.x, 50.0);
assert_eq!(obj.transform.y, 20.0); // Y unchanged
}
// Rollback
action.rollback(&mut document);
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.transform.x, 10.0);
}
}
#[test]
fn test_set_rotation() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape_id = Uuid::new_v4();
let mut instance = ShapeInstance::new(shape_id);
let instance_id = instance.id;
instance.transform.rotation = 0.0;
layer.add_object(instance);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create and execute action
let mut action = SetInstancePropertiesAction::new(
layer_id,
instance_id,
InstancePropertyChange::Rotation(45.0),
);
action.execute(&mut document);
// Verify rotation changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.transform.rotation, 45.0);
}
// Rollback
action.rollback(&mut document);
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.transform.rotation, 0.0);
}
}
#[test]
fn test_set_opacity() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape_id = Uuid::new_v4();
let mut instance = ShapeInstance::new(shape_id);
let instance_id = instance.id;
instance.opacity = 1.0;
layer.add_object(instance);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create and execute action
let mut action = SetInstancePropertiesAction::new(
layer_id,
instance_id,
InstancePropertyChange::Opacity(0.5),
);
action.execute(&mut document);
// Verify opacity changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.opacity, 0.5);
}
// Rollback
action.rollback(&mut document);
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let obj = vl.get_object(&instance_id).unwrap();
assert_eq!(obj.opacity, 1.0);
}
}
#[test]
fn test_batch_set_scale() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape_id = Uuid::new_v4();
let mut instance1 = ShapeInstance::new(shape_id);
let instance1_id = instance1.id;
instance1.transform.scale_x = 1.0;
let mut instance2 = ShapeInstance::new(shape_id);
let instance2_id = instance2.id;
instance2.transform.scale_x = 1.0;
layer.add_object(instance1);
layer.add_object(instance2);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create and execute batch action
let mut action = SetInstancePropertiesAction::new_batch(
layer_id,
vec![instance1_id, instance2_id],
InstancePropertyChange::ScaleX(2.0),
);
action.execute(&mut document);
// Verify both changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
assert_eq!(vl.get_object(&instance1_id).unwrap().transform.scale_x, 2.0);
assert_eq!(vl.get_object(&instance2_id).unwrap().transform.scale_x, 2.0);
}
// Rollback
action.rollback(&mut document);
// Verify both restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
assert_eq!(vl.get_object(&instance1_id).unwrap().transform.scale_x, 1.0);
assert_eq!(vl.get_object(&instance2_id).unwrap().transform.scale_x, 1.0);
}
}
#[test]
fn test_description() {
let layer_id = Uuid::new_v4();
let instance_id = Uuid::new_v4();
let action1 = SetInstancePropertiesAction::new(
layer_id,
instance_id,
InstancePropertyChange::X(0.0),
);
assert_eq!(action1.description(), "Set X position");
let action2 = SetInstancePropertiesAction::new(
layer_id,
instance_id,
InstancePropertyChange::Rotation(0.0),
);
assert_eq!(action2.description(), "Set rotation");
let action3 = SetInstancePropertiesAction::new_batch(
layer_id,
vec![Uuid::new_v4(), Uuid::new_v4()],
InstancePropertyChange::Opacity(1.0),
);
assert_eq!(action3.description(), "Set opacity on 2 objects");
}
}

View File

@ -0,0 +1,261 @@
//! Set shape properties action
//!
//! Handles changing shape properties (fill color, stroke color, stroke width)
//! with undo/redo support.
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::shape::{ShapeColor, StrokeStyle};
use uuid::Uuid;
/// Property change for a shape
#[derive(Clone, Debug)]
pub enum ShapePropertyChange {
FillColor(Option<ShapeColor>),
StrokeColor(Option<ShapeColor>),
StrokeWidth(f64),
}
/// Action that sets properties on a shape
pub struct SetShapePropertiesAction {
/// Layer containing the shape
layer_id: Uuid,
/// Shape to modify
shape_id: Uuid,
/// New property value
new_value: ShapePropertyChange,
/// Old property value (stored after first execution)
old_value: Option<ShapePropertyChange>,
}
impl SetShapePropertiesAction {
/// Create a new action to set a property on a shape
pub fn new(layer_id: Uuid, shape_id: Uuid, new_value: ShapePropertyChange) -> Self {
Self {
layer_id,
shape_id,
new_value,
old_value: None,
}
}
/// Create action to set fill color
pub fn set_fill_color(layer_id: Uuid, shape_id: Uuid, color: Option<ShapeColor>) -> Self {
Self::new(layer_id, shape_id, ShapePropertyChange::FillColor(color))
}
/// Create action to set stroke color
pub fn set_stroke_color(layer_id: Uuid, shape_id: Uuid, color: Option<ShapeColor>) -> Self {
Self::new(layer_id, shape_id, ShapePropertyChange::StrokeColor(color))
}
/// Create action to set stroke width
pub fn set_stroke_width(layer_id: Uuid, shape_id: Uuid, width: f64) -> Self {
Self::new(layer_id, shape_id, ShapePropertyChange::StrokeWidth(width))
}
}
impl Action for SetShapePropertiesAction {
fn execute(&mut self, document: &mut Document) {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) {
// Store old value if not already stored
if self.old_value.is_none() {
self.old_value = Some(match &self.new_value {
ShapePropertyChange::FillColor(_) => {
ShapePropertyChange::FillColor(shape.fill_color)
}
ShapePropertyChange::StrokeColor(_) => {
ShapePropertyChange::StrokeColor(shape.stroke_color)
}
ShapePropertyChange::StrokeWidth(_) => {
let width = shape
.stroke_style
.as_ref()
.map(|s| s.width)
.unwrap_or(1.0);
ShapePropertyChange::StrokeWidth(width)
}
});
}
// Apply new value
match &self.new_value {
ShapePropertyChange::FillColor(color) => {
shape.fill_color = *color;
}
ShapePropertyChange::StrokeColor(color) => {
shape.stroke_color = *color;
}
ShapePropertyChange::StrokeWidth(width) => {
if let Some(ref mut style) = shape.stroke_style {
style.width = *width;
} else {
// Create stroke style if it doesn't exist
shape.stroke_style = Some(StrokeStyle {
width: *width,
..Default::default()
});
}
}
}
}
}
}
}
fn rollback(&mut self, document: &mut Document) {
if let Some(old_value) = &self.old_value {
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(shape) = vector_layer.shapes.get_mut(&self.shape_id) {
match old_value {
ShapePropertyChange::FillColor(color) => {
shape.fill_color = *color;
}
ShapePropertyChange::StrokeColor(color) => {
shape.stroke_color = *color;
}
ShapePropertyChange::StrokeWidth(width) => {
if let Some(ref mut style) = shape.stroke_style {
style.width = *width;
}
}
}
}
}
}
}
}
fn description(&self) -> String {
match &self.new_value {
ShapePropertyChange::FillColor(_) => "Set fill color".to_string(),
ShapePropertyChange::StrokeColor(_) => "Set stroke color".to_string(),
ShapePropertyChange::StrokeWidth(_) => "Set stroke width".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::shape::Shape;
use kurbo::BezPath;
fn create_test_shape() -> Shape {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((100.0, 0.0));
path.line_to((100.0, 100.0));
path.line_to((0.0, 100.0));
path.close_path();
let mut shape = Shape::new(path);
shape.fill_color = Some(ShapeColor::rgb(255, 0, 0));
shape.stroke_color = Some(ShapeColor::rgb(0, 0, 0));
shape.stroke_style = Some(StrokeStyle {
width: 2.0,
..Default::default()
});
shape
}
#[test]
fn test_set_fill_color() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape = create_test_shape();
let shape_id = shape.id;
layer.shapes.insert(shape_id, shape);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Verify initial color
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
assert_eq!(shape.fill_color.unwrap().r, 255);
}
// Create and execute action
let new_color = Some(ShapeColor::rgb(0, 255, 0));
let mut action = SetShapePropertiesAction::set_fill_color(layer_id, shape_id, new_color);
action.execute(&mut document);
// Verify color changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
assert_eq!(shape.fill_color.unwrap().g, 255);
}
// Rollback
action.rollback(&mut document);
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
assert_eq!(shape.fill_color.unwrap().r, 255);
}
}
#[test]
fn test_set_stroke_width() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape = create_test_shape();
let shape_id = shape.id;
layer.shapes.insert(shape_id, shape);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Verify initial width
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 2.0);
}
// Create and execute action
let mut action = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 5.0);
action.execute(&mut document);
// Verify width changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 5.0);
}
// Rollback
action.rollback(&mut document);
// Verify restored
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
let shape = vl.shapes.get(&shape_id).unwrap();
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 2.0);
}
}
#[test]
fn test_description() {
let layer_id = Uuid::new_v4();
let shape_id = Uuid::new_v4();
let action1 =
SetShapePropertiesAction::set_fill_color(layer_id, shape_id, Some(ShapeColor::rgb(0, 0, 0)));
assert_eq!(action1.description(), "Set fill color");
let action2 =
SetShapePropertiesAction::set_stroke_color(layer_id, shape_id, Some(ShapeColor::rgb(0, 0, 0)));
assert_eq!(action2.description(), "Set stroke color");
let action3 = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 3.0);
assert_eq!(action3.description(), "Set stroke width");
}
}

View File

@ -144,7 +144,7 @@ impl StrokeStyle {
} }
/// Serializable color representation /// Serializable color representation
#[derive(Clone, Copy, Debug, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShapeColor { pub struct ShapeColor {
pub r: u8, pub r: u8,
pub g: u8, pub g: u8,
@ -238,12 +238,12 @@ pub struct Shape {
} }
impl Shape { impl Shape {
/// Create a new shape with a single path /// Create a new shape with a single path (no fill or stroke by default)
pub fn new(path: BezPath) -> Self { pub fn new(path: BezPath) -> Self {
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
versions: vec![ShapeVersion::new(path, 0)], versions: vec![ShapeVersion::new(path, 0)],
fill_color: Some(ShapeColor::rgb(0, 0, 0)), fill_color: None,
image_fill: None, image_fill: None,
fill_rule: FillRule::NonZero, fill_rule: FillRule::NonZero,
stroke_color: None, stroke_color: None,
@ -251,12 +251,12 @@ impl Shape {
} }
} }
/// Create a new shape with a specific ID /// Create a new shape with a specific ID (no fill or stroke by default)
pub fn with_id(id: Uuid, path: BezPath) -> Self { pub fn with_id(id: Uuid, path: BezPath) -> Self {
Self { Self {
id, id,
versions: vec![ShapeVersion::new(path, 0)], versions: vec![ShapeVersion::new(path, 0)],
fill_color: Some(ShapeColor::rgb(0, 0, 0)), fill_color: None,
image_fill: None, image_fill: None,
fill_rule: FillRule::NonZero, fill_rule: FillRule::NonZero,
stroke_color: None, stroke_color: None,

View File

@ -7,7 +7,7 @@ use lightningbeam_core::clip::{ClipInstance, VectorClip};
use lightningbeam_core::document::Document; use lightningbeam_core::document::Document;
use lightningbeam_core::layer::{AnyLayer, LayerTrait, VectorLayer}; use lightningbeam_core::layer::{AnyLayer, LayerTrait, VectorLayer};
use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::object::ShapeInstance;
use lightningbeam_core::renderer::{render_document, render_document_with_transform}; use lightningbeam_core::renderer::{render_document, render_document_with_transform, ImageCache};
use lightningbeam_core::shape::{Shape, ShapeColor}; use lightningbeam_core::shape::{Shape, ShapeColor};
use vello::kurbo::{Affine, Circle, Shape as KurboShape}; use vello::kurbo::{Affine, Circle, Shape as KurboShape};
use vello::Scene; use vello::Scene;
@ -53,28 +53,31 @@ fn setup_rendering_document() -> (Document, Vec<uuid::Uuid>) {
fn test_render_empty_document() { fn test_render_empty_document() {
let document = Document::new("Empty"); let document = Document::new("Empty");
let mut scene = Scene::new(); let mut scene = Scene::new();
let mut image_cache = ImageCache::new();
// Should not panic // Should not panic
render_document(&document, &mut scene); render_document(&document, &mut scene, &mut image_cache);
} }
#[test] #[test]
fn test_render_document_with_shapes() { fn test_render_document_with_shapes() {
let (document, _ids) = setup_rendering_document(); let (document, _ids) = setup_rendering_document();
let mut scene = Scene::new(); let mut scene = Scene::new();
let mut image_cache = ImageCache::new();
// Should render all 3 layers without error // Should render all 3 layers without error
render_document(&document, &mut scene); render_document(&document, &mut scene, &mut image_cache);
} }
#[test] #[test]
fn test_render_with_transform() { fn test_render_with_transform() {
let (document, _ids) = setup_rendering_document(); let (document, _ids) = setup_rendering_document();
let mut scene = Scene::new(); let mut scene = Scene::new();
let mut image_cache = ImageCache::new();
// Render with zoom and pan // Render with zoom and pan
let transform = Affine::translate((100.0, 50.0)) * Affine::scale(2.0); let transform = Affine::translate((100.0, 50.0)) * Affine::scale(2.0);
render_document_with_transform(&document, &mut scene, transform); render_document_with_transform(&document, &mut scene, transform, &mut image_cache);
} }
#[test] #[test]
@ -98,7 +101,8 @@ fn test_render_solo_single_layer() {
// Render should work // Render should work
let mut scene = Scene::new(); let mut scene = Scene::new();
render_document(&document, &mut scene); let mut image_cache = ImageCache::new();
render_document(&document, &mut scene, &mut image_cache);
} }
#[test] #[test]
@ -121,7 +125,8 @@ fn test_render_solo_multiple_layers() {
assert_eq!(layers_to_render.len(), 2); assert_eq!(layers_to_render.len(), 2);
let mut scene = Scene::new(); let mut scene = Scene::new();
render_document(&document, &mut scene); let mut image_cache = ImageCache::new();
render_document(&document, &mut scene, &mut image_cache);
} }
#[test] #[test]
@ -137,7 +142,8 @@ fn test_render_hidden_layer_not_rendered() {
assert_eq!(document.visible_layers().count(), 2); assert_eq!(document.visible_layers().count(), 2);
let mut scene = Scene::new(); let mut scene = Scene::new();
render_document(&document, &mut scene); let mut image_cache = ImageCache::new();
render_document(&document, &mut scene, &mut image_cache);
} }
#[test] #[test]
@ -161,7 +167,8 @@ fn test_render_with_layer_opacity() {
assert_eq!(document.root.get_child(&ids[2]).unwrap().opacity(), 1.0); assert_eq!(document.root.get_child(&ids[2]).unwrap().opacity(), 1.0);
let mut scene = Scene::new(); let mut scene = Scene::new();
render_document(&document, &mut scene); let mut image_cache = ImageCache::new();
render_document(&document, &mut scene, &mut image_cache);
} }
#[test] #[test]
@ -198,7 +205,8 @@ fn test_render_with_clip_instances() {
document.set_time(2.0); document.set_time(2.0);
let mut scene = Scene::new(); let mut scene = Scene::new();
render_document(&document, &mut scene); let mut image_cache = ImageCache::new();
render_document(&document, &mut scene, &mut image_cache);
} }
#[test] #[test]
@ -223,7 +231,8 @@ fn test_render_clip_instance_outside_time_range() {
// Clip shouldn't render (it hasn't started yet) // Clip shouldn't render (it hasn't started yet)
let mut scene = Scene::new(); let mut scene = Scene::new();
render_document(&document, &mut scene); let mut image_cache = ImageCache::new();
render_document(&document, &mut scene, &mut image_cache);
} }
#[test] #[test]
@ -242,7 +251,8 @@ fn test_render_all_layers_hidden() {
// Should still render (just background) // Should still render (just background)
let mut scene = Scene::new(); let mut scene = Scene::new();
render_document(&document, &mut scene); let mut image_cache = ImageCache::new();
render_document(&document, &mut scene, &mut image_cache);
} }
#[test] #[test]
@ -269,7 +279,8 @@ fn test_render_solo_hidden_layer_interaction() {
assert_eq!(document.visible_layers().count(), 2); assert_eq!(document.visible_layers().count(), 2);
let mut scene = Scene::new(); let mut scene = Scene::new();
render_document(&document, &mut scene); let mut image_cache = ImageCache::new();
render_document(&document, &mut scene, &mut image_cache);
} }
#[test] #[test]
@ -278,17 +289,19 @@ fn test_render_background_color() {
document.background_color = ShapeColor::rgb(128, 128, 128); document.background_color = ShapeColor::rgb(128, 128, 128);
let mut scene = Scene::new(); let mut scene = Scene::new();
render_document(&document, &mut scene); let mut image_cache = ImageCache::new();
render_document(&document, &mut scene, &mut image_cache);
} }
#[test] #[test]
fn test_render_at_different_times() { fn test_render_at_different_times() {
let (mut document, _ids) = setup_rendering_document(); let (mut document, _ids) = setup_rendering_document();
let mut image_cache = ImageCache::new();
// Render at different times // Render at different times
for time in [0.0, 0.5, 1.0, 2.5, 5.0, 10.0] { for time in [0.0, 0.5, 1.0, 2.5, 5.0, 10.0] {
document.set_time(time); document.set_time(time);
let mut scene = Scene::new(); let mut scene = Scene::new();
render_document(&document, &mut scene); render_document(&document, &mut scene, &mut image_cache);
} }
} }

View File

@ -32,6 +32,7 @@ serde_json = { workspace = true }
# Image loading # Image loading
image = { workspace = true } image = { workspace = true }
resvg = { workspace = true } resvg = { workspace = true }
tiny-skia = "0.11"
# Utilities # Utilities
pollster = { workspace = true } pollster = { workspace = true }

View File

@ -277,6 +277,11 @@ struct EditorApp {
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
// Import dialog state // Import dialog state
last_import_filter: ImportFilter, // Last used import filter (remembered across imports) last_import_filter: ImportFilter, // Last used import filter (remembered across imports)
// Tool-specific options (displayed in infopanel)
stroke_width: f64, // Stroke width for drawing tools (default: 3.0)
fill_enabled: bool, // Whether to fill shapes (default: true)
paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0)
polygon_sides: u32, // Number of sides for polygon tool (default: 5)
} }
/// Import filter types for the file dialog /// Import filter types for the file dialog
@ -308,10 +313,11 @@ impl EditorApp {
use lightningbeam_core::shape::{Shape, ShapeColor}; use lightningbeam_core::shape::{Shape, ShapeColor};
use vello::kurbo::{Circle, Shape as KurboShape}; use vello::kurbo::{Circle, Shape as KurboShape};
let circle = Circle::new((200.0, 150.0), 50.0); // Create circle centered at origin, position via instance transform
let circle = Circle::new((0.0, 0.0), 50.0);
let path = circle.to_path(0.1); let path = circle.to_path(0.1);
let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250)); let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250));
let object = ShapeInstance::new(shape.id); let object = ShapeInstance::new(shape.id).with_position(200.0, 150.0);
let mut vector_layer = VectorLayer::new("Layer 1"); let mut vector_layer = VectorLayer::new("Layer 1");
vector_layer.add_shape(shape); vector_layer.add_shape(shape);
@ -364,6 +370,10 @@ impl EditorApp {
is_playing: false, // Start paused is_playing: false, // Start paused
dragging_asset: None, // No asset being dragged initially dragging_asset: None, // No asset being dragged initially
last_import_filter: ImportFilter::default(), // Default to "All Supported" last_import_filter: ImportFilter::default(), // Default to "All Supported"
stroke_width: 3.0, // Default stroke width
fill_enabled: true, // Default to filling shapes
paint_bucket_gap_tolerance: 5.0, // Default gap tolerance
polygon_sides: 5, // Default to pentagon
} }
} }
@ -981,6 +991,10 @@ impl eframe::App for EditorApp {
playback_time: &mut self.playback_time, playback_time: &mut self.playback_time,
is_playing: &mut self.is_playing, is_playing: &mut self.is_playing,
dragging_asset: &mut self.dragging_asset, dragging_asset: &mut self.dragging_asset,
stroke_width: &mut self.stroke_width,
fill_enabled: &mut self.fill_enabled,
paint_bucket_gap_tolerance: &mut self.paint_bucket_gap_tolerance,
polygon_sides: &mut self.polygon_sides,
}; };
render_layout_node( render_layout_node(
@ -1121,6 +1135,11 @@ struct RenderContext<'a> {
playback_time: &'a mut f64, playback_time: &'a mut f64,
is_playing: &'a mut bool, is_playing: &'a mut bool,
dragging_asset: &'a mut Option<panes::DraggingAsset>, dragging_asset: &'a mut Option<panes::DraggingAsset>,
// Tool-specific options for infopanel
stroke_width: &'a mut f64,
fill_enabled: &'a mut bool,
paint_bucket_gap_tolerance: &'a mut f64,
polygon_sides: &'a mut u32,
} }
/// Recursively render a layout node with drag support /// Recursively render a layout node with drag support
@ -1586,6 +1605,10 @@ fn render_pane(
playback_time: ctx.playback_time, playback_time: ctx.playback_time,
is_playing: ctx.is_playing, is_playing: ctx.is_playing,
dragging_asset: ctx.dragging_asset, dragging_asset: ctx.dragging_asset,
stroke_width: ctx.stroke_width,
fill_enabled: ctx.fill_enabled,
paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance,
polygon_sides: ctx.polygon_sides,
}; };
pane_instance.render_header(&mut header_ui, &mut shared); pane_instance.render_header(&mut header_ui, &mut shared);
} }
@ -1634,6 +1657,10 @@ fn render_pane(
playback_time: ctx.playback_time, playback_time: ctx.playback_time,
is_playing: ctx.is_playing, is_playing: ctx.is_playing,
dragging_asset: ctx.dragging_asset, dragging_asset: ctx.dragging_asset,
stroke_width: ctx.stroke_width,
fill_enabled: ctx.fill_enabled,
paint_bucket_gap_tolerance: ctx.paint_bucket_gap_tolerance,
polygon_sides: ctx.polygon_sides,
}; };
// Render pane content (header was already rendered above) // Render pane content (header was already rendered above)

View File

@ -1,16 +1,752 @@
/// Info Panel pane - displays properties of selected objects /// Info Panel pane - displays and edits properties of selected objects
/// ///
/// This will eventually show editable properties. /// Shows context-sensitive property editors based on current selection:
/// For now, it's a placeholder. /// - Tool options (when a tool is active)
/// - Transform properties (when shapes are selected)
/// - Shape properties (fill/stroke for selected shapes)
/// - Document settings (when nothing is selected)
use eframe::egui; use eframe::egui::{self, DragValue, Sense, Ui};
use lightningbeam_core::actions::{
InstancePropertyChange, SetDocumentPropertiesAction, SetInstancePropertiesAction,
SetShapePropertiesAction,
};
use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::shape::ShapeColor;
use lightningbeam_core::tool::{SimplifyMode, Tool};
use super::{NodePath, PaneRenderer, SharedPaneState}; use super::{NodePath, PaneRenderer, SharedPaneState};
use uuid::Uuid;
pub struct InfopanelPane {} /// Info panel pane state
pub struct InfopanelPane {
/// Whether the tool options section is expanded
tool_section_open: bool,
/// Whether the transform section is expanded
transform_section_open: bool,
/// Whether the shape properties section is expanded
shape_section_open: bool,
}
impl InfopanelPane { impl InfopanelPane {
pub fn new() -> Self { pub fn new() -> Self {
Self {} Self {
tool_section_open: true,
transform_section_open: true,
shape_section_open: true,
}
}
}
/// Aggregated info about the current selection
struct SelectionInfo {
/// True if nothing is selected
is_empty: bool,
/// Number of selected shape instances
shape_count: usize,
/// Layer ID of selected shapes (assumes single layer selection for now)
layer_id: Option<Uuid>,
/// Selected shape instance IDs
instance_ids: Vec<Uuid>,
/// Shape IDs referenced by selected instances
shape_ids: Vec<Uuid>,
// Transform values (None = mixed values across selection)
x: Option<f64>,
y: Option<f64>,
rotation: Option<f64>,
scale_x: Option<f64>,
scale_y: Option<f64>,
skew_x: Option<f64>,
skew_y: Option<f64>,
opacity: Option<f64>,
// Shape property values (None = mixed)
fill_color: Option<Option<ShapeColor>>,
stroke_color: Option<Option<ShapeColor>>,
stroke_width: Option<f64>,
}
impl Default for SelectionInfo {
fn default() -> Self {
Self {
is_empty: true,
shape_count: 0,
layer_id: None,
instance_ids: Vec::new(),
shape_ids: Vec::new(),
x: None,
y: None,
rotation: None,
scale_x: None,
scale_y: None,
skew_x: None,
skew_y: None,
opacity: None,
fill_color: None,
stroke_color: None,
stroke_width: None,
}
}
}
impl InfopanelPane {
/// Gather info about the current selection
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
let mut info = SelectionInfo::default();
let selected_instances = shared.selection.shape_instances();
info.shape_count = selected_instances.len();
info.is_empty = info.shape_count == 0;
if info.is_empty {
return info;
}
info.instance_ids = selected_instances.to_vec();
// Find the layer containing the selected instances
let document = shared.action_executor.document();
let active_layer_id = *shared.active_layer_id;
if let Some(layer_id) = active_layer_id {
info.layer_id = Some(layer_id);
if let Some(layer) = document.get_layer(&layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
// Gather values from all selected instances
let mut first = true;
for instance_id in &info.instance_ids {
if let Some(instance) = vector_layer.get_object(instance_id) {
info.shape_ids.push(instance.shape_id);
if first {
// First instance - set initial values
info.x = Some(instance.transform.x);
info.y = Some(instance.transform.y);
info.rotation = Some(instance.transform.rotation);
info.scale_x = Some(instance.transform.scale_x);
info.scale_y = Some(instance.transform.scale_y);
info.skew_x = Some(instance.transform.skew_x);
info.skew_y = Some(instance.transform.skew_y);
info.opacity = Some(instance.opacity);
// Get shape properties
if let Some(shape) = vector_layer.shapes.get(&instance.shape_id) {
info.fill_color = Some(shape.fill_color);
info.stroke_color = Some(shape.stroke_color);
info.stroke_width = shape
.stroke_style
.as_ref()
.map(|s| Some(s.width))
.unwrap_or(Some(1.0));
}
first = false;
} else {
// Check if values differ (set to None if mixed)
if info.x != Some(instance.transform.x) {
info.x = None;
}
if info.y != Some(instance.transform.y) {
info.y = None;
}
if info.rotation != Some(instance.transform.rotation) {
info.rotation = None;
}
if info.scale_x != Some(instance.transform.scale_x) {
info.scale_x = None;
}
if info.scale_y != Some(instance.transform.scale_y) {
info.scale_y = None;
}
if info.skew_x != Some(instance.transform.skew_x) {
info.skew_x = None;
}
if info.skew_y != Some(instance.transform.skew_y) {
info.skew_y = None;
}
if info.opacity != Some(instance.opacity) {
info.opacity = None;
}
// Check shape properties
if let Some(shape) = vector_layer.shapes.get(&instance.shape_id) {
// Compare fill colors - set to None if mixed
if let Some(current_fill) = &info.fill_color {
if *current_fill != shape.fill_color {
info.fill_color = None;
}
}
// Compare stroke colors - set to None if mixed
if let Some(current_stroke) = &info.stroke_color {
if *current_stroke != shape.stroke_color {
info.stroke_color = None;
}
}
let stroke_w = shape
.stroke_style
.as_ref()
.map(|s| s.width)
.unwrap_or(1.0);
if info.stroke_width != Some(stroke_w) {
info.stroke_width = None;
}
}
}
}
}
}
}
}
info
}
/// Render tool-specific options section
fn render_tool_section(&mut self, ui: &mut Ui, shared: &mut SharedPaneState) {
let tool = *shared.selected_tool;
// Only show tool options for tools that have options
let has_options = matches!(
tool,
Tool::Draw | Tool::Rectangle | Tool::Ellipse | Tool::PaintBucket | Tool::Polygon | Tool::Line
);
if !has_options {
return;
}
egui::CollapsingHeader::new("Tool Options")
.default_open(self.tool_section_open)
.show(ui, |ui| {
self.tool_section_open = true;
ui.add_space(4.0);
match tool {
Tool::Draw => {
// Stroke width
ui.horizontal(|ui| {
ui.label("Stroke Width:");
ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0));
});
// Simplify mode
ui.horizontal(|ui| {
ui.label("Simplify:");
egui::ComboBox::from_id_salt("draw_simplify")
.selected_text(match shared.draw_simplify_mode {
SimplifyMode::Corners => "Corners",
SimplifyMode::Smooth => "Smooth",
SimplifyMode::Verbatim => "Verbatim",
})
.show_ui(ui, |ui| {
ui.selectable_value(
shared.draw_simplify_mode,
SimplifyMode::Corners,
"Corners",
);
ui.selectable_value(
shared.draw_simplify_mode,
SimplifyMode::Smooth,
"Smooth",
);
ui.selectable_value(
shared.draw_simplify_mode,
SimplifyMode::Verbatim,
"Verbatim",
);
});
});
// Fill shape toggle
ui.checkbox(shared.fill_enabled, "Fill Shape");
}
Tool::Rectangle | Tool::Ellipse => {
// Stroke width
ui.horizontal(|ui| {
ui.label("Stroke Width:");
ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0));
});
// Fill shape toggle
ui.checkbox(shared.fill_enabled, "Fill Shape");
}
Tool::PaintBucket => {
// Gap tolerance
ui.horizontal(|ui| {
ui.label("Gap Tolerance:");
ui.add(
DragValue::new(shared.paint_bucket_gap_tolerance)
.speed(0.1)
.range(0.0..=50.0),
);
});
}
Tool::Polygon => {
// Number of sides
ui.horizontal(|ui| {
ui.label("Sides:");
let mut sides = *shared.polygon_sides as i32;
if ui.add(DragValue::new(&mut sides).range(3..=20)).changed() {
*shared.polygon_sides = sides.max(3) as u32;
}
});
// Stroke width
ui.horizontal(|ui| {
ui.label("Stroke Width:");
ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0));
});
// Fill shape toggle
ui.checkbox(shared.fill_enabled, "Fill Shape");
}
Tool::Line => {
// Stroke width
ui.horizontal(|ui| {
ui.label("Stroke Width:");
ui.add(DragValue::new(shared.stroke_width).speed(0.1).range(0.1..=100.0));
});
}
_ => {}
}
ui.add_space(4.0);
});
}
/// Render transform properties section
fn render_transform_section(
&mut self,
ui: &mut Ui,
shared: &mut SharedPaneState,
info: &SelectionInfo,
) {
egui::CollapsingHeader::new("Transform")
.default_open(self.transform_section_open)
.show(ui, |ui| {
self.transform_section_open = true;
ui.add_space(4.0);
let layer_id = match info.layer_id {
Some(id) => id,
None => return,
};
// Position X
self.render_transform_field(
ui,
"X:",
info.x,
1.0,
f64::NEG_INFINITY..=f64::INFINITY,
|value| InstancePropertyChange::X(value),
layer_id,
&info.instance_ids,
shared,
);
// Position Y
self.render_transform_field(
ui,
"Y:",
info.y,
1.0,
f64::NEG_INFINITY..=f64::INFINITY,
|value| InstancePropertyChange::Y(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Rotation
self.render_transform_field(
ui,
"Rotation:",
info.rotation,
1.0,
-360.0..=360.0,
|value| InstancePropertyChange::Rotation(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Scale X
self.render_transform_field(
ui,
"Scale X:",
info.scale_x,
0.01,
0.01..=100.0,
|value| InstancePropertyChange::ScaleX(value),
layer_id,
&info.instance_ids,
shared,
);
// Scale Y
self.render_transform_field(
ui,
"Scale Y:",
info.scale_y,
0.01,
0.01..=100.0,
|value| InstancePropertyChange::ScaleY(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Skew X
self.render_transform_field(
ui,
"Skew X:",
info.skew_x,
1.0,
-89.0..=89.0,
|value| InstancePropertyChange::SkewX(value),
layer_id,
&info.instance_ids,
shared,
);
// Skew Y
self.render_transform_field(
ui,
"Skew Y:",
info.skew_y,
1.0,
-89.0..=89.0,
|value| InstancePropertyChange::SkewY(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Opacity
self.render_transform_field(
ui,
"Opacity:",
info.opacity,
0.01,
0.0..=1.0,
|value| InstancePropertyChange::Opacity(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
});
}
/// Render a single transform property field with drag-to-adjust
fn render_transform_field<F>(
&self,
ui: &mut Ui,
label: &str,
value: Option<f64>,
speed: f64,
range: std::ops::RangeInclusive<f64>,
make_change: F,
layer_id: Uuid,
instance_ids: &[Uuid],
shared: &mut SharedPaneState,
) where
F: Fn(f64) -> InstancePropertyChange,
{
ui.horizontal(|ui| {
// Label with drag sense for drag-to-adjust
let label_response = ui.add(egui::Label::new(label).sense(Sense::drag()));
match value {
Some(mut v) => {
// Handle drag on label
if label_response.dragged() {
let delta = label_response.drag_delta().x as f64 * speed;
v = (v + delta).clamp(*range.start(), *range.end());
// Create action for each selected instance
for instance_id in instance_ids {
let action = SetInstancePropertiesAction::new(
layer_id,
*instance_id,
make_change(v),
);
shared.pending_actions.push(Box::new(action));
}
}
// DragValue widget
let response = ui.add(
DragValue::new(&mut v)
.speed(speed)
.range(range.clone()),
);
if response.changed() {
// Create action for each selected instance
for instance_id in instance_ids {
let action = SetInstancePropertiesAction::new(
layer_id,
*instance_id,
make_change(v),
);
shared.pending_actions.push(Box::new(action));
}
}
}
None => {
// Mixed values - show placeholder
ui.label("--");
}
}
});
}
/// Render shape properties section (fill/stroke)
fn render_shape_section(
&mut self,
ui: &mut Ui,
shared: &mut SharedPaneState,
info: &SelectionInfo,
) {
egui::CollapsingHeader::new("Shape")
.default_open(self.shape_section_open)
.show(ui, |ui| {
self.shape_section_open = true;
ui.add_space(4.0);
let layer_id = match info.layer_id {
Some(id) => id,
None => return,
};
// Fill color
ui.horizontal(|ui| {
ui.label("Fill:");
match info.fill_color {
Some(Some(color)) => {
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
color.r, color.g, color.b, color.a,
);
if ui.color_edit_button_srgba(&mut egui_color).changed() {
let new_color = Some(ShapeColor::new(
egui_color.r(),
egui_color.g(),
egui_color.b(),
egui_color.a(),
));
// Create action for each selected shape
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_fill_color(
layer_id,
*shape_id,
new_color,
);
shared.pending_actions.push(Box::new(action));
}
}
}
Some(None) => {
if ui.button("Add Fill").clicked() {
// Add default black fill
let default_fill = Some(ShapeColor::rgb(0, 0, 0));
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_fill_color(
layer_id,
*shape_id,
default_fill,
);
shared.pending_actions.push(Box::new(action));
}
}
}
None => {
ui.label("--");
}
}
});
// Stroke color
ui.horizontal(|ui| {
ui.label("Stroke:");
match info.stroke_color {
Some(Some(color)) => {
let mut egui_color = egui::Color32::from_rgba_unmultiplied(
color.r, color.g, color.b, color.a,
);
if ui.color_edit_button_srgba(&mut egui_color).changed() {
let new_color = Some(ShapeColor::new(
egui_color.r(),
egui_color.g(),
egui_color.b(),
egui_color.a(),
));
// Create action for each selected shape
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_stroke_color(
layer_id,
*shape_id,
new_color,
);
shared.pending_actions.push(Box::new(action));
}
}
}
Some(None) => {
if ui.button("Add Stroke").clicked() {
// Add default black stroke
let default_stroke = Some(ShapeColor::rgb(0, 0, 0));
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_stroke_color(
layer_id,
*shape_id,
default_stroke,
);
shared.pending_actions.push(Box::new(action));
}
}
}
None => {
ui.label("--");
}
}
});
// Stroke width
ui.horizontal(|ui| {
ui.label("Stroke Width:");
match info.stroke_width {
Some(mut width) => {
let response = ui.add(
DragValue::new(&mut width)
.speed(0.1)
.range(0.1..=100.0),
);
if response.changed() {
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_stroke_width(
layer_id,
*shape_id,
width,
);
shared.pending_actions.push(Box::new(action));
}
}
}
None => {
ui.label("--");
}
}
});
ui.add_space(4.0);
});
}
/// Render document settings section (shown when nothing is selected)
fn render_document_section(&self, ui: &mut Ui, shared: &mut SharedPaneState) {
egui::CollapsingHeader::new("Document")
.default_open(true)
.show(ui, |ui| {
ui.add_space(4.0);
let document = shared.action_executor.document();
// Get current values for editing
let mut width = document.width;
let mut height = document.height;
let mut duration = document.duration;
let mut framerate = document.framerate;
let layer_count = document.root.children.len();
// Canvas width
ui.horizontal(|ui| {
ui.label("Width:");
if ui
.add(DragValue::new(&mut width).speed(1.0).range(1.0..=10000.0))
.changed()
{
let action = SetDocumentPropertiesAction::set_width(width);
shared.pending_actions.push(Box::new(action));
}
});
// Canvas height
ui.horizontal(|ui| {
ui.label("Height:");
if ui
.add(DragValue::new(&mut height).speed(1.0).range(1.0..=10000.0))
.changed()
{
let action = SetDocumentPropertiesAction::set_height(height);
shared.pending_actions.push(Box::new(action));
}
});
// Duration
ui.horizontal(|ui| {
ui.label("Duration:");
if ui
.add(
DragValue::new(&mut duration)
.speed(0.1)
.range(0.1..=3600.0)
.suffix("s"),
)
.changed()
{
let action = SetDocumentPropertiesAction::set_duration(duration);
shared.pending_actions.push(Box::new(action));
}
});
// Framerate
ui.horizontal(|ui| {
ui.label("Framerate:");
if ui
.add(
DragValue::new(&mut framerate)
.speed(1.0)
.range(1.0..=120.0)
.suffix(" fps"),
)
.changed()
{
let action = SetDocumentPropertiesAction::set_framerate(framerate);
shared.pending_actions.push(Box::new(action));
}
});
// Layer count (read-only)
ui.horizontal(|ui| {
ui.label("Layers:");
ui.label(format!("{}", layer_count));
});
ui.add_space(4.0);
});
} }
} }
@ -20,23 +756,61 @@ impl PaneRenderer for InfopanelPane {
ui: &mut egui::Ui, ui: &mut egui::Ui,
rect: egui::Rect, rect: egui::Rect,
_path: &NodePath, _path: &NodePath,
_shared: &mut SharedPaneState, shared: &mut SharedPaneState,
) { ) {
// Placeholder rendering // Background
ui.painter().rect_filled( ui.painter().rect_filled(
rect, rect,
0.0, 0.0,
egui::Color32::from_rgb(30, 50, 40), egui::Color32::from_rgb(30, 35, 40),
); );
let text = "Info Panel\n(TODO: Implement property editor)"; // Create scrollable area for content
ui.painter().text( let content_rect = rect.shrink(8.0);
rect.center(), let mut content_ui = ui.new_child(
egui::Align2::CENTER_CENTER, egui::UiBuilder::new()
text, .max_rect(content_rect)
egui::FontId::proportional(16.0), .layout(egui::Layout::top_down(egui::Align::LEFT)),
egui::Color32::from_gray(150),
); );
egui::ScrollArea::vertical()
.id_salt("infopanel_scroll")
.show(&mut content_ui, |ui| {
ui.set_min_width(content_rect.width() - 16.0);
// 1. Tool options section (always shown if tool has options)
self.render_tool_section(ui, shared);
// 2. Gather selection info
let info = self.gather_selection_info(shared);
// 3. Transform section (if shapes selected)
if info.shape_count > 0 {
self.render_transform_section(ui, shared, &info);
}
// 4. Shape properties section (if shapes selected)
if info.shape_count > 0 {
self.render_shape_section(ui, shared, &info);
}
// 5. Document settings (if nothing selected)
if info.is_empty {
self.render_document_section(ui, shared);
}
// Show selection count at bottom
if info.shape_count > 0 {
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
ui.label(format!(
"{} object{} selected",
info.shape_count,
if info.shape_count == 1 { "" } else { "s" }
));
}
});
} }
fn name(&self) -> &str { fn name(&self) -> &str {

View File

@ -113,6 +113,15 @@ pub struct SharedPaneState<'a> {
pub is_playing: &'a mut bool, // Whether playback is currently active pub is_playing: &'a mut bool, // Whether playback is currently active
/// Asset being dragged from Asset Library (for cross-pane drag-and-drop) /// Asset being dragged from Asset Library (for cross-pane drag-and-drop)
pub dragging_asset: &'a mut Option<DraggingAsset>, pub dragging_asset: &'a mut Option<DraggingAsset>,
// Tool-specific options for infopanel
/// Stroke width for drawing tools (Draw, Rectangle, Ellipse, Line, Polygon)
pub stroke_width: &'a mut f64,
/// Whether to fill shapes when drawing (Rectangle, Ellipse, Polygon)
pub fill_enabled: &'a mut bool,
/// Fill gap tolerance for paint bucket tool
pub paint_bucket_gap_tolerance: &'a mut f64,
/// Number of sides for polygon tool
pub polygon_sides: &'a mut u32,
} }
/// Trait for pane rendering /// Trait for pane rendering

View File

@ -231,13 +231,14 @@ struct VelloCallback {
pan_offset: egui::Vec2, pan_offset: egui::Vec2,
zoom: f32, zoom: f32,
instance_id: u64, instance_id: u64,
document: lightningbeam_core::document::Document, document: std::sync::Arc<lightningbeam_core::document::Document>,
tool_state: lightningbeam_core::tool::ToolState, tool_state: lightningbeam_core::tool::ToolState,
active_layer_id: Option<uuid::Uuid>, active_layer_id: Option<uuid::Uuid>,
drag_delta: Option<vello::kurbo::Vec2>, // Delta for drag preview (world space) drag_delta: Option<vello::kurbo::Vec2>, // Delta for drag preview (world space)
selection: lightningbeam_core::selection::Selection, selection: lightningbeam_core::selection::Selection,
fill_color: egui::Color32, // Current fill color for previews fill_color: egui::Color32, // Current fill color for previews
stroke_color: egui::Color32, // Current stroke color for previews stroke_color: egui::Color32, // Current stroke color for previews
stroke_width: f64, // Current stroke width for previews
selected_tool: lightningbeam_core::tool::Tool, // Current tool for rendering mode-specific UI selected_tool: lightningbeam_core::tool::Tool, // Current tool for rendering mode-specific UI
eyedropper_request: Option<(egui::Pos2, super::ColorMode)>, // Pending eyedropper sample eyedropper_request: Option<(egui::Pos2, super::ColorMode)>, // Pending eyedropper sample
playback_time: f64, // Current playback time for animation evaluation playback_time: f64, // Current playback time for animation evaluation
@ -249,18 +250,19 @@ impl VelloCallback {
pan_offset: egui::Vec2, pan_offset: egui::Vec2,
zoom: f32, zoom: f32,
instance_id: u64, instance_id: u64,
document: lightningbeam_core::document::Document, document: std::sync::Arc<lightningbeam_core::document::Document>,
tool_state: lightningbeam_core::tool::ToolState, tool_state: lightningbeam_core::tool::ToolState,
active_layer_id: Option<uuid::Uuid>, active_layer_id: Option<uuid::Uuid>,
drag_delta: Option<vello::kurbo::Vec2>, drag_delta: Option<vello::kurbo::Vec2>,
selection: lightningbeam_core::selection::Selection, selection: lightningbeam_core::selection::Selection,
fill_color: egui::Color32, fill_color: egui::Color32,
stroke_color: egui::Color32, stroke_color: egui::Color32,
stroke_width: f64,
selected_tool: lightningbeam_core::tool::Tool, selected_tool: lightningbeam_core::tool::Tool,
eyedropper_request: Option<(egui::Pos2, super::ColorMode)>, eyedropper_request: Option<(egui::Pos2, super::ColorMode)>,
playback_time: f64, playback_time: f64,
) -> Self { ) -> Self {
Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection, fill_color, stroke_color, selected_tool, eyedropper_request, playback_time } Self { rect, pan_offset, zoom, instance_id, document, tool_state, active_layer_id, drag_delta, selection, fill_color, stroke_color, stroke_width, selected_tool, eyedropper_request, playback_time }
} }
} }
@ -331,14 +333,44 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
// Render each object at its preview position (original + delta) // Render each object at its preview position (original + delta)
for (object_id, original_pos) in original_positions { for (object_id, original_pos) in original_positions {
// Try shape instance first // Try shape instance first
if let Some(_object) = vector_layer.get_object(object_id) { if let Some(object) = vector_layer.get_object(object_id) {
if let Some(shape) = vector_layer.get_shape(&_object.shape_id) { if let Some(shape) = vector_layer.get_shape(&object.shape_id) {
// New position = original + delta // New position = original + delta
let new_x = original_pos.x + delta.x; let new_x = original_pos.x + delta.x;
let new_y = original_pos.y + delta.y; let new_y = original_pos.y + delta.y;
// Build transform for preview position // Build skew transform around shape center (matching renderer.rs)
let object_transform = Affine::translate((new_x, new_y)); let path = shape.path();
let skew_transform = if object.transform.skew_x != 0.0 || object.transform.skew_y != 0.0 {
let bbox = path.bounding_box();
let center_x = (bbox.x0 + bbox.x1) / 2.0;
let center_y = (bbox.y0 + bbox.y1) / 2.0;
let skew_x_affine = if object.transform.skew_x != 0.0 {
Affine::skew(object.transform.skew_x.to_radians().tan(), 0.0)
} else {
Affine::IDENTITY
};
let skew_y_affine = if object.transform.skew_y != 0.0 {
Affine::skew(0.0, object.transform.skew_y.to_radians().tan())
} else {
Affine::IDENTITY
};
Affine::translate((center_x, center_y))
* skew_x_affine
* skew_y_affine
* Affine::translate((-center_x, -center_y))
} else {
Affine::IDENTITY
};
// Build full transform: translate * rotate * scale * skew
let object_transform = Affine::translate((new_x, new_y))
* Affine::rotate(object.transform.rotation.to_radians())
* Affine::scale_non_uniform(object.transform.scale_x, object.transform.scale_y)
* skew_transform;
let combined_transform = camera_transform * object_transform; let combined_transform = camera_transform * object_transform;
// Render shape with semi-transparent fill (light blue, 40% opacity) // Render shape with semi-transparent fill (light blue, 40% opacity)
@ -348,7 +380,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
combined_transform, combined_transform,
&Brush::Solid(alpha_color), &Brush::Solid(alpha_color),
None, None,
shape.path(), path,
); );
} }
} }
@ -783,8 +815,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
preview_path.line_to(*point); preview_path.line_to(*point);
} }
// Draw the preview path with stroke // Draw the preview path with stroke using configured stroke width
let stroke_width = (2.0 / self.zoom.max(0.5) as f64).max(1.0);
let stroke_color = Color::from_rgb8( let stroke_color = Color::from_rgb8(
self.stroke_color.r(), self.stroke_color.r(),
self.stroke_color.g(), self.stroke_color.g(),
@ -792,7 +823,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
); );
scene.stroke( scene.stroke(
&Stroke::new(stroke_width), &Stroke::new(self.stroke_width),
camera_transform, camera_transform,
stroke_color, stroke_color,
None, None,
@ -1636,8 +1667,8 @@ impl StagePane {
// Mouse up: create the rectangle shape // Mouse up: create the rectangle shape
if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::CreatingRectangle { .. })) { if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::CreatingRectangle { .. })) {
if let ToolState::CreatingRectangle { start_point, current_point, centered, constrain_square } = shared.tool_state.clone() { if let ToolState::CreatingRectangle { start_point, current_point, centered, constrain_square } = shared.tool_state.clone() {
// Calculate rectangle bounds based on mode // Calculate rectangle bounds and center position based on mode
let (width, height, position) = if centered { let (width, height, center) = if centered {
// Centered mode: start_point is center // Centered mode: start_point is center
let dx = current_point.x - start_point.x; let dx = current_point.x - start_point.x;
let dy = current_point.y - start_point.y; let dy = current_point.y - start_point.y;
@ -1649,8 +1680,8 @@ impl StagePane {
(dx.abs() * 2.0, dy.abs() * 2.0) (dx.abs() * 2.0, dy.abs() * 2.0)
}; };
let pos = Point::new(start_point.x - w / 2.0, start_point.y - h / 2.0); // start_point is already the center
(w, h, pos) (w, h, start_point)
} else { } else {
// Corner mode: start_point is corner // Corner mode: start_point is corner
let mut min_x = start_point.x.min(current_point.x); let mut min_x = start_point.x.min(current_point.x);
@ -1676,21 +1707,35 @@ impl StagePane {
} }
} }
(max_x - min_x, max_y - min_y, Point::new(min_x, min_y)) // Return width, height, and center position
let center_x = (min_x + max_x) / 2.0;
let center_y = (min_y + max_y) / 2.0;
(max_x - min_x, max_y - min_y, Point::new(center_x, center_y))
}; };
// Only create shape if rectangle has non-zero size // Only create shape if rectangle has non-zero size
if width > 1.0 && height > 1.0 { if width > 1.0 && height > 1.0 {
use lightningbeam_core::shape::{Shape, ShapeColor}; use lightningbeam_core::shape::{Shape, ShapeColor, StrokeStyle};
use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::object::ShapeInstance;
use lightningbeam_core::actions::AddShapeAction; use lightningbeam_core::actions::AddShapeAction;
// Create shape with rectangle path (built from lines) // Create shape with rectangle path centered at origin
let path = Self::create_rectangle_path(width, height); let path = Self::create_rectangle_path(width, height);
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color)); let mut shape = Shape::new(path);
// Create object at the calculated position // Apply fill if enabled
let object = ShapeInstance::new(shape.id).with_position(position.x, position.y); if *shared.fill_enabled {
shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color));
}
// Apply stroke with configured width
shape = shape.with_stroke(
ShapeColor::from_egui(*shared.stroke_color),
StrokeStyle { width: *shared.stroke_width, ..Default::default() }
);
// Create object at the center position
let object = ShapeInstance::new(shape.id).with_position(center.x, center.y);
// Create and execute action immediately // Create and execute action immediately
let action = AddShapeAction::new(active_layer_id, shape, object); let action = AddShapeAction::new(active_layer_id, shape, object);
@ -1798,13 +1843,24 @@ impl StagePane {
// Only create shape if ellipse has non-zero size // Only create shape if ellipse has non-zero size
if rx > 1.0 && ry > 1.0 { if rx > 1.0 && ry > 1.0 {
use lightningbeam_core::shape::{Shape, ShapeColor}; use lightningbeam_core::shape::{Shape, ShapeColor, StrokeStyle};
use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::object::ShapeInstance;
use lightningbeam_core::actions::AddShapeAction; use lightningbeam_core::actions::AddShapeAction;
// Create shape with ellipse path (built from bezier curves) // Create shape with ellipse path (built from bezier curves)
let path = Self::create_ellipse_path(rx, ry); let path = Self::create_ellipse_path(rx, ry);
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color)); let mut shape = Shape::new(path);
// Apply fill if enabled
if *shared.fill_enabled {
shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color));
}
// Apply stroke with configured width
shape = shape.with_stroke(
ShapeColor::from_egui(*shared.stroke_color),
StrokeStyle { width: *shared.stroke_width, ..Default::default() }
);
// Create object at the calculated position // Create object at the calculated position
let object = ShapeInstance::new(shape.id).with_position(position.x, position.y); let object = ShapeInstance::new(shape.id).with_position(position.x, position.y);
@ -1883,7 +1939,7 @@ impl StagePane {
use lightningbeam_core::object::ShapeInstance; use lightningbeam_core::object::ShapeInstance;
use lightningbeam_core::actions::AddShapeAction; use lightningbeam_core::actions::AddShapeAction;
// Create shape with line path // Create shape with line path centered at origin
let path = Self::create_line_path(dx, dy); let path = Self::create_line_path(dx, dy);
// Lines should have stroke by default, not fill // Lines should have stroke by default, not fill
@ -1891,13 +1947,15 @@ impl StagePane {
.with_stroke( .with_stroke(
ShapeColor::from_egui(*shared.stroke_color), ShapeColor::from_egui(*shared.stroke_color),
StrokeStyle { StrokeStyle {
width: 2.0, width: *shared.stroke_width,
..Default::default() ..Default::default()
} }
); );
// Create object at the start point // Create object at the center of the line
let object = ShapeInstance::new(shape.id).with_position(start_point.x, start_point.y); let center_x = (start_point.x + current_point.x) / 2.0;
let center_y = (start_point.y + current_point.y) / 2.0;
let object = ShapeInstance::new(shape.id).with_position(center_x, center_y);
// Create and execute action immediately // Create and execute action immediately
let action = AddShapeAction::new(active_layer_id, shape, object); let action = AddShapeAction::new(active_layer_id, shape, object);
@ -1977,7 +2035,15 @@ impl StagePane {
// Create shape with polygon path // Create shape with polygon path
let path = Self::create_polygon_path(num_sides, radius); let path = Self::create_polygon_path(num_sides, radius);
let shape = Shape::new(path).with_fill(ShapeColor::from_egui(*shared.fill_color)); use lightningbeam_core::shape::StrokeStyle;
let mut shape = Shape::new(path);
if *shared.fill_enabled {
shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color));
}
shape = shape.with_stroke(
ShapeColor::from_egui(*shared.stroke_color),
StrokeStyle { width: *shared.stroke_width, ..Default::default() }
);
// Create object at the center point // Create object at the center point
let object = ShapeInstance::new(shape.id).with_position(center.x, center.y); let object = ShapeInstance::new(shape.id).with_position(center.x, center.y);
@ -2006,23 +2072,26 @@ impl StagePane {
} }
} }
/// Create a rectangle path from lines (easier for curve editing later) /// Create a rectangle path centered at origin (easier for curve editing later)
fn create_rectangle_path(width: f64, height: f64) -> vello::kurbo::BezPath { fn create_rectangle_path(width: f64, height: f64) -> vello::kurbo::BezPath {
use vello::kurbo::{BezPath, Point}; use vello::kurbo::{BezPath, Point};
let half_w = width / 2.0;
let half_h = height / 2.0;
let mut path = BezPath::new(); let mut path = BezPath::new();
// Start at top-left // Start at top-left (centered at origin)
path.move_to(Point::new(0.0, 0.0)); path.move_to(Point::new(-half_w, -half_h));
// Top-right // Top-right
path.line_to(Point::new(width, 0.0)); path.line_to(Point::new(half_w, -half_h));
// Bottom-right // Bottom-right
path.line_to(Point::new(width, height)); path.line_to(Point::new(half_w, half_h));
// Bottom-left // Bottom-left
path.line_to(Point::new(0.0, height)); path.line_to(Point::new(-half_w, half_h));
// Close path (back to top-left) // Close path (back to top-left)
path.close_path(); path.close_path();
@ -2080,17 +2149,18 @@ impl StagePane {
path path
} }
/// Create a line path from start to end point /// Create a line path centered at origin
fn create_line_path(dx: f64, dy: f64) -> vello::kurbo::BezPath { fn create_line_path(dx: f64, dy: f64) -> vello::kurbo::BezPath {
use vello::kurbo::{BezPath, Point}; use vello::kurbo::{BezPath, Point};
let mut path = BezPath::new(); let mut path = BezPath::new();
// Start at origin (object position will be the start point) // Line goes from -half to +half so it's centered at origin
path.move_to(Point::new(0.0, 0.0)); let half_dx = dx / 2.0;
let half_dy = dy / 2.0;
// Line to end point path.move_to(Point::new(-half_dx, -half_dy));
path.line_to(Point::new(dx, dy)); path.line_to(Point::new(half_dx, half_dy));
path path
} }
@ -2238,26 +2308,29 @@ impl StagePane {
// Only create shape if path is not empty // Only create shape if path is not empty
if !path.is_empty() { if !path.is_empty() {
// Calculate bounding box to position the object // Calculate bounding box center for object position
let bbox = path.bounding_box(); let bbox = path.bounding_box();
let position = Point::new(bbox.x0, bbox.y0); let center_x = (bbox.x0 + bbox.x1) / 2.0;
let center_y = (bbox.y0 + bbox.y1) / 2.0;
// Translate path to be relative to position (0,0 at top-left of bbox) // Translate path so its center is at origin (0,0)
use vello::kurbo::Affine; use vello::kurbo::Affine;
let transform = Affine::translate((-bbox.x0, -bbox.y0)); let transform = Affine::translate((-center_x, -center_y));
let translated_path = transform * path; let translated_path = transform * path;
// Create shape with both fill and stroke // Create shape with fill (if enabled) and stroke
use lightningbeam_core::shape::StrokeStyle; use lightningbeam_core::shape::StrokeStyle;
let shape = Shape::new(translated_path) let mut shape = Shape::new(translated_path);
.with_fill(ShapeColor::from_egui(*shared.fill_color)) if *shared.fill_enabled {
.with_stroke( shape = shape.with_fill(ShapeColor::from_egui(*shared.fill_color));
}
shape = shape.with_stroke(
ShapeColor::from_egui(*shared.stroke_color), ShapeColor::from_egui(*shared.stroke_color),
StrokeStyle::default(), StrokeStyle { width: *shared.stroke_width, ..Default::default() }
); );
// Create object at the calculated position // Create object at the center position
let object = ShapeInstance::new(shape.id).with_position(position.x, position.y); let object = ShapeInstance::new(shape.id).with_position(center_x, center_y);
// Create and execute action immediately // Create and execute action immediately
let action = AddShapeAction::new(active_layer_id, shape, object); let action = AddShapeAction::new(active_layer_id, shape, object);
@ -4257,18 +4330,20 @@ impl PaneRenderer for StagePane {
}; };
// Use egui's custom painting callback for Vello // Use egui's custom painting callback for Vello
// document_arc() returns Arc<Document> - cheap pointer copy, not deep clone
let callback = VelloCallback::new( let callback = VelloCallback::new(
rect, rect,
self.pan_offset, self.pan_offset,
self.zoom, self.zoom,
self.instance_id, self.instance_id,
shared.action_executor.document().clone(), shared.action_executor.document_arc(),
shared.tool_state.clone(), shared.tool_state.clone(),
*shared.active_layer_id, *shared.active_layer_id,
drag_delta, drag_delta,
shared.selection.clone(), shared.selection.clone(),
*shared.fill_color, *shared.fill_color,
*shared.stroke_color, *shared.stroke_color,
*shared.stroke_width,
*shared.selected_tool, *shared.selected_tool,
self.pending_eyedropper_sample, self.pending_eyedropper_sample,
*shared.playback_time, *shared.playback_time,