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:
Levi Neuwirth 2026-07-25 22:04:56 -04:00
parent b76da70d5d
commit d7ad01b535
3 changed files with 556 additions and 134 deletions

View File

@ -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

View File

@ -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);

View File

@ -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"
);
}