feat(ui): context- and state-aware bottom keybinding strip (#27)
Per ADR-0051 (closing issue #27): the bottom status line is now a keystrokes-only, state-selected strip. A pure status_bar_bindings() picks the binding set by priority (first match wins): sidebar focus → Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input completion live → Tab/Shift-Tab cycle · Esc cancel · Enter run history nav → ↑↓ browse · Esc clear · Enter run editing → Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run default (empty) → Ctrl-O sidebar · Tab complete · ↑ history · Enter run The editing state surfaces the #29 readline keys (ADR-0049's deferred advertisement). Typed-command words (mode advanced/simple, the ':' one-shot) and Ctrl-C quit leave the strip; simple mode's empty-input hint gains a '`mode advanced` for SQL' pointer (advanced mode shows none — a switcher knows the way back, and help covers it). Mechanism: status_bar_bindings + a thin renderer (unit-testable); App::is_browsing_history() exposes the private history_cursor; the mode pointer lives in resolve_hint_lines. Catalog: 12 new shortcut labels + panel.hint_mode_advanced (en-US.yaml + keys.rs, validator 1:1), 5 dead strip strings removed. Forks user-chosen: editing state shows #29 keys; quit omitted; no width-drop machinery (longest strip ~65 cols fits; a width-budget test keeps it lean). Modal-aware strip is OOS (pre-existing). Tests: 9 Tier-1 unit (per-state key sets, width budget, mode pointer), 1 Tier-3 rewritten, 15 full-panel snapshots re-accepted (reviewed). 2467 pass / 0 fail / 0 skip, clippy clean.
This commit is contained in:
@@ -1694,7 +1694,19 @@ fn resolve_hint_lines(
|
||||
(None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => {
|
||||
vec![render_candidate_line(&items, selected, inner, theme)]
|
||||
}
|
||||
(None, None) => prose(&crate::t!("panel.hint_empty")),
|
||||
// Empty input: the base prompt, plus — in simple mode only — a
|
||||
// pointer to advanced mode (ADR-0051, issue #27), since the
|
||||
// `mode advanced` switch left the keybinding strip. Advanced
|
||||
// mode shows no pointer: users know how they reached it, and
|
||||
// `help` covers the way back. (One-shot never reaches here — its
|
||||
// `:` makes the input non-empty → ambient path.)
|
||||
(None, None) => {
|
||||
let mut text = crate::t!("panel.hint_empty");
|
||||
if matches!(app.effective_mode(), EffectiveMode::Simple) {
|
||||
text.push_str(&crate::t!("panel.hint_mode_advanced"));
|
||||
}
|
||||
prose(&text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1845,6 +1857,63 @@ fn render_candidate_line(
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
/// The keybinding strip is keystrokes-only and **state-selected**
|
||||
/// (ADR-0051, issue #27): it advertises the keys for the user's *current*
|
||||
/// interaction, chosen by priority — first matching state wins.
|
||||
///
|
||||
/// Returns `(key, label)` pairs (label localised via `t!`); the renderer
|
||||
/// is a thin span builder over this list, so the binding sets are
|
||||
/// unit-testable without a `Frame`. Mode-switch / `:` advertisements
|
||||
/// deliberately leave the strip — they are typed commands, not
|
||||
/// keystrokes — and move to the empty-input hint (`resolve_hint_lines`).
|
||||
fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> {
|
||||
// 1. Sidebar focus (Ctrl-O): the input is occluded by the overlay,
|
||||
// so the panel-scroll keys win outright (ADR-0046).
|
||||
if app.nav_focus.in_sidebar() {
|
||||
return vec![
|
||||
("Ctrl-O", crate::t!("shortcut.next_pane")),
|
||||
("↑↓/PgUp/PgDn", crate::t!("shortcut.scroll")),
|
||||
("Esc", crate::t!("shortcut.to_input")),
|
||||
];
|
||||
}
|
||||
// 2. A live Tab-completion memo (ADR-0022): cycling keys. Pressing
|
||||
// Up clears the memo, so this never co-occurs with state 3.
|
||||
if app.last_completion.is_some() {
|
||||
return vec![
|
||||
("Tab/Shift-Tab", crate::t!("shortcut.cycle")),
|
||||
("Esc", crate::t!("shortcut.cancel")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
];
|
||||
}
|
||||
// 3. Browsing recalled history (unedited): browse keys. Editing the
|
||||
// recalled line ends navigation, dropping to state 4.
|
||||
if app.is_browsing_history() {
|
||||
return vec![
|
||||
("↑↓", crate::t!("shortcut.browse")),
|
||||
("Esc", crate::t!("shortcut.clear")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
];
|
||||
}
|
||||
// 4. Editing — the input has text: surface the readline edit keys
|
||||
// (ADR-0049). The highest-value subset stays within the width
|
||||
// budget; Ctrl-K/U remain unadvertised muscle memory.
|
||||
if !app.input.is_empty() {
|
||||
return vec![
|
||||
("Esc", crate::t!("shortcut.clear")),
|
||||
("Ctrl-A/E", crate::t!("shortcut.home_end")),
|
||||
("Ctrl-W", crate::t!("shortcut.del_word")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
];
|
||||
}
|
||||
// 5. Default — empty input, Input focus.
|
||||
vec![
|
||||
("Ctrl-O", crate::t!("shortcut.nav")),
|
||||
("Tab", crate::t!("shortcut.complete")),
|
||||
("↑", crate::t!("shortcut.history")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
]
|
||||
}
|
||||
|
||||
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||
let key_style = Style::default()
|
||||
.fg(theme.fg)
|
||||
@@ -1855,35 +1924,14 @@ fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect
|
||||
|
||||
let separator = Span::styled(" · ", sep_style);
|
||||
let mut spans: Vec<Span<'_>> = Vec::new();
|
||||
|
||||
let push_shortcut = |spans: &mut Vec<Span<'_>>, key: &'static str, label: &str| {
|
||||
for (key, label) in status_bar_bindings(app) {
|
||||
if !spans.is_empty() {
|
||||
spans.push(separator.clone());
|
||||
}
|
||||
spans.push(Span::styled(key, key_style));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(label.to_string(), label_style));
|
||||
};
|
||||
|
||||
let submit = crate::t!("shortcut.submit");
|
||||
push_shortcut(&mut spans, "Enter", &submit);
|
||||
let switch = crate::t!("shortcut.switch");
|
||||
let advanced_once = crate::t!("shortcut.advanced_once");
|
||||
let cancel_one_shot = crate::t!("shortcut.cancel_one_shot");
|
||||
let quit = crate::t!("shortcut.quit");
|
||||
match app.effective_mode() {
|
||||
EffectiveMode::Simple => {
|
||||
push_shortcut(&mut spans, ":", &advanced_once);
|
||||
push_shortcut(&mut spans, "mode advanced", &switch);
|
||||
}
|
||||
EffectiveMode::AdvancedPersistent => {
|
||||
push_shortcut(&mut spans, "mode simple", &switch);
|
||||
}
|
||||
EffectiveMode::AdvancedOneShot => {
|
||||
push_shortcut(&mut spans, "Backspace", &cancel_one_shot);
|
||||
}
|
||||
spans.push(Span::styled(label, label_style));
|
||||
}
|
||||
push_shortcut(&mut spans, "Ctrl-C", &quit);
|
||||
|
||||
let paragraph = Paragraph::new(Line::from(spans)).style(bar_style);
|
||||
frame.render_widget(paragraph, area);
|
||||
@@ -2582,6 +2630,168 @@ mod tests {
|
||||
.expect("hint bottom border present")
|
||||
}
|
||||
|
||||
// ---- ADR-0051 (issue #27): context- and state-aware strip ----
|
||||
|
||||
fn key_event(code: crossterm::event::KeyCode) -> crate::event::AppEvent {
|
||||
crate::event::AppEvent::Key(crossterm::event::KeyEvent::new(
|
||||
code,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
))
|
||||
}
|
||||
|
||||
/// The `key` column of the strip's bindings, in order.
|
||||
fn strip_keys(app: &App) -> Vec<&'static str> {
|
||||
status_bar_bindings(app).into_iter().map(|(k, _)| k).collect()
|
||||
}
|
||||
|
||||
/// The full rendered strip text (keys + labels + separators).
|
||||
fn strip_text(app: &App) -> String {
|
||||
status_bar_bindings(app)
|
||||
.iter()
|
||||
.map(|(k, l)| format!("{k} {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ")
|
||||
}
|
||||
|
||||
fn hint_text(lines: &[Line<'_>]) -> String {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_default_state_is_nav_complete_history_run() {
|
||||
let app = App::new();
|
||||
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "Tab", "↑", "Enter"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_editing_state_surfaces_readline_keys() {
|
||||
// Input has text (no completion/history transient) → the #29
|
||||
// editing keys (ADR-0049).
|
||||
let mut app = App::new();
|
||||
app.input.push_str("create ta");
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["Esc", "Ctrl-A/E", "Ctrl-W", "Enter"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_sidebar_focus_state_is_pane_scroll_input() {
|
||||
let mut app = App::new();
|
||||
app.nav_focus = NavFocus::SidebarTables;
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],
|
||||
);
|
||||
// ...and the relationships sidebar is the same state.
|
||||
app.nav_focus = NavFocus::SidebarRelationships;
|
||||
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_completion_memo_state_is_cycle_cancel_run() {
|
||||
// Drive the real flow: `show ` + Tab leaves a multi-candidate
|
||||
// memo (ADR-0022). The strip must win over the editing state.
|
||||
let mut app = App::new();
|
||||
for c in "show ".chars() {
|
||||
app.update(key_event(crossterm::event::KeyCode::Char(c)));
|
||||
}
|
||||
app.update(key_event(crossterm::event::KeyCode::Tab));
|
||||
assert!(app.last_completion.is_some(), "memo set by Tab");
|
||||
assert!(!app.input.is_empty(), "input non-empty — would be editing");
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["Tab/Shift-Tab", "Esc", "Enter"],
|
||||
"completion state wins over editing",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_history_navigation_state_is_browse_clear_run() {
|
||||
// Submit a command, then Up to recall it — `history_cursor` is
|
||||
// set, input is the (non-empty) recalled line, no memo.
|
||||
let mut app = App::new();
|
||||
for c in "drop table T".chars() {
|
||||
app.update(key_event(crossterm::event::KeyCode::Char(c)));
|
||||
}
|
||||
app.update(key_event(crossterm::event::KeyCode::Enter)); // submit
|
||||
app.update(key_event(crossterm::event::KeyCode::Up)); // recall
|
||||
assert!(app.is_browsing_history(), "browsing recalled history");
|
||||
assert!(app.last_completion.is_none(), "no completion memo");
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["↑↓", "Esc", "Enter"],
|
||||
"history state wins over editing",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_strip_state_fits_the_eighty_column_budget() {
|
||||
// ADR-0051 §3: the strips are kept lean by construction — the
|
||||
// longest must fit an 80-col status line, so no graceful-drop
|
||||
// machinery is needed. A future over-long strip fails here.
|
||||
let sidebar = {
|
||||
let mut a = App::new();
|
||||
a.nav_focus = NavFocus::SidebarTables;
|
||||
a
|
||||
};
|
||||
let editing = {
|
||||
let mut a = App::new();
|
||||
a.input.push('x');
|
||||
a
|
||||
};
|
||||
for app in [&App::new(), &sidebar, &editing] {
|
||||
let text = strip_text(app);
|
||||
assert!(
|
||||
text.chars().count() <= 80,
|
||||
"strip {} cols > 80: {text:?}",
|
||||
text.chars().count(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_hint_advertises_advanced_mode_in_simple() {
|
||||
let app = App::new();
|
||||
// Wide width so the pointer never wrap-splits.
|
||||
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
|
||||
assert!(
|
||||
text.contains("`mode advanced` for SQL"),
|
||||
"simple empty hint carries the advanced pointer:\n{text}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_mode_empty_hint_has_no_mode_pointer() {
|
||||
// ADR-0051: advanced mode shows no mode pointer (users know how
|
||||
// they got there; `help` covers the way back).
|
||||
let mut app = App::new();
|
||||
app.mode = Mode::Advanced;
|
||||
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
|
||||
assert!(
|
||||
!text.contains("mode simple") && !text.contains("mode advanced"),
|
||||
"advanced empty hint carries no mode pointer:\n{text}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_replaces_the_empty_hint_mode_pointer() {
|
||||
// Non-empty input → ambient hint path, not the empty-hint
|
||||
// mode pointer.
|
||||
let mut app = App::new();
|
||||
app.input.push_str("create table");
|
||||
app.input_cursor = app.input.len();
|
||||
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
|
||||
assert!(
|
||||
!text.contains("for SQL"),
|
||||
"no mode pointer once typing:\n{text}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_wrapped_truncates_with_ellipsis_past_max() {
|
||||
// ≤ max rows: untouched.
|
||||
|
||||
Reference in New Issue
Block a user