diff --git a/src/editor.rs b/src/editor.rs index b08c26a..388ea01 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -2452,6 +2452,80 @@ impl EditorState { true } + /// Fingerprint of the panel's **inverse mapping** for one frontend + /// (§5b, Q#BP-R3). + /// + /// This is the whole of "what decides which byte a cell means". A + /// generation is derived from it by advancing whenever it changes, + /// which makes the changing/stable split **structural** rather than + /// a list of bump sites someone must remember to touch: an input + /// that is hashed moves the key by construction, and one that is not + /// cannot. + /// + /// **Deliberately EXCLUDED**, and each exclusion is a contract: + /// focus, styling and theme, the cursor, and the selection. None of + /// them changes which byte a cell denotes, and a drag provokes + /// selection repaints on every motion — a key that moved with them + /// would cancel the gesture it is meant to protect after one step. + /// + /// Returns `None` when there is no presentable panel, which is not + /// the same as a zero key: absence of a mapping is not a mapping. + pub fn panel_mapping_fingerprint(&self, frontend_id: FrontendId) -> Option { + use std::hash::{Hash, Hasher}; + + let core = self.core.borrow(); + let size = core.panel_grid_size(frontend_id)?; + let side = core.side_window_for(frontend_id)?; + let window = core.windows.get(&side)?; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + // Identity first: a different buffer is a different mapping even + // if every geometry input coincides. + window.buffer_id.hash(&mut hasher); + // Grid ROWS and COLUMNS hashed separately, not as an area — a + // 2x6 and a 6x2 panel invert differently. + size.rows.hash(&mut hasher); + size.cols.hash(&mut hasher); + // Viewport, both axes. `view_left` matters from GUI arc 1b + // onward, and is hashed now so the key does not need revisiting + // when horizontal scrolling starts moving it. + window.view_top.hash(&mut hasher); + window.view_left.hash(&mut hasher); + // Wrap mode and the content width that the gutter reservation + // has already been subtracted from: together these decide how a + // source line is broken into display rows and where column zero + // sits. + window.last_wrap.hash(&mut hasher); + window.last_content_cols.hash(&mut hasher); + // Fold POLICY and fold CONTENT are separate inputs. The policy + // belongs to the owning frontend's view; the content belongs to + // the window. Either alone can change which source line a grid + // row shows. + core.views + .get(&frontend_id) + .is_some_and(|view| view.fold_projection) + .hash(&mut hasher); + // Hashed at their SOURCE — the registry's ranges — rather than + // through the derived `VisibleLineMap`, whose only public + // summary is `is_identity()`. That would be too coarse: a fold + // edit that leaves the map non-identity would not move the key + // while plainly changing which source line a row shows. + for range in core.fold_registry.folds(window.buffer_id) { + range.start.hash(&mut hasher); + range.end.hash(&mut hasher); + } + // Content. A foreign edit moves the mapping with every geometry + // input untouched, and is the case the epoch ladder cannot see. + let registry = core.registry.clone(); + let revision = registry + .borrow() + .get(window.buffer_id) + .ok() + .map(crate::buffer::Buffer::revision); + revision.hash(&mut hasher); + Some(hasher.finish()) + } + /// Paint one semantic frontend's side window into a panel-sized grid /// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17). /// diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 5e46628..b2c8d2b 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -377,6 +377,26 @@ pub struct SemanticRenderState { /// retains its last valid frame and silence would leave a stale band /// on screen indefinitely (Q#BP15). last_panel_payload: Option, + /// §5b — the authoritative **cell-mapping key** for this frontend. + /// + /// `(fingerprint, generation)`. The generation advances whenever the + /// fingerprint changes, and **both projection and inbound + /// validation read it through the same accessor**, so "what the + /// frontend was shown" and "what the daemon checks" cannot drift. + /// + /// It is deliberately **not** recomputed from the last emitted + /// frame: a mapping mutation that has not yet been painted has still + /// changed the inverse, and a gesture arriving in that gap must be + /// refused. Advancing on demand at both seams is what makes + /// "advances before the next inbound pointer, whether or not + /// anything rendered" true rather than aspirational. + /// + /// **Nondecreasing, and never cleared** — not even by `Absent`. A + /// delayed lower frame must not roll the producer's authority + /// backward, so this is a high-water mark for the session. + /// `generation` starts at 0 meaning "never established"; the first + /// real mapping takes 1, because zero is invalid on the wire. + panel_mapping: Option<(u64, 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 @@ -575,6 +595,7 @@ impl SemanticRenderState { // peer already holds. Seeding the baseline keeps the first // frame from shipping a redundant authoritative `Absent`. last_panel_payload: Some(PanelFramePayload::Absent), + panel_mapping: None, panel_epoch_used: 0, panel_presentation: None, panel_error_latched: false, @@ -599,6 +620,37 @@ impl SemanticRenderState { } } + /// Advance-if-changed, then read: the authoritative mapping key. + /// + /// **The single seam §5b requires.** Projection stamps the frame + /// with what this returns, and inbound validation compares against + /// what this returns; there is no second derivation to disagree + /// with. + /// + /// `fingerprint` is `None` when no panel is presentable. That does + /// **not** reset the key — the high-water mark survives `Absent`, + /// so a frame delayed across a hide cannot come back with a lower + /// generation and be believed. + pub fn panel_mapping_generation(&mut self, fingerprint: Option) -> Option { + let fingerprint = fingerprint?; + let next = match self.panel_mapping { + Some((seen, generation)) if seen == fingerprint => generation, + Some((_, generation)) => generation.saturating_add(1), + // First establishment takes 1, never 0: zero is the wire's + // "uninitialised" value and is refused on sight. + None => 1, + }; + self.panel_mapping = Some((fingerprint, next)); + Some(next) + } + + /// The current key without advancing it, for assertions and for + /// callers that must not have a side effect. + #[must_use] + pub fn panel_mapping_generation_peek(&self) -> Option { + self.panel_mapping.map(|(_, generation)| generation) + } + /// Whether the last shipped declaration is a `Present` whose epochs /// both match an inbound panel event **and** which still describes /// the side window that is live now (Q#BP16 steps 2–4). diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index e73109a..a379167 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -1362,3 +1362,316 @@ fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() { // write into their real data root. #[path = "common/iso.rs"] mod iso; + +// --------------------------------------------------------------------------- +// §5b G1–G4 — the authoritative cell-mapping key +// +// The key is derived from a FINGERPRINT of the inverse mapping's inputs, +// so the changing/stable split is structural: an input that is hashed +// moves the key by construction, and one that is not cannot. These rows +// pin each input individually, because a single "it changed" row cannot +// show WHICH input moved it — and a key that silently ignored, say, +// `view_left` would pass every row that only scrolls vertically. +// --------------------------------------------------------------------------- + +/// Edit the panel's buffer from OUTSIDE any gesture — the "foreign +/// edit" the ladder cannot see. Done in Rust rather than Lua because it +/// must be a plain content mutation with no view, cursor or command +/// state attached to it. +fn foreign_edit(session: &Session, text: &str) { + let core = session.state.core.borrow(); + let side = core.side_window_for(FID).expect("a side window"); + let buffer_id = core.windows[&side].buffer_id; + let registry = core.registry.clone(); + let mut reg = registry.borrow_mut(); + let buffer = reg.get_mut(buffer_id).expect("the panel's buffer"); + buffer + .set_generated_contents(text.as_bytes()) + .expect("a generated-contents write is a plain content change"); +} + +/// The key as the daemon would compute it for `FID`, advancing on change. +fn mapping_generation(session: &mut Session) -> Option { + let fingerprint = session.state.panel_mapping_fingerprint(FID); + session.render.panel_mapping_generation(fingerprint) +} + +/// §5b G1 — a **foreign** edit before the next render moves the key. +/// +/// This is the case the whole slice exists for: the epoch ladder cannot +/// see it. No buffer is replaced, no panel reopens, no geometry is +/// re-declared — every epoch holds — and yet the byte under a cell has +/// changed. It is also why the key must be derived on demand rather than +/// from the last emitted frame: nothing has rendered here. +#[test] +fn g1_a_foreign_edit_moves_the_mapping_key_before_anything_renders() { + let mut session = Session::new(); + open_panel(&session, "g1", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let before = mapping_generation(&mut session).expect("a presentable panel has a key"); + assert!( + before >= 1, + "a live key is never zero — zero is the wire's invalid value" + ); + + // An edit from somewhere other than the gesture's frontend, with no + // render in between. + foreign_edit(&session, "foreign edit\n"); + + let after = mapping_generation(&mut session).expect("still presentable"); + assert!( + after > before, + "a foreign edit changes which byte a cell means, and no epoch \ + moves with it — this is the hole the ladder cannot close" + ); +} + +/// §5b G3 — the **stable** inputs, one row each. +/// +/// Every entry here is something that repaints a panel without changing +/// which byte a cell denotes. A drag provokes selection repaints on every +/// motion, so a key that moved with them would cancel the gesture it +/// exists to protect after a single step. +#[test] +fn g3_repaints_that_cannot_move_a_byte_leave_the_key_alone() { + let mut session = Session::new(); + open_panel(&session, "g3", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let baseline = mapping_generation(&mut session).expect("a key"); + + // Re-reading with nothing changed at all. + assert_eq!( + mapping_generation(&mut session), + Some(baseline), + "an idle re-read must not advance the key, or every frame would \ + cancel every gesture" + ); + + // Cursor motion the follow rules absorb: the caret moves inside the + // viewport, so no origin moves with it. Set directly, so nothing but + // the cursor changes. + { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("a side window"); + let window = core.windows.get_mut(&side).expect("the side window"); + window.cursor = 0; + } + assert_eq!( + mapping_generation(&mut session), + Some(baseline), + "cursor motion that moves no origin is not a mapping change" + ); +} + +/// §5b G4a — a **selection-only** repaint preserves the key. +/// +/// Split from G3 because it is the one the lifecycle depends on: G4b — +/// that an in-flight drag then continues through real replay — is owed by +/// the rebased replay lane, which is the only branch where replay exists. +#[test] +fn g4a_a_selection_only_repaint_preserves_the_mapping_key() { + let mut session = Session::new(); + open_panel(&session, "g4a", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let baseline = mapping_generation(&mut session).expect("a key"); + foreign_edit(&session, "alpha beta\n"); + let after_edit = mapping_generation(&mut session).expect("a key"); + assert!(after_edit > baseline, "the edit itself is a mapping change"); + + // Now a selection, with no content or viewport change. + { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("a side window"); + let window = core.windows.get_mut(&side).expect("the side window"); + window.selection = Some(pmacs::window::Selection { anchor: 0 }); + window.cursor = 5; + } + assert_eq!( + mapping_generation(&mut session), + Some(after_edit), + "a selection changes what is HIGHLIGHTED, never what a cell \ + denotes — and a drag repaints the selection on every motion" + ); +} + +/// §5b — the key is a **high-water mark** and survives `Absent`. +/// +/// Hiding the band clears input authority, but it must not reset the +/// generation: a frame delayed across the hide would otherwise return +/// with a lower value and be believed. +#[test] +fn the_mapping_key_never_moves_backward_across_a_hidden_panel() { + let mut session = Session::new(); + open_panel(&session, "hw", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let before = mapping_generation(&mut session).expect("a key"); + foreign_edit(&session, "one\n"); + let peak = mapping_generation(&mut session).expect("a key"); + assert!(peak > before); + + // Hide it: no fingerprint, so no advance — and no reset either. + { + let mut core = session.state.core.borrow_mut(); + core.views + .get_mut(&FID) + .expect("the frontend's view") + .panel_hidden = true; + } + assert_eq!( + mapping_generation(&mut session), + None, + "no presentable panel means no key to stamp, which is not the \ + same as a key of zero" + ); + assert_eq!( + session.render.panel_mapping_generation_peek(), + Some(peak), + "the high-water mark SURVIVES the hide — clearing it would let a \ + delayed frame roll the producer's authority backward" + ); +} + +/// §5b G2 — **every changing input, one leg each.** +/// +/// Enumerated rather than asserted in aggregate, and mutation testing is +/// what forced it: with only the content-edit row present, dropping +/// `view_left` from the key and collapsing the grid to `rows * cols` +/// both stayed GREEN. A single "the key moved" row cannot show *which* +/// input moved it, and a key that ignores horizontal scrolling passes +/// every row that only scrolls vertically. +#[test] +fn g2_each_input_of_the_inverse_mapping_moves_the_key_on_its_own() { + // Each leg names one input and touches only that input. + type Leg = (&'static str, fn(&Session)); + + let legs: &[Leg] = &[ + ("view_top", |session| { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("side"); + core.windows.get_mut(&side).expect("win").view_top += 1; + }), + ("view_left — GUI arc 1b makes this real", |session| { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("side"); + core.windows.get_mut(&side).expect("win").view_left += 1; + }), + ("wrap mode", |session| { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("side"); + let window = core.windows.get_mut(&side).expect("win"); + window.last_wrap = match window.last_wrap { + pmacs::view::WrapMode::Wrap => pmacs::view::WrapMode::Truncate, + pmacs::view::WrapMode::Truncate => pmacs::view::WrapMode::Wrap, + }; + }), + ( + "content columns — the gutter is subtracted here", + |session| { + let mut core = session.state.core.borrow_mut(); + let side = core.side_window_for(FID).expect("side"); + core.windows.get_mut(&side).expect("win").last_content_cols += 1; + }, + ), + ("fold PROJECTION POLICY, owned by the view", |session| { + let mut core = session.state.core.borrow_mut(); + let view = core.views.get_mut(&FID).expect("view"); + view.fold_projection = !view.fold_projection; + }), + ("fold CONTENT, owned by the buffer", |session| { + let core = session.state.core.borrow(); + let side = core.side_window_for(FID).expect("side"); + let buffer_id = core.windows[&side].buffer_id; + let registry = core.registry.clone(); + let mut reg = registry.borrow_mut(); + let buffer = reg.get_mut(buffer_id).expect("buffer"); + let store = core.fold_registry.store_or_attach(buffer); + store + .lock() + .expect("fold store mutex") + .insert(pmacs_protocol::ByteRange { start: 0, end: 1 }); + }), + ]; + + for (name, mutate) in legs { + let mut session = Session::new(); + open_panel(&session, "g2", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let before = mapping_generation(&mut session).expect("a key"); + mutate(&session); + let after = mapping_generation(&mut session).expect("a key"); + assert!( + after > before, + "changing {name} changes which byte a cell means, so the key \ + must move; it did not ({before} → {after})" + ); + } +} + +/// §5b G2 — grid **rows** and **columns** each move the key. +/// +/// **Honest limit, recorded because mutation testing found it:** these +/// two legs do NOT discriminate `rows`/`cols` from their product. +/// Collapsing the key to `rows * cols` leaves both GREEN, because +/// `last_content_cols` co-varies with a column change and the panel's +/// row count co-varies with a resize — the key still moves, by another +/// input. Only a **transposition** (2×6 → 6×2, identical product) would +/// isolate it, and no production path reaches one: rows come from the +/// band's height and columns from the frame declaration, and nothing +/// swaps them. +/// +/// The key hashes them separately anyway. That is cheap and correct, +/// and the alternative — hashing a product because no test can currently +/// tell the difference — would be choosing the weaker construction for +/// the convenience of the test suite. What these legs *do* pin is that +/// each dimension moves the key at all, which is what the rest of the +/// slice depends on. +#[test] +fn g2_grid_rows_and_columns_are_independent_inputs() { + // ROWS come from the band's own height, not the frame's total rows — + // declaring a shorter frame leaves a 4-row panel a 4-row panel. The + // resize path is the one that actually changes them. + { + let mut session = Session::new(); + open_panel(&session, "g2rows", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let before = mapping_generation(&mut session).expect("a key"); + assert!( + session.state.apply_panel_resize_rows(FID, 6), + "the resize must be accepted, or this leg proves nothing" + ); + let after = mapping_generation(&mut session).expect("a key"); + assert!( + after > before, + "changing grid ROWS alone must move the key — an area product \ + would miss a transposition" + ); + } + + // COLUMNS come from the declaration. + { + let mut session = Session::new(); + open_panel(&session, "g2cols", 4); + session.declare(1, 24, 80); + let _ = session.present(); + + let before = mapping_generation(&mut session).expect("a key"); + session.declare(2, 24, 40); + let after = mapping_generation(&mut session).expect("a key"); + assert!( + after > before, + "changing grid COLUMNS alone must move the key" + ); + } +}