Merge pull request #120 from levineuwirth/theme-faces

feat(themes): named UI faces + ThemeFacts channel (protocol v16) — Arc 4 stage 1
This commit is contained in:
Levi Neuwirth 2026-07-15 10:30:31 +00:00 committed by GitHub
commit 8ce2e9cb0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 4907 additions and 208 deletions

View File

@ -116,6 +116,26 @@ layout-local frontend consumes). Peer cursors reuse the existing
is: one capability bit, ~five instance→frontend interpretation
variants, and one frontend→instance `Viewport` variant.
**`BufferSnapshot` resets buffer-scoped interpretation state.** A
frontend receiving a snapshot drops everything it holds for the
named buffer — spans, decorations, adornments, minimap summary,
completion popup, search and menu prompts (which also gate the
frontend's key/pointer interception), and status facts — and
rebuilds from the frames that follow; the instance mirrors this by
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
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
whose first frame lands during staleness (a late joiner attaching
mid-edit). The store maintains those per-URI severity totals with
the retained vector, so frame-time producers read them in O(1)
rather than rescanning diagnostics for every attached session.
## Capability and version mechanics
Identical pattern to `crdt_replica`:
@ -205,6 +225,26 @@ ResourceOffer {
mime: String,
body: ResourceBody, // Inline(Vec<u8>) | Uri(String)
},
/// Themes arc Q#TH7 (protocol v16): the daemon-resolved UI face
/// table. Bufferless — the theme is one global instance. Complete
/// replacement each send: a face absent from `faces` is unset and
/// the frontend uses its own default for that surface. Every
/// attachment receives exactly one authoritative table (the empty
/// table included) with its first emission after viewport
/// declaration; cached-compare suppressed thereafter, so an
/// unthemed session pays one small message and nothing more.
/// Resolution (the `ui.*` dotted-prefix inheritance walk) happens
/// daemon-side over the stage-1 face inventory — frontends do
/// exact-name lookup only, and apply each face within its
/// stage-1 component mask (docs/theme-faces-framing.md Q#TH3/Q#TH5:
/// a set face owns its surface; `Default` components mean the
/// frontend's plain rendering; out-of-mask components are never
/// read). Daemon-gated `>= 16`; appended as the FINAL variant —
/// postcard discriminants are ordinal.
ThemeFacts {
faces: Vec<ThemeFace>, // { name: String, style: Style }, sorted by name
},
```
Each family member diffs against the previous frame the same way

1038
docs/theme-faces-framing.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -52,7 +52,7 @@ pub use message::{
InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent,
LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind,
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, is_builtin_pair_char,
is_supported_protocol_version, negotiate_capabilities,
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, ThemeFace,
is_builtin_pair_char, is_supported_protocol_version, negotiate_capabilities,
};
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};

View File

@ -974,6 +974,40 @@ pub enum InstanceMessage {
/// Total candidate count (the window is a slice of this).
total: u32,
},
/// Themes arc (Q#TH7, protocol v16). The daemon-resolved UI faces.
/// The theme is one global instance, so this is bufferless (the
/// [`Self::MinibufferPrompt`] shape). Complete replacement each
/// send: a face absent from `faces` is unset, and the frontend
/// uses its own default for that surface. Every attachment
/// receives exactly one authoritative table — the empty table
/// included — with its first emission after viewport declaration;
/// cached-compare suppressed thereafter. Daemon-gated `>= 16`.
///
/// Appended as the FINAL 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.
ThemeFacts {
/// Every stage-1 face that resolves to a style (the Q#TH4
/// dotted-prefix walk, resolved daemon-side — frontends do
/// exact-name lookup, no walk), full names, sorted by name
/// for deterministic comparison.
faces: Vec<ThemeFace>,
},
}
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
/// face name (e.g. `"ui.modeline"`) and the style the daemon resolved
/// for it. The face's component *mask* (which components a frontend
/// may read) is a stage-1 contract documented per face in the themes
/// framing; out-of-mask components are never read by either frontend.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ThemeFace {
/// Full face name (`ui` or `ui.`-prefixed).
pub name: String,
/// The daemon-resolved style for this face.
pub style: crate::cell::Style,
}
/// Line-number gutter mode for a window (UX gutter arc). Shared across the
@ -1330,7 +1364,16 @@ pub enum ResourceBody {
/// (encoding change to that variant; its gate moved `>= 8` → `>= 15`,
/// so a v14 peer's status band goes dark rather than mis-decoding —
/// the v10 `SearchPrompt` / v14 `LineNumbers` shape).
pub const PROTOCOL_VERSION: u32 = 15;
///
/// Theme faces (Q#TH7): bumped 15 → 16 for
/// [`InstanceMessage::ThemeFacts`] — a new additive variant carrying
/// the daemon-resolved UI face table. Daemon-gated `< 16`; a v15 peer
/// negotiates v15 and simply receives no `ThemeFacts` (its chrome
/// stays on the frontend defaults), like every prior additive bump.
/// 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;
/// 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
@ -1390,7 +1433,10 @@ pub const PROTOCOL_VERSION: u32 = 15;
///
/// Q#C5: extended to `[6, ..., 15]`. `InstanceMessage::CompletionPopup`
/// is additive and daemon-gated per session.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
///
/// 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];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -984,6 +984,7 @@ fn dispatcher_loop(
upgraded,
&session_registry,
&mut streams,
&mut semantic_states,
);
// The broadcast just delivered this buffer to this
// frontend too; record it so the follow check below
@ -1022,6 +1023,14 @@ fn dispatcher_loop(
&& last_active_buffer_sent.get(fid) != Some(&active_now)
{
send_buffer_snapshot_to_frontend(editor, active_now, *fid, &mut streams);
// PR #120 round 2 — the snapshot just wiped
// this frontend's buffer-scoped render state;
// the emission baselines must die with it or
// an unchanged-generation revisit (A → B → A)
// suppresses every re-send.
if let Some(sem) = semantic_states.get_mut(fid) {
sem.on_buffer_snapshot_sent(active_now);
}
last_active_buffer_sent.insert(*fid, active_now);
}
}
@ -1125,6 +1134,11 @@ fn dispatcher_loop(
let peer_knows_completion_popup = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 15);
// Themes Q#TH7 — ThemeFacts gated at v16; a v15 peer's
// chrome simply stays on its frontend defaults.
let peer_knows_theme_facts = session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 16);
for msg in &messages {
if !peer_knows_status_facts
&& matches!(msg, InstanceMessage::StatusFacts { .. })
@ -1160,6 +1174,10 @@ fn dispatcher_loop(
{
continue;
}
if !peer_knows_theme_facts && matches!(msg, InstanceMessage::ThemeFacts { .. })
{
continue;
}
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
// jitter site: render-write latency.
//
@ -1366,6 +1384,9 @@ fn handle_session_established(
// fires: the semantic frontend holds the rope locally and the
// semantic frame ships no text.
let semantic_render = session_state.negotiated_capabilities.semantic_render;
// Captured before `register_session` consumes the state: the
// semantic producer needs the peer's version (finding 3 below).
let negotiated_protocol_version = session_state.negotiated_protocol_version;
if crdt_replica {
send_buffer_snapshots(editor, &mut write_stream);
}
@ -1377,7 +1398,14 @@ fn handle_session_established(
if semantic_render {
semantic_states.insert(
frontend_id,
crate::semantic_render::SemanticRenderState::new(frontend_id),
// for_peer, not new (PR #120 round 1 finding 3): a v15
// peer's producer must not resolve faces into the
// FileStyleSummary marks — that channel predates the v16
// gate.
crate::semantic_render::SemanticRenderState::for_peer(
frontend_id,
negotiated_protocol_version,
),
);
} else {
let mut render_state = RenderState::new(initial_size);
@ -1844,6 +1872,7 @@ fn broadcast_buffer_snapshot_to_replicas(
buffer_id: crate::buffer::BufferId,
session_registry: &SessionRegistry,
streams: &mut HashMap<FrontendId, UnixStream>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
) {
let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else {
return;
@ -1862,6 +1891,12 @@ fn broadcast_buffer_snapshot_to_replicas(
if let Err(e) = write_message(stream, &msg) {
eprintln!("pmacs: F29 send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}");
}
// PR #120 round 2 — same reset contract as the follow path:
// the snapshot wiped this replica's buffer-scoped render
// state, so its emission baselines for the buffer die too.
if let Some(sem) = semantic_states.get_mut(fid) {
sem.on_buffer_snapshot_sent(buffer_id);
}
}
}

View File

@ -196,6 +196,10 @@ impl Diagnostic {
#[derive(Default)]
pub struct DiagnosticStore {
by_uri: HashMap<String, Vec<Diagnostic>>,
/// Per-URI severity totals, maintained alongside `by_uri` so
/// frame-time consumers do not rescan every diagnostic at render
/// cadence. Entries exist exactly when `by_uri` entries do.
severity_counts: HashMap<String, (u32, u32, u32, u32)>,
/// URIs whose stored diagnostics are known to be out of date
/// because a `textDocument/didChange` was issued after the last
/// `publishDiagnostics` was absorbed. `Self::set` clears entries
@ -210,6 +214,20 @@ pub struct DiagnosticStore {
epochs: HashMap<String, u64>,
}
fn count_severities(diags: &[Diagnostic]) -> (u32, u32, u32, u32) {
let mut counts = (0u32, 0u32, 0u32, 0u32);
for diagnostic in diags {
let slot = match diagnostic.severity {
DiagnosticSeverity::Error => &mut counts.0,
DiagnosticSeverity::Warning => &mut counts.1,
DiagnosticSeverity::Information => &mut counts.2,
DiagnosticSeverity::Hint => &mut counts.3,
};
*slot = slot.saturating_add(1);
}
counts
}
impl DiagnosticStore {
/// Empty store.
#[must_use]
@ -227,11 +245,14 @@ impl DiagnosticStore {
pub fn set(&mut self, uri: impl Into<String>, mut diags: Vec<Diagnostic>) {
diags.sort_by(Diagnostic::compare_by_position);
let uri = uri.into();
let counts = count_severities(&diags);
self.stale_uris.remove(&uri);
*self.epochs.entry(uri.clone()).or_insert(0) += 1;
if diags.is_empty() {
self.by_uri.remove(&uri);
self.severity_counts.remove(&uri);
} else {
self.severity_counts.insert(uri.clone(), counts);
self.by_uri.insert(uri, diags);
}
}
@ -240,6 +261,7 @@ impl DiagnosticStore {
/// no entry to be stale about.
pub fn clear(&mut self, uri: &str) {
self.by_uri.remove(uri);
self.severity_counts.remove(uri);
self.stale_uris.remove(uri);
*self.epochs.entry(uri.to_owned()).or_insert(0) += 1;
}
@ -285,19 +307,26 @@ impl DiagnosticStore {
let mut w = 0;
let mut i = 0;
let mut h = 0;
for diags in self.by_uri.values() {
for d in diags {
match d.severity {
DiagnosticSeverity::Error => e += 1,
DiagnosticSeverity::Warning => w += 1,
DiagnosticSeverity::Information => i += 1,
DiagnosticSeverity::Hint => h += 1,
}
}
for &(errors, warnings, information, hints) in self.severity_counts.values() {
e += errors as usize;
w += warnings as usize;
i += information as usize;
h += hints as usize;
}
(e, w, i, h)
}
/// Per-URI totals `(error, warning, information, hint)`.
///
/// The tuple is computed once by [`Self::set`] and deliberately
/// survives [`Self::mark_stale`]: staleness invalidates byte
/// positions, while the last published counts remain valid as a
/// frozen status summary until the next publication.
#[must_use]
pub fn severity_counts_for(&self, uri: &str) -> (u32, u32, u32, u32) {
self.severity_counts.get(uri).copied().unwrap_or_default()
}
/// Per-URI count.
#[must_use]
pub fn count_for(&self, uri: &str) -> usize {
@ -363,63 +392,61 @@ pub fn make_shared_store() -> SharedDiagStore {
/// [`crate::text_view`] and [`crate::highlight`].
const TAB_WIDTH: u32 = 8;
/// Style applied to bytes covered by an `Error` diagnostic. Wavy
/// underline colored via `underline_color` (not `fg`) so the
/// squiggle reads red while the syntax view's text color survives
/// underneath (T M4.6, protocol v6).
fn error_style() -> Style {
Style {
underline: UnderlineStyle::Curly,
underline_color: DiagnosticSeverity::Error.underline_color(),
..Style::default()
/// The RESOLVED severity color (themes arc Q#TH5): the `ui.diag.*`
/// face's `fg` when a face is set with a concrete color, else the
/// built-in [`DiagnosticSeverity::underline_color`]. The diag family
/// carries a special `Default` policy — `Default` fg means the
/// built-in severity color, never "plain" — because the color doubles
/// as the *presence* encoding in the minimap summary
/// (`FileStyleSummary.underline_color`, where `Default` reads as "no
/// mark"), so a plain severity color is unrepresentable and
/// `ui.diag.error = {}` degrades to the built-in on every surface.
#[must_use]
pub fn severity_color(
theme: Option<&crate::highlight::Theme>,
severity: DiagnosticSeverity,
) -> Color {
let name = match severity {
DiagnosticSeverity::Error => "ui.diag.error",
DiagnosticSeverity::Warning => "ui.diag.warning",
DiagnosticSeverity::Information => "ui.diag.info",
DiagnosticSeverity::Hint => "ui.diag.hint",
};
match theme.and_then(|t| t.face(name)) {
Some(f) if f.fg != Color::Default => f.fg,
_ => severity.underline_color(),
}
}
/// Style applied to bytes covered by a `Warning` diagnostic.
fn warning_style() -> Style {
/// Style applied to bytes covered by a diagnostic: severity-shaped
/// underline (wavy for error/warning, single for info, dotted for
/// hint) colored via `underline_color` (not `fg`) so the squiggle
/// reads its severity color while the syntax view's text color
/// survives underneath (T M4.6, protocol v6). `color` is the
/// resolved severity color ([`severity_color`]).
fn style_for(severity: DiagnosticSeverity, color: Color) -> Style {
let underline = match severity {
DiagnosticSeverity::Error | DiagnosticSeverity::Warning => UnderlineStyle::Curly,
DiagnosticSeverity::Information => UnderlineStyle::Single,
DiagnosticSeverity::Hint => UnderlineStyle::Dotted,
};
Style {
underline: UnderlineStyle::Curly,
underline_color: DiagnosticSeverity::Warning.underline_color(),
underline,
underline_color: color,
..Style::default()
}
}
/// Style applied to bytes covered by an `Information` diagnostic.
fn info_style() -> Style {
Style {
underline: UnderlineStyle::Single,
underline_color: DiagnosticSeverity::Information.underline_color(),
..Style::default()
}
}
/// Style applied to bytes covered by a `Hint` diagnostic.
fn hint_style() -> Style {
Style {
underline: UnderlineStyle::Dotted,
underline_color: DiagnosticSeverity::Hint.underline_color(),
..Style::default()
}
}
fn style_for(severity: DiagnosticSeverity) -> Style {
match severity {
DiagnosticSeverity::Error => error_style(),
DiagnosticSeverity::Warning => warning_style(),
DiagnosticSeverity::Information => info_style(),
DiagnosticSeverity::Hint => hint_style(),
}
}
/// Style of the column-0 line marker — the TUI's gutter sign
/// (T M4.6). The TUI reserves no gutter column, so the sign is a
/// severity-colored *background* on the line's first cell: the
/// glyph and its syntax color survive (the view contract is
/// style-only), and zero-width diagnostics — invisible to the
/// underline pass — still get a visible artifact.
fn marker_style_for(severity: DiagnosticSeverity) -> Style {
/// underline pass — still get a visible artifact. `color` is the
/// resolved severity color ([`severity_color`]).
fn marker_style_for(color: Color) -> Style {
Style {
bg: severity.underline_color(),
bg: color,
..Style::default()
}
}
@ -436,15 +463,25 @@ pub struct DiagnosticView {
/// Shared store; mutated by the LSP manager, read by this view
/// on every render.
store: SharedDiagStore,
/// Shared theme for the `ui.diag.*` face resolution (themes arc
/// Q#TH9; the `SyntaxHighlightView` precedent). `None` — a bare
/// test construction — paints the built-in severity colors.
theme: Option<crate::highlight::ThemeHandle>,
}
impl DiagnosticView {
/// Construct a diagnostic view for `uri` against `store`.
/// Construct a diagnostic view for `uri` against `store`,
/// resolving severity colors through `theme` when given.
#[must_use]
pub fn new(uri: impl Into<String>, store: SharedDiagStore) -> Self {
pub fn new(
uri: impl Into<String>,
store: SharedDiagStore,
theme: Option<crate::highlight::ThemeHandle>,
) -> Self {
Self {
uri: uri.into(),
store,
theme,
}
}
@ -502,8 +539,15 @@ impl View for DiagnosticView {
let mut line_markers: std::collections::HashMap<u32, DiagnosticSeverity> =
std::collections::HashMap::new();
// One theme clone per render (themes arc Q#TH9, the
// SyntaxHighlightView discipline) for the ui.diag.* faces.
let theme = self
.theme
.as_ref()
.map(|t| t.lock().expect("theme mutex poisoned").clone());
for diag in &diags {
let style = style_for(diag.severity);
let style = style_for(diag.severity, severity_color(theme.as_ref(), diag.severity));
// Apply to each line the diagnostic touches. LSP ranges
// are half-open at the end position; if end_col == 0
// the diagnostic stops at the start of `end_line` so
@ -580,6 +624,7 @@ impl View for DiagnosticView {
viewport.gutter_w,
max_cols,
&line_markers,
theme.as_ref(),
);
}
}
@ -597,9 +642,11 @@ fn paint_line_markers(
gutter_w: u32,
max_cols: u32,
line_markers: &std::collections::HashMap<u32, DiagnosticSeverity>,
theme: Option<&crate::highlight::Theme>,
) {
for (&row_offset, &severity) in line_markers {
let row = cell_origin.row + row_offset;
let color = severity_color(theme, severity);
if gutter_w > 0 {
let cell = cells.at(CellCoord::new(
row,
@ -607,12 +654,12 @@ fn paint_line_markers(
));
cell.glyph = Glyph::Char(severity.gutter_glyph());
cell.style = Style {
fg: severity.underline_color(),
fg: color,
..Style::default()
};
} else if max_cols > 0 {
let cell = cells.at(CellCoord::new(row, cell_origin.col));
cell.style = merge_styles(cell.style, marker_style_for(severity));
cell.style = merge_styles(cell.style, marker_style_for(color));
}
}
}
@ -842,12 +889,47 @@ mod tests {
assert_eq!(totals, (2, 1, 1, 1));
}
#[test]
fn cached_severity_counts_replace_clear_and_survive_staleness() {
let mut store = DiagnosticStore::new();
assert_eq!(store.severity_counts_for("file:///a"), (0, 0, 0, 0));
store.set(
"file:///a",
vec![
diag(0, DiagnosticSeverity::Error, "1"),
diag(1, DiagnosticSeverity::Error, "2"),
diag(2, DiagnosticSeverity::Warning, "3"),
diag(3, DiagnosticSeverity::Information, "4"),
diag(4, DiagnosticSeverity::Hint, "5"),
],
);
assert_eq!(store.severity_counts_for("file:///a"), (2, 1, 1, 1));
store.mark_stale("file:///a");
assert_eq!(
store.severity_counts_for("file:///a"),
(2, 1, 1, 1),
"staleness freezes the last published counts"
);
store.set(
"file:///a",
vec![diag(0, DiagnosticSeverity::Warning, "replacement")],
);
assert_eq!(store.severity_counts_for("file:///a"), (0, 1, 0, 0));
store.clear("file:///a");
assert_eq!(store.severity_counts_for("file:///a"), (0, 0, 0, 0));
}
#[test]
fn empty_set_clears_uri() {
let mut s = DiagnosticStore::new();
s.set("a", vec![diag(0, DiagnosticSeverity::Error, "x")]);
s.set("a", Vec::new());
assert!(s.for_uri("a").is_empty());
assert_eq!(s.severity_counts_for("a"), (0, 0, 0, 0));
assert_eq!(s.uris().count(), 0);
}
@ -921,7 +1003,7 @@ mod tests {
DiagnosticSeverity::Information,
DiagnosticSeverity::Hint,
] {
let style = style_for(s);
let style = style_for(s, severity_color(None, s));
assert_eq!(style.fg, Color::Default, "{s:?} must not set fg");
assert_ne!(
style.underline_color,
@ -959,7 +1041,7 @@ mod tests {
// `pmacs.window._overlay_kinds()` introspection (task #23 wire-up,
// mirroring "syntax-highlight" / LspStyleView) relies on this.
let store = make_shared_store();
let view = DiagnosticView::new("file:///a", store);
let view = DiagnosticView::new("file:///a", store, None);
assert_eq!(view.kind(), "diagnostic");
}
@ -984,7 +1066,7 @@ mod tests {
})
.expect("seed buffer");
let mut view = DiagnosticView::new("file:///a", store);
let mut view = DiagnosticView::new("file:///a", store, None);
let mut backing = vec![Cell::default(); 10];
let mut grid = CellGrid {
cells: &mut backing,
@ -1046,7 +1128,7 @@ mod tests {
})
.expect("seed buffer");
let mut view = DiagnosticView::new("file:///a", store);
let mut view = DiagnosticView::new("file:///a", store, None);
let mut backing = vec![Cell::default(); 30];
// Pre-paint glyphs at column 0 to pin the style-only contract.
backing[0].glyph = Glyph::Char('h');
@ -1123,7 +1205,7 @@ mod tests {
// A 2-cell gutter: text is shifted to column 2, signs land at
// window column 0 (`cell_origin.col - gutter_w`).
let mut view = DiagnosticView::new("file:///a", store);
let mut view = DiagnosticView::new("file:///a", store, None);
let mut backing = vec![Cell::default(); 30];
let mut grid = CellGrid {
cells: &mut backing,
@ -1191,7 +1273,7 @@ mod tests {
})
.expect("seed buffer");
let mut view = DiagnosticView::new("file:///a", store);
let mut view = DiagnosticView::new("file:///a", store, None);
let mut backing = vec![Cell::default(); 20];
let mut grid = CellGrid {
cells: &mut backing,

View File

@ -206,6 +206,11 @@ impl EditorState {
lua_host.registry(),
)
.expect("install pmacs.parse");
// Themes Q#TH9: inject the shared theme into the core right
// after SyntaxRegistry construction — the core owns no syntax
// state, but its search overlay resolves wash faces through
// this handle.
core.borrow_mut().theme = Some(syntax_registry.theme());
lua_host
.eval(
Some("@pmacs/builtin/runtime/syntax.lua"),
@ -2093,6 +2098,14 @@ pub fn paint_frame(
}
let text_rows = term_size.rows - 1;
// Themes Q#TH9: one theme clone per frame for the chrome faces —
// the same single-lock discipline as `SyntaxHighlightView::render`.
let theme = {
let handle = state.syntax_registry.theme();
let t = handle.lock().expect("theme mutex poisoned");
t.clone()
};
let mut core_ref = state.core.borrow_mut();
let core: &mut EditorCore = &mut core_ref;
@ -2175,12 +2188,12 @@ pub fn paint_frame(
// overlay in attach order. See [`crate::view::View`].
window.text_view.render(buf, viewport, grid);
if gutter_w > 0 {
paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w);
paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w, &theme);
}
for overlay in &mut window.overlays {
overlay.render(buf, viewport, grid);
}
paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w);
paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w, &theme);
// Mode line for this window. Painted last so the line
// itself is always visible regardless of overlay activity.
let coord = window
@ -2212,21 +2225,29 @@ pub fn paint_frame(
coord.col,
&scroll,
&diags,
mode_line_style(&theme),
);
}
drop(reg);
paint_status_line(grid, core, &state.lua_host, &state.dispatcher, term_size);
paint_status_line(
grid,
core,
&state.lua_host,
&state.dispatcher,
term_size,
&theme,
);
// An active isearch owns the bottom row (its prompt + match
// readout), but the terminal cursor stays in the buffer at the
// active match so the eye follows the search — so paint the prompt
// and fall through to the buffer-cursor placement below.
let mb_cursor_col = if core.search_active() {
paint_search_prompt(grid, core, term_size);
paint_search_prompt(grid, core, term_size, &theme);
None
} else if core.minibuffer.is_active() {
Some(paint_minibuffer(grid, core, term_size))
Some(paint_minibuffer(grid, core, term_size, &theme))
} else {
None
};
@ -2257,33 +2278,59 @@ pub fn paint_frame(
Some(CellCoord::new(grid_row, grid_col))
}
/// The mode-line row style (themes arc Q#TH5): a set `ui.modeline`
/// face owns the surface within its {fg, bg, reverse} mask — the row
/// resets to plain plus the face's in-mask components — else today's
/// reverse video.
fn mode_line_style(theme: &crate::highlight::Theme) -> crate::cell::Style {
theme.face("ui.modeline").map_or(
crate::cell::Style {
reverse: true,
..Default::default()
},
|f| crate::cell::Style {
fg: f.fg,
bg: f.bg,
reverse: f.reverse,
..Default::default()
},
)
}
fn paint_status_line(
grid: &mut crate::cell::CellGrid<'_>,
core: &EditorCore,
lua_host: &LuaHost,
dispatcher: &KeyDispatcher,
term_size: crate::cell::CellSize,
theme: &crate::highlight::Theme,
) {
let status = build_status_line(core, lua_host, dispatcher, term_size.cols);
let row = term_size.rows - 1;
// Themes Q#TH5: a set `ui.statusline` face owns the row within its
// {fg} mask (surface resets to plain); unset keeps reverse video.
let style = theme.face("ui.statusline").map_or(
crate::cell::Style {
reverse: true,
..Default::default()
},
|f| crate::cell::Style {
fg: f.fg,
..Default::default()
},
);
for (col, ch) in status.chars().enumerate() {
if col >= term_size.cols as usize {
break;
}
let cell = grid.at(CellCoord::new(row, col as u32));
cell.glyph = crate::cell::Glyph::Char(ch);
cell.style = crate::cell::Style {
reverse: true,
..Default::default()
};
cell.style = style;
}
for col in (status.chars().count() as u32)..term_size.cols {
let cell = grid.at(CellCoord::new(row, col));
cell.glyph = crate::cell::Glyph::Char(' ');
cell.style = crate::cell::Style {
reverse: true,
..Default::default()
};
cell.style = style;
}
}
@ -2323,16 +2370,25 @@ fn paint_line_number_gutter(
rect: &crate::window::Rect,
inner_rows: u32,
gutter_w: u32,
theme: &crate::highlight::Theme,
) {
let line_count = window.text_view.line_count();
// Relative/Hybrid measure distance from the cursor's buffer line;
// Absolute ignores it. Computed once per frame (the gutter repaints on
// cursor motion, so this stays current).
let cursor_line = window.text_view.line_at_offset(window.cursor);
let style = crate::cell::Style {
fg: crate::cell::Color::Indexed(8),
..crate::cell::Style::default()
};
// Themes Q#TH5: a set `ui.gutter` face owns the strip within its
// {fg} mask; unset keeps the dim Indexed(8).
let style = theme.face("ui.gutter").map_or(
crate::cell::Style {
fg: crate::cell::Color::Indexed(8),
..crate::cell::Style::default()
},
|f| crate::cell::Style {
fg: f.fg,
..crate::cell::Style::default()
},
);
// The number's rightmost digit sits at `field - 1`; the last gutter
// cell (`gutter_w - 1`) is a trailing pad separating it from the code.
let field = gutter_w.saturating_sub(1);
@ -2382,10 +2438,25 @@ fn paint_local_selection(
// text-relative display column shifted right by this (Q#UX2). 0 when
// the gutter is off, so this is a no-op then.
gutter_w: u32,
theme: &crate::highlight::Theme,
) {
let Some((sel_start, sel_end)) = window.region() else {
return;
};
// Themes Q#TH5: the selection is a wash — a set `ui.selection`
// face replaces the default overlay wholesale within its {bg}
// mask (an all-default face disables the wash; out-of-mask
// fg/reverse are never read); unset keeps today's reverse video.
let overlay = theme.face("ui.selection").map_or(
crate::cell::Style {
reverse: true,
..crate::cell::Style::default()
},
|f| crate::cell::Style {
bg: f.bg,
..crate::cell::Style::default()
},
);
if inner_rows == 0 || rect.size.cols == 0 || sel_start >= sel_end {
return;
}
@ -2428,7 +2499,7 @@ fn paint_local_selection(
rect.origin.row + row_offset,
rect.origin.col + gutter_w + col,
));
cell.style.reverse = true;
cell.style = crate::overlay::merge_styles(cell.style, overlay);
}
}
}
@ -2481,6 +2552,10 @@ fn paint_mode_line(
cursor_col: u32,
scroll: &str,
diags: &str,
// The resolved row style ([`mode_line_style`]) — this fn is a
// pure formatter, so the `ui.modeline` face resolution stays with
// the caller (themes arc Q#TH9).
mode_style: crate::cell::Style,
) {
if rect.size.rows == 0 || rect.size.cols == 0 {
return;
@ -2495,11 +2570,7 @@ fn paint_mode_line(
format!(" {diags} L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
};
// Fill the row with reverse-video spaces.
let mode_style = crate::cell::Style {
reverse: true,
..Default::default()
};
// Fill the row with the mode-line style.
for c in 0..rect.size.cols {
let cell = grid.at(CellCoord::new(row, rect.origin.col + c));
cell.glyph = crate::cell::Glyph::Char(' ');
@ -2538,10 +2609,23 @@ fn paint_mode_line(
/// Paint the minibuffer line on the bottom row, replacing the status
/// line. Returns the screen column the terminal cursor should sit
/// at (so the user can see what they're typing).
/// The minibuffer base style (themes arc Q#TH5): a set `ui.minibuffer`
/// face owns the prompt/input/fill (and the search prompt row) within
/// its {fg} mask; unset keeps the terminal default.
fn minibuffer_style(theme: &crate::highlight::Theme) -> crate::cell::Style {
theme
.face("ui.minibuffer")
.map_or(crate::cell::Style::default(), |f| crate::cell::Style {
fg: f.fg,
..crate::cell::Style::default()
})
}
fn paint_minibuffer(
grid: &mut crate::cell::CellGrid<'_>,
core: &EditorCore,
term_size: crate::cell::CellSize,
theme: &crate::highlight::Theme,
) -> u32 {
let session = core
.minibuffer
@ -2562,13 +2646,27 @@ fn paint_minibuffer(
let max = term_size.cols;
let cursor_byte = core.minibuffer.cursor;
let base = minibuffer_style(theme);
// Themes Q#TH5: the inline candidate suffix has its own face,
// `ui.minibuffer.candidate` ({fg} mask); unset keeps reverse.
let candidate = theme.face("ui.minibuffer.candidate").map_or(
crate::cell::Style {
reverse: true,
..Default::default()
},
|f| crate::cell::Style {
fg: f.fg,
..crate::cell::Style::default()
},
);
for ch in prompt.chars() {
if col >= max {
break;
}
let cell = grid.at(CellCoord::new(row, col));
cell.glyph = crate::cell::Glyph::Char(ch);
cell.style = crate::cell::Style::default();
cell.style = base;
col += 1;
written += 1;
}
@ -2585,7 +2683,7 @@ fn paint_minibuffer(
}
let cell = grid.at(CellCoord::new(row, col));
cell.glyph = crate::cell::Glyph::Char(ch);
cell.style = crate::cell::Style::default();
cell.style = base;
col += 1;
written += 1;
byte_pos += ch.len_utf8() as u64;
@ -2602,10 +2700,7 @@ fn paint_minibuffer(
}
let cell = grid.at(CellCoord::new(row, col));
cell.glyph = crate::cell::Glyph::Char(ch);
cell.style = crate::cell::Style {
reverse: true,
..Default::default()
};
cell.style = candidate;
col += 1;
written += 1;
}
@ -2613,7 +2708,7 @@ fn paint_minibuffer(
for col in written..max {
let cell = grid.at(CellCoord::new(row, col));
cell.glyph = crate::cell::Glyph::Char(' ');
cell.style = crate::cell::Style::default();
cell.style = base;
}
cursor_col.min(max.saturating_sub(1))
@ -2630,7 +2725,11 @@ fn paint_search_prompt(
grid: &mut crate::cell::CellGrid<'_>,
core: &EditorCore,
term_size: crate::cell::CellSize,
theme: &crate::highlight::Theme,
) {
// Themes Q#TH5: the search prompt is the echo-area input line, so
// it follows `ui.minibuffer` (the framing's applicability table).
let base = minibuffer_style(theme);
let prompt = match (core.search_is_regex(), core.search_forward()) {
(false, true) => "I-search: ",
(false, false) => "I-search backward: ",
@ -2656,7 +2755,7 @@ fn paint_search_prompt(
if *col < max {
let cell = grid.at(CellCoord::new(row, *col));
cell.glyph = crate::cell::Glyph::Char(ch);
cell.style = crate::cell::Style::default();
cell.style = base;
*col += 1;
}
};
@ -2670,11 +2769,11 @@ fn paint_search_prompt(
put(grid, &mut col, ch);
}
// Clear the remainder of the row (the status line underneath used
// reverse video; blank it with the default style).
// reverse video; blank it with the prompt's base style).
for c in col..max {
let cell = grid.at(CellCoord::new(row, c));
cell.glyph = crate::cell::Glyph::Char(' ');
cell.style = crate::cell::Style::default();
cell.style = base;
}
}
@ -2852,7 +2951,14 @@ mod tests {
size: CellSize::new(rows, cols),
};
let rect = Rect::new(0, 0, rows, cols);
paint_line_number_gutter(&mut grid, &window, &rect, rows, 4);
paint_line_number_gutter(
&mut grid,
&window,
&rect,
rows,
4,
&crate::highlight::Theme::empty(),
);
let glyph = |r: u32, c: u32| storage[(r * cols + c) as usize].glyph.clone();
// Row 0 = line 1: " 1 " (digit right-aligned at col 2, col 3 = pad).
@ -6894,7 +7000,7 @@ mod tests {
{
let mut core = s.core.borrow_mut();
core.active_window_mut()
.push_overlay(Box::new(crate::diag::DiagnosticView::new(uri, store)));
.push_overlay(Box::new(crate::diag::DiagnosticView::new(uri, store, None)));
}
let (cells, stride, _) = render_to_grid(&s, 24, 80);
// Both surfaces of the same store: the overlay's underline

View File

@ -285,6 +285,13 @@ pub struct EditorCore {
/// ([`crate::semantic_render`]) and the TUI search overlay.
/// Cheaply cloneable (`Arc<Mutex>`); shared with both readers.
pub search_store: crate::search::SharedSearchStore,
/// Shared theme handle (themes arc Q#TH9), injected once at editor
/// bring-up right after `SyntaxRegistry` construction — the core
/// owns no syntax state, but `ensure_search_overlay` constructs
/// `SearchView`s that resolve wash faces through it. A bare core
/// (unit-test construction) carries `None` and paints today's
/// literals.
pub theme: Option<crate::highlight::ThemeHandle>,
/// Live incremental-search session (Q#SR5), or `None` when no
/// search is running. Frontend-agnostic: the TUI run loop and the
/// daemon's `FrontendEvent::Key` path both drive it through the
@ -385,6 +392,7 @@ impl EditorCore {
pending_crdt_ops: Vec::new(),
jump_ring: Vec::new(),
search_store: crate::search::make_shared_store(),
theme: None,
search: None,
clipboard_slot: Vec::new(),
pending_clipboard: None,
@ -857,9 +865,12 @@ impl EditorCore {
/// rendered buffer, so one instance suffices per window.
fn ensure_search_overlay(&mut self) {
let store = self.search_store.clone();
// Themes Q#TH9: pass the injected theme through unconditionally
// — a bare core (None) constructs a working unthemed view.
let theme = self.theme.clone();
let win = self.active_window_mut();
if !win.overlay_kinds().contains(&"search") {
win.push_overlay(Box::new(crate::search::SearchView::new(store)));
win.push_overlay(Box::new(crate::search::SearchView::new(store, theme)));
}
}

View File

@ -413,6 +413,11 @@ impl Frontend {
// completion dropdown; the TUI paints the popup via its
// CompletionView cell overlay, so it drops this silently.
| InstanceMessage::CompletionPopup { .. }
// Themes Q#TH7 — ThemeFacts is the semantic-frontend face
// table; the cell-grid TUI receives its chrome pre-painted
// (the daemon resolves faces at paint time), so it drops
// this silently like the other semantic families.
| InstanceMessage::ThemeFacts { .. }
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path
@ -760,6 +765,32 @@ mod tests {
}
}
#[test]
fn theme_facts_drops_silently_on_the_grid_frontend() {
// Themes Q#TH7 / acceptance 18: the cell-grid TUI receives its
// chrome pre-painted (the daemon resolves faces at paint
// time), so a `ThemeFacts` reaching this client — which never
// negotiates it — must fall into the semantic-family silent
// drop, not error. Constructed directly (no terminal
// takeover); the drop arm writes nothing.
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::ThemeFacts {
faces: vec![pmacs_protocol::ThemeFace {
name: "ui.modeline".into(),
style: Style::default(),
}],
})
.expect("the grid frontend must drop ThemeFacts silently");
}
#[test]
fn emit_span_writes_cursor_move_then_chars() {
let span = DiffSpan {

View File

@ -58,12 +58,39 @@ use crate::view::{View, Viewport};
/// the same hierarchy in both cases.
#[derive(Clone, Debug, Default)]
pub struct Theme {
/// Direct map from capture name → style. T M4.3.
/// Direct map from capture name → style. T M4.3. Names matching
/// [`is_face_name`] (`ui` / `ui.*`) are UI faces (themes arc
/// Q#TH2), reserved by convention — no tree-sitter capture or LSP
/// token type uses them.
pub by_capture: HashMap<String, Style>,
/// Fallback style when no capture matches. Defaults to the
/// terminal default colors (no override) so unhighlighted text
/// looks identical to plain rendering.
pub default_style: Style,
/// Monotonic syntax-mutation counter (themes arc Q#TH6). Bumped
/// by every successful Lua mutation that commits a non-face key
/// (or touches `default_style`); keys the `StyleGate` and the
/// minimap summary so a mid-session recolor re-ships spans.
/// INVARIANT: only ever incremented — a wholesale `set` must
/// replace `by_capture`, never the whole `Theme`, or consecutive
/// mutations share an epoch and become invisible to every gate.
pub syntax_epoch: u64,
/// Monotonic face-mutation counter (themes arc Q#TH6). Bumped by
/// every successful Lua mutation that commits a face key
/// ([`is_face_name`]); keys the `ThemeFacts` producer and the
/// minimap summary (`ui.diag.*` feeds its marks). Same
/// increment-only invariant as `syntax_epoch`.
pub face_epoch: u64,
}
/// Themes arc Q#TH2: the face predicate. A theme key names a UI face
/// iff it is exactly `ui` (the deliberate inheritance catch-all —
/// [`Theme::face`]'s walk terminal) or starts with `ui.`. Shared by
/// the namespace reservation, the mutation-counter classification,
/// and the `ThemeFacts` producer's key filter.
#[must_use]
pub fn is_face_name(name: &str) -> bool {
name == "ui" || name.starts_with("ui.")
}
impl Theme {
@ -151,6 +178,8 @@ impl Theme {
Self {
by_capture,
default_style: Style::default(),
syntax_epoch: 0,
face_epoch: 0,
}
}
@ -171,6 +200,32 @@ impl Theme {
}
}
/// Resolve a UI face name to its style, or `None` when unset
/// (themes arc Q#TH4). Same dotted-prefix walk as [`Self::lookup`]
/// — so `ui.search.match.active` falls back to `ui.search.match`,
/// the `ui.diag.*` children to `ui.diag`, and everything to the
/// bare-`ui` catch-all — but the walk returns `None` instead of
/// falling back to `default_style`: an unset face must leave the
/// paint site's hardcoded default untouched, and a user's
/// `pmacs.theme.default` (a *syntax* fallback) must never bleed
/// into chrome. An exact entry stops the walk, so an explicitly
/// empty child (e.g. `ui.diag.error = {}`) blocks inheritance
/// from a themed parent. Callers pass full face names only.
#[must_use]
pub fn face(&self, name: &str) -> Option<Style> {
debug_assert!(is_face_name(name), "face() takes ui/ui.* names");
let mut name = name;
loop {
if let Some(s) = self.by_capture.get(name) {
return Some(*s);
}
match name.rfind('.') {
Some(idx) => name = &name[..idx],
None => return None,
}
}
}
/// Set the style for one capture name, replacing any prior entry.
pub fn insert(&mut self, capture_name: impl Into<String>, style: Style) {
self.by_capture.insert(capture_name.into(), style);
@ -685,6 +740,83 @@ mod tests {
assert!(!s.bold);
}
#[test]
fn face_returns_none_when_unset_never_default_style() {
// Q#TH4: an unset face leaves the paint site's hardcoded
// default untouched — even a loud user default_style (a
// SYNTAX fallback) must not bleed into chrome.
let mut t = Theme::empty();
t.default_style = Style {
bold: true,
..Style::default()
};
assert_eq!(t.face("ui.modeline"), None);
assert_eq!(t.face("ui"), None);
// lookup, by contrast, resolves through to default_style.
assert!(t.lookup("ui.modeline").bold);
}
#[test]
fn face_walks_dotted_prefixes_to_the_ui_catch_all() {
let mut t = Theme::empty();
t.insert(
"ui",
Style {
italic: true,
..Style::default()
},
);
t.insert(
"ui.search.match",
Style {
bold: true,
..Style::default()
},
);
// Exact match.
assert!(t.face("ui.search.match").expect("set").bold);
// One-segment fallback: active inherits from ui.search.match.
assert!(t.face("ui.search.match.active").expect("inherit").bold);
// Everything else falls to the bare-ui catch-all.
assert!(t.face("ui.modeline").expect("catch-all").italic);
assert!(t.face("ui.diag.error").expect("catch-all").italic);
}
#[test]
fn face_exact_empty_child_blocks_parent_inheritance() {
// Q#TH5 (round 3 finding 4): with a themed ui.diag parent, an
// explicitly empty ui.diag.error child stops the walk at the
// exact entry — errors reset to the built-in (the Default fg
// policy applies at the consumer) while siblings inherit.
let mut t = Theme::empty();
t.insert(
"ui.diag",
Style {
fg: Color::Indexed(93),
..Style::default()
},
);
t.insert("ui.diag.error", Style::default());
assert_eq!(t.face("ui.diag.error"), Some(Style::default()));
assert_eq!(
t.face("ui.diag.warning").expect("inherits").fg,
Color::Indexed(93)
);
}
#[test]
fn face_predicate_accepts_ui_root_and_prefix_only() {
// Q#TH2: exactly `ui` or `ui.`-prefixed — nothing else. A
// name like `uix` must classify as syntax, not face.
assert!(is_face_name("ui"));
assert!(is_face_name("ui.modeline"));
assert!(is_face_name("ui.search.match.active"));
assert!(!is_face_name("uix"));
assert!(!is_face_name("u"));
assert!(!is_face_name("keyword"));
assert!(!is_face_name("gui.modeline"));
}
#[test]
fn line_offsets_basic() {
let src = b"a\nbb\nccc";

View File

@ -43,7 +43,11 @@ fn diagnostic_to_lua(lua: &Lua, d: &Diagnostic) -> mlua::Result<Table> {
clippy::too_many_lines,
reason = "linear list of raw bindings; splitting fragments a coherent surface"
)]
pub fn install_diag(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
pub fn install_diag(
lua: &Lua,
manager: &SharedLspManager,
theme: &crate::highlight::ThemeHandle,
) -> mlua::Result<()> {
let pmacs: Table = lua.globals().get("pmacs")?;
let diag_mod = lua.create_table()?;
@ -202,6 +206,7 @@ pub fn install_diag(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
// per buffer; double-attach stacks duplicate overlays.
{
let m = manager.clone();
let th = theme.clone();
diag_mod.set(
"_attach_view",
lua.create_function(move |lua, (id, uri): (BufferIdLua, String)| {
@ -217,7 +222,10 @@ pub fn install_diag(lua: &Lua, manager: &SharedLspManager) -> mlua::Result<()> {
id.0
)));
}
let overlay = crate::diag::DiagnosticView::new(uri, store_handle);
// Themes Q#TH9: the Lua attachment path threads the
// shared theme so ui.diag.* faces reach the squiggles
// and gutter signs.
let overlay = crate::diag::DiagnosticView::new(uri, store_handle, Some(th.clone()));
win.push_overlay(Box::new(overlay));
Ok(true)
})?,

View File

@ -55,7 +55,7 @@ use crate::buffer_registry::BufferRegistry;
use crate::cell::{Color, Style, UnderlineStyle};
use crate::command::{Command, CommandError, CommandRegistry, SourceLocation};
use crate::editor_core::EditorCore;
use crate::highlight::{SyntaxHighlightView, Theme};
use crate::highlight::SyntaxHighlightView;
use crate::hook::{Hook, HookRegistry};
use crate::key::{display_sequence, parse_sequence};
use crate::keymap_stack::KeymapStack;
@ -6862,18 +6862,26 @@ fn underline_to_lua(style: UnderlineStyle) -> &'static str {
/// default to the same values as `Style::default()` --- a Lua table
/// with `{ bold = true }` produces a style that is otherwise
/// terminal-default.
///
/// Every lookup PROPAGATES its error (PR #120 round 1 finding 2):
/// `Table::get` runs `__index`, so a raising metatable must fail the
/// enclosing transactional mutation (Q#TH6 all-or-nothing), not
/// silently parse as an all-default style and let sibling entries
/// commit. Color/underline fields validate strictly through their
/// converters; the boolean fields follow Lua truthiness (mlua's
/// `bool` conversion), so `reverse = 1` reads as `true` by design.
fn lua_to_style(t: &Table) -> mlua::Result<Style> {
let fg: mlua::Value = t.get("fg").unwrap_or(mlua::Value::Nil);
let bg: mlua::Value = t.get("bg").unwrap_or(mlua::Value::Nil);
let underline: mlua::Value = t.get("underline").unwrap_or(mlua::Value::Nil);
let underline_color: mlua::Value = t.get("underline_color").unwrap_or(mlua::Value::Nil);
let fg: mlua::Value = t.get("fg")?;
let bg: mlua::Value = t.get("bg")?;
let underline: mlua::Value = t.get("underline")?;
let underline_color: mlua::Value = t.get("underline_color")?;
Ok(Style {
fg: lua_to_color(&fg)?,
bg: lua_to_color(&bg)?,
bold: t.get("bold").unwrap_or(false),
italic: t.get("italic").unwrap_or(false),
bold: t.get::<Option<bool>>("bold")?.unwrap_or(false),
italic: t.get::<Option<bool>>("italic")?.unwrap_or(false),
underline: lua_to_underline(&underline)?,
reverse: t.get("reverse").unwrap_or(false),
reverse: t.get::<Option<bool>>("reverse")?.unwrap_or(false),
underline_color: lua_to_color(&underline_color)?,
})
}
@ -6895,6 +6903,83 @@ fn style_to_lua(lua: &Lua, style: Style) -> mlua::Result<Table> {
/// attached [`SyntaxHighlightView`] sees the change on its next
/// render. T M4.3 acceptance: "theming via Lua-defined color
/// schemes."
/// Themes arc Q#TH6: how [`commit_theme_entries`] applies a parsed
/// entry set to the theme's capture map.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ThemeCommit {
/// `pmacs.theme.set`: the parsed entries become the whole map
/// (faces wiped with captures, Q#TH10); `default_style` is
/// untouched.
Replace,
/// `pmacs.theme.merge`: insert/overwrite the parsed entries.
Merge,
}
/// Themes arc Q#TH6: the transactional mutation helper behind
/// `pmacs.theme.set` / `pmacs.theme.merge`. Collects the WHOLE entry
/// stream before touching the theme lock, so a malformed entry
/// anywhere in the input returns its error with the theme untouched
/// and the mutation counters unbumped — the pre-fix `merge` inserted
/// while iterating, letting early entries land before a later one
/// failed. After a successful commit the counters bump from their
/// prior values (never reset — [`crate::highlight::Theme`]'s
/// increment-only invariant): `Replace` touches both namespaces
/// wholesale so it bumps both; `Merge` classifies every committed
/// key through [`crate::highlight::is_face_name`] (bare `ui`
/// included) and bumps `syntax_epoch` iff any non-face key
/// committed, `face_epoch` iff any face key did.
fn commit_theme_entries(
theme: &crate::highlight::ThemeHandle,
mode: ThemeCommit,
entries: impl Iterator<Item = mlua::Result<(String, Style)>>,
) -> mlua::Result<()> {
let entries: Vec<(String, Style)> = entries.collect::<mlua::Result<_>>()?;
let mut th = theme.lock().expect("theme mutex poisoned");
match mode {
ThemeCommit::Replace => {
// Replace the FIELD, never the `Theme` value: a fresh
// Theme's zeroed counters would let consecutive `set`
// calls share an epoch and stay invisible to every gate.
th.by_capture = entries.into_iter().collect();
th.syntax_epoch += 1;
th.face_epoch += 1;
}
ThemeCommit::Merge => {
let any_face = entries
.iter()
.any(|(n, _)| crate::highlight::is_face_name(n));
let any_syntax = entries
.iter()
.any(|(n, _)| !crate::highlight::is_face_name(n));
for (name, style) in entries {
th.by_capture.insert(name, style);
}
if any_syntax {
th.syntax_epoch += 1;
}
if any_face {
th.face_epoch += 1;
}
}
}
Ok(())
}
/// Adapt a Lua theme table to [`commit_theme_entries`]'s ordered
/// result stream: each raw `(name, style_table)` pair maps through
/// [`lua_to_style`], and any iteration or conversion error rides the
/// stream so the helper can fail before locking.
fn lua_theme_entries(table: &Table) -> impl Iterator<Item = mlua::Result<(String, Style)>> + use<> {
table
.pairs::<String, Table>()
.map(|pair| {
let (name, style) = pair?;
Ok((name, lua_to_style(&style)?))
})
.collect::<Vec<_>>()
.into_iter()
}
fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table> {
let theme_mod = lua.create_table()?;
@ -6903,17 +6988,7 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
theme_mod.set(
"set",
lua.create_function(move |_, table: Table| {
let mut new_theme = Theme::empty();
table.for_each(|name: String, style: Table| {
new_theme.insert(name, lua_to_style(&style)?);
Ok(())
})?;
let theme = s.theme();
let mut th = theme.lock().expect("theme mutex poisoned");
let prev_default = th.default_style;
*th = new_theme;
th.default_style = prev_default;
Ok(())
commit_theme_entries(&s.theme(), ThemeCommit::Replace, lua_theme_entries(&table))
})?,
)?;
}
@ -6923,13 +6998,7 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
theme_mod.set(
"merge",
lua.create_function(move |_, table: Table| {
let theme = s.theme();
let mut th = theme.lock().expect("theme mutex poisoned");
table.for_each(|name: String, style: Table| {
th.insert(name, lua_to_style(&style)?);
Ok(())
})?;
Ok(())
commit_theme_entries(&s.theme(), ThemeCommit::Merge, lua_theme_entries(&table))
})?,
)?;
}
@ -6952,7 +7021,11 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
"clear",
lua.create_function(move |_, ()| {
let theme = s.theme();
theme.lock().expect("theme mutex poisoned").clear();
let mut th = theme.lock().expect("theme mutex poisoned");
th.clear();
// Q#TH6: clear empties both namespaces — bump both.
th.syntax_epoch += 1;
th.face_epoch += 1;
Ok(())
})?,
)?;
@ -6963,8 +7036,13 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
theme_mod.set(
"default",
lua.create_function(move |_, style: Table| {
// Q#TH6: parse before locking; default_style is a
// syntax-namespace fallback, so bump syntax only.
let parsed = lua_to_style(&style)?;
let theme = s.theme();
theme.lock().expect("theme mutex poisoned").default_style = lua_to_style(&style)?;
let mut th = theme.lock().expect("theme mutex poisoned");
th.default_style = parsed;
th.syntax_epoch += 1;
Ok(())
})?,
)?;
@ -8623,7 +8701,7 @@ pub fn make_lsp_manager(
) -> mlua::Result<SharedLspManager> {
let manager = Rc::new(RefCell::new(LspManager::new(supervisor, runtime)));
install_lsp(lua, &manager, syntax)?;
diag::install_diag(lua, &manager)?;
diag::install_diag(lua, &manager, &syntax.theme())?;
install_completion(lua, &manager)?;
install_hover(lua, &manager)?;
install_signature(lua, &manager)?;
@ -12118,6 +12196,123 @@ mod tests {
(lua, reg, cmds, kms, hks)
}
/// A theme handle with one syntax entry and nonzero counters, for
/// pinning that failed commits change nothing and successful ones
/// bump from the PRIOR values (themes arc Q#TH6).
fn seeded_theme() -> crate::highlight::ThemeHandle {
let mut th = crate::highlight::Theme::empty();
th.insert(
"keyword",
Style {
bold: true,
..Style::default()
},
);
th.syntax_epoch = 3;
th.face_epoch = 5;
std::sync::Arc::new(std::sync::Mutex::new(th))
}
#[test]
fn theme_commit_is_all_or_nothing_with_untouched_counters() {
// Q#TH6 / acceptance 11 (the deterministic bite): an ordered
// entry stream whose TAIL is malformed must error with zero
// theme mutation — the helper collects the whole stream
// before taking the lock. The pre-fix merge inserted while
// iterating, so the leading Ok entry landed before the Err.
let theme = seeded_theme();
let entries: Vec<mlua::Result<(String, Style)>> = vec![
Ok((
"string".to_owned(),
Style {
italic: true,
..Style::default()
},
)),
Err(mlua::Error::RuntimeError("malformed style".into())),
];
let res = commit_theme_entries(&theme, ThemeCommit::Merge, entries.into_iter());
assert!(res.is_err(), "a malformed tail entry must error");
let th = theme.lock().expect("lock");
assert!(
!th.by_capture.contains_key("string"),
"the leading Ok entry must NOT have landed"
);
assert!(
th.by_capture.contains_key("keyword"),
"pre-existing entries survive"
);
assert_eq!(th.syntax_epoch, 3, "failed commit bumps nothing");
assert_eq!(th.face_epoch, 5, "failed commit bumps nothing");
}
#[test]
fn theme_commit_replace_advances_counters_from_prior_values() {
// Q#TH6 / acceptance 10: consecutive wholesale replacements
// must each advance the counters — replacing the FIELD, not
// the Theme value, or two `set`s share an epoch. Replace also
// leaves default_style alone (the historical `set` contract).
let theme = seeded_theme();
theme.lock().expect("lock").default_style = Style {
reverse: true,
..Style::default()
};
for expected in [(4, 6), (5, 7)] {
let entries: Vec<mlua::Result<(String, Style)>> =
vec![Ok(("type".to_owned(), Style::default()))];
commit_theme_entries(&theme, ThemeCommit::Replace, entries.into_iter())
.expect("commit");
let th = theme.lock().expect("lock");
assert_eq!((th.syntax_epoch, th.face_epoch), expected);
assert!(!th.by_capture.contains_key("keyword"), "replaced wholesale");
assert!(th.default_style.reverse, "default_style preserved");
}
}
#[test]
fn theme_commit_merge_classifies_face_and_syntax_keys() {
// Q#TH6: merge bumps syntax_epoch iff any non-face key
// committed and face_epoch iff any face key did — bare `ui`
// classifies as a face (Q#TH2, round 2 finding 3).
let theme = seeded_theme();
// One lock per observation: two `lock()` calls inside one
// tuple expression self-deadlock (the first guard outlives
// the second call).
let epochs = |theme: &crate::highlight::ThemeHandle| {
let th = theme.lock().expect("lock");
(th.syntax_epoch, th.face_epoch)
};
let face_only: Vec<mlua::Result<(String, Style)>> =
vec![Ok(("ui.modeline".to_owned(), Style::default()))];
commit_theme_entries(&theme, ThemeCommit::Merge, face_only.into_iter()).expect("commit");
assert_eq!(
epochs(&theme),
(3, 6),
"face-only merge bumps face_epoch only"
);
let bare_ui: Vec<mlua::Result<(String, Style)>> =
vec![Ok(("ui".to_owned(), Style::default()))];
commit_theme_entries(&theme, ThemeCommit::Merge, bare_ui.into_iter()).expect("commit");
assert_eq!(epochs(&theme), (3, 7), "bare ui is a face key");
let mixed: Vec<mlua::Result<(String, Style)>> = vec![
Ok(("comment".to_owned(), Style::default())),
Ok(("ui.gutter".to_owned(), Style::default())),
];
commit_theme_entries(&theme, ThemeCommit::Merge, mixed.into_iter()).expect("commit");
assert_eq!(epochs(&theme), (4, 8), "mixed merge bumps both");
let empty: Vec<mlua::Result<(String, Style)>> = Vec::new();
commit_theme_entries(&theme, ThemeCommit::Merge, empty.into_iter()).expect("commit");
assert_eq!(
epochs(&theme),
(4, 8),
"an empty merge commits nothing and bumps nothing"
);
}
#[test]
fn create_query_insert_observe_round_trip() {
let (lua, _reg, _cmds, _kms, _hks) = fresh();

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_fifteen_for_completion_popup() {
fn protocol_version_is_sixteen_for_theme_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
@ -1706,8 +1706,10 @@ mod tests {
// gutter modes bumped 13→14 (`LineNumbers` swapped `enabled: bool`
// for a `LineNumberMode` enum — encoding change, still daemon-gated).
// Arc 1a Q#C5 bumped 14→15 (`InstanceMessage::CompletionPopup`,
// additive + daemon-gated).
assert_eq!(PROTOCOL_VERSION, 15);
// 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);
}
#[test]
@ -1781,21 +1783,84 @@ mod tests {
// (`TripleDown`), v8 (`StatusFacts`), v9 + v10 (`SearchPrompt` +
// regex/invalid), v11 (the context menu), v12 (the GUI
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
// (`CompletionPopup`) all interoperate, so v6 through v15 talk.
for accepted in 6..=15 {
// (`CompletionPopup`), v16 (`ThemeFacts`) all interoperate, so
// v6 through v16 talk.
for accepted in 6..=16 {
assert!(
is_supported_protocol_version(accepted),
"v{accepted} must be accepted"
);
}
for rejected in [0, 1, 2, 3, 4, 5, 16, u32::MAX] {
for rejected in [0, 1, 2, 3, 4, 5, 17, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v15 binary"
"v{rejected} must be rejected by a v16 binary"
);
}
}
#[test]
fn theme_facts_round_trips_through_postcard() {
// Themes Q#TH7 (v16): the daemon-resolved UI face table. Pin
// the empty (authoritative-unthemed) and populated shapes.
for msg in [
InstanceMessage::ThemeFacts { faces: Vec::new() },
InstanceMessage::ThemeFacts {
faces: vec![
pmacs_protocol::ThemeFace {
name: "ui.gutter".into(),
style: crate::cell::Style {
fg: crate::cell::Color::Indexed(245),
..crate::cell::Style::default()
},
},
pmacs_protocol::ThemeFace {
name: "ui.modeline".into(),
style: crate::cell::Style {
fg: crate::cell::Color::Rgb(200, 200, 210),
bg: crate::cell::Color::Rgb(30, 30, 46),
..crate::cell::Style::default()
},
},
],
},
] {
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(msg, decoded);
}
}
#[test]
fn completion_popup_encoding_is_unchanged_by_the_v16_build() {
// Themes Q#TH7 placement pin: postcard discriminants are
// ordinal, so a variant inserted anywhere before the end of
// `InstanceMessage` would shift every later variant's tag and
// silently corrupt v15 peers on channels that are NOT
// version-gated. `ThemeFacts` must therefore be APPENDED after
// `CompletionPopup` — the final v15 variant, whose ordinal
// moves if anything is inserted before any v15 variant. These
// are the exact bytes a v15 binary produced for this value
// (discriminant 22 as a postcard varint, then the fields);
// the new variant's own round-trip cannot detect a shift.
let msg = InstanceMessage::CompletionPopup {
buffer_id: pmacs_protocol::BufferId::from_raw(3),
anchor: Some(5),
prefix_len: 2,
rows: Vec::new(),
selected: None,
total: 9,
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes,
[22, 3, 1, 5, 2, 0, 0, 9],
"CompletionPopup's v15 wire bytes changed — a variant was \
inserted before it; append new InstanceMessage variants \
at the end"
);
}
#[test]
fn hello_round_trips_through_postcard() {
let h = Hello {

View File

@ -385,6 +385,10 @@ fn active_match_style() -> Style {
/// maps to one row.
pub struct SearchView {
store: SharedSearchStore,
/// Shared theme for the `ui.search.match(.active)` wash faces
/// (themes arc Q#TH9). `None` — a bare core with no injected
/// theme — paints the built-in yellow literals.
theme: Option<crate::highlight::ThemeHandle>,
}
impl SearchView {
@ -393,10 +397,11 @@ impl SearchView {
/// ([`Buffer::id`]) rather than a fixed id, so a single attached
/// instance keeps highlighting correctly even if the window
/// switches buffers (the store is per-buffer; a buffer with no
/// search entry simply paints nothing).
/// search entry simply paints nothing). Wash faces resolve
/// through `theme` when given.
#[must_use]
pub fn new(store: SharedSearchStore) -> Self {
Self { store }
pub fn new(store: SharedSearchStore, theme: Option<crate::highlight::ThemeHandle>) -> Self {
Self { store, theme }
}
}
@ -437,11 +442,36 @@ impl View for SearchView {
let max_cols = viewport.cell_size.cols;
let cell_origin = viewport.cell_origin;
// Themes Q#TH5: a set wash face replaces the default overlay
// wholesale within its {bg} mask — the face's bg is the wash,
// everything else plain (so `ui.search.match = {}` disables
// the wash; out-of-mask fg/reverse are never read). Unset
// keeps today's literals. One theme clone per render (Q#TH9).
let (match_overlay, active_overlay) = {
let theme = self
.theme
.as_ref()
.map(|t| t.lock().expect("theme mutex poisoned").clone());
let resolve = |name: &str, fallback: fn() -> Style| -> Style {
theme
.as_ref()
.and_then(|t| t.face(name))
.map_or_else(fallback, |f| Style {
bg: f.bg,
..Style::default()
})
};
(
resolve("ui.search.match", match_style),
resolve("ui.search.match.active", active_match_style),
)
};
for m in &matches {
let style = if Some(*m) == active {
active_match_style()
active_overlay
} else {
match_style()
match_overlay
};
// A regex match may span multiple lines (Q#RX4); wash each
// row's clipped slice, mirroring the selection renderer.
@ -707,7 +737,7 @@ mod tests {
.unwrap()
.set(bid, "lo", find_all(b"lo lo lo\n", "lo"));
let mut view = SearchView::new(store.clone());
let mut view = SearchView::new(store.clone(), None);
let mut backing = vec![Cell::default(); 10];
let mut grid = CellGrid {
cells: &mut backing,
@ -783,7 +813,7 @@ mod tests {
stride: cols,
size: CellSize::new(rows, cols),
};
SearchView::new(store.clone()).render(
SearchView::new(store.clone(), None).render(
&buf,
Viewport {
buffer_start: 0,

View File

@ -160,14 +160,27 @@ pub struct SemanticRenderState {
/// buffer at the same generation re-uses what the frontend
/// already has and emits nothing. First emission happens on the
/// first frame for a buffer; further emissions only after edits.
/// `(crdt_generation, diag_epoch)` the last emitted summary was
/// computed against. Diagnostics arrive without a generation
/// bump, so the epoch half catches republishes (minimap marks,
/// T M4.6 GPU parity).
last_summary: HashMap<BufferId, (u64, u64)>,
/// `(crdt_generation, diag_epoch, syntax_epoch, face_epoch)` the
/// last summary was computed against, plus that computed payload.
/// Diagnostics arrive without a generation bump, so the diag
/// epoch catches republishes (minimap marks, T M4.6 GPU parity);
/// the theme epochs (Q#TH6) catch mid-session recolors —
/// `face_epoch` belongs in the key because `ui.diag.*` feeds the
/// marks. The payload copy backs the Q#TH6 payload-equality
/// suppression: a face edit that leaves the summary unchanged
/// (e.g. `ui.modeline`) recomputes once per mutation and emits
/// nothing. The key advances on COMPUTATION, not emission — a
/// suppressed send still inserts, or the whole-file recompute
/// repeats every tick.
last_summary: HashMap<BufferId, SummaryCache>,
/// `(name, modified, diag_errors, diag_warnings, message)` last
/// emitted as `StatusFacts` (Q#S1; `message` since v15) —
/// cached-compare suppression.
/// cached-compare suppression. A peer emission baseline ONLY:
/// the diagnostic-count freeze deliberately holds no session
/// state (rounds 34) — it is sourced from the diag store's
/// retained vector, so it needs nothing here to survive the
/// `on_buffer_snapshot_sent` reset and it holds for sessions
/// with no history (a late joiner attaching mid-edit).
last_status: HashMap<BufferId, (String, bool, u32, u32, Option<String>)>,
/// Last-emitted line-number gutter mode (UX gutter arc, protocol v14) —
/// cached-compare suppression. Seeded to `Some(Off)` (the frontend's
@ -193,11 +206,36 @@ pub struct SemanticRenderState {
/// viewport* (which the GPU frontend sets to the entire buffer)
/// and clones the theme — too expensive to repeat on every tick.
/// The styling depends only on the parse bundle, the CRDT
/// generation, and the viewport — never the cursor — so a gate
/// built from those lets cursor-only ticks skip the query entirely.
/// Only the grammar (tree-sitter) path is gated; the LSP-token path
/// has no comparably cheap handle and recomputes as before.
/// generation, the viewport, and the theme's syntax epoch
/// (Q#TH6) — never the cursor — so a gate built from those lets
/// cursor-only ticks skip the query entirely while a mid-session
/// `pmacs.theme.set` still re-ships recolored spans without an
/// edit. Only the grammar (tree-sitter) path is gated; the
/// LSP-token path has no comparably cheap handle and recomputes
/// as before.
last_style_gate: HashMap<BufferId, StyleGate>,
/// The theme `face_epoch` the `ThemeFacts` producer last
/// INSPECTED (Q#TH7) — `Option`, not a bare zero, because an
/// unthemed daemon sits at `face_epoch == 0` and a `0 == 0`
/// short-circuit would starve the first authoritative send.
/// Advances on computation, not emission: an identical rebuild
/// records the epoch it inspected even though nothing ships.
last_face_epoch: Option<u64>,
/// The face table the frontend believes (Q#TH7), seeded `None` so
/// every attachment receives exactly one authoritative table —
/// the empty table included — with its first emission after
/// viewport declaration. A frontend retaining face state across
/// attachments is therefore corrected even by an unthemed daemon.
last_theme_faces: Option<Vec<crate::protocol::ThemeFace>>,
/// Whether the peer negotiated protocol >= 16 (PR #120 round 1
/// finding 3). Faces reach a semantic frontend through TWO
/// channels: `ThemeFacts` (daemon write-loop gated) and the
/// `ui.diag.*` colors folded into `FileStyleSummary` — an OLDER
/// channel the version gate does not filter. A v15 peer must not
/// receive face-derived minimap marks while its squiggles, signs,
/// and counters stay unthemed, so this producer resolves faces
/// only when the peer can apply the whole face table.
peer_knows_theme_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*
@ -215,6 +253,43 @@ struct DiagLineCache {
source_len: u64,
}
/// One [`SemanticRenderState::last_summary`] entry: the inputs the
/// summary was computed against and the computed per-line payload.
struct SummaryCache {
/// `(crdt_generation, diag_epoch, syntax_epoch, face_epoch)`.
key: (u64, u64, u64, u64),
/// The computed summary — compared before emitting (Q#TH6
/// payload-equality suppression).
lines: Vec<Style>,
}
/// The stage-1 UI face inventory (themes arc Q#TH3): the names the
/// `ThemeFacts` producer resolves through [`crate::highlight::Theme::face`]
/// and ships. Resolution is daemon-side — frontends do exact-name
/// lookup on the shipped table, no walk (Q#TH7). Kept sorted; the
/// wire table's deterministic ordering rides on it.
const UI_FACES: &[&str] = &[
"ui.diag.error",
"ui.diag.hint",
"ui.diag.info",
"ui.diag.warning",
"ui.gutter",
"ui.minibuffer",
"ui.minibuffer.candidate",
"ui.modeline",
"ui.search.match",
"ui.search.match.active",
"ui.selection",
"ui.statusline",
];
/// Read both theme mutation counters under one lock (Q#TH6).
fn theme_epochs(state: &EditorState) -> (u64, u64) {
let theme = state.syntax_registry.theme();
let th = theme.lock().expect("theme mutex poisoned");
(th.syntax_epoch, th.face_epoch)
}
/// Recompute gate for [`scoped_style_spans`] on a grammar-backed
/// buffer. Holds the current parse bundle `Arc` so its address stays
/// stable while cached — comparing by `Arc::ptr_eq` then can't be
@ -230,6 +305,11 @@ struct StyleGate {
generation: u64,
/// Declared viewport.
visible: ByteRange,
/// The theme's syntax mutation counter (Q#TH6): spans are a pure
/// function of the theme too, and before this half the gate a
/// mid-session `pmacs.theme.set` shipped nothing until the next
/// buffer edit — the GPU kept stale colors.
syntax_epoch: u64,
}
impl StyleGate {
@ -237,6 +317,7 @@ impl StyleGate {
fn matches(&self, other: &Self) -> bool {
self.generation == other.generation
&& self.visible == other.visible
&& self.syntax_epoch == other.syntax_epoch
&& match (&self.bundle, &other.bundle) {
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
(None, None) => true,
@ -246,8 +327,22 @@ impl StyleGate {
}
impl SemanticRenderState {
/// Fresh session state for a peer that negotiated
/// `negotiated_protocol_version` — the real daemon construction
/// path (PR #120 round 1 finding 3): a `< 16` peer gets no
/// `ThemeFacts` produced at all and, crucially, no face-derived
/// colors folded into its `FileStyleSummary` marks.
#[must_use]
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
}
/// Fresh session state for frontend `frontend_id`: no viewport
/// declared, nothing sent.
/// declared, nothing sent. Assumes a current-build peer (>= 16);
/// daemon sessions with a real negotiated version use
/// [`Self::for_peer`].
#[must_use]
pub fn new(frontend_id: FrontendId) -> Self {
Self {
@ -268,6 +363,13 @@ impl SemanticRenderState {
// toggle-on (or later toggle-off) ships a message.
last_line_numbers: Some(crate::window::LineNumberMode::Off),
last_style_gate: HashMap::new(),
// Q#TH7: both seeded None — the first frame after viewport
// declaration always ships an authoritative face table
// (empty included), and the epoch gate cannot short-circuit
// an epoch-0 daemon before that send.
last_face_epoch: None,
last_theme_faces: None,
peer_knows_theme_facts: true,
diag_line_cache: HashMap::new(),
}
}
@ -284,6 +386,48 @@ impl SemanticRenderState {
});
}
/// Snapshot/baseline reset contract (PR #120 round 2 finding 1).
///
/// A `BufferSnapshot` resets the receiving frontend's
/// buffer-scoped render state wholesale — spans, decorations,
/// adornments, minimap summary, completion popup (see the GPU's
/// `BufferSnapshot` arm) — so every buffer-scoped emission
/// baseline this producer holds for that buffer must die with the
/// send. Otherwise an unchanged-key revisit (the A → B → A round
/// trip at one CRDT generation) suppresses every re-send and the
/// frontend never regains the state until an edit, diagnostic
/// republish, or theme mutation happens to move the key.
///
/// Called by the daemon wherever it writes a `BufferSnapshot` to
/// this session's stream. Resetting when the write later fails is
/// harmless — the failure mode is one redundant re-send, never
/// staleness.
///
/// Deliberately NOT reset: `last_face_epoch` / `last_theme_faces`
/// (`ThemeFacts` is bufferless — the frontend keeps its face
/// table across snapshots), `last_minibuffer` (one global core
/// instance, not buffer-scoped), `last_line_numbers`
/// (per-frontend gutter mode, kept by the frontend across the
/// switch), and `diag_line_cache` (a revision-keyed compute
/// cache, not a peer-state baseline). The diagnostic-count
/// freeze survives by construction (rounds 34): it is sourced
/// from the diag store's retained vector, never from session
/// state, so this reset cannot zero mid-edit counts. Baselines
/// for OTHER buffers also survive — the snapshot names one
/// buffer, and any buffer the frontend navigates to receives its
/// own snapshot first.
pub fn on_buffer_snapshot_sent(&mut self, buffer_id: BufferId) {
self.last_sent.remove(&buffer_id);
self.last_style_gate.remove(&buffer_id);
self.last_decorations.remove(&buffer_id);
self.last_adornments.remove(&buffer_id);
self.last_summary.remove(&buffer_id);
self.last_status.remove(&buffer_id);
self.last_search_prompt.remove(&buffer_id);
self.last_menu_prompt.remove(&buffer_id);
self.last_completion_popup.remove(&buffer_id);
}
/// Project one frame.
///
/// Returns up to three messages — [`InstanceMessage::StyleSpans`]
@ -475,6 +619,8 @@ impl SemanticRenderState {
out.extend(self.minibuffer_prompt_msg(state, vp.buffer_id));
// --- CompletionPopup (Arc 1a Q#C5, protocol v15) ---
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
}
@ -740,9 +886,14 @@ impl SemanticRenderState {
/// nothing changed. Carries the facts a semantic frontend cannot
/// derive locally: buffer name, modified flag, whole-file
/// diagnostic counts (errors / warnings). Counts freeze at their
/// last value while the diag store is stale — mid-edit positions
/// are wrong but *counts* merely lag, and flickering to zero on
/// every keystroke would be worse. The daemon's write loop keeps
/// last published value while the diag store is stale — mid-edit
/// positions are wrong but *counts* merely lag, and flickering
/// to zero on every keystroke would be worse. The freeze IS the
/// store's retained state (rounds 35): `mark_stale` keeps the
/// last published diagnostics and their cached severity totals,
/// so reading the totals while stale yields the frozen value in
/// O(1) with no session state to lose — not to a snapshot reset,
/// and not by attaching mid-edit. The daemon's write loop keeps
/// the variant off wires negotiated `< 8`.
fn status_facts_msg(
&mut self,
@ -761,32 +912,32 @@ impl SemanticRenderState {
let buf = reg.get(buffer_id).ok()?;
(buf.name().to_owned(), buf.is_modified(), message)
};
let counts = {
let (diag_errors, diag_warnings) = {
let core = state.core.borrow();
buffer_file_uri(&core, buffer_id).and_then(|uri| {
buffer_file_uri(&core, buffer_id).map_or((0, 0), |uri| {
let store = state.lsp_manager.borrow().diag_store();
let guard = store.lock().expect("diag store mutex poisoned");
if guard.is_stale(&uri) {
None // keep the cached counts
} else {
let mut errors = 0u32;
let mut warnings = 0u32;
for d in guard.for_uri(&uri) {
match d.severity {
crate::diag::DiagnosticSeverity::Error => errors += 1,
crate::diag::DiagnosticSeverity::Warning => warnings += 1,
_ => {}
}
}
Some((errors, warnings))
}
// Read even while the store is STALE (round 4):
// `mark_stale` keeps the last published diagnostics
// (T M11.8) — positions are invalid mid-edit, but
// counts merely lag, so the retained totals ARE the
// frozen value. Sourcing the freeze from the store
// rather than any per-session cache means a session
// first rendering during staleness — a late joiner,
// or a buffer first visited mid-edit — reports the
// preserved counts instead of zeros, and the snapshot
// reset has nothing count-related to preserve.
// `DiagnosticStore::set` computes this tuple once
// (round 5). StatusFacts runs at frame cadence for
// every semantic session, so rescanning the retained
// vector here would make stale intervals
// O(frames * diagnostics).
let (errors, warnings, _, _) = guard.severity_counts_for(&uri);
(errors, warnings)
})
};
let cached = self.last_status.get(&buffer_id);
let (diag_errors, diag_warnings) =
counts.unwrap_or_else(|| cached.map_or((0, 0), |c| (c.2, c.3)));
let facts = (name, modified, diag_errors, diag_warnings, message);
if cached == Some(&facts) {
if self.last_status.get(&buffer_id) == Some(&facts) {
return None;
}
let msg = InstanceMessage::StatusFacts {
@ -910,13 +1061,48 @@ impl SemanticRenderState {
// publish without a generation bump — key the cache on the
// diag store's per-URI epoch as well, so a republish
// refreshes the marks and anything else stays suppressed.
// The theme epochs (Q#TH6) join the key so a mid-session
// recolor refreshes the strokes — face_epoch included, since
// `ui.diag.*` feeds the marks.
let diag_epoch = diagnostics_epoch(state, buffer_id);
if self.last_summary.get(&buffer_id).copied() == Some((generation, diag_epoch)) {
let (syntax_epoch, face_epoch) = theme_epochs(state);
// A v15 peer's marks never resolve faces (finding 3), so a
// face mutation cannot change its summary either — zero the
// key component rather than recompute a whole-file pass per
// face edit just to payload-suppress it.
let face_key = if self.peer_knows_theme_facts {
face_epoch
} else {
0
};
let key = (generation, diag_epoch, syntax_epoch, face_key);
if self
.last_summary
.get(&buffer_id)
.is_some_and(|c| c.key == key)
{
return None;
}
let lines = scoped_file_summary(state, buffer_id, self.peer_knows_theme_facts);
// Payload-equality suppression (Q#TH6): a face edit that
// leaves the summary unchanged (e.g. `ui.modeline`) emits
// nothing — but the key still advances, on computation rather
// than emission, or this whole-file pass would repeat every
// tick.
let unchanged = self
.last_summary
.get(&buffer_id)
.is_some_and(|c| c.lines == lines);
self.last_summary.insert(
buffer_id,
SummaryCache {
key,
lines: lines.clone(),
},
);
if unchanged {
return None;
}
let lines = scoped_file_summary(state, buffer_id);
self.last_summary
.insert(buffer_id, (generation, diag_epoch));
Some(InstanceMessage::FileStyleSummary {
buffer_id,
generation,
@ -924,6 +1110,49 @@ impl SemanticRenderState {
})
}
/// The `ThemeFacts` message for this frame, or `None` when the
/// face table is unchanged (themes arc Q#TH7, protocol v16).
/// Resolves the [`UI_FACES`] inventory through
/// [`crate::highlight::Theme::face`] under one lock — resolution
/// is daemon-side; frontends do exact-name lookup, no walk. The
/// `last_face_epoch` gate keeps unchanged ticks to one u64
/// compare; `last_theme_faces` (the frontend's believed table)
/// decides emission. Both advance on computation, and both seed
/// `None`, so every attachment ships exactly one authoritative
/// table — the empty table included — on its first frame.
fn theme_facts_msg(&mut self, state: &EditorState) -> Option<InstanceMessage> {
// PR #120 round 1 finding 3: never even produced for a peer
// below v16 (the daemon write-loop gate remains as the
// belt-and-braces filter).
if !self.peer_knows_theme_facts {
return None;
}
let theme = state.syntax_registry.theme();
let (faces, face_epoch) = {
let th = theme.lock().expect("theme mutex poisoned");
if self.last_face_epoch == Some(th.face_epoch) {
return None;
}
let faces: Vec<crate::protocol::ThemeFace> = UI_FACES
.iter()
.filter_map(|name| {
th.face(name).map(|style| crate::protocol::ThemeFace {
name: (*name).to_owned(),
style,
})
})
.collect();
(faces, th.face_epoch)
};
self.last_face_epoch = Some(face_epoch);
let unchanged = self.last_theme_faces.as_ref() == Some(&faces);
self.last_theme_faces = Some(faces.clone());
if unchanged {
return None;
}
Some(InstanceMessage::ThemeFacts { faces })
}
/// Project the [`Decoration`] set intersecting the declared
/// viewport: the session's selection (instance-authoritative,
/// byte-native) and LSP diagnostics (line/col → byte, severity →
@ -1481,6 +1710,7 @@ fn grammar_style_key(
bundle: handle.current(),
generation,
visible: vp.visible,
syntax_epoch: theme_epochs(state).0,
})
}
@ -1676,7 +1906,11 @@ fn lsp_scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec<Sty
/// `O(spans × lines)` in the worst case; the caller short-circuits on
/// unchanged CRDT generation so this only runs on first sight of a
/// buffer or after an edit, not per frame.
fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
fn scoped_file_summary(
state: &EditorState,
buffer_id: BufferId,
resolve_faces: bool,
) -> Vec<Style> {
let source = {
let core = state.core.borrow();
let registry = core.registry.clone();
@ -1707,7 +1941,7 @@ fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
if spans.is_empty() {
// No styled runs — but diagnostic marks are independent of
// syntax styling (a plain-text buffer can still have lints).
overlay_diagnostic_marks(state, buffer_id, &mut out);
overlay_diagnostic_marks(state, buffer_id, &mut out, resolve_faces);
return out;
}
@ -1740,7 +1974,7 @@ fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
*line_dominant = winner.0;
}
}
overlay_diagnostic_marks(state, buffer_id, &mut out);
overlay_diagnostic_marks(state, buffer_id, &mut out, resolve_faces);
out
}
@ -1752,7 +1986,12 @@ fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
/// describe pre-edit text, same discipline as the decorations
/// producer (the marks return on republish, which bumps the diag
/// epoch and recomputes this summary).
fn overlay_diagnostic_marks(state: &EditorState, buffer_id: BufferId, lines: &mut [Style]) {
fn overlay_diagnostic_marks(
state: &EditorState,
buffer_id: BufferId,
lines: &mut [Style],
resolve_faces: bool,
) {
let uri = {
let core = state.core.borrow();
let Some(uri) = buffer_file_uri(&core, buffer_id) else {
@ -1776,9 +2015,22 @@ fn overlay_diagnostic_marks(state: &EditorState, buffer_id: BufferId, lines: &mu
*slot = Some(slot.map_or(d.severity, |s| s.min(d.severity)));
}
}
// Themes Q#TH5: the mark color is the RESOLVED severity color —
// `ui.diag.*` faces reach the minimap through this summary. The
// diag `Default`-fg policy guarantees a diagnosed line never
// writes `Default` here, which the GPU reads as "no mark".
// `resolve_faces` is false for a peer below v16 (PR #120 round 1
// finding 3): this summary is an ungated pre-v16 channel, and a
// v15 frontend must not get face-derived marks on one surface
// while every other severity surface stays unthemed.
let theme = resolve_faces.then(|| {
let handle = state.syntax_registry.theme();
let t = handle.lock().expect("theme mutex poisoned");
t.clone()
});
for (line, severity) in lines.iter_mut().zip(best) {
if let Some(s) = severity {
line.underline_color = s.underline_color();
line.underline_color = crate::diag::severity_color(theme.as_ref(), s);
}
}
}
@ -1877,10 +2129,256 @@ mod tests {
);
}
/// Pull the `ThemeFacts` table out of a frame, if any.
fn theme_facts_of(msgs: &[InstanceMessage]) -> Option<Vec<crate::protocol::ThemeFace>> {
msgs.iter().find_map(|m| match m {
InstanceMessage::ThemeFacts { faces } => Some(faces.clone()),
_ => None,
})
}
/// Simulate a committed face mutation: what `pmacs.theme.merge`
/// does after its transactional parse (insert + face-epoch bump).
fn merge_face(state: &EditorState, name: &str, style: Style) {
let theme = state.syntax_registry.theme();
let mut th = theme.lock().expect("theme mutex poisoned");
th.insert(name, style);
th.face_epoch += 1;
}
#[test]
fn theme_facts_authoritative_empty_then_silent_then_face_change_emits() {
// Q#TH7: the first frame after viewport declaration ships the
// authoritative table — EMPTY for an unthemed daemon, which
// the Option epoch gate must not short-circuit at 0 == 0 —
// then unchanged ticks say nothing; a face commit re-emits
// the resolved table.
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!(
theme_facts_of(&first),
Some(Vec::new()),
"an unthemed attachment still receives one authoritative empty table"
);
assert_eq!(
theme_facts_of(&s.render_frame(&state)),
None,
"unchanged ticks emit nothing"
);
merge_face(
&state,
"ui.gutter",
Style {
fg: crate::cell::Color::Indexed(99),
..Style::default()
},
);
let facts = theme_facts_of(&s.render_frame(&state)).expect("face change emits");
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].name, "ui.gutter");
assert_eq!(facts[0].style.fg, crate::cell::Color::Indexed(99));
assert_eq!(
theme_facts_of(&s.render_frame(&state)),
None,
"and suppresses again once shipped"
);
}
#[test]
fn theme_facts_resolution_is_daemon_side() {
// Q#TH7 / acceptance 15: with only `ui.diag` set, the shipped
// table carries the four concrete `ui.diag.*` children — the
// walk happens here, never in a frontend.
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 _ = s.render_frame(&state);
merge_face(
&state,
"ui.diag",
Style {
fg: crate::cell::Color::Indexed(93),
..Style::default()
},
);
let facts = theme_facts_of(&s.render_frame(&state)).expect("emits");
let names: Vec<&str> = facts.iter().map(|f| f.name.as_str()).collect();
assert_eq!(
names,
[
"ui.diag.error",
"ui.diag.hint",
"ui.diag.info",
"ui.diag.warning"
],
"only the concrete stage-1 children ship, sorted"
);
assert!(
facts
.iter()
.all(|f| f.style.fg == crate::cell::Color::Indexed(93)),
"each child resolved through the parent"
);
}
#[test]
fn theme_facts_identical_rebuild_advances_epoch_without_emitting() {
// Q#TH7 / acceptance 14: an epoch bump with an unchanged
// table (an identical re-merge) emits nothing but still
// records the inspected epoch — the cache advances on
// computation, or every subsequent tick would rebuild.
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 _ = s.render_frame(&state);
let bumped = {
let theme = state.syntax_registry.theme();
let mut th = theme.lock().expect("lock");
th.face_epoch += 1; // identical re-merge: no table change
th.face_epoch
};
assert_eq!(
theme_facts_of(&s.render_frame(&state)),
None,
"identical rebuild is suppressed"
);
assert_eq!(
s.last_face_epoch,
Some(bumped),
"the inspected epoch advanced despite the suppressed send"
);
}
#[test]
fn summary_cache_key_advances_on_suppressed_emission() {
// Q#TH6 / acceptance 14: a face mutation that leaves the
// summary unchanged (ui.modeline touches no minimap stroke)
// recomputes once, emits nothing, and STILL advances the
// cache key — otherwise the whole-file pass repeats per tick.
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!(
first
.iter()
.any(|m| matches!(m, InstanceMessage::FileStyleSummary { .. })),
"first frame ships the summary"
);
merge_face(&state, "ui.modeline", Style::default());
let (_, _, _, face_epoch) = {
let theme = state.syntax_registry.theme();
let th = theme.lock().expect("lock");
(0, 0, th.syntax_epoch, th.face_epoch)
};
let next = s.render_frame(&state);
assert!(
!next
.iter()
.any(|m| matches!(m, InstanceMessage::FileStyleSummary { .. })),
"an unchanged summary is suppressed"
);
assert_eq!(
s.last_summary
.get(&buffer_id)
.expect("cache entry exists")
.key
.3,
face_epoch,
"the cache key advanced despite the suppressed send"
);
}
#[test]
fn snapshot_reset_drops_one_buffers_baselines_and_keeps_the_rest() {
// PR #120 round 2 finding 1 — the reset contract's scope: a
// `BufferSnapshot` for buffer A kills A's buffer-scoped
// emission baselines (here: summary and status, the two the
// A → B → A round trip visibly strands) while OTHER buffers'
// baselines and the bufferless ThemeFacts pair survive.
let state = empty_state();
let mut s = local();
let a = active_buffer(&state);
let b = {
let core = state.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.create("other")
};
s.set_viewport(a, ByteRange { start: 0, end: 64 }, 0);
let first = s.render_frame(&state);
assert!(
first
.iter()
.any(|m| matches!(m, InstanceMessage::FileStyleSummary { .. })),
"first frame ships A's summary"
);
s.set_viewport(b, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state);
assert!(s.last_summary.contains_key(&a));
assert!(s.last_summary.contains_key(&b));
assert!(s.last_status.contains_key(&a));
let facts_baseline = s.last_theme_faces.clone();
assert!(facts_baseline.is_some(), "first frame shipped ThemeFacts");
s.on_buffer_snapshot_sent(a);
assert!(
!s.last_summary.contains_key(&a) && !s.last_status.contains_key(&a),
"A's baselines die with A's snapshot"
);
assert!(
s.last_summary.contains_key(&b),
"B's baselines survive A's snapshot"
);
assert_eq!(
s.last_theme_faces, facts_baseline,
"ThemeFacts is bufferless — the face table survives snapshots"
);
// And the behavioral consequence: revisiting A at the SAME
// generation re-ships the summary the frontend just dropped.
s.set_viewport(a, ByteRange { start: 0, end: 64 }, 0);
let back = s.render_frame(&state);
assert!(
back.iter()
.any(|m| matches!(m, InstanceMessage::FileStyleSummary { .. })),
"the unchanged-generation revisit re-ships A's summary"
);
}
#[test]
fn style_gate_differs_when_syntax_epoch_bumps() {
// Q#TH6: the gate is a pure function of the theme too — a
// syntax-epoch bump must force the span recompute (the
// pre-existing mid-session staleness bug).
let g1 = StyleGate {
bundle: None,
generation: 1,
visible: ByteRange { start: 0, end: 10 },
syntax_epoch: 0,
};
let mut g2 = g1.clone();
assert!(g1.matches(&g2), "identical gates match");
g2.syntax_epoch = 1;
assert!(!g1.matches(&g2), "a theme mutation breaks the match");
}
/// All `InstanceMessage` variants the semantic projection may
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
/// `FileStyleSummary`, `StatusFacts` (Q#S1), or `SearchPrompt`
/// (Q#SR5) — never `CellDelta`, grid `Cursor`, or the still-unwired
/// `FileStyleSummary`, `StatusFacts` (Q#S1), `SearchPrompt`
/// (Q#SR5), `LineNumbers`, or `ThemeFacts` (Q#TH7) — never
/// `CellDelta`, grid `Cursor`, or the still-unwired
/// `BlockAdornments` / `FoldState` families.
fn assert_semantic_only(msgs: &[InstanceMessage]) {
for m in msgs {
@ -1894,6 +2392,7 @@ mod tests {
| InstanceMessage::StatusFacts { .. }
| InstanceMessage::SearchPrompt { .. }
| InstanceMessage::LineNumbers { .. }
| InstanceMessage::ThemeFacts { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);
@ -2063,12 +2562,14 @@ 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).
// StatusFacts (Q#S1, cached-compare) and the authoritative
// ThemeFacts table (Q#TH7 — empty for an unthemed daemon).
let first = s.render_frame(&state);
assert_eq!(
first.len(),
4,
"first frame ships StyleSpans + Decorations + FileStyleSummary + StatusFacts"
5,
"first frame ships StyleSpans + Decorations + FileStyleSummary \
+ StatusFacts + ThemeFacts"
);
assert_semantic_only(&first);
let (style_full, _) = style_segments(&first).expect("StyleSpans present");

File diff suppressed because it is too large Load Diff