diff --git a/src/app.rs b/src/app.rs index acdde9c..56fc7fc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -394,6 +394,19 @@ pub struct App { /// (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 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, + /// 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 /// being rendered: set from the success event just before its handler /// runs, consumed by `note_ok_summary` (which pushes it beneath @@ -574,6 +587,9 @@ impl App { demo_mode: false, demo_badge: None, demo_badge_seq: 0, + demo_caption: None, + demo_caption_capturing: false, + demo_caption_buffer: String::new(), pending_echo: None, } } @@ -1090,12 +1106,25 @@ impl App { } 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 - // 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.) + // otherwise-invisible key. Done before the modal / nav gates so + // it fires even while a modal is open (the `#24` projects cast) + // or in navigation mode. The runtime times its expiry (D5). if self.demo_mode && 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> { + 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) { let mut idx = self.input_cursor; while idx > 0 { @@ -3089,6 +3171,118 @@ mod tests { 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) { for c in s.chars() { app.update(key(KeyCode::Char(c))); diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap new file mode 100644 index 0000000..f1de558 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap new file mode 100644 index 0000000..1907566 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap new file mode 100644 index 0000000..7863da9 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap @@ -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 diff --git a/src/ui.rs b/src/ui.rs index 0392f92..f2596a0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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 -/// inner bottom-right corner (ADR-0047 D4). Phase B renders the -/// keystroke badge; the step-caption box joins it in Phase C. +/// inner bottom-right corner (ADR-0047 D4): the step caption (if any) at +/// 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<'_>) { let area = app.last_output_area; if area.width == 0 || area.height == 0 { 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 { - render_badge_box(label, area, frame); + render_badge_box(label, area, caption_rect, 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); +/// one cell from the bottom-right of `area` (ADR-0047 D2/D4). When a +/// caption box is present (`above`), the badge sits directly on top of +/// it, right-aligned; otherwise it takes the bottom-right corner. +/// Skipped rather than overflowing if it cannot fit. +fn render_badge_box(label: &str, area: Rect, above: Option, frame: &mut Frame<'_>) { // ` [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 { + if box_w + 1 > area.width { 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 { + // 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 { 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 body = lines + .iter() + .map(|l| format!(" {l}")) + .collect::>() + .join("\n"); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .style(style); - let para = Paragraph::new(format!(" {label} ")).style(style).block(block); + .style(demo_overlay_style()); + let para = Paragraph::new(body).style(demo_overlay_style()).block(block); + frame.render_widget(ratatui::widgets::Clear, rect); frame.render_widget(para, rect); + Some(rect) } /// 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] fn demo_badge_box_skipped_when_area_too_small() { // 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 mut terminal = Terminal::new(backend).expect("create 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"); let buffer = terminal.backend().buffer(); let drew_badge = (0..buffer.area.height).any(|y| {