diff --git a/src/daemon.rs b/src/daemon.rs index b665d07..16364cd 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -69,8 +69,8 @@ use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm} use crate::protocol::{ ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, - InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PointerKind, - SelectionSnapshot, SessionBootstrapRequest, + InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, + PANEL_MIN_VERSION, PointerKind, SelectionSnapshot, SessionBootstrapRequest, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; use crate::transport::{read_message, write_message}; @@ -898,13 +898,22 @@ fn peer_declared_terminal_support( /// Q#BP13). /// /// Grid sessions paint the whole cell grid the daemon composes, so a side -/// window is just another leaf for them. A semantic session needs the -/// Stage 2 `PanelFrame` band, which does not exist yet — so Stage 1 -/// answers `false` for every semantic peer, whatever it declares. No -/// client-asserted standalone boolean is trusted: the answer is derived -/// from the daemon's own negotiated state, and Stage 2 turns the version -/// arm on (`semantic_render && negotiated_protocol_version >= -/// PANEL_MIN_VERSION`). +/// window is just another leaf for them. A semantic session needs the GPU +/// band, which does not exist yet — so this still answers `false` for +/// every semantic peer, whatever it declares. No client-asserted +/// standalone boolean is trusted: the answer is derived from the daemon's +/// own negotiated state. +/// +/// **Stage 2B-2 deliberately does not turn the version arm on.** The +/// daemon-side projection and epoch machine below are complete and +/// exercised through a test-only panel-capable view, but the production +/// flip (`semantic_render && negotiated_protocol_version >= +/// PANEL_MIN_VERSION`) belongs to Stage 2B-3, together with the +/// compatibility-preserving activation the server-first `Hello` requires: +/// [`ADVERTISED_PROTOCOL_VERSION`](pmacs_protocol::ADVERTISED_PROTOCOL_VERSION) +/// is still 20, so no session can negotiate 21 yet, and denying only the +/// events while still *placing* such a peer in a side window would leave +/// its window invisible. fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool { !session_state.negotiated_capabilities.semantic_render } @@ -917,6 +926,73 @@ fn peer_accepts_terminal_message(protocol_version: u32, message: &InstanceMessag protocol_version >= 19 || !matches!(message, InstanceMessage::TerminalFrame(_)) } +/// The same belt-and-braces write-loop gate for the additive +/// protocol-v21 panel frame (Q#BP9). +/// +/// The producer already skips construction for a peer below +/// [`PANEL_MIN_VERSION`]; this filter independently prevents an unknown +/// discriminant reaching one, so neither gate alone is load-bearing. +fn peer_accepts_panel_message(protocol_version: u32, message: &InstanceMessage) -> bool { + protocol_version >= PANEL_MIN_VERSION || !matches!(message, InstanceMessage::PanelFrame(_)) +} + +/// Whether an authenticated source may send the v21 panel event family +/// (Q#BP9's "every gate keys on the daemon's own state"). +/// +/// All three inbound events require the same four facts, and they are +/// checked together so no arm can satisfy three and forget the fourth: +/// an installed **semantic** projection, a negotiated version that +/// carries the variants, and a `FrontendView` this daemon itself marked +/// panel-capable. A grid session, a pre-panel semantic peer, or a +/// non-panel-capable view is rejected before any payload state is +/// trusted. +/// +/// The claimed `frontend_id` in the payload is never consulted anywhere: +/// routing is by the authenticated transport `source`, so a forged id +/// addresses nothing. +/// Q#BP16 steps 2–4: the event addresses the panel declaration this +/// session most recently shipped, under the geometry it most recently +/// accepted. +/// +/// Three facts, one predicate, because they close three different holes +/// and no two of them subsume the third: +/// +/// * the latest declaration is a `Present` (an `Absent` cleared input +/// authority, so nothing is addressable), +/// * its echoed `geometry_epoch` equals both the payload's **and** the +/// daemon's latest accepted declaration — the font/scale/resize race, +/// * its `panel_epoch` equals the payload's — close/hide/reopen of the +/// same persistent buffer, which a `buffer_id` alone cannot see. +fn panel_event_epochs_are_current( + editor: &EditorState, + semantic_states: &HashMap, + source: FrontendId, + geometry_epoch: u64, + panel_epoch: u64, +) -> bool { + semantic_states + .get(&source) + .is_some_and(|sem| sem.panel_declaration_matches(geometry_epoch, panel_epoch)) + && editor + .core + .borrow() + .frame_geometry_for(source) + .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) +} + +fn peer_may_send_panel_events( + editor: &EditorState, + session_registry: &SessionRegistry, + semantic_states: &HashMap, + source: FrontendId, +) -> bool { + semantic_states.contains_key(&source) + && session_registry + .session_state(source) + .is_some_and(|state| state.negotiated_protocol_version >= PANEL_MIN_VERSION) + && editor.core.borrow().panel_capable_for(source) +} + /// T M10.8 — dispatcher loop. The single thread that owns the editor. /// /// All attached frontends' inputs arrive via the `dispatcher_rx` @@ -1376,6 +1452,12 @@ fn dispatcher_loop( if !peer_accepts_statusline_message(negotiated_protocol_version, msg) { continue; } + // Bottom panel Q#BP9 — PanelFrame gated at v21. A v20 + // peer receives no band and, per Q#BP13, is never + // placed in a side window either. + if !peer_accepts_panel_message(negotiated_protocol_version, msg) { + continue; + } // T M10.10 Day 4 / M10.11 F2 — the criterion-1 // jitter site: render-write latency. // @@ -1950,10 +2032,17 @@ fn handle_session_established( term_sizes.insert(frontend_id, initial_size); // Bottom-panel arc (Q#BP2b): a grid session's real attach size IS its // authoritative geometry declaration, cached BEFORE any input can - // reach it. A semantic session deliberately stays UNKNOWN — Stage 2's + // reach it. A semantic session deliberately stays UNKNOWN — its // authenticated `FrontendCellGeometry` fills it, and the permanent // 24x80 attach placeholder is never consulted for panel layout. - if editor.core.borrow().panel_capable_for(frontend_id) { + // + // Stage 2B-2: the gate is `!semantic_render`, NOT `panel_capable`. + // Stage 1 could conflate them because panel capability implied grid; + // once a semantic session can be panel-capable, a capability-keyed + // gate would feed it exactly the placeholder Q#BP15a forbids, and + // parent acceptance 40 would fail through this line rather than + // through the projection. + if !semantic_render && editor.core.borrow().panel_capable_for(frontend_id) { editor.sync_frame_geometry(frontend_id, initial_size); } @@ -2043,7 +2132,17 @@ fn handle_dispatcher_event( // longer satisfy the panel hides it, moves focus out, // and releases its terminal controller here — before // the next drained event dispatches. - if editor.core.borrow().panel_capable_for(source) { + // + // Stage 2B-2: gated on the absence of a semantic + // projection as well as on capability. A semantic + // frontend's `Resize` describes its own surface in + // whatever units it chose; only `FrontendCellGeometry` + // is its authoritative cell-equivalent declaration + // (Q#BP15a), and letting `Resize` mint an epoch here + // would let the two allocators interleave. + if !semantic_states.contains_key(&source) + && editor.core.borrow().panel_capable_for(source) + { editor.sync_frame_geometry(source, size); } } @@ -2189,6 +2288,80 @@ fn handle_dispatcher_event( ); } } + FrontendEvent::FrontendCellGeometry { + geometry_epoch, + total, + .. + } => { + // Bottom panel Q#BP15a — the frontend's authoritative + // cell-equivalent layout capacity. Routed by the + // authenticated `source`; the payload's `frontend_id` + // is never read. + // + // Deliberately does NOT require a side window: the + // daemon needs columns before it can paint a first + // panel frame, so gating this on panel presence would + // deadlock the first open. "Without a side window" + // refers to side-window presence only — the protocol, + // session, and capability gates all still apply. + if peer_may_send_panel_events(editor, session_registry, semantic_states, source) + { + editor.accept_semantic_frame_geometry(source, geometry_epoch, total); + } + } + FrontendEvent::PanelResizeRows { + geometry_epoch, + panel_epoch, + rows, + .. + } => { + // Bottom panel Q#BP15a / Q#BP16 — a divider drag's + // requested rows. Accepted only against the currently + // visible `Present` declaration, matching BOTH the + // latest accepted frontend geometry and that + // declaration's presentation epoch, so a drag racing a + // font change or a panel reopen cannot resize its + // successor. + if peer_may_send_panel_events(editor, session_registry, semantic_states, source) + && panel_event_epochs_are_current( + editor, + semantic_states, + source, + geometry_epoch, + panel_epoch, + ) + { + editor.apply_panel_resize_rows(source, rows); + } + } + FrontendEvent::PanelPointer { + geometry_epoch, + panel_epoch, + buffer_id, + coord, + kind, + .. + } => { + // Bottom panel Q#BP16 — a gesture the frontend + // hit-tested to a panel CELL. Steps 1, 3, and 4 of the + // ladder are checked here (authenticated source, both + // epochs against the declaration the frontend was + // looking at); steps 2, 5, and 6 are re-derived from + // the daemon's own state inside the dispatcher. Any + // failure drops the event before any view, controller, + // selection, menu, or PTY mutation. + if peer_may_send_panel_events(editor, session_registry, semantic_states, source) + && panel_event_epochs_are_current( + editor, + semantic_states, + source, + geometry_epoch, + panel_epoch, + ) + { + editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); + } + } FrontendEvent::Pointer { buffer_id, byte, @@ -3401,6 +3574,11 @@ fn apply_event( // A grid session has no panel band at all, so one arriving // here is a protocol violation; drop it rather than letting // a payload-trusted id reach a view. + // + // Stage 2B-2 added the real routing arms, which `peer_may_ + // send_panel_events` already refuses for a grid session, so + // this is now the belt-and-braces half of the same gate — + // exactly like the `Pointer` and `TerminalResize` arms above. eprintln!( "pmacs daemon: panel declaration from a grid session; dropping \ (grid sessions negotiate no panel band)" @@ -5537,4 +5715,491 @@ mod tests { "#9: a focused TERMINAL panel must not suppress the document viewport — the document window should still have aligned to the declared buffer" ); } + + // ----------------------------------------------------------------- + // Bottom-panel Stage 2B-2 — inbound panel-event routing, driven + // through `handle_dispatcher_event` (the real dispatcher seam). + // + // Deliberately NOT `crdt`-gated: CI never enables that feature, so a + // gated pin is dark exactly where it needs to run. + // ----------------------------------------------------------------- + + /// A semantic, panel-capable frontend. `with_panel` decides whether + /// it also owns a side window — `FrontendCellGeometry` must be + /// accepted **without** one (Q#BP15a breaks the first-open cycle), + /// while the two gesture events must not be. + fn semantic_panel_view( + editor: &crate::editor::EditorState, + fid: FrontendId, + with_panel: bool, + ) -> (crate::window::WindowId, Option) { + use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams}; + + let mut core = editor.core.borrow_mut(); + let doc_buf = core.active_window().buffer_id; + let document = crate::window::WindowId::next(); + let doc_view = { + let reg = core.registry.borrow(); + crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")) + }; + core.windows + .insert(document, Window::new(document, doc_buf, doc_view)); + let panel = with_panel.then(|| { + let panel_buf = core.registry.borrow_mut().create("*panel*"); + let panel_id = crate::window::WindowId::next(); + let panel_view = { + let reg = core.registry.borrow(); + crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")) + }; + let mut window = Window::new(panel_id, panel_buf, panel_view); + let mut params = WindowParams::default(); + params.side = Some(crate::window::Side::Bottom); + params.fixed_rows = Some(4); + window.params = params; + core.windows.insert(panel_id, window); + panel_id + }); + let layout = match panel { + Some(panel) => Layout { + root: LayoutNode::Split { + orientation: Orientation::Horizontal, + children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel)], + weights: vec![1, 1], + }, + }, + None => Layout::single(document), + }; + core.register_frontend_view( + fid, + FrontendView { + layout, + active: document, + fold_projection: false, + // Stage 2B-2 is dark: production negotiation still sets + // this `false` for every semantic session, so the + // projection is exercised through a test-only view (the + // framing's §7.2.2 posture). + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + (document, panel) + } + + fn session(version: u32, semantic: bool) -> crate::presence::SessionState { + crate::presence::SessionState { + negotiated_protocol_version: version, + negotiated_capabilities: crate::protocol::NegotiatedCapabilities { + semantic_render: semantic, + crdt_replica: true, + ..Default::default() + }, + color_slot: 0, + } + } + + /// Drive one authenticated event through the real dispatcher while + /// keeping the caller's projection state, so a test can ship a + /// `PanelFrame` first and then send an event addressing it. + /// + /// The session is registered because the dispatcher drops any event + /// from an uninstalled session before it reaches a handler. + fn dispatch_panel_event( + editor: &mut crate::editor::EditorState, + fid: FrontendId, + version: u32, + semantic_states: &mut HashMap, + render_states: &mut HashMap, + event: FrontendEvent, + ) { + let mut streams = HashMap::new(); + let mut term_sizes = HashMap::new(); + term_sizes.insert(fid, CellSize::new(24, 80)); + let mut last_idle = HashMap::new(); + let mut last_active = HashMap::new(); + let mut bells = HashMap::new(); + let mut registry = SessionRegistry::new(); + registry.register_session(fid, session(version, !render_states.contains_key(&fid))); + handle_dispatcher_event( + DispatcherEvent::FrontendEvent { source: fid, event }, + editor, + render_states, + semantic_states, + &mut streams, + &mut term_sizes, + &mut last_idle, + &mut last_active, + &mut bells, + &mut registry, + ); + } + + fn geometry_event(claimed: FrontendId, epoch: u64, rows: u32, cols: u32) -> FrontendEvent { + FrontendEvent::FrontendCellGeometry { + frontend_id: claimed, + geometry_epoch: epoch, + total: CellSize::new(rows, cols), + } + } + + /// Criterion 50 (accept half) + Q#BP15a: the declaration is valid + /// with no side window at all. Gating it on panel presence would + /// deadlock the first open, because the daemon needs columns before + /// it can paint a first frame. + #[test] + fn frontend_cell_geometry_is_accepted_without_a_side_window() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(701); + semantic_panel_view(&editor, fid, false); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(fid, 1, 40, 100), + ); + + let stored = editor.core.borrow().frame_geometry_for(fid); + assert_eq!( + stored.map(|geometry| (geometry.geometry_epoch, geometry.total)), + Some((1, CellSize::new(40, 100))), + "a panel-capable semantic source declares geometry with no side window" + ); + } + + /// Criterion 50 (reject half): a GRID session has no panel band, so + /// its declaration is dropped before it can reach a view. + #[test] + fn frontend_cell_geometry_from_a_grid_session_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(702); + semantic_panel_view(&editor, fid, false); + // A grid session: a `RenderState`, no semantic projection. + let mut render_states = HashMap::new(); + render_states.insert(fid, RenderState::new(CellSize::new(24, 80))); + // The grid arm would otherwise mint an epoch from its own attach + // size, so start from a known state and assert the epoch never + // answers the wire declaration. + let before = editor.core.borrow().frame_geometry_for(fid); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut HashMap::new(), + &mut render_states, + geometry_event(fid, 9, 40, 100), + ); + + let after = editor.core.borrow().frame_geometry_for(fid); + assert_eq!( + before, after, + "a grid session's panel declaration is dropped" + ); + assert_eq!(after, None, "…and nothing was stored at all"); + } + + /// Criterion 50 (reject half): a peer that negotiated v20 never + /// negotiated these variants, so its declaration is not trusted even + /// though its view is panel-capable. + #[test] + fn frontend_cell_geometry_below_the_panel_version_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(703); + semantic_panel_view(&editor, fid, false); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PANEL_MIN_VERSION - 1), + ); + + dispatch_panel_event( + &mut editor, + fid, + PANEL_MIN_VERSION - 1, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(fid, 1, 40, 100), + ); + + assert_eq!( + editor.core.borrow().frame_geometry_for(fid), + None, + "a pre-panel semantic peer's declaration is dropped" + ); + } + + /// Criterion 50 (reject half) + Q#BP13: capability is the gate, not + /// only the wire version. A semantic view this daemon did not mark + /// panel-capable declares nothing. + #[test] + fn frontend_cell_geometry_from_a_non_panel_capable_view_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(704); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(fid, 1, 40, 100), + ); + + assert_eq!( + editor.core.borrow().frame_geometry_for(fid), + None, + "a non-panel-capable semantic view declares no panel geometry" + ); + } + + /// Criterion 50 (forged-source half): routing is by the + /// authenticated transport source, never the payload's claimed id, so + /// a forged id reaches no other frontend's view. + #[test] + fn a_forged_frontend_id_in_a_geometry_payload_addresses_nothing() { + let mut editor = crate::editor::EditorState::new(); + let source = FrontendId(705); + let victim = FrontendId(706); + semantic_panel_view(&editor, source, false); + semantic_panel_view(&editor, victim, false); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + source, + crate::semantic_render::SemanticRenderState::for_peer(source, PROTOCOL_VERSION), + ); + + dispatch_panel_event( + &mut editor, + source, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(victim, 1, 40, 100), + ); + + let core = editor.core.borrow(); + assert_eq!( + core.frame_geometry_for(victim), + None, + "the claimed id must not be able to declare another frontend's geometry" + ); + assert!( + core.frame_geometry_for(source).is_some(), + "…while the authenticated source's own declaration still lands" + ); + } + + /// Ship one real `PanelFrame` so the session holds a live `Present` + /// declaration, and return its two epochs. + fn shipped_declaration( + editor: &crate::editor::EditorState, + fid: FrontendId, + semantic_states: &mut HashMap, + ) -> (u64, u64) { + let sem = semantic_states.get_mut(&fid).expect("semantic projection"); + let messages = sem.render_frame(editor); + assert!( + messages + .iter() + .any(|msg| matches!(msg, InstanceMessage::PanelFrame(_))), + "fixture precondition: the frame must actually ship a panel declaration" + ); + let frame = sem.panel_declaration().expect("a Present declaration"); + (frame.geometry_epoch, frame.panel_epoch) + } + + /// Criterion 50: a gesture from a source whose latest declaration is + /// not a visible `Present` is dropped. + #[test] + fn a_panel_pointer_without_a_present_declaration_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(707); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + // No geometry declared yet, so nothing has been shipped and the + // seeded baseline is `Absent`. + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: 1, + panel_epoch: 1, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "a gesture with no live Present declaration must not focus the panel" + ); + } + + /// Criterion 49: the accepted case, and the two epoch races beside + /// it. Without the accepted arm the drops above would pass for a + /// dispatcher that ignores the event family entirely. + #[test] + fn panel_pointer_epochs_decide_focus() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(708); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let down = |geometry_epoch, panel_epoch| FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch, + panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }; + + for (label, event) in [ + ( + "stale geometry epoch", + down(geometry_epoch + 1, panel_epoch), + ), + ( + "stale presentation epoch", + down(geometry_epoch, panel_epoch + 1), + ), + ] { + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + event, + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "{label}: the gesture must drop before it can move focus" + ); + } + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + down(geometry_epoch, panel_epoch), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "a gesture matching BOTH epochs activates the panel (click-to-focus)" + ); + } + + /// Criteria 49/50 for the resize event: matching epochs move the + /// stored request, a stale presentation epoch does not. + #[test] + fn panel_resize_rows_honors_both_epochs() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(709); + let (_document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let before = editor.core.borrow().windows[&panel].params.fixed_rows; + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch, + panel_epoch: panel_epoch + 1, + rows: 9, + }, + ); + assert_eq!( + editor.core.borrow().windows[&panel].params.fixed_rows, + before, + "a stale presentation epoch must not resize the panel" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch, + panel_epoch, + rows: 9, + }, + ); + assert_eq!( + editor.core.borrow().windows[&panel].params.fixed_rows, + Some(9), + "matching epochs move the stored request" + ); + } + + /// Q#BP9's write-loop gate, independent of the producer's own flag. + #[test] + fn the_panel_frame_write_gate_rejects_v20_independently() { + let frame = InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent); + assert!(!peer_accepts_panel_message(PANEL_MIN_VERSION - 1, &frame)); + assert!(peer_accepts_panel_message(PANEL_MIN_VERSION, &frame)); + assert!( + peer_accepts_panel_message( + PANEL_MIN_VERSION - 1, + &InstanceMessage::DispatchIdle { idle: true } + ), + "the filter must be scoped to the panel variant" + ); + } } diff --git a/src/editor.rs b/src/editor.rs index 1118254..613bcf7 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1932,6 +1932,10 @@ impl EditorState { /// the *active* frontend — is the wrong source and is deliberately /// not called. #[must_use] + #[allow( + clippy::too_many_lines, + reason = "one panel paint transaction: derive the grid, paint the window, resolve the caret" + )] pub fn prepare_panel_projection( &self, frontend_id: FrontendId, @@ -4256,9 +4260,10 @@ fn window_cursor_cell( ) -> Option { let inner_rows = inner_rows(&rect); let cursor = match folds { - Some(map) => { - map.visible_position(window.text_view.line_at_offset(window.cursor), window.cursor) - } + Some(map) => map.visible_position( + window.text_view.line_at_offset(window.cursor), + window.cursor, + ), None => window.cursor, }; let disp = window.text_view.pos_to_display(buf, cursor)?; diff --git a/src/editor_core.rs b/src/editor_core.rs index bd80a3f..7292284 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -3413,10 +3413,9 @@ impl EditorCore { // The wire's area bound is a transport-safety limit, not a // policy: clamp rows against it rather than shipping a frame the // shared validator would reject whole. - let budget_rows = u32::try_from( - pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1), - ) - .unwrap_or(u32::MAX); + let budget_rows = + u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1)) + .unwrap_or(u32::MAX); let rows = rows.min(budget_rows); (rows >= MIN_WINDOW_OUTER_ROWS).then(|| crate::cell::CellSize::new(rows, cols)) } diff --git a/src/protocol.rs b/src/protocol.rs index baf2709..30341f0 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -60,6 +60,21 @@ use std::path::PathBuf; // directly. pub use pmacs_protocol::*; +/// Lowest negotiated protocol version that carries the bottom-panel wire +/// family (Q#BP9): [`InstanceMessage::PanelFrame`] daemon→frontend, and +/// `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}` +/// frontend→daemon. +/// +/// One constant rather than a literal at each gate, because the panel +/// bump gates in **both** directions: the send filter, the producer's +/// peer flag, and the three inbound event gates must move together or one +/// side starts trusting a wire the other never negotiated. +/// +/// Distinct from [`ADVERTISED_PROTOCOL_VERSION`], which the production +/// daemon still holds at 20: the v21 schema is reserved, and the +/// compatibility-preserving activation is bottom-panel Stage 2B-3's. +pub const PANEL_MIN_VERSION: u32 = 21; + // --------------------------------------------------------------------------- // Attachment // --------------------------------------------------------------------------- diff --git a/src/semantic_render.rs b/src/semantic_render.rs index db96fc4..c42835c 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -38,14 +38,16 @@ use crate::cell::{CellSize, Style}; use crate::editor::EditorState; use crate::protocol::{ AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment, - FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StatuslineSegment, StyleSegment, - StyleSpan, + FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, PANEL_MIN_VERSION, + StatuslineSegment, StyleSegment, StyleSpan, }; use crate::statusline::{ StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget, - evaluate_statusline, + StatuslineWindowSegments, evaluate_statusline, }; use crate::terminal::TerminalFrame; +use crate::window::WindowId; +use pmacs_protocol::panel::{PanelFrame, PanelFramePayload}; /// The viewport a `semantic_render` frontend last declared. #[derive(Clone, Debug, Eq, PartialEq)] @@ -317,6 +319,54 @@ pub struct SemanticRenderState { terminal_error_latched: bool, /// Whether the most recent render pass projected a terminal. terminal_active: bool, + /// Whether the peer negotiated protocol v21, which is where + /// [`InstanceMessage::PanelFrame`] was appended (Q#BP9). A v20 peer + /// receives no band at all — and, per Q#BP13, is never *placed* in a + /// side window either, because denying only the message would leave + /// its window invisible. + peer_knows_panel_frames: bool, + /// The panel payload this peer last received, compared in FULL. + /// + /// Seeded to `Absent` rather than `None`: a fresh session starts with + /// no band, so the opening state is a fact rather than an absence, + /// and seeding it keeps every session from paying one redundant + /// `Absent` before it ever shows a panel. + /// + /// `Absent` is authoritative and duplicate-suppressed like any other + /// payload — hide and close must both send it, because the receiver + /// retains its last valid frame and silence would leave a stale band + /// on screen indefinitely (Q#BP15). + last_panel_payload: Option, + /// 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 + /// never saw. + panel_epoch_used: u64, + /// Identity behind the `Present` in `last_panel_payload`, or `None` + /// when the last payload was `Absent`. + /// + /// Cleared by every `Absent`, which is what makes hide/reopen and + /// close/reopen of the **same** persistent buffer allocate a fresh + /// epoch — the hole a `buffer_id` alone cannot close (Q#BP16). + panel_presentation: Option, + /// Whether an invalid panel frame was already reported since the last + /// valid one. Bounds the log exactly like `terminal_error_latched`. + panel_error_latched: bool, +} + +/// The presentation identity a shipped [`PanelFrame`] carries. +/// +/// `window_id` moves when a new side window is created and `buffer_id` +/// when the panel's buffer is replaced; either one changing allocates a +/// new `panel_epoch`, and that is what stops a stale `PanelPointer` from +/// addressing a reopened panel as if it were the old one (Q#BP16). +/// `WindowId` deliberately stays off the wire — the epoch is the opaque +/// stand-in for it. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +struct PanelPresentation { + window_id: WindowId, + buffer_id: BufferId, + panel_epoch: u64, } /// One [`SemanticRenderState::diag_line_cache`] entry: the line-start @@ -413,6 +463,7 @@ impl SemanticRenderState { s.peer_knows_font_facts = negotiated_protocol_version >= 17; s.peer_knows_statusline_segments = negotiated_protocol_version >= 18; s.peer_knows_terminal_frames = negotiated_protocol_version >= 19; + s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION; s } @@ -464,9 +515,45 @@ impl SemanticRenderState { last_terminal_frame: None, terminal_error_latched: false, terminal_active: false, + peer_knows_panel_frames: true, + // Q#BP15: a fresh session has no band, and that is a fact the + // peer already holds. Seeding the baseline keeps the first + // frame from shipping a redundant authoritative `Absent`. + last_panel_payload: Some(PanelFramePayload::Absent), + panel_epoch_used: 0, + panel_presentation: None, + panel_error_latched: false, } } + /// The `Present` panel declaration this session last shipped. + /// + /// The daemon reads it to run steps 3 and 4 of Q#BP16's validation + /// ladder: an inbound `PanelPointer` or `PanelResizeRows` must name + /// the geometry and presentation epochs of the frame the frontend was + /// actually looking at. `None` after an `Absent` — which is precisely + /// how `Absent` "clears input authority". + #[must_use] + pub fn panel_declaration(&self) -> Option<&PanelFrame> { + match self.last_panel_payload.as_ref()? { + PanelFramePayload::Present(frame) => Some(frame), + PanelFramePayload::Absent => None, + } + } + + /// Whether the last shipped declaration is a `Present` whose epochs + /// both match an inbound panel event (Q#BP16 steps 2–4). + /// + /// Rolled into one predicate so no caller can check the geometry + /// epoch and forget the presentation epoch: they close different + /// holes and neither subsumes the other. + #[must_use] + pub fn panel_declaration_matches(&self, geometry_epoch: u64, panel_epoch: u64) -> bool { + self.panel_declaration().is_some_and(|frame| { + frame.geometry_epoch == geometry_epoch && frame.panel_epoch == panel_epoch + }) + } + /// Record the frontend's declared on-screen byte range. Called by /// the dispatcher when it receives /// [`crate::protocol::FrontendEvent::Viewport`]. Replaces any @@ -616,8 +703,15 @@ impl SemanticRenderState { return messages; } let Some(vp) = self.viewport.clone() else { - // Emit nothing before the frontend declares a viewport. - return Vec::new(); + // Emit nothing document-scoped before the frontend declares a + // viewport — but the band is a SEPARATE surface (Q#BP15a), + // and gating it on the document declaration would make the + // first panel unpaintable on a frontend that has not declared + // one yet. Its statusline is `None` here for the same reason: + // the semantic fan-out is keyed on the declared buffer. + let mut out = Vec::new(); + self.emit_panel_frame(state, None, &mut out); + return out; }; // Evaluate callbacks before any long-lived core borrow and before @@ -825,9 +919,19 @@ impl SemanticRenderState { out.extend(self.theme_facts_msg(state)); out.extend(self.font_facts_msg(state)); // Q#SL6/Q#SL8: face inventory must precede segment text. + // Parent acceptance 45: ONE provider invocation supplies both the + // primary-document wire segments and the panel mode line, so the + // side half is taken from this same evaluation before it is + // consumed. + let side_window = state.core.borrow().side_window_for(self.frontend_id); + let panel_statusline = statusline_evaluation + .as_ref() + .and_then(|evaluation| self.panel_statusline(evaluation, side_window)) + .cloned(); if let Some(evaluation) = statusline_evaluation { self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } + self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out); out } @@ -965,9 +1069,19 @@ impl SemanticRenderState { out.extend(self.theme_facts_msg(state)); out.extend(self.font_facts_msg(state)); // Q#SL6/Q#SL8: face inventory must precede segment text. + // The band rides the terminal path too: a frontend whose DOCUMENT + // surface is a full-window terminal can still hold a side window, + // and suppressing the panel here would leave the peer's retained + // band on screen with no way to clear it. + let side_window = state.core.borrow().side_window_for(self.frontend_id); + let panel_statusline = statusline_evaluation + .as_ref() + .and_then(|evaluation| self.panel_statusline(evaluation, side_window)) + .cloned(); if let Some(evaluation) = statusline_evaluation { self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } + self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out); out } @@ -1033,6 +1147,158 @@ impl SemanticRenderState { } } + /// The side window's evaluated segments from **this frame's** single + /// provider invocation (parent acceptance 45). + /// + /// Selected by window identity, exactly like the document half: the + /// fan-out yields the primary document *and* the visible side window, + /// and taking "some context for my frontend" would depend on capture + /// order and could paint the document's status text into the panel's + /// mode line. + /// + /// An `Invalidated` evaluation yields `None`, which is the panel's + /// authoritative-empty: a callback that mutated layout or focus + /// invalidates the whole evaluation, so the band paints its plain + /// mode line rather than stale provider text. + fn panel_statusline<'a>( + &self, + evaluation: &'a StatuslineEvaluation, + side_window: Option, + ) -> Option<&'a StatuslineWindowSegments> { + let side_window = side_window?; + match &evaluation.outcome { + StatuslineEvaluationOutcome::Ready(windows) => windows.iter().find(|window| { + window.context.frontend_id == self.frontend_id + && window.context.window_id == side_window + }), + StatuslineEvaluationOutcome::Invalidated { .. } + | StatuslineEvaluationOutcome::NoMessage(_) => None, + } + } + + /// Project this frontend's side window as an + /// [`InstanceMessage::PanelFrame`] (Q#BP15). + /// + /// Runs on every frame of a panel-capable v21 semantic session, + /// independently of the document byte viewport: the band is a + /// separate surface, and gating it on a declared viewport would leave + /// the first panel unpaintable on a frontend that has not yet + /// declared one. + /// + /// Not reset by [`Self::on_buffer_snapshot_sent`]: a `BufferSnapshot` + /// resets *document* mirror state, and the band is neither + /// buffer-scoped to the document nor rebuilt from it. + fn emit_panel_frame( + &mut self, + state: &EditorState, + statusline: Option<&StatuslineWindowSegments>, + out: &mut Vec, + ) { + // Q#BP13: capability, not merely wire version. A session that + // cannot render a band must not be shipped one — and, on the + // production path, is never placed in a side window either. + if !self.peer_knows_panel_frames || !state.core.borrow().panel_capable_for(self.frontend_id) + { + return; + } + // Q#BP15a: `Present` echoes the daemon's latest ACCEPTED geometry + // declaration. Read before painting so the frame cannot answer a + // declaration that arrived mid-projection. + let geometry = state.core.borrow().frame_geometry_for(self.frontend_id); + let projection = + geometry.and_then(|_| state.prepare_panel_projection(self.frontend_id, statusline)); + let (Some(geometry), Some(projection)) = (geometry, projection) else { + self.publish_absent_panel(out); + return; + }; + let identity = (projection.window_id, projection.buffer_id); + let panel_epoch = match self.panel_presentation { + Some(presentation) if (presentation.window_id, presentation.buffer_id) == identity => { + Some(presentation.panel_epoch) + } + // A new side window, a replaced buffer, or any `Absent` → + // `Present` transition (which cleared `panel_presentation`) + // takes a fresh identity. + _ => self.panel_epoch_used.checked_add(1), + }; + let Some(panel_epoch) = panel_epoch else { + // Q#BP15: allocation is checked and exhaustion fails closed + // to `Absent`. Wrapping would let a new panel inherit a live + // identity and accept gestures aimed at its predecessor. + self.publish_absent_panel(out); + return; + }; + let payload = PanelFramePayload::Present(PanelFrame { + buffer_id: projection.buffer_id, + panel_epoch, + geometry_epoch: geometry.geometry_epoch, + size: projection.size, + cells: projection.cells, + cursor: projection.cursor, + focused: projection.focused, + }); + // Complete-payload comparison FIRST, like the terminal pass: only + // validated payloads are ever stored, so a payload equal to the + // baseline has already passed and re-running the per-cell width + // and topology checks would recompute a verdict we hold. + if self.last_panel_payload.as_ref() == Some(&payload) { + self.panel_error_latched = false; + return; + } + let PanelFramePayload::Present(frame) = &payload else { + unreachable!("the Present payload was constructed immediately above"); + }; + match frame.validate() { + Ok(()) => { + self.panel_error_latched = false; + self.panel_epoch_used = self.panel_epoch_used.max(panel_epoch); + self.panel_presentation = Some(PanelPresentation { + window_id: projection.window_id, + buffer_id: projection.buffer_id, + panel_epoch, + }); + self.last_panel_payload = Some(payload.clone()); + out.push(InstanceMessage::PanelFrame(payload)); + } + Err(error) => { + // Atomic rejection: the peer keeps its last valid frame, + // this session keeps the presentation identity behind it, + // and one bounded log line marks the condition. Advancing + // `panel_epoch_used` here would burn an identity the peer + // never saw. + if !self.panel_error_latched { + self.panel_error_latched = true; + eprintln!( + "pmacs: panel frame for {:?} on {:?} failed validation, \ + retaining the last valid frame: {error}", + projection.buffer_id, self.frontend_id + ); + } + } + } + } + + /// Publish the authoritative `Absent` for every non-presentable state + /// (Q#BP15, Q#BP2b). + /// + /// Clears the declared presentation on this side before any later + /// event can validate against it — that is what "`Absent` clears + /// input authority" means. The whole-frame geometry declaration + /// deliberately survives: it is answered by the frontend, not by the + /// panel's presence. + fn publish_absent_panel(&mut self, out: &mut Vec) { + self.panel_presentation = None; + if self.last_panel_payload.as_ref() == Some(&PanelFramePayload::Absent) { + // A duplicate `Absent` does no work — but the clear above + // still runs, so the state stays idempotent rather than + // depending on which duplicate arrived first. + return; + } + self.panel_error_latched = false; + self.last_panel_payload = Some(PanelFramePayload::Absent); + out.push(InstanceMessage::PanelFrame(PanelFramePayload::Absent)); + } + fn emit_statusline_payload( &mut self, buffer_id: BufferId, diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs new file mode 100644 index 0000000..f7f5ada --- /dev/null +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -0,0 +1,984 @@ +// bottom_panel_stage2b_daemon_acceptance.rs --- bottom-panel Stage 2B-2 +// (docs/bottom-panel-stage2-framing.md §7.2.2; parent acceptance 38, 39 +// receiver half, 40, 41 daemon half, 42, 45, 49, 51, 52, plus A2B-1). + +//! The daemon panel projection and the epoch machine. +//! +//! Everything here runs through a **test-only** panel-capable semantic +//! view: production negotiation still sets `panel_capable = false` for +//! every semantic session, and the compatibility-preserving v21 +//! activation is Stage 2B-3's. Nothing in this slice is user-reachable. +//! +//! Two disciplines the framing is explicit about: +//! +//! * **Every geometry claim is asserted against the frame the producer +//! actually shipped**, never against `panel_grid_size` alone — the +//! grid the daemon derives is only meaningful if it reaches the wire. +//! * **Every drop is paired with its accepted counterpart in the same +//! fixture.** A suite that only proved "stale events are dropped" +//! would pass against a producer that ships nothing at all. + +use std::collections::HashMap; + +use pmacs::cell::{CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::editor_core::GeometryUpdate; +use pmacs::protocol::{FrontendId, InstanceMessage, PROTOCOL_VERSION}; +use pmacs::semantic_render::SemanticRenderState; +use pmacs::window::{FrontendView, Layout, Window, WindowId}; +use pmacs_protocol::panel::{PanelFrame, PanelFramePayload}; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +const FID: FrontendId = FrontendId(64); +/// Deliberately NOT `24x80`: parent acceptance 40 requires the first +/// open to be sized from the frontend's own declaration, and a fixture +/// that happened to match the attach placeholder could not tell the two +/// apart. +const ROWS: u32 = 40; +const COLS: u32 = 120; + +struct Session { + state: EditorState, + render: SemanticRenderState, + document: WindowId, +} + +impl Session { + /// A semantic, panel-capable frontend with one document window and + /// no geometry declared yet. + fn new() -> Self { + let state = EditorState::new(); + exec(&state, "pmacs.lsp.config = {}"); + let document = { + let mut core = state.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + let window = WindowId::next(); + core.windows + .insert(window, Window::new(window, buffer_id, text_view)); + core.register_frontend_view( + FID, + FrontendView { + layout: Layout::single(window), + active: window, + // Semantic sessions do not project folds (Q#FD21); + // parent acceptance 52 depends on it. + fold_projection: false, + // Test-only. Production negotiation still says false. + panel_capable: true, + // Q#BP15a: UNKNOWN, never the attach placeholder. + frame_geometry: None, + panel_hidden: false, + }, + ); + // Programmatic Lua calls act for the ambient active frontend, + // so every `pmacs.window.*` call below targets this session. + core.active_frontend = FID; + window + }; + Self { + state, + render: SemanticRenderState::for_peer(FID, PROTOCOL_VERSION), + document, + } + } + + fn declare(&self, epoch: u64, rows: u32, cols: u32) -> GeometryUpdate { + self.state + .accept_semantic_frame_geometry(FID, epoch, CellSize::new(rows, cols)) + } + + /// Project one frame and return the panel payload it carried, or + /// `None` when the frame said nothing about the band (which is what + /// duplicate suppression looks like from the wire). + fn frame(&mut self) -> Option { + let messages = self.render.render_frame(&self.state); + let mut panels = messages.into_iter().filter_map(|message| match message { + InstanceMessage::PanelFrame(payload) => Some(payload), + _ => None, + }); + let first = panels.next(); + assert!( + panels.next().is_none(), + "one frame ships at most one panel payload" + ); + first + } + + fn present(&mut self) -> PanelFrame { + match self.frame() { + Some(PanelFramePayload::Present(frame)) => frame, + other => panic!("expected a Present panel payload, got {other:?}"), + } + } + + fn side_window(&self) -> Option { + self.state.core.borrow().side_window_for(FID) + } +} + +fn exec(state: &EditorState, src: &str) { + state.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +/// The panel grid's text as rows of strings, mode line included. +fn rows_of(frame: &PanelFrame) -> Vec { + frame + .cells + .chunks(frame.size.cols as usize) + .map(|row| { + row.iter() + .map(|cell| match &cell.glyph { + Glyph::Char(ch) => ch.to_string(), + Glyph::Cluster(bytes) => String::from_utf8_lossy(bytes).into_owned(), + Glyph::Continuation => String::new(), + }) + .collect::() + }) + .collect() +} + +/// Replace the panel buffer's contents through the real Lua data API. +fn set_panel_text(session: &Session, text: &str) { + exec( + &session.state, + &format!( + "local len = PANEL_BUF:len() + if len > 0 then PANEL_BUF:delete(0, len) end + PANEL_BUF:insert(0, {text:?})" + ), + ); +} + +/// Close the panel: focus it, then take the real `close_active` path. +fn close_panel(session: &Session) { + let panel = session.side_window().expect("side window"); + session.state.core.borrow_mut().focus_window(FID, panel); + exec(&session.state, "pmacs.window.close()"); + session.state.reconcile_panel_layout(FID); +} + +fn open_panel(session: &Session, name: &str, rows: u32) { + exec( + &session.state, + &format!( + "PANEL_BUF = pmacs.buffer.create(\"{name}\") + pmacs.window.display(PANEL_BUF, {{ side = \"bottom\", height = {rows} }})" + ), + ); +} + +// --------------------------------------------------------------------------- +// 40 + A2B-1 — unknown geometry is first-class; the placeholder is never +// consulted +// --------------------------------------------------------------------------- + +#[test] +fn acc40_a_panel_opened_before_any_declaration_stays_absent() { + let mut session = Session::new(); + open_panel(&session, "*panel*", 4); + assert!(session.side_window().is_some(), "the side window exists"); + + assert_eq!( + session.frame(), + None, + "with geometry UNKNOWN the band is non-presentable, and the seeded \ + Absent baseline means there is nothing new to say" + ); + assert_eq!( + session.state.core.borrow().panel_grid_size(FID), + None, + "no grid is derivable before a real declaration" + ); +} + +#[test] +fn acc40_the_first_frame_is_sized_from_the_declaration_not_the_placeholder() { + let mut session = Session::new(); + open_panel(&session, "*panel*", 4); + assert_eq!(session.declare(1, ROWS, COLS), GeometryUpdate::Advanced); + + let frame = session.present(); + assert_eq!( + frame.size.cols, COLS, + "columns come from the frontend's declaration; the permanent 24x80 \ + attach placeholder would have produced 80" + ); + assert_eq!( + frame.size.rows, 4, + "rows are the clamped fixed_rows request" + ); + assert_eq!( + frame.geometry_epoch, 1, + "the frame echoes the declaration it answers" + ); + assert!(frame.panel_epoch >= 1, "presentation epochs start at 1"); +} + +// --------------------------------------------------------------------------- +// 38 / 49 — the full lifecycle, and a new epoch at every identity change +// --------------------------------------------------------------------------- + +#[test] +fn acc38_open_replace_hide_reappear_close() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*first*", 4); + + // --- open --------------------------------------------------------- + let opened = session.present(); + let first_buffer = opened.buffer_id; + + // --- replace the panel's buffer ----------------------------------- + exec( + &session.state, + "SECOND = pmacs.buffer.create(\"*second*\") + pmacs.window.display(SECOND, { side = \"bottom\" })", + ); + let replaced = session.present(); + assert_ne!( + replaced.buffer_id, first_buffer, + "the panel really is showing another buffer" + ); + assert!( + replaced.panel_epoch > opened.panel_epoch, + "49: buffer replacement must move the presentation epoch \ + ({} -> {})", + opened.panel_epoch, + replaced.panel_epoch + ); + + // --- hidden by a frame too small to satisfy it -------------------- + // Q#BP2b: hiding is a durable transition, and the band must be + // cleared AUTHORITATIVELY. Silence would leave the retained frame + // on the peer's screen indefinitely. + session.declare(2, 4, COLS); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "38: hiding sends an authoritative Absent" + ); + assert!( + session.side_window().is_some(), + "…while the side window itself survives, with its request intact" + ); + + // --- reappear ----------------------------------------------------- + session.declare(3, ROWS, COLS); + let reappeared = session.present(); + assert!( + reappeared.panel_epoch > replaced.panel_epoch, + "49: every Absent -> Present transition takes a fresh epoch \ + ({} -> {})", + replaced.panel_epoch, + reappeared.panel_epoch + ); + assert_eq!( + reappeared.buffer_id, replaced.buffer_id, + "…even though the SAME persistent buffer came back, which is exactly \ + the hole a buffer id alone cannot close" + ); + + // --- close -------------------------------------------------------- + close_panel(&session); + assert_eq!(session.side_window(), None, "the side window is gone"); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "38: closing sends an authoritative Absent" + ); +} + +#[test] +fn acc49_close_and_reopen_of_the_same_buffer_takes_a_new_epoch() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + exec( + &session.state, + "PERSISTENT = pmacs.buffer.create(\"*persistent*\") + pmacs.window.display(PERSISTENT, { side = \"bottom\", height = 4 })", + ); + let first = session.present(); + + close_panel(&session); + assert_eq!(session.frame(), Some(PanelFramePayload::Absent)); + + exec( + &session.state, + "pmacs.window.display(PERSISTENT, { side = \"bottom\", height = 4 })", + ); + let second = session.present(); + + assert_eq!( + second.buffer_id, first.buffer_id, + "the SAME persistent buffer is back — a buffer id alone cannot \ + distinguish this from the original presentation" + ); + assert!( + second.panel_epoch > first.panel_epoch, + "49: the presentation epoch must have moved ({} -> {})", + first.panel_epoch, + second.panel_epoch + ); +} + +// --------------------------------------------------------------------------- +// 39 (receiver half) — duplicates do no work; an invalid frame is +// rejected atomically and the previous valid frame is retained +// --------------------------------------------------------------------------- + +#[test] +fn acc39_a_duplicate_frame_does_no_work() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let _first = session.present(); + + assert_eq!( + session.frame(), + None, + "39: nothing changed, so the second frame ships no panel payload" + ); + assert_eq!( + session.frame(), + None, + "…and it stays quiet, rather than alternating" + ); +} + +#[test] +fn acc39_a_duplicate_absent_does_no_work() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let _present = session.present(); + + close_panel(&session); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "the first Absent is authoritative and must be sent" + ); + assert_eq!( + session.frame(), + None, + "39: a duplicate Absent is suppressed exactly like any other payload" + ); +} + +/// 39, receiver half: rejection is atomic and the peer keeps its last +/// valid frame. +/// +/// The producer-reachable route to an invalid frame runs through the +/// **mode line**, not the text: `TextView::render` drops zero-width marks +/// and never emits a cluster, and the terminal screen caps its clusters +/// at `MAX_TERMINAL_GRAPHEME_BYTES` — but `prepare_mode_line_runs` emits +/// whole grapheme clusters verbatim, and one of its inputs is the buffer +/// **name**. A name carrying a cluster past +/// `MAX_WIRE_GRID_GRAPHEME_BYTES` therefore produces a structurally +/// invalid panel frame through the ordinary display path. +#[test] +fn acc39_an_invalid_frame_is_rejected_and_the_previous_one_is_retained() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + set_panel_text(&session, "before"); + let valid = session.present(); + assert!( + rows_of(&valid)[0].starts_with("before"), + "fixture precondition: the retained frame really shows the old text" + ); + + // One grapheme cluster far past the shared per-cell byte ceiling. + let monster = format!("x{}", "\u{301}".repeat(300)); + assert!( + monster.len() > pmacs_protocol::wire_grid::MAX_WIRE_GRID_GRAPHEME_BYTES, + "fixture precondition: the cluster really exceeds the shared ceiling" + ); + exec( + &session.state, + &format!( + "BAD = pmacs.buffer.create({monster:?}) + pmacs.window.display(BAD, {{ side = \"bottom\" }})" + ), + ); + + assert_eq!( + session.frame(), + None, + "39: an invalid frame is not shipped, whole or in part" + ); + let retained = session + .render + .panel_declaration() + .expect("the previous valid frame is retained"); + assert_eq!( + retained, &valid, + "39: the receiver's authority is unchanged — same cells, same epochs" + ); + + // The rejection also burned no presentation identity: nothing the + // peer ever saw carried the epoch the rejected frame would have used. + exec( + &session.state, + "GOOD = pmacs.buffer.create(\"*good*\") + pmacs.window.display(GOOD, { side = \"bottom\" })", + ); + let recovered = session.present(); + assert_eq!( + recovered.panel_epoch, + valid.panel_epoch + 1, + "the next shipped identity is the one the rejected frame did not \ + consume" + ); +} + +// --------------------------------------------------------------------------- +// 41 (daemon half) — the daemon alone derives the grid +// --------------------------------------------------------------------------- + +#[test] +fn acc41_row_clamping_preserves_the_stored_request() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 12); + assert_eq!(session.present().size.rows, 12, "the request fits at first"); + + let panel = session.side_window().expect("side window"); + // A frame with room for the document minimum plus three panel rows. + session.declare(2, 6, COLS); + let clamped = session.present(); + assert_eq!( + clamped.size.rows, 3, + "the panel is clamped, not the document subtree" + ); + assert_eq!( + session.state.core.borrow().windows[&panel] + .params + .fixed_rows, + Some(12), + "41: the STORED request survives the clamp, so a later wider frame \ + can restore it" + ); + + session.declare(3, ROWS, COLS); + assert_eq!( + session.present().size.rows, + 12, + "41: and it is restored exactly" + ); +} + +#[test] +fn acc41_the_wire_area_budget_clamps_rows_and_can_hide_the_panel() { + let max_cells = pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS as u32; + + // Wide enough that only four rows fit inside the shared area bound. + let mut session = Session::new(); + let cols = max_cells / 4; + session.declare(1, 60, cols); + open_panel(&session, "*panel*", 20); + let frame = session.present(); + assert_eq!(frame.size.cols, cols); + assert_eq!( + frame.size.rows, 4, + "41: rows are clamped by the shared wire-area budget, not only by \ + the layout" + ); + frame + .validate() + .expect("41: a clamped frame is one the shared validator accepts"); + + // Wide enough that not even the structural two-row floor fits. + session.declare(2, 60, max_cells); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "41: when even two rows exceed the budget the panel follows the \ + Q#BP2b hidden arm" + ); +} + +#[test] +fn acc41_degenerate_geometry_fails_closed_to_zero_usable_grid() { + for (label, rows, cols) in [ + ("zero columns", ROWS, 0), + ("zero rows", 0, COLS), + ("a frame shorter than its own status row", 1, COLS), + ] { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + assert!( + matches!(session.frame(), Some(PanelFramePayload::Present(_))), + "{label}: fixture precondition — a band was visible first" + ); + + session.declare(2, rows, cols); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "{label}: declares zero usable geometry and hides, without \ + overflow or an oversized allocation" + ); + } +} + +// --------------------------------------------------------------------------- +// A2B-1 — the epoch state machine, row by row +// --------------------------------------------------------------------------- + +#[test] +fn a2b1_the_semantic_acceptance_table_holds_row_by_row() { + let session = Session::new(); + let total = CellSize::new(ROWS, COLS); + + assert_eq!( + session.declare(0, ROWS, COLS), + GeometryUpdate::Rejected, + "epoch 0 is reserved for 'never declared' and is rejected on the wire" + ); + assert_eq!( + session.state.core.borrow().frame_geometry_for(FID), + None, + "…and stores nothing" + ); + + assert_eq!(session.declare(5, ROWS, COLS), GeometryUpdate::Advanced); + assert_eq!(session.declare(5, ROWS, COLS), GeometryUpdate::Duplicate); + assert_eq!( + session.declare(5, ROWS, COLS + 1), + GeometryUpdate::Rejected, + "the same epoch with a different total is conflicting" + ); + assert_eq!( + session.declare(4, ROWS, COLS), + GeometryUpdate::Rejected, + "a LOWER epoch carrying identical data is still stale" + ); + assert_eq!( + session + .state + .core + .borrow() + .frame_geometry_for(FID) + .map(|geometry| (geometry.geometry_epoch, geometry.total)), + Some((5, total)), + "every rejection left the stored declaration untouched" + ); + + assert_eq!( + session.declare(6, ROWS, COLS), + GeometryUpdate::Advanced, + "Q#BP2S1: a greater epoch is accepted even when the total is \ + IDENTICAL — the font/scale case daemon-side value dedup cannot see" + ); + assert_eq!( + session + .state + .core + .borrow() + .frame_geometry_for(FID) + .map(|geometry| geometry.geometry_epoch), + Some(6), + "…and it is stored verbatim" + ); +} + +#[test] +fn a2b1_a_duplicate_reconciles_nothing_while_an_advance_does() { + let session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + + // Force the derived visibility cache to a value only reconciliation + // can correct. `Duplicate` must leave it alone; `Advanced` must not. + let force_hidden = |session: &Session| { + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .panel_hidden = true; + }; + + force_hidden(&session); + assert_eq!(session.declare(1, ROWS, COLS), GeometryUpdate::Duplicate); + assert!( + session.state.core.borrow().panel_hidden_for(FID), + "a Duplicate returns without touching panel state" + ); + + assert_eq!( + session.declare(2, ROWS, COLS), + GeometryUpdate::Advanced, + "…while an advance with the same total still reconciles" + ); + assert!( + !session.state.core.borrow().panel_hidden_for(FID), + "Advanced ran the reconciliation the Duplicate skipped" + ); +} + +#[test] +fn a2b1_a_rejected_declaration_reconciles_nothing() { + let session = Session::new(); + session.declare(4, ROWS, COLS); + open_panel(&session, "*panel*", 4); + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .panel_hidden = true; + + assert_eq!(session.declare(3, ROWS, COLS), GeometryUpdate::Rejected); + assert!( + session.state.core.borrow().panel_hidden_for(FID), + "a Rejected declaration is dropped BEFORE any reconciliation" + ); +} + +#[test] +fn a2b1_grid_allocator_exhaustion_clears_the_declaration_and_hides() { + // The grid/LOCAL allocator, which mints its own epochs. `LOCAL` is + // panel-capable, so this is the production path for a TUI. + let state = EditorState::new(); + exec(&state, "pmacs.lsp.config = {}"); + state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + exec( + &state, + "pmacs.window.display(pmacs.buffer.create(\"*panel*\"), \ + { side = \"bottom\", height = 4 })", + ); + assert!( + !state.core.borrow().panel_hidden_for(FrontendId::LOCAL), + "fixture precondition: the panel is visible before exhaustion" + ); + + // Drive the allocator to its last id. + state + .core + .borrow_mut() + .views + .get_mut(&FrontendId::LOCAL) + .expect("view") + .frame_geometry = Some(pmacs::window::DeclaredFrameGeometry { + geometry_epoch: u64::MAX, + total: CellSize::new(ROWS, COLS), + }); + + // A REAL resize now needs an id the allocator cannot mint. + assert_eq!( + state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS + 1, COLS)), + GeometryUpdate::Rejected, + "checked allocation refuses rather than pinning at u64::MAX, where \ + two different geometries would share one id" + ); + assert_eq!( + state.core.borrow().frame_geometry_for(FrontendId::LOCAL), + None, + "A2B-1: exhaustion CLEARS the authoritative declaration; retaining \ + the old one would keep painting a panel sized to a frame that no \ + longer exists" + ); + assert!( + state.core.borrow().panel_hidden_for(FrontendId::LOCAL), + "A2B-1: unknown geometry is non-presentable, so the panel hides" + ); + assert_eq!( + state.core.borrow().panel_grid_size(FrontendId::LOCAL), + None, + "…and no stale-geometry grid is derivable afterwards" + ); +} + +// --------------------------------------------------------------------------- +// 51 — a non-panel-capable semantic frontend gets no band at all +// --------------------------------------------------------------------------- + +#[test] +fn acc51_a_pre_panel_semantic_frontend_is_never_sent_a_panel_frame() { + let mut session = Session::new(); + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .panel_capable = false; + session.declare(1, ROWS, COLS); + // The Stage 1 fallback discards `side`, so this lands in the document + // window; assert that directly rather than assuming it. + open_panel(&session, "*panel*", 4); + assert_eq!( + session.side_window(), + None, + "51: capability fallback placed the buffer in a document window" + ); + assert_eq!( + session.frame(), + None, + "51: and no band message is produced for it in any case" + ); +} + +#[test] +fn acc51_a_v20_peer_is_sent_no_panel_frame_even_when_capable() { + let mut session = Session::new(); + session.render = SemanticRenderState::for_peer(FID, PROTOCOL_VERSION - 1); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + + assert!( + session.state.core.borrow().panel_grid_size(FID).is_some(), + "fixture precondition: the daemon CAN derive a grid here" + ); + assert_eq!( + session.frame(), + None, + "51: a peer below the panel version receives no PanelFrame" + ); +} + +// --------------------------------------------------------------------------- +// 42 — focusing the panel does not disturb the document projection +// --------------------------------------------------------------------------- + +#[test] +fn acc42_focusing_the_panel_leaves_the_document_surface_alone() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel = session.side_window().expect("side window"); + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + + let unfocused = session.present(); + assert!(!unfocused.focused, "the panel does not own focus yet"); + + session.state.core.borrow_mut().focus_window(FID, panel); + let focused = session.present(); + assert!(focused.focused, "the frame reports the focus transition"); + + let core = session.state.core.borrow(); + assert_eq!( + core.primary_document_window(FID), + Some(session.document), + "42: the document surface is unchanged while the panel is focused" + ); + assert_eq!( + core.primary_document_buffer(FID), + Some(document_buffer), + "42: and still names the document buffer, not the panel's" + ); +} + +// --------------------------------------------------------------------------- +// 52 — the panel honors the OWNING frontend's fold projection +// --------------------------------------------------------------------------- + +#[test] +fn acc52_a_non_projecting_frontend_sees_every_source_line_in_its_panel() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 6); + set_panel_text(&session, "alpha\nbravo\ncharlie\ndelta"); + exec( + &session.state, + "assert(pmacs.fold.fold(PANEL_BUF, { start = 0, ['end'] = 17 }))", + ); + + let rows = rows_of(&session.present()); + let painted = rows.join("\n"); + for line in ["alpha", "bravo", "charlie"] { + assert!( + painted.contains(line), + "52: fold_projection = false means the panel collapses nothing; \ + {line:?} is missing from\n{painted}" + ); + } + + // The discriminating half: the same fold DOES collapse for a + // projecting frontend, so the assertion above is not vacuous. + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .fold_projection = true; + let projected = rows_of(&session.present()).join("\n"); + assert!( + !projected.contains("bravo"), + "sanity: with projection on, the folded lines really do disappear \ + from\n{projected}" + ); +} + +// --------------------------------------------------------------------------- +// 45 — one provider invocation supplies both surfaces +// --------------------------------------------------------------------------- + +#[test] +fn acc45_one_statusline_invocation_serves_the_document_and_the_panel() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel_buffer = { + let core = session.state.core.borrow(); + let side = core.side_window_for(FID).expect("side window"); + core.windows[&side].buffer_id + }; + // The semantic fan-out is keyed on the DECLARED viewport buffer, so + // the document half only runs once the frontend has declared one. + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + session.render.set_viewport( + document_buffer, + pmacs::protocol::ByteRange { start: 0, end: 0 }, + 0, + ); + + exec( + &session.state, + "CALLS = 0 + pmacs.statusline.register { + name = 'probe', side = 'left', + fn = function(ctx) CALLS = CALLS + 1; return 'W' .. tostring(ctx.window) end, + }", + ); + + let messages = session.render.render_frame(&session.state); + let calls: u32 = session + .state + .lua_host + .lua() + .load("return CALLS") + .eval() + .expect("counter"); + assert_eq!( + calls, 2, + "45: exactly one invocation per visible context — the primary \ + document and the visible side window — and no second evaluation \ + for the band" + ); + + let panel_rows = messages + .iter() + .find_map(|message| match message { + InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(rows_of(frame)), + _ => None, + }) + .expect("a panel frame"); + let mode_line = panel_rows.last().expect("mode line").clone(); + let panel_name = { + let core = session.state.core.borrow(); + let reg = core.registry.borrow(); + reg.get(panel_buffer) + .expect("panel buffer") + .name() + .to_owned() + }; + assert!( + mode_line.contains(&panel_name), + "45: the band's mode line carries the SIDE window's provider text, \ + not the document's; got {mode_line:?}" + ); +} + +// --------------------------------------------------------------------------- +// The producer never touches a passive panel's scroll state +// --------------------------------------------------------------------------- + +#[test] +fn a_passive_panel_keeps_its_view_top_while_a_focused_one_scrolls_to_its_caret() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel = session.side_window().expect("side window"); + let body: String = (0..40).map(|n| format!("line {n}\n")).collect(); + set_panel_text(&session, &body); + + // Put the panel's caret far below its viewport while it is PASSIVE. + { + let mut core = session.state.core.borrow_mut(); + let window = core.windows.get_mut(&panel).expect("panel window"); + window.cursor = 200; + window.view_top = 0; + } + let _ = session.present(); + assert_eq!( + session.state.core.borrow().windows[&panel].view_top, + 0, + "a passive panel's view_top is not moved by the projection" + ); + + session.state.core.borrow_mut().focus_window(FID, panel); + let _ = session.present(); + assert!( + session.state.core.borrow().windows[&panel].view_top > 0, + "…while a FOCUSED panel runs the shared auto-scroll clamp" + ); +} + +// --------------------------------------------------------------------------- +// The band rides the terminal document path too +// --------------------------------------------------------------------------- + +#[test] +fn the_band_is_projected_even_when_the_document_surface_is_a_terminal() { + // A frontend with no declared byte viewport takes neither the + // document nor the terminal pass; the band must still be produced, + // or the first panel would be unpaintable. + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + assert!( + session.render.panel_declaration().is_none(), + "fixture precondition: nothing shipped yet" + ); + let frame = session.present(); + assert_eq!(frame.size.cols, COLS); + assert!( + session.render.panel_declaration().is_some(), + "the declaration is recorded for the inbound validation ladder" + ); +} + +// --------------------------------------------------------------------------- +// Sanity: the projected cells really are the panel window's content +// --------------------------------------------------------------------------- + +#[test] +fn the_projection_paints_the_side_windows_own_buffer() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + set_panel_text(&session, "panel-content"); + let frame = session.present(); + let rows = rows_of(&frame); + assert_eq!(rows.len(), frame.size.rows as usize); + assert!( + rows[0].starts_with("panel-content"), + "the first row is the panel buffer's first line, got {:?}", + rows[0] + ); + assert_eq!( + frame.cells.len(), + (frame.size.rows * frame.size.cols) as usize, + "exactly size.area() cells" + ); + frame.validate().expect("a produced frame is a valid frame"); + let _ = HashMap::::new(); +}