From 7975eeda87a879bbd20c500e6ef79dd88b975ae6 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 14 Jul 2026 16:11:09 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB --- docs/semantic-frontend-protocol.md | 20 + pmacs-gpu/src/main.rs | 684 +++++++++++++++++- pmacs-protocol/src/lib.rs | 4 +- pmacs-protocol/src/message.rs | 50 +- src/daemon.rs | 9 + src/diag.rs | 126 ++-- src/editor.rs | 176 ++++- src/editor_core.rs | 13 +- src/frontend.rs | 31 + src/highlight.rs | 134 +++- src/lua_bindings/diag.rs | 12 +- src/lua_bindings/mod.rs | 243 ++++++- src/protocol.rs | 79 ++- src/search.rs | 44 +- src/semantic_render.rs | 376 +++++++++- tests/theme_faces_acceptance.rs | 1044 ++++++++++++++++++++++++++++ 16 files changed, 2884 insertions(+), 161 deletions(-) create mode 100644 tests/theme_faces_acceptance.rs diff --git a/docs/semantic-frontend-protocol.md b/docs/semantic-frontend-protocol.md index 8814bae..33d65e4 100644 --- a/docs/semantic-frontend-protocol.md +++ b/docs/semantic-frontend-protocol.md @@ -205,6 +205,26 @@ ResourceOffer { mime: String, body: ResourceBody, // Inline(Vec) | 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, // { name: String, style: Style }, sorted by name +}, ``` Each family member diffs against the previous frame the same way diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index d4b1956..9c8261a 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -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, } /// 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: (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 { + // 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