ADR-0022 stage 8a: non-modal keyword completion + Esc/Backspace undo
Per the user's framing decision: there is no "completion
mode." Tab is just an action that consumes whatever is
expected at the cursor, and the existing always-on hint
panel (stage 5) tells the user what's available.
New `completion` module: `candidates_at_cursor(input,
cursor)` returns a `Completion { replaced_range,
partial_prefix, candidates }` based on the parser's
expected-token set at the cursor position. Filters to bare
keyword candidates only (no punctuation, no descriptive
labels), narrowed by the typed prefix (case-insensitive).
`LastCompletion` memo struct on `App::last_completion`
carries the cycle state: inserted_range, original_text,
candidates, selection_idx. Wrap-around forward/backward
indices.
App key handling (added before the existing matcher):
- Tab → cycle forward if memo present; else insert first
candidate; create / advance memo.
- Shift-Tab → cycle backward if memo present; else
insert last candidate (alphabetically) so the user can
jump to the end without cycling through everything.
- Esc / Backspace while memo alive → restore
original_text in inserted_range, place cursor at the
pre-Tab position, clear memo.
- Any other key → clear memo, then process normally.
The user's symmetry preference was load-bearing here:
"insert with one keystroke, remove with one keystroke."
Both Esc and Backspace honour that — multiple Tab cycles
collapse into one undo. Documented inline.
A single-candidate completion still creates a memo so
Esc/Backspace can undo it. Multiple Tabs in a row cycle
through the candidate list with wrap-around at both ends
(per the user's #2).
Tests: 728 passing, 0 failing, 1 ignored (705 baseline →
+23: 13 completion module + 10 app integration tests
covering Tab, Shift-Tab, cycling, wrap-around, Esc-undo,
Backspace-undo, multi-Tab-then-Esc, memo invalidation by
typing or cursor movement). Clippy clean.
Stage 8b will add multi-candidate hint-panel rendering
with scroll markers (`<` `>`) per the user's #2. Stage 8c
will plumb in identifier completion + invalid-identifier
detection.
This commit is contained in:
+275
@@ -127,6 +127,14 @@ pub struct App {
|
||||
/// dispatches keys to the modal instead of the input
|
||||
/// field.
|
||||
pub modal: Option<Modal>,
|
||||
/// Memo of the most recent Tab-completion (ADR-0022
|
||||
/// stage 8). Carries enough state to cycle to the next /
|
||||
/// previous candidate on subsequent Tab / Shift-Tab
|
||||
/// presses, and to undo the whole insertion in one
|
||||
/// keystroke via Esc / Backspace. Cleared by *any* other
|
||||
/// keystroke — no completion mode, just a transient
|
||||
/// memory of "the last thing Tab did."
|
||||
pub last_completion: Option<crate::completion::LastCompletion>,
|
||||
}
|
||||
|
||||
/// Dialogs that take over keyboard input when active.
|
||||
@@ -236,6 +244,7 @@ impl App {
|
||||
project_is_temp: false,
|
||||
fatal_message: None,
|
||||
modal: None,
|
||||
last_completion: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +492,27 @@ impl App {
|
||||
if self.modal.is_some() {
|
||||
return self.handle_modal_key(key);
|
||||
}
|
||||
|
||||
// ADR-0022 stage 8 — non-modal completion. Tab /
|
||||
// Shift-Tab cycle; Esc / Backspace undo the whole
|
||||
// last-Tab insertion in one keystroke while the memo
|
||||
// is alive (per the user's symmetry preference: one
|
||||
// keystroke to insert, one to remove). Any other key
|
||||
// clears the memo before being processed normally.
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Tab, _) => return self.completion_tab_forward(),
|
||||
(KeyCode::BackTab, _) => return self.completion_tab_backward(),
|
||||
(KeyCode::Esc, _) if self.last_completion.is_some() => {
|
||||
self.undo_last_completion();
|
||||
return Vec::new();
|
||||
}
|
||||
(KeyCode::Backspace, _) if self.last_completion.is_some() => {
|
||||
self.undo_last_completion();
|
||||
return Vec::new();
|
||||
}
|
||||
_ => self.last_completion = None,
|
||||
}
|
||||
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
|
||||
(KeyCode::Enter, _) => self.submit(),
|
||||
@@ -592,6 +622,118 @@ impl App {
|
||||
self.input_cursor = idx;
|
||||
}
|
||||
|
||||
/// Tab key handler — insert / cycle forward through the
|
||||
/// candidates at the cursor (ADR-0022 stage 8). Three
|
||||
/// shapes:
|
||||
/// - memo present: cycle to next candidate, replace
|
||||
/// inserted_range, advance memo.selection_idx;
|
||||
/// - memo absent + multi-candidate: insert first
|
||||
/// candidate (with trailing space) and create memo;
|
||||
/// - memo absent + single candidate: insert it and
|
||||
/// create a one-element memo so Esc/Backspace can
|
||||
/// still undo;
|
||||
/// - no candidates: no-op.
|
||||
fn completion_tab_forward(&mut self) -> Vec<Action> {
|
||||
self.cancel_history_navigation();
|
||||
if let Some(memo) = self.last_completion.take() {
|
||||
let next = memo.next_idx();
|
||||
self.last_completion = Some(self.replace_inserted(memo, next));
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(memo) = self.start_completion(0) {
|
||||
self.last_completion = Some(memo);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Shift-Tab key handler — symmetric to
|
||||
/// `completion_tab_forward` but cycles backward, and
|
||||
/// starts from the last candidate (alphabetically) if no
|
||||
/// memo exists. Per the user's #2: wrap-around at both
|
||||
/// ends.
|
||||
fn completion_tab_backward(&mut self) -> Vec<Action> {
|
||||
self.cancel_history_navigation();
|
||||
if let Some(memo) = self.last_completion.take() {
|
||||
let prev = memo.prev_idx();
|
||||
self.last_completion = Some(self.replace_inserted(memo, prev));
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(memo) = self.start_completion_last() {
|
||||
self.last_completion = Some(memo);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Esc / Backspace handler while a completion memo is
|
||||
/// alive — restore the original text in
|
||||
/// `inserted_range` and place the cursor where the user
|
||||
/// was when they hit Tab. The memo is cleared.
|
||||
fn undo_last_completion(&mut self) {
|
||||
let Some(memo) = self.last_completion.take() else {
|
||||
return;
|
||||
};
|
||||
let (start, end) = memo.inserted_range;
|
||||
self.input.replace_range(start..end, &memo.original_text);
|
||||
self.input_cursor = start + memo.original_text.len();
|
||||
}
|
||||
|
||||
/// Compute the candidates at the cursor and insert the
|
||||
/// candidate at index `start_idx` (with a trailing space).
|
||||
/// Returns the freshly-built memo. `None` if there are no
|
||||
/// candidates to insert.
|
||||
fn start_completion(&mut self, start_idx: usize) -> Option<crate::completion::LastCompletion> {
|
||||
let cursor = self.input_cursor.min(self.input.len());
|
||||
let comp = crate::completion::candidates_at_cursor(&self.input, cursor)?;
|
||||
let idx = start_idx % comp.candidates.len();
|
||||
let inserted = format!("{} ", comp.candidates[idx]);
|
||||
let original_text =
|
||||
self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
|
||||
self.input
|
||||
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
|
||||
let new_end = comp.replaced_range.0 + inserted.len();
|
||||
self.input_cursor = new_end;
|
||||
Some(crate::completion::LastCompletion {
|
||||
inserted_range: (comp.replaced_range.0, new_end),
|
||||
original_text,
|
||||
candidates: comp.candidates,
|
||||
selection_idx: idx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Same as `start_completion` but starts from the last
|
||||
/// candidate alphabetically (for Shift-Tab from a fresh
|
||||
/// state).
|
||||
fn start_completion_last(&mut self) -> Option<crate::completion::LastCompletion> {
|
||||
// We need to know how many candidates there will be
|
||||
// before we can pick "last". Compute the completion
|
||||
// first, then call start_completion with that index.
|
||||
let cursor = self.input_cursor.min(self.input.len());
|
||||
let comp = crate::completion::candidates_at_cursor(&self.input, cursor)?;
|
||||
let last = comp.candidates.len() - 1;
|
||||
// Fall through to the same path so the inserted text
|
||||
// and memo construction stay in one place.
|
||||
self.start_completion(last)
|
||||
}
|
||||
|
||||
/// Replace the inserted text with `candidates[idx]` and
|
||||
/// return an updated memo. Used by Tab/Shift-Tab cycling.
|
||||
fn replace_inserted(
|
||||
&mut self,
|
||||
memo: crate::completion::LastCompletion,
|
||||
idx: usize,
|
||||
) -> crate::completion::LastCompletion {
|
||||
let new_inserted = format!("{} ", memo.candidates[idx]);
|
||||
let (start, end) = memo.inserted_range;
|
||||
self.input.replace_range(start..end, &new_inserted);
|
||||
let new_end = start + new_inserted.len();
|
||||
self.input_cursor = new_end;
|
||||
crate::completion::LastCompletion {
|
||||
inserted_range: (start, new_end),
|
||||
selection_idx: idx,
|
||||
..memo
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_at_cursor(&mut self) {
|
||||
if self.input_cursor >= self.input.len() {
|
||||
return;
|
||||
@@ -1607,6 +1749,139 @@ mod tests {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// ---- ADR-0022 stage 8: Tab completion + Esc/Backspace undo ----
|
||||
|
||||
#[test]
|
||||
fn tab_inserts_unique_keyword_with_trailing_space() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "cre");
|
||||
let actions = app.update(key(KeyCode::Tab));
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(app.input, "create ");
|
||||
assert_eq!(app.input_cursor, 7);
|
||||
// Memo is alive even for a single-candidate completion
|
||||
// so Esc/Backspace can still undo.
|
||||
assert!(app.last_completion.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_at_word_boundary_inserts_next_expected_keyword() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "create ");
|
||||
let actions = app.update(key(KeyCode::Tab));
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(app.input, "create table ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_with_no_candidates_is_a_noop() {
|
||||
// After `create table T with pk` the parser succeeds —
|
||||
// no candidates, Tab does nothing.
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "create table T with pk");
|
||||
let len = app.input.len();
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input.len(), len);
|
||||
assert!(app.last_completion.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_cycles_forward_through_multi_candidate_set() {
|
||||
// After `show ` two candidates: data, table.
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "show ");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "show data ");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "show table ");
|
||||
// Wrap-around per the user's #2.
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "show data ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_tab_cycles_backward_starting_from_last() {
|
||||
// Fresh Shift-Tab on multi-candidate position starts
|
||||
// at the last candidate alphabetically.
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "show ");
|
||||
app.update(key(KeyCode::BackTab));
|
||||
assert_eq!(app.input, "show table ");
|
||||
app.update(key(KeyCode::BackTab));
|
||||
assert_eq!(app.input, "show data ");
|
||||
// Wrap.
|
||||
app.update(key(KeyCode::BackTab));
|
||||
assert_eq!(app.input, "show table ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_after_tab_restores_original_in_one_keystroke() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "cre");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "create ");
|
||||
app.update(key(KeyCode::Esc));
|
||||
assert_eq!(app.input, "cre");
|
||||
assert_eq!(app.input_cursor, 3);
|
||||
assert!(app.last_completion.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_after_tab_restores_original_in_one_keystroke() {
|
||||
// Symmetry: one keystroke to insert, one to remove.
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "cre");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert_eq!(app.input, "create ");
|
||||
app.update(key(KeyCode::Backspace));
|
||||
assert_eq!(app.input, "cre");
|
||||
assert_eq!(app.input_cursor, 3);
|
||||
assert!(app.last_completion.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_after_multiple_tabs_restores_original_state_not_previous_cycle() {
|
||||
// Tab Tab Tab cycled through three candidates; Esc
|
||||
// restores the pre-completion state (the original
|
||||
// partial prefix), not the previous cycle.
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "drop ");
|
||||
app.update(key(KeyCode::Tab)); // column
|
||||
app.update(key(KeyCode::Tab)); // relationship
|
||||
app.update(key(KeyCode::Tab)); // table
|
||||
assert_eq!(app.input, "drop table ");
|
||||
app.update(key(KeyCode::Esc));
|
||||
assert_eq!(app.input, "drop ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_a_letter_clears_the_completion_memo() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "cre");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert!(app.last_completion.is_some());
|
||||
// Typing any non-Tab key clears the memo. The
|
||||
// inserted text stays in the buffer.
|
||||
type_str(&mut app, "x");
|
||||
assert_eq!(app.input, "create x");
|
||||
assert!(app.last_completion.is_none());
|
||||
// Backspace now does its normal job — delete one char.
|
||||
app.update(key(KeyCode::Backspace));
|
||||
assert_eq!(app.input, "create ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_movement_clears_the_completion_memo() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "cre");
|
||||
app.update(key(KeyCode::Tab));
|
||||
// Per the user's #3 — cursor movement clears the
|
||||
// memo. After this, Esc / Backspace behave normally
|
||||
// and don't trigger the whole-span undo.
|
||||
app.update(key(KeyCode::Home));
|
||||
assert!(app.last_completion.is_none());
|
||||
}
|
||||
|
||||
fn sample_description(name: &str) -> TableDescription {
|
||||
TableDescription {
|
||||
name: name.to_string(),
|
||||
|
||||
Reference in New Issue
Block a user