Skip to content

Commit 1250c8d

Browse files
authored
fix(editor): edit complete grapheme clusters (#115)
* docs(editor): define grapheme editing semantics * fix(editor): edit complete grapheme clusters * perf(editor): bound grapheme rendering work
1 parent da70937 commit 1250c8d

12 files changed

Lines changed: 914 additions & 265 deletions

File tree

‎Cargo.lock‎

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@ tree-sitter-toml-ng = "0.7.0"
2424
tree-sitter-typescript = "0.23.2"
2525
unicode-casefold = "0.2.0"
2626
unicode-normalization = "0.1.25"
27+
unicode-segmentation = "1.13.3"
28+
unicode-width = "0.2.2"

‎docs/issues/63-plan.md‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Issue 63 Plan
2+
3+
## Task
4+
5+
Make extended grapheme clusters the user-visible editing and rendering unit.
6+
Preserve current macOS editor behavior while ensuring Unicode text is never split at a user-perceived character boundary.
7+
8+
## Product Policy
9+
10+
Point remains stored as a Rope character index, but it may rest only at an extended grapheme cluster boundary.
11+
Horizontal movement advances or retreats by one extended grapheme cluster.
12+
Backward and forward deletion remove one complete extended grapheme cluster.
13+
Mark and region endpoints are grapheme boundaries, so selection, cut, yank, undo, and redo never split a cluster.
14+
Vertical movement preserves the preferred terminal cell column and chooses the last grapheme boundary that does not pass that column.
15+
Horizontal clipping includes a complete grapheme cluster or excludes it.
16+
Combining marks, variation selectors, emoji modifiers, regional-indicator flags, and ZWJ sequences remain attached to their cluster.
17+
Terminal width comes from the maintained `unicode-width` implementation and is applied to the complete cluster.
18+
Normal text clusters occupy the reported one or two terminal cells.
19+
A standalone cluster reported as zero cells occupies one editor cell so it remains visible and cursor-addressable.
20+
Tabs keep the existing four-column tab-stop behavior.
21+
Control clusters keep the existing visible-space behavior.
22+
23+
## Implementation
24+
25+
1. Add maintained Unicode grapheme segmentation and terminal-width dependencies.
26+
2. Add small shared helpers for grapheme boundaries, display width, clipping, and terminal-column lookup.
27+
3. Make `View` movement and point clamping grapheme-safe.
28+
4. Make backward and forward deletion operate on complete grapheme ranges.
29+
5. Keep insertion, region cutting, yanking, undo, redo, search, and reload points on grapheme boundaries.
30+
6. Build renderer segments and retained cells from complete grapheme clusters.
31+
7. Remove the temporary context-dependent full-redraw fallback once grapheme cell coordinates are reliable.
32+
8. Document the policy in the PRD and mark issue #63 complete in the roadmap after implementation is verified.
33+
34+
## Acceptance Criteria
35+
36+
- Decomposed accents render with their combining marks and move or delete as one unit.
37+
- ZWJ emoji, regional-indicator flags, skin-tone sequences, and variation-selector sequences move, select, delete, undo, and render as complete units.
38+
- Wide CJK graphemes occupy two cells and are never clipped halfway.
39+
- Cursor placement uses complete-cluster widths after movement, editing, selection, and renderer transitions.
40+
- Vertical movement never places point inside a grapheme cluster.
41+
- Diff rendering skips unchanged grapheme cells and safely transitions between Unicode frames.
42+
- Existing ASCII editing, tabs, syntax styles, picker behavior, resize handling, and terminal cleanup remain unchanged.
43+
44+
## Checks
45+
46+
- Run focused pure tests for combining marks, ZWJ emoji, flags, skin tones, variation selectors, wide CJK, selection, delete, undo, cursor placement, clipping, and renderer transitions.
47+
- Run `cargo fmt -- --check`.
48+
- Run `git diff origin/main...HEAD --check`.
49+
- Run `cargo test --no-fail-fast`.
50+
- Run `cargo clippy --all-targets --all-features -- -D warnings`.
51+
- Run `cargo build --all-targets --all-features`.
52+
- Run a synchronized PTY smoke that edits and navigates representative Unicode text, resizes, exits, and confirms shell restoration.
53+
54+
## Out of Scope
55+
56+
Unicode normalization is out of scope.
57+
Word and sentence movement are out of scope.
58+
Bidirectional text layout is out of scope.
59+
Soft wrapping and horizontal scrolling are out of scope.
60+
Configurable Unicode width policies are out of scope.

‎docs/prd.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ Both a *file window* and a *terminal pane* are leaves in the same window-layout
174174
| Terminal I/O | `crossterm` | Raw mode, key events, 24-bit truecolor, cursor control, cross-platform. |
175175
| Rendering | hand-rolled on crossterm | Editors need fine cursor control + minimal diffed repaints. (`ratatui` optional for chrome only — modeline/minibuffer.) |
176176
| Text buffer | `ropey` | O(log n) edits and slices on million-line files; cheap to view-slice for rendering. |
177+
| Text boundaries | `unicode-segmentation` | Extended grapheme clusters are the user-visible editing unit. |
178+
| Display width | `unicode-width` | Complete grapheme clusters map to terminal cells without splitting Unicode sequences. |
177179
| Syntax highlighting | `tree-sitter` + `tree-sitter-highlight` | Incremental, fast, LSP-independent; the core aesthetic win. |
178180
| Fuzzy finding | `nucleo` | Helix's matcher; powers find-file and buffer switching. |
179181
| File watching | `notify` | inotify/FSEvents/etc. for the (optional, later) auto-reload path. |
@@ -192,11 +194,16 @@ Both a *file window* and a *terminal pane* are leaves in the same window-layout
192194
- Backed by a `ropey::Rope`. (A `Vec<String>` is acceptable for the very first prototype, but `ropey` is the intended default and the migration is cheap.)
193195
- Tracks: the rope, a dirty flag, the file path, and file metadata needed for save/reload behavior.
194196
- Point, mark, and scroll offset belong to the window/editor view state, not solely to the buffer.
197+
- Point and mark use Rope character indices but may rest only at extended grapheme cluster boundaries.
198+
- Horizontal movement, deletion, selection, kill, yank, undo, and redo operate on complete extended grapheme clusters.
199+
- Vertical movement preserves terminal cell columns and never places point inside a grapheme cluster.
195200
- Multiple buffers held in a buffer list; one is active.
196201
- Undo/redo: an edit-history stack (group keystrokes into sensible undo units; Emacs-style undo can come later).
197202

198203
### 6.2 Renderer
199204
- **Diffed paint.** Maintain a shadow of the last drawn screen; only emit escape codes for cells that changed. This is the difference between flicker and butter, and it directly serves the latency goal.
205+
- Display width is measured for complete grapheme clusters, and clipping includes or excludes each cluster as a whole.
206+
- Tabs keep four-column tab stops, normal clusters use their reported one or two cells, and standalone zero-width clusters use one visible editor cell.
200207
- Viewport: scroll offset + visible range derived from point.
201208
- Line wrapping handled explicitly (soft-wrap toggle is a later nicety).
202209
- Generous padding; block/bar cursor styles; truecolor throughout.

‎docs/roadmap.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ Tracking issues:
208208
- [closed] [#60 Preserve file permissions and define symlink save behavior](https://github.com/owainlewis/cortex/issues/60)
209209
- [closed] [#61 Remove whole-buffer work from input and render paths](https://github.com/owainlewis/cortex/issues/61)
210210
- [closed] [#62 Preserve syntax highlight context across viewport boundaries](https://github.com/owainlewis/cortex/issues/62)
211-
- [open] [#63 Render Unicode grapheme clusters and combining marks correctly](https://github.com/owainlewis/cortex/issues/63)
211+
- [closed] [#63 Render Unicode grapheme clusters and combining marks correctly](https://github.com/owainlewis/cortex/issues/63)
212212
- [closed] [#64 Mark final-newline-only changes in the gutter](https://github.com/owainlewis/cortex/issues/64)
213213

214214
## Repository Quality

‎src/app.rs‎

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::{
77
picker::{DirectoryPicker, DirectoryPickerAction},
88
renderer::{Renderer, StatusKind, TerminalSize},
99
terminal::TerminalSession,
10+
text,
1011
view::View,
1112
};
1213
use crossterm::{
@@ -351,8 +352,8 @@ impl AppState {
351352

352353
fn active_region(&self, buffer: &Buffer, view: &View) -> Option<Range<usize>> {
353354
let len_chars = buffer.len_chars();
354-
let mark = self.mark?.min(len_chars);
355-
let point = view.point().min(len_chars);
355+
let mark = buffer.grapheme_boundary_at_or_before(self.mark?.min(len_chars));
356+
let point = buffer.grapheme_boundary_at_or_before(view.point().min(len_chars));
356357

357358
if mark == point {
358359
return None;
@@ -374,8 +375,8 @@ impl AppState {
374375
};
375376

376377
let text = buffer.text_range(region.clone());
377-
buffer.delete_with_points(region.clone(), view.point(), region.start);
378-
view.set_point(region.start, buffer);
378+
let point_after = buffer.delete_with_points(region.clone(), view.point(), region.start);
379+
view.set_point(point_after, buffer);
379380
self.kill_ring = Some(text);
380381
self.mark = None;
381382
self.set_status("Cut region", StatusKind::Success);
@@ -390,8 +391,8 @@ impl AppState {
390391
};
391392

392393
let text = buffer.text_range(region.clone());
393-
buffer.delete_with_points(region, point, point);
394-
view.set_point(point, buffer);
394+
let point_after = buffer.delete_with_points(region, point, point);
395+
view.set_point(point_after, buffer);
395396
self.kill_ring = Some(text);
396397
self.mark = None;
397398
self.set_status("Cut line", StatusKind::Success);
@@ -405,8 +406,8 @@ impl AppState {
405406
};
406407

407408
let point = view.point();
408-
buffer.insert(point, &text);
409-
view.set_point(point + text.chars().count(), buffer);
409+
let point_after = buffer.insert(point, &text);
410+
view.set_point(point_after, buffer);
410411
self.mark = None;
411412
self.set_status("Yanked", StatusKind::Success);
412413
AppAction::Continue
@@ -427,7 +428,7 @@ impl AppState {
427428
}
428429
crate::input::Key::Backspace => {
429430
if let Some(input) = self.command_line.as_mut() {
430-
input.pop();
431+
text::pop_grapheme(input);
431432
}
432433
AppAction::Continue
433434
}
@@ -534,7 +535,7 @@ impl AppState {
534535
return AppAction::Continue;
535536
};
536537

537-
let start = view.point().saturating_add(1).min(buffer.len_chars());
538+
let start = buffer.next_grapheme_boundary(view.point());
538539
self.find_search_match(buffer, view, &query, start)
539540
}
540541

@@ -752,7 +753,7 @@ fn kill_line_range(buffer: &Buffer, point: usize) -> Option<Range<usize>> {
752753
if point < line_end {
753754
Some(point..line_end)
754755
} else {
755-
Some(point..point + 1)
756+
Some(point..buffer.next_grapheme_boundary(point))
756757
}
757758
}
758759

@@ -1012,6 +1013,24 @@ mod tests {
10121013
assert!(!buffer.is_dirty());
10131014
}
10141015

1016+
#[test]
1017+
fn command_line_backspace_removes_one_complete_grapheme() {
1018+
let mut app = AppState::default();
1019+
let mut keymap = Keymap::new();
1020+
let mut buffer = buffer_with_text("notes.txt", "old");
1021+
let mut view = View::new();
1022+
1023+
app.handle_key(Key::Meta('x'), &mut keymap, &mut buffer, &mut view);
1024+
for ch in "e\u{301}👨‍💻".chars() {
1025+
app.handle_key(Key::Char(ch), &mut keymap, &mut buffer, &mut view);
1026+
}
1027+
1028+
app.handle_key(Key::Backspace, &mut keymap, &mut buffer, &mut view);
1029+
assert_eq!(app.command_line.as_deref(), Some("/e\u{301}"));
1030+
app.handle_key(Key::Backspace, &mut keymap, &mut buffer, &mut view);
1031+
assert_eq!(app.command_line.as_deref(), Some("/"));
1032+
}
1033+
10151034
#[test]
10161035
fn bare_slash_and_help_commands_list_available_commands() {
10171036
for command in ["/", "/help", "/commands"] {
@@ -1101,6 +1120,62 @@ mod tests {
11011120
assert_eq!(app.status_message.as_deref(), Some("Cut region"));
11021121
}
11031122

1123+
#[test]
1124+
fn selection_cut_yank_and_undo_keep_graphemes_whole() {
1125+
let mut app = AppState::default();
1126+
let mut keymap = Keymap::new();
1127+
let mut buffer = buffer_with_text("notes.txt", "a👨‍💻🇺🇸b");
1128+
let mut view = View::new();
1129+
1130+
app.handle_key(Key::Ctrl('f'), &mut keymap, &mut buffer, &mut view);
1131+
app.handle_key(Key::Ctrl(' '), &mut keymap, &mut buffer, &mut view);
1132+
app.handle_key(Key::Ctrl('f'), &mut keymap, &mut buffer, &mut view);
1133+
app.handle_key(Key::Ctrl('f'), &mut keymap, &mut buffer, &mut view);
1134+
assert_eq!(app.active_region(&buffer, &view), Some(1..6));
1135+
1136+
app.handle_key(Key::Ctrl('w'), &mut keymap, &mut buffer, &mut view);
1137+
assert_eq!(buffer.text(), "ab");
1138+
assert_eq!(app.kill_ring.as_deref(), Some("👨‍💻🇺🇸"));
1139+
assert_eq!(view.point(), 1);
1140+
1141+
app.handle_key(Key::Ctrl('y'), &mut keymap, &mut buffer, &mut view);
1142+
assert_eq!(buffer.text(), "a👨‍💻🇺🇸b");
1143+
assert_eq!(view.point(), 6);
1144+
1145+
app.handle_key(Key::Command('z'), &mut keymap, &mut buffer, &mut view);
1146+
assert_eq!(buffer.text(), "ab");
1147+
assert_eq!(view.point(), 1);
1148+
}
1149+
1150+
#[test]
1151+
fn cut_and_history_keep_point_after_graphemes_merged_by_the_edit() {
1152+
let mut app = AppState::default();
1153+
let mut keymap = Keymap::new();
1154+
let mut buffer = buffer_with_text("notes.txt", "🇦x🇧");
1155+
let mut view = View::new();
1156+
1157+
app.handle_key(Key::Ctrl('f'), &mut keymap, &mut buffer, &mut view);
1158+
app.handle_key(Key::Ctrl(' '), &mut keymap, &mut buffer, &mut view);
1159+
app.handle_key(Key::Ctrl('f'), &mut keymap, &mut buffer, &mut view);
1160+
app.handle_key(Key::Ctrl('w'), &mut keymap, &mut buffer, &mut view);
1161+
assert_eq!(buffer.text(), "🇦🇧");
1162+
assert_eq!(view.point(), 2);
1163+
1164+
app.dispatch_command(crate::commands::Command::Undo, &mut buffer, &mut view);
1165+
assert_eq!(buffer.text(), "🇦x🇧");
1166+
assert_eq!(view.point(), 2);
1167+
app.dispatch_command(crate::commands::Command::Redo, &mut buffer, &mut view);
1168+
assert_eq!(buffer.text(), "🇦🇧");
1169+
assert_eq!(view.point(), 2);
1170+
1171+
let mut buffer = buffer_with_text("notes.txt", "e\n\u{301}x");
1172+
let mut view = View::new();
1173+
view.move_forward_char(&buffer);
1174+
app.handle_key(Key::Ctrl('k'), &mut keymap, &mut buffer, &mut view);
1175+
assert_eq!(buffer.text(), "e\u{301}x");
1176+
assert_eq!(view.point(), 2);
1177+
}
1178+
11041179
#[test]
11051180
fn editing_after_mark_clears_the_region() {
11061181
let mut app = AppState::default();

0 commit comments

Comments
 (0)