jevsnes.git / packages / panels / src / replay.rs
1//! The replay timeline: a scrubber over everywhere in a recording already
2//! played, play/pause/skip-to-end, a speed control (0.25x-8x), and "run the
3//! bot from here" to start a new branch. Plain data in, like every other
4//! panel here - `packages/replay` is native-only and never linked by this
5//! crate (`../CLAUDE.md`).
6
7use egui::{Color32, RichText, Sense, Stroke};
8
9use crate::palette::{FAINT, GOAL, GOOD, GREY, PLAIN, SECTION};
10
11/// One branch other than the active one, for the scrubber's own fork marks.
12pub struct Branch<'a> {
13    pub id: &'a str,
14    pub fork_frame: u64,
15}
16
17pub struct ReplayBar<'a> {
18    /// Where the picture currently is, in the active branch's own frame
19    /// numbering (0 is the recording's header).
20    pub current_frame: u64,
21    /// The active branch's own tip - its last recorded frame. Scrubbing or
22    /// playing past this hands off to the live bot.
23    pub tip_frame: u64,
24    pub playing: bool,
25    pub speed_label: &'a str,
26    /// The picture is showing the live bot playing, at or past the tip -
27    /// not a replay of something already recorded.
28    pub is_live: bool,
29    /// `Some((done, total))` while a "run the bot from here" rebuild is
30    /// under way; the rest of the bar is replaced by its progress.
31    pub rebuilding: Option<(u64, u64)>,
32    /// Every OTHER branch in the tree, for the scrubber's fork marks.
33    pub other_branches: &'a [Branch<'a>],
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Action {
38    SeekTo(u64),
39    TogglePlay,
40    SkipToEnd,
41    SlowDown,
42    SpeedUp,
43    RunBotFromHere,
44}
45
46/// One `▶`/`⏸` button, skip-to-end, the speed ladder, the scrubber itself
47/// with fork marks, and "run the bot from here". Returns at most one
48/// [`Action`] - the one the user just asked for this frame, if any.
49pub fn show(ui: &mut egui::Ui, bar: &ReplayBar) -> Option<Action> {
50    let mut action = None;
51
52    ui.horizontal(|ui| {
53        if let Some((done, total)) = bar.rebuilding {
54            ui.label(RichText::new(format!("rebuilding bot: {done}/{total} frames")).color(GOAL).strong());
55            return;
56        }
57        if ui.button(if bar.playing { "\u{23F8}" } else { "\u{25B6}" }).clicked() {
58            action = Some(Action::TogglePlay);
59        }
60        if ui.button("\u{23ED}").on_hover_text("skip to the end").clicked() {
61            action = Some(Action::SkipToEnd);
62        }
63        ui.separator();
64        if ui.button("-").on_hover_text("slower").clicked() {
65            action = Some(Action::SlowDown);
66        }
67        ui.label(RichText::new(bar.speed_label).color(if bar.speed_label == "1x" { GREY } else { GOAL }));
68        if ui.button("+").on_hover_text("faster").clicked() {
69            action = Some(Action::SpeedUp);
70        }
71        ui.separator();
72        if bar.is_live {
73            ui.label(RichText::new("LIVE").color(GOOD).strong());
74        } else if ui.button("run the bot from here").on_hover_text("start a new branch, the bot playing live from this frame").clicked() {
75            action = Some(Action::RunBotFromHere);
76        }
77    });
78
79    if bar.rebuilding.is_some() {
80        return action;
81    }
82
83    let (rect, response) = ui.allocate_exact_size(egui::vec2(ui.available_width().max(1.0), 22.0), Sense::click_and_drag());
84    if (response.clicked() || response.dragged())
85        && let Some(pos) = response.interact_pointer_pos()
86    {
87        let frac = ((pos.x - rect.left()) / rect.width().max(1.0)).clamp(0.0, 1.0);
88        let frame = (f64::from(frac) * bar.tip_frame as f64).round() as u64;
89        action = Some(Action::SeekTo(frame));
90    }
91
92    let painter = ui.painter();
93    painter.rect_filled(rect, 3.0, Color32::from_rgb(0x2A, 0x2E, 0x38));
94    let span = bar.tip_frame.max(1) as f32;
95    let played_frac = (bar.current_frame as f32 / span).min(1.0);
96    let played = egui::Rect::from_min_max(rect.min, egui::pos2(rect.min.x + rect.width() * played_frac, rect.max.y));
97    painter.rect_filled(played, 3.0, if bar.is_live { GOOD } else { SECTION });
98    for branch in bar.other_branches {
99        let x = rect.min.x + rect.width() * (branch.fork_frame as f32 / span).clamp(0.0, 1.0);
100        painter.line_segment([egui::pos2(x, rect.min.y), egui::pos2(x, rect.max.y)], Stroke::new(2.0, FAINT));
101    }
102    // The playhead itself, so a paused frame in the middle of the bar is
103    // still visible even when it lands exactly on a branch mark.
104    let head_x = rect.min.x + rect.width() * played_frac;
105    painter.line_segment([egui::pos2(head_x, rect.min.y), egui::pos2(head_x, rect.max.y)], Stroke::new(2.0, PLAIN));
106
107    ui.label(RichText::new(format!("frame {} / {}", bar.current_frame, bar.tip_frame)).color(GREY));
108
109    action
110}
111
112#[cfg(test)]
113mod tests {
114    // egui needs a live `Ui`/`Context` to draw into, which this crate's
115    // other panels (`bot.rs`, `state.rs`) also leave untested at the
116    // draw-call level - the layout invariant that matters (the scrubber's
117    // own rect) is `layout.rs`'s test. What is worth pinning here is the
118    // action mapping's own arithmetic, kept free of egui.
119    #[test]
120    fn a_click_fraction_maps_to_a_frame_proportionally() {
121        let frame_for = |frac: f32, tip: u64| (f64::from(frac.clamp(0.0, 1.0)) * tip as f64).round() as u64;
122        assert_eq!(frame_for(0.0, 10_000), 0);
123        assert_eq!(frame_for(1.0, 10_000), 10_000);
124        assert_eq!(frame_for(0.5, 10_000), 5_000);
125        assert_eq!(frame_for(-0.2, 10_000), 0); // clamped
126        assert_eq!(frame_for(1.2, 10_000), 10_000); // clamped
127    }
128}