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    serial_depth: usize,
32}
33
34#[derive(Clone)]
35struct SerialBand {
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    serial_bands: Vec<SerialBand>,
47}
48
49struct ExpandedContainer {
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_serial_bands(&painter, time_track_rect, track);
431            self.paint_track_clips(ui, &painter, time_track_rect, track, &font_id);
432            self.paint_container_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 expandable = is_expandable_container(info) && !info.children.is_empty();
511        let label_x = if expandable {
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            short_anim_name(&info.anim_name),
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 expandable {
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_serial_bands(&self, painter: &egui::Painter, track_rect: Rect, track: &VisibleTrack) {
584        for band in &track.serial_bands {
585            let start_x = self.x_from_sec(track_rect, band.global_range.start);
586            let end_x = self.x_from_sec(track_rect, band.global_range.end);
587            if track_rect.right() < start_x || end_x < track_rect.left() {
588                continue;
589            }
590
591            let vertical_inset = (2.0 + band.depth as f32 * 2.0).min(8.0);
592            let rect = Rect::from_min_max(
593                pos2(
594                    start_x.max(track_rect.left()),
595                    track_rect.top() + vertical_inset,
596                ),
597                pos2(
598                    end_x.min(track_rect.right()),
599                    track_rect.bottom() - vertical_inset,
600                ),
601            );
602            let selected = self.selected.as_ref() == Some(&band.path);
603            let color = if selected {
604                painter
605                    .ctx()
606                    .style_of(egui::Theme::Dark)
607                    .visuals
608                    .selection
609                    .stroke
610                    .color
611            } else {
612                animation_color(AnimationInfoKind::Children).gamma_multiply(0.55)
613            };
614            painter.rect_filled(rect, CornerRadius::same(3), color.gamma_multiply(0.12));
615            painter.line_segment(
616                [rect.left_bottom(), rect.right_bottom()],
617                Stroke::new(if selected { 2.0 } else { 1.0 }, color),
618            );
619        }
620    }
621
622    fn paint_track_clips(
623        &mut self,
624        ui: &Ui,
625        painter: &egui::Painter,
626        track_rect: Rect,
627        track: &VisibleTrack,
628        font_id: &FontId,
629    ) {
630        let mut clip_index = 0;
631        while clip_index < track.clips.len() {
632            let clip = &track.clips[clip_index];
633            let start_x = self.x_from_sec(track_rect, clip.global_range.start);
634            let end_x = self.x_from_sec(track_rect, clip.global_range.end);
635            if track_rect.right() < start_x || end_x < track_rect.left() {
636                clip_index += 1;
637                continue;
638            }
639
640            let nested_inset = (5.0 + clip.serial_depth as f32 * 1.5).min(9.0);
641            let zero_duration =
642                (clip.global_range.end - clip.global_range.start).abs() < f64::EPSILON;
643            let visible_start = start_x.max(track_rect.left());
644            let visible_end = if zero_duration {
645                (start_x + 4.0).min(track_rect.right())
646            } else {
647                end_x.min(track_rect.right()).max(visible_start + 1.0)
648            };
649
650            if visible_end - visible_start < MIN_INTERACTIVE_CLIP_WIDTH {
651                let run_start_index = clip_index;
652                let run_start_sec = clip.global_range.start;
653                let mut run_end_sec = clip.global_range.end;
654                let mut run_end_x = visible_end;
655                let mut run_inset = nested_inset;
656                let mut run_selected = self.selected.as_ref() == Some(&clip.path);
657                clip_index += 1;
658
659                while let Some(next) = track.clips.get(clip_index) {
660                    let next_start_x = self.x_from_sec(track_rect, next.global_range.start);
661                    let next_end_x = self.x_from_sec(track_rect, next.global_range.end);
662                    if next_start_x > track_rect.right() || next_start_x > run_end_x + 1.0 {
663                        break;
664                    }
665
666                    let next_zero_duration =
667                        (next.global_range.end - next.global_range.start).abs() < f64::EPSILON;
668                    let next_visible_start = next_start_x.max(track_rect.left());
669                    let next_visible_end = if next_zero_duration {
670                        (next_start_x + 4.0).min(track_rect.right())
671                    } else {
672                        next_end_x
673                            .min(track_rect.right())
674                            .max(next_visible_start + 1.0)
675                    };
676                    if next_visible_end - next_visible_start >= MIN_INTERACTIVE_CLIP_WIDTH {
677                        break;
678                    }
679
680                    run_end_sec = next.global_range.end;
681                    run_end_x = run_end_x.max(next_visible_end);
682                    run_inset = run_inset.max((5.0 + next.serial_depth as f32 * 1.5).min(9.0));
683                    run_selected |= self.selected.as_ref() == Some(&next.path);
684                    clip_index += 1;
685                }
686
687                let run_rect = Rect::from_min_max(
688                    pos2(visible_start, track_rect.top() + run_inset),
689                    pos2(run_end_x, track_rect.bottom() - run_inset),
690                );
691                let run_len = clip_index - run_start_index;
692                let response = ui
693                    .interact(
694                        run_rect.expand2(vec2(2.0, 3.0)),
695                        ui.id()
696                            .with(("animation_clip_cluster", &track.path, run_start_index)),
697                        Sense::click(),
698                    )
699                    .on_hover_text(format!(
700                        "{run_len} animations\n{} - {}",
701                        format_time(run_start_sec),
702                        format_time(run_end_sec)
703                    ));
704                self.paint_dense_clip_run(painter, run_rect, run_selected, response.hovered());
705                if response.clicked() {
706                    self.selected = Some(track.path.clone());
707                }
708                continue;
709            }
710
711            let info = animation_info_at_path(&self.animation_infos, &clip.path);
712            let clip_rect = Rect::from_min_max(
713                pos2(visible_start, track_rect.top() + nested_inset),
714                pos2(visible_end, track_rect.bottom() - nested_inset),
715            );
716
717            let response = ui
718                .interact(
719                    clip_rect.expand2(vec2(2.0, 3.0)),
720                    ui.id().with(("animation_clip", &clip.path)),
721                    Sense::click(),
722                )
723                .on_hover_text(format!(
724                    "{}\n{} - {}",
725                    info.anim_name,
726                    format_time(clip.global_range.start),
727                    format_time(clip.global_range.end)
728                ));
729            let selected = self.selected.as_ref() == Some(&clip.path);
730            let hovered = response.hovered();
731            self.paint_clip_body(
732                painter,
733                clip_rect,
734                info,
735                selected,
736                hovered,
737                zero_duration,
738                font_id,
739            );
740
741            if response.clicked() {
742                self.selected = Some(clip.path.clone());
743            }
744
745            if is_expandable_container(info) && !info.children.is_empty() && clip.path != track.path
746            {
747                let marker = if self.expanded.contains(&clip.path) {
748                    egui_phosphor::regular::CARET_DOWN
749                } else {
750                    egui_phosphor::regular::CARET_RIGHT
751                };
752                let toggle_rect = Rect::from_min_size(
753                    pos2(clip_rect.left() + 2.0, clip_rect.top()),
754                    vec2(14.0, clip_rect.height()),
755                );
756                painter.text(
757                    toggle_rect.center(),
758                    Align2::CENTER_CENTER,
759                    marker,
760                    font_id.clone(),
761                    Color32::BLACK,
762                );
763                let toggle_response = ui.interact(
764                    toggle_rect.expand(2.0),
765                    ui.id().with(("animation_clip_toggle", &clip.path)),
766                    Sense::click(),
767                );
768                if toggle_response.clicked() {
769                    toggle_path(&mut self.expanded, &clip.path);
770                    self.tracks_dirty = true;
771                }
772            }
773            clip_index += 1;
774        }
775    }
776
777    fn paint_dense_clip_run(
778        &self,
779        painter: &egui::Painter,
780        rect: Rect,
781        selected: bool,
782        hovered: bool,
783    ) {
784        let mut color = animation_color(AnimationInfoKind::Children).gamma_multiply(0.78);
785        if hovered {
786            color = color.gamma_multiply(1.15);
787        }
788        let border = if selected {
789            painter
790                .ctx()
791                .style_of(egui::Theme::Dark)
792                .visuals
793                .selection
794                .stroke
795                .color
796        } else {
797            color.gamma_multiply(0.72)
798        };
799        painter.rect_filled(rect, CornerRadius::same(3), border);
800        let inner = rect.shrink(if selected { 2.0 } else { 1.0 });
801        painter.rect_filled(inner, CornerRadius::same(2), color);
802        painter.line_segment(
803            [inner.left_top(), inner.right_top()],
804            Stroke::new(1.0, Color32::WHITE.gamma_multiply(0.22)),
805        );
806    }
807
808    #[allow(clippy::too_many_arguments)]
809    fn paint_clip_body(
810        &self,
811        painter: &egui::Painter,
812        rect: Rect,
813        info: &AnimationInfo,
814        selected: bool,
815        hovered: bool,
816        zero_duration: bool,
817        font_id: &FontId,
818    ) {
819        let mut color = animation_color_for_info(info);
820        if !info.enabled {
821            color = color.gamma_multiply(0.38);
822        } else if hovered {
823            color = color.gamma_multiply(1.15);
824        }
825
826        let border = if selected {
827            painter
828                .ctx()
829                .style_of(egui::Theme::Dark)
830                .visuals
831                .selection
832                .stroke
833                .color
834        } else {
835            color.gamma_multiply(0.72)
836        };
837        painter.rect_filled(rect, CornerRadius::same(4), border);
838        let inner = rect.shrink(if selected { 2.0 } else { 1.0 });
839        painter.rect_filled(inner, CornerRadius::same(3), color);
840
841        if zero_duration {
842            painter.line_segment(
843                [
844                    pos2(rect.center().x, rect.top() - 2.0),
845                    pos2(rect.center().x, rect.bottom() + 2.0),
846                ],
847                Stroke::new(2.0, color),
848            );
849            return;
850        }
851
852        painter.line_segment(
853            [inner.left_top(), inner.right_top()],
854            Stroke::new(1.0, Color32::WHITE.gamma_multiply(0.28)),
855        );
856
857        if !info.enabled {
858            let mut x = rect.left() - rect.height();
859            while x < rect.right() {
860                painter.line_segment(
861                    [pos2(x, rect.bottom()), pos2(x + rect.height(), rect.top())],
862                    Stroke::new(1.0, Color32::BLACK.gamma_multiply(0.35)),
863                );
864                x += 7.0;
865            }
866        }
867
868        let label_inset = if is_expandable_container(info) && !info.children.is_empty() {
869            17.0
870        } else {
871            5.0
872        };
873        if rect.width() > label_inset + 24.0 {
874            let label_rect = Rect::from_min_max(
875                pos2(rect.left() + label_inset, rect.top()),
876                pos2(rect.right() - 4.0, rect.bottom()),
877            );
878            painter.with_clip_rect(label_rect).text(
879                pos2(label_rect.left(), label_rect.center().y),
880                Align2::LEFT_CENTER,
881                short_anim_name(&info.anim_name),
882                font_id.clone(),
883                Color32::BLACK,
884            );
885        }
886    }
887
888    fn paint_container_rails(
889        &self,
890        painter: &egui::Painter,
891        time_rect: Rect,
892        tracks: &[VisibleTrack],
893        track_index: usize,
894        track: &VisibleTrack,
895        canvas_top: f32,
896    ) {
897        for clip in &track.clips {
898            let info = animation_info_at_path(&self.animation_infos, &clip.path);
899            if !is_expandable_container(info) || !self.expanded.contains(&clip.path) {
900                continue;
901            }
902
903            let last_descendant = tracks
904                .iter()
905                .enumerate()
906                .skip(track_index + 1)
907                .take_while(|(_, candidate)| is_descendant(&candidate.path, &clip.path))
908                .map(|(index, _)| index)
909                .last();
910            let Some(last_descendant) = last_descendant else {
911                continue;
912            };
913
914            let rail_top = canvas_top
915                + HEADER_HEIGHT
916                + track_index as f32 * (TRACK_HEIGHT + TRACK_GAP)
917                + TRACK_HEIGHT;
918            let rail_bottom = canvas_top
919                + HEADER_HEIGHT
920                + last_descendant as f32 * (TRACK_HEIGHT + TRACK_GAP)
921                + TRACK_HEIGHT;
922            let color = animation_color(info.kind).gamma_multiply(0.48);
923            for sec in [clip.global_range.start, clip.global_range.end] {
924                let x = self.x_from_sec(time_rect, sec);
925                if time_rect.left() <= x && x <= time_rect.right() {
926                    painter.line_segment(
927                        [pos2(x, rail_top), pos2(x, rail_bottom)],
928                        Stroke::new(1.0, color),
929                    );
930                }
931            }
932        }
933    }
934
935    fn paint_grid(
936        &self,
937        painter: &egui::Painter,
938        time_rect: Rect,
939        bottom: f32,
940        font_id: &FontId,
941        visuals: &egui::Visuals,
942    ) {
943        if time_rect.width() <= 0.0 || self.width_sec <= 0.0 {
944            return;
945        }
946
947        let painter = painter.with_clip_rect(time_rect);
948
949        let header_rect = Rect::from_min_max(
950            time_rect.min,
951            pos2(time_rect.right(), time_rect.top() + HEADER_HEIGHT),
952        );
953        painter.rect_filled(header_rect, 0.0, visuals.panel_fill);
954
955        let step = nice_grid_step(self.width_sec, time_rect.width());
956        let first = (self.offset_sec / step).floor() as i64;
957        let last = ((self.offset_sec + self.width_sec) / step).ceil() as i64;
958        let grid_color = visuals.widgets.noninteractive.bg_stroke.color;
959        let text_color = visuals.weak_text_color();
960
961        for index in first..=last {
962            let sec = index as f64 * step;
963            let x = self.x_from_sec(time_rect, sec);
964            painter.line_segment(
965                [pos2(x, header_rect.bottom()), pos2(x, bottom)],
966                Stroke::new(1.0, grid_color.gamma_multiply(0.52)),
967            );
968            painter.text(
969                pos2(x + 5.0, header_rect.center().y),
970                Align2::LEFT_CENTER,
971                format_grid_time(sec),
972                font_id.clone(),
973                text_color,
974            );
975        }
976    }
977
978    fn x_from_sec(&self, rect: Rect, sec: f64) -> f32 {
979        rect.left() + ((sec - self.offset_sec) / self.width_sec) as f32 * rect.width()
980    }
981
982    fn sec_from_x(&self, rect: Rect, x: f32) -> f64 {
983        self.offset_sec + ((x - rect.left()) / rect.width()) as f64 * self.width_sec
984    }
985
986    fn clamp_viewport(&mut self) {
987        let max_width = self.total_sec.max(MIN_VISIBLE_SECS);
988        self.width_sec = self
989            .width_sec
990            .clamp(MIN_VISIBLE_SECS.min(max_width), max_width);
991        let max_offset = (self.total_sec - self.width_sec).max(0.0);
992        self.offset_sec = self.offset_sec.clamp(0.0, max_offset);
993    }
994}
995
996fn collect_default_expanded(
997    infos: &[AnimationInfo],
998    path: &mut AnimationPath,
999    container_depth: usize,
1000    expanded: &mut HashSet<AnimationPath>,
1001) {
1002    for (index, info) in infos.iter().enumerate() {
1003        path.push(index);
1004        if is_expandable_container(info) && !info.children.is_empty() && container_depth < 2 {
1005            expanded.insert(path.clone());
1006        }
1007        let next_container_depth = container_depth + usize::from(is_expandable_container(info));
1008        collect_default_expanded(&info.children, path, next_container_depth, expanded);
1009        path.pop();
1010    }
1011}
1012
1013#[allow(clippy::too_many_arguments)]
1014fn collect_tracks(
1015    roots: &[AnimationInfo],
1016    info: &AnimationInfo,
1017    path: &mut AnimationPath,
1018    depth: usize,
1019    global_range: Range<f64>,
1020    expanded: &HashSet<AnimationPath>,
1021    output: &mut Vec<VisibleTrack>,
1022) {
1023    if is_serial_container(info) {
1024        let mut clips = Vec::new();
1025        let mut serial_bands = Vec::new();
1026        let mut expanded_containers = Vec::new();
1027        collect_serial_content(
1028            info,
1029            path,
1030            global_range.clone(),
1031            0,
1032            expanded,
1033            &mut clips,
1034            &mut serial_bands,
1035            &mut expanded_containers,
1036        );
1037        output.push(VisibleTrack {
1038            path: path.clone(),
1039            depth,
1040            global_range,
1041            clips,
1042            serial_bands,
1043        });
1044
1045        for container in expanded_containers {
1046            let container_info = animation_info_at_path(roots, &container.path);
1047            for (index, child) in container_info.children.iter().enumerate() {
1048                let mut child_path = container.path.clone();
1049                child_path.push(index);
1050                let child_range =
1051                    map_child_range(container_info, &container.global_range, &child.range);
1052                collect_tracks(
1053                    roots,
1054                    child,
1055                    &mut child_path,
1056                    depth + 1,
1057                    child_range,
1058                    expanded,
1059                    output,
1060                );
1061            }
1062        }
1063    } else {
1064        output.push(VisibleTrack {
1065            path: path.clone(),
1066            depth,
1067            global_range: global_range.clone(),
1068            clips: vec![VisibleClip {
1069                path: path.clone(),
1070                global_range: global_range.clone(),
1071                serial_depth: 0,
1072            }],
1073            serial_bands: Vec::new(),
1074        });
1075        if info.kind == AnimationInfoKind::Children && expanded.contains(path) {
1076            for (index, child) in info.children.iter().enumerate() {
1077                path.push(index);
1078                let child_range = map_child_range(info, &global_range, &child.range);
1079                collect_tracks(roots, child, path, depth + 1, child_range, expanded, output);
1080                path.pop();
1081            }
1082        }
1083    }
1084}
1085
1086#[allow(clippy::too_many_arguments)]
1087fn collect_serial_content(
1088    info: &AnimationInfo,
1089    path: &mut AnimationPath,
1090    global_range: Range<f64>,
1091    serial_depth: usize,
1092    expanded: &HashSet<AnimationPath>,
1093    clips: &mut Vec<VisibleClip>,
1094    bands: &mut Vec<SerialBand>,
1095    expanded_containers: &mut Vec<ExpandedContainer>,
1096) {
1097    bands.push(SerialBand {
1098        path: path.clone(),
1099        global_range: global_range.clone(),
1100        depth: serial_depth,
1101    });
1102
1103    for (index, child) in info.children.iter().enumerate() {
1104        path.push(index);
1105        let child_range = map_child_range(info, &global_range, &child.range);
1106        if is_serial_container(child) {
1107            collect_serial_content(
1108                child,
1109                path,
1110                child_range,
1111                serial_depth + 1,
1112                expanded,
1113                clips,
1114                bands,
1115                expanded_containers,
1116            );
1117        } else {
1118            clips.push(VisibleClip {
1119                path: path.clone(),
1120                global_range: child_range.clone(),
1121                serial_depth,
1122            });
1123            if is_expandable_container(child) && expanded.contains(path) {
1124                expanded_containers.push(ExpandedContainer {
1125                    path: path.clone(),
1126                    global_range: child_range,
1127                });
1128            }
1129        }
1130        path.pop();
1131    }
1132}
1133
1134fn map_child_range(
1135    parent: &AnimationInfo,
1136    parent_global_range: &Range<f64>,
1137    child_range: &Range<f64>,
1138) -> Range<f64> {
1139    if parent.content_duration_secs <= 0.0 {
1140        return parent_global_range.start..parent_global_range.start;
1141    }
1142
1143    let duration = parent_global_range.end - parent_global_range.start;
1144    let start_alpha = child_range.start / parent.content_duration_secs;
1145    let end_alpha = child_range.end / parent.content_duration_secs;
1146    parent_global_range.start + duration * start_alpha
1147        ..parent_global_range.start + duration * end_alpha
1148}
1149
1150fn animation_info_at_path<'a>(roots: &'a [AnimationInfo], path: &[usize]) -> &'a AnimationInfo {
1151    let mut info = &roots[path[0]];
1152    for &index in &path[1..] {
1153        info = &info.children[index];
1154    }
1155    info
1156}
1157
1158fn is_descendant(candidate: &[usize], parent: &[usize]) -> bool {
1159    candidate.len() > parent.len() && candidate.starts_with(parent)
1160}
1161
1162fn toggle_path(expanded: &mut HashSet<AnimationPath>, path: &[usize]) {
1163    if !expanded.remove(path) {
1164        expanded.insert(path.to_vec());
1165    }
1166}
1167
1168fn short_anim_name(name: &str) -> &str {
1169    // Trim the Pure<...>/Iterative<...> adapter wrappers to show the inner type.
1170    let name = [
1171        "ranim_core::animation::eval::pure::Pure<",
1172        "ranim_core::animation::eval::iterative::Iterative<",
1173    ]
1174    .iter()
1175    .find_map(|prefix| name.strip_prefix(prefix).and_then(|s| s.strip_suffix('>')))
1176    .unwrap_or(name);
1177    let outer_type = name.split('<').next().unwrap_or(name);
1178    let short = outer_type.rsplit("::").next().unwrap_or(outer_type);
1179    if short == "{{closure}}" {
1180        "Eval"
1181    } else {
1182        short
1183    }
1184}
1185
1186fn preferred_tree_width(infos: &[AnimationInfo]) -> f32 {
1187    fn required_width(infos: &[AnimationInfo], container_depth: usize, widest: &mut f32) {
1188        for info in infos {
1189            if info.kind != AnimationInfoKind::Children || !info.children.is_empty() {
1190                let indent = container_depth as f32 * INDENT_WIDTH;
1191                let toggle = if is_expandable_container(info) && !info.children.is_empty() {
1192                    20.0
1193                } else {
1194                    4.0
1195                };
1196                let label = short_anim_name(&info.anim_name).chars().count() as f32 * 7.25;
1197                let duration_column = 54.0;
1198                *widest = widest.max(6.0 + indent + toggle + label + duration_column);
1199            }
1200            let child_depth = container_depth + usize::from(is_expandable_container(info));
1201            required_width(&info.children, child_depth, widest);
1202        }
1203    }
1204
1205    let mut widest = MIN_TREE_WIDTH;
1206    required_width(infos, 0, &mut widest);
1207    widest.clamp(MIN_TREE_WIDTH, 320.0)
1208}
1209
1210fn animation_color_for_info(info: &AnimationInfo) -> Color32 {
1211    if info.kind == AnimationInfoKind::Anim && info.anim_name.contains("::Static<") {
1212        let rgba = manim::YELLOW_C.to_rgba8();
1213        Rgba::from_srgba_unmultiplied(rgba.r, rgba.g, rgba.b, rgba.a).into()
1214    } else {
1215        animation_color(info.kind)
1216    }
1217}
1218
1219/// Ordered, non-overlapping children can share one visual track.
1220fn is_serial_container(info: &AnimationInfo) -> bool {
1221    info.kind == AnimationInfoKind::Children
1222        && info
1223            .children
1224            .windows(2)
1225            .all(|pair| pair[0].range.end <= pair[1].range.start)
1226}
1227
1228/// Overlapping children use expandable tracks instead of a shared row.
1229fn is_expandable_container(info: &AnimationInfo) -> bool {
1230    info.kind == AnimationInfoKind::Children && !is_serial_container(info)
1231}
1232
1233fn animation_color(kind: AnimationInfoKind) -> Color32 {
1234    let rgba = match kind {
1235        AnimationInfoKind::Anim => manim::BLUE_C.to_rgba8(),
1236        AnimationInfoKind::Children => manim::ORANGE.to_rgba8(),
1237        AnimationInfoKind::Audio => manim::GREEN_C.to_rgba8(),
1238    };
1239    Rgba::from_srgba_unmultiplied(rgba.r, rgba.g, rgba.b, rgba.a).into()
1240}
1241
1242fn nice_grid_step(width_sec: f64, width_points: f32) -> f64 {
1243    let target_lines = (width_points / 90.0).max(1.0) as f64;
1244    let raw_step = width_sec / target_lines;
1245    let magnitude = 10.0f64.powf(raw_step.log10().floor());
1246    let normalized = raw_step / magnitude;
1247    let factor = if normalized <= 1.0 {
1248        1.0
1249    } else if normalized <= 2.0 {
1250        2.0
1251    } else if normalized <= 5.0 {
1252        5.0
1253    } else {
1254        10.0
1255    };
1256    factor * magnitude
1257}
1258
1259fn format_grid_time(sec: f64) -> String {
1260    if sec.abs() >= 10.0 {
1261        format!("{sec:.1}s")
1262    } else {
1263        format!("{sec:.2}s")
1264    }
1265}
1266
1267fn format_duration(sec: f64) -> String {
1268    if sec >= 10.0 {
1269        format!("{sec:.1}s")
1270    } else {
1271        format!("{sec:.3}s")
1272    }
1273}
1274
1275fn format_time(sec: f64) -> String {
1276    format!("{sec:.3}s")
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281    use super::*;
1282    use ranim_core::utils::rate_functions::linear;
1283
1284    fn animation(kind: AnimationInfoKind, range: Range<f64>) -> AnimationInfo {
1285        AnimationInfo {
1286            anim_name: "test".to_owned(),
1287            kind,
1288            content_duration_secs: range.end - range.start,
1289            range,
1290            rate_func: linear,
1291            enabled: true,
1292            sim_step: None,
1293            children: Vec::new(),
1294        }
1295    }
1296
1297    #[test]
1298    fn children_are_laid_out_by_placement_instead_of_authoring_type() {
1299        let mut group = animation(AnimationInfoKind::Children, 0.0..3.0);
1300        group.children = vec![
1301            animation(AnimationInfoKind::Anim, 0.0..1.0),
1302            animation(AnimationInfoKind::Anim, 1.0..3.0),
1303        ];
1304        let mut tracks = Vec::new();
1305        collect_tracks(
1306            std::slice::from_ref(&group),
1307            &group,
1308            &mut vec![0],
1309            0,
1310            10.0..16.0,
1311            &HashSet::from([vec![0]]),
1312            &mut tracks,
1313        );
1314        assert_eq!(tracks.len(), 1);
1315        assert_eq!(tracks[0].clips.len(), 2);
1316        assert_eq!(tracks[0].clips[0].global_range, 10.0..12.0);
1317        assert_eq!(tracks[0].clips[1].global_range, 12.0..16.0);
1318
1319        group.children[0].range.end = 2.0;
1320        tracks.clear();
1321        collect_tracks(
1322            std::slice::from_ref(&group),
1323            &group,
1324            &mut vec![0],
1325            0,
1326            10.0..16.0,
1327            &HashSet::from([vec![0]]),
1328            &mut tracks,
1329        );
1330        assert_eq!(tracks.len(), 3);
1331        assert_eq!(tracks[1].path, vec![0, 0]);
1332        assert_eq!(tracks[1].global_range, 10.0..14.0);
1333        assert_eq!(tracks[2].path, vec![0, 1]);
1334        assert_eq!(tracks[2].global_range, 12.0..16.0);
1335    }
1336}