diff --git a/crates/helm-editor/src/command.rs b/crates/helm-editor/src/command.rs index 50dadd3..24ab26b 100644 --- a/crates/helm-editor/src/command.rs +++ b/crates/helm-editor/src/command.rs @@ -153,6 +153,16 @@ impl CommandRegistry { registry } + /// A registry holding the commands of + /// [`with_editor_commands`](Self::with_editor_commands) and the cursor + /// commands under the ids in + /// [`cursor_commands`](crate::cursor_commands): every command the + /// [`vscode`](crate::vscode) keymap binds. This is the registry + /// `helm-ui` builds its dispatcher with. + pub fn with_all_commands() -> Self { + todo!() + } + /// Adds `command`. Errors, and adds nothing, if its id isn't in the /// form [`CommandId`] describes or is already registered. pub fn register(&mut self, command: Command) -> Result<(), RegisterError> { diff --git a/crates/helm-editor/src/cursor_commands.rs b/crates/helm-editor/src/cursor_commands.rs new file mode 100644 index 0000000..750a089 --- /dev/null +++ b/crates/helm-editor/src/cursor_commands.rs @@ -0,0 +1,115 @@ +//! The ids of the cursor commands, which +//! [`CommandRegistry::with_all_commands`] registers next to the +//! [`editor_commands`](crate::editor_commands). Keymaps (T-107, T-108) +//! bind these. +//! +//! Each move and select command runs [`Editor::move_cursors`] or +//! [`Editor::extend_selections`] with the [`Motion`] its name says, and +//! each delete command runs [`Editor::delete_to`]. They live apart from +//! `editor_commands` because T-106's tests pin that module's list. +//! +//! [`CommandRegistry::with_all_commands`]: crate::CommandRegistry::with_all_commands +//! [`Editor::move_cursors`]: crate::Editor::move_cursors +//! [`Editor::extend_selections`]: crate::Editor::extend_selections +//! [`Editor::delete_to`]: crate::Editor::delete_to +//! [`Motion`]: crate::Motion + +#![expect( + dead_code, + reason = "T-107 interface stubs; the implementation deletes this" +)] + +use crate::CommandRegistry; + +/// `move_cursors(Motion::Left)`. +pub const LEFT: &str = "cursor.left"; +/// `move_cursors(Motion::Right)`. +pub const RIGHT: &str = "cursor.right"; +/// `move_cursors(Motion::Up)`. +pub const UP: &str = "cursor.up"; +/// `move_cursors(Motion::Down)`. +pub const DOWN: &str = "cursor.down"; +/// `move_cursors(Motion::WordLeft)`. +pub const WORD_LEFT: &str = "cursor.word_left"; +/// `move_cursors(Motion::WordRight)`. +pub const WORD_RIGHT: &str = "cursor.word_right"; +/// `move_cursors(Motion::LineStart)`. +pub const LINE_START: &str = "cursor.line_start"; +/// `move_cursors(Motion::LineEnd)`. +pub const LINE_END: &str = "cursor.line_end"; +/// `move_cursors(Motion::TextStart)`. +pub const TEXT_START: &str = "cursor.text_start"; +/// `move_cursors(Motion::TextEnd)`. +pub const TEXT_END: &str = "cursor.text_end"; + +/// `extend_selections(Motion::Left)`. +pub const SELECT_LEFT: &str = "cursor.select_left"; +/// `extend_selections(Motion::Right)`. +pub const SELECT_RIGHT: &str = "cursor.select_right"; +/// `extend_selections(Motion::Up)`. +pub const SELECT_UP: &str = "cursor.select_up"; +/// `extend_selections(Motion::Down)`. +pub const SELECT_DOWN: &str = "cursor.select_down"; +/// `extend_selections(Motion::WordLeft)`. +pub const SELECT_WORD_LEFT: &str = "cursor.select_word_left"; +/// `extend_selections(Motion::WordRight)`. +pub const SELECT_WORD_RIGHT: &str = "cursor.select_word_right"; +/// `extend_selections(Motion::LineStart)`. +pub const SELECT_LINE_START: &str = "cursor.select_line_start"; +/// `extend_selections(Motion::LineEnd)`. +pub const SELECT_LINE_END: &str = "cursor.select_line_end"; +/// `extend_selections(Motion::TextStart)`. +pub const SELECT_TEXT_START: &str = "cursor.select_text_start"; +/// `extend_selections(Motion::TextEnd)`. +pub const SELECT_TEXT_END: &str = "cursor.select_text_end"; + +/// [`Editor::select_all`](crate::Editor::select_all). +pub const SELECT_ALL: &str = "cursor.select_all"; +/// [`Editor::collapse_selections`](crate::Editor::collapse_selections). +pub const COLLAPSE: &str = "cursor.collapse"; + +/// `delete_to(Motion::WordLeft)`. +pub const DELETE_WORD_LEFT: &str = "cursor.delete_word_left"; +/// `delete_to(Motion::WordRight)`. +pub const DELETE_WORD_RIGHT: &str = "cursor.delete_word_right"; +/// `delete_to(Motion::LineStart)`. +pub const DELETE_LINE_START: &str = "cursor.delete_line_start"; +/// `delete_to(Motion::LineEnd)`. +pub const DELETE_LINE_END: &str = "cursor.delete_line_end"; + +/// Every id above, sorted. +pub const ALL: [&str; 26] = [ + COLLAPSE, + DELETE_LINE_END, + DELETE_LINE_START, + DELETE_WORD_LEFT, + DELETE_WORD_RIGHT, + DOWN, + LEFT, + LINE_END, + LINE_START, + RIGHT, + SELECT_ALL, + SELECT_DOWN, + SELECT_LEFT, + SELECT_LINE_END, + SELECT_LINE_START, + SELECT_RIGHT, + SELECT_TEXT_END, + SELECT_TEXT_START, + SELECT_UP, + SELECT_WORD_LEFT, + SELECT_WORD_RIGHT, + TEXT_END, + TEXT_START, + UP, + WORD_LEFT, + WORD_RIGHT, +]; + +/// Registers one command for each id in [`ALL`], each with a non-empty +/// title. +pub(crate) fn register_all(registry: &mut CommandRegistry) { + let _ = registry; + todo!() +} diff --git a/crates/helm-editor/src/editor.rs b/crates/helm-editor/src/editor.rs index e012579..83d3ab1 100644 --- a/crates/helm-editor/src/editor.rs +++ b/crates/helm-editor/src/editor.rs @@ -8,7 +8,7 @@ use helm_text::{ Buffer, BufferId, BufferSet, Edit, FileFormat, TextBuffer, Transaction, TransactionId, }; -use crate::{EditorError, Selection, Selections}; +use crate::{EditorError, Motion, Selection, Selections}; /// The selections on one buffer of a [`BufferSet`], and the commands that /// edit at every selection or add new ones. @@ -70,6 +70,30 @@ use crate::{EditorError, Selection, Selections}; /// around a cursor extends left from it while the character before is a /// word character, and right while the character after is one. If both /// neighbours aren't word characters, there is no word. +/// +/// # Motions (T-107) +/// +/// [`move_cursors`](Self::move_cursors), +/// [`extend_selections`](Self::extend_selections) and +/// [`delete_to`](Self::delete_to) move each selection's head by a +/// [`Motion`], which documents where each one goes. Moving reads only the +/// text near each head, and the lines it crosses for +/// [`Motion::Up`] and [`Motion::Down`]; it never copies the whole text. +/// +/// # Goal columns +/// +/// Moving up and down through a short line and back keeps the column, as +/// in every editor. Each selection has a goal column, in chars from the +/// start of its head's line. The first [`Motion::Up`] or [`Motion::Down`], +/// through `move_cursors` or `extend_selections`, sets it to the head's +/// column, capped at the line's length without its line break. Later Up +/// and Down moves aim for the goal, not the current column, and keep it. +/// +/// Any other `&mut self` method drops every goal, including other motions, +/// editing commands, undo, redo and +/// [`set_selections`](Self::set_selections). If an Up or Down move merges +/// selections, the merged one keeps the goal of one of them; which one is +/// not specified. #[derive(Clone, Debug)] pub struct Editor { buffer: BufferId, @@ -326,6 +350,95 @@ impl Editor { } Ok(id) } + + /// Moves every selection by `motion` (T-107): each becomes a cursor + /// where `motion` takes its head. Returns whether the selections + /// changed. + /// + /// [`Motion::Left`] and [`Motion::Right`] are the exception: a + /// selection that isn't empty becomes a cursor at its start or its end, + /// without moving further. [`Motion::Up`] and [`Motion::Down`] use and + /// keep goal columns, as the type docs describe. + /// + /// The selections are clamped first and put back in normal form after, + /// so cursors that land on one offset merge. The primary is the + /// selection the old primary became. + #[expect( + unused_variables, + reason = "T-107 interface stub; the implementation deletes this" + )] + pub fn move_cursors(&mut self, set: &BufferSet, motion: Motion) -> Result { + todo!() + } + + /// Extends every selection by `motion` (T-107): each head moves where + /// `motion` takes it, and each anchor stays. Returns whether the + /// selections changed. + /// + /// Unlike [`move_cursors`](Self::move_cursors), [`Motion::Left`] and + /// [`Motion::Right`] move the head of a selection that isn't empty like + /// any other. A head that moves back onto its anchor leaves a cursor. + /// Clamping, normal form, the primary and goal columns work as in + /// `move_cursors`. + #[expect( + unused_variables, + reason = "T-107 interface stub; the implementation deletes this" + )] + pub fn extend_selections( + &mut self, + set: &BufferSet, + motion: Motion, + ) -> Result { + todo!() + } + + /// Deletes from every cursor to where `motion` takes it (T-107), in one + /// transaction, such as Option+Backspace for [`Motion::WordLeft`]. + /// A selection that isn't empty deletes its range instead, as + /// [`delete_backward`](Self::delete_backward) does. + /// + /// Each cursor's range runs between its head and where `motion` takes + /// it, whichever comes first. Otherwise it works as `delete_backward`: + /// overlapping ranges are joined, each selection that deleted something + /// becomes a cursor where its range started, the others stay where they + /// were, moved by the edits before them, and with nothing to delete it + /// applies no transaction and returns `Ok(None)`. Undo and redo put + /// the selections back as for any editing command. + #[expect( + unused_variables, + reason = "T-107 interface stub; the implementation deletes this" + )] + pub fn delete_to( + &mut self, + set: &mut BufferSet, + motion: Motion, + ) -> Result, EditorError> { + todo!() + } + + /// Replaces the selections with one selection of the whole text, + /// pointing forward from offset 0, as the primary (T-107). Returns + /// whether the selections changed. In an empty text that is a cursor + /// at 0. + #[expect( + unused_variables, + reason = "T-107 interface stub; the implementation deletes this" + )] + pub fn select_all(&mut self, set: &BufferSet) -> Result { + todo!() + } + + /// VS Code's Escape (T-107). With more than one selection, keeps only + /// the primary, as it is. With one selection, makes it a cursor at its + /// head. Returns whether the selections changed. The selections are + /// clamped first. + #[expect( + unused_variables, + reason = "T-107 interface stub; the implementation deletes this" + )] + pub fn collapse_selections(&mut self, set: &BufferSet) -> Result { + todo!() + } } impl Editor { diff --git a/crates/helm-editor/src/lib.rs b/crates/helm-editor/src/lib.rs index 40ab90c..f17708b 100644 --- a/crates/helm-editor/src/lib.rs +++ b/crates/helm-editor/src/lib.rs @@ -46,8 +46,10 @@ //! `"editor.delete_backward"`, and runs against one [`Editor`] and the //! [`BufferSet`](helm_text::BufferSet) that owns its buffer. A //! [`CommandRegistry`] maps ids to commands. -//! [`CommandRegistry::with_editor_commands`] registers every `Editor` -//! command under the ids in [`editor_commands`]. +//! [`CommandRegistry::with_editor_commands`] registers the T-105 `Editor` +//! commands under the ids in [`editor_commands`], and +//! [`CommandRegistry::with_all_commands`] adds the cursor commands in +//! [`cursor_commands`]: moving, selecting and deleting by a [`Motion`]. //! //! # Keys, keymaps and modes //! @@ -58,8 +60,8 @@ //! A [`Keymap`] is data: a list of [`Mode`]s and a list of [`Binding`]s //! from a [`KeySequence`] (one keystroke, or a chord such as //! `cmd-k cmd-s`) to a command id, each scoped to a mode and an optional -//! context. VS Code's keymap (T-107) has one mode; Vim's (T-108) has -//! several. A mode says whether a key no binding matches types its text, +//! context. VS Code's keymap ([`vscode::keymap`], T-107) has one mode; +//! Vim's (T-108) has several. A mode says whether a key no binding matches types its text, //! so typing takes the same path as every other command. //! //! A [`Dispatcher`] holds the active keymap and the registry, and each @@ -70,13 +72,16 @@ //! editor uses the new keymap from its next key (ED-6). mod command; +pub mod cursor_commands; mod dispatch; mod editor; pub mod editor_commands; mod error; mod key; mod keymap; +mod motion; mod selection; +pub mod vscode; pub use command::{ Command, CommandArgs, CommandContext, CommandError, CommandFn, CommandId, CommandRegistry, @@ -87,4 +92,5 @@ pub use editor::Editor; pub use error::EditorError; pub use key::{Key, KeyEvent, KeyParseError, KeySequence, Keystroke, Modifiers}; pub use keymap::{Action, Binding, Keymap, KeymapError, Mode, ModeId, Typing}; +pub use motion::Motion; pub use selection::{Selection, Selections}; diff --git a/crates/helm-editor/src/motion.rs b/crates/helm-editor/src/motion.rs new file mode 100644 index 0000000..ade9f00 --- /dev/null +++ b/crates/helm-editor/src/motion.rs @@ -0,0 +1,84 @@ +//! Motions: where a cursor goes from its head. [`Editor::move_cursors`], +//! [`Editor::extend_selections`] and [`Editor::delete_to`] take one, and +//! the cursor commands in [`cursor_commands`](crate::cursor_commands) are +//! one motion each. +//! +//! [`Editor::move_cursors`]: crate::Editor::move_cursors +//! [`Editor::extend_selections`]: crate::Editor::extend_selections +//! [`Editor::delete_to`]: crate::Editor::delete_to + +/// Where a cursor moves from its head. +/// +/// Every motion lands on a char boundary of the buffer's text, never +/// between the CR and LF of a CRLF. A line break is LF, CRLF or a lone CR, +/// as `helm-text` defines it, and CRLF always counts as one character. +/// Columns are in chars, not display columns: tab width and the width of +/// wide characters like 中 are `helm-ui`'s job. +/// +/// # Word classes +/// +/// [`WordLeft`](Self::WordLeft) and [`WordRight`](Self::WordRight) sort +/// each char into one of three classes, plus line breaks: +/// +/// - **Word:** `char::is_alphanumeric`, or `_`. The same rule as +/// [`Editor::add_next_match`](crate::Editor::add_next_match). +/// - **Blank:** `char::is_whitespace` and not part of a line break, such +/// as a space or a tab. +/// - **Punctuation:** every other char, such as `.`, `(` or `😀`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Motion { + /// One character back: two bytes after a CRLF, one char otherwise. At + /// offset 0 it stays. + /// + /// When [`Editor::move_cursors`](crate::Editor::move_cursors) moves a + /// selection that isn't empty, the selection becomes a cursor at its + /// start instead, the way VS Code collapses a selection on Left. + Left, + /// One character forward: two bytes before a CRLF, one char otherwise. + /// At the end of the text it stays. + /// + /// When [`Editor::move_cursors`](crate::Editor::move_cursors) moves a + /// selection that isn't empty, the selection becomes a cursor at its + /// end instead. + Right, + /// To the line above, at the selection's goal column (see + /// [`Editor`](crate::Editor), "Goal columns"), capped at that line's + /// length without its line break. On the first line it goes to offset + /// 0. + Up, + /// To the line below, the same way as [`Up`](Self::Up). On the last + /// line it goes to the end of the text. + Down, + /// To the start of the word, or run of punctuation, before the head: + /// + /// 1. If a line break ends right before the head, move to before it, + /// and stop. + /// 2. Skip back over blanks. If that reaches the start of a line, or + /// offset 0, stop there. + /// 3. Skip back over the run of chars of the same class as the char + /// just before, word or punctuation, and stop. + /// + /// At offset 0 it stays. + WordLeft, + /// To the end of the word, or run of punctuation, after the head: + /// + /// 1. If a line break starts at the head, move to after it, and stop. + /// 2. Skip over blanks. If that reaches a line break, or the end of the + /// text, stop there. + /// 3. Skip over the run of chars of the same class as the char just + /// after, word or punctuation, and stop. + /// + /// At the end of the text it stays. + WordRight, + /// To the first char of the line that isn't a blank, or, if the head + /// is already there, to the start of the line. A line of only blanks + /// has its first non-blank position at the end of its text. This is + /// VS Code's Home. + LineStart, + /// To the end of the line's text, before its line break. + LineEnd, + /// To offset 0. + TextStart, + /// To the end of the text. + TextEnd, +} diff --git a/crates/helm-editor/src/vscode.rs b/crates/helm-editor/src/vscode.rs new file mode 100644 index 0000000..baf54c1 --- /dev/null +++ b/crates/helm-editor/src/vscode.rs @@ -0,0 +1,48 @@ +//! VS Code's default keymap on macOS (ED-4), for every command +//! [`CommandRegistry::with_all_commands`] registers. +//! +//! [`CommandRegistry::with_all_commands`]: crate::CommandRegistry::with_all_commands + +use crate::Keymap; + +/// The keymap's name. +pub const NAME: &str = "vscode"; + +/// The keymap's one mode. Its typing is +/// [`Typing::Insert`](crate::Typing::Insert), so a key no binding matches +/// types its text. +pub const MODE: &str = "default"; + +/// VS Code's default keymap on macOS: one mode, [`MODE`], and these +/// bindings, each [`Action::Run`](crate::Action::Run) with no mode, no +/// context and no mode switch. Keys are in +/// [`KeySequence`](crate::KeySequence) notation. +/// +/// | Keys | Command | +/// | --- | --- | +/// | `enter` | `editor.newline` | +/// | `backspace` | `editor.delete_backward` | +/// | `delete` | `editor.delete_forward` | +/// | `cmd-z` | `editor.undo` | +/// | `shift-cmd-z` | `editor.redo` | +/// | `cmd-d` | `editor.add_next_match` | +/// | `shift-cmd-l` | `editor.select_all_matches` | +/// | `alt-cmd-up` | `editor.add_cursor_above` | +/// | `alt-cmd-down` | `editor.add_cursor_below` | +/// | `left`, `right`, `up`, `down` | `cursor.left`, `cursor.right`, `cursor.up`, `cursor.down` | +/// | `alt-left`, `alt-right` | `cursor.word_left`, `cursor.word_right` | +/// | `home`, `cmd-left` | `cursor.line_start` | +/// | `end`, `cmd-right` | `cursor.line_end` | +/// | `cmd-up`, `cmd-down` | `cursor.text_start`, `cursor.text_end` | +/// | `shift-` with each of the twelve keys in the five rows above | the `cursor.select_` command of the same motion | +/// | `cmd-a` | `cursor.select_all` | +/// | `escape` | `cursor.collapse` | +/// | `alt-backspace`, `alt-delete` | `cursor.delete_word_left`, `cursor.delete_word_right` | +/// | `cmd-backspace`, `cmd-delete` | `cursor.delete_line_start`, `cursor.delete_line_end` | +/// +/// That is 39 bindings. None of them is a chord, and none uses a char +/// that Shift makes, so the notation's folding of Shift into chars changes +/// nothing here. +pub fn keymap() -> Keymap { + todo!() +} diff --git a/crates/helm-editor/tests/motion_properties.rs b/crates/helm-editor/tests/motion_properties.rs new file mode 100644 index 0000000..d78ea76 --- /dev/null +++ b/crates/helm-editor/tests/motion_properties.rs @@ -0,0 +1,226 @@ +//! T-107 acceptance tests: property tests for motions on random text with +//! every line break and character width. Every motion lands on a char +//! boundary outside any CRLF, Left and Right undo each other, word motions +//! always make progress, many cursors move as each would alone, and +//! `delete_to` matches a plain `String` oracle. + +use helm_editor::{Editor, Motion, Selection, Selections}; +use helm_text::{Buffer, BufferSet, FileFormat, LineEndings}; +use proptest::prelude::*; +use proptest::sample::Index; + +/// Every motion. +const MOTIONS: [Motion; 10] = [ + Motion::Left, + Motion::Right, + Motion::Up, + Motion::Down, + Motion::WordLeft, + Motion::WordRight, + Motion::LineStart, + Motion::LineEnd, + Motion::TextStart, + Motion::TextEnd, +]; + +/// One piece of random text: words, blanks, punctuation, each line break +/// and each character width. +fn piece() -> impl Strategy { + prop_oneof![ + 4 => "[a-c_]{1,4}", + 2 => "[ \t]{1,3}", + 2 => "[.(){}]{1,2}", + 1 => Just("\n".to_owned()), + 1 => Just("\r".to_owned()), + 1 => Just("\r\n".to_owned()), + 1 => Just("é".to_owned()), + 1 => Just("中".to_owned()), + 1 => Just("😀".to_owned()), + 1 => any::().prop_map(String::from), + ] +} + +/// Random text of up to `max_pieces` pieces. +fn text(max_pieces: usize) -> impl Strategy { + prop::collection::vec(piece(), 0..max_pieces).prop_map(|pieces| pieces.concat()) +} + +fn motion() -> impl Strategy { + prop::sample::select(MOTIONS.to_vec()) +} + +/// Every char boundary of `text` that isn't between a CR and an LF, +/// including its length. +fn positions(text: &str) -> Vec { + text.char_indices() + .map(|(byte, _)| byte) + .chain([text.len()]) + .filter(|&at| !inside_crlf(text, at)) + .collect() +} + +/// Whether `at` sits between the CR and the LF of a CRLF. +fn inside_crlf(text: &str, at: usize) -> bool { + at > 0 && text[..at].ends_with('\r') && text[at..].starts_with('\n') +} + +fn format_of(text: &str) -> FileFormat { + FileFormat { + bom: false, + line_endings: LineEndings::count(text), + } +} + +/// An editor on `text` with `selections`. +fn setup(text: &str, selections: Selections) -> (BufferSet, Editor) { + let mut set = BufferSet::new(); + let id = set.add(Buffer::from(text)); + let mut editor = Editor::new(id, format_of(text)); + editor.set_selections(selections); + (set, editor) +} + +/// Where `motion` takes a single cursor at `at`, by moving it. +fn target(text: &str, at: usize, motion: Motion) -> usize { + let (set, mut editor) = setup(text, Selections::single(Selection::cursor(at))); + editor + .move_cursors(&set, motion) + .expect("buffer is in the set"); + let selections = editor.selections().as_slice(); + assert_eq!(selections.len(), 1); + assert!(selections[0].is_empty()); + selections[0].head +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// From any position, every motion lands on a char boundary that isn't + /// inside a CRLF, within the text. + #[test] + #[ignore = "T-107: not implemented yet"] + fn every_motion_lands_on_a_valid_position( + text in text(24), + at in any::(), + motion in motion(), + ) { + let at = *at.get(&positions(&text)); + let to = target(&text, at, motion); + prop_assert!(to <= text.len()); + prop_assert!(text.is_char_boundary(to)); + prop_assert!(!inside_crlf(&text, to), "{motion:?} from {at} in {text:?} gave {to}"); + } + + /// Left then Right, and Right then Left, come back to where they + /// started, away from the ends of the text. + #[test] + #[ignore = "T-107: not implemented yet"] + fn left_and_right_undo_each_other(text in text(24), at in any::()) { + let at = *at.get(&positions(&text)); + if at < text.len() { + let right = target(&text, at, Motion::Right); + prop_assert!(right > at); + prop_assert_eq!(target(&text, right, Motion::Left), at); + } + if at > 0 { + let left = target(&text, at, Motion::Left); + prop_assert!(left < at); + prop_assert_eq!(target(&text, left, Motion::Right), at); + } + } + + /// Word motions always move, until the end they head for, so pressing + /// one repeatedly crosses the whole text. + #[test] + #[ignore = "T-107: not implemented yet"] + fn word_motions_always_make_progress(text in text(24)) { + let mut at = 0; + let mut steps = 0; + while at < text.len() { + let next = target(&text, at, Motion::WordRight); + prop_assert!(next > at, "WordRight stuck at {at} in {text:?}"); + at = next; + steps += 1; + } + prop_assert!(steps <= text.len()); + while at > 0 { + let next = target(&text, at, Motion::WordLeft); + prop_assert!(next < at, "WordLeft stuck at {at} in {text:?}"); + at = next; + } + } + + /// Moving many cursors at once gives what moving each alone gives, put + /// in normal form, with the primary the one the old primary became. + #[test] + #[ignore = "T-107: not implemented yet"] + fn many_cursors_move_as_each_would_alone( + text in text(24), + picks in prop::collection::vec(any::(), 1..=12), + primary in any::(), + motion in motion(), + ) { + let positions = positions(&text); + let cursors: Vec = picks + .iter() + .map(|pick| Selection::cursor(*pick.get(&positions))) + .collect(); + let selections = Selections::new(cursors, primary.index(picks.len())) + .expect("non-empty, primary in range"); + let (set, mut editor) = setup(&text, selections.clone()); + editor.move_cursors(&set, motion).expect("buffer is in the set"); + + let alone: Vec = selections + .as_slice() + .iter() + .map(|cursor| Selection::cursor(target(&text, cursor.head, motion))) + .collect(); + let expected = Selections::new(alone, selections.primary_index()) + .expect("non-empty, primary in range"); + prop_assert_eq!(editor.selections(), &expected); + } + + /// Extending a cursor puts its head where moving it would, and keeps + /// its anchor. + #[test] + #[ignore = "T-107: not implemented yet"] + fn extending_keeps_the_anchor( + text in text(24), + at in any::(), + motion in motion(), + ) { + let at = *at.get(&positions(&text)); + let (set, mut editor) = setup(&text, Selections::single(Selection::cursor(at))); + editor.extend_selections(&set, motion).expect("buffer is in the set"); + let expected = Selection::new(at, target(&text, at, motion)); + prop_assert_eq!(editor.selections().as_slice(), &[expected]); + } + + /// `delete_to` from one cursor removes exactly the text between the + /// cursor and where the motion takes it, leaves the cursor at the + /// start of that range, and one undo restores the text. + #[test] + #[ignore = "T-107: not implemented yet"] + fn delete_to_matches_a_string_oracle( + text in text(24), + at in any::(), + motion in motion(), + ) { + let at = *at.get(&positions(&text)); + let to = target(&text, at, motion); + let range = at.min(to)..at.max(to); + let mut expected = text.clone(); + expected.replace_range(range.clone(), ""); + + let (mut set, mut editor) = setup(&text, Selections::single(Selection::cursor(at))); + let id = editor.delete_to(&mut set, motion).expect("buffer is in the set"); + prop_assert_eq!(id.is_some(), !range.is_empty()); + let after = set.get(editor.buffer()).expect("buffer is in the set").to_string(); + prop_assert_eq!(&after, &expected); + prop_assert_eq!(editor.selections().as_slice(), &[Selection::cursor(range.start)]); + + editor.undo(&mut set).expect("buffer is in the set"); + let undone = set.get(editor.buffer()).expect("buffer is in the set").to_string(); + prop_assert_eq!(undone, text); + } +} diff --git a/crates/helm-editor/tests/motions.rs b/crates/helm-editor/tests/motions.rs new file mode 100644 index 0000000..91fc850 --- /dev/null +++ b/crates/helm-editor/tests/motions.rs @@ -0,0 +1,762 @@ +//! T-107 acceptance tests: moving, extending and deleting by a `Motion`, +//! goal columns, `select_all` and `collapse_selections`. +//! +//! Texts are written with markers: `|` is a head, and `^` is the anchor of +//! the selection whose head is the marker right next to it. A `|` with no +//! `^` beside it is a cursor. The first selection in the text is the +//! primary unless a test says otherwise. + +use helm_editor::{Editor, EditorError, Motion, Selection, Selections}; +use helm_text::{Buffer, BufferId, BufferSet, FileFormat, LineEndings, TextBuffer}; + +/// The text with its markers removed, and the selections they mark, in +/// the order they appear. +fn parse(marked: &str) -> (String, Vec) { + let mut text = String::new(); + let mut markers: Vec<(char, usize)> = Vec::new(); + for c in marked.chars() { + match c { + '|' | '^' => markers.push((c, text.len())), + _ => text.push(c), + } + } + // Pair each `^` with the `|` right after it, or else right before it. + let mut paired = vec![false; markers.len()]; + let mut selections: Vec<(usize, Selection)> = Vec::new(); + for j in 0..markers.len() { + if markers[j].0 != '^' { + continue; + } + let head = if j + 1 < markers.len() && markers[j + 1].0 == '|' && !paired[j + 1] { + j + 1 + } else if j > 0 && markers[j - 1].0 == '|' && !paired[j - 1] { + j - 1 + } else { + panic!( + "`^` at {} has no `|` next to it in {marked:?}", + markers[j].1 + ); + }; + paired[j] = true; + paired[head] = true; + selections.push((j.min(head), Selection::new(markers[j].1, markers[head].1))); + } + for (j, &(c, at)) in markers.iter().enumerate() { + if c == '|' && !paired[j] { + selections.push((j, Selection::cursor(at))); + } + } + selections.sort_by_key(|&(j, _)| j); + assert!(!selections.is_empty(), "no selection in {marked:?}"); + (text, selections.into_iter().map(|(_, s)| s).collect()) +} + +/// `text` with the markers for `selections`. +fn show(text: &str, selections: &Selections) -> String { + let mut markers: Vec<(usize, usize, char)> = Vec::new(); + for (i, s) in selections.as_slice().iter().enumerate() { + if s.is_empty() { + markers.push((s.head, i, '|')); + } else if s.is_reversed() { + markers.push((s.head, i, '|')); + markers.push((s.anchor, i, '^')); + } else { + markers.push((s.anchor, i, '^')); + markers.push((s.head, i, '|')); + } + } + markers.sort(); + let mut out = String::new(); + let mut at = 0; + for (offset, _, c) in markers { + out.push_str(&text[at..offset]); + out.push(c); + at = offset; + } + out.push_str(&text[at..]); + out +} + +/// One editor on one buffer. +struct Session { + set: BufferSet, + editor: Editor, +} + +impl Session { + /// An editor on the marked text, the first selection primary. + fn new(marked: &str) -> Self { + Self::with_primary(marked, 0) + } + + /// An editor on the marked text, with selection `primary` (in text + /// order) as the primary. + fn with_primary(marked: &str, primary: usize) -> Self { + let (text, selections) = parse(marked); + let mut set = BufferSet::new(); + let id = set.add(Buffer::from(text.as_str())); + let format = FileFormat { + bom: false, + line_endings: LineEndings::count(&text), + }; + let mut editor = Editor::new(id, format); + editor.set_selections(Selections::new(selections, primary).expect("valid selections")); + Self { set, editor } + } + + fn text(&self) -> String { + self.set + .get(self.editor.buffer()) + .expect("buffer is in the set") + .to_string() + } + + /// The text with the editor's selections marked. + fn show(&self) -> String { + show(&self.text(), self.editor.selections()) + } + + fn move_by(&mut self, motion: Motion) -> bool { + self.editor + .move_cursors(&self.set, motion) + .expect("buffer is in the set") + } + + fn extend_by(&mut self, motion: Motion) -> bool { + self.editor + .extend_selections(&self.set, motion) + .expect("buffer is in the set") + } + + fn delete_to(&mut self, motion: Motion) -> bool { + self.editor + .delete_to(&mut self.set, motion) + .expect("buffer is in the set") + .is_some() + } +} + +/// The marked text after moving every selection in `marked` by `motion`. +fn moved(motion: Motion, marked: &str) -> String { + let mut s = Session::new(marked); + s.move_by(motion); + s.show() +} + +/// The marked text after extending every selection in `marked` by +/// `motion`. +fn extended(motion: Motion, marked: &str) -> String { + let mut s = Session::new(marked); + s.extend_by(motion); + s.show() +} + +/// The marked text after `delete_to(motion)` on `marked`. +fn deleted(motion: Motion, marked: &str) -> String { + let mut s = Session::new(marked); + s.delete_to(motion); + s.show() +} + +/// Checks each `(before, after)` pair for one function and motion. +fn table(f: fn(Motion, &str) -> String, motion: Motion, cases: &[(&str, &str)]) { + for &(before, after) in cases { + assert_eq!( + f(motion, before), + after, + "{motion:?} from {before:?} should give {after:?}" + ); + } +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn the_markers_round_trip() { + // Checks the helpers, and calls into the implementation once so the + // test runs against it. + for marked in ["ab|c", "a^bc|d", "a|bc^d", "|a ^b|", "^a|^b|", "a\r\n|b"] { + let (text, selections) = parse(marked); + let selections = Selections::new(selections, 0).expect("valid"); + assert_eq!(show(&text, &selections), marked); + } + assert_eq!(moved(Motion::TextStart, "ab|c"), "|abc"); +} + +// Left and Right. + +#[test] +#[ignore = "T-107: not implemented yet"] +fn left_and_right_move_one_character() { + table( + moved, + Motion::Left, + &[ + ("ab|c", "a|bc"), + ("|abc", "|abc"), + ("a\r\n|b", "a|\r\nb"), + ("a\n|b", "a|\nb"), + ("a\r|b", "a|\rb"), + ("é|中😀", "|é中😀"), + ("é中😀|", "é中|😀"), + ], + ); + table( + moved, + Motion::Right, + &[ + ("ab|c", "abc|"), + ("abc|", "abc|"), + ("a|\r\nb", "a\r\n|b"), + ("a|\nb", "a\n|b"), + ("a|\rb", "a\r|b"), + ("|é中😀", "é|中😀"), + ("é中|😀", "é中😀|"), + ], + ); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn a_head_inside_a_crlf_moves_out_of_it() { + // A stale cursor between the CR and the LF. + assert_eq!(moved(Motion::Left, "a\r|\nb"), "a|\r\nb"); + assert_eq!(moved(Motion::Right, "a\r|\nb"), "a\r\n|b"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn moving_left_or_right_collapses_a_selection_to_its_side() { + table( + moved, + Motion::Left, + &[("a^bc|d", "a|bcd"), ("a|bc^d", "a|bcd")], + ); + table( + moved, + Motion::Right, + &[("a^bc|d", "abc|d"), ("a|bc^d", "abc|d")], + ); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn other_motions_move_a_selection_from_its_head() { + assert_eq!(moved(Motion::WordRight, "^ab| cd"), "ab cd|"); + assert_eq!(moved(Motion::LineEnd, "a|b^c\nd"), "abc|\nd"); + assert_eq!(moved(Motion::Down, "a^b|\ncd"), "ab\ncd|"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn extending_moves_only_the_head() { + table( + extended, + Motion::Left, + &[("ab|c", "a|b^c"), ("a^bc|d", "a^b|cd"), ("|abc", "|abc")], + ); + table( + extended, + Motion::Right, + &[ + ("ab|c", "ab^c|"), + ("a|bc^d", "ab|c^d"), + ("a^b|\r\nc", "a^b\r\n|c"), + ], + ); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn a_head_that_reaches_its_anchor_leaves_a_cursor() { + assert_eq!(extended(Motion::Left, "a^b|c"), "a|bc"); + assert_eq!(extended(Motion::Right, "a|b^c"), "ab|c"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn moving_reports_whether_anything_changed() { + let mut s = Session::new("|abc"); + assert!(!s.move_by(Motion::Left)); + assert!(!s.move_by(Motion::TextStart)); + assert!(s.move_by(Motion::Right)); + assert!(s.extend_by(Motion::LineStart)); + assert_eq!(s.show(), "|a^bc"); + assert!(!s.extend_by(Motion::TextStart)); + let mut s = Session::new("a^b|c"); + assert!(s.extend_by(Motion::Left)); + assert_eq!(s.show(), "a|bc"); +} + +// Up and Down. + +#[test] +#[ignore = "T-107: not implemented yet"] +fn up_and_down_keep_the_column() { + table( + moved, + Motion::Up, + &[ + ("abc\nd|ef\nghi", "a|bc\ndef\nghi"), + ("abc\r\nd|ef", "a|bc\r\ndef"), + ("a\rb|c", "a|\rbc"), + ], + ); + table( + moved, + Motion::Down, + &[ + ("abc\nd|ef\nghi", "abc\ndef\ng|hi"), + ("a|bc\r\ndef", "abc\r\nd|ef"), + ("a|bc\n", "abc\n|"), + ], + ); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn up_on_the_first_line_and_down_on_the_last_go_to_the_ends() { + assert_eq!(moved(Motion::Up, "ab|c\ndef"), "|abc\ndef"); + assert_eq!(moved(Motion::Down, "abc\nd|ef"), "abc\ndef|"); + assert_eq!(moved(Motion::Up, "a|bc"), "|abc"); + assert_eq!(moved(Motion::Down, "a|bc"), "abc|"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn the_column_is_capped_before_the_line_break() { + assert_eq!(moved(Motion::Down, "abcd|\nab\nx"), "abcd\nab|\nx"); + assert_eq!(moved(Motion::Down, "abcd|\r\nab\r\nx"), "abcd\r\nab|\r\nx"); + assert_eq!(moved(Motion::Up, "\nabc|"), "|\nabc"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn columns_count_chars_not_bytes() { + assert_eq!(moved(Motion::Down, "中文字|\nabcdef"), "中文字\nabc|def"); + assert_eq!(moved(Motion::Up, "é😀ab\nab|c"), "é😀|ab\nabc"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn the_goal_column_survives_a_short_line() { + let mut s = Session::new("abcde|f\nab\nabcdef"); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abcdef\nab|\nabcdef"); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abcdef\nab\nabcde|f"); + s.move_by(Motion::Up); + assert_eq!(s.show(), "abcdef\nab|\nabcdef"); + s.move_by(Motion::Up); + assert_eq!(s.show(), "abcde|f\nab\nabcdef"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn the_goal_column_survives_the_first_and_last_lines() { + let mut s = Session::new("ab|c\nabc"); + s.move_by(Motion::Up); + assert_eq!(s.show(), "|abc\nabc"); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abc\nab|c"); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abc\nabc|"); + s.move_by(Motion::Up); + assert_eq!(s.show(), "ab|c\nabc"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn extending_up_and_down_shares_the_goal_with_moving() { + let mut s = Session::new("abcde|f\nab\nabcdef"); + s.extend_by(Motion::Down); + assert_eq!(s.show(), "abcde^f\nab|\nabcdef"); + s.extend_by(Motion::Down); + assert_eq!(s.show(), "abcde^f\nab\nabcde|f"); + s.move_by(Motion::Up); + assert_eq!(s.show(), "abcdef\nab|\nabcdef"); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abcdef\nab\nabcde|f"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn any_other_motion_drops_the_goal() { + let mut s = Session::new("abcde|f\nab\nabcdef"); + s.move_by(Motion::Down); + s.move_by(Motion::Left); + assert_eq!(s.show(), "abcdef\na|b\nabcdef"); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abcdef\nab\na|bcdef"); + + let mut s = Session::new("abcde|f\nab\nabcdef"); + s.move_by(Motion::Down); + s.extend_by(Motion::LineEnd); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abcdef\nab\nab|cdef"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn editing_and_set_selections_drop_the_goal() { + let mut s = Session::new("abcde|f\nab\nabcdef"); + s.move_by(Motion::Down); + s.editor + .insert(&mut s.set, "x") + .expect("buffer is in the set"); + assert_eq!(s.show(), "abcdef\nabx|\nabcdef"); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abcdef\nabx\nabc|def"); + + let mut s = Session::new("abcde|f\nab\nabcdef"); + s.move_by(Motion::Down); + s.editor + .set_selections(Selections::single(Selection::cursor(8))); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abcdef\nab\na|bcdef"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn undo_drops_the_goal() { + let mut s = Session::new("abcde|f\nab\nabcdef"); + s.editor + .insert(&mut s.set, "x") + .expect("buffer is in the set"); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abcdexf\nab|\nabcdef"); + s.editor.undo(&mut s.set).expect("buffer is in the set"); + assert_eq!(s.show(), "abcde|f\nab\nabcdef"); + s.move_by(Motion::Down); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abcdef\nab\nabcde|f"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn each_cursor_keeps_its_own_goal() { + let mut s = Session::new("a|bcde|f\nab\nabcdef"); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abcdef\na|b|\nabcdef"); + s.move_by(Motion::Down); + assert_eq!(s.show(), "abcdef\nab\na|bcde|f"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn cursors_that_meet_merge_and_keep_the_primary() { + let mut s = Session::with_primary("a|b|c\nx", 1); + assert!(s.move_by(Motion::Up)); + assert_eq!(s.show(), "|abc\nx"); + assert_eq!(s.editor.selections().as_slice().len(), 1); + + let mut s = Session::with_primary("a|b\nc|d\ne|f", 2); + s.move_by(Motion::TextEnd); + assert_eq!(s.show(), "ab\ncd\nef|"); + let mut s = Session::with_primary("a|b\nc|d\ne|f", 1); + s.move_by(Motion::Right); + assert_eq!(s.show(), "ab|\ncd|\nef|"); + assert_eq!(s.editor.selections().primary_index(), 1); +} + +// Words. + +#[test] +#[ignore = "T-107: not implemented yet"] +fn word_right_stops_at_the_end_of_each_run() { + table( + moved, + Motion::WordRight, + &[ + ("|foo.bar baz\nqux", "foo|.bar baz\nqux"), + ("foo|.bar baz\nqux", "foo.|bar baz\nqux"), + ("foo.|bar baz\nqux", "foo.bar| baz\nqux"), + ("foo.bar| baz\nqux", "foo.bar baz|\nqux"), + ("foo.bar baz|\nqux", "foo.bar baz\n|qux"), + ("foo.bar baz\n|qux", "foo.bar baz\nqux|"), + ("qux|", "qux|"), + ("a| \t \nb", "a \t |\nb"), + ("a| ", "a |"), + ("a|\r\nb", "a\r\n|b"), + ("a|\rb", "a\r|b"), + ("|__init__()", "__init__|()"), + ("|café 中文", "café| 中文"), + ("café| 中文", "café 中文|"), + ("|😀😀a", "😀😀|a"), + ("|(a)", "(|a)"), + ], + ); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn word_left_stops_at_the_start_of_each_run() { + table( + moved, + Motion::WordLeft, + &[ + ("foo\nfoo.bar baz|", "foo\nfoo.bar |baz"), + ("foo\nfoo.bar |baz", "foo\nfoo.|bar baz"), + ("foo\nfoo.|bar baz", "foo\nfoo|.bar baz"), + ("foo\nfoo|.bar baz", "foo\n|foo.bar baz"), + ("foo\n|foo.bar baz", "foo|\nfoo.bar baz"), + ("foo|\nfoo.bar baz", "|foo\nfoo.bar baz"), + ("|foo", "|foo"), + ("a\n \t |b", "a\n| \t b"), + (" |b", "| b"), + ("a\r\n|b", "a|\r\nb"), + ("a\r|b", "a|\rb"), + ("x.__init__|", "x.|__init__"), + ("café 中文|", "café |中文"), + ("a😀😀|", "a|😀😀"), + ], + ); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn extending_by_words_selects_them() { + assert_eq!(extended(Motion::WordRight, "|foo bar"), "^foo| bar"); + assert_eq!(extended(Motion::WordLeft, "foo bar|"), "foo |bar^"); + let mut s = Session::new("|foo bar"); + s.extend_by(Motion::WordRight); + s.extend_by(Motion::WordRight); + assert_eq!(s.show(), "^foo bar|"); +} + +// Lines and the whole text. + +#[test] +#[ignore = "T-107: not implemented yet"] +fn line_start_goes_to_the_first_non_blank_then_the_line_start() { + let mut s = Session::new("x\n foo|bar"); + s.move_by(Motion::LineStart); + assert_eq!(s.show(), "x\n |foobar"); + s.move_by(Motion::LineStart); + assert_eq!(s.show(), "x\n| foobar"); + s.move_by(Motion::LineStart); + assert_eq!(s.show(), "x\n |foobar"); + table( + moved, + Motion::LineStart, + &[ + (" | foo", " |foo"), + ("a\r\n\tb|c", "a\r\n\t|bc"), + ("foo|", "|foo"), + (" |", "| "), + ("| ", " |"), + ("a\n|\nb", "a\n|\nb"), + ("a\r\r\n|x", "a\r\r\n|x"), + ], + ); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn line_end_goes_before_the_line_break() { + table( + moved, + Motion::LineEnd, + &[ + ("a|bc\r\nd", "abc|\r\nd"), + ("a|bc\nd", "abc|\nd"), + ("a|b\rc", "ab|\rc"), + ("a\nb|c", "a\nbc|"), + ("abc|\nd", "abc|\nd"), + ], + ); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn text_start_and_end() { + assert_eq!(moved(Motion::TextStart, "ab\nc|d"), "|ab\ncd"); + assert_eq!(moved(Motion::TextEnd, "a|b\nc|d"), "ab\ncd|"); + assert_eq!(extended(Motion::TextStart, "ab\nc|d"), "|ab\nc^d"); + assert_eq!(extended(Motion::TextEnd, "a|b\r\ncd"), "a^b\r\ncd|"); + assert_eq!(moved(Motion::TextEnd, "|"), "|"); +} + +// Deleting by a motion. + +#[test] +#[ignore = "T-107: not implemented yet"] +fn delete_to_deletes_from_each_cursor_to_where_the_motion_goes() { + table( + deleted, + Motion::WordLeft, + &[ + ("foo bar|", "foo |"), + ("foo |bar", "|bar"), + ("a\r\n|b", "a|b"), + ("|abc", "|abc"), + ], + ); + table( + deleted, + Motion::WordRight, + &[ + ("|foo bar", "| bar"), + ("foo| bar", "foo|"), + ("a|\nb", "a|b"), + ], + ); + table( + deleted, + Motion::LineStart, + &[ + (" foo|bar", " |bar"), + (" |bar", "|bar"), + ("a\n|b", "a\n|b"), + ], + ); + table( + deleted, + Motion::LineEnd, + &[ + ("fo|o\nbar", "fo|\nbar"), + ("foo|\nbar", "foo|\nbar"), + ("a|b\r\nc", "a|\r\nc"), + ], + ); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn delete_to_deletes_a_selection_instead() { + assert_eq!(deleted(Motion::WordLeft, "a^bc|d"), "a|d"); + assert_eq!(deleted(Motion::LineEnd, "a|bc^d"), "a|d"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn delete_to_with_nothing_to_delete_applies_nothing() { + let mut s = Session::new("|abc"); + assert!(!s.delete_to(Motion::WordLeft)); + assert!(!s.delete_to(Motion::LineStart)); + assert_eq!(s.show(), "|abc"); + assert_eq!(s.editor.undo(&mut s.set), Ok(None)); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn delete_to_at_every_cursor_is_one_undo_step() { + let mut s = Session::new("foo bar|\nbaz qux|"); + assert!(s.delete_to(Motion::WordLeft)); + assert_eq!(s.show(), "foo |\nbaz |"); + s.editor.undo(&mut s.set).expect("buffer is in the set"); + assert_eq!(s.show(), "foo bar|\nbaz qux|"); + s.editor.redo(&mut s.set).expect("buffer is in the set"); + assert_eq!(s.show(), "foo |\nbaz |"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn delete_to_joins_overlapping_ranges() { + assert_eq!(deleted(Motion::WordLeft, "ab|c|"), "|"); + assert_eq!(deleted(Motion::LineStart, "x\nab|c|d"), "x\n|d"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn delete_to_keeps_cursors_that_delete_nothing() { + assert_eq!(deleted(Motion::WordLeft, "|ab cd|"), "|ab |"); + assert_eq!(deleted(Motion::LineEnd, "ab|\ncd|\nef|gh"), "ab|\ncd|\nef|"); +} + +// Selecting all and collapsing. + +#[test] +#[ignore = "T-107: not implemented yet"] +fn select_all_selects_the_whole_text() { + let mut s = Session::new("a|b\nc|d"); + assert!(s.editor.select_all(&s.set).expect("buffer is in the set")); + assert_eq!(s.show(), "^ab\ncd|"); + assert_eq!(s.editor.selections().as_slice().len(), 1); + assert!(!s.editor.select_all(&s.set).expect("buffer is in the set")); + + let mut s = Session::new("|"); + assert!(!s.editor.select_all(&s.set).expect("buffer is in the set")); + assert_eq!(s.show(), "|"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn collapse_keeps_the_primary_then_makes_it_a_cursor() { + let mut s = Session::with_primary("a|b^c|d^e|", 1); + assert!( + s.editor + .collapse_selections(&s.set) + .expect("buffer is in the set") + ); + assert_eq!(s.show(), "ab^c|de"); + assert!( + s.editor + .collapse_selections(&s.set) + .expect("buffer is in the set") + ); + assert_eq!(s.show(), "abc|de"); + assert!( + !s.editor + .collapse_selections(&s.set) + .expect("buffer is in the set") + ); + + assert_eq!( + { + let mut s = Session::new("a|bc^d"); + s.editor + .collapse_selections(&s.set) + .expect("buffer is in the set"); + s.show() + }, + "a|bcd" + ); +} + +// Clamping and errors. + +#[test] +#[ignore = "T-107: not implemented yet"] +fn stale_selections_are_clamped_first() { + let mut s = Session::new("|abc"); + s.editor + .set_selections(Selections::single(Selection::cursor(100))); + s.move_by(Motion::Left); + assert_eq!(s.show(), "ab|c"); + + // Offset 2 is inside é. + let mut s = Session::new("|aéb"); + s.editor + .set_selections(Selections::single(Selection::cursor(2))); + s.move_by(Motion::Right); + assert_eq!(s.show(), "aé|b"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn an_unknown_buffer_is_an_error_and_changes_nothing() { + let set = BufferSet::new(); + let mut other = BufferSet::new(); + let id: BufferId = other.add(Buffer::from("abc")); + let mut editor = Editor::new( + id, + FileFormat { + bom: false, + line_endings: LineEndings::count(""), + }, + ); + editor.set_selections(Selections::single(Selection::new(1, 2))); + let before = editor.selections().clone(); + let unknown = Err(EditorError::UnknownBuffer { buffer: id }); + assert_eq!(editor.move_cursors(&set, Motion::Left), unknown); + assert_eq!(editor.extend_selections(&set, Motion::Down), unknown); + assert_eq!(editor.select_all(&set), unknown); + assert_eq!(editor.collapse_selections(&set), unknown); + let mut empty = BufferSet::new(); + assert_eq!( + editor.delete_to(&mut empty, Motion::WordLeft), + Err(EditorError::UnknownBuffer { buffer: id }) + ); + assert_eq!(editor.selections(), &before); + assert_eq!(other.get(id).expect("in the set").len_bytes(), 3); +} diff --git a/crates/helm-editor/tests/vscode_keymap.rs b/crates/helm-editor/tests/vscode_keymap.rs new file mode 100644 index 0000000..c937a99 --- /dev/null +++ b/crates/helm-editor/tests/vscode_keymap.rs @@ -0,0 +1,483 @@ +//! T-107 acceptance tests: the cursor command ids, the registry with every +//! command, VS Code's keymap as data, and typing through it into an +//! `Editor` (ED-4). + +use helm_editor::{ + Action, CommandRegistry, Dispatcher, Editor, Invocation, KeyEvent, KeySequence, KeyState, + Keystroke, Mode, Motion, Resolution, Selection, Selections, Typing, cursor_commands, + editor_commands, vscode, +}; +use helm_text::{Buffer, BufferSet, FileFormat, LineEndings}; + +/// VS Code's bindings as the spec lists them: keys and command id. +const BINDINGS: [(&str, &str); 39] = [ + ("enter", "editor.newline"), + ("backspace", "editor.delete_backward"), + ("delete", "editor.delete_forward"), + ("cmd-z", "editor.undo"), + ("shift-cmd-z", "editor.redo"), + ("cmd-d", "editor.add_next_match"), + ("shift-cmd-l", "editor.select_all_matches"), + ("alt-cmd-up", "editor.add_cursor_above"), + ("alt-cmd-down", "editor.add_cursor_below"), + ("left", "cursor.left"), + ("right", "cursor.right"), + ("up", "cursor.up"), + ("down", "cursor.down"), + ("alt-left", "cursor.word_left"), + ("alt-right", "cursor.word_right"), + ("home", "cursor.line_start"), + ("cmd-left", "cursor.line_start"), + ("end", "cursor.line_end"), + ("cmd-right", "cursor.line_end"), + ("cmd-up", "cursor.text_start"), + ("cmd-down", "cursor.text_end"), + ("shift-left", "cursor.select_left"), + ("shift-right", "cursor.select_right"), + ("shift-up", "cursor.select_up"), + ("shift-down", "cursor.select_down"), + ("alt-shift-left", "cursor.select_word_left"), + ("alt-shift-right", "cursor.select_word_right"), + ("shift-home", "cursor.select_line_start"), + ("shift-cmd-left", "cursor.select_line_start"), + ("shift-end", "cursor.select_line_end"), + ("shift-cmd-right", "cursor.select_line_end"), + ("shift-cmd-up", "cursor.select_text_start"), + ("shift-cmd-down", "cursor.select_text_end"), + ("cmd-a", "cursor.select_all"), + ("escape", "cursor.collapse"), + ("alt-backspace", "cursor.delete_word_left"), + ("alt-delete", "cursor.delete_word_right"), + ("cmd-backspace", "cursor.delete_line_start"), + ("cmd-delete", "cursor.delete_line_end"), +]; + +/// An editor on `text` with `selections`, the first primary. +fn setup(text: &str, selections: &[Selection]) -> (BufferSet, Editor) { + let mut set = BufferSet::new(); + let id = set.add(Buffer::from(text)); + let format = FileFormat { + bom: false, + line_endings: LineEndings::count(text), + }; + let mut editor = Editor::new(id, format); + editor.set_selections(Selections::new(selections.to_vec(), 0).expect("non-empty")); + (set, editor) +} + +fn text(set: &BufferSet, editor: &Editor) -> String { + set.get(editor.buffer()) + .expect("buffer is in the set") + .to_string() +} + +/// One editor typing through VS Code's keymap and every command. +struct Session { + dispatcher: Dispatcher, + state: KeyState, + set: BufferSet, + editor: Editor, +} + +impl Session { + fn new(text: &str, selections: &[Selection]) -> Self { + let dispatcher = Dispatcher::new(CommandRegistry::with_all_commands(), vscode::keymap()); + let state = dispatcher.key_state(); + let (set, editor) = setup(text, selections); + Self { + dispatcher, + state, + set, + editor, + } + } + + fn event(&mut self, event: &KeyEvent) -> Resolution { + self.dispatcher + .dispatch(&mut self.state, event, &[], &mut self.editor, &mut self.set) + .expect("the command runs") + } + + /// Dispatches each keystroke of `keys`, in `KeySequence` notation. + fn keys(&mut self, keys: &str) { + for &keystroke in KeySequence::parse(keys).expect("valid keys").as_slice() { + self.event(&KeyEvent::new(keystroke)); + } + } + + /// Dispatches one key event per char of `text`. + fn type_text(&mut self, text: &str) { + for c in text.chars() { + let keystroke = if c == ' ' { + Keystroke::parse("space").unwrap() + } else { + Keystroke::parse(&c.to_string()).unwrap() + }; + self.event(&KeyEvent::new(keystroke)); + } + } + + fn text(&self) -> String { + text(&self.set, &self.editor) + } + + fn selections(&self) -> &[Selection] { + self.editor.selections().as_slice() + } +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn cursor_command_ids_are_the_documented_ones() { + assert_eq!( + cursor_commands::ALL, + [ + "cursor.collapse", + "cursor.delete_line_end", + "cursor.delete_line_start", + "cursor.delete_word_left", + "cursor.delete_word_right", + "cursor.down", + "cursor.left", + "cursor.line_end", + "cursor.line_start", + "cursor.right", + "cursor.select_all", + "cursor.select_down", + "cursor.select_left", + "cursor.select_line_end", + "cursor.select_line_start", + "cursor.select_right", + "cursor.select_text_end", + "cursor.select_text_start", + "cursor.select_up", + "cursor.select_word_left", + "cursor.select_word_right", + "cursor.text_end", + "cursor.text_start", + "cursor.up", + "cursor.word_left", + "cursor.word_right", + ] + ); + // Calls into the registry so the test runs against the implementation. + assert!( + CommandRegistry::with_all_commands() + .get("cursor.left") + .is_some() + ); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn with_all_commands_registers_both_lists_sorted_with_titles() { + let registry = CommandRegistry::with_all_commands(); + let ids: Vec<&str> = registry.commands().map(|c| c.id.as_str()).collect(); + let mut expected: Vec<&str> = editor_commands::ALL + .iter() + .chain(cursor_commands::ALL.iter()) + .copied() + .collect(); + expected.sort_unstable(); + assert_eq!(ids, expected); + assert_eq!(ids.len(), 36); + for id in expected { + let command = registry.get(id).expect("registered"); + assert!(!command.title.is_empty(), "{id} has a title"); + } + // `with_editor_commands` is unchanged. + let editor_registry = CommandRegistry::with_editor_commands(); + let editor_only: Vec<&str> = editor_registry.commands().map(|c| c.id.as_str()).collect(); + assert_eq!(editor_only, editor_commands::ALL); +} + +/// Runs `id` through a registry with every command on a fresh editor, and +/// `direct` on another fresh editor, and checks both end the same. +fn same_as_direct(id: &str, direct: fn(&mut Editor, &mut BufferSet)) { + let text = "foo.bar baz\r\n qux é中😀\nend"; + let selections = [ + Selection::cursor(5), + Selection::new(16, 19), + Selection::cursor(27), + ]; + let registry = CommandRegistry::with_all_commands(); + let (mut set, mut editor) = setup(text, &selections); + registry + .run(&Invocation::new(id), &mut editor, &mut set) + .expect("the command runs"); + let (mut direct_set, mut direct_editor) = setup(text, &selections); + direct(&mut direct_editor, &mut direct_set); + assert_eq!( + text_of(&set, &editor), + text_of(&direct_set, &direct_editor), + "{id} text" + ); + assert_eq!( + editor.selections(), + direct_editor.selections(), + "{id} selections" + ); +} + +fn text_of(set: &BufferSet, editor: &Editor) -> String { + text(set, editor) +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn each_cursor_command_runs_its_editor_method() { + let motions = [ + ("left", Motion::Left), + ("right", Motion::Right), + ("up", Motion::Up), + ("down", Motion::Down), + ("word_left", Motion::WordLeft), + ("word_right", Motion::WordRight), + ("line_start", Motion::LineStart), + ("line_end", Motion::LineEnd), + ("text_start", Motion::TextStart), + ("text_end", Motion::TextEnd), + ]; + // Function pointers can't capture the motion, so each case names it. + macro_rules! check { + ($prefix:literal, $name:literal, $method:ident, $motion:expr) => { + same_as_direct(concat!("cursor.", $prefix, $name), |e, s| { + e.$method(s, $motion).expect("buffer is in the set"); + }) + }; + } + check!("", "left", move_cursors, Motion::Left); + check!("", "right", move_cursors, Motion::Right); + check!("", "up", move_cursors, Motion::Up); + check!("", "down", move_cursors, Motion::Down); + check!("", "word_left", move_cursors, Motion::WordLeft); + check!("", "word_right", move_cursors, Motion::WordRight); + check!("", "line_start", move_cursors, Motion::LineStart); + check!("", "line_end", move_cursors, Motion::LineEnd); + check!("", "text_start", move_cursors, Motion::TextStart); + check!("", "text_end", move_cursors, Motion::TextEnd); + check!("select_", "left", extend_selections, Motion::Left); + check!("select_", "right", extend_selections, Motion::Right); + check!("select_", "up", extend_selections, Motion::Up); + check!("select_", "down", extend_selections, Motion::Down); + check!("select_", "word_left", extend_selections, Motion::WordLeft); + check!( + "select_", + "word_right", + extend_selections, + Motion::WordRight + ); + check!( + "select_", + "line_start", + extend_selections, + Motion::LineStart + ); + check!("select_", "line_end", extend_selections, Motion::LineEnd); + check!( + "select_", + "text_start", + extend_selections, + Motion::TextStart + ); + check!("select_", "text_end", extend_selections, Motion::TextEnd); + check!("delete_", "word_left", delete_to, Motion::WordLeft); + check!("delete_", "word_right", delete_to, Motion::WordRight); + check!("delete_", "line_start", delete_to, Motion::LineStart); + check!("delete_", "line_end", delete_to, Motion::LineEnd); + same_as_direct(cursor_commands::SELECT_ALL, |e, s| { + e.select_all(s).expect("buffer is in the set"); + }); + same_as_direct(cursor_commands::COLLAPSE, |e, s| { + e.collapse_selections(s).expect("buffer is in the set"); + }); + // Every motion name above is one of the cursor command ids. + for (name, _) in motions { + assert!(cursor_commands::ALL.contains(&format!("cursor.{name}").as_str())); + } +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn the_keymap_is_one_typing_mode() { + let keymap = vscode::keymap(); + assert_eq!(vscode::NAME, "vscode"); + assert_eq!(vscode::MODE, "default"); + assert_eq!(keymap.name(), "vscode"); + assert_eq!(keymap.modes(), &[Mode::new("default", Typing::Insert)]); + assert_eq!(keymap.initial_mode().as_str(), "default"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn the_keymap_holds_exactly_the_documented_bindings() { + let keymap = vscode::keymap(); + let mut actual: Vec<(String, String)> = keymap + .bindings() + .iter() + .map(|binding| { + assert_eq!(binding.mode, None, "{}", binding.keys); + assert_eq!(binding.context, None, "{}", binding.keys); + assert_eq!(binding.then_mode, None, "{}", binding.keys); + let Some(Action::Run(command)) = &binding.action else { + panic!("{} should run a command", binding.keys); + }; + (binding.keys.to_string(), command.as_str().to_owned()) + }) + .collect(); + let mut expected: Vec<(String, String)> = BINDINGS + .iter() + .map(|&(keys, command)| { + let keys = KeySequence::parse(keys).expect("valid keys").to_string(); + (keys, command.to_owned()) + }) + .collect(); + actual.sort(); + expected.sort(); + assert_eq!(actual, expected); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn every_bound_command_is_registered() { + let registry = CommandRegistry::with_all_commands(); + for binding in vscode::keymap().bindings() { + let Some(Action::Run(command)) = &binding.action else { + panic!("{} should run a command", binding.keys); + }; + assert!( + registry.get(command.as_str()).is_some(), + "{} runs {command}, which isn't registered", + binding.keys + ); + } +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn each_binding_resolves_to_its_command() { + let dispatcher = Dispatcher::new(CommandRegistry::with_all_commands(), vscode::keymap()); + for (keys, command) in BINDINGS { + let mut state = dispatcher.key_state(); + let event = KeyEvent::parse(keys).expect("valid key"); + assert_eq!( + dispatcher.resolve(&mut state, &event, &[]), + Resolution::Run(Invocation::new(command)), + "{keys}" + ); + assert!(state.pending().is_empty()); + } +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn typing_and_arrows() { + let mut s = Session::new("", &[Selection::cursor(0)]); + s.type_text("abc"); + s.keys("left left"); + s.type_text("X"); + assert_eq!(s.text(), "aXbc"); + s.keys("end"); + s.type_text("!"); + assert_eq!(s.text(), "aXbc!"); + s.keys("cmd-left"); + s.type_text(">"); + assert_eq!(s.text(), ">aXbc!"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn up_and_down_keep_the_goal_through_keys() { + let mut s = Session::new("abcdef\nab\nabcdef", &[Selection::cursor(5)]); + s.keys("down down"); + s.type_text("X"); + assert_eq!(s.text(), "abcdef\nab\nabcdeXf"); + s.keys("cmd-up"); + s.type_text("^"); + assert_eq!(s.text(), "^abcdef\nab\nabcdeXf"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn shift_selects_and_typing_replaces_the_selection() { + let mut s = Session::new("say hello", &[Selection::cursor(9)]); + s.keys("alt-shift-left"); + assert_eq!(s.selections(), &[Selection::new(9, 4)]); + s.type_text("bye"); + assert_eq!(s.text(), "say bye"); + s.keys("shift-home"); + s.keys("backspace"); + assert_eq!(s.text(), ""); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn select_all_then_delete_and_undo() { + let mut s = Session::new("one\r\ntwo", &[Selection::cursor(2)]); + s.keys("cmd-a"); + assert_eq!(s.selections(), &[Selection::new(0, 8)]); + s.keys("delete"); + assert_eq!(s.text(), ""); + s.keys("cmd-z"); + assert_eq!(s.text(), "one\r\ntwo"); + assert_eq!(s.selections(), &[Selection::new(0, 8)]); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn word_and_line_deletes() { + let mut s = Session::new("let foo = bar;", &[Selection::cursor(13)]); + s.keys("alt-backspace"); + assert_eq!(s.text(), "let foo = ;"); + s.keys("cmd-backspace"); + assert_eq!(s.text(), ";"); + s.keys("cmd-z cmd-z"); + assert_eq!(s.text(), "let foo = bar;"); + s.keys("cmd-left alt-delete"); + assert_eq!(s.text(), " foo = bar;"); + s.keys("cmd-delete"); + assert_eq!(s.text(), ""); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn escape_drops_extra_cursors_then_the_selection() { + let mut s = Session::new("ab ab ab", &[Selection::cursor(1)]); + s.keys("cmd-d cmd-d cmd-d"); + assert_eq!(s.selections().len(), 3); + s.type_text("X"); + assert_eq!(s.text(), "X X X"); + s.keys("shift-left escape"); + assert_eq!(s.selections().len(), 1); + assert!(!s.selections()[0].is_empty()); + s.keys("escape"); + assert!(s.selections()[0].is_empty()); + s.type_text("Y"); + assert_eq!(s.text(), "X X YX"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn multi_cursor_movement_through_keys() { + let mut s = Session::new("a1\nb2\nc3", &[Selection::cursor(0)]); + s.keys("alt-cmd-down alt-cmd-down"); + assert_eq!(s.selections().len(), 3); + s.keys("end"); + s.type_text(";"); + assert_eq!(s.text(), "a1;\nb2;\nc3;"); + s.keys("alt-left"); + s.type_text("_"); + assert_eq!(s.text(), "a1_;\nb2_;\nc3_;"); +} + +#[test] +#[ignore = "T-107: not implemented yet"] +fn option_text_still_types() { + let mut s = Session::new("", &[Selection::cursor(0)]); + let alt_e = Keystroke::parse("alt-e").expect("valid key"); + assert_eq!( + s.event(&KeyEvent::with_text(alt_e, "´")), + Resolution::Run(Invocation::insert("´")) + ); + assert_eq!(s.text(), "´"); +} diff --git a/tasks/107-vscode-keymap.md b/tasks/107-vscode-keymap.md new file mode 100644 index 0000000..c15c35d --- /dev/null +++ b/tasks/107-vscode-keymap.md @@ -0,0 +1,153 @@ +# T-107: VS Code keymap, with cursor movement and selection + +After: T-106. This task waits until that one is merged. + +## Goal + +`helm-editor` gets VS Code's default macOS keymap as data, and the commands it needs that don't exist yet: moving cursors by character, line, word, line start and end and the whole text, extending selections the same way with Shift, a sticky goal column for Up and Down, select all, Escape, and deleting by word or to either end of the line. With this, every editing feature built so far is reachable from the keyboard the way a VS Code user expects (ED-4). + +PRD: ED-4 + +## Crates and public interface + +Crates: `helm-editor` (the implementation) and `helm-bench` (the new benchmark file and its `[[bench]]` entry only). `helm-bench` already depends on `helm-editor`. + +The interface is already in place, with doc comments: a new `motion.rs`, `cursor_commands.rs` and `vscode.rs`, five new `Editor` methods at the end of the first `impl Editor` block in `editor.rs`, and `CommandRegistry::with_all_commands` in `command.rs`. Each new function has a `todo!()` body. Fill them in. Don't change a signature, a type or a doc comment's contract. If one looks wrong, say so in the PR description. Private fields and helpers, including `cursor_commands::register_all`, are placeholders, so replace or add them freely. Delete the `#[expect(unused_variables, ...)]` on each stub and the `#![expect(dead_code, ...)]` at the top of `cursor_commands.rs` once they are filled, or clippy with `-D warnings` fails. + +Don't touch `crates/helm-text`. Don't change the behaviour of any existing `Editor` method, except that every `&mut self` method now drops the goal columns, as the `Editor` docs describe. Don't change `editor_commands.rs`: T-106's tests pin its list of ten ids. + +```rust +// lib.rs +pub mod cursor_commands; +pub mod vscode; +pub use motion::Motion; + +// motion.rs +pub enum Motion { Left, Right, Up, Down, WordLeft, WordRight, LineStart, LineEnd, TextStart, TextEnd } + +// editor.rs +impl Editor { + pub fn move_cursors(&mut self, set: &BufferSet, motion: Motion) -> Result; + pub fn extend_selections(&mut self, set: &BufferSet, motion: Motion) -> Result; + pub fn delete_to(&mut self, set: &mut BufferSet, motion: Motion) + -> Result, EditorError>; + pub fn select_all(&mut self, set: &BufferSet) -> Result; + pub fn collapse_selections(&mut self, set: &BufferSet) -> Result; +} + +// command.rs +impl CommandRegistry { + pub fn with_all_commands() -> Self; // editor_commands::ALL and cursor_commands::ALL +} + +// cursor_commands.rs +pub const LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, LINE_START, LINE_END, + TEXT_START, TEXT_END: &str; // "cursor.left", ... +pub const SELECT_LEFT, ..., SELECT_TEXT_END: &str; // "cursor.select_left", ... +pub const SELECT_ALL, COLLAPSE: &str; // "cursor.select_all", "cursor.collapse" +pub const DELETE_WORD_LEFT, DELETE_WORD_RIGHT, + DELETE_LINE_START, DELETE_LINE_END: &str; // "cursor.delete_word_left", ... +pub const ALL: [&str; 26]; // sorted + +// vscode.rs +pub const NAME: &str = "vscode"; +pub const MODE: &str = "default"; +pub fn keymap() -> Keymap; +``` + +### Rules the implementation must follow + +The doc comments on `Motion`, the new `Editor` methods and `vscode::keymap` hold the full rules. In short: + +- **Every motion lands on a char boundary, never inside a CRLF.** CRLF counts as one character for Left, Right and the word motions. A stale head between the CR and the LF moves out of it. +- **Left and Right collapse a selection** to its start or end when moving, as VS Code does. Extending moves the head like any other motion. +- **Up and Down** go to the same column, in chars, on the line above or below, capped before that line's break. On the first line Up goes to offset 0, and on the last line Down goes to the end of the text. +- **Goal columns.** The first Up or Down sets each selection's goal to its head's column. Later Up and Down moves, through `move_cursors` or `extend_selections`, aim for the goal and keep it, including at the first and last lines. Any other `&mut self` method on `Editor` drops every goal. Columns are chars, not display columns, the same unit `add_cursor_above` uses. +- **Words.** Chars are word (`is_alphanumeric` or `_`), blank (whitespace that isn't a line break) or punctuation. WordRight crosses a line break if one starts at the head, and otherwise skips blanks, stops at a line break or the end, then skips one run of word or punctuation chars. WordLeft is the mirror. +- **LineStart** is VS Code's Home: the line's first non-blank, or the line's start if the head is already there. **LineEnd** is the end of the line's text, before its break. +- **`delete_to`** deletes from each cursor to where the motion takes it, or a non-empty selection's range, as one transaction, joined and placed like `delete_backward`. +- **`select_all`** leaves one forward selection of the whole text. **`collapse_selections`** is Escape: it keeps only the primary, and with one selection left it makes it a cursor at its head. +- **Commands.** `with_all_commands` registers the ten `editor_commands` and the 26 `cursor_commands`, each with a non-empty title. Each cursor command calls the `Editor` method and motion its name gives. `with_editor_commands` doesn't change. +- **The keymap.** One mode, `default`, with `Typing::Insert`, and exactly the 39 bindings in the table on `vscode::keymap`, each a plain `Action::Run` with no mode, context or mode switch. Every command it binds is in `with_all_commands`. +- **No whole-text copies.** Motions read only the text near each head, and the lines Up and Down cross. Don't call `to_string` on the buffer. + +### The bindings + +macOS bindings, written from VS Code's default keyboard shortcuts as I know them. I didn't check them against VS Code's published keybindings file, so check any that look off: + +| Keys | Command | +| --- | --- | +| `enter`, `backspace`, `delete` | `editor.newline`, `editor.delete_backward`, `editor.delete_forward` | +| `cmd-z`, `shift-cmd-z` | `editor.undo`, `editor.redo` | +| `cmd-d`, `shift-cmd-l` | `editor.add_next_match`, `editor.select_all_matches` | +| `alt-cmd-up`, `alt-cmd-down` | `editor.add_cursor_above`, `editor.add_cursor_below` | +| `left`, `right`, `up`, `down` | `cursor.left`, `cursor.right`, `cursor.up`, `cursor.down` | +| `alt-left`, `alt-right` | `cursor.word_left`, `cursor.word_right` | +| `home`, `cmd-left` | `cursor.line_start` | +| `end`, `cmd-right` | `cursor.line_end` | +| `cmd-up`, `cmd-down` | `cursor.text_start`, `cursor.text_end` | +| `shift-` plus each of the twelve keys in the five rows above | the matching `cursor.select_` command | +| `cmd-a`, `escape` | `cursor.select_all`, `cursor.collapse` | +| `alt-backspace`, `alt-delete` | `cursor.delete_word_left`, `cursor.delete_word_right` | +| `cmd-backspace`, `cmd-delete` | `cursor.delete_line_start`, `cursor.delete_line_end` | + +T-106 asked T-107 to list any binding where folding Shift into chars matters (VS Code's `cmd+shift+[` becomes `cmd-{`). None of these does: every shifted binding is on a named key or a letter. + +### Choices I made, and the ones I rejected + +- **One `Motion` enum, not one `Editor` method per direction.** Move, extend and delete are three methods that take a motion, so 10 motions give 26 commands without 26 methods. T-108's Vim motions and operators can reuse the same enum and `delete_to`. Adding Vim's motions (`w` versus `e`, `0` versus `^`) means adding variants, not methods. +- **Cursor commands live in a new module with a `cursor.` prefix.** T-106's tests pin `editor_commands::ALL` to its ten ids and `with_editor_commands` to exactly those. Adding the new commands there would mean editing a protected test. A second module and `with_all_commands` leave T-106's grader alone. The cost is two registries to choose from. `helm-ui` should use `with_all_commands`. +- **Goal columns live on the `Editor`, not on `Selection`.** `Selection` is plain `Copy` data compared with `==` all over T-105's tests. A goal field would change what equality means there. The editor drops goals on any other `&mut self` call, which is simpler than tracking which calls move a head. +- **Up on the first line goes to offset 0 and keeps the goal.** So Up then Down returns to the column you started in. +- **Word boundaries stop at line breaks.** WordRight at the end of a line moves just past the break, then the next press goes to the end of the first word. As far as I know, VS Code's `cursorWordEndRight` instead jumps to the end of the next word in one press. I didn't check that against VS Code. Stopping at the break is simpler to state. Only a few rows in `word_right_stops_at_the_end_of_each_run` and `word_left_stops_at_the_start_of_each_run` pin this. +- **`cmd-backspace` deletes to `LineStart`, smart Home included.** From the first non-blank it deletes the indentation, and from after it it deletes back to the first non-blank. VS Code's `deleteAllLeft` may differ on indentation. I didn't check it. +- **`delete_to(LineEnd)` at the end of a line deletes nothing.** It doesn't join the next line. Use `delete` for that. +- **Escape is one command, not two context-scoped bindings.** VS Code binds Escape twice, under `editorHasMultipleSelections` and `editorHasSelection`. `collapse_selections` does both in one, so `helm-ui` doesn't have to compute those contexts. + +### Dependencies + +None. No new dependency, no change to any `Cargo.toml` except the `[[bench]]` entry, and `Cargo.lock` is unchanged. + +## Acceptance tests + +Three new files, committed before the implementation. 57 tests: + +| File | What it proves | +| --- | --- | +| `crates/helm-editor/tests/motions.rs` (36) | Every motion as tables of before and after texts with markers: Left and Right over ASCII, é, 中, 😀, LF, CRLF and lone CR, out of a CRLF, collapsing a selection; extending, back to a cursor; whether a call changed anything. Up and Down: columns in chars, capped before the line break, first and last lines, CRLF. Goal columns through a short line, at the first and last line, shared between move and extend, kept per cursor, and dropped by another motion, typing, `set_selections` and undo. Cursors merging with the primary kept. WordRight and WordLeft over words, blanks, punctuation, `_`, é, 中, 😀 and each line break. LineStart's toggle, indentation, blank and empty lines. LineEnd before each line break. TextStart and TextEnd. `delete_to` for each deleting motion, over a selection, with nothing to delete, at two cursors as one undo step, joining overlaps, and keeping cursors that delete nothing. `select_all` and `collapse_selections`. Clamping stale offsets, and `UnknownBuffer` changing nothing. | +| `crates/helm-editor/tests/vscode_keymap.rs` (15) | `cursor_commands::ALL` is the 26 documented ids. `with_all_commands` holds both lists, 36 ids, sorted, with titles, and `with_editor_commands` is unchanged. Each of the 26 cursor commands, run through the registry, does what its `Editor` call does. The keymap's name and single mode, and exactly the 39 documented bindings, each a plain run. Every bound command is registered. Each binding resolves to its command. Sessions through the dispatcher: typing and arrows, Home and `cmd-left`, goal columns by key, Shift+Option+Left then typing over the word, `cmd-a` then Delete then `cmd-z`, word and line deletes and their undo, `cmd-d` three times then Escape twice, three cursors moving with End and Option+Left, and Option text still typing. | +| `crates/helm-editor/tests/motion_properties.rs` (6) | proptest, 256 cases per property, over random text of words, blanks, punctuation, each line break, each char width and any char. (1) Every motion from any valid position lands on a char boundary outside any CRLF. (2) Right then Left, and Left then Right, return to the start away from the ends. (3) WordRight and WordLeft always move until the end they head for. (4) Up to 12 cursors moved at once equal each cursor moved alone, in normal form, with the same primary. (5) Extending a cursor keeps its anchor and puts the head where moving would. (6) `delete_to` from one cursor matches a `String` oracle, leaves the cursor at the range's start, and undoes in one step. | + +Each test carries `#[ignore = "T-107: not implemented yet"]` so CI stays green until the implementation lands. Against the stubs, `cargo test -p helm-editor --no-fail-fast -- --ignored` fails all 57 (36 + 15 + 6). + +I checked the tests against a throwaway implementation before committing them. All 57 pass on it, and the properties also pass at 20,000 cases each. That implementation isn't in this PR. + +**The rule for the implementer: the only allowed change to these three files is deleting the `#[ignore = "T-107: not implemented yet"]` lines.** Nothing else in them changes. That means no edited assertion, no new `#[ignore]`, and no change to a case count. Khakha checks the diff of these files before adding the `approved-protected` label, which the protected-paths check needs for any edit under `crates/*/tests/`. If a test looks wrong, say so in the PR description instead of changing it. + +## Performance budget + +NF-1 gives a keypress 5 ms from input to pixel, and a motion at many cursors is on that path. Add a new file, `crates/helm-bench/benches/cursor.rs`, with a `[[bench]] name = "cursor"`, `harness = false` entry in `crates/helm-bench/Cargo.toml`. It holds two criterion benchmarks in a group named `cursor`, on the same 10 MiB text `benches/editor.rs` builds: + +| Benchmark id | What one iteration does | +| --- | --- | +| `cursor/word_right_1000_cursors_10mb` | `move_cursors(Motion::WordRight)` at 1,000 cursors spread evenly across the text, then `move_cursors(Motion::WordLeft)`. | +| `cursor/down_up_1000_cursors_10mb` | `move_cursors(Motion::Down)` at 1,000 cursors spread evenly across the text, then `move_cursors(Motion::Up)`. | + +Set the cursors once, outside the timed code, each at the start of a line so every iteration starts and ends in the same place. Pass each result through `black_box`. + +Budget: the default 5% from `budgets.toml`. Add no per-benchmark override. The base commit has no `cursor` benchmarks, so `bench-compare` reports them as head-only in this task's PR and gates them from the next PR on. No absolute limit is gated, but both should be far under a millisecond. A motion that copies the 10 MiB text would show here, so don't. Say in the PR what the benchmarks measured. + +As in T-101 to T-106, the implementation PR adds the benchmark file. This spec PR doesn't. + +## Out of scope + +- Clipboard: cut, copy and paste (ED-1). It needs the macOS clipboard in `CommandContext`, and gets its own task. +- Tab and Shift+Tab, indent and outdent. They need indentation settings (tabs or spaces, width). Until then Tab is unbound and types nothing. +- Line commands: move line up or down, copy line, delete line, toggle comment, join lines. +- Page Up and Page Down, and moving by visual line in wrapped text. Both need the viewport, which is `helm-ui`'s (Phase 2). +- Display columns for tabs and wide characters. Goal columns are in chars until `helm-ui` can measure text. +- macOS Emacs-style keys (`ctrl-a`, `ctrl-e`, `ctrl-k` and the rest). +- Mouse clicks and drags: `helm-ui`. +- Vim's motions, operators and counts: T-108, which can reuse `Motion` and `delete_to`. +- Loading keymaps or a keybindings file from TOML, and the settings toggle (ST-1): later tasks. +- Commands outside the editor, such as save, open and the command palette: `helm-ui`.