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