diff --git a/docs/active-work.md b/docs/active-work.md index de0aab8..4935211 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,11 +281,31 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: §5a framing revision 16 and its GUI Stage - 1b revision-13 ownership amendment AWAITING APPROVAL (revision 15 - was reviewed, corrected and folded into this branch); - IMPLEMENTATION STILL PAUSED, now on approval rather than on a - blocker.** §5a's **pre-merge** replay contract was approved at +- **No PR yet. Checkpoint: framing revision 16 APPROVED; + IMPLEMENTATION UNDER WAY.** Read the tip with + `git log --oneline githubsucks/main..HEAD`; no count or SHA is + recorded here, for the reason the §5b lane learned twice. + - **LANDED: Q#BP-R4's pre-effect disposition and the lifecycle + table** — `PanelPointerOutcome` (`Refused`/`Consumed`/`Accepted`) + decided before any target effect, with the resolution carried so + the daemon never re-derives. + - **LANDED: G5k's recorded gesture domain.** The first version was + reviewed and rejected: it drove tails through the mode-sensitive + adapter, which re-reads Shift, scroll position and the child's + modes per event — G5k's named mutation. The press now records + `PanelGestureDomain` (document / terminal-child-with-encoding / + terminal-local) and every tail and completion follows it. + - **Witnesses: G5k(a)–(d), P1, P3 both legs, P4, P5, P7, P8.** Each + reads a TARGET EFFECT — the child's byte stream, the terminal drag + state, or the document selection — never the latch alone. Each + bites its own mutation, including G5k's verbatim. + - **REMAINING, in order:** task 18's pending-release slot and drains, + task 19's four stranding transitions, then the full head-exact gate + and the PR. + - **Two test seams added for this:** an opt-in child-input tap + (`start_send_tap_for_test`) and a drag-state read + (`view_is_dragging_for_test`). Nothing else exposes what the child + actually received, which is what these rows must assert. §5a's **pre-merge** replay contract was approved at revision 12; revisions 14–16 are the post-merge amendment now under review. Revision 13 ruled Q#BP-R3 and blocked the lane on a protocol-bearing mapping generation; **that block is DISCHARGED** — @@ -312,7 +332,8 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. discarded the uncommitted stub edit as obsolete and missed that its **deletion** half was still owed. Removed; exactly one §5a and one §5b remain. - - **Collision, ruled by revision 14 and NOT yet fixed in code:** + - **Collision, ruled by revision 14 and NOW FIXED IN CODE** (see the + checkpoint above; this bullet records what it was): §5b and this lane gave `dispatch_semantic_panel_pointer`'s `bool` different meanings — accepted-as-a-gesture versus consumed-here. A mode-line press therefore **arms the latch** on this branch today. diff --git a/src/daemon.rs b/src/daemon.rs index a0aef23..425c0d7 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1060,16 +1060,20 @@ fn replay_panel_pointer( // A chrome press begins nothing. return; } - let reached_child = editor.apply_panel_pointer(source, &disposition, coord, kind, mods); + // ARMED FROM THE EFFECT RESULT, and only if there was one. + // `None` means the target refused the press --- a terminal + // view that is gone, for instance --- and arming over that + // would record a gesture no tail can deliver. + let Some(domain) = editor.apply_panel_pointer(source, &disposition, coord, kind, mods) + else { + return; + }; 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, + domain, }); } } @@ -1080,7 +1084,18 @@ fn replay_panel_pointer( // pointer is not over content. return; } - let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods); + // THE TAIL FOLLOWS THE RECORD, not a fresh classification. + // The disposition above decided only whether this cell is + // ours and in content; WHERE the drag goes is the domain the + // press resolved (G5k). + let Some(domain) = semantic_states + .get(&source) + .and_then(crate::semantic_render::SemanticRenderState::accepted_gesture) + .map(|record| record.domain) + else { + return; + }; + editor.replay_panel_gesture_in_domain(source, domain, coord, kind, mods); if let Some(state) = semantic_states.get_mut(&source) { state.note_gesture_content_cell(coord); } @@ -1094,11 +1109,25 @@ fn replay_panel_pointer( } match outcome { Outcome::Accepted => { - let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods); - if let Some(state) = semantic_states.get_mut(&source) { + // In content, so the ordinary completion runs --- but + // still in the RECORDED domain, or a mid-gesture mode + // flip would route this release away from the target + // that received the press (G5k). + let record = semantic_states.get_mut(&source).and_then( + crate::semantic_render::SemanticRenderState::consume_accepted_gesture, + ); + if let Some(record) = record { // Taken WITHOUT counting a cancellation, and - // without also running the recorded completion. - let _ = state.consume_accepted_gesture(); + // exactly one completion: this is the ordinary + // one, so `complete_panel_gesture` must not also + // run (P5). + editor.replay_panel_gesture_in_domain( + source, + record.domain, + coord, + kind, + mods, + ); } } Outcome::Consumed => { @@ -7376,7 +7405,7 @@ mod tests { button: pmacs_protocol::MouseButton::Left, coord: pmacs_protocol::CellCoord::new(0, 0), buffer_id: buffer_a, - reached_child: false, + domain: crate::editor::PanelGestureDomain::Document { window: panel_a }, }, ); @@ -7813,6 +7842,531 @@ mod tests { ); } + /// A panel session whose side window holds a live TERMINAL, with + /// the send tap armed. + /// + /// Legacy, deliberately: reading the live mapping generation + /// ADVANCES the key, and §5b wired a key advance to cancel the live + /// gesture, so a mapped fixture destroys the gesture these rows are + /// about. + type TerminalPanelFixture = ( + crate::editor::EditorState, + HashMap, + HashMap, + crate::window::WindowId, + crate::buffer::BufferId, + (u64, u64), + ); + + fn terminal_panel_session(fid: FrontendId, reporting: bool) -> TerminalPanelFixture { + use crate::terminal::TerminalSpec; + + let (editor, mut states, 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; + } + editor + .terminal_manager + .borrow_mut() + .set_mouse_reporting_for_test(terminal_buffer, reporting); + editor.terminal_manager.borrow().start_send_tap_for_test(); + let epochs = shipped_declaration(&editor, fid, &mut states); + (editor, states, render, panel, terminal_buffer, epochs) + } + + /// Everything the child received, in order. + fn child_stream(editor: &crate::editor::EditorState) -> Vec> { + editor + .terminal_manager + .borrow() + .take_send_tap_for_test() + .into_iter() + .map(|(_, bytes)| bytes) + .collect() + } + + /// Send one legacy panel gesture. + #[expect( + clippy::too_many_arguments, + reason = "one call shape for every G5k leg" + )] + fn send_panel( + editor: &mut crate::editor::EditorState, + states: &mut HashMap, + render: &mut HashMap, + fid: FrontendId, + epochs: (u64, u64), + buffer_id: crate::buffer::BufferId, + coord: pmacs_protocol::CellCoord, + kind: pmacs_protocol::MouseKind, + mods: pmacs_protocol::Modifiers, + ) { + let mut event = arm_pointer(PanelArm::Legacy, fid, epochs, buffer_id, 0, coord, kind); + if let FrontendEvent::PanelPointer { mods: slot, .. } = &mut event { + *slot = mods; + } + dispatch_panel_event(editor, fid, LEGACY_PANEL_VERSION, states, render, event); + } + + // ----------------------------------------------------------------- + // G5k — the terminal gesture-domain matrix. + // + // Four legs. In each, the press resolves a domain and then the + // condition that chose it REVERSES before the tail. The gesture must + // finish in the domain it began in; re-reading Shift, the scroll + // position or the child's modes per event is the framing's named + // mutation, and it either strands a child press or sends the child + // an `Up` for a `Down` it never saw. + // ----------------------------------------------------------------- + + /// G5k(a) — child press, then the child turns REPORTING OFF: the + /// release still reaches the child, and no local selection forms. + #[test] + fn g5k_a_reporting_off_mid_gesture_still_releases_to_the_child() { + let fid = FrontendId(794); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + let after_press = child_stream(&editor); + assert_eq!(after_press.len(), 1, "fixture: the press reached the child"); + assert!( + states[&fid] + .accepted_gesture() + .is_some_and(crate::semantic_render::AcceptedPanelGesture::reached_child), + "fixture: the record says the child owns this gesture" + ); + + // The child stops reporting MID-GESTURE. + editor + .terminal_manager + .borrow_mut() + .set_mouse_reporting_for_test(buffer_id, false); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert_eq!( + child_stream(&editor).len(), + 1, + "the release must still reach the child: it holds a button \ + down that only this release can lift, and re-reading the \ + modes here is what strands it" + ); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and no local selection was started behind the child's back" + ); + } + + /// G5k(b) — child press, then SHIFT is held before the release: the + /// release still reaches the child. + #[test] + fn g5k_b_shift_before_the_release_still_releases_to_the_child() { + let fid = FrontendId(795); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let shift = pmacs_protocol::Modifiers::SHIFT; + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor).len(), + 1, + "fixture: press reached the child" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + shift, + ); + + assert_eq!( + child_stream(&editor).len(), + 1, + "Shift is the LOCAL-HANDLING override for a NEW gesture, not \ + a way to abandon one already delivered to the child" + ); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and no local selection was started" + ); + } + + /// G5k(c) — SHIFT press starts a LOCAL gesture; releasing without + /// Shift finishes locally and sends the child nothing. + #[test] + fn g5k_c_a_shift_started_gesture_finishes_locally() { + let fid = FrontendId(796); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let shift = pmacs_protocol::Modifiers::SHIFT; + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + shift, + ); + assert!( + child_stream(&editor).is_empty(), + "fixture: a Shift press is handled locally" + ); + assert!( + editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: it began a local drag" + ); + + // Shift released before the button. + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert!( + child_stream(&editor).is_empty(), + "the child must receive NOTHING: it never saw the press, so \ + an Up here is a release for a Down that never happened" + ); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and the local drag finishes" + ); + } + + /// G5k(d) — a press taken locally because reporting was OFF stays + /// local when the child turns reporting ON mid-gesture. + #[test] + fn g5k_d_reporting_on_mid_gesture_does_not_capture_a_local_gesture() { + let fid = FrontendId(797); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, false); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert!( + child_stream(&editor).is_empty(), + "fixture: reporting is off, so the press is local" + ); + assert!( + editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: it began a local drag" + ); + + // The child turns reporting ON mid-gesture. + editor + .terminal_manager + .borrow_mut() + .set_mouse_reporting_for_test(buffer_id, true); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert!( + child_stream(&editor).is_empty(), + "the child must receive NOTHING --- it never saw this press" + ); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and the local gesture still finishes locally" + ); + } + + /// P3, reporting leg — a chrome release still delivers the child's + /// release, in the RECORDED encoding. + #[test] + fn r4_p3_child_a_chrome_release_still_reaches_the_reporting_child() { + let fid = FrontendId(798); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let chrome = { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("grid"); + pmacs_protocol::CellCoord::new(grid.rows.saturating_sub(1), 0) + }; + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor).len(), + 1, + "fixture: press reached the child" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + chrome, + release, + none, + ); + + let sent = child_stream(&editor); + assert_eq!( + sent.len(), + 1, + "the chrome release must still terminate the child's gesture \ + --- the terminal path returns before replay, so without the \ + recorded completion the child holds the button forever" + ); + assert!( + !states[&fid].has_accepted_gesture(), + "and the record is taken" + ); + } + + /// P5 — an accepted in-content release delivers EXACTLY ONE child + /// release, never the ordinary one plus a record-driven one. + #[test] + fn r4_p5_an_accepted_release_reaches_the_child_exactly_once() { + let fid = FrontendId(799); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor).len(), + 1, + "fixture: press reached the child" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert_eq!( + child_stream(&editor).len(), + 1, + "EXACTLY ONE release: the in-content path already completed \ + the gesture, so running the recorded completion as well \ + sends the child two Ups for one Down" + ); + } + + /// P4 — a REFUSED release performs no completion and retains the + /// record, so a later authoritative cancellation can still end it. + #[test] + fn r4_p4_a_refused_release_neither_completes_nor_takes_the_record() { + let fid = FrontendId(800); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let outside = { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("grid"); + pmacs_protocol::CellCoord::new(grid.rows, 0) + }; + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor).len(), + 1, + "fixture: press reached the child" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + outside, + release, + none, + ); + + assert!( + child_stream(&editor).is_empty(), + "a refused release must deliver NO completion --- the daemon \ + cannot tell it concerns this gesture at all" + ); + assert!( + states[&fid].has_accepted_gesture(), + "and it must RETAIN the record, so an authoritative \ + cancellation can still end the gesture properly" + ); + } + /// 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 deed2f8..b9e46e6 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -432,6 +432,82 @@ pub enum PanelPointerOutcome { Accepted, } +/// The domain a live panel gesture was RESOLVED INTO at its accepted +/// press, and which every tail and its completion must follow +/// (parent 48 G5k). +/// +/// **Re-deciding this per event is G5k's forbidden mutation.** The +/// terminal adapter picks between reporting to the child and handling +/// locally by reading Shift, the scrollback position and the child's +/// current mouse modes — all three of which can change mid-gesture. +/// A press reported to the child followed by a release re-evaluated +/// after the child turned reporting off leaves that child holding a +/// button down forever; the reverse transition sends a child an `Up` +/// for a `Down` it never saw. +/// +/// So the press records the contract and the tails obey it. The +/// encoding travels with it for the same reason: the report must be +/// framed the way the press was framed, not the way the child would +/// ask for it now. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PanelGestureDomain { + /// A document panel; every write is addressed to `window`. + Document { + /// The side window the gesture belongs to. + window: WindowId, + }, + /// A terminal panel whose press REACHED THE CHILD. + TerminalChild { + /// The side window the gesture belongs to. + window: WindowId, + /// The terminal buffer the child is attached to. + buffer_id: crate::buffer::BufferId, + /// The modes the press was encoded under, replayed verbatim. + modes: crate::terminal::screen::TerminalModes, + }, + /// A terminal panel handled LOCALLY — Shift, reporting off, or a + /// scrolled-back view at press time. + TerminalLocal { + /// The side window the gesture belongs to. + window: WindowId, + /// The terminal buffer whose local selection is being built. + buffer_id: crate::buffer::BufferId, + }, +} + +impl PanelGestureDomain { + /// The side window this gesture is addressed to. + #[must_use] + pub fn window(&self) -> WindowId { + match self { + Self::Document { window } + | Self::TerminalChild { window, .. } + | Self::TerminalLocal { window, .. } => *window, + } + } + + /// Whether the press reached the child, so a release is OWED to it. + #[must_use] + pub fn reached_child(&self) -> bool { + matches!(self, Self::TerminalChild { .. }) + } +} + +/// Which way the shared terminal adapter routed one gesture. +/// +/// Returned rather than recomputed so a panel press can RECORD the +/// route it actually took (G5k). Other callers of the adapter ignore +/// it — they have no gesture latch to bind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalGestureRoute { + /// Nothing ran: the view is gone or has no status at that size. + None, + /// Reported to the child, under these modes. + Child(crate::terminal::screen::TerminalModes), + /// Handled locally — scrollback, local selection, or the menu. + Local, +} + /// A classified panel gesture: the outcome, and the resolution it was /// decided from. /// @@ -2952,10 +3028,11 @@ impl EditorState { /// Bare hover neither focuses nor claims, on either kind. /// /// **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. + /// returns without effect: the claim was the effect. + /// + /// Returns the [`PanelGestureDomain`] the effect RESOLVED INTO, so + /// an accepted press can record the contract its tails must follow + /// (G5k). `None` means nothing ran and nothing may be recorded. pub fn apply_panel_pointer( &mut self, frontend_id: FrontendId, @@ -2963,15 +3040,13 @@ impl EditorState { coord: CellCoord, kind: pmacs_protocol::MouseKind, mods: pmacs_protocol::Modifiers, - ) -> bool { + ) -> Option { use pmacs_protocol::MouseKind as PKind; if disposition.outcome != PanelPointerOutcome::Accepted { - return false; + return None; } - let Some(target) = disposition.resolved.as_ref() else { - return false; - }; + let target = disposition.resolved.as_ref()?; let activates = if target.is_terminal { !matches!(kind, PKind::Move) @@ -2991,18 +3066,95 @@ impl EditorState { // cell and put every clamp one row out. 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( + return match self.apply_terminal_gesture( key, viewport, coord, kind, mods, (coord.row, coord.col), - ); + ) { + TerminalGestureRoute::None => None, + TerminalGestureRoute::Child(modes) => Some(PanelGestureDomain::TerminalChild { + window: target.side, + buffer_id: target.buffer_id, + modes, + }), + TerminalGestureRoute::Local => Some(PanelGestureDomain::TerminalLocal { + window: target.side, + buffer_id: target.buffer_id, + }), + }; } self.replay_panel_document_gesture(frontend_id, target.side, coord, kind, mods); - false + Some(PanelGestureDomain::Document { + window: target.side, + }) + } + + /// Replay a tail or a completion in the gesture's RECORDED domain + /// (parent 48 G5k). + /// + /// **Nothing here re-decides the domain.** The press resolved it and + /// the record carries it, so Shift going down mid-drag, the view + /// scrolling back, or the child flipping its mouse modes cannot move + /// a live gesture from one target to another. Re-evaluating those + /// per event is G5k's forbidden mutation, and it strands a child + /// press in one direction and fabricates an unmatched child release + /// in the other. + /// + /// **The window is read from the record too.** An earlier version + /// re-derived the current side window and returned when it had + /// changed — which is exactly the set of transitions the stranding + /// rows must terminate, so the completion they need would have been + /// the one thing that refused to run. + pub fn replay_panel_gesture_in_domain( + &mut self, + frontend_id: FrontendId, + domain: PanelGestureDomain, + coord: CellCoord, + kind: pmacs_protocol::MouseKind, + mods: pmacs_protocol::Modifiers, + ) { + match domain { + PanelGestureDomain::Document { window } => { + self.replay_panel_document_gesture(frontend_id, window, coord, kind, mods); + } + PanelGestureDomain::TerminalChild { + window, + buffer_id, + modes, + } => { + // Encoded under the RECORDED modes, and gated on + // nothing: not Shift, not the scroll position, not the + // child's current modes. Those are precisely the three + // inputs G5k forbids re-reading. + let key = TerminalViewKey::new(frontend_id, window, buffer_id); + let _ = key; + if let Some(bytes) = crate::terminal::input::encode_mouse(kind, coord, mods, modes) + { + self.send_terminal_bytes(buffer_id, &bytes); + } + } + PanelGestureDomain::TerminalLocal { window, buffer_id } => { + let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { + return; + }; + let viewport = CellSize::new(size.rows.saturating_sub(1), size.cols); + let key = TerminalViewKey::new(frontend_id, window, buffer_id); + let mut manager = self.terminal_manager.borrow_mut(); + match kind { + pmacs_protocol::MouseKind::Drag(pmacs_protocol::MouseButton::Left) => { + let _ = manager.update_selection(key, viewport, coord); + } + pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left) => { + let _ = manager.finish_selection(key, viewport, coord); + } + _ => {} + } + } + } } /// Deliver the RECORDED completion for a gesture the ordinary path @@ -3014,69 +3166,23 @@ impl EditorState { /// 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. + /// Delivery goes through the gesture's recorded domain at its last + /// valid content cell (R-c2), so the release is framed the way its + /// press was. 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); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + self.replay_panel_gesture_in_domain( + frontend_id, + record.domain, + record.coord, + release, + mods, + ); } /// Replay one accepted gesture into a DOCUMENT panel (parent 48). @@ -4047,13 +4153,12 @@ 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. + /// Returns WHICH WAY it routed the gesture (G5k). /// - /// 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. + /// The route has to be measured where the branch is taken, not + /// predicted from the modes beforehand: the report is gated on five + /// conditions and `encode_mouse` can still decline. A panel press + /// records the answer and binds its whole gesture to it. fn apply_terminal_gesture( &mut self, key: TerminalViewKey, @@ -4062,12 +4167,12 @@ impl EditorState { kind: TerminalMouseKind, modifiers: TerminalModifiers, global: (u32, u32), - ) -> bool { + ) -> TerminalGestureRoute { 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 false; + return TerminalGestureRoute::None; }; let modes = manager.modes_for_view(key).unwrap_or_default(); let screen_size = manager.screen_size_for_view(key).unwrap_or(viewport_size); @@ -4097,7 +4202,7 @@ impl EditorState { self.claim_terminal_controller(key); } self.send_terminal_bytes(key.buffer_id, &bytes); - return true; + return TerminalGestureRoute::Child(modes); } if claims_control { @@ -4130,7 +4235,7 @@ impl EditorState { } // The local branch: scrollback, local selection or the menu. The // child heard nothing. - false + TerminalGestureRoute::Local } fn is_double_click( diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 408940e..4758b9c 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -174,11 +174,24 @@ pub struct AcceptedPanelGesture { pub coord: pmacs_protocol::CellCoord, /// The panel buffer the gesture belongs to. pub buffer_id: BufferId, - /// Whether the press actually REACHED the child, for a reporting - /// terminal. A release is owed only if a press was delivered; - /// synthesising one for a press the child never saw is the same - /// defect in the other direction. - pub reached_child: bool, + /// The domain the accepted press RESOLVED INTO, and which every + /// tail and the completion must follow (G5k). + /// + /// This replaces a bare `reached_child` flag. The flag said whether + /// a release was owed to a child but not how to frame it, nor which + /// window to address, so a tail had to re-derive both from state + /// that moves mid-gesture — Shift, the scroll position, the child's + /// modes, and the panel's own identity. Recording the resolution + /// is what makes the tail independent of all four. + pub domain: crate::editor::PanelGestureDomain, +} + +impl AcceptedPanelGesture { + /// Whether the press reached the child, so a release is OWED to it. + #[must_use] + pub fn reached_child(&self) -> bool { + self.domain.reached_child() + } } /// Owns one `semantic_render` session's projection state: the last diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs index 516c5d7..abcc11c 100644 --- a/src/terminal/screen.rs +++ b/src/terminal/screen.rs @@ -619,6 +619,22 @@ impl TerminalScreen { pub fn modes(&self) -> TerminalModes { self.modes } + + /// Turn SGR mouse reporting on or off, for parent 48 G5k. + /// + /// The domain matrix needs a child that reports and then STOPS + /// reporting mid-gesture. Driving that through the ANSI parser + /// would make the row depend on escape-sequence handling it is not + /// testing; this sets the two modes the adapter actually reads. + #[doc(hidden)] + pub fn set_mouse_reporting_for_test(&mut self, enabled: bool) { + self.modes.mouse_sgr = enabled; + self.modes.mouse_tracking = if enabled { + MouseTrackingMode::Button + } else { + MouseTrackingMode::Off + }; + } /// Return whether the published active screen is alternate. #[must_use] pub fn alternate_active(&self) -> bool { diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 47fae56..2eddab0 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -247,10 +247,21 @@ pub(super) struct EscapeCache { pub(super) reported_invalid: Option, } +/// What each child was sent, in order, while the G5k tap is armed. +type ChildSendLog = Vec<(BufferId, Vec)>; + /// Owns the one-buffer/one-process/one-screen terminal registry. #[derive(Default)] pub struct TerminalManager { pub(super) sessions: HashMap, + /// An OPT-IN tap on child input, for parent 48 G5k's witnesses. + /// + /// The gesture-domain rows have to read what the child actually + /// received --- a release delivered in the recorded encoding, and + /// exactly one of it --- and no other seam exposes that. Off by + /// default, so production pays one `is_some` check per send and + /// never accumulates. + send_tap: RefCell>, /// Total escape-key parses performed (Q#TC4c observability). escape_parses: u64, process_to_buffer: HashMap, @@ -564,11 +575,35 @@ impl TerminalManager { .sessions .get(&buffer_id) .ok_or(TerminalError::NotTerminal(buffer_id))?; + if let Some(tap) = self.send_tap.borrow_mut().as_mut() { + tap.push((buffer_id, bytes.to_vec())); + } supervisor .write_stdin(session.process_id, bytes) .map_err(TerminalError::Process) } + /// Begin recording child input for G5k's witnesses. + #[doc(hidden)] + pub fn start_send_tap_for_test(&self) { + *self.send_tap.borrow_mut() = Some(Vec::new()); + } + + /// Take everything sent to children since the tap was started. + /// + /// Returns the sends in ORDER, because "one release, not two" and + /// "the old gesture's release before the new gesture's press" are + /// both ordering claims that a set cannot express. + #[doc(hidden)] + #[must_use] + pub fn take_send_tap_for_test(&self) -> ChildSendLog { + self.send_tap + .borrow_mut() + .as_mut() + .map(std::mem::take) + .unwrap_or_default() + } + /// Resolve this terminal's effective escape chord, parsing at most /// once per `(terminal, config epoch)` (Q#TC4c). /// diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 44aac29..8e47474 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -525,6 +525,14 @@ impl TerminalManager { state.selection.take().is_some() || state.drag.take().is_some() } + /// Turn SGR mouse reporting on or off for one session (G5k). + #[doc(hidden)] + pub fn set_mouse_reporting_for_test(&mut self, buffer_id: BufferId, enabled: bool) { + if let Some(session) = self.sessions.get_mut(&buffer_id) { + session.screen.set_mouse_reporting_for_test(enabled); + } + } + /// Current child input modes for one session. #[must_use] pub fn modes_for_view(&self, key: TerminalViewKey) -> Option {