feat(gui-1b): B2's horizontal leg --- the panel origin actually moves

`PKind::ScrollLeft | PKind::ScrollRight` were CLAIMED AND DROPPED in the
panel replay, with a comment assigning the axis to Stage 1b. That is the
"frontend emits, receiver discards" shape the panel-replay lane was
opened to fix, inherited for the horizontal axis. This closes it.

`scroll_window_columns` moves the side window's `view_left` by B7's
bound, 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`. It returns
whether the origin actually moved, which is lifetime clause 2's
"effective move".

The widest-line rule is SHARED. `display_width::widest_line_columns`
lives beside the module's other column helpers and both frontends use
it, for the same reason `scroll::follow_left` is shared: two frontends
that compute the right bound differently disagree about where the
document ends.

B2's row asserts the EFFECT --- `view_left` before and after --- not an
emission, and it carries the discriminating setup the bound requires: a
panel whose content fits has a maximum origin of zero, so the move is
absorbed by the clamp and a dropped event reads identical to correct
behaviour. The fixture gets a line wider than the viewport. Mutation:
restore the claimed-and-dropped arm, and the row fires.

Two mistakes of mine in this commit's history, both caught before it:

- I reverted a mutation with `git checkout -- src/editor.rs` on a file
  holding UNCOMMITTED work, and destroyed the whole B2 implementation.
  Re-applied, and the mutation check redone against a file snapshot ---
  the discipline I had used earlier in the CRDT lane and dropped here.
- Inserting the new test above an existing one STOLE ITS `#[test]` and
  its doc comment, so `r4_p1_a_chrome_press_neither_arms_nor_moves_point`
  silently stopped being a test. Clippy's "never used" caught it. Both
  are restored, and the suite count confirms 1994 tests rather than
  1993.
This commit is contained in:
Levi Neuwirth 2026-09-01 10:07:23 +02:00
parent 0f29530dd8
commit d3d720ba8a
No known key found for this signature in database
4 changed files with 167 additions and 11 deletions

View File

@ -8377,6 +8377,17 @@ impl State {
/// budget rows and is recorded rather than pre-optimised: a cache /// budget rows and is recorded rather than pre-optimised: a cache
/// needs an invalidation key, and the wrong key is a worse defect /// needs an invalidation key, and the wrong key is a worse defect
/// than a measurable scan. /// 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 { fn widest_display_columns(&self) -> u32 {
let widest = self let widest = self
.current_text .current_text

View File

@ -7673,6 +7673,70 @@ mod tests {
/// P1 — a press on the band's MODE LINE begins nothing. /// 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 /// The merge made this arm the latch, because `Consumed` and
/// `Accepted` were the same `true`. The row reads the cursor as well /// `Accepted` were the same `true`. The row reads the cursor as well
/// as the latch: a chrome press must not move point either. /// as the latch: a chrome press must not move point either.

View File

@ -23,6 +23,24 @@ pub fn advance_char(column: u32, ch: char) -> u32 {
column.saturating_add(width) 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`. /// Display width of the valid UTF-8 prefix of `bytes`.
/// ///
/// Invalid input is conservatively truncated at the first invalid byte. This /// Invalid input is conservatively truncated at the first invalid byte. This

View File

@ -3356,23 +3356,81 @@ impl EditorState {
} }
self.open_context_menu(side, coord.row, coord.col, (coord.row, coord.col)); self.open_context_menu(side, coord.row, coord.col, (coord.row, coord.col));
} }
// Claimed and dropped, for two different reasons kept in one // GUI Stage 1b B2 — the horizontal leg, which this arm used
// arm because their bodies are identical: horizontal panel // to claim and drop. One notch is `SCROLL_COLUMNS`; the
// scrolling belongs to GUI arc Stage 1b's B-rows rather than // frontend banks fractions and sends whole notches, so the
// parent 48, bare `Move` neither focuses nor claims, and the // step is applied here exactly once.
// remaining buttons have no panel semantics at all. PKind::ScrollLeft => {
PKind::ScrollLeft self.scroll_window_columns(side, -SCROLL_COLUMNS);
| PKind::ScrollRight }
| PKind::Move PKind::ScrollRight => {
| PKind::Down(_) self.scroll_window_columns(side, SCROLL_COLUMNS);
| PKind::Up(_) }
| PKind::Drag(_) => {} // 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. // Only a left press can anchor, and it returns `true` above.
// Every other kind reaching here handled something already live. // Every other kind reaching here handled something already live.
false 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 /// Byte under a panel cell, resolved against the SIDE window's own
/// `view_top` and fold map. /// `view_top` and fold map.
/// ///
@ -4654,6 +4712,11 @@ impl EditorState {
/// readline / Emacs default and is what most terminal users expect. /// readline / Emacs default and is what most terminal users expect.
const SCROLL_LINES: i32 = 3; 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, /// 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 /// 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 /// diagnostic sign uses — so it adds no column and changes no width; it