Skip to content

feat(gpui): gpui render backend, macos-app target with native text layout, and the desktop benchmark - #293

Draft
doodlewind wants to merge 8 commits into
mainfrom
north-pancreas
Draft

feat(gpui): gpui render backend, macos-app target with native text layout, and the desktop benchmark#293
doodlewind wants to merge 8 commits into
mainfrom
north-pancreas

Conversation

@doodlewind

@doodlewind doodlewind commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

The DrawList grows a second backend class: gpui (Zed's Metal renderer) on macOS, beside the portable baked-text family (wgpu, software raster, GE, PPA, GLES2). Same JSX + Tailwind guest, same deterministic frame transactions and animation engine — the one capability that legitimately differs per backend, text layout, becomes an explicit contract. Full design: docs/BACKENDS.md.

The text capability contract

  • text.glyphs.baked (portable): compile-time Inter atlases, core-owned measurement, GLYPH_RUN, byte-deterministic pixels across hosts.
  • text.layout.native (new): the host installs a core text measurer before the guest mounts (Ui::set_text_measure); taffy leaf sizes, the measureText op and painted glyphs all observe one provider — CoreText through gpui. Full OS font fallback (CJK, color emoji), no runtime atlas baking, no tofu. Pixels are deterministic per host, not across hosts — hence a different id, per the platforms.ts header rule.

Core mechanics (backend-neutral; zero behavior change until a host installs the hook):

  • New DRAW_OP.TEXT_RUN (9): the run string's UTF-8 bytes are packed into the word stream (8 header words — slot/align/origin/boxW/lineHeight/color/byteLen — plus payload), so the DrawList stays the complete Vec<u32> pixel truth: snapshots, demand-render hashes and damage word-diffs are exact by construction, never a digest. The measurement provider is chosen ONCE at layout build and recorded on the node (Node::text_native): native iff a measurer is installed, tracking is 0 and the subtree declares no non-translation transform — paint follows the record unconditionally, so a node's box and glyphs always come from the same provider (rotated/tracked text = the baked pair on both sides). Partially clipped runs are scissor-bracketed (the origin words are f32 and exempt from the i16 clip guarantee).
  • The raster, damage and wgpu interpreters learn to skip the op — it is a known member of the closed set that fixed-function backends never receive.
  • 5 new core tests pin the gates (native_measure_routes_layout_and_emits_text_run, fallbacks, scissor bracketing, styleHash content tracking, hook clearing).

The backend and host

  • engine/backends/gpui (standalone, like esp32p4-ppa — gpui must not enter the ubuntu release workspace): the DrawList → gpui interpreter. Quads/gradients as vector geometry, baked glyph-cell blitting for portable-text apps, scissors as recursive with_content_mask scopes, TEXT_RUN shaping with kern/liga/calt disabled so prefix-sum caret math stays exact. Gouraud TRI/TEX_TRI batches (rotated gradients/images, 3D subtrees) raster through pocketjs_core::raster into cached bbox-local images at target density — the portable rasterizer as a pixel-exact sub-backend. Shaped-line + measured-width caches make a keystroke repaint reshape one line, not the document.
  • hosts/macos (standalone lone-bin, like pocketbook): the stock host of the new macos-app target (hostAbi 3 — the desktop wire generation macos-widget already speaks; form: "window"; acceptsFixed — the slot platforms.ts had reserved). Fixed 60 Hz guest ticks from a foreground timer governor (one guest.frame() + surface.tick() per tick, never from paint; bounded catch-up, drop-backlog resync), demand renders off the DrawList hash, speaks note-widget's svc editor protocol (keyboard, pointer, scroll, clipboard, IME via EntityInputHandler), letterboxes fixed-viewport apps. Governor receipt on exit: pocket-macos: N ticks, M frames rendered.
  • bun run macos <app> (tools/macos.ts): resolves the manifest against macos-app and derives every host flag from the plan (--fixed, --native-text, --editor) — never from convention. Every fixed-viewport console demo admits unchanged (admission matrix now 4 targets wide).

Flagship: the markdown editor

apps/note — the existing full markdown editor (soft wrap, caret math, selection, undo/redo, clipboard, IME) — runs unmodified on the gpui host; the only diff is one manifest line (enhances: ["text.layout.native"]). Same JSX, native text: proportional CoreText metrics, CJK and color emoji straight from input, resizable window.

  • Acceptance: bun run macos note --proof — scripted pencil-toggle click + typing through the svc protocol, debounced autosave round-trips the file.
  • Editor polish that rode along: double-click word selection in both modes (browser semantics: runs for spaces/punctuation, nearest-boundary forgiveness, CJK/emoji word class, atomic code blocks in preview; detection on the virtual clock so replays stay deterministic), and the sample doc's "same bytes as the PSP build" line now states the real two-backend contract.
  • Found & fixed along the way: a guest measuring an emoji prefix sliced between UTF-16 surrogate halves crashed the frame transaction through rquickjs's strict string conversion. Host string ops now decode lossily (LossyString) — a legal JS string can never abort a frame again.

Benchmark vs Tauri and Electron

Split into the stacked #294 per review (the harness, comparison apps and results review on their own boundary). Headline, same machine and protocol:

app procs cold start (ms) idle RSS (MB) idle CPU (%) storm CPU (%) storm RSS (MB) disk (MB)
pocket (gpui) 1 149 83 3.65 47.9 87 10
tauri v2 4 380 193 3.45 16.4 208 9
electron 5 301 382 0.55 39.6 487 242

Apple M3 Max, macOS 26.5.2, measured on the post-review TEXT_RUN code — one process, a quarter of Electron's memory, 2x faster cold start, 1/24th its disk; the storm confirmed complete on all three (~3600 chars). Idle pcpu at these magnitudes drifts ±1.5 points across runs (pocket and Tauri alike); the structural idle number is the governor receipt: 84 repaints / 2400 idle ticks (~2/s, the caret square wave's edges).

Honest caveats (in #294's report): the pocket storm CPU ramps with document length — the note re-parses the whole growing document per keystroke through the QuickJS interpreter (profiled: JS_CallInternal + regex, not rendering — incremental note layout is the follow-up).

The caret itself became a measured fix: animate-pulse (a continuous opacity sine) changed the DrawList every tick, so an idle editor repainted at 60 fps on EVERY host. It is now a browser-style square wave (500 ms on/off, solid while typing, ON-phase restart after a 0.6 s rest) whose constant segments demand rendering skips: idle-in-edit repaints fell from 88% of ticks to 3.5% (84/2400 — exactly the wave's edge count) on the gpui host, with the same word-stream skip on the wgpu widget host — one app-level fix, both backends benefit. (Repaint rate is the robust metric here; single-run pcpu medians at low single digits drift ±1.5 points.)

CI, tests, validation

  • .github/workflows/macos.yml — the first macOS lane (core tests + clippy on the gpui crate + host build on macos-latest, path-filtered like esp32p4.yml).
  • bun run test: all 11 stages green. cargo test engine/core (118) + desktop workspace green. Goldens untouched by construction (no portable host installs the hook; clearing_native_measure_restores_baked_goldens_path pins the escape).
  • Admission + feature resolution pinned in tests/platform-contracts.test.ts (macos-app column, note native-vs-widget feature split).

Review round 1 — resolved

  1. TEXT_RUN hash collisions → the run bytes now ride in the words; no side table, no digest, exact comparison everywhere (text_run_words_are_the_complete_pixel_truth).
  2. measure/paint provider mismatch on transformed text → provider recorded per node at layout build; both sides always agree (tracked_and_transformed_runs_use_the_baked_pair_on_both_sides, recorded_provider_survives_a_paint_only_transform).
  3. capabilities conflated with the note svc protocol__pocketResizeViewport fires for every dynamic app inside the tick transaction (the hero-resize regression); --editor = the note companion adapter only, never derived from input.text; the registry comment states each capability's delivery path plainly. A host-generic pointer feed is real follow-up work: the touch packing's 9-bit axes (≤511 px) cannot carry an app-frame window, so it needs new framework surface — pointer/text/IME/clipboard ride the companion adapter today, the same stock-host bar macos-widget set in feat(pocket-widget): flat widget runtime — Pocket Note, a markdown sticky on the desktop #129.
  4. Rot: hosts/macos passes clippy -D warnings and CI runs it; the workflow path filter covers contracts/spec/**, tools/macos.ts, apps/note/**; tools/macos.ts is negated out of the npm tarball; the benchmark is split into feat(bench): desktop editor benchmark — gpui vs Tauri v2 vs Electron #294.

Known limitations / follow-ups

  • DRAW_OP id 9 contention: the wild draft (feat(wild): playable-world kernel + BotW-style meadow on scene3d #265) also claims op 9 for scene3d — whichever lands second renumbers (one-line spec.ts change + bun run gen).
  • Rotated/scaled/tracked text uses the baked pair end-to-end on the gpui host (by design, for exactness); complex-script caret fidelity would need a run-aware caret API beyond prefix sums.
  • Host-generic pointer delivery (hover=focus without a companion) needs a framework absolute-pointer channel — named above.
  • macos-app does not claim text.glyphs.runtime (no runtime atlas extension in this host — native mode makes it unnecessary for the flagship).
  • The pocket CLI gets no macos-app backend yet; tools/macos.ts follows the tools/note.ts wrapper precedent. The host and gpui crate stay git-only; the files map gained exactly one negation (!tools/bench-desktop, with its governed-list twin) so the benchmark fixtures' cargo target/ can never pack — the tarball tripwire caught it.
  • Storm CPU is an app-level O(n) (note's full-document re-wrap per keystroke) — incremental layout in the note is the interesting follow-up, not backend work.

🤖 Generated with Claude Code

doodlewind and others added 8 commits August 18, 2026 06:13
The portable backend family (wgpu, software raster, PPA, GLES2) keeps
executing the DrawList against compile-time baked font atlases. This adds a
second backend class on the same DrawList contract: engine/backends/gpui
paints through Zed's gpui/Metal renderer, and text measurement + shaping
move to the host text system (CoreText) when an app opts in.

Core (backend-neutral, no behavior change unless a host installs the hook):
- text::MeasureFn — a pluggable native text measurer on Fonts;
  Ui::set_text_measure installs it before mount. measure_run, the taffy
  MeasureCtx and the measureText op all route through the one provider.
- DRAW_OP.TEXT_RUN (9): translation-only tracking-0 runs emit the run string
  + style through a DrawList side table; a styleHash word keeps identical
  word streams pixel-identical (demand-render hashes stay truthful). Tracked,
  scaled and rotated runs keep the baked GLYPH_RUN pair on BOTH the measure
  and paint sides. Partially clipped runs are scissor-bracketed.
- raster/damage/wgpu interpreters learn to skip the op (it never reaches
  fixed-function backends); 5 new core tests pin the gates.

Registry: text.layout.native capability + the macos-app target profile
(hostAbi 3, window form, acceptsFixed — the slot platforms.ts reserved), so
every fixed-viewport console demo admits unchanged, size-locked.

engine/backends/gpui (standalone, like esp32p4-ppa): the DrawList -> gpui
interpreter (quads, gradients, glyph-cell blitting for baked apps, content-
mask scissors, TEXT_RUN shaping via shape_line with kern/liga off so
prefix-sum caret math stays exact) plus a pixel-exact escape hatch: gouraud
TRI / TEX_TRI batches raster through pocketjs_core::raster into cached
local images at target density.

hosts/macos (standalone lone-bin, like pocketbook): gpui window host of the
macos-app target. Fixed 60 Hz guest ticks from a foreground timer governor
(one guest.frame + surface.tick per tick, never from paint), demand renders
off the DrawList hash, speaks note-widget's svc editor protocol (keyboard,
pointer, scroll, clipboard, IME through EntityInputHandler), letterboxes
fixed-viewport apps. bun run macos <app> resolves the manifest against
macos-app and derives every host flag from the plan.

Also: host string ops now decode JS strings lossily (LossyString) — an app
measuring an emoji prefix sliced between surrogate halves is legal JS and
must never abort the frame transaction.

apps/note enhances text.layout.native: the same unmodified JSX markdown
editor now runs with CoreText metrics, full CJK + color emoji, no runtime
atlas baking. Proof: bun run macos note --proof (scripted click + typing,
debounced autosave round-trips through the gpui host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tools/bench-desktop.ts measures the pocket note on the gpui host against
byte-identical web editors shelled by Tauri v2 and Electron: cold start to
each app's own first-painted-frame READY report, hands-off idle and a
120 chars/s typing storm through each stack's real edit path, ps process-
tree medians (WebKit XPC helpers attributed by spawn-delta — Tauri's
WebContent/GPU processes are launchd children), footprint for physical
memory. The gpui renderer gains shaped-line and measured-width caches so a
keystroke repaint reshapes one line, not the document. docs/BACKENDS.md
names the backend split; .github/workflows/macos.yml is the first macOS CI
lane (core tests + clippy + host build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nch fixtures

Final measured run (M3 Max, methodology + fairness caveats in the report):
the pocket note on the gpui host takes 1 process / 84 MB idle RSS / 145 ms
cold start / 10 MB disk against Tauri v2 (4 procs, 192 MB, 391 ms) and
Electron (5 procs, 382 MB, 328 ms, 242 MB). Storm completion is now
verified (STORM-DONE), the Tauri window is explicitly focused (an
unfocused WKWebView throttles timers and reads fiction), and the
footprint column is dropped from the table — Electron's hardened helpers
refuse task inspection, so RSS is the uniform metric.

The benchmark fixtures' cargo target/ was packing into the npm tarball
through the wholesale "tools" files entry (the v0.8.0 E415 failure mode —
the tests/npm-package.test.ts tripwire caught it): tools/bench-desktop is
now a governed negation in the files map. Tauri codegen (gen/) untracked;
gpui backend passes clippy -D warnings for the macOS CI lane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The caret was animate-pulse — a continuous opacity sine that changes the
DrawList every tick, so an idle editor repainted at 60 fps on every host
(88% of ticks; ~3.6% CPU on both the gpui and wgpu hosts). Replace it with
a browser-style square wave (app-local pocket.config.ts theme: 500 ms on /
500 ms off, hard edges via a same-frame twin keyframe) plus the browser
input discipline: the caret is SOLID while typing or moving and resumes
blinking from its ON phase after a 0.6 s rest (a <Show> swap remounts the
animated node, restarting the baked timeline at frame 0).

The square wave's constant segments keep the DrawList byte-stable between
edges, so every demand-rendering host skips them: idle-in-edit repaints
drop from 2115/2400 ticks to 83/2400 (~2 fps, exactly the edge count), CPU
from 3.6% to 0.95% on the gpui host and 3.65% to 0.7% on the wgpu widget
host. Same measurement protocol as docs/bench.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Double click selects the word under the pointer in BOTH modes, browser
semantics throughout: whitespace and punctuation select as runs, a click
just past a word's right half still selects the word (caretFromX rounds to
the nearest boundary), CJK and surrogate pairs ride the non-ASCII word
class, code blocks stay atomic in preview (rowSelSpan granularity), and a
drag after the double click extends from the word start. Detection rides
the virtual clock (0.4 s / 3 px on the svc press stream) so replays stay
deterministic; wordRangeAt is regex-free QuickJS-portable math with unit
coverage.

The sample doc's 'same bytes as the PSP build' line predates the backend
split — it now names the real contract (same core, same DrawList; wgpu
paints baked atlases, gpui paints native text) and the two launch
commands, and the charset-anchor comment drops the stale tofu caveat
(runtime glyph baking and native shaping both exist now).

Verified end-to-end on the gpui host: scripted double click on 'Pocket' +
typing X autosaves '# X Note'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… svc-independent capabilities

Three review blockers on #293, each fixed at the contract level:

1. TEXT_RUN carried a 32-bit content hash while the run string lived in a
   side table — colliding texts could leave identical word streams with
   different pixels, silently defeating demand-render hashes and damage
   word-diffs, and breaking the "DrawList is the complete Vec<u32>"
   contract. The op now packs the run string's UTF-8 bytes INTO the words
   (8 header words + payload; slot/align/lineHeight all in-stream) and the
   side table is gone: snapshots, hashes and diffs are exact by
   construction, not probabilistic. All interpreters skip the variable
   length; the gpui backend decodes from the stream.

2. Measurement and paint could disagree on rotated/scaled text (native-
   sized box, baked glyphs). The provider is now chosen ONCE at layout
   build (native iff a measurer is installed, tracking is 0 and the
   subtree declares no non-translation transform) and RECORDED on the node
   (Node::text_native); paint follows the record unconditionally, and the
   style-dirty restyle path can only flip it for a node-local tracking
   change. Rotated/transformed text takes the baked pair on BOTH sides —
   pinned by tests including a transformed-ancestor case and a
   paint-only-transform consistency case.

3. macos-app capabilities were conflated with the note's svc protocol.
   display.viewport.live is now host-generic: every dynamic app receives
   the framework's __pocketResizeViewport hook inside the tick transaction
   (the hero-resize regression); --editor derives from the app being the
   note companion, never from input.text; and the registry comment states
   the delivery paths plainly — buttons + live viewport host-generic,
   pointer/text/IME/clipboard via the companion adapter today (the
   macos-widget stock-host bar), with the host-generic pointer feed named
   as follow-up (the touch packing's 9-bit axes cannot carry a 4096-px
   window, so it needs new framework surface).

Rot: hosts/macos passes clippy -D warnings and CI now runs it; the macOS
workflow path filter covers contracts/spec, tools/macos.ts and apps/note;
tools/macos.ts is negated out of the npm tarball (its build inputs are
git-only — a published entry point that cannot run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Tauri/Electron comparison apps, the runner and the results move to a
stacked PR so the backend/host/contract surface reviews on its own
boundary. The npm files negation stays — it guards the re-add.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@doodlewind

Copy link
Copy Markdown
Collaborator Author

三个 P1 均已在 17e1881 修复,rot 项一并处理,benchmark 已拆分为 stacked #294。逐条回应:

1. TEXT_RUN 哈希碰撞 → 词流现在直接携带字节。 你的判断成立:短哈希不能替代精确内容,且违反了 "DrawList 是完整 Vec" 的契约。修复取消了 side table 和 styleHash:TEXT_RUN 现在把 run 字符串的 UTF-8 字节打包进词流(8 个头部词 + payload,slot/align/lineHeight 全部随流),所有 hash/damage/快照消费者天然精确——是字节比较,不是摘要。各解释器按变长跳过;text_run_words_are_the_complete_pixel_truth 钉住该性质。你构造的碰撞对现在两帧词流必然不同。

2. 测量/绘制 provider 不一致 → provider 在布局构建期决策一次并记录在节点上。 原实现确实矛盾(native 高度布局 + baked 图集绘制,测试还钉错了语义)。现在 layout.rs build() 决策(native ⇔ 测量器已装 ∧ tracking=0 ∧ 子树未声明非平移 transform),记录为 Node::text_native,绘制无条件跟随记录;style-dirty 增量路径只允许节点本地 tracking 变化翻转记录。旋转/缩放/tracking 文本在两侧一致地走 baked 对——tracked_and_transformed_runs_use_the_baked_pair_on_both_sides(含祖先 transform 用例)与 recorded_provider_survives_a_paint_only_transform(绘制期 transform 不得撕裂已记录的 pair)钉住。

3. 能力与 Note 协议解耦。

  • hero resize 回归已修:每个动态视口应用在 tick 事务内收到框架的 __pocketResizeViewport 钩子(不再只改 Rust core);note 的 svc resize 只是其 companion 方言的叠加,幂等。
  • --editor 不再由 input.text 推导:tools/macos.ts 明确它是 pocket-note 的 companion 适配器(app 协议,非能力)。
  • 指针的宿主级通用化是真实的后续工作而非疏忽:现有 touch 打包是 9 bit/轴(≤511 逻辑像素),装不下 app-frame 窗口,需要新的框架层绝对指针通道。在此之前 pointer/text/IME/clipboard 经 companion 适配器交付——与 macos-widget 在 feat(pocket-widget): flat widget runtime — Pocket Note, a markdown sticky on the desktop #129 确立的 stock-host 标准相同,registry 注释已逐能力如实写明交付路径。

Rot 项: hosts/macos 过 clippy -D warnings 且 CI 现在跑它;workflow 路径过滤覆盖 contracts/spec/**tools/macos.tsapps/note/**!tools/macos.ts 负排除出 npm tarball(其构建输入 git-only,发布入口无法运行的问题成立);benchmark 工具+对比应用+结果整体移至 #294(基于本分支 stack,本分支保留 !tools/bench-desktop 负排除守卫合并后的 target/ 打包风险)。

验证:core 119 测试、bun run test 11/11 stage、engine workspace 30 个 test-result、双 clippy、tsc、tarball 守卫、bun run macos note --proof 全绿。

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