Skip to content

fix(core/editor-view): snap visual cursor columns to grapheme boundaries - #1329

Open
3aKHP wants to merge 2 commits into
anomalyco:mainfrom
3aKHP:vesicle/fix-1289-cursor-boundary
Open

fix(core/editor-view): snap visual cursor columns to grapheme boundaries#1329
3aKHP wants to merge 2 commits into
anomalyco:mainfrom
3aKHP:vesicle/fix-1289-cursor-boundary

Conversation

@3aKHP

@3aKHP 3aKHP commented Aug 4, 2026

Copy link
Copy Markdown

Fixes #1289

Rebase note (2026-08-27)

Rebased onto current main across the #1391 source move (packages/core/src/zigpackages/native/src) and the #1428 wrapped-layout rewrite (VirtualLine.source_col_offsetsource_col_start; the unsnapped arithmetic this PR targets is unchanged, now at editor-view.zig:655). getVisualEOL keeps the newer viewport/occupancy computation from #1393 and only routes its final visual→logical mapping through the snapped path. One test expectation was aligned with #1393 semantics: under cell occupancy a forward inclusive selection stores the exclusive end, so the assertion is sel.end == cursor.offset + width(签) rather than sel.end == cursor.offset.

Sensitivity on this base: without the fix, 8 of the 10 new tests fail; the two selection-extension tests pass because #1393's occupancy machinery already snaps the selection-sync path — the direct-move path (moveUpVisual/moveDownVisual) remains broken. With the fix, all 10 pass.

Summary

Vertical cursor movement (moveUpVisual/moveDownVisual) preserves a desired visual column measured in display cells. When the target row's grapheme boundaries differ from the row the column was measured on — e.g. width-2 CJK graphemes shifted by an odd-width ASCII char — the desired column can fall strictly inside a grapheme. visualToLogicalCursor committed it verbatim:

const clamped_visual_col = @min(visual_col, vline.width_cols);
const logical_col = vline.source_col_start + clamped_visual_col;

The terminal caret then renders between the two cells of a wide glyph, and Shift+Up/Down (same code path) can produce a selection endpoint inside a grapheme. All column/offset units here are display-cell widths (coordsToOffset = line_start_weight + col), so an interior column is not a legal cursor position — it conflates display cells with grapheme boundaries.

Reproduction (headless) is in #1289: buffer 的[代码签名政策]( / 因此签名批准者角色, cursor after (visual col 5), move down → col 5 lands inside (cells 4..6).

Fix

Snap the requested visual column to the leading (line-start-side) boundary of the containing grapheme cluster before committing the cursor:

  • New iterator helper snapColToGraphemeBoundary (text-buffer-iterators.zig), placed next to getGraphemeWidthAt/getPrevGraphemeWidth whose segment-walk pattern it mirrors. It reuses utf8.findPosByWidth(..., include_start_before=false, width_method), so it is grapheme-cluster-aware (combining marks, emoji/ZWJ) and width-method-aware; no new width tables. One subtlety: findPosByWidth's final-cluster tail path reports the trailing boundary when the column sits inside the last cluster of a chunk — the helper detects columns_used > local_col and steps back one cluster width via utf8.getPrevGraphemeStart.
  • visualToLogicalCursor snaps after clamping (central fix; covers Up/Down, Shift+Up/Down, and mouse/TS callers).
  • getVisualEOL and makeCursorVisible had the same unsnapped col arithmetic; both now route through the snapped mapping.

Leading-boundary policy is deterministic, keeps the caret on the grapheme it was aimed at, and composes with clampVisualColToStayOnVisualRow (#1078), whose one-cell step back from a wrap boundary can itself land inside a trailing wide grapheme — leading snap resolves that back onto the same visual row instead of drifting onto the next row's start.

Legal boundaries, column 0, end-of-line, and ASCII-only text are identity mappings (verified by the unchanged pre-existing suite).

Tests

10 new tests in packages/native/src/tests/editor-view_test.zig:

  • exact issue repro: move down onto interior column snaps to 's leading boundary (col 4), plus the direct visualToLogicalCursor(1, 5) mapping
  • move up with the same boundary mismatch
  • Shift+Up/Down selection extension ends on a legal grapheme boundary (mirrors the TS path: moveCursorUp/DowngetVisualCursorsetLocalSelection/updateLocalSelectionsyncCursorToSelectionFocus)
  • vertical moves between soft-wrapped visual rows of one logical line never split wide graphemes
  • short and empty target rows: clamping without splitting, desired-col persistence, round trip
  • combining-mark cluster and emoji/ZWJ cluster boundaries
  • down-down-up-up round trip returns to the original column without drift
  • inserting at the snapped cursor keeps graphemes intact (full-text equality)

zig build test -Dtest-filter="EditorView": 118/118 passed. Full zig build test: 2099/2108 (8 skipped; the one failure is an environment-dependent X11 clipboard test that fails identically on pristine main). zig fmt clean. TS: bun test src/editor-view.test.ts 73/73 against the rebuilt native library (including the aligned emoji vertical-nav expectation from the companion commit: col 8 sits inside 🌟 cells 7–8, the snapped leading boundary is 7); edit-buffer/buffer/text-buffer TS suites: 218 passed.

Out of scope (deliberately; happy to follow up)

  • Mouse selection: coordsToCharOffset (text-buffer-view.zig) can commit a raw interior-cell offset into a selection, and syncCursorToSelectionFocus (editor-view.zig) can then put the cursor there when clicking the second cell of a wide grapheme. Not changed here: snapping in coordsToCharOffset would alter selection-range semantics (selection offsets deliberately snap at extraction time), and snapping only the cursor would decouple cursor.offset from the selection focus offset the TS layer relies on. Shift+arrow selection is covered by this PR because it anchors from the already-snapped committed cursor.
  • Logical-line moves: EditBuffer.moveUp/moveDown (edit-buffer.zig:515,538) contain the same @min(desired_col, line_width) unsnapped arithmetic. EditBufferRenderable uses moveUpVisual/moveDownVisual, so the reported defect path is unaffected; the same one-line helper applies if you want a follow-up.

@simonklee

Copy link
Copy Markdown
Member

VESICLE_FORK.md ..

@3aKHP
3aKHP force-pushed the vesicle/fix-1289-cursor-boundary branch from 5009f3b to 1ba266e Compare August 4, 2026 10:44
@3aKHP

3aKHP commented Aug 4, 2026

Copy link
Copy Markdown
Author

VESICLE_FORK.md ..

Sorry for my carelessness — that file is internal to our fork and should not have been part of this PR. My mistake. The branch has been rebased onto the pristine upstream tag, and the diff now contains only the intended Zig changes. Thank you for the time you spent looking at it.

@3aKHP
3aKHP force-pushed the vesicle/fix-1289-cursor-boundary branch from b355022 to 06b9083 Compare August 7, 2026 12:54
@3aKHP

3aKHP commented Aug 7, 2026

Copy link
Copy Markdown
Author

Rebased onto current main (5329177, including the Zig 0.16 migration) without conflicts. The internal fork file noted earlier remains absent from the diff. This is ready for review when you have time; thank you.

3aKHP and others added 2 commits August 27, 2026 17:23
Vertical movement (moveUpVisual/moveDownVisual) preserves a desired
visual column measured in display cells. When the target row's grapheme
boundaries differ from the source row's (e.g. width-2 CJK graphemes
offset by an odd-width ASCII char), the column can fall strictly inside
a grapheme and was committed verbatim, rendering the caret between the
two cells of a wide glyph and giving Shift+Up/Down an illegal selection
endpoint.

visualToLogicalCursor now snaps the requested column to the leading
boundary of the containing grapheme cluster (grapheme-cluster- and
width-method-aware, reusing utf8.findPosByWidth via the new
snapColToGraphemeBoundary iterator helper). getVisualEOL and
makeCursorVisible route through the same snapped mapping instead of
their own unsnapped col arithmetic. Legal boundaries, column 0,
end-of-line, and ASCII-only lines are unaffected (identity snap).

Fixes anomalyco#1289
…ry snap

The parent commit changed vertical cursor movement to snap visual
columns to grapheme leading boundaries. The JS test still expected the
cursor to land on visualCol 8, the second cell of the wide emoji
grapheme; it now expects the snapped leading boundary 7.
@3aKHP
3aKHP force-pushed the vesicle/fix-1289-cursor-boundary branch from 06b9083 to 841e435 Compare August 27, 2026 09:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Textarea vertical navigation can place the cursor inside a wide CJK grapheme

2 participants