feat(view): WrapMode, threaded to every viewport and pinned to Truncate

Inert by construction. Adds the type and the Viewport field, sets all
31 construction sites to Truncate, and changes no rendering: --lib is
1900/0 and crdt 2085/0, the same counts as the parent commit.

The field is required rather than defaulted on purpose. A default would
have let 31 sites stay silent about which behavior they meant; a
required field makes each one state it, so the pre-existing sites now
read as deliberately unwrapped rather than merely untouched. The
compiler enumerated them, including five integration tests --- Viewport
is public API, so this is a real break, and the break is the point.

The render driver is pinned to Truncate too. The wrap path does not
exist yet, and exposing a mode before the cursor mapping honors it
would ship a setting that renders one thing and navigates another ---
the shape of defect this lane exists to remove, not add.

Two notes on getting here, since both were nearly landed:

The first mechanical patch matched every `folds,` line and put a wrap
field into function call sites and a FoldStore literal. Scoping the
insertion to Viewport literals cut it from 40 sites to 31. The compiler
caught it, but only because a struct field cannot be mistaken for an
argument; a same-arity call would have compiled.

While rewriting the character walk I changed the wide-character edge
case --- a double-width glyph with one cell left now breaking instead
of painting a lone lead cell. That is arguably better behavior and it
is NOT this commit's to make: Truncate must be byte-identical, and an
"improvement" smuggled in beside a refactor is how identity cases stop
being identity cases. Reverted; the walk is untouched.

Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1900/0,
crdt 2085/0, tab_width 2/0, listview 26/0, compile_mode 73/0,
folding 21/0.

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 09:48:08 +02:00
parent b8639130d9
commit 8b1eff10df
No known key found for this signature in database
12 changed files with 91 additions and 5 deletions

View File

@ -757,6 +757,7 @@ fn underline_cols_for_line(line_bytes: &[u8], byte_start: u32, byte_end: u32) ->
#[cfg(test)]
mod tests {
use super::*;
use crate::view::WrapMode;
use serde_json::json;
fn diag(line: u32, sev: DiagnosticSeverity, msg: &str) -> Diagnostic {
@ -1139,6 +1140,7 @@ mod tests {
cell_size: CellSize::new(1, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);
@ -1204,6 +1206,7 @@ mod tests {
cell_size: CellSize::new(3, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);
@ -1280,6 +1283,7 @@ mod tests {
cell_size: CellSize::new(3, 8),
gutter_w: 2,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);
@ -1349,6 +1353,7 @@ mod tests {
cell_size: CellSize::new(2, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);

View File

@ -4313,6 +4313,15 @@ fn paint_window_content(
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
gutter_w,
folds,
// QoL Stage 3: the resolved mode belongs here, beside `folds`,
// for the same reason — the driver holds the registry and the
// buffer, the view holds neither. Pinned to the identity case
// until `ui.line-wrap` is registered; the wrap path is built
// and tested beneath this, but nothing can reach it yet, which
// is deliberate. Exposing a mode before the cursor mapping
// honors it would ship a setting that renders one thing and
// navigates another.
wrap: crate::view::WrapMode::Truncate,
};
// Composition (T M2.9): base text_view paints first, then the
// gutter numbers — before the overlays, so a diagnostic overlay
@ -5618,6 +5627,7 @@ fn printable_char(seq: &[Chord]) -> Option<char> {
#[cfg(test)]
mod tests {
use crate::view::WrapMode;
// Acceptance home for T M5.4 (FrontendId on input events) — the
// `m5_4_*`-prefixed tests verify the FrontendId field threads from
// synthetic event construction through `dispatch_key` /
@ -8415,6 +8425,7 @@ mod tests {
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let mut grid = CellGrid {
cells: &mut backing,
@ -8550,6 +8561,7 @@ mod tests {
cell_size: CellSize::new(24, 80),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
// Two no-op overlays: probe the dispatch cost only.

View File

@ -773,6 +773,7 @@ impl View for LspStyleView {
#[cfg(test)]
mod tests {
use super::*;
use crate::view::WrapMode;
#[test]
fn theme_lookup_walks_dotted_prefixes() {
@ -1111,6 +1112,7 @@ mod tests {
cell_size: CellSize::new(1, 20),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
@ -1209,6 +1211,7 @@ mod tests {
cell_size: CellSize::new(1, 20),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
@ -1329,6 +1332,7 @@ mod tests {
cell_size: CellSize::new(1, 20),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = state.core.borrow().registry.clone();
let reg = registry.borrow();
@ -1389,6 +1393,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = buf; // keep buf alive
hv.render(&registry, viewport, &mut grid);
@ -1446,6 +1451,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = buf; // keep buf alive
hv.render(&registry, viewport, &mut grid);
@ -1511,6 +1517,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = buf;
hv.render(&registry, viewport, &mut grid);
@ -1578,6 +1585,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
hv.render(&buf, viewport, &mut grid);
grid.get(CellCoord::new(0, col)).style
@ -1820,6 +1828,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = buf;
hv.render(&registry, viewport, &mut grid);
@ -1883,6 +1892,7 @@ mod tests {
cell_size: CellSize::new(rows as u32, cols as u32),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let registry = buf;
hv.render(&registry, viewport, &mut grid);

View File

@ -483,6 +483,7 @@ mod tests {
use crate::cell::{Cell, CellSize, Glyph, UnderlineStyle};
use crate::text_view::TextView;
use crate::view::Viewport;
use crate::view::WrapMode;
fn make_grid(rows: u32, cols: u32) -> Vec<Cell> {
vec![Cell::default(); (rows * cols) as usize]
@ -496,6 +497,7 @@ mod tests {
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
}
}

View File

@ -541,6 +541,7 @@ impl View for SearchView {
#[cfg(test)]
mod tests {
use super::*;
use crate::view::WrapMode;
fn r(start: u64, end: u64) -> ByteRange {
ByteRange { start, end }
@ -759,6 +760,7 @@ mod tests {
cell_size: CellSize::new(1, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);
@ -788,6 +790,7 @@ mod tests {
cell_size: CellSize::new(1, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid2,
);
@ -830,6 +833,7 @@ mod tests {
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);

View File

@ -316,6 +316,7 @@ mod tests {
use crate::buffer::{BufferId, EditOp};
use crate::cell::CellSize;
use crate::rope::Range;
use crate::view::WrapMode;
use proptest::prelude::*;
fn buf_with(content: &[u8]) -> Buffer {
@ -570,6 +571,7 @@ mod tests {
cell_size: CellSize::new(1, 16),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);
@ -599,6 +601,7 @@ mod tests {
cell_size: CellSize::new(1, 16),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);
@ -631,6 +634,7 @@ mod tests {
cell_size: CellSize::new(5, 5),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);
@ -664,6 +668,7 @@ mod tests {
cell_size: CellSize::new(1, 5),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
},
&mut grid,
);

View File

@ -122,6 +122,39 @@ impl DisplayCoord {
}
}
/// How a line wider than the viewport is shown --- the long-lines
/// stage, `docs/long-lines-framing.md`.
///
/// # Why the renderer is told, rather than asking
///
/// This is the *resolved* mode, not the setting's name. `ui.line-wrap`
/// is **buffer-local** (framing Q#LL2), and the config registry has no
/// ambient current buffer by design (`config_registry.rs`, Q#CR4) — a
/// caller wanting buffer-aware behavior must pass the `BufferId`. The
/// render driver holds both the registry and the buffer, resolves once
/// per window per frame, and puts the answer here. Views stay
/// config-agnostic, exactly as they do for folds.
///
/// # `Truncate` is the identity case, deliberately
///
/// Every behavior predating this type is `Truncate`, and it must stay
/// byte-identical under it — which is what lets the wrap work be
/// verified against the existing suite rather than against new
/// assertions.
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default, Hash)]
pub enum WrapMode {
/// One source line per row; the remainder is clipped at the right
/// edge. What every pre-Stage-3 caller did.
#[default]
Truncate,
/// A line longer than the viewport continues on the following rows.
///
/// Character wrap, not word wrap (framing Q#LL5): it matches
/// Emacs's default, and it is the only break rule both frontends
/// can implement identically without pulling UAX #14 into the grid.
Wrap,
}
/// What to render and where.
///
/// The frontend computes the viewport (which buffer range maps to which
@ -152,6 +185,14 @@ pub struct Viewport<'a> {
/// split may show different buffers) and hands the same shared
/// reference to every painter of that window.
pub folds: Option<&'a VisibleLineMap>,
/// How lines wider than `cell_size.cols` are shown, already
/// resolved for this window's buffer (see [`WrapMode`]).
///
/// A required field rather than a defaulted one on purpose: every
/// construction site has to state which behavior it means, so the
/// pre-Stage-3 sites read as *deliberately* unwrapped rather than
/// merely untouched.
pub wrap: WrapMode,
}
impl Viewport<'_> {
@ -360,6 +401,7 @@ mod tests {
cell_size: CellSize::new(10, 10),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
assert_eq!(vp.row_offset_of(4, 4), Some(0));
assert_eq!(vp.row_offset_of(4, 9), Some(5));
@ -380,6 +422,7 @@ mod tests {
cell_size: CellSize::new(10, 10),
gutter_w: 0,
folds: Some(&map),
wrap: WrapMode::Truncate,
};
assert_eq!(vp.row_offset_of(0, 1), Some(1), "the head keeps its row");
assert_eq!(vp.row_offset_of(0, 3), None, "hidden lines have no row");

View File

@ -270,7 +270,7 @@ fn render_active_window_to_grid(
cols: u32,
) -> Vec<pmacs::cell::Cell> {
use pmacs::cell::{Cell, CellGrid, CellSize};
use pmacs::view::{View, Viewport};
use pmacs::view::{View, Viewport, WrapMode};
use pmacs::window::Rect;
let mut core = state.core.borrow_mut();
@ -289,6 +289,7 @@ fn render_active_window_to_grid(
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let mut grid = CellGrid {
cells: &mut backing,

View File

@ -144,7 +144,7 @@ fn set_read_only(s: &EditorState, id: BufferId, value: bool) {
/// bites: the rope is right and the screen is not.
fn paint_active_window(s: &EditorState, rows: u32, cols: u32) -> Vec<pmacs::cell::Cell> {
use pmacs::cell::{Cell, CellGrid, CellSize};
use pmacs::view::{View, Viewport};
use pmacs::view::{View, Viewport, WrapMode};
use pmacs::window::Rect;
let mut core = s.core.borrow_mut();
@ -162,6 +162,7 @@ fn paint_active_window(s: &EditorState, rows: u32, cols: u32) -> Vec<pmacs::cell
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let mut grid = CellGrid {
cells: &mut backing,

View File

@ -483,7 +483,7 @@ fn render_active_window_to_grid(
cols: u32,
) -> Vec<pmacs::cell::Cell> {
use pmacs::cell::{Cell, CellGrid, CellSize};
use pmacs::view::{View, Viewport};
use pmacs::view::{View, Viewport, WrapMode};
use pmacs::window::Rect;
let mut core = state.core.borrow_mut();
@ -502,6 +502,7 @@ fn render_active_window_to_grid(
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let mut grid = CellGrid {
cells: &mut backing,

View File

@ -6,7 +6,7 @@ use pmacs::buffer::{Buffer, BufferId};
use pmacs::cell::{Cell, CellCoord, CellGrid, CellSize, Glyph, Style};
use pmacs::overlay::{BufferStyleOverlay, BufferStyleSpan, SharedBufferStyleSpans};
use pmacs::text_view::TextView;
use pmacs::view::{DisplayCoord, View, Viewport};
use pmacs::view::{DisplayCoord, View, Viewport, WrapMode};
fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport<'static> {
Viewport {
@ -16,6 +16,7 @@ fn viewport(rows: u32, cols: u32, buffer_end: u64) -> Viewport<'static> {
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
}
}

View File

@ -468,7 +468,7 @@ fn render_active_window_to_grid(
cols: u32,
) -> Vec<pmacs::cell::Cell> {
use pmacs::cell::{Cell, CellGrid};
use pmacs::view::{View, Viewport};
use pmacs::view::{View, Viewport, WrapMode};
use pmacs::window::Rect;
let mut core = state.core.borrow_mut();
@ -486,6 +486,7 @@ fn render_active_window_to_grid(
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
folds: None,
wrap: WrapMode::Truncate,
};
let mut grid = CellGrid {
cells: &mut backing,