Add the frame-geometry epoch machine and the panel projection

Bottom-panel Stage 2B-2, first half: the daemon-side primitives the
panel producer needs.

`GeometryUpdate` is three-valued rather than a boolean because the
caller must act differently on each arm. `declare_frame_geometry` stays
the grid/LOCAL allocator, keeps value dedup, and moves from
`saturating_add` to checked allocation with a fail-closed exhaustion
arm: it clears the declaration back to unknown, which is already
non-presentable, so reconciliation hides the panel rather than painting
one sized to a frame that no longer exists.

`accept_frame_geometry` is the separate semantic path. No value dedup —
a font or scale change can invalidate a panel frame while `CellSize` is
identical, which is exactly what daemon-side dedup cannot see (Q#BP2S1)
— and a lower epoch is rejected even when it carries identical data.

`panel_grid_size` derives Q#BP15a's third geometry: full declared
width, `fixed_rows` clamped by the recursive document minimum and then
by the shared wire area budget, with the stored request left alone.

`prepare_panel_projection` paints the side window through the Stage 2A
extracted painter, gating folds on the OWNING frontend rather than
`fold_map_for_window`'s active-frontend gate (Q#BP17), and takes the
side window's statusline segments as a parameter so one provider
invocation serves both surfaces. `window_cursor_cell` is `paint_frame`'s
caret derivation lifted out so the band does not become a second,
drifting copy of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
Levi Neuwirth 2026-07-28 17:06:36 -04:00
parent 6bee09dc98
commit 81f54e23a9
2 changed files with 498 additions and 38 deletions

View File

@ -25,7 +25,7 @@ use unicode_width::UnicodeWidthStr;
use crate::async_runtime::SharedAsyncRuntime; use crate::async_runtime::SharedAsyncRuntime;
use crate::cell::{CellCoord, CellSize}; use crate::cell::{CellCoord, CellSize};
use crate::editor_core::EditorCore; use crate::editor_core::{EditorCore, GeometryUpdate};
use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook}; use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook};
use crate::key::{Chord, display_sequence}; use crate::key::{Chord, display_sequence};
use crate::keymap_stack::{Action, KeyDispatcher}; use crate::keymap_stack::{Action, KeyDispatcher};
@ -1196,13 +1196,49 @@ impl EditorState {
/// (Q#BP2b / Q#BP15a). /// (Q#BP2b / Q#BP15a).
/// ///
/// The single seam for grid and `LOCAL` views, whose real attach and /// The single seam for grid and `LOCAL` views, whose real attach and
/// resize sizes ARE the declaration. A semantic view never calls this /// resize sizes ARE the declaration. A semantic view never calls this;
/// in Stage 1; its geometry stays **unknown**. /// its geometry arrives through
pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) { /// [`Self::accept_semantic_frame_geometry`].
self.core ///
/// Reconciliation runs on every call, not only on
/// [`GeometryUpdate::Advanced`]: panel presentability depends on the
/// layout as well as on the geometry, and this is also the defensive
/// pre-paint reconciliation point. The exhaustion arm is exactly why
/// it must still run after a `Rejected` — `declare_frame_geometry`
/// cleared the declaration to unknown, and the panel has to hide.
pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) -> GeometryUpdate {
let update = self
.core
.borrow_mut() .borrow_mut()
.declare_frame_geometry(frontend_id, total); .declare_frame_geometry(frontend_id, total);
self.reconcile_panel_layout(frontend_id); self.reconcile_panel_layout(frontend_id);
update
}
/// Accept an authenticated semantic frontend's
/// `FrontendEvent::FrontendCellGeometry` declaration (Q#BP15a).
///
/// The three outcomes are acted on differently, and that is the whole
/// point of the three-valued result: `Advanced` reconciles panel
/// layout, `Duplicate` returns without touching panel state, and
/// `Rejected` drops the event before any reconciliation. A
/// `Duplicate` that reconciled would do redundant work on every
/// repeated declaration; a `Rejected` that reconciled would let a
/// stale or conflicting declaration move the panel.
pub fn accept_semantic_frame_geometry(
&self,
frontend_id: FrontendId,
geometry_epoch: u64,
total: CellSize,
) -> GeometryUpdate {
let update =
self.core
.borrow_mut()
.accept_frame_geometry(frontend_id, geometry_epoch, total);
if update == GeometryUpdate::Advanced {
self.reconcile_panel_layout(frontend_id);
}
update
} }
/// Local-frontend compatibility wrapper. /// Local-frontend compatibility wrapper.
@ -1875,6 +1911,231 @@ impl EditorState {
true true
} }
/// Paint one semantic frontend's side window into a panel-sized grid
/// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17).
///
/// Returns `None` for every non-presentable state — no side window,
/// a hidden panel, unknown geometry, a zero-column frame, or a grid
/// too small for the structural floor. The caller turns that into an
/// **authoritative**
/// [`pmacs_protocol::panel::PanelFramePayload::Absent`]: silence
/// would leave the receiver's retained band on screen forever.
///
/// `statusline` is the side window's evaluated segments, supplied by
/// the caller from the *same* provider invocation that produced the
/// document's wire segments (parent acceptance 45). Evaluating again
/// here would run every provider twice per frame.
///
/// **Folds are gated on the OWNING frontend (Q#BP17).** The panel is
/// painted for `frontend_id`, which is not necessarily the acting
/// frontend, so `EditorCore::fold_map_for_window` — which gates on
/// the *active* frontend — is the wrong source and is deliberately
/// not called.
#[must_use]
pub fn prepare_panel_projection(
&self,
frontend_id: FrontendId,
statusline: Option<&crate::statusline::StatuslineWindowSegments>,
) -> Option<PanelProjection> {
let (size, window_id, buffer_id, focused, fold_projection) = {
let core = self.core.borrow();
let size = core.panel_grid_size(frontend_id)?;
let window_id = core.side_window_for(frontend_id)?;
let buffer_id = core.windows.get(&window_id)?.buffer_id;
let view = core.views.get(&frontend_id)?;
(
size,
window_id,
buffer_id,
view.active == window_id,
view.fold_projection,
)
};
let outer = Rect::new(0, 0, size.rows, size.cols);
let content = Rect::new(0, 0, size.rows.saturating_sub(1), size.cols);
let placement = WindowPlacement { outer, content };
let theme = {
let handle = self.syntax_registry.theme();
let t = handle.lock().expect("theme mutex poisoned");
t.clone()
};
let mut cells = vec![crate::cell::Cell::default(); (size.rows * size.cols) as usize];
let mut grid = crate::cell::CellGrid {
cells: &mut cells,
stride: size.cols,
size,
};
// Q#BP7 / Q#BP15a: a terminal panel's grid excludes its one mode
// line, and its geometry reaches the shared screen through the
// same view-size path the grid frontends use — never the 24×80
// attach placeholder and never the full-window declaration.
let terminal = self.terminal_manager.borrow().is_terminal(buffer_id);
let cursor = if terminal {
let key = TerminalViewKey::new(frontend_id, window_id, buffer_id);
let snapshot = self
.terminal_manager
.borrow_mut()
.snapshot_for_view(key, content.size)?;
paint_terminal_snapshot(&mut grid, content, &snapshot, &theme);
let registry = self.core.borrow().registry.clone();
let reg = registry.borrow();
if let Ok(buf) = reg.get(buffer_id) {
let coord = snapshot.cursor.unwrap_or_default();
let scroll = if snapshot.scroll_offset == 0 {
String::new()
} else {
format!("{}", snapshot.scroll_offset)
};
paint_mode_line(
&mut grid,
&outer,
buf.name(),
false,
focused,
coord.row,
coord.col,
&scroll,
"",
mode_line_style(&theme),
statusline.map_or(&[], |segments| segments.left.as_slice()),
statusline.map_or(&[], |segments| segments.right.as_slice()),
&theme,
);
}
snapshot
.cursor
.filter(|coord| coord.row < content.size.rows && coord.col < content.size.cols)
} else {
let registry = self.core.borrow().registry.clone();
let reg = registry.borrow();
let diag_store = self.lsp_manager.borrow().diag_store();
let mut core = self.core.borrow_mut();
let window = core.windows.get_mut(&window_id)?;
let buf = reg.get(buffer_id).ok()?;
let folds = if fold_projection {
crate::fold_view::map_for_window(&self.fold_registry, window)
} else {
None
};
window.last_visible_rows = content.size.rows;
// A2A-3 / parent 48: the auto-scroll clamp belongs to the
// FOCUSED window only. Running it for a passive panel would
// move a `view_top` the user is not driving.
if focused {
prepare_window_cursor_visible(window, buf, content.size.rows, folds.as_ref());
}
paint_window_content(
&mut grid,
window,
buf,
placement,
folds.as_ref(),
focused,
&theme,
statusline,
&diag_store,
);
window_cursor_cell(window, buf, folds.as_ref(), outer)
};
Some(PanelProjection {
window_id,
buffer_id,
size,
cells,
cursor,
focused,
})
}
/// Apply an accepted `FrontendEvent::PanelResizeRows` (Q#BP15a).
///
/// The request is expressed as a boundary move rather than a direct
/// `fixed_rows` write, so it lands on the same Q#BP5b clamp path a
/// TUI divider drag takes — including the interactive
/// `window.min-height` preference resolved per leaf. A request the
/// clamp cannot satisfy is a no-op, not an error.
///
/// Returns whether the effective allocation actually moved.
pub fn apply_panel_resize_rows(&self, frontend_id: FrontendId, rows: u32) -> bool {
let (side, area_rows, current) = {
let core = self.core.borrow();
match (
core.side_window_for(frontend_id),
core.frontend_area_rows(frontend_id),
) {
(Some(side), Some(area_rows)) => (
side,
area_rows,
core.panel_allocation(frontend_id, area_rows),
),
_ => return false,
}
};
let Some(current) = current else {
return false;
};
let Ok(rows) = EditorCore::clamp_panel_rows(rows) else {
return false;
};
let Ok(delta) = i32::try_from(i64::from(rows) - i64::from(current)) else {
return false;
};
if delta == 0 {
return false;
}
let _ = self.resize_window_boundary(frontend_id, side, delta, area_rows);
self.reconcile_panel_layout(frontend_id);
self.core
.borrow()
.panel_allocation(frontend_id, area_rows)
.is_some_and(|now| now != current)
}
/// Apply an accepted `FrontendEvent::PanelPointer` gesture (Q#BP16).
///
/// Steps 2, 5, and 6 of Q#BP16's ladder are re-derived here from the
/// daemon's own state — a live, non-hidden side window whose current
/// buffer matches the payload, and a coordinate inside the grid the
/// daemon derived. Steps 1, 3, and 4 (source authentication and both
/// epochs) belong to the caller, because only the session holds the
/// declaration the frontend was actually looking at.
///
/// **Click-to-focus only in Stage 2B-2.** A `Down`/`Up`/wheel/context
/// gesture activates the panel; replaying it into selection, listview
/// rows, or child SGR reporting is parent acceptance 48, which needs
/// the GPU band and lands in Stage 2B-3. Bare hover neither focuses
/// nor claims anything, exactly as on the document terminal path.
///
/// Returns whether the gesture was accepted.
pub fn dispatch_semantic_panel_pointer(
&self,
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
coord: CellCoord,
kind: pmacs_protocol::MouseKind,
) -> bool {
let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else {
return false;
};
if coord.row >= size.rows || coord.col >= size.cols {
return false;
}
let mut core = self.core.borrow_mut();
let Some(side) = core.side_window_for(frontend_id) else {
return false;
};
if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) {
return false;
}
if !matches!(kind, pmacs_protocol::MouseKind::Move) {
core.focus_window(frontend_id, side);
core.active_frontend = frontend_id;
}
true
}
/// Precompute owned terminal view snapshots before entering paint borrows. /// Precompute owned terminal view snapshots before entering paint borrows.
pub fn prepare_terminal_views( pub fn prepare_terminal_views(
&mut self, &mut self,
@ -3085,6 +3346,29 @@ const SCROLL_LINES: i32 = 3;
/// therefore only appears when a line-number mode reserves a gutter. /// therefore only appears when a line-number mode reserves a gutter.
const FOLD_GUTTER_GLYPH: char = '▸'; const FOLD_GUTTER_GLYPH: char = '▸';
/// One painted side window, ready to become a
/// [`pmacs_protocol::panel::PanelFrame`] (bottom-panel Stage 2B-2).
///
/// The producer carries the identity fields as well as the cells because
/// the presentation epoch is allocated from them: `window_id` changes on
/// a new side window and `buffer_id` on a replacement, and either one
/// moving is what makes a stale `PanelPointer` unaddressable (Q#BP16).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PanelProjection {
/// The side window this frame projects.
pub window_id: WindowId,
/// Buffer that window is currently showing.
pub buffer_id: crate::buffer::BufferId,
/// Panel grid dimensions, mode line included.
pub size: CellSize,
/// Row-major cells; exactly `size.area()` entries.
pub cells: Vec<crate::cell::Cell>,
/// Panel caret, or `None` when it is scrolled out of the band.
pub cursor: Option<CellCoord>,
/// Whether the panel currently owns this frontend's focus.
pub focused: bool,
}
/// Shared outer/content geometry consumed by terminal paint and PTY resize. /// Shared outer/content geometry consumed by terminal paint and PTY resize.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct WindowPlacement { pub(crate) struct WindowPlacement {
@ -3943,44 +4227,64 @@ pub fn paint_frame(
let registry = core.registry.clone(); let registry = core.registry.clone();
let reg = registry.borrow(); let reg = registry.borrow();
let aw = &core.windows[&active]; let aw = &core.windows[&active];
let inner_rows = inner_rows(&active_rect);
let buf = reg.get(aw.buffer_id).ok()?; let buf = reg.get(aw.buffer_id).ok()?;
// Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden
// line renders at its hidden component's head POSITION — the visible
// head row *and* that head's end-of-content column, i.e. exactly
// where Stage 1 moves point on a fold-at-cursor. Row-only clamping
// would leave the column unspecified; resolving through the merged
// component (rather than the innermost containing fold) also keeps a
// crossing overlap from landing on another hidden position.
let folds = crate::fold_view::map_for_window(&state.fold_registry, aw); let folds = crate::fold_view::map_for_window(&state.fold_registry, aw);
let cursor = match folds.as_ref() { window_cursor_cell(aw, buf, folds.as_ref(), active_rect)
Some(map) => map.visible_position(aw.text_view.line_at_offset(aw.cursor), aw.cursor), }
None => aw.cursor,
}; /// Where one window's caret lands in the cell grid, or `None` when it is
let disp = aw.text_view.pos_to_display(buf, cursor)?; /// scrolled out of that window's text area.
let row_offset = match folds.as_ref() { ///
/// Extracted from `paint_frame`'s tail for bottom-panel Stage 2B-2: the
/// panel band ships its own caret in
/// [`pmacs_protocol::panel::PanelFrame::cursor`], and a second derivation
/// would be the exact shape of Stage 1's `Layout::compute` two-caller
/// defect — one consumer silently reckoning against different geometry.
///
/// Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden line
/// renders at its hidden component's head POSITION — the visible head row
/// *and* that head's end-of-content column, i.e. exactly where Stage 1
/// moves point on a fold-at-cursor. Row-only clamping would leave the
/// column unspecified; resolving through the merged component (rather
/// than the innermost containing fold) also keeps a crossing overlap from
/// landing on another hidden position.
fn window_cursor_cell(
window: &crate::window::Window,
buf: &crate::buffer::Buffer,
folds: Option<&crate::fold_view::VisibleLineMap>,
rect: Rect,
) -> Option<CellCoord> {
let inner_rows = inner_rows(&rect);
let cursor = match folds {
Some(map) => { Some(map) => {
let top = map.clamp_view_top(aw.view_top); map.visible_position(window.text_view.line_at_offset(window.cursor), window.cursor)
}
None => window.cursor,
};
let disp = window.text_view.pos_to_display(buf, cursor)?;
let row_offset = match folds {
Some(map) => {
let top = map.clamp_view_top(window.view_top);
let row = disp.row as usize; let row = disp.row as usize;
if row < top { if row < top {
return None; return None;
} }
map.visible_rows_between(top, row) map.visible_rows_between(top, row)
} }
None => (disp.row as usize).checked_sub(aw.view_top)?, None => (disp.row as usize).checked_sub(window.view_top)?,
}; };
if row_offset >= inner_rows as usize { if row_offset >= inner_rows as usize {
return None; return None;
} }
// UX gutter: the terminal caret sits in the text area, past the // UX gutter: the caret sits in the text area, past the reserved
// reserved gutter strip (mirrors the viewport shift above). // gutter strip (mirrors the viewport shift in `paint_window_content`).
let gutter_w = { let gutter_w = {
let w = aw.gutter_width(); let w = window.gutter_width();
if w >= active_rect.size.cols { 0 } else { w } if w >= rect.size.cols { 0 } else { w }
}; };
let grid_row = active_rect.origin.row + u32::try_from(row_offset).ok()?; let grid_row = rect.origin.row + u32::try_from(row_offset).ok()?;
let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1); let max_col = rect.origin.col + rect.size.cols.saturating_sub(1);
let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col); let grid_col = (rect.origin.col + gutter_w + disp.col).min(max_col);
Some(CellCoord::new(grid_row, grid_col)) Some(CellCoord::new(grid_row, grid_col))
} }

View File

@ -236,6 +236,28 @@ pub struct PanelReconciliation {
pub released_terminal: Option<WindowId>, pub released_terminal: Option<WindowId>,
} }
/// What a frame-geometry declaration did (Q#BP2S1, Stage 2 §3.1).
///
/// Three-valued rather than a boolean because the caller must act
/// differently on each, and collapsing the middle arm is a defect in one
/// direction or the other: folded into `Advanced` it reconciles panel
/// layout on every repeated declaration; folded into `Rejected` it
/// reports a stale-event condition that never happened. A `Duplicate`
/// **is** accepted — which is why a narrower internal boolean would have
/// to be named `advanced`, never `accepted`.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum GeometryUpdate {
/// The epoch advanced and the declaration was stored verbatim. Run
/// panel reconciliation.
Advanced,
/// Same epoch, same total: already current. Do no work.
Duplicate,
/// Same epoch with a different total, a lower epoch, the reserved
/// epoch `0`, an unknown frontend, or allocator exhaustion. Drop the
/// event before any reconciliation.
Rejected,
}
/// Row extent of an arbitrary subtree, derived from its leaves' computed /// Row extent of an arbitrary subtree, derived from its leaves' computed
/// rects: leaves tile their parent, so the union's height is the node's. /// rects: leaves tile their parent, so the union's height is the node's.
fn node_row_extent(node: &LayoutNode, placements: &HashMap<WindowId, crate::window::Rect>) -> u32 { fn node_row_extent(node: &LayoutNode, placements: &HashMap<WindowId, crate::window::Rect>) -> u32 {
@ -3240,29 +3262,163 @@ impl EditorCore {
(geometry.total.rows >= 2 && geometry.total.cols > 0).then(|| geometry.total.rows - 1) (geometry.total.rows >= 2 && geometry.total.cols > 0).then(|| geometry.total.rows - 1)
} }
/// Cache a frontend's authoritative frame capacity (Q#BP2b). /// A frontend's current authoritative frame-geometry declaration.
///
/// The panel producer echoes `geometry_epoch` into every
/// [`pmacs_protocol::panel::PanelFrame`] it ships, and the daemon
/// compares an inbound panel event's epoch against it (Q#BP16 step
/// 3), so the epoch has to be readable, not only the size.
#[must_use]
pub fn frame_geometry_for(
&self,
fid: FrontendId,
) -> Option<crate::window::DeclaredFrameGeometry> {
self.views.get(&fid)?.frame_geometry
}
/// Cache a frontend's authoritative frame capacity — the **grid /
/// `LOCAL`** allocator (Q#BP2b, Stage 2 §3.1).
/// ///
/// Grid / `LOCAL` views call this from their real attach and resize /// Grid / `LOCAL` views call this from their real attach and resize
/// sizes with an internally minted epoch; a semantic view stays /// sizes with an internally minted epoch; a semantic view never takes
/// `None` until Stage 2's authenticated declaration. A repeated /// this path at all — it goes through
/// identical size is not a new declaration. /// [`Self::accept_frame_geometry`], which applies the frontend-owned
pub fn declare_frame_geometry(&mut self, fid: FrontendId, total: crate::cell::CellSize) { /// epoch verbatim and does **no** value dedup.
///
/// Value dedup is correct *here* and only here: a grid frontend's
/// cells are the unit it declares, so an unchanged grid under
/// unchanged metrics leaves any existing frame valid. It is wrong on
/// the semantic path, where a font or scale change can invalidate a
/// panel frame while [`crate::cell::CellSize`] is identical — the
/// case daemon-side dedup cannot see (Q#BP2S1).
///
/// **Exhaustion fails closed.** Allocation is checked rather than
/// saturating: `saturating_add` pins at `u64::MAX`, after which two
/// different geometries share one declaration id. On exhaustion the
/// authoritative declaration is *cleared* to `None` (unknown), which
/// is already non-presentable under Q#BP2b, so the caller's
/// reconciliation hides the panel. Retaining the last valid geometry
/// would keep painting a panel sized to a frame that no longer
/// exists.
pub fn declare_frame_geometry(
&mut self,
fid: FrontendId,
total: crate::cell::CellSize,
) -> GeometryUpdate {
let Some(view) = self.views.get_mut(&fid) else { let Some(view) = self.views.get_mut(&fid) else {
return; return GeometryUpdate::Rejected;
}; };
if view if view
.frame_geometry .frame_geometry
.is_some_and(|geometry| geometry.total == total) .is_some_and(|geometry| geometry.total == total)
{ {
return; return GeometryUpdate::Duplicate;
} }
let next = view let next = match view.frame_geometry {
.frame_geometry None => Some(1),
.map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1)); Some(geometry) => geometry.geometry_epoch.checked_add(1),
};
let Some(next) = next else {
view.frame_geometry = None;
return GeometryUpdate::Rejected;
};
view.frame_geometry = Some(crate::window::DeclaredFrameGeometry { view.frame_geometry = Some(crate::window::DeclaredFrameGeometry {
geometry_epoch: next, geometry_epoch: next,
total, total,
}); });
GeometryUpdate::Advanced
}
/// Accept a **semantic** frontend's authoritative geometry
/// declaration (Q#BP15a, Stage 2 §3.1).
///
/// Deliberately a second method rather than
/// [`Self::declare_frame_geometry`] with an optional epoch: the two
/// regimes differ in whether value dedup applies, and one ambiguous
/// entry point would let a future caller silently take the wrong one.
///
/// | Incoming declaration | Result |
/// | --- | --- |
/// | epoch **greater** than stored | [`GeometryUpdate::Advanced`], stored **verbatim**, even when `total` is unchanged |
/// | same epoch, same `total` | [`GeometryUpdate::Duplicate`] |
/// | same epoch, **different** `total` | [`GeometryUpdate::Rejected`] |
/// | **lower** epoch, any `total` | [`GeometryUpdate::Rejected`] |
///
/// The last row is not an optimization: a lower epoch carrying
/// *identical* data is still stale, and accepting it would let a
/// reordered declaration resurrect geometry the frontend has moved
/// past.
///
/// Epoch `0` is reserved for "never declared" and is rejected on the
/// wire.
pub fn accept_frame_geometry(
&mut self,
fid: FrontendId,
geometry_epoch: u64,
total: crate::cell::CellSize,
) -> GeometryUpdate {
if geometry_epoch == 0 {
return GeometryUpdate::Rejected;
}
let Some(view) = self.views.get_mut(&fid) else {
return GeometryUpdate::Rejected;
};
match view.frame_geometry {
Some(stored) if geometry_epoch < stored.geometry_epoch => GeometryUpdate::Rejected,
Some(stored) if geometry_epoch == stored.geometry_epoch => {
if stored.total == total {
GeometryUpdate::Duplicate
} else {
GeometryUpdate::Rejected
}
}
_ => {
view.frame_geometry = Some(crate::window::DeclaredFrameGeometry {
geometry_epoch,
total,
});
GeometryUpdate::Advanced
}
}
}
/// The **third** geometry of Q#BP15a: the panel grid the daemon
/// derives and paints, or `None` when no panel is presentable.
///
/// Columns are the frontend's full declared width. Rows are the
/// stored `fixed_rows` request clamped by Q#BP2's recursive document
/// minimum ([`Self::panel_allocation`]) and then by the shared wire
/// area budget, so a very wide frame cannot produce a frame the
/// protocol would reject. **The stored request is never rewritten**
/// — a later narrower geometry restores it.
///
/// Returns `None` — the Q#BP2b hidden arm — when geometry is unknown,
/// when the panel is hidden or absent, when the frame declares zero
/// columns, or when even [`MIN_WINDOW_OUTER_ROWS`] rows would exceed
/// the area budget.
#[must_use]
pub fn panel_grid_size(&self, fid: FrontendId) -> Option<crate::cell::CellSize> {
let view = self.views.get(&fid)?;
if view.panel_hidden {
return None;
}
self.side_window_for(fid)?;
let geometry = view.frame_geometry?;
let cols = geometry.total.cols;
if cols == 0 {
return None;
}
let area_rows = self.frontend_area_rows(fid)?;
let rows = self.panel_allocation(fid, area_rows)?;
// The wire's area bound is a transport-safety limit, not a
// policy: clamp rows against it rather than shipping a frame the
// shared validator would reject whole.
let budget_rows = u32::try_from(
pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1),
)
.unwrap_or(u32::MAX);
let rows = rows.min(budget_rows);
(rows >= MIN_WINDOW_OUTER_ROWS).then(|| crate::cell::CellSize::new(rows, cols))
} }
/// Core half of the idempotent panel-reconciliation transaction /// Core half of the idempotent panel-reconciliation transaction