feat(replay): add 2 prev rows above active row in Move Log panel

Extends the Move Log panel's single active-row to a 3-row recent-
history window: 2 prev rows showing the moves applied just before
the active one, then the active row. Display order top-to-bottom:
header → prev offset 2 (oldest) → prev offset 1 → active.

Panel grows from 56 → 84 px to fit the additional rows. Active
row keeps TEXT_PRIMARY; prev rows render in TEXT_SECONDARY so
the active row stands out from context rows even without an
explicit highlight. (Active-row highlight is a follow-up commit.)

The format helper generalises:
- New `format_kth_recent_row(state, k)` returns the text for the
  kth-most-recently-applied move (k=1 is active, k=2 is row above,
  etc.). Returns empty when k > cursor (early-replay under-fill)
  or k = 0 (degenerate).
- `format_active_move_row` becomes a thin wrapper for k=1, kept
  at module scope so call sites stay readable.

New `ReplayOverlayMoveLogPrevRow { offset: u8 }` component carries
the row's offset (1 = just-before-active, 2 = before that). Spawn
loop iterates `MOVE_LOG_PREV_ROWS..=1` in reverse so the highest-
offset (oldest) row sits topmost in the panel's flex column.

Per-frame `update_move_log_prev_rows` system reads each row's
offset, computes k = offset + 1, and repaints via
format_kth_recent_row. Empty-when-out-of-range means panels gracefully
under-fill at cursor=1 (only active populated) and cursor=2
(active + offset 1, offset 2 empty).

4 new tests:
- format_kth_recent_row: k=1, 2, 3 in-range cases + k>cursor
  out-of-range + k=0 degenerate.
- move_log_prev_rows_spawn_with_panel: cardinality matches the
  MOVE_LOG_PREV_ROWS const.
- move_log_prev_rows_paint_helper_strings_at_spawn: text matches
  helper output per offset.
- move_log_prev_rows_repaint_on_cursor_advance: drives cursor=2
  → cursor=5 and asserts offset 1 / offset 2 texts follow.

Tests: 1262 → 1266. Clippy clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 17:35:07 -07:00
parent d6f32d3154
commit 140251beae
+263 -19
View File
@@ -96,11 +96,23 @@ const KEYBIND_FOOTER_HEIGHT: f32 = 16.0;
/// 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. Two
/// vertical content rows (header + active-row) at `TYPE_CAPTION`
/// and `TYPE_BODY` plus standard vertical padding lands at
/// 11 + 8 + 14 + 1245; round to 56 for headroom.
const MOVE_LOG_PANEL_HEIGHT: f32 = 56.0;
/// Total height of the bottom-edge Move Log panel in pixels.
/// Sized for: header (`TYPE_CAPTION` 11) + 2 prev rows + active
/// row (`TYPE_BODY` 14 each = 42) + row gaps (~6) + vertical
/// padding (~16)75; round to 84 for headroom.
///
/// Growth history:
/// - 56 in the move-log-panel-init commit (header + active row).
/// - 56 → 84 in the move-log-prev-rows commit to make room for
/// 2 prev rows above the active row.
const MOVE_LOG_PANEL_HEIGHT: f32 = 84.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;
/// 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
@@ -308,6 +320,22 @@ pub struct ReplayOverlayMoveLogHeader;
#[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,
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -353,6 +381,7 @@ impl Plugin for ReplayOverlayPlugin {
update_scrub_fill,
update_move_log_header,
update_move_log_active_row,
update_move_log_prev_rows,
update_pause_button_label,
handle_pause_button,
handle_step_button,
@@ -887,11 +916,36 @@ fn spawn_overlay(
},
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. 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.
// cursor advances. TEXT_PRIMARY (vs prev rows'
// TEXT_SECONDARY) gives the active row more visual
// weight — it's the load-bearing information.
panel.spawn((
ReplayOverlayMoveLogActiveRow,
Text::new(format_active_move_row(state)),
@@ -1136,6 +1190,26 @@ fn update_move_log_active_row(
}
}
/// Repaints every "previous move" row text whenever
/// [`ReplayPlaybackState`] changes. Each row's `offset` is read
/// from the marker; `k = offset + 1` feeds [`format_kth_recent_row`]
/// (active is k=1, prev offset 1 is k=2, prev offset 2 is k=3).
/// Rows with `offset >= cursor` paint as empty — the panel
/// gracefully under-fills early in a replay without spurious
/// "out-of-range" text.
fn update_move_log_prev_rows(
state: Res<ReplayPlaybackState>,
mut q: Query<(&ReplayOverlayMoveLogPrevRow, &mut Text)>,
) {
if !state.is_changed() {
return;
}
for (row, mut text) in &mut q {
let label = format_kth_recent_row(&state, row.offset as usize + 1);
**text = label;
}
}
/// Repaints the bottom-edge accent scrub fill to mirror cursor progress.
/// Same change-detection guard as the text updaters — the overlay
/// already early-exits when nothing moved, so an idle replay leaves the
@@ -1237,24 +1311,39 @@ fn format_move_log_header(state: &ReplayPlaybackState) -> String {
}
}
/// 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 {
/// Pure helper — formats the kth-most-recently-applied move's row
/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`,
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
/// and so on.
///
/// Returns the empty string in any of these cases:
/// - State isn't `Playing` (no replay attached).
/// - `k == 0` (no kth-most-recent for k=0; the active is k=1).
/// - `k > cursor` (not enough history — e.g. cursor=2 has rows
/// for k=1 and k=2 only, k=3 returns empty).
/// - The move list is shorter than expected (defensive guard).
fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
return String::new();
};
if *cursor == 0 {
if k == 0 || k > *cursor {
return String::new();
}
let applied_idx = *cursor - 1;
let Some(m) = replay.moves.get(applied_idx) else {
let zero_idx = *cursor - k;
let Some(m) = replay.moves.get(zero_idx) else {
return String::new();
};
format!("{} \u{2502} {}", *cursor, format_move_body(m))
let display_idx = *cursor - k + 1;
format!("{} \u{2502} {}", display_idx, format_move_body(m))
}
/// Pure helper — formats the active-row text for the move-log
/// panel. Thin wrapper around [`format_kth_recent_row`] with `k=1`.
/// The active row IS the kth-most-recent for k=1, so this exists
/// to keep call sites readable.
fn format_active_move_row(state: &ReplayPlaybackState) -> String {
format_kth_recent_row(state, 1)
}
// ---------------------------------------------------------------------------
@@ -2704,6 +2793,161 @@ mod tests {
);
}
/// `format_kth_recent_row` covers the active-row helper for
/// `k=1` and the prev-row helpers for `k>1`. Pins the "k larger
/// than cursor returns empty" branch so under-filled panels
/// early in a replay don't paint stale text.
#[test]
fn format_kth_recent_row_handles_in_range_and_out_of_range() {
let state_at_three = ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
};
// k=1 → active (most recent applied). cursor=3 → display=3.
assert_eq!(
format_kth_recent_row(&state_at_three, 1),
"3 \u{2502} stock cycle",
);
// k=2 → row above active. display=2.
assert_eq!(
format_kth_recent_row(&state_at_three, 2),
"2 \u{2502} stock cycle",
);
// k=3 → second-prev row. display=1.
assert_eq!(
format_kth_recent_row(&state_at_three, 3),
"1 \u{2502} stock cycle",
);
// k=4 — exceeds cursor, no history that far back.
assert_eq!(
format_kth_recent_row(&state_at_three, 4),
"",
"k > cursor must return empty (panel under-fills gracefully)",
);
// k=0 — degenerate, no kth-most-recent for k=0.
assert_eq!(format_kth_recent_row(&state_at_three, 0), "");
}
fn move_log_prev_row_count(app: &mut App) -> usize {
app.world_mut()
.query::<&ReplayOverlayMoveLogPrevRow>()
.iter(app.world())
.count()
}
fn move_log_prev_row_text_at_offset(app: &mut App, offset: u8) -> String {
let world = app.world_mut();
let mut q = world.query::<(&ReplayOverlayMoveLogPrevRow, &Text)>();
for (row, text) in q.iter(world) {
if row.offset == offset {
return text.0.clone();
}
}
String::new()
}
/// `MOVE_LOG_PREV_ROWS` prev rows spawn with the panel — one
/// per offset 1..=N. Cardinality matches the constant.
#[test]
fn move_log_prev_rows_spawn_with_panel() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 3,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(
move_log_prev_row_count(&mut app),
MOVE_LOG_PREV_ROWS,
"exactly MOVE_LOG_PREV_ROWS prev rows must spawn with the panel",
);
}
/// Each prev row's text at spawn time matches the helper
/// output for its offset. Pins the spawn path against drift
/// between marker offset and rendered text.
#[test]
fn move_log_prev_rows_paint_helper_strings_at_spawn() {
let mut app = headless_app();
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
// offset 1 → k=2 → display=4
assert_eq!(
move_log_prev_row_text_at_offset(&mut app, 1),
"4 \u{2502} stock cycle",
);
// offset 2 → k=3 → display=3
assert_eq!(
move_log_prev_row_text_at_offset(&mut app, 2),
"3 \u{2502} stock cycle",
);
}
/// Prev rows repaint as the cursor advances. Drives the
/// resource through cursor=2 → cursor=5 and asserts the texts
/// follow.
#[test]
fn move_log_prev_rows_repaint_on_cursor_advance() {
let mut app = headless_app();
// Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor).
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_prev_row_text_at_offset(&mut app, 1),
"1 \u{2502} stock cycle",
);
assert_eq!(
move_log_prev_row_text_at_offset(&mut app, 2),
"",
"offset 2 (k=3) must be empty when cursor=2 (no history that far back)",
);
// Advance to cursor=5 — both offsets now have history.
set_state(
&mut app,
ReplayPlaybackState::Playing {
replay: synthetic_replay(10),
cursor: 5,
secs_to_next: 0.5,
paused: false,
},
);
app.update();
assert_eq!(
move_log_prev_row_text_at_offset(&mut app, 1),
"4 \u{2502} stock cycle",
"offset 1 must repaint to k=2 of new cursor (display=4)",
);
assert_eq!(
move_log_prev_row_text_at_offset(&mut app, 2),
"3 \u{2502} stock cycle",
);
}
/// Panel shares the overlay tree's lifecycle — it despawns on
/// `Playing → Inactive` along with the banner root.
#[test]