diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index b278823..c232c73 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -2224,6 +2224,15 @@ struct PanelBand { /// because the first drag after a press must reach the daemon even at the /// cell the press landed on. last_pointer_cell: Option, + /// §5b G9b — the mapping generation `last_pointer_cell` was measured + /// under, so the dedupe re-arms across a mapping change. + /// + /// Comparing the cell alone EATS the first motion after the mapping + /// moves: the pointer has not travelled, but the cell now denotes + /// different text, and that is exactly the motion the daemon needs. + /// `None` for a legacy session, where it is constant and the dedupe + /// behaves as it always did. + last_pointer_generation: Option, /// Whether the pointer is currently over the divider strip, which /// decides the `RowResize` cursor icon. hover_divider: bool, @@ -7044,6 +7053,7 @@ impl State { self.panel.hover_divider = false; self.panel.pointer_held = false; self.panel.last_pointer_cell = None; + self.panel.last_pointer_generation = None; had } // §5b G8b — a mapped session REJECTS the legacy family @@ -7429,10 +7439,18 @@ impl State { /// traffic and every one of those is a daemon-side gesture — the same /// reason the terminal path dedupes. fn panel_motion_is_new(&mut self, coord: CellCoord) -> bool { - if self.panel.last_pointer_cell == Some(coord) { + // §5b G9b — keyed by generation as well as cell. Deliberately + // read here rather than reset from the frame path: resetting on + // every accepted repaint would re-arm within one generation and + // bring pixel-rate traffic back, and the dedupe would stop + // being a dedupe. + if self.panel.last_pointer_cell == Some(coord) + && self.panel.last_pointer_generation == self.panel.mapping_generation + { return false; } self.panel.last_pointer_cell = Some(coord); + self.panel.last_pointer_generation = self.panel.mapping_generation; true } @@ -7441,6 +7459,7 @@ impl State { fn set_panel_pointer_held(&mut self, held: bool) { self.panel.pointer_held = held; self.panel.last_pointer_cell = None; + self.panel.last_pointer_generation = None; } /// Begin a divider drag at surface pixel `y`, if the pointer is on the @@ -20002,6 +20021,327 @@ mod tests { } } + /// A mapped session with a real installed frame, and the frame. + /// + /// The baseline is installed in the CORRECT family, so every + /// assertion below has real authority to preserve rather than a + /// `None` that was never set. + fn mapped_panel_session(rows: u32, generation: u64) -> Option<(State, PanelFrame)> { + let mut state = headless_or_skip(800, 600, "alpha")?; + let frame = present_panel(&mut state, rows); + state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION); + let mut mapped = frame; + mapped.panel_epoch += 1; + assert!( + state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: mapped.clone(), + mapping_generation: generation, + }), + "fixture: the mapped baseline must install" + ); + Some((state, mapped)) + } + + /// §5b G9b — motion dedupe suppresses a repeat WITHIN one generation + /// and RE-ARMS across one. + /// + /// The pointer has not moved in either case. What differs is whether + /// the cell still denotes the same text: comparing the cell alone + /// eats the first motion after the mapping moves, which is precisely + /// the motion the daemon needs to re-anchor the gesture. + #[test] + fn g9b_motion_dedupe_is_keyed_by_generation_not_by_cell_alone() { + let Some((mut state, frame)) = mapped_panel_session(4, 5) else { + return; + }; + let cell = pmacs_protocol::CellCoord::new(1, 1); + + assert!( + state.panel_motion_is_new(cell), + "the first motion at a cell is always new" + ); + assert!( + !state.panel_motion_is_new(cell), + "a repeat within one generation is suppressed — without this \ + sub-cell motion becomes pixel-rate wire traffic" + ); + + // A same-generation repaint: a different frame, same key. The + // dedupe must NOT re-arm, or the suppression above is defeated + // by any style or selection repaint. + let mut repaint = frame.clone(); + repaint.focused = !frame.focused; + assert!( + state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: repaint, + mapping_generation: 5, + }), + "fixture: the repaint must be accepted, or it proves nothing" + ); + assert!( + !state.panel_motion_is_new(cell), + "a same-generation repaint does not re-arm the dedupe" + ); + + // The mapping MOVES. The pointer still has not. + let mut rekeyed = frame.clone(); + rekeyed.focused = frame.focused; + assert!( + state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: rekeyed, + mapping_generation: 6, + }), + "fixture: the re-keyed frame must be accepted" + ); + assert!( + state.panel_motion_is_new(cell), + "and across a generation the same cell IS new: it denotes \ + different text now" + ); + } + + /// §5b G9c — a byte-identical frame at a HIGHER generation still + /// updates authority. + /// + /// The visual result is nothing, which is what makes this easy to + /// get wrong: returning early on frame equality leaves the frontend + /// echoing a generation the daemon has already retired, and every + /// gesture it sends is then refused. + #[test] + fn g9c_an_identical_frame_at_a_higher_generation_still_updates_authority() { + let Some((mut state, frame)) = mapped_panel_session(4, 5) else { + return; + }; + + // Byte-identical, and the same generation: no work at all. + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: frame.clone(), + mapping_generation: 5, + }), + "a true duplicate does nothing" + ); + assert_eq!(state.panel.mapping_generation, Some(5)); + + // Byte-identical, HIGHER generation: authority moves. + assert!( + state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: frame.clone(), + mapping_generation: 6, + }), + "the daemon has re-keyed the mapping under an unchanged \ + picture — a fold or an edit off-screen does exactly that" + ); + assert_eq!( + state.panel.mapping_generation, + Some(6), + "echoing 5 from here would have every gesture refused" + ); + assert_eq!( + state.panel.frame.as_ref(), + Some(&frame), + "and the frame is unchanged, because it was identical" + ); + } + + /// §5b G10 — a structurally invalid mapped frame is refused + /// ATOMICALLY: previous frame and previous generation both retained. + /// + /// Updating one without the other leaves a valid generation naming a + /// frame that was never shown, which is the same stale-mapping hole + /// from the receiving side. + #[test] + fn g10_an_invalid_mapped_frame_is_refused_atomically() { + let Some((mut state, frame)) = mapped_panel_session(4, 5) else { + return; + }; + state.set_panel_pointer_held(true); + state.panel.last_pointer_cell = Some(pmacs_protocol::CellCoord::new(1, 1)); + + // G10 — structurally invalid: the cell count contradicts `size`. + let mut invalid = frame.clone(); + invalid.cells.pop(); + assert!( + invalid.validate().is_err(), + "fixture: the frame must actually be invalid" + ); + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: invalid, + mapping_generation: 9, + }) + ); + assert_eq!(state.panel.frame.as_ref(), Some(&frame), "frame retained"); + assert_eq!( + state.panel.mapping_generation, + Some(5), + "and its generation with it — a valid 9 here would name a \ + frame this frontend never received" + ); + + // G10a — structurally VALID, generation zero. Zero is what a + // sender that never initialised the field produces; accepting it + // once disables the check for the session. + let mut zeroed = frame.clone(); + zeroed.panel_epoch += 7; + assert!( + zeroed.validate().is_ok(), + "fixture: this one is structurally fine, so the refusal is \ + about the zero" + ); + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: zeroed, + mapping_generation: 0, + }) + ); + assert_eq!(state.panel.frame.as_ref(), Some(&frame)); + assert_eq!(state.panel.mapping_generation, Some(5)); + assert!(state.panel.pointer_held, "a refusal ends no live gesture"); + assert_eq!( + state.panel.last_pointer_cell, + Some(pmacs_protocol::CellCoord::new(1, 1)), + "nor discards its dedupe baseline" + ); + } + + /// §5b G10a — a ZERO generation is refused when NO authority is + /// held yet. + /// + /// This is the only setup that isolates the zero check. With a + /// generation already held, zero is also *lower* than it, so the + /// nondecreasing clause refuses the frame and a row built that way + /// stays green with the zero check deleted — measured, not assumed. + /// + /// The case is the one the framing names: a sender that never + /// initialised the field. Accepting it once records `Some(0)`, and + /// from there no later frame is ever "lower", so the check is + /// disabled for the whole session. + #[test] + fn g10a_a_zero_generation_is_refused_before_any_authority_is_held() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + // A legacy frame first, so there is a real retained band to + // preserve, then the family switch. `mapping_generation` is + // genuinely `None` here — that is the state under test, not an + // accident of the fixture. + let baseline = present_panel(&mut state, 4); + state.set_panel_wire(pmacs_protocol::PANEL_MAPPING_MIN_VERSION); + assert_eq!( + state.panel.mapping_generation, None, + "fixture: no authority held yet" + ); + + let mut zeroed = baseline.clone(); + zeroed.panel_epoch += 1; + assert!( + zeroed.validate().is_ok(), + "fixture: structurally fine, so the refusal is about the zero" + ); + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: zeroed, + mapping_generation: 0, + }), + "zero is the wire's uninitialised value and is never authority" + ); + assert_eq!( + state.panel.mapping_generation, None, + "and nothing is recorded — `Some(0)` would make every later \ + frame non-lower and disable the check for the session" + ); + assert_eq!( + state.panel.frame.as_ref().map(|f| f.panel_epoch), + Some(baseline.panel_epoch), + "the retained band survives the refusal" + ); + + // POSITIVE CONTROL — the same frame with a real generation + // installs, so the refusal is about the zero and not about the + // family switch. + let mut real = baseline.clone(); + real.panel_epoch += 2; + assert!(state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: real, + mapping_generation: 1, + })); + assert_eq!(state.panel.mapping_generation, Some(1)); + } + + /// §5b G10c — the generation is NONDECREASING, and `Absent` does not + /// erase the high-water mark. + /// + /// Values may SKIP: the daemon advances the key on mapping changes, + /// not on frames, so a frontend that misses two repaints legitimately + /// jumps. What may never happen is going backward — a delayed frame + /// must not roll this frontend's authority back to a mapping that is + /// gone. + #[test] + fn g10c_generation_is_nondecreasing_and_survives_absent() { + let Some((mut state, frame)) = mapped_panel_session(4, 5) else { + return; + }; + + // A SKIP forward is fine. + let mut skipped = frame.clone(); + skipped.panel_epoch += 1; + assert!(state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: skipped.clone(), + mapping_generation: 9, + })); + assert_eq!(state.panel.mapping_generation, Some(9)); + + // A LOWER one is refused, atomically. + let mut delayed = frame.clone(); + delayed.panel_epoch += 2; + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: delayed.clone(), + mapping_generation: 8, + }) + ); + assert_eq!( + state.panel.frame.as_ref().map(|f| f.panel_epoch), + Some(skipped.panel_epoch), + "the retained frame survives" + ); + assert_eq!(state.panel.mapping_generation, Some(9)); + + // An EQUAL generation with a different frame is accepted: a + // style or selection repaint does not move the mapping, and + // refusing it would freeze the band. + let mut repaint = skipped.clone(); + repaint.focused = !skipped.focused; + assert!( + state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: repaint, + mapping_generation: 9, + }), + "an equal-generation repaint is valid" + ); + assert_eq!(state.panel.mapping_generation, Some(9)); + + // `Absent` hides the band but must NOT erase the high-water + // mark: a frame delayed across the hide would otherwise come + // back believed. + assert!(state.apply_panel_payload(PanelFramePayload::Absent)); + assert!(state.panel.presented().is_none(), "the band is down"); + assert_eq!( + state.panel.mapping_generation, + Some(9), + "and the high-water mark stands" + ); + assert!( + !state.apply_panel_payload(PanelFramePayload::PresentMapped { + frame: delayed, + mapping_generation: 8, + }), + "so the delayed frame is still refused after the hide" + ); + assert!(state.panel.presented().is_none(), "and stays down"); + } + /// §5b G8b — a MAPPED frontend refuses the legacy family, and the /// refusal is ATOMIC. /// diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs index 880ed25..616fc69 100644 --- a/tests/bottom_panel_stage2b_daemon_acceptance.rs +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -1442,6 +1442,223 @@ fn g1_a_foreign_edit_moves_the_mapping_key_before_anything_renders() { ); } +/// §5b G15 — **TUI structural control**: the local panel click, drag +/// and wheel paths keep their effects with NO mapping generation +/// anywhere. +/// +/// The TUI hit-tests the daemon's own state directly. It receives no +/// `PanelFrame`, so it has no generation to echo and there is nothing +/// for a freshness check to compare. If the check ever migrates from +/// the authenticated semantic boundary into shared replay, local input +/// stops working entirely — and it would stop silently, because +/// refusing a gesture looks exactly like a gesture that did nothing. +/// +/// The control is the FIXTURE: this session has no +/// `SemanticRenderState` at all. Every effect below is therefore +/// reached without a producer in existence, which is the strongest form +/// of "no generation was consulted" — not an assertion about a value, +/// but the absence of anything that could hold one. +#[test] +#[allow( + clippy::too_many_lines, + reason = "one control over three input kinds: splitting it would \ + give each leg its own fixture, and the shared fixture — a \ + session with no SemanticRenderState at all — is the control" +)] +fn g15_local_tui_panel_input_keeps_its_effects_with_no_generation() { + use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind as TuiMouseKind}; + + let state = EditorState::new_with_roots(&crate::iso::roots()); + exec(&state, "pmacs.lsp.config = {}"); + state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + exec( + &state, + "PANEL_BUF = pmacs.buffer.create(\"*g15*\") + pmacs.buffer.set_generated_contents(PANEL_BUF, \ + \"alpha alpha\\nbravo bravo\\ncharlie\\ndelta\\necho\\nfox\\ngolf\\nhotel\\n\") + pmacs.window.display(PANEL_BUF, { side = \"bottom\", height = 4 })", + ); + let mut state = state; + let panel = state + .core + .borrow() + .side_window_for(FrontendId::LOCAL) + .expect("a panel"); + let document = state + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("a document"); + + // Paint one frame first, exactly as the TUI does before a user can + // click anything. The window's text view is built from the buffer at + // paint time, so input dispatched against an unpainted window maps + // every row to offset zero and the row proves nothing. + { + let mut cells = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize]; + let mut grid = pmacs::cell::CellGrid { + cells: &mut cells, + stride: COLS, + size: CellSize::new(ROWS, COLS), + }; + pmacs::editor::paint_frame( + &state, + FrontendId::LOCAL, + &HashMap::new(), + &mut grid, + CellSize::new(ROWS, COLS), + ); + } + + // Where the panel actually is, from the same layout the TUI paints. + let panel_rect = { + let core = state.core.borrow(); + let view = core.views.get(&FrontendId::LOCAL).expect("LOCAL view"); + let area = pmacs::window::Rect::new(0, 0, ROWS - 1, COLS); + let fixed = core.panel_fixed_rows(FrontendId::LOCAL, area.size.rows); + view.layout.compute(area, &fixed)[&panel] + }; + let row = u16::try_from(panel_rect.origin.row + 1).expect("row fits"); + // Past the gutter, and inside a line that is long enough for the + // drag below to stay within it: clamping at the line end would make + // press and drag land on the same byte and prove nothing. + let gutter = state.core.borrow().windows[&panel].gutter_width(); + let col = u16::try_from(panel_rect.origin.col + gutter + 1).expect("col fits"); + let click = |kind| MouseEvent { + kind, + column: col, + row, + modifiers: KeyModifiers::NONE, + }; + + // Precondition, scoped to THIS view: the panel is not already + // focused, or every assertion below would pass without input. + assert_eq!( + state.core.borrow().views[&FrontendId::LOCAL].active, + document, + "precondition: the document is focused" + ); + + // CLICK — focuses the panel and positions the cursor. + state.dispatch_mouse( + FrontendId::LOCAL, + click(TuiMouseKind::Down(MouseButton::Left)), + CellSize::new(ROWS, COLS), + ); + assert_eq!( + state.core.borrow().views[&FrontendId::LOCAL].active, + panel, + "a local press focuses the panel — no token, no refusal" + ); + let pressed_cursor = state.core.borrow().windows[&panel].cursor; + assert_ne!( + pressed_cursor, 0, + "the press positioned the cursor inside the text, not at the \ + buffer start — a row that pressed into an unpainted window \ + would read zero here and never notice" + ); + + // DRAG — extends a selection inside the panel. + state.dispatch_mouse( + FrontendId::LOCAL, + MouseEvent { + kind: TuiMouseKind::Drag(MouseButton::Left), + column: col + 7, + row, + modifiers: KeyModifiers::NONE, + }, + CellSize::new(ROWS, COLS), + ); + assert!( + state.core.borrow().windows[&panel].selection.is_some(), + "a local drag selects inside the panel" + ); + assert_ne!( + state.core.borrow().windows[&panel].cursor, + pressed_cursor, + "and the drag moved the cursor, so the selection is a real range" + ); + + state.dispatch_mouse( + FrontendId::LOCAL, + MouseEvent { + kind: TuiMouseKind::Up(MouseButton::Left), + column: col + 7, + row, + modifiers: KeyModifiers::NONE, + }, + CellSize::new(ROWS, COLS), + ); + + // WHEEL — two ticks, both of which must land. The mapped family's + // wheel exemption exists so this stays true over the wire; here + // there is no wire, and it must be true for the same reason. + let before = state.core.borrow().windows[&panel].view_top; + for _ in 0..2 { + state.dispatch_mouse( + FrontendId::LOCAL, + click(TuiMouseKind::ScrollDown), + CellSize::new(ROWS, COLS), + ); + } + assert_ne!( + state.core.borrow().windows[&panel].view_top, + before, + "a local wheel scrolls the panel" + ); +} + +/// §5b G9a — a generation change is EMITTED even when the picture is +/// byte-identical. +/// +/// The panel shows four rows; an edit further down the buffer moves the +/// key without changing a single visible cell. Suppressing that frame +/// because the cells match would leave the frontend echoing a +/// generation the daemon has already retired, and every gesture it then +/// sends is refused — the panel goes quietly dead while looking +/// perfectly correct. +#[test] +fn g9a_a_generation_change_ships_even_when_the_cells_are_identical() { + let mut session = Session::new(); + open_panel(&session, "g9a", 4); + session.declare(1, 24, 80); + + // Enough lines that the visible four cannot see the edit below. + let mut lines = String::new(); + for line in 0..20 { + use std::fmt::Write as _; + writeln!(lines, "line {line}").expect("writing to a String never fails"); + } + foreign_edit(&session, &lines); + let baseline = session.present(); + let before = mapping_generation(&mut session).expect("a live key"); + + // Change line 15 only. Rows 0..4 are untouched. + let edited = lines.replace("line 15\n", "LINE 15 CHANGED\n"); + assert_ne!(edited, lines, "fixture: the edit must actually apply"); + foreign_edit(&session, &edited); + + let payload = session + .frame() + .expect("a generation change is not silence: the frame must ship"); + let (frame, shipped) = match payload { + PanelFramePayload::PresentMapped { + frame, + mapping_generation, + } => (frame, mapping_generation), + other => panic!("a v25 producer ships the mapped family, got {other:?}"), + }; + assert_eq!( + frame.cells, baseline.cells, + "fixture: the visible cells really are identical — if they \ + differ, this row is testing an ordinary repaint instead" + ); + assert!( + shipped > before, + "and the key moved with the edit below the fold" + ); +} + /// §5b G3 — the **stable** inputs, one row each. /// /// Every entry here is something that repaints a panel without changing