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:
parent
4a26f0005e
commit
eaf3df8765
|
|
@ -4416,7 +4416,30 @@ fn paint_window_content(
|
||||||
coord.row as usize,
|
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
|
// Lock scoped to the summary computation only: the overlay
|
||||||
// renders above include `DiagnosticView`, which takes this
|
// renders above include `DiagnosticView`, which takes this
|
||||||
// same mutex — holding the guard across the loop deadlocked
|
// 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
|
/// `visible` may be 0 in tests that never rendered (so
|
||||||
/// `last_visible_rows` was never populated); in that case we fall
|
/// `last_visible_rows` was never populated); in that case we fall
|
||||||
/// back to cursor-row-based percent without the All/Top/Bot caps.
|
/// 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(
|
fn format_scroll_indicator(
|
||||||
view_top: usize,
|
view_top: usize,
|
||||||
visible: usize,
|
visible: usize,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,16 @@ pub const FOLD_ELLIPSIS: char = '…';
|
||||||
|
|
||||||
/// View that renders a buffer as plain UTF-8 text, one buffer line per row.
|
/// View that renders a buffer as plain UTF-8 text, one buffer line per row.
|
||||||
pub struct TextView {
|
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`
|
/// Byte offsets of each line's first byte. `line_offsets[0] == 0`
|
||||||
/// always; `line_offsets.last()` is the start of the final line.
|
/// always; `line_offsets.last()` is the start of the final line.
|
||||||
/// `line_offsets.len()` equals the number of lines (not the number of
|
/// `line_offsets.len()` equals the number of lines (not the number of
|
||||||
|
|
@ -57,11 +67,19 @@ impl TextView {
|
||||||
pub fn new(buf: &Buffer) -> Self {
|
pub fn new(buf: &Buffer) -> Self {
|
||||||
let mut v = Self {
|
let mut v = Self {
|
||||||
line_offsets: vec![0],
|
line_offsets: vec![0],
|
||||||
|
reached_buffer_end: false,
|
||||||
};
|
};
|
||||||
v.rebuild_lines_from(buf, 0);
|
v.rebuild_lines_from(buf, 0);
|
||||||
v
|
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.
|
/// Number of lines in the buffer, as understood by this view.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn line_count(&self) -> usize {
|
pub fn line_count(&self) -> usize {
|
||||||
|
|
@ -567,6 +585,15 @@ impl View for TextView {
|
||||||
// would re-enter the same grid row forever.
|
// would re-enter the same grid row forever.
|
||||||
row_offset += used.max(1);
|
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()
|
.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
|
/// A viewport too narrow to hold a wide glyph must not insert a
|
||||||
/// blank row before it.
|
/// blank row before it.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue