feat(ui): flat filled rectangles for demo overlays (#22, ADR-0047 D4)

Render the keystroke badge and step caption as a solid yellow rectangle
with no border glyphs and a one-cell text margin, instead of a
rounded-border box — deliberately unlike the app's bordered panels so
the demo overlays read as a distinct, eye-catching callout. Shared
fill_overlay_rect helper (borderless Block fill + inset Paragraph).
Snapshots regenerated; ADR-0047 D4 wording updated.
This commit is contained in:
claude@clouddev1
2026-06-11 08:40:07 +00:00
parent 241f60c503
commit 2d0f4b2958
7 changed files with 76 additions and 71 deletions
+25 -16
View File
@@ -186,12 +186,21 @@ confirmed by test if live `--demo` on Windows is exercised.)*
### D4 — Both overlays are floating boxes at the output panel's inner bottom-right ### D4 — Both overlays are floating boxes at the output panel's inner bottom-right
The badge and the caption both render as **floating, bordered boxes The badge and the caption both render as **floating, flat filled
anchored to the inside of the output panel's bottom-right corner** rectangles anchored to the inside of the output panel's bottom-right
(inset one cell from the panel's inner edge), drawn **last over the base corner** (inset one cell from the panel's inner edge), drawn **last over
render** — after modals, so they remain visible while the load-picker the base render** — after modals, so they remain visible while the
(the `#24` cast) or any modal is up, and with **no layout reflow** load-picker (the `#24` cast) or any modal is up, and with **no layout
(consistent with the modal / nav-overlay precedent; honours R8). reflow** (consistent with the modal / nav-overlay precedent; honours
R8).
**Flat rectangle, not a bordered box (user decision, post-build).** The
overlays draw as a **solid yellow rectangle with no border glyphs** and
a one-cell margin around the text — deliberately *unlike* the app's
rounded-border panels, so they read as a distinct callout that "stands
out nicely" rather than as another panel. Implemented with a borderless
`Block` fill (the `paint_background` mechanism) plus a `Paragraph` inset
into a one-cell `Margin`.
The top-level `render()` does not currently know the output-panel rect The top-level `render()` does not currently know the output-panel rect
(it is computed inside `render_right_column`), so a **new field (it is computed inside `render_right_column`), so a **new field
@@ -204,19 +213,19 @@ When **both** are present, the **keystroke badge stacks directly above
the caption box** (both right-aligned in the corner) so they never the caption box** (both right-aligned in the corner) so they never
overlap. overlap.
**Styling — deliberately high-contrast:** **black text on a yellow **Styling — deliberately high-contrast:** **bold black text on a yellow
background**, bold, bordered — hard to overlook, identical in light and fill** — hard to overlook, identical in light and dark themes (a fixed
dark themes (a fixed high-contrast pair centralised in `theme.rs`, not high-contrast pair centralised in `theme.rs`, not theme-derived).
theme-derived).
**Caption sizing (user-confirmed).** The caption is **word-wrapped to at **Caption sizing (user-confirmed).** The caption is **word-wrapped to at
most 3 lines** within a content width of `min(40, output_inner_width most 3 lines** within a content width of `min(40, output_inner_width
6)` columns, ellipsised beyond the third line. So the caption box is 4)` columns, ellipsised beyond the third line. So the caption rectangle
**35 rows** tall (13 text rows + 2 border), its height varying with is **35 rows** tall (13 text rows + a one-cell margin top and bottom),
the text — a full sentence fits without forcing the author to split it, its height varying with the text — a full sentence fits without forcing
while the 3-line cap keeps it corner-sized. The **badge** box is always the author to split it, while the 3-line cap keeps it corner-sized. The
a single short token (`[TAB]` `[SHIFT-TAB]`), so it is a fixed **3 **badge** rectangle is always a single short token (`[TAB]`
rows** (1 text + 2 border), narrow. `[SHIFT-TAB]`), so it is a fixed **3 rows** (1 text row + the margin),
narrow.
**Clamping (runda finding).** Stacked, the two boxes are up to 8 rows **Clamping (runda finding).** Stacked, the two boxes are up to 8 rows
(5 caption + 3 badge); the output panel's inner height is only `Min(5)`, (5 caption + 3 badge); the output panel's inner height is only `Min(5)`,
@@ -11,12 +11,12 @@ expression: snapshot
│ │ │ │
│ │ │ │
│ │ │ │
╭───────╮
[TAB] [TAB] │
╰───────╯
╭─────────────────────╮
Completing the name Completing the name │
╰─────────────────────╯
│ │ │ │
╰────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ ╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
@@ -14,9 +14,9 @@ expression: snapshot
│ │ │ │
│ │ │ │
│ │ │ │
╭─────────╮
[ENTER] [ENTER] │
╰─────────╯
│ │ │ │
╰────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ ╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
@@ -14,9 +14,9 @@ expression: snapshot
│ │ │ │
│ │ │ │
│ │ │ │
╭───────╮
[TAB] [TAB] │
╰───────╯
│ │ │ │
╰────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ ╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
@@ -14,9 +14,9 @@ expression: snapshot
│ │ │ │
│ │ │ │
│ │ │ │
╭──────────────────────────────────────────╮
Now press Tab to complete the table name Now press Tab to complete the table name │
╰──────────────────────────────────────────╯
│ │ │ │
╰────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ ╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
@@ -12,11 +12,11 @@ expression: snapshot
│ │ │ │
│ │ │ │
│ │ │ │
╭──────────────────────────────────────────╮
This is a deliberately long step caption This is a deliberately long step caption │
that must wrap onto several lines and that must wrap onto several lines and │
then be clipped to three with an… then be clipped to three with an… │
╰──────────────────────────────────────────╯
│ │ │ │
╰────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ ╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮
+31 -35
View File
@@ -152,15 +152,31 @@ fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) {
} }
} }
/// Paint a flat filled overlay rectangle — a solid yellow block with no
/// border glyphs (ADR-0047 D4) — and lay `body` inside a one-cell
/// margin. The borderless solid block is deliberately *unlike* the app's
/// bordered panels, so the demo overlays read as a distinct callout.
fn fill_overlay_rect(rect: Rect, body: String, frame: &mut Frame<'_>) {
frame.render_widget(ratatui::widgets::Clear, rect);
// `Block` with no borders fills the whole rect with the overlay
// background (same mechanism as `paint_background`).
frame.render_widget(Block::default().style(demo_overlay_style()), rect);
let inner = rect.inner(Margin {
horizontal: 1,
vertical: 1,
});
frame.render_widget(Paragraph::new(body).style(demo_overlay_style()), inner);
}
/// 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). When a /// one cell from the bottom-right of `area` (ADR-0047 D2/D4) — the label
/// caption box is present (`above`), the badge sits directly on top of /// on a flat yellow rectangle with a one-cell margin. When a caption box
/// it, right-aligned; otherwise it takes the bottom-right corner. /// is present (`above`), the badge sits directly on top of it, right
/// Skipped rather than overflowing if it cannot fit. /// edges 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<'_>) { 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 + 2; // one-cell margin each side
let box_w = label.chars().count() as u16 + 4; let box_h = 3; // text row + a margin row above and below
let box_h = 3;
if box_w + 1 > area.width { if box_w + 1 > area.width {
return; return;
} }
@@ -180,34 +196,25 @@ fn render_badge_box(label: &str, area: Rect, above: Option<Rect>, frame: &mut Fr
area.y + area.height - box_h - 1 area.y + area.height - box_h - 1
} }
}; };
let rect = Rect { x, y, width: box_w, height: box_h }; fill_overlay_rect(Rect { x, y, width: box_w, height: box_h }, label.to_string(), frame);
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` /// 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 /// (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, /// corner-sized width, bold black on a flat yellow rectangle. Returns
/// or `None` if it was too small to place (so the badge can fall back to /// the rect it drew, or `None` if it was too small to place (so the
/// the bottom-right corner). /// badge can fall back to the bottom-right corner).
fn render_caption_box(text: &str, area: Rect, frame: &mut Frame<'_>) -> Option<Rect> { fn render_caption_box(text: &str, area: Rect, frame: &mut Frame<'_>) -> Option<Rect> {
// Content width capped so the box stays corner-sized; the caption // Content width capped so the box stays corner-sized; the caption
// wraps to ≤ 3 lines and ellipsises beyond (D4). // wraps to ≤ 3 lines and ellipsises beyond (D4).
let content_w = 40.min(area.width.saturating_sub(6)) as usize; let content_w = 40.min(area.width.saturating_sub(4)) as usize;
if content_w < 4 { if content_w < 4 {
return None; // output too narrow for a useful caption return None; // output too narrow for a useful caption
} }
let lines = clamp_wrapped(text, content_w, 3); let lines = clamp_wrapped(text, content_w, 3);
let inner_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); 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_w = inner_w as u16 + 2; // one-cell margin each side
let box_h = lines.len() as u16 + 2; // 2 border let box_h = lines.len() as u16 + 2; // text rows + a margin row above and below
if box_w + 1 > area.width || box_h + 1 > area.height { if box_w + 1 > area.width || box_h + 1 > area.height {
return None; return None;
} }
@@ -217,18 +224,7 @@ fn render_caption_box(text: &str, area: Rect, frame: &mut Frame<'_>) -> Option<R
width: box_w, width: box_w,
height: box_h, height: box_h,
}; };
let body = lines fill_overlay_rect(rect, lines.join("\n"), frame);
.iter()
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n");
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.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) Some(rect)
} }