ui_storybook/components/editor/
dope_sheet.rs1use 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 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 #[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}