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:
@@ -0,0 +1,147 @@
|
||||
# ADR-0051: Bottom keybinding strip — context- and state-aware
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted 2026-06-13 (issue #27).** Closes Gitea **#27**. All forks
|
||||
below were escalated to the user and user-chosen before any code was
|
||||
written; to be implemented test-first. Builds on ADR-0046 (nav focus),
|
||||
ADR-0003 (input modes), ADR-0049 (the #29 readline keys this strip now
|
||||
advertises), and ADR-0022 (the Tab-completion memo).
|
||||
|
||||
## Context
|
||||
|
||||
The bottom status line (`render_status_bar`, `ui.rs`) mixed keystrokes
|
||||
with typed-command words: `Enter submit · : advanced once · mode
|
||||
advanced switch · Ctrl-C quit`. That is redundant — the hint panel
|
||||
already teaches `help` and `Enter` when the input is empty — and it is
|
||||
static apart from a three-way mode branch, so it never reflects what the
|
||||
user can actually do *right now* (navigating the sidebar, cycling a
|
||||
completion, browsing history, editing a line).
|
||||
|
||||
Issue #27: repurpose the line as a **keybindings-only** strip that is
|
||||
**context-sensitive to nav focus** and **state-aware of the current
|
||||
transient interaction**, and move mode discovery into the empty-input
|
||||
hint.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. The strip is keybindings-only and state-selected
|
||||
|
||||
A single pure function `status_bar_bindings(app) -> Vec<Binding>`
|
||||
computes the strip from app state; `render_status_bar` is a thin
|
||||
renderer over it (so the binding sets are unit-testable without a
|
||||
Frame). `history_cursor` is private to `App`, so a small
|
||||
`pub fn is_browsing_history(&self) -> bool` accessor exposes the
|
||||
history-navigation predicate; `mode` / `nav_focus` / `last_completion`
|
||||
are already `pub` and `effective_mode()` is a `pub` method. The state is
|
||||
chosen by **priority — first match wins**:
|
||||
|
||||
| Priority | State (predicate) | Strip |
|
||||
|---|---|---|
|
||||
| 1 | **Sidebar focus** (`nav_focus` in a sidebar) | `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input` |
|
||||
| 2 | **Completion memo live** (`last_completion.is_some()`) | `Tab/Shift-Tab cycle · Esc cancel · Enter run` |
|
||||
| 3 | **History navigation** (`history_cursor.is_some()`) | `↑↓ browse · Esc clear · Enter run` |
|
||||
| 4 | **Editing** (Input focus, input non-empty) | `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` |
|
||||
| 5 | **Default** (Input focus, input empty) | `Ctrl-O sidebar · Tab complete · ↑ history · Enter run` |
|
||||
|
||||
Priority order matters: a completion memo or history navigation is a
|
||||
non-empty-input situation, so states 2 and 3 must precede state 4. The
|
||||
sidebar overlay occludes the input entirely (ADR-0046), so state 1 wins
|
||||
outright.
|
||||
|
||||
### 2. Mode discovery moves off the strip, into the empty-input hint
|
||||
|
||||
The typed-command advertisements (`mode advanced` / `mode simple`
|
||||
switch, the `:` one-shot) leave the strip — they are not keystrokes.
|
||||
Mode discovery moves to the **empty-input hint** (`resolve_hint_lines`'s
|
||||
`(None, None)` arm), in **simple mode only**:
|
||||
|
||||
- **Simple:** `… · \`mode advanced\` for SQL`
|
||||
- **Advanced (persistent):** no pointer.
|
||||
|
||||
The pointer omits the verb "type" — the surrounding prompt already
|
||||
implies it (we don't say "type `help`" either). Advanced mode shows
|
||||
**no** pointer (user decision, post-trial): a user who switched into
|
||||
advanced mode knows how they got there, and `help` covers the way back —
|
||||
a "switch back" pointer only reads naturally in the moment right after
|
||||
switching, so it earns its space poorly.
|
||||
|
||||
The one-shot advanced state's old `Backspace cancel one-shot` label is
|
||||
**subsumed** by the editing state (the input is non-empty in one-shot;
|
||||
Esc-clear and Backspace both cancel it). No behaviour is lost — only the
|
||||
dedicated label.
|
||||
|
||||
### 3. Width: no drop machinery; a budget test instead
|
||||
|
||||
The longest strip (state 4, editing) is ≈ **65 display columns**, which
|
||||
fits every supported width (90-col screencasts, 80-col terminals) with
|
||||
margin — so the priority-drop / abbreviation machinery considered would
|
||||
never trigger and is not built (user-confirmed). Ratatui's existing
|
||||
**clip-at-edge** is the trivial fallback for pathologically narrow
|
||||
(< 65-col) terminals. Instead, a **width-budget unit test** pins the
|
||||
longest rendered strip within an 80-col budget, keeping the strip lean
|
||||
*by construction* — a future over-long strip fails the test rather than
|
||||
silently clipping in a cast.
|
||||
|
||||
## Forks (all user-chosen)
|
||||
|
||||
- **Editing state — yes:** when the input has text, surface the #29
|
||||
readline keys (Esc-clear, Ctrl-A/E, Ctrl-W); the strip stays lean
|
||||
(nav/complete/history) when empty. (vs not advertising the #29 keys.)
|
||||
- **`Ctrl-C quit` — omitted** from the strip (vs always shown): quit is
|
||||
a near-universal convention; omitting it keeps the strips lean and
|
||||
matches the issue's sketch.
|
||||
- **Width — budget test, no drop logic** (vs graceful priority-drop /
|
||||
abbreviation): the strips fit at supported widths, so the machinery
|
||||
would be dead weight (user's own observation).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The strip now teaches the keys for the *current* situation; learners
|
||||
see `Tab/Shift-Tab cycle` exactly while cycling, the editing keys
|
||||
exactly while editing, etc.
|
||||
- The #29 readline keys (ADR-0049) gain their on-screen advertisement,
|
||||
closing that ADR's deferred item.
|
||||
- 15 existing full-panel insta snapshots churn (the bottom line — and,
|
||||
on empty-input views, the hint pointer — changes in every one,
|
||||
including the rebuild-confirm modal view, whose modal box is itself
|
||||
unchanged); each diff was reviewed, not blind-accepted.
|
||||
- `requirements.md` is unaffected (an ADR-tracked UI refinement); the
|
||||
change is cross-referenced from the commit + this ADR.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Tier-1 (`ui.rs` unit):** `status_bar_bindings` returns the expected
|
||||
key set for each of the five states (sidebar, completion-live,
|
||||
history-nav, editing, default) — the completion/history states driven
|
||||
through real key events (`update`) so the predicate transitions are
|
||||
exercised, the others by setting `App` fields; plus the width-budget
|
||||
assertion across states. (Per-state coverage is these unit tests, not
|
||||
snapshots — a one-line strip is asserted more precisely by its exact
|
||||
key list than by a full-panel snapshot.)
|
||||
- **Tier-1:** the empty-input hint appends the correct mode pointer in
|
||||
Simple vs Advanced, and does **not** append it when an ambient hint is
|
||||
showing (non-empty input).
|
||||
- **Tier-3 (`walking_skeleton`):** the old `status_bar_lists_quit_and_
|
||||
submit_in_all_modes` (which asserted the pre-ADR strip) is rewritten +
|
||||
renamed to assert the keystroke-only, state-aware strip end-to-end
|
||||
through the real render path (default → editing transition).
|
||||
- **Tier-2 (insta):** the 15 full-panel snapshots re-accepted (each diff
|
||||
reviewed — strip line and/or hint pointer only).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Modal-aware strip.** While a modal is open (load picker, rebuild /
|
||||
undo confirm) it owns the keyboard and carries its own in-box key
|
||||
hints; the bottom strip under a modal computes from input state
|
||||
exactly as it does today (modals render *over* the status bar). This
|
||||
issue does not redesign the modal case — pre-existing behaviour,
|
||||
unchanged and not worsened.
|
||||
- A persistent/togglable help overlay listing *all* keys (the strip is a
|
||||
contextual subset, not a cheatsheet).
|
||||
- Per-key colour theming beyond the existing key/label/separator styles.
|
||||
- Localisation of the new label strings beyond adding catalog entries.
|
||||
- The remaining I1b kill keys' (Ctrl-K/Ctrl-U) advertisement — the
|
||||
editing strip shows the highest-value subset (Esc/Ctrl-A/E/Ctrl-W) to
|
||||
stay within the width budget; Ctrl-K/U remain unadvertised muscle
|
||||
memory.
|
||||
File diff suppressed because one or more lines are too long
+11
@@ -646,6 +646,17 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the user is currently browsing a recalled history entry
|
||||
/// (Up/Down navigation, unedited). Exposes the private
|
||||
/// `history_cursor` predicate so the context-aware status strip
|
||||
/// (ADR-0051) can select its history-navigation state. Editing the
|
||||
/// recalled line ends navigation (`cancel_history_navigation`), so
|
||||
/// this is `false` again the moment the user types.
|
||||
#[must_use]
|
||||
pub const fn is_browsing_history(&self) -> bool {
|
||||
self.history_cursor.is_some()
|
||||
}
|
||||
|
||||
/// The input view the **live-feedback** walkers (completion, ambient
|
||||
/// hint, validity verdict, highlight overlays) should see, plus the
|
||||
/// byte offset stripped from the front and the cursor mapped into the
|
||||
|
||||
+13
-5
@@ -446,6 +446,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("undo.redo_failed", &["error"]),
|
||||
// ---- Status bar + panels ----
|
||||
("panel.hint_empty", &[]),
|
||||
("panel.hint_mode_advanced", &[]),
|
||||
("panel.hint_title", &[]),
|
||||
("panel.output_title", &[]),
|
||||
("panel.relationships_empty", &[]),
|
||||
@@ -462,18 +463,25 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("save.title_as", &[]),
|
||||
("save.title_save", &[]),
|
||||
// ---- Shortcut hint labels ----
|
||||
("shortcut.advanced_once", &[]),
|
||||
("shortcut.back_to_list", &[]),
|
||||
("shortcut.browse", &[]),
|
||||
("shortcut.browse_path", &[]),
|
||||
("shortcut.cancel", &[]),
|
||||
("shortcut.cancel_one_shot", &[]),
|
||||
("shortcut.clear", &[]),
|
||||
("shortcut.complete", &[]),
|
||||
("shortcut.confirm", &[]),
|
||||
("shortcut.cycle", &[]),
|
||||
("shortcut.del_word", &[]),
|
||||
("shortcut.history", &[]),
|
||||
("shortcut.home_end", &[]),
|
||||
("shortcut.load", &[]),
|
||||
("shortcut.nav", &[]),
|
||||
("shortcut.next_pane", &[]),
|
||||
("shortcut.no", &[]),
|
||||
("shortcut.quit", &[]),
|
||||
("shortcut.run", &[]),
|
||||
("shortcut.scroll", &[]),
|
||||
("shortcut.select", &[]),
|
||||
("shortcut.submit", &[]),
|
||||
("shortcut.switch", &[]),
|
||||
("shortcut.to_input", &[]),
|
||||
("shortcut.yes", &[]),
|
||||
// ---- mode / messages banners ----
|
||||
("messages.set_short", &[]),
|
||||
|
||||
@@ -883,14 +883,21 @@ panel:
|
||||
relationships_title: "Relationships"
|
||||
relationships_empty: "(none)"
|
||||
hint_empty: "Type a command — press Tab for options, `help` for a list"
|
||||
# Mode-discovery pointer appended to the empty-input hint in SIMPLE
|
||||
# mode (ADR-0051): the `mode advanced` switch left the keybinding
|
||||
# strip, so the hint advertises it. Leading separator continues the
|
||||
# prompt line. Advanced mode shows no pointer — users know how they
|
||||
# got there, and `help` covers the way back.
|
||||
hint_mode_advanced: " · `mode advanced` for SQL"
|
||||
# Panel titles for the output and hint panels (rendered inside
|
||||
# the rounded border, hence the leading/trailing space).
|
||||
output_title: "Output"
|
||||
hint_title: "Hint"
|
||||
|
||||
# ---- Shortcut hints (paired with key names in the bottom bar) -------
|
||||
# The bottom strip is keystrokes-only and state-aware (ADR-0051). Labels
|
||||
# pair with a key name in the renderer (e.g. `Enter` + `run`).
|
||||
shortcut:
|
||||
submit: "submit"
|
||||
confirm: "confirm"
|
||||
cancel: "cancel"
|
||||
yes: "Yes"
|
||||
@@ -899,10 +906,19 @@ shortcut:
|
||||
select: "select"
|
||||
browse_path: "browse path"
|
||||
back_to_list: "back to list"
|
||||
switch: "switch"
|
||||
advanced_once: "advanced once"
|
||||
cancel_one_shot: "cancel one-shot"
|
||||
quit: "quit"
|
||||
# Status-strip labels (ADR-0051, issue #27).
|
||||
run: "run"
|
||||
nav: "sidebar"
|
||||
next_pane: "next pane"
|
||||
scroll: "scroll"
|
||||
to_input: "input"
|
||||
cycle: "cycle"
|
||||
browse: "browse"
|
||||
clear: "clear"
|
||||
complete: "complete"
|
||||
history: "history"
|
||||
home_end: "home/end"
|
||||
del_word: "del word"
|
||||
|
||||
# ---- mode / messages banners (app-level commands) -------------------
|
||||
mode:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2326
|
||||
assertion_line: 2836
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -26,4 +26,4 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · mode simple switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2309
|
||||
assertion_line: 2819
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│for SQL │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2317
|
||||
assertion_line: 2827
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│for SQL │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3442
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3388
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3378
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3431
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3457
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2369
|
||||
assertion_line: 2880
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -26,4 +26,4 @@ expression: snapshot
|
||||
│insert into <Table> [(<col>[, ...])] [values] (<value>[, ...]) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2967
|
||||
assertion_line: 3347
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
╰───────────────────────────────────────────╯ │
|
||||
╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯
|
||||
│Customers_Orders │ ─────────────────────────────────╮
|
||||
│ Customers.id -> │ ` for a list │
|
||||
│ Customers.id -> │ ` for a list · `mode advanced` │
|
||||
│ Orders.customer_id │ │
|
||||
╰───────────────────────────────────────────╯ ─────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2385
|
||||
assertion_line: 2896
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -26,4 +26,4 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · Backspace cancel one-shot · Ctrl-C quit
|
||||
Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2679
|
||||
assertion_line: 3099
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
╰──────────────────────────╯│ │
|
||||
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│(none) │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ││Type a command — press Tab for options, `help` for a list │
|
||||
│ ││ │
|
||||
│ ││Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│ ││for SQL │
|
||||
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2399
|
||||
assertion_line: 2909
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│for SQL │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2789
|
||||
assertion_line: 3209
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
╰──────────────────────────╯│ │
|
||||
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
|
||||
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list │
|
||||
│ Orders.customer_id ││ │
|
||||
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│ Orders.customer_id ││for SQL │
|
||||
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2265
|
||||
assertion_line: 2616
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────╮
|
||||
@@ -46,4 +46,4 @@ expression: snapshot
|
||||
│with `mode advanced`, or prefix the line with `:` to run… │
|
||||
╰──────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch ·
|
||||
Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Ente
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -223,21 +223,30 @@ fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_lists_quit_and_submit_in_all_modes() {
|
||||
fn status_bar_is_keystroke_only_and_state_aware() {
|
||||
// ADR-0051 (issue #27): the bottom strip is keystrokes-only and
|
||||
// tracks the interaction state. Typed-command words (`:` advanced
|
||||
// once, `mode advanced`/`mode simple` switch) and `Ctrl-C quit`
|
||||
// leave the strip; mode discovery moves to the hint (locked by the
|
||||
// ui.rs unit tests). This test exercises the real render path.
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
|
||||
let simple = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(simple.contains("Enter"), "status bar lists Enter");
|
||||
assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C");
|
||||
assert!(simple.contains("mode advanced"));
|
||||
// Default (empty input): nav / complete / history / run keystrokes.
|
||||
let default_view = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(default_view.contains("Ctrl-O sidebar"), "strip lists sidebar:\n{default_view}");
|
||||
assert!(default_view.contains("Enter run"), "strip lists run:\n{default_view}");
|
||||
assert!(!default_view.contains("Ctrl-C"), "quit dropped from the strip:\n{default_view}");
|
||||
assert!(
|
||||
!default_view.contains("advanced once"),
|
||||
"`:` command word dropped from the strip:\n{default_view}",
|
||||
);
|
||||
|
||||
type_str(&mut app, "mode advanced");
|
||||
submit(&mut app);
|
||||
let advanced = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(advanced.contains("Enter"));
|
||||
assert!(advanced.contains("Ctrl-C"));
|
||||
assert!(advanced.contains("mode simple"));
|
||||
// Editing (input has text): the #29 readline edit keys appear.
|
||||
type_str(&mut app, "create");
|
||||
let editing = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(editing.contains("Esc clear"), "editing strip lists clear:\n{editing}");
|
||||
assert!(editing.contains("Ctrl-W del word"), "editing strip lists del word:\n{editing}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user