From 4b65b9e1e538a3e2ad9220d07e0b8e947935228a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 11:24:20 -0400 Subject: [PATCH] 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