Merge pull request #125 from levineuwirth/statusline-segments
feat(statusline): composable modeline segments at protocol v18
This commit is contained in:
commit
7bc0c61339
|
|
@ -2568,6 +2568,7 @@ dependencies = [
|
|||
"tree-sitter-typescript",
|
||||
"tree-sitter-yaml",
|
||||
"tree-sitter-zig",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ crdt = ["dep:loro", "pmacs-protocol/crdt"]
|
|||
crossterm = "0.28"
|
||||
thiserror = { workspace = true }
|
||||
unicode-width = "0.2"
|
||||
unicode-segmentation = "1"
|
||||
# Regex engine for in-buffer regex search (Q#RX1). `regex::bytes::Regex`
|
||||
# matches over rope-snapshot bytes and yields byte offsets directly.
|
||||
# Already in the lockfile transitively; promoted to a direct dependency.
|
||||
|
|
|
|||
|
|
@ -791,6 +791,22 @@ function pmacs.lsp.active_attachment()
|
|||
return attachments[tostring(buf)]
|
||||
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
|
||||
-- (Q#C8): when the active buffer already has a server attached,
|
||||
-- flush any debounced didChange first and return the record, so the
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
# Agent handoff — cross-machine continuity
|
||||
|
||||
**Last updated: 2026-07-15, after multi-language injections (#122)
|
||||
merged; also carries the #120 themes-stage-1 snapshot.** This file is the
|
||||
bridge between development machines. If you are an agent reading
|
||||
this on a fresh clone: this document plus the `docs/*-framing.md`
|
||||
files ARE your memory. Read this fully before taking on work, seed
|
||||
your persistent memory from it, and **update this file (and commit
|
||||
it) whenever project state changes materially** — the next machine
|
||||
reads it the way you just did.
|
||||
**Last updated: 2026-07-21, with Themes Arc 4 stage 3 implemented and
|
||||
fully gated on the `statusline-segments` feature branch (awaiting
|
||||
review; not merged).** This file is the bridge between development
|
||||
machines. If you are an agent reading on a fresh clone: this document
|
||||
plus the `docs/*-framing.md` files ARE your memory. Read this fully
|
||||
before taking on work, seed persistent memory from it, and **update this
|
||||
file (and commit it) whenever project state changes materially** — the
|
||||
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
|
||||
themes stage 1 below it), protocol **v16** (`SUPPORTED=[6..16]`;
|
||||
v15→16 shipped the `ThemeFacts` channel — injections added no wire).
|
||||
- Canonical `main` @ `bb17ec9` (#123 merged atop #124), protocol
|
||||
**v17** (`SUPPORTED=[6..17]`). The rebased `statusline-segments`
|
||||
branch implements protocol v18, but v18 is **not on main** until
|
||||
review and merge.
|
||||
- **Syntax-highlight / language-detection side-quest (#114–#118)
|
||||
LANDED** — a one-shot arc built in sibling worktrees off main while
|
||||
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,
|
||||
Q#AP9). Substrate: `buf:path()`, `pmacs.lsp.buffer_language(buf)`,
|
||||
`PMACS_FAKE_LSP_CHANGE_SINK`, `TestDaemon::spawn_with_config`.
|
||||
- **Themes (Arc 4) stage 1 LANDED — #120 merged after 5 review
|
||||
rounds** (`docs/theme-faces-framing.md` rev 9 is the full record):
|
||||
named UI faces as reserved `ui`/`ui.*` theme entries (12-face
|
||||
inventory, owns-surface-within-mask, masks identical on both
|
||||
frontends); `Theme::face()` walk (`None` when unset); transactional
|
||||
mutators with split syntax/face epochs (fixed the pre-existing
|
||||
mid-session `theme.set` span staleness); `ThemeFacts` channel (v16,
|
||||
one authoritative send per attachment; v15 peers excluded incl. the
|
||||
`FileStyleSummary` face-leak side channel). Review rounds hardened
|
||||
substrate beyond faces: the **snapshot/baseline reset contract**
|
||||
(`on_buffer_snapshot_sent` daemon-side + the GPU arm's symmetric
|
||||
search/menu/status clears; minibuffer, gutter mode, `ThemeFacts`
|
||||
survive both sides) and the **store-sourced diag-count freeze**
|
||||
(per-URI severity totals in `DiagnosticStore`, O(1), survive
|
||||
`mark_stale`).
|
||||
- **NEXT: themes stage 2 — `pmacs.gpu.set_font` at protocol v17**
|
||||
(shipped versions are never reused; the `pmacs-gpu-design.md:299`
|
||||
no-wire-change claim is superseded and must be corrected in the
|
||||
stage-2 framing). Glyphon font reload was flagged HARD. Stage 3
|
||||
after: Lua statusline-segment API (segments carry face names).
|
||||
Workflow as always: framing → user approval → branch → gates → PR.
|
||||
- **Themes (Arc 4) stages 1 and 2 LANDED; stage 3 IMPLEMENTED ON ITS
|
||||
FEATURE BRANCH, AWAITING REVIEW.**
|
||||
- Stage 1 (#120, `docs/theme-faces-framing.md` rev 9): named UI faces
|
||||
as reserved `ui`/`ui.*` theme entries; transactional split
|
||||
syntax/face epochs; protocol-v16 `ThemeFacts`; snapshot/baseline
|
||||
symmetry; store-sourced diagnostic-count freeze.
|
||||
- Stage 2 (#124, `docs/gpu-set-font-framing.md` rev 5):
|
||||
`pmacs.gpu.set_font` and authoritative protocol-v17 `FontFacts`;
|
||||
frontend-local family resolution, live font reload/reflow, and
|
||||
visual-run caret geometry.
|
||||
- Stage 3 (`statusline-segments`,
|
||||
`docs/statusline-segments-framing.md` rev 3): composable strict
|
||||
`pmacs.statusline` providers; borrow-released per-window evaluation
|
||||
with failure latches; legacy-preserving TUI composition; a pure
|
||||
built-in LSP provider; dynamic modeline faces; protocol-v18
|
||||
`StatuslineSegments`; authoritative-empty/snapshot symmetry; and
|
||||
atomic GPU validation, face resolution, shaping, clipping, and
|
||||
cache invalidation. Acceptance 1-27 is implemented. Final gates:
|
||||
Clippy clean; 1,619 default + 1,793 CRDT library tests; 7 default +
|
||||
8 CRDT feature acceptance; 114 M4; 109 required GPU; one-invocation
|
||||
workspace sweep 2,718 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:
|
||||
- **Arc 1 (LSP utility surface) COMPLETE** — completion popup
|
||||
(#92/#93), panels/references/outline/hover (#94–#96), plus
|
||||
|
|
|
|||
|
|
@ -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
|
||||
state setup. Surfaces installed by the base Lua host are available
|
||||
there: `pmacs.buffer`, `pmacs.command`, `pmacs.keymap`,
|
||||
`pmacs.hook`, `pmacs.describe`, `pmacs.help`, `pmacs.attach`,
|
||||
`pmacs.now_ms`, and the standard Lua libraries.
|
||||
`pmacs.hook`, `pmacs.statusline`, `pmacs.describe`, `pmacs.help`,
|
||||
`pmacs.attach`, `pmacs.now_ms`, and the standard Lua libraries.
|
||||
|
||||
Editor-state surfaces are available once the editor bridge is
|
||||
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)`
|
||||
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
|
||||
|
||||
The four async fs operations packages need without reaching for
|
||||
|
|
|
|||
|
|
@ -80,14 +80,16 @@ saveplace, autosave + crash recovery, optional backups. Generalize the
|
|||
question: what is a "session" in a daemon world; do CRDT snapshots
|
||||
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,
|
||||
minibuffer, gutter, selection, status band); wire GPU chrome to it —
|
||||
Q#UX1 lesson applies: rendering is frontend-local but control is
|
||||
daemon-owned, so a wire channel (`ThemeFacts`-style) is needed. Add
|
||||
`pmacs.gpu.set_font` (designed, never built) and a Lua
|
||||
statusline-segment API.
|
||||
Stages 1 and 2 landed as #120 and #124: named `ui.*` faces with
|
||||
daemon-resolved `ThemeFacts`, then the live global
|
||||
`pmacs.gpu.set_font` preference at protocol v17. Stage 3 is implemented
|
||||
and fully gated on `statusline-segments`, awaiting review and **not yet
|
||||
merged**: composable `pmacs.statusline` providers, per-window TUI
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ against this design:
|
|||
- **M11.5** — the headless `SemanticClient` glue + reconstruction-
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
locally via the existing `crdt_replica` machinery
|
||||
(`BufferSnapshot` to bootstrap, `CrdtOp` to stay live). The
|
||||
semantic frame is purely the *interpretation layer* over a buffer
|
||||
the frontend already has: styling and decoration keyed by byte
|
||||
range. This mirrors how v1.0 already coupled `multi_frontend`
|
||||
and `crdt_replica`, and it keeps the new wire tiny — single-digit
|
||||
KB for a screenful, diffable at span granularity.
|
||||
locally via the existing `crdt_replica` machinery (`BufferSnapshot` to
|
||||
bootstrap, `CrdtOp` to stay live). Styling and decorations are purely
|
||||
interpretation over bytes the frontend already holds. Protocol v18's
|
||||
one deliberate text-bearing exception is `StatuslineSegments`: bounded
|
||||
one-line chrome text that is not document content. This preserves the
|
||||
semantics-down model while letting daemon-owned Lua state contribute to
|
||||
frontend-local modeline layout.
|
||||
|
||||
Consequently the new surface is small. Cursor reuses the existing
|
||||
`InstanceMessage::CursorByte` (authoritative cursor as a buffer
|
||||
offset — added for CRDT optimistic-apply, exactly what a
|
||||
layout-local frontend consumes). Peer cursors reuse the existing
|
||||
`PresenceUpdate`. Edits and local cursor travel the existing
|
||||
`FrontendEvent::CrdtOp` / presence path. The genuinely new wire
|
||||
is: one capability bit, ~five instance→frontend interpretation
|
||||
variants, and one frontend→instance `Viewport` variant.
|
||||
`FrontendEvent::CrdtOp` / presence path. Later interpretation and
|
||||
chrome families append under explicit protocol-version gates; v18 adds
|
||||
only `StatuslineSegments` to the v17 shape.
|
||||
|
||||
**`BufferSnapshot` resets buffer-scoped interpretation state.** A
|
||||
frontend receiving a snapshot drops everything it holds for the
|
||||
named buffer — spans, decorations, adornments, minimap summary,
|
||||
completion popup, search and menu prompts (which also gate the
|
||||
frontend's key/pointer interception), and status facts — and
|
||||
rebuilds from the frames that follow; the instance mirrors this by
|
||||
invalidating its per-buffer emission baselines whenever it writes a
|
||||
snapshot, so the frontend's post-snapshot viewport declaration
|
||||
receives authoritative re-sends even when nothing changed
|
||||
daemon-side (the unchanged-generation A → B → A revisit). Bufferless
|
||||
facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and
|
||||
frontend's key/pointer interception), status facts, and statusline
|
||||
segments — and rebuilds from the frames that follow; the instance
|
||||
mirrors this by invalidating its per-buffer emission baselines whenever
|
||||
it writes a snapshot. The frontend's post-snapshot viewport declaration
|
||||
therefore receives authoritative re-sends even when nothing changed
|
||||
daemon-side (the unchanged-generation A → B → A revisit).
|
||||
Bufferless facts (`ThemeFacts`, `FontFacts`, the minibuffer prompt) and
|
||||
per-frontend state (the gutter mode) survive snapshots on both sides
|
||||
(frontend-locally the normalized code scroll — a caret-follow view
|
||||
residual — is buffer-scoped and resets, while the resolved font and
|
||||
|
|
@ -238,13 +244,19 @@ ResourceOffer {
|
|||
/// declaration; cached-compare suppressed thereafter, so an
|
||||
/// unthemed session pays one small message and nothing more.
|
||||
/// Resolution (the `ui.*` dotted-prefix inheritance walk) happens
|
||||
/// daemon-side over the stage-1 face inventory — frontends do
|
||||
/// exact-name lookup only, and apply each face within its
|
||||
/// stage-1 component mask (docs/theme-faces-framing.md Q#TH3/Q#TH5:
|
||||
/// a set face owns its surface; `Default` components mean the
|
||||
/// frontend's plain rendering; out-of-mask components are never
|
||||
/// read). Daemon-gated `>= 16`; appended as the FINAL variant —
|
||||
/// postcard discriminants are ordinal.
|
||||
/// daemon-side; frontends do exact-name lookup only and apply each face
|
||||
/// within its stage-1 component mask
|
||||
/// (`docs/theme-faces-framing.md` Q#TH3/Q#TH5: a set face owns its
|
||||
/// surface; `Default` components mean the frontend's plain rendering;
|
||||
/// out-of-mask components are never read).
|
||||
///
|
||||
/// 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 {
|
||||
faces: Vec<ThemeFace>, // { name: String, style: Style }, sorted by name
|
||||
},
|
||||
|
|
@ -263,19 +275,59 @@ ThemeFacts {
|
|||
/// owns every metric consequence; sizes travel as integer
|
||||
/// hundredths of a logical pixel (1600 = 16.0, validated to
|
||||
/// 600..=7200 on BOTH sides — the receiver fails closed on
|
||||
/// out-of-range wire values). Daemon-gated `>= 17`; appended as
|
||||
/// the FINAL variant — postcard discriminants are ordinal, and the
|
||||
/// ThemeFacts byte pin above guards this placement.
|
||||
/// out-of-range wire values). Daemon-gated `>= 17`; v18's
|
||||
/// `StatuslineSegments` is appended after it because postcard
|
||||
/// discriminants are ordinal.
|
||||
FontFacts {
|
||||
family: Option<String>, // None = the frontend's default family
|
||||
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
|
||||
`CellDelta` does today — the instance ships changed spans, not
|
||||
full re-sends, scoped to the viewport range the frontend last
|
||||
declared.
|
||||
`StyleSpans` retains its dirty-segment diffing. `StatuslineSegments`
|
||||
uses a complete-payload baseline instead: first sight of a buffer sends
|
||||
one authoritative replacement, including `left=[]`, `right=[]`; a
|
||||
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`
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -50,9 +50,11 @@ pub use message::{
|
|||
CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment,
|
||||
FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InlineAdornment,
|
||||
InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key, KeyEvent,
|
||||
LineNumberMode, MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind,
|
||||
NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind, ResourceBody,
|
||||
SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, StyleSegment, StyleSpan, ThemeFace,
|
||||
is_builtin_pair_char, is_supported_protocol_version, negotiate_capabilities,
|
||||
LineNumberMode, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
|
||||
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
|
||||
MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities,
|
||||
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};
|
||||
|
|
|
|||
|
|
@ -104,6 +104,32 @@ pub const BUILTIN_PAIR_CHARS: [char; 9] = ['(', ')', '[', ']', '{', '}', '"', '\
|
|||
pub fn is_builtin_pair_char(c: char) -> bool {
|
||||
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.
|
||||
///
|
||||
|
|
@ -1026,6 +1052,21 @@ pub enum InstanceMessage {
|
|||
/// closed (deserialized protocol input is untrusted).
|
||||
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
|
||||
|
|
@ -1041,6 +1082,19 @@ pub struct ThemeFace {
|
|||
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
|
||||
/// wire, the daemon, and both frontends so the *number rule* — what value
|
||||
/// 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
|
||||
/// font. Appended after `ThemeFacts` — the final v16 variant —
|
||||
/// 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
|
||||
/// 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`
|
||||
/// 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
|
||||
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
||||
|
|
|
|||
|
|
@ -791,6 +791,14 @@ fn per_attach_thread(
|
|||
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.
|
||||
///
|
||||
/// All attached frontends' inputs arrive via the `dispatcher_rx`
|
||||
|
|
@ -1144,6 +1152,12 @@ fn dispatcher_loop(
|
|||
let peer_knows_font_facts = session_registry
|
||||
.session_state(*fid)
|
||||
.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 {
|
||||
if !peer_knows_status_facts
|
||||
&& matches!(msg, InstanceMessage::StatusFacts { .. })
|
||||
|
|
@ -1186,6 +1200,9 @@ fn dispatcher_loop(
|
|||
if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) {
|
||||
continue;
|
||||
}
|
||||
if !peer_accepts_statusline_message(negotiated_protocol_version, msg) {
|
||||
continue;
|
||||
}
|
||||
// T M10.10 Day 4 / M10.11 F2 — the criterion-1
|
||||
// jitter site: render-write latency.
|
||||
//
|
||||
|
|
@ -1267,6 +1284,10 @@ fn dispatcher_loop(
|
|||
last_dispatch_idle_sent.remove(fid);
|
||||
last_active_buffer_sent.remove(fid);
|
||||
session_registry.unregister_session(*fid);
|
||||
editor
|
||||
.statusline_registry
|
||||
.borrow_mut()
|
||||
.detach_frontend(*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_active_buffer_sent.remove(&frontend_id);
|
||||
session_registry.unregister_session(frontend_id);
|
||||
editor
|
||||
.statusline_registry
|
||||
.borrow_mut()
|
||||
.detach_frontend(frontend_id);
|
||||
{
|
||||
let mut core = editor.core.borrow_mut();
|
||||
core.unregister_frontend_view(frontend_id);
|
||||
|
|
@ -2555,6 +2580,24 @@ mod tests {
|
|||
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]
|
||||
fn build_identity_includes_version_and_uptime() {
|
||||
let s = DaemonState::new(Some("research".into()));
|
||||
|
|
|
|||
646
src/editor.rs
646
src/editor.rs
|
|
@ -13,12 +13,15 @@
|
|||
//! until the user quits.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::async_runtime::SharedAsyncRuntime;
|
||||
use crate::cell::CellCoord;
|
||||
|
|
@ -101,6 +104,8 @@ pub struct EditorState {
|
|||
/// Snippet store (T M4.11). Co-owned with the snippet
|
||||
/// provider closure inside [`Self::completion_registry`].
|
||||
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
|
||||
/// clicks from crossterm's plain Down/Up mouse event stream.
|
||||
mouse_click: Option<MouseClickState>,
|
||||
|
|
@ -167,6 +172,8 @@ impl EditorState {
|
|||
lua_host
|
||||
.attach_editor(&core)
|
||||
.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
|
||||
// deliberately NOT configured here — see `install_state_dirs`,
|
||||
// called by the real entry points (`run` / `run_daemon`) only.
|
||||
|
|
@ -481,6 +488,7 @@ impl EditorState {
|
|||
project_indexer,
|
||||
completion_registry,
|
||||
snippets,
|
||||
statusline_registry,
|
||||
mouse_click: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -2108,6 +2116,25 @@ pub fn paint_frame(
|
|||
return None;
|
||||
}
|
||||
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 —
|
||||
// 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");
|
||||
diag_mode_line_summary(&guard, buf)
|
||||
};
|
||||
let custom = statusline_by_window.get(id);
|
||||
paint_mode_line(
|
||||
grid,
|
||||
&rect,
|
||||
|
|
@ -2237,6 +2265,9 @@ pub fn paint_frame(
|
|||
&scroll,
|
||||
&diags,
|
||||
mode_line_style(&theme),
|
||||
custom.map_or(&[], |segments| segments.left.as_slice()),
|
||||
custom.map_or(&[], |segments| segments.right.as_slice()),
|
||||
&theme,
|
||||
);
|
||||
}
|
||||
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(
|
||||
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(
|
||||
grid: &mut crate::cell::CellGrid<'_>,
|
||||
|
|
@ -2563,10 +2716,10 @@ fn paint_mode_line(
|
|||
cursor_col: u32,
|
||||
scroll: &str,
|
||||
diags: &str,
|
||||
// The resolved row style ([`mode_line_style`]) — this fn is a
|
||||
// pure formatter, so the `ui.modeline` face resolution stays with
|
||||
// the caller (themes arc Q#TH9).
|
||||
mode_style: crate::cell::Style,
|
||||
custom_left: &[crate::statusline::EvaluatedStatuslineSegment],
|
||||
custom_right: &[crate::statusline::EvaluatedStatuslineSegment],
|
||||
theme: &crate::highlight::Theme,
|
||||
) {
|
||||
if rect.size.rows == 0 || rect.size.cols == 0 {
|
||||
return;
|
||||
|
|
@ -2574,46 +2727,78 @@ fn paint_mode_line(
|
|||
let row = rect.origin.row + rect.size.rows - 1;
|
||||
let marker = if modified { '*' } else { ' ' };
|
||||
let active_marker = if is_active { '+' } else { '-' };
|
||||
let left = format!(" {active_marker}{marker} {name} ");
|
||||
let right = if diags.is_empty() {
|
||||
let protected_left = format!(" {active_marker}{marker} {name} ");
|
||||
let protected_right = if diags.is_empty() {
|
||||
format!(" L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
|
||||
} else {
|
||||
format!(" {diags} L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
|
||||
};
|
||||
|
||||
// Fill the row with the mode-line style.
|
||||
for c in 0..rect.size.cols {
|
||||
let cell = grid.at(CellCoord::new(row, rect.origin.col + c));
|
||||
// Fill exactly this window's row once with the base modeline surface.
|
||||
for col in 0..rect.size.cols {
|
||||
let cell = grid.at(CellCoord::new(row, rect.origin.col + col));
|
||||
cell.glyph = crate::cell::Glyph::Char(' ');
|
||||
cell.style = mode_style;
|
||||
}
|
||||
|
||||
// Right-align the cursor / scroll readout. If the window is too
|
||||
// narrow to fit both halves, drop the right side rather than
|
||||
// overlap the buffer name.
|
||||
let right_chars: Vec<char> = right.chars().collect();
|
||||
let right_len = right_chars.len() as u32;
|
||||
let right_start_col = if right_len < rect.size.cols {
|
||||
Some(rect.size.cols - right_len)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(start_col) = right_start_col {
|
||||
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 mut left_runs = Vec::with_capacity(custom_left.len().saturating_mul(2) + 2);
|
||||
left_runs.push(ModeLineRun {
|
||||
text: &protected_left,
|
||||
style: mode_style,
|
||||
});
|
||||
if !custom_left.is_empty() {
|
||||
left_runs.push(ModeLineRun {
|
||||
text: " ",
|
||||
style: mode_style,
|
||||
});
|
||||
left_runs.extend(custom_mode_line_runs(custom_left, theme, mode_style));
|
||||
}
|
||||
let left_graphemes = prepare_mode_line_runs(&left_runs);
|
||||
|
||||
// Paint the left side, stopping before the right side begins.
|
||||
let stop_col = right_start_col.unwrap_or(rect.size.cols);
|
||||
for (i, ch) in left.chars().enumerate() {
|
||||
let i = i as u32;
|
||||
if i >= stop_col {
|
||||
break;
|
||||
let protected_right_graphemes = prepare_mode_line_runs(&[ModeLineRun {
|
||||
text: &protected_right,
|
||||
style: mode_style,
|
||||
}]);
|
||||
let protected_right_width = mode_line_grapheme_width(&protected_right_graphemes);
|
||||
|
||||
// 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;
|
||||
grid.at(CellCoord::new(row, col)).glyph = crate::cell::Glyph::Char(ch);
|
||||
let right_prefix_graphemes = prepare_mode_line_runs(&right_prefix_runs);
|
||||
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://`
|
||||
/// URI, so diag-store entries can be keyed to it.
|
||||
fn set_active_buffer_path(s: &EditorState, path: &str) -> String {
|
||||
|
|
|
|||
|
|
@ -422,6 +422,10 @@ impl Frontend {
|
|||
// preference; terminal fonts belong to the terminal, so
|
||||
// the cell-grid TUI drops this silently too.
|
||||
| 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 { .. }
|
||||
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
|
||||
// optimistic-apply gate; if any reaches this render path
|
||||
|
|
@ -817,6 +821,28 @@ mod tests {
|
|||
.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]
|
||||
fn emit_span_writes_cursor_move_then_chars() {
|
||||
let span = DiffSpan {
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ pub struct Theme {
|
|||
/// and the `ThemeFacts` producer's key filter.
|
||||
#[must_use]
|
||||
pub fn is_face_name(name: &str) -> bool {
|
||||
name == "ui" || name.starts_with("ui.")
|
||||
pmacs_protocol::is_ui_face_name(name)
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn insert(&mut self, capture_name: impl Into<String>, style: Style) {
|
||||
self.by_capture.insert(capture_name.into(), style);
|
||||
|
|
@ -842,6 +873,47 @@ mod tests {
|
|||
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]
|
||||
fn line_offsets_basic() {
|
||||
let src = b"a\nbb\nccc";
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ pub mod semantic_tokens;
|
|||
pub mod signature;
|
||||
pub mod socket_path;
|
||||
pub mod state;
|
||||
pub mod statusline;
|
||||
pub mod symbol;
|
||||
pub mod syntax;
|
||||
pub mod text_view;
|
||||
|
|
|
|||
|
|
@ -1233,6 +1233,10 @@ impl LspManager {
|
|||
let id = LspServerId::next();
|
||||
let mut client = LspClient::new(spec);
|
||||
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);
|
||||
Ok(id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ use crate::packages::{
|
|||
};
|
||||
use crate::protocol::{AttachTarget, AttachmentHandle, InstanceIdentity};
|
||||
use crate::rope::Range;
|
||||
use crate::statusline::{
|
||||
SharedStatuslineRegistry, StatuslineProviderFailure, StatuslineProviderId, StatuslineRegistry,
|
||||
StatuslineSide,
|
||||
};
|
||||
use crate::syntax::{self, ParseTreeBundle, ParseView, ParseViewHandle, SharedSyntaxRegistry};
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2047,6 +2275,8 @@ pub fn install(
|
|||
lua.set_app_data(PackageUnloadHooks::new());
|
||||
lua.set_app_data(CurrentlyLoadingPackage::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()?;
|
||||
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("menu", install_menu_module(lua, menus)?)?;
|
||||
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
|
||||
// chunks for timeout loops; `os.clock()` only counts CPU time and
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
/// 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_error_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*`.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
|||
// --- M5.5a handshake & postcard round-trips ---
|
||||
|
||||
#[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 /
|
||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||
|
|
@ -1712,7 +1712,10 @@ mod tests {
|
|||
// Themes stage 2 Q#F4 bumped 16→17 (`InstanceMessage::
|
||||
// FontFacts`, additive + daemon-gated, appended as the final
|
||||
// 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]
|
||||
|
|
@ -1786,18 +1789,18 @@ mod tests {
|
|||
// (`TripleDown`), v8 (`StatusFacts`), v9 + v10 (`SearchPrompt` +
|
||||
// regex/invalid), v11 (the context menu), v12 (the GUI
|
||||
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
|
||||
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`)
|
||||
// all interoperate, so v6 through v17 talk.
|
||||
for accepted in 6..=17 {
|
||||
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
|
||||
// and v18 (`StatuslineSegments`) all interoperate.
|
||||
for accepted in 6..=18 {
|
||||
assert!(
|
||||
is_supported_protocol_version(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!(
|
||||
!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]
|
||||
fn theme_facts_encoding_is_unchanged_by_the_v17_build() {
|
||||
// Q#F4 placement pin: `FontFacts` must be APPENDED after
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ use crate::cell::Style;
|
|||
use crate::editor::EditorState;
|
||||
use crate::protocol::{
|
||||
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.
|
||||
|
|
@ -227,6 +232,11 @@ pub struct SemanticRenderState {
|
|||
/// viewport declaration. A frontend retaining face state across
|
||||
/// attachments is therefore corrected even by an unthemed daemon.
|
||||
last_theme_faces: Option<Vec<crate::protocol::ThemeFace>>,
|
||||
/// 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
|
||||
/// finding 3). Faces reach a semantic frontend through TWO
|
||||
/// 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
|
||||
/// filter.
|
||||
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
|
||||
/// by buffer revision. Building it costs an O(buffer) rope copy
|
||||
/// 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);
|
||||
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
|
||||
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
|
||||
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
|
||||
s
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// [`Self::for_peer`].
|
||||
#[must_use]
|
||||
|
|
@ -384,6 +403,7 @@ impl SemanticRenderState {
|
|||
// (empty included), and the epoch gate cannot short-circuit
|
||||
// an epoch-0 daemon before that send.
|
||||
last_face_epoch: None,
|
||||
last_statusline_face_set_epoch: None,
|
||||
last_theme_faces: None,
|
||||
peer_knows_theme_facts: true,
|
||||
// Q#F5: both seeded None — the first frame after viewport
|
||||
|
|
@ -393,6 +413,8 @@ impl SemanticRenderState {
|
|||
last_font_epoch: None,
|
||||
last_font_facts: None,
|
||||
peer_knows_font_facts: true,
|
||||
peer_knows_statusline_segments: true,
|
||||
last_statusline: HashMap::new(),
|
||||
diag_line_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -413,10 +435,11 @@ impl SemanticRenderState {
|
|||
///
|
||||
/// A `BufferSnapshot` resets the receiving frontend's
|
||||
/// buffer-scoped render state wholesale — spans, decorations,
|
||||
/// adornments, minimap summary, completion popup (see the GPU's
|
||||
/// `BufferSnapshot` arm) — so every buffer-scoped emission
|
||||
/// baseline this producer holds for that buffer must die with the
|
||||
/// send. Otherwise an unchanged-key revisit (the A → B → A round
|
||||
/// adornments, minimap summary, completion popup, status facts, and
|
||||
/// statusline segments (see the GPU's `BufferSnapshot` arm) — so
|
||||
/// every buffer-scoped emission baseline this producer holds for
|
||||
/// that buffer must die with the send.
|
||||
/// Otherwise an unchanged-key revisit (the A → B → A round
|
||||
/// trip at one CRDT generation) suppresses every re-send and the
|
||||
/// frontend never regains the state until an edit, diagnostic
|
||||
/// republish, or theme mutation happens to move the key.
|
||||
|
|
@ -426,7 +449,8 @@ impl SemanticRenderState {
|
|||
/// harmless — the failure mode is one redundant re-send, never
|
||||
/// 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
|
||||
/// table across snapshots), `last_minibuffer` (one global core
|
||||
/// instance, not buffer-scoped), `last_line_numbers`
|
||||
|
|
@ -449,6 +473,7 @@ impl SemanticRenderState {
|
|||
self.last_search_prompt.remove(&buffer_id);
|
||||
self.last_menu_prompt.remove(&buffer_id);
|
||||
self.last_completion_popup.remove(&buffer_id);
|
||||
self.last_statusline.remove(&buffer_id);
|
||||
}
|
||||
|
||||
/// Project one frame.
|
||||
|
|
@ -476,6 +501,23 @@ impl SemanticRenderState {
|
|||
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 mut out = Vec::new();
|
||||
|
||||
|
|
@ -645,9 +687,85 @@ impl SemanticRenderState {
|
|||
// --- ThemeFacts (UI faces; themes arc Q#TH7, protocol v16) ---
|
||||
out.extend(self.theme_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
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// popup state for `buffer_id` is unchanged (Arc 1a Q#C5). Only the
|
||||
/// 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
|
||||
/// face table is unchanged (themes arc Q#TH7, protocol v16).
|
||||
/// Resolves the [`UI_FACES`] inventory through
|
||||
/// [`crate::highlight::Theme::face`] under one lock — resolution
|
||||
/// is daemon-side; frontends do exact-name lookup, no walk. The
|
||||
/// `last_face_epoch` gate keeps unchanged ticks to one u64
|
||||
/// compare; `last_theme_faces` (the frontend's believed table)
|
||||
/// decides emission. Both advance on computation, and both seed
|
||||
/// `None`, so every attachment ships exactly one authoritative
|
||||
/// table — the empty table included — on its first frame.
|
||||
/// Build the authoritative `ThemeFacts` table. v16/v17 peers retain the
|
||||
/// fixed stage-1 inventory and never inspect the statusline registry.
|
||||
/// v18 peers union in enabled provider faces and key recomputation on
|
||||
/// `(theme.face_epoch, registry.face_set_epoch)`.
|
||||
fn theme_facts_msg(&mut self, state: &EditorState) -> Option<InstanceMessage> {
|
||||
// PR #120 round 1 finding 3: never even produced for a peer
|
||||
// below v16 (the daemon write-loop gate remains as the
|
||||
// belt-and-braces filter).
|
||||
if !self.peer_knows_theme_facts {
|
||||
return None;
|
||||
}
|
||||
|
||||
let theme = state.syntax_registry.theme();
|
||||
let (faces, face_epoch) = {
|
||||
let th = theme.lock().expect("theme mutex poisoned");
|
||||
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) {
|
||||
return None;
|
||||
}
|
||||
let faces: Vec<crate::protocol::ThemeFace> = UI_FACES
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
th.face(name).map(|style| crate::protocol::ThemeFace {
|
||||
name: (*name).to_owned(),
|
||||
style,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(faces, th.face_epoch)
|
||||
(None, Vec::new())
|
||||
};
|
||||
|
||||
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_statusline_face_set_epoch = face_set_epoch;
|
||||
let unchanged = self.last_theme_faces.as_ref() == Some(&faces);
|
||||
self.last_theme_faces = Some(faces.clone());
|
||||
if unchanged {
|
||||
|
|
@ -2251,6 +2381,14 @@ mod tests {
|
|||
state.core.borrow().active_window().buffer_id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_ui_face_inventory_is_strictly_sorted() {
|
||||
assert!(
|
||||
UI_FACES.windows(2).all(|pair| pair[0] < pair[1]),
|
||||
"theme_facts_msg uses binary_search; duplicates or unsorted insertions misclassify faces"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_numbers_emitted_on_toggle_then_suppressed() {
|
||||
// UX gutter (protocol v13): the daemon ships the per-window gutter
|
||||
|
|
@ -2303,6 +2441,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`
|
||||
/// does after its transactional parse (insert + face-epoch bump).
|
||||
fn merge_face(state: &EditorState, name: &str, style: Style) {
|
||||
|
|
@ -2312,6 +2463,222 @@ mod tests {
|
|||
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]
|
||||
fn theme_facts_authoritative_empty_then_silent_then_face_change_emits() {
|
||||
// Q#TH7: the first frame after viewport declaration ships the
|
||||
|
|
@ -2647,9 +3014,10 @@ mod tests {
|
|||
/// All `InstanceMessage` variants the semantic projection may
|
||||
/// emit are `StyleSpans`, `Decorations`, `InlineAdornments`,
|
||||
/// `FileStyleSummary`, `StatusFacts` (Q#S1), `SearchPrompt`
|
||||
/// (Q#SR5), `LineNumbers`, or `ThemeFacts` (Q#TH7) — never
|
||||
/// `CellDelta`, grid `Cursor`, or the still-unwired
|
||||
/// `BlockAdornments` / `FoldState` families.
|
||||
/// (Q#SR5), `LineNumbers`, `ThemeFacts` (Q#TH7), `FontFacts`
|
||||
/// (Q#F5), or `StatuslineSegments` (Q#SL7) — never `CellDelta`,
|
||||
/// grid `Cursor`, or the still-unwired `BlockAdornments` /
|
||||
/// `FoldState` families.
|
||||
fn assert_semantic_only(msgs: &[InstanceMessage]) {
|
||||
for m in msgs {
|
||||
assert!(
|
||||
|
|
@ -2664,6 +3032,7 @@ mod tests {
|
|||
| InstanceMessage::LineNumbers { .. }
|
||||
| InstanceMessage::ThemeFacts { .. }
|
||||
| InstanceMessage::FontFacts { .. }
|
||||
| InstanceMessage::StatuslineSegments { .. }
|
||||
),
|
||||
"semantic projection emitted an unexpected variant: {m:?}"
|
||||
);
|
||||
|
|
@ -2835,13 +3204,14 @@ mod tests {
|
|||
// (post-M11 minimap producer, generation-keyed), as does
|
||||
// StatusFacts (Q#S1, cached-compare), the authoritative
|
||||
// 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);
|
||||
assert_eq!(
|
||||
first.len(),
|
||||
6,
|
||||
7,
|
||||
"first frame ships StyleSpans + Decorations + FileStyleSummary \
|
||||
+ StatusFacts + ThemeFacts + FontFacts"
|
||||
+ StatusFacts + ThemeFacts + FontFacts + StatuslineSegments"
|
||||
);
|
||||
assert_semantic_only(&first);
|
||||
let (style_full, _) = style_segments(&first).expect("StyleSpans present");
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue