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 std::collections::VecDeque;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::Rect;
use tracing::{debug, trace, warn}; use tracing::{debug, trace, warn};
use crate::action::Action; use crate::action::Action;
@@ -323,6 +324,12 @@ pub struct App {
/// diagram's side-by-side vs vertical layout choice. Defaults to /// diagram's side-by-side vs vertical layout choice. Defaults to
/// `80` until the first render measures the real width. /// `80` until the first render measures the real width.
pub last_output_width: u16, 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 /// Top visible row of the Tables / Relationships sidebar panels
/// while scrolled in navigation mode (ADR-0046 DC3), with the most /// while scrolled in navigation mode (ADR-0046 DC3), with the most
/// recent visible-row count the renderer reported for each (used to /// 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 /// issue #22). `true` under `--demo` / `RDBMS_PLAYGROUND_DEMO`. When
/// off (the default) none of the demo key handling or overlay /// off (the default) none of the demo key handling or overlay
/// rendering runs — zero footprint. When on, otherwise-invisible /// rendering runs — zero footprint. When on, otherwise-invisible
/// keys raise a transient badge (set by the runtime, see /// keys raise a transient badge (`demo_badge`) and `Ctrl+]` drives
/// `demo_badge`) and `Ctrl+]` drives the stealth step-caption /// the stealth step-caption buffer (`demo_caption` / `demo_capturing`,
/// buffer (`demo_caption` / `demo_capturing`). /// Phase C).
pub demo_mode: bool, 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 /// The DSL → SQL teaching echo (ADR-0038) for the command currently
/// being rendered: set from the success event just before its handler /// being rendered: set from the success event just before its handler
/// runs, consumed by `note_ok_summary` (which pushes it beneath /// 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; 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 { impl Default for App {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@@ -507,6 +555,7 @@ impl App {
last_output_visible: 0, last_output_visible: 0,
last_output_total_wrapped: 0, last_output_total_wrapped: 0,
last_output_width: 80, last_output_width: 80,
last_output_area: Rect::new(0, 0, 0, 0),
tables_scroll: 0, tables_scroll: 0,
relationships_scroll: 0, relationships_scroll: 0,
last_tables_visible: 0, last_tables_visible: 0,
@@ -523,6 +572,8 @@ impl App {
// Demo mode is off by default; the runtime flips it on for // Demo mode is off by default; the runtime flips it on for
// a `--demo` session (ADR-0047). // a `--demo` session (ADR-0047).
demo_mode: false, demo_mode: false,
demo_badge: None,
demo_badge_seq: 0,
pending_echo: None, pending_echo: None,
} }
} }
@@ -1039,6 +1090,19 @@ impl App {
} }
trace!(?key, "handle_key"); 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 // While a modal is open it owns the keyboard. Normal
// input editing, history navigation, and command // input editing, history navigation, and command
// submission are all gated behind closing the modal. // submission are all gated behind closing the modal.
@@ -2934,6 +2998,97 @@ mod tests {
AppEvent::Key(KeyEvent::new(code, mods)) 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) { fn type_str(app: &mut App, s: &str) {
for c in s.chars() { for c in s.chars() {
app.update(key(KeyCode::Char(c))); app.update(key(KeyCode::Char(c)));
+83 -10
View File
@@ -11,7 +11,7 @@
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::{Duration, Instant};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crossterm::event::{Event as CtEvent, EventStream}; 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). /// reappears once typing stops (ADR-0027 §3).
const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000); 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 /// The input-validity indicator's debounce state machine
/// (ADR-0027 §3, step E). /// (ADR-0027 §3, step E).
/// ///
@@ -383,6 +401,17 @@ async fn run_loop(
// no wake-ups. See `IndicatorDebounce` for the decision // no wake-ups. See `IndicatorDebounce` for the decision
// logic; `app.input_indicator` mirrors it for the renderer. // logic; `app.input_indicator` mirrors it for the renderer.
let mut debounce = IndicatorDebounce::default(); 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). // Long-lived native clipboard for the `copy` command (ADR-0041).
// Created lazily on first copy (so an OSC-52-only session never // Created lazily on first copy (so an OSC-52-only session never
// opens an X11 connection) and kept alive for the session — the // 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. // handle, so it must outlive each write.
let mut native_clipboard = crate::clipboard::SystemClipboard::new(); let mut native_clipboard = crate::clipboard::SystemClipboard::new();
loop { loop {
let event = if debounce.is_armed() { let event = match nearest_deadline(debounce_deadline, badge_deadline) {
match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await { 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(Some(event)) => event,
Ok(None) => break, Ok(None) => break,
Err(_elapsed) => { Err(_elapsed) => {
// Typing has been quiet for the debounce let now = Instant::now();
// interval — settle the indicator. // 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()); debounce.settle(app.input_validity_verdict());
app.input_indicator = debounce.visible(); app.input_indicator = debounce.visible();
debounce_deadline = None;
}
terminal terminal
.draw(|f| ui::render(&mut app, &theme, f)) .draw(|f| ui::render(&mut app, &theme, f))
.context("redraw")?; .context("redraw")?;
continue; continue;
} }
} }
} else {
match event_rx.recv().await {
Some(event) => event,
None => break,
} }
}; };
let is_key = matches!(event, AppEvent::Key(_)); let is_key = matches!(event, AppEvent::Key(_));
@@ -591,6 +631,23 @@ async fn run_loop(
// pauses; non-key events leave it untouched. // pauses; non-key events leave it untouched.
debounce.note_event(is_key); debounce.note_event(is_key);
app.input_indicator = debounce.visible(); 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 terminal
.draw(|f| ui::render(&mut app, &theme, f)) .draw(|f| ui::render(&mut app, &theme, f))
.context("redraw")?; .context("redraw")?;
@@ -3012,8 +3069,24 @@ fn teardown_terminal(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::IndicatorDebounce; use super::{IndicatorDebounce, nearest_deadline};
use crate::dsl::walker::Severity; 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] #[test]
fn starts_hidden_and_disarmed() { fn starts_hidden_and_disarmed() {
@@ -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
@@ -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
+10
View File
@@ -20,6 +20,16 @@ use ratatui::style::Color;
use crate::dsl::grammar::HighlightClass; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Background { pub enum Background {
Light, Light,
+142
View File
@@ -113,6 +113,55 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
if let Some(modal) = app.modal.as_ref() { if let Some(modal) = app.modal.as_ref() {
render_modal(modal, theme, frame, area); 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 /// 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` // ADR-0044 §3: record the panel width so a later `show relationship`
// diagram (rendered App-side) can choose side-by-side vs vertical. // diagram (rendered App-side) can choose side-by-side vs vertical.
app.last_output_width = inner.width; 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<Line<'_>> = app let lines: Vec<Line<'_>> = app
.output .output
@@ -2966,4 +3018,94 @@ mod tests {
let snapshot = render_to_string(&mut app, &theme, 80, 24); let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("nav_overlay_relationships_focused_dark", snapshot); 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<u16, String> = 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");
}
} }