diff --git a/Cargo.lock b/Cargo.lock index dd5b071..9ed454f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2568,6 +2568,7 @@ dependencies = [ "tree-sitter-typescript", "tree-sitter-yaml", "tree-sitter-zig", + "unicode-segmentation", "unicode-width", ] diff --git a/Cargo.toml b/Cargo.toml index c3e9ea9..166751e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index e924a3a..251f4b7 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -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 diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 9a090db..8252577 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -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 diff --git a/docs/package-author-guide.md b/docs/package-author-guide.md index e43de11..17fc538 100644 --- a/docs/package-author-guide.md +++ b/docs/package-author-guide.md @@ -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 diff --git a/docs/roadmap-2026-07.md b/docs/roadmap-2026-07.md index d48426e..412de58 100644 --- a/docs/roadmap-2026-07.md +++ b/docs/roadmap-2026-07.md @@ -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 diff --git a/docs/semantic-frontend-protocol.md b/docs/semantic-frontend-protocol.md index 66c2baf..f2fd6eb 100644 --- a/docs/semantic-frontend-protocol.md +++ b/docs/semantic-frontend-protocol.md @@ -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, // { 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, // None = the frontend's default family size_centi_px: Option, // 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, + right: Vec, +}, ``` -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` diff --git a/docs/statusline-segments-framing.md b/docs/statusline-segments-framing.md new file mode 100644 index 0000000..612f89a --- /dev/null +++ b/docs/statusline-segments-framing.md @@ -0,0 +1,1009 @@ +# Statusline segments - framing (Arc 4 stage 3) + +**Revision 3 - 2026-07-21. Implemented on branch +`statusline-segments` against current `main` `bb17ec9` (#123 atop #124, +protocol v17). It advances the wire to v18, satisfies Acceptance 1-27, +and is fully gated; awaiting review, not merged.** + +Revision 3: closes review findings on authoritative-empty baseline retention +and the TUI's protected-suffix clipping boundary. + +The implementation review corrected the record for the GPU's +built-in-only narrow-band case: stage 3 deliberately changes the legacy +clipping edge and now pins that behavior with a headless regression test. + +Revision 2: closes review findings on invalidation, terminal-control-safe +grapheme painting, separator ownership, detached-frontend latches, and the +unknown-LSP label. Revision 1 was the initial post-#124 architecture scout. + +Arc 4 names three deliverables: named UI faces, a live GPU font +preference, and a Lua statusline-segment API +(`docs/roadmap-2026-07.md:83-90`). Stages 1 and 2 landed as #120 and +#124. This framing covers **stage 3 only**. It adds composable Lua +providers to the per-window modeline, carries their text plus face +names to semantic frontends at protocol v18, and uses the existing LSP +status tracker as the first built-in provider. Completing this stage +completes Arc 4. + +## Implementation record (2026-07-21) + +The approved Q#SL1-Q#SL11 design is implemented without changing the +framed ownership boundary: + +- `pmacs.statusline` owns a shared editor-global registry with strict + registration, lifecycle/introspection, monotonic layout/face-set + epochs, borrow-released three-phase evaluation, per-context failure + latches, deterministic ordering, and bounded one-line results. +- TUI composition preserves the legacy modeline when providers are + absent, owns separators by adjacent segment face, shapes terminal-safe + grapheme runs, and protects the right diagnostic/cursor/scroll suffix. +- Protocol v18 appends complete `StatuslineSegments` replacements. The + semantic producer distinguishes authoritative empty from no message, + versions provider execution before callbacks, expands dynamic + `ThemeFacts`, and resets buffer baselines symmetrically with + `BufferSnapshot`. +- The GPU consumes v18 atomically, resolves exact dynamic faces, clips + provider runs without wrapping or displacing the protected suffix, + deliberately right-pins over-wide built-in-only readouts, and + preserves its prior valid state on malformed input. +- `builtin/runtime/lsp.lua` registers the first pure right-side provider + from its private attachment map; the Rust tracker exposes bounded + `init`/`ready`/`degraded`/`crashed`/`stopped`/unknown labels. + +The final gate run was sequential and clean: `cargo fmt --check`; +workspace/all-target Clippy with `-D warnings`; 1,619 default and 1,793 +CRDT library tests; 7 default and 8 CRDT stage-3 acceptance tests; 114 +M4 acceptance tests (3 ignored, `basedpyright` filtered); 109 required +GPU tests; and the one-invocation workspace sweep (2,718 passed across +78 suites, 19 ignored, `basedpyright` filtered). `git diff --check` was +clean. No flaky rerun was needed. + +## Ground truth (as of `main` at `bb17ec9`, protocol v17) + +### There are two different bottom surfaces in the TUI + +- `EditorCore.status: String` is one global, one-line transient message + (`src/editor_core.rs:234-235`). Lua writes it through + `pmacs.editor.set_status` (`src/lua_bindings/mod.rs:11414-11422`). + `dispatch_key` clears it at entry (`src/editor.rs:677-685`), and the + optimistic CRDT self-insert path clears it too + (`src/daemon.rs:2159-2165`). +- Every TUI window reserves its own final row for a **modeline**. + `paint_frame` renders all windows in the active frontend's layout, + then calls `paint_mode_line` with buffer name, modified state, + active-window marker, diagnostics, cursor L:C, and scroll state + (`src/editor.rs:2102-2240`). The current formatter has a left string + (`+/-`, modified marker, name) and a right string (diagnostics, L:C, + scroll); the right side is right-aligned and dropped wholesale if it + is wider than the window (`:2556-2617`). +- The terminal's last physical row is a separate **global echo row**. + `build_status_line` contains only `core.status`, the last captured Lua + error, and an in-flight key prefix (`src/editor.rs:2791-2827`). + Isearch or the minibuffer paints over that row afterward + (`:2244-2265`). Per-window buffer facts deliberately do not live + there. +- `ui.modeline` owns the per-window row within its stage-1 + `{fg,bg,reverse}` mask. `ui.statusline` owns the global echo row's + foreground only. Search/minibuffer text uses `ui.minibuffer` + (`docs/theme-faces-framing.md` Q#TH3/Q#TH5). The two face names are + not synonyms. +- Modeline width currently counts `char`s, not terminal display + columns (`editor.rs:2594-2616`). A custom CJK or combining segment + would therefore overlap its neighbor unless this stage moves the + whole modeline through Unicode display-width discipline. +- The cell protocol already has `Glyph::Cluster` for a UTF-8 grapheme + plus `Glyph::Continuation` for its trailing columns, and the terminal + emitter writes clusters verbatim (`pmacs-protocol/src/cell.rs:65-77`; + `src/frontend.rs:580-591`). `TextView` still skips combining marks, + but that older limitation need not be copied into this new painter. + `unicode-segmentation` is currently only transitive through + cosmic-text; using it in the core requires one direct manifest entry. + +### The GPU compresses those surfaces into one physical band + +- `StatusFacts` (protocol v8, widened at v15) carries daemon-owned + buffer name, modified flag, error/warning counts, and the transient + `core.status` message. Cursor and scroll deliberately stay + frontend-derived so they follow the optimistic caret + (`pmacs-protocol/src/message.rs:764-792`; + `docs/pmacs-gpu-status-band-framing.md` Q#S1). +- `SemanticRenderState::last_status` is a per-buffer peer-emission + baseline. `status_facts_msg` frame-polls cheap Rust state and emits + only on payload change (`src/semantic_render.rs:176-180`, + `:909-976`). `on_buffer_snapshot_sent` removes that baseline because + the frontend snapshot clears its buffer-scoped status mirror + (`:412-450`). +- GPU composition has one left glyphon buffer and one right glyphon + buffer. The left side's priority is minibuffer, isearch, transient + message, then buffer name/modified (`pmacs-gpu/src/main.rs:4033-4087`). + The right side is diagnostics followed by optimistic L:C and scroll + (`:3971-4030`). Both use string-equality shaping caches + (`:4089-4137`); `ThemeFacts` clears those caches because colors can + change while strings do not (`:3035-3047`). +- The right buffer is measured and positioned flush right; the left + buffer's clip ends before it (`main.rs:5318-5408`). Search, + minibuffer, and transient messages replace only the left content. + Diagnostics/cursor/scroll remain visible on the right. +- Unlike the three popup buffers, neither status glyphon buffer is + currently set to `Wrap::None` (`main.rs:2171-2201`). Long custom text + would otherwise wrap before its measured origin can enforce the + single-band clipping policy. In pinned glyphon 0.11, + `TextArea.left` is an independent `f32` origin and `TextBounds` + performs clipping, so a negative origin is supported without + reshaping away the protected right suffix. +- `BufferSnapshot` clears spans, decorations, adornments, summary, + completion, search, menu, and `status_facts`; it deliberately keeps + global minibuffer, theme, and font state (`main.rs:2736-2818`). + A new buffer's first closed prompt state may be suppressed, so every + new buffer-scoped status mirror must join this symmetric reset + contract rather than wait for a later close message. + +### The old `ModeLine` wire variant is not this feature's carrier + +- `InstanceMessage::ModeLine(Vec)` has existed since the first + protocol and remains unused (`pmacs-protocol/src/message.rs:506-510`; + the only consumers are silent-drop/debug-name arms). It contains + daemon-painted grid cells, not structured text and face names. +- The status-band framing already rejected it: preformatted cells bake + TUI layout into a frontend that owns font shaping and would make a + daemon-formatted cursor visibly lag optimistic typing + (`docs/pmacs-gpu-status-band-framing.md` Q#S1). +- Changing that existing variant's shape would be a wire break under an + already-shipped discriminant. Reusing it unchanged would contradict + both the frontend-local-rendering boundary and this arc's requirement + that segments carry face names rather than raw colors. + +### Lua has provider and error-isolation precedents, but no statusline registry + +- `pmacs.completion.register { name, priority?, fn }` returns a stable + userdata handle and supports unregister, priority, enable, and + introspection (`src/lua_bindings/mod.rs:10624-10712`). The completion + registry establishes the repository pattern for composable + Lua-defined providers. +- Hooks snapshot callbacks before invocation so a callback can re-enter + its registry without a `RefCell` double borrow (`src/hook.rs:250-259`). + Hook callback errors are isolated and appended to `*errors*` + (`src/lua.rs:278-306`). +- `paint_frame` takes a mutable `EditorCore` borrow before walking + windows and holds it through both bottom surfaces + (`src/editor.rs:2120-2250`). Calling arbitrary Lua inside + `paint_mode_line` would let an ordinary provider call + `pmacs.window.*` or `pmacs.buffer.*` and immediately double-borrow + the core. Provider evaluation must therefore happen before that + paint borrow, against owned context snapshots. +- The daemon stamps `core.active_frontend` before every frontend's + projection (`src/daemon.rs:958-960`) and at session establishment + (`:1426-1428`). `pmacs.frontend.id()` consequently has the correct + per-session value during a pre-render provider fan-out. +- `EditorCore` already owns distinct layouts/windows per + `FrontendId`; `active_window_for(fid)` has no cross-frontend fallback + (`src/editor_core.rs:512-526`). A grid frontend may have several + visible windows, while the current semantic GPU has one active + buffer/view. Provider output must be evaluated and cached per + frontend/window context, never as one global string. + +### A real first consumer is already waiting + +- `LspStatusTracker` exists specifically as the stable higher-level + state a modeline can read (`src/lsp_status.rs:30-85`). Its tracker + labels are the bounded set `init`, `ready`, `idx`, `degraded`, + `crashed`, and `stopped`; `pmacs.lsp.modeline_label` additionally + returns `"?"` for a forgotten/unknown server id (`src/lsp.rs:1190`). +- Lua already exposes `pmacs.lsp.modeline_label(server)` and a richer + `status_summary` intended for one call per render frame + (`src/lua_bindings/mod.rs:8700-8805`). +- `builtin/runtime/lsp.lua` owns the authoritative + buffer-handle-to-attachment map. Its public `active_attachment` + deliberately reads only the active window (`:721-731`), but a + statusline provider in that same Lua chunk can safely index the + private map by a passed `ctx.buffer`, including passive TUI windows. +- Despite comments saying LSP data feeds a modeline, no renderer + currently consumes it. Stage 3 can prove the API on a shipped, + useful segment instead of landing an unused extension point. + +### ThemeFacts currently cannot represent arbitrary segment faces + +- `Theme::face(name)` owns daemon-side dotted-prefix inheritance for + `ui`/`ui.*` names and returns `None` when unset + (`src/highlight.rs:207-226`). +- The namespace predicate itself currently lives only in the main + crate as `highlight::is_face_name` (`src/highlight.rs:92-95`). + `pmacs-gpu` cannot import that crate without reversing the dependency + graph, so merely calling two copied expressions "shared" would leave + registration and the untrusted wire boundary free to drift. +- The `ThemeFacts` producer resolves only the fixed twelve stage-1 face + names in `UI_FACES` (`src/semantic_render.rs:281-299`, + `:1137-1177`). Frontends perform exact-name lookup; they never walk + parent names. +- Therefore a segment naming `ui.modeline.lsp` cannot inherit a + configured `ui.modeline` on the GPU unless the producer learns that + exact referenced name and ships its resolved style. Sending raw + theme entries and reimplementing the walk frontend-side would + contradict Q#TH7. + +### Protocol placement + +- `PROTOCOL_VERSION == 17`; supported versions are `6..=17` + (`pmacs-protocol/src/message.rs:1414`, `:1472-1480`). +- `FontFacts` is the final variant. Postcard enum discriminants are + ordinal; stage 2 pinned the byte encoding of the final pre-v17 + `ThemeFacts` variant. Stage 3 must append after `FontFacts` and pin + `FontFacts` bytes before changing the enum. + +## Decisions + +### Q#SL1 - Scope: additive per-window modeline segments; Arc 4 ends here + +Stage 3 extends the **per-window modeline/status band**, not the global +echo area: + +- TUI: custom left/right segments render on each visible window's + modeline. +- GPU: the same custom segments render in the existing status band, + scoped to its current buffer. +- The TUI echo row remains owned by `core.status`, Lua errors, pending + keys, isearch, and minibuffer. `pmacs.editor.set_status` is unchanged. +- GPU minibuffer/isearch/transient-message precedence remains + unchanged. The physical single-band compromise is explicit in Q#SL5. +- Existing buffer identity, modified state, diagnostics, cursor L:C, + and scroll facts remain built in. This API is additive; replacing, + removing, or arbitrarily reordering those built-ins is Deferred. +- Cursor and scroll remain frontend-derived. A Lua provider receives no + cursor/scroll value in its context; sending the daemon's cursor as a + custom segment would regress optimistic freshness by design. + +No popup, click action, second row, or new layout surface is in scope. +Protocol v17 -> v18 is reserved for one additive segment-facts variant. +When this stage lands, Arc 4 is complete. + +### Q#SL2 - Lua surface: composable provider registry + +The new module is `pmacs.statusline`: + +```lua +local handle = pmacs.statusline.register { + name = "my-project", + side = "left", -- required: "left" or "right" + priority = 20, -- optional signed 32-bit integer; default 0 + face = "ui.modeline.project",-- optional; default "ui.modeline" + fn = function(ctx) + if not ctx.buffer then return nil end + return "project" + end, +} + +pmacs.statusline.set_priority(handle, 50) -- true iff handle is live +pmacs.statusline.set_enabled(handle, false) +pmacs.statusline.unregister(handle) +local providers = pmacs.statusline.providers() +``` + +Contract: + +- A new `SharedStatuslineRegistry` is installed from `EditorState::new` + before `builtin/runtime/lsp.lua`, stored on `EditorState`, and passed + by reference to both grid and semantic renderers. User config still + runs after all builtins, so it can discover and tune the built-in LSP + provider. Bare test states construct an empty registry rather than an + optional/absent surface. +- `register` returns a stable `StatuslineProviderId` userdata. Names are + non-empty display/debug labels, not unique keys; handles own + lifecycle, matching completion providers and package unload + discipline. Registrations start enabled; ids are monotonic and are + never reused, so registration-id tie breaks remain stable. The + binding captures `caller_source(lua, 2)` at registration for later + error attribution. +- The registration table is strict plain data. Raw keys are exactly + `name`, `side`, `priority`, `face`, and `fn`; an unknown key is + rejected with its name. Raw reads/traversal do not invoke + `__index`/`__pairs`. `name`, `side`, `face`, integer range, and + function type are completely validated before mutating the registry. + Priority accepts a finite, mathematically integral Lua number in the + signed-32-bit range on both LuaJIT and Lua 5.4; strings/fractional + values do not coerce. +- The namespace tests move to dependency-neutral protocol helpers: + `pmacs_protocol::is_ui_face_name` retains the exact stage-1 + `name == "ui" || name.starts_with("ui.")` reservation, while + `is_modeline_face_name` accepts only `ui.modeline` or + `ui.modeline.*`. The core's `highlight::is_face_name` delegates to + the former; statusline registration, ThemeFacts expansion, and GPU + wire validation delegate to the latter. A modeline segment cannot + borrow another surface family's special mask/Default policy. + Statusline registration additionally requires valid UTF-8, rejects + control characters, and bounds `name` and `face` to + `MAX_STATUSLINE_PROVIDER_NAME_BYTES` / `MAX_STATUSLINE_FACE_BYTES` + (256 each). +- `face` is static for the registration. Dynamic face changes use two + providers or unregister/register; this keeps the authoritative face + inventory knowable without executing user code. +- The callback returns a valid UTF-8 string or `nil`. `nil` and the + empty string omit the segment and contribute no separator. Invalid + UTF-8 or any other return type is an isolated provider error. +- At most `MAX_STATUSLINE_PROVIDERS` (64) registrations may be live. + Disabled registrations still count; unregistering releases the slot. + This makes the producer's wire-size bound structural rather than a + lossy "drop some providers after evaluation" policy. +- Returned text is flattened with the existing one-line policy: stop at + the first `\n`, replace other control characters with spaces. A + post-sanitization value above `MAX_STATUSLINE_SEGMENT_BYTES` (1024) + is a provider error rather than an unbounded wire/shaping input. +- `providers()` returns fresh plain metadata tables in registration + order: handle, name, side, priority, face, enabled. It never exposes + the stored function. +- `set_priority` and `set_enabled` return `false` for a stale handle; + an actual change advances registry state. Mutator arguments are also + strict raw types (`set_enabled` accepts only a boolean, never Lua + truthiness). `unregister` is idempotent and returns whether a live + provider was removed. +- The module/registry installs before `builtin/runtime/lsp.lua` and + before user config. Registration and all mutators are live + mid-session, not init-gated. + +The registry carries two monotonic counters: + +- `layout_epoch`: register/unregister, actual priority changes, and + enable changes. It guards evaluation snapshots and orders. +- `face_set_epoch`: register/unregister and enable changes that alter + the enabled referenced-face set. It keys `ThemeFacts` expansion + (Q#SL6). Priority-only changes do not make every semantic session + re-resolve theme faces. + +Both advance from their prior values and never reset. + +### Q#SL3 - Callback context and evaluation lifecycle + +Each enabled provider is called once per rendered window context: + +```lua +ctx = { + frontend = 7, -- integer FrontendId + window = 42, -- integer WindowId + buffer = buffer_id, -- normal pmacs buffer-handle userdata + active = true, -- focused window within that frontend +} +``` + +There is deliberately no terminal width, pixel width, cursor, scroll, +or frontend-kind field. Layout stays frontend-local; providers produce +semantic text, not presentation guesses. A provider that supports +passive split windows must read `ctx.buffer`, not +`pmacs.window.buffer()` (which names the focused window). + +Evaluation is a three-phase, borrow-released transaction: + +1. Borrow the core only long enough to capture the target frontend's + visible `(window, buffer, active)` contexts. For the semantic path, + capture only `active_window_for(frontend_id)` and require its buffer + to match the declared viewport; during a snapshot -> new-viewport + transition, emit nothing for the stale viewport. +2. Snapshot enabled provider definitions plus `layout_epoch`, release + every core/registry borrow, then invoke Lua in the deterministic + order from Q#SL4. Every call gets a fresh context table. +3. Re-read `layout_epoch` and the core contexts. Publish the owned + results only if the registry epoch is unchanged and every + `(frontend, window)` still exists on the same buffer with the same + active flag. A callback that changes layout, switches/kills a buffer, + or registers/unregisters/disables a provider makes this evaluation + **invalid**. Invalid is not a silent dropped fan-out: for the + declared matching v18 buffer, the producer emits an authoritative + replacement `StatuslineSegments { left: [], right: [] }`, records + that empty payload as the new emission baseline only after queuing + the replacement, and discards every evaluated result. The next frame + therefore stays silent if the surviving truth is also empty, or + emits the newly evaluated non-empty truth as a change from empty. If + a callback changed the initially matching window away from the + declared buffer, the empty replacement clears that prior buffer's + mirror before the next frame evaluates the new truth. A snapshot -> + new-viewport transition that was already stale at phase 1 instead + follows that phase's no-message rule: `BufferSnapshot` has already + cleared the frontend mirror, and `on_buffer_snapshot_sent` owns the + corresponding baseline removal. Thus no callback mutation can leave + a prior non-empty GPU payload resident indefinitely, and no invalid + evaluation creates a redundant second empty send. + +The TUI calls the evaluator at the start of `paint_frame`, before the +long-lived mutable core borrow. `SemanticRenderState::render_frame` +calls it before producing `StatuslineSegments`, but only for a peer +that negotiated v18. A v17 semantic peer pays no Lua callback cost for +an unsupported surface. The daemon already stamps `active_frontend` +before both paths, so `pmacs.frontend.id()` agrees with `ctx.frontend`. + +Provider failures are independent: + +- One error or invalid return omits only that provider. Later providers + still run and all built-in facts still render. +- The first failure in a consecutive failure run is appended to + `*errors*` with provider name and registration source. Repeating the + same failing callback every frame does not flood the buffer. Latches + are keyed by the full `(provider_id, frontend_id, window_id, + buffer_id, active)` context: success in one split must not re-arm a + provider that keeps failing in another, and switching a window to a + different buffer or focus role starts a truthful new failure run. + A successful string-or-`nil` result clears only that context's latch, + so a later failure there is reportable again. Unregister and stale + context cleanup discard the corresponding latches; disabling a + provider clears all of its latches so re-enable begins a new run. + Frontend detach also discards every latch keyed by that `FrontendId` + (with a live-context sweep as defense in depth), so a detached session + cannot retain failure suppression into a later reconnect. +- Evaluation snapshots definitions before calls; a provider may + unregister itself without a `RefCell` panic. The epoch guard drops the + old fan-out's result and takes the authoritative-empty invalidation + path above. +- Providers are documented as pure, fast render functions. The binding + cannot prevent a callback from invoking editor mutators, but the + context/epoch guard prevents wrong-window publication; recurring + mutation loops are user-code bugs, not an implicit scheduling API. + +No content epoch is assumed. LSP/process/async state can change without +touching the registry, so enabled callbacks are polled each render. +Owned output is payload-compared before wire emission; an empty registry +or no enabled providers is an O(1) fast path. + +### Q#SL4 - Composition, order, separators, and narrow-window policy + +Current built-in positions remain anchored: + +- **Left:** the frontend's current active/modified/buffer-identity group, + with its existing edge padding, then custom left segments. +- **Right:** custom right segments, then the frontend's current + diagnostic/cursor/scroll group with its existing internal and edge + spacing. + +The compositor inserts exactly one ASCII space between adjacent custom +segments and at a custom/built-in boundary. Provider text does not need +to carry padding. No separator is emitted for `nil`/empty results. +Every compositor-inserted separator is a base `ui.modeline` run: it +never inherits an adjacent custom segment face. Legacy built-in internal +spacing retains its current base modeline styling too. This rule is +identical in TUI cells and GPU rich text, so a face colors only the +provider's visible text, not the gaps around it. +Each legacy built-in group stays atomic and byte-for-byte unchanged +inside: in particular, stage 3 does not normalize the GPU's existing +two-space diagnostic/readout separators to the TUI's one-space +formatting. + +Priority means **survival priority when horizontal space is tight**: + +- Left custom providers are ordered by `(priority descending, + registration id ascending)`. Higher-priority items sit closest to the + leading-edge buffer identity. Overflow clips the low-priority tail. +- Right custom providers are displayed by `(priority ascending, + registration id ascending)`, placing higher-priority items closest to + the protected diagnostic/cursor/scroll suffix. The complete right run + is right-aligned; overflow clips its low-priority left edge. +- The protected built-in suffix is never discarded merely because a + custom provider is long. If the built-in suffix itself cannot fit, + the TUI retains its legacy wholesale drop. The GPU deliberately + changes its legacy narrow-band policy: before stage 3 it pinned the + built-in group's left edge and clipped the right tail; stage 3 pins + the right edge and clips the left so the readout tail survives. + Custom-prefix clipping preserves the complete built-in suffix only + when that suffix fits by itself. +- The left group gets the space before the right group's measured + origin and clips at the collision boundary, without the legacy GPU's + extra 10-pixel gap. It never overwrites the right group. This anchors + buffer identity at the leading edge but does not guarantee its + survival: an over-wide right group may consume all available left + space. + +This asymmetric visual ordering is intentional: priority determines +what survives, not a generic ascending sort that would protect opposite +ends on the two sides. Registration id makes ties deterministic across +TUI painting, payload comparison, and wire encoding. + +### Q#SL5 - Echo/minibuffer precedence on the single GPU band + +The TUI always keeps modelines visible while its separate global row +shows a message, search, or minibuffer. The GPU has one physical band, +so exact topology parity is impossible without adding a second GPU +surface (Deferred). Stage 3 follows the existing content priority: + +- Ordinary buffer-name state: buffer identity followed by custom left + segments. +- Minibuffer, isearch, or transient message state: that content owns + the whole left group; custom left segments are suppressed. +- Custom right segments remain visible with the existing + diagnostic/cursor/scroll right group, just as that group remains + visible during minibuffer/search/message state today. + +This makes custom segments modeline content, never echo content. +`ui.statusline` continues to color transient messages only. + +### Q#SL6 - Segment faces and dynamic ThemeFacts inventory + +Every segment carries a face **name**, never raw color. The registered +default is `ui.modeline`; a typical package uses a child such as +`ui.modeline.lsp`. + +Segment faces have a stage-3 component mask of **visual `{fg}` only** +on both frontends: + +- The modeline/status-band background remains wholly owned by + `ui.modeline`; a text segment cannot create a per-run background on + one frontend only. +- The default face name `ui.modeline` and an unresolved custom face keep + the base modeline's EFFECTIVE text color after its own reverse + mapping. +- A resolved custom face applies only its logical `fg` as the + POST-modeline visible glyph color when that component is concrete. + `Default` means "use the effective base modeline text color": an + exact all-default child still blocks a colored intermediate parent, + but returns the run to the base rather than trying to express a + terminal-default foreground through a reversed background channel. + The visible background remains the base modeline surface. + Out-of-mask bg/bold/italic/underline/reverse fields are ignored by + both frontends. +- The TUI's built-in modeline is normally `reverse = true`. To apply a + visible glyph color without changing that surface, the cell painter + writes the override into the base style's logical `bg` when reverse + is set, and into logical `fg` otherwise. After the terminal performs + reverse, the requested color is the glyph foreground in both cases. + The GPU writes the same requested color into the glyphon run. +- A `ui.modeline.*` custom child uses **base-relative inheritance**: + walk exact child/intermediate entries but stop before + `ui.modeline`; reaching the base means "no override", so the segment + inherits the modeline's already-mapped effective text color. This + avoids taking `ui.modeline`'s pre-reverse logical `fg` and applying it + as a post-reverse glyph color. One shared + `Theme::modeline_segment_face` helper owns this rule for TUI + resolution and ThemeFacts production. A concrete custom foreground + returns a mask-normalized `Style { fg, ..Default::default() }`; a + found Default foreground stops inheritance and returns `None` (base). + Out-of-mask components never enter the dynamic wire table. +- GPU performs exact lookup in `ThemeFacts`; absence means base + modeline text. Existing `Indexed` palette divergence remains the + stage-1 accepted behavior. + +For semantic peers at v18, the `ThemeFacts` inventory becomes: + +```text +fixed stage-1 UI_FACES +UNION +distinct face names of enabled statusline providers +``` + +The union is sorted/deduplicated. Custom names resolve through +`Theme::modeline_segment_face`: exact/intermediate concrete foreground +overrides are shipped, while a name that reaches the base or finds a +Default foreground is omitted and therefore uses the frontend's +effective modeline text. Thus an unset `ui.modeline.lsp` correctly +follows a configured, possibly reversed `ui.modeline` without shipping +a pre-reverse component under a post-reverse mask. Frontend lookup +remains exact; the Q#TH7 ownership boundary does not move. + +`theme_facts_msg` keys its computation on +`(theme.face_epoch, statusline.face_set_epoch)` for a v18 peer. Both +cache records advance on computation; payload equality can suppress a +send. Removing/disabling the last provider for a custom face removes +that entry from the next authoritative table. For v16/v17 peers the +inventory stays the fixed stage-1 list: they cannot render segments and +pay no irrelevant face traffic. + +If a face-table change and segment payload occur in one frame, +`ThemeFacts` is ordered before `StatuslineSegments`. A theme-only +recolor sends `ThemeFacts` but not unchanged segment text; the GPU face +arm invalidates both status shaping caches, so existing runs reshape +under the new color. The invalid-evaluation authoritative-empty path +uses this same ordering: a provider removal may remove its dynamic face +from `ThemeFacts`, but its prior non-empty segment payload is replaced +by empty vectors in that frame rather than being retained beside the +reduced face inventory. + +### Q#SL7 - Wire: `StatuslineSegments`, protocol v18, appended final + +```rust +/// One daemon-produced custom modeline segment. Text has already been +/// sanitized to one line; `face` is ui.modeline or a child name. A +/// custom override, when set, is resolved in the authoritative +/// ThemeFacts table; absence means the base modeline text color. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct StatuslineSegment { + pub text: String, + pub face: String, +} + +/// Arc 4 stage 3 (protocol v18): custom Lua modeline output for the +/// semantic frontend's current buffer. Complete replacement each +/// send; empty vectors authoritatively mean no custom segments. +StatuslineSegments { + buffer_id: BufferId, + left: Vec, + right: Vec, +}, +``` + +- Append after `FontFacts`, the final v17 variant. Before appending, + add a byte-level encoding pin of representative `FontFacts` values; + the new variant's own round-trip cannot detect an accidental ordinal + shift of old channels. +- `PROTOCOL_VERSION` becomes 18; supported versions become `6..=18`; + the ladder accepts 18 and rejects 19. Add populated and empty + postcard round-trips. +- Daemon write-loop and producer both gate at negotiated `>=18`. + A v17 GPU keeps today's built-in band. The grid TUI silently drops + the semantic-only variant if one is delivered unexpectedly. +- The payload contains custom provider output only. Existing + `StatusFacts` remains unchanged at v15; widening it would move its + whole gate to v18 and unnecessarily darken buffer/diagnostic facts + for v15-v17 peers. +- `docs/semantic-frontend-protocol.md` records the v18 schema, + authoritative-empty rule, ordering after `ThemeFacts`, snapshot + reset, and the division between custom daemon text and + frontend-derived cursor/scroll. + +Wire values are untrusted at the GPU boundary. Before replacing current +state, the GPU validates the whole message atomically. The provider, +segment-text, face, and total-text limits live as public constants in +`pmacs-protocol`; registration/production and consumption do not copy +numeric policy: + +- no more than 64 segments total across both sides; +- total text bytes no more than 64 KiB; +- each text is non-empty, at most 1024 bytes, and contains no control + character; +- each face is at most 256 bytes, contains no control character, and + satisfies `pmacs_protocol::is_modeline_face_name`. + +An invalid message is logged and ignored wholesale; the prior valid +state remains. These bounds protect shaping/layout even if a malformed +peer bypasses the trusted Lua producer. + +### Q#SL8 - Producer, emission baselines, and snapshot symmetry + +`SemanticRenderState` gains: + +- `peer_knows_statusline_segments: bool`; +- `last_statusline: HashMap, + Vec)>`. + +After viewport declaration and only when the declared buffer matches +the frontend's active daemon window, the producer evaluates the active +context and compares the complete ordered payload: + +- First sight of every buffer emits an authoritative message, including + `(left=[], right=[])`. +- Changed output emits one complete replacement. +- Byte-identical output is silent even though callbacks were evaluated. +- Back-to-back state changes before one frame legitimately coalesce into + the latest payload. +- `on_buffer_snapshot_sent(buffer_id)` removes that buffer's baseline. + An unchanged A -> B -> A revisit must re-send A's segment payload. + +The GPU `BufferSnapshot` arm clears its custom left/right segment +mirror alongside `status_facts`, search, and menu. `ThemeFacts` and the +provider registry remain global and survive. This is the #120 +snapshot/baseline contract applied symmetrically, not a new special +case. + +### Q#SL9 - TUI rendering: styled runs and display-column correctness + +`paint_mode_line` stops flattening each side to an unstyled `String`. +It receives logical runs `(text, effective Style)` and uses one shared +single-row painter: + +- Before grapheme segmentation, **every** logical run passes through a + shared terminal-control sanitizer: provider text, buffer names, mode + markers, diagnostics/readouts, and compositor separators alike have + all control scalars (including CR, LF, and ESC) replaced with spaces. + Provider-return sanitation remains an earlier validation boundary; + this final run-level pass is defense in depth for core-owned text. + Consequently `Glyph::Cluster`, whose frontend emitter writes bytes + verbatim, can never carry a terminal control sequence. +- Runs are split with `UnicodeSegmentation::graphemes`; width and + clipping use `UnicodeWidthStr` on each complete grapheme. This stage + adds `unicode-segmentation` as a direct dependency rather than + relying on cosmic-text's transitive copy. +- A one-scalar grapheme writes `Glyph::Char`; a multi-scalar printable + grapheme writes `Glyph::Cluster`. Every extra display column writes a + `Glyph::Continuation`; clipping never emits half a wide grapheme. + A standalone zero-column grapheme is skipped, while a combining + sequence such as `e` + U+0301 remains one visible cluster. +- Left/right collision uses display columns, not scalar count or UTF-8 + bytes. +- The row is still filled once with the `ui.modeline` base style. + Built-in runs keep that style. A set segment face replaces only the + run's visible foreground per Q#SL6, writing logical `bg` rather than + `fg` when the base row is reversed. +- Every separator inserted by Q#SL4 is likewise painted with this base + style, regardless of the faces on either side. +- When the protected built-in right suffix fits by itself, clipping the + combined right group removes only the low-priority custom prefix and + preserves that suffix in full. If the built-in suffix itself does not + fit, the TUI retains today's wholesale drop instead of introducing a + new partial-suffix policy. Left clipping keeps the prefix. No run can + write outside its window rect or into another split's modeline. + +With no visible provider output, the resulting cells are byte-for-byte +the current modeline for ordinary ASCII buffers. + +### Q#SL10 - GPU application: rich runs, cache invalidation, clipping + +GPU state stores the latest validated custom segment vectors plus their +`buffer_id`. Composition filters them against `current_buffer_id`, the +same belt as `StatusFacts`. + +- Right custom segments are inserted before diagnostic/cursor/scroll + spans. Each segment becomes a rich-text run. The color resolver + special-cases `ui.modeline` and an absent child to the already-mapped + base modeline text color; a present child maps only its concrete + `fg`, with defensive Default handling also selecting the base. It + never re-applies the base face's pre-reverse logical foreground. +- Ordinary left composition becomes rich text: buffer-name/modified + base run followed by custom left runs; each Q#SL4 separator is its + own base-color rich run. Modal/message states produce their existing + single content run and no custom left runs. Right-side custom/built-in + separators are likewise base-color runs, never extensions of an + adjacent provider face. +- The two shaping caches become + `Option>`, seeded/invalidation-set to + `None`, and retain the complete ordered rich-run vectors after shape. + Concatenation is not a sufficient key once `"buffer" + custom` can + equal a transient/minibuffer string byte-for-byte while requiring + different attributes; an empty vector is legitimate content, not an + invalidation sentinel. Cache state advances only after the matching + rich text has been installed. +- Applying a changed `StatuslineSegments` payload clears both status + shaping caches before redraw. This is required even when concatenated + text is unchanged but a face name changed. +- Both status glyphon buffers use `Wrap::None`, set at construction and + retained across the FontFacts metric transaction. They remain + single-line surfaces even when a custom segment is wider than the + viewport. +- Right placement uses the full shaped width without clamping its + origin to `TEXT_LEFT`: the run's right edge stays at the right pad, + while a negative/left-of-surface origin clips low-priority custom + prefixes and preserves the built-in tail. This intentionally changes + the legacy built-in-only narrow case, which anchored the readout at + `TEXT_LEFT` and clipped its right tail. +- The left TextArea clips at the right group's actual origin rather + than retaining the legacy extra `STATUS_TEXT_PAD` gap. The right + group therefore owns collision priority and may fully obscure the + left buffer identity in an extremely narrow band. Existing geometry + bounds still keep all glyphs inside the band. +- `ThemeFacts` continues to invalidate both caches. FontFacts already + re-metrics/re-shapes both status buffers; the new rich runs ride that + path without a new font transaction. + +The message does not request a viewport re-declaration: status text +changes no code geometry or visible-line count. + +### Q#SL11 - Built-in LSP segment proves the extension point + +After `pmacs.statusline` is installed, `builtin/runtime/lsp.lua` +registers one right provider: + +```lua +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, +} +``` + +It is pure: it never triggers attachment, flushes didChange, or mutates +the server. It indexes the private attachment map by `ctx.buffer`, so +passive split windows show their own buffer's state. No attachment means +`nil`, preserving today's modeline outside LSP-backed buffers. + +The face name is intentionally a new child. Unset, it inherits +`ui.modeline`/the built-in segment color. A user can theme LSP state +without changing the whole band: + +```lua +pmacs.theme.merge { + ["ui.modeline.lsp"] = { fg = 6 }, +} +``` + +The provider handle appears in `pmacs.statusline.providers()`, so user +config can disable or reprioritize it without a special LSP option. + +## Bets + +- Additive providers are sufficient for the first extensibility stage: + they deliver real package/user value without turning optimistic + cursor/scroll facts into stale daemon text or destabilizing the + existing default layout. +- Static registration faces plus the dynamic ThemeFacts inventory keep + inheritance daemon-owned and make face availability independent of + callback output. No frontend walk or raw color enters the API. +- Per-render Lua polling is the honest freshness mechanism. Generic + callbacks can depend on LSP/process/plugin state with no shared epoch; + payload comparison keeps the wire quiet, and an empty registry takes + the O(1) fast path. The existing `status_summary` API was already + shaped for one call per render frame. +- Three-phase evaluation prevents the known core/registry `RefCell` + hazards and fails closed across context-changing callbacks. It does + not pretend arbitrary mutating render code is a supported scheduling + model. +- One authoritative v18 message per buffer plus snapshot-symmetric + reset makes first attach, late join, and unchanged A -> B -> A + revisits correct without an epoch on the wire. +- The priority-at-the-protected-edge rule is deterministic and keeps + today's essential built-ins readable under narrow layouts. +- The first built-in LSP provider validates passive-window context, + live async updates, arbitrary child faces, and cross-frontend wire + rendering in one useful feature. + +## Deferred (named) + +Wholesale replacement/removal/reordering of built-in buffer, +diagnostic, cursor, and scroll components; a frontend-local custom +cursor/scroll token vocabulary; customization of the global echo row; +a second GPU bottom surface that would keep modeline left segments +visible during minibuffer/search/messages exactly like the TUI; +segment click/hover actions and mouse hit maps; multi-row statuslines; +icons/images/resources; per-segment backgrounds, reverse, +bold/italic/underline, and wider chrome masks; `ui.modeline.inactive`; +borrowing face families outside `ui.modeline`; dynamic face names +returned by callbacks; async/yielding providers; +provider-specific separators; timed refresh scheduling below/above the +normal frame cadence; automatic package ownership/unregister (packages +retain handles and use unload hooks today); GPU splits/multi-buffer +status bands (Arc 8 structural work); horizontal scrolling/marquee and +ellipsis policies; repurposing or deleting the legacy +`ModeLine(Vec)` variant. + +## Acceptance + +Primary suite: `tests/statusline_segments_acceptance.rs` for Lua, +TUI, producer, and daemon/wire behavior; protocol pins stay in +`src/protocol.rs`; GPU routes live in the headless +`PMACS_REQUIRE_GPU=1` suite. Dispatch/render tests use real +`RenderState`/semantic frame paths, not direct helper-only formatting. + +1. **Default preservation:** with no visible provider output, scratch + TUI cells and ordinary non-overlapping GPU modeline/status-band + pixels are byte-identical to the pre-stage rendering. The deliberate + GPU narrow-band exception pins an over-wide built-in readout's right + edge and clips its left edge; a built-in-only headless fixture pins + that behavior. The global TUI echo row is unchanged. +2. **Lua strict contract:** valid registration returns a handle and + appears in `providers`; bad/unknown side, empty name, non-integer or + out-of-range priority, non-function `fn`, non-modeline face + (including another valid `ui.*` family), control or over-limit + name/face, provider 65, and unknown key all error with the field/key + named and leave registry epochs and provider list untouched. A + value-providing or raising metatable is never invoked. Protocol, + core, producer, and GPU tests pin the same namespace predicate table + through the shared helpers. +3. **Handle lifecycle:** priority and enable changes affect order/output + and advance only their specified epochs; no-op setters do not; + unregister is true then false; stale-handle setters return false; + fractional/coerced priority and truthy non-boolean enable values + error without mutation. +4. **Callback result contract:** string renders; `nil` and empty string + omit without separators; newline/control output is sanitized; + invalid UTF-8, non-string, and over-limit output omit that provider + and report an error. +5. **Error isolation and latch:** a failing provider between two good + providers does not suppress either neighbor or built-ins; one error + lands in `*errors*`, repeated frames do not append duplicates, a + successful evaluation clears the latch, and a later failure reports + once again. In two splits, success in B does not re-arm a provider + that remains failing in A; closing A or unregistering the provider + releases that context's latch, and disable/re-enable starts a new + failure run. Detaching a frontend releases every latch carrying its + `FrontendId`; reconnecting and failing again reports once rather than + inheriting suppression from the detached session. +6. **Re-entrant registry mutation:** a provider unregistering itself + during evaluation causes no borrow panic and discards the old + fan-out by epoch guard. A semantic producer test first establishes a + non-empty payload (and its custom face) for the matching buffer, then + triggers self-unregister/disable: the invalid evaluation emits one + authoritative empty replacement, the reduced `ThemeFacts` precedes + that replacement, the resulting GPU frame has no prior text, and the + empty replacement becomes the emission baseline. The provider is + absent and a still-empty next frame is wire-silent; a surviving good + provider instead reappears on that next frame as a change from empty. +7. **Context-change guard:** callbacks that switch the window buffer, + close a split, or kill the source buffer cannot publish text under + the old context; the next frame evaluates the surviving truth. +8. **Per-window context:** two TUI splits on different buffers receive + distinct `ctx.window`, `ctx.buffer`, and `ctx.active` values and + render their own text. Focusing the other split flips only `active`; + two frontends cannot consume each other's context/output. +9. **Ordering and separators:** mixed left/right providers with tied and + distinct priorities produce the exact Q#SL4 order, stable id tie + break, and one-space custom boundaries with nil providers absent; + the built-in groups retain their legacy internal spacing. With two + visibly different custom faces, every custom/custom and + custom/built-in separator is pinned to the base `ui.modeline` style + in TUI cells and GPU rich runs/pixels. +10. **TUI placement:** buffer identity remains first on the left; + custom right segments precede diagnostics/L:C/scroll; the global + echo row still shows `pmacs.editor.set_status` independently. +11. **TUI Unicode and clipping bite:** CJK, combining, and ASCII custom + runs beside a right suffix occupy correct display columns with + cluster/continuation cells and no overlap; the combining sequence is + emitted rather than silently dropped. A narrow-split fixture whose + built-in suffix fits by itself clips the low-priority custom edges + while retaining that suffix in full and never writes outside its + rect. A second fixture where the built-in suffix itself does not fit + pins the current TUI wholesale-drop behavior. A buffer name + containing CR, LF, and ESC is sanitized before segmentation: its + resulting `Glyph::Char` / `Glyph::Cluster` cells and captured + terminal bytes contain no raw control scalar or escape sequence. +12. **LSP built-in:** an unattached buffer adds nothing. Attached + buffers show `LSP:init/ready/idx/degraded/crashed/stopped` as the + tracker changes without a buffer edit, and `LSP:?` for a forgotten + server id; a passive split uses its own attachment. + Disabling/reprioritizing the discovered provider handle works. +13. **Version and placement pins:** protocol is 18; ladder accepts + `6..=18` and rejects 19; empty/populated + `StatuslineSegments` round-trip; a byte-level `FontFacts` encoding + pin proves the append shifted no v17 discriminant. +14. **Authoritative first frame and live output:** a v18 session's first + matching-viewport frame carries empty vectors when no provider is + visible, then silence. A callback-state change with no edit/registry + mutation emits exactly one updated payload; unchanged polling is + wire-silent. +15. **Init and late join:** a provider/theme established from + `init.lua` is present in the first attachment's first matching + frame. The same established state is present in a later second + session without a post-attach mutation. +16. **Version gate:** a real-daemon v17 semantic peer receives neither + `StatuslineSegments` nor dynamic provider-only ThemeFacts entries + and does not execute the provider; a v18 peer receives both. Daemon + producer and write-loop gates are independently pinned. +17. **TUI drop arm:** the grid frontend consumes an unexpected + `StatuslineSegments` message without error. +18. **Snapshot round trip:** after A's segment payload is established, + A -> B -> A at unchanged generations re-sends A because the producer + baseline reset; the GPU snapshot clears A's mirror immediately and + restores the exact A pixels only after the authoritative re-send. +19. **Dynamic face inventory:** registering enabled + `ui.modeline.lsp` adds its daemon-resolved exact name to v18 + `ThemeFacts` only when a custom override exists; a configured + `ui.modeline` parent is inherited through base absence (no redundant + child entry), while an intermediate custom parent is shipped under + the exact referenced child name with only `fg` retained; + disabling/removing the last reference removes any custom entry. A + priority-only change does not recompute the face set. +20. **Message ordering and recolor:** when registration and theme + change together, `ThemeFacts` precedes `StatuslineSegments`. + Recoloring a segment face with constant text emits ThemeFacts only + and changes both TUI cells and GPU pixels through cache + invalidation. +21. **Face mask parity:** a segment face carrying + `{fg=F,bg=B,reverse=true,bold=true}` renders exactly like `{fg=F}` + on both frontends, including under the TUI's default reverse row; + an exact empty child blocks a colored intermediate parent and + returns to the effective base text while retaining the base + modeline surface. +22. **GPU normal composition:** ordinary state renders buffer identity + plus differently faced custom left runs, and custom right runs + before colored diagnostics and optimistic cursor/scroll. A changed + face name with identical concatenated text still reshapes. +23. **GPU precedence:** minibuffer, isearch, and transient status each + suppress custom left segments while preserving custom right and the + existing right facts; closing the modal/message restores the custom + left payload without requiring a new segment message. A fixture + makes the ordinary rich composition and transient message + concatenate to identical bytes and proves both transitions reshape + with the correct attributes. +24. **GPU narrow-band clipping:** an over-wide right provider is clipped + at the left edge while diagnostic/L:C/scroll pixels remain at the + right; left content stops before the right origin. Bounds contain + all glyphs at both stage-2 font-size limits. A wrapping-sensitive + fixture proves both status buffers remain one visual row. +25. **GPU wire validation:** direct messages with too many segments, + excess bytes, control text, overlong/invalid face names, or a + face outside `ui.modeline` are rejected atomically with the prior + valid frame byte-identical and no panic; boundary-valid payloads + apply. The predicate cases are the same table exercised by Lua/core + tests, not a copied GPU interpretation. +26. **Unsupported-peer cost:** a semantic v17 render with an enabled + side-effect-counting callback never invokes it. Grid TUI and v18 + semantic renders invoke exactly once per target window per frame. +27. **Docs/handoff:** semantic protocol documents v18 and ownership; + `docs/package-author-guide.md` shows register/unregister lifecycle + and passive `ctx.buffer` use; the roadmap/handoff record Arc 4 + complete once the implementation lands. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 3012479..4da619e 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -37,10 +37,12 @@ use loro::{ContainerTrait, ExportMode}; use pmacs_protocol::{ AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CompletionPopupRow, CrdtOp, Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, - InstanceSignal, Key as ProtocolKey, LineNumberMode, MenuPromptRow, Modifiers, PointerKind, - SelectionSnapshot, StyleSegment, StyleSpan, + InstanceSignal, Key as ProtocolKey, LineNumberMode, MAX_STATUSLINE_FACE_BYTES, + MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, + MenuPromptRow, Modifiers, PointerKind, SelectionSnapshot, StatuslineSegment, StyleSegment, + StyleSpan, cell::{Color as CellColor, Style as CellStyle}, - is_builtin_pair_char, + is_builtin_pair_char, is_modeline_face_name, }; use wgpu::MultisampleState; use winit::application::ApplicationHandler; @@ -874,19 +876,17 @@ struct State { squiggle_vertex_buffer: ReusableVertexBuffer, caret_vertex_buffer: ReusableVertexBuffer, minimap_vertex_buffer: ReusableVertexBuffer, - /// Q#S2 — the status band's one-line text. Shaped only when the - /// composed status string changes; rendered as a second - /// `TextArea` in the same prepare pass as the main buffer. + /// Q#S2/Q#SL10 — the status band's shaped right rich text. status_buffer: Buffer, - /// The string `status_buffer` currently holds, for change - /// detection. - status_text: String, - /// Q#S2 — the band's left side (buffer name + modified dot), - /// its own buffer so it left-aligns independently of the - /// right-aligned readout. + /// Rich runs currently installed in the right status buffer. + /// `None` is the invalidation sentinel; an empty vector is valid. + status_runs: Option>, + /// The independently left-aligned status buffer. status_left_buffer: Buffer, - /// Change-detection twin of `status_text` for the left side. - status_left_text: String, + /// Rich runs currently installed in the left status buffer. + status_left_runs: Option>, + /// Latest atomically validated custom statusline replacement. + statusline_segments: Option, /// Q#S1 — the wire-authoritative status facts (protocol v8). status_facts: Option, /// Q#SR5 — the live incremental-search prompt (protocol v9), or @@ -980,6 +980,57 @@ fn completion_kind_glyph(kind: u8) -> char { } } +/// Latest validated custom statusline replacement (Q#SL7/Q#SL10). +#[derive(Clone, Debug, PartialEq, Eq)] +struct StatuslineSegmentsLocal { + buffer_id: BufferId, + left: Vec, + right: Vec, +} +/// Validate the complete untrusted statusline payload before any state +/// changes. Numeric and namespace policy lives only in pmacs-protocol. +fn validate_statusline_segments( + left: &[StatuslineSegment], + right: &[StatuslineSegment], +) -> Result<(), &'static str> { + let count = left + .len() + .checked_add(right.len()) + .ok_or("segment count overflow")?; + if count > MAX_STATUSLINE_PROVIDERS { + return Err("too many segments"); + } + + let mut total_text_bytes = 0usize; + for segment in left.iter().chain(right) { + if segment.text.is_empty() { + return Err("empty segment text"); + } + if segment.text.len() > MAX_STATUSLINE_SEGMENT_BYTES { + return Err("segment text too long"); + } + if segment.text.chars().any(char::is_control) { + return Err("segment text contains a control character"); + } + total_text_bytes = total_text_bytes + .checked_add(segment.text.len()) + .ok_or("total text length overflow")?; + if total_text_bytes > MAX_STATUSLINE_TOTAL_TEXT_BYTES { + return Err("total segment text too long"); + } + if segment.face.len() > MAX_STATUSLINE_FACE_BYTES { + return Err("segment face too long"); + } + if segment.face.chars().any(char::is_control) { + return Err("segment face contains a control character"); + } + if !is_modeline_face_name(&segment.face) { + return Err("segment face is outside ui.modeline"); + } + } + Ok(()) +} + /// The wire-authoritative status facts (Q#S1, protocol v8; `message` /// since v15), mirrored from `InstanceMessage::StatusFacts`. #[derive(Clone, Debug, PartialEq, Eq)] @@ -2172,6 +2223,7 @@ impl State { Some(config.width as f32), Some(fm.status_band_height()), ); + status_buffer.set_wrap(&mut font_system, Wrap::None); let mut status_left_buffer = Buffer::new( &mut font_system, Metrics::new(fm.status_font_size(), fm.status_line_height()), @@ -2181,6 +2233,7 @@ impl State { Some(config.width as f32), Some(fm.status_band_height()), ); + status_left_buffer.set_wrap(&mut font_system, Wrap::None); let mut menu_buffer = Buffer::new( &mut font_system, Metrics::new(fm.menu_font_size(), fm.menu_line_height()), @@ -2294,9 +2347,10 @@ impl State { caret_vertex_buffer: ReusableVertexBuffer::new(), minimap_vertex_buffer: ReusableVertexBuffer::new(), status_buffer, - status_text: String::new(), + status_runs: None, status_left_buffer, - status_left_text: String::new(), + status_left_runs: None, + statusline_segments: None, status_facts: None, search_prompt: None, minibuffer: None, @@ -2792,6 +2846,9 @@ impl State { self.search_prompt = None; self.menu = None; self.status_facts = None; + self.statusline_segments = None; + self.status_runs = None; + self.status_left_runs = None; self.cursor_fresh = false; self.optimistic_cursor_floor = None; self.optimistic_floor_set_at = None; @@ -3042,8 +3099,8 @@ impl State { // would keep stale colors indefinitely without this. InstanceMessage::ThemeFacts { faces } => { self.faces = faces.into_iter().map(|f| (f.name, f.style)).collect(); - self.status_text.clear(); - self.status_left_text.clear(); + self.status_runs = None; + self.status_left_runs = None; self.request_redraw(); None } @@ -3262,6 +3319,30 @@ impl State { self.request_redraw(); None } + // Arc 4 stage 3 (Q#SL7/Q#SL10) — validate the entire + // untrusted replacement before changing either side. + InstanceMessage::StatuslineSegments { + buffer_id, + left, + right, + } => { + if let Err(reason) = validate_statusline_segments(&left, &right) { + eprintln!("pmacs-gpu: ignoring invalid StatuslineSegments: {reason}"); + return None; + } + let next = StatuslineSegmentsLocal { + buffer_id, + left, + right, + }; + if self.statusline_segments.as_ref() != Some(&next) { + self.statusline_segments = Some(next); + self.status_runs = None; + self.status_left_runs = None; + self.request_redraw(); + } + None + } // Arc 4 stage 2 (framing Q#F6/Q#F7) — the global font // preference. Authoritative per attachment: `(None, None)` // is a real reset to the sanitized defaults, never @@ -3941,14 +4022,10 @@ impl State { Some(self.face_wash_or(name, fallback)) } - /// The band's left-segment text color, mirroring - /// [`Self::compose_status_left`]'s priority: minibuffer/isearch - /// content follows `ui.minibuffer`, a transient message follows - /// `ui.statusline`, and the buffer name follows `ui.modeline` - /// (the framing's content-class applicability, Q#TH3). + /// The band's left-segment text color, mirroring the content + /// precedence in [`Self::compose_status_left_runs`]. fn status_left_color(&self) -> Color { - const LEFT_DEFAULT: (u8, u8, u8) = (200, 200, 210); - let fallback = Color::rgb(LEFT_DEFAULT.0, LEFT_DEFAULT.1, LEFT_DEFAULT.2); + let fallback = Color::rgb(200, 200, 210); if self.minibuffer.is_some() || self .search_prompt @@ -3965,39 +4042,76 @@ impl State { if has_message { return self.face_fg_or("ui.statusline", fallback); } - self.modeline_face_colors().map_or(fallback, |(_, t)| t) + self.modeline_face_colors() + .map_or(fallback, |(_, text)| text) } - /// Compose the status-band readout (Q#S1): diagnostic counts - /// (wire-authoritative, severity-colored, omitted when zero), - /// then cursor L:C from the *optimistic* caret (so it tracks - /// typing bursts instead of lagging a round trip), then the - /// All/Top/Bot/NN% scroll indicator. Returns the colored spans. - fn compose_status_spans(&self) -> Vec<(String, Option)> { - use std::fmt::Write as _; - let mut spans: Vec<(String, Option)> = Vec::new(); - if let Some(facts) = self - .status_facts + fn status_right_base_color(&self) -> Color { + self.modeline_face_colors() + .map_or(Color::rgb(168, 168, 180), |(_, text)| text) + } + + /// Resolve an exact custom face against `ThemeFacts`. The producer + /// already normalizes custom entries to an {fg}-only style; absent + /// entries, `ui.modeline`, and defensive `Default` all select the + /// effective base modeline color. + fn status_segment_color(&self, face: &str, base: Color) -> Color { + if face == "ui.modeline" { + return base; + } + self.faces + .get(face) + .and_then(|style| cell_color_to_glyphon(style.fg)) + .unwrap_or(base) + } + + fn current_statusline_segments(&self) -> Option<&StatuslineSegmentsLocal> { + self.statusline_segments .as_ref() - .filter(|f| Some(f.buffer_id) == self.current_buffer_id) - { - if facts.diag_errors > 0 { - // Themes Q#TH5: the counters follow the diag faces - // (fg mask; the shaping-cache invalidation in the - // ThemeFacts arm makes a recolor with constant counts - // actually re-shape, Q#TH8). - spans.push(( - format!("E:{}", facts.diag_errors), - Some(self.diag_face_fg_or("ui.diag.error", Color::rgb(241, 76, 76))), - )); - } - if facts.diag_warnings > 0 { - spans.push(( - format!("W:{}", facts.diag_warnings), - Some(self.diag_face_fg_or("ui.diag.warning", Color::rgb(245, 245, 67))), + .filter(|segments| Some(segments.buffer_id) == self.current_buffer_id) + } + + /// Compose the protected right group. Custom providers precede the + /// legacy diagnostic/cursor/scroll suffix. Custom boundaries are one + /// base-colored space; the built-in suffix retains its exact two-space + /// separators. + fn compose_status_runs(&self) -> Vec<(String, Color)> { + use std::fmt::Write as _; + + let base = self.status_right_base_color(); + let mut runs = Vec::new(); + if let Some(custom) = self.current_statusline_segments() { + for segment in &custom.right { + if !runs.is_empty() { + runs.push((" ".to_owned(), base)); + } + runs.push(( + segment.text.clone(), + self.status_segment_color(&segment.face, base), )); } } + + let mut builtins = Vec::new(); + if let Some(facts) = self + .status_facts + .as_ref() + .filter(|facts| Some(facts.buffer_id) == self.current_buffer_id) + { + if facts.diag_errors > 0 { + builtins.push(( + format!("E:{}", facts.diag_errors), + self.diag_face_fg_or("ui.diag.error", Color::rgb(241, 76, 76)), + )); + } + if facts.diag_warnings > 0 { + builtins.push(( + format!("W:{}", facts.diag_warnings), + self.diag_face_fg_or("ui.diag.warning", Color::rgb(245, 245, 67)), + )); + } + } + let mut readout = String::new(); let mut cursor_row = self.scroll_top; if let Some(own) = self.own_cursor @@ -4009,14 +4123,14 @@ impl State { ); let line = self .current_line_starts - .partition_point(|&s| s as usize <= byte) + .partition_point(|&start| start as usize <= byte) .saturating_sub(1); cursor_row = line; - let ls = self.current_line_starts.get(line).copied().unwrap_or(0) as usize; + let line_start = self.current_line_starts.get(line).copied().unwrap_or(0) as usize; let col = self .current_text - .get(ls..byte) - .map_or(0, |s| s.chars().count()); + .get(line_start..byte) + .map_or(0, |text| text.chars().count()); let _ = write!(readout, "L{}:C{}", line + 1, col + 1); readout.push_str(" "); } @@ -4026,90 +4140,107 @@ impl State { self.current_line_starts.len(), cursor_row, )); - spans.push((readout, None)); - spans + builtins.push((readout, base)); + + if !runs.is_empty() { + runs.push((" ".to_owned(), base)); + } + for (index, builtin) in builtins.into_iter().enumerate() { + if index > 0 { + runs.push((" ".to_owned(), base)); + } + runs.push(builtin); + } + runs } - /// The band's left side. While an incremental search is running - /// (Q#SR5) it shows `I-search: (n/m)` — the prompt takes - /// over the band like Emacs's echo area, returning to the buffer - /// name + modified dot (v8 `StatusFacts`) when the search ends. - fn compose_status_left(&self) -> String { - // Q#MB1 — an open minibuffer takes over the band: prompt + input - // (the candidates render separately as a dropdown). Measured by - // the band caret, so it must stay exactly `prompt + input`. - if let Some(mb) = self.minibuffer.as_ref() { - return format!("{}{}", mb.prompt, mb.input); + /// Compose the left group. Minibuffer, isearch, and transient + /// messages suppress custom left segments; ordinary buffer identity + /// starts at the leading edge but may be fully clipped by the right group. + fn compose_status_left_runs(&self) -> Vec<(String, Color)> { + if let Some(minibuffer) = self.minibuffer.as_ref() { + return vec![( + format!("{}{}", minibuffer.prompt, minibuffer.input), + self.status_left_color(), + )]; } - if let Some(sp) = self + if let Some(search) = self .search_prompt .as_ref() - .filter(|s| Some(s.buffer_id) == self.current_buffer_id) + .filter(|search| Some(search.buffer_id) == self.current_buffer_id) { - let label = if sp.regex { + let label = if search.regex { "Regex I-search: " } else { "I-search: " }; - let count = if sp.query.is_empty() { + let count = if search.query.is_empty() { String::new() - } else if sp.invalid { - " [invalid]".to_string() - } else if sp.total == 0 { - " [no match]".to_string() + } else if search.invalid { + " [invalid]".to_owned() + } else if search.total == 0 { + " [no match]".to_owned() } else { - format!(" ({}/{})", sp.active.map_or(0, |a| a + 1), sp.total) + format!( + " ({}/{})", + search.active.map_or(0, |active| active + 1), + search.total + ) }; - return format!("{}{}{}", label, sp.query, count); + return vec![( + format!("{label}{}{count}", search.query), + self.status_left_color(), + )]; } - // A transient status message (v15 `StatusFacts.message` — LSP - // command summaries like "12 references", error reports) takes - // the band over echo-area style; the daemon clears it on the - // next keypress, which ships a fresh `StatusFacts` and returns - // the band to the buffer name. - if let Some(msg) = self + if let Some(message) = self .status_facts .as_ref() - .filter(|f| Some(f.buffer_id) == self.current_buffer_id) - .and_then(|f| f.message.as_deref()) + .filter(|facts| Some(facts.buffer_id) == self.current_buffer_id) + .and_then(|facts| facts.message.as_deref()) { - return msg.to_owned(); + return vec![(message.to_owned(), self.status_left_color())]; } - match self + + let base = self.status_left_color(); + let identity = match self .status_facts .as_ref() - .filter(|f| Some(f.buffer_id) == self.current_buffer_id) + .filter(|facts| Some(facts.buffer_id) == self.current_buffer_id) { Some(facts) if facts.modified => format!("{} ●", facts.name), Some(facts) => facts.name.clone(), None => String::new(), + }; + let mut runs = Vec::new(); + if !identity.is_empty() { + runs.push((identity, base)); } + if let Some(custom) = self.current_statusline_segments() { + for segment in &custom.left { + if !runs.is_empty() { + runs.push((" ".to_owned(), base)); + } + runs.push(( + segment.text.clone(), + self.status_segment_color(&segment.face, base), + )); + } + } + runs } - /// Re-shape the status-band text iff the composed content - /// changed (short lines — shaping is trivial, but not free per - /// frame). + /// Re-shape only when the complete ordered rich-run key changes. + /// Cache advancement follows successful installation and shaping. fn refresh_status_line(&mut self) { - let spans = self.compose_status_spans(); - let composed: String = spans - .iter() - .map(|(t, _)| t.as_str()) - .collect::>() - .join(" "); + let right = self.compose_status_runs(); + let left = self.compose_status_left_runs(); let family = self.resolved_family.clone(); let default_attrs = Attrs::new().family(Family::Name(&family)); - if composed != self.status_text { - let mut rich: Vec<(&str, Attrs)> = Vec::new(); - for (i, (t, c)) in spans.iter().enumerate() { - if i > 0 { - rich.push((" ", default_attrs.clone())); - } - let attrs = match c { - Some(color) => default_attrs.clone().color(*color), - None => default_attrs.clone(), - }; - rich.push((t.as_str(), attrs)); - } + + if self.status_runs.as_ref() != Some(&right) { + let rich = right + .iter() + .map(|(text, color)| (text.as_str(), default_attrs.clone().color(*color))); self.status_buffer.set_rich_text( &mut self.font_system, rich, @@ -4119,20 +4250,22 @@ impl State { ); self.status_buffer .shape_until_scroll(&mut self.font_system, false); - self.status_text = composed; + self.status_runs = Some(right); } - let left = self.compose_status_left(); - if left != self.status_left_text { - self.status_left_buffer.set_text( + if self.status_left_runs.as_ref() != Some(&left) { + let rich = left + .iter() + .map(|(text, color)| (text.as_str(), default_attrs.clone().color(*color))); + self.status_left_buffer.set_rich_text( &mut self.font_system, - &left, + rich, &default_attrs, Shaping::Advanced, None, ); self.status_left_buffer .shape_until_scroll(&mut self.font_system, false); - self.status_left_text = left; + self.status_left_runs = Some(left); } } @@ -5006,20 +5139,22 @@ impl State { } else { selected_advance }; - // Rows stay rows: idempotent no-wrap on the popup buffers - // (assembly set it; a set_wrap no-op costs a comparison). + // Every row-oriented surface stays one row across the metric + // transaction, including the two status buffers (Q#SL10). + self.status_buffer + .set_wrap(&mut self.font_system, Wrap::None); + self.status_left_buffer + .set_wrap(&mut self.font_system, Wrap::None); self.menu_buffer.set_wrap(&mut self.font_system, Wrap::None); self.mb_buffer.set_wrap(&mut self.font_system, Wrap::None); self.completion_buffer .set_wrap(&mut self.font_system, Wrap::None); // Metrics + current dimensions atomically on all seven. self.sync_buffer_dimensions(); - // The two string-equality shaping gates (the popups rebuild - // unconditionally per frame). NUL can never equal a composed - // status string, so the next frame re-shapes with new attrs - // even when its composed text is unchanged. - "\0".clone_into(&mut self.status_text); - "\0".clone_into(&mut self.status_left_text); + // Colors and family are attrs embedded in the status buffers. + // `None` forces the next frame to install and shape rich runs. + self.status_runs = None; + self.status_left_runs = None; // Attrs-bearing reshape at the retained scroll (reshape // normalizes it against the FINAL family/metrics/dims). self.reshape(); @@ -5315,15 +5450,15 @@ impl State { let after_minimap = debug_frame().then(std::time::Instant::now); let text_bounds_right = self.text_bounds_right(); - // Right-align the status readout: measure the shaped width - // and place the area flush to the right pad (Q#S2). + // Right-align from the true full shaped width. An over-wide + // custom prefix may put this origin left of the surface; bounds + // clip it while the protected suffix remains pinned. let status_width = self .status_buffer .layout_runs() - .map(|r| r.line_w) + .map(|run| run.line_w) .fold(0.0_f32, f32::max); - let status_left = - (self.config.width as f32 - STATUS_TEXT_PAD - status_width).max(TEXT_LEFT); + let status_left = self.config.width as f32 - STATUS_TEXT_PAD - status_width; let status_top = text_area_bottom(self.config.height, self.fm) + (self.fm.status_band_height() - self.fm.status_line_height()) / 2.0; // UX gutter: the code's left origin (past the gutter) and the @@ -5393,8 +5528,8 @@ impl State { bounds: TextBounds { left: 0, top: text_area_bottom(self.config.height, self.fm).round() as i32, - // Stop before the right-aligned readout. - right: (status_left - STATUS_TEXT_PAD).max(0.0).round() as i32, + // Stop at the right group's actual origin. + right: status_left.max(0.0).round() as i32, bottom: self.config.height.cast_signed(), }, // Themes Q#TH3: the left segment's face follows @@ -6679,6 +6814,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::CompletionPopup { .. } => "CompletionPopup", InstanceMessage::ThemeFacts { .. } => "ThemeFacts", InstanceMessage::FontFacts { .. } => "FontFacts", + InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments", } } @@ -9504,6 +9640,493 @@ mod tests { } bounds } + fn statusline_segment(text: impl Into, face: impl Into) -> StatuslineSegment { + StatuslineSegment { + text: text.into(), + face: face.into(), + } + } + + fn apply_statusline( + state: &mut State, + buffer_id: BufferId, + left: Vec, + right: Vec, + ) { + let _ = state.apply_attach_message(InstanceMessage::StatuslineSegments { + buffer_id, + left, + right, + }); + } + + fn status_facts(buffer_id: BufferId, message: Option<&str>) -> StatusFactsLocal { + StatusFactsLocal { + buffer_id, + name: "main.rs".to_owned(), + modified: true, + diag_errors: 1, + diag_warnings: 2, + message: message.map(str::to_owned), + } + } + + #[test] + fn statusline_wire_validation_is_atomic_and_accepts_exact_boundaries() { + let Some(mut state) = headless_or_skip(420, 260, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + apply_statusline( + &mut state, + buffer_id, + vec![statusline_segment("valid", "ui.modeline.good")], + vec![statusline_segment("right", "ui.modeline")], + ); + let valid_frame = state.render_offscreen(); + let valid_state = state + .statusline_segments + .clone() + .expect("valid payload installed"); + let valid_right_cache = state.status_runs.clone(); + let valid_left_cache = state.status_left_runs.clone(); + + let invalid_payloads = vec![ + (vec![statusline_segment("", "ui.modeline")], Vec::new()), + ( + vec![statusline_segment( + "x".repeat(MAX_STATUSLINE_SEGMENT_BYTES + 1), + "ui.modeline", + )], + Vec::new(), + ), + ( + vec![statusline_segment("bad\ntext", "ui.modeline")], + Vec::new(), + ), + ( + vec![statusline_segment( + "bad-face", + format!("ui.modeline.{}", "x".repeat(MAX_STATUSLINE_FACE_BYTES)), + )], + Vec::new(), + ), + ( + vec![statusline_segment("bad-face", "ui.modeline.\u{7f}")], + Vec::new(), + ), + ( + vec![statusline_segment("wrong-family", "ui.statusline")], + Vec::new(), + ), + ( + (0..=MAX_STATUSLINE_PROVIDERS) + .map(|index| statusline_segment(format!("s{index}"), "ui.modeline")) + .collect(), + Vec::new(), + ), + ]; + for (left, right) in invalid_payloads { + apply_statusline(&mut state, buffer_id, left, right); + assert_eq!(state.statusline_segments.as_ref(), Some(&valid_state)); + assert_eq!(state.status_runs, valid_right_cache); + assert_eq!(state.status_left_runs, valid_left_cache); + assert_eq!( + state.render_offscreen(), + valid_frame, + "a rejected replacement must retain the prior frame byte-for-byte" + ); + } + + let max_face = format!( + "ui.modeline.{}", + "f".repeat(MAX_STATUSLINE_FACE_BYTES - "ui.modeline.".len()) + ); + let boundary: Vec<_> = (0..MAX_STATUSLINE_PROVIDERS) + .map(|_| statusline_segment("x".repeat(MAX_STATUSLINE_SEGMENT_BYTES), &max_face)) + .collect(); + assert_eq!( + boundary + .iter() + .map(|segment| segment.text.len()) + .sum::(), + MAX_STATUSLINE_TOTAL_TEXT_BYTES + ); + apply_statusline(&mut state, buffer_id, boundary, Vec::new()); + let installed = state.statusline_segments.as_ref().expect("boundary valid"); + assert_eq!(installed.left.len(), MAX_STATUSLINE_PROVIDERS); + assert_eq!(installed.left[0].face.len(), MAX_STATUSLINE_FACE_BYTES); + } + + #[test] + fn buffer_snapshot_clears_statusline_mirror_but_keeps_theme_facts() { + let Some(mut state) = headless_or_skip(320, 240, "same") else { + return; + }; + let first = BufferId::next(); + state.current_buffer_id = Some(first); + apply_faces( + &mut state, + vec![theme_face( + "ui.modeline.custom", + CellStyle { + fg: CellColor::Rgb(10, 20, 30), + ..CellStyle::default() + }, + )], + ); + apply_statusline( + &mut state, + first, + vec![statusline_segment("old", "ui.modeline.custom")], + Vec::new(), + ); + let _ = state.render_offscreen(); + assert!(state.statusline_segments.is_some()); + + let doc = loro::LoroDoc::new(); + doc.get_text(LORO_TEXT_CONTAINER) + .insert(0, "same") + .expect("snapshot text"); + let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot { + buffer_id: BufferId::next(), + crdt_snapshot: doc.export(loro::ExportMode::Snapshot).expect("snapshot"), + }); + assert!(state.statusline_segments.is_none()); + assert!(state.status_runs.is_none()); + assert!(state.status_left_runs.is_none()); + assert!(state.faces.contains_key("ui.modeline.custom")); + } + + #[test] + fn statusline_rich_runs_preserve_builtins_separators_and_face_changes() { + let Some(mut state) = headless_or_skip(500, 280, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + state.own_cursor = Some(OwnCursor { buffer_id, byte: 0 }); + apply_faces( + &mut state, + vec![ + theme_face( + "ui.modeline.red", + CellStyle { + fg: CellColor::Rgb(230, 20, 30), + ..CellStyle::default() + }, + ), + theme_face( + "ui.modeline.green", + CellStyle { + fg: CellColor::Rgb(20, 220, 40), + ..CellStyle::default() + }, + ), + ], + ); + apply_statusline( + &mut state, + buffer_id, + vec![ + statusline_segment("L1", "ui.modeline.red"), + statusline_segment("L2", "ui.modeline"), + ], + vec![ + statusline_segment("R1", "ui.modeline.green"), + statusline_segment("R2", "ui.modeline"), + ], + ); + + let left = state.compose_status_left_runs(); + let right = state.compose_status_runs(); + let left_text: String = left.iter().map(|(text, _)| text.as_str()).collect(); + let right_text: String = right.iter().map(|(text, _)| text.as_str()).collect(); + assert_eq!(left_text, "main.rs ● L1 L2"); + assert_eq!(right_text, "R1 R2 E:1 W:2 L1:C1 All"); + let left_base = state.status_left_color(); + assert_eq!(left[1], (" ".to_owned(), left_base)); + assert_eq!(left[3], (" ".to_owned(), left_base)); + let right_base = state.status_right_base_color(); + assert_eq!(right[1], (" ".to_owned(), right_base)); + assert_eq!(right[3], (" ".to_owned(), right_base)); + assert_eq!(right[5], (" ".to_owned(), right_base)); + assert_eq!(right[7], (" ".to_owned(), right_base)); + assert_eq!( + left[2].1, + Color::rgb(230, 20, 30), + "custom text takes the exact ThemeFacts foreground" + ); + assert_eq!(right[0].1, Color::rgb(20, 220, 40)); + + let _ = state.render_offscreen(); + let before_text: String = state + .status_left_runs + .as_ref() + .expect("left shaped") + .iter() + .map(|(text, _)| text.as_str()) + .collect(); + apply_statusline( + &mut state, + buffer_id, + vec![ + statusline_segment("L1", "ui.modeline.green"), + statusline_segment("L2", "ui.modeline"), + ], + vec![ + statusline_segment("R1", "ui.modeline.green"), + statusline_segment("R2", "ui.modeline"), + ], + ); + assert!(state.status_runs.is_none()); + assert!(state.status_left_runs.is_none()); + let _ = state.render_offscreen(); + let after = state.status_left_runs.as_ref().expect("left reshaped"); + assert_eq!( + after + .iter() + .map(|(text, _)| text.as_str()) + .collect::(), + before_text, + "changing only the face name keeps concatenated text constant" + ); + assert_eq!(after[2].1, Color::rgb(20, 220, 40)); + } + + #[test] + fn modal_left_precedence_suppresses_custom_left_but_preserves_right() { + let Some(mut state) = headless_or_skip(420, 260, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + apply_statusline( + &mut state, + buffer_id, + vec![statusline_segment("CUSTOM-L", "ui.modeline")], + vec![statusline_segment("CUSTOM-R", "ui.modeline")], + ); + assert!(state.compose_status_left_runs()[2].0.contains("CUSTOM-L")); + let ordinary_right = state.compose_status_runs(); + + state.minibuffer = Some(MinibufferLocal { + prompt: "M-x ".to_owned(), + input: "find".to_owned(), + cursor: 4, + candidates: Vec::new(), + selected: None, + total: 0, + }); + assert_eq!(state.compose_status_left_runs()[0].0, "M-x find"); + assert_eq!(state.compose_status_runs(), ordinary_right); + + state.minibuffer = None; + state.search_prompt = Some(SearchPromptLocal { + buffer_id, + query: "needle".to_owned(), + active: Some(0), + total: 1, + regex: false, + invalid: false, + }); + assert_eq!( + state.compose_status_left_runs()[0].0, + "I-search: needle (1/1)" + ); + assert_eq!(state.compose_status_runs(), ordinary_right); + + state.search_prompt = None; + state.status_facts = Some(status_facts(buffer_id, Some("CUSTOM-L"))); + assert_eq!(state.compose_status_left_runs()[0].0, "CUSTOM-L"); + assert_eq!(state.compose_status_left_runs().len(), 1); + assert_eq!(state.compose_status_runs(), ordinary_right); + + state.status_facts = Some(status_facts(buffer_id, None)); + assert!(state.compose_status_left_runs()[2].0.contains("CUSTOM-L")); + } + + #[test] + fn theme_recolor_invalidates_both_rich_caches_and_repaints_custom_text() { + let (width, height) = (420, 260); + let Some(mut state) = headless_or_skip(width, height, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + apply_statusline( + &mut state, + buffer_id, + vec![statusline_segment("RECOLOR", "ui.modeline.custom")], + vec![statusline_segment("RECOLOR", "ui.modeline.custom")], + ); + apply_faces( + &mut state, + vec![theme_face( + "ui.modeline.custom", + CellStyle { + fg: CellColor::Rgb(240, 10, 20), + ..CellStyle::default() + }, + )], + ); + let red = state.render_offscreen(); + assert_eq!( + state.status_left_runs.as_ref().expect("left shaped")[2].1, + Color::rgb(240, 10, 20) + ); + + apply_faces( + &mut state, + vec![theme_face( + "ui.modeline.custom", + CellStyle { + fg: CellColor::Rgb(10, 220, 40), + ..CellStyle::default() + }, + )], + ); + assert!(state.status_runs.is_none()); + assert!(state.status_left_runs.is_none()); + let green = state.render_offscreen(); + assert_ne!(red, green, "constant text must repaint after ThemeFacts"); + assert_eq!( + state.status_left_runs.as_ref().expect("left reshaped")[2].1, + Color::rgb(10, 220, 40) + ); + let (_, min_y, _, max_y) = + frame_diff_bounds(&red, &green, width).expect("recolor changes pixels"); + assert!( + min_y >= text_area_bottom(height, state.fm).floor() as u32 && max_y <= height, + "the recolor stays inside the status band" + ); + } + + #[test] + fn built_in_only_overwide_readout_clips_left_and_keeps_its_right_tail_pinned() { + let (narrow_width, wide_width, height) = (96, 500, 260); + let Some(mut narrow) = headless_or_skip(narrow_width, height, "text") else { + return; + }; + let Some(mut wide) = headless_or_skip(wide_width, height, "text") else { + return; + }; + for state in [&mut narrow, &mut wide] { + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + state.own_cursor = Some(OwnCursor { buffer_id, byte: 0 }); + } + + let narrow_frame = narrow.render_offscreen(); + let wide_frame = wide.render_offscreen(); + assert!( + narrow.statusline_segments.is_none() && wide.statusline_segments.is_none(), + "fixture must exercise the built-in-only legacy surface" + ); + let narrow_status_width = narrow + .status_buffer + .layout_runs() + .map(|run| run.line_w) + .fold(0.0_f32, f32::max); + let wide_status_width = wide + .status_buffer + .layout_runs() + .map(|run| run.line_w) + .fold(0.0_f32, f32::max); + assert!( + (narrow_status_width - wide_status_width).abs() < 0.01, + "surface width must not reshape the no-wrap readout" + ); + assert!( + narrow_width as f32 - STATUS_TEXT_PAD - narrow_status_width < 0.0, + "fixture must force the built-in readout past the left edge" + ); + assert!( + wide_width as f32 - STATUS_TEXT_PAD - wide_status_width > 0.0, + "comparison surface must fit the complete built-in readout" + ); + + let band_top = text_area_bottom(height, narrow.fm).floor() as u32; + let pinned_tail_width = 80; + for y in band_top..height { + for offset in 0..pinned_tail_width { + assert_eq!( + px_at(&narrow_frame, narrow_width, narrow_width - 1 - offset, y), + px_at(&wide_frame, wide_width, wide_width - 1 - offset, y), + "built-in readout tail moved at right-edge offset {offset}, y={y}" + ); + } + } + } + + #[test] + fn overwide_status_runs_never_wrap_and_keep_the_suffix_pinned() { + let (width, height) = (800, 300); + for size in [600, 7200] { + let Some(mut state) = headless_or_skip(width, height, "text") else { + return; + }; + let buffer_id = BufferId::next(); + state.current_buffer_id = Some(buffer_id); + state.status_facts = Some(status_facts(buffer_id, None)); + state.own_cursor = Some(OwnCursor { buffer_id, byte: 0 }); + state.apply_font_facts(None, Some(size)); + let baseline = state.render_offscreen(); + let suffix_width = state + .status_buffer + .layout_runs() + .map(|run| run.line_w) + .fold(0.0_f32, f32::max); + let suffix_left = (width as f32 - STATUS_TEXT_PAD - suffix_width) + .max(0.0) + .ceil() as u32; + + apply_statusline( + &mut state, + buffer_id, + vec![statusline_segment( + "L".repeat(MAX_STATUSLINE_SEGMENT_BYTES), + "ui.modeline", + )], + vec![statusline_segment( + "R".repeat(MAX_STATUSLINE_SEGMENT_BYTES), + "ui.modeline", + )], + ); + let overwide = state.render_offscreen(); + let full_width = state + .status_buffer + .layout_runs() + .map(|run| run.line_w) + .fold(0.0_f32, f32::max); + let actual_origin = width as f32 - STATUS_TEXT_PAD - full_width; + assert!(actual_origin < 0.0, "fixture must cross the left edge"); + assert_eq!(state.status_buffer.wrap(), Wrap::None); + assert_eq!(state.status_left_buffer.wrap(), Wrap::None); + assert_eq!(state.status_buffer.layout_runs().count(), 1); + assert_eq!(state.status_left_buffer.layout_runs().count(), 1); + + let band_top = text_area_bottom(height, state.fm).floor() as u32; + for y in band_top..height { + for x in suffix_left..width { + assert_eq!( + px_at(&overwide, width, x, y), + px_at(&baseline, width, x, y), + "protected suffix pixel moved at size {size}, ({x},{y})" + ); + } + } + let (_, min_y, _, max_y) = + frame_diff_bounds(&baseline, &overwide, width).expect("custom text paints"); + assert!(min_y >= band_top && max_y <= height); + } + } #[test] fn headless_theme_facts_empty_table_renders_identically() { @@ -10426,9 +11049,8 @@ mod tests { } } - /// Acceptance 13 — the two string-equality status caches drop on - /// a font change, so an unchanged composed status re-shapes with - /// the new attrs on the next frame. + /// Acceptance 13 — the rich-run status caches drop on a font + /// change, so unchanged content re-shapes with new attrs. #[test] #[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums fn font_change_invalidates_the_status_shaping_caches() { @@ -10436,27 +11058,25 @@ mod tests { return; }; let _ = state.render_offscreen(); - let composed_before = state.status_text.clone(); - assert!( - !composed_before.is_empty(), - "precondition: a frame composed the status readout" - ); + let right_before = state.status_runs.clone().expect("right cache shaped"); + let left_before = state + .status_left_runs + .clone() + .expect("left cache shaped, including empty content"); + assert!(!right_before.is_empty(), "the readout is always present"); + state.apply_attach_message(font_facts(None, Some(3200))); - assert_eq!( - state.status_text, "\0", - "the sentinel must defeat the string-equality gate" - ); - assert_eq!(state.status_left_text, "\0"); + assert!(state.status_runs.is_none()); + assert!(state.status_left_runs.is_none()); + let _ = state.render_offscreen(); assert_eq!( state.status_buffer.metrics().font_size, state.fm.status_font_size(), "the re-shaped band must carry the derived metrics" ); - assert_eq!( - state.status_text, composed_before, - "same composed text, re-shaped anyway" - ); + assert_eq!(state.status_runs.as_ref(), Some(&right_before)); + assert_eq!(state.status_left_runs.as_ref(), Some(&left_before)); } /// Acceptance 14 — a size that shrinks the visible line count diff --git a/pmacs-protocol/src/lib.rs b/pmacs-protocol/src/lib.rs index 1585558..70377f1 100644 --- a/pmacs-protocol/src/lib.rs +++ b/pmacs-protocol/src/lib.rs @@ -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}; diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 6ff187e..fe88144 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -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, }, + /// 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, + /// Right-side custom segments in display order. + right: Vec, + }, } /// 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`]. diff --git a/src/daemon.rs b/src/daemon.rs index 3cff08c..3587622 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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())); diff --git a/src/editor.rs b/src/editor.rs index 7bc447d..20c91d9 100644 --- a/src/editor.rs +++ b/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, @@ -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 = + 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 { + 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::() + }); + 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> { + 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 = 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::(); + 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::(); + let right_mode = (60..120) + .map(|col| glyph_at(&cells, stride, 22, col)) + .collect::(); + 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::("window").unwrap(), + table.get::("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::(); + let narrow_right = (15..30) + .map(|col| glyph_at(&narrow_cells, narrow_stride, 22, col)) + .collect::(); + 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::(); + 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 { diff --git a/src/frontend.rs b/src/frontend.rs index 108798f..8fe6831 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -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 { diff --git a/src/highlight.rs b/src/highlight.rs index a74176a..487afb9 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -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