use crate::atuin_client::settings::WordJumpMode; pub(crate) struct Cursor { source: String, index: usize, } impl From for Cursor { fn from(source: String) -> Self { Self { source, index: 0 } } } pub(crate) 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(crate) fn as_str(&self) -> &str { self.source.as_str() } pub(crate) fn into_inner(self) -> String { self.source } /// Returns the string before the cursor pub(crate) fn substring(&self) -> &str { &self.source[..self.index] } /// Returns the currently selected [`char`] pub(crate) fn char(&self) -> Option { self.source[self.index..].chars().next() } pub(crate) fn right(&mut self) { if self.index < self.source.len() { loop { self.index += 1; if self.source.is_char_boundary(self.index) { break; } } } } pub(crate) fn left(&mut self) -> bool { if self.index > 0 { loop { self.index -= 1; if self.source.is_char_boundary(self.index) { break true; } } } else { false } } pub(crate) 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(crate) 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); } /// Move cursor to the end of the current/next word (vim `e` motion). /// /// If cursor is in the middle of a word, moves to the end of that word. /// If cursor is at the end of a word (or on whitespace), moves to the /// end of the next word. pub(crate) fn word_end(&mut self, word_chars: &str) { let len = self.source.len(); if self.index >= len { return; } let chars: Vec = self.source.chars().collect(); let mut char_idx = self.source[..self.index].chars().count(); if char_idx >= chars.len() { return; } let current = chars[char_idx]; // Check if we're at a word boundary (end of current word or on whitespace) let at_word_boundary = current.is_whitespace() || char_idx + 1 >= chars.len() || { let next = chars[char_idx + 1]; next.is_whitespace() || (word_chars.contains(current) != word_chars.contains(next)) }; // If at word boundary, advance past it and skip whitespace to find next word if at_word_boundary { char_idx += 1; while char_idx < chars.len() && chars[char_idx].is_whitespace() { char_idx += 1; } } // If we've gone past end, go to end of string if char_idx >= chars.len() { self.index = len; return; } // Find end of word: advance until next char is whitespace or different word type let in_word_chars = word_chars.contains(chars[char_idx]); while char_idx < chars.len() { let next_idx = char_idx + 1; if next_idx >= chars.len() { // At last char, move past it char_idx = next_idx; break; } let next_c = chars[next_idx]; if next_c.is_whitespace() || (word_chars.contains(next_c) != in_word_chars) { // Next char is start of new word/whitespace, so current char is end char_idx = next_idx; break; } char_idx += 1; } // Convert char index back to byte index self.index = chars.iter().take(char_idx).map(|c| c.len_utf8()).sum(); } pub(crate) fn insert(&mut self, c: char) { self.source.insert(self.index, c); self.index += c.len_utf8(); } pub(crate) fn remove(&mut self) -> Option { if self.index < self.source.len() { Some(self.source.remove(self.index)) } else { None } } pub(crate) 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(crate) 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(crate) fn back(&mut self) -> Option { if self.left() { self.remove() } else { None } } pub(crate) fn clear(&mut self) { self.source.clear(); self.index = 0; } pub(crate) fn clear_to_start(&mut self) { self.source.replace_range(..self.index, ""); self.index = 0; } pub(crate) fn clear_to_end(&mut self) { self.source.replace_range(self.index.., ""); self.index = self.source.len(); } pub(crate) fn end(&mut self) { self.index = self.source.len(); } pub(crate) fn start(&mut self) { self.index = 0; } pub(crate) fn position(&self) -> usize { self.index } } #[cfg(test)] mod cursor_tests { use super::{Cursor, WordJumpMode, WordJumper}; 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() { // ö is 2 bytes let mut c = Cursor::from(String::from("öaöböcödöeöfö")); let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20]; for i in indices { assert_eq!(c.index, i); c.right(); } } #[test] fn left() { // ö is 2 bytes let mut c = Cursor::from(String::from("öaöböcödöeöfö")); c.end(); let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0]; for i in indices { assert_eq!(c.index, i); c.left(); } } #[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()); c.end(); while !s.is_empty() { let c1 = s.pop(); let c2 = c.back(); assert_eq!(c1, c2); assert_eq!(s.as_str(), c.substring()); } let c1 = s.pop(); let c2 = c.back(); assert_eq!(c1, c2); } #[test] fn back() { let mut c = Cursor::from(String::from("öaöböcödöeöfö")); // move to ^ for _ in 0..4 { c.right(); } assert_eq!(c.substring(), "öaöb"); assert_eq!(c.back(), Some('b')); assert_eq!(c.back(), Some('ö')); assert_eq!(c.back(), Some('a')); assert_eq!(c.back(), Some('ö')); assert_eq!(c.back(), None); assert_eq!(c.as_str(), "öcödöeöfö"); } #[test] fn insert() { let mut c = Cursor::from(String::from("öaöböcödöeöfö")); // move to ^ for _ in 0..4 { c.right(); } assert_eq!(c.substring(), "öaöb"); c.insert('ö'); c.insert('g'); c.insert('ö'); c.insert('h'); assert_eq!(c.substring(), "öaöbögöh"); assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö"); } }