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
|
is: one capability bit, ~five instance→frontend interpretation
|
||||||
variants, and one frontend→instance `Viewport` variant.
|
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
|
## Capability and version mechanics
|
||||||
|
|
||||||
Identical pattern to `crdt_replica`:
|
Identical pattern to `crdt_replica`:
|
||||||
|
|
@ -205,6 +225,26 @@ ResourceOffer {
|
||||||
mime: String,
|
mime: String,
|
||||||
body: ResourceBody, // Inline(Vec<u8>) | Uri(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
|
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,
|
InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent,
|
||||||
LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind,
|
LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind,
|
||||||
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
|
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
|
||||||
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, is_builtin_pair_char,
|
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, ThemeFace,
|
||||||
is_supported_protocol_version, negotiate_capabilities,
|
is_builtin_pair_char, is_supported_protocol_version, negotiate_capabilities,
|
||||||
};
|
};
|
||||||
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};
|
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 candidate count (the window is a slice of this).
|
||||||
total: u32,
|
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
|
/// 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`,
|
/// (encoding change to that variant; its gate moved `>= 8` → `>= 15`,
|
||||||
/// so a v14 peer's status band goes dark rather than mis-decoding —
|
/// so a v14 peer's status band goes dark rather than mis-decoding —
|
||||||
/// the v10 `SearchPrompt` / v14 `LineNumbers` shape).
|
/// 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
|
/// 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
|
/// 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`
|
/// Q#C5: extended to `[6, ..., 15]`. `InstanceMessage::CompletionPopup`
|
||||||
/// is additive and daemon-gated per session.
|
/// 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
|
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||||
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
||||||
|
|
|
||||||
|
|
@ -984,6 +984,7 @@ fn dispatcher_loop(
|
||||||
upgraded,
|
upgraded,
|
||||||
&session_registry,
|
&session_registry,
|
||||||
&mut streams,
|
&mut streams,
|
||||||
|
&mut semantic_states,
|
||||||
);
|
);
|
||||||
// The broadcast just delivered this buffer to this
|
// The broadcast just delivered this buffer to this
|
||||||
// frontend too; record it so the follow check below
|
// 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)
|
&& last_active_buffer_sent.get(fid) != Some(&active_now)
|
||||||
{
|
{
|
||||||
send_buffer_snapshot_to_frontend(editor, active_now, *fid, &mut streams);
|
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);
|
last_active_buffer_sent.insert(*fid, active_now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1125,6 +1134,11 @@ fn dispatcher_loop(
|
||||||
let peer_knows_completion_popup = session_registry
|
let peer_knows_completion_popup = session_registry
|
||||||
.session_state(*fid)
|
.session_state(*fid)
|
||||||
.is_some_and(|s| s.negotiated_protocol_version >= 15);
|
.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 {
|
for msg in &messages {
|
||||||
if !peer_knows_status_facts
|
if !peer_knows_status_facts
|
||||||
&& matches!(msg, InstanceMessage::StatusFacts { .. })
|
&& matches!(msg, InstanceMessage::StatusFacts { .. })
|
||||||
|
|
@ -1160,6 +1174,10 @@ fn dispatcher_loop(
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if !peer_knows_theme_facts && matches!(msg, InstanceMessage::ThemeFacts { .. })
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
|
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
|
||||||
// jitter site: render-write latency.
|
// jitter site: render-write latency.
|
||||||
//
|
//
|
||||||
|
|
@ -1366,6 +1384,9 @@ fn handle_session_established(
|
||||||
// fires: the semantic frontend holds the rope locally and the
|
// fires: the semantic frontend holds the rope locally and the
|
||||||
// semantic frame ships no text.
|
// semantic frame ships no text.
|
||||||
let semantic_render = session_state.negotiated_capabilities.semantic_render;
|
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 {
|
if crdt_replica {
|
||||||
send_buffer_snapshots(editor, &mut write_stream);
|
send_buffer_snapshots(editor, &mut write_stream);
|
||||||
}
|
}
|
||||||
|
|
@ -1377,7 +1398,14 @@ fn handle_session_established(
|
||||||
if semantic_render {
|
if semantic_render {
|
||||||
semantic_states.insert(
|
semantic_states.insert(
|
||||||
frontend_id,
|
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 {
|
} else {
|
||||||
let mut render_state = RenderState::new(initial_size);
|
let mut render_state = RenderState::new(initial_size);
|
||||||
|
|
@ -1844,6 +1872,7 @@ fn broadcast_buffer_snapshot_to_replicas(
|
||||||
buffer_id: crate::buffer::BufferId,
|
buffer_id: crate::buffer::BufferId,
|
||||||
session_registry: &SessionRegistry,
|
session_registry: &SessionRegistry,
|
||||||
streams: &mut HashMap<FrontendId, UnixStream>,
|
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 {
|
let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1862,6 +1891,12 @@ fn broadcast_buffer_snapshot_to_replicas(
|
||||||
if let Err(e) = write_message(stream, &msg) {
|
if let Err(e) = write_message(stream, &msg) {
|
||||||
eprintln!("pmacs: F29 send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}");
|
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)]
|
#[derive(Default)]
|
||||||
pub struct DiagnosticStore {
|
pub struct DiagnosticStore {
|
||||||
by_uri: HashMap<String, Vec<Diagnostic>>,
|
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
|
/// URIs whose stored diagnostics are known to be out of date
|
||||||
/// because a `textDocument/didChange` was issued after the last
|
/// because a `textDocument/didChange` was issued after the last
|
||||||
/// `publishDiagnostics` was absorbed. `Self::set` clears entries
|
/// `publishDiagnostics` was absorbed. `Self::set` clears entries
|
||||||
|
|
@ -210,6 +214,20 @@ pub struct DiagnosticStore {
|
||||||
epochs: HashMap<String, u64>,
|
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 {
|
impl DiagnosticStore {
|
||||||
/// Empty store.
|
/// Empty store.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -227,11 +245,14 @@ impl DiagnosticStore {
|
||||||
pub fn set(&mut self, uri: impl Into<String>, mut diags: Vec<Diagnostic>) {
|
pub fn set(&mut self, uri: impl Into<String>, mut diags: Vec<Diagnostic>) {
|
||||||
diags.sort_by(Diagnostic::compare_by_position);
|
diags.sort_by(Diagnostic::compare_by_position);
|
||||||
let uri = uri.into();
|
let uri = uri.into();
|
||||||
|
let counts = count_severities(&diags);
|
||||||
self.stale_uris.remove(&uri);
|
self.stale_uris.remove(&uri);
|
||||||
*self.epochs.entry(uri.clone()).or_insert(0) += 1;
|
*self.epochs.entry(uri.clone()).or_insert(0) += 1;
|
||||||
if diags.is_empty() {
|
if diags.is_empty() {
|
||||||
self.by_uri.remove(&uri);
|
self.by_uri.remove(&uri);
|
||||||
|
self.severity_counts.remove(&uri);
|
||||||
} else {
|
} else {
|
||||||
|
self.severity_counts.insert(uri.clone(), counts);
|
||||||
self.by_uri.insert(uri, diags);
|
self.by_uri.insert(uri, diags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -240,6 +261,7 @@ impl DiagnosticStore {
|
||||||
/// no entry to be stale about.
|
/// no entry to be stale about.
|
||||||
pub fn clear(&mut self, uri: &str) {
|
pub fn clear(&mut self, uri: &str) {
|
||||||
self.by_uri.remove(uri);
|
self.by_uri.remove(uri);
|
||||||
|
self.severity_counts.remove(uri);
|
||||||
self.stale_uris.remove(uri);
|
self.stale_uris.remove(uri);
|
||||||
*self.epochs.entry(uri.to_owned()).or_insert(0) += 1;
|
*self.epochs.entry(uri.to_owned()).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -285,19 +307,26 @@ impl DiagnosticStore {
|
||||||
let mut w = 0;
|
let mut w = 0;
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
let mut h = 0;
|
let mut h = 0;
|
||||||
for diags in self.by_uri.values() {
|
for &(errors, warnings, information, hints) in self.severity_counts.values() {
|
||||||
for d in diags {
|
e += errors as usize;
|
||||||
match d.severity {
|
w += warnings as usize;
|
||||||
DiagnosticSeverity::Error => e += 1,
|
i += information as usize;
|
||||||
DiagnosticSeverity::Warning => w += 1,
|
h += hints as usize;
|
||||||
DiagnosticSeverity::Information => i += 1,
|
|
||||||
DiagnosticSeverity::Hint => h += 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
(e, w, i, h)
|
(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.
|
/// Per-URI count.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn count_for(&self, uri: &str) -> usize {
|
pub fn count_for(&self, uri: &str) -> usize {
|
||||||
|
|
@ -363,63 +392,61 @@ pub fn make_shared_store() -> SharedDiagStore {
|
||||||
/// [`crate::text_view`] and [`crate::highlight`].
|
/// [`crate::text_view`] and [`crate::highlight`].
|
||||||
const TAB_WIDTH: u32 = 8;
|
const TAB_WIDTH: u32 = 8;
|
||||||
|
|
||||||
/// Style applied to bytes covered by an `Error` diagnostic. Wavy
|
/// The RESOLVED severity color (themes arc Q#TH5): the `ui.diag.*`
|
||||||
/// underline colored via `underline_color` (not `fg`) so the
|
/// face's `fg` when a face is set with a concrete color, else the
|
||||||
/// squiggle reads red while the syntax view's text color survives
|
/// built-in [`DiagnosticSeverity::underline_color`]. The diag family
|
||||||
/// underneath (T M4.6, protocol v6).
|
/// carries a special `Default` policy — `Default` fg means the
|
||||||
fn error_style() -> Style {
|
/// built-in severity color, never "plain" — because the color doubles
|
||||||
Style {
|
/// as the *presence* encoding in the minimap summary
|
||||||
underline: UnderlineStyle::Curly,
|
/// (`FileStyleSummary.underline_color`, where `Default` reads as "no
|
||||||
underline_color: DiagnosticSeverity::Error.underline_color(),
|
/// mark"), so a plain severity color is unrepresentable and
|
||||||
..Style::default()
|
/// `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.
|
/// Style applied to bytes covered by a diagnostic: severity-shaped
|
||||||
fn warning_style() -> Style {
|
/// 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 {
|
Style {
|
||||||
underline: UnderlineStyle::Curly,
|
underline,
|
||||||
underline_color: DiagnosticSeverity::Warning.underline_color(),
|
underline_color: color,
|
||||||
..Style::default()
|
..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
|
/// 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
|
/// (T M4.6). The TUI reserves no gutter column, so the sign is a
|
||||||
/// severity-colored *background* on the line's first cell: the
|
/// severity-colored *background* on the line's first cell: the
|
||||||
/// glyph and its syntax color survive (the view contract is
|
/// glyph and its syntax color survive (the view contract is
|
||||||
/// style-only), and zero-width diagnostics — invisible to the
|
/// style-only), and zero-width diagnostics — invisible to the
|
||||||
/// underline pass — still get a visible artifact.
|
/// underline pass — still get a visible artifact. `color` is the
|
||||||
fn marker_style_for(severity: DiagnosticSeverity) -> Style {
|
/// resolved severity color ([`severity_color`]).
|
||||||
|
fn marker_style_for(color: Color) -> Style {
|
||||||
Style {
|
Style {
|
||||||
bg: severity.underline_color(),
|
bg: color,
|
||||||
..Style::default()
|
..Style::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -436,15 +463,25 @@ pub struct DiagnosticView {
|
||||||
/// Shared store; mutated by the LSP manager, read by this view
|
/// Shared store; mutated by the LSP manager, read by this view
|
||||||
/// on every render.
|
/// on every render.
|
||||||
store: SharedDiagStore,
|
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 {
|
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]
|
#[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 {
|
Self {
|
||||||
uri: uri.into(),
|
uri: uri.into(),
|
||||||
store,
|
store,
|
||||||
|
theme,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -502,8 +539,15 @@ impl View for DiagnosticView {
|
||||||
let mut line_markers: std::collections::HashMap<u32, DiagnosticSeverity> =
|
let mut line_markers: std::collections::HashMap<u32, DiagnosticSeverity> =
|
||||||
std::collections::HashMap::new();
|
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 {
|
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
|
// Apply to each line the diagnostic touches. LSP ranges
|
||||||
// are half-open at the end position; if end_col == 0
|
// are half-open at the end position; if end_col == 0
|
||||||
// the diagnostic stops at the start of `end_line` so
|
// the diagnostic stops at the start of `end_line` so
|
||||||
|
|
@ -580,6 +624,7 @@ impl View for DiagnosticView {
|
||||||
viewport.gutter_w,
|
viewport.gutter_w,
|
||||||
max_cols,
|
max_cols,
|
||||||
&line_markers,
|
&line_markers,
|
||||||
|
theme.as_ref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -597,9 +642,11 @@ fn paint_line_markers(
|
||||||
gutter_w: u32,
|
gutter_w: u32,
|
||||||
max_cols: u32,
|
max_cols: u32,
|
||||||
line_markers: &std::collections::HashMap<u32, DiagnosticSeverity>,
|
line_markers: &std::collections::HashMap<u32, DiagnosticSeverity>,
|
||||||
|
theme: Option<&crate::highlight::Theme>,
|
||||||
) {
|
) {
|
||||||
for (&row_offset, &severity) in line_markers {
|
for (&row_offset, &severity) in line_markers {
|
||||||
let row = cell_origin.row + row_offset;
|
let row = cell_origin.row + row_offset;
|
||||||
|
let color = severity_color(theme, severity);
|
||||||
if gutter_w > 0 {
|
if gutter_w > 0 {
|
||||||
let cell = cells.at(CellCoord::new(
|
let cell = cells.at(CellCoord::new(
|
||||||
row,
|
row,
|
||||||
|
|
@ -607,12 +654,12 @@ fn paint_line_markers(
|
||||||
));
|
));
|
||||||
cell.glyph = Glyph::Char(severity.gutter_glyph());
|
cell.glyph = Glyph::Char(severity.gutter_glyph());
|
||||||
cell.style = Style {
|
cell.style = Style {
|
||||||
fg: severity.underline_color(),
|
fg: color,
|
||||||
..Style::default()
|
..Style::default()
|
||||||
};
|
};
|
||||||
} else if max_cols > 0 {
|
} else if max_cols > 0 {
|
||||||
let cell = cells.at(CellCoord::new(row, cell_origin.col));
|
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));
|
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]
|
#[test]
|
||||||
fn empty_set_clears_uri() {
|
fn empty_set_clears_uri() {
|
||||||
let mut s = DiagnosticStore::new();
|
let mut s = DiagnosticStore::new();
|
||||||
s.set("a", vec![diag(0, DiagnosticSeverity::Error, "x")]);
|
s.set("a", vec![diag(0, DiagnosticSeverity::Error, "x")]);
|
||||||
s.set("a", Vec::new());
|
s.set("a", Vec::new());
|
||||||
assert!(s.for_uri("a").is_empty());
|
assert!(s.for_uri("a").is_empty());
|
||||||
|
assert_eq!(s.severity_counts_for("a"), (0, 0, 0, 0));
|
||||||
assert_eq!(s.uris().count(), 0);
|
assert_eq!(s.uris().count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -921,7 +1003,7 @@ mod tests {
|
||||||
DiagnosticSeverity::Information,
|
DiagnosticSeverity::Information,
|
||||||
DiagnosticSeverity::Hint,
|
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_eq!(style.fg, Color::Default, "{s:?} must not set fg");
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
style.underline_color,
|
style.underline_color,
|
||||||
|
|
@ -959,7 +1041,7 @@ mod tests {
|
||||||
// `pmacs.window._overlay_kinds()` introspection (task #23 wire-up,
|
// `pmacs.window._overlay_kinds()` introspection (task #23 wire-up,
|
||||||
// mirroring "syntax-highlight" / LspStyleView) relies on this.
|
// mirroring "syntax-highlight" / LspStyleView) relies on this.
|
||||||
let store = make_shared_store();
|
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");
|
assert_eq!(view.kind(), "diagnostic");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -984,7 +1066,7 @@ mod tests {
|
||||||
})
|
})
|
||||||
.expect("seed buffer");
|
.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 backing = vec![Cell::default(); 10];
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
@ -1046,7 +1128,7 @@ mod tests {
|
||||||
})
|
})
|
||||||
.expect("seed buffer");
|
.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];
|
let mut backing = vec![Cell::default(); 30];
|
||||||
// Pre-paint glyphs at column 0 to pin the style-only contract.
|
// Pre-paint glyphs at column 0 to pin the style-only contract.
|
||||||
backing[0].glyph = Glyph::Char('h');
|
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
|
// A 2-cell gutter: text is shifted to column 2, signs land at
|
||||||
// window column 0 (`cell_origin.col - gutter_w`).
|
// 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 backing = vec![Cell::default(); 30];
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
@ -1191,7 +1273,7 @@ mod tests {
|
||||||
})
|
})
|
||||||
.expect("seed buffer");
|
.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 backing = vec![Cell::default(); 20];
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
|
||||||
172
src/editor.rs
172
src/editor.rs
|
|
@ -206,6 +206,11 @@ impl EditorState {
|
||||||
lua_host.registry(),
|
lua_host.registry(),
|
||||||
)
|
)
|
||||||
.expect("install pmacs.parse");
|
.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
|
lua_host
|
||||||
.eval(
|
.eval(
|
||||||
Some("@pmacs/builtin/runtime/syntax.lua"),
|
Some("@pmacs/builtin/runtime/syntax.lua"),
|
||||||
|
|
@ -2093,6 +2098,14 @@ pub fn paint_frame(
|
||||||
}
|
}
|
||||||
let text_rows = term_size.rows - 1;
|
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 mut core_ref = state.core.borrow_mut();
|
||||||
let core: &mut EditorCore = &mut core_ref;
|
let core: &mut EditorCore = &mut core_ref;
|
||||||
|
|
||||||
|
|
@ -2175,12 +2188,12 @@ pub fn paint_frame(
|
||||||
// overlay in attach order. See [`crate::view::View`].
|
// overlay in attach order. See [`crate::view::View`].
|
||||||
window.text_view.render(buf, viewport, grid);
|
window.text_view.render(buf, viewport, grid);
|
||||||
if gutter_w > 0 {
|
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 {
|
for overlay in &mut window.overlays {
|
||||||
overlay.render(buf, viewport, grid);
|
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
|
// Mode line for this window. Painted last so the line
|
||||||
// itself is always visible regardless of overlay activity.
|
// itself is always visible regardless of overlay activity.
|
||||||
let coord = window
|
let coord = window
|
||||||
|
|
@ -2212,21 +2225,29 @@ pub fn paint_frame(
|
||||||
coord.col,
|
coord.col,
|
||||||
&scroll,
|
&scroll,
|
||||||
&diags,
|
&diags,
|
||||||
|
mode_line_style(&theme),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
drop(reg);
|
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
|
// An active isearch owns the bottom row (its prompt + match
|
||||||
// readout), but the terminal cursor stays in the buffer at the
|
// readout), but the terminal cursor stays in the buffer at the
|
||||||
// active match so the eye follows the search — so paint the prompt
|
// active match so the eye follows the search — so paint the prompt
|
||||||
// and fall through to the buffer-cursor placement below.
|
// and fall through to the buffer-cursor placement below.
|
||||||
let mb_cursor_col = if core.search_active() {
|
let mb_cursor_col = if core.search_active() {
|
||||||
paint_search_prompt(grid, core, term_size);
|
paint_search_prompt(grid, core, term_size, &theme);
|
||||||
None
|
None
|
||||||
} else if core.minibuffer.is_active() {
|
} else if core.minibuffer.is_active() {
|
||||||
Some(paint_minibuffer(grid, core, term_size))
|
Some(paint_minibuffer(grid, core, term_size, &theme))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
@ -2257,33 +2278,59 @@ pub fn paint_frame(
|
||||||
Some(CellCoord::new(grid_row, grid_col))
|
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(
|
fn paint_status_line(
|
||||||
grid: &mut crate::cell::CellGrid<'_>,
|
grid: &mut crate::cell::CellGrid<'_>,
|
||||||
core: &EditorCore,
|
core: &EditorCore,
|
||||||
lua_host: &LuaHost,
|
lua_host: &LuaHost,
|
||||||
dispatcher: &KeyDispatcher,
|
dispatcher: &KeyDispatcher,
|
||||||
term_size: crate::cell::CellSize,
|
term_size: crate::cell::CellSize,
|
||||||
|
theme: &crate::highlight::Theme,
|
||||||
) {
|
) {
|
||||||
let status = build_status_line(core, lua_host, dispatcher, term_size.cols);
|
let status = build_status_line(core, lua_host, dispatcher, term_size.cols);
|
||||||
let row = term_size.rows - 1;
|
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() {
|
for (col, ch) in status.chars().enumerate() {
|
||||||
if col >= term_size.cols as usize {
|
if col >= term_size.cols as usize {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let cell = grid.at(CellCoord::new(row, col as u32));
|
let cell = grid.at(CellCoord::new(row, col as u32));
|
||||||
cell.glyph = crate::cell::Glyph::Char(ch);
|
cell.glyph = crate::cell::Glyph::Char(ch);
|
||||||
cell.style = crate::cell::Style {
|
cell.style = style;
|
||||||
reverse: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
for col in (status.chars().count() as u32)..term_size.cols {
|
for col in (status.chars().count() as u32)..term_size.cols {
|
||||||
let cell = grid.at(CellCoord::new(row, col));
|
let cell = grid.at(CellCoord::new(row, col));
|
||||||
cell.glyph = crate::cell::Glyph::Char(' ');
|
cell.glyph = crate::cell::Glyph::Char(' ');
|
||||||
cell.style = crate::cell::Style {
|
cell.style = style;
|
||||||
reverse: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2323,16 +2370,25 @@ fn paint_line_number_gutter(
|
||||||
rect: &crate::window::Rect,
|
rect: &crate::window::Rect,
|
||||||
inner_rows: u32,
|
inner_rows: u32,
|
||||||
gutter_w: u32,
|
gutter_w: u32,
|
||||||
|
theme: &crate::highlight::Theme,
|
||||||
) {
|
) {
|
||||||
let line_count = window.text_view.line_count();
|
let line_count = window.text_view.line_count();
|
||||||
// Relative/Hybrid measure distance from the cursor's buffer line;
|
// Relative/Hybrid measure distance from the cursor's buffer line;
|
||||||
// Absolute ignores it. Computed once per frame (the gutter repaints on
|
// Absolute ignores it. Computed once per frame (the gutter repaints on
|
||||||
// cursor motion, so this stays current).
|
// cursor motion, so this stays current).
|
||||||
let cursor_line = window.text_view.line_at_offset(window.cursor);
|
let cursor_line = window.text_view.line_at_offset(window.cursor);
|
||||||
let style = crate::cell::Style {
|
// 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),
|
fg: crate::cell::Color::Indexed(8),
|
||||||
..crate::cell::Style::default()
|
..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
|
// 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.
|
// cell (`gutter_w - 1`) is a trailing pad separating it from the code.
|
||||||
let field = gutter_w.saturating_sub(1);
|
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
|
// text-relative display column shifted right by this (Q#UX2). 0 when
|
||||||
// the gutter is off, so this is a no-op then.
|
// the gutter is off, so this is a no-op then.
|
||||||
gutter_w: u32,
|
gutter_w: u32,
|
||||||
|
theme: &crate::highlight::Theme,
|
||||||
) {
|
) {
|
||||||
let Some((sel_start, sel_end)) = window.region() else {
|
let Some((sel_start, sel_end)) = window.region() else {
|
||||||
return;
|
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 {
|
if inner_rows == 0 || rect.size.cols == 0 || sel_start >= sel_end {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2428,7 +2499,7 @@ fn paint_local_selection(
|
||||||
rect.origin.row + row_offset,
|
rect.origin.row + row_offset,
|
||||||
rect.origin.col + gutter_w + col,
|
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,
|
cursor_col: u32,
|
||||||
scroll: &str,
|
scroll: &str,
|
||||||
diags: &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 {
|
if rect.size.rows == 0 || rect.size.cols == 0 {
|
||||||
return;
|
return;
|
||||||
|
|
@ -2495,11 +2570,7 @@ fn paint_mode_line(
|
||||||
format!(" {diags} L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
|
format!(" {diags} L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fill the row with reverse-video spaces.
|
// Fill the row with the mode-line style.
|
||||||
let mode_style = crate::cell::Style {
|
|
||||||
reverse: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
for c in 0..rect.size.cols {
|
for c in 0..rect.size.cols {
|
||||||
let cell = grid.at(CellCoord::new(row, rect.origin.col + c));
|
let cell = grid.at(CellCoord::new(row, rect.origin.col + c));
|
||||||
cell.glyph = crate::cell::Glyph::Char(' ');
|
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
|
/// Paint the minibuffer line on the bottom row, replacing the status
|
||||||
/// line. Returns the screen column the terminal cursor should sit
|
/// line. Returns the screen column the terminal cursor should sit
|
||||||
/// at (so the user can see what they're typing).
|
/// 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(
|
fn paint_minibuffer(
|
||||||
grid: &mut crate::cell::CellGrid<'_>,
|
grid: &mut crate::cell::CellGrid<'_>,
|
||||||
core: &EditorCore,
|
core: &EditorCore,
|
||||||
term_size: crate::cell::CellSize,
|
term_size: crate::cell::CellSize,
|
||||||
|
theme: &crate::highlight::Theme,
|
||||||
) -> u32 {
|
) -> u32 {
|
||||||
let session = core
|
let session = core
|
||||||
.minibuffer
|
.minibuffer
|
||||||
|
|
@ -2562,13 +2646,27 @@ fn paint_minibuffer(
|
||||||
let max = term_size.cols;
|
let max = term_size.cols;
|
||||||
let cursor_byte = core.minibuffer.cursor;
|
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() {
|
for ch in prompt.chars() {
|
||||||
if col >= max {
|
if col >= max {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let cell = grid.at(CellCoord::new(row, col));
|
let cell = grid.at(CellCoord::new(row, col));
|
||||||
cell.glyph = crate::cell::Glyph::Char(ch);
|
cell.glyph = crate::cell::Glyph::Char(ch);
|
||||||
cell.style = crate::cell::Style::default();
|
cell.style = base;
|
||||||
col += 1;
|
col += 1;
|
||||||
written += 1;
|
written += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -2585,7 +2683,7 @@ fn paint_minibuffer(
|
||||||
}
|
}
|
||||||
let cell = grid.at(CellCoord::new(row, col));
|
let cell = grid.at(CellCoord::new(row, col));
|
||||||
cell.glyph = crate::cell::Glyph::Char(ch);
|
cell.glyph = crate::cell::Glyph::Char(ch);
|
||||||
cell.style = crate::cell::Style::default();
|
cell.style = base;
|
||||||
col += 1;
|
col += 1;
|
||||||
written += 1;
|
written += 1;
|
||||||
byte_pos += ch.len_utf8() as u64;
|
byte_pos += ch.len_utf8() as u64;
|
||||||
|
|
@ -2602,10 +2700,7 @@ fn paint_minibuffer(
|
||||||
}
|
}
|
||||||
let cell = grid.at(CellCoord::new(row, col));
|
let cell = grid.at(CellCoord::new(row, col));
|
||||||
cell.glyph = crate::cell::Glyph::Char(ch);
|
cell.glyph = crate::cell::Glyph::Char(ch);
|
||||||
cell.style = crate::cell::Style {
|
cell.style = candidate;
|
||||||
reverse: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
col += 1;
|
col += 1;
|
||||||
written += 1;
|
written += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -2613,7 +2708,7 @@ fn paint_minibuffer(
|
||||||
for col in written..max {
|
for col in written..max {
|
||||||
let cell = grid.at(CellCoord::new(row, col));
|
let cell = grid.at(CellCoord::new(row, col));
|
||||||
cell.glyph = crate::cell::Glyph::Char(' ');
|
cell.glyph = crate::cell::Glyph::Char(' ');
|
||||||
cell.style = crate::cell::Style::default();
|
cell.style = base;
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor_col.min(max.saturating_sub(1))
|
cursor_col.min(max.saturating_sub(1))
|
||||||
|
|
@ -2630,7 +2725,11 @@ fn paint_search_prompt(
|
||||||
grid: &mut crate::cell::CellGrid<'_>,
|
grid: &mut crate::cell::CellGrid<'_>,
|
||||||
core: &EditorCore,
|
core: &EditorCore,
|
||||||
term_size: crate::cell::CellSize,
|
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()) {
|
let prompt = match (core.search_is_regex(), core.search_forward()) {
|
||||||
(false, true) => "I-search: ",
|
(false, true) => "I-search: ",
|
||||||
(false, false) => "I-search backward: ",
|
(false, false) => "I-search backward: ",
|
||||||
|
|
@ -2656,7 +2755,7 @@ fn paint_search_prompt(
|
||||||
if *col < max {
|
if *col < max {
|
||||||
let cell = grid.at(CellCoord::new(row, *col));
|
let cell = grid.at(CellCoord::new(row, *col));
|
||||||
cell.glyph = crate::cell::Glyph::Char(ch);
|
cell.glyph = crate::cell::Glyph::Char(ch);
|
||||||
cell.style = crate::cell::Style::default();
|
cell.style = base;
|
||||||
*col += 1;
|
*col += 1;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -2670,11 +2769,11 @@ fn paint_search_prompt(
|
||||||
put(grid, &mut col, ch);
|
put(grid, &mut col, ch);
|
||||||
}
|
}
|
||||||
// Clear the remainder of the row (the status line underneath used
|
// 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 {
|
for c in col..max {
|
||||||
let cell = grid.at(CellCoord::new(row, c));
|
let cell = grid.at(CellCoord::new(row, c));
|
||||||
cell.glyph = crate::cell::Glyph::Char(' ');
|
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),
|
size: CellSize::new(rows, cols),
|
||||||
};
|
};
|
||||||
let rect = Rect::new(0, 0, 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();
|
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).
|
// 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();
|
let mut core = s.core.borrow_mut();
|
||||||
core.active_window_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);
|
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||||||
// Both surfaces of the same store: the overlay's underline
|
// 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.
|
/// ([`crate::semantic_render`]) and the TUI search overlay.
|
||||||
/// Cheaply cloneable (`Arc<Mutex>`); shared with both readers.
|
/// Cheaply cloneable (`Arc<Mutex>`); shared with both readers.
|
||||||
pub search_store: crate::search::SharedSearchStore,
|
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
|
/// Live incremental-search session (Q#SR5), or `None` when no
|
||||||
/// search is running. Frontend-agnostic: the TUI run loop and the
|
/// search is running. Frontend-agnostic: the TUI run loop and the
|
||||||
/// daemon's `FrontendEvent::Key` path both drive it through the
|
/// daemon's `FrontendEvent::Key` path both drive it through the
|
||||||
|
|
@ -385,6 +392,7 @@ impl EditorCore {
|
||||||
pending_crdt_ops: Vec::new(),
|
pending_crdt_ops: Vec::new(),
|
||||||
jump_ring: Vec::new(),
|
jump_ring: Vec::new(),
|
||||||
search_store: crate::search::make_shared_store(),
|
search_store: crate::search::make_shared_store(),
|
||||||
|
theme: None,
|
||||||
search: None,
|
search: None,
|
||||||
clipboard_slot: Vec::new(),
|
clipboard_slot: Vec::new(),
|
||||||
pending_clipboard: None,
|
pending_clipboard: None,
|
||||||
|
|
@ -857,9 +865,12 @@ impl EditorCore {
|
||||||
/// rendered buffer, so one instance suffices per window.
|
/// rendered buffer, so one instance suffices per window.
|
||||||
fn ensure_search_overlay(&mut self) {
|
fn ensure_search_overlay(&mut self) {
|
||||||
let store = self.search_store.clone();
|
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();
|
let win = self.active_window_mut();
|
||||||
if !win.overlay_kinds().contains(&"search") {
|
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
|
// completion dropdown; the TUI paints the popup via its
|
||||||
// CompletionView cell overlay, so it drops this silently.
|
// CompletionView cell overlay, so it drops this silently.
|
||||||
| InstanceMessage::CompletionPopup { .. }
|
| 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 { .. }
|
| InstanceMessage::ResourceOffer { .. }
|
||||||
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
|
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
|
||||||
// optimistic-apply gate; if any reaches this render path
|
// 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]
|
#[test]
|
||||||
fn emit_span_writes_cursor_move_then_chars() {
|
fn emit_span_writes_cursor_move_then_chars() {
|
||||||
let span = DiffSpan {
|
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.
|
/// the same hierarchy in both cases.
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct Theme {
|
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>,
|
pub by_capture: HashMap<String, Style>,
|
||||||
/// Fallback style when no capture matches. Defaults to the
|
/// Fallback style when no capture matches. Defaults to the
|
||||||
/// terminal default colors (no override) so unhighlighted text
|
/// terminal default colors (no override) so unhighlighted text
|
||||||
/// looks identical to plain rendering.
|
/// looks identical to plain rendering.
|
||||||
pub default_style: Style,
|
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 {
|
impl Theme {
|
||||||
|
|
@ -151,6 +178,8 @@ impl Theme {
|
||||||
Self {
|
Self {
|
||||||
by_capture,
|
by_capture,
|
||||||
default_style: Style::default(),
|
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.
|
/// Set the style for one capture name, replacing any prior entry.
|
||||||
pub fn insert(&mut self, capture_name: impl Into<String>, style: Style) {
|
pub fn insert(&mut self, capture_name: impl Into<String>, style: Style) {
|
||||||
self.by_capture.insert(capture_name.into(), style);
|
self.by_capture.insert(capture_name.into(), style);
|
||||||
|
|
@ -685,6 +740,83 @@ mod tests {
|
||||||
assert!(!s.bold);
|
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]
|
#[test]
|
||||||
fn line_offsets_basic() {
|
fn line_offsets_basic() {
|
||||||
let src = b"a\nbb\nccc";
|
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,
|
clippy::too_many_lines,
|
||||||
reason = "linear list of raw bindings; splitting fragments a coherent surface"
|
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 pmacs: Table = lua.globals().get("pmacs")?;
|
||||||
let diag_mod = lua.create_table()?;
|
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.
|
// per buffer; double-attach stacks duplicate overlays.
|
||||||
{
|
{
|
||||||
let m = manager.clone();
|
let m = manager.clone();
|
||||||
|
let th = theme.clone();
|
||||||
diag_mod.set(
|
diag_mod.set(
|
||||||
"_attach_view",
|
"_attach_view",
|
||||||
lua.create_function(move |lua, (id, uri): (BufferIdLua, String)| {
|
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
|
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));
|
win.push_overlay(Box::new(overlay));
|
||||||
Ok(true)
|
Ok(true)
|
||||||
})?,
|
})?,
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ use crate::buffer_registry::BufferRegistry;
|
||||||
use crate::cell::{Color, Style, UnderlineStyle};
|
use crate::cell::{Color, Style, UnderlineStyle};
|
||||||
use crate::command::{Command, CommandError, CommandRegistry, SourceLocation};
|
use crate::command::{Command, CommandError, CommandRegistry, SourceLocation};
|
||||||
use crate::editor_core::EditorCore;
|
use crate::editor_core::EditorCore;
|
||||||
use crate::highlight::{SyntaxHighlightView, Theme};
|
use crate::highlight::SyntaxHighlightView;
|
||||||
use crate::hook::{Hook, HookRegistry};
|
use crate::hook::{Hook, HookRegistry};
|
||||||
use crate::key::{display_sequence, parse_sequence};
|
use crate::key::{display_sequence, parse_sequence};
|
||||||
use crate::keymap_stack::KeymapStack;
|
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
|
/// default to the same values as `Style::default()` --- a Lua table
|
||||||
/// with `{ bold = true }` produces a style that is otherwise
|
/// with `{ bold = true }` produces a style that is otherwise
|
||||||
/// terminal-default.
|
/// 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> {
|
fn lua_to_style(t: &Table) -> mlua::Result<Style> {
|
||||||
let fg: mlua::Value = t.get("fg").unwrap_or(mlua::Value::Nil);
|
let fg: mlua::Value = t.get("fg")?;
|
||||||
let bg: mlua::Value = t.get("bg").unwrap_or(mlua::Value::Nil);
|
let bg: mlua::Value = t.get("bg")?;
|
||||||
let underline: mlua::Value = t.get("underline").unwrap_or(mlua::Value::Nil);
|
let underline: mlua::Value = t.get("underline")?;
|
||||||
let underline_color: mlua::Value = t.get("underline_color").unwrap_or(mlua::Value::Nil);
|
let underline_color: mlua::Value = t.get("underline_color")?;
|
||||||
Ok(Style {
|
Ok(Style {
|
||||||
fg: lua_to_color(&fg)?,
|
fg: lua_to_color(&fg)?,
|
||||||
bg: lua_to_color(&bg)?,
|
bg: lua_to_color(&bg)?,
|
||||||
bold: t.get("bold").unwrap_or(false),
|
bold: t.get::<Option<bool>>("bold")?.unwrap_or(false),
|
||||||
italic: t.get("italic").unwrap_or(false),
|
italic: t.get::<Option<bool>>("italic")?.unwrap_or(false),
|
||||||
underline: lua_to_underline(&underline)?,
|
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)?,
|
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
|
/// attached [`SyntaxHighlightView`] sees the change on its next
|
||||||
/// render. T M4.3 acceptance: "theming via Lua-defined color
|
/// render. T M4.3 acceptance: "theming via Lua-defined color
|
||||||
/// schemes."
|
/// 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> {
|
fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table> {
|
||||||
let theme_mod = lua.create_table()?;
|
let theme_mod = lua.create_table()?;
|
||||||
|
|
||||||
|
|
@ -6903,17 +6988,7 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
|
||||||
theme_mod.set(
|
theme_mod.set(
|
||||||
"set",
|
"set",
|
||||||
lua.create_function(move |_, table: Table| {
|
lua.create_function(move |_, table: Table| {
|
||||||
let mut new_theme = Theme::empty();
|
commit_theme_entries(&s.theme(), ThemeCommit::Replace, lua_theme_entries(&table))
|
||||||
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(())
|
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
@ -6923,13 +6998,7 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
|
||||||
theme_mod.set(
|
theme_mod.set(
|
||||||
"merge",
|
"merge",
|
||||||
lua.create_function(move |_, table: Table| {
|
lua.create_function(move |_, table: Table| {
|
||||||
let theme = s.theme();
|
commit_theme_entries(&s.theme(), ThemeCommit::Merge, lua_theme_entries(&table))
|
||||||
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(())
|
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
@ -6952,7 +7021,11 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
|
||||||
"clear",
|
"clear",
|
||||||
lua.create_function(move |_, ()| {
|
lua.create_function(move |_, ()| {
|
||||||
let theme = s.theme();
|
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(())
|
Ok(())
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -6963,8 +7036,13 @@ fn install_theme(lua: &Lua, syntax: &SharedSyntaxRegistry) -> mlua::Result<Table
|
||||||
theme_mod.set(
|
theme_mod.set(
|
||||||
"default",
|
"default",
|
||||||
lua.create_function(move |_, style: Table| {
|
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();
|
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(())
|
Ok(())
|
||||||
})?,
|
})?,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -8623,7 +8701,7 @@ pub fn make_lsp_manager(
|
||||||
) -> mlua::Result<SharedLspManager> {
|
) -> mlua::Result<SharedLspManager> {
|
||||||
let manager = Rc::new(RefCell::new(LspManager::new(supervisor, runtime)));
|
let manager = Rc::new(RefCell::new(LspManager::new(supervisor, runtime)));
|
||||||
install_lsp(lua, &manager, syntax)?;
|
install_lsp(lua, &manager, syntax)?;
|
||||||
diag::install_diag(lua, &manager)?;
|
diag::install_diag(lua, &manager, &syntax.theme())?;
|
||||||
install_completion(lua, &manager)?;
|
install_completion(lua, &manager)?;
|
||||||
install_hover(lua, &manager)?;
|
install_hover(lua, &manager)?;
|
||||||
install_signature(lua, &manager)?;
|
install_signature(lua, &manager)?;
|
||||||
|
|
@ -12118,6 +12196,123 @@ mod tests {
|
||||||
(lua, reg, cmds, kms, hks)
|
(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]
|
#[test]
|
||||||
fn create_query_insert_observe_round_trip() {
|
fn create_query_insert_observe_round_trip() {
|
||||||
let (lua, _reg, _cmds, _kms, _hks) = fresh();
|
let (lua, _reg, _cmds, _kms, _hks) = fresh();
|
||||||
|
|
|
||||||
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
||||||
// --- M5.5a handshake & postcard round-trips ---
|
// --- M5.5a handshake & postcard round-trips ---
|
||||||
|
|
||||||
#[test]
|
#[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 /
|
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
||||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||||
|
|
@ -1706,8 +1706,10 @@ mod tests {
|
||||||
// gutter modes bumped 13→14 (`LineNumbers` swapped `enabled: bool`
|
// gutter modes bumped 13→14 (`LineNumbers` swapped `enabled: bool`
|
||||||
// for a `LineNumberMode` enum — encoding change, still daemon-gated).
|
// for a `LineNumberMode` enum — encoding change, still daemon-gated).
|
||||||
// Arc 1a Q#C5 bumped 14→15 (`InstanceMessage::CompletionPopup`,
|
// Arc 1a Q#C5 bumped 14→15 (`InstanceMessage::CompletionPopup`,
|
||||||
// additive + daemon-gated).
|
// additive + daemon-gated). Themes Q#TH7 bumped 15→16
|
||||||
assert_eq!(PROTOCOL_VERSION, 15);
|
// (`InstanceMessage::ThemeFacts`, additive + daemon-gated,
|
||||||
|
// appended as the final variant — see the placement pin).
|
||||||
|
assert_eq!(PROTOCOL_VERSION, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1781,21 +1783,84 @@ mod tests {
|
||||||
// (`TripleDown`), v8 (`StatusFacts`), v9 + v10 (`SearchPrompt` +
|
// (`TripleDown`), v8 (`StatusFacts`), v9 + v10 (`SearchPrompt` +
|
||||||
// regex/invalid), v11 (the context menu), v12 (the GUI
|
// regex/invalid), v11 (the context menu), v12 (the GUI
|
||||||
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
|
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
|
||||||
// (`CompletionPopup`) all interoperate, so v6 through v15 talk.
|
// (`CompletionPopup`), v16 (`ThemeFacts`) all interoperate, so
|
||||||
for accepted in 6..=15 {
|
// v6 through v16 talk.
|
||||||
|
for accepted in 6..=16 {
|
||||||
assert!(
|
assert!(
|
||||||
is_supported_protocol_version(accepted),
|
is_supported_protocol_version(accepted),
|
||||||
"v{accepted} must be 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!(
|
assert!(
|
||||||
!is_supported_protocol_version(rejected),
|
!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]
|
#[test]
|
||||||
fn hello_round_trips_through_postcard() {
|
fn hello_round_trips_through_postcard() {
|
||||||
let h = Hello {
|
let h = Hello {
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,10 @@ fn active_match_style() -> Style {
|
||||||
/// maps to one row.
|
/// maps to one row.
|
||||||
pub struct SearchView {
|
pub struct SearchView {
|
||||||
store: SharedSearchStore,
|
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 {
|
impl SearchView {
|
||||||
|
|
@ -393,10 +397,11 @@ impl SearchView {
|
||||||
/// ([`Buffer::id`]) rather than a fixed id, so a single attached
|
/// ([`Buffer::id`]) rather than a fixed id, so a single attached
|
||||||
/// instance keeps highlighting correctly even if the window
|
/// instance keeps highlighting correctly even if the window
|
||||||
/// switches buffers (the store is per-buffer; a buffer with no
|
/// 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]
|
#[must_use]
|
||||||
pub fn new(store: SharedSearchStore) -> Self {
|
pub fn new(store: SharedSearchStore, theme: Option<crate::highlight::ThemeHandle>) -> Self {
|
||||||
Self { store }
|
Self { store, theme }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,11 +442,36 @@ impl View for SearchView {
|
||||||
let max_cols = viewport.cell_size.cols;
|
let max_cols = viewport.cell_size.cols;
|
||||||
let cell_origin = viewport.cell_origin;
|
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 {
|
for m in &matches {
|
||||||
let style = if Some(*m) == active {
|
let style = if Some(*m) == active {
|
||||||
active_match_style()
|
active_overlay
|
||||||
} else {
|
} else {
|
||||||
match_style()
|
match_overlay
|
||||||
};
|
};
|
||||||
// A regex match may span multiple lines (Q#RX4); wash each
|
// A regex match may span multiple lines (Q#RX4); wash each
|
||||||
// row's clipped slice, mirroring the selection renderer.
|
// row's clipped slice, mirroring the selection renderer.
|
||||||
|
|
@ -707,7 +737,7 @@ mod tests {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.set(bid, "lo", find_all(b"lo lo lo\n", "lo"));
|
.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 backing = vec![Cell::default(); 10];
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
@ -783,7 +813,7 @@ mod tests {
|
||||||
stride: cols,
|
stride: cols,
|
||||||
size: CellSize::new(rows, cols),
|
size: CellSize::new(rows, cols),
|
||||||
};
|
};
|
||||||
SearchView::new(store.clone()).render(
|
SearchView::new(store.clone(), None).render(
|
||||||
&buf,
|
&buf,
|
||||||
Viewport {
|
Viewport {
|
||||||
buffer_start: 0,
|
buffer_start: 0,
|
||||||
|
|
|
||||||
|
|
@ -160,14 +160,27 @@ pub struct SemanticRenderState {
|
||||||
/// buffer at the same generation re-uses what the frontend
|
/// buffer at the same generation re-uses what the frontend
|
||||||
/// already has and emits nothing. First emission happens on the
|
/// already has and emits nothing. First emission happens on the
|
||||||
/// first frame for a buffer; further emissions only after edits.
|
/// first frame for a buffer; further emissions only after edits.
|
||||||
/// `(crdt_generation, diag_epoch)` the last emitted summary was
|
/// `(crdt_generation, diag_epoch, syntax_epoch, face_epoch)` the
|
||||||
/// computed against. Diagnostics arrive without a generation
|
/// last summary was computed against, plus that computed payload.
|
||||||
/// bump, so the epoch half catches republishes (minimap marks,
|
/// Diagnostics arrive without a generation bump, so the diag
|
||||||
/// T M4.6 GPU parity).
|
/// epoch catches republishes (minimap marks, T M4.6 GPU parity);
|
||||||
last_summary: HashMap<BufferId, (u64, u64)>,
|
/// 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
|
/// `(name, modified, diag_errors, diag_warnings, message)` last
|
||||||
/// emitted as `StatusFacts` (Q#S1; `message` since v15) —
|
/// 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_status: HashMap<BufferId, (String, bool, u32, u32, Option<String>)>,
|
||||||
/// Last-emitted line-number gutter mode (UX gutter arc, protocol v14) —
|
/// Last-emitted line-number gutter mode (UX gutter arc, protocol v14) —
|
||||||
/// cached-compare suppression. Seeded to `Some(Off)` (the frontend's
|
/// 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)
|
/// viewport* (which the GPU frontend sets to the entire buffer)
|
||||||
/// and clones the theme — too expensive to repeat on every tick.
|
/// and clones the theme — too expensive to repeat on every tick.
|
||||||
/// The styling depends only on the parse bundle, the CRDT
|
/// The styling depends only on the parse bundle, the CRDT
|
||||||
/// generation, and the viewport — never the cursor — so a gate
|
/// generation, the viewport, and the theme's syntax epoch
|
||||||
/// built from those lets cursor-only ticks skip the query entirely.
|
/// (Q#TH6) — never the cursor — so a gate built from those lets
|
||||||
/// Only the grammar (tree-sitter) path is gated; the LSP-token path
|
/// cursor-only ticks skip the query entirely while a mid-session
|
||||||
/// has no comparably cheap handle and recomputes as before.
|
/// `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>,
|
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
|
/// Cached byte↔line table for the diagnostics projection, keyed
|
||||||
/// by buffer revision. Building it costs an O(buffer) rope copy
|
/// by buffer revision. Building it costs an O(buffer) rope copy
|
||||||
/// plus a full scan; before this cache, that ran on *every tick*
|
/// plus a full scan; before this cache, that ran on *every tick*
|
||||||
|
|
@ -215,6 +253,43 @@ struct DiagLineCache {
|
||||||
source_len: u64,
|
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
|
/// Recompute gate for [`scoped_style_spans`] on a grammar-backed
|
||||||
/// buffer. Holds the current parse bundle `Arc` so its address stays
|
/// buffer. Holds the current parse bundle `Arc` so its address stays
|
||||||
/// stable while cached — comparing by `Arc::ptr_eq` then can't be
|
/// stable while cached — comparing by `Arc::ptr_eq` then can't be
|
||||||
|
|
@ -230,6 +305,11 @@ struct StyleGate {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
/// Declared viewport.
|
/// Declared viewport.
|
||||||
visible: ByteRange,
|
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 {
|
impl StyleGate {
|
||||||
|
|
@ -237,6 +317,7 @@ impl StyleGate {
|
||||||
fn matches(&self, other: &Self) -> bool {
|
fn matches(&self, other: &Self) -> bool {
|
||||||
self.generation == other.generation
|
self.generation == other.generation
|
||||||
&& self.visible == other.visible
|
&& self.visible == other.visible
|
||||||
|
&& self.syntax_epoch == other.syntax_epoch
|
||||||
&& match (&self.bundle, &other.bundle) {
|
&& match (&self.bundle, &other.bundle) {
|
||||||
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
|
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
|
||||||
(None, None) => true,
|
(None, None) => true,
|
||||||
|
|
@ -246,8 +327,22 @@ impl StyleGate {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SemanticRenderState {
|
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
|
/// 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]
|
#[must_use]
|
||||||
pub fn new(frontend_id: FrontendId) -> Self {
|
pub fn new(frontend_id: FrontendId) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -268,6 +363,13 @@ impl SemanticRenderState {
|
||||||
// toggle-on (or later toggle-off) ships a message.
|
// toggle-on (or later toggle-off) ships a message.
|
||||||
last_line_numbers: Some(crate::window::LineNumberMode::Off),
|
last_line_numbers: Some(crate::window::LineNumberMode::Off),
|
||||||
last_style_gate: HashMap::new(),
|
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(),
|
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.
|
/// Project one frame.
|
||||||
///
|
///
|
||||||
/// Returns up to three messages — [`InstanceMessage::StyleSpans`]
|
/// Returns up to three messages — [`InstanceMessage::StyleSpans`]
|
||||||
|
|
@ -475,6 +619,8 @@ impl SemanticRenderState {
|
||||||
out.extend(self.minibuffer_prompt_msg(state, vp.buffer_id));
|
out.extend(self.minibuffer_prompt_msg(state, vp.buffer_id));
|
||||||
// --- CompletionPopup (Arc 1a Q#C5, protocol v15) ---
|
// --- CompletionPopup (Arc 1a Q#C5, protocol v15) ---
|
||||||
out.extend(self.completion_popup_msg(state, vp.buffer_id));
|
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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -740,9 +886,14 @@ impl SemanticRenderState {
|
||||||
/// nothing changed. Carries the facts a semantic frontend cannot
|
/// nothing changed. Carries the facts a semantic frontend cannot
|
||||||
/// derive locally: buffer name, modified flag, whole-file
|
/// derive locally: buffer name, modified flag, whole-file
|
||||||
/// diagnostic counts (errors / warnings). Counts freeze at their
|
/// diagnostic counts (errors / warnings). Counts freeze at their
|
||||||
/// last value while the diag store is stale — mid-edit positions
|
/// last published value while the diag store is stale — mid-edit
|
||||||
/// are wrong but *counts* merely lag, and flickering to zero on
|
/// positions are wrong but *counts* merely lag, and flickering
|
||||||
/// every keystroke would be worse. The daemon's write loop keeps
|
/// 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`.
|
/// the variant off wires negotiated `< 8`.
|
||||||
fn status_facts_msg(
|
fn status_facts_msg(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -761,32 +912,32 @@ impl SemanticRenderState {
|
||||||
let buf = reg.get(buffer_id).ok()?;
|
let buf = reg.get(buffer_id).ok()?;
|
||||||
(buf.name().to_owned(), buf.is_modified(), message)
|
(buf.name().to_owned(), buf.is_modified(), message)
|
||||||
};
|
};
|
||||||
let counts = {
|
let (diag_errors, diag_warnings) = {
|
||||||
let core = state.core.borrow();
|
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 store = state.lsp_manager.borrow().diag_store();
|
||||||
let guard = store.lock().expect("diag store mutex poisoned");
|
let guard = store.lock().expect("diag store mutex poisoned");
|
||||||
if guard.is_stale(&uri) {
|
// Read even while the store is STALE (round 4):
|
||||||
None // keep the cached counts
|
// `mark_stale` keeps the last published diagnostics
|
||||||
} else {
|
// (T M11.8) — positions are invalid mid-edit, but
|
||||||
let mut errors = 0u32;
|
// counts merely lag, so the retained totals ARE the
|
||||||
let mut warnings = 0u32;
|
// frozen value. Sourcing the freeze from the store
|
||||||
for d in guard.for_uri(&uri) {
|
// rather than any per-session cache means a session
|
||||||
match d.severity {
|
// first rendering during staleness — a late joiner,
|
||||||
crate::diag::DiagnosticSeverity::Error => errors += 1,
|
// or a buffer first visited mid-edit — reports the
|
||||||
crate::diag::DiagnosticSeverity::Warning => warnings += 1,
|
// 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
|
||||||
Some((errors, warnings))
|
// 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);
|
let facts = (name, modified, diag_errors, diag_warnings, message);
|
||||||
if cached == Some(&facts) {
|
if self.last_status.get(&buffer_id) == Some(&facts) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let msg = InstanceMessage::StatusFacts {
|
let msg = InstanceMessage::StatusFacts {
|
||||||
|
|
@ -910,13 +1061,48 @@ impl SemanticRenderState {
|
||||||
// publish without a generation bump — key the cache on the
|
// publish without a generation bump — key the cache on the
|
||||||
// diag store's per-URI epoch as well, so a republish
|
// diag store's per-URI epoch as well, so a republish
|
||||||
// refreshes the marks and anything else stays suppressed.
|
// 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);
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let lines = scoped_file_summary(state, buffer_id);
|
|
||||||
self.last_summary
|
|
||||||
.insert(buffer_id, (generation, diag_epoch));
|
|
||||||
Some(InstanceMessage::FileStyleSummary {
|
Some(InstanceMessage::FileStyleSummary {
|
||||||
buffer_id,
|
buffer_id,
|
||||||
generation,
|
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
|
/// Project the [`Decoration`] set intersecting the declared
|
||||||
/// viewport: the session's selection (instance-authoritative,
|
/// viewport: the session's selection (instance-authoritative,
|
||||||
/// byte-native) and LSP diagnostics (line/col → byte, severity →
|
/// byte-native) and LSP diagnostics (line/col → byte, severity →
|
||||||
|
|
@ -1481,6 +1710,7 @@ fn grammar_style_key(
|
||||||
bundle: handle.current(),
|
bundle: handle.current(),
|
||||||
generation,
|
generation,
|
||||||
visible: vp.visible,
|
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
|
/// `O(spans × lines)` in the worst case; the caller short-circuits on
|
||||||
/// unchanged CRDT generation so this only runs on first sight of a
|
/// unchanged CRDT generation so this only runs on first sight of a
|
||||||
/// buffer or after an edit, not per frame.
|
/// 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 source = {
|
||||||
let core = state.core.borrow();
|
let core = state.core.borrow();
|
||||||
let registry = core.registry.clone();
|
let registry = core.registry.clone();
|
||||||
|
|
@ -1707,7 +1941,7 @@ fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
|
||||||
if spans.is_empty() {
|
if spans.is_empty() {
|
||||||
// No styled runs — but diagnostic marks are independent of
|
// No styled runs — but diagnostic marks are independent of
|
||||||
// syntax styling (a plain-text buffer can still have lints).
|
// 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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1740,7 +1974,7 @@ fn scoped_file_summary(state: &EditorState, buffer_id: BufferId) -> Vec<Style> {
|
||||||
*line_dominant = winner.0;
|
*line_dominant = winner.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
overlay_diagnostic_marks(state, buffer_id, &mut out);
|
overlay_diagnostic_marks(state, buffer_id, &mut out, resolve_faces);
|
||||||
out
|
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
|
/// describe pre-edit text, same discipline as the decorations
|
||||||
/// producer (the marks return on republish, which bumps the diag
|
/// producer (the marks return on republish, which bumps the diag
|
||||||
/// epoch and recomputes this summary).
|
/// 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 uri = {
|
||||||
let core = state.core.borrow();
|
let core = state.core.borrow();
|
||||||
let Some(uri) = buffer_file_uri(&core, buffer_id) else {
|
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)));
|
*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) {
|
for (line, severity) in lines.iter_mut().zip(best) {
|
||||||
if let Some(s) = severity {
|
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
|
/// All `InstanceMessage` variants the semantic projection may
|
||||||
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
|
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
|
||||||
/// `FileStyleSummary`, `StatusFacts` (Q#S1), or `SearchPrompt`
|
/// `FileStyleSummary`, `StatusFacts` (Q#S1), `SearchPrompt`
|
||||||
/// (Q#SR5) — never `CellDelta`, grid `Cursor`, or the still-unwired
|
/// (Q#SR5), `LineNumbers`, or `ThemeFacts` (Q#TH7) — never
|
||||||
|
/// `CellDelta`, grid `Cursor`, or the still-unwired
|
||||||
/// `BlockAdornments` / `FoldState` families.
|
/// `BlockAdornments` / `FoldState` families.
|
||||||
fn assert_semantic_only(msgs: &[InstanceMessage]) {
|
fn assert_semantic_only(msgs: &[InstanceMessage]) {
|
||||||
for m in msgs {
|
for m in msgs {
|
||||||
|
|
@ -1894,6 +2392,7 @@ mod tests {
|
||||||
| InstanceMessage::StatusFacts { .. }
|
| InstanceMessage::StatusFacts { .. }
|
||||||
| InstanceMessage::SearchPrompt { .. }
|
| InstanceMessage::SearchPrompt { .. }
|
||||||
| InstanceMessage::LineNumbers { .. }
|
| InstanceMessage::LineNumbers { .. }
|
||||||
|
| InstanceMessage::ThemeFacts { .. }
|
||||||
),
|
),
|
||||||
"semantic projection emitted an unexpected variant: {m:?}"
|
"semantic projection emitted an unexpected variant: {m:?}"
|
||||||
);
|
);
|
||||||
|
|
@ -2063,12 +2562,14 @@ mod tests {
|
||||||
// (the frontend clears its viewport), carrying empty segments.
|
// (the frontend clears its viewport), carrying empty segments.
|
||||||
// FileStyleSummary also emits on the first frame for this buffer
|
// FileStyleSummary also emits on the first frame for this buffer
|
||||||
// (post-M11 minimap producer, generation-keyed), as does
|
// (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);
|
let first = s.render_frame(&state);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
first.len(),
|
first.len(),
|
||||||
4,
|
5,
|
||||||
"first frame ships StyleSpans + Decorations + FileStyleSummary + StatusFacts"
|
"first frame ships StyleSpans + Decorations + FileStyleSummary \
|
||||||
|
+ StatusFacts + ThemeFacts"
|
||||||
);
|
);
|
||||||
assert_semantic_only(&first);
|
assert_semantic_only(&first);
|
||||||
let (style_full, _) = style_segments(&first).expect("StyleSpans present");
|
let (style_full, _) = style_segments(&first).expect("StyleSpans present");
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue