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:
commit
f8096ff826
|
|
@ -2579,6 +2579,7 @@ dependencies = [
|
|||
"loro",
|
||||
"pmacs-protocol",
|
||||
"pollster",
|
||||
"sys-locale",
|
||||
"wgpu",
|
||||
"winit",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 1–5): 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 1–5): 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 1–7): 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 1–6): 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,492 @@
|
|||
// gpu_font_acceptance.rs --- gpu-set-font Arc 4 stage 2 acceptance
|
||||
// (docs/gpu-set-font-framing.md, acceptance items 2–7; item 1's pins
|
||||
// live in src/protocol.rs, item 8's TUI drop arm is a unit in
|
||||
// src/frontend.rs, items 9–14 and 16–19 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"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue