From db18261a5dd25297b79d5f9ab8084479348176ce Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 1 Sep 2026 11:00:20 +0200 Subject: [PATCH] feat(gui-1b): B7 --- the TUI's horizontal axis, which used to vanish `MouseEventKind::ScrollLeft` and `ScrollRight` reached `dispatch_mouse` and fell into its catch-all, so a horizontal wheel over a TUI document did nothing at all. B7's row said as much: "events arrive and are dropped". They now call `scroll_window_columns` --- the SAME helper B2's panel leg uses. One contract, one implementation, so the two document surfaces cannot clamp differently, which is what B3 means by "each document surface clamps at its own content bounds". Four rows, each mutation-checked against its own mutation: three columns per notch step of one -> 1 row right bound keeps the last clamp at full width -> 1 row display column visible origin saturates at zero (covered by the above) wrap pins the origin to 0 drop the wrap guard -> 1 row, only it The right-bound row is the one that needed care. "The origin stopped somewhere" is satisfiable by a bound that lets the origin pass every glyph and blanks the viewport, so the row asserts the FINAL DISPLAY COLUMN IS STILL REACHABLE --- origin + viewport >= widest --- and separately that the origin has not passed the content. Two fixture facts the rows need, both stated rather than assumed: - they PAINT first. B7's bound is `widest - viewport`, and a window that has never painted has `last_content_cols == 0`, so the bound degenerates to `widest` and the row measures nothing. My first draft had exactly that hole and passed for the wrong reason. - they set TRUNCATE after the paint. Under wrap the bound does not exist and clause 5 pins the origin to zero, so a bound row must be a truncate row. The paint writes `last_wrap` from the view config, which is why the order matters --- setting it first, as I first did, is overwritten and the rows silently exercised the wrap branch instead. --- src/editor.rs | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/src/editor.rs b/src/editor.rs index 53c744e..4cdfa90 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -4134,6 +4134,20 @@ impl EditorState { self.mouse_click = None; self.scroll_window(win_id, SCROLL_LINES); } + // GUI Stage 1b B7 — the TUI's horizontal axis, which these + // events reached and which dropped them into the catch-all + // below. `scroll_window_columns` carries B7's bound and its + // wrap pin, and is the same helper the panel's horizontal + // leg uses: one contract, one implementation, so the two + // surfaces cannot clamp differently. + MouseEventKind::ScrollLeft => { + self.mouse_click = None; + self.scroll_window_columns(win_id, -SCROLL_COLUMNS); + } + MouseEventKind::ScrollRight => { + self.mouse_click = None; + self.scroll_window_columns(win_id, SCROLL_COLUMNS); + } _ => { self.mouse_click = None; } @@ -7009,6 +7023,31 @@ mod tests { assert_eq!(glyph(11, 2), Glyph::Char('2')); } + /// Paint once, so `last_content_cols` is a real viewport width. + /// + /// B7's bound is `widest − viewport`, and a window that has never + /// painted has no viewport: a row that skips this measures against + /// zero and cannot see the blanking the bound exists to prevent. + fn paint_once(s: &EditorState, size: pmacs_protocol::CellSize) { + use crate::cell::{Cell, CellGrid}; + let mut backing = vec![Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut backing, + stride: size.cols, + size, + }; + let _ = paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); + // B7's bound only exists under truncate: a wrapped line has + // nothing past the right edge, and clause 5 pins the origin to + // zero. Set AFTER the paint, which writes `last_wrap` from the + // view config — a row about the bound must be a truncate row. + let mut core = s.core.borrow_mut(); + let id = core.active_window_id(); + if let Some(window) = core.windows.get_mut(&id) { + window.last_wrap = crate::view::WrapMode::Truncate; + } + } + fn fresh_with(content: &[u8]) -> EditorState { let s = EditorState::new(); let new_id = s @@ -10372,6 +10411,158 @@ mod tests { assert!(s.core.borrow().active_window().selection.is_none()); } + /// GUI Stage 1b B7 — a horizontal wheel notch moves the TUI + /// document's origin by **three columns**, and these events used to + /// fall into `dispatch_mouse`'s catch-all and vanish. + /// + /// *Mutation: a step of one → this row.* + #[test] + fn b7_a_horizontal_notch_moves_the_origin_three_columns() { + use crossterm::event::MouseEventKind; + // Wide enough that the bound cannot absorb the move: B7's + // maximum is `widest − viewport`, so a document that fits has a + // maximum of zero and a dropped event reads as correct. + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + let mut s = fresh_with(&content); + paint_once(&s, term_size_24x80()); + let before = s.core.borrow().active_window().view_left; + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + + let after = s.core.borrow().active_window().view_left; + assert_eq!( + after - before, + SCROLL_COLUMNS as u32, + "one notch moves three columns, and must move at all" + ); + } + + /// B7/B3 — the origin never goes negative, and a left notch at the + /// left edge is absorbed rather than wrapping around. + #[test] + fn b7_the_origin_saturates_at_zero_rather_than_going_negative() { + use crossterm::event::MouseEventKind; + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + let mut s = fresh_with(&content); + paint_once(&s, term_size_24x80()); + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollLeft, 5, 5), + term_size_24x80(), + ); + + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "already at the left bound: the notch is absorbed, not wrapped" + ); + } + + /// B7's right bound is `widest − viewport`, **saturating at zero**. + /// + /// The row that discriminates: scroll far past the end and assert + /// the **final display column is still visible**. Clamping at the + /// widest line's FULL width would let the origin pass every glyph + /// and leave the viewport blank, which a "the origin stopped + /// somewhere" assertion cannot see. + /// + /// *Mutation: clamp at the widest line's full width → this row.* + #[test] + fn b7_the_right_bound_keeps_the_final_display_column_visible() { + use crossterm::event::MouseEventKind; + let width = 400usize; + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(width)); + content.push(b'\n'); + let mut s = fresh_with(&content); + paint_once(&s, term_size_24x80()); + + // Far more notches than the bound can absorb. + for _ in 0..500 { + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + } + + let window = s.core.borrow(); + let window = window.active_window(); + let viewport = window.last_content_cols; + assert!( + viewport > 0, + "fixture: the window must have content columns" + ); + assert!( + u64::from(window.view_left) + u64::from(viewport) >= width as u64, + "the final display column must still be reachable: origin {} + \ + viewport {viewport} against a widest line of {width}", + window.view_left + ); + assert!( + u64::from(window.view_left) < width as u64, + "and the origin must not pass every glyph: {} against {width}", + window.view_left + ); + } + + /// B7's wrap clause — **wrap pins the origin to zero**, and a + /// horizontal notch under wrap moves nothing. + /// + /// A wrapped line has nothing past the right edge, so an origin + /// there would scroll a buffer sideways that has no sideways. + /// + /// *Mutation: drop the wrap guard in `scroll_window_columns` → this + /// row, and only this row.* + #[test] + fn b7_wrap_pins_the_origin_to_zero_and_a_notch_moves_nothing() { + use crossterm::event::MouseEventKind; + let mut content = b"short\n".to_vec(); + content.extend_from_slice(&b"w".repeat(400)); + content.push(b'\n'); + let mut s = fresh_with(&content); + paint_once(&s, term_size_24x80()); + // Scroll sideways first, so the row can see the pin rather than + // an origin that merely never left zero. + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + assert!( + s.core.borrow().active_window().view_left > 0, + "setup: the origin must be off zero before wrap is applied" + ); + { + let mut core = s.core.borrow_mut(); + let id = core.active_window_id(); + if let Some(window) = core.windows.get_mut(&id) { + window.last_wrap = crate::view::WrapMode::Wrap; + } + } + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::ScrollRight, 5, 5), + term_size_24x80(), + ); + + assert_eq!( + s.core.borrow().active_window().view_left, + 0, + "wrap pins the origin to zero; a notch under wrap moves nothing" + ); + } + /// Mouse-wheel scrolls advance `view_top` and drag the cursor /// along by the same delta so it keeps its relative position in /// the viewport. Without the cursor-shift, the renderer's