Merge pull request #124 from levineuwirth/gpu-set-font

feat(font): pmacs.gpu.set_font — global font preference at protocol v17 (Arc 4 stage 2)
This commit is contained in:
Levi Neuwirth 2026-07-18 16:00:13 +00:00 committed by GitHub
commit f8096ff826
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 4709 additions and 307 deletions

1
Cargo.lock generated
View File

@ -2579,6 +2579,7 @@ dependencies = [
"loro",
"pmacs-protocol",
"pollster",
"sys-locale",
"wgpu",
"winit",
]

View File

@ -0,0 +1,832 @@
# GPU font preference — framing (Arc 4 stage 2, `pmacs.gpu.set_font`)
**Revision 5 — 2026-07-18. Status: implemented on branch
`gpu-set-font` (protocol v17); awaiting PR review.**
Revision 5 (PR review, findings 15): source bytes that fall inside a
shaped cluster are normalized to an explicit representable cosmic-text
cursor before geometry or following; combining-mark and real-ligature
fixtures pin that they can no longer fall back to the source-line start
(finding 1). Code-buffer reflow is now one transaction for every
dynamic horizontal input, not only font application: line-number mode,
gutter digit-count transitions, minimap appearance/disappearance, full
text replacement, incremental CRDT edits, and byte-identical
`BufferSnapshot` summary clearing all synchronize the shaping width
before the final reshape and re-follow only a previously painted caret
(finding 2). The monospace advance probe divides the total shaped-run
width by the probe's cell count, rather than sampling its first glyph;
the embedded alternate monospace carries a real multi-cell ligature so
the old result is observably wrong (finding 3). All four fixture fonts
now enter the explicit database before `FontSystem` construction and
their retained IDs are checked against cosmic-text's actual
`is_monospace` classification, removing the post-construction test
approximation (finding 4). Acceptance 10, 11, 17, and 18 now exercise
their complete claims at both size bounds, including rendered band
containment, selection/hit row identity, wrapped completion anchors,
gutter continuation alignment, reverse caret-free reflow, and both
directions of caret-free resize (finding 5).
Revision 4 (framing round 3, findings 15): caret preservation is now
VISUAL-RUN aware rather than source-line-only. The code buffer keeps a
normalized cosmic-text `Scroll`, full reshapes reapply it, and the
shared caret-follow helper uses `Buffer::shape_until_cursor` (which
handles wrapped runs vertically) before normalizing a
slice-local `scroll.line` into the frontend's whole-file `scroll_top`;
the font path and `CursorByte` path share the helper. A long line that
fits at size 6 and wraps below the viewport at size 72 is a load-bearing
acceptance case (finding 1). Context-menu vertical clipping joins its
already-accepted horizontal clipping at extreme sizes: minibuffer and
completion popups retain their existing surface-windowing guarantee,
while viewport-aware context-menu flip/window/ellipsis is one named
deferral; acceptance 10 no longer promises geometry the menu substrate
cannot provide (finding 2). Metrics and dimensions update together via
`set_metrics_and_size`; the code buffer uses the actual drawable code
width/height, the status buffers use the derived band height, all seven
buffers are brought current after a resize, and a resize→font-change
test pins the dimensions before pixels are inspected (finding 3). The
default family becomes a SANITIZED, CURRENT-ORDER `"JetBrains Mono"`
query: the explicit database preserves today's order (system fonts,
then the bundle), removes only non-monospace faces colliding with that
family, and is only then wrapped in `FontSystem`. A valid installed
JetBrains Mono keeps today's pixels, while a proportional same-family
face cannot win the default query or make the monospace fallback recurse
(finding 4). The last stale "bundled font" statement is aligned with
that now-structural guarantee (finding 5).
The final feasibility audit also pins three substrate details exposed by
the larger metric range: popup buffers use `Wrap::None` so one wire row
cannot become several interactive rows; caret and completion-anchor
mapping select the VISUAL run that actually contains the byte; and
font-dependent fallback/menu advances are measured relative to the
sanitized per-process default even when the code buffer is empty.
Glyphon 0.11 ignores cosmic-text's horizontal scroll component, so
stage 2 explicitly keeps
that component zero and defers the wider horizontal-offset substrate.
Every final shape normalizes a cosmic source-line advance back into the
frontend's whole-file origin (including caret-free shrink), optimistic
edits use the same visual follower, the Lua table is strict raw data,
and font resolution validates all four style queries rather than only
the normal face.
Revision 3 (framing round 2, findings 17): the GPU validates the
WIRE size before mutating any state — `FontFacts` is deserialized
protocol input, `u32` does not enforce the range, and
`Buffer::set_metrics` panics on a zero font size (cosmic-text
`buffer.rs:563`) — an out-of-range `size_centi_px` fails closed:
the whole message is ignored, logged, and current state kept;
direct GPU-arm tests cover 0, 599, 7201, and `u32::MAX`
(finding 1). The caret repair is re-ordered — record the
painted-visibility predicate (EXCLUDING the two-line overscan that
`view_range` carries, `main.rs:4290`), set metrics, conditionally
adjust `scroll_top`, THEN `reshape()` once at the final scroll,
then declare the viewport — matching the existing cursor path that
rebuilds lines before declaring (`main.rs:2854`); the
scrolled-away pin in acceptance 11 targets exactly the
one-line-past-the-painted-window overscan case (finding 2).
Acceptance 10 is narrowed to VERTICAL containment (code stops at
the band, status glyphs fit the band, popups stay inside the
surface): popups occlude the text area by layer contract, and at
size 72 the 380 px menu cap clips long labels today — accepted and
documented; viewport-aware menu width + ellipsis moves to Deferred
(finding 3). The default family is REDEFINED as the locally
resolved "JetBrains Mono" query, with the bundled asset
guaranteeing the query is never empty rather than promising face
identity — `FontSystem::new()` loads system fonts before the
bundle, and fontdb returns the first surviving candidate in
insertion order, so a system-installed JetBrains Mono may win;
never-set and fallback resolve through the same query in the same
process, so the byte-identical acceptance claims stay valid
per-machine (finding 4). Quantization is pinned: range-check the
ORIGINAL finite value first (6.0 ≤ size ≤ 72.0), then
nearest-hundredth via round — 5.999 errors rather than rounding
into range; values on both sides of a hundredth are pinned
(finding 5). Acceptance 2's field name corrected to
`size_centi_px` (finding 6). Atlas wording corrected: `trim()`
clears `glyphs_in_use`; old glyphs become ELIGIBLE for later
LRU-style eviction under allocation pressure, they do not age out
on their own (finding 7).
Revision 2 (framing round 1, findings 16): the wire size is an
**integer in hundredths of a logical pixel** (`Option<u32>`) —
`InstanceMessage` derives `Eq` (`message.rs:490`), so `Option<f32>`
cannot compile, and cosmic-text metrics are logical pixels, not
typographic points; units corrected throughout (finding 1).
`STATUS_BAND_HEIGHT` joins the derived-geometry inventory and is
threaded through buffer sizing, band geometry, `text_area_bottom`,
minimap height, and visible-line math; acceptance renders open
status/minibuffer/menu surfaces at both size bounds and asserts no
overlap or clipping (finding 2). `apply_font_facts` records caret
visibility before re-metricing and restores it afterward — without
snapping a viewport that was intentionally scrolled away — because
`reshape()` shrinks the shaped slice and `CursorByte` only
scroll-to-cursors on byte *change*, so an enlarged font could
otherwise hide a stationary caret indefinitely (finding 3). Family
resolution additionally requires the fontdb face to be
**monospaced** (`FaceInfo.monospaced`, fontdb 0.23); proportional
families take the deterministic fallback, pinned by an embedded
proportional test font — measured-advance support for proportional
faces is deferred, not designed around (finding 4). The `pmacs.gpu`
module and the preference handle install before
`load_user_config` runs, and acceptance proves `set_font` works
from init.lua and survives into the first attachment (finding 5).
Ground truth corrected: seven family-literal sites, five
`TextRenderer`s, `atlas.trim()` already runs after every submitted
frame and only clears the glyphs-in-use set (no immediate orphan
eviction — the per-frame trim cycle ages old-font glyphs out, no
explicit step needed), and menu/mb/completion buffers are rebuilt
unconditionally per frame, so explicit shaping-cache invalidation
applies only to the status buffers that cache composed strings
(finding 6).
Revision 1: initial framing.
## Ground truth (as of `de50a51`, protocol v16)
### Font handling in pmacs-gpu today — everything hardcoded
- One bundled font: `const JETBRAINS_MONO: &[u8] =
include_bytes!("../fonts/JetBrainsMono-Regular.ttf")`
(`pmacs-gpu/src/main.rs:54`), loaded via
`font_system.db_mut().load_font_data(...)` in `assemble()`
(`:1845-1846`) — the only font-DB mutation site. `FontSystem::new()`
also loads system fonts (cosmic-text default), so system families
are already resolvable; nothing selects them.
- The family literal `Family::Name("JetBrains Mono")` appears at
seven call sites (`:1946, :3113, :3681, :3751, :3808, :3906,
:6590`); sizes and line heights are compile-time consts in
**logical pixels** — cosmic-text metrics, not typographic points —
(`:90-158`): `CODE_FONT_SIZE 16.0` / `CODE_LINE_HEIGHT 22.0`,
`STATUS_FONT_SIZE 13.0` / `STATUS_LINE_HEIGHT 18.0` /
`STATUS_BAND_HEIGHT 26.0` (`:129`),
`MENU_FONT_SIZE 14.0` / `MENU_LINE_HEIGHT 22.0` /
`MENU_ROW_HEIGHT 22.0` / `MENU_CHAR_W 8.4`,
`MB_DROP_FONT_SIZE 13.0` / `MB_DROP_LINE_HEIGHT 20.0` /
`MB_DROP_ROW_HEIGHT 20.0`, `GUTTER_MONO_ADVANCE_FALLBACK 9.6`. The
main code buffer additionally uses a literal
`Metrics::new(16.0, 22.0)` (`:1879`) rather than the consts. No env
var, no CLI flag (`parse_args`, `:301`).
- Seven glyphon `Buffer`s carry `Metrics` frozen at construction
(`buffer`, `status_buffer`, `status_left_buffer`, `menu_buffer`,
`mb_buffer`, `completion_buffer`, `gutter_buffer`; built
`:1879-1942`). One shared `TextAtlas` (`:390`, built `:1857`) feeds
five `TextRenderer`s; `self.atlas.trim()` already runs after every
submitted frame (`:4952`) — glyphon's `trim` only clears the
glyphs-in-use set (`text_atlas.rs:219`), which makes unused glyphs
ELIGIBLE for later LRU-style eviction under allocation pressure;
they do not age out on their own, and stale entries are wasted
atlas space, never wrong rendering. Menu/minibuffer/completion
buffers are rebuilt unconditionally each frame (`:3740`); only the
two status buffers cache composed strings behind string-equality
gates.
- Metric-derived layout: `mono_advance()` reads the first shaped
glyph's width (`:3034-3040`); `gutter_width_px()` (`:3045-3063`);
`text_left()` (`:3068`); `estimated_visible_lines()` divides by
`CODE_LINE_HEIGHT` (`:5463`); scroll-wheel line math (`:1299`);
caret pixels from shaped-run geometry (`caret_rect` `:5251-5294`);
hit-testing (`:3142`) and popup row math (`:3219`, `:3974`).
- Scale factor is unhandled: `scale: 1.0` hardcoded in every
`TextArea`; no `ScaleFactorChanged` arm. Pre-existing gap,
orthogonal to this stage (Deferred).
- Reshape/resize paths that exist: `reshape()` (`:4305-4334`)
rebuilds the code buffer's lines from `line_chunk_cache` and
re-shapes; `resize()` (`:4344-4381`) reconfigures the surface and
calls `set_size` on four buffers (width/height only — never
`Metrics`) then `reshape()`. Neither touches family or size.
- **Library capabilities at the pinned versions** (glyphon 0.11.0 /
cosmic-text 0.18.2, verified in the registry sources):
`Buffer::set_metrics(&mut FontSystem, Metrics)` exists and
re-shapes (`cosmic-text buffer.rs:566`; borrowed-with-font-system
variant `:1419`), and `TextAtlas::trim()` exists (`glyphon
text_atlas.rs:334`). Runtime reload therefore needs **no renderer
or atlas rebuild** — the field types are owned values with no
lifetime coupling (`font_system: FontSystem` `:387`; atlas and
renderers meet only inside per-frame `prepare(...)` calls).
- Cosmic-text's `Buffer::shape_until_cursor` updates all three `Scroll`
components (`buffer.rs:320-413`), but glyphon 0.11's `TextRenderer`
consumes the vertical layout-run positions and passes each glyph's
unshifted `x` to `physical(...)` (`text_render.rs:237-257`): it does
NOT apply `Scroll.horizontal`. Stage 2 can therefore use the library's
wrapped-run/vertical following, but cannot promise horizontal reveal
from that field. With `Wrap::WordOrGlyph` and a buffer width equal to
the paint clip, normal code wraps; visibility for one indivisible
glyph wider than the code viewport remains a named Deferred case.
### The design-doc claim this stage corrects
`docs/pmacs-gpu-design.md:278` ("Font at v0.1", stance γ `:280`)
sketches `pmacs.gpu.set_font(path)` "or similar" (`:292`) and claims
(`:298-299`): "The bundled-default-plus-override shape means v0.1
works without configuration; **future customization needs no
wire-protocol changes**." That claim predates the Q#UX1 lesson
(rendering is frontend-local; *control* is daemon-owned Lua, so a
preference must cross the wire as a versioned fact — the LineNumbers
v13/v14 and ThemeFacts v16 shape). This framing supersedes it, and
the design doc is corrected in this stage's diff.
### The facts-channel template post-v16
- `PROTOCOL_VERSION = 16` (`pmacs-protocol/src/message.rs:1376`);
`SUPPORTED_PROTOCOL_VERSIONS = [6..=16]` (`:1439`). Pin tests:
version pin (`src/protocol.rs:1712`), resume ladder accepting
`6..=16` and rejecting `17` (`:1776-1800`), postcard round-trips,
and the placement byte-pin `completion_popup_encoding_is_
unchanged_by_the_v16_build` (`:1835-1862`).
- `ThemeFacts` is the **final** `InstanceMessage` variant
(`message.rs:991-997`); postcard discriminants are ordinal, so new
variants append after it, and the final-pre-bump variant gets a
byte-level encoding pin.
- Producer pattern (`src/semantic_render.rs`): bufferless global
facts follow `theme_facts_msg` (`:1123-1153`) — an `Option`-seeded
epoch gate plus an `Option`-seeded last-payload baseline, both
advancing on computation, yielding exactly one authoritative send
per attachment (the default state included) and cached-compare
suppression thereafter. `on_buffer_snapshot_sent` (`:419-429`)
deliberately does NOT reset bufferless baselines.
- Daemon write-loop: per-peer `>= version` gates + skip arms
(`src/daemon.rs:1102-1141`, `:1177-1180` for ThemeFacts) as
belt-and-braces over the producer-side `for_peer` gate.
- The grid TUI folds all facts variants into one silent-drop arm
(`src/frontend.rs:395-425`) with a per-variant regression test.
- No `pmacs.gpu` Lua namespace exists anywhere; top-level modules
install via `pmacs.set("<name>", install_<name>_module(...)?)` in
`install()` (`src/lua_bindings/mod.rs:2029`; recent examples
`:2085-2087`). `pmacs.theme.set` is deliberately NOT init-gated
(mid-session live, `mod.rs:6983-7053`); `require_init_phase`
(`:659`) gates only lifecycle APIs (`pmacs.attach`,
`pmacs.packages.*`).
## Decisions
### Q#F1 — Scope: one global font preference, family + size, live
Stage 2 ships `pmacs.gpu.set_font { family?, size? }`: a single
GLOBAL daemon-side preference (family name and/or size in logical
pixels),
carried to GPU-capable peers as a new bufferless fact at protocol
v17, applied mid-session with full visual rebuild. The grid TUI
silently drops it (terminal fonts belong to the terminal). Out of
scope, named in Deferred: font-file paths/bytes, per-frontend
overrides, per-surface size knobs, weight/style variants, DPI/scale
work.
### Q#F2 — Lua surface: `pmacs.gpu.set_font`, un-gated, kwargs table
- `pmacs.gpu.set_font { family = "Iosevka", size = 18 }` — both
fields optional; an absent field means "frontend default for that
axis" (the sanitized current-order "JetBrains Mono" family query,
Q#F6, or 16.0 logical px).
`pmacs.gpu.set_font {}` therefore resets to defaults. Replacement
semantics, not merge: the table IS the preference (the
`pmacs.theme.set` wholesale lesson, Q#TH10). `size` is in
**logical pixels** (what today's constants are). Quantization rule
(round 2 finding 5): the ORIGINAL finite value is range-checked
first (`6.0 <= size <= 72.0` — so `5.999` errors instead of
rounding into range), then converted to the nearest hundredth via
`round` (`(size * 100.0).round() as u32`); the getter returns the
quantized value.
- Validation is daemon-local and throws (house conventions):
`family` must be a non-empty string; `size` must be a finite
number in `[6.0, 72.0]` logical px. **Family existence is NOT
validated daemon-side** — fonts are frontend-local resources and
the daemon never learns what is installed (no-pixels corollary);
resolution failure is a deterministic frontend fallback (Q#F6).
The kwargs table is strict plain data: read `family`/`size` with
`raw_get`, reject every other raw key with that key named, and never
consult `__index`/`__pairs`. Parse, validate, and quantize the complete
table before locking or changing the preference. The getter returns a
fresh plain table, not the stored table or a mutable handle.
- The module and the preference handle install before
`load_user_config` runs (`src/editor.rs:455`), so `set_font` is
init.lua-reachable: font selection is primarily configuration,
and the preference set at init must survive into the first
attachment (finding 5).
- Getter `pmacs.gpu.font()` returns the current preference table
(getter, not a stored handle). No init gating: the setter follows
the live `pmacs.theme.set` pattern, not `require_init_phase`.
- New module: `pmacs.set("gpu", install_gpu_module(...)?)` — the
namespace is greenfield; `set_font`/`font` are its first members.
### Q#F3 — Daemon state: a shared handle with a monotonic epoch
`FontPref { family: Option<String>, size_centi_px: Option<u32>,
epoch: u64 }` behind an `Arc<Mutex<...>>` handle on `EditorState`,
mirroring the theme handle's shape (`syntax_registry.theme()`); the
Lua setter validates and quantizes fully before locking, writes the
whole preference, and bumps `epoch` from its prior value (the Q#TH6
transactional-mutator lesson — trivial here since the payload is two
scalars, but the increment-only invariant is kept so producer gates
stay monotonic). The handle exists before `load_user_config` so
init.lua writes land in the same state the first attachment's
producer reads (finding 5).
### Q#F4 — Wire fact: `FontFacts`, protocol v16→17, appended final
```rust
/// Arc 4 stage 2 (protocol v17). The daemon-relayed GPU font
/// preference. One global instance ⇒ bufferless (the ThemeFacts
/// shape). Complete replacement each send; `None` means the
/// frontend's built-in default for that axis. The daemon relays a
/// PREFERENCE — it never learns metrics, advances, or what
/// resolves; the frontend owns resolution and every pixel
/// consequence (no-pixels invariant).
FontFacts {
family: Option<String>,
/// Font size in HUNDREDTHS of a logical pixel (1600 = today's
/// 16.0) — an integer because `InstanceMessage` derives `Eq`
/// (`message.rs:490`), which `f32` cannot satisfy, and because
/// cosmic-text metrics are logical pixels, not typographic
/// points. Validated range 600..=7200.
size_centi_px: Option<u32>,
},
```
- Appended after `ThemeFacts`, the current final variant; the
placement guard is a byte-level encoding pin of `ThemeFacts` (the
final pre-v17 variant), the same discipline as the v16
`CompletionPopup` pin.
- `PROTOCOL_VERSION` 16→17 with a ladder paragraph;
`SUPPORTED_PROTOCOL_VERSIONS` grows 17; pin tests updated
(version pin 17; ladder accepts `6..=17`, rejects 18; `FontFacts`
postcard round-trip incl. the all-`None` shape). The integer wire
size keeps `Eq`/`Hash` derivability and makes the cached-compare
exact by construction; the GPU converts once
(`size_centi_px as f32 / 100.0`) at application.
- Daemon write-loop gains `peer_knows_font_facts = negotiated >= 17`
+ skip arm; the producer is peer-version-aware via the existing
`for_peer` (the PR #120 round-1 lesson — but note there is no
pre-v17 side channel that could leak font state, so the gate has
no summary-style companion filter).
- TUI: `FontFacts` joins the silent-drop family arm with the
family's regression-test pattern.
- `docs/semantic-frontend-protocol.md` gains the v17 bufferless
`FontFacts` schema, authoritative-default/late-join behavior,
`< 17` exclusion, and the fact that buffer snapshots reset neither
producer nor frontend font preference. This is a wire contract, not
only a GPU implementation detail.
### Q#F5 — Producer: `font_facts_msg`, the ThemeFacts discipline
`SemanticRenderState` gains `last_font_epoch: Option<u64>` and
`last_font_facts: Option<(Option<String>, Option<u32>)>`, both
seeded `None`: every attachment receives exactly one authoritative
`FontFacts` — the all-default `(None, None)` included — with its
first frame after viewport declaration; unchanged ticks compare one
`u64`; both records advance on computation (an identical re-set
emits nothing but records the inspected epoch). Bufferless ⇒
`on_buffer_snapshot_sent` does not touch either field. Emission
rides `render_frame`'s existing list after `theme_facts_msg`.
### Q#F6 — GPU application: resolve-or-fallback, then full re-metric
On a `FontFacts` arrival the GPU replaces its font state wholesale:
- **Wire validation, fail closed** (round 2 finding 1): `FontFacts`
is deserialized protocol input — the daemon-local Lua validation
is a UX courtesy, not a trust boundary. Before mutating ANY state,
`apply_font_facts` checks `size_centi_px ∈ 600..=7200`; an
out-of-range value (0 would panic `Buffer::set_metrics`,
cosmic-text `buffer.rs:563`; huge values produce pathological
metrics/allocations) rejects the whole message: logged to stderr,
current state kept, nothing re-shaped.
- **Resolution (frontend-local)**: `family: Some(name)` resolves
against the existing fontdb (bundled JetBrains Mono + system
fonts) AND every face the shipped attribute set can select must be
**monospaced**
(`FaceInfo.monospaced`, fontdb 0.23) — `mono_advance()` treats the
first shaped glyph's width as universal (`:3030-3040`) and menu
width multiplies a fixed advance by character count (`:3194`), so
a proportional family would silently under-size gutters and menu
hitboxes (finding 4). Unresolvable OR non-monospaced families fall
back to the default — deterministic, logged to stderr, never
round-tripped back (the daemon never learns resolution outcomes;
no-pixels). **The default is a sanitized, current-order "JetBrains
Mono" FAMILY query** (round 3 finding 4). `assemble()` replaces the
current `FontSystem::new(); db_mut().load_font_data(...)` order with
an explicit `fontdb::Database`: call `load_system_fonts()` FIRST as
`FontSystem::new()` does today, load the bundled bytes second with
`load_font_source(Source::Binary(...))`, and retain the returned
bundled `fontdb::ID`. Then remove any NON-monospace face that
advertises the exact `"JetBrains
Mono"` family (collect IDs before `remove_face`; the bundled face is
monospaced and survives). This prevents a closer-weight proportional
system face from winning bold/italic text even when the normal query
selected a valid monospaced system face. Restore cosmic-text's current
generic-family defaults (`Noto Sans Mono`, `Open Sans`, and `DejaVu
Serif`), then construct
`FontSystem::new_with_locale_and_db` using `sys_locale::get_locale`
with cosmic-text's current `"en-US"` fallback when it returns `None`
(new direct `pmacs-gpu` dependency; fontdb continues through
glyphon's cosmic-text re-export). Fontdb returns the first surviving
equally-good candidate in insertion order (`lib.rs:661`), so a valid
monospaced system JetBrains face retains today's precedence while the
bundled face guarantees a survivor after invalid collisions are
removed. Constructing `FontSystem` only after every load/filter also
includes every surviving monospaced ID (the bundle included) in
cosmic-text's internal monospace-ID set.
Resolution checks the four queries reachable through the shipped
attributes — normal, bold, italic, and bold-italic, all at normal
stretch — and rejects a requested family if any selected face is not
monospaced. The normal query and assertion are the same
`fontdb::Query` implied by the base `Attrs` installed on all seven
buffers. The retained DEFAULT query ID and the bundled ID are both
asserted present and monospaced at assembly. `family: None`
and every rejected requested family use that same known-monospace
query: the fallback is total, cannot recurse into a proportional
collision, and never-set/reset/fallback resolve identically.
Measured-advance support for proportional faces is Deferred, not
designed around.
- **Metrics**: the hardcoded consts become `State` fields derived
from the preference size (default 16.0) by fixed ratios — code
line height 22/16, status 13/16 + 18/16 + **band height 26/16**
(`STATUS_BAND_HEIGHT`, finding 2), menu 14/16 + 22/16, mb dropdown
13/16 + 20/16, `MENU_CHAR_W` 8.4/16,
`GUTTER_MONO_ADVANCE_FALLBACK` 9.6/16 — so one knob scales every
surface coherently and an unset size reproduces today's constants
bit-for-bit. Never-set/reset are byte-identical within the resulting
process; pre-stage pixels are also preserved when the old winning
family is monospaced across the four shipped style queries. A
non-monospace collision is the deliberate safety exception (the
Q#TH5 default-preservation lesson applied within the viable domain).
The derived band height threads through buffer sizing, band quad
geometry, `text_area_bottom`, minimap height, and the visible-line
math — at size 72 the status line is 81 logical px and today's
fixed 26 px band would clip it. The stray literal
`Metrics::new(16.0, 22.0)` (`:1879`) is unified into the same
fields. **Dimensions change atomically with metrics** (round 3
finding 3): use `set_metrics_and_size`, not `set_metrics` (which
deliberately preserves the old dimensions). Code and gutter height
are the nonnegative drawable code height
(`(text_area_bottom - TEXT_TOP).max(0.0)`), status
height is the derived band height, and menu/mb/completion height is
the current surface height. The code buffer's width is its actual
nonnegative drawable width
(`(text_bounds_right - text_left).max(0)`), not the whole
surface: wrapping and `shape_until_cursor` must use the same clip the
painter uses. Because `text_left` depends on the newly-shaped
`mono_advance`, the rebuild may make one internal measure pass and
one final size/shape pass; no frame is submitted between them.
`resize()` goes through the same dimension helper for ALL seven
buffers, closing the existing four-of-seven resize skew.
The same helper and settle transaction also own every runtime input
that changes the code clip without changing the font: line-number
mode, a gutter digit-count transition after full or incremental text,
minimap presence after `FileStyleSummary`, and summary removal during
even a byte-identical `BufferSnapshot`. Each path captures the old
painted-caret predicate before changing geometry, synchronizes the
final buffer dimensions before shaping, and follows only when that
predicate was true.
- **Rows stay rows**: menu, minibuffer-candidate, and completion buffers
explicitly use `Wrap::None`. Their protocols, row-window calculations,
selection quads, and hit tests all assign exactly one row-height to one
source line; allowing a long label to wrap after a size/family change
would paint glyphs on a second visual row that still hit-tests as the
following item. The existing pixel bounds remain responsible for
horizontal clipping. Code and gutter retain wrapping; status strings
may clip within their single derived band.
- **Font-dependent advances without default drift**: the internal
measure pass shapes a fixed ASCII probe in the resolved family at the
relevant metrics, independent of document contents. It sums every
shaped run's width and divides by the probe's logical cell count;
sampling one glyph is invalid because even a monospaced face may
shape several probe characters into one multi-cell ligature. It
records the selected/default advance ratio. The empty-code gutter
fallback and `menu_char_w` use today's exact constants multiplied by
that ratio;
the sanitized per-process default is therefore ratio 1 and remains
byte-identical, while an alternate monospace family cannot leave stale
JetBrains-only gutter/menu geometry. The measured NORMAL-face advance
becomes authoritative for the normal-style gutter even when the first
code glyph is bold/italic (different monospaced faces need not share an
advance); `mono_advance()` no longer samples an arbitrary code glyph.
The measure result is committed with the other derived geometry before
hit maps are dirtied.
- **Extreme-size context-menu policy** (round 3 finding 2): the
context menu keeps its current raw pointer anchor, full row set, and
fixed 380 px width cap. Wgpu clips geometry at the surface, so it
remains safe, but long labels and/or lower rows may be clipped at
large configured sizes; unlike the minibuffer and completion
dropdowns it does NOT claim surface containment. Adding viewport-
aware horizontal ellipsis plus vertical flip/window/scroll is one
named Deferred item, not smuggled into the font reload. Acceptance
exercises the clipped route for no panic and coherent hit geometry,
while containment assertions cover only surfaces that own it.
- **Caret visibility across the re-metric — visual runs, not source
lines** (round 3 finding 1): `Buffer` defaults to
`Wrap::WordOrGlyph`; a source line that fits at size 6 can occupy
several visual runs at size 72, so source-line-only
`scroll_to_cursor` cannot uphold a painted-caret guarantee. `State`
therefore retains a normalized code-buffer `Scroll` (slice-local
`line == 0`, the `vertical` residual, and `horizontal == 0`), and full
reshapes reapply it instead of blindly installing `Scroll::default`.
This residual is buffer-scoped view state: `BufferSnapshot` resets it
to default even though the global font preference/metrics survive.
A shared `normalize_code_scroll` runs after EVERY final code shape,
not only caret following: if cosmic-text advances slice-local
`scroll.line` because new wrapping/metrics make the retained vertical
offset cross source lines, add that delta to whole-file `scroll_top`,
retain the residual, rebuild from the new source origin, and repeat
until `line == 0`. Each iteration must strictly advance the clamped
source origin; at EOF, a non-advancing residual is clamped to the last
source line with `Scroll::default` rather than looping. This preserves
an intentionally caret-free viewport through a size decrease without
leaving a stale/blank slice. Any
incidental scroll changes made by intermediate measure/metric calls
are discarded; normalization starts from the pre-transaction retained
scroll against the FINAL family, metrics, dimensions, and attrs.
A shared byte-to-layout helper selects the visual run whose glyph byte
interval contains the target (or the final run at source-line end); it
replaces the current first-run-of-source-line scans in both
`caret_rect` and `completion_anchor_px`. The helper first inverts that
line's `line_chunk_cache` projection — source bytes are not projected
bytes when inline adornments are present — using the earliest
projected boundary for an adornment anchor (the current left-gravity
caret placement), then uses cosmic-text's `layout_cursor`/affinity so
a wrap boundary selects the same run as `shape_until_cursor`. A source
byte inside a combining or ligature cluster is snapped explicitly to
the cluster's logical end with `Before` affinity; it is never handed
to cosmic-text as an unrepresentable interior cursor, whose fallback
is the source-line start. A shared
`ensure_caret_painted` helper first performs the existing coarse
source-line `scroll_to_cursor` and rebuild when the byte is outside the
shaped slice, then maps the byte to a cosmic-text `Cursor` and calls
the library's `Buffer::shape_until_cursor` (`buffer.rs:320-413`), which already
follows wrapped layout runs vertically. The helper deliberately
discards the call's `Scroll.horizontal` result: glyphon 0.11 does not
apply that component when placing glyphs, so retaining it would make
state claim a scroll the painter never displays. The helper then calls
`normalize_code_scroll`; only after its final source origin is stable
may it declare the viewport. Explicit wheel/minimap/source-line jumps
clear that residual; ordinary full reshapes preserve it. The existing
`CursorByte` arm uses this helper under its existing `moved` gate
too, fixing its identical pre-existing wrapped-line hole without
snapping a stationary cursor after a wheel/minimap scroll. The
optimistic edit completion path also replaces its current
source-line-only `scroll_to_cursor` call with the helper: it has already
installed the predicted `own_cursor`, and the confirming identical
`CursorByte` will have `moved == false`, so deferring visual-run repair
would leave a newly wrapped caret off-screen indefinitely. The gutter
projection mirrors the code layout at the same time: emit the source
line number on its first visual run and blank gutter rows for wrapped
continuation runs, then apply the same normalized vertical scroll to
the gutter buffer. That keeps line numbers aligned when the font
change creates wraps instead of exposing the existing one-row-per-
source-line skew.
- The pre-change follow decision remains conservative: compute it
before any mutation from the ACTUAL code-caret rectangle intersected
with the drawable code clip (and only when the minibuffer is closed),
not from `view_range`; this excludes both the two-line source
overscan and wrapped runs clipped below the band. Painted before ⇒
run `ensure_caret_painted` after the new family/metrics are shaped.
Not painted before ⇒ preserve the user's scroll and do not call the
helper. Thus the font change never turns an overscan-only caret into
a snap-back, while a formerly painted caret survives new wrapping.
`resize()` uses this same painted-before policy after its final
dimensions are installed: narrowing a window cannot strand a
stationary caret in a new wrap, and widening an intentionally
caret-free viewport only normalizes its retained scroll.
- **Rebuild sequence** (one transaction, `apply_font_facts`): validate
the wire size (fail closed, above); record actual painted-caret
visibility; resolve the known-safe family; store the derived metrics;
set the three row-oriented popup buffers to no-wrap; measure the
selected/default advance ratio; update metrics + current dimensions
on all seven buffers; clear the two status shaping caches
(`status_text`, `status_left_text` — the
only string-equality gates; menu/mb/completion rebuild
unconditionally per frame); attrs-bearing reshape/measure; settle
the final drawable code width and reshape if it changed; if the old
caret was painted, run the visual-run helper; otherwise run the same
scroll normalizer without caret following; recompute dependent layout,
set `hit_map_dirty`, drop
the minimap vertex cache, `request_redraw()`, and finally call
`viewport_send_if_changed`. No intermediate state renders and the
viewport is derived from the final normalized source origin. No
atlas action: the per-frame `atlas.trim()` (`:4952`) clears
`glyphs_in_use`, making old-font glyphs eligible for later LRU-style
eviction under allocation pressure (they do not age out on their own
— glyphon `text_atlas.rs:219`).
- The seven `Family::Name` literals collapse into one accessor on
`State` so family application is a single site.
### Q#F7 — Default semantics and late join
`(None, None)` is a real, always-shipped state meaning "frontend
built-ins" — an attachment never infers defaults from silence (the
Q#TH7 authoritative-per-attachment lesson). A late-joining GPU peer
receives the current preference among its first frames; a running
peer receiving `(None, None)` after a themed session resets to the
sanitized current-order JetBrains Mono query and today's constants
exactly.
## Bets
- `Buffer::set_metrics_and_size` + attrs-bearing re-set is a sufficient
reload path at glyphon 0.11 / cosmic-text 0.18 — no atlas or
renderer rebuild, no `FontSystem` swap (the db only ever grows;
system fonts load at startup); the per-frame `atlas.trim()`
cycle makes old-font glyphs eligible for later eviction under
allocation pressure, which is sufficient because stale entries
are only wasted atlas space, never wrong rendering. The headless
harness runs the identical `assemble()` path, so this bet is
testable end to end under `PMACS_REQUIRE_GPU=1`.
- Proportional scaling of the chrome constants from one size knob is
acceptable at stage 2; per-surface knobs are deferred, not
designed around.
- Hermetic family testing: CI GPU runners may have zero system
fonts, so acceptance embeds FOUR test-local faces (test bytes, not
shipped assets): a second **monospaced** family to prove
resolution-and-switch, a **proportional** family to pin the monospace
gate's fallback, a monospaced TEST-DEFAULT face to model today's
system-order winner, and a BOLD proportional face carrying that same
test-only family name to pin the sanitized-default collision and
styled-query rule. The database sanitizer takes the default family
name and bundled-equivalent ID as internal parameters; production
passes `"JetBrains Mono"` and the real bundled ID, while tests use an
unreserved fixture name. A test-only assembly input loads all four into the explicit
pre-`FontSystem` database so cache/monospace-ID construction and the
collision filter are the production path, not a post-construction
approximation. The missing-family path is exercised with a name
guaranteed absent.
Test-font licenses/notices live beside the fixtures.
- The scale-factor gap stays orthogonal: this stage neither fixes
nor worsens DPI handling (`scale: 1.0` everywhere, unchanged).
## Deferred (named)
Font file paths/bytes over the wire (the design doc's original
`set_font(path)` sketch — needs a resource channel, plausibly the
dormant `ResourceOffer`, and a frontend-trust story);
**proportional-family support** (per-glyph layout widths replacing the
single measured monospace ratio and chars×constant hit geometry — the
monospace gate is the stage-2 stance, finding 4); per-frontend font
overrides (the LineNumbers `frontend_id`-routed shape is available
if wanted); per-surface size knobs (status/menu/dropdown independent
of code); weight/style variants (bold/italic family selection —
interacts with the chrome-attribute mask widening already deferred
by stage 1); fallback-chain configuration; ligature/feature toggles;
DPI/scale-factor handling (pre-existing gap: no `ScaleFactorChanged`
arm, `scale: 1.0` hardcoded); cursor-blink and other GPU chrome
config the roadmap groups nearby; TUI font anything (terminal-owned
by definition); **viewport-aware context-menu layout** (horizontal
ellipsis plus vertical flip/window/scroll — extreme sizes deliberately
surface-clip in stage 2); wrap-exact minimap-thumb/status-percentage
accounting (the current source-line estimate remains conservative;
visual-run exactness is load-bearing for caret following and gutter
alignment here, not promoted into the whole-file overview model);
horizontal reveal for a single indivisible glyph wider than the code
viewport (cosmic-text computes `Scroll.horizontal`, but glyphon 0.11's
renderer does not apply it; a frontend-local x-offset would have to
thread through painting, caret/decorations, popup anchors, and hit
testing).
## Acceptance
Suites: `tests/gpu_font_acceptance.rs` (wire + Lua + producer),
existing pin/round-trip homes in `src/protocol.rs`, and the GPU
route tests in pmacs-gpu's headless suite (`PMACS_REQUIRE_GPU=1`).
1. **Version pins**: `PROTOCOL_VERSION == 17`; ladder accepts
`6..=17` and rejects 18; `FontFacts` postcard round-trip (both
populated and all-`None`); byte-level encoding pin of
`ThemeFacts` (the final pre-v17 variant) proving the appended
placement shifted no existing discriminant.
2. **Authoritative default per attachment**: a fresh session's first
frame after viewport declaration carries `FontFacts { family:
None, size_centi_px: None }`; unchanged ticks are silent; a
late-joining second session receives the current preference
without any mutation post-attach.
3. **Live re-ship**: `pmacs.gpu.set_font { size = 18 }` mid-session
emits exactly one `FontFacts` on the next frame; an identical
re-set advances the inspected epoch without emitting (asserted on
internal state, the caches-advance-on-computation pin).
4. **Snapshot survival, both sides**: `on_buffer_snapshot_sent` leaves
the producer's font baselines untouched — an A → B → A round trip
re-ships buffer facts but NOT `FontFacts`. The GPU `BufferSnapshot`
arm retains the resolved family, metrics, and derived geometry; it
resets the normalized code scroll with the other BUFFER-scoped view
state so B cannot inherit A's visual residual. The new buffer shapes
under the same preference without waiting for a redundant global
fact. A byte-identical snapshot also removes the prior buffer's
minimap reservation before reshaping, so identical text cannot retain
the old buffer's narrower code clip.
5. **Version gate**: a v16 peer session never receives `FontFacts`
(producer `for_peer` + daemon skip arm, the real-daemon probe
shape from stage 1).
6. **Lua contract**: bad size (non-finite, out of range — including
`5.999`, which must error rather than round into range) and bad
family (empty, non-string) throw with the offending field named;
nothing lands and nothing emits on a failed set; quantization
pins values on both sides of a hundredth (e.g. `15.994` → 1599,
`15.996` → 1600); an unknown key is rejected with its name; a hostile
or value-providing metatable is never invoked; `pmacs.gpu.font()`
returns a fresh quantized plain table; `set_font {}` resets both
axes. Every rejected shape leaves state and emissions untouched.
7. **Init.lua reachability** (finding 5): a `load_user_config_at`
fixture whose init.lua calls `pmacs.gpu.set_font` succeeds, and
the first attachment's first frame ships that preference (the
handle installs before user config runs, `src/editor.rs:455`).
8. **TUI drop arm**: the grid frontend consumes `FontFacts` without
error (family test pattern).
9. **GPU size route**: applying `FontFacts { size_centi_px:
Some(2000) }` to a headless state changes the rendered frame,
widens `mono_advance`/gutter, and reduces
`estimated_visible_lines`; re-applying `(None, None)` restores
the original frame byte-identically (unset = today's constants).
10. **GPU band geometry at the bounds — owned containment only**
(round 1 finding 2, narrowed in rounds 2/3): with status band,
minibuffer, and completion surfaces open, applying the minimum
(600) and maximum (7200) sizes yields VERTICAL containment — code
glyphs stop at the band edge, status glyphs fit inside the derived
band height, and the two dropdowns' existing row windows remain
inside the surface. Popups may occlude code by layer contract. A
separate context-menu route uses the shipped multi-row menu near
the lower edge at size 7200 and asserts safe surface clipping, no
panic, and hit-testing for pixels inside the surface that agrees
with the same clipped geometry; it does NOT assert containment or
complete labels/rows. The derived
band height tracks the status line, and `text_area_bottom` /
minimap height / source-line visible estimate follow.
11. **GPU caret survival — source + wrapped visual runs** (round 1
finding 3 + round 2 finding 2 + round 3 finding 1):
with the caret on the OLD last visible line, applying a larger
size keeps the caret rendered and the re-declared viewport's
origin corrected after scroll normalization; with the caret
deliberately scrolled to exactly ONE source line past the painted
window — inside `view_range`'s two-line overscan — the same size
change does NOT snap back. The load-bearing wrap bite is one long
source line whose end caret is painted at size 600 but wraps below
the code clip at size 7200: after the change the caret is painted,
`Buffer::scroll` carries the needed VERTICAL visual-run offset (and
keeps `horizontal == 0`), any nonzero slice-local line is normalized
into `scroll_top`; `view_range` agrees with that source origin, and
gutter continuation blanks keep
the next source-line number aligned. The caret rect and completion
popup anchor both resolve to the run containing the byte rather than
the first run of that source line; an inline adornment before the
byte proves the source→projected conversion is not an identity map.
A moved `CursorByte` repeats the guarantee; an optimistic insertion
that creates a new bottom-edge
wrap follows immediately and its identical confirming `CursorByte`
needs no second repair; a stationary cursor after an explicit wheel
scroll does not snap. A reverse 7200→600 change while the caret is
off-screen collapses wraps, translates any cosmic `scroll.line`
advance into whole-file `scroll_top`, and preserves a nonblank
viewport without following the caret. A width-only narrow/widen
resize repeats both painted and scrolled-away halves through the
shared helper.
12. **GPU family routes** (rounds 1/3 finding 4): a test-embedded second
monospaced font resolves and changes the frame; a test-embedded
PROPORTIONAL font is rejected by the monospace gate and falls
back to the default family query; an unresolvable family name does
the same. A monospaced system-order default fixture remains the
default query winner in the parameterized sanitizer unit (baseline
preservation); a same-family BOLD proportional collision is removed
during database assembly, styled
code still selects only monospaced faces, and the bundled ID is
present in cosmic-text's monospace-ID set. All four fixture IDs are
retained from the pre-`FontSystem` assembly and checked against
cosmic-text's real `is_monospace` classification. Direct resolver
units cover normal, bold, italic, and bold-italic queries. Both
rejected-request routes and the collision-safe default reset render
byte-identically to never-set.
13. **GPU shaping-cache invalidation**: with composed band strings
constant, a size change re-shapes the status band (the Q#TH8
counter lesson applied to metrics; only the status buffers cache
composed strings, finding 6).
14. **GPU viewport re-declaration**: a size change that alters
`estimated_visible_lines` produces a `Viewport` re-declaration
(`viewport_send_if_changed` returns `Some`).
15. **Protocol/design docs**: `docs/semantic-frontend-protocol.md`
records `FontFacts`, its v17 gate, authoritative default, and
snapshot survival; `docs/pmacs-gpu-design.md:298-299` no longer
claims font customization needs no wire change and points here.
16. **GPU wire validation fails closed** (round 2 finding 1):
applying `FontFacts` with `size_centi_px` of 0 (the
`Buffer::set_metrics` panic value), 599, 7201, and `u32::MAX`
directly to the GPU arm mutates nothing — the frame renders
byte-identically to before, no panic, no partial application —
while 600 and 7200 apply.
17. **Metric/dimension atomicity and resize symmetry** (round 3
finding 3): resize the headless surface, then apply both size
bounds before rendering. Immediately after each application,
`Buffer::size()` reports the actual drawable code width/height for
code, the derived band height for both status buffers, the current
surface height for menu/mb/completion, and the current code height
for gutter; no buffer retains construction-time or prior-size
dimensions. A family whose advance changes the gutter width forces
the final code-width reflow, and the resulting wrap/hit map and
rendered clip use that same width. Line-number enable/disable,
gutter digit transitions, and minimap appearance/removal exercise
the same dynamic reflow transaction independently of a font change.
18. **Popup row invariance at both size bounds**: long menu,
minibuffer-candidate, and completion labels remain one layout run per
wire row at 600 and 7200 (`Wrap::None`). Their selection quads and
in-surface hit tests select the same semantic row as the painted
glyphs; overlong horizontal text clips rather than creating an
untracked second row.
19. **Family-dependent geometry on an empty document**: with line
numbers and a context menu open over an empty buffer, switching to
the alternate embedded monospace family updates the measured
gutter fallback and menu hit width by the selected/default advance
ratio. Reset restores the exact original geometry and frame; the
test does not depend on a code glyph already being shaped. A styled
twin whose first code glyph is bold proves gutter measurement still
uses the selected family's normal face rather than that glyph.

View File

@ -289,14 +289,21 @@ override.**
classified as a small finding under rule (iii) and absorbed; the
bundled `fonts/OFL.txt` is shipped alongside the TTF as required
by the OFL.
- Lua override: `pmacs.gpu.set_font(path)` or similar (precise binding
shape decided session 2).
- 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; future customization needs no wire-protocol changes.
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

View File

@ -126,8 +126,11 @@ invalidating its per-buffer emission baselines whenever it writes a
snapshot, so the frontend's post-snapshot viewport declaration
receives authoritative re-sends even when nothing changed
daemon-side (the unchanged-generation A → B → A revisit). Bufferless
facts (`ThemeFacts`, the minibuffer prompt) and per-frontend state
(the gutter mode) survive snapshots on both sides, and the
facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and
per-frontend state (the gutter mode) survive snapshots on both sides
(frontend-locally the normalized code scroll — a caret-follow view
residual — is buffer-scoped and resets, while the resolved font and
derived metrics survive), and the
instance's stale-store diagnostic-count freeze is store knowledge,
not session state — the re-sent `StatusFacts` after a snapshot
carries the frozen counts, never zeros, including for a session
@ -245,6 +248,28 @@ ResourceOffer {
ThemeFacts {
faces: Vec<ThemeFace>, // { name: String, style: Style }, sorted by name
},
/// The GLOBAL font preference (protocol v17, Arc 4 stage 2,
/// docs/gpu-set-font-framing.md), written by `pmacs.gpu.set_font`.
/// Bufferless and authoritative per attachment: every session's
/// first frame after viewport declaration carries the current
/// preference — the all-default `(None, None)` included, never
/// inferred from silence — and it is epoch-gated/cached-compare
/// suppressed thereafter, so an unchanged preference costs one
/// small message per attachment. `BufferSnapshot` resets never
/// touch it on either side. The daemon relays a PREFERENCE only
/// (no pixels): the frontend resolves the family locally
/// (monospace-gated, total fallback to its sanitized default) and
/// owns every metric consequence; sizes travel as integer
/// hundredths of a logical pixel (1600 = 16.0, validated to
/// 600..=7200 on BOTH sides — the receiver fails closed on
/// out-of-range wire values). Daemon-gated `>= 17`; appended as
/// the FINAL variant — postcard discriminants are ordinal, and the
/// ThemeFacts byte pin above guards this placement.
FontFacts {
family: Option<String>, // None = the frontend's default family
size_centi_px: Option<u32>, // None = the frontend's default size
},
```
Each family member diffs against the previous frame the same way

View File

@ -50,6 +50,10 @@ env_logger = "0.11.10"
# ends up with two cosmic-text versions resolving to the same name.
glyphon = "0.11.0"
loro = "=1.12.0"
# Locale for cosmic-text FontSystem construction (Q#F6 sanitized db
# assembly) -- the same crate cosmic-text uses internally, so the
# resolved locale matches what FontSystem::new() would have picked.
sys-locale = "0.3"
# Session 1's wire-types crate. Pulled in now so the dep graph is
# settled from session 2 forward; protocol consumption itself lands
# in session 3.

View File

@ -0,0 +1,6 @@
The Pmacs Test* font fixtures in this directory are trivial synthetic
faces (rectangle glyphs over space, digits, and a-z) generated for the
pmacs-gpu test suite by the accompanying generate.py script. They are
original to the pmacs project, contain no third-party outlines or
data, and are released under the same license as the pmacs source
tree. They are test fixtures, not usable typefaces.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Generate the four hermetic test faces for the pmacs-gpu font tests.
The gpu-set-font acceptance suite (docs/gpu-set-font-framing.md) needs
family-routing tests that cannot depend on whatever fonts the host has
installed, so these tiny fixture faces are generated and committed:
PmacsTestMonoTwo-Regular.ttf "Pmacs Test Mono Two" monospaced,
advance 720/1000 (JetBrains Mono is
600/1000, so the measured advance
ratio is exactly 1.2), with true
"01" and "fi" ligatures whose advances
preserve two cells
PmacsTestProportional-Regular.ttf "Pmacs Test Proportional" varying
advances, not monospaced
PmacsTestFamily-Regular.ttf "Pmacs Test Family" monospaced
normal face, advance 800/1000
PmacsTestFamily-Bold.ttf "Pmacs Test Family" BOLD and
proportional -- the same-family
collision the sanitizer removes and
the four-style monospace gate must
reject
Every glyph is a plain rectangle (ink for frame-diff tests); coverage
is space, the digits (the ADVANCE_PROBE string), and a-z. fontdb's
`monospaced` flag reads the post table's isFixedPitch, so that is the
one bit that decides mono vs proportional here.
Run from this directory: python3 generate.py
Requires fontTools (any recent version).
"""
from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
UPM = 1000
CHARS = " 0123456789abcdefghijklmnopqrstuvwxyz"
ASCENT = 800
DESCENT = -200
# Fixed at the first committed fixture generation so re-running this
# script changes only intentional font data, not the head timestamps.
FIXTURE_TIMESTAMP = 3866975598
def glyph_name(char):
return "uni%04X" % ord(char)
def rect_glyph(advance):
"""A filled rectangle spanning most of the advance width."""
pen = TTGlyphPen(None)
left = 60
right = max(left + 40, advance - 60)
pen.moveTo((left, 0))
pen.lineTo((right, 0))
pen.lineTo((right, 700))
pen.lineTo((left, 700))
pen.closePath()
return pen.glyph()
def empty_glyph():
return TTGlyphPen(None).glyph()
def build(
path,
family,
style,
weight,
bold,
fixed_pitch,
advance_for,
ligatures=(),
):
order = [".notdef"] + [glyph_name(c) for c in CHARS]
order += [name for name, _ in ligatures]
fb = FontBuilder(UPM, isTTF=True)
fb.setupGlyphOrder(order)
fb.setupCharacterMap({ord(c): glyph_name(c) for c in CHARS})
glyphs = {".notdef": rect_glyph(600)}
metrics = {".notdef": (600, 60)}
for c in CHARS:
adv = advance_for(c)
name = glyph_name(c)
glyphs[name] = empty_glyph() if c == " " else rect_glyph(adv)
metrics[name] = (adv, 0 if c == " " else 60)
for name, components in ligatures:
advance = sum(advance_for(c) for c in components)
glyphs[name] = rect_glyph(advance)
metrics[name] = (advance, 60)
fb.setupGlyf(glyphs)
fb.setupHorizontalMetrics(metrics)
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
# fontdb refuses faces without a PostScript name (nameID 6).
ps_name = (family + "-" + style).replace(" ", "")
fb.setupNameTable({"familyName": family, "styleName": style, "psName": ps_name})
fb.setupOS2(
sTypoAscender=ASCENT,
sTypoDescender=DESCENT,
usWinAscent=ASCENT,
usWinDescent=-DESCENT,
usWeightClass=weight,
fsSelection=0x20 if bold else 0x40, # BOLD else REGULAR
)
fb.setupPost(isFixedPitch=1 if fixed_pitch else 0)
if bold:
fb.font["head"].macStyle = 0x01
if ligatures:
substitutions = "\n".join(
"sub %s by %s;"
% (" ".join(glyph_name(c) for c in components), name)
for name, components in ligatures
)
addOpenTypeFeaturesFromString(
fb.font,
"feature liga {\n%s\n} liga;" % substitutions,
)
fb.font["head"].created = FIXTURE_TIMESTAMP
fb.font["head"].modified = FIXTURE_TIMESTAMP
fb.font.recalcTimestamp = False
fb.save(path)
print("wrote", path)
def proportional_advance(c):
if c == " ":
return 250
if c.isdigit():
return 500
# A spread of widths so no two adjacent letters share one.
return 300 + (ord(c) - ord("a")) * 15
build(
"PmacsTestMonoTwo-Regular.ttf",
"Pmacs Test Mono Two",
"Regular",
400,
bold=False,
fixed_pitch=True,
advance_for=lambda c: 720,
ligatures=(
("zero_one.liga", "01"),
("f_i.liga", "fi"),
),
)
build(
"PmacsTestProportional-Regular.ttf",
"Pmacs Test Proportional",
"Regular",
400,
bold=False,
fixed_pitch=False,
advance_for=proportional_advance,
)
build(
"PmacsTestFamily-Regular.ttf",
"Pmacs Test Family",
"Regular",
400,
bold=False,
fixed_pitch=True,
advance_for=lambda c: 800,
)
build(
"PmacsTestFamily-Bold.ttf",
"Pmacs Test Family",
"Bold",
700,
bold=True,
fixed_pitch=False,
advance_for=proportional_advance,
)

File diff suppressed because it is too large Load Diff

View File

@ -983,11 +983,12 @@ pub enum InstanceMessage {
/// included — with its first emission after viewport declaration;
/// cached-compare suppressed thereafter. Daemon-gated `>= 16`.
///
/// Appended as the FINAL variant deliberately: postcard
/// Appended as the final v16 variant deliberately: postcard
/// discriminants are ordinal, so inserting earlier would shift
/// every later variant's tag and corrupt v15 peers on ungated
/// channels. The `CompletionPopup` byte pin in `src/protocol.rs`
/// guards this placement.
/// guards this placement; the `ThemeFacts` byte pin there guards
/// the v17 `FontFacts` placement after it in turn.
ThemeFacts {
/// Every stage-1 face that resolves to a style (the Q#TH4
/// dotted-prefix walk, resolved daemon-side — frontends do
@ -995,6 +996,36 @@ pub enum InstanceMessage {
/// for deterministic comparison.
faces: Vec<ThemeFace>,
},
/// Themes arc stage 2 (Q#F4, protocol v17). The daemon-relayed
/// GPU font preference. One global instance ⇒ bufferless (the
/// [`Self::MinibufferPrompt`] shape). Complete replacement each
/// send; `None` means the frontend's built-in default for that
/// axis. The daemon relays a PREFERENCE — it never learns
/// metrics, advances, or what resolves; the frontend owns
/// resolution and every pixel consequence (the no-pixels
/// invariant). Every attachment receives exactly one
/// authoritative preference — the all-default `(None, None)`
/// included — with its first emission after viewport
/// declaration; cached-compare suppressed thereafter.
/// Daemon-gated `>= 17`.
///
/// Appended as the FINAL variant deliberately: postcard
/// discriminants are ordinal, so inserting earlier would shift
/// every later variant's tag and corrupt v16 peers on ungated
/// channels. The `ThemeFacts` byte pin in `src/protocol.rs`
/// guards this placement.
FontFacts {
/// Font family name to resolve frontend-locally, or `None`
/// for the frontend's default family query.
family: Option<String>,
/// Font size in HUNDREDTHS of a logical pixel (1600 =
/// today's 16.0) — an integer because this enum derives
/// `Eq`, which `f32` cannot satisfy, and because cosmic-text
/// metrics are logical pixels, not typographic points.
/// Valid range 600..=7200; frontends validate and fail
/// closed (deserialized protocol input is untrusted).
size_centi_px: Option<u32>,
},
}
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
@ -1373,7 +1404,14 @@ pub enum ResourceBody {
/// The variant is appended after `CompletionPopup` — the final v15
/// variant — because postcard discriminants are ordinal and an
/// earlier insertion would shift existing tags under v15 peers.
pub const PROTOCOL_VERSION: u32 = 16;
///
/// GPU font preference (Q#F4): bumped 16 → 17 for
/// [`InstanceMessage::FontFacts`] — a new additive variant relaying
/// the global font preference to GPU-capable peers. Daemon-gated
/// `< 17`; a v16 peer negotiates v16 and simply keeps its built-in
/// font. Appended after `ThemeFacts` — the final v16 variant —
/// same ordinal-discriminant reasoning as every additive bump.
pub const PROTOCOL_VERSION: u32 = 17;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1436,7 +1474,10 @@ pub const PROTOCOL_VERSION: u32 = 16;
///
/// Q#TH7: extended to `[6, ..., 16]`. `InstanceMessage::ThemeFacts`
/// is additive and daemon-gated per session, so the ladder resumes.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
///
/// Q#F4: extended to `[6, ..., 17]`. `InstanceMessage::FontFacts`
/// is additive and daemon-gated per session, so the ladder resumes.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -1139,6 +1139,11 @@ fn dispatcher_loop(
let peer_knows_theme_facts = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 16);
// Themes stage 2 Q#F4 — FontFacts gated at v17; a v16
// peer simply keeps its built-in font.
let peer_knows_font_facts = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 17);
for msg in &messages {
if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. })
@ -1178,6 +1183,9 @@ fn dispatcher_loop(
{
continue;
}
if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) {
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency.
//

View File

@ -69,6 +69,10 @@ pub struct EditorState {
/// supervisor's reader threads, which means a runaway server's
/// log-flood doesn't stall the editor.
pub lsp_manager: crate::lsp::SharedLspManager,
/// The global GPU font preference (Arc 4 stage 2, Q#F3). Written
/// by `pmacs.gpu.set_font`; read by the `semantic_render`
/// producer, which relays it as `FontFacts` (protocol v17).
pub font_pref: crate::font_pref::FontPrefHandle,
/// MCP manager (T M9.1). Holds one [`crate::mcp::McpClient`] per
/// MCP server; rides on top of [`Self::process_supervisor`] for
/// spawn / I/O / restart, sharing the supervisor with the LSP
@ -211,6 +215,12 @@ impl EditorState {
// state, but its search overlay resolves wash faces through
// this handle.
core.borrow_mut().theme = Some(syntax_registry.theme());
// Arc 4 stage 2 (Q#F2/Q#F3): the GPU font preference and its
// `pmacs.gpu` Lua surface. Installed BEFORE load_user_config
// below, so an init.lua `set_font` lands in the same handle
// the first attachment's semantic producer reads.
let font_pref =
crate::lua_bindings::make_font_pref(lua_host.lua()).expect("install pmacs.gpu");
lua_host
.eval(
Some("@pmacs/builtin/runtime/syntax.lua"),
@ -465,6 +475,7 @@ impl EditorState {
syntax_registry,
process_supervisor,
lsp_manager,
font_pref,
mcp_manager,
workspace,
project_indexer,

42
src/font_pref.rs Normal file
View File

@ -0,0 +1,42 @@
//! The global GPU font preference (Arc 4 stage 2, framing Q#F3,
//! `docs/gpu-set-font-framing.md`).
//!
//! One daemon-side preference — family name and/or size — written by
//! `pmacs.gpu.set_font` and read by the `semantic_render` producer,
//! which relays it to GPU-capable peers as the bufferless
//! `InstanceMessage::FontFacts` at protocol v17. The daemon relays a
//! PREFERENCE: it never learns metrics, advances, or what resolves
//! (the no-pixels invariant); the frontend owns resolution and every
//! pixel consequence.
use std::sync::{Arc, Mutex};
/// Shared handle, mirroring [`crate::highlight::ThemeHandle`]'s
/// shape: the Lua setter writes it, per-session producers read it.
pub type FontPrefHandle = Arc<Mutex<FontPref>>;
/// The preference itself. `None` per axis means "the frontend's
/// built-in default" — a REAL, always-shipped state, never inferred
/// from silence (the Q#TH7 authoritative-per-attachment lesson).
#[derive(Debug, Default)]
pub struct FontPref {
/// Font family name to resolve frontend-locally, or `None` for
/// the frontend's default family query.
pub family: Option<String>,
/// Size in HUNDREDTHS of a logical pixel (1600 = 16.0), already
/// validated and quantized by the Lua boundary (range-check the
/// original value first, then nearest-hundredth via round —
/// framing Q#F2). `u32` matches the wire, which derives `Eq`.
pub size_centi_px: Option<u32>,
/// Monotonic mutation counter, increment-only from its prior
/// value on every successful `set_font` (the Q#TH6 lesson). The
/// producer's `Option`-seeded gate compares this one `u64` per
/// tick.
pub epoch: u64,
}
/// Fresh all-default preference behind a new handle.
#[must_use]
pub fn new_handle() -> FontPrefHandle {
Arc::new(Mutex::new(FontPref::default()))
}

View File

@ -418,6 +418,10 @@ impl Frontend {
// (the daemon resolves faces at paint time), so it drops
// this silently like the other semantic families.
| InstanceMessage::ThemeFacts { .. }
// Themes stage 2 Q#F4 — FontFacts is the GPU font
// preference; terminal fonts belong to the terminal, so
// the cell-grid TUI drops this silently too.
| InstanceMessage::FontFacts { .. }
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path
@ -791,6 +795,28 @@ mod tests {
.expect("the grid frontend must drop ThemeFacts silently");
}
#[test]
fn font_facts_drops_silently_on_the_grid_frontend() {
// Themes stage 2 Q#F4 / acceptance 8: terminal fonts belong
// to the terminal, so a `FontFacts` reaching the cell-grid
// TUI — which never negotiates it — must fall into the
// semantic-family silent drop, not error.
let mut fe = Frontend {
out: BufWriter::new(io::stdout()),
size: CellSize::new(24, 80),
raw_mode: false,
alt_screen: false,
bracketed_paste: false,
mouse: false,
keyboard_enhancement: false,
};
fe.apply_message(&InstanceMessage::FontFacts {
family: Some("Iosevka".into()),
size_centi_px: Some(1800),
})
.expect("the grid frontend must drop FontFacts silently");
}
#[test]
fn emit_span_writes_cursor_move_then_chars() {
let span = DiffSpan {

View File

@ -73,6 +73,7 @@ pub mod document_highlight;
pub mod editor;
pub mod editor_core;
pub mod file_io;
pub mod font_pref;
pub mod formatting;
pub mod frontend;
pub mod fs;

View File

@ -7559,6 +7559,121 @@ pub fn install_process(lua: &Lua, supervisor: &SharedProcessSupervisor) -> mlua:
Ok(())
}
/// `pmacs.gpu.*` — GPU frontend preferences (Arc 4 stage 2, framing
/// Q#F2). Installs the module and returns the shared preference
/// handle the `semantic_render` producer reads. Called from
/// `EditorState::new` BEFORE `load_user_config` runs: font selection
/// is primarily configuration, and an init.lua `set_font` must land
/// in the same state the first attachment's producer reads.
///
/// `set_font` follows the live `pmacs.theme.set` pattern — no
/// `require_init_phase` gate; mid-session calls re-ship on the next
/// frame. The kwargs table is STRICT PLAIN DATA: `raw_get` reads,
/// unknown raw keys are rejected by name, and metatables are never
/// consulted (`for_each` iterates raw pairs) — a hostile `__index`
/// cannot inject values, and the whole table is parsed, validated,
/// and quantized before the lock is taken (all-or-nothing, Q#TH6).
pub fn make_font_pref(lua: &Lua) -> mlua::Result<crate::font_pref::FontPrefHandle> {
let handle = crate::font_pref::new_handle();
let gpu = lua.create_table()?;
{
let h = handle.clone();
gpu.set(
"set_font",
lua.create_function(move |_, spec: Table| -> mlua::Result<()> {
// Reject unknown keys first, naming the offender —
// raw iteration, so metatable trickery is invisible.
let mut unknown: Option<String> = None;
spec.clone().for_each(|k: Value, _: Value| {
let name = match &k {
Value::String(s) => s.to_str()?.to_owned(),
other => format!("{other:?}"),
};
if name != "family" && name != "size" && unknown.is_none() {
unknown = Some(name);
}
Ok(())
})?;
if let Some(key) = unknown {
return Err(mlua::Error::external(format!(
"pmacs.gpu.set_font: unknown field `{key}` (expected `family` and/or `size`)"
)));
}
// Parse + validate the complete table BEFORE locking.
let family = match spec.raw_get::<Value>("family")? {
Value::Nil => None,
Value::String(s) => {
let f = s.to_str()?.to_owned();
if f.is_empty() {
return Err(mlua::Error::external(
"pmacs.gpu.set_font: `family` must be a non-empty string",
));
}
Some(f)
}
other => {
return Err(mlua::Error::external(format!(
"pmacs.gpu.set_font: `family` must be a string, got {}",
other.type_name()
)));
}
};
let size_centi_px = match spec.raw_get::<Value>("size")? {
Value::Nil => None,
Value::Integer(i) => Some(validate_font_size(i as f64)?),
Value::Number(n) => Some(validate_font_size(n)?),
other => {
return Err(mlua::Error::external(format!(
"pmacs.gpu.set_font: `size` must be a number, got {}",
other.type_name()
)));
}
};
let mut pref = h.lock().expect("font pref mutex poisoned");
pref.family = family;
pref.size_centi_px = size_centi_px;
pref.epoch += 1;
Ok(())
})?,
)?;
}
{
let h = handle.clone();
gpu.set(
"font",
lua.create_function(move |lua, ()| -> mlua::Result<Table> {
// A FRESH plain table each call — a getter, never the
// stored table or a mutable handle (Q#F2).
let t = lua.create_table()?;
let pref = h.lock().expect("font pref mutex poisoned");
if let Some(f) = &pref.family {
t.set("family", f.clone())?;
}
if let Some(c) = pref.size_centi_px {
t.set("size", f64::from(c) / 100.0)?;
}
Ok(t)
})?,
)?;
}
let pmacs: Table = lua.globals().get("pmacs")?;
pmacs.set("gpu", gpu)?;
Ok(handle)
}
/// Range-check the ORIGINAL value first — `5.999` must error, not
/// round into range — then quantize to the nearest hundredth of a
/// logical pixel (framing Q#F2, round 2 finding 5).
fn validate_font_size(size: f64) -> mlua::Result<u32> {
if !size.is_finite() || !(6.0..=72.0).contains(&size) {
return Err(mlua::Error::external(format!(
"pmacs.gpu.set_font: `size` must be a finite number in [6.0, 72.0] logical px, got {size}"
)));
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Ok((size * 100.0).round() as u32)
}
/// Build a fresh [`ProcessSupervisor`] and install
/// `pmacs.process.*` over it. Mirrors [`make_async_runtime`] /
/// [`make_syntax_registry`] in shape.

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_sixteen_for_theme_facts() {
fn protocol_version_is_seventeen_for_font_facts() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1708,8 +1708,11 @@ mod tests {
// Arc 1a Q#C5 bumped 14→15 (`InstanceMessage::CompletionPopup`,
// additive + daemon-gated). Themes Q#TH7 bumped 15→16
// (`InstanceMessage::ThemeFacts`, additive + daemon-gated,
// appended as the final variant — see the placement pin).
assert_eq!(PROTOCOL_VERSION, 16);
// appended as the final v16 variant — see the placement pin).
// Themes stage 2 Q#F4 bumped 16→17 (`InstanceMessage::
// FontFacts`, additive + daemon-gated, appended as the final
// variant — see the ThemeFacts placement pin).
assert_eq!(PROTOCOL_VERSION, 17);
}
#[test]
@ -1783,18 +1786,18 @@ mod tests {
// (`TripleDown`), v8 (`StatusFacts`), v9 + v10 (`SearchPrompt` +
// regex/invalid), v11 (the context menu), v12 (the GUI
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
// (`CompletionPopup`), v16 (`ThemeFacts`) all interoperate, so
// v6 through v16 talk.
for accepted in 6..=16 {
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`)
// all interoperate, so v6 through v17 talk.
for accepted in 6..=17 {
assert!(
is_supported_protocol_version(accepted),
"v{accepted} must be accepted"
);
}
for rejected in [0, 1, 2, 3, 4, 5, 17, u32::MAX] {
for rejected in [0, 1, 2, 3, 4, 5, 18, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v16 binary"
"v{rejected} must be rejected by a v17 binary"
);
}
}
@ -1831,6 +1834,47 @@ mod tests {
}
}
#[test]
fn font_facts_round_trips_through_postcard() {
// Themes stage 2 Q#F4 (v17): the global GPU font preference.
// Pin the all-default (authoritative-unset) and populated
// shapes.
for msg in [
InstanceMessage::FontFacts {
family: None,
size_centi_px: None,
},
InstanceMessage::FontFacts {
family: Some("Iosevka".into()),
size_centi_px: Some(1850),
},
] {
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(msg, decoded);
}
}
#[test]
fn theme_facts_encoding_is_unchanged_by_the_v17_build() {
// Q#F4 placement pin: `FontFacts` must be APPENDED after
// `ThemeFacts` — the final v16 variant, whose ordinal moves
// if anything is inserted before any v16 variant. These are
// the exact bytes a v16 binary produced for this value
// (discriminant 23 as a postcard varint, then the empty
// face vector); the new variant's own round-trip cannot
// detect a shift.
let msg = InstanceMessage::ThemeFacts { faces: Vec::new() };
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes,
[23, 0],
"ThemeFacts' v16 wire bytes changed — a variant was \
inserted before it; append new InstanceMessage variants \
at the end"
);
}
#[test]
fn completion_popup_encoding_is_unchanged_by_the_v16_build() {
// Themes Q#TH7 placement pin: postcard discriminants are

View File

@ -236,6 +236,21 @@ pub struct SemanticRenderState {
/// and counters stay unthemed, so this producer resolves faces
/// only when the peer can apply the whole face table.
peer_knows_theme_facts: bool,
/// The font-pref `epoch` this producer last INSPECTED (Q#F5) —
/// `Option`, not a bare zero, or an all-default daemon's `0 == 0`
/// short-circuit would starve the first authoritative send.
/// Advances on computation, not emission.
last_font_epoch: Option<u64>,
/// The preference the frontend believes (Q#F5), seeded `None` so
/// every attachment receives exactly one authoritative
/// `FontFacts` — the all-default `(None, None)` included.
/// Bufferless: `on_buffer_snapshot_sent` never touches it.
last_font_facts: Option<(Option<String>, Option<u32>)>,
/// Whether the peer negotiated protocol >= 17 (Q#F4). Unlike the
/// theme case there is no pre-v17 side channel that could leak
/// font state, so this gate has no summary-style companion
/// filter.
peer_knows_font_facts: bool,
/// Cached byte↔line table for the diagnostics projection, keyed
/// by buffer revision. Building it costs an O(buffer) rope copy
/// plus a full scan; before this cache, that ran on *every tick*
@ -336,6 +351,7 @@ impl SemanticRenderState {
pub fn for_peer(frontend_id: FrontendId, negotiated_protocol_version: u32) -> Self {
let mut s = Self::new(frontend_id);
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
s
}
@ -370,6 +386,13 @@ impl SemanticRenderState {
last_face_epoch: None,
last_theme_faces: None,
peer_knows_theme_facts: true,
// Q#F5: both seeded None — the first frame after viewport
// declaration always ships an authoritative FontFacts
// (the all-default preference included), and the epoch
// gate cannot short-circuit an epoch-0 daemon before it.
last_font_epoch: None,
last_font_facts: None,
peer_knows_font_facts: true,
diag_line_cache: HashMap::new(),
}
}
@ -621,6 +644,7 @@ impl SemanticRenderState {
out.extend(self.completion_popup_msg(state, vp.buffer_id));
// --- ThemeFacts (UI faces; themes arc Q#TH7, protocol v16) ---
out.extend(self.theme_facts_msg(state));
out.extend(self.font_facts_msg(state));
out
}
@ -1153,6 +1177,41 @@ impl SemanticRenderState {
Some(InstanceMessage::ThemeFacts { faces })
}
/// The `FontFacts` message for this frame, or `None` when the
/// preference is unchanged (Arc 4 stage 2, Q#F5, protocol v17).
/// The `theme_facts_msg` discipline exactly: an `Option`-seeded
/// epoch gate keeps unchanged ticks to one `u64` compare, the
/// `Option`-seeded payload baseline decides emission, both
/// advance on computation, and every attachment ships exactly
/// one authoritative preference — the all-default `(None, None)`
/// included — on its first frame after viewport declaration.
/// Bufferless: `on_buffer_snapshot_sent` never touches these
/// baselines.
fn font_facts_msg(&mut self, state: &EditorState) -> Option<InstanceMessage> {
// Never produced for a peer below v17 (the daemon write-loop
// gate remains as the belt-and-braces filter).
if !self.peer_knows_font_facts {
return None;
}
let (facts, epoch) = {
let pref = state.font_pref.lock().expect("font pref mutex poisoned");
if self.last_font_epoch == Some(pref.epoch) {
return None;
}
((pref.family.clone(), pref.size_centi_px), pref.epoch)
};
self.last_font_epoch = Some(epoch);
let unchanged = self.last_font_facts.as_ref() == Some(&facts);
self.last_font_facts = Some(facts.clone());
if unchanged {
return None;
}
Some(InstanceMessage::FontFacts {
family: facts.0,
size_centi_px: facts.1,
})
}
/// Project the [`Decoration`] set intersecting the declared
/// viewport: the session's selection (instance-authoritative,
/// byte-native) and LSP diagnostics (line/col → byte, severity →
@ -2407,6 +2466,105 @@ mod tests {
);
}
/// Pull the `FontFacts` payload out of a frame, if any.
fn font_facts_of(msgs: &[InstanceMessage]) -> Option<(Option<String>, Option<u32>)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::FontFacts {
family,
size_centi_px,
} => Some((family.clone(), *size_centi_px)),
_ => None,
})
}
/// Simulate a committed `pmacs.gpu.set_font`: what the Lua setter
/// does after its parse/validate/quantize (write + epoch bump).
fn set_font(state: &EditorState, family: Option<&str>, size_centi_px: Option<u32>) {
let mut pref = state.font_pref.lock().expect("font pref");
pref.family = family.map(str::to_owned);
pref.size_centi_px = size_centi_px;
pref.epoch += 1;
}
#[test]
fn font_facts_authoritative_default_then_silent_then_set_emits() {
// Q#F5 / acceptance 2-3: the first frame ships the
// authoritative all-default preference — the Option epoch
// gate must not short-circuit at 0 == 0 — then unchanged
// ticks say nothing; a set_font re-ships; an identical
// re-set advances the inspected epoch without emitting.
let state = empty_state();
let mut s = local();
let buffer_id = active_buffer(&state);
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let first = s.render_frame(&state);
assert_eq!(
font_facts_of(&first),
Some((None, None)),
"an all-default daemon still ships one authoritative preference"
);
assert_eq!(
font_facts_of(&s.render_frame(&state)),
None,
"unchanged ticks emit nothing"
);
set_font(&state, Some("Iosevka"), Some(1800));
assert_eq!(
font_facts_of(&s.render_frame(&state)),
Some((Some("Iosevka".into()), Some(1800))),
"a live set_font re-ships on the next frame"
);
assert_eq!(
font_facts_of(&s.render_frame(&state)),
None,
"and suppresses again once shipped"
);
// Identical re-set: epoch bumps, payload unchanged — nothing
// emits, but the inspected epoch advances (cache advances on
// computation, or every later tick would rebuild).
set_font(&state, Some("Iosevka"), Some(1800));
let bumped = state.font_pref.lock().expect("font pref").epoch;
assert_eq!(
font_facts_of(&s.render_frame(&state)),
None,
"identical re-set is suppressed"
);
assert_eq!(
s.last_font_epoch,
Some(bumped),
"the inspected epoch advanced despite the suppressed send"
);
}
#[test]
fn font_facts_never_produced_for_a_v16_peer() {
// Q#F4 / acceptance 5 (producer half; the daemon skip arm is
// the belt-and-braces filter).
let state = empty_state();
set_font(&state, None, Some(2000));
let buffer_id = active_buffer(&state);
let mut v16 = SemanticRenderState::for_peer(FrontendId::LOCAL, 16);
v16.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let frame = v16.render_frame(&state);
assert_eq!(font_facts_of(&frame), None, "v16 peers get no FontFacts");
assert!(
frame
.iter()
.any(|m| matches!(m, InstanceMessage::ThemeFacts { .. })),
"the same peer still receives v16 facts"
);
let mut v17 = SemanticRenderState::for_peer(FrontendId::LOCAL, 17);
v17.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
assert_eq!(
font_facts_of(&v17.render_frame(&state)),
Some((None, Some(2000))),
"a v17 peer receives the current preference"
);
}
#[test]
fn snapshot_reset_drops_one_buffers_baselines_and_keeps_the_rest() {
// PR #120 round 2 finding 1 — the reset contract's scope: a
@ -2452,6 +2610,11 @@ mod tests {
s.last_theme_faces, facts_baseline,
"ThemeFacts is bufferless — the face table survives snapshots"
);
assert_eq!(
s.last_font_facts,
Some((None, None)),
"FontFacts is bufferless too — the preference baseline survives"
);
// And the behavioral consequence: revisiting A at the SAME
// generation re-ships the summary the frontend just dropped.
@ -2500,6 +2663,7 @@ mod tests {
| InstanceMessage::SearchPrompt { .. }
| InstanceMessage::LineNumbers { .. }
| InstanceMessage::ThemeFacts { .. }
| InstanceMessage::FontFacts { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);
@ -2669,14 +2833,15 @@ mod tests {
// (the frontend clears its viewport), carrying empty segments.
// FileStyleSummary also emits on the first frame for this buffer
// (post-M11 minimap producer, generation-keyed), as does
// StatusFacts (Q#S1, cached-compare) and the authoritative
// ThemeFacts table (Q#TH7 — empty for an unthemed daemon).
// StatusFacts (Q#S1, cached-compare), the authoritative
// ThemeFacts table (Q#TH7 — empty for an unthemed daemon), and
// the authoritative FontFacts preference (Q#F5 — all-default).
let first = s.render_frame(&state);
assert_eq!(
first.len(),
5,
6,
"first frame ships StyleSpans + Decorations + FileStyleSummary \
+ StatusFacts + ThemeFacts"
+ StatusFacts + ThemeFacts + FontFacts"
);
assert_semantic_only(&first);
let (style_full, _) = style_segments(&first).expect("StyleSpans present");

View File

@ -0,0 +1,492 @@
// gpu_font_acceptance.rs --- gpu-set-font Arc 4 stage 2 acceptance
// (docs/gpu-set-font-framing.md, acceptance items 27; item 1's pins
// live in src/protocol.rs, item 8's TUI drop arm is a unit in
// src/frontend.rs, items 914 and 1619 are GPU routes in pmacs-gpu's
// headless suite, and item 15 is the docs themselves).
//! The `pmacs.gpu.set_font` preference + the `FontFacts` wire channel
//! (protocol v17).
//!
//! Wire claims drive a `SemanticRenderState` frame by frame (the
//! `ThemeFacts` discipline: authoritative per attachment, epoch-gated,
//! silent when unchanged); the Lua contract is exercised against the
//! real `pmacs.gpu` module installed by `EditorState::new`; the
//! version gate exercises a real daemon; init.lua reachability goes
//! through the real `load_user_config_at`.
use pmacs::editor::EditorState;
use pmacs::protocol::{ByteRange, FrontendId, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
#[cfg(feature = "crdt")]
mod common;
// ---------------------------------------------------------------------------
// Harness (theme_faces_acceptance conventions)
// ---------------------------------------------------------------------------
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn exec_err(s: &EditorState, src: &str) -> mlua::Error {
s.lua_host
.lua()
.load(src.to_string())
.exec()
.expect_err("chunk must error")
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
/// Fresh editor with LSP spawning disabled.
fn editor() -> EditorState {
let s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
s
}
fn active_buffer(state: &EditorState) -> pmacs::buffer::BufferId {
state.core.borrow().active_window().buffer_id
}
fn semantic(state: &EditorState) -> SemanticRenderState {
let buffer_id = active_buffer(state);
let mut s = SemanticRenderState::new(FrontendId::LOCAL);
s.set_viewport(
buffer_id,
ByteRange {
start: 0,
end: 1 << 20,
},
0,
);
s
}
fn font_facts_of(msgs: &[InstanceMessage]) -> Option<(Option<String>, Option<u32>)> {
msgs.iter().find_map(|m| match m {
InstanceMessage::FontFacts {
family,
size_centi_px,
} => Some((family.clone(), *size_centi_px)),
_ => None,
})
}
fn font_facts_count(msgs: &[InstanceMessage]) -> usize {
msgs.iter()
.filter(|m| matches!(m, InstanceMessage::FontFacts { .. }))
.count()
}
/// The daemon-side preference triple `(family, size_centi_px, epoch)`.
fn pref_of(state: &EditorState) -> (Option<String>, Option<u32>, u64) {
let pref = state.font_pref.lock().expect("font pref lock");
(pref.family.clone(), pref.size_centi_px, pref.epoch)
}
// ---------------------------------------------------------------------------
// 2 — authoritative default per attachment; unchanged ticks silent;
// late joiner receives the current preference
// ---------------------------------------------------------------------------
#[test]
fn first_frame_ships_the_authoritative_default_and_then_stays_silent() {
let state = editor();
let mut sem = semantic(&state);
let first = sem.render_frame(&state);
assert_eq!(
font_facts_of(&first),
Some((None, None)),
"a fresh attachment's first frame carries the REAL (None, None) default — \
never inferred from silence"
);
for tick in 0..3 {
let next = sem.render_frame(&state);
assert_eq!(
font_facts_count(&next),
0,
"unchanged tick {tick} must be FontFacts-silent"
);
}
// `state` untouched since the set below — a late-joining second
// session receives the current preference without any mutation
// post-attach.
exec(
&state,
r#"pmacs.gpu.set_font { family = "Iosevka", size = 18 }"#,
);
let mut late = semantic(&state);
let joined = late.render_frame(&state);
assert_eq!(
font_facts_of(&joined),
Some((Some("Iosevka".to_owned()), Some(1800))),
"a late joiner's first frame carries the current preference"
);
}
// ---------------------------------------------------------------------------
// 3 — live re-ship: one FontFacts on the next frame; an identical
// re-set advances the epoch without emitting
// ---------------------------------------------------------------------------
#[test]
fn set_font_reships_once_and_identical_resets_advance_the_epoch_silently() {
let state = editor();
let mut sem = semantic(&state);
let _ = sem.render_frame(&state);
exec(&state, "pmacs.gpu.set_font { size = 18 }");
let next = sem.render_frame(&state);
assert_eq!(
font_facts_count(&next),
1,
"a mid-session set emits exactly one FontFacts on the next frame"
);
assert_eq!(font_facts_of(&next), Some((None, Some(1800))));
let (_, _, epoch_before) = pref_of(&state);
exec(&state, "pmacs.gpu.set_font { size = 18 }");
let (_, _, epoch_after) = pref_of(&state);
assert_eq!(
epoch_after,
epoch_before + 1,
"an identical re-set still advances the epoch (caches advance on \
computation, Q#TH6)"
);
let silent = sem.render_frame(&state);
assert_eq!(
font_facts_count(&silent),
0,
"an identical payload does not re-emit"
);
}
// ---------------------------------------------------------------------------
// 4 — snapshot survival, producer side: the buffer-baseline reset
// re-ships buffer facts but never the bufferless FontFacts
// ---------------------------------------------------------------------------
#[test]
fn buffer_snapshot_reset_never_reships_font_facts() {
let state = editor();
exec(&state, "pmacs.gpu.set_font { size = 20 }");
let buffer_id = active_buffer(&state);
let mut sem = semantic(&state);
let first = sem.render_frame(&state);
assert_eq!(
font_facts_of(&first),
Some((None, Some(2000))),
"precondition: the preference shipped on the first frame"
);
let has_status = |msgs: &[InstanceMessage]| {
msgs.iter()
.any(|m| matches!(m, InstanceMessage::StatusFacts { .. }))
};
assert!(has_status(&first), "precondition: buffer facts shipped too");
// The daemon wrote a BufferSnapshot for this buffer (an A → B → A
// revisit): every BUFFER-scoped baseline resets…
sem.on_buffer_snapshot_sent(buffer_id);
let after = sem.render_frame(&state);
assert!(
has_status(&after),
"the reset re-ships the buffer's facts on the next frame"
);
// …but the global font baseline survives: the new buffer shapes
// under the same preference without a redundant global fact.
assert_eq!(
font_facts_count(&after),
0,
"FontFacts is bufferless — the snapshot reset must not touch it"
);
}
// ---------------------------------------------------------------------------
// 5 — the daemon version gate (v16 peer never receives FontFacts)
// ---------------------------------------------------------------------------
#[cfg(feature = "crdt")]
#[test]
fn v16_peer_never_receives_font_facts_and_v17_does() {
use common::daemon::{TestDaemon, build_default_caps};
use pmacs::cell::CellSize;
use pmacs::protocol::{AttachRequest, FrontendCapabilities, FrontendEvent, Hello};
use pmacs::transport::{read_message, write_message};
use std::time::{Duration, Instant};
fn semantic_caps() -> FrontendCapabilities {
FrontendCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
..build_default_caps()
}
}
/// Attach a semantic session at `version`, declare a viewport,
/// and report `(saw_font_facts, saw_style_spans)` within the
/// deadline.
fn probe(daemon: &TestDaemon, version: u32) -> (bool, bool) {
let mut stream = daemon.connect();
stream
.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
let hello: Hello = read_message(&mut stream).expect("read Hello");
let fid = hello.assigned_frontend_id;
write_message(
&mut stream,
&AttachRequest {
protocol_version: version,
frontend_capabilities: semantic_caps(),
initial_size: CellSize::new(24, 80),
},
)
.expect("write AttachRequest");
let mut buf = None;
let learn_by = Instant::now() + Duration::from_secs(2);
while Instant::now() < learn_by && buf.is_none() {
if let Ok(InstanceMessage::BufferSnapshot { buffer_id, .. }) =
read_message::<InstanceMessage>(&mut stream)
{
buf = Some(buffer_id);
}
}
let buffer_id = buf.expect("received a BufferSnapshot");
write_message(
&mut stream,
&FrontendEvent::Viewport {
frontend_id: fid,
buffer_id,
visible: ByteRange {
start: 0,
end: 4096,
},
generation: 0,
},
)
.expect("write Viewport");
let deadline = Instant::now() + Duration::from_secs(3);
let (mut saw_facts, mut saw_spans) = (false, false);
while Instant::now() < deadline && !(saw_facts && saw_spans) {
match read_message::<InstanceMessage>(&mut stream) {
Ok(InstanceMessage::FontFacts { .. }) => saw_facts = true,
Ok(InstanceMessage::StyleSpans { .. }) => saw_spans = true,
Ok(_) | Err(_) => {}
}
}
(saw_facts, saw_spans)
}
let daemon = TestDaemon::spawn();
let (v17_facts, v17_spans) = probe(&daemon, 17);
assert!(v17_spans, "a v17 semantic session receives StyleSpans");
assert!(
v17_facts,
"a v17 semantic session receives the authoritative FontFacts"
);
let (v16_facts, v16_spans) = probe(&daemon, 16);
assert!(v16_spans, "a v16 peer still receives StyleSpans");
assert!(
!v16_facts,
"the daemon skip arm must keep FontFacts off a v16 wire"
);
}
// ---------------------------------------------------------------------------
// 6 — the Lua contract: strict plain data, all-or-nothing
// ---------------------------------------------------------------------------
#[test]
fn set_font_rejects_bad_sizes_naming_the_field_and_nothing_lands() {
let state = editor();
let mut sem = semantic(&state);
let _ = sem.render_frame(&state);
let before = pref_of(&state);
for bad in [
"pmacs.gpu.set_font { size = 5.999 }", // must error, not round into range
"pmacs.gpu.set_font { size = 72.01 }",
"pmacs.gpu.set_font { size = 0 }",
"pmacs.gpu.set_font { size = -16 }",
"pmacs.gpu.set_font { size = 0/0 }", // NaN
"pmacs.gpu.set_font { size = 1/0 }", // +inf
"pmacs.gpu.set_font { size = '16' }", // non-number
] {
let err = exec_err(&state, bad);
assert!(
err.to_string().contains("`size`"),
"{bad}: the error names the offending field, got: {err}"
);
}
assert_eq!(pref_of(&state), before, "no failed set may land");
let silent = sem.render_frame(&state);
assert_eq!(
font_facts_count(&silent),
0,
"no failed set may emit on the wire"
);
}
#[test]
fn set_font_rejects_bad_families_and_unknown_keys_by_name() {
let state = editor();
let err = exec_err(&state, "pmacs.gpu.set_font { family = '' }");
assert!(
err.to_string().contains("`family`"),
"empty family names the field: {err}"
);
let err = exec_err(&state, "pmacs.gpu.set_font { family = 12 }");
assert!(
err.to_string().contains("`family`"),
"non-string family names the field: {err}"
);
let err = exec_err(&state, "pmacs.gpu.set_font { size = 18, sise = 20 }");
assert!(
err.to_string().contains("`sise`"),
"an unknown key is rejected by NAME: {err}"
);
assert_eq!(
pref_of(&state),
(None, None, 0),
"every rejected shape leaves the preference untouched"
);
}
#[test]
fn set_font_never_consults_metatables() {
let state = editor();
// A hostile `__index` that answers every lookup: raw reads must
// never see its values, and raw iteration must never invoke it.
exec(
&state,
r#"
_G.__mt_hits = 0
local spec = setmetatable({ size = 18 }, {
__index = function(_, _)
_G.__mt_hits = _G.__mt_hits + 1
return "Injected Family"
end,
__pairs = function()
_G.__mt_hits = _G.__mt_hits + 1
return function() return nil end
end,
})
pmacs.gpu.set_font(spec)
"#,
);
let hits: i64 = eval(&state, "return _G.__mt_hits");
assert_eq!(hits, 0, "metatables are never invoked");
assert_eq!(
pref_of(&state),
(None, Some(1800), 1),
"the metatable's `family` answer must NOT be injected — only the \
raw `size` landed"
);
}
#[test]
fn set_font_quantizes_both_sides_of_a_hundredth_and_empty_resets() {
let state = editor();
exec(&state, "pmacs.gpu.set_font { size = 15.994 }");
assert_eq!(pref_of(&state).1, Some(1599), "15.994 rounds DOWN");
exec(&state, "pmacs.gpu.set_font { size = 15.996 }");
assert_eq!(pref_of(&state).1, Some(1600), "15.996 rounds UP");
// Boundary values are in range and quantize exactly.
exec(&state, "pmacs.gpu.set_font { size = 6 }");
assert_eq!(pref_of(&state).1, Some(600));
exec(&state, "pmacs.gpu.set_font { size = 72 }");
assert_eq!(pref_of(&state).1, Some(7200));
exec(
&state,
r#"pmacs.gpu.set_font { family = "Iosevka", size = 18 }"#,
);
exec(&state, "pmacs.gpu.set_font {}");
let (family, size, _) = pref_of(&state);
assert_eq!(
(family, size),
(None, None),
"set_font {{}} resets BOTH axes to the frontend default"
);
}
#[test]
fn font_getter_returns_a_fresh_quantized_plain_table() {
let state = editor();
exec(&state, "pmacs.gpu.set_font { size = 15.996 }");
let (size, fresh, no_family): (f64, bool, bool) = eval(
&state,
r"
local a = pmacs.gpu.font()
local b = pmacs.gpu.font()
a.size = 999 -- scribbling on the returned table
local c = pmacs.gpu.font()
return c.size, rawequal(a, b) == false, c.family == nil
",
);
assert!(
(size - 16.0).abs() < f64::EPSILON,
"the getter reports the QUANTIZED value (1600 → 16.0), got {size}"
);
assert!(
fresh,
"each call returns a fresh table, never a stored handle"
);
assert!(
no_family,
"an unset axis is absent, and scribbles don't stick"
);
}
// ---------------------------------------------------------------------------
// 7 — init.lua reachability: the module installs BEFORE user config
// ---------------------------------------------------------------------------
#[test]
fn init_lua_set_font_lands_in_the_preference_the_first_frame_reads() {
use pmacs::config::load_user_config_at;
use pmacs::lua::LuaHost;
let dir = tempfile::TempDir::new().expect("tempdir");
std::fs::write(
dir.path().join("init.lua"),
r#"pmacs.gpu.set_font { family = "Iosevka", size = 18 }"#,
)
.expect("write init.lua");
let mut host = LuaHost::new().expect("LuaHost::new");
// Mirror `EditorState::new`'s ordering: the pmacs.gpu module
// installs BEFORE user config runs (src/editor.rs), so an
// init.lua set_font lands in the same state the first
// attachment's producer reads.
let handle = pmacs::lua_bindings::make_font_pref(host.lua()).expect("install pmacs.gpu");
load_user_config_at(&mut host, dir.path());
host.set_init_complete();
assert!(
host.errors().is_empty(),
"init.lua produced errors: {:?}",
host.errors()
);
let pref = handle.lock().expect("font pref lock");
assert_eq!(pref.family.as_deref(), Some("Iosevka"));
assert_eq!(pref.size_centi_px, Some(1800));
assert_eq!(pref.epoch, 1, "exactly the init.lua set landed");
}
/// The producer half of item 7: a preference already in place before
/// the first attachment (the init.lua timing) ships on that
/// attachment's FIRST frame.
#[test]
fn preference_set_before_attach_ships_on_the_first_frame() {
let state = editor();
exec(
&state,
r#"pmacs.gpu.set_font { family = "Iosevka", size = 18 }"#,
);
let mut sem = semantic(&state);
let first = sem.render_frame(&state);
assert_eq!(
font_facts_of(&first),
Some((Some("Iosevka".to_owned()), Some(1800))),
"the first frame ships the pre-attach preference"
);
}