feat(font): FontFacts wire + daemon half (v17, FontPref, producer)

Protocol v16->17: InstanceMessage::FontFacts { family,
size_centi_px } appended after ThemeFacts (integer hundredths of a
logical pixel -- the enum derives Eq, f32 cannot; range 600..=7200
documented on the wire). Pins updated: version 17, ladder accepts
6..=17 rejects 18, FontFacts round-trip (populated + all-None), and
a ThemeFacts byte pin ([23, 0]) guarding the appended placement.

Daemon half: FontPref { family, size_centi_px, epoch } behind a
shared handle on EditorState, installed with the new pmacs.gpu Lua
module BEFORE load_user_config so init.lua set_font lands in the
state the first attachment reads. set_font is strict plain data
(raw_get, unknown raw keys rejected by name, metatables never
consulted, parse/validate/quantize fully before locking -- range-
check the ORIGINAL value so 5.999 errors, then nearest-hundredth
round); pmacs.gpu.font() returns a fresh quantized table.

Producer: font_facts_msg (the theme_facts_msg discipline --
Option-seeded epoch + payload baselines, advance on computation,
one authoritative send per attachment incl (None, None),
bufferless so on_buffer_snapshot_sent never touches it); for_peer
gains peer_knows_font_facts (>= 17); daemon write-loop skip arm;
TUI silent-drop arm + regression test; first-frame count test now
expects 6 messages. GPU application follows in the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
Levi Neuwirth 2026-07-15 14:22:50 +01:00
parent 2521169350
commit 661b4968d9
10 changed files with 470 additions and 16 deletions

View File

@ -5836,6 +5836,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::LineNumbers { .. } => "LineNumbers",
InstanceMessage::CompletionPopup { .. } => "CompletionPopup",
InstanceMessage::ThemeFacts { .. } => "ThemeFacts",
InstanceMessage::FontFacts { .. } => "FontFacts",
}
}

View File

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

View File

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

View File

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

42
src/font_pref.rs Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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