1577 lines
59 KiB
Rust
1577 lines
59 KiB
Rust
// text_view.rs --- The plain-text View. ASCII, UTF-8, wide characters.
|
||
|
||
//! Plain-text view: maps rope bytes to display cells, line-aware.
|
||
//!
|
||
//! Implements the [`crate::view::View`] trait for the common case: a buffer
|
||
//! of UTF-8 text rendered as one row per line. Holds an incremental line
|
||
//! index keyed by byte offset; updates the index in place on each edit.
|
||
//!
|
||
//! # Width
|
||
//!
|
||
//! Display columns are computed via [`unicode_width`]. ASCII codepoints
|
||
//! are 1 column; CJK and similar wide characters are 2 columns (the second
|
||
//! column is filled with [`Glyph::Continuation`] in the cell grid).
|
||
//! Zero-width combining marks are skipped --- a future grapheme-aware pass
|
||
//! (M2+) will attach them to the preceding cell as a [`Glyph::Cluster`].
|
||
//!
|
||
//! # Threading
|
||
//!
|
||
//! Main thread only. The view is held inside a [`Buffer`], which is itself
|
||
//! main-only.
|
||
|
||
use crate::buffer::{Buffer, BufferError};
|
||
use crate::cell::{Cell, CellCoord, CellGrid, Glyph, Style};
|
||
use crate::display_width::{advance_char, valid_prefix_width};
|
||
use crate::rope::{Edit, Position};
|
||
use crate::view::{DisplayCoord, LayoutCtx, View, Viewport, WrapMode};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tuning
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Line-prefix lengths up to this many bytes are decoded on the stack in
|
||
/// [`TextView::pos_to_display`]; longer prefixes fall back to a heap buffer.
|
||
const STACK_CAP: usize = 256;
|
||
|
||
/// Trailing marker painted after a collapsed region's head line (Arc 6
|
||
/// Stage 2, Q#FD13). One column wide, so it never disturbs layout.
|
||
pub const FOLD_ELLIPSIS: char = '…';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TextView
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// View that renders a buffer as plain UTF-8 text, one buffer line per row.
|
||
pub struct TextView {
|
||
/// Whether the last [`View::render`] ran out of BUFFER before it ran
|
||
/// out of rows — i.e. the buffer's final visual row was on screen.
|
||
///
|
||
/// Recorded by the walk rather than recomputed, for the same reason
|
||
/// `Window::last_content_cols` is taken from the viewport: under
|
||
/// wrapping this cannot be derived from line counts, and a second
|
||
/// derivation could disagree with what was actually painted. The
|
||
/// scroll indicator reads it as a local predicate, which is what
|
||
/// lets `All`/`Top`/`Bot` stay exact with no row total in existence.
|
||
reached_buffer_end: bool,
|
||
/// Byte offsets of each line's first byte. `line_offsets[0] == 0`
|
||
/// always; `line_offsets.last()` is the start of the final line.
|
||
/// `line_offsets.len()` equals the number of lines (not the number of
|
||
/// newlines: a trailing newline produces one extra empty line).
|
||
line_offsets: Vec<u64>,
|
||
}
|
||
|
||
impl TextView {
|
||
/// Construct a `TextView` for `buf`. Walks the buffer once to build
|
||
/// the line index. Threading: main thread only.
|
||
#[must_use]
|
||
pub fn new(buf: &Buffer) -> Self {
|
||
let mut v = Self {
|
||
line_offsets: vec![0],
|
||
reached_buffer_end: false,
|
||
};
|
||
v.rebuild_lines_from(buf, 0);
|
||
v
|
||
}
|
||
|
||
/// Whether the last render reached the buffer's end — see
|
||
/// [`Self::reached_buffer_end`]. `false` before the first render.
|
||
#[must_use]
|
||
pub fn reached_buffer_end(&self) -> bool {
|
||
self.reached_buffer_end
|
||
}
|
||
|
||
/// Number of lines in the buffer, as understood by this view.
|
||
#[must_use]
|
||
pub fn line_count(&self) -> usize {
|
||
self.line_offsets.len()
|
||
}
|
||
|
||
/// First-byte offset of `line`, or `None` if `line` is out of range.
|
||
#[must_use]
|
||
pub fn line_offset(&self, line: usize) -> Option<u64> {
|
||
self.line_offsets.get(line).copied()
|
||
}
|
||
|
||
/// Index of the line containing byte `offset`.
|
||
///
|
||
/// For an offset equal to a line's first byte, returns that line. For an
|
||
/// offset past the buffer end, returns the last line.
|
||
#[must_use]
|
||
pub fn line_at_offset(&self, offset: u64) -> usize {
|
||
match self.line_offsets.binary_search(&offset) {
|
||
Ok(i) => i,
|
||
Err(i) => i.saturating_sub(1),
|
||
}
|
||
}
|
||
|
||
/// Rebuild line offsets from `start_line` onward by re-scanning the
|
||
/// buffer from `line_offsets[start_line]` to the buffer end. Lines
|
||
/// before `start_line` are left untouched.
|
||
fn rebuild_lines_from(&mut self, buf: &Buffer, start_line: usize) {
|
||
let start_offset = self.line_offsets[start_line];
|
||
self.line_offsets.truncate(start_line + 1);
|
||
|
||
let rope = buf.snapshot_rope();
|
||
let buf_end = rope.len();
|
||
if start_offset >= buf_end {
|
||
return;
|
||
}
|
||
|
||
let mut pos = start_offset;
|
||
for chunk in rope.chunks(start_offset, buf_end) {
|
||
for (i, b) in chunk.iter().enumerate() {
|
||
if *b == b'\n' {
|
||
self.line_offsets.push(pos + i as u64 + 1);
|
||
}
|
||
}
|
||
pos += chunk.len() as u64;
|
||
}
|
||
}
|
||
|
||
/// Length in bytes of `line` (excluding the trailing `\n` if any). Used
|
||
/// by render and by tests; `None` if `line` is out of range.
|
||
#[must_use]
|
||
pub fn line_len(&self, buf: &Buffer, line: usize) -> Option<u64> {
|
||
let start = *self.line_offsets.get(line)?;
|
||
let raw_end = self
|
||
.line_offsets
|
||
.get(line + 1)
|
||
.copied()
|
||
.unwrap_or(buf.len());
|
||
// If a newline terminates this line, exclude it.
|
||
if line + 1 < self.line_count() && raw_end > start {
|
||
Some(raw_end - start - 1)
|
||
} else {
|
||
Some(raw_end - start)
|
||
}
|
||
}
|
||
|
||
/// Read a line's bytes (excluding the trailing newline) into a fresh
|
||
/// `Vec<u8>`. Lines are usually small; the copy is acceptable.
|
||
fn read_line_bytes(&self, buf: &Buffer, line: usize) -> Vec<u8> {
|
||
let Some(line_start) = self.line_offsets.get(line).copied() else {
|
||
return Vec::new();
|
||
};
|
||
let Some(line_len) = self.line_len(buf, line) else {
|
||
return Vec::new();
|
||
};
|
||
let mut out = vec![0u8; line_len as usize];
|
||
if !out.is_empty() {
|
||
buf.snapshot_rope()
|
||
.slice(line_start, line_start + line_len, &mut out);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Which visual row of `line` holds byte `within` (relative to the
|
||
/// line's start), at `max_cols` columns under character wrap.
|
||
///
|
||
/// Total: any offset is legal, and one past the line's end lands on
|
||
/// its last row. `max_cols == 0` yields row 0 rather than looping.
|
||
/// Which visual row of `line` holds byte `within`, discarding the
|
||
/// column. Thin wrapper over [`Self::place_of_byte`].
|
||
fn row_of_byte(&self, buf: &Buffer, line: usize, within: u64, max_cols: u32) -> u32 {
|
||
self.place_of_byte(buf, line, within, max_cols).0
|
||
}
|
||
|
||
/// Where byte `within` (relative to `line`'s start) sits under
|
||
/// character wrap, as `(visual row, column)`.
|
||
///
|
||
/// Total: any offset is legal, and one past the line's end lands
|
||
/// just after its last character. A byte **inside** a multi-byte
|
||
/// codepoint yields that codepoint's own place — the same
|
||
/// projection `valid_prefix_width` performs on the unwrapped path,
|
||
/// so the interior-byte contract is unchanged by wrapping.
|
||
fn place_of_byte(&self, buf: &Buffer, line: usize, within: u64, max_cols: u32) -> (u32, u32) {
|
||
if max_cols == 0 {
|
||
return (0, 0);
|
||
}
|
||
let bytes = self.read_line_bytes(buf, line);
|
||
let Ok(s) = std::str::from_utf8(&bytes) else {
|
||
return (0, 0);
|
||
};
|
||
let (mut row, mut col, mut seen) = (0u32, 0u32, 0u64);
|
||
for ch in s.chars() {
|
||
if seen >= within {
|
||
break;
|
||
}
|
||
let (start_row, start_col, end_row, end_col) =
|
||
advance_wrapped(row, col, ch, max_cols, true);
|
||
seen += ch.len_utf8() as u64;
|
||
if seen > within {
|
||
// `within` fell inside this character: project to the
|
||
// character's own start, which is where it is drawn.
|
||
return (start_row, start_col);
|
||
}
|
||
row = end_row;
|
||
col = end_col;
|
||
}
|
||
// A position that lands exactly on a row boundary belongs to
|
||
// column 0 of the NEXT row, not one past the end of the last
|
||
// one (framing §7: the wrap position is owned downstream). The
|
||
// downstream cell always exists; `(row, max_cols)` does not.
|
||
if col >= max_cols {
|
||
(row.saturating_add(1), 0)
|
||
} else {
|
||
(row, col)
|
||
}
|
||
}
|
||
|
||
/// 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`].
|
||
///
|
||
/// Rounds forward to the next character boundary when the column
|
||
/// lands inside a wide glyph, matching the unwrapped
|
||
/// `display_to_pos`. A row past the line's height clamps to the
|
||
/// line's end.
|
||
fn byte_at_place(
|
||
&self,
|
||
buf: &Buffer,
|
||
line: usize,
|
||
sub_row: u32,
|
||
col: u32,
|
||
max_cols: u32,
|
||
) -> u64 {
|
||
let bytes = self.read_line_bytes(buf, line);
|
||
let Ok(s) = std::str::from_utf8(&bytes) else {
|
||
return 0;
|
||
};
|
||
if max_cols == 0 {
|
||
return 0;
|
||
}
|
||
let (mut row, mut c, mut walked) = (0u32, 0u32, 0u64);
|
||
for ch in s.chars() {
|
||
let (start_row, start_col, end_row, end_col) =
|
||
advance_wrapped(row, c, ch, max_cols, true);
|
||
if start_row > sub_row || (start_row == sub_row && start_col >= col) {
|
||
return walked;
|
||
}
|
||
walked += ch.len_utf8() as u64;
|
||
row = end_row;
|
||
c = end_col;
|
||
}
|
||
walked
|
||
}
|
||
|
||
/// Paint one source line and report how many grid rows it used.
|
||
///
|
||
/// `first_row` is where the line begins in the viewport; `skip_rows`
|
||
/// drops that many of the line's own leading visual rows, which is
|
||
/// non-zero only for the first line when the byte anchor sits partway
|
||
/// down it (framing Q#LL6).
|
||
///
|
||
/// Under [`WrapMode::Truncate`] this returns 1 and walks exactly as
|
||
/// the pre-wrap renderer did — the identity case the staging rests on.
|
||
fn paint_line(
|
||
&self,
|
||
buf: &Buffer,
|
||
line: usize,
|
||
viewport: Viewport<'_>,
|
||
cells: &mut CellGrid<'_>,
|
||
place: LinePlacement,
|
||
) -> u32 {
|
||
let LinePlacement {
|
||
first_row,
|
||
skip_rows,
|
||
is_fold_head,
|
||
} = place;
|
||
let max_rows = viewport.cell_size.rows;
|
||
let max_cols = viewport.cell_size.cols;
|
||
let origin = viewport.cell_origin;
|
||
let wrapping = viewport.wrap == WrapMode::Wrap;
|
||
|
||
// A zero-width content area has no cell to paint into. Bail
|
||
// before the walk rather than inside it: under `Wrap` the first
|
||
// `col >= max_cols` test is true immediately, so the walk would
|
||
// advance a row and then index column 0 of a zero-width grid.
|
||
// Reachable whenever the gutter consumes the window's width.
|
||
if max_cols == 0 {
|
||
return 1;
|
||
}
|
||
let line_bytes = self.read_line_bytes(buf, line);
|
||
let Ok(s) = std::str::from_utf8(&line_bytes) else {
|
||
return 1;
|
||
};
|
||
|
||
// `sub_row` counts this line's own visual rows. The grid row is
|
||
// derived and is `None` while still skipping, or once past the
|
||
// viewport's bottom.
|
||
let grid_row = |sub: u32| -> Option<u32> {
|
||
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 + screen));
|
||
cell.glyph = glyph;
|
||
cell.style = Style::default();
|
||
cell.attachment = None;
|
||
}
|
||
};
|
||
|
||
let (mut sub_row, mut col) = (0u32, 0u32);
|
||
for ch in s.chars() {
|
||
if !wrapping && col >= max_cols.saturating_add(left) {
|
||
break;
|
||
}
|
||
let (start_row, start_col, end_row, end_col) =
|
||
advance_wrapped(sub_row, col, ch, max_cols, wrapping);
|
||
if start_row >= skip_rows && grid_row(start_row).is_none() {
|
||
sub_row = start_row;
|
||
break;
|
||
}
|
||
if ch == '\t' {
|
||
for c in start_col..end_col {
|
||
put(cells, start_row, c, Glyph::Char(' '));
|
||
}
|
||
} 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.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;
|
||
col = end_col;
|
||
}
|
||
|
||
// The head of a collapsed region carries a trailing ellipsis in
|
||
// the CONTENT area (Q#FD13/FD20): the authoritative,
|
||
// layout-neutral fold indicator, present in every gutter state,
|
||
// clipped like any long line.
|
||
if is_fold_head {
|
||
for marker in [' ', FOLD_ELLIPSIS] {
|
||
if col >= max_cols {
|
||
if !wrapping {
|
||
break;
|
||
}
|
||
sub_row += 1;
|
||
col = 0;
|
||
if sub_row >= skip_rows && grid_row(sub_row).is_none() {
|
||
break;
|
||
}
|
||
}
|
||
put(cells, sub_row, col, Glyph::Char(marker));
|
||
col += 1;
|
||
}
|
||
}
|
||
|
||
sub_row.saturating_sub(skip_rows) + 1
|
||
}
|
||
}
|
||
|
||
/// Where a line goes in the viewport, for [`TextView::paint_line`].
|
||
#[derive(Copy, Clone, Debug)]
|
||
struct LinePlacement {
|
||
/// Grid row (viewport-relative) where this line begins.
|
||
first_row: u32,
|
||
/// Leading visual rows of the line to drop, non-zero only for the
|
||
/// first line when the byte anchor sits partway down it.
|
||
skip_rows: u32,
|
||
/// Whether the line heads a collapsed region and owes an ellipsis.
|
||
is_fold_head: bool,
|
||
}
|
||
|
||
/// Where a character is drawn, and where it leaves the cursor, under
|
||
/// character wrap.
|
||
///
|
||
/// **This is the wrap rule and it exists exactly once.** Both
|
||
/// [`TextView::row_of_byte`] and [`TextView::paint_line`] go through it,
|
||
/// because they must agree perfectly: the first decides which visual row
|
||
/// the viewport's byte anchor sits on, the second decides which row the
|
||
/// text is drawn on. Two copies that drifted by one row would scroll the
|
||
/// buffer to a position it does not render — a defect with no local
|
||
/// symptom, and the exact shape this lane keeps finding.
|
||
///
|
||
/// Returns `(start_row, start_col, end_row, end_col)`: where the
|
||
/// character itself goes (it may already have moved to the next row),
|
||
/// and where the following character resumes.
|
||
///
|
||
/// With `wrapping == false` the row never advances and the column
|
||
/// arithmetic is the pre-wrap walk's own, unchanged.
|
||
fn advance_wrapped(
|
||
row: u32,
|
||
col: u32,
|
||
ch: char,
|
||
max_cols: u32,
|
||
wrapping: bool,
|
||
) -> (u32, u32, u32, u32) {
|
||
// The break belongs to the character that could not fit, so it is
|
||
// taken before drawing rather than after the previous glyph.
|
||
let (row, col) = if wrapping && col >= max_cols {
|
||
(row.saturating_add(1), 0)
|
||
} else {
|
||
(row, col)
|
||
};
|
||
if ch == '\t' {
|
||
// A tab fills to the row's end and stops; it never spans a wrap.
|
||
// Column 0 of the next row is itself a tab stop, so alignment
|
||
// survives the break rather than being approximated — carrying
|
||
// the remaining pad across would put the next character at a
|
||
// column the tab-stop arithmetic never chose.
|
||
return (row, col, row, advance_char(col, ch).min(max_cols));
|
||
}
|
||
let width = advance_char(col, ch) - col;
|
||
if width == 0 {
|
||
// Combining mark or other zero-width control: M1.5 skips; M2+
|
||
// will attach it to the previous cell as `Glyph::Cluster`.
|
||
return (row, col, row, col);
|
||
}
|
||
// `max_cols >= 2` is the whole of the narrow-viewport policy: a
|
||
// double-width glyph moves to the next row only when the next row
|
||
// could actually hold it. At one column it never can, so moving
|
||
// would insert a blank row before every wide character and paint it
|
||
// clipped anyway — a single CJK glyph would render on row 1 with
|
||
// row 0 left empty. Below two columns a wide glyph is clipped in
|
||
// place, which is what `Truncate` does at the edge for the same
|
||
// reason: there is no better row to move it to.
|
||
if wrapping && width == 2 && max_cols >= 2 && col + 1 >= max_cols {
|
||
// A double-width glyph with a single cell left moves to the next
|
||
// row whole rather than being split across the break.
|
||
//
|
||
// `Truncate` keeps its existing behavior instead — lead cell
|
||
// painted, continuation omitted. That is arguably worse, and
|
||
// changing it is not this lane's to make: `Truncate` must stay
|
||
// byte-identical.
|
||
let r = row.saturating_add(1);
|
||
return (r, 0, r, 2.min(max_cols));
|
||
}
|
||
(row, col, row, col + width)
|
||
}
|
||
|
||
impl View for TextView {
|
||
fn on_edit(&mut self, buf: &Buffer, edit: &Edit) -> Result<(), BufferError> {
|
||
let start_line = self.line_at_offset(edit.range.start);
|
||
self.rebuild_lines_from(buf, start_line);
|
||
Ok(())
|
||
}
|
||
|
||
fn pos_to_display(&self, buf: &Buffer, pos: Position, ctx: LayoutCtx) -> Option<DisplayCoord> {
|
||
if pos > buf.len() {
|
||
return None;
|
||
}
|
||
let row_idx = self.line_at_offset(pos);
|
||
let line_start = self.line_offsets[row_idx];
|
||
if ctx.wrapping() {
|
||
let (sub_row, col) = self.place_of_byte(buf, row_idx, pos - line_start, ctx.cols);
|
||
return Some(DisplayCoord::wrapped(row_idx as u32, sub_row, col));
|
||
}
|
||
// Everything below is the pre-wrap path, unchanged.
|
||
|
||
// Slice [line_start, pos) and sum the display widths of any complete
|
||
// codepoints inside. Bytes that look like UTF-8 continuation bytes
|
||
// outside a complete codepoint are skipped (the position falls inside
|
||
// a multi-byte codepoint; we treat the codepoint's column as the
|
||
// answer, which means trimming the in-progress bytes).
|
||
let take = (pos - line_start) as usize;
|
||
if take == 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
|
||
// the per-call allocation that previously ran on every cursor move.
|
||
let mut stack_buf = [0u8; STACK_CAP];
|
||
let mut heap_buf: Vec<u8>;
|
||
let bytes: &mut [u8] = if take <= STACK_CAP {
|
||
&mut stack_buf[..take]
|
||
} else {
|
||
heap_buf = vec![0u8; take];
|
||
&mut heap_buf
|
||
};
|
||
buf.snapshot_rope().slice(line_start, pos, bytes);
|
||
let col = valid_prefix_width(bytes);
|
||
// 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(
|
||
&self,
|
||
buf: &Buffer,
|
||
coord: DisplayCoord,
|
||
ctx: LayoutCtx,
|
||
) -> Option<Position> {
|
||
let row = coord.row as usize;
|
||
if row >= self.line_count() {
|
||
return None;
|
||
}
|
||
let line_start = self.line_offsets[row];
|
||
if ctx.wrapping() {
|
||
let within = self.byte_at_place(buf, row, coord.sub_row, coord.col, ctx.cols);
|
||
return Some(line_start + within);
|
||
}
|
||
// Everything below is the pre-wrap path, unchanged.
|
||
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 >= target {
|
||
walked_bytes = byte_idx;
|
||
return Some(line_start + walked_bytes as u64);
|
||
}
|
||
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.
|
||
Some(line_start + walked_bytes as u64)
|
||
}
|
||
|
||
fn render(&mut self, buf: &Buffer, viewport: Viewport<'_>, cells: &mut CellGrid<'_>) {
|
||
// Arc 6 Stage 2 (Q#FD13): row `r` shows the `r`-th VISIBLE source
|
||
// line at or after `view_top`; a collapsed region's lines are
|
||
// skipped entirely and the rows below shift up. Without a fold
|
||
// map this walk is the pre-folding `start_line + row_offset`
|
||
// identity. Folding is deliberately not an overlay — overlays
|
||
// repaint cells, they cannot delete rows.
|
||
let folds = viewport.folds.filter(|m| !m.is_identity());
|
||
// `view_top` is clamped backward before the frame, but a caller
|
||
// that hands us a hidden start still gets its head.
|
||
let start_line = {
|
||
let raw = self.line_at_offset(viewport.buffer_start);
|
||
folds.map_or(raw, |m| m.visible_head_of(raw))
|
||
};
|
||
let max_rows = viewport.cell_size.rows;
|
||
let max_cols = viewport.cell_size.cols;
|
||
let origin = viewport.cell_origin;
|
||
|
||
// Under `Wrap` one source line can own several rows, so the row
|
||
// walk is no longer the line walk and `row_offset` is carried
|
||
// rather than iterated. Clearing moves up front for the same
|
||
// reason — a row's occupant is not known until the line reaching
|
||
// it has been laid out — and every row is still blanked exactly
|
||
// once, as before.
|
||
for row_offset in 0..max_rows {
|
||
let cell_row = origin.row + row_offset;
|
||
for col in 0..max_cols {
|
||
*cells.at(CellCoord::new(cell_row, origin.col + col)) = Cell::default();
|
||
}
|
||
}
|
||
|
||
// Visual rows of the first line to skip. `buffer_start` may sit
|
||
// partway down a wrapped line — the reason the anchor is a byte
|
||
// and not a row index (framing Q#LL6). Always 0 under `Truncate`,
|
||
// where a line owns exactly one row.
|
||
let mut skip_rows = if viewport.wrap == WrapMode::Wrap {
|
||
let line_start = self.line_offsets.get(start_line).copied().unwrap_or(0);
|
||
self.row_of_byte(
|
||
buf,
|
||
start_line,
|
||
viewport.buffer_start.saturating_sub(line_start),
|
||
max_cols,
|
||
)
|
||
} else {
|
||
0
|
||
};
|
||
|
||
let mut row_offset: u32 = 0;
|
||
let mut line = start_line;
|
||
while row_offset < max_rows && line < self.line_count() {
|
||
let this_line = line;
|
||
line = folds.map_or(this_line + 1, |m| m.next_visible(this_line));
|
||
let used = self.paint_line(
|
||
buf,
|
||
this_line,
|
||
viewport,
|
||
cells,
|
||
LinePlacement {
|
||
first_row: row_offset,
|
||
skip_rows,
|
||
is_fold_head: folds.is_some_and(|m| m.is_head(this_line)),
|
||
},
|
||
);
|
||
skip_rows = 0;
|
||
// A line always advances the row cursor, even when it painted
|
||
// nothing (invalid UTF-8, an empty line): otherwise the walk
|
||
// would re-enter the same grid row forever.
|
||
row_offset += used.max(1);
|
||
}
|
||
// Ran out of buffer before running out of rows.
|
||
//
|
||
// BOTH halves are needed. `line >= line_count` alone is true
|
||
// whenever the last line was *started*, which under wrapping
|
||
// happens while its remaining rows sit below the viewport — a
|
||
// fifty-row line begun on the last visible row would report the
|
||
// buffer end as on screen. `row_offset <= max_rows` is what says
|
||
// the rows it needed actually fit.
|
||
self.reached_buffer_end = line >= self.line_count() && row_offset <= max_rows;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::buffer::{BufferId, EditOp};
|
||
use crate::cell::CellSize;
|
||
use crate::rope::Range;
|
||
use crate::view::WrapMode;
|
||
use proptest::prelude::*;
|
||
|
||
fn buf_with(content: &[u8]) -> Buffer {
|
||
Buffer::from_bytes(BufferId::next(), "test", content)
|
||
}
|
||
|
||
fn attached(content: &[u8]) -> (Buffer, TextView) {
|
||
let buf = buf_with(content);
|
||
let view = TextView::new(&buf);
|
||
(buf, view)
|
||
}
|
||
|
||
// ----- line index -----
|
||
|
||
#[test]
|
||
fn empty_buffer_has_one_line() {
|
||
let (_buf, view) = attached(b"");
|
||
assert_eq!(view.line_count(), 1);
|
||
assert_eq!(view.line_offset(0), Some(0));
|
||
}
|
||
|
||
#[test]
|
||
fn single_line_no_newline() {
|
||
let (_buf, view) = attached(b"hello");
|
||
assert_eq!(view.line_count(), 1);
|
||
assert_eq!(view.line_offset(0), Some(0));
|
||
}
|
||
|
||
#[test]
|
||
fn three_lines() {
|
||
let (buf, view) = attached(b"alpha\nbeta\ngamma");
|
||
assert_eq!(view.line_count(), 3);
|
||
assert_eq!(view.line_offset(0), Some(0));
|
||
assert_eq!(view.line_offset(1), Some(6));
|
||
assert_eq!(view.line_offset(2), Some(11));
|
||
assert_eq!(view.line_len(&buf, 0), Some(5)); // "alpha"
|
||
assert_eq!(view.line_len(&buf, 1), Some(4)); // "beta"
|
||
assert_eq!(view.line_len(&buf, 2), Some(5)); // "gamma" (no trailing \n)
|
||
}
|
||
|
||
#[test]
|
||
fn trailing_newline_creates_empty_line() {
|
||
// "a\nb\n" -> three lines: "a", "b", "" (the third is empty after the
|
||
// last newline).
|
||
let (buf, view) = attached(b"a\nb\n");
|
||
assert_eq!(view.line_count(), 3);
|
||
assert_eq!(view.line_offset(2), Some(4));
|
||
assert_eq!(view.line_len(&buf, 2), Some(0));
|
||
}
|
||
|
||
#[test]
|
||
fn line_at_offset_walks_correctly() {
|
||
let (_buf, view) = attached(b"abc\ndef\nghi");
|
||
// line 0: bytes 0..4, line 1: 4..8, line 2: 8..11
|
||
assert_eq!(view.line_at_offset(0), 0);
|
||
assert_eq!(view.line_at_offset(3), 0);
|
||
assert_eq!(view.line_at_offset(4), 1);
|
||
assert_eq!(view.line_at_offset(7), 1);
|
||
assert_eq!(view.line_at_offset(8), 2);
|
||
assert_eq!(view.line_at_offset(11), 2);
|
||
}
|
||
|
||
// ----- incremental update on edit -----
|
||
|
||
#[test]
|
||
fn insert_within_a_line_keeps_other_offsets_pointer_stable() {
|
||
let mut buf = buf_with(b"alpha\nbeta\ngamma");
|
||
let mut view = TextView::new(&buf);
|
||
// Capture the spine before the edit.
|
||
let before: Vec<u64> = view.line_offsets.clone();
|
||
|
||
// Insert one byte inside line 1 ("beta"); lines 0..=1 unchanged in
|
||
// start offset, line 2 shifts by +1.
|
||
let edit = buf
|
||
.apply_edit(EditOp::Insert {
|
||
pos: 8,
|
||
bytes: b"X",
|
||
})
|
||
.unwrap();
|
||
view.on_edit(&buf, &edit).unwrap();
|
||
|
||
assert_eq!(view.line_offsets[0], before[0]);
|
||
assert_eq!(view.line_offsets[1], before[1]);
|
||
assert_eq!(view.line_offsets[2], before[2] + 1);
|
||
}
|
||
|
||
#[test]
|
||
fn insert_a_newline_splits_a_line() {
|
||
let mut buf = buf_with(b"abcdef");
|
||
let mut view = TextView::new(&buf);
|
||
let edit = buf
|
||
.apply_edit(EditOp::Insert {
|
||
pos: 3,
|
||
bytes: b"\n",
|
||
})
|
||
.unwrap();
|
||
view.on_edit(&buf, &edit).unwrap();
|
||
assert_eq!(view.line_count(), 2);
|
||
assert_eq!(view.line_offset(0), Some(0));
|
||
assert_eq!(view.line_offset(1), Some(4));
|
||
}
|
||
|
||
#[test]
|
||
fn delete_a_newline_merges_lines() {
|
||
let mut buf = buf_with(b"abc\ndef");
|
||
let mut view = TextView::new(&buf);
|
||
let edit = buf
|
||
.apply_edit(EditOp::Delete {
|
||
range: Range::new(3, 4),
|
||
})
|
||
.unwrap();
|
||
view.on_edit(&buf, &edit).unwrap();
|
||
assert_eq!(view.line_count(), 1);
|
||
assert_eq!(view.line_offset(0), Some(0));
|
||
}
|
||
|
||
// ----- pos <-> display -----
|
||
|
||
#[test]
|
||
fn ascii_pos_to_display_basic() {
|
||
let (buf, view) = attached(b"hello\nworld");
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 0, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 0))
|
||
);
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 5, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 5))
|
||
);
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 6, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(1, 0))
|
||
);
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 11, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(1, 5))
|
||
);
|
||
assert_eq!(view.pos_to_display(&buf, 12, LayoutCtx::truncated()), None);
|
||
}
|
||
|
||
#[test]
|
||
fn ascii_display_to_pos_basic() {
|
||
let (buf, view) = attached(b"hello\nworld");
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(0, 0), LayoutCtx::truncated()),
|
||
Some(0)
|
||
);
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(0, 5), LayoutCtx::truncated()),
|
||
Some(5)
|
||
);
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(1, 0), LayoutCtx::truncated()),
|
||
Some(6)
|
||
);
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(1, 5), LayoutCtx::truncated()),
|
||
Some(11)
|
||
);
|
||
// Past the visible end of a line: clamps.
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(0, 10), LayoutCtx::truncated()),
|
||
Some(5)
|
||
);
|
||
// Past the last line: None.
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(2, 0), LayoutCtx::truncated()),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn multibyte_utf8_widths() {
|
||
// "héllo" with é = U+00E9 (2 bytes UTF-8, 1 column wide).
|
||
// Bytes: h(0x68) é(0xC3 0xA9) l(0x6C) l(0x6C) o(0x6F) -> 6 bytes total.
|
||
let (buf, view) = attached("héllo".as_bytes());
|
||
assert_eq!(buf.len(), 6);
|
||
// Position 0 -> col 0
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 0, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 0))
|
||
);
|
||
// Position 1 (just after 'h') -> col 1
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 1, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 1))
|
||
);
|
||
// Position 3 (just after 'é') -> col 2
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 3, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 2))
|
||
);
|
||
// Position 6 (end) -> col 5
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 6, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 5))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wide_cjk_widths() {
|
||
// "中文" each codepoint is 3 bytes UTF-8 and 2 columns wide.
|
||
let (buf, view) = attached("中文".as_bytes());
|
||
assert_eq!(buf.len(), 6);
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 0, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 0))
|
||
);
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 3, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 2))
|
||
);
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 6, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 4))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn display_to_pos_jumps_over_wide_chars() {
|
||
let (buf, view) = attached("中a".as_bytes());
|
||
// "中" is 2 cols wide, 3 bytes. "a" is 1 col, 1 byte. Total 4 bytes, 3 cols.
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(0, 0), LayoutCtx::truncated()),
|
||
Some(0)
|
||
);
|
||
// Asking for col 1 lands inside the wide char; we round to the next
|
||
// codepoint boundary (col 2's start position).
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(0, 2), LayoutCtx::truncated()),
|
||
Some(3)
|
||
);
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(0, 3), LayoutCtx::truncated()),
|
||
Some(4)
|
||
);
|
||
}
|
||
|
||
proptest! {
|
||
// Inverse property on ASCII text (incl. tabs): every codepoint-
|
||
// aligned position round-trips through pos -> display -> pos.
|
||
#[test]
|
||
fn ascii_pos_display_inverse(content in r"[a-z\t\n]{0,200}", offset in 0usize..=200) {
|
||
let buf = buf_with(content.as_bytes());
|
||
let view = TextView::new(&buf);
|
||
let pos = (offset as u64).min(buf.len());
|
||
if let Some(disp) = view.pos_to_display(&buf, pos, LayoutCtx::truncated()) {
|
||
let back = view.display_to_pos(&buf, disp, LayoutCtx::truncated());
|
||
prop_assert_eq!(back, Some(pos));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ----- tabs -----
|
||
|
||
#[test]
|
||
fn tab_at_start_advances_to_column_8() {
|
||
let (buf, view) = attached(b"\tx");
|
||
// Position 0 (before tab) -> col 0
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 0, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 0))
|
||
);
|
||
// Position 1 (after tab) -> col 8
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 1, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 8))
|
||
);
|
||
// Position 2 (after 'x') -> col 9
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 2, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 9))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tab_in_middle_pads_to_next_stop() {
|
||
// "ab\tcd": after 'b' col is 2, tab pads to col 8, 'c' at col 8.
|
||
let (buf, view) = attached(b"ab\tcd");
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 0, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 0))
|
||
);
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 2, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 2))
|
||
);
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 3, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 8))
|
||
);
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 4, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 9))
|
||
);
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 5, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 10))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tab_aligned_input_advances_full_width() {
|
||
// 8 chars then tab: the protocol tab stop advances col 8 to col 16.
|
||
let (buf, view) = attached(b"01234567\tx");
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 8, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 8))
|
||
);
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 9, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 16))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn display_to_pos_inside_tab_rounds_to_next_codepoint() {
|
||
// "\tx": col 0..8 are the tab; col 5 (inside the tab) should
|
||
// round to byte 1 (the start of 'x').
|
||
let (buf, view) = attached(b"\tx");
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(0, 0), LayoutCtx::truncated()),
|
||
Some(0)
|
||
);
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(0, 5), LayoutCtx::truncated()),
|
||
Some(1)
|
||
);
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(0, 8), LayoutCtx::truncated()),
|
||
Some(1)
|
||
);
|
||
assert_eq!(
|
||
view.display_to_pos(&buf, DisplayCoord::new(0, 9), LayoutCtx::truncated()),
|
||
Some(2)
|
||
);
|
||
}
|
||
|
||
/// Under `wrap`, `pos_to_display` reports the visual row **within**
|
||
/// the source line, and `row` stays the source line.
|
||
#[test]
|
||
fn wrapped_coords_keep_row_as_the_source_line() {
|
||
let (buf, view) = attached(b"abcdefghij\nzz");
|
||
let ctx = LayoutCtx {
|
||
cols: 4,
|
||
wrap: WrapMode::Wrap,
|
||
view_left: 0,
|
||
};
|
||
// 'e' is byte 4: line 0, second visual row, column 0.
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 4, ctx),
|
||
Some(DisplayCoord::wrapped(0, 1, 0))
|
||
);
|
||
// The second SOURCE line is still row 1, not a visual row index.
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 11, ctx),
|
||
Some(DisplayCoord::wrapped(1, 0, 0)),
|
||
"row is the source line; a redefinition would have made this 3"
|
||
);
|
||
}
|
||
|
||
/// The wrap point belongs to column 0 of the next visual row, never
|
||
/// one past the end of the previous one (framing §7).
|
||
#[test]
|
||
fn the_wrap_point_is_owned_by_the_next_row() {
|
||
let (buf, view) = attached(b"abcdefgh");
|
||
let ctx = LayoutCtx {
|
||
cols: 4,
|
||
wrap: WrapMode::Wrap,
|
||
view_left: 0,
|
||
};
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 4, ctx),
|
||
Some(DisplayCoord::wrapped(0, 1, 0)),
|
||
"byte 4 is the start of row 1, not (row 0, col 4)"
|
||
);
|
||
// The two DISTINCT adjacent codepoints across the break map
|
||
// distinctly: 'd' at (0,0,3) and 'e' at (0,1,0).
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 3, ctx),
|
||
Some(DisplayCoord::wrapped(0, 0, 3))
|
||
);
|
||
}
|
||
|
||
/// Round trip is identity on every cursor boundary of a wrapped
|
||
/// line, and projection inside a multi-byte codepoint — the
|
||
/// contract framing §7 settled.
|
||
#[test]
|
||
fn wrapped_round_trip_is_identity_on_boundaries() {
|
||
let text = "abc\tde中fghijklmno";
|
||
let (buf, view) = attached(text.as_bytes());
|
||
for cols in [3_u32, 4, 5, 9] {
|
||
let ctx = LayoutCtx {
|
||
cols,
|
||
wrap: WrapMode::Wrap,
|
||
view_left: 0,
|
||
};
|
||
for (byte, _) in text.char_indices() {
|
||
let coord = view
|
||
.pos_to_display(&buf, byte as u64, ctx)
|
||
.expect("in range");
|
||
let back = view.display_to_pos(&buf, coord, ctx).expect("in range");
|
||
assert_eq!(
|
||
back, byte as u64,
|
||
"cols={cols}: boundary {byte} did not round trip (via {coord:?})"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// An interior byte projects to its codepoint's start, exactly as on
|
||
/// the unwrapped path — wrapping does not change that contract.
|
||
#[test]
|
||
fn wrapped_interior_bytes_project_to_the_codepoint_start() {
|
||
let text = "ab中cd";
|
||
let (buf, view) = attached(text.as_bytes());
|
||
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);
|
||
for interior in [3_u64, 4] {
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, interior, ctx),
|
||
at_start,
|
||
"byte {interior} is inside the codepoint starting at 2"
|
||
);
|
||
}
|
||
// ...and the projection is idempotent.
|
||
let coord = at_start.expect("in range");
|
||
let back = view.display_to_pos(&buf, coord, ctx).expect("in range");
|
||
assert_eq!(back, 2);
|
||
assert_eq!(view.pos_to_display(&buf, back, ctx), at_start);
|
||
}
|
||
|
||
/// The identity control: with `truncated()` the mapping is exactly
|
||
/// what it was before wrapping existed.
|
||
#[test]
|
||
fn truncate_coords_are_unchanged() {
|
||
let (buf, view) = attached(b"abcdefghij");
|
||
assert_eq!(
|
||
view.pos_to_display(&buf, 6, LayoutCtx::truncated()),
|
||
Some(DisplayCoord::new(0, 6)),
|
||
"no sub_row, and the column is the whole prefix width"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// Line wrapping (QoL Stage 3, docs/long-lines-framing.md)
|
||
// -----------------------------------------------------------------
|
||
|
||
/// 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, 0)
|
||
}
|
||
|
||
/// As [`render_grid`], but starting the viewport at byte `start` —
|
||
/// which under `Wrap` may sit partway down a wrapped line.
|
||
fn render_grid_from(
|
||
text: &[u8],
|
||
rows: u32,
|
||
cols: u32,
|
||
wrap: WrapMode,
|
||
view_left: u32,
|
||
start: u64,
|
||
) -> Vec<Glyph> {
|
||
let (buf, mut view) = attached(text);
|
||
let n = (rows * cols) as usize;
|
||
let mut storage = vec![Cell::default(); n];
|
||
let mut grid = CellGrid {
|
||
cells: &mut storage,
|
||
stride: cols,
|
||
size: CellSize::new(rows, cols),
|
||
};
|
||
view.render(
|
||
&buf,
|
||
Viewport {
|
||
buffer_start: start,
|
||
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,
|
||
);
|
||
storage.iter().map(|c| c.glyph.clone()).collect()
|
||
}
|
||
|
||
fn row_text(glyphs: &[Glyph], cols: u32, row: u32) -> String {
|
||
glyphs
|
||
.iter()
|
||
.skip((row * cols) as usize)
|
||
.take(cols as usize)
|
||
.map(|g| match g {
|
||
Glyph::Char(c) => *c,
|
||
_ => ' ',
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// `reached_buffer_end` is a local predicate, recorded by the walk.
|
||
///
|
||
/// The scroll indicator needs `All`/`Top`/`Bot` without a row total
|
||
/// — which under wrapping does not exist — so it asks the walk
|
||
/// instead of counting.
|
||
#[test]
|
||
fn the_walk_reports_whether_it_reached_the_buffer_end() {
|
||
// Ten characters at four columns is three visual rows; a
|
||
// four-row viewport outruns the buffer.
|
||
let (buf, mut view) = attached(b"abcdefghij");
|
||
let mut storage = vec![Cell::default(); 16];
|
||
let mut grid = CellGrid {
|
||
cells: &mut storage,
|
||
stride: 4,
|
||
size: CellSize::new(4, 4),
|
||
};
|
||
let vp = Viewport {
|
||
buffer_start: 0,
|
||
buffer_end: buf.len(),
|
||
cell_origin: CellCoord::new(0, 0),
|
||
cell_size: CellSize::new(4, 4),
|
||
gutter_w: 0,
|
||
folds: None,
|
||
wrap: WrapMode::Wrap,
|
||
view_left: 0,
|
||
};
|
||
view.render(&buf, vp, &mut grid);
|
||
assert!(
|
||
view.reached_buffer_end(),
|
||
"three rows of content in four rows of viewport: the end is on screen"
|
||
);
|
||
|
||
// Two rows of viewport cannot hold three rows of content.
|
||
let mut small = vec![Cell::default(); 8];
|
||
let mut small_grid = CellGrid {
|
||
cells: &mut small,
|
||
stride: 4,
|
||
size: CellSize::new(2, 4),
|
||
};
|
||
view.render(
|
||
&buf,
|
||
Viewport {
|
||
cell_size: CellSize::new(2, 4),
|
||
..vp
|
||
},
|
||
&mut small_grid,
|
||
);
|
||
assert!(
|
||
!view.reached_buffer_end(),
|
||
"the wrapped remainder is below the viewport"
|
||
);
|
||
}
|
||
|
||
/// A viewport too narrow to hold a wide glyph must not insert a
|
||
/// blank row before it.
|
||
///
|
||
/// The wrap rule moves a double-width glyph to the next row when it
|
||
/// will not fit in the cells left. At one column it never fits
|
||
/// there either, so moving would leave row 0 empty and paint the
|
||
/// glyph clipped on row 1 — worse than clipping it in place.
|
||
#[test]
|
||
fn a_one_column_viewport_does_not_shove_wide_glyphs_down() {
|
||
let g = render_grid("中x".as_bytes(), 3, 1, WrapMode::Wrap);
|
||
assert_eq!(
|
||
g[0],
|
||
Glyph::Char('中'),
|
||
"the wide glyph belongs on row 0, clipped, not row 1"
|
||
);
|
||
}
|
||
|
||
/// A zero-width content area paints nothing and does not panic.
|
||
///
|
||
/// Reachable when the line-number gutter consumes the whole window.
|
||
/// Under `Wrap` the walk's first `col >= max_cols` test is true
|
||
/// immediately, so without an explicit bail it advances a row and
|
||
/// then indexes column 0 of a zero-width grid.
|
||
#[test]
|
||
fn a_zero_width_viewport_paints_nothing() {
|
||
let (buf, mut view) = attached(b"abc\ndef");
|
||
let mut storage: Vec<Cell> = Vec::new();
|
||
let mut grid = CellGrid {
|
||
cells: &mut storage,
|
||
stride: 0,
|
||
size: CellSize::new(4, 0),
|
||
};
|
||
view.render(
|
||
&buf,
|
||
Viewport {
|
||
buffer_start: 0,
|
||
buffer_end: buf.len(),
|
||
cell_origin: CellCoord::new(0, 0),
|
||
cell_size: CellSize::new(4, 0),
|
||
gutter_w: 0,
|
||
folds: None,
|
||
wrap: WrapMode::Wrap,
|
||
view_left: 0,
|
||
},
|
||
&mut grid,
|
||
);
|
||
assert!(storage.is_empty(), "nothing to paint, and nothing painted");
|
||
}
|
||
|
||
/// The reported defect: a line wider than the window is readable.
|
||
#[test]
|
||
fn a_long_line_continues_on_the_following_rows() {
|
||
let g = render_grid(b"abcdefghij", 4, 4, WrapMode::Wrap);
|
||
assert_eq!(row_text(&g, 4, 0), "abcd");
|
||
assert_eq!(row_text(&g, 4, 1), "efgh");
|
||
assert_eq!(row_text(&g, 4, 2), "ij ");
|
||
assert_eq!(
|
||
row_text(&g, 4, 3),
|
||
" ",
|
||
"no fifth row of a ten-char line"
|
||
);
|
||
}
|
||
|
||
/// The identity control: the same input under `Truncate` is exactly
|
||
/// what the pre-wrap renderer produced.
|
||
#[test]
|
||
fn truncate_still_clips_at_the_edge() {
|
||
let g = render_grid(b"abcdefghij", 4, 4, WrapMode::Truncate);
|
||
assert_eq!(row_text(&g, 4, 0), "abcd");
|
||
assert_eq!(
|
||
row_text(&g, 4, 1),
|
||
" ",
|
||
"one row per line, remainder clipped"
|
||
);
|
||
}
|
||
|
||
/// Wrapping is per source line: a second line starts a new row
|
||
/// rather than continuing the first.
|
||
#[test]
|
||
fn each_source_line_starts_its_own_row() {
|
||
let g = render_grid(
|
||
b"abcde
|
||
xy",
|
||
4,
|
||
4,
|
||
WrapMode::Wrap,
|
||
);
|
||
assert_eq!(row_text(&g, 4, 0), "abcd");
|
||
assert_eq!(row_text(&g, 4, 1), "e ");
|
||
assert_eq!(row_text(&g, 4, 2), "xy ");
|
||
}
|
||
|
||
/// A double-width glyph with one cell left moves to the next row
|
||
/// whole, rather than being split or half-painted.
|
||
#[test]
|
||
fn a_wide_char_that_does_not_fit_moves_down_whole() {
|
||
// 3 columns: "ab" fills 0..2, leaving one cell — too narrow for
|
||
// the 2-column CJK glyph.
|
||
let g = render_grid("ab中".as_bytes(), 3, 3, WrapMode::Wrap);
|
||
assert_eq!(row_text(&g, 3, 0), "ab ", "the odd cell stays blank");
|
||
assert_eq!(
|
||
g[3],
|
||
Glyph::Char('中'),
|
||
"the glyph starts the next row instead of straddling"
|
||
);
|
||
assert_eq!(g[4], Glyph::Continuation, "and keeps its continuation cell");
|
||
}
|
||
|
||
/// A tab fills to the row's end and stops; it never spans a wrap.
|
||
/// Column 0 of the next row is itself a tab stop, so alignment
|
||
/// survives the break.
|
||
#[test]
|
||
fn a_tab_fills_to_the_row_end_and_stops() {
|
||
let g = render_grid(b"ab z", 4, 4, WrapMode::Wrap);
|
||
assert_eq!(row_text(&g, 4, 0), "ab ", "tab pads to the edge");
|
||
assert_eq!(
|
||
row_text(&g, 4, 1),
|
||
"z ",
|
||
"and the next char resumes at col 0"
|
||
);
|
||
}
|
||
|
||
/// The byte anchor may sit partway down a wrapped line, which is
|
||
/// why `view_top`'s sub-line component is a byte (framing Q#LL6).
|
||
#[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, 0, 4);
|
||
assert_eq!(row_text(&g, 4, 0), "efgh", "the first row is skipped");
|
||
assert_eq!(row_text(&g, 4, 1), "ij ");
|
||
}
|
||
|
||
/// `row_of_byte` and the painter must agree, or the viewport scrolls
|
||
/// to a row the renderer does not draw. They share `advance_wrapped`
|
||
/// precisely so this cannot drift; the witness pins it anyway.
|
||
#[test]
|
||
fn the_anchor_row_matches_where_the_text_is_painted() {
|
||
let text = "ab cd中efghij";
|
||
let (buf, view) = attached(text.as_bytes());
|
||
for cols in [3_u32, 4, 5, 8] {
|
||
for (byte, _) in text.char_indices() {
|
||
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, 0, byte as u64);
|
||
let full = render_grid(text.as_bytes(), 12, cols, WrapMode::Wrap);
|
||
let expect = row_text(&full, cols, row);
|
||
assert_eq!(
|
||
row_text(&g, cols, 0),
|
||
expect,
|
||
"cols={cols} byte={byte}: anchor row {row} is not the row painted first"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn render_expands_tabs_to_spaces() {
|
||
let (buf, mut view) = attached(b"\tx");
|
||
let mut storage = vec![Cell::default(); 16];
|
||
let mut grid = CellGrid {
|
||
cells: &mut storage,
|
||
stride: 16,
|
||
size: CellSize::new(1, 16),
|
||
};
|
||
view.render(
|
||
&buf,
|
||
Viewport {
|
||
buffer_start: 0,
|
||
buffer_end: buf.len(),
|
||
cell_origin: CellCoord::new(0, 0),
|
||
cell_size: CellSize::new(1, 16),
|
||
gutter_w: 0,
|
||
folds: None,
|
||
wrap: WrapMode::Truncate,
|
||
view_left: 0,
|
||
},
|
||
&mut grid,
|
||
);
|
||
// Cells 0..8 are spaces (the expanded tab), cell 8 is 'x'.
|
||
for (i, cell) in storage.iter().enumerate().take(8) {
|
||
assert_eq!(cell.glyph, Glyph::Char(' '), "cell {i} should be space");
|
||
}
|
||
assert_eq!(storage[8].glyph, Glyph::Char('x'));
|
||
}
|
||
|
||
#[test]
|
||
fn render_tab_in_middle_pads_correctly() {
|
||
// "ab\tcd" → cells: a, b, ' ', ' ', ' ', ' ', ' ', ' ', c, d
|
||
let (buf, mut view) = attached(b"ab\tcd");
|
||
let mut storage = vec![Cell::default(); 16];
|
||
let mut grid = CellGrid {
|
||
cells: &mut storage,
|
||
stride: 16,
|
||
size: CellSize::new(1, 16),
|
||
};
|
||
view.render(
|
||
&buf,
|
||
Viewport {
|
||
buffer_start: 0,
|
||
buffer_end: buf.len(),
|
||
cell_origin: CellCoord::new(0, 0),
|
||
cell_size: CellSize::new(1, 16),
|
||
gutter_w: 0,
|
||
folds: None,
|
||
wrap: WrapMode::Truncate,
|
||
view_left: 0,
|
||
},
|
||
&mut grid,
|
||
);
|
||
assert_eq!(storage[0].glyph, Glyph::Char('a'));
|
||
assert_eq!(storage[1].glyph, Glyph::Char('b'));
|
||
for (i, cell) in storage.iter().enumerate().take(8).skip(2) {
|
||
assert_eq!(cell.glyph, Glyph::Char(' '), "cell {i} should be space");
|
||
}
|
||
assert_eq!(storage[8].glyph, Glyph::Char('c'));
|
||
assert_eq!(storage[9].glyph, Glyph::Char('d'));
|
||
}
|
||
|
||
// ----- render -----
|
||
|
||
#[test]
|
||
fn render_writes_ascii_glyphs() {
|
||
let (buf, mut view) = attached(b"abc\nde");
|
||
let mut storage = vec![Cell::default(); 5 * 5];
|
||
let mut grid = CellGrid {
|
||
cells: &mut storage,
|
||
stride: 5,
|
||
size: CellSize::new(5, 5),
|
||
};
|
||
view.render(
|
||
&buf,
|
||
Viewport {
|
||
buffer_start: 0,
|
||
buffer_end: buf.len(),
|
||
cell_origin: CellCoord::new(0, 0),
|
||
cell_size: CellSize::new(5, 5),
|
||
gutter_w: 0,
|
||
folds: None,
|
||
wrap: WrapMode::Truncate,
|
||
view_left: 0,
|
||
},
|
||
&mut grid,
|
||
);
|
||
assert_eq!(storage[0].glyph, Glyph::Char('a'));
|
||
assert_eq!(storage[1].glyph, Glyph::Char('b'));
|
||
assert_eq!(storage[2].glyph, Glyph::Char('c'));
|
||
// Past 'c': default (space) cell.
|
||
assert_eq!(storage[3].glyph, Glyph::Char(' '));
|
||
// Row 1: "de"
|
||
assert_eq!(storage[5].glyph, Glyph::Char('d'));
|
||
assert_eq!(storage[6].glyph, Glyph::Char('e'));
|
||
// Row 2 onward: empty (buffer has only 2 lines).
|
||
assert_eq!(storage[10].glyph, Glyph::Char(' '));
|
||
}
|
||
|
||
#[test]
|
||
fn render_wide_char_writes_continuation() {
|
||
let (buf, mut view) = attached("中a".as_bytes());
|
||
let mut storage = vec![Cell::default(); 5];
|
||
let mut grid = CellGrid {
|
||
cells: &mut storage,
|
||
stride: 5,
|
||
size: CellSize::new(1, 5),
|
||
};
|
||
view.render(
|
||
&buf,
|
||
Viewport {
|
||
buffer_start: 0,
|
||
buffer_end: buf.len(),
|
||
cell_origin: CellCoord::new(0, 0),
|
||
cell_size: CellSize::new(1, 5),
|
||
gutter_w: 0,
|
||
folds: None,
|
||
wrap: WrapMode::Truncate,
|
||
view_left: 0,
|
||
},
|
||
&mut grid,
|
||
);
|
||
assert_eq!(storage[0].glyph, Glyph::Char('中'));
|
||
assert_eq!(storage[1].glyph, Glyph::Continuation);
|
||
assert_eq!(storage[2].glyph, Glyph::Char('a'));
|
||
}
|
||
}
|