feat(replay): add Move Log panel with active-row readout

First slice of the move-log mockup at
docs/ui-mockups/replay-overlay-mobile.html § "Move Log Card".
Adds a separate root UI entity anchored to the viewport's bottom
edge (sibling-of-banner pattern, mirrors ReplayFloatingProgressChip
lifecycle) carrying a `▌ MOVE LOG · N/M` header plus a single row
showing the most-recently-applied move.

Subsequent commits in this multi-session arc add prev/next rows,
active-row highlight, and auto-scroll on cursor advance. Splitting
the work at "panel + active row only" lands the structural piece
(panel exists, lifecycle works, format helpers proven) before
tackling the harder questions about rendering un-applied future
moves and scrolling.

Position decision: bottom-of-viewport (matches mockup), separate
root entity from the 92 px top banner. Keeps the banner from
growing further into a top-heavy 170+ px strip; the
top-status + bottom-info paradigm reads as vim/IDE-style buffer
chrome that players intuitively scan.

Four pure helpers handle the formatting:
- format_pile(p) → lowercase, 1-indexed display string
  ("foundation 3" rather than enum's 0-indexed Foundation(2))
- format_move_body(m) → "{from} → {to}" or "stock cycle"
- format_move_log_header(state) → "▌ MOVE LOG · N/M",
  "▌ MOVE LOG · COMPLETE" for `Completed`, empty for `Inactive`
- format_active_move_row(state) → "{cursor} │ {body}" with
  1-based cursor for player display, empty at cursor=0

Two per-frame update systems (update_move_log_header,
update_move_log_active_row) repaint the texts on resource change
with the standard early-exit-on-no-change idiom.

Despawn handling: react_to_state_change gains a third query for
ReplayOverlayMoveLogPanel entities and despawns them on
Playing → Inactive alongside the banner root and floating chip.

Panel border carries HighContrastBorder so the 1 px top edge
bumps under HC mode — same pattern as the keybind footer.

8 new tests:
- format_pile pile-name + 1-index pinning
- format_move_body both-variant pinning
- format_move_log_header three-state coverage
- format_active_move_row cursor=0 vs cursor>0
- move_log_panel spawn cardinality (exactly one)
- move_log_panel header paints helper string at spawn
- move_log_active_row repaints on cursor advance
- move_log_panel despawn parity with overlay tree

Tests: 1254 → 1262. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 17:29:37 -07:00
parent 8fdc41f36f
commit d6f32d3154
+462
View File
@@ -33,6 +33,7 @@ use crate::replay_playback::{
step_backwards_replay_playback, step_replay_playback, stop_replay_playback, step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
toggle_pause_replay_playback, ReplayPlaybackState, toggle_pause_replay_playback, ReplayPlaybackState,
}; };
use solitaire_core::pile::PileType;
use solitaire_data::ReplayMove; use solitaire_data::ReplayMove;
use crate::ui_modal::{spawn_modal_button, ButtonVariant}; use crate::ui_modal::{spawn_modal_button, ButtonVariant};
use crate::ui_theme::{ use crate::ui_theme::{
@@ -95,6 +96,12 @@ const KEYBIND_FOOTER_HEIGHT: f32 = 16.0;
/// only the *repeat* fires while the key remains held. /// only the *repeat* fires while the key remains held.
const SCRUB_REPEAT_INTERVAL_SECS: f32 = 0.1; const SCRUB_REPEAT_INTERVAL_SECS: f32 = 0.1;
/// Total height of the bottom-edge Move Log panel in pixels. Two
/// vertical content rows (header + active-row) at `TYPE_CAPTION`
/// and `TYPE_BODY` plus standard vertical padding lands at
/// 11 + 8 + 14 + 12 ≈ 45; round to 56 for headroom.
const MOVE_LOG_PANEL_HEIGHT: f32 = 56.0;
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha /// 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 /// 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. /// felt show through enough to anchor the banner to the play surface.
@@ -268,6 +275,39 @@ struct ReplayScrubKeyHold {
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ReplayOverlayKeybindFooter; 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;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -311,6 +351,8 @@ impl Plugin for ReplayOverlayPlugin {
update_progress_text, update_progress_text,
update_floating_progress_chip, update_floating_progress_chip,
update_scrub_fill, update_scrub_fill,
update_move_log_header,
update_move_log_active_row,
update_pause_button_label, update_pause_button_label,
handle_pause_button, handle_pause_button,
handle_step_button, handle_step_button,
@@ -338,6 +380,7 @@ fn react_to_state_change(
state: Res<ReplayPlaybackState>, state: Res<ReplayPlaybackState>,
existing: Query<Entity, With<ReplayOverlayRoot>>, existing: Query<Entity, With<ReplayOverlayRoot>>,
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>, floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
if !state.is_changed() { if !state.is_changed() {
@@ -360,6 +403,12 @@ fn react_to_state_change(
for entity in &floating_chips { for entity in &floating_chips {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
// Move-log panel is also a separate root entity (sibling
// of the banner anchored to the viewport's bottom edge),
// so the banner-root despawn doesn't reach it either.
for entity in &move_log_panels {
commands.entity(entity).despawn();
}
} }
// The `should_be_visible && already_spawned` branch is a no-op here — // The `should_be_visible && already_spawned` branch is a no-op here —
// the per-frame text update systems below repaint the banner label // the per-frame text update systems below repaint the banner label
@@ -389,6 +438,10 @@ fn spawn_overlay(
// the labels closure, so it's still alive for the footer // the labels closure, so it's still alive for the footer
// spawn afterwards — single shared clone covers both. // spawn afterwards — single shared clone covers both.
let font_handle_for_labels = font_handle.clone(); 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();
let banner_label = if state.is_completed() { let banner_label = if state.is_completed() {
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention. "\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
@@ -778,6 +831,78 @@ fn spawn_overlay(
Transform::from_xyz(0.0, 0.0, 100.0), Transform::from_xyz(0.0, 0.0, 100.0),
Visibility::Hidden, 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,
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),
));
// Active move row. Empty at spawn time when cursor=0;
// the per-frame update system populates it as the
// cursor advances. TYPE_BODY gives the row a bit more
// weight than the header — it's the load-bearing
// information.
panel.spawn((
ReplayOverlayMoveLogActiveRow,
Text::new(format_active_move_row(state)),
TextFont {
font: font_handle_for_move_log,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
));
});
} }
/// Pure helper — returns the scrub-fill width as a percentage of the /// Pure helper — returns the scrub-fill width as a percentage of the
@@ -977,6 +1102,40 @@ fn update_floating_progress_chip(
} }
} }
/// Repaints the move-log panel's `▌ MOVE LOG · N/M` header text
/// whenever [`ReplayPlaybackState`] changes. Cheap — early-exits
/// when nothing moved so an idle replay leaves the text mesh
/// untouched.
fn update_move_log_header(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayMoveLogHeader>>,
) {
if !state.is_changed() {
return;
}
let label = format_move_log_header(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Repaints the move-log panel's active-row text whenever
/// [`ReplayPlaybackState`] changes. Same change-detection guard
/// as the header updater. Empty string at `cursor == 0` (no move
/// applied yet) and in non-`Playing` states; populated otherwise.
fn update_move_log_active_row(
state: Res<ReplayPlaybackState>,
mut q: Query<&mut Text, With<ReplayOverlayMoveLogActiveRow>>,
) {
if !state.is_changed() {
return;
}
let label = format_active_move_row(&state);
for mut text in &mut q {
**text = label.clone();
}
}
/// Repaints the bottom-edge accent scrub fill to mirror cursor progress. /// Repaints the bottom-edge accent scrub fill to mirror cursor progress.
/// Same change-detection guard as the text updaters — the overlay /// Same change-detection guard as the text updaters — the overlay
/// already early-exits when nothing moved, so an idle replay leaves the /// already early-exits when nothing moved, so an idle replay leaves the
@@ -1028,6 +1187,76 @@ fn format_progress(state: &ReplayPlaybackState) -> String {
} }
} }
/// Pure helper — formats a [`PileType`] as a short, lowercase,
/// 1-indexed display string for the move-log row. `Foundation(2)`
/// renders as `"foundation 3"` rather than `"foundation 2"` so
/// players see human-friendly numbers; the underlying enum
/// remains 0-indexed.
///
/// Returns `String` rather than `&'static str` because the
/// `Foundation` / `Tableau` variants need formatting; the static
/// variants (`Stock`, `Waste`) still allocate but the cost is
/// trivial against the per-frame update cadence.
fn format_pile(p: &PileType) -> String {
match p {
PileType::Stock => "stock".to_string(),
PileType::Waste => "waste".to_string(),
PileType::Foundation(i) => format!("foundation {}", i + 1),
PileType::Tableau(i) => format!("tableau {}", i + 1),
}
}
/// Pure helper — formats a [`ReplayMove`] as the body of a
/// move-log row. `StockClick` reads as `"stock cycle"`; `Move`
/// reads as `"{from} → {to}"` using [`format_pile`] for both
/// endpoints. The `count` field is omitted from the row body —
/// at row scale it adds visual noise without meaningful
/// information for the typical 1-card moves.
fn format_move_body(m: &ReplayMove) -> String {
match m {
ReplayMove::StockClick => "stock cycle".to_string(),
ReplayMove::Move { from, to, .. } => {
format!("{} \u{2192} {}", format_pile(from), format_pile(to))
}
}
}
/// Pure helper — formats the move-log panel's header text. Reads
/// `▌ MOVE LOG · N/M` while playing, where `N` is the count of
/// moves applied so far and `M` is the total in the replay. The
/// cursor-block prefix (`▌`) matches the splash and replay-banner
/// motifs. Empty in `Inactive` (no replay attached); reads
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
fn format_move_log_header(state: &ReplayPlaybackState) -> String {
match state {
ReplayPlaybackState::Playing { replay, cursor, .. } => {
format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, replay.moves.len())
}
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
ReplayPlaybackState::Inactive => String::new(),
}
}
/// Pure helper — formats the active-row text for the move-log
/// panel. Returns `"{idx} │ {body}"` for the most-recently-applied
/// move (`replay.moves[cursor - 1]`), where `idx` is 1-indexed for
/// player display. Returns the empty string for `cursor == 0`
/// (no move applied yet — panel renders the header alone) and for
/// non-`Playing` states.
fn format_active_move_row(state: &ReplayPlaybackState) -> String {
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
return String::new();
};
if *cursor == 0 {
return String::new();
}
let applied_idx = *cursor - 1;
let Some(m) = replay.moves.get(applied_idx) else {
return String::new();
};
format!("{} \u{2502} {}", *cursor, format_move_body(m))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Playback-control button handlers // Playback-control button handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2268,6 +2497,239 @@ mod tests {
); );
} }
// -----------------------------------------------------------------------
// Move Log panel: helpers + spawn cardinality + lifecycle
// -----------------------------------------------------------------------
fn move_log_panel_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ReplayOverlayMoveLogPanel>()
.iter(app.world())
.count()
}
fn move_log_header_text(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplayOverlayMoveLogHeader>>();
q.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
fn move_log_active_row_text(app: &mut App) -> String {
let mut q = app
.world_mut()
.query_filtered::<&Text, With<ReplayOverlayMoveLogActiveRow>>();
q.iter(app.world())
.next()
.map(|t| t.0.clone())
.unwrap_or_default()
}
/// Pile formatter pins the "lowercase + 1-indexed" contract.
/// `Foundation(2)` displays as `"foundation 3"` rather than
/// the underlying 0-index — players see human-friendly numbers.
#[test]
fn format_pile_uses_one_indexed_lowercase_names() {
use solitaire_core::pile::PileType;
assert_eq!(format_pile(&PileType::Stock), "stock");
assert_eq!(format_pile(&PileType::Waste), "waste");
assert_eq!(format_pile(&PileType::Foundation(0)), "foundation 1");
assert_eq!(format_pile(&PileType::Foundation(2)), "foundation 3");
assert_eq!(format_pile(&PileType::Tableau(0)), "tableau 1");
assert_eq!(format_pile(&PileType::Tableau(6)), "tableau 7");
}
/// Move-body formatter renders `StockClick` as a label and
/// `Move` as a `from → to` arrow. The `count` field is
/// deliberately omitted — at row scale it adds noise.
#[test]
fn format_move_body_handles_both_variants() {
use solitaire_core::pile::PileType;
use solitaire_data::ReplayMove;
assert_eq!(format_move_body(&ReplayMove::StockClick), "stock cycle");
assert_eq!(
format_move_body(&ReplayMove::Move {
from: PileType::Waste,
to: PileType::Tableau(4),
count: 1,
}),
"waste \u{2192} tableau 5",
"Move variant must render as `{{from}} → {{to}}` with 1-indexed pile numbers",
);
}
/// Header text covers all three state branches:
/// `Playing` → `▌ MOVE LOG · N/M`,
/// `Completed` → `▌ MOVE LOG · COMPLETE`,
/// `Inactive` → empty.
#[test]
fn format_move_log_header_covers_state_branches() {
let playing = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
assert_eq!(format_move_log_header(&playing), "\u{258C} MOVE LOG \u{00B7} 3/10");
assert_eq!(
format_move_log_header(&ReplayPlaybackState::Completed),
"\u{258C} MOVE LOG \u{00B7} COMPLETE",
);
assert_eq!(format_move_log_header(&ReplayPlaybackState::Inactive), "");
}
/// Active-row text is empty at cursor 0 (no move applied yet)
/// and populated otherwise. The displayed index is 1-based —
/// when cursor=N, the most-recently-applied move is at
/// `replay.moves[N - 1]` and the row reads `"N | ..."`.
#[test]
fn format_active_move_row_handles_cursor_zero_and_positive() {
let cursor_zero = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
};
assert_eq!(
format_active_move_row(&cursor_zero),
"",
"cursor=0 means no move applied yet; row stays empty",
);
let cursor_three = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
// synthetic_replay produces all StockClicks, so the body
// is "stock cycle". The displayed index is 3 (cursor),
// matching the most-recently-applied move at moves[2].
assert_eq!(
format_active_move_row(&cursor_three),
"3 \u{2502} stock cycle",
"row body must read `cursor │ {{move body}}` with the 1-based displayed index",
);
}
/// Move-log panel spawns alongside the rest of the overlay
/// tree on `Inactive → Playing`. Cardinality is exactly one
/// (singleton bottom-edge panel).
#[test]
fn move_log_panel_spawns_with_overlay() {
let mut app = headless_app();
app.update();
assert_eq!(move_log_panel_count(&mut app), 0);
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(
move_log_panel_count(&mut app),
1,
"exactly one move-log panel must spawn with the overlay",
);
}
/// Spawned panel's header reads `▌ MOVE LOG · N/M` matching
/// the helper output for the active state. Pins the spawn-path
/// against drift between the helper and the actual painted
/// text.
#[test]
fn move_log_panel_header_paints_helper_string() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(8),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(
move_log_header_text(&mut app),
"\u{258C} MOVE LOG \u{00B7} 2/8",
);
}
/// Active-row text repaints when the cursor advances. Drives
/// the resource through cursor=0 → cursor=2 transitions and
/// asserts the row text follows.
#[test]
fn move_log_active_row_repaints_on_cursor_advance() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(
move_log_active_row_text(&mut app),
"",
"cursor=0 must paint an empty row",
);
// Advance cursor to 2 (most-recently-applied move is moves[1]).
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 2,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(
move_log_active_row_text(&mut app),
"2 \u{2502} stock cycle",
"active row must repaint to the cursor's position when state changes",
);
}
/// Panel shares the overlay tree's lifecycle — it despawns on
/// `Playing → Inactive` along with the banner root.
#[test]
fn move_log_panel_despawns_with_overlay() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 0,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(move_log_panel_count(&mut app), 1);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
move_log_panel_count(&mut app),
0,
"panel must despawn with the rest of the overlay tree",
);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// pause_button_label + pause / step click handlers + keyboard accelerator // pause_button_label + pause / step click handlers + keyboard accelerator
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------