feat(panel): Q#BP-R4 --- the disposition, and the lifecycle table it enables

Replaces the panel dispatcher's `bool` with a three-state
PanelPointerOutcome decided BEFORE any target effect, and moves the
gesture lifecycle into one table in the daemon.

The old shape could not express the rule it needed. It validated,
classified and mutated in one pass, so an Up or Drag with no accepted
Down had already reached the child or the selection by the time the
daemon consulted the latch. classify_panel_pointer now returns the
disposition and carries the resolution it was decided from;
apply_panel_pointer acts on that same resolution, so the editor stays
the only authority and the daemon never re-derives chrome, target kind
or content bounds.

The table: a chrome press begins nothing; a left tail with no live
record is inert; an Accepted release performs the ordinary in-content
completion and takes the record; a Consumed release did not reach
content, so it terminates from the record at the gesture's last valid
content cell. Never both --- that is P5.

apply_terminal_gesture now returns whether the gesture REACHED THE
CHILD, so the latch is armed from the effect result rather than from a
prediction about the modes. complete_panel_gesture routes both terminal
domains back through that same shared path, which is what keeps "what a
release does" from having a second implementation.

Four witnesses, each reading a TARGET EFFECT and not the latch, and
each biting its own mutation: P1 chrome press (classify chrome as
Accepted), P3 chrome release on a terminal (drop the recorded
completion), P7 orphan release (remove the Up live-gate), P8 orphan
drag (remove the Drag live-gate).

Two of those rows were vacuous when first written and are recorded here
because the mutations are what caught them. P8 dragged over an EMPTY
panel buffer, so panel_cell_byte returned None and point could not move
whether the gate was there or not. P3 was written against a document
panel --- but R-c lets document chrome Up fall through to content, so it
classifies Accepted and never reaches the Consumed path it claimed to
test; it now uses a terminal panel, on the legacy arm, because reading
the live mapping generation ADVANCES the key and SS5b wired a key
advance to cancel the live gesture, so the mapped fixture destroyed the
gesture it was trying to complete.

Adds view_is_dragging_for_test, the observable that separates a
delivered completion from a latch that merely emptied.

Also re-homes a doc paragraph that described peer_uses_mapped_panel_family
while sitting above update_accepted_gesture; deleting the latter's doc
with the function made the misplacement visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-20 21:16:56 +02:00
parent 1fc3af63de
commit 39b6fa7dba
No known key found for this signature in database
4 changed files with 735 additions and 154 deletions

View File

@ -1019,6 +1019,112 @@ fn panel_event_epochs_are_current(
.is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch)
} }
/// Parent 48 Q#BP-R4 — the authoritative lifecycle table.
///
/// The disposition is decided BEFORE any target effect, and the live
/// record is consulted before a left tail reaches a child or a
/// selection. That ordering is the point: the old shape validated,
/// classified and mutated in one pass, so an `Up` or `Drag` with no
/// accepted `Down` had already landed by the time the latch was read.
///
/// Exactly one completion per gesture. An `Accepted` release performs
/// the ordinary in-content completion and takes the record; a
/// `Consumed` release did not reach content, so it delivers the
/// RECORDED completion and takes the record. Running both is P5.
fn replay_panel_pointer(
editor: &mut EditorState,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
source: FrontendId,
buffer_id: crate::buffer::BufferId,
coord: pmacs_protocol::CellCoord,
kind: pmacs_protocol::MouseKind,
mods: pmacs_protocol::Modifiers,
) {
use crate::editor::PanelPointerOutcome as Outcome;
use pmacs_protocol::{MouseButton, MouseKind};
let disposition = editor.classify_panel_pointer(source, buffer_id, coord, kind);
let outcome = disposition.outcome();
if outcome == Outcome::Refused {
// No effect, and the latch is left exactly as it was: a refused
// event cannot be known to concern the live gesture at all.
return;
}
let live = semantic_states
.get(&source)
.is_some_and(crate::semantic_render::SemanticRenderState::has_accepted_gesture);
match kind {
MouseKind::Down(MouseButton::Left) => {
if outcome != Outcome::Accepted {
// A chrome press begins nothing.
return;
}
let reached_child = editor.apply_panel_pointer(source, &disposition, coord, kind, mods);
if let Some(state) = semantic_states.get_mut(&source) {
// Armed FROM THE EFFECT RESULT: `reached_child` is
// measured where the report branch is taken, not
// predicted from the modes beforehand.
state.arm_accepted_gesture(crate::semantic_render::AcceptedPanelGesture {
button: MouseButton::Left,
coord,
buffer_id,
reached_child,
});
}
}
MouseKind::Drag(MouseButton::Left) => {
if outcome != Outcome::Accepted || !live {
// Stale tail, or a drag over chrome: inert. Any live
// record is retained rather than advanced, because the
// pointer is not over content.
return;
}
let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods);
if let Some(state) = semantic_states.get_mut(&source) {
state.note_gesture_content_cell(coord);
}
}
MouseKind::Up(MouseButton::Left) => {
if !live {
// A release with no accepted press is inert. Letting it
// through would send a child tail or mutate a selection
// for a gesture that never began.
return;
}
match outcome {
Outcome::Accepted => {
let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods);
if let Some(state) = semantic_states.get_mut(&source) {
// Taken WITHOUT counting a cancellation, and
// without also running the recorded completion.
let _ = state.consume_accepted_gesture();
}
}
Outcome::Consumed => {
// The release landed on chrome, so the content path
// never ran. Terminate from the record, at its last
// valid content cell.
let record = semantic_states.get_mut(&source).and_then(
crate::semantic_render::SemanticRenderState::consume_accepted_gesture,
);
if let Some(record) = record {
editor.complete_panel_gesture(source, &record, mods);
}
}
Outcome::Refused => unreachable!("refused returned above"),
}
}
_ => {
// Every other kind: a one-shot content effect, and it never
// touches the left-gesture latch.
if outcome == Outcome::Accepted {
let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods);
}
}
}
}
/// §5b — which panel-pointer family this session speaks. /// §5b — which panel-pointer family this session speaks.
/// ///
/// **Read from the AUTHENTICATED source, never from the payload's /// **Read from the AUTHENTICATED source, never from the payload's
@ -1029,50 +1135,11 @@ fn panel_event_epochs_are_current(
/// Consulted BEFORE the payload is trusted, before any generation is /// Consulted BEFORE the payload is trusted, before any generation is
/// validated and before any mutation: the family decides which variant /// validated and before any mutation: the family decides which variant
/// is even admissible, so it cannot depend on the variant's contents. /// is even admissible, so it cannot depend on the variant's contents.
/// §5b G5c/G5d/G5g — update the accepted-gesture latch for one
/// ACCEPTED panel gesture.
/// ///
/// Called only after every gate has passed, so "accepted" means exactly /// **Re-homed here.** This paragraph documented THIS function but sat
/// that. Three rules, and each closes its own hole: /// above `update_accepted_gesture`, whose own doc followed it in the
/// /// same block. Deleting that function's doc with it made the
/// * only a left `Down` ARMS — a right press opens a menu and ends /// misplacement visible.
/// there, and `Move`, wheel and the other buttons begin nothing, so
/// arming on them would manufacture a delayed release at the next
/// authority loss (G5g);
/// * a left `Up` CONSUMES, or a later invalidation finds a gesture it
/// believes live and duplicates its release (G5c);
/// * an `Up` with no armed gesture is INERT — it terminates nothing,
/// because nothing began (G5d).
fn update_accepted_gesture(
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
source: FrontendId,
kind: pmacs_protocol::MouseKind,
coord: pmacs_protocol::CellCoord,
buffer_id: crate::buffer::BufferId,
reached_child: bool,
) {
use pmacs_protocol::{MouseButton, MouseKind};
let Some(state) = semantic_states.get_mut(&source) else {
return;
};
match kind {
MouseKind::Down(MouseButton::Left) => {
state.arm_accepted_gesture(crate::semantic_render::AcceptedPanelGesture {
button: MouseButton::Left,
coord,
buffer_id,
reached_child,
});
}
MouseKind::Up(MouseButton::Left) => {
// Inert when nothing was armed: `take` on `None` is the
// whole of G5d.
let _ = state.consume_accepted_gesture();
}
_ => {}
}
}
fn peer_uses_mapped_panel_family(session_registry: &SessionRegistry, source: FrontendId) -> bool { fn peer_uses_mapped_panel_family(session_registry: &SessionRegistry, source: FrontendId) -> bool {
session_registry session_registry
.session_state(source) .session_state(source)
@ -2605,23 +2672,17 @@ fn handle_dispatcher_event(
// armed gesture, so the authority loss that // armed gesture, so the authority loss that
// should have ended it finds nothing, and the // should have ended it finds nothing, and the
// child holds the button down forever. // child holds the button down forever.
if editor replay_panel_pointer(
.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind, mods) editor,
{
update_accepted_gesture(
semantic_states, semantic_states,
source, source,
kind,
coord,
buffer_id, buffer_id,
// Whether the press reached a child is coord,
// replay's to know; until replay exists kind,
// no press does. mods,
false,
); );
} }
} }
}
FrontendEvent::PanelPointerMapped { FrontendEvent::PanelPointerMapped {
geometry_epoch, geometry_epoch,
panel_epoch, panel_epoch,
@ -2676,20 +2737,17 @@ fn handle_dispatcher_event(
// survive the ladder; it does not make a // survive the ladder; it does not make a
// surviving one land, so this arm needs the same // surviving one land, so this arm needs the same
// gate. // gate.
if editor replay_panel_pointer(
.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind, mods) editor,
{
update_accepted_gesture(
semantic_states, semantic_states,
source, source,
kind,
coord,
buffer_id, buffer_id,
false, coord,
kind,
mods,
); );
} }
} }
}
FrontendEvent::Pointer { FrontendEvent::Pointer {
buffer_id, buffer_id,
byte, byte,
@ -7452,6 +7510,309 @@ mod tests {
} }
} }
// -----------------------------------------------------------------
// Parent 48 Q#BP-R4 — the lifecycle table's witnesses.
//
// Every row here reads a TARGET EFFECT, never the latch alone. The
// framing is explicit that a latch-only assertion must not satisfy
// these: emptying the latch is bookkeeping, and the defect being
// fenced is precisely a gesture whose bookkeeping looks right while
// the target heard nothing.
// -----------------------------------------------------------------
/// The panel's buffer, an in-content cell, and a cell on its CHROME
/// (the mode line, which is the grid's last row per R-c).
fn panel_buffer_and_chrome_coord(
editor: &crate::editor::EditorState,
fid: FrontendId,
panel: crate::window::WindowId,
) -> (crate::buffer::BufferId, pmacs_protocol::CellCoord) {
let core = editor.core.borrow();
let grid = core.panel_grid_size(fid).expect("a live panel grid");
let content_rows = grid.rows.saturating_sub(1);
assert!(
content_rows > 0,
"fixture: the panel must have content rows"
);
(
core.windows[&panel].buffer_id,
pmacs_protocol::CellCoord::new(content_rows, 0),
)
}
/// The side window's cursor, for reading a replayed effect.
fn panel_cursor(editor: &crate::editor::EditorState, panel: crate::window::WindowId) -> u64 {
editor.core.borrow().windows[&panel].cursor
}
/// P1 — a press on the band's MODE LINE begins nothing.
///
/// The merge made this arm the latch, because `Consumed` and
/// `Accepted` were the same `true`. The row reads the cursor as well
/// as the latch: a chrome press must not move point either.
#[test]
fn r4_p1_a_chrome_press_neither_arms_nor_moves_point() {
let fid = FrontendId(790);
let (mut editor, mut states, mut render, _document, panel, epochs) =
panel_session_at(PROTOCOL_VERSION, fid);
let (buffer_id, chrome) = panel_buffer_and_chrome_coord(&editor, fid, panel);
let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left);
let before = panel_cursor(&editor, panel);
let generation = live_generation(PanelArm::Mapped, &editor, &mut states, fid);
dispatch_panel_event(
&mut editor,
fid,
PROTOCOL_VERSION,
&mut states,
&mut render,
arm_pointer(
PanelArm::Mapped,
fid,
epochs,
buffer_id,
generation,
chrome,
press,
),
);
assert!(
!states[&fid].has_accepted_gesture(),
"a chrome press must not arm: the mode line is not content, \
and a gesture armed there would be cancelled or released \
for a press the target never saw"
);
assert_eq!(
panel_cursor(&editor, panel),
before,
"and it must not move point --- `Consumed` means the panel \
claimed the cell, not that it replayed anything"
);
}
/// P7 — a release with no accepted press reaches nothing.
///
/// This is the stale-tail case the pre-effect disposition exists
/// for. The document `Up` arm clears an ACTIVE BUT EMPTY selection,
/// so the fixture arms one and asserts it SURVIVES: an inert release
/// must not run that clear.
#[test]
fn r4_p7_a_release_with_no_accepted_press_is_inert() {
let fid = FrontendId(791);
let (mut editor, mut states, mut render, _document, panel, epochs) =
panel_session_at(PROTOCOL_VERSION, fid);
let buffer_id = editor.core.borrow().windows[&panel].buffer_id;
let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left);
let inside = pmacs_protocol::CellCoord::new(0, 0);
// An empty selection is exactly what an accepted release would
// clear, so it is the discriminator for "did the effect run".
{
let mut core = editor.core.borrow_mut();
let cursor = core.windows[&panel].cursor;
core.windows.get_mut(&panel).expect("panel").selection =
Some(crate::window::Selection { anchor: cursor });
}
assert!(
!states[&fid].has_accepted_gesture(),
"fixture: no gesture is live"
);
let generation = live_generation(PanelArm::Mapped, &editor, &mut states, fid);
dispatch_panel_event(
&mut editor,
fid,
PROTOCOL_VERSION,
&mut states,
&mut render,
arm_pointer(
PanelArm::Mapped,
fid,
epochs,
buffer_id,
generation,
inside,
release,
),
);
assert!(
editor.core.borrow().windows[&panel].selection.is_some(),
"a release with no accepted press must reach NOTHING --- it \
cleared a selection it never began, which on a terminal is \
a child tail for a press that never happened"
);
}
/// P8 — a drag with no accepted press reaches nothing.
///
/// The document `Drag` arm moves point unconditionally, so an
/// orphan drag used to drive the cursor from a gesture that never
/// began.
#[test]
fn r4_p8_an_orphan_drag_does_not_move_point() {
let fid = FrontendId(792);
let (mut editor, mut states, mut render, _document, panel, epochs) =
panel_session_at(PROTOCOL_VERSION, fid);
let buffer_id = editor.core.borrow().windows[&panel].buffer_id;
let drag = pmacs_protocol::MouseKind::Drag(pmacs_protocol::MouseButton::Left);
// THE PANEL BUFFER MUST HAVE CONTENT. `*panel*` is created empty,
// so `panel_cell_byte` returns `None` for every interesting cell
// and the drag cannot move point whether it is gated or not —
// the row would pass vacuously. Caught by the gating mutation
// failing to bite.
foreign_edit(&editor, buffer_id, b"alpha beta gamma\ndelta\n");
// A cell the cursor is NOT already on, or the row cannot fail.
let elsewhere = pmacs_protocol::CellCoord::new(0, 6);
let before = panel_cursor(&editor, panel);
assert_eq!(before, 0, "fixture: point starts at the buffer head");
let generation = live_generation(PanelArm::Mapped, &editor, &mut states, fid);
dispatch_panel_event(
&mut editor,
fid,
PROTOCOL_VERSION,
&mut states,
&mut render,
arm_pointer(
PanelArm::Mapped,
fid,
epochs,
buffer_id,
generation,
elsewhere,
drag,
),
);
assert_eq!(
panel_cursor(&editor, panel),
before,
"an orphan drag must not move point"
);
assert!(
!states[&fid].has_accepted_gesture(),
"and it must not manufacture a record by writing to one"
);
}
/// P3 — a chrome release on a TERMINAL panel completes the gesture
/// from the record.
///
/// **The document leg cannot test this, and an earlier version of
/// this row tried.** R-c lets document chrome `Up` fall THROUGH to
/// content, so it classifies `Accepted` and takes the ordinary path;
/// the row passed with the recorded completion deleted. Only a
/// terminal panel returns `Consumed` for a chrome release — which is
/// exactly the path the framing named, where the dispatcher returns
/// before `apply_terminal_gesture`.
///
/// The observable is the terminal view's DRAG STATE, not the latch:
/// `finish_selection` is what takes it, so a gesture that ended
/// without a delivered completion stays mid-drag forever.
#[test]
fn r4_p3_a_chrome_release_completes_a_terminal_gesture_from_the_record() {
use crate::terminal::{TerminalSpec, view::TerminalViewKey};
// LEGACY, deliberately. This row is about the Consumed/terminal
// completion, which is family-independent — and the mapped arm
// cannot express it, because reading the live mapping generation
// ADVANCES the key, and §5b wired a key advance to cancel the
// live gesture (G5a). The fixture would destroy the gesture it
// is trying to complete, which is how an earlier version of this
// row failed while the implementation was correct.
let fid = FrontendId(793);
let (mut editor, mut states, mut render, _document, panel, _epochs) =
panel_session_at(LEGACY_PANEL_VERSION, fid);
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
spec.rows = 4;
spec.cols = 20;
let terminal_buffer = editor
.terminal_manager
.borrow_mut()
.open(
spec,
&mut editor.core.borrow_mut(),
&mut editor.process_supervisor.borrow_mut(),
)
.expect("open panel terminal");
{
let mut core = editor.core.borrow_mut();
let view = {
let registry = core.registry.clone();
let registry = registry.borrow();
crate::text_view::TextView::new(
registry.get(terminal_buffer).expect("terminal buffer"),
)
};
let window = core.windows.get_mut(&panel).expect("panel window");
window.buffer_id = terminal_buffer;
window.text_view = view;
}
// Re-ship the declaration so the epochs match the terminal panel.
let epochs = shipped_declaration(&editor, fid, &mut states);
let (buffer_id, chrome) = panel_buffer_and_chrome_coord(&editor, fid, panel);
assert_eq!(
buffer_id, terminal_buffer,
"fixture: the panel is the terminal"
);
let inside = pmacs_protocol::CellCoord::new(0, 0);
let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left);
let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left);
let key = TerminalViewKey::new(fid, panel, terminal_buffer);
dispatch_panel_event(
&mut editor,
fid,
LEGACY_PANEL_VERSION,
&mut states,
&mut render,
arm_pointer(PanelArm::Legacy, fid, epochs, buffer_id, 0, inside, press),
);
assert!(
states[&fid].has_accepted_gesture(),
"fixture: the content press armed"
);
assert!(
editor
.terminal_manager
.borrow()
.view_is_dragging_for_test(key),
"fixture: the press began a local terminal drag"
);
dispatch_panel_event(
&mut editor,
fid,
LEGACY_PANEL_VERSION,
&mut states,
&mut render,
arm_pointer(PanelArm::Legacy, fid, epochs, buffer_id, 0, chrome, release),
);
assert!(
!editor
.terminal_manager
.borrow()
.view_is_dragging_for_test(key),
"the RECORDED completion must run: a terminal chrome release \
returns before the replay path, so without it the drag never \
finishes while the latch looks correctly empty"
);
assert!(
!states[&fid].has_accepted_gesture(),
"and the record is taken"
);
assert_eq!(
states[&fid].panel_gesture_cancellations(),
0,
"a completion is not a cancellation"
);
}
/// The panel's buffer, and a coordinate one row past its grid. /// The panel's buffer, and a coordinate one row past its grid.
fn panel_buffer_and_outside_coord( fn panel_buffer_and_outside_coord(
editor: &crate::editor::EditorState, editor: &crate::editor::EditorState,

View File

@ -408,6 +408,62 @@ impl PanelMappingSnapshot {
} }
} }
/// How an authenticated panel event relates to the authoritative panel
/// surface, decided BEFORE any target effect (parent 48 Q#BP-R4).
///
/// Two branches once agreed on `bool` while disagreeing on its meaning
/// — §5b read it as "the gesture was accepted" and drove the
/// accepted-gesture latch off it, while panel replay read it as "the
/// event was consumed here", chrome swallows included. Merged, a press
/// on the band's mode line armed a gesture that never began in content.
/// **This type exists so that collision cannot be re-created silently:**
/// a two-state answer with the corrected meaning would be right today
/// and would let the next author restore the bug without touching a
/// test.
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum PanelPointerOutcome {
/// Not addressable as this panel: no grid, out of grid, no side
/// window, or a buffer that is not the one shown there.
Refused,
/// This panel owns the cell/event, but it is deliberately not a
/// content gesture — the chrome claims of Q#BP-R2 and R-c.
Consumed,
/// A content gesture for the resolved target.
Accepted,
}
/// A classified panel gesture: the outcome, and the resolution it was
/// decided from.
///
/// The resolution travels with the outcome so that
/// `EditorState::apply_panel_pointer` acts on the SAME derivation the
/// disposition was decided by. Q#BP-R4 fixes the editor as the only
/// authority for that derivation; handing the daemon a bare outcome
/// and letting it re-derive chrome or target kind would be the hole
/// §5b closed, reopened beside the dispatcher.
pub struct PanelPointerDisposition {
outcome: PanelPointerOutcome,
resolved: Option<ResolvedPanelTarget>,
}
impl PanelPointerDisposition {
/// The disposition, for the daemon's lifecycle table.
#[must_use]
pub fn outcome(&self) -> PanelPointerOutcome {
self.outcome
}
}
/// The panel target a disposition resolved to, derived once.
struct ResolvedPanelTarget {
side: WindowId,
buffer_id: crate::buffer::BufferId,
is_terminal: bool,
/// The grid's rows MINUS the mode line — never the frame (R-c).
content_rows: u32,
cols: u32,
}
/// What decides the mapping BELOW the geometry, which differs by /// What decides the mapping BELOW the geometry, which differs by
/// target kind. /// target kind.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
@ -2769,7 +2825,8 @@ impl EditorState {
.is_some_and(|now| now != current) .is_some_and(|now| now != current)
} }
/// Apply an accepted `FrontendEvent::PanelPointer` gesture (Q#BP16). /// Classify an authenticated panel gesture, WITHOUT applying it
/// (Q#BP-R4).
/// ///
/// Steps 2, 5, and 6 of Q#BP16's ladder are re-derived here from the /// Steps 2, 5, and 6 of Q#BP16's ladder are re-derived here from the
/// daemon's own state — a live, non-hidden side window whose current /// daemon's own state — a live, non-hidden side window whose current
@ -2778,6 +2835,104 @@ impl EditorState {
/// epochs) belong to the caller, because only the session holds the /// epochs) belong to the caller, because only the session holds the
/// declaration the frontend was actually looking at. /// declaration the frontend was actually looking at.
/// ///
/// **The answer is a DISPOSITION, decided before any target
/// effect.** Q#BP-R4 fixes two facts and this split exists to hold
/// them: the editor is the **only** authority that derives
/// `Refused`/`Consumed`/`Accepted`, and the disposition completes
/// before a left `Drag`/`Up` reaches a child or a selection. The old
/// single function could not honour the second — it validated,
/// classified and mutated in one pass, so a tail with no accepted
/// press had already landed by the time the daemon consulted the
/// latch.
///
/// The returned value carries the resolution it was decided from, so
/// [`Self::apply_panel_pointer`] acts on the same derivation rather
/// than repeating it. **A second derivation is the hole §5b closed**;
/// reopening it beside the dispatcher would be the same defect in a
/// new place.
#[must_use]
pub fn classify_panel_pointer(
&self,
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
coord: CellCoord,
kind: pmacs_protocol::MouseKind,
) -> PanelPointerDisposition {
use pmacs_protocol::MouseKind as PKind;
let refused = PanelPointerDisposition {
outcome: PanelPointerOutcome::Refused,
resolved: None,
};
let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else {
return refused;
};
if coord.row >= size.rows || coord.col >= size.cols {
return refused;
}
// 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 refused;
}
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 refused;
};
if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) {
return refused;
}
side
};
let resolved = Some(ResolvedPanelTarget {
side,
buffer_id,
is_terminal,
content_rows,
cols: size.cols,
});
let on_chrome = coord.row >= content_rows;
let is_wheel = matches!(
kind,
PKind::ScrollUp | PKind::ScrollDown | PKind::ScrollLeft | PKind::ScrollRight
);
// Everything below is CLAIMED by this panel — the cell is ours —
// and the only question left is whether it is a content gesture.
let outcome = if on_chrome {
// Q#BP-R2, step 3 of the ordering: a terminal panel's chrome
// wheel is consumed BEFORE focus, before `active_frontend`,
// and before any controller claim. Placing it after
// activation would leave the wheel changing focus while
// scrolling nothing.
//
// TUI parity for the rest: a terminal never sees a chrome
// coordinate (`dispatch_mouse` rejects every kind above
// `inner_rows`). Document chrome mirrors the TUI's PER-KIND
// rule — presses and motion are reserved, while `Up` and the
// wheel fall through, because an `Up` must still terminate a
// gesture begun in content and a chrome wheel still scrolls.
if is_terminal || matches!(kind, PKind::Down(_) | PKind::Drag(_) | PKind::Move) {
let _ = is_wheel;
PanelPointerOutcome::Consumed
} else {
PanelPointerOutcome::Accepted
}
} else {
PanelPointerOutcome::Accepted
};
PanelPointerDisposition { outcome, resolved }
}
/// Apply a classified panel gesture to its target (Q#BP-R4).
///
/// **Activation is not uniform, and Q#BP16 says so explicitly.** A /// **Activation is not uniform, and Q#BP16 says so explicitly.** A
/// **press** focuses any panel — that is click-to-focus, and /// **press** focuses any panel — that is click-to-focus, and
/// `Down(Right)` is the context-menu gesture, so both buttons count. /// `Down(Right)` is the context-menu gesture, so both buttons count.
@ -2796,112 +2951,132 @@ impl EditorState {
/// Review round 1 (R2-5) found the terminal clause applied to both. /// Review round 1 (R2-5) found the terminal clause applied to both.
/// Bare hover neither focuses nor claims, on either kind. /// Bare hover neither focuses nor claims, on either kind.
/// ///
/// **Replay is out of scope in Stage 2B-2.** Driving selection, /// **Only `Accepted` reaches a target.** A `Consumed` disposition
/// listview rows, or child SGR reporting is parent acceptance 48, /// returns without effect: the claim was the effect. Returns whether
/// which needs the GPU band and lands in Stage 2B-3. /// the gesture reached a CHILD, which is what the accepted-gesture
/// /// latch records — the framing requires arming "from the effect
/// Returns whether the gesture was accepted. /// result", so this is measured rather than assumed.
/// pub fn apply_panel_pointer(
/// `#[must_use]` because the accepted-gesture latch is driven off
/// this answer, and discarding it silently arms on rejected presses
/// and consumes on rejected releases.
#[must_use]
pub fn dispatch_semantic_panel_pointer(
&mut self, &mut self,
frontend_id: FrontendId, frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId, disposition: &PanelPointerDisposition,
coord: CellCoord, coord: CellCoord,
kind: pmacs_protocol::MouseKind, kind: pmacs_protocol::MouseKind,
mods: pmacs_protocol::Modifiers, mods: pmacs_protocol::Modifiers,
) -> bool { ) -> bool {
use pmacs_protocol::MouseKind as PKind; use pmacs_protocol::MouseKind as PKind;
let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { if disposition.outcome != PanelPointerOutcome::Accepted {
return false;
}
let Some(target) = disposition.resolved.as_ref() else {
return false; return false;
}; };
if coord.row >= size.rows || coord.col >= size.cols {
return false;
}
// 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 activates = if target.is_terminal {
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, PKind::Move) !matches!(kind, PKind::Move)
} else { } else {
matches!(kind, PKind::Down(_)) matches!(kind, PKind::Down(_))
}; };
if activates { if activates {
let mut core = self.core.borrow_mut(); let mut core = self.core.borrow_mut();
core.focus_window(frontend_id, side); core.focus_window(frontend_id, target.side);
core.active_frontend = frontend_id; core.active_frontend = frontend_id;
} }
if is_terminal { if target.is_terminal {
// The ONE terminal pointer path, shared with the TUI and the // The ONE terminal pointer path, shared with the TUI and the
// document terminal. The viewport is `content_rows`, never the // document terminal. The viewport is `content_rows`, never the
// full grid: passing the frame would make the mode line a child // full grid: passing the frame would make the mode line a child
// cell and put every clamp one row out. // cell and put every clamp one row out.
let viewport = CellSize::new(content_rows, size.cols); let viewport = CellSize::new(target.content_rows, target.cols);
let key = TerminalViewKey::new(frontend_id, side, buffer_id); let key = TerminalViewKey::new(frontend_id, target.side, target.buffer_id);
self.apply_terminal_gesture(key, viewport, coord, kind, mods, (coord.row, coord.col)); return self.apply_terminal_gesture(
return true; key,
viewport,
coord,
kind,
mods,
(coord.row, coord.col),
);
} }
self.replay_panel_document_gesture(frontend_id, side, coord, kind, mods); self.replay_panel_document_gesture(frontend_id, target.side, coord, kind, mods);
true false
}
/// Deliver the RECORDED completion for a gesture the ordinary path
/// did not complete (parent 48 Q#BP-R4).
///
/// Used when a release is `Consumed` — it landed on chrome, so the
/// content path never ran — or when an authority loss cancels a
/// live gesture. An `Accepted` release must NOT come here: it
/// already performed the ordinary in-content completion, and doing
/// both is the duplicate P5 forbids.
///
/// **The domain is read from the record, not from where the pointer
/// is now.** `buffer_id` says which panel the gesture belongs to and
/// `reached_child` says whether a press was ever delivered, so the
/// three completions separate without a fourth field:
///
/// * reporting terminal → the child gets its release;
/// * local terminal → the drag's selection is finalised;
/// * document → the gesture completes and an empty selection is
/// cleared without moving point.
///
/// Delivery goes through the SAME paths an in-content release uses,
/// at the record's last valid content cell (R-c2). A second
/// implementation of "what a release does" would drift from the
/// first, which is the whole reason the shared terminal path exists.
pub fn complete_panel_gesture(
&mut self,
frontend_id: FrontendId,
record: &crate::semantic_render::AcceptedPanelGesture,
mods: pmacs_protocol::Modifiers,
) {
use pmacs_protocol::{MouseButton as PButton, MouseKind as PKind};
let release = PKind::Up(PButton::Left);
let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else {
return;
};
let content_rows = size.rows.saturating_sub(1);
if content_rows == 0 {
return;
}
let side = {
let core = self.core.borrow();
let Some(side) = core.side_window_for(frontend_id) else {
return;
};
// The panel must still be showing the buffer the gesture
// belongs to. If it is not, the four stranding transitions
// own this record, not an ordinary completion.
if core.windows.get(&side).map(|window| window.buffer_id) != Some(record.buffer_id) {
return;
}
side
};
if self.terminal_manager.borrow().is_terminal(record.buffer_id) {
// Both terminal domains route here: `apply_terminal_gesture`
// reports to the child when the modes allow and finalises the
// local selection otherwise, which is exactly the split this
// completion needs.
let viewport = CellSize::new(content_rows, size.cols);
let key = TerminalViewKey::new(frontend_id, side, record.buffer_id);
let _ = self.apply_terminal_gesture(
key,
viewport,
record.coord,
release,
mods,
(record.coord.row, record.coord.col),
);
return;
}
self.replay_panel_document_gesture(frontend_id, side, record.coord, release, mods);
} }
/// Replay one accepted gesture into a DOCUMENT panel (parent 48). /// Replay one accepted gesture into a DOCUMENT panel (parent 48).
@ -3872,6 +4047,13 @@ impl EditorState {
/// A second copy of this precedence in the GPU lane is exactly how /// A second copy of this precedence in the GPU lane is exactly how
/// Shift-drag or scrolled-back selection would silently diverge /// Shift-drag or scrolled-back selection would silently diverge
/// between frontends. /// between frontends.
/// Returns whether the gesture REACHED THE CHILD as a mouse report.
///
/// Parent 48 Q#BP-R4 arms the accepted-gesture latch "from the
/// effect result", so `reached_child` has to be measured where the
/// branch is taken rather than predicted from the modes beforehand:
/// the report is gated on five conditions and `encode_mouse` can
/// still decline.
fn apply_terminal_gesture( fn apply_terminal_gesture(
&mut self, &mut self,
key: TerminalViewKey, key: TerminalViewKey,
@ -3880,12 +4062,12 @@ impl EditorState {
kind: TerminalMouseKind, kind: TerminalMouseKind,
modifiers: TerminalModifiers, modifiers: TerminalModifiers,
global: (u32, u32), global: (u32, u32),
) { ) -> bool {
let shift = modifiers.contains(TerminalModifiers::SHIFT); let shift = modifiers.contains(TerminalModifiers::SHIFT);
let (at_bottom, modes, screen_size) = { let (at_bottom, modes, screen_size) = {
let mut manager = self.terminal_manager.borrow_mut(); let mut manager = self.terminal_manager.borrow_mut();
let Some(status) = manager.view_status_for_size(key, viewport_size) else { let Some(status) = manager.view_status_for_size(key, viewport_size) else {
return; return false;
}; };
let modes = manager.modes_for_view(key).unwrap_or_default(); let modes = manager.modes_for_view(key).unwrap_or_default();
let screen_size = manager.screen_size_for_view(key).unwrap_or(viewport_size); let screen_size = manager.screen_size_for_view(key).unwrap_or(viewport_size);
@ -3915,7 +4097,7 @@ impl EditorState {
self.claim_terminal_controller(key); self.claim_terminal_controller(key);
} }
self.send_terminal_bytes(key.buffer_id, &bytes); self.send_terminal_bytes(key.buffer_id, &bytes);
return; return true;
} }
if claims_control { if claims_control {
@ -3946,6 +4128,9 @@ impl EditorState {
} }
_ => {} _ => {}
} }
// The local branch: scrollback, local selection or the menu. The
// child heard nothing.
false
} }
fn is_double_click( fn is_double_click(

View File

@ -769,6 +769,25 @@ impl SemanticRenderState {
cancelled cancelled
} }
/// The live gesture record, if any — parent 48 Q#BP-R4's
/// "live record" test, and the source of the recorded completion.
#[must_use]
pub fn accepted_gesture(&self) -> Option<&AcceptedPanelGesture> {
self.accepted_gesture.as_ref()
}
/// Q#BP-R4: an accepted `Drag` continues the gesture and moves its
/// LAST VALID CONTENT CELL, which is where a release that later
/// lands on chrome gets delivered (R-c2).
///
/// Inert with nothing armed. A drag with no accepted press is a
/// stale tail and must not create a record by writing to one.
pub fn note_gesture_content_cell(&mut self, coord: pmacs_protocol::CellCoord) {
if let Some(gesture) = self.accepted_gesture.as_mut() {
gesture.coord = coord;
}
}
/// Whether a gesture is currently accepted, for assertions. /// Whether a gesture is currently accepted, for assertions.
#[must_use] #[must_use]
pub fn has_accepted_gesture(&self) -> bool { pub fn has_accepted_gesture(&self) -> bool {

View File

@ -501,6 +501,22 @@ impl TerminalManager {
moved || was_dragging moved || was_dragging
} }
/// Whether a view is mid-drag, for parent 48 Q#BP-R4's completion
/// witnesses.
///
/// A local terminal gesture is "finished" exactly when
/// `finish_selection` takes `drag`, so this is the observable that
/// separates a delivered completion from a latch that merely
/// emptied — which is the distinction the framing requires those
/// rows to assert.
#[doc(hidden)]
#[must_use]
pub fn view_is_dragging_for_test(&self, key: TerminalViewKey) -> bool {
self.views
.get(&key)
.is_some_and(|state| state.drag.is_some())
}
/// Clear one view's terminal selection without changing its scroll anchor. /// Clear one view's terminal selection without changing its scroll anchor.
pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool { pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool {
let Some(state) = self.views.get_mut(&key) else { let Some(state) = self.views.get_mut(&key) else {