diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 06b7a27..d7a27c7 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -8377,6 +8377,17 @@ impl State { /// budget rows and is recorded rather than pre-optimised: a cache /// needs an invalidation key, and the wrong key is a worse defect /// than a measurable scan. + /// + /// **Which "display line" this measures is a boundary B3's witness + /// must settle.** This counts SOURCE-TEXT display columns — tab + /// stops and Unicode width — and therefore excludes rendered + /// projections such as inline adornments and math substitutions, + /// which can occupy a different width on screen than the bytes they + /// stand for. That is consistent with the TUI-derived column rule + /// the two frontends share, and it is a choice, not an oversight: + /// "widest display line" is readable the other way. Recorded here + /// so the witness states which meaning governs rather than + /// discovering it. fn widest_display_columns(&self) -> u32 { let widest = self .current_text diff --git a/src/daemon.rs b/src/daemon.rs index 4394d99..7a1c0f6 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -7673,6 +7673,70 @@ mod tests { /// P1 — a press on the band's MODE LINE begins nothing. /// + /// GUI Stage 1b B2 — a horizontal panel notch reaches the daemon's + /// window-targeted `view_left` path and MOVES it. + /// + /// This arm used to be claimed and dropped, which is the + /// "frontend emits, receiver discards" shape §2a named. The row + /// asserts the **effect**, not the emission: `view_left` before and + /// after. + #[test] + fn b2_a_horizontal_panel_notch_moves_the_side_windows_view_left() { + let fid = FrontendId(791); + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + // A CONTENT cell, not chrome: row 0 is inside the grid. + let (buffer_id, cell) = { + let core = editor.core.borrow(); + ( + core.windows[&panel].buffer_id, + pmacs_protocol::CellCoord::new(0, 0), + ) + }; + // **The discriminating setup.** B7's bound is + // `widest − viewport`, so a panel whose content fits has a + // maximum origin of zero and the move is absorbed by the + // clamp — correct behaviour that would read here as a dropped + // event. The row needs content wider than the viewport. + foreign_edit(&editor, buffer_id, "x".repeat(400).as_bytes()); + let before = editor + .core + .borrow() + .windows + .get(&panel) + .map_or(0, |w| w.view_left); + + let generation = live_generation(PanelArm::Mapped, &editor, &mut states, fid); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut states, + &mut render, + arm_pointer( + PanelArm::Mapped, + fid, + epochs, + buffer_id, + generation, + cell, + pmacs_protocol::MouseKind::ScrollRight, + ), + ); + + let after = editor + .core + .borrow() + .windows + .get(&panel) + .map_or(0, |w| w.view_left); + assert_ne!( + after, before, + "a horizontal panel notch must move the side window's origin, \ + not be claimed and dropped" + ); + } + /// The merge made this arm the latch, because `Consumed` and /// `Accepted` were the same `true`. The row reads the cursor as well /// as the latch: a chrome press must not move point either. diff --git a/src/display_width.rs b/src/display_width.rs index c80670e..89386de 100644 --- a/src/display_width.rs +++ b/src/display_width.rs @@ -23,6 +23,24 @@ pub fn advance_char(column: u32, ch: char) -> u32 { column.saturating_add(width) } +/// Widest line in `text`, in display columns. +/// +/// GUI Stage 1b B7's upper-bound input, shared by both frontends so +/// they cannot disagree about where the right bound is — the same +/// reason `scroll::follow_left` is shared. +/// +/// **This measures SOURCE-TEXT display columns**: tab stops and Unicode +/// width. Rendered projections — inline adornments, math substitutions +/// — can occupy a different width on screen and are deliberately not +/// counted, matching the column rule the rest of this module states. +#[must_use] +pub fn widest_line_columns(text: &str) -> u32 { + text.split('\n') + .map(|line| line.chars().fold(0, advance_char)) + .max() + .unwrap_or(0) +} + /// Display width of the valid UTF-8 prefix of `bytes`. /// /// Invalid input is conservatively truncated at the first invalid byte. This diff --git a/src/editor.rs b/src/editor.rs index 7be75ce..53c744e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -3356,23 +3356,81 @@ impl EditorState { } self.open_context_menu(side, coord.row, coord.col, (coord.row, coord.col)); } - // Claimed and dropped, for two different reasons kept in one - // arm because their bodies are identical: horizontal panel - // scrolling belongs to GUI arc Stage 1b's B-rows rather than - // parent 48, bare `Move` neither focuses nor claims, and the - // remaining buttons have no panel semantics at all. - PKind::ScrollLeft - | PKind::ScrollRight - | PKind::Move - | PKind::Down(_) - | PKind::Up(_) - | PKind::Drag(_) => {} + // GUI Stage 1b B2 — the horizontal leg, which this arm used + // to claim and drop. One notch is `SCROLL_COLUMNS`; the + // frontend banks fractions and sends whole notches, so the + // step is applied here exactly once. + PKind::ScrollLeft => { + self.scroll_window_columns(side, -SCROLL_COLUMNS); + } + PKind::ScrollRight => { + self.scroll_window_columns(side, SCROLL_COLUMNS); + } + // Bare `Move` neither focuses nor claims, and the remaining + // buttons have no panel semantics at all. + PKind::Move | PKind::Down(_) | PKind::Up(_) | PKind::Drag(_) => {} } // Only a left press can anchor, and it returns `true` above. // Every other kind reaching here handled something already live. false } + /// GUI Stage 1b B2/B3/B7 — move one window's horizontal origin by + /// `columns`, the daemon-side effect a panel-document horizontal + /// wheel reaches. + /// + /// This closes the leg §2a named: `ScrollLeft`/`ScrollRight` were + /// **claimed and dropped** here, which is the "frontend emits, + /// receiver discards" shape the panel-replay lane was opened to fix + /// and that B1–B3 inherited for the horizontal axis. + /// + /// The bound is B7's, stated exactly — `0 ..= widest − viewport`, + /// **saturating at zero** — so the final display column stays + /// visible; clamping at the widest line's full width would let the + /// origin pass every glyph and blank the viewport. **Wrap pins the + /// origin to zero**, matching `horizontal_follow`. + /// + /// Returns whether the origin actually moved, which is clause 2's + /// "effective move". + fn scroll_window_columns(&mut self, win_id: WindowId, columns: i32) -> bool { + let mut core = self.core.borrow_mut(); + let Some(window) = core.windows.get(&win_id) else { + return false; + }; + if window.last_wrap == crate::view::WrapMode::Wrap { + if let Some(window) = core.windows.get_mut(&win_id) { + window.view_left = 0; + } + return false; + } + let buffer_id = window.buffer_id; + let viewport_cols = window.last_content_cols; + let old_left = window.view_left; + let registry = core.registry.clone(); + let widest = { + let reg = registry.borrow(); + let Ok(buf) = reg.get(buffer_id) else { + return false; + }; + let len = buf.len(); + let mut bytes = vec![0u8; len as usize]; + buf.snapshot_rope().slice(0, len, &mut bytes); + crate::display_width::widest_line_columns(&String::from_utf8_lossy(&bytes)) + }; + let max_left = widest.saturating_sub(viewport_cols); + let next = i64::from(old_left) + .saturating_add(i64::from(columns)) + .clamp(0, i64::from(max_left)); + let next = u32::try_from(next).unwrap_or(0); + if next == old_left { + return false; + } + if let Some(window) = core.windows.get_mut(&win_id) { + window.view_left = next; + } + true + } + /// Byte under a panel cell, resolved against the SIDE window's own /// `view_top` and fold map. /// @@ -4654,6 +4712,11 @@ impl EditorState { /// readline / Emacs default and is what most terminal users expect. const SCROLL_LINES: i32 = 3; +/// Columns one horizontal wheel notch moves a window's origin — B7's +/// "three columns per wheel tick", the horizontal twin of +/// [`SCROLL_LINES`]. +const SCROLL_COLUMNS: i32 = 3; + /// Gutter marker drawn on a collapsed region's head row (Arc 6 Stage 2, /// Q#FD20). Occupies the gutter's leading pad cell — the same cell the /// diagnostic sign uses — so it adds no column and changes no width; it