1use 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;
17const 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#[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 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 pub fn tone(mut self, tone: Tone) -> Self {
64 self.tone = tone;
65 self
66 }
67
68 pub fn selected(mut self, selected: bool) -> Self {
70 self.selected = selected;
71 self
72 }
73
74 pub fn movable(mut self) -> Self {
76 self.movable = true;
77 self
78 }
79
80 pub fn trimmable(mut self) -> Self {
82 self.trimmable = true;
83 self
84 }
85
86 pub fn leading(mut self, view: impl View + 'static) -> Self {
88 self.leading = Some(Box::new(view));
89 self
90 }
91}
92
93#[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 pub fn new(label: impl Into<String>, height: f32, items: Vec<TimelineItem>) -> Self {
106 Self { label: label.into(), height, items }
107 }
108}
109
110#[derive(Clone, Debug, PartialEq)]
112#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
113pub struct TimelineMarker {
114 pub at_ms: u64,
116 pub title: String,
118}
119
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122#[non_exhaustive]
123#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
124pub enum TimelineEdit {
125 Began {
127 id: u64,
129 },
130 Moved {
132 id: u64,
134 start_ms: u64,
136 },
137 TrimmedStart {
139 id: u64,
141 by_ms: i64,
143 },
144 TrimmedEnd {
146 id: u64,
148 by_ms: i64,
150 },
151}
152
153fn clock(seconds: u32) -> String {
155 format!("{}:{:02}", seconds / 60, seconds % 60)
156}
157
158#[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 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 pub fn markers(mut self, markers: Vec<TimelineMarker>) -> Self {
212 self.markers = markers;
213 self
214 }
215
216 pub fn position_ms(mut self, ms: u64) -> Self {
218 self.position_ms = ms;
219 self
220 }
221
222 pub fn pixels_per_second(mut self, pixels: f32) -> Self {
224 self.pixels_per_second = pixels.max(1.0);
225 self
226 }
227
228 pub fn heads_width(mut self, width: f32) -> Self {
230 self.heads_width = width;
231 self
232 }
233
234 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 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 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 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
267struct 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
299struct 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 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
349struct 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
444struct 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 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 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}