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
This commit is contained in:
parent
3eb50a8383
commit
2e79e73af3
5 changed files with 226 additions and 0 deletions
|
@ -38,6 +38,15 @@
|
||||||
## possible values: return-original, return-query
|
## possible values: return-original, return-query
|
||||||
# exit_mode = "return-original"
|
# 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.
|
## 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
|
## 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.
|
## with ^ or end with $, they'll match anywhere in the command.
|
||||||
|
|
|
@ -97,6 +97,15 @@ pub enum Style {
|
||||||
Compact,
|
Compact,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Copy)]
|
||||||
|
pub enum WordJumpMode {
|
||||||
|
#[serde(rename = "emacs")]
|
||||||
|
Emacs,
|
||||||
|
|
||||||
|
#[serde(rename = "subl")]
|
||||||
|
Subl,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub dialect: Dialect,
|
pub dialect: Dialect,
|
||||||
|
@ -114,6 +123,9 @@ pub struct Settings {
|
||||||
pub shell_up_key_binding: bool,
|
pub shell_up_key_binding: bool,
|
||||||
pub show_preview: bool,
|
pub show_preview: bool,
|
||||||
pub exit_mode: ExitMode,
|
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")]
|
#[serde(with = "serde_regex", default = "RegexSet::empty")]
|
||||||
pub history_filter: RegexSet,
|
pub history_filter: RegexSet,
|
||||||
|
|
||||||
|
@ -300,6 +312,12 @@ impl Settings {
|
||||||
.set_default("exit_mode", "return-original")?
|
.set_default("exit_mode", "return-original")?
|
||||||
.set_default("session_token", "")?
|
.set_default("session_token", "")?
|
||||||
.set_default("style", "auto")?
|
.set_default("style", "auto")?
|
||||||
|
.set_default("word_jump_mode", "emacs")?
|
||||||
|
.set_default(
|
||||||
|
"word_chars",
|
||||||
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
||||||
|
)?
|
||||||
|
.set_default("scroll_context_lines", 1)?
|
||||||
.add_source(
|
.add_source(
|
||||||
Environment::with_prefix("atuin")
|
Environment::with_prefix("atuin")
|
||||||
.prefix_separator("_")
|
.prefix_separator("_")
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
use atuin_client::settings::WordJumpMode;
|
||||||
|
|
||||||
pub struct Cursor {
|
pub struct Cursor {
|
||||||
source: String,
|
source: String,
|
||||||
index: usize,
|
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 {
|
impl Cursor {
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
self.source.as_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) {
|
pub fn insert(&mut self, c: char) {
|
||||||
self.source.insert(self.index, c);
|
self.source.insert(self.index, c);
|
||||||
self.index += c.len_utf8();
|
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> {
|
pub fn back(&mut self) -> Option<char> {
|
||||||
if self.left() {
|
if self.left() {
|
||||||
self.remove()
|
self.remove()
|
||||||
|
@ -90,6 +208,17 @@ impl Cursor {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod cursor_tests {
|
mod cursor_tests {
|
||||||
use super::Cursor;
|
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]
|
#[test]
|
||||||
fn right() {
|
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]
|
#[test]
|
||||||
fn pop() {
|
fn pop() {
|
||||||
let mut s = String::from("öaöböcödöeöfö");
|
let mut s = String::from("öaöböcödöeöfö");
|
||||||
|
|
|
@ -19,6 +19,7 @@ pub struct HistoryList<'a> {
|
||||||
pub struct ListState {
|
pub struct ListState {
|
||||||
offset: usize,
|
offset: usize,
|
||||||
selected: usize,
|
selected: usize,
|
||||||
|
max_entries: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListState {
|
impl ListState {
|
||||||
|
@ -26,6 +27,10 @@ impl ListState {
|
||||||
self.selected
|
self.selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn max_entries(&self) -> usize {
|
||||||
|
self.max_entries
|
||||||
|
}
|
||||||
|
|
||||||
pub fn select(&mut self, index: usize) {
|
pub fn select(&mut self, index: usize) {
|
||||||
self.selected = index;
|
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);
|
let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height);
|
||||||
state.offset = start;
|
state.offset = start;
|
||||||
|
state.max_entries = end - start;
|
||||||
|
|
||||||
let mut s = DrawState {
|
let mut s = DrawState {
|
||||||
buf,
|
buf,
|
||||||
|
|
|
@ -111,19 +111,33 @@ impl State {
|
||||||
let c = c.to_digit(10)? as usize;
|
let c = c.to_digit(10)? as usize;
|
||||||
return Some(self.results_state.selected() + c);
|
return Some(self.results_state.selected() + c);
|
||||||
}
|
}
|
||||||
|
KeyCode::Left if ctrl => self
|
||||||
|
.input
|
||||||
|
.prev_word(&settings.word_chars, settings.word_jump_mode),
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
self.input.left();
|
self.input.left();
|
||||||
}
|
}
|
||||||
KeyCode::Char('h') if ctrl => {
|
KeyCode::Char('h') if ctrl => {
|
||||||
self.input.left();
|
self.input.left();
|
||||||
}
|
}
|
||||||
|
KeyCode::Right if ctrl => self
|
||||||
|
.input
|
||||||
|
.next_word(&settings.word_chars, settings.word_jump_mode),
|
||||||
KeyCode::Right => self.input.right(),
|
KeyCode::Right => self.input.right(),
|
||||||
KeyCode::Char('l') if ctrl => self.input.right(),
|
KeyCode::Char('l') if ctrl => self.input.right(),
|
||||||
KeyCode::Char('a') if ctrl => self.input.start(),
|
KeyCode::Char('a') if ctrl => self.input.start(),
|
||||||
|
KeyCode::Home => self.input.start(),
|
||||||
KeyCode::Char('e') if ctrl => self.input.end(),
|
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 => {
|
KeyCode::Backspace => {
|
||||||
self.input.back();
|
self.input.back();
|
||||||
}
|
}
|
||||||
|
KeyCode::Delete if ctrl => self
|
||||||
|
.input
|
||||||
|
.remove_next_word(&settings.word_chars, settings.word_jump_mode),
|
||||||
KeyCode::Delete => {
|
KeyCode::Delete => {
|
||||||
self.input.remove();
|
self.input.remove();
|
||||||
}
|
}
|
||||||
|
@ -168,6 +182,16 @@ impl State {
|
||||||
self.results_state.select(i.min(len - 1));
|
self.results_state.select(i.min(len - 1));
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => self.input.insert(c),
|
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));
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue