From 2521169350cca3025bff50d13454c960c105251b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 15 Jul 2026 13:08:28 +0100 Subject: [PATCH 1/8] docs: gpu-set-font framing (Arc 4 stage 2) at revision 4 pmacs.gpu.set_font { family?, size? }: one global daemon-side font preference relayed to GPU peers as the bufferless FontFacts fact at protocol v17 (size in integer hundredths of a logical pixel -- the InstanceMessage Eq derive forbids f32), applied mid-session as one fail-closed GPU transaction: sanitized current-order fontdb assembly with four-style monospace queries, derived metric fields scaled from one knob (band height included), atomic set_metrics_and_size on all seven buffers with real drawable dimensions, Wrap::None on row-oriented popups, a measured advance-ratio probe, and visual-run-aware caret preservation (a normalized code-buffer Scroll residual + shape_until_cursor, shared with the CursorByte and optimistic-edit paths, gutter continuation blanks keeping line numbers aligned). Revisions 2-3 fold framing rounds 1-2 (Eq-safe wire size, wire validation failing closed, caret repair ordered before the final reshape, vertical-containment acceptance, sanitized default query, quantization pins); revision 4 is the user's round 3 (visual runs, horizontal-scroll deferral, atomicity, popup row invariance, strict Lua raw-data contract). Acceptance items 1-19. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- docs/gpu-set-font-framing.md | 786 +++++++++++++++++++++++++++++++++++ 1 file changed, 786 insertions(+) create mode 100644 docs/gpu-set-font-framing.md diff --git a/docs/gpu-set-font-framing.md b/docs/gpu-set-font-framing.md new file mode 100644 index 0000000..713afe1 --- /dev/null +++ b/docs/gpu-set-font-framing.md @@ -0,0 +1,786 @@ +# GPU font preference — framing (Arc 4 stage 2, `pmacs.gpu.set_font`) + +**Revision 4 — 2026-07-15. Status: awaiting review.** + +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`) — +`InstanceMessage` derives `Eq` (`message.rs:490`), so `Option` +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("", install__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, size_centi_px: Option, +epoch: u64 }` behind an `Arc>` 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, + /// 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, +}, +``` + +- 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` and +`last_font_facts: Option<(Option, Option)>`, 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. +- **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 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 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. +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. 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. +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. From 661b4968d936cf0f491aad568efb64da560e820d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 15 Jul 2026 14:22:50 +0100 Subject: [PATCH 2/8] feat(font): FontFacts wire + daemon half (v17, FontPref, producer) Protocol v16->17: InstanceMessage::FontFacts { family, size_centi_px } appended after ThemeFacts (integer hundredths of a logical pixel -- the enum derives Eq, f32 cannot; range 600..=7200 documented on the wire). Pins updated: version 17, ladder accepts 6..=17 rejects 18, FontFacts round-trip (populated + all-None), and a ThemeFacts byte pin ([23, 0]) guarding the appended placement. Daemon half: FontPref { family, size_centi_px, epoch } behind a shared handle on EditorState, installed with the new pmacs.gpu Lua module BEFORE load_user_config so init.lua set_font lands in the state the first attachment reads. set_font is strict plain data (raw_get, unknown raw keys rejected by name, metatables never consulted, parse/validate/quantize fully before locking -- range- check the ORIGINAL value so 5.999 errors, then nearest-hundredth round); pmacs.gpu.font() returns a fresh quantized table. Producer: font_facts_msg (the theme_facts_msg discipline -- Option-seeded epoch + payload baselines, advance on computation, one authoritative send per attachment incl (None, None), bufferless so on_buffer_snapshot_sent never touches it); for_peer gains peer_knows_font_facts (>= 17); daemon write-loop skip arm; TUI silent-drop arm + regression test; first-frame count test now expects 6 messages. GPU application follows in the next commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- pmacs-gpu/src/main.rs | 1 + pmacs-protocol/src/message.rs | 49 +++++++++- src/daemon.rs | 8 ++ src/editor.rs | 11 +++ src/font_pref.rs | 42 +++++++++ src/frontend.rs | 26 +++++ src/lib.rs | 1 + src/lua_bindings/mod.rs | 115 ++++++++++++++++++++++ src/protocol.rs | 60 ++++++++++-- src/semantic_render.rs | 173 +++++++++++++++++++++++++++++++++- 10 files changed, 470 insertions(+), 16 deletions(-) create mode 100644 src/font_pref.rs diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index e8e220e..24f9d91 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -5836,6 +5836,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::LineNumbers { .. } => "LineNumbers", InstanceMessage::CompletionPopup { .. } => "CompletionPopup", InstanceMessage::ThemeFacts { .. } => "ThemeFacts", + InstanceMessage::FontFacts { .. } => "FontFacts", } } diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 3318e87..6ff187e 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -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, }, + /// 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, + /// 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, + }, } /// 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`]. diff --git a/src/daemon.rs b/src/daemon.rs index 8d3d901..3cff08c 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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. // diff --git a/src/editor.rs b/src/editor.rs index 955db8a..7bc447d 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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, diff --git a/src/font_pref.rs b/src/font_pref.rs new file mode 100644 index 0000000..e72b9e7 --- /dev/null +++ b/src/font_pref.rs @@ -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>; + +/// 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, + /// 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, + /// 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())) +} diff --git a/src/frontend.rs b/src/frontend.rs index 76cfee1..108798f 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -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 { diff --git a/src/lib.rs b/src/lib.rs index 7ee5f29..019dede 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index ae82045..5afe4da 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -7562,6 +7562,121 @@ pub fn install_process(lua: &Lua, supervisor: &SharedProcessSupervisor) -> mlua: /// Build a fresh [`ProcessSupervisor`] and install /// `pmacs.process.*` over it. Mirrors [`make_async_runtime`] / /// [`make_syntax_registry`] in shape. +/// `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 { + 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 = 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::("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::("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 { + // 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 { + 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) +} + pub fn make_process_supervisor(lua: &Lua) -> mlua::Result { let supervisor = Rc::new(RefCell::new(ProcessSupervisor::new())); install_process(lua, &supervisor)?; diff --git a/src/protocol.rs b/src/protocol.rs index 0805b3f..1a4e0f5 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -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 diff --git a/src/semantic_render.rs b/src/semantic_render.rs index da8dd5c..b4993d4 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -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, + /// 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, Option)>, + /// 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 { + // 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, Option)> { + 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) { + 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"); From e9bafdacd69b0602b6beafe889a516e99f762e85 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 15 Jul 2026 14:39:04 +0100 Subject: [PATCH 3/8] feat(font): GPU sanitized font-database assembly (Q#F6) assemble() replaces FontSystem::new() + post-hoc load_font_data with an explicit fontdb::Database built in today's order -- system fonts first, the bundled JetBrains Mono second (its fontdb::ID retained) -- then the parameterized same-family collision filter (sanitize_font_database removes every NON-monospace face advertising the default family; the bundled face survives by construction), cosmic-text's generic-family defaults, and only then FontSystem::new_with_locale_and_db (sys-locale with the "en-US" fallback, the same resolution cosmic-text's own constructor performs; new pmacs-gpu dependency) -- so the internal monospace-ID set is computed over the final database, bundle included. FontDefaults { default_family, bundled_id } lands on State as the total-fallback anchor for the resolution work in the next commit; query_normal_face is the shared normal-style query (the same fontdb::Query the base Attrs imply). Debug assertions pin both anchors present and monospaced at assembly. All 69 GPU tests pass unchanged -- assembly preserves today's pixels. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- Cargo.lock | 1 + pmacs-gpu/Cargo.toml | 4 ++ pmacs-gpu/src/main.rs | 115 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba0dc78..15707b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2579,6 +2579,7 @@ dependencies = [ "loro", "pmacs-protocol", "pollster", + "sys-locale", "wgpu", "winit", ] diff --git a/pmacs-gpu/Cargo.toml b/pmacs-gpu/Cargo.toml index 713bfb9..bdf2937 100644 --- a/pmacs-gpu/Cargo.toml +++ b/pmacs-gpu/Cargo.toml @@ -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. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 24f9d91..4aa6eff 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -30,7 +30,7 @@ use std::sync::{Arc, Mutex}; use glyphon::{ Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache, - TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, + TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, fontdb, }; use loro::{ContainerTrait, ExportMode}; use pmacs_protocol::{ @@ -53,6 +53,106 @@ use crate::attach::{AttachClient, AttachEvent}; /// Bundled font (SIL Open Font License 1.1 — see `fonts/OFL.txt`). const JETBRAINS_MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono-Regular.ttf"); +/// The default font family — the query `family: None` (and every +/// rejected requested family) resolves through (framing Q#F6). The +/// bundle guarantees the query is never empty; a monospaced +/// system-installed face of the same family may legitimately win by +/// insertion order (availability, not face identity). +const DEFAULT_FONT_FAMILY: &str = "JetBrains Mono"; + +/// What sanitized assembly retained (framing Q#F6): the default +/// family name and the bundled face's ID, both asserted present and +/// monospaced at assembly time. The rejected-family fallback and the +/// `family: None` default both resolve through +/// `Family::Name(&default_family)` against the sanitized db, so the +/// fallback is total and cannot recurse into a proportional +/// collision. +struct FontDefaults { + default_family: String, + bundled_id: fontdb::ID, +} + +/// Remove every NON-monospace face that advertises `default_family` +/// (framing Q#F6, round 3 finding 4): fontdb returns the first +/// surviving equally-good candidate in insertion order, so a +/// closer-weight proportional system face could otherwise win +/// bold/italic queries even when the normal query selected a valid +/// monospaced face. Parameterized by family + bundled ID so tests +/// run the PRODUCTION path with an unreserved fixture family. The +/// bundled face is monospaced and survives by construction. +fn sanitize_font_database(db: &mut fontdb::Database, default_family: &str, bundled_id: fontdb::ID) { + let doomed: Vec = db + .faces() + .filter(|f| { + !f.monospaced + && f.id != bundled_id + && f.families.iter().any(|(name, _)| name == default_family) + }) + .map(|f| f.id) + .collect(); + for id in doomed { + db.remove_face(id); + } +} + +/// Sanitized, current-order font database + `FontSystem` (framing +/// Q#F6): system fonts FIRST (what `FontSystem::new()` does today), +/// the bundled bytes second — retaining the bundled `fontdb::ID` — +/// then the collision filter, then cosmic-text's current +/// generic-family defaults, and only then the `FontSystem` +/// construction, so its internal monospace-ID set includes every +/// surviving monospaced face (the bundle included). The locale is +/// resolved exactly as cosmic-text does (`sys_locale`, `"en-US"` +/// fallback). +fn build_font_system() -> (FontSystem, FontDefaults) { + let mut db = fontdb::Database::new(); + db.load_system_fonts(); + let bundled_ids = + db.load_font_source(fontdb::Source::Binary(std::sync::Arc::new(JETBRAINS_MONO))); + let bundled_id = *bundled_ids + .first() + .expect("bundled JetBrains Mono contains one face"); + sanitize_font_database(&mut db, DEFAULT_FONT_FAMILY, bundled_id); + db.set_monospace_family("Noto Sans Mono"); + db.set_sans_serif_family("Open Sans"); + db.set_serif_family("DejaVu Serif"); + let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")); + let font_system = FontSystem::new_with_locale_and_db(locale, db); + let defaults = FontDefaults { + default_family: DEFAULT_FONT_FAMILY.to_owned(), + bundled_id, + }; + // Assembly-time assertions (framing Q#F6): the default query and + // the bundled face are present and monospaced — the total + // fallback depends on both. + debug_assert!( + font_system + .db() + .face(defaults.bundled_id) + .is_some_and(|f| f.monospaced), + "the bundled face must survive sanitization and be monospaced" + ); + debug_assert!( + query_normal_face(font_system.db(), &defaults.default_family) + .and_then(|id| font_system.db().face(id)) + .is_some_and(|f| f.monospaced), + "the default family query must resolve to a monospaced face" + ); + (font_system, defaults) +} + +/// The normal-style query for `family` — the same `fontdb::Query` +/// implied by the base `Attrs` installed on all seven buffers +/// (normal weight, normal style, normal stretch). +fn query_normal_face(db: &fontdb::Database, family: &str) -> Option { + db.query(&fontdb::Query { + families: &[fontdb::Family::Name(family)], + weight: fontdb::Weight::NORMAL, + stretch: fontdb::Stretch::Normal, + style: fontdb::Style::Normal, + }) +} + /// Initial window size in logical pixels. const INITIAL_WIDTH: u32 = 800; const INITIAL_HEIGHT: u32 = 200; @@ -385,6 +485,11 @@ struct State { surface: Option>, config: wgpu::SurfaceConfiguration, font_system: FontSystem, + /// What sanitized assembly retained (Q#F6): the default family + /// and the bundled face ID — the total-fallback anchors for + /// every font resolution. + #[allow(dead_code)] // consumed by apply_font_facts (next commit) + font_defaults: FontDefaults, swash_cache: SwashCache, viewport: Viewport, atlas: TextAtlas, @@ -1842,8 +1947,11 @@ impl State { // Pipelines, atlas, and any offscreen texture must all share the // render-target format; `config.format` is the single source. let format = config.format; - let mut font_system = FontSystem::new(); - font_system.db_mut().load_font_data(JETBRAINS_MONO.to_vec()); + // Q#F6: sanitized current-order assembly — system fonts, the + // bundled face (ID retained), the same-family collision + // filter, generic defaults, THEN FontSystem construction so + // its monospace-ID set sees the final database. + let (mut font_system, font_defaults) = build_font_system(); let swash_cache = SwashCache::new(); let cache = Cache::new(&device); let mut viewport = Viewport::new(&device, &cache); @@ -1957,6 +2065,7 @@ impl State { surface, config, font_system, + font_defaults, swash_cache, viewport, atlas, From 3a7fe0cae9aa9f6efcd8d5f670b1cca09c311831 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 15 Jul 2026 14:58:42 +0100 Subject: [PATCH 4/8] feat(font): GPU derived metric fields + single-source family (Q#F6) The 13 compile-time metric consts become BASE_* values behind a FontMetrics { scale, advance_ratio } carried on State: every surface derives from one knob (size/16.0), menu_char_w and the empty-gutter advance fallback additionally multiply the measured selected/default advance ratio, and the Default (1.0, 1.0) reproduces today's constants bit-for-bit -- all 69 GPU tests pass unchanged. The compiler enumerated every use site via the BASE_* rename; fm threads as a parameter through the free helpers (text_area_bottom, estimated_visible_lines, minimap_height/ band_contains/y_to_line/rects, edge_scroll_direction, mb_dropdown_window, menu_width_px). The stray Metrics::new(16.0, 22.0) literal in assemble() now derives from the same fields, and all seven Family::Name("JetBrains Mono") literals route through State.resolved_family (seeded from the sanitized default; the borrow checker forced hoisted clones at set-text sites, so the field itself is the single source rather than an accessor). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- pmacs-gpu/src/main.rs | 468 ++++++++++++++++++++++++++++++------------ 1 file changed, 333 insertions(+), 135 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 4aa6eff..0a9f6a1 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -60,6 +60,71 @@ const JETBRAINS_MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono-Regular.ttf /// insertion order (availability, not face identity). const DEFAULT_FONT_FAMILY: &str = "JetBrains Mono"; +/// Derived per-preference metrics (framing Q#F6). One knob — the +/// preference size — scales every surface by `size / 16.0`; the +/// unset default (`scale == 1.0`, `advance_ratio == 1.0`) +/// reproduces today's `BASE_*` constants bit-for-bit, so never-set +/// renders byte-identically. `advance_ratio` is the measured +/// selected/default NORMAL-face advance ratio (the fixed-ASCII +/// probe): the empty-document gutter fallback and the menu hit +/// width follow the resolved family without JetBrains-only drift. +#[derive(Clone, Copy)] +struct FontMetrics { + scale: f32, + advance_ratio: f32, +} + +impl Default for FontMetrics { + fn default() -> Self { + Self { + scale: 1.0, + advance_ratio: 1.0, + } + } +} + +impl FontMetrics { + fn code_font_size(self) -> f32 { + BASE_CODE_FONT_SIZE * self.scale + } + fn code_line_height(self) -> f32 { + BASE_CODE_LINE_HEIGHT * self.scale + } + fn gutter_advance_fallback(self) -> f32 { + BASE_GUTTER_MONO_ADVANCE_FALLBACK * self.scale * self.advance_ratio + } + fn status_band_height(self) -> f32 { + BASE_STATUS_BAND_HEIGHT * self.scale + } + fn status_font_size(self) -> f32 { + BASE_STATUS_FONT_SIZE * self.scale + } + fn status_line_height(self) -> f32 { + BASE_STATUS_LINE_HEIGHT * self.scale + } + fn menu_row_height(self) -> f32 { + BASE_MENU_ROW_HEIGHT * self.scale + } + fn menu_font_size(self) -> f32 { + BASE_MENU_FONT_SIZE * self.scale + } + fn menu_line_height(self) -> f32 { + BASE_MENU_LINE_HEIGHT * self.scale + } + fn menu_char_w(self) -> f32 { + BASE_MENU_CHAR_W * self.scale * self.advance_ratio + } + fn mb_drop_row_height(self) -> f32 { + BASE_MB_DROP_ROW_HEIGHT * self.scale + } + fn mb_drop_font_size(self) -> f32 { + BASE_MB_DROP_FONT_SIZE * self.scale + } + fn mb_drop_line_height(self) -> f32 { + BASE_MB_DROP_LINE_HEIGHT * self.scale + } +} + /// What sanitized assembly retained (framing Q#F6): the default /// family name and the bundled face's ID, both asserted present and /// monospaced at assembly time. The rejected-family fallback and the @@ -187,16 +252,16 @@ const MINIMAP_H_PAD: f32 = 3.0; const MINIMAP_CODE_COLS: f32 = 100.0; const MINIMAP_MIN_STROKE_WIDTH: f32 = 1.5; const MINIMAP_MAX_LINE_STROKE_HEIGHT: f32 = 2.0; -const CODE_LINE_HEIGHT: f32 = 22.0; +const BASE_CODE_LINE_HEIGHT: f32 = 22.0; /// Font size of the code buffer (and the line-number gutter, so their /// line heights match and rows align). -const CODE_FONT_SIZE: f32 = 16.0; +const BASE_CODE_FONT_SIZE: f32 = 16.0; /// Gap in px between the line-number gutter digits and the code /// (UX gutter arc, GPU side of sub-arc 1 — mirrors the TUI gutter). const GUTTER_GAP_PX: f32 = 10.0; /// Fallback monospace advance in px when no shaped glyph is available to /// measure (0.6 em at the 16px code font). -const GUTTER_MONO_ADVANCE_FALLBACK: f32 = 9.6; +const BASE_GUTTER_MONO_ADVANCE_FALLBACK: f32 = 9.6; /// Diagnostic gutter sign (UX gutter sub-arc 2): a thin severity-colored /// bar hugging the gutter's left edge, left of the line numbers — the GPU /// analogue of the TUI's leading-column sign glyph. `X` is its left inset, @@ -226,20 +291,20 @@ const JUMP_STYLE_HOLD: std::time::Duration = std::time::Duration::from_millis(25 /// Status band (Q#S2): one-line strip reserved at the surface /// bottom — buffer name + modified star on the left, diagnostics / /// cursor / scroll readout on the right. -const STATUS_BAND_HEIGHT: f32 = 26.0; +const BASE_STATUS_BAND_HEIGHT: f32 = 26.0; const STATUS_BAND_BG: [f32; 4] = [0.105, 0.105, 0.145, 1.0]; const STATUS_TEXT_PAD: f32 = 10.0; -const STATUS_FONT_SIZE: f32 = 13.0; -const STATUS_LINE_HEIGHT: f32 = 18.0; +const BASE_STATUS_FONT_SIZE: f32 = 13.0; +const BASE_STATUS_LINE_HEIGHT: f32 = 18.0; // Context menu popup (Q#CM1). One row per item/separator; width tracks // the widest label (estimated from a fixed per-char advance, which the // code font's monospacing makes good enough for hit-testing + the bg // quad to agree). -const MENU_ROW_HEIGHT: f32 = 22.0; -const MENU_FONT_SIZE: f32 = 14.0; -const MENU_LINE_HEIGHT: f32 = 22.0; +const BASE_MENU_ROW_HEIGHT: f32 = 22.0; +const BASE_MENU_FONT_SIZE: f32 = 14.0; +const BASE_MENU_LINE_HEIGHT: f32 = 22.0; const MENU_PAD_X: f32 = 12.0; -const MENU_CHAR_W: f32 = 8.4; +const BASE_MENU_CHAR_W: f32 = 8.4; const MENU_MIN_WIDTH: f32 = 140.0; const MENU_MAX_WIDTH: f32 = 380.0; const MENU_BG: [f32; 4] = [0.16, 0.16, 0.20, 0.98]; @@ -250,9 +315,9 @@ const MENU_SEPARATOR_BG: [f32; 4] = [0.30, 0.30, 0.36, 1.0]; // above the bottom band, best match at the top; reuses the menu popup's // colors. Width tracks the widest candidate (measured from the shaped // buffer). -const MB_DROP_ROW_HEIGHT: f32 = 20.0; -const MB_DROP_FONT_SIZE: f32 = 13.0; -const MB_DROP_LINE_HEIGHT: f32 = 20.0; +const BASE_MB_DROP_ROW_HEIGHT: f32 = 20.0; +const BASE_MB_DROP_FONT_SIZE: f32 = 13.0; +const BASE_MB_DROP_LINE_HEIGHT: f32 = 20.0; const MB_DROP_PAD_X: f32 = 10.0; const MB_DROP_MIN_WIDTH: f32 = 160.0; const MB_DROP_MAX_WIDTH: f32 = 480.0; @@ -265,11 +330,16 @@ const MB_DROP_MAX_WIDTH: f32 = 480.0; /// show: no candidates, or the window is too short for even one row. When /// the whole list fits this is `(0, n)`, identical to the pre-clamp /// behavior — the common path is unchanged. -fn mb_dropdown_window(n: usize, selected: usize, band_top: f32) -> Option<(usize, usize)> { +fn mb_dropdown_window( + n: usize, + selected: usize, + band_top: f32, + fm: FontMetrics, +) -> Option<(usize, usize)> { if n == 0 { return None; } - let max_rows = (band_top / MB_DROP_ROW_HEIGHT).floor() as usize; + let max_rows = (band_top / fm.mb_drop_row_height()).floor() as usize; if max_rows == 0 { return None; } @@ -490,6 +560,15 @@ struct State { /// every font resolution. #[allow(dead_code)] // consumed by apply_font_facts (next commit) font_defaults: FontDefaults, + /// Derived metrics for the current preference (Q#F6); default + /// reproduces the BASE_* constants exactly. + fm: FontMetrics, + /// The family every shaped attrs run selects (Q#F6): the + /// sanitized default at assembly; replaced only by a + /// four-style-monospace-validated resolution in + /// `apply_font_facts` — rejected requests fall back HERE, so + /// the accessor is total. + resolved_family: String, swash_cache: SwashCache, viewport: Viewport, atlas: TextAtlas, @@ -1264,7 +1343,7 @@ impl ApplicationHandler for App { // Q#M7 — arm/disarm edge auto-scroll from the drag's // vertical position; `about_to_wait` runs the ticks. state.edge_scroll_dir = - edge_scroll_direction(position.y as f32, state.config.height); + edge_scroll_direction(position.y as f32, state.config.height, state.fm); // Drag coalescing (predicted finding #4): pixel-rate // motion only ships when the hit byte changes. let Some(byte) = state.hit_test_source_byte(position.x, position.y) else { @@ -1401,7 +1480,7 @@ impl ApplicationHandler for App { (-y * WHEEL_LINES_PER_TICK).round() as i64 } winit::event::MouseScrollDelta::PixelDelta(p) => { - (-(p.y as f32) / CODE_LINE_HEIGHT).round() as i64 + (-(p.y as f32) / state.fm.code_line_height()).round() as i64 } }; if lines == 0 { @@ -1947,6 +2026,9 @@ impl State { // Pipelines, atlas, and any offscreen texture must all share the // render-target format; `config.format` is the single source. let format = config.format; + // Q#F6: construction always starts at the default metrics; + // a preference arriving later re-metrics via apply_font_facts. + let fm = FontMetrics::default(); // Q#F6: sanitized current-order assembly — system fonts, the // bundled face (ID retained), the same-family collision // filter, generic defaults, THEN FontSystem construction so @@ -1984,7 +2066,10 @@ impl State { // than one line); larger only fits "hello, pmacs"-shaped // strings. Picked metrics that look reasonable for code at // 800px wide. - let mut buffer = Buffer::new(&mut font_system, Metrics::new(16.0, 22.0)); + let mut buffer = Buffer::new( + &mut font_system, + Metrics::new(fm.code_font_size(), fm.code_line_height()), + ); buffer.set_size( &mut font_system, Some(config.width as f32), @@ -1992,25 +2077,25 @@ impl State { ); let mut status_buffer = Buffer::new( &mut font_system, - Metrics::new(STATUS_FONT_SIZE, STATUS_LINE_HEIGHT), + Metrics::new(fm.status_font_size(), fm.status_line_height()), ); status_buffer.set_size( &mut font_system, Some(config.width as f32), - Some(STATUS_BAND_HEIGHT), + Some(fm.status_band_height()), ); let mut status_left_buffer = Buffer::new( &mut font_system, - Metrics::new(STATUS_FONT_SIZE, STATUS_LINE_HEIGHT), + Metrics::new(fm.status_font_size(), fm.status_line_height()), ); status_left_buffer.set_size( &mut font_system, Some(config.width as f32), - Some(STATUS_BAND_HEIGHT), + Some(fm.status_band_height()), ); let mut menu_buffer = Buffer::new( &mut font_system, - Metrics::new(MENU_FONT_SIZE, MENU_LINE_HEIGHT), + Metrics::new(fm.menu_font_size(), fm.menu_line_height()), ); menu_buffer.set_size( &mut font_system, @@ -2019,7 +2104,7 @@ impl State { ); let mut mb_buffer = Buffer::new( &mut font_system, - Metrics::new(MB_DROP_FONT_SIZE, MB_DROP_LINE_HEIGHT), + Metrics::new(fm.mb_drop_font_size(), fm.mb_drop_line_height()), ); mb_buffer.set_size( &mut font_system, @@ -2030,7 +2115,7 @@ impl State { // dropdown's metrics, its own layer. let mut completion_buffer = Buffer::new( &mut font_system, - Metrics::new(MB_DROP_FONT_SIZE, MB_DROP_LINE_HEIGHT), + Metrics::new(fm.mb_drop_font_size(), fm.mb_drop_line_height()), ); completion_buffer.set_size( &mut font_system, @@ -2041,7 +2126,7 @@ impl State { // height as the code buffer so its rows align one-for-one. let mut gutter_buffer = Buffer::new( &mut font_system, - Metrics::new(CODE_FONT_SIZE, CODE_LINE_HEIGHT), + Metrics::new(fm.code_font_size(), fm.code_line_height()), ); gutter_buffer.set_size( &mut font_system, @@ -2051,7 +2136,7 @@ impl State { buffer.set_text( &mut font_system, initial_text, - &Attrs::new().family(Family::Name("JetBrains Mono")), + &Attrs::new().family(Family::Name(DEFAULT_FONT_FAMILY)), Shaping::Advanced, None, ); @@ -2066,6 +2151,8 @@ impl State { config, font_system, font_defaults, + fm: FontMetrics::default(), + resolved_family: DEFAULT_FONT_FAMILY.to_owned(), swash_cache, viewport, atlas, @@ -3126,7 +3213,7 @@ impl State { let cursor_line = line_starts .partition_point(|&s| s <= cursor) .saturating_sub(1); - let visible = estimated_visible_lines(self.config.height).max(1); + let visible = estimated_visible_lines(self.config.height, self.fm).max(1); let old = self.scroll_top; if cursor_line < self.scroll_top { self.scroll_top = cursor_line; @@ -3145,7 +3232,7 @@ impl State { .layout_runs() .flat_map(|run| run.glyphs.iter()) .next() - .map_or(GUTTER_MONO_ADVANCE_FALLBACK, |g| g.w) + .map_or(self.fm.gutter_advance_fallback(), |g| g.w) } /// Width in px the line-number gutter reserves on the left, or 0 when @@ -3216,10 +3303,11 @@ impl State { let num = mode.number_for(first + i, cursor_line).unwrap_or(0); let _ = write!(text, "{num:>digits$}"); } + let family = self.resolved_family.clone(); self.gutter_buffer.set_text( &mut self.font_system, &text, - &Attrs::new().family(Family::Name("JetBrains Mono")), + &Attrs::new().family(Family::Name(&family)), Shaping::Advanced, None, ); @@ -3297,20 +3385,27 @@ impl State { /// (Q#M6). Presses here are consumed locally and never become /// `Pointer` events. fn in_minimap_band(&self, x: f64, y: f64) -> bool { - minimap_band_contains(x as f32, y as f32, self.config.width, self.config.height) + minimap_band_contains( + x as f32, + y as f32, + self.config.width, + self.config.height, + self.fm, + ) } /// Popup width in pixels (Q#CM1) — widest label estimated from a /// fixed per-char advance, padded, clamped. Used by both hit-testing /// and the bg quad so they line up. - fn menu_width_px(menu: &MenuLocal) -> f32 { + fn menu_width_px(menu: &MenuLocal, fm: FontMetrics) -> f32 { let max_chars = menu .rows .iter() .map(|r| r.label.chars().count()) .max() .unwrap_or(0); - (max_chars as f32 * MENU_CHAR_W + 2.0 * MENU_PAD_X).clamp(MENU_MIN_WIDTH, MENU_MAX_WIDTH) + (max_chars as f32 * fm.menu_char_w() + 2.0 * MENU_PAD_X) + .clamp(MENU_MIN_WIDTH, MENU_MAX_WIDTH) } /// Hit-test a pixel against the open popup (Q#CM1). Returns @@ -3319,13 +3414,13 @@ impl State { fn menu_hit(&self, x: f64, y: f64) -> Option<(u32, bool)> { let menu = self.menu.as_ref()?; let (ax, ay) = menu.anchor_px; - let w = f64::from(Self::menu_width_px(menu)); - let h = menu.rows.len() as f64 * f64::from(MENU_ROW_HEIGHT); + let w = f64::from(Self::menu_width_px(menu, self.fm)); + let h = menu.rows.len() as f64 * f64::from(self.fm.menu_row_height()); if x < ax || x >= ax + w || y < ay || y >= ay + h { return None; } - let row = - (((y - ay) / f64::from(MENU_ROW_HEIGHT)).floor() as usize).min(menu.rows.len() - 1); + let row = (((y - ay) / f64::from(self.fm.menu_row_height())).floor() as usize) + .min(menu.rows.len() - 1); Some((row as u32, !menu.rows[row].separator)) } @@ -3334,9 +3429,14 @@ impl State { /// interpolation. Reuses [`Self::scroll_by_lines`] for the /// clamp / rebuild / viewport-send plumbing. fn minimap_jump_to(&mut self, y: f64) -> Option { - let target = - minimap_y_to_line(y as f32, self.config.height, self.current_line_starts.len())?; - let centered = target.saturating_sub(estimated_visible_lines(self.config.height) / 2); + let target = minimap_y_to_line( + y as f32, + self.config.height, + self.current_line_starts.len(), + self.fm, + )?; + let centered = + target.saturating_sub(estimated_visible_lines(self.config.height, self.fm) / 2); let delta = i64::try_from(centered).unwrap_or(i64::MAX) - i64::try_from(self.scroll_top).unwrap_or(i64::MAX); self.scroll_by_lines(delta) @@ -3386,7 +3486,7 @@ impl State { return false; } let chunks = self.chunks_for_line(line_start, content_end); - self.buffer.lines[shaped_idx] = line_from_chunks(&chunks); + self.buffer.lines[shaped_idx] = line_from_chunks(&chunks, &self.resolved_family); self.line_chunk_cache[shaped_idx] = chunks; self.buffer.shape_until_scroll(&mut self.font_system, false); self.view_range = (vstart, vend); @@ -3472,7 +3572,7 @@ impl State { cache.push(chunks); } else { let chunks = self.chunks_for_line(ls, ce); - lines.push(line_from_chunks(&chunks)); + lines.push(line_from_chunks(&chunks, &self.resolved_family)); cache.push(chunks); } } @@ -3516,7 +3616,7 @@ impl State { for (i, &(ls, ce)) in ranges.iter().enumerate() { let chunks = self.chunks_for_line(ls, ce); if chunks != self.line_chunk_cache[i] { - self.buffer.lines[i] = line_from_chunks(&chunks); + self.buffer.lines[i] = line_from_chunks(&chunks, &self.resolved_family); self.line_chunk_cache[i] = chunks; any = true; } @@ -3713,7 +3813,7 @@ impl State { } readout.push_str(&format_scroll_indicator( self.scroll_top, - estimated_visible_lines(self.config.height), + estimated_visible_lines(self.config.height, self.fm), self.current_line_starts.len(), cursor_row, )); @@ -3787,7 +3887,8 @@ impl State { .map(|(t, _)| t.as_str()) .collect::>() .join(" "); - let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono")); + let family = self.resolved_family.clone(); + let default_attrs = Attrs::new().family(Family::Name(&family)); if composed != self.status_text { let mut rich: Vec<(&str, Attrs)> = Vec::new(); for (i, (t, c)) in spans.iter().enumerate() { @@ -3835,9 +3936,9 @@ impl State { .map_or(STATUS_BAND_BG, |(quad, _)| quad); let rect = MinimapRect { x: 0.0, - y: text_area_bottom(self.config.height), + y: text_area_bottom(self.config.height, self.fm), w: self.config.width as f32, - h: STATUS_BAND_HEIGHT, + h: self.fm.status_band_height(), color, }; rects_to_vertex_bytes(&[rect], self.config.width, self.config.height) @@ -3854,10 +3955,11 @@ impl State { .collect::>() .join("\n") }); + let family = self.resolved_family.clone(); self.menu_buffer.set_text( &mut self.font_system, &text, - &Attrs::new().family(Family::Name("JetBrains Mono")), + &Attrs::new().family(Family::Name(&family)), Shaping::Advanced, None, ); @@ -3873,20 +3975,20 @@ impl State { }; let ax = menu.anchor_px.0 as f32; let ay = menu.anchor_px.1 as f32; - let w = Self::menu_width_px(menu); + let w = Self::menu_width_px(menu, self.fm); let mut rects = vec![MinimapRect { x: ax, y: ay, w, - h: menu.rows.len() as f32 * MENU_ROW_HEIGHT, + h: menu.rows.len() as f32 * self.fm.menu_row_height(), color: MENU_BG, }]; for (i, row) in menu.rows.iter().enumerate() { - let ry = ay + i as f32 * MENU_ROW_HEIGHT; + let ry = ay + i as f32 * self.fm.menu_row_height(); if row.separator { rects.push(MinimapRect { x: ax + MENU_PAD_X, - y: ry + MENU_ROW_HEIGHT / 2.0 - 0.5, + y: ry + self.fm.menu_row_height() / 2.0 - 0.5, w: w - 2.0 * MENU_PAD_X, h: 1.0, color: MENU_SEPARATOR_BG, @@ -3896,7 +3998,7 @@ impl State { x: ax, y: ry, w, - h: MENU_ROW_HEIGHT, + h: self.fm.menu_row_height(), color: MENU_SELECTED_BG, }); } @@ -3911,10 +4013,11 @@ impl State { .minibuffer .as_ref() .map_or_else(String::new, |mb| mb.candidates.join("\n")); + let family = self.resolved_family.clone(); self.mb_buffer.set_text( &mut self.font_system, &text, - &Attrs::new().family(Family::Name("JetBrains Mono")), + &Attrs::new().family(Family::Name(&family)), Shaping::Advanced, None, ); @@ -3928,11 +4031,12 @@ impl State { /// candidate-free, or too short for a row. See [`mb_dropdown_window`]. fn mb_visible_window(&self) -> Option<(usize, usize)> { let mb = self.minibuffer.as_ref()?; - let band_top = text_area_bottom(self.config.height); + let band_top = text_area_bottom(self.config.height, self.fm); mb_dropdown_window( mb.candidates.len(), mb.selected.map_or(0, |s| s as usize), band_top, + self.fm, ) } @@ -3951,8 +4055,8 @@ impl State { .map(|r| r.line_w) .fold(0.0_f32, f32::max); let width = (widest + 2.0 * MB_DROP_PAD_X).clamp(MB_DROP_MIN_WIDTH, MB_DROP_MAX_WIDTH); - let band_top = text_area_bottom(self.config.height); - let top_y = band_top - count as f32 * MB_DROP_ROW_HEIGHT; + let band_top = text_area_bottom(self.config.height, self.fm); + let top_y = band_top - count as f32 * self.fm.mb_drop_row_height(); Some((STATUS_TEXT_PAD, top_y, width)) } @@ -3972,7 +4076,7 @@ impl State { x, y: top_y, w: width, - h: count as f32 * MB_DROP_ROW_HEIGHT, + h: count as f32 * self.fm.mb_drop_row_height(), color: MENU_BG, }]; // Highlight the selection at its row *within the visible window*; @@ -3983,9 +4087,9 @@ impl State { { rects.push(MinimapRect { x, - y: top_y + (sel - first) as f32 * MB_DROP_ROW_HEIGHT, + y: top_y + (sel - first) as f32 * self.fm.mb_drop_row_height(), w: width, - h: MB_DROP_ROW_HEIGHT, + h: self.fm.mb_drop_row_height(), color: MENU_SELECTED_BG, }); } @@ -4009,10 +4113,11 @@ impl State { .collect::>() .join("\n") }); + let family = self.resolved_family.clone(); self.completion_buffer.set_text( &mut self.font_system, &text, - &Attrs::new().family(Family::Name("JetBrains Mono")), + &Attrs::new().family(Family::Name(&family)), Shaping::Advanced, None, ); @@ -4077,11 +4182,11 @@ impl State { return None; } let (ax, line_top, line_h) = self.completion_anchor_px()?; - let band_top = text_area_bottom(self.config.height); + let band_top = text_area_bottom(self.config.height, self.fm); let below_px = band_top - (line_top + line_h); let above_px = line_top - TEXT_TOP; - let max_below = (below_px / MB_DROP_ROW_HEIGHT).floor() as usize; - let max_above = (above_px / MB_DROP_ROW_HEIGHT).floor() as usize; + let max_below = (below_px / self.fm.mb_drop_row_height()).floor() as usize; + let max_above = (above_px / self.fm.mb_drop_row_height()).floor() as usize; let (avail, below) = if max_below >= 1 { (max_below, true) } else { @@ -4100,7 +4205,7 @@ impl State { let top_y = if below { line_top + line_h } else { - line_top - count as f32 * MB_DROP_ROW_HEIGHT + line_top - count as f32 * self.fm.mb_drop_row_height() }; Some((first, count, ax, top_y)) } @@ -4138,7 +4243,7 @@ impl State { x, y: top_y, w: width, - h: count as f32 * MB_DROP_ROW_HEIGHT, + h: count as f32 * self.fm.mb_drop_row_height(), color: MENU_BG, }]; if let Some(sel) = comp.selected.map(|s| s as usize) @@ -4147,9 +4252,9 @@ impl State { { rects.push(MinimapRect { x, - y: top_y + (sel - first) as f32 * MB_DROP_ROW_HEIGHT, + y: top_y + (sel - first) as f32 * self.fm.mb_drop_row_height(), w: width, - h: MB_DROP_ROW_HEIGHT, + h: self.fm.mb_drop_row_height(), color: MENU_SELECTED_BG, }); } @@ -4396,7 +4501,7 @@ impl State { let line_starts = &self.current_line_starts; let n = line_starts.len(); let top = self.scroll_top.min(n.saturating_sub(1)); - let span = estimated_visible_lines(self.config.height).max(1) + SCROLL_OVERSCAN; + let span = estimated_visible_lines(self.config.height, self.fm).max(1) + SCROLL_OVERSCAN; let vstart = line_starts[top]; let bottom = top.saturating_add(span).min(n); let vend = if bottom < n { @@ -4421,7 +4526,7 @@ impl State { let mut cache = Vec::with_capacity(ranges.len()); for &(ls, ce) in &ranges { let chunks = self.chunks_for_line(ls, ce); - lines.push(line_from_chunks(&chunks)); + lines.push(line_from_chunks(&chunks, &self.resolved_family)); cache.push(chunks); } self.buffer.lines = lines; @@ -4462,12 +4567,12 @@ impl State { self.status_buffer.set_size( &mut self.font_system, Some(width as f32), - Some(STATUS_BAND_HEIGHT), + Some(self.fm.status_band_height()), ); self.status_left_buffer.set_size( &mut self.font_system, Some(width as f32), - Some(STATUS_BAND_HEIGHT), + Some(self.fm.status_band_height()), ); // UX gutter: resize the line-number buffer too, else it keeps its // construction-time (800x200) height and `shape_until_scroll` only @@ -4736,8 +4841,8 @@ impl State { .fold(0.0_f32, f32::max); let status_left = (self.config.width as f32 - STATUS_TEXT_PAD - status_width).max(TEXT_LEFT); - let status_top = - text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0; + let status_top = text_area_bottom(self.config.height, self.fm) + + (self.fm.status_band_height() - self.fm.status_line_height()) / 2.0; // UX gutter: the code's left origin (past the gutter) and the // main-text clip-left. Computed here as locals — calling `self.*` // inside the `prepare` args would conflict with its `&mut` borrows. @@ -4775,7 +4880,7 @@ impl State { // Clip at the status band (Q#S3): a final // partially-visible line must not bleed // into the band. - bottom: text_area_bottom(self.config.height).round() as i32, + bottom: text_area_bottom(self.config.height, self.fm).round() as i32, }, default_color: Color::rgb(230, 230, 235), custom_glyphs: &[], @@ -4787,7 +4892,7 @@ impl State { scale: 1.0, bounds: TextBounds { left: 0, - top: text_area_bottom(self.config.height).round() as i32, + top: text_area_bottom(self.config.height, self.fm).round() as i32, right: self.config.width.cast_signed(), bottom: self.config.height.cast_signed(), }, @@ -4804,7 +4909,7 @@ impl State { scale: 1.0, bounds: TextBounds { left: 0, - top: text_area_bottom(self.config.height).round() as i32, + top: text_area_bottom(self.config.height, self.fm).round() as i32, // Stop before the right-aligned readout. right: (status_left - STATUS_TEXT_PAD).max(0.0).round() as i32, bottom: self.config.height.cast_signed(), @@ -4834,7 +4939,7 @@ impl State { left: 0, top: 0, right: gutter_clip_left, - bottom: text_area_bottom(self.config.height).round() as i32, + bottom: text_area_bottom(self.config.height, self.fm).round() as i32, }, // Themes Q#TH5: ui.gutter's {fg} mask colors the digits. default_color: gutter_color, @@ -4871,8 +4976,9 @@ impl State { bounds: TextBounds { left: ax as i32, top: ay as i32, - right: (ax + Self::menu_width_px(menu)).round() as i32, - bottom: (ay + menu.rows.len() as f32 * MENU_ROW_HEIGHT).round() as i32, + right: (ax + Self::menu_width_px(menu, self.fm)).round() as i32, + bottom: (ay + menu.rows.len() as f32 * self.fm.menu_row_height()).round() + as i32, }, default_color: Color::rgb(232, 232, 238), custom_glyphs: &[], @@ -4905,13 +5011,13 @@ impl State { .map(|((first, _count), (x, top_y, width))| TextArea { buffer: &self.mb_buffer, left: x + MB_DROP_PAD_X, - top: top_y - first as f32 * MB_DROP_ROW_HEIGHT, + top: top_y - first as f32 * self.fm.mb_drop_row_height(), scale: 1.0, bounds: TextBounds { left: x as i32, top: top_y as i32, right: (x + width).round() as i32, - bottom: text_area_bottom(self.config.height).round() as i32, + bottom: text_area_bottom(self.config.height, self.fm).round() as i32, }, // Themes Q#TH5 (round 3 finding 1): the candidate // glyph layer is ui.minibuffer.candidate's GPU site; @@ -4944,13 +5050,13 @@ impl State { .map(|((first, count, _ax, _ty), (x, top_y, width))| TextArea { buffer: &self.completion_buffer, left: x + MB_DROP_PAD_X, - top: top_y - first as f32 * MB_DROP_ROW_HEIGHT, + top: top_y - first as f32 * self.fm.mb_drop_row_height(), scale: 1.0, bounds: TextBounds { left: x as i32, top: top_y as i32, right: (x + width).round() as i32, - bottom: (top_y + count as f32 * MB_DROP_ROW_HEIGHT).round() as i32, + bottom: (top_y + count as f32 * self.fm.mb_drop_row_height()).round() as i32, }, default_color: Color::rgb(232, 232, 238), custom_glyphs: &[], @@ -5093,7 +5199,7 @@ impl State { let Some(summary) = self.current_summary.as_ref() else { return Vec::new(); }; - let visible_lines = estimated_visible_lines(self.config.height); + let visible_lines = estimated_visible_lines(self.config.height, self.fm); let rects = minimap_rects( &summary.lines, &self.current_line_shapes, @@ -5105,6 +5211,7 @@ impl State { // made the frozen thumb obvious.) self.scroll_top, visible_lines, + self.fm, ); rects_to_vertex_bytes(&rects, self.config.width, self.config.height) } @@ -5337,13 +5444,13 @@ impl State { 0.0 }; let cursor_chars = mb.prompt.chars().count() as f32 + mb.cursor as f32; - let status_top = - text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0; + let status_top = text_area_bottom(self.config.height, self.fm) + + (self.fm.status_band_height() - self.fm.status_line_height()) / 2.0; Some(MinimapRect { x: STATUS_TEXT_PAD + advance * cursor_chars, y: status_top, w: CARET_WIDTH, - h: STATUS_LINE_HEIGHT, + h: self.fm.status_line_height(), color: CARET_COLOR, }) } @@ -5555,18 +5662,18 @@ fn minimap_left(surface_width: u32) -> Option { /// Where editor content stops and the status band begins (Q#S3) — /// the single source for every bottom-of-text computation. -fn text_area_bottom(surface_height: u32) -> f32 { - (surface_height as f32 - STATUS_BAND_HEIGHT).max(0.0) +fn text_area_bottom(surface_height: u32, fm: FontMetrics) -> f32 { + (surface_height as f32 - fm.status_band_height()).max(0.0) } /// The minimap's drawable height: the text area minus its own /// top/bottom insets. -fn minimap_height(surface_height: u32) -> f32 { - text_area_bottom(surface_height) - MINIMAP_TOP - MINIMAP_BOTTOM +fn minimap_height(surface_height: u32, fm: FontMetrics) -> f32 { + text_area_bottom(surface_height, fm) - MINIMAP_TOP - MINIMAP_BOTTOM } -fn estimated_visible_lines(surface_height: u32) -> usize { - ((text_area_bottom(surface_height) - TEXT_TOP.max(0.0)) / CODE_LINE_HEIGHT) +fn estimated_visible_lines(surface_height: u32, fm: FontMetrics) -> usize { + ((text_area_bottom(surface_height, fm) - TEXT_TOP.max(0.0)) / fm.code_line_height()) .ceil() .max(1.0) as usize } @@ -5574,11 +5681,17 @@ fn estimated_visible_lines(surface_height: u32) -> usize { /// True when `(x, y)` lies inside the minimap band — the painter's /// geometry (`minimap_left` × the `MINIMAP_TOP..bottom` column), /// shared by the Q#M6 press hit-test. -fn minimap_band_contains(x: f32, y: f32, surface_width: u32, surface_height: u32) -> bool { +fn minimap_band_contains( + x: f32, + y: f32, + surface_width: u32, + surface_height: u32, + fm: FontMetrics, +) -> bool { let Some(left) = minimap_left(surface_width) else { return false; }; - let height = minimap_height(surface_height); + let height = minimap_height(surface_height, fm); height > 0.0 && x >= left && x < surface_width as f32 - MINIMAP_RIGHT @@ -5617,10 +5730,10 @@ fn format_scroll_indicator( /// `-1` in the band hugging the text area's top, `+1` in the band at /// the text area's bottom (above the status band), `None` in the /// interior. -fn edge_scroll_direction(y: f32, surface_height: u32) -> Option { +fn edge_scroll_direction(y: f32, surface_height: u32, fm: FontMetrics) -> Option { if y < TEXT_TOP + EDGE_SCROLL_BAND { Some(-1) - } else if y > text_area_bottom(surface_height) - EDGE_SCROLL_BAND { + } else if y > text_area_bottom(surface_height, fm) - EDGE_SCROLL_BAND { Some(1) } else { None @@ -5631,11 +5744,16 @@ fn edge_scroll_direction(y: f32, surface_height: u32) -> Option { /// of the painter's `y = MINIMAP_TOP + line * height / total` /// interpolation, clamped into the file. `None` for an empty file or /// a degenerate surface. -fn minimap_y_to_line(y: f32, surface_height: u32, total_lines: usize) -> Option { +fn minimap_y_to_line( + y: f32, + surface_height: u32, + total_lines: usize, + fm: FontMetrics, +) -> Option { if total_lines == 0 { return None; } - let height = minimap_height(surface_height); + let height = minimap_height(surface_height, fm); if height <= 0.0 { return None; } @@ -5650,14 +5768,15 @@ fn minimap_rects( surface_height: u32, first_visible_line: usize, visible_lines: usize, + fm: FontMetrics, ) -> Vec { let Some(x) = minimap_left(surface_width) else { return Vec::new(); }; - if lines.is_empty() || minimap_height(surface_height) <= 0.0 { + if lines.is_empty() || minimap_height(surface_height, fm) <= 0.0 { return Vec::new(); } - let height = minimap_height(surface_height); + let height = minimap_height(surface_height, fm); let pixel_rows = height.round().max(1.0) as usize; let mut rects = Vec::new(); rects.push(MinimapRect { @@ -6692,8 +6811,8 @@ fn px_to_ndc_y(y: f32, height: u32) -> f32 { /// text + an attrs span per colored chunk (mirroring `set_rich_text`'s /// only-when-non-default rule). Every line gets `LineEnding::Lf` — /// the separator byte itself never enters a line's text. -fn line_from_chunks(chunks: &[RichChunk]) -> glyphon::cosmic_text::BufferLine { - let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono")); +fn line_from_chunks(chunks: &[RichChunk], family: &str) -> glyphon::cosmic_text::BufferLine { + let default_attrs = Attrs::new().family(Family::Name(family)); let mut attrs_list = glyphon::cosmic_text::AttrsList::new(&default_attrs); let mut text = String::new(); for chunk in chunks { @@ -7279,15 +7398,27 @@ mod tests { #[test] fn mb_dropdown_window_clamps_and_keeps_selection_visible() { // Row height is 20.0; a 1000px space fits any producer-capped list. - assert_eq!(mb_dropdown_window(5, 2, 1000.0), Some((0, 5))); + assert_eq!( + mb_dropdown_window(5, 2, 1000.0, FontMetrics::default()), + Some((0, 5)) + ); // The whole-fits path is identical to no clamp: (0, n). - assert_eq!(mb_dropdown_window(10, 9, 1000.0), Some((0, 10))); + assert_eq!( + mb_dropdown_window(10, 9, 1000.0, FontMetrics::default()), + Some((0, 10)) + ); // A 100px space fits 5 rows. Selection near the top anchors at 0. - assert_eq!(mb_dropdown_window(10, 0, 100.0), Some((0, 5))); + assert_eq!( + mb_dropdown_window(10, 0, 100.0, FontMetrics::default()), + Some((0, 5)) + ); // Selection past the fold scrolls so it stays visible (bottom edge). - assert_eq!(mb_dropdown_window(10, 9, 100.0), Some((5, 5))); - let (first, count) = mb_dropdown_window(10, 7, 100.0).unwrap(); + assert_eq!( + mb_dropdown_window(10, 9, 100.0, FontMetrics::default()), + Some((5, 5)) + ); + let (first, count) = mb_dropdown_window(10, 7, 100.0, FontMetrics::default()).unwrap(); assert!( first <= 7 && 7 < first + count, "sel 7 in [{first},{})", @@ -7295,11 +7426,20 @@ mod tests { ); // Degenerate: too short for even one row ⇒ hide, never draw above 0. - assert_eq!(mb_dropdown_window(10, 0, 10.0), None); + assert_eq!( + mb_dropdown_window(10, 0, 10.0, FontMetrics::default()), + None + ); // No candidates ⇒ nothing. - assert_eq!(mb_dropdown_window(0, 0, 500.0), None); + assert_eq!( + mb_dropdown_window(0, 0, 500.0, FontMetrics::default()), + None + ); // An out-of-range selection is clamped, not panicked. - assert_eq!(mb_dropdown_window(3, 99, 1000.0), Some((0, 3))); + assert_eq!( + mb_dropdown_window(3, 99, 1000.0, FontMetrics::default()), + Some((0, 3)) + ); } #[test] @@ -8012,32 +8152,66 @@ mod tests { // The status band reserves 26px (Q#S3), so the text area // ends at 574 and the minimap column is y = [12, 562) // (height 550). - assert!(minimap_band_contains(750.0, 100.0, 800, 600)); + assert!(minimap_band_contains( + 750.0, + 100.0, + 800, + 600, + FontMetrics::default() + )); assert!( - !minimap_band_contains(739.0, 100.0, 800, 600), + !minimap_band_contains(739.0, 100.0, 800, 600, FontMetrics::default()), "left of band" ); assert!( - !minimap_band_contains(788.0, 100.0, 800, 600), + !minimap_band_contains(788.0, 100.0, 800, 600, FontMetrics::default()), "right of band" ); - assert!(!minimap_band_contains(750.0, 5.0, 800, 600), "above band"); assert!( - !minimap_band_contains(750.0, 563.0, 800, 600), + !minimap_band_contains(750.0, 5.0, 800, 600, FontMetrics::default()), + "above band" + ); + assert!( + !minimap_band_contains(750.0, 563.0, 800, 600, FontMetrics::default()), "below band (status strip)" ); // Too-narrow surfaces have no minimap at all. - assert!(!minimap_band_contains(100.0, 100.0, 150, 600)); + assert!(!minimap_band_contains( + 100.0, + 100.0, + 150, + 600, + FontMetrics::default() + )); // Inverse mapping: height = 550; 100 lines. Top → line 0, // bottom → last line, midpoint → ~half. - assert_eq!(minimap_y_to_line(12.0, 600, 100), Some(0)); - assert_eq!(minimap_y_to_line(561.9, 600, 100), Some(99)); - assert_eq!(minimap_y_to_line(12.0 + 275.0, 600, 100), Some(50)); + assert_eq!( + minimap_y_to_line(12.0, 600, 100, FontMetrics::default()), + Some(0) + ); + assert_eq!( + minimap_y_to_line(561.9, 600, 100, FontMetrics::default()), + Some(99) + ); + assert_eq!( + minimap_y_to_line(12.0 + 275.0, 600, 100, FontMetrics::default()), + Some(50) + ); // Out-of-band y clamps rather than panics (scrubbing wanders). - assert_eq!(minimap_y_to_line(0.0, 600, 100), Some(0)); - assert_eq!(minimap_y_to_line(9999.0, 600, 100), Some(99)); - assert_eq!(minimap_y_to_line(100.0, 600, 0), None, "empty file"); + assert_eq!( + minimap_y_to_line(0.0, 600, 100, FontMetrics::default()), + Some(0) + ); + assert_eq!( + minimap_y_to_line(9999.0, 600, 100, FontMetrics::default()), + Some(99) + ); + assert_eq!( + minimap_y_to_line(100.0, 600, 0, FontMetrics::default()), + None, + "empty file" + ); } #[test] @@ -8045,16 +8219,32 @@ mod tests { // 600px surface: up-band y < 16 + 24 = 40; the text area // ends at 574 (status band, Q#S3), so the down-band is // y > 574 - 24 = 550. - assert_eq!(edge_scroll_direction(10.0, 600), Some(-1)); - assert_eq!(edge_scroll_direction(39.9, 600), Some(-1)); - assert_eq!(edge_scroll_direction(40.0, 600), None, "interior"); - assert_eq!(edge_scroll_direction(300.0, 600), None); assert_eq!( - edge_scroll_direction(550.0, 600), + edge_scroll_direction(10.0, 600, FontMetrics::default()), + Some(-1) + ); + assert_eq!( + edge_scroll_direction(39.9, 600, FontMetrics::default()), + Some(-1) + ); + assert_eq!( + edge_scroll_direction(40.0, 600, FontMetrics::default()), + None, + "interior" + ); + assert_eq!( + edge_scroll_direction(300.0, 600, FontMetrics::default()), + None + ); + assert_eq!( + edge_scroll_direction(550.0, 600, FontMetrics::default()), None, "band edge exclusive" ); - assert_eq!(edge_scroll_direction(551.0, 600), Some(1)); + assert_eq!( + edge_scroll_direction(551.0, 600, FontMetrics::default()), + Some(1) + ); } #[test] @@ -8073,7 +8263,15 @@ mod tests { let red = style_with_fg(CellColor::Rgb(255, 0, 0)); let blue = style_with_fg(CellColor::Rgb(0, 0, 255)); let shapes = minimap_line_shapes("alpha\nbeta\ngamma\ndelta"); - let rects = minimap_rects(&[red, red, blue, blue], &shapes, 240, 80, 0, 2); + let rects = minimap_rects( + &[red, red, blue, blue], + &shapes, + 240, + 80, + 0, + 2, + FontMetrics::default(), + ); assert!( rects @@ -8110,7 +8308,7 @@ mod tests { lines.len() ]; - let rects = minimap_rects(&lines, &shapes, 240, 120, 0, 30); + let rects = minimap_rects(&lines, &shapes, 240, 120, 0, 30, FontMetrics::default()); let pixel_rows = (120.0 - MINIMAP_TOP - MINIMAP_BOTTOM).round() as usize; assert!( @@ -8127,7 +8325,7 @@ mod tests { content_cols: 10, }]; - assert!(minimap_rects(&lines, &shapes, 120, 120, 0, 1).is_empty()); + assert!(minimap_rects(&lines, &shapes, 120, 120, 0, 1, FontMetrics::default()).is_empty()); } #[test] @@ -8144,7 +8342,7 @@ mod tests { }, ]; - let rects = minimap_rects(&[red, red], &shapes, 240, 80, 0, 2); + let rects = minimap_rects(&[red, red], &shapes, 240, 80, 0, 2, FontMetrics::default()); let strokes: Vec<_> = rects .iter() .filter(|r| color_close(r.color, rgb_to_minimap_color(255, 0, 0))) @@ -8599,7 +8797,7 @@ mod tests { }; state.line_numbers = LineNumberMode::Absolute; let text_left = state.text_left().ceil() as u32; - let band_top = text_area_bottom(h).floor() as u32; + let band_top = text_area_bottom(h, FontMetrics::default()).floor() as u32; let base = state.render_offscreen(); apply_faces( &mut state, From 06a4b2e359ced05b66cd75a2384147098e6e45ba Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 15 Jul 2026 16:33:03 +0100 Subject: [PATCH 5/8] feat(font): GPU apply_font_facts + visual-run caret substrate (Q#F6) The stage-2 GPU application half, framing revision 4: - apply_font_facts transaction: fail-closed wire validation (600..=7200 centi-px; deserialized protocol input, not the Lua courtesy check), painted-before decision from the ACTUAL caret rect intersected with the drawable code clip, four-style monospace resolution with total fallback to the sanitized default, derived metrics + measured advance, atomic re-metric/re-size, status shaping-cache drop, reshape at the retained scroll, drawable-width settle pass, conditional caret re-follow, minimap cache drop. - Advance probe: fixed ASCII digits shaped in the resolved family at the new code metrics; the NORMAL-face advance is authoritative for gutter geometry (mono_advance no longer trusts an arbitrary code glyph once measured), and the selected/default ratio scales the const-based fallbacks -- the default family is ratio 1 by construction, so never-set/reset stays byte-identical. - sync_buffer_dimensions: metrics + REAL drawable dimensions change atomically on all seven buffers via set_metrics_and_size; the code buffer wraps at the painter's clip width; resize() routes through the helper (closes the old four-of-seven skew) and applies the same painted-before follow policy. - Rows stay rows: Wrap::None on the menu/minibuffer/completion buffers at assembly and (idempotently) in the transaction. - Normalized code-buffer Scroll residual: slice-local line == 0 invariant with a vertical pixel residual; horizontal always discarded (glyphon 0.11 never applies it). normalize_code_scroll folds line advances into whole-file scroll_top (strictly advancing origin, EOF clamp) after EVERY final code shape. BufferSnapshot resets the residual (buffer-scoped view state); explicit wheel/minimap jumps clear it -- including a wheel-up whose only remaining motion IS the residual. - Byte-to-layout projection: code_byte_to_projected inverts the line_chunk_cache chunk projection (earliest projected boundary for adornment anchors, the left-gravity caret), then code_byte_px uses cosmic-text's layout_cursor so wrap boundaries select the same visual run shape_until_cursor scrolls to. caret_rect and completion_anchor_px are now visual-run aware instead of scanning the source line's first run. - ensure_caret_painted (coarse source-line follow, then shape_until_cursor, discard Scroll.horizontal, normalize) shared by the CursorByte arm (under its existing moved gate -- fixes the pre-existing wrapped-line follow hole), the optimistic edit completion, the font transaction, and resize. - Gutter mirrors the code layout's visual runs: continuation blanks for wrapped runs plus the same normalized vertical scroll, so line numbers stay row-aligned when wrapping appears. - assemble() shapes the initial text through reshape() so the buffer.lines <-> line_chunk_cache invariant holds from construction (the projection inversion depends on it). PMACS_REQUIRE_GPU suite 69/69; clippy -D warnings and fmt clean. Acceptance tests for items 9-11/13-14/16-19 land in the next commit with the embedded test faces. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- pmacs-gpu/src/main.rs | 754 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 612 insertions(+), 142 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 0a9f6a1..ea3e8d5 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -28,6 +28,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use glyphon::cosmic_text::{Cursor, Scroll, Wrap}; use glyphon::{ Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache, TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, fontdb, @@ -218,6 +219,42 @@ fn query_normal_face(db: &fontdb::Database, family: &str) -> Option }) } +/// The fixed ASCII advance probe (framing Q#F6): digits shape one +/// glyph per char in any face (no ligatures), so the first glyph's +/// advance is the family's monospace advance at these metrics. +const ADVANCE_PROBE: &str = "0123456789"; + +/// Wire-validation bounds for `FontFacts::size_centi_px` — 6.0..=72.0 +/// logical px in integer hundredths (framing Q#F6, fail closed): 0 +/// would panic `Buffer::set_metrics`, and the GPU re-checks on +/// arrival because this is deserialized protocol input — the +/// daemon-side Lua range check is a UX courtesy, not a trust +/// boundary. +const FONT_SIZE_CENTI_PX_RANGE: std::ops::RangeInclusive = 600..=7200; + +/// Measure `family`'s normal-face glyph advance at `metrics` by +/// shaping [`ADVANCE_PROBE`] in a scratch buffer — independent of +/// document contents, so the measurement is deterministic and the +/// NORMAL face is authoritative even when the first code glyph is +/// bold/italic. `None` when the family shapes no glyphs. +fn probe_mono_advance(font_system: &mut FontSystem, family: &str, metrics: Metrics) -> Option { + let mut probe = Buffer::new(font_system, metrics); + probe.set_size(font_system, None, None); + probe.set_text( + font_system, + ADVANCE_PROBE, + &Attrs::new().family(Family::Name(family)), + Shaping::Advanced, + None, + ); + probe.shape_until_scroll(font_system, false); + probe + .layout_runs() + .flat_map(|run| run.glyphs.iter()) + .next() + .map(|glyph| glyph.w) +} + /// Initial window size in logical pixels. const INITIAL_WIDTH: u32 = 800; const INITIAL_HEIGHT: u32 = 200; @@ -558,7 +595,6 @@ struct State { /// What sanitized assembly retained (Q#F6): the default family /// and the bundled face ID — the total-fallback anchors for /// every font resolution. - #[allow(dead_code)] // consumed by apply_font_facts (next commit) font_defaults: FontDefaults, /// Derived metrics for the current preference (Q#F6); default /// reproduces the BASE_* constants exactly. @@ -569,6 +605,21 @@ struct State { /// `apply_font_facts` — rejected requests fall back HERE, so /// the accessor is total. resolved_family: String, + /// The resolved family's measured normal-face advance at the + /// current code metrics (the [`ADVANCE_PROBE`] result, Q#F6). + /// Authoritative for gutter geometry once a `FontFacts` has been + /// applied; `None` until then, falling back to today's + /// first-shaped-glyph sampling. + measured_mono_advance: Option, + /// The retained normalized code-buffer scroll (framing Q#F6): + /// slice-local `line == 0` always holds, this is the `vertical` + /// pixel residual within the top source line's visual runs, and + /// `horizontal` stays 0 (glyphon 0.11 never applies it). Nonzero + /// only after caret-following crossed into a wrapped run; + /// explicit wheel/minimap jumps clear it, `BufferSnapshot` + /// resets it (buffer-scoped view state), and every full reshape + /// reapplies it instead of installing `Scroll::default`. + code_scroll_residual: f32, swash_cache: SwashCache, viewport: Viewport, atlas: TextAtlas, @@ -2102,6 +2153,13 @@ impl State { Some(MENU_MAX_WIDTH), Some(config.height as f32), ); + // Rows stay rows (framing Q#F6): the three row-oriented popup + // buffers never wrap — their protocols, row windows, selection + // quads, and hit tests all assign exactly one row height per + // source line, and a label wrapping after a font change would + // paint on a second visual row that hit-tests as the following + // item. The pixel bounds keep owning horizontal clipping. + menu_buffer.set_wrap(&mut font_system, Wrap::None); let mut mb_buffer = Buffer::new( &mut font_system, Metrics::new(fm.mb_drop_font_size(), fm.mb_drop_line_height()), @@ -2111,6 +2169,7 @@ impl State { Some(MB_DROP_MAX_WIDTH), Some(config.height as f32), ); + mb_buffer.set_wrap(&mut font_system, Wrap::None); // Completion dropdown buffer (Arc 1a): the minibuffer // dropdown's metrics, its own layer. let mut completion_buffer = Buffer::new( @@ -2122,6 +2181,7 @@ impl State { Some(MB_DROP_MAX_WIDTH), Some(config.height as f32), ); + completion_buffer.set_wrap(&mut font_system, Wrap::None); // Line-number gutter buffer (UX gutter arc): same font size + line // height as the code buffer so its rows align one-for-one. let mut gutter_buffer = Buffer::new( @@ -2133,17 +2193,9 @@ impl State { Some(config.width as f32), Some(config.height as f32), ); - buffer.set_text( - &mut font_system, - initial_text, - &Attrs::new().family(Family::Name(DEFAULT_FONT_FAMILY)), - Shaping::Advanced, - None, - ); - buffer.shape_until_scroll(&mut font_system, false); let (current_line_starts, current_line_char_starts) = line_offset_tables(initial_text); - Self { + let mut state = Self { window, device, queue, @@ -2153,6 +2205,8 @@ impl State { font_defaults, fm: FontMetrics::default(), resolved_family: DEFAULT_FONT_FAMILY.to_owned(), + measured_mono_advance: None, + code_scroll_residual: 0.0, swash_cache, viewport, atlas, @@ -2226,7 +2280,14 @@ impl State { faces: HashMap::new(), gutter_buffer, gutter_text_renderer, - } + }; + // Shape the initial text through the shared chunk path so the + // `buffer.lines` ↔ `line_chunk_cache` invariant holds from + // construction — the caret/anchor projection (framing Q#F6) + // inverts the per-line chunk cache, and a directly-set buffer + // would leave it empty. + state.reshape(); + state } fn set_frontend_id(&mut self, frontend_id: FrontendId) { @@ -2417,13 +2478,12 @@ impl State { // `CursorByte` confirms — an optimistic edit on the bottom // visible line that wraps (or a Backspace pulling the caret // above the top) moves it outside the slice, and waiting a - // round trip to scroll reads as a hitch. - let viewport = if self.scroll_to_cursor() { - self.rebuild_lines_reusing_scroll(); - self.viewport_send_if_changed(predicted.buffer_id) - } else { - None - }; + // round trip to scroll reads as a hitch. Visual-run aware + // (framing Q#F6): the confirming identical `CursorByte` has + // `moved == false`, so deferring wrapped-run repair here + // would leave a newly wrapped caret off-screen indefinitely. + self.ensure_caret_painted(); + let viewport = self.viewport_send_if_changed(predicted.buffer_id); CrdtOpSend { buffer_id: predicted.buffer_id, op: CrdtOp { peer_id, bytes }, @@ -2684,7 +2744,11 @@ impl State { self.unconfirmed_edits.clear(); // New buffer ⇒ back to the top, and force a viewport // re-declaration for the new buffer's scoped range. + // The caret-follow residual is buffer-scoped view + // state and resets with it; the global font + // preference/metrics survive (framing Q#F6). self.scroll_top = 0; + self.code_scroll_residual = 0.0; self.last_viewport_sent = None; if !self.set_text(&text) { self.reshape(); @@ -3047,10 +3111,13 @@ impl State { // jump → Viewport → frame + re-announced CursorByte → // snap, in a loop. Scrolling away from a cursor that // isn't moving is the user's prerogative. - if moved && self.scroll_to_cursor() { - // Pure scroll: retained lines keep their shape - // caches; only newly exposed lines shape. - self.rebuild_lines_reusing_scroll(); + // + // The follow is visual-run aware (framing Q#F6): the + // shared helper closes the old source-line-only hole + // where a caret on a wrapped continuation run below + // the band never scrolled into view. + if moved { + self.ensure_caret_painted(); if let Some(vp) = self.viewport_send_if_changed(buffer_id) { return Some(vp); } @@ -3138,6 +3205,20 @@ impl State { self.request_redraw(); None } + // Arc 4 stage 2 (framing Q#F6/Q#F7) — the global font + // preference. Authoritative per attachment: `(None, None)` + // is a real reset to the sanitized defaults, never + // inferred from silence. The rebuild may change the + // visible slice (new wrapping/metrics), so re-declare the + // viewport from the final normalized origin. + InstanceMessage::FontFacts { + family, + size_centi_px, + } => { + self.apply_font_facts(family.as_deref(), size_centi_px); + self.current_buffer_id + .and_then(|bid| self.viewport_send_if_changed(bid)) + } _ => None, } } @@ -3223,11 +3304,53 @@ impl State { self.scroll_top != old } - /// Monospace glyph advance in px, read from the currently-shaped code - /// buffer (every glyph shares it in a monospace font), with a fallback - /// when the buffer has no glyphs yet. Used to size the line-number - /// gutter (UX gutter arc). + /// Bring the own caret's VISUAL run into the drawable code window + /// (framing Q#F6) — the shared follow helper for the `CursorByte` + /// arm, the optimistic edit completion, and the font/resize + /// transactions. The coarse source-line [`Self::scroll_to_cursor`] + /// runs first (the byte may be outside the shaped slice), then + /// cosmic-text's `Buffer::shape_until_cursor` follows wrapped + /// layout runs vertically. Its `Scroll.horizontal` result is + /// discarded — glyphon 0.11 never applies horizontal scroll when + /// placing glyphs, so retaining it would make state claim a + /// scroll the painter never displays. Ends by re-normalizing the + /// scroll; callers declare the viewport only after that final + /// source origin is stable. + fn ensure_caret_painted(&mut self) { + let Some(own) = self.own_cursor else { + return; + }; + if self.current_buffer_id != Some(own.buffer_id) { + return; + } + if self.scroll_to_cursor() { + // Pure scroll: retained lines keep their shape caches; + // only newly exposed lines shape. + self.rebuild_lines_reusing_scroll(); + } + let byte = own.byte.min(self.current_text.len() as u64); + if let Some((slice_i, projected)) = self.code_byte_to_projected(byte) { + self.buffer.shape_until_cursor( + &mut self.font_system, + Cursor::new(slice_i, projected), + false, + ); + } + self.normalize_code_scroll(); + self.request_redraw(); + } + + /// Monospace glyph advance in px, used to size the line-number + /// gutter (UX gutter arc). Once a `FontFacts` has been applied the + /// measured NORMAL-face probe advance is authoritative (framing + /// Q#F6) — different monospaced faces need not share an advance, + /// so sampling an arbitrary (possibly bold/italic) code glyph + /// could skew the gutter. Before any probe: today's behavior — + /// the first shaped glyph, else the ratio-scaled constant. fn mono_advance(&self) -> f32 { + if let Some(advance) = self.measured_mono_advance { + return advance; + } self.buffer .layout_runs() .flat_map(|run| run.glyphs.iter()) @@ -3280,9 +3403,13 @@ impl State { } /// Reshape the gutter buffer to the right-aligned line numbers for the - /// currently-shaped code lines (UX gutter arc). One number per code - /// line starting at `shaped_top`, so the two buffers align row-for-row - /// at the same `top` and line height. No-op when the gutter is off. + /// currently-shaped code lines (UX gutter arc). The projection mirrors + /// the code layout's VISUAL runs (framing Q#F6): each source line's + /// number rides its first run and wrapped continuation runs get blank + /// gutter rows, then the code buffer's normalized vertical scroll is + /// applied verbatim — both buffers share the line height, so rows stay + /// aligned when wrapping or a caret-follow residual is active. No-op + /// when the gutter is off. fn refresh_gutter_buffer(&mut self) { use std::fmt::Write as _; if !self.line_numbers.is_on() { @@ -3302,6 +3429,16 @@ impl State { } let num = mode.number_for(first + i, cursor_line).unwrap_or(0); let _ = write!(text, "{num:>digits$}"); + // Continuation blanks: one empty row per wrapped run past + // the first. Unlaid lines (below the drawable window) count + // as one row; their gutter rows are equally invisible. + let runs = self + .buffer + .line_layout(&mut self.font_system, i) + .map_or(1, |layout| layout.len().max(1)); + for _ in 1..runs { + text.push('\n'); + } } let family = self.resolved_family.clone(); self.gutter_buffer.set_text( @@ -3311,6 +3448,8 @@ impl State { Shaping::Advanced, None, ); + self.gutter_buffer + .set_scroll(Scroll::new(0, self.code_scroll_residual, 0.0)); self.gutter_buffer .shape_until_scroll(&mut self.font_system, false); } @@ -3372,9 +3511,17 @@ impl State { .scroll_top .saturating_add_signed(delta as isize) .min(max_top); - if new_top == self.scroll_top { + // An explicit jump owns the viewport wholesale: it also clears + // the caret-follow residual (framing Q#F6), so a retained + // sub-line offset can't pin the top row. A wheel-up at the top + // clamp edge still scrolls when its only remaining motion IS + // the residual; a wheel-down pinned at the bottom clamp keeps + // the residual (nothing below to reveal). + let residual_scrolls_up = delta < 0 && self.code_scroll_residual != 0.0; + if new_top == self.scroll_top && !residual_scrolls_up { return None; } + self.code_scroll_residual = 0.0; self.scroll_top = new_top; self.rebuild_lines_reusing_scroll(); self.current_buffer_id @@ -3488,8 +3635,12 @@ impl State { let chunks = self.chunks_for_line(line_start, content_end); self.buffer.lines[shaped_idx] = line_from_chunks(&chunks, &self.resolved_family); self.line_chunk_cache[shaped_idx] = chunks; - self.buffer.shape_until_scroll(&mut self.font_system, false); self.view_range = (vstart, vend); + self.buffer.shape_until_scroll(&mut self.font_system, false); + // The edited line's wrap count can shrink under a retained + // residual, advancing the slice-local scroll (framing Q#F6); + // its rebuilds re-derive `view_range` themselves. + self.normalize_code_scroll(); self.hit_map_dirty = true; self.request_redraw(); true @@ -3580,8 +3731,9 @@ impl State { self.line_chunk_cache = cache; self.shaped_top = new_top; self.buffer - .set_scroll(glyphon::cosmic_text::Scroll::default()); + .set_scroll(Scroll::new(0, self.code_scroll_residual, 0.0)); self.buffer.shape_until_scroll(&mut self.font_system, false); + self.normalize_code_scroll(); self.hit_map_dirty = true; if any_reused || self.line_chunk_cache.is_empty() { self.styled_redraw_deadline = None; @@ -3623,6 +3775,9 @@ impl State { } if any { self.buffer.shape_until_scroll(&mut self.font_system, false); + // Adornment chunks can change a line's wrap count under a + // retained residual (framing Q#F6). + self.normalize_code_scroll(); self.hit_map_dirty = true; } // Fresh styling reached the slice — release any held @@ -4131,41 +4286,26 @@ impl State { /// byte. `None` when the popup is closed or the anchor is /// scrolled out of the visible slice (the popup then simply /// doesn't draw this frame; scrolling back restores it). - fn completion_anchor_px(&self) -> Option<(f32, f32, f32)> { + fn completion_anchor_px(&mut self) -> Option<(f32, f32, f32)> { if !self.completion_open_for_current_buffer() { return None; // never paint against a foreign buffer's rope } - let comp = self.completion.as_ref()?; + let anchor = self.completion.as_ref()?.anchor; let (vstart, vend) = self.view_range; - if vend <= vstart { + if vend <= vstart || anchor < vstart || anchor > vend { return None; } - let anchor = comp.anchor; - if anchor < vstart || anchor > vend { + // The caret mapping, visual-run aware (framing Q#F6): the + // anchor's run, not its source line's first run. Off the + // drawable window (a wrapped run below the band, or above a + // caret-follow residual) counts as scrolled out. + let (x, top, line_height) = self.code_byte_px(anchor)?; + let y = TEXT_TOP + top; + let bottom = text_area_bottom(self.config.height, self.fm); + if y >= bottom || y + line_height <= TEXT_TOP { return None; } - let slice = &self.current_text[vstart as usize..vend as usize]; - let line_offsets = line_byte_offsets(slice); - let slice_anchor = anchor - vstart; - let (line_lo, _) = source_line_range(slice, slice_anchor); - let text_left = self.text_left(); - for run in self.buffer.layout_runs() { - if line_offsets.get(run.line_i).copied().unwrap_or(0) != line_lo { - continue; - } - let mut x = text_left; - for glyph in run.glyphs { - if line_lo + glyph.start as u64 >= slice_anchor { - x = text_left + glyph.x; - break; - } - // Anchor is past this glyph; track its right edge so an - // anchor at line end lands after the final glyph. - x = text_left + glyph.x + glyph.w; - } - return Some((x, TEXT_TOP + run.line_top, run.line_height)); - } - None + Some((self.text_left() + x, y, line_height)) } /// Layout of the completion dropdown: `(first_row, row_count, @@ -4175,12 +4315,13 @@ impl State { /// visible slice windows around the selection so it stays on /// screen when fewer rows fit than the wire shipped (the F-007 /// discipline). - fn completion_dropdown_layout(&self) -> Option<(usize, usize, f32, f32)> { + fn completion_dropdown_layout(&mut self) -> Option<(usize, usize, f32, f32)> { let comp = self.completion.as_ref()?; let n = comp.rows.len(); if n == 0 { return None; } + let sel = comp.selected.map_or(0, |s| s as usize); let (ax, line_top, line_h) = self.completion_anchor_px()?; let band_top = text_area_bottom(self.config.height, self.fm); let below_px = band_top - (line_top + line_h); @@ -4196,7 +4337,6 @@ impl State { return None; } let count = n.min(avail); - let sel = comp.selected.map_or(0, |s| s as usize); let first = if n <= count { 0 } else { @@ -4215,7 +4355,7 @@ impl State { /// column shifted back from the window's right margin. /// `refresh_completion_buffer` must have run so the width /// measurement is current. - fn completion_dropdown_rect(&self) -> Option<(f32, f32, f32)> { + fn completion_dropdown_rect(&mut self) -> Option<(f32, f32, f32)> { let (_first, _count, ax, top_y) = self.completion_dropdown_layout()?; let widest = self .completion_buffer @@ -4229,8 +4369,8 @@ impl State { /// Completion dropdown background + selection-highlight quads. /// Empty when closed or the anchor is off-screen. - fn completion_dropdown_vertex_bytes(&self) -> Vec { - let Some(comp) = self.completion.as_ref() else { + fn completion_dropdown_vertex_bytes(&mut self) -> Vec { + let Some(selected) = self.completion.as_ref().map(|c| c.selected) else { return Vec::new(); }; let Some((first, count, _ax, _ty)) = self.completion_dropdown_layout() else { @@ -4246,7 +4386,7 @@ impl State { h: count as f32 * self.fm.mb_drop_row_height(), color: MENU_BG, }]; - if let Some(sel) = comp.selected.map(|s| s as usize) + if let Some(sel) = selected.map(|s| s as usize) && sel >= first && sel < first + count { @@ -4512,7 +4652,13 @@ impl State { (vstart, vend) } - fn reshape(&mut self) { + /// Rebuild the shaped slice from `scroll_top` and reapply the + /// retained normalized scroll (framing Q#F6) — the raw builder + /// shared by [`Self::reshape`] and the [`Self::normalize_code_scroll`] + /// fold loop. Callers that end a "final code shape" must run the + /// normalizer after so the slice-local `line == 0` invariant is + /// re-established. + fn rebuild_code_slice(&mut self) { // Session S1 — shape only the visible byte slice. Feeding the // whole rope to `set_rich_text` (a BufferLine per source line) // made large-file editing O(file) per keystroke; cosmic-text @@ -4533,11 +4679,54 @@ impl State { self.line_chunk_cache = cache; self.shaped_top = top; self.buffer - .set_scroll(glyphon::cosmic_text::Scroll::default()); + .set_scroll(Scroll::new(0, self.code_scroll_residual, 0.0)); self.buffer.shape_until_scroll(&mut self.font_system, false); // The pointer hit map rebuilds lazily from the same caches // (Q#R2) — clicks are rare next to keystrokes/frames. self.hit_map_dirty = true; + } + + /// Re-establish the normalized code-scroll invariant after a + /// final code shape (framing Q#F6): cosmic-text advances the + /// slice-local `scroll.line` when new wrapping/metrics push the + /// retained vertical residual across source lines. Fold that + /// delta into the whole-file `scroll_top`, retain the residual, + /// rebuild from the new source origin, and repeat until + /// `line == 0`. Every iteration strictly advances the clamped + /// source origin; a non-advancing or past-EOF fold clamps to the + /// last source line with a default scroll instead of looping. + /// `scroll.horizontal` is discarded throughout — glyphon 0.11 + /// never applies it when placing glyphs, so retaining it would + /// claim a scroll the painter never displays. + fn normalize_code_scroll(&mut self) { + loop { + let scroll = self.buffer.scroll(); + if scroll.horizontal != 0.0 { + self.buffer + .set_scroll(Scroll::new(scroll.line, scroll.vertical, 0.0)); + } + if scroll.line == 0 { + self.code_scroll_residual = scroll.vertical.max(0.0); + return; + } + let last_line = self.current_line_starts.len().saturating_sub(1); + let new_top = self.shaped_top + scroll.line; + if new_top > self.shaped_top && new_top <= last_line { + self.scroll_top = new_top; + self.code_scroll_residual = scroll.vertical; + self.rebuild_code_slice(); + } else { + self.scroll_top = last_line; + self.code_scroll_residual = 0.0; + self.rebuild_code_slice(); + return; + } + } + } + + fn reshape(&mut self) { + self.rebuild_code_slice(); + self.normalize_code_scroll(); // Full restyle: release any held post-jump frame (Q#M6). self.styled_redraw_deadline = None; self.request_redraw(); @@ -4551,7 +4740,227 @@ impl State { } } + /// One dimension helper for ALL seven buffers (framing Q#F6): + /// metrics and the current REAL drawable dimensions change + /// atomically via `set_metrics_and_size` — `set_metrics` alone + /// deliberately preserves old dimensions, and the old `resize()` + /// only touched four of the seven buffers (the "numbers stop at + /// 10" class of skew). Code and gutter get the drawable code + /// clip's height (and code its clip width, so wrapping and + /// `shape_until_cursor` use the same clip the painter uses); the + /// status pair gets the derived band; the row popups get the + /// surface height — their protocols window rows themselves. + /// `set_metrics_and_size` no-ops when nothing changed, so calling + /// this eagerly is cheap. + fn sync_buffer_dimensions(&mut self) { + let fm = self.fm; + let width = self.config.width as f32; + let height = self.config.height as f32; + let code_metrics = Metrics::new(fm.code_font_size(), fm.code_line_height()); + let code_width = (self.text_bounds_right() as f32 - self.text_left()).max(0.0); + let code_height = (text_area_bottom(self.config.height, fm) - TEXT_TOP).max(0.0); + self.buffer.set_metrics_and_size( + &mut self.font_system, + code_metrics, + Some(code_width), + Some(code_height), + ); + self.gutter_buffer.set_metrics_and_size( + &mut self.font_system, + code_metrics, + Some(width), + Some(code_height), + ); + let status_metrics = Metrics::new(fm.status_font_size(), fm.status_line_height()); + self.status_buffer.set_metrics_and_size( + &mut self.font_system, + status_metrics, + Some(width), + Some(fm.status_band_height()), + ); + self.status_left_buffer.set_metrics_and_size( + &mut self.font_system, + status_metrics, + Some(width), + Some(fm.status_band_height()), + ); + self.menu_buffer.set_metrics_and_size( + &mut self.font_system, + Metrics::new(fm.menu_font_size(), fm.menu_line_height()), + Some(MENU_MAX_WIDTH), + Some(height), + ); + let drop_metrics = Metrics::new(fm.mb_drop_font_size(), fm.mb_drop_line_height()); + self.mb_buffer.set_metrics_and_size( + &mut self.font_system, + drop_metrics, + Some(MB_DROP_MAX_WIDTH), + Some(height), + ); + self.completion_buffer.set_metrics_and_size( + &mut self.font_system, + drop_metrics, + Some(MB_DROP_MAX_WIDTH), + Some(height), + ); + } + + /// Whether every face the shipped attribute set can select for + /// `family` is monospaced (framing Q#F6): the four queries + /// reachable through the base `Attrs` — normal, bold, italic, and + /// bold-italic, all at normal stretch. fontdb's style matching + /// would otherwise let a closer-weight proportional sibling win + /// bold/italic text even when the normal query resolved a valid + /// monospaced face, silently under-sizing gutter and menu + /// advance geometry. + fn family_is_monospace_everywhere(&self, family: &str) -> bool { + let db = self.font_system.db(); + [ + (fontdb::Weight::NORMAL, fontdb::Style::Normal), + (fontdb::Weight::BOLD, fontdb::Style::Normal), + (fontdb::Weight::NORMAL, fontdb::Style::Italic), + (fontdb::Weight::BOLD, fontdb::Style::Italic), + ] + .into_iter() + .all(|(weight, style)| { + db.query(&fontdb::Query { + families: &[fontdb::Family::Name(family)], + weight, + stretch: fontdb::Stretch::Normal, + style, + }) + .and_then(|id| db.face(id)) + .is_some_and(|face| face.monospaced) + }) + } + + /// Apply a `FontFacts` preference wholesale — framing Q#F6's one + /// transaction. Fail-closed wire validation first (this is + /// deserialized protocol input; the daemon-side Lua check is a UX + /// courtesy, not a trust boundary), then: record the actual + /// painted-caret decision, resolve the family (four-style + /// monospace gate, total fallback to the sanitized default), + /// derive metrics + the measured advance, re-metric/re-size all + /// seven buffers, drop the string-equality status caches, reshape + /// at the retained scroll, settle the drawable width, re-follow a + /// formerly painted caret (or only re-normalize an intentionally + /// caret-free viewport), and invalidate dependent layout. No + /// frame is submitted between the passes, and no atlas action is + /// needed — the per-frame `atlas.trim()` clears `glyphs_in_use`, + /// making old-font glyphs eligible for later LRU-style eviction + /// under allocation pressure. + fn apply_font_facts(&mut self, family: Option<&str>, size_centi_px: Option) { + if let Some(size) = size_centi_px + && !FONT_SIZE_CENTI_PX_RANGE.contains(&size) + { + // 0 would panic `Buffer::set_metrics`; huge values produce + // pathological metrics/allocations. Reject the WHOLE + // message: current state kept, nothing re-shaped. + eprintln!( + "pmacs-gpu: ignoring FontFacts with out-of-range size {size} \ + (allowed {}..={} hundredths of a logical px)", + FONT_SIZE_CENTI_PX_RANGE.start(), + FONT_SIZE_CENTI_PX_RANGE.end(), + ); + return; + } + let caret_was_painted = self.caret_painted_in_code_clip(); + let resolved = match family { + None => self.font_defaults.default_family.clone(), + Some(requested) if self.family_is_monospace_everywhere(requested) => { + requested.to_owned() + } + Some(requested) => { + // Deterministic frontend fallback, never round-tripped + // back — the daemon never learns resolution outcomes. + eprintln!( + "pmacs-gpu: font family {requested:?} is unavailable or not \ + monospaced across its normal/bold/italic/bold-italic \ + queries; falling back to {:?}", + self.font_defaults.default_family + ); + self.font_defaults.default_family.clone() + } + }; + #[allow(clippy::cast_precision_loss)] // size <= 7200 is exact in f32 + let scale = size_centi_px.map_or(1.0, |size| size as f32 / 100.0 / BASE_CODE_FONT_SIZE); + // The measure pass (framing Q#F6): a fixed ASCII probe in the + // resolved family at the new code metrics, independent of + // document contents. The NORMAL-face advance becomes + // authoritative for gutter geometry, and the selected/default + // ratio scales the const-based fallbacks — the default family + // is ratio 1 by construction, so never-set/reset stays + // byte-identical. + let code_metrics = Metrics::new(BASE_CODE_FONT_SIZE * scale, BASE_CODE_LINE_HEIGHT * scale); + let selected_advance = probe_mono_advance(&mut self.font_system, &resolved, code_metrics); + let advance_ratio = if resolved == self.font_defaults.default_family { + 1.0 + } else { + let default_advance = probe_mono_advance( + &mut self.font_system, + &self.font_defaults.default_family, + code_metrics, + ); + match (selected_advance, default_advance) { + (Some(selected), Some(default)) if selected > 0.0 && default > 0.0 => { + selected / default + } + _ => 1.0, + } + }; + self.resolved_family = resolved; + self.fm = FontMetrics { + scale, + advance_ratio, + }; + self.measured_mono_advance = selected_advance; + // Rows stay rows: idempotent no-wrap on the popup buffers + // (assembly set it; a set_wrap no-op costs a comparison). + self.menu_buffer.set_wrap(&mut self.font_system, Wrap::None); + self.mb_buffer.set_wrap(&mut self.font_system, Wrap::None); + self.completion_buffer + .set_wrap(&mut self.font_system, Wrap::None); + // Metrics + current dimensions atomically on all seven. + self.sync_buffer_dimensions(); + // The two string-equality shaping gates (the popups rebuild + // unconditionally per frame). NUL can never equal a composed + // status string, so the next frame re-shapes with new attrs + // even when its composed text is unchanged. + "\0".clone_into(&mut self.status_text); + "\0".clone_into(&mut self.status_left_text); + // Attrs-bearing reshape at the retained scroll (reshape + // normalizes it against the FINAL family/metrics/dims). + self.reshape(); + // Settle the drawable code width: `text_left` depends on the + // measured advance via the gutter, so re-derive and reshape + // once more if it moved. No frame is submitted between passes. + let width_before = self.buffer.size().0; + self.sync_buffer_dimensions(); + if self.buffer.size().0 != width_before { + self.reshape(); + } + if caret_was_painted { + self.ensure_caret_painted(); + } else { + // Preserve the user's scroll — a font change must never + // turn an overscan-only caret into a snap-back. + self.normalize_code_scroll(); + } + // Dependent layout: the minimap vertex cache keys on + // (generation, size, scroll_top) and would miss a pure + // metrics change; hit runs rebuild lazily (reshape dirtied + // them). + self.minimap_cache = None; + self.request_redraw(); + } + fn resize(&mut self, width: u32, height: u32) -> Option { + // The same painted-before policy as the font transaction + // (framing Q#F6), decided against the OLD geometry: narrowing + // must not strand a stationary caret in a new wrap, and + // widening an intentionally caret-free viewport only + // normalizes its retained scroll. + let caret_was_painted = self.caret_painted_in_code_clip(); self.config.width = width; self.config.height = height; if let Some(surface) = &self.surface { @@ -4559,32 +4968,14 @@ impl State { } self.viewport .update(&self.queue, Resolution { width, height }); - self.buffer.set_size( - &mut self.font_system, - Some(width as f32), - Some(height as f32), - ); - self.status_buffer.set_size( - &mut self.font_system, - Some(width as f32), - Some(self.fm.status_band_height()), - ); - self.status_left_buffer.set_size( - &mut self.font_system, - Some(width as f32), - Some(self.fm.status_band_height()), - ); - // UX gutter: resize the line-number buffer too, else it keeps its - // construction-time (800x200) height and `shape_until_scroll` only - // shapes the ~10 lines that fit — the "numbers stop at 10" bug. - self.gutter_buffer.set_size( - &mut self.font_system, - Some(width as f32), - Some(height as f32), - ); + // All seven buffers through the shared dimension helper. + self.sync_buffer_dimensions(); // A taller/shorter window changes the visible line count, so the // slice + scoped viewport change (session S1). self.reshape(); + if caret_was_painted { + self.ensure_caret_painted(); + } self.request_redraw(); self.current_buffer_id .and_then(|bid| self.viewport_send_if_changed(bid)) @@ -5044,9 +5435,10 @@ impl State { // scrolls it up by `first` rows so row `first` lands at `top_y`, // and `bounds` clips the rows outside the visible window (the // minibuffer dropdown's F-007 shape). - let completion_areas: Vec