Merge pull request #221 from levineuwirth/long-lines

Long lines: ui.line-wrap, resolved once and honored by both frontends
This commit is contained in:
Levi Neuwirth 2026-08-07 18:41:12 +00:00 committed by GitHub
commit 02f3ec3b7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 4786 additions and 180 deletions

View File

@ -0,0 +1,73 @@
-- Long lines (QoL Stage 3, framing docs/long-lines-framing.md).
--
-- Declares `ui.line-wrap`. Everything that honors it is Rust: the grid
-- renderer walks it through `Viewport`, the coordinate mapping takes it
-- through `LayoutCtx`, and semantic frontends are told over
-- `InstanceMessage::LineWrapFacts` at protocol v22 because they lay out
-- locally and would otherwise never hear it.
--
-- Buffer-local (Q#LL2). The registry already supports a per-buffer
-- layer, and this is a property of the content: prose wants wrapping,
-- a log file usually does not. The *anchor* the viewport scrolls to
-- stays per-window, because two panes on one buffer scroll
-- independently.
--
-- `ui.`, not `editing.`: `editing.*` is buffer-editing behavior
-- (auto-pair, trim-on-save, line endings) and this changes only how
-- text is shown. The two existing `ui.*` settings carry a `gpu-`
-- prefix to mark frontend-specific ones, so the ABSENCE of a prefix
-- here is what says "both frontends".
pmacs.config.define {
name = "ui.line-wrap",
description = "How a line wider than the window is shown: wrap onto following rows, or truncate at the edge.",
-- A closed set, so an unknown value is impossible rather than
-- handled. Adding "word" later is a clean additive change --- which
-- is the plan, since character wrap is what both frontends can do
-- identically today (Q#LL5) and word wrap is a deliberate future
-- choice rather than an inherited library default.
type = "enum",
choices = { "wrap", "truncate" },
-- `wrap` is the only value that leaves every character reachable
-- with this stage's machinery. It is also what the GPU already did,
-- so the default is not a behavior change there --- but it IS one in
-- the TUI, which truncated. No default can preserve both, because
-- the two frontends disagreed before this setting existed; that is
-- the defect, not a side effect of fixing it.
default = "wrap",
mutability = "live",
}
-- `truncate` leaves text past the right edge UNREACHABLE until Stage 4
-- adds horizontal scrolling. That is stated in the description above
-- rather than left for a user to discover, and it is why `truncate` is
-- not the default despite being the TUI's historical behavior.
-- Buffer-local on BOTH sides, and the pairing matters.
--
-- `pmacs.config.get(name)` reads the global chain and
-- `pmacs.config.set(name, ...)` writes the global layer, so a toggle
-- built from those two would be wrong in the case the setting exists
-- for: a buffer pinned to `truncate` would report the GLOBAL value,
-- flip the GLOBAL value, and leave that buffer exactly as it was ---
-- while silently changing every buffer that had not been pinned.
--
-- So: read with `get(name, buf)`, write with `set_local(buf, ...)`,
-- and resolve the buffer ONCE for both. Resolving twice would be a
-- narrower version of the same bug, since the active buffer can change
-- between two calls.
pmacs.command.define {
name = "ui.toggle-line-wrap",
description = "Toggle line wrapping for the current buffer",
fn = function()
local buf = pmacs.window.buffer()
local current = pmacs.config.get("ui.line-wrap", buf)
local next_mode = current == "wrap" and "truncate" or "wrap"
pmacs.config.set_local(buf, "ui.line-wrap", next_mode)
if next_mode == "truncate" then
pmacs.editor.set_status("line wrap off — text past the edge is unreachable until horizontal scrolling lands")
else
pmacs.editor.set_status("line wrap on")
end
end,
}

View File

@ -196,7 +196,7 @@ hazard in a shape that looks committed. **A documented error message
that never appears is worse than no documentation**, because the reader
waits for a signal that is not coming.
## Docs absorption after #217PR #218 OPEN
## Docs absorption after #217MERGED as #218 (2026-08-06 09:59Z)
**PR #218** — https://github.com/levineuwirth/pmacs/pull/218. **This
block was written with the lane's first commit, before the PR
@ -225,7 +225,7 @@ are the lanes this one creates, not work it does. Retiring the
CI-CRDT, Distribution, or reap-ledger lanes: each still owns undone
work and rule 4 does not apply to them.
## Honoring `full_grid` (QoL Stage 1) — PR #219 OPEN
## Honoring `full_grid` (QoL Stage 1) — MERGED as #219 (2026-08-06 13:41Z)
**PR #219** — https://github.com/levineuwirth/pmacs/pull/219. **This
block was written with the lane's first commit, before the PR
@ -296,7 +296,7 @@ change to *when* `needs_full_grid` is set — the producer's triggers
were verified correct, along with per-frame geometry sync and
`view_top` reconciliation on shrink.
## GUI zoom (QoL Stage 2) — PR #220 OPEN
## GUI zoom (QoL Stage 2) — MERGED as #220 (2026-08-07 08:25Z)
**PR #220** — https://github.com/levineuwirth/pmacs/pull/220. **Written
with the lane's first commit, before the PR existed** — the standing
@ -434,6 +434,278 @@ a job executed — **the log is**.
Whether `docs/ci-red-signatures.md` should grow a short non-row section
for this class is an open question for its owner, not something this
lane decided.
## Long lines (QoL Stage 3) — PR #221 OPEN, awaiting review
**Branch `long-lines`**, originally based on `githubsucks/main` @
`218d2e7` (the #219 merge); **main merged in at #220's landing**, so
the branch now carries Stage 2. The two share no code — that merge is
currency, not dependency, taken so the Stage 3 PR opens against a base
it has already been tested on. `githubsucks/long-lines` is the authoritative tip — the ref,
not a SHA, since any edit to this block advances past whatever SHA it
records. Recover: `git fetch githubsucks && git checkout long-lines`.
**This block was written with the lane's first commit, before any PR
existed** — the standing correction from #171, #215 and #220. **PR
#221** opened 2026-08-07 against `main` @ `912bf57`; the number is
recorded here rather than left to be reconstructed from the branch.
- **Framing `docs/long-lines-framing.md` revision 20, APPROVED.** All
eight questions settled, §5d.6 resolved. Revision 20 **withdraws
§1.1** — see "The framing error" below.
- **Independent of #220** (now merged). Stage 2 and Stage 3 share no
code; the merge in this branch is currency, not dependency.
- **Implementation complete.** Seven commits, `937544c..840a338`.
Every gate green locally, macOS unverifiable here as always.
### The defect
A line wider than the window is unreadable past the edge **in the
TUI**. The GPU is not in that state: it already wraps. So the
cross-frontend defect is **not** unreadability — it is that *neither
behavior was chosen*. The TUI truncates because a cell walk breaks at
`max_cols`; the GPU wraps because cosmic-text's `Wrap::WordOrGlyph`
default was never overridden. Two accidents that disagree, with no way
for the user to express a preference in either.
### The six answers
- **Q#LL1** — ships `wrap` + `truncate`, **default `wrap`**; horizontal
scroll is **Stage 4**. No default preserves both frontends, so this
knowingly changes the TUI's behavior and leaves the GPU's alone.
- **Q#LL2** — the mode is **buffer-local**; `Viewport` carries the
*resolved* mode as it already carries `folds`, so `TextView` stays
config-agnostic.
- **Q#LL4** — do **not** adopt `editing.fill-column`; name ours
`ui.line-wrap` (`ConfigKind::Enum`). *(The recorded reason was wrong;
the answer was not. See "The framing error".)*
- **Q#LL5****character wrap in both frontends**; the GPU document
buffer gets its first explicit `set_wrap`, `Wrap::Glyph`.
- **Q#LL6** — no global map (every vertical consumer is local, so
layout is per-line); `view_top`'s sub-line component is a **byte**;
`DisplayCoord` gains `sub_row` rather than redefining `row`.
### The two holes review found in the first commit
- **Q#LL7 --- the GPU had no wire.** The mode resolved into `Viewport`,
which reaches only the *grid* renderer; the GPU lays out locally and
no message expresses a wrap mode. `truncate` would have changed the
TUI and left the GPU wrapping --- the exact disagreement this lane
exists to close. Now specified as an additive v22 variant carrying
`buffer_id`, resent on attach, config change, **and buffer switch**.
That third trigger is the subtle one: font size is global, wrap mode
is per buffer, so a `FontFacts`-shaped design is silently wrong and
looks right in every single-buffer test.
- **Q#LL8 --- "every vertical consumer is local" was false.** The
scroll indicator needs a total. A one-line buffer wrapping to fifty
rows has `total_lines == 1`, so the indicator reports `All` while
forty-nine rows sit off-screen. The bounded distinction survives: a
*total* is one lazily-computed number; an *index* is `O(N)` resident
storage, still ruled out.
**Two further corrections, from review of the second commit.**
`format_scroll_indicator` is **duplicated, not shared**
(`src/editor.rs:5509`, `pmacs-gpu/src/main.rs:10114`), and the GPU
passes a source-line count --- so the first fix would have corrected
one frontend and left the other wrong, *this lane's own defect
reproduced by the section meant to close it*. And the lazy total's
cache key omitted **fold state**, which changes per window with no
edit, resize or mode change; now keyed on the fold projection's own
contents, which cannot be forgotten, rather than a maintained
revision counter that can.
**Then the aggregate was abandoned entirely (revision 17).** The GPU
shapes **only the viewport slice** — Session S1 found the whole rope
made large-file editing `O(file)` per keystroke — so its layout
cannot yield a total, and re-shaping the document for a status-line
readout would reintroduce that cost. `NN%` is now **byte-based in
both frontends**; `All`/`Top`/`Bot` stay exact because they are local
predicates. This retires the cache *and* the fold-key fix above,
which is kept in the framing marked superseded so a reader can tell
"the key was fixed" from "there is no key".
**Then the retained formatter turned out unable to express the new
contract (revision 18).** Every branch of `format_scroll_indicator`
derives from `total_lines`, so a byte total would compare rows
against bytes and a fake total restores the false `All`. Resolved by
keeping the existing formatter **untouched for `truncate`** — output
identical by construction — and adding a `classify(first_visible,
last_visible, byte_pos, byte_len)` for `wrap` that never sees a row
count. **Resolved: the classifier lives in `pmacs-protocol`**
`ScrollPosition` plus a pure `classify`, with string rendering left
in each frontend. Per §16: each frontend computes its own local
layout facts, the shared crate owns the semantic decision they feed.
**No wire message, no version bump**, and the one-copy-fixed defect
becomes unrepresentable rather than reviewer-guarded.
### The framing error, and the four defects review found in the code
**`editing.fill-column` does not exist.** Framing §1.1 called it an
orphaned registry setting "of the exact shape Stage 1 just fixed" and
carried a deliverable to sharpen its description. Both cited
occurrences are inside `#[cfg(test)] mod tests`
`src/config_registry.rs` and `src/lua_bindings/config.rs` — fixture
names in round-trip tests covering one setting per `ConfigKind`. Two of
those five names are real; three, including this one, are defined
nowhere else.
Nineteen revisions and three review rounds inherited it. The mechanism
is worth keeping: a grep hit at a `src/` path, a genuine `r.define(...)`
call that is real API usage rather than a mock, and `#[cfg(test)]` about
fifty lines above the citation. **A file:line citation is not a
substitute for reading the scope it sits in** — and once a conclusion is
in a document, later rounds reason about its consequences rather than
re-check it. Cost: three paragraphs of framing and one carried
deliverable that had no object. Stage 3 ships a comment at both fixture
sites instead.
The four code defects, each caught by user review or by the tests
written for it:
1. **The toggle wrote the global layer.** `ui.line-wrap` is
buffer-local, so `config.get(name)`/`config.set(name, ...)` reported
and flipped the *global* value — leaving a pinned buffer untouched
while silently moving every unpinned one. Invisible in any
single-buffer test. Fixed in `4a26f00`; witnesses use two buffers.
2. **The renderer never got the mode.** The frame resolved
`ui.line-wrap`, stored it on the window, and fed it to coordinate
mapping and the indicator — while the `Viewport` literal still
carried a hard-coded `Truncate`. Every "is the mode right?" test
passed and the text stayed clipped. Fixed in `aa3cd4d`; the new
witnesses read the **grid**, not the resolved value.
3. **The GPU indicator reckoned in source lines.** Pre-existing (the
GPU has always wrapped), but nameable only once `ui.line-wrap`
decided which formula applies. Fixed in `840a338` via
`code_byte_painted` — layout decides, not arithmetic over it. The
two cheap predicates are both wrong: `view_range` includes
`SCROLL_OVERSCAN`, `scroll_top` ignores the sub-line residual.
4. **An empty `view_range` is not an empty layout.** Found *by* the
tests in 3, not confirmed by them: a file ending in a newline has a
final empty line, and a viewport parked on it is `(len, len)` with
one real row. The guard borrowed from the caret path made **every
newline-terminated file report a percentage instead of `Bot`** at
the bottom.
**A process note that earned itself twice.** Two edits in this lane
were silently lost to `str.replace` calls that matched nothing (once
after an unrelated exception aborted the write). Both times the code
looked edited and was not — defect 2 above is one of them. Use the
Edit tool, which errors on mismatch, for anything load-bearing.
### The two decisions most likely to be questioned later
- **GUI users lose word wrap.** Character-wrap parity is cheap and
Emacs-consistent, but the GPU has word-wrapped since it existed.
Accepted deliberately (user, 2026-08-06). **Must appear in the PR
description and release notes**, not only in the framing.
- **The audit strategy is asymmetric on purpose.** The coordinate
functions gain a required context parameter so the **compiler
enumerates** every call site; `DisplayCoord` gains an **additive**
field so untouched consumers stay *correct* rather than merely
findable. Compiler-enforced where possible, correct-by-default where
not.
### Verification (delivered)
Everything framing §7 sketched, plus three groups it did not anticipate
(framing §7.1).
**The gate list below is the one that FAILED to catch this lane's CI
red, and it is kept only to show the hole.** "The touched acceptance
suites" is selected from the diff, and a `PROTOCOL_VERSION` bump breaks
version-assertion tests that appear nowhere in it. Five failed on CI's
first round. Use **`cargo test --tests --no-fail-fast`** on any
protocol bump — `--no-fail-fast` because cargo stops at the first
failing target, which is why CI showed one failure and the local
full-corpus run then found three more. Recorded in
`docs/agent-handoff.md`.
```
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo clippy --workspace --all-targets --features crdt -- -D warnings
cargo test --lib # 1917 / 0
cargo test --lib --features crdt # 2102 / 0
cargo test --tests --no-fail-fast # 108 targets, exit 0
cargo test --tests --features crdt --no-fail-fast # 108 targets, exit 0
PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu # 228 / 0
git diff --check
```
**Both configurations, and that mattered.** The default sweep went
clean while `--features crdt` still had **three** failures, in
crdt-gated real-daemon tests (`vterm_stage3_acceptance` ×2,
`bottom_panel_stage2b_gpu_acceptance` ×1) that assert on a real
socket's negotiated version. This is the handoff's existing "a local
sweep is blind to whichever feature configuration it does not build"
lesson, hit again by a different lane — so **eight** version
assertions broke in total, not five.
The eight version-assertion failures, and what each one was:
| test | was | why it broke |
|---|---|---|
| `acc51_a_v20_peer_..._even_when_capable` (×5 jobs) | `PROTOCOL_VERSION - 1` | **bug** — an absolute contract ("below the panel version") as arithmetic on a moving constant. Now `PANEL_MIN_VERSION - 1`, the idiom `src/` already used in five places |
| `the_baseline_stays_and_the_counter_offer_activates` | `PANEL_MIN_VERSION == PROTOCOL_VERSION` | **bug** — asserted a coincidence true only while panels were the newest feature. Now the two durable bounds |
| `the_panel_stage_takes_protocol_v21` | `PROTOCOL_VERSION == 21` | **bug** — the current wire as a proxy for the panel stage's own version, in a test whose name says which it means |
| `a54_real_daemon_..._panel_hosted_terminal` *(crdt)* | `session_protocol_version == "21"` | **bug** — the negotiated session version is *this binary's* wire, so the literal held only while panels were newest. Now `PROTOCOL_VERSION`, plus an explicit `>= PANEL_MIN_VERSION` for the capability the literal carried implicitly |
| `a37_real_daemon_..._one_terminal_session` *(crdt)* | `session_protocol_version == "21"` | **bug** — same |
| `a13_17_26_..._version_cost` | `PROTOCOL_VERSION == 21`, `6..=21`, `!supported(22)` | **tripwire working as designed** — it says in its own comment that it tracks the current wire. Took the conscious edit |
| `the_baseline_stays_...` (same test) | `PROTOCOL_VERSION == 21` | **tripwire working as designed** |
| `terminal_mode_keeps_reporting_presence_...` *(crdt)* | `PROTOCOL_VERSION == 21` | **tripwire working as designed** |
**Five bugs and three tripwires.** The distinction is the useful part.
A tripwire that fires on a bump is doing its job, and "fixing" it means
editing it deliberately — the baseline pin `ADVERTISED_PROTOCOL_VERSION
== 20` is the one that must *never* be edited, and it never fired.
The five bugs share one shape: **an absolute contract expressed as
arithmetic on, or equality with, a moving constant.** `PROTOCOL_VERSION
- 1` for "below the panel version"; `PANEL_MIN_VERSION ==
PROTOCOL_VERSION` for a coincidence; `PROTOCOL_VERSION == 21` standing
in for the panel stage's own version; `"21"` for a negotiated session
version. Each was true when written and silently false afterwards.
`src/` already had the right idiom — `PANEL_MIN_VERSION - 1`, five
occurrences — and every outlier was in `tests/`.
`tests/long_line_readable_acceptance.rs` is the one that answers the
**report** rather than a mechanism: the shipped binary, a real PTY, a
line 200 columns wide in an 80-column terminal, and an assertion that
the tail marker reaches the host. It bites —
`scripts/bite HEAD~2 src/editor.rs --test long_line_readable_acceptance`
against the pre-`aa3cd4d` editor never paints `TAILZQX` in 20s. Its
`truncate` control (an isolated `init.lua` pinning the mode) is what
makes that marker discriminating.
**What the PTY test does not prove.** The vterm suites assert on raw
output bytes; there is no screen model and no `vt100`/`termwiz`/`vte`
in the workspace. It proves the tail was *written to the terminal*, not
that it occupies the row a human would point at. That is nonetheless
the whole of the original report — under truncation those bytes are
never emitted at all.
**One caveat on the GPU line, stated rather than smoothed over.** One
`-p pmacs-gpu` run went `227 passed; 1 failed` before every run since
went 228/0. **The failing test name was not captured** — that command
was piped through `tail -3`, which kept the summary and discarded the
failure block. 36 later full runs are clean, 6 under deliberate
concurrent load. Per `docs/ci-red-signatures.md`'s rerun rule that
establishes intermittence only, and without a selector it does not even
establish that. Logged there as **U1**, explicitly *not* matched
against A1 (also GPU-headless-under-load) because matching requires a
selector and fragments this occurrence does not have.
**And one more, logged as U2.**
`process::tests::m6_1_pty_raw_mode_disables_kernel_echo` failed once
during a full `--tests --no-fail-fast` run and did not reproduce in a
later full sweep (108 targets, exit 0) or 3 isolated `--lib` runs. It
is in no registry row, so it is a new incident; leaked
`pmacs --daemon` processes remain an unexcluded rival explanation.
### Not in scope
Horizontal scroll in full (Stage 4). `M-q` / auto-fill / reflow. Word
wrap as a mode value — a named future third choice, not this stage.
Bidi/RTL. Soft-wrap gutter indicators.
## Tree primitive (P5) — MERGED as #217; adoption is the open work

View File

@ -404,6 +404,35 @@ someone forgot.
old. They are a rival explanation for any load-sensitive local
failure, so **check `pgrep -f "pmacs --daemon"` before trusting a
local red**. Lane recorded in `docs/active-work.md`.
- **A `PROTOCOL_VERSION` bump's blast radius is every version-sensitive
test, and NONE of them appear in the diff.** "The touched acceptance
suites" is the standing gate, and for a protocol bump it is the wrong
selector: long-lines Stage 3 bumped v21→v22, ran the suites it had
edited, and broke **eight** version assertions across six suites. CI
showed exactly **one**, because **cargo stops at the first failing
target**; the rest surfaced only afterwards, and one at a time would
have cost four more red rounds.
**On any protocol bump run `cargo test --tests --no-fail-fast` in
BOTH feature configurations.** `--no-fail-fast` because of the
stop-at-first-target behavior above, and `--features crdt` because
three of the eight were in crdt-gated real-daemon tests that assert
on a live socket's negotiated version — invisible to a default sweep,
which is the blindness the bullet below already names.
**Sort the failures before fixing them.** A *tripwire*
(`assert_eq!(PROTOCOL_VERSION, N)`) is meant to fire and takes a
deliberate edit; three of the eight were these, and the one pin that
must NEVER be edited — `ADVERTISED_PROTOCOL_VERSION == 20` — did not
fire. The other five were defects sharing one shape: **an absolute
contract expressed as arithmetic on, or equality with, a moving
constant.** `PROTOCOL_VERSION - 1` for "below the panel version";
`PANEL_MIN_VERSION == PROTOCOL_VERSION` for a coincidence true only
while panels were newest; `PROTOCOL_VERSION == 21` standing in for
the panel stage's own version; `"21"` for a negotiated session
version. Anchor on the constant the contract *names*`src/` already
spelled it `PANEL_MIN_VERSION - 1` in five places, and every outlier
was in `tests/`.
- **A local sweep is blind to whichever feature configuration it does
not build.** Stage 3's census and every verification sweep ran
`--features luajit` WITHOUT `crdt`, so no crdt-gated suite was

View File

@ -393,6 +393,51 @@ process test contributed two, and only one of them is a test bug.
|---|---|---|
| 2026-08-05 | R2, R4 | **retired** — mechanism removed, discriminating witness added; see "Retired rows" |
### U1 — an unclassifiable local red (long-lines lane, 2026-08-07)
Recorded because the alternative is to not record it. It is **not** a
row, cannot be matched, and excuses nothing.
| field | value |
|---|---|
| **selector** | `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`**test name not captured** |
| **job / flavor** | local (Linux), immediately after a 60s `m4_acceptance` run |
| **required fragments** | **none captured** |
| **status** | **unclassifiable — evidence destroyed at capture time** |
| **what IS established** | `test result: FAILED. 227 passed; 1 failed` was emitted once |
| **what is NOT** | which test, why, and whether the lane caused it |
| **cause of the gap** | the command piped through `tail -3`, which kept the summary line and discarded the failure block above it |
**Not matched against A1** despite A1 also being GPU-headless-under-load.
Matching needs an exact selector and every required fragment; this has
neither, and treating a shapeless red as "probably the known one" is
precisely the reputation-by-adjacency this file exists to deny.
Follow-up: 36 subsequent full runs clean, 6 of them under deliberate
concurrent load (`m4_acceptance` in parallel). Per the rerun rule that
establishes **intermittence only** — and here not even that, since
without a name there is nothing to call intermittent.
**The lesson is mechanical, not analytical: never pipe a gate through
`tail`/`head` on the run whose result you intend to report.** Filter
with `grep -E "FAILED|panicked|test result"`, which keeps failure
context, or capture the full log to a file and summarize from it.
### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, one local occurrence
Has a selector, which U1 lacks — but still no fragments, so it cannot
be matched either. Recorded so a recurrence is recognisable.
| field | value |
|---|---|
| **selector** | `--lib process::tests::m6_1_pty_raw_mode_disables_kernel_echo` |
| **job / flavor** | local (Linux), during `cargo test --tests --no-fail-fast` — the lib target alongside a full PTY-heavy corpus |
| **required fragments** | **none captured** — output was filtered to the `FAILED` line |
| **status** | **new incident, unreproduced** |
| **what IS established** | it failed once (`1916 passed; 1 failed`), in no registry row, under a full-corpus run |
| **what is NOT** | any mechanism. Not reproduced in a later full `--tests --no-fail-fast` sweep (108 targets, exit 0) nor in 3 isolated `--lib` runs (1917/0 each) |
| **rival explanation not excluded** | leaked `pmacs --daemon` processes, which the handoff names as a standing confound for any load-sensitive local red |
**The retirements are not occurrences and do not close the log.** R1 and
R3 stay live, and each retired row keeps its signature so a later red
matching one reopens it.

1838
docs/long-lines-framing.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3913,6 +3913,21 @@ impl State {
&mut font_system,
Metrics::new(fm.code_font_size(), fm.code_line_height()),
);
// Declare the document's wrap mode instead of inheriting
// cosmic-text's constructor default (`Wrap::WordOrGlyph`).
//
// `Glyph`, not `None`: `ui.line-wrap` defaults to `wrap`, so a
// frontend that has not yet been told anything — or is talking
// to a pre-v22 daemon that never will be — should already be in
// the default mode. What changes versus the inherited default is
// only the break rule, word to character, which is the
// cross-frontend parity this stage buys.
//
// Declaring it is load-bearing rather than tidy: without it the
// document runs on `WordOrGlyph` until some message happens to
// change it, so a frontend talking to a pre-v22 daemon word
// wraps forever while the grid renderer character wraps.
buffer.set_wrap(&mut font_system, Wrap::Glyph);
buffer.set_size(
&mut font_system,
Some(config.width as f32),
@ -5151,6 +5166,10 @@ impl State {
self.apply_terminal_frame(frame);
None
}
InstanceMessage::LineWrapFacts { buffer_id, wrap } => {
self.apply_line_wrap(buffer_id, wrap);
None
}
InstanceMessage::PanelFrame(payload) => {
// The band changes the DOCUMENT's pixel height, so a panel
// that appears or disappears has to reshape the document
@ -7137,7 +7156,14 @@ impl State {
/// legacy diagnostic/cursor/scroll suffix. Custom boundaries are one
/// base-colored space; the built-in suffix retains its exact two-space
/// separators.
fn compose_status_runs(&self) -> Vec<(String, Color)> {
/// `&mut self` because the wrapped branch asks cosmic-text where two
/// bytes actually landed, and laying a line out shapes it. That is
/// the point rather than a wart: the alternative is a per-frame
/// cached `(first_visible, last_visible)` pair, which is a value
/// maintained beside the layout and free to disagree with it —
/// the same shape as the `code_wrap` shadow field this lane already
/// removed once.
fn compose_status_runs(&mut self) -> Vec<(String, Color)> {
use std::fmt::Write as _;
let base = self.status_right_base_color();
@ -7196,12 +7222,54 @@ impl State {
let _ = write!(readout, "L{}:C{}", line + 1, col + 1);
readout.push_str(" ");
}
readout.push_str(&format_scroll_indicator(
self.scroll_top,
estimated_visible_lines(self.config.height, self.fm, self.band_inset()),
self.current_line_starts.len(),
cursor_row,
));
if self.buffer.wrap() == Wrap::None {
readout.push_str(&format_scroll_indicator(
self.scroll_top,
estimated_visible_lines(self.config.height, self.fm, self.band_inset()),
self.current_line_starts.len(),
cursor_row,
));
} else {
// Wrapping makes the line-space formatter wrong, not merely
// imprecise: it compares `visible` (VISUAL rows that fit)
// against `total_lines` (SOURCE lines), so a document whose
// lines each wrap to three rows reports `Bot` from a third
// of the way down. This is not new in this lane — the GPU
// has always wrapped — but the lane is where it became
// nameable, because `ui.line-wrap` is now what decides which
// formula applies.
//
// The percentage comes from bytes because there is no row
// total to take it from: only the viewport slice is shaped,
// so rows below it were never laid out and counting them
// arithmetically would disagree with the breaks cosmic-text
// actually chose. Same rule as the TUI, from
// `pmacs_protocol::scroll`.
let byte_len = self.current_text.len() as u64;
let byte_pos = self
.own_cursor
.filter(|own| self.current_buffer_id == Some(own.buffer_id))
.map_or_else(
// No cursor of ours in this buffer: the viewport's
// own top is the honest position, matching the
// `cursor_row = self.scroll_top` fallback above.
|| {
self.current_line_starts
.get(self.scroll_top)
.copied()
.unwrap_or(0)
},
|own| own.byte.min(byte_len),
);
let first_visible = self.code_byte_painted(0);
let last_visible = self.code_byte_painted(byte_len);
readout.push_str(&render_scroll_position(pmacs_protocol::scroll::classify(
first_visible,
last_visible,
byte_pos,
byte_len,
)));
}
builtins.push((readout, base));
if !runs.is_empty() {
@ -7978,6 +8046,41 @@ impl State {
}
}
/// Honor a wrap mode for `buffer_id` (protocol v22).
///
/// The document buffer has never set a wrap mode, so it has been
/// running on cosmic-text's constructor default,
/// `Wrap::WordOrGlyph` — word wrap that nobody chose. This makes the
/// mode explicit in both directions and settles it on
/// **`Wrap::Glyph`**: character wrap is what the grid renderer can
/// implement identically without pulling UAX #14 into it, and it is
/// what Emacs does by default. GUI users lose word wrap; that is a
/// deliberate, documented trade for the two frontends agreeing.
///
/// Changing wrap reflows the whole document, exactly like a font
/// change, so the retained scroll anchor is repaired through
/// `normalize_code_scroll` rather than left pointing at a row that
/// no longer exists.
fn apply_line_wrap(&mut self, buffer_id: BufferId, wrap: bool) {
// Only the buffer on screen can be reflowed; the mode is
// buffer-local, so a message for anything else is not ours to
// apply. The daemon resends on buffer switch precisely so this
// stays correct rather than needing a per-buffer cache here.
if self.current_buffer_id != Some(buffer_id) {
return;
}
let want = if wrap { Wrap::Glyph } else { Wrap::None };
// Compare against the BUFFER, not a shadow field. A cached copy
// can disagree with what cosmic-text actually holds — and when
// it does, the short-circuit turns a real mode change into a
// silent no-op. Reading the authority cannot drift from it.
if self.buffer.wrap() == want {
return;
}
self.buffer.set_wrap(&mut self.font_system, want);
self.reshape();
}
fn reshape(&mut self) {
self.rebuild_code_slice();
self.normalize_code_scroll();
@ -9609,6 +9712,51 @@ impl State {
self.minibuffer.is_none() && self.code_caret_rect_in_clip().is_some()
}
/// Whether an arbitrary source `byte` is actually painted in the
/// drawable code area — [`Self::code_caret_rect_in_clip`]'s test,
/// generalized off the own cursor.
///
/// The scroll indicator needs this and nothing weaker. Two cheaper
/// predicates are available and both are wrong:
///
/// - `view_range.0 == 0` / `view_range.1 == len` describe the
/// **shaped** span, which carries `SCROLL_OVERSCAN` source lines
/// past the window. A slice that merely reaches EOF says nothing
/// about whether EOF is on screen, so `Bot` would latch on early.
/// (This is exactly the guess that broke
/// `extreme_sizes_render_with_contained_popups` when it was tried.)
/// - `scroll_top == 0` ignores `code_scroll_residual`, so scrolling
/// into the middle of a wrapped first line still claims `Top`.
///
/// Asking where the byte lands answers both, because cosmic-text
/// laid it out: wrapped continuation runs pushed below the band and
/// overscan lines shaped past the bottom both fail the clip.
fn code_byte_painted(&mut self, byte: u64) -> bool {
let (vstart, vend) = self.view_range;
if byte < vstart || byte > vend {
return false;
}
// Deliberately no `vend <= vstart` rejection, though the caret
// and completion-anchor paths both carry one. An empty range is
// not the same as an empty layout: a file ending in a newline
// has a final empty line, and a viewport parked on it shapes
// one real row at `(len, len)`. Rejecting that reported
// "neither end visible" — a percentage — at the exact moment
// the user had scrolled to the bottom. `code_byte_px` already
// returns `None` when nothing is shaped, which is the condition
// that guard was reaching for.
let Some((_x, top, line_height)) = self.code_byte_px(byte) else {
return false;
};
let y = TEXT_TOP + top;
let bottom = document_text_bottom(self.config.height, self.fm, self.band_inset());
// Partial overlap counts as painted, the same rule the caret
// clip uses. A first row half-scrolled under the top edge is
// still legible, and a stricter test here would disagree with
// the caret about the very same row.
y < bottom && y + line_height > TEXT_TOP
}
/// Push one rect per visual line whose glyphs overlap the
/// slice-relative source byte range `[lo, hi)`, spanning the
/// matching projected glyphs' horizontal extent. Each source-line
@ -10108,9 +10256,28 @@ fn minimap_band_contains(
&& y < MINIMAP_TOP + height
}
/// Render a [`pmacs_protocol::scroll::ScrollPosition`] for the status
/// line. The classification is shared with the TUI; only this spelling
/// is local (framing §5d.6), so the two frontends cannot decide
/// differently but each stays free to present it.
fn render_scroll_position(pos: pmacs_protocol::scroll::ScrollPosition) -> String {
use pmacs_protocol::scroll::ScrollPosition;
match pos {
ScrollPosition::All => "All".to_owned(),
ScrollPosition::Top => "Top".to_owned(),
ScrollPosition::Bot => "Bot".to_owned(),
ScrollPosition::Percent(p) => format!("{p}%"),
}
}
/// The TUI mode line's scroll readout, ported verbatim (Q#S1): "All"
/// when the buffer fits, "Top"/"Bot" at the extremes, else the cursor
/// row as a percentage of the file.
///
/// Only reached when wrapping is **off**. Under wrapping it is not
/// merely imprecise but wrong — it reckons in source lines while the
/// window holds visual rows — so that path goes through
/// [`render_scroll_position`] instead.
fn format_scroll_indicator(
view_top: usize,
visible: usize,
@ -10484,6 +10651,7 @@ fn debug_apply() -> bool {
fn instance_message_label(msg: &InstanceMessage) -> &'static str {
match msg {
InstanceMessage::CellDelta { .. } => "CellDelta",
InstanceMessage::LineWrapFacts { .. } => "LineWrapFacts",
InstanceMessage::Cursor(_) => "Cursor",
InstanceMessage::ModeLine(_) => "ModeLine",
InstanceMessage::Signal(_) => "Signal",
@ -15847,6 +16015,222 @@ mod tests {
/// Acceptance 11 — a caret painted on a wrapped visual run
/// survives 16px → 72px → 6px re-wraps, with the normalized
/// A fresh frontend must already be CHARACTER wrapping, not word
/// wrapping.
///
/// The subtle failure this catches: `apply_line_wrap` short-circuits
/// when the request matches `code_wrap`, so if the field said
/// `Glyph` while the buffer was still on cosmic-text's inherited
/// `WordOrGlyph`, the first `wrap: true` message would be a no-op
/// and the document would keep **word** wrapping — the exact
/// divergence from the grid renderer that framing Q#LL5 exists to
/// close, surviving invisibly.
///
/// Wrap-versus-truncate cannot see it, because both modes differ
/// from each other either way. Word-versus-character can: with
/// character wrap, spaces are just glyphs, so a spaced line and an
/// unspaced line of the same length occupy the same number of rows.
/// Word wrap breaks early at the spaces and needs more.
#[test]
fn a_fresh_frontend_wraps_by_character_not_by_word() {
let Some(state) = headless_or_skip(320, 400, "hello world\n") else {
return;
};
assert_eq!(
state.buffer.wrap(),
Wrap::Glyph,
"a frontend told nothing must already be in the DEFAULT mode \
and wrapping by CHARACTER. Inheriting cosmic-text's \
WordOrGlyph would word-wrap the document forever against a \
pre-v22 daemon, diverging from the grid renderer"
);
}
/// The discriminating witness framing §7 asked for.
///
/// `wrapped_caret_survives_size_changes` passes today against a wrap
/// nobody configured — cosmic-text's constructor default — so it
/// cannot tell "honors the setting" from "the default happened to
/// match". The other value is what discriminates: with wrap OFF, an
/// overlong line must NOT occupy a second row.
#[test]
fn the_gpu_honors_an_explicit_non_wrap_mode() {
let long = "x".repeat(400);
let Some(mut state) = headless_or_skip(320, 400, &format!("{long}\nsecond\n")) else {
return;
};
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.apply_line_wrap(bid, true);
state.reshape();
let wrapped_rows = state.buffer.layout_runs().count();
state.apply_line_wrap(bid, false);
state.reshape();
let truncated_rows = state.buffer.layout_runs().count();
assert!(
wrapped_rows > truncated_rows,
"wrap must produce more visual rows than truncate \
(wrapped={wrapped_rows}, truncated={truncated_rows}); equal counts \
would mean the mode reached nothing"
);
}
/// The scroll indicator token: the last builtin in the right-hand
/// status runs, which are joined by a two-space separator.
fn scroll_readout(state: &mut State) -> String {
let runs = state.compose_status_runs();
let text: String = runs.iter().map(|(text, _)| text.as_str()).collect();
text.rsplit(" ").next().unwrap_or_default().to_owned()
}
/// Put `state` in a wrapped, scrolled-to-top state over `text`.
fn wrapped_at_top(state: &mut State, wrap: bool) {
let bid = BufferId::next();
state.current_buffer_id = Some(bid);
state.apply_line_wrap(bid, wrap);
state.scroll_top = 0;
state.code_scroll_residual = 0.0;
state.reshape();
}
/// The defect the shared classifier exists for, in its purest form:
/// **one** source line, wrapping to far more rows than fit.
///
/// `format_scroll_indicator` returns "All" on its very first branch
/// (`total_lines <= 1`) without consulting anything else — so the
/// mode line claimed the whole buffer was on screen while most of it
/// sat below the window. The truncate control below shows "All" is
/// the right answer *in line space*; it is the space that is wrong.
#[test]
fn a_wrapped_single_line_is_not_all() {
let long = "x".repeat(4000);
let Some(mut state) = headless_or_skip(320, 240, &long) else {
return;
};
wrapped_at_top(&mut state, true);
assert_eq!(
scroll_readout(&mut state),
"Top",
"one source line wrapping past the window bottom: the first \
row is on screen and the last is not"
);
wrapped_at_top(&mut state, false);
assert_eq!(
scroll_readout(&mut state),
"All",
"control: truncating, this really is one row and all of it \
is on screen the line formula is right when it applies"
);
}
/// `Bot` must mean the last byte is painted, not that the shaped
/// slice happens to reach EOF.
///
/// This is the discriminating case for [`State::code_byte_painted`]
/// over the cheaper `view_range.1 == len`: few source lines, each
/// wrapping to many rows, so `SCROLL_OVERSCAN` pulls EOF into the
/// slice while EOF is nowhere near the screen.
#[test]
fn a_slice_that_reaches_eof_is_not_yet_bot() {
let long = "y".repeat(1200);
let text = format!("{long}\n{long}\n{long}\n");
let Some(mut state) = headless_or_skip(320, 240, &text) else {
return;
};
wrapped_at_top(&mut state, true);
assert_eq!(
state.view_range.1,
state.current_text.len() as u64,
"precondition: the shaped slice does reach EOF — otherwise \
this test would pass for the wrong reason"
);
assert_eq!(
scroll_readout(&mut state),
"Top",
"EOF is shaped but painted far below the band"
);
while state.scroll_by_lines(1).is_some() {}
assert_eq!(
scroll_readout(&mut state),
"Bot",
"scrolled to the last source line, EOF is now painted"
);
}
/// A file ending in a newline has a final **empty** line, and a
/// viewport parked on it has `view_range == (len, len)`.
///
/// Named separately because the first version of
/// [`State::code_byte_painted`] rejected an empty range outright —
/// borrowed from the caret path, where it means "nothing shaped".
/// Here it means "one empty row", and rejecting it reported a
/// percentage at the precise moment the user reached the bottom.
#[test]
fn an_empty_final_line_still_counts_as_bot() {
let Some(mut state) = headless_or_skip(320, 240, "alpha\nbeta\n") else {
return;
};
wrapped_at_top(&mut state, true);
while state.scroll_by_lines(1).is_some() {}
assert_eq!(
state.view_range.0, state.view_range.1,
"precondition: parked on the trailing empty line"
);
assert_eq!(scroll_readout(&mut state), "Bot");
}
/// Scrolling *within* a wrapped first line moves the first byte off
/// screen, and the readout has to notice.
///
/// `scroll_top == 0` is still true here — which is why the cheap
/// predicate would keep saying `Top` while row one of the document
/// has scrolled away under the top edge.
#[test]
fn a_sub_line_residual_moves_off_top() {
let long = "z".repeat(4000);
let Some(mut state) = headless_or_skip(320, 240, &long) else {
return;
};
wrapped_at_top(&mut state, true);
assert_eq!(scroll_readout(&mut state), "Top");
// Past several wrapped rows, still inside source line 0.
state.code_scroll_residual = state.fm.code_line_height() * 4.0;
state.reshape();
assert_eq!(state.scroll_top, 0, "still the same source line");
assert_ne!(
scroll_readout(&mut state),
"Top",
"the document's first row is above the top edge, so `Top` \
would be a claim about a row that is not painted"
);
}
/// A mode for a buffer that is not on screen is not ours to apply.
/// The daemon resends on buffer switch precisely so this holds.
#[test]
fn a_wrap_message_for_another_buffer_is_ignored() {
let Some(mut state) = headless_or_skip(320, 200, "hello\n") else {
return;
};
let mine = BufferId::next();
let other = BufferId::next();
state.current_buffer_id = Some(mine);
state.apply_line_wrap(mine, true);
let after_mine = state.buffer.wrap();
state.apply_line_wrap(other, false);
assert_eq!(
state.buffer.wrap(),
after_mine,
"another buffer's mode must not reflow this one"
);
}
/// scroll invariant intact throughout.
#[test]
#[allow(clippy::float_cmp)] // exact: assigned constants, not computed sums

View File

@ -41,6 +41,7 @@ pub mod crdt;
pub mod ids;
pub mod message;
pub mod panel;
pub mod scroll;
pub mod terminal;
pub mod transport;
pub mod wire_grid;

View File

@ -1265,6 +1265,40 @@ pub enum InstanceMessage {
/// Appended after [`Self::InitialTargetResult`], the final v20
/// variant, so no existing postcard discriminant moves.
PanelFrame(crate::panel::PanelFramePayload),
/// Long lines (protocol v22): how the receiving frontend should show
/// a line wider than its viewport, for one buffer.
///
/// # Why a message exists at all
///
/// `ui.line-wrap` reaches the grid renderer through the viewport,
/// but `pmacs-gpu` is not a grid consumer — it ignores the
/// `CellDelta` family and lays out locally. Without this, setting
/// the mode would change the TUI and leave the GPU wrapping,
/// which is the cross-frontend disagreement the long-lines stage
/// exists to remove.
///
/// # Why it names a buffer
///
/// The setting is **buffer-local**, so "the current mode" is
/// meaningless without saying whose. It follows that the daemon
/// must resend on a **buffer switch** as well as on attach and on
/// config change: font size is global, but wrap mode is not, so
/// moving from a `Truncate` buffer to a `Wrap` one changes the
/// effective mode with no config event at all. A design that only
/// listens for config changes is silently wrong here and looks
/// correct in every single-buffer test.
///
/// A v21-or-older frontend never receives this and keeps its own
/// behavior; that divergence is documented rather than silent.
///
/// Appended after [`Self::PanelFrame`], the final v21 variant, so no
/// existing postcard discriminant moves. Daemon-gated `>= 22`.
LineWrapFacts {
/// Buffer the mode applies to.
buffer_id: crate::BufferId,
/// Whether that buffer's long lines wrap.
wrap: bool,
},
}
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
@ -1697,7 +1731,7 @@ pub enum ResourceBody {
/// directions: a v20 peer neither receives `PanelFrame` nor is placed in
/// a side window, because denying only the events would leave its
/// window invisible.
pub const PROTOCOL_VERSION: u32 = 21;
pub const PROTOCOL_VERSION: u32 = 22;
/// Protocol version placed in the daemon's server-first [`Hello`].
///
@ -1865,8 +1899,15 @@ pub fn negotiated_session_version(frontend_offer: u32) -> u32 {
/// [`Hello`]. The later capability-activation slice owns moving production
/// negotiation to v21 without making existing v20 frontends reject the
/// handshake.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] =
&[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21];
///
/// Long lines (framing Q#LL7): extended to `[6, ..., 22]` for
/// [`InstanceMessage::LineWrapFacts`]. Additive and daemon-gated, so
/// [`ADVERTISED_PROTOCOL_VERSION`] does not move — a v21 frontend
/// negotiates v21, never receives the variant, and keeps its own
/// behavior.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[
6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

View File

@ -0,0 +1,218 @@
//! Scroll-position classification, shared by both frontends.
//!
//! # Why this lives in the protocol crate
//!
//! It is not a wire type, and it is deliberately not presentation
//! either. The split it encodes (long-lines framing §5d.6,
//! `COHERENCE.md` §16) is:
//!
//! - **each frontend computes its own local layout facts** — whether
//! the buffer's first or last row is on screen is a question only the
//! frontend that laid the text out can answer;
//! - **the shared crate owns the semantic decision those facts feed** —
//! what `All` / `Top` / `Bot` / `NN%` *mean* is one rule, not two.
//!
//! Rendering the outcome to a string stays in each frontend.
//!
//! `pmacs-gpu` depends on `pmacs-protocol` and never on the `pmacs`
//! lib, so before this module the status readout was **duplicated
//! structurally**: `format_scroll_indicator` exists once in
//! `src/editor.rs` and again in `pmacs-gpu/src/main.rs`, each with its
//! own tests. That is not a tidiness complaint — it produced a real
//! defect during this lane's own review, where a fix landed in one copy
//! and the other kept reporting `All` for a wrapped one-line buffer.
//! With one classifier, a frontend *cannot* classify differently.
//!
//! Adding this needs **no wire message and no protocol-version bump**:
//! [`classify`] is a pure function over values each side already holds.
//!
//! # Why the arguments are booleans and bytes
//!
//! The pre-existing formatter took four counts
//! (`view_top, visible, total_lines, cursor_row`) and derived every
//! branch from `total_lines`. Under line wrapping there is no row total
//! to give it: the GPU shapes only its viewport slice, so it cannot
//! count rows it never laid out, and computing a total by arithmetic
//! would disagree with the break points cosmic-text actually chose.
//!
//! Handing that signature byte counts instead would make
//! `view_top + visible >= total_lines` compare **rows against bytes** —
//! plausible strings, meaningless arithmetic. So the mixing is not
//! merely avoided here, it is **unrepresentable**: the caller supplies
//! two decided predicates and a byte pair, and no count of rows enters
//! this module at all.
/// Where the viewport sits in its buffer.
///
/// `Percent` carries whole percent in `0..=100`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScrollPosition {
/// Every row of the buffer is on screen.
All,
/// The first row is on screen and the last is not.
Top,
/// The last row is on screen and the first is not.
Bot,
/// Neither end is on screen; whole percent through the buffer.
Percent(u8),
}
/// Classify the viewport's position from local facts.
///
/// `first_visible` / `last_visible` are the frontend's own answers
/// about its current layout. `byte_pos` is the cursor's byte offset and
/// `byte_len` the buffer's length in bytes; the percentage is taken
/// from those rather than from a row ordinal, because no row total
/// exists (see the module docs).
///
/// An empty buffer (`byte_len == 0`) cannot have a meaningful
/// percentage, and division would trap. It only ever reaches the
/// `Percent` arm if the caller claims neither end is on screen, which
/// is already contradictory for an empty buffer — so that combination
/// yields `All`, matching what the caller's own facts would have said.
#[must_use]
pub fn classify(
first_visible: bool,
last_visible: bool,
byte_pos: u64,
byte_len: u64,
) -> ScrollPosition {
match (first_visible, last_visible) {
(true, true) => ScrollPosition::All,
(true, false) => ScrollPosition::Top,
(false, true) => ScrollPosition::Bot,
(false, false) => {
if byte_len == 0 {
return ScrollPosition::All;
}
// Widen to u128 before scaling. `saturating_mul` was wrong
// here, not merely inelegant: it *silently undercounts*.
// `u64::MAX * 100` saturates to `u64::MAX`, so a cursor at
// the end of a maximal buffer divided out to 1% — a wrong
// answer that looked safe because it stayed in range.
//
// `u64::MAX * 100` fits in u128 with room to spare, so the
// product is exact and the only clamp left is the genuine
// one below.
let pct = u128::from(byte_pos) * 100 / u128::from(byte_len);
// Clamped for a caller that reports a cursor past the end
// (a stale readout mid-edit): 100%, never above.
ScrollPosition::Percent(u8::try_from(pct.min(100)).unwrap_or(100))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The four outcomes are decided by the two predicates alone.
#[test]
fn the_two_predicates_decide_the_three_named_states() {
assert_eq!(classify(true, true, 0, 100), ScrollPosition::All);
assert_eq!(classify(true, false, 0, 100), ScrollPosition::Top);
assert_eq!(classify(false, true, 99, 100), ScrollPosition::Bot);
assert!(matches!(
classify(false, false, 50, 100),
ScrollPosition::Percent(_)
));
}
/// The case the whole of framing §5d exists for.
///
/// A one-source-line buffer that wraps to more rows than fit must
/// report `Top`, not `All`. The pre-existing formatter returned
/// `All` here unconditionally — its first branch was
/// `if total_lines <= 1 { return "All" }`, and a wrapped single line
/// still has `total_lines == 1`.
///
/// This classifier cannot reproduce that bug, because it is never
/// told how many lines there are.
#[test]
fn a_wrapped_single_line_is_top_not_all() {
// One line, first row on screen, last row far below.
assert_eq!(classify(true, false, 0, 4_000), ScrollPosition::Top);
}
/// Percent comes from bytes, and only when neither end shows.
#[test]
fn percent_is_byte_based_and_only_in_the_middle() {
assert_eq!(
classify(false, false, 250, 1_000),
ScrollPosition::Percent(25)
);
// Even at a byte position that would read as "the end", an
// explicit `last_visible` wins — the predicate is the fact, the
// percentage is only a readout.
assert_eq!(classify(false, true, 1_000, 1_000), ScrollPosition::Bot);
}
/// Degenerate inputs stay total: no panic, no wrap, no divide by zero.
#[test]
fn degenerate_inputs_are_total() {
assert_eq!(classify(false, false, 0, 0), ScrollPosition::All);
assert_eq!(
classify(false, false, 10, 5),
ScrollPosition::Percent(100),
"a cursor past the end clamps rather than exceeding 100"
);
assert_eq!(
classify(false, false, u64::MAX, 1),
ScrollPosition::Percent(100),
"saturating multiply, so a huge offset cannot wrap into a small percent"
);
}
/// Percent never leaves `0..=100`, for any input.
///
/// **In range is not the same as correct**, which is why
/// [`large_byte_counts_stay_accurate`] exists beside this. This
/// sweep passed against a `saturating_mul` that silently reported
/// 1% for a cursor at the end of a maximal buffer — a wrong answer
/// that satisfies every assertion here.
#[test]
fn percent_is_always_in_range() {
for pos in [0_u64, 1, 7, 99, 100, 1_000, u64::MAX / 2, u64::MAX] {
for len in [1_u64, 3, 100, 9_999, u64::MAX] {
if let ScrollPosition::Percent(p) = classify(false, false, pos, len) {
assert!(p <= 100, "pos={pos} len={len} gave {p}%");
}
}
}
}
/// The percentage stays *accurate* where `u64` arithmetic would
/// overflow, not merely bounded.
///
/// `byte_pos * 100` exceeds `u64::MAX` for any position above
/// `u64::MAX / 100`. Saturating there collapses the numerator to a
/// constant, so the quotient stops tracking the position at all:
/// `u64::MAX / u64::MAX` is 1, and the readout said **1%** at the
/// very end of the buffer.
#[test]
fn large_byte_counts_stay_accurate() {
assert_eq!(
classify(false, false, u64::MAX, u64::MAX),
ScrollPosition::Percent(100),
"the end of a maximal buffer is 100%, not 1%"
);
assert_eq!(
classify(false, false, u64::MAX / 2, u64::MAX),
ScrollPosition::Percent(49),
"halfway through a maximal buffer, floored"
);
assert_eq!(
classify(false, false, u64::MAX / 4, u64::MAX),
ScrollPosition::Percent(24),
"a quarter through, floored"
);
// The smallest position whose scaling overflows u64 — the first
// input the old implementation got wrong.
let first_overflowing = u64::MAX / 100 + 1;
assert_eq!(
classify(false, false, first_overflowing, u64::MAX),
ScrollPosition::Percent(1),
"correct by arithmetic here, not by saturation"
);
}
}

View File

@ -1187,6 +1187,16 @@ mod tests {
src(2),
)
.unwrap();
// The names here are ILLUSTRATIVE — one per `ConfigKind`, chosen
// to read plausibly. Only `editing.auto-pair` and
// `autosave.interval-ms` are real settings (`builtin/runtime/`);
// the other three are defined nowhere but this test.
//
// Worth saying out loud because `editing.fill-column` fooled the
// long-lines framing into three paragraphs about an "orphaned
// setting of the same shape as the `full_grid` defect". It is not
// orphaned and not a defect: it has never shipped, so no user can
// get it, set it, or discover it.
r.define(
"editing.fill-column".into(),
"Preferred wrap column.".into(),

View File

@ -1493,6 +1493,14 @@ fn dispatcher_loop(
if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) {
continue;
}
// Long lines — LineWrapFacts gated at v22. A v21 peer
// keeps whatever it does today; the semantic producer
// also skips it, so this is the belt-and-braces half.
if negotiated_protocol_version < 22
&& matches!(msg, InstanceMessage::LineWrapFacts { .. })
{
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

View File

@ -757,6 +757,7 @@ fn underline_cols_for_line(line_bytes: &[u8], byte_start: u32, byte_end: u32) ->
#[cfg(test)]
mod tests {
use super::*;
use crate::view::WrapMode;
use serde_json::json;
fn diag(line: u32, sev: DiagnosticSeverity, msg: &str) -> Diagnostic {
@ -1139,6 +1140,7 @@ mod tests {
cell_size: CellSize::new(1, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);
@ -1204,6 +1206,7 @@ mod tests {
cell_size: CellSize::new(3, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);
@ -1280,6 +1283,7 @@ mod tests {
cell_size: CellSize::new(3, 8),
gutter_w: 2,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);
@ -1349,6 +1353,7 @@ mod tests {
cell_size: CellSize::new(2, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);

View File

@ -763,6 +763,12 @@ impl EditorState {
include_str!("../builtin/runtime/zoom.lua"),
)
.expect("load zoom builtin chunk");
lua_host
.eval(
Some("@pmacs/builtin/runtime/linewrap.lua"),
include_str!("../builtin/runtime/linewrap.lua"),
)
.expect("load linewrap builtin chunk");
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
// was loaded directly via `eval(include_str!(...))`; the
// M7.11 deliverable migrates it to the package system so it
@ -2351,6 +2357,7 @@ impl EditorState {
None
};
window.last_visible_rows = content.size.rows;
window.last_content_cols = content.size.cols;
// A2A-3 / parent 48: the auto-scroll clamp belongs to the
// FOCUSED window only. Running it for a passive panel would
// move a `view_top` the user is not driving.
@ -3603,7 +3610,9 @@ impl EditorState {
let Ok(buf) = reg.get(buffer_id) else {
return;
};
core.windows[&win_id].text_view.display_to_pos(buf, target)
core.windows[&win_id]
.text_view
.display_to_pos(buf, target, core.layout_ctx(win_id))
};
if let Some(p) = pos {
let aw = core
@ -3662,7 +3671,9 @@ impl EditorState {
let reg = registry.borrow();
reg.get(buffer_id).ok().and_then(|buf| {
let aw = &core.windows[&win_id];
let cur = aw.text_view.pos_to_display(buf, aw.cursor)?;
let cur = aw
.text_view
.pos_to_display(buf, aw.cursor, aw.layout_ctx())?;
let cur_row = cur.row as usize;
let target_row_usize = match folds.as_ref() {
Some(map) if scroll_up => map.nth_visible_back(cur_row, view_shift),
@ -3674,7 +3685,11 @@ impl EditorState {
};
let target_row = u32::try_from(target_row_usize).ok()?;
aw.text_view
.display_to_pos(buf, crate::view::DisplayCoord::new(target_row, cur.col))
.display_to_pos(
buf,
crate::view::DisplayCoord::new(target_row, cur.col),
aw.layout_ctx(),
)
.or_else(|| aw.text_view.line_offset(target_row_usize))
})
};
@ -4270,7 +4285,7 @@ fn prepare_window_cursor_visible(
) {
let cursor_row = window
.text_view
.pos_to_display(buf, window.cursor)
.pos_to_display(buf, window.cursor, window.layout_ctx())
.map_or(0, |d| d.row as usize);
match folds {
// The logical cursor may sit on a hidden line (a shared fold, or
@ -4348,12 +4363,32 @@ fn paint_window_content(
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
gutter_w,
folds,
// The resolved mode belongs here beside `folds`, for the same
// reason: the driver holds the registry and the buffer, the view
// holds neither.
//
// It reads `last_wrap` rather than resolving again. The render
// loop already resolved it for this window this frame, and the
// coordinate callers read that same field through
// `Window::layout_ctx` — so a second resolution here could
// disagree with the one the cursor is placed against, which is
// the failure this whole one-resolution arrangement exists to
// prevent.
wrap: window.last_wrap,
};
// Composition (T M2.9): base text_view paints first, then the
// gutter numbers — before the overlays, so a diagnostic overlay
// can draw its severity sign into the gutter's leading column
// without the gutter's own blank pass erasing it — then each
// overlay in attach order. See [`crate::view::View`].
// Record the width text actually wrapped at, taken from the
// viewport itself rather than recomputed: a second derivation could
// disagree with the one the renderer used, and the disagreement
// would only show as a cursor on the wrong row.
// Width only: `last_wrap` was resolved before the viewport was
// built and is what the viewport was built FROM, so writing it back
// here would be circular.
window.last_content_cols = viewport.cell_size.cols;
window.text_view.render(buf, viewport, grid);
if gutter_w > 0 {
paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w, folds, theme);
@ -4366,7 +4401,7 @@ fn paint_window_content(
// itself is always visible regardless of overlay activity.
let coord = window
.text_view
.pos_to_display(buf, window.cursor)
.pos_to_display(buf, window.cursor, window.layout_ctx())
.unwrap_or_default();
// Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in
// VISIBLE-line space — a buffer whose remainder is collapsed
@ -4384,7 +4419,30 @@ fn paint_window_content(
coord.row as usize,
),
};
let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor);
let scroll = if window.last_wrap == crate::view::WrapMode::Wrap {
// Under wrapping the line-space formatter is not merely
// imprecise, it is wrong: a one-line buffer wrapping to fifty
// rows has `total_lines == 1`, so its first branch reports
// "All" while forty-nine rows sit below the viewport.
//
// There is no row total to give it instead. The GPU shapes only
// its viewport slice, so it cannot count rows it never laid out,
// and computing a total arithmetically would disagree with the
// break points actually rendered. So `All`/`Top`/`Bot` come from
// LOCAL predicates — which the render walk already knows — and
// the percentage comes from byte position. Both frontends use
// the same rule, from `pmacs_protocol::scroll`.
let first_visible = viewport_buffer_start == 0;
let last_visible = window.text_view.reached_buffer_end();
render_scroll_position(pmacs_protocol::scroll::classify(
first_visible,
last_visible,
window.cursor,
buf.len(),
))
} else {
format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor)
};
// Lock scoped to the summary computation only: the overlay
// renders above include `DiagnosticView`, which takes this
// same mutex — holding the guard across the loop deadlocked
@ -4550,6 +4608,13 @@ pub fn paint_frame(
// Record viewport height for page motion (cursor.page-down /
// cursor.page-up consume this).
window.last_visible_rows = inner_rows;
// Resolve the buffer's wrap mode once per window per frame and
// record it here. Every later consumer — the viewport below,
// and the coordinate callers via `Window::layout_ctx` — reads
// this one answer, so nothing re-resolves and two callers
// cannot disagree about one buffer.
window.last_wrap =
crate::lua_bindings::config_line_wrap(state.lua_host.lua(), Some(window.buffer_id));
if inner_rows == 0 || rect.size.cols == 0 {
continue;
}
@ -4676,7 +4741,9 @@ fn window_cursor_cell(
),
None => window.cursor,
};
let disp = window.text_view.pos_to_display(buf, cursor)?;
let disp = window
.text_view
.pos_to_display(buf, cursor, window.layout_ctx())?;
let row_offset = match folds {
Some(map) => {
let top = map.clamp_view_top(window.view_top);
@ -5003,10 +5070,17 @@ fn paint_local_selection(
continue;
}
let Some(start_coord) = window.text_view.pos_to_display(buf, paint_start) else {
let Some(start_coord) =
window
.text_view
.pos_to_display(buf, paint_start, window.layout_ctx())
else {
continue;
};
let Some(end_coord) = window.text_view.pos_to_display(buf, paint_end) else {
let Some(end_coord) = window
.text_view
.pos_to_display(buf, paint_end, window.layout_ctx())
else {
continue;
};
if start_coord.row as usize != display_row || end_coord.row as usize != display_row {
@ -5541,6 +5615,23 @@ fn first_line(s: &str) -> &str {
/// `visible` may be 0 in tests that never rendered (so
/// `last_visible_rows` was never populated); in that case we fall
/// back to cursor-row-based percent without the All/Top/Bot caps.
/// Render a [`pmacs_protocol::scroll::ScrollPosition`] for the status
/// line.
///
/// The classification is shared with the GPU frontend; only this
/// rendering is per-frontend, which is the split framing §5d.6 settled:
/// each frontend answers its own layout questions, the shared crate owns
/// the decision they feed.
fn render_scroll_position(pos: pmacs_protocol::scroll::ScrollPosition) -> String {
use pmacs_protocol::scroll::ScrollPosition;
match pos {
ScrollPosition::All => "All".to_owned(),
ScrollPosition::Top => "Top".to_owned(),
ScrollPosition::Bot => "Bot".to_owned(),
ScrollPosition::Percent(p) => format!("{p}%"),
}
}
fn format_scroll_indicator(
view_top: usize,
visible: usize,
@ -5653,6 +5744,7 @@ fn printable_char(seq: &[Chord]) -> Option<char> {
#[cfg(test)]
mod tests {
use crate::view::WrapMode;
// Acceptance home for T M5.4 (FrontendId on input events) — the
// `m5_4_*`-prefixed tests verify the FrontendId field threads from
// synthetic event construction through `dispatch_key` /
@ -8450,6 +8542,7 @@ mod tests {
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let mut grid = CellGrid {
cells: &mut backing,
@ -8585,6 +8678,7 @@ mod tests {
cell_size: CellSize::new(24, 80),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
// Two no-op overlays: probe the dispatch cost only.

View File

@ -835,6 +835,33 @@ impl EditorCore {
crate::fold_view::map_for_window(&self.fold_registry, window)
}
/// The layout facts a coordinate mapping needs for `win_id`.
///
/// **One resolver, so every consumer flips together.** The wrap mode
/// is buffer-local and the registry has no ambient buffer, so the
/// resolution belongs somewhere that holds both — here — rather than
/// at each of the twenty call sites. When `ui.line-wrap` is
/// registered, only this function changes and every caller becomes
/// wrap-aware at once.
///
/// Width comes from the last render (`Window::last_content_cols`);
/// `0` until the first frame lands, which
/// [`LayoutCtx::wrapping`](crate::view::LayoutCtx::wrapping) already
/// treats as unwrapped.
#[must_use]
pub fn layout_ctx(&self, win_id: WindowId) -> crate::view::LayoutCtx {
self.windows.get(&win_id).map_or_else(
crate::view::LayoutCtx::truncated,
crate::window::Window::layout_ctx,
)
}
/// [`Self::layout_ctx`] for the active window.
#[must_use]
pub fn layout_ctx_active(&self) -> crate::view::LayoutCtx {
self.layout_ctx(self.active_window_id())
}
/// [`Self::fold_map_for_window`] for the active window — the target
/// of motion, paging, and the auto-scroll clamp.
#[must_use]
@ -2089,6 +2116,7 @@ impl EditorCore {
self.normalize_cursor_to_visible(folds.as_ref());
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let ctx = self.layout_ctx_active();
let goal_col = self.active_window().goal_col;
let result = {
let reg = self.registry.borrow();
@ -2096,7 +2124,7 @@ impl EditorCore {
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.pos_to_display(buffer, cursor, ctx)
.unwrap_or_default();
let from_row = coord.row as usize;
if from_row == 0 {
@ -2110,7 +2138,7 @@ impl EditorCore {
return;
};
let target = DisplayCoord::new(target_row, goal);
let new_pos = aw.text_view.display_to_pos(buffer, target);
let new_pos = aw.text_view.display_to_pos(buffer, target, ctx);
(goal, new_pos)
};
let (goal, new_pos) = result;
@ -2128,6 +2156,7 @@ impl EditorCore {
self.normalize_cursor_to_visible(folds.as_ref());
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let ctx = self.layout_ctx_active();
let goal_col = self.active_window().goal_col;
let result = {
let reg = self.registry.borrow();
@ -2135,7 +2164,7 @@ impl EditorCore {
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.pos_to_display(buffer, cursor, ctx)
.unwrap_or_default();
let from_row = coord.row as usize;
let next_row = folds
@ -2149,7 +2178,7 @@ impl EditorCore {
return;
};
let target = DisplayCoord::new(next_row, goal);
let new_pos = aw.text_view.display_to_pos(buffer, target);
let new_pos = aw.text_view.display_to_pos(buffer, target, ctx);
(goal, new_pos)
};
let (goal, new_pos) = result;
@ -2306,6 +2335,7 @@ impl EditorCore {
self.normalize_cursor_to_visible(folds.as_ref());
let step = self.page_step();
let cursor = self.active_window().cursor;
let ctx = self.layout_ctx_active();
let view_top = self.active_window().view_top;
let id = self.active_buffer_id();
let result = {
@ -2314,7 +2344,7 @@ impl EditorCore {
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.pos_to_display(buffer, cursor, ctx)
.unwrap_or_default();
let max_line = aw.text_view.line_count().saturating_sub(1);
let goal_col = aw.goal_col.unwrap_or(coord.col);
@ -2333,7 +2363,7 @@ impl EditorCore {
return;
};
let target = DisplayCoord::new(target_row, goal_col);
let new_pos = aw.text_view.display_to_pos(buffer, target);
let new_pos = aw.text_view.display_to_pos(buffer, target, ctx);
(goal_col, new_pos, new_top)
};
let (goal, new_pos, new_top) = result;
@ -2358,6 +2388,7 @@ impl EditorCore {
self.normalize_cursor_to_visible(folds.as_ref());
let step = self.page_step();
let cursor = self.active_window().cursor;
let ctx = self.layout_ctx_active();
let view_top = self.active_window().view_top;
let id = self.active_buffer_id();
let result = {
@ -2366,7 +2397,7 @@ impl EditorCore {
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.pos_to_display(buffer, cursor, ctx)
.unwrap_or_default();
let goal_col = aw.goal_col.unwrap_or(coord.col);
let (target_row, new_top) = match folds.as_ref() {
@ -2383,7 +2414,7 @@ impl EditorCore {
return;
};
let target = DisplayCoord::new(target_row, goal_col);
let new_pos = aw.text_view.display_to_pos(buffer, target);
let new_pos = aw.text_view.display_to_pos(buffer, target, ctx);
(goal_col, new_pos, new_top)
};
let (goal, new_pos, new_top) = result;

View File

@ -442,6 +442,11 @@ impl Frontend {
// grid and negotiates no panel capability, so this cannot
// legitimately reach here.
| InstanceMessage::PanelFrame(_)
// Long lines: the grid TUI learns its wrap mode through the
// viewport the daemon already resolved for it, so this
// message is for semantic frontends that lay out locally
// and would otherwise never hear the setting at all.
| InstanceMessage::LineWrapFacts { .. }
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path

View File

@ -773,6 +773,7 @@ impl View for LspStyleView {
#[cfg(test)]
mod tests {
use super::*;
use crate::view::WrapMode;
#[test]
fn theme_lookup_walks_dotted_prefixes() {
@ -1111,6 +1112,7 @@ mod tests {
cell_size: CellSize::new(1, 20),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
@ -1209,6 +1211,7 @@ mod tests {
cell_size: CellSize::new(1, 20),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
@ -1329,6 +1332,7 @@ mod tests {
cell_size: CellSize::new(1, 20),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
@ -1389,6 +1393,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = buf; // keep buf alive
hv.render(&registry, viewport, &mut grid);
@ -1446,6 +1451,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = buf; // keep buf alive
hv.render(&registry, viewport, &mut grid);
@ -1511,6 +1517,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = buf;
hv.render(&registry, viewport, &mut grid);
@ -1578,6 +1585,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
hv.render(&buf, viewport, &mut grid);
grid.get(CellCoord::new(0, col)).style
@ -1820,6 +1828,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = buf;
hv.render(&registry, viewport, &mut grid);
@ -1883,6 +1892,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = buf;
hv.render(&registry, viewport, &mut grid);

View File

@ -887,6 +887,12 @@ mod tests {
// ---- acceptance 1: round-trip every kind, via Lua ----------------------
/// The names below are ILLUSTRATIVE — one per `ConfigKind`, chosen
/// to read plausibly. Only `editing.auto-pair` and
/// `autosave.interval-ms` are real settings (`builtin/runtime/`); the
/// other three are defined nowhere but this test and its twin in
/// `config_registry.rs`. `editing.fill-column` in particular has
/// never shipped, so no user can get it, set it, or discover it.
#[test]
fn define_then_get_round_trips_every_kind_via_lua() {
let (lua, _reg) = fresh();

View File

@ -672,6 +672,31 @@ pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option<BufferId>, fallback:
}
}
/// Resolve `ui.line-wrap` for `buffer_id`.
///
/// The registry has no ambient current buffer by design, so the caller
/// names the buffer; passing `None` reads the global layer.
///
/// Falls back to [`WrapMode::Wrap`] rather than `Truncate`, and the
/// direction matters: the fallback covers a bare core whose runtime
/// never loaded the builtin, and it must agree with the setting's own
/// default or a test-constructed editor would silently render in the
/// mode nobody chose — which is the defect this whole stage exists to
/// remove.
#[must_use]
pub fn config_line_wrap(lua: &Lua, buffer_id: Option<BufferId>) -> crate::view::WrapMode {
let Some(registry) = lua.app_data_ref::<config::SharedConfigRegistry>() else {
return crate::view::WrapMode::Wrap;
};
let borrowed = registry.borrow();
match borrowed.get("ui.line-wrap", buffer_id) {
Ok(crate::config_registry::ConfigValue::Str(v)) if v == "truncate" => {
crate::view::WrapMode::Truncate
}
_ => crate::view::WrapMode::Wrap,
}
}
/// Read a `String` setting plus the registry epoch that keys any cache
/// built from it (Q#TC4c).
///

View File

@ -483,6 +483,7 @@ mod tests {
use crate::cell::{Cell, CellSize, Glyph, UnderlineStyle};
use crate::text_view::TextView;
use crate::view::Viewport;
use crate::view::WrapMode;
fn make_grid(rows: u32, cols: u32) -> Vec<Cell> {
vec![Cell::default(); (rows * cols) as usize]
@ -496,6 +497,7 @@ mod tests {
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
}
}

View File

@ -166,7 +166,10 @@ pub fn paint_other_frontend_overlays(
),
None => presence.snapshot.cursor,
};
let Some(disp) = window.text_view.pos_to_display(buf, peer_cursor) else {
let Some(disp) = window
.text_view
.pos_to_display(buf, peer_cursor, window.layout_ctx())
else {
continue;
};
// Filter to viewport visible range. `view_top` is the
@ -309,7 +312,10 @@ fn paint_selection_in_window(
// straightforward walk is fine.
let mut pos = lo;
while pos < hi {
let Some(disp) = window.text_view.pos_to_display(buf, pos) else {
let Some(disp) = window
.text_view
.pos_to_display(buf, pos, window.layout_ctx())
else {
break;
};
let row_in_window = match folds {

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_twenty_one_for_the_bottom_panel_band() {
fn protocol_version_is_twenty_two_for_line_wrap_facts() {
// 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
@ -1728,7 +1728,11 @@ mod tests {
// bump that gates in BOTH directions; all four appended after
// their enum's final v20 variant, see the placement pins in
// `bottom_panel_stage2b_protocol_acceptance`).
assert_eq!(PROTOCOL_VERSION, 21);
// Long lines bumps 21→22 (`InstanceMessage::LineWrapFacts`,
// daemon-gated, appended after the final v21 variant). The
// GPU lays out locally and would otherwise never hear the wrap
// setting; the advertised baseline is deliberately unmoved.
assert_eq!(PROTOCOL_VERSION, 22);
}
#[test]
@ -1804,18 +1808,18 @@ mod tests {
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
// v20 (semantic initial-target bootstrap), and v21 (the bottom
// panel band) all interoperate.
for accepted in 6..=21 {
// v20 (semantic initial-target bootstrap), v21 (the bottom
// panel band), and v22 (`LineWrapFacts`) all interoperate.
for accepted in 6..=22 {
assert!(
is_supported_protocol_version(accepted),
"v{accepted} must be accepted"
);
}
for rejected in [0, 1, 2, 3, 4, 5, 22, u32::MAX] {
for rejected in [0, 1, 2, 3, 4, 5, 23, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v21 binary"
"v{rejected} must be rejected by a v22 binary"
);
}
}

View File

@ -541,6 +541,7 @@ impl View for SearchView {
#[cfg(test)]
mod tests {
use super::*;
use crate::view::WrapMode;
fn r(start: u64, end: u64) -> ByteRange {
ByteRange { start, end }
@ -759,6 +760,7 @@ mod tests {
cell_size: CellSize::new(1, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);
@ -788,6 +790,7 @@ mod tests {
cell_size: CellSize::new(1, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid2,
);
@ -830,6 +833,7 @@ mod tests {
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);

View File

@ -279,6 +279,12 @@ pub struct SemanticRenderState {
/// font state, so this gate has no summary-style companion
/// filter.
peer_knows_font_facts: bool,
/// `LineWrapFacts` is v22; a v21 peer keeps its own behavior.
peer_knows_line_wrap: bool,
/// Last `(buffer, wrap)` pair sent. Keyed on the PAIR, not the
/// mode: that is what makes a BUFFER SWITCH re-emit without a
/// config event, which a value-keyed cache would miss entirely.
last_line_wrap: Option<(crate::buffer::BufferId, bool)>,
/// Whether the peer negotiated protocol >= 18 (Q#SL7). This gates
/// callback evaluation in the producer, independently of the daemon's
/// write-loop gate.
@ -471,6 +477,7 @@ impl SemanticRenderState {
let mut s = Self::new(frontend_id);
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
s.peer_knows_line_wrap = negotiated_protocol_version >= 22;
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
@ -517,6 +524,8 @@ impl SemanticRenderState {
last_font_epoch: None,
last_font_facts: None,
peer_knows_font_facts: true,
peer_knows_line_wrap: true,
last_line_wrap: None,
peer_knows_statusline_segments: true,
last_statusline: HashMap::new(),
diag_line_cache: HashMap::new(),
@ -952,6 +961,7 @@ impl SemanticRenderState {
// --- ThemeFacts (UI faces; themes arc Q#TH7, protocol v16) ---
out.extend(self.theme_facts_msg(state));
out.extend(self.font_facts_msg(state));
out.extend(self.line_wrap_msg(state, vp.buffer_id));
// Q#SL6/Q#SL8: face inventory must precede segment text.
// Parent acceptance 45: ONE provider invocation supplies both the
// primary-document wire segments and the panel mode line, so the
@ -1100,6 +1110,7 @@ impl SemanticRenderState {
out.extend(self.minibuffer_prompt_msg(state, buffer_id));
out.extend(self.theme_facts_msg(state));
out.extend(self.font_facts_msg(state));
out.extend(self.line_wrap_msg(state, buffer_id));
// Q#SL6/Q#SL8: face inventory must precede segment text.
// The band rides the terminal path too: a frontend whose DOCUMENT
// surface is a full-window terminal can still hold a side window,
@ -2036,6 +2047,38 @@ impl SemanticRenderState {
})
}
/// The wrap mode for the buffer this session is showing (v22).
///
/// `pmacs-gpu` lays out locally and ignores the grid family, so
/// without this it would never hear `ui.line-wrap` at all and would
/// keep wrapping while the TUI truncated — the cross-frontend
/// disagreement the long-lines stage exists to close.
///
/// Deduped on the `(buffer, wrap)` PAIR. That is not a
/// micro-optimisation: the mode is buffer-local, so switching from a
/// truncating buffer to a wrapping one changes the effective mode
/// with **no config event at all**. A cache keyed on the mode alone
/// would stay silent through exactly that transition — and would
/// look correct in every single-buffer test.
fn line_wrap_msg(
&mut self,
state: &EditorState,
buffer_id: crate::buffer::BufferId,
) -> Option<InstanceMessage> {
if !self.peer_knows_line_wrap {
return None;
}
let wrap = matches!(
crate::lua_bindings::config_line_wrap(state.lua_host.lua(), Some(buffer_id)),
crate::view::WrapMode::Wrap
);
if self.last_line_wrap == Some((buffer_id, wrap)) {
return None;
}
self.last_line_wrap = Some((buffer_id, wrap));
Some(InstanceMessage::LineWrapFacts { buffer_id, wrap })
}
/// Project the [`Decoration`] set intersecting the declared
/// viewport: the session's selection (instance-authoritative,
/// byte-native) and LSP diagnostics (line/col → byte, severity →
@ -3738,6 +3781,7 @@ mod tests {
| InstanceMessage::LineNumbers { .. }
| InstanceMessage::ThemeFacts { .. }
| InstanceMessage::FontFacts { .. }
| InstanceMessage::LineWrapFacts { .. }
| InstanceMessage::StatuslineSegments { .. }
),
"semantic projection emitted an unexpected variant: {m:?}"
@ -3911,13 +3955,17 @@ mod tests {
// StatusFacts (Q#S1, cached-compare), the authoritative
// ThemeFacts table (Q#TH7 — empty for an unthemed daemon), and
// the authoritative FontFacts preference (Q#F5 — all-default), and
// authoritative empty statusline segments (Q#SL8).
// authoritative empty statusline segments (Q#SL8), and the
// buffer's authoritative wrap mode (v22 — a semantic frontend
// lays out locally, so it has to be told on the first frame or
// it never learns the setting at all).
let first = s.render_frame(&state);
assert_eq!(
first.len(),
7,
8,
"first frame ships StyleSpans + Decorations + FileStyleSummary \
+ StatusFacts + ThemeFacts + FontFacts + StatuslineSegments"
+ StatusFacts + ThemeFacts + FontFacts + StatuslineSegments \
+ LineWrapFacts"
);
assert_semantic_only(&first);
let (style_full, _) = style_segments(&first).expect("StyleSpans present");
@ -4606,6 +4654,56 @@ mod tests {
}
}
/// A buffer switch re-emits the wrap mode, with no config event.
///
/// This is the trigger a `FontFacts`-shaped design misses. Font size
/// is global, so caching it by value is right; wrap mode is
/// **buffer-local**, so a value-keyed cache stays silent when the
/// user moves from one buffer to another with a different mode —
/// and looks perfectly correct in every single-buffer test.
///
/// Keying the cache on the `(buffer, wrap)` pair is what makes the
/// switch re-emit, so that is what this pins.
#[test]
fn a_buffer_switch_re_emits_the_wrap_mode() {
let state = empty_state();
let mut sem = local();
let first = active_buffer(&state);
let second = crate::buffer::BufferId::from_raw(first.raw() + 1);
let a = sem
.line_wrap_msg(&state, first)
.expect("the first buffer's mode is authoritative");
assert!(matches!(
a,
InstanceMessage::LineWrapFacts { buffer_id, .. } if buffer_id == first
));
assert!(
sem.line_wrap_msg(&state, first).is_none(),
"the same pair is suppressed"
);
let b = sem
.line_wrap_msg(&state, second)
.expect("a different buffer must be told, even at the same mode");
assert!(matches!(
b,
InstanceMessage::LineWrapFacts { buffer_id, .. } if buffer_id == second
));
}
/// A pre-v22 peer is never sent the variant.
#[test]
fn a_v21_peer_is_not_told_about_wrapping() {
let state = empty_state();
let mut sem = local();
sem.peer_knows_line_wrap = false;
assert!(
sem.line_wrap_msg(&state, active_buffer(&state)).is_none(),
"gated at v22; an older peer keeps its own behavior"
);
}
#[test]
fn sibling_of_render_state_reads_same_editor_state() {
// The dispatcher selects the projection per session, not per

File diff suppressed because it is too large Load Diff

View File

@ -108,18 +108,123 @@ impl InterceptContext {
/// and inline expansions appear.
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub struct DisplayCoord {
/// 0-based row.
/// 0-based **source line** index.
///
/// Deliberately still the source line under wrapping, not a visual
/// row. Redefining it would have broken every existing consumer —
/// `overlay_paint`'s `row - view_top`, vertical motion's bounds
/// check — with no compile error. Adding [`Self::sub_row`] beside it
/// instead leaves those consumers *correct*, not merely findable.
pub row: u32,
/// 0-based column.
/// Which visual row **within** `row`, when the line wraps.
///
/// `0` for every unwrapped line and under
/// [`WrapMode::Truncate`](crate::view::WrapMode::Truncate), which is
/// what makes this field additive: code that has never heard of
/// wrapping keeps computing the right answer.
pub sub_row: u32,
/// 0-based column, within the visual row named by `sub_row`.
pub col: u32,
}
impl DisplayCoord {
/// Construct a display coordinate.
/// Construct a display coordinate on a line's first visual row.
#[must_use]
pub const fn new(row: u32, col: u32) -> Self {
Self { row, col }
Self {
row,
sub_row: 0,
col,
}
}
/// Construct a display coordinate on a specific visual row of a
/// wrapped line.
#[must_use]
pub const fn wrapped(row: u32, sub_row: u32, col: u32) -> Self {
Self { row, sub_row, col }
}
}
/// The layout facts a coordinate mapping needs, which the mapping
/// itself cannot know.
///
/// # Why this is a required parameter
///
/// `pos_to_display` took `(&self, buf, pos)` and had no notion of the
/// grid at all — so under wrapping it could not compute a visual row,
/// and `display_to_pos` could not invert one. Passing the missing
/// facts as a required argument is deliberate: it makes the compiler
/// enumerate every call site rather than leaving an audit to grep.
///
/// That is the opposite choice from [`DisplayCoord::sub_row`], and for
/// the opposite reason. Enforcement is possible on the way in, so it is
/// taken; it is not possible on the way out, so the output is made
/// correct-by-default instead.
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub struct LayoutCtx {
/// Width in cells of the content area the text wraps within.
///
/// `0` means "not rendered yet" — the same convention
/// `Window::last_visible_rows` uses — and is treated as unwrapped,
/// since a viewport with no columns has no rows to distinguish.
pub cols: u32,
/// The window's resolved wrap mode.
pub wrap: WrapMode,
}
impl LayoutCtx {
/// The identity context: no wrapping, width irrelevant.
///
/// Every pre-wrap caller means this, and saying so explicitly is
/// what makes those call sites readable as decisions rather than
/// oversights.
#[must_use]
pub const fn truncated() -> Self {
Self {
cols: 0,
wrap: WrapMode::Truncate,
}
}
/// Whether this context actually wraps.
#[must_use]
pub const fn wrapping(self) -> bool {
matches!(self.wrap, WrapMode::Wrap) && self.cols > 0
}
}
/// How a line wider than the viewport is shown --- the long-lines
/// stage, `docs/long-lines-framing.md`.
///
/// # Why the renderer is told, rather than asking
///
/// This is the *resolved* mode, not the setting's name. `ui.line-wrap`
/// is **buffer-local** (framing Q#LL2), and the config registry has no
/// ambient current buffer by design (`config_registry.rs`, Q#CR4) — a
/// caller wanting buffer-aware behavior must pass the `BufferId`. The
/// render driver holds both the registry and the buffer, resolves once
/// per window per frame, and puts the answer here. Views stay
/// config-agnostic, exactly as they do for folds.
///
/// # `Truncate` is the identity case, deliberately
///
/// Every behavior predating this type is `Truncate`, and it must stay
/// byte-identical under it — which is what lets the wrap work be
/// verified against the existing suite rather than against new
/// assertions.
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default, Hash)]
pub enum WrapMode {
/// One source line per row; the remainder is clipped at the right
/// edge. What every pre-Stage-3 caller did.
#[default]
Truncate,
/// A line longer than the viewport continues on the following rows.
///
/// Character wrap, not word wrap (framing Q#LL5): it matches
/// Emacs's default, and it is the only break rule both frontends
/// can implement identically without pulling UAX #14 into the grid.
Wrap,
}
/// What to render and where.
@ -152,6 +257,14 @@ pub struct Viewport<'a> {
/// split may show different buffers) and hands the same shared
/// reference to every painter of that window.
pub folds: Option<&'a VisibleLineMap>,
/// How lines wider than `cell_size.cols` are shown, already
/// resolved for this window's buffer (see [`WrapMode`]).
///
/// A required field rather than a defaulted one on purpose: every
/// construction site has to state which behavior it means, so the
/// pre-Stage-3 sites read as *deliberately* unwrapped rather than
/// merely untouched.
pub wrap: WrapMode,
}
impl Viewport<'_> {
@ -268,14 +381,24 @@ pub trait View {
/// view holds a meaningful mapping for that position.
///
/// Default: returns `None` (view has no opinion).
fn pos_to_display(&self, _buf: &Buffer, _pos: Position) -> Option<DisplayCoord> {
fn pos_to_display(
&self,
_buf: &Buffer,
_pos: Position,
_ctx: LayoutCtx,
) -> Option<DisplayCoord> {
None
}
/// Translate a display coordinate back to a buffer byte position.
///
/// Default: returns `None`.
fn display_to_pos(&self, _buf: &Buffer, _coord: DisplayCoord) -> Option<Position> {
fn display_to_pos(
&self,
_buf: &Buffer,
_coord: DisplayCoord,
_ctx: LayoutCtx,
) -> Option<Position> {
None
}
@ -360,6 +483,7 @@ mod tests {
cell_size: CellSize::new(10, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
assert_eq!(vp.row_offset_of(4, 4), Some(0));
assert_eq!(vp.row_offset_of(4, 9), Some(5));
@ -380,6 +504,7 @@ mod tests {
cell_size: CellSize::new(10, 10),
gutter_w: 0,
folds: Some(&map),
wrap: WrapMode::Truncate,
};
assert_eq!(vp.row_offset_of(0, 1), Some(1), "the head keeps its row");
assert_eq!(vp.row_offset_of(0, 3), None, "hidden lines have no row");

View File

@ -378,6 +378,33 @@ pub struct Window {
/// render. Updated by the renderer; consumed by `cursor.page-down`
/// / `cursor.page-up`. `0` until the first render lands.
pub last_visible_rows: u32,
/// Width in cells of this window's **content** area at last render
/// — the text columns, with the line-number gutter already
/// subtracted. Updated by the renderer alongside
/// [`Self::last_visible_rows`]; `0` until the first render lands.
///
/// Content, not window, width: the gutter grows at the line-count
/// digit boundary (9 -> 10, 99 -> 100), so the two differ and only
/// this one is where text actually wraps.
///
/// Needed because line wrapping makes the cursor's display
/// coordinate width-dependent, and the callers that ask for it —
/// vertical motion, paging, overlay placement — hold a window but
/// not the frame's geometry. `last_visible_rows` established this
/// pattern for rows; wrapping needs the other axis.
pub last_content_cols: u32,
/// Wrap mode this window's buffer resolved to at last render.
///
/// Recorded by the driver beside [`Self::last_content_cols`], for
/// the same reason: the mode is **buffer-local** config and the
/// registry has no ambient buffer, so only the driver can resolve
/// it — but vertical motion, paging and overlay placement all need
/// it and hold a window rather than a registry.
///
/// One resolution, recorded once, consumed everywhere. The
/// alternative — each consumer resolving for itself — is how two
/// callers end up disagreeing about the same buffer.
pub last_wrap: crate::view::WrapMode,
/// Line-number gutter mode for this window (UX gutter arc). `Off` by
/// default → no gutter, no coordinate change.
pub line_numbers: LineNumberMode,
@ -401,6 +428,8 @@ impl Window {
view_top: 0,
goal_col: None,
last_visible_rows: 0,
last_content_cols: 0,
last_wrap: crate::view::WrapMode::Truncate,
line_numbers: LineNumberMode::Off,
params: WindowParams::default(),
}
@ -412,6 +441,18 @@ impl Window {
self.params.is_side()
}
/// The layout facts a coordinate mapping needs for this window.
///
/// Reads what the last render recorded, so every consumer sees the
/// same answer the renderer used rather than deriving its own.
#[must_use]
pub fn layout_ctx(&self) -> crate::view::LayoutCtx {
crate::view::LayoutCtx {
cols: self.last_content_cols,
wrap: self.last_wrap,
}
}
/// Width in cells this window's line-number gutter occupies, or `0`
/// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`;
/// the renderer caps this against the window width and applies it as a

View File

@ -32,7 +32,7 @@ use pmacs::editor_core::GeometryUpdate;
use pmacs::protocol::{FrontendId, InstanceMessage, PROTOCOL_VERSION};
use pmacs::semantic_render::SemanticRenderState;
use pmacs::window::{FrontendView, Layout, Window, WindowId};
use pmacs_protocol::panel::{PanelFrame, PanelFramePayload};
use pmacs_protocol::panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload};
// ---------------------------------------------------------------------------
// Harness
@ -740,10 +740,25 @@ fn acc51_a_pre_panel_semantic_frontend_is_never_sent_a_panel_frame() {
);
}
/// A peer below [`PANEL_MIN_VERSION`] gets no `PanelFrame` even when the
/// daemon could build one for it.
///
/// **Anchored on `PANEL_MIN_VERSION - 1`, not `PROTOCOL_VERSION - 1`.**
/// The original spelling was the latter, which expressed an *absolute*
/// contract — "older than the version that introduced panel frames" —
/// as arithmetic on a *moving* constant. It held only while
/// `PROTOCOL_VERSION` happened to equal `PANEL_MIN_VERSION`, and the
/// long-lines lane's bump to v22 made `PROTOCOL_VERSION - 1` equal
/// `PANEL_MIN_VERSION` exactly: the fixture's "old" peer became
/// panel-capable, so the daemon correctly sent a frame and the test
/// correctly failed. The production code was never wrong.
///
/// `src/daemon.rs` and `pmacs-gpu/src/main.rs` already spell this
/// `PANEL_MIN_VERSION - 1` in five places; this was the one outlier.
#[test]
fn acc51_a_v20_peer_is_sent_no_panel_frame_even_when_capable() {
fn acc51_a_sub_panel_version_peer_is_sent_no_panel_frame_even_when_capable() {
let mut session = Session::new();
session.render = SemanticRenderState::for_peer(FID, PROTOCOL_VERSION - 1);
session.render = SemanticRenderState::for_peer(FID, PANEL_MIN_VERSION - 1);
session.declare(1, ROWS, COLS);
open_panel(&session, "*panel*", 4);

View File

@ -336,13 +336,31 @@ fn one_daemon_serves_a_v21_panel_session_and_a_shipped_v20_client() {
/// and the counter-offer is what reaches the current wire.
#[test]
fn the_baseline_stays_and_the_counter_offer_activates() {
assert_eq!(PROTOCOL_VERSION, 21);
// A deliberate tripwire: bumping the wire must be a conscious edit
// here, not a silent one. v22 is `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 22);
assert_eq!(
ADVERTISED_PROTOCOL_VERSION, 20,
"moving this is the incompatible act the mechanism exists to avoid"
);
const { assert!(PROTOCOL_VERSION > ADVERTISED_PROTOCOL_VERSION) };
assert_eq!(PANEL_MIN_VERSION, PROTOCOL_VERSION);
// Panel frames are gated ABOVE the advertised floor and at or below
// this binary's wire. Both halves are durable properties of the
// gate.
//
// This replaces `assert_eq!(PANEL_MIN_VERSION, PROTOCOL_VERSION)`,
// which asserted a **coincidence**: panel frames were the newest
// feature when it was written, so their minimum happened to equal
// the current wire. Any later feature falsifies that — v22 is the
// first, and the equality would have had to be edited on every
// subsequent bump while telling a reader something that was never
// the contract.
// `const` blocks, matching the line above: these are compile-time
// constants, so a runtime `assert!` is both a clippy error and a
// weaker check than the language already offers.
const { assert!(PANEL_MIN_VERSION > ADVERTISED_PROTOCOL_VERSION) };
const { assert!(PANEL_MIN_VERSION <= PROTOCOL_VERSION) };
// The current baseline is answered with this binary's own version.
assert_eq!(
@ -584,8 +602,24 @@ fn a54_real_daemon_real_pty_and_headless_gpu_render_one_panel_hosted_terminal()
"a deadline-driven pass must not read as success: {text}"
);
// The activation, end to end on a real socket.
assert_eq!(fact("session_protocol_version"), "21", "{text}");
//
// The session version is compared against `PROTOCOL_VERSION` rather
// than the literal "21" it used to pin: what the counter-offer
// activates is *this binary's* wire, so the literal was only ever
// correct while the panel stage was the newest one. The baseline
// stays a literal, because 20 not moving IS the claim.
assert_eq!(
fact("session_protocol_version"),
PROTOCOL_VERSION.to_string(),
"{text}"
);
assert_eq!(fact("baseline_protocol_version"), "20", "{text}");
// …and that negotiated version is panel-capable, which is the part
// "21" used to carry implicitly.
assert!(
number("session_protocol_version") >= PANEL_MIN_VERSION,
"the negotiated wire must reach the panel minimum: {text}"
);
// The band is real: declared, projected, focused, and carrying the child.
assert!(

View File

@ -14,7 +14,7 @@ use pmacs_protocol::message::{
AttachRequest, FrontendEvent, Hello, InstanceMessage, Modifiers, MouseButton, MouseKind,
};
use pmacs_protocol::panel::{
MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload,
MAX_PANEL_VISIBLE_CELLS, PANEL_MIN_VERSION, PanelFrame, PanelFrameError, PanelFramePayload,
};
use pmacs_protocol::terminal::{
MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState,
@ -107,8 +107,18 @@ fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame {
#[test]
fn the_panel_stage_takes_protocol_v21() {
assert_eq!(PROTOCOL_VERSION, 21);
// The panel stage's own version, which does not move when a later
// feature appends to the wire.
//
// This was `assert_eq!(PROTOCOL_VERSION, 21)` — the CURRENT wire
// used as a proxy for the panel stage's version. The two were equal
// only until the next feature landed (v22, `LineWrapFacts`), and the
// proxy then failed in a test whose own name says what it means to
// pin. `PANEL_MIN_VERSION` is that constant.
assert_eq!(PANEL_MIN_VERSION, 21);
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21));
// This binary must be able to speak the stage it implements.
const { assert!(PROTOCOL_VERSION >= PANEL_MIN_VERSION) };
// The advertised version is a compatibility BASELINE, and Stage 2B-3
// made that permanent rather than temporary: the server-first Hello
// reaches an already-shipped frontend before that frontend can send

View File

@ -270,7 +270,7 @@ fn render_active_window_to_grid(
cols: u32,
) -> Vec<pmacs::cell::Cell> {
use pmacs::cell::{Cell, CellGrid, CellSize};
use pmacs::view::{View, Viewport};
use pmacs::view::{View, Viewport, WrapMode};
use pmacs::window::Rect;
let mut core = state.core.borrow_mut();
@ -289,6 +289,7 @@ fn render_active_window_to_grid(
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let mut grid = CellGrid {
cells: &mut backing,

View File

@ -0,0 +1,223 @@
//! Line-wrap acceptance (long lines, `docs/long-lines-framing.md`).
//!
//! `ui.line-wrap` is **buffer-local** (Q#LL2), and buffer-local is the
//! whole point rather than a nicety: prose wants wrapping and a log file
//! usually does not, in the same session.
//!
//! Which makes the toggle command's failure mode specific and invisible
//! in any single-buffer test. `pmacs.config.get(name)` reads the global
//! chain and `pmacs.config.set(name, ...)` writes the global layer, so a
//! toggle built from that pair reports and flips the **global** value.
//! In a buffer pinned to `truncate` it would leave that buffer exactly
//! as it was while silently changing every buffer that had not been
//! pinned — the opposite of what the user asked for, in both directions
//! at once.
//!
//! So these tests use a second buffer, and a buffer pinned against
//! the global layer, to make both halves of that failure visible.
use std::path::Path;
use pmacs::bootstrap::BootstrapRoots;
use pmacs::editor::EditorState;
fn session(name: &str) -> EditorState {
let base = Path::new(env!("CARGO_TARGET_TMPDIR"))
.join("line-wrap")
.join(name);
let _ = std::fs::remove_dir_all(&base);
let roots = BootstrapRoots::isolated_under(&base);
for (_, dir) in roots.child_env() {
std::fs::create_dir_all(&dir).expect("create controlled root");
}
let state = EditorState::new_with_roots(&roots);
state.install_state_dirs();
state
}
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
s.lua_host.lua().load(src.to_string()).eval().unwrap()
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
/// The resolved mode for the buffer currently in the window.
fn mode_here(s: &EditorState) -> String {
eval(
s,
"return pmacs.config.get('ui.line-wrap', pmacs.window.buffer())",
)
}
/// The global layer's value, which is *not* what a buffer necessarily
/// resolves to.
fn mode_global(s: &EditorState) -> String {
eval(s, "return pmacs.config.get('ui.line-wrap')")
}
/// Put `text` in the window's current buffer by typing it, which is the
/// only path Lua exposes and also the one a user takes.
fn fill_active(s: &EditorState, text: &str) {
for ch in text.chars() {
exec(
s,
&format!("pmacs.editor.insert_char_over_region({})", ch as u32),
);
}
}
/// Render one frame and return the text of the first `rows` grid rows,
/// reconstructed from the emitted `CellDelta` spans.
///
/// Two reasons for going through `RenderState` and the wire rather than
/// calling `TextView::render` with a hand-built viewport. The defect
/// this guards against lived in the *driver*, between the resolved mode
/// and the viewport it built — a test that constructed its own viewport
/// would have passed against it. And the spans are what the TUI
/// actually consumes, so this asserts on the bytes that reach a screen.
fn render_rows(s: &EditorState, rows: u32, cols: u32) -> Vec<String> {
use std::collections::HashMap;
let size = pmacs::cell::CellSize::new(rows, cols);
let mut rs = pmacs::instance_render::RenderState::new(size);
let msgs = rs.render_frame(s, pmacs::protocol::FrontendId::LOCAL, &HashMap::new(), &[]);
let mut grid = vec![vec![' '; cols as usize]; rows as usize];
for msg in &msgs {
if let pmacs_protocol::InstanceMessage::CellDelta { spans, .. } = msg {
for span in spans {
for (i, cell) in span.cells.iter().enumerate() {
let r = span.start.row as usize;
let c = span.start.col as usize + i;
if r < rows as usize
&& c < cols as usize
&& let pmacs::cell::Glyph::Char(ch) = cell.glyph
{
grid[r][c] = ch;
}
}
}
}
}
grid.into_iter().map(|r| r.into_iter().collect()).collect()
}
#[test]
fn the_default_is_wrap() {
let s = session("default");
assert_eq!(mode_global(&s), "wrap");
assert_eq!(
mode_here(&s),
"wrap",
"with no buffer-local override, a buffer resolves to the global default"
);
}
#[test]
fn only_wrap_and_truncate_are_accepted() {
let s = session("enum");
let err: bool = eval(
&s,
"local ok = pcall(pmacs.config.set, 'ui.line-wrap', 'sideways'); return not ok",
);
assert!(
err,
"a closed choice set makes an unknown mode impossible rather than handled"
);
}
/// The finding this suite exists for: the toggle must move the buffer
/// it is invoked in, and **only** that buffer.
///
/// There is no Lua buffer-switch, so "the other buffer" is a second
/// buffer that exists but is not shown — which is the case that matters
/// anyway: a global write reaches every buffer without an override of
/// its own, shown or not.
#[test]
fn the_toggle_moves_this_buffer_and_leaves_the_other_alone() {
let s = session("two_buffers");
exec(&s, "OTHER = pmacs.buffer.create('other')");
assert_eq!(
eval::<String>(&s, "return pmacs.config.get('ui.line-wrap', OTHER)"),
"wrap",
"precondition: the second buffer starts at the default"
);
exec(&s, "pmacs.command.invoke('ui.toggle-line-wrap')");
assert_eq!(mode_here(&s), "truncate", "the invoking buffer moved");
assert_eq!(
eval::<String>(&s, "return pmacs.config.get('ui.line-wrap', OTHER)"),
"wrap",
"toggling in one buffer must not change another"
);
assert_eq!(
mode_global(&s),
"wrap",
"a buffer-local toggle must not write the global layer — doing so \
would change every buffer with no override of its own"
);
}
/// A buffer pinned to `truncate` must toggle back to `wrap`, reading its
/// own value rather than the global one.
///
/// This is the half a global-read toggle gets wrong even if its write
/// were harmless: it would see `wrap` globally, decide the next mode is
/// `truncate`, and leave the pinned buffer exactly as it was.
#[test]
fn a_pinned_buffer_toggles_from_its_own_value() {
let s = session("pinned");
exec(
&s,
"pmacs.config.set_local(pmacs.window.buffer(), 'ui.line-wrap', 'truncate')",
);
assert_eq!(mode_here(&s), "truncate");
assert_eq!(mode_global(&s), "wrap", "precondition: the layers differ");
exec(&s, "pmacs.command.invoke('ui.toggle-line-wrap')");
assert_eq!(
mode_here(&s),
"wrap",
"the toggle read this buffer's value, not the global one"
);
}
/// The default must reach the **rendered cells**, not merely the
/// resolved value.
///
/// This is the gap review found: the frame resolved `ui.line-wrap`,
/// recorded it on the window, and fed it to the coordinate mapping and
/// the scroll indicator — while the viewport handed the renderer a
/// hard-coded `Truncate`. Every "is the mode right?" assertion passed
/// and the text was still clipped. So this test reads the grid.
#[test]
fn the_default_actually_wraps_the_painted_text() {
let s = session("rendered_default");
// Wider than the viewport below, and distinctive.
fill_active(&s, "ABCDEFGHIJKLMNOP");
let rows = render_rows(&s, 4, 4);
assert_eq!(rows[0], "ABCD");
assert_eq!(
rows[1], "EFGH",
"the default is `wrap`, so the remainder continues on the next row \
a hard-coded Truncate in the viewport leaves this blank"
);
}
/// And `truncate` still clips, so the witness above is discriminating
/// rather than merely true.
#[test]
fn truncate_clips_the_painted_text() {
let s = session("rendered_truncate");
fill_active(&s, "ABCDEFGHIJKLMNOP");
exec(
&s,
"pmacs.config.set_local(pmacs.window.buffer(), 'ui.line-wrap', 'truncate')",
);
let rows = render_rows(&s, 4, 4);
assert_eq!(rows[0], "ABCD");
assert_eq!(rows[1], " ", "truncate keeps one row per source line");
}

View File

@ -144,7 +144,7 @@ fn set_read_only(s: &EditorState, id: BufferId, value: bool) {
/// bites: the rope is right and the screen is not.
fn paint_active_window(s: &EditorState, rows: u32, cols: u32) -> Vec<pmacs::cell::Cell> {
use pmacs::cell::{Cell, CellGrid, CellSize};
use pmacs::view::{View, Viewport};
use pmacs::view::{View, Viewport, WrapMode};
use pmacs::window::Rect;
let mut core = s.core.borrow_mut();
@ -162,6 +162,7 @@ fn paint_active_window(s: &EditorState, rows: u32, cols: u32) -> Vec<pmacs::cell
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let mut grid = CellGrid {
cells: &mut backing,

View File

@ -0,0 +1,155 @@
//! The originating report, at the real PTY boundary.
//!
//! > "long lines need to either wrap somehow or be scrollable. Haven't
//! > tried this in GUI, but in TUI, a line that extends off screen
//! > cannot be read in full in any way."
//!
//! Every other test in this lane checks a mechanism: that the mode
//! resolves, that it reaches the viewport, that the classifier agrees
//! across frontends. This one checks the **complaint** — that the end of
//! a long line reaches a terminal at all — and it is deliberately the
//! only test here that runs the shipped binary against a real PTY.
//!
//! # Why this is not redundant with `line_wrap_acceptance.rs`
//!
//! That suite reconstructs rows from emitted `CellDelta` spans, which is
//! the right granularity for asserting *where* text lands. But it drives
//! `RenderState` in-process, so everything between the grid and a
//! terminal — the frontend, the ANSI writer, startup, the real terminal
//! size — is assumed rather than exercised. The defect being fixed was
//! reported from a terminal, so at least one test should end in one.
//!
//! # What it asserts, and why that is the honest assertion
//!
//! The vterm suites here assert on raw output bytes; there is no screen
//! model and no `vt100` / `termwiz` / `vte` dependency in the workspace
//! (`full_grid_resync_acceptance.rs` records the same limit). So this
//! proves **the tail of the line was written to the terminal**, not that
//! it occupies the row a human would point at. That is nevertheless the
//! whole of the original report: under truncation those bytes are never
//! emitted at all, because there is no column past the edge to paint
//! them into and no horizontal scrolling to reveal them.
//!
//! Hence the `truncate` control below. Without it the wrap assertion
//! would be satisfied by anything that happened to echo the fixture, and
//! the pair is what makes the marker discriminating.
use std::time::{Duration, Instant};
#[path = "common/mod.rs"]
mod common;
use common::pty::{PmacsPty, spawn_pmacs_in_pty};
/// Painted first, well within any terminal width — the "pmacs got this
/// far" anchor that keeps an absence assertion from passing vacuously.
const HEAD: &[u8] = b"HEADZQX";
/// Painted only if something puts it on a row: it sits ~200 columns into
/// a single source line, past the right edge of the 80-column PTY below.
const TAIL: &[u8] = b"TAILZQX";
/// One source line, far wider than the terminal, marked at both ends.
fn fixture() -> String {
format!(
"{}{}{}\n",
String::from_utf8_lossy(HEAD),
"-".repeat(200),
String::from_utf8_lossy(TAIL),
)
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
/// Block until `needle` appears in pmacs's output.
///
/// Content-anchored rather than timed, for the reason
/// `full_grid_resync_acceptance.rs` spells out: a settled pmacs screen
/// emits per-frame bytes forever, so "output stopped growing" never
/// becomes true and cannot mark the end of startup.
fn wait_for(pty: &PmacsPty, needle: &[u8], timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
if contains(&pty.output(), needle) {
return;
}
assert!(
Instant::now() < deadline,
"pmacs never painted {:?} within {timeout:?}; emitted {} bytes",
String::from_utf8_lossy(needle),
pty.output().len()
);
std::thread::sleep(Duration::from_millis(20));
}
}
/// Spawn pmacs over the fixture in an 80x24 PTY, with an isolated
/// config root that optionally carries an `init.lua`.
fn spawn(dir: &std::path::Path, init_lua: Option<&str>) -> PmacsPty {
let file = dir.join("longline.txt");
std::fs::write(&file, fixture()).expect("write fixture");
if let Some(body) = init_lua {
let cfg = dir.join("pmacs");
std::fs::create_dir_all(&cfg).expect("create config dir");
std::fs::write(cfg.join("init.lua"), body).expect("write init.lua");
}
spawn_pmacs_in_pty(
&[file.to_str().expect("utf-8 path")],
&[("HOME", dir), ("XDG_CONFIG_HOME", dir)],
24,
80,
)
}
fn quit(pty: &mut PmacsPty) {
let _ = pty.write_input(b"\x18\x03"); // C-x C-c
let _ = pty.wait_for_exit(Duration::from_secs(5));
}
/// The report, closed: opening a file whose line runs off the right edge
/// puts the end of that line on the terminal.
#[test]
fn the_end_of_a_long_line_reaches_the_terminal() {
let dir = tempfile::tempdir().expect("tempdir");
let mut pty = spawn(dir.path(), None);
wait_for(&pty, HEAD, Duration::from_secs(20));
wait_for(&pty, TAIL, Duration::from_secs(20));
quit(&mut pty);
}
/// The control that makes the marker above mean something: pinned to
/// `truncate`, the same fixture in the same terminal never emits the
/// tail.
///
/// This is also the honest statement of what `truncate` costs today.
/// Those bytes are not merely off-screen, they are unreachable — there
/// is no horizontal scrolling yet, which is why `wrap` is the default
/// and why `ui.toggle-line-wrap` says so when it turns wrapping off.
#[test]
fn truncate_leaves_the_end_of_the_line_unreachable() {
let dir = tempfile::tempdir().expect("tempdir");
let mut pty = spawn(
dir.path(),
Some("pmacs.config.set('ui.line-wrap', 'truncate')\n"),
);
// The anchor first: without it, "TAIL never appeared" would also be
// satisfied by pmacs failing to start.
wait_for(&pty, HEAD, Duration::from_secs(20));
// Head and tail would be painted in the SAME frame if wrapping were
// on, so this settle is generous rather than load-bearing.
std::thread::sleep(Duration::from_millis(750));
assert!(
!contains(&pty.output(), TAIL),
"truncate must clip at the edge — emitting the tail would mean \
the mode reached the resolver but not the renderer, which is \
exactly the defect the rendered witnesses in \
line_wrap_acceptance.rs guard from the other side"
);
quit(&mut pty);
}

View File

@ -483,7 +483,7 @@ fn render_active_window_to_grid(
cols: u32,
) -> Vec<pmacs::cell::Cell> {
use pmacs::cell::{Cell, CellGrid, CellSize};
use pmacs::view::{View, Viewport};
use pmacs::view::{View, Viewport, WrapMode};
use pmacs::window::Rect;
let mut core = state.core.borrow_mut();
@ -502,6 +502,7 @@ fn render_active_window_to_grid(
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let mut grid = CellGrid {
cells: &mut backing,

View File

@ -788,15 +788,21 @@ fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() {
fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
// Vterm Stage 3 appended the terminal family as v19; GPU initial targets
// appended the semantic bootstrap family as v20; bottom-panel Stage 2B-1
// appended the panel family as v21. This acceptance owns the STATUSLINE
// appended the panel family as v21; long-lines Stage 3 appended
// `LineWrapFacts` as v22. 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, 21);
for version in 6..=21 {
//
// Tracking the current wire is deliberate, so every bump edits these
// three lines on purpose. The ceiling assertion is the load-bearing
// one — it says the supported set ENDS here, which is what makes an
// accidentally-widened set a failure rather than a silent pass.
assert_eq!(PROTOCOL_VERSION, 22);
for version in 6..=22 {
assert!(is_supported_protocol_version(version));
}
assert!(!is_supported_protocol_version(22));
assert!(!is_supported_protocol_version(23));
let sample = InstanceMessage::StatuslineSegments {
buffer_id: BufferId::from_raw(9),
left: vec![StatuslineSegment {

View File

@ -6,7 +6,7 @@ use pmacs::buffer::{Buffer, BufferId};
use pmacs::cell::{Cell, CellCoord, CellGrid, CellSize, Glyph, Style};
use pmacs::overlay::{BufferStyleOverlay, BufferStyleSpan, SharedBufferStyleSpans};
use pmacs::text_view::TextView;
use pmacs::view::{DisplayCoord, View, Viewport};
use pmacs::view::{DisplayCoord, LayoutCtx, View, Viewport, WrapMode};
fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport<'static> {
Viewport {
@ -16,6 +16,7 @@ fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport<'static> {
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
}
}
@ -36,10 +37,16 @@ fn plain_text_projects_tabs_without_changing_source_bytes() {
let buf = Buffer::from_bytes(BufferId::next(), "tabs", source);
let cells = render_text(&buf, 3, 20);
let view = TextView::new(&buf);
assert_eq!(view.pos_to_display(&buf, 1), Some(DisplayCoord::new(0, 8)));
assert_eq!(view.pos_to_display(&buf, 11), Some(DisplayCoord::new(1, 8)));
assert_eq!(
view.pos_to_display(&buf, 22),
view.pos_to_display(&buf, 1, LayoutCtx::truncated()),
Some(DisplayCoord::new(0, 8))
);
assert_eq!(
view.pos_to_display(&buf, 11, LayoutCtx::truncated()),
Some(DisplayCoord::new(1, 8))
);
assert_eq!(
view.pos_to_display(&buf, 22, LayoutCtx::truncated()),
Some(DisplayCoord::new(2, 16))
);

View File

@ -468,7 +468,7 @@ fn render_active_window_to_grid(
cols: u32,
) -> Vec<pmacs::cell::Cell> {
use pmacs::cell::{Cell, CellGrid};
use pmacs::view::{View, Viewport};
use pmacs::view::{View, Viewport, WrapMode};
use pmacs::window::Rect;
let mut core = state.core.borrow_mut();
@ -486,6 +486,7 @@ fn render_active_window_to_grid(
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let mut grid = CellGrid {
cells: &mut backing,

View File

@ -737,10 +737,18 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
// which is exactly the incompatible change this mechanism exists to
// avoid — and asserting only the baseline would pass with the whole
// activation missing.
// Compared against `PROTOCOL_VERSION`, not the literal "21": the
// counter-offer activates THIS BINARY's wire, so the literal held
// only while the panel stage was the newest one (long-lines Stage 3
// appended v22). The baseline assertion below stays literal,
// because 20 not moving is the actual claim.
// Fully qualified: this file imports `PROTOCOL_VERSION` inside a
// different test's scope, not at module level.
let session_version = pmacs_protocol::PROTOCOL_VERSION.to_string();
assert_eq!(
facts.get("session_protocol_version").copied(),
Some("21"),
"the real client must negotiate the v21 panel wire: {text}"
Some(session_version.as_str()),
"the real client must negotiate this binary's wire: {text}"
);
assert_eq!(
facts.get("baseline_protocol_version").copied(),
@ -880,7 +888,9 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
panic!("timed out waiting for {what}");
}
assert_eq!(PROTOCOL_VERSION, 21);
// Tripwire: a wire bump must be a conscious edit here. v22 is
// `LineWrapFacts` (long-lines Stage 3).
assert_eq!(PROTOCOL_VERSION, 22);
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
&[
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),