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