From f16c98b56bf112c7a6fd8c4c407743cf8f3235e5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 20 Jul 2026 10:52:07 -0400 Subject: [PATCH 1/6] docs(statusline): preserve stage-3 framing for review Checkpoint revision 1 of the Arc 4 stage-3 statusline-segments framing on its own portable branch. The framing remains awaiting user review; there is no implementation or PR. --- docs/statusline-segments-framing.md | 888 ++++++++++++++++++++++++++++ 1 file changed, 888 insertions(+) create mode 100644 docs/statusline-segments-framing.md diff --git a/docs/statusline-segments-framing.md b/docs/statusline-segments-framing.md new file mode 100644 index 0000000..7366877 --- /dev/null +++ b/docs/statusline-segments-framing.md @@ -0,0 +1,888 @@ +# Statusline segments - framing (Arc 4 stage 3) + +**Revision 1 - 2026-07-18. Transfer status updated 2026-07-20: +framing preserved on branch `statusline-segments`, based on `main` at +`f8096ff` (protocol v17); awaiting review. No implementation or PR.** + +Revision 1: initial framing after the 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. + +## Ground truth (as of `main` at `f8096ff`, 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 labels are + the bounded set `init`, `ready`, `idx`, `degraded`, `crashed`, and + `stopped`. +- 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 a provider makes this frame fail closed; + the next frame recomputes against the new truth. + +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. +- Evaluation snapshots definitions before calls; a provider may + unregister itself without a `RefCell` panic. The epoch guard drops the + old fan-out's result. +- 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-ins remain protected: + +- **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. +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 + protected 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, + each frontend retains today's terminal/pixel clipping behavior. +- The left group gets the space before the right group's measured + origin and clips at the collision boundary. It never overwrites the + right group. + +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. + +### 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: + +- 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. +- Right suffix clipping keeps the display-column suffix, preserving the + built-in tail; 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. Modal/message states produce + their existing single content run and no custom left runs. +- 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. The left TextArea clips at + the right group's actual origin. 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 GPU pixels are byte-identical to the pre-stage + modeline/status band. 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. +6. **Re-entrant registry mutation:** a provider unregistering itself + during evaluation causes no borrow panic, discards the old fan-out by + epoch guard, and is absent on the next frame. +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. +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 clips the + low-priority edges while retaining the protected built-in tail and + never writes outside its rect. +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; 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. From fe1dc7ad946f6612708ed28b9e5129b3f0ece87f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 10:03:25 -0400 Subject: [PATCH 2/6] docs(statusline): close review contracts in revision 2 Specify authoritative invalidation, safe grapheme runs, separator faces, detach-aware latches, and the unknown LSP label. --- docs/statusline-segments-framing.md | 94 ++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 22 deletions(-) diff --git a/docs/statusline-segments-framing.md b/docs/statusline-segments-framing.md index 7366877..ae75246 100644 --- a/docs/statusline-segments-framing.md +++ b/docs/statusline-segments-framing.md @@ -1,10 +1,13 @@ # Statusline segments - framing (Arc 4 stage 3) -**Revision 1 - 2026-07-18. Transfer status updated 2026-07-20: -framing preserved on branch `statusline-segments`, based on `main` at -`f8096ff` (protocol v17); awaiting review. No implementation or PR.** +**Revision 2 - 2026-07-21. Framing preserved on branch +`statusline-segments`; re-scouted against `main` `bb17ec9` (protocol v17) +after JSON/YAML #123 merged. No relevant substrate drift; awaiting review. +No implementation or PR.** -Revision 1: initial framing after the post-#124 architecture scout. +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 @@ -15,7 +18,7 @@ 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. -## Ground truth (as of `main` at `f8096ff`, protocol v17) +## Ground truth (as of `main` at `bb17ec9`, protocol v17) ### There are two different bottom surfaces in the TUI @@ -142,9 +145,10 @@ completes Arc 4. ### 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 labels are - the bounded set `init`, `ready`, `idx`, `degraded`, `crashed`, and - `stopped`. + 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`). @@ -339,8 +343,15 @@ Evaluation is a three-phase, borrow-released transaction: 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 a provider makes this frame fail closed; - the next frame recomputes against the new truth. + 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: [] }`, clears the + corresponding baseline only after queuing that replacement, and + discards every evaluated result. If the viewport itself changed, it + first clears the prior matching buffer's mirror this way, then the + next frame evaluates the new truth. Thus no callback mutation can + leave a prior non-empty GPU payload resident indefinitely. The TUI calls the evaluator at the start of `paint_frame`, before the long-lived mutable core borrow. `SemanticRenderState::render_frame` @@ -364,9 +375,13 @@ Provider failures are independent: 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. + 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 @@ -390,6 +405,11 @@ Current built-ins remain protected: 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 @@ -508,7 +528,11 @@ 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. +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 @@ -602,6 +626,14 @@ case. 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 @@ -617,6 +649,8 @@ single-row painter: 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. - Right suffix clipping keeps the display-column suffix, preserving the built-in tail; left clipping keeps the prefix. No run can write outside its window rect or into another split's modeline. @@ -637,8 +671,11 @@ same belt as `StatusFacts`. `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. Modal/message states produce - their existing single content run and no custom left runs. + 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. @@ -786,10 +823,17 @@ TUI, producer, and daemon/wire behavior; protocol pins stay in 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. + 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, discards the old fan-out by - epoch guard, and is absent on the next frame. + 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 + provider is absent on the next frame. 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. @@ -800,7 +844,10 @@ TUI, producer, and daemon/wire behavior; protocol pins stay in 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. + 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. @@ -809,12 +856,15 @@ TUI, producer, and daemon/wire behavior; protocol pins stay in cluster/continuation cells and no overlap; the combining sequence is emitted rather than silently dropped. A narrow split clips the low-priority edges while retaining the protected built-in tail and - never writes outside its rect. + never writes outside its rect. 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; a passive split uses its own - attachment. Disabling/reprioritizing the discovered provider handle - works. + 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 From ac7f776c3a27408e6debf686b4bbeecaad8bfc3b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 10:22:13 -0400 Subject: [PATCH 3/6] docs(statusline): close revision 3 review findings Retain authoritative-empty payloads as emission baselines and pin the legacy TUI behavior when the protected suffix itself cannot fit. --- docs/statusline-segments-framing.md | 57 ++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/docs/statusline-segments-framing.md b/docs/statusline-segments-framing.md index ae75246..8eabf07 100644 --- a/docs/statusline-segments-framing.md +++ b/docs/statusline-segments-framing.md @@ -1,10 +1,13 @@ # Statusline segments - framing (Arc 4 stage 3) -**Revision 2 - 2026-07-21. Framing preserved on branch +**Revision 3 - 2026-07-21. Framing preserved on branch `statusline-segments`; re-scouted against `main` `bb17ec9` (protocol v17) after JSON/YAML #123 merged. No relevant substrate drift; awaiting review. No implementation or PR.** +Revision 3: closes review findings on authoritative-empty baseline retention +and the TUI's protected-suffix clipping boundary. + 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. @@ -346,12 +349,20 @@ Evaluation is a three-phase, borrow-released transaction: 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: [] }`, clears the - corresponding baseline only after queuing that replacement, and - discards every evaluated result. If the viewport itself changed, it - first clears the prior matching buffer's mirror this way, then the - next frame evaluates the new truth. Thus no callback mutation can - leave a prior non-empty GPU payload resident indefinitely. + 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` @@ -426,7 +437,10 @@ Priority means **survival priority when horizontal space is tight**: 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, - each frontend retains today's terminal/pixel clipping behavior. + each frontend retains today's behavior: the TUI drops the right group + wholesale, while the GPU keeps its right edge fixed and clips its left + edge. 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. It never overwrites the right group. @@ -651,9 +665,12 @@ single-row painter: `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. -- Right suffix clipping keeps the display-column suffix, preserving the - built-in tail; left clipping keeps the prefix. No run can write - outside its window rect or into another split's modeline. +- 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. @@ -833,7 +850,9 @@ TUI, producer, and daemon/wire behavior; protocol pins stay in 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 - provider is absent on the next frame. + 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. @@ -854,12 +873,14 @@ TUI, producer, and daemon/wire behavior; protocol pins stay in 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 clips the - low-priority edges while retaining the protected built-in tail and - never writes outside its rect. 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. + 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 From 4b65b9e1e538a3e2ad9220d07e0b8e947935228a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 11:24:20 -0400 Subject: [PATCH 4/6] feat(statusline): add composable modeline segments at protocol v18 Add the strict pmacs.statusline provider registry, deterministic borrow-released per-window evaluation, context-scoped failure latches, and a pure built-in LSP provider. Preserve the legacy TUI modeline while composing faced custom runs, and append authoritative complete StatuslineSegments replacements for semantic frontends. Expand dynamic ThemeFacts, reset producer/frontend baselines symmetrically, and gate all provider work off protocol v18. Teach the GPU to atomically validate, resolve, shape, clip, and cache custom modeline runs without displacing the protected status suffix. Document the public Lua lifecycle, wire ownership, snapshot semantics, and the fully gated Arc 4 stage-3 delivery state. Co-authored-by: Claude Opus 4.7 --- Cargo.lock | 1 + Cargo.toml | 1 + builtin/runtime/lsp.lua | 16 + docs/agent-handoff.md | 69 +- docs/package-author-guide.md | 53 +- docs/roadmap-2026-07.md | 16 +- docs/semantic-frontend-protocol.md | 116 ++- docs/statusline-segments-framing.md | 40 +- pmacs-gpu/src/main.rs | 858 ++++++++++++++---- pmacs-protocol/src/lib.rs | 10 +- pmacs-protocol/src/message.rs | 67 +- src/daemon.rs | 43 + src/editor.rs | 646 +++++++++++++- src/frontend.rs | 26 + src/highlight.rs | 74 +- src/lib.rs | 1 + src/lsp.rs | 4 + src/lua_bindings/mod.rs | 273 ++++++ src/protocol.rs | 88 +- src/semantic_render.rs | 438 +++++++++- src/statusline.rs | 1063 +++++++++++++++++++++++ tests/statusline_segments_acceptance.rs | 1028 ++++++++++++++++++++++ 22 files changed, 4621 insertions(+), 310 deletions(-) create mode 100644 src/statusline.rs create mode 100644 tests/statusline_segments_acceptance.rs 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..3dbd2d6 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` @ `f8096ff` (#124, Themes stage 2 merged), protocol + **v17** (`SUPPORTED=[6..17]`). The `statusline-segments` branch is + based there and 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,610 default + 1,784 CRDT library tests; 7 default + + 8 CRDT feature acceptance; 110 M4; 108 required GPU; one-invocation + workspace sweep 2,704 passed across 78 suites (19 ignored, + `basedpyright` filtered); `git diff --check` clean. This branch is + awaiting review and **must not be described as merged**. - Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position: - **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 index 8eabf07..0ae1560 100644 --- a/docs/statusline-segments-framing.md +++ b/docs/statusline-segments-framing.md @@ -1,9 +1,9 @@ # Statusline segments - framing (Arc 4 stage 3) -**Revision 3 - 2026-07-21. Framing preserved on branch -`statusline-segments`; re-scouted against `main` `bb17ec9` (protocol v17) -after JSON/YAML #123 merged. No relevant substrate drift; awaiting review. -No implementation or PR.** +**Revision 3 - 2026-07-21. Implemented on branch +`statusline-segments` against the post-#124 protocol-v17 base. The +implementation 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. @@ -21,6 +21,38 @@ names to semantic frontends at protocol v18, and uses the existing LSP status tracker as the first built-in provider. Completing this stage completes Arc 4. +## Implementation record (2026-07-21) + +The approved Q#SL1-Q#SL11 design is implemented without changing the +framed ownership boundary: + +- `pmacs.statusline` owns a shared editor-global registry with strict + registration, lifecycle/introspection, monotonic layout/face-set + epochs, borrow-released three-phase evaluation, per-context failure + latches, deterministic ordering, and bounded one-line results. +- TUI composition preserves the legacy modeline when providers are + absent, owns separators by adjacent segment face, shapes terminal-safe + grapheme runs, and protects the right diagnostic/cursor/scroll suffix. +- Protocol v18 appends complete `StatuslineSegments` replacements. The + semantic producer distinguishes authoritative empty from no message, + versions provider execution before callbacks, expands dynamic + `ThemeFacts`, and resets buffer baselines symmetrically with + `BufferSnapshot`. +- The GPU consumes v18 atomically, resolves exact dynamic faces, clips + provider runs without wrapping or displacing the protected suffix, + and preserves its prior valid state on malformed input. +- `builtin/runtime/lsp.lua` registers the first pure right-side provider + from its private attachment map; the Rust tracker exposes bounded + `init`/`ready`/`degraded`/`crashed`/`stopped`/unknown labels. + +The final gate run was sequential and clean: `cargo fmt --check`; +workspace/all-target Clippy with `-D warnings`; 1,610 default and 1,784 +CRDT library tests; 7 default and 8 CRDT stage-3 acceptance tests; 110 +M4 acceptance tests (3 ignored, `basedpyright` filtered); 108 required +GPU tests; and the one-invocation workspace sweep (2,704 passed across +78 suites, 19 ignored, `basedpyright` filtered). `git diff --check` was +clean. No flaky rerun was needed. + ## Ground truth (as of `main` at `bb17ec9`, protocol v17) ### There are two different bottom surfaces in the TUI diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 3012479..a2da949 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 + /// stays protected at the leading edge. + 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,435 @@ 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 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 +10991,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 +11000,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