diff --git a/Cargo.lock b/Cargo.lock index 9ed454f..ba0880f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2594,6 +2594,7 @@ dependencies = [ "postcard", "serde", "thiserror 2.0.18", + "unicode-width", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 166751e..8bfb286 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,10 @@ members = [".", "pmacs-protocol", "pmacs-gpu"] serde = { version = "1", features = ["derive"] } postcard = { version = "1", features = ["use-std"] } thiserror = "2" +# Shared between `pmacs` (terminal screen) and `pmacs-protocol` +# (`TerminalFrame::validate`). Pinned here so the producer and the +# validator agree on every glyph's column width. +unicode-width = "0.2" [package] name = "pmacs" @@ -85,7 +89,7 @@ crdt = ["dep:loro", "pmacs-protocol/crdt"] [dependencies] crossterm = "0.28" thiserror = { workspace = true } -unicode-width = "0.2" +unicode-width = { workspace = true } 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. diff --git a/docs/active-work.md b/docs/active-work.md index 0d9ce3d..4ca124b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -52,42 +52,60 @@ git status --short --branch The first command must expose `1dd47fc` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. -## Vterm Stage 3 framing lane +## Vterm Stage 3 implementation lane -- Portable branch: `githubsucks/vterm-stage3-framing` -- Framing contract commit: `d7bb831`; Revision 8 review fixes: `c72dfea`; - both follow canonical-main integration. +- Portable branch: `githubsucks/vterm-gpu` +- Framing carried as the first commit; implementation follows it. - Base: canonical `main` @ `1dd47fc` (modeline detection #132 atop Vterm - Stage 2 #130), protocol v18. -- PR: none. This is framing only; no Stage 3 implementation branch exists. -- State: `docs/vterm-framing.md` Revision 8 maps criteria 28–37, has passed - one external review, and awaits explicit user approval. It locks additive - protocol v19 `TerminalFrame`, `TerminalResize`, and `TerminalPointer`; an - 8 MiB aggregate glyph-byte bound under the unchanged 16 MiB transport cap; - dual viewport declaration for the first terminal frame; authenticated - per-view semantic routing; and a fixed-cell native GPU renderer/input/cache - contract. -- Stage 2 is landed as PR #130 at merge `86fc1bc`. Stage 3 starts from that - integrated substrate and does not reopen its TUI/Lua/controller contracts. -- Review: no architectural defect. `c72dfea` makes the measured-size fixture - maximize style and cluster-prefix overhead, names the GPU clipboard signal - path without implying child OSC 52 support, and aligns Arc 5/internal-stage - naming. -- Verification: documentation-only `git diff --check`; framing consistency - search. No runtime gates apply before implementation. -- Next: explicit user approval. After approval, create `vterm-gpu` from the - then-current canonical main and implement criteria 28–37; do not stack the - feature on this documentation branch. + Stage 2 #130). Cut from `main`, NOT stacked on `vterm-stage3-framing`, + per the framing's §8. +- PR: open against canonical `main`; never merge without explicit + authorization. +- State: criteria 28-37 implemented. Protocol v19 (`SUPPORTED=[6..=19]`): + `InstanceMessage::TerminalFrame` (discriminant 26, daemon-gated), + `FrontendEvent::TerminalResize` (11) and `TerminalPointer` (12) + (frontend-gated). `pmacs-protocol/src/terminal.rs` owns the shared + bounds and `TerminalFrame::validate`; `pmacs-gpu/src/terminal.rs` is the + pure cell-space paint planner; `pmacs-gpu --headless-probe` drives the + real attach client without winit for criterion 37. +- Verification (clean tree, this machine): + - `cargo fmt --check`; + - `cargo clippy --workspace --all-targets -- -D warnings`; + - `cargo test --lib`: 1,757 passed (3 ignored); + - `cargo test --lib --features crdt`: 1,933 passed (3 ignored); + - vterm Stage 1 acceptance: 9 default / 10 CRDT; + - vterm Stage 2 acceptance: 4 default / 4 CRDT; + - vterm Stage 3 acceptance: 4 default / 5 CRDT (the CRDT-only case is + the real-daemon + real-PTY + headless-GPU path); + - statusline acceptance: 7 default / 8 CRDT; + - `cargo test --test m4_acceptance -- --skip basedpyright`: 120 passed + (3 ignored, 1 filtered); + - `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`: 127 passed; + - `cargo test --workspace -- --skip basedpyright`: 2,919 passed across + 83 suites (19 ignored), one invocation; + - `git diff --check`. +- Open caveat: the required-GPU suite failed ONCE mid-session and did not + reproduce across eight subsequent runs or the full sweep. The failing + test's identity was not captured. Re-run `PMACS_REQUIRE_GPU=1 cargo test + -p pmacs-gpu` a few times on review; if it recurs, capture the name. +- Next: user review rounds on the PR. -Recovery worktree on a machine that does not already own the branch: +Recovery worktree: ```sh git worktree add --track \ - -b vterm-stage3-framing \ - ../pmacs-vterm-stage3-framing \ - githubsucks/vterm-stage3-framing + -b vterm-gpu \ + ../pmacs-vterm-gpu \ + githubsucks/vterm-gpu ``` +## Vterm Stage 3 framing lane (superseded) + +- Portable branch: `githubsucks/vterm-stage3-framing` +- Revision 8 framing, reviewed and approved. Its content is carried on + `vterm-gpu`; this branch is kept only as the approval record and has no + unmerged runtime work. + ## Parked lane: kill-ring browser + persistence - Portable branch: `githubsucks/kill-ring-browser` diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 52e6e9a..9648dfe 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,10 +1,11 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-22, after Vterm Stage 2 landed as PR #130 and +**Last updated: 2026-07-22, after Vterm Stage 3 (protocol v19, GPU +terminal) was implemented on `vterm-gpu`. Vterm Stage 2 landed as PR #130 and modeline detection landed as #132. Mode system wiring (#129), config registry (#127), Vterm Stage 1 terminal core (#126), and completed Themes Arc 4 -(#120/#124/#125) are also on `main`. Vterm Stage 3 framing Revision 8 has -passed one external review and is not implemented.** +(#120/#124/#125) are also on `main`. Stage 3 is implemented on `vterm-gpu` +and awaits review; see `docs/active-work.md`.** 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` @@ -19,7 +20,8 @@ commands, read `docs/active-work.md` immediately after this file. ## 1. Where the project stands (2026-07-22) - `main` @ `1dd47fc` (modeline detection #132 atop Vterm Stage 2 #130), - protocol **v18** (`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = + protocol **v18** on `main`, **v19** on `vterm-gpu` + (`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`). - **Config registry LANDED — #127** (`docs/config-registry-framing.md` rev 3; merge `2e37c04`; two review rounds). `pmacs.config` is the @@ -245,11 +247,38 @@ commands, read `docs/active-work.md` immediately after this file. is a clean behavioral bite. The parser dispatch has its independent clean behavioral bite; the original `main`/crate-root bite remains explicitly weaker compile-time API evidence. - - Stage 3 owns `pmacs-gpu/src/attach.rs`, authenticated source routing, - protocol-owned wire types/limits, and complete-frame semantic projection. - Revision 8 chooses a shared 8 MiB aggregate glyph-byte bound whose measured - legal maximum stays below the unchanged 16 MiB transport cap; over-bound - snapshots are rejected, never truncated or silently chunked. + - **Stage 3 GPU/protocol is IMPLEMENTED on `vterm-gpu`** (framing + `docs/vterm-framing.md` Revision 9, criteria 28-37; awaiting review). + Protocol **v19**: `InstanceMessage::TerminalFrame` (discriminant 26, + daemon-gated) plus `FrontendEvent::TerminalResize` (11) and + `TerminalPointer` (12), both frontend-gated - the first bump gating in + BOTH directions. `SUPPORTED=[6..=19]`. + - `pmacs-protocol/src/terminal.rs` now owns the shared terminal bounds, + `TerminalProcessState`, `TerminalSelectionSpan`, and the single + structural policy `TerminalFrame::validate`; `src/terminal/*` + re-exports them so no duplicate type exists. `unicode-width` is a + workspace dependency so the screen and the validator measure glyph + columns identically. `MAX_TERMINAL_FRAME_GLYPH_BYTES = 8 MiB` bounds + the payload instead of widening the transport cap; the measured + maximum legal frame encodes to 13,437,863 bytes under the unchanged + 16 MiB `MAX_FRAME_BYTES`. Over-bound snapshots are rejected, never + truncated or silently chunked. + - **The `Viewport` gate keys on the AUTHENTICATED SOURCE'S ACTIVE + BUFFER, not the buffer the message names.** `Viewport` also aligns the + window to the buffer it declares, so a stale document viewport in + flight when a command opens a terminal drags the frontend back off it + - the terminal then never paints, with no error anywhere. The weaker + "is the declared buffer a terminal" reading looks right and fails + exactly this way. + - Suppression compares the COMPLETE ordered payload, never + `screen_generation`: scroll, selection, viewport, and process state all + change without advancing it. + - GPU: `pmacs-gpu/src/terminal.rs` is a pure cell-space paint planner + (testable without a GPU); the renderer builds one shaped buffer per + text run so a wide/cluster advance can never choose the next column's + origin. `pmacs-gpu --headless-probe` drives the real attach client + without winit (`attach::connect_with_sink`), which is how criterion 37 + gets one real daemon + real PTY + real wgpu path. - **Stage 2 TUI LANDED ON `main` — #130** (`docs/vterm-framing.md` Revision 7, criteria 15–27). `TerminalViewKey` keys per-frontend/window projection state over one shared process/screen; logical row anchors retain @@ -443,10 +472,12 @@ buffer owns a path's recovery slot; only recover/discard release unclaimed crash data; adopt clears the old owner's skip cache. **Protocol** — encoding-breaking bumps are deliberate and versioned -(`SUPPORTED=[6..18]`). v15 = `CompletionPopup` + +(`SUPPORTED=[6..=19]`). v15 = `CompletionPopup` + `StatusFacts.message`; v16 = `ThemeFacts`; v17 = `FontFacts`; v18 = -`StatuslineSegments`. New wire surface ⇒ bump + both-frontends support + -acceptance. +`StatuslineSegments`; v19 = the vterm terminal family. New wire surface ⇒ +bump + both-frontends support + acceptance. An APPENDED variant must be +guarded by a byte pin on the PREVIOUS final variant — its own round-trip +cannot detect a discriminant shift. **Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`, `rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation), @@ -512,6 +543,31 @@ acceptance. and go through `pmacs.command.invoke("buffer.save")`. Caught only because the *other* case failed and the cause was chased instead of the assertion adjusted. +- **A message that ALIGNS state cannot be gated on the state it names.** + `FrontendEvent::Viewport` both declares a byte range and switches the + frontend's window to the buffer it names. Gating the vterm v19 dual + declaration on "is the DECLARED buffer a terminal" therefore left a + stale in-flight document viewport free to drag a frontend straight back + off a terminal a command had just opened — the window oscillated, the + terminal declaration was refused every time, and no frame ever arrived. + Nothing errored. The gate has to key on the authenticated source's + ACTIVE buffer. Generalizes: when two messages declare competing views of + "what am I showing", the arbiter is the daemon's own state, never the + claim inside either message. +- **A pass that sets a mode flag must clear it on EVERY exit.** The + semantic producer's terminal pass returned early via `?` when no + declaration existed, leaving `terminal_active` set — and the daemon uses + that flag to suppress `CursorByte` and the presence sweep, so a frontend + that went back to a document silently lost both. Caught by an acceptance + assertion, not by any type. +- **Sub-crate acceptance needs a real seam, not a fixture.** `pmacs-gpu` + depends only on `pmacs-protocol`, so "real daemon + real PTY + real + wgpu in one path" could not be an in-crate test. Generalizing + `attach::connect`'s reader sink (`connect_with_sink`) and adding + `--headless-probe` gave the acceptance the REAL handshake, outbox, + writer, and `render_to_view` — which is the whole point; a + decoded-message fixture would have proved none of the three fit + together. The probe found two real defects the in-process tests did not. - **Real-grid acceptance must budget for macOS startup and path width.** A 100 ms first-Hello timeout failed under loaded macOS CI; use the normal five-second handshake window, then short polling reads. An 80-column split diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md index 5f0e6f0..fcbd471 100644 --- a/docs/vterm-framing.md +++ b/docs/vterm-framing.md @@ -1,8 +1,11 @@ # Vterm — framing (Arc 5 stage 2, three-PR delivery) -**Revision 8 — 2026-07-22. Status: Stage 1 landed on `main` as PR #126 +**Revision 9 — 2026-07-22. Status: Stage 1 landed on `main` as PR #126 at merge `643d1e1`; Stage 2 landed as PR #130 at merge `86fc1bc`; Stage 3 -is framed here and is not implemented.** +is IMPLEMENTED on `vterm-gpu` and awaits review. Revision 8's framing text +below is preserved verbatim as the approved contract; §0.9 records what the +implementation actually did, including the two places it had to go beyond +the letter of the framing.** Revision 8 re-scouts the final stage against the integrated protocol-v18 tree and closes its remaining producer/frontend boundary decisions. Protocol v19 @@ -303,6 +306,81 @@ architectural defect. This round closes its three precision findings: - Arc 5 stage 2 is the vterm delivery; capitalized Stages 1–3 are its three internal PR stages. +### 0.9 Stage 3 as built + +Stage 3 is implemented on `vterm-gpu`, cut from canonical `main` @ `1dd47fc` +rather than stacked on the documentation branch, with the approved Revision 8 +framing as its first commit. Criteria 28–37 are implemented. + +**Protocol.** `pmacs-protocol` gained `src/terminal.rs`, which now owns the +shared row/column/visible-cell/grapheme/metadata bounds (re-exported from +`crate::terminal::*` so every Stage 1/2 caller keeps its path and no duplicate +type exists), `TerminalProcessState`, `TerminalSelectionSpan`, `TerminalFrame`, +and `TerminalFrame::validate`. `unicode-width` was promoted to a workspace +dependency so the terminal screen and the wire validator measure glyph columns +with one table. Discriminants: `InstanceMessage::TerminalFrame` is 26, +`FrontendEvent::TerminalResize` 11, `TerminalPointer` 12 — each appended after +its enum's final v18 variant, with placement pins on `StatuslineSegments` and +`MenuPointer` guarding them. `SUPPORTED_PROTOCOL_VERSIONS` is `[6..=19]`. + +The measured maximum legal frame — 512x512, one maximal selection span per row, +maximum title and process metadata, the maximal-encoding `Style` on every cell, +and the exact 8 MiB aggregate glyph budget distributed to maximize serialized +length-prefix overhead — encodes to **13,437,863 bytes**, against the unchanged +16,777,216-byte transport cap. A one-byte-over aggregate is rejected before +serialization. + +**Two decisions the implementation had to make.** + +1. *The `Viewport` gate keys on the ACTIVE buffer, not the declared one.* + §6.2 says "a document buffer accepts only `Viewport`; an active terminal + buffer accepts only `TerminalResize`", and the first implementation read + that as a test on the buffer the message names. That is not sufficient: + `Viewport` also ALIGNS the frontend's window to the buffer it names, so a + stale document viewport still in flight when a command opened a terminal + dragged the frontend straight back off it — the real-daemon acceptance + showed the window oscillating and no frame ever arriving. The daemon now + drops `Viewport` when the authenticated source's active window shows a + terminal (and, defensively, when the declared buffer is itself a terminal). + This is the framing's own wording taken literally; it is recorded here + because the weaker reading looks correct and silently produces a terminal + that never paints. +2. *The producer clears terminal mode on every exit path.* `in_terminal_mode` + is used daemon-side to suppress `CursorByte` and the presence sweep. An + early return from the terminal pass that left the flag set kept those + suppressed after the frontend went back to a document. Every path out of + the pass now clears it explicitly. + +**Renderer.** `pmacs-gpu/src/terminal.rs` is a pure, cell-space paint planner: +it resolves a validated frame into background/underline/selection/cursor runs +and explicitly positioned text runs, taking the frontend's two default colors +as parameters so every paint rule is unit-testable without a GPU. `main.rs` +holds a two-state machine (`Document` / `Terminal`), builds one shaped buffer +per text run so a wide or cluster glyph's advance can never choose the next +column's origin, and swaps the document quad/squiggle/caret/minimap/gutter +batches for terminal ones while leaving the status band and popup layers alone. + +**Criterion 37.** The GPU is a separate binary that depends only on +`pmacs-protocol`, so the single real path is driven as a process: +`pmacs-gpu --headless-probe ` attaches through the REAL +`attach` client (the reader sink was generalized so the winit path and the probe +share one handshake, outbox, and writer), presses a real key that opens a real +`/bin/sh` child, applies real `TerminalFrame`s, composites real pixels through +`render_to_view`, sends real input and a real geometry change, and writes named +observations the acceptance asserts on. `tests/vterm_stage3_acceptance.rs` +`a37_…` is that test; it is CRDT-gated because the daemon advertises +`crdt_replica` / `semantic_render` only on CRDT builds. + +**Verification (from a clean tree).** `cargo fmt --check`; strict workspace +Clippy; 1,757 default + 1,933 CRDT library tests (3 ignored each); Stage 1 +acceptance 9 default + 10 CRDT; Stage 2 acceptance 4 default + 4 CRDT; Stage 3 +acceptance 4 default + 5 CRDT (the fifth is the CRDT-gated real-daemon path); +statusline acceptance 7 default + 8 CRDT; M4 120 passed (3 ignored, 1 filtered); +required GPU 127; one-invocation workspace sweep 2,919 passed across 83 suites +(19 ignored); `git diff --check` clean. One unexplained single failure of the +required-GPU suite occurred once mid-session and did not reproduce across eight +subsequent runs including the full sweep; its identity was not captured. + ## 1. Problem and ownership boundary Pmacs can supervise a PTY and can parse enough ANSI to turn command output into diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index ad4226d..7377ddb 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -23,10 +23,10 @@ use std::sync::{Arc, Condvar, Mutex}; use std::thread; use pmacs_protocol::{ - AttachRequest, BufferId, ByteRange, CrdtOp, FrontendCapabilities, FrontendEvent, FrontendId, - Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, PointerKind, - SUPPORTED_PROTOCOL_VERSIONS, TransportError, is_supported_protocol_version, read_message, - write_message, + AttachRequest, BufferId, ByteRange, CellCoord, CellSize, CrdtOp, FrontendCapabilities, + FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, MouseKind, + PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS, TransportError, + is_supported_protocol_version, read_message, write_message, }; use winit::event_loop::EventLoopProxy; @@ -141,6 +141,19 @@ fn coalesce_kind(event: &FrontendEvent) -> Option { kind: PointerKind::Drag, .. } => Some(1), + // Vterm Stage 3 — terminal pointer MOVE and DRAG are the + // high-frequency terminal gestures and coalesce like their + // document twin. Press, release, and wheel stay lossless: + // collapsing a wheel run would silently lose scroll distance, + // and collapsing a press/release would break selection. + FrontendEvent::TerminalPointer { + kind: MouseKind::Move, + .. + } => Some(2), + FrontendEvent::TerminalPointer { + kind: MouseKind::Drag(_), + .. + } => Some(3), _ => None, } } @@ -216,6 +229,24 @@ impl Outbox { pub fn connect( socket_path: &Path, proxy: EventLoopProxy, +) -> Result { + connect_with_sink(socket_path, move |event| { + proxy.send_event(AppEvent::Attach(event)).is_ok() + }) +} + +/// [`connect`], with the decoded-message destination left to the caller. +/// +/// The winit path forwards to the event loop; the headless probe used by +/// the Stage 3 acceptance forwards to a channel. Both drive the SAME +/// handshake, capability gate, reader, writer, and outbox — a probe that +/// reimplemented any of that would prove nothing about the real client. +/// +/// `sink` returns `false` when its destination is gone, which stops the +/// reader thread. +pub fn connect_with_sink( + socket_path: &Path, + sink: impl Fn(AttachEvent) -> bool + Send + 'static, ) -> Result { let stream = UnixStream::connect(socket_path).map_err(AttachClientError::Connect)?; @@ -300,17 +331,13 @@ pub fn connect( loop { match read_message::(&mut read_stream) { Ok(msg) => { - if proxy - .send_event(AppEvent::Attach(AttachEvent::Message(Box::new(msg)))) - .is_err() - { - // Main loop torn down — quietly exit. + if !sink(AttachEvent::Message(Box::new(msg))) { + // Destination torn down — quietly exit. return; } } Err(e) => { - let _ = proxy - .send_event(AppEvent::Attach(AttachEvent::Disconnected(e.to_string()))); + sink(AttachEvent::Disconnected(e.to_string())); return; } } @@ -455,6 +482,44 @@ impl AttachClient { }) } + /// Send a `FrontendEvent::TerminalResize` (Vterm Stage 3): the + /// terminal-cell geometry this frontend has on screen. Callers gate + /// on [`Self::server_protocol_version`] `>= 19`. + /// + /// Cells, never pixels — the frontend divides its own drawable + /// rectangle by its own metrics, keeping the no-pixels contract the + /// document `Viewport` established. + pub fn send_terminal_resize( + &self, + buffer_id: BufferId, + size: CellSize, + ) -> Result<(), TransportError> { + self.send_event(FrontendEvent::TerminalResize { + frontend_id: self.frontend_id, + buffer_id, + size, + }) + } + + /// Send a `FrontendEvent::TerminalPointer` (Vterm Stage 3): a + /// gesture hit-tested locally to a terminal cell. Callers gate on + /// [`Self::server_protocol_version`] `>= 19`. + pub fn send_terminal_pointer( + &self, + buffer_id: BufferId, + coord: CellCoord, + kind: MouseKind, + mods: Modifiers, + ) -> Result<(), TransportError> { + self.send_event(FrontendEvent::TerminalPointer { + frontend_id: self.frontend_id, + buffer_id, + coord, + kind, + mods, + }) + } + /// Send a `FrontendEvent::MenuPointer` (Q#CM1) — open-menu /// navigation hit-tested locally against the popup we drew. `index` /// is the row the pointer is over (`None` = off the menu); `invoke` @@ -518,7 +583,7 @@ impl AttachClient { #[cfg(test)] mod tests { use super::*; - use pmacs_protocol::InstanceCapabilities; + use pmacs_protocol::{InstanceCapabilities, MouseButton}; fn caps( multi_frontend: bool, @@ -652,6 +717,70 @@ mod tests { )); } + fn fe_terminal_pointer(kind: MouseKind, row: u32, col: u32) -> FrontendEvent { + FrontendEvent::TerminalPointer { + frontend_id: FrontendId(1), + buffer_id: BufferId::from_raw(1), + coord: CellCoord::new(row, col), + kind, + mods: Modifiers::NONE, + } + } + + /// Acceptance 34: terminal move/drag runs coalesce to the latest + /// cell, while press, release, and wheel stay lossless and ordered. + #[test] + fn terminal_motion_coalesces_but_presses_and_wheels_stay_lossless() { + let mut ob = Outbox::new(); + ob.enqueue(fe_terminal_pointer( + MouseKind::Down(MouseButton::Left), + 0, + 0, + )); + ob.enqueue(fe_terminal_pointer( + MouseKind::Drag(MouseButton::Left), + 0, + 1, + )); + ob.enqueue(fe_terminal_pointer( + MouseKind::Drag(MouseButton::Left), + 0, + 2, + )); + ob.enqueue(fe_terminal_pointer( + MouseKind::Drag(MouseButton::Left), + 0, + 3, + )); + ob.enqueue(fe_terminal_pointer(MouseKind::Up(MouseButton::Left), 0, 3)); + assert_eq!(ob.queue.len(), 3, "the drag run collapsed to one"); + assert!(matches!( + &ob.queue[1], + FrontendEvent::TerminalPointer { + kind: MouseKind::Drag(MouseButton::Left), + coord: CellCoord { row: 0, col: 3 }, + .. + } + )); + + // Wheel ticks carry scroll DISTANCE; collapsing a run would + // silently lose scrollback rows. + let mut wheel = Outbox::new(); + wheel.enqueue(fe_terminal_pointer(MouseKind::ScrollUp, 1, 1)); + wheel.enqueue(fe_terminal_pointer(MouseKind::ScrollUp, 1, 1)); + wheel.enqueue(fe_terminal_pointer(MouseKind::ScrollUp, 1, 1)); + assert_eq!(wheel.queue.len(), 3, "wheel ticks are lossless"); + + // Hover motion coalesces, but not across a different kind. + let mut moves = Outbox::new(); + moves.enqueue(fe_terminal_pointer(MouseKind::Move, 2, 1)); + moves.enqueue(fe_terminal_pointer(MouseKind::Move, 2, 2)); + assert_eq!(moves.queue.len(), 1); + moves.enqueue(fe_terminal_pointer(MouseKind::ScrollDown, 2, 2)); + moves.enqueue(fe_terminal_pointer(MouseKind::Move, 2, 3)); + assert_eq!(moves.queue.len(), 3); + } + #[test] fn coalescing_only_collapses_a_same_kind_tail() { let mut ob = Outbox::new(); diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 4da619e..e18ce27 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -23,9 +23,10 @@ //! the SIL Open Font License 1.1 (see `fonts/OFL.txt`). mod attach; +mod terminal; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use glyphon::cosmic_text::{Affinity, Cursor, Scroll, Wrap}; @@ -35,12 +36,13 @@ use glyphon::{ }; use loro::{ContainerTrait, ExportMode}; use pmacs_protocol::{ - AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CompletionPopupRow, CrdtOp, - Decoration, DecorationKind, DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, - 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, + AdornmentContent, AdornmentPlacement, BufferId, ByteRange, CellCoord, CellSize, + CompletionPopupRow, CrdtOp, Decoration, DecorationKind, DecorationSegment, FrontendId, + InlineAdornment, InstanceMessage, InstanceSignal, Key as ProtocolKey, LineNumberMode, + MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, + MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers, MouseButton as ProtocolMouseButton, + MouseKind as ProtocolMouseKind, PointerKind, SelectionSnapshot, StatuslineSegment, + StyleSegment, StyleSpan, TerminalFrame, UnderlineStyle, cell::{Color as CellColor, Style as CellStyle}, is_builtin_pair_char, is_modeline_face_name, }; @@ -52,6 +54,7 @@ use winit::keyboard::{Key, NamedKey}; use winit::window::{Window, WindowId}; use crate::attach::{AttachClient, AttachEvent}; +use crate::terminal::{TerminalPaintPlan, TerminalPalette}; /// Bundled font (SIL Open Font License 1.1 — see `fonts/OFL.txt`). const JETBRAINS_MONO: &[u8] = include_bytes!("../fonts/JetBrainsMono-Regular.ttf"); @@ -72,6 +75,20 @@ const TEST_FONT_SOURCES: &[&[u8]] = &[ TEST_FAMILY_BOLD, ]; +/// Extra font sources a headless `State` assembles with. Test builds add +/// the fixture families the font-preference tests rely on; the Vterm +/// Stage 3 attach probe, which is a release-mode binary, gets none. +fn headless_extra_font_sources() -> &'static [&'static [u8]] { + #[cfg(test)] + { + TEST_FONT_SOURCES + } + #[cfg(not(test))] + { + &[] + } +} + /// The default font family — the query `family: None` (and every /// rejected requested family) resolves through (framing Q#F6). The /// bundle guarantees the query is never empty; a monospaced @@ -305,6 +322,17 @@ const BG: wgpu::Color = wgpu::Color { const TEXT_LEFT: f32 = 16.0; const TEXT_TOP: f32 = 16.0; + +/// Stroke thickness for terminal straight-underline forms, in pixels. +const TERMINAL_UNDERLINE_PX: f32 = 1.0; + +/// Fallback terminal selection wash when no `ui.selection` face is set. +const TERMINAL_SELECTION_RGBA: [f32; 4] = [0.35, 0.45, 0.75, 0.35]; + +/// The terminal cursor block. Translucent so the glyph beneath stays +/// readable — a terminal cursor sits ON a character, unlike the +/// document caret, which sits between two. +const TERMINAL_CURSOR_RGBA: [f32; 4] = [0.85, 0.85, 0.9, 0.55]; /// Caret bar width in px, and its color (bright, near-opaque — drawn /// over the text so it reads as the active insertion point). Session /// B1. @@ -524,6 +552,16 @@ enum Mode { /// `pmacs-gpu --attach `: connect + render the daemon's /// rope. Attach { socket: PathBuf }, + /// `pmacs-gpu --headless-probe `: attach through + /// the real client, render real frames offscreen, and write a + /// machine-readable report. + /// + /// This exists for the Vterm Stage 3 acceptance, which must exercise + /// a real daemon, a real PTY, and real wgpu rendering in ONE path. + /// It drives the same `attach` handshake, the same + /// `apply_attach_message`, and the same `render_to_view` the windowed + /// mode does — only winit is absent, because CI has no display. + HeadlessProbe { socket: PathBuf, report: PathBuf }, } /// Number of decimal digits in `n` (for `n >= 1`); allocation-free. Sizes @@ -542,6 +580,9 @@ fn decimal_digits(mut n: usize) -> u32 { fn main() { env_logger::init(); let mode = parse_args(std::env::args().skip(1).collect()); + if let Mode::HeadlessProbe { socket, report } = &mode { + std::process::exit(run_headless_probe(socket, report)); + } let event_loop = EventLoop::::with_user_event() .build() .expect("create winit event loop"); @@ -558,6 +599,201 @@ fn main() { .expect("winit event loop run_app"); } +/// Drive a real attach session headlessly and write a probe report. +/// +/// The Vterm Stage 3 acceptance needs one path that exercises a real +/// daemon, a real PTY child, and real wgpu rendering together — a +/// decoded-message fixture would prove none of the three fit. This is +/// that path minus winit, which CI has no display for. +/// +/// The report is one `key=value` line per fact so the acceptance test +/// asserts on named observations rather than parsing prose. Exit code 0 +/// means the report was written; anything else means the probe could not +/// run and the acceptance fails loudly rather than reading a stale file. +#[allow( + clippy::too_many_lines, + reason = "one linear attach-render-observe probe session" +)] +fn run_headless_probe(socket: &Path, report: &Path) -> i32 { + use std::fmt::Write as _; + use std::sync::mpsc; + + let Some(mut state) = State::new_headless(900, 600, "(connecting...)") else { + eprintln!("pmacs-gpu probe: no wgpu adapter available"); + return 3; + }; + + let (tx, rx) = mpsc::channel::(); + let client = match attach::connect_with_sink(socket, move |event| tx.send(event).is_ok()) { + Ok(client) => client, + Err(error) => { + eprintln!("pmacs-gpu probe: attach failed: {error}"); + return 4; + } + }; + state.set_frontend_id(client.frontend_id()); + + let mut facts = ProbeFacts { + server_protocol_version: client.server_protocol_version(), + ..ProbeFacts::default() + }; + + // Ask the daemon to open the acceptance terminal in THIS frontend's + // window. Going through a real key press is the point: the daemon's + // `terminal.open` targets the invoking frontend, so this is what + // puts the attached window on a terminal buffer. + if let Some(chord) = std::env::var_os("PMACS_GPU_PROBE_OPEN_KEY") + .and_then(|value| value.into_string().ok()) + .and_then(|value| value.chars().next()) + { + let _ = client.send_key(ProtocolKey::Char(chord), Modifiers::CTRL | Modifiers::ALT); + } + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + let mut sent_input = false; + let mut sent_resize = false; + while std::time::Instant::now() < deadline { + let Ok(event) = rx.recv_timeout(std::time::Duration::from_millis(200)) else { + continue; + }; + match event { + AttachEvent::Disconnected(reason) => { + facts.disconnect = Some(reason); + break; + } + AttachEvent::Message(msg) => { + let is_snapshot = matches!(*msg, InstanceMessage::BufferSnapshot { .. }); + if let InstanceMessage::TerminalFrame(frame) = msg.as_ref() { + facts.frames += 1; + facts.last_frame_cols = frame.size.cols; + facts.last_frame_rows = frame.size.rows; + facts.last_frame_text = frame_probe_text(frame); + facts.last_title.clone_from(&frame.title); + } + state.apply_attach_message(*msg); + if is_snapshot { + // The dual declaration: a byte viewport for a + // document, a cell size for a terminal. The daemon + // keeps whichever matches. + if let Some(buffer_id) = state.current_buffer_id { + let (start, end) = state.view_range; + let _ = client.send_viewport( + buffer_id, + pmacs_protocol::ByteRange { start, end }, + 0, + ); + } + if let Some((buffer_id, size)) = state.terminal_declaration_if_changed() { + facts.declarations += 1; + let _ = client.send_terminal_resize(buffer_id, size); + } + } + if state.terminal.is_some() { + facts.entered_terminal_mode = true; + // Render a REAL frame through the real composition + // path, then record that it composited. + let pixels = state.render_offscreen(); + let first = pixels.first().copied().unwrap_or_default(); + if pixels.iter().any(|&b| b != first) { + facts.rendered_nonuniform_frames += 1; + } + if !sent_input && facts.frames >= 1 { + sent_input = true; + // Real child input over the real wire. + let _ = client.send_key(ProtocolKey::Char('x'), Modifiers::NONE); + let _ = client.send_key(ProtocolKey::Enter, Modifiers::NONE); + } + if !sent_resize && facts.frames >= 2 { + sent_resize = true; + state.resize(700, 500); + if let Some((buffer_id, size)) = state.terminal_declaration_if_changed() { + facts.declarations += 1; + facts.resized_cols = size.cols; + facts.resized_rows = size.rows; + let _ = client.send_terminal_resize(buffer_id, size); + } + } + if facts.resized_cols > 0 && facts.last_frame_cols == facts.resized_cols { + facts.observed_resized_frame = true; + } + } + if facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 { + break; + } + } + } + } + + let mut out = String::new(); + let _ = writeln!( + out, + "server_protocol_version={}", + facts.server_protocol_version + ); + let _ = writeln!(out, "declarations={}", facts.declarations); + let _ = writeln!(out, "frames={}", facts.frames); + let _ = writeln!( + out, + "rendered_nonuniform_frames={}", + facts.rendered_nonuniform_frames + ); + let _ = writeln!(out, "entered_terminal_mode={}", facts.entered_terminal_mode); + let _ = writeln!( + out, + "observed_resized_frame={}", + facts.observed_resized_frame + ); + let _ = writeln!(out, "last_frame_rows={}", facts.last_frame_rows); + let _ = writeln!(out, "last_frame_cols={}", facts.last_frame_cols); + let _ = writeln!(out, "resized_rows={}", facts.resized_rows); + let _ = writeln!(out, "resized_cols={}", facts.resized_cols); + let _ = writeln!(out, "last_title={}", facts.last_title.unwrap_or_default()); + let _ = writeln!(out, "last_frame_text={}", facts.last_frame_text); + let _ = writeln!(out, "disconnect={}", facts.disconnect.unwrap_or_default()); + if let Err(error) = std::fs::write(report, out) { + eprintln!( + "pmacs-gpu probe: writing {} failed: {error}", + report.display() + ); + return 5; + } + 0 +} + +/// Named observations the headless probe reports back to the acceptance. +#[derive(Default)] +struct ProbeFacts { + server_protocol_version: u32, + declarations: u32, + frames: u32, + rendered_nonuniform_frames: u32, + entered_terminal_mode: bool, + observed_resized_frame: bool, + last_frame_rows: u32, + last_frame_cols: u32, + resized_rows: u32, + resized_cols: u32, + last_title: Option, + last_frame_text: String, + disconnect: Option, +} + +/// One-line printable text of a terminal frame, for probe reporting. +fn frame_probe_text(frame: &TerminalFrame) -> String { + let mut text = String::new(); + for cell in &frame.cells { + match &cell.glyph { + pmacs_protocol::Glyph::Char(ch) => text.push(*ch), + pmacs_protocol::Glyph::Cluster(bytes) => { + text.push_str(&String::from_utf8_lossy(bytes)); + } + pmacs_protocol::Glyph::Continuation => {} + } + } + text.retain(|ch| !ch.is_control()); + text +} + /// Tiny argv parser. No `clap` because the surface is genuinely two /// shapes; full CLI parsing arrives when there's more to parse. The /// `for` ranges over a small set: at most one `--attach ` or @@ -577,6 +813,20 @@ fn parse_args(args: Vec) -> Mode { socket: PathBuf::from(socket), } } + "--headless-probe" => { + let socket = iter.next().unwrap_or_else(|| { + eprintln!("pmacs-gpu: --headless-probe requires a socket path"); + std::process::exit(2); + }); + let report = iter.next().unwrap_or_else(|| { + eprintln!("pmacs-gpu: --headless-probe requires a report path"); + std::process::exit(2); + }); + Mode::HeadlessProbe { + socket: PathBuf::from(socket), + report: PathBuf::from(report), + } + } "--help" | "-h" => { eprintln!( "pmacs-gpu — GPU/GUI frontend for pmacs\n\nUSAGE:\n pmacs-gpu \ @@ -955,6 +1205,36 @@ struct State { /// `face_fg_or` / `face_wash_or` / `modeline_face_colors` / /// `diag_face_rgba` resolvers (Q#TH5 mask + `Default` mapping). faces: HashMap, + /// Vterm Stage 3 — the installed terminal frame and its derived + /// paint plan, or `None` in document mode. The two-state machine is + /// explicit: `BufferSnapshot` always leaves terminal mode, a valid + /// matching `TerminalFrame` always enters it. + terminal: Option, + /// The terminal geometry last declared to the daemon, with the + /// buffer it described. Suppresses an unchanged re-declaration and + /// forces a fresh one after a buffer switch. + last_terminal_size_sent: Option<(BufferId, CellSize)>, + /// Whether an invalid terminal frame has already been reported. + /// Bounds the log while a bad producer keeps sending. + terminal_frame_error_latched: bool, + /// One shaped buffer per planned text run. Rebuilt only when the + /// plan changes, never per frame. + terminal_text_buffers: Vec, + /// Dedicated renderer for terminal glyphs, so they draw in their own + /// layer with terminal clipping rather than through the document + /// text pass. + terminal_text_renderer: TextRenderer, +} + +/// Vterm Stage 3 — the GPU's terminal mode. +struct TerminalLocal { + /// The terminal identity buffer this frame describes. + buffer_id: BufferId, + /// The last valid frame. Retained verbatim so an identical + /// re-send can be recognized and skipped without a rebuild. + frame: TerminalFrame, + /// Cell-space paint data derived from `frame`. + plan: TerminalPaintPlan, } /// Kind-glyph column for a completion row: the LSP @@ -1176,6 +1456,71 @@ impl App { } } + /// Ship a terminal-cell gesture if the daemon speaks v19+. + /// + /// The terminal twin of [`Self::send_pointer`]: same frontend-side + /// version gate, cells instead of source bytes. + fn send_terminal_pointer( + &self, + buffer_id: BufferId, + coord: CellCoord, + kind: ProtocolMouseKind, + mods: Modifiers, + ) { + let Some(client) = self.attach_client.as_ref() else { + return; + }; + if client.server_protocol_version() < 19 { + return; + } + if let Err(e) = client.send_terminal_pointer(buffer_id, coord, kind, mods) { + eprintln!("pmacs-gpu: send_terminal_pointer failed: {e}"); + } + } + + /// Declare the current terminal cell geometry when it has changed. + /// + /// Called after every applied message and after any real geometry + /// change. An unchanged size sends nothing, so a redraw storm + /// produces no wire traffic; a changed one sends exactly once. + fn flush_terminal_declaration(&mut self) { + let Some(client) = self.attach_client.as_ref() else { + return; + }; + if client.server_protocol_version() < 19 { + return; + } + let Some(state) = self.state.as_mut() else { + return; + }; + let Some((buffer_id, size)) = state.terminal_declaration_if_changed() else { + return; + }; + if let Err(e) = client.send_terminal_resize(buffer_id, size) { + eprintln!("pmacs-gpu: send_terminal_resize failed: {e}"); + } + } + + /// Resolve a pixel to a terminal cell, or `None` when this window is + /// not in terminal mode or the pixel is outside the grid. + /// + /// The status band and the padding past the last whole column are + /// deliberately not terminal hits: a gesture there belongs to the + /// chrome, not the child. + fn terminal_pointer_hit(&self, x: f64, y: f64) -> Option<(BufferId, CellCoord)> { + let state = self.state.as_ref()?; + let terminal = state.terminal.as_ref()?; + let coord = crate::terminal::hit_test_cell( + x as f32, + y as f32, + (TEXT_LEFT, TEXT_TOP), + state.mono_advance(), + state.fm.code_line_height(), + terminal.plan.size, + )?; + Some((terminal.buffer_id, coord)) + } + /// Ship a [`pmacs_protocol::FrontendEvent::MenuPointer`] if the /// daemon speaks v11+ (Q#CM1). Navigates the open menu the daemon /// owns; pixels stay local, only the resolved row index crosses. @@ -1199,7 +1544,7 @@ impl ApplicationHandler for App { } let initial_text = match &self.mode { Mode::HelloWorld => HELLO_TEXT, - Mode::Attach { .. } => "(connecting...)", + Mode::Attach { .. } | Mode::HeadlessProbe { .. } => "(connecting...)", }; self.state = Some(State::new(event_loop, initial_text)); @@ -1439,6 +1784,11 @@ impl ApplicationHandler for App { { eprintln!("pmacs-gpu: resize send_viewport failed: {e}"); } + // Vterm Stage 3 — a resize is a real geometry change, so + // the cell grid is re-derived and declared here. The + // daemon resizes the shared PTY only if this frontend is + // the durable controller. + self.flush_terminal_declaration(); } // Session M-2 — pointer input (docs/pmacs-gpu-mouse-framing.md). WindowEvent::CursorMoved { position, .. } => { @@ -1459,6 +1809,25 @@ impl ApplicationHandler for App { } return; } + // Vterm Stage 3 — inside the terminal clip, motion is a + // terminal gesture. Consumed before minimap scrubbing + // and document hit testing: terminal mode paints no + // minimap and has no source bytes to resolve. + if state.terminal.is_some() { + let dragging = state.pointer_drag_active; + if let Some((buffer_id, coord)) = + self.terminal_pointer_hit(position.x, position.y) + { + let mods = translate_mods(self.modifiers); + let kind = if dragging { + ProtocolMouseKind::Drag(ProtocolMouseButton::Left) + } else { + ProtocolMouseKind::Move + }; + self.send_terminal_pointer(buffer_id, coord, kind, mods); + } + return; + } if state.minimap_scrub_active { // Scrubbing (Q#M6): the press began on the // minimap; motion keeps jumping, even if the @@ -1524,6 +1893,22 @@ impl ApplicationHandler for App { return; } let mods = translate_mods(self.modifiers); + if state.terminal.is_some() { + let kind = match button_state { + ElementState::Pressed => { + state.pointer_drag_active = true; + ProtocolMouseKind::Down(ProtocolMouseButton::Left) + } + ElementState::Released => { + state.pointer_drag_active = false; + ProtocolMouseKind::Up(ProtocolMouseButton::Left) + } + }; + if let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, y) { + self.send_terminal_pointer(buffer_id, coord, kind, mods); + } + return; + } match button_state { ElementState::Pressed => { if state.in_minimap_band(x, y) { @@ -1594,6 +1979,23 @@ impl ApplicationHandler for App { self.send_menu_pointer(None, true); return; } + // Vterm Stage 3 — a right-click in the terminal clip is + // a terminal gesture; the daemon decides between child + // reporting and the editor context menu, so the anchor + // is remembered here exactly as for a document click. + if state.terminal.is_some() { + state.menu_anchor_px = (x, y); + if let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, y) { + let mods = translate_mods(self.modifiers); + self.send_terminal_pointer( + buffer_id, + coord, + ProtocolMouseKind::Down(ProtocolMouseButton::Right), + mods, + ); + } + return; + } let Some(byte) = state.hit_test_source_byte(x, y) else { return; }; @@ -1622,6 +2024,24 @@ impl ApplicationHandler for App { if lines == 0 { return; } + // Vterm Stage 3 — the terminal's scrollback belongs to + // the daemon-side view, not to this frontend's local + // scroll, so a wheel tick crosses the wire as a + // terminal gesture instead of moving `scroll_top`. + if state.terminal.is_some() { + if let Some((x, y)) = state.pointer_pos + && let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, y) + { + let mods = translate_mods(self.modifiers); + let kind = if lines < 0 { + ProtocolMouseKind::ScrollUp + } else { + ProtocolMouseKind::ScrollDown + }; + self.send_terminal_pointer(buffer_id, coord, kind, mods); + } + return; + } let vp = state.scroll_by_lines(lines); if let Some(vp) = vp && let Some(client) = self.attach_client.as_ref() @@ -1747,6 +2167,18 @@ impl ApplicationHandler for App { { eprintln!("pmacs-gpu: send Viewport failed: {e}"); } + // Vterm Stage 3 — the dual declaration. After every + // snapshot the frontend re-declares BOTH its byte + // viewport (above) and its terminal cell size, because + // an empty terminal identity snapshot does not announce + // itself as a terminal. The daemon keeps whichever one + // matches the buffer's kind, which is what breaks the + // otherwise circular "need a frame to know to ask for + // one" dependency. + self.flush_terminal_declaration(); + let Some(state) = self.state.as_mut() else { + return; + }; state.release_timed_out_floor(); let ready_keys = state.take_ready_round_trip_keys(); if let Some(client) = self.attach_client.as_ref() { @@ -2106,10 +2538,10 @@ impl State { } /// Build a windowless `State` that renders to an offscreen texture, for - /// the headless render tests (F-014). Returns `None` when no GPU - /// adapter is available (a dev box with no working Vulkan, or CI - /// without lavapipe), so the caller skips rather than fails. - #[cfg(test)] + /// the headless render tests (F-014) and the Vterm Stage 3 headless + /// attach probe. Returns `None` when no GPU adapter is available (a + /// dev box with no working Vulkan, or CI without lavapipe), so the + /// caller skips rather than fails. fn new_headless(width: u32, height: u32, initial_text: &str) -> Option { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { @@ -2143,7 +2575,9 @@ impl State { queue, config, initial_text, - TEST_FONT_SOURCES, + // Font fixtures exist only in test builds; the release-mode + // attach probe runs on the bundled face alone. + headless_extra_font_sources(), )) } @@ -2198,6 +2632,11 @@ impl State { // UX gutter — a renderer for the line-number layer. let gutter_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); + // Vterm Stage 3 — terminal glyphs draw in their own layer with + // their own clip; interleaving them with document text would + // subject them to the document's gutter offset and wrapping. + let terminal_text_renderer = + TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); let quad_renderer = QuadRenderer::new(&device, format); let squiggle_renderer = SquiggleRenderer::new(&device, format); @@ -2371,6 +2810,11 @@ impl State { faces: HashMap::new(), gutter_buffer, gutter_text_renderer, + terminal: None, + last_terminal_size_sent: None, + terminal_frame_error_latched: false, + terminal_text_buffers: Vec::new(), + terminal_text_renderer, }; // Real drawable dimensions from construction (framing Q#F6): // wrapping and `shape_until_cursor` must use the same clip the @@ -2862,6 +3306,13 @@ impl State { self.scroll_top = 0; self.code_scroll_residual = 0.0; self.last_viewport_sent = None; + // Vterm Stage 3 — a snapshot ALWAYS leaves terminal + // mode, including a terminal→terminal switch. The prior + // frame describes another session's screen, and the + // daemon has already dropped its own baseline, so the + // next valid frame is authoritative for whatever this + // buffer turns out to be. + self.exit_terminal_mode(); if !self.set_text(&text) { // Even byte-identical A -> B snapshots can clear a // prior minimap, so the new buffer's shaping clip @@ -3354,13 +3805,364 @@ impl State { size_centi_px, } => { self.apply_font_facts(family.as_deref(), size_centi_px); + // Q#F6 + Vterm Stage 3: new metrics mean a new cell + // grid. The terminal shape/geometry caches are dropped + // here and stay dropped until an authoritative frame at + // the matching size arrives, so nothing paints at the + // old advance under the new font. + self.invalidate_terminal_shaping(); self.current_buffer_id .and_then(|bid| self.viewport_send_if_changed(bid)) } + InstanceMessage::TerminalFrame(frame) => { + self.apply_terminal_frame(frame); + None + } _ => None, } } + /// Install a decoded terminal frame, or reject it whole. + /// + /// Rejection is total by design: a partially applied frame would mix + /// cells from two screens. An invalid frame therefore keeps the + /// previous valid one, requests no redraw, and reports one latched + /// diagnostic instead of painting something the daemon never + /// authorized. + fn apply_terminal_frame(&mut self, frame: TerminalFrame) { + if self.current_buffer_id != Some(frame.buffer_id) { + // A frame for a buffer this window is no longer showing. + // The daemon clears its baseline on every snapshot, so the + // authoritative frame for the buffer we DO show is already + // on its way. + return; + } + if let Err(error) = frame.validate() { + if !self.terminal_frame_error_latched { + self.terminal_frame_error_latched = true; + eprintln!("pmacs-gpu: rejecting invalid TerminalFrame: {error}"); + } + return; + } + self.terminal_frame_error_latched = false; + if self + .terminal + .as_ref() + .is_some_and(|terminal| terminal.frame == frame) + { + // A duplicate valid frame does no work at all: no plan + // rebuild, no reshape, no redraw. + return; + } + let plan = TerminalPaintPlan::build(&frame, Self::terminal_palette()); + let buffer_id = frame.buffer_id; + self.terminal = Some(TerminalLocal { + buffer_id, + frame, + plan, + }); + self.rebuild_terminal_text_buffers(); + self.request_redraw(); + } + + /// The frontend defaults `Color::Default` resolves against. + fn terminal_palette() -> TerminalPalette { + let fg = plain_text_color(); + TerminalPalette { + default_fg: [fg.r(), fg.g(), fg.b()], + default_bg: [ + (WINDOW_BG_RGBA[0] * 255.0) as u8, + (WINDOW_BG_RGBA[1] * 255.0) as u8, + (WINDOW_BG_RGBA[2] * 255.0) as u8, + ], + } + } + + /// Leave terminal mode and drop every terminal-only cache. + fn exit_terminal_mode(&mut self) { + self.terminal = None; + self.terminal_text_buffers.clear(); + self.terminal_frame_error_latched = false; + self.last_terminal_size_sent = None; + } + + /// Drop shaping and geometry caches without leaving terminal mode. + /// + /// Used when the font changes: the installed frame is still the + /// child's authoritative screen, but every cached shape was measured + /// at the old advance. + fn invalidate_terminal_shaping(&mut self) { + if self.terminal.is_some() { + self.rebuild_terminal_text_buffers(); + } + self.last_terminal_size_sent = None; + } + + /// Reshape one cosmic-text buffer per planned run. + /// + /// One buffer per RUN, not per row: a row-wide buffer would let a + /// wide or cluster glyph's shaped advance decide where the following + /// column starts, and terminal columns belong to the child. + fn rebuild_terminal_text_buffers(&mut self) { + let Some(terminal) = self.terminal.as_ref() else { + self.terminal_text_buffers.clear(); + return; + }; + let metrics = Metrics::new(self.fm.code_font_size(), self.fm.code_line_height()); + let advance = self.mono_advance(); + let family = self.resolved_family.clone(); + let runs: Vec<_> = terminal + .plan + .runs + .iter() + .map(|run| { + ( + run.text.clone(), + run.cells as f32 * advance, + run.bold, + run.italic, + ) + }) + .collect(); + let mut buffers = Vec::with_capacity(runs.len()); + for (text, width, bold, italic) in runs { + let mut buffer = Buffer::new(&mut self.font_system, metrics); + // No wrapping: a run occupies exactly the cells the child + // gave it, and overflow is a clip, never a second row. + buffer.set_wrap(&mut self.font_system, Wrap::None); + buffer.set_size( + &mut self.font_system, + Some(width.max(1.0)), + Some(metrics.line_height), + ); + let attrs = Attrs::new() + .family(Family::Name(&family)) + .weight(if bold { + glyphon::cosmic_text::Weight::BOLD + } else { + glyphon::cosmic_text::Weight::NORMAL + }) + .style(if italic { + glyphon::cosmic_text::Style::Italic + } else { + glyphon::cosmic_text::Style::Normal + }); + buffer.set_text( + &mut self.font_system, + &text, + &attrs, + Shaping::Advanced, + None, + ); + buffer.shape_until_scroll(&mut self.font_system, false); + buffers.push(buffer); + } + self.terminal_text_buffers = buffers; + } + + /// The terminal content rectangle's pixel origin. + /// + /// Deliberately not `text_left()`: terminal mode draws no document + /// gutter, so the grid starts at the plain text inset. + fn terminal_origin() -> (f32, f32) { + (TEXT_LEFT, TEXT_TOP) + } + + /// The cell grid this window's drawable rectangle admits, or `None` + /// when it cannot fit one whole cell. + fn terminal_cell_viewport(&self) -> Option { + let (origin_x, origin_y) = Self::terminal_origin(); + let width = self.config.width as f32 - origin_x; + let height = text_area_bottom(self.config.height, self.fm) - origin_y; + crate::terminal::cell_viewport( + width, + height, + self.mono_advance(), + self.fm.code_line_height(), + ) + } + + /// Pixel rectangle of a cell run in the terminal grid. + fn terminal_run_rect(&self, run: crate::terminal::CellRun) -> (f32, f32, f32, f32) { + let (ox, oy) = Self::terminal_origin(); + let advance = self.mono_advance(); + let line = self.fm.code_line_height(); + ( + ox + run.start_col as f32 * advance, + oy + run.row as f32 * line, + (run.end_col - run.start_col) as f32 * advance, + line, + ) + } + + /// Backgrounds, straight underlines, the selection wash, and the + /// terminal clip, as one quad batch drawn under the glyphs. + /// + /// Runs whose resolved background equals the window clear color are + /// dropped: the clear already painted them, and emitting a + /// full-screen quad per frame for the common case is pure waste. + fn terminal_quad_vertex_bytes(&self) -> Vec { + let Some(terminal) = self.terminal.as_ref() else { + return Vec::new(); + }; + let window_bg = Self::terminal_palette().default_bg; + let mut rects = Vec::new(); + for bg in &terminal.plan.backgrounds { + if bg.color == window_bg { + continue; + } + let (x, y, w, h) = self.terminal_run_rect(bg.run); + rects.push(MinimapRect { + x, + y, + w, + h, + color: rgb_to_quad(bg.color, 1.0), + }); + } + // Straight underline forms as fixed-cell quads; curly rides the + // squiggle pipeline, which owns the sine wave. + for underline in &terminal.plan.underlines { + if underline.style == UnderlineStyle::Curly { + continue; + } + let (x, y, w, h) = self.terminal_run_rect(underline.run); + let color = rgb_to_quad(underline.color, 1.0); + let thickness = TERMINAL_UNDERLINE_PX; + let baseline = y + h - thickness * 2.0; + match underline.style { + UnderlineStyle::Double => { + rects.push(MinimapRect { + x, + y: baseline, + w, + h: thickness, + color, + }); + rects.push(MinimapRect { + x, + y: baseline + thickness * 2.0, + w, + h: thickness, + color, + }); + } + UnderlineStyle::Dotted | UnderlineStyle::Dashed => { + // Dotted and dashed differ only in duty cycle; both + // are stepped along the run so a one-cell run still + // shows at least one mark. + let period = if underline.style == UnderlineStyle::Dotted { + TERMINAL_UNDERLINE_PX * 3.0 + } else { + TERMINAL_UNDERLINE_PX * 8.0 + }; + let duty = if underline.style == UnderlineStyle::Dotted { + 0.5 + } else { + 0.625 + }; + let mut at = x; + while at < x + w { + let seg = (period * duty).min(x + w - at); + rects.push(MinimapRect { + x: at, + y: baseline, + w: seg, + h: thickness, + color, + }); + at += period; + } + } + _ => rects.push(MinimapRect { + x, + y: baseline, + w, + h: thickness, + color, + }), + } + } + // Terminal selection is the editor's, not the child's: it draws + // as a separate wash through the existing `ui.selection` site + // and never rewrites a cell's own style. + let selection_color = self.face_wash_or("ui.selection", TERMINAL_SELECTION_RGBA); + for run in &terminal.plan.selection { + let (x, y, w, h) = self.terminal_run_rect(*run); + rects.push(MinimapRect { + x, + y, + w, + h, + color: selection_color, + }); + } + rects_to_vertex_bytes(&rects, self.config.width, self.config.height) + } + + /// Curly terminal underlines through the existing squiggle pipeline. + fn terminal_squiggle_vertex_bytes(&self) -> Vec { + let Some(terminal) = self.terminal.as_ref() else { + return Vec::new(); + }; + let rects: Vec = terminal + .plan + .underlines + .iter() + .filter(|underline| underline.style == UnderlineStyle::Curly) + .map(|underline| { + let (x, y, w, h) = self.terminal_run_rect(underline.run); + MinimapRect { + x, + y: y + h - DIAG_SQUIGGLE_PX, + w, + h: DIAG_SQUIGGLE_PX, + color: rgb_to_quad(underline.color, 1.0), + } + }) + .collect(); + squiggles_to_vertex_bytes(&rects, self.config.width, self.config.height) + } + + /// The child cursor's quad, painted through the caret primitive so + /// it lands over the glyph it sits on. + fn terminal_cursor_vertex_bytes(&self) -> Vec { + let Some(terminal) = self.terminal.as_ref() else { + return Vec::new(); + }; + let Some(cursor) = terminal.plan.cursor else { + return Vec::new(); + }; + let (x, y, w, h) = self.terminal_run_rect(cursor); + rects_to_vertex_bytes( + &[MinimapRect { + x, + y, + w, + h, + color: TERMINAL_CURSOR_RGBA, + }], + self.config.width, + self.config.height, + ) + } + + /// A geometry declaration for the current buffer if it + /// changed, else `None`. + /// + /// Called after a snapshot and after any real geometry change + /// (window resize, scale, font). An equal size is silent, so a + /// redraw storm produces no wire traffic. + fn terminal_declaration_if_changed(&mut self) -> Option<(BufferId, CellSize)> { + let buffer_id = self.current_buffer_id?; + let size = self.terminal_cell_viewport()?; + if self.last_terminal_size_sent == Some((buffer_id, size)) { + return None; + } + self.last_terminal_size_sent = Some((buffer_id, size)); + Some((buffer_id, size)) + } + /// True while the completion popup is open **for the buffer this /// window currently shows** — the predicate the key gates (Esc, /// RET/TAB) and the render path share, so a stale mirror can @@ -5240,9 +6042,10 @@ impl State { } /// Render one frame to an offscreen texture and read it back as packed - /// RGBA8 (`width * height * 4` bytes, row padding removed). Test-only, - /// the entry point for the headless render harness (F-014). - #[cfg(test)] + /// RGBA8 (`width * height * 4` bytes, row padding removed). The entry + /// point for the headless render harness (F-014) and for the Vterm + /// Stage 3 attach probe, which needs real composited pixels rather + /// than a claim that it rendered. fn render_offscreen(&mut self) -> Vec { let width = self.config.width; let height = self.config.height; @@ -5379,9 +6182,19 @@ impl State { &completion_vertices, ) .cloned(); + // Vterm Stage 3 — terminal mode replaces every document paint + // batch. Document decoration washes, squiggles, caret, minimap, + // and gutter describe a rope this window is not showing; the + // status band and the popup layers above it stay, because they + // are buffer-independent chrome the daemon still drives. + let terminal_mode = self.terminal.is_some(); // The band's strip rides the bg quad batch so it draws under // the band text (text renders after the first quad draw). - let mut bg_vertices = self.decoration_background_vertex_bytes(); + let mut bg_vertices = if terminal_mode { + self.terminal_quad_vertex_bytes() + } else { + self.decoration_background_vertex_bytes() + }; bg_vertices.extend(self.status_band_vertex_bytes()); let bg_vertex_count = (bg_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let bg_buffer = self @@ -5396,7 +6209,11 @@ impl State { // Diagnostic squiggles (Q#W1): own pipeline + buffer, drawn // between the wash quads and the text (under the glyphs, the // z-slot the straight bar held). - let squiggle_vertices = self.squiggle_vertex_bytes(); + let squiggle_vertices = if terminal_mode { + self.terminal_squiggle_vertex_bytes() + } else { + self.squiggle_vertex_bytes() + }; let squiggle_vertex_count = (squiggle_vertices.len() / SQUIGGLE_VERTEX_STRIDE as usize) as u32; let squiggle_buffer = self @@ -5408,7 +6225,11 @@ impl State { &squiggle_vertices, ) .cloned(); - let caret_vertices = self.caret_vertex_bytes(); + let caret_vertices = if terminal_mode { + self.terminal_cursor_vertex_bytes() + } else { + self.caret_vertex_bytes() + }; let caret_vertex_count = (caret_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let caret_buffer = self .caret_vertex_buffer @@ -5436,7 +6257,12 @@ impl State { { self.minimap_cache = Some((minimap_key, self.minimap_vertex_bytes())); } - let minimap_vertices = &self.minimap_cache.as_ref().expect("just filled").1; + let empty_minimap: Vec = Vec::new(); + let minimap_vertices = if terminal_mode { + &empty_minimap + } else { + &self.minimap_cache.as_ref().expect("just filled").1 + }; let minimap_vertex_count = (minimap_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let minimap_buffer = self .minimap_vertex_buffer @@ -5465,6 +6291,9 @@ impl State { // main-text clip-left. Computed here as locals — calling `self.*` // inside the `prepare` args would conflict with its `&mut` borrows. let text_left = self.text_left(); + // Hoisted for the same borrow reason as the colors below: the + // terminal areas are built inside a `&mut self.*` argument list. + let mono_advance = self.mono_advance(); let gutter_clip_left = if self.line_numbers.is_on() { text_left.floor() as i32 } else { @@ -5478,6 +6307,30 @@ impl State { .map_or(Color::rgb(168, 168, 180), |(_, text)| text); let left_color = self.status_left_color(); let gutter_color = self.face_fg_or("ui.gutter", Color::rgb(120, 120, 135)); + // Vterm Stage 3 — the document code layer is dropped entirely + // in terminal mode; terminal glyphs draw from their own + // per-run layer below, positioned at cell origins. + let code_areas: Vec