From f2103cf85d95efa8da66c34bae09e960f56087ea Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 9 Sep 2026 21:52:27 -0700 Subject: [PATCH] feat(rust-lite): mirror the editor word and line keys Port `src/edit-keys.ts` to `rust/src/edit_keys.rs` so the native editor moves and kills by the same boundaries as the Bun runtime: Option/Alt and Ctrl arrows plus Alt+B/Alt+F move by word, Command/Super arrows and Ctrl+A/Ctrl+E move to the line edges, and Ctrl+W, Alt+Backspace, and Ctrl+U kill back to a word or line start. Word boundaries use JavaScript's `\s` class, cross a newline one step at a time, and treat a CJK run as one word. Add `screen.editor.word-line-moves` and `screen.editor.word-line-kills` to the parity harness, which send the raw byte sequence for every new key and type a marker after each move, and record the result in both parity documents. refs #38 --- docs/rust-lite-parity-proof.md | 70 +++++--- docs/rust-lite-parity.md | 8 +- rust/src/edit_keys.rs | 311 +++++++++++++++++++++++++++++++++ rust/src/editor.rs | 78 +++++++++ rust/src/lib.rs | 1 + scripts/parity-lite.py | 96 ++++++++++ 6 files changed, 542 insertions(+), 22 deletions(-) create mode 100644 rust/src/edit_keys.rs diff --git a/docs/rust-lite-parity-proof.md b/docs/rust-lite-parity-proof.md index ac3d874..08f523b 100644 --- a/docs/rust-lite-parity-proof.md +++ b/docs/rust-lite-parity-proof.md @@ -5,7 +5,7 @@ TypeScript Lite surface to the Rust call path at function granularity and names case that compares the result. The current local result is: ```text -Parity Lite: 510 observables compared, 94 screens diffed, zero divergences / 1 deliberate +Parity Lite: 622 observables compared, 145 screens diffed, zero divergences / 1 deliberate ``` Run it from the repository root with `bash scripts/parity-lite.sh`. The shell wrapper stages a fresh @@ -94,10 +94,10 @@ contract: success and blank input exit 0; a defined failure produces one stderr | Observable decision or effect | TypeScript call path | Rust call path | Mechanical evidence | |---|---|---|---| -| Manifest and pending selection | `lite/herdr-plugin.toml:46` → top-level `src/editor.ts:17`. `invocationContext` at `:20`; pending-file parse at `:34`; fallback `pendingAnnotationFromInvocation` at `src/types.ts:65`; parsed-file canonicalization at `src/types.ts:79`. | `lite-rs/herdr-plugin.toml:56` → dispatcher → `editor::run` at `rust/src/editor.rs:346`; `pending_from_env` at `:320`; invocation fallback and pending parsing at `rust/src/types.rs:107` and `:119`. | Missing/invalid process cases; `screen.editor.pending-file-save`; the other editor PTY cases use invocation fallback. | -| Terminal and screen | `render` at `src/editor.ts:77` uses `sanitizeTerminalText`, `wrapText`, `layoutComment`, and width helpers; alternate-screen setup is `:207`. | `EditorApp::draw` at `rust/src/editor.rs:67` calls the corresponding helpers at `rust/src/format.rs:7`/`:26`, `rust/src/layout.rs:14`, and `rust/src/width.rs:35`; `editor::run` owns the Ratatui terminal. | Initial frame and every frame in `screen.editor.*`; fixed 86×22 cells include wide Hangul. | -| Input and save | Key dispatch is `src/editor.ts:172`; vertical movement is `:52`; `save` is `:122`; successful save renders, waits 250 ms, and exits. | `EditorApp::handle_key` at `rust/src/editor.rs:168`; vertical movement at `:227`; `save` at `:253`; `run` at `:346` renders the saved state, waits 250 ms, and exits. | `screen.editor.edit-save`, empty-save, missing-state, Esc, and Ctrl+C cases. Store bytes are compared after save. | -| Cleanup and signals | `cleanup`/`exit` at `src/editor.ts:110`/`:117`; SIGTERM/SIGHUP handlers at `:160`. | `Termination::install`/`requested` at `rust/src/termination.rs:17`/`:33`; the polling loop at `rust/src/editor.rs:346` reaches `ratatui::restore`. | `screen.editor.sigterm` compares exit 0 and the restored terminal grid. | +| Manifest and pending selection | `lite/herdr-plugin.toml:46` → top-level `src/editor.ts:17`. `invocationContext` at `:20`; pending-file parse at `:34`; fallback `pendingAnnotationFromInvocation` at `src/types.ts:65`; parsed-file canonicalization at `src/types.ts:79`. | `lite-rs/herdr-plugin.toml:56` → dispatcher → `editor::run` at `rust/src/editor.rs:371`; `pending_from_env` at `:345`; invocation fallback and pending parsing at `rust/src/types.rs:107` and `:119`. | Missing/invalid process cases; `screen.editor.pending-file-save`; the other editor PTY cases use invocation fallback. | +| Terminal and screen | `render` at `src/editor.ts:77` uses `sanitizeTerminalText`, `wrapText`, `layoutComment`, and width helpers; alternate-screen setup is `:207`. | `EditorApp::draw` at `rust/src/editor.rs:68` calls the corresponding helpers at `rust/src/format.rs:7`/`:26`, `rust/src/layout.rs:14`, and `rust/src/width.rs:35`; `editor::run` owns the Ratatui terminal. | Initial frame and every frame in `screen.editor.*`; fixed 86×22 cells include wide Hangul. | +| Input and save | Key dispatch is `src/editor.ts:172`, with `resolveEditKey` consulted at `:181` before the single-character arm; vertical movement is `:52`; `save` is `:122`; successful save renders, waits 250 ms, and exits. | `EditorApp::handle_key` at `rust/src/editor.rs:169`, with `resolve_edit_key` consulted at `:181` and applied by `apply_edit_action` at `:228`; vertical movement at `:252`; `save` at `:278`; `run` at `:371` renders the saved state, waits 250 ms, and exits. | `screen.editor.edit-save`, empty-save, missing-state, Esc, and Ctrl+C cases. Store bytes are compared after save. | +| Cleanup and signals | `cleanup`/`exit` at `src/editor.ts:110`/`:117`; SIGTERM/SIGHUP handlers at `:160`. | `Termination::install`/`requested` at `rust/src/termination.rs:17`/`:33`; the polling loop at `rust/src/editor.rs:371` reaches `ratatui::restore`. | `screen.editor.sigterm` compares exit 0 and the restored terminal grid. | ### `manager` @@ -112,21 +112,44 @@ contract: success and blank input exit 0; a defined failure produces one stderr ## Every editor key All key paths clear the prior status before acting. TypeScript dispatch is `src/editor.ts:172`; -Rust dispatch is `EditorApp::handle_key` at `rust/src/editor.rs:168`; both render again after the +Rust dispatch is `EditorApp::handle_key` at `rust/src/editor.rs:169`; both render again after the transition. | Key | TypeScript → Rust call path | Required state/store/screen effect | Harness step | |---|---|---|---| -| Character input, including wide text | `src/editor.ts:199` uses `Array.from` and `splice` → `rust/src/editor.rs:210` calls `insert` at `:222`. | Insert Unicode scalar(s) at cursor, advance by character count, render using cell width. | `screen.editor.edit-save`: `chars`, `chars-second-line`. | -| Enter | `src/editor.ts:196` → Rust `KeyCode::Enter` at `rust/src/editor.rs:209` → `insert`. | Insert `\n`, move cursor, preserve explicit blank/line layout. | `enter`. | -| Backspace | `src/editor.ts:180` → `rust/src/editor.rs:182`. | If cursor > 0, remove the character before it and move left; otherwise no change. | `backspace`. | -| Delete | `src/editor.ts:182` → `rust/src/editor.rs:188`. | Remove the character at cursor if present; cursor stays. | `delete`. | -| Left / Right | `src/editor.ts:184`/`:186` → `rust/src/editor.rs:193`/`:194`. | Move one character, clamped to `[0, length]`. | `left`, `right`. | -| Up / Down | `moveCursorVertical` at `src/editor.ts:52` → `move_cursor_vertical` at `rust/src/editor.rs:227`. | Preserve terminal-cell column as closely as possible on the adjacent line; clamp first/last row and never split a wide glyph. | `up`, `down`. | -| Home / End | `src/editor.ts:192`/`:194` → `rust/src/editor.rs:197`/`:202`. | Move to start/end of the current logical line. | `home`, `end`. | -| Ctrl+S | `src/editor.ts:175` → `save` at `:122`; Rust control branch at `rust/src/editor.rs:177` → `save` at `:253`. | Blank comment: `Write a comment before saving.` and remain. Missing state: `Plugin state directory is unavailable.` and remain. Store error: display it and remain. Success: append exact JSONL, display `Saved.`, wait 250 ms, cleanly exit 0. | `screen.editor.edit-save`, `empty-save-escape`, `missing-state`. | -| Esc | `src/editor.ts:179` → `exit`/`cleanup`; Rust `rust/src/editor.rs:181` sets quit and `run` restores. | No store write; cursor shown, screen restored, exit 0. | `screen.editor.empty-save-escape`. | -| Ctrl+C | `src/editor.ts:174` → `exit`/`cleanup`; Rust `rust/src/editor.rs:173` sets quit. | Same cancellation effect as Esc. | `screen.editor.control-c`. | +| Character input, including wide text | `src/editor.ts:199` uses `Array.from` and `splice` → `rust/src/editor.rs:215` calls `insert` at `:247`. | Insert Unicode scalar(s) at cursor, advance by character count, render using cell width. | `screen.editor.edit-save`: `chars`, `chars-second-line`. | +| Enter | `src/editor.ts:196` → Rust `KeyCode::Enter` at `rust/src/editor.rs:214` → `insert`. | Insert `\n`, move cursor, preserve explicit blank/line layout. | `enter`. | +| Backspace | `src/editor.ts:180` → `rust/src/editor.rs:187`. | If cursor > 0, remove the character before it and move left; otherwise no change. | `backspace`. | +| Delete | `src/editor.ts:182` → `rust/src/editor.rs:193`. | Remove the character at cursor if present; cursor stays. | `delete`. | +| Left / Right | `src/editor.ts:184`/`:186` → `rust/src/editor.rs:198`/`:199`. | Move one character, clamped to `[0, length]`. | `left`, `right`. | +| Up / Down | `moveCursorVertical` at `src/editor.ts:52` → `move_cursor_vertical` at `rust/src/editor.rs:252`. | Preserve terminal-cell column as closely as possible on the adjacent line; clamp first/last row and never split a wide glyph. | `up`, `down`. | +| Home / End | `src/editor.ts:192`/`:194` → `rust/src/editor.rs:202`/`:207`. | Move to start/end of the current logical line. | `home`, `end`. | +| Word left (Option/Alt+Left, Ctrl+Left, Alt+B) | `resolveEditKey` at `src/edit-keys.ts:53` → `wordStart` at `:16`, applied at `src/editor.ts:182`. | `resolve_edit_key` at `rust/src/edit_keys.rs:111` → `word_start` at `:56`, applied at `rust/src/editor.rs:230`. | Skip whitespace back to the previous word start; a newline is crossed by itself in one step; a CJK run is one word. | `screen.editor.word-line-moves`: `alt-left`, `alt-b`, `ctrl-left`, `alt-left-word`, `alt-left-previous-word`, `alt-left-line-first-word`, `alt-left-over-newline`. | +| Word right (Option/Alt+Right, Ctrl+Right, Alt+F) | `resolveEditKey` at `src/edit-keys.ts:53` → `wordEnd` at `:25`, applied at `src/editor.ts:184`. | `resolve_edit_key` at `rust/src/edit_keys.rs:111` → `word_end` at `:71`, applied at `rust/src/editor.rs:231`. | Skip whitespace forward to the next word end; a newline is crossed by itself in one step. | `screen.editor.word-line-moves`: `alt-f`, `alt-right`, `ctrl-right`, `alt-right-over-newline`, `alt-right-next-word`. | +| Line start (Command/Super+Left, Ctrl+A) | `resolveEditKey` at `src/edit-keys.ts:53` (xterm modifier 9 via the raw sequence) → `lineStart` at `:33`, applied at `src/editor.ts:186`. | `resolve_edit_key` at `rust/src/edit_keys.rs:111` (`KeyModifiers::SUPER`) → `line_start` at `:86`, applied at `rust/src/editor.rs:232`. | Move to the first character of the current logical line without crossing the newline. | `screen.editor.word-line-moves`: `super-left`, `ctrl-a`. | +| Line end (Command/Super+Right, Ctrl+E) | `resolveEditKey` at `src/edit-keys.ts:53` → `lineEnd` at `:39`, applied at `src/editor.ts:188`. | `resolve_edit_key` at `rust/src/edit_keys.rs:111` → `line_end` at `:95`, applied at `rust/src/editor.rs:233`. | Move to the end of the current logical line without crossing the newline. | `screen.editor.word-line-moves`: `super-right`, `ctrl-e`. | +| Delete word (Ctrl+W, Alt/Option+Backspace) | `src/editor.ts:190` splices `wordStart(comment, cursor)..cursor`. | `rust/src/editor.rs:234` drains `word_start(..)..cursor`. | Kill back to the word start and leave the cursor there; at a line start the kill takes only the newline. | `screen.editor.word-line-kills`: `ctrl-w`, `alt-backspace-marker`, `alt-backspace-word`, `alt-backspace-newline`. | +| Delete line (Ctrl+U) | `src/editor.ts:194` splices `lineStart(comment, cursor)..cursor`. | `rust/src/editor.rs:239` drains `line_start(..)..cursor`. | Kill back to the line start and leave the cursor there; at a line start it removes nothing. | `screen.editor.word-line-kills`: `ctrl-u`, `ctrl-u-at-line-start`. | +| Ctrl+S | `src/editor.ts:175` → `save` at `:122`; Rust control branch at `rust/src/editor.rs:178` → `save` at `:278`. | Blank comment: `Write a comment before saving.` and remain. Missing state: `Plugin state directory is unavailable.` and remain. Store error: display it and remain. Success: append exact JSONL, display `Saved.`, wait 250 ms, cleanly exit 0. | `screen.editor.edit-save`, `empty-save-escape`, `missing-state`. | +| Esc | `src/editor.ts:179` → `exit`/`cleanup`; Rust `rust/src/editor.rs:186` sets quit and `run` restores. | No store write; cursor shown, screen restored, exit 0. | `screen.editor.empty-save-escape`. | +| Ctrl+C | `src/editor.ts:174` → `exit`/`cleanup`; Rust `rust/src/editor.rs:174` sets quit. | Same cancellation effect as Esc. | `screen.editor.control-c`. | + +### How each runtime decodes the word and line modifiers + +The two runtimes reach the same action from different modifier models, so the mapping is stated here +rather than inferred. + +- Node's readline folds xterm modifiers 3 and 9 into a single `key.meta` flag (`modifier & 10`), so + `src/edit-keys.ts:54` inspects the raw sequence with `/;9[A-Z~]$/` to tell Command from Option. +- Crossterm's `parse_modifiers` subtracts one and reads the result as a bit set, so modifier 3 is + `KeyModifiers::ALT` and modifier 9 is `KeyModifiers::SUPER`. `KeyModifiers::META` is bit 32, which + is modifier 33, never Command, so `rust/src/edit_keys.rs:111` gates line moves on `SUPER` alone. +- Crossterm parses an `ESC`-prefixed byte as the following event with `ALT` folded in, so `\x1bb` is + `Char('b')` plus `ALT` and `\x1b\x7f` is `Backspace` plus `ALT`, matching Node's `meta` forms. +- Word boundaries use JavaScript's `\s` class, not `char::is_whitespace`. The two sets differ at + U+0085 and U+FEFF, so `is_javascript_whitespace` at `rust/src/edit_keys.rs:30` spells the + TypeScript set out and `edit_keys::tests::javascript_whitespace_matches_the_typescript_class` + pins both scalars. ## Every manager key in both views @@ -166,7 +189,7 @@ also fed by `screen.manager.all-views`. |---|---|---|---| | State directory creation | `fs.mkdirSync(..., {recursive:true})` at `src/capture.ts:42` and `withStoreLock` at `src/store.ts:196`. | `create_dir_all` at `rust/src/cli.rs:84` and `create_private_dir_all` at `rust/src/store.rs:392`. | Process-default directory mode (0755 under harness umask 022), not forced 0700. `process.copy.empty` compares the directory mode. | | Pending file | `src/capture.ts:44`/`:49`/`:52`. | `rust/src/cli.rs:85`/`:92` and `write_pending` at `:247`. | Name `pending--.json`; mode 0600 on creation; bytes are one JSON object plus `\n`; property order `selectedText`, `context`, `capturedAt`. Capture cases byte/mode-diff it. | -| Pending consumption | `src/editor.ts:34` reads/parses, then `fs.rmSync(...,{force:true})` at `:39`. | `pending_from_env` at `rust/src/editor.rs:320`, then `remove_pending_file` at `:337`. | Delete only after successful read and semantic parse; missing-at-delete is ignored; other deletion errors fail startup. `screen.editor.pending-file-save` compares the consumed tree and saved JSONL; Rust regression `pending_removal_is_forceful_like_typescript` pins the delete race. | +| Pending consumption | `src/editor.ts:34` reads/parses, then `fs.rmSync(...,{force:true})` at `:39`. | `pending_from_env` at `rust/src/editor.rs:345`, then `remove_pending_file` at `:362`. | Delete only after successful read and semantic parse; missing-at-delete is ignored; other deletion errors fail startup. `screen.editor.pending-file-save` compares the consumed tree and saved JSONL; Rust regression `pending_removal_is_forceful_like_typescript` pins the delete race. | | Handoff take | `handoffPath`/`takeHandoff` at `src/handoff.ts:17`/`:24`. | `handoff_path`/`take_handoff` at `rust/src/handoff.rs:12`/`:32`. | `$XDG_RUNTIME_DIR` else temp + `herdr-annotate-/selection`; missing/stat failure means absent; regular files ≤15 s old are decoded as UTF-8 with replacement; stale and blank values are rejected; every found node is removed; non-NotFound removal failure propagates. Context-skipped handoff remains. Capture handoff cases diff pending bytes and the runtime tree. | | Active append | `appendAnnotation` at `src/store.ts:42`. | `append_annotation` / `append_annotation_context_first` at `rust/src/store.rs:77`/`:82`, sharing `append_annotation_record` at `:97`. | Append one compact JSON object plus `\n`; mode 0600 on creation. Capture-file editor order is `selectedText,capturedAt,context,id,comment,createdAt`; direct invocation fallback preserves TypeScript's distinct `selectedText,context,capturedAt,id,comment,createdAt`. `screen.editor.pending-file-save.state` and `screen.editor.edit-save.state` byte-diff both orders; two Rust regressions pin them. | | JSONL read | `loadJsonLines` at `src/store.ts:150` and parsers at `src/types.ts:92`/`:103`. | `load_json_lines` at `rust/src/store.rs:227` and parsers at `rust/src/types.rs:133`/`:145`. | Missing file = empty; empty lines skipped; any malformed/nonconforming non-empty line rejects the whole store; unknown fields tolerated by parsers. Invalid-store and paired unit cases cover both stores. | @@ -269,10 +292,10 @@ the prefix exactly as shown. | `HERDR_PLUGIN_STATE_DIR is not set` | `src/capture.ts:22`, `src/export.ts:9`, `src/export-archive.ts:48`, `src/manager.ts:33` | `rust/src/cli.rs:69`/`:125`/`:185`, `rust/src/manager.rs:725` | Missing-state process cases. | | `HERDR_PLUGIN_ROOT is not set` | `src/capture.ts:24`, `src/open-manager.ts:7` | `rust/src/cli.rs:70`/`:215` | Missing-root cases. | | `No supported clipboard reader is available` | `src/clipboard.ts:59` | `rust/src/clipboard.rs:106` | `process.capture.no-clipboard`. | -| `Missing pending annotation` | `src/editor.ts:32` | `rust/src/editor.rs:326` | `process.editor.missing-pending`. | -| `Pending annotation is invalid` | `src/editor.ts:37` | `rust/src/editor.rs:332` | `process.editor.invalid-pending`. | -| `Write a comment before saving.` | `src/editor.ts:125` | `rust/src/editor.rs:256` | Editor empty-save frame. | -| `Plugin state directory is unavailable.` | `src/editor.ts:131` | `rust/src/editor.rs:260` | Editor missing-state frame. | +| `Missing pending annotation` | `src/editor.ts:32` | `rust/src/editor.rs:351` | `process.editor.missing-pending`. | +| `Pending annotation is invalid` | `src/editor.ts:37` | `rust/src/editor.rs:357` | `process.editor.invalid-pending`. | +| `Write a comment before saving.` | `src/editor.ts:125` | `rust/src/editor.rs:281` | Editor empty-save frame. | +| `Plugin state directory is unavailable.` | `src/editor.ts:131` | `rust/src/editor.rs:285` | Editor missing-state frame. | | `Unable to save annotation[ ()]` | `src/store.ts:48` | `rust/src/store.rs:100` | Store error catalog and paired store/editor tests. | | `Unable to read annotations (invalid data)` / `Unable to read archives (invalid data)` | `src/store.ts:163`/`:166` | `rust/src/store.rs:245`/`:247` | Invalid active process case and paired active/archive tests. | | `Unable to read annotations ()` / `Unable to read archives ()` | `src/store.ts:171` | `rust/src/store.rs:235`/`:240` | Source/error catalog; OS-specific access cases remain unit-level. | @@ -314,6 +337,11 @@ Notable differential groups: declared entrypoint. - `process.manage.*`, `process.editor.*`, `process.manager.*`: argv/error fallback and initialization. - `screen.editor.*`: both pending sources, every requested edit key, save branches, cancel keys, and SIGTERM. + `screen.editor.word-line-moves` and `screen.editor.word-line-kills` send the raw byte sequence for + each word/line key (`\x1b[1;3D`, `\x1b[1;3C`, `\x1b[1;5D`, `\x1b[1;5C`, `\x1b[1;9D`, `\x1b[1;9C`, + `\x1bb`, `\x1bf`, `\x1b\x7f`, `\x17`, `\x15`, `\x01`, `\x05`) and type a marker character after + each one, because a compared screen holds cells and not the cursor. The saved store bytes are the + record of where both runtimes put the cursor. - `screen.manager.*`: both views, every view-valid key, ignored cross-view keys by handler mapping, both confirmation flows, empty actions, exit keys, and SIGHUP. - `store.manager.*`: successful clipboard-only and copy/archive products; the three diff --git a/docs/rust-lite-parity.md b/docs/rust-lite-parity.md index c029a19..6039de3 100644 --- a/docs/rust-lite-parity.md +++ b/docs/rust-lite-parity.md @@ -19,6 +19,10 @@ separately so test coverage is not confused with behavior observed inside Herdr. - [x] `editor.ts` → `herdr-annotate editor`: pending-file and invocation-context fallback, delete-on- successful-read behavior, multiline Unicode editing, wide-cell cursor layout, Ctrl+S validation, save delay, Esc/Ctrl+C cancellation, raw terminal restoration. +- [x] `edit-keys.ts` → `rust/src/edit_keys.rs`: the same `wordStart`/`wordEnd`/`lineStart`/`lineEnd` + boundaries, including JavaScript's `\s` class, one-step newline crossing, and CJK runs as single + words; the same key map for Option/Alt and Ctrl arrows, Command/Super arrows, Alt+B/Alt+F, + Ctrl+A/Ctrl+E, and the Ctrl+W/Alt+Backspace/Ctrl+U kills. - [x] `manager.ts` → `herdr-annotate manager`: active/archive views, newest-first lists, detail panes, navigation, copy one/all, copy-and-archive, delete, double-confirm clear, restore, double-confirm permanent archive deletion, reload, status messages, Esc/Tab/q/Ctrl+C behavior. Every pane copy also @@ -65,6 +69,7 @@ document-anchor/API wire shape, not Lite's existing terminal-selection JSONL sha | `test/format.test.ts` | `format::tests` plus malformed-boundary store/type tests | | `test/width.test.ts` | `width::tests` (3 grouped tests covering every assertion) | | `test/layout.test.ts` | `layout::tests` (3 grouped tests covering every assertion) | +| `test/edit-keys.test.ts` | `edit_keys::tests` (14 tests, including the JavaScript whitespace class) | | `test/store.test.ts` | `store::tests` (6 tests) | | `test/archive-workflow.test.ts` | `archive_workflow::tests` (8 tests) | | `test/export-archive.test.ts` | `cli::tests::copy_archive_maps_every_outcome_to_its_notification_and_exit_status` | @@ -73,7 +78,8 @@ document-anchor/API wire shape, not Lite's existing terminal-selection JSONL sha Additional Rust-only coverage: -- `editor::tests`: headless 86×22 `TestBackend` frame, Unicode edit keys, empty-save validation, quit. +- `editor::tests`: headless 86×22 `TestBackend` frame, Unicode edit keys, word/line moves and kills + wired through `handle_key`, empty-save validation, quit. - `manager::tests`: headless 98×28 active/archive frames, newest-first detail, exact TypeScript detail width at the clipping boundary, confirmation, real clear. - `rust/tests/commands.rs`: subprocess-level capture/manage/copy-context commands with a fake Herdr, diff --git a/rust/src/edit_keys.rs b/rust/src/edit_keys.rs new file mode 100644 index 0000000..94726de --- /dev/null +++ b/rust/src/edit_keys.rs @@ -0,0 +1,311 @@ +//! Word and line editing keys for the comment editor. +//! +//! This mirrors `src/edit-keys.ts` character for character so both runtimes move, and kill, by the +//! same boundaries. The helpers work on the editor's `Vec` buffer and its scalar cursor. + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// An editing action that moves or kills more than one character. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditAction { + /// Move to the start of the word before the cursor. + WordLeft, + /// Move to the end of the word after the cursor. + WordRight, + /// Move to the first character of the current line. + LineStart, + /// Move to the last character of the current line. + LineEnd, + /// Kill back to the start of the word before the cursor. + DeleteWord, + /// Kill back to the start of the current line. + DeleteLine, +} + +/// The characters JavaScript's `\s` class matches. +/// +/// `char::is_whitespace` follows Unicode `White_Space`, which includes U+0085 and excludes U+FEFF; +/// `/\s/` does the opposite. The editor splits words with the TypeScript set so a buffer holding +/// either scalar is divided identically by both runtimes. +fn is_javascript_whitespace(character: char) -> bool { + matches!( + character, + '\u{9}'..='\u{d}' + | '\u{20}' + | '\u{a0}' + | '\u{1680}' + | '\u{2000}'..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +fn is_word_char(character: Option<&char>) -> bool { + character.is_some_and(|&character| character != '\n' && !is_javascript_whitespace(character)) +} + +fn is_newline(character: Option<&char>) -> bool { + character == Some(&'\n') +} + +/// Index of the start of the word before `cursor` (readline backward-word). +pub fn word_start(chars: &[char], cursor: usize) -> usize { + let mut index = cursor.min(chars.len()); + while index > 0 && !is_word_char(chars.get(index - 1)) && !is_newline(chars.get(index - 1)) { + index -= 1; + } + if index > 0 && is_newline(chars.get(index - 1)) && index == cursor { + return index - 1; + } + while index > 0 && is_word_char(chars.get(index - 1)) { + index -= 1; + } + index +} + +/// Index of the end of the word after `cursor` (readline forward-word). +pub fn word_end(chars: &[char], cursor: usize) -> usize { + let mut index = cursor; + while index < chars.len() && !is_word_char(chars.get(index)) && !is_newline(chars.get(index)) { + index += 1; + } + if index < chars.len() && is_newline(chars.get(index)) && index == cursor { + return index + 1; + } + while index < chars.len() && is_word_char(chars.get(index)) { + index += 1; + } + index +} + +/// Index of the first character on the line holding `cursor`. +pub fn line_start(chars: &[char], cursor: usize) -> usize { + let mut index = cursor; + while index > 0 && !is_newline(chars.get(index - 1)) { + index -= 1; + } + index +} + +/// Index just past the last character on the line holding `cursor`. +pub fn line_end(chars: &[char], cursor: usize) -> usize { + let mut index = cursor; + while index < chars.len() && !is_newline(chars.get(index)) { + index += 1; + } + index +} + +/// Map one crossterm key event to an editing action beyond single-character moves. +/// +/// Terminals encode Option/Alt as xterm modifier 3 and Command/Super as 9. Crossterm's +/// `parse_modifiers` subtracts one and reads the result as a bit set, so modifier 3 arrives as +/// `ALT` and modifier 9 as `SUPER`; `META` is bit 32, which is modifier 33 and never Command. +/// A bare `ESC b` is parsed as the following event with `ALT` folded in, which is `Char('b')` plus +/// `ALT`, and `ESC DEL` is `Backspace` plus `ALT`. Ghostty (and most macOS terminals) rewrite +/// Cmd+Backspace to Ctrl+U and Opt+Backspace to Ctrl+W, matching the readline kill bindings. +pub fn resolve_edit_key(key: &KeyEvent) -> Option { + let control = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + let super_modifier = key.modifiers.contains(KeyModifiers::SUPER); + + match key.code { + KeyCode::Char('u') if control => Some(EditAction::DeleteLine), + KeyCode::Char('w') if control => Some(EditAction::DeleteWord), + KeyCode::Backspace if alt => Some(EditAction::DeleteWord), + KeyCode::Char('a') if control => Some(EditAction::LineStart), + KeyCode::Char('e') if control => Some(EditAction::LineEnd), + KeyCode::Char('b') if alt => Some(EditAction::WordLeft), + KeyCode::Char('f') if alt => Some(EditAction::WordRight), + KeyCode::Left if super_modifier => Some(EditAction::LineStart), + KeyCode::Right if super_modifier => Some(EditAction::LineEnd), + KeyCode::Left if alt || control => Some(EditAction::WordLeft), + KeyCode::Right if alt || control => Some(EditAction::WordRight), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chars(text: &str) -> Vec { + text.chars().collect() + } + + fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) + } + + #[test] + fn word_start_skips_trailing_spaces_then_the_word() { + let c = chars("foo bar "); + assert_eq!(word_start(&c, c.len()), 4); + assert_eq!(word_start(&c, 4), 0); + assert_eq!(word_start(&c, 0), 0); + } + + #[test] + fn word_start_stops_at_a_newline_before_crossing_it() { + let c = chars("foo\nbar"); + assert_eq!(word_start(&c, 4), 3); + assert_eq!(word_start(&c, 3), 0); + } + + #[test] + fn word_end_skips_leading_spaces_then_the_word() { + let c = chars(" foo bar"); + assert_eq!(word_end(&c, 0), 5); + assert_eq!(word_end(&c, 5), 9); + assert_eq!(word_end(&c, 9), 9); + } + + #[test] + fn word_end_stops_after_a_newline() { + let c = chars("foo\nbar"); + assert_eq!(word_end(&c, 3), 4); + } + + #[test] + fn cjk_runs_count_as_one_word() { + let c = chars("한글 테스트"); + assert_eq!(word_start(&c, c.len()), 3); + assert_eq!(word_end(&c, 0), 2); + } + + #[test] + fn line_start_and_line_end_stay_within_the_current_line() { + let c = chars("ab\ncd\nef"); + assert_eq!(line_start(&c, 4), 3); + assert_eq!(line_end(&c, 4), 5); + assert_eq!(line_start(&c, 0), 0); + assert_eq!(line_end(&c, 8), 8); + } + + #[test] + fn boundaries_handle_a_cursor_past_the_end_like_typescript() { + // `wordStart` clamps with `Math.min`; the other three walk from the given index, so an + // out-of-range cursor is returned unchanged by the forward helpers. + let c = chars("ab"); + assert_eq!(word_start(&c, 9), 0); + assert_eq!(word_end(&c, 9), 9); + assert_eq!(line_start(&c, 9), 0); + assert_eq!(line_end(&c, 9), 9); + } + + #[test] + fn javascript_whitespace_matches_the_typescript_class() { + // U+00A0 and U+3000 are whitespace to both languages, U+FEFF only to JavaScript's `\s`, + // and U+0085 only to `char::is_whitespace`. + // With `char::is_whitespace` these would be 8 and 7 instead of 6 and 9. + let c = chars("a\u{a0}b\u{3000}c\u{feff}d\u{85}e"); + assert_eq!(word_start(&c, c.len()), 6); + assert_eq!(word_end(&c, 0), 1); + assert_eq!(word_end(&c, 6), 9); + } + + #[test] + fn readline_kill_bindings_resolve() { + assert_eq!( + resolve_edit_key(&key(KeyCode::Char('u'), KeyModifiers::CONTROL)), + Some(EditAction::DeleteLine) + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Char('w'), KeyModifiers::CONTROL)), + Some(EditAction::DeleteWord) + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Backspace, KeyModifiers::ALT)), + Some(EditAction::DeleteWord) + ); + } + + #[test] + fn control_a_and_control_e_move_to_line_edges() { + assert_eq!( + resolve_edit_key(&key(KeyCode::Char('a'), KeyModifiers::CONTROL)), + Some(EditAction::LineStart) + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Char('e'), KeyModifiers::CONTROL)), + Some(EditAction::LineEnd) + ); + } + + #[test] + fn option_arrows_and_alt_letters_move_by_word() { + assert_eq!( + resolve_edit_key(&key(KeyCode::Left, KeyModifiers::ALT)), + Some(EditAction::WordLeft) + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Right, KeyModifiers::ALT)), + Some(EditAction::WordRight) + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Char('b'), KeyModifiers::ALT)), + Some(EditAction::WordLeft) + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Char('f'), KeyModifiers::ALT)), + Some(EditAction::WordRight) + ); + } + + #[test] + fn control_arrows_move_by_word() { + assert_eq!( + resolve_edit_key(&key(KeyCode::Left, KeyModifiers::CONTROL)), + Some(EditAction::WordLeft) + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Right, KeyModifiers::CONTROL)), + Some(EditAction::WordRight) + ); + } + + #[test] + fn command_arrows_move_to_line_edges() { + // xterm modifier 9 reaches crossterm as SUPER, and wins over a folded-in ALT. + assert_eq!( + resolve_edit_key(&key(KeyCode::Left, KeyModifiers::SUPER)), + Some(EditAction::LineStart) + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Right, KeyModifiers::SUPER)), + Some(EditAction::LineEnd) + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Left, KeyModifiers::SUPER | KeyModifiers::ALT)), + Some(EditAction::LineStart) + ); + } + + #[test] + fn plain_keys_are_left_to_the_editor() { + assert_eq!( + resolve_edit_key(&key(KeyCode::Left, KeyModifiers::NONE)), + None + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Backspace, KeyModifiers::NONE)), + None + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Char('a'), KeyModifiers::NONE)), + None + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Esc, KeyModifiers::NONE)), + None + ); + assert_eq!( + resolve_edit_key(&key(KeyCode::Char('u'), KeyModifiers::ALT)), + None + ); + } +} diff --git a/rust/src/editor.rs b/rust/src/editor.rs index 2bec8f8..81860d2 100644 --- a/rust/src/editor.rs +++ b/rust/src/editor.rs @@ -13,6 +13,7 @@ use ratatui::widgets::{Clear, Paragraph}; use serde_json::Value; use uuid::Uuid; +use crate::edit_keys::{EditAction, line_end, line_start, resolve_edit_key, word_end, word_start}; use crate::format::{sanitize_terminal_text, wrap_text}; use crate::layout::layout_comment; use crate::paths::state_dir; @@ -177,6 +178,10 @@ impl EditorApp { if key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL) { return true; } + if let Some(action) = resolve_edit_key(&key) { + self.apply_edit_action(action); + return false; + } match key.code { KeyCode::Esc => self.quit = true, KeyCode::Backspace => { @@ -219,6 +224,26 @@ impl EditorApp { false } + /// Apply a word or line action, mirroring the `resolveEditKey` branches in `src/editor.ts`. + fn apply_edit_action(&mut self, action: EditAction) { + match action { + EditAction::WordLeft => self.cursor = word_start(&self.comment, self.cursor), + EditAction::WordRight => self.cursor = word_end(&self.comment, self.cursor), + EditAction::LineStart => self.cursor = line_start(&self.comment, self.cursor), + EditAction::LineEnd => self.cursor = line_end(&self.comment, self.cursor), + EditAction::DeleteWord => { + let start = word_start(&self.comment, self.cursor); + self.comment.drain(start..self.cursor); + self.cursor = start; + } + EditAction::DeleteLine => { + let start = line_start(&self.comment, self.cursor); + self.comment.drain(start..self.cursor); + self.cursor = start; + } + } + } + fn insert(&mut self, character: char) { self.comment.insert(self.cursor, character); self.cursor += 1; @@ -440,6 +465,59 @@ mod tests { assert_eq!(empty.status, "Write a comment before saving."); } + #[test] + fn word_and_line_keys_match_the_typescript_editor() { + let mut editor = app(); + for character in "alpha beta".chars() { + editor.handle_key(KeyEvent::from(KeyCode::Char(character))); + } + editor.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)); + assert_eq!(editor.cursor, 6); + editor.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT)); + assert_eq!(editor.cursor, 0); + editor.handle_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT)); + assert_eq!(editor.cursor, 5); + editor.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL)); + assert_eq!(editor.cursor, 10); + editor.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::SUPER)); + assert_eq!(editor.cursor, 0); + editor.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::SUPER)); + assert_eq!(editor.cursor, 10); + editor.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)); + assert_eq!(editor.cursor, 0); + editor.handle_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL)); + assert_eq!(editor.cursor, 10); + editor.handle_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert_eq!(editor.comment.iter().collect::(), "alpha "); + assert_eq!(editor.cursor, 6); + editor.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)); + assert_eq!(editor.comment.iter().collect::(), ""); + assert_eq!(editor.cursor, 0); + } + + #[test] + fn delete_line_kills_only_the_current_line() { + let mut editor = app(); + for character in "one two".chars() { + editor.handle_key(KeyEvent::from(KeyCode::Char(character))); + } + editor.handle_key(KeyEvent::from(KeyCode::Enter)); + for character in "한글 three".chars() { + editor.handle_key(KeyEvent::from(KeyCode::Char(character))); + } + editor.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert_eq!(editor.comment.iter().collect::(), "one two\n"); + assert_eq!(editor.cursor, 8); + editor.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)); + assert_eq!(editor.cursor, 7); + editor.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)); + assert_eq!(editor.cursor, 4); + editor.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT)); + assert_eq!(editor.cursor, 7); + editor.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT)); + assert_eq!(editor.cursor, 8); + } + #[test] fn escape_and_control_c_quit() { let mut escape = app(); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index a8d38ae..82d60ac 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2,6 +2,7 @@ pub mod archive_workflow; pub mod clipboard; +pub mod edit_keys; pub mod editor; pub mod format; pub mod handoff; diff --git a/scripts/parity-lite.py b/scripts/parity-lite.py index abd3ae1..a074512 100755 --- a/scripts/parity-lite.py +++ b/scripts/parity-lite.py @@ -1129,6 +1129,19 @@ def manager_missing(_impl: str, state: Path, runtime: Path, log: Path, source: P "editor:ctrl-s", "editor:esc", "editor:ctrl-c", + "editor:alt-left", + "editor:alt-right", + "editor:ctrl-left", + "editor:ctrl-right", + "editor:super-left", + "editor:super-right", + "editor:alt-b", + "editor:alt-f", + "editor:alt-backspace", + "editor:ctrl-w", + "editor:ctrl-u", + "editor:ctrl-a", + "editor:ctrl-e", } MANAGER_REQUIRED = { f"manager:{view}:{key}" @@ -1203,6 +1216,89 @@ def run_screen_and_store_layer(harness: Harness) -> tuple[Path, Path]: editor_context, compare_state=True, ) + # Word and line editing keys. A compared screen holds cells, not the cursor, so every move is + # followed by a marker character: the saved text is the record of where both runtimes put the + # cursor. Alt/Option is xterm modifier 3, Ctrl is 5, and Command/Super is 9. + move_steps = [ + Step("chars", b"alpha beta gamma", ("editor:chars",)), + Step("enter", b"\r", ("editor:enter",)), + Step("chars-second-line", "\ud55c\uae00 delta epsilon".encode(), ("editor:chars",)), + Step("alt-left", b"\x1b[1;3D", ("editor:alt-left",)), + Step("mark-1", b"1"), + Step("alt-b", b"\x1bb", ("editor:alt-b",)), + Step("mark-2", b"2"), + Step("alt-f", b"\x1bf", ("editor:alt-f",)), + Step("mark-3", b"3"), + Step("alt-right", b"\x1b[1;3C", ("editor:alt-right",)), + Step("mark-4", b"4"), + Step("ctrl-left", b"\x1b[1;5D", ("editor:ctrl-left",)), + Step("mark-5", b"5"), + Step("ctrl-right", b"\x1b[1;5C", ("editor:ctrl-right",)), + Step("mark-6", b"6"), + # A word move crosses a newline in a step of its own: three presses walk back over the words + # of the second line, the fourth lands on the end of the first, and coming back needs one + # press for the newline and one for the next word. + Step("alt-left-word", b"\x1b[1;3D", ("editor:alt-left",)), + Step("alt-left-previous-word", b"\x1b[1;3D", ("editor:alt-left",)), + Step("alt-left-line-first-word", b"\x1b[1;3D", ("editor:alt-left",)), + Step("alt-left-over-newline", b"\x1b[1;3D", ("editor:alt-left",)), + Step("mark-7", b"7"), + Step("alt-right-over-newline", b"\x1b[1;3C", ("editor:alt-right",)), + Step("alt-right-next-word", b"\x1b[1;3C", ("editor:alt-right",)), + Step("mark-8", b"8"), + Step("super-left", b"\x1b[1;9D", ("editor:super-left",)), + Step("mark-9", b"9"), + Step("super-right", b"\x1b[1;9C", ("editor:super-right",)), + Step("mark-a", b"A"), + Step("ctrl-a", b"\x01", ("editor:ctrl-a",)), + Step("mark-b", b"B"), + Step("ctrl-e", b"\x05", ("editor:ctrl-e",)), + Step("mark-c", b"C"), + Step("save", b"\x13", ("editor:ctrl-s",)), + ] + harness.pty_pair( + "screen.editor.word-line-moves", + "editor", + move_steps, + "Selected text", + 22, + 86, + editor_seed, + editor_context, + compare_state=True, + ) + kill_steps = [ + Step("chars", b"alpha beta gamma", ("editor:chars",)), + Step("enter", b"\r", ("editor:enter",)), + Step("chars-second-line", "\ud55c\uae00 delta epsilon".encode(), ("editor:chars",)), + Step("ctrl-w", b"\x17", ("editor:ctrl-w",)), + Step("mark-w", b"W"), + Step("alt-backspace-marker", b"\x1b\x7f", ("editor:alt-backspace",)), + Step("alt-backspace-word", b"\x1b\x7f", ("editor:alt-backspace",)), + Step("mark-x", b"X"), + Step("ctrl-u", b"\x15", ("editor:ctrl-u",)), + Step("mark-y", b"Y"), + # At a line start the kill takes the newline itself, one step, and joins the two lines. + Step("super-left-to-line-start", b"\x1b[1;9D", ("editor:super-left",)), + Step("alt-backspace-newline", b"\x1b\x7f", ("editor:alt-backspace",)), + Step("mark-z", b"Z"), + Step("ctrl-a-line-start", b"\x01", ("editor:ctrl-a",)), + # Killing back to a line start the cursor already sits on removes nothing. + Step("ctrl-u-at-line-start", b"\x15", ("editor:ctrl-u",)), + Step("mark-tail", b"tail ", ("editor:chars",)), + Step("save", b"\x13", ("editor:ctrl-s",)), + ] + harness.pty_pair( + "screen.editor.word-line-kills", + "editor", + kill_steps, + "Selected text", + 22, + 86, + editor_seed, + editor_context, + compare_state=True, + ) harness.pty_pair( "screen.editor.pending-file-save", "editor",