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:
commit
02f3ec3b7c
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 #217 — PR #218 OPEN
|
||||
## Docs absorption after #217 — MERGED 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
112
src/editor.rs
112
src/editor.rs
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(®istry, 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(®istry, 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(®istry, 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(®istry, 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(®istry, viewport, &mut grid);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
985
src/text_view.rs
985
src/text_view.rs
File diff suppressed because it is too large
Load Diff
137
src/view.rs
137
src/view.rs
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue