543 lines
19 KiB
Rust
543 lines
19 KiB
Rust
//! Action system for undo/redo functionality
|
|
//!
|
|
//! This module provides a type-safe action system that ensures document
|
|
//! mutations can only happen through actions, enforced by Rust's type system.
|
|
//!
|
|
//! ## Architecture
|
|
//!
|
|
//! - `Action` trait: Defines execute() and rollback() operations
|
|
//! - `ActionExecutor`: Wraps the document and manages undo/redo stacks
|
|
//! - Document mutations are only accessible via `pub(crate)` methods
|
|
//! - External code gets read-only access via `ActionExecutor::document()`
|
|
//!
|
|
//! ## 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 std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use uuid::Uuid;
|
|
|
|
/// Backend clip instance ID - wraps both MIDI and Audio instance IDs
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum BackendClipInstanceId {
|
|
Midi(daw_backend::MidiClipInstanceId),
|
|
Audio(daw_backend::AudioClipInstanceId),
|
|
}
|
|
|
|
/// Backend context for actions that need to interact with external systems
|
|
///
|
|
/// This bundles all backend references (audio, future video) that actions
|
|
/// may need to synchronize state with external systems beyond the document.
|
|
pub struct BackendContext<'a> {
|
|
/// Audio engine controller (optional - may not be initialized)
|
|
pub audio_controller: Option<&'a mut daw_backend::EngineController>,
|
|
|
|
/// Mapping from document layer UUIDs to backend track IDs
|
|
pub layer_to_track_map: &'a HashMap<Uuid, daw_backend::TrackId>,
|
|
|
|
/// Mapping from document clip instance UUIDs to backend clip instance IDs
|
|
pub clip_instance_to_backend_map: &'a mut HashMap<Uuid, BackendClipInstanceId>,
|
|
|
|
/// Mapping from movie clip UUIDs to backend metatrack (group track) TrackIds
|
|
pub clip_to_metatrack_map: &'a HashMap<Uuid, daw_backend::TrackId>,
|
|
|
|
// Future: pub video_controller: Option<&'a mut VideoController>,
|
|
}
|
|
|
|
/// Action trait for undo/redo operations
|
|
///
|
|
/// Each action must be able to execute (apply changes) and rollback (undo changes).
|
|
/// Actions are stored in the undo stack and can be re-executed from the redo stack.
|
|
///
|
|
/// ## Backend Integration
|
|
///
|
|
/// Actions can optionally implement backend synchronization via `execute_backend()`
|
|
/// and `rollback_backend()`. Default implementations do nothing, so actions that
|
|
/// only affect the document (vector graphics) don't need to implement these.
|
|
pub trait Action: Send {
|
|
/// Apply this action to the document
|
|
///
|
|
/// Returns Ok(()) if successful, or Err(message) if the action cannot be performed
|
|
fn execute(&mut self, document: &mut Document) -> Result<(), String>;
|
|
|
|
/// Undo this action (rollback changes)
|
|
///
|
|
/// Returns Ok(()) if successful, or Err(message) if rollback fails
|
|
fn rollback(&mut self, document: &mut Document) -> Result<(), String>;
|
|
|
|
/// Get a human-readable description of this action (for UI display)
|
|
fn description(&self) -> String;
|
|
|
|
/// Execute backend operations after document changes
|
|
///
|
|
/// Called AFTER execute() succeeds. If this returns an error, execute()
|
|
/// will be automatically rolled back to maintain atomicity.
|
|
///
|
|
/// # Arguments
|
|
/// * `backend` - Backend context with audio/video controllers
|
|
/// * `document` - Read-only document access for looking up clip data
|
|
///
|
|
/// Default: No backend operations
|
|
fn execute_backend(&mut self, _backend: &mut BackendContext, _document: &Document) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Rollback backend operations during undo
|
|
///
|
|
/// Called BEFORE rollback() to undo backend changes in reverse order.
|
|
///
|
|
/// # Arguments
|
|
/// * `backend` - Backend context with audio/video controllers
|
|
/// * `document` - Read-only document access (if needed)
|
|
///
|
|
/// Default: No backend operations
|
|
fn rollback_backend(&mut self, _backend: &mut BackendContext, _document: &Document) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Return MIDI cache data reflecting the state after execute/redo.
|
|
/// Format: (clip_id, notes) where notes are (start_time, note, velocity, duration).
|
|
/// Used to keep the frontend MIDI event cache in sync after undo/redo.
|
|
fn midi_notes_after_execute(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> {
|
|
None
|
|
}
|
|
|
|
/// Return MIDI cache data reflecting the state after rollback/undo.
|
|
fn midi_notes_after_rollback(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Action executor that wraps the document and manages undo/redo
|
|
///
|
|
/// This is the only way to get mutable access to the document, ensuring
|
|
/// all mutations go through the action system.
|
|
///
|
|
/// 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 {
|
|
/// The document being edited (wrapped in Arc for cheap cloning)
|
|
document: Arc<Document>,
|
|
|
|
/// Stack of executed actions (for undo)
|
|
undo_stack: Vec<Box<dyn Action>>,
|
|
|
|
/// Stack of undone actions (for redo)
|
|
redo_stack: Vec<Box<dyn Action>>,
|
|
|
|
/// Maximum number of actions to keep in undo stack
|
|
max_undo_depth: usize,
|
|
|
|
/// Monotonically increasing counter, bumped on every `execute` call.
|
|
/// Used to detect whether any actions were taken during a region selection.
|
|
epoch: u64,
|
|
}
|
|
|
|
impl ActionExecutor {
|
|
/// Create a new action executor with the given document
|
|
pub fn new(mut document: Document) -> Self {
|
|
// Rebuild transient lookup maps (not serialized)
|
|
document.rebuild_layer_to_clip_map();
|
|
Self {
|
|
document: Arc::new(document),
|
|
undo_stack: Vec::new(),
|
|
redo_stack: Vec::new(),
|
|
max_undo_depth: 100, // Default: keep last 100 actions
|
|
epoch: 0,
|
|
}
|
|
}
|
|
|
|
/// Get read-only access to the document
|
|
///
|
|
/// This is the public API for reading document state.
|
|
/// Mutations must go through execute() which requires an Action.
|
|
pub fn document(&self) -> &Document {
|
|
&self.document
|
|
}
|
|
|
|
/// 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
|
|
///
|
|
/// 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
|
|
/// should go through the execute() method to support undo/redo.
|
|
pub fn document_mut(&mut self) -> &mut Document {
|
|
Arc::make_mut(&mut self.document)
|
|
}
|
|
|
|
/// Execute an action and add it to the undo stack
|
|
///
|
|
/// This clears the redo stack since we're creating a new timeline branch.
|
|
///
|
|
/// Returns Ok(()) if successful, or Err(message) if the action failed
|
|
pub fn execute(&mut self, mut action: Box<dyn Action>) -> Result<(), String> {
|
|
// Apply the action (uses copy-on-write if other Arc holders exist)
|
|
action.execute(Arc::make_mut(&mut self.document))?;
|
|
|
|
// Clear redo stack (new action invalidates redo history)
|
|
self.redo_stack.clear();
|
|
|
|
// Bump epoch so region selections can detect that an action occurred
|
|
self.epoch = self.epoch.wrapping_add(1);
|
|
|
|
// Add to undo stack
|
|
self.undo_stack.push(action);
|
|
|
|
// Limit undo stack size
|
|
if self.undo_stack.len() > self.max_undo_depth {
|
|
self.undo_stack.remove(0);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Undo the last action
|
|
///
|
|
/// Returns Ok(true) if an action was undone, Ok(false) if undo stack is empty,
|
|
/// or Err(message) if rollback failed
|
|
pub fn undo(&mut self) -> Result<bool, String> {
|
|
if let Some(mut action) = self.undo_stack.pop() {
|
|
// Rollback the action (uses copy-on-write if other Arc holders exist)
|
|
match action.rollback(Arc::make_mut(&mut self.document)) {
|
|
Ok(()) => {
|
|
// Move to redo stack
|
|
self.redo_stack.push(action);
|
|
Ok(true)
|
|
}
|
|
Err(e) => {
|
|
// Put action back on undo stack if rollback failed
|
|
self.undo_stack.push(action);
|
|
Err(e)
|
|
}
|
|
}
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
/// Redo the last undone action
|
|
///
|
|
/// Returns Ok(true) if an action was redone, Ok(false) if redo stack is empty,
|
|
/// or Err(message) if re-execution failed
|
|
pub fn redo(&mut self) -> Result<bool, String> {
|
|
if let Some(mut action) = self.redo_stack.pop() {
|
|
// Re-execute the action (uses copy-on-write if other Arc holders exist)
|
|
match action.execute(Arc::make_mut(&mut self.document)) {
|
|
Ok(()) => {
|
|
// Move back to undo stack
|
|
self.undo_stack.push(action);
|
|
Ok(true)
|
|
}
|
|
Err(e) => {
|
|
// Put action back on redo stack if re-execution failed
|
|
self.redo_stack.push(action);
|
|
Err(e)
|
|
}
|
|
}
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
/// Check if undo is available
|
|
pub fn can_undo(&self) -> bool {
|
|
!self.undo_stack.is_empty()
|
|
}
|
|
|
|
/// Check if redo is available
|
|
pub fn can_redo(&self) -> bool {
|
|
!self.redo_stack.is_empty()
|
|
}
|
|
|
|
/// Get the description of the next action to undo
|
|
pub fn undo_description(&self) -> Option<String> {
|
|
self.undo_stack.last().map(|a| a.description())
|
|
}
|
|
|
|
/// Get MIDI cache data from the last action on the undo stack (after redo).
|
|
/// Returns the notes reflecting execute state.
|
|
pub fn last_undo_midi_notes(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> {
|
|
self.undo_stack.last().and_then(|a| a.midi_notes_after_execute())
|
|
}
|
|
|
|
/// Get MIDI cache data from the last action on the redo stack (after undo).
|
|
/// Returns the notes reflecting rollback state.
|
|
pub fn last_redo_midi_notes(&self) -> Option<(u32, &[(f64, u8, u8, f64)])> {
|
|
self.redo_stack.last().and_then(|a| a.midi_notes_after_rollback())
|
|
}
|
|
|
|
/// Get the description of the next action to redo
|
|
pub fn redo_description(&self) -> Option<String> {
|
|
self.redo_stack.last().map(|a| a.description())
|
|
}
|
|
|
|
/// Get the number of actions in the undo stack
|
|
pub fn undo_depth(&self) -> usize {
|
|
self.undo_stack.len()
|
|
}
|
|
|
|
/// Get the number of actions in the redo stack
|
|
pub fn redo_depth(&self) -> usize {
|
|
self.redo_stack.len()
|
|
}
|
|
|
|
/// Return the current action epoch.
|
|
///
|
|
/// The epoch is a monotonically increasing counter that is bumped every
|
|
/// time `execute` is called. It is never decremented on undo/redo, so
|
|
/// callers can record it at a point in time and later compare to detect
|
|
/// whether any action was executed in the interim.
|
|
pub fn epoch(&self) -> u64 {
|
|
self.epoch
|
|
}
|
|
|
|
/// Clear all undo/redo history
|
|
pub fn clear_history(&mut self) {
|
|
self.undo_stack.clear();
|
|
self.redo_stack.clear();
|
|
}
|
|
|
|
/// Set the maximum undo depth
|
|
pub fn set_max_undo_depth(&mut self, depth: usize) {
|
|
self.max_undo_depth = depth;
|
|
|
|
// Trim undo stack if needed
|
|
if self.undo_stack.len() > depth {
|
|
let remove_count = self.undo_stack.len() - depth;
|
|
self.undo_stack.drain(0..remove_count);
|
|
}
|
|
}
|
|
|
|
/// Execute an action with backend synchronization
|
|
///
|
|
/// This performs atomic execution: if backend operations fail, the document
|
|
/// changes are automatically rolled back to maintain consistency.
|
|
///
|
|
/// # Arguments
|
|
/// * `action` - The action to execute
|
|
/// * `backend` - Backend context for audio/video operations
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(())` if both document and backend operations succeeded
|
|
/// * `Err(msg)` if backend failed (document changes are rolled back)
|
|
pub fn execute_with_backend(
|
|
&mut self,
|
|
mut action: Box<dyn Action>,
|
|
backend: &mut BackendContext,
|
|
) -> Result<(), String> {
|
|
// 1. Execute document changes
|
|
action.execute(Arc::make_mut(&mut self.document))?;
|
|
|
|
// 2. Execute backend changes (pass document for reading clip data)
|
|
if let Err(e) = action.execute_backend(backend, &self.document) {
|
|
// ATOMIC ROLLBACK: Backend failed → undo document
|
|
action.rollback(Arc::make_mut(&mut self.document))?;
|
|
return Err(e);
|
|
}
|
|
|
|
// 3. Push to undo stack (both succeeded)
|
|
self.redo_stack.clear();
|
|
self.epoch = self.epoch.wrapping_add(1);
|
|
self.undo_stack.push(action);
|
|
|
|
// Limit undo stack size
|
|
if self.undo_stack.len() > self.max_undo_depth {
|
|
self.undo_stack.remove(0);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Undo the last action with backend synchronization
|
|
///
|
|
/// Rollback happens in reverse order: backend first, then document.
|
|
///
|
|
/// # Arguments
|
|
/// * `backend` - Backend context for audio/video operations
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(true)` if an action was undone
|
|
/// * `Ok(false)` if undo stack is empty
|
|
/// * `Err(msg)` if backend rollback failed
|
|
pub fn undo_with_backend(&mut self, backend: &mut BackendContext) -> Result<bool, String> {
|
|
if let Some(mut action) = self.undo_stack.pop() {
|
|
// Rollback in REVERSE order: backend first, then document
|
|
action.rollback_backend(backend, &self.document)?;
|
|
action.rollback(Arc::make_mut(&mut self.document))?;
|
|
|
|
// Move to redo stack
|
|
self.redo_stack.push(action);
|
|
|
|
Ok(true)
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
/// Redo the last undone action with backend synchronization
|
|
///
|
|
/// Re-execution happens in normal order: document first, then backend.
|
|
///
|
|
/// # Arguments
|
|
/// * `backend` - Backend context for audio/video operations
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(true)` if an action was redone
|
|
/// * `Ok(false)` if redo stack is empty
|
|
/// * `Err(msg)` if backend execution failed
|
|
pub fn redo_with_backend(&mut self, backend: &mut BackendContext) -> Result<bool, String> {
|
|
if let Some(mut action) = self.redo_stack.pop() {
|
|
// Re-execute in same order: document first, then backend
|
|
if let Err(e) = action.execute(Arc::make_mut(&mut self.document)) {
|
|
// Put action back on redo stack if document execute fails
|
|
self.redo_stack.push(action);
|
|
return Err(e);
|
|
}
|
|
|
|
if let Err(e) = action.execute_backend(backend, &self.document) {
|
|
// Rollback document if backend fails
|
|
action.rollback(Arc::make_mut(&mut self.document))?;
|
|
// Put action back on redo stack
|
|
self.redo_stack.push(action);
|
|
return Err(e);
|
|
}
|
|
|
|
// Move back to undo stack
|
|
self.undo_stack.push(action);
|
|
|
|
Ok(true)
|
|
} else {
|
|
Ok(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Test action that just tracks execute/rollback calls
|
|
struct TestAction {
|
|
description: String,
|
|
executed: bool,
|
|
}
|
|
|
|
impl TestAction {
|
|
fn new(description: &str) -> Self {
|
|
Self {
|
|
description: description.to_string(),
|
|
executed: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Action for TestAction {
|
|
fn execute(&mut self, _document: &mut Document) -> Result<(), String> {
|
|
self.executed = true;
|
|
Ok(())
|
|
}
|
|
|
|
fn rollback(&mut self, _document: &mut Document) -> Result<(), String> {
|
|
self.executed = false;
|
|
Ok(())
|
|
}
|
|
|
|
fn description(&self) -> String {
|
|
self.description.clone()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_executor_basic() {
|
|
let document = Document::new("Test");
|
|
let mut executor = ActionExecutor::new(document);
|
|
|
|
assert!(!executor.can_undo());
|
|
assert!(!executor.can_redo());
|
|
|
|
// Execute an action
|
|
let action = Box::new(TestAction::new("Test Action"));
|
|
executor.execute(action).unwrap();
|
|
|
|
assert!(executor.can_undo());
|
|
assert!(!executor.can_redo());
|
|
assert_eq!(executor.undo_depth(), 1);
|
|
|
|
// Undo
|
|
assert!(executor.undo().unwrap());
|
|
assert!(!executor.can_undo());
|
|
assert!(executor.can_redo());
|
|
assert_eq!(executor.redo_depth(), 1);
|
|
|
|
// Redo
|
|
assert!(executor.redo().unwrap());
|
|
assert!(executor.can_undo());
|
|
assert!(!executor.can_redo());
|
|
}
|
|
|
|
#[test]
|
|
fn test_action_descriptions() {
|
|
let document = Document::new("Test");
|
|
let mut executor = ActionExecutor::new(document);
|
|
|
|
executor.execute(Box::new(TestAction::new("Action 1"))).unwrap();
|
|
executor.execute(Box::new(TestAction::new("Action 2"))).unwrap();
|
|
|
|
assert_eq!(executor.undo_description(), Some("Action 2".to_string()));
|
|
|
|
executor.undo().unwrap();
|
|
assert_eq!(executor.redo_description(), Some("Action 2".to_string()));
|
|
assert_eq!(executor.undo_description(), Some("Action 1".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_action_clears_redo() {
|
|
let document = Document::new("Test");
|
|
let mut executor = ActionExecutor::new(document);
|
|
|
|
executor.execute(Box::new(TestAction::new("Action 1"))).unwrap();
|
|
executor.execute(Box::new(TestAction::new("Action 2"))).unwrap();
|
|
executor.undo().unwrap();
|
|
|
|
assert!(executor.can_redo());
|
|
|
|
// Execute new action should clear redo stack
|
|
executor.execute(Box::new(TestAction::new("Action 3"))).unwrap();
|
|
|
|
assert!(!executor.can_redo());
|
|
assert_eq!(executor.undo_depth(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_max_undo_depth() {
|
|
let document = Document::new("Test");
|
|
let mut executor = ActionExecutor::new(document);
|
|
executor.set_max_undo_depth(3);
|
|
|
|
executor.execute(Box::new(TestAction::new("Action 1"))).unwrap();
|
|
executor.execute(Box::new(TestAction::new("Action 2"))).unwrap();
|
|
executor.execute(Box::new(TestAction::new("Action 3"))).unwrap();
|
|
executor.execute(Box::new(TestAction::new("Action 4"))).unwrap();
|
|
|
|
// Should only keep last 3
|
|
assert_eq!(executor.undo_depth(), 3);
|
|
assert_eq!(executor.undo_description(), Some("Action 4".to_string()));
|
|
}
|
|
}
|