diff --git a/src/editor.rs b/src/editor.rs index 3dd9436..c91659a 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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 { diff --git a/src/editor_core.rs b/src/editor_core.rs index 6a8ec90..0976756 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -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; diff --git a/src/overlay_paint.rs b/src/overlay_paint.rs index b32195a..7ea555d 100644 --- a/src/overlay_paint.rs +++ b/src/overlay_paint.rs @@ -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 { diff --git a/src/text_view.rs b/src/text_view.rs index 2b90711..25625e7 100644 --- a/src/text_view.rs +++ b/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 { + fn pos_to_display(&self, buf: &Buffer, pos: Position, ctx: LayoutCtx) -> Option { 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 { + fn display_to_pos( + &self, + buf: &Buffer, + coord: DisplayCoord, + ctx: LayoutCtx, + ) -> Option { 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" + ); } // ----------------------------------------------------------------- diff --git a/src/view.rs b/src/view.rs index bae61e2..ab77cf1 100644 --- a/src/view.rs +++ b/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 { + fn pos_to_display( + &self, + _buf: &Buffer, + _pos: Position, + _ctx: LayoutCtx, + ) -> Option { None } /// Translate a display coordinate back to a buffer byte position. /// /// Default: returns `None`. - fn display_to_pos(&self, _buf: &Buffer, _coord: DisplayCoord) -> Option { + fn display_to_pos( + &self, + _buf: &Buffer, + _coord: DisplayCoord, + _ctx: LayoutCtx, + ) -> Option { None } diff --git a/src/window.rs b/src/window.rs index 2ac0087..4b6514d 100644 --- a/src/window.rs +++ b/src/window.rs @@ -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 diff --git a/tests/tab_width_acceptance.rs b/tests/tab_width_acceptance.rs index b6e9e03..90e84db 100644 --- a/tests/tab_width_acceptance.rs +++ b/tests/tab_width_acceptance.rs @@ -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)) );