feat(input): readline keymap — Esc-clear + Ctrl-A/E/W/K/U (#29)
Implement the deferred I1b readline shortcuts in the command input field (ADR-0049, closing issue #29): Esc clear a partly-typed command (only when no completion memo) Ctrl-A cursor to line start (Home alias) Ctrl-E cursor to line end (End alias) Ctrl-W delete the previous word (readline-style, UTF-8 safe) Ctrl-K kill to end of line Ctrl-U kill to start of line Esc precedence is preserved: a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022); Esc clears only when no memo is alive. While a sidebar panel is focused (Ctrl-O), Esc exits navigation mode upstream and never clears the input draft. Cursor-only keys leave history navigation intact like Home/End; buffer-mutating keys end it like Backspace. New helpers clear_input / delete_prev_word / kill_to_end / kill_to_start in src/app.rs. 22 new Tier-1 tests (2458 pass / 0 fail / 0 skip, clippy clean). ADR-0049 amends ADR-0046's OOS list; requirements.md I1b marked done.
This commit is contained in:
@@ -525,7 +525,9 @@ All tiers green, zero skips; clippy clean (nursery).
|
|||||||
submits over a multi-logical-line buffer. DA3/DA4 keep a single
|
submits over a multi-logical-line buffer. DA3/DA4 keep a single
|
||||||
logical line; this remains a separate, deferred feature.
|
logical line; this remains a separate, deferred feature.
|
||||||
- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred;
|
- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred;
|
||||||
not touched here.
|
not touched here. *(Superseded 2026-06-12: I1b is now in scope and
|
||||||
|
decided by **ADR-0049** — Esc-clear + Ctrl-A/E/W/K/U in the input
|
||||||
|
field, issue #29.)*
|
||||||
- **Cross-session sidebar persistence** — visibility is session-only
|
- **Cross-session sidebar persistence** — visibility is session-only
|
||||||
(DB1); persisting it would amend ADR-0015.
|
(DB1); persisting it would amend ADR-0015.
|
||||||
- **The output panel as a third navigation focus target** — navigation
|
- **The output panel as a third navigation focus target** — navigation
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# ADR-0049: Input-field readline keymap — Esc-clear + Ctrl-A/E/W/K/U (I1b)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Accepted + implemented 2026-06-12 (issue #29).** Closes Gitea **#29**
|
||||||
|
("Command input keystroke support") and the deferred **I1b** readline
|
||||||
|
requirement in `requirements.md`. Every fork below was escalated to the
|
||||||
|
user and user-chosen before any code was written; implemented test-first
|
||||||
|
(22 new Tier-1 tests in `src/app.rs`, all green; clippy nursery clean).
|
||||||
|
|
||||||
|
This ADR **amends ADR-0046**, which explicitly listed "readline
|
||||||
|
shortcuts (I1b)" in its out-of-scope set: that item is now in scope and
|
||||||
|
decided here. It is orthogonal to ADR-0003's input-*mode* model (simple
|
||||||
|
vs advanced, the `:` sigil) — these are editing keys within the input
|
||||||
|
field, not mode or sigil changes — and it extends the single-line cursor
|
||||||
|
editing already shipped under requirement **I1a** (Left/Right/Home/End/
|
||||||
|
Backspace/Delete, `app.rs`).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The input field already supported in-line cursor editing (I1a): Left/
|
||||||
|
Right by char (UTF-8 aware), Home/End to the extremes, Backspace/Delete.
|
||||||
|
Two gaps remained, raised in issue #29:
|
||||||
|
|
||||||
|
1. No way to **clear a partly-typed command** in one keystroke — a user
|
||||||
|
who started typing the wrong thing had to hold Backspace.
|
||||||
|
2. No **readline cursor/kill shortcuts** (Ctrl-A/Ctrl-E and friends) for
|
||||||
|
keyboards without Home/End and for muscle-memory in a command-driven
|
||||||
|
workflow. This is requirement I1b, deferred by ADR-0046.
|
||||||
|
|
||||||
|
`Esc` was free in the input field except that a *live Tab-completion
|
||||||
|
memo* consumes it first (to undo the completion in one keystroke,
|
||||||
|
ADR-0022). Ctrl-A/E/W/K/U were unbound. The existing chords are Ctrl-C
|
||||||
|
(quit), Ctrl-O (nav focus cycle, ADR-0046), and Ctrl-`]` (demo caption
|
||||||
|
toggle, ADR-0047) — none collide with a/e/w/k/u.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Bind the following in the input field (non-modal, non-navigation,
|
||||||
|
both input modes), in `App::handle_key`:
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----------|---------------------------------------------------|
|
||||||
|
| `Esc` | Clear the input (empty buffer, cursor→0, scroll→0)|
|
||||||
|
| `Ctrl-A` | Cursor to line start (alias of Home) |
|
||||||
|
| `Ctrl-E` | Cursor to line end (alias of End) |
|
||||||
|
| `Ctrl-W` | Delete the word before the cursor |
|
||||||
|
| `Ctrl-K` | Kill from the cursor to end of line |
|
||||||
|
| `Ctrl-U` | Kill from start of line to the cursor |
|
||||||
|
|
||||||
|
Behavioural rules:
|
||||||
|
|
||||||
|
- **Esc precedence.** A live completion memo still wins: the first Esc
|
||||||
|
undoes the completion (ADR-0022), and Esc only *clears* when no memo
|
||||||
|
is alive. This is a natural progression — Esc once to back out the
|
||||||
|
completion, Esc again to clear.
|
||||||
|
- **Esc does not clear while navigating the sidebar.** When a sidebar
|
||||||
|
panel is focused (Ctrl-O, ADR-0046 DC3), `handle_key` routes every
|
||||||
|
key to the navigation handler *before* the input-field keymap, where
|
||||||
|
Esc exits navigation mode (`nav_exit`). Entering nav mode never
|
||||||
|
touched the input buffer, so Esc-to-close-the-panel returns focus to
|
||||||
|
the input with the partly-typed command intact — it cannot reach the
|
||||||
|
clear binding. Locked by a regression test.
|
||||||
|
- **Single Esc clears** (user-chosen over double-Esc). Discoverable and
|
||||||
|
fast; the trade-off (an accidental Esc wipes an unsubmitted line) was
|
||||||
|
accepted. A submitted line is always recoverable from history; only
|
||||||
|
*unsubmitted* draft text is lost.
|
||||||
|
- **Cursor-only keys don't touch history navigation.** Ctrl-A/Ctrl-E,
|
||||||
|
like Home/End, move the cursor without ending history recall.
|
||||||
|
- **Buffer-mutating keys end history navigation.** Esc-clear and
|
||||||
|
Ctrl-W/K/U call `cancel_history_navigation` (the cleared/edited line
|
||||||
|
*is* the new draft), matching Backspace/Delete.
|
||||||
|
- **Ctrl-W is readline-style and UTF-8 safe.** It eats any run of
|
||||||
|
trailing whitespace, then the preceding run of non-whitespace; word
|
||||||
|
boundaries are found on char boundaries so multi-byte words delete
|
||||||
|
cleanly. It only ever deletes back to the cursor (a mid-line Ctrl-W
|
||||||
|
leaves the suffix intact).
|
||||||
|
|
||||||
|
Helpers added: `clear_input`, `delete_prev_word`, `kill_to_end`,
|
||||||
|
`kill_to_start` (`src/app.rs`), mirroring the existing `cursor_left` /
|
||||||
|
`delete_before_cursor` style.
|
||||||
|
|
||||||
|
## Forks (all user-chosen)
|
||||||
|
|
||||||
|
- **Esc semantics:** single-Esc-clears, *not* double-Esc — discoverable
|
||||||
|
over accident-proof.
|
||||||
|
- **Scope:** the *full* I1b set (Esc-clear + Ctrl-A/E/W/K/U), not just
|
||||||
|
the issue's literal Ctrl-A/E + Esc — closes the whole I1b requirement
|
||||||
|
in one pass rather than leaving Ctrl-W/K/U for a follow-up.
|
||||||
|
- **Documentation:** a new ADR (this one), recording the input-field
|
||||||
|
keymap convention and amending ADR-0046's OOS list — over folding it
|
||||||
|
into ADR-0046 or shipping it I1a-style with no ADR.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- I1b is complete; `requirements.md` I1b moves to `[x]`.
|
||||||
|
- The new keys are **not yet advertised on screen.** Surfacing per-focus
|
||||||
|
keybindings in the bottom status line is issue #27's domain (a
|
||||||
|
separate, in-design UX change); this ADR makes the keys *work*, #27
|
||||||
|
will make them *discoverable*.
|
||||||
|
- **Demo-mode badges** (ADR-0047) are *not* extended to the new Ctrl-
|
||||||
|
chords here. Esc already badges as `[ESC]`; Ctrl-A/E/W/K/U are
|
||||||
|
glyph-less and would be invisible in an asciinema cast. Whether to add
|
||||||
|
`[CTRL-A]`…`[CTRL-U]` badges is left to ADR-0047's scope and flagged
|
||||||
|
as a follow-up — it is a cast-polish concern, not a #29 requirement.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- On-screen keybinding hints for the input field (issue #27).
|
||||||
|
- Demo badges for the new chords (ADR-0047 follow-up; flagged above).
|
||||||
|
- Multi-line input (I1) and its Ctrl-Enter submit — unrelated, still
|
||||||
|
deferred.
|
||||||
|
- Word-wise *cursor motion* (Alt-B/Alt-F) and transpose/yank — not
|
||||||
|
requested; not part of I1b.
|
||||||
File diff suppressed because one or more lines are too long
+10
-2
@@ -147,11 +147,19 @@ since ADR-0027.)
|
|||||||
cursor editing and is complete on its own terms; the separate
|
cursor editing and is complete on its own terms; the separate
|
||||||
**multi-line** entry goal is tracked under I1, which is
|
**multi-line** entry goal is tracked under I1, which is
|
||||||
genuinely not started.)*
|
genuinely not started.)*
|
||||||
- [ ] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
|
- [x] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
|
||||||
as aliases for Home / End for users on keyboards without those
|
as aliases for Home / End for users on keyboards without those
|
||||||
keys (and for ergonomics in command-driven workflows). Likely
|
keys (and for ergonomics in command-driven workflows). Likely
|
||||||
followed by Ctrl-W (delete previous word), Ctrl-K (delete to
|
followed by Ctrl-W (delete previous word), Ctrl-K (delete to
|
||||||
end), Ctrl-U (delete to start). Pending.
|
end), Ctrl-U (delete to start).
|
||||||
|
*(Done 2026-06-12 — ADR-0049, issue #29: the full set —
|
||||||
|
Esc-clear + Ctrl-A/E/W/K/U — wired in `App::handle_key`
|
||||||
|
(`src/app.rs`) with helpers `clear_input` / `delete_prev_word`
|
||||||
|
/ `kill_to_end` / `kill_to_start`; Esc clears only when no
|
||||||
|
completion memo is alive (the memo wins first, ADR-0022);
|
||||||
|
cursor-only keys leave history navigation intact, kill keys
|
||||||
|
end it; 22 Tier-1 tests. On-screen advertisement of these keys
|
||||||
|
is issue #27's bottom-status-line work.)*
|
||||||
- [x] **I2** Persistent navigable input history (project-scoped).
|
- [x] **I2** Persistent navigable input history (project-scoped).
|
||||||
*(Implemented across Iterations 2 + 6: per-command append to
|
*(Implemented across Iterations 2 + 6: per-command append to
|
||||||
`history.log` (Iter 2); on project open, the in-memory
|
`history.log` (Iter 2); on project open, the in-memory
|
||||||
|
|||||||
+317
-2
@@ -1217,6 +1217,13 @@ impl App {
|
|||||||
match (key.code, key.modifiers) {
|
match (key.code, key.modifiers) {
|
||||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
|
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
|
||||||
(KeyCode::Enter, _) => self.submit(),
|
(KeyCode::Enter, _) => self.submit(),
|
||||||
|
// ADR-0049 (issue #29): Esc clears a partly-typed command.
|
||||||
|
// Reached only when no completion memo is alive — the memo
|
||||||
|
// block above consumes Esc first to undo a completion.
|
||||||
|
(KeyCode::Esc, _) => {
|
||||||
|
self.clear_input();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
(KeyCode::Up, _) => {
|
(KeyCode::Up, _) => {
|
||||||
self.history_back();
|
self.history_back();
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -1233,11 +1240,15 @@ impl App {
|
|||||||
self.cursor_right();
|
self.cursor_right();
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
(KeyCode::Home, _) => {
|
// ADR-0049: Ctrl-A / Ctrl-E are readline aliases for
|
||||||
|
// Home / End — line start / end — for keyboards without
|
||||||
|
// those keys. Cursor-only, so (like Home/End) they do not
|
||||||
|
// cancel history navigation.
|
||||||
|
(KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
|
||||||
self.input_cursor = 0;
|
self.input_cursor = 0;
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
(KeyCode::End, _) => {
|
(KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
|
||||||
self.input_cursor = self.input.len();
|
self.input_cursor = self.input.len();
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -1251,6 +1262,23 @@ impl App {
|
|||||||
self.delete_at_cursor();
|
self.delete_at_cursor();
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
// ADR-0049: readline kill shortcuts. Each mutates the
|
||||||
|
// buffer, so each ends history navigation like Backspace.
|
||||||
|
(KeyCode::Char('w'), KeyModifiers::CONTROL) => {
|
||||||
|
self.cancel_history_navigation();
|
||||||
|
self.delete_prev_word();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
(KeyCode::Char('k'), KeyModifiers::CONTROL) => {
|
||||||
|
self.cancel_history_navigation();
|
||||||
|
self.kill_to_end();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
|
||||||
|
self.cancel_history_navigation();
|
||||||
|
self.kill_to_start();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
(KeyCode::PageUp, _) => {
|
(KeyCode::PageUp, _) => {
|
||||||
self.scroll_output_up();
|
self.scroll_output_up();
|
||||||
Vec::new()
|
Vec::new()
|
||||||
@@ -1545,6 +1573,54 @@ impl App {
|
|||||||
self.input.replace_range(self.input_cursor..idx, "");
|
self.input.replace_range(self.input_cursor..idx, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Esc — clear a partly-typed command (ADR-0049). Empties the
|
||||||
|
/// buffer, parks the cursor at the start, drops any horizontal
|
||||||
|
/// scroll, and ends history navigation (the cleared line *is* the
|
||||||
|
/// new draft). Only reached when no completion memo is alive — Esc
|
||||||
|
/// undoes a live completion first (handle_key precedence).
|
||||||
|
fn clear_input(&mut self) {
|
||||||
|
self.cancel_history_navigation();
|
||||||
|
self.input.clear();
|
||||||
|
self.input_cursor = 0;
|
||||||
|
self.input_scroll_offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ctrl-W — delete the word before the cursor (ADR-0049). Eats any
|
||||||
|
/// run of trailing whitespace, then the preceding run of
|
||||||
|
/// non-whitespace, readline-style. UTF-8 safe: word boundaries are
|
||||||
|
/// found on char boundaries, so multi-byte words delete cleanly.
|
||||||
|
fn delete_prev_word(&mut self) {
|
||||||
|
if self.input_cursor == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let prefix = &self.input[..self.input_cursor];
|
||||||
|
// Strip trailing whitespace, then locate the start of the
|
||||||
|
// word that now ends the prefix.
|
||||||
|
let after_ws = prefix.trim_end_matches(char::is_whitespace);
|
||||||
|
// `idx` is the byte offset of the last whitespace char before
|
||||||
|
// the word; the word starts at the next char. No whitespace at
|
||||||
|
// all → the word starts at the buffer start.
|
||||||
|
let start = after_ws.rfind(char::is_whitespace).map_or(0, |idx| {
|
||||||
|
idx + after_ws[idx..].chars().next().map_or(0, char::len_utf8)
|
||||||
|
});
|
||||||
|
self.input.replace_range(start..self.input_cursor, "");
|
||||||
|
self.input_cursor = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ctrl-K — kill from the cursor to the end of the line (ADR-0049).
|
||||||
|
/// The cursor is always a char boundary, so a plain truncate is
|
||||||
|
/// safe.
|
||||||
|
fn kill_to_end(&mut self) {
|
||||||
|
self.input.truncate(self.input_cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ctrl-U — kill from the start of the line to the cursor
|
||||||
|
/// (ADR-0049). The cursor moves to the start.
|
||||||
|
fn kill_to_start(&mut self) {
|
||||||
|
self.input.replace_range(0..self.input_cursor, "");
|
||||||
|
self.input_cursor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Move backwards in history (towards older entries).
|
/// Move backwards in history (towards older entries).
|
||||||
fn history_back(&mut self) {
|
fn history_back(&mut self) {
|
||||||
if self.history.is_empty() {
|
if self.history.is_empty() {
|
||||||
@@ -5756,6 +5832,245 @@ mod tests {
|
|||||||
assert_eq!(app.input_cursor, 0);
|
assert_eq!(app.input_cursor, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ADR-0049 (issue #29): input-field readline keymap ----
|
||||||
|
|
||||||
|
fn ctrl(c: char) -> AppEvent {
|
||||||
|
key_mod(KeyCode::Char(c), KeyModifiers::CONTROL)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_clears_a_partly_typed_command() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "drop table T");
|
||||||
|
app.update(key(KeyCode::Esc));
|
||||||
|
assert_eq!(app.input, "");
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_clear_resets_horizontal_scroll() {
|
||||||
|
// A long line that has been horizontally scrolled must
|
||||||
|
// reset its scroll offset on clear, exactly like submit.
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "drop table T");
|
||||||
|
app.input_scroll_offset = 5;
|
||||||
|
app.update(key(KeyCode::Esc));
|
||||||
|
assert_eq!(app.input, "");
|
||||||
|
assert_eq!(app.input_scroll_offset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_clear_cancels_history_navigation() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "drop table A");
|
||||||
|
submit(&mut app);
|
||||||
|
app.update(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.input, "drop table A");
|
||||||
|
app.update(key(KeyCode::Esc));
|
||||||
|
assert_eq!(app.input, "");
|
||||||
|
assert!(app.history_cursor.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_with_live_completion_memo_undoes_rather_than_clears() {
|
||||||
|
// Precedence: while a multi-candidate Tab memo is alive, Esc
|
||||||
|
// undoes the completion (restoring the original text), it does
|
||||||
|
// NOT clear the whole input.
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "show ");
|
||||||
|
app.update(key(KeyCode::Tab)); // → "show data", memo alive
|
||||||
|
assert!(app.last_completion.is_some());
|
||||||
|
app.update(key(KeyCode::Esc));
|
||||||
|
assert_eq!(app.input, "show ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_a_moves_cursor_to_start() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.update(ctrl('a'));
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_e_moves_cursor_to_end() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.update(key(KeyCode::Home));
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
app.update(ctrl('e'));
|
||||||
|
assert_eq!(app.input_cursor, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_w_deletes_the_previous_word() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "drop table T");
|
||||||
|
app.update(ctrl('w'));
|
||||||
|
assert_eq!(app.input, "drop table ");
|
||||||
|
assert_eq!(app.input_cursor, "drop table ".len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_w_eats_trailing_whitespace_then_the_word() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "foo bar ");
|
||||||
|
app.update(ctrl('w'));
|
||||||
|
assert_eq!(app.input, "foo ");
|
||||||
|
assert_eq!(app.input_cursor, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_w_at_start_is_a_noop() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.input_cursor = 0;
|
||||||
|
app.update(ctrl('w'));
|
||||||
|
assert_eq!(app.input, "hello");
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_w_only_deletes_back_to_the_cursor() {
|
||||||
|
// Mid-line: deletes the word before the cursor, leaving the
|
||||||
|
// suffix untouched.
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "drop table T");
|
||||||
|
app.input_cursor = "drop table".len(); // cursor right after "table"
|
||||||
|
app.update(ctrl('w'));
|
||||||
|
assert_eq!(app.input, "drop T");
|
||||||
|
assert_eq!(app.input_cursor, "drop ".len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_w_handles_multibyte_words() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "héllo wörld");
|
||||||
|
app.update(ctrl('w'));
|
||||||
|
assert_eq!(app.input, "héllo ");
|
||||||
|
assert_eq!(app.input_cursor, "héllo ".len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_k_kills_to_end_of_line() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello world");
|
||||||
|
app.input_cursor = 5; // after "hello"
|
||||||
|
app.update(ctrl('k'));
|
||||||
|
assert_eq!(app.input, "hello");
|
||||||
|
assert_eq!(app.input_cursor, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_u_kills_to_start_of_line() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello world");
|
||||||
|
app.input_cursor = 6; // after "hello "
|
||||||
|
app.update(ctrl('u'));
|
||||||
|
assert_eq!(app.input, "world");
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_u_cancels_history_navigation() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "drop table A");
|
||||||
|
submit(&mut app);
|
||||||
|
app.update(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.input, "drop table A");
|
||||||
|
app.update(ctrl('u')); // cursor is at end after recall → clears all
|
||||||
|
assert_eq!(app.input, "");
|
||||||
|
assert!(app.history_cursor.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_w_cancels_history_navigation() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "drop table A");
|
||||||
|
submit(&mut app);
|
||||||
|
app.update(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.input, "drop table A");
|
||||||
|
app.update(ctrl('w')); // deletes the recalled "A" word
|
||||||
|
assert_eq!(app.input, "drop table ");
|
||||||
|
assert!(app.history_cursor.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_w_on_whitespace_only_clears_it() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, " ");
|
||||||
|
app.update(ctrl('w'));
|
||||||
|
assert_eq!(app.input, "");
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_k_at_end_of_line_is_a_noop() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
// Cursor at end.
|
||||||
|
app.update(ctrl('k'));
|
||||||
|
assert_eq!(app.input, "hello");
|
||||||
|
assert_eq!(app.input_cursor, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_k_at_start_kills_the_whole_line() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.input_cursor = 0;
|
||||||
|
app.update(ctrl('k'));
|
||||||
|
assert_eq!(app.input, "");
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_u_at_start_of_line_is_a_noop() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
app.input_cursor = 0;
|
||||||
|
app.update(ctrl('u'));
|
||||||
|
assert_eq!(app.input, "hello");
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_u_at_end_kills_the_whole_line() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "hello");
|
||||||
|
// Cursor at end.
|
||||||
|
app.update(ctrl('u'));
|
||||||
|
assert_eq!(app.input, "");
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_on_empty_input_is_harmless() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(key(KeyCode::Esc));
|
||||||
|
assert_eq!(app.input, "");
|
||||||
|
assert_eq!(app.input_cursor, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn esc_exiting_nav_mode_does_not_clear_the_input() {
|
||||||
|
// ADR-0049 / ADR-0046 DC3: while a sidebar panel is focused
|
||||||
|
// (Ctrl-O), Esc exits navigation mode — the nav handler
|
||||||
|
// consumes it upstream of the input-field keymap, so the
|
||||||
|
// partly-typed command is preserved, NOT cleared.
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "create table T");
|
||||||
|
app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
|
||||||
|
assert_eq!(app.nav_focus, NavFocus::SidebarTables);
|
||||||
|
// The draft survives entering nav mode.
|
||||||
|
assert_eq!(app.input, "create table T");
|
||||||
|
app.update(key(KeyCode::Esc));
|
||||||
|
// Esc returned focus to the input WITHOUT clearing it.
|
||||||
|
assert_eq!(app.nav_focus, NavFocus::Input);
|
||||||
|
assert_eq!(app.input, "create table T");
|
||||||
|
assert_eq!(app.input_cursor, "create table T".len());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn relationships_refreshed_event_updates_the_field() {
|
fn relationships_refreshed_event_updates_the_field() {
|
||||||
// ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the
|
// ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the
|
||||||
|
|||||||
Reference in New Issue
Block a user