summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSteven Xu <stevenxxiu@users.noreply.github.com>2023-03-06 05:49:09 +1100
committerGitHub <noreply@github.com>2023-03-05 18:49:09 +0000
commit2e79e73af32793f7fe103b81246a5ef5a6e1cfbe (patch)
tree1916278c4aa294f5fca3b7bcf29802eecf81e1fa
parent3eb50a8383e65379eddf0edd8dc73431ee8080ff (diff)
feat: add common default keybindings (#719)
* feat: add common default keybindings * feat: add `WORD_SEPARATORS` to config as `word_chars`, as this is what *Zsh* calls it * feat: add option for *Emacs* word jumping * feat: scroll with `PageUp` and `PageDown`, cf #374
-rw-r--r--atuin-client/config.toml9
-rw-r--r--atuin-client/src/settings.rs18
-rw-r--r--src/command/client/search/cursor.rs169
-rw-r--r--src/command/client/search/history_list.rs6
-rw-r--r--src/command/client/search/interactive.rs24
5 files changed, 226 insertions, 0 deletions
diff --git a/atuin-client/config.toml b/atuin-client/config.toml
index 0c9b4ede..a3c255b6 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 6b642b78..bd47d5aa 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 827242c8..2bce4f37 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<String> 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<char> {
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() {
@@ -115,6 +244,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ö");
let mut c = Cursor::from(s.clone());
diff --git a/src/command/client/search/history_list.rs b/src/command/client/search/history_list.rs
index f4725b02..9e266fe9 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 01175b52..c5c983ac 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));
+ }
_ => {}
};