feat(text): Notepad word wrap + undo/redo + the wrapText host op (gpui LineWrapper) - #309
Draft
doodlewind wants to merge 3 commits into
Draft
feat(text): Notepad word wrap + undo/redo + the wrapText host op (gpui LineWrapper)#309doodlewind wants to merge 3 commits into
doodlewind wants to merge 3 commits into
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'sWrapMap— application layer — calls it and owns the wrapped coordinate space, caret movement and hit testing on top. PocketJS now has the identical split:LineWrapperwrapTextop (spec 43)WrapMapnotepad.tsvisual segmentsThe 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 overmeasureText.measure_runresolves 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.native_wrap— gpui's ownLineWrapper, resolved through the sameTextConfigasnative_measureand the painter;hosts/macosinstalls it next to the measurer under--native-text. Measurement, wrap and paint stay one provider.ui_wrap_text/ui_wrap_text_ptrstaging exports;wasm-ops.jsfeature-detects them.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. OneapplyEditdoor 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).bun run test12/12;pocket checkclean; gpui + macos clippy/fmt clean;bun run contractgreen (spec.rs regenerated).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