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