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:
@@ -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<Rect>, 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<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 {
|
||||
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::<Vec<_>>()
|
||||
.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| {
|
||||
|
||||
Reference in New Issue
Block a user