diff --git a/.gitignore b/.gitignore index d909355c..7ac05d38 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ tarpaulin-report.html *.rsproj *.rsproj.user *.sln + +# Cargo output +result diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d930baa..80d763d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,17 +37,25 @@ Before submitting a PR make sure to run: - for formatting ```shell - cargo fmt --all + cargo fmt --all -- --check ``` -- the clippy lints +- the clippy lints (with warnings treated as errors) ```shell - cargo clippy + cargo clippy --all-targets --all -- -D warnings ``` - the test suite ```shell - cargo test + cargo test --all ``` + +- the lockfile check + + ```shell + cargo check --locked --all-targets --all + ``` + +Note: CI runs these checks across multiple feature combinations (`bashisms`, `sqlite`, `external_printer`, etc.), so you may want to test with the features relevant to your changes using the `--features` flag. diff --git a/Cargo.lock b/Cargo.lock index a407a648..6191f473 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,6 +49,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -331,6 +346,18 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "glob" version = "0.3.1" @@ -439,11 +466,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" -version = "0.2.153" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libloading" @@ -646,6 +679,15 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "pretty_assertions" version = "1.4.0" @@ -665,6 +707,32 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb0be07becd10686a0bb407298fb425360a5c44a663774406340c59a22de4ce" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.9.4", + "lazy_static", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-xml" version = "0.31.0" @@ -683,6 +751,50 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -705,6 +817,7 @@ dependencies = [ "itertools", "nu-ansi-term", "pretty_assertions", + "proptest", "rstest", "rusqlite", "serde", @@ -822,6 +935,18 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.17" @@ -1024,6 +1149,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.13" @@ -1074,12 +1205,30 @@ dependencies = [ "quote", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.92" @@ -1370,6 +1519,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "wl-clipboard-rs" version = "0.8.1" @@ -1412,3 +1567,23 @@ name = "yansi" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index b20c4999..41b8e3dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ unicode-width = "0.2" [dev-dependencies] gethostname = "0.4.0" pretty_assertions = "1.4.0" +proptest = "1.6.0" rstest = { version = "0.23.0", default-features = false } tempfile = "3.3.0" diff --git a/examples/demo.rs b/examples/demo.rs index 44e7b921..7adb4d65 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -77,6 +77,9 @@ fn main() -> reedline::Result<()> { vi_insert: Some(SetCursorStyle::BlinkingBar), vi_normal: Some(SetCursorStyle::SteadyBlock), emacs: None, + helix_insert: None, + helix_normal: None, + helix_select: None, }; let mut line_editor = Reedline::create() diff --git a/examples/helix_mode.rs b/examples/helix_mode.rs new file mode 100644 index 00000000..43fa1acf --- /dev/null +++ b/examples/helix_mode.rs @@ -0,0 +1,1764 @@ +// Helix mode end-to-end test suite +// +// This file contains comprehensive tests for Helix keybinding mode. +// For interactive demos, see examples/hx_mode_tutorial.rs: +// - Guided: cargo run --example hx_mode_tutorial +// - Sandbox: cargo run --example hx_mode_tutorial -- --sandbox +// +// Run tests: +// cargo test --example helix_mode + +fn main() { + eprintln!("This example is test-only."); + eprintln!("Run the interactive demos instead:"); + eprintln!(" cargo run --example hx_mode_tutorial"); + eprintln!(" cargo run --example hx_mode_tutorial -- --sandbox"); + eprintln!(); + eprintln!("Or run the tests:"); + eprintln!(" cargo test --example helix_mode"); +} + +// ============================================================================ +// End-to-End Tests +// ============================================================================ +// +// This test suite provides comprehensive, executable specifications for Helix mode. +// Based on research from Helix's actual implementation via DeepWiki, these tests +// verify the anchor/cursor/head selection model and mode behaviors. +// +// ## Test Coverage +// +// ### Manual Test Sequences +// - `test_manual_sequence_basic_workflow()` - Complete workflow (see demo output) +// - `test_manual_sequence_simple_mode_display()` - Mode display verification +// - `test_manual_sequence_exit_test()` - Exit behavior (Ctrl+D) +// +// ### Keybinding Tests - Basic Motions +// - `test_insert_mode_entry_keybindings()` - i, a, I, A entry to insert mode +// - `test_character_motions_with_selection()` - h, l character motions +// - `test_word_motions_with_selection()` - w, b, e word motions +// - `test_bigword_motions_with_selection()` - W, B, E WORD motions (whitespace-delimited) +// - `test_line_motions_with_selection()` - 0, $ line start/end motions +// - `test_find_till_motions()` - f, t, F, T find/till character motions +// - `test_backward_motion_with_b()` - Multiple 'b' presses moving backward +// - `test_end_of_word_motion_with_e()` - End-of-word motion +// - `test_multiple_b_presses_from_end()` - Backward word navigation from end +// - `test_tutorial_double_b_selection()` - Tutorial scenario: double 'b' selection +// +// ### Selection Model Tests (Based on Helix's anchor/cursor mechanism) +// - `test_normal_mode_motions_collapse_selection()` - Normal mode collapses selection +// - `test_select_mode_entry_with_v()` - Enter Select mode with 'v' +// - `test_word_motions_in_select_mode()` - Motions extend selection in Select mode +// - `test_line_selection_with_x()` - 'x' selects entire line +// - `test_collapse_selection_with_semicolon()` - ';' collapses selection to cursor +// - `test_find_motion_extends_in_select_mode()` - Find motions extend in Select mode +// +// ### Selection and Editing Operations +// - `test_selection_commands()` - x, d, c, ; selection operations +// - `test_yank_and_paste()` - y, p, P clipboard operations +// - `test_delete_removes_selection_stays_normal()` - 'd' deletes but stays Normal +// - `test_change_enters_insert_mode()` - 'c' deletes and enters Insert mode +// +// ### Special Behaviors +// - `test_esc_cursor_behavior()` - Cursor moves left on Esc (vi-style) +// - `test_ctrl_c_and_ctrl_d_in_both_modes()` - Exit keys work in all modes +// - `test_difference_from_vi_mode_default_mode()` - Starts in Normal, not Insert +// - `test_complete_workflow_multiple_edits()` - Complex multi-step workflow +// +// ## Helix Selection Model +// +// These tests verify Helix's unique selection-first editing model: +// +// 1. **Anchor and Head**: Every selection has an anchor (fixed) and head (movable) +// 2. **Normal Mode**: Motions collapse selection (move both anchor and head together) +// 3. **Select Mode**: Motions extend selection (anchor stays fixed, head moves) +// 4. **Selection Operations**: Commands like 'd', 'c', 'y' work on current selection +// +// ## Running the Tests +// +// Run all tests: +// ```bash +// cargo test --example helix_mode +// ``` +// +// Run a specific test: +// ```bash +// cargo test --example helix_mode test_manual_sequence_basic_workflow +// ``` +// +// Run with output: +// ```bash +// cargo test --example helix_mode -- --nocapture +// ``` + +#[cfg(test)] +mod tests { + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + use reedline::{ + EditCommand, EditMode, Helix, PromptEditMode, PromptViMode, Reedline, ReedlineEvent, + ReedlineRawEvent, + }; + + #[test] + fn test_manual_sequence_basic_workflow() { + // This test follows the exact sequence from the demo output + // Tests are explicit about parsing events and applying commands + + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + // Step 1: Start - Verify we're in NORMAL mode (Helix default) + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Normal) + )); + assert_eq!(line_editor.current_buffer_contents(), ""); + + // Step 2: Press `i` - Enter INSERT mode + let _ = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Insert) + )); + + // Step 3: Type "hello world" - Apply each character as an edit command + for ch in "hello world".chars() { + let raw_event = ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::NONE, + ))) + .unwrap(); + let event = helix.parse_event(raw_event); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + } + assert_eq!(line_editor.current_buffer_contents(), "hello world"); + assert_eq!(line_editor.current_insertion_point(), 11); + + // Step 4: Press Esc - Return to NORMAL mode (cursor moves left) + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + .unwrap(), + ); + if let ReedlineEvent::Multiple(events) = event { + for e in events { + if let ReedlineEvent::Edit(commands) = e { + line_editor.run_edit_commands(&commands); + } + } + } + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Normal) + )); + assert_eq!(line_editor.current_insertion_point(), 10); // Moved left + + // Step 5: Press `b` - Move back to start of word + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 6); + + // Step 5b: Press `e` - Move to end of word + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('e'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 10); + + // Step 6: Press `d` - Delete selection + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + let buffer = line_editor.current_buffer_contents(); + assert!(buffer.starts_with("hello")); + assert!(buffer.len() < 11); + + // Step 7: Press `i` - Enter INSERT mode again + let _event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Insert) + )); + + // Step 8: Type "universe" + for ch in "universe".chars() { + let raw_event = ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::NONE, + ))) + .unwrap(); + let event = helix.parse_event(raw_event); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + } + assert!(line_editor.current_buffer_contents().contains("universe")); + + // Step 9: Press Enter - Verify it produces Enter event + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))) + .unwrap(), + ); + assert!(matches!(event, ReedlineEvent::Enter)); + + // Step 10: Verify final state contains both parts + let final_buffer = line_editor.current_buffer_contents(); + assert!(final_buffer.contains("hello")); + assert!(final_buffer.contains("universe")); + } + + #[test] + fn test_manual_sequence_simple_mode_display() { + let mut helix = Helix::default(); + + // Verify initial Normal mode + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Normal) + )); + + // Press 'i' to enter Insert mode + let _event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Insert) + )); + + // Press Esc to return to Normal mode + let _event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + .unwrap(), + ); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Normal) + )); + } + + #[test] + fn test_manual_sequence_exit_test() { + let mut helix = Helix::default(); + + // Ctrl+D from Normal mode + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::CONTROL, + ))) + .unwrap(), + ); + assert!(matches!(event, ReedlineEvent::CtrlD)); + + // Enter Insert mode then Ctrl+D + let _event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::CONTROL, + ))) + .unwrap(), + ); + assert!(matches!(event, ReedlineEvent::CtrlD)); + } + + #[test] + fn test_insert_mode_entry_keybindings() { + // Test 'i' - Enter insert mode at cursor + let mut helix = Helix::default(); + let _event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Insert) + )); + + // Test 'a' - Enter insert mode after cursor (moves right) + let mut helix = Helix::default(); + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + // Should produce edit commands to move right + assert!(matches!(event, ReedlineEvent::Multiple(_))); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Insert) + )); + + // Test 'I' (Shift+i) - Enter insert mode at line start + let mut helix = Helix::default(); + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::SHIFT, + ))) + .unwrap(), + ); + assert!(matches!(event, ReedlineEvent::Multiple(_))); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Insert) + )); + + // Test 'A' (Shift+a) - Enter insert mode at line end + let mut helix = Helix::default(); + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('a'), + KeyModifiers::SHIFT, + ))) + .unwrap(), + ); + assert!(matches!(event, ReedlineEvent::Multiple(_))); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Insert) + )); + } + + #[test] + fn test_character_motions_with_selection() { + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello".to_string()), + EditCommand::MoveToPosition { + position: 2, + select: false, + }, + ]); + + // Press 'h' - move left + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('h'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 1); + + // Press 'l' - move right + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('l'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 2); + } + + #[test] + fn test_word_motions_with_selection() { + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world test".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Press 'w' - next word start + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 6); + + // Press 'e' - next word end + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('e'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 10); + + // Press 'b' - previous word start + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 6); + } + + #[test] + fn test_bigword_motions_with_selection() { + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello-world test.case".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Press 'W' (Shift+w) - next WORD start + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::SHIFT, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 12); + + // Press 'E' (Shift+e) - next WORD end + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('e'), + KeyModifiers::SHIFT, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 20); + + // Press 'B' (Shift+b) - previous WORD start + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::SHIFT, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 12); + } + + #[test] + fn test_line_motions_with_selection() { + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 5, + select: false, + }, + ]); + + // Press '0' - line start + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('0'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 0); + + // Press '$' - line end + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('$'), + KeyModifiers::SHIFT, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + let pos = line_editor.current_insertion_point(); + assert!(pos >= 10 && pos <= 11); + } + + #[test] + fn test_find_till_motions() { + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Test 'f' - find next 'w' + let _ = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('f'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 6); + + // Reset and test 't' - till next 'w' + line_editor.run_edit_commands(&[EditCommand::MoveToPosition { + position: 0, + select: false, + }]); + let _ = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('t'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 5); + + // Reset to end and test 'F' - find previous 'h' + line_editor.run_edit_commands(&[EditCommand::MoveToPosition { + position: 10, + select: false, + }]); + let _ = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('f'), + KeyModifiers::SHIFT, + ))) + .unwrap(), + ); + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('h'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 0); + + // Reset to end and test 'T' - till previous 'h' + line_editor.run_edit_commands(&[EditCommand::MoveToPosition { + position: 10, + select: false, + }]); + let _ = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('t'), + KeyModifiers::SHIFT, + ))) + .unwrap(), + ); + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('h'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 1); + } + + #[test] + fn test_selection_commands() { + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Test 'x' - select entire line + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Test ';' - collapse selection + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char(';'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Test delete with selection + line_editor.run_edit_commands(&[ + EditCommand::Clear, + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Select a word then delete + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert!(line_editor.current_buffer_contents().len() < 11); + + // Test 'c' - change (delete and enter insert) + line_editor.run_edit_commands(&[ + EditCommand::Clear, + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Insert) + )); + } + + #[test] + fn test_yank_and_paste() { + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Select and yank + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('e'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('y'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_buffer_contents(), "hello world"); + + // Move to end and paste + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('$'), + KeyModifiers::SHIFT, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('p'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Test paste before (P) + line_editor.run_edit_commands(&[ + EditCommand::Clear, + EditCommand::InsertString("test".to_string()), + EditCommand::MoveToPosition { + position: 2, + select: false, + }, + ]); + + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('p'), + KeyModifiers::SHIFT, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + } + + #[test] + fn test_esc_cursor_behavior() { + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + // Enter insert mode + let _event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + + // Type text + for ch in "hello".chars() { + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + } + assert_eq!(line_editor.current_insertion_point(), 5); + + // Press Esc - cursor should move left + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + .unwrap(), + ); + if let ReedlineEvent::Multiple(events) = event { + for e in events { + if let ReedlineEvent::Edit(commands) = e { + line_editor.run_edit_commands(&commands); + } + } + } + assert_eq!(line_editor.current_insertion_point(), 4); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Normal) + )); + } + + #[test] + fn test_complete_workflow_multiple_edits() { + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + // Enter insert mode + let _event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Insert) + )); + + // Type text + for ch in "foo bar baz".chars() { + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + } + assert_eq!(line_editor.current_buffer_contents(), "foo bar baz"); + + // Exit to normal + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + .unwrap(), + ); + if let ReedlineEvent::Multiple(events) = event { + for e in events { + if let ReedlineEvent::Edit(commands) = e { + line_editor.run_edit_commands(&commands); + } + } + } + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Normal) + )); + + // Move to start + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('0'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + assert_eq!(line_editor.current_insertion_point(), 0); + + // Select with 'w' + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Delete selection + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + let buffer = line_editor.current_buffer_contents(); + assert!(buffer.len() < 11); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Normal) + )); + } + + #[test] + fn test_ctrl_c_and_ctrl_d_in_both_modes() { + let mut helix = Helix::default(); + + // Ctrl+C in Normal mode + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))) + .unwrap(), + ); + assert!(matches!(event, ReedlineEvent::CtrlC)); + + // Ctrl+D in Normal mode + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::CONTROL, + ))) + .unwrap(), + ); + assert!(matches!(event, ReedlineEvent::CtrlD)); + + // Enter Insert mode + let _event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + + // Ctrl+C in Insert mode + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))) + .unwrap(), + ); + assert!(matches!(event, ReedlineEvent::CtrlC)); + + // Ctrl+D in Insert mode + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::CONTROL, + ))) + .unwrap(), + ); + assert!(matches!(event, ReedlineEvent::CtrlD)); + } + + #[test] + fn test_difference_from_vi_mode_default_mode() { + let helix = Helix::default(); + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Normal) + )); + } + + #[test] + fn test_multiple_b_presses_from_end() { + // Test pressing 'b' multiple times to select backwards + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + // Setup: "hello world test" with cursor at end + line_editor.run_edit_commands(&[EditCommand::InsertString("hello world test".to_string())]); + println!("Initial: pos={}", line_editor.current_insertion_point()); + assert_eq!(line_editor.current_insertion_point(), 16); // At end + + // Press 'b' first time - should move to start of "test" + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + println!("After 1st b: pos={}", line_editor.current_insertion_point()); + assert_eq!(line_editor.current_insertion_point(), 12); // Start of "test" + + // Press 'b' second time - should move to start of "world" + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + println!("After 2nd b: pos={}", line_editor.current_insertion_point()); + assert_eq!(line_editor.current_insertion_point(), 6); // Start of "world" + + // Press 'b' third time - should move to start of "hello" + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + println!("After 3rd b: pos={}", line_editor.current_insertion_point()); + assert_eq!(line_editor.current_insertion_point(), 0); // Start of "hello" + } + + #[test] + fn test_tutorial_double_b_selection() { + // Test the specific tutorial scenario: "hello world" with 'b' pressed twice + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + // Enter insert mode and type "hello world" + let _event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('i'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + + for ch in "hello world".chars() { + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + } + + println!( + "After typing: buffer='{}', pos={}", + line_editor.current_buffer_contents(), + line_editor.current_insertion_point() + ); + assert_eq!(line_editor.current_buffer_contents(), "hello world"); + assert_eq!(line_editor.current_insertion_point(), 11); + + // Press Esc to return to Normal mode + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + .unwrap(), + ); + if let ReedlineEvent::Multiple(events) = event { + for e in events { + if let ReedlineEvent::Edit(commands) = e { + line_editor.run_edit_commands(&commands); + } + } + } + + println!( + "After Esc: buffer='{}', pos={}", + line_editor.current_buffer_contents(), + line_editor.current_insertion_point() + ); + assert_eq!(line_editor.current_insertion_point(), 10); + + // Press 'b' twice + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + println!("First 'b' commands: {:?}", commands); + line_editor.run_edit_commands(&commands); + } + println!( + "After 1st b: buffer='{}', pos={}", + line_editor.current_buffer_contents(), + line_editor.current_insertion_point() + ); + + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + println!("Second 'b' commands: {:?}", commands); + line_editor.run_edit_commands(&commands); + } + println!( + "After 2nd b: buffer='{}', pos={}", + line_editor.current_buffer_contents(), + line_editor.current_insertion_point() + ); + assert_eq!(line_editor.current_insertion_point(), 0); + + // Now press 'd' to delete the selection + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + println!("Delete commands: {:?}", commands); + line_editor.run_edit_commands(&commands); + } + + let buffer_after_delete = line_editor.current_buffer_contents(); + println!( + "After delete: buffer='{}', pos={}", + buffer_after_delete, + line_editor.current_insertion_point() + ); + + // What gets deleted? This will tell us what was selected + // If "hello " was selected, buffer should be "world" + // If entire string was selected, buffer should be empty + println!("Expected: 'world' if 'hello ' was selected, '' if everything was selected"); + } + + // ======================================================================== + // Selection Model Tests - Based on Helix's anchor/cursor/head mechanism + // ======================================================================== + + #[test] + fn test_normal_mode_motions_collapse_selection() { + // In Helix Normal mode, motions move the cursor without creating a selection. + // Both anchor and head collapse to the new position. + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // In Normal mode, 'w' should move cursor to next word start + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + // Cursor should be at position 6 (start of "world") + // In Helix Normal mode, this creates a selection from [6, 7) which appears as a cursor at 6 + assert_eq!(line_editor.current_insertion_point(), 6); + + // Another 'w' should move to end (no more words) + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + // Should be at or near end of line + assert!(line_editor.current_insertion_point() >= 10); + } + + #[test] + fn test_select_mode_entry_with_v() { + // Test that 'v' enters Select mode where motions extend selection + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Start in Normal mode + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Normal) + )); + + // Press 'v' to enter Select mode + let _ = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('v'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + + // Should now be in Select mode + // Note: Helix uses PromptViMode for compatibility, but in actual Helix + // there's a separate Select mode. Check the implementation details. + // For now, we verify that subsequent motions extend selection. + } + + #[test] + fn test_line_selection_with_x() { + // Test that 'x' selects the entire line + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 5, + select: false, + }, + ]); + + // Press 'x' to select the entire line + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // After 'x', the entire line should be selected + // We can verify this by pressing 'd' and checking the buffer is empty + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Buffer should be empty after deleting the line selection + assert_eq!(line_editor.current_buffer_contents(), ""); + } + + #[test] + fn test_collapse_selection_with_semicolon() { + // Test that ';' collapses selection to cursor + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Select with 'x' (entire line) + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Now collapse selection with ';' + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char(';'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // After collapse, pressing 'd' should only delete one character + let initial_len = line_editor.current_buffer_contents().len(); + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Only one character should be deleted (cursor position, not whole line) + let final_len = line_editor.current_buffer_contents().len(); + assert!(final_len >= initial_len - 1); + } + + #[test] + fn test_word_motions_in_select_mode() { + // Test that word motions extend selection in Select mode + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("foo bar baz".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Enter Select mode with 'v' + let _ = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('v'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + + let initial_pos = line_editor.current_insertion_point(); + + // Press 'w' to extend selection to next word + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + let after_w_pos = line_editor.current_insertion_point(); + + // Press 'w' again to extend further + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + let after_2w_pos = line_editor.current_insertion_point(); + + // In Select mode, cursor should keep moving forward + assert!(after_w_pos > initial_pos); + assert!(after_2w_pos > after_w_pos); + + // Delete should remove the extended selection + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Most of the text should be gone + assert!(line_editor.current_buffer_contents().len() < 11); + } + + #[test] + fn test_change_enters_insert_mode() { + // Test that 'c' deletes selection and enters Insert mode + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Use 'x' to select entire line first (in Helix, 'c' works on selections) + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + let before_len = line_editor.current_buffer_contents().len(); + + // Press 'c' to change (delete and enter Insert mode) + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + + // Handle Multiple event properly + if let ReedlineEvent::Multiple(events) = event { + for e in events { + if let ReedlineEvent::Edit(commands) = e { + line_editor.run_edit_commands(&commands); + } + } + } else if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Should be in Insert mode now + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Insert) + )); + + // Buffer should be shorter or empty (text deleted) + let after_len = line_editor.current_buffer_contents().len(); + assert!(after_len < before_len); + } + + #[test] + fn test_delete_removes_selection_stays_normal() { + // Test that 'd' deletes selection but stays in Normal mode + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Select word with 'w' + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + let before_delete = line_editor.current_buffer_contents().len(); + + // Press 'd' to delete selection + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Should still be in Normal mode + assert!(matches!( + helix.edit_mode(), + PromptEditMode::Vi(PromptViMode::Normal) + )); + + // Buffer should be shorter + assert!(line_editor.current_buffer_contents().len() < before_delete); + } + + #[test] + fn test_find_motion_extends_in_select_mode() { + // Test that find motions (f, t) extend selection in Select mode + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("hello world test".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Enter Select mode + let _ = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('v'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + + // Use 'f' to find 'w' + let _ = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('f'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('w'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Cursor should have moved to 'w' position + assert_eq!(line_editor.current_insertion_point(), 6); + + // Delete should remove "hello w" (from start to found position) + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('d'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + let buffer = line_editor.current_buffer_contents(); + // Should have something like "orld test" remaining + assert!(buffer.contains("orld") || buffer.len() < 16); + } + + #[test] + fn test_backward_motion_with_b() { + // Test backward word motion with 'b' + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("one two three".to_string()), + EditCommand::MoveToPosition { + position: 13, + select: false, + }, + ]); + + // Press 'b' to move back one word + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Should be at start of "three" (position 8) + assert_eq!(line_editor.current_insertion_point(), 8); + + // Press 'b' again + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('b'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Should be at start of "two" (position 4) + assert_eq!(line_editor.current_insertion_point(), 4); + } + + #[test] + fn test_end_of_word_motion_with_e() { + // Test end-of-word motion with 'e' + let mut helix = Helix::default(); + let mut line_editor = Reedline::create(); + + line_editor.run_edit_commands(&[ + EditCommand::InsertString("one two three".to_string()), + EditCommand::MoveToPosition { + position: 0, + select: false, + }, + ]); + + // Press 'e' to move to end of first word + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('e'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Should be at end of "one" (position 2) + assert_eq!(line_editor.current_insertion_point(), 2); + + // Press 'e' again + let event = helix.parse_event( + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new( + KeyCode::Char('e'), + KeyModifiers::NONE, + ))) + .unwrap(), + ); + if let ReedlineEvent::Edit(commands) = event { + line_editor.run_edit_commands(&commands); + } + + // Should be at end of "two" (position 6) + assert_eq!(line_editor.current_insertion_point(), 6); + } +} diff --git a/examples/hx_mode_tutorial.rs b/examples/hx_mode_tutorial.rs new file mode 100644 index 00000000..e9cc7fa5 --- /dev/null +++ b/examples/hx_mode_tutorial.rs @@ -0,0 +1,339 @@ +// Helix mode interactive tutorial & sandbox +// Guided: cargo run --example hx_mode_tutorial +// Sandbox: cargo run --example hx_mode_tutorial -- --sandbox + +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use reedline::{ + EditCommand, EditMode, Helix, Prompt, PromptEditMode, PromptHelixMode, PromptHistorySearch, + Reedline, ReedlineEvent, ReedlineRawEvent, Signal, +}; +use std::borrow::Cow; +use std::env; +use std::io; +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Copy)] +enum PromptStyle { + Tutorial, + Minimal, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TutorialStage { + NormalWorkflow, + SelectMode, + Completed, +} + +enum SubmissionOutcome { + Retry, + Continue, + Completed, +} + +struct HelixPrompt { + style: PromptStyle, +} + +impl HelixPrompt { + fn new(style: PromptStyle) -> Self { + Self { style } + } + + fn set_style(&mut self, style: PromptStyle) { + self.style = style; + } +} + +impl Prompt for HelixPrompt { + fn render_prompt_left(&self) -> Cow<'_, str> { + Cow::Borrowed("") + } + + fn render_prompt_right(&self) -> Cow<'_, str> { + Cow::Borrowed("") + } + + fn render_prompt_indicator(&self, edit_mode: PromptEditMode) -> Cow<'_, str> { + match (self.style, edit_mode) { + (PromptStyle::Tutorial, PromptEditMode::Helix(helix_mode)) => match helix_mode { + PromptHelixMode::Normal => Cow::Borrowed("[ NORMAL ] 〉"), + PromptHelixMode::Insert => Cow::Borrowed("[ INSERT ] : "), + PromptHelixMode::Select => Cow::Borrowed("[ SELECT ] » "), + }, + (PromptStyle::Minimal, PromptEditMode::Helix(helix_mode)) => match helix_mode { + PromptHelixMode::Normal => Cow::Borrowed("〉"), + PromptHelixMode::Insert => Cow::Borrowed(": "), + PromptHelixMode::Select => Cow::Borrowed("» "), + }, + _ => Cow::Borrowed("> "), + } + } + + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { + Cow::Borrowed("::: ") + } + + fn render_prompt_history_search_indicator( + &self, + history_search: PromptHistorySearch, + ) -> Cow<'_, str> { + let prefix = match history_search.status { + reedline::PromptHistorySearchStatus::Passing => "", + reedline::PromptHistorySearchStatus::Failing => "failing ", + }; + Cow::Owned(format!( + "({}reverse-search: {}) ", + prefix, history_search.term + )) + } +} + +struct SharedHelix { + state: Arc>, +} + +impl SharedHelix { + fn new(state: Arc>) -> Self { + Self { state } + } +} + +impl EditMode for SharedHelix { + fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent { + let mut helix = self.state.lock().expect("helix lock poisoned"); + ::parse_event(&mut *helix, event) + } + + fn edit_mode(&self) -> PromptEditMode { + let helix = self.state.lock().expect("helix lock poisoned"); + ::edit_mode(&*helix) + } +} + +struct TutorialGuide { + stage: TutorialStage, +} + +impl TutorialGuide { + fn new() -> Self { + Self { + stage: TutorialStage::NormalWorkflow, + } + } + + fn handle_submission(&mut self, buffer: &str) -> SubmissionOutcome { + match self.stage { + TutorialStage::NormalWorkflow => { + if buffer.contains("hello") + && buffer.contains("universe") + && !buffer.contains("world") + { + println!("\n🎉 Stage 1 Complete! 🎉"); + println!("You mastered the normal-mode workflow:"); + println!(" • Entered INSERT mode with 'i' (insert)"); + println!(" • Typed 'hello world'"); + println!(" • Stayed in INSERT mode when finishing the edit"); + println!(" • Used 'b' (back) twice to land on the start of 'hello'"); + println!(" • Highlighted 'hello' with 'e' (end of word) then saw 'w' (word) land in the gap ahead"); + println!(" • Used 'w' (word) again to select 'world' and deleted using 'd' (delete)"); + println!(" • Added 'universe' with 'i' (insert) + typing\n"); + println!( + "Next up: practise Helix Select mode to edit with a highlighted region." + ); + println!("We'll reset the buffer to 'hello universe' so you can inspect it before continuing."); + self.stage = TutorialStage::SelectMode; + self.print_current_stage_instructions(); + SubmissionOutcome::Continue + } else { + println!("Not quite right. Expected 'hello universe' (without 'world')."); + println!("Follow the checklist and submit again.\n"); + SubmissionOutcome::Retry + } + } + TutorialStage::SelectMode => { + if buffer.trim() == "goodbye friend" { + println!("\n🌟 Stage 2 Complete! 🌟"); + println!("You performed a Select mode edit:"); + println!(" • Entered Select mode with 'v' (visual/select)"); + println!(" • Pressed 'b' (back) twice to highlight both words"); + println!(" • Replaced the selection with 'c' (change) → 'goodbye friend'"); + println!(" • Submitted directly from INSERT mode\n"); + println!("Final result: {}\n", buffer); + println!("Tutorial accomplished! The prompt now switches to sandbox mode so you can explore."); + self.stage = TutorialStage::Completed; + SubmissionOutcome::Completed + } else { + println!("Select mode step not finished. Goal: transform 'hello universe' → 'goodbye friend'."); + println!("Hint: enter Select mode with 'v' (visual/select), press 'b' (back) twice to grow the highlight, then 'c' (change) to replace it.\n"); + SubmissionOutcome::Retry + } + } + TutorialStage::Completed => SubmissionOutcome::Completed, + } + } + + fn stage(&self) -> TutorialStage { + self.stage + } + + fn print_current_stage_instructions(&self) { + match self.stage { + TutorialStage::NormalWorkflow => { + println!("\n╭─ Stage 1: Normal Mode Workflow ──────────────────────────╮"); + println!("│ 1. Press 'i' (insert) to enter INSERT mode │"); + println!("│ 2. Type: hello world │"); + println!("│ 3. Press 'b' (back) twice to land on the start of 'hello' │"); + println!( + "│ 4. Press 'e' (end of word) to highlight 'hello' with the cursor on 'o'│" + ); + println!("│ 5. Press 'b' (back) to re-highlight 'hello', then 'w' (word) to land in the gap │"); + println!("│ 6. Press 'w' (word) again to select 'world' │"); + println!("│ 7. Press 'd' (delete) to remove the word │"); + println!("│ 8. Press 'i' (insert) and type: universe │"); + println!("│ 9. Press Enter (submit) to finish │"); + println!("╰──────────────────────────────────────────────────────────╯"); + println!("💡 Goal: Transform 'hello world' → 'hello universe'"); + println!( + "💡 'e' highlights through the word end; 'w' settles in the gap before the next word." + ); + println!("💡 Watch the prompt change: [ NORMAL ] 〉 ⟷ [ INSERT ] :\n"); + } + TutorialStage::SelectMode => { + println!("\n╭─ Stage 2: Select Mode Edit ──────────────────────────────╮"); + println!("│ 1. Press 'v' (visual/select) to enter SELECT mode ([ SELECT ] ») │"); + println!("│ 2. Press 'b' (back) to highlight the word 'universe' │"); + println!("│ 3. Press 'b' (back) again to include 'hello' in the highlight │"); + println!("│ 4. Press 'c' (change) and type: goodbye friend │"); + println!("│ 5. Press Enter (submit) to finish │"); + println!("╰──────────────────────────────────────────────────────────╯"); + println!("💡 Goal: Transform 'hello universe' → 'goodbye friend'"); + println!("💡 You're already in NORMAL mode with 'hello universe' visible—hit 'v' to begin."); + println!( + "💡 Notice how pressing 'b' in Select mode grows the highlight backward.\n" + ); + } + TutorialStage::Completed => {} + } + } +} + +fn preload_stage_two_buffer(line_editor: &mut Reedline, helix_state: &Arc>) { + ensure_stage_two_normal_mode(line_editor, helix_state); + line_editor.run_edit_commands(&[EditCommand::ClearSelection]); + line_editor.run_edit_commands(&[EditCommand::Clear]); + line_editor.run_edit_commands(&[EditCommand::InsertString("hello universe".to_string())]); + line_editor.run_edit_commands(&[EditCommand::MoveToEnd { select: false }]); +} + +fn ensure_stage_two_normal_mode(line_editor: &mut Reedline, helix_state: &Arc>) { + if let Ok(raw) = + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))) + { + let event = { + let mut helix = helix_state.lock().expect("helix lock poisoned"); + ::parse_event(&mut *helix, raw) + }; + apply_reedline_event(line_editor, event); + } +} + +fn apply_reedline_event(line_editor: &mut Reedline, event: ReedlineEvent) { + match event { + ReedlineEvent::Edit(commands) => line_editor.run_edit_commands(&commands), + ReedlineEvent::Multiple(events) => { + for nested in events { + apply_reedline_event(line_editor, nested); + } + } + ReedlineEvent::Repaint | ReedlineEvent::Esc | ReedlineEvent::None => {} + // The tutorial reset path only expects edit/esc/repaint events. Surface any new ones + // during development so we do not silently drop behaviour updates from Helix. + unexpected => { + debug_assert!( + false, + "Unhandled ReedlineEvent during tutorial reset: {unexpected:?}" + ); + } + } +} + +fn main() -> io::Result<()> { + let sandbox_requested = env::args().skip(1).any(|arg| arg == "--sandbox"); + + if sandbox_requested { + println!("Helix Mode Sandbox"); + println!("=================="); + println!("Prompt: 〉(normal) :(insert) »(select)"); + println!("Exit: Ctrl+C or Ctrl+D\n"); + } else { + println!("Helix Mode Interactive Tutorial"); + println!("================================\n"); + println!("Welcome! Complete the full workflow in a single editing session."); + println!("Stage 1 covers normal-mode editing, Stage 2 introduces Select mode.\n"); + + println!("Quick reference:"); + println!(" Modes: NORMAL (commands) ⟷ INSERT (typing)"); + println!(" Select mode: enter with 'v', exit with Esc"); + println!(" Exit: Ctrl+C or Ctrl+D at any time\n"); + } + + let helix_state = Arc::new(Mutex::new(Helix::default())); + let mut line_editor = + Reedline::create().with_edit_mode(Box::new(SharedHelix::new(helix_state.clone()))); + let mut prompt = HelixPrompt::new(if sandbox_requested { + PromptStyle::Minimal + } else { + PromptStyle::Tutorial + }); + let mut guide = if sandbox_requested { + None + } else { + Some(TutorialGuide::new()) + }; + let mut sandbox_active = sandbox_requested; + let mut tutorial_completed = false; + + // Show instructions + if let Some(guide_ref) = guide.as_ref() { + guide_ref.print_current_stage_instructions(); + } + + loop { + let sig = line_editor.read_line(&prompt)?; + + match sig { + Signal::Success(buffer) => { + if let Some(guide_ref) = guide.as_mut() { + match guide_ref.handle_submission(&buffer) { + SubmissionOutcome::Retry => {} + SubmissionOutcome::Continue => { + if guide_ref.stage() == TutorialStage::SelectMode { + preload_stage_two_buffer(&mut line_editor, &helix_state); + } + continue; + } + SubmissionOutcome::Completed => { + tutorial_completed = true; + println!("Continue experimenting below or exit with Ctrl+C/D when finished.\n"); + prompt.set_style(PromptStyle::Minimal); + guide = None; + sandbox_active = true; + continue; + } + } + } else if sandbox_active { + println!("{buffer}"); + } + } + Signal::CtrlD | Signal::CtrlC => { + if tutorial_completed || sandbox_active { + println!("\nGoodbye! 👋"); + } else { + println!("\nTutorial interrupted. Run again to try once more!"); + } + break Ok(()); + } + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..b502288a --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1759362264, + "narHash": "sha256-wfG0S7pltlYyZTM+qqlhJ7GMw2fTF4mLKCIVhLii/4M=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "758cf7296bee11f1706a574c77d072b8a7baa881", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1760038930, + "narHash": "sha256-Oncbh0UmHjSlxO7ErQDM3KM0A5/Znfofj2BSzlHLeVw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0b4defa2584313f3b781240b29d61f6f9f7e0df3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1754788789, + "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "a73b9c743612e4244d865a2fdee11865283c04e6", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1760236527, + "narHash": "sha256-h9+WEQtUIZaZMvA1pnbZbMM+5X39OFnW92Q8hNoToD0=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "a38dd7f462825c75ce8567816ae38c2e7d826bfa", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..77a3b9b5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,100 @@ +{ + description = "A readline-like crate for CLI text input"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + inputs@{ + flake-parts, + rust-overlay, + ... + }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + perSystem = + { + config, + self', + inputs', + pkgs, + system, + ... + }: + let + overlays = [ (import rust-overlay) ]; + pkgs = import inputs.nixpkgs { + inherit system overlays; + }; + + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ + "rust-src" + "rust-analyzer" + ]; + }; + + nativeBuildInputs = with pkgs; [ + rustToolchain + pkg-config + ]; + + buildInputs = + with pkgs; + [ + sqlite + cargo-nextest + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ + wayland + libxkbcommon + ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ + darwin.apple_sdk.frameworks.AppKit + ]; + in + { + devShells.default = pkgs.mkShell { + inherit buildInputs nativeBuildInputs; + + RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; + }; + + packages = { + default = config.packages.reedline; + + reedline = pkgs.rustPlatform.buildRustPackage { + pname = "reedline"; + version = "0.42.0"; + + src = ./.; + + cargoLock = { + lockFile = ./Cargo.lock; + }; + + inherit nativeBuildInputs buildInputs; + + meta = with pkgs.lib; { + description = "A readline-like crate for CLI text input"; + homepage = "https://github.com/nushell/reedline"; + license = licenses.mit; + maintainers = [ ]; + }; + }; + }; + }; + }; +} diff --git a/proptest-regressions/edit_mode/helix/mod.txt b/proptest-regressions/edit_mode/helix/mod.txt new file mode 100644 index 00000000..3274fbc9 --- /dev/null +++ b/proptest-regressions/edit_mode/helix/mod.txt @@ -0,0 +1,11 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 7bee4bd43ec0e73c8191caa9f40032eae4a69f794474eb58db2886e001799b55 # shrinks to search_char = 'b', prefix = "ab", suffix = "" +cc 3b36b8909d0533a79ac9625a1c050babeaf1c81e688dbfe201bbdb5312dcb825 # shrinks to search_char = 'q', prefix = "q", suffix = "" +cc 649014c43cce78973ab884c67bc9e0ef20bda3fb5d8b122cd97a767a699cb9f4 # shrinks to search_char = 'À', prefix = "", suffix = "" +cc 5a3e8306b4eff29c4504023495e65e76cad84fdb5662110cffbfb99dd5da3108 # shrinks to word1 = "a", word2 = "aa", separator = " " +cc f858c5a5b9236d63dd91fc811059b13c01551938b2c58d9415be418dcda6faf2 # shrinks to search_char = '\u{1abf}', prefix = "a", suffix = "" diff --git a/src/core_editor/editor.rs b/src/core_editor/editor.rs index e43d27f2..1c3b74e1 100644 --- a/src/core_editor/editor.rs +++ b/src/core_editor/editor.rs @@ -2,7 +2,7 @@ use super::{edit_stack::EditStack, Clipboard, ClipboardMode, LineBuffer}; #[cfg(feature = "system_clipboard")] use crate::core_editor::get_system_clipboard; use crate::enums::{EditType, TextObject, TextObjectScope, TextObjectType, UndoBehavior}; -use crate::prompt::{PromptEditMode, PromptViMode}; +use crate::prompt::{PromptEditMode, PromptHelixMode, PromptViMode}; use crate::{core_editor::get_local_clipboard, EditCommand}; use std::ops::{DerefMut, Range}; @@ -64,8 +64,11 @@ impl Editor { EditCommand::MoveRight { select } => self.move_right(*select), EditCommand::MoveWordLeft { select } => self.move_word_left(*select), EditCommand::MoveBigWordLeft { select } => self.move_big_word_left(*select), + EditCommand::HelixWordLeft => self.helix_word_left(), EditCommand::MoveWordRight { select } => self.move_word_right(*select), EditCommand::MoveWordRightStart { select } => self.move_word_right_start(*select), + EditCommand::MoveWordRightGap { select } => self.move_word_right_gap(*select), + EditCommand::HelixWordRightGap => self.helix_word_right_gap(), EditCommand::MoveBigWordRightStart { select } => { self.move_big_word_right_start(*select) } @@ -122,6 +125,7 @@ impl Editor { EditCommand::MoveLeftBefore { c, select } => { self.move_left_until_char(*c, true, true, *select) } + EditCommand::ClearSelection => self.clear_selection(), EditCommand::SelectAll => self.select_all(), EditCommand::CutSelection => self.cut_selection_to_cut_buffer(), EditCommand::CopySelection => self.copy_selection_to_cut_buffer(), @@ -604,6 +608,7 @@ impl Editor { let inclusive = matches!( self.selection_mode.as_ref().unwrap_or(&self.edit_mode), PromptEditMode::Vi(PromptViMode::Normal) + | PromptEditMode::Helix(PromptHelixMode::Normal | PromptHelixMode::Select) ); let selection_is_from_left_to_right = selection_anchor < self.insertion_point(); @@ -664,6 +669,58 @@ impl Editor { self.move_to_position(self.line_buffer.big_word_left_index(), select); } + fn helix_word_left(&mut self) { + if self.helix_prepare_backward_selection() { + return; + } + + self.move_word_left(false); + self.move_word_right_gap(true); + self.swap_cursor_and_anchor(); + } + + fn helix_word_right_gap(&mut self) { + self.helix_prepare_forward_selection(); + + self.move_word_right_gap(true); + } + + /// When selections extend to the right, Helix expects a backward motion to keep the + /// highlight anchored on the word that was just traversed. This helper snaps the anchor to + /// the cursor and moves the insertion point back to the previous word start. Returns `true` + /// when the helper has fully handled the motion so no additional selection reshaping is + /// required by the caller. + fn helix_prepare_backward_selection(&mut self) -> bool { + if let Some(anchor_pos) = self.selection_anchor { + let insertion = self.insertion_point(); + + if anchor_pos <= insertion { + self.selection_anchor = Some(insertion); + let new_pos = self.line_buffer.word_left_index(); + self.line_buffer.set_insertion_point(new_pos); + return true; + } + } + false + } + + /// Normalizes the selection before a forward motion so that the anchor always sits on the + /// word boundary we are leaving. This matches Helix's expectation that repeated `w` presses + /// drop the previously selected word before extending to the next one. + fn helix_prepare_forward_selection(&mut self) { + if let Some(anchor_pos) = self.selection_anchor { + let insertion = self.insertion_point(); + if anchor_pos > insertion { + self.selection_anchor = Some(insertion); + self.line_buffer.set_insertion_point(anchor_pos); + } else if anchor_pos < insertion { + let next_start = self.line_buffer.word_right_start_index().max(insertion); + self.selection_anchor = Some(next_start); + self.line_buffer.set_insertion_point(next_start); + } + } + } + fn move_word_right(&mut self, select: bool) { self.move_to_position(self.line_buffer.word_right_index(), select); } @@ -672,6 +729,10 @@ impl Editor { self.move_to_position(self.line_buffer.word_right_start_index(), select); } + fn move_word_right_gap(&mut self, select: bool) { + self.move_to_position(self.line_buffer.word_right_gap_index(), select); + } + fn move_big_word_right_start(&mut self, select: bool) { self.move_to_position(self.line_buffer.big_word_right_start_index(), select); } diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 57d79f27..dd36c1e5 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -254,6 +254,62 @@ impl LineBuffer { .unwrap_or_else(|| self.lines.len()) } + /// Cursor position within the gap (whitespace) before the next word to the right + pub fn word_right_gap_index(&self) -> usize { + let mut found_alphanum = false; + + let result = self.lines[self.insertion_point..] + .split_word_bound_indices() + .find_map(|(i, word)| { + if is_alphanumeric_str(word) { + if found_alphanum { + // Second alphanumeric segment, return its position + Some(self.insertion_point + i) + } else { + // First alphanumeric segment, mark and continue + found_alphanum = true; + None + } + } else if found_alphanum { + // Found non-alphanumeric after alphanumeric + if is_whitespace_str(word) { + // Whitespace: return its start position (the gap) + Some(self.insertion_point + i) + } else { + // Punctuation: return position after it + Some(self.insertion_point + i + word.len()) + } + } else { + // Haven't found alphanumeric yet, keep searching + None + } + }); + + // If we found a result via word boundaries, return it + if let Some(pos) = result { + return pos; + } + + // Fallback: manually find first alphanumeric word, then first non-alphanumeric + let mut in_word = false; + for (i, ch) in self.lines[self.insertion_point..].char_indices() { + if ch.is_alphanumeric() { + in_word = true; + } else if in_word { + // Found first non-alphanumeric after word + let pos = self.insertion_point + i; + if ch.is_whitespace() { + return pos; // Return position of whitespace + } else { + // Skip this punct char and return position after it + return pos + ch.len_utf8(); + } + } + } + + self.lines.len() + } + /// Cursor position *in front of* the next WORD to the right pub fn big_word_right_start_index(&self) -> usize { let mut found_ws = false; @@ -1061,6 +1117,10 @@ fn is_whitespace_str(s: &str) -> bool { s.chars().all(char::is_whitespace) } +fn is_alphanumeric_str(s: &str) -> bool { + s.chars().all(|c| c.is_alphanumeric()) +} + #[cfg(test)] mod test { use super::*; @@ -1812,6 +1872,100 @@ mod test { assert_eq!(index, expected); } + #[rstest] + #[case("abc def ghi", 0, 3)] + #[case("abc-def ghi", 0, 4)] + #[case("abc.def ghi", 0, 4)] + #[case("abc", 0, 3)] + #[case("abc def", 0, 3)] + #[case("hello world", 0, 5)] // Regression test: 'w' lands at space, not before "world" + fn test_word_right_gap_index( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + let index = line_buffer.word_right_gap_index(); + + assert_eq!(index, expected); + } + + /// Comprehensive test documenting the tutorial scenario behavior + /// This test documents that the current behavior does not exactly match + /// the tutorial description, but is consistent with the implementation. + #[test] + fn test_tutorial_hello_world_scenario() { + let mut line_buffer = buffer_with("hello world"); + + // Step 1: Start at beginning (simulating after pressing 'b' twice) + line_buffer.set_insertion_point(0); + assert_eq!(line_buffer.insertion_point(), 0); // At 'h' in "hello" + + // Step 2: Press 'e' to go to end of "hello" + let e_pos = line_buffer.word_right_end_index(); + line_buffer.set_insertion_point(e_pos); + assert_eq!(line_buffer.insertion_point(), 4); // At 'o' in "hello" + + // Step 3: Press 'b' to return to start of "hello" + let b_pos = line_buffer.word_left_index(); + line_buffer.set_insertion_point(b_pos); + assert_eq!(line_buffer.insertion_point(), 0); // Back at 'h' in "hello" + + // Step 4: Press 'w' to land "in the gap" + let w_pos = line_buffer.word_right_gap_index(); + line_buffer.set_insertion_point(w_pos); + assert_eq!(line_buffer.insertion_point(), 5); // At the space character + + // Step 5: Press 'b' once more + let final_b_pos = line_buffer.word_left_index(); + line_buffer.set_insertion_point(final_b_pos); + + // DOCUMENTED BEHAVIOR: This goes back to "hello", not to "world" as tutorial suggests + // This is because word_left_index from the space (position 5) finds the previous word "hello" + assert_eq!(line_buffer.insertion_point(), 0); // Back at 'h' in "hello" + + // Note: The tutorial says this should get to "world", but that would require + // different movement semantics or a different interpretation of the commands. + // The current behavior is consistent with treating the space as "within the gap" + // and 'b' moving to the previous word boundary. + } + + use proptest::prelude::*; + + proptest! { + /// Property test: word_right_gap_index should always move forward and land + /// at or after the first word, but before or at the second word + #[test] + fn prop_word_right_gap_index_consistency( + word1 in "[a-zA-Z]{1,8}", + separator in "[ \t]{1,4}", + word2 in "[a-zA-Z]{1,8}", + ) { + let text = format!("{}{}{}", word1, separator, word2); + let mut buffer = buffer_with(&text); + buffer.set_insertion_point(0); + + let gap_pos = buffer.word_right_gap_index(); + + // Should move forward from start + prop_assert!(gap_pos > 0, "Gap position should be forward from start"); + + // Should be at least at the end of the first word + prop_assert!(gap_pos >= word1.len(), "Gap should be at or after first word end"); + + // Should be at most at the start of the second word + let word2_start = word1.len() + separator.len(); + prop_assert!(gap_pos <= word2_start, "Gap should be at or before second word start"); + + // For single space separator, should land exactly at the space + if separator == " " { + prop_assert_eq!(gap_pos, word1.len(), "For single space, should land at the space"); + } + } + } + #[rstest] #[case("abc def ghi", 0, 4)] #[case("abc-def ghi", 0, 8)] diff --git a/src/edit_mode/cursors.rs b/src/edit_mode/cursors.rs index 67a1765a..1cfefad1 100644 --- a/src/edit_mode/cursors.rs +++ b/src/edit_mode/cursors.rs @@ -1,6 +1,6 @@ use crossterm::cursor::SetCursorStyle; -/// Maps cursor shapes to each edit mode (emacs, vi normal & vi insert). +/// Maps cursor shapes to each edit mode (emacs, vi normal & vi insert, helix normal/insert/select). /// If any of the fields is `None`, the cursor won't get changed by Reedline for that mode. #[derive(Default)] pub struct CursorConfig { @@ -10,4 +10,10 @@ pub struct CursorConfig { pub vi_normal: Option, /// The cursor to be used when in emacs mode pub emacs: Option, + /// The cursor to be used when in helix insert mode + pub helix_insert: Option, + /// The cursor to be used when in helix normal mode + pub helix_normal: Option, + /// The cursor to be used when in helix select mode + pub helix_select: Option, } diff --git a/src/edit_mode/helix/helix_keybindings.rs b/src/edit_mode/helix/helix_keybindings.rs new file mode 100644 index 00000000..0a422bea --- /dev/null +++ b/src/edit_mode/helix/helix_keybindings.rs @@ -0,0 +1,353 @@ +use crate::{ + edit_mode::keybindings::Keybindings, + enums::{EditCommand, ReedlineEvent}, +}; +use crossterm::event::{KeyCode, KeyModifiers}; + +fn add_normal_motion_binding( + keybindings: &mut Keybindings, + modifiers: KeyModifiers, + key: char, + command: EditCommand, +) { + // In Normal mode, reset selection anchor before each motion + // We move to the same position with select: false to clear the anchor + keybindings.add_binding( + modifiers, + KeyCode::Char(key), + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::MoveRight { select: false }, + command, + ]), + ); +} + +fn add_select_motion_binding( + keybindings: &mut Keybindings, + modifiers: KeyModifiers, + key: char, + command: EditCommand, +) { + // In Select mode, keep the anchor fixed + keybindings.add_binding( + modifiers, + KeyCode::Char(key), + ReedlineEvent::Edit(vec![command]), + ); +} + +/// Returns the default keybindings for Helix normal mode +/// +/// Includes: +/// - Enter: accept line +/// - Ctrl+C: abort/exit +/// - Ctrl+D: exit/EOF +/// - h/l: left/right (with selection) +/// - w/b/e: word motions (with selection) +/// - W/B/E: WORD motions (with selection) +/// - 0/$: line start/end (with selection) +/// - x: select line +/// - d: delete selection +/// - c: change selection (delete and enter insert mode) +/// - y: yank/copy selection +/// - p/P: paste after/before +/// - ;: collapse selection +/// - Alt+;: swap cursor and anchor +/// - u/U: undo/redo +pub fn default_helix_normal_keybindings() -> Keybindings { + let mut keybindings = Keybindings::default(); + + keybindings.add_binding(KeyModifiers::NONE, KeyCode::Enter, ReedlineEvent::Enter); + keybindings.add_binding( + KeyModifiers::CONTROL, + KeyCode::Char('c'), + ReedlineEvent::CtrlC, + ); + keybindings.add_binding( + KeyModifiers::CONTROL, + KeyCode::Char('d'), + ReedlineEvent::CtrlD, + ); + + add_normal_motion_binding( + &mut keybindings, + KeyModifiers::NONE, + 'h', + EditCommand::MoveLeft { select: true }, + ); + add_normal_motion_binding( + &mut keybindings, + KeyModifiers::NONE, + 'l', + EditCommand::MoveRight { select: true }, + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('w'), + ReedlineEvent::Edit(vec![EditCommand::HelixWordRightGap]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('b'), + ReedlineEvent::Edit(vec![EditCommand::HelixWordLeft]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('e'), + ReedlineEvent::Edit(vec![ + EditCommand::ClearSelection, + EditCommand::MoveWordRightEnd { select: true }, + ]), + ); + add_normal_motion_binding( + &mut keybindings, + KeyModifiers::SHIFT, + 'w', + EditCommand::MoveBigWordRightStart { select: true }, + ); + keybindings.add_binding( + KeyModifiers::SHIFT, + KeyCode::Char('b'), + ReedlineEvent::Edit(vec![ + EditCommand::MoveBigWordLeft { select: false }, + EditCommand::MoveBigWordRightEnd { select: true }, + EditCommand::SwapCursorAndAnchor, + ]), + ); + keybindings.add_binding( + KeyModifiers::SHIFT, + KeyCode::Char('e'), + ReedlineEvent::Edit(vec![ + EditCommand::ClearSelection, + EditCommand::MoveBigWordRightEnd { select: true }, + ]), + ); + add_normal_motion_binding( + &mut keybindings, + KeyModifiers::NONE, + '0', + EditCommand::MoveToLineStart { select: true }, + ); + add_normal_motion_binding( + &mut keybindings, + KeyModifiers::SHIFT, + '$', + EditCommand::MoveToLineEnd { select: true }, + ); + + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('x'), + ReedlineEvent::Edit(vec![EditCommand::SelectAll]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('d'), + ReedlineEvent::Edit(vec![EditCommand::CutSelection]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('c'), + ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutSelection])]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('y'), + ReedlineEvent::Edit(vec![EditCommand::CopySelection]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('p'), + ReedlineEvent::Edit(vec![EditCommand::Paste]), + ); + keybindings.add_binding( + KeyModifiers::SHIFT, + KeyCode::Char('p'), + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferBefore]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char(';'), + ReedlineEvent::Edit(vec![EditCommand::MoveRight { select: false }]), + ); + keybindings.add_binding( + KeyModifiers::ALT, + KeyCode::Char(';'), + ReedlineEvent::Edit(vec![EditCommand::SwapCursorAndAnchor]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('u'), + ReedlineEvent::Edit(vec![EditCommand::Undo]), + ); + keybindings.add_binding( + KeyModifiers::SHIFT, + KeyCode::Char('u'), + ReedlineEvent::Edit(vec![EditCommand::Redo]), + ); + + keybindings +} + +/// Returns the default keybindings for Helix select mode +/// +/// In Select mode, the selection anchor stays fixed and motions extend from it. +/// Includes the same motions as Normal mode, but without anchor reset. +pub fn default_helix_select_keybindings() -> Keybindings { + let mut keybindings = Keybindings::default(); + + keybindings.add_binding(KeyModifiers::NONE, KeyCode::Enter, ReedlineEvent::Enter); + keybindings.add_binding( + KeyModifiers::CONTROL, + KeyCode::Char('c'), + ReedlineEvent::CtrlC, + ); + keybindings.add_binding( + KeyModifiers::CONTROL, + KeyCode::Char('d'), + ReedlineEvent::CtrlD, + ); + + add_select_motion_binding( + &mut keybindings, + KeyModifiers::NONE, + 'h', + EditCommand::MoveLeft { select: true }, + ); + add_select_motion_binding( + &mut keybindings, + KeyModifiers::NONE, + 'l', + EditCommand::MoveRight { select: true }, + ); + add_select_motion_binding( + &mut keybindings, + KeyModifiers::NONE, + 'w', + EditCommand::MoveWordRightStart { select: true }, + ); + add_select_motion_binding( + &mut keybindings, + KeyModifiers::NONE, + 'b', + EditCommand::MoveWordLeft { select: true }, + ); + add_select_motion_binding( + &mut keybindings, + KeyModifiers::NONE, + 'e', + EditCommand::MoveWordRightEnd { select: true }, + ); + add_select_motion_binding( + &mut keybindings, + KeyModifiers::SHIFT, + 'w', + EditCommand::MoveBigWordRightStart { select: true }, + ); + add_select_motion_binding( + &mut keybindings, + KeyModifiers::SHIFT, + 'b', + EditCommand::MoveBigWordLeft { select: true }, + ); + add_select_motion_binding( + &mut keybindings, + KeyModifiers::SHIFT, + 'e', + EditCommand::MoveBigWordRightEnd { select: true }, + ); + add_select_motion_binding( + &mut keybindings, + KeyModifiers::NONE, + '0', + EditCommand::MoveToLineStart { select: true }, + ); + add_select_motion_binding( + &mut keybindings, + KeyModifiers::SHIFT, + '$', + EditCommand::MoveToLineEnd { select: true }, + ); + + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('x'), + ReedlineEvent::Edit(vec![EditCommand::SelectAll]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('d'), + ReedlineEvent::Edit(vec![EditCommand::CutSelection]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('c'), + ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutSelection])]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('y'), + ReedlineEvent::Edit(vec![EditCommand::CopySelection]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('p'), + ReedlineEvent::Edit(vec![EditCommand::Paste]), + ); + keybindings.add_binding( + KeyModifiers::SHIFT, + KeyCode::Char('p'), + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferBefore]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char(';'), + ReedlineEvent::Edit(vec![EditCommand::MoveRight { select: false }]), + ); + keybindings.add_binding( + KeyModifiers::ALT, + KeyCode::Char(';'), + ReedlineEvent::Edit(vec![EditCommand::SwapCursorAndAnchor]), + ); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('u'), + ReedlineEvent::Edit(vec![EditCommand::Undo]), + ); + keybindings.add_binding( + KeyModifiers::SHIFT, + KeyCode::Char('u'), + ReedlineEvent::Edit(vec![EditCommand::Redo]), + ); + + keybindings +} + +/// Returns the default keybindings for Helix insert mode +/// +/// Includes: +/// - Backspace: delete previous character +/// - Ctrl+C: abort/exit +/// - Ctrl+D: exit/EOF +pub fn default_helix_insert_keybindings() -> Keybindings { + let mut keybindings = Keybindings::default(); + + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Backspace, + ReedlineEvent::Edit(vec![crate::enums::EditCommand::Backspace]), + ); + keybindings.add_binding( + KeyModifiers::CONTROL, + KeyCode::Char('c'), + ReedlineEvent::CtrlC, + ); + keybindings.add_binding( + KeyModifiers::CONTROL, + KeyCode::Char('d'), + ReedlineEvent::CtrlD, + ); + + keybindings +} diff --git a/src/edit_mode/helix/mod.rs b/src/edit_mode/helix/mod.rs new file mode 100644 index 00000000..9c24934b --- /dev/null +++ b/src/edit_mode/helix/mod.rs @@ -0,0 +1,1868 @@ +mod helix_keybindings; + +use std::str::FromStr; + +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +pub use helix_keybindings::{ + default_helix_insert_keybindings, default_helix_normal_keybindings, + default_helix_select_keybindings, +}; + +use super::EditMode; +use crate::{ + edit_mode::keybindings::Keybindings, + enums::{EditCommand, EventStatus, ReedlineEvent, ReedlineRawEvent}, + PromptEditMode, PromptHelixMode, +}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum HelixMode { + Normal, + Insert, + Select, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum PendingCharSearch { + Find, + Till, + FindBack, + TillBack, +} + +impl PendingCharSearch { + fn to_command(self, c: char) -> EditCommand { + match self { + PendingCharSearch::Find => EditCommand::MoveRightUntil { c, select: true }, + PendingCharSearch::Till => EditCommand::MoveRightBefore { c, select: true }, + PendingCharSearch::FindBack => EditCommand::MoveLeftUntil { c, select: true }, + PendingCharSearch::TillBack => EditCommand::MoveLeftBefore { c, select: true }, + } + } +} + +impl FromStr for HelixMode { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "normal" => Ok(HelixMode::Normal), + "insert" => Ok(HelixMode::Insert), + "select" => Ok(HelixMode::Select), + _ => Err(()), + } + } +} + +/// This parses incoming input `Event`s using Helix-style modal editing +/// +/// Helix mode starts in Normal mode by default (unlike Vi mode which starts in Insert). +/// It supports basic insert mode entry (i/a/I/A) and escape back to normal mode. +pub struct Helix { + insert_keybindings: Keybindings, + normal_keybindings: Keybindings, + select_keybindings: Keybindings, + mode: HelixMode, + pending_char_search: Option, + /// Command to run when exiting insert mode (e.g., move cursor left for append modes) + insert_mode_exit_adjustment: Option, +} + +impl Default for Helix { + fn default() -> Self { + Helix { + insert_keybindings: default_helix_insert_keybindings(), + normal_keybindings: default_helix_normal_keybindings(), + select_keybindings: default_helix_select_keybindings(), + mode: HelixMode::Normal, + pending_char_search: None, + insert_mode_exit_adjustment: None, + } + } +} + +impl Helix { + /// Creates a Helix editor with custom keybindings + pub fn new( + insert_keybindings: Keybindings, + normal_keybindings: Keybindings, + select_keybindings: Keybindings, + ) -> Self { + Self { + insert_keybindings, + normal_keybindings, + select_keybindings, + mode: HelixMode::Normal, + pending_char_search: None, + insert_mode_exit_adjustment: None, + } + } +} + +impl Helix { + fn enter_insert_mode( + &mut self, + edit_command: Option, + exit_adjustment: Option, + ) -> ReedlineEvent { + self.mode = HelixMode::Insert; + self.insert_mode_exit_adjustment = exit_adjustment; + match edit_command { + None => ReedlineEvent::Repaint, + Some(cmd) => ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![cmd]), + ReedlineEvent::Repaint, + ]), + } + } + + fn handle_pending_char_search(&mut self, code: KeyCode) -> Option { + if let Some(search_type) = self.pending_char_search.take() { + if let KeyCode::Char(c) = code { + Some(ReedlineEvent::Edit(vec![search_type.to_command(c)])) + } else { + // Non-char key pressed, cancel the search + Some(ReedlineEvent::None) + } + } else { + None + } + } + + fn start_char_search(&mut self, search_type: PendingCharSearch) -> ReedlineEvent { + self.pending_char_search = Some(search_type); + ReedlineEvent::None + } +} + +impl EditMode for Helix { + fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent { + match event.into() { + Event::Key(KeyEvent { + code, modifiers, .. + }) => { + // Handle pending character search (f/t/F/T waiting for char) + if let Some(event) = self.handle_pending_char_search(code) { + return event; + } + + match (self.mode, modifiers, code) { + (HelixMode::Normal, KeyModifiers::NONE, KeyCode::Char('v')) => { + self.mode = HelixMode::Select; + ReedlineEvent::Repaint + } + (HelixMode::Select, KeyModifiers::NONE, KeyCode::Char('v')) + | (HelixMode::Select, KeyModifiers::NONE, KeyCode::Esc) => { + self.mode = HelixMode::Normal; + ReedlineEvent::Repaint + } + ( + HelixMode::Normal | HelixMode::Select, + KeyModifiers::NONE, + KeyCode::Char('f'), + ) => self.start_char_search(PendingCharSearch::Find), + ( + HelixMode::Normal | HelixMode::Select, + KeyModifiers::NONE, + KeyCode::Char('t'), + ) => self.start_char_search(PendingCharSearch::Till), + ( + HelixMode::Normal | HelixMode::Select, + KeyModifiers::SHIFT, + KeyCode::Char('f'), + ) => self.start_char_search(PendingCharSearch::FindBack), + ( + HelixMode::Normal | HelixMode::Select, + KeyModifiers::SHIFT, + KeyCode::Char('t'), + ) => self.start_char_search(PendingCharSearch::TillBack), + ( + HelixMode::Normal | HelixMode::Select, + KeyModifiers::NONE, + KeyCode::Char('i'), + ) => { + self.mode = HelixMode::Normal; + self.enter_insert_mode(None, None) + } + ( + HelixMode::Normal | HelixMode::Select, + KeyModifiers::NONE, + KeyCode::Char('a'), + ) => { + self.mode = HelixMode::Normal; + self.enter_insert_mode( + Some(EditCommand::MoveRight { select: false }), + Some(EditCommand::MoveLeft { select: false }), + ) + } + ( + HelixMode::Normal | HelixMode::Select, + KeyModifiers::SHIFT, + KeyCode::Char('i'), + ) => { + self.mode = HelixMode::Normal; + self.enter_insert_mode( + Some(EditCommand::MoveToLineStart { select: false }), + None, + ) + } + ( + HelixMode::Normal | HelixMode::Select, + KeyModifiers::SHIFT, + KeyCode::Char('a'), + ) => { + self.mode = HelixMode::Normal; + self.enter_insert_mode( + Some(EditCommand::MoveToLineEnd { select: false }), + Some(EditCommand::MoveLeft { select: false }), + ) + } + ( + HelixMode::Normal | HelixMode::Select, + KeyModifiers::NONE, + KeyCode::Char('c'), + ) => { + self.mode = HelixMode::Normal; + self.enter_insert_mode(Some(EditCommand::CutSelection), None) + } + (HelixMode::Normal, _, _) => self + .normal_keybindings + .find_binding(modifiers, code) + .unwrap_or(ReedlineEvent::None), + (HelixMode::Select, _, _) => self + .select_keybindings + .find_binding(modifiers, code) + .unwrap_or(ReedlineEvent::None), + (HelixMode::Insert, KeyModifiers::NONE, KeyCode::Esc) => { + self.mode = HelixMode::Normal; + let mut events = vec![]; + if let Some(cmd) = self.insert_mode_exit_adjustment.take() { + events.push(ReedlineEvent::Edit(vec![cmd])); + } + events.extend([ReedlineEvent::Esc, ReedlineEvent::Repaint]); + ReedlineEvent::Multiple(events) + } + (HelixMode::Insert, KeyModifiers::NONE, KeyCode::Enter) => ReedlineEvent::Enter, + (HelixMode::Insert, modifier, KeyCode::Char(c)) => { + let c = match modifier { + KeyModifiers::NONE => c, + _ => c.to_ascii_lowercase(), + }; + + self.insert_keybindings + .find_binding(modifier, KeyCode::Char(c)) + .unwrap_or_else(|| { + if modifier == KeyModifiers::NONE || modifier == KeyModifiers::SHIFT + { + ReedlineEvent::Edit(vec![EditCommand::InsertChar( + if modifier == KeyModifiers::SHIFT { + c.to_ascii_uppercase() + } else { + c + }, + )]) + } else { + ReedlineEvent::None + } + }) + } + (HelixMode::Insert, _, _) => self + .insert_keybindings + .find_binding(modifiers, code) + .unwrap_or(ReedlineEvent::None), + } + } + + Event::Mouse(_) => ReedlineEvent::Mouse, + Event::Resize(width, height) => ReedlineEvent::Resize(width, height), + Event::FocusGained => ReedlineEvent::None, + Event::FocusLost => ReedlineEvent::None, + Event::Paste(body) => ReedlineEvent::Edit(vec![EditCommand::InsertString( + body.replace("\r\n", "\n").replace('\r', "\n"), + )]), + } + } + + fn edit_mode(&self) -> PromptEditMode { + match self.mode { + HelixMode::Normal => PromptEditMode::Helix(PromptHelixMode::Normal), + HelixMode::Insert => PromptEditMode::Helix(PromptHelixMode::Insert), + HelixMode::Select => PromptEditMode::Helix(PromptHelixMode::Select), + } + } + + fn handle_mode_specific_event(&mut self, _event: ReedlineEvent) -> EventStatus { + EventStatus::Inapplicable + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use proptest::prelude::*; + + fn make_key_event(code: KeyCode, modifiers: KeyModifiers) -> ReedlineRawEvent { + ReedlineRawEvent::try_from(Event::Key(KeyEvent::new(code, modifiers))).unwrap() + } + + #[test] + fn i_enters_insert_mode_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('i'), KeyModifiers::NONE)); + + assert_eq!(result, ReedlineEvent::Repaint); + assert_eq!(helix.mode, HelixMode::Insert); + } + + #[test] + fn esc_returns_to_normal_mode_test() { + let mut helix = Helix { + mode: HelixMode::Insert, + ..Default::default() + }; + + let result = helix.parse_event(make_key_event(KeyCode::Esc, KeyModifiers::NONE)); + + // When restore_cursor is false (default), Esc should NOT move cursor left + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + ); + assert_eq!(helix.mode, HelixMode::Normal); + } + + #[test] + fn insert_text_in_insert_mode_test() { + let mut helix = Helix { + mode: HelixMode::Insert, + ..Default::default() + }; + + let result = helix.parse_event(make_key_event(KeyCode::Char('h'), KeyModifiers::NONE)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![EditCommand::InsertChar('h')]) + ); + assert_eq!(helix.mode, HelixMode::Insert); + } + + #[test] + fn normal_mode_ignores_unbound_chars_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('q'), KeyModifiers::NONE)); + + assert_eq!(result, ReedlineEvent::None); + assert_eq!(helix.mode, HelixMode::Normal); + } + + #[test] + fn a_enters_insert_after_cursor_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('a'), KeyModifiers::NONE)); + + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::MoveRight { select: false }]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(helix.mode, HelixMode::Insert); + } + + #[test] + fn shift_i_enters_insert_at_line_start_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('i'), KeyModifiers::SHIFT)); + + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart { select: false }]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(helix.mode, HelixMode::Insert); + } + + #[test] + fn shift_a_enters_insert_at_line_end_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('a'), KeyModifiers::SHIFT)); + + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd { select: false }]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(helix.mode, HelixMode::Insert); + } + + #[test] + fn ctrl_c_aborts_in_normal_mode_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + assert_eq!(result, ReedlineEvent::CtrlC); + } + + #[test] + fn ctrl_c_aborts_in_insert_mode_test() { + let mut helix = Helix { + mode: HelixMode::Insert, + ..Default::default() + }; + + let result = helix.parse_event(make_key_event(KeyCode::Char('c'), KeyModifiers::CONTROL)); + + assert_eq!(result, ReedlineEvent::CtrlC); + } + + #[test] + fn ctrl_d_exits_in_normal_mode_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('d'), KeyModifiers::CONTROL)); + + assert_eq!(result, ReedlineEvent::CtrlD); + } + + #[test] + fn ctrl_d_exits_in_insert_mode_test() { + let mut helix = Helix { + mode: HelixMode::Insert, + ..Default::default() + }; + + let result = helix.parse_event(make_key_event(KeyCode::Char('d'), KeyModifiers::CONTROL)); + + assert_eq!(result, ReedlineEvent::CtrlD); + } + + #[test] + fn h_moves_left_with_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('h'), KeyModifiers::NONE)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::MoveRight { select: false }, + EditCommand::MoveLeft { select: true } + ]) + ); + } + + #[test] + fn l_moves_right_with_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('l'), KeyModifiers::NONE)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::MoveRight { select: false }, + EditCommand::MoveRight { select: true } + ]) + ); + } + + #[test] + fn w_moves_word_forward_with_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![EditCommand::HelixWordRightGap]) + ); + } + + #[test] + fn b_moves_word_back_with_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![EditCommand::HelixWordLeft]) + ); + } + + #[test] + fn e_moves_word_end_with_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('e'), KeyModifiers::NONE)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![ + EditCommand::ClearSelection, + EditCommand::MoveWordRightEnd { select: true } + ]) + ); + } + + #[test] + fn shift_w_moves_bigword_forward_with_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('w'), KeyModifiers::SHIFT)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::MoveRight { select: false }, + EditCommand::MoveBigWordRightStart { select: true } + ]) + ); + } + + #[test] + fn shift_b_moves_bigword_back_with_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::SHIFT)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![ + EditCommand::MoveBigWordLeft { select: false }, + EditCommand::MoveBigWordRightEnd { select: true }, + EditCommand::SwapCursorAndAnchor + ]) + ); + } + + #[test] + fn shift_e_moves_bigword_end_with_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('e'), KeyModifiers::SHIFT)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![ + EditCommand::ClearSelection, + EditCommand::MoveBigWordRightEnd { select: true } + ]) + ); + } + + #[test] + fn zero_moves_to_line_start_with_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('0'), KeyModifiers::NONE)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::MoveRight { select: false }, + EditCommand::MoveToLineStart { select: true } + ]) + ); + } + + #[test] + fn dollar_moves_to_line_end_with_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('$'), KeyModifiers::SHIFT)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::MoveRight { select: false }, + EditCommand::MoveToLineEnd { select: true } + ]) + ); + } + + #[test] + fn x_selects_line_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert_eq!(result, ReedlineEvent::Edit(vec![EditCommand::SelectAll])); + } + + #[test] + fn d_deletes_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('d'), KeyModifiers::NONE)); + + assert_eq!(result, ReedlineEvent::Edit(vec![EditCommand::CutSelection])); + } + + #[test] + fn semicolon_collapses_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char(';'), KeyModifiers::NONE)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![EditCommand::MoveRight { select: false }]) + ); + } + + #[test] + fn c_changes_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('c'), KeyModifiers::NONE)); + + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutSelection]), + ReedlineEvent::Repaint, + ]) + ); + assert_eq!(helix.mode, HelixMode::Insert); + } + + #[test] + fn y_yanks_selection_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('y'), KeyModifiers::NONE)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![EditCommand::CopySelection]) + ); + } + + #[test] + fn p_pastes_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('p'), KeyModifiers::NONE)); + + assert_eq!(result, ReedlineEvent::Edit(vec![EditCommand::Paste])); + } + + #[test] + fn shift_p_pastes_before_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('p'), KeyModifiers::SHIFT)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferBefore]) + ); + } + + #[test] + fn alt_semicolon_swaps_cursor_and_anchor_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char(';'), KeyModifiers::ALT)); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![EditCommand::SwapCursorAndAnchor]) + ); + } + + #[test] + fn f_char_finds_next_char_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result1 = helix.parse_event(make_key_event(KeyCode::Char('f'), KeyModifiers::NONE)); + assert_eq!(result1, ReedlineEvent::None); + assert_eq!(helix.pending_char_search, Some(PendingCharSearch::Find)); + + let result2 = helix.parse_event(make_key_event(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!( + result2, + ReedlineEvent::Edit(vec![EditCommand::MoveRightUntil { + c: 'x', + select: true + }]) + ); + assert_eq!(helix.pending_char_search, None); + } + + #[test] + fn t_char_moves_till_next_char_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result1 = helix.parse_event(make_key_event(KeyCode::Char('t'), KeyModifiers::NONE)); + assert_eq!(result1, ReedlineEvent::None); + assert_eq!(helix.pending_char_search, Some(PendingCharSearch::Till)); + + let result2 = helix.parse_event(make_key_event(KeyCode::Char('y'), KeyModifiers::NONE)); + assert_eq!( + result2, + ReedlineEvent::Edit(vec![EditCommand::MoveRightBefore { + c: 'y', + select: true + }]) + ); + assert_eq!(helix.pending_char_search, None); + } + + #[test] + fn shift_f_finds_previous_char_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result1 = helix.parse_event(make_key_event(KeyCode::Char('f'), KeyModifiers::SHIFT)); + assert_eq!(result1, ReedlineEvent::None); + assert_eq!(helix.pending_char_search, Some(PendingCharSearch::FindBack)); + + let result2 = helix.parse_event(make_key_event(KeyCode::Char('z'), KeyModifiers::NONE)); + assert_eq!( + result2, + ReedlineEvent::Edit(vec![EditCommand::MoveLeftUntil { + c: 'z', + select: true + }]) + ); + assert_eq!(helix.pending_char_search, None); + } + + #[test] + fn shift_t_moves_till_previous_char_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result1 = helix.parse_event(make_key_event(KeyCode::Char('t'), KeyModifiers::SHIFT)); + assert_eq!(result1, ReedlineEvent::None); + assert_eq!(helix.pending_char_search, Some(PendingCharSearch::TillBack)); + + let result2 = helix.parse_event(make_key_event(KeyCode::Char('a'), KeyModifiers::NONE)); + assert_eq!( + result2, + ReedlineEvent::Edit(vec![EditCommand::MoveLeftBefore { + c: 'a', + select: true + }]) + ); + assert_eq!(helix.pending_char_search, None); + } + + #[test] + fn v_enters_select_mode_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result = helix.parse_event(make_key_event(KeyCode::Char('v'), KeyModifiers::NONE)); + + assert_eq!(result, ReedlineEvent::Repaint); + assert_eq!(helix.mode, HelixMode::Select); + } + + #[test] + fn v_exits_select_mode_test() { + let mut helix = Helix { + mode: HelixMode::Select, + ..Default::default() + }; + + let result = helix.parse_event(make_key_event(KeyCode::Char('v'), KeyModifiers::NONE)); + + assert_eq!(result, ReedlineEvent::Repaint); + assert_eq!(helix.mode, HelixMode::Normal); + } + + #[test] + fn esc_exits_select_mode_test() { + let mut helix = Helix { + mode: HelixMode::Select, + ..Default::default() + }; + + let result = helix.parse_event(make_key_event(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(result, ReedlineEvent::Repaint); + assert_eq!(helix.mode, HelixMode::Normal); + } + + #[test] + fn pending_char_search_cancels_on_non_char_test() { + let mut helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + + let result1 = helix.parse_event(make_key_event(KeyCode::Char('f'), KeyModifiers::NONE)); + assert_eq!(result1, ReedlineEvent::None); + assert_eq!(helix.pending_char_search, Some(PendingCharSearch::Find)); + + let result2 = helix.parse_event(make_key_event(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(result2, ReedlineEvent::None); + assert_eq!(helix.pending_char_search, None); + } + + #[test] + fn normal_mode_returns_normal_prompt_mode_test() { + let helix = Helix::default(); + assert_eq!(helix.mode, HelixMode::Normal); + assert_eq!( + helix.edit_mode(), + PromptEditMode::Helix(PromptHelixMode::Normal) + ); + } + + #[test] + fn insert_mode_returns_insert_prompt_mode_test() { + let helix = Helix { + mode: HelixMode::Insert, + ..Default::default() + }; + assert_eq!( + helix.edit_mode(), + PromptEditMode::Helix(PromptHelixMode::Insert) + ); + } + + #[test] + fn select_mode_returns_select_prompt_mode_test() { + let helix = Helix { + mode: HelixMode::Select, + ..Default::default() + }; + assert_eq!( + helix.edit_mode(), + PromptEditMode::Helix(PromptHelixMode::Select) + ); + } + + #[test] + fn cursor_selection_sync_after_insert_mode_test() { + use crate::core_editor::Editor; + + let mut editor = Editor::default(); + let mut helix = Helix::default(); + editor.set_edit_mode(helix.edit_mode()); + + // Start in normal mode, enter append mode with 'a' (restore_cursor = true) + let _result = helix.parse_event(make_key_event(KeyCode::Char('a'), KeyModifiers::NONE)); + assert_eq!(helix.mode, HelixMode::Insert); + editor.set_edit_mode(helix.edit_mode()); + + // Type some text in insert mode + for event in &[ + ReedlineEvent::Edit(vec![EditCommand::InsertChar('h')]), + ReedlineEvent::Edit(vec![EditCommand::InsertChar('e')]), + ReedlineEvent::Edit(vec![EditCommand::InsertChar('l')]), + ReedlineEvent::Edit(vec![EditCommand::InsertChar('l')]), + ReedlineEvent::Edit(vec![EditCommand::InsertChar('o')]), + ] { + if let ReedlineEvent::Edit(commands) = event { + for cmd in commands { + editor.run_edit_command(cmd); + } + } + } + + // Verify we have "hello" and cursor is at position 5 (end of buffer) + assert_eq!(editor.get_buffer(), "hello"); + assert_eq!(editor.insertion_point(), 5); + + // Exit insert mode with Esc - since we entered with 'a', restore_cursor=true, so cursor moves left + let result = helix.parse_event(make_key_event(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(helix.mode, HelixMode::Normal); + editor.set_edit_mode(helix.edit_mode()); + + // In Helix, when entering via append (a), Esc moves cursor left to restore position + // The result includes MoveLeft, then Esc (which resets selection), then Repaint + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::MoveLeft { select: false }]), + ReedlineEvent::Esc, + ReedlineEvent::Repaint, + ]) + ); + + // Apply the move left command + if let ReedlineEvent::Multiple(events) = result { + for event in events { + if let ReedlineEvent::Edit(commands) = event { + for cmd in commands { + editor.run_edit_command(&cmd); + } + } + } + } + + // After Esc + MoveLeft, cursor should be at position 4 + assert_eq!(editor.insertion_point(), 4); + + // After processing ReedlineEvent::Esc, selection should be reset + // The cursor should still be at position 5 + + // Now press 'h' to move left in normal mode + // Starting from pos 4 (on the 'o'), we want to move left to pos 3 (on the second 'l') + let result = helix.parse_event(make_key_event(KeyCode::Char('h'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + + // Expected: MoveLeft{false}, MoveRight{false}, MoveLeft{true} + assert_eq!( + result, + ReedlineEvent::Edit(vec![ + EditCommand::MoveLeft { select: false }, + EditCommand::MoveRight { select: false }, + EditCommand::MoveLeft { select: true } + ]) + ); + + // Execute these commands on the editor + if let ReedlineEvent::Edit(commands) = result { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + + // After MoveLeft{false}, MoveRight{false}, MoveLeft{true}: + // Starting from pos 4: + // 1. MoveLeft{false} -> pos 3, no selection + // 2. MoveRight{false} -> pos 4, no selection + // 3. MoveLeft{true} -> pos 3, selection anchor at 4, cursor at 3 + // get_selection() returns (cursor, grapheme_right_from_anchor) + // So cursor should be at 3, anchor at 4, and selection should be (3, 5) + // This selects the character at position 3 ('l') and 4 ('o') + assert_eq!(editor.insertion_point(), 3); + assert_eq!(editor.get_selection(), Some((3, 5))); + } + + #[test] + fn cursor_selection_sync_after_mode_transitions_test() { + use crate::core_editor::Editor; + + let mut editor = Editor::default(); + editor.set_buffer("world".to_string(), crate::UndoBehavior::CreateUndoPoint); + // set_buffer moves cursor to end, so move it back to start + editor.run_edit_command(&EditCommand::MoveToStart { select: false }); + let mut helix = Helix::default(); + editor.set_edit_mode(helix.edit_mode()); + + // Start at position 0 in normal mode + // Move right with 'l' - the sequence is: MoveLeft{false}, MoveRight{false}, MoveRight{true} + // From position 0: stays at 0, moves to 1, moves to 2 with selection + let result = helix.parse_event(make_key_event(KeyCode::Char('l'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = result { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + + // After the movement sequence, we should be at position 2 + // with selection from position 1 to 2 (which displays as chars at index 1) + // get_selection returns (1, grapheme_right_from(2)) = (1, 3) + assert_eq!(editor.insertion_point(), 2); + assert_eq!(editor.get_selection(), Some((1, 3))); + + // Enter insert mode with 'i' + let _result = helix.parse_event(make_key_event(KeyCode::Char('i'), KeyModifiers::NONE)); + assert_eq!(helix.mode, HelixMode::Insert); + editor.set_edit_mode(helix.edit_mode()); + + // In insert mode, selection should be cleared automatically + // when transitioning (though we'd need to test this with full engine) + + // Exit insert mode with Esc - since we entered with 'i', restore_cursor=false, so NO cursor movement + let result = helix.parse_event(make_key_event(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(helix.mode, HelixMode::Normal); + editor.set_edit_mode(helix.edit_mode()); + + // When entering via insert (i), Esc should NOT move cursor left + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint,]) + ); + + // Apply the Esc commands (no MoveLeft) + if let ReedlineEvent::Multiple(events) = result { + for event in events { + if let ReedlineEvent::Edit(commands) = event { + for cmd in commands { + editor.run_edit_command(&cmd); + } + } + } + } + + // After exiting insert mode from position 2, cursor stays at position 2 + assert_eq!(editor.insertion_point(), 2); + + // Now move left with 'h' from position 2 + // The sequence MoveLeft{false}, MoveRight{false}, MoveLeft{true} becomes: + // 1. MoveLeft{false} -> moves to pos 1 + // 2. MoveRight{false} -> moves back to pos 2 + // 3. MoveLeft{true} -> moves to pos 1, with anchor at 2, creating selection + let result = helix.parse_event(make_key_event(KeyCode::Char('h'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = result { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + + // After the move sequence, cursor should be at 1 with selection including char at pos 1 + // get_selection returns (1, grapheme_right_from(2)) = (1, 3) + assert_eq!(editor.insertion_point(), 1); + assert_eq!(editor.get_selection(), Some((1, 3))); + } + + #[test] + fn e_motion_highlights_full_word_from_start_test() { + use crate::core_editor::Editor; + + let mut editor = Editor::default(); + editor.set_buffer( + "hello world".to_string(), + crate::UndoBehavior::CreateUndoPoint, + ); + editor.run_edit_command(&EditCommand::MoveToStart { select: false }); + + let mut helix = Helix::default(); + editor.set_edit_mode(helix.edit_mode()); + + let result = helix.parse_event(make_key_event(KeyCode::Char('e'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + assert_eq!( + result, + ReedlineEvent::Edit(vec![ + EditCommand::ClearSelection, + EditCommand::MoveWordRightEnd { select: true } + ]) + ); + + if let ReedlineEvent::Edit(commands) = result { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + + assert_eq!(editor.insertion_point(), 4); + assert_eq!(editor.get_selection(), Some((0, 5))); + } + + #[test] + fn w_motion_lands_in_gap_between_words_test() { + use crate::core_editor::Editor; + + let mut editor = Editor::default(); + editor.set_buffer( + "hello world".to_string(), + crate::UndoBehavior::CreateUndoPoint, + ); + editor.run_edit_command(&EditCommand::MoveToStart { select: false }); + + let mut helix = Helix::default(); + editor.set_edit_mode(helix.edit_mode()); + + let result = helix.parse_event(make_key_event(KeyCode::Char('w'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + assert_eq!( + result, + ReedlineEvent::Edit(vec![EditCommand::HelixWordRightGap]) + ); + + if let ReedlineEvent::Edit(commands) = result { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + + assert_eq!(editor.insertion_point(), 5); + assert_eq!(editor.get_selection(), Some((0, 6))); + } + + #[test] + fn test_b_selection_from_end_detailed() { + use crate::core_editor::Editor; + + let mut editor = Editor::default(); + editor.set_buffer( + "hello world".to_string(), + crate::UndoBehavior::CreateUndoPoint, + ); + let mut helix = Helix::default(); + editor.set_edit_mode(helix.edit_mode()); + + println!("\n=== Initial state ==="); + println!("Buffer: '{}'", editor.get_buffer()); + println!("Cursor: {}", editor.insertion_point()); + + // First 'b' from end + println!("\n=== First 'b' press (from position 11) ==="); + let first_b = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = first_b { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + + println!("Cursor: {}", editor.insertion_point()); + println!("Selection: {:?}", editor.get_selection()); + if let Some((start, end)) = editor.get_selection() { + println!("Selected text: '{}'", &editor.get_buffer()[start..end]); + } + + // Second 'b' from start of "world" + println!("\n=== Second 'b' press (from position 6, start of 'world') ==="); + let second_b = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = second_b { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + + println!("Cursor: {}", editor.insertion_point()); + println!("Selection: {:?}", editor.get_selection()); + if let Some((start, end)) = editor.get_selection() { + println!("Selected text: '{}'", &editor.get_buffer()[start..end]); + println!("Expected: 'hello ' (hello + space)"); + + assert_eq!(editor.get_selection(), Some((0, 6))); + } + } + + #[test] + fn repeated_b_motion_clears_previous_word_selection() { + use crate::core_editor::Editor; + + let mut editor = Editor::default(); + editor.set_buffer( + "alpha beta gamma".to_string(), + crate::UndoBehavior::CreateUndoPoint, + ); + let mut helix = Helix::default(); + editor.set_edit_mode(helix.edit_mode()); + + // First `b`: expect to select the trailing word "gamma" + let first_b = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = first_b { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } else { + panic!("Expected ReedlineEvent::Edit for initial `b` motion"); + } + assert_eq!(editor.get_selection(), Some((11, 16))); + + // Second `b`: should clear the previous selection and select "beta " (including space) + let second_b = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = second_b { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } else { + panic!("Expected ReedlineEvent::Edit for second `b` motion"); + } + assert_eq!(editor.get_selection(), Some((6, 11))); + assert_eq!(editor.insertion_point(), 6); + } + + #[test] + fn select_mode_keeps_anchor_on_backward_motion() { + use crate::core_editor::Editor; + + let mut editor = Editor::default(); + editor.set_buffer( + "alpha beta gamma".to_string(), + crate::UndoBehavior::CreateUndoPoint, + ); + let mut helix = Helix::default(); + editor.set_edit_mode(helix.edit_mode()); + + // Enter select mode with 'v' + let _ = helix.parse_event(make_key_event(KeyCode::Char('v'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + assert_eq!(helix.mode, HelixMode::Select); + + // Perform first backward word motion in select mode + let first_b = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = first_b { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } else { + panic!("Expected ReedlineEvent::Edit for first `b` in select mode"); + } + assert_eq!(editor.get_selection(), Some((11, 16))); + + // Second `b` should extend the selection while keeping anchor at the buffer end + let second_b = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = second_b { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } else { + panic!("Expected ReedlineEvent::Edit for second `b` in select mode"); + } + assert_eq!(editor.get_selection(), Some((6, 16))); + } + + // Property-based tests using proptest to verify character search works correctly + // for arbitrary characters. Split into separate tests for each keybind. + + proptest! { + /// Property: 'f' (find forward) should find the character and move cursor to it + /// Tests both command generation AND actual editor behavior + #[test] + fn property_f_find_forward_any_char( + search_char_str in "[a-zA-Z0-9 ]", // ASCII alphanum and space only for reliable testing + prefix in "[a-z]{1,5}", // Random prefix before the search char (at least 1 char) + suffix in "[a-z]{0,5}" // Random suffix after the search char + ) { + use crate::core_editor::Editor; + + // Convert string to char (regex generates a single-char string) + let search_char = search_char_str.chars().next().unwrap(); + + let mut helix = Helix::default(); + prop_assert_eq!(helix.mode, HelixMode::Normal); + + // Press 'f' to enter find mode + let f_result = helix.parse_event(make_key_event(KeyCode::Char('f'), KeyModifiers::NONE)); + prop_assert_eq!(f_result, ReedlineEvent::None); + prop_assert_eq!(helix.pending_char_search, Some(PendingCharSearch::Find)); + + // Press the search character + let char_result = helix.parse_event(make_key_event(KeyCode::Char(search_char), KeyModifiers::NONE)); + prop_assert_eq!( + char_result, + ReedlineEvent::Edit(vec![EditCommand::MoveRightUntil { c: search_char, select: true }]) + ); + prop_assert_eq!(helix.pending_char_search, None); + + // PROPERTY-BASED ASSERTION: Test actual editor behavior + // Create a buffer with the search character in it + let buffer_content = format!("{}{}{}", prefix, search_char, suffix); + + // MoveRightUntil searches for the character AFTER the current cursor position + // We need to work with character indices (not byte indices) for Unicode safety + let chars: Vec = buffer_content.chars().collect(); + + // Skip test if buffer is empty or too short + prop_assume!(chars.len() >= 2); + + // For this test, we need the search char to appear AFTER position 0 + // So we skip if the search character is at position 0 + let start_char_idx = 0; + + // Find the first occurrence of search_char AFTER the starting character index + let expected_char_idx = chars.iter() + .skip(start_char_idx + 1) + .position(|&c| c == search_char) + .map(|pos| start_char_idx + 1 + pos); + + // Only test if the character exists after the starting cursor position + if let Some(expected_idx) = expected_char_idx { + let mut editor = Editor::default(); + editor.set_buffer(buffer_content.clone(), crate::UndoBehavior::CreateUndoPoint); + editor.run_edit_command(&EditCommand::MoveToStart { select: false }); + editor.set_edit_mode(helix.edit_mode()); + + // Execute the find command + let find_command = EditCommand::MoveRightUntil { c: search_char, select: true }; + editor.run_edit_command(&find_command); + + // Convert character index to byte position for comparison with editor + let expected_byte_pos = chars.iter().take(expected_idx).map(|c| c.len_utf8()).sum::(); + + // PROPERTY: The cursor should move to the first occurrence of the search character + // AFTER the current cursor position (not including the position we're at) + prop_assert_eq!( + editor.insertion_point(), + expected_byte_pos, + "Cursor should move to byte position {} (character {} '{}' in buffer '{}')", + expected_byte_pos, + expected_idx, + search_char, + buffer_content + ); + + // Additional property: The selection should exist and include the found character + let selection = editor.get_selection(); + prop_assert!( + selection.is_some(), + "Selection should exist after find command" + ); + + // Property: The selection should start at the initial cursor position + if let Some((sel_start, _sel_end)) = selection { + prop_assert_eq!(sel_start, 0, "Selection should start at the initial cursor position (byte 0)"); + } + } + } + + /// Property: 't' (till forward) should produce MoveRightBefore for any valid character + #[test] + fn property_t_till_forward_any_char( + search_char in any::().prop_filter("Valid printable chars", |c| c.is_alphanumeric() || c.is_whitespace()) + ) { + let mut helix = Helix::default(); + prop_assert_eq!(helix.mode, HelixMode::Normal); + + // Press 't' to enter till mode + let t_result = helix.parse_event(make_key_event(KeyCode::Char('t'), KeyModifiers::NONE)); + prop_assert_eq!(t_result, ReedlineEvent::None); + prop_assert_eq!(helix.pending_char_search, Some(PendingCharSearch::Till)); + + // Press the search character + let char_result = helix.parse_event(make_key_event(KeyCode::Char(search_char), KeyModifiers::NONE)); + prop_assert_eq!( + char_result, + ReedlineEvent::Edit(vec![EditCommand::MoveRightBefore { c: search_char, select: true }]) + ); + prop_assert_eq!(helix.pending_char_search, None); + } + + /// Property: 'F' (find backward) should produce MoveLeftUntil for any valid character + #[test] + fn property_shift_f_find_backward_any_char( + search_char in any::().prop_filter("Valid printable chars", |c| c.is_alphanumeric() || c.is_whitespace()) + ) { + let mut helix = Helix::default(); + prop_assert_eq!(helix.mode, HelixMode::Normal); + + // Press 'F' (shift+f) to enter find backward mode + let f_back_result = helix.parse_event(make_key_event(KeyCode::Char('f'), KeyModifiers::SHIFT)); + prop_assert_eq!(f_back_result, ReedlineEvent::None); + prop_assert_eq!(helix.pending_char_search, Some(PendingCharSearch::FindBack)); + + // Press the search character + let char_result = helix.parse_event(make_key_event(KeyCode::Char(search_char), KeyModifiers::NONE)); + prop_assert_eq!( + char_result, + ReedlineEvent::Edit(vec![EditCommand::MoveLeftUntil { c: search_char, select: true }]) + ); + prop_assert_eq!(helix.pending_char_search, None); + } + + /// Property: 'T' (till backward) should produce MoveLeftBefore for any valid character + #[test] + fn property_shift_t_till_backward_any_char( + search_char in any::().prop_filter("Valid printable chars", |c| c.is_alphanumeric() || c.is_whitespace()) + ) { + let mut helix = Helix::default(); + prop_assert_eq!(helix.mode, HelixMode::Normal); + + // Press 'T' (shift+t) to enter till backward mode + let t_back_result = helix.parse_event(make_key_event(KeyCode::Char('t'), KeyModifiers::SHIFT)); + prop_assert_eq!(t_back_result, ReedlineEvent::None); + prop_assert_eq!(helix.pending_char_search, Some(PendingCharSearch::TillBack)); + + // Press the search character + let char_result = helix.parse_event(make_key_event(KeyCode::Char(search_char), KeyModifiers::NONE)); + prop_assert_eq!( + char_result, + ReedlineEvent::Edit(vec![EditCommand::MoveLeftBefore { c: search_char, select: true }]) + ); + prop_assert_eq!(helix.pending_char_search, None); + } + + /// Property: 'w' (word forward) should move cursor forward by words + /// Tests that word movement respects word boundaries (alphanumeric vs whitespace/punctuation) + #[test] + fn property_w_word_forward_movement( + word1 in "[a-z]{1,5}", // First word (lowercase letters) + word2 in "[a-z]{1,5}", // Second word + separator in "[ \t]{1,3}", // Whitespace between words + ) { + use crate::core_editor::Editor; + + let mut helix = Helix::default(); + prop_assert_eq!(helix.mode, HelixMode::Normal); + + // Create a buffer with two words separated by whitespace + let buffer_content = format!("{}{}{}", word1, separator, word2); + + let mut editor = Editor::default(); + editor.set_buffer(buffer_content.clone(), crate::UndoBehavior::CreateUndoPoint); + editor.run_edit_command(&EditCommand::MoveToStart { select: false }); + editor.set_edit_mode(helix.edit_mode()); + + // Press 'w' to move forward one word + let w_result = helix.parse_event(make_key_event(KeyCode::Char('w'), KeyModifiers::NONE)); + prop_assert_eq!( + w_result, + ReedlineEvent::Edit(vec![EditCommand::HelixWordRightGap]) + ); + + // Execute the command (recreate it to avoid move) + editor.run_edit_command(&EditCommand::HelixWordRightGap); + + // PROPERTY 1: Cursor should have moved forward from the start + let actual_cursor_pos = editor.insertion_point(); + prop_assert!( + actual_cursor_pos > 0, + "After 'w' from start, cursor should move forward from position 0 in buffer '{}'", + buffer_content + ); + + // PROPERTY 2: Cursor should have moved past word1 + // The cursor should be at least past word1 + let min_pos = word1.len(); + prop_assert!( + actual_cursor_pos >= min_pos, + "After 'w', cursor at {} should be at least {} (past word1 '{}') for buffer '{}'", + actual_cursor_pos, + min_pos, + word1, + buffer_content + ); + + // PROPERTY 2b: Cursor should not exceed the buffer length + prop_assert!( + actual_cursor_pos <= buffer_content.len(), + "After 'w', cursor at {} should not exceed buffer length {} for buffer '{}'", + actual_cursor_pos, + buffer_content.len(), + buffer_content + ); + + // PROPERTY 3: A selection should exist after the movement + let selection = editor.get_selection(); + prop_assert!( + selection.is_some(), + "Selection should exist after word movement" + ); + + // PROPERTY 4: Selection should start from the original cursor position (0) + if let Some((sel_start, sel_end)) = selection { + prop_assert_eq!( + sel_start, 0, + "Selection should start at position 0 (anchor preserved)" + ); + prop_assert!( + sel_end >= actual_cursor_pos, + "Selection end ({}) should be at or after cursor at position {}", + sel_end, + actual_cursor_pos + ); + } + } + + /// Property: Multiple 'w' presses should traverse all words in a buffer + /// Tests that repeated word forward movements eventually reach the end + #[test] + fn property_w_multiple_movements_reach_end( + words in prop::collection::vec("[a-z]{1,5}", 2..=5), // 2-5 words + ) { + use crate::core_editor::Editor; + + let mut helix = Helix::default(); + + // Create buffer with words separated by single spaces + let buffer_content = words.join(" "); + let buffer_len = buffer_content.len(); + + // Skip if buffer is too short + prop_assume!(buffer_len > 0); + + let mut editor = Editor::default(); + editor.set_buffer(buffer_content.clone(), crate::UndoBehavior::CreateUndoPoint); + editor.run_edit_command(&EditCommand::MoveToStart { select: false }); + editor.set_edit_mode(helix.edit_mode()); + + let initial_pos = editor.insertion_point(); + + // Press 'w' enough times to definitely move through all words + // (words.len() + 1 should be more than enough) + for _ in 0..words.len() + 1 { + let w_result = helix.parse_event(make_key_event(KeyCode::Char('w'), KeyModifiers::NONE)); + if let ReedlineEvent::Edit(commands) = w_result { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + } + + // PROPERTY: After enough 'w' presses, cursor should have moved from start + let final_pos = editor.insertion_point(); + prop_assert!( + final_pos > initial_pos, + "After {} 'w' movements, cursor should move from position {} in buffer '{}' (words: {:?})", + words.len() + 1, + initial_pos, + buffer_content, + words + ); + + // PROPERTY: Cursor should not go past the end of the buffer + prop_assert!( + final_pos <= buffer_len, + "Cursor at position {} should not exceed buffer length {} for buffer '{}'", + final_pos, + buffer_len, + buffer_content + ); + } + + /// Property: 'b' (word backward) should move cursor backward by words + /// Tests backward word movement with various word and separator combinations + #[test] + fn property_b_word_backward_movement( + word1 in "[a-z]{1,5}", // First word (lowercase letters) + word2 in "[a-z]{1,5}", // Second word + separator in "[ \t]{1,3}", // Whitespace between words + ) { + use crate::core_editor::Editor; + + let mut helix = Helix::default(); + prop_assert_eq!(helix.mode, HelixMode::Normal); + + // Create a buffer with two words separated by whitespace + let buffer_content = format!("{}{}{}", word1, separator, word2); + let buffer_len = buffer_content.len(); + + let mut editor = Editor::default(); + editor.set_buffer(buffer_content.clone(), crate::UndoBehavior::CreateUndoPoint); + // set_buffer positions cursor at end - this is our starting position for 'b' + let start_pos = editor.insertion_point(); + editor.set_edit_mode(helix.edit_mode()); + + // Press 'b' to move backward one word + // Expected command sequence for 'b' in normal mode + let b_result = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); + prop_assert_eq!( + b_result, + ReedlineEvent::Edit(vec![EditCommand::HelixWordLeft]) + ); + + editor.run_edit_command(&EditCommand::HelixWordLeft); + + // PROPERTY 1: Cursor should have moved backward from the end + let actual_cursor_pos = editor.insertion_point(); + let word2_start = word1.len() + separator.len(); + prop_assert!( + actual_cursor_pos < start_pos, + "After 'b' from end (pos {}), cursor should move backward to position {} in buffer '{}'", + start_pos, + actual_cursor_pos, + buffer_content + ); + + prop_assert_eq!( + actual_cursor_pos, + word2_start, + "Cursor should land at start of trailing word (index {}) for buffer '{}'", + word2_start, + buffer_content + ); + + // PROPERTY 2: Cursor should not be negative or exceed buffer length + prop_assert!( + actual_cursor_pos <= buffer_len, + "After 'b', cursor at {} should not exceed buffer length {} for buffer '{}'", + actual_cursor_pos, + buffer_len, + buffer_content + ); + + // PROPERTY 3: Selection exists and matches the trailing word exactly (no gap) + if let Some((sel_start, sel_end)) = editor.get_selection() { + prop_assert_eq!( + sel_start, + actual_cursor_pos, + "Selection should begin at cursor position ({}) for buffer '{}'", + actual_cursor_pos, + buffer_content + ); + prop_assert_eq!( + sel_end, + start_pos, + "Selection should extend back to the original cursor position ({}) for buffer '{}'", + start_pos, + buffer_content + ); + + let selected_slice = &editor.get_buffer()[sel_start..sel_end]; + prop_assert_eq!( + selected_slice, + word2.as_str(), + "Selection after 'b' should match trailing word '{}', got '{}'", + word2, + selected_slice + ); + } else { + prop_assert!(false, "Selection should exist after backward word movement"); + } + } + + /// Property: pressing 'e' then 'b' from the start highlights the first word + /// without pulling in the trailing separator. This mirrors tutorial Step 5/6. + #[test] + fn property_e_then_b_keeps_first_word_selection( + word1 in "[a-z]{1,5}", + word2 in "[a-z]{1,5}", + separator in "[ \t]{1,3}", + ) { + use crate::core_editor::Editor; + + let mut helix = Helix::default(); + prop_assert_eq!(helix.mode, HelixMode::Normal); + + let buffer_content = format!("{}{}{}", word1, separator, word2); + let mut editor = Editor::default(); + editor.set_buffer(buffer_content.clone(), crate::UndoBehavior::CreateUndoPoint); + editor.run_edit_command(&EditCommand::MoveToStart { select: false }); + editor.set_edit_mode(helix.edit_mode()); + + // Step 5: press 'e' + let e_event = helix.parse_event(make_key_event(KeyCode::Char('e'), KeyModifiers::NONE)); + let e_commands = vec![ + EditCommand::ClearSelection, + EditCommand::MoveWordRightEnd { select: true }, + ]; + prop_assert_eq!( + e_event, + ReedlineEvent::Edit(e_commands.clone()) + ); + for cmd in &e_commands { + editor.run_edit_command(cmd); + } + + let (first_sel_start, first_sel_end) = editor + .get_selection() + .expect("Selection should exist after pressing 'e'"); + prop_assert_eq!(first_sel_start, 0); + let first_slice = &editor.get_buffer()[first_sel_start..first_sel_end]; + prop_assume!(first_slice == word1.as_str()); + + // Step 6: press 'b' + let b_event = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); + prop_assert_eq!( + b_event, + ReedlineEvent::Edit(vec![EditCommand::HelixWordLeft]) + ); + editor.run_edit_command(&EditCommand::HelixWordLeft); + + let (sel_start, sel_end) = editor + .get_selection() + .expect("Selection should persist after pressing 'b'"); + prop_assert_eq!(sel_start, 0); + let selected_slice = &editor.get_buffer()[sel_start..sel_end]; + prop_assert_eq!(selected_slice, word1.as_str()); + prop_assert_eq!(editor.insertion_point(), 0); + } + + /// Property: Multiple 'b' presses from end should traverse all words backward + /// Tests that repeated backward movements eventually reach the start + #[test] + fn property_b_multiple_movements_reach_start( + words in prop::collection::vec("[a-z]{1,5}", 2..=5), // 2-5 words + ) { + use crate::core_editor::Editor; + + let mut helix = Helix::default(); + + // Create buffer with words separated by single spaces + let buffer_content = words.join(" "); + let buffer_len = buffer_content.len(); + + // Skip if buffer is too short + prop_assume!(buffer_len > 0); + + let mut editor = Editor::default(); + editor.set_buffer(buffer_content.clone(), crate::UndoBehavior::CreateUndoPoint); + // Cursor starts at end after set_buffer + editor.set_edit_mode(helix.edit_mode()); + + let initial_pos = editor.insertion_point(); + + // Press 'b' enough times to traverse all words backward + // (words.len() + 1 should be more than enough) + for _ in 0..words.len() + 1 { + let b_result = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); + if let ReedlineEvent::Edit(commands) = b_result { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + } + + // PROPERTY: After enough 'b' presses, cursor should have moved backward + let final_pos = editor.insertion_point(); + prop_assert!( + final_pos < initial_pos, + "After {} 'b' movements, cursor should move backward from position {} to {} in buffer '{}' (words: {:?})", + words.len() + 1, + initial_pos, + final_pos, + buffer_content, + words + ); + + // PROPERTY: Cursor should not be negative + prop_assert!( + final_pos <= buffer_len, + "Cursor at position {} should not exceed buffer length {} for buffer '{}'", + final_pos, + buffer_len, + buffer_content + ); + + // PROPERTY: Cursor should be near the start after enough backward movements + // After enough 'b' presses, we should be at or near position 0 + let first_word_len = words.first().map(|w| w.len()).unwrap_or(0); + prop_assert!( + final_pos <= first_word_len, + "After {} 'b' movements, cursor at {} should be in or before first word for buffer '{}' (words: {:?})", + words.len() + 1, + final_pos, + buffer_content, + words + ); + } + } + + #[test] + fn tutorial_step_6_and_7_workflow_test() { + use crate::core_editor::Editor; + + let mut editor = Editor::default(); + editor.set_buffer( + "hello world".to_string(), + crate::UndoBehavior::CreateUndoPoint, + ); + editor.run_edit_command(&EditCommand::MoveToStart { select: false }); + + let mut helix = Helix::default(); + editor.set_edit_mode(helix.edit_mode()); + + // Simulate: After pressing 'e' from start (step 5), we should have "hello" selected + let e_result = helix.parse_event(make_key_event(KeyCode::Char('e'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = e_result { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + if let Some((start, end)) = editor.get_selection() { + assert_eq!( + &editor.get_buffer()[start..end], + "hello", + "Step 5: pressing 'e' should select 'hello'" + ); + } else { + panic!("Step 5 should result in an active selection"); + } + + // Step 6 (part 1): Press 'b' to return to the start of 'hello' + let b_event = helix.parse_event(make_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = b_event { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + if let Some((start, end)) = editor.get_selection() { + assert_eq!( + &editor.get_buffer()[start..end], + "hello", + "Step 6: 'b' should keep the selection focused on 'hello'" + ); + assert_eq!( + editor.insertion_point(), + 0, + "Step 6: cursor should return to start of 'hello'" + ); + } else { + panic!("Step 6 'b' should maintain an active selection"); + } + + // Step 6 (part 2): Press first 'w' - this demonstrates the selection behavior + let first_w = helix.parse_event(make_key_event(KeyCode::Char('w'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = first_w { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + if let Some((start, end)) = editor.get_selection() { + assert_eq!( + &editor.get_buffer()[start..end], + "hello ", + "Step 6: first 'w' should extend the selection to include the trailing space" + ); + } else { + panic!("Step 6 should maintain an active selection"); + } + + // Step 7: Press second 'w' - this should select 'world' + let second_w = helix.parse_event(make_key_event(KeyCode::Char('w'), KeyModifiers::NONE)); + editor.set_edit_mode(helix.edit_mode()); + if let ReedlineEvent::Edit(commands) = second_w { + for cmd in &commands { + editor.run_edit_command(cmd); + } + } + + if let Some((start, end)) = editor.get_selection() { + // Verify that only 'world' remains selected + let selected_text = &editor.get_buffer()[start..end]; + assert_eq!( + selected_text, "world", + "Step 7: second 'w' should deselect 'hello' and select only 'world'" + ); + } else { + panic!("Step 7 should result in an active selection"); + } + } +} diff --git a/src/edit_mode/mod.rs b/src/edit_mode/mod.rs index 38e1456f..a1212649 100644 --- a/src/edit_mode/mod.rs +++ b/src/edit_mode/mod.rs @@ -1,11 +1,13 @@ mod base; mod cursors; mod emacs; +mod helix; mod keybindings; mod vi; pub use base::EditMode; pub use cursors::CursorConfig; pub use emacs::{default_emacs_keybindings, Emacs}; +pub use helix::{default_helix_insert_keybindings, default_helix_normal_keybindings, Helix}; pub use keybindings::Keybindings; pub use vi::{default_vi_insert_keybindings, default_vi_normal_keybindings, Vi}; diff --git a/src/enums.rs b/src/enums.rs index 3f1d9181..993051df 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -120,6 +120,12 @@ pub enum EditCommand { select: bool, }, + /// Move one word to the right, stopping in the whitespace gap before the next word + MoveWordRightGap { + /// Select the text between the current cursor position and destination + select: bool, + }, + /// Move one WORD to the right, stop at start of WORD MoveBigWordRightStart { /// Select the text between the current cursor position and destination @@ -138,6 +144,16 @@ pub enum EditCommand { select: bool, }, + /// Helix-specific backward word motion that preserves the existing word selection + /// when moving from the end of a word back to its start. Used to keep tutorial + /// selections anchored on the full word without capturing the trailing gap. + HelixWordLeft, + + /// Helix-specific forward word motion that normalizes selection orientation before + /// stepping into the inter-word gap. Ensures motions like Step 6 highlight behave + /// consistently regardless of cursor/anchor ordering. + HelixWordRightGap, + /// Move to position MoveToPosition { /// Position to move to @@ -299,6 +315,9 @@ pub enum EditCommand { select: bool, }, + /// Clear any active selection while keeping the cursor in place + ClearSelection, + /// Select whole input buffer SelectAll, @@ -445,9 +464,14 @@ impl Display for EditCommand { EditCommand::MoveWordRightStart { .. } => { write!(f, "MoveWordRightStart Optional[select: ]") } + EditCommand::MoveWordRightGap { .. } => { + write!(f, "MoveWordRightGap Optional[select: ]") + } EditCommand::MoveBigWordRightStart { .. } => { write!(f, "MoveBigWordRightStart Optional[select: ]") } + EditCommand::HelixWordLeft => write!(f, "HelixWordLeft"), + EditCommand::HelixWordRightGap => write!(f, "HelixWordRightGap"), EditCommand::MoveToPosition { .. } => { write!(f, "MoveToPosition Value: , Optional[select: ]") } @@ -498,6 +522,7 @@ impl Display for EditCommand { EditCommand::MoveRightBefore { .. } => write!(f, "MoveRightBefore Value: "), EditCommand::CutLeftUntil(_) => write!(f, "CutLeftUntil Value: "), EditCommand::CutLeftBefore(_) => write!(f, "CutLeftBefore Value: "), + EditCommand::ClearSelection => write!(f, "ClearSelection"), EditCommand::SelectAll => write!(f, "SelectAll"), EditCommand::CutSelection => write!(f, "CutSelection"), EditCommand::CopySelection => write!(f, "CopySelection"), @@ -553,6 +578,7 @@ impl EditCommand { | EditCommand::MoveBigWordLeft { select, .. } | EditCommand::MoveWordRight { select, .. } | EditCommand::MoveWordRightStart { select, .. } + | EditCommand::MoveWordRightGap { select, .. } | EditCommand::MoveBigWordRightStart { select, .. } | EditCommand::MoveWordRightEnd { select, .. } | EditCommand::MoveBigWordRightEnd { select, .. } @@ -562,6 +588,10 @@ impl EditCommand { | EditCommand::MoveLeftBefore { select, .. } => { EditType::MoveCursor { select: *select } } + EditCommand::HelixWordLeft | EditCommand::HelixWordRightGap => { + EditType::MoveCursor { select: true } + } + EditCommand::ClearSelection => EditType::MoveCursor { select: false }, EditCommand::SwapCursorAndAnchor => EditType::MoveCursor { select: true }, EditCommand::SelectAll => EditType::MoveCursor { select: true }, diff --git a/src/lib.rs b/src/lib.rs index 6785d995..3c02e63b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -255,14 +255,15 @@ pub use history::{ mod prompt; pub use prompt::{ - DefaultPrompt, DefaultPromptSegment, Prompt, PromptEditMode, PromptHistorySearch, - PromptHistorySearchStatus, PromptViMode, + DefaultPrompt, DefaultPromptSegment, Prompt, PromptEditMode, PromptHelixMode, + PromptHistorySearch, PromptHistorySearchStatus, PromptViMode, }; mod edit_mode; pub use edit_mode::{ - default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings, - CursorConfig, EditMode, Emacs, Keybindings, Vi, + default_emacs_keybindings, default_helix_insert_keybindings, default_helix_normal_keybindings, + default_vi_insert_keybindings, default_vi_normal_keybindings, CursorConfig, EditMode, Emacs, + Helix, Keybindings, Vi, }; mod highlighter; diff --git a/src/painting/painter.rs b/src/painting/painter.rs index b2ffb6e6..2e2b8e54 100644 --- a/src/painting/painter.rs +++ b/src/painting/painter.rs @@ -1,4 +1,4 @@ -use crate::{CursorConfig, PromptEditMode, PromptViMode}; +use crate::{CursorConfig, PromptEditMode, PromptHelixMode, PromptViMode}; use { super::utils::{coerce_crlf, line_width}, @@ -276,16 +276,32 @@ impl Painter { self.stdout.queue(RestorePosition)?; - if let Some(shapes) = cursor_config { - let shape = match &prompt_mode { + let mut shape = cursor_config + .as_ref() + .and_then(|shapes| match &prompt_mode { PromptEditMode::Emacs => shapes.emacs, PromptEditMode::Vi(PromptViMode::Insert) => shapes.vi_insert, PromptEditMode::Vi(PromptViMode::Normal) => shapes.vi_normal, + PromptEditMode::Helix(PromptHelixMode::Insert) => shapes.helix_insert, + PromptEditMode::Helix(PromptHelixMode::Normal) => shapes.helix_normal, + PromptEditMode::Helix(PromptHelixMode::Select) => shapes.helix_select, + _ => None, + }); + + if shape.is_none() { + // Fall back to Helix's native cursor style when no explicit configuration is + // provided so the selection block stays visible for Normal/Select modes. + shape = match &prompt_mode { + PromptEditMode::Helix(PromptHelixMode::Normal) + | PromptEditMode::Helix(PromptHelixMode::Select) => { + Some(cursor::SetCursorStyle::BlinkingBlock) + } _ => None, }; - if let Some(shape) = shape { - self.stdout.queue(shape)?; - } + } + + if let Some(shape) = shape { + self.stdout.queue(shape)?; } self.stdout.queue(cursor::Show)?; diff --git a/src/prompt/base.rs b/src/prompt/base.rs index 60a37010..700d5df2 100644 --- a/src/prompt/base.rs +++ b/src/prompt/base.rs @@ -43,7 +43,7 @@ impl PromptHistorySearch { } /// Modes that the prompt can be in -#[derive(Serialize, Deserialize, Clone, Debug, EnumIter, Default)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, EnumIter, Default)] pub enum PromptEditMode { /// The default mode #[default] @@ -55,12 +55,15 @@ pub enum PromptEditMode { /// A vi-specific mode Vi(PromptViMode), + /// A helix-specific mode + Helix(PromptHelixMode), + /// A custom mode Custom(String), } /// The vi-specific modes that the prompt can be in -#[derive(Serialize, Deserialize, Clone, Debug, EnumIter, Default)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, EnumIter, Default)] pub enum PromptViMode { /// The default mode #[default] @@ -70,12 +73,27 @@ pub enum PromptViMode { Insert, } +/// The helix-specific modes that the prompt can be in +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, EnumIter, Default)] +pub enum PromptHelixMode { + /// The default mode + #[default] + Normal, + + /// Insertion mode + Insert, + + /// Select mode + Select, +} + impl Display for PromptEditMode { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { match self { PromptEditMode::Default => write!(f, "Default"), PromptEditMode::Emacs => write!(f, "Emacs"), PromptEditMode::Vi(_) => write!(f, "Vi_Normal\nVi_Insert"), + PromptEditMode::Helix(_) => write!(f, "Helix_Normal\nHelix_Insert\nHelix_Select"), PromptEditMode::Custom(s) => write!(f, "Custom_{s}"), } } diff --git a/src/prompt/default.rs b/src/prompt/default.rs index 24625a2c..f5880054 100644 --- a/src/prompt/default.rs +++ b/src/prompt/default.rs @@ -9,6 +9,7 @@ use { pub static DEFAULT_PROMPT_INDICATOR: &str = "〉"; pub static DEFAULT_VI_INSERT_PROMPT_INDICATOR: &str = ": "; pub static DEFAULT_VI_NORMAL_PROMPT_INDICATOR: &str = "〉"; +pub static DEFAULT_VI_SELECT_PROMPT_INDICATOR: &str = "» "; pub static DEFAULT_MULTILINE_INDICATOR: &str = "::: "; /// Simple [`Prompt`] displaying a configurable left and a right prompt. @@ -66,6 +67,11 @@ impl Prompt for DefaultPrompt { PromptViMode::Normal => DEFAULT_VI_NORMAL_PROMPT_INDICATOR.into(), PromptViMode::Insert => DEFAULT_VI_INSERT_PROMPT_INDICATOR.into(), }, + PromptEditMode::Helix(helix_mode) => match helix_mode { + crate::PromptHelixMode::Normal => DEFAULT_VI_NORMAL_PROMPT_INDICATOR.into(), + crate::PromptHelixMode::Insert => DEFAULT_VI_INSERT_PROMPT_INDICATOR.into(), + crate::PromptHelixMode::Select => DEFAULT_VI_SELECT_PROMPT_INDICATOR.into(), + }, PromptEditMode::Custom(str) => format!("({str})").into(), } } diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 83d2e3b5..12f6e0e5 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -2,7 +2,8 @@ mod base; mod default; pub use base::{ - Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode, + Prompt, PromptEditMode, PromptHelixMode, PromptHistorySearch, PromptHistorySearchStatus, + PromptViMode, }; pub use default::{DefaultPrompt, DefaultPromptSegment}; diff --git a/src/utils/text_manipulation.rs b/src/utils/text_manipulation.rs index 0428180b..587a7fd4 100644 --- a/src/utils/text_manipulation.rs +++ b/src/utils/text_manipulation.rs @@ -24,9 +24,9 @@ mod test { #[test] fn remove_last_char_works_with_normal_string() { - let string = "this is a string"; + let string = "this is a test"; - assert_eq!(remove_last_grapheme(string), "this is a strin"); + assert_eq!(remove_last_grapheme(string), "this is a tes"); } #[test]