aboutsummaryrefslogtreecommitdiff
path: root/src/buffer/view.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
commit8e16347b0eb329e84892af8ece36886324c95f62 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/view.rs
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'HEADmain
Establishes the first working baseline: moji <file> opens a file into a ropey rope and edits it with Helix selection-first modal editing, under a DO-178C DAL-C requirements and traceability process. Prior to this, main tracked four files and src/main.rs was still println!("Hello, world!") — there was no buildable state to build on. Verified on a fresh clone of the branch with no untracked files: cargo build; clippy --all-targets -D warnings clean; 294 tests passing; scripts/check-trace.sh reports 98/98 requirements traced in both directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/view.rs')
-rw-r--r--src/buffer/view.rs371
1 files changed, 371 insertions, 0 deletions
diff --git a/src/buffer/view.rs b/src/buffer/view.rs
new file mode 100644
index 0000000..e419052
--- /dev/null
+++ b/src/buffer/view.rs
@@ -0,0 +1,371 @@
+//! 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<Item = RopeSlice<'a>> {
+ 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<String> = 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();
+ }
+}