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:
parent
cad9393ef0
commit
937544cd61
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
384
src/text_view.rs
384
src/text_view.rs
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
|
|
|||
94
src/view.rs
94
src/view.rs
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue