1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
#![allow(dead_code)]
//! Clip edit history and undo/redo support.
//!
//! This module provides a non-destructive edit history system for clips,
//! tracking all changes (trims, metadata edits, rating changes, etc.)
//! with full undo/redo support. Each change is recorded as an action
//! in a linear history stack.
use std::collections::VecDeque;
use std::fmt;
/// Type of edit action performed on a clip.
#[derive(Debug, Clone, PartialEq)]
pub enum EditAction {
/// Trim in-point changed.
TrimIn {
/// Previous in-point in frames.
old_frame: u64,
/// New in-point in frames.
new_frame: u64,
},
/// Trim out-point changed.
TrimOut {
/// Previous out-point in frames.
old_frame: u64,
/// New out-point in frames.
new_frame: u64,
},
/// Rating changed.
RatingChange {
/// Previous rating.
old_rating: u8,
/// New rating.
new_rating: u8,
},
/// Name / label changed.
Rename {
/// Previous name.
old_name: String,
/// New name.
new_name: String,
},
/// Keyword added.
KeywordAdd {
/// The keyword that was added.
keyword: String,
},
/// Keyword removed.
KeywordRemove {
/// The keyword that was removed.
keyword: String,
},
/// Marker added at a frame position.
MarkerAdd {
/// Frame position of the marker.
frame: u64,
/// Marker label.
label: String,
},
/// Marker removed.
MarkerRemove {
/// Frame position of the marker.
frame: u64,
/// Marker label.
label: String,
},
/// Note / comment changed.
NoteChange {
/// Previous note text.
old_text: String,
/// New note text.
new_text: String,
},
/// Color label changed.
ColorLabel {
/// Previous color label.
old_color: String,
/// New color label.
new_color: String,
},
}
impl fmt::Display for EditAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TrimIn { new_frame, .. } => write!(f, "Set In Point: {new_frame}"),
Self::TrimOut { new_frame, .. } => write!(f, "Set Out Point: {new_frame}"),
Self::RatingChange { new_rating, .. } => write!(f, "Rate: {new_rating} stars"),
Self::Rename { new_name, .. } => write!(f, "Rename: {new_name}"),
Self::KeywordAdd { keyword } => write!(f, "Add Keyword: {keyword}"),
Self::KeywordRemove { keyword } => write!(f, "Remove Keyword: {keyword}"),
Self::MarkerAdd { frame, label } => {
write!(f, "Add Marker at {frame}: {label}")
}
Self::MarkerRemove { frame, label } => {
write!(f, "Remove Marker at {frame}: {label}")
}
Self::NoteChange { .. } => write!(f, "Edit Note"),
Self::ColorLabel { new_color, .. } => write!(f, "Color: {new_color}"),
}
}
}
/// A recorded entry in the edit history.
#[derive(Debug, Clone)]
pub struct HistoryEntry {
/// The action performed.
pub action: EditAction,
/// Clip ID affected.
pub clip_id: u64,
/// Timestamp of the action (seconds since epoch).
pub timestamp: u64,
/// User or session that performed the action.
pub user: String,
}
impl HistoryEntry {
/// Creates a new history entry.
#[must_use]
pub fn new(action: EditAction, clip_id: u64, timestamp: u64, user: impl Into<String>) -> Self {
Self {
action,
clip_id,
timestamp,
user: user.into(),
}
}
/// Returns a human-readable description.
#[must_use]
pub fn description(&self) -> String {
format!("[{}] Clip {}: {}", self.user, self.clip_id, self.action)
}
}
/// Edit history with undo/redo support.
#[derive(Debug)]
pub struct ClipEditHistory {
/// Stack of performed actions (undo stack).
undo_stack: Vec<HistoryEntry>,
/// Stack of undone actions (redo stack).
redo_stack: Vec<HistoryEntry>,
/// Maximum history depth.
max_depth: usize,
/// Whether history recording is active.
recording: bool,
}
impl ClipEditHistory {
/// Creates a new empty edit history.
#[must_use]
pub fn new(max_depth: usize) -> Self {
Self {
undo_stack: Vec::new(),
redo_stack: Vec::new(),
max_depth,
recording: true,
}
}
/// Creates a history with default max depth (1000).
#[must_use]
pub fn with_default_depth() -> Self {
Self::new(1000)
}
/// Records a new action. Clears the redo stack.
pub fn record(&mut self, entry: HistoryEntry) {
if !self.recording {
return;
}
self.redo_stack.clear();
self.undo_stack.push(entry);
// Trim to max depth
while self.undo_stack.len() > self.max_depth {
self.undo_stack.remove(0);
}
}
/// Returns the most recent action without undoing it.
#[must_use]
pub fn peek_undo(&self) -> Option<&HistoryEntry> {
self.undo_stack.last()
}
/// Undoes the most recent action and returns it.
pub fn undo(&mut self) -> Option<HistoryEntry> {
if let Some(entry) = self.undo_stack.pop() {
self.redo_stack.push(entry.clone());
Some(entry)
} else {
None
}
}
/// Redoes the most recently undone action and returns it.
pub fn redo(&mut self) -> Option<HistoryEntry> {
if let Some(entry) = self.redo_stack.pop() {
self.undo_stack.push(entry.clone());
Some(entry)
} else {
None
}
}
/// Returns true if undo is available.
#[must_use]
pub fn can_undo(&self) -> bool {
!self.undo_stack.is_empty()
}
/// Returns true if redo is available.
#[must_use]
pub fn can_redo(&self) -> bool {
!self.redo_stack.is_empty()
}
/// Returns the undo stack depth.
#[must_use]
pub fn undo_depth(&self) -> usize {
self.undo_stack.len()
}
/// Returns the redo stack depth.
#[must_use]
pub fn redo_depth(&self) -> usize {
self.redo_stack.len()
}
/// Clears all history.
pub fn clear(&mut self) {
self.undo_stack.clear();
self.redo_stack.clear();
}
/// Pauses history recording.
pub fn pause(&mut self) {
self.recording = false;
}
/// Resumes history recording.
pub fn resume(&mut self) {
self.recording = true;
}
/// Returns whether recording is active.
#[must_use]
pub fn is_recording(&self) -> bool {
self.recording
}
/// Returns all entries in the undo stack (oldest first).
#[must_use]
pub fn undo_entries(&self) -> &[HistoryEntry] {
&self.undo_stack
}
/// Returns the history for a specific clip.
#[must_use]
pub fn clip_history(&self, clip_id: u64) -> Vec<&HistoryEntry> {
self.undo_stack
.iter()
.filter(|e| e.clip_id == clip_id)
.collect()
}
}
/// A batch of edits grouped as a single undoable unit.
#[derive(Debug, Clone)]
pub struct EditBatch {
/// Label for this batch.
pub label: String,
/// Actions in this batch.
pub actions: Vec<HistoryEntry>,
}
impl EditBatch {
/// Creates a new edit batch.
#[must_use]
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
actions: Vec::new(),
}
}
/// Adds an action to the batch.
pub fn add(&mut self, entry: HistoryEntry) {
self.actions.push(entry);
}
/// Returns the number of actions in the batch.
#[must_use]
pub fn len(&self) -> usize {
self.actions.len()
}
/// Returns true if the batch is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.actions.is_empty()
}
}
/// History log that persists a chronological record of all edits.
#[derive(Debug)]
pub struct HistoryLog {
/// All logged entries.
entries: VecDeque<HistoryEntry>,
/// Maximum number of entries to keep.
capacity: usize,
}
impl HistoryLog {
/// Creates a new history log with the given capacity.
#[must_use]
pub fn new(capacity: usize) -> Self {
Self {
entries: VecDeque::new(),
capacity,
}
}
/// Appends an entry to the log.
pub fn append(&mut self, entry: HistoryEntry) {
self.entries.push_back(entry);
while self.entries.len() > self.capacity {
self.entries.pop_front();
}
}
/// Returns the number of entries.
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
/// Returns true if the log is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Returns all entries (oldest first).
#[must_use]
pub fn entries(&self) -> &VecDeque<HistoryEntry> {
&self.entries
}
/// Returns entries for a specific clip.
#[must_use]
pub fn entries_for_clip(&self, clip_id: u64) -> Vec<&HistoryEntry> {
self.entries
.iter()
.filter(|e| e.clip_id == clip_id)
.collect()
}
/// Clears the log.
pub fn clear(&mut self) {
self.entries.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_entry(action: EditAction, clip_id: u64) -> HistoryEntry {
HistoryEntry::new(action, clip_id, 1000, "editor")
}
#[test]
fn test_edit_action_display() {
let action = EditAction::TrimIn {
old_frame: 0,
new_frame: 100,
};
let display = format!("{action}");
assert!(display.contains("100"));
}
#[test]
fn test_history_entry_description() {
let entry = make_entry(
EditAction::Rename {
old_name: "Old".into(),
new_name: "New".into(),
},
42,
);
let desc = entry.description();
assert!(desc.contains("42"));
assert!(desc.contains("editor"));
}
#[test]
fn test_history_record_and_undo() {
let mut history = ClipEditHistory::new(100);
let entry = make_entry(
EditAction::RatingChange {
old_rating: 0,
new_rating: 5,
},
1,
);
history.record(entry);
assert!(history.can_undo());
assert!(!history.can_redo());
assert_eq!(history.undo_depth(), 1);
let undone = history.undo().expect("undo should succeed");
assert_eq!(undone.clip_id, 1);
assert!(!history.can_undo());
assert!(history.can_redo());
}
#[test]
fn test_history_redo() {
let mut history = ClipEditHistory::new(100);
history.record(make_entry(
EditAction::KeywordAdd {
keyword: "test".into(),
},
1,
));
history.undo();
assert!(history.can_redo());
let redone = history.redo().expect("redo should succeed");
assert_eq!(redone.clip_id, 1);
assert!(history.can_undo());
assert!(!history.can_redo());
}
#[test]
fn test_new_action_clears_redo() {
let mut history = ClipEditHistory::new(100);
history.record(make_entry(
EditAction::TrimIn {
old_frame: 0,
new_frame: 10,
},
1,
));
history.undo();
assert!(history.can_redo());
// New action should clear redo
history.record(make_entry(
EditAction::TrimOut {
old_frame: 100,
new_frame: 90,
},
1,
));
assert!(!history.can_redo());
}
#[test]
fn test_max_depth() {
let mut history = ClipEditHistory::new(3);
for i in 0..5 {
history.record(make_entry(
EditAction::RatingChange {
old_rating: 0,
new_rating: i as u8,
},
1,
));
}
assert_eq!(history.undo_depth(), 3);
}
#[test]
fn test_clear_history() {
let mut history = ClipEditHistory::new(100);
history.record(make_entry(
EditAction::KeywordAdd {
keyword: "a".into(),
},
1,
));
history.undo();
history.clear();
assert!(!history.can_undo());
assert!(!history.can_redo());
}
#[test]
fn test_pause_resume() {
let mut history = ClipEditHistory::new(100);
history.pause();
assert!(!history.is_recording());
history.record(make_entry(
EditAction::KeywordAdd {
keyword: "x".into(),
},
1,
));
assert_eq!(history.undo_depth(), 0); // not recorded
history.resume();
history.record(make_entry(
EditAction::KeywordAdd {
keyword: "y".into(),
},
1,
));
assert_eq!(history.undo_depth(), 1);
}
#[test]
fn test_clip_history_filter() {
let mut history = ClipEditHistory::new(100);
history.record(make_entry(
EditAction::KeywordAdd {
keyword: "a".into(),
},
1,
));
history.record(make_entry(
EditAction::KeywordAdd {
keyword: "b".into(),
},
2,
));
history.record(make_entry(
EditAction::KeywordAdd {
keyword: "c".into(),
},
1,
));
let clip1_hist = history.clip_history(1);
assert_eq!(clip1_hist.len(), 2);
}
#[test]
fn test_edit_batch() {
let mut batch = EditBatch::new("Batch Rating");
assert!(batch.is_empty());
batch.add(make_entry(
EditAction::RatingChange {
old_rating: 0,
new_rating: 5,
},
1,
));
batch.add(make_entry(
EditAction::RatingChange {
old_rating: 0,
new_rating: 4,
},
2,
));
assert_eq!(batch.len(), 2);
assert!(!batch.is_empty());
}
#[test]
fn test_history_log() {
let mut log = HistoryLog::new(5);
assert!(log.is_empty());
for i in 0..7 {
log.append(make_entry(
EditAction::RatingChange {
old_rating: 0,
new_rating: i as u8,
},
i,
));
}
assert_eq!(log.len(), 5); // capped at capacity
}
#[test]
fn test_history_log_entries_for_clip() {
let mut log = HistoryLog::new(100);
log.append(make_entry(
EditAction::KeywordAdd {
keyword: "a".into(),
},
10,
));
log.append(make_entry(
EditAction::KeywordAdd {
keyword: "b".into(),
},
20,
));
log.append(make_entry(
EditAction::KeywordAdd {
keyword: "c".into(),
},
10,
));
let entries = log.entries_for_clip(10);
assert_eq!(entries.len(), 2);
}
#[test]
fn test_peek_undo() {
let mut history = ClipEditHistory::new(100);
assert!(history.peek_undo().is_none());
history.record(make_entry(
EditAction::Rename {
old_name: "A".into(),
new_name: "B".into(),
},
1,
));
let peeked = history.peek_undo().expect("peek_undo should succeed");
assert_eq!(peeked.clip_id, 1);
// peek should not consume
assert_eq!(history.undo_depth(), 1);
}
}