feat(gui-1b): B1's per-target fractional wheel residual

The producer 1b owes. `apply_wheel` used to round to whole lines and
return on zero BEFORE consulting the pointer, so every sub-tick delta
bound for the panel or the terminal was discarded by a decision taken
upstream of routing. §2a CORRECTION 5 measured that ordering; this
inverts it.

The pipeline is now: classify the target, bank the fractional delta
against THAT target's accumulator, route only the whole ticks that fall
out. `WheelTarget` exists because `PointerSurface` cannot name what B1
needs --- it resolves panel geometry only and collapses the document,
the terminal, the minimap and the chrome into one `Elsewhere`, three of
which B1 and B6 must keep apart.

Residual owners follow §2a's enumeration exactly: per panel, per
terminal, the minimap's own, the document's --- and chrome shares the
document's deliberately, so a gesture that strays onto the gutter does
not lose its banked motion. Panel divider and background bank NOWHERE
and clear the panel banks: a residual they could share with a cell would
let motion over an inert strip complete a tick the moment the pointer
entered a live one, which is the surface-switch jump B1 exists to
forbid.

Nine rows, including R1-R5's identity discriminators and §2a's required
crossing witness. `trunc` rather than `round`, so a half-tick that was
never delivered is not spent.

Two things this commit does not do, both recorded rather than stubbed:

- IDENTITY'S SECOND HALF --- disposal --- is owed. A residual keyed to a
  surface that goes away must go with it, and this frontend does not
  currently track "that buffer is gone". A helper nothing calls would
  read as a contract met, so the method is absent and the gap is
  documented on the type.
- `scroll_by_columns` and the manual-authority latch land here as B3/B7
  and Q#S1-11's foundation, but their witnesses (L1-L8) do not. They
  come with the horizontal wire path.

One behaviour regression caught by an existing row and fixed: a wheel
before the first cursor motion has no pointer position, and the first
draft dropped it. It targets the document, as it did before 1b.
This commit is contained in:
Levi Neuwirth 2026-08-31 22:54:44 +02:00
parent 204998351b
commit 2d19713b0e
No known key found for this signature in database
1 changed files with 495 additions and 57 deletions

View File

@ -1869,6 +1869,15 @@ struct State {
/// selection, until release. Never sends `Pointer` events —
/// the viewport is frontend-owned.
minimap_scrub_active: bool,
/// GUI Stage 1b, Q#S1-11 clause 2: a horizontal wheel that
/// EFFECTIVELY moved the origin makes it authoritative, and the
/// caret follow leaves it alone until the cursor position actually
/// changes. A move fully absorbed by the clamp arms nothing.
manual_left_authority: bool,
/// GUI Stage 1b B1: per-target, per-axis fractional wheel residual.
/// Sub-tick deltas are banked here instead of being rounded away
/// before routing knows where they were going.
wheel_residuals: WheelResiduals,
/// Q#M7 — `Some(±1)` while a drag sits in the top/bottom edge
/// band; `about_to_wait` ticks the viewport one line toward the
/// pointer per [`EDGE_SCROLL_TICK`] and re-runs the drag
@ -2109,6 +2118,122 @@ enum PointerSurface {
Elsewhere,
}
/// The six wheel targets GUI Stage 1b's B1 owns a residual for.
///
/// **`PointerSurface` cannot name these**, which is why this exists
/// (framing §2a CORRECTION 5): that classifier resolves panel geometry
/// only, and collapses the document, the terminal, the minimap and the
/// chrome into a single `Elsewhere` — three of which B1 and B6 must
/// keep apart.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WheelTarget {
/// A cell inside the band. Residual **per panel**, keyed by the
/// panel's buffer.
PanelCell { buffer: BufferId, coord: CellCoord },
/// The divider or the band's background. **The band owns the
/// pixel**, so both axes are consumed — and nothing is banked.
PanelChrome,
/// The terminal clip. Residual **per terminal**, keyed by buffer.
Terminal { buffer: BufferId, coord: CellCoord },
/// The minimap band. **Its own** residual (B6).
Minimap,
/// Document text. Shares its residual with [`WheelTarget::Chrome`].
Document,
/// Anything else outside the band: gutter, margins, status chrome.
/// **Shares the document's residual, deliberately** — a wheel that
/// strays onto the gutter mid-gesture must not lose the motion.
Chrome,
}
/// Which accumulator a [`WheelTarget`] banks into.
///
/// Two targets map to `Document` on purpose, and one target maps to
/// nothing at all: panel chrome consumes without banking, because a
/// residual it could share with a cell would let motion over an inert
/// strip complete a tick the moment the pointer entered a live one —
/// **a surface-switch jump manufactured by the accumulator itself**,
/// which is exactly what B1 exists to forbid.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
enum ResidualOwner {
/// R2's identity: two panels are two owners.
Panel(BufferId),
/// R3's identity: two terminals are two owners.
Terminal(BufferId),
/// R5: independent of the document's, though it moves the document.
Minimap,
/// R4: the document's, shared with chrome.
Document,
}
impl WheelTarget {
/// The accumulator this target banks into, or `None` when it banks
/// nowhere.
fn residual_owner(self) -> Option<ResidualOwner> {
match self {
Self::PanelCell { buffer, .. } => Some(ResidualOwner::Panel(buffer)),
Self::Terminal { buffer, .. } => Some(ResidualOwner::Terminal(buffer)),
Self::Minimap => Some(ResidualOwner::Minimap),
Self::Document | Self::Chrome => Some(ResidualOwner::Document),
Self::PanelChrome => None,
}
}
}
/// B1's per-owner, per-axis fractional wheel residual.
///
/// **The producer 1b adds.** Before this, `apply_wheel` rounded to
/// whole lines and returned on zero *before* it knew where the delta
/// was going, so every sub-tick motion bound for the panel or the
/// terminal was discarded by a decision taken upstream of routing.
///
/// Identity has two halves. The first is the key: a panel residual is
/// keyed to *that* panel, so a gesture that crosses from panel A to
/// panel B does not spend A's bank on B (R2, and R3 for terminals).
/// The second is **disposal** — a residual keyed to a surface that goes
/// away must go away with it, or it is spent on whatever later takes
/// that identity.
///
/// **Disposal is NOT implemented yet, and is owed before this slice
/// ships.** It needs a signal for "this panel/terminal buffer is gone",
/// which this frontend does not currently track, and it needs its own
/// witness. It is recorded here rather than stubbed, because a helper
/// nothing calls reads as a contract met.
#[derive(Debug, Default)]
struct WheelResiduals {
/// `(owner) -> (x, y)` in fractional ticks, each in `(-1.0, 1.0)`.
banks: HashMap<ResidualOwner, (f32, f32)>,
}
impl WheelResiduals {
/// Bank `(dx, dy)` fractional ticks for `owner` and return the whole
/// ticks that fall out, keeping the remainder.
///
/// `trunc`, not `round`: rounding would spend a half-tick that was
/// never delivered and leave a negative remainder behind.
fn accumulate(&mut self, owner: ResidualOwner, dx: f32, dy: f32) -> (i64, i64) {
let bank = self.banks.entry(owner).or_insert((0.0, 0.0));
bank.0 += dx;
bank.1 += dy;
let ticks_x = bank.0.trunc();
let ticks_y = bank.1.trunc();
bank.0 -= ticks_x;
bank.1 -= ticks_y;
(ticks_x as i64, ticks_y as i64)
}
/// Drop every panel bank. Panel chrome consumes both axes and must
/// leave nothing that could combine with cell input later.
fn clear_panels(&mut self) {
self.banks
.retain(|owner, _| !matches!(owner, ResidualOwner::Panel(_)));
}
#[cfg(test)]
fn bank_of(&self, owner: ResidualOwner) -> Option<(f32, f32)> {
self.banks.get(&owner).copied()
}
}
/// A live divider drag (Q#BP15a, parent acceptance 47).
#[derive(Clone, Copy, Debug)]
struct PanelDrag {
@ -3242,73 +3367,148 @@ impl App {
}
}
/// Resolve a pointer position to the wheel target that owns it.
///
/// **This runs BEFORE quantization**, which is the whole point:
/// §2a CORRECTION 5 measured that rounding and the `lines == 0`
/// return happened upstream of routing, so a sub-tick delta bound
/// for the panel or the terminal was discarded before anything knew
/// where it was going.
///
/// `Chrome` is the fallthrough rather than `Document` so the
/// enumeration stays total: every pixel outside the band is one of
/// terminal, minimap, document or chrome, and the first three are
/// each tested explicitly.
fn classify_wheel_target(&mut self, x: f64, y: f64) -> WheelTarget {
match self
.state
.as_ref()
.map(|s| s.classify_pointer_surface(x as f32, y as f32))
{
Some(PointerSurface::PanelCell(_)) => {
if let Some((_, _, buffer, coord)) = self.panel_pointer_hit(x, y) {
return WheelTarget::PanelCell { buffer, coord };
}
// The band owns the pixel even when it maps to no cell.
return WheelTarget::PanelChrome;
}
Some(PointerSurface::PanelDivider | PointerSurface::PanelBackground) => {
return WheelTarget::PanelChrome;
}
Some(PointerSurface::Elsewhere) | None => {}
}
if let Some((buffer, coord)) = self.terminal_pointer_hit(x, y) {
return WheelTarget::Terminal { buffer, coord };
}
let Some(state) = self.state.as_mut() else {
return WheelTarget::Chrome;
};
if state.in_minimap_band(x, y) {
return WheelTarget::Minimap;
}
if state.hit_test_source_byte(x, y).is_some() {
return WheelTarget::Document;
}
WheelTarget::Chrome
}
/// Perform [`PointerRoute::Wheel`].
///
/// **GUI Stage 1b B1 reorders this pipeline.** It used to round to
/// whole lines and return on zero *before* consulting the pointer,
/// so a sub-tick delta bound for the panel or the terminal was
/// discarded by a decision taken upstream of routing. The order is
/// now: classify the target, bank the fractional delta against
/// **that target's** accumulator, and route only the whole ticks
/// that fall out.
#[allow(clippy::too_many_lines)] // one linear gesture pipeline; splitting hides the order.
fn apply_wheel(&mut self, delta: MouseScrollDelta) {
let Some(state) = self.state.as_mut() else {
let Some(state) = self.state.as_ref() else {
return;
};
// Wheel scroll is local-only: the GPU owns the
// viewport. Positive winit y = scroll up = smaller
// scroll_top.
let lines = match delta {
winit::event::MouseScrollDelta::LineDelta(_, y) => {
(-y * WHEEL_LINES_PER_TICK).round() as i64
// Fractional deltas, in the units each axis scrolls by: lines
// for y, columns for x. Positive winit y = scroll up = smaller
// scroll_top, hence the negation.
let line_height = state.fm.code_line_height();
let column_width = state.mono_advance();
let (dx, dy) = match delta {
winit::event::MouseScrollDelta::LineDelta(x, y) => {
(x * WHEEL_LINES_PER_TICK, -y * WHEEL_LINES_PER_TICK)
}
winit::event::MouseScrollDelta::PixelDelta(p) => {
(-(p.y as f32) / state.fm.code_line_height()).round() as i64
}
};
if lines == 0 {
return;
}
// Bottom panel Stage 2B-3 — a wheel tick over the band scrolls
// the PANEL's window, which is daemon-side state, so it
// crosses the wire instead of moving this frontend's local
// document `scroll_top`. Falling through would scroll the
// document while the pointer is inside the panel.
if let Some((x, y)) = state.pointer_pos
&& matches!(
state.classify_pointer_surface(x as f32, y as f32),
PointerSurface::PanelCell(_)
)
{
let mods = translate_mods(self.modifiers);
let kind = if lines < 0 {
ProtocolMouseKind::ScrollUp
} else {
ProtocolMouseKind::ScrollDown
};
self.send_panel_pointer_at(x, y, kind, mods);
return;
}
let Some(state) = self.state.as_mut() else {
return;
};
// Vterm Stage 3 — the terminal's scrollback belongs to
// the daemon-side view, not to this frontend's local
// scroll, so a wheel tick crosses the wire as a
// terminal gesture instead of moving `scroll_top`.
if state.terminal.is_some() {
if let Some((x, y)) = state.pointer_pos
&& let Some((buffer_id, coord)) = self.terminal_pointer_hit(x, y)
{
let mods = translate_mods(self.modifiers);
let kind = if lines < 0 {
ProtocolMouseKind::ScrollUp
winit::event::MouseScrollDelta::PixelDelta(p) => (
if column_width > 0.0 {
p.x as f32 / column_width
} else {
ProtocolMouseKind::ScrollDown
};
self.send_terminal_pointer(buffer_id, coord, kind, mods);
0.0
},
if line_height > 0.0 {
-(p.y as f32) / line_height
} else {
0.0
},
),
};
// No pointer position yet — a wheel before the first cursor
// motion. The document is the target, which is what this path
// did before 1b; dropping the input instead would be a
// regression B1 never asked for.
let (target, x, y) = match state.pointer_pos {
Some((x, y)) => (self.classify_wheel_target(x, y), x, y),
None => (WheelTarget::Document, 0.0, 0.0),
};
// The band owns the pixel: consume both axes and bank nothing.
// Panel banks are cleared so this motion can never combine with
// cell input later, which is the surface-switch jump B1 forbids.
if matches!(target, WheelTarget::PanelChrome) {
if let Some(state) = self.state.as_mut() {
state.wheel_residuals.clear_panels();
}
return;
}
let vp = state.scroll_by_lines(lines);
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: wheel send_viewport failed: {e}");
let Some(owner) = target.residual_owner() else {
return;
};
let Some(state) = self.state.as_mut() else {
return;
};
let (ticks_x, ticks_y) = state.wheel_residuals.accumulate(owner, dx, dy);
if ticks_x == 0 && ticks_y == 0 {
return;
}
let mods = translate_mods(self.modifiers);
match target {
WheelTarget::PanelChrome => unreachable!("consumed above"),
WheelTarget::PanelCell { .. } => {
for kind in wheel_kinds(ticks_x, ticks_y) {
self.send_panel_pointer_at(x, y, kind, mods);
}
}
WheelTarget::Terminal { buffer, coord } => {
for kind in wheel_kinds(ticks_x, ticks_y) {
self.send_terminal_pointer(buffer, coord, kind, mods);
}
}
WheelTarget::Minimap | WheelTarget::Document | WheelTarget::Chrome => {
if ticks_y != 0 {
let vp = self
.state
.as_mut()
.and_then(|state| state.scroll_by_lines(ticks_y));
if let Some(vp) = vp
&& let Some(client) = self.attach_client.as_ref()
&& let Err(e) =
client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
{
eprintln!("pmacs-gpu: wheel send_viewport failed: {e}");
}
}
if ticks_x != 0
&& let Some(state) = self.state.as_mut()
{
state.scroll_by_columns(ticks_x);
}
}
}
}
@ -5059,6 +5259,32 @@ const DOUBLE_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_milli
/// Wheel lines scrolled per `MouseScrollDelta::LineDelta` unit.
const WHEEL_LINES_PER_TICK: f32 = 3.0;
/// The wire scroll kinds for a banked `(x, y)` tick count, in order.
///
/// One event per whole tick: a single wheel notch that banks two ticks
/// must move the receiver twice, and a receiver that coalesces is
/// making its own decision rather than being handed a rounded one.
fn wheel_kinds(ticks_x: i64, ticks_y: i64) -> Vec<ProtocolMouseKind> {
let mut kinds = Vec::new();
let vertical = if ticks_y < 0 {
ProtocolMouseKind::ScrollUp
} else {
ProtocolMouseKind::ScrollDown
};
for _ in 0..ticks_y.unsigned_abs() {
kinds.push(vertical);
}
let horizontal = if ticks_x < 0 {
ProtocolMouseKind::ScrollLeft
} else {
ProtocolMouseKind::ScrollRight
};
for _ in 0..ticks_x.unsigned_abs() {
kinds.push(horizontal);
}
kinds
}
/// Byte range an optimistic Backspace/Delete removes at `cursor`, or
/// `None` when it can't be predicted locally: buffer edge (the
/// daemon's behavior is a no-op there anyway), a modifier variant
@ -5637,6 +5863,8 @@ impl State {
last_pointer_sent_byte: None,
last_pointer_down: None,
minimap_scrub_active: false,
manual_left_authority: false,
wheel_residuals: WheelResiduals::default(),
edge_scroll_dir: None,
edge_scroll_last: None,
styled_redraw_deadline: None,
@ -8090,6 +8318,66 @@ impl State {
self.request_redraw();
}
/// Widest display line, in columns — B7's upper-bound input.
///
/// Scans the laid-out lines; `line_layout` caches, so a repeat scan
/// on an unchanged buffer re-reads the cache rather than reshaping.
/// **Cost is proportional to the buffer's line count**, which is a
/// real consideration on a per-tick path and is called out for
/// review rather than optimised speculatively.
fn widest_display_columns(&mut self) -> u32 {
let advance = self.mono_advance();
if advance <= 0.0 {
return 0;
}
let mut widest = 0.0f32;
for line_i in 0..self.buffer.lines.len() {
if let Some(layout) = self.buffer.line_layout(&mut self.font_system, line_i) {
for layout_line in layout {
widest = widest.max(layout_line.w);
}
}
}
(widest / advance).ceil().max(0.0) as u32
}
/// GUI Stage 1b B3/B7: move the horizontal origin by whole columns.
///
/// The bound is B7's, stated exactly: `0 ..= widest − viewport`,
/// **saturating at zero** for buffers narrower than the viewport.
/// Clamping at the widest line's *full* width would let the origin
/// pass every glyph and leave the viewport blank.
///
/// **Wrap pins the origin to zero** and clears manual authority
/// (lifetime clause 5): a wrapped buffer has nothing past the right
/// edge, so an origin — and a latch that would defend it — must not
/// survive.
///
/// Returns whether the origin actually moved. Clause 2's "effective
/// move": one fully absorbed by the clamp arms nothing.
fn scroll_by_columns(&mut self, columns: i64) -> bool {
if self.buffer.wrap() != Wrap::None {
self.code_scroll_left = 0.0;
self.manual_left_authority = false;
return false;
}
let advance = self.mono_advance();
let width = self.text_bounds_right() as f32 - self.text_left();
if advance <= 0.0 || width <= 0.0 {
return false;
}
let viewport_cols = (width / advance).floor().max(0.0) as u32;
let max_left = self.widest_display_columns().saturating_sub(viewport_cols);
let current = (self.code_scroll_left / advance).round().max(0.0) as i64;
let next = (current + columns).clamp(0, i64::from(max_left));
if next == current {
return false;
}
self.code_scroll_left = next as f32 * advance;
self.manual_left_authority = true;
true
}
/// Move `code_scroll_left` so the caret's column is on screen
/// (Stage 5, framing Q#G2 — automatic only).
///
@ -14095,6 +14383,156 @@ fn decoration_kind_to_bg_color(kind: DecorationKind) -> Option<[f32; 4]> {
#[cfg(test)]
mod tests {
use super::{BufferId, ResidualOwner, WheelResiduals, WheelTarget, wheel_kinds};
use pmacs_protocol::MouseKind as ProtocolMouseKind;
/// Distinct ids. `BufferId::next` is the only constructor — the
/// inner field is private on purpose — so identity comes from
/// allocation order rather than a literal.
fn buf(_n: u64) -> BufferId {
BufferId::next()
}
/// B1/R1 — the producer itself: sub-tick deltas are banked, not
/// rounded away, and they reach a tick together.
///
/// This is the row #243 cannot satisfy. Its receiver only ever saw
/// whole ticks, so a producer that discards every fraction is
/// invisible to it.
#[test]
fn r1_sub_tick_deltas_bank_and_then_spend_exactly_one_tick() {
let mut r = WheelResiduals::default();
let owner = ResidualOwner::Document;
assert_eq!(
r.accumulate(owner, 0.0, 0.4),
(0, 0),
"first sub-tick moves nothing"
);
assert_eq!(
r.accumulate(owner, 0.0, 0.4),
(0, 0),
"still short of a tick"
);
assert_eq!(
r.accumulate(owner, 0.0, 0.4),
(0, 1),
"1.2 banked spends exactly one tick, not two"
);
let (_, y) = r.bank_of(owner).expect("bank survives");
assert!(
(y - 0.2).abs() < 1e-5,
"the remainder is kept, not dropped: {y}"
);
}
/// B1 — the two axes are independent accumulators, not one.
#[test]
fn b1_axes_bank_independently() {
let mut r = WheelResiduals::default();
let owner = ResidualOwner::Document;
assert_eq!(r.accumulate(owner, 0.6, 0.0), (0, 0));
assert_eq!(r.accumulate(owner, 0.0, 0.6), (0, 0));
assert_eq!(
r.accumulate(owner, 0.6, 0.6),
(1, 1),
"each axis reaches its own tick on its own schedule"
);
}
/// R2 — panel A's bank is not spent on panel B.
#[test]
fn r2_panel_identity_does_not_leak_across_panels() {
let mut r = WheelResiduals::default();
let a = ResidualOwner::Panel(buf(1));
let b = ResidualOwner::Panel(buf(2));
assert_eq!(r.accumulate(a, 0.0, 0.9), (0, 0));
assert_eq!(
r.accumulate(b, 0.0, 0.2),
(0, 0),
"panel B starts from zero; sharing would spend A's 0.9 here"
);
}
/// R3 — the same, across two terminals.
#[test]
fn r3_terminal_identity_does_not_leak_across_terminals() {
let mut r = WheelResiduals::default();
let a = ResidualOwner::Terminal(buf(7));
let b = ResidualOwner::Terminal(buf(8));
assert_eq!(r.accumulate(a, 0.0, 0.9), (0, 0));
assert_eq!(r.accumulate(b, 0.0, 0.2), (0, 0));
}
/// R4 — document and chrome SHARE, deliberately: a gesture that
/// strays onto the gutter must not lose its banked motion.
#[test]
fn r4_document_and_chrome_share_one_bank() {
assert_eq!(
WheelTarget::Document.residual_owner(),
WheelTarget::Chrome.residual_owner(),
"chrome banks into the document's accumulator"
);
let mut r = WheelResiduals::default();
let owner = WheelTarget::Document
.residual_owner()
.expect("document banks");
assert_eq!(r.accumulate(owner, 0.0, 0.7), (0, 0));
assert_eq!(
r.accumulate(WheelTarget::Chrome.residual_owner().unwrap(), 0.0, 0.7),
(0, 1),
"the strayed half completes the tick instead of being lost"
);
}
/// R5 — the minimap moves the document viewport but banks
/// independently of it.
#[test]
fn r5_minimap_bank_is_independent_of_the_documents() {
let mut r = WheelResiduals::default();
assert_eq!(r.accumulate(ResidualOwner::Minimap, 0.0, 0.9), (0, 0));
assert_eq!(
r.accumulate(ResidualOwner::Document, 0.0, 0.2),
(0, 0),
"the document starts from zero despite the minimap's 0.9"
);
}
/// The crossing witness §2a requires: partial motion over the band's
/// chrome, then partial motion over a cell, **must not reach a
/// tick**. Panel chrome banks nowhere and clears what a cell banked.
#[test]
fn panel_chrome_banks_nothing_and_cannot_combine_with_cell_input() {
assert_eq!(
WheelTarget::PanelChrome.residual_owner(),
None,
"the band owns the pixel and banks nothing"
);
let mut r = WheelResiduals::default();
let cell = ResidualOwner::Panel(buf(3));
assert_eq!(r.accumulate(cell, 0.0, 0.9), (0, 0));
r.clear_panels();
assert_eq!(
r.accumulate(cell, 0.0, 0.2),
(0, 0),
"a gesture that scrolled nothing must not complete a tick on arrival"
);
}
/// One event per whole tick, in a stable order.
#[test]
fn wheel_kinds_emits_one_event_per_banked_tick() {
assert_eq!(wheel_kinds(0, 0), Vec::new());
assert_eq!(
wheel_kinds(0, 2),
vec![ProtocolMouseKind::ScrollDown, ProtocolMouseKind::ScrollDown]
);
assert_eq!(wheel_kinds(0, -1), vec![ProtocolMouseKind::ScrollUp]);
assert_eq!(
wheel_kinds(-1, 1),
vec![ProtocolMouseKind::ScrollDown, ProtocolMouseKind::ScrollLeft]
);
}
use super::*;
use pmacs_protocol::cell::Style;