//! Viewport — after Helix's `helix-view/src/view.rs`. //! //! The pagination requirement (MJB-HLR-012) is met structurally, not by //! optimisation: the viewport is anchored by the **byte offset of the first //! visible line**, and rendering walks `lines_at(top_line)` for at most //! `height` lines. Nothing in this module iterates the whole rope, so per-frame //! cost is O(viewport) whatever the file size. //! //! Helix's `ViewPosition` also carries a `vertical_offset` addressing rows //! within a soft-wrapped line. There is no soft wrap here, so one buffer line //! is exactly one screen row and the field is omitted — see MJB-DR-003. use ropey::RopeSlice; use super::{ LINE_TYPE, grapheme::display_column, movement::last_content_line, selection::Range, }; /// MJB-LLR-090 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct ViewPosition { /// Byte offset of the first visible line's start. Always a line start. pub anchor: usize, /// Leftmost visible display column. pub horizontal_offset: usize, } #[derive(Debug, Clone, Copy, Default)] pub struct View { pub offset: ViewPosition, } impl View { pub fn new() -> Self { Self::default() } /// MJB-LLR-091 pub fn top_line(&self, text: RopeSlice) -> usize { let anchor = self.offset.anchor.min(text.len()); text.byte_to_line_idx(anchor, LINE_TYPE) } /// MJB-LLR-092: the visible line range, `(first, count)`. /// /// Deliberately returns indices rather than content so the caller can drive /// `lines_at` directly and touch no other line. pub fn visible_line_range(&self, text: RopeSlice, height: usize) -> (usize, usize) { let top = self.top_line(text); let total = text.len_lines(LINE_TYPE); let count = height.min(total.saturating_sub(top)); (top, count) } /// MJB-LLR-092: at most `height` line slices, starting at the top line. pub fn visible_lines<'a>( &self, text: RopeSlice<'a>, height: usize, ) -> impl Iterator> { let (top, count) = self.visible_line_range(text, height); text.lines_at(top, LINE_TYPE).take(count) } /// Set the top line directly, clamping into the buffer (MJB-LLR-097). pub fn set_top_line(&mut self, text: RopeSlice, line: usize) { let last = text.len_lines(LINE_TYPE).saturating_sub(1); let line = line.min(last); self.offset.anchor = text.line_to_byte_idx(line, LINE_TYPE); } /// MJB-LLR-093..098: scroll vertically so the cursor sits inside the /// scroll-off margins. pub fn ensure_cursor_in_view( &mut self, text: RopeSlice, range: Range, height: usize, scrolloff: usize, ) { // MJB-LLR-098: a zero-height viewport has no inside; the margin // arithmetic below would underflow. if height == 0 { return; } // MJB-LLR-093: Helix clamps the margins to half the viewport, so a // scrolloff larger than the viewport cannot fight itself. let scrolloff_top = scrolloff.min((height - 1) / 2); let scrolloff_bottom = scrolloff.min(height / 2); let cursor_line = range.cursor_line(text); let top = self.top_line(text); let new_top = if cursor_line < top + scrolloff_top { // MJB-LLR-094 Some(cursor_line.saturating_sub(scrolloff_top)) } else if cursor_line + scrolloff_bottom >= top + height { // MJB-LLR-095 Some((cursor_line + scrolloff_bottom + 1).saturating_sub(height)) } else { // MJB-LLR-096 None }; if let Some(t) = new_top { self.set_top_line(text, t); } } /// MJB-LLR-099: scroll horizontally so the cursor's column is visible. pub fn ensure_horizontal_in_view(&mut self, text: RopeSlice, range: Range, width: usize) { if width == 0 { return; } let cursor = range.cursor(text); let line = text.byte_to_line_idx(cursor, LINE_TYPE); let line_start = text.line_to_byte_idx(line, LINE_TYPE); let column = display_column(text.line(line, LINE_TYPE), cursor - line_start); if column < self.offset.horizontal_offset { self.offset.horizontal_offset = column; } else if column >= self.offset.horizontal_offset + width { self.offset.horizontal_offset = column + 1 - width; } } /// MJB-LLR-100, MJB-LLR-101, MJB-LLR-102: move cursor and viewport together /// by `lines`, saturating at the buffer's ends. pub fn page(&mut self, text: RopeSlice, range: Range, lines: usize, down: bool) -> Range { let last = last_content_line(text); let cursor_line = range.cursor_line(text); let top = self.top_line(text); let (target_line, new_top) = if down { ( cursor_line.saturating_add(lines).min(last), top.saturating_add(lines), ) } else { ( cursor_line.saturating_sub(lines), top.saturating_sub(lines), ) }; self.set_top_line(text, new_top); // Land on the same display column where the target line allows it. let line_start = text.line_to_byte_idx(cursor_line, LINE_TYPE); let column = display_column(text.line(cursor_line, LINE_TYPE), range.cursor(text) - line_start); let target_start = text.line_to_byte_idx(target_line, LINE_TYPE); let offset = super::grapheme::byte_at_display_column(text.line(target_line, LINE_TYPE), column); Range::point(target_start + offset).clamped(text) } } #[cfg(test)] mod tests { use ropey::Rope; use super::*; /// 100 lines: "line0\nline1\n...". fn doc(n: usize) -> Rope { let mut s = String::new(); for i in 0..n { s.push_str(&format!("line{i}\n")); } Rope::from_str(&s) } fn at_line(text: RopeSlice, line: usize) -> Range { Range::point(text.line_to_byte_idx(line, LINE_TYPE)) } /// MJB-LLR-090: the anchor is a **byte** offset and always a line start. #[test] fn mjb_llr_090_anchor_is_a_byte_offset_at_a_line_start() { // Multi-byte lines, so a byte anchor differs from a line index. let t = Rope::from_str("文字\n化け\n三行\n"); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 1); assert_eq!(v.offset.anchor, 7, "byte offset, not line index"); assert_eq!( v.offset.anchor, s.line_to_byte_idx(1, LINE_TYPE), "anchor must land exactly on a line start" ); assert_eq!(v.top_line(s), 1, "and convert back"); assert_eq!(v.offset.horizontal_offset, 0, "columns start unscrolled"); } #[test] fn mjb_llr_091_top_line_from_anchor() { let t = doc(10); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 4); assert_eq!(v.top_line(s), 4); } /// MJB-LLR-092: the renderer must see exactly the visible window. #[test] fn mjb_llr_092_visible_lines_are_bounded_by_height() { let t = doc(100); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 10); let lines: Vec = v.visible_lines(s, 5).map(|l| l.to_string()).collect(); assert_eq!(lines.len(), 5, "must not exceed the viewport height"); assert_eq!(lines[0], "line10\n"); assert_eq!(lines[4], "line14\n"); } #[test] fn mjb_llr_092_visible_lines_clamp_near_end_of_buffer() { let t = doc(10); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 9); // 10 content lines plus the trailing empty line ropey reports. let count = v.visible_lines(s, 20).count(); assert!(count <= 2, "must not run past the end, got {count}"); } #[test] fn mjb_llr_096_no_scroll_when_cursor_is_comfortable() { let t = doc(100); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 10); let before = v.offset; v.ensure_cursor_in_view(s, at_line(s, 15), 20, 5); assert_eq!(v.offset, before, "cursor already inside both margins"); } #[test] fn mjb_llr_094_scrolls_up_to_honour_top_margin() { let t = doc(100); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 20); v.ensure_cursor_in_view(s, at_line(s, 21), 20, 5); assert_eq!(v.top_line(s), 16, "cursor_line - scrolloff_top"); } #[test] fn mjb_llr_095_scrolls_down_to_honour_bottom_margin() { let t = doc(100); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 0); // height 20, scrolloff 5 -> cursor at 18 forces top to 18+5+1-20 = 4. v.ensure_cursor_in_view(s, at_line(s, 18), 20, 5); assert_eq!(v.top_line(s), 4); } /// MJB-LLR-093: scrolloff exceeding the viewport must be clamped, not /// allowed to drive the anchor past the cursor. #[test] fn mjb_llr_093_scrolloff_larger_than_viewport_is_clamped() { let t = doc(100); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 50); v.ensure_cursor_in_view(s, at_line(s, 50), 10, 999); // Margins clamp to (10-1)/2 = 4 and 10/2 = 5. assert_eq!(v.top_line(s), 46); } /// MJB-LLR-098: a zero-height viewport must not underflow `height - 1`. #[test] fn mjb_llr_098_zero_height_viewport_is_a_noop() { let t = doc(10); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 3); let before = v.offset; v.ensure_cursor_in_view(s, at_line(s, 9), 0, 5); assert_eq!(v.offset, before); } #[test] fn mjb_llr_097_top_line_clamps_into_buffer() { let t = doc(10); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 9_999); assert!(v.top_line(s) < t.len_lines(LINE_TYPE)); } #[test] fn mjb_llr_094_scroll_near_start_saturates_at_zero() { let t = doc(100); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 2); v.ensure_cursor_in_view(s, at_line(s, 0), 20, 5); assert_eq!(v.top_line(s), 0, "must not underflow below line zero"); } #[test] fn mjb_llr_099_horizontal_scroll_follows_cursor() { let t = Rope::from_str(&format!("{}\n", "x".repeat(200))); let s = t.slice(..); let mut v = View::new(); v.ensure_horizontal_in_view(s, Range::point(150), 80); assert_eq!(v.offset.horizontal_offset, 150 + 1 - 80); v.ensure_horizontal_in_view(s, Range::point(10), 80); assert_eq!(v.offset.horizontal_offset, 10, "scrolls back left"); } #[test] fn mjb_llr_100_half_page_moves_cursor_and_view() { let t = doc(100); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 0); let moved = v.page(s, at_line(s, 0), 10, true); assert_eq!(moved.cursor_line(s), 10, "cursor moved"); assert_eq!(v.top_line(s), 10, "and the viewport moved with it"); } #[test] fn mjb_llr_101_full_page_moves_by_height() { let t = doc(100); let s = t.slice(..); let mut v = View::new(); v.set_top_line(s, 0); let moved = v.page(s, at_line(s, 0), 20, true); assert_eq!(moved.cursor_line(s), 20); assert_eq!(v.top_line(s), 20); } #[test] fn mjb_llr_102_paging_saturates_at_both_ends() { let t = doc(10); let s = t.slice(..); let mut v = View::new(); let up = v.page(s, at_line(s, 0), 50, false); assert_eq!(up.cursor_line(s), 0, "must not underflow"); assert_eq!(v.top_line(s), 0); let down = v.page(s, at_line(s, 0), 500, true); assert!( down.cursor_line(s) <= last_content_line(s), "must not run past the last content line" ); } #[test] fn mjb_llr_092_empty_buffer_renders_safely() { let t = Rope::from_str(""); let s = t.slice(..); let v = View::new(); assert_eq!(v.top_line(s), 0); let _ = v.visible_lines(s, 10).count(); } }