feat(view): wrap-aware coordinates, breaking in and additive out

pos_to_display and display_to_pos now take a LayoutCtx and DisplayCoord
carries a sub_row. The asymmetry is the whole audit strategy, and it
played out as designed.

Breaking on the way in: adding a required parameter turned the audit
into 61 compiler errors instead of a grep. Additive on the way out:
sub_row defaults to 0, so overlay_paint's `row - view_top` and vertical
motion's bounds check stayed CORRECT rather than merely findable ---
neither needed touching. row is still the source line. Redefining it as
a visual row would have broken both silently.

Two structural gaps this surfaced, neither in the framing:

Window recorded last_visible_rows but no width, so the coordinate
callers --- vertical motion, paging, overlay placement --- had nothing
to build a context from. Added last_content_cols, taken from the
viewport the renderer actually used rather than recomputed: a second
derivation could disagree, and the disagreement would show only as a
cursor on the wrong row. Content width, not window width, because the
gutter grows at the line-count digit boundary.

The mode had the same problem one level up. It is buffer-local and the
registry has no ambient buffer, so only the driver can resolve it ---
but every consumer holds a window, not a registry. Window::last_wrap is
recorded beside the width and read through Window::layout_ctx(), so
there is ONE resolution consumed everywhere. When ui.line-wrap is
registered, only the driver changes and all twenty call sites become
wrap-aware together. The alternative, each caller resolving for itself,
is how two callers end up disagreeing about one buffer.

One real regression, caught by the render tests rather than reasoning:
generalising row_of_byte into place_of_byte lost the boundary rule. A
byte landing exactly on a row edge reported (row, max_cols) instead of
(row+1, 0), so a viewport anchored there painted the wrong row. The fix
is the rule framing section 7 already settled --- the wrap position is
owned by column 0 of the NEXT row, because that cell always exists and
(row, max_cols) does not.

Five coordinate witnesses. Identity on every cursor boundary of a line
containing a tab and a CJK glyph, across four widths; projection to the
codepoint start for interior bytes, unchanged by wrapping; the two
distinct adjacent codepoints across a break mapping distinctly; row
staying the source line; and a truncate control. They bite --- forcing
the wrap branch off fails three, including the round trip.

Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1912/0,
crdt 2092/0, tab_width 2/0, folding 21/0, gui_zoom 15/15.

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 13:07:54 +02:00
parent cad9393ef0
commit 937544cd61
No known key found for this signature in database
7 changed files with 553 additions and 77 deletions

View File

@ -2351,6 +2351,7 @@ impl EditorState {
None
};
window.last_visible_rows = content.size.rows;
window.last_content_cols = content.size.cols;
// A2A-3 / parent 48: the auto-scroll clamp belongs to the
// FOCUSED window only. Running it for a passive panel would
// move a `view_top` the user is not driving.
@ -3603,7 +3604,9 @@ impl EditorState {
let Ok(buf) = reg.get(buffer_id) else {
return;
};
core.windows[&win_id].text_view.display_to_pos(buf, target)
core.windows[&win_id]
.text_view
.display_to_pos(buf, target, core.layout_ctx(win_id))
};
if let Some(p) = pos {
let aw = core
@ -3662,7 +3665,9 @@ impl EditorState {
let reg = registry.borrow();
reg.get(buffer_id).ok().and_then(|buf| {
let aw = &core.windows[&win_id];
let cur = aw.text_view.pos_to_display(buf, aw.cursor)?;
let cur = aw
.text_view
.pos_to_display(buf, aw.cursor, aw.layout_ctx())?;
let cur_row = cur.row as usize;
let target_row_usize = match folds.as_ref() {
Some(map) if scroll_up => map.nth_visible_back(cur_row, view_shift),
@ -3674,7 +3679,11 @@ impl EditorState {
};
let target_row = u32::try_from(target_row_usize).ok()?;
aw.text_view
.display_to_pos(buf, crate::view::DisplayCoord::new(target_row, cur.col))
.display_to_pos(
buf,
crate::view::DisplayCoord::new(target_row, cur.col),
aw.layout_ctx(),
)
.or_else(|| aw.text_view.line_offset(target_row_usize))
})
};
@ -4270,7 +4279,7 @@ fn prepare_window_cursor_visible(
) {
let cursor_row = window
.text_view
.pos_to_display(buf, window.cursor)
.pos_to_display(buf, window.cursor, window.layout_ctx())
.map_or(0, |d| d.row as usize);
match folds {
// The logical cursor may sit on a hidden line (a shared fold, or
@ -4363,6 +4372,12 @@ fn paint_window_content(
// can draw its severity sign into the gutter's leading column
// without the gutter's own blank pass erasing it — then each
// overlay in attach order. See [`crate::view::View`].
// Record the width text actually wrapped at, taken from the
// viewport itself rather than recomputed: a second derivation could
// disagree with the one the renderer used, and the disagreement
// would only show as a cursor on the wrong row.
window.last_content_cols = viewport.cell_size.cols;
window.last_wrap = viewport.wrap;
window.text_view.render(buf, viewport, grid);
if gutter_w > 0 {
paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w, folds, theme);
@ -4375,7 +4390,7 @@ fn paint_window_content(
// itself is always visible regardless of overlay activity.
let coord = window
.text_view
.pos_to_display(buf, window.cursor)
.pos_to_display(buf, window.cursor, window.layout_ctx())
.unwrap_or_default();
// Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in
// VISIBLE-line space — a buffer whose remainder is collapsed
@ -4685,7 +4700,9 @@ fn window_cursor_cell(
),
None => window.cursor,
};
let disp = window.text_view.pos_to_display(buf, cursor)?;
let disp = window
.text_view
.pos_to_display(buf, cursor, window.layout_ctx())?;
let row_offset = match folds {
Some(map) => {
let top = map.clamp_view_top(window.view_top);
@ -5012,10 +5029,17 @@ fn paint_local_selection(
continue;
}
let Some(start_coord) = window.text_view.pos_to_display(buf, paint_start) else {
let Some(start_coord) =
window
.text_view
.pos_to_display(buf, paint_start, window.layout_ctx())
else {
continue;
};
let Some(end_coord) = window.text_view.pos_to_display(buf, paint_end) else {
let Some(end_coord) = window
.text_view
.pos_to_display(buf, paint_end, window.layout_ctx())
else {
continue;
};
if start_coord.row as usize != display_row || end_coord.row as usize != display_row {

View File

@ -835,6 +835,33 @@ impl EditorCore {
crate::fold_view::map_for_window(&self.fold_registry, window)
}
/// The layout facts a coordinate mapping needs for `win_id`.
///
/// **One resolver, so every consumer flips together.** The wrap mode
/// is buffer-local and the registry has no ambient buffer, so the
/// resolution belongs somewhere that holds both — here — rather than
/// at each of the twenty call sites. When `ui.line-wrap` is
/// registered, only this function changes and every caller becomes
/// wrap-aware at once.
///
/// Width comes from the last render (`Window::last_content_cols`);
/// `0` until the first frame lands, which
/// [`LayoutCtx::wrapping`](crate::view::LayoutCtx::wrapping) already
/// treats as unwrapped.
#[must_use]
pub fn layout_ctx(&self, win_id: WindowId) -> crate::view::LayoutCtx {
self.windows.get(&win_id).map_or_else(
crate::view::LayoutCtx::truncated,
crate::window::Window::layout_ctx,
)
}
/// [`Self::layout_ctx`] for the active window.
#[must_use]
pub fn layout_ctx_active(&self) -> crate::view::LayoutCtx {
self.layout_ctx(self.active_window_id())
}
/// [`Self::fold_map_for_window`] for the active window — the target
/// of motion, paging, and the auto-scroll clamp.
#[must_use]
@ -2089,6 +2116,7 @@ impl EditorCore {
self.normalize_cursor_to_visible(folds.as_ref());
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let ctx = self.layout_ctx_active();
let goal_col = self.active_window().goal_col;
let result = {
let reg = self.registry.borrow();
@ -2096,7 +2124,7 @@ impl EditorCore {
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.pos_to_display(buffer, cursor, ctx)
.unwrap_or_default();
let from_row = coord.row as usize;
if from_row == 0 {
@ -2110,7 +2138,7 @@ impl EditorCore {
return;
};
let target = DisplayCoord::new(target_row, goal);
let new_pos = aw.text_view.display_to_pos(buffer, target);
let new_pos = aw.text_view.display_to_pos(buffer, target, ctx);
(goal, new_pos)
};
let (goal, new_pos) = result;
@ -2128,6 +2156,7 @@ impl EditorCore {
self.normalize_cursor_to_visible(folds.as_ref());
let id = self.active_buffer_id();
let cursor = self.active_window().cursor;
let ctx = self.layout_ctx_active();
let goal_col = self.active_window().goal_col;
let result = {
let reg = self.registry.borrow();
@ -2135,7 +2164,7 @@ impl EditorCore {
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.pos_to_display(buffer, cursor, ctx)
.unwrap_or_default();
let from_row = coord.row as usize;
let next_row = folds
@ -2149,7 +2178,7 @@ impl EditorCore {
return;
};
let target = DisplayCoord::new(next_row, goal);
let new_pos = aw.text_view.display_to_pos(buffer, target);
let new_pos = aw.text_view.display_to_pos(buffer, target, ctx);
(goal, new_pos)
};
let (goal, new_pos) = result;
@ -2306,6 +2335,7 @@ impl EditorCore {
self.normalize_cursor_to_visible(folds.as_ref());
let step = self.page_step();
let cursor = self.active_window().cursor;
let ctx = self.layout_ctx_active();
let view_top = self.active_window().view_top;
let id = self.active_buffer_id();
let result = {
@ -2314,7 +2344,7 @@ impl EditorCore {
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.pos_to_display(buffer, cursor, ctx)
.unwrap_or_default();
let max_line = aw.text_view.line_count().saturating_sub(1);
let goal_col = aw.goal_col.unwrap_or(coord.col);
@ -2333,7 +2363,7 @@ impl EditorCore {
return;
};
let target = DisplayCoord::new(target_row, goal_col);
let new_pos = aw.text_view.display_to_pos(buffer, target);
let new_pos = aw.text_view.display_to_pos(buffer, target, ctx);
(goal_col, new_pos, new_top)
};
let (goal, new_pos, new_top) = result;
@ -2358,6 +2388,7 @@ impl EditorCore {
self.normalize_cursor_to_visible(folds.as_ref());
let step = self.page_step();
let cursor = self.active_window().cursor;
let ctx = self.layout_ctx_active();
let view_top = self.active_window().view_top;
let id = self.active_buffer_id();
let result = {
@ -2366,7 +2397,7 @@ impl EditorCore {
let aw = self.active_window();
let coord = aw
.text_view
.pos_to_display(buffer, cursor)
.pos_to_display(buffer, cursor, ctx)
.unwrap_or_default();
let goal_col = aw.goal_col.unwrap_or(coord.col);
let (target_row, new_top) = match folds.as_ref() {
@ -2383,7 +2414,7 @@ impl EditorCore {
return;
};
let target = DisplayCoord::new(target_row, goal_col);
let new_pos = aw.text_view.display_to_pos(buffer, target);
let new_pos = aw.text_view.display_to_pos(buffer, target, ctx);
(goal_col, new_pos, new_top)
};
let (goal, new_pos, new_top) = result;

View File

@ -166,7 +166,10 @@ pub fn paint_other_frontend_overlays(
),
None => presence.snapshot.cursor,
};
let Some(disp) = window.text_view.pos_to_display(buf, peer_cursor) else {
let Some(disp) = window
.text_view
.pos_to_display(buf, peer_cursor, window.layout_ctx())
else {
continue;
};
// Filter to viewport visible range. `view_top` is the
@ -309,7 +312,10 @@ fn paint_selection_in_window(
// straightforward walk is fine.
let mut pos = lo;
while pos < hi {
let Some(disp) = window.text_view.pos_to_display(buf, pos) else {
let Some(disp) = window
.text_view
.pos_to_display(buf, pos, window.layout_ctx())
else {
break;
};
let row_in_window = match folds {

View File

@ -23,7 +23,7 @@ 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, View, Viewport, WrapMode};
use crate::view::{DisplayCoord, LayoutCtx, View, Viewport, WrapMode};
// ---------------------------------------------------------------------------
// Tuning
@ -150,28 +150,90 @@ impl TextView {
///
/// 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 {
if max_cols == 0 || within == 0 {
return 0;
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;
return (0, 0);
};
let (mut row, mut col, mut seen) = (0u32, 0u32, 0u64);
for ch in s.chars() {
let (start_row, _, end_row, end_col) = advance_wrapped(row, col, ch, max_cols, true);
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` falls inside this character, so the answer is
// the row the character is DRAWN on — a glyph pushed to
// the next row takes its bytes with it.
return start_row;
// `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;
}
row
// 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)
}
}
/// 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.
@ -350,12 +412,17 @@ impl View for TextView {
Ok(())
}
fn pos_to_display(&self, buf: &Buffer, pos: Position) -> Option<DisplayCoord> {
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
@ -382,12 +449,22 @@ impl View for TextView {
Some(DisplayCoord::new(row_idx as u32, col))
}
fn display_to_pos(&self, buf: &Buffer, coord: DisplayCoord) -> Option<Position> {
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()?;
@ -609,24 +686,54 @@ mod tests {
#[test]
fn ascii_pos_to_display_basic() {
let (buf, view) = attached(b"hello\nworld");
assert_eq!(view.pos_to_display(&buf, 0), Some(DisplayCoord::new(0, 0)));
assert_eq!(view.pos_to_display(&buf, 5), Some(DisplayCoord::new(0, 5)));
assert_eq!(view.pos_to_display(&buf, 6), Some(DisplayCoord::new(1, 0)));
assert_eq!(view.pos_to_display(&buf, 11), Some(DisplayCoord::new(1, 5)));
assert_eq!(view.pos_to_display(&buf, 12), None);
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)), Some(0));
assert_eq!(view.display_to_pos(&buf, DisplayCoord::new(0, 5)), Some(5));
assert_eq!(view.display_to_pos(&buf, DisplayCoord::new(1, 0)), Some(6));
assert_eq!(view.display_to_pos(&buf, DisplayCoord::new(1, 5)), Some(11));
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)), Some(5));
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)), None);
assert_eq!(
view.display_to_pos(&buf, DisplayCoord::new(2, 0), LayoutCtx::truncated()),
None
);
}
#[test]
@ -636,13 +743,25 @@ mod tests {
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), Some(DisplayCoord::new(0, 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), Some(DisplayCoord::new(0, 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), Some(DisplayCoord::new(0, 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), Some(DisplayCoord::new(0, 5)));
assert_eq!(
view.pos_to_display(&buf, 6, LayoutCtx::truncated()),
Some(DisplayCoord::new(0, 5))
);
}
#[test]
@ -650,20 +769,38 @@ mod tests {
// "中文" 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), Some(DisplayCoord::new(0, 0)));
assert_eq!(view.pos_to_display(&buf, 3), Some(DisplayCoord::new(0, 2)));
assert_eq!(view.pos_to_display(&buf, 6), Some(DisplayCoord::new(0, 4)));
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)), Some(0));
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)), Some(3));
assert_eq!(view.display_to_pos(&buf, DisplayCoord::new(0, 3)), Some(4));
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! {
@ -674,8 +811,8 @@ mod tests {
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) {
let back = view.display_to_pos(&buf, disp);
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));
}
}
@ -687,30 +824,60 @@ mod tests {
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), Some(DisplayCoord::new(0, 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), Some(DisplayCoord::new(0, 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), Some(DisplayCoord::new(0, 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), Some(DisplayCoord::new(0, 0)));
assert_eq!(view.pos_to_display(&buf, 2), Some(DisplayCoord::new(0, 2)));
assert_eq!(view.pos_to_display(&buf, 3), Some(DisplayCoord::new(0, 8)));
assert_eq!(view.pos_to_display(&buf, 4), Some(DisplayCoord::new(0, 9)));
assert_eq!(view.pos_to_display(&buf, 5), Some(DisplayCoord::new(0, 10)));
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), Some(DisplayCoord::new(0, 8)));
assert_eq!(view.pos_to_display(&buf, 9), Some(DisplayCoord::new(0, 16)));
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]
@ -718,10 +885,129 @@ mod tests {
// "\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)), Some(0));
assert_eq!(view.display_to_pos(&buf, DisplayCoord::new(0, 5)), Some(1));
assert_eq!(view.display_to_pos(&buf, DisplayCoord::new(0, 8)), Some(1));
assert_eq!(view.display_to_pos(&buf, DisplayCoord::new(0, 9)), Some(2));
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,
};
// '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,
};
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,
};
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,
};
// '中' 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"
);
}
// -----------------------------------------------------------------

View File

@ -108,17 +108,89 @@ impl InterceptContext {
/// and inline expansions appear.
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub struct DisplayCoord {
/// 0-based row.
/// 0-based **source line** index.
///
/// Deliberately still the source line under wrapping, not a visual
/// row. Redefining it would have broken every existing consumer —
/// `overlay_paint`'s `row - view_top`, vertical motion's bounds
/// check — with no compile error. Adding [`Self::sub_row`] beside it
/// instead leaves those consumers *correct*, not merely findable.
pub row: u32,
/// 0-based column.
/// Which visual row **within** `row`, when the line wraps.
///
/// `0` for every unwrapped line and under
/// [`WrapMode::Truncate`](crate::view::WrapMode::Truncate), which is
/// what makes this field additive: code that has never heard of
/// wrapping keeps computing the right answer.
pub sub_row: u32,
/// 0-based column, within the visual row named by `sub_row`.
pub col: u32,
}
impl DisplayCoord {
/// Construct a display coordinate.
/// Construct a display coordinate on a line's first visual row.
#[must_use]
pub const fn new(row: u32, col: u32) -> Self {
Self { row, col }
Self {
row,
sub_row: 0,
col,
}
}
/// Construct a display coordinate on a specific visual row of a
/// wrapped line.
#[must_use]
pub const fn wrapped(row: u32, sub_row: u32, col: u32) -> Self {
Self { row, sub_row, col }
}
}
/// The layout facts a coordinate mapping needs, which the mapping
/// itself cannot know.
///
/// # Why this is a required parameter
///
/// `pos_to_display` took `(&self, buf, pos)` and had no notion of the
/// grid at all — so under wrapping it could not compute a visual row,
/// and `display_to_pos` could not invert one. Passing the missing
/// facts as a required argument is deliberate: it makes the compiler
/// enumerate every call site rather than leaving an audit to grep.
///
/// That is the opposite choice from [`DisplayCoord::sub_row`], and for
/// the opposite reason. Enforcement is possible on the way in, so it is
/// taken; it is not possible on the way out, so the output is made
/// correct-by-default instead.
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub struct LayoutCtx {
/// Width in cells of the content area the text wraps within.
///
/// `0` means "not rendered yet" — the same convention
/// `Window::last_visible_rows` uses — and is treated as unwrapped,
/// since a viewport with no columns has no rows to distinguish.
pub cols: u32,
/// The window's resolved wrap mode.
pub wrap: WrapMode,
}
impl LayoutCtx {
/// The identity context: no wrapping, width irrelevant.
///
/// Every pre-wrap caller means this, and saying so explicitly is
/// what makes those call sites readable as decisions rather than
/// oversights.
#[must_use]
pub const fn truncated() -> Self {
Self {
cols: 0,
wrap: WrapMode::Truncate,
}
}
/// Whether this context actually wraps.
#[must_use]
pub const fn wrapping(self) -> bool {
matches!(self.wrap, WrapMode::Wrap) && self.cols > 0
}
}
@ -309,14 +381,24 @@ pub trait View {
/// view holds a meaningful mapping for that position.
///
/// Default: returns `None` (view has no opinion).
fn pos_to_display(&self, _buf: &Buffer, _pos: Position) -> Option<DisplayCoord> {
fn pos_to_display(
&self,
_buf: &Buffer,
_pos: Position,
_ctx: LayoutCtx,
) -> Option<DisplayCoord> {
None
}
/// Translate a display coordinate back to a buffer byte position.
///
/// Default: returns `None`.
fn display_to_pos(&self, _buf: &Buffer, _coord: DisplayCoord) -> Option<Position> {
fn display_to_pos(
&self,
_buf: &Buffer,
_coord: DisplayCoord,
_ctx: LayoutCtx,
) -> Option<Position> {
None
}

View File

@ -378,6 +378,33 @@ pub struct Window {
/// render. Updated by the renderer; consumed by `cursor.page-down`
/// / `cursor.page-up`. `0` until the first render lands.
pub last_visible_rows: u32,
/// Width in cells of this window's **content** area at last render
/// — the text columns, with the line-number gutter already
/// subtracted. Updated by the renderer alongside
/// [`Self::last_visible_rows`]; `0` until the first render lands.
///
/// Content, not window, width: the gutter grows at the line-count
/// digit boundary (9 -> 10, 99 -> 100), so the two differ and only
/// this one is where text actually wraps.
///
/// Needed because line wrapping makes the cursor's display
/// coordinate width-dependent, and the callers that ask for it —
/// vertical motion, paging, overlay placement — hold a window but
/// not the frame's geometry. `last_visible_rows` established this
/// pattern for rows; wrapping needs the other axis.
pub last_content_cols: u32,
/// Wrap mode this window's buffer resolved to at last render.
///
/// Recorded by the driver beside [`Self::last_content_cols`], for
/// the same reason: the mode is **buffer-local** config and the
/// registry has no ambient buffer, so only the driver can resolve
/// it — but vertical motion, paging and overlay placement all need
/// it and hold a window rather than a registry.
///
/// One resolution, recorded once, consumed everywhere. The
/// alternative — each consumer resolving for itself — is how two
/// callers end up disagreeing about the same buffer.
pub last_wrap: crate::view::WrapMode,
/// Line-number gutter mode for this window (UX gutter arc). `Off` by
/// default → no gutter, no coordinate change.
pub line_numbers: LineNumberMode,
@ -401,6 +428,8 @@ impl Window {
view_top: 0,
goal_col: None,
last_visible_rows: 0,
last_content_cols: 0,
last_wrap: crate::view::WrapMode::Truncate,
line_numbers: LineNumberMode::Off,
params: WindowParams::default(),
}
@ -412,6 +441,18 @@ impl Window {
self.params.is_side()
}
/// The layout facts a coordinate mapping needs for this window.
///
/// Reads what the last render recorded, so every consumer sees the
/// same answer the renderer used rather than deriving its own.
#[must_use]
pub fn layout_ctx(&self) -> crate::view::LayoutCtx {
crate::view::LayoutCtx {
cols: self.last_content_cols,
wrap: self.last_wrap,
}
}
/// Width in cells this window's line-number gutter occupies, or `0`
/// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`;
/// the renderer caps this against the window width and applies it as a

View File

@ -6,7 +6,7 @@ use pmacs::buffer::{Buffer, BufferId};
use pmacs::cell::{Cell, CellCoord, CellGrid, CellSize, Glyph, Style};
use pmacs::overlay::{BufferStyleOverlay, BufferStyleSpan, SharedBufferStyleSpans};
use pmacs::text_view::TextView;
use pmacs::view::{DisplayCoord, View, Viewport, WrapMode};
use pmacs::view::{DisplayCoord, LayoutCtx, View, Viewport, WrapMode};
fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport<'static> {
Viewport {
@ -37,10 +37,16 @@ fn plain_text_projects_tabs_without_changing_source_bytes() {
let buf = Buffer::from_bytes(BufferId::next(), "tabs", source);
let cells = render_text(&buf, 3, 20);
let view = TextView::new(&buf);
assert_eq!(view.pos_to_display(&buf, 1), Some(DisplayCoord::new(0, 8)));
assert_eq!(view.pos_to_display(&buf, 11), Some(DisplayCoord::new(1, 8)));
assert_eq!(
view.pos_to_display(&buf, 22),
view.pos_to_display(&buf, 1, LayoutCtx::truncated()),
Some(DisplayCoord::new(0, 8))
);
assert_eq!(
view.pos_to_display(&buf, 11, LayoutCtx::truncated()),
Some(DisplayCoord::new(1, 8))
);
assert_eq!(
view.pos_to_display(&buf, 22, LayoutCtx::truncated()),
Some(DisplayCoord::new(2, 16))
);