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:
@@ -113,6 +113,55 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
|
||||
if let Some(modal) = app.modal.as_ref() {
|
||||
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
|
||||
@@ -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`
|
||||
// diagram (rendered App-side) can choose side-by-side vs vertical.
|
||||
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
|
||||
.output
|
||||
@@ -2966,4 +3018,94 @@ mod tests {
|
||||
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user