diff --git a/TODO.md b/TODO.md index deb16bd..604e5ca 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,6 @@ -- [ ] navigation control/actions: seek occurrance of character ('f' forwards, 'F' backwards by default; allow e.g. `2f` for seeking second occurrance instead of first). +- [x] navigation control/actions: seek occurrence of character ('f' forwards, 'F' backwards by default; allow e.g. `2f` for seeking second occurrence instead of first). +- [ ] buffer ux improvement: enable mouse scrolling sideways so longer lines are easier to follow and read (e.g. in case they extend beyond the width of the buffer view). +- [ ] buffer ux improvement: add toggleable word wrap (no keybinding by default). - [ ] diff view: add character-wise change highlight (enabled by default). - [ ] diff view: change diff view modes; choose between split and unified diff view (split by default). - [ ] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default. diff --git a/docs/configuration.md b/docs/configuration.md index 6eab2eb..bed4ed9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,6 +68,8 @@ The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visua Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `config.core.delete_line` or `config.core.change_line` to a single key or leader sequence for another route. A configured/disabled `d` or `c`, or choosing that key as the leader, overrides its built-in doubled sequence. These are dedicated line actions, not general operator–motion grammar. +`f` / `F` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands. + ## Action reference | Available modes | Methods on `config.core` | @@ -90,6 +92,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con | Normal, Visual | `repeat_last_edit` | | Normal, Visual | `file_picker`, `file_explorer` | | Normal, Visual | `document_start`, `document_end` | +| Normal, Visual | `seek_forward`, `seek_backward` | | Normal, Insert, Visual | `line_start`, `line_end` | | Normal, Insert, Visual, Tree | `left`, `right` | | Normal, Insert, Visual, Selection, Tree | `down`, `up` | diff --git a/docs/editing.md b/docs/editing.md index 6d43b9b..5078c91 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -16,6 +16,7 @@ Default controls and editing behavior. For custom bindings, see [configuration]( | Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first | | Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End | | Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs | +| Normal, Visual | `f` seeks forward on the current line; `F` seeks backward; `2fx` finds the second `x` after each caret | | Normal | `gf` follows a Markdown file link or heading fragment at the primary caret | | Normal, Visual | `gt` goes to the first line; `gb` goes to the last line, at first nonblank; Visual extends every selection | | Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations | @@ -61,9 +62,17 @@ Word motions separate runs of Unicode letters/numbers/underscore from runs of pu These keys type literal text in Insert mode. The corresponding actions can also be bound in Insert mode to move all insertion carets without splitting their undo group. Motions never edit text, add history, or clear an active search highlight. +## Character seeks + +`f` / `F` moves every caret to the next / previous matching grapheme on its current line, excluding the current position. A count seeks that occurrence independently for each caret: `2fx` finds the second following `x`, and `2Fx` the second preceding `x`. With too few matches, that caret stays unchanged. Seeks never wrap or cross lines, and Unicode targets land on whole graphemes, including combining sequences and ZWJ emoji. + +Normal and both Visual shapes support these motions, including read-only diff panes. Visual keeps its anchors; native edit replay retains the seek's direction, target, and count rather than its original distance. The following character is literal even if it is a digit, leader key, remapped key, or diff expansion bracket. Escape cancels only the pending seek without moving or collapsing selections. `f`/`F` remain literal text in Insert and command/search prompts; `gf` retains its Markdown-link behavior. + +Bind `config.core.seek_forward` / `config.core.seek_backward` to another Normal or Visual key or leader sequence to start the same target-character prompt. + ## Motion counts -Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, and `n`/`N`, including remapped keys for those actions. Every caret moves independently before duplicate/overlapping targets normalize. Vertical counts preserve the clamping behavior of repeated steps through short rows; horizontal and word motions stop at buffer boundaries. Search counts wrap through the available matches. +Counts apply to `h`/`j`/`k`/`l`, arrows, `w`/`b`/`e`, `W`/`B`/`E`, `f`/`F`, and `n`/`N`, including remapped keys for those actions. Every caret moves independently before duplicate/overlapping targets normalize. Vertical counts preserve the clamping behavior of repeated steps through short rows; horizontal and word motions stop at buffer boundaries. Character seeks require the requested occurrence on the current line or leave that caret unchanged. Search counts wrap through the available matches. Unbound ASCII digits `1`–`9` start a count in Normal/Visual; subsequent digits extend it. `0` retains line-start behavior without a count and extends an existing count when bound to line-start. Explicit digit bindings, including disabled keys, take precedence. Count arithmetic saturates rather than overflowing; large counts stop at motion boundaries or wrap for search, without executing an unbounded repeat loop. diff --git a/src/editor.rs b/src/editor.rs index 0536d54..370a98a 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -262,12 +262,26 @@ enum EditStart { #[cfg_attr(test, derive(Debug))] enum SelectionIntent { - Motions(Vec<(Action, usize)>), + Motions { + motions: Vec, + linewise: bool, + }, // Whole graphemes, with a newline counting as one boundary. Spans(Vec), Rows(Vec), } +#[cfg_attr(test, derive(Debug))] +#[derive(Clone, Copy)] +enum Motion { + Action(Action, usize), + Seek { + forward: bool, + target: char, + count: usize, + }, +} + #[cfg_attr(test, derive(Debug))] enum InsertStep { Typed(String), @@ -442,6 +456,16 @@ core_actions![ Action::DocumentEnd, &[Mode::Normal, Mode::Visual], ), + ( + "core.seek_forward", + Action::SeekForward, + &[Mode::Normal, Mode::Visual], + ), + ( + "core.seek_backward", + Action::SeekBackward, + &[Mode::Normal, Mode::Visual], + ), ( "core.word_next", Action::WordNext, @@ -842,6 +866,8 @@ fn default_bindings() -> HashMap> { ('W', BigWordNext), ('B', BigWordPrevious), ('E', BigWordEnd), + ('f', SeekForward), + ('F', SeekBackward), ('n', SearchNext), ('N', SearchPrevious), ('v', Select), @@ -1007,7 +1033,7 @@ struct Buffer { redo: Vec, insert_start: Option, pending_edit: Option, - visual_motions: Option>, + visual_motions: Option>, } impl Buffer { @@ -1125,6 +1151,7 @@ pub struct Editor { leader_display: String, g_pending: bool, line_pending: Option, + seek_pending: Option<(bool, usize)>, palette: Option, hover: Option, completion: Option, @@ -1199,6 +1226,7 @@ impl Editor { leader_display: String::new(), g_pending: false, line_pending: None, + seek_pending: None, palette: None, hover: None, completion: None, @@ -1732,6 +1760,7 @@ impl Editor { || self.count.is_some() || self.g_pending || self.line_pending.is_some() + || self.seek_pending.is_some() { return self.handle_input(input); } @@ -2801,15 +2830,26 @@ impl Editor { self.line_pending } + /// Native character seek awaiting a literal target. + pub fn seek_pending(&self) -> Option { + self.seek_pending + .map(|(forward, _)| if forward { 'f' } else { 'F' }) + } + fn clear_leader(&mut self) { self.g_pending = false; self.line_pending = None; + self.seek_pending = None; self.leader_suffix.clear(); self.leader_display.clear(); } fn cancel_leader(&mut self) { - if self.leader_pending().is_some() || self.g_pending || self.line_pending.is_some() { + if self.leader_pending().is_some() + || self.g_pending + || self.line_pending.is_some() + || self.seek_pending.is_some() + { self.count = None; self.clear_leader(); } @@ -3004,6 +3044,9 @@ impl Editor { Some("core.right") if mode == "tree" => Binding::Action(Action::Right), Some(target) => return Err(format!("Unknown {mode} action: {target}")), }; + if self.seek_pending.is_some() { + self.cancel_leader(); + } if mode == "tree" { self.tree_bindings.insert(input, binding); } else { @@ -3560,6 +3603,34 @@ impl Editor { _ => self.finish_completion(), } } + if let Some((forward, count)) = self.seek_pending.take() { + if matches!(self.active.mode, Mode::Normal | Mode::Visual) + && self.startup_diagnostic.is_none() + && self.hover().is_none() + && self.palette.is_none() + && !self + .file_picker + .as_ref() + .is_some_and(|picker| picker.focused) + && let Input::Char(target) = input + && (!target.is_control() || target == '\t') + { + let motion = Motion::Seek { + forward, + target, + count, + }; + if let Some(motions) = &mut self.active.visual_motions { + motions.push(motion); + } + self.move_motion(motion); + return None; + } + self.count = None; + if input == Input::Escape { + return None; + } + } if std::mem::take(&mut self.g_pending) { if matches!(self.active.mode, Mode::Normal | Mode::Visual) && self.startup_diagnostic.is_none() @@ -3861,6 +3932,9 @@ impl Editor { | Action::LspDiagnostics => unreachable!("catalog Ex actions dispatch above"), Action::JumpBack => self.traverse_jumps(false, count), Action::JumpForward => self.traverse_jumps(true, count), + Action::SeekForward | Action::SeekBackward => { + self.seek_pending = Some((action == Action::SeekForward, count)); + } Action::DeleteLine | Action::ChangeLine => { self.edit_lines(matches!(action, Action::ChangeLine), count); } @@ -3902,7 +3976,7 @@ impl Editor { | Action::BigWordEnd => { if self.active.mode == Mode::Visual { if let Some(motions) = &mut self.active.visual_motions { - motions.push((action, count)); + motions.push(Motion::Action(action, count)); } } else if self.active.mode == Mode::Insert { self.push_insert_step(InsertStep::Move(action, count)); @@ -5693,8 +5767,13 @@ impl Editor { } fn selection_intent(&mut self, ranges: &[Selection]) -> SelectionIntent { + if let Some(motions) = self.active.visual_motions.take() { + return SelectionIntent::Motions { + motions, + linewise: self.visual_linewise(), + }; + } if self.visual_linewise() { - self.active.visual_motions = None; let mut rows: Vec<_> = self .active .targets @@ -5713,9 +5792,6 @@ impl Editor { } return SelectionIntent::Rows(rows); } - if let Some(motions) = self.active.visual_motions.take() { - return SelectionIntent::Motions(motions); - } let mut spans: Vec<_> = ranges .iter() .map(|range| { @@ -5951,12 +6027,13 @@ impl Editor { self.normalize_targets(); self.selections() } - SelectionIntent::Motions(motions) => { + SelectionIntent::Motions { motions, linewise } => { + self.active.linewise = *linewise; for target in &mut self.active.targets { target.anchor = target.active; } - for (action, count) in motions { - self.move_targets(*action, *count); + for motion in motions { + self.move_motion(*motion); } self.normalize_targets(); self.selections() @@ -6068,6 +6145,32 @@ impl Editor { ) } + fn move_motion(&mut self, motion: Motion) { + match motion { + Motion::Action(action, count) => self.move_targets(action, count), + Motion::Seek { + forward, + target, + count, + } => { + self.reveal_caret(); + for caret in &mut self.active.targets { + if let Some(column) = seek_column( + &self.active.lines[caret.active.row], + caret.active.column, + forward, + target, + count, + ) { + caret.active.column = column; + caret.preferred_columns = None; + } + } + self.normalize_targets(); + } + } + } + fn move_targets(&mut self, action: Action, count: usize) { self.reveal_caret(); for index in 0..self.active.targets.len() { @@ -7006,6 +7109,40 @@ fn split_lines(text: &str) -> Vec { .collect() } +fn seek_column( + line: &str, + column: usize, + forward: bool, + target: char, + count: usize, +) -> Option { + let occurrence = count.saturating_sub(1); + if forward { + line.graphemes(true) + .enumerate() + .skip(column.saturating_add(1)) + .flat_map(|(column, grapheme)| { + grapheme + .chars() + .filter(move |ch| *ch == target) + .map(move |_| column) + }) + .nth(occurrence) + } else { + line[..grapheme_byte(line, column)] + .graphemes(true) + .rev() + .enumerate() + .flat_map(|(offset, grapheme)| { + grapheme + .chars() + .filter(move |ch| *ch == target) + .map(move |_| column - offset - 1) + }) + .nth(occurrence) + } +} + fn grapheme_count(line: &str) -> usize { if line.is_ascii() { // CRLF is the only multi-byte ASCII grapheme. @@ -12156,6 +12293,285 @@ mod tests { } } + #[test] + fn character_seek_counts_are_strict_line_local_and_atomic_when_missing() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("xaxaax\nx\n\nx".into())]) + .unwrap(); + let undo = editor.active.undo.len(); + for (column, keys, expected) in [ + (0, "fx", 2), + (0, "2fx", 5), + (0, "3fx", 0), + (5, "Fx", 2), + (5, "2Fx", 0), + (5, "3Fx", 5), + (2, "fz", 2), + (0, "999999999999999999999999fx", 0), + ] { + set_carets(&mut editor, &[(0, column)], 0); + type_text(&mut editor, keys); + assert_eq!( + editor.cursor(), + Cursor { + row: 0, + column: expected + } + ); + assert_eq!(editor.count(), None); + assert_eq!(editor.seek_pending(), None); + } + for row in [1, 2, 3] { + set_carets(&mut editor, &[(row, 0)], 0); + type_text(&mut editor, "fxFx"); + assert_eq!(editor.cursor(), Cursor { row, column: 0 }); + } + assert_eq!(editor.active.undo.len(), undo); + } + + #[test] + fn character_seek_consumes_literal_targets_before_bindings_counts_and_prefixes() { + for target in ['0', '2', 'g', 'f', 'F', '[', ']', ' ', '\t'] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(format!("a{target}b{target}"))]) + .unwrap(); + let key = display_key(&Input::Char(target)); + editor + .bind("normal", &key, Some("core.right".into())) + .unwrap(); + editor + .bind("normal", "z", Some("core.seek_forward".into())) + .unwrap(); + type_text(&mut editor, "2z"); + editor.handle(Input::Char(target)); + assert_eq!(editor.cursor().column, 3); + assert_eq!(editor.count(), None); + assert!(!editor.g_pending()); + assert!(editor.leader_pending().is_none()); + assert!(editor.seek_pending().is_none()); + } + let mut editor = editor(); + editor.apply(vec![Effect::Replace("axbx".into())]).unwrap(); + editor + .bind("normal", "f", Some("core.right".into())) + .unwrap(); + type_text(&mut editor, "f"); + assert_eq!(editor.cursor().column, 1); + assert!(editor.seek_pending().is_none()); + editor + .bind("normal", "leader f", Some("core.seek_forward".into())) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "2 fx"); + assert_eq!(editor.cursor().column, 3); + for mode in ["insert", "command", "search", "selection", "tree"] { + assert!( + editor + .bind(mode, "z", Some("core.seek_forward".into())) + .is_err() + ); + assert!( + editor + .bind(mode, "z", Some("core.seek_backward".into())) + .is_err() + ); + } + editor.apply(vec![Effect::Replace(String::new())]).unwrap(); + type_text(&mut editor, "ifFgf"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "fFgf"); + } + + #[test] + fn character_seek_unicode_scalars_land_on_graphemes_for_all_carets_and_visual_shapes() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace( + "界e\u{301}👩‍🚀e\u{301}x\n\naxax".into(), + )]) + .unwrap(); + type_text(&mut editor, "f\u{301}"); + assert_eq!(editor.cursor().column, 1); + type_text(&mut editor, "f🚀"); + assert_eq!(editor.cursor().column, 2); + type_text(&mut editor, "F\u{301}"); + assert_eq!(editor.cursor().column, 1); + type_text(&mut editor, "F\u{301}"); + assert_eq!(editor.cursor().column, 1); // The current grapheme is excluded. + for selector in ["", "v", "V"] { + set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); + type_text(&mut editor, selector); + type_text(&mut editor, "2fx"); + assert_eq!( + editor.carets(), + vec![ + Cursor { row: 0, column: 0 }, + Cursor { row: 1, column: 0 }, + Cursor { row: 2, column: 3 }, + ] + ); + assert_eq!(editor.visual_linewise(), selector == "V"); + } + } + + #[test] + fn character_seek_escape_and_lifecycle_cancel_pending_without_changing_selection() { + let directory = Directory::new(); + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("axbx\nnext".into())]) + .unwrap(); + for selector in ["", "v", "V"] { + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, selector); + let mode = editor.mode(); + let targets = editor.active.targets.clone(); + type_text(&mut editor, "2f"); + assert_eq!(editor.seek_pending(), Some('f')); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), mode); + assert_eq!(editor.active.targets, targets); + assert_eq!(editor.count(), None); + assert_eq!(editor.seek_pending(), None); + } + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "2f"); + editor.handle(Input::Right); + assert_eq!(editor.cursor().column, 1); + assert_eq!(editor.seek_pending(), None); + type_text(&mut editor, "2f"); + editor.handle(Input::Paste("x".into())); + assert_eq!(editor.cursor().column, 1); + assert_eq!(editor.seek_pending(), None); + for command in ["plugins", "bnext", "bprev", "buffer 1"] { + type_text(&mut editor, "f"); + editor.execute(command); + assert_eq!(editor.seek_pending(), None); + } + type_text(&mut editor, "f"); + editor + .bind("normal", "z", Some("core.right".into())) + .unwrap(); + assert_eq!(editor.seek_pending(), None); + type_text(&mut editor, "f"); + editor + .bind("selection", "z", Some("core.down".into())) + .unwrap(); + assert_eq!(editor.seek_pending(), None); + type_text(&mut editor, "f"); + editor.set_leader(",").unwrap(); + assert_eq!(editor.seek_pending(), None); + type_text(&mut editor, "f"); + editor + .navigate_to( + directory.path("missing"), + TextPosition { row: 0, column: 0 }, + ) + .unwrap_err(); + assert_eq!(editor.seek_pending(), None); + type_text(&mut editor, "f"); + editor.execute("palette"); + assert_eq!(editor.seek_pending(), None); + editor.handle(Input::Escape); + type_text(&mut editor, "f"); + editor + .apply(vec![Effect::Replace("replacement".into())]) + .unwrap(); + assert_eq!(editor.seek_pending(), None); + } + + #[test] + fn character_seek_visual_edit_replay_resolves_target_direction_and_count_fresh() { + for (seek, original, replacement, column) in [ + ("2fx", "axbxTAIL", "aLONGxbLONGxTAIL", 0), + ("2Fx", "xaxbTAIL", "xLONGxaLONGbTAIL", 11), + ] { + for change in [false, true] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace(original.into())]) + .unwrap(); + let start = if seek == "2Fx" { 3 } else { 0 }; + set_carets(&mut editor, &[(0, start)], 0); + type_text( + &mut editor, + &format!("v{seek}{}", if change { "c!" } else { "d" }), + ); + if change { + editor.handle(Input::Escape); + } + editor + .bind("visual", "f", Some("core.left".into())) + .unwrap(); + editor + .bind("visual", "F", Some("core.right".into())) + .unwrap(); + editor + .bind("visual", "x", Some("core.line_end".into())) + .unwrap(); + editor + .apply(vec![Effect::Replace(replacement.into())]) + .unwrap(); + set_carets(&mut editor, &[(0, column)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, if change { "!TAIL" } else { "TAIL" }); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.seek_pending(), None); + } + } + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("ax\nkeep".into())]) + .unwrap(); + type_text(&mut editor, "Vfxd"); + editor + .apply(vec![Effect::Replace("alongx\nkeep".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 0)], 0); + editor.handle(Input::Char('.')); + assert_eq!(editor.context().text, "keep"); + assert!(matches!( + &editor.last_edit.as_ref().unwrap().start, + EditStart::Selection { + selector: SelectionIntent::Motions { linewise: true, .. }, + .. + } + )); + } + + #[test] + fn readonly_diff_character_seek_consumes_brackets_and_tab_before_pane_dispatch() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["a[b]\tc".into()], + vec!["a[b]\td".into()], + ); + for focus in [Focus::Before, Focus::After] { + if editor.diff().unwrap().focus != focus { + editor.handle(Input::Char('\t')); + } + let text = editor.context().text; + let rows = editor.diff().unwrap().display_rows.clone(); + type_text(&mut editor, "f["); + assert_eq!(editor.cursor().column, 1); + type_text(&mut editor, "f]"); + assert_eq!(editor.cursor().column, 3); + type_text(&mut editor, "f\t"); + assert_eq!(editor.cursor().column, 4); + type_text(&mut editor, "F["); + assert_eq!(editor.cursor().column, 1); + assert_eq!(editor.diff().unwrap().focus, focus); + assert_eq!(editor.diff().unwrap().display_rows, rows); + assert_eq!(editor.context().text, text); + assert!(editor.active.undo.is_empty()); + } + } + fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) { editor.active.mode = Mode::Normal; editor.active.visual_motions = None; diff --git a/src/main.rs b/src/main.rs index 3f5f2c8..c05012d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by defaul Normal: i/a insert, I/A insert at first nonblank/end, o/O new line h/j/k/l move, 0/$ line ends, x delete, J join lines w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs + Normal/Visual: f/F seek next/previous on current line; 2fx seeks second x gf follows Markdown file links/reference links and heading fragments (no LSP) Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported) diff --git a/src/tui.rs b/src/tui.rs index 8b86b7d..71ed78c 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -583,6 +583,9 @@ fn draw( if let Some(operator) = editor.line_operator_pending() { write!(status, " {operator}").unwrap(); } + if let Some(seek) = editor.seek_pending() { + write!(status, " {seek}").unwrap(); + } if editor.buffer_count() > 1 { write!( status, diff --git a/tests/navigation.rs b/tests/navigation.rs new file mode 100644 index 0000000..437e866 --- /dev/null +++ b/tests/navigation.rs @@ -0,0 +1,390 @@ +use std::{ + fs, + path::PathBuf, + process::Command, + sync::atomic::{AtomicU64, Ordering}, + thread, + time::{Duration, Instant}, +}; + +use vamp::{ + diff::{DisplayRow, Focus, Phase}, + editor::{Cursor, Editor, Input, Mode, Selection}, + session::EditorSession, +}; + +static NEXT: AtomicU64 = AtomicU64::new(0); + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "vamp-navigation-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir_all(&root).unwrap(); + Self(root) + } + + fn file(&self, name: &str, text: &str) -> PathBuf { + let path = self.0.join(name); + fs::write(&path, text).unwrap(); + path + } + + fn editor(&self, text: &str) -> Editor { + let mut editor = + Editor::open(Some(self.file("buffer.txt", text)), vec!["text".into()]).unwrap(); + editor.set_clipboard_mode("separate").unwrap(); + editor + } + + fn session(&self, text: &str, bindings: &str) -> EditorSession { + let source = self.file("buffer.txt", text); + let config = self.file( + "config.rb", + &format!( + "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n{bindings}\nend\n" + ), + ); + let session = + EditorSession::open(Some(source), vec!["text".into()], Some(&config)).unwrap(); + assert!(session.editor().startup_diagnostic().is_none()); + session + } + + fn git(&self, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(self.0.join("repo")) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "Navigation Fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") + .env("GIT_COMMITTER_NAME", "Navigation Fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") + .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn keys(editor: &mut Editor, text: &str) { + for ch in text.chars() { + editor.handle(Input::Char(ch)); + } +} + +fn session_keys(session: &mut EditorSession, text: &str) { + for ch in text.chars() { + session.handle(Input::Char(ch)); + } +} + +fn palette_seek(session: &mut EditorSession, action: &str, target: char) { + session.handle(Input::Ctrl('p')); + session_keys(session, action); + session.handle(Input::Enter); + assert!(session.editor().palette().is_none()); + session.handle(Input::Char(target)); +} + +fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !ready(session) { + session.poll(); + assert!( + Instant::now() < deadline, + "diff state: {:?}", + session.editor().diff() + ); + if let Some(diff) = session.editor().diff() { + assert!(diff.error.is_none(), "{:?}", diff.error); + } + thread::sleep(Duration::from_millis(1)); + } +} + +#[test] +fn counted_character_seeks_move_every_native_caret_independently() { + let fixture = Fixture::new(); + let text = "xaxbx tail\nx---x----x tail\nx only\n"; + let mut editor = fixture.editor(text); + editor.handle(Input::Ctrl('e')); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.selection_count(), 3); + + keys(&mut editor, "2fx"); + assert_eq!( + editor.carets(), + [ + Cursor { row: 0, column: 4 }, + Cursor { row: 1, column: 9 }, + Cursor { row: 2, column: 0 }, + ] + ); + keys(&mut editor, "2Fx"); + assert_eq!( + editor.carets(), + [ + Cursor { row: 0, column: 0 }, + Cursor { row: 1, column: 0 }, + Cursor { row: 2, column: 0 }, + ] + ); + assert_eq!(editor.context().text, text); + assert!(!editor.dirty()); +} + +#[test] +fn visual_character_and_line_seeks_yank_the_selected_text_into_another_buffer() { + for (mode, expected) in [('v', "a x x"), ('V', "a x xx tail\n")] { + let fixture = Fixture::new(); + let mut editor = fixture.editor("a x xx tail\n"); + editor.handle(Input::Char(mode)); + keys(&mut editor, "2fx"); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); + assert_eq!(editor.visual_linewise(), mode == 'V'); + assert_eq!( + editor.selections(), + [Selection { + start: Cursor { row: 0, column: 0 }, + end: if mode == 'V' { + Cursor { row: 1, column: 0 } + } else { + Cursor { row: 0, column: 5 } + }, + }] + ); + editor.handle(Input::Char('y')); + assert_eq!(editor.mode(), Mode::Normal); + assert!(!editor.dirty()); + + let destination = fixture.file("destination.txt", ""); + editor.execute(&format!("edit {}", destination.display())); + editor.handle(Input::Char('P')); + assert_eq!(editor.context().text, expected, "{mode}"); + assert_eq!( + fs::read_to_string(fixture.0.join("buffer.txt")).unwrap(), + "a x xx tail\n" + ); + } +} + +#[test] +fn typed_ruby_seek_remaps_and_palette_actions_preserve_normal_and_visual_navigation() { + let fixture = Fixture::new(); + let text = "a x x tail\n"; + let mut session = fixture.session( + text, + r#" config.bind 'leader s', config.core.seek_forward + config.bind 'leader r', config.core.seek_backward + config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual + config.bind 'leader r', config.core.seek_backward, mode: config.modes.visual"#, + ); + session_keys(&mut session, "2 sx"); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 }); + session_keys(&mut session, " rx"); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 }); + + session_keys(&mut session, "v sx"); + let expanded = [Selection { + start: Cursor { row: 0, column: 2 }, + end: Cursor { row: 0, column: 5 }, + }]; + assert_eq!(session.editor().selections(), expanded); + session_keys(&mut session, " rx"); + let contracted = [Selection { + start: Cursor { row: 0, column: 2 }, + end: Cursor { row: 0, column: 3 }, + }]; + assert_eq!(session.editor().selections(), contracted); + palette_seek(&mut session, "core.seek_forward", 'x'); + assert_eq!(session.editor().mode(), Mode::Visual); + assert_eq!(session.editor().selections(), expanded); + palette_seek(&mut session, "core.seek_backward", 'x'); + assert_eq!(session.editor().selections(), contracted); + + session.handle(Input::Escape); + palette_seek(&mut session, "core.seek_forward", 'x'); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 }); + palette_seek(&mut session, "core.seek_backward", 'x'); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 2 }); + assert_eq!(session.editor().mode(), Mode::Normal); + assert_eq!(session.editor().context().text, text); + assert!(!session.editor().dirty()); +} + +#[test] +fn repeating_a_visual_seek_change_resolves_the_counted_character_on_the_new_line() { + let fixture = Fixture::new(); + let mut session = fixture.session( + "a x x tail\nlong x gap x tail\n", + " config.bind 'leader s', config.core.seek_forward, mode: config.modes.visual", + ); + session_keys(&mut session, "v2 sxc!"); + session.handle(Input::Escape); + assert_eq!( + session.editor().context().text, + "! tail\nlong x gap x tail\n" + ); + + session_keys(&mut session, "j0."); + assert_eq!(session.editor().context().text, "! tail\n! tail\n"); + assert_eq!(session.editor().mode(), Mode::Normal); + session.handle(Input::Char('u')); + assert_eq!( + session.editor().context().text, + "! tail\nlong x gap x tail\n" + ); +} + +#[test] +fn readonly_before_and_historical_after_seeks_preserve_snapshots_and_folded_excerpts() { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.0.join("repo")).unwrap(); + fixture.git(&["init", "-q"]); + let source = fixture.0.join("repo/context.txt"); + let mut lines = (1..=40) + .map(|row| format!("stable_{row:03} [x] [x]")) + .collect::>(); + lines[19] = "before x x tail".into(); + let before_text = lines.join("\n") + "\n"; + fs::write(&source, &before_text).unwrap(); + fixture.git(&["add", "context.txt"]); + fixture.git(&["commit", "-qm", "before"]); + let before = fixture.git(&["rev-parse", "HEAD"]); + lines[19] = "after x x tail".into(); + let after_text = lines.join("\n") + "\n"; + fs::write(&source, &after_text).unwrap(); + fixture.git(&["commit", "-qam", "after"]); + let after = fixture.git(&["rev-parse", "HEAD"]); + lines[19] = "working x x tail".into(); + let working_text = lines.join("\n") + "\n"; + fs::write(&source, &working_text).unwrap(); + let config = fixture.file( + "config.rb", + "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\nend\n", + ); + let mut session = + EditorSession::open(Some(source.clone()), vec!["text".into()], Some(&config)).unwrap(); + assert!(session.editor().startup_diagnostic().is_none()); + session_keys(&mut session, "iunsaved "); + session.handle(Input::Escape); + let unsaved_text = session.editor().context().text; + let original_cursor = session.editor().cursor(); + + session.execute("diff"); + wait_until(&mut session, |session| { + session + .editor() + .diff() + .is_some_and(|diff| diff.catalog.is_some() && !diff.loading) + }); + for revision in [&before, &after] { + session_keys(&mut session, revision); + session.handle(Input::Down); + session.handle(Input::Enter); + } + wait_until(&mut session, |session| { + session + .editor() + .diff() + .is_some_and(|diff| diff.phase == Phase::View) + }); + let excerpts = session.editor().diff().unwrap().display_rows.clone(); + assert!( + excerpts + .iter() + .any(|row| matches!(row, DisplayRow::Fold { .. })) + ); + + for (focus, text, first_x, second_x) in [ + (Focus::Before, &before_text, 7, 9), + (Focus::After, &after_text, 6, 8), + ] { + for _ in 0..3 { + if session.editor().diff().unwrap().focus == focus { + break; + } + session.handle(Input::Char('\t')); + } + assert_eq!(session.editor().diff().unwrap().focus, focus); + let revision = session.editor().text_revision(); + session_keys(&mut session, "gt02f["); + assert_eq!( + session.editor().cursor(), + Cursor { + row: 18, + column: 15 + } + ); + session_keys(&mut session, "F[f]"); + assert_eq!( + session.editor().cursor(), + Cursor { + row: 18, + column: 13 + } + ); + session_keys(&mut session, "j02fx"); + assert_eq!( + session.editor().cursor(), + Cursor { + row: 19, + column: second_x + } + ); + session_keys(&mut session, "Fxvfx"); + assert_eq!( + session.editor().selections(), + [Selection { + start: Cursor { + row: 19, + column: first_x + }, + end: Cursor { + row: 19, + column: second_x + 1 + }, + }] + ); + session_keys(&mut session, "yVfx"); + assert!(session.editor().visual_linewise()); + assert_eq!( + session.editor().cursor(), + Cursor { + row: 19, + column: second_x + } + ); + session.handle(Input::Escape); + assert_eq!(session.editor().context().text, *text); + assert_eq!(session.editor().text_revision(), revision); + assert!(!session.editor().dirty()); + assert_eq!(session.editor().diff().unwrap().display_rows, excerpts); + } + + session.execute("diff.close"); + assert_eq!(session.editor().context().text, unsaved_text); + assert_eq!(session.editor().cursor(), original_cursor); + assert!(session.editor().dirty()); + assert_eq!(fs::read_to_string(&source).unwrap(), working_text); + assert_eq!(fixture.git(&["rev-parse", "HEAD"]), after); +}