Skip to main content

tellers_timeline_core/stack_methods/
stack_snap.rs

1use std::collections::HashSet;
2
3use crate::metadata::{item_link_group_id, item_tellers_group_id};
4use crate::{IdMetadataExt, Item, Seconds, Stack};
5
6/// Which edge of a time range a snap refers to.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SnapEdge {
9    Start,
10    End,
11}
12
13/// What the moving range snapped to.
14#[derive(Debug, Clone, PartialEq)]
15pub enum SnapTarget {
16    /// The start or end of a clip already on the timeline.
17    ItemEdge {
18        item_id: Option<String>,
19        track_id: Option<String>,
20        track_index: usize,
21        edge: SnapEdge,
22    },
23    /// One of the caller's extra snap points (playhead, markers, ...), by its
24    /// index in `extra_points`.
25    Point { index: usize },
26}
27
28/// The closest snap found by [`Stack::find_snap`].
29#[derive(Debug, Clone, PartialEq)]
30pub struct SnapMatch {
31    pub target: SnapTarget,
32    /// The time of the target edge or point: where a guide line belongs.
33    pub target_time: Seconds,
34    /// Which edge of the moving range lands on `target_time`.
35    pub moving_edge: SnapEdge,
36    /// The shift to add to the moving start so it snaps (`|delta| <= tolerance`).
37    pub delta: Seconds,
38}
39
40/// Target times this close are the same snap line.
41const SNAP_TIME_EPS: Seconds = 1e-6;
42
43impl Stack {
44    /// Find the clip edge (or extra point) closest to either edge of the moving
45    /// range `[moving_start, moving_start + moving_duration]`, within
46    /// `tolerance` seconds.
47    ///
48    /// Snapping is a query only: the caller decides when it is enabled and
49    /// converts its on-screen distance into `tolerance`, then moves to
50    /// `moving_start + delta`. Pass `moving_duration = 0` to snap a single edge
51    /// (a resize handle); the match then reports `SnapEdge::Start`.
52    ///
53    /// Clips in `exclude_item_ids` never act as targets, and neither do their
54    /// sync partners or Tellers group members, which travel with them. Gaps
55    /// are never targets. Targets at any of `ignored_times` are skipped too,
56    /// so an editor can let a line it has already been pulled off pass by
57    /// while still snapping to the next one. A snap that would move the range
58    /// before 0 is skipped.
59    pub fn find_snap(
60        &self,
61        moving_start: Seconds,
62        moving_duration: Seconds,
63        tolerance: Seconds,
64        exclude_item_ids: &[String],
65        extra_points: &[Seconds],
66        ignored_times: &[Seconds],
67    ) -> Option<SnapMatch> {
68        if tolerance.is_nan()
69            || tolerance <= 0.0
70            || !moving_start.is_finite()
71            || !moving_duration.is_finite()
72        {
73            return None;
74        }
75        let moving_duration = moving_duration.max(0.0);
76        let moving_end = moving_start + moving_duration;
77        let excluded = self.snap_excluded_items(exclude_item_ids);
78
79        let mut best: Option<SnapMatch> = None;
80        let mut consider = |target: SnapTarget, target_time: Seconds| {
81            if ignored_times
82                .iter()
83                .any(|ignored| (ignored - target_time).abs() <= SNAP_TIME_EPS)
84            {
85                return;
86            }
87            let mut edges = vec![(SnapEdge::Start, moving_start)];
88            if moving_duration > 0.0 {
89                edges.push((SnapEdge::End, moving_end));
90            }
91            for (moving_edge, moving_time) in edges {
92                let delta = target_time - moving_time;
93                if delta.abs() > tolerance || moving_start + delta < -super::EPS {
94                    continue;
95                }
96                if best.as_ref().is_some_and(|b| b.delta.abs() <= delta.abs()) {
97                    continue;
98                }
99                best = Some(SnapMatch {
100                    target: target.clone(),
101                    target_time,
102                    moving_edge,
103                    delta,
104                });
105            }
106        };
107
108        for (track_index, track) in self.children.iter().enumerate() {
109            let track_id = track.get_id();
110            let mut cursor = 0.0;
111            for (item_index, item) in track.items.iter().enumerate() {
112                let start = cursor;
113                cursor += item.duration();
114                if matches!(item, Item::Gap(_)) || excluded.contains(&(track_index, item_index)) {
115                    continue;
116                }
117                for (edge, time) in [(SnapEdge::Start, start), (SnapEdge::End, cursor)] {
118                    consider(
119                        SnapTarget::ItemEdge {
120                            item_id: item.get_id(),
121                            track_id: track_id.clone(),
122                            track_index,
123                            edge,
124                        },
125                        time,
126                    );
127                }
128            }
129        }
130        for (index, &time) in extra_points.iter().enumerate() {
131            if time.is_finite() {
132                consider(SnapTarget::Point { index }, time);
133            }
134        }
135        best
136    }
137
138    /// The excluded clips plus everything that moves with them: their sync
139    /// (link group) partners and Tellers group members.
140    /// Returned as `(track_index, item_index)` pairs.
141    fn snap_excluded_items(&self, exclude_item_ids: &[String]) -> HashSet<(usize, usize)> {
142        let ids: HashSet<&str> = exclude_item_ids.iter().map(String::as_str).collect();
143        let mut link_groups = HashSet::new();
144        let mut tellers_groups = HashSet::new();
145        for id in &ids {
146            if let Some((_, _, item)) = self.get_item(id) {
147                link_groups.extend(item_link_group_id(item));
148                tellers_groups.extend(item_tellers_group_id(item));
149            }
150        }
151        let mut excluded = HashSet::new();
152        for (track_index, track) in self.children.iter().enumerate() {
153            for (item_index, item) in track.items.iter().enumerate() {
154                if item.get_id().is_some_and(|id| ids.contains(id.as_str()))
155                    || item_link_group_id(item).is_some_and(|g| link_groups.contains(&g))
156                    || item_tellers_group_id(item).is_some_and(|g| tellers_groups.contains(&g))
157                {
158                    excluded.insert((track_index, item_index));
159                }
160            }
161        }
162        excluded
163    }
164}