feat(panel): extract the per-window painter + Stage 2A acceptance
Bottom-panel Stage 2A, second half (Q#BP8, Q#BP17). Still no protocol change and no behavior change: `paint_frame` builds the same fold map it always did and passes it in, so grid rendering is unchanged. Two extractions, both taking the fold map as a **parameter** rather than building it: - `prepare_window_cursor_visible` — the active-window auto-scroll clamp. The panel band (2B) runs this for its own window when that window owns focus, and leaves a passive panel's `view_top` alone. - `paint_window_content` — the per-window document body: text, gutter, overlays, selection, and the mode line. The panel paints into a panel-sized grid at the same origin-agnostic `Viewport`, so this is that body lifted out, not a second painter (Bet B2'). The parameter is the point (Q#BP17). Folding built its per-window map ungated on the premise that "a semantic session never enters `paint_frame`", which the panel band breaks. The panel path must pass `None` for a frontend whose `fold_projection` is false, and must not call `EditorCore::fold_map_for_window` — that gates on the **active** frontend, which is right for command-time reckoning and wrong for painting another frontend's panel. `tests/bottom_panel_stage2a_acceptance.rs` — 10 tests. The negative half is the load-bearing half, so Projection assertions are paired with focus-class assertions taken in the SAME state: - `focus_and_projection_disagree_in_the_same_state` is the key one: with a panel focused, the focus authority must name the panel while the projection authority names the document. Routing the focus class through `primary_document_window` fails this even though every Projection test still passes. - The statusline pair pins the split: the LOOKUP resolves the document window while `active` reports actual focus, with a non-vacuity twin that flips `active` back to true when focus returns. - The extraction pair pins cells, the returned cursor, the focused window's `view_top`, AND a passive window's untouched scroll — identical cells alone would not catch a clamp that moved to the wrong window on a single-window frame. - `the_panel_fixture_really_builds_a_side_window` pins the fixture's own precondition, since every other test is worthless if `focused_panel` silently produced an ordinary split. One crdt-gated caller of the old `align_semantic_window_to_buffer` was updated; it compiles only under `--features crdt`, which is the config CI never runs. 1,832 default + 2,009 CRDT library tests, 10 new acceptance; fmt and workspace clippy clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b76da70d5d
commit
d7ad01b535
|
|
@ -4439,7 +4439,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.
|
||||
|
|
@ -4477,7 +4477,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
|
||||
|
|
|
|||
307
src/editor.rs
307
src/editor.rs
|
|
@ -3169,6 +3169,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.
|
||||
///
|
||||
|
|
@ -3278,6 +3442,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)
|
||||
|
|
@ -3285,36 +3454,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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3366,114 +3506,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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,377 @@
|
|||
// 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(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) => {
|
||||
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, {})",
|
||||
);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue