Adaptive auto-continue input lock overlay

This commit is contained in:
2026-02-27 02:31:37 +00:00
parent 3ef433404e
commit a088075924
3 changed files with 218 additions and 7 deletions

View File

@@ -0,0 +1,93 @@
# Adaptive Auto-Continue Input Lock Overlay
## Context
In adaptive mode, when a drill completes with no milestone popups to show, the app auto-continues to the next drill immediately (`finish_drill``start_drill()` with no intermediate screen). The existing 800ms input lock (`POST_DRILL_INPUT_LOCK_MS`) is only armed when there IS an intermediate screen (DrillResult or milestone popup). This means trailing keystrokes from the previous drill can bleed into the next drill as unintended inputs.
The fix: arm the same 800ms lock during adaptive auto-continue, block drill input while it's active, and show a small countdown popup overlay on the drill screen so the user knows why their input is temporarily ignored.
## Changes
### 1. Arm the lock on adaptive auto-continue
**`src/app.rs``finish_drill()`**
Currently the auto-continue path does not arm the lock:
```rust
if self.drill_mode == DrillMode::Adaptive && self.milestone_queue.is_empty() {
self.start_drill();
}
```
Add `arm_post_drill_input_lock()` after `start_drill()`. It must come after because `start_drill()` calls `clear_post_drill_input_lock()` as its first action (to clear stale locks from manual continues). Re-arming immediately after means the 800ms window starts from when the new drill begins:
```rust
if self.drill_mode == DrillMode::Adaptive && self.milestone_queue.is_empty() {
self.start_drill();
self.arm_post_drill_input_lock();
}
```
**Event ordering safety**: The event loop in `run_app()` is single-threaded: `draw``events.next()``handle_key` → loop. `finish_drill()` runs inside a `handle_key()` call, so both `start_drill()` and `arm_post_drill_input_lock()` complete within the same event iteration. Any buffered key events are processed in subsequent loop iterations, where the lock is already active.
### 2. Allow Ctrl+C through the lock and add Drill screen to lock guard
**`src/main.rs``handle_key()`**
Move the Ctrl+C quit handler ABOVE the input lock guard so it always works, even during lockout. Then add `AppScreen::Drill` to the lock guard:
```rust
// Ctrl+C always quits, even during input lock
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
app.should_quit = true;
return;
}
// Briefly block all input right after a drill completes to avoid accidental
// popup dismissal or continuation from trailing keystrokes.
if app.post_drill_input_lock_remaining_ms().is_some()
&& (!app.milestone_queue.is_empty()
|| app.screen == AppScreen::DrillResult
|| app.screen == AppScreen::Drill)
{
return;
}
```
This is a behavior change for the existing DrillResult/milestone lock too: previously Ctrl+C was blocked during the 800ms window, now it passes through. All other keys remain blocked. The 800ms window is short enough that blocking everything else is not disruptive.
### 3. Render lock overlay on the drill screen
**`src/main.rs` — end of `render_drill()`**
After all existing drill UI is rendered, if the lock is active, draw a small centered popup overlay on top of the typing area:
- Check `app.post_drill_input_lock_remaining_ms()` — if `None`, skip overlay entirely
- **Size**: 3 rows tall (top border + message + bottom border), width = message length + 4 (border + padding), centered within the full `area` rect
- **Clear** the overlay rect with `ratatui::widgets::Clear`
- **Block**: `Block::bordered()` with `colors.accent()` border style and `colors.bg()` background — same pattern as `render_milestone_overlay`
- **Message**: `"Keys re-enabled in {ms}ms"` as a `Paragraph` with `colors.text_pending()` style — matches the milestone overlay footer color
- Render inside the block's `inner()` area
This overlay is intentionally small (single bordered line) since the drill content should remain visible behind it and it only appears for ≤800ms.
**Countdown repainting**: The event loop (`run_app()`) uses `EventHandler::new(Duration::from_millis(100))` which sends `AppEvent::Tick` every 100ms when idle. Each tick triggers `terminal.draw()`, which re-renders the drill screen. `post_drill_input_lock_remaining_ms()` recomputes the remaining time from `Instant::now()` on each call, so the countdown value updates every ~100ms without any additional machinery.
### 4. Tests
**`src/app.rs`** — add to the existing `#[cfg(test)] mod tests`:
1. **`adaptive_auto_continue_arms_input_lock`**: Create `App`, verify it starts in adaptive mode with a drill. Simulate completing the drill by calling `finish_drill()` (set up drill state as complete first). Assert `post_drill_input_lock_remaining_ms().is_some()` and `screen == AppScreen::Drill` after auto-continue.
2. **`adaptive_auto_continue_lock_not_armed_with_milestones`**: Same setup but push a milestone into `milestone_queue` before calling `finish_drill()`. Assert `screen == AppScreen::DrillResult` (not auto-continued) and lock is armed via the existing milestone path.
## Files to modify
- `src/app.rs` — 1-line addition in `finish_drill()` auto-continue path; 2 tests
- `src/main.rs` — extend input lock guard condition in `handle_key()`; add overlay rendering at end of `render_drill()`
## Verification
1. `cargo test` — all existing and new tests pass
2. Manual: start adaptive drill, complete it. Verify small popup appears briefly over the next drill, countdown decrements every ~100ms, then disappears and typing works normally
3. Manual: complete adaptive drill that triggers a milestone popup. Verify milestone popup still works as before (no double-lock or interference)
4. Manual: complete Code or Passage drill. Verify DrillResult screen lockout still works as before