feat(panel): replay PanelPointer --- the wiring, the seam, and the producer half
First implementation commit for parent acceptance 48. The daemon-side
replay and every producer-side rule land here; the daemon-side witness
matrices (A1-A5, B1-B6, and Q#BP-R2's document->terminal row) are the
next commit and are NOT claimed yet.
**MODIFIERS NOW CROSS THE SEAM (R-a).** The daemon destructured `mods`
into `..` and the dispatcher had no parameter for them, which inverted
two behaviours rather than degrading them: `apply_terminal_gesture`
gates child reporting on `!shift`, so Shift is the user's "select
locally instead of talking to the child" override, and the document
path reads Shift to extend the selection.
**THE REPLAY IS THE SHARED PATHS, NOT NEW ONES.** A terminal panel goes
through `apply_terminal_gesture` --- "the one terminal pointer path,
shared by both frontend kinds" --- with the side window's
`TerminalViewKey` and a viewport of `rows - 1`, never the full grid: the
frame would make the mode line a child cell and put every clamp a row
out. A document panel scrolls through the window-scoped `scroll_window`
and replays selection through new window-TARGETED writers.
Those writers exist because the selection API is active-window scoped.
`Drag` and `Up` do not activate, and another frontend can interleave
between a `Down` and its tail, so a replay reading `active_window_mut()`
would act on whatever happened to be active then. `panel_cell_byte`
converts against the SIDE window's own `view_top` and fold map without
`activate_and_position`'s `set_active_window_id`. The one place the
ambient helper is used is the double-click word selection, two
statements after the `Down` activated that window synchronously, and it
says so.
**Q#BP-R2 IS ORDERED, NOT MERELY PLACED.** A terminal panel's chrome
wheel is consumed before `focus_window`, before `active_frontend`,
before any controller claim and before the shared path --- `activates`
is `!Move` for a terminal, so a check any lower would leave the wheel
changing FOCUS while scrolling nothing.
Producer half, all target-blind because `PanelFrame` carries no
target-kind field:
- a press on the band's MODE LINE neither sends nor arms. Arming
would let a drag into content emit a `Drag` with no accepted
`Down`, which no receiver-side rule can undo.
- `gesture_last_content_cell`, a TERMINATION FALLBACK distinct from
the dedupe baseline. `last_pointer_cell` is cleared on press
precisely so the first drag after a press reaches the daemon
(asserted at `main.rs:19841`); storing the press cell there would
suppress it. The new field is written on arm and on each accepted
content motion, cleared on release and on either identity change,
and `panel_motion_is_new` never consults it.
- a crossing `Drag` is normalized and then deduped; `Up` is always
sent, always at a content coordinate.
- the gesture latch now dies on a change of EITHER identity --- panel
or geometry --- and survives a same-identity repaint.
Six mutations, each biting its own row:
M-P1 arm on a chrome press -> the producer arming row
M-P2 release reads the dedupe field -> the chrome and no-motion rows
M-D1 no reset on panel epoch -> the identity row
M-D2 no reset on geometry epoch -> the identity row
M-D3 reset clears `pointer_held` only -> the identity row
M-D4 reset on every frame -> the identity row's negative leg
M-P2 caught a defect in my own witness before it caught the code: the
no-intervening-motion row called `panel_motion_is_new` BEFORE asserting
the release, which populated the very field the mutation reads, so a
conflated implementation passed. The probe now runs after the
assertion, and the row is named for a scenario it actually performs.
One existing test moved with the contract rather than against it:
`a_held_button_makes_panel_motion_a_drag_and_a_release_lands_outside`
poked `panel_motion_is_new` and expected the release to follow it. It
now drives both fields as the production motion path does; its
assertion, and the dedupe guarantee it protects, are unchanged.
Gates: all nine green under `env -u TMPDIR`, log 20260814T151901Z.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
515a4515be
commit
4bd3a76618
|
|
@ -2131,6 +2131,16 @@ struct PanelBand {
|
|||
/// because the first drag after a press must reach the daemon even at the
|
||||
/// cell the press landed on.
|
||||
last_pointer_cell: Option<CellCoord>,
|
||||
/// Where the live gesture last legitimately pointed **inside content**,
|
||||
/// used to normalize a release that lands on chrome or outside the band
|
||||
/// (parent 48 R-c2).
|
||||
///
|
||||
/// **Deliberately NOT `last_pointer_cell`.** That field is a wire
|
||||
/// DEDUPE BASELINE and is cleared on press precisely so the first drag
|
||||
/// after a press reaches the daemon; storing the press cell there would
|
||||
/// suppress it. This one is a TERMINATION FALLBACK with a different
|
||||
/// lifetime, and `panel_motion_is_new` never consults it.
|
||||
gesture_last_content_cell: Option<CellCoord>,
|
||||
/// Whether the pointer is currently over the divider strip, which
|
||||
/// decides the `RowResize` cursor icon.
|
||||
hover_divider: bool,
|
||||
|
|
@ -2753,6 +2763,25 @@ impl App {
|
|||
// so reporting a drag as a hover makes a selection
|
||||
// drag silently do nothing.
|
||||
let kind = state.panel_motion_kind();
|
||||
let is_chrome = state.panel_cell_is_chrome(coord);
|
||||
if !is_chrome {
|
||||
let state = self.state.as_mut().expect("checked above");
|
||||
state.panel.gesture_last_content_cell = Some(coord);
|
||||
}
|
||||
let state = self.state.as_mut().expect("checked above");
|
||||
if is_chrome {
|
||||
// A crossing drag is normalized to the last content cell
|
||||
// and then deduped like any other motion — usually
|
||||
// suppressed, because that cell was already reported.
|
||||
let Some(normalized) = state.panel.gesture_last_content_cell else {
|
||||
return;
|
||||
};
|
||||
if state.panel_motion_is_new(normalized) {
|
||||
let mods = translate_mods(self.modifiers);
|
||||
self.send_panel_pointer_at_cell(Some(normalized), kind, mods);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if state.panel_motion_is_new(coord) {
|
||||
let mods = translate_mods(self.modifiers);
|
||||
self.send_panel_pointer_at(x, y, kind, mods);
|
||||
|
|
@ -2875,9 +2904,22 @@ impl App {
|
|||
// no preceding `Down`, and not arming at all means
|
||||
// `Drag(Left)` is never emitted and panel selection
|
||||
// cannot work at all.
|
||||
if let PointerSurface::PanelCell(_) = panel_surface {
|
||||
if let PointerSurface::PanelCell(coord) = panel_surface {
|
||||
// Parent 48 R-c: a press on the band's MODE LINE is
|
||||
// reserved, and must not arm. Arming would let a drag
|
||||
// into content emit a `Drag` with no accepted `Down` —
|
||||
// an orphan the daemon cannot tell from a real gesture,
|
||||
// and one no receiver-side rule can prevent, because the
|
||||
// frontend has already latched.
|
||||
if state.panel_cell_is_chrome(coord) {
|
||||
return;
|
||||
}
|
||||
let state = self.state.as_mut().expect("checked above");
|
||||
state.set_panel_pointer_held(true);
|
||||
// R-c2: the TERMINATION FALLBACK, not the dedupe
|
||||
// baseline. `set_panel_pointer_held` just cleared the
|
||||
// latter on purpose.
|
||||
state.panel.gesture_last_content_cell = Some(coord);
|
||||
self.send_panel_pointer_at(
|
||||
x,
|
||||
y,
|
||||
|
|
@ -4343,6 +4385,116 @@ mod input_routing_tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Parent 48 R-c, the PRODUCER half — a press on the band's mode line
|
||||
/// must neither send nor arm, and the row above it must do both.
|
||||
///
|
||||
/// This drives the real `MouseInput` path and reads the wire, because
|
||||
/// the hazard is precisely that the frontend latches BEFORE the daemon
|
||||
/// can refuse: `panel_hit_test` reports across the whole frame, so a
|
||||
/// chrome press is indistinguishable from a content press to the
|
||||
/// arming code, and once armed, a drag into content emits a `Drag`
|
||||
/// with no accepted `Down`. No receiver-side rule can undo that.
|
||||
///
|
||||
/// The content leg is not decoration: without it, an implementation
|
||||
/// that never arms anywhere passes the chrome half.
|
||||
#[test]
|
||||
fn a_press_on_the_bands_mode_line_neither_sends_nor_arms() {
|
||||
let mut h = EffectHarness::new();
|
||||
let rows = 4;
|
||||
{
|
||||
// Inlined rather than shared: `present_panel` lives in the
|
||||
// other test module. Same shape — wire on, one declaration,
|
||||
// one `Present`.
|
||||
let state = h.app.state.as_mut().expect("harness state");
|
||||
state.set_panel_wire(PANEL_MIN_VERSION);
|
||||
// The harness already declared during setup, so the `Surface`
|
||||
// trigger dedups; reuse the standing declaration rather than
|
||||
// asserting a second one.
|
||||
let geometry_epoch = state
|
||||
.next_geometry_declaration(GeometryTrigger::Surface)
|
||||
.map_or(state.panel.geometry_epoch, |(epoch, _)| epoch);
|
||||
assert_ne!(
|
||||
geometry_epoch, 0,
|
||||
"a declaration must exist to present against"
|
||||
);
|
||||
let cols = state.declared_cell_total().0.cols.max(1);
|
||||
let frame = pmacs_protocol::panel::PanelFrame {
|
||||
buffer_id: BufferId::from_raw(77),
|
||||
panel_epoch: 1,
|
||||
geometry_epoch,
|
||||
size: CellSize::new(rows, cols),
|
||||
cells: vec![pmacs_protocol::Cell::default(); (rows * cols) as usize],
|
||||
cursor: None,
|
||||
focused: true,
|
||||
};
|
||||
assert!(
|
||||
state.apply_panel_payload(pmacs_protocol::panel::PanelFramePayload::Present(frame)),
|
||||
"installing a first frame changes the band"
|
||||
);
|
||||
}
|
||||
let (ox, oy, _, band_h) = h
|
||||
.app
|
||||
.state
|
||||
.as_ref()
|
||||
.expect("harness state")
|
||||
.panel_content_rect()
|
||||
.expect("a presented band has a rect");
|
||||
let row_h = band_h / rows as f32;
|
||||
let press_at = |h: &mut EffectHarness, y: f32| {
|
||||
h.feed(&WindowEvent::CursorMoved {
|
||||
device_id: DeviceId::dummy(),
|
||||
position: PhysicalPosition::new(f64::from(ox) + 0.5, f64::from(y)),
|
||||
});
|
||||
h.feed(&WindowEvent::MouseInput {
|
||||
device_id: DeviceId::dummy(),
|
||||
state: ElementState::Pressed,
|
||||
button: MouseButton::Left,
|
||||
})
|
||||
};
|
||||
let panel_events = |step: &Step| {
|
||||
step.outbound
|
||||
.iter()
|
||||
.filter(|event| matches!(event, pmacs_protocol::FrontendEvent::PanelPointer { .. }))
|
||||
.count()
|
||||
};
|
||||
|
||||
// Chrome: the band's LAST row.
|
||||
let step = press_at(&mut h, oy + band_h - 0.5);
|
||||
assert_eq!(
|
||||
panel_events(&step),
|
||||
0,
|
||||
"a press on the mode line is reserved and reaches no daemon"
|
||||
);
|
||||
assert!(
|
||||
!h.app
|
||||
.state
|
||||
.as_ref()
|
||||
.expect("harness state")
|
||||
.panel
|
||||
.pointer_held,
|
||||
"and it must not ARM — an armed chrome press turns the next \
|
||||
motion into a `Drag` with no accepted `Down`"
|
||||
);
|
||||
|
||||
// Content: one row up, same column, same gesture.
|
||||
let step = press_at(&mut h, oy + band_h - row_h - 0.5);
|
||||
assert_eq!(
|
||||
panel_events(&step),
|
||||
1,
|
||||
"the row above chrome is content and must still work — without \
|
||||
this leg, never arming anywhere passes the half above"
|
||||
);
|
||||
assert!(
|
||||
h.app
|
||||
.state
|
||||
.as_ref()
|
||||
.expect("harness state")
|
||||
.panel
|
||||
.pointer_held,
|
||||
"a content press arms the gesture"
|
||||
);
|
||||
}
|
||||
|
||||
/// P2, pointer — **the row that shows a route cannot stand in for an
|
||||
/// effect.** A wheel carries only a delta; whether it becomes a
|
||||
/// viewport update, a panel event, a terminal event or nothing at
|
||||
|
|
@ -6908,6 +7060,7 @@ impl State {
|
|||
self.panel.hover_divider = false;
|
||||
self.panel.pointer_held = false;
|
||||
self.panel.last_pointer_cell = None;
|
||||
self.panel.gesture_last_content_cell = None;
|
||||
had
|
||||
}
|
||||
PanelFramePayload::Present(frame) => {
|
||||
|
|
@ -6919,6 +7072,28 @@ impl State {
|
|||
// A duplicate does no work — not even a reshape.
|
||||
return false;
|
||||
}
|
||||
// Parent 48 R-d: a held gesture belongs to the presentation
|
||||
// it began on. `Absent` clears the latch, but a
|
||||
// `Present` → `Present` REPLACEMENT did not, so a press on
|
||||
// panel A could emit a drag or release for B with no B
|
||||
// press — and acceptance 49 cannot reject that, because the
|
||||
// event carries B's CURRENT epochs. Geometry counts too: a
|
||||
// font or scale change moves `geometry_epoch` while
|
||||
// `panel_epoch` holds, and the gesture would resume under a
|
||||
// new grid.
|
||||
//
|
||||
// Only on a CHANGE of identity. A panel repaints constantly
|
||||
// during a drag, and resetting on every frame would make
|
||||
// selection impossible.
|
||||
let identity_changed = self.panel.presented().is_some_and(|current| {
|
||||
current.panel_epoch != frame.panel_epoch
|
||||
|| current.geometry_epoch != frame.geometry_epoch
|
||||
});
|
||||
if identity_changed {
|
||||
self.panel.pointer_held = false;
|
||||
self.panel.last_pointer_cell = None;
|
||||
self.panel.gesture_last_content_cell = None;
|
||||
}
|
||||
let plan = TerminalPaintPlan::build_grid(
|
||||
frame.size,
|
||||
&frame.cells,
|
||||
|
|
@ -7223,12 +7398,29 @@ impl State {
|
|||
/// A panel selection drag routinely ends past the band's edge, and
|
||||
/// dropping that release leaves the daemon holding a button down forever.
|
||||
fn panel_release_cell(&self, x: f32, y: f32) -> Option<CellCoord> {
|
||||
// Parent 48 R-c/R-c2: `Up` is the load-bearing crossing event — a
|
||||
// gesture whose release is dropped leaves the daemon holding a
|
||||
// button down forever. It is therefore always sent, and always at
|
||||
// a CONTENT coordinate: chrome and outside-the-band both fall back
|
||||
// to where the gesture last legitimately pointed, which is set at
|
||||
// press time so a release with no intervening motion still has one.
|
||||
match self.classify_pointer_surface(x, y) {
|
||||
PointerSurface::PanelCell(coord) => Some(coord),
|
||||
_ => self.panel.last_pointer_cell,
|
||||
PointerSurface::PanelCell(coord) if !self.panel_cell_is_chrome(coord) => Some(coord),
|
||||
_ => self.panel.gesture_last_content_cell,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `coord` is the band's MODE-LINE row (parent 48 R-c).
|
||||
///
|
||||
/// The daemon projects panel content as `rows - 1` and paints the last
|
||||
/// row as chrome, but `panel_hit_test` reports across the whole frame,
|
||||
/// so a hit is not automatically a content cell.
|
||||
fn panel_cell_is_chrome(&self, coord: CellCoord) -> bool {
|
||||
self.panel
|
||||
.presented()
|
||||
.is_some_and(|frame| coord.row + 1 >= frame.size.rows)
|
||||
}
|
||||
|
||||
/// Whether a panel motion at `coord` carries anything new, and latch it.
|
||||
///
|
||||
/// Sub-cell motion resolves to the same cell and says nothing the daemon
|
||||
|
|
@ -7248,6 +7440,10 @@ impl State {
|
|||
fn set_panel_pointer_held(&mut self, held: bool) {
|
||||
self.panel.pointer_held = held;
|
||||
self.panel.last_pointer_cell = None;
|
||||
// The termination fallback dies with the gesture (parent 48 R-c2).
|
||||
// Safe in both directions: the press path rewrites it immediately
|
||||
// after arming, and a release READS it before this runs.
|
||||
self.panel.gesture_last_content_cell = None;
|
||||
}
|
||||
|
||||
/// Begin a divider drag at surface pixel `y`, if the pointer is on the
|
||||
|
|
@ -19809,6 +20005,167 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Parent 48 R-c — the band's MODE-LINE row is chrome, and the
|
||||
/// producer must not arm a gesture there.
|
||||
///
|
||||
/// `panel_hit_test` reports across the whole frame, so a chrome press
|
||||
/// looks exactly like a content press to the arming code. If it armed,
|
||||
/// dragging into content would emit a `Drag` with no accepted `Down` —
|
||||
/// an orphan the daemon cannot distinguish from a real gesture, and one
|
||||
/// no receiver-side rule can prevent, because the latch is already set.
|
||||
#[test]
|
||||
fn a_press_on_the_bands_mode_line_neither_arms_nor_reports_content() {
|
||||
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
|
||||
return;
|
||||
};
|
||||
let frame = present_panel(&mut state, 4);
|
||||
let last = frame.size.rows - 1;
|
||||
|
||||
assert!(
|
||||
state.panel_cell_is_chrome(pmacs_protocol::CellCoord::new(last, 0)),
|
||||
"the band's last row is its mode line"
|
||||
);
|
||||
assert!(
|
||||
!state.panel_cell_is_chrome(pmacs_protocol::CellCoord::new(last - 1, 0)),
|
||||
"the row above it is content — without this the row proves nothing"
|
||||
);
|
||||
|
||||
// A release that lands on chrome normalizes to the last CONTENT
|
||||
// cell rather than reporting the mode line to the daemon.
|
||||
state.set_panel_pointer_held(true);
|
||||
state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 2));
|
||||
let (ox, oy, _, h) = state
|
||||
.panel_content_rect()
|
||||
.expect("a presented band has a rect");
|
||||
let on_chrome_y = oy + h - 0.5;
|
||||
assert_eq!(
|
||||
state.panel_release_cell(ox + 0.5, on_chrome_y),
|
||||
Some(pmacs_protocol::CellCoord::new(1, 2)),
|
||||
"`Up` is the load-bearing crossing event: it must arrive, and at \
|
||||
a CONTENT coordinate — a chrome row would fail the terminal \
|
||||
reporting bounds check and drop into local handling"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parent 48 R-c2 — a release with NO intervening motion still carries
|
||||
/// a coordinate.
|
||||
///
|
||||
/// The press cell is remembered in `gesture_last_content_cell`, not in
|
||||
/// `last_pointer_cell`: that one is cleared on press precisely so the
|
||||
/// first drag after a press reaches the daemon, and storing the press
|
||||
/// cell there would suppress it.
|
||||
#[test]
|
||||
fn a_release_with_no_intervening_motion_carries_the_press_cell() {
|
||||
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
|
||||
return;
|
||||
};
|
||||
present_panel(&mut state, 4);
|
||||
let press = pmacs_protocol::CellCoord::new(1, 3);
|
||||
|
||||
state.set_panel_pointer_held(true);
|
||||
state.panel.gesture_last_content_cell = Some(press);
|
||||
|
||||
assert_eq!(
|
||||
state.panel.last_pointer_cell, None,
|
||||
"arming clears the DEDUPE baseline — the tested guarantee that \
|
||||
the first drag after a press is not suppressed"
|
||||
);
|
||||
|
||||
// Released outside the band entirely, with NO motion in between —
|
||||
// asserted BEFORE any motion probe below, because a probe would
|
||||
// populate `last_pointer_cell` and let a conflated implementation
|
||||
// pass. (It did, on the first run of this row.)
|
||||
assert_eq!(
|
||||
state.panel_release_cell(0.0, 0.0),
|
||||
Some(press),
|
||||
"without the press cell retained, this release has no coordinate \
|
||||
and the daemon is left holding a button down forever"
|
||||
);
|
||||
|
||||
// Separation, checked after: the dedupe must not consult the
|
||||
// fallback, so a drag at the press cell still reaches the daemon.
|
||||
assert!(
|
||||
state.panel_motion_is_new(press),
|
||||
"the first drag after a press must not be suppressed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parent 48 R-d — a held gesture belongs to the presentation it began
|
||||
/// on, and BOTH identities end it.
|
||||
///
|
||||
/// `Absent` already cleared the latch; a `Present` → `Present`
|
||||
/// replacement did not, and acceptance 49 cannot catch the resulting
|
||||
/// orphan because it carries the successor's CURRENT epochs. Geometry
|
||||
/// counts too: a font or scale change moves `geometry_epoch` while
|
||||
/// `panel_epoch` holds.
|
||||
#[test]
|
||||
fn a_change_of_either_panel_identity_ends_a_held_gesture() {
|
||||
let Some(mut state) = headless_or_skip(800, 600, "alpha") else {
|
||||
return;
|
||||
};
|
||||
let frame = present_panel(&mut state, 4);
|
||||
|
||||
let arm = |state: &mut State| {
|
||||
state.set_panel_pointer_held(true);
|
||||
state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 1));
|
||||
};
|
||||
|
||||
// D4, the NEGATIVE leg, first: a CHANGED frame at the SAME identity
|
||||
// must not cancel a live gesture. A panel repaints constantly during
|
||||
// a drag, and a reset-every-frame implementation would make
|
||||
// selection impossible while passing D1 and D2.
|
||||
arm(&mut state);
|
||||
let mut refreshed = frame.clone();
|
||||
refreshed.focused = !frame.focused;
|
||||
assert!(
|
||||
state.apply_panel_payload(PanelFramePayload::Present(refreshed)),
|
||||
"a focus repaint is a real frame, not a suppressed duplicate"
|
||||
);
|
||||
assert!(
|
||||
state.panel.pointer_held,
|
||||
"a same-identity repaint must NOT end the gesture"
|
||||
);
|
||||
assert!(state.panel.gesture_last_content_cell.is_some());
|
||||
|
||||
// D1 — panel identity.
|
||||
arm(&mut state);
|
||||
let mut replaced = frame.clone();
|
||||
replaced.panel_epoch = frame.panel_epoch + 1;
|
||||
assert!(state.apply_panel_payload(PanelFramePayload::Present(replaced)));
|
||||
assert!(
|
||||
!state.panel.pointer_held,
|
||||
"a replacement panel never saw the press"
|
||||
);
|
||||
assert_eq!(state.panel.gesture_last_content_cell, None);
|
||||
assert_eq!(
|
||||
state.panel.last_pointer_cell, None,
|
||||
"and the dedupe baseline goes too, or the successor's first \
|
||||
same-cell motion is silently suppressed as a duplicate"
|
||||
);
|
||||
|
||||
// D2 — geometry identity, with panel identity UNCHANGED. This is the
|
||||
// font/scale case: the gesture would otherwise resume under a new
|
||||
// grid carrying epochs that are current and perfectly valid.
|
||||
let base = state
|
||||
.panel
|
||||
.presented()
|
||||
.cloned()
|
||||
.expect("a frame is present");
|
||||
arm(&mut state);
|
||||
let mut regeometried = base.clone();
|
||||
regeometried.geometry_epoch = base.geometry_epoch + 1;
|
||||
assert_eq!(
|
||||
regeometried.panel_epoch, base.panel_epoch,
|
||||
"the point of this leg is that PANEL identity holds"
|
||||
);
|
||||
assert!(state.apply_panel_payload(PanelFramePayload::Present(regeometried)));
|
||||
assert!(
|
||||
!state.panel.pointer_held,
|
||||
"a new grid is a new gesture context, even under the same panel"
|
||||
);
|
||||
assert_eq!(state.panel.gesture_last_content_cell, None);
|
||||
}
|
||||
|
||||
/// F1 — a held left button makes motion a `Drag(Left)`, and the dedupe
|
||||
/// re-arms on every press and release.
|
||||
#[test]
|
||||
|
|
@ -19849,7 +20206,13 @@ mod tests {
|
|||
state.panel_release_cell(ox + 0.5, oy + 0.5),
|
||||
Some(pmacs_protocol::CellCoord::new(0, 0))
|
||||
);
|
||||
// The dedupe baseline and the TERMINATION FALLBACK are separate
|
||||
// fields (parent 48 R-c2): `panel_motion_is_new` owns the first and
|
||||
// a release reads only the second, so drive both exactly as the
|
||||
// production motion path does. Conflating them is what would
|
||||
// suppress the first drag after a press, asserted above.
|
||||
state.panel_motion_is_new(pmacs_protocol::CellCoord::new(1, 7));
|
||||
state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 7));
|
||||
assert_eq!(
|
||||
state.panel_release_cell(ox + 0.5, 0.0),
|
||||
Some(pmacs_protocol::CellCoord::new(1, 7)),
|
||||
|
|
|
|||
|
|
@ -2428,6 +2428,13 @@ fn handle_dispatcher_event(
|
|||
buffer_id,
|
||||
coord,
|
||||
kind,
|
||||
// Parent 48 R-a: modifiers are NOT decoration here.
|
||||
// `apply_terminal_gesture` gates child reporting on
|
||||
// `!shift`, so Shift is the user's "select locally
|
||||
// instead of talking to the child" override, and the
|
||||
// document path reads Shift to extend the selection.
|
||||
// Dropping them into `..` inverted both.
|
||||
mods,
|
||||
..
|
||||
} => {
|
||||
// Bottom panel Q#BP16 — a gesture the frontend
|
||||
|
|
@ -2447,7 +2454,8 @@ fn handle_dispatcher_event(
|
|||
panel_epoch,
|
||||
)
|
||||
{
|
||||
editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind);
|
||||
editor
|
||||
.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind, mods);
|
||||
}
|
||||
}
|
||||
FrontendEvent::Pointer {
|
||||
|
|
|
|||
233
src/editor.rs
233
src/editor.rs
|
|
@ -2672,38 +2672,253 @@ impl EditorState {
|
|||
///
|
||||
/// Returns whether the gesture was accepted.
|
||||
pub fn dispatch_semantic_panel_pointer(
|
||||
&self,
|
||||
&mut self,
|
||||
frontend_id: FrontendId,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
coord: CellCoord,
|
||||
kind: pmacs_protocol::MouseKind,
|
||||
mods: pmacs_protocol::Modifiers,
|
||||
) -> bool {
|
||||
use pmacs_protocol::MouseKind as PKind;
|
||||
|
||||
let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else {
|
||||
return false;
|
||||
};
|
||||
if coord.row >= size.rows || coord.col >= size.cols {
|
||||
return false;
|
||||
}
|
||||
let is_terminal = self.terminal_manager.borrow().is_terminal(buffer_id);
|
||||
let mut core = self.core.borrow_mut();
|
||||
let Some(side) = core.side_window_for(frontend_id) else {
|
||||
return false;
|
||||
};
|
||||
if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) {
|
||||
// Parent 48 R-c: the panel's LAST ROW IS ITS MODE LINE. Projection
|
||||
// derives content as `rows - 1` while the frontend hit-tests the
|
||||
// whole frame, so a `PanelPointer` can legitimately name chrome —
|
||||
// and chrome is never a content cell for either target.
|
||||
let content_rows = size.rows.saturating_sub(1);
|
||||
if content_rows == 0 {
|
||||
return false;
|
||||
}
|
||||
let on_chrome = coord.row >= content_rows;
|
||||
let is_wheel = matches!(
|
||||
kind,
|
||||
PKind::ScrollUp | PKind::ScrollDown | PKind::ScrollLeft | PKind::ScrollRight
|
||||
);
|
||||
|
||||
let is_terminal = self.terminal_manager.borrow().is_terminal(buffer_id);
|
||||
let side = {
|
||||
let core = self.core.borrow();
|
||||
let Some(side) = core.side_window_for(frontend_id) else {
|
||||
return false;
|
||||
};
|
||||
if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) {
|
||||
return false;
|
||||
}
|
||||
side
|
||||
};
|
||||
|
||||
// Q#BP-R2, step 3 of the ordering: a terminal panel's CHROME WHEEL
|
||||
// is not a terminal gesture at all, so it is consumed HERE —
|
||||
// before `focus_window`, before `active_frontend`, before any
|
||||
// controller claim, and before the shared terminal path.
|
||||
//
|
||||
// Placing this below the activation block would leave the wheel
|
||||
// CHANGING FOCUS while scrolling nothing and claiming no
|
||||
// controller: `activates` is `!Move` for a terminal, so the wheel
|
||||
// already activates. That half-state is what the
|
||||
// activate-then-claim rule exists to prevent.
|
||||
if is_terminal && on_chrome && is_wheel {
|
||||
return true;
|
||||
}
|
||||
if on_chrome {
|
||||
if is_terminal {
|
||||
// TUI parity: a terminal never sees a chrome coordinate
|
||||
// (`dispatch_mouse` rejects every kind above `inner_rows`
|
||||
// for a terminal window). The wheel left above; the rest
|
||||
// stops here.
|
||||
return true;
|
||||
}
|
||||
// Document chrome mirrors the TUI's PER-KIND rule: presses and
|
||||
// motion are reserved, while `Up` and the wheel fall through —
|
||||
// an `Up` must still terminate a gesture begun in content, and
|
||||
// a chrome wheel still scrolls.
|
||||
if matches!(kind, PKind::Down(_) | PKind::Drag(_) | PKind::Move) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let activates = if is_terminal {
|
||||
!matches!(kind, pmacs_protocol::MouseKind::Move)
|
||||
!matches!(kind, PKind::Move)
|
||||
} else {
|
||||
matches!(kind, pmacs_protocol::MouseKind::Down(_))
|
||||
matches!(kind, PKind::Down(_))
|
||||
};
|
||||
if activates {
|
||||
let mut core = self.core.borrow_mut();
|
||||
core.focus_window(frontend_id, side);
|
||||
core.active_frontend = frontend_id;
|
||||
}
|
||||
|
||||
if is_terminal {
|
||||
// The ONE terminal pointer path, shared with the TUI and the
|
||||
// document terminal. The viewport is `content_rows`, never the
|
||||
// full grid: passing the frame would make the mode line a child
|
||||
// cell and put every clamp one row out.
|
||||
let viewport = CellSize::new(content_rows, size.cols);
|
||||
let key = TerminalViewKey::new(frontend_id, side, buffer_id);
|
||||
self.apply_terminal_gesture(key, viewport, coord, kind, mods, (coord.row, coord.col));
|
||||
return true;
|
||||
}
|
||||
|
||||
self.replay_panel_document_gesture(frontend_id, side, coord, kind, mods);
|
||||
true
|
||||
}
|
||||
|
||||
/// Replay one accepted gesture into a DOCUMENT panel (parent 48).
|
||||
///
|
||||
/// Every write here is addressed to `side`, never to the ambient
|
||||
/// active window. `Drag` and `Up` do not activate, and another
|
||||
/// frontend's input can interleave between a `Down` and its tail, so
|
||||
/// a replay reading `active_window_mut()` would act on whatever
|
||||
/// happened to be active at that moment (R-b).
|
||||
fn replay_panel_document_gesture(
|
||||
&mut self,
|
||||
frontend_id: FrontendId,
|
||||
side: WindowId,
|
||||
coord: CellCoord,
|
||||
kind: pmacs_protocol::MouseKind,
|
||||
mods: pmacs_protocol::Modifiers,
|
||||
) {
|
||||
use pmacs_protocol::{MouseButton as PButton, MouseKind as PKind};
|
||||
|
||||
match kind {
|
||||
PKind::ScrollUp => self.scroll_window(side, -SCROLL_LINES),
|
||||
PKind::ScrollDown => self.scroll_window(side, SCROLL_LINES),
|
||||
|
||||
PKind::Down(PButton::Left) => {
|
||||
self.core.borrow_mut().break_command_chain(frontend_id);
|
||||
let is_double = self.is_double_click(frontend_id, side, coord);
|
||||
let Some(byte) = self.panel_cell_byte(side, coord) else {
|
||||
return;
|
||||
};
|
||||
let extending = mods.contains(pmacs_protocol::Modifiers::SHIFT);
|
||||
let prev = self.core.borrow().windows[&side].cursor;
|
||||
let keep_anchor = extending
|
||||
&& self
|
||||
.core
|
||||
.borrow()
|
||||
.windows
|
||||
.get(&side)
|
||||
.is_some_and(|w| w.selection.is_some());
|
||||
self.panel_set_cursor(side, byte);
|
||||
if is_double && !extending {
|
||||
// Repeated left `Down`s are a multi-click (Q#BP16), and
|
||||
// the second selects the word — the same rule the TUI
|
||||
// applies, resolved against the SIDE window.
|
||||
// Safe to use the active-window helper HERE and only
|
||||
// here: `Down` activated the side window two statements
|
||||
// ago, synchronously, so active == side. The tail
|
||||
// (`Drag`/`Up`) does not activate and uses the
|
||||
// window-targeted writers instead.
|
||||
if self.core.borrow_mut().select_word_at_cursor() {
|
||||
self.mouse_click = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if extending {
|
||||
if !keep_anchor {
|
||||
self.panel_set_selection(side, Some(prev));
|
||||
}
|
||||
} else {
|
||||
self.panel_set_selection(side, Some(byte));
|
||||
}
|
||||
self.mouse_click = Some(MouseClickState {
|
||||
frontend_id,
|
||||
window_id: side,
|
||||
cell: coord,
|
||||
at: Instant::now(),
|
||||
});
|
||||
}
|
||||
PKind::Drag(PButton::Left) => {
|
||||
self.mouse_click = None;
|
||||
self.core.borrow_mut().break_command_chain(frontend_id);
|
||||
if let Some(byte) = self.panel_cell_byte(side, coord) {
|
||||
self.panel_set_cursor(side, byte);
|
||||
}
|
||||
}
|
||||
PKind::Up(PButton::Left) => {
|
||||
// A click without a drag leaves an ACTIVE BUT EMPTY
|
||||
// selection whose stale anchor would capture the next
|
||||
// shift-motion, so it is cleared rather than left set.
|
||||
let mut core = self.core.borrow_mut();
|
||||
if let Some(window) = core.windows.get_mut(&side)
|
||||
&& window
|
||||
.selection
|
||||
.is_some_and(|selection| selection.anchor == window.cursor)
|
||||
{
|
||||
window.selection = None;
|
||||
}
|
||||
}
|
||||
PKind::Down(PButton::Right) => {
|
||||
self.core.borrow_mut().break_command_chain(frontend_id);
|
||||
if let Some(byte) = self.panel_cell_byte(side, coord) {
|
||||
self.panel_set_cursor(side, byte);
|
||||
}
|
||||
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(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte under a panel cell, resolved against the SIDE window's own
|
||||
/// `view_top` and fold map.
|
||||
///
|
||||
/// Deliberately not `activate_and_position`: that helper converts
|
||||
/// correctly but also calls `set_active_window_id`, and a panel tail
|
||||
/// must not re-activate a window on a frontend whose active window may
|
||||
/// have moved since the `Down` (R-b).
|
||||
fn panel_cell_byte(&self, win_id: WindowId, coord: CellCoord) -> Option<u64> {
|
||||
let core = self.core.borrow();
|
||||
let window = core.windows.get(&win_id)?;
|
||||
let buffer_id = window.buffer_id;
|
||||
let view_top = window.view_top;
|
||||
let folds = core.fold_map_for_window(win_id);
|
||||
let display_row = match folds.as_ref() {
|
||||
Some(map) => map.nth_visible_from(view_top, coord.row as usize),
|
||||
None => view_top.saturating_add(coord.row as usize),
|
||||
};
|
||||
let display_row = u32::try_from(display_row).ok()?;
|
||||
let target = crate::view::DisplayCoord::new(display_row, coord.col);
|
||||
let registry = core.registry.clone();
|
||||
let reg = registry.borrow();
|
||||
let buf = reg.get(buffer_id).ok()?;
|
||||
core.windows[&win_id]
|
||||
.text_view
|
||||
.display_to_pos(buf, target, core.layout_ctx(win_id))
|
||||
}
|
||||
|
||||
/// Move ONE window's point, never the ambient active window's.
|
||||
fn panel_set_cursor(&self, win_id: WindowId, byte: u64) {
|
||||
let mut core = self.core.borrow_mut();
|
||||
if let Some(window) = core.windows.get_mut(&win_id) {
|
||||
window.cursor = byte;
|
||||
window.goal_col = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set or clear ONE window's selection anchor.
|
||||
fn panel_set_selection(&self, win_id: WindowId, anchor: Option<u64>) {
|
||||
let mut core = self.core.borrow_mut();
|
||||
if let Some(window) = core.windows.get_mut(&win_id) {
|
||||
window.selection = anchor.map(|anchor| crate::window::Selection { anchor });
|
||||
}
|
||||
}
|
||||
|
||||
/// Precompute owned terminal view snapshots before entering paint borrows.
|
||||
pub fn prepare_terminal_views(
|
||||
&mut self,
|
||||
|
|
|
|||
Loading…
Reference in New Issue