Skip to main content

oximedia_edit/
insert_mode.rs

1#![allow(dead_code)]
2//! Insert mode semantics for clip placement operations.
3
4/// How a clip insertion interacts with existing timeline content.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6pub enum InsertMode {
7    /// Overwrites existing content at the target position.
8    Overwrite,
9    /// Pushes all subsequent clips downstream to make room.
10    Ripple,
11    /// Pulls downstream clips and fills gaps with handles.
12    PushPull,
13}
14
15impl InsertMode {
16    /// Short description of the insert mode.
17    #[must_use]
18    pub fn description(&self) -> &'static str {
19        match self {
20            InsertMode::Overwrite => "Overwrite existing clips",
21            InsertMode::Ripple => "Ripple downstream clips to make room",
22            InsertMode::PushPull => "Push/pull downstream with handle preservation",
23        }
24    }
25
26    /// Returns true if this mode shifts existing clips in time.
27    #[must_use]
28    pub fn shifts_clips(&self) -> bool {
29        matches!(self, InsertMode::Ripple | InsertMode::PushPull)
30    }
31}
32
33/// A candidate position for inserting a clip, with snap metadata.
34#[derive(Debug, Clone)]
35pub struct InsertPoint {
36    /// Frame position on the timeline.
37    pub frame: i64,
38    /// Whether this point was snapped from an original request.
39    pub snapped: bool,
40    /// The original requested frame (before snapping).
41    pub requested_frame: i64,
42}
43
44impl InsertPoint {
45    /// Creates a new insert point.
46    #[must_use]
47    pub fn new(frame: i64) -> Self {
48        Self {
49            frame,
50            snapped: false,
51            requested_frame: frame,
52        }
53    }
54
55    /// Snaps this insert point to the nearest snap position from `candidates`.
56    /// Returns `self` unchanged when `candidates` is empty.
57    #[must_use]
58    pub fn snap_to_nearest(mut self, candidates: &[i64], threshold: i64) -> Self {
59        if candidates.is_empty() {
60            return self;
61        }
62        let (nearest, dist) = candidates
63            .iter()
64            .fold((candidates[0], i64::MAX), |best, &c| {
65                let d = (c - self.frame).abs();
66                if d < best.1 {
67                    (c, d)
68                } else {
69                    best
70                }
71            });
72        if dist <= threshold {
73            self.requested_frame = self.frame;
74            self.frame = nearest;
75            self.snapped = true;
76        }
77        self
78    }
79}
80
81/// Describes a pending insert operation and its expected outcome.
82#[derive(Debug, Clone)]
83pub struct InsertOperation {
84    /// Mode to use for insertion.
85    pub mode: InsertMode,
86    /// Where to insert.
87    pub point: InsertPoint,
88    /// Duration of the clip being inserted (frames).
89    pub clip_duration: u64,
90    /// Duration of the timeline before the insert (frames).
91    pub timeline_duration_before: u64,
92}
93
94impl InsertOperation {
95    /// Creates a new insert operation.
96    #[must_use]
97    pub fn new(
98        mode: InsertMode,
99        point: InsertPoint,
100        clip_duration: u64,
101        timeline_duration_before: u64,
102    ) -> Self {
103        Self {
104            mode,
105            point,
106            clip_duration,
107            timeline_duration_before,
108        }
109    }
110
111    /// Predicted resulting timeline duration after applying this operation.
112    #[must_use]
113    pub fn resulting_duration(&self) -> u64 {
114        match self.mode {
115            InsertMode::Overwrite => {
116                // Overwrite does not extend beyond the clip's footprint unless it's at the tail.
117                let end = self.point.frame.max(0) as u64 + self.clip_duration;
118                self.timeline_duration_before.max(end)
119            }
120            InsertMode::Ripple | InsertMode::PushPull => {
121                // Ripple always adds the full clip duration.
122                self.timeline_duration_before + self.clip_duration
123            }
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_overwrite_description() {
134        assert_eq!(
135            InsertMode::Overwrite.description(),
136            "Overwrite existing clips"
137        );
138    }
139
140    #[test]
141    fn test_ripple_description() {
142        assert!(InsertMode::Ripple.description().contains("Ripple"));
143    }
144
145    #[test]
146    fn test_push_pull_description() {
147        assert!(InsertMode::PushPull.description().contains("Push/pull"));
148    }
149
150    #[test]
151    fn test_overwrite_does_not_shift() {
152        assert!(!InsertMode::Overwrite.shifts_clips());
153    }
154
155    #[test]
156    fn test_ripple_shifts() {
157        assert!(InsertMode::Ripple.shifts_clips());
158    }
159
160    #[test]
161    fn test_push_pull_shifts() {
162        assert!(InsertMode::PushPull.shifts_clips());
163    }
164
165    #[test]
166    fn test_insert_point_new() {
167        let p = InsertPoint::new(100);
168        assert_eq!(p.frame, 100);
169        assert!(!p.snapped);
170    }
171
172    #[test]
173    fn test_snap_to_nearest_within_threshold() {
174        let p = InsertPoint::new(98).snap_to_nearest(&[100, 200, 50], 5);
175        assert_eq!(p.frame, 100);
176        assert!(p.snapped);
177        assert_eq!(p.requested_frame, 98);
178    }
179
180    #[test]
181    fn test_snap_outside_threshold_no_snap() {
182        let p = InsertPoint::new(50).snap_to_nearest(&[100], 10);
183        assert_eq!(p.frame, 50);
184        assert!(!p.snapped);
185    }
186
187    #[test]
188    fn test_snap_empty_candidates() {
189        let p = InsertPoint::new(30).snap_to_nearest(&[], 5);
190        assert_eq!(p.frame, 30);
191    }
192
193    #[test]
194    fn test_ripple_resulting_duration() {
195        let pt = InsertPoint::new(50);
196        let op = InsertOperation::new(InsertMode::Ripple, pt, 30, 200);
197        assert_eq!(op.resulting_duration(), 230);
198    }
199
200    #[test]
201    fn test_overwrite_within_timeline() {
202        let pt = InsertPoint::new(10);
203        let op = InsertOperation::new(InsertMode::Overwrite, pt, 20, 200);
204        // clip ends at frame 30, timeline stays 200
205        assert_eq!(op.resulting_duration(), 200);
206    }
207
208    #[test]
209    fn test_overwrite_extending_timeline() {
210        let pt = InsertPoint::new(190);
211        let op = InsertOperation::new(InsertMode::Overwrite, pt, 30, 200);
212        // clip ends at 220, extends timeline
213        assert_eq!(op.resulting_duration(), 220);
214    }
215
216    #[test]
217    fn test_push_pull_resulting_duration() {
218        let pt = InsertPoint::new(0);
219        let op = InsertOperation::new(InsertMode::PushPull, pt, 48, 100);
220        assert_eq!(op.resulting_duration(), 148);
221    }
222}