diff --git a/atuin-client/config.toml b/atuin-client/config.toml index 0c9b4ed..a3c255b 100644 --- a/atuin-client/config.toml +++ b/atuin-client/config.toml @@ -38,6 +38,15 @@ ## possible values: return-original, return-query # exit_mode = "return-original" +## possible values: emacs, subl +# word_jump_mode = "emacs" + +## characters that count as a part of a word +# word_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +## number of context lines to show when scrolling by pages +# scroll_context_lines = 1 + ## prevent commands matching any of these regexes from being written to history. ## Note that these regular expressions are unanchored, i.e. if they don't start ## with ^ or end with $, they'll match anywhere in the command. diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs index 6b642b7..bd47d5a 100644 --- a/atuin-client/src/settings.rs +++ b/atuin-client/src/settings.rs @@ -97,6 +97,15 @@ pub enum Style { Compact, } +#[derive(Clone, Debug, Deserialize, Copy)] +pub enum WordJumpMode { + #[serde(rename = "emacs")] + Emacs, + + #[serde(rename = "subl")] + Subl, +} + #[derive(Clone, Debug, Deserialize)] pub struct Settings { pub dialect: Dialect, @@ -114,6 +123,9 @@ pub struct Settings { pub shell_up_key_binding: bool, pub show_preview: bool, pub exit_mode: ExitMode, + pub word_jump_mode: WordJumpMode, + pub word_chars: String, + pub scroll_context_lines: usize, #[serde(with = "serde_regex", default = "RegexSet::empty")] pub history_filter: RegexSet, @@ -300,6 +312,12 @@ impl Settings { .set_default("exit_mode", "return-original")? .set_default("session_token", "")? .set_default("style", "auto")? + .set_default("word_jump_mode", "emacs")? + .set_default( + "word_chars", + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + )? + .set_default("scroll_context_lines", 1)? .add_source( Environment::with_prefix("atuin") .prefix_separator("_") diff --git a/src/command/client/search/cursor.rs b/src/command/client/search/cursor.rs index 827242c..2bce4f3 100644 --- a/src/command/client/search/cursor.rs +++ b/src/command/client/search/cursor.rs @@ -1,3 +1,5 @@ +use atuin_client::settings::WordJumpMode; + pub struct Cursor { source: String, index: usize, @@ -9,6 +11,87 @@ impl From for Cursor { } } +pub struct WordJumper<'a> { + word_chars: &'a str, + word_jump_mode: WordJumpMode, +} + +impl WordJumper<'_> { + fn is_word_boundary(&self, c: char, next_c: char) -> bool { + (c.is_whitespace() && !next_c.is_whitespace()) + || (!c.is_whitespace() && next_c.is_whitespace()) + || (self.word_chars.contains(c) && !self.word_chars.contains(next_c)) + || (!self.word_chars.contains(c) && self.word_chars.contains(next_c)) + } + + fn emacs_get_next_word_pos(&self, source: &str, index: usize) -> usize { + let index = (index + 1..source.len().saturating_sub(1)) + .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) + .unwrap_or(source.len()); + (index + 1..source.len().saturating_sub(1)) + .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) + .unwrap_or(source.len()) + } + + fn emacs_get_prev_word_pos(&self, source: &str, index: usize) -> usize { + let index = (1..index) + .rev() + .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) + .unwrap_or(0); + (1..index) + .rev() + .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) + .map_or(0, |i| i + 1) + } + + fn subl_get_next_word_pos(&self, source: &str, index: usize) -> usize { + let index = (index..source.len().saturating_sub(1)).find(|&i| { + self.is_word_boundary( + source.chars().nth(i).unwrap(), + source.chars().nth(i + 1).unwrap(), + ) + }); + if index.is_none() { + return source.len(); + } + (index.unwrap() + 1..source.len()) + .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()) + .unwrap_or(source.len()) + } + + fn subl_get_prev_word_pos(&self, source: &str, index: usize) -> usize { + let index = (1..index) + .rev() + .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()); + if index.is_none() { + return 0; + } + (1..index.unwrap()) + .rev() + .find(|&i| { + self.is_word_boundary( + source.chars().nth(i - 1).unwrap(), + source.chars().nth(i).unwrap(), + ) + }) + .unwrap_or(0) + } + + fn get_next_word_pos(&self, source: &str, index: usize) -> usize { + match self.word_jump_mode { + WordJumpMode::Emacs => self.emacs_get_next_word_pos(source, index), + WordJumpMode::Subl => self.subl_get_next_word_pos(source, index), + } + } + + fn get_prev_word_pos(&self, source: &str, index: usize) -> usize { + match self.word_jump_mode { + WordJumpMode::Emacs => self.emacs_get_prev_word_pos(source, index), + WordJumpMode::Subl => self.subl_get_prev_word_pos(source, index), + } + } +} + impl Cursor { pub fn as_str(&self) -> &str { self.source.as_str() @@ -52,6 +135,22 @@ impl Cursor { } } + pub fn next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { + let word_jumper = WordJumper { + word_chars, + word_jump_mode, + }; + self.index = word_jumper.get_next_word_pos(&self.source, self.index); + } + + pub fn prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { + let word_jumper = WordJumper { + word_chars, + word_jump_mode, + }; + self.index = word_jumper.get_prev_word_pos(&self.source, self.index); + } + pub fn insert(&mut self, c: char) { self.source.insert(self.index, c); self.index += c.len_utf8(); @@ -65,6 +164,25 @@ impl Cursor { } } + pub fn remove_next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { + let word_jumper = WordJumper { + word_chars, + word_jump_mode, + }; + let next_index = word_jumper.get_next_word_pos(&self.source, self.index); + self.source.replace_range(self.index..next_index, ""); + } + + pub fn remove_prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { + let word_jumper = WordJumper { + word_chars, + word_jump_mode, + }; + let next_index = word_jumper.get_prev_word_pos(&self.source, self.index); + self.source.replace_range(next_index..self.index, ""); + self.index = next_index; + } + pub fn back(&mut self) -> Option { if self.left() { self.remove() @@ -90,6 +208,17 @@ impl Cursor { #[cfg(test)] mod cursor_tests { use super::Cursor; + use super::*; + + static EMACS_WORD_JUMPER: WordJumper = WordJumper { + word_chars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + word_jump_mode: WordJumpMode::Emacs, + }; + + static SUBL_WORD_JUMPER: WordJumper = WordJumper { + word_chars: "./\\()\"'-:,.;<>~!@#$%^&*|+=[]{}`~?", + word_jump_mode: WordJumpMode::Subl, + }; #[test] fn right() { @@ -114,6 +243,46 @@ mod cursor_tests { } } + #[test] + fn test_emacs_get_next_word_pos() { + let s = String::from(" aaa ((()))bbb ((())) "); + let indices = [(0, 6), (3, 6), (7, 18), (19, 30)]; + for (i_src, i_dest) in indices { + assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); + } + assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos("", 0), 0); + } + + #[test] + fn test_emacs_get_prev_word_pos() { + let s = String::from(" aaa ((()))bbb ((())) "); + let indices = [(30, 15), (29, 15), (15, 3), (3, 0)]; + for (i_src, i_dest) in indices { + assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); + } + assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos("", 0), 0); + } + + #[test] + fn test_subl_get_next_word_pos() { + let s = String::from(" aaa ((()))bbb ((())) "); + let indices = [(0, 3), (1, 3), (3, 9), (9, 15), (15, 21), (21, 30)]; + for (i_src, i_dest) in indices { + assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); + } + assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos("", 0), 0); + } + + #[test] + fn test_subl_get_prev_word_pos() { + let s = String::from(" aaa ((()))bbb ((())) "); + let indices = [(30, 21), (21, 15), (15, 9), (9, 3), (3, 0)]; + for (i_src, i_dest) in indices { + assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); + } + assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos("", 0), 0); + } + #[test] fn pop() { let mut s = String::from("öaöböcödöeöfö"); diff --git a/src/command/client/search/history_list.rs b/src/command/client/search/history_list.rs index f4725b0..9e266fe 100644 --- a/src/command/client/search/history_list.rs +++ b/src/command/client/search/history_list.rs @@ -19,6 +19,7 @@ pub struct HistoryList<'a> { pub struct ListState { offset: usize, selected: usize, + max_entries: usize, } impl ListState { @@ -26,6 +27,10 @@ impl ListState { self.selected } + pub fn max_entries(&self) -> usize { + self.max_entries + } + pub fn select(&mut self, index: usize) { self.selected = index; } @@ -48,6 +53,7 @@ impl<'a> StatefulWidget for HistoryList<'a> { let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height); state.offset = start; + state.max_entries = end - start; let mut s = DrawState { buf, diff --git a/src/command/client/search/interactive.rs b/src/command/client/search/interactive.rs index 01175b5..c5c983a 100644 --- a/src/command/client/search/interactive.rs +++ b/src/command/client/search/interactive.rs @@ -111,19 +111,33 @@ impl State { let c = c.to_digit(10)? as usize; return Some(self.results_state.selected() + c); } + KeyCode::Left if ctrl => self + .input + .prev_word(&settings.word_chars, settings.word_jump_mode), KeyCode::Left => { self.input.left(); } KeyCode::Char('h') if ctrl => { self.input.left(); } + KeyCode::Right if ctrl => self + .input + .next_word(&settings.word_chars, settings.word_jump_mode), KeyCode::Right => self.input.right(), KeyCode::Char('l') if ctrl => self.input.right(), KeyCode::Char('a') if ctrl => self.input.start(), + KeyCode::Home => self.input.start(), KeyCode::Char('e') if ctrl => self.input.end(), + KeyCode::End => self.input.end(), + KeyCode::Backspace if ctrl => self + .input + .remove_prev_word(&settings.word_chars, settings.word_jump_mode), KeyCode::Backspace => { self.input.back(); } + KeyCode::Delete if ctrl => self + .input + .remove_next_word(&settings.word_chars, settings.word_jump_mode), KeyCode::Delete => { self.input.remove(); } @@ -168,6 +182,16 @@ impl State { self.results_state.select(i.min(len - 1)); } KeyCode::Char(c) => self.input.insert(c), + KeyCode::PageDown => { + let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; + let i = self.results_state.selected().saturating_sub(scroll_len); + self.results_state.select(i); + } + KeyCode::PageUp => { + let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; + let i = self.results_state.selected() + scroll_len; + self.results_state.select(i.min(len - 1)); + } _ => {} };