From 8b1eff10df31bb3f168fdfccc5ea6edde8ad0833 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 7 Aug 2026 09:48:08 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/diag.rs | 5 +++ src/editor.rs | 12 +++++++ src/highlight.rs | 10 ++++++ src/overlay.rs | 2 ++ src/search.rs | 4 +++ src/text_view.rs | 5 +++ src/view.rs | 43 ++++++++++++++++++++++++++ tests/compile_mode_acceptance.rs | 3 +- tests/listview_acceptance.rs | 3 +- tests/m4_acceptance.rs | 3 +- tests/tab_width_acceptance.rs | 3 +- tests/terminal_copy_mode_acceptance.rs | 3 +- 12 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/diag.rs b/src/diag.rs index b81a4dc..4ba4d6d 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -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, ); diff --git a/src/editor.rs b/src/editor.rs index 4d3fd17..9867501 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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 { #[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. diff --git a/src/highlight.rs b/src/highlight.rs index de8ffe0..dfa27c9 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -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(®istry, 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(®istry, 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(®istry, 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(®istry, 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(®istry, viewport, &mut grid); diff --git a/src/overlay.rs b/src/overlay.rs index cdbce35..80ec62b 100644 --- a/src/overlay.rs +++ b/src/overlay.rs @@ -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 { 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, } } diff --git a/src/search.rs b/src/search.rs index b77d231..a2e84b0 100644 --- a/src/search.rs +++ b/src/search.rs @@ -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, ); diff --git a/src/text_view.rs b/src/text_view.rs index aae8c06..e02e252 100644 --- a/src/text_view.rs +++ b/src/text_view.rs @@ -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, ); diff --git a/src/view.rs b/src/view.rs index e3e4b6c..bae61e2 100644 --- a/src/view.rs +++ b/src/view.rs @@ -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"); diff --git a/tests/compile_mode_acceptance.rs b/tests/compile_mode_acceptance.rs index 1b38abe..1b5150c 100644 --- a/tests/compile_mode_acceptance.rs +++ b/tests/compile_mode_acceptance.rs @@ -270,7 +270,7 @@ fn render_active_window_to_grid( cols: u32, ) -> Vec { 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, diff --git a/tests/listview_acceptance.rs b/tests/listview_acceptance.rs index e845fce..6653fbd 100644 --- a/tests/listview_acceptance.rs +++ b/tests/listview_acceptance.rs @@ -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 { 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 Vec { 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, diff --git a/tests/tab_width_acceptance.rs b/tests/tab_width_acceptance.rs index e153983..b6e9e03 100644 --- a/tests/tab_width_acceptance.rs +++ b/tests/tab_width_acceptance.rs @@ -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, } } diff --git a/tests/terminal_copy_mode_acceptance.rs b/tests/terminal_copy_mode_acceptance.rs index ecb6e69..13f3758 100644 --- a/tests/terminal_copy_mode_acceptance.rs +++ b/tests/terminal_copy_mode_acceptance.rs @@ -468,7 +468,7 @@ fn render_active_window_to_grid( cols: u32, ) -> Vec { 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,