diff --git a/src/editor.rs b/src/editor.rs index 5824a52..7647251 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -4416,7 +4416,30 @@ fn paint_window_content( coord.row as usize, ), }; - let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor); + let scroll = if window.last_wrap == crate::view::WrapMode::Wrap { + // Under wrapping the line-space formatter is not merely + // imprecise, it is wrong: a one-line buffer wrapping to fifty + // rows has `total_lines == 1`, so its first branch reports + // "All" while forty-nine rows sit below the viewport. + // + // There is no row total to give it instead. The GPU shapes only + // its viewport slice, so it cannot count rows it never laid out, + // and computing a total arithmetically would disagree with the + // break points actually rendered. So `All`/`Top`/`Bot` come from + // LOCAL predicates — which the render walk already knows — and + // the percentage comes from byte position. Both frontends use + // the same rule, from `pmacs_protocol::scroll`. + let first_visible = viewport_buffer_start == 0; + let last_visible = window.text_view.reached_buffer_end(); + render_scroll_position(pmacs_protocol::scroll::classify( + first_visible, + last_visible, + window.cursor, + buf.len(), + )) + } else { + format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor) + }; // Lock scoped to the summary computation only: the overlay // renders above include `DiagnosticView`, which takes this // same mutex — holding the guard across the loop deadlocked @@ -5589,6 +5612,23 @@ fn first_line(s: &str) -> &str { /// `visible` may be 0 in tests that never rendered (so /// `last_visible_rows` was never populated); in that case we fall /// back to cursor-row-based percent without the All/Top/Bot caps. +/// Render a [`pmacs_protocol::scroll::ScrollPosition`] for the status +/// line. +/// +/// The classification is shared with the GPU frontend; only this +/// rendering is per-frontend, which is the split framing §5d.6 settled: +/// each frontend answers its own layout questions, the shared crate owns +/// the decision they feed. +fn render_scroll_position(pos: pmacs_protocol::scroll::ScrollPosition) -> String { + use pmacs_protocol::scroll::ScrollPosition; + match pos { + ScrollPosition::All => "All".to_owned(), + ScrollPosition::Top => "Top".to_owned(), + ScrollPosition::Bot => "Bot".to_owned(), + ScrollPosition::Percent(p) => format!("{p}%"), + } +} + fn format_scroll_indicator( view_top: usize, visible: usize, diff --git a/src/text_view.rs b/src/text_view.rs index 57fe8e5..777e809 100644 --- a/src/text_view.rs +++ b/src/text_view.rs @@ -43,6 +43,16 @@ pub const FOLD_ELLIPSIS: char = '…'; /// View that renders a buffer as plain UTF-8 text, one buffer line per row. pub struct TextView { + /// Whether the last [`View::render`] ran out of BUFFER before it ran + /// out of rows — i.e. the buffer's final visual row was on screen. + /// + /// Recorded by the walk rather than recomputed, for the same reason + /// `Window::last_content_cols` is taken from the viewport: under + /// wrapping this cannot be derived from line counts, and a second + /// derivation could disagree with what was actually painted. The + /// scroll indicator reads it as a local predicate, which is what + /// lets `All`/`Top`/`Bot` stay exact with no row total in existence. + reached_buffer_end: bool, /// Byte offsets of each line's first byte. `line_offsets[0] == 0` /// always; `line_offsets.last()` is the start of the final line. /// `line_offsets.len()` equals the number of lines (not the number of @@ -57,11 +67,19 @@ impl TextView { pub fn new(buf: &Buffer) -> Self { let mut v = Self { line_offsets: vec![0], + reached_buffer_end: false, }; v.rebuild_lines_from(buf, 0); v } + /// Whether the last render reached the buffer's end — see + /// [`Self::reached_buffer_end`]. `false` before the first render. + #[must_use] + pub fn reached_buffer_end(&self) -> bool { + self.reached_buffer_end + } + /// Number of lines in the buffer, as understood by this view. #[must_use] pub fn line_count(&self) -> usize { @@ -567,6 +585,15 @@ impl View for TextView { // would re-enter the same grid row forever. row_offset += used.max(1); } + // Ran out of buffer before running out of rows. + // + // BOTH halves are needed. `line >= line_count` alone is true + // whenever the last line was *started*, which under wrapping + // happens while its remaining rows sit below the viewport — a + // fifty-row line begun on the last visible row would report the + // buffer end as on screen. `row_offset <= max_rows` is what says + // the rows it needed actually fit. + self.reached_buffer_end = line >= self.line_count() && row_offset <= max_rows; } } @@ -1081,6 +1108,58 @@ mod tests { .collect() } + /// `reached_buffer_end` is a local predicate, recorded by the walk. + /// + /// The scroll indicator needs `All`/`Top`/`Bot` without a row total + /// — which under wrapping does not exist — so it asks the walk + /// instead of counting. + #[test] + fn the_walk_reports_whether_it_reached_the_buffer_end() { + // Ten characters at four columns is three visual rows; a + // four-row viewport outruns the buffer. + let (buf, mut view) = attached(b"abcdefghij"); + let mut storage = vec![Cell::default(); 16]; + let mut grid = CellGrid { + cells: &mut storage, + stride: 4, + size: CellSize::new(4, 4), + }; + let vp = Viewport { + buffer_start: 0, + buffer_end: buf.len(), + cell_origin: CellCoord::new(0, 0), + cell_size: CellSize::new(4, 4), + gutter_w: 0, + folds: None, + wrap: WrapMode::Wrap, + }; + view.render(&buf, vp, &mut grid); + assert!( + view.reached_buffer_end(), + "three rows of content in four rows of viewport: the end is on screen" + ); + + // Two rows of viewport cannot hold three rows of content. + let mut small = vec![Cell::default(); 8]; + let mut small_grid = CellGrid { + cells: &mut small, + stride: 4, + size: CellSize::new(2, 4), + }; + view.render( + &buf, + Viewport { + cell_size: CellSize::new(2, 4), + ..vp + }, + &mut small_grid, + ); + assert!( + !view.reached_buffer_end(), + "the wrapped remainder is below the viewport" + ); + } + /// A viewport too narrow to hold a wide glyph must not insert a /// blank row before it. ///