feat(replay): add keybind-hint footer to overlay banner

Vim-style mode line on the left (`▌ NORMAL │ replay`) plus a
keybind-hint on the right (`[SPACE] pause/resume`) gives the
existing Space accelerator a visible UI counterpart, satisfying
the UI-first contract from CLAUDE.md §3.3 for the keyboard
accelerator that v0.21.4 shipped.

The footer lists only keybinds that are *actually wired today*.
Future commits that wire ESC for stop or ← / → for prev/next
move will extend the right-hand text in lockstep — the footer
never lists aspirational keybinds (would lie to users).

Banner height grew from 76 → 92 px to make room for the 16 px
footer row. Second layout-changing commit in B-2's screen-
takeover arc; same "grow container, add flex-column child"
pattern as the notch-labels commit. 1px top border in
BORDER_SUBTLE separates the footer from the notch-label row.

Two pure helpers (`keybind_footer_mode_text`,
`keybind_footer_hint_text`) keep the static text testable
without per-text marker components on the inner Text entities.
The shared `font_handle_for_labels` clone covers both label and
footer text spawns since the labels closure only `.clone()`s
the handle (never moves it).

4 new tests: pure-helper guards, footer-spawn cardinality
(exactly one), text-set assertion (both helper strings appear as
descendants), lifecycle parity with the overlay tree.

Tests: 1236 → 1240 (+4). Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 15:58:28 -07:00
parent d11d97e677
commit 1873b3f9be
+229 -12
View File
@@ -61,14 +61,16 @@ pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
/// gameplay surface visible underneath, tall enough to comfortably fit /// gameplay surface visible underneath, tall enough to comfortably fit
/// the headline-sized "▌ replay" label stacked above the /// the headline-sized "▌ replay" label stacked above the
/// `TYPE_CAPTION` "GAME #YYYY-DDD" subtitle (the left column needs /// `TYPE_CAPTION` "GAME #YYYY-DDD" subtitle (the left column needs
/// ~26 + 2 + 11 = 39 px of inner content; banner = scrub (1) + label /// ~26 + 2 + 11 = 39 px of inner content; banner = top row (59
/// row (16) + vertical padding (16) + content gives 76 with a few px /// flex-grow) + scrub track (1) + label row (16) + footer (16)
/// headroom). /// gives 92).
/// ///
/// Grew from 60 → 76 in the scrub-notch-labels commit to make room /// Growth history:
/// for the percentage labels (`0%` / `25%` / … / `100%`) under each /// - 60 → 76 in the scrub-notch-labels commit to make room for the
/// notch on the scrub track. /// `0%` / … / `100%` percentage labels under each notch.
const BANNER_HEIGHT: f32 = 76.0; /// - 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 /// Height of the label row that sits below the 1px scrub track and
/// carries the `0%` / `25%` / `50%` / `75%` / `100%` notch labels. /// carries the `0%` / `25%` / `50%` / `75%` / `100%` notch labels.
@@ -76,6 +78,13 @@ const BANNER_HEIGHT: f32 = 76.0;
/// room above the bottom edge). /// room above the bottom edge).
const SCRUB_LABEL_ROW_HEIGHT: f32 = 16.0; 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;
/// 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.
@@ -217,6 +226,21 @@ pub struct ReplayOverlayScrubNotch;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ReplayOverlayScrubNotchLabel; 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;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -325,11 +349,14 @@ fn spawn_overlay(
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is // the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
// `Arc`-backed, the clone bumps a refcount. // `Arc`-backed, the clone bumps a refcount.
let font_handle_for_floating = font_handle.clone(); let font_handle_for_floating = font_handle.clone();
// Second clone for the scrub-bar label row inside the outer // Second clone for the scrub-bar label row and keybind footer
// banner closure. The inner top-row closure consumes the // inside the outer banner closure. The inner top-row closure
// original `font_handle` for the progress-chip text, so by the // consumes the original `font_handle` for the progress-chip
// time the outer closure reaches the label-row spawn the // text, so by the time the outer closure reaches the
// original is gone. // 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(); let font_handle_for_labels = font_handle.clone();
let banner_label = if state.is_completed() { let banner_label = if state.is_completed() {
@@ -630,6 +657,49 @@ fn spawn_overlay(
)); ));
} }
}); });
// 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),
))
.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),
));
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 // Floating progress chip — a 2D world-space `Text2d` rendered
@@ -693,6 +763,26 @@ fn scrub_notch_labels() -> [&'static str; 5] {
["0%", "25%", "50%", "75%", "100%"] ["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. Future commits that wire ESC for stop or ← / → for
/// scrub will extend this string — the footer never lists
/// unimplemented keybinds (would lie to users).
fn keybind_footer_hint_text() -> &'static str {
"[SPACE] pause/resume"
}
/// Pure helper — returns the WIN MOVE marker's left-edge position as /// Pure helper — returns the WIN MOVE marker's left-edge position as
/// a percentage of the scrub track, or `None` when no marker should /// a percentage of the scrub track, or `None` when no marker should
/// be drawn. /// be drawn.
@@ -1784,6 +1874,133 @@ mod tests {
); );
} }
fn keybind_footer_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ReplayOverlayKeybindFooter>()
.iter(app.world())
.count()
}
/// Returns every `Text` rendered as a descendant of the
/// keybind-footer row. Used to assert the mode + hint texts
/// appear inside the footer without requiring per-text markers.
fn keybind_footer_text_set(app: &mut App) -> Vec<String> {
let world = app.world_mut();
// Find the footer entity, then walk its descendants for `Text`.
let mut footer_q = world.query_filtered::<Entity, With<ReplayOverlayKeybindFooter>>();
let Some(footer) = footer_q.iter(world).next() else {
return Vec::new();
};
let mut child_q = world.query::<&Children>();
let Ok(children) = child_q.get(world, footer) else {
return Vec::new();
};
let child_entities: Vec<Entity> = children.iter().collect();
let mut text_q = world.query::<&Text>();
child_entities
.into_iter()
.filter_map(|e| text_q.get(world, e).ok().map(|t| t.0.clone()))
.collect()
}
/// Pure-helper guards for the static text strings. Pin both
/// helpers so a future refactor that reformats the mode line
/// or extends the hint with un-wired keybinds fails at the
/// helper test rather than at visual review.
#[test]
fn keybind_footer_helpers_carry_expected_text() {
assert_eq!(
keybind_footer_mode_text(),
"\u{258C} NORMAL \u{2502} replay",
"mode line must read as the cursor-block + NORMAL + bar + replay motif",
);
assert_eq!(
keybind_footer_hint_text(),
"[SPACE] pause/resume",
"hint text must list only wired keybinds (Space → pause/resume)",
);
}
/// Footer entity spawns alongside the rest of the overlay tree
/// on `Inactive → Playing`. Cardinality is exactly one — the
/// footer is a singleton row, not a per-keybind multiple.
#[test]
fn keybind_footer_spawns_with_overlay() {
let mut app = headless_app();
app.update();
assert_eq!(keybind_footer_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!(
keybind_footer_count(&mut app),
1,
"exactly one keybind-footer row must spawn with the overlay",
);
}
/// Spawned footer carries both helper strings as direct-child
/// `Text` content — pins the spawn-path against drift between
/// the helpers and the actual painted text.
#[test]
fn keybind_footer_paints_helper_strings() {
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();
let texts = keybind_footer_text_set(&mut app);
assert!(
texts.contains(&keybind_footer_mode_text().to_string()),
"footer must contain the mode-line text; got {texts:?}",
);
assert!(
texts.contains(&keybind_footer_hint_text().to_string()),
"footer must contain the keybind-hint text; got {texts:?}",
);
}
/// Footer shares the overlay tree's lifecycle — it despawns on
/// `Playing → Inactive` along with the banner root.
#[test]
fn keybind_footer_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!(keybind_footer_count(&mut app), 1);
set_state(&mut app, ReplayPlaybackState::Inactive);
app.update();
assert_eq!(
keybind_footer_count(&mut app),
0,
"footer must despawn with the rest of the overlay tree",
);
}
/// Notches are independent of `win_move_index` — a replay with no /// Notches are independent of `win_move_index` — a replay with no
/// win marker still gets the full five-notch ladder (notches give /// win marker still gets the full five-notch ladder (notches give
/// quarter-mark anchor points; the win marker is an additional /// quarter-mark anchor points; the win marker is an additional