diff --git a/docs/active-work.md b/docs/active-work.md index 9fb49bb..b6ba1f8 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,8 +281,13 @@ from #171 and #215. **`githubsucks/panel-mapping-generation` is the authoritative tip.** Recover with `git fetch githubsucks && git checkout panel-mapping-generation`. -- **No PR yet. Checkpoint: framing revision 16 (§5b) APPROVED - 2026-08-15; NO IMPLEMENTATION.** +- **No PR yet. Checkpoint: `3c06176`** — framing revision 16 (§5b) + APPROVED 2026-08-15 at `7e85a6f`, then eleven implementation commits: + wire shapes and pins (G0), the authoritative key with its terminal + half (G1–G4), outbound family selection, inbound family gating, the + GPU's negotiated family enum, and the nine family-gate rows + (G6a–b, G7a–b, G8). **Full `--protocol` gate green at `3c06176`.** + In progress since, uncommitted: G5's accepted-gesture latch. - **PROTOCOL-BEARING — v25, and it runs alone.** `ADVERTISED_PROTOCOL_VERSION` stays pinned at **20**. - **Why it exists.** A `PanelPointer` names a cell and nothing on the @@ -384,6 +389,22 @@ from #171 and #215. invalidations and every document, terminal, complete producer-latch and click-chain effect through real call sites. No dead classification helper is added on a base without replay. +- **G5's latch lands here under SUBSTRATE names, not the deferred + IDs.** G5a needs something to cancel, so the per-frontend + `AcceptedPanelGesture` latch and its arming from the accepted inbound + arms are this slice's. Writing that code decides which events arm, + that an ordinary `Up` consumes without counting as a cancellation, + and that the latch is per frontend — pinned as `g5_substrate_*` + rather than as G5c/G5d/G5g/G5p, which stay on the replay lane per + §5b's split table. **Claiming an ID in two branches is the merge + hazard**, and this file already records duplicate ids surviving a + clean merge once. Cancellations are **counted, not queued**: the + record queue is what replay drains, and landing it here would grow + one entry per cancelled drag with no reader. The other G5b + transitions (panel epoch, buffer replacement, same-size geometry, + detach) deliberately leave the latch armed on this base — inert + while nothing consumes it, and a defect only once replay supplies + effects, in the branch that owns the row. - **Routing and effects are separate controls.** This slice can prove v24/v25 events reach the existing focus path, but selection, terminal reporting, stable-generation drag continuation, two-tick @@ -450,6 +471,15 @@ from #171 and #215. three of them lost to these two signatures. U9's synthetic-load control remains unrun and is the cheapest thing that would either implicate load or clear it. +- **Rustdoc split, FOUR occurrences on this branch** (`screen_size`, + `peer_may_send_panel_events`, `send_panel_pointer`, and + `SemanticRenderState`). Always the same mechanism: inserting an item + at what reads as a blank gap when the lines directly above are the + NEXT item's doc comment, which the insertion then adopts. The fourth + also stole an `#[allow(clippy::struct_excessive_bools)]`, silently + un-suppressing a lint on the struct that needed it. Three were caught + by the user in review, one by a `missing_docs` warning. **The check + is to look UP from the insertion point before writing, not down.** - **Gates:** the four `bottom_panel_*` suites, the GUI 1a wire suite, `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, and **`--protocol`**. diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index fd5349d..685d3e5 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -2460,6 +2460,43 @@ lane must run the whole G5 matrix through real document and terminal call sites. This is the same producer-emits/receiver-discards blind spot that forced the replay prerequisite in the first place. +#### The latch substrate that lands here, and what it does not claim + +G5a needs something to cancel, so the accepted-gesture latch itself — +`AcceptedPanelGesture` and the per-frontend slot on +`SemanticRenderState` — lands on this slice, armed from the daemon's +accepted inbound arms. Writing that code decides three things the +deferred rows later assert about, so those decisions are pinned here +under **substrate** names (`g5_substrate_*`) rather than under G5c, +G5d, G5g or G5p: + +- which events arm and which consume (`update_accepted_gesture`); +- that an ordinary `Up` consumes **without** counting as a + cancellation; +- that the latch is per frontend, so one frontend's loss cannot empty + another's. + +The framing rows keep their IDs on `panel-pointer-replay`, because each +of them asserts something about a synthetic release or a real drag +continuation that does not exist on this base. Two IDs claimed in two +branches is the merge hazard; substrate names avoid it. + +Two consequences are recorded rather than fixed: + +- **Cancellations are counted, not queued.** A queue of records is what + replay drains to deliver each release; landing it here would grow one + entry per cancelled drag with nothing ever draining it. A saturating + count is bounded and still separates a consume from a cancellation. +- **The other G5b transitions leave the latch armed on this base.** A + panel-epoch change, a buffer replacement, a same-size geometry change + and a detach all strand a live gesture — its release can never be + accepted. That is inert here, because nothing consumes the latch, and + becomes a defect exactly when replay gives it effects, in the branch + that owns the row. `Absent` is wired anyway, because + `publish_absent_panel` already clears input authority two lines + later and leaving that one out would be an inconsistency inside a + single function rather than a clean deferral. + ### Coherence impact (`COHERENCE.md` §20) - **Journey steps touched: 5** (document-panel editing and selection) diff --git a/src/daemon.rs b/src/daemon.rs index 0ab1bce..68e2b4e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1029,6 +1029,50 @@ 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(); + } + _ => {} + } +} + fn peer_uses_mapped_panel_family(session_registry: &SessionRegistry, source: FrontendId) -> bool { session_registry .session_state(source) @@ -2507,6 +2551,17 @@ fn handle_dispatcher_event( ) { editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); + 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, + ); } } FrontendEvent::PanelPointerMapped { @@ -2551,6 +2606,14 @@ fn handle_dispatcher_event( ) { editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); + update_accepted_gesture( + semantic_states, + source, + kind, + coord, + buffer_id, + false, + ); } } FrontendEvent::Pointer { @@ -6670,6 +6733,226 @@ mod tests { ); } + /// §5b G5a — the key advancing RAISES cancellation, without waiting + /// for another pointer event. + /// + /// The daemon-side half. The EFFECTS — clearing an empty selection, + /// clearing the click chain, delivering the child's release — are + /// owed by `panel-pointer-replay`, which is the only branch where + /// replay exists; this pins the trigger and the record it produces. + #[test] + fn g5a_a_mapping_change_cancels_a_live_gesture_with_no_further_event() { + let fid = FrontendId(770); + let (mut editor, mut semantic_states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let generation = stamped_generation(&semantic_states, fid); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut render, + mapped_pointer(fid, epochs, buffer_id, generation), + ); + assert!( + semantic_states[&fid].has_accepted_gesture(), + "fixture: an accepted left press arms the latch" + ); + + // A FOREIGN edit — nothing this frontend did, and no further + // pointer event. The key moves on the next read. + { + let core = editor.core.borrow(); + let registry = core.registry.clone(); + let mut reg = registry.borrow_mut(); + reg.get_mut(buffer_id) + .expect("panel buffer") + .set_generated_contents(b"moved\n") + .expect("a plain content change"); + } + let snapshot = editor.panel_mapping_snapshot(fid); + let state = semantic_states.get_mut(&fid).expect("projection"); + let advanced = state.panel_mapping_generation(snapshot); + assert_ne!(advanced, Some(generation), "the key must have moved"); + + assert!( + !state.has_accepted_gesture(), + "the advance itself ends the gesture — waiting for another \ + event loses the race where the successor frame lands first" + ); + assert_eq!( + state.panel_gesture_cancellations(), + 1, + "and it records exactly one cancellation for replay to \ + terminate — an ordinary consume would leave this at zero" + ); + } + + /// §5b — SUBSTRATE for the framing's G5c/G5d/G5g: the latch's + /// arming rules, as this branch's inbound arms decide them. + /// + /// Deliberately not named for those rows. G5c, G5d and G5g are + /// `panel-pointer-replay`'s per §5b's split table, and each asserts + /// something about a synthetic release that does not exist on this + /// branch. What is provable here is narrower and still worth + /// pinning, because `update_accepted_gesture` decides it: which + /// events arm, which consume, and that a consume is not a + /// cancellation. + #[test] + fn g5_substrate_only_an_accepted_left_press_arms_and_a_release_consumes() { + let fid = FrontendId(771); + let (mut editor, mut semantic_states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let generation = stamped_generation(&semantic_states, fid); + let mut send = |kind, semantic_states: &mut HashMap<_, _>, editor: &mut _| { + let mut event = mapped_pointer(fid, epochs, buffer_id, generation); + if let FrontendEvent::PanelPointerMapped { kind: k, .. } = &mut event { + *k = kind; + } + dispatch_panel_event( + editor, + fid, + PROTOCOL_VERSION, + semantic_states, + &mut render, + event, + ); + }; + + // G5d — a release with nothing armed is INERT. + send( + pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left), + &mut semantic_states, + &mut editor, + ); + assert!( + !semantic_states[&fid].has_accepted_gesture(), + "a stale release terminates nothing, because nothing began" + ); + + // G5g — a RIGHT press does not arm. + send( + pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Right), + &mut semantic_states, + &mut editor, + ); + assert!( + !semantic_states[&fid].has_accepted_gesture(), + "a right press opens a menu and ends there — arming would \ + manufacture a delayed release at the next authority loss" + ); + + // G5g — nor does a wheel step. + send( + pmacs_protocol::MouseKind::ScrollDown, + &mut semantic_states, + &mut editor, + ); + assert!(!semantic_states[&fid].has_accepted_gesture()); + + // Arming, then G5c — an ordinary release CONSUMES. + send( + pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + &mut semantic_states, + &mut editor, + ); + assert!(semantic_states[&fid].has_accepted_gesture()); + send( + pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left), + &mut semantic_states, + &mut editor, + ); + assert!( + !semantic_states[&fid].has_accepted_gesture(), + "leaving it armed lets a later invalidation duplicate the \ + release for a button already up" + ); + assert_eq!( + semantic_states[&fid].panel_gesture_cancellations(), + 0, + "an ordinary release is not a cancellation — counting it \ + would make replay deliver a release for a button the user \ + already lifted" + ); + } + + /// §5b — SUBSTRATE for the framing's G5p: per-frontend gesture + /// ownership. + /// + /// A and B hold gestures on their own panels; B losing authority + /// cancels B exactly once and leaves A's gesture intact. One global + /// latch would make either frontend's lifecycle cancel or erase the + /// other's. + /// + /// G5p itself is replay's per §5b's split table — it requires A's + /// next valid Drag to still APPLY, which needs replay. The + /// ownership shape underneath it is decided here, by putting the + /// latch on `SemanticRenderState` rather than beside the dispatcher, + /// so it is pinned here. + #[test] + fn g5_substrate_one_frontends_authority_loss_leaves_anothers_gesture_alone() { + let fid_a = FrontendId(772); + let fid_b = FrontendId(773); + let (mut editor_a, mut states_a, mut render_a, _doc_a, panel_a, epochs_a) = + panel_session_at(PROTOCOL_VERSION, fid_a); + let buffer_a = editor_a.core.borrow().windows[&panel_a].buffer_id; + let generation_a = stamped_generation(&states_a, fid_a); + + // B lives in the same projection map, so a global latch would be + // shared between them. + states_a.insert( + fid_b, + crate::semantic_render::SemanticRenderState::for_peer(fid_b, PROTOCOL_VERSION), + ); + states_a.get_mut(&fid_b).expect("B").arm_accepted_gesture( + crate::semantic_render::AcceptedPanelGesture { + button: pmacs_protocol::MouseButton::Left, + coord: pmacs_protocol::CellCoord::new(0, 0), + buffer_id: buffer_a, + reached_child: false, + }, + ); + + dispatch_panel_event( + &mut editor_a, + fid_a, + PROTOCOL_VERSION, + &mut states_a, + &mut render_a, + mapped_pointer(fid_a, epochs_a, buffer_a, generation_a), + ); + assert!(states_a[&fid_a].has_accepted_gesture(), "A is armed"); + assert!(states_a[&fid_b].has_accepted_gesture(), "B is armed"); + + // B loses authority. + states_a + .get_mut(&fid_b) + .expect("B") + .cancel_accepted_gesture(); + + assert!( + !states_a[&fid_b].has_accepted_gesture(), + "B's own gesture ends" + ); + assert_eq!( + states_a[&fid_b].panel_gesture_cancellations(), + 1, + "exactly once" + ); + assert!( + states_a[&fid_a].has_accepted_gesture(), + "and A's survives — a global latch would have erased it" + ); + assert_eq!( + states_a[&fid_a].panel_gesture_cancellations(), + 0, + "with no cancellation attributed to A" + ); + } + /// Criterion 50: a gesture from a source whose latest declaration is /// not a visible `Present` is dropped. #[test] diff --git a/src/semantic_render.rs b/src/semantic_render.rs index d91bd71..1774870 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -158,6 +158,29 @@ fn minibuffer_window(candidates: &[T], selected: Option) -> (Ve (window, selected_in_window) } +/// §5b G5 — what an accepted panel `Down` established, and what a +/// cancellation must therefore terminate. +/// +/// A release has to MATCH the press it ends: same button, same +/// encoding, at a coordinate the child was actually told about. A +/// cancellation that guessed any of those would put bytes in a child's +/// input stream describing an event that never happened. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AcceptedPanelGesture { + /// The button the accepted `Down` carried. + pub button: pmacs_protocol::MouseButton, + /// The last coordinate known valid for this gesture — where a + /// release is delivered when the pointer has since left content. + 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, +} + /// Owns one `semantic_render` session's projection state: the last /// viewport the frontend declared, and the diff baseline per buffer /// for the `StyleSpans` and `Decorations` families. @@ -406,6 +429,31 @@ pub struct SemanticRenderState { /// `generation` starts at 0 meaning "never established"; the first /// real mapping takes 1, because zero is invalid on the wire. panel_mapping: Option<(crate::editor::PanelMappingSnapshot, u64)>, + /// §5b G5 — the panel gesture this frontend has an ACCEPTED `Down` + /// for, if any. + /// + /// Per frontend, never global: two frontends can hold gestures on + /// distinct panels at once, and one global slot would make either + /// one's authority loss cancel or erase the other's (G5p). + /// + /// Cancellation needs this to exist at all. Without a record of + /// what was accepted there is nothing to terminate: a release must + /// match the press it ends, and a stale `Up` with no accepted + /// `Down` must be inert rather than synthesising one. + accepted_gesture: Option, + /// §5b — how many armed gestures an authority loss has ended this + /// session. + /// + /// A COUNT, not a queue of records. The records are what replay + /// consumes to deliver each release, and replay is the branch that + /// introduces their reader — a queue landed here would grow one + /// entry per cancelled drag for the life of the daemon with nothing + /// ever draining it. A saturating count is bounded and still + /// distinguishes the two ways a latch empties: an ordinary `Up` + /// consumes it and leaves this alone, an authority loss ends it and + /// bumps it. It also does not collapse two losses in one dispatcher + /// burst the way a boolean flag would. + panel_gesture_cancellations: u64, /// Highest presentation epoch allocated for this session; `0` means /// none has been. Advanced only when a frame is actually shipped, so /// a frame that fails validation does not burn an identity the peer @@ -613,6 +661,8 @@ impl SemanticRenderState { // frame from shipping a redundant authoritative `Absent`. last_panel_payload: Some(PanelFramePayload::Absent), panel_mapping: None, + accepted_gesture: None, + panel_gesture_cancellations: 0, panel_epoch_used: 0, panel_presentation: None, panel_error_latched: false, @@ -644,6 +694,69 @@ impl SemanticRenderState { self.last_panel_payload.clone() } + /// §5b — how many armed gestures an authority loss has ended. + /// + /// Counts rather than collapses: two losses in one dispatcher burst + /// read as two. Coincident causes on ONE transition are a different + /// matter — those take the latch once, so the count moves once. + #[must_use] + pub fn panel_gesture_cancellations(&self) -> u64 { + self.panel_gesture_cancellations + } + + /// §5b — arm the latch, and ONLY for an accepted left press. + /// + /// A right press opens a menu and ends there; `Move`, wheel and the + /// other buttons begin nothing. Arming on any accepted pointer + /// event would let one of them manufacture a delayed release at the + /// next authority loss. + /// + /// This is the SUBSTRATE for the framing's G5g. G5g itself — the + /// table-driven non-gesture events followed by an authority loss, + /// asserted to emit no synthetic release — is `panel-pointer-replay`'s + /// per §5b's split table, because the release it denies does not + /// exist on this branch. + pub fn arm_accepted_gesture(&mut self, gesture: AcceptedPanelGesture) { + self.accepted_gesture = Some(gesture); + } + + /// §5b — an ordinary accepted release CONSUMES the latch, WITHOUT + /// counting as a cancellation. + /// + /// Otherwise a later invalidation finds a gesture it believes is + /// still live and synthesises a duplicate release for a button + /// already up. Substrate for the framing's G5c/G5d, whose duplicate- + /// release assertions are replay's. + pub fn consume_accepted_gesture(&mut self) -> Option { + self.accepted_gesture.take() + } + + /// §5b G5a — cancellation: end the live gesture, if any, and return + /// its record so the caller can terminate it. + /// + /// Returning the record rather than acting is deliberate. The + /// EFFECTS — clearing an empty selection, clearing the click chain, + /// delivering the child's release — live in panel replay, which is + /// `panel-pointer-replay`'s. This is the trigger and the state; the + /// two meet at that rebase. + /// + /// Idempotent on an empty latch: no gesture, no count. That is what + /// lets a duplicate `Absent` and a coincident advance both call it + /// without inventing a second cancellation. + pub fn cancel_accepted_gesture(&mut self) -> Option { + let cancelled = self.accepted_gesture.take(); + if cancelled.is_some() { + self.panel_gesture_cancellations = self.panel_gesture_cancellations.saturating_add(1); + } + cancelled + } + + /// Whether a gesture is currently accepted, for assertions. + #[must_use] + pub fn has_accepted_gesture(&self) -> bool { + self.accepted_gesture.is_some() + } + /// Advance-if-changed, then read: the authoritative mapping key. /// /// **The single seam §5b requires.** Projection stamps the frame @@ -660,12 +773,27 @@ impl SemanticRenderState { snapshot: Option, ) -> Option { let snapshot = snapshot?; - let next = match &self.panel_mapping { - // Compared STRUCTURALLY. A hash would make this + // Matched by value, not by reference: the changed arm CANCELS, + // and cancelling needs `&mut self`. + let seen = self + .panel_mapping + .as_ref() + .map(|(seen, generation)| (*seen == snapshot, *generation)); + let next = match seen { + // Compared STRUCTURALLY above. A hash would make this // probabilistic, and a collision here silently accepts a // stale gesture — the exact failure the key exists for. - Some((seen, generation)) if *seen == snapshot => *generation, - Some((_, generation)) => generation.saturating_add(1), + Some((true, generation)) => generation, + Some((false, generation)) => { + // §5b G5a — the key advancing IS the cancellation + // trigger, raised here rather than waiting for another + // pointer event. Reactive cancellation loses a race: if + // the successor frame reaches the frontend before the + // physical `Up`, the producer clears its latch and the + // cancelling event never arrives. + self.cancel_accepted_gesture(); + generation.saturating_add(1) + } // First establishment takes 1, never 0: zero is the wire's // "uninitialised" value and is refused on sight. None => 1, @@ -1542,6 +1670,22 @@ impl SemanticRenderState { /// deliberately survives: it is answered by the frontend, not by the /// panel's presence. fn publish_absent_panel(&mut self, out: &mut Vec) { + // §5b — `Absent` is a loss of gesture authority: it clears + // `panel_presentation` two lines below, so every later inbound + // event for that gesture is refused and its release can never + // arrive. Cancelled even on a DUPLICATE `Absent`, which does no + // wire work but must still leave no live gesture behind — the + // clears below are idempotent for the same reason. + // + // Only the producer half lands here. The framing's G5b matrix — + // the panel-epoch, buffer-replacement, same-size geometry and + // detach transitions, each with its v24 and v25 legs and its + // document and terminal effects — is `panel-pointer-replay`'s per + // §5b's split table. Those transitions leave the latch armed on + // this branch, which is inert here (nothing consumes it) and + // becomes a defect only once replay gives it effects, in the + // branch that owns the row. + self.cancel_accepted_gesture(); self.panel_presentation = None; // `Absent` also clears the peer's retained mode line. A later // `Present` under `NoMessage` therefore has nothing it can