diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index e8e220e..24f9d91 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -5836,6 +5836,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::LineNumbers { .. } => "LineNumbers", InstanceMessage::CompletionPopup { .. } => "CompletionPopup", InstanceMessage::ThemeFacts { .. } => "ThemeFacts", + InstanceMessage::FontFacts { .. } => "FontFacts", } } diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 3318e87..6ff187e 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -983,11 +983,12 @@ pub enum InstanceMessage { /// included — with its first emission after viewport declaration; /// cached-compare suppressed thereafter. Daemon-gated `>= 16`. /// - /// Appended as the FINAL variant deliberately: postcard + /// Appended as the final v16 variant deliberately: postcard /// discriminants are ordinal, so inserting earlier would shift /// every later variant's tag and corrupt v15 peers on ungated /// channels. The `CompletionPopup` byte pin in `src/protocol.rs` - /// guards this placement. + /// guards this placement; the `ThemeFacts` byte pin there guards + /// the v17 `FontFacts` placement after it in turn. ThemeFacts { /// Every stage-1 face that resolves to a style (the Q#TH4 /// dotted-prefix walk, resolved daemon-side — frontends do @@ -995,6 +996,36 @@ pub enum InstanceMessage { /// for deterministic comparison. faces: Vec, }, + /// Themes arc stage 2 (Q#F4, protocol v17). The daemon-relayed + /// GPU font preference. One global instance ⇒ bufferless (the + /// [`Self::MinibufferPrompt`] shape). Complete replacement each + /// send; `None` means the frontend's built-in default for that + /// axis. The daemon relays a PREFERENCE — it never learns + /// metrics, advances, or what resolves; the frontend owns + /// resolution and every pixel consequence (the no-pixels + /// invariant). Every attachment receives exactly one + /// authoritative preference — the all-default `(None, None)` + /// included — with its first emission after viewport + /// declaration; cached-compare suppressed thereafter. + /// Daemon-gated `>= 17`. + /// + /// Appended as the FINAL variant deliberately: postcard + /// discriminants are ordinal, so inserting earlier would shift + /// every later variant's tag and corrupt v16 peers on ungated + /// channels. The `ThemeFacts` byte pin in `src/protocol.rs` + /// guards this placement. + FontFacts { + /// Font family name to resolve frontend-locally, or `None` + /// for the frontend's default family query. + family: Option, + /// Font size in HUNDREDTHS of a logical pixel (1600 = + /// today's 16.0) — an integer because this enum derives + /// `Eq`, which `f32` cannot satisfy, and because cosmic-text + /// metrics are logical pixels, not typographic points. + /// Valid range 600..=7200; frontends validate and fail + /// closed (deserialized protocol input is untrusted). + size_centi_px: Option, + }, } /// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full @@ -1373,7 +1404,14 @@ pub enum ResourceBody { /// The variant is appended after `CompletionPopup` — the final v15 /// variant — because postcard discriminants are ordinal and an /// earlier insertion would shift existing tags under v15 peers. -pub const PROTOCOL_VERSION: u32 = 16; +/// +/// GPU font preference (Q#F4): bumped 16 → 17 for +/// [`InstanceMessage::FontFacts`] — a new additive variant relaying +/// the global font preference to GPU-capable peers. Daemon-gated +/// `< 17`; a v16 peer negotiates v16 and simply keeps its built-in +/// font. Appended after `ThemeFacts` — the final v16 variant — +/// same ordinal-discriminant reasoning as every additive bump. +pub const PROTOCOL_VERSION: u32 = 17; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1436,7 +1474,10 @@ pub const PROTOCOL_VERSION: u32 = 16; /// /// Q#TH7: extended to `[6, ..., 16]`. `InstanceMessage::ThemeFacts` /// is additive and daemon-gated per session, so the ladder resumes. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; +/// +/// Q#F4: extended to `[6, ..., 17]`. `InstanceMessage::FontFacts` +/// is additive and daemon-gated per session, so the ladder resumes. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. diff --git a/src/daemon.rs b/src/daemon.rs index 8d3d901..3cff08c 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1139,6 +1139,11 @@ fn dispatcher_loop( let peer_knows_theme_facts = session_registry .session_state(*fid) .is_some_and(|s| s.negotiated_protocol_version >= 16); + // Themes stage 2 Q#F4 — FontFacts gated at v17; a v16 + // peer simply keeps its built-in font. + let peer_knows_font_facts = session_registry + .session_state(*fid) + .is_some_and(|s| s.negotiated_protocol_version >= 17); for msg in &messages { if !peer_knows_status_facts && matches!(msg, InstanceMessage::StatusFacts { .. }) @@ -1178,6 +1183,9 @@ fn dispatcher_loop( { continue; } + if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) { + continue; + } // T M10.10 Day 4 / M10.11 F2 — the criterion-1 // jitter site: render-write latency. // diff --git a/src/editor.rs b/src/editor.rs index 955db8a..7bc447d 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -69,6 +69,10 @@ pub struct EditorState { /// supervisor's reader threads, which means a runaway server's /// log-flood doesn't stall the editor. pub lsp_manager: crate::lsp::SharedLspManager, + /// The global GPU font preference (Arc 4 stage 2, Q#F3). Written + /// by `pmacs.gpu.set_font`; read by the `semantic_render` + /// producer, which relays it as `FontFacts` (protocol v17). + pub font_pref: crate::font_pref::FontPrefHandle, /// MCP manager (T M9.1). Holds one [`crate::mcp::McpClient`] per /// MCP server; rides on top of [`Self::process_supervisor`] for /// spawn / I/O / restart, sharing the supervisor with the LSP @@ -211,6 +215,12 @@ impl EditorState { // state, but its search overlay resolves wash faces through // this handle. core.borrow_mut().theme = Some(syntax_registry.theme()); + // Arc 4 stage 2 (Q#F2/Q#F3): the GPU font preference and its + // `pmacs.gpu` Lua surface. Installed BEFORE load_user_config + // below, so an init.lua `set_font` lands in the same handle + // the first attachment's semantic producer reads. + let font_pref = + crate::lua_bindings::make_font_pref(lua_host.lua()).expect("install pmacs.gpu"); lua_host .eval( Some("@pmacs/builtin/runtime/syntax.lua"), @@ -465,6 +475,7 @@ impl EditorState { syntax_registry, process_supervisor, lsp_manager, + font_pref, mcp_manager, workspace, project_indexer, diff --git a/src/font_pref.rs b/src/font_pref.rs new file mode 100644 index 0000000..e72b9e7 --- /dev/null +++ b/src/font_pref.rs @@ -0,0 +1,42 @@ +//! The global GPU font preference (Arc 4 stage 2, framing Q#F3, +//! `docs/gpu-set-font-framing.md`). +//! +//! One daemon-side preference — family name and/or size — written by +//! `pmacs.gpu.set_font` and read by the `semantic_render` producer, +//! which relays it to GPU-capable peers as the bufferless +//! `InstanceMessage::FontFacts` at protocol v17. The daemon relays a +//! PREFERENCE: it never learns metrics, advances, or what resolves +//! (the no-pixels invariant); the frontend owns resolution and every +//! pixel consequence. + +use std::sync::{Arc, Mutex}; + +/// Shared handle, mirroring [`crate::highlight::ThemeHandle`]'s +/// shape: the Lua setter writes it, per-session producers read it. +pub type FontPrefHandle = Arc>; + +/// The preference itself. `None` per axis means "the frontend's +/// built-in default" — a REAL, always-shipped state, never inferred +/// from silence (the Q#TH7 authoritative-per-attachment lesson). +#[derive(Debug, Default)] +pub struct FontPref { + /// Font family name to resolve frontend-locally, or `None` for + /// the frontend's default family query. + pub family: Option, + /// Size in HUNDREDTHS of a logical pixel (1600 = 16.0), already + /// validated and quantized by the Lua boundary (range-check the + /// original value first, then nearest-hundredth via round — + /// framing Q#F2). `u32` matches the wire, which derives `Eq`. + pub size_centi_px: Option, + /// Monotonic mutation counter, increment-only from its prior + /// value on every successful `set_font` (the Q#TH6 lesson). The + /// producer's `Option`-seeded gate compares this one `u64` per + /// tick. + pub epoch: u64, +} + +/// Fresh all-default preference behind a new handle. +#[must_use] +pub fn new_handle() -> FontPrefHandle { + Arc::new(Mutex::new(FontPref::default())) +} diff --git a/src/frontend.rs b/src/frontend.rs index 76cfee1..108798f 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -418,6 +418,10 @@ impl Frontend { // (the daemon resolves faces at paint time), so it drops // this silently like the other semantic families. | InstanceMessage::ThemeFacts { .. } + // Themes stage 2 Q#F4 — FontFacts is the GPU font + // preference; terminal fonts belong to the terminal, so + // the cell-grid TUI drops this silently too. + | InstanceMessage::FontFacts { .. } | InstanceMessage::ResourceOffer { .. } // T M11.6 — DispatchIdle is consumed by `attach.rs`'s // optimistic-apply gate; if any reaches this render path @@ -791,6 +795,28 @@ mod tests { .expect("the grid frontend must drop ThemeFacts silently"); } + #[test] + fn font_facts_drops_silently_on_the_grid_frontend() { + // Themes stage 2 Q#F4 / acceptance 8: terminal fonts belong + // to the terminal, so a `FontFacts` reaching the cell-grid + // TUI — which never negotiates it — must fall into the + // semantic-family silent drop, not error. + let mut fe = Frontend { + out: BufWriter::new(io::stdout()), + size: CellSize::new(24, 80), + raw_mode: false, + alt_screen: false, + bracketed_paste: false, + mouse: false, + keyboard_enhancement: false, + }; + fe.apply_message(&InstanceMessage::FontFacts { + family: Some("Iosevka".into()), + size_centi_px: Some(1800), + }) + .expect("the grid frontend must drop FontFacts silently"); + } + #[test] fn emit_span_writes_cursor_move_then_chars() { let span = DiffSpan { diff --git a/src/lib.rs b/src/lib.rs index 7ee5f29..019dede 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -73,6 +73,7 @@ pub mod document_highlight; pub mod editor; pub mod editor_core; pub mod file_io; +pub mod font_pref; pub mod formatting; pub mod frontend; pub mod fs; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index ae82045..5afe4da 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -7562,6 +7562,121 @@ pub fn install_process(lua: &Lua, supervisor: &SharedProcessSupervisor) -> mlua: /// Build a fresh [`ProcessSupervisor`] and install /// `pmacs.process.*` over it. Mirrors [`make_async_runtime`] / /// [`make_syntax_registry`] in shape. +/// `pmacs.gpu.*` — GPU frontend preferences (Arc 4 stage 2, framing +/// Q#F2). Installs the module and returns the shared preference +/// handle the `semantic_render` producer reads. Called from +/// `EditorState::new` BEFORE `load_user_config` runs: font selection +/// is primarily configuration, and an init.lua `set_font` must land +/// in the same state the first attachment's producer reads. +/// +/// `set_font` follows the live `pmacs.theme.set` pattern — no +/// `require_init_phase` gate; mid-session calls re-ship on the next +/// frame. The kwargs table is STRICT PLAIN DATA: `raw_get` reads, +/// unknown raw keys are rejected by name, and metatables are never +/// consulted (`for_each` iterates raw pairs) — a hostile `__index` +/// cannot inject values, and the whole table is parsed, validated, +/// and quantized before the lock is taken (all-or-nothing, Q#TH6). +pub fn make_font_pref(lua: &Lua) -> mlua::Result { + let handle = crate::font_pref::new_handle(); + let gpu = lua.create_table()?; + { + let h = handle.clone(); + gpu.set( + "set_font", + lua.create_function(move |_, spec: Table| -> mlua::Result<()> { + // Reject unknown keys first, naming the offender — + // raw iteration, so metatable trickery is invisible. + let mut unknown: Option = None; + spec.clone().for_each(|k: Value, _: Value| { + let name = match &k { + Value::String(s) => s.to_str()?.to_owned(), + other => format!("{other:?}"), + }; + if name != "family" && name != "size" && unknown.is_none() { + unknown = Some(name); + } + Ok(()) + })?; + if let Some(key) = unknown { + return Err(mlua::Error::external(format!( + "pmacs.gpu.set_font: unknown field `{key}` (expected `family` and/or `size`)" + ))); + } + // Parse + validate the complete table BEFORE locking. + let family = match spec.raw_get::("family")? { + Value::Nil => None, + Value::String(s) => { + let f = s.to_str()?.to_owned(); + if f.is_empty() { + return Err(mlua::Error::external( + "pmacs.gpu.set_font: `family` must be a non-empty string", + )); + } + Some(f) + } + other => { + return Err(mlua::Error::external(format!( + "pmacs.gpu.set_font: `family` must be a string, got {}", + other.type_name() + ))); + } + }; + let size_centi_px = match spec.raw_get::("size")? { + Value::Nil => None, + Value::Integer(i) => Some(validate_font_size(i as f64)?), + Value::Number(n) => Some(validate_font_size(n)?), + other => { + return Err(mlua::Error::external(format!( + "pmacs.gpu.set_font: `size` must be a number, got {}", + other.type_name() + ))); + } + }; + let mut pref = h.lock().expect("font pref mutex poisoned"); + pref.family = family; + pref.size_centi_px = size_centi_px; + pref.epoch += 1; + Ok(()) + })?, + )?; + } + { + let h = handle.clone(); + gpu.set( + "font", + lua.create_function(move |lua, ()| -> mlua::Result { + // A FRESH plain table each call — a getter, never the + // stored table or a mutable handle (Q#F2). + let t = lua.create_table()?; + let pref = h.lock().expect("font pref mutex poisoned"); + if let Some(f) = &pref.family { + t.set("family", f.clone())?; + } + if let Some(c) = pref.size_centi_px { + t.set("size", f64::from(c) / 100.0)?; + } + Ok(t) + })?, + )?; + } + let pmacs: Table = lua.globals().get("pmacs")?; + pmacs.set("gpu", gpu)?; + Ok(handle) +} + +/// Range-check the ORIGINAL value first — `5.999` must error, not +/// round into range — then quantize to the nearest hundredth of a +/// logical pixel (framing Q#F2, round 2 finding 5). +fn validate_font_size(size: f64) -> mlua::Result { + if !size.is_finite() || !(6.0..=72.0).contains(&size) { + return Err(mlua::Error::external(format!( + "pmacs.gpu.set_font: `size` must be a finite number in [6.0, 72.0] logical px, got {size}" + ))); + } + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + Ok((size * 100.0).round() as u32) +} + pub fn make_process_supervisor(lua: &Lua) -> mlua::Result { let supervisor = Rc::new(RefCell::new(ProcessSupervisor::new())); install_process(lua, &supervisor)?; diff --git a/src/protocol.rs b/src/protocol.rs index 0805b3f..1a4e0f5 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_sixteen_for_theme_facts() { + fn protocol_version_is_seventeen_for_font_facts() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1708,8 +1708,11 @@ mod tests { // Arc 1a Q#C5 bumped 14→15 (`InstanceMessage::CompletionPopup`, // additive + daemon-gated). Themes Q#TH7 bumped 15→16 // (`InstanceMessage::ThemeFacts`, additive + daemon-gated, - // appended as the final variant — see the placement pin). - assert_eq!(PROTOCOL_VERSION, 16); + // appended as the final v16 variant — see the placement pin). + // Themes stage 2 Q#F4 bumped 16→17 (`InstanceMessage:: + // FontFacts`, additive + daemon-gated, appended as the final + // variant — see the ThemeFacts placement pin). + assert_eq!(PROTOCOL_VERSION, 17); } #[test] @@ -1783,18 +1786,18 @@ mod tests { // (`TripleDown`), v8 (`StatusFacts`), v9 + v10 (`SearchPrompt` + // regex/invalid), v11 (the context menu), v12 (the GUI // minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15 - // (`CompletionPopup`), v16 (`ThemeFacts`) all interoperate, so - // v6 through v16 talk. - for accepted in 6..=16 { + // (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`) + // all interoperate, so v6 through v17 talk. + for accepted in 6..=17 { assert!( is_supported_protocol_version(accepted), "v{accepted} must be accepted" ); } - for rejected in [0, 1, 2, 3, 4, 5, 17, u32::MAX] { + for rejected in [0, 1, 2, 3, 4, 5, 18, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v16 binary" + "v{rejected} must be rejected by a v17 binary" ); } } @@ -1831,6 +1834,47 @@ mod tests { } } + #[test] + fn font_facts_round_trips_through_postcard() { + // Themes stage 2 Q#F4 (v17): the global GPU font preference. + // Pin the all-default (authoritative-unset) and populated + // shapes. + for msg in [ + InstanceMessage::FontFacts { + family: None, + size_centi_px: None, + }, + InstanceMessage::FontFacts { + family: Some("Iosevka".into()), + size_centi_px: Some(1850), + }, + ] { + let bytes = postcard::to_allocvec(&msg).expect("encode"); + let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode"); + assert_eq!(msg, decoded); + } + } + + #[test] + fn theme_facts_encoding_is_unchanged_by_the_v17_build() { + // Q#F4 placement pin: `FontFacts` must be APPENDED after + // `ThemeFacts` — the final v16 variant, whose ordinal moves + // if anything is inserted before any v16 variant. These are + // the exact bytes a v16 binary produced for this value + // (discriminant 23 as a postcard varint, then the empty + // face vector); the new variant's own round-trip cannot + // detect a shift. + let msg = InstanceMessage::ThemeFacts { faces: Vec::new() }; + let bytes = postcard::to_allocvec(&msg).expect("encode"); + assert_eq!( + bytes, + [23, 0], + "ThemeFacts' v16 wire bytes changed — a variant was \ + inserted before it; append new InstanceMessage variants \ + at the end" + ); + } + #[test] fn completion_popup_encoding_is_unchanged_by_the_v16_build() { // Themes Q#TH7 placement pin: postcard discriminants are diff --git a/src/semantic_render.rs b/src/semantic_render.rs index da8dd5c..b4993d4 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -236,6 +236,21 @@ pub struct SemanticRenderState { /// and counters stay unthemed, so this producer resolves faces /// only when the peer can apply the whole face table. peer_knows_theme_facts: bool, + /// The font-pref `epoch` this producer last INSPECTED (Q#F5) — + /// `Option`, not a bare zero, or an all-default daemon's `0 == 0` + /// short-circuit would starve the first authoritative send. + /// Advances on computation, not emission. + last_font_epoch: Option, + /// The preference the frontend believes (Q#F5), seeded `None` so + /// every attachment receives exactly one authoritative + /// `FontFacts` — the all-default `(None, None)` included. + /// Bufferless: `on_buffer_snapshot_sent` never touches it. + last_font_facts: Option<(Option, Option)>, + /// Whether the peer negotiated protocol >= 17 (Q#F4). Unlike the + /// theme case there is no pre-v17 side channel that could leak + /// font state, so this gate has no summary-style companion + /// filter. + peer_knows_font_facts: bool, /// Cached byte↔line table for the diagnostics projection, keyed /// by buffer revision. Building it costs an O(buffer) rope copy /// plus a full scan; before this cache, that ran on *every tick* @@ -336,6 +351,7 @@ impl SemanticRenderState { pub fn for_peer(frontend_id: FrontendId, negotiated_protocol_version: u32) -> Self { let mut s = Self::new(frontend_id); s.peer_knows_theme_facts = negotiated_protocol_version >= 16; + s.peer_knows_font_facts = negotiated_protocol_version >= 17; s } @@ -370,6 +386,13 @@ impl SemanticRenderState { last_face_epoch: None, last_theme_faces: None, peer_knows_theme_facts: true, + // Q#F5: both seeded None — the first frame after viewport + // declaration always ships an authoritative FontFacts + // (the all-default preference included), and the epoch + // gate cannot short-circuit an epoch-0 daemon before it. + last_font_epoch: None, + last_font_facts: None, + peer_knows_font_facts: true, diag_line_cache: HashMap::new(), } } @@ -621,6 +644,7 @@ impl SemanticRenderState { out.extend(self.completion_popup_msg(state, vp.buffer_id)); // --- ThemeFacts (UI faces; themes arc Q#TH7, protocol v16) --- out.extend(self.theme_facts_msg(state)); + out.extend(self.font_facts_msg(state)); out } @@ -1153,6 +1177,41 @@ impl SemanticRenderState { Some(InstanceMessage::ThemeFacts { faces }) } + /// The `FontFacts` message for this frame, or `None` when the + /// preference is unchanged (Arc 4 stage 2, Q#F5, protocol v17). + /// The `theme_facts_msg` discipline exactly: an `Option`-seeded + /// epoch gate keeps unchanged ticks to one `u64` compare, the + /// `Option`-seeded payload baseline decides emission, both + /// advance on computation, and every attachment ships exactly + /// one authoritative preference — the all-default `(None, None)` + /// included — on its first frame after viewport declaration. + /// Bufferless: `on_buffer_snapshot_sent` never touches these + /// baselines. + fn font_facts_msg(&mut self, state: &EditorState) -> Option { + // Never produced for a peer below v17 (the daemon write-loop + // gate remains as the belt-and-braces filter). + if !self.peer_knows_font_facts { + return None; + } + let (facts, epoch) = { + let pref = state.font_pref.lock().expect("font pref mutex poisoned"); + if self.last_font_epoch == Some(pref.epoch) { + return None; + } + ((pref.family.clone(), pref.size_centi_px), pref.epoch) + }; + self.last_font_epoch = Some(epoch); + let unchanged = self.last_font_facts.as_ref() == Some(&facts); + self.last_font_facts = Some(facts.clone()); + if unchanged { + return None; + } + Some(InstanceMessage::FontFacts { + family: facts.0, + size_centi_px: facts.1, + }) + } + /// Project the [`Decoration`] set intersecting the declared /// viewport: the session's selection (instance-authoritative, /// byte-native) and LSP diagnostics (line/col → byte, severity → @@ -2407,6 +2466,105 @@ mod tests { ); } + /// Pull the `FontFacts` payload out of a frame, if any. + fn font_facts_of(msgs: &[InstanceMessage]) -> Option<(Option, Option)> { + msgs.iter().find_map(|m| match m { + InstanceMessage::FontFacts { + family, + size_centi_px, + } => Some((family.clone(), *size_centi_px)), + _ => None, + }) + } + + /// Simulate a committed `pmacs.gpu.set_font`: what the Lua setter + /// does after its parse/validate/quantize (write + epoch bump). + fn set_font(state: &EditorState, family: Option<&str>, size_centi_px: Option) { + let mut pref = state.font_pref.lock().expect("font pref"); + pref.family = family.map(str::to_owned); + pref.size_centi_px = size_centi_px; + pref.epoch += 1; + } + + #[test] + fn font_facts_authoritative_default_then_silent_then_set_emits() { + // Q#F5 / acceptance 2-3: the first frame ships the + // authoritative all-default preference — the Option epoch + // gate must not short-circuit at 0 == 0 — then unchanged + // ticks say nothing; a set_font re-ships; an identical + // re-set advances the inspected epoch without emitting. + let state = empty_state(); + let mut s = local(); + let buffer_id = active_buffer(&state); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + + let first = s.render_frame(&state); + assert_eq!( + font_facts_of(&first), + Some((None, None)), + "an all-default daemon still ships one authoritative preference" + ); + assert_eq!( + font_facts_of(&s.render_frame(&state)), + None, + "unchanged ticks emit nothing" + ); + + set_font(&state, Some("Iosevka"), Some(1800)); + assert_eq!( + font_facts_of(&s.render_frame(&state)), + Some((Some("Iosevka".into()), Some(1800))), + "a live set_font re-ships on the next frame" + ); + assert_eq!( + font_facts_of(&s.render_frame(&state)), + None, + "and suppresses again once shipped" + ); + + // Identical re-set: epoch bumps, payload unchanged — nothing + // emits, but the inspected epoch advances (cache advances on + // computation, or every later tick would rebuild). + set_font(&state, Some("Iosevka"), Some(1800)); + let bumped = state.font_pref.lock().expect("font pref").epoch; + assert_eq!( + font_facts_of(&s.render_frame(&state)), + None, + "identical re-set is suppressed" + ); + assert_eq!( + s.last_font_epoch, + Some(bumped), + "the inspected epoch advanced despite the suppressed send" + ); + } + + #[test] + fn font_facts_never_produced_for_a_v16_peer() { + // Q#F4 / acceptance 5 (producer half; the daemon skip arm is + // the belt-and-braces filter). + let state = empty_state(); + set_font(&state, None, Some(2000)); + let buffer_id = active_buffer(&state); + let mut v16 = SemanticRenderState::for_peer(FrontendId::LOCAL, 16); + v16.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + let frame = v16.render_frame(&state); + assert_eq!(font_facts_of(&frame), None, "v16 peers get no FontFacts"); + assert!( + frame + .iter() + .any(|m| matches!(m, InstanceMessage::ThemeFacts { .. })), + "the same peer still receives v16 facts" + ); + let mut v17 = SemanticRenderState::for_peer(FrontendId::LOCAL, 17); + v17.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + assert_eq!( + font_facts_of(&v17.render_frame(&state)), + Some((None, Some(2000))), + "a v17 peer receives the current preference" + ); + } + #[test] fn snapshot_reset_drops_one_buffers_baselines_and_keeps_the_rest() { // PR #120 round 2 finding 1 — the reset contract's scope: a @@ -2452,6 +2610,11 @@ mod tests { s.last_theme_faces, facts_baseline, "ThemeFacts is bufferless — the face table survives snapshots" ); + assert_eq!( + s.last_font_facts, + Some((None, None)), + "FontFacts is bufferless too — the preference baseline survives" + ); // And the behavioral consequence: revisiting A at the SAME // generation re-ships the summary the frontend just dropped. @@ -2500,6 +2663,7 @@ mod tests { | InstanceMessage::SearchPrompt { .. } | InstanceMessage::LineNumbers { .. } | InstanceMessage::ThemeFacts { .. } + | InstanceMessage::FontFacts { .. } ), "semantic projection emitted an unexpected variant: {m:?}" ); @@ -2669,14 +2833,15 @@ mod tests { // (the frontend clears its viewport), carrying empty segments. // FileStyleSummary also emits on the first frame for this buffer // (post-M11 minimap producer, generation-keyed), as does - // StatusFacts (Q#S1, cached-compare) and the authoritative - // ThemeFacts table (Q#TH7 — empty for an unthemed daemon). + // StatusFacts (Q#S1, cached-compare), the authoritative + // ThemeFacts table (Q#TH7 — empty for an unthemed daemon), and + // the authoritative FontFacts preference (Q#F5 — all-default). let first = s.render_frame(&state); assert_eq!( first.len(), - 5, + 6, "first frame ships StyleSpans + Decorations + FileStyleSummary \ - + StatusFacts + ThemeFacts" + + StatusFacts + ThemeFacts + FontFacts" ); assert_semantic_only(&first); let (style_full, _) = style_segments(&first).expect("StyleSpans present");