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