feat(vterm): protocol v19 terminal frames and a native GPU terminal
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.
This commit is contained in:
parent
3adac8c4cb
commit
bdf2b6e4b4
|
|
@ -2594,6 +2594,7 @@ dependencies = [
|
|||
"postcard",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <socket> <report>` 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
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
|||
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<AppEvent>,
|
||||
) -> Result<AttachClient, AttachClientError> {
|
||||
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<AttachClient, AttachClientError> {
|
||||
let stream = UnixStream::connect(socket_path).map_err(AttachClientError::Connect)?;
|
||||
|
||||
|
|
@ -300,17 +331,13 @@ pub fn connect(
|
|||
loop {
|
||||
match read_message::<InstanceMessage>(&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();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,753 @@
|
|||
//! Fixed-cell terminal paint planning (Vterm Stage 3).
|
||||
//!
|
||||
//! The document renderer derives geometry from shaped text: a glyph's
|
||||
//! advance decides where the next glyph starts. A terminal cannot work
|
||||
//! that way. Its column origins are defined by the CHILD, and the
|
||||
//! frontend's font has no say in them — a wide glyph the font renders
|
||||
//! 1.9 cells wide still occupies exactly two columns, and the cell after
|
||||
//! it still starts at exactly `col * advance`.
|
||||
//!
|
||||
//! So this module resolves a [`TerminalFrame`] into a plan expressed in
|
||||
//! CELLS, never pixels. Row/column rectangles own backgrounds,
|
||||
//! underlines, selection, the cursor, and clipping; the renderer
|
||||
//! multiplies by its own metrics at the end. That split is also what
|
||||
//! makes the paint rules testable without a GPU: everything here is a
|
||||
//! pure function of the frame plus two default colors.
|
||||
|
||||
use pmacs_protocol::{Cell, CellSize, Color, Glyph, Style, TerminalFrame, UnderlineStyle};
|
||||
|
||||
/// A resolved 24-bit color. The plan carries no `Default` sentinel:
|
||||
/// resolution happens once, up front, because `reverse` swaps the
|
||||
/// RESOLVED pair — a reversed default-on-default cell must come out as
|
||||
/// dark-on-light, which is impossible if `Default` survives into the
|
||||
/// swap.
|
||||
pub type Rgb = [u8; 3];
|
||||
|
||||
/// The frontend defaults `Color::Default` resolves to.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct TerminalPalette {
|
||||
/// The GPU's plain-text color.
|
||||
pub default_fg: Rgb,
|
||||
/// The GPU's window background.
|
||||
pub default_bg: Rgb,
|
||||
}
|
||||
|
||||
/// A half-open run of cells on one row: `[start_col, end_col)`.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct CellRun {
|
||||
/// Row within the frame.
|
||||
pub row: u32,
|
||||
/// Inclusive first column.
|
||||
pub start_col: u32,
|
||||
/// Exclusive last column.
|
||||
pub end_col: u32,
|
||||
}
|
||||
|
||||
/// A background run with its resolved (post-`reverse`) color.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct BackgroundRun {
|
||||
/// Cells covered.
|
||||
pub run: CellRun,
|
||||
/// Resolved fill.
|
||||
pub color: Rgb,
|
||||
}
|
||||
|
||||
/// An underline run with its resolved color and form.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct UnderlineRun {
|
||||
/// Cells covered.
|
||||
pub run: CellRun,
|
||||
/// Resolved stroke color.
|
||||
pub color: Rgb,
|
||||
/// Which underline form to draw.
|
||||
pub style: UnderlineStyle,
|
||||
}
|
||||
|
||||
/// One shaped text run pinned to an explicit cell origin.
|
||||
///
|
||||
/// `cells` is the run's declared footprint. The renderer clips to it, so
|
||||
/// a font whose glyph is wider than the cells the child allocated
|
||||
/// overflows into a clip, never into the next column's origin.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TextRun {
|
||||
/// Row within the frame.
|
||||
pub row: u32,
|
||||
/// Origin column.
|
||||
pub col: u32,
|
||||
/// Declared width in cells.
|
||||
pub cells: u32,
|
||||
/// Text to shape.
|
||||
pub text: String,
|
||||
/// Resolved (post-`reverse`) foreground.
|
||||
pub color: Rgb,
|
||||
/// Bold via font attributes.
|
||||
pub bold: bool,
|
||||
/// Italic via font attributes.
|
||||
pub italic: bool,
|
||||
}
|
||||
|
||||
/// Everything one terminal frame paints, in cell coordinates.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct TerminalPaintPlan {
|
||||
/// The frame's declared grid.
|
||||
pub size: CellSize,
|
||||
/// Background fills, coalesced across adjacent equal colors.
|
||||
pub backgrounds: Vec<BackgroundRun>,
|
||||
/// Text runs in row-major order.
|
||||
pub runs: Vec<TextRun>,
|
||||
/// Underline strokes, coalesced across adjacent equal color+form.
|
||||
pub underlines: Vec<UnderlineRun>,
|
||||
/// Editor-owned selection wash, one run per selected row.
|
||||
pub selection: Vec<CellRun>,
|
||||
/// The child's cursor cell, when visible.
|
||||
pub cursor: Option<CellRun>,
|
||||
}
|
||||
|
||||
/// Resolve a cell's foreground and background, applying `reverse` after
|
||||
/// both defaults have been substituted.
|
||||
fn resolved_colors(style: &Style, palette: TerminalPalette) -> (Rgb, Rgb) {
|
||||
let fg = resolve_color(style.fg, palette.default_fg);
|
||||
let bg = resolve_color(style.bg, palette.default_bg);
|
||||
if style.reverse { (bg, fg) } else { (fg, bg) }
|
||||
}
|
||||
|
||||
/// Map one wire color through the frontend's defaults and palette.
|
||||
fn resolve_color(color: Color, default: Rgb) -> Rgb {
|
||||
match color {
|
||||
Color::Default => default,
|
||||
Color::Rgb(r, g, b) => [r, g, b],
|
||||
Color::Indexed(index) => indexed_rgb(index),
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard xterm-style 256-color palette: 16 base colors, the 6×6×6
|
||||
/// cube (16..=231), then the 24-step grayscale ramp (232..=255).
|
||||
///
|
||||
/// Deliberately the same table the document path's `indexed_to_glyphon`
|
||||
/// uses. Two palettes in one frontend would make an indexed diagnostic
|
||||
/// and an indexed terminal cell disagree on what "red" is.
|
||||
pub fn indexed_rgb(index: u8) -> Rgb {
|
||||
const ANSI16: [Rgb; 16] = [
|
||||
[0, 0, 0],
|
||||
[205, 49, 49],
|
||||
[13, 188, 121],
|
||||
[229, 229, 16],
|
||||
[36, 114, 200],
|
||||
[188, 63, 188],
|
||||
[17, 168, 205],
|
||||
[229, 229, 229],
|
||||
[102, 102, 102],
|
||||
[241, 76, 76],
|
||||
[35, 209, 139],
|
||||
[245, 245, 67],
|
||||
[59, 142, 234],
|
||||
[214, 112, 214],
|
||||
[41, 184, 219],
|
||||
[255, 255, 255],
|
||||
];
|
||||
if index < 16 {
|
||||
return ANSI16[index as usize];
|
||||
}
|
||||
if (16..=231).contains(&index) {
|
||||
const STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||
let i = index - 16;
|
||||
return [
|
||||
STEPS[(i / 36) as usize],
|
||||
STEPS[((i / 6) % 6) as usize],
|
||||
STEPS[(i % 6) as usize],
|
||||
];
|
||||
}
|
||||
let level = 8 + 10 * (index - 232);
|
||||
[level, level, level]
|
||||
}
|
||||
|
||||
/// The style a cell paints with.
|
||||
///
|
||||
/// A continuation has no paint identity of its own: it is the second
|
||||
/// column of the preceding glyph, so it inherits that lead's style. A
|
||||
/// continuation that carried its own style would let the two halves of
|
||||
/// one wide character disagree on background or underline.
|
||||
fn paint_style(cells: &[Cell], index: usize, row_start: usize) -> &Style {
|
||||
if matches!(cells[index].glyph, Glyph::Continuation) && index > row_start {
|
||||
&cells[index - 1].style
|
||||
} else {
|
||||
&cells[index].style
|
||||
}
|
||||
}
|
||||
|
||||
/// The text a cell contributes, or `None` for a continuation.
|
||||
fn cell_text(cell: &Cell) -> Option<String> {
|
||||
match &cell.glyph {
|
||||
Glyph::Char(ch) => Some(ch.to_string()),
|
||||
// Validation already proved the cluster is UTF-8; a defensive
|
||||
// lossy decode keeps a future validator change from panicking
|
||||
// the renderer.
|
||||
Glyph::Cluster(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
|
||||
Glyph::Continuation => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a cell can join an ASCII run.
|
||||
///
|
||||
/// Only single-column ASCII qualifies. Everything else — clusters, wide
|
||||
/// leads, non-ASCII — gets its own explicitly positioned run, because
|
||||
/// only for ASCII in a monospace face does the shaped advance reliably
|
||||
/// equal the cell advance.
|
||||
fn ascii_runnable(cell: &Cell) -> bool {
|
||||
matches!(&cell.glyph, Glyph::Char(ch) if ch.is_ascii_graphic() || *ch == ' ')
|
||||
}
|
||||
|
||||
/// Whether a cell is a wide lead (its continuation follows).
|
||||
fn is_wide_lead(cells: &[Cell], index: usize) -> bool {
|
||||
cells
|
||||
.get(index + 1)
|
||||
.is_some_and(|next| matches!(next.glyph, Glyph::Continuation))
|
||||
}
|
||||
|
||||
impl TerminalPaintPlan {
|
||||
/// Resolve a validated frame into cell-space paint data.
|
||||
///
|
||||
/// The frame must already have passed
|
||||
/// [`TerminalFrame::validate`]; this assumes the cell count, the
|
||||
/// wide-continuation topology, and the selection spans are sound.
|
||||
#[must_use]
|
||||
pub fn build(frame: &TerminalFrame, palette: TerminalPalette) -> Self {
|
||||
let cols = frame.size.cols as usize;
|
||||
let mut plan = Self {
|
||||
size: frame.size,
|
||||
..Self::default()
|
||||
};
|
||||
if cols == 0 {
|
||||
return plan;
|
||||
}
|
||||
|
||||
for row in 0..frame.size.rows {
|
||||
let row_start = row as usize * cols;
|
||||
let row_cells = &frame.cells[row_start..row_start + cols];
|
||||
plan.plan_row(row, row_cells, palette);
|
||||
}
|
||||
|
||||
plan.selection = frame
|
||||
.selection
|
||||
.iter()
|
||||
.map(|span| CellRun {
|
||||
row: span.row,
|
||||
start_col: span.start_col,
|
||||
end_col: span.end_col,
|
||||
})
|
||||
.collect();
|
||||
|
||||
plan.cursor = frame.cursor.map(|cursor| CellRun {
|
||||
row: cursor.row,
|
||||
start_col: cursor.col,
|
||||
end_col: cursor.col + 1,
|
||||
});
|
||||
|
||||
plan
|
||||
}
|
||||
|
||||
/// Plan one row's backgrounds, underlines, and text runs.
|
||||
#[allow(clippy::too_many_lines, reason = "one row's paint state machine")]
|
||||
fn plan_row(&mut self, row: u32, cells: &[Cell], palette: TerminalPalette) {
|
||||
// Backgrounds and underlines coalesce across the whole row;
|
||||
// text runs break on any attribute change AND on any glyph that
|
||||
// cannot be positioned by shaping.
|
||||
let mut bg_open: Option<(u32, Rgb)> = None;
|
||||
let mut ul_open: Option<(u32, Rgb, UnderlineStyle)> = None;
|
||||
let mut text_open: Option<(u32, String, Rgb, bool, bool)> = None;
|
||||
|
||||
for col in 0..cells.len() {
|
||||
let style = paint_style(cells, col, 0);
|
||||
let (fg, bg) = resolved_colors(style, palette);
|
||||
let col_u32 = col as u32;
|
||||
|
||||
match bg_open {
|
||||
Some((start, color)) if color != bg => {
|
||||
self.backgrounds.push(BackgroundRun {
|
||||
run: CellRun {
|
||||
row,
|
||||
start_col: start,
|
||||
end_col: col_u32,
|
||||
},
|
||||
color,
|
||||
});
|
||||
bg_open = Some((col_u32, bg));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => bg_open = Some((col_u32, bg)),
|
||||
}
|
||||
|
||||
// A `Default` underline color follows the POST-reverse
|
||||
// foreground: on a reversed cell the underline must track
|
||||
// the color the glyph actually drew in, not the one the
|
||||
// child nominally set.
|
||||
let underline = (style.underline != UnderlineStyle::None).then(|| {
|
||||
let color = match style.underline_color {
|
||||
Color::Default => fg,
|
||||
other => resolve_color(other, fg),
|
||||
};
|
||||
(color, style.underline)
|
||||
});
|
||||
match (ul_open, underline) {
|
||||
(Some((start, color, form)), Some((next_color, next_form)))
|
||||
if color != next_color || form != next_form =>
|
||||
{
|
||||
self.underlines.push(UnderlineRun {
|
||||
run: CellRun {
|
||||
row,
|
||||
start_col: start,
|
||||
end_col: col_u32,
|
||||
},
|
||||
color,
|
||||
style: form,
|
||||
});
|
||||
ul_open = Some((col_u32, next_color, next_form));
|
||||
}
|
||||
(Some((start, color, form)), None) => {
|
||||
self.underlines.push(UnderlineRun {
|
||||
run: CellRun {
|
||||
row,
|
||||
start_col: start,
|
||||
end_col: col_u32,
|
||||
},
|
||||
color,
|
||||
style: form,
|
||||
});
|
||||
ul_open = None;
|
||||
}
|
||||
(None, Some((color, form))) => ul_open = Some((col_u32, color, form)),
|
||||
(Some(_), Some(_)) | (None, None) => {}
|
||||
}
|
||||
|
||||
let cell = &cells[col];
|
||||
let joinable = ascii_runnable(cell) && !is_wide_lead(cells, col);
|
||||
if joinable {
|
||||
match text_open.as_mut() {
|
||||
Some((_, text, color, bold, italic))
|
||||
if *color == fg && *bold == style.bold && *italic == style.italic =>
|
||||
{
|
||||
text.push_str(&cell_text(cell).unwrap_or_default());
|
||||
}
|
||||
_ => {
|
||||
self.flush_text_run(row, text_open.take());
|
||||
text_open = Some((
|
||||
col_u32,
|
||||
cell_text(cell).unwrap_or_default(),
|
||||
fg,
|
||||
style.bold,
|
||||
style.italic,
|
||||
));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
self.flush_text_run(row, text_open.take());
|
||||
let Some(text) = cell_text(cell) else {
|
||||
// A continuation draws nothing: its lead already
|
||||
// covers both columns.
|
||||
continue;
|
||||
};
|
||||
let cells_wide = if is_wide_lead(cells, col) { 2 } else { 1 };
|
||||
self.runs.push(TextRun {
|
||||
row,
|
||||
col: col_u32,
|
||||
cells: cells_wide,
|
||||
text,
|
||||
color: fg,
|
||||
bold: style.bold,
|
||||
italic: style.italic,
|
||||
});
|
||||
}
|
||||
|
||||
let end = cells.len() as u32;
|
||||
if let Some((start, color)) = bg_open {
|
||||
self.backgrounds.push(BackgroundRun {
|
||||
run: CellRun {
|
||||
row,
|
||||
start_col: start,
|
||||
end_col: end,
|
||||
},
|
||||
color,
|
||||
});
|
||||
}
|
||||
if let Some((start, color, form)) = ul_open {
|
||||
self.underlines.push(UnderlineRun {
|
||||
run: CellRun {
|
||||
row,
|
||||
start_col: start,
|
||||
end_col: end,
|
||||
},
|
||||
color,
|
||||
style: form,
|
||||
});
|
||||
}
|
||||
self.flush_text_run(row, text_open);
|
||||
}
|
||||
|
||||
fn flush_text_run(&mut self, row: u32, open: Option<(u32, String, Rgb, bool, bool)>) {
|
||||
let Some((col, text, color, bold, italic)) = open else {
|
||||
return;
|
||||
};
|
||||
let cells = text.chars().count() as u32;
|
||||
self.runs.push(TextRun {
|
||||
row,
|
||||
col,
|
||||
cells,
|
||||
text,
|
||||
color,
|
||||
bold,
|
||||
italic,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The terminal cell viewport a drawable rectangle admits.
|
||||
///
|
||||
/// Rows and columns are `floor(extent / metric)`, clamped through the
|
||||
/// shared protocol limits so an enormous window cannot declare a grid
|
||||
/// the daemon would reject. A rectangle too small for one whole cell
|
||||
/// yields `None`: a zero-area declaration is not sent at all, and the
|
||||
/// next geometry change that produces a valid size sends one.
|
||||
#[must_use]
|
||||
pub fn cell_viewport(
|
||||
width_px: f32,
|
||||
height_px: f32,
|
||||
advance_px: f32,
|
||||
line_px: f32,
|
||||
) -> Option<CellSize> {
|
||||
if !(advance_px.is_finite() && line_px.is_finite()) || advance_px <= 0.0 || line_px <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if !(width_px.is_finite() && height_px.is_finite()) || width_px <= 0.0 || height_px <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let cols = (width_px / advance_px).floor();
|
||||
let rows = (height_px / line_px).floor();
|
||||
if cols < 1.0 || rows < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let cols = (cols as u32).min(u32::from(pmacs_protocol::MAX_TERMINAL_COLS));
|
||||
let rows = (rows as u32).min(u32::from(pmacs_protocol::MAX_TERMINAL_ROWS));
|
||||
// The area bound can still bite at the extremes (512x512 is exactly
|
||||
// the cap, but a future limit change need not keep that true), so
|
||||
// shed rows rather than emit a size the daemon must reject.
|
||||
let max_rows = (pmacs_protocol::MAX_TERMINAL_VISIBLE_CELLS / cols as usize) as u32;
|
||||
let rows = rows.min(max_rows);
|
||||
if rows == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(CellSize::new(rows, cols))
|
||||
}
|
||||
|
||||
/// Hit-test a pixel inside the terminal rectangle to a cell.
|
||||
///
|
||||
/// Returns `None` outside the declared grid, so the status band, the
|
||||
/// padding past the last full column, and any point above the terminal
|
||||
/// origin are never terminal hits.
|
||||
#[must_use]
|
||||
pub fn hit_test_cell(
|
||||
x_px: f32,
|
||||
y_px: f32,
|
||||
origin: (f32, f32),
|
||||
advance_px: f32,
|
||||
line_px: f32,
|
||||
size: CellSize,
|
||||
) -> Option<pmacs_protocol::CellCoord> {
|
||||
if advance_px <= 0.0 || line_px <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let dx = x_px - origin.0;
|
||||
let dy = y_px - origin.1;
|
||||
if dx < 0.0 || dy < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let col = (dx / advance_px).floor();
|
||||
let row = (dy / line_px).floor();
|
||||
if col < 0.0 || row < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let col = col as u32;
|
||||
let row = row as u32;
|
||||
if row >= size.rows || col >= size.cols {
|
||||
return None;
|
||||
}
|
||||
Some(pmacs_protocol::CellCoord::new(row, col))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pmacs_protocol::{BufferId, CellCoord, TerminalProcessState, TerminalSelectionSpan};
|
||||
|
||||
const PALETTE: TerminalPalette = TerminalPalette {
|
||||
default_fg: [230, 230, 235],
|
||||
default_bg: [13, 13, 18],
|
||||
};
|
||||
|
||||
fn styled(glyph: Glyph, style: Style) -> Cell {
|
||||
Cell {
|
||||
glyph,
|
||||
style,
|
||||
attachment: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn plain(ch: char) -> Cell {
|
||||
styled(Glyph::Char(ch), Style::default())
|
||||
}
|
||||
|
||||
fn frame(rows: u32, cols: u32, cells: Vec<Cell>) -> TerminalFrame {
|
||||
TerminalFrame {
|
||||
buffer_id: BufferId::from_raw(1),
|
||||
size: CellSize::new(rows, cols),
|
||||
cells,
|
||||
cursor: None,
|
||||
title: None,
|
||||
screen_generation: 1,
|
||||
selection: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
at_bottom: true,
|
||||
pid: 1,
|
||||
process: TerminalProcessState::Running,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_coalesces_by_attributes_and_keeps_explicit_origins() {
|
||||
let red = Style {
|
||||
fg: Color::Indexed(1),
|
||||
..Style::default()
|
||||
};
|
||||
let cells = vec![
|
||||
plain('a'),
|
||||
plain('b'),
|
||||
styled(Glyph::Char('c'), red),
|
||||
plain('d'),
|
||||
];
|
||||
let plan = TerminalPaintPlan::build(&frame(1, 4, cells), PALETTE);
|
||||
assert_eq!(plan.runs.len(), 3);
|
||||
assert_eq!(plan.runs[0].text, "ab");
|
||||
assert_eq!(plan.runs[0].col, 0);
|
||||
assert_eq!(plan.runs[0].cells, 2);
|
||||
assert_eq!(plan.runs[1].text, "c");
|
||||
assert_eq!(plan.runs[1].col, 2);
|
||||
assert_eq!(plan.runs[1].color, indexed_rgb(1));
|
||||
// The run after the attribute change is positioned by its own
|
||||
// column, not by where the previous run's glyphs happened to end.
|
||||
assert_eq!(plan.runs[2].col, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_lead_owns_two_cells_and_its_continuation_draws_nothing() {
|
||||
let cyan = Style {
|
||||
bg: Color::Rgb(0, 40, 40),
|
||||
..Style::default()
|
||||
};
|
||||
let cells = vec![
|
||||
styled(Glyph::Char('\u{4e00}'), cyan),
|
||||
styled(Glyph::Continuation, Style::default()),
|
||||
plain('x'),
|
||||
plain('y'),
|
||||
];
|
||||
let plan = TerminalPaintPlan::build(&frame(1, 4, cells), PALETTE);
|
||||
let wide = &plan.runs[0];
|
||||
assert_eq!(wide.text, "\u{4e00}");
|
||||
assert_eq!(wide.col, 0);
|
||||
assert_eq!(wide.cells, 2, "a wide lead declares a two-cell footprint");
|
||||
assert_eq!(
|
||||
plan.runs[1].col, 2,
|
||||
"the run after a wide glyph starts at its own column"
|
||||
);
|
||||
assert!(
|
||||
plan.runs.iter().all(|run| run.col != 1),
|
||||
"a continuation contributes no text run"
|
||||
);
|
||||
// The continuation carries a DEFAULT style on the wire but must
|
||||
// paint with its lead's background.
|
||||
let covering = plan
|
||||
.backgrounds
|
||||
.iter()
|
||||
.find(|bg| bg.run.start_col == 0)
|
||||
.expect("lead background");
|
||||
assert_eq!(covering.color, [0, 40, 40]);
|
||||
assert_eq!(
|
||||
covering.run.end_col, 2,
|
||||
"the lead's background covers both of its columns"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_swaps_resolved_defaults_and_drives_the_underline_color() {
|
||||
let style = Style {
|
||||
reverse: true,
|
||||
underline: UnderlineStyle::Single,
|
||||
..Style::default()
|
||||
};
|
||||
let plan =
|
||||
TerminalPaintPlan::build(&frame(1, 1, vec![styled(Glyph::Char('z'), style)]), PALETTE);
|
||||
assert_eq!(plan.backgrounds[0].color, PALETTE.default_fg);
|
||||
assert_eq!(plan.runs[0].color, PALETTE.default_bg);
|
||||
assert_eq!(
|
||||
plan.underlines[0].color, PALETTE.default_bg,
|
||||
"a default underline color follows the post-reverse foreground"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_underline_color_and_form_coalesce_then_break() {
|
||||
let curly = Style {
|
||||
underline: UnderlineStyle::Curly,
|
||||
underline_color: Color::Rgb(200, 0, 0),
|
||||
..Style::default()
|
||||
};
|
||||
let mut dotted = curly;
|
||||
dotted.underline = UnderlineStyle::Dotted;
|
||||
let cells = vec![
|
||||
styled(Glyph::Char('a'), curly),
|
||||
styled(Glyph::Char('b'), curly),
|
||||
styled(Glyph::Char('c'), dotted),
|
||||
plain('d'),
|
||||
];
|
||||
let plan = TerminalPaintPlan::build(&frame(1, 4, cells), PALETTE);
|
||||
assert_eq!(plan.underlines.len(), 2);
|
||||
assert_eq!(plan.underlines[0].run.start_col, 0);
|
||||
assert_eq!(plan.underlines[0].run.end_col, 2);
|
||||
assert_eq!(plan.underlines[0].style, UnderlineStyle::Curly);
|
||||
assert_eq!(plan.underlines[0].color, [200, 0, 0]);
|
||||
assert_eq!(plan.underlines[1].run.start_col, 2);
|
||||
assert_eq!(plan.underlines[1].run.end_col, 3);
|
||||
assert_eq!(plan.underlines[1].style, UnderlineStyle::Dotted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_italic_and_truecolor_reach_the_run() {
|
||||
let style = Style {
|
||||
bold: true,
|
||||
italic: true,
|
||||
fg: Color::Rgb(1, 2, 3),
|
||||
..Style::default()
|
||||
};
|
||||
let plan =
|
||||
TerminalPaintPlan::build(&frame(1, 1, vec![styled(Glyph::Char('q'), style)]), PALETTE);
|
||||
assert!(plan.runs[0].bold);
|
||||
assert!(plan.runs[0].italic);
|
||||
assert_eq!(plan.runs[0].color, [1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_and_cursor_come_only_from_the_frame() {
|
||||
let mut f = frame(2, 4, vec![plain('.'); 8]);
|
||||
f.selection = vec![TerminalSelectionSpan {
|
||||
row: 1,
|
||||
start_col: 1,
|
||||
end_col: 3,
|
||||
}];
|
||||
f.cursor = Some(CellCoord::new(0, 2));
|
||||
let plan = TerminalPaintPlan::build(&f, PALETTE);
|
||||
assert_eq!(
|
||||
plan.selection,
|
||||
vec![CellRun {
|
||||
row: 1,
|
||||
start_col: 1,
|
||||
end_col: 3
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
plan.cursor,
|
||||
Some(CellRun {
|
||||
row: 0,
|
||||
start_col: 2,
|
||||
end_col: 3
|
||||
})
|
||||
);
|
||||
|
||||
// A frame with no cursor paints none: visibility is the child's
|
||||
// decision, never the frontend's.
|
||||
f.cursor = None;
|
||||
assert!(TerminalPaintPlan::build(&f, PALETTE).cursor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_cells_get_their_own_positioned_run() {
|
||||
let cells = vec![
|
||||
plain('a'),
|
||||
styled(
|
||||
Glyph::Cluster("e\u{301}".as_bytes().to_vec().into_boxed_slice()),
|
||||
Style::default(),
|
||||
),
|
||||
plain('b'),
|
||||
];
|
||||
let plan = TerminalPaintPlan::build(&frame(1, 3, cells), PALETTE);
|
||||
assert_eq!(plan.runs.len(), 3);
|
||||
assert_eq!(plan.runs[1].text, "e\u{301}");
|
||||
assert_eq!(plan.runs[1].col, 1);
|
||||
assert_eq!(plan.runs[1].cells, 1);
|
||||
assert_eq!(plan.runs[2].col, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_never_share_runs_or_background_spans() {
|
||||
let plan = TerminalPaintPlan::build(&frame(2, 2, vec![plain('x'); 4]), PALETTE);
|
||||
assert_eq!(
|
||||
plan.runs.len(),
|
||||
2,
|
||||
"one run per row, never wrapped together"
|
||||
);
|
||||
assert_eq!(plan.runs[0].row, 0);
|
||||
assert_eq!(plan.runs[1].row, 1);
|
||||
assert!(plan.backgrounds.iter().all(|bg| bg.run.end_col <= 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_viewport_floors_clamps_and_refuses_a_degenerate_rectangle() {
|
||||
assert_eq!(
|
||||
cell_viewport(100.0, 50.0, 10.0, 20.0),
|
||||
Some(CellSize::new(2, 10))
|
||||
);
|
||||
// Partial cells are dropped, never rounded up into a column the
|
||||
// child would write past.
|
||||
assert_eq!(
|
||||
cell_viewport(109.0, 59.0, 10.0, 20.0),
|
||||
Some(CellSize::new(2, 10))
|
||||
);
|
||||
assert_eq!(cell_viewport(9.0, 20.0, 10.0, 20.0), None);
|
||||
assert_eq!(cell_viewport(100.0, 19.0, 10.0, 20.0), None);
|
||||
assert_eq!(cell_viewport(100.0, 50.0, 0.0, 20.0), None);
|
||||
assert_eq!(cell_viewport(f32::NAN, 50.0, 10.0, 20.0), None);
|
||||
let huge = cell_viewport(1_000_000.0, 1_000_000.0, 1.0, 1.0).expect("clamped");
|
||||
assert_eq!(huge.rows, u32::from(pmacs_protocol::MAX_TERMINAL_ROWS));
|
||||
assert_eq!(huge.cols, u32::from(pmacs_protocol::MAX_TERMINAL_COLS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_yields_only_in_bounds_cells() {
|
||||
let size = CellSize::new(3, 4);
|
||||
assert_eq!(
|
||||
hit_test_cell(16.0, 16.0, (16.0, 16.0), 10.0, 20.0, size),
|
||||
Some(CellCoord::new(0, 0))
|
||||
);
|
||||
assert_eq!(
|
||||
hit_test_cell(16.0 + 25.0, 16.0 + 41.0, (16.0, 16.0), 10.0, 20.0, size),
|
||||
Some(CellCoord::new(2, 2))
|
||||
);
|
||||
// Above / left of the origin, and past the last declared cell —
|
||||
// the status band and the trailing padding are not terminal hits.
|
||||
assert_eq!(
|
||||
hit_test_cell(15.0, 16.0, (16.0, 16.0), 10.0, 20.0, size),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
hit_test_cell(16.0, 15.0, (16.0, 16.0), 10.0, 20.0, size),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
hit_test_cell(16.0 + 40.0, 16.0, (16.0, 16.0), 10.0, 20.0, size),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
hit_test_cell(16.0, 16.0 + 60.0, (16.0, 16.0), 10.0, 20.0, size),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -46,3 +46,9 @@ crdt = []
|
|||
serde = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
# Vterm Stage 3: `TerminalFrame::validate` is the ONE structural policy
|
||||
# for terminal cells, so the column-width and wide-continuation rules
|
||||
# live here rather than being re-implemented per frontend. That needs a
|
||||
# direct width table; pinned through the workspace so this crate and
|
||||
# `pmacs`'s terminal screen measure glyphs identically.
|
||||
unicode-width = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ pub mod cell;
|
|||
pub mod crdt;
|
||||
pub mod ids;
|
||||
pub mod message;
|
||||
pub mod terminal;
|
||||
pub mod transport;
|
||||
|
||||
pub use cell::{
|
||||
|
|
@ -57,4 +58,9 @@ pub use message::{
|
|||
StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char,
|
||||
is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
|
||||
};
|
||||
pub use terminal::{
|
||||
MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES,
|
||||
MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame,
|
||||
TerminalFrameError, TerminalProcessState, TerminalSelectionSpan,
|
||||
};
|
||||
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};
|
||||
|
|
|
|||
|
|
@ -393,6 +393,55 @@ pub enum FrontendEvent {
|
|||
/// moves the highlight.
|
||||
invoke: bool,
|
||||
},
|
||||
/// Vterm Stage 3 (protocol v19): the terminal-cell geometry a
|
||||
/// semantic frontend has on screen for `buffer_id`.
|
||||
///
|
||||
/// This is the terminal twin of [`Self::Viewport`] and keeps the
|
||||
/// same no-pixels contract: it carries a CELL size, never pixel
|
||||
/// extent, DPI, or glyph advances. The frontend divides its own
|
||||
/// drawable rectangle by its own metrics.
|
||||
///
|
||||
/// A v19 frontend sends both this and `Viewport` after every
|
||||
/// `BufferSnapshot`; the daemon accepts only the declaration
|
||||
/// matching the authenticated active buffer's kind and drops the
|
||||
/// other. That dual declaration is what removes the otherwise
|
||||
/// circular dependency where a frontend would need a terminal frame
|
||||
/// before it knew to ask for one.
|
||||
///
|
||||
/// Recording the size is not claiming control: a passive view's
|
||||
/// declaration produces its own clipped/padded projection, while
|
||||
/// only the durable controller changes the shared PTY geometry.
|
||||
/// Sent only to a `>= 19` daemon.
|
||||
TerminalResize {
|
||||
/// Which frontend declared the geometry (untrusted; the daemon
|
||||
/// routes by the authenticated session, matching the
|
||||
/// `CrdtOp` / `Viewport` / `Pointer` source-trust rule).
|
||||
frontend_id: FrontendId,
|
||||
/// Terminal identity buffer the frontend was displaying.
|
||||
buffer_id: crate::BufferId,
|
||||
/// Content rectangle size in terminal cells.
|
||||
size: CellSize,
|
||||
},
|
||||
/// Vterm Stage 3 (protocol v19): a pointer gesture a semantic
|
||||
/// frontend hit-tested to a terminal CELL.
|
||||
///
|
||||
/// Terminal windows have no source bytes to hit-test against, so
|
||||
/// this replaces [`Self::Pointer`] inside the terminal clip. Once
|
||||
/// accepted it follows the landed Stage 2 pointer path: child SGR
|
||||
/// mouse reporting when eligible, otherwise per-view scroll,
|
||||
/// selection, or context menu. Sent only to a `>= 19` daemon.
|
||||
TerminalPointer {
|
||||
/// Which frontend produced the gesture (untrusted, as above).
|
||||
frontend_id: FrontendId,
|
||||
/// Terminal identity buffer the frontend was displaying.
|
||||
buffer_id: crate::BufferId,
|
||||
/// Cell the pointer is over, within the last declared viewport.
|
||||
coord: CellCoord,
|
||||
/// Which gesture step this is.
|
||||
kind: MouseKind,
|
||||
/// Modifiers held during the gesture.
|
||||
mods: Modifiers,
|
||||
},
|
||||
}
|
||||
|
||||
/// Gesture step for [`FrontendEvent::Pointer`]. Double-click
|
||||
|
|
@ -437,7 +486,9 @@ impl FrontendEvent {
|
|||
| Self::CrdtOp { frontend_id, .. }
|
||||
| Self::Viewport { frontend_id, .. }
|
||||
| Self::Pointer { frontend_id, .. }
|
||||
| Self::MenuPointer { frontend_id, .. } => *frontend_id,
|
||||
| Self::MenuPointer { frontend_id, .. }
|
||||
| Self::TerminalResize { frontend_id, .. }
|
||||
| Self::TerminalPointer { frontend_id, .. } => *frontend_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1067,6 +1118,25 @@ pub enum InstanceMessage {
|
|||
/// Right-side custom segments in display order.
|
||||
right: Vec<StatuslineSegment>,
|
||||
},
|
||||
/// Vterm Stage 3 (protocol v19). The complete visible terminal grid
|
||||
/// for the receiving frontend's active terminal window.
|
||||
///
|
||||
/// A terminal identity buffer's `BufferSnapshot` is an empty CRDT
|
||||
/// anchor, so a semantic frontend has no text to lay out; this
|
||||
/// carries the cells instead. It is a whole-grid replacement, never
|
||||
/// a delta, and it is validated by
|
||||
/// [`crate::terminal::TerminalFrame::validate`] both before the
|
||||
/// daemon emits it and after the frontend decodes it.
|
||||
///
|
||||
/// Suppression compares the COMPLETE ordered payload, not
|
||||
/// `screen_generation`: selection, scroll, viewport, and process
|
||||
/// state all change without advancing that counter, and a
|
||||
/// generation-keyed producer would go silent on exactly those
|
||||
/// view-only updates. Daemon-gated `>= 19`.
|
||||
///
|
||||
/// Appended after [`Self::StatuslineSegments`], the final v18
|
||||
/// variant, so no existing postcard discriminant moves.
|
||||
TerminalFrame(crate::terminal::TerminalFrame),
|
||||
}
|
||||
|
||||
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
|
||||
|
|
@ -1471,7 +1541,19 @@ pub enum ResourceBody {
|
|||
/// carrying custom modeline provider output. Daemon-gated `< 18`; a
|
||||
/// v17 peer keeps the built-in status band. Appended after `FontFacts`
|
||||
/// so the final v17 discriminant remains stable.
|
||||
pub const PROTOCOL_VERSION: u32 = 18;
|
||||
///
|
||||
/// Vterm Stage 3: bumped 18 → 19 for the terminal family —
|
||||
/// [`InstanceMessage::TerminalFrame`] (daemon-gated `< 19`) plus
|
||||
/// [`FrontendEvent::TerminalResize`] and
|
||||
/// [`FrontendEvent::TerminalPointer`] (frontend-gated, sent only to a
|
||||
/// `>= 19` instance). All three are appended after their enum's final
|
||||
/// v18 variant, so the ladder resumes on the v6 encoding floor: a v18
|
||||
/// grid peer keeps receiving Stage 2's composed `CellDelta` terminal
|
||||
/// windows, and a v18 semantic peer keeps ordinary document editing
|
||||
/// with no terminal surface at all. This is the first bump to gate in
|
||||
/// BOTH directions at once, which is why criterion 28 pins the two
|
||||
/// send filters independently.
|
||||
pub const PROTOCOL_VERSION: u32 = 19;
|
||||
|
||||
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
|
||||
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
|
||||
|
|
@ -1540,7 +1622,13 @@ pub const PROTOCOL_VERSION: u32 = 18;
|
|||
///
|
||||
/// Q#SL7: extended to `[6, ..., 18]`.
|
||||
/// [`InstanceMessage::StatuslineSegments`] is additive and daemon-gated.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18];
|
||||
///
|
||||
/// Vterm Stage 3: extended to `[6, ..., 19]`. The terminal family is
|
||||
/// additive in both directions — `TerminalFrame` is daemon-gated,
|
||||
/// `TerminalResize` / `TerminalPointer` are frontend-gated — so v18 and
|
||||
/// v19 binaries interoperate with terminal traffic simply absent.
|
||||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] =
|
||||
&[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
||||
|
||||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||||
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
153
src/daemon.rs
153
src/daemon.rs
|
|
@ -799,6 +799,14 @@ fn peer_accepts_statusline_message(protocol_version: u32, message: &InstanceMess
|
|||
protocol_version >= 18 || !matches!(message, InstanceMessage::StatuslineSegments { .. })
|
||||
}
|
||||
|
||||
/// The same belt-and-braces write-loop gate for the additive
|
||||
/// protocol-v19 terminal frame. The semantic producer skips construction
|
||||
/// for an older peer; this filter independently prevents an unknown
|
||||
/// discriminant reaching one, so neither gate alone is load-bearing.
|
||||
fn peer_accepts_terminal_message(protocol_version: u32, message: &InstanceMessage) -> bool {
|
||||
protocol_version >= 19 || !matches!(message, InstanceMessage::TerminalFrame(_))
|
||||
}
|
||||
|
||||
/// T M10.8 — dispatcher loop. The single thread that owns the editor.
|
||||
///
|
||||
/// All attached frontends' inputs arrive via the `dispatcher_rx`
|
||||
|
|
@ -1078,11 +1086,24 @@ fn dispatcher_loop(
|
|||
render_state.render_frame(editor, *fid, &terminal_snapshots, &other_presences)
|
||||
};
|
||||
|
||||
// Vterm Stage 3 — a semantic frontend showing a terminal has
|
||||
// no document cursor: the identity buffer is empty, so both
|
||||
// presence and `CursorByte` would describe byte 0 of a
|
||||
// buffer with no text, painting a phantom peer caret over
|
||||
// the cell grid.
|
||||
let terminal_mode = semantic_states
|
||||
.get(fid)
|
||||
.is_some_and(crate::semantic_render::SemanticRenderState::in_terminal_mode);
|
||||
|
||||
// T M10.6 per-frontend presence sweep. The snapshot is
|
||||
// computed from this frontend's view; the sweep then
|
||||
// produces broadcasts to OTHER multi-frontend recipients.
|
||||
let snapshot = build_presence_snapshot(editor, *fid);
|
||||
let broadcasts = session_registry.sweep(&[(*fid, snapshot)]);
|
||||
let broadcasts = if terminal_mode {
|
||||
Vec::new()
|
||||
} else {
|
||||
let snapshot = build_presence_snapshot(editor, *fid);
|
||||
session_registry.sweep(&[(*fid, snapshot)])
|
||||
};
|
||||
|
||||
// T M11.6 — DispatchIdle signal. `crdt_replica` frontends
|
||||
// gate their optimistic-apply path on this; we ship it
|
||||
|
|
@ -1217,6 +1238,14 @@ fn dispatcher_loop(
|
|||
if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) {
|
||||
continue;
|
||||
}
|
||||
// Vterm Stage 3 — TerminalFrame gated at v19. A v18
|
||||
// semantic peer keeps the empty identity snapshot and
|
||||
// no terminal surface; a v18 grid peer is unaffected
|
||||
// because it composes terminal windows into its own
|
||||
// CellDelta.
|
||||
if !peer_accepts_terminal_message(negotiated_protocol_version, msg) {
|
||||
continue;
|
||||
}
|
||||
if !peer_accepts_statusline_message(negotiated_protocol_version, msg) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1266,6 +1295,7 @@ fn dispatcher_loop(
|
|||
// optimistic-apply path consumes byte_pos; the legacy
|
||||
// paint path consumes the grid coord.
|
||||
if !write_failed
|
||||
&& !terminal_mode
|
||||
&& session_registry
|
||||
.session_state(*fid)
|
||||
.is_some_and(|s| s.negotiated_capabilities.crdt_replica)
|
||||
|
|
@ -1380,6 +1410,18 @@ fn dispatcher_loop(
|
|||
if let Some(size) = term_sizes.get(frontend_id).copied() {
|
||||
editor.sync_terminal_layout(*frontend_id, size);
|
||||
}
|
||||
// Vterm Stage 3 — the semantic twin, right beside the grid
|
||||
// sync so both frontend kinds resize the screen before the
|
||||
// next child-output drain. The frontend declared a CONTENT
|
||||
// rectangle, so this consumes the size directly instead of
|
||||
// running the TUI placement helper, which would subtract a
|
||||
// modeline the GPU never drew.
|
||||
if let Some((buffer_id, size)) = semantic_states
|
||||
.get(frontend_id)
|
||||
.and_then(crate::semantic_render::SemanticRenderState::terminal_viewport)
|
||||
{
|
||||
editor.sync_semantic_terminal_layout(*frontend_id, buffer_id, size);
|
||||
}
|
||||
}
|
||||
|
||||
// `tick_async` last: the M4.5 async bridge settles awaiters
|
||||
|
|
@ -1621,7 +1663,30 @@ fn handle_dispatcher_event(
|
|||
// session never sends this; if one does, there is
|
||||
// no `SemanticRenderState` to update and it is a
|
||||
// benign no-op.
|
||||
if semantic_states.contains_key(&source) {
|
||||
// Vterm Stage 3 — a v19 frontend declares BOTH a
|
||||
// byte viewport and a terminal cell size after every
|
||||
// snapshot, because an empty terminal identity
|
||||
// snapshot does not announce itself as a terminal.
|
||||
// The daemon keeps only the declaration appropriate
|
||||
// to the authenticated source's ACTIVE buffer.
|
||||
//
|
||||
// Keying on the active buffer rather than the
|
||||
// declared one is load-bearing: `Viewport` also
|
||||
// ALIGNS the window to the buffer it names, so a
|
||||
// stale document viewport still in flight when a
|
||||
// command opens a terminal would drag the frontend
|
||||
// straight back off it. The declared buffer is
|
||||
// checked too — a terminal has no byte viewport to
|
||||
// honor from any direction.
|
||||
let terminal_context = {
|
||||
let manager = editor.terminal_manager.borrow();
|
||||
let core = editor.core.borrow();
|
||||
let active = core
|
||||
.active_window_for(source)
|
||||
.is_some_and(|window| manager.is_terminal(window.buffer_id));
|
||||
active || manager.is_terminal(buffer_id)
|
||||
};
|
||||
if semantic_states.contains_key(&source) && !terminal_context {
|
||||
// Phase B (B1) — the Viewport declares *which
|
||||
// buffer this frontend is displaying*. Align its
|
||||
// editor window to that buffer so keyboard input
|
||||
|
|
@ -1637,6 +1702,47 @@ fn handle_dispatcher_event(
|
|||
}
|
||||
}
|
||||
}
|
||||
FrontendEvent::TerminalResize {
|
||||
buffer_id, size, ..
|
||||
} => {
|
||||
// Vterm Stage 3 — the terminal half of the dual
|
||||
// declaration. Routed by the authenticated `source`;
|
||||
// the payload's `frontend_id` is never read.
|
||||
//
|
||||
// Recording the geometry is what lets a PASSIVE view
|
||||
// receive its own clipped/padded projection, so the
|
||||
// record happens for any accepted declaration. Only
|
||||
// the durable controller's declaration reaches the
|
||||
// shared PTY — a declaration never claims control.
|
||||
if semantic_states.contains_key(&source)
|
||||
&& editor.semantic_terminal_declaration_is_active(source, buffer_id)
|
||||
{
|
||||
if let Some(sem) = semantic_states.get_mut(&source) {
|
||||
sem.set_terminal_viewport(buffer_id, size);
|
||||
}
|
||||
editor.sync_semantic_terminal_layout(source, buffer_id, size);
|
||||
}
|
||||
}
|
||||
FrontendEvent::TerminalPointer {
|
||||
buffer_id,
|
||||
coord,
|
||||
kind,
|
||||
mods,
|
||||
..
|
||||
} => {
|
||||
// Vterm Stage 3 — a terminal-cell gesture. The
|
||||
// adapter re-derives the window from the
|
||||
// authenticated source and checks the coordinate
|
||||
// against the geometry that source declared, so a
|
||||
// forged id, a stale buffer, a missing declaration,
|
||||
// or an out-of-bounds cell all drop before any view,
|
||||
// controller, selection, menu, or PTY mutation.
|
||||
if semantic_states.contains_key(&source) {
|
||||
editor.dispatch_semantic_terminal_pointer(
|
||||
source, buffer_id, coord, kind, mods,
|
||||
);
|
||||
}
|
||||
}
|
||||
FrontendEvent::Pointer {
|
||||
buffer_id,
|
||||
byte,
|
||||
|
|
@ -2653,6 +2759,18 @@ fn apply_event(
|
|||
"pmacs daemon: FrontendEvent::MenuPointer from a grid session; dropping"
|
||||
);
|
||||
}
|
||||
FrontendEvent::TerminalResize { .. } | FrontendEvent::TerminalPointer { .. } => {
|
||||
// Vterm Stage 3 — the terminal declarations belong to
|
||||
// semantic sessions and are routed by the authenticated
|
||||
// source in `handle_dispatcher_event`. A grid session
|
||||
// resizes its terminal through the Stage 2 layout path, so
|
||||
// one arriving here is a protocol violation; drop it
|
||||
// rather than letting a payload-trusted id reach a view.
|
||||
eprintln!(
|
||||
"pmacs daemon: terminal declaration from a grid session; dropping \
|
||||
(grid terminals resize through the Stage 2 layout path)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2687,6 +2805,35 @@ mod tests {
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_frame_write_gate_rejects_v18_independently() {
|
||||
let frame = InstanceMessage::TerminalFrame(crate::terminal::TerminalFrame {
|
||||
buffer_id: crate::buffer::BufferId::from_raw(2),
|
||||
size: crate::cell::CellSize::new(1, 1),
|
||||
cells: vec![crate::cell::Cell::default()],
|
||||
cursor: None,
|
||||
title: None,
|
||||
screen_generation: 0,
|
||||
selection: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
at_bottom: true,
|
||||
pid: 1,
|
||||
process: crate::terminal::TerminalProcessState::Running,
|
||||
});
|
||||
assert!(!peer_accepts_terminal_message(18, &frame));
|
||||
assert!(peer_accepts_terminal_message(19, &frame));
|
||||
// The gate is variant-scoped: it must not silence anything else
|
||||
// on an older wire.
|
||||
assert!(peer_accepts_terminal_message(
|
||||
18,
|
||||
&InstanceMessage::StatuslineSegments {
|
||||
buffer_id: crate::buffer::BufferId::from_raw(2),
|
||||
left: Vec::new(),
|
||||
right: Vec::new(),
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_identity_includes_version_and_uptime() {
|
||||
let s = DaemonState::new(Some("research".into()));
|
||||
|
|
|
|||
193
src/editor.rs
193
src/editor.rs
|
|
@ -1122,6 +1122,155 @@ impl EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve the exact terminal view a semantic frontend is showing.
|
||||
///
|
||||
/// The window identity is DERIVED from the authenticated frontend,
|
||||
/// never accepted from the wire: a semantic peer names only a
|
||||
/// buffer, so a forged or stale `buffer_id` fails this check and
|
||||
/// reaches no view, controller, or PTY. A window that has since
|
||||
/// switched away also fails, which is what makes a pointer racing a
|
||||
/// buffer switch a no-op instead of a gesture on the wrong buffer.
|
||||
fn semantic_terminal_key(
|
||||
&self,
|
||||
frontend_id: FrontendId,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
) -> Option<TerminalViewKey> {
|
||||
let core = self.core.borrow();
|
||||
let view = core.views.get(&frontend_id)?;
|
||||
let window = core.windows.get(&view.active)?;
|
||||
if window.buffer_id != buffer_id {
|
||||
return None;
|
||||
}
|
||||
let key = TerminalViewKey::new(frontend_id, window.id, buffer_id);
|
||||
self.terminal_manager
|
||||
.borrow()
|
||||
.is_terminal(buffer_id)
|
||||
.then_some(key)
|
||||
}
|
||||
|
||||
/// Whether `buffer_id` is the terminal an authenticated semantic
|
||||
/// frontend is currently displaying.
|
||||
///
|
||||
/// The daemon calls this before recording a terminal declaration so
|
||||
/// a stale or forged buffer never becomes a frontend's projection
|
||||
/// target — a declaration is only meaningful for the window the
|
||||
/// sender actually has on screen.
|
||||
#[must_use]
|
||||
pub fn semantic_terminal_declaration_is_active(
|
||||
&self,
|
||||
frontend_id: FrontendId,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
) -> bool {
|
||||
self.semantic_terminal_key(frontend_id, buffer_id).is_some()
|
||||
}
|
||||
|
||||
/// Project one semantic frontend's active terminal view.
|
||||
///
|
||||
/// Called from the render pass, after `sync_semantic_terminal_layout`
|
||||
/// has already applied any geometry change, so the snapshot comes
|
||||
/// from an already-published screen rather than one mid-resize.
|
||||
pub fn prepare_semantic_terminal_view(
|
||||
&self,
|
||||
frontend_id: FrontendId,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
size: CellSize,
|
||||
) -> Option<TerminalSnapshot> {
|
||||
let key = self.semantic_terminal_key(frontend_id, buffer_id)?;
|
||||
self.terminal_manager
|
||||
.borrow_mut()
|
||||
.snapshot_for_view(key, size)
|
||||
}
|
||||
|
||||
/// Record a semantic frontend's declared terminal geometry.
|
||||
///
|
||||
/// Recording is unconditional for a valid declaration — that is what
|
||||
/// gives a passive split its own clipped/padded projection — but the
|
||||
/// PTY resizes only when this exact view is the durable controller.
|
||||
/// Declaring geometry never CLAIMS control: a background frontend
|
||||
/// repainting at a different size must not steal the shared screen
|
||||
/// out from under the frontend the user is typing into.
|
||||
///
|
||||
/// Returns whether the shared screen geometry actually changed.
|
||||
pub fn sync_semantic_terminal_layout(
|
||||
&mut self,
|
||||
frontend_id: FrontendId,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
size: CellSize,
|
||||
) -> bool {
|
||||
let Some(key) = self.semantic_terminal_key(frontend_id, buffer_id) else {
|
||||
return false;
|
||||
};
|
||||
if !self
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.record_view_size(key, size)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let controls = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.controller(buffer_id)
|
||||
.is_some_and(|controller| controller.matches(key));
|
||||
if !controls {
|
||||
return false;
|
||||
}
|
||||
let old_size = self
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(buffer_id)
|
||||
.map(|snapshot| snapshot.size);
|
||||
if old_size == Some(size) {
|
||||
return false;
|
||||
}
|
||||
let (Ok(rows), Ok(cols)) = (u16::try_from(size.rows), u16::try_from(size.cols)) else {
|
||||
return false;
|
||||
};
|
||||
let result = self.terminal_manager.borrow_mut().resize(
|
||||
buffer_id,
|
||||
rows,
|
||||
cols,
|
||||
&mut self.process_supervisor.borrow_mut(),
|
||||
);
|
||||
if let Err(error) = result {
|
||||
self.core.borrow_mut().status = error.to_string();
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a semantic frontend's terminal-cell pointer gesture.
|
||||
///
|
||||
/// The gesture must name the authenticated frontend's active
|
||||
/// terminal buffer, match the viewport that frontend last declared,
|
||||
/// and land inside it. Anything else is dropped before any view,
|
||||
/// controller, selection, menu, or PTY mutation — a coordinate is
|
||||
/// only meaningful relative to the geometry the sender declared, so
|
||||
/// accepting one against a stale or undeclared viewport would let a
|
||||
/// peer select cells it never saw.
|
||||
pub fn dispatch_semantic_terminal_pointer(
|
||||
&mut self,
|
||||
frontend_id: FrontendId,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
coord: CellCoord,
|
||||
kind: TerminalMouseKind,
|
||||
mods: TerminalModifiers,
|
||||
) -> bool {
|
||||
let Some(key) = self.semantic_terminal_key(frontend_id, buffer_id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(size) = self.terminal_manager.borrow().declared_view_size(key) else {
|
||||
return false;
|
||||
};
|
||||
if coord.row >= size.rows || coord.col >= size.cols {
|
||||
return false;
|
||||
}
|
||||
self.core.borrow_mut().active_frontend = frontend_id;
|
||||
self.apply_terminal_gesture(key, size, coord, kind, mods, (coord.row, coord.col));
|
||||
true
|
||||
}
|
||||
|
||||
/// Precompute owned terminal view snapshots before entering paint borrows.
|
||||
pub fn prepare_terminal_views(
|
||||
&mut self,
|
||||
|
|
@ -1763,10 +1912,34 @@ impl EditorState {
|
|||
event: MouseEvent,
|
||||
global: (u32, u32),
|
||||
) {
|
||||
use crossterm::event::{MouseButton, MouseEventKind};
|
||||
self.apply_terminal_gesture(
|
||||
key,
|
||||
viewport_size,
|
||||
coord,
|
||||
terminal_mouse_kind(event.kind),
|
||||
terminal_modifiers(event.modifiers),
|
||||
global,
|
||||
);
|
||||
}
|
||||
|
||||
let kind = terminal_mouse_kind(event.kind);
|
||||
let modifiers = terminal_modifiers(event.modifiers);
|
||||
/// The one terminal pointer path, shared by both frontend kinds.
|
||||
///
|
||||
/// The TUI reaches it through crossterm translation and the semantic
|
||||
/// frontend through `FrontendEvent::TerminalPointer`; both arrive as
|
||||
/// the protocol-native kind/modifier pair, so child mouse reporting,
|
||||
/// scroll, selection, and the context menu stay single-sourced.
|
||||
/// A second copy of this precedence in the GPU lane is exactly how
|
||||
/// Shift-drag or scrolled-back selection would silently diverge
|
||||
/// between frontends.
|
||||
fn apply_terminal_gesture(
|
||||
&mut self,
|
||||
key: TerminalViewKey,
|
||||
viewport_size: CellSize,
|
||||
coord: CellCoord,
|
||||
kind: TerminalMouseKind,
|
||||
modifiers: TerminalModifiers,
|
||||
global: (u32, u32),
|
||||
) {
|
||||
let shift = modifiers.contains(TerminalModifiers::SHIFT);
|
||||
let (at_bottom, modes, screen_size) = {
|
||||
let mut manager = self.terminal_manager.borrow_mut();
|
||||
|
|
@ -1792,23 +1965,23 @@ impl EditorState {
|
|||
|
||||
self.claim_terminal_controller(key);
|
||||
let mut manager = self.terminal_manager.borrow_mut();
|
||||
match event.kind {
|
||||
MouseEventKind::ScrollUp => {
|
||||
match kind {
|
||||
TerminalMouseKind::ScrollUp => {
|
||||
let _ = manager.scroll_view(key, viewport_size, SCROLL_LINES);
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
TerminalMouseKind::ScrollDown => {
|
||||
let _ = manager.scroll_view(key, viewport_size, -SCROLL_LINES);
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
TerminalMouseKind::Down(TerminalMouseButton::Left) => {
|
||||
let _ = manager.begin_selection(key, viewport_size, coord);
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => {
|
||||
TerminalMouseKind::Drag(TerminalMouseButton::Left) => {
|
||||
let _ = manager.update_selection(key, viewport_size, coord);
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
TerminalMouseKind::Up(TerminalMouseButton::Left) => {
|
||||
let _ = manager.finish_selection(key, viewport_size, coord);
|
||||
}
|
||||
MouseEventKind::Down(MouseButton::Right) => {
|
||||
TerminalMouseKind::Down(TerminalMouseButton::Right) => {
|
||||
drop(manager);
|
||||
self.core.borrow_mut().break_command_chain(key.frontend_id);
|
||||
let rows = self.build_menu_rows();
|
||||
|
|
|
|||
|
|
@ -429,6 +429,13 @@ impl Frontend {
|
|||
// the grid TUI paints provider output directly from the
|
||||
// registry and silently drops an unexpected wire copy.
|
||||
| InstanceMessage::StatuslineSegments { .. }
|
||||
// Vterm Stage 3 — TerminalFrame is the semantic frontend's
|
||||
// terminal surface. A grid TUI composes terminal windows
|
||||
// into its own CellDelta (Stage 2) and advertises no
|
||||
// semantic render, so the daemon never sends it here; an
|
||||
// unexpected copy drops silently like the rest of the
|
||||
// family rather than being re-interpreted as cells.
|
||||
| InstanceMessage::TerminalFrame(_)
|
||||
| InstanceMessage::ResourceOffer { .. }
|
||||
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
|
||||
// optimistic-apply gate; if any reaches this render path
|
||||
|
|
|
|||
138
src/protocol.rs
138
src/protocol.rs
|
|
@ -1683,7 +1683,7 @@ mod tests {
|
|||
// --- M5.5a handshake & postcard round-trips ---
|
||||
|
||||
#[test]
|
||||
fn protocol_version_is_eighteen_for_statusline_segments() {
|
||||
fn protocol_version_is_nineteen_for_the_terminal_family() {
|
||||
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
|
||||
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
|
||||
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
|
||||
|
|
@ -1714,8 +1714,13 @@ mod tests {
|
|||
// variant — see the ThemeFacts placement pin).
|
||||
// Statusline segments Q#SL7 bumped 17→18 (`InstanceMessage::
|
||||
// StatuslineSegments`, additive + daemon-gated, appended after
|
||||
// FontFacts — see the v17 placement pin).
|
||||
assert_eq!(PROTOCOL_VERSION, 18);
|
||||
// FontFacts — see the v17 placement pin). Vterm Stage 3 bumped
|
||||
// 18→19 (`InstanceMessage::TerminalFrame`, daemon-gated, plus
|
||||
// `FrontendEvent::TerminalResize` / `TerminalPointer`,
|
||||
// frontend-gated — the first bump that gates in BOTH
|
||||
// directions; all three appended after their enum's final v18
|
||||
// variant, see the placement pins).
|
||||
assert_eq!(PROTOCOL_VERSION, 19);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1790,17 +1795,18 @@ mod tests {
|
|||
// regex/invalid), v11 (the context menu), v12 (the GUI
|
||||
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
|
||||
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
|
||||
// and v18 (`StatuslineSegments`) all interoperate.
|
||||
for accepted in 6..=18 {
|
||||
// v18 (`StatuslineSegments`), and v19 (the vterm terminal
|
||||
// family) all interoperate.
|
||||
for accepted in 6..=19 {
|
||||
assert!(
|
||||
is_supported_protocol_version(accepted),
|
||||
"v{accepted} must be accepted"
|
||||
);
|
||||
}
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 19, u32::MAX] {
|
||||
for rejected in [0, 1, 2, 3, 4, 5, 20, u32::MAX] {
|
||||
assert!(
|
||||
!is_supported_protocol_version(rejected),
|
||||
"v{rejected} must be rejected by a v18 binary"
|
||||
"v{rejected} must be rejected by a v19 binary"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1891,6 +1897,124 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statusline_segments_encoding_is_unchanged_by_the_v19_build() {
|
||||
// Vterm Stage 3 placement pin: `TerminalFrame` must be APPENDED
|
||||
// after `StatuslineSegments` — the final v18 variant, whose
|
||||
// ordinal moves if anything is inserted before any v18 variant.
|
||||
// These are the exact bytes a v18 binary produced for this
|
||||
// value (discriminant 25 as a postcard varint, then the buffer
|
||||
// id and two empty vectors); the new variant's own round-trip
|
||||
// cannot detect a shift.
|
||||
let msg = InstanceMessage::StatuslineSegments {
|
||||
buffer_id: pmacs_protocol::BufferId::from_raw(4),
|
||||
left: Vec::new(),
|
||||
right: Vec::new(),
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
assert_eq!(
|
||||
bytes,
|
||||
[25, 4, 0, 0],
|
||||
"StatuslineSegments' v18 wire bytes changed — a variant was \
|
||||
inserted before it; append new InstanceMessage variants \
|
||||
at the end"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_pointer_encoding_is_unchanged_by_the_v19_build() {
|
||||
// The same placement pin for the frontend→instance enum:
|
||||
// `TerminalResize` / `TerminalPointer` are appended after
|
||||
// `MenuPointer`, the final v18 `FrontendEvent` variant. A
|
||||
// frontend-gated variant inserted earlier would shift the
|
||||
// discriminants of `Viewport` and `Pointer`, which older
|
||||
// daemons decode on every session.
|
||||
let ev = FrontendEvent::MenuPointer {
|
||||
frontend_id: FrontendId(2),
|
||||
index: Some(1),
|
||||
invoke: true,
|
||||
};
|
||||
let bytes = postcard::to_allocvec(&ev).expect("encode");
|
||||
assert_eq!(
|
||||
bytes,
|
||||
[10, 2, 1, 1, 1],
|
||||
"MenuPointer's v18 wire bytes changed — a variant was \
|
||||
inserted before it; append new FrontendEvent variants \
|
||||
at the end"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_family_round_trips_and_pins_its_discriminants() {
|
||||
let bid = pmacs_protocol::BufferId::from_raw(9);
|
||||
let frame = pmacs_protocol::TerminalFrame {
|
||||
buffer_id: bid,
|
||||
size: CellSize::new(1, 2),
|
||||
cells: vec![
|
||||
Cell {
|
||||
glyph: pmacs_protocol::Glyph::Char('h'),
|
||||
style: Style::default(),
|
||||
attachment: None,
|
||||
},
|
||||
Cell {
|
||||
glyph: pmacs_protocol::Glyph::Char('i'),
|
||||
style: Style::default(),
|
||||
attachment: None,
|
||||
},
|
||||
],
|
||||
cursor: Some(CellCoord::new(0, 1)),
|
||||
title: None,
|
||||
screen_generation: 2,
|
||||
selection: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
at_bottom: true,
|
||||
pid: 77,
|
||||
process: pmacs_protocol::TerminalProcessState::Running,
|
||||
};
|
||||
assert_eq!(frame.validate(), Ok(()));
|
||||
|
||||
let msg = InstanceMessage::TerminalFrame(frame);
|
||||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||||
assert_eq!(
|
||||
bytes.first(),
|
||||
Some(&26),
|
||||
"TerminalFrame must be the 27th InstanceMessage variant \
|
||||
(appended after StatuslineSegments)"
|
||||
);
|
||||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||||
assert_eq!(decoded, msg);
|
||||
|
||||
for (ev, discriminant) in [
|
||||
(
|
||||
FrontendEvent::TerminalResize {
|
||||
frontend_id: FrontendId(3),
|
||||
buffer_id: bid,
|
||||
size: CellSize::new(24, 80),
|
||||
},
|
||||
11u8,
|
||||
),
|
||||
(
|
||||
FrontendEvent::TerminalPointer {
|
||||
frontend_id: FrontendId(3),
|
||||
buffer_id: bid,
|
||||
coord: CellCoord::new(4, 5),
|
||||
kind: MouseKind::Down(MouseButton::Left),
|
||||
mods: Modifiers::SHIFT,
|
||||
},
|
||||
12,
|
||||
),
|
||||
] {
|
||||
let bytes = postcard::to_allocvec(&ev).expect("encode");
|
||||
assert_eq!(
|
||||
bytes.first(),
|
||||
Some(&discriminant),
|
||||
"terminal FrontendEvent variants must be appended after MenuPointer"
|
||||
);
|
||||
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
|
||||
assert_eq!(decoded, ev);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_facts_encoding_is_unchanged_by_the_v18_build() {
|
||||
let msg = InstanceMessage::FontFacts {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use crate::buffer::BufferId;
|
||||
use crate::cell::Style;
|
||||
use crate::cell::{CellSize, Style};
|
||||
use crate::editor::EditorState;
|
||||
use crate::protocol::{
|
||||
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
|
||||
|
|
@ -44,6 +44,7 @@ use crate::statusline::{
|
|||
StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget,
|
||||
evaluate_statusline,
|
||||
};
|
||||
use crate::terminal::TerminalFrame;
|
||||
|
||||
/// The viewport a `semantic_render` frontend last declared.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
|
|
@ -131,6 +132,10 @@ fn minibuffer_window(candidates: &[String], selected: Option<usize>) -> (Vec<Str
|
|||
/// Owns one `semantic_render` session's projection state: the last
|
||||
/// viewport the frontend declared, and the diff baseline per buffer
|
||||
/// for the `StyleSpans` and `Decorations` families.
|
||||
#[allow(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "independent per-peer capability and latch flags"
|
||||
)]
|
||||
pub struct SemanticRenderState {
|
||||
/// The session this projection serves. Selection is per-window
|
||||
/// (per-frontend) state, so the decoration projection needs the
|
||||
|
|
@ -276,6 +281,31 @@ pub struct SemanticRenderState {
|
|||
/// when the store is non-stale and non-empty) — a steady-state
|
||||
/// CPU burn for a value that changes only when the buffer does.
|
||||
diag_line_cache: HashMap<BufferId, DiagLineCache>,
|
||||
/// Whether the peer negotiated protocol >= 19 (Vterm Stage 3). A
|
||||
/// v18 semantic peer receives the terminal identity buffer's empty
|
||||
/// snapshot and nothing else: terminal use is unsupported and
|
||||
/// invisible there, while ordinary document editing is unchanged.
|
||||
peer_knows_terminal_frames: bool,
|
||||
/// The terminal-cell geometry this frontend last declared, or
|
||||
/// `None` before its first accepted `TerminalResize`. One value,
|
||||
/// not a map: a frontend displays at most one terminal at a time
|
||||
/// (its active window), and the buffer travels with the size so a
|
||||
/// declaration outliving a switch cannot project the wrong session.
|
||||
terminal_viewport: Option<(BufferId, CellSize)>,
|
||||
/// The last terminal frame this peer received, compared in FULL.
|
||||
///
|
||||
/// Not keyed on `screen_generation`: selection, scroll, viewport,
|
||||
/// and process state all change without advancing that counter, so
|
||||
/// a generation-keyed baseline goes silent on exactly the view-only
|
||||
/// updates the frontend needs. `None` means the peer has received
|
||||
/// no frame, so the next valid one is authoritative.
|
||||
last_terminal_frame: Option<TerminalFrame>,
|
||||
/// Whether an invalid terminal snapshot was already reported since
|
||||
/// the last valid frame. Bounds the log to one line per distinct
|
||||
/// failure rather than one per tick while the condition persists.
|
||||
terminal_error_latched: bool,
|
||||
/// Whether the most recent render pass projected a terminal.
|
||||
terminal_active: bool,
|
||||
}
|
||||
|
||||
/// One [`SemanticRenderState::diag_line_cache`] entry: the line-start
|
||||
|
|
@ -371,6 +401,7 @@ impl SemanticRenderState {
|
|||
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
|
||||
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
|
||||
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
|
||||
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
|
||||
s
|
||||
}
|
||||
|
||||
|
|
@ -416,6 +447,11 @@ impl SemanticRenderState {
|
|||
peer_knows_statusline_segments: true,
|
||||
last_statusline: HashMap::new(),
|
||||
diag_line_cache: HashMap::new(),
|
||||
peer_knows_terminal_frames: true,
|
||||
terminal_viewport: None,
|
||||
last_terminal_frame: None,
|
||||
terminal_error_latched: false,
|
||||
terminal_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -431,6 +467,59 @@ impl SemanticRenderState {
|
|||
});
|
||||
}
|
||||
|
||||
/// Record the terminal-cell geometry this frontend declared
|
||||
/// (`FrontendEvent::TerminalResize`, accepted by the daemon only
|
||||
/// when it names the authenticated source's active terminal).
|
||||
///
|
||||
/// Replacing the declaration for a DIFFERENT buffer drops the frame
|
||||
/// baseline: the next frame describes another session entirely, and
|
||||
/// comparing it against the old one could suppress it.
|
||||
pub fn set_terminal_viewport(&mut self, buffer_id: BufferId, size: CellSize) {
|
||||
if self
|
||||
.terminal_viewport
|
||||
.is_some_and(|(previous, _)| previous != buffer_id)
|
||||
{
|
||||
self.clear_terminal_baseline();
|
||||
}
|
||||
self.terminal_viewport = Some((buffer_id, size));
|
||||
}
|
||||
|
||||
/// The terminal geometry this frontend last declared.
|
||||
///
|
||||
/// The daemon reads this to apply the semantic layout sync beside
|
||||
/// the landed grid sync, before the next child-output drain.
|
||||
#[must_use]
|
||||
pub fn terminal_viewport(&self) -> Option<(BufferId, CellSize)> {
|
||||
self.terminal_viewport
|
||||
}
|
||||
|
||||
/// Whether the last render pass projected a terminal.
|
||||
///
|
||||
/// The daemon consults this to suppress the document `CursorByte`
|
||||
/// and the presence sweep: a terminal identity buffer is empty, so
|
||||
/// both would describe a cursor at byte 0 of a buffer with no text.
|
||||
/// It tracks the PASS, not the baseline — a first frame rejected by
|
||||
/// validation still means this frontend is displaying a terminal,
|
||||
/// and falling back to the document path there would paint an empty
|
||||
/// buffer over a live session.
|
||||
#[must_use]
|
||||
pub fn in_terminal_mode(&self) -> bool {
|
||||
self.terminal_active
|
||||
}
|
||||
|
||||
/// Forget the terminal frame baseline so the next valid frame is
|
||||
/// authoritative, and re-arm the invalid-frame log.
|
||||
fn clear_terminal_baseline(&mut self) {
|
||||
self.last_terminal_frame = None;
|
||||
self.terminal_error_latched = false;
|
||||
}
|
||||
|
||||
/// Drop terminal projection state on detach or context replacement.
|
||||
pub fn on_terminal_context_released(&mut self) {
|
||||
self.terminal_viewport = None;
|
||||
self.clear_terminal_baseline();
|
||||
}
|
||||
|
||||
/// Snapshot/baseline reset contract (PR #120 round 2 finding 1).
|
||||
///
|
||||
/// A `BufferSnapshot` resets the receiving frontend's
|
||||
|
|
@ -464,6 +553,14 @@ impl SemanticRenderState {
|
|||
/// buffer, and any buffer the frontend navigates to receives its
|
||||
/// own snapshot first.
|
||||
pub fn on_buffer_snapshot_sent(&mut self, buffer_id: BufferId) {
|
||||
// Vterm Stage 3: a snapshot takes the GPU out of terminal mode
|
||||
// unconditionally — it clears the prior frame and every
|
||||
// terminal-only cache before painting. Both sides must forget
|
||||
// together, and the frontend re-declares its geometry
|
||||
// immediately after applying the snapshot, so dropping the
|
||||
// declaration here costs one message, not a stuck terminal.
|
||||
self.terminal_viewport = None;
|
||||
self.clear_terminal_baseline();
|
||||
self.last_sent.remove(&buffer_id);
|
||||
self.last_style_gate.remove(&buffer_id);
|
||||
self.last_decorations.remove(&buffer_id);
|
||||
|
|
@ -496,6 +593,14 @@ impl SemanticRenderState {
|
|||
/// to say (no hints, no prior non-empty send).
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn render_frame(&mut self, state: &EditorState) -> Vec<InstanceMessage> {
|
||||
// Vterm Stage 3: a terminal window suppresses the whole document
|
||||
// projection. It is checked FIRST because the terminal identity
|
||||
// buffer is a valid (empty) document — running the document path
|
||||
// over it would ship an authoritative empty styling/summary
|
||||
// resync on top of the live cell grid.
|
||||
if let Some(messages) = self.terminal_frame_pass(state) {
|
||||
return messages;
|
||||
}
|
||||
let Some(vp) = self.viewport.clone() else {
|
||||
// Emit nothing before the frontend declares a viewport.
|
||||
return Vec::new();
|
||||
|
|
@ -694,6 +799,98 @@ impl SemanticRenderState {
|
|||
out
|
||||
}
|
||||
|
||||
/// Project this frontend's active terminal, or `None` when it is
|
||||
/// not displaying one (so the document path runs instead).
|
||||
///
|
||||
/// Returning `Some` means terminal mode: the caller emits exactly
|
||||
/// these messages and no document family at all. What survives is
|
||||
/// the buffer-independent chrome the native frontend still needs —
|
||||
/// status band, theme, font, statusline, menu, and minibuffer — plus
|
||||
/// the frame itself.
|
||||
fn terminal_frame_pass(&mut self, state: &EditorState) -> Option<Vec<InstanceMessage>> {
|
||||
// A v18 peer has no terminal surface at all: it keeps the
|
||||
// document path over the empty identity buffer, exactly as it
|
||||
// did before this protocol version existed.
|
||||
//
|
||||
// Every path out of terminal mode clears `terminal_active`
|
||||
// explicitly. An early `?` that left it set would keep the
|
||||
// daemon suppressing this frontend's `CursorByte` and presence
|
||||
// long after it went back to editing a document.
|
||||
let declaration = self
|
||||
.peer_knows_terminal_frames
|
||||
.then_some(self.terminal_viewport)
|
||||
.flatten();
|
||||
let Some((buffer_id, size)) = declaration else {
|
||||
self.terminal_active = false;
|
||||
return None;
|
||||
};
|
||||
let Some(snapshot) =
|
||||
state.prepare_semantic_terminal_view(self.frontend_id, buffer_id, size)
|
||||
else {
|
||||
// The window switched away, the session died, or the
|
||||
// declared size went out of range. Leave terminal mode and
|
||||
// let the document path resume.
|
||||
self.terminal_active = false;
|
||||
self.clear_terminal_baseline();
|
||||
return None;
|
||||
};
|
||||
self.terminal_active = true;
|
||||
|
||||
// Evaluate callbacks before `ThemeFacts` for the same reason the
|
||||
// document path does: a callback may register a face, and the
|
||||
// face inventory must precede the segment text that names it.
|
||||
let statusline_evaluation = self.peer_knows_statusline_segments.then(|| {
|
||||
evaluate_statusline(
|
||||
state.lua_host.lua(),
|
||||
&state.core,
|
||||
&state.statusline_registry,
|
||||
StatuslineEvaluationTarget::Semantic {
|
||||
frontend_id: self.frontend_id,
|
||||
declared_buffer: buffer_id,
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
let mut out = Vec::new();
|
||||
let frame = snapshot.into_terminal_frame();
|
||||
match frame.validate() {
|
||||
Ok(()) => {
|
||||
self.terminal_error_latched = false;
|
||||
// Complete-payload comparison, not `screen_generation`:
|
||||
// a scroll, a selection change, or a process exit must
|
||||
// reach the frontend even though the screen itself is
|
||||
// byte-identical.
|
||||
if self.last_terminal_frame.as_ref() != Some(&frame) {
|
||||
self.last_terminal_frame = Some(frame.clone());
|
||||
out.push(InstanceMessage::TerminalFrame(frame));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
// Never emit a malformed or truncated frame. The peer
|
||||
// keeps the last valid one; one bounded log line marks
|
||||
// the condition until a valid frame clears the latch.
|
||||
if !self.terminal_error_latched {
|
||||
self.terminal_error_latched = true;
|
||||
eprintln!(
|
||||
"pmacs: terminal frame for {:?} on {:?} failed validation, \
|
||||
retaining the last valid frame: {error}",
|
||||
buffer_id, self.frontend_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.extend(self.status_facts_msg(state, buffer_id));
|
||||
out.extend(self.menu_prompt_msg(state, buffer_id));
|
||||
out.extend(self.minibuffer_prompt_msg(state, buffer_id));
|
||||
out.extend(self.theme_facts_msg(state));
|
||||
out.extend(self.font_facts_msg(state));
|
||||
if let Some(evaluation) = statusline_evaluation {
|
||||
self.emit_statusline_segments(evaluation, &mut out);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Apply the lead evaluator's publication outcome to the v18 wire
|
||||
/// baseline. Invalidated evaluations discard all callback text and
|
||||
/// publish authoritative empty replacements for the captured old
|
||||
|
|
|
|||
|
|
@ -12,24 +12,28 @@ pub mod session;
|
|||
pub mod view;
|
||||
|
||||
pub use session::{
|
||||
SharedTerminalManager, TerminalError, TerminalManager, TerminalProcessState,
|
||||
TerminalSelectionSpan, TerminalSnapshot, TerminalSpec,
|
||||
SharedTerminalManager, TerminalError, TerminalManager, TerminalSnapshot, TerminalSpec,
|
||||
};
|
||||
pub use view::{
|
||||
LogicalCellAnchor, TerminalController, TerminalSelection, TerminalViewKey, TerminalViewState,
|
||||
};
|
||||
|
||||
/// Maximum terminal rows accepted at creation or resize.
|
||||
pub const MAX_TERMINAL_ROWS: u16 = 512;
|
||||
/// Maximum terminal columns accepted at creation or resize.
|
||||
pub const MAX_TERMINAL_COLS: u16 = 512;
|
||||
/// Maximum visible terminal cells accepted at creation or resize.
|
||||
pub const MAX_TERMINAL_VISIBLE_CELLS: usize = 262_144;
|
||||
/// Maximum UTF-8 bytes retained in one terminal grapheme cluster.
|
||||
pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = 256;
|
||||
// Vterm Stage 3: the screen bounds and the process/selection payload
|
||||
// types moved to `pmacs-protocol` so the daemon's pre-emission check and
|
||||
// a frontend's post-decode check run the SAME policy. Re-exported here
|
||||
// so Stage 1/2 callers keep their `crate::terminal::…` paths and no
|
||||
// duplicate type appears in the tree.
|
||||
pub use pmacs_protocol::terminal::{
|
||||
MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES,
|
||||
MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame,
|
||||
TerminalFrameError, TerminalProcessState, TerminalSelectionSpan,
|
||||
};
|
||||
|
||||
/// Default retained main-screen scrollback rows.
|
||||
///
|
||||
/// Configuration-time, not a wire bound: history never crosses the
|
||||
/// protocol, so this stays core-owned.
|
||||
pub const DEFAULT_TERMINAL_SCROLLBACK_ROWS: usize = 10_000;
|
||||
/// Maximum retained main-screen history cells.
|
||||
/// Maximum retained main-screen history cells. Core-owned for the same
|
||||
/// reason as [`DEFAULT_TERMINAL_SCROLLBACK_ROWS`].
|
||||
pub const MAX_TERMINAL_HISTORY_CELLS: usize = 4_000_000;
|
||||
/// Shared cap for terminal title and process-outcome metadata.
|
||||
pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ use crate::terminal::screen::TerminalScreen;
|
|||
use crate::terminal::view::{TerminalController, TerminalViewKey, TerminalViewState};
|
||||
use crate::terminal::{
|
||||
MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS,
|
||||
MAX_TERMINAL_VISIBLE_CELLS,
|
||||
MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame, TerminalProcessState, TerminalSelectionSpan,
|
||||
};
|
||||
|
||||
/// Shared single-owner terminal registry used by editor and future Lua bindings.
|
||||
|
|
@ -133,30 +133,6 @@ impl TerminalSpec {
|
|||
}
|
||||
}
|
||||
|
||||
/// Process outcome published with an owned terminal snapshot.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum TerminalProcessState {
|
||||
/// Child is running or termination has only been requested.
|
||||
Running,
|
||||
/// Child exited with a status code.
|
||||
Exited(i32),
|
||||
/// Child was terminated by a sanitized symbolic signal.
|
||||
Signaled(String),
|
||||
/// Supervision failed after the session was published.
|
||||
Crashed(String),
|
||||
}
|
||||
|
||||
/// One selected terminal-row span. Stage 1 snapshots leave selection empty.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TerminalSelectionSpan {
|
||||
/// Visible row.
|
||||
pub row: u32,
|
||||
/// Inclusive starting column.
|
||||
pub start_col: u32,
|
||||
/// Exclusive ending column.
|
||||
pub end_col: u32,
|
||||
}
|
||||
|
||||
/// Owned, renderer-safe terminal state captured after a manager tick.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TerminalSnapshot {
|
||||
|
|
@ -184,6 +160,35 @@ pub struct TerminalSnapshot {
|
|||
pub process: TerminalProcessState,
|
||||
}
|
||||
|
||||
impl TerminalSnapshot {
|
||||
/// Convert an owned snapshot into its protocol-v19 wire form.
|
||||
///
|
||||
/// The two shapes are deliberately distinct types even though their
|
||||
/// fields line up: the snapshot is core-owned state the TUI also
|
||||
/// consumes, while [`TerminalFrame`] is wire input a peer may forge.
|
||||
/// The conversion is total, but the CALLER must still
|
||||
/// [`TerminalFrame::validate`] before emitting — the aggregate glyph
|
||||
/// bound is a wire limit the screen does not enforce, so a child
|
||||
/// that builds a legal-but-huge internal snapshot must be caught
|
||||
/// here rather than sent truncated.
|
||||
#[must_use]
|
||||
pub fn into_terminal_frame(self) -> TerminalFrame {
|
||||
TerminalFrame {
|
||||
buffer_id: self.buffer_id,
|
||||
size: self.size,
|
||||
cells: self.cells,
|
||||
cursor: self.cursor,
|
||||
title: self.title,
|
||||
screen_generation: self.screen_generation,
|
||||
selection: self.selection,
|
||||
scroll_offset: self.scroll_offset,
|
||||
at_bottom: self.at_bottom,
|
||||
pid: self.pid,
|
||||
process: self.process,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal session/registry failures.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TerminalError {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ use crate::buffer::BufferId;
|
|||
use crate::cell::{Cell, CellCoord, CellSize, Glyph, Style};
|
||||
use crate::protocol::FrontendId;
|
||||
use crate::terminal::screen::{BorrowedScreenProjection, TerminalModes, TerminalRow};
|
||||
use crate::terminal::session::{TerminalManager, TerminalSelectionSpan, TerminalSnapshot};
|
||||
use crate::terminal::{MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS};
|
||||
use crate::terminal::session::{TerminalManager, TerminalSnapshot};
|
||||
use crate::terminal::{
|
||||
MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalProcessState,
|
||||
TerminalSelectionSpan,
|
||||
};
|
||||
use crate::window::WindowId;
|
||||
|
||||
/// One frontend/window projection of a terminal session.
|
||||
|
|
@ -250,6 +253,42 @@ impl TerminalManager {
|
|||
.map(|session| session.screen.projection_ref().size)
|
||||
}
|
||||
|
||||
/// Record an exact view's declared viewport size without projecting.
|
||||
///
|
||||
/// Vterm Stage 3: a semantic frontend declares terminal geometry
|
||||
/// through its own message rather than through a layout pass, and a
|
||||
/// PASSIVE view must still record its size — that is what lets it
|
||||
/// receive its own clipped/padded projection instead of the
|
||||
/// controller's. Recording a size is deliberately not claiming
|
||||
/// control; the caller decides whether to resize the PTY.
|
||||
///
|
||||
/// Returns `false` for an unknown session or an out-of-range size,
|
||||
/// leaving prior geometry untouched.
|
||||
pub fn record_view_size(&mut self, key: TerminalViewKey, viewport_size: CellSize) -> bool {
|
||||
if !valid_viewport(viewport_size) {
|
||||
return false;
|
||||
}
|
||||
let Some(session) = self.sessions.get(&key.buffer_id) else {
|
||||
return false;
|
||||
};
|
||||
let projection = session.screen.projection_ref();
|
||||
let bell_count = session.screen.bell_count();
|
||||
let state = self.views.entry(key).or_insert_with(|| TerminalViewState {
|
||||
alternate_active: Some(projection.alternate_active),
|
||||
last_bell_count: bell_count,
|
||||
..TerminalViewState::default()
|
||||
});
|
||||
normalize_state(state, projection);
|
||||
state.viewport_size = Some(viewport_size);
|
||||
true
|
||||
}
|
||||
|
||||
/// The viewport size an exact view last declared or rendered at.
|
||||
#[must_use]
|
||||
pub fn declared_view_size(&self, key: TerminalViewKey) -> Option<CellSize> {
|
||||
self.views.get(&key).and_then(|state| state.viewport_size)
|
||||
}
|
||||
|
||||
/// Return fresh geometric status for one registered view.
|
||||
#[must_use]
|
||||
pub fn view_status(&mut self, key: TerminalViewKey) -> Option<TerminalViewStatus> {
|
||||
|
|
@ -670,7 +709,7 @@ fn project_snapshot(
|
|||
projection: BorrowedScreenProjection<'_>,
|
||||
state: &TerminalViewState,
|
||||
pid: u32,
|
||||
process: crate::terminal::session::TerminalProcessState,
|
||||
process: TerminalProcessState,
|
||||
) -> TerminalSnapshot {
|
||||
let rows = retained_rows(projection);
|
||||
let geometry = view_geometry(&rows, state, viewport_size.rows);
|
||||
|
|
@ -782,8 +821,8 @@ fn copy_selection_bytes(rows: &RetainedRows<'_>, selection: TerminalSelection) -
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::TerminalProcessState;
|
||||
use crate::terminal::screen::ScreenProjection;
|
||||
use crate::terminal::session::TerminalProcessState;
|
||||
|
||||
fn row(id: u64, offset: u32, text: &str, soft_wrapped: bool) -> TerminalRow {
|
||||
TerminalRow {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,17 @@ impl TestDaemon {
|
|||
Self::spawn_with_env_and_config(&[], Some(init_lua))
|
||||
}
|
||||
|
||||
/// Spawn with BOTH env overrides and a user `init.lua`.
|
||||
///
|
||||
/// Vterm Stage 3 needs this pair together: `init.lua` opens the
|
||||
/// terminal the frontend will attach to, and
|
||||
/// `PMACS_INSTANCE_SEMANTIC_RENDER` is what makes the daemon
|
||||
/// advertise the capability a semantic frontend requires.
|
||||
#[allow(dead_code)] // consumed per-suite; not every test crate uses it
|
||||
pub fn spawn_with_env_and_init(env_vars: &[(&str, &str)], init_lua: &str) -> Self {
|
||||
Self::spawn_with_env_and_config(env_vars, Some(init_lua))
|
||||
}
|
||||
|
||||
fn spawn_with_env_and_config(env_vars: &[(&str, &str)], init_lua: Option<&str>) -> Self {
|
||||
let tempdir = TempDir::new().expect("tempdir");
|
||||
// tempfile::TempDir creates 0755-mode directories; the daemon
|
||||
|
|
|
|||
|
|
@ -781,11 +781,16 @@ fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() {
|
|||
// (the drop arm itself is pinned beside Frontend::apply_message).
|
||||
#[test]
|
||||
fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
|
||||
assert_eq!(PROTOCOL_VERSION, 18);
|
||||
for version in 6..=18 {
|
||||
// Vterm Stage 3 appended the terminal family as v19. This
|
||||
// acceptance owns the STATUSLINE variant's placement and gate, so
|
||||
// it tracks the current wire version rather than pinning 18: the
|
||||
// v18 floor it actually cares about is asserted below and in
|
||||
// `peer_accepts_statusline_message`.
|
||||
assert_eq!(PROTOCOL_VERSION, 19);
|
||||
for version in 6..=19 {
|
||||
assert!(is_supported_protocol_version(version));
|
||||
}
|
||||
assert!(!is_supported_protocol_version(19));
|
||||
assert!(!is_supported_protocol_version(20));
|
||||
let sample = InstanceMessage::StatuslineSegments {
|
||||
buffer_id: BufferId::from_raw(9),
|
||||
left: vec![StatuslineSegment {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,809 @@
|
|||
//! Stage 3 terminal/GPU protocol acceptance (criteria 28–37).
|
||||
//!
|
||||
//! Focused wire and paint assertions live in `pmacs-protocol` and
|
||||
//! `pmacs-gpu`; this suite owns the cross-surface behavior — the
|
||||
//! semantic producer's baselines, the authenticated per-view routing,
|
||||
//! and the real-daemon path.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pmacs::cell::{CellCoord, CellSize, Glyph};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::protocol::{
|
||||
FrontendId, InstanceMessage, Modifiers, MouseButton, MouseKind, TerminalFrame,
|
||||
};
|
||||
use pmacs::semantic_render::SemanticRenderState;
|
||||
use pmacs::terminal::{TerminalProcessState, TerminalSpec, TerminalViewKey};
|
||||
use pmacs::window::{FrontendView, Layout, Window, WindowId};
|
||||
|
||||
fn tick_until(
|
||||
state: &mut EditorState,
|
||||
timeout: Duration,
|
||||
mut done: impl FnMut(&EditorState) -> bool,
|
||||
) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
state.tick_processes();
|
||||
if done(state) {
|
||||
return;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "terminal condition timed out");
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String {
|
||||
let mut text = String::new();
|
||||
for cell in &snapshot.cells {
|
||||
match &cell.glyph {
|
||||
Glyph::Char(ch) => text.push(*ch),
|
||||
Glyph::Cluster(bytes) => text.push_str(&String::from_utf8_lossy(bytes)),
|
||||
Glyph::Continuation => {}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn frame_text(frame: &TerminalFrame) -> String {
|
||||
let mut text = String::new();
|
||||
for cell in &frame.cells {
|
||||
match &cell.glyph {
|
||||
Glyph::Char(ch) => text.push(*ch),
|
||||
Glyph::Cluster(bytes) => text.push_str(&String::from_utf8_lossy(bytes)),
|
||||
Glyph::Continuation => {}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Give `frontend_id` its own window showing `buffer_id`, the way the
|
||||
/// daemon's session bootstrap and buffer-follow do.
|
||||
fn attach_view(
|
||||
state: &EditorState,
|
||||
frontend_id: FrontendId,
|
||||
buffer_id: pmacs::buffer::BufferId,
|
||||
) -> WindowId {
|
||||
let mut core = state.core.borrow_mut();
|
||||
let text_view = {
|
||||
let registry = core.registry.clone();
|
||||
let registry = registry.borrow();
|
||||
let buffer = registry.get(buffer_id).expect("buffer present");
|
||||
pmacs::text_view::TextView::new(buffer)
|
||||
};
|
||||
let window_id = WindowId::next();
|
||||
let window = Window::new(window_id, buffer_id, text_view);
|
||||
core.windows.insert(window_id, window);
|
||||
core.register_frontend_view(
|
||||
frontend_id,
|
||||
FrontendView {
|
||||
layout: Layout::single(window_id),
|
||||
active: window_id,
|
||||
},
|
||||
);
|
||||
window_id
|
||||
}
|
||||
|
||||
/// Point an already-registered view at another buffer, the way a
|
||||
/// daemon-side buffer switch does.
|
||||
fn switch_view(state: &EditorState, frontend_id: FrontendId, buffer_id: pmacs::buffer::BufferId) {
|
||||
let mut core = state.core.borrow_mut();
|
||||
let window_id = core.views.get(&frontend_id).expect("view present").active;
|
||||
let text_view = {
|
||||
let registry = core.registry.clone();
|
||||
let registry = registry.borrow();
|
||||
let buffer = registry.get(buffer_id).expect("buffer present");
|
||||
pmacs::text_view::TextView::new(buffer)
|
||||
};
|
||||
let window = core.windows.get_mut(&window_id).expect("window present");
|
||||
window.buffer_id = buffer_id;
|
||||
window.text_view = text_view;
|
||||
window.cursor = 0;
|
||||
window.selection = None;
|
||||
}
|
||||
|
||||
fn open_terminal(
|
||||
state: &mut EditorState,
|
||||
args: &str,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
) -> pmacs::buffer::BufferId {
|
||||
let mut spec = TerminalSpec::new("/bin/sh");
|
||||
spec.args = vec!["-c".into(), args.into()];
|
||||
spec.rows = rows;
|
||||
spec.cols = cols;
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.open(
|
||||
spec,
|
||||
&mut state.core.borrow_mut(),
|
||||
&mut state.process_supervisor.borrow_mut(),
|
||||
)
|
||||
.expect("open terminal")
|
||||
}
|
||||
|
||||
fn terminal_frames(messages: &[InstanceMessage]) -> Vec<TerminalFrame> {
|
||||
messages
|
||||
.iter()
|
||||
.filter_map(|msg| match msg {
|
||||
InstanceMessage::TerminalFrame(frame) => Some(frame.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn one_frame(messages: &[InstanceMessage]) -> TerminalFrame {
|
||||
let frames = terminal_frames(messages);
|
||||
assert_eq!(frames.len(), 1, "expected exactly one terminal frame");
|
||||
frames.into_iter().next().expect("checked length")
|
||||
}
|
||||
|
||||
/// Acceptance 30: the first activation emits one authoritative complete
|
||||
/// frame, an equal payload is silent, view-only and process-only changes
|
||||
/// emit despite an unchanged screen generation, the document projection
|
||||
/// is suppressed while terminal, and switching back resyncs the
|
||||
/// document in full.
|
||||
#[test]
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "one producer-baseline lifecycle scenario"
|
||||
)]
|
||||
fn a30_first_frame_is_authoritative_then_only_real_changes_emit() {
|
||||
let mut state = EditorState::new();
|
||||
let frontend_id = FrontendId(31);
|
||||
let terminal_buffer = open_terminal(
|
||||
&mut state,
|
||||
"printf 'alpha\\nbeta\\n'; i=0; while [ $i -lt 20 ]; do printf 'row%02d\\n' \"$i\"; \
|
||||
i=$((i+1)); done; sleep 30",
|
||||
6,
|
||||
24,
|
||||
);
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(terminal_buffer)
|
||||
.is_some_and(|snapshot| snapshot_text(&snapshot).contains("row19"))
|
||||
});
|
||||
attach_view(&state, frontend_id, terminal_buffer);
|
||||
|
||||
let mut producer = SemanticRenderState::for_peer(frontend_id, 19);
|
||||
let size = CellSize::new(6, 24);
|
||||
producer.set_terminal_viewport(terminal_buffer, size);
|
||||
|
||||
// First activation: exactly one complete frame, and no document
|
||||
// family at all.
|
||||
let first = producer.render_frame(&state);
|
||||
let frame = one_frame(&first);
|
||||
assert_eq!(frame.buffer_id, terminal_buffer);
|
||||
assert_eq!(frame.size, size);
|
||||
assert_eq!(frame.cells.len(), (size.rows * size.cols) as usize);
|
||||
assert_eq!(frame.validate(), Ok(()));
|
||||
assert!(frame_text(&frame).contains("row19"));
|
||||
assert!(
|
||||
producer.in_terminal_mode(),
|
||||
"a projected terminal puts the producer in terminal mode"
|
||||
);
|
||||
for msg in &first {
|
||||
assert!(
|
||||
!matches!(
|
||||
msg,
|
||||
InstanceMessage::StyleSpans { .. }
|
||||
| InstanceMessage::Decorations { .. }
|
||||
| InstanceMessage::InlineAdornments { .. }
|
||||
| InstanceMessage::FileStyleSummary { .. }
|
||||
| InstanceMessage::LineNumbers { .. }
|
||||
| InstanceMessage::SearchPrompt { .. }
|
||||
| InstanceMessage::CompletionPopup { .. }
|
||||
),
|
||||
"terminal mode must suppress the document projection, saw {msg:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing changed: a completely equal payload is silent.
|
||||
let second = producer.render_frame(&state);
|
||||
assert!(
|
||||
terminal_frames(&second).is_empty(),
|
||||
"an unchanged terminal payload must not re-send"
|
||||
);
|
||||
|
||||
// A view-only change — scrolling this frontend's own view — leaves
|
||||
// `screen_generation` alone but must still reach the frontend.
|
||||
let key = {
|
||||
let core = state.core.borrow();
|
||||
let window_id = core.views.get(&frontend_id).expect("view").active;
|
||||
TerminalViewKey::new(frontend_id, window_id, terminal_buffer)
|
||||
};
|
||||
let generation_before = frame.screen_generation;
|
||||
assert!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.scroll_view(key, size, 3),
|
||||
"scrolling the view back into history"
|
||||
);
|
||||
let scrolled = one_frame(&producer.render_frame(&state));
|
||||
assert_eq!(
|
||||
scrolled.screen_generation, generation_before,
|
||||
"scrolling does not advance the screen generation"
|
||||
);
|
||||
assert_eq!(scrolled.scroll_offset, 3);
|
||||
assert!(!scrolled.at_bottom);
|
||||
|
||||
// A selection is also view-only.
|
||||
assert!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.begin_selection(key, size, CellCoord::new(0, 0))
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.finish_selection(key, size, CellCoord::new(0, 4))
|
||||
);
|
||||
let selected = one_frame(&producer.render_frame(&state));
|
||||
assert!(
|
||||
!selected.selection.is_empty(),
|
||||
"a selection change must reach the frontend"
|
||||
);
|
||||
assert_eq!(selected.screen_generation, generation_before);
|
||||
|
||||
// A process-only change: the child exits. The frame carries the new
|
||||
// outcome even though the frontend has not scrolled or typed.
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.terminate(terminal_buffer, &mut state.process_supervisor.borrow_mut())
|
||||
.expect("terminate child");
|
||||
let mut exited = None;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
state.tick_processes();
|
||||
for frame in terminal_frames(&producer.render_frame(&state)) {
|
||||
if !matches!(frame.process, TerminalProcessState::Running) {
|
||||
exited = Some(frame);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if exited.is_some() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let exited = exited.expect("a process-state change must produce a frame");
|
||||
assert_ne!(exited.process, TerminalProcessState::Running);
|
||||
|
||||
// Switching back to a document: the snapshot reset drops the
|
||||
// terminal declaration and baseline, and the producer leaves
|
||||
// terminal mode so the document resync can happen.
|
||||
let document = state.core.borrow().active_buffer_id();
|
||||
switch_view(&state, frontend_id, document);
|
||||
producer.on_buffer_snapshot_sent(document);
|
||||
assert!(
|
||||
producer.terminal_viewport().is_none(),
|
||||
"a snapshot clears the terminal declaration"
|
||||
);
|
||||
producer.set_viewport(document, pmacs::protocol::ByteRange { start: 0, end: 0 }, 0);
|
||||
let back = producer.render_frame(&state);
|
||||
assert!(
|
||||
terminal_frames(&back).is_empty(),
|
||||
"a document buffer produces no terminal frames"
|
||||
);
|
||||
assert!(
|
||||
!producer.in_terminal_mode(),
|
||||
"the producer leaves terminal mode when the window switches away"
|
||||
);
|
||||
}
|
||||
|
||||
/// Acceptance 31: two semantic frontends over one terminal keep
|
||||
/// independent sizes, scroll, selection, and baselines while sharing one
|
||||
/// process and screen; a passive declaration produces its own
|
||||
/// clipped/padded frame but only the durable controller resizes the PTY.
|
||||
#[test]
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "one shared-session two-frontend scenario"
|
||||
)]
|
||||
fn a31_two_semantic_frontends_share_one_session_with_independent_views() {
|
||||
let mut state = EditorState::new();
|
||||
let first_id = FrontendId(41);
|
||||
let second_id = FrontendId(42);
|
||||
let terminal_buffer = open_terminal(
|
||||
&mut state,
|
||||
"i=0; while [ $i -lt 30 ]; do printf 'row%02d\\n' \"$i\"; i=$((i+1)); done; sleep 30",
|
||||
8,
|
||||
40,
|
||||
);
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(terminal_buffer)
|
||||
.is_some_and(|snapshot| snapshot_text(&snapshot).contains("row29"))
|
||||
});
|
||||
let first_window = attach_view(&state, first_id, terminal_buffer);
|
||||
let second_window = attach_view(&state, second_id, terminal_buffer);
|
||||
|
||||
let first_size = CellSize::new(6, 30);
|
||||
let second_size = CellSize::new(4, 20);
|
||||
let mut first = SemanticRenderState::for_peer(first_id, 19);
|
||||
let mut second = SemanticRenderState::for_peer(second_id, 19);
|
||||
|
||||
// The first frontend declares and becomes the controller by acting
|
||||
// on its view; the second declares only.
|
||||
assert!(state.semantic_terminal_declaration_is_active(first_id, terminal_buffer));
|
||||
first.set_terminal_viewport(terminal_buffer, first_size);
|
||||
second.set_terminal_viewport(terminal_buffer, second_size);
|
||||
|
||||
let first_key = TerminalViewKey::new(first_id, first_window, terminal_buffer);
|
||||
state.terminal_manager.borrow_mut().register_view(first_key);
|
||||
assert!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.claim_controller(first_key)
|
||||
);
|
||||
|
||||
let screen_before = state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(terminal_buffer)
|
||||
.expect("snapshot")
|
||||
.size;
|
||||
|
||||
// A PASSIVE declaration records geometry and produces the passive
|
||||
// frontend's own projection, but never touches the shared screen.
|
||||
assert!(!state.sync_semantic_terminal_layout(second_id, terminal_buffer, second_size));
|
||||
assert_eq!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(terminal_buffer)
|
||||
.expect("snapshot")
|
||||
.size,
|
||||
screen_before,
|
||||
"a passive declaration must not resize the shared PTY"
|
||||
);
|
||||
|
||||
// The CONTROLLER's declaration does resize it.
|
||||
assert!(state.sync_semantic_terminal_layout(first_id, terminal_buffer, first_size));
|
||||
assert_eq!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(terminal_buffer)
|
||||
.expect("snapshot")
|
||||
.size,
|
||||
first_size,
|
||||
"the durable controller owns the shared geometry"
|
||||
);
|
||||
|
||||
let first_frame = one_frame(&first.render_frame(&state));
|
||||
let second_frame = one_frame(&second.render_frame(&state));
|
||||
assert_eq!(first_frame.size, first_size);
|
||||
assert_eq!(second_frame.size, second_size);
|
||||
assert_eq!(
|
||||
first_frame.pid, second_frame.pid,
|
||||
"both views project one process"
|
||||
);
|
||||
assert_eq!(first_frame.process, second_frame.process);
|
||||
assert_eq!(
|
||||
first_frame.screen_generation,
|
||||
second_frame.screen_generation
|
||||
);
|
||||
|
||||
// Scroll and select on the first view only.
|
||||
assert!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.scroll_view(first_key, first_size, 4)
|
||||
);
|
||||
let second_key = TerminalViewKey::new(second_id, second_window, terminal_buffer);
|
||||
assert!(state.terminal_manager.borrow_mut().begin_selection(
|
||||
first_key,
|
||||
first_size,
|
||||
CellCoord::new(0, 0)
|
||||
));
|
||||
assert!(state.terminal_manager.borrow_mut().finish_selection(
|
||||
first_key,
|
||||
first_size,
|
||||
CellCoord::new(0, 5)
|
||||
));
|
||||
|
||||
let first_after = one_frame(&first.render_frame(&state));
|
||||
assert_eq!(first_after.scroll_offset, 4);
|
||||
assert!(!first_after.at_bottom);
|
||||
assert!(!first_after.selection.is_empty());
|
||||
|
||||
// The second frontend's baseline is its own: its projection is
|
||||
// unchanged, so it emits nothing — and when it does emit, it carries
|
||||
// its own scroll and no selection.
|
||||
let second_after = second.render_frame(&state);
|
||||
for frame in terminal_frames(&second_after) {
|
||||
assert_eq!(frame.scroll_offset, 0, "scroll is per view");
|
||||
assert!(frame.selection.is_empty(), "selection is per view");
|
||||
}
|
||||
assert!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.view_state(second_key)
|
||||
.is_some(),
|
||||
"the passive view remains registered"
|
||||
);
|
||||
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.terminate(terminal_buffer, &mut state.process_supervisor.borrow_mut())
|
||||
.expect("terminate child");
|
||||
}
|
||||
|
||||
/// Acceptance 32: forged frontend/buffer identities, stale buffers,
|
||||
/// undeclared viewports, and out-of-bounds coordinates cannot affect
|
||||
/// another view, the controller, terminal selection, the menu, the PTY
|
||||
/// size, or child input.
|
||||
#[test]
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "one case per rejected identity or bound"
|
||||
)]
|
||||
fn a32_forged_stale_and_out_of_bounds_terminal_events_change_nothing() {
|
||||
let mut state = EditorState::new();
|
||||
let owner = FrontendId(51);
|
||||
let attacker = FrontendId(52);
|
||||
let terminal_buffer = open_terminal(&mut state, "sleep 30", 6, 20);
|
||||
let other_terminal = open_terminal(&mut state, "sleep 30", 6, 20);
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(terminal_buffer)
|
||||
.is_some()
|
||||
});
|
||||
let owner_window = attach_view(&state, owner, terminal_buffer);
|
||||
let document = state.core.borrow().active_buffer_id();
|
||||
attach_view(&state, attacker, document);
|
||||
|
||||
// Declare a size that differs from the spec's 6x20, so the
|
||||
// controller's resize is observable rather than suppressed as
|
||||
// unchanged.
|
||||
let size = CellSize::new(5, 18);
|
||||
let owner_key = TerminalViewKey::new(owner, owner_window, terminal_buffer);
|
||||
state.terminal_manager.borrow_mut().register_view(owner_key);
|
||||
assert!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.claim_controller(owner_key)
|
||||
);
|
||||
assert!(state.sync_semantic_terminal_layout(owner, terminal_buffer, size));
|
||||
let geometry_before = state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(terminal_buffer)
|
||||
.expect("snapshot")
|
||||
.size;
|
||||
assert_eq!(
|
||||
geometry_before, size,
|
||||
"the controller's declaration applied"
|
||||
);
|
||||
|
||||
// The attacker's window shows a DOCUMENT, so naming the owner's
|
||||
// terminal buffer is a forgery: the declaration is refused outright.
|
||||
assert!(!state.semantic_terminal_declaration_is_active(attacker, terminal_buffer));
|
||||
assert!(!state.sync_semantic_terminal_layout(attacker, terminal_buffer, CellSize::new(2, 4)));
|
||||
assert_eq!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(terminal_buffer)
|
||||
.expect("snapshot")
|
||||
.size,
|
||||
geometry_before,
|
||||
"a forged declaration must not resize another frontend's session"
|
||||
);
|
||||
assert_eq!(
|
||||
state.terminal_manager.borrow().controller(terminal_buffer),
|
||||
Some(pmacs::terminal::TerminalController::from_view(owner_key)),
|
||||
"a forged declaration must not steal control"
|
||||
);
|
||||
|
||||
// A pointer naming a buffer the sender is not displaying, and one
|
||||
// naming a terminal that exists but is not its active window, are
|
||||
// both dropped before any view mutation.
|
||||
assert!(!state.dispatch_semantic_terminal_pointer(
|
||||
attacker,
|
||||
terminal_buffer,
|
||||
CellCoord::new(0, 0),
|
||||
MouseKind::Down(MouseButton::Left),
|
||||
Modifiers::NONE,
|
||||
));
|
||||
assert!(!state.dispatch_semantic_terminal_pointer(
|
||||
owner,
|
||||
other_terminal,
|
||||
CellCoord::new(0, 0),
|
||||
MouseKind::Down(MouseButton::Left),
|
||||
Modifiers::NONE,
|
||||
));
|
||||
|
||||
// An out-of-bounds coordinate against a DECLARED viewport is
|
||||
// dropped too — a cell the sender never saw is not a hit.
|
||||
assert!(!state.dispatch_semantic_terminal_pointer(
|
||||
owner,
|
||||
terminal_buffer,
|
||||
CellCoord::new(size.rows, 0),
|
||||
MouseKind::Down(MouseButton::Left),
|
||||
Modifiers::NONE,
|
||||
));
|
||||
assert!(!state.dispatch_semantic_terminal_pointer(
|
||||
owner,
|
||||
terminal_buffer,
|
||||
CellCoord::new(0, size.cols),
|
||||
MouseKind::Down(MouseButton::Left),
|
||||
Modifiers::NONE,
|
||||
));
|
||||
assert!(
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.view_state(owner_key)
|
||||
.expect("owner view")
|
||||
.selection
|
||||
.is_none(),
|
||||
"a rejected pointer must not begin a selection"
|
||||
);
|
||||
assert!(
|
||||
!state.core.borrow().menu_is_open(),
|
||||
"a rejected pointer must not open the context menu"
|
||||
);
|
||||
|
||||
// A frontend with no declared viewport cannot hit-test at all: the
|
||||
// coordinate has no geometry to be relative to.
|
||||
let fresh = FrontendId(53);
|
||||
attach_view(&state, fresh, terminal_buffer);
|
||||
assert!(!state.dispatch_semantic_terminal_pointer(
|
||||
fresh,
|
||||
terminal_buffer,
|
||||
CellCoord::new(0, 0),
|
||||
MouseKind::Down(MouseButton::Left),
|
||||
Modifiers::NONE,
|
||||
));
|
||||
|
||||
// The owner's own in-bounds gesture is accepted, proving the
|
||||
// rejections above are about identity and bounds, not a dead path.
|
||||
assert!(state.dispatch_semantic_terminal_pointer(
|
||||
owner,
|
||||
terminal_buffer,
|
||||
CellCoord::new(0, 0),
|
||||
MouseKind::Down(MouseButton::Left),
|
||||
Modifiers::NONE,
|
||||
));
|
||||
|
||||
for buffer in [terminal_buffer, other_terminal] {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.terminate(buffer, &mut state.process_supervisor.borrow_mut())
|
||||
.expect("terminate child");
|
||||
}
|
||||
}
|
||||
|
||||
/// The daemon config the Stage 3 real-path acceptance runs against: a
|
||||
/// command that opens a controlled terminal child, bound to a key the
|
||||
/// probe can press.
|
||||
#[cfg(feature = "crdt")]
|
||||
const PROBE_INIT_LUA: &str = r#"
|
||||
pmacs.command.define {
|
||||
name = "vterm-probe.open",
|
||||
description = "Open the Stage 3 acceptance terminal child.",
|
||||
fn = function()
|
||||
return pmacs.terminal.open {
|
||||
command = "/bin/sh",
|
||||
args = { "-c",
|
||||
"i=0; while [ $i -lt 400 ]; do printf 'VTERMROW%02d\n' \"$i\"; i=$((i+1)); sleep 0.05; done" },
|
||||
}
|
||||
end,
|
||||
}
|
||||
-- C-M-t is deliberately an unbound chord: `bind` is strict and
|
||||
-- refuses to shadow an existing binding, so a bound one (C-t is
|
||||
-- transpose-chars) would fail init and leave the probe on a scratch.
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-M-t", command = "vterm-probe.open" }
|
||||
"#;
|
||||
|
||||
/// Acceptance 37: one path through a real daemon, a real PTY child, and
|
||||
/// real headless wgpu rendering.
|
||||
///
|
||||
/// The GPU is a separate binary that deliberately depends only on
|
||||
/// `pmacs-protocol`, so this drives it as a process: the daemon opens a
|
||||
/// `/bin/sh` terminal from its own `init.lua`, and `pmacs-gpu
|
||||
/// --headless-probe` attaches through the REAL attach client, applies
|
||||
/// real `TerminalFrame`s, composites real pixels offscreen, sends real
|
||||
/// input and a real geometry change, and reports named observations.
|
||||
/// A decoded-message fixture is deliberately not a substitute — it would
|
||||
/// prove nothing about the three fitting together.
|
||||
#[cfg(feature = "crdt")]
|
||||
#[test]
|
||||
fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The `pmacs-gpu` binary sits beside the `pmacs` one in the same
|
||||
/// target directory. Cargo exposes `CARGO_BIN_EXE_*` only for the
|
||||
/// package under test, so the sibling is derived rather than named.
|
||||
fn gpu_binary() -> PathBuf {
|
||||
Path::new(env!("CARGO_BIN_EXE_pmacs"))
|
||||
.parent()
|
||||
.expect("test binary directory")
|
||||
.join("pmacs-gpu")
|
||||
}
|
||||
|
||||
let required = std::env::var_os("PMACS_REQUIRE_GPU").is_some();
|
||||
let binary = gpu_binary();
|
||||
if !binary.exists() {
|
||||
assert!(
|
||||
!required,
|
||||
"PMACS_REQUIRE_GPU is set but {} is not built; run the workspace build first",
|
||||
binary.display()
|
||||
);
|
||||
eprintln!(
|
||||
"skipping a37: {} is not built (build the workspace to include it)",
|
||||
binary.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
|
||||
&[
|
||||
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),
|
||||
("PMACS_INSTANCE_MULTI_FRONTEND", "1"),
|
||||
],
|
||||
// The terminal is opened BY THE FRONTEND, through a real key
|
||||
// binding: `terminal.open` targets the invoking frontend's
|
||||
// window, so this is what actually puts the GPU's own window on
|
||||
// a terminal buffer. Opening it from init.lua instead would
|
||||
// switch the daemon's local window and leave the attached
|
||||
// frontend on a scratch — which is a real behavior, just not
|
||||
// the one this acceptance is about.
|
||||
//
|
||||
// The child keeps writing so the rendered frame carries live
|
||||
// cursor-addressed content and outlives the resize.
|
||||
PROBE_INIT_LUA,
|
||||
);
|
||||
|
||||
let report = daemon
|
||||
.socket_path()
|
||||
.parent()
|
||||
.expect("socket parent")
|
||||
.join("gpu-probe.txt");
|
||||
let output = std::process::Command::new(&binary)
|
||||
.arg("--headless-probe")
|
||||
.arg(daemon.socket_path())
|
||||
.arg(&report)
|
||||
// The chord the probe presses to run `vterm-probe.open`.
|
||||
.env("PMACS_GPU_PROBE_OPEN_KEY", "t")
|
||||
.output()
|
||||
.expect("run the headless GPU probe");
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
// Exit 3 is "no wgpu adapter" — the same skip contract the
|
||||
// in-crate headless render tests use.
|
||||
let no_adapter = output.status.code() == Some(3);
|
||||
assert!(
|
||||
no_adapter && !required,
|
||||
"headless GPU probe failed (status {:?}):\n{stderr}",
|
||||
output.status.code()
|
||||
);
|
||||
eprintln!("skipping a37: no wgpu adapter available");
|
||||
return;
|
||||
}
|
||||
|
||||
let text = std::fs::read_to_string(&report).expect("probe report");
|
||||
let facts: std::collections::HashMap<&str, &str> = text
|
||||
.lines()
|
||||
.filter_map(|line| line.split_once('='))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
facts.get("server_protocol_version").copied(),
|
||||
Some("19"),
|
||||
"the real daemon negotiated v19 with the real client: {text}"
|
||||
);
|
||||
assert_eq!(
|
||||
facts.get("entered_terminal_mode").copied(),
|
||||
Some("true"),
|
||||
"the GPU entered terminal mode from a real frame: {text}"
|
||||
);
|
||||
let frames: u32 = facts
|
||||
.get("frames")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
frames >= 2,
|
||||
"expected live terminal frames, got {frames}: {text}"
|
||||
);
|
||||
let rendered: u32 = facts
|
||||
.get("rendered_nonuniform_frames")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
rendered >= 2,
|
||||
"expected real composited frames, got {rendered}: {text}"
|
||||
);
|
||||
assert!(
|
||||
facts
|
||||
.get("last_frame_text")
|
||||
.is_some_and(|t| t.contains("VTERMROW")),
|
||||
"the child's cursor-addressed output must reach the rendered frame: {text}"
|
||||
);
|
||||
let declarations: u32 = facts
|
||||
.get("declarations")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
declarations >= 2,
|
||||
"expected a bootstrap declaration and a resize declaration, got \
|
||||
{declarations}: {text}"
|
||||
);
|
||||
assert_eq!(
|
||||
facts.get("observed_resized_frame").copied(),
|
||||
Some("true"),
|
||||
"the controller's resize must reach the shared PTY and come back \
|
||||
as a frame at the new width: {text}"
|
||||
);
|
||||
assert!(
|
||||
facts
|
||||
.get("disconnect")
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.is_empty(),
|
||||
"the probe must finish without a transport failure: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Acceptance 30 (v18 half) and 28: a peer that negotiated v18 receives
|
||||
/// no terminal message at all and keeps the ordinary document path over
|
||||
/// the empty identity buffer.
|
||||
#[test]
|
||||
fn a28_a30_a_v18_semantic_peer_has_no_terminal_surface() {
|
||||
let mut state = EditorState::new();
|
||||
let frontend_id = FrontendId(61);
|
||||
let terminal_buffer = open_terminal(&mut state, "sleep 30", 4, 20);
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(terminal_buffer)
|
||||
.is_some()
|
||||
});
|
||||
attach_view(&state, frontend_id, terminal_buffer);
|
||||
|
||||
let mut producer = SemanticRenderState::for_peer(frontend_id, 18);
|
||||
producer.set_terminal_viewport(terminal_buffer, CellSize::new(4, 20));
|
||||
producer.set_viewport(
|
||||
terminal_buffer,
|
||||
pmacs::protocol::ByteRange { start: 0, end: 0 },
|
||||
0,
|
||||
);
|
||||
let messages = producer.render_frame(&state);
|
||||
assert!(
|
||||
terminal_frames(&messages).is_empty(),
|
||||
"a v18 peer must never receive a terminal frame"
|
||||
);
|
||||
assert!(
|
||||
!producer.in_terminal_mode(),
|
||||
"a v18 peer stays on the document path"
|
||||
);
|
||||
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow_mut()
|
||||
.terminate(terminal_buffer, &mut state.process_supervisor.borrow_mut())
|
||||
.expect("terminate child");
|
||||
}
|
||||
Loading…
Reference in New Issue