feat(editor): the TUI scroll indicator stops lying under wrapping

format_scroll_indicator derives every branch from total_lines, and its
first branch is `if total_lines <= 1 { return "All" }`. A one-line
buffer wrapping to fifty rows still has one line, so the indicator
claimed the whole buffer was on screen while forty-nine rows sat below
the viewport.

Under wrap the TUI now classifies through pmacs_protocol:📜
All/Top/Bot from LOCAL predicates, the percentage from byte position.
Under truncate it calls the existing formatter with the same arguments
in the same units, so that output is byte-identical by construction and
every existing formatter test stays valid.

The local predicate needed a new fact, and getting it right took two
attempts. TextView::render now records whether the walk ran out of
BUFFER before it ran out of rows --- recorded by the walk rather than
recomputed, because under wrapping it cannot be derived from line
counts and a second derivation could disagree with what was painted.

The first version was `line >= line_count`, which is true whenever the
last line was STARTED. Under wrapping that is exactly the wrong moment:
a fifty-row line begun on the last visible row would report the buffer
end as on screen. It needs `row_offset <= max_rows` as well --- the
rows it wanted actually fit. The witness caught it; the reasoning did
not.

The GPU half is NOT in this commit, and that is deliberate. I wired it,
and extreme_sizes_render_with_contained_popups failed --- correctly.
That test asserts the frame diff between two renders is confined to the
completion popup, and my last_visible predicate read self.view_range,
which the popup's reshape moves, so the status text changed between the
two frames. The predicate was also simply wrong: view_range includes
overscan, so it can reach EOF while the last row is off-screen. A
stable, correct local predicate on that side needs an understanding of
the slice/overscan relationship I do not have yet, and a guess there
would ship the same class of defect this stage exists to remove.
Reverted; the GPU indicator remains open work.

Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1917/0,
crdt 2102/0, pmacs-gpu 224/0, line_wrap_acceptance 4/4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-07 18:37:41 +02:00
parent 4a26f0005e
commit eaf3df8765
No known key found for this signature in database
2 changed files with 120 additions and 1 deletions

View File

@ -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,

View File

@ -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.
///