feat(ui): demo-mode step-caption stealth buffer (#22, ADR-0047 D3/D4)
Ctrl+] (decodes to Char('5')+CONTROL) toggles an invisible capture
buffer: typed characters accumulate without touching the input/output,
Backspace edits, every other key is inert, and a second Ctrl+] commits
the text to a caption box (empty commit dismisses). Handled at the top
of handle_key — before the badge and modal gates — so captions can be
authored over the load picker (the #24 cast); an ordinary keystroke
clears a visible caption. The caption renders as a floating
black-on-yellow box at the output panel's bottom-right, wrapped to <=3
lines (then ellipsised), with the keystroke badge stacked directly
above it when both are present.
Tier 1: capture/commit, invisible accumulation, backspace, inert keys
(incl. no badge), empty-commit dismiss, next-key clear, over-modal,
demo-off inert. Tier 2: caption / stacked / wrapped snapshots. Phase C
of ADR-0047 — feature complete.
This commit is contained in:
+199
-5
@@ -394,6 +394,19 @@ pub struct App {
|
|||||||
/// (ADR-0047 D5). The runtime watches it so a *new* badge — even the
|
/// (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.
|
/// same label twice in a row (Tab, Tab) — restarts the expiry timer.
|
||||||
pub demo_badge_seq: u64,
|
pub demo_badge_seq: u64,
|
||||||
|
/// The step-caption currently displayed in demo mode (ADR-0047 D3),
|
||||||
|
/// or `None`. Committed from the stealth buffer on the closing
|
||||||
|
/// `Ctrl+]`; cleared by the next ordinary keystroke (or an empty
|
||||||
|
/// commit). Rendered as a wrapped box stacked above the badge.
|
||||||
|
pub demo_caption: Option<String>,
|
||||||
|
/// Whether the stealth caption buffer is open (ADR-0047 D3): between
|
||||||
|
/// the opening and closing `Ctrl+]`, typed characters accumulate into
|
||||||
|
/// `demo_caption_buffer` invisibly and every other key is inert.
|
||||||
|
pub demo_caption_capturing: bool,
|
||||||
|
/// The invisible accumulator for the caption being typed while
|
||||||
|
/// `demo_caption_capturing` (ADR-0047 D3). Never rendered directly;
|
||||||
|
/// its trimmed contents become `demo_caption` on commit.
|
||||||
|
pub demo_caption_buffer: String,
|
||||||
/// 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
|
||||||
@@ -574,6 +587,9 @@ impl App {
|
|||||||
demo_mode: false,
|
demo_mode: false,
|
||||||
demo_badge: None,
|
demo_badge: None,
|
||||||
demo_badge_seq: 0,
|
demo_badge_seq: 0,
|
||||||
|
demo_caption: None,
|
||||||
|
demo_caption_capturing: false,
|
||||||
|
demo_caption_buffer: String::new(),
|
||||||
pending_echo: None,
|
pending_echo: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1090,12 +1106,25 @@ impl App {
|
|||||||
}
|
}
|
||||||
trace!(?key, "handle_key");
|
trace!(?key, "handle_key");
|
||||||
|
|
||||||
|
// ADR-0047 D3: the demo step-caption stealth buffer runs before
|
||||||
|
// every other gate — even ahead of the badge and the modal gate —
|
||||||
|
// so it can be authored over the load picker (the `#24` cast) and
|
||||||
|
// so captured keystrokes never leak into the input, a badge, or a
|
||||||
|
// command. `Ctrl+]` toggles capture; while capturing, the key is
|
||||||
|
// consumed here.
|
||||||
|
if self.demo_mode {
|
||||||
|
if let Some(actions) = self.handle_demo_caption_key(key) {
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
// Not a caption key: any ordinary keystroke dismisses a
|
||||||
|
// visible caption (it then falls through to normal handling).
|
||||||
|
self.demo_caption = None;
|
||||||
|
}
|
||||||
|
|
||||||
// ADR-0047 D2: in demo mode raise a transient badge for an
|
// ADR-0047 D2: in demo mode raise a transient badge for an
|
||||||
// otherwise-invisible key. Done before every gate below so it
|
// otherwise-invisible key. Done before the modal / nav gates so
|
||||||
// fires even while a modal is open (the `#24` projects cast) or
|
// it fires even while a modal is open (the `#24` projects cast)
|
||||||
// in navigation mode. The runtime times its expiry (D5). (Phase
|
// or in navigation mode. The runtime times its expiry (D5).
|
||||||
// C inserts the caption-capture gate *above* this, so captured
|
|
||||||
// keystrokes return early and never raise a badge.)
|
|
||||||
if self.demo_mode
|
if self.demo_mode
|
||||||
&& let Some(label) = demo_badge_label(&key)
|
&& let Some(label) = demo_badge_label(&key)
|
||||||
{
|
{
|
||||||
@@ -1206,6 +1235,59 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drive the demo step-caption stealth buffer (ADR-0047 D3).
|
||||||
|
///
|
||||||
|
/// Returns `Some(_)` when the key belongs to the caption mechanism
|
||||||
|
/// (the `Ctrl+]` toggle, or any key while capturing) — the caller
|
||||||
|
/// then returns it and processes nothing else. Returns `None` when
|
||||||
|
/// the key is not consumed, so normal handling continues.
|
||||||
|
///
|
||||||
|
/// `Ctrl+]` decodes to `Char('5') + CONTROL` (ADR-0047 D3, verified
|
||||||
|
/// against crossterm 0.29). Only active in demo mode (the caller
|
||||||
|
/// gates on `self.demo_mode`).
|
||||||
|
fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> {
|
||||||
|
let is_toggle = key.code == KeyCode::Char('5')
|
||||||
|
&& key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
|
||||||
|
if self.demo_caption_capturing {
|
||||||
|
if is_toggle {
|
||||||
|
// Commit: a trimmed, non-empty buffer becomes the caption;
|
||||||
|
// an empty commit dismisses any caption (explicit clear).
|
||||||
|
self.demo_caption_capturing = false;
|
||||||
|
let text = std::mem::take(&mut self.demo_caption_buffer);
|
||||||
|
let trimmed = text.trim();
|
||||||
|
self.demo_caption =
|
||||||
|
(!trimmed.is_empty()).then(|| trimmed.to_string());
|
||||||
|
} else {
|
||||||
|
match key.code {
|
||||||
|
// Plain characters accumulate invisibly; the prompt
|
||||||
|
// and output are untouched.
|
||||||
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
self.demo_caption_buffer.push(c);
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.demo_caption_buffer.pop();
|
||||||
|
}
|
||||||
|
// Every other key (Enter, arrows, Tab, …) is inert
|
||||||
|
// while capturing.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Some(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_toggle {
|
||||||
|
// Open capture. Starting a new annotation clears any caption
|
||||||
|
// currently on screen.
|
||||||
|
self.demo_caption_capturing = true;
|
||||||
|
self.demo_caption_buffer.clear();
|
||||||
|
self.demo_caption = None;
|
||||||
|
return Some(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn cursor_left(&mut self) {
|
fn cursor_left(&mut self) {
|
||||||
let mut idx = self.input_cursor;
|
let mut idx = self.input_cursor;
|
||||||
while idx > 0 {
|
while idx > 0 {
|
||||||
@@ -3089,6 +3171,118 @@ mod tests {
|
|||||||
assert_eq!(app.demo_badge_seq, 1);
|
assert_eq!(app.demo_badge_seq, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ADR-0047 (issue #22): demo-mode step-caption stealth buffer ----
|
||||||
|
|
||||||
|
/// `Ctrl+]` — the caption toggle (decodes to Char('5')+CONTROL).
|
||||||
|
fn caption_toggle() -> AppEvent {
|
||||||
|
key_mod(KeyCode::Char('5'), KeyModifiers::CONTROL)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_caption_toggle_captures_then_commits() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.demo_mode = true;
|
||||||
|
|
||||||
|
app.update(caption_toggle());
|
||||||
|
assert!(app.demo_caption_capturing, "first Ctrl+] opens capture");
|
||||||
|
assert_eq!(app.demo_caption, None);
|
||||||
|
|
||||||
|
type_str(&mut app, "Press Tab");
|
||||||
|
// The text accumulates invisibly — nothing on the input line.
|
||||||
|
assert_eq!(app.input, "");
|
||||||
|
assert_eq!(app.demo_caption_buffer, "Press Tab");
|
||||||
|
assert_eq!(app.demo_caption, None, "not shown until committed");
|
||||||
|
|
||||||
|
app.update(caption_toggle());
|
||||||
|
assert!(!app.demo_caption_capturing, "second Ctrl+] commits");
|
||||||
|
assert_eq!(app.demo_caption.as_deref(), Some("Press Tab"));
|
||||||
|
assert_eq!(app.demo_caption_buffer, "", "buffer drained on commit");
|
||||||
|
assert_eq!(app.input, "", "input never touched");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_caption_backspace_edits_the_buffer() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.demo_mode = true;
|
||||||
|
app.update(caption_toggle());
|
||||||
|
type_str(&mut app, "Helloo");
|
||||||
|
app.update(key(KeyCode::Backspace));
|
||||||
|
assert_eq!(app.demo_caption_buffer, "Hello");
|
||||||
|
assert_eq!(app.input, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_caption_other_keys_are_inert_while_capturing() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.demo_mode = true;
|
||||||
|
app.update(caption_toggle());
|
||||||
|
type_str(&mut app, "note");
|
||||||
|
// Enter must not submit, Tab must not complete, arrows do nothing.
|
||||||
|
let a1 = app.update(key(KeyCode::Enter));
|
||||||
|
let a2 = app.update(key(KeyCode::Tab));
|
||||||
|
let a3 = app.update(key(KeyCode::Up));
|
||||||
|
assert!(a1.is_empty() && a2.is_empty() && a3.is_empty());
|
||||||
|
assert!(app.demo_caption_capturing, "still capturing");
|
||||||
|
assert_eq!(app.demo_caption_buffer, "note");
|
||||||
|
assert_eq!(app.input, "");
|
||||||
|
assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_caption_empty_commit_dismisses() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.demo_mode = true;
|
||||||
|
app.demo_caption = Some("old".to_string());
|
||||||
|
// Open (clears the visible caption) then commit empty.
|
||||||
|
app.update(caption_toggle());
|
||||||
|
assert_eq!(app.demo_caption, None, "opening clears the visible caption");
|
||||||
|
app.update(caption_toggle());
|
||||||
|
assert_eq!(app.demo_caption, None, "empty commit leaves nothing");
|
||||||
|
assert!(!app.demo_caption_capturing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_caption_cleared_by_next_ordinary_keystroke() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.demo_mode = true;
|
||||||
|
app.demo_caption = Some("step 1".to_string());
|
||||||
|
// An ordinary key clears the caption, then is processed normally.
|
||||||
|
app.update(key(KeyCode::Char('a')));
|
||||||
|
assert_eq!(app.demo_caption, None);
|
||||||
|
assert_eq!(app.input, "a", "the key still reaches the input");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_caption_captures_over_an_open_modal() {
|
||||||
|
// The stealth buffer sits before the modal gate, so captions can
|
||||||
|
// be authored while the load picker is up (the `#24` cast).
|
||||||
|
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(caption_toggle());
|
||||||
|
type_str(&mut app, "pick one");
|
||||||
|
app.update(caption_toggle());
|
||||||
|
assert_eq!(app.demo_caption.as_deref(), Some("pick one"));
|
||||||
|
// The modal is untouched by the capture.
|
||||||
|
assert!(matches!(app.modal, Some(Modal::LoadPicker(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_mode_off_makes_ctrl_rbracket_inert() {
|
||||||
|
let mut app = App::new();
|
||||||
|
assert!(!app.demo_mode);
|
||||||
|
app.update(caption_toggle());
|
||||||
|
type_str(&mut app, "x");
|
||||||
|
assert!(!app.demo_caption_capturing);
|
||||||
|
assert_eq!(app.demo_caption, None);
|
||||||
|
// Ctrl+] did nothing; the later 'x' is an ordinary character.
|
||||||
|
assert_eq!(app.input, "x");
|
||||||
|
}
|
||||||
|
|
||||||
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)));
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
source: src/ui.rs
|
||||||
|
expression: snapshot
|
||||||
|
---
|
||||||
|
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ ╭───────╮ │
|
||||||
|
│ │ [TAB] │ │
|
||||||
|
│ ╰───────╯ │
|
||||||
|
│ ╭─────────────────────╮ │
|
||||||
|
│ │ Completing the name │ │
|
||||||
|
│ ╰─────────────────────╯ │
|
||||||
|
│ │
|
||||||
|
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭ 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 ────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ ╭──────────────────────────────────────────╮ │
|
||||||
|
│ │ Now press Tab to complete the table name │ │
|
||||||
|
│ ╰──────────────────────────────────────────╯ │
|
||||||
|
│ │
|
||||||
|
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭ 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 ────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ ╭──────────────────────────────────────────╮ │
|
||||||
|
│ │ This is a deliberately long step caption │ │
|
||||||
|
│ │ that must wrap onto several lines and │ │
|
||||||
|
│ │ then be clipped to three with an… │ │
|
||||||
|
│ ╰──────────────────────────────────────────╯ │
|
||||||
|
│ │
|
||||||
|
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||||
|
╭ 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
|
||||||
@@ -122,46 +122,114 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fixed high-contrast style for every demo overlay (ADR-0047 D4):
|
||||||
|
/// bold black text on a yellow background.
|
||||||
|
fn demo_overlay_style() -> Style {
|
||||||
|
Style::default()
|
||||||
|
.bg(crate::theme::DEMO_OVERLAY_BG)
|
||||||
|
.fg(crate::theme::DEMO_OVERLAY_FG)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
/// Draw the demonstration-mode overlays anchored to the output panel's
|
/// Draw the demonstration-mode overlays anchored to the output panel's
|
||||||
/// inner bottom-right corner (ADR-0047 D4). Phase B renders the
|
/// inner bottom-right corner (ADR-0047 D4): the step caption (if any) at
|
||||||
/// keystroke badge; the step-caption box joins it in Phase C.
|
/// the bottom, the keystroke badge stacked directly above it (or at the
|
||||||
|
/// bottom when there is no caption). Both are inset one cell and skipped
|
||||||
|
/// rather than overflowing when the area is too small.
|
||||||
fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) {
|
fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) {
|
||||||
let area = app.last_output_area;
|
let area = app.last_output_area;
|
||||||
if area.width == 0 || area.height == 0 {
|
if area.width == 0 || area.height == 0 {
|
||||||
return; // not measured yet
|
return; // not measured yet
|
||||||
}
|
}
|
||||||
|
// Caption first — it owns the bottom-right corner. The badge then
|
||||||
|
// stacks above whatever the caption actually occupied.
|
||||||
|
let caption_rect = app
|
||||||
|
.demo_caption
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|text| render_caption_box(text, area, frame));
|
||||||
if let Some(label) = app.demo_badge {
|
if let Some(label) = app.demo_badge {
|
||||||
render_badge_box(label, area, frame);
|
render_badge_box(label, area, caption_rect, frame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A small high-contrast keystroke badge (`[TAB]`, `[ENTER]`, …) inset
|
/// A small high-contrast keystroke badge (`[TAB]`, `[ENTER]`, …) inset
|
||||||
/// one cell from the bottom-right of `area` (ADR-0047 D2/D4). Skipped
|
/// one cell from the bottom-right of `area` (ADR-0047 D2/D4). When a
|
||||||
/// rather than overflowing if the output area is too small to host it.
|
/// caption box is present (`above`), the badge sits directly on top of
|
||||||
fn render_badge_box(label: &str, area: Rect, frame: &mut Frame<'_>) {
|
/// it, right-aligned; otherwise it takes the bottom-right corner.
|
||||||
let style = Style::default()
|
/// Skipped rather than overflowing if it cannot fit.
|
||||||
.bg(crate::theme::DEMO_OVERLAY_BG)
|
fn render_badge_box(label: &str, area: Rect, above: Option<Rect>, frame: &mut Frame<'_>) {
|
||||||
.fg(crate::theme::DEMO_OVERLAY_FG)
|
|
||||||
.add_modifier(Modifier::BOLD);
|
|
||||||
// ` [LABEL] ` (one pad each side) inside a rounded border.
|
// ` [LABEL] ` (one pad each side) inside a rounded border.
|
||||||
let box_w = label.chars().count() as u16 + 4;
|
let box_w = label.chars().count() as u16 + 4;
|
||||||
let box_h = 3;
|
let box_h = 3;
|
||||||
if box_w + 1 > area.width || box_h + 1 > area.height {
|
if box_w + 1 > area.width {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let x = area.x + area.width - box_w - 1;
|
||||||
|
let y = match above {
|
||||||
|
// Directly above the caption, right edges aligned.
|
||||||
|
Some(c) => {
|
||||||
|
if c.y < area.y + box_h {
|
||||||
|
return; // no room above the caption
|
||||||
|
}
|
||||||
|
c.y - box_h
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if box_h + 1 > area.height {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
area.y + area.height - box_h - 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rect = Rect { x, y, width: box_w, height: box_h };
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.style(demo_overlay_style());
|
||||||
|
let para = Paragraph::new(format!(" {label} "))
|
||||||
|
.style(demo_overlay_style())
|
||||||
|
.block(block);
|
||||||
|
frame.render_widget(ratatui::widgets::Clear, rect);
|
||||||
|
frame.render_widget(para, rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A step-caption box inset one cell from the bottom-right of `area`
|
||||||
|
/// (ADR-0047 D3/D4): the text word-wrapped to at most 3 lines within a
|
||||||
|
/// corner-sized width, bold black on yellow. Returns the rect it drew,
|
||||||
|
/// or `None` if it was too small to place (so the badge can fall back to
|
||||||
|
/// the bottom-right corner).
|
||||||
|
fn render_caption_box(text: &str, area: Rect, frame: &mut Frame<'_>) -> Option<Rect> {
|
||||||
|
// Content width capped so the box stays corner-sized; the caption
|
||||||
|
// wraps to ≤ 3 lines and ellipsises beyond (D4).
|
||||||
|
let content_w = 40.min(area.width.saturating_sub(6)) as usize;
|
||||||
|
if content_w < 4 {
|
||||||
|
return None; // output too narrow for a useful caption
|
||||||
|
}
|
||||||
|
let lines = clamp_wrapped(text, content_w, 3);
|
||||||
|
let inner_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
|
||||||
|
let box_w = inner_w as u16 + 4; // 2 border + 1 pad each side
|
||||||
|
let box_h = lines.len() as u16 + 2; // 2 border
|
||||||
|
if box_w + 1 > area.width || box_h + 1 > area.height {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let rect = Rect {
|
let rect = Rect {
|
||||||
x: area.x + area.width - box_w - 1,
|
x: area.x + area.width - box_w - 1,
|
||||||
y: area.y + area.height - box_h - 1,
|
y: area.y + area.height - box_h - 1,
|
||||||
width: box_w,
|
width: box_w,
|
||||||
height: box_h,
|
height: box_h,
|
||||||
};
|
};
|
||||||
frame.render_widget(ratatui::widgets::Clear, rect);
|
let body = lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| format!(" {l}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
.style(style);
|
.style(demo_overlay_style());
|
||||||
let para = Paragraph::new(format!(" {label} ")).style(style).block(block);
|
let para = Paragraph::new(body).style(demo_overlay_style()).block(block);
|
||||||
|
frame.render_widget(ratatui::widgets::Clear, rect);
|
||||||
frame.render_widget(para, rect);
|
frame.render_widget(para, rect);
|
||||||
|
Some(rect)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Width (columns) of the navigation-mode expanded sidebar overlay
|
/// Width (columns) of the navigation-mode expanded sidebar overlay
|
||||||
@@ -3093,6 +3161,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_caption_box_renders_at_output_bottom_right() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.demo_mode = true;
|
||||||
|
app.demo_caption = Some("Now press Tab to complete the table name".to_string());
|
||||||
|
let theme = Theme::dark();
|
||||||
|
let snapshot = render_to_string(&mut app, &theme, 90, 26);
|
||||||
|
insta::assert_snapshot!("demo_caption_dark_90x26", snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_badge_stacks_above_caption() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.demo_mode = true;
|
||||||
|
app.demo_badge = Some("[TAB]");
|
||||||
|
app.demo_caption = Some("Completing the name".to_string());
|
||||||
|
let theme = Theme::dark();
|
||||||
|
let snapshot = render_to_string(&mut app, &theme, 90, 26);
|
||||||
|
insta::assert_snapshot!("demo_badge_and_caption_stacked_90x26", snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn demo_caption_wraps_to_three_lines_and_ellipsises() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.demo_mode = true;
|
||||||
|
app.demo_caption = Some(
|
||||||
|
"This is a deliberately long step caption that must wrap onto \
|
||||||
|
several lines and then be clipped to three with an ellipsis \
|
||||||
|
so the corner box never grows without bound."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
let theme = Theme::dark();
|
||||||
|
let snapshot = render_to_string(&mut app, &theme, 90, 26);
|
||||||
|
insta::assert_snapshot!("demo_caption_wrapped_90x26", snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn demo_badge_box_skipped_when_area_too_small() {
|
fn demo_badge_box_skipped_when_area_too_small() {
|
||||||
// ADR-0047 D4 clamp guard: a box that cannot fit the given area
|
// ADR-0047 D4 clamp guard: a box that cannot fit the given area
|
||||||
@@ -3100,7 +3204,7 @@ mod tests {
|
|||||||
let backend = TestBackend::new(40, 10);
|
let backend = TestBackend::new(40, 10);
|
||||||
let mut terminal = Terminal::new(backend).expect("create terminal");
|
let mut terminal = Terminal::new(backend).expect("create terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| super::render_badge_box("[SHIFT-TAB]", Rect::new(0, 0, 5, 3), f))
|
.draw(|f| super::render_badge_box("[SHIFT-TAB]", Rect::new(0, 0, 5, 3), None, f))
|
||||||
.expect("draw frame");
|
.expect("draw frame");
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
let drew_badge = (0..buffer.area.height).any(|y| {
|
let drew_badge = (0..buffer.area.height).any(|y| {
|
||||||
|
|||||||
Reference in New Issue
Block a user