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:
commit
0a3fcd1942
|
|
@ -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
|
||||
|
|
|
|||
629
src/daemon.rs
629
src/daemon.rs
|
|
@ -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,
|
||||
®istry,
|
||||
&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,
|
||||
®istry,
|
||||
&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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
328
src/editor.rs
328
src/editor.rs
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue