Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 <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 | |
Expand All @@ -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 |
Expand Down
23 changes: 23 additions & 0 deletions docs/issues/168-plan.md
Original file line number Diff line number Diff line change
@@ -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-</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.
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
owainlewis marked this conversation as resolved.
- [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)
Expand Down
94 changes: 94 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<usize>().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);
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -981,6 +1016,9 @@ fn keycast_text(key: crate::input::Key) -> Option<String> {
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()),
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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')),
Expand Down
4 changes: 4 additions & 0 deletions src/buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
if query.is_empty() {
return None;
Expand Down
63 changes: 63 additions & 0 deletions src/command_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 29 additions & 1 deletion src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ pub enum Command {
KillLine,
KillRegion,
MoveForwardChar,
MoveForwardWord,
MoveBackwardWord,
MoveToBufferStart,
MoveToBufferEnd,
PageDown,
PageUp,
GotoLine,
KillWord,
BackwardKillWord,
MoveBackwardChar,
MoveNextLine,
MovePreviousLine,
Expand Down Expand Up @@ -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);
Expand All @@ -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()
Expand Down
Loading