Merge pull request #177 from levineuwirth/bottom-panel-stage2a

feat(panel): bottom-panel Stage 2A — classified census routing + painter extraction
This commit is contained in:
Levi Neuwirth 2026-07-26 17:28:49 +00:00 committed by GitHub
commit 0a3fcd1942
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1911 additions and 201 deletions

View File

@ -757,10 +757,72 @@ If it does not, stop and repair the remote/fetch configuration.
- `pmacs-gpu` itself failed 201/202 once under the same load and passed
202/202 on immediate rerun.
## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 IN FRAMING
## Bottom-panel lane (Arc 7) — Stage 1 + framing MERGED; Stage 2A IN REVIEW
Stage 1 is on `main`. **Stage 2 is in framing**, no implementation in
flight.
Stage 1 and the Stage 2 framing are on `main`. **Stage 2A is
implemented and in review.**
- **Stage 2A — portable branch `githubsucks/bottom-panel-stage2a`**,
worktree `../pmacs-bp-stage2a`, **canonical `main` @ `cf54270`
integrated** (review round 1, finding 4 — the terminal-config lane
#173 also changes `src/editor.rs`, so gates were rerun on the merge
result, not the old combination). Five commits: the classified census
routing, the painter extraction + acceptance, the lane record, then
the round-1, round-2 and round-3 review fixes. **No protocol change; no behavior
change for any frontend today** — with `panel_capable = false` for
semantic sessions, `primary_document_window` returns `view.active`
in every existing configuration, so this is seam adoption that
becomes load-bearing in 2B.
- Verification on the merge result: `cargo fmt --check` clean; strict
workspace Clippy clean; **1,832 default + 2,015 CRDT** library tests;
`bottom_panel_stage2a_acceptance` **17**; bottom-panel Stage 1 46;
statusline segments 8 CRDT; m11_5 semantic 2 CRDT; GPU initial target
14 CRDT; terminal config 12 CRDT; vterm Stage 1/2 10 / 6; folding
Stage 2 48; M4 121; required GPU 202; `git diff --check` clean.
- **Every routed producer is now pinned at a seam its production caller
uses, and each pin was falsified by revert**: #1 follow, #2 lazy CRDT
upgrade, #3 `CursorByte`, #5 decorations, #7 `Viewport` (aligns
without focusing), #8 `Pointer` (aligns and focuses), #9 the
terminal-context gate, #12 statusline, #21 the publication filter,
plus the focus-class negatives. #1/#3/#21 required extracting three
named helpers, because their only production caller is
`dispatcher_loop`, which no test can drive.
- **Three lessons about the TESTS, not the code, all from review:**
(a) a *structural* test comparing the two authorities directly does
**not** catch a misrouted consumer — only consumer-level assertions
do; (b) a daemon-path test must `register_session` or the event is
dropped at the uninstalled-session check before reaching the code
under test; (c) a discriminating fixture must make the two routings
DISAGREE — comparing two non-terminal buffers, or two windows with no
selection, yields the same answer either way and proves nothing.
Round 2 found four of my own pins vacuous by exactly these shapes, and
round 3 found two more problems of the same family: a pin placed at a
HELPER while production called it from a producer (reverting only the
producer's call site left every test green), and a socket-pair
assertion whose blocking read made a regression HANG instead of fail.
Both now assert at the producer, with read timeouts on every read.
- **Review round 1 closed: 4 P1 + 2 P2, all real.** The P1s were a
stale-`Pointer` focus steal (the failed-alignment arm returned the
window, so #8's activation focused it before `dispatch_pointer`
rejected the buffer), the missing A2A-2 two-context fan-out, a census
suite that asserted the AUTHORITY rather than the CONSUMERS, and the
missing main integration. **Two of the new pins were themselves
vacuous on the first attempt** — the dispatcher test passed because an
unregistered session is dropped at `daemon.rs:1962` before reaching
the aligner, and the painter test was a fixed-point check that
survived deleting `text_view.render`. Both now fail under their own
bite.
- **`vterm_stage3_acceptance::a37` is a pre-existing flake here**, not a
Stage 2A regression: measured **6/8 failures on the base commit** and
**7/8 on the branch** in matched isolated samples. It needs a real
daemon + real PTY + headless GPU and is documented load-sensitive.
It also silently returns `ok` unless `pmacs-gpu` has been built, and
is `crdt`-gated so CI never runs it at all.
- **Two suites are dark without `--features crdt`**:
`m11_5_semantic_acceptance` reports **0 tests** and
`gpu_initial_target_acceptance` reports **1** in the default config.
Both are semantic-census suites, so Stage 2A must be gated with the
feature on or its most relevant coverage never executes.
- Stage 1 merged as **#155** (`main` @ `e745068`, 2026-07-24, after two
review rounds). No protocol change. Durable substrate facts live in

View File

@ -1145,10 +1145,7 @@ fn dispatcher_loop(
.session_state(*fid)
.is_some_and(|s| s.negotiated_capabilities.semantic_render)
{
let active_now = {
let core = editor.core.borrow();
core.active_window_for(*fid).map(|w| w.buffer_id)
};
let active_now = document_buffer_to_follow(editor, *fid);
if let Some(active_now) = active_now
&& last_active_buffer_sent.get(fid) != Some(&active_now)
{
@ -1429,17 +1426,15 @@ fn dispatcher_loop(
&& session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_capabilities.crdt_replica)
&& let Some((buffer_id, byte_pos)) = document_cursor_byte(editor, *fid)
{
let core = editor.core.borrow();
if let Some(window) = core.active_window_for(*fid) {
let cursor_byte_msg = InstanceMessage::CursorByte {
buffer_id: window.buffer_id,
byte_pos: window.cursor,
};
if let Err(e) = write_message(stream, &cursor_byte_msg) {
eprintln!("pmacs: write CursorByte for {fid:?} failed: {e}");
write_failed = true;
}
let cursor_byte_msg = InstanceMessage::CursorByte {
buffer_id,
byte_pos,
};
if let Err(e) = write_message(stream, &cursor_byte_msg) {
eprintln!("pmacs: write CursorByte for {fid:?} failed: {e}");
write_failed = true;
}
}
}
@ -2038,12 +2033,17 @@ fn handle_dispatcher_event(
// straight back off it. The declared buffer is
// checked too — a terminal has no byte viewport to
// honor from any direction.
// Bottom-panel §1.3 #9 — Projection. The gate asks
// "is this frontend's DOCUMENT surface a terminal",
// so it tests the primary document window. A focused
// TERMINAL PANEL must not suppress the still-visible
// document's viewport.
let terminal_context = {
let manager = editor.terminal_manager.borrow();
let core = editor.core.borrow();
let active = core
.active_window_for(source)
.is_some_and(|window| manager.is_terminal(window.buffer_id));
.primary_document_buffer(source)
.is_some_and(|document| manager.is_terminal(document));
active || manager.is_terminal(buffer_id)
};
if semantic_states.contains_key(&source) && !terminal_context {
@ -2056,7 +2056,11 @@ fn handle_dispatcher_event(
// LOCAL's attach-time buffer (often a scratch the
// user isn't viewing), so arrow keys moved an
// off-screen cursor and the caret never tracked.
align_semantic_window_to_buffer(editor, source, buffer_id);
// Bottom-panel §1.3 #7 — Projection, and it must
// NOT move focus. Routing this through the
// focused window would let an ordinary document
// viewport overwrite a focused panel's buffer.
align_primary_document_window(editor, source, buffer_id);
if let Some(sem) = semantic_states.get_mut(&source) {
sem.set_viewport(buffer_id, visible, generation);
}
@ -2121,7 +2125,11 @@ fn handle_dispatcher_event(
// aligns to the buffer the frontend says it was
// displaying: a click can race a buffer switch.
if semantic_states.contains_key(&source) {
align_semantic_window_to_buffer(editor, source, buffer_id);
// Bottom-panel §1.3 #8 — Projection + focus. A
// click in the DOCUMENT area means "work here",
// so unlike `Viewport` (#7) this one also takes
// focus out of a panel.
align_and_activate_primary_document_window(editor, source, buffer_id);
if kind == PointerKind::Context {
// Q#CM1 — right-click opens the context menu
// at the hit byte (needs the Lua builder, so
@ -2359,9 +2367,15 @@ fn ensure_active_buffer_crdt_backed(
editor: &EditorState,
fid: FrontendId,
) -> Option<crate::buffer::BufferId> {
// Bottom-panel §1.3 #2 — Projection, and the sharpest case in the
// census. The upgrade BROADCASTS a `BufferSnapshot` to every
// replica, so keying it on focus would mean focusing a fresh
// generated panel buffer swaps every peer's document mirror to it.
// A panel buffer that genuinely needs CRDT backing gets it when it
// is displayed as a document, not as a side effect of focus.
let buffer_id_opt = {
let core = editor.core.borrow();
core.active_window_for(fid).map(|w| w.buffer_id)
core.primary_document_buffer(fid)
};
let buffer_id = buffer_id_opt?;
let core = editor.core.borrow();
@ -2493,11 +2507,7 @@ fn publish_buffer_snapshot_to_replicas(
continue;
}
if session.negotiated_capabilities.semantic_render {
let displays_buffer = editor
.core
.borrow()
.active_window_for(*peer_id)
.is_some_and(|window| window.buffer_id == buffer_id);
let displays_buffer = peer_displays_buffer_as_document(editor, *peer_id, buffer_id);
if !displays_buffer {
continue;
}
@ -2936,32 +2946,88 @@ fn handle_remote_crdt_op(
/// whole switch. This is the input/display alignment fix for B1: the
/// frontend's *declared* buffer becomes the buffer its keys edit and
/// its `CursorByte` reports.
fn align_semantic_window_to_buffer(
/// The buffer a semantic frontend DISPLAYS AS ITS DOCUMENT — the
/// buffer-follow / `BufferSnapshot` re-send target (bottom-panel §1.3
/// #1, Projection).
///
/// Not the focused buffer: focusing a panel must re-send no snapshot and
/// must never swap the replica's document mirror. Named as its own
/// function so the rule is pinnable — its only caller is
/// `dispatcher_loop`, which no test can drive.
#[cfg(feature = "crdt")]
fn document_buffer_to_follow(
editor: &EditorState,
fid: FrontendId,
) -> Option<crate::buffer::BufferId> {
editor.core.borrow().primary_document_buffer(fid)
}
/// The `(buffer, byte)` a semantic replica's authoritative `CursorByte`
/// describes (bottom-panel §1.3 #3, Projection).
///
/// Q#BP14's vocabulary split: "active buffer" in the replica is a
/// DOCUMENT-SURFACE term, not an input-focus term, so a focused panel
/// must not retarget the document caret at the panel's buffer.
fn document_cursor_byte(
editor: &EditorState,
fid: FrontendId,
) -> Option<(crate::buffer::BufferId, u64)> {
let core = editor.core.borrow();
let win_id = core.primary_document_window(fid)?;
let window = core.windows.get(&win_id)?;
Some((window.buffer_id, window.cursor))
}
/// Whether `peer_id` displays `buffer_id` on its DOCUMENT surface — the
/// `BufferSnapshot` publication recipient filter (bottom-panel §1.3 #21,
/// Projection).
///
/// Testing the focused window instead would both miss a buffer visible
/// in the document while a panel holds focus, and replace the peer's
/// document mirror for a buffer visible only in a panel.
fn peer_displays_buffer_as_document(
editor: &EditorState,
peer_id: FrontendId,
buffer_id: crate::buffer::BufferId,
) -> bool {
editor.core.borrow().primary_document_buffer(peer_id) == Some(buffer_id)
}
/// Align a semantic frontend's **primary document window** to the
/// buffer it declared (bottom-panel §1.3 #7, Q#BP14).
///
/// **Never touches `view.active`.** This is why rejecting panel-named
/// events does not fix the *document* event: with a panel focused, an
/// ordinary document `Viewport` routed through the focused window would
/// overwrite the panel's buffer with the document buffer. Returns the
/// window it aligned so the `Pointer` path (#8) can activate it.
fn align_primary_document_window(
editor: &mut EditorState,
fid: FrontendId,
buffer_id: crate::buffer::BufferId,
) {
) -> Option<crate::window::WindowId> {
use crate::text_view::TextView;
let text_view = {
let (win_id, text_view) = {
let core = editor.core.borrow();
let Some(win_id) = core.views.get(&fid).map(|v| v.active) else {
return;
};
let win_id = core.primary_document_window(fid)?;
if core.windows.get(&win_id).map(|w| w.buffer_id) == Some(buffer_id) {
return; // Already displaying this buffer.
return Some(win_id); // Already displaying this buffer.
}
let reg = core.registry.borrow();
let Ok(buf) = reg.get(buffer_id) else {
return; // Unknown buffer — leave the window as-is.
// Unknown buffer — leave the window as-is, and report
// FAILURE. Returning the window here would let a stale or
// forged `Pointer` naming a dead buffer take focus out of a
// panel via #8's activation, *before* `dispatch_pointer`
// rejects the mismatched buffer. Alignment did not happen,
// so no caller may treat this as a document gesture.
return None;
};
TextView::new(buf)
(win_id, TextView::new(buf))
};
let mut core = editor.core.borrow_mut();
let Some(win_id) = core.views.get(&fid).map(|v| v.active) else {
return;
};
if let Some(win) = core.windows.get_mut(&win_id) {
win.buffer_id = buffer_id;
win.text_view = text_view;
@ -2969,6 +3035,24 @@ fn align_semantic_window_to_buffer(
win.selection = None;
win.overlays.clear();
}
Some(win_id)
}
/// Align the primary document window **and take focus to it**
/// (bottom-panel §1.3 #8, Q#BP14).
///
/// A click in the document area means "work here", so it moves focus
/// out of a panel. This is the one place projection and focus
/// legitimately move together — every other Projection consumer must
/// use [`align_primary_document_window`] alone.
fn align_and_activate_primary_document_window(
editor: &mut EditorState,
fid: FrontendId,
buffer_id: crate::buffer::BufferId,
) {
if let Some(win_id) = align_primary_document_window(editor, fid, buffer_id) {
editor.core.borrow_mut().focus_window(fid, win_id);
}
}
fn build_fresh_frontend_view(
@ -3399,6 +3483,96 @@ mod tests {
);
}
/// Bottom-panel §1.3 #21 through the REAL producer (round 3).
///
/// A semantic peer with a FOCUSED PANEL must still receive the
/// snapshot for the buffer on its DOCUMENT surface, and must NOT
/// receive one for a buffer visible only in its panel. Asserting the
/// helper alone was insufficient: reverting the producer's call site
/// to focused-window routing left every helper-level test green.
#[cfg(feature = "crdt")]
#[test]
fn snapshot_publication_follows_the_document_under_a_focused_panel() {
let (editor, fid, document, panel) = panel_focused_semantic_fixture();
let (doc_buf, panel_buf) = {
let core = editor.core.borrow();
(
core.windows[&document].buffer_id,
core.windows[&panel].buffer_id,
)
};
assert_ne!(doc_buf, panel_buf, "fixture: distinct buffers");
let caps = crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
};
let mut registry = SessionRegistry::new();
registry.register_session(
fid,
crate::presence::SessionState::new(PROTOCOL_VERSION, caps, 0),
);
// The DOCUMENT buffer's snapshot must be delivered.
{
let (server, mut client) = UnixStream::pair().expect("socketpair");
// A read timeout on the DELIVERY read too. Without it a
// regression that suppresses the snapshot makes this test
// HANG rather than fail, which is strictly worse than a red
// assertion — found by biting this very test.
client
.set_read_timeout(Some(Duration::from_millis(500)))
.expect("delivery timeout");
let mut streams = HashMap::from([(fid, server)]);
let message = InstanceMessage::BufferSnapshot {
buffer_id: doc_buf,
crdt_snapshot: vec![1, 2, 3],
};
publish_buffer_snapshot_to_replicas(
&editor,
doc_buf,
&message,
&registry,
&mut streams,
&mut HashMap::new(),
);
let delivered: InstanceMessage =
read_message(&mut client).expect("the document snapshot must arrive");
assert_eq!(
delivered, message,
"#21: a buffer on the DOCUMENT surface must still be published while a \
panel holds focus"
);
}
// The PANEL-only buffer's snapshot must NOT be delivered.
{
let (server, mut client) = UnixStream::pair().expect("socketpair");
let mut streams = HashMap::from([(fid, server)]);
let message = InstanceMessage::BufferSnapshot {
buffer_id: panel_buf,
crdt_snapshot: vec![4, 5, 6],
};
publish_buffer_snapshot_to_replicas(
&editor,
panel_buf,
&message,
&registry,
&mut streams,
&mut HashMap::new(),
);
client
.set_read_timeout(Some(Duration::from_millis(50)))
.expect("timeout");
assert!(
read_message::<InstanceMessage>(&mut client).is_err(),
"#21: a buffer visible only in a PANEL must not replace the peer's \
document mirror"
);
}
}
// ---- GPU terminal input: the double terminal-layout sync -------------
//
// These drive `sync_terminal_layouts_for_tick` — the REAL dispatcher loop
@ -4385,7 +4559,7 @@ mod tests {
/// B1 input/display alignment: a semantic frontend's window is bound
/// to LOCAL's attach-time buffer, but the buffer it *displays* is
/// the one it declares via `Viewport`. `align_semantic_window_to_buffer`
/// the one it declares via `Viewport`. `align_primary_document_window`
/// re-points the window so keys edit the displayed buffer — without
/// it, arrow keys moved an off-screen cursor in the wrong buffer and
/// the caret never tracked.
@ -4423,7 +4597,9 @@ mod tests {
);
// The frontend declares it is displaying the file buffer.
align_semantic_window_to_buffer(&mut editor, fid, file);
// Bottom-panel §1.3 #7: `Viewport` takes the projection-only
// aligner, which never touches `view.active`.
align_primary_document_window(&mut editor, fid, file);
assert_eq!(
editor
.core
@ -4582,4 +4758,381 @@ mod tests {
"the panel was not overwritten with the target"
);
}
/// Bottom-panel §1.3 #8, review round 1 finding 1: a STALE document
/// `Pointer` must not steal focus out of a panel.
///
/// Driven through `handle_dispatcher_event` — the real dispatcher
/// seam — because the defect lived in the *pair* of alignment and
/// activation, not in either alone. `align_primary_document_window`
/// once returned the window even when the named buffer was gone, so
/// #8's activation focused the document before `dispatch_pointer`
/// ever rejected the mismatched buffer.
#[cfg(feature = "crdt")]
#[test]
fn a_stale_document_pointer_does_not_steal_focus_from_a_panel() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams};
use pmacs_protocol::{Modifiers, PointerKind};
let mut editor = EditorState::new();
let fid = FrontendId(88);
// One document window + one focused bottom panel.
let (document, panel, dead_buffer) = {
let mut core = editor.core.borrow_mut();
let doc_buf = core.active_window().buffer_id;
let panel_buf = core.registry.borrow_mut().create("*panel*");
// A buffer id that names nothing: the stale-pointer payload.
let dead_buffer = crate::buffer::BufferId::from_raw(999_999);
let document = crate::window::WindowId::next();
let panel_id = crate::window::WindowId::next();
let (doc_view, panel_view) = {
let reg = core.registry.borrow();
(
crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")),
crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")),
)
};
core.windows
.insert(document, Window::new(document, doc_buf, doc_view));
let mut panel = 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);
panel.params = params;
core.windows.insert(panel_id, panel);
core.register_frontend_view(
fid,
FrontendView {
layout: Layout {
root: LayoutNode::Split {
orientation: Orientation::Horizontal,
children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel_id)],
weights: vec![1, 1],
},
},
active: panel_id,
fold_projection: false,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
(document, panel_id, dead_buffer)
};
let mut render_states = HashMap::new();
let mut semantic_states = HashMap::new();
semantic_states.insert(fid, crate::semantic_render::SemanticRenderState::new(fid));
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();
// The dispatcher drops any event from an UNINSTALLED session
// (#148's defense-in-depth membership check), so the session must
// be registered or this test passes for the wrong reason — it did,
// on the first attempt.
let mut registry = SessionRegistry::new();
registry.register_session(
fid,
crate::presence::SessionState {
negotiated_protocol_version: pmacs_protocol::PROTOCOL_VERSION,
negotiated_capabilities: crate::protocol::NegotiatedCapabilities {
semantic_render: true,
crdt_replica: true,
..Default::default()
},
color_slot: 0,
},
);
handle_dispatcher_event(
DispatcherEvent::FrontendEvent {
source: fid,
event: FrontendEvent::Pointer {
frontend_id: fid,
buffer_id: dead_buffer,
byte: 0,
kind: PointerKind::Down,
mods: Modifiers::default(),
},
},
&mut editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut last_idle,
&mut last_active,
&mut bells,
&mut registry,
);
assert_eq!(
editor.core.borrow().views[&fid].active,
panel,
"a stale Pointer naming a dead buffer must NOT move focus out of the panel"
);
assert_ne!(
editor.core.borrow().views[&fid].active,
document,
"non-vacuity: the document window is a real, distinct focus target"
);
}
/// Bottom-panel §1.3 #1/#3/#21 — the three Projection producers whose
/// only production caller is `dispatcher_loop`, pinned at the named
/// seams that loop calls. Round 2 finding: reverting any of them to
/// `active_window_for` previously left every test green.
#[cfg(feature = "crdt")]
#[test]
fn tick_producers_describe_the_document_while_a_panel_is_focused() {
let (editor, fid, document, panel) = panel_focused_semantic_fixture();
let (doc_buf, panel_buf, doc_cursor) = {
let core = editor.core.borrow();
(
core.windows[&document].buffer_id,
core.windows[&panel].buffer_id,
core.windows[&document].cursor,
)
};
assert_ne!(doc_buf, panel_buf, "fixture: distinct buffers");
// #1 buffer-follow / BufferSnapshot re-send target.
assert_eq!(
document_buffer_to_follow(&editor, fid),
Some(doc_buf),
"#1: the follow target must be the DOCUMENT buffer, not the focused panel's"
);
// #3 CursorByte.
assert_eq!(
document_cursor_byte(&editor, fid),
Some((doc_buf, doc_cursor)),
"#3: CursorByte must describe the DOCUMENT surface"
);
// #21 is deliberately NOT asserted here. Round 3: pinning it at
// this helper left the real producer free to regress — reverting
// the call site inside `publish_buffer_snapshot_to_replicas`
// kept both this test and the existing socket-pair test green.
// It is pinned through the producer instead, in
// `snapshot_publication_follows_the_document_under_a_focused_panel`.
let _ = panel_buf;
}
/// Bottom-panel §1.3 #2 — the sharpest census case: the lazy CRDT
/// upgrade BROADCASTS a snapshot, so keying it on focus would let
/// focusing a fresh generated panel buffer swap every peer's mirror.
#[cfg(feature = "crdt")]
#[test]
fn lazy_crdt_upgrade_never_targets_a_focused_panel_buffer() {
let (editor, fid, document, panel) = panel_focused_semantic_fixture();
let (doc_buf, panel_buf) = {
let core = editor.core.borrow();
(
core.windows[&document].buffer_id,
core.windows[&panel].buffer_id,
)
};
let upgraded = ensure_active_buffer_crdt_backed(&editor, fid);
assert_eq!(
upgraded,
Some(doc_buf),
"#2: the upgrade must target the DOCUMENT buffer"
);
assert_ne!(
upgraded,
Some(panel_buf),
"#2: focusing a panel must never trigger its buffer's upgrade+broadcast"
);
}
/// Bottom-panel §1.3 #7 vs #8 — `Viewport` aligns WITHOUT moving
/// focus; only `Pointer` activates. Driven through the real
/// dispatcher seam.
#[cfg(feature = "crdt")]
#[test]
fn viewport_aligns_the_document_without_taking_focus_from_the_panel() {
let (mut editor, fid, document, panel) = panel_focused_semantic_fixture();
let other = {
let mut core = editor.core.borrow_mut();
core.registry.borrow_mut().create("*other*")
};
dispatch_one_semantic_event(
&mut editor,
fid,
FrontendEvent::Viewport {
frontend_id: fid,
buffer_id: other,
visible: pmacs_protocol::ByteRange { start: 0, end: 0 },
generation: 0,
},
);
assert_eq!(
editor.core.borrow().views[&fid].active,
panel,
"#7: a document Viewport must NOT move focus out of the panel"
);
assert_eq!(
editor.core.borrow().windows[&document].buffer_id,
other,
"#7: it must still have ALIGNED the document window to the declared buffer"
);
}
/// Shared fixture: a semantic frontend with a document window and a
/// FOCUSED bottom panel. `panel_capable` is set explicitly because
/// Stage 1 ships `false` for semantic sessions and 2B flips it for a
/// v21-negotiated peer.
#[cfg(feature = "crdt")]
fn panel_focused_semantic_fixture() -> (
crate::editor::EditorState,
FrontendId,
crate::window::WindowId,
crate::window::WindowId,
) {
use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams};
let editor = crate::editor::EditorState::new();
let fid = FrontendId(91);
let (document, panel) = {
let mut core = editor.core.borrow_mut();
let doc_buf = core.active_window().buffer_id;
let panel_buf = core.registry.borrow_mut().create("*panel*");
let document = crate::window::WindowId::next();
let panel = crate::window::WindowId::next();
let (doc_view, panel_view) = {
let reg = core.registry.borrow();
(
crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")),
crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")),
)
};
core.windows
.insert(document, Window::new(document, doc_buf, doc_view));
let mut panel_window = Window::new(panel, panel_buf, panel_view);
let mut params = WindowParams::default();
params.side = Some(crate::window::Side::Bottom);
params.fixed_rows = Some(4);
panel_window.params = params;
core.windows.insert(panel, panel_window);
core.register_frontend_view(
fid,
FrontendView {
layout: Layout {
root: LayoutNode::Split {
orientation: Orientation::Horizontal,
children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel)],
weights: vec![1, 1],
},
},
active: panel,
fold_projection: false,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
(document, panel)
};
editor.sync_frame_geometry(fid, CellSize::new(24, 80));
(editor, fid, document, panel)
}
/// Drive ONE authenticated semantic event through the real
/// dispatcher. The session must be registered or the event is
/// dropped at the uninstalled-session check before reaching any
/// handler.
#[cfg(feature = "crdt")]
fn dispatch_one_semantic_event(
editor: &mut crate::editor::EditorState,
fid: FrontendId,
event: FrontendEvent,
) {
let mut render_states = HashMap::new();
let mut semantic_states = HashMap::new();
semantic_states.insert(fid, crate::semantic_render::SemanticRenderState::new(fid));
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,
crate::presence::SessionState {
negotiated_protocol_version: pmacs_protocol::PROTOCOL_VERSION,
negotiated_capabilities: crate::protocol::NegotiatedCapabilities {
semantic_render: true,
crdt_replica: true,
..Default::default()
},
color_slot: 0,
},
);
handle_dispatcher_event(
DispatcherEvent::FrontendEvent { source: fid, event },
editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut last_idle,
&mut last_active,
&mut bells,
&mut registry,
);
}
/// Bottom-panel §1.3 #9 — Projection. The `Viewport` terminal-context
/// gate asks "is this frontend's DOCUMENT surface a terminal", so a
/// focused TERMINAL PANEL must not suppress the still-visible
/// document's viewport.
#[cfg(feature = "crdt")]
#[test]
fn a_focused_terminal_panel_does_not_suppress_the_document_viewport() {
use crate::terminal::TerminalSpec;
let (mut editor, fid, document, panel) = panel_focused_semantic_fixture();
let other = editor.core.borrow().registry.borrow_mut().create("*other*");
// A REAL terminal in the focused panel.
let mut spec = TerminalSpec::new("/bin/sh");
spec.rows = 10;
spec.cols = 40;
let term_buf = editor.open_terminal(spec).expect("a real terminal");
editor
.core
.borrow_mut()
.install_buffer_in_window(panel, term_buf)
.expect("terminal into the panel");
editor.core.borrow_mut().focus_window(fid, panel);
dispatch_one_semantic_event(
&mut editor,
fid,
FrontendEvent::Viewport {
frontend_id: fid,
buffer_id: other,
visible: pmacs_protocol::ByteRange { start: 0, end: 0 },
generation: 0,
},
);
assert_eq!(
editor.core.borrow().windows[&document].buffer_id,
other,
"#9: a focused TERMINAL panel must not suppress the document viewport — the document window should still have aligned to the declared buffer"
);
}
}

View File

@ -1409,9 +1409,16 @@ impl EditorState {
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
) -> Option<TerminalViewKey> {
// Bottom-panel §1.3 #6/#10/#11 — Projection. The full-window
// semantic terminal declaration, its snapshot/sync, and its
// frame suppression all describe the frontend's PRIMARY DOCUMENT
// surface, never a panel band: panel terminals get `PanelFrame`
// / `PanelPointer` in Stage 2B instead. Resolving through
// `view.active` would let a focused panel terminal both claim
// the document declaration and suppress the document pass.
let core = self.core.borrow();
let view = core.views.get(&frontend_id)?;
let window = core.windows.get(&view.active)?;
let win_id = core.primary_document_window(frontend_id)?;
let window = core.windows.get(&win_id)?;
if window.buffer_id != buffer_id {
return None;
}
@ -1540,6 +1547,16 @@ impl EditorState {
if coord.row >= size.rows || coord.col >= size.cols {
return false;
}
// Bottom-panel §1.3 #11 — Projection + focus. A non-hover
// gesture on the DOCUMENT terminal means "work here", so it
// takes focus back out of a panel before the gesture replays;
// bare hover neither focuses nor claims the controller.
if !matches!(kind, TerminalMouseKind::Move) {
let mut core = self.core.borrow_mut();
if let Some(win_id) = core.primary_document_window(frontend_id) {
core.focus_window(frontend_id, win_id);
}
}
self.core.borrow_mut().active_frontend = frontend_id;
self.apply_terminal_gesture(key, size, coord, kind, mods, (coord.row, coord.col));
true
@ -3220,6 +3237,170 @@ impl CompletionPopupKey {
}
}
/// Scroll one window so its cursor stays visible, reckoning in
/// **visible** lines when a fold map is supplied (Arc 6 Q#FD18).
///
/// Extracted from `paint_frame` for bottom-panel Stage 2 (Q#BP8): the
/// panel band runs this for its own window when that window owns focus,
/// against the same supplied map, and leaves a passive panel's
/// `view_top` untouched.
///
/// **The fold map is a parameter, never built here (Q#BP17).** A panel
/// painted for a frontend whose `fold_projection` is false must pass
/// `None`; `EditorCore::fold_map_for_window` is the wrong source there
/// because it gates on the **active** frontend, which is right for
/// command-time reckoning and wrong for painting another frontend's
/// panel.
fn prepare_window_cursor_visible(
window: &mut crate::window::Window,
buf: &crate::buffer::Buffer,
inner_rows: u32,
folds: Option<&crate::fold_view::VisibleLineMap>,
) {
let cursor_row = window
.text_view
.pos_to_display(buf, window.cursor)
.map_or(0, |d| d.row as usize);
match folds {
// The logical cursor may sit on a hidden line (a shared fold, or
// goto-line into one); the row that actually renders — and so
// the row to scroll to — is its visible head (Q#FD16/FD18,
// framing acceptance 8).
Some(map) => {
let anchor = map.visible_head_of(cursor_row);
let top = map.clamp_view_top(window.view_top);
window.view_top = if anchor < top {
anchor
} else if inner_rows > 0 && map.visible_rows_between(top, anchor) >= inner_rows as usize
{
map.nth_visible_back(anchor, inner_rows as usize - 1)
} else {
top
};
}
None => {
if cursor_row < window.view_top {
window.view_top = cursor_row;
} else if inner_rows > 0 && cursor_row >= window.view_top + inner_rows as usize {
window.view_top = cursor_row + 1 - inner_rows as usize;
}
}
}
}
/// Paint one window's document content: text, gutter, overlays,
/// selection, and its mode line.
///
/// Extracted from `paint_frame`'s per-window loop for bottom-panel
/// Stage 2 (Q#BP8) — the panel band paints its window into a
/// panel-sized grid at the same origin-agnostic `Viewport`, so this is
/// that body lifted out rather than a second painter. No concrete
/// text/gutter/overlay/mode-line painter forks (Bet B2').
///
/// **`folds` is a parameter, never built here (Q#BP17).** Folding's
/// "a semantic session never enters `paint_frame`" premise is what the
/// panel band breaks; the panel path passes `None` when the owning
/// frontend's `fold_projection` is false, and must not call
/// `EditorCore::fold_map_for_window`, which gates on the **active**
/// frontend.
#[allow(clippy::too_many_arguments)]
fn paint_window_content(
grid: &mut crate::cell::CellGrid<'_>,
window: &mut crate::window::Window,
buf: &crate::buffer::Buffer,
placement: WindowPlacement,
folds: Option<&crate::fold_view::VisibleLineMap>,
focused: bool,
theme: &crate::highlight::Theme,
statusline: Option<&crate::statusline::StatuslineWindowSegments>,
diag_store: &std::sync::Arc<std::sync::Mutex<crate::diag::DiagnosticStore>>,
) {
let rect = placement.outer;
let inner_rows = placement.content.size.rows;
if let Some(map) = folds {
window.view_top = map.clamp_view_top(window.view_top);
}
let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0);
// UX gutter (Q#UX2): reserve a left strip for line numbers and
// shrink+shift the text area into the remainder, so every
// viewport-relative painter (text, syntax, diagnostics, search)
// stays gutter-agnostic. A window too narrow for the gutter falls
// back to no gutter this frame rather than starving the text.
let gutter_w = {
let w = window.gutter_width();
if w >= rect.size.cols { 0 } else { w }
};
let viewport = Viewport {
buffer_start: viewport_buffer_start,
buffer_end: buf.len(),
cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w),
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
gutter_w,
folds,
};
// Composition (T M2.9): base text_view paints first, then the
// gutter numbers — before the overlays, so a diagnostic overlay
// can draw its severity sign into the gutter's leading column
// without the gutter's own blank pass erasing it — then each
// overlay in attach order. See [`crate::view::View`].
window.text_view.render(buf, viewport, grid);
if gutter_w > 0 {
paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w, folds, theme);
}
for overlay in &mut window.overlays {
overlay.render(buf, viewport, grid);
}
paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w, folds, theme);
// Mode line for this window. Painted last so the line
// itself is always visible regardless of overlay activity.
let coord = window
.text_view
.pos_to_display(buf, window.cursor)
.unwrap_or_default();
// Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in
// VISIBLE-line space — a buffer whose remainder is collapsed
// reads "All", not "Top". The cursor's ordinal anchors on its
// visible head, since that is the row it renders on.
let (ind_top, ind_total, ind_cursor) = match folds {
Some(map) => (
map.visible_rows_between(0, window.view_top),
map.visible_line_count(window.text_view.line_count()),
map.visible_rows_between(0, map.visible_head_of(coord.row as usize)),
),
None => (
window.view_top,
window.text_view.line_count(),
coord.row as usize,
),
};
let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor);
// Lock scoped to the summary computation only: the overlay
// renders above include `DiagnosticView`, which takes this
// same mutex — holding the guard across the loop deadlocked
// the daemon on the first frame after a file (and thus a
// diagnostic overlay) was opened.
let diags = {
let guard = diag_store.lock().expect("diag store mutex poisoned");
diag_mode_line_summary(&guard, buf)
};
let custom = statusline;
paint_mode_line(
grid,
&rect,
buf.name(),
buf.is_modified(),
focused,
coord.row,
coord.col,
&scroll,
&diags,
mode_line_style(theme),
custom.map_or(&[], |segments| segments.left.as_slice()),
custom.map_or(&[], |segments| segments.right.as_slice()),
theme,
);
}
/// Paint one full frame into `grid` and return the desired terminal
/// cursor position.
///
@ -3329,6 +3510,11 @@ pub fn paint_frame(
// Arc 6 Stage 2 (Q#FD18): the auto-scroll clamp reckons in
// VISIBLE lines. Built from the active window itself, before
// the mutable borrow below.
//
// Bottom-panel Q#BP17: built HERE and passed in, because the
// panel path (Stage 2B) must supply `None` for a frontend
// whose `fold_projection` is false. Building it inside the
// clamp would hard-wire the grid's answer.
let folds = core
.windows
.get(&active)
@ -3336,36 +3522,7 @@ pub fn paint_frame(
let aw = core.windows.get_mut(&active).expect(
"invariant: active_window_id always references a live window in core.windows",
);
let cursor_row = aw
.text_view
.pos_to_display(buf, aw.cursor)
.map_or(0, |d| d.row as usize);
match folds.as_ref() {
// The logical cursor may sit on a hidden line (a shared
// fold, or goto-line into one); the row that actually
// renders — and so the row to scroll to — is its visible
// head (Q#FD16/FD18, framing acceptance 8).
Some(map) => {
let anchor = map.visible_head_of(cursor_row);
let top = map.clamp_view_top(aw.view_top);
aw.view_top = if anchor < top {
anchor
} else if inner_rows > 0
&& map.visible_rows_between(top, anchor) >= inner_rows as usize
{
map.nth_visible_back(anchor, inner_rows as usize - 1)
} else {
top
};
}
None => {
if cursor_row < aw.view_top {
aw.view_top = cursor_row;
} else if inner_rows > 0 && cursor_row >= aw.view_top + inner_rows as usize {
aw.view_top = cursor_row + 1 - inner_rows as usize;
}
}
}
prepare_window_cursor_visible(aw, buf, inner_rows, folds.as_ref());
}
}
@ -3417,114 +3574,17 @@ pub fn paint_frame(
let Ok(buf) = reg.get(window.buffer_id) else {
continue;
};
// Arc 6 Stage 2 (Q#FD12, round-2 F2): ONE visible-line map per
// rendered document window, keyed on that window's own buffer and
// line offsets. A split may show different buffers with only one
// folded, so a per-frame singleton would leak one pane's folds
// into the other. `None` when this buffer has no folds — the
// unfolded path then paints exactly as before.
let folds = crate::fold_view::map_for_window(&state.fold_registry, window);
// `view_top` stays a source-line index (Bet B5) but must never
// rest on a hidden line: clamp BACKWARD so a fold at the top of
// the viewport shows its head (Q#FD18, acceptance 8).
if let Some(map) = folds.as_ref() {
window.view_top = map.clamp_view_top(window.view_top);
}
let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0);
// UX gutter (Q#UX2): reserve a left strip for line numbers and
// shrink+shift the text area into the remainder, so every
// viewport-relative painter (text, syntax, diagnostics, search)
// stays gutter-agnostic. A window too narrow for the gutter falls
// back to no gutter this frame rather than starving the text.
let gutter_w = {
let w = window.gutter_width();
if w >= rect.size.cols { 0 } else { w }
};
let viewport = Viewport {
buffer_start: viewport_buffer_start,
buffer_end: buf.len(),
cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w),
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
gutter_w,
folds: folds.as_ref(),
};
// Composition (T M2.9): base text_view paints first, then the
// gutter numbers — before the overlays, so a diagnostic overlay
// can draw its severity sign into the gutter's leading column
// without the gutter's own blank pass erasing it — then each
// overlay in attach order. See [`crate::view::View`].
window.text_view.render(buf, viewport, grid);
if gutter_w > 0 {
paint_line_number_gutter(
grid,
window,
&rect,
inner_rows,
gutter_w,
folds.as_ref(),
&theme,
);
}
for overlay in &mut window.overlays {
overlay.render(buf, viewport, grid);
}
paint_local_selection(
paint_window_content(
grid,
buf,
window,
&rect,
inner_rows,
gutter_w,
buf,
placement,
folds.as_ref(),
&theme,
);
// Mode line for this window. Painted last so the line
// itself is always visible regardless of overlay activity.
let coord = window
.text_view
.pos_to_display(buf, window.cursor)
.unwrap_or_default();
// Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in
// VISIBLE-line space — a buffer whose remainder is collapsed
// reads "All", not "Top". The cursor's ordinal anchors on its
// visible head, since that is the row it renders on.
let (ind_top, ind_total, ind_cursor) = match folds.as_ref() {
Some(map) => (
map.visible_rows_between(0, window.view_top),
map.visible_line_count(window.text_view.line_count()),
map.visible_rows_between(0, map.visible_head_of(coord.row as usize)),
),
None => (
window.view_top,
window.text_view.line_count(),
coord.row as usize,
),
};
let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor);
// Lock scoped to the summary computation only: the overlay
// renders above include `DiagnosticView`, which takes this
// same mutex — holding the guard across the loop deadlocked
// the daemon on the first frame after a file (and thus a
// diagnostic overlay) was opened.
let diags = {
let guard = diag_store.lock().expect("diag store mutex poisoned");
diag_mode_line_summary(&guard, buf)
};
let custom = statusline_by_window.get(id);
paint_mode_line(
grid,
&rect,
buf.name(),
buf.is_modified(),
*id == active,
coord.row,
coord.col,
&scroll,
&diags,
mode_line_style(&theme),
custom.map_or(&[], |segments| segments.left.as_slice()),
custom.map_or(&[], |segments| segments.right.as_slice()),
&theme,
statusline_by_window.get(id),
&diag_store,
);
}
drop(reg);

View File

@ -625,6 +625,22 @@ impl SemanticRenderState {
// post-evaluation face inventory must then precede the authoritative
// segment replacement in this same frame. Unsupported peers skip the
// evaluator entirely and therefore pay no Lua callback/dynamic-face cost.
// Bottom-panel A2A-2, round 3: the document identity used to
// FILTER the results must be the PRE-CALLBACK one. Both outcome
// arms carry phase-1 contexts, and a provider that closes the
// primary document split changes `primary_document_window`
// mid-evaluation — reading it after the fact would compare
// phase-1 contexts against a replacement identity, match
// nothing, and silently suppress the authoritative clear.
let statusline_document_window = self
.peer_knows_statusline_segments
.then(|| {
state
.core
.borrow()
.primary_document_window(self.frontend_id)
})
.flatten();
let statusline_evaluation = self.peer_knows_statusline_segments.then(|| {
evaluate_statusline(
state.lua_host.lua(),
@ -810,7 +826,7 @@ impl SemanticRenderState {
out.extend(self.font_facts_msg(state));
// Q#SL6/Q#SL8: face inventory must precede segment text.
if let Some(evaluation) = statusline_evaluation {
self.emit_statusline_segments(evaluation, &mut out);
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
}
out
}
@ -855,6 +871,16 @@ impl SemanticRenderState {
// Evaluate callbacks before `ThemeFacts` for the same reason the
// document path does: a callback may register a face, and the
// face inventory must precede the segment text that names it.
// Same pre-callback capture as the document path (round 3).
let statusline_document_window = self
.peer_knows_statusline_segments
.then(|| {
state
.core
.borrow()
.primary_document_window(self.frontend_id)
})
.flatten();
let statusline_evaluation = self.peer_knows_statusline_segments.then(|| {
evaluate_statusline(
state.lua_host.lua(),
@ -881,7 +907,12 @@ impl SemanticRenderState {
// a verdict we hold.
if self.last_terminal_frame.as_ref() == Some(&frame) {
self.terminal_error_latched = false;
out.extend(self.terminal_chrome(state, buffer_id, statusline_evaluation));
out.extend(self.terminal_chrome(
state,
buffer_id,
statusline_evaluation,
statusline_document_window,
));
return Some(out);
}
match frame.validate() {
@ -905,7 +936,12 @@ impl SemanticRenderState {
}
}
out.extend(self.terminal_chrome(state, buffer_id, statusline_evaluation));
out.extend(self.terminal_chrome(
state,
buffer_id,
statusline_evaluation,
statusline_document_window,
));
Some(out)
}
@ -920,6 +956,7 @@ impl SemanticRenderState {
state: &EditorState,
buffer_id: BufferId,
statusline_evaluation: Option<StatuslineEvaluation>,
statusline_document_window: Option<crate::window::WindowId>,
) -> Vec<InstanceMessage> {
let mut out = Vec::new();
out.extend(self.status_facts_msg(state, buffer_id));
@ -929,7 +966,7 @@ impl SemanticRenderState {
out.extend(self.font_facts_msg(state));
// Q#SL6/Q#SL8: face inventory must precede segment text.
if let Some(evaluation) = statusline_evaluation {
self.emit_statusline_segments(evaluation, &mut out);
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
}
out
}
@ -941,6 +978,7 @@ impl SemanticRenderState {
fn emit_statusline_segments(
&mut self,
evaluation: StatuslineEvaluation,
document_window: Option<crate::window::WindowId>,
out: &mut Vec<InstanceMessage>,
) {
let to_wire = |segments: Vec<crate::statusline::EvaluatedStatuslineSegment>| {
@ -955,10 +993,16 @@ impl SemanticRenderState {
let frontend_id = self.frontend_id;
match evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
if let Some(window) = windows
.into_iter()
.find(|window| window.context.frontend_id == frontend_id)
{
// Bottom-panel A2A-2: the fan-out now yields the primary
// document AND the visible side window, so the wire
// segments must be selected by WINDOW IDENTITY. Taking
// "the first context for my frontend" would silently
// depend on capture order and could ship the panel's
// mode-line text as the document status band.
if let Some(window) = windows.into_iter().find(|window| {
window.context.frontend_id == frontend_id
&& Some(window.context.window_id) == document_window
}) {
self.emit_statusline_payload(
window.context.buffer_id,
to_wire(window.left),
@ -970,10 +1014,18 @@ impl SemanticRenderState {
StatuslineEvaluationOutcome::Invalidated {
authoritative_empty,
} => {
for context in authoritative_empty
.into_iter()
.filter(|context| context.frontend_id == frontend_id)
{
// Bottom-panel A2A-2: the clear must be filtered by
// DOCUMENT WINDOW exactly like the Ready arm. The
// semantic peer has ONE statusline slot, so publishing
// the panel context's clear here replaces the document's
// payload with the panel's — the same misrouting the
// Ready arm was fixed for, on the clear path.
//
// A panel's own clear belongs to the future panel
// painter (`PanelFrame`, Stage 2B), not to this wire.
for context in authoritative_empty.into_iter().filter(|context| {
context.frontend_id == frontend_id && Some(context.window_id) == document_window
}) {
self.emit_statusline_payload(context.buffer_id, Vec::new(), Vec::new(), out);
}
}
@ -1345,9 +1397,13 @@ impl SemanticRenderState {
state: &EditorState,
buffer_id: BufferId,
) -> Option<InstanceMessage> {
// Bottom-panel §1.3 #4 — Projection. `LineNumbers` describes the
// replica's DOCUMENT surface; a focused panel must not replace
// the document's gutter mode with the panel window's.
let mode = {
let core = state.core.borrow();
core.active_window_for(self.frontend_id)
core.primary_document_window(self.frontend_id)
.and_then(|win_id| core.windows.get(&win_id))
.map_or(crate::window::LineNumberMode::Off, |w| w.line_numbers)
};
if self.last_line_numbers == Some(mode) {
@ -1703,7 +1759,12 @@ impl SemanticRenderState {
// Emitting CurrentLine here forced a whole-buffer line table on
// every frame even though pmacs-gpu ignores its own current-line
// wash.
if let Some(win) = core.active_window_for(self.frontend_id)
// Bottom-panel §1.3 #5 — Projection. Selection decorations
// belong to the document surface the viewport describes; a
// selection made inside a focused panel must not paint into it.
if let Some(win) = core
.primary_document_window(self.frontend_id)
.and_then(|win_id| core.windows.get(&win_id))
&& win.buffer_id == vp.buffer_id
&& let Some((lo, hi)) = win.region()
&& let Some(range) = clip_to_viewport(lo, hi, vp)
@ -2916,6 +2977,7 @@ mod tests {
),
new_failures: Vec::new(),
},
None,
&mut stale,
);
assert!(stale.is_empty(), "phase-1 stale evaluation emits nothing");
@ -2924,11 +2986,15 @@ mod tests {
"stale evaluation retains the prior baseline until snapshot reset"
);
// Bottom-panel A2A-2: the clear is filtered by DOCUMENT window
// identity, so the context under test must BE the document
// window — passing `None` here would assert nothing.
let document_window = crate::window::WindowId::next();
let invalidated = || StatuslineEvaluation {
outcome: StatuslineEvaluationOutcome::Invalidated {
authoritative_empty: vec![crate::statusline::StatuslineContext {
frontend_id: FrontendId::LOCAL,
window_id: crate::window::WindowId::next(),
window_id: document_window,
buffer_id,
active: true,
}],
@ -2936,13 +3002,13 @@ mod tests {
new_failures: Vec::new(),
};
let mut replacement = Vec::new();
semantic.emit_statusline_segments(invalidated(), &mut replacement);
semantic.emit_statusline_segments(invalidated(), Some(document_window), &mut replacement);
assert_eq!(
statusline_of(&replacement),
Some((buffer_id, Vec::new(), Vec::new()))
);
let mut unchanged = Vec::new();
semantic.emit_statusline_segments(invalidated(), &mut unchanged);
semantic.emit_statusline_segments(invalidated(), Some(document_window), &mut unchanged);
assert!(
unchanged.is_empty(),
"the empty invalidation became baseline"

View File

@ -215,10 +215,25 @@ pub enum StatuslineEvaluationTarget {
/// Frontend whose entire visible layout is evaluated.
frontend_id: FrontendId,
},
/// Only the frontend's active window, iff it still displays the declared
/// semantic viewport buffer.
/// The frontend's **primary document window**, iff it still displays
/// the declared semantic viewport buffer, **plus its visible side
/// window** when one exists (bottom-panel Q#BP8 / A2A-2).
///
/// Two contexts, not one: the document result feeds the semantic
/// `StatuslineSegments` wire, while the side result paints in the
/// panel's own mode line. Unprojected document splits run no
/// callbacks, and a derived-hidden side (Q#BP2b) is omitted because
/// it has no mode line to paint this frame.
///
/// The document context is captured **first**; consumers must still
/// select by window identity rather than position, since only one of
/// the two may reach the single semantic statusline slot.
///
/// `active` on each context reports **actual focus**, so a document
/// provider truthfully observes `active = false` while a panel owns
/// focus (Q#BP14, parent acceptance 42).
Semantic {
/// Frontend whose focused daemon window is evaluated.
/// Frontend whose document (and visible side) window is evaluated.
frontend_id: FrontendId,
/// Buffer declared by the semantic viewport.
declared_buffer: BufferId,
@ -639,9 +654,20 @@ fn capture_target_contexts(
.views
.get(&frontend_id)
.ok_or(StatuslineNoMessageReason::ContextUnavailable)?;
// Bottom-panel §1.3 #12 — Projection. This LOOKUP resolves
// the primary document window: with a panel focused,
// `view.active` would name the panel and the declared-buffer
// check would clear the document's statusline.
//
// `active` is NOT rerouted with it (Q#BP14/parent 42): it
// reports ACTUAL focus, so a document provider truthfully
// observes `active = false` while the panel owns focus.
let window_id = core
.primary_document_window(frontend_id)
.ok_or(StatuslineNoMessageReason::ContextUnavailable)?;
let window = core
.windows
.get(&view.active)
.get(&window_id)
.ok_or(StatuslineNoMessageReason::ContextUnavailable)?;
if buffers.get(window.buffer_id).is_err() {
return Err(StatuslineNoMessageReason::BufferUnavailable);
@ -649,12 +675,46 @@ fn capture_target_contexts(
if window.buffer_id != declared_buffer {
return Err(StatuslineNoMessageReason::DeclaredBufferMismatch);
}
Ok(vec![StatuslineContext {
let mut contexts = vec![StatuslineContext {
frontend_id,
window_id: window.id,
buffer_id: window.buffer_id,
active: true,
}])
active: window.id == view.active,
}];
// Bottom-panel Q#BP8 / A2A-2 — the semantic fan-out is the
// primary document PLUS the frontend's visible side window,
// and nothing else: unprojected document splits run no
// callbacks. The document result feeds the semantic
// `StatuslineSegments`; the side result paints in the panel's
// own mode line.
//
// A derived-hidden side window is omitted (Q#BP2b): it has no
// mode line to paint this frame, so evaluating providers for
// it would invoke callbacks for a surface nobody can see.
if !view.panel_hidden {
for side_id in view.layout.iter_ids() {
if side_id == window.id {
continue;
}
let Some(side) = core.windows.get(&side_id) else {
return Err(StatuslineNoMessageReason::ContextUnavailable);
};
if !side.is_side() {
continue;
}
if buffers.get(side.buffer_id).is_err() {
return Err(StatuslineNoMessageReason::BufferUnavailable);
}
contexts.push(StatuslineContext {
frontend_id,
window_id: side.id,
buffer_id: side.buffer_id,
// Same rule as the document context: ACTUAL focus.
active: side.id == view.active,
});
}
}
Ok(contexts)
}
}
}

View File

@ -558,8 +558,19 @@ pub struct FrontendView {
/// store. Reckoning in visible lines unconditionally would make that
/// GPU session's cursor skip lines it is still showing, so every
/// command/event-time visible-line reckoning is gated on the
/// **acting** frontend's flag. Render-time clamps need no gate: a
/// semantic session never enters `paint_frame`.
/// **acting** frontend's flag.
///
/// **Render-time clamps used to need no gate, on the premise that a
/// semantic session never enters `paint_frame`. The bottom-panel
/// band breaks that premise** (Q#BP17): the daemon projects a
/// semantic frontend's side window through the same per-window
/// painter. So the extracted painters
/// (`prepare_window_cursor_visible`, `paint_window_content`) take the
/// visible-line map as a **parameter**, and the panel path passes
/// `None` when the *owning* frontend's `fold_projection` is false.
/// That path must not call `EditorCore::fold_map_for_window`, which
/// gates on the **active** frontend — correct for command-time
/// reckoning, wrong for painting another frontend's panel.
///
/// Set at attach from the negotiated selected-render bit (grid ⇒
/// `true`, semantic ⇒ `false`), cleared with the view at detach, and

View File

@ -0,0 +1,898 @@
// bottom_panel_stage2a_acceptance.rs --- bottom-panel Stage 2A
// (docs/bottom-panel-stage2-framing.md, criteria A2A-1 / A2A-2 / A2A-3).
//! Classified §1.3 census routing + the per-window painter extraction.
//! No wire change.
//!
//! **The negative half is the load-bearing half.** A suite that only
//! proved "the document surface is used" would pass with the focus,
//! focus-chrome, and focus/session consumers *wrongly* rerouted to the
//! document — which is the defect the framing spent three review rounds
//! eliminating, and which would break remote-op validation,
//! `DispatchIdle`, presence, focused search/menu/completion routing, and
//! terminal bell ownership. So every Projection assertion here is paired
//! with a focus-class assertion taken in the *same* state.
use pmacs::cell::{CellGrid, CellSize};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
use pmacs::window::{Side, WindowId};
const ROWS: u32 = 24;
const COLS: u32 = 60;
fn editor() -> EditorState {
let s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS));
s
}
fn exec(s: &EditorState, src: &str) {
s.lua_host.lua().load(src.to_string()).exec().unwrap();
}
fn side_window_of(core: &pmacs::editor_core::EditorCore, fid: FrontendId) -> Option<WindowId> {
core.views[&fid].layout.iter_ids().into_iter().find(|id| {
core.windows
.get(id)
.is_some_and(|w| w.params.side.is_some())
})
}
fn side_window(s: &EditorState) -> Option<WindowId> {
let core = s.core.borrow();
core.views[&FrontendId::LOCAL]
.layout
.iter_ids()
.into_iter()
.find(|id| {
core.windows
.get(id)
.is_some_and(|w| w.params.side.is_some())
})
}
/// Open a bottom panel and leave it FOCUSED — the state in which every
/// classification difference becomes observable.
fn focused_panel(s: &EditorState) -> (WindowId, WindowId) {
let document = s.core.borrow().views[&FrontendId::LOCAL].active;
exec(
s,
"PANEL_BUF = pmacs.buffer.create(\"*panel*\")
PANEL_WIN = pmacs.window.display(PANEL_BUF, \
{ side = \"bottom\", height = 4 })",
);
let panel = side_window(s).expect("panel exists");
s.core.borrow_mut().focus_window(FrontendId::LOCAL, panel);
assert_eq!(
s.core.borrow().views[&FrontendId::LOCAL].active,
panel,
"fixture precondition: the panel must own focus"
);
(document, panel)
}
fn render(s: &EditorState) {
let size = CellSize::new(ROWS, COLS);
let mut cells = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize];
let mut grid = CellGrid {
cells: &mut cells,
stride: size.cols,
size,
};
let _ = pmacs::editor::paint_frame(
s,
FrontendId::LOCAL,
&std::collections::HashMap::new(),
&mut grid,
size,
);
}
// ---------------------------------------------------------------------------
// A2A-1 — the Projection class resolves the document surface
// ---------------------------------------------------------------------------
#[test]
fn projection_resolves_the_document_window_while_a_panel_is_focused() {
let s = editor();
let (document, panel) = focused_panel(&s);
let core = s.core.borrow();
assert_eq!(
core.primary_document_window(FrontendId::LOCAL),
Some(document),
"Projection consumers must resolve the document window, not the focused panel"
);
assert_ne!(document, panel);
}
#[test]
fn projection_buffer_is_the_document_buffer_not_the_panel_buffer() {
let s = editor();
let (document, _panel) = focused_panel(&s);
let core = s.core.borrow();
let document_buffer = core.windows[&document].buffer_id;
assert_eq!(
core.primary_document_buffer(FrontendId::LOCAL),
Some(document_buffer),
"the replica's document mirror must not follow panel focus"
);
assert_ne!(
core.primary_document_buffer(FrontendId::LOCAL),
Some(core.windows[&core.views[&FrontendId::LOCAL].active].buffer_id),
"non-vacuity: the focused window's buffer differs, so this test can fail"
);
}
// ---------------------------------------------------------------------------
// A2A-1 — the NEGATIVE half: focus classes still resolve focus
// ---------------------------------------------------------------------------
#[test]
fn focus_class_dispatch_idle_still_tracks_the_focused_window() {
let s = editor();
let (_document, _panel) = focused_panel(&s);
// §1.3 #14 — Focus. Q#BP14a: optimistic input is gated per WINDOW.
// A panel that owns focus must suppress `DispatchIdle` even though
// the *document* projection is unaffected.
assert!(
!s.dispatch_idle_for(FrontendId::LOCAL),
"a focused side window must gate optimistic input off (#14)"
);
}
#[test]
fn focus_class_gate_lifts_when_focus_returns_to_the_document() {
let s = editor();
let (document, _panel) = focused_panel(&s);
s.core
.borrow_mut()
.focus_window(FrontendId::LOCAL, document);
assert!(
s.dispatch_idle_for(FrontendId::LOCAL),
"non-vacuity: the gate must lift with focus, or the test above proves nothing"
);
}
#[test]
fn focus_and_projection_disagree_in_the_same_state() {
// The single most important assertion in this suite: in ONE state,
// the two classes must resolve DIFFERENT windows. If a future change
// routes the focus class through `primary_document_window`, this
// fails even though every Projection test above still passes.
let s = editor();
let (document, panel) = focused_panel(&s);
let core = s.core.borrow();
let focused = core.views[&FrontendId::LOCAL].active;
let projected = core
.primary_document_window(FrontendId::LOCAL)
.expect("a document window exists");
assert_eq!(focused, panel, "focus authority must name the panel");
assert_eq!(
projected, document,
"projection authority must name the document"
);
assert_ne!(
focused, projected,
"the two authorities must be genuinely distinct in this state"
);
}
// ---------------------------------------------------------------------------
// A2A-2 — the statusline split: lookup reroutes, `active` does not
// ---------------------------------------------------------------------------
#[test]
fn statusline_document_context_reports_active_false_under_a_focused_panel() {
use pmacs::statusline::{
StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline,
};
let s = editor();
let (document, _panel) = focused_panel(&s);
let declared = s.core.borrow().windows[&document].buffer_id;
let evaluation = evaluate_statusline(
s.lua_host.lua(),
&s.core,
&s.statusline_registry,
StatuslineEvaluationTarget::Semantic {
frontend_id: FrontendId::LOCAL,
declared_buffer: declared,
},
);
match evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
// A2A-2: the semantic fan-out captures the primary document
// AND the visible side window — two contexts, not one.
assert_eq!(
windows.len(),
2,
"the semantic-layout target must capture document + visible side window"
);
let side = windows
.iter()
.find(|w| w.context.window_id != document)
.expect("a side-window context");
assert!(
side.context.active,
"the focused panel's own context reports active = true"
);
let context = windows
.first()
.map(|segments| segments.context)
.expect("one document context");
// The LOOKUP rerouted: it resolved the document window even
// though the panel is focused (§1.3 #12).
assert_eq!(
context.window_id, document,
"the semantic target must resolve the primary document window"
);
// `active` did NOT reroute (parent acceptance 42): a document
// provider observes the truth, that it is not focused.
assert!(
!context.active,
"a document provider must observe active = false while the panel owns focus"
);
}
other => panic!("expected a ready evaluation, got {other:?}"),
}
}
#[test]
fn statusline_document_context_is_active_when_the_document_is_focused() {
use pmacs::statusline::{
StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline,
};
// Non-vacuity for the assertion above: with focus on the document,
// the same context must report `active = true`.
let s = editor();
let (document, _panel) = focused_panel(&s);
s.core
.borrow_mut()
.focus_window(FrontendId::LOCAL, document);
let declared = s.core.borrow().windows[&document].buffer_id;
let evaluation = evaluate_statusline(
s.lua_host.lua(),
&s.core,
&s.statusline_registry,
StatuslineEvaluationTarget::Semantic {
frontend_id: FrontendId::LOCAL,
declared_buffer: declared,
},
);
match evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
let context = windows.first().map(|s| s.context).expect("one context");
assert!(context.active, "a focused document context must be active");
}
other => panic!("expected a ready evaluation, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// A2A-3 — the painter extraction preserves grid behavior
// ---------------------------------------------------------------------------
#[test]
fn extraction_preserves_cells_cursor_and_focused_view_top() {
// The extraction must preserve four things, not just cells: a clamp
// that silently moved to the WRONG window would leave the painted
// cells identical on a single-window frame.
let s = editor();
exec(
&s,
"local b = pmacs.buffer.create(\"*doc*\")
b:insert(0, string.rep(\"line\\n\", 200))
pmacs.window.display(b, {})",
);
// The gutter only paints when line numbers are on, so turn them on
// rather than dropping the assertion.
{
let mut core = s.core.borrow_mut();
let active = core.views[&FrontendId::LOCAL].active;
core.windows.get_mut(&active).unwrap().line_numbers =
pmacs::window::LineNumberMode::Absolute;
}
let size = CellSize::new(ROWS, COLS);
let mut cells_a = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize];
let mut grid_a = CellGrid {
cells: &mut cells_a,
stride: size.cols,
size,
};
let cursor_a = pmacs::editor::paint_frame(
&s,
FrontendId::LOCAL,
&std::collections::HashMap::new(),
&mut grid_a,
size,
);
let active = s.core.borrow().views[&FrontendId::LOCAL].active;
let view_top_a = s.core.borrow().windows[&active].view_top;
// A second identical paint is a fixed point: same cells, same
// returned cursor, same `view_top`.
let mut cells_b = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize];
let mut grid_b = CellGrid {
cells: &mut cells_b,
stride: size.cols,
size,
};
let cursor_b = pmacs::editor::paint_frame(
&s,
FrontendId::LOCAL,
&std::collections::HashMap::new(),
&mut grid_b,
size,
);
let view_top_b = s.core.borrow().windows[&active].view_top;
assert_eq!(cells_a, cells_b, "painted cells must be stable");
assert_eq!(cursor_a, cursor_b, "the returned cursor must be stable");
assert_eq!(view_top_a, view_top_b, "focused view_top must be stable");
// Review round 1, finding 5: a fixed-point check alone is VACUOUS —
// deleting `text_view.render` leaves it green. Assert the extracted
// painter actually produced each of its four outputs.
let rows = |cells: &[pmacs::cell::Cell]| -> Vec<String> {
(0..ROWS as usize)
.map(|r| {
(0..COLS as usize)
.map(|c| match &cells[r * COLS as usize + c].glyph {
pmacs::cell::Glyph::Char(ch) => *ch,
_ => ' ',
})
.collect::<String>()
})
.collect()
};
let painted = rows(&cells_a);
// TEXT: the buffer's content reached the grid.
assert!(
painted.iter().any(|row| row.contains("line")),
"the extracted painter must paint buffer TEXT; got {painted:?}"
);
// GUTTER: line numbers were painted beside it.
assert!(
painted.iter().any(|row| row.trim_start().starts_with('1')),
"the extracted painter must paint the line-number GUTTER"
);
// MODE LINE: the window's mode line names its buffer.
assert!(
painted.iter().any(|row| row.contains("*doc*")),
"the extracted painter must paint the window MODE LINE"
);
// CURSOR: a real caret position came back, not None.
assert!(
cursor_a.is_some(),
"the extraction must still return a caret position"
);
}
#[test]
fn extraction_leaves_a_passive_window_view_top_untouched() {
// The auto-scroll clamp runs for the FOCUSED window only. A passive
// window's scroll state must survive a frame it did not own.
let s = editor();
exec(
&s,
"local b = pmacs.buffer.create(\"*doc*\")
b:insert(0, string.rep(\"line\\n\", 200))
pmacs.window.display(b, {})
pmacs.window.split_horizontal()",
);
render(&s);
let (passive, before) = {
let core = s.core.borrow();
let view = &core.views[&FrontendId::LOCAL];
let passive = view
.layout
.iter_ids()
.into_iter()
.find(|id| *id != view.active)
.expect("a second window exists");
(passive, core.windows[&passive].view_top)
};
// Scroll the passive window somewhere the clamp would "fix" if it
// ever ran against the wrong window.
s.core
.borrow_mut()
.windows
.get_mut(&passive)
.unwrap()
.view_top = 120;
render(&s);
assert_eq!(
s.core.borrow().windows[&passive].view_top,
120,
"a passive window's view_top must not be clamped by another window's frame"
);
assert_ne!(before, 120, "non-vacuity: the value actually changed");
}
// ---------------------------------------------------------------------------
// Fixture integrity
// ---------------------------------------------------------------------------
#[test]
fn the_panel_fixture_really_builds_a_side_window() {
// Every test above is worthless if `focused_panel` silently produced
// an ordinary split, so pin the fixture's own precondition.
let s = editor();
let (_document, panel) = focused_panel(&s);
let core = s.core.borrow();
assert_eq!(
core.windows[&panel].params.side,
Some(Side::Bottom),
"the fixture must produce a real bottom side window"
);
}
// ---------------------------------------------------------------------------
// A2A-1 at the CONSUMER seam — review round 1, finding 3.
//
// The tests above assert the *authority* (`primary_document_window`).
// That is not sufficient: restoring a producer to `active_window_for`
// leaves every one of them green. These drive the real producers through
// `SemanticRenderState::render_frame` with a panel focused, so a reverted
// routing fails here.
// ---------------------------------------------------------------------------
/// A semantic frontend that CAN hold a panel. Stage 1 ships
/// `panel_capable = false` for semantic sessions and 2B flips it for a
/// v21-negotiated peer; until then the projection is only reachable with
/// a test-only capable view, which is exactly what the framing's §7.2
/// says 2B must replace with the real capability flip.
fn semantic_frontend_with_focused_panel(
s: &EditorState,
) -> (FrontendId, WindowId, WindowId, pmacs::buffer::BufferId) {
use pmacs::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams};
let fid = FrontendId(77);
let (doc_win, panel_win, doc_buf) = {
let mut core = s.core.borrow_mut();
let doc_buf = core.active_window().buffer_id;
let panel_buf = core.registry.borrow_mut().create("*panel*");
let doc_win = WindowId::next();
let panel_win = WindowId::next();
let doc_view = {
let reg = core.registry.borrow();
pmacs::text_view::TextView::new(reg.get(doc_buf).expect("document buffer"))
};
let panel_view = {
let reg = core.registry.borrow();
pmacs::text_view::TextView::new(reg.get(panel_buf).expect("panel buffer"))
};
core.windows
.insert(doc_win, Window::new(doc_win, doc_buf, doc_view));
let mut panel = Window::new(panel_win, panel_buf, panel_view);
let mut params = WindowParams::default();
params.side = Some(Side::Bottom);
params.fixed_rows = Some(4);
panel.params = params;
core.windows.insert(panel_win, panel);
core.register_frontend_view(
fid,
FrontendView {
layout: Layout {
root: LayoutNode::Split {
orientation: Orientation::Horizontal,
children: vec![LayoutNode::Leaf(doc_win), LayoutNode::Leaf(panel_win)],
weights: vec![1, 1],
},
},
// The panel owns focus; the document is the projection.
active: panel_win,
fold_projection: false,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
(doc_win, panel_win, doc_buf)
};
s.sync_frame_geometry(fid, CellSize::new(ROWS, COLS));
(fid, doc_win, panel_win, doc_buf)
}
#[test]
fn consumer_line_numbers_follow_the_document_not_the_focused_panel() {
use pmacs::protocol::{ByteRange, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
use pmacs::window::LineNumberMode;
let s = editor();
let (fid, doc_win, panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s);
// Make the two windows DISAGREE, so the emitted mode identifies
// which window the producer read (§1.3 #4).
{
let mut core = s.core.borrow_mut();
core.windows.get_mut(&doc_win).unwrap().line_numbers = LineNumberMode::Absolute;
core.windows.get_mut(&panel_win).unwrap().line_numbers = LineNumberMode::Off;
}
let mut sem = SemanticRenderState::new(fid);
sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0);
let msgs = sem.render_frame(&s);
let mode = msgs.iter().find_map(|m| match m {
InstanceMessage::LineNumbers { mode, .. } => Some(*mode),
_ => None,
});
assert_eq!(
mode,
Some(pmacs::protocol::LineNumberMode::Absolute),
"LineNumbers must describe the DOCUMENT window's mode, not the focused panel's"
);
}
#[test]
fn consumer_statusline_segments_carry_the_document_payload_not_the_panel() {
use pmacs::protocol::{ByteRange, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
// §1.3 #12 / A2A-2 at the WIRE. Round 2 finding: the previous
// version discarded `render_frame`'s output and only reasserted
// `primary_document_window`, so restoring the producer's
// "first context for my frontend" selector left it green.
//
// The peer must negotiate v18 or no `StatuslineSegments` is emitted
// at all and the assertion would be vacuous a second way.
let s = editor();
let (fid, _doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s);
let panel_buf = {
let core = s.core.borrow();
let panel = side_window_of(&core, fid).expect("panel");
core.windows[&panel].buffer_id
};
// One provider so a payload exists to misroute.
exec(
&s,
"pmacs.statusline.register({ name = \"probe\", side = \"left\",
face = \"ui.modeline\", fn = function(ctx) return \"X\" end })",
);
let mut sem = SemanticRenderState::for_peer(fid, 18);
sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0);
let msgs = sem.render_frame(&s);
let targets: Vec<_> = msgs
.iter()
.filter_map(|m| match m {
InstanceMessage::StatuslineSegments { buffer_id, .. } => Some(*buffer_id),
_ => None,
})
.collect();
assert!(
!targets.is_empty(),
"non-vacuity: a v18 peer with a registered provider must emit StatuslineSegments"
);
assert!(
targets.iter().all(|b| *b == doc_buf),
"every StatuslineSegments must target the DOCUMENT buffer; got {targets:?} (document {doc_buf:?}, panel {panel_buf:?})"
);
assert!(
!targets.contains(&panel_buf),
"the panel's context must never reach the document statusline wire"
);
}
#[test]
fn consumer_terminal_declaration_resolves_the_document_not_the_focused_panel() {
use pmacs::terminal::TerminalSpec;
// §1.3 #6/#10/#11 through the real guard. Round 2 finding: the
// previous version compared two NON-terminal buffers, so both the
// old and new routings returned `false` and it could not
// discriminate. Make the DOCUMENT window hold a real terminal: the
// document routing then answers `true` while the old `view.active`
// routing (which names the focused panel) answers `false`.
let mut s = editor();
let (fid, doc_win, _panel_win, _doc_buf) = semantic_frontend_with_focused_panel(&s);
let mut spec = TerminalSpec::new("/bin/sh");
spec.rows = 10;
spec.cols = 40;
let term_buf = s.open_terminal(spec).expect("a real terminal session");
// Install the terminal in the DOCUMENT window; the panel keeps its
// own non-terminal buffer and keeps focus.
{
let mut core = s.core.borrow_mut();
core.install_buffer_in_window(doc_win, term_buf)
.expect("install the terminal in the document window");
}
let panel_buf = {
let core = s.core.borrow();
let panel = side_window_of(&core, fid).expect("panel");
core.windows[&panel].buffer_id
};
assert!(
s.semantic_terminal_declaration_is_active(fid, term_buf),
"the DOCUMENT window's terminal must be declarable while the panel owns focus"
);
assert!(
!s.semantic_terminal_declaration_is_active(fid, panel_buf),
"the focused panel's own buffer must never claim the document declaration"
);
}
#[test]
fn invalidated_statusline_clears_only_the_document_not_the_panel() {
use pmacs::protocol::{ByteRange, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
// Round 2 finding 1. The `Invalidated` arm emits an
// authoritative-empty payload for EVERY context of the frontend.
// Once A2A-2's fan-out yields document + panel, that publishes two
// clears on a wire with ONE statusline slot, so the panel's payload
// replaces the document's. This is the live, observable half of the
// routing bug — the `Ready` arm happens to be safe today only
// because the document context is captured first.
let s = editor();
let (fid, _doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s);
let panel_buf = {
let core = s.core.borrow();
let panel = side_window_of(&core, fid).expect("panel");
core.windows[&panel].buffer_id
};
assert_ne!(doc_buf, panel_buf, "fixture: the two buffers must differ");
// A provider that unregisters itself mid-evaluation is the canonical
// registry-mutation invalidation.
exec(
&s,
r"_G.SL_SELF = pmacs.statusline.register {
name='self-remove', side='left', priority=100,
fn=function() pmacs.statusline.unregister(SL_SELF); return 'STALE' end,
}",
);
let mut sem = SemanticRenderState::for_peer(fid, 18);
sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0);
let msgs = sem.render_frame(&s);
let targets: Vec<_> = msgs
.iter()
.filter_map(|m| match m {
InstanceMessage::StatuslineSegments { buffer_id, .. } => Some(*buffer_id),
_ => None,
})
.collect();
assert!(
!targets.contains(&panel_buf),
"an invalidated evaluation must not clear the PANEL's context on the \
document statusline wire; got {targets:?} (document {doc_buf:?}, \
panel {panel_buf:?})"
);
}
#[test]
fn the_semantic_fan_out_captures_the_document_first() {
use pmacs::statusline::{
StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline,
};
// The `Ready` arm selects by window identity, so capture order is not
// load-bearing for correctness — but it IS load-bearing for the
// falsifiability of that selector, so pin it explicitly rather than
// leaving a silent dependency. If a future change reorders the
// fan-out, this fails and whoever reads it learns why it mattered.
let s = editor();
let (fid, doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s);
let evaluation = evaluate_statusline(
s.lua_host.lua(),
&s.core,
&s.statusline_registry,
StatuslineEvaluationTarget::Semantic {
frontend_id: fid,
declared_buffer: doc_buf,
},
);
match evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
assert_eq!(windows.len(), 2, "document + visible side window");
assert_eq!(
windows[0].context.window_id, doc_win,
"the DOCUMENT context must be captured first"
);
}
other => panic!("expected Ready, got {other:?}"),
}
}
#[test]
fn consumer_decorations_follow_the_document_selection_not_the_panel() {
use pmacs::protocol::{ByteRange, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
// §1.3 #5 — Projection. A selection made inside a FOCUSED PANEL must
// not paint selection decorations into the document's viewport.
//
// To DISCRIMINATE, the panel must display the SAME buffer the
// viewport declares and hold a NON-EMPTY selection while the
// document holds none. With different buffers (the first attempt)
// both routings emit nothing and the test proves nothing.
let s = editor();
let (fid, doc_win, panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s);
exec(&s, "PROBE = pmacs.buffer.list()[1]");
{
let mut core = s.core.borrow_mut();
// Put real text in the document buffer so a span exists.
{
let reg = core.registry.borrow();
let _ = reg.get(doc_buf).expect("doc");
}
// The panel shows the document's buffer and selects a range.
core.install_buffer_in_window(panel_win, doc_buf)
.expect("panel shows the document buffer");
let panel = core.windows.get_mut(&panel_win).expect("panel");
panel.selection = Some(pmacs::window::Selection { anchor: 0 });
panel.cursor = 4;
// The document window selects nothing.
let doc = core.windows.get_mut(&doc_win).expect("doc");
doc.selection = None;
doc.cursor = 0;
}
let mut sem = SemanticRenderState::for_peer(fid, 18);
sem.set_viewport(doc_buf, ByteRange { start: 0, end: 8 }, 0);
let msgs = sem.render_frame(&s);
let selection_decorations: usize = msgs
.iter()
.filter_map(|m| match m {
InstanceMessage::Decorations { segments, .. } => Some(
segments
.iter()
.map(|seg| seg.decorations.len())
.sum::<usize>(),
),
_ => None,
})
.sum();
assert_eq!(
selection_decorations, 0,
"a selection living in the focused PANEL must not decorate the document viewport"
);
}
#[test]
fn a_provider_closing_the_document_split_still_clears_the_statusline() {
use pmacs::protocol::{ByteRange, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
// Round 3 finding 1. `authoritative_empty` carries PHASE-1 contexts,
// so the identity used to filter them must be the PRE-CALLBACK one.
// A provider that closes the primary document split changes
// `primary_document_window` mid-evaluation; reading it afterwards
// compares phase-1 contexts against a replacement identity, matches
// nothing, and silently suppresses the authoritative clear — leaving
// stale statusline text on screen forever.
//
// Driven on LOCAL, because the Lua window API acts on the ACTIVE
// FRONTEND: a synthetic semantic view would be untouched by
// `pmacs.window.close()` and the identity would never change, which
// is exactly how the first version of this test came back vacuous.
// TWO document windows plus the panel: closing the only document
// window is structurally refused (Q#BP6 forbids a lone side window
// as a resting state), so the first attempt could not change the
// identity at all. Distinct buffers make the target selectable from
// a Lua provider, which has no focus-by-id.
let s = editor();
exec(
&s,
"DOC_A = pmacs.buffer.create(\"*doc-a*\")
DOC_B = pmacs.buffer.create(\"*doc-b*\")
pmacs.window.display(DOC_A, {})
pmacs.window.split_horizontal()
pmacs.window.focus_next()
pmacs.window.display(DOC_B, {})",
);
let (_origin, panel) = focused_panel(&s);
let (document, doc_buf) = {
let core = s.core.borrow();
let win = core
.primary_document_window(FrontendId::LOCAL)
.expect("a primary document window");
(win, core.windows[&win].buffer_id)
};
assert_ne!(document, panel);
let mut sem = SemanticRenderState::for_peer(FrontendId::LOCAL, 18);
sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0);
// Seed a baseline payload so a CLEAR is observable as a change.
exec(
&s,
r"_G.SL_SEED = pmacs.statusline.register {
name='seed', side='left', priority=10,
fn=function() return 'OLD' end,
}",
);
let seeded = sem.render_frame(&s);
assert!(
seeded
.iter()
.any(|m| matches!(m, InstanceMessage::StatuslineSegments { .. })),
"non-vacuity: a baseline payload must exist before we test its clear"
);
// A provider that unregisters itself (making the evaluation
// Invalidated) AND closes the captured document window. `close()`
// closes the ACTIVE window and Lua has no focus-by-id, so step
// around the ring until the captured buffer is current.
s.lua_host
.lua()
.globals()
.set("TARGET_BUF", pmacs::lua_bindings::BufferIdLua(doc_buf))
.expect("expose the target buffer");
exec(
&s,
r"_G.SL_CLOSER = pmacs.statusline.register {
name='closer', side='left', priority=100,
fn=function()
pmacs.statusline.unregister(SL_CLOSER)
for _ = 1, 8 do
if pmacs.window.buffer() == TARGET_BUF then break end
pmacs.window.focus_next()
end
pmacs.window.close()
return 'STALE'
end,
}",
);
let msgs = sem.render_frame(&s);
// The fixture must actually have changed the identity, or this test
// discriminates nothing.
assert_ne!(
s.core.borrow().primary_document_window(FrontendId::LOCAL),
Some(document),
"fixture: the callback must really have changed the document identity"
);
let cleared = msgs.iter().any(|m| match m {
InstanceMessage::StatuslineSegments {
buffer_id,
left,
right,
..
} => *buffer_id == doc_buf && left.is_empty() && right.is_empty(),
_ => false,
});
assert!(
cleared,
"an invalidated evaluation must still publish the authoritative EMPTY clear for \
the phase-1 document identity, even when a callback closed that window; got {msgs:?}"
);
}