feat(ui): demo-mode keystroke badges (#22, ADR-0047 D2/D4/D5)
In --demo mode, an otherwise-invisible key (Tab, Enter, arrows, Ctrl-O, …) raises a transient [LABEL] badge — a floating black-on-yellow box inset at the output panel's bottom-right. Set in App::update before the modal gate (so it shows over the load picker, the #24 cast); pure demo_badge_label maps the key set. The runtime expires it on a ~1.5s timer via a new nearest_deadline helper that extends the existing time-boxed-recv arm condition without disturbing the ADR-0027 indicator debounce. New App.last_output_area lets the top-level draw anchor the overlay; overlay colours centralised in theme.rs. Tier 1 (label fn, badge set/seq, over-modal), Tier 2 (dark/light snapshots, black-on-yellow style, too-small clamp), runtime unit (nearest_deadline). Phase B of ADR-0047; captions land in C.
This commit is contained in:
+92
-19
@@ -11,7 +11,7 @@
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{Event as CtEvent, EventStream};
|
||||
@@ -53,6 +53,24 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
|
||||
/// reappears once typing stops (ADR-0027 §3).
|
||||
const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// How long a demo-mode keystroke badge stays on screen before it
|
||||
/// fades on its own (ADR-0047 D5). Long enough to read in a screencast
|
||||
/// or in front of a class; short enough that a trailing `wait` in a
|
||||
/// cast ends on a clean frame.
|
||||
const DEMO_BADGE_TTL: Duration = Duration::from_millis(1500);
|
||||
|
||||
/// The nearest (soonest) of two optional deadlines (ADR-0047 D5) — the
|
||||
/// instant the event loop should next wake to service a timer. `None`
|
||||
/// when neither is set (the loop then blocks on `recv`). Pure, so the
|
||||
/// scheduling decision is unit-testable without the loop.
|
||||
fn nearest_deadline(a: Option<Instant>, b: Option<Instant>) -> Option<Instant> {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// The input-validity indicator's debounce state machine
|
||||
/// (ADR-0027 §3, step E).
|
||||
///
|
||||
@@ -383,6 +401,17 @@ async fn run_loop(
|
||||
// no wake-ups. See `IndicatorDebounce` for the decision
|
||||
// logic; `app.input_indicator` mirrors it for the renderer.
|
||||
let mut debounce = IndicatorDebounce::default();
|
||||
// ADR-0027 §3 + ADR-0047 D5: absolute deadlines for the two timed
|
||||
// wake-ups — the indicator debounce and the demo keystroke-badge
|
||||
// expiry. The loop time-boxes `recv` on the *nearest* of them and,
|
||||
// on elapse, services whichever actually fired. Tracking them as
|
||||
// `Instant`s (rather than one fixed `timeout` duration) lets the
|
||||
// shorter badge timer fire without prematurely settling the longer
|
||||
// debounce, and vice-versa. Both `None` ⇒ block on `recv` (no idle
|
||||
// wake-ups).
|
||||
let mut debounce_deadline: Option<Instant> = None;
|
||||
let mut badge_deadline: Option<Instant> = None;
|
||||
let mut last_badge_seq: u64 = app.demo_badge_seq;
|
||||
// Long-lived native clipboard for the `copy` command (ADR-0041).
|
||||
// Created lazily on first copy (so an OSC-52-only session never
|
||||
// opens an X11 connection) and kept alive for the session — the
|
||||
@@ -390,25 +419,36 @@ async fn run_loop(
|
||||
// handle, so it must outlive each write.
|
||||
let mut native_clipboard = crate::clipboard::SystemClipboard::new();
|
||||
loop {
|
||||
let event = if debounce.is_armed() {
|
||||
match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await {
|
||||
Ok(Some(event)) => event,
|
||||
Ok(None) => break,
|
||||
Err(_elapsed) => {
|
||||
// Typing has been quiet for the debounce
|
||||
// interval — settle the indicator.
|
||||
debounce.settle(app.input_validity_verdict());
|
||||
app.input_indicator = debounce.visible();
|
||||
terminal
|
||||
.draw(|f| ui::render(&mut app, &theme, f))
|
||||
.context("redraw")?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match event_rx.recv().await {
|
||||
let event = match nearest_deadline(debounce_deadline, badge_deadline) {
|
||||
None => match event_rx.recv().await {
|
||||
Some(event) => event,
|
||||
None => break,
|
||||
},
|
||||
Some(deadline) => {
|
||||
let wait = deadline.saturating_duration_since(Instant::now());
|
||||
match tokio::time::timeout(wait, event_rx.recv()).await {
|
||||
Ok(Some(event)) => event,
|
||||
Ok(None) => break,
|
||||
Err(_elapsed) => {
|
||||
let now = Instant::now();
|
||||
// ADR-0047 D5: the keystroke badge has aged out.
|
||||
if badge_deadline.is_some_and(|d| d <= now) {
|
||||
app.demo_badge = None;
|
||||
badge_deadline = None;
|
||||
}
|
||||
// ADR-0027 §3: typing has paused for the debounce
|
||||
// interval — settle the validity indicator.
|
||||
if debounce_deadline.is_some_and(|d| d <= now) {
|
||||
debounce.settle(app.input_validity_verdict());
|
||||
app.input_indicator = debounce.visible();
|
||||
debounce_deadline = None;
|
||||
}
|
||||
terminal
|
||||
.draw(|f| ui::render(&mut app, &theme, f))
|
||||
.context("redraw")?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let is_key = matches!(event, AppEvent::Key(_));
|
||||
@@ -591,6 +631,23 @@ async fn run_loop(
|
||||
// pauses; non-key events leave it untouched.
|
||||
debounce.note_event(is_key);
|
||||
app.input_indicator = debounce.visible();
|
||||
// Keep the debounce deadline in lock-step with `is_armed()`,
|
||||
// restarting it on every event while armed (preserving the prior
|
||||
// behaviour) and clearing it once the indicator is visible again.
|
||||
debounce_deadline = debounce
|
||||
.is_armed()
|
||||
.then(|| Instant::now() + INDICATOR_DEBOUNCE);
|
||||
// ADR-0047 D5: (re)arm the badge timer whenever `update()` set a
|
||||
// fresh badge. `demo_badge_seq` bumps even for the same label
|
||||
// twice, so a repeated key restarts the timer rather than letting
|
||||
// a stale deadline expire it early.
|
||||
if app.demo_badge_seq != last_badge_seq {
|
||||
last_badge_seq = app.demo_badge_seq;
|
||||
badge_deadline = app
|
||||
.demo_badge
|
||||
.is_some()
|
||||
.then(|| Instant::now() + DEMO_BADGE_TTL);
|
||||
}
|
||||
terminal
|
||||
.draw(|f| ui::render(&mut app, &theme, f))
|
||||
.context("redraw")?;
|
||||
@@ -3012,8 +3069,24 @@ fn teardown_terminal(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::IndicatorDebounce;
|
||||
use super::{IndicatorDebounce, nearest_deadline};
|
||||
use crate::dsl::walker::Severity;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn nearest_deadline_picks_the_soonest_or_none() {
|
||||
let now = Instant::now();
|
||||
let soon = now + Duration::from_millis(100);
|
||||
let later = now + Duration::from_millis(500);
|
||||
// Neither armed ⇒ block (None).
|
||||
assert_eq!(nearest_deadline(None, None), None);
|
||||
// One armed ⇒ that one, regardless of order.
|
||||
assert_eq!(nearest_deadline(Some(soon), None), Some(soon));
|
||||
assert_eq!(nearest_deadline(None, Some(soon)), Some(soon));
|
||||
// Both armed ⇒ the soonest, regardless of order.
|
||||
assert_eq!(nearest_deadline(Some(soon), Some(later)), Some(soon));
|
||||
assert_eq!(nearest_deadline(Some(later), Some(soon)), Some(soon));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starts_hidden_and_disarmed() {
|
||||
|
||||
Reference in New Issue
Block a user