diff --git a/docs/active-work.md b/docs/active-work.md index 3cc2b2a..f50e8b0 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -295,10 +295,26 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. 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. + - **LANDED: the record is SELF-CONTAINED.** `TerminalLocal` carries + the accepted content **viewport** (ambient geometry is `None` + exactly when a hidden panel needs completing); a press that anchors + nothing no longer arms on either target; and pointer routing goes + through the renderer's own `terminal_projection_size` clamp — a + band wider than `MAX_TERMINAL_COLS` painted fine while every click + inside it resolved to nothing. + - **LANDED: `PanelPointerDisposition` is an ENUM.** As + `{outcome, Option}` the invalid pair — refused, yet + carrying a target — stayed representable inside `editor.rs`. Now + `Refused` holds no target at all. + - **Witnesses: G5k(a)–(d), P1, P2, P3 both legs, P4, P5, P7, P8, P9, + P10, P11, P12.** Each reads a TARGET EFFECT — the child's byte + stream (**exact bytes**, not a count), the terminal drag state, the + document selection, or focus and controller ownership — never the + latch alone. Each bites its own mutation, including G5k's verbatim. + - **Every fixture asserts its own precondition** (the disposition is + `Accepted`, or is `Refused`) because four rows in these rounds + passed vacuously: cells that were out of grid, or that clamped to + byte 0, exercised a refusal instead of the path they named. - **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. diff --git a/src/daemon.rs b/src/daemon.rs index 18e7994..60bc11e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -8332,25 +8332,130 @@ mod tests { ); } - // P10 IS DELIBERATELY ABSENT, and this note is why. - // - // The press path also gates arming on `begin_selection` actually - // starting a drag, so a local terminal press that begins nothing - // records nothing. That gate has NO WITNESS because it has no - // reachable false branch through the daemon: `begin_selection` - // returns `false` only when the session is missing or the cell maps - // to no retained row, and by the time the daemon reaches the press - // path `classify_panel_pointer` has already established that the - // buffer IS the side window's live terminal, while `anchor_at` - // resolves every in-grid cell of a live view — measured, not - // assumed: a press at every content row of the 4-row fixture - // anchors. - // - // The gate is kept as insurance against a future view that can - // refuse, and it is recorded as UNWITNESSED rather than covered by - // a row that would pass whether or not the gate existed. A row at - // an out-of-grid cell looks like it tests this and does not: such a - // press is `Refused` long before the local path. + /// P10 — a LOCAL terminal press that begins no drag does not arm. + /// + /// **The panel is wider than its terminal.** The fixture's band is + /// 80 columns while the child's screen is 20, so columns 20..79 are + /// painted padding: inside accepted panel content, and behind no + /// terminal cell. `anchor_at` refuses them + /// (`coord.col >= row.cells.len()`), `begin_selection` returns + /// `false`, and nothing begins. + /// + /// An earlier round recorded this gate as UNWITNESSED, claiming + /// `anchor_at` resolves every in-grid cell of a live view. That was + /// measured on ROWS and generalised to cells, which is a different + /// statement and a false one. + #[test] + fn r4_p10_a_local_press_that_begins_no_drag_does_not_arm() { + let fid = FrontendId(803); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, false); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + // Column 20 of an 80-column band over a 20-column child. + let padding = pmacs_protocol::CellCoord::new(1, 20); + assert_eq!( + editor + .classify_panel_pointer(fid, buffer_id, padding, press) + .outcome(), + crate::editor::PanelPointerOutcome::Accepted, + "fixture: the cell is ACCEPTED content, so a refusal cannot \ + be what this row observes" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + padding, + press, + none, + ); + + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: no local drag began --- the cell is painted padding" + ); + assert!( + !states[&fid].has_accepted_gesture(), + "so nothing may be armed: the record would name a drag that \ + does not exist, and its completion would have nothing to \ + finish" + ); + } + + /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. + /// + /// A panel deliberately does not inherit the terminal's per-axis PTY + /// caps (Bet B5'): a 4K surface at a small font is legitimately + /// wider than `MAX_TERMINAL_COLS`, and the renderer clamps through + /// `terminal_projection_size` so the band paints. Pointer routing + /// passed the RAW panel width, and `view_status_for_size` refuses + /// anything over the cap — so on exactly those panels a click + /// squarely inside the visible terminal resolved to nothing and the + /// whole band was dead to the mouse while looking perfectly normal. + /// + /// A POSITIVE control: the gesture must land, not merely fail + /// safely. + #[test] + fn r4_p12_a_panel_wider_than_the_terminal_cap_still_routes_pointer_input() { + let fid = FrontendId(805); + let (mut editor, mut states, mut render, panel, buffer_id, _epochs) = + terminal_panel_session(fid, true); + + // Re-declare the surface far wider than MAX_TERMINAL_COLS. + let wide = u32::from(crate::terminal::MAX_TERMINAL_COLS) + 128; + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, wide)); + let epochs = shipped_declaration(&editor, fid, &mut states); + { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("a live panel grid"); + assert!( + grid.cols > u32::from(crate::terminal::MAX_TERMINAL_COLS), + "fixture: the band must actually exceed the terminal cap, \ + got {} columns", + grid.cols + ); + } + + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let _ = child_stream(&editor); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "the press must reach the child on a wide band: routing has \ + to use the SAME projection clamp the renderer uses, or the \ + two disagree and the panel paints while ignoring the mouse" + ); + assert!( + states[&fid].has_accepted_gesture(), + "and it must arm --- this is a positive control, so failing \ + safely is still failing" + ); + let _ = panel; + } /// P11 — a recorded LOCAL completion still runs with the panel /// HIDDEN. @@ -8432,6 +8537,19 @@ mod tests { pmacs_protocol::CellCoord::new(grid.rows, 0) }; let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + let active_before = editor.core.borrow().active_frontend; + let controller_before = editor + .terminal_manager + .borrow() + .controller_view_for_frontend(fid); + assert_eq!( + editor + .classify_panel_pointer(fid, buffer_id, outside, press) + .outcome(), + crate::editor::PanelPointerOutcome::Refused, + "fixture: this press really is refused --- asserted, because \ + every effect assertion below is vacuous if it is not" + ); send_panel( &mut editor, @@ -8450,6 +8568,21 @@ mod tests { "a refused press must send the child NOTHING --- a press it \ receives is one it will expect a release for" ); + assert_eq!( + editor.core.borrow().active_frontend, + active_before, + "and it must not ACTIVATE the panel: a misclassified press \ + focuses before its out-of-range anchor fails, so byte and \ + latch assertions alone stay green while focus has moved" + ); + assert_eq!( + editor + .terminal_manager + .borrow() + .controller_view_for_frontend(fid), + controller_before, + "and it must not claim the terminal CONTROLLER" + ); assert!( !editor .terminal_manager diff --git a/src/editor.rs b/src/editor.rs index d6cbed1..40e0ec7 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -526,21 +526,41 @@ pub enum TerminalGestureRoute { /// 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, +pub enum PanelPointerDisposition { + /// Not addressable as this panel. **Carries no target**, which is + /// the point: as two independent fields, `Refused` plus a resolved + /// target stayed representable inside this module, and the only + /// thing preventing a refused gesture from reaching a target was + /// that no code currently built that pair. + Refused, + /// Claimed by this panel, but deliberately not a content gesture. + Consumed(ResolvedPanelTarget), + /// A content gesture for the resolved target. + Accepted(ResolvedPanelTarget), } impl PanelPointerDisposition { /// The disposition, for the daemon's lifecycle table. #[must_use] pub fn outcome(&self) -> PanelPointerOutcome { - self.outcome + match self { + Self::Refused => PanelPointerOutcome::Refused, + Self::Consumed(_) => PanelPointerOutcome::Consumed, + Self::Accepted(_) => PanelPointerOutcome::Accepted, + } } } /// The panel target a disposition resolved to, derived once. -struct ResolvedPanelTarget { +/// +/// **Public as a type, opaque as a value.** It has to be nameable +/// because `PanelPointerDisposition`'s variants carry it, but every +/// field stays private: the daemon receives a disposition and hands it +/// back, and cannot read the side window, the target kind or the +/// content bounds out of it. That is Q#BP-R4's first fixed fact — the +/// editor is the only authority for the derivation — enforced by +/// visibility rather than by convention. +pub struct ResolvedPanelTarget { side: WindowId, buffer_id: crate::buffer::BufferId, is_terminal: bool, @@ -2963,10 +2983,7 @@ impl EditorState { ) -> PanelPointerDisposition { use pmacs_protocol::MouseKind as PKind; - let refused = PanelPointerDisposition { - outcome: PanelPointerOutcome::Refused, - resolved: None, - }; + let refused = PanelPointerDisposition::Refused; let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { return refused; @@ -2994,13 +3011,13 @@ impl EditorState { side }; - let resolved = Some(ResolvedPanelTarget { + let resolved = ResolvedPanelTarget { side, buffer_id, is_terminal, content_rows, cols: size.cols, - }); + }; let on_chrome = coord.row >= content_rows; let is_wheel = matches!( kind, @@ -3009,7 +3026,7 @@ impl EditorState { // 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 { + let consumed = 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 @@ -3022,16 +3039,16 @@ impl EditorState { // 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 - } + let _ = is_wheel; + is_terminal || matches!(kind, PKind::Down(_) | PKind::Drag(_) | PKind::Move) } else { - PanelPointerOutcome::Accepted + false }; - PanelPointerDisposition { outcome, resolved } + if consumed { + PanelPointerDisposition::Consumed(resolved) + } else { + PanelPointerDisposition::Accepted(resolved) + } } /// Apply a classified panel gesture to its target (Q#BP-R4). @@ -3070,10 +3087,11 @@ impl EditorState { ) -> Option { use pmacs_protocol::MouseKind as PKind; - if disposition.outcome != PanelPointerOutcome::Accepted { + // Only `Accepted` carries a target into an effect, and the type + // is what enforces it: there is no `Refused` value holding one. + let PanelPointerDisposition::Accepted(target) = disposition else { return None; - } - let target = disposition.resolved.as_ref()?; + }; let activates = if target.is_terminal { !matches!(kind, PKind::Move) @@ -3091,7 +3109,17 @@ impl EditorState { // 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(target.content_rows, target.cols); + // THROUGH THE RENDERER'S OWN CLAMP. A panel deliberately does + // not inherit the terminal's per-axis PTY caps (Bet B5'), so + // a 4K surface at a small font is legitimately wider than + // `MAX_TERMINAL_COLS` — and `view_status_for_size` refuses + // anything over that cap. Passing the raw panel width made + // every gesture on such a panel resolve to `None`: a click + // squarely inside the visible terminal did nothing at all, + // while the same panel rendered fine because projection + // clamps and pointer routing did not agree. + let viewport = + terminal_projection_size(CellSize::new(target.content_rows, target.cols)); let key = TerminalViewKey::new(frontend_id, target.side, target.buffer_id); return match self.apply_terminal_gesture( key,