diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18c11e0d2..290cfb400 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: rust: - stable # Define the feature sets that will be built here (for caching you define a separate name) - style: [bashisms, default, sqlite, basqlite, external_printer] + style: [bashisms, default, sqlite, basqlite, external_printer, hx] include: - style: bashisms flags: "--features bashisms" @@ -27,6 +27,8 @@ jobs: flags: "--features sqlite" - style: basqlite flags: "--features bashisms,sqlite" + - style: hx + flags: "--features hx" runs-on: ${{ matrix.platform }} diff --git a/Cargo.lock b/Cargo.lock index 42e817b7c..39a5ec1d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + [[package]] name = "arboard" version = "3.6.1" @@ -109,6 +115,12 @@ dependencies = [ "error-code", ] +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "convert_case" version = "0.7.1" @@ -180,6 +192,22 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.9.4", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -188,7 +216,7 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.9.4", "crossterm_winapi", - "derive_more", + "derive_more 2.0.1", "document-features", "libc", "mio", @@ -209,6 +237,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -224,7 +265,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ - "convert_case", + "convert_case 0.7.1", "proc-macro2", "quote", "syn", @@ -261,12 +302,53 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" +[[package]] +name = "editor-types" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e99679670f67825fcd24a23cb4eb655a0f92c82bd4d1c1a1357c0cd71e87" +dependencies = [ + "bitflags 2.9.4", + "editor-types-macros", + "keybindings", + "regex", +] + +[[package]] +name = "editor-types-macros" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42680de76cf91f231abd90cc623750d39077f7d2fadb7962325fb082871f4c66" +dependencies = [ + "editor-types-parser", + "nom", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "editor-types-parser" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cac4b91fe830fbbe0a60c37ba0264b6e9ffc70e3664c028234dac59e79299ad4" +dependencies = [ + "nom", + "thiserror 1.0.69", +] + [[package]] name = "either" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "equivalent" version = "1.0.1" @@ -433,6 +515,15 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "intervaltree" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "270bc34e57047cab801a8c871c124d9dc7132f6473c6401f645524f4e6edd111" +dependencies = [ + "smallvec", +] + [[package]] name = "itertools" version = "0.13.0" @@ -457,6 +548,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keybindings" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a726307ed87e05155c31329676130e6a237e62dda80211f7e1ed811e47630f" +dependencies = [ + "textwrap", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "libc" version = "0.2.177" @@ -533,6 +635,37 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "modalkit" +version = "0.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cbb03c35f23ec7d13f7870049803cd8829f5e60b69d38fa98f5e7876de9f34e" +dependencies = [ + "anymap2", + "bitflags 2.9.4", + "crossterm 0.28.1", + "derive_more 0.99.20", + "editor-types", + "intervaltree", + "keybindings", + "nom", + "radix_trie", + "regex", + "ropey", + "thiserror 1.0.69", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nom" version = "7.1.3" @@ -647,7 +780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -738,6 +871,16 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -754,10 +897,11 @@ dependencies = [ "arboard", "chrono", "crossbeam", - "crossterm", + "crossterm 0.29.0", "fd-lock", "gethostname", "itertools", + "modalkit", "nu-ansi-term", "pretty_assertions", "rstest", @@ -768,10 +912,10 @@ dependencies = [ "strum", "strum_macros", "tempfile", - "thiserror", + "thiserror 2.0.12", "unicase", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -809,6 +953,16 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + [[package]] name = "rstest" version = "0.23.0" @@ -982,6 +1136,18 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + [[package]] name = "strip-ansi-escapes" version = "0.2.0" @@ -1034,13 +1200,44 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.2.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1078,12 +1275,24 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.0" @@ -1441,7 +1650,7 @@ dependencies = [ "os_pipe", "rustix 0.38.44", "tempfile", - "thiserror", + "thiserror 2.0.12", "tree_magic_mini", "wayland-backend", "wayland-client", diff --git a/Cargo.toml b/Cargo.toml index f18c765e1..60e1b56d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" license = "MIT" name = "reedline" repository = "https://github.com/nushell/reedline" -rust-version = "1.63.0" +rust-version = "1.74.0" version = "0.45.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -24,6 +24,7 @@ crossbeam = { version = "0.8.2", optional = true } crossterm = { version = "0.29.0", features = ["serde"] } fd-lock = "4.0.2" itertools = "0.13.0" +modalkit = "0.0.24" nu-ansi-term = "0.50.0" rusqlite = { version = "0.37.0", optional = true } serde = { version = "1.0", features = ["derive"] } @@ -51,6 +52,7 @@ sqlite = ["rusqlite/bundled", "serde_json"] sqlite-dynlib = ["rusqlite", "serde_json"] system_clipboard = ["arboard"] libc = ["crossterm/libc"] +hx = [] [[example]] name = "cwd_aware_hinter" diff --git a/src/edit_mode/hx/bindings.rs b/src/edit_mode/hx/bindings.rs new file mode 100644 index 000000000..ee09427ac --- /dev/null +++ b/src/edit_mode/hx/bindings.rs @@ -0,0 +1,69 @@ +use modalkit::keybindings::{EdgeEvent, EdgeRepeat, InputBindings}; + +use super::commands::{ + APPEND_MODE, INSERT_MODE, MOVE_CHAR_LEFT, MOVE_CHAR_RIGHT, MOVE_VISUAL_LINE_DOWN, + MOVE_VISUAL_LINE_UP, +}; +use super::{HelixAction, HelixMachine, HelixMode, HelixStep, ESC}; + +#[derive(Default)] +pub(super) struct HelixBindings; + +const BINDINGS: &[(HelixMode, char, HelixStep)] = &[ + (HelixMode::Insert, ESC, (None, Some(HelixMode::Normal))), + ( + HelixMode::Normal, + 'h', + (Some(HelixAction::Motion(MOVE_CHAR_LEFT)), None), + ), + ( + HelixMode::Normal, + 'l', + (Some(HelixAction::Motion(MOVE_CHAR_RIGHT)), None), + ), + ( + HelixMode::Normal, + 'j', + (Some(HelixAction::Motion(MOVE_VISUAL_LINE_DOWN)), None), + ), + ( + HelixMode::Normal, + 'k', + (Some(HelixAction::Motion(MOVE_VISUAL_LINE_UP)), None), + ), + // Insert mode entry + (HelixMode::Normal, 'i', INSERT_MODE), + (HelixMode::Normal, 'a', APPEND_MODE), + // v toggles between Normal and Select + (HelixMode::Normal, 'v', (None, Some(HelixMode::Select))), + (HelixMode::Select, 'v', (None, Some(HelixMode::Normal))), + // Select mode has the same motion bindings as Normal + ( + HelixMode::Select, + 'h', + (Some(HelixAction::Motion(MOVE_CHAR_LEFT)), None), + ), + ( + HelixMode::Select, + 'l', + (Some(HelixAction::Motion(MOVE_CHAR_RIGHT)), None), + ), + ( + HelixMode::Select, + 'j', + (Some(HelixAction::Motion(MOVE_VISUAL_LINE_DOWN)), None), + ), + ( + HelixMode::Select, + 'k', + (Some(HelixAction::Motion(MOVE_VISUAL_LINE_UP)), None), + ), +]; + +impl InputBindings for HelixBindings { + fn setup(&self, machine: &mut HelixMachine) { + for &(mode, key, ref step) in BINDINGS { + machine.add_mapping(mode, &[(EdgeRepeat::Once, EdgeEvent::Key(key))], step); + } + } +} diff --git a/src/edit_mode/hx/commands.rs b/src/edit_mode/hx/commands.rs new file mode 100644 index 000000000..1175f588b --- /dev/null +++ b/src/edit_mode/hx/commands.rs @@ -0,0 +1,57 @@ +//! Helix command catalog for reedline's hx mode. +//! +//! Each constant maps a Helix command name to a native `modalkit` motion target, +//! so keybindings can stay traceable to Helix docs without translation layers. +//! Reference: + +use modalkit::prelude::{Count, EditTarget, MoveDir1D, MoveType}; + +use super::{HelixAction, HelixMode, HelixStep}; + +/// `move_char_left` +pub(super) const MOVE_CHAR_LEFT: EditTarget = EditTarget::Motion( + MoveType::Column(MoveDir1D::Previous, false), + Count::Contextual, +); + +/// `move_char_right` +pub(super) const MOVE_CHAR_RIGHT: EditTarget = + EditTarget::Motion(MoveType::Column(MoveDir1D::Next, false), Count::Contextual); + +/// `move_visual_line_down` +pub(super) const MOVE_VISUAL_LINE_DOWN: EditTarget = + EditTarget::Motion(MoveType::Line(MoveDir1D::Next), Count::Contextual); + +/// `move_visual_line_up` +pub(super) const MOVE_VISUAL_LINE_UP: EditTarget = + EditTarget::Motion(MoveType::Line(MoveDir1D::Previous), Count::Contextual); + +/// `insert_mode` (`i`): enter Insert with cursor before the current selection. +pub(super) const INSERT_MODE: HelixStep = (None, Some(HelixMode::Insert)); + +/// `append_mode` (`a`): enter Insert with cursor after the current selection. +pub(super) const APPEND_MODE: HelixStep = ( + Some(HelixAction::Motion(MOVE_CHAR_RIGHT)), + Some(HelixMode::Insert), +); + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_insert_mode_enters_before_selection() { + let (action, mode) = INSERT_MODE.clone(); + // In our collapsed-selection model, "before selection" means no pre-insert motion. + assert_eq!(action, None); + assert_eq!(mode, Some(HelixMode::Insert)); + } + + #[test] + fn test_append_mode_enters_after_selection() { + let (action, mode) = APPEND_MODE.clone(); + // In our collapsed-selection model, "after selection" is encoded as one-char right motion. + assert_eq!(action, Some(HelixAction::Motion(MOVE_CHAR_RIGHT))); + assert_eq!(mode, Some(HelixMode::Insert)); + } +} diff --git a/src/edit_mode/hx/mod.rs b/src/edit_mode/hx/mod.rs new file mode 100644 index 000000000..912a7d83d --- /dev/null +++ b/src/edit_mode/hx/mod.rs @@ -0,0 +1,260 @@ +mod bindings; +mod commands; + +use modalkit::{ + keybindings::{BindingMachine, EmptyKeyState, InputKey, ModalMachine, Mode, ModeKeys}, + prelude::EditTarget, +}; + +const ESC: char = '\u{1B}'; + +#[derive(Clone, Copy, Debug, Default, Hash, Eq, PartialEq)] +/// Modal states for the experimental Helix edit mode key machine. +pub enum HelixMode { + #[default] + Insert, + Normal, + Select, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +/// Actions produced by the experimental Helix edit mode key machine. +pub enum HelixAction { + Type(char), + Motion(EditTarget), + #[default] + NoOp, +} + +impl HelixAction { + fn motion_target(&self) -> Option<&EditTarget> { + match self { + HelixAction::Motion(target) => Some(target), + _ => None, + } + } +} + +type HelixStep = (Option, Option); +/// Modal keybinding machine used by reedline's experimental Helix edit mode. +pub type HelixMachine = ModalMachine; + +impl Mode for HelixMode {} + +impl ModeKeys for HelixMode { + fn unmapped(&self, key: &K, _: &mut EmptyKeyState) -> (Vec, Option) { + match self { + HelixMode::Normal | HelixMode::Select => (vec![], None), + HelixMode::Insert => match key.get_char() { + Some(c) => (vec![HelixAction::Type(c)], None), + None => (vec![], None), + }, + } + } +} + +#[cfg(test)] +#[cfg(feature = "hx")] +mod test { + + use super::bindings::HelixBindings; + use super::commands::{ + APPEND_MODE, INSERT_MODE, MOVE_CHAR_LEFT, MOVE_CHAR_RIGHT, MOVE_VISUAL_LINE_DOWN, + MOVE_VISUAL_LINE_UP, + }; + use super::*; + use modalkit::{ + actions::{EditAction, EditorActions}, + editing::{ + application::EmptyInfo, + buffer::EditBuffer, + context::EditContextBuilder, + cursor::{Cursor, CursorGroup, CursorState}, + store::Store, + }, + prelude::{TargetShape, ViewportContext}, + }; + + use rstest::*; + + struct TestBuf { + ebuf: EditBuffer, + gid: modalkit::editing::buffer::CursorGroupId, + vwctx: ViewportContext, + store: Store, + } + + impl TestBuf { + fn new(s: &str, start: Cursor) -> Self { + let mut ebuf = EditBuffer::new("".to_string()); + let gid = ebuf.create_group(); + let vwctx = ViewportContext::default(); + let store = Store::default(); + + ebuf.set_text(s); + + let leader = CursorState::Selection(start.clone(), start, TargetShape::CharWise); + ebuf.set_group(gid, CursorGroup::new(leader, vec![])); + + Self { + ebuf, + gid, + vwctx, + store, + } + } + + fn apply_motion(&mut self, action: &HelixAction) { + let target = action + .motion_target() + .expect("action should map to an EditTarget"); + let ectx = EditContextBuilder::default() + .target_shape(Some(TargetShape::CharWise)) + .build(); + let ctx = &(self.gid, &self.vwctx, &ectx); + self.ebuf + .edit(&EditAction::Motion, target, ctx, &mut self.store) + .unwrap(); + } + + fn leader(&mut self) -> Cursor { + self.ebuf.get_leader(self.gid) + } + + fn selection(&mut self) -> Option<(Cursor, Cursor, TargetShape)> { + self.ebuf.get_leader_selection(self.gid) + } + } + + #[test] + fn test_insert_mode_is_default() { + assert_eq!(HelixMachine::empty().mode(), HelixMode::Insert); + } + + #[test] + fn test_escape_to_normal_mode() { + let mut machine = HelixMachine::from_bindings::(); + machine.input_key(ESC); + assert_eq!(machine.mode(), HelixMode::Normal); + } + + #[fixture] + fn normal_machine() -> HelixMachine { + let mut machine = HelixMachine::from_bindings::(); + machine.input_key(ESC); + let _ = machine.pop(); + machine + } + + #[rstest] + #[case('h', HelixAction::Motion(MOVE_CHAR_LEFT), Cursor::new(0, 1))] + #[case('l', HelixAction::Motion(MOVE_CHAR_RIGHT), Cursor::new(0, 3))] + fn test_move_char( + mut normal_machine: HelixMachine, + #[case] key: char, + #[case] expected_action: HelixAction, + #[case] end: Cursor, + ) { + normal_machine.input_key(key); + let (action, _) = normal_machine.pop().unwrap(); + assert_eq!(action, expected_action); + + let mut tb = TestBuf::new("hello\n", Cursor::new(0, 2)); + tb.apply_motion(&action); + assert_eq!(tb.leader(), end); + } + + #[rstest] + #[case('j', HelixAction::Motion(MOVE_VISUAL_LINE_DOWN), Cursor::new(2, 2))] + #[case('k', HelixAction::Motion(MOVE_VISUAL_LINE_UP), Cursor::new(0, 2))] + fn test_move_line( + mut normal_machine: HelixMachine, + #[case] key: char, + #[case] expected_action: HelixAction, + #[case] end: Cursor, + ) { + normal_machine.input_key(key); + let (action, _) = normal_machine.pop().unwrap(); + assert_eq!(action, expected_action); + + let mut tb = TestBuf::new("hello\nworld\nfoo\n", Cursor::new(1, 2)); + tb.apply_motion(&action); + assert_eq!(tb.leader(), end); + } + + #[test] + fn test_cursor_always_has_selection() { + let mut tb = TestBuf::new("hello\n", Cursor::new(0, 2)); + assert_eq!( + tb.selection(), + Some((Cursor::new(0, 2), Cursor::new(0, 2), TargetShape::CharWise)) + ); + } + + #[rstest] + fn test_selection_survives_motion(mut normal_machine: HelixMachine) { + normal_machine.input_key('l'); + let (action, _) = normal_machine.pop().unwrap(); + + let mut tb = TestBuf::new("hello\n", Cursor::new(0, 2)); + tb.apply_motion(&action); + + let sel = tb.selection().unwrap(); + assert_eq!(sel.0, Cursor::new(0, 2), "anchor should stay at start"); + assert_eq!(sel.1, Cursor::new(0, 3), "head should have moved"); + assert_eq!(sel.2, TargetShape::CharWise, "shape should remain CharWise"); + } + + #[rstest] + fn test_v_toggles_select_mode(mut normal_machine: HelixMachine) { + normal_machine.input_key('v'); + let _ = normal_machine.pop(); + assert_eq!(normal_machine.mode(), HelixMode::Select); + + normal_machine.input_key('v'); + let _ = normal_machine.pop(); + assert_eq!(normal_machine.mode(), HelixMode::Normal); + } + + #[rstest] + fn test_i_enters_insert_mode(mut normal_machine: HelixMachine) { + normal_machine.input_key('i'); + let (action, _) = normal_machine.pop().unwrap(); + assert_eq!(action, INSERT_MODE.0.clone().unwrap_or_default()); + assert_eq!(normal_machine.mode(), HelixMode::Insert); + } + + #[rstest] + fn test_a_enters_insert_mode_after_moving_right(mut normal_machine: HelixMachine) { + normal_machine.input_key('a'); + let (action, _) = normal_machine.pop().unwrap(); + assert_eq!(action, APPEND_MODE.0.clone().unwrap_or_default()); + assert_eq!(normal_machine.mode(), HelixMode::Insert); + } + + #[rstest] + #[case("hello\n", Cursor::new(0, 1), &['l', 'l'], &[Cursor::new(0, 2), Cursor::new(0, 3)])] + #[case("hello\nworld\n", Cursor::new(0, 2), &['j', 'l'], &[Cursor::new(1, 2), Cursor::new(1, 3)])] + fn test_select_mode_anchor_fixed( + mut normal_machine: HelixMachine, + #[case] text: &str, + #[case] start: Cursor, + #[case] keys: &[char], + #[case] expected_heads: &[Cursor], + ) { + normal_machine.input_key('v'); + let _ = normal_machine.pop(); + + let mut tb = TestBuf::new(text, start.clone()); + + for (key, expected_head) in keys.iter().zip(expected_heads) { + normal_machine.input_key(*key); + let (action, _) = normal_machine.pop().unwrap(); + tb.apply_motion(&action); + + let sel = tb.selection().unwrap(); + assert_eq!(sel.0, start, "anchor should stay fixed"); + assert_eq!(sel.1, *expected_head, "head should have moved"); + } + } +} diff --git a/src/edit_mode/mod.rs b/src/edit_mode/mod.rs index 38e1456f5..66a0eac0c 100644 --- a/src/edit_mode/mod.rs +++ b/src/edit_mode/mod.rs @@ -7,5 +7,10 @@ mod vi; pub use base::EditMode; pub use cursors::CursorConfig; pub use emacs::{default_emacs_keybindings, Emacs}; + pub use keybindings::Keybindings; pub use vi::{default_vi_insert_keybindings, default_vi_normal_keybindings, Vi}; +#[cfg(feature = "hx")] +mod hx; +#[cfg(feature = "hx")] +pub use hx::HelixMachine; diff --git a/src/lib.rs b/src/lib.rs index 4bd27db74..03a96a40c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -269,6 +269,8 @@ pub use prompt::{ }; mod edit_mode; +#[cfg(feature = "hx")] +pub use edit_mode::HelixMachine; pub use edit_mode::{ default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings, CursorConfig, EditMode, Emacs, Keybindings, Vi,