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/docs/gpu-set-font-framing.md b/docs/gpu-set-font-framing.md new file mode 100644 index 0000000..eab128c --- /dev/null +++ b/docs/gpu-set-font-framing.md @@ -0,0 +1,832 @@ +# GPU font preference — framing (Arc 4 stage 2, `pmacs.gpu.set_font`) + +**Revision 5 — 2026-07-18. Status: implemented on branch +`gpu-set-font` (protocol v17); awaiting PR review.** + +Revision 5 (PR review, findings 1–5): source bytes that fall inside a +shaped cluster are normalized to an explicit representable cosmic-text +cursor before geometry or following; combining-mark and real-ligature +fixtures pin that they can no longer fall back to the source-line start +(finding 1). Code-buffer reflow is now one transaction for every +dynamic horizontal input, not only font application: line-number mode, +gutter digit-count transitions, minimap appearance/disappearance, full +text replacement, incremental CRDT edits, and byte-identical +`BufferSnapshot` summary clearing all synchronize the shaping width +before the final reshape and re-follow only a previously painted caret +(finding 2). The monospace advance probe divides the total shaped-run +width by the probe's cell count, rather than sampling its first glyph; +the embedded alternate monospace carries a real multi-cell ligature so +the old result is observably wrong (finding 3). All four fixture fonts +now enter the explicit database before `FontSystem` construction and +their retained IDs are checked against cosmic-text's actual +`is_monospace` classification, removing the post-construction test +approximation (finding 4). Acceptance 10, 11, 17, and 18 now exercise +their complete claims at both size bounds, including rendered band +containment, selection/hit row identity, wrapped completion anchors, +gutter continuation alignment, reverse caret-free reflow, and both +directions of caret-free resize (finding 5). + +Revision 4 (framing round 3, findings 1–5): caret preservation is now +VISUAL-RUN aware rather than source-line-only. The code buffer keeps a +normalized cosmic-text `Scroll`, full reshapes reapply it, and the +shared caret-follow helper uses `Buffer::shape_until_cursor` (which +handles wrapped runs vertically) before normalizing a +slice-local `scroll.line` into the frontend's whole-file `scroll_top`; +the font path and `CursorByte` path share the helper. A long line that +fits at size 6 and wraps below the viewport at size 72 is a load-bearing +acceptance case (finding 1). Context-menu vertical clipping joins its +already-accepted horizontal clipping at extreme sizes: minibuffer and +completion popups retain their existing surface-windowing guarantee, +while viewport-aware context-menu flip/window/ellipsis is one named +deferral; acceptance 10 no longer promises geometry the menu substrate +cannot provide (finding 2). Metrics and dimensions update together via +`set_metrics_and_size`; the code buffer uses the actual drawable code +width/height, the status buffers use the derived band height, all seven +buffers are brought current after a resize, and a resize→font-change +test pins the dimensions before pixels are inspected (finding 3). The +default family becomes a SANITIZED, CURRENT-ORDER `"JetBrains Mono"` +query: the explicit database preserves today's order (system fonts, +then the bundle), removes only non-monospace faces colliding with that +family, and is only then wrapped in `FontSystem`. A valid installed +JetBrains Mono keeps today's pixels, while a proportional same-family +face cannot win the default query or make the monospace fallback recurse +(finding 4). The last stale "bundled font" statement is aligned with +that now-structural guarantee (finding 5). +The final feasibility audit also pins three substrate details exposed by +the larger metric range: popup buffers use `Wrap::None` so one wire row +cannot become several interactive rows; caret and completion-anchor +mapping select the VISUAL run that actually contains the byte; and +font-dependent fallback/menu advances are measured relative to the +sanitized per-process default even when the code buffer is empty. +Glyphon 0.11 ignores cosmic-text's horizontal scroll component, so +stage 2 explicitly keeps +that component zero and defers the wider horizontal-offset substrate. +Every final shape normalizes a cosmic source-line advance back into the +frontend's whole-file origin (including caret-free shrink), optimistic +edits use the same visual follower, the Lua table is strict raw data, +and font resolution validates all four style queries rather than only +the normal face. + +Revision 3 (framing round 2, findings 1–7): the GPU validates the +WIRE size before mutating any state — `FontFacts` is deserialized +protocol input, `u32` does not enforce the range, and +`Buffer::set_metrics` panics on a zero font size (cosmic-text +`buffer.rs:563`) — an out-of-range `size_centi_px` fails closed: +the whole message is ignored, logged, and current state kept; +direct GPU-arm tests cover 0, 599, 7201, and `u32::MAX` +(finding 1). The caret repair is re-ordered — record the +painted-visibility predicate (EXCLUDING the two-line overscan that +`view_range` carries, `main.rs:4290`), set metrics, conditionally +adjust `scroll_top`, THEN `reshape()` once at the final scroll, +then declare the viewport — matching the existing cursor path that +rebuilds lines before declaring (`main.rs:2854`); the +scrolled-away pin in acceptance 11 targets exactly the +one-line-past-the-painted-window overscan case (finding 2). +Acceptance 10 is narrowed to VERTICAL containment (code stops at +the band, status glyphs fit the band, popups stay inside the +surface): popups occlude the text area by layer contract, and at +size 72 the 380 px menu cap clips long labels today — accepted and +documented; viewport-aware menu width + ellipsis moves to Deferred +(finding 3). The default family is REDEFINED as the locally +resolved "JetBrains Mono" query, with the bundled asset +guaranteeing the query is never empty rather than promising face +identity — `FontSystem::new()` loads system fonts before the +bundle, and fontdb returns the first surviving candidate in +insertion order, so a system-installed JetBrains Mono may win; +never-set and fallback resolve through the same query in the same +process, so the byte-identical acceptance claims stay valid +per-machine (finding 4). Quantization is pinned: range-check the +ORIGINAL finite value first (6.0 ≤ size ≤ 72.0), then +nearest-hundredth via round — 5.999 errors rather than rounding +into range; values on both sides of a hundredth are pinned +(finding 5). Acceptance 2's field name corrected to +`size_centi_px` (finding 6). Atlas wording corrected: `trim()` +clears `glyphs_in_use`; old glyphs become ELIGIBLE for later +LRU-style eviction under allocation pressure, they do not age out +on their own (finding 7). + +Revision 2 (framing round 1, findings 1–6): the wire size is an +**integer in hundredths of a logical pixel** (`Option`) — +`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. + The same helper and settle transaction also own every runtime input + that changes the code clip without changing the font: line-number + mode, a gutter digit-count transition after full or incremental text, + minimap presence after `FileStyleSummary`, and summary removal during + even a byte-identical `BufferSnapshot`. Each path captures the old + painted-caret predicate before changing geometry, synchronizes the + final buffer dimensions before shaping, and follows only when that + predicate was true. +- **Rows stay rows**: menu, minibuffer-candidate, and completion buffers + explicitly use `Wrap::None`. Their protocols, row-window calculations, + selection quads, and hit tests all assign exactly one row-height to one + source line; allowing a long label to wrap after a size/family change + would paint glyphs on a second visual row that still hit-tests as the + following item. The existing pixel bounds remain responsible for + horizontal clipping. Code and gutter retain wrapping; status strings + may clip within their single derived band. +- **Font-dependent advances without default drift**: the internal + measure pass shapes a fixed ASCII probe in the resolved family at the + relevant metrics, independent of document contents. It sums every + shaped run's width and divides by the probe's logical cell count; + sampling one glyph is invalid because even a monospaced face may + shape several probe characters into one multi-cell ligature. It + records the selected/default advance ratio. The empty-code gutter + fallback and `menu_char_w` use today's exact constants multiplied by + that ratio; + the sanitized per-process default is therefore ratio 1 and remains + byte-identical, while an alternate monospace family cannot leave stale + JetBrains-only gutter/menu geometry. The measured NORMAL-face advance + becomes authoritative for the normal-style gutter even when the first + code glyph is bold/italic (different monospaced faces need not share an + advance); `mono_advance()` no longer samples an arbitrary code glyph. + The measure result is committed with the other derived geometry before + hit maps are dirtied. +- **Extreme-size context-menu policy** (round 3 finding 2): the + context menu keeps its current raw pointer anchor, full row set, and + fixed 380 px width cap. Wgpu clips geometry at the surface, so it + remains safe, but long labels and/or lower rows may be clipped at + large configured sizes; unlike the minibuffer and completion + dropdowns it does NOT claim surface containment. Adding viewport- + aware horizontal ellipsis plus vertical flip/window/scroll is one + named Deferred item, not smuggled into the font reload. Acceptance + exercises the clipped route for no panic and coherent hit geometry, + while containment assertions cover only surfaces that own it. +- **Caret visibility across the re-metric — visual runs, not source + lines** (round 3 finding 1): `Buffer` defaults to + `Wrap::WordOrGlyph`; a source line that fits at size 6 can occupy + several visual runs at size 72, so source-line-only + `scroll_to_cursor` cannot uphold a painted-caret guarantee. `State` + therefore retains a normalized code-buffer `Scroll` (slice-local + `line == 0`, the `vertical` residual, and `horizontal == 0`), and full + reshapes reapply it instead of blindly installing `Scroll::default`. + This residual is buffer-scoped view state: `BufferSnapshot` resets it + to default even though the global font preference/metrics survive. + A shared `normalize_code_scroll` runs after EVERY final code shape, + not only caret following: if cosmic-text advances slice-local + `scroll.line` because new wrapping/metrics make the retained vertical + offset cross source lines, add that delta to whole-file `scroll_top`, + retain the residual, rebuild from the new source origin, and repeat + until `line == 0`. Each iteration must strictly advance the clamped + source origin; at EOF, a non-advancing residual is clamped to the last + source line with `Scroll::default` rather than looping. This preserves + an intentionally caret-free viewport through a size decrease without + leaving a stale/blank slice. Any + incidental scroll changes made by intermediate measure/metric calls + are discarded; normalization starts from the pre-transaction retained + scroll against the FINAL family, metrics, dimensions, and attrs. + A shared byte-to-layout helper selects the visual run whose glyph byte + interval contains the target (or the final run at source-line end); it + replaces the current first-run-of-source-line scans in both + `caret_rect` and `completion_anchor_px`. The helper first inverts that + line's `line_chunk_cache` projection — source bytes are not projected + bytes when inline adornments are present — using the earliest + projected boundary for an adornment anchor (the current left-gravity + caret placement), then uses cosmic-text's `layout_cursor`/affinity so + a wrap boundary selects the same run as `shape_until_cursor`. A source + byte inside a combining or ligature cluster is snapped explicitly to + the cluster's logical end with `Before` affinity; it is never handed + to cosmic-text as an unrepresentable interior cursor, whose fallback + is the source-line start. A shared + `ensure_caret_painted` helper first performs the existing coarse + source-line `scroll_to_cursor` and rebuild when the byte is outside the + shaped slice, then maps the byte to a cosmic-text `Cursor` and calls + the library's `Buffer::shape_until_cursor` (`buffer.rs:320-413`), which already + follows wrapped layout runs vertically. The helper deliberately + discards the call's `Scroll.horizontal` result: glyphon 0.11 does not + apply that component when placing glyphs, so retaining it would make + state claim a scroll the painter never displays. The helper then calls + `normalize_code_scroll`; only after its final source origin is stable + may it declare the viewport. Explicit wheel/minimap/source-line jumps + clear that residual; ordinary full reshapes preserve it. The existing + `CursorByte` arm uses this helper under its existing `moved` gate + too, fixing its identical pre-existing wrapped-line hole without + snapping a stationary cursor after a wheel/minimap scroll. The + optimistic edit completion path also replaces its current + source-line-only `scroll_to_cursor` call with the helper: it has already + installed the predicted `own_cursor`, and the confirming identical + `CursorByte` will have `moved == false`, so deferring visual-run repair + would leave a newly wrapped caret off-screen indefinitely. The gutter + projection mirrors the code layout at the same time: emit the source + line number on its first visual run and blank gutter rows for wrapped + continuation runs, then apply the same normalized vertical scroll to + the gutter buffer. That keeps line numbers aligned when the font + change creates wraps instead of exposing the existing one-row-per- + source-line skew. +- The pre-change follow decision remains conservative: compute it + before any mutation from the ACTUAL code-caret rectangle intersected + with the drawable code clip (and only when the minibuffer is closed), + not from `view_range`; this excludes both the two-line source + overscan and wrapped runs clipped below the band. Painted before ⇒ + run `ensure_caret_painted` after the new family/metrics are shaped. + Not painted before ⇒ preserve the user's scroll and do not call the + helper. Thus the font change never turns an overscan-only caret into + a snap-back, while a formerly painted caret survives new wrapping. + `resize()` uses this same painted-before policy after its final + dimensions are installed: narrowing a window cannot strand a + stationary caret in a new wrap, and widening an intentionally + caret-free viewport only normalizes its retained scroll. +- **Rebuild sequence** (one transaction, `apply_font_facts`): validate + the wire size (fail closed, above); record actual painted-caret + visibility; resolve the known-safe family; store the derived metrics; + set the three row-oriented popup buffers to no-wrap; measure the + selected/default advance ratio; update metrics + current dimensions + on all seven buffers; clear the two status shaping caches + (`status_text`, `status_left_text` — the + only string-equality gates; menu/mb/completion rebuild + unconditionally per frame); attrs-bearing reshape/measure; settle + the final drawable code width and reshape if it changed; if the old + caret was painted, run the visual-run helper; otherwise run the same + scroll normalizer without caret following; recompute dependent layout, + set `hit_map_dirty`, drop + the minimap vertex cache, `request_redraw()`, and finally call + `viewport_send_if_changed`. No intermediate state renders and the + viewport is derived from the final normalized source origin. No + atlas action: the per-frame `atlas.trim()` (`:4952`) clears + `glyphs_in_use`, making old-font glyphs eligible for later LRU-style + eviction under allocation pressure (they do not age out on their own + — glyphon `text_atlas.rs:219`). +- The seven `Family::Name` literals collapse into one accessor on + `State` so family application is a single site. + +### Q#F7 — Default semantics and late join + +`(None, None)` is a real, always-shipped state meaning "frontend +built-ins" — an attachment never infers defaults from silence (the +Q#TH7 authoritative-per-attachment lesson). A late-joining GPU peer +receives the current preference among its first frames; a running +peer receiving `(None, None)` after a themed session resets to the +sanitized current-order JetBrains Mono query and today's constants +exactly. + +## Bets + +- `Buffer::set_metrics_and_size` + attrs-bearing re-set is a sufficient + reload path at glyphon 0.11 / cosmic-text 0.18 — no atlas or + renderer rebuild, no `FontSystem` swap (the db only ever grows; + system fonts load at startup); the per-frame `atlas.trim()` + cycle makes old-font glyphs eligible for later eviction under + allocation pressure, which is sufficient because stale entries + are only wasted atlas space, never wrong rendering. The headless + harness runs the identical `assemble()` path, so this bet is + testable end to end under `PMACS_REQUIRE_GPU=1`. +- Proportional scaling of the chrome constants from one size knob is + acceptable at stage 2; per-surface knobs are deferred, not + designed around. +- Hermetic family testing: CI GPU runners may have zero system + fonts, so acceptance embeds FOUR test-local faces (test bytes, not + shipped assets): a second **monospaced** family to prove + resolution-and-switch, a **proportional** family to pin the monospace + gate's fallback, a monospaced TEST-DEFAULT face to model today's + system-order winner, and a BOLD proportional face carrying that same + test-only family name to pin the sanitized-default collision and + styled-query rule. The database sanitizer takes the default family + name and bundled-equivalent ID as internal parameters; production + passes `"JetBrains Mono"` and the real bundled ID, while tests use an + unreserved fixture name. A test-only assembly input loads all four into the explicit + pre-`FontSystem` database so cache/monospace-ID construction and the + collision filter are the production path, not a post-construction + approximation. The missing-family path is exercised with a name + guaranteed absent. + Test-font licenses/notices live beside the fixtures. +- The scale-factor gap stays orthogonal: this stage neither fixes + nor worsens DPI handling (`scale: 1.0` everywhere, unchanged). + +## Deferred (named) + +Font file paths/bytes over the wire (the design doc's original +`set_font(path)` sketch — needs a resource channel, plausibly the +dormant `ResourceOffer`, and a frontend-trust story); +**proportional-family support** (per-glyph layout widths replacing the +single measured monospace ratio and chars×constant hit geometry — the +monospace gate is the stage-2 stance, finding 4); per-frontend font +overrides (the LineNumbers `frontend_id`-routed shape is available +if wanted); per-surface size knobs (status/menu/dropdown independent +of code); weight/style variants (bold/italic family selection — +interacts with the chrome-attribute mask widening already deferred +by stage 1); fallback-chain configuration; ligature/feature toggles; +DPI/scale-factor handling (pre-existing gap: no `ScaleFactorChanged` +arm, `scale: 1.0` hardcoded); cursor-blink and other GPU chrome +config the roadmap groups nearby; TUI font anything (terminal-owned +by definition); **viewport-aware context-menu layout** (horizontal +ellipsis plus vertical flip/window/scroll — extreme sizes deliberately +surface-clip in stage 2); wrap-exact minimap-thumb/status-percentage +accounting (the current source-line estimate remains conservative; +visual-run exactness is load-bearing for caret following and gutter +alignment here, not promoted into the whole-file overview model); +horizontal reveal for a single indivisible glyph wider than the code +viewport (cosmic-text computes `Scroll.horizontal`, but glyphon 0.11's +renderer does not apply it; a frontend-local x-offset would have to +thread through painting, caret/decorations, popup anchors, and hit +testing). + +## Acceptance + +Suites: `tests/gpu_font_acceptance.rs` (wire + Lua + producer), +existing pin/round-trip homes in `src/protocol.rs`, and the GPU +route tests in pmacs-gpu's headless suite (`PMACS_REQUIRE_GPU=1`). + +1. **Version pins**: `PROTOCOL_VERSION == 17`; ladder accepts + `6..=17` and rejects 18; `FontFacts` postcard round-trip (both + populated and all-`None`); byte-level encoding pin of + `ThemeFacts` (the final pre-v17 variant) proving the appended + placement shifted no existing discriminant. +2. **Authoritative default per attachment**: a fresh session's first + frame after viewport declaration carries `FontFacts { family: + None, size_centi_px: None }`; unchanged ticks are silent; a + late-joining second session receives the current preference + without any mutation post-attach. +3. **Live re-ship**: `pmacs.gpu.set_font { size = 18 }` mid-session + emits exactly one `FontFacts` on the next frame; an identical + re-set advances the inspected epoch without emitting (asserted on + internal state, the caches-advance-on-computation pin). +4. **Snapshot survival, both sides**: `on_buffer_snapshot_sent` leaves + the producer's font baselines untouched — an A → B → A round trip + re-ships buffer facts but NOT `FontFacts`. The GPU `BufferSnapshot` + arm retains the resolved family, metrics, and derived geometry; it + resets the normalized code scroll with the other BUFFER-scoped view + state so B cannot inherit A's visual residual. The new buffer shapes + under the same preference without waiting for a redundant global + fact. A byte-identical snapshot also removes the prior buffer's + minimap reservation before reshaping, so identical text cannot retain + the old buffer's narrower code clip. +5. **Version gate**: a v16 peer session never receives `FontFacts` + (producer `for_peer` + daemon skip arm, the real-daemon probe + shape from stage 1). +6. **Lua contract**: bad size (non-finite, out of range — including + `5.999`, which must error rather than round into range) and bad + family (empty, non-string) throw with the offending field named; + nothing lands and nothing emits on a failed set; quantization + pins values on both sides of a hundredth (e.g. `15.994` → 1599, + `15.996` → 1600); an unknown key is rejected with its name; a hostile + or value-providing metatable is never invoked; `pmacs.gpu.font()` + returns a fresh quantized plain table; `set_font {}` resets both + axes. Every rejected shape leaves state and emissions untouched. +7. **Init.lua reachability** (finding 5): a `load_user_config_at` + fixture whose init.lua calls `pmacs.gpu.set_font` succeeds, and + the first attachment's first frame ships that preference (the + handle installs before user config runs, `src/editor.rs:455`). +8. **TUI drop arm**: the grid frontend consumes `FontFacts` without + error (family test pattern). +9. **GPU size route**: applying `FontFacts { size_centi_px: + Some(2000) }` to a headless state changes the rendered frame, + widens `mono_advance`/gutter, and reduces + `estimated_visible_lines`; re-applying `(None, None)` restores + the original frame byte-identically (unset = today's constants). +10. **GPU band geometry at the bounds — owned containment only** + (round 1 finding 2, narrowed in rounds 2/3): with status band, + minibuffer, and completion surfaces open, applying the minimum + (600) and maximum (7200) sizes yields VERTICAL containment — code + glyphs stop at the band edge, status glyphs fit inside the derived + band height, and the two dropdowns' existing row windows remain + inside the surface. Popups may occlude code by layer contract. A + separate context-menu route uses the shipped multi-row menu near + the lower edge at size 7200 and asserts safe surface clipping, no + panic, and hit-testing for pixels inside the surface that agrees + with the same clipped geometry; it does NOT assert containment or + complete labels/rows. The derived + band height tracks the status line, and `text_area_bottom` / + minimap height / source-line visible estimate follow. +11. **GPU caret survival — source + wrapped visual runs** (round 1 + finding 3 + round 2 finding 2 + round 3 finding 1): + with the caret on the OLD last visible line, applying a larger + size keeps the caret rendered and the re-declared viewport's + origin corrected after scroll normalization; with the caret + deliberately scrolled to exactly ONE source line past the painted + window — inside `view_range`'s two-line overscan — the same size + change does NOT snap back. The load-bearing wrap bite is one long + source line whose end caret is painted at size 600 but wraps below + the code clip at size 7200: after the change the caret is painted, + `Buffer::scroll` carries the needed VERTICAL visual-run offset (and + keeps `horizontal == 0`), any nonzero slice-local line is normalized + into `scroll_top`; `view_range` agrees with that source origin, and + gutter continuation blanks keep + the next source-line number aligned. The caret rect and completion + popup anchor both resolve to the run containing the byte rather than + the first run of that source line; an inline adornment before the + byte proves the source→projected conversion is not an identity map. + A moved `CursorByte` repeats the guarantee; an optimistic insertion + that creates a new bottom-edge + wrap follows immediately and its identical confirming `CursorByte` + needs no second repair; a stationary cursor after an explicit wheel + scroll does not snap. A reverse 7200→600 change while the caret is + off-screen collapses wraps, translates any cosmic `scroll.line` + advance into whole-file `scroll_top`, and preserves a nonblank + viewport without following the caret. A width-only narrow/widen + resize repeats both painted and scrolled-away halves through the + shared helper. +12. **GPU family routes** (rounds 1/3 finding 4): a test-embedded second + monospaced font resolves and changes the frame; a test-embedded + PROPORTIONAL font is rejected by the monospace gate and falls + back to the default family query; an unresolvable family name does + the same. A monospaced system-order default fixture remains the + default query winner in the parameterized sanitizer unit (baseline + preservation); a same-family BOLD proportional collision is removed + during database assembly, styled + code still selects only monospaced faces, and the bundled ID is + present in cosmic-text's monospace-ID set. All four fixture IDs are + retained from the pre-`FontSystem` assembly and checked against + cosmic-text's real `is_monospace` classification. Direct resolver + units cover normal, bold, italic, and bold-italic queries. Both + rejected-request routes and the collision-safe default reset render + byte-identically to never-set. +13. **GPU shaping-cache invalidation**: with composed band strings + constant, a size change re-shapes the status band (the Q#TH8 + counter lesson applied to metrics; only the status buffers cache + composed strings, finding 6). +14. **GPU viewport re-declaration**: a size change that alters + `estimated_visible_lines` produces a `Viewport` re-declaration + (`viewport_send_if_changed` returns `Some`). +15. **Protocol/design docs**: `docs/semantic-frontend-protocol.md` + records `FontFacts`, its v17 gate, authoritative default, and + snapshot survival; `docs/pmacs-gpu-design.md:298-299` no longer + claims font customization needs no wire change and points here. +16. **GPU wire validation fails closed** (round 2 finding 1): + applying `FontFacts` with `size_centi_px` of 0 (the + `Buffer::set_metrics` panic value), 599, 7201, and `u32::MAX` + directly to the GPU arm mutates nothing — the frame renders + byte-identically to before, no panic, no partial application — + while 600 and 7200 apply. +17. **Metric/dimension atomicity and resize symmetry** (round 3 + finding 3): resize the headless surface, then apply both size + bounds before rendering. Immediately after each application, + `Buffer::size()` reports the actual drawable code width/height for + code, the derived band height for both status buffers, the current + surface height for menu/mb/completion, and the current code height + for gutter; no buffer retains construction-time or prior-size + dimensions. A family whose advance changes the gutter width forces + the final code-width reflow, and the resulting wrap/hit map and + rendered clip use that same width. Line-number enable/disable, + gutter digit transitions, and minimap appearance/removal exercise + the same dynamic reflow transaction independently of a font change. +18. **Popup row invariance at both size bounds**: long menu, + minibuffer-candidate, and completion labels remain one layout run per + wire row at 600 and 7200 (`Wrap::None`). Their selection quads and + in-surface hit tests select the same semantic row as the painted + glyphs; overlong horizontal text clips rather than creating an + untracked second row. +19. **Family-dependent geometry on an empty document**: with line + numbers and a context menu open over an empty buffer, switching to + the alternate embedded monospace family updates the measured + gutter fallback and menu hit width by the selected/default advance + ratio. Reset restores the exact original geometry and frame; the + test does not depend on a code glyph already being shaped. A styled + twin whose first code glyph is bold proves gutter measurement still + uses the selected family's normal face rather than that glyph. diff --git a/docs/pmacs-gpu-design.md b/docs/pmacs-gpu-design.md index 61d19e8..c7982e4 100644 --- a/docs/pmacs-gpu-design.md +++ b/docs/pmacs-gpu-design.md @@ -289,14 +289,21 @@ override.** classified as a small finding under rule (iii) and absorbed; the bundled `fonts/OFL.txt` is shipped alongside the TTF as required by the OFL. -- Lua override: `pmacs.gpu.set_font(path)` or similar (precise binding - shape decided session 2). +- Lua override: **landed** as `pmacs.gpu.set_font { family?, size? }` + (Arc 4 stage 2, `docs/gpu-set-font-framing.md`) — a family NAME + resolved frontend-locally against the sanitized font database, not + a path. - Missing-glyph fallback: tofu (replacement character `U+FFFD`). Explicit non-goal to ship a sophisticated fallback chain in v0.1. If real users hit this, it's v0.2+ scope. The bundled-default-plus-override shape means v0.1 works without -configuration; future customization needs no wire-protocol changes. +configuration. **Correction (Arc 4 stage 2):** this section originally +claimed future customization would need no wire-protocol change, but +the preference lives daemon-side (init.lua runs in the daemon, and +every attaching GPU must render consistently), so customization +shipped as the `FontFacts` fact at protocol v17 — +`docs/gpu-set-font-framing.md` is the design of record. ## Rhythm diff --git a/docs/semantic-frontend-protocol.md b/docs/semantic-frontend-protocol.md index 3258fb7..66c2baf 100644 --- a/docs/semantic-frontend-protocol.md +++ b/docs/semantic-frontend-protocol.md @@ -126,8 +126,11 @@ invalidating its per-buffer emission baselines whenever it writes a snapshot, so the frontend's post-snapshot viewport declaration receives authoritative re-sends even when nothing changed daemon-side (the unchanged-generation A → B → A revisit). Bufferless -facts (`ThemeFacts`, the minibuffer prompt) and per-frontend state -(the gutter mode) survive snapshots on both sides, and the +facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and +per-frontend state (the gutter mode) survive snapshots on both sides +(frontend-locally the normalized code scroll — a caret-follow view +residual — is buffer-scoped and resets, while the resolved font and +derived metrics survive), and the instance's stale-store diagnostic-count freeze is store knowledge, not session state — the re-sent `StatusFacts` after a snapshot carries the frozen counts, never zeros, including for a session @@ -245,6 +248,28 @@ ResourceOffer { ThemeFacts { faces: Vec, // { name: String, style: Style }, sorted by name }, + +/// The GLOBAL font preference (protocol v17, Arc 4 stage 2, +/// docs/gpu-set-font-framing.md), written by `pmacs.gpu.set_font`. +/// Bufferless and authoritative per attachment: every session's +/// first frame after viewport declaration carries the current +/// preference — the all-default `(None, None)` included, never +/// inferred from silence — and it is epoch-gated/cached-compare +/// suppressed thereafter, so an unchanged preference costs one +/// small message per attachment. `BufferSnapshot` resets never +/// touch it on either side. The daemon relays a PREFERENCE only +/// (no pixels): the frontend resolves the family locally +/// (monospace-gated, total fallback to its sanitized default) and +/// owns every metric consequence; sizes travel as integer +/// hundredths of a logical pixel (1600 = 16.0, validated to +/// 600..=7200 on BOTH sides — the receiver fails closed on +/// out-of-range wire values). Daemon-gated `>= 17`; appended as +/// the FINAL variant — postcard discriminants are ordinal, and the +/// ThemeFacts byte pin above guards this placement. +FontFacts { + family: Option, // None = the frontend's default family + size_centi_px: Option, // None = the frontend's default size +}, ``` Each family member diffs against the previous frame the same way 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/fonts/test/LICENSE.txt b/pmacs-gpu/fonts/test/LICENSE.txt new file mode 100644 index 0000000..2531812 --- /dev/null +++ b/pmacs-gpu/fonts/test/LICENSE.txt @@ -0,0 +1,6 @@ +The Pmacs Test* font fixtures in this directory are trivial synthetic +faces (rectangle glyphs over space, digits, and a-z) generated for the +pmacs-gpu test suite by the accompanying generate.py script. They are +original to the pmacs project, contain no third-party outlines or +data, and are released under the same license as the pmacs source +tree. They are test fixtures, not usable typefaces. diff --git a/pmacs-gpu/fonts/test/PmacsTestFamily-Bold.ttf b/pmacs-gpu/fonts/test/PmacsTestFamily-Bold.ttf new file mode 100644 index 0000000..328f771 Binary files /dev/null and b/pmacs-gpu/fonts/test/PmacsTestFamily-Bold.ttf differ diff --git a/pmacs-gpu/fonts/test/PmacsTestFamily-Regular.ttf b/pmacs-gpu/fonts/test/PmacsTestFamily-Regular.ttf new file mode 100644 index 0000000..c4bafcf Binary files /dev/null and b/pmacs-gpu/fonts/test/PmacsTestFamily-Regular.ttf differ diff --git a/pmacs-gpu/fonts/test/PmacsTestMonoTwo-Regular.ttf b/pmacs-gpu/fonts/test/PmacsTestMonoTwo-Regular.ttf new file mode 100644 index 0000000..bef6c61 Binary files /dev/null and b/pmacs-gpu/fonts/test/PmacsTestMonoTwo-Regular.ttf differ diff --git a/pmacs-gpu/fonts/test/PmacsTestProportional-Regular.ttf b/pmacs-gpu/fonts/test/PmacsTestProportional-Regular.ttf new file mode 100644 index 0000000..1542f17 Binary files /dev/null and b/pmacs-gpu/fonts/test/PmacsTestProportional-Regular.ttf differ diff --git a/pmacs-gpu/fonts/test/generate.py b/pmacs-gpu/fonts/test/generate.py new file mode 100644 index 0000000..6b4750a --- /dev/null +++ b/pmacs-gpu/fonts/test/generate.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Generate the four hermetic test faces for the pmacs-gpu font tests. + +The gpu-set-font acceptance suite (docs/gpu-set-font-framing.md) needs +family-routing tests that cannot depend on whatever fonts the host has +installed, so these tiny fixture faces are generated and committed: + + PmacsTestMonoTwo-Regular.ttf "Pmacs Test Mono Two" monospaced, + advance 720/1000 (JetBrains Mono is + 600/1000, so the measured advance + ratio is exactly 1.2), with true + "01" and "fi" ligatures whose advances + preserve two cells + PmacsTestProportional-Regular.ttf "Pmacs Test Proportional" varying + advances, not monospaced + PmacsTestFamily-Regular.ttf "Pmacs Test Family" monospaced + normal face, advance 800/1000 + PmacsTestFamily-Bold.ttf "Pmacs Test Family" BOLD and + proportional -- the same-family + collision the sanitizer removes and + the four-style monospace gate must + reject + +Every glyph is a plain rectangle (ink for frame-diff tests); coverage +is space, the digits (the ADVANCE_PROBE string), and a-z. fontdb's +`monospaced` flag reads the post table's isFixedPitch, so that is the +one bit that decides mono vs proportional here. + +Run from this directory: python3 generate.py +Requires fontTools (any recent version). +""" + +from fontTools.feaLib.builder import addOpenTypeFeaturesFromString +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen + +UPM = 1000 +CHARS = " 0123456789abcdefghijklmnopqrstuvwxyz" +ASCENT = 800 +DESCENT = -200 +# Fixed at the first committed fixture generation so re-running this +# script changes only intentional font data, not the head timestamps. +FIXTURE_TIMESTAMP = 3866975598 + + +def glyph_name(char): + return "uni%04X" % ord(char) + + +def rect_glyph(advance): + """A filled rectangle spanning most of the advance width.""" + pen = TTGlyphPen(None) + left = 60 + right = max(left + 40, advance - 60) + pen.moveTo((left, 0)) + pen.lineTo((right, 0)) + pen.lineTo((right, 700)) + pen.lineTo((left, 700)) + pen.closePath() + return pen.glyph() + + +def empty_glyph(): + return TTGlyphPen(None).glyph() + + +def build( + path, + family, + style, + weight, + bold, + fixed_pitch, + advance_for, + ligatures=(), +): + order = [".notdef"] + [glyph_name(c) for c in CHARS] + order += [name for name, _ in ligatures] + fb = FontBuilder(UPM, isTTF=True) + fb.setupGlyphOrder(order) + fb.setupCharacterMap({ord(c): glyph_name(c) for c in CHARS}) + glyphs = {".notdef": rect_glyph(600)} + metrics = {".notdef": (600, 60)} + for c in CHARS: + adv = advance_for(c) + name = glyph_name(c) + glyphs[name] = empty_glyph() if c == " " else rect_glyph(adv) + metrics[name] = (adv, 0 if c == " " else 60) + for name, components in ligatures: + advance = sum(advance_for(c) for c in components) + glyphs[name] = rect_glyph(advance) + metrics[name] = (advance, 60) + fb.setupGlyf(glyphs) + fb.setupHorizontalMetrics(metrics) + fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT) + # fontdb refuses faces without a PostScript name (nameID 6). + ps_name = (family + "-" + style).replace(" ", "") + fb.setupNameTable({"familyName": family, "styleName": style, "psName": ps_name}) + fb.setupOS2( + sTypoAscender=ASCENT, + sTypoDescender=DESCENT, + usWinAscent=ASCENT, + usWinDescent=-DESCENT, + usWeightClass=weight, + fsSelection=0x20 if bold else 0x40, # BOLD else REGULAR + ) + fb.setupPost(isFixedPitch=1 if fixed_pitch else 0) + if bold: + fb.font["head"].macStyle = 0x01 + if ligatures: + substitutions = "\n".join( + "sub %s by %s;" + % (" ".join(glyph_name(c) for c in components), name) + for name, components in ligatures + ) + addOpenTypeFeaturesFromString( + fb.font, + "feature liga {\n%s\n} liga;" % substitutions, + ) + fb.font["head"].created = FIXTURE_TIMESTAMP + fb.font["head"].modified = FIXTURE_TIMESTAMP + fb.font.recalcTimestamp = False + fb.save(path) + print("wrote", path) + + +def proportional_advance(c): + if c == " ": + return 250 + if c.isdigit(): + return 500 + # A spread of widths so no two adjacent letters share one. + return 300 + (ord(c) - ord("a")) * 15 + + +build( + "PmacsTestMonoTwo-Regular.ttf", + "Pmacs Test Mono Two", + "Regular", + 400, + bold=False, + fixed_pitch=True, + advance_for=lambda c: 720, + ligatures=( + ("zero_one.liga", "01"), + ("f_i.liga", "fi"), + ), +) +build( + "PmacsTestProportional-Regular.ttf", + "Pmacs Test Proportional", + "Regular", + 400, + bold=False, + fixed_pitch=False, + advance_for=proportional_advance, +) +build( + "PmacsTestFamily-Regular.ttf", + "Pmacs Test Family", + "Regular", + 400, + bold=False, + fixed_pitch=True, + advance_for=lambda c: 800, +) +build( + "PmacsTestFamily-Bold.ttf", + "Pmacs Test Family", + "Bold", + 700, + bold=True, + fixed_pitch=False, + advance_for=proportional_advance, +) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index e8e220e..3012479 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -28,9 +28,10 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use glyphon::cosmic_text::{Affinity, Cursor, Scroll, Wrap}; 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 +54,241 @@ 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"); +#[cfg(test)] +const TEST_MONO_TWO: &[u8] = include_bytes!("../fonts/test/PmacsTestMonoTwo-Regular.ttf"); +#[cfg(test)] +const TEST_PROPORTIONAL: &[u8] = include_bytes!("../fonts/test/PmacsTestProportional-Regular.ttf"); +#[cfg(test)] +const TEST_FAMILY_REGULAR: &[u8] = include_bytes!("../fonts/test/PmacsTestFamily-Regular.ttf"); +#[cfg(test)] +const TEST_FAMILY_BOLD: &[u8] = include_bytes!("../fonts/test/PmacsTestFamily-Bold.ttf"); +#[cfg(test)] +const TEST_FONT_SOURCES: &[&[u8]] = &[ + TEST_MONO_TWO, + TEST_PROPORTIONAL, + TEST_FAMILY_REGULAR, + TEST_FAMILY_BOLD, +]; + +/// 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"; + +/// 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 +/// `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, + /// IDs loaded through the optional assembly input. Production + /// passes no extras; headless tests retain fixture IDs so they can + /// assert cosmic-text classified the final database, not a + /// post-construction mutation. + #[cfg(test)] + extra_ids: Vec, +} + +/// 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(extra_sources: &[&'static [u8]]) -> (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"); + let extra_ids: Vec = extra_sources + .iter() + .flat_map(|bytes| db.load_font_source(fontdb::Source::Binary(std::sync::Arc::new(*bytes)))) + .collect(); + 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); + #[cfg(not(test))] + drop(extra_ids); + let defaults = FontDefaults { + default_family: DEFAULT_FONT_FAMILY.to_owned(), + bundled_id, + #[cfg(test)] + extra_ids, + }; + // 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!( + font_system.is_monospace(defaults.bundled_id), + "cosmic-text must register the bundled face as monospace" + ); + 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, + }) +} + +/// The fixed ASCII advance probe (framing Q#F6). The measurement uses +/// its total shaped width divided by this logical cell count; it does +/// not assume one glyph per digit because a valid monospace face may +/// substitute multi-cell digit ligatures. +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 cell 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. Total run width divided by logical cells survives +/// ligature substitution; `None` when the family shapes no width. +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); + let total_width: f32 = probe.layout_runs().map(|run| run.line_w).sum(); + let cells = ADVANCE_PROBE.chars().count() as f32; + (total_width > 0.0 && cells > 0.0).then_some(total_width / cells) +} + /// Initial window size in logical pixels. const INITIAL_WIDTH: u32 = 800; const INITIAL_HEIGHT: u32 = 200; @@ -87,16 +323,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, @@ -126,20 +362,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]; @@ -150,9 +386,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; @@ -165,11 +401,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; } @@ -385,6 +626,34 @@ 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. + 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, + /// 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, @@ -1159,7 +1428,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 { @@ -1296,7 +1565,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 { @@ -1781,6 +2050,7 @@ impl State { queue, config, initial_text, + &[], ) } @@ -1822,6 +2092,7 @@ impl State { queue, config, initial_text, + TEST_FONT_SOURCES, )) } @@ -1838,12 +2109,19 @@ impl State { queue: wgpu::Queue, config: wgpu::SurfaceConfiguration, initial_text: &str, + extra_font_sources: &[&'static [u8]], ) -> Self { // 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: 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 + // its monospace-ID set sees the final database. + let (mut font_system, font_defaults) = build_font_system(extra_font_sources); let swash_cache = SwashCache::new(); let cache = Cache::new(&device); let mut viewport = Viewport::new(&device, &cache); @@ -1876,7 +2154,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), @@ -1884,79 +2165,85 @@ 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, 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(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, 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( &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, 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( &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, Some(config.width as f32), Some(config.height as f32), ); - buffer.set_text( - &mut font_system, - initial_text, - &Attrs::new().family(Family::Name("JetBrains Mono")), - 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, surface, config, font_system, + 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, @@ -2030,7 +2317,19 @@ impl State { faces: HashMap::new(), gutter_buffer, gutter_text_renderer, - } + }; + // Real drawable dimensions from construction (framing Q#F6): + // wrapping and `shape_until_cursor` must use the same clip the + // painter uses, and a v16 daemon never sends the FontFacts + // that would sync them later. + state.sync_buffer_dimensions(); + // 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) { @@ -2221,13 +2520,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 }, @@ -2243,6 +2541,7 @@ impl State { &mut self, delta_batches: &[Vec], ) -> Result, &'static str> { + let caret_was_painted = self.caret_painted_in_code_clip(); let line_count_before = self.current_line_starts.len(); let edits = apply_loro_text_delta_batches( &mut self.current_text, @@ -2254,6 +2553,10 @@ impl State { return Ok(edits); } self.translate_cached_anchors(&edits); + // A newline edit can cross a gutter digit boundary (9 -> 10, + // 99 -> 100). Synchronize the painter-derived code width + // before any reshape so cosmic-text wraps at the final clip. + let geometry_changed = self.sync_buffer_dimensions(); // Q#R1 — the keystroke case (one edit, no line-structure // change) re-shapes only the affected BufferLine; everything // else falls back to the full slice reshape. @@ -2262,9 +2565,12 @@ impl State { && !self.current_text [edits[0].start as usize..(edits[0].start + edits[0].inserted_len) as usize] .contains('\n'); - if !(single_line_edit && self.try_reshape_line(edits[0])) { + if geometry_changed || !(single_line_edit && self.try_reshape_line(edits[0])) { self.reshape(); } + if geometry_changed && caret_was_painted { + self.ensure_caret_painted(); + } Ok(edits) } @@ -2339,12 +2645,17 @@ impl State { if self.current_text == text { return false; } + let caret_was_painted = self.caret_painted_in_code_clip(); self.current_text.clear(); self.current_text.push_str(text); let (line_starts, line_char_starts) = line_offset_tables(text); self.current_line_starts = line_starts; self.current_line_char_starts = line_char_starts; + let geometry_changed = self.sync_buffer_dimensions(); self.reshape(); + if geometry_changed && caret_was_painted { + self.ensure_caret_painted(); + } true } @@ -2488,9 +2799,17 @@ 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) { + // Even byte-identical A -> B snapshots can clear a + // prior minimap, so the new buffer's shaping clip + // must still be synchronized before rebuilding. + self.sync_buffer_dimensions(); self.reshape(); } self.viewport_send_if_changed(buffer_id) @@ -2678,10 +2997,7 @@ impl State { buffer_id, generation, lines, - } => { - self.apply_file_style_summary(buffer_id, generation, lines); - None - } + } => self.apply_file_style_summary(buffer_id, generation, lines), // Q#S1 (protocol v8; `message` since v15) — the // wire-authoritative half of the status band: name, // modified, whole-file diag counts, and the transient @@ -2710,8 +3026,9 @@ impl State { // repaint on change. InstanceMessage::LineNumbers { mode, .. } => { if self.line_numbers != mode { + let caret_was_painted = self.caret_painted_in_code_clip(); self.line_numbers = mode; - self.request_redraw(); + return self.reflow_dynamic_code_geometry(caret_was_painted); } None } @@ -2851,10 +3168,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); } @@ -2942,6 +3262,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, } } @@ -3017,7 +3351,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; @@ -3027,16 +3361,55 @@ 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(cursor) = self.code_byte_to_layout_cursor(byte) { + self.buffer + .shape_until_cursor(&mut self.font_system, cursor, 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()) .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 @@ -3084,9 +3457,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() { @@ -3106,14 +3483,27 @@ 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( &mut self.font_system, &text, - &Attrs::new().family(Family::Name("JetBrains Mono")), + &Attrs::new().family(Family::Name(&family)), 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); } @@ -3175,9 +3565,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 @@ -3188,20 +3586,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 @@ -3210,13 +3615,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)) } @@ -3225,9 +3630,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) @@ -3277,10 +3687,14 @@ 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); + 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 @@ -3363,7 +3777,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); } } @@ -3371,8 +3785,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; @@ -3407,13 +3822,16 @@ 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; } } 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 @@ -3604,7 +4022,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, )); @@ -3678,7 +4096,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() { @@ -3726,9 +4145,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) @@ -3745,10 +4164,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, ); @@ -3764,20 +4184,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, @@ -3787,7 +4207,7 @@ impl State { x: ax, y: ry, w, - h: MENU_ROW_HEIGHT, + h: self.fm.menu_row_height(), color: MENU_SELECTED_BG, }); } @@ -3802,10 +4222,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, ); @@ -3819,11 +4240,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, ) } @@ -3842,8 +4264,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)) } @@ -3863,7 +4285,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*; @@ -3874,9 +4296,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, }); } @@ -3900,10 +4322,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, ); @@ -3917,41 +4340,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, @@ -3961,18 +4369,19 @@ 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); + 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 { @@ -3982,7 +4391,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 { @@ -3991,7 +4399,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)) } @@ -4001,7 +4409,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 @@ -4015,8 +4423,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 { @@ -4029,18 +4437,18 @@ 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) + if let Some(sel) = selected.map(|s| s as usize) && sel >= first && sel < first + count { 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, }); } @@ -4098,17 +4506,18 @@ impl State { buffer_id: BufferId, generation: u64, lines: Vec, - ) { + ) -> Option { if self.current_buffer_id != Some(buffer_id) { - return; + return None; } if self .current_summary .as_ref() .is_some_and(|summary| generation < summary.generation) { - return; + return None; } + let caret_was_painted = self.caret_painted_in_code_clip(); self.current_line_shapes = minimap_line_shapes(&self.current_text); self.current_summary = Some(FileStyleSummaryState { generation, lines }); // PR #120 round 1 finding 1: a newly accepted summary can @@ -4120,7 +4529,7 @@ impl State { // summary accepted here is genuinely new and the invalidation // is precise. self.minimap_cache = None; - self.request_redraw(); + self.reflow_dynamic_code_geometry(caret_was_painted) } /// `full = true` path: discard prior styling, take the segments' @@ -4287,7 +4696,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 { @@ -4298,7 +4707,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 @@ -4312,18 +4727,61 @@ 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; 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(); @@ -4337,7 +4795,264 @@ 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. Returns whether the code buffer's + /// metrics or drawable dimensions changed and therefore require + /// reshaping before its next frame. + fn sync_buffer_dimensions(&mut self) -> bool { + 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); + let code_layout_changed = self.buffer.metrics() != code_metrics + || self.buffer.size() != (Some(code_width), Some(code_height)); + 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), + ); + code_layout_changed + } + + /// Reflow after a dynamic painter-geometry input changes: gutter + /// mode/digit width or minimap presence. The caller captures + /// `caret_was_painted` against the old geometry before mutating + /// that input. Resize the buffer first, shape once at the final + /// clip, normalize the visual residual, and re-follow only a caret + /// that was actually painted. A viewport is returned only when + /// that settling changed the source range. + fn reflow_dynamic_code_geometry(&mut self, caret_was_painted: bool) -> Option { + if self.sync_buffer_dimensions() { + self.reshape(); + if caret_was_painted { + self.ensure_caret_painted(); + } + } else { + self.request_redraw(); + } + self.current_buffer_id + .and_then(|buffer_id| self.viewport_send_if_changed(buffer_id)) + } + + /// 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 resolved_is_default = resolved == self.font_defaults.default_family; + let advance_ratio = if resolved_is_default { + 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, + }; + // The default family already has an exact, pre-preference + // geometry path: a shaped glyph when present, otherwise the + // ratio-scaled baseline constant. Keep using it so resetting + // to `(None, None)` is bit-identical to never-set; averaging a + // ten-cell f32 run can differ by one ulp. Alternate families + // need the measured normal-face advance because their cell + // width is not encoded in the baseline. + self.measured_mono_advance = if resolved_is_default { + None + } else { + 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 { @@ -4345,32 +5060,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(STATUS_BAND_HEIGHT), - ); - self.status_left_buffer.set_size( - &mut self.font_system, - Some(width as f32), - Some(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)) @@ -4627,8 +5324,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. @@ -4666,7 +5363,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: &[], @@ -4678,7 +5375,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(), }, @@ -4695,7 +5392,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(), @@ -4725,7 +5422,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, @@ -4762,8 +5459,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: &[], @@ -4796,13 +5494,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; @@ -4829,19 +5527,20 @@ 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