Merge pull request #120 from levineuwirth/theme-faces
feat(themes): named UI faces + ThemeFacts channel (protocol v16) — Arc 4 stage 1
This commit is contained in:
commit
8ce2e9cb0f
|
|
@ -116,6 +116,26 @@ layout-local frontend consumes). Peer cursors reuse the existing
|
|||
is: one capability bit, ~five instance→frontend interpretation
|
||||
variants, and one frontend→instance `Viewport` variant.
|
||||
|
||||
**`BufferSnapshot` resets buffer-scoped interpretation state.** A
|
||||
frontend receiving a snapshot drops everything it holds for the
|
||||
named buffer — spans, decorations, adornments, minimap summary,
|
||||
completion popup, search and menu prompts (which also gate the
|
||||
frontend's key/pointer interception), and status facts — and
|
||||
rebuilds from the frames that follow; the instance mirrors this by
|
||||
invalidating its per-buffer emission baselines whenever it writes a
|
||||
snapshot, so the frontend's post-snapshot viewport declaration
|
||||
receives authoritative re-sends even when nothing changed
|
||||
daemon-side (the unchanged-generation A → B → A revisit). Bufferless
|
||||
facts (`ThemeFacts`, the minibuffer prompt) and per-frontend state
|
||||
(the gutter mode) survive snapshots on both sides, and the
|
||||
instance's stale-store diagnostic-count freeze is store knowledge,
|
||||
not session state — the re-sent `StatusFacts` after a snapshot
|
||||
carries the frozen counts, never zeros, including for a session
|
||||
whose first frame lands during staleness (a late joiner attaching
|
||||
mid-edit). The store maintains those per-URI severity totals with
|
||||
the retained vector, so frame-time producers read them in O(1)
|
||||
rather than rescanning diagnostics for every attached session.
|
||||
|
||||
## Capability and version mechanics
|
||||
|
||||
Identical pattern to `crdt_replica`:
|
||||
|
|
@ -205,6 +225,26 @@ ResourceOffer {
|
|||
mime: String,
|
||||
body: ResourceBody, // Inline(Vec<u8>) | Uri(String)
|
||||
},
|
||||
|
||||
/// Themes arc Q#TH7 (protocol v16): the daemon-resolved UI face
|
||||
/// table. Bufferless — the theme is one global instance. Complete
|
||||
/// replacement each send: a face absent from `faces` is unset and
|
||||
/// the frontend uses its own default for that surface. Every
|
||||
/// attachment receives exactly one authoritative table (the empty
|
||||
/// table included) with its first emission after viewport
|
||||
/// declaration; cached-compare suppressed thereafter, so an
|
||||
/// unthemed session pays one small message and nothing more.
|
||||
/// Resolution (the `ui.*` dotted-prefix inheritance walk) happens
|
||||
/// daemon-side over the stage-1 face inventory — frontends do
|
||||
/// exact-name lookup only, and apply each face within its
|
||||
/// stage-1 component mask (docs/theme-faces-framing.md Q#TH3/Q#TH5:
|
||||
/// a set face owns its surface; `Default` components mean the
|
||||
/// frontend's plain rendering; out-of-mask components are never
|
||||
/// read). Daemon-gated `>= 16`; appended as the FINAL variant —
|
||||
/// postcard discriminants are ordinal.
|
||||
ThemeFacts {
|
||||
faces: Vec<ThemeFace>, // { name: String, style: Style }, sorted by name
|
||||
},
|
||||
```
|
||||
|
||||
Each family member diffs against the previous frame the same way
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -984,6 +984,7 @@ fn dispatcher_loop(
|
|||
upgraded,
|
||||
&session_registry,
|
||||
&mut streams,
|
||||
&mut semantic_states,
|
||||
);
|
||||
// The broadcast just delivered this buffer to this
|
||||
// frontend too; record it so the follow check below
|
||||
|
|
@ -1022,6 +1023,14 @@ fn dispatcher_loop(
|
|||
&& last_active_buffer_sent.get(fid) != Some(&active_now)
|
||||
{
|
||||
send_buffer_snapshot_to_frontend(editor, active_now, *fid, &mut streams);
|
||||
// PR #120 round 2 — the snapshot just wiped
|
||||
// this frontend's buffer-scoped render state;
|
||||
// the emission baselines must die with it or
|
||||
// an unchanged-generation revisit (A → B → A)
|
||||
// suppresses every re-send.
|
||||
if let Some(sem) = semantic_states.get_mut(fid) {
|
||||
sem.on_buffer_snapshot_sent(active_now);
|
||||
}
|
||||
last_active_buffer_sent.insert(*fid, active_now);
|
||||
}
|
||||
}
|
||||
|
|
@ -1125,6 +1134,11 @@ fn dispatcher_loop(
|
|||
let peer_knows_completion_popup = session_registry
|
||||
.session_state(*fid)
|
||||
.is_some_and(|s| s.negotiated_protocol_version >= 15);
|
||||
// Themes Q#TH7 — ThemeFacts gated at v16; a v15 peer's
|
||||
// chrome simply stays on its frontend defaults.
|
||||
let peer_knows_theme_facts = session_registry
|
||||
.session_state(*fid)
|
||||
.is_some_and(|s| s.negotiated_protocol_version >= 16);
|
||||
for msg in &messages {
|
||||
if !peer_knows_status_facts
|
||||
&& matches!(msg, InstanceMessage::StatusFacts { .. })
|
||||
|
|
@ -1160,6 +1174,10 @@ fn dispatcher_loop(
|
|||
{
|
||||
continue;
|
||||
}
|
||||
if !peer_knows_theme_facts && matches!(msg, InstanceMessage::ThemeFacts { .. })
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
|
||||
// jitter site: render-write latency.
|
||||
//
|
||||
|
|
@ -1366,6 +1384,9 @@ fn handle_session_established(
|
|||
// fires: the semantic frontend holds the rope locally and the
|
||||
// semantic frame ships no text.
|
||||
let semantic_render = session_state.negotiated_capabilities.semantic_render;
|
||||
// Captured before `register_session` consumes the state: the
|
||||
// semantic producer needs the peer's version (finding 3 below).
|
||||
let negotiated_protocol_version = session_state.negotiated_protocol_version;
|
||||
if crdt_replica {
|
||||
send_buffer_snapshots(editor, &mut write_stream);
|
||||
}
|
||||
|
|
@ -1377,7 +1398,14 @@ fn handle_session_established(
|
|||
if semantic_render {
|
||||
semantic_states.insert(
|
||||
frontend_id,
|
||||
crate::semantic_render::SemanticRenderState::new(frontend_id),
|
||||
// for_peer, not new (PR #120 round 1 finding 3): a v15
|
||||
// peer's producer must not resolve faces into the
|
||||
// FileStyleSummary marks — that channel predates the v16
|
||||
// gate.
|
||||
crate::semantic_render::SemanticRenderState::for_peer(
|
||||
frontend_id,
|
||||
negotiated_protocol_version,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
let mut render_state = RenderState::new(initial_size);
|
||||
|
|
@ -1844,6 +1872,7 @@ fn broadcast_buffer_snapshot_to_replicas(
|
|||
buffer_id: crate::buffer::BufferId,
|
||||
session_registry: &SessionRegistry,
|
||||
streams: &mut HashMap<FrontendId, UnixStream>,
|
||||
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
|
||||
) {
|
||||
let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else {
|
||||
return;
|
||||
|
|
@ -1862,6 +1891,12 @@ fn broadcast_buffer_snapshot_to_replicas(
|
|||
if let Err(e) = write_message(stream, &msg) {
|
||||
eprintln!("pmacs: F29 send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}");
|
||||
}
|
||||
// PR #120 round 2 — same reset contract as the follow path:
|
||||
// the snapshot wiped this replica's buffer-scoped render
|
||||
// state, so its emission baselines for the buffer die too.
|
||||
if let Some(sem) = semantic_states.get_mut(fid) {
|
||||
sem.on_buffer_snapshot_sent(buffer_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
208
src/diag.rs
208
src/diag.rs
|
|
@ -196,6 +196,10 @@ impl Diagnostic {
|
|||
#[derive(Default)]
|
||||
pub struct DiagnosticStore {
|
||||
by_uri: HashMap<String, Vec<Diagnostic>>,
|
||||
/// Per-URI severity totals, maintained alongside `by_uri` so
|
||||
/// frame-time consumers do not rescan every diagnostic at render
|
||||
/// cadence. Entries exist exactly when `by_uri` entries do.
|
||||
severity_counts: HashMap<String, (u32, u32, u32, u32)>,
|
||||
/// URIs whose stored diagnostics are known to be out of date
|
||||
/// because a `textDocument/didChange` was issued after the last
|
||||
/// `publishDiagnostics` was absorbed. `Self::set` clears entries
|
||||
|
|
@ -210,6 +214,20 @@ pub struct DiagnosticStore {
|
|||
epochs: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
fn count_severities(diags: &[Diagnostic]) -> (u32, u32, u32, u32) {
|
||||
let mut counts = (0u32, 0u32, 0u32, 0u32);
|
||||
for diagnostic in diags {
|
||||
let slot = match diagnostic.severity {
|
||||
DiagnosticSeverity::Error => &mut counts.0,
|
||||
DiagnosticSeverity::Warning => &mut counts.1,
|
||||
DiagnosticSeverity::Information => &mut counts.2,
|
||||
DiagnosticSeverity::Hint => &mut counts.3,
|
||||
};
|
||||
*slot = slot.saturating_add(1);
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
impl DiagnosticStore {
|
||||
/// Empty store.
|
||||
#[must_use]
|
||||
|
|
@ -227,11 +245,14 @@ impl DiagnosticStore {
|
|||
pub fn set(&mut self, uri: impl Into<String>, mut diags: Vec<Diagnostic>) {
|
||||
diags.sort_by(Diagnostic::compare_by_position);
|
||||
let uri = uri.into();
|
||||
let counts = count_severities(&diags);
|
||||
self.stale_uris.remove(&uri);
|
||||
*self.epochs.entry(uri.clone()).or_insert(0) += 1;
|
||||
if diags.is_empty() {
|
||||
self.by_uri.remove(&uri);
|
||||
self.severity_counts.remove(&uri);
|
||||
} else {
|
||||
self.severity_counts.insert(uri.clone(), counts);
|
||||
self.by_uri.insert(uri, diags);
|
||||
}
|
||||
}
|
||||
|
|
@ -240,6 +261,7 @@ impl DiagnosticStore {
|
|||
/// no entry to be stale about.
|
||||
pub fn clear(&mut self, uri: &str) {
|
||||
self.by_uri.remove(uri);
|
||||
self.severity_counts.remove(uri);
|
||||
self.stale_uris.remove(uri);
|
||||
*self.epochs.entry(uri.to_owned()).or_insert(0) += 1;
|
||||
}
|
||||
|
|
@ -285,19 +307,26 @@ impl DiagnosticStore {
|
|||
let mut w = 0;
|
||||
let mut i = 0;
|
||||
let mut h = 0;
|
||||
for diags in self.by_uri.values() {
|
||||
for d in diags {
|
||||
match d.severity {
|
||||
DiagnosticSeverity::Error => e += 1,
|
||||
DiagnosticSeverity::Warning => w += 1,
|
||||
DiagnosticSeverity::Information => i += 1,
|
||||
DiagnosticSeverity::Hint => h += 1,
|
||||
}
|
||||
}
|
||||
for &(errors, warnings, information, hints) in self.severity_counts.values() {
|
||||
e += errors as usize;
|
||||
w += warnings as usize;
|
||||
i += information as usize;
|
||||
h += hints as usize;
|
||||
}
|
||||
(e, w, i, h)
|
||||
}
|
||||
|
||||
/// Per-URI totals `(error, warning, information, hint)`.
|
||||
///
|
||||
/// The tuple is computed once by [`Self::set`] and deliberately
|
||||
/// survives [`Self::mark_stale`]: staleness invalidates byte
|
||||
/// positions, while the last published counts remain valid as a
|
||||
/// frozen status summary until the next publication.
|
||||
#[must_use]
|
||||
pub fn severity_counts_for(&self, uri: &str) -> (u32, u32, u32, u32) {
|
||||
self.severity_counts.get(uri).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Per-URI count.
|
||||
#[must_use]
|
||||
pub fn count_for(&self, uri: &str) -> usize {
|
||||
|
|
@ -363,63 +392,61 @@ pub fn make_shared_store() -> SharedDiagStore {
|
|||
/// [`crate::text_view`] and [`crate::highlight`].
|
||||
const TAB_WIDTH: u32 = 8;
|
||||
|
||||
/// Style applied to bytes covered by an `Error` diagnostic. Wavy
|
||||
/// underline colored via `underline_color` (not `fg`) so the
|
||||
/// squiggle reads red while the syntax view's text color survives
|
||||
/// underneath (T M4.6, protocol v6).
|
||||
fn error_style() -> Style {
|
||||
Style {
|
||||
underline: UnderlineStyle::Curly,
|
||||
underline_color: DiagnosticSeverity::Error.underline_color(),
|
||||
..Style::default()
|
||||
/// The RESOLVED severity color (themes arc Q#TH5): the `ui.diag.*`
|
||||
/// face's `fg` when a face is set with a concrete color, else the
|
||||
/// built-in [`DiagnosticSeverity::underline_color`]. The diag family
|
||||
/// carries a special `Default` policy — `Default` fg means the
|
||||
/// built-in severity color, never "plain" — because the color doubles
|
||||
/// as the *presence* encoding in the minimap summary
|
||||
/// (`FileStyleSummary.underline_color`, where `Default` reads as "no
|
||||
/// mark"), so a plain severity color is unrepresentable and
|
||||
/// `ui.diag.error = {}` degrades to the built-in on every surface.
|
||||
#[must_use]
|
||||
pub fn severity_color(
|
||||
theme: Option<&crate::highlight::Theme>,
|
||||
severity: DiagnosticSeverity,
|
||||
) -> Color {
|
||||
let name = match severity {
|
||||
DiagnosticSeverity::Error => "ui.diag.error",
|
||||
DiagnosticSeverity::Warning => "ui.diag.warning",
|
||||
DiagnosticSeverity::Information => "ui.diag.info",
|
||||
DiagnosticSeverity::Hint => "ui.diag.hint",
|
||||
};
|
||||
match theme.and_then(|t| t.face(name)) {
|
||||
Some(f) if f.fg != Color::Default => f.fg,
|
||||
_ => severity.underline_color(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Style applied to bytes covered by a `Warning` diagnostic.
|
||||
fn warning_style() -> Style {
|
||||
/// Style applied to bytes covered by a diagnostic: severity-shaped
|
||||
/// underline (wavy for error/warning, single for info, dotted for
|
||||
/// hint) colored via `underline_color` (not `fg`) so the squiggle
|
||||
/// reads its severity color while the syntax view's text color
|
||||
/// survives underneath (T M4.6, protocol v6). `color` is the
|
||||
/// resolved severity color ([`severity_color`]).
|
||||
fn style_for(severity: DiagnosticSeverity, color: Color) -> Style {
|
||||
let underline = match severity {
|
||||
DiagnosticSeverity::Error | DiagnosticSeverity::Warning => UnderlineStyle::Curly,
|
||||
DiagnosticSeverity::Information => UnderlineStyle::Single,
|
||||
DiagnosticSeverity::Hint => UnderlineStyle::Dotted,
|
||||
};
|
||||
Style {
|
||||
underline: UnderlineStyle::Curly,
|
||||
underline_color: DiagnosticSeverity::Warning.underline_color(),
|
||||
underline,
|
||||
underline_color: color,
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Style applied to bytes covered by an `Information` diagnostic.
|
||||
fn info_style() -> Style {
|
||||
Style {
|
||||
underline: UnderlineStyle::Single,
|
||||
underline_color: DiagnosticSeverity::Information.underline_color(),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Style applied to bytes covered by a `Hint` diagnostic.
|
||||
fn hint_style() -> Style {
|
||||
Style {
|
||||
underline: UnderlineStyle::Dotted,
|
||||
underline_color: DiagnosticSeverity::Hint.underline_color(),
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn style_for(severity: DiagnosticSeverity) -> Style {
|
||||
match severity {
|
||||
DiagnosticSeverity::Error => error_style(),
|
||||
DiagnosticSeverity::Warning => warning_style(),
|
||||
DiagnosticSeverity::Information => info_style(),
|
||||
DiagnosticSeverity::Hint => hint_style(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Style of the column-0 line marker — the TUI's gutter sign
|
||||
/// (T M4.6). The TUI reserves no gutter column, so the sign is a
|
||||
/// severity-colored *background* on the line's first cell: the
|
||||
/// glyph and its syntax color survive (the view contract is
|
||||
/// style-only), and zero-width diagnostics — invisible to the
|
||||
/// underline pass — still get a visible artifact.
|
||||
fn marker_style_for(severity: DiagnosticSeverity) -> Style {
|
||||
/// underline pass — still get a visible artifact. `color` is the
|
||||
/// resolved severity color ([`severity_color`]).
|
||||
fn marker_style_for(color: Color) -> Style {
|
||||
Style {
|
||||
bg: severity.underline_color(),
|
||||
bg: color,
|
||||
..Style::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -436,15 +463,25 @@ pub struct DiagnosticView {
|
|||
/// Shared store; mutated by the LSP manager, read by this view
|
||||
/// on every render.
|
||||
store: SharedDiagStore,
|
||||
/// Shared theme for the `ui.diag.*` face resolution (themes arc
|
||||
/// Q#TH9; the `SyntaxHighlightView` precedent). `None` — a bare
|
||||
/// test construction — paints the built-in severity colors.
|
||||
theme: Option<crate::highlight::ThemeHandle>,
|
||||
}
|
||||
|
||||
impl DiagnosticView {
|
||||
/// Construct a diagnostic view for `uri` against `store`.
|
||||
/// Construct a diagnostic view for `uri` against `store`,
|
||||
/// resolving severity colors through `theme` when given.
|
||||
#[must_use]
|
||||
pub fn new(uri: impl Into<String>, store: SharedDiagStore) -> Self {
|
||||
pub fn new(
|
||||
uri: impl Into<String>,
|
||||
store: SharedDiagStore,
|
||||
theme: Option<crate::highlight::ThemeHandle>,
|
||||
) -> Self {
|
||||
Self {
|
||||
uri: uri.into(),
|
||||
store,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -502,8 +539,15 @@ impl View for DiagnosticView {
|
|||
let mut line_markers: std::collections::HashMap<u32, DiagnosticSeverity> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
// One theme clone per render (themes arc Q#TH9, the
|
||||
// SyntaxHighlightView discipline) for the ui.diag.* faces.
|
||||
let theme = self
|
||||
.theme
|
||||
.as_ref()
|
||||
.map(|t| t.lock().expect("theme mutex poisoned").clone());
|
||||
|
||||
for diag in &diags {
|
||||
let style = style_for(diag.severity);
|
||||
let style = style_for(diag.severity, severity_color(theme.as_ref(), diag.severity));
|
||||
// Apply to each line the diagnostic touches. LSP ranges
|
||||
// are half-open at the end position; if end_col == 0
|
||||
// the diagnostic stops at the start of `end_line` so
|
||||
|
|
@ -580,6 +624,7 @@ impl View for DiagnosticView {
|
|||
viewport.gutter_w,
|
||||
max_cols,
|
||||
&line_markers,
|
||||
theme.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -597,9 +642,11 @@ fn paint_line_markers(
|
|||
gutter_w: u32,
|
||||
max_cols: u32,
|
||||
line_markers: &std::collections::HashMap<u32, DiagnosticSeverity>,
|
||||
theme: Option<&crate::highlight::Theme>,
|
||||
) {
|
||||
for (&row_offset, &severity) in line_markers {
|
||||
let row = cell_origin.row + row_offset;
|
||||
let color = severity_color(theme, severity);
|
||||
if gutter_w > 0 {
|
||||
let cell = cells.at(CellCoord::new(
|
||||
row,
|
||||
|
|
@ -607,12 +654,12 @@ fn paint_line_markers(
|
|||
));
|
||||
cell.glyph = Glyph::Char(severity.gutter_glyph());
|
||||
cell.style = Style {
|
||||
fg: severity.underline_color(),
|
||||
fg: color,
|
||||
..Style::default()
|
||||
};
|
||||
} else if max_cols > 0 {
|
||||
let cell = cells.at(CellCoord::new(row, cell_origin.col));
|
||||
cell.style = merge_styles(cell.style, marker_style_for(severity));
|
||||
cell.style = merge_styles(cell.style, marker_style_for(color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -842,12 +889,47 @@ mod tests {
|
|||
assert_eq!(totals, (2, 1, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_severity_counts_replace_clear_and_survive_staleness() {
|
||||
let mut store = DiagnosticStore::new();
|
||||
assert_eq!(store.severity_counts_for("file:///a"), (0, 0, 0, 0));
|
||||
|
||||
store.set(
|
||||
"file:///a",
|
||||
vec![
|
||||
diag(0, DiagnosticSeverity::Error, "1"),
|
||||
diag(1, DiagnosticSeverity::Error, "2"),
|
||||
diag(2, DiagnosticSeverity::Warning, "3"),
|
||||
diag(3, DiagnosticSeverity::Information, "4"),
|
||||
diag(4, DiagnosticSeverity::Hint, "5"),
|
||||
],
|
||||
);
|
||||
assert_eq!(store.severity_counts_for("file:///a"), (2, 1, 1, 1));
|
||||
|
||||
store.mark_stale("file:///a");
|
||||
assert_eq!(
|
||||
store.severity_counts_for("file:///a"),
|
||||
(2, 1, 1, 1),
|
||||
"staleness freezes the last published counts"
|
||||
);
|
||||
|
||||
store.set(
|
||||
"file:///a",
|
||||
vec![diag(0, DiagnosticSeverity::Warning, "replacement")],
|
||||
);
|
||||
assert_eq!(store.severity_counts_for("file:///a"), (0, 1, 0, 0));
|
||||
|
||||
store.clear("file:///a");
|
||||
assert_eq!(store.severity_counts_for("file:///a"), (0, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_set_clears_uri() {
|
||||
let mut s = DiagnosticStore::new();
|
||||
s.set("a", vec![diag(0, DiagnosticSeverity::Error, "x")]);
|
||||
s.set("a", Vec::new());
|
||||
assert!(s.for_uri("a").is_empty());
|
||||
assert_eq!(s.severity_counts_for("a"), (0, 0, 0, 0));
|
||||
assert_eq!(s.uris().count(), 0);
|
||||
}
|
||||
|
||||
|
|
@ -921,7 +1003,7 @@ mod tests {
|
|||
DiagnosticSeverity::Information,
|
||||
DiagnosticSeverity::Hint,
|
||||
] {
|
||||
let style = style_for(s);
|
||||
let style = style_for(s, severity_color(None, s));
|
||||
assert_eq!(style.fg, Color::Default, "{s:?} must not set fg");
|
||||
assert_ne!(
|
||||
style.underline_color,
|
||||
|
|
@ -959,7 +1041,7 @@ mod tests {
|
|||
// `pmacs.window._overlay_kinds()` introspection (task #23 wire-up,
|
||||
// mirroring "syntax-highlight" / LspStyleView) relies on this.
|
||||
let store = make_shared_store();
|
||||
let view = DiagnosticView::new("file:///a", store);
|
||||
let view = DiagnosticView::new("file:///a", store, None);
|
||||
assert_eq!(view.kind(), "diagnostic");
|
||||
}
|
||||
|
||||
|
|
@ -984,7 +1066,7 @@ mod tests {
|
|||
})
|
||||
.expect("seed buffer");
|
||||
|
||||
let mut view = DiagnosticView::new("file:///a", store);
|
||||
let mut view = DiagnosticView::new("file:///a", store, None);
|
||||
let mut backing = vec![Cell::default(); 10];
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
|
|
@ -1046,7 +1128,7 @@ mod tests {
|
|||
})
|
||||
.expect("seed buffer");
|
||||
|
||||
let mut view = DiagnosticView::new("file:///a", store);
|
||||
let mut view = DiagnosticView::new("file:///a", store, None);
|
||||
let mut backing = vec![Cell::default(); 30];
|
||||
// Pre-paint glyphs at column 0 to pin the style-only contract.
|
||||
backing[0].glyph = Glyph::Char('h');
|
||||
|
|
@ -1123,7 +1205,7 @@ mod tests {
|
|||
|
||||
// A 2-cell gutter: text is shifted to column 2, signs land at
|
||||
// window column 0 (`cell_origin.col - gutter_w`).
|
||||
let mut view = DiagnosticView::new("file:///a", store);
|
||||
let mut view = DiagnosticView::new("file:///a", store, None);
|
||||
let mut backing = vec![Cell::default(); 30];
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
|
|
@ -1191,7 +1273,7 @@ mod tests {
|
|||
})
|
||||
.expect("seed buffer");
|
||||
|
||||
let mut view = DiagnosticView::new("file:///a", store);
|
||||
let mut view = DiagnosticView::new("file:///a", store, None);
|
||||
let mut backing = vec![Cell::default(); 20];
|
||||
let mut grid = CellGrid {
|
||||
cells: &mut backing,
|
||||
|
|
|
|||
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;
|
||||
|
|
@ -6862,18 +6862,26 @@ fn underline_to_lua(style: UnderlineStyle) -> &'static str {
|
|||
/// default to the same values as `Style::default()` --- a Lua table
|
||||
/// with `{ bold = true }` produces a style that is otherwise
|
||||
/// terminal-default.
|
||||
///
|
||||
/// Every lookup PROPAGATES its error (PR #120 round 1 finding 2):
|
||||
/// `Table::get` runs `__index`, so a raising metatable must fail the
|
||||
/// enclosing transactional mutation (Q#TH6 all-or-nothing), not
|
||||
/// silently parse as an all-default style and let sibling entries
|
||||
/// commit. Color/underline fields validate strictly through their
|
||||
/// converters; the boolean fields follow Lua truthiness (mlua's
|
||||
/// `bool` conversion), so `reverse = 1` reads as `true` by design.
|
||||
fn lua_to_style(t: &Table) -> mlua::Result<Style> {
|
||||
let fg: mlua::Value = t.get("fg").unwrap_or(mlua::Value::Nil);
|
||||
let bg: mlua::Value = t.get("bg").unwrap_or(mlua::Value::Nil);
|
||||
let underline: mlua::Value = t.get("underline").unwrap_or(mlua::Value::Nil);
|
||||
let underline_color: mlua::Value = t.get("underline_color").unwrap_or(mlua::Value::Nil);
|
||||
let fg: mlua::Value = t.get("fg")?;
|
||||
let bg: mlua::Value = t.get("bg")?;
|
||||
let underline: mlua::Value = t.get("underline")?;
|
||||
let underline_color: mlua::Value = t.get("underline_color")?;
|
||||
Ok(Style {
|
||||
fg: lua_to_color(&fg)?,
|
||||
bg: lua_to_color(&bg)?,
|
||||
bold: t.get("bold").unwrap_or(false),
|
||||
italic: t.get("italic").unwrap_or(false),
|
||||
bold: t.get::<Option<bool>>("bold")?.unwrap_or(false),
|
||||
italic: t.get::<Option<bool>>("italic")?.unwrap_or(false),
|
||||
underline: lua_to_underline(&underline)?,
|
||||
reverse: t.get("reverse").unwrap_or(false),
|
||||
reverse: t.get::<Option<bool>>("reverse")?.unwrap_or(false),
|
||||
underline_color: lua_to_color(&underline_color)?,
|
||||
})
|
||||
}
|
||||
|
|
@ -6895,6 +6903,83 @@ fn style_to_lua(lua: &Lua, style: Style) -> mlua::Result<Table> {
|
|||
/// attached [`SyntaxHighlightView`] sees the change on its next
|
||||
/// render. T M4.3 acceptance: "theming via Lua-defined color
|
||||
/// schemes."
|
||||
/// Themes arc Q#TH6: how [`commit_theme_entries`] applies a parsed
|
||||
/// entry set to the theme's capture map.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum ThemeCommit {
|
||||
/// `pmacs.theme.set`: the parsed entries become the whole map
|
||||
/// (faces wiped with captures, Q#TH10); `default_style` is
|
||||
/// untouched.
|
||||
Replace,
|
||||
/// `pmacs.theme.merge`: insert/overwrite the parsed entries.
|
||||
Merge,
|
||||
}
|
||||
|
||||
/// Themes arc Q#TH6: the transactional mutation helper behind
|
||||
/// `pmacs.theme.set` / `pmacs.theme.merge`. Collects the WHOLE entry
|
||||
/// stream before touching the theme lock, so a malformed entry
|
||||
/// anywhere in the input returns its error with the theme untouched
|
||||
/// and the mutation counters unbumped — the pre-fix `merge` inserted
|
||||
/// while iterating, letting early entries land before a later one
|
||||
/// failed. After a successful commit the counters bump from their
|
||||
/// prior values (never reset — [`crate::highlight::Theme`]'s
|
||||
/// increment-only invariant): `Replace` touches both namespaces
|
||||
/// wholesale so it bumps both; `Merge` classifies every committed
|
||||
/// key through [`crate::highlight::is_face_name`] (bare `ui`
|
||||
/// included) and bumps `syntax_epoch` iff any non-face key
|
||||
/// committed, `face_epoch` iff any face key did.
|
||||
fn commit_theme_entries(
|
||||
theme: &crate::highlight::ThemeHandle,
|
||||
mode: ThemeCommit,
|
||||
entries: impl Iterator<Item = mlua::Result<(String, Style)>>,
|
||||
) -> mlua::Result<()> {
|
||||
let entries: Vec<(String, Style)> = entries.collect::<mlua::Result<_>>()?;
|
||||
let mut th = theme.lock().expect("theme mutex poisoned");
|
||||
match mode {
|
||||
ThemeCommit::Replace => {
|
||||
// Replace the FIELD, never the `Theme` value: a fresh
|
||||
// Theme's zeroed counters would let consecutive `set`
|
||||
// calls share an epoch and stay invisible to every gate.
|
||||
th.by_capture = entries.into_iter().collect();
|
||||
th.syntax_epoch += 1;
|
||||
th.face_epoch += 1;
|
||||
}
|
||||
ThemeCommit::Merge => {
|
||||
let any_face = entries
|
||||
.iter()
|
||||
.any(|(n, _)| crate::highlight::is_face_name(n));
|
||||
let any_syntax = entries
|
||||
.iter()
|
||||
.any(|(n, _)| !crate::highlight::is_face_name(n));
|
||||
for (name, style) in entries {
|
||||
th.by_capture.insert(name, style);
|
||||
}
|
||||
if any_syntax {
|
||||
th.syntax_epoch += 1;
|
||||
}
|
||||
if any_face {
|
||||
th.face_epoch += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adapt a Lua theme table to [`commit_theme_entries`]'s ordered
|
||||
/// result stream: each raw `(name, style_table)` pair maps through
|
||||
/// [`lua_to_style`], and any iteration or conversion error rides the
|
||||
/// stream so the helper can fail before locking.
|
||||
fn lua_theme_entries(table: &Table) -> impl Iterator<Item = mlua::Result<(String, Style)>> + use<> {
|
||||
table
|
||||
.pairs::<String, Table>()
|
||||
.map(|pair| {
|
||||
let (name, style) = pair?;
|
||||
Ok((name, lua_to_style(&style)?))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
}
|
||||
|
||||
fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table> {
|
||||
let theme_mod = lua.create_table()?;
|
||||
|
||||
|
|
@ -6903,17 +6988,7 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
|
|||
theme_mod.set(
|
||||
"set",
|
||||
lua.create_function(move |_, table: Table| {
|
||||
let mut new_theme = Theme::empty();
|
||||
table.for_each(|name: String, style: Table| {
|
||||
new_theme.insert(name, lua_to_style(&style)?);
|
||||
Ok(())
|
||||
})?;
|
||||
let theme = s.theme();
|
||||
let mut th = theme.lock().expect("theme mutex poisoned");
|
||||
let prev_default = th.default_style;
|
||||
*th = new_theme;
|
||||
th.default_style = prev_default;
|
||||
Ok(())
|
||||
commit_theme_entries(&s.theme(), ThemeCommit::Replace, lua_theme_entries(&table))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
|
@ -6923,13 +6998,7 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
|
|||
theme_mod.set(
|
||||
"merge",
|
||||
lua.create_function(move |_, table: Table| {
|
||||
let theme = s.theme();
|
||||
let mut th = theme.lock().expect("theme mutex poisoned");
|
||||
table.for_each(|name: String, style: Table| {
|
||||
th.insert(name, lua_to_style(&style)?);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
commit_theme_entries(&s.theme(), ThemeCommit::Merge, lua_theme_entries(&table))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
|
@ -6952,7 +7021,11 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
|
|||
"clear",
|
||||
lua.create_function(move |_, ()| {
|
||||
let theme = s.theme();
|
||||
theme.lock().expect("theme mutex poisoned").clear();
|
||||
let mut th = theme.lock().expect("theme mutex poisoned");
|
||||
th.clear();
|
||||
// Q#TH6: clear empties both namespaces — bump both.
|
||||
th.syntax_epoch += 1;
|
||||
th.face_epoch += 1;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
|
@ -6963,8 +7036,13 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
|
|||
theme_mod.set(
|
||||
"default",
|
||||
lua.create_function(move |_, style: Table| {
|
||||
// Q#TH6: parse before locking; default_style is a
|
||||
// syntax-namespace fallback, so bump syntax only.
|
||||
let parsed = lua_to_style(&style)?;
|
||||
let theme = s.theme();
|
||||
theme.lock().expect("theme mutex poisoned").default_style = lua_to_style(&style)?;
|
||||
let mut th = theme.lock().expect("theme mutex poisoned");
|
||||
th.default_style = parsed;
|
||||
th.syntax_epoch += 1;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
|
@ -8623,7 +8701,7 @@ pub fn make_lsp_manager(
|
|||
) -> mlua::Result<SharedLspManager> {
|
||||
let manager = Rc::new(RefCell::new(LspManager::new(supervisor, runtime)));
|
||||
install_lsp(lua, &manager, syntax)?;
|
||||
diag::install_diag(lua, &manager)?;
|
||||
diag::install_diag(lua, &manager, &syntax.theme())?;
|
||||
install_completion(lua, &manager)?;
|
||||
install_hover(lua, &manager)?;
|
||||
install_signature(lua, &manager)?;
|
||||
|
|
@ -12118,6 +12196,123 @@ mod tests {
|
|||
(lua, reg, cmds, kms, hks)
|
||||
}
|
||||
|
||||
/// A theme handle with one syntax entry and nonzero counters, for
|
||||
/// pinning that failed commits change nothing and successful ones
|
||||
/// bump from the PRIOR values (themes arc Q#TH6).
|
||||
fn seeded_theme() -> crate::highlight::ThemeHandle {
|
||||
let mut th = crate::highlight::Theme::empty();
|
||||
th.insert(
|
||||
"keyword",
|
||||
Style {
|
||||
bold: true,
|
||||
..Style::default()
|
||||
},
|
||||
);
|
||||
th.syntax_epoch = 3;
|
||||
th.face_epoch = 5;
|
||||
std::sync::Arc::new(std::sync::Mutex::new(th))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_commit_is_all_or_nothing_with_untouched_counters() {
|
||||
// Q#TH6 / acceptance 11 (the deterministic bite): an ordered
|
||||
// entry stream whose TAIL is malformed must error with zero
|
||||
// theme mutation — the helper collects the whole stream
|
||||
// before taking the lock. The pre-fix merge inserted while
|
||||
// iterating, so the leading Ok entry landed before the Err.
|
||||
let theme = seeded_theme();
|
||||
let entries: Vec<mlua::Result<(String, Style)>> = vec![
|
||||
Ok((
|
||||
"string".to_owned(),
|
||||
Style {
|
||||
italic: true,
|
||||
..Style::default()
|
||||
},
|
||||
)),
|
||||
Err(mlua::Error::RuntimeError("malformed style".into())),
|
||||
];
|
||||
let res = commit_theme_entries(&theme, ThemeCommit::Merge, entries.into_iter());
|
||||
assert!(res.is_err(), "a malformed tail entry must error");
|
||||
let th = theme.lock().expect("lock");
|
||||
assert!(
|
||||
!th.by_capture.contains_key("string"),
|
||||
"the leading Ok entry must NOT have landed"
|
||||
);
|
||||
assert!(
|
||||
th.by_capture.contains_key("keyword"),
|
||||
"pre-existing entries survive"
|
||||
);
|
||||
assert_eq!(th.syntax_epoch, 3, "failed commit bumps nothing");
|
||||
assert_eq!(th.face_epoch, 5, "failed commit bumps nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_commit_replace_advances_counters_from_prior_values() {
|
||||
// Q#TH6 / acceptance 10: consecutive wholesale replacements
|
||||
// must each advance the counters — replacing the FIELD, not
|
||||
// the Theme value, or two `set`s share an epoch. Replace also
|
||||
// leaves default_style alone (the historical `set` contract).
|
||||
let theme = seeded_theme();
|
||||
theme.lock().expect("lock").default_style = Style {
|
||||
reverse: true,
|
||||
..Style::default()
|
||||
};
|
||||
for expected in [(4, 6), (5, 7)] {
|
||||
let entries: Vec<mlua::Result<(String, Style)>> =
|
||||
vec![Ok(("type".to_owned(), Style::default()))];
|
||||
commit_theme_entries(&theme, ThemeCommit::Replace, entries.into_iter())
|
||||
.expect("commit");
|
||||
let th = theme.lock().expect("lock");
|
||||
assert_eq!((th.syntax_epoch, th.face_epoch), expected);
|
||||
assert!(!th.by_capture.contains_key("keyword"), "replaced wholesale");
|
||||
assert!(th.default_style.reverse, "default_style preserved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_commit_merge_classifies_face_and_syntax_keys() {
|
||||
// Q#TH6: merge bumps syntax_epoch iff any non-face key
|
||||
// committed and face_epoch iff any face key did — bare `ui`
|
||||
// classifies as a face (Q#TH2, round 2 finding 3).
|
||||
let theme = seeded_theme();
|
||||
// One lock per observation: two `lock()` calls inside one
|
||||
// tuple expression self-deadlock (the first guard outlives
|
||||
// the second call).
|
||||
let epochs = |theme: &crate::highlight::ThemeHandle| {
|
||||
let th = theme.lock().expect("lock");
|
||||
(th.syntax_epoch, th.face_epoch)
|
||||
};
|
||||
|
||||
let face_only: Vec<mlua::Result<(String, Style)>> =
|
||||
vec![Ok(("ui.modeline".to_owned(), Style::default()))];
|
||||
commit_theme_entries(&theme, ThemeCommit::Merge, face_only.into_iter()).expect("commit");
|
||||
assert_eq!(
|
||||
epochs(&theme),
|
||||
(3, 6),
|
||||
"face-only merge bumps face_epoch only"
|
||||
);
|
||||
|
||||
let bare_ui: Vec<mlua::Result<(String, Style)>> =
|
||||
vec![Ok(("ui".to_owned(), Style::default()))];
|
||||
commit_theme_entries(&theme, ThemeCommit::Merge, bare_ui.into_iter()).expect("commit");
|
||||
assert_eq!(epochs(&theme), (3, 7), "bare ui is a face key");
|
||||
|
||||
let mixed: Vec<mlua::Result<(String, Style)>> = vec![
|
||||
Ok(("comment".to_owned(), Style::default())),
|
||||
Ok(("ui.gutter".to_owned(), Style::default())),
|
||||
];
|
||||
commit_theme_entries(&theme, ThemeCommit::Merge, mixed.into_iter()).expect("commit");
|
||||
assert_eq!(epochs(&theme), (4, 8), "mixed merge bumps both");
|
||||
|
||||
let empty: Vec<mlua::Result<(String, Style)>> = Vec::new();
|
||||
commit_theme_entries(&theme, ThemeCommit::Merge, empty.into_iter()).expect("commit");
|
||||
assert_eq!(
|
||||
epochs(&theme),
|
||||
(4, 8),
|
||||
"an empty merge commits nothing and bumps nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_query_insert_observe_round_trip() {
|
||||
let (lua, _reg, _cmds, _kms, _hks) = fresh();
|
||||
|
|
|
|||
|
|
@ -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,14 +160,27 @@ pub struct SemanticRenderState {
|
|||
/// buffer at the same generation re-uses what the frontend
|
||||
/// already has and emits nothing. First emission happens on the
|
||||
/// first frame for a buffer; further emissions only after edits.
|
||||
/// `(crdt_generation, diag_epoch)` the last emitted summary was
|
||||
/// computed against. Diagnostics arrive without a generation
|
||||
/// bump, so the epoch half catches republishes (minimap marks,
|
||||
/// T M4.6 GPU parity).
|
||||
last_summary: HashMap<BufferId, (u64, u64)>,
|
||||
/// `(crdt_generation, diag_epoch, syntax_epoch, face_epoch)` the
|
||||
/// last summary was computed against, plus that computed payload.
|
||||
/// Diagnostics arrive without a generation bump, so the diag
|
||||
/// epoch catches republishes (minimap marks, T M4.6 GPU parity);
|
||||
/// the theme epochs (Q#TH6) catch mid-session recolors —
|
||||
/// `face_epoch` belongs in the key because `ui.diag.*` feeds the
|
||||
/// marks. The payload copy backs the Q#TH6 payload-equality
|
||||
/// suppression: a face edit that leaves the summary unchanged
|
||||
/// (e.g. `ui.modeline`) recomputes once per mutation and emits
|
||||
/// nothing. The key advances on COMPUTATION, not emission — a
|
||||
/// suppressed send still inserts, or the whole-file recompute
|
||||
/// repeats every tick.
|
||||
last_summary: HashMap<BufferId, SummaryCache>,
|
||||
/// `(name, modified, diag_errors, diag_warnings, message)` last
|
||||
/// emitted as `StatusFacts` (Q#S1; `message` since v15) —
|
||||
/// cached-compare suppression.
|
||||
/// cached-compare suppression. A peer emission baseline ONLY:
|
||||
/// the diagnostic-count freeze deliberately holds no session
|
||||
/// state (rounds 3–4) — it is sourced from the diag store's
|
||||
/// retained vector, so it needs nothing here to survive the
|
||||
/// `on_buffer_snapshot_sent` reset and it holds for sessions
|
||||
/// with no history (a late joiner attaching mid-edit).
|
||||
last_status: HashMap<BufferId, (String, bool, u32, u32, Option<String>)>,
|
||||
/// Last-emitted line-number gutter mode (UX gutter arc, protocol v14) —
|
||||
/// cached-compare suppression. Seeded to `Some(Off)` (the frontend's
|
||||
|
|
@ -193,11 +206,36 @@ pub struct SemanticRenderState {
|
|||
/// viewport* (which the GPU frontend sets to the entire buffer)
|
||||
/// and clones the theme — too expensive to repeat on every tick.
|
||||
/// The styling depends only on the parse bundle, the CRDT
|
||||
/// generation, and the viewport — never the cursor — so a gate
|
||||
/// built from those lets cursor-only ticks skip the query entirely.
|
||||
/// Only the grammar (tree-sitter) path is gated; the LSP-token path
|
||||
/// has no comparably cheap handle and recomputes as before.
|
||||
/// generation, the viewport, and the theme's syntax epoch
|
||||
/// (Q#TH6) — never the cursor — so a gate built from those lets
|
||||
/// cursor-only ticks skip the query entirely while a mid-session
|
||||
/// `pmacs.theme.set` still re-ships recolored spans without an
|
||||
/// edit. Only the grammar (tree-sitter) path is gated; the
|
||||
/// LSP-token path has no comparably cheap handle and recomputes
|
||||
/// as before.
|
||||
last_style_gate: HashMap<BufferId, StyleGate>,
|
||||
/// The theme `face_epoch` the `ThemeFacts` producer last
|
||||
/// INSPECTED (Q#TH7) — `Option`, not a bare zero, because an
|
||||
/// unthemed daemon sits at `face_epoch == 0` and a `0 == 0`
|
||||
/// short-circuit would starve the first authoritative send.
|
||||
/// Advances on computation, not emission: an identical rebuild
|
||||
/// records the epoch it inspected even though nothing ships.
|
||||
last_face_epoch: Option<u64>,
|
||||
/// The face table the frontend believes (Q#TH7), seeded `None` so
|
||||
/// every attachment receives exactly one authoritative table —
|
||||
/// the empty table included — with its first emission after
|
||||
/// viewport declaration. A frontend retaining face state across
|
||||
/// attachments is therefore corrected even by an unthemed daemon.
|
||||
last_theme_faces: Option<Vec<crate::protocol::ThemeFace>>,
|
||||
/// Whether the peer negotiated protocol >= 16 (PR #120 round 1
|
||||
/// finding 3). Faces reach a semantic frontend through TWO
|
||||
/// channels: `ThemeFacts` (daemon write-loop gated) and the
|
||||
/// `ui.diag.*` colors folded into `FileStyleSummary` — an OLDER
|
||||
/// channel the version gate does not filter. A v15 peer must not
|
||||
/// receive face-derived minimap marks while its squiggles, signs,
|
||||
/// and counters stay unthemed, so this producer resolves faces
|
||||
/// only when the peer can apply the whole face table.
|
||||
peer_knows_theme_facts: bool,
|
||||
/// Cached byte↔line table for the diagnostics projection, keyed
|
||||
/// by buffer revision. Building it costs an O(buffer) rope copy
|
||||
/// plus a full scan; before this cache, that ran on *every tick*
|
||||
|
|
@ -215,6 +253,43 @@ struct DiagLineCache {
|
|||
source_len: u64,
|
||||
}
|
||||
|
||||
/// One [`SemanticRenderState::last_summary`] entry: the inputs the
|
||||
/// summary was computed against and the computed per-line payload.
|
||||
struct SummaryCache {
|
||||
/// `(crdt_generation, diag_epoch, syntax_epoch, face_epoch)`.
|
||||
key: (u64, u64, u64, u64),
|
||||
/// The computed summary — compared before emitting (Q#TH6
|
||||
/// payload-equality suppression).
|
||||
lines: Vec<Style>,
|
||||
}
|
||||
|
||||
/// The stage-1 UI face inventory (themes arc Q#TH3): the names the
|
||||
/// `ThemeFacts` producer resolves through [`crate::highlight::Theme::face`]
|
||||
/// and ships. Resolution is daemon-side — frontends do exact-name
|
||||
/// lookup on the shipped table, no walk (Q#TH7). Kept sorted; the
|
||||
/// wire table's deterministic ordering rides on it.
|
||||
const UI_FACES: &[&str] = &[
|
||||
"ui.diag.error",
|
||||
"ui.diag.hint",
|
||||
"ui.diag.info",
|
||||
"ui.diag.warning",
|
||||
"ui.gutter",
|
||||
"ui.minibuffer",
|
||||
"ui.minibuffer.candidate",
|
||||
"ui.modeline",
|
||||
"ui.search.match",
|
||||
"ui.search.match.active",
|
||||
"ui.selection",
|
||||
"ui.statusline",
|
||||
];
|
||||
|
||||
/// Read both theme mutation counters under one lock (Q#TH6).
|
||||
fn theme_epochs(state: &EditorState) -> (u64, u64) {
|
||||
let theme = state.syntax_registry.theme();
|
||||
let th = theme.lock().expect("theme mutex poisoned");
|
||||
(th.syntax_epoch, th.face_epoch)
|
||||
}
|
||||
|
||||
/// Recompute gate for [`scoped_style_spans`] on a grammar-backed
|
||||
/// buffer. Holds the current parse bundle `Arc` so its address stays
|
||||
/// stable while cached — comparing by `Arc::ptr_eq` then can't be
|
||||
|
|
@ -230,6 +305,11 @@ struct StyleGate {
|
|||
generation: u64,
|
||||
/// Declared viewport.
|
||||
visible: ByteRange,
|
||||
/// The theme's syntax mutation counter (Q#TH6): spans are a pure
|
||||
/// function of the theme too, and before this half the gate a
|
||||
/// mid-session `pmacs.theme.set` shipped nothing until the next
|
||||
/// buffer edit — the GPU kept stale colors.
|
||||
syntax_epoch: u64,
|
||||
}
|
||||
|
||||
impl StyleGate {
|
||||
|
|
@ -237,6 +317,7 @@ impl StyleGate {
|
|||
fn matches(&self, other: &Self) -> bool {
|
||||
self.generation == other.generation
|
||||
&& self.visible == other.visible
|
||||
&& self.syntax_epoch == other.syntax_epoch
|
||||
&& match (&self.bundle, &other.bundle) {
|
||||
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
|
|
@ -246,8 +327,22 @@ impl StyleGate {
|
|||
}
|
||||
|
||||
impl SemanticRenderState {
|
||||
/// Fresh session state for a peer that negotiated
|
||||
/// `negotiated_protocol_version` — the real daemon construction
|
||||
/// path (PR #120 round 1 finding 3): a `< 16` peer gets no
|
||||
/// `ThemeFacts` produced at all and, crucially, no face-derived
|
||||
/// colors folded into its `FileStyleSummary` marks.
|
||||
#[must_use]
|
||||
pub fn for_peer(frontend_id: FrontendId, negotiated_protocol_version: u32) -> Self {
|
||||
let mut s = Self::new(frontend_id);
|
||||
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
|
||||
s
|
||||
}
|
||||
|
||||
/// Fresh session state for frontend `frontend_id`: no viewport
|
||||
/// declared, nothing sent.
|
||||
/// declared, nothing sent. Assumes a current-build peer (>= 16);
|
||||
/// daemon sessions with a real negotiated version use
|
||||
/// [`Self::for_peer`].
|
||||
#[must_use]
|
||||
pub fn new(frontend_id: FrontendId) -> Self {
|
||||
Self {
|
||||
|
|
@ -268,6 +363,13 @@ impl SemanticRenderState {
|
|||
// toggle-on (or later toggle-off) ships a message.
|
||||
last_line_numbers: Some(crate::window::LineNumberMode::Off),
|
||||
last_style_gate: HashMap::new(),
|
||||
// Q#TH7: both seeded None — the first frame after viewport
|
||||
// declaration always ships an authoritative face table
|
||||
// (empty included), and the epoch gate cannot short-circuit
|
||||
// an epoch-0 daemon before that send.
|
||||
last_face_epoch: None,
|
||||
last_theme_faces: None,
|
||||
peer_knows_theme_facts: true,
|
||||
diag_line_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -284,6 +386,48 @@ impl SemanticRenderState {
|
|||
});
|
||||
}
|
||||
|
||||
/// Snapshot/baseline reset contract (PR #120 round 2 finding 1).
|
||||
///
|
||||
/// A `BufferSnapshot` resets the receiving frontend's
|
||||
/// buffer-scoped render state wholesale — spans, decorations,
|
||||
/// adornments, minimap summary, completion popup (see the GPU's
|
||||
/// `BufferSnapshot` arm) — so every buffer-scoped emission
|
||||
/// baseline this producer holds for that buffer must die with the
|
||||
/// send. Otherwise an unchanged-key revisit (the A → B → A round
|
||||
/// trip at one CRDT generation) suppresses every re-send and the
|
||||
/// frontend never regains the state until an edit, diagnostic
|
||||
/// republish, or theme mutation happens to move the key.
|
||||
///
|
||||
/// Called by the daemon wherever it writes a `BufferSnapshot` to
|
||||
/// this session's stream. Resetting when the write later fails is
|
||||
/// harmless — the failure mode is one redundant re-send, never
|
||||
/// staleness.
|
||||
///
|
||||
/// Deliberately NOT reset: `last_face_epoch` / `last_theme_faces`
|
||||
/// (`ThemeFacts` is bufferless — the frontend keeps its face
|
||||
/// table across snapshots), `last_minibuffer` (one global core
|
||||
/// instance, not buffer-scoped), `last_line_numbers`
|
||||
/// (per-frontend gutter mode, kept by the frontend across the
|
||||
/// switch), and `diag_line_cache` (a revision-keyed compute
|
||||
/// cache, not a peer-state baseline). The diagnostic-count
|
||||
/// freeze survives by construction (rounds 3–4): it is sourced
|
||||
/// from the diag store's retained vector, never from session
|
||||
/// state, so this reset cannot zero mid-edit counts. Baselines
|
||||
/// for OTHER buffers also survive — the snapshot names one
|
||||
/// buffer, and any buffer the frontend navigates to receives its
|
||||
/// own snapshot first.
|
||||
pub fn on_buffer_snapshot_sent(&mut self, buffer_id: BufferId) {
|
||||
self.last_sent.remove(&buffer_id);
|
||||
self.last_style_gate.remove(&buffer_id);
|
||||
self.last_decorations.remove(&buffer_id);
|
||||
self.last_adornments.remove(&buffer_id);
|
||||
self.last_summary.remove(&buffer_id);
|
||||
self.last_status.remove(&buffer_id);
|
||||
self.last_search_prompt.remove(&buffer_id);
|
||||
self.last_menu_prompt.remove(&buffer_id);
|
||||
self.last_completion_popup.remove(&buffer_id);
|
||||
}
|
||||
|
||||
/// Project one frame.
|
||||
///
|
||||
/// Returns up to three messages — [`InstanceMessage::StyleSpans`]
|
||||
|
|
@ -475,6 +619,8 @@ impl SemanticRenderState {
|
|||
out.extend(self.minibuffer_prompt_msg(state, vp.buffer_id));
|
||||
// --- CompletionPopup (Arc 1a Q#C5, protocol v15) ---
|
||||
out.extend(self.completion_popup_msg(state, vp.buffer_id));
|
||||
// --- ThemeFacts (UI faces; themes arc Q#TH7, protocol v16) ---
|
||||
out.extend(self.theme_facts_msg(state));
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -740,9 +886,14 @@ impl SemanticRenderState {
|
|||
/// nothing changed. Carries the facts a semantic frontend cannot
|
||||
/// derive locally: buffer name, modified flag, whole-file
|
||||
/// diagnostic counts (errors / warnings). Counts freeze at their
|
||||
/// last value while the diag store is stale — mid-edit positions
|
||||
/// are wrong but *counts* merely lag, and flickering to zero on
|
||||
/// every keystroke would be worse. The daemon's write loop keeps
|
||||
/// last published value while the diag store is stale — mid-edit
|
||||
/// positions are wrong but *counts* merely lag, and flickering
|
||||
/// to zero on every keystroke would be worse. The freeze IS the
|
||||
/// store's retained state (rounds 3–5): `mark_stale` keeps the
|
||||
/// last published diagnostics and their cached severity totals,
|
||||
/// so reading the totals while stale yields the frozen value in
|
||||
/// O(1) with no session state to lose — not to a snapshot reset,
|
||||
/// and not by attaching mid-edit. The daemon's write loop keeps
|
||||
/// the variant off wires negotiated `< 8`.
|
||||
fn status_facts_msg(
|
||||
&mut self,
|
||||
|
|
@ -761,32 +912,32 @@ impl SemanticRenderState {
|
|||
let buf = reg.get(buffer_id).ok()?;
|
||||
(buf.name().to_owned(), buf.is_modified(), message)
|
||||
};
|
||||
let counts = {
|
||||
let (diag_errors, diag_warnings) = {
|
||||
let core = state.core.borrow();
|
||||
buffer_file_uri(&core, buffer_id).and_then(|uri| {
|
||||
buffer_file_uri(&core, buffer_id).map_or((0, 0), |uri| {
|
||||
let store = state.lsp_manager.borrow().diag_store();
|
||||
let guard = store.lock().expect("diag store mutex poisoned");
|
||||
if guard.is_stale(&uri) {
|
||||
None // keep the cached counts
|
||||
} else {
|
||||
let mut errors = 0u32;
|
||||
let mut warnings = 0u32;
|
||||
for d in guard.for_uri(&uri) {
|
||||
match d.severity {
|
||||
crate::diag::DiagnosticSeverity::Error => errors += 1,
|
||||
crate::diag::DiagnosticSeverity::Warning => warnings += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some((errors, warnings))
|
||||
}
|
||||
// Read even while the store is STALE (round 4):
|
||||
// `mark_stale` keeps the last published diagnostics
|
||||
// (T M11.8) — positions are invalid mid-edit, but
|
||||
// counts merely lag, so the retained totals ARE the
|
||||
// frozen value. Sourcing the freeze from the store
|
||||
// rather than any per-session cache means a session
|
||||
// first rendering during staleness — a late joiner,
|
||||
// or a buffer first visited mid-edit — reports the
|
||||
// preserved counts instead of zeros, and the snapshot
|
||||
// reset has nothing count-related to preserve.
|
||||
// `DiagnosticStore::set` computes this tuple once
|
||||
// (round 5). StatusFacts runs at frame cadence for
|
||||
// every semantic session, so rescanning the retained
|
||||
// vector here would make stale intervals
|
||||
// O(frames * diagnostics).
|
||||
let (errors, warnings, _, _) = guard.severity_counts_for(&uri);
|
||||
(errors, warnings)
|
||||
})
|
||||
};
|
||||
let cached = self.last_status.get(&buffer_id);
|
||||
let (diag_errors, diag_warnings) =
|
||||
counts.unwrap_or_else(|| cached.map_or((0, 0), |c| (c.2, c.3)));
|
||||
let facts = (name, modified, diag_errors, diag_warnings, message);
|
||||
if cached == Some(&facts) {
|
||||
if self.last_status.get(&buffer_id) == Some(&facts) {
|
||||
return None;
|
||||
}
|
||||
let msg = InstanceMessage::StatusFacts {
|
||||
|
|
@ -910,13 +1061,48 @@ impl SemanticRenderState {
|
|||
// publish without a generation bump — key the cache on the
|
||||
// diag store's per-URI epoch as well, so a republish
|
||||
// refreshes the marks and anything else stays suppressed.
|
||||
// The theme epochs (Q#TH6) join the key so a mid-session
|
||||
// recolor refreshes the strokes — face_epoch included, since
|
||||
// `ui.diag.*` feeds the marks.
|
||||
let diag_epoch = diagnostics_epoch(state, buffer_id);
|
||||
if self.last_summary.get(&buffer_id).copied() == Some((generation, diag_epoch)) {
|
||||
let (syntax_epoch, face_epoch) = theme_epochs(state);
|
||||
// A v15 peer's marks never resolve faces (finding 3), so a
|
||||
// face mutation cannot change its summary either — zero the
|
||||
// key component rather than recompute a whole-file pass per
|
||||
// face edit just to payload-suppress it.
|
||||
let face_key = if self.peer_knows_theme_facts {
|
||||
face_epoch
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let key = (generation, diag_epoch, syntax_epoch, face_key);
|
||||
if self
|
||||
.last_summary
|
||||
.get(&buffer_id)
|
||||
.is_some_and(|c| c.key == key)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let lines = scoped_file_summary(state, buffer_id, self.peer_knows_theme_facts);
|
||||
// Payload-equality suppression (Q#TH6): a face edit that
|
||||
// leaves the summary unchanged (e.g. `ui.modeline`) emits
|
||||
// nothing — but the key still advances, on computation rather
|
||||
// than emission, or this whole-file pass would repeat every
|
||||
// tick.
|
||||
let unchanged = self
|
||||
.last_summary
|
||||
.get(&buffer_id)
|
||||
.is_some_and(|c| c.lines == lines);
|
||||
self.last_summary.insert(
|
||||
buffer_id,
|
||||
SummaryCache {
|
||||
key,
|
||||
lines: lines.clone(),
|
||||
},
|
||||
);
|
||||
if unchanged {
|
||||
return None;
|
||||
}
|
||||
let lines = scoped_file_summary(state, buffer_id);
|
||||
self.last_summary
|
||||
.insert(buffer_id, (generation, diag_epoch));
|
||||
Some(InstanceMessage::FileStyleSummary {
|
||||
buffer_id,
|
||||
generation,
|
||||
|
|
@ -924,6 +1110,49 @@ impl SemanticRenderState {
|
|||
})
|
||||
}
|
||||
|
||||
/// The `ThemeFacts` message for this frame, or `None` when the
|
||||
/// face table is unchanged (themes arc Q#TH7, protocol v16).
|
||||
/// Resolves the [`UI_FACES`] inventory through
|
||||
/// [`crate::highlight::Theme::face`] under one lock — resolution
|
||||
/// is daemon-side; frontends do exact-name lookup, no walk. The
|
||||
/// `last_face_epoch` gate keeps unchanged ticks to one u64
|
||||
/// compare; `last_theme_faces` (the frontend's believed table)
|
||||
/// decides emission. Both advance on computation, and both seed
|
||||
/// `None`, so every attachment ships exactly one authoritative
|
||||
/// table — the empty table included — on its first frame.
|
||||
fn theme_facts_msg(&mut self, state: &EditorState) -> Option<InstanceMessage> {
|
||||
// PR #120 round 1 finding 3: never even produced for a peer
|
||||
// below v16 (the daemon write-loop gate remains as the
|
||||
// belt-and-braces filter).
|
||||
if !self.peer_knows_theme_facts {
|
||||
return None;
|
||||
}
|
||||
let theme = state.syntax_registry.theme();
|
||||
let (faces, face_epoch) = {
|
||||
let th = theme.lock().expect("theme mutex poisoned");
|
||||
if self.last_face_epoch == Some(th.face_epoch) {
|
||||
return None;
|
||||
}
|
||||
let faces: Vec<crate::protocol::ThemeFace> = UI_FACES
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
th.face(name).map(|style| crate::protocol::ThemeFace {
|
||||
name: (*name).to_owned(),
|
||||
style,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(faces, th.face_epoch)
|
||||
};
|
||||
self.last_face_epoch = Some(face_epoch);
|
||||
let unchanged = self.last_theme_faces.as_ref() == Some(&faces);
|
||||
self.last_theme_faces = Some(faces.clone());
|
||||
if unchanged {
|
||||
return None;
|
||||
}
|
||||
Some(InstanceMessage::ThemeFacts { faces })
|
||||
}
|
||||
|
||||
/// Project the [`Decoration`] set intersecting the declared
|
||||
/// viewport: the session's selection (instance-authoritative,
|
||||
/// byte-native) and LSP diagnostics (line/col → byte, severity →
|
||||
|
|
@ -1481,6 +1710,7 @@ fn grammar_style_key(
|
|||
bundle: handle.current(),
|
||||
generation,
|
||||
visible: vp.visible,
|
||||
syntax_epoch: theme_epochs(state).0,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1676,7 +1906,11 @@ fn lsp_scoped_style_spans(state: &EditorState, vp: &DeclaredViewport) -> Vec<Sty
|
|||
/// `O(spans × lines)` in the worst case; the caller short-circuits on
|
||||
/// unchanged CRDT generation so this only runs on first sight of a
|
||||
/// buffer or after an edit, not per frame.
|
||||
fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
|
||||
fn scoped_file_summary(
|
||||
state: &EditorState,
|
||||
buffer_id: BufferId,
|
||||
resolve_faces: bool,
|
||||
) -> Vec<Style> {
|
||||
let source = {
|
||||
let core = state.core.borrow();
|
||||
let registry = core.registry.clone();
|
||||
|
|
@ -1707,7 +1941,7 @@ fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
|
|||
if spans.is_empty() {
|
||||
// No styled runs — but diagnostic marks are independent of
|
||||
// syntax styling (a plain-text buffer can still have lints).
|
||||
overlay_diagnostic_marks(state, buffer_id, &mut out);
|
||||
overlay_diagnostic_marks(state, buffer_id, &mut out, resolve_faces);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
@ -1740,7 +1974,7 @@ fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
|
|||
*line_dominant = winner.0;
|
||||
}
|
||||
}
|
||||
overlay_diagnostic_marks(state, buffer_id, &mut out);
|
||||
overlay_diagnostic_marks(state, buffer_id, &mut out, resolve_faces);
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -1752,7 +1986,12 @@ fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
|
|||
/// describe pre-edit text, same discipline as the decorations
|
||||
/// producer (the marks return on republish, which bumps the diag
|
||||
/// epoch and recomputes this summary).
|
||||
fn overlay_diagnostic_marks(state: &EditorState, buffer_id: BufferId, lines: &mut [Style]) {
|
||||
fn overlay_diagnostic_marks(
|
||||
state: &EditorState,
|
||||
buffer_id: BufferId,
|
||||
lines: &mut [Style],
|
||||
resolve_faces: bool,
|
||||
) {
|
||||
let uri = {
|
||||
let core = state.core.borrow();
|
||||
let Some(uri) = buffer_file_uri(&core, buffer_id) else {
|
||||
|
|
@ -1776,9 +2015,22 @@ fn overlay_diagnostic_marks(state: &EditorState, buffer_id: BufferId, lines: &mu
|
|||
*slot = Some(slot.map_or(d.severity, |s| s.min(d.severity)));
|
||||
}
|
||||
}
|
||||
// Themes Q#TH5: the mark color is the RESOLVED severity color —
|
||||
// `ui.diag.*` faces reach the minimap through this summary. The
|
||||
// diag `Default`-fg policy guarantees a diagnosed line never
|
||||
// writes `Default` here, which the GPU reads as "no mark".
|
||||
// `resolve_faces` is false for a peer below v16 (PR #120 round 1
|
||||
// finding 3): this summary is an ungated pre-v16 channel, and a
|
||||
// v15 frontend must not get face-derived marks on one surface
|
||||
// while every other severity surface stays unthemed.
|
||||
let theme = resolve_faces.then(|| {
|
||||
let handle = state.syntax_registry.theme();
|
||||
let t = handle.lock().expect("theme mutex poisoned");
|
||||
t.clone()
|
||||
});
|
||||
for (line, severity) in lines.iter_mut().zip(best) {
|
||||
if let Some(s) = severity {
|
||||
line.underline_color = s.underline_color();
|
||||
line.underline_color = crate::diag::severity_color(theme.as_ref(), s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1877,10 +2129,256 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Pull the `ThemeFacts` table out of a frame, if any.
|
||||
fn theme_facts_of(msgs: &[InstanceMessage]) -> Option<Vec<crate::protocol::ThemeFace>> {
|
||||
msgs.iter().find_map(|m| match m {
|
||||
InstanceMessage::ThemeFacts { faces } => Some(faces.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Simulate a committed face mutation: what `pmacs.theme.merge`
|
||||
/// does after its transactional parse (insert + face-epoch bump).
|
||||
fn merge_face(state: &EditorState, name: &str, style: Style) {
|
||||
let theme = state.syntax_registry.theme();
|
||||
let mut th = theme.lock().expect("theme mutex poisoned");
|
||||
th.insert(name, style);
|
||||
th.face_epoch += 1;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_facts_authoritative_empty_then_silent_then_face_change_emits() {
|
||||
// Q#TH7: the first frame after viewport declaration ships the
|
||||
// authoritative table — EMPTY for an unthemed daemon, which
|
||||
// the Option epoch gate must not short-circuit at 0 == 0 —
|
||||
// then unchanged ticks say nothing; a face commit re-emits
|
||||
// the resolved table.
|
||||
let state = empty_state();
|
||||
let mut s = local();
|
||||
let buffer_id = active_buffer(&state);
|
||||
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
|
||||
|
||||
let first = s.render_frame(&state);
|
||||
assert_eq!(
|
||||
theme_facts_of(&first),
|
||||
Some(Vec::new()),
|
||||
"an unthemed attachment still receives one authoritative empty table"
|
||||
);
|
||||
assert_eq!(
|
||||
theme_facts_of(&s.render_frame(&state)),
|
||||
None,
|
||||
"unchanged ticks emit nothing"
|
||||
);
|
||||
|
||||
merge_face(
|
||||
&state,
|
||||
"ui.gutter",
|
||||
Style {
|
||||
fg: crate::cell::Color::Indexed(99),
|
||||
..Style::default()
|
||||
},
|
||||
);
|
||||
let facts = theme_facts_of(&s.render_frame(&state)).expect("face change emits");
|
||||
assert_eq!(facts.len(), 1);
|
||||
assert_eq!(facts[0].name, "ui.gutter");
|
||||
assert_eq!(facts[0].style.fg, crate::cell::Color::Indexed(99));
|
||||
assert_eq!(
|
||||
theme_facts_of(&s.render_frame(&state)),
|
||||
None,
|
||||
"and suppresses again once shipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_facts_resolution_is_daemon_side() {
|
||||
// Q#TH7 / acceptance 15: with only `ui.diag` set, the shipped
|
||||
// table carries the four concrete `ui.diag.*` children — the
|
||||
// walk happens here, never in a frontend.
|
||||
let state = empty_state();
|
||||
let mut s = local();
|
||||
let buffer_id = active_buffer(&state);
|
||||
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
|
||||
let _ = s.render_frame(&state);
|
||||
|
||||
merge_face(
|
||||
&state,
|
||||
"ui.diag",
|
||||
Style {
|
||||
fg: crate::cell::Color::Indexed(93),
|
||||
..Style::default()
|
||||
},
|
||||
);
|
||||
let facts = theme_facts_of(&s.render_frame(&state)).expect("emits");
|
||||
let names: Vec<&str> = facts.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"ui.diag.error",
|
||||
"ui.diag.hint",
|
||||
"ui.diag.info",
|
||||
"ui.diag.warning"
|
||||
],
|
||||
"only the concrete stage-1 children ship, sorted"
|
||||
);
|
||||
assert!(
|
||||
facts
|
||||
.iter()
|
||||
.all(|f| f.style.fg == crate::cell::Color::Indexed(93)),
|
||||
"each child resolved through the parent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_facts_identical_rebuild_advances_epoch_without_emitting() {
|
||||
// Q#TH7 / acceptance 14: an epoch bump with an unchanged
|
||||
// table (an identical re-merge) emits nothing but still
|
||||
// records the inspected epoch — the cache advances on
|
||||
// computation, or every subsequent tick would rebuild.
|
||||
let state = empty_state();
|
||||
let mut s = local();
|
||||
let buffer_id = active_buffer(&state);
|
||||
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
|
||||
let _ = s.render_frame(&state);
|
||||
|
||||
let bumped = {
|
||||
let theme = state.syntax_registry.theme();
|
||||
let mut th = theme.lock().expect("lock");
|
||||
th.face_epoch += 1; // identical re-merge: no table change
|
||||
th.face_epoch
|
||||
};
|
||||
assert_eq!(
|
||||
theme_facts_of(&s.render_frame(&state)),
|
||||
None,
|
||||
"identical rebuild is suppressed"
|
||||
);
|
||||
assert_eq!(
|
||||
s.last_face_epoch,
|
||||
Some(bumped),
|
||||
"the inspected epoch advanced despite the suppressed send"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_cache_key_advances_on_suppressed_emission() {
|
||||
// Q#TH6 / acceptance 14: a face mutation that leaves the
|
||||
// summary unchanged (ui.modeline touches no minimap stroke)
|
||||
// recomputes once, emits nothing, and STILL advances the
|
||||
// cache key — otherwise the whole-file pass repeats per tick.
|
||||
let state = empty_state();
|
||||
let mut s = local();
|
||||
let buffer_id = active_buffer(&state);
|
||||
s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
|
||||
let first = s.render_frame(&state);
|
||||
assert!(
|
||||
first
|
||||
.iter()
|
||||
.any(|m| matches!(m, InstanceMessage::FileStyleSummary { .. })),
|
||||
"first frame ships the summary"
|
||||
);
|
||||
|
||||
merge_face(&state, "ui.modeline", Style::default());
|
||||
let (_, _, _, face_epoch) = {
|
||||
let theme = state.syntax_registry.theme();
|
||||
let th = theme.lock().expect("lock");
|
||||
(0, 0, th.syntax_epoch, th.face_epoch)
|
||||
};
|
||||
let next = s.render_frame(&state);
|
||||
assert!(
|
||||
!next
|
||||
.iter()
|
||||
.any(|m| matches!(m, InstanceMessage::FileStyleSummary { .. })),
|
||||
"an unchanged summary is suppressed"
|
||||
);
|
||||
assert_eq!(
|
||||
s.last_summary
|
||||
.get(&buffer_id)
|
||||
.expect("cache entry exists")
|
||||
.key
|
||||
.3,
|
||||
face_epoch,
|
||||
"the cache key advanced despite the suppressed send"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_reset_drops_one_buffers_baselines_and_keeps_the_rest() {
|
||||
// PR #120 round 2 finding 1 — the reset contract's scope: a
|
||||
// `BufferSnapshot` for buffer A kills A's buffer-scoped
|
||||
// emission baselines (here: summary and status, the two the
|
||||
// A → B → A round trip visibly strands) while OTHER buffers'
|
||||
// baselines and the bufferless ThemeFacts pair survive.
|
||||
let state = empty_state();
|
||||
let mut s = local();
|
||||
let a = active_buffer(&state);
|
||||
let b = {
|
||||
let core = state.core.borrow();
|
||||
let mut reg = core.registry.borrow_mut();
|
||||
reg.create("other")
|
||||
};
|
||||
s.set_viewport(a, ByteRange { start: 0, end: 64 }, 0);
|
||||
let first = s.render_frame(&state);
|
||||
assert!(
|
||||
first
|
||||
.iter()
|
||||
.any(|m| matches!(m, InstanceMessage::FileStyleSummary { .. })),
|
||||
"first frame ships A's summary"
|
||||
);
|
||||
s.set_viewport(b, ByteRange { start: 0, end: 64 }, 0);
|
||||
let _ = s.render_frame(&state);
|
||||
assert!(s.last_summary.contains_key(&a));
|
||||
assert!(s.last_summary.contains_key(&b));
|
||||
assert!(s.last_status.contains_key(&a));
|
||||
let facts_baseline = s.last_theme_faces.clone();
|
||||
assert!(facts_baseline.is_some(), "first frame shipped ThemeFacts");
|
||||
|
||||
s.on_buffer_snapshot_sent(a);
|
||||
|
||||
assert!(
|
||||
!s.last_summary.contains_key(&a) && !s.last_status.contains_key(&a),
|
||||
"A's baselines die with A's snapshot"
|
||||
);
|
||||
assert!(
|
||||
s.last_summary.contains_key(&b),
|
||||
"B's baselines survive A's snapshot"
|
||||
);
|
||||
assert_eq!(
|
||||
s.last_theme_faces, facts_baseline,
|
||||
"ThemeFacts is bufferless — the face table survives snapshots"
|
||||
);
|
||||
|
||||
// And the behavioral consequence: revisiting A at the SAME
|
||||
// generation re-ships the summary the frontend just dropped.
|
||||
s.set_viewport(a, ByteRange { start: 0, end: 64 }, 0);
|
||||
let back = s.render_frame(&state);
|
||||
assert!(
|
||||
back.iter()
|
||||
.any(|m| matches!(m, InstanceMessage::FileStyleSummary { .. })),
|
||||
"the unchanged-generation revisit re-ships A's summary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_gate_differs_when_syntax_epoch_bumps() {
|
||||
// Q#TH6: the gate is a pure function of the theme too — a
|
||||
// syntax-epoch bump must force the span recompute (the
|
||||
// pre-existing mid-session staleness bug).
|
||||
let g1 = StyleGate {
|
||||
bundle: None,
|
||||
generation: 1,
|
||||
visible: ByteRange { start: 0, end: 10 },
|
||||
syntax_epoch: 0,
|
||||
};
|
||||
let mut g2 = g1.clone();
|
||||
assert!(g1.matches(&g2), "identical gates match");
|
||||
g2.syntax_epoch = 1;
|
||||
assert!(!g1.matches(&g2), "a theme mutation breaks the match");
|
||||
}
|
||||
|
||||
/// All `InstanceMessage` variants the semantic projection may
|
||||
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
|
||||
/// `FileStyleSummary`, `StatusFacts` (Q#S1), or `SearchPrompt`
|
||||
/// (Q#SR5) — never `CellDelta`, grid `Cursor`, or the still-unwired
|
||||
/// `FileStyleSummary`, `StatusFacts` (Q#S1), `SearchPrompt`
|
||||
/// (Q#SR5), `LineNumbers`, or `ThemeFacts` (Q#TH7) — never
|
||||
/// `CellDelta`, grid `Cursor`, or the still-unwired
|
||||
/// `BlockAdornments` / `FoldState` families.
|
||||
fn assert_semantic_only(msgs: &[InstanceMessage]) {
|
||||
for m in msgs {
|
||||
|
|
@ -1894,6 +2392,7 @@ mod tests {
|
|||
| InstanceMessage::StatusFacts { .. }
|
||||
| InstanceMessage::SearchPrompt { .. }
|
||||
| InstanceMessage::LineNumbers { .. }
|
||||
| InstanceMessage::ThemeFacts { .. }
|
||||
),
|
||||
"semantic projection emitted an unexpected variant: {m:?}"
|
||||
);
|
||||
|
|
@ -2063,12 +2562,14 @@ mod tests {
|
|||
// (the frontend clears its viewport), carrying empty segments.
|
||||
// FileStyleSummary also emits on the first frame for this buffer
|
||||
// (post-M11 minimap producer, generation-keyed), as does
|
||||
// StatusFacts (Q#S1, cached-compare).
|
||||
// StatusFacts (Q#S1, cached-compare) and the authoritative
|
||||
// ThemeFacts table (Q#TH7 — empty for an unthemed daemon).
|
||||
let first = s.render_frame(&state);
|
||||
assert_eq!(
|
||||
first.len(),
|
||||
4,
|
||||
"first frame ships StyleSpans + Decorations + FileStyleSummary + StatusFacts"
|
||||
5,
|
||||
"first frame ships StyleSpans + Decorations + FileStyleSummary \
|
||||
+ StatusFacts + ThemeFacts"
|
||||
);
|
||||
assert_semantic_only(&first);
|
||||
let (style_full, _) = style_segments(&first).expect("StyleSpans present");
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue