Add info panel
This commit is contained in:
parent
4d1e052ee7
commit
c943f7bfe6
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue