From dfa469b9eeabc51a5f24def0e8916866a632c30e Mon Sep 17 00:00:00 2001 From: Ben Van Sleen Date: Sat, 18 Nov 2023 13:52:50 -0500 Subject: [PATCH 1/6] Introduce `(KeyCode, KeyCode)` tuple that functions as ESC in Vi Insert mode (e.g. press "jk" to exit insert mode) --- examples/demo.rs | 6 +++- src/edit_mode/vi/mod.rs | 66 +++++++++++++++++++++++++++++++++-------- src/lib.rs | 3 +- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/examples/demo.rs b/examples/demo.rs index 2e7a402e3..8f39667dd 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -114,7 +114,11 @@ fn main() -> std::io::Result<()> { add_newline_keybinding(&mut insert_keybindings); - Box::new(Vi::new(insert_keybindings, normal_keybindings)) + Box::new(Vi::new( + insert_keybindings, + normal_keybindings, + (KeyCode::Null, KeyCode::Null), + )) } else { let mut keybindings = default_emacs_keybindings(); add_menu_keybindings(&mut keybindings); diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 4428c645e..e713ff68d 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -30,6 +30,8 @@ pub struct Vi { previous: Option, // last f, F, t, T motion for ; and , last_char_search: Option, + alternate_esc_seq: (KeyCode, KeyCode), + most_recent_keycode: KeyCode, } impl Default for Vi { @@ -37,32 +39,75 @@ impl Default for Vi { Vi { insert_keybindings: default_vi_insert_keybindings(), normal_keybindings: default_vi_normal_keybindings(), + alternate_esc_seq: (KeyCode::Null, KeyCode::Null), cache: Vec::new(), mode: ViMode::Insert, previous: None, last_char_search: None, + most_recent_keycode: KeyCode::Null, } } } impl Vi { /// Creates Vi editor using defined keybindings - pub fn new(insert_keybindings: Keybindings, normal_keybindings: Keybindings) -> Self { + pub fn new( + insert_keybindings: Keybindings, + normal_keybindings: Keybindings, + alternate_esc_seq: (KeyCode, KeyCode), + ) -> Self { Self { insert_keybindings, normal_keybindings, + alternate_esc_seq, ..Default::default() } } } +fn exit_insert_mode(editor: &mut Vi) -> ReedlineEvent { + editor.most_recent_keycode = KeyCode::Null; + editor.cache.clear(); + editor.mode = ViMode::Normal; + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) +} + impl EditMode for Vi { fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent { match event.into() { Event::Key(KeyEvent { code, modifiers, .. - }) => match (self.mode, modifiers, code) { - (ViMode::Normal, modifier, KeyCode::Char(c)) => { + }) => match ( + self.mode, + modifiers, + code, + self.alternate_esc_seq, + self.most_recent_keycode, + ) { + (ViMode::Insert, KeyModifiers::NONE, code, (e1, e2), mr) + if code == e2 && mr == e1 => + { + exit_insert_mode(self) + } + (ViMode::Insert, KeyModifiers::NONE, code, (e1, _), _) if code == e1 => { + self.most_recent_keycode = code; + ReedlineEvent::None + } + ( + ViMode::Insert, + KeyModifiers::NONE, + KeyCode::Char(code), + (KeyCode::Char(e1), KeyCode::Char(e2)), + KeyCode::Char(mr), + ) if code != e2 && mr == e1 => { + self.most_recent_keycode = KeyCode::Char(code); + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::InsertChar(e1)]), + ReedlineEvent::Edit(vec![EditCommand::InsertChar(code)]), + ]) + } + + (ViMode::Normal, modifier, KeyCode::Char(c), _, _) => { let c = c.to_ascii_lowercase(); if let Some(event) = self @@ -97,7 +142,7 @@ impl EditMode for Vi { ReedlineEvent::None } } - (ViMode::Insert, modifier, KeyCode::Char(c)) => { + (ViMode::Insert, modifier, KeyCode::Char(c), _, _) => { // Note. The modifier can also be a combination of modifiers, for // example: // KeyModifiers::CONTROL | KeyModifiers::ALT @@ -122,6 +167,7 @@ impl EditMode for Vi { | KeyModifiers::ALT | KeyModifiers::SHIFT { + self.most_recent_keycode = KeyCode::Char(c); ReedlineEvent::Edit(vec![EditCommand::InsertChar( if modifier == KeyModifiers::SHIFT { c.to_ascii_uppercase() @@ -134,20 +180,16 @@ impl EditMode for Vi { } }) } - (_, KeyModifiers::NONE, KeyCode::Esc) => { - self.cache.clear(); - self.mode = ViMode::Normal; - ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) - } - (_, KeyModifiers::NONE, KeyCode::Enter) => { + (_, KeyModifiers::NONE, KeyCode::Esc, _, _) => exit_insert_mode(self), + (_, KeyModifiers::NONE, KeyCode::Enter, _, _) => { self.mode = ViMode::Insert; ReedlineEvent::Enter } - (ViMode::Normal, _, _) => self + (ViMode::Normal, _, _, _, _) => self .normal_keybindings .find_binding(modifiers, code) .unwrap_or(ReedlineEvent::None), - (ViMode::Insert, _, _) => self + (ViMode::Insert, _, _, _, _) => self .insert_keybindings .find_binding(modifiers, code) .unwrap_or(ReedlineEvent::None), diff --git a/src/lib.rs b/src/lib.rs index ec31a401d..702c18bf5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -166,12 +166,13 @@ //! // Create a reedline object with custom edit mode //! // This can define a keybinding setting or enable vi-emulation //! use reedline::{ -//! default_vi_insert_keybindings, default_vi_normal_keybindings, EditMode, Reedline, Vi, +//! default_vi_insert_keybindings, default_vi_normal_keybindings, EditMode, Reedline, Vi, KeyCode //! }; //! //! let mut line_editor = Reedline::create().with_edit_mode(Box::new(Vi::new( //! default_vi_insert_keybindings(), //! default_vi_normal_keybindings(), +//! (KeyCode::Null, KeyCode::Null), //! ))); //! ``` //! From 82e18fc0b4a5010b9289307326f1803efa35d3da Mon Sep 17 00:00:00 2001 From: Ben Van Sleen <78059325+benvansleen@users.noreply.github.com> Date: Sun, 29 Jun 2025 18:46:43 -0400 Subject: [PATCH 2/6] Fix/vi visual mode (#1) * move visual mode activation to top of match-case * fixup: missed a call of `exit_insert_mode` * allow `normal_keybindings` in `ViMode::Visual` --- src/edit_mode/vi/mod.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 34757fe3f..f417cd2ba 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -66,10 +66,10 @@ impl Vi { } } -fn exit_insert_mode(editor: &mut Vi) -> ReedlineEvent { +fn exit_insert_mode(editor: &mut Vi, new_mode: ViMode) -> ReedlineEvent { editor.most_recent_keycode = KeyCode::Null; editor.cache.clear(); - editor.mode = ViMode::Normal; + editor.mode = new_mode; ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) } @@ -88,7 +88,10 @@ impl EditMode for Vi { (ViMode::Insert, KeyModifiers::NONE, code, (e1, e2), mr) if code == e2 && mr == e1 => { - exit_insert_mode(self) + exit_insert_mode(self, ViMode::Normal) + } + (ViMode::Normal, KeyModifiers::NONE, KeyCode::Char('v'), _, _) => { + exit_insert_mode(self, ViMode::Visual) } (ViMode::Insert, KeyModifiers::NONE, code, (e1, _), _) if code == e1 => { self.most_recent_keycode = code; @@ -107,13 +110,8 @@ impl EditMode for Vi { ReedlineEvent::Edit(vec![EditCommand::InsertChar(code)]), ]) } - (ViMode::Normal, KeyModifiers::NONE, KeyCode::Char('v'), _, _) => { - self.cache.clear(); - self.mode = ViMode::Visual; - ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) - } - (ViMode::Normal, modifier, KeyCode::Char(c), _, _) => { + (ViMode::Normal | ViMode::Visual, modifier, KeyCode::Char(c), _, _) => { let c = c.to_ascii_lowercase(); if let Some(event) = self @@ -185,7 +183,7 @@ impl EditMode for Vi { } }) } - (_, KeyModifiers::NONE, KeyCode::Esc, _, _) => exit_insert_mode(self), + (_, KeyModifiers::NONE, KeyCode::Esc, _, _) => exit_insert_mode(self, ViMode::Normal), (_, KeyModifiers::NONE, KeyCode::Enter, _, _) => { self.mode = ViMode::Insert; ReedlineEvent::Enter From 0964d6e0bc45423b81078fcc7fe8d5e645277d2a Mon Sep 17 00:00:00 2001 From: Ben Van Sleen <78059325+benvansleen@users.noreply.github.com> Date: Sun, 26 Oct 2025 13:41:22 -0400 Subject: [PATCH 3/6] Update fork (#2) * fix: prompt glitch when resizing with cursor in a multiline command (#898) * fix: prompt glitch when resizing with cursor in a multiline command * fix: considering line wraps * fix: clippy * Bump version to `0.41.0` (#936) * Protect against invalid suggestion spans (#915) * Protect against invalid suggestion spans * Protect against start > end * feat: Builder option to immediately accept input (#933) * feat: Option to immediately accept input * chore: PR feedback * feat: add `ViChangeMode` event (#932) * feat: add `ViChangeMode` event * fix: use `FromStr` trait * fix: undo unused change * fix: clippy * Fix `mismatched_lifetime_syntaxes` (#947) From Rust 1.89.0 on this lints. https://blog.rust-lang.org/2025/08/07/Rust-1.89.0/#mismatched-lifetime-syntaxes-lint * Fix missing import in README.md (#942) Co-authored-by: sholderbach * fix: dislocation of cursor after previous_history navigation to a multiline entry (#899) * bumping version to 42 (#949) Co-authored-by: Jack Wright * Bump `rusqlite` to 0.37 (#950) * feat: make columnar menu traversal direction configurable (#951) * Make columnar menu traversal direction configurable * Add tests for columnar menu selection position updates * Apply changes based on review * Upgrade GitHub Actions and Rust toolchain versions (#954) * Upgrade GitHub Actions and Rust toolchain versions * Update .github/workflows/ci.yml * Vi mode text objects for word, WORD, brackets and quotes (#939) * Addd initial change inner and around word text objects and handle whitespace Note a buffer full of whitespace is not properly considered and still causing incorrect behaviour. TODO fix this. * Renaming functions and fix default value of current_whitespace_range_start * Rename cut/yank inside enums and methods to cut/yank inside pair and add general yank/cut range methods * Use TextObject enum instead of passing through the character * WIP: Add quote and bracket text objects and add jumping if not inside objects * Add my own methods for finding matching pair and jumping * Fix bugs in new function to get matching pair range and it's finish features - Now handles jumps to next open/close or equal symbol pairs if not in a pair already - Searching only on current line for equal symbol pairs e.g. quotes - Correctly handles graphemes * Simplify heirarchy of pair range finding functions - Refactor the structure of the methods to get ranges, don't need to pass in depth unecessarily, high level functions don't require cursor passed in. - Now two seperate functions for ranges, one "next" and one "current" range that gets you either the range inside next text object or inside current one depending on position of cursor. - Finilise logic to correctly handle graphemes (not byte sized chars) TODO Update unit tests * Refactoring range functions and tidy up/extend unit test cases and coverage * More refactoring - Improve some text object ranges to use iterators rather than complex logic - Clean up documentation, add consts etc - Look through and refactor some editor functions * Move text object range methods into line_buffer from editor * Combine line_buffer quote and pair text object functions into generic and rewrite a lot of doc strings * Testing for quote and bracket text object functions in editor.rs * Whitespace * Rework unit tests for new function structure * Remove angle brackets from b text object * Rename yank text object functions to copy * Add bracket test cases to range_inside_next_pair_in_group unit tests * Add more detailed unicode safety tests * Fix display enum string for renamed enums * Unicode and overflow/underflow safety when expanding text object ranges * Pass through matching pair group const for quote and bracket text object functions * Rename yank_range -> copy_range for consistency with other methods * Remove unecessary guard clause from expand_range_to_include_pair * Correct display string for CutInsidePair * Make text object types public (#957) * Make TextObject, TextObjectScope and TextObjectType public * Correct CopyInsidePair and CopyAroundPair EditType to NoOp * fix: dislocation of cursor during history navigation (#959) * fix: dislocation of cursor during history navigation * test: new test case by Claude Sonnet * simplify * Fix typos (#962) * Bump version for `0.43.0` release (#961) * Fix shift selection in vi (insert) & emacs mode (#927) * Add match_indices field to Suggestion (#798) * Add match_indices field to Suggestion Make columnar_menu use match indices Make ide menu use match indices Add fuzzy completions example Test style_suggestion Make doctests in default.rs pass Highlight entire graphemes Extract ANSI escapes from strings to apply match highlighting Fix clippy lint for fuzzy completion example Shut the typo checker up Use existing variable `escape` Copy regex from parse-ansi crate * replace LazyLock with lazy_static that works with Rust 1.63.0 (#2) * Homegrown ANSI parser Fix padding for columnar menu Highlight substring matches too by default Simplify (?) columnar menu * Fix clippy lints after rebase * Use get_match_indices helper * Stop using 'fo' because it's a typo? Fo shizzle. * Use to_string() instead of as_str() * Style entire suggestion same color * RESET after suggestion --------- Co-authored-by: Divanshu Grover * fix bashism parsing (#958) * refactor: use crossterm `supports_keyboard_enhancement` once when KittyProtocolGuard is initialized (#920) Co-authored-by: Pierre POLLET --------- Co-authored-by: zc he Co-authored-by: Stefan Holderbach Co-authored-by: Yash Thakur <45539777+ysthakur@users.noreply.github.com> Co-authored-by: Stuart Carnie Co-authored-by: Daniel Bonofiglio <63377579+bonofiglio@users.noreply.github.com> Co-authored-by: Daniel del Castillo <52696601+CastilloDel@users.noreply.github.com> Co-authored-by: Jack Wright <56345+ayax79@users.noreply.github.com> Co-authored-by: Jack Wright Co-authored-by: Piepmatz Co-authored-by: simonborje Co-authored-by: Darren Schroeder <343840+fdncred@users.noreply.github.com> Co-authored-by: JonLD <73548328+JonLD@users.noreply.github.com> Co-authored-by: Collin Murch Co-authored-by: Divanshu Grover Co-authored-by: migraine-user Co-authored-by: PtiBouchon <31276594+PitiBouchon@users.noreply.github.com> Co-authored-by: Pierre POLLET --- .github/workflows/ci.yml | 7 +- .typos.toml | 1 + Cargo.lock | 115 ++- Cargo.toml | 4 +- README.md | 2 +- examples/custom_prompt.rs | 10 +- examples/transient_prompt.rs | 10 +- src/completion/base.rs | 3 + src/completion/default.rs | 25 +- src/completion/history.rs | 1 + src/core_editor/editor.rs | 1135 ++++++++++++++++++++++++------ src/core_editor/line_buffer.rs | 646 ++++++++++++++--- src/edit_mode/base.rs | 7 +- src/edit_mode/vi/command.rs | 129 +++- src/edit_mode/vi/mod.rs | 30 +- src/edit_mode/vi/parser.rs | 1 + src/engine.rs | 141 +++- src/enums.rs | 94 ++- src/lib.rs | 9 +- src/menu/columnar_menu.rs | 616 ++++++++++------ src/menu/ide_menu.rs | 91 +-- src/menu/menu_functions.rs | 224 +++++- src/menu/mod.rs | 1 + src/painting/painter.rs | 14 +- src/painting/prompt_lines.rs | 28 +- src/painting/utils.rs | 2 +- src/prompt/base.rs | 13 +- src/prompt/default.rs | 12 +- src/terminal_extensions/kitty.rs | 12 +- 29 files changed, 2583 insertions(+), 800 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65b0e203f..18c11e0d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,10 +31,13 @@ jobs: runs-on: ${{ matrix.platform }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1.3.4 + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: clippy, rustfmt + - name: Setup nextest uses: taiki-e/install-action@nextest diff --git a/.typos.toml b/.typos.toml index 7a64bdaf3..8431ef2e4 100644 --- a/.typos.toml +++ b/.typos.toml @@ -9,6 +9,7 @@ bui = "bui" # For testing string truncation descriptio = "descriptio" ot = "ot" +strin = "strin" # for sqlite backed history wheres = "wheres" # for list_menu tests diff --git a/Cargo.lock b/Cargo.lock index 81ce15e6b..a407a648f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -23,12 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" - [[package]] name = "android-tzdata" version = "0.1.1" @@ -75,9 +57,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" dependencies = [ "serde", ] @@ -96,9 +78,13 @@ checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" [[package]] name = "cc" -version = "1.0.90" +version = "1.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" +dependencies = [ + "find-msvc-tools", + "shlex", +] [[package]] name = "cfg-if" @@ -202,7 +188,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.9.4", "crossterm_winapi", "mio", "parking_lot", @@ -311,6 +297,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -323,6 +315,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "gethostname" version = "0.4.3" @@ -344,18 +342,23 @@ name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "ahash", - "allocator-api2", + "foldhash", ] [[package]] name = "hashlink" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692eaaf7f7607518dd3cef090f1474b61edc5301d8012f09579920df68b725ee" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] [[package]] @@ -409,7 +412,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.3", ] [[package]] @@ -454,9 +457,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.28.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" dependencies = [ "cc", "pkg-config", @@ -525,7 +528,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.9.4", "cfg-if", "cfg_aliases", "libc", @@ -691,7 +694,7 @@ dependencies = [ [[package]] name = "reedline" -version = "0.40.0" +version = "0.43.0" dependencies = [ "arboard", "chrono", @@ -779,11 +782,11 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.31.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.9.4", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -806,7 +809,7 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys", @@ -874,6 +877,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook" version = "0.3.17" @@ -1045,12 +1054,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version_check" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" - [[package]] name = "vte" version = "0.11.1" @@ -1151,7 +1154,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82fb96ee935c2cea6668ccb470fb7771f6215d1691746c2d896b447a00ad3f1f" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.9.4", "rustix", "wayland-backend", "wayland-scanner", @@ -1163,7 +1166,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.9.4", "wayland-backend", "wayland-client", "wayland-scanner", @@ -1175,7 +1178,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.9.4", "wayland-backend", "wayland-client", "wayland-protocols", @@ -1409,23 +1412,3 @@ name = "yansi" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" - -[[package]] -name = "zerocopy" -version = "0.7.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/Cargo.toml b/Cargo.toml index b2167e8bc..b20c4999d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ license = "MIT" name = "reedline" repository = "https://github.com/nushell/reedline" rust-version = "1.63.0" -version = "0.40.0" +version = "0.43.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] @@ -25,7 +25,7 @@ crossterm = { version = "0.28.1", features = ["serde"] } fd-lock = "4.0.2" itertools = "0.13.0" nu-ansi-term = "0.50.0" -rusqlite = { version = "0.31.0", optional = true } +rusqlite = { version = "0.37.0", optional = true } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0.79", optional = true } strip-ansi-escapes = "0.2.0" diff --git a/README.md b/README.md index f60f3204d..c2dc74551 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Reedline::create().with_highlighter(Box::new(ExampleHighlighter::new(commands))) ```rust // Create a reedline object with tab completions support -use reedline::{default_emacs_keybindings, ColumnarMenu, DefaultCompleter, Emacs, KeyCode, KeyModifiers, Reedline, ReedlineEvent, ReedlineMenu}; +use reedline::{default_emacs_keybindings, ColumnarMenu, DefaultCompleter, Emacs, KeyCode, KeyModifiers, Reedline, ReedlineEvent, ReedlineMenu, MenuBuilder}; let commands = vec![ "test".into(), diff --git a/examples/custom_prompt.rs b/examples/custom_prompt.rs index fc9b1034a..4ca3d662c 100644 --- a/examples/custom_prompt.rs +++ b/examples/custom_prompt.rs @@ -16,13 +16,13 @@ use std::{borrow::Cow, cell::Cell, io}; pub struct CustomPrompt(Cell, &'static str); pub static DEFAULT_MULTILINE_INDICATOR: &str = "::: "; impl Prompt for CustomPrompt { - fn render_prompt_left(&self) -> Cow { + fn render_prompt_left(&self) -> Cow<'_, str> { { Cow::Owned(self.1.to_string()) } } - fn render_prompt_right(&self) -> Cow { + fn render_prompt_right(&self) -> Cow<'_, str> { { let old = self.0.get(); self.0.set(old + 1); @@ -30,18 +30,18 @@ impl Prompt for CustomPrompt { } } - fn render_prompt_indicator(&self, _edit_mode: PromptEditMode) -> Cow { + fn render_prompt_indicator(&self, _edit_mode: PromptEditMode) -> Cow<'_, str> { Cow::Owned(">".to_string()) } - fn render_prompt_multiline_indicator(&self) -> Cow { + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { Cow::Borrowed(DEFAULT_MULTILINE_INDICATOR) } fn render_prompt_history_search_indicator( &self, history_search: PromptHistorySearch, - ) -> Cow { + ) -> Cow<'_, str> { let prefix = match history_search.status { PromptHistorySearchStatus::Passing => "", PromptHistorySearchStatus::Failing => "failing ", diff --git a/examples/transient_prompt.rs b/examples/transient_prompt.rs index d3aaa3900..80abd8ea5 100644 --- a/examples/transient_prompt.rs +++ b/examples/transient_prompt.rs @@ -24,26 +24,26 @@ pub static TRANSIENT_PROMPT: &str = "! "; pub static TRANSIENT_MULTILINE_INDICATOR: &str = ": "; impl Prompt for TransientPrompt { - fn render_prompt_left(&self) -> Cow { + fn render_prompt_left(&self) -> Cow<'_, str> { Cow::Owned(String::new()) } - fn render_prompt_right(&self) -> Cow { + fn render_prompt_right(&self) -> Cow<'_, str> { Cow::Owned(String::new()) } - fn render_prompt_indicator(&self, _prompt_mode: PromptEditMode) -> Cow { + fn render_prompt_indicator(&self, _prompt_mode: PromptEditMode) -> Cow<'_, str> { Cow::Borrowed(TRANSIENT_PROMPT) } - fn render_prompt_multiline_indicator(&self) -> Cow { + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { Cow::Borrowed(TRANSIENT_MULTILINE_INDICATOR) } fn render_prompt_history_search_indicator( &self, history_search: PromptHistorySearch, - ) -> Cow { + ) -> Cow<'_, str> { let prefix = match history_search.status { PromptHistorySearchStatus::Passing => "", PromptHistorySearchStatus::Failing => "failing ", diff --git a/src/completion/base.rs b/src/completion/base.rs index 909c467be..bfbec1180 100644 --- a/src/completion/base.rs +++ b/src/completion/base.rs @@ -90,4 +90,7 @@ pub struct Suggestion { /// Whether to append a space after selecting this suggestion. /// This helps to avoid that a completer repeats the complete suggestion. pub append_whitespace: bool, + /// Indices of the graphemes in the suggestion that matched the typed text. + /// Useful if using fuzzy matching. + pub match_indices: Option>, } diff --git a/src/completion/default.rs b/src/completion/default.rs index 882debb6b..a4f4d6073 100644 --- a/src/completion/default.rs +++ b/src/completion/default.rs @@ -55,17 +55,17 @@ impl Completer for DefaultCompleter { /// assert_eq!( /// completions.complete("bat",3), /// vec![ - /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, - /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, - /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, + /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, ..Default::default()}, + /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, ..Default::default()}, + /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, ..Default::default()}, /// ]); /// /// assert_eq!( /// completions.complete("to the\r\nbat",11), /// vec![ - /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false}, - /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false}, - /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false}, + /// Suggestion {value: "batcave".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, ..Default::default()}, + /// Suggestion {value: "batman".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, ..Default::default()}, + /// Suggestion {value: "batmobile".into(), description: None, style: None, extra: None, span: Span { start: 8, end: 11 }, append_whitespace: false, ..Default::default()}, /// ]); /// ``` fn complete(&mut self, line: &str, pos: usize) -> Vec { @@ -110,6 +110,7 @@ impl Completer for DefaultCompleter { extra: None, span, append_whitespace: false, + ..Default::default() } }) .filter(|t| t.value.len() > (t.span.end - t.span.start)) @@ -182,15 +183,15 @@ impl DefaultCompleter { /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( /// completions.complete("te",2), - /// vec![Suggestion {value: "test".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}]); + /// vec![Suggestion {value: "test".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}]); /// /// let mut completions = DefaultCompleter::with_inclusions(&['-', '_']); /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); /// assert_eq!( /// completions.complete("te",2), /// vec![ - /// Suggestion {value: "test-hyphen".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}, - /// Suggestion {value: "test_underscore".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}, + /// Suggestion {value: "test-hyphen".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}, + /// Suggestion {value: "test_underscore".into(), description: None, style: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false, ..Default::default()}, /// ]); /// ``` pub fn with_inclusions(incl: &[char]) -> Self { @@ -384,6 +385,7 @@ mod tests { extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, + ..Default::default() }, Suggestion { value: "number".into(), @@ -392,6 +394,7 @@ mod tests { extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, + ..Default::default() }, Suggestion { value: "nushell".into(), @@ -400,6 +403,7 @@ mod tests { extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false, + ..Default::default() }, ] ); @@ -428,6 +432,7 @@ mod tests { extra: None, span: Span { start: 8, end: 9 }, append_whitespace: false, + ..Default::default() }, Suggestion { value: "this is the reedline crate".into(), @@ -436,6 +441,7 @@ mod tests { extra: None, span: Span { start: 8, end: 9 }, append_whitespace: false, + ..Default::default() }, Suggestion { value: "this is the reedline crate".into(), @@ -444,6 +450,7 @@ mod tests { extra: None, span: Span { start: 0, end: 9 }, append_whitespace: false, + ..Default::default() }, ] ); diff --git a/src/completion/history.rs b/src/completion/history.rs index a29e2b047..a48cffca0 100644 --- a/src/completion/history.rs +++ b/src/completion/history.rs @@ -65,6 +65,7 @@ impl<'menu> HistoryCompleter<'menu> { extra: None, span, append_whitespace: false, + ..Default::default() } } } diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index 1df298df7..e43d27f22 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -1,9 +1,10 @@ use super::{edit_stack::EditStack, Clipboard, ClipboardMode, LineBuffer}; #[cfg(feature = "system_clipboard")] use crate::core_editor::get_system_clipboard; -use crate::enums::{EditType, UndoBehavior}; +use crate::enums::{EditType, TextObject, TextObjectScope, TextObjectType, UndoBehavior}; +use crate::prompt::{PromptEditMode, PromptViMode}; use crate::{core_editor::get_local_clipboard, EditCommand}; -use std::ops::DerefMut; +use std::ops::{DerefMut, Range}; /// Stateful editor executing changes to the underlying [`LineBuffer`] /// @@ -17,6 +18,8 @@ pub struct Editor { edit_stack: EditStack, last_undo_behavior: UndoBehavior, selection_anchor: Option, + selection_mode: Option, + edit_mode: PromptEditMode, } impl Default for Editor { @@ -29,6 +32,8 @@ impl Default for Editor { edit_stack: EditStack::new(), last_undo_behavior: UndoBehavior::CreateUndoPoint, selection_anchor: None, + selection_mode: None, + edit_mode: PromptEditMode::Default, } } } @@ -171,11 +176,15 @@ impl Editor { EditCommand::CopySelectionSystem => self.copy_selection_to_system(), #[cfg(feature = "system_clipboard")] EditCommand::PasteSystem => self.paste_from_system(), - EditCommand::CutInside { left, right } => self.cut_inside(*left, *right), - EditCommand::YankInside { left, right } => self.yank_inside(*left, *right), + EditCommand::CutInsidePair { left, right } => self.cut_inside_pair(*left, *right), + EditCommand::CopyInsidePair { left, right } => self.copy_inside_pair(*left, *right), + EditCommand::CutAroundPair { left, right } => self.cut_around_pair(*left, *right), + EditCommand::CopyAroundPair { left, right } => self.copy_around_pair(*left, *right), + EditCommand::CutTextObject { text_object } => self.cut_text_object(*text_object), + EditCommand::CopyTextObject { text_object } => self.copy_text_object(*text_object), } if !matches!(command.edit_type(), EditType::MoveCursor { select: true }) { - self.selection_anchor = None; + self.clear_selection(); } if let EditType::MoveCursor { select: true } = command.edit_type() {} @@ -204,13 +213,25 @@ impl Editor { } } + pub(crate) fn clear_selection(&mut self) { + self.selection_anchor = None; + self.selection_mode = None; + } + fn update_selection_anchor(&mut self, select: bool) { - self.selection_anchor = if select { - self.selection_anchor - .or_else(|| Some(self.insertion_point())) + if select { + if self.selection_anchor.is_none() { + self.selection_anchor = Some(self.insertion_point()); + self.selection_mode = Some(self.edit_mode.clone()); + } } else { - None - }; + self.clear_selection(); + } + } + + /// Set the current edit mode + pub fn set_edit_mode(&mut self, mode: PromptEditMode) { + self.edit_mode = mode; } fn move_to_position(&mut self, position: usize, select: bool) { self.update_selection_anchor(select); @@ -374,94 +395,47 @@ impl Editor { fn cut_word_left(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let left_index = self.line_buffer.word_left_index(); - if left_index < insertion_offset { - let cut_range = left_index..insertion_offset; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - self.line_buffer.set_insertion_point(left_index); - } + let word_start = self.line_buffer.word_left_index(); + self.cut_range(word_start..insertion_offset); } fn cut_big_word_left(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let left_index = self.line_buffer.big_word_left_index(); - if left_index < insertion_offset { - let cut_range = left_index..insertion_offset; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - self.line_buffer.set_insertion_point(left_index); - } + let big_word_start = self.line_buffer.big_word_left_index(); + self.cut_range(big_word_start..insertion_offset); } fn cut_word_right(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.word_right_index(); - if right_index > insertion_offset { - let cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let word_end = self.line_buffer.word_right_index(); + self.cut_range(insertion_offset..word_end); } fn cut_big_word_right(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.next_whitespace(); - if right_index > insertion_offset { - let cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let big_word_end = self.line_buffer.next_whitespace(); + self.cut_range(insertion_offset..big_word_end); } fn cut_word_right_to_next(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.word_right_start_index(); - if right_index > insertion_offset { - let cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let next_word_start = self.line_buffer.word_right_start_index(); + self.cut_range(insertion_offset..next_word_start); } fn cut_big_word_right_to_next(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.big_word_right_start_index(); - if right_index > insertion_offset { - let cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); - } + let next_big_word_start = self.line_buffer.big_word_right_start_index(); + self.cut_range(insertion_offset..next_big_word_start); } fn cut_char(&mut self) { - let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.grapheme_right_index(); - if right_index > insertion_offset { - let cut_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[cut_range.clone()], - ClipboardMode::Normal, - ); - self.line_buffer.clear_range(cut_range); + if self.selection_anchor.is_some() { + self.cut_selection_to_cut_buffer(); + } else { + let insertion_offset = self.line_buffer.insertion_point(); + let next_char = self.line_buffer.grapheme_right_index(); + self.cut_range(insertion_offset..next_char); } } @@ -594,17 +568,15 @@ impl Editor { if let Some((start, end)) = self.get_selection() { let cut_slice = &self.line_buffer.get_buffer()[start..end]; self.system_clipboard.set(cut_slice, ClipboardMode::Normal); - self.line_buffer.clear_range_safe(start, end); - self.selection_anchor = None; + self.cut_range(start..end); + self.clear_selection(); } } fn cut_selection_to_cut_buffer(&mut self) { if let Some((start, end)) = self.get_selection() { - let cut_slice = &self.line_buffer.get_buffer()[start..end]; - self.cut_buffer.set(cut_slice, ClipboardMode::Normal); - self.line_buffer.clear_range_safe(start, end); - self.selection_anchor = None; + self.cut_range(start..end); + self.clear_selection(); } } @@ -626,28 +598,45 @@ impl Editor { /// If a selection is active returns the selected range, otherwise None. /// The range is guaranteed to be ascending. pub fn get_selection(&self) -> Option<(usize, usize)> { - self.selection_anchor.map(|selection_anchor| { - let buffer_len = self.line_buffer.len(); - if self.insertion_point() > selection_anchor { - ( - selection_anchor, - self.line_buffer.grapheme_right_index().min(buffer_len), - ) + let selection_anchor = self.selection_anchor?; + + // Use the mode that was active when the selection was created, not the current mode + let inclusive = matches!( + self.selection_mode.as_ref().unwrap_or(&self.edit_mode), + PromptEditMode::Vi(PromptViMode::Normal) + ); + + let selection_is_from_left_to_right = selection_anchor < self.insertion_point(); + + let start_pos = if selection_is_from_left_to_right { + selection_anchor + } else { + self.insertion_point() + }; + + let end_pos = if selection_is_from_left_to_right { + if inclusive { + self.line_buffer.grapheme_right_index() } else { - ( - self.insertion_point(), - self.line_buffer - .grapheme_right_index_from_pos(selection_anchor) - .min(buffer_len), - ) + self.insertion_point() } - }) + } else { + // selection is from right to left + if inclusive { + self.line_buffer + .grapheme_right_index_from_pos(selection_anchor) + } else { + selection_anchor + } + }; + + Some((start_pos, end_pos.min(self.line_buffer.len()))) } fn delete_selection(&mut self) { if let Some((start, end)) = self.get_selection() { - self.line_buffer.clear_range_safe(start, end); - self.selection_anchor = None; + self.line_buffer.clear_range_safe(start..end); + self.clear_selection(); } } @@ -721,32 +710,159 @@ impl Editor { insert_clipboard_content_before(&mut self.line_buffer, self.cut_buffer.deref_mut()); } - pub(crate) fn reset_selection(&mut self) { - self.selection_anchor = None; + fn cut_range(&mut self, range: Range) { + if range.start <= range.end { + self.copy_range(range.clone()); + self.line_buffer.clear_range_safe(range.clone()); + self.line_buffer.set_insertion_point(range.start); + } } - /// Delete text strictly between matching `left_char` and `right_char`. - /// Places deleted text into the cut buffer. - /// Leaves the parentheses/quotes/etc. themselves. - /// On success, move the cursor just after the `left_char`. - /// If matching chars can't be found, restore the original cursor. - pub(crate) fn cut_inside(&mut self, left_char: char, right_char: char) { - let buffer_len = self.line_buffer.len(); + fn copy_range(&mut self, range: Range) { + if range.start < range.end { + let slice = &self.line_buffer.get_buffer()[range]; + self.cut_buffer.set(slice, ClipboardMode::Normal); + } + } - if let Some((lp, rp)) = - self.line_buffer - .find_matching_pair(left_char, right_char, self.insertion_point()) + /// Delete text strictly between matching `open_char` and `close_char`. + fn cut_inside_pair(&mut self, open_char: char, close_char: char) { + if let Some(range) = self + .line_buffer + .range_inside_current_pair(open_char, close_char) + .or_else(|| { + self.line_buffer + .range_inside_next_pair(open_char, close_char) + }) { - let inside_start = lp + left_char.len_utf8(); - if inside_start < rp && rp <= buffer_len { - let inside_slice = &self.line_buffer.get_buffer()[inside_start..rp]; - if !inside_slice.is_empty() { - self.cut_buffer.set(inside_slice, ClipboardMode::Normal); - self.line_buffer.clear_range_safe(inside_start, rp); + self.cut_range(range) + } + } + + /// Return the range of the word under the cursor. + /// A word consists of a sequence of letters, digits and underscores, + /// separated with white space. + /// A block of whitespace under the cursor is also treated as a word. + /// + /// `text_object_scope` Inner includes only the word itself + /// while Around also includes trailing whitespace, + /// or preceding whitespace if there is no trailing whitespace. + fn word_text_object_range(&self, text_object_scope: TextObjectScope) -> Range { + self.line_buffer + .current_whitespace_range() + .unwrap_or_else(|| { + let word_range = self.line_buffer.current_word_range(); + match text_object_scope { + TextObjectScope::Inner => word_range, + TextObjectScope::Around => { + self.line_buffer.expand_range_with_whitespace(word_range) + } + } + }) + } + + /// Return the range of the WORD under the cursor. + /// A WORD consists of a sequence of non-blank characters, separated with white space. + /// A block of whitespace under the cursor is also treated as a word. + /// + /// `text_object_scope` Inner includes only the word itself + /// while Around also includes trailing whitespace, + /// or preceding whitespace if there is no trailing whitespace. + fn big_word_text_object_range(&self, text_object_scope: TextObjectScope) -> Range { + self.line_buffer + .current_whitespace_range() + .unwrap_or_else(|| { + let big_word_range = self.line_buffer.current_big_word_range(); + match text_object_scope { + TextObjectScope::Inner => big_word_range, + TextObjectScope::Around => self + .line_buffer + .expand_range_with_whitespace(big_word_range), } + }) + } + + /// Returns `Some(Range)` for range inside the character pair in `pair_group` + /// at or surrounding the cursor, the next pair if no pairs in `pair_group` + /// surround the cursor, or `None` if there are no pairs from `pair_group` found. + /// + /// `text_object_scope` [`TextObjectScope::Inner`] includes only the range inside the pair + /// whereas [`TextObjectScope::Around`] also includes the surrounding pair characters + /// + /// If multiple pair types exist, returns the innermost pair that surrounds + /// the cursor. Handles empty pair as zero-length ranges inside pair. + /// For asymmetric pairs like `(` `)` the search is multi-line, however, + /// for symmetric pairs like `"` `"` the search is restricted to the current line. + fn matching_pair_group_text_object_range( + &self, + text_object_scope: TextObjectScope, + matching_pair_group: &[(char, char)], + ) -> Option> { + self.line_buffer + .range_inside_current_pair_in_group(matching_pair_group) + .or_else(|| { self.line_buffer - .set_insertion_point(lp + left_char.len_utf8()); - } + .range_inside_next_pair_in_group(matching_pair_group) + }) + .and_then(|pair_range| match text_object_scope { + TextObjectScope::Inner => Some(pair_range), + TextObjectScope::Around => self.expand_range_to_include_pair(pair_range), + }) + } + + /// Returns `Some(Range)` for range inside brackets (`()`, `[]`, `{}`) + /// at or surrounding the cursor, the next pair of brackets if no brackets + /// surround the cursor, or `None` if there are no brackets found. + /// + /// `text_object_scope` [`TextObjectScope::Inner`] includes only the range inside the pair + /// whereas [`TextObjectScope::Around`] also includes the surrounding pair characters + /// + /// If multiple bracket types exist, returns the innermost pair that surrounds + /// the cursor. Handles empty brackets as zero-length ranges inside brackets. + /// Includes brackets that span multiple lines. + fn bracket_text_object_range( + &self, + text_object_scope: TextObjectScope, + ) -> Option> { + const BRACKET_PAIRS: &[(char, char)] = &[('(', ')'), ('[', ']'), ('{', '}')]; + self.matching_pair_group_text_object_range(text_object_scope, BRACKET_PAIRS) + } + + /// Returns `Some(Range)` for the range inside quotes (`""`, `''` or `\`\`\`) + /// at the cursor, the next pair of quotes if the cursor is not within quotes, + /// or `None` if there are no quotes found. + /// + /// Quotes are restricted to the current line. + /// + /// `text_object_scope` [`TextObjectScope::Inner`] includes only the range inside the pair + /// whereas [`TextObjectScope::Around`] also includes the surrounding pair characters + /// + /// If multiple quote types exist, returns the innermost pair that surrounds + /// the cursor. Handles empty quotes as zero-length ranges inside quote. + fn quote_text_object_range(&self, text_object_scope: TextObjectScope) -> Option> { + const QUOTE_PAIRS: &[(char, char)] = &[('"', '"'), ('\'', '\''), ('`', '`')]; + self.matching_pair_group_text_object_range(text_object_scope, QUOTE_PAIRS) + } + + /// Get the bounds for a text object operation + fn text_object_range(&self, text_object: TextObject) -> Option> { + match text_object.object_type { + TextObjectType::Word => Some(self.word_text_object_range(text_object.scope)), + TextObjectType::BigWord => Some(self.big_word_text_object_range(text_object.scope)), + TextObjectType::Brackets => self.bracket_text_object_range(text_object.scope), + TextObjectType::Quote => self.quote_text_object_range(text_object.scope), + } + } + + fn cut_text_object(&mut self, text_object: TextObject) { + if let Some(range) = self.text_object_range(text_object) { + self.cut_range(range); + } + } + + fn copy_text_object(&mut self, text_object: TextObject) { + if let Some(range) = self.text_object_range(text_object) { + self.copy_range(range); } } @@ -770,143 +886,122 @@ impl Editor { start }; let copy_range = start_offset..previous_offset; - let copy_slice = &self.line_buffer.get_buffer()[copy_range]; - if !copy_slice.is_empty() { - self.cut_buffer.set(copy_slice, ClipboardMode::Normal); - } + self.copy_range(copy_range); } pub(crate) fn copy_from_end(&mut self) { - let copy_slice = &self.line_buffer.get_buffer()[self.line_buffer.insertion_point()..]; - if !copy_slice.is_empty() { - self.cut_buffer.set(copy_slice, ClipboardMode::Normal); - } + let copy_range = self.line_buffer.insertion_point()..self.line_buffer.len(); + self.copy_range(copy_range); } pub(crate) fn copy_to_line_end(&mut self) { - let copy_slice = &self.line_buffer.get_buffer() - [self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end()]; - if !copy_slice.is_empty() { - self.cut_buffer.set(copy_slice, ClipboardMode::Normal); - } + let copy_range = + self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end(); + self.copy_range(copy_range); } pub(crate) fn copy_word_left(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let left_index = self.line_buffer.word_left_index(); - if left_index < insertion_offset { - let copy_range = left_index..insertion_offset; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let word_start = self.line_buffer.word_left_index(); + self.copy_range(word_start..insertion_offset); } pub(crate) fn copy_big_word_left(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let left_index = self.line_buffer.big_word_left_index(); - if left_index < insertion_offset { - let copy_range = left_index..insertion_offset; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let big_word_start = self.line_buffer.big_word_left_index(); + self.copy_range(big_word_start..insertion_offset); } pub(crate) fn copy_word_right(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.word_right_index(); - if right_index > insertion_offset { - let copy_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let word_end = self.line_buffer.word_right_index(); + self.copy_range(insertion_offset..word_end); } pub(crate) fn copy_big_word_right(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.next_whitespace(); - if right_index > insertion_offset { - let copy_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let big_word_end = self.line_buffer.next_whitespace(); + self.copy_range(insertion_offset..big_word_end); } pub(crate) fn copy_word_right_to_next(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.word_right_start_index(); - if right_index > insertion_offset { - let copy_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let next_word_start = self.line_buffer.word_right_start_index(); + self.copy_range(insertion_offset..next_word_start); } pub(crate) fn copy_big_word_right_to_next(&mut self) { let insertion_offset = self.line_buffer.insertion_point(); - let right_index = self.line_buffer.big_word_right_start_index(); - if right_index > insertion_offset { - let copy_range = insertion_offset..right_index; - self.cut_buffer.set( - &self.line_buffer.get_buffer()[copy_range], - ClipboardMode::Normal, - ); - } + let next_big_word_start = self.line_buffer.big_word_right_start_index(); + self.copy_range(insertion_offset..next_big_word_start); } pub(crate) fn copy_right_until_char(&mut self, c: char, before_char: bool, current_line: bool) { if let Some(index) = self.line_buffer.find_char_right(c, current_line) { let extra = if before_char { 0 } else { c.len_utf8() }; - let copy_slice = - &self.line_buffer.get_buffer()[self.line_buffer.insertion_point()..index + extra]; - if !copy_slice.is_empty() { - self.cut_buffer.set(copy_slice, ClipboardMode::Normal); - } + let copy_range = self.line_buffer.insertion_point()..index + extra; + self.copy_range(copy_range); } } pub(crate) fn copy_left_until_char(&mut self, c: char, before_char: bool, current_line: bool) { if let Some(index) = self.line_buffer.find_char_left(c, current_line) { let extra = if before_char { c.len_utf8() } else { 0 }; - let copy_slice = - &self.line_buffer.get_buffer()[index + extra..self.line_buffer.insertion_point()]; - if !copy_slice.is_empty() { - self.cut_buffer.set(copy_slice, ClipboardMode::Normal); - } + let copy_range = index + extra..self.line_buffer.insertion_point(); + self.copy_range(copy_range); } } - /// Yank text strictly between matching `left_char` and `right_char`. - /// Copies it into the cut buffer without removing anything. - /// Leaves the buffer unchanged and restores the original cursor. - pub(crate) fn yank_inside(&mut self, left_char: char, right_char: char) { - let old_pos = self.insertion_point(); - let buffer_len = self.line_buffer.len(); + /// Copy text strictly between matching `open_char` and `close_char`. + fn copy_inside_pair(&mut self, open_char: char, close_char: char) { + if let Some(range) = self + .line_buffer + .range_inside_current_pair(open_char, close_char) + .or_else(|| { + self.line_buffer + .range_inside_next_pair(open_char, close_char) + }) + { + self.copy_range(range); + } + } - if let Some((lp, rp)) = - self.line_buffer - .find_matching_pair(left_char, right_char, self.insertion_point()) + /// Expand the range to include `open_char` and `close_char` + fn expand_range_to_include_pair(&self, range: Range) -> Option> { + let start = self.line_buffer.grapheme_left_index_from_pos(range.start); + let end = self.line_buffer.grapheme_right_index_from_pos(range.end); + + Some(start..end) + } + + /// Delete text around matching `open_char` and `close_char` (including the pair characters). + fn cut_around_pair(&mut self, open_char: char, close_char: char) { + if let Some(around_range) = self + .line_buffer + .range_inside_current_pair(open_char, close_char) + .or_else(|| { + self.line_buffer + .range_inside_next_pair(open_char, close_char) + }) + .and_then(|range| self.expand_range_to_include_pair(range)) { - let inside_start = lp + left_char.len_utf8(); - if inside_start < rp && rp <= buffer_len { - let inside_slice = &self.line_buffer.get_buffer()[inside_start..rp]; - if !inside_slice.is_empty() { - self.cut_buffer.set(inside_slice, ClipboardMode::Normal); - } - } + self.cut_range(around_range); } + } - // Always restore the cursor position - self.line_buffer.set_insertion_point(old_pos); + /// Copy text around matching `open_char` and `close_char` (including the pair characters). + fn copy_around_pair(&mut self, open_char: char, close_char: char) { + if let Some(around_range) = self + .line_buffer + .range_inside_current_pair(open_char, close_char) + .or_else(|| { + self.line_buffer + .range_inside_next_pair(open_char, close_char) + }) + .and_then(|range| self.expand_range_to_include_pair(range)) + { + self.copy_range(around_range); + } } } @@ -1148,19 +1243,127 @@ mod test { } assert_eq!(editor.selection_anchor, Some(0)); assert_eq!(editor.insertion_point(), 3); - assert_eq!(editor.get_selection(), Some((0, 4))); + assert_eq!(editor.get_selection(), Some((0, 3))); editor.run_edit_command(&EditCommand::SwapCursorAndAnchor); assert_eq!(editor.selection_anchor, Some(3)); assert_eq!(editor.insertion_point(), 0); - assert_eq!(editor.get_selection(), Some((0, 4))); + assert_eq!(editor.get_selection(), Some((0, 3))); editor.run_edit_command(&EditCommand::SwapCursorAndAnchor); assert_eq!(editor.selection_anchor, Some(0)); assert_eq!(editor.insertion_point(), 3); + assert_eq!(editor.get_selection(), Some((0, 3))); + } + + #[test] + fn test_vi_normal_mode_inclusive_selection() { + let mut editor = editor_with("This is some test content"); + editor.line_buffer.set_insertion_point(0); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.update_selection_anchor(true); + + for _ in 0..3 { + editor.run_edit_command(&EditCommand::MoveRight { select: true }); + } + assert_eq!(editor.selection_anchor, Some(0)); + assert_eq!(editor.insertion_point(), 3); + // In Vi normal mode, selection should be inclusive (include character at position 3) assert_eq!(editor.get_selection(), Some((0, 4))); } + #[test] + fn test_vi_normal_mode_inclusive_selection_backward() { + let mut editor = editor_with("This is some test content"); + editor.line_buffer.set_insertion_point(4); // Start at position 4 ('i') + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.update_selection_anchor(true); + + for _ in 0..3 { + editor.run_edit_command(&EditCommand::MoveLeft { select: true }); + } + assert_eq!(editor.selection_anchor, Some(4)); + assert_eq!(editor.insertion_point(), 1); // cursor at position 1 ('h') + // In Vi normal mode, selection should be inclusive from cursor to anchor+1 + // So it should select from position 1 to 5 (inclusive of char at position 4) + assert_eq!(editor.get_selection(), Some((1, 5))); + } + + #[test] + fn test_vi_normal_mode_cut_selection_backward() { + let mut editor = editor_with("This is some test content"); + + editor.line_buffer.set_insertion_point(4); // Start at position 4 (' ') + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + editor.update_selection_anchor(true); + + for _ in 0..3 { + editor.run_edit_command(&EditCommand::MoveLeft { select: true }); + } + + // Should select "his " (from position 1 to 5, inclusive of char at position 4) + assert_eq!(editor.get_selection(), Some((1, 5))); + + editor.run_edit_command(&EditCommand::CutSelection); + + // After cutting, should have "Tis some test content" (removed "his ") + assert_eq!(editor.get_buffer(), "Tis some test content"); + assert_eq!(editor.insertion_point(), 1); // cursor should be at start of cut + } + + #[test] + fn test_vi_visual_mode_c_command() { + // Test the exact scenario: select in visual mode, then press 'c' + let mut editor = editor_with("hello world"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + + // Start at position 0, enter visual mode by selecting + editor.line_buffer.set_insertion_point(0); + editor.update_selection_anchor(true); + + // Move right 4 characters to select "hello" (from pos 0 to pos 4) + for _ in 0..4 { + editor.run_edit_command(&EditCommand::MoveRight { select: true }); + } + + // In vi normal mode, this should be inclusive selection + // So we should select "hello" (positions 0-4, inclusive of position 4) + assert_eq!(editor.get_selection(), Some((0, 5))); // should include character at position 4 + + // Now simulate pressing 'c' - this should cut the selection + editor.run_edit_command(&EditCommand::CutSelection); + + // Should have " world" left (removed "hello") + assert_eq!(editor.get_buffer(), " world"); + assert_eq!(editor.insertion_point(), 0); + } + + #[test] + fn test_vi_normal_mode_c_command_with_selection() { + // Test the exact issue: c command in vi normal mode with selection + let mut editor = editor_with("hello world"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + + // Start at position 0, create selection by moving cursor + editor.line_buffer.set_insertion_point(0); + editor.update_selection_anchor(true); + + // Move right to select "hello" (positions 0-4, should be inclusive of pos 4) + for _ in 0..4 { + editor.run_edit_command(&EditCommand::MoveRight { select: true }); + } + + // In vi normal mode, selection should include character at cursor position + assert_eq!(editor.get_selection(), Some((0, 5))); // inclusive selection + + // Now simulate pressing 'c' - this should cut the selection + editor.run_edit_command(&EditCommand::CutSelection); + + // Should have " world" left (removed "hello" including the 'o') + assert_eq!(editor.get_buffer(), " world"); + assert_eq!(editor.insertion_point(), 0); + } + #[cfg(feature = "system_clipboard")] mod without_system_clipboard { use super::*; @@ -1188,7 +1391,7 @@ mod test { fn test_cut_inside_brackets() { let mut editor = editor_with("foo(bar)baz"); editor.move_to_position(5, false); // Move inside brackets - editor.cut_inside('(', ')'); + editor.cut_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo()baz"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, "bar"); @@ -1196,15 +1399,15 @@ mod test { // Test with cursor outside brackets let mut editor = editor_with("foo(bar)baz"); editor.move_to_position(0, false); - editor.cut_inside('(', ')'); - assert_eq!(editor.get_buffer(), "foo(bar)baz"); - assert_eq!(editor.insertion_point(), 0); - assert_eq!(editor.cut_buffer.get().0, ""); + editor.cut_inside_pair('(', ')'); + assert_eq!(editor.get_buffer(), "foo()baz"); + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.cut_buffer.get().0, "bar"); // Test with no matching brackets let mut editor = editor_with("foo bar baz"); editor.move_to_position(4, false); - editor.cut_inside('(', ')'); + editor.cut_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo bar baz"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, ""); @@ -1214,7 +1417,7 @@ mod test { fn test_cut_inside_quotes() { let mut editor = editor_with("foo\"bar\"baz"); editor.move_to_position(5, false); // Move inside quotes - editor.cut_inside('"', '"'); + editor.cut_inside_pair('"', '"'); assert_eq!(editor.get_buffer(), "foo\"\"baz"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, "bar"); @@ -1222,15 +1425,15 @@ mod test { // Test with cursor outside quotes let mut editor = editor_with("foo\"bar\"baz"); editor.move_to_position(0, false); - editor.cut_inside('"', '"'); - assert_eq!(editor.get_buffer(), "foo\"bar\"baz"); - assert_eq!(editor.insertion_point(), 0); - assert_eq!(editor.cut_buffer.get().0, ""); + editor.cut_inside_pair('"', '"'); + assert_eq!(editor.get_buffer(), "foo\"\"baz"); + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.cut_buffer.get().0, "bar"); // Test with no matching quotes let mut editor = editor_with("foo bar baz"); editor.move_to_position(4, false); - editor.cut_inside('"', '"'); + editor.cut_inside_pair('"', '"'); assert_eq!(editor.get_buffer(), "foo bar baz"); assert_eq!(editor.insertion_point(), 4); } @@ -1239,13 +1442,13 @@ mod test { fn test_cut_inside_nested() { let mut editor = editor_with("foo(bar(baz)qux)quux"); editor.move_to_position(8, false); // Move inside inner brackets - editor.cut_inside('(', ')'); + editor.cut_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar()qux)quux"); assert_eq!(editor.insertion_point(), 8); assert_eq!(editor.cut_buffer.get().0, "baz"); editor.move_to_position(4, false); // Move inside outer brackets - editor.cut_inside('(', ')'); + editor.cut_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo()quux"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, "bar()qux"); @@ -1255,7 +1458,7 @@ mod test { fn test_yank_inside_brackets() { let mut editor = editor_with("foo(bar)baz"); editor.move_to_position(5, false); // Move inside brackets - editor.yank_inside('(', ')'); + editor.copy_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar)baz"); // Buffer shouldn't change assert_eq!(editor.insertion_point(), 5); // Cursor should return to original position @@ -1266,7 +1469,7 @@ mod test { // Test with cursor outside brackets let mut editor = editor_with("foo(bar)baz"); editor.move_to_position(0, false); - editor.yank_inside('(', ')'); + editor.copy_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar)baz"); assert_eq!(editor.insertion_point(), 0); } @@ -1275,7 +1478,7 @@ mod test { fn test_yank_inside_quotes() { let mut editor = editor_with("foo\"bar\"baz"); editor.move_to_position(5, false); // Move inside quotes - editor.yank_inside('"', '"'); + editor.copy_inside_pair('"', '"'); assert_eq!(editor.get_buffer(), "foo\"bar\"baz"); // Buffer shouldn't change assert_eq!(editor.insertion_point(), 5); // Cursor should return to original position assert_eq!(editor.cut_buffer.get().0, "bar"); @@ -1283,7 +1486,7 @@ mod test { // Test with no matching quotes let mut editor = editor_with("foo bar baz"); editor.move_to_position(4, false); - editor.yank_inside('"', '"'); + editor.copy_inside_pair('"', '"'); assert_eq!(editor.get_buffer(), "foo bar baz"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, ""); @@ -1293,7 +1496,7 @@ mod test { fn test_yank_inside_nested() { let mut editor = editor_with("foo(bar(baz)qux)quux"); editor.move_to_position(8, false); // Move inside inner brackets - editor.yank_inside('(', ')'); + editor.copy_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar(baz)qux)quux"); // Buffer shouldn't change assert_eq!(editor.insertion_point(), 8); assert_eq!(editor.cut_buffer.get().0, "baz"); @@ -1303,7 +1506,7 @@ mod test { assert_eq!(editor.get_buffer(), "foo(bar(bazbaz)qux)quux"); editor.move_to_position(4, false); // Move inside outer brackets - editor.yank_inside('(', ')'); + editor.copy_inside_pair('(', ')'); assert_eq!(editor.get_buffer(), "foo(bar(bazbaz)qux)quux"); assert_eq!(editor.insertion_point(), 4); assert_eq!(editor.cut_buffer.get().0, "bar(bazbaz)qux"); @@ -1363,4 +1566,480 @@ mod test { assert_eq!(editor.insertion_point(), 3); // Cursor should return to original position assert_eq!(editor.cut_buffer.get().0, "\r\n"); } + + #[test] + fn test_vi_normal_mode_shift_select_right_c_command() { + // Test vi normal mode inclusive selection with cut operation + let mut editor = editor_with("hello world"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + + editor.line_buffer.set_insertion_point(0); + editor.update_selection_anchor(true); + + for _ in 0..4 { + editor.run_edit_command(&EditCommand::MoveRight { select: true }); + } + + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.selection_anchor, Some(0)); + assert_eq!(editor.get_selection(), Some((0, 5))); // inclusive selection + + editor.run_edit_command(&EditCommand::CutSelection); + + assert_eq!(editor.get_buffer(), " world"); + assert_eq!(editor.insertion_point(), 0); + assert_eq!(editor.cut_buffer.get().0, "hello"); + } + + #[test] + fn test_vi_mode_selection_calculation_bug() { + // Test selection calculation preserves original mode after mode switch + let mut editor = editor_with("hello world"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + + editor.line_buffer.set_insertion_point(0); + editor.update_selection_anchor(true); + + for _ in 0..4 { + editor.run_edit_command(&EditCommand::MoveRight { select: true }); + } + + assert_eq!(editor.get_selection(), Some((0, 5))); // inclusive in normal mode + + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Insert)); + + assert_eq!(editor.get_selection(), Some((0, 5))); // still inclusive after mode switch + } + + #[test] + fn test_vi_c_command_mode_switch_bug_fix() { + // Test vi 'c' command selection behavior with mode switching + let mut editor = editor_with("hello world"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + + editor.line_buffer.set_insertion_point(0); + editor.update_selection_anchor(true); + + for _ in 0..4 { + editor.run_edit_command(&EditCommand::MoveRight { select: true }); + } + + assert_eq!(editor.get_selection(), Some((0, 5))); // inclusive selection + + // Simulate vi 'c' command: mode switches to insert then cuts selection + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Insert)); + editor.run_edit_command(&EditCommand::CutSelection); + + assert_eq!(editor.get_buffer(), " world"); + assert_eq!(editor.insertion_point(), 0); + assert_eq!(editor.cut_buffer.get().0, "hello"); + } + + #[test] + fn test_vi_x_command_with_shift_selection() { + // Test that 'x' (cut char) works with shift+selection in vi normal mode + let mut editor = editor_with("hello world"); + editor.set_edit_mode(PromptEditMode::Vi(PromptViMode::Normal)); + + editor.line_buffer.set_insertion_point(0); + editor.update_selection_anchor(true); + + for _ in 0..4 { + editor.run_edit_command(&EditCommand::MoveRight { select: true }); + } + + assert_eq!(editor.get_selection(), Some((0, 5))); // inclusive selection + + // Simulate vi 'x' command - should cut the selection, not just one character + editor.run_edit_command(&EditCommand::CutChar); + + assert_eq!(editor.get_buffer(), " world"); + assert_eq!(editor.insertion_point(), 0); + assert_eq!(editor.cut_buffer.get().0, "hello"); + } + + #[rstest] + #[case("hello world test", 7, "hello test", 6, "world")] // cursor inside word + #[case("hello world test", 6, "hello test", 6, "world")] // cursor at start of word + #[case("hello world test", 10, "hello test", 6, "world")] // cursor at end of word + fn test_cut_inside_word( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + #[case("hello world test", 7, "world")] // cursor inside word + #[case("hello world test", 6, "world")] // cursor at start of word + #[case("hello world test", 10, "world")] // cursor at end of word + fn test_yank_inside_word( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_yank: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.copy_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.get_buffer(), input); // Buffer shouldn't change + assert_eq!(editor.insertion_point(), cursor_pos); // Cursor should return to original position + assert_eq!(editor.cut_buffer.get().0, expected_yank); + } + + #[rstest] + #[case("hello world test", 7, "hello test", 6, "world ")] // word with following space + #[case("hello world", 7, "hello", 5, " world")] // word at end, gets preceding space + #[case("word test", 2, "test", 0, "word ")] // first word with following space + #[case("hello word", 7, "hello", 5, " word")] // last word gets preceding space + // Edge cases at end of string + #[case("word", 2, "", 0, "word")] // single word, no whitespace + #[case(" word", 2, "", 0, " word")] // word with only leading space + // Edge cases with punctuation boundaries + #[case("word.", 2, ".", 0, "word")] // word followed by punctuation + #[case(".word", 2, ".", 1, "word")] // word preceded by punctuation + #[case("(word)", 2, "()", 1, "word")] // word surrounded by punctuation + #[case("hello,world", 2, ",world", 0, "hello")] // word followed by punct+word + #[case("hello,world", 7, "hello,", 6, "world")] // word preceded by word+punct + fn test_cut_around_word( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Around, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + #[case("hello world test", 7, "world ")] // word with following space + #[case("hello world", 7, " world")] // word at end, gets preceding space + #[case("word test", 2, "word ")] // first word with following space + fn test_yank_around_word( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_yank: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.copy_text_object(TextObject { + scope: TextObjectScope::Around, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.get_buffer(), input); // Buffer shouldn't change + assert_eq!(editor.insertion_point(), cursor_pos); // Cursor should return to original position + assert_eq!(editor.cut_buffer.get().0, expected_yank); + } + + #[rstest] + #[case("hello big-word test", 10, "hello test", 6, "big-word")] // big word with punctuation + #[case("hello BIGWORD test", 10, "hello test", 6, "BIGWORD")] // simple big word + #[case("test@example.com file", 8, " file", 0, "test@example.com")] //cursor on email address + #[case("test@example.com file", 17, "test@example.com ", 17, "file")] // cursor at end of "file" + fn test_cut_inside_big_word( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::BigWord, + }); + + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + #[case("hello-world test", 2, "-world test", 0, "hello")] // cursor on "hello" + #[case("hello-world test", 5, "helloworld test", 5, "-")] // cursor on "-" + #[case("hello-world test", 8, "hello- test", 6, "world")] // cursor on "world" + #[case("a-b-c test", 0, "-b-c test", 0, "a")] // single char "a" + #[case("a-b-c test", 2, "a--c test", 2, "b")] // single char "b" + fn test_cut_inside_word_with_punctuation( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + #[case("hello-world test", 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "-world test", "hello")] // small word gets just "hello" + #[case("hello-world test", 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::BigWord }, " test", "hello-world")] // big word gets "hello-word" + #[case("test@example.com", 6, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "test@", "example.com")] // small word in email (UAX#29 extends across punct) + #[case("test@example.com", 6, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::BigWord }, "", "test@example.com")] // big word gets entire email + fn test_word_vs_big_word_comparison( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] text_object: TextObject, + #[case] expected_buffer: &str, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(text_object); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + // Test inside operations (iw) at word boundaries + #[case("hello world", 0, "hello")] // start of first word + #[case("hello world", 4, "hello")] // end of first word + #[case("hello world", 6, "world")] // start of second word + #[case("hello world", 10, "world")] // end of second word + // Test at exact word boundaries with punctuation + #[case("hello-world", 4, "hello")] // just before punctuation + #[case("hello-world", 5, "-")] // on punctuation + #[case("hello-world", 6, "world")] // just after punctuation + fn test_cut_inside_word_boundaries( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + // Test around operations (aw) at word boundaries + #[case("hello world", 0, "hello ")] // start of first word + #[case("hello world", 4, "hello ")] // end of first word + #[case("hello world", 6, " world")] // start of second word (gets preceding space) + #[case("hello world", 10, " world")] // end of second word + #[case("word", 0, "word")] // single word, no whitespace + #[case("word ", 0, "word ")] // word with trailing space + #[case(" word", 1, " word")] // word with leading space + fn test_cut_around_word_boundaries( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(TextObject { + scope: TextObjectScope::Around, + object_type: TextObjectType::Word, + }); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + fn test_cut_text_object_unicode_safety() { + let mut editor = editor_with("hello 🦀end"); + editor.move_to_position(10, false); // Position after the emoji + editor.move_to_position(6, false); // Move to the emoji + + editor.cut_text_object(TextObject { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + }); // Cut the emoji + + assert!(editor.line_buffer.is_valid()); // Should not panic or be invalid + } + + #[rstest] + // Test operations when cursor is IN WHITESPACE (middle of spaces) + #[case("hello world test", 5, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "helloworld test", 5, " ")] // single space + #[case("hello world", 6, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "helloworld", 5, " ")] // multiple spaces, cursor on second + #[case("hello world", 7, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "helloworld", 5, " ")] // multiple spaces, cursor on middle + #[case(" hello", 1, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "hello", 0, " ")] // leading spaces, cursor on middle + #[case("hello ", 7, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "hello", 5, " ")] // trailing spaces, cursor on middle + #[case("hello\tworld", 5, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "helloworld", 5, "\t")] // tab character + #[case("hello\nworld", 5, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "helloworld", 5, "\n")] // newline character + #[case("hello world test", 5, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::BigWord }, "helloworld test", 5, " ")] // single space (big word) + #[case("hello world", 6, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::BigWord }, "helloworld", 5, " ")] // multiple spaces (big word) + #[case(" ", 0, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "", 0, " ")] // only whitespace at start + #[case(" ", 1, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "", 0, " ")] // only whitespace at end + #[case("hello ", 5, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "hello", 5, " ")] // trailing whitespace at string end + #[case(" hello", 0, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Word }, "hello", 0, " ")] // leading whitespace at string start + fn test_text_object_in_whitespace( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] text_object: TextObject, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(text_object); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + // Test text object jumping behavior in various scenarios + // Cursor inside empty pairs should operate on current pair (cursor stays, nothing cut) + #[case(r#"foo()bar"#, 4, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Brackets }, "foo()bar", 4, "")] // inside empty brackets + #[case(r#"foo""bar"#, 4, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Quote }, "foo\"\"bar", 4, "")] // inside empty quotes + // Cursor outside pairs should jump to next pair (even if empty) + #[case(r#"foo ()bar"#, 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Brackets }, "foo ()bar", 5, "")] // jump to empty brackets + #[case(r#"foo ""bar"#, 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Quote }, "foo \"\"bar", 5, "")] // jump to empty quote + #[case(r#"foo (content)bar"#, 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Brackets }, "foo ()bar", 5, "content")] // jump to non-empty brackets + #[case(r#"foo "content"bar"#, 2, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Quote }, "foo \"\"bar", 5, "content")] // jump to non-empty quotes + // Cursor between pairs should jump to next pair + #[case(r#"(first) (second)"#, 8, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Brackets }, "(first) ()", 9, "second")] // between brackets + #[case(r#""first" "second""#, 8, TextObject { scope: TextObjectScope::Inner, object_type: TextObjectType::Quote }, "\"first\"\"second\"", 7, " ")] // between quotes + // Around scope should include the pair characters + #[case(r#"foo (bar)"#, 2, TextObject { scope: TextObjectScope::Around, object_type: TextObjectType::Brackets }, "foo ", 4, "(bar)")] // around includes parentheses + #[case(r#"foo "bar""#, 2, TextObject { scope: TextObjectScope::Around, object_type: TextObjectType::Quote }, "foo ", 4, "\"bar\"")] // around includes quotes + fn test_text_object_jumping_behavior( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] text_object: TextObject, + #[case] expected_buffer: &str, + #[case] expected_cursor: usize, + #[case] expected_cut: &str, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + editor.cut_text_object(text_object); + assert_eq!(editor.get_buffer(), expected_buffer); + assert_eq!(editor.insertion_point(), expected_cursor); + assert_eq!(editor.cut_buffer.get().0, expected_cut); + } + + #[rstest] + // Test bracket_text_object_range with Inner scope - just the content inside brackets + #[case("foo(bar)baz", 5, TextObjectScope::Inner, Some(4..7))] // cursor inside brackets + #[case("foo[bar]baz", 5, TextObjectScope::Inner, Some(4..7))] // square brackets + #[case("foo{bar}baz", 5, TextObjectScope::Inner, Some(4..7))] // square brackets + #[case("foo()bar", 4, TextObjectScope::Inner, Some(4..4))] // empty brackets + #[case("(nested[inner]outer)", 8, TextObjectScope::Inner, Some(8..13))] // nested, innermost + #[case("(nested[mixed{inner}brackets]outer)", 8, TextObjectScope::Inner, Some(8..28))] // nested, innermost + #[case("next(nested[mixed{inner}brackets]outer)", 0, TextObjectScope::Inner, Some(5..38))] // next nested mixed + #[case("foo (bar)baz", 0, TextObjectScope::Inner, Some(5..8))] // next pair from line start + #[case(" (bar)baz", 1, TextObjectScope::Inner, Some(5..8))] // next pair from whitespace + #[case("foo(bar)baz", 2, TextObjectScope::Inner, Some(4..7))] // next pair from word + #[case("foo(bar\nbaz)qux", 8, TextObjectScope::Inner, Some(4..11))] // multi-line brackets + #[case("foo\n(bar\nbaz)qux", 0, TextObjectScope::Inner, Some(5..12))] // next multi-line brackets + #[case("foo\n(bar\nbaz)qux", 3, TextObjectScope::Around, Some(4..13))] // next multi-line brackets + #[case("{hello}", 3, TextObjectScope::Around, Some(0..7))] // includes curly brackets + #[case("foo()bar", 4, TextObjectScope::Around, Some(3..5))] // around empty brackets + #[case("(nested(inner)outer)", 8, TextObjectScope::Around, Some(7..14))] // nested around includes delimiters + #[case("start(nested(inner)outer)", 2, TextObjectScope::Around, Some(5..25))] // Next outer nested pair + #[case("(mixed{nested)brackets", 1, TextObjectScope::Inner, Some(1..13))] // mixed nesting + #[case("(unclosed(nested)brackets", 1, TextObjectScope::Inner, Some(10..16))] // unclosed bracket, find next closed + #[case("no brackets here", 5, TextObjectScope::Inner, None)] // no brackets found + #[case("(unclosed", 1, TextObjectScope::Inner, None)] // unclosed bracket + #[case("(mismatched}", 1, TextObjectScope::Inner, None)] // mismatched brackets + fn test_bracket_text_object_range( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] scope: TextObjectScope, + #[case] expected: Option>, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + let result = editor.bracket_text_object_range(scope); + assert_eq!(result, expected); + } + + #[rstest] + // Test quote_text_object_range with Inner scope - just the content inside quotes + #[case(r#"foo"bar"baz"#, 5, TextObjectScope::Inner, Some(4..7))] // cursor inside double quotes + #[case("foo'bar'baz", 5, TextObjectScope::Inner, Some(4..7))] // single quotes + #[case("foo`bar`baz", 5, TextObjectScope::Inner, Some(4..7))] // backticks + #[case(r#"foo""bar"#, 4, TextObjectScope::Inner, Some(4..4))] // empty quotes + #[case(r#""nested'inner'outer""#, 8, TextObjectScope::Inner, Some(8..13))] // nested, innermost + #[case(r#""nested`mixed'inner'backticks`outer""#, 8, TextObjectScope::Inner, Some(8..29))] // nested, innermost + #[case(r#"next"nested'mixed`inner`quotes'outer""#, 0, TextObjectScope::Inner, Some(5..36))] // next nested mixed + #[case(r#"foo "bar"baz"#, 0, TextObjectScope::Inner, Some(5..8))] // next pair + #[case(r#"foo"bar"baz"#, 2, TextObjectScope::Inner, Some(4..7))] // next from inside word + #[case(r#"foo"bar"baz"#, 4, TextObjectScope::Around, Some(3..8))] // around includes quotes + #[case(r#"foo"bar"baz"#, 3, TextObjectScope::Around, Some(3..8))] // around on opening quote + #[case(r#"foo"bar"baz"#, 2, TextObjectScope::Around, Some(3..8))] // around next quotes + #[case(r#"foo""bar"#, 4, TextObjectScope::Around, Some(3..5))] // around empty quotes + #[case(r#"foo""bar"#, 1, TextObjectScope::Around, Some(3..5))] // around empty quotes + #[case(r#""nested"inner"outer""#, 8, TextObjectScope::Around, Some(7..14))] // nested around includes delimiters + #[case(r#"start"nested'inner'outer""#, 2, TextObjectScope::Around, Some(5..25))] // Next outer nested pair + #[case("no quotes here", 5, TextObjectScope::Inner, None)] // no quotes found + #[case(r#"foo"bar"#, 1, TextObjectScope::Inner, None)] // unclosed quote + #[case("foo'bar\nbaz'qux", 5, TextObjectScope::Inner, None)] // quotes don't span multiple lines + #[case("foo'bar\nbaz'qux", 0, TextObjectScope::Inner, None)] // quotes don't span multiple lines + #[case("foobar\n`baz`qux", 6, TextObjectScope::Inner, None)] // quotes don't span multiple lines + #[case("foo\n(bar\nbaz)qux", 0, TextObjectScope::Inner, None)] // next multi-line brackets + #[case("foo\n(bar\nbaz)qux", 3, TextObjectScope::Around, None)] // next multi-line brackets + fn test_quote_text_object_range( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] scope: TextObjectScope, + #[case] expected: Option>, + ) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(cursor_pos); + let result = editor.quote_text_object_range(scope); + assert_eq!(result, expected); + } + + #[rstest] + // Test edge cases and complex scenarios for both bracket and quote text objects + #[case("", 0, TextObjectScope::Inner, None, None)] // empty buffer + #[case("a", 0, TextObjectScope::Inner, None, None)] // single character + #[case("()", 1, TextObjectScope::Inner, Some(1..1), None)] // empty brackets, cursor inside + #[case(r#""""#, 1, TextObjectScope::Inner, None, Some(1..1))] // empty quotes, cursor inside + #[case("([{}])", 3, TextObjectScope::Inner, Some(3..3), None)] // deeply nested brackets + #[case(r#""'`text`'""#, 5, TextObjectScope::Inner, None, Some(3..7))] // deeply nested quotes + #[case("(text) and [more]", 5, TextObjectScope::Around, Some(0..6), None)] // multiple bracket types + #[case(r#""text" and 'more'"#, 5, TextObjectScope::Around, None, Some(0..6))] // multiple quote types + fn test_text_object_edge_cases( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] scope: TextObjectScope, + #[case] expected_bracket: Option>, + #[case] expected_quote: Option>, + ) { + let mut editor = editor_with(input); + editor.move_to_position(cursor_pos, false); + + let bracket_result = editor.bracket_text_object_range(scope); + let quote_result = editor.quote_text_object_range(scope); + + assert_eq!(bracket_result, expected_bracket); + assert_eq!(quote_result, expected_quote); + } } diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 07eaa0350..57d79f273 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -152,20 +152,12 @@ impl LineBuffer { /// Cursor position *behind* the next unicode grapheme to the right pub fn grapheme_right_index(&self) -> usize { - self.lines[self.insertion_point..] - .grapheme_indices(true) - .nth(1) - .map(|(i, _)| self.insertion_point + i) - .unwrap_or_else(|| self.lines.len()) + self.grapheme_right_index_from_pos(self.insertion_point) } /// Cursor position *in front of* the next unicode grapheme to the left pub fn grapheme_left_index(&self) -> usize { - self.lines[..self.insertion_point] - .grapheme_indices(true) - .next_back() - .map(|(i, _)| i) - .unwrap_or(0) + self.grapheme_left_index_from_pos(self.insertion_point) } /// Cursor position *behind* the next unicode grapheme to the right from the given position @@ -177,6 +169,15 @@ impl LineBuffer { .unwrap_or_else(|| self.lines.len()) } + /// Cursor position *behind* the previous unicode grapheme to the left from the given position + pub(crate) fn grapheme_left_index_from_pos(&self, pos: usize) -> usize { + self.lines[..pos] + .grapheme_indices(true) + .next_back() + .map(|(i, _)| i) + .unwrap_or(0) + } + /// Cursor position *behind* the next word to the right pub fn word_right_index(&self) -> usize { self.lines[self.insertion_point..] @@ -307,6 +308,47 @@ impl LineBuffer { .unwrap_or_else(|| self.lines.len()) } + /// Returns true if cursor is at the end of the buffer with preceding whitespace. + fn at_end_of_line_with_preceding_whitespace(&self) -> bool { + !self.is_empty() // No point checking if empty + && self.insertion_point == self.lines.len() + && self.lines.chars().last().map_or(false, |c| c.is_whitespace()) + } + + /// Cursor position at the end of the current whitespace block. + fn current_whitespace_end_index(&self) -> usize { + self.lines[self.insertion_point..] + .char_indices() + .find(|(_, ch)| !ch.is_whitespace()) + .map(|(i, _)| self.insertion_point + i) + .unwrap_or(self.lines.len()) + } + + /// Cursor position at the start of the current whitespace block. + fn current_whitespace_start_index(&self) -> usize { + self.lines[..self.insertion_point] + .char_indices() + .rev() + .find(|(_, ch)| !ch.is_whitespace()) + .map(|(i, _)| i + 1) + .unwrap_or(0) + } + + /// Returns the range of consecutive whitespace characters that includes + /// the cursor position. If cursor is at the end of trailing whitespace, includes + /// that trailing block. Return None if no surrounding whitespace. + pub(crate) fn current_whitespace_range(&self) -> Option> { + let range_start = self.current_whitespace_start_index(); + if self.on_whitespace() { + let range_end = self.current_whitespace_end_index(); + Some(range_start..range_end) + } else if self.at_end_of_line_with_preceding_whitespace() { + Some(range_start..self.insertion_point) + } else { + None + } + } + /// Move cursor position *behind* the next unicode grapheme to the right pub fn move_right(&mut self) { self.insertion_point = self.grapheme_right_index(); @@ -409,11 +451,11 @@ impl LineBuffer { /// /// If the cursor is located between `start` and `end` it is adjusted to `start`. /// If the cursor is located after `end` it is adjusted to stay at its current char boundary. - pub fn clear_range_safe(&mut self, start: usize, end: usize) { - let (start, end) = if start > end { - (end, start) + pub fn clear_range_safe(&mut self, range: Range) { + let (start, end) = if range.start > range.end { + (range.end, range.start) } else { - (start, end) + (range.start, range.end) }; if self.insertion_point <= start { // No action necessary @@ -777,67 +819,241 @@ impl LineBuffer { } } - /// Attempts to find the matching `(left_char, right_char)` pair *enclosing* - /// the cursor position, respecting nested pairs. + /// Returns `Some(Range)` for the range inside the surrounding + /// `open_char` and `close_char`, or `None` if no pair is found. /// - /// Algorithm: - /// 1. Walk left from `cursor` until we find the "outermost" `left_char`, - /// ignoring any extra `right_char` we see (i.e., we keep a depth counter). - /// 2. Then from that left bracket, walk right to find the matching `right_char`, - /// also respecting nesting. + /// If cursor is positioned just before an opening character, treat it as + /// being "inside" that pair. /// - /// Returns `Some((left_index, right_index))` if found, or `None` otherwise. - pub fn find_matching_pair( + /// For symmetric characters (e.g. quotes), the search is restricted to the current line only. + /// For asymmetric characters (e.g. brackets), the search spans the entire buffer. + pub(crate) fn range_inside_current_pair( &self, - left_char: char, - right_char: char, - cursor: usize, - ) -> Option<(usize, usize)> { - // encode to &str so we can compare with &strs later - let mut tmp = ([0u8; 4], [0u8, 4]); - let left_str = left_char.encode_utf8(&mut tmp.0); - let right_str = right_char.encode_utf8(&mut tmp.1); - // search left for left char - let to_cursor = self.lines.get(..=cursor)?; - let left_index = find_with_depth(to_cursor, left_str, right_str, true)?; - - // search right for right char - let scan_start = left_index + left_char.len_utf8(); - let after_left = self.lines.get(scan_start..)?; - let right_offset = find_with_depth(after_left, right_str, left_str, false)?; - - Some((left_index, scan_start + right_offset)) + open_char: char, + close_char: char, + ) -> Option> { + let only_search_current_line: bool = open_char == close_char; + let find_range_between_pair_at_position = |pos| { + self.range_between_matching_pair_at_pos( + pos, + only_search_current_line, + open_char, + close_char, + ) + }; + + // First try to find pair from current cursor position + find_range_between_pair_at_position(self.insertion_point).or_else(|| { + // Second try, if cursor is positioned just before an opening character, + // treat it as being "inside" that pair and try from the next position + self.grapheme_right() + .starts_with(open_char) + .then(|| find_range_between_pair_at_position(self.grapheme_right_index())) + .flatten() + }) + } + + /// Returns `Some(Range)` for the range inside the next pair + /// or `None` if no pair is found + /// + /// Search forward from the cursor to find the next occurrence of `open_char` + /// (including char at cursors current position), then finds its matching + /// `close_char` and returns the range of text inside those characters. + /// Note the end of Range is exclusive so the end of the range returned so + /// the end of the range is index of final char + 1. + /// + /// For symmetric characters (e.g. quotes), the search is restricted to the current line only. + /// For asymmetric characters (e.g. brackets), the search spans the entire buffer. + pub(crate) fn range_inside_next_pair( + &self, + open_char: char, + close_char: char, + ) -> Option> { + let only_search_current_line: bool = open_char == close_char; + + // Find the next opening character, including the current position + let open_pair_index = if self.grapheme_right().starts_with(open_char) { + self.insertion_point + } else { + self.find_char_right(open_char, only_search_current_line)? + }; + + self.range_between_matching_pair_at_pos( + self.grapheme_right_index_from_pos(open_pair_index), + only_search_current_line, + open_char, + close_char, + ) + } + + /// Returns `Some(Range)` for the range inside the pair `open_char` + /// and `close_char` surrounding the cursor position NOT including the character + /// at the current cursor position, or `None` if no valid pair is found. + /// + /// This is the underlying algorithm used by both `range_inside_current_pair` and + /// `range_inside_next_pair`. + /// It uses a forward-first search approach: + /// 1. Search forward from cursor to find the closing character (ignoring nested pairs) + /// 2. Search backward from closing to find the matching opening character + /// 3. Return the range between them + fn range_between_matching_pair_at_pos( + &self, + position: usize, + only_search_current_line: bool, + open_char: char, + close_char: char, + ) -> Option> { + let search_range = if only_search_current_line { + self.current_line_range() + } else { + 0..self.lines.len() + }; + + let after_cursor = &self.lines[position..search_range.end]; + let close_pair_index_after_cursor = + Self::find_index_of_matching_pair(after_cursor, open_char, close_char, false)?; + let close_char_index_in_buffer = position + close_pair_index_after_cursor; + + let start_to_close_char = &self.lines[search_range.start..close_char_index_in_buffer]; + + Self::find_index_of_matching_pair(start_to_close_char, open_char, close_char, true).map( + |open_char_index_from_start| { + let open_char_index_in_buffer = search_range.start + open_char_index_from_start; + (open_char_index_in_buffer + open_char.len_utf8())..close_char_index_in_buffer + }, + ) + } + + /// Find the index of a pair character that matches the nesting depth at the + /// start or end of `slice` using depth counting to handle nested pairs. + /// Helper for [`LineBuffer::range_between_matching_pair_at_pos`] + /// + /// If `search_backwards` is false: + /// Find close_char at same level of nesting as start of slice. + /// + /// If `search_backwards` is true: + /// Find open_char at same level of nesting as end of slice. + /// + /// Returns index of the open or closing character that matches the start of slice, + /// or `None` if not found. + fn find_index_of_matching_pair( + slice: &str, + open_char: char, + close_char: char, + search_backwards: bool, + ) -> Option { + let mut depth = 0; + let mut graphemes: Vec<(usize, &str)> = slice.grapheme_indices(true).collect(); + + if search_backwards { + graphemes.reverse(); + } + + let (target, increment) = if search_backwards { + (open_char, close_char) + } else { + (close_char, open_char) + }; + + for (index, grapheme) in graphemes { + if let Some(char) = grapheme.chars().next() { + if char == target { + if depth == 0 { + return Some(index); + } + depth -= 1; + } else if char == increment && index > 0 { + depth += 1; + } + } + } + None + } + + /// Returns `Some(Range)` for the range inside pair in `pair_group` + /// at cursor position including pair of character at current cursor position, + /// or `None` if cursor is not inside or at a pair included in `pair_group. + /// + /// If the opening and closing char in the pair are equal then search is + /// restricted to the current line. + /// + /// If multiple pair types are found in the buffer or line, return the innermost + /// pair that surrounds the cursor. Handles empty quotes as zero-length ranges inside quote. + pub(crate) fn range_inside_current_pair_in_group( + &self, + matching_pair_group: &[(char, char)], + ) -> Option> { + matching_pair_group + .iter() + .filter_map(|(open_char, close_char)| { + self.range_inside_current_pair(*open_char, *close_char) + }) + .min_by_key(|range| range.len()) + } + + /// Returns `Some(Range)` for the range inside the next pair in `pair_group` + /// or `None` if cursor is not inside a pair included in `pair_group`. + /// + /// If the opening and closing char in the pair are equal then search is + /// restricted to the current line. + /// + /// If multiple pair types are found in the buffer or line, return the innermost + /// pair that surrounds the cursor. Handles empty pairs as zero-length ranges + /// inside pair (this enables caller to still get the location of the pair). + pub(crate) fn range_inside_next_pair_in_group( + &self, + matching_pair_group: &[(char, char)], + ) -> Option> { + matching_pair_group + .iter() + .filter_map(|(open_char, close_char)| { + self.range_inside_next_pair(*open_char, *close_char) + }) + .min_by_key(|range| range.start) } -} -/// Helper function for [`LineBuffer::find_matching_pair`] -fn find_with_depth( - slice: &str, - deep_char: &str, - shallow_char: &str, - reverse: bool, -) -> Option { - let mut depth: i32 = 0; - - let mut indices: Vec<_> = slice.grapheme_indices(true).collect(); - if reverse { - indices.reverse(); - } - - for (idx, c) in indices.into_iter() { - match c { - c if c == deep_char && depth == 0 => return Some(idx), - c if c == deep_char => depth -= 1, - // special case: shallow char at end of slice shouldn't affect depth. - // cursor over right bracket should be counted as the end of the pair, - // not as a closing a separate nested pair - c if c == shallow_char && idx == (slice.len() - 1) => (), - c if c == shallow_char => depth += 1, - _ => (), + /// Get the range of the current big word (WORD) at cursor position + pub(crate) fn current_big_word_range(&self) -> Range { + let right_index = self.big_word_right_end_index(); + + let mut left_index = 0; + for (i, char) in self.lines[..right_index].char_indices().rev() { + if char.is_whitespace() { + left_index = i + char.len_utf8(); + break; + } } + left_index..(right_index + 1) + } + + /// Return range of `range` expanded with neighbouring whitespace for "around" operations + /// Prioritizes whitespace after the word, falls back to whitespace before if none after + pub(crate) fn expand_range_with_whitespace(&self, range: Range) -> Range { + let end = self.next_non_whitespace_index(range.end); + let start = if end == range.end { + self.prev_non_whitespace_index(range.start) + } else { + range.start + }; + start..end } - None + /// Return next non-whitespace character index after `pos` + fn next_non_whitespace_index(&self, pos: usize) -> usize { + self.lines[pos..] + .char_indices() + .find(|(_, char)| !char.is_whitespace()) + .map_or(self.lines.len(), |(i, _)| pos + i) + } + + /// Extend range leftward to include leading whitespace + fn prev_non_whitespace_index(&self, pos: usize) -> usize { + self.lines[..pos] + .char_indices() + .rev() + .find(|(_, char)| !char.is_whitespace()) + .map_or(0, |(i, char)| i + char.len_utf8()) + } } /// Match any sequence of characters that are considered a word boundary @@ -1691,35 +1907,285 @@ mod test { ); } + const BRACKET_PAIRS: &[(char, char); 3] = &[('(', ')'), ('[', ']'), ('{', '}')]; + const QUOTE_PAIRS: &[(char, char); 3] = &[('"', '"'), ('\'', '\''), ('`', '`')]; + // Tests for range_inside_current_quote - cursor inside or on the boundary + #[rstest] + #[case("foo(bar)baz", 5, BRACKET_PAIRS, Some(4..7))] // cursor on 'a' in "bar" + #[case("foo[bar]baz", 5, BRACKET_PAIRS, Some(4..7))] // square brackets + #[case("foo{bar}baz", 5, BRACKET_PAIRS, Some(4..7))] // curly brackets + #[case("foo(bar(baz)qux)end", 9, BRACKET_PAIRS, Some(8..11))] // cursor on 'a' in "baz", finds inner + #[case("foo(bar(baz)qux)end", 5, BRACKET_PAIRS, Some(4..15))] // cursor on 'a' in "bar", finds outer + #[case("foo([bar])baz", 6, BRACKET_PAIRS, Some(5..8))] // mixed bracket types, cursor on 'a' - should find [bar], not (...) + #[case("foo[(bar)]baz", 6, BRACKET_PAIRS, Some(5..8))] // reversed nesting, cursor on 'a' - should find (bar), not [...] + #[case("foo(bar)baz", 4, BRACKET_PAIRS, Some(4..7))] // cursor just after opening bracket + #[case("foo(bar)baz", 7, BRACKET_PAIRS, Some(4..7))] // cursor just before closing bracket + #[case("foo[]bar", 4, BRACKET_PAIRS, Some(4..4))] // empty square brackets + #[case("(content)", 0, BRACKET_PAIRS, Some(1..8))] // brackets at buffer start/end + #[case("a(b)c", 2, BRACKET_PAIRS, Some(2..3))] // minimal case - cursor inside brackets + #[case(r#"foo("bar")baz"#, 6, BRACKET_PAIRS, Some(4..9))] // quotes inside brackets + #[case(r#"foo"(bar)"baz"#, 6, BRACKET_PAIRS, Some(5..8))] // brackets inside quotes + #[case("())", 1, BRACKET_PAIRS, Some(1..1))] // extra closing bracket + #[case("", 0, BRACKET_PAIRS, None)] // empty buffer + #[case("(", 0, BRACKET_PAIRS, None)] // single opening bracket + #[case(")", 0, BRACKET_PAIRS, None)] // single closing bracket + #[case("", 0, BRACKET_PAIRS, None)] // empty buffer + #[case(r#"foo"bar"baz"#, 5, QUOTE_PAIRS, Some(4..7))] // cursor on 'a' in "bar" + #[case("foo'bar'baz", 5, QUOTE_PAIRS, Some(4..7))] // single quotes + #[case("foo`bar`baz", 5, QUOTE_PAIRS, Some(4..7))] // backticks + #[case(r#"'foo"baz`bar`taz"baz'"#, 0, QUOTE_PAIRS, Some(1..20))] // backticks + #[case(r#""foo"'bar'`baz`"#, 0, QUOTE_PAIRS, Some(1..4))] // cursor at start, should find first (double) + #[case("no quotes here", 5, QUOTE_PAIRS, None)] // no quotes in buffer + #[case(r#"unclosed "quotes"#, 10, QUOTE_PAIRS, None)] // unmatched quotes + #[case("", 0, QUOTE_PAIRS, None)] // empty buffer + fn test_range_inside_current_pair_group( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] pairs: &[(char, char); 3], + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + assert_eq!(buf.range_inside_current_pair_in_group(pairs), expected); + } + + // Tests for range_inside_next_pair_in_group - cursor before pairs, return range inside next pair if exists + #[rstest] + #[case("foo (bar)baz", 1, BRACKET_PAIRS, Some(5..8))] // cursor before brackets + #[case("foo []bar", 1, BRACKET_PAIRS, Some(5..5))] // cursor before empty brackets + #[case("(first)(second)", 4, BRACKET_PAIRS, Some(8..14))] // inside first, should find second + #[case("foo{bar[baz]qux}end", 0, BRACKET_PAIRS, Some(4..15))] // cursor at start, finds outermost + #[case("foo{bar[baz]qux}end", 1, BRACKET_PAIRS, Some(4..15))] // cursor before nested, finds innermost + #[case("foo{bar[baz]qux}end", 4, BRACKET_PAIRS, Some(8..11))] // cursor before nested, finds innermost + #[case("(){}[]", 0, BRACKET_PAIRS, Some(1..1))] // cursor at start, finds first empty pair + #[case("(){}[]", 2, BRACKET_PAIRS, Some(3..3))] // cursor between pairs, finds next + #[case("no brackets here", 5, BRACKET_PAIRS, None)] // no brackets found + #[case("", 0, BRACKET_PAIRS, None)] // empty buffer + #[case(r#"foo "'bar'" baz"#, 1, QUOTE_PAIRS, Some(5..10))] // cursor before nested quotes + #[case(r#"foo '' "bar" baz"#, 1, QUOTE_PAIRS, Some(5..5))] // cursor before first quotes + #[case(r#""foo"'bar`b'az`"#, 1, QUOTE_PAIRS, Some(6..11))] // cursor inside first quotes, find single quotes + #[case(r#""foo"'bar'`baz`"#, 6, QUOTE_PAIRS, Some(11..14))] // cursor after second quotes, find backticks + #[case(r#"zaz'foo"b`a`r"baz'zaz"#, 3, QUOTE_PAIRS, Some(4..17))] // range inside outermost nested quotes + #[case(r#""""#, 0, QUOTE_PAIRS, Some(1..1))] // single quote pair (empty) - should find it ahead + #[case(r#"""asdf"#, 0, QUOTE_PAIRS, Some(1..1))] // unmatched trailing quote + #[case(r#""foo"'bar'`baz`"#, 0, QUOTE_PAIRS, Some(1..4))] // cursor at start, should find first quotes + #[case(r#"foo'bar""#, 1, QUOTE_PAIRS, None)] // mismatched quotes + #[case("no quotes here", 5, QUOTE_PAIRS, None)] // no quotes in buffer + #[case("", 0, QUOTE_PAIRS, None)] // empty buffer + fn test_range_inside_next_pair_in_group( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] pairs: &[(char, char); 3], + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + assert_eq!(buf.range_inside_next_pair_in_group(pairs), expected); + } + + // Tests for range_inside_current_pair - when cursor is inside a pair + #[rstest] + #[case("(abc)", 1, '(', ')', Some(1..4))] // cursor inside simple pair + #[case("foo(bar)baz", 3, '(', ')', Some(4..7))] // cursor inside pair + #[case("[abc]", 1, '[', ']', Some(1..4))] // square brackets + #[case("{abc}", 1, '{', '}', Some(1..4))] // curly brackets + #[case("foo(🦀bar)baz", 8, '(', ')', Some(4..11))] // emoji inside brackets - cursor inside (on 'b') + #[case("🦀(bar)🦀", 6, '(', ')', Some(5..8))] // emoji outside brackets - cursor inside + #[case("()", 1, '(', ')', Some(1..1))] // empty pair + #[case("foo()bar", 4, '(', ')', Some(4..4))] // empty pair - cursor inside + // Cases where cursor is not inside any pair + #[case("(abc)", 0, '(', ')', Some(1..4))] // cursor at start, not inside + #[case("foo(bar)baz", 2, '(', ')', None)] // cursor before pair + #[case("foo(bar)baz", 0, '(', ')', None)] // cursor at start of buffer + #[case("", 0, '(', ')', None)] // empty string + #[case("no brackets", 5, '(', ')', None)] // no brackets + #[case("(unclosed", 1, '(', ')', None)] // unclosed bracket + #[case("unclosed)", 1, '(', ')', None)] // unclosed bracket + #[case("end of line", 11, '(', ')', None)] // unclosed bracket + fn test_range_inside_current_pair( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_current_pair(open_char, close_char); + assert_eq!( + result, expected, + "Failed for input: '{}', cursor: {}, chars: '{}' '{}'", + input, cursor_pos, open_char, close_char + ); + } + + // Tests for range_inside_next_pair - when looking for the next pair forward #[rstest] - #[case("(abc)", 0, '(', ')', Some((0, 4)))] // Basic matching - #[case("(abc)", 4, '(', ')', Some((0, 4)))] // Cursor at end - #[case("(abc)", 2, '(', ')', Some((0, 4)))] // Cursor in middle - #[case("((abc))", 0, '(', ')', Some((0, 6)))] // Nested pairs outer - #[case("((abc))", 1, '(', ')', Some((1, 5)))] // Nested pairs inner - #[case("(abc)(def)", 0, '(', ')', Some((0, 4)))] // Multiple pairs first - #[case("(abc)(def)", 5, '(', ')', Some((5, 9)))] // Multiple pairs second - #[case("(abc", 0, '(', ')', None)] // Incomplete open - #[case("abc)", 3, '(', ')', None)] // Incomplete close - #[case("()", 0, '(', ')', Some((0, 1)))] // Empty pair - #[case("()", 1, '(', ')', Some((0, 1)))] // Empty pair from end - #[case("(αβγ)", 0, '(', ')', Some((0, 7)))] // Unicode content - #[case("([)]", 0, '(', ')', Some((0, 2)))] // Mixed brackets - #[case("\"abc\"", 0, '"', '"', Some((0, 4)))] // Quotes - fn test_find_matching_pair( + #[case("(abc)", 0, '(', ')', Some(1..4))] // cursor at start, find first pair + #[case("foo(bar)baz", 2, '(', ')', Some(4..7))] // cursor before pair + #[case("(first)(second)", 4, '(', ')', Some(8..14))] // inside first, should find second + #[case("()", 0, '(', ')', Some(1..1))] // empty pair + #[case("foo()bar", 2, '(', ')', Some(4..4))] // empty pair + #[case("[abc]", 0, '[', ']', Some(1..4))] // square brackets + #[case("{abc}", 0, '{', '}', Some(1..4))] // curly brackets + #[case("foo(🦀bar)baz", 0, '(', ')', Some(4..11))] // emoji inside brackets - find from start + #[case("🦀(bar)🦀", 0, '(', ')', Some(5..8))] // emoji outside brackets - find from start + #[case("", 0, '(', ')', None)] // empty string + #[case("no brackets", 5, '(', ')', None)] // no brackets + #[case("(unclosed", 1, '(', ')', None)] // unclosed bracket + #[case("(abc)", 4, '(', ')', None)] // cursor after pair, no more pairs + #[case(r#""""#, 0, '"', '"', Some(1..1))] // single quote pair (empty) - should find it ahead + #[case(r#"""asdf"#, 0, '"', '"', Some(1..1))] // unmatched quote - should find it ahead + #[case(r#""foo"'bar'`baz`"#, 0, '"', '"', Some(1..4))] // cursor at start, should find first quotes + fn test_range_inside_next_pair( #[case] input: &str, - #[case] cursor: usize, - #[case] left_char: char, - #[case] right_char: char, - #[case] expected: Option<(usize, usize)>, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, ) { - let buf = LineBuffer::from(input); + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_next_pair(open_char, close_char); assert_eq!( - buf.find_matching_pair(left_char, right_char, cursor), + result, expected, + "Failed for input: '{}', cursor: {}, chars: '{}' '{}'", + input, cursor_pos, open_char, close_char + ); + } + + #[rstest] + // Test next quote is restricted to single line + #[case("line1\n\"quote\"", 7, '"', '"', None)] // Inside second line quote, no quotes after + #[case("\"quote\"\nline2", 2, '"', '"', None)] // No next quote on current line + #[case("line1\n\"quote\"", 6, '"', '"', Some(7..12))] // cursor at start of line 2 + #[case("line1\n\"quote\"", 0, '"', '"', None)] // cursor line 1 doesn't find quote on line 2 + #[case("line1\n\"quote\"", 5, '"', '"', None)] // cursor at end of line 1 + fn test_multiline_next_quote_multiline( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_next_pair(open_char, close_char); + assert_eq!( + result, + expected, + "MULTILINE TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", + input, + cursor_pos, + open_char, + close_char, + input.lines().collect::>() + ); + } + + // Test that range_inside_current_pair work across multiple lines + #[rstest] + #[case("line1\n(bracket)", 7, '(', ')', Some(7..14))] // cursor at bracket start on line 2 + #[case("(bracket)\nline2", 2, '(', ')', Some(1..8))] // cursor inside bracket on line 1 + #[case("line1\n(bracket)", 5, '(', ')', None)] // cursor end of line 1 + #[case("(1\ninner\n3)", 4, '(', ')', Some(1..10))] // bracket spanning 3 lines + #[case("(1\ninner\n3)", 2, '(', ')', Some(1..10))] // bracket spanning 3 lines, cursor end of line 1 + #[case("outer(\ninner(\ndeep\n)\nback\n)", 15, '(', ')', Some(13..19))] // nested multiline brackets + #[case("outer(\ninner(\ndeep\n)\nback\n)", 8, '(', ')', Some(6..26))] // nested multiline brackets + #[case("{\nkey: [\n value\n]\n}", 10, '[', ']', Some(8..17))] // mixed bracket types across lines + fn test_multiline_bracket_behavior( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_current_pair(open_char, close_char); + assert_eq!( + result, expected, - "Failed for input: {}, cursor: {}", + "MULTILINE BRACKET TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", input, - cursor + cursor_pos, + open_char, + close_char, + input.lines().collect::>() ); } + + // Test next brackets work across multiple lines (unlike quotes which are line-restricted) + #[rstest] + #[case("line1\n(bracket)", 2, '(', ')', Some(7..14))] // cursor at bracket start on line 2 + #[case("line1\n(bracket)", 5, '(', ')', Some(7..14))] // cursor end of line 1 + #[case("outer(\ninner(\ndeep\n)\nback\n)", 0, '(', ')', Some(6..26))] // nested multiline brackets + #[case("outer(\ninner(\ndeep\n)\nback\n)", 8, '(', ')', Some(13..19))] // nested multiline brackets + fn test_multiline_next_bracket_behavior( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_next_pair(open_char, close_char); + assert_eq!( + result, + expected, + "MULTILINE BRACKET TEST - Input: {:?}, cursor: {}, chars: '{}' '{}', lines: {:?}", + input, + cursor_pos, + open_char, + close_char, + input.lines().collect::>() + ); + } + + // Unicode safety tests for core pair-finding functionality + #[rstest] + #[case("(🦀)", 1, '(', ')', Some(1..5))] // emoji inside brackets + #[case("🦀(text)🦀", 5, '(', ')', Some(5..9))] // emojis outside brackets + #[case("(multi👨‍👩‍👧‍👦family)", 1, '(', ')', Some(1..37))] // complex emoji family inside (25 bytes) + #[case("(åëïöü)", 1, '(', ')', Some(1..11))] // accented characters + #[case("(mixed🦀åëïtext)", 1, '(', ')', Some(1..20))] // mixed unicode content + #[case("'🦀emoji🦀'", 1, '\'', '\'', Some(1..14))] // emojis in quotes + #[case("'mixed👨‍👩‍👧‍👦åëï'", 1, '\'', '\'', Some(1..37))] // complex 25 byte family emoji + fn test_range_inside_current_pair_unicode_safety( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_current_pair(open_char, close_char); + assert_eq!(result, expected); + // Verify buffer remains valid after operations + assert!(buf.is_valid()); + } + + #[rstest] + #[case("start🦀(content)end", 0, '(', ')', Some(10..17))] // emoji before brackets + #[case("start(🦀)end", 0, '(', ')', Some(6..10))] // emoji inside brackets to find + #[case("🦀'text'🦀", 0, '\'', '\'', Some(5..9))] // emoji before quotes + #[case("start'🦀text🦀'", 0, '\'', '\'', Some(6..18))] // emoji before quotes + #[case("start'multi👨‍👩‍👧‍👦family'end", 0, '\'', '\'', Some(6..42))] // complex 25 byte family emoji + #[case("start'👨‍👩‍👧‍👦multifamily'end", 0, '\'', '\'', Some(6..42))] // complex 25 byte family emoji + fn test_range_inside_next_pair_unicode_safety( + #[case] input: &str, + #[case] cursor_pos: usize, + #[case] open_char: char, + #[case] close_char: char, + #[case] expected: Option>, + ) { + let mut buf = LineBuffer::from(input); + buf.set_insertion_point(cursor_pos); + let result = buf.range_inside_next_pair(open_char, close_char); + assert_eq!(result, expected); + // Verify buffer remains valid after operations + assert!(buf.is_valid()); + } } diff --git a/src/edit_mode/base.rs b/src/edit_mode/base.rs index 455e2c80e..408098b32 100644 --- a/src/edit_mode/base.rs +++ b/src/edit_mode/base.rs @@ -1,5 +1,5 @@ use crate::{ - enums::{ReedlineEvent, ReedlineRawEvent}, + enums::{EventStatus, ReedlineEvent, ReedlineRawEvent}, PromptEditMode, }; @@ -13,4 +13,9 @@ pub trait EditMode: Send { /// What to display in the prompt indicator fn edit_mode(&self) -> PromptEditMode; + + /// Handles events that apply only to specific edit modes (e.g changing vi mode) + fn handle_mode_specific_event(&mut self, _event: ReedlineEvent) -> EventStatus { + EventStatus::Inapplicable + } } diff --git a/src/edit_mode/vi/command.rs b/src/edit_mode/vi/command.rs index 94871c19d..8c856418d 100644 --- a/src/edit_mode/vi/command.rs +++ b/src/edit_mode/vi/command.rs @@ -1,4 +1,5 @@ use super::{motion::Motion, motion::ViCharSearch, parser::ReedlineOption, ViMode}; +use crate::enums::{TextObject, TextObjectScope, TextObjectType}; use crate::{EditCommand, ReedlineEvent, Vi}; use std::iter::Peekable; @@ -9,26 +10,54 @@ where match input.peek() { Some('d') => { let _ = input.next(); - // Checking for "di(" or "di)" etc. + // Checking for "di(" or "diw" etc. if let Some('i') = input.peek() { let _ = input.next(); - input - .next() - .and_then(|c| bracket_pair_for(*c)) - .map(|(left, right)| Command::DeleteInsidePair { left, right }) + input.next().and_then(|c| { + bracket_pair_for(*c) + .map(|(left, right)| Command::DeleteInsidePair { left, right }) + .or_else(|| { + char_to_text_object(*c, TextObjectScope::Inner) + .map(|text_object| Command::DeleteTextObject { text_object }) + }) + }) + } else if let Some('a') = input.peek() { + let _ = input.next(); + input.next().and_then(|c| { + bracket_pair_for(*c) + .map(|(left, right)| Command::DeleteAroundPair { left, right }) + .or_else(|| { + char_to_text_object(*c, TextObjectScope::Around) + .map(|text_object| Command::DeleteTextObject { text_object }) + }) + }) } else { Some(Command::Delete) } } - // Checking for "yi(" or "yi)" etc. + // Checking for "yi(" or "yiw" etc. Some('y') => { let _ = input.next(); if let Some('i') = input.peek() { let _ = input.next(); - input - .next() - .and_then(|c| bracket_pair_for(*c)) - .map(|(left, right)| Command::YankInsidePair { left, right }) + input.next().and_then(|c| { + bracket_pair_for(*c) + .map(|(left, right)| Command::YankInsidePair { left, right }) + .or_else(|| { + char_to_text_object(*c, TextObjectScope::Inner) + .map(|text_object| Command::YankTextObject { text_object }) + }) + }) + } else if let Some('a') = input.peek() { + let _ = input.next(); + input.next().and_then(|c| { + bracket_pair_for(*c) + .map(|(left, right)| Command::YankAroundPair { left, right }) + .or_else(|| { + char_to_text_object(*c, TextObjectScope::Around) + .map(|text_object| Command::YankTextObject { text_object }) + }) + }) } else { Some(Command::Yank) } @@ -53,15 +82,25 @@ where let _ = input.next(); Some(Command::Undo) } - // Checking for "ci(" or "ci)" etc. + // Checking for "ci(" or "ciw" etc. Some('c') => { let _ = input.next(); if let Some('i') = input.peek() { let _ = input.next(); - input - .next() - .and_then(|c| bracket_pair_for(*c)) - .map(|(left, right)| Command::ChangeInsidePair { left, right }) + input.next().and_then(|c| { + bracket_pair_for(*c) + .map(|(left, right)| Command::ChangeInsidePair { left, right }) + .or_else(|| { + char_to_text_object(*c, TextObjectScope::Inner) + .map(|text_object| Command::ChangeTextObject { text_object }) + }) + }) + } else if let Some('a') = input.peek() { + let _ = input.next(); + input.next().and_then(|c| { + char_to_text_object(*c, TextObjectScope::Around) + .map(|text_object| Command::ChangeTextObject { text_object }) + }) } else { Some(Command::Change) } @@ -147,6 +186,11 @@ pub enum Command { ChangeInsidePair { left: char, right: char }, DeleteInsidePair { left: char, right: char }, YankInsidePair { left: char, right: char }, + DeleteAroundPair { left: char, right: char }, + YankAroundPair { left: char, right: char }, + ChangeTextObject { text_object: TextObject }, + YankTextObject { text_object: TextObject }, + DeleteTextObject { text_object: TextObject }, SwapCursorAndAnchor, } @@ -210,23 +254,50 @@ impl Command { None => vec![], }, Self::ChangeInsidePair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CutInside { + vec![ReedlineOption::Edit(EditCommand::CutInsidePair { left: *left, right: *right, })] } Self::DeleteInsidePair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::CutInside { + vec![ReedlineOption::Edit(EditCommand::CutInsidePair { left: *left, right: *right, })] } Self::YankInsidePair { left, right } => { - vec![ReedlineOption::Edit(EditCommand::YankInside { + vec![ReedlineOption::Edit(EditCommand::CopyInsidePair { left: *left, right: *right, })] } + Self::DeleteAroundPair { left, right } => { + vec![ReedlineOption::Edit(EditCommand::CutAroundPair { + left: *left, + right: *right, + })] + } + Self::YankAroundPair { left, right } => { + vec![ReedlineOption::Edit(EditCommand::CopyAroundPair { + left: *left, + right: *right, + })] + } + Self::ChangeTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::CutTextObject { + text_object: *text_object, + })] + } + Self::YankTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::CopyTextObject { + text_object: *text_object, + })] + } + Self::DeleteTextObject { text_object } => { + vec![ReedlineOption::Edit(EditCommand::CutTextObject { + text_object: *text_object, + })] + } Self::SwapCursorAndAnchor => { vec![ReedlineOption::Edit(EditCommand::SwapCursorAndAnchor)] } @@ -400,6 +471,28 @@ impl Command { } } +fn char_to_text_object(c: char, scope: TextObjectScope) -> Option { + match c { + 'w' => Some(TextObject { + scope, + object_type: TextObjectType::Word, + }), + 'W' => Some(TextObject { + scope, + object_type: TextObjectType::BigWord, + }), + 'b' => Some(TextObject { + scope, + object_type: TextObjectType::Brackets, + }), + 'q' => Some(TextObject { + scope, + object_type: TextObjectType::Quote, + }), + _ => None, + } +} + fn bracket_pair_for(c: char) -> Option<(char, char)> { match c { '(' | ')' => Some(('(', ')')), diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index f417cd2ba..6868f1c53 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -3,6 +3,8 @@ mod motion; mod parser; mod vi_keybindings; +use std::str::FromStr; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; pub use vi_keybindings::{default_vi_insert_keybindings, default_vi_normal_keybindings}; @@ -11,7 +13,7 @@ use self::motion::ViCharSearch; use super::EditMode; use crate::{ edit_mode::{keybindings::Keybindings, vi::parser::parse}, - enums::{EditCommand, ReedlineEvent, ReedlineRawEvent}, + enums::{EditCommand, EventStatus, ReedlineEvent, ReedlineRawEvent}, PromptEditMode, PromptViMode, }; @@ -22,6 +24,19 @@ enum ViMode { Visual, } +impl FromStr for ViMode { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "normal" => Ok(ViMode::Normal), + "insert" => Ok(ViMode::Insert), + "visual" => Ok(ViMode::Visual), + _ => Err(()), + } + } +} + /// This parses incoming input `Event`s like a Vi-Style editor pub struct Vi { cache: Vec, @@ -214,6 +229,19 @@ impl EditMode for Vi { ViMode::Insert => PromptEditMode::Vi(PromptViMode::Insert), } } + + fn handle_mode_specific_event(&mut self, event: ReedlineEvent) -> EventStatus { + match event { + ReedlineEvent::ViChangeMode(mode_str) => match ViMode::from_str(&mode_str) { + Ok(mode) => { + self.mode = mode; + EventStatus::Handled + } + Err(_) => EventStatus::Inapplicable, + }, + _ => EventStatus::Inapplicable, + } + } } #[cfg(test)] diff --git a/src/edit_mode/vi/parser.rs b/src/edit_mode/vi/parser.rs index 3c39a3eaf..d7bebb8bc 100644 --- a/src/edit_mode/vi/parser.rs +++ b/src/edit_mode/vi/parser.rs @@ -114,6 +114,7 @@ impl ParsedViSequence { Some(ViMode::Normal) } (Some(Command::ChangeInsidePair { .. }), _) => Some(ViMode::Insert), + (Some(Command::ChangeTextObject { .. }), _) => Some(ViMode::Insert), (Some(Command::Delete), ParseResult::Incomplete) | (Some(Command::DeleteChar), ParseResult::Incomplete) | (Some(Command::DeleteToEnd), ParseResult::Incomplete) diff --git a/src/engine.rs b/src/engine.rs index b5f6958a3..5297a74d9 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -165,6 +165,9 @@ pub struct Reedline { // Manage optional kitty protocol kitty_protocol: KittyProtocolGuard, + // Whether lines should be accepted immediately + immediately_accept: bool, + #[cfg(feature = "external_printer")] external_printer: Option>, } @@ -237,7 +240,8 @@ impl Reedline { buffer_editor: None, cursor_shapes: None, bracketed_paste: BracketedPasteGuard::default(), - kitty_protocol: KittyProtocolGuard::default(), + kitty_protocol: KittyProtocolGuard::new(), + immediately_accept: false, #[cfg(feature = "external_printer")] external_printer: None, } @@ -545,6 +549,12 @@ impl Reedline { self } + /// A builder that configures whether reedline should immediately accept the input. + pub fn with_immediately_accept(mut self, immediately_accept: bool) -> Self { + self.immediately_accept = immediately_accept; + self + } + /// Returns the corresponding expected prompt style for the given edit mode pub fn prompt_edit_mode(&self) -> PromptEditMode { self.edit_mode.edit_mode() @@ -734,30 +744,32 @@ impl Reedline { let mut events: Vec = vec![]; - // If the `external_printer` feature is enabled, we need to - // periodically yield so that external printers get a chance to - // print. Otherwise, we can just block until we receive an event. - #[cfg(feature = "external_printer")] - if event::poll(EXTERNAL_PRINTER_WAIT)? { - events.push(crossterm::event::read()?); - } - #[cfg(not(feature = "external_printer"))] - events.push(crossterm::event::read()?); - - // Receive all events in the queue without blocking. Will stop when - // a line of input is completed. - while !completed(&events) && event::poll(Duration::from_millis(0))? { + if !self.immediately_accept { + // If the `external_printer` feature is enabled, we need to + // periodically yield so that external printers get a chance to + // print. Otherwise, we can just block until we receive an event. + #[cfg(feature = "external_printer")] + if event::poll(EXTERNAL_PRINTER_WAIT)? { + events.push(crossterm::event::read()?); + } + #[cfg(not(feature = "external_printer"))] events.push(crossterm::event::read()?); - } - // If we believe there's text pasting or resizing going on, batch - // more events at the cost of a slight delay. - if events.len() > EVENTS_THRESHOLD - || events.iter().any(|e| matches!(e, Event::Resize(_, _))) - { - while !completed(&events) && event::poll(POLL_WAIT)? { + // Receive all events in the queue without blocking. Will stop when + // a line of input is completed. + while !completed(&events) && event::poll(Duration::from_millis(0))? { events.push(crossterm::event::read()?); } + + // If we believe there's text pasting or resizing going on, batch + // more events at the cost of a slight delay. + if events.len() > EVENTS_THRESHOLD + || events.iter().any(|e| matches!(e, Event::Resize(_, _))) + { + while !completed(&events) && event::poll(POLL_WAIT)? { + events.push(crossterm::event::read()?); + } + } } // Convert `Event` into `ReedlineEvent`. Also, fuse consecutive @@ -787,6 +799,9 @@ impl Reedline { if let Some((x, y)) = resize { reedline_events.push(ReedlineEvent::Resize(x, y)); } + if self.immediately_accept { + reedline_events.push(ReedlineEvent::Submit); + } // Handle reedline events. let mut need_repaint = false; @@ -928,7 +943,8 @@ impl Reedline { | ReedlineEvent::MenuLeft | ReedlineEvent::MenuRight | ReedlineEvent::MenuPageNext - | ReedlineEvent::MenuPagePrevious => Ok(EventStatus::Inapplicable), + | ReedlineEvent::MenuPagePrevious + | ReedlineEvent::ViChangeMode(_) => Ok(EventStatus::Inapplicable), } } @@ -1070,7 +1086,7 @@ impl Reedline { } ReedlineEvent::Esc => { self.deactivate_menus(); - self.editor.reset_selection(); + self.editor.clear_selection(); Ok(EventStatus::Handled) } ReedlineEvent::CtrlD => { @@ -1273,6 +1289,7 @@ impl Reedline { // Exhausting the event handlers is still considered handled Ok(EventStatus::Inapplicable) } + ReedlineEvent::ViChangeMode(_) => Ok(self.edit_mode.handle_mode_specific_event(event)), ReedlineEvent::None | ReedlineEvent::Mouse => Ok(EventStatus::Inapplicable), } } @@ -1288,9 +1305,7 @@ impl Reedline { } fn previous_history(&mut self) { - if self.history_cursor_on_excluded { - self.history_cursor_on_excluded = false; - } + self.history_cursor_on_excluded = false; if self.input_mode != InputMode::HistoryTraversal { self.input_mode = InputMode::HistoryTraversal; self.history_cursor = HistoryCursor::new( @@ -1310,8 +1325,6 @@ impl Reedline { } self.update_buffer_from_history(); self.editor.move_to_start(false); - self.editor - .update_undo_state(UndoBehavior::HistoryNavigation); self.editor.move_to_line_end(false); self.editor .update_undo_state(UndoBehavior::HistoryNavigation); @@ -1474,13 +1487,21 @@ impl Reedline { HistoryNavigationQuery::Normal(_) ) { if let Some(string) = self.history_cursor.string_at_cursor() { - self.editor - .set_buffer(string, UndoBehavior::HistoryNavigation); + // NOTE: `set_buffer` resets the insertion point, + // which we should avoid during history navigation through the same buffer + // https://github.com/nushell/reedline/pull/899 + if string != self.editor.get_buffer() { + self.editor + .set_buffer(string, UndoBehavior::HistoryNavigation); + } } } self.input_mode = InputMode::Regular; } + // Update editor with current edit mode for mode-aware selection behavior + self.editor.set_edit_mode(self.edit_mode.edit_mode()); + // Run the commands over the edit buffer for command in commands { self.editor.run_edit_command(command); @@ -1896,8 +1917,60 @@ impl Reedline { } } -#[test] -fn thread_safe() { - fn f(_: S) {} - f(Reedline::create()); +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cursor_position_after_multiline_history_navigation() { + // Test for https://github.com/nushell/reedline/pull/899 + // Ensure that after navigating to a multiline history entry and then + // running edit commands, the cursor doesn't jump unexpectedly. + // The fix prevents set_buffer() from being called unnecessarily, + // which would reset the insertion point. + + let mut reedline = Reedline::create(); + + // Add a multiline entry to history + let multiline_command = "echo 'line 1'\necho 'line 2'\necho 'line 3'"; + let history_item = HistoryItem::from_command_line(multiline_command); + reedline + .history + .save(history_item) + .expect("Failed to save history"); + + // Navigate to previous history + reedline.previous_history(); + + // Get the initial insertion point after history navigation + let initial_insertion_point = reedline.current_insertion_point(); + + // The buffer should contain our multiline command + assert_eq!(reedline.current_buffer_contents(), multiline_command); + + // After the fix, previous_history() positions cursor at end of first line + // (after move_to_start + move_to_line_end) + let first_line_end = multiline_command.find('\n').unwrap(); + assert_eq!(initial_insertion_point, first_line_end); + + // Now simulate pressing the right arrow key, which should move cursor right + // Without the fix, set_buffer() would be called and reset the insertion point, + // causing the cursor to jump unexpectedly. With the fix, it stays where it is + // and moves correctly. + reedline.run_edit_commands(&[EditCommand::MoveRight { select: false }]); + + let after_move_insertion_point = reedline.current_insertion_point(); + + // The cursor should have moved right by 1 from where it was + assert_eq!(after_move_insertion_point, initial_insertion_point + 1); + + // The buffer should still be unchanged + assert_eq!(reedline.current_buffer_contents(), multiline_command); + } + + #[test] + fn thread_safe() { + fn f(_: S) {} + f(Reedline::create()); + } } diff --git a/src/enums.rs b/src/enums.rs index 643afb485..3f1d9181f 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -14,6 +14,46 @@ pub enum Signal { CtrlD, // End terminal session } +/// Scope of text object operation ("i" inner or "a" around) +#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum TextObjectScope { + /// Just the text object itself + Inner, + /// Expanded to include surrounding based on object type + Around, +} + +/// Type of text object to operate on +#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum TextObjectType { + /// word (delimited by non-alphanumeric characters) + Word, + /// WORD (delimited only by whitespace) + BigWord, + /// (, ), [, ], {, } + Brackets, + /// ", ', ` + Quote, +} + +/// Text objects that can be operated on with vim-style commands +#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct TextObject { + /// Whether to include surrounding context + pub scope: TextObjectScope, + /// The type of text object + pub object_type: TextObjectType, +} + +impl Default for TextObject { + fn default() -> Self { + Self { + scope: TextObjectScope::Inner, + object_type: TextObjectType::Word, + } + } +} + /// Editing actions which can be mapped to key bindings. /// /// Executed by `Reedline::run_edit_commands()` @@ -337,19 +377,43 @@ pub enum EditCommand { PasteSystem, /// Delete text between matching characters atomically - CutInside { + CutInsidePair { /// Left character of the pair left: char, /// Right character of the pair (usually matching bracket) right: char, }, /// Yank text between matching characters atomically - YankInside { + CopyInsidePair { /// Left character of the pair left: char, /// Right character of the pair (usually matching bracket) right: char, }, + /// Delete text around matching characters atomically (including the pair characters) + CutAroundPair { + /// Left character of the pair + left: char, + /// Right character of the pair (usually matching bracket) + right: char, + }, + /// Yank text around matching characters atomically (including the pair characters) + CopyAroundPair { + /// Left character of the pair + left: char, + /// Right character of the pair (usually matching bracket) + right: char, + }, + /// Cut the specified text object + CutTextObject { + /// The text object to operate on + text_object: TextObject, + }, + /// Copy the specified text object + CopyTextObject { + /// The text object to operate on + text_object: TextObject, + }, } impl Display for EditCommand { @@ -462,8 +526,12 @@ impl Display for EditCommand { EditCommand::CopySelectionSystem => write!(f, "CopySelectionSystem"), #[cfg(feature = "system_clipboard")] EditCommand::PasteSystem => write!(f, "PasteSystem"), - EditCommand::CutInside { .. } => write!(f, "CutInside Value: "), - EditCommand::YankInside { .. } => write!(f, "YankInside Value: "), + EditCommand::CutInsidePair { .. } => write!(f, "CutInsidePair Value: "), + EditCommand::CopyInsidePair { .. } => write!(f, "CopyInsidePair Value: "), + EditCommand::CutAroundPair { .. } => write!(f, "CutAroundPair Value: "), + EditCommand::CopyAroundPair { .. } => write!(f, "CopyAroundPair Value: "), + EditCommand::CutTextObject { .. } => write!(f, "CutTextObject Value: "), + EditCommand::CopyTextObject { .. } => write!(f, "CopyTextObject Value: "), } } } @@ -536,7 +604,10 @@ impl EditCommand { | EditCommand::CutLeftUntil(_) | EditCommand::CutLeftBefore(_) | EditCommand::CutSelection - | EditCommand::Paste => EditType::EditText, + | EditCommand::Paste + | EditCommand::CutInsidePair { .. } + | EditCommand::CutAroundPair { .. } + | EditCommand::CutTextObject { .. } => EditType::EditText, #[cfg(feature = "system_clipboard")] // Sadly cfg attributes in patterns don't work EditCommand::CutSelectionSystem | EditCommand::PasteSystem => EditType::EditText, @@ -546,8 +617,6 @@ impl EditCommand { EditCommand::CopySelection => EditType::NoOp, #[cfg(feature = "system_clipboard")] EditCommand::CopySelectionSystem => EditType::NoOp, - EditCommand::CutInside { .. } => EditType::EditText, - EditCommand::YankInside { .. } => EditType::EditText, EditCommand::CopyFromStart | EditCommand::CopyFromLineStart | EditCommand::CopyToEnd @@ -564,7 +633,10 @@ impl EditCommand { | EditCommand::CopyRightUntil(_) | EditCommand::CopyRightBefore(_) | EditCommand::CopyLeftUntil(_) - | EditCommand::CopyLeftBefore(_) => EditType::NoOp, + | EditCommand::CopyLeftBefore(_) + | EditCommand::CopyInsidePair { .. } + | EditCommand::CopyAroundPair { .. } + | EditCommand::CopyTextObject { .. } => EditType::NoOp, } } } @@ -758,6 +830,9 @@ pub enum ReedlineEvent { /// Open text editor OpenEditor, + + /// Change mode (vi mode only) + ViChangeMode(String), } impl Display for ReedlineEvent { @@ -801,11 +876,12 @@ impl Display for ReedlineEvent { ReedlineEvent::MenuPagePrevious => write!(f, "MenuPagePrevious"), ReedlineEvent::ExecuteHostCommand(_) => write!(f, "ExecuteHostCommand"), ReedlineEvent::OpenEditor => write!(f, "OpenEditor"), + ReedlineEvent::ViChangeMode(_) => write!(f, "ViChangeMode mode: "), } } } -pub(crate) enum EventStatus { +pub enum EventStatus { Handled, Inapplicable, Exits(Signal), diff --git a/src/lib.rs b/src/lib.rs index cc740d32e..4ecfae04d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -194,7 +194,7 @@ //! - Configurable prompt //! - Content-aware syntax highlighting. //! - Autocompletion (With graphical selection menu or simple cycling inline). -//! - History with interactive search options (optionally persists to file, can support multilple sessions accessing the same file) +//! - History with interactive search options (optionally persists to file, can support multiple sessions accessing the same file) //! - Fish-style history autosuggestion hints //! - Undo support. //! - Clipboard integration @@ -231,7 +231,10 @@ pub use core_editor::Editor; pub use core_editor::LineBuffer; mod enums; -pub use enums::{EditCommand, ReedlineEvent, ReedlineRawEvent, Signal, UndoBehavior}; +pub use enums::{ + EditCommand, ReedlineEvent, ReedlineRawEvent, Signal, TextObject, TextObjectScope, + TextObjectType, UndoBehavior, +}; mod painting; pub use painting::{Painter, StyledText}; @@ -279,7 +282,7 @@ pub use validator::{DefaultValidator, ValidationResult, Validator}; mod menu; pub use menu::{ menu_functions, ColumnarMenu, DescriptionMenu, DescriptionMode, IdeMenu, ListMenu, Menu, - MenuBuilder, MenuEvent, MenuTextStyle, ReedlineMenu, + MenuBuilder, MenuEvent, MenuTextStyle, ReedlineMenu, TraversalDirection, }; mod terminal_extensions; diff --git a/src/menu/columnar_menu.rs b/src/menu/columnar_menu.rs index 53a7f9db6..14d7c44a5 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -1,13 +1,25 @@ use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, - menu_functions::{can_partially_complete, completer_input, replace_in_buffer}, + menu_functions::{ + can_partially_complete, completer_input, floor_char_boundary, get_match_indices, + replace_in_buffer, style_suggestion, + }, painting::Painter, Completer, Suggestion, }; use nu_ansi_term::ansi::RESET; use unicode_width::UnicodeWidthStr; +/// The traversal direction of the menu +#[derive(Debug, PartialEq, Eq)] +pub enum TraversalDirection { + /// Traverse horizontally + Horizontal, + /// Traverse vertically + Vertical, +} + /// Default values used as reference for the menu. These values are set during /// the initial declaration of the menu and are always kept as reference for the /// changeable [`ColumnDetails`] @@ -18,6 +30,8 @@ struct DefaultColumnDetails { pub col_width: Option, /// Column padding pub col_padding: usize, + /// Traversal direction + pub traversal_dir: TraversalDirection, } impl Default for DefaultColumnDetails { @@ -26,6 +40,7 @@ impl Default for DefaultColumnDetails { columns: 4, col_width: None, col_padding: 2, + traversal_dir: TraversalDirection::Horizontal, } } } @@ -63,9 +78,9 @@ pub struct ColumnarMenu { col_pos: u16, /// row position in the menu. Starts from 0 row_pos: u16, - /// Number of values that are skipped when printing, + /// Number of rows that are skipped when printing, /// depending on selected value and terminal height - skip_values: u16, + skip_rows: u16, /// Event sent to the menu event: Option, /// Longest suggestion found in the values @@ -85,7 +100,7 @@ impl Default for ColumnarMenu { values: Vec::new(), col_pos: 0, row_pos: 0, - skip_values: 0, + skip_rows: 0, event: None, longest_suggestion: 0, input: None, @@ -122,114 +137,166 @@ impl ColumnarMenu { self.default_details.col_padding = col_padding; self } + + /// Menu builder with new traversal direction value + #[must_use] + pub fn with_traversal_direction(mut self, direction: TraversalDirection) -> Self { + self.default_details.traversal_dir = direction; + self + } } // Menu functionality impl ColumnarMenu { /// Move menu cursor to the next element fn move_next(&mut self) { - let mut new_col = self.col_pos + 1; - let mut new_row = self.row_pos; - - if new_col >= self.get_cols() { - new_row += 1; - new_col = 0; - } + let new_index = self.index() + 1; - if new_row >= self.get_rows() { - new_row = 0; - new_col = 0; - } - - let position = new_row * self.get_cols() + new_col; - if position >= self.get_values().len() as u16 { - self.reset_position(); + let new_index = if new_index >= self.get_values().len() { + 0 } else { - self.col_pos = new_col; - self.row_pos = new_row; - } + new_index + }; + + (self.row_pos, self.col_pos) = self.position_from_index(new_index); } /// Move menu cursor to the previous element fn move_previous(&mut self) { - let new_col = self.col_pos.checked_sub(1); - - let (new_col, new_row) = match new_col { - Some(col) => (col, self.row_pos), - None => match self.row_pos.checked_sub(1) { - Some(row) => (self.get_cols().saturating_sub(1), row), - None => ( - self.get_cols().saturating_sub(1), - self.get_rows().saturating_sub(1), - ), - }, + let new_index = match self.index().checked_sub(1) { + Some(index) => index, + None => self.values.len().saturating_sub(1), }; - let position = new_row * self.get_cols() + new_col; - if position >= self.get_values().len() as u16 { - self.col_pos = (self.get_values().len() as u16 % self.get_cols()).saturating_sub(1); - self.row_pos = self.get_rows().saturating_sub(1); - } else { - self.col_pos = new_col; - self.row_pos = new_row; - } + (self.row_pos, self.col_pos) = self.position_from_index(new_index); } /// Move menu cursor up fn move_up(&mut self) { - self.row_pos = if let Some(new_row) = self.row_pos.checked_sub(1) { - new_row - } else { - let new_row = self.get_rows().saturating_sub(1); - let index = new_row * self.get_cols() + self.col_pos; - if index >= self.values.len() as u16 { - new_row.saturating_sub(1) - } else { - new_row - } + self.row_pos = match self.row_pos.checked_sub(1) { + Some(index) => index, + None => self.get_last_row_at_col(self.col_pos), } } - /// Move menu cursor left + /// Move menu cursor down fn move_down(&mut self) { let new_row = self.row_pos + 1; - self.row_pos = if new_row >= self.get_rows() { + self.row_pos = if new_row > self.get_last_row_at_col(self.col_pos) { 0 } else { - let index = new_row * self.get_cols() + self.col_pos; - if index >= self.values.len() as u16 { - 0 - } else { - new_row - } + new_row } } /// Move menu cursor left fn move_left(&mut self) { - self.col_pos = if let Some(row) = self.col_pos.checked_sub(1) { - row - } else if self.index() + 1 == self.values.len() { - 0 + self.col_pos = if let Some(col) = self.col_pos.checked_sub(1) { + col } else { - self.get_cols().saturating_sub(1) + self.get_last_col_at_row(self.row_pos) } } - /// Move menu cursor element + /// Move menu cursor right fn move_right(&mut self) { let new_col = self.col_pos + 1; - self.col_pos = if new_col >= self.get_cols() || self.index() + 2 > self.values.len() { + self.col_pos = if new_col > self.get_last_col_at_row(self.row_pos) { 0 } else { new_col } } + /// Calculates row and column positions from an index + fn position_from_index(&self, index: usize) -> (u16, u16) { + match self.default_details.traversal_dir { + TraversalDirection::Vertical => { + let row = index % self.get_rows() as usize; + let col = index / self.get_rows() as usize; + (row as u16, col as u16) + } + TraversalDirection::Horizontal => { + let row = index / self.get_used_cols() as usize; + let col = index % self.get_used_cols() as usize; + (row as u16, col as u16) + } + } + } + + /// Calculates the last row containing a value for the specified column + fn get_last_row_at_col(&self, col_pos: u16) -> u16 { + let num_values = self.get_values().len() as u16; + match self.default_details.traversal_dir { + TraversalDirection::Vertical => { + if col_pos >= self.get_used_cols() - 1 { + // Last column, might not be full + let mod_val = num_values % self.get_rows(); + if mod_val == 0 { + // Full column + self.get_rows().saturating_sub(1) + } else { + // Column with last row empty + mod_val.saturating_sub(1) + } + } else { + // Full column + self.get_rows().saturating_sub(1) + } + } + TraversalDirection::Horizontal => { + let mod_val = num_values % self.get_used_cols(); + if mod_val > 0 && col_pos >= mod_val { + // Column with last row empty + self.get_rows().saturating_sub(2) + } else { + // Full column + self.get_rows().saturating_sub(1) + } + } + } + } + + /// Calculates the last column containing a value for the specified row + fn get_last_col_at_row(&self, row_pos: u16) -> u16 { + let num_values = self.get_values().len() as u16; + match self.default_details.traversal_dir { + TraversalDirection::Vertical => { + let mod_val = num_values % self.get_rows(); + if mod_val > 0 && row_pos >= mod_val { + // Row with last column empty + self.get_used_cols().saturating_sub(2) + } else { + // Full row + self.get_used_cols().saturating_sub(1) + } + } + TraversalDirection::Horizontal => { + if row_pos >= self.get_rows() - 1 { + // Last row, might not be full + let mod_val = num_values % self.get_used_cols(); + if mod_val == 0 { + // Full row + self.get_used_cols().saturating_sub(1) + } else { + // Row with some columns empty + mod_val.saturating_sub(1) + } + } else { + // Full row + self.get_used_cols().saturating_sub(1) + } + } + } + } + /// Menu index based on column and row position fn index(&self) -> usize { - let index = self.row_pos * self.get_cols() + self.col_pos; - index as usize + let index = match self.default_details.traversal_dir { + TraversalDirection::Vertical => self.col_pos * self.get_rows() + self.row_pos, + TraversalDirection::Horizontal => self.row_pos * self.get_used_cols() + self.col_pos, + }; + index.into() } /// Get selected value from the menu @@ -237,12 +304,12 @@ impl ColumnarMenu { self.get_values().get(self.index()).cloned() } - /// Calculates how many rows the Menu will use + /// Calculates how many rows the menu will use fn get_rows(&self) -> u16 { let values = self.get_values().len() as u16; if values == 0 { - // When the values are empty the no_records_msg is shown, taking 1 line + // When the values are empty the "NO RECORDS FOUND" message is shown, taking 1 line return 1; } @@ -254,6 +321,28 @@ impl ColumnarMenu { } } + /// Calculates how many columns will be used to display values + fn get_used_cols(&self) -> u16 { + let values = self.get_values().len() as u16; + + if values == 0 { + // When the values are empty the "NO RECORDS FOUND" message is shown, taking 1 column + return 1; + } + + match self.default_details.traversal_dir { + TraversalDirection::Vertical => { + let cols = values / self.get_rows(); + if values % self.get_rows() != 0 { + cols + 1 + } else { + cols + } + } + TraversalDirection::Horizontal => self.get_cols().min(values), + } + } + /// Returns working details col width fn get_width(&self) -> usize { self.working_details.col_width @@ -284,154 +373,73 @@ impl ColumnarMenu { self.working_details.columns.max(1) } - /// End of line for menu - fn end_of_line(&self, column: u16) -> &str { - if column == self.get_cols().saturating_sub(1) { - "\r\n" - } else { - "" - } - } - /// Creates default string that represents one suggestion from the menu fn create_string( &self, suggestion: &Suggestion, index: usize, - column: u16, - empty_space: usize, use_ansi_coloring: bool, ) -> String { + let selected = index == self.index(); + let empty_space = self.get_width().saturating_sub(suggestion.value.width()); + if use_ansi_coloring { - // strip quotes + // TODO(ysthakur): let the user strip quotes, rather than doing it here let is_quote = |c: char| "`'\"".contains(c); let shortest_base = &self.working_details.shortest_base_string; let shortest_base = shortest_base .strip_prefix(is_quote) .unwrap_or(shortest_base); - let match_len = shortest_base.chars().count(); - - // Find match position - look for the base string in the suggestion (case-insensitive) - let match_position = suggestion - .value - .to_lowercase() - .find(&shortest_base.to_lowercase()) - .unwrap_or(0); - - // The match is just the part that matches the shortest_base - let match_str = { - let match_str = &suggestion.value[match_position..]; - let match_len_bytes = match_str - .char_indices() - .nth(match_len) - .map(|(i, _)| i) - .unwrap_or_else(|| match_str.len()); - &suggestion.value[match_position..match_position + match_len_bytes] - }; - - // Prefix is everything before the match - let prefix = &suggestion.value[..match_position]; - - // Remaining is everything after the match - let remaining_str = &suggestion.value[match_position + match_str.len()..]; - let suggestion_style_prefix = suggestion - .style - .unwrap_or(self.settings.color.text_style) - .prefix(); + let match_indices = + get_match_indices(&suggestion.value, &suggestion.match_indices, shortest_base); let left_text_size = self.longest_suggestion + self.default_details.col_padding; - let right_text_size = self.get_width().saturating_sub(left_text_size); + let description_size = self.get_width().saturating_sub(left_text_size); + let padding = left_text_size.saturating_sub(suggestion.value.len()); - let max_remaining = left_text_size.saturating_sub(match_str.width() + prefix.width()); - let max_match = max_remaining.saturating_sub(remaining_str.width()); - - if index == self.index() { - if let Some(description) = &suggestion.description { + let value_style = if selected { + &self.settings.color.selected_text_style + } else { + &suggestion.style.unwrap_or(self.settings.color.text_style) + }; + let styled_value = style_suggestion( + &value_style.paint(&suggestion.value).to_string(), + match_indices.as_ref(), + &self.settings.color.match_style, + ); + + if let Some(description) = &suggestion.description { + let desc_trunc = description + .chars() + .take(description_size) + .collect::() + .replace('\n', " "); + if selected { format!( - "{}{}{}{}{}{}{}{}{}{:max_match$}{:max_remaining$}{}{}{}{}{}{}", - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - prefix, - RESET, - suggestion_style_prefix, - self.settings.color.selected_match_style.prefix(), - match_str, + "{}{}{}{}{}", + styled_value, + value_style.prefix(), + " ".repeat(padding), + desc_trunc, RESET, - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - remaining_str, - RESET, - self.settings.color.description_style.prefix(), - self.settings.color.selected_text_style.prefix(), - description - .chars() - .take(right_text_size) - .collect::() - .replace('\n', " "), - RESET, - self.end_of_line(column), ) } else { format!( - "{}{}{}{}{}{}{}{}{}{}{}{}{:>empty$}{}", - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - prefix, - RESET, - suggestion_style_prefix, - self.settings.color.selected_match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - remaining_str, + "{}{}{}{}", + styled_value, + " ".repeat(padding), + self.settings.color.description_style.paint(desc_trunc), RESET, - "", - self.end_of_line(column), - empty = empty_space, ) } - } else if let Some(description) = &suggestion.description { - format!( - "{}{}{}{}{}{}{}{:max_match$}{:max_remaining$}{}{}{}{}{}", - suggestion_style_prefix, - prefix, - RESET, - suggestion_style_prefix, - self.settings.color.match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - remaining_str, - RESET, - self.settings.color.description_style.prefix(), - description - .chars() - .take(right_text_size) - .collect::() - .replace('\n', " "), - RESET, - self.end_of_line(column), - ) } else { format!( - "{}{}{}{}{}{}{}{}{}{}{}{:>empty$}{}{}", - suggestion_style_prefix, - prefix, - RESET, - suggestion_style_prefix, - self.settings.color.match_style.prefix(), - match_str, + "{}{}{:>empty$}", + styled_value, RESET, - suggestion_style_prefix, - remaining_str, - RESET, - self.settings.color.description_style.prefix(), "", - RESET, - self.end_of_line(column), - empty = empty_space, + empty = empty_space ) } } else { @@ -440,7 +448,7 @@ impl ColumnarMenu { let line = if let Some(description) = &suggestion.description { format!( - "{}{:max$}{}{}", + "{}{:max$}{}", marker, &suggestion.value, description @@ -448,7 +456,6 @@ impl ColumnarMenu { .take(empty_space) .collect::() .replace('\n', " "), - self.end_of_line(column), max = self.longest_suggestion + self .default_details @@ -457,16 +464,15 @@ impl ColumnarMenu { ) } else { format!( - "{}{}{:>empty$}{}", + "{}{}{:>empty$}", marker, &suggestion.value, "", - self.end_of_line(column), empty = empty_space.saturating_sub(marker.width()), ) }; - if index == self.index() { + if selected { line.to_uppercase() } else { line @@ -544,7 +550,11 @@ impl Menu for ColumnarMenu { self.values = values; self.working_details.shortest_base_string = base_ranges .iter() - .map(|range| editor.get_buffer()[range.clone()].to_string()) + .map(|range| { + let end = floor_char_boundary(editor.get_buffer(), range.end); + let start = floor_char_boundary(editor.get_buffer(), range.start).min(end); + editor.get_buffer()[start..end].to_string() + }) .min_by_key(|s| s.width()) .unwrap_or_default(); @@ -659,17 +669,15 @@ impl Menu for ColumnarMenu { available_lines = painter.remaining_lines().min(self.min_rows()); } - let first_visible_row = self.skip_values / self.get_cols(); - - self.skip_values = if self.row_pos <= first_visible_row { + self.skip_rows = if self.row_pos < self.skip_rows { // Selection is above the visible area, scroll up - self.row_pos * self.get_cols() - } else if self.row_pos >= first_visible_row + available_lines { + self.row_pos + } else if self.row_pos >= self.skip_rows + available_lines { // Selection is below the visible area, scroll down - (self.row_pos.saturating_sub(available_lines) + 1) * self.get_cols() + self.row_pos - available_lines + 1 } else { // Selection is within the visible area - self.skip_values + self.skip_rows }; } } @@ -700,23 +708,58 @@ impl Menu for ColumnarMenu { // It seems that crossterm prefers to have a complete string ready to be printed // rather than looping through the values and printing multiple things // This reduces the flickering when printing the menu - let available_values = (available_lines * self.get_cols()) as usize; - let skip_values = self.skip_values as usize; - - self.get_values() - .iter() - .skip(skip_values) - .take(available_values) - .enumerate() - .map(|(index, suggestion)| { - // Correcting the enumerate index based on the number of skipped values - let index = index + skip_values; - let column = index as u16 % self.get_cols(); - let empty_space = self.get_width().saturating_sub(suggestion.value.width()); - - self.create_string(suggestion, index, column, empty_space, use_ansi_coloring) - }) - .collect() + match self.default_details.traversal_dir { + TraversalDirection::Vertical => { + let num_rows: usize = self.get_rows().into(); + let rows_to_draw = num_rows.min(available_lines.into()); + let mut menu_string = String::new(); + for line in 0..rows_to_draw { + let skip_value = self.skip_rows as usize + line; + let row_string: String = self + .get_values() + .iter() + .enumerate() + .skip(skip_value) + .step_by(num_rows) + .take(self.get_cols().into()) + .map(|(index, suggestion)| { + self.create_string(suggestion, index, use_ansi_coloring) + }) + .collect(); + menu_string.push_str(&row_string); + menu_string.push_str("\r\n"); + } + menu_string + } + TraversalDirection::Horizontal => { + let available_values = (available_lines * self.get_cols()) as usize; + let skip_values = (self.skip_rows * self.get_used_cols()) as usize; + + self.get_values() + .iter() + .skip(skip_values) + .take(available_values) + .enumerate() + .map(|(index, suggestion)| { + // Correcting the enumerate index based on the number of skipped values + let index = index + skip_values; + let column = index % self.get_cols() as usize; + + let end_of_line = + if column == self.get_cols().saturating_sub(1) as usize { + "\r\n" + } else { + "" + }; + format!( + "{}{}", + self.create_string(suggestion, index, use_ansi_coloring), + end_of_line + ) + }) + .collect() + } + } } } } @@ -808,6 +851,7 @@ mod tests { extra: None, span: Span { start: 0, end: pos }, append_whitespace: false, + ..Default::default() } } @@ -867,4 +911,138 @@ mod tests { menu.update_values(&mut editor, &mut completer); assert!(menu.menu_string(10, true).contains("验")); } + + #[test] + fn test_horizontal_menu_selection_position() { + // Test selection position update + let vs: Vec = (0..10).map(|v| v.to_string()).collect(); + let vs: Vec<_> = vs.iter().map(|v| v.as_ref()).collect(); + let mut completer = FakeCompleter::new(&vs); + let mut menu = ColumnarMenu::default() + .with_traversal_direction(TraversalDirection::Horizontal) + .with_name("testmenu"); + menu.working_details.columns = 4; + let mut editor = Editor::default(); + + editor.set_buffer("a".to_string(), UndoBehavior::CreateUndoPoint); + menu.update_values(&mut editor, &mut completer); + assert!(menu.index() == 0); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + // Next/previous wrapping + menu.move_previous(); + assert!(menu.index() == vs.len() - 1); + assert!(menu.row_pos == 2 && menu.col_pos == 1); + menu.move_next(); + assert!(menu.index() == 0); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + // Up/down/left/right wrapping for full rows/columns + menu.move_up(); + assert!(menu.row_pos == 2 && menu.col_pos == 0); + menu.move_down(); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + menu.move_left(); + assert!(menu.row_pos == 0 && menu.col_pos == 3); + menu.move_right(); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + // Up/down/left/right wrapping for non-full rows/columns + menu.move_left(); + assert!(menu.row_pos == 0 && menu.col_pos == 3); + menu.move_up(); + assert!(menu.row_pos == 1 && menu.col_pos == 3); + menu.move_down(); + assert!(menu.row_pos == 0 && menu.col_pos == 3); + menu.move_right(); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + menu.move_up(); + assert!(menu.row_pos == 2 && menu.col_pos == 0); + menu.move_left(); + assert!(menu.row_pos == 2 && menu.col_pos == 1); + menu.move_right(); + assert!(menu.row_pos == 2 && menu.col_pos == 0); + } + + #[test] + fn test_vertical_menu_selection_position() { + // Test selection position update + let vs: Vec = (0..11).map(|v| v.to_string()).collect(); + let vs: Vec<_> = vs.iter().map(|v| v.as_ref()).collect(); + let mut completer = FakeCompleter::new(&vs); + let mut menu = ColumnarMenu::default() + .with_traversal_direction(TraversalDirection::Vertical) + .with_name("testmenu"); + menu.working_details.columns = 4; + let mut editor = Editor::default(); + + editor.set_buffer("a".to_string(), UndoBehavior::CreateUndoPoint); + menu.update_values(&mut editor, &mut completer); + assert!(menu.index() == 0); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + // Next/previous wrapping + menu.move_previous(); + assert!(menu.index() == vs.len() - 1); + assert!(menu.row_pos == 1 && menu.col_pos == 3); + menu.move_next(); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + // Up/down/left/right wrapping for full rows/columns + menu.move_up(); + assert!(menu.row_pos == 2 && menu.col_pos == 0); + menu.move_down(); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + menu.move_left(); + assert!(menu.row_pos == 0 && menu.col_pos == 3); + menu.move_right(); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + // Up/down/left/right wrapping for non-full rows/columns + menu.move_left(); + assert!(menu.row_pos == 0 && menu.col_pos == 3); + menu.move_up(); + assert!(menu.row_pos == 1 && menu.col_pos == 3); + menu.move_down(); + assert!(menu.row_pos == 0 && menu.col_pos == 3); + menu.move_right(); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + menu.move_up(); + assert!(menu.row_pos == 2 && menu.col_pos == 0); + menu.move_left(); + assert!(menu.row_pos == 2 && menu.col_pos == 2); + menu.move_right(); + assert!(menu.row_pos == 2 && menu.col_pos == 0); + } + + #[test] + fn test_small_menu_selection_position() { + // Test selection position update for menus with fewer values than available columns + let mut vertical_menu = ColumnarMenu::default() + .with_traversal_direction(TraversalDirection::Vertical) + .with_name("testmenu"); + vertical_menu.working_details.columns = 4; + let mut horizontal_menu = ColumnarMenu::default() + .with_traversal_direction(TraversalDirection::Horizontal) + .with_name("testmenu"); + horizontal_menu.working_details.columns = 4; + let mut editor = Editor::default(); + + let mut completer = FakeCompleter::new(&["1", "2"]); + + for menu in &mut [vertical_menu, horizontal_menu] { + menu.update_values(&mut editor, &mut completer); + assert!(menu.index() == 0); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + menu.move_previous(); + assert!(menu.index() == menu.get_values().len() - 1); + assert!(menu.row_pos == 0 && menu.col_pos == 1); + menu.move_next(); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + menu.move_next(); + assert!(menu.row_pos == 0 && menu.col_pos == 1); + menu.move_right(); + assert!(menu.row_pos == 0 && menu.col_pos == 0); + menu.move_left(); + assert!(menu.row_pos == 0 && menu.col_pos == 1); + menu.move_up(); + assert!(menu.row_pos == 0 && menu.col_pos == 1); + menu.move_down(); + assert!(menu.row_pos == 0 && menu.col_pos == 1); + } + } } diff --git a/src/menu/ide_menu.rs b/src/menu/ide_menu.rs index afa1be5c7..8587b7349 100644 --- a/src/menu/ide_menu.rs +++ b/src/menu/ide_menu.rs @@ -1,7 +1,10 @@ use super::{Menu, MenuBuilder, MenuEvent, MenuSettings}; use crate::{ core_editor::Editor, - menu_functions::{can_partially_complete, completer_input, replace_in_buffer}, + menu_functions::{ + can_partially_complete, completer_input, floor_char_boundary, get_match_indices, + replace_in_buffer, style_suggestion, + }, painting::Painter, Completer, Suggestion, }; @@ -451,7 +454,7 @@ impl IdeMenu { RESET ); } else { - *line = format!("{}{}", line, padding); + *line = format!("{line}{padding}"); } } } @@ -516,76 +519,49 @@ impl IdeMenu { }; if use_ansi_coloring { - // strip quotes + // TODO(ysthakur): let the user strip quotes, rather than doing it here let is_quote = |c: char| "`'\"".contains(c); let shortest_base = &self.working_details.shortest_base_string; let shortest_base = shortest_base .strip_prefix(is_quote) .unwrap_or(shortest_base); - let match_len = shortest_base.chars().count().min(string.chars().count()); - - // Find match position - look for the base string in the suggestion (case-insensitive) - let match_position = string - .to_lowercase() - .find(&shortest_base.to_lowercase()) - .unwrap_or(0); - - // The match is just the part that matches the shortest_base - let match_str = { - let match_str = &string[match_position..]; - let match_len_bytes = match_str - .char_indices() - .nth(match_len) - .map(|(i, _)| i) - .unwrap_or_else(|| match_str.len()); - &string[match_position..match_position + match_len_bytes] - }; - - // Prefix is everything before the match - let prefix = &string[..match_position]; - // Remaining is everything after the match - let remaining_str = &string[match_position + match_str.len()..]; + let match_indices = + get_match_indices(&suggestion.value, &suggestion.match_indices, shortest_base); - let suggestion_style_prefix = suggestion - .style - .unwrap_or(self.settings.color.text_style) - .prefix(); + let suggestion_style = suggestion.style.unwrap_or(self.settings.color.text_style); if index == self.index() { format!( - "{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}", + "{}{}{}{}{}{}{}", vertical_border, - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - prefix, - RESET, - suggestion_style_prefix, + suggestion_style.prefix(), " ".repeat(padding), - self.settings.color.selected_match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - self.settings.color.selected_text_style.prefix(), - remaining_str, + style_suggestion( + &self + .settings + .color + .selected_text_style + .paint(&string) + .to_string(), + &match_indices, + &self.settings.color.selected_match_style, + ), " ".repeat(padding_right), RESET, vertical_border, ) } else { format!( - "{}{}{}{}{}{}{}{}{}{}{}{}{}{}", + "{}{}{}{}{}{}{}", vertical_border, - suggestion_style_prefix, - prefix, - RESET, - suggestion_style_prefix, + suggestion_style.prefix(), " ".repeat(padding), - self.settings.color.match_style.prefix(), - match_str, - RESET, - suggestion_style_prefix, - remaining_str, + style_suggestion( + &suggestion_style.paint(&string).to_string(), + &match_indices, + &self.settings.color.match_style, + ), " ".repeat(padding_right), RESET, vertical_border, @@ -673,7 +649,11 @@ impl Menu for IdeMenu { self.values = values; self.working_details.shortest_base_string = base_ranges .iter() - .map(|range| editor.get_buffer()[range.clone()].to_string()) + .map(|range| { + let end = floor_char_boundary(editor.get_buffer(), range.end); + let start = floor_char_boundary(editor.get_buffer(), range.start).min(end); + editor.get_buffer()[start..end].to_string() + }) .min_by_key(|s| s.len()) .unwrap_or_default(); @@ -980,7 +960,7 @@ impl Menu for IdeMenu { ) } Left(suggestion_line) => { - strings[idx] = format!("{}{}", distance_left, suggestion_line); + strings[idx] = format!("{distance_left}{suggestion_line}"); } Right(description_line) => strings.push(format!( "{}{}", @@ -1022,7 +1002,7 @@ impl Menu for IdeMenu { ); } Right(description_line) => { - strings.push(format!("{}{}", distance_left, description_line,)) + strings.push(format!("{distance_left}{description_line}",)) } } } @@ -1428,6 +1408,7 @@ mod tests { extra: None, span: Span { start: 0, end: pos }, append_whitespace: false, + ..Default::default() } } diff --git a/src/menu/menu_functions.rs b/src/menu/menu_functions.rs index 84575f0c2..0ceb1cce7 100644 --- a/src/menu/menu_functions.rs +++ b/src/menu/menu_functions.rs @@ -1,4 +1,9 @@ //! Collection of common functions that can be used to create menus +use std::borrow::Cow; + +use nu_ansi_term::{ansi::RESET, Style}; +use unicode_segmentation::UnicodeSegmentation; + use crate::{Editor, Suggestion, UndoBehavior}; /// Index result obtained from parsing a string with an index marker @@ -56,7 +61,7 @@ pub enum ParseAction { /// ) /// /// ``` -pub fn parse_selection_char(buffer: &str, marker: char) -> ParseResult { +pub fn parse_selection_char(buffer: &str, marker: char) -> ParseResult<'_> { if buffer.is_empty() { return ParseResult { remainder: buffer, @@ -70,7 +75,6 @@ pub fn parse_selection_char(buffer: &str, marker: char) -> ParseResult { let mut input = buffer.chars().peekable(); let mut index = 0; - let mut action = ParseAction::ForwardSearch; while let Some(char) = input.next() { if char == marker { match input.peek() { @@ -97,12 +101,15 @@ pub fn parse_selection_char(buffer: &str, marker: char) -> ParseResult { Some(&x) if x.is_ascii_digit() || x == '-' => { let mut count: usize = 0; let mut size: usize = marker.len_utf8(); + let action = if x == '-' { + size += 1; + let _ = input.next(); + ParseAction::BackwardSearch + } else { + ParseAction::ForwardSearch + }; while let Some(&c) = input.peek() { - if c == '-' { - let _ = input.next(); - size += 1; - action = ParseAction::BackwardSearch; - } else if c.is_ascii_digit() { + if c.is_ascii_digit() { let c = c.to_digit(10).expect("already checked if is a digit"); let _ = input.next(); count *= 10; @@ -141,7 +148,7 @@ pub fn parse_selection_char(buffer: &str, marker: char) -> ParseResult { remainder: &buffer[0..index], index: Some(0), marker: Some(&buffer[index..buffer.len()]), - action, + action: ParseAction::ForwardSearch, prefix: Some(&buffer[index..buffer.len()]), } } @@ -155,7 +162,7 @@ pub fn parse_selection_char(buffer: &str, marker: char) -> ParseResult { remainder: buffer, index: None, marker: None, - action, + action: ParseAction::ForwardSearch, prefix: None, } } @@ -295,6 +302,22 @@ pub fn completer_input( } } +/// Find the closest index less than or equal to the current index that's a +/// character boundary +/// +/// This is already a method on `str`, but it's nightly-only. Once that becomes +/// stable, this function will be removed. +pub fn floor_char_boundary(s: &str, index: usize) -> usize { + if index >= s.len() { + s.len() + } else { + (1..=index) + .rev() + .find(|i| s.is_char_boundary(*i)) + .unwrap_or(0) + } +} + /// Helper to accept a completion suggestion and edit the buffer pub fn replace_in_buffer(value: Option, editor: &mut Editor) { if let Some(Suggestion { @@ -304,8 +327,8 @@ pub fn replace_in_buffer(value: Option, editor: &mut Editor) { .. }) = value { - let start = span.start.min(editor.line_buffer().len()); - let end = span.end.min(editor.line_buffer().len()); + let end = floor_char_boundary(editor.get_buffer(), span.end); + let start = floor_char_boundary(editor.get_buffer(), span.start).min(end); if append_whitespace { value.push(' '); } @@ -325,21 +348,23 @@ pub fn can_partially_complete(values: &[Suggestion], editor: &mut Editor) -> boo if let (Some(Suggestion { value, span, .. }), Some(index)) = find_common_string(values) { let index = index.min(value.len()); let matching = &value[0..index]; + let end = floor_char_boundary(editor.get_buffer(), span.end); + let start = floor_char_boundary(editor.get_buffer(), span.start).min(end); // make sure that the partial completion does not overwrite user entered input - let extends_input = matching.starts_with(&editor.get_buffer()[span.start..span.end]) - && matching != &editor.get_buffer()[span.start..span.end]; + let extends_input = matching.starts_with(&editor.get_buffer()[start..end]) + && matching != &editor.get_buffer()[start..end]; if !matching.is_empty() && extends_input { let mut line_buffer = editor.line_buffer().clone(); - line_buffer.replace_range(span.start..span.end, matching); + line_buffer.replace_range(start..end, matching); - let offset = if matching.len() < (span.end - span.start) { + let offset = if matching.len() < (end - start) { line_buffer .insertion_point() - .saturating_sub((span.end - span.start) - matching.len()) + .saturating_sub((end - start) - matching.len()) } else { - line_buffer.insertion_point() + matching.len() - (span.end - span.start) + line_buffer.insertion_point() + matching.len() - (end - start) }; line_buffer.set_insertion_point(offset); @@ -354,10 +379,104 @@ pub fn can_partially_complete(values: &[Suggestion], editor: &mut Editor) -> boo } } +/// Parse ANSI sequences for setting display attributes in the given string. +/// Each returned item is a tuple (escape start, escape end, text end), for +/// finding each sequence and the text affected by it. +/// +/// Essentially just looks for 'ESC [' followed by /[0-9;]*m/, ignoring other ANSI sequences. +fn parse_ansi(s: &str) -> Vec<(usize, usize, usize)> { + let mut segments = Vec::new(); + + let mut last_escape_start = 0; + let mut last_escape_end = 0; + let mut offset = 0; + while offset < s.len() { + let Some(start) = &s[offset..].find("\x1b[") else { + break; + }; + let escape_start = offset + start; + + let after_params = &s[escape_start + 2..] + .trim_start_matches(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ';']); + if !after_params.starts_with('m') { + // Not a valid Select Graphic Rendition sequence + offset = s.len() - after_params.len(); + continue; + } + + if escape_start != 0 { + segments.push((last_escape_start, last_escape_end, escape_start)); + } + last_escape_start = escape_start; + last_escape_end = s.len() - after_params.len() + 1; + offset = last_escape_end; + } + + segments.push((last_escape_start, last_escape_end, s.len())); + segments +} + +/// Style a suggestion to be shown in a completer menu +/// +/// * `match_indices` - Indices of graphemes (NOT bytes or chars) that matched the typed text +/// * `match_style` - Style to use for matched characters +pub fn style_suggestion(suggestion: &str, match_indices: &[usize], match_style: &Style) -> String { + let mut res = String::new(); + let mut offset = 0; + for (escape_start, text_start, text_end) in parse_ansi(suggestion) { + let escape = &suggestion[escape_start..text_start]; + let text = &suggestion[text_start..text_end]; + let graphemes = text.graphemes(true).collect::>(); + let mut prev_matched = false; + + res.push_str(escape); + for (i, grapheme) in graphemes.iter().enumerate() { + let is_match = match_indices.contains(&(i + offset)); + + if is_match && !prev_matched { + res.push_str(&match_style.prefix().to_string()); + } else if !is_match && prev_matched && i != 0 { + res.push_str(RESET); + res.push_str(escape); + } + res.push_str(grapheme); + prev_matched = is_match; + } + + if prev_matched { + res.push_str(RESET); + } + + offset += graphemes.len(); + } + + res +} + +/// If `match_indices` is given, then returns that. Otherwise, tries to find `typed_text` +/// inside `value`, then returns the indices for that substring. +pub fn get_match_indices<'a>( + value: &str, + match_indices: &'a Option>, + typed_text: &str, +) -> Cow<'a, Vec> { + if let Some(inds) = match_indices { + Cow::Borrowed(inds) + } else { + let Some(match_pos) = value.to_lowercase().find(&typed_text.to_lowercase()) else { + // Don't highlight anything if no match + return Cow::Owned(vec![]); + }; + let match_len = typed_text.graphemes(true).count(); + Cow::Owned((match_pos..match_pos + match_len).collect()) + } +} + #[cfg(test)] mod tests { use super::*; use crate::{EditCommand, LineBuffer, Span}; + use nu_ansi_term::Color; use rstest::rstest; #[test] @@ -612,6 +731,7 @@ mod tests { extra: None, span: Span::new(0, s.len()), append_whitespace: false, + ..Default::default() }) .collect(); let res = find_common_string(&input); @@ -632,6 +752,7 @@ mod tests { extra: None, span: Span::new(0, s.len()), append_whitespace: false, + ..Default::default() }) .collect(); let res = find_common_string(&input); @@ -687,6 +808,7 @@ mod tests { extra: None, span: Span::new(start, end), append_whitespace: false, + ..Default::default() }), &mut editor, ); @@ -697,4 +819,72 @@ mod tests { assert_eq!(orig_buffer, editor.get_buffer()); assert_eq!(orig_insertion_point, editor.insertion_point()); } + + #[rstest] + #[case("plain", vec![(0, 0, 5)])] + #[case("\x1b[mempty", vec![(0, 3, 8)])] + #[case("\x1b[\x1b[minvalid", vec![(0, 0, 2), (2, 5, 12)])] + #[case("a\x1b[1;mb\x1b[;mc", vec![(0, 0, 1), (1, 6, 7), (7, 11, 12)])] + fn test_parse_ansi(#[case] s: &str, #[case] expected: Vec<(usize, usize, usize)>) { + assert_eq!(parse_ansi(s), expected); + } + + #[test] + fn style_fuzzy_suggestion() { + let match_style = Style::new().underline(); + let style1 = Style::new().on(Color::Blue); + let style2 = Style::new().on(Color::Green); + + let expected = format!( + "{}{}{}{}{}{}{}{}{}{}{}{}{}", + style1.prefix(), + "ab", + match_style.paint("汉"), + style1.prefix(), + "d", + RESET, + style2.prefix(), + match_style.paint("y̆👩🏾"), + style2.prefix(), + "e", + RESET, + "b@", + match_style.paint("r"), + ); + let match_indices = &[ + 2, // 汉 + 4, 5, // y̆👩🏾 + 9, // r + ]; + assert_eq!( + expected, + style_suggestion( + &format!("{}{}{}", style1.paint("ab汉d"), style2.paint("y̆👩🏾e"), "b@r"), + match_indices, + &match_style + ) + ); + } + + #[test] + fn style_fuzzy_suggestion_out_of_bounds() { + let text_style = Style::new().on(Color::Blue).bold(); + let match_style = Style::new().underline(); + + let expected = format!( + "{}{}{}{}", + text_style.prefix(), + "go", + match_style.paint("o"), + RESET + ); + assert_eq!( + expected, + style_suggestion( + &text_style.paint("goo").to_string(), + &[2, 3, 4, 6], + &match_style + ) + ); + } } diff --git a/src/menu/mod.rs b/src/menu/mod.rs index a08ccbc09..23cfc1bb5 100644 --- a/src/menu/mod.rs +++ b/src/menu/mod.rs @@ -8,6 +8,7 @@ use crate::core_editor::Editor; use crate::History; use crate::{completion::history::HistoryCompleter, painting::Painter, Completer, Suggestion}; pub use columnar_menu::ColumnarMenu; +pub use columnar_menu::TraversalDirection; pub use description_menu::DescriptionMenu; pub use ide_menu::DescriptionMode; pub use ide_menu::IdeMenu; diff --git a/src/painting/painter.rs b/src/painting/painter.rs index b48e0cfc8..b2ffb6e62 100644 --- a/src/painting/painter.rs +++ b/src/painting/painter.rs @@ -215,19 +215,19 @@ impl Painter { // We add one here as [`PromptLines::prompt_lines_with_wrap`] intentionally subtracts 1 from the real value. self.prompt_height = lines.prompt_lines_with_wrap(screen_width) + 1; + let lines_before_cursor = lines.required_lines(screen_width, true, None); - // Handle resize for multi line prompt + // Calibrate prompt start position for multi-line prompt/content before cursor. Check issue #841/#848/#930 if self.just_resized { - self.prompt_start_row = self.prompt_start_row.saturating_sub( - (lines.prompt_str_left.matches('\n').count() - + lines.prompt_indicator.matches('\n').count()) as u16, - ); + self.prompt_start_row = self + .prompt_start_row + .saturating_sub(lines_before_cursor - 1); self.just_resized = false; } // Lines and distance parameters let remaining_lines = self.remaining_lines(); - let required_lines = lines.required_lines(screen_width, menu); + let required_lines = lines.required_lines(screen_width, false, menu); // Marking the painter state as larger buffer to avoid animations self.large_buffer = required_lines >= screen_height; @@ -243,7 +243,7 @@ impl Painter { // Moving the start position of the cursor based on the size of the required lines if self.large_buffer || is_reset() { - for _ in 0..screen_height - lines.required_lines(screen_width, None) { + for _ in 0..screen_height.saturating_sub(lines_before_cursor) { self.stdout.queue(Print(&coerce_crlf("\n")))?; } self.prompt_start_row = 0; diff --git a/src/painting/prompt_lines.rs b/src/painting/prompt_lines.rs index 06082868b..6d7597cc1 100644 --- a/src/painting/prompt_lines.rs +++ b/src/painting/prompt_lines.rs @@ -57,19 +57,21 @@ impl<'prompt> PromptLines<'prompt> { /// The required lines to paint the buffer are calculated by counting the /// number of newlines in all the strings that form the prompt and buffer. /// The plus 1 is to indicate that there should be at least one line. - pub(crate) fn required_lines(&self, terminal_columns: u16, menu: Option<&ReedlineMenu>) -> u16 { - let input = if menu.is_none() { - self.prompt_str_left.to_string() - + &self.prompt_indicator - + &self.before_cursor - + &self.after_cursor - + &self.hint - } else { - self.prompt_str_left.to_string() - + &self.prompt_indicator - + &self.before_cursor - + &self.after_cursor - }; + pub(crate) fn required_lines( + &self, + terminal_columns: u16, + before_cursor: bool, + menu: Option<&ReedlineMenu>, + ) -> u16 { + let mut input = + self.prompt_str_left.to_string() + &self.prompt_indicator + &self.before_cursor; + + if !before_cursor { + input += &self.after_cursor; + if menu.is_none() { + input += &self.hint; + } + } let lines = estimate_required_lines(&input, terminal_columns); diff --git a/src/painting/utils.rs b/src/painting/utils.rs index 541e61775..0c9d8ed51 100644 --- a/src/painting/utils.rs +++ b/src/painting/utils.rs @@ -5,7 +5,7 @@ use unicode_width::UnicodeWidthStr; /// /// Needed for correct output in raw mode. /// Only replaces solitary LF with CRLF. -pub(crate) fn coerce_crlf(input: &str) -> Cow { +pub(crate) fn coerce_crlf(input: &str) -> Cow<'_, str> { let mut result = Cow::Borrowed(input); let mut cursor: usize = 0; for (idx, _) in input.match_indices('\n') { diff --git a/src/prompt/base.rs b/src/prompt/base.rs index db69f2e06..60a37010b 100644 --- a/src/prompt/base.rs +++ b/src/prompt/base.rs @@ -43,9 +43,10 @@ impl PromptHistorySearch { } /// Modes that the prompt can be in -#[derive(Serialize, Deserialize, Clone, Debug, EnumIter)] +#[derive(Serialize, Deserialize, Clone, Debug, EnumIter, Default)] pub enum PromptEditMode { /// The default mode + #[default] Default, /// Emacs normal mode @@ -85,18 +86,18 @@ impl Display for PromptEditMode { /// displayed before the `LineBuffer` is drawn. pub trait Prompt: Send { /// Provide content of the left full prompt - fn render_prompt_left(&self) -> Cow; + fn render_prompt_left(&self) -> Cow<'_, str>; /// Provide content of the right full prompt - fn render_prompt_right(&self) -> Cow; + fn render_prompt_right(&self) -> Cow<'_, str>; /// Render the prompt indicator (Last part of the prompt that changes based on the editor mode) - fn render_prompt_indicator(&self, prompt_mode: PromptEditMode) -> Cow; + fn render_prompt_indicator(&self, prompt_mode: PromptEditMode) -> Cow<'_, str>; /// Indicator to show before explicit new lines - fn render_prompt_multiline_indicator(&self) -> Cow; + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str>; /// Render the prompt indicator for `Ctrl-R` history search fn render_prompt_history_search_indicator( &self, history_search: PromptHistorySearch, - ) -> Cow; + ) -> Cow<'_, str>; /// Get the default prompt color fn get_prompt_color(&self) -> Color { DEFAULT_PROMPT_COLOR diff --git a/src/prompt/default.rs b/src/prompt/default.rs index f56ae9030..24625a2c0 100644 --- a/src/prompt/default.rs +++ b/src/prompt/default.rs @@ -38,7 +38,7 @@ pub enum DefaultPromptSegment { /// Given a prompt segment, render it to a Cow that we can use to /// easily implement [`Prompt`]'s `render_prompt_left` and `render_prompt_right` /// functions. -fn render_prompt_segment(prompt: &DefaultPromptSegment) -> Cow { +fn render_prompt_segment(prompt: &DefaultPromptSegment) -> Cow<'_, str> { match &prompt { DefaultPromptSegment::Basic(s) => Cow::Borrowed(s), DefaultPromptSegment::WorkingDirectory => { @@ -51,15 +51,15 @@ fn render_prompt_segment(prompt: &DefaultPromptSegment) -> Cow { } impl Prompt for DefaultPrompt { - fn render_prompt_left(&self) -> Cow { + fn render_prompt_left(&self) -> Cow<'_, str> { render_prompt_segment(&self.left_prompt) } - fn render_prompt_right(&self) -> Cow { + fn render_prompt_right(&self) -> Cow<'_, str> { render_prompt_segment(&self.right_prompt) } - fn render_prompt_indicator(&self, edit_mode: PromptEditMode) -> Cow { + fn render_prompt_indicator(&self, edit_mode: PromptEditMode) -> Cow<'_, str> { match edit_mode { PromptEditMode::Default | PromptEditMode::Emacs => DEFAULT_PROMPT_INDICATOR.into(), PromptEditMode::Vi(vi_mode) => match vi_mode { @@ -70,14 +70,14 @@ impl Prompt for DefaultPrompt { } } - fn render_prompt_multiline_indicator(&self) -> Cow { + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { Cow::Borrowed(DEFAULT_MULTILINE_INDICATOR) } fn render_prompt_history_search_indicator( &self, history_search: PromptHistorySearch, - ) -> Cow { + ) -> Cow<'_, str> { let prefix = match history_search.status { PromptHistorySearchStatus::Passing => "", PromptHistorySearchStatus::Failing => "failing ", diff --git a/src/terminal_extensions/kitty.rs b/src/terminal_extensions/kitty.rs index 65fb6e34a..c26452fea 100644 --- a/src/terminal_extensions/kitty.rs +++ b/src/terminal_extensions/kitty.rs @@ -12,15 +12,23 @@ use crossterm::{event, execute}; /// * [dte text editor](https://gitlab.com/craigbarnes/dte/-/issues/138) /// /// Refer to if you're curious. -#[derive(Default)] pub(crate) struct KittyProtocolGuard { enabled: bool, active: bool, + support_kitty_protocol: bool, } impl KittyProtocolGuard { + pub fn new() -> Self { + Self { + support_kitty_protocol: super::kitty_protocol_available(), + enabled: false, + active: false, + } + } + pub fn set(&mut self, enable: bool) { - self.enabled = enable && super::kitty_protocol_available(); + self.enabled = enable && self.support_kitty_protocol; } pub fn enter(&mut self) { if self.enabled && !self.active { From 177b119f4d95a450c4ee009716ca8b7b6f0bcb15 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 17 Nov 2025 14:14:10 -0500 Subject: [PATCH 4/6] allow for `keycode: enter` keybindings in `vi_insert` mode --- src/edit_mode/vi/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index 6868f1c53..ad5313af9 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -201,7 +201,9 @@ impl EditMode for Vi { (_, KeyModifiers::NONE, KeyCode::Esc, _, _) => exit_insert_mode(self, ViMode::Normal), (_, KeyModifiers::NONE, KeyCode::Enter, _, _) => { self.mode = ViMode::Insert; - ReedlineEvent::Enter + self.insert_keybindings + .find_binding(modifiers, code) + .unwrap_or(ReedlineEvent::Enter) } (ViMode::Normal | ViMode::Visual, _, _, _, _) => self .normal_keybindings From 710829284b7db155155ca5eb95962a712d05a4a7 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 22 Dec 2025 13:30:56 -0500 Subject: [PATCH 5/6] when exit insert mode, move left like vim --- src/edit_mode/vi/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/edit_mode/vi/mod.rs b/src/edit_mode/vi/mod.rs index ad5313af9..988a6d06f 100644 --- a/src/edit_mode/vi/mod.rs +++ b/src/edit_mode/vi/mod.rs @@ -85,7 +85,7 @@ fn exit_insert_mode(editor: &mut Vi, new_mode: ViMode) -> ReedlineEvent { editor.most_recent_keycode = KeyCode::Null; editor.cache.clear(); editor.mode = new_mode; - ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint, ReedlineEvent::Left]) } impl EditMode for Vi { From 0089c4c3e88648d4b478af966b5d80e93b07e49c Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 5 Jan 2026 11:10:39 -0500 Subject: [PATCH 6/6] git checkout upstream/main to correct a couple of incorrectly-merged files --- Cargo.lock | 743 +++++++++++++++++--------------------- src/menu/columnar_menu.rs | 134 ------- 2 files changed, 330 insertions(+), 547 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 432beff47..e51b2c403 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,13 +4,19 @@ version = 3 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -33,37 +39,43 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] [[package]] name = "autocfg" -version = "1.5.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "bitflags" -version = "2.10.0" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" dependencies = [ - "serde_core", + "serde", ] [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" [[package]] name = "cc" -version = "1.2.51" +version = "1.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44" dependencies = [ "find-msvc-tools", "shlex", @@ -71,20 +83,21 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a" dependencies = [ + "android-tzdata", "iana-time-zone", "num-traits", "serde", - "windows-link", + "windows-targets 0.52.4", ] [[package]] @@ -98,18 +111,18 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.10.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" dependencies = [ "unicode-segmentation", ] [[package]] name = "core-foundation-sys" -version = "0.8.7" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] name = "crossbeam" @@ -135,9 +148,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -154,18 +167,18 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" [[package]] name = "crossterm" @@ -173,13 +186,13 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.9.4", "crossterm_winapi", "derive_more", "document-features", "mio", "parking_lot", - "rustix", + "rustix 1.1.2", "serde", "signal-hook", "signal-hook-mio", @@ -197,23 +210,22 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.1.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.1.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "convert_case", "proc-macro2", "quote", - "rustc_version", "syn", ] @@ -229,7 +241,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags", + "bitflags 2.9.4", "objc2", ] @@ -244,21 +256,21 @@ dependencies = [ [[package]] name = "downcast-rs" -version = "1.2.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" [[package]] name = "either" -version = "1.15.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" @@ -267,14 +279,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "error-code" -version = "3.3.2" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" [[package]] name = "fallible-iterator" @@ -301,21 +313,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix", - "windows-sys 0.59.0", + "rustix 1.1.2", + "windows-sys 0.52.0", ] [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" [[package]] name = "fixedbitset" -version = "0.5.7" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "foldhash" @@ -333,16 +345,6 @@ dependencies = [ "windows-targets 0.48.5", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix", - "windows-link", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -357,9 +359,15 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" [[package]] name = "hashbrown" @@ -370,12 +378,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "hashlink" version = "0.10.0" @@ -387,21 +389,26 @@ dependencies = [ [[package]] name = "heck" -version = "0.5.0" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", - "log", "wasm-bindgen", "windows-core", ] @@ -417,12 +424,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.14.3", ] [[package]] @@ -436,25 +443,24 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" dependencies = [ - "once_cell", "wasm-bindgen", ] [[package]] name = "libc" -version = "0.2.179" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libsqlite3-sys" @@ -467,6 +473,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -481,60 +493,69 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" -version = "0.4.14" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" dependencies = [ + "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.29" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "memchr" -version = "2.7.6" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.1.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ + "hermit-abi", "libc", "log", "wasi", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "nom" -version = "8.0.0" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", + "minimal-lexical", ] [[package]] name = "nu-ansi-term" -version = "0.50.3" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "dd2800e1520bdc966782168a627aa5d1ad92e33b984bf7c7615d31280c83ff14" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] name = "num-traits" -version = "0.2.19" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" dependencies = [ "autocfg", ] @@ -554,7 +575,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags", + "bitflags 2.9.4", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -566,7 +587,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.9.4", "dispatch2", "objc2", ] @@ -577,7 +598,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags", + "bitflags 2.9.4", "dispatch2", "objc2", "objc2-core-foundation", @@ -596,7 +617,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", ] @@ -607,16 +628,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags", + "bitflags 2.9.4", "objc2", "objc2-core-foundation", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "os_pipe" @@ -625,14 +646,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] name = "parking_lot" -version = "0.12.5" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" dependencies = [ "lock_api", "parking_lot_core", @@ -640,15 +661,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.12" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-targets 0.48.5", ] [[package]] @@ -659,26 +680,25 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petgraph" -version = "0.8.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +checksum = "e1d3afd2628e69da2be385eb6f2fd57c8ac7977ceeff6dc166ff1657b0e386a9" dependencies = [ "fixedbitset", - "hashbrown 0.15.5", "indexmap", ] [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" [[package]] name = "pretty_assertions" -version = "1.4.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" dependencies = [ "diff", "yansi", @@ -686,27 +706,27 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" dependencies = [ "unicode-ident", ] [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -719,11 +739,11 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "redox_syscall" -version = "0.5.18" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -735,7 +755,7 @@ dependencies = [ "crossbeam", "crossterm", "fd-lock", - "gethostname 0.4.3", + "gethostname", "itertools", "nu-ansi-term", "pretty_assertions", @@ -755,9 +775,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -767,9 +787,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -778,9 +798,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "relative-path" @@ -821,7 +841,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags", + "bitflags 2.9.4", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -840,22 +860,41 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.9.4", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.52.0", ] [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + +[[package]] +name = "ryu" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" [[package]] name = "scopeguard" @@ -865,34 +904,24 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" [[package]] name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", @@ -901,15 +930,13 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" dependencies = [ "itoa", - "memchr", + "ryu", "serde", - "serde_core", - "zmij", ] [[package]] @@ -920,9 +947,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" -version = "0.3.18" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" dependencies = [ "libc", "signal-hook-registry", @@ -930,9 +957,9 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", "mio", @@ -941,40 +968,39 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.8" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" dependencies = [ - "errno", "libc", ] [[package]] name = "smallvec" -version = "1.15.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "strip-ansi-escapes" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +checksum = "55ff8ef943b384c414f54aefa961dd2bd853add74ec75e7ac74cf91dba62bcfa" dependencies = [ "vte", ] [[package]] name = "strum" -version = "0.26.3" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" dependencies = [ "heck", "proc-macro2", @@ -985,9 +1011,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.113" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -996,31 +1022,31 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom", "once_cell", - "rustix", - "windows-sys 0.61.2", + "rustix 1.1.2", + "windows-sys 0.52.0", ] [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", @@ -1029,12 +1055,13 @@ dependencies = [ [[package]] name = "tree_magic_mini" -version = "3.2.2" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c" dependencies = [ "memchr", "nom", + "once_cell", "petgraph", ] @@ -1046,21 +1073,27 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "vcpkg" @@ -1070,18 +1103,29 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vte" -version = "0.14.1" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" dependencies = [ - "memchr", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d257817081c7dffcdbab24b9e62d2def62e2ff7d00b1c20062551e6cccc145ff" +dependencies = [ + "proc-macro2", + "quote", ] [[package]] name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasip2" @@ -1094,22 +1138,34 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" dependencies = [ "cfg-if", - "once_cell", - "rustversion", "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1117,58 +1173,55 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ - "bumpalo", "proc-macro2", "quote", "syn", + "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" -dependencies = [ - "unicode-ident", -] +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" [[package]] name = "wayland-backend" -version = "0.3.12" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 1.1.2", "smallvec", "wayland-sys", ] [[package]] name = "wayland-client" -version = "0.31.12" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags", - "rustix", + "bitflags 2.9.4", + "rustix 1.1.2", "wayland-backend", "wayland-scanner", ] [[package]] name = "wayland-protocols" -version = "0.32.10" +version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags", + "bitflags 2.9.4", "wayland-backend", "wayland-client", "wayland-scanner", @@ -1176,11 +1229,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.10" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags", + "bitflags 2.9.4", "wayland-backend", "wayland-client", "wayland-protocols", @@ -1189,9 +1242,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.8" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" dependencies = [ "proc-macro2", "quick-xml", @@ -1200,9 +1253,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.8" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ "pkg-config", ] @@ -1231,88 +1284,29 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.6", + "windows-targets 0.52.4", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets 0.53.5", + "windows-targets 0.48.5", ] [[package]] name = "windows-sys" -version = "0.61.2" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-link", + "windows-targets 0.52.4", ] [[package]] @@ -1332,35 +1326,17 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm 0.52.4", + "windows_aarch64_msvc 0.52.4", + "windows_i686_gnu 0.52.4", + "windows_i686_msvc 0.52.4", + "windows_x86_64_gnu 0.52.4", + "windows_x86_64_gnullvm 0.52.4", + "windows_x86_64_msvc 0.52.4", ] [[package]] @@ -1371,15 +1347,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.6" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" [[package]] name = "windows_aarch64_msvc" @@ -1389,15 +1359,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.6" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" [[package]] name = "windows_i686_gnu" @@ -1407,27 +1371,9 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" [[package]] name = "windows_i686_msvc" @@ -1437,15 +1383,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.6" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" [[package]] name = "windows_x86_64_gnu" @@ -1455,15 +1395,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.6" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" [[package]] name = "windows_x86_64_gnullvm" @@ -1473,15 +1407,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" [[package]] name = "windows_x86_64_msvc" @@ -1491,15 +1419,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.6" +version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" [[package]] name = "wit-bindgen" @@ -1509,14 +1431,15 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wl-clipboard-rs" -version = "0.9.3" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb" dependencies = [ "libc", "log", "os_pipe", - "rustix", + "rustix 0.38.44", + "tempfile", "thiserror", "tree_magic_mini", "wayland-backend", @@ -1527,29 +1450,23 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.13.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +checksum = "f8f25ead8c7e4cba123243a6367da5d3990e0d3affa708ea19dce96356bd9f1a" dependencies = [ - "gethostname 1.1.0", - "rustix", + "gethostname", + "rustix 0.38.44", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.13.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +checksum = "e63e71c4b8bd9ffec2c963173a4dc4cbde9ee96961d4fcb4429db9929b606c34" [[package]] name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "zmij" -version = "1.0.10" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" diff --git a/src/menu/columnar_menu.rs b/src/menu/columnar_menu.rs index fcf34522c..4e1ad6501 100644 --- a/src/menu/columnar_menu.rs +++ b/src/menu/columnar_menu.rs @@ -1073,138 +1073,4 @@ mod tests { assert!(menu.row_pos == 0 && menu.col_pos == 1); } } - - // #[test] - // fn test_horizontal_menu_selection_position() { - // // Test selection position update - // let vs: Vec = (0..10).map(|v| v.to_string()).collect(); - // let vs: Vec<_> = vs.iter().map(|v| v.as_ref()).collect(); - // let mut completer = FakeCompleter::new(&vs); - // let mut menu = ColumnarMenu::default() - // .with_traversal_direction(TraversalDirection::Horizontal) - // .with_name("testmenu"); - // menu.working_details.columns = 4; - // let mut editor = Editor::default(); - // - // editor.set_buffer("a".to_string(), UndoBehavior::CreateUndoPoint); - // menu.update_values(&mut editor, &mut completer); - // assert!(menu.index() == 0); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // // Next/previous wrapping - // menu.move_previous(); - // assert!(menu.index() == vs.len() - 1); - // assert!(menu.row_pos == 2 && menu.col_pos == 1); - // menu.move_next(); - // assert!(menu.index() == 0); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // // Up/down/left/right wrapping for full rows/columns - // menu.move_up(); - // assert!(menu.row_pos == 2 && menu.col_pos == 0); - // menu.move_down(); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // menu.move_left(); - // assert!(menu.row_pos == 0 && menu.col_pos == 3); - // menu.move_right(); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // // Up/down/left/right wrapping for non-full rows/columns - // menu.move_left(); - // assert!(menu.row_pos == 0 && menu.col_pos == 3); - // menu.move_up(); - // assert!(menu.row_pos == 1 && menu.col_pos == 3); - // menu.move_down(); - // assert!(menu.row_pos == 0 && menu.col_pos == 3); - // menu.move_right(); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // menu.move_up(); - // assert!(menu.row_pos == 2 && menu.col_pos == 0); - // menu.move_left(); - // assert!(menu.row_pos == 2 && menu.col_pos == 1); - // menu.move_right(); - // assert!(menu.row_pos == 2 && menu.col_pos == 0); - // } - - // #[test] - // fn test_vertical_menu_selection_position() { - // // Test selection position update - // let vs: Vec = (0..11).map(|v| v.to_string()).collect(); - // let vs: Vec<_> = vs.iter().map(|v| v.as_ref()).collect(); - // let mut completer = FakeCompleter::new(&vs); - // let mut menu = ColumnarMenu::default() - // .with_traversal_direction(TraversalDirection::Vertical) - // .with_name("testmenu"); - // menu.working_details.columns = 4; - // let mut editor = Editor::default(); - // - // editor.set_buffer("a".to_string(), UndoBehavior::CreateUndoPoint); - // menu.update_values(&mut editor, &mut completer); - // assert!(menu.index() == 0); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // // Next/previous wrapping - // menu.move_previous(); - // assert!(menu.index() == vs.len() - 1); - // assert!(menu.row_pos == 1 && menu.col_pos == 3); - // menu.move_next(); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // // Up/down/left/right wrapping for full rows/columns - // menu.move_up(); - // assert!(menu.row_pos == 2 && menu.col_pos == 0); - // menu.move_down(); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // menu.move_left(); - // assert!(menu.row_pos == 0 && menu.col_pos == 3); - // menu.move_right(); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // // Up/down/left/right wrapping for non-full rows/columns - // menu.move_left(); - // assert!(menu.row_pos == 0 && menu.col_pos == 3); - // menu.move_up(); - // assert!(menu.row_pos == 1 && menu.col_pos == 3); - // menu.move_down(); - // assert!(menu.row_pos == 0 && menu.col_pos == 3); - // menu.move_right(); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // menu.move_up(); - // assert!(menu.row_pos == 2 && menu.col_pos == 0); - // menu.move_left(); - // assert!(menu.row_pos == 2 && menu.col_pos == 2); - // menu.move_right(); - // assert!(menu.row_pos == 2 && menu.col_pos == 0); - // } - - // #[test] - // fn test_small_menu_selection_position() { - // // Test selection position update for menus with fewer values than available columns - // let mut vertical_menu = ColumnarMenu::default() - // .with_traversal_direction(TraversalDirection::Vertical) - // .with_name("testmenu"); - // vertical_menu.working_details.columns = 4; - // let mut horizontal_menu = ColumnarMenu::default() - // .with_traversal_direction(TraversalDirection::Horizontal) - // .with_name("testmenu"); - // horizontal_menu.working_details.columns = 4; - // let mut editor = Editor::default(); - // - // let mut completer = FakeCompleter::new(&["1", "2"]); - // - // for menu in &mut [vertical_menu, horizontal_menu] { - // menu.update_values(&mut editor, &mut completer); - // assert!(menu.index() == 0); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // menu.move_previous(); - // assert!(menu.index() == menu.get_values().len() - 1); - // assert!(menu.row_pos == 0 && menu.col_pos == 1); - // menu.move_next(); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // menu.move_next(); - // assert!(menu.row_pos == 0 && menu.col_pos == 1); - // menu.move_right(); - // assert!(menu.row_pos == 0 && menu.col_pos == 0); - // menu.move_left(); - // assert!(menu.row_pos == 0 && menu.col_pos == 1); - // menu.move_up(); - // assert!(menu.row_pos == 0 && menu.col_pos == 1); - // menu.move_down(); - // assert!(menu.row_pos == 0 && menu.col_pos == 1); - // } - // } }