|
@@ -1,193 +1,119 @@
|
|
|
|
|
+use std::borrow::Cow;
|
|
|
|
|
+use std::cell::RefCell;
|
|
|
use std::io::{self, IsTerminal, Write};
|
|
use std::io::{self, IsTerminal, Write};
|
|
|
|
|
|
|
|
-use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp};
|
|
|
|
|
-use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
|
|
|
|
-use crossterm::queue;
|
|
|
|
|
-use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
|
|
|
|
|
|
|
+use rustyline::completion::{Completer, Pair};
|
|
|
|
|
+use rustyline::error::ReadlineError;
|
|
|
|
|
+use rustyline::highlight::{CmdKind, Highlighter};
|
|
|
|
|
+use rustyline::hint::Hinter;
|
|
|
|
|
+use rustyline::history::DefaultHistory;
|
|
|
|
|
+use rustyline::validate::Validator;
|
|
|
|
|
+use rustyline::{
|
|
|
|
|
+ Cmd, CompletionType, Config, Context, EditMode, Editor, Helper, KeyCode, KeyEvent, Modifiers,
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
-pub struct InputBuffer {
|
|
|
|
|
- buffer: String,
|
|
|
|
|
- cursor: usize,
|
|
|
|
|
|
|
+pub enum ReadOutcome {
|
|
|
|
|
+ Submit(String),
|
|
|
|
|
+ Cancel,
|
|
|
|
|
+ Exit,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-impl InputBuffer {
|
|
|
|
|
- #[must_use]
|
|
|
|
|
- pub fn new() -> Self {
|
|
|
|
|
- Self {
|
|
|
|
|
- buffer: String::new(),
|
|
|
|
|
- cursor: 0,
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- pub fn insert(&mut self, ch: char) {
|
|
|
|
|
- self.buffer.insert(self.cursor, ch);
|
|
|
|
|
- self.cursor += ch.len_utf8();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- pub fn insert_newline(&mut self) {
|
|
|
|
|
- self.insert('\n');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- pub fn backspace(&mut self) {
|
|
|
|
|
- if self.cursor == 0 {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let previous = self.buffer[..self.cursor]
|
|
|
|
|
- .char_indices()
|
|
|
|
|
- .last()
|
|
|
|
|
- .map_or(0, |(idx, _)| idx);
|
|
|
|
|
- self.buffer.drain(previous..self.cursor);
|
|
|
|
|
- self.cursor = previous;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- pub fn move_left(&mut self) {
|
|
|
|
|
- if self.cursor == 0 {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- self.cursor = self.buffer[..self.cursor]
|
|
|
|
|
- .char_indices()
|
|
|
|
|
- .last()
|
|
|
|
|
- .map_or(0, |(idx, _)| idx);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+struct SlashCommandHelper {
|
|
|
|
|
+ completions: Vec<String>,
|
|
|
|
|
+ current_line: RefCell<String>,
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- pub fn move_right(&mut self) {
|
|
|
|
|
- if self.cursor >= self.buffer.len() {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- if let Some(next) = self.buffer[self.cursor..].chars().next() {
|
|
|
|
|
- self.cursor += next.len_utf8();
|
|
|
|
|
|
|
+impl SlashCommandHelper {
|
|
|
|
|
+ fn new(completions: Vec<String>) -> Self {
|
|
|
|
|
+ Self {
|
|
|
|
|
+ completions,
|
|
|
|
|
+ current_line: RefCell::new(String::new()),
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- pub fn move_home(&mut self) {
|
|
|
|
|
- self.cursor = 0;
|
|
|
|
|
|
|
+ fn reset_current_line(&self) {
|
|
|
|
|
+ self.current_line.borrow_mut().clear();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- pub fn move_end(&mut self) {
|
|
|
|
|
- self.cursor = self.buffer.len();
|
|
|
|
|
|
|
+ fn current_line(&self) -> String {
|
|
|
|
|
+ self.current_line.borrow().clone()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- #[must_use]
|
|
|
|
|
- pub fn as_str(&self) -> &str {
|
|
|
|
|
- &self.buffer
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- #[cfg(test)]
|
|
|
|
|
- #[must_use]
|
|
|
|
|
- pub fn cursor(&self) -> usize {
|
|
|
|
|
- self.cursor
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- pub fn clear(&mut self) {
|
|
|
|
|
- self.buffer.clear();
|
|
|
|
|
- self.cursor = 0;
|
|
|
|
|
|
|
+ fn set_current_line(&self, line: &str) {
|
|
|
|
|
+ let mut current = self.current_line.borrow_mut();
|
|
|
|
|
+ current.clear();
|
|
|
|
|
+ current.push_str(line);
|
|
|
}
|
|
}
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- pub fn replace(&mut self, value: impl Into<String>) {
|
|
|
|
|
- self.buffer = value.into();
|
|
|
|
|
- self.cursor = self.buffer.len();
|
|
|
|
|
- }
|
|
|
|
|
|
|
+impl Completer for SlashCommandHelper {
|
|
|
|
|
+ type Candidate = Pair;
|
|
|
|
|
|
|
|
- #[must_use]
|
|
|
|
|
- fn current_command_prefix(&self) -> Option<&str> {
|
|
|
|
|
- if self.cursor != self.buffer.len() {
|
|
|
|
|
- return None;
|
|
|
|
|
- }
|
|
|
|
|
- let prefix = &self.buffer[..self.cursor];
|
|
|
|
|
- if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
|
|
|
|
|
- return None;
|
|
|
|
|
- }
|
|
|
|
|
- Some(prefix)
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- pub fn complete_slash_command(&mut self, candidates: &[String]) -> bool {
|
|
|
|
|
- let Some(prefix) = self.current_command_prefix() else {
|
|
|
|
|
- return false;
|
|
|
|
|
|
|
+ fn complete(
|
|
|
|
|
+ &self,
|
|
|
|
|
+ line: &str,
|
|
|
|
|
+ pos: usize,
|
|
|
|
|
+ _ctx: &Context<'_>,
|
|
|
|
|
+ ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
|
|
|
|
|
+ let Some(prefix) = slash_command_prefix(line, pos) else {
|
|
|
|
|
+ return Ok((0, Vec::new()));
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- let matches = candidates
|
|
|
|
|
|
|
+ let matches = self
|
|
|
|
|
+ .completions
|
|
|
.iter()
|
|
.iter()
|
|
|
.filter(|candidate| candidate.starts_with(prefix))
|
|
.filter(|candidate| candidate.starts_with(prefix))
|
|
|
- .map(String::as_str)
|
|
|
|
|
- .collect::<Vec<_>>();
|
|
|
|
|
- if matches.is_empty() {
|
|
|
|
|
- return false;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let replacement = longest_common_prefix(&matches);
|
|
|
|
|
- if replacement == prefix {
|
|
|
|
|
- return false;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ .map(|candidate| Pair {
|
|
|
|
|
+ display: candidate.clone(),
|
|
|
|
|
+ replacement: candidate.clone(),
|
|
|
|
|
+ })
|
|
|
|
|
+ .collect();
|
|
|
|
|
|
|
|
- self.replace(replacement);
|
|
|
|
|
- true
|
|
|
|
|
|
|
+ Ok((0, matches))
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
-pub struct RenderedBuffer {
|
|
|
|
|
- lines: Vec<String>,
|
|
|
|
|
- cursor_row: u16,
|
|
|
|
|
- cursor_col: u16,
|
|
|
|
|
|
|
+impl Hinter for SlashCommandHelper {
|
|
|
|
|
+ type Hint = String;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-impl RenderedBuffer {
|
|
|
|
|
- #[must_use]
|
|
|
|
|
- pub fn line_count(&self) -> usize {
|
|
|
|
|
- self.lines.len()
|
|
|
|
|
|
|
+impl Highlighter for SlashCommandHelper {
|
|
|
|
|
+ fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
|
|
|
|
|
+ self.set_current_line(line);
|
|
|
|
|
+ Cow::Borrowed(line)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- fn write(&self, out: &mut impl Write) -> io::Result<()> {
|
|
|
|
|
- for (index, line) in self.lines.iter().enumerate() {
|
|
|
|
|
- if index > 0 {
|
|
|
|
|
- writeln!(out)?;
|
|
|
|
|
- }
|
|
|
|
|
- write!(out, "{line}")?;
|
|
|
|
|
- }
|
|
|
|
|
- Ok(())
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- #[cfg(test)]
|
|
|
|
|
- #[must_use]
|
|
|
|
|
- pub fn lines(&self) -> &[String] {
|
|
|
|
|
- &self.lines
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- #[cfg(test)]
|
|
|
|
|
- #[must_use]
|
|
|
|
|
- pub fn cursor_position(&self) -> (u16, u16) {
|
|
|
|
|
- (self.cursor_row, self.cursor_col)
|
|
|
|
|
|
|
+ fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
|
|
|
|
|
+ self.set_current_line(line);
|
|
|
|
|
+ false
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
-pub enum ReadOutcome {
|
|
|
|
|
- Submit(String),
|
|
|
|
|
- Cancel,
|
|
|
|
|
- Exit,
|
|
|
|
|
-}
|
|
|
|
|
|
|
+impl Validator for SlashCommandHelper {}
|
|
|
|
|
+impl Helper for SlashCommandHelper {}
|
|
|
|
|
|
|
|
pub struct LineEditor {
|
|
pub struct LineEditor {
|
|
|
prompt: String,
|
|
prompt: String,
|
|
|
- continuation_prompt: String,
|
|
|
|
|
- history: Vec<String>,
|
|
|
|
|
- history_index: Option<usize>,
|
|
|
|
|
- draft: Option<String>,
|
|
|
|
|
- completions: Vec<String>,
|
|
|
|
|
|
|
+ editor: Editor<SlashCommandHelper, DefaultHistory>,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
impl LineEditor {
|
|
impl LineEditor {
|
|
|
#[must_use]
|
|
#[must_use]
|
|
|
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
|
|
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
|
|
|
|
|
+ let config = Config::builder()
|
|
|
|
|
+ .completion_type(CompletionType::List)
|
|
|
|
|
+ .edit_mode(EditMode::Emacs)
|
|
|
|
|
+ .build();
|
|
|
|
|
+ let mut editor = Editor::<SlashCommandHelper, DefaultHistory>::with_config(config)
|
|
|
|
|
+ .expect("rustyline editor should initialize");
|
|
|
|
|
+ editor.set_helper(Some(SlashCommandHelper::new(completions)));
|
|
|
|
|
+ editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline);
|
|
|
|
|
+ editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline);
|
|
|
|
|
+
|
|
|
Self {
|
|
Self {
|
|
|
prompt: prompt.into(),
|
|
prompt: prompt.into(),
|
|
|
- continuation_prompt: String::from("> "),
|
|
|
|
|
- history: Vec::new(),
|
|
|
|
|
- history_index: None,
|
|
|
|
|
- draft: None,
|
|
|
|
|
- completions,
|
|
|
|
|
|
|
+ editor,
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -196,9 +122,8 @@ impl LineEditor {
|
|
|
if entry.trim().is_empty() {
|
|
if entry.trim().is_empty() {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- self.history.push(entry);
|
|
|
|
|
- self.history_index = None;
|
|
|
|
|
- self.draft = None;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ let _ = self.editor.add_history_entry(entry);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
|
|
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
|
|
@@ -206,45 +131,43 @@ impl LineEditor {
|
|
|
return self.read_line_fallback();
|
|
return self.read_line_fallback();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- enable_raw_mode()?;
|
|
|
|
|
- let mut stdout = io::stdout();
|
|
|
|
|
- let mut input = InputBuffer::new();
|
|
|
|
|
- let mut rendered_lines = 1usize;
|
|
|
|
|
- self.redraw(&mut stdout, &input, rendered_lines)?;
|
|
|
|
|
-
|
|
|
|
|
- loop {
|
|
|
|
|
- let event = event::read()?;
|
|
|
|
|
- if let Event::Key(key) = event {
|
|
|
|
|
- match self.handle_key(key, &mut input) {
|
|
|
|
|
- EditorAction::Continue => {
|
|
|
|
|
- rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?;
|
|
|
|
|
- }
|
|
|
|
|
- EditorAction::Submit => {
|
|
|
|
|
- disable_raw_mode()?;
|
|
|
|
|
- writeln!(stdout)?;
|
|
|
|
|
- self.history_index = None;
|
|
|
|
|
- self.draft = None;
|
|
|
|
|
- return Ok(ReadOutcome::Submit(input.as_str().to_owned()));
|
|
|
|
|
- }
|
|
|
|
|
- EditorAction::Cancel => {
|
|
|
|
|
- disable_raw_mode()?;
|
|
|
|
|
- writeln!(stdout)?;
|
|
|
|
|
- self.history_index = None;
|
|
|
|
|
- self.draft = None;
|
|
|
|
|
- return Ok(ReadOutcome::Cancel);
|
|
|
|
|
- }
|
|
|
|
|
- EditorAction::Exit => {
|
|
|
|
|
- disable_raw_mode()?;
|
|
|
|
|
- writeln!(stdout)?;
|
|
|
|
|
- self.history_index = None;
|
|
|
|
|
- self.draft = None;
|
|
|
|
|
- return Ok(ReadOutcome::Exit);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if let Some(helper) = self.editor.helper_mut() {
|
|
|
|
|
+ helper.reset_current_line();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ match self.editor.readline(&self.prompt) {
|
|
|
|
|
+ Ok(line) => Ok(ReadOutcome::Submit(line)),
|
|
|
|
|
+ Err(ReadlineError::Interrupted) => {
|
|
|
|
|
+ let has_input = !self.current_line().is_empty();
|
|
|
|
|
+ self.finish_interrupted_read()?;
|
|
|
|
|
+ if has_input {
|
|
|
|
|
+ Ok(ReadOutcome::Cancel)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Ok(ReadOutcome::Exit)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ Err(ReadlineError::Eof) => {
|
|
|
|
|
+ self.finish_interrupted_read()?;
|
|
|
|
|
+ Ok(ReadOutcome::Exit)
|
|
|
|
|
+ }
|
|
|
|
|
+ Err(error) => Err(io::Error::other(error)),
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ fn current_line(&self) -> String {
|
|
|
|
|
+ self.editor
|
|
|
|
|
+ .helper()
|
|
|
|
|
+ .map_or_else(String::new, SlashCommandHelper::current_line)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn finish_interrupted_read(&mut self) -> io::Result<()> {
|
|
|
|
|
+ if let Some(helper) = self.editor.helper_mut() {
|
|
|
|
|
+ helper.reset_current_line();
|
|
|
|
|
+ }
|
|
|
|
|
+ let mut stdout = io::stdout();
|
|
|
|
|
+ writeln!(stdout)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
|
|
fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
|
|
|
let mut stdout = io::stdout();
|
|
let mut stdout = io::stdout();
|
|
|
write!(stdout, "{}", self.prompt)?;
|
|
write!(stdout, "{}", self.prompt)?;
|
|
@@ -261,388 +184,86 @@ impl LineEditor {
|
|
|
}
|
|
}
|
|
|
Ok(ReadOutcome::Submit(buffer))
|
|
Ok(ReadOutcome::Submit(buffer))
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- #[allow(clippy::too_many_lines)]
|
|
|
|
|
- fn handle_key(&mut self, key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
|
|
|
|
|
- match key {
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Char('c'),
|
|
|
|
|
- modifiers,
|
|
|
|
|
- ..
|
|
|
|
|
- } if modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
- if input.as_str().is_empty() {
|
|
|
|
|
- EditorAction::Exit
|
|
|
|
|
- } else {
|
|
|
|
|
- input.clear();
|
|
|
|
|
- self.history_index = None;
|
|
|
|
|
- self.draft = None;
|
|
|
|
|
- EditorAction::Cancel
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Char('j'),
|
|
|
|
|
- modifiers,
|
|
|
|
|
- ..
|
|
|
|
|
- } if modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
- input.insert_newline();
|
|
|
|
|
- EditorAction::Continue
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Enter,
|
|
|
|
|
- modifiers,
|
|
|
|
|
- ..
|
|
|
|
|
- } if modifiers.contains(KeyModifiers::SHIFT) => {
|
|
|
|
|
- input.insert_newline();
|
|
|
|
|
- EditorAction::Continue
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Enter,
|
|
|
|
|
- ..
|
|
|
|
|
- } => EditorAction::Submit,
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Backspace,
|
|
|
|
|
- ..
|
|
|
|
|
- } => {
|
|
|
|
|
- input.backspace();
|
|
|
|
|
- EditorAction::Continue
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Left,
|
|
|
|
|
- ..
|
|
|
|
|
- } => {
|
|
|
|
|
- input.move_left();
|
|
|
|
|
- EditorAction::Continue
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Right,
|
|
|
|
|
- ..
|
|
|
|
|
- } => {
|
|
|
|
|
- input.move_right();
|
|
|
|
|
- EditorAction::Continue
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Up, ..
|
|
|
|
|
- } => {
|
|
|
|
|
- self.navigate_history_up(input);
|
|
|
|
|
- EditorAction::Continue
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Down,
|
|
|
|
|
- ..
|
|
|
|
|
- } => {
|
|
|
|
|
- self.navigate_history_down(input);
|
|
|
|
|
- EditorAction::Continue
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Tab, ..
|
|
|
|
|
- } => {
|
|
|
|
|
- input.complete_slash_command(&self.completions);
|
|
|
|
|
- EditorAction::Continue
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Home,
|
|
|
|
|
- ..
|
|
|
|
|
- } => {
|
|
|
|
|
- input.move_home();
|
|
|
|
|
- EditorAction::Continue
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::End, ..
|
|
|
|
|
- } => {
|
|
|
|
|
- input.move_end();
|
|
|
|
|
- EditorAction::Continue
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Esc, ..
|
|
|
|
|
- } => {
|
|
|
|
|
- input.clear();
|
|
|
|
|
- self.history_index = None;
|
|
|
|
|
- self.draft = None;
|
|
|
|
|
- EditorAction::Cancel
|
|
|
|
|
- }
|
|
|
|
|
- KeyEvent {
|
|
|
|
|
- code: KeyCode::Char(ch),
|
|
|
|
|
- modifiers,
|
|
|
|
|
- ..
|
|
|
|
|
- } if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {
|
|
|
|
|
- input.insert(ch);
|
|
|
|
|
- self.history_index = None;
|
|
|
|
|
- self.draft = None;
|
|
|
|
|
- EditorAction::Continue
|
|
|
|
|
- }
|
|
|
|
|
- _ => EditorAction::Continue,
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- fn navigate_history_up(&mut self, input: &mut InputBuffer) {
|
|
|
|
|
- if self.history.is_empty() {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- match self.history_index {
|
|
|
|
|
- Some(0) => {}
|
|
|
|
|
- Some(index) => {
|
|
|
|
|
- let next_index = index - 1;
|
|
|
|
|
- input.replace(self.history[next_index].clone());
|
|
|
|
|
- self.history_index = Some(next_index);
|
|
|
|
|
- }
|
|
|
|
|
- None => {
|
|
|
|
|
- self.draft = Some(input.as_str().to_owned());
|
|
|
|
|
- let next_index = self.history.len() - 1;
|
|
|
|
|
- input.replace(self.history[next_index].clone());
|
|
|
|
|
- self.history_index = Some(next_index);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- fn navigate_history_down(&mut self, input: &mut InputBuffer) {
|
|
|
|
|
- let Some(index) = self.history_index else {
|
|
|
|
|
- return;
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- if index + 1 < self.history.len() {
|
|
|
|
|
- let next_index = index + 1;
|
|
|
|
|
- input.replace(self.history[next_index].clone());
|
|
|
|
|
- self.history_index = Some(next_index);
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- input.replace(self.draft.take().unwrap_or_default());
|
|
|
|
|
- self.history_index = None;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- fn redraw(
|
|
|
|
|
- &self,
|
|
|
|
|
- out: &mut impl Write,
|
|
|
|
|
- input: &InputBuffer,
|
|
|
|
|
- previous_line_count: usize,
|
|
|
|
|
- ) -> io::Result<usize> {
|
|
|
|
|
- let rendered = render_buffer(&self.prompt, &self.continuation_prompt, input);
|
|
|
|
|
- if previous_line_count > 1 {
|
|
|
|
|
- queue!(out, MoveUp(saturating_u16(previous_line_count - 1)))?;
|
|
|
|
|
- }
|
|
|
|
|
- queue!(out, MoveToColumn(0), Clear(ClearType::FromCursorDown),)?;
|
|
|
|
|
- rendered.write(out)?;
|
|
|
|
|
- queue!(
|
|
|
|
|
- out,
|
|
|
|
|
- MoveUp(saturating_u16(rendered.line_count().saturating_sub(1))),
|
|
|
|
|
- MoveToColumn(0),
|
|
|
|
|
- )?;
|
|
|
|
|
- if rendered.cursor_row > 0 {
|
|
|
|
|
- queue!(out, MoveDown(rendered.cursor_row))?;
|
|
|
|
|
- }
|
|
|
|
|
- queue!(out, MoveToColumn(rendered.cursor_col))?;
|
|
|
|
|
- out.flush()?;
|
|
|
|
|
- Ok(rendered.line_count())
|
|
|
|
|
- }
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
-enum EditorAction {
|
|
|
|
|
- Continue,
|
|
|
|
|
- Submit,
|
|
|
|
|
- Cancel,
|
|
|
|
|
- Exit,
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-#[must_use]
|
|
|
|
|
-pub fn render_buffer(
|
|
|
|
|
- prompt: &str,
|
|
|
|
|
- continuation_prompt: &str,
|
|
|
|
|
- input: &InputBuffer,
|
|
|
|
|
-) -> RenderedBuffer {
|
|
|
|
|
- let before_cursor = &input.as_str()[..input.cursor];
|
|
|
|
|
- let cursor_row = saturating_u16(before_cursor.chars().filter(|ch| *ch == '\n').count());
|
|
|
|
|
- let cursor_line = before_cursor.rsplit('\n').next().unwrap_or_default();
|
|
|
|
|
- let cursor_prompt = if cursor_row == 0 {
|
|
|
|
|
- prompt
|
|
|
|
|
- } else {
|
|
|
|
|
- continuation_prompt
|
|
|
|
|
- };
|
|
|
|
|
- let cursor_col = saturating_u16(cursor_prompt.chars().count() + cursor_line.chars().count());
|
|
|
|
|
-
|
|
|
|
|
- let mut lines = Vec::new();
|
|
|
|
|
- for (index, line) in input.as_str().split('\n').enumerate() {
|
|
|
|
|
- let prefix = if index == 0 {
|
|
|
|
|
- prompt
|
|
|
|
|
- } else {
|
|
|
|
|
- continuation_prompt
|
|
|
|
|
- };
|
|
|
|
|
- lines.push(format!("{prefix}{line}"));
|
|
|
|
|
- }
|
|
|
|
|
- if lines.is_empty() {
|
|
|
|
|
- lines.push(prompt.to_string());
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- RenderedBuffer {
|
|
|
|
|
- lines,
|
|
|
|
|
- cursor_row,
|
|
|
|
|
- cursor_col,
|
|
|
|
|
|
|
+fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
|
|
|
|
|
+ if pos != line.len() {
|
|
|
|
|
+ return None;
|
|
|
}
|
|
}
|
|
|
-}
|
|
|
|
|
|
|
|
|
|
-#[must_use]
|
|
|
|
|
-fn longest_common_prefix(values: &[&str]) -> String {
|
|
|
|
|
- let Some(first) = values.first() else {
|
|
|
|
|
- return String::new();
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- let mut prefix = (*first).to_string();
|
|
|
|
|
- for value in values.iter().skip(1) {
|
|
|
|
|
- while !value.starts_with(&prefix) {
|
|
|
|
|
- prefix.pop();
|
|
|
|
|
- if prefix.is_empty() {
|
|
|
|
|
- break;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ let prefix = &line[..pos];
|
|
|
|
|
+ if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
|
|
|
|
|
+ return None;
|
|
|
}
|
|
}
|
|
|
- prefix
|
|
|
|
|
-}
|
|
|
|
|
|
|
|
|
|
-#[must_use]
|
|
|
|
|
-fn saturating_u16(value: usize) -> u16 {
|
|
|
|
|
- u16::try_from(value).unwrap_or(u16::MAX)
|
|
|
|
|
|
|
+ Some(prefix)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
#[cfg(test)]
|
|
|
mod tests {
|
|
mod tests {
|
|
|
- use super::{render_buffer, InputBuffer, LineEditor};
|
|
|
|
|
- use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
|
|
|
-
|
|
|
|
|
- fn key(code: KeyCode) -> KeyEvent {
|
|
|
|
|
- KeyEvent::new(code, KeyModifiers::NONE)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ use super::{slash_command_prefix, LineEditor, SlashCommandHelper};
|
|
|
|
|
+ use rustyline::completion::Completer;
|
|
|
|
|
+ use rustyline::highlight::Highlighter;
|
|
|
|
|
+ use rustyline::history::{DefaultHistory, History};
|
|
|
|
|
+ use rustyline::Context;
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn supports_basic_line_editing() {
|
|
|
|
|
- let mut input = InputBuffer::new();
|
|
|
|
|
- input.insert('h');
|
|
|
|
|
- input.insert('i');
|
|
|
|
|
- input.move_end();
|
|
|
|
|
- input.insert_newline();
|
|
|
|
|
- input.insert('x');
|
|
|
|
|
-
|
|
|
|
|
- assert_eq!(input.as_str(), "hi\nx");
|
|
|
|
|
- assert_eq!(input.cursor(), 4);
|
|
|
|
|
-
|
|
|
|
|
- input.move_left();
|
|
|
|
|
- input.backspace();
|
|
|
|
|
- assert_eq!(input.as_str(), "hix");
|
|
|
|
|
- assert_eq!(input.cursor(), 2);
|
|
|
|
|
|
|
+ fn extracts_only_terminal_slash_command_prefixes() {
|
|
|
|
|
+ assert_eq!(slash_command_prefix("/he", 3), Some("/he"));
|
|
|
|
|
+ assert_eq!(slash_command_prefix("/help me", 5), None);
|
|
|
|
|
+ assert_eq!(slash_command_prefix("hello", 5), None);
|
|
|
|
|
+ assert_eq!(slash_command_prefix("/help", 2), None);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn completes_unique_slash_command() {
|
|
|
|
|
- let mut input = InputBuffer::new();
|
|
|
|
|
- for ch in "/he".chars() {
|
|
|
|
|
- input.insert(ch);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- assert!(input.complete_slash_command(&[
|
|
|
|
|
|
|
+ fn completes_matching_slash_commands() {
|
|
|
|
|
+ let helper = SlashCommandHelper::new(vec![
|
|
|
"/help".to_string(),
|
|
"/help".to_string(),
|
|
|
"/hello".to_string(),
|
|
"/hello".to_string(),
|
|
|
"/status".to_string(),
|
|
"/status".to_string(),
|
|
|
- ]));
|
|
|
|
|
- assert_eq!(input.as_str(), "/hel");
|
|
|
|
|
-
|
|
|
|
|
- assert!(input.complete_slash_command(&["/help".to_string(), "/status".to_string()]));
|
|
|
|
|
- assert_eq!(input.as_str(), "/help");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- #[test]
|
|
|
|
|
- fn ignores_completion_when_prefix_is_not_a_slash_command() {
|
|
|
|
|
- let mut input = InputBuffer::new();
|
|
|
|
|
- for ch in "hello".chars() {
|
|
|
|
|
- input.insert(ch);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- assert!(!input.complete_slash_command(&["/help".to_string()]));
|
|
|
|
|
- assert_eq!(input.as_str(), "hello");
|
|
|
|
|
|
|
+ ]);
|
|
|
|
|
+ let history = DefaultHistory::new();
|
|
|
|
|
+ let ctx = Context::new(&history);
|
|
|
|
|
+ let (start, matches) = helper
|
|
|
|
|
+ .complete("/he", 3, &ctx)
|
|
|
|
|
+ .expect("completion should work");
|
|
|
|
|
+
|
|
|
|
|
+ assert_eq!(start, 0);
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ matches
|
|
|
|
|
+ .into_iter()
|
|
|
|
|
+ .map(|candidate| candidate.replacement)
|
|
|
|
|
+ .collect::<Vec<_>>(),
|
|
|
|
|
+ vec!["/help".to_string(), "/hello".to_string()]
|
|
|
|
|
+ );
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn history_navigation_restores_current_draft() {
|
|
|
|
|
- let mut editor = LineEditor::new("› ", vec![]);
|
|
|
|
|
- editor.push_history("/help");
|
|
|
|
|
- editor.push_history("status report");
|
|
|
|
|
-
|
|
|
|
|
- let mut input = InputBuffer::new();
|
|
|
|
|
- for ch in "draft".chars() {
|
|
|
|
|
- input.insert(ch);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let _ = editor.handle_key(key(KeyCode::Up), &mut input);
|
|
|
|
|
- assert_eq!(input.as_str(), "status report");
|
|
|
|
|
-
|
|
|
|
|
- let _ = editor.handle_key(key(KeyCode::Up), &mut input);
|
|
|
|
|
- assert_eq!(input.as_str(), "/help");
|
|
|
|
|
|
|
+ fn ignores_non_slash_command_completion_requests() {
|
|
|
|
|
+ let helper = SlashCommandHelper::new(vec!["/help".to_string()]);
|
|
|
|
|
+ let history = DefaultHistory::new();
|
|
|
|
|
+ let ctx = Context::new(&history);
|
|
|
|
|
+ let (_, matches) = helper
|
|
|
|
|
+ .complete("hello", 5, &ctx)
|
|
|
|
|
+ .expect("completion should work");
|
|
|
|
|
|
|
|
- let _ = editor.handle_key(key(KeyCode::Down), &mut input);
|
|
|
|
|
- assert_eq!(input.as_str(), "status report");
|
|
|
|
|
-
|
|
|
|
|
- let _ = editor.handle_key(key(KeyCode::Down), &mut input);
|
|
|
|
|
- assert_eq!(input.as_str(), "draft");
|
|
|
|
|
|
|
+ assert!(matches.is_empty());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn tab_key_completes_from_editor_candidates() {
|
|
|
|
|
- let mut editor = LineEditor::new(
|
|
|
|
|
- "› ",
|
|
|
|
|
- vec![
|
|
|
|
|
- "/help".to_string(),
|
|
|
|
|
- "/status".to_string(),
|
|
|
|
|
- "/session".to_string(),
|
|
|
|
|
- ],
|
|
|
|
|
- );
|
|
|
|
|
- let mut input = InputBuffer::new();
|
|
|
|
|
- for ch in "/st".chars() {
|
|
|
|
|
- input.insert(ch);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ fn tracks_current_buffer_through_highlighter() {
|
|
|
|
|
+ let helper = SlashCommandHelper::new(Vec::new());
|
|
|
|
|
+ let _ = helper.highlight("draft", 5);
|
|
|
|
|
|
|
|
- let _ = editor.handle_key(key(KeyCode::Tab), &mut input);
|
|
|
|
|
- assert_eq!(input.as_str(), "/status");
|
|
|
|
|
|
|
+ assert_eq!(helper.current_line(), "draft");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn renders_multiline_buffers_with_continuation_prompt() {
|
|
|
|
|
- let mut input = InputBuffer::new();
|
|
|
|
|
- for ch in "hello\nworld".chars() {
|
|
|
|
|
- if ch == '\n' {
|
|
|
|
|
- input.insert_newline();
|
|
|
|
|
- } else {
|
|
|
|
|
- input.insert(ch);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- let rendered = render_buffer("› ", "> ", &input);
|
|
|
|
|
- assert_eq!(
|
|
|
|
|
- rendered.lines(),
|
|
|
|
|
- &["› hello".to_string(), "> world".to_string()]
|
|
|
|
|
- );
|
|
|
|
|
- assert_eq!(rendered.cursor_position(), (1, 7));
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ fn push_history_ignores_blank_entries() {
|
|
|
|
|
+ let mut editor = LineEditor::new("> ", vec!["/help".to_string()]);
|
|
|
|
|
+ editor.push_history(" ");
|
|
|
|
|
+ editor.push_history("/help");
|
|
|
|
|
|
|
|
- #[test]
|
|
|
|
|
- fn ctrl_c_exits_only_when_buffer_is_empty() {
|
|
|
|
|
- let mut editor = LineEditor::new("› ", vec![]);
|
|
|
|
|
- let mut empty = InputBuffer::new();
|
|
|
|
|
- assert!(matches!(
|
|
|
|
|
- editor.handle_key(
|
|
|
|
|
- KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
|
|
|
|
- &mut empty,
|
|
|
|
|
- ),
|
|
|
|
|
- super::EditorAction::Exit
|
|
|
|
|
- ));
|
|
|
|
|
-
|
|
|
|
|
- let mut filled = InputBuffer::new();
|
|
|
|
|
- filled.insert('x');
|
|
|
|
|
- assert!(matches!(
|
|
|
|
|
- editor.handle_key(
|
|
|
|
|
- KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
|
|
|
|
- &mut filled,
|
|
|
|
|
- ),
|
|
|
|
|
- super::EditorAction::Cancel
|
|
|
|
|
- ));
|
|
|
|
|
- assert!(filled.as_str().is_empty());
|
|
|
|
|
|
|
+ assert_eq!(editor.editor.history().len(), 1);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|