From 5976e8d229497099f450403ed8ba3116d08e6a5f Mon Sep 17 00:00:00 2001 From: Wez Furlong Date: Mon, 27 May 2019 17:39:10 -0700 Subject: [PATCH] lineedit: add tab completion support --- termwiz/examples/line_editor.rs | 64 ++++++++++++++++++++++++++- termwiz/src/lineedit/actions.rs | 1 + termwiz/src/lineedit/host.rs | 20 +++++++++ termwiz/src/lineedit/mod.rs | 78 ++++++++++++++++++++++++++++++++- 4 files changed, 161 insertions(+), 2 deletions(-) diff --git a/termwiz/examples/line_editor.rs b/termwiz/examples/line_editor.rs index 9a38a18d1..e4f8ceac8 100644 --- a/termwiz/examples/line_editor.rs +++ b/termwiz/examples/line_editor.rs @@ -27,10 +27,72 @@ impl LineEditorHost for Host { fn history(&mut self) -> &mut History { &mut self.history } + + /// Demo of the completion API for words starting with "h" or "he" + fn complete(&self, line: &str, cursor_position: usize) -> Vec { + let mut candidates = vec![]; + if let Some((range, word)) = word_at_cursor(line, cursor_position) { + let words = &["hello", "help", "he-man"]; + + for w in words { + if w.starts_with(word) { + candidates.push(CompletionCandidate { + range: range.clone(), + text: w.to_string(), + }); + } + } + } + candidates + } +} + +/// This is a conceptually simple function that computes the bounds +/// of the whitespace delimited word at the specified cursor position +/// in the supplied line string. +/// It returns the range and the corresponding slice out of the line. +/// This function is sufficient for example purposes; in a real application +/// the equivalent function would need to be aware of quoting and other +/// application specific context. +fn word_at_cursor(line: &str, cursor_position: usize) -> Option<(std::ops::Range, &str)> { + let char_indices: Vec<(usize, char)> = line.char_indices().collect(); + if char_indices.is_empty() { + return None; + } + let char_position = char_indices + .iter() + .position(|(idx, _)| *idx == cursor_position) + .unwrap_or(char_indices.len()); + + // Look back until we find whitespace + let mut start_position = char_position; + while start_position > 0 + && start_position <= char_indices.len() + && !char_indices[start_position - 1].1.is_whitespace() + { + start_position -= 1; + } + + // Look forwards until we find whitespace + let mut end_position = char_position; + while end_position < char_indices.len() && !char_indices[end_position].1.is_whitespace() { + end_position += 1; + } + + if end_position > start_position { + let range = char_indices[start_position].0 + ..char_indices + .get(end_position) + .map(|c| c.0 + 1) + .unwrap_or(line.len()); + Some((range.clone(), &line[range])) + } else { + None + } } fn main() -> Fallible<()> { - println!("Type `exit` to quit this example"); + println!("Type `exit` to quit this example, or start a word with `h` and press Tab."); let mut editor = line_editor()?; let mut host = Host::default(); diff --git a/termwiz/src/lineedit/actions.rs b/termwiz/src/lineedit/actions.rs index c1382f71b..d0a74fe87 100644 --- a/termwiz/src/lineedit/actions.rs +++ b/termwiz/src/lineedit/actions.rs @@ -22,4 +22,5 @@ pub enum Action { Kill(Movement), HistoryPrevious, HistoryNext, + Complete, } diff --git a/termwiz/src/lineedit/host.rs b/termwiz/src/lineedit/host.rs index ae7d3b522..f683e53a3 100644 --- a/termwiz/src/lineedit/host.rs +++ b/termwiz/src/lineedit/host.rs @@ -63,6 +63,26 @@ pub trait LineEditorHost { /// Returns the history implementation fn history(&mut self) -> &mut History; + + /// Tab completion support. + /// The line and current cursor position are provided and it is up to the + /// embedding application to produce a list of completion candidates. + /// The default implementation is an empty list. + fn complete(&self, _line: &str, _cursor_position: usize) -> Vec { + vec![] + } +} + +/// A candidate for tab completion. +/// If the line and cursor look like "why he" and if "hello" is a valid +/// completion of "he" in that context, then the corresponding CompletionCandidate +/// would have its range set to [4..6] (the "he" slice range) and its text +/// set to "hello". +pub struct CompletionCandidate { + /// The section of the input line to be replaced + pub range: std::ops::Range, + /// The replacement text + pub text: String, } /// A concrete implementation of `LineEditorHost` that uses the default behaviors. diff --git a/termwiz/src/lineedit/mod.rs b/termwiz/src/lineedit/mod.rs index 5f075f603..f38fdf8fc 100644 --- a/termwiz/src/lineedit/mod.rs +++ b/termwiz/src/lineedit/mod.rs @@ -76,6 +76,39 @@ pub struct LineEditor { history_pos: Option, bottom_line: Option, + + completion: Option, +} + +struct CompletionState { + candidates: Vec, + index: usize, + original_line: String, + original_cursor: usize, +} + +impl CompletionState { + fn next(&mut self) { + self.index += 1; + if self.index >= self.candidates.len() { + self.index = 0; + } + } + + fn current(&self) -> (usize, String) { + let mut line = self.original_line.clone(); + let candidate = &self.candidates[self.index]; + line.replace_range(candidate.range.clone(), &candidate.text); + + // To figure the new cursor position do a little math: + // "he" when the completion is "hello" will set the completion + // candidate to replace "he" with "hello", so the difference in the + // lengths of these two is how far the cursor needs to move. + let range_len = candidate.range.end - candidate.range.start; + let new_cursor = self.original_cursor + candidate.text.len() - range_len; + + (new_cursor, line) + } } impl LineEditor { @@ -107,6 +140,7 @@ impl LineEditor { cursor: 0, history_pos: None, bottom_line: None, + completion: None, } } @@ -173,6 +207,11 @@ impl LineEditor { modifiers: Modifiers::CTRL, }) => Some(Action::Cancel), + InputEvent::Key(KeyEvent { + key: KeyCode::Tab, + modifiers: Modifiers::NONE, + }) => Some(Action::Complete), + InputEvent::Key(KeyEvent { key: KeyCode::Char('D'), modifiers: Modifiers::CTRL, @@ -389,6 +428,8 @@ impl LineEditor { } fn kill_text(&mut self, movement: Movement) { + self.clear_completion(); + let new_cursor = self.eval_movement(movement); let (lower, upper) = if new_cursor < self.cursor { @@ -405,11 +446,16 @@ impl LineEditor { self.cursor = new_cursor.min(self.line.len()); } + fn clear_completion(&mut self) { + self.completion = None; + } + fn read_line_impl(&mut self, host: &mut LineEditorHost) -> Fallible> { self.line.clear(); self.cursor = 0; self.history_pos = None; self.bottom_line = None; + self.clear_completion(); self.render(host)?; while let Some(event) = self.terminal.poll_input(None)? { @@ -424,8 +470,12 @@ impl LineEditor { .into()) } Some(Action::Kill(movement)) => self.kill_text(movement), - Some(Action::Move(movement)) => self.cursor = self.eval_movement(movement), + Some(Action::Move(movement)) => { + self.clear_completion(); + self.cursor = self.eval_movement(movement); + } Some(Action::InsertChar(rep, c)) => { + self.clear_completion(); for _ in 0..rep { self.line.insert(self.cursor, c); let mut cursor = GraphemeCursor::new(self.cursor, self.line.len(), false); @@ -435,6 +485,7 @@ impl LineEditor { } } Some(Action::InsertText(rep, text)) => { + self.clear_completion(); for _ in 0..rep { self.line.insert_str(self.cursor, &text); self.cursor += text.len(); @@ -445,6 +496,7 @@ impl LineEditor { .render(&[Change::ClearScreen(Default::default())])?; } Some(Action::HistoryPrevious) => { + self.clear_completion(); if let Some(cur_pos) = self.history_pos.as_ref() { let prior_idx = cur_pos.saturating_sub(1); if let Some(prior) = host.history().get(prior_idx) { @@ -466,6 +518,7 @@ impl LineEditor { } } Some(Action::HistoryNext) => { + self.clear_completion(); if let Some(cur_pos) = self.history_pos.as_ref() { let next_idx = cur_pos.saturating_add(1); if let Some(next) = host.history().get(next_idx) { @@ -481,6 +534,29 @@ impl LineEditor { } } } + Some(Action::Complete) => { + if self.completion.is_none() { + let candidates = host.complete(&self.line, self.cursor); + if !candidates.is_empty() { + let state = CompletionState { + candidates, + index: 0, + original_line: self.line.clone(), + original_cursor: self.cursor, + }; + + let (cursor, line) = state.current(); + self.cursor = cursor; + self.line = line; + self.completion = Some(state); + } + } else if let Some(state) = self.completion.as_mut() { + state.next(); + let (cursor, line) = state.current(); + self.cursor = cursor; + self.line = line; + } + } None => {} } self.render(host)?;