//! On-screen overlay shown while a recorded [`Replay`] plays back. //! //! The overlay is a thin top-of-window banner with three pieces of UI: //! //! - A "▌ replay" label on the left so the player knows the surface is //! under playback control rather than live input. //! - A "MOVE N/M" progress chip in the centre, recomputed every frame //! the cursor advances and bordered in `ACCENT_PRIMARY` so it //! reads as a discrete callout. //! - A "Stop" button on the right that aborts playback and returns //! control to the player. //! //! When playback finishes ([`ReplayPlaybackState::Completed`]) the banner //! label swaps to "▌ replay complete" and stays visible until the playback //! core auto-clears the resource back to [`ReplayPlaybackState::Inactive`] //! a few seconds later, at which point the overlay despawns. //! //! The overlay sits at z-layer [`Z_REPLAY_OVERLAY`] — above gameplay but //! below every modal layer ([`Z_MODAL_SCRIM`] and up). That ordering lets //! the player still open Settings, Pause, and Help during a replay; those //! modals will render on top of the banner as expected. //! //! [`Replay`]: solitaire_data::Replay //! [`Z_MODAL_SCRIM`]: crate::ui_theme::Z_MODAL_SCRIM mod format; mod input; #[cfg(test)] mod tests; mod update; pub(crate) use self::format::*; pub(crate) use self::input::*; pub(crate) use self::update::*; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent}; use crate::font_plugin::FontResource; use crate::platform::SHOW_KEYBOARD_ACCELERATORS; use crate::replay_playback::ReplayPlaybackState; use crate::ui_modal::{ButtonVariant, spawn_modal_button}; use crate::ui_theme::{ ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder, STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY, }; use bevy::prelude::*; // --------------------------------------------------------------------------- // Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above. // --------------------------------------------------------------------------- /// `bevy::ui` `ZIndex` value for the replay overlay banner. /// /// Numeric value is `Z_DROP_OVERLAY as i32 + 5 = 55`; chosen so the banner /// sits clearly above the HUD top layer (`Z_HUD_TOP = 60` is intentionally /// **below** modals, but the overlay needs to be above HUD readouts) yet /// well below `Z_MODAL_SCRIM = 200` so Settings, Pause, and Help modals /// continue to render on top of the overlay during a replay. /// /// The `Z_DROP_OVERLAY + 5` formula in the spec is reproduced here as an /// integer because `Z_DROP_OVERLAY` itself is a `f32` Sprite-space z used /// for the drop-target overlay sprites — UI nodes use `i32` `ZIndex`, so /// we materialise a separate constant rather than reuse the `f32` value. pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5; /// `bevy::ui` `ZIndex` for the full-screen tableau dim layer. /// /// One rung below [`Z_REPLAY_OVERLAY`] (= 54) so the replay chrome /// (banner + move-log panel) renders clearly on top while the dim scrim /// darkens the card world beneath it. World-space sprites (cards, /// badges, drop-target overlays) are always below any UI node regardless /// of their Transform.z — the dim layer doesn't need to know their z /// values. const Z_REPLAY_DIM: i32 = Z_REPLAY_OVERLAY - 1; /// Alpha for the tableau dim layer — 50 % opacity black. Dark enough /// to visually separate the gameplay scene from the replay chrome /// above it; light enough that card positions remain legible through /// the scrim. Matches the mockup's "Game Peek Band at 50 % opacity" /// spec in `docs/ui-mockups/replay-overlay-mobile.html`. const TABLEAU_DIM_ALPHA: f32 = 0.5; /// Total height of the banner in pixels. Thin enough to leave the /// gameplay surface visible underneath, tall enough to comfortably fit /// the headline-sized "▌ replay" label stacked above the /// `TYPE_CAPTION` "GAME #YYYY-DDD" subtitle (the left column needs /// ~26 + 2 + 11 = 39 px of inner content; banner = top row (59 /// flex-grow) + scrub track (1) + label row (16) + footer (16) /// gives 92). /// /// Growth history: /// - 60 → 76 in the scrub-notch-labels commit to make room for the /// `0%` / … / `100%` percentage labels under each notch. /// - 76 → 92 in the keybind-footer commit to make room for the /// vim-style mode line + keybind-hint footer at the bottom. const BANNER_HEIGHT: f32 = 92.0; /// Height of the label row that sits below the 1px scrub track and /// carries the `0%` / `25%` / `50%` / `75%` / `100%` notch labels. /// 16 px is enough for `TYPE_CAPTION` text (12 px font + 4 px breathing /// room above the bottom edge). const SCRUB_LABEL_ROW_HEIGHT: f32 = 16.0; /// Height of the keybind-hint footer that sits below the notch-label /// row. Carries a vim-style mode indicator on the left and a /// keybind-hint on the right (`[SPACE] pause/resume`). 16 px matches /// `SCRUB_LABEL_ROW_HEIGHT` for visual symmetry — `TYPE_CAPTION` text /// (12 px) + 4 px breathing room. const KEYBIND_FOOTER_HEIGHT: f32 = 16.0; /// Fixed pixel width of the centred scrub-bar notch-label container. /// Wide enough to hold the widest label ("100%" at 4 chars) while /// narrower than the 25 % gap between adjacent notches (≈ banner_w /// × 0.25; on a 320 px banner that's 80 px). A 36 px container /// leaves ≥ 44 px of clearance on each side at the narrowest common /// screen width. /// /// Container width drives the `margin.left = -width / 2` centering /// trick: the container's left edge is placed at `left: Percent(pct)` /// and then shifted left by half its own width, so the container's /// centre coincides with the notch line. `Justify::Center` then /// renders the text centred within the container. This is the /// CSS `translateX(-50%)` pattern adapted for Bevy 0.18 UI. const SCRUB_LABEL_CENTER_WIDTH: f32 = 36.0; /// How long a held arrow key waits before firing the next repeat /// step. 100 ms = 10 steps/sec — fast enough to scrub through a /// hundred-move replay in ~10 seconds while held, slow enough that /// the player can release after a known number of steps. Initial /// `just_pressed` always fires immediately; this interval gates /// only the *repeat* fires while the key remains held. const SCRUB_REPEAT_INTERVAL_SECS: f32 = 0.1; /// Total height of the bottom-edge Move Log panel in pixels. /// Sized for: header (`TYPE_CAPTION` 11) + 2 prev rows + active /// row + 2 next rows (`TYPE_BODY` 14 each = 70) + row gaps (~10) /// + vertical padding (~16) ≈ 107; round to 112. /// /// Growth history: /// - 56 in the move-log-panel-init commit (header + active row). /// - 56 → 84 in the move-log-prev-rows commit (+ 2 prev rows). /// - 84 → 112 in the move-log-next-rows commit (+ 2 next rows). const MOVE_LOG_PANEL_HEIGHT: f32 = 112.0; /// Number of "previous move" rows rendered above the active row /// in the move-log panel. Tuned to fit the panel height comfortably /// alongside the header + active row at `TYPE_BODY`. The active /// row plus this many prev rows gives the player a 3-row window /// onto recent move history. const MOVE_LOG_PREV_ROWS: usize = 2; /// Number of "next move" rows rendered below the active row. /// Same logic as [`MOVE_LOG_PREV_ROWS`] — symmetric window /// around the active row showing about-to-apply moves. For a /// post-game replay these aren't spoilers (the game is already /// won); for a future "live preview during play" use case the /// preview-shape might need rethinking. const MOVE_LOG_NEXT_ROWS: usize = 2; /// Vertical offset from the top edge of the window to the top edge of the /// mini-tableau preview panel. Places the panel 8 px below the banner's /// bottom edge so the two surfaces don't overlap. Derived from /// `BANNER_HEIGHT` so the gap stays consistent if the banner ever grows. const MINI_TABLEAU_TOP_OFFSET: f32 = BANNER_HEIGHT + 8.0; /// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha /// reads as a clear "this is a UI strip" callout while still letting the /// felt show through enough to anchor the banner to the play surface. const BANNER_ALPHA: f32 = 0.92; // --------------------------------------------------------------------------- // Marker components // --------------------------------------------------------------------------- /// Marker on the banner's root `Node`. Used by the spawn / despawn / /// progress-update systems to find the overlay. #[derive(Component, Debug)] pub struct ReplayOverlayRoot; /// Marker on the left-hand banner label `Text`. Carries either /// "▌ replay" (during playback) or "▌ replay complete" (once /// finished — the cursor-block prefix matches the splash boot-screen /// idiom so the surface reads as a Terminal output line); the /// completion-text-update system swaps the contents in place. #[derive(Component, Debug)] pub struct ReplayOverlayBannerText; /// Marker on the centre progress `Text`. Updated every frame to reflect /// the current `(cursor, total)` returned by /// [`ReplayPlaybackState::progress`]. #[derive(Component, Debug)] pub struct ReplayOverlayProgressText; /// Marker on the **floating** progress chip — a 2D world-space text /// entity rendered above the destination pile of the most-recently- /// applied move. Sits independently of the banner overlay (which /// lives in the UI tree and never moves) so the player can see /// progress without breaking eye contact with the focal card. /// /// Lifecycle matches the banner overlay: spawned by `spawn_overlay` /// when a replay starts, despawned by `react_to_state_change` when /// it ends. Position updated each frame by /// `update_floating_progress_chip`. Hidden when cursor=0 (no moves /// applied yet) or the last applied move was a `StockClick` (no /// destination pile to follow). #[derive(Component, Debug)] pub struct ReplayFloatingProgressChip; /// Marker on the right-hand "Stop" button. Click handler queries for this /// and calls [`stop_replay_playback`] when an `Interaction::Pressed` /// transition is seen. #[derive(Component, Debug)] pub struct ReplayStopButton; /// Marker on the Pause / Resume button. Click handler queries for this /// and calls [`toggle_pause_replay_playback`] on each press. The /// button's label text is repainted in lockstep by /// `update_pause_button_label` so it always reflects the action the /// next click will perform ("Pause" while running, "Resume" while /// paused). #[derive(Component, Debug)] pub struct ReplayPauseButton; /// Marker on the Step button. Click handler queries for this and /// calls [`step_replay_playback`] — only meaningful when paused /// (clicks while running are no-ops because the tick loop would race /// the manual advance). The button stays visually present but /// unresponsive while the playback is running so the player has a /// stable layout to scan. #[derive(Component, Debug)] pub struct ReplayStepButton; /// Marker on the full-screen tableau dim layer spawned at the start of /// every replay. The dim layer is a 100 % × 100 % `Node` at /// [`Z_REPLAY_DIM`] (= `Z_REPLAY_OVERLAY - 1`) with a semi-transparent /// black `BackgroundColor`. It darkens the card world so the replay /// chrome reads clearly against it without obscuring card positions. /// /// Carries no [`Interaction`] component — purely visual; pointer events /// pass through to the underlying UI and world-space systems. /// Despawned by `react_to_state_change` when the replay ends. #[derive(Component, Debug)] pub struct ReplayTableauDimLayer; /// Marker on the small caption sitting below the "▌ replay" /// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a /// replay is playing — a compact, monotonically-increasing identifier /// that mirrors the `▌replay.tsx` / `GAME #2024-127` Terminal-output /// motif from the mockup. The caption is empty in `Inactive` / /// `Completed` since the replay is consumed when transitioning out /// of `Playing` and the identifier is no longer recoverable from /// state alone. #[derive(Component, Debug)] pub struct ReplayOverlayGameCaption; /// Marker on the accent "fill" of the bottom-edge scrub bar. The /// `Node`'s `width` is rewritten every frame the cursor advances to /// `cursor / total` of the bar's full width, so the player has a /// continuous visual cue of how far through the replay they are. /// /// Distinct from the simpler text-based `ReplayOverlayProgressText` /// (which spells out "MOVE N/M" in a chip): the scrub fill gives immediate /// at-a-glance positioning; the text gives the exact numbers. Both /// surfaces stay together because they answer the same question for /// players with different scanning preferences. #[derive(Component, Debug)] pub struct ReplayOverlayScrubFill; /// Marker for the WIN MOVE tick on the scrub bar — a small absolute- /// positioned `Node` anchored at `replay.win_move_index / total` along /// the track. Painted in [`STATE_SUCCESS`] so the player can see at a /// glance where the winning move sits relative to the playback cursor. /// /// Static — the position is set at spawn time and never changes during /// playback (the underlying replay's `win_move_index` is immutable /// while `Playing`). Despawned with the rest of the overlay tree when /// the replay state transitions back to `Inactive`. /// /// Spawned only when the active replay carries /// [`Replay::win_move_index`](solitaire_data::Replay::win_move_index) /// `= Some(_)` — older replays loaded from disk pre-date the field /// and have no win index to surface. #[derive(Component, Debug)] pub struct ReplayOverlayWinMoveMarker; /// Marker for the fixed-position notches on the scrub bar — five 1px /// vertical ticks at 0 % / 25 % / 50 % / 75 % / 100 % that give the /// player visual anchor points for "where am I, relative to the /// quarter-marks of the replay." Mirrors the notch ladder in the /// screen-takeover mockup at /// `docs/ui-mockups/replay-overlay-mobile.html`. /// /// Static — positions are set at spawn time and never change. The /// notches paint in [`BORDER_SUBTLE`] which is the same colour as the /// unfilled track, so visibility comes from extending the notch /// **vertically past** the 1px track (5px tall, anchored 2px above /// the track top) rather than from colour contrast. Same trick the /// WIN MOVE marker uses. #[derive(Component, Debug)] pub struct ReplayOverlayScrubNotch; /// Marker for the percentage labels under each scrub-bar notch /// (`0%` / `25%` / `50%` / `75%` / `100%`). One label per notch; /// labels live in a dedicated 16 px row below the 1 px scrub track /// (the row that grew the banner from 60 → 76 px). /// /// Positioning follows a "endpoints flush to edges, middle three /// anchored at percentage" pattern: the leftmost label uses /// `left: 0`, the rightmost uses `right: 0`, and the middle three /// (`25%` / `50%` / `75%`) anchor at `left: Val::Percent(p)`. This /// avoids overflow at 100 % without needing CSS-style /// `translate-x: -50%` centering (which Bevy 0.18 UI doesn't have a /// clean equivalent for) — the trade-off is a slight right-of-notch /// offset on the middle three, which is visually subtle at the /// `TYPE_CAPTION` font size. #[derive(Component, Debug)] pub struct ReplayOverlayScrubNotchLabel; /// Marker on the keybind-hint footer row at the bottom edge of the /// banner. Carries two `Text` children: a vim-style mode indicator /// (`▌ NORMAL │ replay`) on the left and the keybind hint /// (`[SPACE] pause/resume`) on the right. 1 px top border in /// [`BORDER_SUBTLE`] separates it from the notch-label row above. /// /// Surfaces the existing Space-key accelerator visually so the /// UI-first contract from CLAUDE.md §3.3 (every player action has /// a visible UI control) holds for keyboard accelerators too. /// Future commits that wire ESC for stop or ← / → for scrub will /// extend the right-hand text in lockstep — the footer always /// reflects what's actually wired, never aspirational. #[derive(Component, Debug)] pub struct ReplayOverlayKeybindFooter; /// Marker on the bottom-edge **Move Log** panel — a separate root /// UI entity (not a child of the banner) that sits anchored to the /// viewport's bottom edge. Carries a header (`▌ MOVE LOG · N/M`) /// plus a row showing the most-recently-applied move. /// /// Spawned by `spawn_overlay` alongside the banner and the /// floating progress chip; despawned by `react_to_state_change` /// on the same `Playing → Inactive` transition. Same lifecycle /// pattern as `ReplayFloatingProgressChip` — a sibling root, not /// a banner child, because it lives at a different screen anchor. /// /// First slice of the move-log mockup at /// `docs/ui-mockups/replay-overlay-mobile.html` § "Move Log Card". /// Subsequent commits add prev/next rows and scrolling. #[derive(Component, Debug)] pub struct ReplayOverlayMoveLogPanel; /// Marker on the move-log panel's header `Text`. Carries /// `▌ MOVE LOG · N/M` while a replay is playing; the /// `update_move_log_header` system repaints it as the cursor /// advances. #[derive(Component, Debug)] pub struct ReplayOverlayMoveLogHeader; /// Marker on the move-log panel's active-row `Text`. Carries the /// most-recently-applied move's text (`47 │ waste → tableau 5`) /// when `cursor > 0`; empty when no moves have been applied yet /// (initial spawn) or in `Completed`/`Inactive` states. The /// `update_move_log_active_row` system repaints it as the cursor /// advances. #[derive(Component, Debug)] pub struct ReplayOverlayMoveLogActiveRow; /// Marker on a "previous move" row above the active row. /// `offset` is the 1-based distance backwards from the active /// row: `offset = 1` is the move applied just before the active /// one (e.g. cursor=47 → row reads "46 │ ..."), `offset = 2` is /// the one before that, and so on. Up to [`MOVE_LOG_PREV_ROWS`] /// rows render above the active row. /// /// Empty text when there isn't enough history (`offset >= cursor`, /// e.g. cursor=1 has no prev rows; cursor=2 has only the /// `offset = 1` row populated). #[derive(Component, Debug)] pub struct ReplayOverlayMoveLogPrevRow { /// Distance backwards from the active row (1-based). pub offset: u8, } /// Marker on a "next move" row below the active row. `offset` /// is the 1-based distance forward from the active row: /// `offset = 1` is the move that will apply next /// (`moves[cursor]`, displayed as `cursor + 1`), /// `offset = 2` is the one after that, and so on. Up to /// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row. /// /// Empty text when there isn't enough remaining replay /// (`cursor + offset - 1 >= moves.len()`, e.g. cursor=99 of /// a 100-move replay shows offset 1 but offset 2 stays empty). #[derive(Component, Debug)] pub struct ReplayOverlayMoveLogNextRow { /// Distance forward from the active row (1-based). pub offset: u8, } /// Marker added to every top-level entity spawned by [`spawn_overlay`]. /// `react_to_state_change` uses a single `Query>` /// to despawn all of them, rather than keeping a separate query per /// entity type. Future sibling overlay surfaces just need this marker /// at spawn time — no changes to the despawn logic required. #[derive(Component, Debug)] pub struct DespawnWithReplay; /// Marker on the mini-tableau preview panel root. A right-edge-anchored /// panel that shows a compact summary of the live game state during /// replay: the four foundation tops and the stock / waste heads. /// Spawned as a sibling root entity (same lifecycle pattern as /// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`. #[derive(Component, Debug)] pub struct ReplayMiniTableauPanel; /// Marker on the foundations row `Text` inside the mini-tableau panel. /// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by /// `update_mini_tableau` whenever [`GameStateResource`] changes. #[derive(Component, Debug)] pub struct ReplayMiniTableauFoundations; /// Marker on the stock/waste row `Text` inside the mini-tableau panel. /// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever /// [`GameStateResource`] changes. #[derive(Component, Debug)] pub struct ReplayMiniTableauStockWaste; // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- /// Bevy plugin that registers every system needed to drive the replay /// overlay's lifecycle. /// /// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`] /// — it only reads the shared `ReplayPlaybackState` resource. Tests insert /// the resource manually and exercise the overlay in isolation. pub struct ReplayOverlayPlugin; impl Plugin for ReplayOverlayPlugin { fn build(&self, app: &mut App) { // The systems are ordered so that, on a single frame: // 1. The state-watcher spawns or despawns the overlay if the // `ReplayPlaybackState` resource changed. // 2. The completion-text update swaps the banner label when the // state is `Completed`. // 3. The progress-text update writes the latest "Move N of M". // 4. The Stop-button click handler reads `Interaction::Pressed` // and calls `stop_replay_playback` (which mutates the state). // Putting Stop last means a click in frame N is observed by // `react_to_state_change` in frame N+1, which then despawns the // overlay in response — a clean state-driven loop. // Step-button handler dispatches into the same canonical move // / draw events that the tick loop fires. Register them // defensively here so this plugin can run under // `MinimalPlugins` without the playback plugin attached; // `add_message` is idempotent so the duplicate registration // in production (alongside `replay_playback`) is harmless. app.init_resource::() .add_message::() .add_message::() .add_message::() .add_message::() .add_systems( Update, ( react_to_state_change, update_banner_label, update_progress_text, update_floating_progress_chip, update_scrub_fill, update_move_log_header, update_move_log_active_row, update_move_log_prev_rows, update_move_log_next_rows, update_mini_tableau_foundations, update_mini_tableau_stock_waste, update_pause_button_label, handle_pause_button, handle_step_button, handle_pause_keyboard, handle_stop_keyboard, handle_arrow_keyboard, handle_stop_button, ) .chain(), ); } } // --------------------------------------------------------------------------- // Spawning // --------------------------------------------------------------------------- /// Reads [`ReplayPlaybackState`] every time the resource changes and either /// spawns or despawns the overlay accordingly. Treats the resource as the /// single source of truth — the spawn / despawn decision is derived from /// `is_playing() || is_completed()` rather than tracking previous-state /// transitions explicitly, which keeps the system stateless. fn react_to_state_change( mut commands: Commands, state: Res, roots: Query>, despawnable: Query>, font_res: Option>, ) { if !state.is_changed() { return; } let should_be_visible = state.is_playing() || state.is_completed(); let already_spawned = roots.iter().next().is_some(); if should_be_visible && !already_spawned { spawn_overlay(&mut commands, font_res.as_deref(), &state); } else if !should_be_visible && already_spawned { // Despawn all sibling root entities in one loop — every entity // spawned by `spawn_overlay` carries `DespawnWithReplay` for // exactly this purpose. for entity in &despawnable { commands.entity(entity).despawn(); } } // The `should_be_visible && already_spawned` branch is a no-op here — // the per-frame text update systems below repaint the banner label // and progress readout in place without a respawn. } /// Spawns the banner — a flex-row Node anchored to the top edge of the /// window with three children: the "▌ replay" / "▌ replay complete" label, /// the centred progress text, and the right-aligned Stop button. fn spawn_overlay( commands: &mut Commands, font_res: Option<&FontResource>, state: &ReplayPlaybackState, ) { let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); // Clone for the floating chip spawn that runs *after* the // banner's `.with_children(|banner| { ... })` closure consumes // the original `font_handle`. Cheap — Bevy's `Handle` is // `Arc`-backed, the clone bumps a refcount. let font_handle_for_floating = font_handle.clone(); // Second clone for the scrub-bar label row and keybind footer // inside the outer banner closure. The inner top-row closure // consumes the original `font_handle` for the progress-chip // text, so by the time the outer closure reaches the // label-row / footer spawns the original is gone. // `font_handle_for_labels` is `.clone()`'d (never moved) inside // the labels closure, so it's still alive for the footer // spawn afterwards — single shared clone covers both. let font_handle_for_labels = font_handle.clone(); // Third clone for the move-log panel — a separate root // entity spawned after the banner closure closes. Mirrors the // floating-chip clone reasoning. let font_handle_for_move_log = font_handle.clone(); // Fourth clone for the mini-tableau preview panel. let font_handle_for_mini_tableau = font_handle.clone(); let banner_label = if state.is_completed() { "\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention. } else { "\u{258C} replay" // ▌ }; let progress_label = format_progress(state); // Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54). // Spawned first so it sits behind the banner (z=55) and move-log (z=55) // in the UI stacking context. World-space sprites (cards, badges) are // always below any UI node, so the dim layer darkens the entire // gameplay scene without needing to touch card_plugin. No Interaction // component — purely visual. commands.spawn(( ReplayTableauDimLayer, DespawnWithReplay, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)), ZIndex(Z_REPLAY_DIM), GlobalZIndex(Z_REPLAY_DIM), )); let banner_bg = Color::srgba( BG_ELEVATED_HI.to_srgba().red, BG_ELEVATED_HI.to_srgba().green, BG_ELEVATED_HI.to_srgba().blue, BANNER_ALPHA, ); commands .spawn(( ReplayOverlayRoot, DespawnWithReplay, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Px(BANNER_HEIGHT), // Column outer so the content row sits above the 1px // scrub bar at the bottom edge. flex_direction: FlexDirection::Column, ..default() }, BackgroundColor(banner_bg), // Pin the banner to its z layer in both the local and the // global stacking context — `GlobalZIndex` matters because // the overlay is a top-level Node (no parent), and Bevy 0.18 // has historically had subtle stacking-context drift here. ZIndex(Z_REPLAY_OVERLAY), GlobalZIndex(Z_REPLAY_OVERLAY), )) .with_children(|banner| { // Top row: the existing content (label / progress / Stop). banner .spawn(Node { flex_grow: 1.0, flex_direction: FlexDirection::Row, align_items: AlignItems::Center, justify_content: JustifyContent::SpaceBetween, padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2), column_gap: VAL_SPACE_4, ..default() }) .with_children(|row| { // Left: column with the accent "▌ replay" headline // above and a small `GAME #YYYY-DDD` caption below. // The caption mirrors the mockup's right-anchored // game identifier but stays visually grouped with // the headline so the two pieces of "this is a // replay of game X" read as a single unit. row.spawn(Node { flex_direction: FlexDirection::Column, align_items: AlignItems::FlexStart, row_gap: Val::Px(2.0), ..default() }) .with_children(|left| { left.spawn(( ReplayOverlayBannerText, Text::new(banner_label), TextFont { font: font_handle.clone(), font_size: TYPE_HEADLINE, ..default() }, TextColor(ACCENT_PRIMARY), )); left.spawn(( ReplayOverlayGameCaption, Text::new(format_game_caption(state).unwrap_or_default()), TextFont { font: font_handle.clone(), font_size: TYPE_CAPTION, ..default() }, TextColor(TEXT_SECONDARY), )); }); // Centre: progress readout, wrapped in a 1 px // ACCENT_PRIMARY-bordered chip so it reads as a // discrete callout rather than free-floating // text. No fill — the Terminal aesthetic gets // depth from borders + tonal layering, not // shadows. The marker stays on the inner Text so // `update_progress_text` keeps working unchanged. row.spawn(( Node { border: UiRect::all(Val::Px(1.0)), padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), ..default() }, BorderColor::all(ACCENT_PRIMARY), )) .with_children(|chip| { chip.spawn(( ReplayOverlayProgressText, Text::new(progress_label), TextFont { font: font_handle, font_size: TYPE_BODY, ..default() }, TextColor(TEXT_PRIMARY), )); }); // Right: Stop button. Tertiary variant — the // action is available but not the loudest element // in the banner; the "Replay" primary accent owns // that slot. `spawn_modal_button` gives us hover / // press paint and focus rings for free via the // existing `UiModalPlugin` paint system. row.spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, ..default() }) .with_children(|wrap| { // Pause / Resume label is set from the current // state so a freshly-spawned overlay (which // currently always starts unpaused) reads // "Pause". `update_pause_button_label` // repaints it whenever the state changes. spawn_modal_button( wrap, ReplayPauseButton, pause_button_label(state), None, ButtonVariant::Tertiary, font_res, ); spawn_modal_button( wrap, ReplayStepButton, "Step", None, ButtonVariant::Tertiary, font_res, ); spawn_modal_button( wrap, ReplayStopButton, "Stop", None, ButtonVariant::Tertiary, font_res, ); }); }); // Bottom edge: 1px-tall scrub bar. Track in `BORDER_SUBTLE`, // fill in `ACCENT_PRIMARY`. The fill width is rewritten by // [`update_scrub_fill`] every tick the cursor advances. // Initial fill width matches the spawn-time progress so the // first-frame paint already reflects state instead of // popping from 0 → cursor on the first tick. let initial_scrub_pct = scrub_pct(state); let win_pct = win_move_marker_pct(state); banner .spawn(( Node { width: Val::Percent(100.0), height: Val::Px(1.0), ..default() }, BackgroundColor(BORDER_SUBTLE), // HC marker: bumps the 1 px track from #505050 // → #a0a0a0 under high-contrast mode. The track // paints via BackgroundColor (it's a 1 px Node, // not a border on a wider container) so the // BorderColor-targeting HighContrastBorder marker // doesn't apply — HighContrastBackground is the // parallel primitive for this case. HighContrastBackground::with_default(BORDER_SUBTLE), )) .with_children(|track| { track.spawn(( ReplayOverlayScrubFill, Node { width: Val::Percent(initial_scrub_pct), height: Val::Percent(100.0), ..default() }, BackgroundColor(ACCENT_PRIMARY), )); // WIN MOVE marker — small green tick anchored at // `win_move_index / total`. Spawned only when the // active replay carries the field; older replays // pre-dating `win_move_index` simply don't get a // marker. Centered vertically on the 1px track via // a 3px-tall node offset 1px above the track top so // 1px sits above and 1px below the track line. if let Some(pct) = win_pct { track.spawn(( ReplayOverlayWinMoveMarker, Node { position_type: PositionType::Absolute, left: Val::Percent(pct), top: Val::Px(-1.0), width: Val::Px(2.0), height: Val::Px(3.0), ..default() }, BackgroundColor(STATE_SUCCESS), // HC bump: lime → brighter lime so the win // marker reads clearly above the bumped // notch ticks (BORDER_SUBTLE_HC gray) under // high-contrast mode. HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC), )); } // Fixed quarter-mark notches: five 1px vertical // ticks at 0 / 25 / 50 / 75 / 100 % that give the // player visual anchor points without needing to // mentally bisect the bar. Painted in // BORDER_SUBTLE — same colour as the unfilled // track — so visibility comes from extending past // the 1px track height (5px tall, anchored 2px // above the track top) rather than colour // contrast. Spawned *after* the WIN MOVE marker // so a notch and the marker landing on the same // percentage paint the marker on top. for pct in scrub_notch_positions() { track.spawn(( ReplayOverlayScrubNotch, Node { position_type: PositionType::Absolute, left: Val::Percent(pct), top: Val::Px(-2.0), width: Val::Px(1.0), height: Val::Px(5.0), ..default() }, BackgroundColor(BORDER_SUBTLE), // Same HC-paint reasoning as the track // above: 5 px tall × 1 px wide tick mark // paints via BackgroundColor, so // HighContrastBackground (not -Border) is // the right marker. HighContrastBackground::with_default(BORDER_SUBTLE), )); } }); // Third banner row: percentage labels (`0%` / `25%` / // `50%` / `75%` / `100%`) under each scrub-bar notch. // Sibling of (not child of) the 1px track because labels // need their own vertical real estate (TYPE_CAPTION text // doesn't fit inside a 1px container). Position math: // track Node has `Val::Percent(p)` referencing the // banner's full width; this label row also has the // banner's full width, so labels at the same // percentages line up vertically with their notches. let labels = scrub_notch_labels(); let positions = scrub_notch_positions(); banner .spawn(Node { width: Val::Percent(100.0), height: Val::Px(SCRUB_LABEL_ROW_HEIGHT), position_type: PositionType::Relative, ..default() }) .with_children(|row| { for (i, (label, pct)) in labels.iter().zip(positions.iter()).enumerate() { // Endpoints flush to the row's edges; middle // three labels use the `translateX(-50%)` // pattern for Bevy 0.18 UI: a fixed-width // container is placed at `left: Percent(pct)` // then shifted left by half its own width via // `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`. // `Justify::Center` renders the text centred // within the container so the text's visual // centre coincides with the notch line. let (node, justify) = if i == 0 { ( Node { position_type: PositionType::Absolute, top: Val::Px(2.0), left: Val::Px(0.0), ..default() }, Justify::Left, ) } else if i == labels.len() - 1 { ( Node { position_type: PositionType::Absolute, top: Val::Px(2.0), right: Val::Px(0.0), ..default() }, Justify::Right, ) } else { ( Node { position_type: PositionType::Absolute, top: Val::Px(2.0), left: Val::Percent(*pct), width: Val::Px(SCRUB_LABEL_CENTER_WIDTH), margin: UiRect { left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0), ..default() }, ..default() }, Justify::Center, ) }; row.spawn(( ReplayOverlayScrubNotchLabel, node, Text::new(*label), TextLayout::new_with_justify(justify), TextFont { font: font_handle_for_labels.clone(), font_size: TYPE_CAPTION, ..default() }, // TEXT_SECONDARY keeps the subdued visual // hierarchy (caption, not headline) while // staying readable against BG_ELEVATED_HI. TextColor(TEXT_SECONDARY), )); } }); // Fourth banner row: keybind-hint footer. Vim-style // mode line on the left (`▌ NORMAL │ replay`), keybind // hint on the right (`[SPACE] pause/resume`), 1px top // border in BORDER_SUBTLE separating it from the // labels row above. Surfaces the existing Space // accelerator visually so CLAUDE.md §3.3's UI-first // contract holds for keyboard accelerators too. banner .spawn(( ReplayOverlayKeybindFooter, Node { width: Val::Percent(100.0), height: Val::Px(KEYBIND_FOOTER_HEIGHT), flex_direction: FlexDirection::Row, justify_content: JustifyContent::SpaceBetween, align_items: AlignItems::Center, padding: UiRect::horizontal(VAL_SPACE_4), border: UiRect::top(Val::Px(1.0)), ..default() }, BorderColor::all(BORDER_SUBTLE), // Marker for `apply_high_contrast_borders`: bumps // the 1 px top border from BORDER_SUBTLE (#505050) // to BORDER_SUBTLE_HC (#a0a0a0) when // `Settings::high_contrast_mode` is on. Without // this the footer reads as floating loose under // HC because the border that visually anchors it // to the labels row above is near-invisible. HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|footer| { footer.spawn(( Text::new(keybind_footer_mode_text()), TextFont { font: font_handle_for_labels.clone(), font_size: TYPE_CAPTION, ..default() }, TextColor(TEXT_SECONDARY), )); if SHOW_KEYBOARD_ACCELERATORS { footer.spawn(( Text::new(keybind_footer_hint_text()), TextFont { font: font_handle_for_labels.clone(), font_size: TYPE_CAPTION, ..default() }, TextColor(TEXT_SECONDARY), )); } }); }); // Floating progress chip — a 2D world-space `Text2d` rendered // above the destination pile of the most-recently-applied move. // Sibling of (not child of) the banner overlay because it lives // in world-space coordinates, not the UI tree. Spawned hidden; // `update_floating_progress_chip` shows + positions it on the // first frame the cursor advances past 0. Lifecycle matches // the banner overlay — `react_to_state_change` despawns both // when the replay state transitions back to `Inactive`. commands.spawn(( ReplayFloatingProgressChip, DespawnWithReplay, Text2d::new(format_progress(state)), TextFont { font: font_handle_for_floating, font_size: TYPE_BODY, ..default() }, TextColor(TEXT_PRIMARY), // High Z keeps the chip above every card stack // (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards // stack to the low double digits at most). Transform::from_xyz(0.0, 0.0, 100.0), Visibility::Hidden, )); // Move-log panel — a separate root UI entity anchored to the // viewport's bottom edge. Carries a `▌ MOVE LOG · N/M` header // plus a row showing the most-recently-applied move. // Sibling-of-banner pattern (not a banner child) because the // panel lives at a different screen anchor and has its own // spawn/despawn lifecycle synced via `react_to_state_change`. let banner_bg = Color::srgba( BG_ELEVATED_HI.to_srgba().red, BG_ELEVATED_HI.to_srgba().green, BG_ELEVATED_HI.to_srgba().blue, BANNER_ALPHA, ); commands .spawn(( ReplayOverlayMoveLogPanel, DespawnWithReplay, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), bottom: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Px(MOVE_LOG_PANEL_HEIGHT), flex_direction: FlexDirection::Column, align_items: AlignItems::FlexStart, justify_content: JustifyContent::Center, padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2), row_gap: VAL_SPACE_1, border: UiRect::top(Val::Px(1.0)), ..default() }, BackgroundColor(banner_bg), BorderColor::all(BORDER_SUBTLE), // Same z-stack rationale as the banner — above gameplay, // below modals. ZIndex(Z_REPLAY_OVERLAY), GlobalZIndex(Z_REPLAY_OVERLAY), // HC marker so the top border bumps under HC mode. // Without it the panel reads as floating loose because // the border that anchors it to the gameplay area above // is near-invisible at #505050. HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|panel| { // Header row: `▌ MOVE LOG · N/M` in ACCENT_PRIMARY for // the cursor-block prefix consistency with the banner // headline. panel.spawn(( ReplayOverlayMoveLogHeader, Text::new(format_move_log_header(state)), TextFont { font: font_handle_for_move_log.clone(), font_size: TYPE_CAPTION, ..default() }, TextColor(ACCENT_PRIMARY), )); // Prev rows — render above the active row in display // order (oldest first), so the active row sits at the // bottom of the visible window. Spawn from // MOVE_LOG_PREV_ROWS down to 1 (offset 2, then 1) so // the highest-offset (oldest) row is topmost in the // panel's flex column. Each carries // ReplayOverlayMoveLogPrevRow { offset } — the // per-frame system reads `offset` and recomputes the // text on cursor advance. Painted in TEXT_SECONDARY // so the active row stands out from context rows. for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() { panel.spawn(( ReplayOverlayMoveLogPrevRow { offset }, Text::new(format_kth_recent_row(state, offset as usize + 1)), TextFont { font: font_handle_for_move_log.clone(), font_size: TYPE_BODY, ..default() }, TextColor(TEXT_SECONDARY), )); } // Active move row. Wrapped in a Node with an // ACCENT_PRIMARY background so the row reads as // "current focus" — the player can scan vertically // and the highlighted row is the move that just // applied. Empty text at spawn time when cursor=0; // the per-frame update system populates it as the // cursor advances. Text colour is TEXT_PRIMARY_HC // (near-white) for contrast against the brick-red // background — same trick as the modal-button // primary-variant paint. panel .spawn(( Node { width: Val::Percent(100.0), padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), ..default() }, BackgroundColor(ACCENT_PRIMARY), )) .with_children(|active| { active.spawn(( ReplayOverlayMoveLogActiveRow, Text::new(format_active_move_row(state)), TextFont { font: font_handle_for_move_log.clone(), font_size: TYPE_BODY, ..default() }, TextColor(TEXT_PRIMARY_HC), )); }); // Next rows — render below the active row in display // order (offset 1 directly below active, then offset // 2). Same TEXT_SECONDARY de-emphasis as prev rows so // the active row stays the focal point. Empty text // late in the replay (when cursor + offset exceeds // moves.len()) — the panel under-fills gracefully. for offset in 1..=MOVE_LOG_NEXT_ROWS as u8 { panel.spawn(( ReplayOverlayMoveLogNextRow { offset }, Text::new(format_kth_next_row(state, offset as usize)), TextFont { font: font_handle_for_move_log.clone(), font_size: TYPE_BODY, ..default() }, TextColor(TEXT_SECONDARY), )); } }); // Mini-tableau preview panel — right-edge anchor, just below the banner. // Compact two-row readout: foundation tops then stock/waste head. // Sibling-of-banner pattern (separate root entity, own spawn/despawn). let banner_bg = Color::srgba( BG_ELEVATED_HI.to_srgba().red, BG_ELEVATED_HI.to_srgba().green, BG_ELEVATED_HI.to_srgba().blue, BANNER_ALPHA, ); commands .spawn(( ReplayMiniTableauPanel, DespawnWithReplay, Node { position_type: PositionType::Absolute, right: Val::Px(0.0), top: Val::Px(MINI_TABLEAU_TOP_OFFSET), padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), flex_direction: FlexDirection::Column, align_items: AlignItems::FlexStart, row_gap: VAL_SPACE_1, border: UiRect::left(Val::Px(1.0)), ..default() }, BackgroundColor(banner_bg), BorderColor::all(BORDER_SUBTLE), ZIndex(Z_REPLAY_OVERLAY), GlobalZIndex(Z_REPLAY_OVERLAY), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|panel| { panel.spawn(( Text::new("\u{258C} BOARD"), TextFont { font: font_handle_for_mini_tableau.clone(), font_size: TYPE_CAPTION, ..default() }, TextColor(ACCENT_PRIMARY), )); panel.spawn(( ReplayMiniTableauFoundations, Text::new("F: -- -- -- --"), TextFont { font: font_handle_for_mini_tableau.clone(), font_size: TYPE_CAPTION, ..default() }, TextColor(TEXT_PRIMARY), )); panel.spawn(( ReplayMiniTableauStockWaste, Text::new("STK:-- WST:--"), TextFont { font: font_handle_for_mini_tableau, font_size: TYPE_CAPTION, ..default() }, TextColor(TEXT_SECONDARY), )); }); } /// Pure helper — returns the scrub-fill width as a percentage of the /// track for the given playback state. `Completed` reads as 100 %; /// `Inactive` and `Playing` with no progress read as 0 %. fn scrub_pct(state: &ReplayPlaybackState) -> f32 { if state.is_completed() { return 100.0; } match state.progress() { Some((_, 0)) | None => 0.0, Some((cursor, total)) => { let frac = (cursor as f32 / total as f32).clamp(0.0, 1.0); frac * 100.0 } } } /// Pure helper — returns the fixed scrub-bar notch positions as /// percentages along the track. Five evenly-spaced notches at the /// quarter-marks: `[0, 25, 50, 75, 100]`. Function (rather than /// const) so the unit-test surface is obvious and a future /// regression — e.g. someone simplifying to three notches — fails /// at the helper test rather than at visual review. fn scrub_notch_positions() -> [f32; 5] { [0.0, 25.0, 50.0, 75.0, 100.0] } /// Pure helper — returns the percentage-label text for each notch, /// in left-to-right order. Paired with [`scrub_notch_positions`] so /// `labels[i]` belongs at `positions[i]`. Lifted to a function for /// the same reason as the positions helper: a clean unit-test /// surface that fails at a regression (e.g. someone simplifying /// `100%` → `MAX`) rather than at visual review. fn scrub_notch_labels() -> [&'static str; 5] { ["0%", "25%", "50%", "75%", "100%"] } /// Pure helper — returns the vim-style mode indicator text shown on /// the left side of the keybind-hint footer row. `▌ NORMAL │ replay` /// matches the `▌replay.tsx` motif from the splash boot-screen and /// the screen-takeover mockup. The cursor block (`▌`) matches the /// banner-label prefix; "NORMAL" is the vim mode (mockup parity); /// "replay" identifies the surface. fn keybind_footer_mode_text() -> &'static str { "\u{258C} NORMAL \u{2502} replay" // ▌ NORMAL │ replay } /// Pure helper — returns the keybind-hint text shown on the right /// side of the keybind-hint footer row. Lists only the keys that /// are *actually wired* today: the Space accelerator for /// pause/resume, the ESC accelerator for stop, and the ← / → /// accelerators for paused single-move stepping. The footer never /// lists unimplemented keybinds (would lie to users). fn keybind_footer_hint_text() -> &'static str { if SHOW_KEYBOARD_ACCELERATORS { "[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator } else { "" } } /// Pure helper — returns the WIN MOVE marker's left-edge position as /// a percentage of the scrub track, or `None` when no marker should /// be drawn. /// /// `None` is returned in any of these cases: /// - The state isn't `Playing` (no replay attached). /// - The replay's `win_move_index` is `None` (older replay loaded /// from disk pre-dating the field). /// - The replay's move list is empty (shouldn't happen for real wins, /// but guards the divide-by-zero). /// /// The percentage clamps to `[0, 100]` so a malformed /// `win_move_index >= total` (defensive — shouldn't happen) doesn't /// position the marker outside the track. fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option { let ReplayPlaybackState::Playing { replay, moves, .. } = state else { return None; }; let idx = replay.win_move_index?; let total = moves.len(); if total == 0 { return None; } let frac = (idx as f32 / total as f32).clamp(0.0, 1.0); Some(frac * 100.0) }