From 2584e76b22f4e480ba4bc75f0c71e84851e3979b Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 11 Jun 2026 07:02:23 +0000 Subject: [PATCH] feat(ui): demo-mode keystroke badges (#22, ADR-0047 D2/D4/D5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app.rs | 161 +++++++++++++++++- src/runtime.rs | 111 +++++++++--- ...__tests__demo_badge_enter_light_90x26.snap | 30 ++++ ..._ui__tests__demo_badge_tab_dark_90x26.snap | 30 ++++ src/theme.rs | 10 ++ src/ui.rs | 142 +++++++++++++++ 6 files changed, 462 insertions(+), 22 deletions(-) create mode 100644 src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap create mode 100644 src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap diff --git a/src/app.rs b/src/app.rs index fd1f2eb..acdde9c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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))); diff --git a/src/runtime.rs b/src/runtime.rs index ee1b75b..ba9c056 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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, b: Option) -> Option { + 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 = None; + let mut badge_deadline: Option = 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() { diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap new file mode 100644 index 0000000..290d0cb --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ╭─────────╮ │ +│ │ [ENTER] │ │ +│ ╰─────────╯ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap new file mode 100644 index 0000000..ce45304 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ╭───────╮ │ +│ │ [TAB] │ │ +│ ╰───────╯ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/theme.rs b/src/theme.rs index 925e48d..ad2a424 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -20,6 +20,16 @@ use ratatui::style::Color; use crate::dsl::grammar::HighlightClass; +/// Foreground of the demonstration-mode overlays (ADR-0047 D4). +/// +/// Deliberately a fixed, theme-independent high-contrast pair — black +/// on yellow — so the badge / caption boxes are hard to overlook in a +/// screencast on any background. +pub const DEMO_OVERLAY_FG: Color = Color::Black; +/// Background of the demonstration-mode overlays (ADR-0047 D4); see +/// [`DEMO_OVERLAY_FG`]. +pub const DEMO_OVERLAY_BG: Color = Color::Rgb(0xFF, 0xD7, 0x00); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Background { Light, diff --git a/src/ui.rs b/src/ui.rs index 187936e..0392f92 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -113,6 +113,55 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { if let Some(modal) = app.modal.as_ref() { render_modal(modal, theme, frame, area); } + + // ADR-0047 D4: the demo overlays draw last of all — over modals — so + // a keystroke badge (and, in Phase C, a step caption) stays visible + // while the load picker (the #24 cast) or any modal is up. + if app.demo_mode { + render_demo_overlays(app, frame); + } +} + +/// Draw the demonstration-mode overlays anchored to the output panel's +/// inner bottom-right corner (ADR-0047 D4). Phase B renders the +/// keystroke badge; the step-caption box joins it in Phase C. +fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) { + let area = app.last_output_area; + if area.width == 0 || area.height == 0 { + return; // not measured yet + } + if let Some(label) = app.demo_badge { + render_badge_box(label, area, frame); + } +} + +/// A small high-contrast keystroke badge (`[TAB]`, `[ENTER]`, …) inset +/// one cell from the bottom-right of `area` (ADR-0047 D2/D4). Skipped +/// rather than overflowing if the output area is too small to host it. +fn render_badge_box(label: &str, area: Rect, frame: &mut Frame<'_>) { + let style = Style::default() + .bg(crate::theme::DEMO_OVERLAY_BG) + .fg(crate::theme::DEMO_OVERLAY_FG) + .add_modifier(Modifier::BOLD); + // ` [LABEL] ` (one pad each side) inside a rounded border. + let box_w = label.chars().count() as u16 + 4; + let box_h = 3; + if box_w + 1 > area.width || box_h + 1 > area.height { + return; + } + let rect = Rect { + x: area.x + area.width - box_w - 1, + y: area.y + area.height - box_h - 1, + width: box_w, + height: box_h, + }; + frame.render_widget(ratatui::widgets::Clear, rect); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(style); + let para = Paragraph::new(format!(" {label} ")).style(style).block(block); + frame.render_widget(para, rect); } /// Width (columns) of the navigation-mode expanded sidebar overlay @@ -933,6 +982,9 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area // ADR-0044 §3: record the panel width so a later `show relationship` // diagram (rendered App-side) can choose side-by-side vs vertical. app.last_output_width = inner.width; + // ADR-0047 D4: record the full inner area so the top-level draw can + // anchor the demo overlays to the output panel's bottom-right corner. + app.last_output_area = inner; let lines: Vec> = app .output @@ -2966,4 +3018,94 @@ mod tests { let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("nav_overlay_relationships_focused_dark", snapshot); } + + // ---- ADR-0047 (issue #22): demo-mode keystroke badge ---- + + /// Render to a `TestBackend` buffer (for cell-level style checks the + /// text-only `render_to_string` cannot make). + fn render_to_buffer( + app: &mut App, + theme: &Theme, + width: u16, + height: u16, + ) -> ratatui::buffer::Buffer { + if app.project_name.is_none() { + app.project_name = Some("Term Planner".to_string()); + } + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("create terminal"); + terminal.draw(|f| render(app, theme, f)).expect("draw frame"); + terminal.backend().buffer().clone() + } + + #[test] + fn demo_badge_box_renders_at_output_bottom_right() { + // At the 90×26 cast geometry the sidebar is hidden and the badge + // box sits inset in the output panel's bottom-right corner. + let mut app = App::new(); + app.demo_mode = true; + app.demo_badge = Some("[TAB]"); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_badge_tab_dark_90x26", snapshot); + } + + #[test] + fn demo_badge_box_renders_in_light_theme() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_badge = Some("[ENTER]"); + let theme = Theme::light(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_badge_enter_light_90x26", snapshot); + } + + #[test] + fn demo_badge_box_is_black_on_yellow() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_badge = Some("[TAB]"); + let theme = Theme::dark(); + let buffer = render_to_buffer(&mut app, &theme, 90, 26); + // Collect the badge cells (the only ones painted with the fixed + // overlay background) and confirm the high-contrast pairing. + let mut badge_cells = 0; + let mut row_text: std::collections::BTreeMap = Default::default(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + let cell = &buffer[(x, y)]; + if cell.bg == crate::theme::DEMO_OVERLAY_BG { + badge_cells += 1; + assert_eq!( + cell.fg, + crate::theme::DEMO_OVERLAY_FG, + "badge cell at ({x},{y}) must be black-on-yellow" + ); + row_text.entry(y).or_default().push_str(cell.symbol()); + } + } + } + assert!(badge_cells > 0, "expected a yellow badge box to be drawn"); + // The label appears on the box's middle (text) row. + assert!( + row_text.values().any(|line| line.contains("[TAB]")), + "badge text not found among styled rows: {row_text:?}" + ); + } + + #[test] + fn demo_badge_box_skipped_when_area_too_small() { + // ADR-0047 D4 clamp guard: a box that cannot fit the given area + // is not drawn rather than overflowing. + let backend = TestBackend::new(40, 10); + let mut terminal = Terminal::new(backend).expect("create terminal"); + terminal + .draw(|f| super::render_badge_box("[SHIFT-TAB]", Rect::new(0, 0, 5, 3), f)) + .expect("draw frame"); + let buffer = terminal.backend().buffer(); + let drew_badge = (0..buffer.area.height).any(|y| { + (0..buffer.area.width).any(|x| buffer[(x, y)].bg == crate::theme::DEMO_OVERLAY_BG) + }); + assert!(!drew_badge, "badge must be skipped when it cannot fit"); + } }