feat(themes): named UI faces + ThemeFacts channel (protocol v16)

Arc 4 stage 1 (docs/theme-faces-framing.md, revision 4). Faces are
theme entries under the reserved ui/ui.* namespace -- zero new Lua
API. Theme::face() resolves with the dotted-prefix walk but never
falls back to default_style; each face applies owns-surface within
its stage-1 component mask, identical on both frontends.

Substrate: two monotonic theme mutation counters (syntax/face) with
transactional set/merge/clear/default (parse before locking, commit
all-or-nothing, bump from the prior value); the StyleGate and the
minimap summary key on the counters -- fixing the pre-existing bug
where a mid-session pmacs.theme.set never re-shipped StyleSpans --
with the summary gaining payload-equality suppression that still
advances its key on computation.

Wire: InstanceMessage::ThemeFacts appended after CompletionPopup
(postcard discriminants are ordinal; a byte pin guards placement),
PROTOCOL_VERSION 15 -> 16, daemon-gated >= 16, one authoritative
table per attachment (None-seeded baselines), TUI silent-drop arm.

Grid: paint_frame resolves ui.modeline / ui.statusline /
ui.minibuffer(.candidate) / ui.gutter / ui.selection faces;
SearchView and DiagnosticView take the theme handle through the real
attachment paths (EditorCore injection, install_diag threading); the
canonical severity color resolves ui.diag.* with the Default ->
built-in policy that keeps the minimap presence encoding sound.

GPU: exact-name face table applied per draw with the Q#TH5 Default
mapping (plain text / window bg, reverse swap), local/peer wash
split, candidate-dropdown glyph site, and the status-band
shaping-cache invalidation without which a diag-face recolor with
constant counts kept stale counter colors.

Tests: 18-test acceptance suite (grid, wire, daemon gate, atomicity,
monotonicity, late join), 7 GPU headless tests incl. decoded vertex
colors, units for the face walk / transactional commits / producer
caches; protocol pins for v16 + the CompletionPopup byte pin.
Bites vs 3cbb9de (scripts/bite): semantic_render.rs (8 runtime test
failures), editor.rs (5 runtime), daemon.rs (v15 gate, runtime);
lua_bindings/mod.rs, pmacs-gpu/main.rs, search.rs, diag.rs, and
highlight.rs bite as compile failures (weaker evidence, disclosed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
Levi Neuwirth 2026-07-14 16:11:09 +01:00
parent 6ce1532699
commit 7975eeda87
16 changed files with 2884 additions and 161 deletions

View File

@ -205,6 +205,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

View File

@ -678,6 +678,14 @@ struct State {
gutter_buffer: Buffer,
/// Dedicated renderer for the gutter number layer (like the menu / mb).
gutter_text_renderer: TextRenderer,
/// The daemon-resolved UI face table (themes arc Q#TH7, protocol
/// v16). Exact-name lookup only — inheritance is resolved
/// daemon-side, so the frontend never walks. Complete replacement
/// per `ThemeFacts`; a face absent from the map means "use the
/// site's hardcoded default". Applied per draw through the
/// `face_fg_or` / `face_wash_or` / `modeline_face_colors` /
/// `diag_face_rgba` resolvers (Q#TH5 mask + `Default` mapping).
faces: HashMap<String, CellStyle>,
}
/// Kind-glyph column for a completion row: the LSP
@ -2019,6 +2027,7 @@ impl State {
completion_bg_vertex_buffer: ReusableVertexBuffer::new(),
minimap_cache: None,
line_numbers: LineNumberMode::Off,
faces: HashMap::new(),
gutter_buffer,
gutter_text_renderer,
}
@ -2690,6 +2699,21 @@ impl State {
}
None
}
// Themes Q#TH7 (protocol v16): the daemon-resolved UI face
// table — complete replacement each send. The status-band
// shaping cache MUST be invalidated here (Q#TH8): the
// E:/W: counter colors are baked into glyphon rich-text
// attributes at compose time and `refresh_status_line`
// skips re-shaping while the composed strings are
// unchanged, so a diag-face change with constant counts
// would keep stale colors indefinitely without this.
InstanceMessage::ThemeFacts { faces } => {
self.faces = faces.into_iter().map(|f| (f.name, f.style)).collect();
self.status_text.clear();
self.status_left_text.clear();
self.request_redraw();
None
}
// Q#SR5 / Q#RX6 — the live isearch prompt (protocol v10).
// `query: None` clears the band (search ended); `Some` shows
// `[Regex] I-search: <query> (n/m)` on the band's left side.
@ -3382,6 +3406,134 @@ impl State {
self.request_redraw();
}
// -----------------------------------------------------------------
// Themes (Q#TH5/Q#TH7/Q#TH8): UI-face resolution. Faces arrive
// daemon-resolved over `ThemeFacts`; lookups are exact-name. A set
// face owns its surface within its stage-1 mask, and a `Default`
// component inside the mask maps to the frontend's PLAIN rendering
// — the buffer-text default fg / the window-background bg — never
// the old chrome constant. An UNSET face keeps the site constant.
// -----------------------------------------------------------------
/// fg resolution for an {fg}-mask site: face set → its fg
/// (`Default` ↦ the plain text color); unset → `fallback`
/// (today's site constant).
fn face_fg_or(&self, name: &str, fallback: Color) -> Color {
match self.faces.get(name) {
Some(f) => cell_color_to_glyphon(f.fg).unwrap_or_else(plain_text_color),
None => fallback,
}
}
/// Wash resolution for a {bg}-mask face: face set → its bg RGB
/// carrying the site's current alpha (`Default` bg ↦ no wash — a
/// fully transparent quad); unset → `fallback` (today's wash
/// constant, alpha included).
fn face_wash_or(&self, name: &str, fallback: [f32; 4]) -> [f32; 4] {
match self.faces.get(name) {
Some(f) => match cell_color_to_glyphon(f.bg) {
Some(c) => glyphon_to_rgba(c, fallback[3]),
None => [0.0, 0.0, 0.0, 0.0],
},
None => fallback,
}
}
/// `Some((band quad rgba, band text color))` when `ui.modeline`
/// is set — mask {fg, bg, reverse}: `Default` bg ↦ the window
/// background (an untinted band), `Default` fg ↦ the plain text
/// color, `reverse` swaps the two after mapping. `None` when
/// unset: each band site keeps its own constant.
fn modeline_face_colors(&self) -> Option<([f32; 4], Color)> {
let f = self.faces.get("ui.modeline")?;
let text = cell_color_to_glyphon(f.fg).unwrap_or_else(plain_text_color);
let quad = match cell_color_to_glyphon(f.bg) {
Some(c) => glyphon_to_rgba(c, 1.0),
None => WINDOW_BG_RGBA,
};
Some(if f.reverse {
(glyphon_to_rgba(text, 1.0), rgba_to_glyphon(quad))
} else {
(quad, text)
})
}
/// Diag-family TEXT color (Q#TH5 policy): the `ui.diag.*` face's
/// fg when set with a concrete color, else `fallback` (the
/// built-in severity constant). Unlike [`Self::face_fg_or`], a
/// set face's `Default` fg maps to the BUILT-IN color, never
/// plain — the severity color doubles as the minimap presence
/// encoding, so a plain severity is unrepresentable.
fn diag_face_fg_or(&self, name: &str, fallback: Color) -> Color {
self.faces
.get(name)
.and_then(|f| cell_color_to_glyphon(f.fg))
.unwrap_or(fallback)
}
/// Diag-family quad color — [`Self::diag_face_fg_or`]'s rgba
/// twin, keyed by decoration kind.
fn diag_face_rgba(&self, kind: DecorationKind, fallback: [f32; 4]) -> [f32; 4] {
let name = match kind {
DecorationKind::DiagnosticError => "ui.diag.error",
DecorationKind::DiagnosticWarning => "ui.diag.warning",
DecorationKind::DiagnosticInfo => "ui.diag.info",
DecorationKind::DiagnosticHint => "ui.diag.hint",
_ => return fallback,
};
match self
.faces
.get(name)
.and_then(|f| cell_color_to_glyphon(f.fg))
{
Some(c) => glyphon_to_rgba(c, 1.0),
None => fallback,
}
}
/// The OWN-window wash color for a background decoration kind:
/// the local selection and search washes resolve their faces;
/// peer rects (`collect_peer_rects`) deliberately keep the
/// constants — peer theming rides the deferred peer-cursor
/// palette arc (Q#TH5, round 2 finding 9).
fn own_wash_color(&self, kind: DecorationKind) -> Option<[f32; 4]> {
let fallback = decoration_kind_to_bg_color(kind)?;
let name = match kind {
DecorationKind::Selection => "ui.selection",
DecorationKind::SearchMatch => "ui.search.match",
DecorationKind::SearchMatchActive => "ui.search.match.active",
_ => return Some(fallback),
};
Some(self.face_wash_or(name, fallback))
}
/// The band's left-segment text color, mirroring
/// [`Self::compose_status_left`]'s priority: minibuffer/isearch
/// content follows `ui.minibuffer`, a transient message follows
/// `ui.statusline`, and the buffer name follows `ui.modeline`
/// (the framing's content-class applicability, Q#TH3).
fn status_left_color(&self) -> Color {
const LEFT_DEFAULT: (u8, u8, u8) = (200, 200, 210);
let fallback = Color::rgb(LEFT_DEFAULT.0, LEFT_DEFAULT.1, LEFT_DEFAULT.2);
if self.minibuffer.is_some()
|| self
.search_prompt
.as_ref()
.is_some_and(|s| Some(s.buffer_id) == self.current_buffer_id)
{
return self.face_fg_or("ui.minibuffer", fallback);
}
let has_message = self
.status_facts
.as_ref()
.filter(|f| Some(f.buffer_id) == self.current_buffer_id)
.is_some_and(|f| f.message.is_some());
if has_message {
return self.face_fg_or("ui.statusline", fallback);
}
self.modeline_face_colors().map_or(fallback, |(_, t)| t)
}
/// Compose the status-band readout (Q#S1): diagnostic counts
/// (wire-authoritative, severity-colored, omitted when zero),
/// then cursor L:C from the *optimistic* caret (so it tracks
@ -3396,15 +3548,19 @@ impl State {
.filter(|f| Some(f.buffer_id) == self.current_buffer_id)
{
if facts.diag_errors > 0 {
// Themes Q#TH5: the counters follow the diag faces
// (fg mask; the shaping-cache invalidation in the
// ThemeFacts arm makes a recolor with constant counts
// actually re-shape, Q#TH8).
spans.push((
format!("E:{}", facts.diag_errors),
Some(Color::rgb(241, 76, 76)),
Some(self.diag_face_fg_or("ui.diag.error", Color::rgb(241, 76, 76))),
));
}
if facts.diag_warnings > 0 {
spans.push((
format!("W:{}", facts.diag_warnings),
Some(Color::rgb(245, 245, 67)),
Some(self.diag_face_fg_or("ui.diag.warning", Color::rgb(245, 245, 67))),
));
}
}
@ -3548,12 +3704,16 @@ impl State {
/// The status band's background quad (Q#S2): a full-width strip
/// under the band text.
fn status_band_vertex_bytes(&self) -> Vec<u8> {
// Themes Q#TH5: a set ui.modeline face owns the band surface.
let color = self
.modeline_face_colors()
.map_or(STATUS_BAND_BG, |(quad, _)| quad);
let rect = MinimapRect {
x: 0.0,
y: text_area_bottom(self.config.height),
w: self.config.width as f32,
h: STATUS_BAND_HEIGHT,
color: STATUS_BAND_BG,
color,
};
rects_to_vertex_bytes(&[rect], self.config.width, self.config.height)
}
@ -4457,6 +4617,14 @@ impl State {
} else {
0
};
// Themes Q#TH5/Q#TH9: resolve the face-driven colors before
// the prepare call — its `&mut self.*` field borrows preclude
// method calls on `self` inside the argument list.
let readout_color = self
.modeline_face_colors()
.map_or(Color::rgb(168, 168, 180), |(_, text)| text);
let left_color = self.status_left_color();
let gutter_color = self.face_fg_or("ui.gutter", Color::rgb(120, 120, 135));
self.text_renderer
.prepare(
&self.device,
@ -4493,7 +4661,10 @@ impl State {
right: self.config.width.cast_signed(),
bottom: self.config.height.cast_signed(),
},
default_color: Color::rgb(168, 168, 180),
// Themes Q#TH5: a set ui.modeline face colors
// the readout too (its fg after the reverse
// swap); unset keeps the dimmer gray.
default_color: readout_color,
custom_glyphs: &[],
},
TextArea {
@ -4508,7 +4679,11 @@ impl State {
right: (status_left - STATUS_TEXT_PAD).max(0.0).round() as i32,
bottom: self.config.height.cast_signed(),
},
default_color: Color::rgb(200, 200, 210),
// Themes Q#TH3: the left segment's face follows
// its CONTENT class (minibuffer/isearch →
// ui.minibuffer; message → ui.statusline; name
// → ui.modeline).
default_color: left_color,
custom_glyphs: &[],
},
],
@ -4531,7 +4706,8 @@ impl State {
right: gutter_clip_left,
bottom: text_area_bottom(self.config.height).round() as i32,
},
default_color: Color::rgb(120, 120, 135),
// Themes Q#TH5: ui.gutter's {fg} mask colors the digits.
default_color: gutter_color,
custom_glyphs: &[],
}]
} else {
@ -4591,6 +4767,8 @@ impl State {
// by `first` rows so line `first` lands at `top_y`, and the
// existing `bounds.top`/`bottom` clip the rows scrolled out of the
// visible window (no per-resize re-shape needed).
// Hoisted for the same borrow reason as the band colors above.
let candidate_color = self.face_fg_or("ui.minibuffer.candidate", Color::rgb(232, 232, 238));
let mb_areas: Vec<TextArea> = self
.mb_visible_window()
.zip(self.mb_dropdown_rect())
@ -4605,7 +4783,10 @@ impl State {
right: (x + width).round() as i32,
bottom: text_area_bottom(self.config.height).round() as i32,
},
default_color: Color::rgb(232, 232, 238),
// Themes Q#TH5 (round 3 finding 1): the candidate
// glyph layer is ui.minibuffer.candidate's GPU site;
// the popup bg/selection quads stay chrome constants.
default_color: candidate_color,
custom_glyphs: &[],
})
.into_iter()
@ -4864,7 +5045,9 @@ impl State {
if best.is_none_or(|(r, _)| rank < r)
&& let Some(color) = decoration_kind_to_underline_color(d.kind)
{
best = Some((rank, color));
// Themes Q#TH5: gutter signs share the resolved
// severity color with the squiggles.
best = Some((rank, self.diag_face_rgba(d.kind, color)));
}
}
if let Some((_, color)) = best {
@ -4902,8 +5085,9 @@ impl State {
};
// Diagnostic underlines are squiggles now, drawn by their
// own pipeline (`squiggle_vertex_bytes`); only the solid
// washes belong in this quad batch.
if let Some(color) = decoration_kind_to_bg_color(d.kind) {
// washes belong in this quad batch. Own washes resolve
// their theme faces (Q#TH5); peers keep the constants.
if let Some(color) = self.own_wash_color(d.kind) {
self.push_glyph_extent_rects(rects, line_offsets, lo, hi, color, None);
}
}
@ -4929,6 +5113,9 @@ impl State {
let Some(color) = decoration_kind_to_underline_color(d.kind) else {
continue;
};
// Themes Q#TH5: squiggles follow the ui.diag.* faces
// (fg mask, Default ↦ the built-in severity constant).
let color = self.diag_face_rgba(d.kind, color);
if let Some((lo, hi)) = clip_rebase_range(d.range.start, d.range.end, vstart, vend) {
self.push_glyph_extent_rects(
&mut rects,
@ -5627,6 +5814,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::DispatchIdle { .. } => "DispatchIdle",
InstanceMessage::LineNumbers { .. } => "LineNumbers",
InstanceMessage::CompletionPopup { .. } => "CompletionPopup",
InstanceMessage::ThemeFacts { .. } => "ThemeFacts",
}
}
@ -6545,6 +6733,45 @@ fn cell_color_to_glyphon(c: CellColor) -> Option<glyphon::Color> {
}
}
/// The GPU's "plain" text color — the buffer-text default at
/// `main.rs`'s primary `TextArea`. The Q#TH5 mapping target for a
/// set face's `Default` fg.
fn plain_text_color() -> glyphon::Color {
glyphon::Color::rgb(230, 230, 235)
}
/// The window clear color as a quad rgba — the Q#TH5 mapping target
/// for a set face's `Default` bg (an untinted surface). Must equal
/// [`BG`].
const WINDOW_BG_RGBA: [f32; 4] = [0.05, 0.05, 0.07, 1.0];
/// glyphon (u8) → quad (f32) color, carrying `alpha`. Divides by 255
/// — the same space every existing float constant uses (e.g. the
/// error squiggle `[0.945, …]` is exactly `rgb(241, 76, 76) / 255`).
fn glyphon_to_rgba(c: glyphon::Color, alpha: f32) -> [f32; 4] {
[
f32::from(c.r()) / 255.0,
f32::from(c.g()) / 255.0,
f32::from(c.b()) / 255.0,
alpha,
]
}
/// quad (f32) → glyphon (u8) color (alpha dropped) — the `reverse`
/// swap's other direction.
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "inputs are 0.0..=1.0 quad colors by construction"
)]
fn rgba_to_glyphon(c: [f32; 4]) -> glyphon::Color {
glyphon::Color::rgb(
(c[0] * 255.0) as u8,
(c[1] * 255.0) as u8,
(c[2] * 255.0) as u8,
)
}
/// Standard xterm-style 256-color palette: 16 base colors + 6×6×6
/// RGB cube (16..=231) + 24-step grayscale (232..=255). Values
/// pulled from the conventional xterm defaults; the 6×6×6 cube uses
@ -8097,4 +8324,441 @@ mod tests {
"a wide window keeps the gutter"
);
}
// -----------------------------------------------------------------
// Themes (Q#TH5/Q#TH7/Q#TH8): GPU face application.
// -----------------------------------------------------------------
fn theme_face(name: &str, style: CellStyle) -> pmacs_protocol::ThemeFace {
pmacs_protocol::ThemeFace {
name: name.into(),
style,
}
}
fn apply_faces(state: &mut State, faces: Vec<pmacs_protocol::ThemeFace>) {
let _ = state.apply_attach_message(InstanceMessage::ThemeFacts { faces });
}
fn px_at(px: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] {
let i = ((y * width + x) * 4) as usize;
[px[i], px[i + 1], px[i + 2], px[i + 3]]
}
#[test]
fn headless_theme_facts_empty_table_renders_identically() {
// Acceptance 20: the authoritative empty table (an unthemed
// daemon's first send) must change nothing.
let Some(mut state) = headless_or_skip(320, 240, "fn main() {}") else {
return;
};
let base = state.render_offscreen();
apply_faces(&mut state, Vec::new());
let themed = state.render_offscreen();
assert_eq!(base, themed, "an empty face table must be a no-op");
}
#[test]
fn headless_modeline_face_owns_the_band_and_reverse_swaps() {
// Acceptance 20: ui.modeline bg retints the band quad, and
// reverse swaps quad/text after the Default mapping. Sampled
// at a band pixel left of the text pad — comparing two states
// whose (fg, bg, reverse) SHOULD produce the same quad avoids
// any dependence on the surface's exact color space.
let (w, h) = (400u32, 300u32);
let fg = CellColor::Rgb(200, 30, 30);
let bg = CellColor::Rgb(10, 60, 110);
let Some(mut plain) = headless_or_skip(w, h, "hello") else {
return;
};
let plain_sample = px_at(&plain.render_offscreen(), w, 2, h - 2);
apply_faces(
&mut plain,
vec![theme_face(
"ui.modeline",
CellStyle {
fg,
bg,
..CellStyle::default()
},
)],
);
let tinted_sample = px_at(&plain.render_offscreen(), w, 2, h - 2);
assert_ne!(
plain_sample, tinted_sample,
"a bg face must retint the band quad"
);
// Swapped face + reverse ⇒ the identical quad color…
apply_faces(
&mut plain,
vec![theme_face(
"ui.modeline",
CellStyle {
fg: bg,
bg: fg,
reverse: true,
..CellStyle::default()
},
)],
);
let swapped_sample = px_at(&plain.render_offscreen(), w, 2, h - 2);
assert_eq!(
tinted_sample, swapped_sample,
"reverse must swap fg/bg after mapping (same quad both ways)"
);
// …while reverse on the ORIGINAL face tints the quad with fg.
apply_faces(
&mut plain,
vec![theme_face(
"ui.modeline",
CellStyle {
fg,
bg,
reverse: true,
..CellStyle::default()
},
)],
);
let reversed_sample = px_at(&plain.render_offscreen(), w, 2, h - 2);
assert_ne!(
tinted_sample, reversed_sample,
"reverse must move the quad off the bg color"
);
}
#[test]
fn headless_gutter_face_recolors_only_the_gutter_region() {
// Acceptance 20: a ui.gutter fg change alters the gutter
// digits and nothing else — every differing pixel lies left
// of the text origin and above the band.
let (w, h) = (400u32, 300u32);
let text = "alpha\nbeta\ngamma\ndelta\n";
let Some(mut state) = headless_or_skip(w, h, text) else {
return;
};
state.line_numbers = LineNumberMode::Absolute;
let text_left = state.text_left().ceil() as u32;
let band_top = text_area_bottom(h).floor() as u32;
let base = state.render_offscreen();
apply_faces(
&mut state,
vec![theme_face(
"ui.gutter",
CellStyle {
fg: CellColor::Rgb(220, 120, 40),
..CellStyle::default()
},
)],
);
let themed = state.render_offscreen();
let mut differing = 0usize;
for (i, (a, b)) in base.iter().zip(&themed).enumerate() {
if a != b {
differing += 1;
let pixel = (i / 4) as u32;
let (x, y) = (pixel % w, pixel / w);
assert!(
x < text_left && y < band_top,
"gutter face leaked outside the gutter region at ({x}, {y})"
);
}
}
assert!(differing > 20, "the digits must actually recolor");
}
#[test]
fn headless_band_text_faces_follow_content_class() {
// Acceptance 20: with the composed strings held constant,
// ui.statusline recolors a transient message, ui.minibuffer
// recolors a live minibuffer and an isearch band — and each
// face is a NO-OP on the content classes it doesn't own.
let (w, h) = (400u32, 300u32);
let Some(mut state) = headless_or_skip(w, h, "hello") else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
let statusline_face = vec![theme_face(
"ui.statusline",
CellStyle {
fg: CellColor::Rgb(240, 140, 40),
..CellStyle::default()
},
)];
let minibuffer_face = vec![theme_face(
"ui.minibuffer",
CellStyle {
fg: CellColor::Rgb(40, 220, 140),
..CellStyle::default()
},
)];
// (a) Buffer name showing: ui.statusline must not repaint it.
state.status_facts = Some(StatusFactsLocal {
buffer_id: bid,
name: "main.rs".into(),
modified: false,
diag_errors: 0,
diag_warnings: 0,
message: None,
});
let name_base = state.render_offscreen();
apply_faces(&mut state, statusline_face.clone());
assert_eq!(
name_base,
state.render_offscreen(),
"ui.statusline must not color the buffer-name class"
);
apply_faces(&mut state, Vec::new());
// (b) Transient message showing: ui.statusline recolors it.
state.status_facts = Some(StatusFactsLocal {
buffer_id: bid,
name: "main.rs".into(),
modified: false,
diag_errors: 0,
diag_warnings: 0,
message: Some("12 references".into()),
});
let msg_base = state.render_offscreen();
apply_faces(&mut state, statusline_face);
assert_ne!(
msg_base,
state.render_offscreen(),
"ui.statusline must recolor the transient message"
);
apply_faces(&mut state, Vec::new());
// (c) Live minibuffer: ui.minibuffer recolors the band text.
state.minibuffer = Some(MinibufferLocal {
prompt: "M-x ".into(),
input: "theme".into(),
cursor: 5,
candidates: Vec::new(),
selected: None,
total: 0,
});
let mb_base = state.render_offscreen();
apply_faces(&mut state, minibuffer_face.clone());
assert_ne!(
mb_base,
state.render_offscreen(),
"ui.minibuffer must recolor the live minibuffer"
);
apply_faces(&mut state, Vec::new());
state.minibuffer = None;
// (d) Isearch band: same face, same route.
state.search_prompt = Some(SearchPromptLocal {
buffer_id: bid,
query: "needle".into(),
active: Some(0),
total: 2,
regex: false,
invalid: false,
});
let isearch_base = state.render_offscreen();
apply_faces(&mut state, minibuffer_face);
assert_ne!(
isearch_base,
state.render_offscreen(),
"ui.minibuffer must recolor the isearch band"
);
}
#[test]
fn headless_candidate_face_recolors_the_dropdown_glyphs() {
// Acceptance 20 (round 3 finding 1): ui.minibuffer.candidate's
// GPU site is the dropdown's candidate glyph layer.
let (w, h) = (400u32, 300u32);
let Some(mut state) = headless_or_skip(w, h, "hello") else {
return;
};
state.minibuffer = Some(MinibufferLocal {
prompt: "M-x ".into(),
input: "the".into(),
cursor: 3,
candidates: vec!["theme-set".into(), "theme-clear".into()],
selected: Some(0),
total: 2,
});
let base = state.render_offscreen();
apply_faces(
&mut state,
vec![theme_face(
"ui.minibuffer.candidate",
CellStyle {
fg: CellColor::Rgb(250, 80, 160),
..CellStyle::default()
},
)],
);
assert_ne!(
base,
state.render_offscreen(),
"the candidate glyphs must recolor"
);
}
#[test]
fn headless_diag_face_recolors_band_counter_despite_unchanged_text() {
// Acceptance 22 — the round-1 finding-3 bite. The E: counter
// color is baked into shaped rich text and the composed string
// is CONSTANT here, so this fails without the ThemeFacts arm's
// shaping-cache invalidation (Q#TH8). The empty diag child is
// the built-in reset (Q#TH5): identical to unthemed.
let (w, h) = (400u32, 300u32);
let Some(mut state) = headless_or_skip(w, h, "alpha\nbeta\n") else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.view_range = (0, state.current_text.len() as u64);
state.status_facts = Some(StatusFactsLocal {
buffer_id: bid,
name: "main.rs".into(),
modified: false,
diag_errors: 2,
diag_warnings: 0,
message: None,
});
state.current_decorations.push(Decoration {
range: ByteRange { start: 0, end: 5 },
kind: DecorationKind::DiagnosticError,
});
let base = state.render_offscreen();
apply_faces(
&mut state,
vec![theme_face(
"ui.diag.error",
CellStyle {
fg: CellColor::Rgb(40, 200, 255),
..CellStyle::default()
},
)],
);
assert_ne!(
base,
state.render_offscreen(),
"the counter (and squiggle) must recolor with counts constant"
);
// An all-default diag child resets to the built-in color.
apply_faces(
&mut state,
vec![theme_face("ui.diag.error", CellStyle::default())],
);
assert_eq!(
base,
state.render_offscreen(),
"ui.diag.error = {{}} must render as the built-in severity color"
);
}
#[test]
fn own_wash_faces_color_local_rects_peers_keep_the_constant() {
// Acceptance 21 + 23: decode the emitted decoration vertex
// colors — the LOCAL selection and both search washes resolve
// their faces (site alpha preserved), while simultaneous PEER
// selection/current-line rects keep the hardcoded constants.
fn decode_quad_colors(bytes: &[u8]) -> Vec<[f32; 4]> {
bytes
.chunks_exact(24)
.map(|v| {
let f = |i: usize| {
f32::from_ne_bytes(v[i * 4..i * 4 + 4].try_into().expect("4 bytes"))
};
[f(2), f(3), f(4), f(5)]
})
.collect()
}
let text = "alpha\nbeta\ngamma\ndelta\n";
let Some(mut state) = headless_or_skip(400, 300, text) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.view_range = (0, text.len() as u64);
state.current_decorations.extend([
Decoration {
range: ByteRange { start: 0, end: 5 }, // "alpha"
kind: DecorationKind::Selection,
},
Decoration {
range: ByteRange { start: 6, end: 10 }, // "beta"
kind: DecorationKind::SearchMatch,
},
Decoration {
range: ByteRange { start: 11, end: 16 }, // "gamma"
kind: DecorationKind::SearchMatchActive,
},
]);
state.peer_presences.insert(
FrontendId(7),
PeerPresence {
buffer_id: bid,
cursor: 17,
selection: Some(SelectionSnapshot {
anchor: 17,
active: 22, // "delta"
}),
},
);
apply_faces(
&mut state,
vec![
theme_face(
"ui.selection",
CellStyle {
bg: CellColor::Rgb(9, 99, 199),
..CellStyle::default()
},
),
theme_face(
"ui.search.match",
CellStyle {
bg: CellColor::Rgb(10, 20, 30),
..CellStyle::default()
},
),
theme_face(
"ui.search.match.active",
CellStyle {
bg: CellColor::Rgb(40, 50, 60),
..CellStyle::default()
},
),
],
);
let colors = decode_quad_colors(&state.decoration_background_vertex_bytes());
// Exact float equality is deliberate: both sides derive from
// the same constants through the same arithmetic.
let has = |c: [f32; 4]| colors.contains(&c);
// Local rects: face RGB with each site's original alpha.
assert!(
has(glyphon_to_rgba(glyphon::Color::rgb(9, 99, 199), 0.30)),
"local selection must use ui.selection"
);
assert!(
has(glyphon_to_rgba(glyphon::Color::rgb(10, 20, 30), 0.30)),
"search match must use ui.search.match"
);
assert!(
has(glyphon_to_rgba(glyphon::Color::rgb(40, 50, 60), 0.48)),
"active match must use ui.search.match.active"
);
// Peer rects: the hardcoded constants, face table ignored.
assert!(
has([0.31, 0.42, 0.82, 0.30]),
"the peer selection must keep the Selection constant"
);
assert!(
has([0.55, 0.60, 0.75, 0.22]),
"the peer cursor line must keep the CurrentLine constant"
);
}
}

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

@ -1125,6 +1125,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 +1165,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.
//

View File

@ -363,63 +363,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 +434,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 +510,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 +595,7 @@ impl View for DiagnosticView {
viewport.gutter_w,
max_cols,
&line_markers,
theme.as_ref(),
);
}
}
@ -597,9 +613,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 +625,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));
}
}
}
@ -921,7 +939,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 +977,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 +1002,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 +1064,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 +1141,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 +1209,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;
@ -6895,6 +6895,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 +6980,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 +6990,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 +7013,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 +7028,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 +8693,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 +12188,135 @@ 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();
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!(
(
theme.lock().expect("lock").syntax_epoch,
theme.lock().expect("lock").face_epoch
),
(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!(
(
theme.lock().expect("lock").syntax_epoch,
theme.lock().expect("lock").face_epoch
),
(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!(
(
theme.lock().expect("lock").syntax_epoch,
theme.lock().expect("lock").face_epoch
),
(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!(
(
theme.lock().expect("lock").syntax_epoch,
theme.lock().expect("lock").face_epoch
),
(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,11 +160,19 @@ 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.
@ -193,11 +201,27 @@ 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>>,
/// 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 +239,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 +291,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 +303,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,
@ -268,6 +335,12 @@ 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,
diag_line_cache: HashMap::new(),
}
}
@ -475,6 +548,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
}
@ -910,13 +985,39 @@ 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);
let key = (generation, diag_epoch, syntax_epoch, face_epoch);
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.last_summary
.insert(buffer_id, (generation, diag_epoch));
// 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;
}
Some(InstanceMessage::FileStyleSummary {
buffer_id,
generation,
@ -924,6 +1025,43 @@ 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> {
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 +1619,7 @@ fn grammar_style_key(
bundle: handle.current(),
generation,
visible: vp.visible,
syntax_epoch: theme_epochs(state).0,
})
}
@ -1776,9 +1915,18 @@ 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".
let theme = {
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(Some(&theme), s);
}
}
}
@ -1877,10 +2025,199 @@ 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 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 +2231,7 @@ mod tests {
| InstanceMessage::StatusFacts { .. }
| InstanceMessage::SearchPrompt { .. }
| InstanceMessage::LineNumbers { .. }
| InstanceMessage::ThemeFacts { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
);
@ -2063,12 +2401,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