Skip to main content

ravenrs_widgets/
timeline.rs

1//! Things along time, on tracks: the timeline of a video or audio editor, and of
2//! anything else planned in milliseconds.
3
4use std::cell::Cell;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, CursorIcon, Insets, Rect, Role};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_style::{Semantic, TextStyle};
10
11use crate::context::{BuildCx, Drag, DragPhase, NodeId, Paint, QuadPaint, Semantics};
12use crate::{AnyView, ScrollView, Text, Tone, View, ViewExt};
13
14const RULER_HEIGHT: f32 = 26.0;
15const HANDLE: f32 = 8.0;
16const MARKER: f32 = 10.0;
17/// Labels on the ruler stay at least this far apart, in logical pixels.
18const TICK_ROOM: f32 = 56.0;
19const TICK_STEPS: [u32; 9] = [1, 2, 5, 10, 15, 30, 60, 120, 300];
20const LONGEST_TICK: u32 = 600;
21
22/// One block on a [`TimelineTrack`].
23#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
24pub struct TimelineItem {
25    id: u64,
26    start_ms: u64,
27    duration_ms: u64,
28    label: String,
29    tone: Tone,
30    selected: bool,
31    movable: bool,
32    trimmable: bool,
33    leading: Option<AnyView>,
34}
35
36impl std::fmt::Debug for TimelineItem {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("TimelineItem")
39            .field("id", &self.id)
40            .field("label", &self.label)
41            .finish_non_exhaustive()
42    }
43}
44
45#[cfg_attr(feature = "live", ravenrs_live::expose)]
46impl TimelineItem {
47    /// A block known to the app as `id`, from `start_ms` for `duration_ms`.
48    pub fn new(id: u64, start_ms: u64, duration_ms: u64, label: impl Into<String>) -> Self {
49        Self {
50            id,
51            start_ms,
52            duration_ms,
53            label: label.into(),
54            tone: Tone::Neutral,
55            selected: false,
56            movable: false,
57            trimmable: false,
58            leading: None,
59        }
60    }
61
62    /// Its color.
63    pub fn tone(mut self, tone: Tone) -> Self {
64        self.tone = tone;
65        self
66    }
67
68    /// Marks it as the selected one.
69    pub fn selected(mut self, selected: bool) -> Self {
70        self.selected = selected;
71        self
72    }
73
74    /// It can be dragged along its track.
75    pub fn movable(mut self) -> Self {
76        self.movable = true;
77        self
78    }
79
80    /// Its ends can be dragged.
81    pub fn trimmable(mut self) -> Self {
82        self.trimmable = true;
83        self
84    }
85
86    /// Something before its label, such as a thumbnail.
87    pub fn leading(mut self, view: impl View + 'static) -> Self {
88        self.leading = Some(Box::new(view));
89        self
90    }
91}
92
93/// A row of a [`Timeline`].
94#[derive(Debug)]
95#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
96pub struct TimelineTrack {
97    label: String,
98    height: f32,
99    items: Vec<TimelineItem>,
100}
101
102#[cfg_attr(feature = "live", ravenrs_live::expose)]
103impl TimelineTrack {
104    /// A track named `label`, `height` logical pixels tall.
105    pub fn new(label: impl Into<String>, height: f32, items: Vec<TimelineItem>) -> Self {
106        Self { label: label.into(), height, items }
107    }
108}
109
110/// A named moment, on the ruler of a [`Timeline`].
111#[derive(Clone, Debug, PartialEq)]
112#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
113pub struct TimelineMarker {
114    /// When.
115    pub at_ms: u64,
116    /// Its name.
117    pub title: String,
118}
119
120/// What someone did to a block, for the app to apply.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122#[non_exhaustive]
123#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
124pub enum TimelineEdit {
125    /// A drag began on this block: the moves that follow are one gesture.
126    Began {
127        /// The block.
128        id: u64,
129    },
130    /// A block should now start here.
131    Moved {
132        /// The block.
133        id: u64,
134        /// Its new start.
135        start_ms: u64,
136    },
137    /// A block's start moved by this much.
138    TrimmedStart {
139        /// The block.
140        id: u64,
141        /// How much, negative for earlier.
142        by_ms: i64,
143    },
144    /// A block's end moved by this much.
145    TrimmedEnd {
146        /// The block.
147        id: u64,
148        /// How much, negative for earlier.
149        by_ms: i64,
150    },
151}
152
153/// `2:05`.
154fn clock(seconds: u32) -> String {
155    format!("{}:{:02}", seconds / 60, seconds % 60)
156}
157
158/// Things along time, on named tracks, under a ruler with markers and a playhead.
159///
160/// It holds nothing: the app gives it what is there and where the playhead is,
161/// and gets back what people do. Blocks are buttons, so they work with the
162/// keyboard. Dragging the ruler moves the playhead, dragging a
163/// [movable](TimelineItem::movable) block moves it, and the ends of a
164/// [trimmable](TimelineItem::trimmable) one change its length.
165///
166/// ```
167/// use ravenrs_widgets::{Timeline, TimelineItem, TimelineTrack, Tone};
168///
169/// let timeline = Timeline::new(vec![
170///     TimelineTrack::new("Notes", 34.0, vec![TimelineItem::new(1, 2_000, 3_000, "Callout").movable()]),
171///     TimelineTrack::new("Film", 56.0, vec![TimelineItem::new(2, 0, 10_000, "Take 1").tone(Tone::Accent)]),
172/// ])
173/// .position_ms(4_000)
174/// .on_seek(|ms| println!("{ms}"));
175/// ```
176#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
177pub struct Timeline {
178    tracks: Vec<TimelineTrack>,
179    markers: Vec<TimelineMarker>,
180    position_ms: u64,
181    pixels_per_second: f32,
182    heads_width: f32,
183    on_seek: Option<Rc<dyn Fn(u64)>>,
184    on_select: Option<Rc<dyn Fn(u64)>>,
185    on_edit: Option<Rc<dyn Fn(TimelineEdit)>>,
186}
187
188impl std::fmt::Debug for Timeline {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        f.debug_struct("Timeline").field("tracks", &self.tracks).finish_non_exhaustive()
191    }
192}
193
194#[cfg_attr(feature = "live", ravenrs_live::expose)]
195impl Timeline {
196    /// A timeline of these tracks, top to bottom.
197    pub fn new(tracks: Vec<TimelineTrack>) -> Self {
198        Self {
199            tracks,
200            markers: Vec::new(),
201            position_ms: 0,
202            pixels_per_second: 28.0,
203            heads_width: 112.0,
204            on_seek: None,
205            on_select: None,
206            on_edit: None,
207        }
208    }
209
210    /// The named moments on its ruler.
211    pub fn markers(mut self, markers: Vec<TimelineMarker>) -> Self {
212        self.markers = markers;
213        self
214    }
215
216    /// Where the playhead is.
217    pub fn position_ms(mut self, ms: u64) -> Self {
218        self.position_ms = ms;
219        self
220    }
221
222    /// How wide a second is, in logical pixels (28 by default).
223    pub fn pixels_per_second(mut self, pixels: f32) -> Self {
224        self.pixels_per_second = pixels.max(1.0);
225        self
226    }
227
228    /// The width of the column of track names (112 by default).
229    pub fn heads_width(mut self, width: f32) -> Self {
230        self.heads_width = width;
231        self
232    }
233
234    /// Runs when someone moves the playhead.
235    pub fn on_seek(mut self, handler: impl Fn(u64) + 'static) -> Self {
236        self.on_seek = Some(Rc::new(handler));
237        self
238    }
239
240    /// Runs when someone selects a block.
241    pub fn on_select(mut self, handler: impl Fn(u64) + 'static) -> Self {
242        self.on_select = Some(Rc::new(handler));
243        self
244    }
245
246    /// Runs when someone moves or trims a block.
247    pub fn on_edit(mut self, handler: impl Fn(TimelineEdit) + 'static) -> Self {
248        self.on_edit = Some(Rc::new(handler));
249        self
250    }
251
252    /// The whole height: the ruler and every track.
253    pub fn total_height(&self) -> f32 {
254        RULER_HEIGHT + self.tracks.iter().map(|track| track.height).sum::<f32>()
255    }
256
257    fn end_ms(&self) -> u64 {
258        self.tracks
259            .iter()
260            .flat_map(|track| &track.items)
261            .map(|item| item.start_ms + item.duration_ms)
262            .max()
263            .unwrap_or(0)
264    }
265}
266
267/// The column of track names.
268struct Heads<'a>(&'a Timeline);
269
270impl View for Heads<'_> {
271    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
272        let theme = *cx.theme();
273        let mut children = vec![cx.container(
274            LayoutStyle { height: Some(RULER_HEIGHT), ..LayoutStyle::stack(Axis::Vertical) },
275            &[],
276            Paint::None,
277        )];
278        for track in &self.0.tracks {
279            let label = cx.child(&Text::new(track.label.clone()).style(TextStyle::Callout));
280            children.push(cx.container(
281                LayoutStyle {
282                    height: Some(track.height),
283                    padding: Insets::symmetric(0.0, theme.spacing.s3),
284                    justify: Justify::Center,
285                    ..LayoutStyle::stack(Axis::Vertical)
286                },
287                &[label],
288                Paint::None,
289            ));
290        }
291        cx.container(
292            LayoutStyle { width: Some(self.0.heads_width), ..LayoutStyle::stack(Axis::Vertical) },
293            &children,
294            Paint::None,
295        )
296    }
297}
298
299/// One end of a block.
300struct Handle {
301    x: f32,
302    height: f32,
303    id: u64,
304    at_start: bool,
305    per_ms: f32,
306    on_edit: Rc<dyn Fn(TimelineEdit)>,
307}
308
309impl View for Handle {
310    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
311        let node = cx.container(
312            LayoutStyle {
313                absolute: Some((self.x, 0.0)),
314                width: Some(HANDLE),
315                height: Some(self.height),
316                ..LayoutStyle::stack(Axis::Vertical)
317            },
318            &[],
319            Paint::None,
320        );
321        let (id, at_start, per_ms, on_edit) = (self.id, self.at_start, self.per_ms, self.on_edit.clone());
322        let last = cx.use_state(|| Rc::new(Cell::new(0.0_f32))).get();
323        cx.on_drag(
324            node,
325            Rc::new(move |drag: Drag| {
326                if drag.phase == DragPhase::Start {
327                    last.set(drag.position.x);
328                    on_edit(TimelineEdit::Began { id });
329                    return;
330                }
331                let by_ms = ((drag.position.x - last.get()) / per_ms) as i64;
332                if by_ms != 0 {
333                    // Only what was used: the fractions stay for the next move.
334                    last.set(last.get() + by_ms as f32 * per_ms);
335                    on_edit(if at_start {
336                        TimelineEdit::TrimmedStart { id, by_ms }
337                    } else {
338                        TimelineEdit::TrimmedEnd { id, by_ms }
339                    });
340                }
341            }),
342        );
343        cx.cursor(node, CursorIcon::ColResize);
344        cx.semantics(node, Semantics::hidden());
345        node
346    }
347}
348
349/// A block.
350struct Block<'a> {
351    item: &'a TimelineItem,
352    frame: Rect,
353    per_ms: f32,
354    on_select: Option<Rc<dyn Fn(u64)>>,
355    on_edit: Option<Rc<dyn Fn(TimelineEdit)>>,
356}
357
358impl View for Block<'_> {
359    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
360        let theme = *cx.theme();
361        let item = self.item;
362        let tint = item.tone.color(&theme);
363        let (width, height) = (self.frame.width().max(4.0), self.frame.height());
364        let mut row = Vec::new();
365        if let Some(leading) = &item.leading {
366            row.push(cx.child(leading));
367        }
368        row.push(cx.child(&Text::new(item.label.clone()).style(TextStyle::Caption).truncate().grow(1.0)));
369        let content = cx.container(
370            LayoutStyle {
371                spacing: theme.spacing.s2,
372                padding: Insets::symmetric(0.0, HANDLE + 2.0),
373                align: Align::Center,
374                height: Some(height),
375                ..LayoutStyle::stack(Axis::Horizontal)
376            },
377            &row,
378            Paint::None,
379        );
380        let mut children = vec![content];
381        if let (true, Some(on_edit)) = (item.trimmable, &self.on_edit) {
382            let handle = |x: f32, at_start: bool| Handle {
383                x,
384                height,
385                id: item.id,
386                at_start,
387                per_ms: self.per_ms,
388                on_edit: on_edit.clone(),
389            };
390            children.push(cx.child(&handle(0.0, true)));
391            children.push(cx.child(&handle((width - HANDLE).max(0.0), false)));
392        }
393        let node = cx.container(
394            LayoutStyle {
395                absolute: Some((self.frame.x(), self.frame.y())),
396                width: Some(width),
397                height: Some(height),
398                overflow_hidden: true,
399                ..LayoutStyle::stack(Axis::Vertical)
400            },
401            &children,
402            Paint::Quad(QuadPaint {
403                fill: tint.faded(if item.selected { 0.34 } else { 0.18 }).into(),
404                corner_radius: theme.radii.sm,
405                border: Some(Border {
406                    width: if item.selected { 2.0 } else { 1.0 },
407                    color: if item.selected { theme.color(Semantic::FocusRing) } else { tint },
408                }),
409                shadow: None,
410            }),
411        );
412        cx.clip(node);
413        let mut semantics = Semantics::new(Role::Button).name(item.label.clone());
414        if item.selected {
415            semantics = semantics.selected(true);
416        }
417        cx.semantics(node, semantics);
418        if let Some(select) = &self.on_select {
419            let (select, id) = (select.clone(), item.id);
420            cx.on_click(node, Rc::new(move || select(id)));
421            cx.focusable(node, theme.radii.sm);
422            cx.cursor(node, CursorIcon::Pointer);
423        }
424        if let (true, Some(on_edit)) = (item.movable, self.on_edit.clone()) {
425            let (id, per_ms) = (item.id, self.per_ms);
426            let grab = cx.use_state(|| Rc::new(Cell::new(0.0_f32))).get();
427            cx.on_drag(
428                node,
429                Rc::new(move |drag: Drag| {
430                    if drag.phase == DragPhase::Start {
431                        grab.set(drag.position.x - drag.frame.x());
432                        on_edit(TimelineEdit::Began { id });
433                        return;
434                    }
435                    let x = (drag.position.x - grab.get() - drag.container.x()).max(0.0);
436                    on_edit(TimelineEdit::Moved { id, start_ms: (x / per_ms) as u64 });
437                }),
438            );
439        }
440        node
441    }
442}
443
444/// The ruler, the rows and the playhead, wider than the view when they have to be.
445struct Rows<'a>(&'a Timeline);
446
447impl View for Rows<'_> {
448    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
449        let theme = *cx.theme();
450        let timeline = self.0;
451        let per_ms = timeline.pixels_per_second / 1000.0;
452        let width = (timeline.end_ms() as f32 * per_ms + 200.0).max(cx.viewport().width);
453        let height = timeline.total_height();
454        let mut children = Vec::new();
455
456        // The ruler, under everything so that blocks and marks take their clicks.
457        let every = TICK_STEPS
458            .into_iter()
459            .find(|s| *s as f32 * timeline.pixels_per_second >= TICK_ROOM)
460            .unwrap_or(LONGEST_TICK);
461        let mut ticks = Vec::new();
462        let mut second = 0_u32;
463        while second as f32 * timeline.pixels_per_second < width {
464            let label = Text::new(clock(second)).style(TextStyle::Callout).foreground(Semantic::InkMuted);
465            let label = cx.child(&label);
466            cx.semantics(label, Semantics::hidden());
467            ticks.push(cx.container(
468                LayoutStyle {
469                    absolute: Some((second as f32 * timeline.pixels_per_second + 4.0, 4.0)),
470                    ..LayoutStyle::stack(Axis::Vertical)
471                },
472                &[label],
473                Paint::None,
474            ));
475            second += every;
476        }
477        let ruler = cx.container(
478            LayoutStyle {
479                absolute: Some((0.0, 0.0)),
480                width: Some(width),
481                height: Some(RULER_HEIGHT),
482                ..LayoutStyle::stack(Axis::Vertical)
483            },
484            &ticks,
485            Paint::None,
486        );
487        if let Some(seek) = timeline.on_seek.clone() {
488            cx.on_drag(
489                ruler,
490                Rc::new(move |drag: Drag| {
491                    let x = (drag.position.x - drag.frame.x()).max(0.0);
492                    seek((x / per_ms) as u64);
493                }),
494            );
495            cx.cursor(ruler, CursorIcon::Pointer);
496        }
497        children.push(ruler);
498
499        let mut y = RULER_HEIGHT;
500        for track in &timeline.tracks {
501            let line = cx.container(
502                LayoutStyle {
503                    absolute: Some((0.0, y)),
504                    width: Some(width),
505                    height: Some(1.0),
506                    ..LayoutStyle::stack(Axis::Vertical)
507                },
508                &[],
509                Paint::Quad(QuadPaint::new(theme.color(Semantic::Border))),
510            );
511            cx.semantics(line, Semantics::hidden());
512            children.push(line);
513            for item in &track.items {
514                let frame = Rect::new(
515                    item.start_ms as f32 * per_ms,
516                    y + 4.0,
517                    item.duration_ms as f32 * per_ms - 2.0,
518                    track.height - 8.0,
519                );
520                children.push(cx.child(&Block {
521                    item,
522                    frame,
523                    per_ms,
524                    on_select: timeline.on_select.clone(),
525                    on_edit: timeline.on_edit.clone(),
526                }));
527            }
528            y += track.height;
529        }
530
531        for marker in &timeline.markers {
532            let node = cx.container(
533                LayoutStyle {
534                    absolute: Some((
535                        marker.at_ms as f32 * per_ms - MARKER / 2.0,
536                        RULER_HEIGHT - MARKER / 2.0,
537                    )),
538                    width: Some(MARKER),
539                    height: Some(MARKER),
540                    ..LayoutStyle::stack(Axis::Vertical)
541                },
542                &[],
543                Paint::Quad(QuadPaint {
544                    corner_radius: MARKER / 2.0,
545                    ..QuadPaint::new(theme.color(Semantic::Warning))
546                }),
547            );
548            cx.semantics(node, Semantics::new(Role::Button).name(marker.title.clone()));
549            if let Some(seek) = timeline.on_seek.clone() {
550                let at = marker.at_ms;
551                cx.on_click(node, Rc::new(move || seek(at)));
552                cx.focusable(node, MARKER / 2.0);
553                cx.cursor(node, CursorIcon::Pointer);
554            }
555            children.push(node);
556        }
557
558        // The playhead, with the time where it can be read.
559        let x = timeline.position_ms as f32 * per_ms;
560        let needle = cx.container(
561            LayoutStyle {
562                absolute: Some((x - 1.0, 0.0)),
563                width: Some(2.0),
564                height: Some(height),
565                ..LayoutStyle::stack(Axis::Vertical)
566            },
567            &[],
568            Paint::Quad(QuadPaint::new(theme.color(Semantic::Danger))),
569        );
570        cx.semantics(needle, Semantics::hidden());
571        children.push(needle);
572
573        cx.container(
574            LayoutStyle { width: Some(width), height: Some(height), ..LayoutStyle::stack(Axis::Vertical) },
575            &children,
576            Paint::None,
577        )
578    }
579}
580
581impl View for Timeline {
582    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
583        let heads = cx.child(&Heads(self));
584        let rows = cx.child(&ScrollView::new((Rows(self),)).horizontal().grow(1.0));
585        let node = cx.container(
586            LayoutStyle {
587                align: Align::Stretch,
588                align_self: Some(Align::Stretch),
589                height: Some(self.total_height()),
590                ..LayoutStyle::stack(Axis::Horizontal)
591            },
592            &[heads, rows],
593            Paint::None,
594        );
595        cx.semantics(node, Semantics::new(Role::Group));
596        node
597    }
598}