diff --git a/src/daemon.rs b/src/daemon.rs index cb792f0..a0aef23 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1019,6 +1019,112 @@ fn panel_event_epochs_are_current( .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, + 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. /// /// **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 /// validated and before any mutation: the family decides which variant /// 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 -/// that. Three rules, and each closes its own hole: -/// -/// * only a left `Down` ARMS — a right press opens a menu and ends -/// 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, - 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(); - } - _ => {} - } -} - +/// **Re-homed here.** This paragraph documented THIS function but sat +/// above `update_accepted_gesture`, whose own doc followed it in the +/// same block. Deleting that function's doc with it made the +/// misplacement visible. fn peer_uses_mapped_panel_family(session_registry: &SessionRegistry, source: FrontendId) -> bool { session_registry .session_state(source) @@ -2605,21 +2672,15 @@ fn handle_dispatcher_event( // armed gesture, so the authority loss that // should have ended it finds nothing, and the // child holds the button down forever. - if editor - .dispatch_semantic_panel_pointer(source, buffer_id, coord, kind, mods) - { - update_accepted_gesture( - semantic_states, - source, - kind, - coord, - buffer_id, - // Whether the press reached a child is - // replay's to know; until replay exists - // no press does. - false, - ); - } + replay_panel_pointer( + editor, + semantic_states, + source, + buffer_id, + coord, + kind, + mods, + ); } } FrontendEvent::PanelPointerMapped { @@ -2676,18 +2737,15 @@ fn handle_dispatcher_event( // survive the ladder; it does not make a // surviving one land, so this arm needs the same // gate. - if editor - .dispatch_semantic_panel_pointer(source, buffer_id, coord, kind, mods) - { - update_accepted_gesture( - semantic_states, - source, - kind, - coord, - buffer_id, - false, - ); - } + replay_panel_pointer( + editor, + semantic_states, + source, + buffer_id, + coord, + kind, + mods, + ); } } FrontendEvent::Pointer { @@ -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. fn panel_buffer_and_outside_coord( editor: &crate::editor::EditorState, diff --git a/src/editor.rs b/src/editor.rs index adc925c..deed2f8 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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, +} + +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 /// target kind. #[derive(Clone, PartialEq, Eq, Debug)] @@ -2769,7 +2825,8 @@ impl EditorState { .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 /// 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 /// 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 /// **press** focuses any panel — that is click-to-focus, and /// `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. /// Bare hover neither focuses nor claims, on either kind. /// - /// **Replay is out of scope in Stage 2B-2.** Driving selection, - /// listview rows, or child SGR reporting is parent acceptance 48, - /// which needs the GPU band and lands in Stage 2B-3. - /// - /// Returns whether the gesture was accepted. - /// - /// `#[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( + /// **Only `Accepted` reaches a target.** A `Consumed` disposition + /// returns without effect: the claim was the effect. Returns whether + /// the gesture reached a CHILD, which is what the accepted-gesture + /// latch records — the framing requires arming "from the effect + /// result", so this is measured rather than assumed. + pub fn apply_panel_pointer( &mut self, frontend_id: FrontendId, - buffer_id: crate::buffer::BufferId, + disposition: &PanelPointerDisposition, 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 { + if disposition.outcome != PanelPointerOutcome::Accepted { + return false; + } + let Some(target) = disposition.resolved.as_ref() else { 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 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 { + let activates = if target.is_terminal { !matches!(kind, PKind::Move) } else { matches!(kind, PKind::Down(_)) }; if activates { 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; } - if is_terminal { + if target.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; + let viewport = CellSize::new(target.content_rows, target.cols); + let key = TerminalViewKey::new(frontend_id, target.side, target.buffer_id); + return self.apply_terminal_gesture( + key, + viewport, + coord, + kind, + mods, + (coord.row, coord.col), + ); } - self.replay_panel_document_gesture(frontend_id, side, coord, kind, mods); - true + self.replay_panel_document_gesture(frontend_id, target.side, coord, kind, mods); + 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). @@ -3872,6 +4047,13 @@ impl EditorState { /// A second copy of this precedence in the GPU lane is exactly how /// Shift-drag or scrolled-back selection would silently diverge /// 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( &mut self, key: TerminalViewKey, @@ -3880,12 +4062,12 @@ impl EditorState { kind: TerminalMouseKind, modifiers: TerminalModifiers, global: (u32, u32), - ) { + ) -> bool { let shift = modifiers.contains(TerminalModifiers::SHIFT); let (at_bottom, modes, screen_size) = { let mut manager = self.terminal_manager.borrow_mut(); 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 screen_size = manager.screen_size_for_view(key).unwrap_or(viewport_size); @@ -3915,7 +4097,7 @@ impl EditorState { self.claim_terminal_controller(key); } self.send_terminal_bytes(key.buffer_id, &bytes); - return; + return true; } 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( diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 2f429f4..408940e 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -769,6 +769,25 @@ impl SemanticRenderState { 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. #[must_use] pub fn has_accepted_gesture(&self) -> bool { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 414d607..44aac29 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -501,6 +501,22 @@ impl TerminalManager { 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. pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool { let Some(state) = self.views.get_mut(&key) else {