diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a249f52..8b9e843 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,7 +70,7 @@ There is no formal crate-level enforcement because all modules are in one binary ## 3. Current architectural invariants 1. A `Buffer` owns text, file path, disk baseline, dirty state, undo and redo history, change markers, and text revision. -2. A `View` owns point, vertical scroll, horizontal scroll, and the preferred terminal column for vertical movement. +2. A `View` owns point, vertical scroll, horizontal scroll, the last rendered viewport height, and the preferred terminal column for vertical movement. 3. Point and edit boundaries are Rope character indexes that are clamped to extended grapheme cluster boundaries. 4. `Editor` deduplicates open buffers by normalized macOS path identity and keeps one active buffer. 5. The active buffer has exactly one stored `View` in the current implementation. @@ -93,7 +93,7 @@ There is no formal crate-level enforcement because all modules are in one binary | `app.rs` | Main event loop, transient UI state, nested picker flow, and coordination | Editor, commands, input, picker, renderer, terminal, and signals | Buffer text or retained terminal cells | | `editor.rs` | Buffer collection, active index, per-buffer view, and path deduplication | Buffer, View, filesystem path metadata, and macOS path rules | Editing operations or screen layout | | `buffer.rs` | Rope text, file identity and baseline, history, revisions, changed lines, save and reload rules | Ropey, Unicode helpers, filesystem, randomness, and macOS file APIs | Point, scrolling, prompt state, or screen cells | -| `view.rs` | Point, scroll offsets, and preferred display column | Buffer queries | Text, file identity, or rendering styles | +| `view.rs` | Point, scroll offsets, viewport height, and preferred display column | Buffer queries | Text, file identity, or rendering styles | | `commands.rs` | Core editing, movement, save, reload, undo, redo, and quit outcomes | Buffer and View | Prompt-driven commands and application-wide state | | `command_registry.rs` | Static names, aliases, descriptions, argument validation, and prefix completion | Command enum | Editor state or command execution | | `input.rs` and `keymap.rs` | Terminal-key normalization and one pending `C-x` prefix | Crossterm events and the command enum | Command execution or configurable bindings | @@ -234,6 +234,8 @@ The buffer evicts oldest whole groups with a deque and keeps history identities Each edit keeps its structural line-change metadata; grouped undo reverses these edits in order. Typing and same-direction deletion use explicit timestamps with a 750 ms pause boundary. The application, command dispatch, and buffer-switch paths end groups for deliberate actions. +Word boundaries traverse Rope graphemes using Unicode alphanumeric and underscore classes, without copying the buffer. +Word movement and word kills share these boundaries; paging uses the View viewport height with a two-line overlap. Open-buffer count, clean baselines, and history metadata have no separate memory budget. The kill ring retains at most 32 complete nonempty cuts; its text has no separate byte limit. Application yank state records buffer identity, revision, the exact inserted range, point, and ring index. diff --git a/README.md b/README.md index 526c7a3..620ffe0 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,10 @@ It includes shared prompts for commands and buffer navigation, a directory picke | Down or `C-n` | Move to next line | | `C-a` | Move to start of line | | `C-e` | Move to end of line | +| `M-f`, `M-b` | Move forward or backward by word | +| `M-<`, `M->` | Move to buffer start or end | +| `C-v` / PageDown, `M-v` / PageUp | Move forward or backward by one page with overlap | +| `M-d`, `M-Backspace` | Cut forward or backward by word | | `C-s` | Repeat the previous search | | `C-Space` | Set the mark | | `C-w` | Cut the active region | @@ -183,6 +187,11 @@ Escape cancels the prompt; unknown names and invalid arguments report an error w | `forward-char`, `backward-char` | Move one grapheme | | | `next-line`, `previous-line` | Move one line | | | `beginning-of-line`, `end-of-line` | Move to a line boundary | | +| `forward-word`, `backward-word` | Move by Unicode words | | +| `beginning-of-buffer`, `end-of-buffer` | Move to a buffer boundary | | +| `scroll-up`, `scroll-down` | Move forward or backward by one page | | +| `goto-line ` | Move to a positive one-based line number, clamped at EOF | | +| `kill-word`, `backward-kill-word` | Cut by word into the kill ring | | | `newline` | Insert a newline carrying leading indentation | | | `indent`, `outdent` | Apply Tab or Shift-Tab behavior | | | `delete-char`, `delete-backward-char` | Delete one grapheme | | @@ -199,6 +208,12 @@ Aliases also work without a leading slash. Use `find-file` to browse a directory. Typing `/` in the editor still inserts a slash. +Words contain Unicode letters and numbers plus underscore; combining marks remain with their grapheme. +Word movement skips separators and reaches the next word end or previous word beginning. +Word kills use the same boundaries. +Pages overlap by two lines, move at least one line, and retain the preferred display column. +`goto-line` rejects invalid numbers without moving point and clamps numbers beyond the file to its last line. + ## Directory Picker Keybindings | Key | Action | diff --git a/docs/issues/168-plan.md b/docs/issues/168-plan.md new file mode 100644 index 0000000..ebd3010 --- /dev/null +++ b/docs/issues/168-plan.md @@ -0,0 +1,23 @@ +# Issue 168: Word, page, buffer, and line navigation + +## Task and acceptance + +Add M-f/M-b word movement and M-d/M-Backspace word kills through the existing kill ring. +Words are Unicode alphanumeric runs plus underscore, with combining marks kept in their containing grapheme. +Forward movement skips separators then reaches the end of a word; backward movement skips separators then reaches its beginning. +Word kills use those same boundaries and one undo edit, clearing the region after a successful cut. + +Add M- for buffer start/end. +Add C-v/PageDown and M-v/PageUp for paging with two lines of overlap and at least one line of movement. +Store the last rendered viewport height in View, preserve the preferred display column, and keep point visible at edges and after resize. +Add goto-line with a positive one-based argument through M-x; clamp beyond EOF and reject zero, negative, overflow, and nonnumeric input without moving point. +All commands appear in the registry and end typing/yank-pop groups as deliberate actions. + +## Checks + +Test Unicode words, punctuation, underscores, combining marks, emoji separators, empty buffers, EOF, and long lines. +Retain grapheme context in both directions and bound requested context bytes over growing regional-indicator fixtures. +Compare retained traversal against flat Unicode segmentation across rope chunk boundaries. +Test forward/backward kills with yank and undo, buffer endpoints, page overlap and edges, preferred columns, and all input mappings. +Test goto-line validation and named-command/key equivalence where applicable. +Run formatting, strict Clippy, the full suite, release build, independent review, and a real Unicode navigation/kill/yank/page/goto/save/quit session with shell restoration. diff --git a/docs/roadmap.md b/docs/roadmap.md index 15cf0b9..0bf3992 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -142,7 +142,7 @@ Delivery order: - [closed] [#165 Handle literal terminal paste](https://github.com/owainlewis/cortex/issues/165) - [closed] [#166 Group typing and deletion undo steps](https://github.com/owainlewis/cortex/issues/166) - [closed] [#47 Add kill ring and yank-pop](https://github.com/owainlewis/cortex/issues/47) -- [open] [#167 Add explicit macOS clipboard commands](https://github.com/owainlewis/cortex/issues/167) +- [closed] [#167 Add explicit macOS clipboard commands](https://github.com/owainlewis/cortex/issues/167) - [open] [#168 Add word, page, and line navigation](https://github.com/owainlewis/cortex/issues/168) - [open] [#29 Add incremental search](https://github.com/owainlewis/cortex/issues/29) - [open] [#169 Add fuzzy file and buffer navigation](https://github.com/owainlewis/cortex/issues/169) diff --git a/src/app.rs b/src/app.rs index beddc9a..d8aec39 100644 --- a/src/app.rs +++ b/src/app.rs @@ -516,6 +516,37 @@ impl AppState { AppAction::Continue } + fn kill_word(&mut self, buffer: &mut Buffer, view: &mut View, forward: bool) -> AppAction { + let point = view.point(); + let end = buffer.word_boundary(point, forward); + if point == end { + self.set_status("Nothing to cut", StatusKind::Info); + return AppAction::Continue; + } + let range = point.min(end)..point.max(end); + let text = buffer.text_range(range.clone()); + let point_after = buffer.delete_with_points(range.clone(), point, range.start); + view.set_point(point_after, buffer); + self.kill_ring.push(text); + self.mark = None; + self.set_status("Cut word", StatusKind::Success); + AppAction::Continue + } + + fn goto_line(&mut self, argument: &str, buffer: &Buffer, view: &mut View) -> AppAction { + let Some(line) = argument.parse::().ok().filter(|line| *line > 0) else { + self.set_status( + "goto-line requires a positive line number", + StatusKind::Error, + ); + return AppAction::Continue; + }; + let line = (line - 1).min(buffer.len_lines().saturating_sub(1)); + view.set_point(buffer.line_start_char(line), buffer); + self.set_status(format!("Line {}", line + 1), StatusKind::Info); + AppAction::Continue + } + fn yank(&mut self, buffer: &mut Buffer, view: &mut View) -> AppAction { let Some(text) = self.kill_ring.get(0) else { self.set_status("No cut text", StatusKind::Error); @@ -741,6 +772,10 @@ impl AppState { Command::YankPop => self.yank_pop(buffer, view), Command::CopyRegion => self.copy_region(buffer, view), Command::ClipboardPaste => self.paste_clipboard(buffer, view), + Command::KillWord | Command::BackwardKillWord => { + self.kill_word(buffer, view, command == Command::KillWord) + } + Command::GotoLine => self.goto_line(argument, buffer, view), Command::RepeatSearch => self.repeat_search(buffer, view), Command::OpenFile => self.start_find_file(), Command::SwitchBuffer => self.start_switch_buffer(), @@ -981,6 +1016,9 @@ fn keycast_text(key: crate::input::Key) -> Option { crate::input::Key::BackTab => Some("Shift-Tab".to_string()), crate::input::Key::Escape => Some("Esc".to_string()), crate::input::Key::Backspace => Some("Backspace".to_string()), + crate::input::Key::MetaBackspace => Some("M-Backspace".to_string()), + crate::input::Key::PageDown => Some("PageDown".to_string()), + crate::input::Key::PageUp => Some("PageUp".to_string()), crate::input::Key::Delete => Some("Delete".to_string()), crate::input::Key::Left => Some("Left".to_string()), crate::input::Key::Right => Some("Right".to_string()), @@ -1056,6 +1094,54 @@ mod tests { } } + #[test] + fn word_kills_use_the_same_boundaries_and_retain_complete_text() { + let mut app = AppState::default(); + let mut keymap = Keymap::new(); + let mut buffer = buffer_with_text("words.txt", "one, café_2 👨‍💻 東京"); + let mut view = View::new(); + view.set_point(3, &buffer); + app.mark = Some(0); + app.handle_key(Key::Meta('d'), &mut keymap, &mut buffer, &mut view); + assert_eq!(buffer.text(), "one 👨‍💻 東京"); + assert_eq!(view.point(), 3); + assert_eq!(app.kill_ring.get(0), Some(", café_2")); + assert!(app.mark.is_none()); + app.handle_key(Key::Ctrl('y'), &mut keymap, &mut buffer, &mut view); + assert_eq!(buffer.text(), "one, café_2 👨‍💻 東京"); + app.handle_key(Key::MetaBackspace, &mut keymap, &mut buffer, &mut view); + assert_eq!(buffer.text(), "one, 👨‍💻 東京"); + assert_eq!(app.kill_ring.get(0), Some("café_2")); + assert_eq!(app.kill_ring.get(1), Some(", café_2")); + app.execute_command(commands::Command::Undo, "", &mut buffer, &mut view); + assert_eq!(buffer.text(), "one, café_2 👨‍💻 東京"); + assert_eq!(view.point(), 11); + } + + #[test] + fn goto_line_validates_input_and_clamps_to_the_last_line() { + let mut app = AppState::default(); + let mut keymap = Keymap::new(); + let mut buffer = buffer_with_text("lines.txt", "a\r\n界\nlast"); + let mut view = View::new(); + run_slash_command("goto-line 2", &mut app, &mut keymap, &mut buffer, &mut view); + assert_eq!(view.point(), 3); + for invalid in ["", "0", "-1", "1.5", "abc", "184467440737095516160"] { + app.execute_command(commands::Command::GotoLine, invalid, &mut buffer, &mut view); + assert_eq!(view.point(), 3); + assert_eq!(app.status_kind, Some(StatusKind::Error)); + } + run_slash_command( + "goto-line 999", + &mut app, + &mut keymap, + &mut buffer, + &mut view, + ); + assert_eq!(view.point(), 5); + assert_eq!(buffer.undo(), None); + } + #[test] fn clipboard_copy_preserves_region_text_history_and_kill_ring() { let dir = test_dir("clipboard-copy"); @@ -2607,6 +2693,14 @@ mod tests { fn named_commands_match_editing_and_movement_keys() { for (name, key) in [ ("forward-char", Key::Ctrl('f')), + ("forward-word", Key::Meta('f')), + ("backward-word", Key::Meta('b')), + ("beginning-of-buffer", Key::Meta('<')), + ("end-of-buffer", Key::Meta('>')), + ("scroll-up", Key::PageDown), + ("scroll-down", Key::PageUp), + ("kill-word", Key::Meta('d')), + ("backward-kill-word", Key::MetaBackspace), ("backward-char", Key::Ctrl('b')), ("next-line", Key::Ctrl('n')), ("previous-line", Key::Ctrl('p')), diff --git a/src/buffer.rs b/src/buffer.rs index 3543feb..0d018db 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -717,6 +717,10 @@ impl Buffer { self.line_change_range_probes.replace(0) } + pub(crate) fn word_boundary(&self, point: usize, forward: bool) -> usize { + text::rope_word_boundary(self.text.slice(..), point, forward).0 + } + pub fn find_forward(&self, query: &str, start_char: usize) -> Option { if query.is_empty() { return None; diff --git a/src/command_registry.rs b/src/command_registry.rs index f3932b7..dfd299b 100644 --- a/src/command_registry.rs +++ b/src/command_registry.rs @@ -235,6 +235,69 @@ pub const COMMANDS: &[CommandSpec] = &[ Command::ClipboardPaste, None, ), + command( + "forward-word", + "Move to the end of the next word", + &[], + Command::MoveForwardWord, + None, + ), + command( + "backward-word", + "Move to the beginning of the previous word", + &[], + Command::MoveBackwardWord, + None, + ), + command( + "beginning-of-buffer", + "Move to the beginning of the buffer", + &[], + Command::MoveToBufferStart, + None, + ), + command( + "end-of-buffer", + "Move to the end of the buffer", + &[], + Command::MoveToBufferEnd, + None, + ), + command( + "scroll-up", + "Move forward one page with overlap", + &[], + Command::PageDown, + None, + ), + command( + "scroll-down", + "Move backward one page with overlap", + &[], + Command::PageUp, + None, + ), + command( + "goto-line", + "Move to a positive one-based line number", + &[], + Command::GotoLine, + Some("line"), + ), + command( + "kill-word", + "Cut through the end of the next word", + &[], + Command::KillWord, + None, + ), + command( + "backward-kill-word", + "Cut through the beginning of the previous word", + &[], + Command::BackwardKillWord, + None, + ), command("yank", "Insert the newest kill", &[], Command::Yank, None), command( "yank-pop", diff --git a/src/commands.rs b/src/commands.rs index 268de4e..74559f5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -19,6 +19,15 @@ pub enum Command { KillLine, KillRegion, MoveForwardChar, + MoveForwardWord, + MoveBackwardWord, + MoveToBufferStart, + MoveToBufferEnd, + PageDown, + PageUp, + GotoLine, + KillWord, + BackwardKillWord, MoveBackwardChar, MoveNextLine, MovePreviousLine, @@ -145,7 +154,10 @@ pub(crate) fn dispatch_at( | Command::Yank | Command::YankPop | Command::CopyRegion - | Command::ClipboardPaste => CommandOutcome::default(), + | Command::ClipboardPaste + | Command::GotoLine + | Command::KillWord + | Command::BackwardKillWord => CommandOutcome::default(), Command::Undo => { if let Some(point) = buffer.undo() { view.set_point(point, buffer); @@ -160,6 +172,22 @@ pub(crate) fn dispatch_at( } Command::ReloadBuffer => reload_buffer(buffer, view), Command::RepeatSearch => CommandOutcome::default(), + Command::MoveForwardWord | Command::MoveBackwardWord => { + view.move_word(buffer, command == Command::MoveForwardWord); + CommandOutcome::default() + } + Command::MoveToBufferStart => { + view.move_to_buffer_start(buffer); + CommandOutcome::default() + } + Command::MoveToBufferEnd => { + view.move_to_buffer_end(buffer); + CommandOutcome::default() + } + Command::PageDown | Command::PageUp => { + view.move_page(buffer, command == Command::PageDown); + CommandOutcome::default() + } Command::MoveForwardChar => { view.move_forward_char(buffer); CommandOutcome::default() diff --git a/src/input.rs b/src/input.rs index 5af92ae..3c4b5fb 100644 --- a/src/input.rs +++ b/src/input.rs @@ -11,6 +11,9 @@ pub enum Key { BackTab, Escape, Backspace, + MetaBackspace, + PageDown, + PageUp, Delete, Left, Right, @@ -33,6 +36,15 @@ pub fn key_from_event(event: KeyEvent) -> Key { { Key::Meta(ch.to_ascii_lowercase()) } + KeyCode::Backspace + if event.modifiers.intersects(meta_modifiers) + && event + .modifiers + .difference(allowed_meta_modifiers) + .is_empty() => + { + Key::MetaBackspace + } _ if event.modifiers.intersects(meta_modifiers) => Key::Unhandled, KeyCode::Null => Key::Ctrl(' '), KeyCode::Char(ch) if event.modifiers.contains(KeyModifiers::SUPER) => { @@ -54,6 +66,8 @@ pub fn key_from_event(event: KeyEvent) -> Key { KeyCode::Right => Key::Right, KeyCode::Up => Key::Up, KeyCode::Down => Key::Down, + KeyCode::PageDown => Key::PageDown, + KeyCode::PageUp => Key::PageUp, _ => Key::Unhandled, } } @@ -85,6 +99,41 @@ mod tests { use super::{key_from_event, Key}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + #[test] + fn maps_page_keys_and_shifted_meta_buffer_boundaries() { + for (event, key) in [ + ( + KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), + Key::PageDown, + ), + ( + KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), + Key::PageUp, + ), + ( + KeyEvent::new(KeyCode::Char('<'), KeyModifiers::ALT | KeyModifiers::SHIFT), + Key::Meta('<'), + ), + ( + KeyEvent::new(KeyCode::Char('>'), KeyModifiers::ALT | KeyModifiers::SHIFT), + Key::Meta('>'), + ), + ( + KeyEvent::new(KeyCode::Backspace, KeyModifiers::META), + Key::MetaBackspace, + ), + ( + KeyEvent::new( + KeyCode::Backspace, + KeyModifiers::ALT | KeyModifiers::CONTROL, + ), + Key::Unhandled, + ), + ] { + assert_eq!(key_from_event(event), key); + } + } + #[test] fn prompt_paste_flattens_line_breaks_and_tabs_without_command_controls() { assert_eq!( @@ -163,10 +212,10 @@ mod tests { } #[test] - fn maps_meta_modified_non_characters_to_unhandled() { + fn maps_meta_backspace_and_rejects_other_meta_non_characters() { assert_eq!( key_from_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)), - Key::Unhandled + Key::MetaBackspace ); assert_eq!( key_from_event(KeyEvent::new(KeyCode::Left, KeyModifiers::META)), diff --git a/src/keymap.rs b/src/keymap.rs index 5b49ded..06abe72 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -58,6 +58,14 @@ impl Keymap { Key::Ctrl('k') => KeymapResult::Command(Command::KillLine), Key::Ctrl('w') => KeymapResult::Command(Command::KillRegion), Key::Ctrl('y') => KeymapResult::Command(Command::Yank), + Key::Meta('f') => KeymapResult::Command(Command::MoveForwardWord), + Key::Meta('b') => KeymapResult::Command(Command::MoveBackwardWord), + Key::Meta('<') => KeymapResult::Command(Command::MoveToBufferStart), + Key::Meta('>') => KeymapResult::Command(Command::MoveToBufferEnd), + Key::Meta('d') => KeymapResult::Command(Command::KillWord), + Key::MetaBackspace => KeymapResult::Command(Command::BackwardKillWord), + Key::Ctrl('v') | Key::PageDown => KeymapResult::Command(Command::PageDown), + Key::Meta('v') | Key::PageUp => KeymapResult::Command(Command::PageUp), Key::Meta('y') => KeymapResult::Command(Command::YankPop), Key::Right | Key::Ctrl('f') => KeymapResult::Command(Command::MoveForwardChar), Key::Left | Key::Ctrl('b') => KeymapResult::Command(Command::MoveBackwardChar), @@ -96,6 +104,24 @@ mod tests { use super::{Keymap, KeymapResult}; use crate::{commands::Command, input::Key}; + #[test] + fn word_buffer_and_page_keys_resolve_to_navigation_commands() { + for (key, command) in [ + (Key::Meta('f'), Command::MoveForwardWord), + (Key::Meta('b'), Command::MoveBackwardWord), + (Key::Meta('<'), Command::MoveToBufferStart), + (Key::Meta('>'), Command::MoveToBufferEnd), + (Key::Meta('d'), Command::KillWord), + (Key::MetaBackspace, Command::BackwardKillWord), + (Key::Ctrl('v'), Command::PageDown), + (Key::PageDown, Command::PageDown), + (Key::Meta('v'), Command::PageUp), + (Key::PageUp, Command::PageUp), + ] { + assert_eq!(Keymap::new().resolve(key), KeymapResult::Command(command)); + } + } + #[test] fn clipboard_bindings_are_explicit_and_do_not_change_the_quit_prefix() { let mut keymap = Keymap::new(); @@ -103,7 +129,10 @@ mod tests { keymap.resolve(Key::Meta('w')), KeymapResult::Command(Command::CopyRegion) ); - assert_eq!(keymap.resolve(Key::Ctrl('v')), KeymapResult::Unbound); + assert_eq!( + keymap.resolve(Key::Ctrl('v')), + KeymapResult::Command(Command::PageDown) + ); assert_eq!(keymap.resolve(Key::Ctrl('c')), KeymapResult::PendingPrefix); assert_eq!(keymap.pending_label(), Some("C-c")); assert_eq!( diff --git a/src/text.rs b/src/text.rs index b62630c..e801e0c 100644 --- a/src/text.rs +++ b/src/text.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use ropey::{iter::Chunks, str_utils::byte_to_char_idx, RopeSlice}; use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete, UnicodeSegmentation}; use unicode_width::UnicodeWidthStr; @@ -113,57 +115,115 @@ pub(crate) fn rope_boundary_at_or_after(text: RopeSlice<'_>, char_index: usize) } } -pub(crate) fn next_rope_boundary(text: RopeSlice<'_>, char_index: usize) -> usize { - let byte_index = text.char_to_byte(char_index); - let (mut chunk, mut chunk_start, mut chunk_char_start, _) = text.chunk_at_byte(byte_index); - let mut cursor = GraphemeCursor::new(byte_index, text.len_bytes(), true); +// Retain Unicode context while walking a run of graphemes. In particular, regional +// indicators need the parity of the preceding run in either direction. +struct RopeBoundaryCursor<'a> { + text: RopeSlice<'a>, + chunk: Cow<'a, str>, + chunk_start: usize, + chunk_char_start: usize, + cursor: GraphemeCursor, + context_bytes: usize, +} - loop { - match cursor.next_boundary(chunk, chunk_start) { - Ok(None) => return text.len_chars(), - Ok(Some(boundary)) => { - return chunk_char_start + byte_to_char_idx(chunk, boundary - chunk_start); - } - Err(GraphemeIncomplete::NextChunk) => { - chunk_start += chunk.len(); - let (next_chunk, _, next_char_start, _) = text.chunk_at_byte(chunk_start); - chunk = next_chunk; - chunk_char_start = next_char_start; - } - Err(GraphemeIncomplete::PreContext(byte_idx)) => { - let (context, context_start, _, _) = text.chunk_at_byte(byte_idx.saturating_sub(1)); - cursor.provide_context(context, context_start); +impl<'a> RopeBoundaryCursor<'a> { + fn new(text: RopeSlice<'a>, char_index: usize) -> Self { + let byte_index = text.char_to_byte(char_index); + let (chunk, chunk_start, chunk_char_start, _) = text.chunk_at_byte(byte_index); + Self { + text, + chunk: Cow::Borrowed(chunk), + chunk_start, + chunk_char_start, + cursor: GraphemeCursor::new(byte_index, text.len_bytes(), true), + context_bytes: 0, + } + } + + fn boundary(&mut self, forward: bool) -> usize { + loop { + let result = if forward { + self.cursor.next_boundary(&self.chunk, self.chunk_start) + } else { + self.cursor.prev_boundary(&self.chunk, self.chunk_start) + }; + match result { + Ok(None) => return if forward { self.text.len_chars() } else { 0 }, + Ok(Some(boundary)) => { + return self.chunk_char_start + + byte_to_char_idx(&self.chunk, boundary - self.chunk_start); + } + Err(GraphemeIncomplete::NextChunk) => { + self.set_chunk(self.chunk_start + self.chunk.len(), true); + } + Err(GraphemeIncomplete::PrevChunk) => { + self.set_chunk(self.chunk_start - 1, false); + } + Err(GraphemeIncomplete::PreContext(byte_idx)) => { + let (context, context_start, _, _) = + self.text.chunk_at_byte(byte_idx.saturating_sub(1)); + let context = &context[..byte_idx - context_start]; + self.context_bytes += context.len(); + self.cursor.provide_context(context, context_start); + } + Err(_) => unreachable!("rope chunks must cover the grapheme cursor"), } - Err(_) => unreachable!("rope chunks must cover the grapheme cursor"), } } + + fn set_chunk(&mut self, byte_index: usize, overlap: bool) { + let (chunk, chunk_start, chunk_char_start, _) = self.text.chunk_at_byte(byte_index); + self.chunk = Cow::Borrowed(chunk); + self.chunk_start = chunk_start; + self.chunk_char_start = chunk_char_start; + if overlap && chunk_char_start > 0 { + // GraphemeCursor requests fresh RI context at an exact chunk start, + // even with cached parity. One overlapping scalar keeps that parity + // usable while copying only one rope chunk, never a growing prefix. + let previous = self.text.char(chunk_char_start - 1); + let mut joined = String::with_capacity(previous.len_utf8() + chunk.len()); + joined.push(previous); + joined.push_str(chunk); + self.chunk = Cow::Owned(joined); + self.chunk_start -= previous.len_utf8(); + self.chunk_char_start -= 1; + } + } +} + +pub(crate) fn next_rope_boundary(text: RopeSlice<'_>, char_index: usize) -> usize { + RopeBoundaryCursor::new(text, char_index).boundary(true) } pub(crate) fn previous_rope_boundary(text: RopeSlice<'_>, char_index: usize) -> usize { - let byte_index = text.char_to_byte(char_index); - let (mut chunk, mut chunk_start, mut chunk_char_start, _) = text.chunk_at_byte(byte_index); - let mut cursor = GraphemeCursor::new(byte_index, text.len_bytes(), true); + RopeBoundaryCursor::new(text, char_index).boundary(false) +} - loop { - match cursor.prev_boundary(chunk, chunk_start) { - Ok(None) => return 0, - Ok(Some(boundary)) => { - return chunk_char_start + byte_to_char_idx(chunk, boundary - chunk_start); - } - Err(GraphemeIncomplete::PrevChunk) => { - let (previous_chunk, previous_start, previous_char_start, _) = - text.chunk_at_byte(chunk_start - 1); - chunk = previous_chunk; - chunk_start = previous_start; - chunk_char_start = previous_char_start; - } - Err(GraphemeIncomplete::PreContext(byte_idx)) => { - let (context, context_start, _, _) = text.chunk_at_byte(byte_idx.saturating_sub(1)); - cursor.provide_context(context, context_start); - } - Err(_) => unreachable!("rope chunks must cover the grapheme cursor"), +pub(crate) fn rope_word_boundary( + text: RopeSlice<'_>, + point: usize, + forward: bool, +) -> (usize, usize) { + let mut point = rope_boundary_at_or_before(text, point.min(text.len_chars())); + let mut cursor = RopeBoundaryCursor::new(text, point); + let mut found_word = false; + while if forward { + point < text.len_chars() + } else { + point > 0 + } { + let next = cursor.boundary(forward); + let word = text + .slice(point.min(next)..point.max(next)) + .chars() + .any(|ch| ch.is_alphanumeric() || ch == '_'); + if found_word && !word { + break; } + found_word |= word; + point = next; } + (point, cursor.context_bytes) } #[cfg(test)] @@ -325,8 +385,54 @@ mod tests { grapheme_char_indices, measure_rope_width, measure_width, next_rope_boundary, pop_grapheme, previous_rope_boundary, rope_boundary_at_or_after, rope_boundary_at_or_before, rope_char_index_at_column, rope_char_index_at_or_after_column, rope_prefix_for_width, + rope_word_boundary, }; + #[test] + fn retained_cursor_matches_flat_graphemes_across_rope_chunks() { + for value in [ + format!("{}end", "🇦🇧🇨".repeat(1_001)), + "a\u{301}👨‍💻🇦🇧👍🏽\u{600}界क्ष\r\n".repeat(301), + ] { + let rope = Rope::from_str(&value); + let mut boundaries: Vec<_> = grapheme_char_indices(&value) + .map(|(start, _)| start) + .collect(); + boundaries.push(rope.len_chars()); + let mut forward = super::RopeBoundaryCursor::new(rope.slice(..), 0); + for expected in boundaries.iter().skip(1) { + assert_eq!(forward.boundary(true), *expected); + } + let mut backward = super::RopeBoundaryCursor::new(rope.slice(..), rope.len_chars()); + for expected in boundaries.iter().rev().skip(1) { + assert_eq!(backward.boundary(false), *expected); + } + } + } + + #[test] + fn word_traversal_keeps_regional_indicator_context_in_both_directions() { + for count in [1_000, 4_000, 16_000] { + let flags = "🇦🇧".repeat(count); + for (value, point, forward, expected) in [ + (format!("{flags}end"), 0, true, count * 2 + 3), + (format!("start{flags}"), count * 2 + 5, false, 0), + (format!("{flags}end"), count, true, count * 2 + 3), + (format!("start{flags}"), count + 5, false, 0), + ] { + let rope = Rope::from_str(&value); + let (boundary, context_bytes) = rope_word_boundary(rope.slice(..), point, forward); + assert_eq!(boundary, expected); + // One initial context scan is enough. Restarting the cursor for + // every flag makes this grow quadratically across rope chunks. + assert!( + context_bytes <= rope.len_bytes() * 2, + "n={count} point={point} forward={forward} context={context_bytes}" + ); + } + } + } + #[test] fn boundaries_keep_common_extended_graphemes_whole() { let text = "e\u{301}👨‍💻🇺🇸👍🏽✈️界"; diff --git a/src/view.rs b/src/view.rs index 3203aa5..aabbaee 100644 --- a/src/view.rs +++ b/src/view.rs @@ -6,6 +6,7 @@ pub struct View { scroll_line: usize, scroll_column: usize, preferred_column: Option, + viewport_height: usize, } impl View { @@ -60,12 +61,50 @@ impl View { self.clear_preferred_column(); } + pub fn move_word(&mut self, buffer: &Buffer, forward: bool) { + self.set_point(buffer.word_boundary(self.point, forward), buffer); + } + + pub fn move_to_buffer_start(&mut self, buffer: &Buffer) { + self.set_point(0, buffer); + } + + pub fn move_to_buffer_end(&mut self, buffer: &Buffer) { + self.set_point(buffer.len_chars(), buffer); + } + + pub fn move_page(&mut self, buffer: &Buffer, forward: bool) { + let step = self + .viewport_height + .saturating_sub(2) + .max(1) + .min(isize::MAX as usize); + self.move_vertical( + buffer, + if forward { + step as isize + } else { + -(step as isize) + }, + ); + self.scroll_line = if forward { + self.scroll_line.saturating_add(step).min( + buffer + .len_lines() + .saturating_sub(self.viewport_height.max(1)), + ) + } else { + self.scroll_line.saturating_sub(step) + }; + } + pub fn ensure_point_visible( &mut self, buffer: &Buffer, viewport_height: usize, viewport_width: usize, ) { + self.viewport_height = viewport_height; let point_line = buffer.line_for_char(self.point); if viewport_height > 0 { if point_line < self.scroll_line { @@ -127,6 +166,105 @@ mod tests { static TEST_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0); + #[test] + fn word_movement_keeps_unicode_words_and_graphemes_whole() { + let buffer = buffer_with_text(" α_2...e\u{301}lan 👨‍💻 東京"); + let mut view = View::new(); + for point in [5, 13, 20, 20] { + view.move_word(&buffer, true); + assert_eq!(view.point(), point); + } + for point in [18, 8, 2, 0, 0] { + view.move_word(&buffer, false); + assert_eq!(view.point(), point); + } + view.set_point(10, &buffer); + view.move_word(&buffer, false); + assert_eq!(view.point(), 8); + view.move_word(&buffer, true); + assert_eq!(view.point(), 13); + } + + #[test] + fn word_and_buffer_movement_handle_empty_files_and_long_lines() { + let empty = buffer_with_text(""); + let mut view = View::new(); + view.move_word(&empty, true); + view.move_word(&empty, false); + view.move_to_buffer_end(&empty); + view.move_to_buffer_start(&empty); + assert_eq!(view.point(), 0); + let source = format!("{}α_beta", " ".repeat(100_000)); + let buffer = buffer_with_text(&source); + view.move_word(&buffer, true); + assert_eq!(view.point(), 100_006); + view.move_word(&buffer, false); + assert_eq!(view.point(), 100_000); + view.move_to_buffer_start(&buffer); + assert_eq!(view.point(), 0); + view.move_to_buffer_end(&buffer); + assert_eq!(view.point(), buffer.len_chars()); + } + + #[test] + fn pages_use_viewport_overlap_and_restore_the_preferred_column() { + let source: String = (0..40) + .map(|line| { + if line == 11 { + "a\n".to_string() + } else { + format!("{}\n", "x".repeat(30)) + } + }) + .collect(); + let buffer = buffer_with_text(&source); + let mut view = View::new(); + view.set_point(buffer.line_start_char(3) + 20, &buffer); + view.ensure_point_visible(&buffer, 10, 8); + view.move_page(&buffer, true); + assert_eq!(buffer.line_for_char(view.point()), 11); + assert_eq!(buffer.display_column(view.point()), 1); + assert_eq!(view.scroll_line(), 8); + view.move_page(&buffer, false); + assert_eq!(buffer.line_for_char(view.point()), 3); + assert_eq!(buffer.display_column(view.point()), 20); + assert_eq!(view.scroll_line(), 0); + view.ensure_point_visible(&buffer, 6, 8); + view.move_page(&buffer, true); + assert_eq!(buffer.line_for_char(view.point()), 7); + assert_eq!(view.scroll_line(), 4); + for _ in 0..50 { + view.move_page(&buffer, true); + } + assert_eq!(view.point(), buffer.len_chars()); + assert!(buffer.line_for_char(view.point()) < view.scroll_line() + 6); + for _ in 0..50 { + view.move_page(&buffer, false); + } + assert_eq!(buffer.line_for_char(view.point()), 0); + assert_eq!(view.scroll_line(), 0); + } + + #[test] + fn pages_move_at_least_one_line_in_tiny_viewports_and_clamp_empty_buffers() { + let buffer = buffer_with_text("a\nb\nc"); + for height in [0, 1, 2, 3] { + let mut view = View::new(); + view.ensure_point_visible(&buffer, height, 1); + view.move_page(&buffer, true); + assert_eq!(buffer.line_for_char(view.point()), 1); + view.move_page(&buffer, false); + assert_eq!(buffer.line_for_char(view.point()), 0); + } + let buffer = buffer_with_text(""); + let mut view = View::new(); + view.ensure_point_visible(&buffer, usize::MAX, 1); + view.move_page(&buffer, true); + view.move_page(&buffer, false); + assert_eq!(view.point(), 0); + assert_eq!(view.scroll_line(), 0); + } + #[test] fn forward_char_moves_one_character_and_clamps_at_eof() { let buffer = buffer_with_text("ab");