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}