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:
parent
204998351b
commit
2d19713b0e
|
|
@ -1869,6 +1869,15 @@ struct State {
|
||||||
/// selection, until release. Never sends `Pointer` events —
|
/// selection, until release. Never sends `Pointer` events —
|
||||||
/// the viewport is frontend-owned.
|
/// the viewport is frontend-owned.
|
||||||
minimap_scrub_active: bool,
|
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
|
/// Q#M7 — `Some(±1)` while a drag sits in the top/bottom edge
|
||||||
/// band; `about_to_wait` ticks the viewport one line toward the
|
/// band; `about_to_wait` ticks the viewport one line toward the
|
||||||
/// pointer per [`EDGE_SCROLL_TICK`] and re-runs the drag
|
/// pointer per [`EDGE_SCROLL_TICK`] and re-runs the drag
|
||||||
|
|
@ -2109,6 +2118,122 @@ enum PointerSurface {
|
||||||
Elsewhere,
|
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).
|
/// A live divider drag (Q#BP15a, parent acceptance 47).
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
struct PanelDrag {
|
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`].
|
/// 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.
|
#[allow(clippy::too_many_lines)] // one linear gesture pipeline; splitting hides the order.
|
||||||
fn apply_wheel(&mut self, delta: MouseScrollDelta) {
|
fn apply_wheel(&mut self, delta: MouseScrollDelta) {
|
||||||
let Some(state) = self.state.as_mut() else {
|
let Some(state) = self.state.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
// Wheel scroll is local-only: the GPU owns the
|
// Fractional deltas, in the units each axis scrolls by: lines
|
||||||
// viewport. Positive winit y = scroll up = smaller
|
// for y, columns for x. Positive winit y = scroll up = smaller
|
||||||
// scroll_top.
|
// scroll_top, hence the negation.
|
||||||
let lines = match delta {
|
let line_height = state.fm.code_line_height();
|
||||||
winit::event::MouseScrollDelta::LineDelta(_, y) => {
|
let column_width = state.mono_advance();
|
||||||
(-y * WHEEL_LINES_PER_TICK).round() as i64
|
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) => {
|
winit::event::MouseScrollDelta::PixelDelta(p) => (
|
||||||
(-(p.y as f32) / state.fm.code_line_height()).round() as i64
|
if column_width > 0.0 {
|
||||||
}
|
p.x as f32 / column_width
|
||||||
};
|
|
||||||
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
|
|
||||||
} else {
|
} else {
|
||||||
ProtocolMouseKind::ScrollDown
|
0.0
|
||||||
};
|
},
|
||||||
self.send_terminal_pointer(buffer_id, coord, kind, mods);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let vp = state.scroll_by_lines(lines);
|
let Some(owner) = target.residual_owner() else {
|
||||||
if let Some(vp) = vp
|
return;
|
||||||
&& let Some(client) = self.attach_client.as_ref()
|
};
|
||||||
&& let Err(e) = client.send_viewport(vp.buffer_id, vp.visible, vp.generation)
|
let Some(state) = self.state.as_mut() else {
|
||||||
{
|
return;
|
||||||
eprintln!("pmacs-gpu: wheel send_viewport failed: {e}");
|
};
|
||||||
|
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.
|
/// Wheel lines scrolled per `MouseScrollDelta::LineDelta` unit.
|
||||||
const WHEEL_LINES_PER_TICK: f32 = 3.0;
|
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
|
/// Byte range an optimistic Backspace/Delete removes at `cursor`, or
|
||||||
/// `None` when it can't be predicted locally: buffer edge (the
|
/// `None` when it can't be predicted locally: buffer edge (the
|
||||||
/// daemon's behavior is a no-op there anyway), a modifier variant
|
/// 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_sent_byte: None,
|
||||||
last_pointer_down: None,
|
last_pointer_down: None,
|
||||||
minimap_scrub_active: false,
|
minimap_scrub_active: false,
|
||||||
|
manual_left_authority: false,
|
||||||
|
wheel_residuals: WheelResiduals::default(),
|
||||||
edge_scroll_dir: None,
|
edge_scroll_dir: None,
|
||||||
edge_scroll_last: None,
|
edge_scroll_last: None,
|
||||||
styled_redraw_deadline: None,
|
styled_redraw_deadline: None,
|
||||||
|
|
@ -8090,6 +8318,66 @@ impl State {
|
||||||
self.request_redraw();
|
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
|
/// Move `code_scroll_left` so the caret's column is on screen
|
||||||
/// (Stage 5, framing Q#G2 — automatic only).
|
/// (Stage 5, framing Q#G2 — automatic only).
|
||||||
///
|
///
|
||||||
|
|
@ -14095,6 +14383,156 @@ fn decoration_kind_to_bg_color(kind: DecorationKind) -> Option<[f32; 4]> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 super::*;
|
||||||
use pmacs_protocol::cell::Style;
|
use pmacs_protocol::cell::Style;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue