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:
Levi Neuwirth 2026-08-14 17:24:04 +02:00
parent 515a4515be
commit 4bd3a76618
No known key found for this signature in database
3 changed files with 599 additions and 13 deletions

View File

@ -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)),

View File

@ -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 {

View File

@ -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,