395 lines
14 KiB
Rust
395 lines
14 KiB
Rust
//! 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"
|
||
);
|
||
}
|