Produce PanelFrame and gate the inbound panel events
Bottom-panel Stage 2B-2, second half: the producer, the presentation epoch, and the three inbound event gates. The producer lives beside the terminal pass in `semantic_render.rs` and follows its shape: compare the complete payload first, validate only a payload that differs, and store only what was actually shipped. The presentation epoch is allocated from the side window and its buffer, so a new side window, a replaced buffer, and every `Absent` -> `Present` transition each take a fresh identity; `Absent` clears the identity, which is what makes close/hide/reopen of the SAME persistent buffer unaddressable by a stale `PanelPointer`. Allocation is checked and exhaustion fails closed to `Absent` rather than wrapping into a live identity. The `Absent` baseline is seeded rather than left empty: a fresh session has no band, so the opening state is a fact the peer already holds. The band rides both render paths and does not wait for a declared byte viewport: it is a separate surface, and gating it on the document declaration would leave the first panel unpaintable. Its mode line takes the side window's segments from the SAME provider invocation that serves the document's wire segments. Inbound, `peer_may_send_panel_events` checks four facts together — an installed semantic projection, the negotiated version, the daemon's own capability bit, and (via the transport source) that the payload's claimed id is never consulted. `panel_event_epochs_are_current` then runs Q#BP16 steps 2-4 as one predicate so no caller can check the geometry epoch and forget the presentation epoch. Two daemon gates moved from `panel_capable` to `!semantic_render`. Stage 1 could conflate them because panel capability implied grid; now that a semantic view can be panel-capable, a capability-keyed gate would feed it the permanent 24x80 attach placeholder that Q#BP15a forbids, and parent acceptance 40 would fail through the attach line rather than through the projection. Not a live defect — no production semantic session is panel-capable yet — but it is the landmine Stage 2B-3 would have stepped on. `panel_capable` is unchanged for production negotiation and the unsolicited `Hello` still advertises v20. Nothing here is reachable by a user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
parent
81f54e23a9
commit
817b134ae8
689
src/daemon.rs
689
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<FrontendId, crate::semantic_render::SemanticRenderState>,
|
||||
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<FrontendId, crate::semantic_render::SemanticRenderState>,
|
||||
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<crate::window::WindowId>) {
|
||||
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<FrontendId, crate::semantic_render::SemanticRenderState>,
|
||||
render_states: &mut HashMap<FrontendId, RenderState>,
|
||||
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<FrontendId, crate::semantic_render::SemanticRenderState>,
|
||||
) -> (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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<CellCoord> {
|
||||
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)?;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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<PanelFramePayload>,
|
||||
/// 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<PanelPresentation>,
|
||||
/// 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<WindowId>,
|
||||
) -> 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<InstanceMessage>,
|
||||
) {
|
||||
// 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<InstanceMessage>) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<PanelFramePayload> {
|
||||
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<WindowId> {
|
||||
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<String> {
|
||||
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::<String>()
|
||||
})
|
||||
.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::<u32, u32>::new();
|
||||
}
|
||||
Loading…
Reference in New Issue