Skip to main content

ranim/cmd/preview/
timeline.rs

1use std::{collections::HashSet, ops::Range};
2
3use egui::{
4    Align2, Color32, CornerRadius, FontId, PointerButton, Rect, Rgba, ScrollArea, Sense, Stroke,
5    TextStyle, Ui, pos2, vec2,
6};
7
8use crate::core::{AnimationInfo, AnimationInfoKind, color::palettes::manim};
9
10const HEADER_HEIGHT: f32 = 26.0;
11const TRACK_HEIGHT: f32 = 28.0;
12const TRACK_GAP: f32 = 2.0;
13const INDENT_WIDTH: f32 = 16.0;
14const MIN_VISIBLE_SECS: f64 = 0.1;
15const MIN_TREE_WIDTH: f32 = 136.0;
16const MAX_TREE_WIDTH: f32 = 420.0;
17const MIN_INTERACTIVE_CLIP_WIDTH: f32 = 3.0;
18const VIEWPORT_SCROLLBAR_HEIGHT: f32 = 14.0;
19const VIEWPORT_SCROLLBAR_GAP: f32 = 3.0;
20const MIN_VIEWPORT_THUMB_WIDTH: f32 = 28.0;
21
22type AnimationPath = Vec<usize>;
23
24#[derive(Clone)]
25struct VisibleClip {
26    path: AnimationPath,
27    global_range: Range<f64>,
28    sequence_depth: usize,
29}
30
31#[derive(Clone)]
32struct SequenceBand {
33    path: AnimationPath,
34    global_range: Range<f64>,
35    depth: usize,
36}
37
38struct VisibleTrack {
39    path: AnimationPath,
40    depth: usize,
41    global_range: Range<f64>,
42    clips: Vec<VisibleClip>,
43    sequence_bands: Vec<SequenceBand>,
44}
45
46struct ExpandedStack {
47    path: AnimationPath,
48    global_range: Range<f64>,
49}
50
51pub struct TimelineState {
52    pub total_sec: f64,
53    pub current_sec: f64,
54    width_sec: f64,
55    offset_sec: f64,
56    animation_infos: Vec<AnimationInfo>,
57    expanded: HashSet<AnimationPath>,
58    selected: Option<AnimationPath>,
59    tree_width: f32,
60    visible_tracks: Vec<VisibleTrack>,
61    tracks_dirty: bool,
62}
63
64impl TimelineState {
65    pub fn new(total_sec: f64, animation_infos: Vec<AnimationInfo>) -> Self {
66        let mut expanded = HashSet::new();
67        collect_default_expanded(&animation_infos, &mut Vec::new(), 0, &mut expanded);
68
69        let tree_width = preferred_tree_width(&animation_infos);
70
71        Self {
72            total_sec,
73            current_sec: 0.0,
74            width_sec: total_sec.max(MIN_VISIBLE_SECS),
75            offset_sec: 0.0,
76            animation_infos,
77            expanded,
78            selected: None,
79            tree_width,
80            visible_tracks: Vec::new(),
81            tracks_dirty: true,
82        }
83    }
84
85    pub fn ui_main_timeline(&mut self, ui: &mut Ui) {
86        egui::Frame::canvas(ui.style()).show(ui, |ui| {
87            let min_height =
88                HEADER_HEIGHT + TRACK_HEIGHT + VIEWPORT_SCROLLBAR_GAP + VIEWPORT_SCROLLBAR_HEIGHT;
89            let desired_size = vec2(ui.available_width(), ui.available_height().max(min_height));
90            let (timeline_rect, _) = ui.allocate_exact_size(desired_size, Sense::hover());
91            let scrollbar_top = timeline_rect.bottom() - VIEWPORT_SCROLLBAR_HEIGHT;
92            let track_viewport_rect = Rect::from_min_max(
93                timeline_rect.min,
94                pos2(
95                    timeline_rect.right(),
96                    scrollbar_top - VIEWPORT_SCROLLBAR_GAP,
97                ),
98            );
99            let mut track_ui = ui.new_child(
100                egui::UiBuilder::new()
101                    .id_salt("timeline_tracks")
102                    .max_rect(track_viewport_rect),
103            );
104            let scroll_output = ScrollArea::vertical()
105                .max_height(track_viewport_rect.height())
106                .show(&mut track_ui, |ui| {
107                    if self.tracks_dirty {
108                        self.visible_tracks = self.build_visible_tracks();
109                        self.tracks_dirty = false;
110                    }
111                    let tracks = std::mem::take(&mut self.visible_tracks);
112                    let tracks_height = tracks.len() as f32 * (TRACK_HEIGHT + TRACK_GAP);
113                    let available_height = ui.available_height().max(HEADER_HEIGHT + TRACK_HEIGHT);
114                    let content_height = (HEADER_HEIGHT + tracks_height).max(available_height);
115                    let desired_size = vec2(ui.available_width(), content_height);
116                    let (canvas, response) =
117                        ui.allocate_exact_size(desired_size, Sense::click_and_drag());
118
119                    let max_tree_width = (canvas.width() * 0.6).min(MAX_TREE_WIDTH);
120                    self.tree_width = self
121                        .tree_width
122                        .clamp(MIN_TREE_WIDTH.min(max_tree_width), max_tree_width);
123
124                    let initial_separator_x = canvas.left() + self.tree_width;
125                    let splitter_rect = Rect::from_min_max(
126                        pos2(initial_separator_x - 4.0, canvas.top()),
127                        pos2(initial_separator_x + 4.0, canvas.bottom()),
128                    );
129                    let splitter_response = ui
130                        .interact(
131                            splitter_rect,
132                            ui.id().with("timeline_tree_splitter"),
133                            Sense::click_and_drag(),
134                        )
135                        .on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
136                    if splitter_response.dragged() {
137                        self.tree_width = (self.tree_width
138                            + ui.ctx().input(|input| input.pointer.delta().x))
139                        .clamp(MIN_TREE_WIDTH, max_tree_width);
140                    }
141                    if splitter_response.double_clicked() {
142                        self.tree_width = preferred_tree_width(&self.animation_infos)
143                            .clamp(MIN_TREE_WIDTH, max_tree_width);
144                    }
145
146                    let tree_rect = Rect::from_min_max(
147                        canvas.min,
148                        pos2(
149                            (canvas.left() + self.tree_width).min(canvas.right()),
150                            canvas.bottom(),
151                        ),
152                    );
153                    let time_rect =
154                        Rect::from_min_max(pos2(tree_rect.right(), canvas.top()), canvas.max);
155
156                    self.interact_timeline(ui, &response, time_rect);
157                    self.paint_timeline(ui, canvas, tree_rect, time_rect, &tracks);
158                    if splitter_response.hovered() || splitter_response.dragged() {
159                        ui.painter().line_segment(
160                            [
161                                pos2(tree_rect.right(), canvas.top()),
162                                pos2(tree_rect.right(), canvas.bottom()),
163                            ],
164                            Stroke::new(2.0, ui.visuals().selection.stroke.color),
165                        );
166                    }
167
168                    if !self.tracks_dirty {
169                        self.visible_tracks = tracks;
170                    }
171                });
172
173            let scrollbar_rect = Rect::from_min_max(
174                pos2(timeline_rect.left(), scrollbar_top),
175                pos2(scroll_output.inner_rect.right(), timeline_rect.bottom()),
176            );
177            self.ui_viewport_scrollbar(ui, scrollbar_rect);
178        });
179    }
180
181    fn ui_viewport_scrollbar(&mut self, ui: &Ui, rect: Rect) {
182        let visuals = ui.visuals();
183        let painter = ui.painter_at(rect);
184        let tree_right = (rect.left() + self.tree_width).min(rect.right());
185
186        painter.rect_filled(rect, 0.0, visuals.panel_fill);
187        painter.line_segment(
188            [
189                pos2(rect.left(), rect.top()),
190                pos2(rect.right(), rect.top()),
191            ],
192            Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color),
193        );
194        painter.line_segment(
195            [
196                pos2(tree_right, rect.top()),
197                pos2(tree_right, rect.bottom()),
198            ],
199            Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color),
200        );
201
202        let time_rect = Rect::from_min_max(pos2(tree_right, rect.top()), rect.max);
203        let track_rect = time_rect.shrink2(vec2(6.0, 3.0));
204        if track_rect.width() <= 0.0 {
205            return;
206        }
207
208        painter.rect_filled(track_rect, CornerRadius::same(3), visuals.extreme_bg_color);
209
210        let total_sec = self.total_sec.max(MIN_VISIBLE_SECS);
211        let max_offset = (self.total_sec - self.width_sec).max(0.0);
212        let visible_ratio = (self.width_sec / total_sec).clamp(0.0, 1.0) as f32;
213        let thumb_width = (track_rect.width() * visible_ratio)
214            .max(MIN_VIEWPORT_THUMB_WIDTH.min(track_rect.width()))
215            .min(track_rect.width());
216        let thumb_travel = (track_rect.width() - thumb_width).max(0.0);
217        let offset_ratio = if max_offset > 0.0 {
218            (self.offset_sec / max_offset).clamp(0.0, 1.0) as f32
219        } else {
220            0.0
221        };
222        let thumb_left = track_rect.left() + thumb_travel * offset_ratio;
223        let thumb_rect = Rect::from_min_size(
224            pos2(thumb_left, track_rect.top()),
225            vec2(thumb_width, track_rect.height()),
226        );
227
228        let thumb_response = ui
229            .interact(
230                Rect::from_min_max(
231                    pos2(thumb_rect.left(), time_rect.top()),
232                    pos2(thumb_rect.right(), time_rect.bottom()),
233                ),
234                ui.id().with("timeline_viewport_thumb"),
235                Sense::click_and_drag(),
236            )
237            .on_hover_cursor(egui::CursorIcon::ResizeHorizontal)
238            .on_hover_text(format!(
239                "{} - {}",
240                format_time(self.offset_sec),
241                format_time(self.offset_sec + self.width_sec)
242            ));
243
244        if thumb_response.dragged() && thumb_travel > 0.0 {
245            let delta_x = ui.ctx().input(|input| input.pointer.delta().x);
246            self.offset_sec += delta_x as f64 / thumb_travel as f64 * max_offset;
247            self.clamp_viewport();
248        }
249
250        if thumb_travel > 0.0 {
251            let left_response = ui.interact(
252                Rect::from_min_max(time_rect.min, pos2(thumb_rect.left(), time_rect.bottom())),
253                ui.id().with("timeline_viewport_track_left"),
254                Sense::click(),
255            );
256            let right_response = ui.interact(
257                Rect::from_min_max(pos2(thumb_rect.right(), time_rect.top()), time_rect.max),
258                ui.id().with("timeline_viewport_track_right"),
259                Sense::click(),
260            );
261            if let Some(pointer_pos) = left_response
262                .interact_pointer_pos()
263                .filter(|_| left_response.clicked())
264                .or_else(|| {
265                    right_response
266                        .interact_pointer_pos()
267                        .filter(|_| right_response.clicked())
268                })
269            {
270                let thumb_center =
271                    (pointer_pos.x - track_rect.left() - thumb_width * 0.5) / thumb_travel;
272                self.offset_sec = thumb_center.clamp(0.0, 1.0) as f64 * max_offset;
273                self.clamp_viewport();
274            }
275        }
276
277        let thumb_color = if thumb_response.dragged() || thumb_response.hovered() {
278            visuals.widgets.hovered.bg_fill
279        } else {
280            visuals.widgets.inactive.bg_fill
281        };
282        painter.rect_filled(thumb_rect, CornerRadius::same(3), thumb_color);
283        painter.line_segment(
284            [thumb_rect.left_top(), thumb_rect.right_top()],
285            Stroke::new(1.0, Color32::WHITE.gamma_multiply(0.18)),
286        );
287
288        if self.total_sec > 0.0 {
289            let playhead_ratio = (self.current_sec / self.total_sec).clamp(0.0, 1.0) as f32;
290            let playhead_x = track_rect.left() + track_rect.width() * playhead_ratio;
291            painter.line_segment(
292                [
293                    pos2(playhead_x, track_rect.top() - 1.0),
294                    pos2(playhead_x, track_rect.bottom() + 1.0),
295                ],
296                Stroke::new(1.5, visuals.selection.stroke.color),
297            );
298        }
299    }
300
301    fn build_visible_tracks(&self) -> Vec<VisibleTrack> {
302        let mut tracks = Vec::new();
303        let mut path = Vec::new();
304        for (index, info) in self.animation_infos.iter().enumerate() {
305            path.push(index);
306            collect_tracks(
307                &self.animation_infos,
308                info,
309                &mut path,
310                0,
311                info.range.clone(),
312                &self.expanded,
313                &mut tracks,
314            );
315            path.pop();
316        }
317        tracks
318    }
319
320    fn interact_timeline(&mut self, ui: &Ui, response: &egui::Response, time_rect: Rect) {
321        let Some(pointer_pos) = response.hover_pos() else {
322            return;
323        };
324        if !time_rect.contains(pointer_pos) {
325            return;
326        }
327
328        if response.dragged_by(PointerButton::Secondary) {
329            let delta_sec =
330                response.drag_delta().x as f64 / time_rect.width() as f64 * self.width_sec;
331            self.offset_sec -= delta_sec;
332            self.clamp_viewport();
333        } else if response.dragged_by(PointerButton::Primary) || response.clicked() {
334            self.current_sec = self
335                .sec_from_x(time_rect, pointer_pos.x)
336                .clamp(0.0, self.total_sec);
337        }
338
339        let zoom_factor = ui.ctx().input(|input| input.zoom_delta_2d().x);
340        if zoom_factor != 1.0 && time_rect.width() > 0.0 {
341            let anchor_ratio = ((pointer_pos.x - time_rect.left()) / time_rect.width()) as f64;
342            let anchor_sec = self.offset_sec + anchor_ratio * self.width_sec;
343            let max_width = self.total_sec.max(MIN_VISIBLE_SECS);
344            self.width_sec = (self.width_sec / zoom_factor as f64)
345                .clamp(MIN_VISIBLE_SECS.min(max_width), max_width);
346            self.offset_sec = anchor_sec - anchor_ratio * self.width_sec;
347            self.clamp_viewport();
348        }
349    }
350
351    fn paint_timeline(
352        &mut self,
353        ui: &Ui,
354        canvas: Rect,
355        tree_rect: Rect,
356        time_rect: Rect,
357        tracks: &[VisibleTrack],
358    ) {
359        let painter = ui.painter_at(canvas);
360        let font_id = TextStyle::Body.resolve(ui.style());
361        let small_font_id = TextStyle::Small.resolve(ui.style());
362        let visuals = ui.visuals();
363
364        painter.rect_filled(tree_rect, 0.0, visuals.panel_fill);
365        painter.line_segment(
366            [
367                pos2(tree_rect.right(), canvas.top()),
368                pos2(tree_rect.right(), canvas.bottom()),
369            ],
370            Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color),
371        );
372
373        painter.text(
374            pos2(
375                tree_rect.left() + 10.0,
376                tree_rect.top() + HEADER_HEIGHT * 0.5,
377            ),
378            Align2::LEFT_CENTER,
379            "ANIMATIONS",
380            small_font_id.clone(),
381            visuals.weak_text_color(),
382        );
383        painter.line_segment(
384            [
385                pos2(canvas.left(), canvas.top() + HEADER_HEIGHT),
386                pos2(canvas.right(), canvas.top() + HEADER_HEIGHT),
387            ],
388            Stroke::new(1.0, visuals.widgets.noninteractive.bg_stroke.color),
389        );
390
391        self.paint_grid(
392            &painter,
393            time_rect,
394            canvas.bottom(),
395            &small_font_id,
396            visuals,
397        );
398
399        for (track_index, track) in tracks.iter().enumerate() {
400            let top =
401                canvas.top() + HEADER_HEIGHT + track_index as f32 * (TRACK_HEIGHT + TRACK_GAP);
402            let track_rect = Rect::from_min_max(
403                pos2(canvas.left(), top),
404                pos2(canvas.right(), top + TRACK_HEIGHT),
405            );
406            let tree_track_rect =
407                Rect::from_min_max(track_rect.min, pos2(tree_rect.right(), track_rect.bottom()));
408            let time_track_rect =
409                Rect::from_min_max(pos2(time_rect.left(), track_rect.top()), track_rect.max);
410
411            if !track_rect.intersects(painter.clip_rect()) {
412                continue;
413            }
414
415            if track_index % 2 == 1 {
416                painter.rect_filled(track_rect, 0.0, visuals.faint_bg_color);
417            }
418
419            self.paint_tree_track(
420                ui,
421                &painter,
422                tree_track_rect,
423                track,
424                &font_id,
425                &small_font_id,
426            );
427            self.paint_sequence_bands(&painter, time_track_rect, track);
428            self.paint_track_clips(ui, &painter, time_track_rect, track, &font_id);
429            self.paint_stack_rails(
430                &painter,
431                time_rect,
432                tracks,
433                track_index,
434                track,
435                canvas.top(),
436            );
437        }
438
439        let playhead_x = self.x_from_sec(time_rect, self.current_sec);
440        if time_rect.left() <= playhead_x && playhead_x <= time_rect.right() {
441            painter.line_segment(
442                [
443                    pos2(playhead_x, canvas.top()),
444                    pos2(playhead_x, canvas.bottom()),
445                ],
446                Stroke::new(1.5, visuals.selection.stroke.color),
447            );
448            let marker = [
449                pos2(playhead_x - 5.0, canvas.top()),
450                pos2(playhead_x + 5.0, canvas.top()),
451                pos2(playhead_x, canvas.top() + 6.0),
452            ];
453            painter.add(egui::Shape::convex_polygon(
454                marker.to_vec(),
455                visuals.selection.stroke.color,
456                Stroke::NONE,
457            ));
458
459            let time_text = format_time(self.current_sec);
460            let text_size = painter.layout_no_wrap(
461                time_text.clone(),
462                small_font_id.clone(),
463                visuals.text_color(),
464            );
465            let badge_width = text_size.rect.width() + 10.0;
466            let badge_left = (playhead_x - badge_width * 0.5)
467                .clamp(time_rect.left(), time_rect.right() - badge_width);
468            let badge_rect = Rect::from_min_size(
469                pos2(badge_left, canvas.top() + 3.0),
470                vec2(badge_width, HEADER_HEIGHT - 6.0),
471            );
472            painter.rect_filled(badge_rect, CornerRadius::same(3), visuals.selection.bg_fill);
473            painter.text(
474                badge_rect.center(),
475                Align2::CENTER_CENTER,
476                time_text,
477                small_font_id,
478                visuals.selection.stroke.color,
479            );
480        }
481    }
482
483    fn paint_tree_track(
484        &mut self,
485        ui: &Ui,
486        painter: &egui::Painter,
487        rect: Rect,
488        track: &VisibleTrack,
489        font_id: &FontId,
490        small_font_id: &FontId,
491    ) {
492        let info = animation_info_at_path(&self.animation_infos, &track.path);
493        let selected = self.selected.as_ref() == Some(&track.path);
494        if selected {
495            painter.rect_filled(rect, 0.0, ui.visuals().selection.bg_fill);
496        }
497
498        for depth in 0..track.depth {
499            let x = rect.left() + 14.0 + depth as f32 * INDENT_WIDTH;
500            painter.line_segment(
501                [pos2(x, rect.top()), pos2(x, rect.bottom())],
502                Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color),
503            );
504        }
505
506        let indent_x = rect.left() + 6.0 + track.depth as f32 * INDENT_WIDTH;
507        let is_expandable_stack =
508            info.kind == AnimationInfoKind::Stack && !info.children.is_empty();
509        let label_x = if is_expandable_stack {
510            let marker = if self.expanded.contains(&track.path) {
511                egui_phosphor::regular::CARET_DOWN
512            } else {
513                egui_phosphor::regular::CARET_RIGHT
514            };
515            painter.text(
516                pos2(indent_x + 8.0, rect.center().y),
517                Align2::CENTER_CENTER,
518                marker,
519                font_id.clone(),
520                ui.visuals().text_color(),
521            );
522            indent_x + 20.0
523        } else {
524            indent_x + 4.0
525        };
526
527        let duration = track.global_range.end - track.global_range.start;
528        painter.text(
529            pos2(rect.right() - 8.0, rect.center().y),
530            Align2::RIGHT_CENTER,
531            format_duration(duration),
532            small_font_id.clone(),
533            ui.visuals().weak_text_color(),
534        );
535
536        let label_rect = Rect::from_min_max(
537            pos2(label_x, rect.top()),
538            pos2(rect.right() - 54.0, rect.bottom()),
539        );
540        let text_color = if info.enabled {
541            ui.visuals().text_color()
542        } else {
543            ui.visuals().weak_text_color()
544        };
545        painter.with_clip_rect(label_rect).text(
546            pos2(label_x, rect.center().y),
547            Align2::LEFT_CENTER,
548            display_anim_name(info),
549            font_id.clone(),
550            text_color,
551        );
552
553        let response = ui
554            .interact(
555                rect,
556                ui.id().with(("animation_track", &track.path)),
557                Sense::click(),
558            )
559            .on_hover_text(format!("{}\n{}", info.anim_name, format_duration(duration)));
560        if response.clicked() {
561            self.selected = Some(track.path.clone());
562        }
563
564        if is_expandable_stack {
565            let toggle_rect = Rect::from_min_size(
566                pos2(indent_x, rect.top() + 4.0),
567                vec2(18.0, rect.height() - 8.0),
568            );
569            let toggle_response = ui.interact(
570                toggle_rect,
571                ui.id().with(("animation_track_toggle", &track.path)),
572                Sense::click(),
573            );
574            if toggle_response.clicked() {
575                toggle_path(&mut self.expanded, &track.path);
576                self.tracks_dirty = true;
577            }
578        }
579    }
580
581    fn paint_sequence_bands(
582        &self,
583        painter: &egui::Painter,
584        track_rect: Rect,
585        track: &VisibleTrack,
586    ) {
587        for band in &track.sequence_bands {
588            let start_x = self.x_from_sec(track_rect, band.global_range.start);
589            let end_x = self.x_from_sec(track_rect, band.global_range.end);
590            if track_rect.right() < start_x || end_x < track_rect.left() {
591                continue;
592            }
593
594            let vertical_inset = (2.0 + band.depth as f32 * 2.0).min(8.0);
595            let rect = Rect::from_min_max(
596                pos2(
597                    start_x.max(track_rect.left()),
598                    track_rect.top() + vertical_inset,
599                ),
600                pos2(
601                    end_x.min(track_rect.right()),
602                    track_rect.bottom() - vertical_inset,
603                ),
604            );
605            let selected = self.selected.as_ref() == Some(&band.path);
606            let color = if selected {
607                painter
608                    .ctx()
609                    .style_of(egui::Theme::Dark)
610                    .visuals
611                    .selection
612                    .stroke
613                    .color
614            } else {
615                animation_color(AnimationInfoKind::Sequence).gamma_multiply(0.55)
616            };
617            painter.rect_filled(rect, CornerRadius::same(3), color.gamma_multiply(0.12));
618            painter.line_segment(
619                [rect.left_bottom(), rect.right_bottom()],
620                Stroke::new(if selected { 2.0 } else { 1.0 }, color),
621            );
622        }
623    }
624
625    fn paint_track_clips(
626        &mut self,
627        ui: &Ui,
628        painter: &egui::Painter,
629        track_rect: Rect,
630        track: &VisibleTrack,
631        font_id: &FontId,
632    ) {
633        let mut clip_index = 0;
634        while clip_index < track.clips.len() {
635            let clip = &track.clips[clip_index];
636            let start_x = self.x_from_sec(track_rect, clip.global_range.start);
637            let end_x = self.x_from_sec(track_rect, clip.global_range.end);
638            if track_rect.right() < start_x || end_x < track_rect.left() {
639                clip_index += 1;
640                continue;
641            }
642
643            let nested_inset = (5.0 + clip.sequence_depth as f32 * 1.5).min(9.0);
644            let zero_duration =
645                (clip.global_range.end - clip.global_range.start).abs() < f64::EPSILON;
646            let visible_start = start_x.max(track_rect.left());
647            let visible_end = if zero_duration {
648                (start_x + 4.0).min(track_rect.right())
649            } else {
650                end_x.min(track_rect.right()).max(visible_start + 1.0)
651            };
652
653            if visible_end - visible_start < MIN_INTERACTIVE_CLIP_WIDTH {
654                let run_start_index = clip_index;
655                let run_start_sec = clip.global_range.start;
656                let mut run_end_sec = clip.global_range.end;
657                let mut run_end_x = visible_end;
658                let mut run_inset = nested_inset;
659                let mut run_selected = self.selected.as_ref() == Some(&clip.path);
660                clip_index += 1;
661
662                while let Some(next) = track.clips.get(clip_index) {
663                    let next_start_x = self.x_from_sec(track_rect, next.global_range.start);
664                    let next_end_x = self.x_from_sec(track_rect, next.global_range.end);
665                    if next_start_x > track_rect.right() || next_start_x > run_end_x + 1.0 {
666                        break;
667                    }
668
669                    let next_zero_duration =
670                        (next.global_range.end - next.global_range.start).abs() < f64::EPSILON;
671                    let next_visible_start = next_start_x.max(track_rect.left());
672                    let next_visible_end = if next_zero_duration {
673                        (next_start_x + 4.0).min(track_rect.right())
674                    } else {
675                        next_end_x
676                            .min(track_rect.right())
677                            .max(next_visible_start + 1.0)
678                    };
679                    if next_visible_end - next_visible_start >= MIN_INTERACTIVE_CLIP_WIDTH {
680                        break;
681                    }
682
683                    run_end_sec = next.global_range.end;
684                    run_end_x = run_end_x.max(next_visible_end);
685                    run_inset = run_inset.max((5.0 + next.sequence_depth as f32 * 1.5).min(9.0));
686                    run_selected |= self.selected.as_ref() == Some(&next.path);
687                    clip_index += 1;
688                }
689
690                let run_rect = Rect::from_min_max(
691                    pos2(visible_start, track_rect.top() + run_inset),
692                    pos2(run_end_x, track_rect.bottom() - run_inset),
693                );
694                let run_len = clip_index - run_start_index;
695                let response = ui
696                    .interact(
697                        run_rect.expand2(vec2(2.0, 3.0)),
698                        ui.id()
699                            .with(("animation_clip_cluster", &track.path, run_start_index)),
700                        Sense::click(),
701                    )
702                    .on_hover_text(format!(
703                        "{run_len} animations\n{} - {}",
704                        format_time(run_start_sec),
705                        format_time(run_end_sec)
706                    ));
707                self.paint_dense_clip_run(painter, run_rect, run_selected, response.hovered());
708                if response.clicked() {
709                    self.selected = Some(track.path.clone());
710                }
711                continue;
712            }
713
714            let info = animation_info_at_path(&self.animation_infos, &clip.path);
715            let clip_rect = Rect::from_min_max(
716                pos2(visible_start, track_rect.top() + nested_inset),
717                pos2(visible_end, track_rect.bottom() - nested_inset),
718            );
719
720            let response = ui
721                .interact(
722                    clip_rect.expand2(vec2(2.0, 3.0)),
723                    ui.id().with(("animation_clip", &clip.path)),
724                    Sense::click(),
725                )
726                .on_hover_text(format!(
727                    "{}\n{} - {}",
728                    info.anim_name,
729                    format_time(clip.global_range.start),
730                    format_time(clip.global_range.end)
731                ));
732            let selected = self.selected.as_ref() == Some(&clip.path);
733            let hovered = response.hovered();
734            self.paint_clip_body(
735                painter,
736                clip_rect,
737                info,
738                selected,
739                hovered,
740                zero_duration,
741                font_id,
742            );
743
744            if response.clicked() {
745                self.selected = Some(clip.path.clone());
746            }
747
748            if info.kind == AnimationInfoKind::Stack
749                && !info.children.is_empty()
750                && clip.path != track.path
751            {
752                let marker = if self.expanded.contains(&clip.path) {
753                    egui_phosphor::regular::CARET_DOWN
754                } else {
755                    egui_phosphor::regular::CARET_RIGHT
756                };
757                let toggle_rect = Rect::from_min_size(
758                    pos2(clip_rect.left() + 2.0, clip_rect.top()),
759                    vec2(14.0, clip_rect.height()),
760                );
761                painter.text(
762                    toggle_rect.center(),
763                    Align2::CENTER_CENTER,
764                    marker,
765                    font_id.clone(),
766                    Color32::BLACK,
767                );
768                let toggle_response = ui.interact(
769                    toggle_rect.expand(2.0),
770                    ui.id().with(("animation_clip_toggle", &clip.path)),
771                    Sense::click(),
772                );
773                if toggle_response.clicked() {
774                    toggle_path(&mut self.expanded, &clip.path);
775                    self.tracks_dirty = true;
776                }
777            }
778            clip_index += 1;
779        }
780    }
781
782    fn paint_dense_clip_run(
783        &self,
784        painter: &egui::Painter,
785        rect: Rect,
786        selected: bool,
787        hovered: bool,
788    ) {
789        let mut color = animation_color(AnimationInfoKind::Sequence).gamma_multiply(0.78);
790        if hovered {
791            color = color.gamma_multiply(1.15);
792        }
793        let border = if selected {
794            painter
795                .ctx()
796                .style_of(egui::Theme::Dark)
797                .visuals
798                .selection
799                .stroke
800                .color
801        } else {
802            color.gamma_multiply(0.72)
803        };
804        painter.rect_filled(rect, CornerRadius::same(3), border);
805        let inner = rect.shrink(if selected { 2.0 } else { 1.0 });
806        painter.rect_filled(inner, CornerRadius::same(2), color);
807        painter.line_segment(
808            [inner.left_top(), inner.right_top()],
809            Stroke::new(1.0, Color32::WHITE.gamma_multiply(0.22)),
810        );
811    }
812
813    #[allow(clippy::too_many_arguments)]
814    fn paint_clip_body(
815        &self,
816        painter: &egui::Painter,
817        rect: Rect,
818        info: &AnimationInfo,
819        selected: bool,
820        hovered: bool,
821        zero_duration: bool,
822        font_id: &FontId,
823    ) {
824        let mut color = animation_color_for_info(info);
825        if !info.enabled {
826            color = color.gamma_multiply(0.38);
827        } else if hovered {
828            color = color.gamma_multiply(1.15);
829        }
830
831        let border = if selected {
832            painter
833                .ctx()
834                .style_of(egui::Theme::Dark)
835                .visuals
836                .selection
837                .stroke
838                .color
839        } else {
840            color.gamma_multiply(0.72)
841        };
842        painter.rect_filled(rect, CornerRadius::same(4), border);
843        let inner = rect.shrink(if selected { 2.0 } else { 1.0 });
844        painter.rect_filled(inner, CornerRadius::same(3), color);
845
846        if zero_duration {
847            painter.line_segment(
848                [
849                    pos2(rect.center().x, rect.top() - 2.0),
850                    pos2(rect.center().x, rect.bottom() + 2.0),
851                ],
852                Stroke::new(2.0, color),
853            );
854            return;
855        }
856
857        painter.line_segment(
858            [inner.left_top(), inner.right_top()],
859            Stroke::new(1.0, Color32::WHITE.gamma_multiply(0.28)),
860        );
861
862        if !info.enabled {
863            let mut x = rect.left() - rect.height();
864            while x < rect.right() {
865                painter.line_segment(
866                    [pos2(x, rect.bottom()), pos2(x + rect.height(), rect.top())],
867                    Stroke::new(1.0, Color32::BLACK.gamma_multiply(0.35)),
868                );
869                x += 7.0;
870            }
871        }
872
873        let label_inset = if info.kind == AnimationInfoKind::Stack && !info.children.is_empty() {
874            17.0
875        } else {
876            5.0
877        };
878        if rect.width() > label_inset + 24.0 {
879            let label_rect = Rect::from_min_max(
880                pos2(rect.left() + label_inset, rect.top()),
881                pos2(rect.right() - 4.0, rect.bottom()),
882            );
883            painter.with_clip_rect(label_rect).text(
884                pos2(label_rect.left(), label_rect.center().y),
885                Align2::LEFT_CENTER,
886                display_anim_name(info),
887                font_id.clone(),
888                Color32::BLACK,
889            );
890        }
891    }
892
893    fn paint_stack_rails(
894        &self,
895        painter: &egui::Painter,
896        time_rect: Rect,
897        tracks: &[VisibleTrack],
898        track_index: usize,
899        track: &VisibleTrack,
900        canvas_top: f32,
901    ) {
902        for clip in &track.clips {
903            let info = animation_info_at_path(&self.animation_infos, &clip.path);
904            if info.kind != AnimationInfoKind::Stack || !self.expanded.contains(&clip.path) {
905                continue;
906            }
907
908            let last_descendant = tracks
909                .iter()
910                .enumerate()
911                .skip(track_index + 1)
912                .take_while(|(_, candidate)| is_descendant(&candidate.path, &clip.path))
913                .map(|(index, _)| index)
914                .last();
915            let Some(last_descendant) = last_descendant else {
916                continue;
917            };
918
919            let rail_top = canvas_top
920                + HEADER_HEIGHT
921                + track_index as f32 * (TRACK_HEIGHT + TRACK_GAP)
922                + TRACK_HEIGHT;
923            let rail_bottom = canvas_top
924                + HEADER_HEIGHT
925                + last_descendant as f32 * (TRACK_HEIGHT + TRACK_GAP)
926                + TRACK_HEIGHT;
927            let color = animation_color(AnimationInfoKind::Stack).gamma_multiply(0.48);
928            for sec in [clip.global_range.start, clip.global_range.end] {
929                let x = self.x_from_sec(time_rect, sec);
930                if time_rect.left() <= x && x <= time_rect.right() {
931                    painter.line_segment(
932                        [pos2(x, rail_top), pos2(x, rail_bottom)],
933                        Stroke::new(1.0, color),
934                    );
935                }
936            }
937        }
938    }
939
940    fn paint_grid(
941        &self,
942        painter: &egui::Painter,
943        time_rect: Rect,
944        bottom: f32,
945        font_id: &FontId,
946        visuals: &egui::Visuals,
947    ) {
948        if time_rect.width() <= 0.0 || self.width_sec <= 0.0 {
949            return;
950        }
951
952        let painter = painter.with_clip_rect(time_rect);
953
954        let header_rect = Rect::from_min_max(
955            time_rect.min,
956            pos2(time_rect.right(), time_rect.top() + HEADER_HEIGHT),
957        );
958        painter.rect_filled(header_rect, 0.0, visuals.panel_fill);
959
960        let step = nice_grid_step(self.width_sec, time_rect.width());
961        let first = (self.offset_sec / step).floor() as i64;
962        let last = ((self.offset_sec + self.width_sec) / step).ceil() as i64;
963        let grid_color = visuals.widgets.noninteractive.bg_stroke.color;
964        let text_color = visuals.weak_text_color();
965
966        for index in first..=last {
967            let sec = index as f64 * step;
968            let x = self.x_from_sec(time_rect, sec);
969            painter.line_segment(
970                [pos2(x, header_rect.bottom()), pos2(x, bottom)],
971                Stroke::new(1.0, grid_color.gamma_multiply(0.52)),
972            );
973            painter.text(
974                pos2(x + 5.0, header_rect.center().y),
975                Align2::LEFT_CENTER,
976                format_grid_time(sec),
977                font_id.clone(),
978                text_color,
979            );
980        }
981    }
982
983    fn x_from_sec(&self, rect: Rect, sec: f64) -> f32 {
984        rect.left() + ((sec - self.offset_sec) / self.width_sec) as f32 * rect.width()
985    }
986
987    fn sec_from_x(&self, rect: Rect, x: f32) -> f64 {
988        self.offset_sec + ((x - rect.left()) / rect.width()) as f64 * self.width_sec
989    }
990
991    fn clamp_viewport(&mut self) {
992        let max_width = self.total_sec.max(MIN_VISIBLE_SECS);
993        self.width_sec = self
994            .width_sec
995            .clamp(MIN_VISIBLE_SECS.min(max_width), max_width);
996        let max_offset = (self.total_sec - self.width_sec).max(0.0);
997        self.offset_sec = self.offset_sec.clamp(0.0, max_offset);
998    }
999}
1000
1001fn collect_default_expanded(
1002    infos: &[AnimationInfo],
1003    path: &mut AnimationPath,
1004    stack_depth: usize,
1005    expanded: &mut HashSet<AnimationPath>,
1006) {
1007    for (index, info) in infos.iter().enumerate() {
1008        path.push(index);
1009        if info.kind == AnimationInfoKind::Stack && !info.children.is_empty() && stack_depth < 2 {
1010            expanded.insert(path.clone());
1011        }
1012        let next_stack_depth = stack_depth + usize::from(info.kind == AnimationInfoKind::Stack);
1013        collect_default_expanded(&info.children, path, next_stack_depth, expanded);
1014        path.pop();
1015    }
1016}
1017
1018#[allow(clippy::too_many_arguments)]
1019fn collect_tracks(
1020    roots: &[AnimationInfo],
1021    info: &AnimationInfo,
1022    path: &mut AnimationPath,
1023    depth: usize,
1024    global_range: Range<f64>,
1025    expanded: &HashSet<AnimationPath>,
1026    output: &mut Vec<VisibleTrack>,
1027) {
1028    match info.kind {
1029        AnimationInfoKind::Sequence => {
1030            let mut clips = Vec::new();
1031            let mut sequence_bands = Vec::new();
1032            let mut expanded_stacks = Vec::new();
1033            collect_sequence_content(
1034                info,
1035                path,
1036                global_range.clone(),
1037                0,
1038                expanded,
1039                &mut clips,
1040                &mut sequence_bands,
1041                &mut expanded_stacks,
1042            );
1043            output.push(VisibleTrack {
1044                path: path.clone(),
1045                depth,
1046                global_range,
1047                clips,
1048                sequence_bands,
1049            });
1050
1051            for stack in expanded_stacks {
1052                let stack_info = animation_info_at_path(roots, &stack.path);
1053                for (index, child) in stack_info.children.iter().enumerate() {
1054                    let mut child_path = stack.path.clone();
1055                    child_path.push(index);
1056                    let child_range =
1057                        map_child_range(stack_info, &stack.global_range, &child.range);
1058                    collect_tracks(
1059                        roots,
1060                        child,
1061                        &mut child_path,
1062                        depth + 1,
1063                        child_range,
1064                        expanded,
1065                        output,
1066                    );
1067                }
1068            }
1069        }
1070        AnimationInfoKind::Stack => {
1071            output.push(VisibleTrack {
1072                path: path.clone(),
1073                depth,
1074                global_range: global_range.clone(),
1075                clips: vec![VisibleClip {
1076                    path: path.clone(),
1077                    global_range: global_range.clone(),
1078                    sequence_depth: 0,
1079                }],
1080                sequence_bands: Vec::new(),
1081            });
1082
1083            if expanded.contains(path) {
1084                for (index, child) in info.children.iter().enumerate() {
1085                    path.push(index);
1086                    let child_range = map_child_range(info, &global_range, &child.range);
1087                    collect_tracks(roots, child, path, depth + 1, child_range, expanded, output);
1088                    path.pop();
1089                }
1090            }
1091        }
1092        AnimationInfoKind::Eval | AnimationInfoKind::Static => {
1093            output.push(VisibleTrack {
1094                path: path.clone(),
1095                depth,
1096                global_range: global_range.clone(),
1097                clips: vec![VisibleClip {
1098                    path: path.clone(),
1099                    global_range,
1100                    sequence_depth: 0,
1101                }],
1102                sequence_bands: Vec::new(),
1103            });
1104        }
1105    }
1106}
1107
1108#[allow(clippy::too_many_arguments)]
1109fn collect_sequence_content(
1110    info: &AnimationInfo,
1111    path: &mut AnimationPath,
1112    global_range: Range<f64>,
1113    sequence_depth: usize,
1114    expanded: &HashSet<AnimationPath>,
1115    clips: &mut Vec<VisibleClip>,
1116    bands: &mut Vec<SequenceBand>,
1117    expanded_stacks: &mut Vec<ExpandedStack>,
1118) {
1119    bands.push(SequenceBand {
1120        path: path.clone(),
1121        global_range: global_range.clone(),
1122        depth: sequence_depth,
1123    });
1124
1125    for (index, child) in info.children.iter().enumerate() {
1126        path.push(index);
1127        let child_range = map_child_range(info, &global_range, &child.range);
1128        if child.kind == AnimationInfoKind::Sequence {
1129            collect_sequence_content(
1130                child,
1131                path,
1132                child_range,
1133                sequence_depth + 1,
1134                expanded,
1135                clips,
1136                bands,
1137                expanded_stacks,
1138            );
1139        } else {
1140            clips.push(VisibleClip {
1141                path: path.clone(),
1142                global_range: child_range.clone(),
1143                sequence_depth,
1144            });
1145            if child.kind == AnimationInfoKind::Stack && expanded.contains(path) {
1146                expanded_stacks.push(ExpandedStack {
1147                    path: path.clone(),
1148                    global_range: child_range,
1149                });
1150            }
1151        }
1152        path.pop();
1153    }
1154}
1155
1156fn map_child_range(
1157    parent: &AnimationInfo,
1158    parent_global_range: &Range<f64>,
1159    child_range: &Range<f64>,
1160) -> Range<f64> {
1161    if parent.content_duration_secs <= 0.0 {
1162        return parent_global_range.start..parent_global_range.start;
1163    }
1164
1165    let duration = parent_global_range.end - parent_global_range.start;
1166    let start_alpha = child_range.start / parent.content_duration_secs;
1167    let end_alpha = child_range.end / parent.content_duration_secs;
1168    parent_global_range.start + duration * start_alpha
1169        ..parent_global_range.start + duration * end_alpha
1170}
1171
1172fn animation_info_at_path<'a>(roots: &'a [AnimationInfo], path: &[usize]) -> &'a AnimationInfo {
1173    let mut info = &roots[path[0]];
1174    for &index in &path[1..] {
1175        info = &info.children[index];
1176    }
1177    info
1178}
1179
1180fn is_descendant(candidate: &[usize], parent: &[usize]) -> bool {
1181    candidate.len() > parent.len() && candidate.starts_with(parent)
1182}
1183
1184fn toggle_path(expanded: &mut HashSet<AnimationPath>, path: &[usize]) {
1185    if !expanded.remove(path) {
1186        expanded.insert(path.to_vec());
1187    }
1188}
1189
1190fn display_anim_name(info: &AnimationInfo) -> &str {
1191    if info.kind == AnimationInfoKind::Static {
1192        "Hold"
1193    } else {
1194        short_anim_name(&info.anim_name)
1195    }
1196}
1197
1198fn short_anim_name(name: &str) -> &str {
1199    let outer_type = name.split('<').next().unwrap_or(name);
1200    let short = outer_type.rsplit("::").next().unwrap_or(outer_type);
1201    if short == "{{closure}}" {
1202        "Eval"
1203    } else {
1204        short
1205    }
1206}
1207
1208fn preferred_tree_width(infos: &[AnimationInfo]) -> f32 {
1209    fn required_width(infos: &[AnimationInfo], stack_depth: usize, widest: &mut f32) {
1210        for info in infos {
1211            if info.kind != AnimationInfoKind::Sequence || !info.children.is_empty() {
1212                let indent = stack_depth as f32 * INDENT_WIDTH;
1213                let toggle = if info.kind == AnimationInfoKind::Stack && !info.children.is_empty() {
1214                    20.0
1215                } else {
1216                    4.0
1217                };
1218                let label = display_anim_name(info).chars().count() as f32 * 7.25;
1219                let duration_column = 54.0;
1220                *widest = widest.max(6.0 + indent + toggle + label + duration_column);
1221            }
1222            let child_depth = stack_depth + usize::from(info.kind == AnimationInfoKind::Stack);
1223            required_width(&info.children, child_depth, widest);
1224        }
1225    }
1226
1227    let mut widest = MIN_TREE_WIDTH;
1228    required_width(infos, 0, &mut widest);
1229    widest.clamp(MIN_TREE_WIDTH, 320.0)
1230}
1231
1232fn animation_color_for_info(info: &AnimationInfo) -> Color32 {
1233    if info.kind == AnimationInfoKind::Eval && info.anim_name.contains("::Static<") {
1234        animation_color(AnimationInfoKind::Static)
1235    } else {
1236        animation_color(info.kind)
1237    }
1238}
1239
1240fn animation_color(kind: AnimationInfoKind) -> Color32 {
1241    let rgba = match kind {
1242        AnimationInfoKind::Eval => manim::BLUE_C.to_rgba8(),
1243        AnimationInfoKind::Sequence => manim::TEAL_C.to_rgba8(),
1244        AnimationInfoKind::Stack => manim::ORANGE.to_rgba8(),
1245        AnimationInfoKind::Static => manim::YELLOW_C.to_rgba8(),
1246    };
1247    Rgba::from_srgba_unmultiplied(rgba.r, rgba.g, rgba.b, rgba.a).into()
1248}
1249
1250fn nice_grid_step(width_sec: f64, width_points: f32) -> f64 {
1251    let target_lines = (width_points / 90.0).max(1.0) as f64;
1252    let raw_step = width_sec / target_lines;
1253    let magnitude = 10.0f64.powf(raw_step.log10().floor());
1254    let normalized = raw_step / magnitude;
1255    let factor = if normalized <= 1.0 {
1256        1.0
1257    } else if normalized <= 2.0 {
1258        2.0
1259    } else if normalized <= 5.0 {
1260        5.0
1261    } else {
1262        10.0
1263    };
1264    factor * magnitude
1265}
1266
1267fn format_grid_time(sec: f64) -> String {
1268    if sec.abs() >= 10.0 {
1269        format!("{sec:.1}s")
1270    } else {
1271        format!("{sec:.2}s")
1272    }
1273}
1274
1275fn format_duration(sec: f64) -> String {
1276    if sec >= 10.0 {
1277        format!("{sec:.1}s")
1278    } else {
1279        format!("{sec:.3}s")
1280    }
1281}
1282
1283fn format_time(sec: f64) -> String {
1284    format!("{sec:.3}s")
1285}