18 KiB
pmacs-gpu — design note
Status: implemented and evolving. The GPU frontend attaches to a
daemon and renders + edits: syntax, diagnostics (squiggles), minimap,
peer presence, mouse selection + right-click context menu (protocol
v11), OS clipboard (v11's Signal::Clipboard + arboard), the
incremental-search band (v9/v10), and the minibuffer with a completion
dropdown (v12); command chords forward generally to the daemon keymap.
This note captures the original design contract; the current
per-feature detail lives in the framing/as-built docs alongside it
(in-buffer-search-framing.md, context-menu-framing.md,
gpu-minibuffer-framing.md, gpu-chord-forwarding-framing.md).
This is the post-v1.0 design artifact for the GPU/GUI frontend. It
inherits the contract boundary established by
semantic-frontend-protocol.md and
applies the M10-matured framing discipline (verification-milestone
premise-check, predictive-density, categorical scoring, per-phase audit
docs) to a multi-month effort.
Why this exists
The M11 producer arc shipped without a real consumer. Everything we've
built — StyleSpans (policy A, tree-sitter + LSP), Decorations
(diagnostics + selection), InlineAdornments (inlay hints),
FileStyleSummary (minimap), CursorByte — is a hypothesis about
wire shape until something renders against it. pmacs-gpu is that
consumer. Phase A's adversarial verification of the producer arc is its
load-bearing job; the "read-only viewer that renders text" is a side
effect of validating the wire.
This framing is the most consequential commitment in the note. Phase A is not "build a viewer that works" — it is "exercise every field of every producer-arc message in conditions that would surface shape issues, and ship a viewer as the artifact of having done so."
Contract inheritance
From semantic-frontend-protocol.md's "Contract boundary" section,
verbatim: the instance never learns a pixel. Not viewport pixel size,
not DPI, not font metrics, not glyph advances. The only spatial fact
the instance learns is which buffer byte range is on screen
(FrontendEvent::Viewport).
Corollary, also from the doc: there is no hit-test round trip. The
frontend resolves pixel→offset locally and only ever emits buffer
offsets and edits. pmacs-gpu ships sophisticated client logic
specifically to honor this; if a render path would require the
instance to learn pixels, the verification-milestone premise-check
classifies that as a load-bearing finding (see Finding feedback
loop below).
Toolkit
wgpu + custom + cosmic-text + glyphon. Unambiguous.
Against gpui
The gpui option is more attractive than it should be and worth
recording an unambiguous against-paragraph so this decision isn't
relitigated:
gpui is built around Zed's editor model — a specific text-buffer
abstraction, a specific event-loop shape, a specific approach to
composing editor UI. Pmacs's model differs in load-bearing ways:
CRDT-backed buffers, attach-protocol-driven rendering, Lua-extensible
behavior, daemon-frontend split. Each difference means either reshaping
pmacs to fit gpui (foreign architecture imposed on mature pmacs
design) or reshaping gpui to fit pmacs (rewriting parts of a
framework we don't control). Vendoring forks an upstream we'd have to
track; depending pins us to Zed's release cadence and refactors. Six
months of head-start cost the next five years of upgrade friction.
The wgpu + custom path has the property that every line of code in
the frontend is ours. The "biggest work item: text rendering" is real
but bounded — cosmic-text + glyphon resolve most of it. The
foreign-API surface to misread (the M10.10 library-API-verification
lesson) is far smaller than gpui's.
Why not the alternatives
iced/egui— general-purpose Rust GUI; neither editor-shaped. Immediate-mode (egui) fights long-running editor model.- Tauri / web stack — loses the "pmacs is a real native binary" character; adds JS runtime story.
- GTK or other native widget toolkits — not GPU-first, loses the framing.
Scope phases
Sequential, not alternative. Recording the total honestly at decision-time so future-us doesn't relitigate the timeline:
| Phase | Deliverable | Duration |
|---|---|---|
| A | Adversarial verification of the producer arc, artifact = read-only viewer | 2–3 weeks |
| B | Editing parity with TUI | 2–3 months after A |
| C | Beyond-TUI features (smooth scroll, real inline inlay hints, minimap consumption) | 3–4 months after B |
Total path: ~6 months to TUI parity, ~9–10 months to beyond-TUI. v0.1
of pmacs-gpu is the end of Phase A.
Phase A: adversarial verification
Scope framing
Phase A's success criterion is not "renders correctly." It is "exercises every field of every producer-arc message in conditions that would surface shape issues." The choice of what to render is driven by what the producer arc needs validated, not by what looks good in a demo.
Test corpus
Six static probes + one temporal probe. Each is a planned exercise of a specific wire surface, not an open-file-and-see-what-happens session.
| # | Probe | Wire surface exercised |
|---|---|---|
| 1 | Non-ASCII source (mixed CJK / emoji / combining marks) | UTF-16 col/byte conversion edge cases in StyleSpans LSP path; char_to_byte correctness across encodings |
| 2 | Mixed tree-sitter + LSP file (Rust with rust-analyzer) | The M_B3 composition path — merge_styles behavior when both authorities cover the same byte |
| 3 | Viewport-boundary tokens (token straddles the visible region's edge) | StyleSpans/Decorations dirty-segment edges; M11.4 clip behavior |
| 4 | 100k-line file | FileStyleSummary at scale; per-line dominant-style accuracy; minimap-style consumption |
| 5 | Active diagnostics + multi-frontend PresenceUpdate overlap |
Decorations ↔ PresenceUpdate composition; peer-cursor SO_PEERCRED color stability |
| 6 | \r\n line endings |
line_offsets convention; trailing-newline assumptions across producers |
| 7 | Sustained typing (1000 keystrokes in 30s synthetic) | Temporal probe — CursorByte emission cadence; InlineAdornments suppression timing; dirty-segment propagation under fast edits |
The temporal probe is structurally different from the six static probes. State-shape probes test "does the wire carry the right thing"; the temporal probe tests "do the wire shapes hold under sustained interaction." Predict 1–2 additional findings from the temporal surface beyond the categorical bets below.
Predicted findings — categorical bets
Five named bets, each probing a categorically different failure surface:
| # | Bet | Category |
|---|---|---|
| 1 | StyleSpans/Decorations dirty-segment edges at viewport boundaries |
Headless-test-blind-spot probe |
| 2 | InlineAdornments M11.2-level suppression visible-flicker on edit-then-revert |
Temporal-interaction probe |
| 3 | FileStyleSummary trailing-empty-line behavior may need wire decision |
Convention-vs-contract probe |
| 4 | CursorByte per-tick cadence may surface as wrong frequency for a 60fps consumer |
Producer-frequency-vs-consumer-need probe |
| 5 | PresenceUpdate peer-cursor color stability may need M10.9 SO_PEERCRED discipline ported into the renderer, not re-derived |
State-derivation-location probe |
Scoring methodology (committed before data lands)
The predicted-vs-actual score at Phase A's close is a category matrix, not a count. This is the M10.10 dual-methodology-scorecard discipline applied here: different measurements capture different signals; report them separately rather than collapse to one number.
At Phase A close, report:
- Predicted categories that surfaced (true positives): which of the five bets above produced findings.
- Predicted categories that didn't surface (false positives): bets that turned out not to be load-bearing.
- Unpredicted categories that surfaced (false negatives): genuine gaps the model missed entirely.
- Count distribution within each category: how many findings of each category appeared.
The model is right about structure if predicted categories surface even at low count; it is right about distribution only if the count within each category roughly matches the prediction. A score of "4/5 predicted categories surfaced; 2 unpredicted categories surfaced; one predicted category produced 3 findings" is a structurally right but distributively wrong prediction.
This methodology is recorded before data lands to prevent the M10.10 Day-5 reconciliation trap where scoring criteria get decided after results are known.
Finding feedback loop
Classification rule (iii) — pre-authorized at framing time, not discovered mid-implementation:
- Small finding (≈ half-day patch, no structural change, no contract violation): absorb into Phase A. Patch the producer arc, re-verify, continue.
- Structural finding (changes a contract, ripples across producers or consumers, invalidates a v1.0 assumption, or breaks the pixel-pure-instance invariant): apply the verification-milestone-premise-check. Defer to a numbered v0.x. Phase A ships with the limitation documented; the structural work becomes its own scoped milestone.
Classification happens at surface-time, not at Phase A's close. Phase A's framing pre-authorizes both branches; neither blocks the other. The audit doc for Phase A records each finding with its classification and the resolution path taken.
Distribution
Workspace member: separate pmacs-gpu binary. Single binary with a
flag would force wgpu runtime deps (vulkan/metal/dx12) and font
handling into every TUI install. Separate repo would sever the audit
trail and require cross-repo coordination for protocol changes.
Workspace + separate binary is the right shape: shared protocol crate,
independent dep graphs, single audit history.
Protocol-crate extraction (prerequisite)
src/protocol.rs is currently inlined in the main pmacs crate. The
extraction is a discrete prerequisite PR, not folded into "workspace
setup":
- Extract
pmacs-protocolas a workspace crate. - Move wire types (
InstanceMessage,FrontendEvent, capability structs,NegotiatedCapabilities,BufferId,FrontendId, theSemanticFramefamily,SelectionSnapshot,ResourceBody, etc.). - Leave internal types (serialization wrappers, dispatch utilities, format-version constants if internal-only) in the main crate.
- Use
[workspace.dependencies]for cross-crate version pinning, not path-dependency duplication.
Budget: 4 hours, not 1–2. Mechanical if protocol.rs is clean;
longer if internal types need disentangling. The wire-vs-internal pass
takes care — mismatched extraction (moving too much or too little)
propagates into pmacs-gpu's dependency graph in ways that are
expensive to undo.
Naming: pmacs-protocol is the chosen name for consistency with
protocol.rs. Alternatives considered (pmacs-wire, pmacs-ipc) are
more specific but break the naming continuity.
Forced decisions
These are decisions the producer arc deferred because no frontend existed. The framing pass commits each before Phase A starts so the work isn't blocked on rediscovery.
Q#1 — visual motion API: stance (β)
The frontend implements visual motion. The instance stays pixel-pure.
Concretely: on arrow-down in a soft-wrapped buffer, the frontend
computes the visual-next byte position locally and sends
FrontendEvent::SetCursor with the target byte. The instance never
reasons about wrap.
If Phase A surfaces (β) as unworkable, that's a load-bearing finding the verification-milestone-premise-check classifies as structural — defer, scope its own work, don't try to patch in Phase A.
Implementation: (β-impl) for Phase A, with documented upgrade path
Phase A starts with (β-impl): the frontend recomputes wrap state
from BufferMirror on each visual-motion event. O(viewport) per motion
event; no maintained index. Cheaper to implement; fine if motion is
keyboard-driven (one event per keypress).
Upgrade to (α-impl) — a maintained wrapped-line tree indexed by byte position, incrementally invalidated on edits — becomes necessary if smooth scroll or kinetic scroll lands in v0.1. The framing pre-authorizes the upgrade if Phase A's scope grows there.
Cursor scope at v0.1
In scope:
- Blink (configurable rate; standard).
- Multi-cursor rendering — pmacs's data model supports multiple cursors; rendering is an additive concern. Renders correctly when present, even if not heavily exercised in Phase A.
- Peer cursors via
PresenceUpdatewith stable per-frontend colors honoringM10.9's SO_PEERCRED-stable color discipline. Data already exists; rendering is what validates it.
Out of scope:
- Multi-cursor commands — TUI doesn't have ways to create multiple cursors; the command layer is a separate concern. Multi-cursor rendering ships ahead of the commands that exercise it.
Font at v0.1
Stance (γ): bundle JetBrains Mono as default; expose Lua hook for override.
- Bundled font: JetBrains Mono (SIL Open Font License 1.1; broad
glyph coverage; designed for code; ~270 KB for the Regular weight,
dwarfed by
wgpu's footprint). Correction: the framing pass recorded the license as Apache 2.0, but the actual license has been OFL since the family's open-source release. Session 2 surfaced this when fetching the real asset for the bundle — classified as a small finding under rule (iii) and absorbed; the bundledfonts/OFL.txtis shipped alongside the TTF as required by the OFL. - Lua override: landed as
pmacs.gpu.set_font { family?, size? }(Arc 4 stage 2,docs/gpu-set-font-framing.md) — a family NAME resolved frontend-locally against the sanitized font database, not a path. - Missing-glyph fallback: tofu (replacement character
U+FFFD). Explicit non-goal to ship a sophisticated fallback chain in v0.1. If real users hit this, it's v0.2+ scope.
The bundled-default-plus-override shape means v0.1 works without
configuration. Correction (Arc 4 stage 2): this section originally
claimed future customization would need no wire-protocol change, but
the preference lives daemon-side (init.lua runs in the daemon, and
every attaching GPU must render consistently), so customization
shipped as the FontFacts fact at protocol v17 —
docs/gpu-set-font-framing.md is the design of record.
Rhythm
The worktree-per-step pattern that drove v1.0 ships individual PRs at hour-level granularity. The GPU work has portions that don't decompose that cleanly: text rendering integration, layout, input event handling are days of work each, not hours.
The cadence relaxes; the discipline does not bend.
- Worktree-per-step continues for discrete features (project setup, protocol extraction, isolable subsystems).
- Larger features ship at daily-PR cadence: each session is a day's worth of work, ending in a commit; multi-day features accumulate across sessions, with a final feature-completion commit.
Discipline anchor: session, not PR
In v1.0's hour-level rhythm, each PR was an audit-doc-finding-recording event. With daily cadence, PRs are too coarse for the discipline; the session is the unit.
Concretely:
- A session starts with audit-doc state and ends with audit-doc state.
- Findings surface within sessions; pause-and-report happens at session boundaries or mid-session as the finding warrants.
- The session-end commit (or audit-doc update for sessions where no code-merge ships) is the rhythm anchor.
The pause-before-implementation, finding-recording-at-surface-time, and audit-doc-as-canonical-source disciplines are unchanged.
Audit artifacts
This doc (pmacs-gpu-design.md) is the design artifact. It lives
in the same documentation tier as
docs/semantic-frontend-protocol.md — forward-looking design framing,
read by future contributors and sessions.
Per-phase audit material lives in separate per-phase audit docs, following the M10.x pattern:
docs/pmacs-gpu-phase-a-audit.md— Phase A findings, classification decisions, predicted-vs-actual scoring at Phase A close.docs/pmacs-gpu-phase-b-audit.md— Phase B equivalent. (Future.)- etc.
This doc updates with cross-references to per-phase audit docs as they land. Design artifact stays readable; audit material gets the per-phase shape M10 validated.
Deliberately not committed (framing-pass scope)
The framing pass closes with the following deliberately deferred to session 1 or later:
winit(the window/event-loop crate). The Rust ecosystem alternative is sparse;winitis the likely default. Session 1 confirms; if the ecosystem has shifted, the decision happens then.pmacs-gpuinternal layering (UI module structure, threading model, frame-budget discipline). Decided session 2 when the workspace is set up, not pre-decided here.- Acceptance-test shape. Golden-frame / screenshot-comparison via
headless
wgpurendering is the likely substrate; pixel-exact vs perceptual comparison is a separate decision. If acceptance-test shape surfaces complications during session 1 or 2, classify as structural finding (iii).
Session plan
| Session | Work | Gate at close |
|---|---|---|
| 1 | pmacs-protocol crate extraction. Move wire types out; [workspace.dependencies] pattern; main crate keeps internal protocol utilities. |
Full gate set; main pmacs binary still passes all v1.0 tests. |
| 2 | pmacs-gpu workspace member; wgpu/winit/cosmic-text/glyphon deps; hello-world: open a window, render "hello, pmacs" with the bundled font. |
Hello-world runs; no protocol consumption yet. |
| 3 | Attach loop — pmacs-gpu connects to a running pmacs daemon as a semantic_render session; receives BufferSnapshot; rebuilds rope locally. |
Daemon ↔ frontend handshake; rope reconstruction matches. |
| 4+ | Phase A proper — render the rope; consume StyleSpans, Decorations, InlineAdornments, FileStyleSummary, CursorByte; work through the test corpus. |
Phase A close = predicted-vs-actual scoring against the bets above. |
The session-numbered plan is forward-looking; reality will reshape it as Phase A surfaces findings. The framing pass commits the structure; the sequence is allowed to bend.