Skip to content

feat(text): Notepad word wrap + undo/redo + the wrapText host op (gpui LineWrapper) - #309

Draft
doodlewind wants to merge 3 commits into
mainfrom
desk98-wrap
Draft

feat(text): Notepad word wrap + undo/redo + the wrapText host op (gpui LineWrapper)#309
doodlewind wants to merge 3 commits into
mainfrom
desk98-wrap

Conversation

@doodlewind

@doodlewind doodlewind commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

desktop

Word wrap, complete: the app-layer layout model and its platform half — the same division of labor Zed uses.

The pattern (settled against Zed's source)

Zed's editor does NOT hand paragraphs to a toolkit text view. gpui provides fonts, per-char advances and LineWrapper (greedy width-fitting with break candidates); the editor's WrapMap — application layer — calls it and owns the wrapped coordinate space, caret movement and hit testing on top. PocketJS now has the identical split:

half Zed PocketJS
"where does this line break at width W" gpui LineWrapper wrapText op (spec 43)
wrapped coordinate space + caret/selection/hit editor WrapMap notepad.ts visual segments

The wrapText op (spec op 43)

wrapText(str, fontSlot, maxW) → u32[] — ascending UTF-16 break columns, empty = the line fits. Optional like every post-16 op; apps without it fall back to the same greedy rules over measureText.

  • engine/core: greedy word wrap (break before the overflowing word, trailing spaces hang, over-wide words char-split) computed over whatever provider measure_run resolves to — atlas advances on baked hosts, the native measurer for native-text apps. Break positions always derive from the metrics that size and paint the text.
  • gpui backend: native_wrap — gpui's own LineWrapper, resolved through the same TextConfig as native_measure and the painter; hosts/macos installs it next to the measurer under --native-text. Measurement, wrap and paint stay one provider.
  • wasm + symbian: ui_wrap_text / ui_wrap_text_ptr staging exports; wasm-ops.js feature-detects them.
  • pocket-ui-surface: the QuickJS op + set_text_wrap.

The app layer (desk98 Notepad)

The Doc keeps logical lines; wrapDoc() projects them onto visual segments {row, from, to}, and render, clicks, drag-select, visual Up/Down/Home/End (with wrap affinity at soft boundaries), selection highlight, scrolling and the IME caret all read that one segment list — reflow on window resize falls out of the reactive width. Edit > Word Wrap is a live checked toggle. desk98 consumes the op when present and keeps the JS greedy as the fallback.

Undo/redo (⌘Z / ⌘⇧Z)

Docs are immutable values, so history is plain snapshot references — O(1) per step (notepad.ts History, pure). Coalescing: typing runs and erase runs collapse into one undo unit each; Enter/paste/cut/Time-Date/New stand alone; a caret move between keystrokes breaks the group (continuity is checked against the doc the last edit produced). Undo restores caret and selection. One applyEdit door for every mutation (no-op edits record nothing); Undo/Redo rows join the Edit + context menus with live disabled states.

Verified

  • tests/wrap-op.test.ts: the op column-for-column equal to the JS fallback over desk98's committed W95FA atlas (275 assertions — both reduce to the same additive advances).
  • Core unit tests: greedy rules + native-wrapper override (122 core tests green).
  • 4 pure wrap-model test groups (greedy/hanging/char-split, caret↔row mapping with affinity, visual movement, selection∩segment); the desk-companion sim journey extended (a two-keystroke run undoes with ONE ⌘Z, replays with ⌘⇧Z); bun run test 12/12; pocket check clean; gpui + macos clippy/fmt clean; bun run contract green (spec.rs regenerated).
  • Sim renders through the op path are pixel-identical to the JS path (narrow-window reflow, cross-row selection, the ✓ menu item).

Follow-ups (not in scope): migrating apps/note/apps/im's own wrap math onto the op; richer break rules (UAX #14 CJK classes) — they'd land in ONE place now, core + LineWrapper already handle the western rules identically.

🤖 Generated with Claude Code

doodlewind and others added 2 commits August 20, 2026 16:02
The Doc keeps LOGICAL lines and the caret/selection stay in logical
(row, col) coordinates; wrapDoc() (notepad.ts, pure) projects each line
onto visual segments {row, from, to} under a pixel width, and every
mapping in both directions goes through that one segment list — the
render (NotepadView), hit-testing (clicks, drags), caret movement
(Up/Down/Home/End step VISUAL rows), selection highlight (spans
intersected per segment), scrolling and the IME caret intent all share
one layout truth, so reflow on window resize falls out of the reactive
width for free.

- Greedy word wrap: break before the overflowing word, trailing spaces
  hang on the upper row (classic Notepad), words wider than a whole row
  split at character level. Widths come from the additive-advance
  measureText, cached per token (bounded).
- Wrap affinity: a caret at a soft-wrap boundary belongs to the next
  row's start unless it carries `end` (End key, clicks past a wrapped
  row's text) — no visual caret jumps.
- Edit > Word Wrap is a real toggle now (checked menu items render a
  checkmark, gen-icons check-16); wrap off = maxW Infinity, the same
  code path with one segment per line.
- The welcome text's paragraphs become single logical lines — resizing
  the window reflows them live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Soft-wrap gains its platform half, the same division of labor Zed uses:
the platform answers "where does this line break at this width in this
font", the application owns the wrapped coordinate space on top.

- spec op 43 `wrapText(str, fontSlot, maxW) -> u32[]` (ascending UTF-16
  break columns, empty = fits). Optional like every post-16 op: hosts
  that predate it — apps fall back to the same greedy rules over
  measureText.
- engine/core: Fonts::wrap_text — greedy word wrap (break before the
  overflowing word, hanging trailing spaces, char-split for over-wide
  words) over whatever provider measure_run resolves to, so break
  positions always derive from the metrics that size and paint the text;
  Ui::set_text_wrap installs a full native wrapper whose positions win.
- gpui backend: native_wrap — gpui's own LineWrapper (the machinery
  Zed's editor WrapMap consumes), resolved through the same TextConfig
  as the measurer and painter; hosts/macos installs it next to
  native_measure under --native-text.
- wasm + symbian: ui_wrap_text/ui_wrap_text_ptr (FRAMEBUFFER-style
  staging); hosts/web wasm-ops feature-detects the export.
- pocket-ui-surface: op!("wrapText") + set_text_wrap, the QuickJS side.
- desk98 consumes the op when present (segsFromBreaks) and keeps the JS
  greedy as the no-op-host fallback; the visual-segment model, caret
  affinity and selection mapping are unchanged.
- tests: core greedy + native-override unit test;
  tests/wrap-op.test.ts pins the op column-for-column equal to the JS
  fallback over desk98's committed W95FA atlas (both reduce to the same
  additive advances).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@doodlewind doodlewind changed the title feat(desk98): word wrap in Notepad — logical lines, visual segments feat(text): Notepad word wrap + the wrapText host op (gpui LineWrapper) Aug 20, 2026
Docs are immutable values (every edit returns a fresh one), so history is
plain snapshot references — O(1) per step, structure shared (notepad.ts
History, pure + unit-tested). Coalescing: consecutive edits of the same
continuous kind collapse into one undo unit (typing runs, erase runs);
Enter/paste/cut/Time-Date/New always stand alone; continuity is checked
against the doc the last recorded edit produced, so a caret move or click
between keystrokes breaks the group without recording anything itself.
Undo restores caret AND selection (the snapshot carries the anchor).

Every mutation routes through one applyEdit(w, kind, next) door (no-op
edits record nothing, docEquals); plain caret/selection moves bypass it.
Chords: ⌘Z undo, ⌘⇧Z redo; Undo/Redo rows join the Edit menu and the
context menu with live disabled states (menus already build their items
at open). The desk journey covers the loop end to end: a two-keystroke
typing run pulls back out with ONE ⌘Z and replays with ⌘⇧Z.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@doodlewind doodlewind changed the title feat(text): Notepad word wrap + the wrapText host op (gpui LineWrapper) feat(text): Notepad word wrap + undo/redo + the wrapText host op (gpui LineWrapper) Aug 20, 2026
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.

1 participant