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:
parent
0f29530dd8
commit
d3d720ba8a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue