diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
| commit | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/view.rs | |
| parent | 8c0b4c53b130555f040884c1f52b90f16b23e241 (diff) | |
feat: implement Helix-style modal buffer under DO-178C DAL-C
The repository was an unmodified ratatui component template: no editor
code, JSON5 config, and placeholder widgets. This establishes the first
working baseline — `moji <file>` opens a file into a ropey rope and edits
it with Helix selection-first semantics.
Requirements, implementation and tests land together because they must:
the traceability check rejects requirements with no implementation and
tests naming requirements that do not exist, so neither half is a valid
commit on its own.
Package renamed to mojibake-editor (mojibake was taken on crates.io);
binary is moji, library target stays mojibake.
Class: New behaviour
Requirements: MJB-HLR-001..019, MJB-LLR-001..205
Derived: MJB-DR-001..007 (DR-001 resolved, six open for review)
Verified: cargo build; clippy --all-targets -D warnings clean;
cargo test 294 passing; ./scripts/check-trace.sh 98/98/98;
cargo package clean
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/view.rs')
| -rw-r--r-- | src/buffer/view.rs | 371 |
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(); + } +} |
