Skip to main content

ui_storybook/components/editor/
dope_sheet.rs

1//! `DopeSheet` — net-new component, the editor's timeline.
2//!
3//! A dope sheet is the workhorse of any keyframe editor: rows are tracks
4//! (video, cursor, audio, captions, …), columns are time, dots are keyframes,
5//! and a vertical line marks the playhead. We intentionally keep this purely
6//! presentational for now — interaction (drag-to-move, snap-to-frame, scrub)
7//! lives behind a future signal-driven variant.
8//!
9//! Layout: a left labels column at fixed width, then a flexible timeline area
10//! that contains a ruler row plus one row per track. Keyframes are absolutely
11//! positioned by percentage of total duration, so the same component scales
12//! cleanly to any container width without re-layout math.
13
14use leptos::prelude::*;
15
16#[derive(Clone, Debug)]
17pub struct DopeSheetTrack {
18    pub label: String,
19    pub kind: TrackKind,
20    pub keyframes: Vec<DopeSheetKeyframe>,
21}
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub enum TrackKind {
25    #[default]
26    Video,
27    Cursor,
28    Audio,
29    Caption,
30    Effect,
31}
32
33impl TrackKind {
34    fn css(self) -> &'static str {
35        match self {
36            TrackKind::Video => "track-video",
37            TrackKind::Cursor => "track-cursor",
38            TrackKind::Audio => "track-audio",
39            TrackKind::Caption => "track-caption",
40            TrackKind::Effect => "track-effect",
41        }
42    }
43
44    fn glyph(self) -> &'static str {
45        match self {
46            TrackKind::Video => "▣",
47            TrackKind::Cursor => "↗",
48            TrackKind::Audio => "♪",
49            TrackKind::Caption => "T",
50            TrackKind::Effect => "✦",
51        }
52    }
53}
54
55#[derive(Clone, Copy, Debug)]
56pub struct DopeSheetKeyframe {
57    /// Time in seconds (clamped to `duration_seconds` at render time).
58    pub time: f32,
59    pub kind: KeyframeKind,
60}
61
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
63pub enum KeyframeKind {
64    #[default]
65    Hold,
66    Linear,
67    Ease,
68    Marker,
69}
70
71impl KeyframeKind {
72    fn css(self) -> &'static str {
73        match self {
74            KeyframeKind::Hold => "kf-hold",
75            KeyframeKind::Linear => "kf-linear",
76            KeyframeKind::Ease => "kf-ease",
77            KeyframeKind::Marker => "kf-marker",
78        }
79    }
80}
81
82#[component]
83pub fn DopeSheet(
84    tracks: Vec<DopeSheetTrack>,
85    #[prop(optional)] duration_seconds: f32,
86    #[prop(optional)] playhead_seconds: f32,
87) -> impl IntoView {
88    let duration = if duration_seconds <= 0.0 {
89        8.0
90    } else {
91        duration_seconds
92    };
93    let playhead_pct = (playhead_seconds / duration).clamp(0.0, 1.0) * 100.0;
94
95    // Whole-second tick marks on the ruler.
96    #[allow(
97        clippy::cast_possible_truncation,
98        clippy::cast_sign_loss,
99        reason = "duration is small + non-negative; ceil keeps the trailing tick"
100    )]
101    let tick_count = duration.ceil() as usize + 1;
102    let ticks: Vec<(usize, f32)> = (0..tick_count)
103        .map(|i| {
104            #[allow(
105                clippy::cast_precision_loss,
106                reason = "tick_count fits in f32 without loss"
107            )]
108            let pct = (i as f32 / duration).clamp(0.0, 1.0) * 100.0;
109            (i, pct)
110        })
111        .collect();
112
113    let track_views = tracks
114        .into_iter()
115        .map(|track| {
116            let kind_css = track.kind.css();
117            let glyph = track.kind.glyph();
118            let label = track.label.clone();
119            let keyframes = track
120                .keyframes
121                .into_iter()
122                .map(|kf| {
123                    let pct = (kf.time / duration).clamp(0.0, 1.0) * 100.0;
124                    let class = format!("dope-keyframe {}", kf.kind.css());
125                    let style = format!("left: {pct:.2}%");
126                    view! { <div class=class style=style></div> }
127                })
128                .collect_view();
129            view! {
130                <div class=format!("dope-row {kind_css}")>
131                    <div class="dope-label">
132                        <span class="dope-label-glyph">{glyph}</span>
133                        <span class="dope-label-text">{label}</span>
134                    </div>
135                    <div class="dope-track">
136                        {keyframes}
137                    </div>
138                </div>
139            }
140        })
141        .collect_view();
142
143    let tick_views = ticks
144        .into_iter()
145        .map(|(i, pct)| {
146            let style = format!("left: {pct:.2}%");
147            view! {
148                <div class="dope-tick" style=style>
149                    <span class="dope-tick-label">{format!("{i}s")}</span>
150                </div>
151            }
152        })
153        .collect_view();
154
155    let playhead_style = format!("left: {playhead_pct:.2}%");
156
157    view! {
158        <div class="dope-sheet">
159            <div class="dope-row dope-ruler">
160                <div class="dope-label dope-label-spacer"></div>
161                <div class="dope-track dope-ruler-track">
162                    {tick_views}
163                </div>
164            </div>
165            {track_views}
166            <div class="dope-playhead" style=playhead_style></div>
167        </div>
168    }
169}