From bdf2b6e4b4b231028170e91eb165dfd53fdd0919 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 13:28:35 -0400 Subject: [PATCH] feat(vterm): protocol v19 terminal frames and a native GPU terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vterm Stage 3 — the final vterm stage. A semantic frontend can now host a terminal: the daemon ships complete validated cell grids, and pmacs-gpu renders them with fixed-cell geometry, its own input path, and no document projection at all. Protocol v19 appends three variants after their enums' final v18 members: InstanceMessage::TerminalFrame (daemon-gated), and FrontendEvent:: TerminalResize / TerminalPointer (frontend-gated). It is the first bump to gate in both directions, so criterion 28 pins each filter independently and byte pins on StatuslineSegments and MenuPointer guard the placements. pmacs-protocol gains src/terminal.rs: the shared row/column/visible-cell/ grapheme/metadata bounds, TerminalProcessState, TerminalSelectionSpan, and TerminalFrame::validate — the ONE structural policy the daemon runs before emission and the frontend runs after decode. src/terminal/* re-exports them so no duplicate type exists, and unicode-width becomes a workspace dependency so the screen and the validator measure glyph columns with one table. A new 8 MiB aggregate glyph bound keeps the largest legal frame (measured: 13,437,863 bytes) under the unchanged 16 MiB transport cap rather than widening every connection's allocation ceiling. The semantic producer suppresses the whole document family for a terminal buffer while keeping the status band, theme, font, statusline, menu, and minibuffer, and compares the complete ordered payload rather than screen_generation — scroll, selection, and process state all change without advancing it. Two things the framing did not spell out, both found by the real-daemon acceptance: The Viewport gate keys on the authenticated source's ACTIVE buffer, not the buffer the message names. Viewport also aligns the window to what it declares, so a stale document viewport in flight when a command opened a terminal dragged the frontend straight back off it: the window oscillated, every terminal declaration was refused, and no frame ever arrived, with nothing logged anywhere. The producer clears terminal mode on every exit path. The daemon uses that flag to suppress CursorByte and the presence sweep, so an early return that left it set kept both suppressed after the frontend returned to a document. pmacs-gpu/src/terminal.rs is a pure cell-space paint planner, unit-testable without a GPU. The renderer builds one shaped buffer per text run, so a wide or cluster glyph's advance can never choose the next column's origin. Criterion 37 needed a seam rather than a fixture: pmacs-gpu depends only on pmacs-protocol, so attach::connect's reader sink was generalized and a --headless-probe mode added. The acceptance drives a real daemon, a real /bin/sh child, the real attach client, and real composited pixels in one path — which is how both defects above were found. Gates: fmt; strict workspace clippy; 1,757 default + 1,933 CRDT library tests; vterm Stage 1 9/10, Stage 2 4/4, Stage 3 4/5 acceptance (default/CRDT); statusline 7/8; M4 120; required GPU 127; workspace sweep 2,919 across 83 suites; diff check clean. --- Cargo.lock | 1 + Cargo.toml | 6 +- docs/active-work.md | 74 +- docs/agent-handoff.md | 80 +- docs/vterm-framing.md | 82 +- pmacs-gpu/src/attach.rs | 153 ++- pmacs-gpu/src/main.rs | 1423 ++++++++++++++++++++++- pmacs-gpu/src/terminal.rs | 753 ++++++++++++ pmacs-protocol/Cargo.toml | 6 + pmacs-protocol/src/lib.rs | 6 + pmacs-protocol/src/message.rs | 94 +- pmacs-protocol/src/terminal.rs | 1010 ++++++++++++++++ src/daemon.rs | 153 ++- src/editor.rs | 193 ++- src/frontend.rs | 7 + src/protocol.rs | 138 ++- src/semantic_render.rs | 199 +++- src/terminal/mod.rs | 30 +- src/terminal/session.rs | 55 +- src/terminal/view.rs | 47 +- tests/common/daemon.rs | 11 + tests/statusline_segments_acceptance.rs | 11 +- tests/vterm_stage3_acceptance.rs | 809 +++++++++++++ 23 files changed, 5177 insertions(+), 164 deletions(-) create mode 100644 pmacs-gpu/src/terminal.rs create mode 100644 pmacs-protocol/src/terminal.rs create mode 100644 tests/vterm_stage3_acceptance.rs 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