Skip to main content

app_ui/
timeline_view.rs

1//! Timeline coordinate system + ruler (ED.8 / M-EDIT).
2//!
3//! [`TimelineViewport`] is the pure frame↔pixel mapping — zoom
4//! (`px_per_frame`), scroll (`scroll_frame`), and "nice" ruler-tick
5//! generation — that the whole timeline (ruler, lanes, playhead, snapping)
6//! hangs off. It is GPU/DOM-free and exhaustively unit-tested at multiple
7//! zoom levels.
8//!
9//! [`TimelineRuler`] renders a fit-to-width ruler (the full-clip "global
10//! progress" view, decoupled from per-lane zoom) with frame-correct tick
11//! labels, a reactive playhead, and click-to-seek.
12
13use leptos::prelude::*;
14use wasm_bindgen::JsCast;
15
16use crate::editor_ipc::{self, EditorStatus, TransportAction};
17
18/// Zoom bounds, in pixels per frame.
19const MIN_PX_PER_FRAME: f64 = 0.01;
20const MAX_PX_PER_FRAME: f64 = 40.0;
21/// Target minimum spacing between ruler tick labels, in pixels.
22const MIN_TICK_SPACING_PX: f64 = 64.0;
23
24/// One labeled tick on the ruler.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct RulerTick {
27    /// Project frame the tick sits at.
28    pub frame: u64,
29    /// `M:SS` label.
30    pub label: String,
31}
32
33/// Maps between project frames and timeline pixels at a zoom + scroll.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct TimelineViewport {
36    px_per_frame: f64,
37    scroll_frame: f64,
38    width_px: f64,
39    fps: u32,
40    duration_frames: u64,
41}
42
43#[allow(
44    clippy::cast_precision_loss,
45    reason = "frame counts are well under 2^52; u64→f64 is lossless at these magnitudes"
46)]
47fn frames_f64(frames: u64) -> f64 {
48    frames as f64
49}
50
51impl TimelineViewport {
52    /// A viewport zoomed so the whole clip fits in `width_px`.
53    #[must_use]
54    pub fn fit(duration_frames: u64, fps: u32, width_px: f64) -> Self {
55        let width_px = width_px.max(1.0);
56        let dur = frames_f64(duration_frames.max(1));
57        let px_per_frame = (width_px / dur).clamp(MIN_PX_PER_FRAME, MAX_PX_PER_FRAME);
58        Self {
59            px_per_frame,
60            scroll_frame: 0.0,
61            width_px,
62            fps: fps.max(1),
63            duration_frames,
64        }
65    }
66
67    /// Pixels per frame (the zoom level).
68    #[must_use]
69    pub fn px_per_frame(&self) -> f64 {
70        self.px_per_frame
71    }
72
73    /// Leftmost visible frame.
74    #[must_use]
75    pub fn scroll_frame(&self) -> f64 {
76        self.scroll_frame
77    }
78
79    /// Pixel x of a frame within the viewport.
80    #[must_use]
81    pub fn frame_to_px(&self, frame: f64) -> f64 {
82        (frame - self.scroll_frame) * self.px_per_frame
83    }
84
85    /// Frame at a pixel x within the viewport.
86    #[must_use]
87    pub fn px_to_frame(&self, px: f64) -> f64 {
88        self.scroll_frame + px / self.px_per_frame
89    }
90
91    /// Fraction `0..=1` of the viewport width for `frame` (for percent-based
92    /// CSS positioning of a responsive, fit-to-width ruler).
93    #[must_use]
94    pub fn frame_to_fraction(&self, frame: f64) -> f64 {
95        if self.width_px <= 0.0 {
96            return 0.0;
97        }
98        (self.frame_to_px(frame) / self.width_px).clamp(0.0, 1.0)
99    }
100
101    /// Visible frame range `(first, last)`.
102    #[must_use]
103    pub fn visible_range(&self) -> (f64, f64) {
104        (
105            self.scroll_frame,
106            self.scroll_frame + self.width_px / self.px_per_frame,
107        )
108    }
109
110    /// Zoom by `factor` (>1 = in) keeping the frame under `anchor_px` fixed —
111    /// so zooming centred on the playhead keeps the playhead put.
112    pub fn zoom_at(&mut self, factor: f64, anchor_px: f64) {
113        let anchor_frame = self.px_to_frame(anchor_px);
114        self.px_per_frame = (self.px_per_frame * factor).clamp(MIN_PX_PER_FRAME, MAX_PX_PER_FRAME);
115        self.scroll_frame = anchor_frame - anchor_px / self.px_per_frame;
116        self.clamp_scroll();
117    }
118
119    /// Pan by `dx` pixels (positive `dx` scrolls the content left).
120    pub fn pan_px(&mut self, dx: f64) {
121        self.scroll_frame -= dx / self.px_per_frame;
122        self.clamp_scroll();
123    }
124
125    fn clamp_scroll(&mut self) {
126        let visible = self.width_px / self.px_per_frame;
127        let max_scroll = (frames_f64(self.duration_frames) - visible).max(0.0);
128        self.scroll_frame = self.scroll_frame.clamp(0.0, max_scroll);
129    }
130
131    /// Labeled ruler ticks across the visible range, at a "nice" second
132    /// interval chosen so labels stay at least [`MIN_TICK_SPACING_PX`] apart.
133    /// Frame-correct at every zoom level.
134    #[must_use]
135    pub fn ruler_ticks(&self) -> Vec<RulerTick> {
136        let px_per_second = self.px_per_frame * f64::from(self.fps);
137        let interval_frames = nice_second_interval(px_per_second) * u64::from(self.fps);
138        if interval_frames == 0 {
139            return Vec::new();
140        }
141        let (first, last) = self.visible_range();
142        #[allow(
143            clippy::cast_possible_truncation,
144            clippy::cast_sign_loss,
145            reason = "first is clamped non-negative; frame counts fit u64"
146        )]
147        let first_frame = first.max(0.0) as u64;
148        let start = (first_frame / interval_frames) * interval_frames;
149        #[allow(
150            clippy::cast_possible_truncation,
151            clippy::cast_sign_loss,
152            reason = "last is positive and bounded by the clip duration"
153        )]
154        let last_frame = (last.ceil() as u64).min(self.duration_frames);
155        let mut ticks = Vec::new();
156        let mut frame = start;
157        while frame <= last_frame {
158            ticks.push(RulerTick {
159                frame,
160                label: format_clock(frame, self.fps),
161            });
162            frame += interval_frames;
163            if ticks.len() > 1024 {
164                break; // safety net against a degenerate interval
165            }
166        }
167        ticks
168    }
169}
170
171/// Smallest "nice" second interval whose pixel width is at least
172/// [`MIN_TICK_SPACING_PX`] — so ruler labels never crowd.
173fn nice_second_interval(px_per_second: f64) -> u64 {
174    const CANDIDATES: [u64; 11] = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800];
175    for &secs in &CANDIDATES {
176        #[allow(
177            clippy::cast_precision_loss,
178            reason = "interval candidates are tiny; exact in f64"
179        )]
180        let width = px_per_second * secs as f64;
181        if width >= MIN_TICK_SPACING_PX {
182            return secs;
183        }
184    }
185    *CANDIDATES.last().unwrap_or(&1)
186}
187
188/// `M:SS` clock label for a frame.
189fn format_clock(frame: u64, fps: u32) -> String {
190    let secs = frame / u64::from(fps.max(1));
191    format!("{}:{:02}", secs / 60, secs % 60)
192}
193
194/// A fit-to-width ruler: frame-correct tick labels + a reactive playhead +
195/// click-to-seek. Reads the playhead from the editor-status context.
196#[component]
197pub fn TimelineRuler() -> impl IntoView {
198    let status = use_context::<RwSignal<EditorStatus>>()
199        .unwrap_or_else(|| RwSignal::new(EditorStatus::default()));
200    // A nominal width is used only to pick the tick interval; positioning is
201    // percent-based so the ruler is responsive to the real element width.
202    let viewport = move || {
203        let st = status.get();
204        TimelineViewport::fit(st.duration_frames, st.fps, 1000.0)
205    };
206    view! {
207        <div
208            class="timeline-ruler"
209            on:click=move |ev| {
210                let Some(target) = ev.current_target() else { return };
211                let Ok(el) = target.dyn_into::<web_sys::Element>() else { return };
212                let width = el.client_width();
213                if width <= 0 {
214                    return;
215                }
216                let st = status.get_untracked();
217                let frac = (f64::from(ev.offset_x()) / f64::from(width)).clamp(0.0, 1.0);
218                #[allow(
219                    clippy::cast_possible_truncation,
220                    clippy::cast_sign_loss,
221                    reason = "frac in [0,1]; product with the (u64) duration is non-negative and in range"
222                )]
223                let frame = (frac * frames_f64(st.duration_frames)) as u64;
224                editor_ipc::editor_transport(&TransportAction::Seek { frame });
225            }
226        >
227            {move || {
228                let vp = viewport();
229                vp.ruler_ticks()
230                    .into_iter()
231                    .map(|tick| {
232                        let left = vp.frame_to_fraction(frames_f64(tick.frame)) * 100.0;
233                        view! {
234                            <span class="timeline-tick" style=format!("left:{left:.3}%")>
235                                {tick.label}
236                            </span>
237                        }
238                    })
239                    .collect_view()
240            }}
241            <div
242                class="timeline-playhead"
243                style=move || {
244                    let vp = viewport();
245                    let left = vp.frame_to_fraction(frames_f64(status.get().current_frame)) * 100.0;
246                    format!("left:{left:.3}%")
247                }
248            ></div>
249        </div>
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn frame_px_round_trips() {
259        let vp = TimelineViewport::fit(900, 30, 600.0);
260        for frame in [0.0, 1.0, 100.0, 450.0, 899.0] {
261            let px = vp.frame_to_px(frame);
262            assert!(
263                (vp.px_to_frame(px) - frame).abs() < 1e-6,
264                "round-trip {frame}"
265            );
266        }
267    }
268
269    #[test]
270    fn fit_spans_full_width() {
271        let vp = TimelineViewport::fit(900, 30, 600.0);
272        assert!((vp.frame_to_fraction(0.0)).abs() < 1e-9);
273        assert!((vp.frame_to_fraction(900.0) - 1.0).abs() < 1e-9);
274        assert!((vp.frame_to_fraction(450.0) - 0.5).abs() < 1e-3);
275    }
276
277    #[test]
278    fn zoom_keeps_anchor_frame_put() {
279        let mut vp = TimelineViewport::fit(9000, 30, 600.0);
280        let anchor_px = 300.0;
281        let before = vp.px_to_frame(anchor_px);
282        vp.zoom_at(4.0, anchor_px);
283        let after = vp.px_to_frame(anchor_px);
284        assert!(
285            (before - after).abs() < 1e-6,
286            "anchor frame stayed put under zoom"
287        );
288        assert!(vp.px_per_frame() > TimelineViewport::fit(9000, 30, 600.0).px_per_frame());
289    }
290
291    #[test]
292    fn scroll_clamps_within_clip() {
293        let mut vp = TimelineViewport::fit(9000, 30, 600.0);
294        vp.zoom_at(8.0, 0.0);
295        vp.pan_px(-1_000_000.0); // pan way past the end
296        let (_first, last) = vp.visible_range();
297        assert!(
298            last <= 9000.0 + 1.0,
299            "can't scroll past the clip end, last={last}"
300        );
301        vp.pan_px(1_000_000.0); // and back past the start
302        assert!(vp.scroll_frame() >= -1e-9, "can't scroll before frame 0");
303    }
304
305    #[test]
306    fn ruler_ticks_are_frame_correct_and_spaced() {
307        // 5s clip @ 30fps fit into 600px → ~4px/frame → ~120px/s.
308        let vp = TimelineViewport::fit(150, 30, 600.0);
309        let ticks = vp.ruler_ticks();
310        assert!(!ticks.is_empty());
311        // First tick at frame 0, labeled 0:00.
312        assert_eq!(
313            ticks[0],
314            RulerTick {
315                frame: 0,
316                label: "0:00".into()
317            }
318        );
319        // Ticks land on whole-second multiples (interval = N×fps).
320        for t in &ticks {
321            assert_eq!(t.frame % 30, 0, "tick {t:?} on a second boundary");
322        }
323    }
324
325    #[test]
326    fn nice_interval_widens_as_we_zoom_out() {
327        // Lots of px/sec → 1s ticks; very few px/sec → coarse ticks.
328        assert_eq!(nice_second_interval(200.0), 1);
329        assert_eq!(nice_second_interval(10.0), 10); // 10px/s → 10s interval ≈100px
330        assert!(nice_second_interval(0.05) >= 600);
331    }
332
333    #[test]
334    fn clock_label_format() {
335        assert_eq!(format_clock(0, 30), "0:00");
336        assert_eq!(format_clock(900, 30), "0:30");
337        assert_eq!(format_clock(1800, 30), "1:00");
338        assert_eq!(format_clock(3690, 30), "2:03");
339    }
340}