feat(view): horizontal scroll, text and decorations together

Stage 4 of the QoL arc, framing revision 4 (approved). Under
`truncate`, text past the right edge was UNREACHABLE; moving the cursor
now brings it into view. Automatic only — no commands, no bindings, no
new interaction island (Q#HS2).

THE CONTRACT. `view_left` is an unsnapped per-window display column
(Q#HS7(a)), and each line derives its own effective edge during the
walk it already performs from column 0. Starting at 0 is not laziness:
tab expansion depends on the absolute column from the line start, so a
walk beginning at the edge would put tab stops in the wrong place. The
walk stays line-absolute and only the emit translates.

Where the edge bisects a wide glyph on a given line (Q#HS7(c′)), its
trailing cell paints a styled BLANK rather than a `Continuation` — that
glyph means "the cell before me is a wide glyph's head", and here that
cell is off-screen, so emitting it would name a cell nobody painted.
The mapping designates that cell to the glyph's START byte, which keeps
`byte_at_place` total over visible cells and makes the character the
user scrolled toward clickable. Tabs keep FORWARD rounding (Q#HS7(c″))
— preserved, not chosen.

DECORATIONS TRAVEL WITH THE TEXT. The first version of this commit
translated the base glyph walk and nothing else, which split the frame
in half: at `view_left = 10` a glyph from source column 10 painted at
screen column 0 while its syntax style, diagnostic underline, search
wash and `BufferStyleOverlay` span painted at screen column 10 — or
vanished. Decorations drifting off the characters they describe,
silently, and only once a window had been scrolled.

Every such site carried the same two lines (`start_col.min(max_cols)`,
`end_col.min(max_cols)`), correct only while the left edge was pinned
at zero. `Viewport::visible_cols` is now the one rule all FIVE adopters
share — syntax/LSP styling, diagnostic underlines, search washes,
`BufferStyleOverlay`, and the selection painter — so a future decorator
inherits the translation instead of re-deriving it. It also subsumes
the old `end_col <= start_col` guard rather than sitting beside it.
`StyleSpanOverlay` and `VirtualCellOverlay` are deliberately untouched:
they are documented as viewport-relative, so translating them would be
the mirror defect.

The selection painter was nearly a sixth site with its own copy of the
rule, which I justified by a width it 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. It now takes that same viewport and drops
its `rect`/`gutter_w` parameters entirely. A canonical rule with one
honest exception is not canonical.

The selection painter had the same defect with a worse failure mode: it
asked `pos_to_display` through the LIVE context, which returns `None`
for a position left of the edge, so a selection beginning off-screen
and reaching into view took `continue` and painted NOTHING. That is the
common shape, not an edge case — select rightward from column 0 past
the window width and the view scrolls with the cursor.

TWO THINGS THE TESTS FOUND, both in `pos_to_display`. My framing note
said a caret sits between characters so never lands inside a glyph;
true for the caret, false for the DESIGNATION direction — the glyph's
start byte must map to its visible trailing cell, so `screen_col` needs
the straddle rule and not a bare subtraction. And the `take == 0` early
return short-circuited the translation entirely, so byte 0 looked
visible at every offset.

`view_left` is inert under `wrap` BY CONSTRUCTION —
`LayoutCtx::effective_left` and `Viewport::left_edge` return 0 while
wrapping — rather than by every caller remembering.

Persisted per leaf at DESKTOP_VERSION 1 (Q#HS5) with both approval
conditions: `#[serde(default)]` and a literal v1 JSON fixture omitting
the field, hand-written because a generated one would gain the field
and prove nothing.

Also: `view_left: window.view_left` in the render viewport, not a
literal 0. My mechanical fill put 0 there and it is EXACTLY the
`aa3cd4d` defect — coordinates and the indicator following the scroll
while the painter stays pinned at column 0.

BITE, per clause. Forcing `bisected = false` fails the multi-line
straddle witness; dropping the backward designation fails the
round-trip witness; removing `#[serde(default)]` fails the v1 fixture;
pinning `visible_cols` to an absolute clamp fails all three decorator
witnesses; restoring the selection painter's live-context lookup fails
the off-screen-start selection witness. Each alone. And with selection
now reading the shared helper, pinning `visible_cols` to an absolute
clamp fails the selection witnesses TOO — which is the check that the
duplication is really gone rather than merely reworded.

One unrelated red, logged as R7 in ci-red-signatures.md — the first
this session with a COMPLETE signature, so a matchable row rather than
a U note. `pmacs-gpu`'s managed-retry attach hit a BrokenPipe once
under full-sweep load and did not reproduce (6 isolated runs plus a
clean 113-target sweep). Per the rerun rule that is intermittence only,
and the row explicitly does not claim harmlessness. Not attributed to
this lane: Stage 4 touches no `pmacs-gpu` file and adds no wire
surface.

Gates: fmt; clippy --workspace --all-targets -D warnings, both
configurations; `cargo test --workspace --no-fail-fast -- --skip
basedpyright` 113 targets exit 0, and the same with --features crdt,
113 targets exit 0; git diff --check. No protocol change, so no version
bump and no protocol-bump matrix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-07 21:49:00 +02:00
parent 403fb7fb07
commit bec8fc9aae
No known key found for this signature in database
20 changed files with 1040 additions and 75 deletions

View File

@ -20,7 +20,15 @@
pmacs.config.define {
name = "ui.line-wrap",
description = "How a line wider than the window is shown: wrap onto following rows, or truncate at the edge.",
-- 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
-- handled. Adding "word" later is a clean additive change --- which
-- 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"
pmacs.config.set_local(buf, "ui.line-wrap", next_mode)
if next_mode == "truncate" then
pmacs.editor.set_status("line wrap off — text past the edge is unreachable until horizontal scrolling lands")
pmacs.editor.set_status("line wrap off — move the cursor past the edge to scroll (GUI: not yet)")
else
pmacs.editor.set_status("line wrap on")
end

View File

@ -457,9 +457,8 @@ 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`.
**Status: `docs/horizontal-scroll-framing.md` revision 4 — every
question answered, APPROVAL NOT YET RECORDED. No implementation may
begin until it is.**
**Status: `docs/horizontal-scroll-framing.md` revision 4 — APPROVED
2026-08-07. Implementing.**
**Answered by the user 2026-08-07:**

View File

@ -423,6 +423,28 @@ without a name there is nothing to call intermittent.
with `grep -E "FAILED|panicked|test result"`, which keeps failure
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
Has a selector, which U1 lacks — but still no fragments, so it cannot

View File

@ -1,7 +1,13 @@
# Horizontal scroll — QoL Stage 4
**Status: revision 4 — every question answered; APPROVAL NOT YET
RECORDED. No implementation may begin until it is.**
**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 |
|---|---|

View File

@ -90,6 +90,20 @@ pub struct SavedLeaf {
pub cursor: u64,
/// First visible source line.
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).
@ -277,6 +291,7 @@ pub fn snapshot(core: &EditorCore, session_key: String) -> Option<SavedDesktop>
path: path.display().to_string(),
cursor: win.cursor,
view_top: win.view_top,
view_left: win.view_left,
})
};
@ -384,6 +399,7 @@ struct RestoreLeaf {
window: WindowId,
cursor: u64,
view_top: usize,
view_left: u32,
}
/// 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) {
win.cursor = leaf.cursor;
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);
@ -514,11 +535,19 @@ fn build_restore_node(
let mut win = Window::new(wid, buffer_id, text_view);
win.cursor = cursor;
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);
leaves.push(RestoreLeaf {
window: wid,
cursor,
view_top,
view_left: leaf.view_left,
});
save_slots.push(Some(wid));
Some(LayoutNode::Leaf(wid))
@ -597,11 +626,13 @@ mod tests {
path: "/a.rs".into(),
cursor: 10,
view_top: 2,
view_left: 0,
}),
SavedNode::Leaf(SavedLeaf {
path: "/b.rs".into(),
cursor: 0,
view_top: 0,
view_left: 0,
}),
],
},
@ -611,11 +642,45 @@ mod tests {
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 {
SavedLeaf {
path: path.into(),
cursor: 0,
view_top: 0,
view_left: 0,
}
}

View File

@ -553,7 +553,6 @@ impl View for DiagnosticView {
let start_line_buf = line_at_offset(&line_offsets, viewport.buffer_start as u32);
let max_rows = viewport.cell_size.rows;
let max_cols = viewport.cell_size.cols;
let cell_origin = viewport.cell_origin;
// Column-0 line markers (gutter signs, T M4.6): most severe
@ -629,12 +628,14 @@ impl View for DiagnosticView {
};
let (start_col, end_col) =
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;
}
};
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 {
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
cell.style = merge_styles(cell.style, style);
@ -648,7 +649,9 @@ impl View for DiagnosticView {
cells,
cell_origin,
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,
theme.as_ref(),
);
@ -1141,6 +1144,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
},
&mut grid,
);
@ -1207,6 +1211,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
},
&mut grid,
);
@ -1284,6 +1289,7 @@ mod tests {
gutter_w: 2,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
},
&mut grid,
);
@ -1354,6 +1360,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
},
&mut grid,
);

View File

@ -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
/// **visible** lines when a fold map is supplied (Arc 6 Q#FD18).
///
@ -4283,10 +4313,22 @@ fn prepare_window_cursor_visible(
inner_rows: u32,
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
.pos_to_display(buf, window.cursor, window.layout_ctx())
.map_or(0, |d| d.row as usize);
.pos_to_display(buf, window.cursor, unscrolled);
let cursor_row = coord.map_or(0, |d| d.row as usize);
horizontal_follow(window, coord.map_or(0, |d| d.col));
match folds {
// The logical cursor may sit on a hidden line (a shared fold, or
// 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
// prevent.
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
// gutter numbers — before the overlays, so a diagnostic overlay
@ -4396,7 +4446,7 @@ fn paint_window_content(
for overlay in &mut window.overlays {
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
// itself is always visible regardless of overlay activity.
let coord = window
@ -5009,12 +5059,16 @@ fn paint_local_selection(
grid: &mut crate::cell::CellGrid<'_>,
buf: &crate::buffer::Buffer,
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,
// 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`.
folds: Option<&crate::fold_view::VisibleLineMap>,
theme: &crate::highlight::Theme,
@ -5047,10 +5101,9 @@ fn paint_local_selection(
..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;
}
let text_cols = rect.size.cols.saturating_sub(gutter_w);
// 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));
@ -5070,32 +5123,42 @@ fn paint_local_selection(
continue;
}
let Some(start_coord) =
window
.text_view
.pos_to_display(buf, paint_start, window.layout_ctx())
// Asked in LINE-absolute columns, then clipped below.
//
// Through the live context this dropped whole visible segments:
// `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 {
continue;
};
let Some(end_coord) = window
.text_view
.pos_to_display(buf, paint_end, window.layout_ctx())
else {
let Some(end_coord) = window.text_view.pos_to_display(buf, paint_end, unscrolled) else {
continue;
};
if start_coord.row as usize != display_row || end_coord.row as usize != display_row {
continue;
}
let start_col = start_coord.col.min(text_cols);
let end_col = end_coord.col.min(text_cols);
if start_col >= end_col {
// The one shared clip rule (Stage 4). Five adopters now read it:
// syntax/LSP styling, diagnostic underlines, search washes,
// `BufferStyleOverlay`, and this.
let Some((start_col, end_col)) = viewport.visible_cols(start_coord.col, end_coord.col)
else {
continue;
}
};
for col in start_col..end_col {
let cell = grid.at(CellCoord::new(
rect.origin.row + row_offset,
rect.origin.col + gutter_w + col,
viewport.cell_origin.row + row_offset,
viewport.cell_origin.col + col,
));
cell.style = crate::overlay::merge_styles(cell.style, overlay);
}
@ -8543,6 +8606,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let mut grid = CellGrid {
cells: &mut backing,
@ -8679,6 +8743,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
// 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"
);
}
}

View File

@ -471,7 +471,6 @@ impl View for SyntaxHighlightView {
let start_line = line_at_offset(&self.cache.line_offsets, viewport.buffer_start as u32);
let max_rows = viewport.cell_size.rows;
let max_cols = viewport.cell_size.cols;
let cell_origin = viewport.cell_origin;
let total_lines = self.cache.line_offsets.len() as u32;
@ -531,8 +530,11 @@ impl View for SyntaxHighlightView {
continue;
}
let cell_row = cell_origin.row + row_offset;
let clamped_start = start_col.min(max_cols);
let clamped_end = end_col.min(max_cols);
let Some((clamped_start, clamped_end)) =
viewport.visible_cols(start_col, end_col)
else {
continue;
};
for col in clamped_start..clamped_end {
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
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 max_rows = viewport.cell_size.rows;
let max_cols = viewport.cell_size.cols;
let cell_origin = viewport.cell_origin;
let total_lines = line_offsets.len() as u32;
@ -751,12 +752,14 @@ impl View for LspStyleView {
continue;
}
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;
}
};
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 {
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
cell.style = merge_styles(cell.style, style);
@ -1113,6 +1116,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
@ -1212,6 +1216,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
@ -1333,6 +1338,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
@ -1394,6 +1400,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let registry = buf; // keep buf alive
hv.render(&registry, viewport, &mut grid);
@ -1452,6 +1459,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let registry = buf; // keep buf alive
hv.render(&registry, viewport, &mut grid);
@ -1518,6 +1526,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let registry = buf;
hv.render(&registry, viewport, &mut grid);
@ -1586,6 +1595,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
hv.render(&buf, viewport, &mut grid);
grid.get(CellCoord::new(0, col)).style
@ -1829,6 +1839,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let registry = buf;
hv.render(&registry, viewport, &mut grid);
@ -1893,6 +1904,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let registry = buf;
hv.render(&registry, viewport, &mut grid);

View File

@ -402,8 +402,12 @@ fn render_buffer_style_span(
(style_start - line_start) as usize,
line_prefix.len(),
);
let start_col = start_col.min(viewport.cell_size.cols);
let end_col = end_col.min(viewport.cell_size.cols);
// Buffer coordinates, so they translate (Stage 4). Its siblings
// `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 {
let coord = CellCoord::new(
viewport.cell_origin.row + row_offset,
@ -498,6 +502,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
}
}

View File

@ -443,7 +443,6 @@ impl View for SearchView {
// 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 max_rows = viewport.cell_size.rows;
let max_cols = viewport.cell_size.cols;
let cell_origin = viewport.cell_origin;
// 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 (start_col, end_col) =
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;
}
};
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 {
let cell = cells.at(CellCoord::new(cell_row, cell_origin.col + col));
cell.style = merge_styles(cell.style, style);
@ -761,6 +762,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
},
&mut grid,
);
@ -791,6 +793,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
},
&mut grid2,
);
@ -834,6 +837,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
},
&mut grid,
);

View File

@ -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`,
/// column `col`, under character wrap — the inverse of
/// [`Self::place_of_byte`].
@ -301,9 +347,26 @@ impl TextView {
let r = first_row.checked_add(sub.checked_sub(skip_rows)?)?;
(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| {
// 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) {
let cell = cells.at(CellCoord::new(row, origin.col + col));
let cell = cells.at(CellCoord::new(row, origin.col + screen));
cell.glyph = glyph;
cell.style = Style::default();
cell.attachment = None;
@ -312,7 +375,7 @@ impl TextView {
let (mut sub_row, mut col) = (0u32, 0u32);
for ch in s.chars() {
if !wrapping && col >= max_cols {
if !wrapping && col >= max_cols.saturating_add(left) {
break;
}
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 {
put(cells, start_row, start_col, Glyph::Char(ch));
if end_col.saturating_sub(start_col) == 2 && start_col + 1 < max_cols {
put(cells, start_row, start_col + 1, Glyph::Continuation);
if end_col.saturating_sub(start_col) == 2
&& 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;
@ -465,7 +543,14 @@ impl View for TextView {
// answer, which means trimming the in-progress bytes).
let take = (pos - line_start) as usize;
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
// 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);
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(
@ -502,14 +601,38 @@ impl View for TextView {
let line_bytes = self.read_line_bytes(buf, row);
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_bytes: usize = 0;
for (byte_idx, ch) in s.char_indices() {
if walked_cols >= coord.col {
if walked_cols >= target {
walked_bytes = byte_idx;
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();
}
// Past the line's last codepoint: clamp to the line's visible end.
@ -954,6 +1077,7 @@ mod tests {
let ctx = LayoutCtx {
cols: 4,
wrap: WrapMode::Wrap,
view_left: 0,
};
// 'e' is byte 4: line 0, second visual row, column 0.
assert_eq!(
@ -976,6 +1100,7 @@ mod tests {
let ctx = LayoutCtx {
cols: 4,
wrap: WrapMode::Wrap,
view_left: 0,
};
assert_eq!(
view.pos_to_display(&buf, 4, ctx),
@ -1001,6 +1126,7 @@ mod tests {
let ctx = LayoutCtx {
cols,
wrap: WrapMode::Wrap,
view_left: 0,
};
for (byte, _) in text.char_indices() {
let coord = view
@ -1024,6 +1150,7 @@ mod tests {
let ctx = LayoutCtx {
cols: 4,
wrap: WrapMode::Wrap,
view_left: 0,
};
// '中' starts at byte 2 and is three bytes long.
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
/// every cell, row-major.
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` —
@ -1070,6 +1197,7 @@ mod tests {
rows: u32,
cols: u32,
wrap: WrapMode,
view_left: u32,
start: u64,
) -> Vec<Glyph> {
let (buf, mut view) = attached(text);
@ -1090,6 +1218,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap,
view_left,
},
&mut grid,
);
@ -1132,6 +1261,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Wrap,
view_left: 0,
};
view.render(&buf, vp, &mut grid);
assert!(
@ -1202,6 +1332,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Wrap,
view_left: 0,
},
&mut grid,
);
@ -1286,7 +1417,7 @@ xy",
#[test]
fn the_viewport_can_start_partway_down_a_wrapped_line() {
// 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, 1), "ij ");
}
@ -1303,7 +1434,7 @@ xy",
let row = view.row_of_byte(&buf, 0, byte as u64, cols);
// Anchoring the viewport at that byte must put the
// 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 expect = row_text(&full, cols, row);
assert_eq!(
@ -1334,6 +1465,7 @@ xy",
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
},
&mut grid,
);
@ -1364,6 +1496,7 @@ xy",
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
},
&mut grid,
);
@ -1397,6 +1530,7 @@ xy",
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
},
&mut grid,
);
@ -1431,6 +1565,7 @@ xy",
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
},
&mut grid,
);

View File

@ -171,6 +171,19 @@ pub struct LayoutCtx {
pub cols: u32,
/// The window's resolved wrap mode.
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 {
@ -184,6 +197,7 @@ impl LayoutCtx {
Self {
cols: 0,
wrap: WrapMode::Truncate,
view_left: 0,
}
}
@ -192,6 +206,18 @@ impl LayoutCtx {
pub const fn wrapping(self) -> bool {
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
@ -265,9 +291,68 @@ pub struct Viewport<'a> {
/// pre-Stage-3 sites read as *deliberately* unwrapped rather than
/// merely untouched.
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<'_> {
/// 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
/// viewport's first (visible) source line.
///
@ -484,6 +569,7 @@ mod tests {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
assert_eq!(vp.row_offset_of(4, 4), Some(0));
assert_eq!(vp.row_offset_of(4, 9), Some(5));
@ -505,6 +591,7 @@ mod tests {
gutter_w: 0,
folds: Some(&map),
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, 3), None, "hidden lines have no row");

View File

@ -372,6 +372,18 @@ pub struct Window {
pub selection: Option<Selection>,
/// First buffer line shown at the top of this window's viewport.
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.
pub goal_col: Option<u32>,
/// Number of text rows that fit in this window's viewport at last
@ -426,6 +438,7 @@ impl Window {
cursor: 0,
selection: None,
view_top: 0,
view_left: 0,
goal_col: None,
last_visible_rows: 0,
last_content_cols: 0,
@ -450,6 +463,7 @@ impl Window {
crate::view::LayoutCtx {
cols: self.last_content_cols,
wrap: self.last_wrap,
view_left: self.view_left,
}
}

View File

@ -290,6 +290,7 @@ fn render_active_window_to_grid(
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let mut grid = CellGrid {
cells: &mut backing,

View File

@ -0,0 +1,389 @@
//! 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. These
// witnesses pin it from three directions, because the four call sites
// were 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"
);
}

View File

@ -163,6 +163,7 @@ fn paint_active_window(s: &EditorState, rows: u32, cols: u32) -> Vec<pmacs::cell
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let mut grid = CellGrid {
cells: &mut backing,

View File

@ -120,16 +120,17 @@ fn the_end_of_a_long_line_reaches_the_terminal() {
quit(&mut pty);
}
/// The control that makes the marker above mean something: pinned to
/// `truncate`, the same fixture in the same terminal never emits the
/// tail.
/// `truncate` clips at the edge — the control that makes the marker
/// above discriminating.
///
/// This is also the honest statement of what `truncate` costs today.
/// Those bytes are not merely off-screen, they are unreachable — there
/// is no horizontal scrolling yet, which is why `wrap` is the default
/// and why `ui.toggle-line-wrap` says so when it turns wrapping off.
/// **Scoped to the initial frame on purpose.** Before Stage 4 this
/// asserted the tail was never emitted *at all*, which was true because
/// the text was unreachable. It is no longer: moving the cursor now
/// 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]
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 mut pty = spawn(
dir.path(),
@ -145,11 +146,43 @@ fn truncate_leaves_the_end_of_the_line_unreachable() {
assert!(
!contains(&pty.output(), TAIL),
"truncate must clip at the edge — emitting the tail would mean \
the mode reached the resolver but not the renderer, which is \
exactly the defect the rendered witnesses in \
"truncate must clip at the edge on the initial frame — emitting \
the tail here would mean the mode reached the resolver but not \
the renderer, the defect the rendered witnesses in \
line_wrap_acceptance.rs guard from the other side"
);
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);
}

View File

@ -503,6 +503,7 @@ fn render_active_window_to_grid(
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let mut grid = CellGrid {
cells: &mut backing,

View File

@ -17,6 +17,7 @@ fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport<'static> {
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
}
}

View File

@ -487,6 +487,7 @@ fn render_active_window_to_grid(
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
view_left: 0,
};
let mut grid = CellGrid {
cells: &mut backing,