Skip to main content

oximedia_edit/
edit_context.rs

1#![allow(dead_code)]
2//! Edit context management for scoped editing operations.
3
4/// Scope of an edit operation.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum EditScope {
7    /// Affects only the targeted clip segment.
8    Local,
9    /// Affects all clips on the same track.
10    Track,
11    /// Affects all tracks in the timeline.
12    Global,
13}
14
15impl EditScope {
16    /// Returns true if this scope causes downstream clips to shift.
17    #[must_use]
18    pub fn affects_downstream(&self) -> bool {
19        matches!(self, EditScope::Track | EditScope::Global)
20    }
21
22    /// Human-readable label for the scope.
23    #[must_use]
24    pub fn label(&self) -> &'static str {
25        match self {
26            EditScope::Local => "Local",
27            EditScope::Track => "Track",
28            EditScope::Global => "Global",
29        }
30    }
31}
32
33/// A single editing context capturing scope, description, and whether it is reversible.
34#[derive(Debug, Clone)]
35pub struct EditContext {
36    /// Unique identifier for this context.
37    pub id: u64,
38    /// Human-readable description (e.g. "Trim clip end").
39    pub description: String,
40    /// Scope of the context.
41    pub scope: EditScope,
42    /// Whether this context can be undone.
43    pub reversible: bool,
44    /// Epoch timestamp (ms) when the context was created.
45    pub created_at_ms: u64,
46}
47
48impl EditContext {
49    /// Creates a new edit context.
50    pub fn new(
51        id: u64,
52        description: impl Into<String>,
53        scope: EditScope,
54        reversible: bool,
55        created_at_ms: u64,
56    ) -> Self {
57        Self {
58            id,
59            description: description.into(),
60            scope,
61            reversible,
62            created_at_ms,
63        }
64    }
65
66    /// Returns `true` if an undo entry exists for this context.
67    #[must_use]
68    pub fn has_undo(&self) -> bool {
69        self.reversible
70    }
71}
72
73/// Manages a stack of edit contexts, supporting push/pop operations.
74#[derive(Debug, Default)]
75pub struct EditContextManager {
76    stack: Vec<EditContext>,
77    next_id: u64,
78}
79
80impl EditContextManager {
81    /// Creates a new, empty manager.
82    #[must_use]
83    pub fn new() -> Self {
84        Self {
85            stack: Vec::new(),
86            next_id: 1,
87        }
88    }
89
90    /// Pushes a new context onto the stack, returning its assigned id.
91    pub fn push_context(
92        &mut self,
93        description: impl Into<String>,
94        scope: EditScope,
95        reversible: bool,
96        timestamp_ms: u64,
97    ) -> u64 {
98        let id = self.next_id;
99        self.next_id += 1;
100        self.stack.push(EditContext::new(
101            id,
102            description,
103            scope,
104            reversible,
105            timestamp_ms,
106        ));
107        id
108    }
109
110    /// Pops the most recent context from the stack.
111    pub fn pop_context(&mut self) -> Option<EditContext> {
112        self.stack.pop()
113    }
114
115    /// Returns a reference to the current (topmost) context, if any.
116    #[must_use]
117    pub fn current(&self) -> Option<&EditContext> {
118        self.stack.last()
119    }
120
121    /// Number of contexts currently on the stack.
122    #[must_use]
123    pub fn depth(&self) -> usize {
124        self.stack.len()
125    }
126
127    /// Returns all reversible contexts, most recent first.
128    #[must_use]
129    pub fn reversible_contexts(&self) -> Vec<&EditContext> {
130        self.stack.iter().rev().filter(|c| c.reversible).collect()
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_scope_local_not_downstream() {
140        assert!(!EditScope::Local.affects_downstream());
141    }
142
143    #[test]
144    fn test_scope_track_downstream() {
145        assert!(EditScope::Track.affects_downstream());
146    }
147
148    #[test]
149    fn test_scope_global_downstream() {
150        assert!(EditScope::Global.affects_downstream());
151    }
152
153    #[test]
154    fn test_scope_labels() {
155        assert_eq!(EditScope::Local.label(), "Local");
156        assert_eq!(EditScope::Track.label(), "Track");
157        assert_eq!(EditScope::Global.label(), "Global");
158    }
159
160    #[test]
161    fn test_edit_context_has_undo_true() {
162        let ctx = EditContext::new(1, "Trim", EditScope::Local, true, 0);
163        assert!(ctx.has_undo());
164    }
165
166    #[test]
167    fn test_edit_context_has_undo_false() {
168        let ctx = EditContext::new(2, "Export", EditScope::Global, false, 0);
169        assert!(!ctx.has_undo());
170    }
171
172    #[test]
173    fn test_manager_new_empty() {
174        let mgr = EditContextManager::new();
175        assert_eq!(mgr.depth(), 0);
176        assert!(mgr.current().is_none());
177    }
178
179    #[test]
180    fn test_manager_push_returns_id() {
181        let mut mgr = EditContextManager::new();
182        let id = mgr.push_context("Cut", EditScope::Local, true, 100);
183        assert_eq!(id, 1);
184        let id2 = mgr.push_context("Ripple", EditScope::Track, true, 200);
185        assert_eq!(id2, 2);
186    }
187
188    #[test]
189    fn test_manager_current_is_latest() {
190        let mut mgr = EditContextManager::new();
191        mgr.push_context("First", EditScope::Local, true, 0);
192        mgr.push_context("Second", EditScope::Track, false, 1);
193        let cur = mgr.current().expect("cur should be valid");
194        assert_eq!(cur.description, "Second");
195    }
196
197    #[test]
198    fn test_manager_pop_returns_context() {
199        let mut mgr = EditContextManager::new();
200        mgr.push_context("Op", EditScope::Global, true, 50);
201        let popped = mgr.pop_context().expect("popped should be valid");
202        assert_eq!(popped.description, "Op");
203        assert_eq!(mgr.depth(), 0);
204    }
205
206    #[test]
207    fn test_manager_pop_empty_is_none() {
208        let mut mgr = EditContextManager::new();
209        assert!(mgr.pop_context().is_none());
210    }
211
212    #[test]
213    fn test_manager_depth_grows() {
214        let mut mgr = EditContextManager::new();
215        for i in 0..5 {
216            mgr.push_context(format!("Op{i}"), EditScope::Local, true, i as u64);
217        }
218        assert_eq!(mgr.depth(), 5);
219    }
220
221    #[test]
222    fn test_reversible_contexts_filtered() {
223        let mut mgr = EditContextManager::new();
224        mgr.push_context("A", EditScope::Local, true, 0);
225        mgr.push_context("B", EditScope::Track, false, 1);
226        mgr.push_context("C", EditScope::Global, true, 2);
227        let rev = mgr.reversible_contexts();
228        assert_eq!(rev.len(), 2);
229        // Most recent first
230        assert_eq!(rev[0].description, "C");
231        assert_eq!(rev[1].description, "A");
232    }
233
234    #[test]
235    fn test_context_scope_stored() {
236        let ctx = EditContext::new(10, "Test", EditScope::Track, true, 999);
237        assert_eq!(ctx.scope, EditScope::Track);
238        assert_eq!(ctx.id, 10);
239    }
240}