Skip to main content

oximedia_edit/
marker_edit.rs

1//! Marker editing operations for timeline annotation management.
2//!
3//! Extends the basic marker system with editing-specific operations such as
4//! marker nudge, snap-to-marker, marker filtering, range selection from
5//! markers, and batch marker manipulation.
6
7#![allow(dead_code)]
8
9use std::collections::HashMap;
10use std::fmt;
11
12// ---------------------------------------------------------------------------
13// Marker category
14// ---------------------------------------------------------------------------
15
16/// Category label for organising markers visually and semantically.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum MarkerCategory {
19    /// General-purpose marker.
20    General,
21    /// Comment / annotation marker.
22    Comment,
23    /// Chapter / section boundary.
24    Chapter,
25    /// Sync point for audio alignment.
26    SyncPoint,
27    /// Cue for playback automation.
28    Cue,
29    /// Error / issue flag.
30    Error,
31    /// Review note.
32    Review,
33    /// To-do item.
34    Todo,
35}
36
37impl fmt::Display for MarkerCategory {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::General => write!(f, "general"),
41            Self::Comment => write!(f, "comment"),
42            Self::Chapter => write!(f, "chapter"),
43            Self::SyncPoint => write!(f, "sync"),
44            Self::Cue => write!(f, "cue"),
45            Self::Error => write!(f, "error"),
46            Self::Review => write!(f, "review"),
47            Self::Todo => write!(f, "todo"),
48        }
49    }
50}
51
52// ---------------------------------------------------------------------------
53// Editable marker
54// ---------------------------------------------------------------------------
55
56/// A timeline marker with editing metadata.
57#[derive(Debug, Clone)]
58pub struct EditMarker {
59    /// Unique ID within the timeline.
60    pub id: u64,
61    /// Position on the timeline (in timebase units).
62    pub position: u64,
63    /// Optional end position for range markers.
64    pub end_position: Option<u64>,
65    /// Category tag.
66    pub category: MarkerCategory,
67    /// Short label (shown on timeline).
68    pub label: String,
69    /// Extended description / notes.
70    pub notes: String,
71    /// RGBA colour for display.
72    pub color: u32,
73    /// Whether this marker is locked against edits.
74    pub locked: bool,
75}
76
77impl EditMarker {
78    /// Create a new point marker at `position`.
79    pub fn new(id: u64, position: u64, label: impl Into<String>) -> Self {
80        Self {
81            id,
82            position,
83            end_position: None,
84            category: MarkerCategory::General,
85            label: label.into(),
86            notes: String::new(),
87            color: 0xFFFF00FF, // yellow
88            locked: false,
89        }
90    }
91
92    /// Create a range marker spanning `[start, end)`.
93    pub fn range(id: u64, start: u64, end: u64, label: impl Into<String>) -> Self {
94        Self {
95            id,
96            position: start,
97            end_position: Some(end),
98            category: MarkerCategory::General,
99            label: label.into(),
100            notes: String::new(),
101            color: 0x00FF00FF,
102            locked: false,
103        }
104    }
105
106    /// Builder: set category.
107    #[must_use]
108    pub fn with_category(mut self, cat: MarkerCategory) -> Self {
109        self.category = cat;
110        self
111    }
112
113    /// Builder: set notes.
114    pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
115        self.notes = notes.into();
116        self
117    }
118
119    /// Builder: set color.
120    #[must_use]
121    pub fn with_color(mut self, rgba: u32) -> Self {
122        self.color = rgba;
123        self
124    }
125
126    /// Returns the duration of a range marker, or 0 for point markers.
127    #[must_use]
128    pub fn duration(&self) -> u64 {
129        self.end_position
130            .map_or(0, |end| end.saturating_sub(self.position))
131    }
132
133    /// Returns `true` if this is a range marker.
134    #[must_use]
135    pub fn is_range(&self) -> bool {
136        self.end_position.is_some()
137    }
138
139    /// Returns `true` if the given position falls within this marker's range.
140    /// For point markers, this checks exact equality.
141    #[must_use]
142    pub fn contains_position(&self, pos: u64) -> bool {
143        match self.end_position {
144            Some(end) => pos >= self.position && pos < end,
145            None => pos == self.position,
146        }
147    }
148
149    /// Nudge the marker by a signed offset. Clamps at zero.
150    pub fn nudge(&mut self, offset: i64) {
151        if self.locked {
152            return;
153        }
154        let new_pos = (self.position as i64).saturating_add(offset).max(0) as u64;
155        if let Some(ref mut end) = self.end_position {
156            let delta = new_pos as i64 - self.position as i64;
157            *end = (*end as i64).saturating_add(delta).max(0) as u64;
158        }
159        self.position = new_pos;
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Snap helper
165// ---------------------------------------------------------------------------
166
167/// Result of a snap-to-marker operation.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub struct SnapResult {
170    /// The marker ID that was snapped to.
171    pub marker_id: u64,
172    /// The snapped position.
173    pub position: u64,
174    /// Distance from the original position to the snap target.
175    pub distance: u64,
176}
177
178/// Find the nearest marker to `pos` within `threshold` (in timebase units).
179/// Returns `None` if no marker is close enough.
180#[must_use]
181pub fn snap_to_nearest(markers: &[EditMarker], pos: u64, threshold: u64) -> Option<SnapResult> {
182    let mut best: Option<SnapResult> = None;
183    for m in markers {
184        let dist = pos.abs_diff(m.position);
185        if dist <= threshold && best.as_ref().map_or(true, |b| dist < b.distance) {
186            best = Some(SnapResult {
187                marker_id: m.id,
188                position: m.position,
189                distance: dist,
190            });
191        }
192    }
193    best
194}
195
196// ---------------------------------------------------------------------------
197// Marker editor
198// ---------------------------------------------------------------------------
199
200/// Manages a collection of editable markers on a timeline.
201#[derive(Debug, Clone)]
202pub struct MarkerEditor {
203    /// All markers, keyed by ID.
204    markers: HashMap<u64, EditMarker>,
205    /// Auto-increment counter for marker IDs.
206    next_id: u64,
207}
208
209impl MarkerEditor {
210    /// Create a new, empty marker editor.
211    #[must_use]
212    pub fn new() -> Self {
213        Self {
214            markers: HashMap::new(),
215            next_id: 1,
216        }
217    }
218
219    /// Add a point marker and return its ID.
220    pub fn add_point(&mut self, position: u64, label: impl Into<String>) -> u64 {
221        let id = self.next_id;
222        self.next_id += 1;
223        self.markers
224            .insert(id, EditMarker::new(id, position, label));
225        id
226    }
227
228    /// Add a range marker and return its ID.
229    pub fn add_range(&mut self, start: u64, end: u64, label: impl Into<String>) -> u64 {
230        let id = self.next_id;
231        self.next_id += 1;
232        self.markers
233            .insert(id, EditMarker::range(id, start, end, label));
234        id
235    }
236
237    /// Remove a marker by ID.
238    pub fn remove(&mut self, id: u64) -> Option<EditMarker> {
239        self.markers.remove(&id)
240    }
241
242    /// Get a marker by ID.
243    #[must_use]
244    pub fn get(&self, id: u64) -> Option<&EditMarker> {
245        self.markers.get(&id)
246    }
247
248    /// Get a mutable reference to a marker by ID.
249    pub fn get_mut(&mut self, id: u64) -> Option<&mut EditMarker> {
250        self.markers.get_mut(&id)
251    }
252
253    /// Return all markers sorted by position.
254    #[must_use]
255    pub fn sorted(&self) -> Vec<&EditMarker> {
256        let mut v: Vec<&EditMarker> = self.markers.values().collect();
257        v.sort_by_key(|m| m.position);
258        v
259    }
260
261    /// Filter markers by category.
262    #[must_use]
263    pub fn filter_by_category(&self, cat: MarkerCategory) -> Vec<&EditMarker> {
264        self.markers
265            .values()
266            .filter(|m| m.category == cat)
267            .collect()
268    }
269
270    /// Nudge all unlocked markers by a signed offset.
271    pub fn nudge_all(&mut self, offset: i64) {
272        for marker in self.markers.values_mut() {
273            marker.nudge(offset);
274        }
275    }
276
277    /// Delete all markers that match a given category.
278    pub fn delete_by_category(&mut self, cat: MarkerCategory) -> usize {
279        let to_remove: Vec<u64> = self
280            .markers
281            .values()
282            .filter(|m| m.category == cat)
283            .map(|m| m.id)
284            .collect();
285        let count = to_remove.len();
286        for id in to_remove {
287            self.markers.remove(&id);
288        }
289        count
290    }
291
292    /// Returns the total number of markers.
293    #[must_use]
294    pub fn count(&self) -> usize {
295        self.markers.len()
296    }
297
298    /// Returns `true` if there are no markers.
299    #[must_use]
300    pub fn is_empty(&self) -> bool {
301        self.markers.is_empty()
302    }
303
304    /// Clear all markers.
305    pub fn clear(&mut self) {
306        self.markers.clear();
307    }
308
309    /// Find all markers whose range contains the given position.
310    #[must_use]
311    pub fn markers_at(&self, pos: u64) -> Vec<&EditMarker> {
312        self.markers
313            .values()
314            .filter(|m| m.contains_position(pos))
315            .collect()
316    }
317}
318
319impl Default for MarkerEditor {
320    fn default() -> Self {
321        Self::new()
322    }
323}
324
325// ---------------------------------------------------------------------------
326// Tests
327// ---------------------------------------------------------------------------
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_marker_category_display() {
335        assert_eq!(MarkerCategory::Chapter.to_string(), "chapter");
336        assert_eq!(MarkerCategory::Todo.to_string(), "todo");
337    }
338
339    #[test]
340    fn test_edit_marker_point() {
341        let m = EditMarker::new(1, 1000, "Take 1");
342        assert_eq!(m.position, 1000);
343        assert!(!m.is_range());
344        assert_eq!(m.duration(), 0);
345    }
346
347    #[test]
348    fn test_edit_marker_range() {
349        let m = EditMarker::range(1, 100, 500, "Scene");
350        assert!(m.is_range());
351        assert_eq!(m.duration(), 400);
352    }
353
354    #[test]
355    fn test_edit_marker_contains_position_point() {
356        let m = EditMarker::new(1, 50, "x");
357        assert!(m.contains_position(50));
358        assert!(!m.contains_position(51));
359    }
360
361    #[test]
362    fn test_edit_marker_contains_position_range() {
363        let m = EditMarker::range(1, 100, 200, "r");
364        assert!(m.contains_position(100));
365        assert!(m.contains_position(199));
366        assert!(!m.contains_position(200));
367        assert!(!m.contains_position(99));
368    }
369
370    #[test]
371    fn test_edit_marker_nudge() {
372        let mut m = EditMarker::new(1, 100, "n");
373        m.nudge(50);
374        assert_eq!(m.position, 150);
375    }
376
377    #[test]
378    fn test_edit_marker_nudge_negative_clamps() {
379        let mut m = EditMarker::new(1, 10, "n");
380        m.nudge(-100);
381        assert_eq!(m.position, 0);
382    }
383
384    #[test]
385    fn test_edit_marker_nudge_locked() {
386        let mut m = EditMarker::new(1, 100, "locked");
387        m.locked = true;
388        m.nudge(50);
389        assert_eq!(m.position, 100);
390    }
391
392    #[test]
393    fn test_edit_marker_nudge_range() {
394        let mut m = EditMarker::range(1, 100, 200, "r");
395        m.nudge(50);
396        assert_eq!(m.position, 150);
397        assert_eq!(m.end_position, Some(250));
398    }
399
400    #[test]
401    fn test_snap_to_nearest_found() {
402        let markers = vec![EditMarker::new(1, 100, "a"), EditMarker::new(2, 200, "b")];
403        let result = snap_to_nearest(&markers, 105, 10);
404        assert!(result.is_some());
405        assert_eq!(result.expect("test expectation failed").marker_id, 1);
406        assert_eq!(result.expect("test expectation failed").distance, 5);
407    }
408
409    #[test]
410    fn test_snap_to_nearest_not_found() {
411        let markers = vec![EditMarker::new(1, 100, "a")];
412        let result = snap_to_nearest(&markers, 200, 10);
413        assert!(result.is_none());
414    }
415
416    #[test]
417    fn test_marker_editor_add_and_get() {
418        let mut ed = MarkerEditor::new();
419        let id = ed.add_point(500, "Point");
420        assert_eq!(ed.count(), 1);
421        assert!(ed.get(id).is_some());
422        assert_eq!(ed.get(id).expect("get should succeed").label, "Point");
423    }
424
425    #[test]
426    fn test_marker_editor_remove() {
427        let mut ed = MarkerEditor::new();
428        let id = ed.add_point(100, "X");
429        assert!(ed.remove(id).is_some());
430        assert!(ed.is_empty());
431    }
432
433    #[test]
434    fn test_marker_editor_sorted() {
435        let mut ed = MarkerEditor::new();
436        ed.add_point(300, "c");
437        ed.add_point(100, "a");
438        ed.add_point(200, "b");
439        let sorted = ed.sorted();
440        assert_eq!(sorted[0].position, 100);
441        assert_eq!(sorted[1].position, 200);
442        assert_eq!(sorted[2].position, 300);
443    }
444
445    #[test]
446    fn test_marker_editor_filter_by_category() {
447        let mut ed = MarkerEditor::new();
448        let id1 = ed.add_point(100, "ch1");
449        ed.get_mut(id1).expect("get_mut should succeed").category = MarkerCategory::Chapter;
450        let _id2 = ed.add_point(200, "gen");
451        let chapters = ed.filter_by_category(MarkerCategory::Chapter);
452        assert_eq!(chapters.len(), 1);
453    }
454
455    #[test]
456    fn test_marker_editor_nudge_all() {
457        let mut ed = MarkerEditor::new();
458        ed.add_point(100, "a");
459        ed.add_point(200, "b");
460        ed.nudge_all(50);
461        let sorted = ed.sorted();
462        assert_eq!(sorted[0].position, 150);
463        assert_eq!(sorted[1].position, 250);
464    }
465
466    #[test]
467    fn test_marker_editor_delete_by_category() {
468        let mut ed = MarkerEditor::new();
469        let id = ed.add_point(100, "err");
470        ed.get_mut(id).expect("get_mut should succeed").category = MarkerCategory::Error;
471        ed.add_point(200, "gen");
472        let removed = ed.delete_by_category(MarkerCategory::Error);
473        assert_eq!(removed, 1);
474        assert_eq!(ed.count(), 1);
475    }
476
477    #[test]
478    fn test_marker_editor_markers_at() {
479        let mut ed = MarkerEditor::new();
480        ed.add_range(100, 300, "range");
481        ed.add_point(200, "point");
482        let at_200 = ed.markers_at(200);
483        assert_eq!(at_200.len(), 2);
484    }
485
486    #[test]
487    fn test_marker_editor_clear() {
488        let mut ed = MarkerEditor::new();
489        ed.add_point(10, "x");
490        ed.clear();
491        assert!(ed.is_empty());
492    }
493
494    #[test]
495    fn test_marker_editor_default() {
496        let ed = MarkerEditor::default();
497        assert!(ed.is_empty());
498    }
499
500    #[test]
501    fn test_marker_builders() {
502        let m = EditMarker::new(1, 0, "t")
503            .with_category(MarkerCategory::Cue)
504            .with_notes("hello")
505            .with_color(0xFF0000FF);
506        assert_eq!(m.category, MarkerCategory::Cue);
507        assert_eq!(m.notes, "hello");
508        assert_eq!(m.color, 0xFF0000FF);
509    }
510}