fix(gpu): present horizontal wheel and reset replaced panels

Request a frame whenever frontend-local horizontal scrolling changes the
GPU origin, so the waiting event loop presents the new viewport without
unrelated input.

Discard panel wheel residuals on accepted mapped and legacy identity
replacements, while preserving them across a geometry-only re-grid of the
same panel. Add production-path witnesses for the redraw and both frame
families, plus the geometry negative control.

Refresh the Stage 1b framing as revision 22 with the review findings and
their discriminators.
This commit is contained in:
Levi Neuwirth 2026-09-02 20:02:09 +02:00
parent c4b1505179
commit ef2592020d
No known key found for this signature in database
2 changed files with 368 additions and 10 deletions

View File

@ -1,13 +1,30 @@
# GUI arc, Stage 1 — input foundation (framing)
**Status: revision 21 — revision 20's §2a re-measurement at `0ec13b3`
stands; the GPU lifetime rows now describe structural authority rather
than a latch that never had a reader.** Revision 18's §2a was measured
**Status: revision 22 — IMPLEMENTED, IN REVIEW as PR #247. Revision
21's GPU structural-authority correction stands; the first PR review
closed one presentation gap and one incomplete disposal path without
changing a ruling.** Revision 18's §2a was measured
at `72da24a`. That
base is now **167 commits back**, and #242 and #243 landed on exactly
the pointer paths 1b builds on, so every 1b anchor is stale again —
which §2a's own history predicted would happen.
**Revision 22 closes two review findings in the implementation and in
the evidence.** First, the GPU's document-horizontal wheel changed
`code_scroll_left` but requested no redraw. The event loop returns to
`ControlFlow::Wait`, and this axis deliberately sends no viewport wire
event, so the stored origin could remain invisible until an unrelated
frame. B2 now includes a request-count witness whose origin assertion
still passes when the request is removed — the redraw is the
discriminator, not inferred from state. Second, B1's panel disposal ran
on `Absent` only. A direct accepted `Present` → `Present` replacement
could retain panel A's bank; A → B → A could later spend it. Separate
mapped and legacy rows drive the real wheel producer across a same-buffer,
new-`panel_epoch` replacement with no `Absent`. A geometry-only re-grid
is the negative control: it is still the same panel surface and keeps
the fraction. **No ruling changes; two paths that failed to implement
the existing rulings are now covered.**
**All four load-bearing claims were re-verified and all four SURVIVE:**
1. **Quantization still happens BEFORE routing.** `:3254` quantizes,
@ -497,6 +514,13 @@ defect as leaking between kinds but invisible to a kind-level row.
with panel B (the `buffer_id`/`panel_epoch` replacement acceptance 49
already models), motion over B must **start from zero**. *Mutation:
key the residual by "the panel" rather than by panel identity.*
**Revision 22 adds the disposal discriminator R2 alone could not
supply:** A starting from zero is satisfied by `BufferId` keying even
while A's stale bank remains. Drive a direct accepted A → B → A (or
same-buffer/new-`panel_epoch`) replacement with no `Absent`; A's first
successor half-notch does nothing and its second completes. Run it in
both mapped and legacy frame families. A geometry-only successor is a
negative control and preserves the bank.
- **R3 — terminal A→terminal B:** the same, across two terminal
buffers. *Mutation: key the residual by "the terminal".*
@ -1214,7 +1238,7 @@ The crate has **exactly one** executable `event_loop.exit()`, in
| # | Contract | Witness | Mutation |
|---|---|---|---|
| B1 | Residual per **axis and surface** — primary document, panel document and minimap are distinct owners | deltas discarded | share one accumulator → primary↔panel or document↔minimap surface-switch jump |
| B2 | Wheel-right raises the leftmost column of the **document surface under the pointer**; wheel-down raises its top line. Primary-document horizontal state stays frontend-local; a panel-document tick reaches the daemon's window-targeted `view_left` path | `x` discarded; panel replay currently claims and drops document `ScrollLeft`/`ScrollRight` | invert a sign → that axis's row; omit the panel effect → primary moves while the panel remains inert |
| B2 | Wheel-right raises the leftmost column of the **document surface under the pointer**; wheel-down raises its top line. Primary-document horizontal state stays frontend-local; a panel-document tick reaches the daemon's window-targeted `view_left` path | `x` discarded; panel replay currently claims and drops document `ScrollLeft`/`ScrollRight` | invert a sign → that axis's row; omit the panel effect → primary moves while the panel remains inert; omit the GPU local redraw request → `code_scroll_left` changes while the waiting event loop never presents it |
| B3 | Each document surface clamps at its own content bounds; never a negative origin, and wrap pins horizontal origin to zero | no horizontal scroll to clamp | remove a clamp → **at-bounds row: that surface's origin goes negative and the view blanks**; reuse primary bounds for a panel → unequal-width surface row fails |
| B4 | Middle-click paste uses **PRIMARY on Linux** | no middle-click path | use `CLIPBOARD` → B4 only |
| B5 | I-beam over text content only | no I-beam | extend over the gutter → B5 only |

View File

@ -1636,6 +1636,12 @@ struct State {
/// on a windowless `State`. Test-only.
#[cfg(test)]
render_calls: u64,
/// How many redraws production requested. Test-only: a headless
/// `State` has no [`Window`] to retain the request, so without this
/// counter a row can see viewport state change while missing that the
/// live event loop will remain asleep and never present it.
#[cfg(test)]
redraw_requests: std::cell::Cell<u64>,
// `None` in the headless render-test path (F-014): a windowless State
// that renders to an offscreen texture instead of a surface.
window: Option<Arc<Window>>,
@ -2204,12 +2210,14 @@ impl WheelTarget {
/// away must go away with it, or it is spent on whatever later takes
/// that identity.
///
/// **Disposal is implemented at both teardowns**, because the key
/// alone cannot see a surface closed and REOPENED on the same buffer:
/// the successor carries the same `BufferId`, so nothing distinguishes
/// it from the surface the user was actually scrolling.
/// `PanelFramePayload::Absent` clears the panel banks and
/// `exit_terminal_mode` clears the terminal ones.
/// **Disposal is implemented at both teardowns and at accepted panel
/// replacement.** The key alone cannot see a surface closed and REOPENED
/// on the same buffer: the successor carries the same `BufferId`, so
/// nothing distinguishes it from the surface the user was actually
/// scrolling. `PanelFramePayload::Absent` clears the panel banks,
/// accepted `Present`/`PresentMapped` identity replacements clear them
/// without waiting for an `Absent`, and `exit_terminal_mode` clears the
/// terminal ones.
#[derive(Debug, Default)]
struct WheelResiduals {
/// `(owner) -> (x, y)` in fractional ticks, each in `(-1.0, 1.0)`.
@ -5138,6 +5146,66 @@ mod input_routing_tests {
);
}
/// B2, GPU presentation half — changing the frontend-local horizontal
/// origin requests the frame that makes the change visible.
///
/// The event loop sleeps in `ControlFlow::Wait`, and this axis sends no
/// viewport message that could provoke a daemon frame. Inspecting
/// `code_scroll_left` alone therefore cannot distinguish a working
/// gesture from one whose new origin remains invisible until some
/// unrelated repaint.
///
/// *Mutation: omit `request_redraw()` from `scroll_by_columns` → this
/// row. The origin assertion still passes, proving the redraw counter is
/// the discriminator rather than corroboration.*
#[test]
fn b2_a_gpu_horizontal_wheel_requests_the_frame_that_displays_it() {
let mut h = EffectHarness::with_document(&format!("{}\n", "wide ".repeat(120)).repeat(200));
{
let buffer_id = h
.app
.state
.as_ref()
.expect("harness state")
.current_buffer_id
.expect("the harness stands in a buffer");
let state = h.app.state.as_mut().expect("harness state");
let _ = state.apply_attach_message(InstanceMessage::LineWrapFacts {
buffer_id,
wrap: false,
});
assert_eq!(state.buffer.wrap(), Wrap::None, "setup: wrap is off");
}
let document = document_probe(&h);
move_pointer(&mut h, document);
assert_eq!(
h.app.classify_wheel_target(document.0, document.1),
WheelTarget::Document,
"setup: document text"
);
let state = h.app.state.as_ref().expect("state");
let left_before = state.code_scroll_left;
let redraws_before = state.redraw_requests.get();
let step = h.feed(&wheel(1.0, 0.0));
let state = h.app.state.as_ref().expect("state");
assert!(
state.code_scroll_left > left_before,
"setup: the wheel must change the origin, or a redraw is not owed"
);
assert_eq!(
state.redraw_requests.get(),
redraws_before + 1,
"the local-only origin change must wake the waiting event loop"
);
assert!(
step.outbound.is_empty(),
"the redraw is locally requested, not induced by wire traffic: {:?}",
step.outbound
);
}
/// Replace the harness's document with a fresh buffer, through the
/// production `BufferSnapshot` receiver.
fn replace_the_buffer(h: &mut EffectHarness) {
@ -6548,6 +6616,8 @@ impl State {
let mut state = Self {
#[cfg(test)]
render_calls: 0,
#[cfg(test)]
redraw_requests: std::cell::Cell::new(0),
window,
device,
queue,
@ -8320,6 +8390,13 @@ impl State {
{
return false;
}
// B1's disposal half applies to a direct accepted
// replacement too, not only to `Absent`. The bank key is
// the buffer id, so without this a panel A -> B -> A
// sequence can spend A's pre-replacement fraction when A
// returns. Run only after every refusal/duplicate check:
// an unaccepted successor owns no state to reset.
self.discard_replaced_panel_wheel_residual(&frame);
// NOTE: the gesture-latch reset on an identity change is
// R-d, owned by `panel-pointer-replay`. It is not
// duplicated here — two branches resetting the same
@ -8375,6 +8452,11 @@ impl State {
self.panel.last_pointer_cell = None;
self.panel.gesture_last_content_cell = None;
}
// The pointer latch above includes a geometry change; the
// wheel bank does not. A fractional notch belongs to the
// panel presentation and survives a re-grid of that same
// panel, but never a panel/buffer replacement.
self.discard_replaced_panel_wheel_residual(&frame);
let plan = TerminalPaintPlan::build_grid(
frame.size,
&frame.cells,
@ -8389,6 +8471,22 @@ impl State {
}
}
/// Drop panel wheel state when an accepted frame replaces the panel
/// presentation that owned it.
///
/// `BufferId` alone is not an identity: a persistent buffer can leave
/// and later return as a new `panel_epoch`. Conversely, a geometry-only
/// change leaves the same scroll surface in place, so it does not spend
/// or discard a fractional notch.
fn discard_replaced_panel_wheel_residual(&mut self, successor: &PanelFrame) {
let replaced = self.panel.frame.as_ref().is_some_and(|current| {
current.buffer_id != successor.buffer_id || current.panel_epoch != successor.panel_epoch
});
if replaced {
self.wheel_residuals.clear_panels();
}
}
/// Reshape one cosmic-text buffer per planned panel run.
///
/// One buffer per RUN for the same reason terminal mode does it: a
@ -9284,7 +9382,11 @@ impl State {
/// frontends are not required to share a representation.
fn scroll_by_columns(&mut self, columns: i64) {
if self.buffer.wrap() != Wrap::None {
let changed = self.code_scroll_left != 0.0;
self.code_scroll_left = 0.0;
if changed {
self.request_redraw();
}
return;
}
let advance = self.mono_advance();
@ -9300,6 +9402,11 @@ impl State {
return;
}
self.code_scroll_left = next as f32 * advance;
// Unlike a vertical wheel, this path emits no Viewport and
// rebuilds no lines. Nothing else wakes the `ControlFlow::Wait`
// event loop, so changing the origin without requesting a frame
// leaves the new viewport invisible until an unrelated redraw.
self.request_redraw();
}
/// Move `code_scroll_left` so the caret's column is on screen
@ -11264,6 +11371,9 @@ impl State {
/// Ask the window to repaint. A no-op headless (no window), where the
/// render tests drive `render_offscreen` directly (F-014).
fn request_redraw(&self) {
#[cfg(test)]
self.redraw_requests
.set(self.redraw_requests.get().saturating_add(1));
if let Some(window) = &self.window {
window.request_redraw();
}
@ -22430,6 +22540,230 @@ mod tests {
);
}
/// Exercise B1's panel-disposal rule across a direct, accepted
/// `Present` -> `Present` identity replacement, with no intervening
/// `Absent` to clear the bank for us.
///
/// The same persistent buffer returns under a new `panel_epoch`, which
/// is the case `ResidualOwner::Panel(BufferId)` cannot distinguish. A
/// first successor half-notch must do nothing; a second must still
/// complete, so the row cannot pass against an accumulator that banks
/// nothing.
fn install_replacement_test_panel(h: &mut EffectHarness, mapped: bool) {
let state = h.app.state.as_mut().expect("harness state");
state.set_panel_wire(if mapped {
pmacs_protocol::PANEL_MAPPING_MIN_VERSION
} else {
PANEL_MIN_VERSION
});
let (geometry_epoch, total) = state
.next_geometry_declaration(GeometryTrigger::Metrics)
.expect("metrics advance the panel geometry");
let frame = panel_frame_of_buffer(
BufferId::from_raw(77),
4,
total.cols.max(1),
geometry_epoch,
1,
);
let payload = if mapped {
PanelFramePayload::PresentMapped {
frame,
mapping_generation: 1,
}
} else {
PanelFramePayload::Present(frame)
};
assert!(
state.apply_panel_payload(payload),
"setup: the initial {} frame must be accepted",
if mapped { "mapped" } else { "legacy" }
);
}
fn point_at_replacement_test_panel(h: &mut EffectHarness) {
use winit::dpi::PhysicalPosition;
use winit::event::DeviceId;
let (px, py, _, ph) = h
.app
.state
.as_ref()
.expect("harness state")
.panel_content_rect()
.expect("the panel is presented");
let point = (f64::from(px + 4.0), f64::from(py + ph / 2.0));
h.feed(&WindowEvent::CursorMoved {
device_id: DeviceId::dummy(),
position: PhysicalPosition::new(point.0, point.1),
});
assert!(
matches!(
h.app.classify_wheel_target(point.0, point.1),
WheelTarget::PanelCell { .. }
),
"setup: the probe must be a panel cell"
);
}
fn panel_half_notch(h: &mut EffectHarness) -> Step {
use winit::event::{DeviceId, MouseScrollDelta, TouchPhase};
h.feed(&WindowEvent::MouseWheel {
device_id: DeviceId::dummy(),
delta: MouseScrollDelta::LineDelta(0.0, -0.6),
phase: TouchPhase::Moved,
})
}
fn panel_gesture_count(step: &Step) -> usize {
step.outbound
.iter()
.filter(|event| {
matches!(
event,
pmacs_protocol::FrontendEvent::PanelPointer { .. }
| pmacs_protocol::FrontendEvent::PanelPointerMapped { .. }
)
})
.count()
}
fn replace_test_panel_directly(h: &mut EffectHarness, mapped: bool) {
let state = h.app.state.as_mut().expect("harness state");
let mut successor = state.panel.frame.clone().expect("retained frame");
successor.panel_epoch += 1;
let payload = if mapped {
PanelFramePayload::PresentMapped {
frame: successor,
mapping_generation: 1,
}
} else {
PanelFramePayload::Present(successor)
};
assert!(
state.apply_panel_payload(payload),
"the direct identity replacement must be accepted"
);
assert_eq!(
state.panel.frame.as_ref().map(|frame| frame.panel_epoch),
Some(2),
"setup: the successor, not the predecessor, is retained"
);
}
fn assert_panel_replacement_discards_wheel_residual(mapped: bool) {
let mut h = EffectHarness::new();
install_replacement_test_panel(&mut h, mapped);
point_at_replacement_test_panel(&mut h);
let step = panel_half_notch(&mut h);
assert_eq!(
panel_gesture_count(&step),
0,
"setup: the predecessor banks 0.6 and emits nothing"
);
// Direct replacement: no `Absent`, no pointer motion and the same
// buffer id. Only the accepted successor identity can discard the
// predecessor's bank.
replace_test_panel_directly(&mut h, mapped);
let step = panel_half_notch(&mut h);
assert_eq!(
panel_gesture_count(&step),
0,
"the successor starts from zero; the predecessor's 0.6 is gone: {:?}",
step.outbound
);
let step = panel_half_notch(&mut h);
assert_eq!(
panel_gesture_count(&step),
1,
"the successor's own 0.6 + 0.6 still completes exactly once: {:?}",
step.outbound
);
assert!(
matches!(
step.outbound.last(),
Some(
pmacs_protocol::FrontendEvent::PanelPointer {
kind: pmacs_protocol::MouseKind::ScrollDown,
..
} | pmacs_protocol::FrontendEvent::PanelPointerMapped {
kind: pmacs_protocol::MouseKind::ScrollDown,
..
}
)
),
"the completed successor notch is downward: {:?}",
step.outbound
);
}
/// Current production sessions negotiate the mapped family.
///
/// *Mutation: omit the replacement discard from `PresentMapped` →
/// this row only.*
#[test]
fn b1_a_mapped_panel_replacement_discards_the_predecessors_residual() {
assert_panel_replacement_discards_wheel_residual(true);
}
/// The legacy family remains supported at the protocol floor.
///
/// *Mutation: omit the replacement discard from `Present` → this row
/// only.*
#[test]
fn b1_a_legacy_panel_replacement_discards_the_predecessors_residual() {
assert_panel_replacement_discards_wheel_residual(false);
}
/// A geometry declaration re-grids the same panel; it does not replace
/// the scroll surface that owns an in-progress fractional notch.
///
/// *Mutation: include `geometry_epoch` in
/// `discard_replaced_panel_wheel_residual`'s identity comparison → this
/// row. The second half-notch no longer completes.*
#[test]
fn b1_a_panel_geometry_change_preserves_the_same_panels_residual() {
let mut h = EffectHarness::new();
install_replacement_test_panel(&mut h, false);
point_at_replacement_test_panel(&mut h);
let step = panel_half_notch(&mut h);
assert_eq!(
panel_gesture_count(&step),
0,
"setup: the first 0.6 banks against the panel"
);
{
let state = h.app.state.as_mut().expect("harness state");
let (geometry_epoch, total) = state
.next_geometry_declaration(GeometryTrigger::Metrics)
.expect("metrics advance the geometry epoch");
let mut regridded = state.panel.frame.clone().expect("retained frame");
regridded.geometry_epoch = geometry_epoch;
regridded.size.cols = total.cols.max(1);
regridded.cells.resize(
regridded.size.rows as usize * regridded.size.cols as usize,
terminal_cell(pmacs_protocol::Glyph::Char('x'), CellStyle::default()),
);
assert!(
state.apply_panel_payload(PanelFramePayload::Present(regridded)),
"the matching frame must settle the new geometry"
);
}
let step = panel_half_notch(&mut h);
assert_eq!(
panel_gesture_count(&step),
1,
"the same panel keeps its fraction across a re-grid: {:?}",
step.outbound
);
}
/// B1's disposal half, **terminal side** — leaving a terminal and
/// re-entering **the same** one starts from zero.
///