fix(editor): the renderer never got the mode everything else was reading

The frame resolved ui.line-wrap, recorded it on the window, and fed it
to the coordinate mapping and the scroll indicator --- while the
viewport handed TextView::render a hard-coded Truncate. With the
default of wrap, that meant the cursor was placed for wrapped text and
the indicator reckoned against wrapped rows while the text was still
clipped at the edge. The worst possible split: every part that reports
where things are agreed, and the part that draws them did not.

The viewport now reads window.last_wrap, so it consumes the same single
resolution as everything else rather than resolving again.

How it survived: the edit was in a script that raised on a LATER
assertion, so nothing was written; a follow-up script's replace then
matched nothing and silently did nothing. Every test I had asked "is
the mode right?" and none asked "is the text wrapped?", so all of them
passed.

Hence the new witness reads the GRID. the_default_actually_wraps_the_
painted_text goes through RenderState and reconstructs rows from the
emitted CellDelta spans, for two reasons: the defect lived in the
DRIVER, between the resolved mode and the viewport it built, so a test
building its own viewport would have passed against it --- and the
spans are what a TUI actually consumes. truncate_clips_the_painted_text
is its control, so the pair is discriminating rather than merely true.

It bites: restoring the hard-coded Truncate fails the wrap witness
while all five other tests keep passing, which is exactly the shape
that let it through.

Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1917/0,
crdt 2102/0, line_wrap 6/6, tab_width 2/0, folding 21/0,
full_grid_resync 1/1.

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:48:11 +02:00
parent eaf3df8765
commit aa3cd4da34
No known key found for this signature in database
2 changed files with 96 additions and 9 deletions

View File

@ -4363,15 +4363,18 @@ fn paint_window_content(
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w), cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
gutter_w, gutter_w,
folds, folds,
// QoL Stage 3: the resolved mode belongs here, beside `folds`, // The resolved mode belongs here beside `folds`, for the same
// for the same reason — the driver holds the registry and the // reason: the driver holds the registry and the buffer, the view
// buffer, the view holds neither. Pinned to the identity case // holds neither.
// until `ui.line-wrap` is registered; the wrap path is built //
// and tested beneath this, but nothing can reach it yet, which // It reads `last_wrap` rather than resolving again. The render
// is deliberate. Exposing a mode before the cursor mapping // loop already resolved it for this window this frame, and the
// honors it would ship a setting that renders one thing and // coordinate callers read that same field through
// navigates another. // `Window::layout_ctx` — so a second resolution here could
wrap: crate::view::WrapMode::Truncate, // disagree with the one the cursor is placed against, which is
// the failure this whole one-resolution arrangement exists to
// prevent.
wrap: window.last_wrap,
}; };
// Composition (T M2.9): base text_view paints first, then the // Composition (T M2.9): base text_view paints first, then the
// gutter numbers — before the overlays, so a diagnostic overlay // gutter numbers — before the overlays, so a diagnostic overlay

View File

@ -57,6 +57,53 @@ fn mode_global(s: &EditorState) -> String {
eval(s, "return pmacs.config.get('ui.line-wrap')") eval(s, "return pmacs.config.get('ui.line-wrap')")
} }
/// Put `text` in the window's current buffer by typing it, which is the
/// only path Lua exposes and also the one a user takes.
fn fill_active(s: &EditorState, text: &str) {
for ch in text.chars() {
exec(
s,
&format!("pmacs.editor.insert_char_over_region({})", ch as u32),
);
}
}
/// Render one frame and return the text of the first `rows` grid rows,
/// reconstructed from the emitted `CellDelta` spans.
///
/// Two reasons for going through `RenderState` and the wire rather than
/// calling `TextView::render` with a hand-built viewport. The defect
/// this guards against lived in the *driver*, between the resolved mode
/// and the viewport it built — a test that constructed its own viewport
/// would have passed against it. And the spans are what the TUI
/// actually consumes, so this asserts on the bytes that reach a screen.
fn render_rows(s: &EditorState, rows: u32, cols: u32) -> Vec<String> {
use std::collections::HashMap;
let size = pmacs::cell::CellSize::new(rows, cols);
let mut rs = pmacs::instance_render::RenderState::new(size);
let msgs = rs.render_frame(s, pmacs::protocol::FrontendId::LOCAL, &HashMap::new(), &[]);
let mut grid = vec![vec![' '; cols as usize]; rows as usize];
for msg in &msgs {
if let pmacs_protocol::InstanceMessage::CellDelta { spans, .. } = msg {
for span in spans {
for (i, cell) in span.cells.iter().enumerate() {
let r = span.start.row as usize;
let c = span.start.col as usize + i;
if r < rows as usize
&& c < cols as usize
&& let pmacs::cell::Glyph::Char(ch) = cell.glyph
{
grid[r][c] = ch;
}
}
}
}
}
grid.into_iter().map(|r| r.into_iter().collect()).collect()
}
#[test] #[test]
fn the_default_is_wrap() { fn the_default_is_wrap() {
let s = session("default"); let s = session("default");
@ -137,3 +184,40 @@ fn a_pinned_buffer_toggles_from_its_own_value() {
"the toggle read this buffer's value, not the global one" "the toggle read this buffer's value, not the global one"
); );
} }
/// The default must reach the **rendered cells**, not merely the
/// resolved value.
///
/// This is the gap review found: the frame resolved `ui.line-wrap`,
/// recorded it on the window, and fed it to the coordinate mapping and
/// the scroll indicator — while the viewport handed the renderer a
/// hard-coded `Truncate`. Every "is the mode right?" assertion passed
/// and the text was still clipped. So this test reads the grid.
#[test]
fn the_default_actually_wraps_the_painted_text() {
let s = session("rendered_default");
// Wider than the viewport below, and distinctive.
fill_active(&s, "ABCDEFGHIJKLMNOP");
let rows = render_rows(&s, 4, 4);
assert_eq!(rows[0], "ABCD");
assert_eq!(
rows[1], "EFGH",
"the default is `wrap`, so the remainder continues on the next row \
a hard-coded Truncate in the viewport leaves this blank"
);
}
/// And `truncate` still clips, so the witness above is discriminating
/// rather than merely true.
#[test]
fn truncate_clips_the_painted_text() {
let s = session("rendered_truncate");
fill_active(&s, "ABCDEFGHIJKLMNOP");
exec(
&s,
"pmacs.config.set_local(pmacs.window.buffer(), 'ui.line-wrap', 'truncate')",
);
let rows = render_rows(&s, 4, 4);
assert_eq!(rows[0], "ABCD");
assert_eq!(rows[1], " ", "truncate keeps one row per source line");
}