Merge pull request #222 from levineuwirth/horizontal-scroll
Horizontal scroll (TUI): reachable long lines, with decorations that travel
This commit is contained in:
commit
2b56d16069
|
|
@ -20,7 +20,15 @@
|
||||||
|
|
||||||
pmacs.config.define {
|
pmacs.config.define {
|
||||||
name = "ui.line-wrap",
|
name = "ui.line-wrap",
|
||||||
description = "How a line wider than the window is shown: wrap onto following rows, or truncate at the edge.",
|
-- The description names what `truncate` COSTS, because the toggle's
|
||||||
|
-- status message is not enough: a user who sets this in `init.lua`
|
||||||
|
-- never invokes the toggle and so never sees it. #221 shipped that
|
||||||
|
-- gap (framing §6).
|
||||||
|
--
|
||||||
|
-- In the TUI the cost is now only the GUI's, because Stage 4 gave the
|
||||||
|
-- grid renderer horizontal scrolling. Stage 5 closes the rest, and
|
||||||
|
-- this sentence shrinks back when it lands.
|
||||||
|
description = "How a line wider than the window is shown: wrap onto following rows, or truncate at the edge. Truncated text is reachable by moving the cursor past the edge in the terminal UI; in the GUI it is not yet reachable at all.",
|
||||||
-- A closed set, so an unknown value is impossible rather than
|
-- A closed set, so an unknown value is impossible rather than
|
||||||
-- handled. Adding "word" later is a clean additive change --- which
|
-- handled. Adding "word" later is a clean additive change --- which
|
||||||
-- is the plan, since character wrap is what both frontends can do
|
-- is the plan, since character wrap is what both frontends can do
|
||||||
|
|
@ -65,7 +73,7 @@ pmacs.command.define {
|
||||||
local next_mode = current == "wrap" and "truncate" or "wrap"
|
local next_mode = current == "wrap" and "truncate" or "wrap"
|
||||||
pmacs.config.set_local(buf, "ui.line-wrap", next_mode)
|
pmacs.config.set_local(buf, "ui.line-wrap", next_mode)
|
||||||
if next_mode == "truncate" then
|
if next_mode == "truncate" then
|
||||||
pmacs.editor.set_status("line wrap off — text past the edge is unreachable until horizontal scrolling lands")
|
pmacs.editor.set_status("line wrap off — move the cursor past the edge to scroll (GUI: not yet)")
|
||||||
else
|
else
|
||||||
pmacs.editor.set_status("line wrap on")
|
pmacs.editor.set_status("line wrap on")
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -434,278 +434,191 @@ a job executed — **the log is**.
|
||||||
Whether `docs/ci-red-signatures.md` should grow a short non-row section
|
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
|
for this class is an open question for its owner, not something this
|
||||||
lane decided.
|
lane decided.
|
||||||
## Long lines (QoL Stage 3) — PR #221 OPEN, awaiting review
|
|
||||||
|
|
||||||
**Branch `long-lines`**, originally based on `githubsucks/main` @
|
## Long lines (QoL arc) — Stage 3 MERGED as #221; Stage 4 is PR #222 OPEN; Stage 5 ahead
|
||||||
`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
|
**Rewritten, not removed.** Rule 4 removes a lane when its ARC is done;
|
||||||
existed** — the standing correction from #171, #215 and #220. **PR
|
this one has Stage 4 ahead. Stage 3's durable facts are absorbed into
|
||||||
#221** opened 2026-08-07 against `main` @ `912bf57`; the number is
|
`docs/agent-handoff.md` §1 — rule 4's precondition, satisfied rather
|
||||||
recorded here rather than left to be reconstructed from the branch.
|
than deferred — so what remains here is the Stage 4 plan and only the
|
||||||
|
Stage 3 residue that constrains it.
|
||||||
|
|
||||||
- **Framing `docs/long-lines-framing.md` revision 20, APPROVED.** All
|
> **RULE 4 DOES NOT APPLY AT STAGE 4's MERGE.** The arc closes at
|
||||||
eight questions settled, §5d.6 resolved. Revision 20 **withdraws
|
> **Stage 5** (GPU horizontal scroll), not Stage 4. Q#HS1 split the GPU
|
||||||
§1.1** — see "The framing error" below.
|
> out deliberately and time-boxed it; retiring this lane when the TUI
|
||||||
- **Independent of #220** (now merged). Stage 2 and Stage 3 share no
|
> half merges would orphan exactly the half the time box exists to
|
||||||
code; the merge in this branch is currency, not dependency.
|
> guarantee, and would do so while `truncate` is still a dead end in
|
||||||
- **Implementation complete.** Seven commits, `937544c..840a338`.
|
> the GUI. **Do not remove this block until Stage 5 has merged**, and
|
||||||
Every gate green locally, macOS unverifiable here as always.
|
> read Q#HS1's four time-box items before concluding otherwise.
|
||||||
|
|
||||||
### The defect
|
**Branch `horizontal-scroll`**, based on `githubsucks/main` @ `02f3ec3`
|
||||||
|
(the #221 merge). `githubsucks/horizontal-scroll` 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 horizontal-scroll`.
|
||||||
|
|
||||||
A line wider than the window is unreadable past the edge **in the
|
**Status: `docs/horizontal-scroll-framing.md` revision 4 — APPROVED
|
||||||
TUI**. The GPU is not in that state: it already wraps. So the
|
2026-08-07. Stage 4 implemented; **PR #222 OPEN**, awaiting review. Do
|
||||||
cross-frontend defect is **not** unreadability — it is that *neither
|
not merge unprompted.**
|
||||||
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
|
**What Stage 4 shipped**, beyond the `view_left` contract below:
|
||||||
|
|
||||||
- **Q#LL1** — ships `wrap` + `truncate`, **default `wrap`**; horizontal
|
- **`Viewport::visible_cols` — one clip rule, five adopters.** Review
|
||||||
scroll is **Stage 4**. No default preserves both frontends, so this
|
found the first version had translated the base glyph walk and
|
||||||
knowingly changes the TUI's behavior and leaves the GPU's alone.
|
nothing else, so syntax styling, diagnostic underlines, search
|
||||||
- **Q#LL2** — the mode is **buffer-local**; `Viewport` carries the
|
washes, `BufferStyleOverlay` and the selection painter all kept
|
||||||
*resolved* mode as it already carries `folds`, so `TextView` stays
|
painting at absolute columns: decorations drifting off the characters
|
||||||
config-agnostic.
|
they describe, only once a window had been scrolled. The selection
|
||||||
- **Q#LL4** — do **not** adopt `editing.fill-column`; name ours
|
painter was worst — it asked `pos_to_display` through the live
|
||||||
`ui.line-wrap` (`ConfigKind::Enum`). *(The recorded reason was wrong;
|
context, which returns `None` left of the edge, so a selection
|
||||||
the answer was not. See "The framing error".)*
|
starting off-screen painted **nothing at all**.
|
||||||
- **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
|
A second review round caught that my reason for letting selection
|
||||||
|
keep its own copy of the rule (a width the viewport supposedly
|
||||||
|
lacked) was **false**: the render viewport is already
|
||||||
|
`rect.size.cols - gutter_w` with an origin past the gutter. It now
|
||||||
|
takes that viewport. `StyleSpanOverlay` / `VirtualCellOverlay` stay
|
||||||
|
untouched — viewport-relative by contract.
|
||||||
|
- **The `ui.line-wrap` description now names what `truncate` costs**,
|
||||||
|
closing the #221 gap where only the toggle's status message said it.
|
||||||
|
- **`R7`** in `docs/ci-red-signatures.md` — an unrelated, unreproduced
|
||||||
|
`pmacs-gpu` managed-retry `BrokenPipe` under full-sweep load. First
|
||||||
|
incident this session with a complete signature, so a matchable row
|
||||||
|
rather than a `U` note.
|
||||||
|
|
||||||
- **Q#LL7 --- the GPU had no wire.** The mode resolved into `Viewport`,
|
**Answered by the user 2026-08-07:**
|
||||||
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.**
|
- **Q#HS1 — the GPU is Stage 5, not Stage 4.** A conscious, bounded
|
||||||
`format_scroll_indicator` is **duplicated, not shared**
|
divergence rather than a repeat of Stage 3's accidental one. The time
|
||||||
(`src/editor.rs:5509`, `pmacs-gpu/src/main.rs:10114`), and the GPU
|
box is concrete: Stage 5 is the *immediately-next* QoL lane after
|
||||||
passes a source-line count --- so the first fix would have corrected
|
Stage 4 merges, `wrap` stays the default until it lands, Stage 4's
|
||||||
one frontend and left the other wrong, *this lane's own defect
|
release notes state the asymmetry, and the `truncate` affordances
|
||||||
reproduced by the section meant to close it*. And the lazy total's
|
name the GUI gap while it exists.
|
||||||
cache key omitted **fold state**, which changes per window with no
|
- **Q#HS2 — automatic only.** The cursor-visibility pass gains a
|
||||||
edit, resize or mode change; now keyed on the fold projection's own
|
horizontal component; no commands, no bindings, no new interaction
|
||||||
contents, which cannot be forgotten, rather than a maintained
|
island. Explicit `ui.scroll-*` is deliberately out.
|
||||||
revision counter that can.
|
- **Q#HS6 — `wrap` stays the default.** Coupled to Q#HS1: with the GPU
|
||||||
|
deferred, a `truncate` default would ship a mode that is navigable in
|
||||||
|
the TUI and a **dead end in the GUI** for anyone who never opened the
|
||||||
|
setting. Revisit after Stage 5, on use evidence.
|
||||||
|
|
||||||
**Then the aggregate was abandoned entirely (revision 17).** The GPU
|
- **Q#HS7 — ACCEPTED.** `view_left` is an unsnapped window display
|
||||||
shapes **only the viewport slice** — Session S1 found the whole rope
|
column; the effective edge is derived **per line**; a bisected wide
|
||||||
made large-file editing `O(file)` per keystroke — so its layout
|
glyph's trailing cell renders as styled blank and is designated to
|
||||||
cannot yield a total, and re-shaping the document for a status-line
|
the **glyph's start** byte; a straddling tab's surviving cells keep
|
||||||
readout would reintroduce that cost. `NN%` is now **byte-based in
|
the **existing** forward rounding to the byte after the tab
|
||||||
both frontends**; `All`/`Top`/`Bot` stay exact because they are local
|
(`src/text_view.rs:224` — preserved, not chosen). Together these make
|
||||||
predicates. This retires the cache *and* the fold-key fix above,
|
the mapping **total over visible cells**, which is the (d) invariant.
|
||||||
which is kept in the framing marked superseded so a reader can tell
|
The discriminating witness is multi-line, with glyph widths differing
|
||||||
"the key was fixed" from "there is no key".
|
at the same column.
|
||||||
|
- **Q#HS5 — APPROVED: yes, persist, no `DESKTOP_VERSION` bump** —
|
||||||
|
conditional on `#[serde(default)]` **and** a literal v1 JSON fixture
|
||||||
|
omitting the field, asserting restore at zero. Both conditions are
|
||||||
|
part of the approval.
|
||||||
|
|
||||||
**Then the retained formatter turned out unable to express the new
|
- **Q#HS3** is re-confirmed rather than open (per-window, per Q#LL2);
|
||||||
contract (revision 18).** Every branch of `format_scroll_indicator`
|
**Q#HS4** is deferred, live only if explicit commands arrive.
|
||||||
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
|
**The reasoning worth keeping from the withdrawn Q#HS7(c).** Revision
|
||||||
|
2 voted to snap `view_left` to a valid boundary when set. That cannot
|
||||||
|
exist, and the reason generalizes: `view_left` is ONE per-window
|
||||||
|
column, but *"does column N bisect a wide glyph?"* is a **per-line**
|
||||||
|
question. No setter-time value is canonical for every visible line,
|
||||||
|
and snapping per line instead would break the vertical alignment a
|
||||||
|
column-oriented view exists to provide. Recorded because the same trap
|
||||||
|
waits for any future window-wide value derived from per-line content.
|
||||||
|
|
||||||
**`editing.fill-column` does not exist.** Framing §1.1 called it an
|
**One correction carried into revision 2.** Revision 1 claimed the
|
||||||
orphaned registry setting "of the exact shape Stage 1 just fixed" and
|
"unreachable past the edge" caveat is recorded in the setting's
|
||||||
carried a deliverable to sharpen its description. Both cited
|
description. It is not — `builtin/runtime/linewrap.lua:23` says only
|
||||||
occurrences are inside `#[cfg(test)] mod tests` —
|
"truncate at the edge"; the word appears in the toggle's status message
|
||||||
`src/config_registry.rs` and `src/lua_bindings/config.rs` — fixture
|
and a source comment, neither of which a user sees if they set the mode
|
||||||
names in round-trip tests covering one setting per `ConfigKind`. Two of
|
in `init.lua`. **A real, small user-facing gap shipped in #221**;
|
||||||
those five names are real; three, including this one, are defined
|
amending the description is now a Stage 4 deliverable (framing §6).
|
||||||
nowhere else.
|
|
||||||
|
|
||||||
Nineteen revisions and three review rounds inherited it. The mechanism
|
### What Stage 3 shipped that Stage 4 must live with
|
||||||
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
|
- **`ui.line-wrap` is BUFFER-local** (`ConfigKind::Enum`,
|
||||||
written for it:
|
`wrap`/`truncate`, default `wrap`). Q#LL2 recorded the consequence
|
||||||
|
and deliberately deferred it: `view_left` is unambiguously
|
||||||
|
**per-window**, because two panes on one buffer must scroll
|
||||||
|
independently exactly as they already hold independent `view_top`s
|
||||||
|
(`src/desktop.rs:92`, `src/window.rs:374`). So the two halves of one
|
||||||
|
user-facing concept land at different scopes. Emacs effectively does
|
||||||
|
this and it is survivable — but Stage 3 signed up for it as *a
|
||||||
|
decision*, and Stage 4 is where the bill arrives.
|
||||||
|
- **`truncate` is the mode Stage 4 makes navigable.** Today text past
|
||||||
|
the right edge is not merely off-screen but **unreachable** — and
|
||||||
|
that is stated **only** in `ui.toggle-line-wrap`'s status message and
|
||||||
|
a source comment, **not** in the setting's description
|
||||||
|
(`builtin/runtime/linewrap.lua:23` says just "truncate at the edge").
|
||||||
|
A user who sets the mode in `init.lua` and never invokes the toggle
|
||||||
|
is told nothing. Amending the description is a Stage 4 deliverable
|
||||||
|
(framing §6); the status message is revisited when scroll lands.
|
||||||
|
- **Under `wrap`, horizontal scroll is meaningless.** Stage 4's surface
|
||||||
|
is therefore conditional on the mode, which is a coherence question
|
||||||
|
(one concept, two behaviors) and not only an implementation one.
|
||||||
|
- **The scroll indicator's `wrap` path takes byte percentages** from
|
||||||
|
`pmacs-protocol::scroll`. It is vertical-only and Stage 4 does not
|
||||||
|
change it — recorded because "scroll" in this lane means horizontal
|
||||||
|
and the two must not be conflated in review.
|
||||||
|
|
||||||
1. **The toggle wrote the global layer.** `ui.line-wrap` is
|
### Ground truth gathered for the framing (verify before trusting)
|
||||||
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
|
- **There is no horizontal scroll anywhere in the tree.** No
|
||||||
were silently lost to `str.replace` calls that matched nothing (once
|
`view_left`, `scroll_left`, or `hscroll` in `src/` or `builtin/`.
|
||||||
after an unrelated exception aborted the write). Both times the code
|
This is greenfield, not an extension.
|
||||||
looked edited and was not — defect 2 above is one of them. Use the
|
- **`paint_line` starts every walk at column 0** (`src/text_view.rs`),
|
||||||
Edit tool, which errors on mismatch, for anything load-bearing.
|
which is the same walk Stage 3 rewrote for wrapping. A `view_left`
|
||||||
|
enters here, and the wrap rule (`advance_wrapped`) must stay written
|
||||||
|
exactly once.
|
||||||
|
- **The GPU cannot honor horizontal scroll through cosmic-text.**
|
||||||
|
`Scroll::horizontal` is discarded throughout, because **glyphon 0.11
|
||||||
|
never applies it when placing glyphs** — documented at
|
||||||
|
`pmacs-gpu/src/main.rs:1611`, `:6316`, `:8020` and asserted by tests
|
||||||
|
at `:16266`, `:16337`, `:16737`. The GPU's Stage 4 half needs a
|
||||||
|
different mechanism entirely. **This is the fact most likely to
|
||||||
|
invert the cost estimate**, exactly as the "both frontends consume
|
||||||
|
the same `CellGrid`" error did in Stage 3 revision 1.
|
||||||
|
- **`view_top` is persisted per leaf** in `SavedLeaf` alongside
|
||||||
|
`cursor`, at `DESKTOP_VERSION = 1` (`src/desktop.rs:33`). A
|
||||||
|
`view_left` that survives a restart needs either a defaulted field or
|
||||||
|
a version bump — a decision, not an afterthought.
|
||||||
|
- **`scroll_window` carries the cursor with the scroll** to defeat the
|
||||||
|
renderer's "auto-scroll to keep cursor visible" pass, whose comment
|
||||||
|
already records the hazard: an unconditional snap-back makes explicit
|
||||||
|
scrolling feel stuck (`src/editor.rs:3624-3628`). A horizontal analog
|
||||||
|
faces the identical problem, and Q#LL3 deferred the choice — drag the
|
||||||
|
cursor, or let the next motion snap back — to this stage.
|
||||||
|
- **`goal_col` is the existing column-memory field**
|
||||||
|
(`src/window.rs:376`, cleared at seven sites in `src/editor.rs`). Its
|
||||||
|
relationship to a horizontal offset is unexamined and is a framing
|
||||||
|
question, not an implementation detail.
|
||||||
|
|
||||||
### The two decisions most likely to be questioned later
|
### Gate note this lane inherits
|
||||||
|
|
||||||
- **GUI users lose word wrap.** Character-wrap parity is cheap and
|
Stage 3 put eight broken version assertions on CI by running
|
||||||
Emacs-consistent, but the GPU has word-wrapped since it existed.
|
`CLAUDE.md`'s short gate list instead of `docs/agent-handoff.md` §3's,
|
||||||
Accepted deliberately (user, 2026-08-06). **Must appear in the PR
|
which includes a full sweep. **§3 is the authority.**
|
||||||
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)
|
**Stage 4 should not need a protocol bump at all** — Q#HS1 puts the GPU
|
||||||
|
in Stage 5, and `view_left` is per-window TUI state with no wire. If
|
||||||
|
that changes, §3's protocol-bump form is
|
||||||
|
`cargo test --workspace --no-fail-fast -- --skip basedpyright` in
|
||||||
|
**both** feature configurations.
|
||||||
|
|
||||||
Everything framing §7 sketched, plus three groups it did not anticipate
|
**`--workspace`, not `--tests`**, and the distinction is not cosmetic:
|
||||||
(framing §7.1).
|
`--tests` selects 108 targets where `--workspace` selects 110, and the
|
||||||
|
two it drops are **`pmacs_protocol` and `pmacs_gpu`**. Stage 3's own
|
||||||
**The gate list below is the one that FAILED to catch this lane's CI
|
*remediation* sweep used `--tests`, so it never ran `pmacs-protocol`'s
|
||||||
red, and it is kept only to show the hole.** "The touched acceptance
|
25 tests — including the `scroll::classify` tests that lane had just
|
||||||
suites" is selected from the diff, and a `PROTOCOL_VERSION` bump breaks
|
written. They passed, but by luck, and a correction that reproduces the
|
||||||
version-assertion tests that appear nowhere in it. Five failed on CI's
|
shape of its own mistake is worth naming.
|
||||||
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
|
### Not in scope
|
||||||
|
|
||||||
Horizontal scroll in full (Stage 4). `M-q` / auto-fill / reflow. Word
|
`M-q` / auto-fill / reflow. Word wrap as a mode value — a named future
|
||||||
wrap as a mode value — a named future third choice, not this stage.
|
third choice. Bidi/RTL. Soft-wrap gutter indicators.
|
||||||
Bidi/RTL. Soft-wrap gutter indicators.
|
|
||||||
|
|
||||||
## Tree primitive (P5) — MERGED as #217; adoption is the open work
|
## Tree primitive (P5) — MERGED as #217; adoption is the open work
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,66 @@ reads it the way you just did.
|
||||||
For volatile branches, checkpoints, verification, and recovery
|
For volatile branches, checkpoints, verification, and recovery
|
||||||
commands, read `docs/active-work.md` immediately after this file.
|
commands, read `docs/active-work.md` immediately after this file.
|
||||||
|
|
||||||
## 1. Where the project stands (2026-08-06)
|
## 1. Where the project stands (2026-08-07)
|
||||||
|
|
||||||
|
- **QoL arc — Stages 1-3 merged (#219, #220, #221); Stages 4 AND 5
|
||||||
|
remain, and the arc closes at Stage 5.** From one daily-driver
|
||||||
|
report: terminal zoom broke TUI rendering and did nothing in the GUI,
|
||||||
|
and a long line was unreadable past the edge.
|
||||||
|
- **#219** made the grid TUI honor `full_grid`, so a post-resize
|
||||||
|
resync blanks the host before repainting.
|
||||||
|
- **#220** gave the GUI native zoom over the font preference that
|
||||||
|
already existed, quantizing the step so the round-trip guarantee is
|
||||||
|
exact rather than approximately true.
|
||||||
|
- **#221** added **`ui.line-wrap`** — `ConfigKind::Enum`
|
||||||
|
(`wrap`/`truncate`), default `wrap`, **buffer-local**, with
|
||||||
|
`ui.toggle-line-wrap`. Both frontends honor it: the grid renderer
|
||||||
|
through `Viewport`, the GPU through
|
||||||
|
**`InstanceMessage::LineWrapFacts` at protocol v22**, resent on
|
||||||
|
attach, config change, **and buffer switch** (the mode is per
|
||||||
|
buffer, so a `FontFacts`-shaped global design is silently wrong).
|
||||||
|
`ADVERTISED_PROTOCOL_VERSION` stays pinned at 20.
|
||||||
|
|
||||||
|
Three durable consequences:
|
||||||
|
- **GUI users lost word wrap.** The GPU document buffer's first
|
||||||
|
explicit `set_wrap` is `Wrap::Glyph`. Character wrap is what the
|
||||||
|
grid can implement identically without UAX #14; a whitespace
|
||||||
|
approximation of cosmic-text's real breaking was judged worse
|
||||||
|
than honest divergence. Word wrap is a clean additive third
|
||||||
|
choice.
|
||||||
|
- **`pmacs-protocol::scroll`** owns `ScrollPosition` and a pure
|
||||||
|
`classify(first_visible, last_visible, byte_pos, byte_len)`.
|
||||||
|
`pmacs-gpu` depends on `pmacs-protocol` and never on the `pmacs`
|
||||||
|
lib, so the status readout had been duplicated *structurally* —
|
||||||
|
and during this lane's own review a fix landed in one copy while
|
||||||
|
the other kept reporting `All` for a wrapped one-line buffer.
|
||||||
|
Each frontend supplies local layout facts and renders the string;
|
||||||
|
the shared crate owns the decision. No wire message, no version
|
||||||
|
bump. **Under `truncate` the old line-space formatter is
|
||||||
|
untouched**, so its output is identical by construction.
|
||||||
|
- **The GPU answers "is this byte on screen?" with
|
||||||
|
`code_byte_painted`** — `code_byte_px` intersected with the
|
||||||
|
drawable clip. Neither `view_range` (it carries
|
||||||
|
`SCROLL_OVERSCAN` past the window) nor `scroll_top` (it ignores
|
||||||
|
`code_scroll_residual`) can answer it.
|
||||||
|
- **Stage 4 is horizontal scroll in the TUI; Stage 5 is the GPU**, a
|
||||||
|
split decided rather than inherited (framing Q#HS1) and time-boxed:
|
||||||
|
Stage 5 is the immediately-next QoL lane after Stage 4 merges, and
|
||||||
|
`wrap` stays the default until it lands — which is what keeps the
|
||||||
|
divergence invisible to anyone who has not opted in.
|
||||||
|
|
||||||
|
`truncate` is incomplete without scroll: text past the right edge is
|
||||||
|
currently *unreachable*. **That caveat is NOT in the setting's
|
||||||
|
description** — `builtin/runtime/linewrap.lua:23` says only
|
||||||
|
"truncate at the edge", and the word appears in
|
||||||
|
`ui.toggle-line-wrap`'s status message and a source comment, which
|
||||||
|
a user who sets the mode in `init.lua` never sees. A small
|
||||||
|
user-facing gap shipped in #221; amending the description is a
|
||||||
|
Stage 4 deliverable.
|
||||||
|
|
||||||
|
**Rule 4 must not retire the long-lines lane when Stage 4 merges** —
|
||||||
|
the arc closes at Stage 5. Framing in
|
||||||
|
`docs/horizontal-scroll-framing.md`.
|
||||||
|
|
||||||
- **`main` @ `db1bbe9`.** The **tree primitive #217** — `listview` rows
|
- **`main` @ `db1bbe9`.** The **tree primitive #217** — `listview` rows
|
||||||
take optional `depth`/`id`, collapse is primitive-owned, folding is
|
take optional `depth`/`id`, collapse is primitive-owned, folding is
|
||||||
|
|
@ -405,20 +464,31 @@ someone forgot.
|
||||||
failure, so **check `pgrep -f "pmacs --daemon"` before trusting a
|
failure, so **check `pgrep -f "pmacs --daemon"` before trusting a
|
||||||
local red**. Lane recorded in `docs/active-work.md`.
|
local red**. Lane recorded in `docs/active-work.md`.
|
||||||
- **A `PROTOCOL_VERSION` bump's blast radius is every version-sensitive
|
- **A `PROTOCOL_VERSION` bump's blast radius is every version-sensitive
|
||||||
test, and NONE of them appear in the diff.** "The touched acceptance
|
test, and NONE of them appear in the diff.** Long-lines Stage 3
|
||||||
suites" is the standing gate, and for a protocol bump it is the wrong
|
bumped v21→v22 and broke **eight** version assertions across six
|
||||||
selector: long-lines Stage 3 bumped v21→v22, ran the suites it had
|
suites. CI showed exactly **one**, because **cargo stops at the first
|
||||||
edited, and broke **eight** version assertions across six suites. CI
|
failing target**; the rest surfaced only afterwards, and one at a
|
||||||
showed exactly **one**, because **cargo stops at the first failing
|
time would have cost four more red rounds.
|
||||||
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
|
**The first thing to say is that §3's gate list would have caught
|
||||||
BOTH feature configurations.** `--no-fail-fast` because of the
|
it.** `cargo test --workspace -- --skip basedpyright` is in that list
|
||||||
stop-at-first-target behavior above, and `--features crdt` because
|
and was not run — `CLAUDE.md` carries a shorter list ending at "the
|
||||||
three of the eight were in crdt-gated real-daemon tests that assert
|
touched acceptance suites", and that shorter list is what the lane
|
||||||
on a live socket's negotiated version — invisible to a default sweep,
|
followed. **When the two disagree, §3 is the authority**; the short
|
||||||
which is the blindness the bullet below already names.
|
form is a summary, and a summary of a gate suite is not a gate suite.
|
||||||
|
|
||||||
|
**But §3's sweep alone would still have understated it**, which is
|
||||||
|
why this bullet exists rather than just a pointer. Plain
|
||||||
|
`--workspace` stops at the first failing target and builds one
|
||||||
|
feature configuration, so it would have shown one or two of the
|
||||||
|
eight. §3 now carries the strengthened form: **`--workspace
|
||||||
|
--no-fail-fast -- --skip basedpyright` in BOTH configurations.**
|
||||||
|
|
||||||
|
**Note `--workspace`, not `--tests`** — the lane's own remediation
|
||||||
|
sweep used `--tests`, which silently drops the `pmacs_protocol` and
|
||||||
|
`pmacs_gpu` targets. On a *protocol* bump that omits the protocol
|
||||||
|
crate's tests, which is how a correction can reproduce the shape of
|
||||||
|
the mistake it is correcting.
|
||||||
|
|
||||||
**Sort the failures before fixing them.** A *tripwire*
|
**Sort the failures before fixing them.** A *tripwire*
|
||||||
(`assert_eq!(PROTOCOL_VERSION, N)`) is meant to fire and takes a
|
(`assert_eq!(PROTOCOL_VERSION, N)`) is meant to fire and takes a
|
||||||
|
|
@ -2065,6 +2135,40 @@ cargo test --workspace -- --skip basedpyright # full sweep
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**The full sweep is not optional, and `CLAUDE.md`'s shorter list is not
|
||||||
|
a substitute.** That list stops at "the touched acceptance suites";
|
||||||
|
this one continues. Long-lines Stage 3 followed the short form and put
|
||||||
|
eight broken version assertions on CI. When the two disagree, **this
|
||||||
|
list wins**.
|
||||||
|
|
||||||
|
**Touching `PROTOCOL_VERSION` STRENGTHENS the sweep line. It does not
|
||||||
|
replace it:**
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo test --workspace --no-fail-fast -- --skip basedpyright
|
||||||
|
cargo test --workspace --features crdt --no-fail-fast -- --skip basedpyright
|
||||||
|
```
|
||||||
|
|
||||||
|
Every part is load-bearing:
|
||||||
|
|
||||||
|
- **`--workspace`, never `--tests`.** `--tests` selects 108 targets
|
||||||
|
where `--workspace` selects 110, and the two it drops are
|
||||||
|
**`pmacs_protocol` and `pmacs_gpu`**. On a protocol bump, dropping
|
||||||
|
the protocol crate's own unit tests is precisely the wrong loss.
|
||||||
|
Long-lines Stage 3 swept with `--tests` and so never ran
|
||||||
|
`pmacs-protocol`'s 25 tests — including the `scroll::classify` tests
|
||||||
|
that same lane had just written. They passed, but by luck.
|
||||||
|
- **`--no-fail-fast`**, because cargo stops at the first failing
|
||||||
|
target: without it a bump that breaks eight assertions reports one.
|
||||||
|
- **`--features crdt`**, because crdt-gated real-daemon tests assert on
|
||||||
|
a live socket's negotiated version and are invisible otherwise.
|
||||||
|
- **`-- --skip basedpyright`** for the same reason the line above
|
||||||
|
carries it.
|
||||||
|
|
||||||
|
See §5's protocol-bump bullet for how to sort the failures this finds —
|
||||||
|
some are tripwires doing their job, and one pin
|
||||||
|
(`ADVERTISED_PROTOCOL_VERSION`) must never be edited at all.
|
||||||
|
|
||||||
Machine-specific caveats — re-verify on a machine you haven't used
|
Machine-specific caveats — re-verify on a machine you haven't used
|
||||||
before trusting them:
|
before trusting them:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -423,6 +423,28 @@ without a name there is nothing to call intermittent.
|
||||||
with `grep -E "FAILED|panicked|test result"`, which keeps failure
|
with `grep -E "FAILED|panicked|test result"`, which keeps failure
|
||||||
context, or capture the full log to a file and summarize from it.
|
context, or capture the full log to a file and summarize from it.
|
||||||
|
|
||||||
|
### R7 — managed-retry attach hits a broken pipe under full-sweep load
|
||||||
|
|
||||||
|
The first incident this session with a **complete** signature, so it is
|
||||||
|
a matchable row rather than a `U` note. Recorded during long-lines
|
||||||
|
Stage 4; the lane touches no `pmacs-gpu` code at all.
|
||||||
|
|
||||||
|
| field | value |
|
||||||
|
|---|---|
|
||||||
|
| **selector** | `-p pmacs-gpu attach::tests::managed_retry_survives_transients_and_uses_the_successful_stream` |
|
||||||
|
| **job / flavor** | local (Linux), `cargo test --workspace --features crdt --no-fail-fast`, i.e. under full-sweep load |
|
||||||
|
| **required fragments** | `transient sequence must attach` + `Handshake(Io(` + `BrokenPipe` (or `code: 32`) |
|
||||||
|
| **status** | **new incident, unreproduced — causal status UNRESOLVED** |
|
||||||
|
| **what IS established** | one occurrence at `pmacs-gpu/src/attach.rs:1680`; the test drives a scripted transient-then-success sequence over a real socket pair |
|
||||||
|
| **what is NOT** | whether the broken pipe is the *fixture's* writer closing early or a real retry-path defect. **This row is not a claim that it is harmless** |
|
||||||
|
| **rerun evidence** | 6 isolated runs green, plus a full `--workspace --features crdt` sweep green (113 targets). Per the rerun rule this establishes **intermittence only** |
|
||||||
|
| **retirement** | hardening that removes the named mechanism plus a discriminating witness — or a diagnosis showing the fixture, not the code, closes the pipe |
|
||||||
|
|
||||||
|
**Not attributed to this lane**, and the reasoning is not merely "my
|
||||||
|
diff looks unrelated": Stage 4 adds no wire surface, no protocol
|
||||||
|
version change, and touches no file in `pmacs-gpu`. A merge-base
|
||||||
|
control would settle it if this recurs.
|
||||||
|
|
||||||
### U2 — `m6_1_pty_raw_mode_disables_kernel_echo`, one local occurrence
|
### 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
|
Has a selector, which U1 lacks — but still no fragments, so it cannot
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,481 @@
|
||||||
|
# Horizontal scroll — QoL Stage 4
|
||||||
|
|
||||||
|
**Status: revision 4 — APPROVED 2026-08-07. Every question answered.
|
||||||
|
Implementation may begin within this scope.**
|
||||||
|
|
||||||
|
> *"Q#HS7's per-line effective edge and preserved tab-forward mapping
|
||||||
|
> are coherent and fully specified. Q#HS5 is approved with the required
|
||||||
|
> serde default and literal-v1-fixture conditions. The Stage 4/Stage 5
|
||||||
|
> split and Rule 4 protection are now durably reflected in the handoff
|
||||||
|
> and ledger."*
|
||||||
|
|
||||||
|
| question | state |
|
||||||
|
|---|---|
|
||||||
|
| Q#HS1 — GPU in scope? | **answered**: no, Stage 5, time-boxed (§3) |
|
||||||
|
| Q#HS2 — what moves the viewport? | **answered**: automatic only |
|
||||||
|
| Q#HS3 — window or buffer? | re-confirmed: per window |
|
||||||
|
| Q#HS4 — cursor follows explicit scroll? | **deferred** — not live under HS2 |
|
||||||
|
| Q#HS5 — persist `view_left`? | **approved**: yes, no version bump, on two conditions |
|
||||||
|
| Q#HS6 — the default | **answered**: `wrap` stays |
|
||||||
|
| Q#HS7 — what IS `view_left`? | **accepted**: (a), (b), (c′), (c″), (d) |
|
||||||
|
|
||||||
|
Revision 4 adds only Q#HS7(c″) — the tab-straddle mapping — and fixes
|
||||||
|
the handoff's "Stage 4 is the remainder" to name Stages 4–5.
|
||||||
|
|
||||||
|
**Stage 4 does NOT close the QoL arc.** Revision 1 said it did, and
|
||||||
|
that was written before Q#HS1 moved GPU horizontal scroll to Stage 5.
|
||||||
|
The claim is not merely stale — it is load-bearing in the wrong
|
||||||
|
direction: `docs/active-work.md`'s **Rule 4 removes a lane when its
|
||||||
|
ARC is done**, so a framing asserting Stage 4 closes the arc would
|
||||||
|
license retiring this lane at the TUI merge, **orphaning the very
|
||||||
|
Stage 5 that Q#HS1's time box exists to guarantee**. The arc closes at
|
||||||
|
**Stage 5**.
|
||||||
|
|
||||||
|
Stage 1 (#219) made the TUI survive terminal zoom; Stage 2 (#220) gave
|
||||||
|
the GUI native zoom; Stage 3 (#221) added `ui.line-wrap` and made
|
||||||
|
`wrap` the default. Stage 4 is the TUI half of the other half of the
|
||||||
|
user's own sentence:
|
||||||
|
|
||||||
|
> long lines need to either **wrap somehow or be scrollable**. […] This
|
||||||
|
> should also be something that the user can configure, whether to wrap
|
||||||
|
> or scrollable.
|
||||||
|
|
||||||
|
Stage 3 shipped the *mode*. It did not ship the *navigation*: under
|
||||||
|
`truncate`, text past the right edge is not merely off-screen but
|
||||||
|
**unreachable**.
|
||||||
|
|
||||||
|
**Revision 1 claimed that caveat "is recorded in the setting's
|
||||||
|
description". It is not.** `builtin/runtime/linewrap.lua:23` says only
|
||||||
|
*"How a line wider than the window is shown: wrap onto following rows,
|
||||||
|
or truncate at the edge."* The word "unreachable" appears in
|
||||||
|
`ui.toggle-line-wrap`'s status message and in a source comment — neither
|
||||||
|
of which a user sees if they set `ui.line-wrap = "truncate"` in
|
||||||
|
`init.lua` and never invoke the toggle. **That is a real, if small,
|
||||||
|
user-facing gap shipped in #221**, and §6 makes amending the
|
||||||
|
description a Stage 4 deliverable rather than leaving the false claim
|
||||||
|
standing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What is actually there today
|
||||||
|
|
||||||
|
**Verified in the tree at `02f3ec3`, not recalled.** Stage 3's revision
|
||||||
|
1 inverted its whole cost model by assuming both frontends consumed the
|
||||||
|
same `CellGrid`; every claim below carries a citation for that reason.
|
||||||
|
|
||||||
|
### 1.1 There is no horizontal scroll anywhere
|
||||||
|
|
||||||
|
No `view_left`, `scroll_left`, or `hscroll` in `src/` or `builtin/`.
|
||||||
|
The window carries `view_top`, `cursor`, and `goal_col`
|
||||||
|
(`src/window.rs:374-376`) and nothing horizontal. This is greenfield —
|
||||||
|
not "extend the vertical mechanism sideways", because there is no
|
||||||
|
shared abstraction to extend.
|
||||||
|
|
||||||
|
### 1.2 The grid walk starts every line at column 0
|
||||||
|
|
||||||
|
`paint_line` (`src/text_view.rs:266`) walks from the line's first
|
||||||
|
character with no offset parameter, exactly as before Stage 3 —
|
||||||
|
wrapping changed *where rows break*, not *where the walk starts*.
|
||||||
|
`place_of_byte` and `byte_at_place` have the same shape.
|
||||||
|
|
||||||
|
So `view_left` enters the same functions Stage 3 just rewrote, and
|
||||||
|
**the wrap rule must stay written exactly once** (`advance_wrapped`,
|
||||||
|
`src/text_view.rs:396`). A second copy differing by an offset is the
|
||||||
|
defect Stage 3 spent its review budget avoiding.
|
||||||
|
|
||||||
|
### 1.3 The GPU cannot use cosmic-text's horizontal scroll
|
||||||
|
|
||||||
|
**The finding most likely to invert the cost estimate, so it leads.**
|
||||||
|
|
||||||
|
`Scroll::horizontal` is discarded throughout the GPU, and not by
|
||||||
|
oversight: **glyphon 0.11 never applies it when placing glyphs.**
|
||||||
|
Documented at `pmacs-gpu/src/main.rs:1611`, `:6316`, `:8020`, and
|
||||||
|
*asserted* by tests at `:16266` (`"horizontal is discarded"`), `:16337`,
|
||||||
|
`:16737`.
|
||||||
|
|
||||||
|
The GPU's half therefore cannot be "set the scroll and reshape". It
|
||||||
|
needs a mechanism that does not exist — a shifted text origin at paint
|
||||||
|
time, adjusted clip bounds, or something else — interacting correctly
|
||||||
|
with the gutter, the caret (`code_byte_px`), decoration geometry
|
||||||
|
(`push_glyph_extent_rects`), and hit testing (`gutter_aware_rel_x`),
|
||||||
|
each of which assumes x starts at `text_left()`.
|
||||||
|
|
||||||
|
**Answered in Q#HS1: the GPU is Stage 5.**
|
||||||
|
|
||||||
|
### 1.4 `view_top` is persisted; a `view_left` would want to be
|
||||||
|
|
||||||
|
`SavedLeaf` carries `path`, `cursor`, and `view_top` at
|
||||||
|
`DESKTOP_VERSION = 1` (`src/desktop.rs:33`, `:276-280`); the restore
|
||||||
|
path clamps `view_top` against the line count (`:512`). **Q#HS5.**
|
||||||
|
|
||||||
|
### 1.5 The cursor-follow hazard is already documented
|
||||||
|
|
||||||
|
`scroll_window` (`src/editor.rs:3628`) carries the cursor with a
|
||||||
|
vertical scroll, and its comment says why:
|
||||||
|
|
||||||
|
> The cursor must follow the scroll: the renderer has an "auto-scroll
|
||||||
|
> to keep cursor visible" pass that would otherwise snap `view_top`
|
||||||
|
> straight back to wherever the cursor sits, so the user's mouse-wheel
|
||||||
|
> scroll would feel stuck after one notch.
|
||||||
|
|
||||||
|
Under Q#HS2's answer (automatic-only) this pass is not a hazard but
|
||||||
|
**the entire mechanism** — Stage 4 adds its horizontal component. The
|
||||||
|
hazard returns with explicit commands, which is why Q#HS4 is deferred
|
||||||
|
rather than closed.
|
||||||
|
|
||||||
|
### 1.6 `goal_col` exists and its relationship to `view_left` is unexamined
|
||||||
|
|
||||||
|
`goal_col` (`src/window.rs:376`) remembers a target column across
|
||||||
|
vertical motion, cleared at seven sites in `src/editor.rs`. It is a
|
||||||
|
*column within the line*; `view_left` is a *viewport offset*. Both are
|
||||||
|
horizontal state on the same window, and a design ignoring the
|
||||||
|
interaction produces a cursor that jumps on the first vertical motion
|
||||||
|
after a horizontal scroll. Feeds **Q#HS7**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The scope fact, stated before the answers
|
||||||
|
|
||||||
|
**Under `wrap`, horizontal scroll is meaningless** — nothing sits past
|
||||||
|
the right edge. So Stage 4's entire surface is conditional on a
|
||||||
|
buffer-local mode: one user-facing question ("how do I see the rest of
|
||||||
|
this line?") gets two disjoint answers depending on a setting. Stated
|
||||||
|
here because `COHERENCE.md` §20 requires it, and answered in Q#HS6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Questions
|
||||||
|
|
||||||
|
### Q#HS1 — is the GPU in scope? **ANSWERED: no — Stage 5**
|
||||||
|
|
||||||
|
> **Answered 2026-08-07 (user):** split the GPU work into Stage 5.
|
||||||
|
> *"This is a conscious, bounded divergence, not a repeat of Stage 3's
|
||||||
|
> accidental one. Make the time box concrete and keep `wrap` default
|
||||||
|
> until parity lands."*
|
||||||
|
|
||||||
|
The distinction is the load-bearing part. Stage 3's defect was never
|
||||||
|
"the frontends differ" — it was "the frontends differ and **nobody
|
||||||
|
chose that**". A divergence that is decided, recorded, and bounded is a
|
||||||
|
different object from one inherited from a library default.
|
||||||
|
|
||||||
|
**The time box, concrete** (the user's requirement, and the part that
|
||||||
|
makes this a decision rather than a deferral):
|
||||||
|
|
||||||
|
1. **Stage 5 is the immediately-next QoL lane after Stage 4 merges** —
|
||||||
|
not backlogged behind another arc. If something displaces it, that
|
||||||
|
displacement is itself a decision to record here.
|
||||||
|
2. **`wrap` stays the default until Stage 5 lands** (independently
|
||||||
|
reaffirmed in Q#HS6). This is what keeps the divergence invisible to
|
||||||
|
anyone who has not opted in: a default-configuration user is never
|
||||||
|
exposed to it.
|
||||||
|
3. **Stage 4's release notes must state the asymmetry** — horizontal
|
||||||
|
scroll works in the TUI and not yet the GUI — in the same way #221's
|
||||||
|
had to state the word-wrap loss.
|
||||||
|
4. **While the gap exists, the `truncate` affordances must name it.**
|
||||||
|
§6's description amendment is where that lands, so a GUI user
|
||||||
|
choosing `truncate` learns the limitation from the setting rather
|
||||||
|
than from the behavior.
|
||||||
|
|
||||||
|
### Q#HS2 — what moves the viewport? **ANSWERED: automatic only**
|
||||||
|
|
||||||
|
> **Answered 2026-08-07 (user):** *"Automatic-only first. It makes the
|
||||||
|
> report's text reachable with no new command surface."*
|
||||||
|
|
||||||
|
The cursor-visibility pass gains a horizontal component, so moving the
|
||||||
|
cursor past the edge scrolls the view. No new commands, no binding
|
||||||
|
decisions, no new interaction island.
|
||||||
|
|
||||||
|
Explicit `ui.scroll-left` / `ui.scroll-right` are **not** in Stage 4.
|
||||||
|
They can follow with evidence of use, and they are what re-opens Q#HS4.
|
||||||
|
|
||||||
|
### Q#HS3 — per window or per buffer? **NOT ACTUALLY OPEN**
|
||||||
|
|
||||||
|
`view_left` is **per window**, unambiguously: two panes on one buffer
|
||||||
|
must scroll independently, exactly as they already hold independent
|
||||||
|
`view_top`s (`src/desktop.rs:92`). Stage 3's Q#LL2 recorded this and
|
||||||
|
accepted the consequence — the *mode* is buffer-local while the
|
||||||
|
*offset* is per-window, so one user-facing concept spans two scopes.
|
||||||
|
|
||||||
|
Listed so the accepted split is re-confirmed where it takes effect
|
||||||
|
rather than inherited silently.
|
||||||
|
|
||||||
|
### Q#HS4 — does the cursor follow an explicit scroll? **DEFERRED, not answered**
|
||||||
|
|
||||||
|
Not live under Q#HS2's answer: automatic-only means every viewport move
|
||||||
|
already originates from a cursor move. §1.5 holds the precedent and the
|
||||||
|
hazard for whenever explicit commands arrive.
|
||||||
|
|
||||||
|
Deferring rather than deleting, because the hazard is real and
|
||||||
|
rediscovering it costs more than carrying the paragraph.
|
||||||
|
|
||||||
|
### Q#HS5 — does `view_left` survive a restart? **APPROVED: yes**
|
||||||
|
|
||||||
|
> **Approved 2026-08-07 (user):** persist `view_left` **without** a
|
||||||
|
> desktop-version bump, **provided implementation adds
|
||||||
|
> `#[serde(default)]` and a literal v1 JSON fixture omitting the field,
|
||||||
|
> asserting restoration at zero.** Both conditions are part of the
|
||||||
|
> approval, not advice attached to it.
|
||||||
|
|
||||||
|
`view_top` does (§1.4). Consistency argues yes.
|
||||||
|
|
||||||
|
**Verified, not assumed:** `SavedLeaf` is a plain
|
||||||
|
`#[derive(Serialize, Deserialize)]` (`src/desktop.rs:85`) with **no
|
||||||
|
`#[serde(default)]` on any field and none anywhere in the file**. So
|
||||||
|
serde will **reject** a version-1 desktop JSON that omits a newly added
|
||||||
|
`view_left` — a missing field is a deserialization error, not a zero.
|
||||||
|
Revision 2 cited the struct's shape as though it settled serde's
|
||||||
|
behavior on it; it did not.
|
||||||
|
|
||||||
|
**"Yes, persisted, no `DESKTOP_VERSION` bump" is sound only with both
|
||||||
|
of:**
|
||||||
|
|
||||||
|
1. **`#[serde(default)]` on the new field.** This is the whole of the
|
||||||
|
new-binary-reads-old-file direction.
|
||||||
|
2. **A regression fixture**: a literal version-1 desktop JSON with no
|
||||||
|
`view_left`, deserialized in a test, asserting it restores at offset
|
||||||
|
0 rather than erroring. Without this, (1) is an untested claim about
|
||||||
|
a crate's behavior — which is precisely the failure this question
|
||||||
|
was reopened for.
|
||||||
|
|
||||||
|
**The other direction already works, and that is why no bump is
|
||||||
|
needed.** An old binary reading a new file passes the version check
|
||||||
|
(`version` is still 1, `src/desktop.rs:366`) and then meets an unknown
|
||||||
|
`view_left` field — which serde **ignores** by default, and
|
||||||
|
`src/desktop.rs` sets no `deny_unknown_fields` anywhere (verified). So
|
||||||
|
both directions are safe at `DESKTOP_VERSION = 1` **given (1)**, and
|
||||||
|
neither is safe without it.
|
||||||
|
|
||||||
|
### Q#HS6 — the coherence statement **ANSWERED: keep `wrap` default**
|
||||||
|
|
||||||
|
> **Answered 2026-08-07 (user):** *"Keep `wrap` as default for now.
|
||||||
|
> Revisit only after GPU parity and use evidence; changing to
|
||||||
|
> `truncate` before Stage 5 would make the default unreachable in the
|
||||||
|
> GUI."*
|
||||||
|
|
||||||
|
That last clause is the argument revision 1 missed. I had framed this
|
||||||
|
as "if scroll makes `truncate` good, the default deserves
|
||||||
|
re-examination" — but with the GPU deferred to Stage 5, a `truncate`
|
||||||
|
default would ship a mode that is **navigable in the TUI and a dead end
|
||||||
|
in the GUI**, for every user who never opened the setting. Q#HS1 and
|
||||||
|
Q#HS6 are therefore coupled: the split is only safe *because* the
|
||||||
|
default does not move.
|
||||||
|
|
||||||
|
Revisit after Stage 5, on use evidence, not before.
|
||||||
|
|
||||||
|
### Q#HS7 — what IS `view_left`? **ACCEPTED (revision 4)**
|
||||||
|
|
||||||
|
> **Accepted 2026-08-07 (user):** *"keep `view_left` as an unsnapped
|
||||||
|
> window display column; derive the effective edge per line; render a
|
||||||
|
> bisected wide glyph's trailing cell as styled blank and designate it
|
||||||
|
> to the glyph start. The multi-line discriminating witness is exactly
|
||||||
|
> right."* Plus (c″) below, on the user's recommendation.
|
||||||
|
|
||||||
|
**Revision 1 decided what moves the viewport without ever saying what
|
||||||
|
the viewport offset is.** That is the same omission Stage 3 would have
|
||||||
|
made had it shipped `WrapMode` without `DisplayCoord`: the mode is
|
||||||
|
useless until the coordinate contract is written down, and the contract
|
||||||
|
is where every sharp edge lives.
|
||||||
|
|
||||||
|
Revision 1's verification sketch named tabs and wide characters. **It
|
||||||
|
had no oracle for either**, because nothing defined what a left edge
|
||||||
|
is. Four things must be settled together:
|
||||||
|
|
||||||
|
**(a) The unit.** Candidates: a source byte offset within the line; a
|
||||||
|
display column (cells from the line start, after tab expansion); or a
|
||||||
|
cell boundary with an explicit validity rule.
|
||||||
|
|
||||||
|
*My vote: display column.* Tab expansion depends on the absolute column
|
||||||
|
from the line start, so the walk must begin at column 0 and compute
|
||||||
|
forward **regardless** of the offset — which makes a byte offset buy
|
||||||
|
nothing and lose tab correctness. Starting at 0 and suppressing paint
|
||||||
|
until `col >= view_left` preserves tab stops **for free**, and costs no
|
||||||
|
more than `paint_line` already pays under wrapping.
|
||||||
|
|
||||||
|
**(b) What happens at a left edge that bisects a glyph.** A tab
|
||||||
|
straddling the edge is unambiguous: its expansion is width-1 spaces, so
|
||||||
|
the remaining ones paint. **A wide (width-2) glyph is not** — a grid
|
||||||
|
cannot paint half of one.
|
||||||
|
|
||||||
|
**(c) ~~The snap rule when an invalid edge is requested.~~ WITHDRAWN
|
||||||
|
(revision 3).**
|
||||||
|
|
||||||
|
> Revision 2 voted *"a left edge may not fall inside a wide glyph"* plus
|
||||||
|
> *"snap toward the line start, at the moment `view_left` is set"*.
|
||||||
|
> **That cannot hold, and the reason is structural rather than a detail
|
||||||
|
> to tune.**
|
||||||
|
>
|
||||||
|
> `view_left` is **one** per-window display column. "Does column N
|
||||||
|
> bisect a wide glyph?" is a **per-line** question: column 11 can be a
|
||||||
|
> wide glyph's trailing cell on line 3 and an ordinary ASCII cell on
|
||||||
|
> line 4. **No single setter-time value is canonical for every visible
|
||||||
|
> line**, so a snap performed once is simply wrong for most of them —
|
||||||
|
> and the invariant in (d), which the whole question exists to serve,
|
||||||
|
> would stay undefined exactly where it matters.
|
||||||
|
>
|
||||||
|
> Snapping *per line* is the other way to read it, and it is worse: the
|
||||||
|
> same source column would then appear at different screen columns on
|
||||||
|
> different rows, destroying the vertical alignment that a
|
||||||
|
> column-oriented view exists to provide.
|
||||||
|
|
||||||
|
**(c′) The per-line effective edge, which replaces it.**
|
||||||
|
|
||||||
|
`view_left` is stored **unsnapped** — the requested display column,
|
||||||
|
constrained only to `>= 0` and whatever maximum the design picks. Each
|
||||||
|
line derives its own **effective edge** during the walk it already
|
||||||
|
performs from column 0.
|
||||||
|
|
||||||
|
When the requested edge bisects a wide glyph *on this line*, that
|
||||||
|
glyph's trailing cell is the leftmost visible cell. It cannot be
|
||||||
|
painted as half a glyph, so:
|
||||||
|
|
||||||
|
- **It paints as a space**, carrying the glyph's own cell style.
|
||||||
|
- **The mapping designates that cell to the wide glyph's START byte.**
|
||||||
|
|
||||||
|
Both halves are load-bearing, and the second is the part the finding
|
||||||
|
correctly says was missing:
|
||||||
|
|
||||||
|
- The cell visually belongs to that character, so a click there
|
||||||
|
selecting it is what a user expects.
|
||||||
|
- It keeps `byte_at_place` **total** over visible cells — every painted
|
||||||
|
cell maps to some byte, with no hole at column 0.
|
||||||
|
- It preserves the round trip: `place_of_byte(glyph_start)` reports the
|
||||||
|
straddle and designates cell 0, so `byte_at_place(0) == glyph_start`.
|
||||||
|
|
||||||
|
**And the direction rule `place_of_byte` needs at the left edge:** a
|
||||||
|
byte whose cells lie *entirely* left of the effective edge is **not
|
||||||
|
visible**, and `place_of_byte` must report that rather than clamping to
|
||||||
|
column 0. Clamping would make arbitrarily many bytes share cell 0 and
|
||||||
|
destroy (d). Only the straddling glyph designates cell 0.
|
||||||
|
|
||||||
|
**This is deliberately NOT the mirror of Stage 3's right-edge rule**,
|
||||||
|
and the asymmetry should be stated so nobody "fixes" one to match the
|
||||||
|
other. Under `wrap`, a wide glyph that will not fit at the right edge is
|
||||||
|
pushed to the next row **entirely** (`advance_wrapped`, with its
|
||||||
|
`max_cols >= 2` guard). At the left edge under `truncate` there is no
|
||||||
|
next row to push to, so the blank-plus-designation rule is what the
|
||||||
|
same intent requires here.
|
||||||
|
|
||||||
|
**(c″) A tab whose expansion straddles the edge — PRESERVED, not
|
||||||
|
chosen.**
|
||||||
|
|
||||||
|
Each visible tab-expansion cell maps to **the byte immediately after
|
||||||
|
the tab**. This is not a new rule: it is what `byte_at_place` already
|
||||||
|
does, and its doc comment says so — *"Rounds forward to the next
|
||||||
|
character boundary… matching the unwrapped `display_to_pos`"*
|
||||||
|
(`src/text_view.rs:224`). The walk accumulates `walked` past the tab
|
||||||
|
byte and returns on the *next* character's `start_col`, so every column
|
||||||
|
inside the expansion already yields the post-tab offset
|
||||||
|
(`src/text_view.rs:243-254`).
|
||||||
|
|
||||||
|
So the requirement on Stage 4 is **that horizontal scroll not perturb
|
||||||
|
it**: with the expansion's leading cells scrolled off, the surviving
|
||||||
|
cells must still map post-tab, exactly as they do at offset 0.
|
||||||
|
|
||||||
|
**Why this direction differs from (c′)'s, which is the obvious
|
||||||
|
objection.** A wide glyph's two cells belong to **one character**;
|
||||||
|
forward-rounding its trailing cell would designate it to the *next*
|
||||||
|
character and leave the straddling glyph with **no visible cell mapping
|
||||||
|
to it at all** — unreachable by click precisely when it is the thing
|
||||||
|
the user scrolled toward. A tab's expansion cells are whitespace
|
||||||
|
*between* the tab byte and the next character, and forward-rounding
|
||||||
|
them is already how clicking in indentation lands at the start of the
|
||||||
|
text. Different directions, one principle: **every visible cell is
|
||||||
|
designated to the byte a user would mean by clicking it.**
|
||||||
|
|
||||||
|
With (c′) and (c″) together, the (d) contract is total over visible
|
||||||
|
cells: ordinary character → its own start byte; bisected wide glyph →
|
||||||
|
the glyph's start byte; tab expansion → the byte after the tab.
|
||||||
|
|
||||||
|
**(d) The invariant rendering and coordinate mapping share.** For a
|
||||||
|
given `view_left` **and line**, `byte_at_place` must invert
|
||||||
|
`place_of_byte` on every canonical input, and `paint_line` must place
|
||||||
|
exactly the bytes `place_of_byte` claims. If the painter clips where the
|
||||||
|
mapper does not, **clicks land on the wrong character** — silently, and
|
||||||
|
only for lines wide enough to scroll.
|
||||||
|
|
||||||
|
**"And line" is what (c′) forced**, and it is the whole of that
|
||||||
|
finding: the invariant is not a property of `view_left` alone. It is a
|
||||||
|
property of `(view_left, line)`, because the effective edge is derived
|
||||||
|
per line. A test that fixes one line and sweeps offsets will not see
|
||||||
|
the failure; the oracle has to sweep **lines whose glyph widths differ
|
||||||
|
at the same column**.
|
||||||
|
|
||||||
|
*This is the invariant the verification sketch needs as its oracle*,
|
||||||
|
and it is why Q#HS7 blocks: §4 cannot be written until it exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Verification sketch (depends on Q#HS7)
|
||||||
|
|
||||||
|
- Cell-level tests at several window widths **at non-zero offset** —
|
||||||
|
the case Stage 3's sketch explicitly refused, because "at non-zero
|
||||||
|
offset" was a `view_left` requirement smuggled into a wrap lane.
|
||||||
|
- **A `wrap` control for every claim**, asserting the wrap path is
|
||||||
|
byte-identical with a horizontal offset present. Under `wrap` the
|
||||||
|
offset must be **inert**, not merely harmless.
|
||||||
|
- Round-trip identity for `place_of_byte` / `byte_at_place` at non-zero
|
||||||
|
offset — the Q#HS7(d) invariant, walked exhaustively over a short
|
||||||
|
line rather than sampled, as Stage 3 established.
|
||||||
|
- **The Q#HS7(c′) case**: a wide glyph straddling the left edge — the
|
||||||
|
trailing cell blank, and `byte_at_place` on it returning the glyph's
|
||||||
|
**start** byte.
|
||||||
|
- **The Q#HS7(c″) case**: a tab whose expansion straddles the edge,
|
||||||
|
with every surviving cell still mapping to the byte **after** the
|
||||||
|
tab. This one is a **regression** witness rather than a new claim —
|
||||||
|
`byte_at_place` already behaves this way at offset 0
|
||||||
|
(`src/text_view.rs:224`), so the test asserts scroll did not perturb
|
||||||
|
it, and it should fail if the walk is "optimized" to start at the
|
||||||
|
effective edge instead of column 0.
|
||||||
|
- **A multi-line fixture whose glyph widths DIFFER at the same column**
|
||||||
|
— the case (c′) exists for, and the one a single-line sweep cannot
|
||||||
|
reach. At one `view_left`, one line must take the straddle path and
|
||||||
|
another the ordinary path, with the (d) invariant holding on both.
|
||||||
|
A test that fixes one line and sweeps offsets passes against the
|
||||||
|
withdrawn setter-time snap, which is what makes this the
|
||||||
|
discriminating fixture rather than an extra one.
|
||||||
|
- **A PTY acceptance test for reachability**, following
|
||||||
|
`tests/long_line_readable_acceptance.rs`. That file's `truncate`
|
||||||
|
control currently asserts the tail is **absent** — Stage 4 must
|
||||||
|
update it, and **that update is itself the proof the caveat is
|
||||||
|
gone**.
|
||||||
|
- **No GPU witness in Stage 4** (Q#HS1). Stage 5 owes one that the
|
||||||
|
caret, a decoration, and a hit test all agree with the shifted
|
||||||
|
origin — the three consumers §1.3 names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Coherence impact (§20 requirement)
|
||||||
|
|
||||||
|
- **Journey step 4, "Understand interface — Partial."** Stage 4
|
||||||
|
completes "a line that cannot be read in full" for `truncate` **in
|
||||||
|
the TUI only**. The scorecard row should say so rather than reading
|
||||||
|
as closed.
|
||||||
|
- **§16 Semantic Frontend Architecture.** Q#HS1 *widens* frontend
|
||||||
|
divergence for the duration of the Stage 4→5 gap. Per the answer,
|
||||||
|
this is deliberate and time-boxed, and materially different from
|
||||||
|
Stage 3's inherited accident — but the release notes must say which
|
||||||
|
kind it is, or a reader cannot tell them apart.
|
||||||
|
- **No new interaction island** — automatic-only adds no command
|
||||||
|
surface (Q#HS2).
|
||||||
|
- **Config registry: no new settings expected.** Stage 4 navigates the
|
||||||
|
mode Stage 3 declared. If it needs a setting, that is a signal the
|
||||||
|
design has drifted, not a feature.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. A Stage 4 deliverable that is not scroll
|
||||||
|
|
||||||
|
**Amend `ui.line-wrap`'s description** (`builtin/runtime/linewrap.lua`).
|
||||||
|
Today it says only *"…or truncate at the edge"*, which does not tell a
|
||||||
|
user that the edge is a wall. The honest text depends on where Stage 4
|
||||||
|
lands:
|
||||||
|
|
||||||
|
- **Before Stage 4**, `truncate` means unreachable in both frontends.
|
||||||
|
- **After Stage 4**, it means reachable in the TUI and unreachable in
|
||||||
|
the GUI until Stage 5 (Q#HS1's time box, item 4).
|
||||||
|
- **After Stage 5**, the caveat is gone and the sentence should shrink
|
||||||
|
back.
|
||||||
|
|
||||||
|
Carried here rather than filed elsewhere because it is the one place
|
||||||
|
the arc's user-visible honesty is currently wrong, and revision 1
|
||||||
|
asserted it was already right.
|
||||||
|
|
@ -90,6 +90,20 @@ pub struct SavedLeaf {
|
||||||
pub cursor: u64,
|
pub cursor: u64,
|
||||||
/// First visible source line.
|
/// First visible source line.
|
||||||
pub view_top: usize,
|
pub view_top: usize,
|
||||||
|
/// First visible display column — horizontal scroll (Stage 4).
|
||||||
|
///
|
||||||
|
/// **`#[serde(default)]` is load-bearing, not tidiness.** Nothing
|
||||||
|
/// else in this file carries it, so without it serde would REJECT
|
||||||
|
/// every desktop written before this field existed: a missing field
|
||||||
|
/// is a deserialization error, not a zero. That is why no
|
||||||
|
/// `DESKTOP_VERSION` bump is needed — and why removing this
|
||||||
|
/// attribute would silently orphan every user's saved desktop.
|
||||||
|
///
|
||||||
|
/// The reverse direction needs nothing: an older binary meets an
|
||||||
|
/// unknown field, which serde ignores absent `deny_unknown_fields`
|
||||||
|
/// (this file sets none).
|
||||||
|
#[serde(default)]
|
||||||
|
pub view_left: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serde mirror of [`Orientation`] (which is not itself serde).
|
/// Serde mirror of [`Orientation`] (which is not itself serde).
|
||||||
|
|
@ -277,6 +291,7 @@ pub fn snapshot(core: &EditorCore, session_key: String) -> Option<SavedDesktop>
|
||||||
path: path.display().to_string(),
|
path: path.display().to_string(),
|
||||||
cursor: win.cursor,
|
cursor: win.cursor,
|
||||||
view_top: win.view_top,
|
view_top: win.view_top,
|
||||||
|
view_left: win.view_left,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -384,6 +399,7 @@ struct RestoreLeaf {
|
||||||
window: WindowId,
|
window: WindowId,
|
||||||
cursor: u64,
|
cursor: u64,
|
||||||
view_top: usize,
|
view_top: usize,
|
||||||
|
view_left: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Do the structural rebuild: open buffers, prune the old LOCAL layout,
|
/// Do the structural rebuild: open buffers, prune the old LOCAL layout,
|
||||||
|
|
@ -476,6 +492,11 @@ pub fn restore_into(
|
||||||
if let Some(win) = c.windows.get_mut(&leaf.window) {
|
if let Some(win) = c.windows.get_mut(&leaf.window) {
|
||||||
win.cursor = leaf.cursor;
|
win.cursor = leaf.cursor;
|
||||||
win.view_top = leaf.view_top;
|
win.view_top = leaf.view_top;
|
||||||
|
// Re-applied for the same reason `cursor` and `view_top`
|
||||||
|
// are: `buffer.after-load` can move the window, and the
|
||||||
|
// desktop is authoritative over whatever a hook (saveplace)
|
||||||
|
// did (Q#DS3).
|
||||||
|
win.view_left = leaf.view_left;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
core.borrow_mut().set_active_window_id(active_wid);
|
core.borrow_mut().set_active_window_id(active_wid);
|
||||||
|
|
@ -514,11 +535,19 @@ fn build_restore_node(
|
||||||
let mut win = Window::new(wid, buffer_id, text_view);
|
let mut win = Window::new(wid, buffer_id, text_view);
|
||||||
win.cursor = cursor;
|
win.cursor = cursor;
|
||||||
win.view_top = view_top;
|
win.view_top = view_top;
|
||||||
|
// Not clamped, unlike `view_top` against the line count.
|
||||||
|
// There is no cheap column bound (it would mean measuring
|
||||||
|
// the widest visible line), and none is needed: the
|
||||||
|
// horizontal follow pass moves the offset to the cursor on
|
||||||
|
// the first frame, so a stale value from a since-shortened
|
||||||
|
// file corrects itself rather than persisting.
|
||||||
|
win.view_left = leaf.view_left;
|
||||||
core.windows.insert(wid, win);
|
core.windows.insert(wid, win);
|
||||||
leaves.push(RestoreLeaf {
|
leaves.push(RestoreLeaf {
|
||||||
window: wid,
|
window: wid,
|
||||||
cursor,
|
cursor,
|
||||||
view_top,
|
view_top,
|
||||||
|
view_left: leaf.view_left,
|
||||||
});
|
});
|
||||||
save_slots.push(Some(wid));
|
save_slots.push(Some(wid));
|
||||||
Some(LayoutNode::Leaf(wid))
|
Some(LayoutNode::Leaf(wid))
|
||||||
|
|
@ -597,11 +626,13 @@ mod tests {
|
||||||
path: "/a.rs".into(),
|
path: "/a.rs".into(),
|
||||||
cursor: 10,
|
cursor: 10,
|
||||||
view_top: 2,
|
view_top: 2,
|
||||||
|
view_left: 0,
|
||||||
}),
|
}),
|
||||||
SavedNode::Leaf(SavedLeaf {
|
SavedNode::Leaf(SavedLeaf {
|
||||||
path: "/b.rs".into(),
|
path: "/b.rs".into(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
view_top: 0,
|
view_top: 0,
|
||||||
|
view_left: 0,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -611,11 +642,45 @@ mod tests {
|
||||||
assert_eq!(serde_json::from_str::<SavedDesktop>(&json).unwrap(), d);
|
assert_eq!(serde_json::from_str::<SavedDesktop>(&json).unwrap(), d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A desktop written **before** `view_left` existed must still load
|
||||||
|
/// (Stage 4, framing Q#HS5 — a condition of that approval, not a
|
||||||
|
/// nicety).
|
||||||
|
///
|
||||||
|
/// This is a literal v1 document, not one produced by serializing
|
||||||
|
/// the current struct: a generated fixture would gain the field and
|
||||||
|
/// prove nothing. `SavedLeaf` carries no other `#[serde(default)]`,
|
||||||
|
/// so without that attribute serde treats the missing field as an
|
||||||
|
/// **error** and every saved desktop in the wild stops loading —
|
||||||
|
/// which is exactly why no `DESKTOP_VERSION` bump was needed and why
|
||||||
|
/// deleting the attribute must fail here rather than in the field.
|
||||||
|
#[test]
|
||||||
|
fn a_desktop_saved_before_horizontal_scroll_still_loads() {
|
||||||
|
let v1 = r#"{
|
||||||
|
"version": 1,
|
||||||
|
"session_key": "cwd.abc",
|
||||||
|
"buffers": [{"path": "/a.rs", "modified": false}],
|
||||||
|
"root": {"Leaf": {"path": "/a.rs", "cursor": 7, "view_top": 3}},
|
||||||
|
"active_leaf": 0
|
||||||
|
}"#;
|
||||||
|
let saved: SavedDesktop = serde_json::from_str(v1)
|
||||||
|
.expect("a pre-Stage-4 desktop must load, not error on a missing field");
|
||||||
|
let SavedNode::Leaf(leaf) = &saved.root else {
|
||||||
|
panic!("expected a single leaf");
|
||||||
|
};
|
||||||
|
assert_eq!(leaf.cursor, 7, "the fields that existed are unchanged");
|
||||||
|
assert_eq!(leaf.view_top, 3);
|
||||||
|
assert_eq!(
|
||||||
|
leaf.view_left, 0,
|
||||||
|
"and the new one restores unscrolled rather than erroring"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn leaf(path: &str) -> SavedLeaf {
|
fn leaf(path: &str) -> SavedLeaf {
|
||||||
SavedLeaf {
|
SavedLeaf {
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
view_top: 0,
|
view_top: 0,
|
||||||
|
view_left: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
19
src/diag.rs
19
src/diag.rs
|
|
@ -553,7 +553,6 @@ impl View for DiagnosticView {
|
||||||
let start_line_buf = line_at_offset(&line_offsets, viewport.buffer_start as u32);
|
let start_line_buf = line_at_offset(&line_offsets, viewport.buffer_start as u32);
|
||||||
|
|
||||||
let max_rows = viewport.cell_size.rows;
|
let max_rows = viewport.cell_size.rows;
|
||||||
let max_cols = viewport.cell_size.cols;
|
|
||||||
let cell_origin = viewport.cell_origin;
|
let cell_origin = viewport.cell_origin;
|
||||||
|
|
||||||
// Column-0 line markers (gutter signs, T M4.6): most severe
|
// Column-0 line markers (gutter signs, T M4.6): most severe
|
||||||
|
|
@ -629,12 +628,14 @@ impl View for DiagnosticView {
|
||||||
};
|
};
|
||||||
let (start_col, end_col) =
|
let (start_col, end_col) =
|
||||||
underline_cols_for_line(line_bytes, byte_start, byte_end);
|
underline_cols_for_line(line_bytes, byte_start, byte_end);
|
||||||
if end_col <= start_col {
|
// `visible_cols` returns `None` for an empty range too, so the
|
||||||
|
// old `end_col <= start_col` guard is subsumed rather than
|
||||||
|
// dropped.
|
||||||
|
let Some((clamped_start, clamped_end)) = viewport.visible_cols(start_col, end_col)
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let cell_row = cell_origin.row + row_offset;
|
let cell_row = cell_origin.row + row_offset;
|
||||||
let clamped_start = start_col.min(max_cols);
|
|
||||||
let clamped_end = end_col.min(max_cols);
|
|
||||||
for col in clamped_start..clamped_end {
|
for col in clamped_start..clamped_end {
|
||||||
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
||||||
cell.style = merge_styles(cell.style, style);
|
cell.style = merge_styles(cell.style, style);
|
||||||
|
|
@ -648,7 +649,9 @@ impl View for DiagnosticView {
|
||||||
cells,
|
cells,
|
||||||
cell_origin,
|
cell_origin,
|
||||||
viewport.gutter_w,
|
viewport.gutter_w,
|
||||||
max_cols,
|
// Gutter-anchored, so unaffected by the horizontal offset:
|
||||||
|
// a sign lives left of the text area, not in it.
|
||||||
|
viewport.cell_size.cols,
|
||||||
&line_markers,
|
&line_markers,
|
||||||
theme.as_ref(),
|
theme.as_ref(),
|
||||||
);
|
);
|
||||||
|
|
@ -1141,6 +1144,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -1207,6 +1211,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -1284,6 +1289,7 @@ mod tests {
|
||||||
gutter_w: 2,
|
gutter_w: 2,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -1354,6 +1360,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
224
src/editor.rs
224
src/editor.rs
|
|
@ -4263,6 +4263,36 @@ impl CompletionPopupKey {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Move `view_left` so the cursor's column is on screen (Stage 4,
|
||||||
|
/// framing Q#HS2 — automatic only).
|
||||||
|
///
|
||||||
|
/// The horizontal mirror of the `view_top` rule below, and deliberately
|
||||||
|
/// the same shape: scroll only as far as it takes to bring the cursor
|
||||||
|
/// back inside, so a cursor already visible never moves the view. That
|
||||||
|
/// is what makes this the whole of Stage 4's navigation — there are no
|
||||||
|
/// explicit scroll commands, so every viewport move originates here,
|
||||||
|
/// and Q#HS4's snap-back hazard cannot arise.
|
||||||
|
///
|
||||||
|
/// A no-op under `wrap`: there is nothing past the right edge to reach,
|
||||||
|
/// so the offset is pinned to 0 rather than merely ignored. Leaving a
|
||||||
|
/// stale non-zero value would surface the moment the buffer toggled
|
||||||
|
/// back to `truncate`.
|
||||||
|
fn horizontal_follow(window: &mut crate::window::Window, cursor_col: u32) {
|
||||||
|
if window.last_wrap == crate::view::WrapMode::Wrap {
|
||||||
|
window.view_left = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cols = window.last_content_cols;
|
||||||
|
if cols == 0 {
|
||||||
|
return; // not rendered yet; nothing to be visible within
|
||||||
|
}
|
||||||
|
if cursor_col < window.view_left {
|
||||||
|
window.view_left = cursor_col;
|
||||||
|
} else if cursor_col >= window.view_left.saturating_add(cols) {
|
||||||
|
window.view_left = cursor_col + 1 - cols;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Scroll one window so its cursor stays visible, reckoning in
|
/// Scroll one window so its cursor stays visible, reckoning in
|
||||||
/// **visible** lines when a fold map is supplied (Arc 6 Q#FD18).
|
/// **visible** lines when a fold map is supplied (Arc 6 Q#FD18).
|
||||||
///
|
///
|
||||||
|
|
@ -4283,10 +4313,22 @@ fn prepare_window_cursor_visible(
|
||||||
inner_rows: u32,
|
inner_rows: u32,
|
||||||
folds: Option<&crate::fold_view::VisibleLineMap>,
|
folds: Option<&crate::fold_view::VisibleLineMap>,
|
||||||
) {
|
) {
|
||||||
let cursor_row = window
|
// Ask in LINE-absolute columns by pinning `view_left` to 0 — this
|
||||||
|
// pass decides what the offset should BE, so consulting the current
|
||||||
|
// one would make it self-referential. `pos_to_display` returns
|
||||||
|
// `None` for a position left of the edge (framing Q#HS7(c′)), which
|
||||||
|
// is exactly the case this pass exists to fix; reading it through
|
||||||
|
// the live context would report row 0 and scroll the window to the
|
||||||
|
// top instead.
|
||||||
|
let unscrolled = crate::view::LayoutCtx {
|
||||||
|
view_left: 0,
|
||||||
|
..window.layout_ctx()
|
||||||
|
};
|
||||||
|
let coord = window
|
||||||
.text_view
|
.text_view
|
||||||
.pos_to_display(buf, window.cursor, window.layout_ctx())
|
.pos_to_display(buf, window.cursor, unscrolled);
|
||||||
.map_or(0, |d| d.row as usize);
|
let cursor_row = coord.map_or(0, |d| d.row as usize);
|
||||||
|
horizontal_follow(window, coord.map_or(0, |d| d.col));
|
||||||
match folds {
|
match folds {
|
||||||
// The logical cursor may sit on a hidden line (a shared fold, or
|
// The logical cursor may sit on a hidden line (a shared fold, or
|
||||||
// goto-line into one); the row that actually renders — and so
|
// goto-line into one); the row that actually renders — and so
|
||||||
|
|
@ -4375,6 +4417,14 @@ fn paint_window_content(
|
||||||
// the failure this whole one-resolution arrangement exists to
|
// the failure this whole one-resolution arrangement exists to
|
||||||
// prevent.
|
// prevent.
|
||||||
wrap: window.last_wrap,
|
wrap: window.last_wrap,
|
||||||
|
// Same discipline as `wrap` directly above, and for the same
|
||||||
|
// reason it was needed: `aa3cd4d` shipped a hard-coded
|
||||||
|
// `Truncate` here while every other consumer read the resolved
|
||||||
|
// value, so the cursor was placed for wrapped text over text
|
||||||
|
// that was still clipped. A literal `0` here would reproduce it
|
||||||
|
// exactly — coordinates and the indicator would follow the
|
||||||
|
// scroll while the painter stayed pinned at column 0.
|
||||||
|
view_left: window.view_left,
|
||||||
};
|
};
|
||||||
// Composition (T M2.9): base text_view paints first, then the
|
// Composition (T M2.9): base text_view paints first, then the
|
||||||
// gutter numbers — before the overlays, so a diagnostic overlay
|
// gutter numbers — before the overlays, so a diagnostic overlay
|
||||||
|
|
@ -4396,7 +4446,7 @@ fn paint_window_content(
|
||||||
for overlay in &mut window.overlays {
|
for overlay in &mut window.overlays {
|
||||||
overlay.render(buf, viewport, grid);
|
overlay.render(buf, viewport, grid);
|
||||||
}
|
}
|
||||||
paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w, folds, theme);
|
paint_local_selection(grid, buf, window, viewport, inner_rows, folds, theme);
|
||||||
// Mode line for this window. Painted last so the line
|
// Mode line for this window. Painted last so the line
|
||||||
// itself is always visible regardless of overlay activity.
|
// itself is always visible regardless of overlay activity.
|
||||||
let coord = window
|
let coord = window
|
||||||
|
|
@ -5009,12 +5059,16 @@ fn paint_local_selection(
|
||||||
grid: &mut crate::cell::CellGrid<'_>,
|
grid: &mut crate::cell::CellGrid<'_>,
|
||||||
buf: &crate::buffer::Buffer,
|
buf: &crate::buffer::Buffer,
|
||||||
window: &crate::window::Window,
|
window: &crate::window::Window,
|
||||||
rect: &crate::window::Rect,
|
// The SAME viewport the text and every decorator were painted
|
||||||
|
// through. It already carries the gutter-adjusted width
|
||||||
|
// (`rect.size.cols - gutter_w`) and an origin shifted past the
|
||||||
|
// gutter, so the selection shares one clip rule with them rather
|
||||||
|
// than re-deriving it — the first version of Stage 4 duplicated the
|
||||||
|
// rule here on the false premise that this painter needed a
|
||||||
|
// different width (Q#UX2 handled it via `gutter_w`, which the
|
||||||
|
// viewport has already applied).
|
||||||
|
viewport: crate::view::Viewport<'_>,
|
||||||
inner_rows: u32,
|
inner_rows: u32,
|
||||||
// UX gutter: the reserved left-strip width; selection cells are the
|
|
||||||
// text-relative display column shifted right by this (Q#UX2). 0 when
|
|
||||||
// the gutter is off, so this is a no-op then.
|
|
||||||
gutter_w: u32,
|
|
||||||
// Arc 6 Stage 2: this window's collapsed regions, or `None`.
|
// Arc 6 Stage 2: this window's collapsed regions, or `None`.
|
||||||
folds: Option<&crate::fold_view::VisibleLineMap>,
|
folds: Option<&crate::fold_view::VisibleLineMap>,
|
||||||
theme: &crate::highlight::Theme,
|
theme: &crate::highlight::Theme,
|
||||||
|
|
@ -5047,10 +5101,9 @@ fn paint_local_selection(
|
||||||
..crate::cell::Style::default()
|
..crate::cell::Style::default()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if inner_rows == 0 || rect.size.cols == 0 || sel_start >= sel_end {
|
if inner_rows == 0 || viewport.cell_size.cols == 0 || sel_start >= sel_end {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let text_cols = rect.size.cols.saturating_sub(gutter_w);
|
|
||||||
|
|
||||||
// Row `r` shows the `r`-th VISIBLE line at or after `view_top`.
|
// Row `r` shows the `r`-th VISIBLE line at or after `view_top`.
|
||||||
let mut next_line = folds.map_or(window.view_top, |map| map.visible_head_of(window.view_top));
|
let mut next_line = folds.map_or(window.view_top, |map| map.visible_head_of(window.view_top));
|
||||||
|
|
@ -5070,32 +5123,42 @@ fn paint_local_selection(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(start_coord) =
|
// Asked in LINE-absolute columns, then clipped below.
|
||||||
window
|
//
|
||||||
.text_view
|
// Through the live context this dropped whole visible segments:
|
||||||
.pos_to_display(buf, paint_start, window.layout_ctx())
|
// `pos_to_display` returns `None` for a position left of the
|
||||||
|
// edge (framing Q#HS7(c′)), so a selection beginning off-screen
|
||||||
|
// and reaching well into view took the `continue` and painted
|
||||||
|
// nothing — the most common shape there is, since selecting
|
||||||
|
// rightward from column 0 then scrolling produces exactly it.
|
||||||
|
let unscrolled = crate::view::LayoutCtx {
|
||||||
|
view_left: 0,
|
||||||
|
..window.layout_ctx()
|
||||||
|
};
|
||||||
|
let Some(start_coord) = window
|
||||||
|
.text_view
|
||||||
|
.pos_to_display(buf, paint_start, unscrolled)
|
||||||
else {
|
else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(end_coord) = window
|
let Some(end_coord) = window.text_view.pos_to_display(buf, paint_end, unscrolled) else {
|
||||||
.text_view
|
|
||||||
.pos_to_display(buf, paint_end, window.layout_ctx())
|
|
||||||
else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if start_coord.row as usize != display_row || end_coord.row as usize != display_row {
|
if start_coord.row as usize != display_row || end_coord.row as usize != display_row {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let start_col = start_coord.col.min(text_cols);
|
// The one shared clip rule (Stage 4). Five adopters now read it:
|
||||||
let end_col = end_coord.col.min(text_cols);
|
// syntax/LSP styling, diagnostic underlines, search washes,
|
||||||
if start_col >= end_col {
|
// `BufferStyleOverlay`, and this.
|
||||||
|
let Some((start_col, end_col)) = viewport.visible_cols(start_coord.col, end_coord.col)
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
for col in start_col..end_col {
|
for col in start_col..end_col {
|
||||||
let cell = grid.at(CellCoord::new(
|
let cell = grid.at(CellCoord::new(
|
||||||
rect.origin.row + row_offset,
|
viewport.cell_origin.row + row_offset,
|
||||||
rect.origin.col + gutter_w + col,
|
viewport.cell_origin.col + col,
|
||||||
));
|
));
|
||||||
cell.style = crate::overlay::merge_styles(cell.style, overlay);
|
cell.style = crate::overlay::merge_styles(cell.style, overlay);
|
||||||
}
|
}
|
||||||
|
|
@ -8543,6 +8606,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
@ -8679,6 +8743,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Two no-op overlays: probe the dispatch cost only.
|
// Two no-op overlays: probe the dispatch cost only.
|
||||||
|
|
@ -11429,3 +11494,112 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod horizontal_scroll_selection_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::cell::{Cell, CellCoord, CellGrid, CellSize, Style};
|
||||||
|
use crate::view::WrapMode;
|
||||||
|
use crate::window::{Selection, Window, WindowId};
|
||||||
|
|
||||||
|
/// A selection that begins LEFT of the horizontal edge and reaches
|
||||||
|
/// into view must paint its visible tail (Stage 4 review P1).
|
||||||
|
///
|
||||||
|
/// The selection painter asked `pos_to_display` through the live
|
||||||
|
/// layout context, which returns `None` for a position left of the
|
||||||
|
/// edge (framing Q#HS7(c′)) — so the whole segment took `continue`
|
||||||
|
/// and painted nothing. That is the *common* shape, not an edge
|
||||||
|
/// case: select rightward from column 0, keep going past the window
|
||||||
|
/// width, and the view scrolls with the cursor.
|
||||||
|
#[test]
|
||||||
|
fn a_selection_starting_off_screen_paints_its_visible_tail() {
|
||||||
|
let buf = crate::buffer::Buffer::from_bytes(
|
||||||
|
crate::buffer::BufferId::next(),
|
||||||
|
"t",
|
||||||
|
b"ABCDEFGHIJKL",
|
||||||
|
);
|
||||||
|
let text_view = crate::text_view::TextView::new(&buf);
|
||||||
|
let mut window = Window::new(WindowId::next(), buf.id(), text_view);
|
||||||
|
window.last_wrap = WrapMode::Truncate;
|
||||||
|
window.last_content_cols = 4;
|
||||||
|
// Scrolled so screen column 0 shows source column 4.
|
||||||
|
window.view_left = 4;
|
||||||
|
// Selected from the line start through byte 6 — bytes 0..4 are
|
||||||
|
// off-screen left, bytes 4..6 ("EF") are the visible tail.
|
||||||
|
// `Selection` holds only the anchor; the other end is the
|
||||||
|
// window's cursor.
|
||||||
|
window.selection = Some(Selection { anchor: 0 });
|
||||||
|
window.cursor = 6;
|
||||||
|
|
||||||
|
let mut storage = vec![Cell::default(); 4];
|
||||||
|
let mut grid = CellGrid {
|
||||||
|
cells: &mut storage,
|
||||||
|
stride: 4,
|
||||||
|
size: CellSize::new(1, 4),
|
||||||
|
};
|
||||||
|
let viewport = crate::view::Viewport {
|
||||||
|
buffer_start: 0,
|
||||||
|
buffer_end: buf.len(),
|
||||||
|
cell_origin: CellCoord::new(0, 0),
|
||||||
|
cell_size: CellSize::new(1, 4),
|
||||||
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 4,
|
||||||
|
};
|
||||||
|
let theme = crate::highlight::Theme::default_dark();
|
||||||
|
paint_local_selection(&mut grid, &buf, &window, viewport, 1, None, &theme);
|
||||||
|
|
||||||
|
let washed: Vec<bool> = (0..4)
|
||||||
|
.map(|c| storage[c].style != Style::default())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
washed,
|
||||||
|
vec![true, true, false, false],
|
||||||
|
"the visible tail (E, F) must carry the selection wash; \
|
||||||
|
painting nothing at all is the defect, and painting at \
|
||||||
|
absolute columns 0..6 would wash the whole window"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The control: a selection entirely left of the edge paints nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_selection_entirely_off_screen_paints_nothing() {
|
||||||
|
let buf = crate::buffer::Buffer::from_bytes(
|
||||||
|
crate::buffer::BufferId::next(),
|
||||||
|
"t",
|
||||||
|
b"ABCDEFGHIJKL",
|
||||||
|
);
|
||||||
|
let text_view = crate::text_view::TextView::new(&buf);
|
||||||
|
let mut window = Window::new(WindowId::next(), buf.id(), text_view);
|
||||||
|
window.last_wrap = WrapMode::Truncate;
|
||||||
|
window.last_content_cols = 4;
|
||||||
|
window.view_left = 4;
|
||||||
|
window.selection = Some(Selection { anchor: 0 });
|
||||||
|
window.cursor = 3;
|
||||||
|
|
||||||
|
let mut storage = vec![Cell::default(); 4];
|
||||||
|
let mut grid = CellGrid {
|
||||||
|
cells: &mut storage,
|
||||||
|
stride: 4,
|
||||||
|
size: CellSize::new(1, 4),
|
||||||
|
};
|
||||||
|
let viewport = crate::view::Viewport {
|
||||||
|
buffer_start: 0,
|
||||||
|
buffer_end: buf.len(),
|
||||||
|
cell_origin: CellCoord::new(0, 0),
|
||||||
|
cell_size: CellSize::new(1, 4),
|
||||||
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 4,
|
||||||
|
};
|
||||||
|
let theme = crate::highlight::Theme::default_dark();
|
||||||
|
paint_local_selection(&mut grid, &buf, &window, viewport, 1, None, &theme);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(0..4).all(|c| storage[c].style == Style::default()),
|
||||||
|
"a selection ending before the edge must not wash anything"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -471,7 +471,6 @@ impl View for SyntaxHighlightView {
|
||||||
|
|
||||||
let start_line = line_at_offset(&self.cache.line_offsets, viewport.buffer_start as u32);
|
let start_line = line_at_offset(&self.cache.line_offsets, viewport.buffer_start as u32);
|
||||||
let max_rows = viewport.cell_size.rows;
|
let max_rows = viewport.cell_size.rows;
|
||||||
let max_cols = viewport.cell_size.cols;
|
|
||||||
let cell_origin = viewport.cell_origin;
|
let cell_origin = viewport.cell_origin;
|
||||||
let total_lines = self.cache.line_offsets.len() as u32;
|
let total_lines = self.cache.line_offsets.len() as u32;
|
||||||
|
|
||||||
|
|
@ -531,8 +530,11 @@ impl View for SyntaxHighlightView {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let cell_row = cell_origin.row + row_offset;
|
let cell_row = cell_origin.row + row_offset;
|
||||||
let clamped_start = start_col.min(max_cols);
|
let Some((clamped_start, clamped_end)) =
|
||||||
let clamped_end = end_col.min(max_cols);
|
viewport.visible_cols(start_col, end_col)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
for col in clamped_start..clamped_end {
|
for col in clamped_start..clamped_end {
|
||||||
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
||||||
cell.style = merge_styles(cell.style, style);
|
cell.style = merge_styles(cell.style, style);
|
||||||
|
|
@ -688,7 +690,6 @@ impl View for LspStyleView {
|
||||||
|
|
||||||
let start_line = line_at_offset(&line_offsets, viewport.buffer_start as u32);
|
let start_line = line_at_offset(&line_offsets, viewport.buffer_start as u32);
|
||||||
let max_rows = viewport.cell_size.rows;
|
let max_rows = viewport.cell_size.rows;
|
||||||
let max_cols = viewport.cell_size.cols;
|
|
||||||
let cell_origin = viewport.cell_origin;
|
let cell_origin = viewport.cell_origin;
|
||||||
let total_lines = line_offsets.len() as u32;
|
let total_lines = line_offsets.len() as u32;
|
||||||
|
|
||||||
|
|
@ -751,12 +752,14 @@ impl View for LspStyleView {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (start_col, end_col) = byte_range_to_columns(line_bytes, start_b, end_b);
|
let (start_col, end_col) = byte_range_to_columns(line_bytes, start_b, end_b);
|
||||||
if end_col <= start_col {
|
// `visible_cols` returns `None` for an empty range too, so the
|
||||||
|
// old `end_col <= start_col` guard is subsumed rather than
|
||||||
|
// dropped.
|
||||||
|
let Some((clamped_start, clamped_end)) = viewport.visible_cols(start_col, end_col)
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let cell_row = cell_origin.row + row_offset;
|
let cell_row = cell_origin.row + row_offset;
|
||||||
let clamped_start = start_col.min(max_cols);
|
|
||||||
let clamped_end = end_col.min(max_cols);
|
|
||||||
for col in clamped_start..clamped_end {
|
for col in clamped_start..clamped_end {
|
||||||
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
||||||
cell.style = merge_styles(cell.style, style);
|
cell.style = merge_styles(cell.style, style);
|
||||||
|
|
@ -1113,6 +1116,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let registry = state.core.borrow().registry.clone();
|
let registry = state.core.borrow().registry.clone();
|
||||||
let reg = registry.borrow();
|
let reg = registry.borrow();
|
||||||
|
|
@ -1212,6 +1216,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let registry = state.core.borrow().registry.clone();
|
let registry = state.core.borrow().registry.clone();
|
||||||
let reg = registry.borrow();
|
let reg = registry.borrow();
|
||||||
|
|
@ -1333,6 +1338,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let registry = state.core.borrow().registry.clone();
|
let registry = state.core.borrow().registry.clone();
|
||||||
let reg = registry.borrow();
|
let reg = registry.borrow();
|
||||||
|
|
@ -1394,6 +1400,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let registry = buf; // keep buf alive
|
let registry = buf; // keep buf alive
|
||||||
hv.render(®istry, viewport, &mut grid);
|
hv.render(®istry, viewport, &mut grid);
|
||||||
|
|
@ -1452,6 +1459,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let registry = buf; // keep buf alive
|
let registry = buf; // keep buf alive
|
||||||
hv.render(®istry, viewport, &mut grid);
|
hv.render(®istry, viewport, &mut grid);
|
||||||
|
|
@ -1518,6 +1526,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let registry = buf;
|
let registry = buf;
|
||||||
hv.render(®istry, viewport, &mut grid);
|
hv.render(®istry, viewport, &mut grid);
|
||||||
|
|
@ -1586,6 +1595,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
hv.render(&buf, viewport, &mut grid);
|
hv.render(&buf, viewport, &mut grid);
|
||||||
grid.get(CellCoord::new(0, col)).style
|
grid.get(CellCoord::new(0, col)).style
|
||||||
|
|
@ -1829,6 +1839,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let registry = buf;
|
let registry = buf;
|
||||||
hv.render(®istry, viewport, &mut grid);
|
hv.render(®istry, viewport, &mut grid);
|
||||||
|
|
@ -1893,6 +1904,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let registry = buf;
|
let registry = buf;
|
||||||
hv.render(®istry, viewport, &mut grid);
|
hv.render(®istry, viewport, &mut grid);
|
||||||
|
|
|
||||||
|
|
@ -402,8 +402,12 @@ fn render_buffer_style_span(
|
||||||
(style_start - line_start) as usize,
|
(style_start - line_start) as usize,
|
||||||
line_prefix.len(),
|
line_prefix.len(),
|
||||||
);
|
);
|
||||||
let start_col = start_col.min(viewport.cell_size.cols);
|
// Buffer coordinates, so they translate (Stage 4). Its siblings
|
||||||
let end_col = end_col.min(viewport.cell_size.cols);
|
// `StyleSpanOverlay` and `VirtualCellOverlay` are documented as
|
||||||
|
// viewport-relative and deliberately do NOT.
|
||||||
|
let Some((start_col, end_col)) = viewport.visible_cols(start_col, end_col) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
for col in start_col..end_col {
|
for col in start_col..end_col {
|
||||||
let coord = CellCoord::new(
|
let coord = CellCoord::new(
|
||||||
viewport.cell_origin.row + row_offset,
|
viewport.cell_origin.row + row_offset,
|
||||||
|
|
@ -498,6 +502,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -443,7 +443,6 @@ impl View for SearchView {
|
||||||
// the line is collapsed away (the wash then paints nothing).
|
// the line is collapsed away (the wash then paints nothing).
|
||||||
let row_of = |line: u32| viewport.row_offset_of(start_line_buf as usize, line as usize);
|
let row_of = |line: u32| viewport.row_offset_of(start_line_buf as usize, line as usize);
|
||||||
let max_rows = viewport.cell_size.rows;
|
let max_rows = viewport.cell_size.rows;
|
||||||
let max_cols = viewport.cell_size.cols;
|
|
||||||
let cell_origin = viewport.cell_origin;
|
let cell_origin = viewport.cell_origin;
|
||||||
|
|
||||||
// Themes Q#TH5: a set wash face replaces the default overlay
|
// Themes Q#TH5: a set wash face replaces the default overlay
|
||||||
|
|
@ -523,12 +522,14 @@ impl View for SearchView {
|
||||||
let within_end = (paint_end - line_start) as usize;
|
let within_end = (paint_end - line_start) as usize;
|
||||||
let (start_col, end_col) =
|
let (start_col, end_col) =
|
||||||
byte_range_to_columns(line_bytes, within_start, within_end);
|
byte_range_to_columns(line_bytes, within_start, within_end);
|
||||||
if end_col <= start_col {
|
// `visible_cols` returns `None` for an empty range too, so the
|
||||||
|
// old `end_col <= start_col` guard is subsumed rather than
|
||||||
|
// dropped.
|
||||||
|
let Some((clamped_start, clamped_end)) = viewport.visible_cols(start_col, end_col)
|
||||||
|
else {
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
let cell_row = cell_origin.row + row_offset;
|
let cell_row = cell_origin.row + row_offset;
|
||||||
let clamped_start = start_col.min(max_cols);
|
|
||||||
let clamped_end = end_col.min(max_cols);
|
|
||||||
for col in clamped_start..clamped_end {
|
for col in clamped_start..clamped_end {
|
||||||
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
|
||||||
cell.style = merge_styles(cell.style, style);
|
cell.style = merge_styles(cell.style, style);
|
||||||
|
|
@ -761,6 +762,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -791,6 +793,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid2,
|
&mut grid2,
|
||||||
);
|
);
|
||||||
|
|
@ -834,6 +837,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
157
src/text_view.rs
157
src/text_view.rs
|
|
@ -217,6 +217,52 @@ impl TextView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Translate a LINE column to a SCREEN column at horizontal offset
|
||||||
|
/// `left`, or `None` when the byte is not visible (framing
|
||||||
|
/// Q#HS7(c′)).
|
||||||
|
///
|
||||||
|
/// The straddle case is why this is not a bare subtraction, and it
|
||||||
|
/// was the first version's bug. A wide glyph starting at `left - 1`
|
||||||
|
/// has its **trailing** cell on screen at column 0, so its start
|
||||||
|
/// byte must designate that cell — otherwise the character the user
|
||||||
|
/// scrolled toward has no visible cell mapping to it at all, and the
|
||||||
|
/// round trip against `display_to_pos` breaks.
|
||||||
|
///
|
||||||
|
/// A **tab** is deliberately excluded. Its expansion cells map
|
||||||
|
/// FORWARD to the byte after it (Q#HS7(c″), the pre-Stage-4
|
||||||
|
/// behavior), so the tab byte itself is simply off-screen; letting
|
||||||
|
/// it claim cell 0 would put two bytes on one cell.
|
||||||
|
fn screen_col(buf: &Buffer, pos: Position, col: u32, left: u32) -> Option<u32> {
|
||||||
|
if col >= left {
|
||||||
|
return Some(col - left);
|
||||||
|
}
|
||||||
|
// Left of the edge — visible only if the glyph *starting* here
|
||||||
|
// reaches past it.
|
||||||
|
let mut probe = [0u8; 4];
|
||||||
|
let end = (pos + 4).min(buf.len());
|
||||||
|
let n = (end - pos) as usize;
|
||||||
|
if n == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
buf.snapshot_rope().slice(pos, end, &mut probe[..n]);
|
||||||
|
let ch = std::str::from_utf8(&probe[..n])
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.chars().next())
|
||||||
|
.or_else(|| {
|
||||||
|
// A truncated read can split the final codepoint; decode
|
||||||
|
// the longest valid prefix instead of giving up.
|
||||||
|
std::str::from_utf8(&probe[..n])
|
||||||
|
.err()
|
||||||
|
.map(|e| e.valid_up_to())
|
||||||
|
.and_then(|v| std::str::from_utf8(&probe[..v]).ok())
|
||||||
|
.and_then(|s| s.chars().next())
|
||||||
|
})?;
|
||||||
|
if ch == '\t' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
(advance_char(col, ch) > left).then_some(0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Byte offset (relative to `line`'s start) at visual row `sub_row`,
|
/// Byte offset (relative to `line`'s start) at visual row `sub_row`,
|
||||||
/// column `col`, under character wrap — the inverse of
|
/// column `col`, under character wrap — the inverse of
|
||||||
/// [`Self::place_of_byte`].
|
/// [`Self::place_of_byte`].
|
||||||
|
|
@ -301,9 +347,26 @@ impl TextView {
|
||||||
let r = first_row.checked_add(sub.checked_sub(skip_rows)?)?;
|
let r = first_row.checked_add(sub.checked_sub(skip_rows)?)?;
|
||||||
(r < max_rows).then_some(origin.row + r)
|
(r < max_rows).then_some(origin.row + r)
|
||||||
};
|
};
|
||||||
|
// The walk stays in LINE-absolute columns and only `put`
|
||||||
|
// translates to the screen (framing Q#HS7(a)). Tab expansion
|
||||||
|
// depends on the absolute column from the line start, so a walk
|
||||||
|
// that began at the edge would put tab stops in the wrong place;
|
||||||
|
// starting at 0 and translating on output preserves them for
|
||||||
|
// free, at the cost `paint_line` already pays under wrapping.
|
||||||
|
//
|
||||||
|
// `left` is 0 whenever this line wraps, so the wrap path below is
|
||||||
|
// byte-identical to Stage 3.
|
||||||
|
let left = viewport.left_edge();
|
||||||
let put = |cells: &mut CellGrid<'_>, sub: u32, col: u32, glyph: Glyph| {
|
let put = |cells: &mut CellGrid<'_>, sub: u32, col: u32, glyph: Glyph| {
|
||||||
|
// Entirely left of the edge: not this viewport's cell.
|
||||||
|
let Some(screen) = col.checked_sub(left) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if screen >= max_cols {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(row) = grid_row(sub) {
|
if let Some(row) = grid_row(sub) {
|
||||||
let cell = cells.at(CellCoord::new(row, origin.col + col));
|
let cell = cells.at(CellCoord::new(row, origin.col + screen));
|
||||||
cell.glyph = glyph;
|
cell.glyph = glyph;
|
||||||
cell.style = Style::default();
|
cell.style = Style::default();
|
||||||
cell.attachment = None;
|
cell.attachment = None;
|
||||||
|
|
@ -312,7 +375,7 @@ impl TextView {
|
||||||
|
|
||||||
let (mut sub_row, mut col) = (0u32, 0u32);
|
let (mut sub_row, mut col) = (0u32, 0u32);
|
||||||
for ch in s.chars() {
|
for ch in s.chars() {
|
||||||
if !wrapping && col >= max_cols {
|
if !wrapping && col >= max_cols.saturating_add(left) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let (start_row, start_col, end_row, end_col) =
|
let (start_row, start_col, end_row, end_col) =
|
||||||
|
|
@ -327,8 +390,23 @@ impl TextView {
|
||||||
}
|
}
|
||||||
} else if end_col > start_col || start_row > sub_row {
|
} else if end_col > start_col || start_row > sub_row {
|
||||||
put(cells, start_row, start_col, Glyph::Char(ch));
|
put(cells, start_row, start_col, Glyph::Char(ch));
|
||||||
if end_col.saturating_sub(start_col) == 2 && start_col + 1 < max_cols {
|
if end_col.saturating_sub(start_col) == 2
|
||||||
put(cells, start_row, start_col + 1, Glyph::Continuation);
|
&& start_col + 1 < max_cols.saturating_add(left)
|
||||||
|
{
|
||||||
|
// A wide glyph the left edge BISECTS cannot draw its
|
||||||
|
// leading cell, so its trailing cell shows a blank
|
||||||
|
// rather than a `Continuation` — which is a marker
|
||||||
|
// meaning "the cell before me is a wide glyph's
|
||||||
|
// head", and here that cell is off-screen. Emitting
|
||||||
|
// it would name a cell nobody painted (framing
|
||||||
|
// Q#HS7(c′)).
|
||||||
|
let bisected = start_col < left;
|
||||||
|
let trailing = if bisected {
|
||||||
|
Glyph::Char(' ')
|
||||||
|
} else {
|
||||||
|
Glyph::Continuation
|
||||||
|
};
|
||||||
|
put(cells, start_row, start_col + 1, trailing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sub_row = end_row;
|
sub_row = end_row;
|
||||||
|
|
@ -465,7 +543,14 @@ impl View for TextView {
|
||||||
// answer, which means trimming the in-progress bytes).
|
// answer, which means trimming the in-progress bytes).
|
||||||
let take = (pos - line_start) as usize;
|
let take = (pos - line_start) as usize;
|
||||||
if take == 0 {
|
if take == 0 {
|
||||||
return Some(DisplayCoord::new(row_idx as u32, 0));
|
// Still translated: at a non-zero offset the line's first
|
||||||
|
// byte is off-screen (or straddling), and returning column 0
|
||||||
|
// unconditionally was the first version's bug — it made byte
|
||||||
|
// 0 look visible at every offset.
|
||||||
|
return Some(DisplayCoord::new(
|
||||||
|
row_idx as u32,
|
||||||
|
Self::screen_col(buf, pos, 0, ctx.effective_left())?,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
// Copy [line_start, pos) into a stack buffer for the common short-line
|
// Copy [line_start, pos) into a stack buffer for the common short-line
|
||||||
// case, hitting the heap only for unusually long prefixes. This removes
|
// case, hitting the heap only for unusually long prefixes. This removes
|
||||||
|
|
@ -480,7 +565,21 @@ impl View for TextView {
|
||||||
};
|
};
|
||||||
buf.snapshot_rope().slice(line_start, pos, bytes);
|
buf.snapshot_rope().slice(line_start, pos, bytes);
|
||||||
let col = valid_prefix_width(bytes);
|
let col = valid_prefix_width(bytes);
|
||||||
Some(DisplayCoord::new(row_idx as u32, col))
|
// Translate to the screen. A caret sits BETWEEN characters, so it
|
||||||
|
// never lands inside a glyph — the straddle case belongs to
|
||||||
|
// `display_to_pos` and the painter, not here.
|
||||||
|
//
|
||||||
|
// `None` for a position left of the edge is deliberate and is the
|
||||||
|
// contract in framing Q#HS7(c′): clamping to column 0 instead
|
||||||
|
// would make arbitrarily many positions share one cell and
|
||||||
|
// destroy the round trip. Callers already handle `None` (it is
|
||||||
|
// what an out-of-range `pos` returns), and the horizontal
|
||||||
|
// visibility pass keeps the cursor on screen so this is not
|
||||||
|
// reachable for the caret itself.
|
||||||
|
Some(DisplayCoord::new(
|
||||||
|
row_idx as u32,
|
||||||
|
Self::screen_col(buf, pos, col, ctx.effective_left())?,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn display_to_pos(
|
fn display_to_pos(
|
||||||
|
|
@ -502,14 +601,38 @@ impl View for TextView {
|
||||||
let line_bytes = self.read_line_bytes(buf, row);
|
let line_bytes = self.read_line_bytes(buf, row);
|
||||||
let s = std::str::from_utf8(&line_bytes).ok()?;
|
let s = std::str::from_utf8(&line_bytes).ok()?;
|
||||||
|
|
||||||
|
// Screen column back to line column. The walk below is otherwise
|
||||||
|
// unchanged, so tab stops stay right (framing Q#HS7(a)).
|
||||||
|
let left = ctx.effective_left();
|
||||||
|
let target = coord.col.saturating_add(left);
|
||||||
|
|
||||||
let mut walked_cols: u32 = 0;
|
let mut walked_cols: u32 = 0;
|
||||||
let mut walked_bytes: usize = 0;
|
let mut walked_bytes: usize = 0;
|
||||||
for (byte_idx, ch) in s.char_indices() {
|
for (byte_idx, ch) in s.char_indices() {
|
||||||
if walked_cols >= coord.col {
|
if walked_cols >= target {
|
||||||
walked_bytes = byte_idx;
|
walked_bytes = byte_idx;
|
||||||
return Some(line_start + walked_bytes as u64);
|
return Some(line_start + walked_bytes as u64);
|
||||||
}
|
}
|
||||||
walked_cols = advance_char(walked_cols, ch);
|
let next = advance_char(walked_cols, ch);
|
||||||
|
// The bisected wide glyph, and ONLY at the leftmost visible
|
||||||
|
// cell (framing Q#HS7(c′)). Its trailing cell is screen
|
||||||
|
// column 0, and it is designated to the glyph's START byte:
|
||||||
|
// the cell belongs to that character, so a click there must
|
||||||
|
// select it, and nothing else can — its leading cell is off
|
||||||
|
// screen.
|
||||||
|
//
|
||||||
|
// Deliberately narrow. Everywhere else a column landing
|
||||||
|
// inside a glyph keeps rounding FORWARD, which is the
|
||||||
|
// pre-Stage-4 behavior and what `byte_at_place` documents;
|
||||||
|
// widening this would change unscrolled mappings. Tabs keep
|
||||||
|
// forward rounding here too — their expansion is whitespace
|
||||||
|
// BETWEEN the tab byte and the next character, so landing
|
||||||
|
// after it is what clicking indentation should do
|
||||||
|
// (Q#HS7(c″)).
|
||||||
|
if coord.col == 0 && left > 0 && ch != '\t' && walked_cols < target && next > target {
|
||||||
|
return Some(line_start + byte_idx as u64);
|
||||||
|
}
|
||||||
|
walked_cols = next;
|
||||||
walked_bytes = byte_idx + ch.len_utf8();
|
walked_bytes = byte_idx + ch.len_utf8();
|
||||||
}
|
}
|
||||||
// Past the line's last codepoint: clamp to the line's visible end.
|
// Past the line's last codepoint: clamp to the line's visible end.
|
||||||
|
|
@ -954,6 +1077,7 @@ mod tests {
|
||||||
let ctx = LayoutCtx {
|
let ctx = LayoutCtx {
|
||||||
cols: 4,
|
cols: 4,
|
||||||
wrap: WrapMode::Wrap,
|
wrap: WrapMode::Wrap,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
// 'e' is byte 4: line 0, second visual row, column 0.
|
// 'e' is byte 4: line 0, second visual row, column 0.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -976,6 +1100,7 @@ mod tests {
|
||||||
let ctx = LayoutCtx {
|
let ctx = LayoutCtx {
|
||||||
cols: 4,
|
cols: 4,
|
||||||
wrap: WrapMode::Wrap,
|
wrap: WrapMode::Wrap,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.pos_to_display(&buf, 4, ctx),
|
view.pos_to_display(&buf, 4, ctx),
|
||||||
|
|
@ -1001,6 +1126,7 @@ mod tests {
|
||||||
let ctx = LayoutCtx {
|
let ctx = LayoutCtx {
|
||||||
cols,
|
cols,
|
||||||
wrap: WrapMode::Wrap,
|
wrap: WrapMode::Wrap,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
for (byte, _) in text.char_indices() {
|
for (byte, _) in text.char_indices() {
|
||||||
let coord = view
|
let coord = view
|
||||||
|
|
@ -1024,6 +1150,7 @@ mod tests {
|
||||||
let ctx = LayoutCtx {
|
let ctx = LayoutCtx {
|
||||||
cols: 4,
|
cols: 4,
|
||||||
wrap: WrapMode::Wrap,
|
wrap: WrapMode::Wrap,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
// '中' starts at byte 2 and is three bytes long.
|
// '中' starts at byte 2 and is three bytes long.
|
||||||
let at_start = view.pos_to_display(&buf, 2, ctx);
|
let at_start = view.pos_to_display(&buf, 2, ctx);
|
||||||
|
|
@ -1060,7 +1187,7 @@ mod tests {
|
||||||
/// Render `text` into a `rows` x `cols` grid and return the glyph of
|
/// Render `text` into a `rows` x `cols` grid and return the glyph of
|
||||||
/// every cell, row-major.
|
/// every cell, row-major.
|
||||||
fn render_grid(text: &[u8], rows: u32, cols: u32, wrap: WrapMode) -> Vec<Glyph> {
|
fn render_grid(text: &[u8], rows: u32, cols: u32, wrap: WrapMode) -> Vec<Glyph> {
|
||||||
render_grid_from(text, rows, cols, wrap, 0)
|
render_grid_from(text, rows, cols, wrap, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// As [`render_grid`], but starting the viewport at byte `start` —
|
/// As [`render_grid`], but starting the viewport at byte `start` —
|
||||||
|
|
@ -1070,6 +1197,7 @@ mod tests {
|
||||||
rows: u32,
|
rows: u32,
|
||||||
cols: u32,
|
cols: u32,
|
||||||
wrap: WrapMode,
|
wrap: WrapMode,
|
||||||
|
view_left: u32,
|
||||||
start: u64,
|
start: u64,
|
||||||
) -> Vec<Glyph> {
|
) -> Vec<Glyph> {
|
||||||
let (buf, mut view) = attached(text);
|
let (buf, mut view) = attached(text);
|
||||||
|
|
@ -1090,6 +1218,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap,
|
wrap,
|
||||||
|
view_left,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -1132,6 +1261,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Wrap,
|
wrap: WrapMode::Wrap,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
view.render(&buf, vp, &mut grid);
|
view.render(&buf, vp, &mut grid);
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -1202,6 +1332,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Wrap,
|
wrap: WrapMode::Wrap,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -1286,7 +1417,7 @@ xy",
|
||||||
#[test]
|
#[test]
|
||||||
fn the_viewport_can_start_partway_down_a_wrapped_line() {
|
fn the_viewport_can_start_partway_down_a_wrapped_line() {
|
||||||
// Byte 4 is 'e', the first character of the second visual row.
|
// Byte 4 is 'e', the first character of the second visual row.
|
||||||
let g = render_grid_from(b"abcdefghij", 2, 4, WrapMode::Wrap, 4);
|
let g = render_grid_from(b"abcdefghij", 2, 4, WrapMode::Wrap, 0, 4);
|
||||||
assert_eq!(row_text(&g, 4, 0), "efgh", "the first row is skipped");
|
assert_eq!(row_text(&g, 4, 0), "efgh", "the first row is skipped");
|
||||||
assert_eq!(row_text(&g, 4, 1), "ij ");
|
assert_eq!(row_text(&g, 4, 1), "ij ");
|
||||||
}
|
}
|
||||||
|
|
@ -1303,7 +1434,7 @@ xy",
|
||||||
let row = view.row_of_byte(&buf, 0, byte as u64, cols);
|
let row = view.row_of_byte(&buf, 0, byte as u64, cols);
|
||||||
// Anchoring the viewport at that byte must put the
|
// Anchoring the viewport at that byte must put the
|
||||||
// character on the viewport's FIRST row.
|
// character on the viewport's FIRST row.
|
||||||
let g = render_grid_from(text.as_bytes(), 3, cols, WrapMode::Wrap, byte as u64);
|
let g = render_grid_from(text.as_bytes(), 3, cols, WrapMode::Wrap, 0, byte as u64);
|
||||||
let full = render_grid(text.as_bytes(), 12, cols, WrapMode::Wrap);
|
let full = render_grid(text.as_bytes(), 12, cols, WrapMode::Wrap);
|
||||||
let expect = row_text(&full, cols, row);
|
let expect = row_text(&full, cols, row);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1334,6 +1465,7 @@ xy",
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -1364,6 +1496,7 @@ xy",
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -1397,6 +1530,7 @@ xy",
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
@ -1431,6 +1565,7 @@ xy",
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
},
|
},
|
||||||
&mut grid,
|
&mut grid,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
87
src/view.rs
87
src/view.rs
|
|
@ -171,6 +171,19 @@ pub struct LayoutCtx {
|
||||||
pub cols: u32,
|
pub cols: u32,
|
||||||
/// The window's resolved wrap mode.
|
/// The window's resolved wrap mode.
|
||||||
pub wrap: WrapMode,
|
pub wrap: WrapMode,
|
||||||
|
/// First visible display column — the window's horizontal scroll
|
||||||
|
/// offset (Stage 4, framing Q#HS7).
|
||||||
|
///
|
||||||
|
/// **Stored unsnapped.** It is one per-window column, while "does
|
||||||
|
/// this column bisect a wide glyph?" is a *per-line* question, so no
|
||||||
|
/// single snapped value could be canonical for every visible line.
|
||||||
|
/// Each line derives its own **effective edge** during the walk it
|
||||||
|
/// already performs from column 0 (framing Q#HS7(c′)).
|
||||||
|
///
|
||||||
|
/// Inert under [`WrapMode::Wrap`]: a wrapped line has nothing past
|
||||||
|
/// the right edge to scroll toward, so the wrap path ignores this
|
||||||
|
/// entirely and stays byte-identical to Stage 3.
|
||||||
|
pub view_left: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LayoutCtx {
|
impl LayoutCtx {
|
||||||
|
|
@ -184,6 +197,7 @@ impl LayoutCtx {
|
||||||
Self {
|
Self {
|
||||||
cols: 0,
|
cols: 0,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,6 +206,18 @@ impl LayoutCtx {
|
||||||
pub const fn wrapping(self) -> bool {
|
pub const fn wrapping(self) -> bool {
|
||||||
matches!(self.wrap, WrapMode::Wrap) && self.cols > 0
|
matches!(self.wrap, WrapMode::Wrap) && self.cols > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The horizontal offset that actually applies.
|
||||||
|
///
|
||||||
|
/// Always `0` when wrapping, which is what makes `view_left` inert
|
||||||
|
/// under `wrap` **by construction** rather than by every caller
|
||||||
|
/// remembering to check. A wrapped line has no content past the
|
||||||
|
/// right edge, so a non-zero offset there could only hide text that
|
||||||
|
/// nothing would ever scroll back to.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn effective_left(self) -> u32 {
|
||||||
|
if self.wrapping() { 0 } else { self.view_left }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a line wider than the viewport is shown --- the long-lines
|
/// How a line wider than the viewport is shown --- the long-lines
|
||||||
|
|
@ -265,9 +291,68 @@ pub struct Viewport<'a> {
|
||||||
/// pre-Stage-3 sites read as *deliberately* unwrapped rather than
|
/// pre-Stage-3 sites read as *deliberately* unwrapped rather than
|
||||||
/// merely untouched.
|
/// merely untouched.
|
||||||
pub wrap: WrapMode,
|
pub wrap: WrapMode,
|
||||||
|
/// First visible display column (Stage 4). See
|
||||||
|
/// [`LayoutCtx::view_left`]; required rather than defaulted for the
|
||||||
|
/// same reason `wrap` is.
|
||||||
|
pub view_left: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Viewport<'_> {
|
impl Viewport<'_> {
|
||||||
|
/// The horizontal offset that actually applies — `0` when wrapping,
|
||||||
|
/// for the reason [`LayoutCtx::effective_left`] gives.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn left_edge(&self) -> u32 {
|
||||||
|
if matches!(self.wrap, WrapMode::Wrap) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.view_left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clip a **line**-column range to what is on screen, returning
|
||||||
|
/// **screen** columns — or `None` when none of it is visible.
|
||||||
|
///
|
||||||
|
/// # Why every buffer-coordinate decorator must use this
|
||||||
|
///
|
||||||
|
/// Stage 4 translated the base text walk and nothing else, which
|
||||||
|
/// split the frame in half: at `view_left = 10` a glyph at source
|
||||||
|
/// column 10 painted at screen column 0 while its syntax style,
|
||||||
|
/// diagnostic underline, search wash and selection painted at screen
|
||||||
|
/// column 10 — or vanished. Decorations drifted off the characters
|
||||||
|
/// they describe, silently, and only once a window was scrolled.
|
||||||
|
///
|
||||||
|
/// Every such site had the same two lines (`start_col.min(max_cols)`,
|
||||||
|
/// `end_col.min(max_cols)`) — correct only while the left edge was
|
||||||
|
/// pinned at zero. One helper replaces all of them so a future
|
||||||
|
/// decorator inherits the translation instead of re-deriving it.
|
||||||
|
///
|
||||||
|
/// **Five adopters**, and the count is the point: syntax/LSP
|
||||||
|
/// styling, diagnostic underlines, search washes,
|
||||||
|
/// [`crate::overlay::BufferStyleOverlay`], and the selection
|
||||||
|
/// painter. The selection was nearly the exception — Stage 4's first
|
||||||
|
/// version duplicated the rule there, justified by a width this
|
||||||
|
/// painter supposedly needed and the viewport lacked. That was
|
||||||
|
/// false: the render viewport's `cell_size.cols` is already
|
||||||
|
/// `rect.size.cols - gutter_w`, and its origin already sits past the
|
||||||
|
/// gutter. A canonical rule with one honest exception is not
|
||||||
|
/// canonical, so the exception went.
|
||||||
|
///
|
||||||
|
/// **Not for [`crate::overlay::StyleSpanOverlay`] or
|
||||||
|
/// [`crate::overlay::VirtualCellOverlay`]**: those are documented as
|
||||||
|
/// viewport-relative, so their columns are already screen columns
|
||||||
|
/// and translating them twice would be the mirror defect.
|
||||||
|
#[must_use]
|
||||||
|
pub fn visible_cols(&self, start_col: u32, end_col: u32) -> Option<(u32, u32)> {
|
||||||
|
let left = self.left_edge();
|
||||||
|
let right = left.saturating_add(self.cell_size.cols);
|
||||||
|
let start = start_col.max(left);
|
||||||
|
let end = end_col.min(right);
|
||||||
|
// A range that begins off-screen left and reaches past the edge
|
||||||
|
// is CLIPPED, not skipped — that is the selection defect this
|
||||||
|
// returns `Some` for.
|
||||||
|
(end > start).then(|| (start - left, end - left))
|
||||||
|
}
|
||||||
|
|
||||||
/// Row offset within this viewport for source `line`, given the
|
/// Row offset within this viewport for source `line`, given the
|
||||||
/// viewport's first (visible) source line.
|
/// viewport's first (visible) source line.
|
||||||
///
|
///
|
||||||
|
|
@ -484,6 +569,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
assert_eq!(vp.row_offset_of(4, 4), Some(0));
|
assert_eq!(vp.row_offset_of(4, 4), Some(0));
|
||||||
assert_eq!(vp.row_offset_of(4, 9), Some(5));
|
assert_eq!(vp.row_offset_of(4, 9), Some(5));
|
||||||
|
|
@ -505,6 +591,7 @@ mod tests {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: Some(&map),
|
folds: Some(&map),
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
assert_eq!(vp.row_offset_of(0, 1), Some(1), "the head keeps its row");
|
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");
|
assert_eq!(vp.row_offset_of(0, 3), None, "hidden lines have no row");
|
||||||
|
|
|
||||||
|
|
@ -372,6 +372,18 @@ pub struct Window {
|
||||||
pub selection: Option<Selection>,
|
pub selection: Option<Selection>,
|
||||||
/// First buffer line shown at the top of this window's viewport.
|
/// First buffer line shown at the top of this window's viewport.
|
||||||
pub view_top: usize,
|
pub view_top: usize,
|
||||||
|
/// First display column shown at the left of this window's viewport
|
||||||
|
/// — the horizontal scroll offset (Stage 4, framing Q#HS7).
|
||||||
|
///
|
||||||
|
/// **Per window**, exactly as `view_top` is: two panes on one buffer
|
||||||
|
/// must scroll independently. Note the deliberate asymmetry with
|
||||||
|
/// `ui.line-wrap`, which is **buffer**-local — the two halves of one
|
||||||
|
/// user-facing concept live at different scopes, accepted in Stage
|
||||||
|
/// 3's Q#LL2 as a decision rather than discovered here.
|
||||||
|
///
|
||||||
|
/// Always `0` while this window's buffer wraps; see
|
||||||
|
/// [`LayoutCtx::effective_left`](crate::view::LayoutCtx::effective_left).
|
||||||
|
pub view_left: u32,
|
||||||
/// Sticky display column for vertical motion.
|
/// Sticky display column for vertical motion.
|
||||||
pub goal_col: Option<u32>,
|
pub goal_col: Option<u32>,
|
||||||
/// Number of text rows that fit in this window's viewport at last
|
/// Number of text rows that fit in this window's viewport at last
|
||||||
|
|
@ -426,6 +438,7 @@ impl Window {
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
selection: None,
|
selection: None,
|
||||||
view_top: 0,
|
view_top: 0,
|
||||||
|
view_left: 0,
|
||||||
goal_col: None,
|
goal_col: None,
|
||||||
last_visible_rows: 0,
|
last_visible_rows: 0,
|
||||||
last_content_cols: 0,
|
last_content_cols: 0,
|
||||||
|
|
@ -450,6 +463,7 @@ impl Window {
|
||||||
crate::view::LayoutCtx {
|
crate::view::LayoutCtx {
|
||||||
cols: self.last_content_cols,
|
cols: self.last_content_cols,
|
||||||
wrap: self.last_wrap,
|
wrap: self.last_wrap,
|
||||||
|
view_left: self.view_left,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,7 @@ fn render_active_window_to_grid(
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,394 @@
|
||||||
|
//! Horizontal scroll acceptance (`QoL` Stage 4,
|
||||||
|
//! `docs/horizontal-scroll-framing.md`).
|
||||||
|
//!
|
||||||
|
//! Stage 3 shipped `ui.line-wrap`; under `truncate` the text past the
|
||||||
|
//! right edge was **unreachable**. Stage 4 makes it reachable by moving
|
||||||
|
//! the cursor — automatic only, no commands (Q#HS2).
|
||||||
|
//!
|
||||||
|
//! # The contract these tests are the oracle for
|
||||||
|
//!
|
||||||
|
//! `view_left` is an **unsnapped** per-window display column, and each
|
||||||
|
//! line derives its own **effective edge** (Q#HS7(c′)). A setter-time
|
||||||
|
//! snap was the first design and cannot exist: one column can bisect a
|
||||||
|
//! wide glyph on one line and be an ordinary boundary on the next, so no
|
||||||
|
//! single snapped value is canonical for every visible line.
|
||||||
|
//!
|
||||||
|
//! That is why the discriminating witness here is **multi-line with
|
||||||
|
//! differing glyph widths at the same column**. A single-line sweep
|
||||||
|
//! passes against the withdrawn design and proves nothing.
|
||||||
|
|
||||||
|
use pmacs::buffer::{Buffer, BufferId};
|
||||||
|
use pmacs::cell::{Cell, CellCoord, CellGrid, CellSize, Glyph};
|
||||||
|
use pmacs::text_view::TextView;
|
||||||
|
use pmacs::view::{DisplayCoord, LayoutCtx, View, Viewport, WrapMode};
|
||||||
|
|
||||||
|
fn attached(text: &[u8]) -> (Buffer, TextView) {
|
||||||
|
let buf = Buffer::from_bytes(BufferId::next(), "test", text);
|
||||||
|
let view = TextView::new(&buf);
|
||||||
|
(buf, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ctx(cols: u32, wrap: WrapMode, view_left: u32) -> LayoutCtx {
|
||||||
|
LayoutCtx {
|
||||||
|
cols,
|
||||||
|
wrap,
|
||||||
|
view_left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render and return each grid row's text.
|
||||||
|
fn rows_of(text: &[u8], rows: u32, cols: u32, wrap: WrapMode, view_left: u32) -> Vec<String> {
|
||||||
|
let (buf, mut view) = attached(text);
|
||||||
|
let mut storage = vec![Cell::default(); (rows * cols) as usize];
|
||||||
|
let mut grid = CellGrid {
|
||||||
|
cells: &mut storage,
|
||||||
|
stride: cols,
|
||||||
|
size: CellSize::new(rows, cols),
|
||||||
|
};
|
||||||
|
view.render(
|
||||||
|
&buf,
|
||||||
|
Viewport {
|
||||||
|
buffer_start: 0,
|
||||||
|
buffer_end: buf.len(),
|
||||||
|
cell_origin: CellCoord::new(0, 0),
|
||||||
|
cell_size: CellSize::new(rows, cols),
|
||||||
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
|
wrap,
|
||||||
|
view_left,
|
||||||
|
},
|
||||||
|
&mut grid,
|
||||||
|
);
|
||||||
|
(0..rows)
|
||||||
|
.map(|r| {
|
||||||
|
(0..cols)
|
||||||
|
.map(|c| match storage[(r * cols + c) as usize].glyph {
|
||||||
|
Glyph::Char(ch) => ch,
|
||||||
|
// Rendered as a distinct marker so a test can tell
|
||||||
|
// "wide glyph's second cell" from "blank".
|
||||||
|
Glyph::Continuation => '\u{1}',
|
||||||
|
Glyph::Cluster(_) => ' ',
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The report's own case: text past the edge becomes visible.
|
||||||
|
#[test]
|
||||||
|
fn scrolling_right_reveals_text_past_the_edge() {
|
||||||
|
let text = b"ABCDEFGHIJKL";
|
||||||
|
assert_eq!(rows_of(text, 1, 4, WrapMode::Truncate, 0)[0], "ABCD");
|
||||||
|
assert_eq!(rows_of(text, 1, 4, WrapMode::Truncate, 4)[0], "EFGH");
|
||||||
|
assert_eq!(
|
||||||
|
rows_of(text, 1, 4, WrapMode::Truncate, 8)[0],
|
||||||
|
"IJKL",
|
||||||
|
"the tail of a long line is reachable, which is the whole of the \
|
||||||
|
report Stage 3 could only half-answer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `view_left` must be **inert** under `wrap`, not merely harmless.
|
||||||
|
///
|
||||||
|
/// A wrapped line has nothing past the right edge, so an offset there
|
||||||
|
/// could only hide text nothing would scroll back to. Pinned to 0 by
|
||||||
|
/// `LayoutCtx::effective_left` rather than by callers remembering.
|
||||||
|
#[test]
|
||||||
|
fn wrap_ignores_a_horizontal_offset() {
|
||||||
|
let text = b"ABCDEFGH";
|
||||||
|
let unscrolled = rows_of(text, 2, 4, WrapMode::Wrap, 0);
|
||||||
|
for offset in [1, 4, 7, 99] {
|
||||||
|
assert_eq!(
|
||||||
|
rows_of(text, 2, 4, WrapMode::Wrap, offset),
|
||||||
|
unscrolled,
|
||||||
|
"offset {offset} changed a wrapped render; it must be inert"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(unscrolled[0], "ABCD");
|
||||||
|
assert_eq!(unscrolled[1], "EFGH");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The discriminating witness** (framing Q#HS7(c′)/(d)).
|
||||||
|
///
|
||||||
|
/// At one `view_left`, one line takes the straddle path and another the
|
||||||
|
/// ordinary path. A setter-time snap has a single value to choose and
|
||||||
|
/// must be wrong for one of these two lines; a per-line effective edge
|
||||||
|
/// is right for both.
|
||||||
|
#[test]
|
||||||
|
fn one_offset_straddles_on_one_line_and_not_another() {
|
||||||
|
// Line 0: a wide glyph occupying columns 1-2, so column 2 bisects it.
|
||||||
|
// Line 1: all narrow, so column 2 is an ordinary boundary.
|
||||||
|
let text = "a\u{4e00}bcd\nabcd".as_bytes();
|
||||||
|
let out = rows_of(text, 2, 3, WrapMode::Truncate, 2);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
out[0].chars().next(),
|
||||||
|
Some(' '),
|
||||||
|
"the bisected glyph's trailing cell is a styled BLANK — not a \
|
||||||
|
Continuation, which would name a leading cell nobody painted"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
&out[0][1..],
|
||||||
|
"bc",
|
||||||
|
"and the rest of that line follows it normally"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out[1], "cd ",
|
||||||
|
"the same offset on an all-narrow line is an ordinary boundary"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bisected glyph's trailing cell is designated to the glyph's
|
||||||
|
/// **start** byte, so clicking it selects the character it belongs to.
|
||||||
|
///
|
||||||
|
/// Forward-rounding here would designate the NEXT character and leave
|
||||||
|
/// the straddling glyph with no visible cell mapping to it at all —
|
||||||
|
/// unreachable exactly when it is what the user scrolled toward.
|
||||||
|
#[test]
|
||||||
|
fn the_bisected_cell_maps_back_to_its_own_glyph() {
|
||||||
|
let (buf, view) = attached("a\u{4e00}bcd".as_bytes());
|
||||||
|
let c = ctx(3, WrapMode::Truncate, 2);
|
||||||
|
// 'a' is byte 0; the wide glyph is bytes 1..4.
|
||||||
|
assert_eq!(
|
||||||
|
view.display_to_pos(&buf, DisplayCoord::new(0, 0), c),
|
||||||
|
Some(1),
|
||||||
|
"screen column 0 is the wide glyph's trailing cell"
|
||||||
|
);
|
||||||
|
// …and the round trip: the glyph reports that same cell.
|
||||||
|
assert_eq!(
|
||||||
|
view.pos_to_display(&buf, 1, c).map(|d| d.col),
|
||||||
|
Some(0),
|
||||||
|
"place_of_byte designates cell 0, so byte_at_place inverts it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tab straddling the edge keeps **forward** rounding (Q#HS7(c″)).
|
||||||
|
///
|
||||||
|
/// This is a REGRESSION witness, not a new claim: `display_to_pos`
|
||||||
|
/// already rounds forward for a column inside a tab's expansion. Stage 4
|
||||||
|
/// must not perturb it — and it would, if the walk were "optimized" to
|
||||||
|
/// start at the effective edge instead of column 0, because tab stops
|
||||||
|
/// are computed from the line start.
|
||||||
|
#[test]
|
||||||
|
fn a_straddling_tab_still_rounds_forward() {
|
||||||
|
// Tab expands to columns 0..8 at the default tab width; 'x' is byte 1.
|
||||||
|
let (buf, view) = attached(b"\txyz");
|
||||||
|
let unscrolled =
|
||||||
|
view.display_to_pos(&buf, DisplayCoord::new(0, 4), ctx(8, WrapMode::Truncate, 0));
|
||||||
|
assert_eq!(unscrolled, Some(1), "precondition: forward rounding today");
|
||||||
|
|
||||||
|
// Same absolute column 4, now reached as screen column 0 with the
|
||||||
|
// expansion's leading cells scrolled off.
|
||||||
|
assert_eq!(
|
||||||
|
view.display_to_pos(&buf, DisplayCoord::new(0, 0), ctx(8, WrapMode::Truncate, 4)),
|
||||||
|
Some(1),
|
||||||
|
"scroll must not change where a tab-interior column lands"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tab stops are preserved because the walk still starts at column 0.
|
||||||
|
#[test]
|
||||||
|
fn tab_stops_survive_a_horizontal_offset() {
|
||||||
|
// "a\tb": the tab advances to the next multiple of the tab width, so
|
||||||
|
// 'b' sits at column 8 regardless of what is scrolled off.
|
||||||
|
let full = rows_of(b"a\tb", 1, 12, WrapMode::Truncate, 0);
|
||||||
|
assert_eq!(full[0].chars().nth(8), Some('b'), "precondition");
|
||||||
|
|
||||||
|
let scrolled = rows_of(b"a\tb", 1, 12, WrapMode::Truncate, 6);
|
||||||
|
assert_eq!(
|
||||||
|
scrolled[0].chars().next(),
|
||||||
|
Some(' '),
|
||||||
|
"column 6 is still inside the tab's expansion"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scrolled[0].chars().nth(2),
|
||||||
|
Some('b'),
|
||||||
|
"'b' is at absolute column 8, so screen column 8-6=2 — a walk \
|
||||||
|
restarted at the edge would put it at 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round-trip identity at a non-zero offset, walked exhaustively — the
|
||||||
|
/// Q#HS7(d) invariant, on the ordinary (non-straddling) path.
|
||||||
|
#[test]
|
||||||
|
fn round_trip_is_identity_at_a_non_zero_offset() {
|
||||||
|
let (buf, view) = attached(b"abcdefghij");
|
||||||
|
let c = ctx(4, WrapMode::Truncate, 3);
|
||||||
|
// Bytes 3.. are at or right of the edge; earlier ones are off-screen
|
||||||
|
// and report None rather than clamping.
|
||||||
|
for pos in 0..3u64 {
|
||||||
|
assert_eq!(
|
||||||
|
view.pos_to_display(&buf, pos, c),
|
||||||
|
None,
|
||||||
|
"byte {pos} is left of the edge: not visible, never clamped \
|
||||||
|
to column 0 — clamping would make many bytes share one cell"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for pos in 3..=10u64 {
|
||||||
|
let coord = view.pos_to_display(&buf, pos, c).expect("visible");
|
||||||
|
assert_eq!(
|
||||||
|
view.display_to_pos(&buf, coord, c),
|
||||||
|
Some(pos),
|
||||||
|
"round trip must be identity at byte {pos}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Decorations must travel WITH the text (review P1)
|
||||||
|
//
|
||||||
|
// Stage 4's first commit translated the base glyph walk and nothing
|
||||||
|
// else. Every buffer-coordinate decorator — syntax/LSP styling,
|
||||||
|
// diagnostic underlines, search washes, `BufferStyleOverlay`, and the
|
||||||
|
// selection painter — kept clamping `start_col..end_col` straight onto
|
||||||
|
// `cell_origin.col`. At `view_left = 10` the glyph from source column 10
|
||||||
|
// painted at screen column 0 while its style painted at screen column 10
|
||||||
|
// or vanished: decorations drifting off the characters they describe,
|
||||||
|
// silently, and only once a window had been scrolled.
|
||||||
|
//
|
||||||
|
// `Viewport::visible_cols` is the one rule they now share, across FIVE
|
||||||
|
// adopters: four decorator families — syntax/LSP styling, diagnostic
|
||||||
|
// underlines, search washes, `BufferStyleOverlay` — plus the selection
|
||||||
|
// painter, whose own witnesses live in `src/editor.rs` because
|
||||||
|
// `paint_local_selection` is private.
|
||||||
|
//
|
||||||
|
// These witnesses pin the rule from three directions, because the
|
||||||
|
// decorator sites were textually identical and a single test would have
|
||||||
|
// let a missed adopter through.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use pmacs::cell::Style;
|
||||||
|
use pmacs::overlay::{BufferStyleOverlay, BufferStyleSpan};
|
||||||
|
|
||||||
|
/// `(glyph, is_styled)` per cell of row 0 — decoration read against the
|
||||||
|
/// character it is supposed to be describing.
|
||||||
|
fn row0_with_styles(
|
||||||
|
text: &[u8],
|
||||||
|
cols: u32,
|
||||||
|
view_left: u32,
|
||||||
|
spans: Vec<BufferStyleSpan>,
|
||||||
|
) -> Vec<(char, bool)> {
|
||||||
|
let (buf, mut view) = attached(text);
|
||||||
|
let mut storage = vec![Cell::default(); cols as usize];
|
||||||
|
let mut grid = CellGrid {
|
||||||
|
cells: &mut storage,
|
||||||
|
stride: cols,
|
||||||
|
size: CellSize::new(1, cols),
|
||||||
|
};
|
||||||
|
let viewport = Viewport {
|
||||||
|
buffer_start: 0,
|
||||||
|
buffer_end: buf.len(),
|
||||||
|
cell_origin: CellCoord::new(0, 0),
|
||||||
|
cell_size: CellSize::new(1, cols),
|
||||||
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left,
|
||||||
|
};
|
||||||
|
view.render(&buf, viewport, &mut grid);
|
||||||
|
let store: pmacs::overlay::SharedBufferStyleSpans = Arc::new(Mutex::new(spans));
|
||||||
|
let mut overlay = BufferStyleOverlay::new(store);
|
||||||
|
overlay.render(&buf, viewport, &mut grid);
|
||||||
|
(0..cols as usize)
|
||||||
|
.map(|c| {
|
||||||
|
let g = match storage[c].glyph {
|
||||||
|
Glyph::Char(ch) => ch,
|
||||||
|
_ => ' ',
|
||||||
|
};
|
||||||
|
(g, storage[c].style != Style::default())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn styled(start: u64, end: u64) -> BufferStyleSpan {
|
||||||
|
BufferStyleSpan {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
style: Style {
|
||||||
|
bold: true,
|
||||||
|
..Style::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A style span sits on the characters it names, at a non-zero offset.
|
||||||
|
#[test]
|
||||||
|
fn a_style_span_travels_with_its_characters() {
|
||||||
|
// Style covers bytes 4..6 ("EF"), which scroll to screen columns 0..2.
|
||||||
|
let out = row0_with_styles(b"ABCDEFGHIJ", 4, 4, vec![styled(4, 6)]);
|
||||||
|
let text: String = out.iter().map(|(g, _)| *g).collect();
|
||||||
|
assert_eq!(text, "EFGH", "precondition: the glyphs did translate");
|
||||||
|
assert_eq!(
|
||||||
|
out.iter().map(|(_, s)| *s).collect::<Vec<_>>(),
|
||||||
|
vec![true, true, false, false],
|
||||||
|
"the style must land on E and F — before the fix it painted at \
|
||||||
|
absolute columns 4..6, i.e. screen columns 4..6, off this window"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A span beginning off-screen and reaching into view is CLIPPED, not
|
||||||
|
/// dropped — the boundary the selection painter got wrong.
|
||||||
|
#[test]
|
||||||
|
fn a_span_starting_off_screen_still_paints_its_visible_tail() {
|
||||||
|
// Style covers bytes 2..6 ("CDEF"); C and D are scrolled off.
|
||||||
|
let out = row0_with_styles(b"ABCDEFGHIJ", 4, 4, vec![styled(2, 6)]);
|
||||||
|
assert_eq!(
|
||||||
|
out.iter().map(|(_, s)| *s).collect::<Vec<_>>(),
|
||||||
|
vec![true, true, false, false],
|
||||||
|
"the visible tail (E, F) must still be styled; skipping the whole \
|
||||||
|
span because it starts left of the edge is the defect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And a span entirely left of the edge paints nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_span_entirely_off_screen_paints_nothing() {
|
||||||
|
let out = row0_with_styles(b"ABCDEFGHIJ", 4, 4, vec![styled(0, 3)]);
|
||||||
|
assert!(
|
||||||
|
out.iter().all(|(_, s)| !*s),
|
||||||
|
"a span that ends before the edge must not paint — clamping it to \
|
||||||
|
column 0 instead would smear it onto unrelated text"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Under `wrap` the decorator translation is inert too, matching the
|
||||||
|
/// base walk.
|
||||||
|
#[test]
|
||||||
|
fn decorations_ignore_the_offset_under_wrap() {
|
||||||
|
let (buf, _) = attached(b"ABCDEFGH");
|
||||||
|
let _ = buf;
|
||||||
|
let a = row0_with_styles(b"ABCDEFGH", 4, 0, vec![styled(0, 2)]);
|
||||||
|
// Same span, non-zero offset, wrapping: `left_edge()` pins to 0.
|
||||||
|
let (buf2, mut view2) = attached(b"ABCDEFGH");
|
||||||
|
let mut storage = vec![Cell::default(); 4];
|
||||||
|
let mut grid = CellGrid {
|
||||||
|
cells: &mut storage,
|
||||||
|
stride: 4,
|
||||||
|
size: CellSize::new(1, 4),
|
||||||
|
};
|
||||||
|
let viewport = Viewport {
|
||||||
|
buffer_start: 0,
|
||||||
|
buffer_end: buf2.len(),
|
||||||
|
cell_origin: CellCoord::new(0, 0),
|
||||||
|
cell_size: CellSize::new(1, 4),
|
||||||
|
gutter_w: 0,
|
||||||
|
folds: None,
|
||||||
|
wrap: WrapMode::Wrap,
|
||||||
|
view_left: 4,
|
||||||
|
};
|
||||||
|
view2.render(&buf2, viewport, &mut grid);
|
||||||
|
let store: pmacs::overlay::SharedBufferStyleSpans = Arc::new(Mutex::new(vec![styled(0, 2)]));
|
||||||
|
let mut overlay = BufferStyleOverlay::new(store);
|
||||||
|
overlay.render(&buf2, viewport, &mut grid);
|
||||||
|
let wrapped: Vec<bool> = (0..4)
|
||||||
|
.map(|c| storage[c].style != Style::default())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
wrapped,
|
||||||
|
a.iter().map(|(_, s)| *s).collect::<Vec<_>>(),
|
||||||
|
"a wrapped render must ignore the offset for decorations exactly \
|
||||||
|
as it does for glyphs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -163,6 +163,7 @@ fn paint_active_window(s: &EditorState, rows: u32, cols: u32) -> Vec<pmacs::cell
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
|
||||||
|
|
@ -120,16 +120,17 @@ fn the_end_of_a_long_line_reaches_the_terminal() {
|
||||||
quit(&mut pty);
|
quit(&mut pty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The control that makes the marker above mean something: pinned to
|
/// `truncate` clips at the edge — the control that makes the marker
|
||||||
/// `truncate`, the same fixture in the same terminal never emits the
|
/// above discriminating.
|
||||||
/// tail.
|
|
||||||
///
|
///
|
||||||
/// This is also the honest statement of what `truncate` costs today.
|
/// **Scoped to the initial frame on purpose.** Before Stage 4 this
|
||||||
/// Those bytes are not merely off-screen, they are unreachable — there
|
/// asserted the tail was never emitted *at all*, which was true because
|
||||||
/// is no horizontal scrolling yet, which is why `wrap` is the default
|
/// the text was unreachable. It is no longer: moving the cursor now
|
||||||
/// and why `ui.toggle-line-wrap` says so when it turns wrapping off.
|
/// scrolls the view. So the claim narrows to what `truncate` still
|
||||||
|
/// means — the tail is not on screen until something moves — and the
|
||||||
|
/// test below is what proves the rest.
|
||||||
#[test]
|
#[test]
|
||||||
fn truncate_leaves_the_end_of_the_line_unreachable() {
|
fn truncate_clips_the_end_of_the_line_until_something_moves() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut pty = spawn(
|
let mut pty = spawn(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
|
|
@ -145,11 +146,43 @@ fn truncate_leaves_the_end_of_the_line_unreachable() {
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!contains(&pty.output(), TAIL),
|
!contains(&pty.output(), TAIL),
|
||||||
"truncate must clip at the edge — emitting the tail would mean \
|
"truncate must clip at the edge on the initial frame — emitting \
|
||||||
the mode reached the resolver but not the renderer, which is \
|
the tail here would mean the mode reached the resolver but not \
|
||||||
exactly the defect the rendered witnesses in \
|
the renderer, the defect the rendered witnesses in \
|
||||||
line_wrap_acceptance.rs guard from the other side"
|
line_wrap_acceptance.rs guard from the other side"
|
||||||
);
|
);
|
||||||
|
|
||||||
quit(&mut pty);
|
quit(&mut pty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Stage 4, and the point of the lane**: under `truncate`, moving the
|
||||||
|
/// cursor toward the end of a long line brings the end into view.
|
||||||
|
///
|
||||||
|
/// This test is the reason the control above had to be rewritten. Its
|
||||||
|
/// predecessor asserted the tail is *never* emitted, and that assertion
|
||||||
|
/// was a statement of the defect, not of the design — so updating it is
|
||||||
|
/// itself the proof the caveat is gone (framing §4).
|
||||||
|
///
|
||||||
|
/// `C-e` (end of line) is the motion, because it is one keystroke and
|
||||||
|
/// it is what a user reaching for the end of a line actually presses.
|
||||||
|
#[test]
|
||||||
|
fn moving_the_cursor_past_the_edge_scrolls_the_view() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let mut pty = spawn(
|
||||||
|
dir.path(),
|
||||||
|
Some("pmacs.config.set('ui.line-wrap', 'truncate')\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
wait_for(&pty, HEAD, Duration::from_secs(20));
|
||||||
|
assert!(
|
||||||
|
!contains(&pty.output(), TAIL),
|
||||||
|
"precondition: the tail is off-screen before the motion, or this \
|
||||||
|
test would pass without scrolling anything"
|
||||||
|
);
|
||||||
|
|
||||||
|
pty.write_input(b"\x05").expect("C-e: end of line");
|
||||||
|
|
||||||
|
wait_for(&pty, TAIL, Duration::from_secs(20));
|
||||||
|
|
||||||
|
quit(&mut pty);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -503,6 +503,7 @@ fn render_active_window_to_grid(
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport<'static> {
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,7 @@ fn render_active_window_to_grid(
|
||||||
gutter_w: 0,
|
gutter_w: 0,
|
||||||
folds: None,
|
folds: None,
|
||||||
wrap: WrapMode::Truncate,
|
wrap: WrapMode::Truncate,
|
||||||
|
view_left: 0,
|
||||||
};
|
};
|
||||||
let mut grid = CellGrid {
|
let mut grid = CellGrid {
|
||||||
cells: &mut backing,
|
cells: &mut backing,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue