1use crate::app::i18n::fill;
8use crate::app::settings::GameSettings;
9use crate::app::{Playback, Screen, menu_ui, palette};
10use crate::sim::Replay;
11use bevy::prelude::*;
12
13pub fn library_dir() -> std::path::PathBuf {
16 crate::app::paths::data_dir().join("replays")
17}
18
19pub struct Kept {
21 pub path: std::path::PathBuf,
22 pub label: String,
26 pub draw: bool,
29}
30
31#[derive(Resource, Default)]
33pub struct Library {
34 pub kept: Vec<Kept>,
35 pub selected: usize,
36 pub scroll: usize,
39 pub feedback: String,
41}
42
43impl Library {
44 pub fn settle(&mut self) {
49 let Self {
50 kept,
51 selected,
52 scroll,
53 feedback: _,
54 } = self;
55 *selected = (*selected).min(kept.len().saturating_sub(1));
56 *scroll = (*scroll)
60 .min(*selected)
61 .max(selected.saturating_sub(ROWS - 1))
62 .min(kept.len().saturating_sub(ROWS));
63 }
64}
65
66pub fn file_name(stamp: u64, winner: Option<&str>) -> String {
79 match winner {
80 Some(winner) => {
81 let tidy: String = winner
82 .chars()
83 .map(|c| if c.is_alphanumeric() { c } else { '_' })
84 .take(12)
85 .collect();
86 format!("round_{stamp:010}_{tidy}.txt")
87 }
88 None => format!("round_{stamp:010}.txt"),
89 }
90}
91
92pub fn free_path(dir: &std::path::Path, stamp: u64, winner: Option<&str>) -> std::path::PathBuf {
100 (stamp..)
101 .map(|at| dir.join(file_name(at, winner)))
102 .find(|path| !path.exists())
103 .unwrap_or_else(|| dir.join(file_name(stamp, winner)))
104}
105
106pub fn prune(cap: u8) {
116 prune_in(&library_dir(), cap);
117}
118
119pub fn prune_in(dir: &std::path::Path, cap: u8) {
122 let kept = shelf_in(dir);
123 for old in kept.iter().skip(usize::from(cap)) {
124 if let Err(e) = std::fs::remove_file(&old.path) {
125 warn!("could not drop the oldest round: {e}");
126 }
127 }
128}
129
130pub fn shelf() -> Vec<Kept> {
132 shelf_in(&library_dir())
133}
134
135pub fn shelf_in(dir: &std::path::Path) -> Vec<Kept> {
139 let Ok(dir) = std::fs::read_dir(dir) else {
140 return Vec::new();
141 };
142 let mut kept: Vec<Kept> = dir
143 .filter_map(Result::ok)
144 .map(|entry| entry.path())
145 .filter(|path| {
146 path.extension().is_some_and(|ext| ext == "txt")
147 && path
148 .file_name()
149 .and_then(|n| n.to_str())
150 .is_some_and(|n| n.starts_with("round_"))
151 })
152 .map(|path| {
153 let (label, draw) = label_of(&path);
154 Kept { path, label, draw }
155 })
156 .collect();
157 kept.sort_by(|a, b| b.path.cmp(&a.path));
159 kept
160}
161
162fn label_of(path: &std::path::Path) -> (String, bool) {
168 let stem = path
169 .file_stem()
170 .and_then(|s| s.to_str())
171 .unwrap_or("round")
172 .to_string();
173 let mut parts = stem.splitn(3, '_');
174 let (_, stamp, winner) = (parts.next(), parts.next(), parts.next());
175 match (stamp.and_then(|s| s.parse::<u64>().ok()), winner) {
176 (Some(stamp), Some(winner)) => (
179 format!("{} - {}", clock(stamp), winner.replace('_', " ")),
180 false,
181 ),
182 (Some(stamp), None) => (clock(stamp), true),
183 (None, _) => (stem, false),
184 }
185}
186
187fn clock(stamp: u64) -> String {
195 stamp_text(stamp, crate::app::clock::local_offset(stamp))
196}
197
198fn stamp_text(stamp: u64, offset: i64) -> String {
201 let local = (stamp as i64 + offset).max(0) as u64;
205 let secs = local % 86_400;
206 let (h, m) = (secs / 3600, (secs % 3600) / 60);
207 let (day, month) = crate::app::clock::civil_date((local / 86_400) as u32);
208 format!("{day:02}/{month:02} {h:02}:{m:02}")
209}
210
211#[derive(Component)]
212pub struct LibraryUi;
213
214#[derive(Component)]
215pub struct LibraryRow(usize);
216
217#[derive(Component)]
219pub struct EmptyShelfNote;
220
221const ROWS: usize = 12;
223
224pub fn enter_library(
225 mut commands: Commands,
226 settings: Res<GameSettings>,
227 mut library: ResMut<Library>,
228) {
229 library.kept = shelf();
230 library.settle();
231 let tr = settings.tr();
232 library.feedback = tr.replay_drop_hint.to_string();
235 commands
236 .spawn((LibraryUi, menu_ui::between_bars()))
237 .with_children(|wrap| {
238 wrap.spawn(menu_ui::screen_card()).with_children(|card| {
239 menu_ui::heading_row(card, tr.replays_heading, None);
240 card.spawn((
246 EmptyShelfNote,
247 Text::new(""),
248 TextFont {
249 font_size: FontSize::Px(menu_ui::type_scale::ROW),
250 ..default()
251 },
252 TextColor(palette::PARCHMENT.with_alpha(0.6)),
253 Node {
257 width: Val::Px(ROW_W),
258 margin: UiRect::axes(Val::Px(10.0), Val::Px(4.0)),
259 ..default()
260 },
261 ));
262 for row in 0..ROWS {
263 card.spawn((LibraryRow(row), menu_ui::card_row()))
264 .with_children(|line| {
265 line.spawn((LibraryRow(row), menu_ui::cell(ROW_W, 19.0)));
266 });
267 }
268 });
269 });
270}
271
272const ROW_W: f32 = 420.0;
274
275pub fn update_library(
276 library: Res<Library>,
277 settings: Res<GameSettings>,
278 mut cells: Query<(&LibraryRow, &mut Text, &mut TextColor), Without<EmptyShelfNote>>,
279 mut rows: Query<(&LibraryRow, &mut BackgroundColor, &mut Node), Without<EmptyShelfNote>>,
280 mut empty_note: Query<(&mut Text, &mut Node), With<EmptyShelfNote>>,
281) {
282 let tr = settings.tr();
283 let empty = library.kept.is_empty();
288 for (mut note, mut node) in &mut empty_note {
289 menu_ui::set_text(&mut note, if empty { tr.replays_empty } else { "" });
290 menu_ui::set_shown(&mut node, empty);
291 }
292 let at = |row: usize| library.scroll + row;
294 for (row, mut text, mut color) in &mut cells {
295 let line = match library.kept.get(at(row.0)) {
296 Some(kept) if kept.draw => format!("{} - {}", kept.label, tr.replay_draw),
299 Some(kept) => kept.label.clone(),
300 None => String::new(),
301 };
302 let picked = at(row.0) == library.selected && !library.kept.is_empty();
303 menu_ui::set_text(&mut text, &line);
304 menu_ui::set_color(
305 &mut color,
306 match (picked, library.kept.get(at(row.0)).is_some()) {
307 (true, _) => Color::WHITE,
308 (false, true) => palette::PARCHMENT.with_alpha(0.75),
309 (false, false) => palette::PARCHMENT.with_alpha(0.45),
310 },
311 );
312 }
313 for (row, mut fill, mut node) in &mut rows {
314 let ground = menu_ui::band(at(row.0) == library.selected && !library.kept.is_empty());
315 menu_ui::set_bg(&mut fill, ground);
316 menu_ui::set_shown(&mut node, !empty);
322 }
323}
324
325pub fn library_input(
328 keys: Res<ButtonInput<KeyCode>>,
329 settings: Res<GameSettings>,
330 caps: Res<crate::app::keycaps::KeyCaps>,
331 mut clipboard: ResMut<Clipboard>,
332 mut library: ResMut<Library>,
333 mut playback: ResMut<Playback>,
334 mut next_screen: ResMut<NextState<Screen>>,
335) {
336 let tr = settings.tr();
337 if keys.just_pressed(KeyCode::Escape) {
338 next_screen.set(Screen::Menu);
339 return;
340 }
341 let shown = library.kept.len();
344 if shown > 0 {
345 library.selected = menu_ui::nav(&keys, library.selected, shown);
346 library.settle();
347 }
348 if caps.just_pressed(&keys, 'C') {
349 library.feedback = copy_selected(&mut clipboard, tr, &library);
350 }
351 if caps.just_pressed(&keys, 'V') {
352 library.feedback = keep_pasted(&mut clipboard, tr, &library_dir(), settings.replay_cap);
353 library.kept = shelf();
357 library.settle();
358 }
359 if !menu_ui::enter(&keys) {
360 return;
361 }
362 let Some(kept) = library.kept.get(library.selected) else {
363 return;
364 };
365 match std::fs::read_to_string(&kept.path)
366 .map_err(|e| e.to_string())
367 .and_then(|text| Replay::parse(&text))
368 {
369 Ok(replay) => {
370 playback.0 = Some((replay, 0));
371 next_screen.set(Screen::Versus);
372 }
373 Err(e) => library.feedback = e,
374 }
375}
376
377fn copy_selected(
383 clipboard: &mut Clipboard,
384 tr: &crate::app::i18n::Tr,
385 library: &Library,
386) -> String {
387 let Some(kept) = library.kept.get(library.selected) else {
388 return tr.replays_empty.to_string();
389 };
390 let text = match std::fs::read_to_string(&kept.path) {
391 Ok(text) => text,
392 Err(e) => return fill(tr.code_round_bad, &[("e", &e.to_string())]),
393 };
394 crate::app::codes::copy_feedback(
395 clipboard,
396 tr,
397 crate::share::Kind::Round,
398 text.as_bytes(),
399 tr.code_copied,
400 )
401}
402
403fn round_from(
411 pasted: Option<(crate::share::Kind, Vec<u8>)>,
412 tr: &crate::app::i18n::Tr,
413) -> Result<(Option<String>, String), String> {
414 let text =
415 crate::app::codes::payload_text(pasted, tr, crate::share::Kind::Round, tr.code_round_bad)?;
416 let replay = Replay::parse(&text).map_err(|e| fill(tr.code_round_bad, &[("e", &e)]))?;
417 Ok((winner_of(&replay, tr), text))
418}
419
420fn winner_of(replay: &Replay, tr: &crate::app::i18n::Tr) -> Option<String> {
426 let board = replay.playback();
427 let leaders = crate::app::side_panels::leading_seats(
428 board.scores(),
429 board.castle_seats(),
430 crate::app::teams::TeamMode::Solo,
431 );
432 let seat = leaders.iter().position(|&led| led)?;
433 Some(if replay.names[seat].is_empty() {
434 crate::app::seat_label(tr, seat as u8)
435 } else {
436 replay.names[seat].clone()
437 })
438}
439
440fn keep_pasted(
442 clipboard: &mut Clipboard,
443 tr: &crate::app::i18n::Tr,
444 dir: &std::path::Path,
445 cap: u8,
446) -> String {
447 keep(crate::app::codes::paste(clipboard), tr, dir, cap)
448}
449
450fn keep(
455 pasted: Option<(crate::share::Kind, Vec<u8>)>,
456 tr: &crate::app::i18n::Tr,
457 dir: &std::path::Path,
458 cap: u8,
459) -> String {
460 let (winner, text) = match round_from(pasted, tr) {
461 Ok(round) => round,
462 Err(complaint) => return complaint,
463 };
464 let path = free_path(dir, crate::app::clock::now_secs(), winner.as_deref());
465 match crate::app::paths::write_atomic(&path, &text) {
466 Ok(()) => {
467 prune_in(dir, cap);
468 tr.code_round_saved.to_string()
469 }
470 Err(e) => fill(tr.code_round_bad, &[("e", &e.to_string())]),
471 }
472}
473
474#[derive(Resource)]
476pub struct PlaybackSpeed(pub u8);
477
478impl Default for PlaybackSpeed {
479 fn default() -> Self {
480 PlaybackSpeed(1)
481 }
482}
483
484impl PlaybackSpeed {
485 pub const STEPS: [u8; 3] = [1, 2, 4];
487
488 fn stepped(self) -> PlaybackSpeed {
489 let next = Self::STEPS
490 .iter()
491 .position(|&s| s == self.0)
492 .map_or(0, |at| (at + 1) % Self::STEPS.len());
493 PlaybackSpeed(Self::STEPS[next])
494 }
495}
496
497pub fn playback_speed_input(
499 keys: Res<ButtonInput<KeyCode>>,
500 playback: Res<Playback>,
501 mut speed: ResMut<PlaybackSpeed>,
502) {
503 if playback.0.is_some() && keys.just_pressed(KeyCode::KeyS) {
504 *speed = PlaybackSpeed(speed.0).stepped();
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
515 fn the_shelf_window_follows_the_cursor() {
516 let mut library = Library {
517 kept: (0..ROWS + 8)
518 .map(|n| Kept {
519 path: format!("{n}.txt").into(),
520 label: n.to_string(),
521 draw: false,
522 })
523 .collect(),
524 ..Library::default()
525 };
526 library.settle();
527 assert_eq!((library.selected, library.scroll), (0, 0));
528 library.selected = ROWS - 1;
530 library.settle();
531 assert_eq!(library.scroll, 0);
532 library.selected = ROWS;
534 library.settle();
535 assert_eq!(library.scroll, 1);
536 library.selected = ROWS + 7;
538 library.settle();
539 assert_eq!(library.scroll, 8);
540 library.selected = 3;
542 library.settle();
543 assert_eq!(library.scroll, 3);
544 library.kept.truncate(4);
546 library.selected = 10;
547 library.settle();
548 assert_eq!((library.selected, library.scroll), (3, 0));
549 library.kept.clear();
551 library.settle();
552 assert_eq!((library.selected, library.scroll), (0, 0));
553 }
554
555 #[test]
556 fn round_names_sort_newest_last_and_survive_odd_winners() {
557 let name = |stamp, winner| file_name(stamp, Some(winner));
558 assert_eq!(name(12_345, "Anna"), "round_0000012345_Anna.txt");
559 assert_eq!(name(7, "Bo/../etc"), "round_0000000007_Bo____etc.txt");
561 assert_eq!(name(7, ""), "round_0000000007_.txt");
562 assert_eq!(file_name(7, None), "round_0000000007.txt");
564 assert!(name(2, "a") < name(10, "a"));
566 assert!(name(1, &"x".repeat(40)).len() < 40);
568 }
569
570 #[test]
571 fn labels_read_as_a_time_and_a_winner() {
572 let (label, draw) = label_of(std::path::Path::new("replays/round_1751328000_Anna.txt"));
573 assert!(label.contains("Anna"), "{label}");
574 assert!(label.contains('/') && label.contains(':'), "{label}");
575 assert!(!draw);
576 let odd = label_of(std::path::Path::new("replays/round_handmade.txt"));
578 assert_eq!(odd, ("round_handmade".to_string(), false));
579 }
580
581 #[test]
586 fn a_draw_is_told_from_a_winner_called_draw() {
587 let read = |winner| label_of(std::path::Path::new(&file_name(1_751_328_000, winner)));
588 let (tie, draw) = read(None);
589 assert!(draw, "{tie}");
590 assert!(
591 !tie.contains("draw"),
592 "the word is the shelf's to add: {tie}"
593 );
594 for name in ["draw", "drawbridge"] {
595 let (label, draw) = read(Some(name));
596 assert!(!draw, "{name} won");
597 assert!(label.ends_with(&format!(" - {name}")), "{label}");
598 }
599 }
600
601 fn scratch(name: &str) -> std::path::PathBuf {
603 let dir = std::env::temp_dir().join(format!("pinch-{name}-{}", std::process::id()));
604 let _ = std::fs::remove_dir_all(&dir);
605 std::fs::create_dir_all(&dir).expect("a scratch folder");
606 dir
607 }
608
609 #[test]
614 fn pasted_rounds_keep_their_own_files_and_the_cap() {
615 let tr = &crate::app::i18n::EN;
616 let dir = scratch("pasted-rounds");
617 let code = || {
618 let text = banked_round().to_text();
619 crate::share::decode(&crate::share::encode(
620 crate::share::Kind::Round,
621 text.as_bytes(),
622 ))
623 };
624 assert_eq!(keep(code(), tr, &dir, 10), tr.code_round_saved);
625 assert_eq!(keep(code(), tr, &dir, 10), tr.code_round_saved);
626 assert_eq!(shelf_in(&dir).len(), 2, "two pastes, two rounds");
627 std::fs::write(dir.join(file_name(5, Some("P1"))), "x").expect("written");
629 assert_eq!(
630 free_path(&dir, 5, Some("P1")),
631 dir.join(file_name(6, Some("P1"))),
632 "a taken name is passed over for the next second"
633 );
634 assert_eq!(
635 free_path(&dir, 5, None),
636 dir.join(file_name(5, None)),
637 "and a free one is kept"
638 );
639 for _ in 0..4 {
640 keep(code(), tr, &dir, 3);
641 }
642 assert_eq!(shelf_in(&dir).len(), 3, "and the cap holds");
643 let _ = std::fs::remove_dir_all(&dir);
644 }
645
646 #[test]
650 fn a_stamp_reads_on_the_local_clock() {
651 const NOON_LESS_TWELVE: u64 = 1_751_328_000;
653 assert_eq!(stamp_text(NOON_LESS_TWELVE, 0), "01/07 00:00", "Greenwich");
654 assert_eq!(stamp_text(NOON_LESS_TWELVE, 7200), "01/07 02:00", "Paris");
655 assert_eq!(
658 stamp_text(NOON_LESS_TWELVE, -4 * 3600),
659 "30/06 20:00",
660 "Halifax"
661 );
662 assert_eq!(stamp_text(60, -4 * 3600), "01/01 00:00");
665 }
666
667 #[test]
672 fn a_round_travels_as_a_code_and_comes_back() {
673 let tr = &crate::app::i18n::EN;
674 let mut replay = Replay::new(crate::sim::Level::from_board(
675 "Turf War",
676 3,
677 crate::sim::classic_arena(false, 2),
678 ));
679 for tick in 0..40u8 {
680 let mut actions = [crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS];
681 actions[0] = crate::sim::PlayerAction::Place {
682 x: tick % 10,
683 y: 3,
684 dir: crate::sim::Direction::Down,
685 };
686 replay.record(actions);
687 }
688 let text = replay.to_text();
689 let code = crate::share::encode(crate::share::Kind::Round, text.as_bytes());
690 let (winner, back) = round_from(crate::share::decode(&code), tr).expect("a round");
691 assert_eq!(back, text, "what went in is what comes out");
692 assert_eq!(
693 winner, None,
694 "filed under the result (a draw), not the beach"
695 );
696 }
697
698 fn banked_round() -> Replay {
702 let level = crate::sim::Level::parse(
703 "name: T\nposts: 1\ncrab: 0,0 R R common\nmap:\n\
704 +-+-+\n|. 0|\n+ + +\n|. .|\n+-+-+\n",
705 )
706 .expect("level");
707 let mut replay = Replay::new(level);
708 for _ in 0..60 {
709 replay.record([crate::sim::PlayerAction::None; crate::sim::MAX_PLAYERS]);
710 }
711 assert!(replay.playback().scores()[0] > 0, "the crab walked home");
712 replay
713 }
714
715 #[test]
716 fn a_pasted_round_is_filed_under_the_name_of_whoever_took_it() {
717 let tr = &crate::app::i18n::EN;
718 let mut replay = banked_round();
719 replay.names[0] = "Anna".into();
720 assert_eq!(winner_of(&replay, tr).as_deref(), Some("Anna"));
721 }
722
723 #[test]
724 fn a_pasted_round_with_no_names_is_filed_under_the_seat() {
725 let tr = &crate::app::i18n::EN;
726 assert_eq!(winner_of(&banked_round(), tr).as_deref(), Some("P1"));
727 }
728
729 #[test]
732 fn what_is_not_a_round_says_which_way_it_is_not() {
733 let tr = &crate::app::i18n::EN;
734 assert_eq!(round_from(None, tr), Err(tr.code_none_pasted.to_string()));
736 let level = crate::share::encode(crate::share::Kind::Level, b"name: X\nposts: 3\n");
739 let complaint = round_from(crate::share::decode(&level), tr).expect_err("not a round");
740 assert!(
741 complaint.contains("a level") && complaint.contains("a round"),
742 "{complaint}"
743 );
744 let junk = crate::share::encode(crate::share::Kind::Round, b"not a replay at all");
746 assert!(round_from(crate::share::decode(&junk), tr).is_err());
747 }
748
749 #[test]
753 fn the_transport_clock_pads_its_seconds() {
754 assert_eq!(stamp(0), "0:00", "the very start");
755 assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 7), "0:07");
756 assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 59), "0:59");
757 assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 60), "1:00");
758 assert_eq!(stamp(crate::sim::TICKS_PER_SECOND as usize * 185), "3:05");
760 }
761
762 #[test]
766 fn the_transport_clock_counts_in_ticks() {
767 let one_second = crate::sim::TICKS_PER_SECOND as usize;
768 assert_eq!(stamp(one_second - 1), "0:00", "part of a second is not one");
769 assert_eq!(stamp(one_second), "0:01", "and a whole one is");
770 }
771
772 #[test]
773 fn the_speed_key_cycles_the_steps() {
774 let mut speed = PlaybackSpeed::default();
775 assert_eq!(speed.0, 1);
776 for want in [2, 4, 1, 2] {
777 speed = PlaybackSpeed(speed.0).stepped();
778 assert_eq!(speed.0, want);
779 }
780 }
781}
782
783#[derive(Component)]
791pub struct ReplayBar;
792
793#[derive(Component)]
795pub struct ReplayProgress;
796
797#[derive(Component)]
799pub struct ReplayReadout;
800
801const TRACK: f32 = 420.0;
803
804pub fn tend_replay_bar(
810 mut commands: Commands,
811 playback: Res<Playback>,
812 screen: Res<State<Screen>>,
813 bar: Query<Entity, With<ReplayBar>>,
814) {
815 let watching = playback.0.is_some() && *screen.get() == Screen::Versus;
816 if !watching {
817 for entity in &bar {
818 commands.entity(entity).despawn();
819 }
820 return;
821 }
822 if !bar.is_empty() {
823 return;
824 }
825 commands
826 .spawn((
827 ReplayBar,
828 Node {
829 position_type: PositionType::Absolute,
830 bottom: Val::Px(56.0),
834 left: Val::Px(0.0),
835 width: Val::Percent(100.0),
836 flex_direction: FlexDirection::Column,
837 align_items: AlignItems::Center,
838 row_gap: Val::Px(5.0),
839 ..default()
840 },
841 ))
842 .with_children(|column| {
843 column
845 .spawn((
846 Node {
847 width: Val::Px(TRACK),
848 height: Val::Px(6.0),
849 border_radius: BorderRadius::all(Val::Px(3.0)),
850 ..default()
851 },
852 BackgroundColor(palette::BAR_TRACK),
853 ))
854 .with_children(|rail| {
855 rail.spawn((
856 ReplayProgress,
857 Node {
858 width: Val::Percent(0.0),
859 height: Val::Percent(100.0),
860 border_radius: BorderRadius::all(Val::Px(3.0)),
861 ..default()
862 },
863 BackgroundColor(palette::GOLD),
864 ));
865 });
866 column
867 .spawn((
868 Node {
869 padding: UiRect::axes(Val::Px(12.0), Val::Px(3.0)),
870 border_radius: BorderRadius::all(Val::Px(10.0)),
871 ..default()
872 },
873 BackgroundColor(palette::PILL_FILL),
874 ))
875 .with_children(|pill| {
876 pill.spawn((
877 ReplayReadout,
878 Text::new(String::new()),
879 TextFont {
880 font_size: FontSize::Px(menu_ui::type_scale::FINE),
881 ..default()
882 },
883 TextLayout::no_wrap(),
884 TextColor(palette::PARCHMENT),
885 ));
886 });
887 });
888}
889
890fn stamp(ticks: usize) -> String {
892 let seconds = ticks / crate::sim::TICKS_PER_SECOND as usize;
893 format!("{}:{:02}", seconds / 60, seconds % 60)
894}
895
896pub fn update_replay_bar(
898 playback: Res<Playback>,
899 paused: Res<crate::app::Paused>,
900 speed: Res<PlaybackSpeed>,
901 settings: Res<GameSettings>,
902 mut track: Query<&mut Node, With<ReplayProgress>>,
903 mut readout: Query<&mut Text, With<ReplayReadout>>,
904) {
905 let Some((replay, at)) = &playback.0 else {
906 return;
907 };
908 let total = replay.inputs.len().max(1);
909 let through = (*at).min(total);
910 let width = Val::Percent(through as f32 / total as f32 * 100.0);
911 for mut node in &mut track {
912 if node.width != width {
915 node.width = width;
916 }
917 }
918 let tr = settings.tr();
919 let line = format!(
920 "{} {}x {} / {}",
921 if paused.0 {
922 tr.replay_paused
923 } else {
924 tr.replay_playing
925 },
926 speed.0,
927 stamp(through),
928 stamp(total)
929 );
930 for mut text in &mut readout {
931 menu_ui::set_text(&mut text, &line);
932 }
933}
934
935pub fn playback_pause_input(
946 keys: Res<ButtonInput<KeyCode>>,
947 playback: Res<Playback>,
948 mut paused: ResMut<crate::app::Paused>,
949) {
950 if playback.0.is_none() {
951 return;
952 }
953 if keys.just_pressed(KeyCode::Space) {
954 paused.0 = !paused.0;
955 }
956}