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 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
1219fn 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
1228fn 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}