1use leptos::prelude::*;
14use wasm_bindgen::JsCast;
15
16use crate::editor_ipc::{self, EditorStatus, TransportAction};
17
18const MIN_PX_PER_FRAME: f64 = 0.01;
20const MAX_PX_PER_FRAME: f64 = 40.0;
21const MIN_TICK_SPACING_PX: f64 = 64.0;
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct RulerTick {
27 pub frame: u64,
29 pub label: String,
31}
32
33#[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 #[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 #[must_use]
69 pub fn px_per_frame(&self) -> f64 {
70 self.px_per_frame
71 }
72
73 #[must_use]
75 pub fn scroll_frame(&self) -> f64 {
76 self.scroll_frame
77 }
78
79 #[must_use]
81 pub fn frame_to_px(&self, frame: f64) -> f64 {
82 (frame - self.scroll_frame) * self.px_per_frame
83 }
84
85 #[must_use]
87 pub fn px_to_frame(&self, px: f64) -> f64 {
88 self.scroll_frame + px / self.px_per_frame
89 }
90
91 #[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 #[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 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 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 #[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; }
166 }
167 ticks
168 }
169}
170
171fn 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
188fn 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#[component]
197pub fn TimelineRuler() -> impl IntoView {
198 let status = use_context::<RwSignal<EditorStatus>>()
199 .unwrap_or_else(|| RwSignal::new(EditorStatus::default()));
200 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); 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); 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 let vp = TimelineViewport::fit(150, 30, 600.0);
309 let ticks = vp.ruler_ticks();
310 assert!(!ticks.is_empty());
311 assert_eq!(
313 ticks[0],
314 RulerTick {
315 frame: 0,
316 label: "0:00".into()
317 }
318 );
319 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 assert_eq!(nice_second_interval(200.0), 1);
329 assert_eq!(nice_second_interval(10.0), 10); 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}