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:
@@ -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 + 12 ≈ 45; 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]
|
||||
|
||||
Reference in New Issue
Block a user