diff --git a/docs/editing.md b/docs/editing.md index d7163eb..40bc2f9 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -50,7 +50,7 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete | Insert | Ctrl+n requests completion at the primary caret or cycles next; Ctrl+j/k cycles next/previous while completion is open | | Insert, active snippet | Type to replace the highlighted argument and its mirrors; Tab / Shift+Tab moves next/previous | | Command | Enter executes; Backspace edits; Esc cancels | -| Search | Type a literal query; Enter finds the next match; Backspace edits; Esc cancels | +| Search | Type a literal query; Enter accepts the current occurrence or finds the next match; Backspace edits; Esc cancels | | Insert, Command, Search | Ctrl+u deletes backward to the logical line start | | Insert, Command, Search (macOS) | Option+Backspace deletes backward by word; Command+Backspace deletes backward to the logical line start | @@ -172,6 +172,8 @@ The last recipe is session-wide across buffers, but pending Insert groups and un Search is case-sensitive and single-line, with no regular expressions or Unicode normalization. Matches start and end on whole grapheme boundaries, including combining marks and emoji. Next/previous navigation skips the current position and wraps through the buffer; a sole match wraps back to itself. All occurrences of the active query are highlighted yellow. +Submitting `/` keeps each caret in place if it is at or inside an occurrence of the query; otherwise, it moves that caret to the next match, wrapping as needed. This also applies to an empty submission repeating the previous query. Use `n`/`N` to explicitly move to the next/previous match. + Normal `*` highlights the primary caret's word without moving any caret when it selects a new query or changes the matching mode. Pressing `*` again for the same active word navigates forward; counts such as `2*` then skip to the requested occurrence, with every caret searching the primary word from its own position. Selecting a different word starts a new highlight-only search. It uses the same Unicode letters/digits/underscore boundaries as `w`, so `cat` skips `scatter`, `cat_cat`, and `cat2`. If the caret is not on a keyword, it takes the next keyword on that line; with none, it uses the punctuation run under or after the caret as a literal substring. A blank remainder reports no word and preserves the previous search. This also works in read-only diff text. Bind `config.core.search_word` to remap it. `n`/`N` and an empty `/` submission retain the whole-word setting; a nonempty `/` query returns to literal substring search. Cancelling `/` preserves both the previous query and its matching behavior. diff --git a/src/editor.rs b/src/editor.rs index a8ce008..f1faac2 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -8426,7 +8426,7 @@ impl Editor { self.active.view.search_query = command; self.active.view.search_whole_word = false; } - self.seek_search(Action::SearchNext, 1); + self.seek_search(Action::Search, 1); } else { return self.execute(&command); } @@ -8531,7 +8531,16 @@ impl Editor { cursor.row, grapheme_byte(&self.active.lines[cursor.row], cursor.column), ); - let first = if forward { + let current = (action == Action::Search) + .then(|| matches.partition_point(|position| *position <= origin)) + .and_then(|end| end.checked_sub(1)) + .filter(|index| { + let found = matches[*index]; + found.0 == origin.0 && origin.1 < found.1 + query.len() + }); + let first = if let Some(current) = current { + current + } else if forward { matches.partition_point(|position| *position <= origin) % matches.len() } else { matches @@ -8546,15 +8555,18 @@ impl Editor { (first + matches.len() - distance) % matches.len() }; let found = matches[destination]; - target.active = Cursor { + let start = Cursor { row: found.0, column: column_after_byte(&self.active.lines[found.0], found.1), }; - target.preferred_columns = None; + if current.is_none() { + target.active = start; + target.preferred_columns = None; + } if index == self.active.view.primary { self.active.view.search_match = Some(SearchMatch { - start: target.active, - end_column: target.active.column + grapheme_count(query), + start, + end_column: start.column + grapheme_count(query), }); } } @@ -16112,7 +16124,7 @@ mod tests { type_text(&mut editor, "Vjy"); assert!(editor.clipboard_linewise); assert_eq!(editor.clipboard, ["alpha beta alpha\nsecond line\n"]); - type_text(&mut editor, "gt/alpha\n"); + type_text(&mut editor, "gt/alpha\nn"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 11 }); editor.handle(Input::Char('n')); assert_eq!(editor.cursor(), Cursor::default()); @@ -17364,7 +17376,7 @@ mod tests { editor .apply(vec![Effect::Replace("hit\n中👩‍💻 hit\nhit".into())]) .unwrap(); - type_text(&mut editor, "/hit\n"); + type_text(&mut editor, "/hit\nn"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 3 }); type_text(&mut editor, "nN"); assert_eq!(editor.jumps.len(), 4); @@ -17789,7 +17801,7 @@ mod tests { editor .apply(vec![Effect::Replace("hit\nhit".into())]) .unwrap(); - type_text(&mut editor, "/hit\n"); + type_text(&mut editor, "/hit\nn"); editor.handle(Input::Ctrl('k')); assert_eq!(editor.carets().len(), 2); let targets = editor.active.view.targets.clone(); @@ -22061,6 +22073,7 @@ mod tests { .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); submit_search(&mut editor, "cat"); + editor.handle(Input::Char('n')); type_text(&mut editor, "2n"); assert_eq!( editor.carets(), @@ -22771,6 +22784,7 @@ mod tests { .apply(vec![Effect::Replace("hit a\nz\nhit b\nz\nhit c\n".into())]) .unwrap(); submit_search(&mut editor, "hit"); + editor.handle(Input::Char('n')); assert_eq!(editor.cursor().row, 2); type_text(&mut editor, "Vn"); assert_eq!(editor.linewise_rows().collect::>(), [2..=4]); @@ -23477,6 +23491,7 @@ mod tests { assert_eq!(editor.buffer_id(), 2); set_carets(&mut editor, &[(0, 0), (1, 0)], 0); submit_search(&mut editor, "dog"); + editor.handle(Input::Char('n')); let b_carets = editor.carets(); let b_primary = editor.cursor(); editor.handle(Input::Char('i')); @@ -24794,6 +24809,7 @@ mod tests { assert_eq!(editor.selection_count(), 2); assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); submit_search(&mut editor, "cat"); + editor.handle(Input::Char('n')); assert_eq!( editor.carets(), vec![Cursor { row: 0, column: 4 }, Cursor { row: 1, column: 4 }] @@ -24950,7 +24966,7 @@ mod tests { assert_eq!(editor.search_match(), active); assert!(editor.command_line().is_empty()); submit_search(&mut editor, ""); - assert_eq!(editor.cursor(), Cursor { row: 1, column: 4 }); + assert_eq!(editor.cursor(), cursor); let cursor = editor.cursor(); submit_search(&mut editor, "missing"); diff --git a/src/main.rs b/src/main.rs index a7fe56c..2e351bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,7 +47,7 @@ Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+ Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends Outside snippets Enter inserts newline, Tab inserts a tab or configured spaces; Esc returns to Normal Insert/Command/Search: Ctrl+u deletes backward to the logical line start -Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps) +Search: Enter accepts current/next literal match, Esc cancels (case-sensitive, wraps) Commands: :save [PATH], :force_save [PATH], :quit, :force_quit, :save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS] Aliases: :w=save, :w!=force_save, :q=quit, :q!=force_quit, diff --git a/src/session.rs b/src/session.rs index 99c347a..e74d791 100644 --- a/src/session.rs +++ b/src/session.rs @@ -4311,6 +4311,8 @@ end session.handle(Input::Enter); assert_eq!(session.editor().mode(), crate::editor::Mode::Search); session.handle(Input::Ctrl('x')); + assert_eq!(session.editor().cursor().row, 0); + session.handle(Input::Ctrl('n')); assert_eq!(session.editor().cursor().row, 1); session.handle(Input::Ctrl('n')); assert_eq!(session.editor().cursor().row, 2); diff --git a/tests/navigation.rs b/tests/navigation.rs index 307bc92..cbb1acd 100644 --- a/tests/navigation.rs +++ b/tests/navigation.rs @@ -120,6 +120,36 @@ fn wait_until(session: &mut EditorSession, ready: impl Fn(&EditorSession) -> boo } } +#[test] +fn submitting_search_keeps_each_caret_inside_a_current_match() { + let fixture = Fixture::new(); + for (text, query, column, end) in [ + ("jj next jj", "jj", 0, 2), + ("jj next jj", "jj", 1, 2), + ("界e\u{301} next 界e\u{301}", "界e\u{301}", 1, 2), + ("aaaa next aa", "aa", 1, 3), + ] { + let mut editor = fixture.editor(text); + keys(&mut editor, &"l".repeat(column)); + let cursor = editor.cursor(); + keys(&mut editor, &format!("/{query}")); + editor.handle(Input::Enter); + assert_eq!(editor.cursor(), cursor, "{text:?}"); + assert_eq!(editor.search_match().unwrap().end_column, end); + keys(&mut editor, "n"); + assert_ne!(editor.cursor(), cursor); + assert!(!editor.dirty()); + } + let mut editor = fixture.editor("jj next jj\n-- next jj"); + editor.handle(Input::Ctrl('e')); + keys(&mut editor, "/jj"); + editor.handle(Input::Enter); + assert_eq!( + editor.carets(), + [Cursor::default(), Cursor { row: 1, column: 8 }] + ); +} + #[test] fn normal_escape_hides_highlights_but_retains_search_and_rearms_star() { let fixture = Fixture::new(); @@ -145,7 +175,7 @@ fn normal_escape_hides_highlights_but_retains_search_and_rearms_star() { editor.handle(Input::Escape); editor.handle(Input::Char('/')); editor.handle(Input::Enter); - assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); + assert_eq!(editor.cursor(), Cursor::default()); assert_eq!(editor.search_matches_on_row(0).count(), 2); assert!(!editor.dirty()); } @@ -192,7 +222,7 @@ fn star_search_keeps_whole_word_counts_history_and_literal_search_distinct() { editor.handle(Input::Escape); editor.handle(Input::Char('/')); editor.handle(Input::Enter); - assert_eq!(editor.cursor(), Cursor { row: 6, column: 0 }); + assert_eq!(editor.cursor(), Cursor::default()); keys(&mut editor, "gb*"); assert_eq!(editor.cursor(), Cursor { row: 9, column: 0 }); keys(&mut editor, "n"); @@ -200,6 +230,7 @@ fn star_search_keeps_whole_word_counts_history_and_literal_search_distinct() { keys(&mut editor, "/cat"); editor.handle(Input::Enter); + keys(&mut editor, "n"); assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); assert_eq!(editor.search_match().unwrap().end_column, 4); assert_eq!(editor.context().text, text); @@ -260,6 +291,7 @@ fn star_search_rebinding_moves_all_carets_and_remains_view_local() { assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 }); session_keys(&mut session, "/cat"); session.handle(Input::Enter); + session_keys(&mut session, "n"); assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 4 }); session.handle(Input::Alt('h')); session_keys(&mut session, "2N");