diff --git a/solitaire_engine/src/replay_overlay.rs b/solitaire_engine/src/replay_overlay.rs index 87631aa..90ce257 100644 --- a/solitaire_engine/src/replay_overlay.rs +++ b/solitaire_engine/src/replay_overlay.rs @@ -87,6 +87,14 @@ const SCRUB_LABEL_ROW_HEIGHT: f32 = 16.0; /// (12 px) + 4 px breathing room. const KEYBIND_FOOTER_HEIGHT: f32 = 16.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; + /// 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. @@ -228,6 +236,23 @@ pub struct ReplayOverlayScrubNotch; #[derive(Component, Debug)] pub struct ReplayOverlayScrubNotchLabel; +/// Per-arrow-key time-since-last-fire accumulators that drive the +/// continuous-scrub repeat behaviour for held arrow keys. Each +/// frame the key is held, the corresponding accumulator absorbs +/// `time.delta_secs()`; when it exceeds +/// [`SCRUB_REPEAT_INTERVAL_SECS`] the handler fires another step +/// and resets the accumulator. +/// +/// `just_pressed` events bypass the accumulator entirely and fire +/// immediately — only *repeat* fires (while held) are gated by +/// the interval. Releases reset the accumulator to 0 so the next +/// fresh press fires immediately rather than at half-interval. +#[derive(Resource, Default, Debug)] +struct ReplayScrubKeyHold { + left_held_secs: f32, + right_held_secs: f32, +} + /// 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 @@ -274,7 +299,8 @@ impl Plugin for ReplayOverlayPlugin { // `MinimalPlugins` without the playback plugin attached; // `add_message` is idempotent so the duplicate registration // in production (alongside `replay_playback`) is harmless. - app.add_message::() + app.init_resource::() + .add_message::() .add_message::() .add_message::() .add_systems( @@ -1110,34 +1136,65 @@ fn handle_pause_keyboard( toggle_pause_replay_playback(&mut state); } -/// Watches the arrow keys for the paused single-step +/// Watches the arrow keys for the paused step / scrub /// accelerators. UI-first contract from CLAUDE.md §3.3 is /// satisfied by the on-screen Step button (forward only); these /// are the optional accelerators that also surface a backwards -/// step. +/// step plus continuous scrub. /// /// Both keys are paused-only — the underlying step helpers /// hard-gate via destructure on `paused: true`. Pressing → during /// running playback or ← at cursor 0 are silent no-ops; the /// player learns "pause first, then arrow." /// -/// The mockup labels these `[← →] scrub` (continuous fast scan). -/// Single-move step is the closest behaviour shippable today; the -/// footer hint reads `[← →] step` to match what's wired rather -/// than the aspirational "scrub." +/// **Single press fires once immediately** +/// (`just_pressed`). **Holding** the key triggers continuous +/// scrub at [`SCRUB_REPEAT_INTERVAL_SECS`] cadence (10 steps/sec +/// at 100 ms): the per-key accumulator on +/// [`ReplayScrubKeyHold`] absorbs `time.delta_secs()` each frame +/// the key is held, fires + resets when the threshold is hit, and +/// resets to 0 on key release so the next fresh press fires +/// immediately. This matches the mockup's `[← →] scrub` +/// terminology while keeping single-press = single-step semantics. fn handle_arrow_keyboard( keys: Option>>, + time: Res