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:
claude@clouddev1
2026-06-11 07:02:23 +00:00
parent f879d54721
commit 2584e76b22
6 changed files with 462 additions and 22 deletions
+158 -3
View File
@@ -9,6 +9,7 @@
use std::collections::VecDeque;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::Rect;
use tracing::{debug, trace, warn};
use crate::action::Action;
@@ -323,6 +324,12 @@ pub struct App {
/// diagram's side-by-side vs vertical layout choice. Defaults to
/// `80` until the first render measures the real width.
pub last_output_width: u16,
/// The most recent **inner area** (inside the border) of the output
/// panel, recorded by the renderer (ADR-0047 D4). The demo overlays
/// anchor to its bottom-right corner; read at the top-level draw
/// pass, which otherwise does not know where the output panel sits.
/// Zero-sized until the first render measures it.
pub last_output_area: Rect,
/// Top visible row of the Tables / Relationships sidebar panels
/// while scrolled in navigation mode (ADR-0046 DC3), with the most
/// recent visible-row count the renderer reported for each (used to
@@ -372,10 +379,21 @@ pub struct App {
/// issue #22). `true` under `--demo` / `RDBMS_PLAYGROUND_DEMO`. When
/// off (the default) none of the demo key handling or overlay
/// rendering runs — zero footprint. When on, otherwise-invisible
/// keys raise a transient badge (set by the runtime, see
/// `demo_badge`) and `Ctrl+]` drives the stealth step-caption
/// buffer (`demo_caption` / `demo_capturing`).
/// keys raise a transient badge (`demo_badge`) and `Ctrl+]` drives
/// the stealth step-caption buffer (`demo_caption` / `demo_capturing`,
/// Phase C).
pub demo_mode: bool,
/// The keystroke badge currently displayed in demo mode (ADR-0047
/// D2), e.g. `"[TAB]"`. Set in `update()` when an otherwise-invisible
/// key is handled; cleared by the runtime when its ~1.5 s timer
/// elapses (the timing lives in the runtime, mirroring how
/// `input_indicator` is driven from `IndicatorDebounce`). `None` when
/// no badge is showing.
pub demo_badge: Option<&'static str>,
/// Monotonic counter bumped every time `demo_badge` is (re)set
/// (ADR-0047 D5). The runtime watches it so a *new* badge — even the
/// same label twice in a row (Tab, Tab) — restarts the expiry timer.
pub demo_badge_seq: u64,
/// The DSL → SQL teaching echo (ADR-0038) for the command currently
/// being rendered: set from the success event just before its handler
/// runs, consumed by `note_ok_summary` (which pushes it beneath
@@ -478,6 +496,36 @@ const PAGE_SCROLL_LINES: usize = 5;
const HISTORY_CAPACITY: usize = 1000;
/// The demo-mode keystroke badge for `key`, or `None` if the key
/// produces a glyph of its own (and so needs no badge) — ADR-0047 D2.
///
/// The set is exactly the *otherwise-invisible* keys: motion, editing,
/// submission, and the `Ctrl-O` navigation toggle. Plain character keys
/// already appear on the input line, and `Ctrl-C` (quit) / `Ctrl+]`
/// (the caption toggle) are deliberately excluded. Pure and total, so
/// it is exhaustively unit-testable without a running app.
pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> {
match (key.code, key.modifiers) {
(KeyCode::Tab, _) => Some("[TAB]"),
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
(KeyCode::Enter, _) => Some("[ENTER]"),
(KeyCode::Esc, _) => Some("[ESC]"),
(KeyCode::Up, _) => Some("[UP]"),
(KeyCode::Down, _) => Some("[DOWN]"),
(KeyCode::Left, _) => Some("[LEFT]"),
(KeyCode::Right, _) => Some("[RIGHT]"),
(KeyCode::Home, _) => Some("[HOME]"),
(KeyCode::End, _) => Some("[END]"),
(KeyCode::PageUp, _) => Some("[PGUP]"),
(KeyCode::PageDown, _) => Some("[PGDN]"),
(KeyCode::Backspace, _) => Some("[BKSP]"),
(KeyCode::Delete, _) => Some("[DEL]"),
// The only badged control chord: the ADR-0046 navigation toggle.
(KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => Some("[CTRL-O]"),
_ => None,
}
}
impl Default for App {
fn default() -> Self {
Self::new()
@@ -507,6 +555,7 @@ impl App {
last_output_visible: 0,
last_output_total_wrapped: 0,
last_output_width: 80,
last_output_area: Rect::new(0, 0, 0, 0),
tables_scroll: 0,
relationships_scroll: 0,
last_tables_visible: 0,
@@ -523,6 +572,8 @@ impl App {
// Demo mode is off by default; the runtime flips it on for
// a `--demo` session (ADR-0047).
demo_mode: false,
demo_badge: None,
demo_badge_seq: 0,
pending_echo: None,
}
}
@@ -1039,6 +1090,19 @@ impl App {
}
trace!(?key, "handle_key");
// ADR-0047 D2: in demo mode raise a transient badge for an
// otherwise-invisible key. Done before every gate below so it
// fires even while a modal is open (the `#24` projects cast) or
// in navigation mode. The runtime times its expiry (D5). (Phase
// C inserts the caption-capture gate *above* this, so captured
// keystrokes return early and never raise a badge.)
if self.demo_mode
&& let Some(label) = demo_badge_label(&key)
{
self.demo_badge = Some(label);
self.demo_badge_seq = self.demo_badge_seq.wrapping_add(1);
}
// While a modal is open it owns the keyboard. Normal
// input editing, history navigation, and command
// submission are all gated behind closing the modal.
@@ -2934,6 +2998,97 @@ mod tests {
AppEvent::Key(KeyEvent::new(code, mods))
}
// ---- ADR-0047 (issue #22): demo-mode keystroke badges ----
fn ke(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
#[test]
fn demo_badge_label_maps_the_invisible_keys() {
let none = KeyModifiers::NONE;
assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]"));
assert_eq!(demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), Some("[SHIFT-TAB]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Down, none)), Some("[DOWN]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Left, none)), Some("[LEFT]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Right, none)), Some("[RIGHT]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]"));
assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)),
Some("[CTRL-O]")
);
}
#[test]
fn demo_badge_label_none_for_glyphs_and_excluded_chords() {
// Plain characters render their own glyph — no badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None);
assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None);
// Quit and the (Phase C) caption toggle are deliberately excluded.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), None);
// Ctrl+] decodes to Char('5')+CONTROL — must not badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), None);
}
#[test]
fn demo_mode_off_never_sets_a_badge() {
let mut app = App::new();
assert!(!app.demo_mode);
app.update(key(KeyCode::Tab));
assert_eq!(app.demo_badge, None);
assert_eq!(app.demo_badge_seq, 0);
}
#[test]
fn demo_mode_on_sets_badge_and_bumps_seq() {
let mut app = App::new();
app.demo_mode = true;
app.update(key(KeyCode::Tab));
assert_eq!(app.demo_badge, Some("[TAB]"));
assert_eq!(app.demo_badge_seq, 1);
app.update(key(KeyCode::Enter));
assert_eq!(app.demo_badge, Some("[ENTER]"));
assert_eq!(app.demo_badge_seq, 2);
// The same label twice still bumps the seq so the runtime
// restarts the expiry timer.
app.update(key(KeyCode::Enter));
assert_eq!(app.demo_badge, Some("[ENTER]"));
assert_eq!(app.demo_badge_seq, 3);
// A glyph key leaves the badge (and seq) untouched — the
// runtime's timer is what clears it, not the next key.
app.update(key(KeyCode::Char('x')));
assert_eq!(app.demo_badge, Some("[ENTER]"));
assert_eq!(app.demo_badge_seq, 3);
}
#[test]
fn demo_badge_fires_over_an_open_modal() {
// Badges are set before the modal gate, so the `#24` projects
// cast can show [ENTER]/[DOWN] while the load picker is up.
let mut app = App::new();
app.demo_mode = true;
app.modal = Some(Modal::LoadPicker(LoadPickerModal {
entries: Vec::new(),
selected: 0,
sub_mode: LoadPickerSubMode::List,
}));
app.update(key(KeyCode::Down));
assert_eq!(app.demo_badge, Some("[DOWN]"));
assert_eq!(app.demo_badge_seq, 1);
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));