feat(statusline): add composable modeline segments at protocol v18

Add the strict pmacs.statusline provider registry, deterministic
borrow-released per-window evaluation, context-scoped failure latches,
and a pure built-in LSP provider.

Preserve the legacy TUI modeline while composing faced custom runs,
and append authoritative complete StatuslineSegments replacements for
semantic frontends. Expand dynamic ThemeFacts, reset producer/frontend
baselines symmetrically, and gate all provider work off protocol v18.

Teach the GPU to atomically validate, resolve, shape, clip, and cache
custom modeline runs without displacing the protected status suffix.
Document the public Lua lifecycle, wire ownership, snapshot semantics,
and the fully gated Arc 4 stage-3 delivery state.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-21 11:24:20 -04:00
parent ac7f776c3a
commit 4b65b9e1e5
22 changed files with 4621 additions and 310 deletions

1
Cargo.lock generated
View File

@ -2568,6 +2568,7 @@ dependencies = [
"tree-sitter-typescript", "tree-sitter-typescript",
"tree-sitter-yaml", "tree-sitter-yaml",
"tree-sitter-zig", "tree-sitter-zig",
"unicode-segmentation",
"unicode-width", "unicode-width",
] ]

View File

@ -86,6 +86,7 @@ crdt = ["dep:loro", "pmacs-protocol/crdt"]
crossterm = "0.28" crossterm = "0.28"
thiserror = { workspace = true } thiserror = { workspace = true }
unicode-width = "0.2" unicode-width = "0.2"
unicode-segmentation = "1"
# Regex engine for in-buffer regex search (Q#RX1). `regex::bytes::Regex` # Regex engine for in-buffer regex search (Q#RX1). `regex::bytes::Regex`
# matches over rope-snapshot bytes and yields byte offsets directly. # matches over rope-snapshot bytes and yields byte offsets directly.
# Already in the lockfile transitively; promoted to a direct dependency. # Already in the lockfile transitively; promoted to a direct dependency.

View File

@ -791,6 +791,22 @@ function pmacs.lsp.active_attachment()
return attachments[tostring(buf)] return attachments[tostring(buf)]
end end
-- Arc 4 stage 3: pure modeline projection. This reads the private
-- per-buffer attachment map directly so passive split windows report their
-- own buffer instead of the focused window. It never attaches, flushes
-- didChange, or issues a request.
pmacs.statusline.register {
name = "lsp",
side = "right",
priority = 0,
face = "ui.modeline.lsp",
fn = function(ctx)
local rec = attachments[tostring(ctx.buffer)]
if not rec then return nil end
return "LSP:" .. pmacs.lsp.modeline_label(rec.server)
end,
}
-- Flushing variant for request-issuing callers outside this file -- Flushing variant for request-issuing callers outside this file
-- (Q#C8): when the active buffer already has a server attached, -- (Q#C8): when the active buffer already has a server attached,
-- flush any debounced didChange first and return the record, so the -- flush any debounced didChange first and return the record, so the

View File

@ -1,19 +1,20 @@
# Agent handoff — cross-machine continuity # Agent handoff — cross-machine continuity
**Last updated: 2026-07-15, after multi-language injections (#122) **Last updated: 2026-07-21, with Themes Arc 4 stage 3 implemented and
merged; also carries the #120 themes-stage-1 snapshot.** This file is the fully gated on the `statusline-segments` feature branch (awaiting
bridge between development machines. If you are an agent reading review; not merged).** This file is the bridge between development
this on a fresh clone: this document plus the `docs/*-framing.md` machines. If you are an agent reading on a fresh clone: this document
files ARE your memory. Read this fully before taking on work, seed plus the `docs/*-framing.md` files ARE your memory. Read this fully
your persistent memory from it, and **update this file (and commit before taking on work, seed persistent memory from it, and **update this
it) whenever project state changes materially** — the next machine file (and commit it) whenever project state changes materially** — the
reads it the way you just did. next machine reads it the way you just did.
## 1. Where the project stands (2026-07-15) ## 1. Where the project stands (2026-07-21)
- `main` @ `5e73966` (multi-language injections #122 merged; #120 - Canonical `main` @ `f8096ff` (#124, Themes stage 2 merged), protocol
themes stage 1 below it), protocol **v16** (`SUPPORTED=[6..16]`; **v17** (`SUPPORTED=[6..17]`). The `statusline-segments` branch is
v15→16 shipped the `ThemeFacts` channel — injections added no wire). based there and implements protocol v18, but v18 is **not on main**
until review and merge.
- **Syntax-highlight / language-detection side-quest (#114#118) - **Syntax-highlight / language-detection side-quest (#114#118)
LANDED** — a one-shot arc built in sibling worktrees off main while LANDED** — a one-shot arc built in sibling worktrees off main while
the user's themes lane (`theme-faces`) ran concurrently in the shared the user's themes lane (`theme-faces`) ran concurrently in the shared
@ -99,27 +100,29 @@ reads it the way you just did.
`pmacs.editor.take_typed_edit()` (buffer-revision postcondition, `pmacs.editor.take_typed_edit()` (buffer-revision postcondition,
Q#AP9). Substrate: `buf:path()`, `pmacs.lsp.buffer_language(buf)`, Q#AP9). Substrate: `buf:path()`, `pmacs.lsp.buffer_language(buf)`,
`PMACS_FAKE_LSP_CHANGE_SINK`, `TestDaemon::spawn_with_config`. `PMACS_FAKE_LSP_CHANGE_SINK`, `TestDaemon::spawn_with_config`.
- **Themes (Arc 4) stage 1 LANDED — #120 merged after 5 review - **Themes (Arc 4) stages 1 and 2 LANDED; stage 3 IMPLEMENTED ON ITS
rounds** (`docs/theme-faces-framing.md` rev 9 is the full record): FEATURE BRANCH, AWAITING REVIEW.**
named UI faces as reserved `ui`/`ui.*` theme entries (12-face - Stage 1 (#120, `docs/theme-faces-framing.md` rev 9): named UI faces
inventory, owns-surface-within-mask, masks identical on both as reserved `ui`/`ui.*` theme entries; transactional split
frontends); `Theme::face()` walk (`None` when unset); transactional syntax/face epochs; protocol-v16 `ThemeFacts`; snapshot/baseline
mutators with split syntax/face epochs (fixed the pre-existing symmetry; store-sourced diagnostic-count freeze.
mid-session `theme.set` span staleness); `ThemeFacts` channel (v16, - Stage 2 (#124, `docs/gpu-set-font-framing.md` rev 5):
one authoritative send per attachment; v15 peers excluded incl. the `pmacs.gpu.set_font` and authoritative protocol-v17 `FontFacts`;
`FileStyleSummary` face-leak side channel). Review rounds hardened frontend-local family resolution, live font reload/reflow, and
substrate beyond faces: the **snapshot/baseline reset contract** visual-run caret geometry.
(`on_buffer_snapshot_sent` daemon-side + the GPU arm's symmetric - Stage 3 (`statusline-segments`,
search/menu/status clears; minibuffer, gutter mode, `ThemeFacts` `docs/statusline-segments-framing.md` rev 3): composable strict
survive both sides) and the **store-sourced diag-count freeze** `pmacs.statusline` providers; borrow-released per-window evaluation
(per-URI severity totals in `DiagnosticStore`, O(1), survive with failure latches; legacy-preserving TUI composition; a pure
`mark_stale`). built-in LSP provider; dynamic modeline faces; protocol-v18
- **NEXT: themes stage 2 — `pmacs.gpu.set_font` at protocol v17** `StatuslineSegments`; authoritative-empty/snapshot symmetry; and
(shipped versions are never reused; the `pmacs-gpu-design.md:299` atomic GPU validation, face resolution, shaping, clipping, and
no-wire-change claim is superseded and must be corrected in the cache invalidation. Acceptance 1-27 is implemented. Final gates:
stage-2 framing). Glyphon font reload was flagged HARD. Stage 3 Clippy clean; 1,610 default + 1,784 CRDT library tests; 7 default +
after: Lua statusline-segment API (segments carry face names). 8 CRDT feature acceptance; 110 M4; 108 required GPU; one-invocation
Workflow as always: framing → user approval → branch → gates → PR. workspace sweep 2,704 passed across 78 suites (19 ignored,
`basedpyright` filtered); `git diff --check` clean. This branch is
awaiting review and **must not be described as merged**.
- Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position: - Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position:
- **Arc 1 (LSP utility surface) COMPLETE** — completion popup - **Arc 1 (LSP utility surface) COMPLETE** — completion popup
(#92/#93), panels/references/outline/hover (#94#96), plus (#92/#93), panels/references/outline/hover (#94#96), plus

View File

@ -106,8 +106,8 @@ Package entry chunks run during package load, including audit and
headless load paths. Keep top-level code limited to registration and headless load paths. Keep top-level code limited to registration and
state setup. Surfaces installed by the base Lua host are available state setup. Surfaces installed by the base Lua host are available
there: `pmacs.buffer`, `pmacs.command`, `pmacs.keymap`, there: `pmacs.buffer`, `pmacs.command`, `pmacs.keymap`,
`pmacs.hook`, `pmacs.describe`, `pmacs.help`, `pmacs.attach`, `pmacs.hook`, `pmacs.statusline`, `pmacs.describe`, `pmacs.help`,
`pmacs.now_ms`, and the standard Lua libraries. `pmacs.attach`, `pmacs.now_ms`, and the standard Lua libraries.
Editor-state surfaces are available once the editor bridge is Editor-state surfaces are available once the editor bridge is
installed: command bodies invoked by pmacs, main-thread hooks fired by installed: command bodies invoked by pmacs, main-thread hooks fired by
@ -414,6 +414,55 @@ to work whenever `define` works (parity), and packages need to
call it from `on_unload` hooks that fire on post-init `reload(name)` call it from `on_unload` hooks that fire on post-init `reload(name)`
calls. calls.
### Statusline providers: register for every window, unregister on unload
`pmacs.statusline.register` installs a live provider and returns an
opaque handle. Registration accepts a strict table with only `name`,
`side`, `priority`, `face`, and `fn`: `name` is a non-empty display
label, `side` is `"left"` or `"right"`, `priority` defaults to `0`,
`face` defaults to `"ui.modeline"` and otherwise must be a
`ui.modeline.*` face, and `fn` is the callback.
Providers are evaluated once for each rendered window context, not once
for the editor's active buffer. Always read the callback's `ctx.buffer`
handle; a split's passive window can display a different buffer:
```lua
local segment = pmacs.statusline.register {
name = "mypkg-buffer",
side = "left",
priority = 20,
face = "ui.modeline.mypkg",
fn = function(ctx)
-- ctx.frontend and ctx.window are integer identities.
-- ctx.buffer is this window's Buffer handle, even when passive.
local marker = ctx.active and "*" or ""
return marker .. ctx.buffer:name()
end,
}
pmacs.packages.on_unload(function()
pmacs.statusline.unregister(segment) -- idempotent; false if already gone
end)
```
The callback returns a string, `nil`, or `""`; the latter two mean no
segment. Output is one line (the first newline ends it), control
characters become spaces, and an over-limit result is omitted as a
provider failure. Failures are reported once per provider/window
context until that context succeeds or the provider is disabled and
re-enabled.
Ordering is deterministic: left providers use priority descending,
then registration order; right providers use priority ascending, then
registration order. `pmacs.statusline.providers()` returns fresh
metadata tables. `set_priority(handle, integer)` and
`set_enabled(handle, boolean)` return `false` for a stale handle and
change live output immediately. `unregister(handle)` is idempotent and
returns whether it removed a live provider. Registering in package
top-level code without the matching `on_unload` cleanup leaks the old
provider across `reload(name)`.
### `pmacs.fs.*` — worker-dispatched filesystem primitives ### `pmacs.fs.*` — worker-dispatched filesystem primitives
The four async fs operations packages need without reaching for The four async fs operations packages need without reaching for

View File

@ -80,14 +80,16 @@ saveplace, autosave + crash recovery, optional backups. Generalize the
question: what is a "session" in a daemon world; do CRDT snapshots question: what is a "session" in a daemon world; do CRDT snapshots
ride along. ride along.
### Arc 4 — Themes + extensibility surface ### Arc 4 — Themes + extensibility surface — COMPLETE ON FEATURE BRANCH
Extend `pmacs.theme` from syntax captures to named UI faces (modeline, Stages 1 and 2 landed as #120 and #124: named `ui.*` faces with
minibuffer, gutter, selection, status band); wire GPU chrome to it — daemon-resolved `ThemeFacts`, then the live global
Q#UX1 lesson applies: rendering is frontend-local but control is `pmacs.gpu.set_font` preference at protocol v17. Stage 3 is implemented
daemon-owned, so a wire channel (`ThemeFacts`-style) is needed. Add and fully gated on `statusline-segments`, awaiting review and **not yet
`pmacs.gpu.set_font` (designed, never built) and a Lua merged**: composable `pmacs.statusline` providers, per-window TUI
statusline-segment API. composition, a pure built-in LSP segment, dynamic modeline faces, and
semantic/GPU transport through protocol v18. Merging stage 3 completes
Arc 4 on `main`.
### Arc 5 — Terminal, staged ### Arc 5 — Terminal, staged

View File

@ -21,6 +21,12 @@ against this design:
- **M11.5** — the headless `SemanticClient` glue + reconstruction- - **M11.5** — the headless `SemanticClient` glue + reconstruction-
equivalence and end-to-end tests. equivalence and end-to-end tests.
- **Themes Arc 4 stage 3 (protocol v18)** — composable Lua statusline
providers project complete ordered left/right text+face runs through
`StatuslineSegments`. The daemon evaluates one callback per matching
window context; the frontend owns shaping, separators, clipping, and
all pixel placement.
Post-M11 producer arc (the LSP feature arc landed the missing data Post-M11 producer arc (the LSP feature arc landed the missing data
sources, so the "wire in when those features land" promise came due): sources, so the "wire in when those features land" promise came due):
@ -97,36 +103,36 @@ prohibited by this contract, not merely discouraged.
## Composition with v1.0 primitives ## Composition with v1.0 primitives
The semantic projection ships **no text**. A `semantic_render` The semantic projection ships **no document text**. A `semantic_render`
session is required to also be a text replica — it holds the rope session is required to also be a text replica — it holds the rope
locally via the existing `crdt_replica` machinery locally via the existing `crdt_replica` machinery (`BufferSnapshot` to
(`BufferSnapshot` to bootstrap, `CrdtOp` to stay live). The bootstrap, `CrdtOp` to stay live). Styling and decorations are purely
semantic frame is purely the *interpretation layer* over a buffer interpretation over bytes the frontend already holds. Protocol v18's
the frontend already has: styling and decoration keyed by byte one deliberate text-bearing exception is `StatuslineSegments`: bounded
range. This mirrors how v1.0 already coupled `multi_frontend` one-line chrome text that is not document content. This preserves the
and `crdt_replica`, and it keeps the new wire tiny — single-digit semantics-down model while letting daemon-owned Lua state contribute to
KB for a screenful, diffable at span granularity. frontend-local modeline layout.
Consequently the new surface is small. Cursor reuses the existing Consequently the new surface is small. Cursor reuses the existing
`InstanceMessage::CursorByte` (authoritative cursor as a buffer `InstanceMessage::CursorByte` (authoritative cursor as a buffer
offset — added for CRDT optimistic-apply, exactly what a offset — added for CRDT optimistic-apply, exactly what a
layout-local frontend consumes). Peer cursors reuse the existing layout-local frontend consumes). Peer cursors reuse the existing
`PresenceUpdate`. Edits and local cursor travel the existing `PresenceUpdate`. Edits and local cursor travel the existing
`FrontendEvent::CrdtOp` / presence path. The genuinely new wire `FrontendEvent::CrdtOp` / presence path. Later interpretation and
is: one capability bit, ~five instance→frontend interpretation chrome families append under explicit protocol-version gates; v18 adds
variants, and one frontend→instance `Viewport` variant. only `StatuslineSegments` to the v17 shape.
**`BufferSnapshot` resets buffer-scoped interpretation state.** A **`BufferSnapshot` resets buffer-scoped interpretation state.** A
frontend receiving a snapshot drops everything it holds for the frontend receiving a snapshot drops everything it holds for the
named buffer — spans, decorations, adornments, minimap summary, named buffer — spans, decorations, adornments, minimap summary,
completion popup, search and menu prompts (which also gate the completion popup, search and menu prompts (which also gate the
frontend's key/pointer interception), and status facts — and frontend's key/pointer interception), status facts, and statusline
rebuilds from the frames that follow; the instance mirrors this by segments — and rebuilds from the frames that follow; the instance
invalidating its per-buffer emission baselines whenever it writes a mirrors this by invalidating its per-buffer emission baselines whenever
snapshot, so the frontend's post-snapshot viewport declaration it writes a snapshot. The frontend's post-snapshot viewport declaration
receives authoritative re-sends even when nothing changed therefore receives authoritative re-sends even when nothing changed
daemon-side (the unchanged-generation A → B → A revisit). Bufferless daemon-side (the unchanged-generation A → B → A revisit).
facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and Bufferless facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and
per-frontend state (the gutter mode) survive snapshots on both sides per-frontend state (the gutter mode) survive snapshots on both sides
(frontend-locally the normalized code scroll — a caret-follow view (frontend-locally the normalized code scroll — a caret-follow view
residual — is buffer-scoped and resets, while the resolved font and residual — is buffer-scoped and resets, while the resolved font and
@ -238,13 +244,19 @@ ResourceOffer {
/// declaration; cached-compare suppressed thereafter, so an /// declaration; cached-compare suppressed thereafter, so an
/// unthemed session pays one small message and nothing more. /// unthemed session pays one small message and nothing more.
/// Resolution (the `ui.*` dotted-prefix inheritance walk) happens /// Resolution (the `ui.*` dotted-prefix inheritance walk) happens
/// daemon-side over the stage-1 face inventory — frontends do /// daemon-side; frontends do exact-name lookup only and apply each face
/// exact-name lookup only, and apply each face within its /// within its stage-1 component mask
/// stage-1 component mask (docs/theme-faces-framing.md Q#TH3/Q#TH5: /// (`docs/theme-faces-framing.md` Q#TH3/Q#TH5: a set face owns its
/// a set face owns its surface; `Default` components mean the /// surface; `Default` components mean the frontend's plain rendering;
/// frontend's plain rendering; out-of-mask components are never /// out-of-mask components are never read).
/// read). Daemon-gated `>= 16`; appended as the FINAL variant — ///
/// postcard discriminants are ordinal. /// At protocol v18 the resolved inventory also includes every enabled
/// statusline provider's exact `ui.modeline.*` face name. Registration,
/// unregister, and enable changes invalidate that inventory; priority
/// changes do not. v16/v17 peers retain only the fixed stage-1 set and
/// never execute statusline providers. Daemon-gated `>= 16`; its
/// postcard placement remains before `FontFacts` and
/// `StatuslineSegments`.
ThemeFacts { ThemeFacts {
faces: Vec<ThemeFace>, // { name: String, style: Style }, sorted by name faces: Vec<ThemeFace>, // { name: String, style: Style }, sorted by name
}, },
@ -263,19 +275,59 @@ ThemeFacts {
/// owns every metric consequence; sizes travel as integer /// owns every metric consequence; sizes travel as integer
/// hundredths of a logical pixel (1600 = 16.0, validated to /// hundredths of a logical pixel (1600 = 16.0, validated to
/// 600..=7200 on BOTH sides — the receiver fails closed on /// 600..=7200 on BOTH sides — the receiver fails closed on
/// out-of-range wire values). Daemon-gated `>= 17`; appended as /// out-of-range wire values). Daemon-gated `>= 17`; v18's
/// the FINAL variant — postcard discriminants are ordinal, and the /// `StatuslineSegments` is appended after it because postcard
/// ThemeFacts byte pin above guards this placement. /// discriminants are ordinal.
FontFacts { FontFacts {
family: Option<String>, // None = the frontend's default family family: Option<String>, // None = the frontend's default family
size_centi_px: Option<u32>, // None = the frontend's default size size_centi_px: Option<u32>, // None = the frontend's default size
}, },
/// One daemon-evaluated statusline run. `text` is non-empty,
/// control-free UTF-8; `face` is `ui.modeline` or a valid
/// `ui.modeline.*` name resolved through `ThemeFacts`.
StatuslineSegment {
text: String,
face: String,
},
/// Themes Arc 4 stage 3 (protocol v18). A complete replacement for one
/// buffer's custom modeline runs, never a patch. The left vector is in
/// display order (priority descending, registration id ascending);
/// right is in display order from the center toward the protected
/// suffix (priority ascending, registration id ascending).
StatuslineSegments {
buffer_id: BufferId,
left: Vec<StatuslineSegment>,
right: Vec<StatuslineSegment>,
},
``` ```
Each family member diffs against the previous frame the same way `StyleSpans` retains its dirty-segment diffing. `StatuslineSegments`
`CellDelta` does today — the instance ships changed spans, not uses a complete-payload baseline instead: first sight of a buffer sends
full re-sends, scoped to the viewport range the frontend last one authoritative replacement, including `left=[]`, `right=[]`; a
declared. byte-identical later evaluation is silent. Authoritative empty is data,
not "no message": it clears a prior payload after unregister, disable,
provider failure, or an evaluation invalidated by callback mutation.
Nil and empty-string provider returns are simply absent runs.
The v18 producer evaluates only after a matching viewport declaration
for the semantic session's active daemon window. Provider execution is
version-gated before evaluation, so a v17 peer incurs no callbacks and
receives neither this variant nor provider-only dynamic `ThemeFacts`
entries. The receiver validates a whole message atomically using the
shared protocol limits (64 runs, 1024 bytes per run, 64 KiB aggregate,
256-byte valid face names); malformed input leaves the prior payload
unchanged.
`BufferSnapshot` clears the named buffer's frontend mirror immediately
and drops the producer baseline. The unchanged-generation A → B → A
return therefore remains empty until the authoritative re-send arrives,
then restores the exact prior runs. The instance owns callback order,
sanitation, face names, and replacement semantics. The frontend owns
separators (using the adjacent run's face), grapheme shaping, clipping,
and the protected diagnostic/cursor/scroll suffix; none of those pixel
decisions return to the daemon.
## Frontend → instance: `Viewport` ## Frontend → instance: `Viewport`

View File

@ -1,9 +1,9 @@
# Statusline segments - framing (Arc 4 stage 3) # Statusline segments - framing (Arc 4 stage 3)
**Revision 3 - 2026-07-21. Framing preserved on branch **Revision 3 - 2026-07-21. Implemented on branch
`statusline-segments`; re-scouted against `main` `bb17ec9` (protocol v17) `statusline-segments` against the post-#124 protocol-v17 base. The
after JSON/YAML #123 merged. No relevant substrate drift; awaiting review. implementation advances the wire to v18, satisfies Acceptance 1-27,
No implementation or PR.** and is fully gated; awaiting review, not merged.**
Revision 3: closes review findings on authoritative-empty baseline retention Revision 3: closes review findings on authoritative-empty baseline retention
and the TUI's protected-suffix clipping boundary. and the TUI's protected-suffix clipping boundary.
@ -21,6 +21,38 @@ names to semantic frontends at protocol v18, and uses the existing LSP
status tracker as the first built-in provider. Completing this stage status tracker as the first built-in provider. Completing this stage
completes Arc 4. completes Arc 4.
## Implementation record (2026-07-21)
The approved Q#SL1-Q#SL11 design is implemented without changing the
framed ownership boundary:
- `pmacs.statusline` owns a shared editor-global registry with strict
registration, lifecycle/introspection, monotonic layout/face-set
epochs, borrow-released three-phase evaluation, per-context failure
latches, deterministic ordering, and bounded one-line results.
- TUI composition preserves the legacy modeline when providers are
absent, owns separators by adjacent segment face, shapes terminal-safe
grapheme runs, and protects the right diagnostic/cursor/scroll suffix.
- Protocol v18 appends complete `StatuslineSegments` replacements. The
semantic producer distinguishes authoritative empty from no message,
versions provider execution before callbacks, expands dynamic
`ThemeFacts`, and resets buffer baselines symmetrically with
`BufferSnapshot`.
- The GPU consumes v18 atomically, resolves exact dynamic faces, clips
provider runs without wrapping or displacing the protected suffix,
and preserves its prior valid state on malformed input.
- `builtin/runtime/lsp.lua` registers the first pure right-side provider
from its private attachment map; the Rust tracker exposes bounded
`init`/`ready`/`degraded`/`crashed`/`stopped`/unknown labels.
The final gate run was sequential and clean: `cargo fmt --check`;
workspace/all-target Clippy with `-D warnings`; 1,610 default and 1,784
CRDT library tests; 7 default and 8 CRDT stage-3 acceptance tests; 110
M4 acceptance tests (3 ignored, `basedpyright` filtered); 108 required
GPU tests; and the one-invocation workspace sweep (2,704 passed across
78 suites, 19 ignored, `basedpyright` filtered). `git diff --check` was
clean. No flaky rerun was needed.
## Ground truth (as of `main` at `bb17ec9`, protocol v17) ## Ground truth (as of `main` at `bb17ec9`, protocol v17)
### There are two different bottom surfaces in the TUI ### There are two different bottom surfaces in the TUI

File diff suppressed because it is too large Load Diff

View File

@ -50,9 +50,11 @@ pub use message::{
CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment, CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment,
FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InlineAdornment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InlineAdornment,
InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent,
LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, LineNumberMode, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, ThemeFace, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities,
is_builtin_pair_char, is_supported_protocol_version, negotiate_capabilities, PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char,
is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
}; };
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message}; pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};

View File

@ -104,6 +104,32 @@ pub const BUILTIN_PAIR_CHARS: [char; 9] = ['(', ')', '[', ']', '{', '}', '"', '\
pub fn is_builtin_pair_char(c: char) -> bool { pub fn is_builtin_pair_char(c: char) -> bool {
BUILTIN_PAIR_CHARS.contains(&c) BUILTIN_PAIR_CHARS.contains(&c)
} }
/// Maximum number of live statusline providers and wire segments.
pub const MAX_STATUSLINE_PROVIDERS: usize = 64;
/// Maximum UTF-8 byte length of a statusline provider's display name.
pub const MAX_STATUSLINE_PROVIDER_NAME_BYTES: usize = 256;
/// Maximum UTF-8 byte length of a statusline segment face name.
pub const MAX_STATUSLINE_FACE_BYTES: usize = 256;
/// Maximum UTF-8 byte length of one statusline segment's text.
pub const MAX_STATUSLINE_SEGMENT_BYTES: usize = 1024;
/// Maximum aggregate UTF-8 text bytes in one statusline payload.
pub const MAX_STATUSLINE_TOTAL_TEXT_BYTES: usize = 64 * 1024;
/// True when `name` belongs to the reserved UI-face namespace.
#[must_use]
pub fn is_ui_face_name(name: &str) -> bool {
name == "ui" || name.starts_with("ui.")
}
/// True when `name` is the modeline face or one of its children.
#[must_use]
pub fn is_modeline_face_name(name: &str) -> bool {
name == "ui.modeline" || name.starts_with("ui.modeline.")
}
/// Modifier-key set. Bit-flag encoding for compact wire shape. /// Modifier-key set. Bit-flag encoding for compact wire shape.
/// ///
@ -1026,6 +1052,21 @@ pub enum InstanceMessage {
/// closed (deserialized protocol input is untrusted). /// closed (deserialized protocol input is untrusted).
size_centi_px: Option<u32>, size_centi_px: Option<u32>,
}, },
/// Statusline segments (Q#SL7, protocol v18). Custom provider output
/// for the semantic frontend's current buffer. This is a complete
/// replacement: empty vectors authoritatively mean no custom segments.
/// Daemon-gated `>= 18`.
///
/// Appended after [`Self::FontFacts`], the final v17 variant, so no
/// existing postcard discriminant moves.
StatuslineSegments {
/// Buffer whose modeline the segments describe.
buffer_id: crate::BufferId,
/// Left-side custom segments in display order.
left: Vec<StatuslineSegment>,
/// Right-side custom segments in display order.
right: Vec<StatuslineSegment>,
},
} }
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full /// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
@ -1041,6 +1082,19 @@ pub struct ThemeFace {
pub style: crate::cell::Style, pub style: crate::cell::Style,
} }
/// One daemon-produced custom modeline segment.
///
/// `text` has already been sanitized to one line. `face` is
/// `ui.modeline` or one of its child names; a missing exact entry in
/// [`InstanceMessage::ThemeFacts`] means the base modeline text color.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct StatuslineSegment {
/// Non-empty, single-line segment text.
pub text: String,
/// Static modeline face name selected at provider registration.
pub face: String,
}
/// 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
/// wire, the daemon, and both frontends so the *number rule* — what value /// wire, the daemon, and both frontends so the *number rule* — what value
/// each line shows — is identical everywhere (Q#UX7). `pmacs` re-exports /// each line shows — is identical everywhere (Q#UX7). `pmacs` re-exports
@ -1411,7 +1465,13 @@ pub enum ResourceBody {
/// `< 17`; a v16 peer negotiates v16 and simply keeps its built-in /// `< 17`; a v16 peer negotiates v16 and simply keeps its built-in
/// font. Appended after `ThemeFacts` — the final v16 variant — /// font. Appended after `ThemeFacts` — the final v16 variant —
/// same ordinal-discriminant reasoning as every additive bump. /// same ordinal-discriminant reasoning as every additive bump.
pub const PROTOCOL_VERSION: u32 = 17; ///
/// Statusline segments (Q#SL7): bumped 17 → 18 for
/// [`InstanceMessage::StatuslineSegments`] — a new additive variant
/// carrying custom modeline provider output. Daemon-gated `< 18`; a
/// v17 peer keeps the built-in status band. Appended after `FontFacts`
/// so the final v17 discriminant remains stable.
pub const PROTOCOL_VERSION: u32 = 18;
/// 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
@ -1477,7 +1537,10 @@ pub const PROTOCOL_VERSION: u32 = 17;
/// ///
/// Q#F4: extended to `[6, ..., 17]`. `InstanceMessage::FontFacts` /// Q#F4: extended to `[6, ..., 17]`. `InstanceMessage::FontFacts`
/// is additive and daemon-gated per session, so the ladder resumes. /// 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, 17]; ///
/// Q#SL7: extended to `[6, ..., 18]`.
/// [`InstanceMessage::StatuslineSegments`] is additive and daemon-gated.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18];
/// 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`].

View File

@ -791,6 +791,14 @@ fn per_attach_thread(
let _ = dispatcher_tx.send(DispatcherEvent::SessionDetached { frontend_id }); let _ = dispatcher_tx.send(DispatcherEvent::SessionDetached { frontend_id });
} }
/// Belt-and-braces write-loop gate for the additive protocol-v18
/// statusline variant. The producer has its own callback/evaluation gate;
/// this filter independently prevents an unknown discriminant reaching an
/// older peer even if a message is injected into the frame vector.
fn peer_accepts_statusline_message(protocol_version: u32, message: &InstanceMessage) -> bool {
protocol_version >= 18 || !matches!(message, InstanceMessage::StatuslineSegments { .. })
}
/// T M10.8 — dispatcher loop. The single thread that owns the editor. /// T M10.8 — dispatcher loop. The single thread that owns the editor.
/// ///
/// All attached frontends' inputs arrive via the `dispatcher_rx` /// All attached frontends' inputs arrive via the `dispatcher_rx`
@ -1144,6 +1152,12 @@ fn dispatcher_loop(
let peer_knows_font_facts = session_registry let peer_knows_font_facts = session_registry
.session_state(*fid) .session_state(*fid)
.is_some_and(|s| s.negotiated_protocol_version >= 17); .is_some_and(|s| s.negotiated_protocol_version >= 17);
// Q#SL7 — independently gate the v18 statusline variant
// even though the semantic producer also skips callbacks
// and message construction for older peers.
let negotiated_protocol_version = session_registry
.session_state(*fid)
.map_or(0, |s| s.negotiated_protocol_version);
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 { .. })
@ -1186,6 +1200,9 @@ fn dispatcher_loop(
if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) { if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) {
continue; continue;
} }
if !peer_accepts_statusline_message(negotiated_protocol_version, msg) {
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.
// //
@ -1267,6 +1284,10 @@ fn dispatcher_loop(
last_dispatch_idle_sent.remove(fid); last_dispatch_idle_sent.remove(fid);
last_active_buffer_sent.remove(fid); last_active_buffer_sent.remove(fid);
session_registry.unregister_session(*fid); session_registry.unregister_session(*fid);
editor
.statusline_registry
.borrow_mut()
.detach_frontend(*fid);
editor.core.borrow_mut().unregister_frontend_view(*fid); editor.core.borrow_mut().unregister_frontend_view(*fid);
} }
} }
@ -1639,6 +1660,10 @@ fn handle_dispatcher_event(
last_dispatch_idle_sent.remove(&frontend_id); last_dispatch_idle_sent.remove(&frontend_id);
last_active_buffer_sent.remove(&frontend_id); last_active_buffer_sent.remove(&frontend_id);
session_registry.unregister_session(frontend_id); session_registry.unregister_session(frontend_id);
editor
.statusline_registry
.borrow_mut()
.detach_frontend(frontend_id);
{ {
let mut core = editor.core.borrow_mut(); let mut core = editor.core.borrow_mut();
core.unregister_frontend_view(frontend_id); core.unregister_frontend_view(frontend_id);
@ -2555,6 +2580,24 @@ mod tests {
assert_eq!(b, 3); assert_eq!(b, 3);
} }
#[test]
fn statusline_segments_write_gate_rejects_v17_independently() {
let segments = InstanceMessage::StatuslineSegments {
buffer_id: crate::buffer::BufferId::from_raw(1),
left: Vec::new(),
right: Vec::new(),
};
assert!(!peer_accepts_statusline_message(17, &segments));
assert!(peer_accepts_statusline_message(18, &segments));
assert!(peer_accepts_statusline_message(
17,
&InstanceMessage::FontFacts {
family: None,
size_centi_px: None,
}
));
}
#[test] #[test]
fn build_identity_includes_version_and_uptime() { fn build_identity_includes_version_and_uptime() {
let s = DaemonState::new(Some("research".into())); let s = DaemonState::new(Some("research".into()));

View File

@ -13,12 +13,15 @@
//! until the user quits. //! until the user quits.
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap;
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyModifiers};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::async_runtime::SharedAsyncRuntime; use crate::async_runtime::SharedAsyncRuntime;
use crate::cell::CellCoord; use crate::cell::CellCoord;
@ -101,6 +104,8 @@ pub struct EditorState {
/// Snippet store (T M4.11). Co-owned with the snippet /// Snippet store (T M4.11). Co-owned with the snippet
/// provider closure inside [`Self::completion_registry`]. /// provider closure inside [`Self::completion_registry`].
pub snippets: crate::completion_framework::SharedSnippetRegistry, pub snippets: crate::completion_framework::SharedSnippetRegistry,
/// Lua statusline providers shared by grid and semantic renderers.
pub statusline_registry: crate::statusline::SharedStatuslineRegistry,
/// Last left-button down event, used to synthesize terminal double /// Last left-button down event, used to synthesize terminal double
/// clicks from crossterm's plain Down/Up mouse event stream. /// clicks from crossterm's plain Down/Up mouse event stream.
mouse_click: Option<MouseClickState>, mouse_click: Option<MouseClickState>,
@ -167,6 +172,8 @@ impl EditorState {
lua_host lua_host
.attach_editor(&core) .attach_editor(&core)
.expect("editor bindings + builtin chunks"); .expect("editor bindings + builtin chunks");
let statusline_registry = crate::lua_bindings::statusline_registry(lua_host.lua())
.expect("statusline registry installed by editor bindings");
// The on-disk state dirs (minibuffer history + pmacs.state) are // The on-disk state dirs (minibuffer history + pmacs.state) are
// deliberately NOT configured here — see `install_state_dirs`, // deliberately NOT configured here — see `install_state_dirs`,
// called by the real entry points (`run` / `run_daemon`) only. // called by the real entry points (`run` / `run_daemon`) only.
@ -481,6 +488,7 @@ impl EditorState {
project_indexer, project_indexer,
completion_registry, completion_registry,
snippets, snippets,
statusline_registry,
mouse_click: None, mouse_click: None,
} }
} }
@ -2108,6 +2116,25 @@ pub fn paint_frame(
return None; return None;
} }
let text_rows = term_size.rows - 1; let text_rows = term_size.rows - 1;
// Statusline callbacks may call arbitrary editor APIs. Evaluate the
// complete visible-window fan-out before the long mutable core borrow
// below, then paint only the transactionally validated owned results.
let frontend_id = state.core.borrow().active_frontend;
let statusline_evaluation = crate::statusline::evaluate_statusline(
state.lua_host.lua(),
&state.core,
&state.statusline_registry,
crate::statusline::StatuslineEvaluationTarget::Grid { frontend_id },
);
let statusline_by_window: HashMap<WindowId, crate::statusline::StatuslineWindowSegments> =
match statusline_evaluation.outcome {
crate::statusline::StatuslineEvaluationOutcome::Ready(windows) => windows
.into_iter()
.map(|segments| (segments.context.window_id, segments))
.collect(),
crate::statusline::StatuslineEvaluationOutcome::Invalidated { .. }
| crate::statusline::StatuslineEvaluationOutcome::NoMessage(_) => HashMap::new(),
};
// Themes Q#TH9: one theme clone per frame for the chrome faces — // Themes Q#TH9: one theme clone per frame for the chrome faces —
// the same single-lock discipline as `SyntaxHighlightView::render`. // the same single-lock discipline as `SyntaxHighlightView::render`.
@ -2226,6 +2253,7 @@ pub fn paint_frame(
let guard = diag_store.lock().expect("diag store mutex poisoned"); let guard = diag_store.lock().expect("diag store mutex poisoned");
diag_mode_line_summary(&guard, buf) diag_mode_line_summary(&guard, buf)
}; };
let custom = statusline_by_window.get(id);
paint_mode_line( paint_mode_line(
grid, grid,
&rect, &rect,
@ -2237,6 +2265,9 @@ pub fn paint_frame(
&scroll, &scroll,
&diags, &diags,
mode_line_style(&theme), mode_line_style(&theme),
custom.map_or(&[], |segments| segments.left.as_slice()),
custom.map_or(&[], |segments| segments.right.as_slice()),
&theme,
); );
} }
drop(reg); drop(reg);
@ -2549,9 +2580,131 @@ fn diag_mode_line_summary(
} }
} }
#[derive(Copy, Clone)]
struct ModeLineRun<'a> {
text: &'a str,
style: crate::cell::Style,
}
struct ModeLineGrapheme {
glyph: crate::cell::Glyph,
width: u32,
style: crate::cell::Style,
}
fn prepare_mode_line_runs(runs: &[ModeLineRun<'_>]) -> Vec<ModeLineGrapheme> {
let mut graphemes = Vec::new();
for run in runs {
let sanitized = run.text.chars().any(char::is_control).then(|| {
run.text
.chars()
.map(|ch| if ch.is_control() { ' ' } else { ch })
.collect::<String>()
});
let text = sanitized.as_deref().unwrap_or(run.text);
for grapheme in text.graphemes(true) {
let width = UnicodeWidthStr::width(grapheme) as u32;
if width == 0 {
continue;
}
let mut chars = grapheme.chars();
let first = chars
.next()
.expect("unicode segmentation never yields an empty grapheme");
let glyph = if chars.next().is_none() {
crate::cell::Glyph::Char(first)
} else {
crate::cell::Glyph::Cluster(grapheme.as_bytes().into())
};
graphemes.push(ModeLineGrapheme {
glyph,
width,
style: run.style,
});
}
}
graphemes
}
fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 {
graphemes.iter().map(|grapheme| grapheme.width).sum()
}
/// Paint complete graphemes at a logical signed origin. A grapheme that
/// straddles either clip edge is omitted wholesale, so a wide glyph can never
/// leave a dangling half-cell at a window or left/right collision boundary.
fn paint_mode_line_graphemes(
grid: &mut crate::cell::CellGrid<'_>,
rect: &crate::window::Rect,
row: u32,
origin: i64,
clip_start: u32,
clip_end: u32,
graphemes: &[ModeLineGrapheme],
) {
let mut logical_col = origin;
for grapheme in graphemes {
let next_col = logical_col + i64::from(grapheme.width);
if logical_col >= i64::from(clip_start) && next_col <= i64::from(clip_end) {
let local_col =
u32::try_from(logical_col).expect("non-negative clipped modeline column");
let cell = grid.at(CellCoord::new(row, rect.origin.col + local_col));
cell.glyph = grapheme.glyph.clone();
cell.style = grapheme.style;
for continuation in 1..grapheme.width {
let cell = grid.at(CellCoord::new(
row,
rect.origin.col + local_col + continuation,
));
cell.glyph = crate::cell::Glyph::Continuation;
cell.style = grapheme.style;
}
}
logical_col = next_col;
}
}
fn statusline_segment_style(
theme: &crate::highlight::Theme,
face: &str,
base: crate::cell::Style,
) -> crate::cell::Style {
let Some(override_style) = theme.modeline_segment_face(face) else {
return base;
};
let mut style = base;
if style.reverse {
style.bg = override_style.fg;
} else {
style.fg = override_style.fg;
}
style
}
fn custom_mode_line_runs<'a>(
segments: &'a [crate::statusline::EvaluatedStatuslineSegment],
theme: &crate::highlight::Theme,
base: crate::cell::Style,
) -> Vec<ModeLineRun<'a>> {
let mut runs = Vec::with_capacity(segments.len().saturating_mul(2));
for (index, segment) in segments.iter().enumerate() {
if index > 0 {
runs.push(ModeLineRun {
text: " ",
style: base,
});
}
runs.push(ModeLineRun {
text: &segment.text,
style: statusline_segment_style(theme, &segment.face, base),
});
}
runs
}
#[allow( #[allow(
clippy::too_many_arguments, clippy::too_many_arguments,
reason = "the mode line packs nine unrelated facts; bundling them into a struct just adds ceremony" reason = "the modeline packs built-in facts plus two already-evaluated custom sides"
)] )]
fn paint_mode_line( fn paint_mode_line(
grid: &mut crate::cell::CellGrid<'_>, grid: &mut crate::cell::CellGrid<'_>,
@ -2563,10 +2716,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, mode_style: crate::cell::Style,
custom_left: &[crate::statusline::EvaluatedStatuslineSegment],
custom_right: &[crate::statusline::EvaluatedStatuslineSegment],
theme: &crate::highlight::Theme,
) { ) {
if rect.size.rows == 0 || rect.size.cols == 0 { if rect.size.rows == 0 || rect.size.cols == 0 {
return; return;
@ -2574,46 +2727,78 @@ fn paint_mode_line(
let row = rect.origin.row + rect.size.rows - 1; let row = rect.origin.row + rect.size.rows - 1;
let marker = if modified { '*' } else { ' ' }; let marker = if modified { '*' } else { ' ' };
let active_marker = if is_active { '+' } else { '-' }; let active_marker = if is_active { '+' } else { '-' };
let left = format!(" {active_marker}{marker} {name} "); let protected_left = format!(" {active_marker}{marker} {name} ");
let right = if diags.is_empty() { let protected_right = if diags.is_empty() {
format!(" L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1) format!(" L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
} else { } else {
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 the mode-line style. // Fill exactly this window's row once with the base modeline surface.
for c in 0..rect.size.cols { for col 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 + col));
cell.glyph = crate::cell::Glyph::Char(' '); cell.glyph = crate::cell::Glyph::Char(' ');
cell.style = mode_style; cell.style = mode_style;
} }
// Right-align the cursor / scroll readout. If the window is too let mut left_runs = Vec::with_capacity(custom_left.len().saturating_mul(2) + 2);
// narrow to fit both halves, drop the right side rather than left_runs.push(ModeLineRun {
// overlap the buffer name. text: &protected_left,
let right_chars: Vec<char> = right.chars().collect(); style: mode_style,
let right_len = right_chars.len() as u32; });
let right_start_col = if right_len < rect.size.cols { if !custom_left.is_empty() {
Some(rect.size.cols - right_len) left_runs.push(ModeLineRun {
} else { text: " ",
None style: mode_style,
}; });
if let Some(start_col) = right_start_col { left_runs.extend(custom_mode_line_runs(custom_left, theme, mode_style));
for (i, ch) in right_chars.iter().enumerate() {
let col = rect.origin.col + start_col + i as u32;
grid.at(CellCoord::new(row, col)).glyph = crate::cell::Glyph::Char(*ch);
}
} }
let left_graphemes = prepare_mode_line_runs(&left_runs);
// Paint the left side, stopping before the right side begins. let protected_right_graphemes = prepare_mode_line_runs(&[ModeLineRun {
let stop_col = right_start_col.unwrap_or(rect.size.cols); text: &protected_right,
for (i, ch) in left.chars().enumerate() { style: mode_style,
let i = i as u32; }]);
if i >= stop_col { let protected_right_width = mode_line_grapheme_width(&protected_right_graphemes);
break;
// Preserve the legacy strict boundary: a suffix as wide as the entire
// window is dropped wholesale. Custom text can never cause that drop when
// the protected suffix itself still satisfies the legacy fit test.
if protected_right_width < rect.size.cols {
let mut right_prefix_runs = custom_mode_line_runs(custom_right, theme, mode_style);
if !custom_right.is_empty() {
right_prefix_runs.push(ModeLineRun {
text: " ",
style: mode_style,
});
} }
let col = rect.origin.col + i; let right_prefix_graphemes = prepare_mode_line_runs(&right_prefix_runs);
grid.at(CellCoord::new(row, col)).glyph = crate::cell::Glyph::Char(ch); let right_prefix_width = mode_line_grapheme_width(&right_prefix_graphemes);
let suffix_start = rect.size.cols - protected_right_width;
let right_origin = i64::from(suffix_start) - i64::from(right_prefix_width);
let left_clip_end = u32::try_from(right_origin).unwrap_or(0);
paint_mode_line_graphemes(grid, rect, row, 0, 0, left_clip_end, &left_graphemes);
paint_mode_line_graphemes(
grid,
rect,
row,
right_origin,
0,
suffix_start,
&right_prefix_graphemes,
);
paint_mode_line_graphemes(
grid,
rect,
row,
i64::from(suffix_start),
suffix_start,
rect.size.cols,
&protected_right_graphemes,
);
} else {
paint_mode_line_graphemes(grid, rect, row, 0, 0, rect.size.cols, &left_graphemes);
} }
} }
@ -6921,6 +7106,403 @@ mod tests {
} }
} }
#[test]
fn statusline_no_visible_provider_preserves_ascii_modeline_cells() {
let s = fresh_with(b"hello");
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let actual = (0..80)
.map(|col| glyph_at(&cells, stride, 22, col))
.collect::<String>();
let left = " + test ";
let right = " L1:C1 All ";
let expected = format!("{left}{}{right}", " ".repeat(80 - left.len() - right.len()));
assert_eq!(actual, expected);
}
#[test]
fn statusline_real_frame_orders_runs_styles_separators_and_keeps_echo_independent() {
let s = fresh_with(b"hello");
s.core.borrow_mut().status = "echo-only".to_owned();
s.lua_host
.lua()
.load(
r#"
pmacs.theme.merge {
["ui.modeline.red"] = { fg = 1 },
["ui.modeline.blue"] = { fg = 2 },
}
_G.statusline_handles = {
pmacs.statusline.register {
name = "left-zero", side = "left", priority = 0,
face = "ui.modeline.blue", fn = function() return "L0" end,
},
pmacs.statusline.register {
name = "left-high", side = "left", priority = 10,
face = "ui.modeline.red", fn = function() return "LH" end,
},
pmacs.statusline.register {
name = "left-nil", side = "left", priority = 100,
fn = function() return nil end,
},
pmacs.statusline.register {
name = "left-empty", side = "left", priority = 100,
fn = function() return "" end,
},
pmacs.statusline.register {
name = "left-zero-late", side = "left", priority = 0,
face = "ui.modeline.blue", fn = function() return "L1" end,
},
pmacs.statusline.register {
name = "right-zero", side = "right", priority = 0,
face = "ui.modeline.blue", fn = function() return "R0" end,
},
pmacs.statusline.register {
name = "right-high", side = "right", priority = 10,
face = "ui.modeline.red", fn = function() return "RH" end,
},
pmacs.statusline.register {
name = "right-zero-late", side = "right", priority = 0,
face = "ui.modeline.blue", fn = function() return "R1" end,
},
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 100);
let mode = row_text(&cells, stride, 22, 100);
assert!(
mode.starts_with(" + test LH L0 L1"),
"wrong left composition: {mode:?}"
);
assert!(
mode.ends_with("R0 R1 RH L1:C1 All"),
"wrong right composition: {mode:?}"
);
assert!(!mode.contains("left-nil") && !mode.contains("left-empty"));
assert_eq!(row_text(&cells, stride, 23, 100), "echo-only");
let lh_col = mode.find("LH").unwrap() as u32;
let l0_col = mode.find("L0").unwrap() as u32;
let rh_col = mode.find("RH").unwrap() as u32;
let base = cells[(22 * stride) as usize].style;
for col in [lh_col, lh_col + 1, rh_col, rh_col + 1] {
let style = cells[(22 * stride + col) as usize].style;
assert!(style.reverse);
assert_eq!(style.bg, crate::cell::Color::Indexed(1));
}
for col in [l0_col, l0_col + 1] {
let style = cells[(22 * stride + col) as usize].style;
assert!(style.reverse);
assert_eq!(style.bg, crate::cell::Color::Indexed(2));
}
assert_eq!(
cells[(22 * stride + lh_col + 2) as usize].style,
base,
"custom/custom separator must retain ui.modeline"
);
let protected_right_col = mode.find(" L1:C1 All").unwrap() as u32;
assert_eq!(
cells[(22 * stride + protected_right_col - 1) as usize].style,
base,
"custom/built-in separator must retain ui.modeline"
);
}
#[test]
fn statusline_real_frame_evaluates_distinct_split_contexts_and_focus() {
let s = fresh_with(b"left");
s.lua_host
.lua()
.load(
r#"
_G.other_statusline_buffer = pmacs.buffer.create("other")
pmacs.window.split_vertical()
pmacs.window.switch_buffer(_G.other_statusline_buffer)
_G.statusline_seen = {}
_G.statusline_context_handle = pmacs.statusline.register {
name = "contexts", side = "left",
fn = function(ctx)
table.insert(_G.statusline_seen, {
frontend = ctx.frontend,
window = ctx.window,
buffer = tostring(ctx.buffer),
active = ctx.active,
})
return ctx.active and "ACTIVE" or "PASSIVE"
end,
}
_G.statusline_split_clip_handle = pmacs.statusline.register {
name = "split-clipping", side = "right",
fn = function(ctx)
return string.rep(ctx.active and "X" or "Y", 20)
end,
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 120);
let seen: mlua::Table = s.lua_host.lua().globals().get("statusline_seen").unwrap();
assert_eq!(seen.raw_len(), 2);
let first: mlua::Table = seen.raw_get(1).unwrap();
let second: mlua::Table = seen.raw_get(2).unwrap();
let first_window: u64 = first.get("window").unwrap();
let second_window: u64 = second.get("window").unwrap();
let first_buffer: String = first.get("buffer").unwrap();
let second_buffer: String = second.get("buffer").unwrap();
let first_frontend: u64 = first.get("frontend").unwrap();
let second_frontend: u64 = second.get("frontend").unwrap();
let first_active: bool = first.get("active").unwrap();
let second_active: bool = second.get("active").unwrap();
assert_ne!(first_window, second_window);
assert_ne!(first_buffer, second_buffer);
assert_eq!(first_frontend, FrontendId::LOCAL.0);
assert_eq!(second_frontend, FrontendId::LOCAL.0);
assert_ne!(first_active, second_active);
let left_mode = (0..60)
.map(|col| glyph_at(&cells, stride, 22, col))
.collect::<String>();
let right_mode = (60..120)
.map(|col| glyph_at(&cells, stride, 22, col))
.collect::<String>();
assert!(
(left_mode.contains("ACTIVE") && right_mode.contains("PASSIVE"))
|| (left_mode.contains("PASSIVE") && right_mode.contains("ACTIVE"))
);
s.lua_host
.lua()
.load("_G.statusline_seen = {}; pmacs.window.focus_next()")
.exec()
.unwrap();
let _ = render_to_grid(&s, 24, 120);
let seen: mlua::Table = s.lua_host.lua().globals().get("statusline_seen").unwrap();
assert_eq!(seen.raw_len(), 2);
let now_first: mlua::Table = seen.raw_get(1).unwrap();
let now_second: mlua::Table = seen.raw_get(2).unwrap();
let active_by_window = |table: &mlua::Table| {
(
table.get::<u64>("window").unwrap(),
table.get::<bool>("active").unwrap(),
)
};
let flipped = [active_by_window(&now_first), active_by_window(&now_second)];
assert!(flipped.contains(&(first_window, !first_active)));
assert!(flipped.contains(&(second_window, !second_active)));
let (narrow_cells, narrow_stride, _) = render_to_grid(&s, 24, 30);
let narrow_left = (0..15)
.map(|col| glyph_at(&narrow_cells, narrow_stride, 22, col))
.collect::<String>();
let narrow_right = (15..30)
.map(|col| glyph_at(&narrow_cells, narrow_stride, 22, col))
.collect::<String>();
assert!(
(narrow_left.contains('X')
&& !narrow_left.contains('Y')
&& narrow_right.contains('Y')
&& !narrow_right.contains('X'))
|| (narrow_left.contains('Y')
&& !narrow_left.contains('X')
&& narrow_right.contains('X')
&& !narrow_right.contains('Y')),
"custom runs crossed a split boundary: left={narrow_left:?} right={narrow_right:?}"
);
}
#[test]
fn statusline_real_frame_discards_context_mutated_during_callback() {
let s = fresh_with(b"old");
s.lua_host
.lua()
.load(
r#"
_G.statusline_switch_target = pmacs.buffer.create("switched")
_G.statusline_switch_once = true
_G.statusline_switch_handle = pmacs.statusline.register {
name = "context-mutator", side = "left",
fn = function()
if _G.statusline_switch_once then
_G.statusline_switch_once = false
pmacs.window.switch_buffer(_G.statusline_switch_target)
return "STALE"
end
return "FRESH"
end,
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let first = row_text(&cells, stride, 22, 80);
assert!(
first.contains("switched"),
"callback buffer switch did not land"
);
assert!(
!first.contains("STALE"),
"invalidated old-context output reached the new buffer: {first:?}"
);
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let second = row_text(&cells, stride, 22, 80);
assert!(
second.contains("FRESH"),
"next valid frame did not evaluate the surviving context: {second:?}"
);
}
#[test]
fn statusline_real_frame_paints_unicode_clusters_and_sanitizes_all_runs() {
let s = fresh_with(b"hello");
{
let core = s.core.borrow();
let registry = core.registry.clone();
registry
.borrow_mut()
.get_mut(core.active_buffer_id())
.unwrap()
.set_name("na\r\n\u{1b}me");
}
s.lua_host
.lua()
.load(
r#"
_G.statusline_unicode_handle = pmacs.statusline.register {
name = "unicode", side = "left",
fn = function() return "\204\129界e\204\129\27Z" end,
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 24, 80);
let row = &cells[(22 * stride) as usize..(23 * stride) as usize];
let wide_col = row
.iter()
.position(|cell| cell.glyph == crate::cell::Glyph::Char('界'))
.expect("CJK grapheme should be present");
assert_eq!(row[wide_col + 1].glyph, crate::cell::Glyph::Continuation);
assert_eq!(
row[wide_col + 2].glyph,
crate::cell::Glyph::Cluster("e\u{301}".as_bytes().into())
);
assert_eq!(row[wide_col + 3].glyph, crate::cell::Glyph::Char(' '));
assert_eq!(row[wide_col + 4].glyph, crate::cell::Glyph::Char('Z'));
for cell in row {
match &cell.glyph {
crate::cell::Glyph::Char(ch) => assert!(!ch.is_control()),
crate::cell::Glyph::Cluster(bytes) => {
let text = std::str::from_utf8(bytes).unwrap();
assert!(!text.chars().any(char::is_control));
assert_ne!(text, "\u{301}", "standalone zero-width grapheme leaked");
}
crate::cell::Glyph::Continuation => {}
}
}
let ascii_projection = row
.iter()
.map(|cell| match cell.glyph {
crate::cell::Glyph::Char(ch) => ch,
_ => '?',
})
.collect::<String>();
assert!(
ascii_projection.contains("na me"),
"buffer-name controls were not replaced independently: {ascii_projection:?}"
);
}
#[test]
fn statusline_real_frame_clips_custom_edges_but_preserves_protected_suffix() {
let s = fresh_with(b"hello");
s.lua_host
.lua()
.load(
r#"
_G.statusline_clip_handles = {
pmacs.statusline.register {
name = "left-high", side = "left", priority = 10,
fn = function() return "HIGH" end,
},
pmacs.statusline.register {
name = "left-low", side = "left", priority = 0,
fn = function() return "界LOW" end,
},
pmacs.statusline.register {
name = "right-low", side = "right", priority = 0,
fn = function() return "LOW" end,
},
pmacs.statusline.register {
name = "right-high", side = "right", priority = 10,
fn = function() return "HIGH" end,
},
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&s, 6, 17);
let mode = row_text(&cells, stride, 4, 17);
assert!(
mode.contains("HIGH"),
"high-priority right edge lost: {mode:?}"
);
assert!(
!mode.contains("LOW"),
"low-priority right edge survived: {mode:?}"
);
assert!(
mode.ends_with(" L1:C1 All"),
"protected suffix was not preserved in full: {mode:?}"
);
assert_ne!(
cells[(4 * stride) as usize].glyph,
crate::cell::Glyph::Continuation,
"a clipped wide grapheme left a continuation at the window edge"
);
let left_only = fresh_with(b"hello");
left_only
.lua_host
.lua()
.load(
r#"
_G.statusline_left_clip_handles = {
pmacs.statusline.register {
name = "left-high", side = "left", priority = 10,
fn = function() return "HIGH" end,
},
pmacs.statusline.register {
name = "left-low", side = "left", priority = 0,
fn = function() return "界LOW" end,
},
}
"#,
)
.exec()
.unwrap();
let (cells, stride, _) = render_to_grid(&left_only, 6, 26);
let mode = row_text(&cells, stride, 4, 26);
assert!(mode.starts_with(" + test HIGH"));
assert!(!mode.contains("LOW"));
assert!(mode.ends_with(" L1:C1 All"));
let (cells, stride, _) = render_to_grid(&s, 6, 11);
let mode = row_text(&cells, stride, 4, 11);
assert!(
!mode.contains("L1:C1") && !mode.contains("HIGH") && !mode.contains("LOW"),
"a non-fitting protected suffix must drop the whole right group: {mode:?}"
);
}
/// Give the active buffer a file path and return its `file://` /// Give the active buffer a file path and return its `file://`
/// URI, so diag-store entries can be keyed to it. /// URI, so diag-store entries can be keyed to it.
fn set_active_buffer_path(s: &EditorState, path: &str) -> String { fn set_active_buffer_path(s: &EditorState, path: &str) -> String {

View File

@ -422,6 +422,10 @@ impl Frontend {
// preference; terminal fonts belong to the terminal, so // preference; terminal fonts belong to the terminal, so
// the cell-grid TUI drops this silently too. // the cell-grid TUI drops this silently too.
| InstanceMessage::FontFacts { .. } | InstanceMessage::FontFacts { .. }
// Q#SL7 — custom statusline segments are semantic-only;
// the grid TUI paints provider output directly from the
// registry and silently drops an unexpected wire copy.
| InstanceMessage::StatuslineSegments { .. }
| 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
@ -817,6 +821,28 @@ mod tests {
.expect("the grid frontend must drop FontFacts silently"); .expect("the grid frontend must drop FontFacts silently");
} }
#[test]
fn statusline_segments_drop_silently_on_the_grid_frontend() {
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::StatuslineSegments {
buffer_id: crate::buffer::BufferId::from_raw(7),
left: vec![pmacs_protocol::StatuslineSegment {
text: "project".into(),
face: "ui.modeline.project".into(),
}],
right: Vec::new(),
})
.expect("the grid frontend must drop StatuslineSegments 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 {

View File

@ -90,7 +90,7 @@ pub struct Theme {
/// and the `ThemeFacts` producer's key filter. /// and the `ThemeFacts` producer's key filter.
#[must_use] #[must_use]
pub fn is_face_name(name: &str) -> bool { pub fn is_face_name(name: &str) -> bool {
name == "ui" || name.starts_with("ui.") pmacs_protocol::is_ui_face_name(name)
} }
impl Theme { impl Theme {
@ -226,6 +226,37 @@ impl Theme {
} }
} }
/// Resolve a custom modeline segment face relative to the already
/// resolved `ui.modeline` surface (statusline framing Q#SL6).
///
/// Only a concrete foreground from the exact child or an intermediate
/// child is returned. The walk stops before `ui.modeline`: reaching the
/// base means the segment keeps the base modeline's effective text
/// color. An explicitly default foreground also stops inheritance and
/// returns to that base. Out-of-mask style components are discarded.
#[must_use]
pub fn modeline_segment_face(&self, name: &str) -> Option<Style> {
debug_assert!(
pmacs_protocol::is_modeline_face_name(name),
"modeline_segment_face() takes ui.modeline/ui.modeline.* names"
);
let mut name = name;
while name != "ui.modeline" {
if let Some(style) = self.by_capture.get(name) {
return match style.fg {
Color::Default => None,
fg => Some(Style {
fg,
..Style::default()
}),
};
}
let index = name.rfind('.')?;
name = &name[..index];
}
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);
@ -842,6 +873,47 @@ mod tests {
assert!(!is_face_name("gui.modeline")); assert!(!is_face_name("gui.modeline"));
} }
#[test]
fn modeline_segment_face_is_base_relative_and_fg_only() {
let mut t = Theme::empty();
t.insert(
"ui.modeline",
Style {
fg: Color::Indexed(1),
bg: Color::Indexed(2),
reverse: true,
..Style::default()
},
);
assert_eq!(t.modeline_segment_face("ui.modeline"), None);
assert_eq!(t.modeline_segment_face("ui.modeline.unset"), None);
t.insert(
"ui.modeline.project",
Style {
fg: Color::Indexed(6),
bg: Color::Indexed(5),
reverse: true,
bold: true,
..Style::default()
},
);
assert_eq!(
t.modeline_segment_face("ui.modeline.project.branch"),
Some(Style {
fg: Color::Indexed(6),
..Style::default()
})
);
t.insert("ui.modeline.project.branch", Style::default());
assert_eq!(
t.modeline_segment_face("ui.modeline.project.branch"),
None,
"an exact default foreground blocks the colored intermediate parent"
);
}
#[test] #[test]
fn line_offsets_basic() { fn line_offsets_basic() {
let src = b"a\nbb\nccc"; let src = b"a\nbb\nccc";

View File

@ -128,6 +128,7 @@ pub mod semantic_tokens;
pub mod signature; pub mod signature;
pub mod socket_path; pub mod socket_path;
pub mod state; pub mod state;
pub mod statusline;
pub mod symbol; pub mod symbol;
pub mod syntax; pub mod syntax;
pub mod text_view; pub mod text_view;

View File

@ -1233,6 +1233,10 @@ impl LspManager {
let id = LspServerId::next(); let id = LspServerId::next();
let mut client = LspClient::new(spec); let mut client = LspClient::new(spec);
self.start_generation(id, &mut client)?; self.start_generation(id, &mut client)?;
// The statusline may render before the supervisor's next `Started`
// event is drained. Seed the documented initializing state now so an
// attached live server is never mislabeled as forgotten (`?`).
self.status_tracker.ensure(id, Instant::now());
self.clients.insert(id, client); self.clients.insert(id, client);
Ok(id) Ok(id)
} }

View File

@ -66,6 +66,10 @@ use crate::packages::{
}; };
use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity}; use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity};
use crate::rope::Range; use crate::rope::Range;
use crate::statusline::{
SharedStatuslineRegistry, StatuslineProviderFailure, StatuslineProviderId, StatuslineRegistry,
StatuslineSide,
};
use crate::syntax::{self, ParseTreeBundle, ParseView, ParseViewHandle, SharedSyntaxRegistry}; use crate::syntax::{self, ParseTreeBundle, ParseView, ParseViewHandle, SharedSyntaxRegistry};
use crate::workers_buffer; use crate::workers_buffer;
@ -2011,6 +2015,230 @@ impl UserData for MarkHandleLua {
} }
} }
/// Opaque Lua handle for a statusline provider registration.
#[derive(Copy, Clone)]
pub struct StatuslineProviderIdLua(pub StatuslineProviderId);
impl FromLua for StatuslineProviderIdLua {
fn from_lua(value: Value, _: &Lua) -> mlua::Result<Self> {
match value {
Value::UserData(data) => Ok(*data.borrow::<Self>()?),
other => Err(mlua::Error::FromLuaConversionError {
from: other.type_name(),
to: "StatuslineProviderIdLua".to_owned(),
message: Some("expected a statusline provider handle".to_owned()),
}),
}
}
}
impl UserData for StatuslineProviderIdLua {
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
methods.add_method("raw", |_, this, ()| Ok(this.0.raw()));
methods.add_meta_method(mlua::MetaMethod::ToString, |_, this, ()| {
Ok(this.0.to_string())
});
methods.add_meta_method(
mlua::MetaMethod::Eq,
|_, this, other: StatuslineProviderIdLua| Ok(this.0 == other.0),
);
}
}
/// Retrieve the statusline registry installed with the base `pmacs` table.
///
/// `EditorState` and semantic/TUI constructors use this exact shared handle;
/// bare Lua hosts always receive an empty registry rather than an absent API.
pub fn statusline_registry(lua: &Lua) -> mlua::Result<SharedStatuslineRegistry> {
lua.app_data_ref::<SharedStatuslineRegistry>()
.map(|registry| registry.clone())
.ok_or_else(|| mlua::Error::external("statusline registry is not installed"))
}
/// Install the strict `pmacs.statusline` registration/lifecycle surface.
#[allow(
clippy::too_many_lines,
reason = "one strict table parser followed by four small lifecycle bindings; splitting obscures the all-fields-before-mutation contract"
)]
pub fn install_statusline_module(
lua: &Lua,
registry: &SharedStatuslineRegistry,
) -> mlua::Result<Table> {
let module = lua.create_table()?;
{
let registry = registry.clone();
module.set(
"register",
lua.create_function(move |lua, spec: Table| {
let mut unknown = None;
spec.clone().for_each(|key: Value, _: Value| {
let name = match &key {
Value::String(value) => value.to_str().map_or_else(
|_| "<invalid UTF-8>".to_owned(),
|value| value.to_owned(),
),
other => format!("{other:?}"),
};
if !matches!(name.as_str(), "name" | "side" | "priority" | "face" | "fn")
&& unknown.is_none()
{
unknown = Some(name);
}
Ok(())
})?;
if let Some(key) = unknown {
return Err(mlua::Error::external(format!(
"pmacs.statusline.register: unknown field `{key}`"
)));
}
let name =
strict_statusline_string(spec.raw_get("name")?, "name", false)?
.expect("required statusline name");
let side_value =
strict_statusline_string(spec.raw_get("side")?, "side", false)?
.expect("required statusline side");
let side = match side_value.as_str() {
"left" => StatuslineSide::Left,
"right" => StatuslineSide::Right,
other => {
return Err(mlua::Error::external(format!(
"pmacs.statusline.register: `side` must be \"left\" or \"right\", got {other:?}"
)));
}
};
let priority = strict_statusline_priority(spec.raw_get("priority")?)?;
let face = strict_statusline_string(spec.raw_get("face")?, "face", true)?
.unwrap_or_else(|| "ui.modeline".to_owned());
let callback = match spec.raw_get::<Value>("fn")? {
Value::Function(function) => function,
other => {
return Err(mlua::Error::external(format!(
"pmacs.statusline.register: `fn` must be a function, got {}",
other.type_name()
)));
}
};
// Every raw field is now parsed and typed; only this final call
// mutates the registry.
let id = registry.borrow_mut().register(
name,
side,
priority,
face,
callback,
caller_source(lua, 2),
)
.map_err(mlua::Error::external)?;
Ok(StatuslineProviderIdLua(id))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"unregister",
lua.create_function(move |_, id: StatuslineProviderIdLua| {
Ok(registry.borrow_mut().unregister(id.0))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"set_priority",
lua.create_function(move |_, (id, value): (StatuslineProviderIdLua, Value)| {
let priority = strict_statusline_priority(value)?;
Ok(registry.borrow_mut().set_priority(id.0, priority))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"set_enabled",
lua.create_function(move |_, (id, value): (StatuslineProviderIdLua, Value)| {
let Value::Boolean(enabled) = value else {
return Err(mlua::Error::external(
"pmacs.statusline.set_enabled: `enabled` must be a boolean",
));
};
Ok(registry.borrow_mut().set_enabled(id.0, enabled))
})?,
)?;
}
{
let registry = registry.clone();
module.set(
"providers",
lua.create_function(move |lua, ()| {
let providers = registry.borrow().providers();
let output = lua.create_table_with_capacity(providers.len(), 0)?;
for (index, provider) in providers.iter().enumerate() {
let metadata = lua.create_table_with_capacity(0, 6)?;
metadata.raw_set("handle", StatuslineProviderIdLua(provider.id))?;
metadata.raw_set("name", provider.name.as_str())?;
metadata.raw_set("side", provider.side.as_str())?;
metadata.raw_set("priority", provider.priority)?;
metadata.raw_set("face", provider.face.as_str())?;
metadata.raw_set("enabled", provider.enabled)?;
output.raw_set(index + 1, metadata)?;
}
Ok(output)
})?,
)?;
}
Ok(module)
}
fn strict_statusline_string(
value: Value,
field: &'static str,
optional: bool,
) -> mlua::Result<Option<String>> {
match value {
Value::Nil if optional => Ok(None),
Value::String(value) => value
.to_str()
.map(|value| Some(value.to_owned()))
.map_err(|_| {
mlua::Error::external(format!(
"pmacs.statusline.register: `{field}` must be valid UTF-8"
))
}),
other => Err(mlua::Error::external(format!(
"pmacs.statusline.register: `{field}` must be a string, got {}",
other.type_name()
))),
}
}
fn strict_statusline_priority(value: Value) -> mlua::Result<i32> {
match value {
Value::Nil => Ok(0),
Value::Integer(value) => i32::try_from(value).map_err(|_| {
mlua::Error::external(
"pmacs.statusline priority must be an integer in the signed 32-bit range",
)
}),
Value::Number(value)
if value.is_finite()
&& value.fract() == 0.0
&& value >= f64::from(i32::MIN)
&& value <= f64::from(i32::MAX) =>
{
Ok(value as i32)
}
Value::Number(_) => Err(mlua::Error::external(
"pmacs.statusline priority must be an integer in the signed 32-bit range",
)),
other => Err(mlua::Error::external(format!(
"pmacs.statusline priority must be a number, got {}",
other.type_name()
))),
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Module install // Module install
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -2047,6 +2275,8 @@ pub fn install(
lua.set_app_data(PackageUnloadHooks::new()); lua.set_app_data(PackageUnloadHooks::new());
lua.set_app_data(CurrentlyLoadingPackage::new()); lua.set_app_data(CurrentlyLoadingPackage::new());
lua.set_app_data(BufferRemoveCallbacks::new()); lua.set_app_data(BufferRemoveCallbacks::new());
let statusline = Rc::new(RefCell::new(StatuslineRegistry::new()));
lua.set_app_data(statusline.clone());
let pmacs = lua.create_table()?; let pmacs = lua.create_table()?;
pmacs.set("buffer", install_buffer_module(lua, registry)?)?; pmacs.set("buffer", install_buffer_module(lua, registry)?)?;
@ -2054,6 +2284,7 @@ pub fn install(
pmacs.set("keymap", install_keymap_module(lua, keymaps)?)?; pmacs.set("keymap", install_keymap_module(lua, keymaps)?)?;
pmacs.set("menu", install_menu_module(lua, menus)?)?; pmacs.set("menu", install_menu_module(lua, menus)?)?;
pmacs.set("hook", install_hook_module(lua, hooks)?)?; pmacs.set("hook", install_hook_module(lua, hooks)?)?;
pmacs.set("statusline", install_statusline_module(lua, &statusline)?)?;
// Wall-clock millis (since UNIX epoch). Used by builtin runtime // Wall-clock millis (since UNIX epoch). Used by builtin runtime
// chunks for timeout loops; `os.clock()` only counts CPU time and // chunks for timeout loops; `os.clock()` only counts CPU time and
// is a poor fit for "wait until something arrives over I/O". // is a poor fit for "wait until something arrives over I/O".
@ -5364,6 +5595,48 @@ fn log_hook_error(lua: &Lua, hook_name: &str, err: &crate::hook::HookCallbackErr
notify_buffer_edit_to_windows(lua, id, &edit); notify_buffer_edit_to_windows(lua, id, &edit);
} }
} }
/// Append a first-in-run statusline provider failure to `*errors*`.
///
/// The latch decision lives in [`crate::statusline::StatuslineRegistry`];
/// this function owns only the repository-standard durable sink and window
/// invalidation.
pub(crate) fn log_statusline_provider_error(lua: &Lua, failure: &StatuslineProviderFailure) {
let message = crate::statusline::sanitize_provider_text(&failure.message);
let line = format!(
"[statusline:{}] provider registered at {} failed for {:?}/{:?}/{:?}/active={}: {}\n",
failure.provider_name,
failure.source.render(),
failure.context.frontend_id,
failure.context.window_id,
failure.context.buffer_id,
failure.context.active,
message,
);
let result = {
let Some(app) = lua.app_data_ref::<SharedRegistry>() else {
return;
};
let mut registry = app.borrow_mut();
let id = match registry.find_by_name(crate::lua::ERRORS_BUFFER_NAME) {
Some(id) => id,
None => registry.create(crate::lua::ERRORS_BUFFER_NAME),
};
let Ok(buffer) = registry.get_mut(id) else {
return;
};
let position = buffer.len();
let edit = buffer
.apply_edit(EditOp::Insert {
pos: position,
bytes: line.as_bytes(),
})
.ok();
edit.map(|edit| (id, edit))
};
if let Some((id, edit)) = result {
notify_buffer_edit_to_windows(lua, id, &edit);
}
}
/// T M7.8: append a `[package <name>]` entry to `*errors*`. /// T M7.8: append a `[package <name>]` entry to `*errors*`.
/// ///

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips --- // --- M5.5a handshake & postcard round-trips ---
#[test] #[test]
fn protocol_version_is_seventeen_for_font_facts() { fn protocol_version_is_eighteen_for_statusline_segments() {
// 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
@ -1712,7 +1712,10 @@ mod tests {
// Themes stage 2 Q#F4 bumped 16→17 (`InstanceMessage:: // Themes stage 2 Q#F4 bumped 16→17 (`InstanceMessage::
// FontFacts`, additive + daemon-gated, appended as the final // FontFacts`, additive + daemon-gated, appended as the final
// variant — see the ThemeFacts placement pin). // variant — see the ThemeFacts placement pin).
assert_eq!(PROTOCOL_VERSION, 17); // Statusline segments Q#SL7 bumped 17→18 (`InstanceMessage::
// StatuslineSegments`, additive + daemon-gated, appended after
// FontFacts — see the v17 placement pin).
assert_eq!(PROTOCOL_VERSION, 18);
} }
#[test] #[test]
@ -1786,18 +1789,18 @@ 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`), v16 (`ThemeFacts`), v17 (`FontFacts`) // (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
// all interoperate, so v6 through v17 talk. // and v18 (`StatuslineSegments`) all interoperate.
for accepted in 6..=17 { for accepted in 6..=18 {
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, 18, u32::MAX] { for rejected in [0, 1, 2, 3, 4, 5, 19, u32::MAX] {
assert!( assert!(
!is_supported_protocol_version(rejected), !is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v17 binary" "v{rejected} must be rejected by a v18 binary"
); );
} }
} }
@ -1855,6 +1858,77 @@ mod tests {
} }
} }
#[test]
fn statusline_segments_round_trip_through_postcard() {
let bid = crate::buffer::BufferId::next();
for msg in [
InstanceMessage::StatuslineSegments {
buffer_id: bid,
left: Vec::new(),
right: Vec::new(),
},
InstanceMessage::StatuslineSegments {
buffer_id: bid,
left: vec![StatuslineSegment {
text: "project".into(),
face: "ui.modeline.project".into(),
}],
right: vec![
StatuslineSegment {
text: "LSP:ready".into(),
face: "ui.modeline.lsp".into(),
},
StatuslineSegment {
text: "main".into(),
face: "ui.modeline".into(),
},
],
},
] {
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(msg, decoded);
}
}
#[test]
fn font_facts_encoding_is_unchanged_by_the_v18_build() {
let msg = InstanceMessage::FontFacts {
family: Some("Iosevka".into()),
size_centi_px: Some(1850),
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes,
[
24, 1, 7, b'I', b'o', b's', b'e', b'v', b'k', b'a', 1, 186, 14
],
"FontFacts' v17 wire bytes changed — append new InstanceMessage variants at the end"
);
}
#[test]
fn shared_face_namespace_predicates_are_exact() {
for (name, ui, modeline) in [
("ui", true, false),
("ui.", true, false),
("ui.modeline", true, true),
("ui.modeline.", true, true),
("ui.modeline.lsp", true, true),
("ui.statusline", true, false),
("uix", false, false),
("gui.modeline", false, false),
("", false, false),
] {
assert_eq!(is_ui_face_name(name), ui, "UI predicate for {name:?}");
assert_eq!(
is_modeline_face_name(name),
modeline,
"modeline predicate for {name:?}"
);
}
}
#[test] #[test]
fn theme_facts_encoding_is_unchanged_by_the_v17_build() { fn theme_facts_encoding_is_unchanged_by_the_v17_build() {
// Q#F4 placement pin: `FontFacts` must be APPENDED after // Q#F4 placement pin: `FontFacts` must be APPENDED after

View File

@ -37,7 +37,12 @@ use crate::cell::Style;
use crate::editor::EditorState; use crate::editor::EditorState;
use crate::protocol::{ use crate::protocol::{
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment, AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StyleSegment, StyleSpan, FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StatuslineSegment, StyleSegment,
StyleSpan,
};
use crate::statusline::{
StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget,
evaluate_statusline,
}; };
/// The viewport a `semantic_render` frontend last declared. /// The viewport a `semantic_render` frontend last declared.
@ -227,6 +232,11 @@ pub struct SemanticRenderState {
/// viewport declaration. A frontend retaining face state across /// viewport declaration. A frontend retaining face state across
/// attachments is therefore corrected even by an unthemed daemon. /// attachments is therefore corrected even by an unthemed daemon.
last_theme_faces: Option<Vec<crate::protocol::ThemeFace>>, last_theme_faces: Option<Vec<crate::protocol::ThemeFace>>,
/// For v18 peers, the enabled provider-face set epoch inspected by
/// `theme_facts_msg`. Kept separate from `last_face_epoch` so
/// priority-only provider changes do not rebuild the face table.
/// v16/v17 peers never read the registry and leave this `None`.
last_statusline_face_set_epoch: Option<u64>,
/// Whether the peer negotiated protocol >= 16 (PR #120 round 1 /// Whether the peer negotiated protocol >= 16 (PR #120 round 1
/// finding 3). Faces reach a semantic frontend through TWO /// finding 3). Faces reach a semantic frontend through TWO
/// channels: `ThemeFacts` (daemon write-loop gated) and the /// channels: `ThemeFacts` (daemon write-loop gated) and the
@ -251,6 +261,14 @@ pub struct SemanticRenderState {
/// font state, so this gate has no summary-style companion /// font state, so this gate has no summary-style companion
/// filter. /// filter.
peer_knows_font_facts: bool, peer_knows_font_facts: bool,
/// Whether the peer negotiated protocol >= 18 (Q#SL7). This gates
/// callback evaluation in the producer, independently of the daemon's
/// write-loop gate.
peer_knows_statusline_segments: bool,
/// Complete replacement baseline per buffer. `None` means the peer has
/// never received an authoritative payload, so the first empty result
/// must still be emitted.
last_statusline: HashMap<BufferId, (Vec<StatuslineSegment>, Vec<StatuslineSegment>)>,
/// 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*
@ -352,11 +370,12 @@ impl SemanticRenderState {
let mut s = Self::new(frontend_id); let mut s = Self::new(frontend_id);
s.peer_knows_theme_facts = negotiated_protocol_version >= 16; s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
s.peer_knows_font_facts = negotiated_protocol_version >= 17; s.peer_knows_font_facts = negotiated_protocol_version >= 17;
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
s s
} }
/// Fresh session state for frontend `frontend_id`: no viewport /// Fresh session state for frontend `frontend_id`: no viewport
/// declared, nothing sent. Assumes a current-build peer (>= 16); /// declared, nothing sent. Assumes a current-build peer (>= 18);
/// daemon sessions with a real negotiated version use /// daemon sessions with a real negotiated version use
/// [`Self::for_peer`]. /// [`Self::for_peer`].
#[must_use] #[must_use]
@ -384,6 +403,7 @@ impl SemanticRenderState {
// (empty included), and the epoch gate cannot short-circuit // (empty included), and the epoch gate cannot short-circuit
// an epoch-0 daemon before that send. // an epoch-0 daemon before that send.
last_face_epoch: None, last_face_epoch: None,
last_statusline_face_set_epoch: None,
last_theme_faces: None, last_theme_faces: None,
peer_knows_theme_facts: true, peer_knows_theme_facts: true,
// Q#F5: both seeded None — the first frame after viewport // Q#F5: both seeded None — the first frame after viewport
@ -393,6 +413,8 @@ impl SemanticRenderState {
last_font_epoch: None, last_font_epoch: None,
last_font_facts: None, last_font_facts: None,
peer_knows_font_facts: true, peer_knows_font_facts: true,
peer_knows_statusline_segments: true,
last_statusline: HashMap::new(),
diag_line_cache: HashMap::new(), diag_line_cache: HashMap::new(),
} }
} }
@ -413,10 +435,11 @@ impl SemanticRenderState {
/// ///
/// A `BufferSnapshot` resets the receiving frontend's /// A `BufferSnapshot` resets the receiving frontend's
/// buffer-scoped render state wholesale — spans, decorations, /// buffer-scoped render state wholesale — spans, decorations,
/// adornments, minimap summary, completion popup (see the GPU's /// adornments, minimap summary, completion popup, status facts, and
/// `BufferSnapshot` arm) — so every buffer-scoped emission /// statusline segments (see the GPU's `BufferSnapshot` arm) — so
/// baseline this producer holds for that buffer must die with the /// every buffer-scoped emission baseline this producer holds for
/// send. Otherwise an unchanged-key revisit (the A → B → A round /// 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 /// trip at one CRDT generation) suppresses every re-send and the
/// frontend never regains the state until an edit, diagnostic /// frontend never regains the state until an edit, diagnostic
/// republish, or theme mutation happens to move the key. /// republish, or theme mutation happens to move the key.
@ -426,7 +449,8 @@ impl SemanticRenderState {
/// harmless — the failure mode is one redundant re-send, never /// harmless — the failure mode is one redundant re-send, never
/// staleness. /// staleness.
/// ///
/// Deliberately NOT reset: `last_face_epoch` / `last_theme_faces` /// Deliberately NOT reset: `last_face_epoch`,
/// `last_statusline_face_set_epoch`, and `last_theme_faces`
/// (`ThemeFacts` is bufferless — the frontend keeps its face /// (`ThemeFacts` is bufferless — the frontend keeps its face
/// table across snapshots), `last_minibuffer` (one global core /// table across snapshots), `last_minibuffer` (one global core
/// instance, not buffer-scoped), `last_line_numbers` /// instance, not buffer-scoped), `last_line_numbers`
@ -449,6 +473,7 @@ impl SemanticRenderState {
self.last_search_prompt.remove(&buffer_id); self.last_search_prompt.remove(&buffer_id);
self.last_menu_prompt.remove(&buffer_id); self.last_menu_prompt.remove(&buffer_id);
self.last_completion_popup.remove(&buffer_id); self.last_completion_popup.remove(&buffer_id);
self.last_statusline.remove(&buffer_id);
} }
/// Project one frame. /// Project one frame.
@ -476,6 +501,23 @@ impl SemanticRenderState {
return Vec::new(); return Vec::new();
}; };
// Evaluate callbacks before any long-lived core borrow and before
// ThemeFacts is computed. A callback may change the registry; the
// post-evaluation face inventory must then precede the authoritative
// segment replacement in this same frame. Unsupported peers skip the
// evaluator entirely and therefore pay no Lua callback/dynamic-face cost.
let statusline_evaluation = self.peer_knows_statusline_segments.then(|| {
evaluate_statusline(
state.lua_host.lua(),
&state.core,
&state.statusline_registry,
StatuslineEvaluationTarget::Semantic {
frontend_id: self.frontend_id,
declared_buffer: vp.buffer_id,
},
)
});
let generation = buffer_generation(state, vp.buffer_id); let generation = buffer_generation(state, vp.buffer_id);
let mut out = Vec::new(); let mut out = Vec::new();
@ -645,9 +687,85 @@ impl SemanticRenderState {
// --- ThemeFacts (UI faces; themes arc Q#TH7, protocol v16) --- // --- ThemeFacts (UI faces; themes arc Q#TH7, protocol v16) ---
out.extend(self.theme_facts_msg(state)); out.extend(self.theme_facts_msg(state));
out.extend(self.font_facts_msg(state)); out.extend(self.font_facts_msg(state));
// Q#SL6/Q#SL8: face inventory must precede segment text.
if let Some(evaluation) = statusline_evaluation {
self.emit_statusline_segments(evaluation, &mut out);
}
out out
} }
/// Apply the lead evaluator's publication outcome to the v18 wire
/// baseline. Invalidated evaluations discard all callback text and
/// publish authoritative empty replacements for the captured old
/// contexts; phase-1 stale outcomes publish nothing.
fn emit_statusline_segments(
&mut self,
evaluation: StatuslineEvaluation,
out: &mut Vec<InstanceMessage>,
) {
let to_wire = |segments: Vec<crate::statusline::EvaluatedStatuslineSegment>| {
segments
.into_iter()
.map(|segment| StatuslineSegment {
text: segment.text,
face: segment.face,
})
.collect()
};
let frontend_id = self.frontend_id;
match evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
if let Some(window) = windows
.into_iter()
.find(|window| window.context.frontend_id == frontend_id)
{
self.emit_statusline_payload(
window.context.buffer_id,
to_wire(window.left),
to_wire(window.right),
out,
);
}
}
StatuslineEvaluationOutcome::Invalidated {
authoritative_empty,
} => {
for context in authoritative_empty
.into_iter()
.filter(|context| context.frontend_id == frontend_id)
{
self.emit_statusline_payload(context.buffer_id, Vec::new(), Vec::new(), out);
}
}
StatuslineEvaluationOutcome::NoMessage(_) => {}
}
}
fn emit_statusline_payload(
&mut self,
buffer_id: BufferId,
left: Vec<StatuslineSegment>,
right: Vec<StatuslineSegment>,
out: &mut Vec<InstanceMessage>,
) {
if self
.last_statusline
.get(&buffer_id)
.is_some_and(|(old_left, old_right)| old_left == &left && old_right == &right)
{
return;
}
let baseline = (left.clone(), right.clone());
out.push(InstanceMessage::StatuslineSegments {
buffer_id,
left,
right,
});
// Advance only after the complete replacement has entered the
// frame output, including the authoritative-empty invalidation path.
self.last_statusline.insert(buffer_id, baseline);
}
/// The `CompletionPopup` message for this frame, or `None` when the /// The `CompletionPopup` message for this frame, or `None` when the
/// popup state for `buffer_id` is unchanged (Arc 1a Q#C5). Only the /// popup state for `buffer_id` is unchanged (Arc 1a Q#C5). Only the
/// active buffer carries a live popup, and — the multi-frontend /// active buffer carries a live popup, and — the multi-frontend
@ -1134,41 +1252,53 @@ impl SemanticRenderState {
}) })
} }
/// The `ThemeFacts` message for this frame, or `None` when the /// Build the authoritative `ThemeFacts` table. v16/v17 peers retain the
/// face table is unchanged (themes arc Q#TH7, protocol v16). /// fixed stage-1 inventory and never inspect the statusline registry.
/// Resolves the [`UI_FACES`] inventory through /// v18 peers union in enabled provider faces and key recomputation on
/// [`crate::highlight::Theme::face`] under one lock — resolution /// `(theme.face_epoch, registry.face_set_epoch)`.
/// 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> { 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 { if !self.peer_knows_theme_facts {
return None; return None;
} }
let theme = state.syntax_registry.theme(); let theme = state.syntax_registry.theme();
let (faces, face_epoch) = { let th = theme.lock().expect("theme mutex poisoned");
let th = theme.lock().expect("theme mutex poisoned"); let (face_set_epoch, dynamic_faces) = if self.peer_knows_statusline_segments {
let registry = state.statusline_registry.borrow();
let face_set_epoch = registry.face_set_epoch();
if self.last_face_epoch == Some(th.face_epoch)
&& self.last_statusline_face_set_epoch == Some(face_set_epoch)
{
return None;
}
(Some(face_set_epoch), registry.enabled_face_names())
} else {
if self.last_face_epoch == Some(th.face_epoch) { if self.last_face_epoch == Some(th.face_epoch) {
return None; return None;
} }
let faces: Vec<crate::protocol::ThemeFace> = UI_FACES (None, Vec::new())
.iter()
.filter_map(|name| {
th.face(name).map(|style| crate::protocol::ThemeFace {
name: (*name).to_owned(),
style,
})
})
.collect();
(faces, th.face_epoch)
}; };
let mut names: Vec<String> = UI_FACES.iter().map(|name| (*name).to_owned()).collect();
names.extend(dynamic_faces);
names.sort_unstable();
names.dedup();
let faces = names
.into_iter()
.filter_map(|name| {
let style = if UI_FACES.binary_search(&name.as_str()).is_ok() {
th.face(&name)
} else {
th.modeline_segment_face(&name)
};
style.map(|style| crate::protocol::ThemeFace { name, style })
})
.collect::<Vec<_>>();
let face_epoch = th.face_epoch;
drop(th);
self.last_face_epoch = Some(face_epoch); self.last_face_epoch = Some(face_epoch);
self.last_statusline_face_set_epoch = face_set_epoch;
let unchanged = self.last_theme_faces.as_ref() == Some(&faces); let unchanged = self.last_theme_faces.as_ref() == Some(&faces);
self.last_theme_faces = Some(faces.clone()); self.last_theme_faces = Some(faces.clone());
if unchanged { if unchanged {
@ -2303,6 +2433,19 @@ mod tests {
}) })
} }
fn statusline_of(
msgs: &[InstanceMessage],
) -> Option<(BufferId, Vec<StatuslineSegment>, Vec<StatuslineSegment>)> {
msgs.iter().find_map(|message| match message {
InstanceMessage::StatuslineSegments {
buffer_id,
left,
right,
} => Some((*buffer_id, left.clone(), right.clone())),
_ => None,
})
}
/// Simulate a committed face mutation: what `pmacs.theme.merge` /// Simulate a committed face mutation: what `pmacs.theme.merge`
/// does after its transactional parse (insert + face-epoch bump). /// does after its transactional parse (insert + face-epoch bump).
fn merge_face(state: &EditorState, name: &str, style: Style) { fn merge_face(state: &EditorState, name: &str, style: Style) {
@ -2312,6 +2455,222 @@ mod tests {
th.face_epoch += 1; th.face_epoch += 1;
} }
#[test]
fn statusline_first_empty_is_authoritative_then_silent_and_snapshot_resends() {
let state = empty_state();
let buffer_id = active_buffer(&state);
let lua = state.lua_host.lua();
let callback = lua
.load("return function(_) return __baseline_text end")
.eval()
.expect("callback");
state
.statusline_registry
.borrow_mut()
.register(
"baseline".into(),
crate::statusline::StatuslineSide::Left,
0,
"ui.modeline".into(),
callback,
crate::command::SourceLocation::default(),
)
.expect("register");
let mut semantic = SemanticRenderState::for_peer(FrontendId::LOCAL, 18);
semantic.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
Some((buffer_id, Vec::new(), Vec::new()))
);
assert_eq!(statusline_of(&semantic.render_frame(&state)), None);
lua.globals()
.set("__baseline_text", "changed")
.expect("set");
let changed = vec![StatuslineSegment {
text: "changed".into(),
face: "ui.modeline".into(),
}];
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
Some((buffer_id, changed.clone(), Vec::new()))
);
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
None,
"byte-identical callback output is silent"
);
semantic.on_buffer_snapshot_sent(buffer_id);
assert_eq!(
statusline_of(&semantic.render_frame(&state)),
Some((buffer_id, changed, Vec::new())),
"snapshot reset makes an unchanged revisit authoritative again"
);
}
#[test]
fn v17_skips_callbacks_and_dynamic_faces_while_v18_orders_theme_first() {
let state = empty_state();
let buffer_id = active_buffer(&state);
merge_face(
&state,
"ui.modeline.project",
Style {
fg: crate::cell::Color::Indexed(6),
bg: crate::cell::Color::Indexed(2),
bold: true,
reverse: true,
..Style::default()
},
);
let lua = state.lua_host.lua();
lua.globals().set("__statusline_calls", 0).expect("set");
let callback = lua
.load(
"return function(_) \
__statusline_calls = __statusline_calls + 1; return 'project' end",
)
.eval()
.expect("callback");
let provider_id = state
.statusline_registry
.borrow_mut()
.register(
"project".into(),
crate::statusline::StatuslineSide::Left,
0,
"ui.modeline.project".into(),
callback,
crate::command::SourceLocation::default(),
)
.expect("register");
let mut v17 = SemanticRenderState::for_peer(FrontendId::LOCAL, 17);
v17.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let old_frame = v17.render_frame(&state);
assert_eq!(lua.globals().get::<i64>("__statusline_calls").unwrap(), 0);
assert_eq!(statusline_of(&old_frame), None);
assert!(
theme_facts_of(&old_frame)
.expect("v17 still receives fixed ThemeFacts")
.iter()
.all(|face| face.name != "ui.modeline.project")
);
let mut v18 = SemanticRenderState::for_peer(FrontendId::LOCAL, 18);
v18.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0);
let frame = v18.render_frame(&state);
assert_eq!(lua.globals().get::<i64>("__statusline_calls").unwrap(), 1);
let theme_index = frame
.iter()
.position(|message| matches!(message, InstanceMessage::ThemeFacts { .. }))
.expect("dynamic ThemeFacts");
let segments_index = frame
.iter()
.position(|message| matches!(message, InstanceMessage::StatuslineSegments { .. }))
.expect("segments");
assert!(theme_index < segments_index);
let project_face = theme_facts_of(&frame)
.unwrap()
.into_iter()
.find(|face| face.name == "ui.modeline.project")
.expect("exact dynamic face");
assert_eq!(
project_face.style,
Style {
fg: crate::cell::Color::Indexed(6),
..Style::default()
}
);
let face_epoch = v18.last_statusline_face_set_epoch;
assert!(
state
.statusline_registry
.borrow_mut()
.set_priority(provider_id, 10)
);
assert_eq!(theme_facts_of(&v18.render_frame(&state)), None);
assert_eq!(v18.last_statusline_face_set_epoch, face_epoch);
assert!(
state
.statusline_registry
.borrow_mut()
.set_enabled(provider_id, false)
);
let disabled = v18.render_frame(&state);
assert!(
theme_facts_of(&disabled)
.expect("face-set shrink emits")
.iter()
.all(|face| face.name != "ui.modeline.project")
);
assert_eq!(
statusline_of(&disabled),
Some((buffer_id, Vec::new(), Vec::new()))
);
}
#[test]
fn invalidated_statusline_publishes_one_empty_baseline() {
let buffer_id = BufferId::from_raw(77);
let mut semantic = local();
let mut initial = Vec::new();
semantic.emit_statusline_payload(
buffer_id,
vec![StatuslineSegment {
text: "old".into(),
face: "ui.modeline".into(),
}],
Vec::new(),
&mut initial,
);
assert_eq!(initial.len(), 1);
let mut stale = Vec::new();
semantic.emit_statusline_segments(
StatuslineEvaluation {
outcome: StatuslineEvaluationOutcome::NoMessage(
crate::statusline::StatuslineNoMessageReason::DeclaredBufferMismatch,
),
new_failures: Vec::new(),
},
&mut stale,
);
assert!(stale.is_empty(), "phase-1 stale evaluation emits nothing");
assert_eq!(
semantic.last_statusline[&buffer_id].0[0].text, "old",
"stale evaluation retains the prior baseline until snapshot reset"
);
let invalidated = || StatuslineEvaluation {
outcome: StatuslineEvaluationOutcome::Invalidated {
authoritative_empty: vec![crate::statusline::StatuslineContext {
frontend_id: FrontendId::LOCAL,
window_id: crate::window::WindowId::next(),
buffer_id,
active: true,
}],
},
new_failures: Vec::new(),
};
let mut replacement = Vec::new();
semantic.emit_statusline_segments(invalidated(), &mut replacement);
assert_eq!(
statusline_of(&replacement),
Some((buffer_id, Vec::new(), Vec::new()))
);
let mut unchanged = Vec::new();
semantic.emit_statusline_segments(invalidated(), &mut unchanged);
assert!(
unchanged.is_empty(),
"the empty invalidation became baseline"
);
}
#[test] #[test]
fn theme_facts_authoritative_empty_then_silent_then_face_change_emits() { fn theme_facts_authoritative_empty_then_silent_then_face_change_emits() {
// Q#TH7: the first frame after viewport declaration ships the // Q#TH7: the first frame after viewport declaration ships the
@ -2647,9 +3006,10 @@ mod tests {
/// 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), `SearchPrompt` /// `FileStyleSummary`, `StatusFacts` (Q#S1), `SearchPrompt`
/// (Q#SR5), `LineNumbers`, or `ThemeFacts` (Q#TH7) — never /// (Q#SR5), `LineNumbers`, `ThemeFacts` (Q#TH7), `FontFacts`
/// `CellDelta`, grid `Cursor`, or the still-unwired /// (Q#F5), or `StatuslineSegments` (Q#SL7) — never `CellDelta`,
/// `BlockAdornments` / `FoldState` families. /// grid `Cursor`, or the still-unwired `BlockAdornments` /
/// `FoldState` families.
fn assert_semantic_only(msgs: &[InstanceMessage]) { fn assert_semantic_only(msgs: &[InstanceMessage]) {
for m in msgs { for m in msgs {
assert!( assert!(
@ -2664,6 +3024,7 @@ mod tests {
| InstanceMessage::LineNumbers { .. } | InstanceMessage::LineNumbers { .. }
| InstanceMessage::ThemeFacts { .. } | InstanceMessage::ThemeFacts { .. }
| InstanceMessage::FontFacts { .. } | InstanceMessage::FontFacts { .. }
| InstanceMessage::StatuslineSegments { .. }
), ),
"semantic projection emitted an unexpected variant: {m:?}" "semantic projection emitted an unexpected variant: {m:?}"
); );
@ -2835,13 +3196,14 @@ mod tests {
// (post-M11 minimap producer, generation-keyed), as does // (post-M11 minimap producer, generation-keyed), as does
// StatusFacts (Q#S1, cached-compare), the authoritative // StatusFacts (Q#S1, cached-compare), the authoritative
// ThemeFacts table (Q#TH7 — empty for an unthemed daemon), and // ThemeFacts table (Q#TH7 — empty for an unthemed daemon), and
// the authoritative FontFacts preference (Q#F5 — all-default). // the authoritative FontFacts preference (Q#F5 — all-default), and
// authoritative empty statusline segments (Q#SL8).
let first = s.render_frame(&state); let first = s.render_frame(&state);
assert_eq!( assert_eq!(
first.len(), first.len(),
6, 7,
"first frame ships StyleSpans + Decorations + FileStyleSummary \ "first frame ships StyleSpans + Decorations + FileStyleSummary \
+ StatusFacts + ThemeFacts + FontFacts" + StatusFacts + ThemeFacts + FontFacts + StatuslineSegments"
); );
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");

1063
src/statusline.rs Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff