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:
parent
6ce1532699
commit
7975eeda87
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
126
src/diag.rs
126
src/diag.rs
|
|
@ -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,
|
||||
|
|
|
|||
176
src/editor.rs
176
src/editor.rs
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
134
src/highlight.rs
134
src/highlight.rs
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})?,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue