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 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
1247fn 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}