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:
parent
6bee09dc98
commit
81f54e23a9
360
src/editor.rs
360
src/editor.rs
|
|
@ -25,7 +25,7 @@ use unicode_width::UnicodeWidthStr;
|
|||
|
||||
use crate::async_runtime::SharedAsyncRuntime;
|
||||
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::key::{Chord, display_sequence};
|
||||
use crate::keymap_stack::{Action, KeyDispatcher};
|
||||
|
|
@ -1196,13 +1196,49 @@ impl EditorState {
|
|||
/// (Q#BP2b / Q#BP15a).
|
||||
///
|
||||
/// The single seam for grid and `LOCAL` views, whose real attach and
|
||||
/// resize sizes ARE the declaration. A semantic view never calls this
|
||||
/// in Stage 1; its geometry stays **unknown**.
|
||||
pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) {
|
||||
self.core
|
||||
/// resize sizes ARE the declaration. A semantic view never calls this;
|
||||
/// its geometry arrives through
|
||||
/// [`Self::accept_semantic_frame_geometry`].
|
||||
///
|
||||
/// 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()
|
||||
.declare_frame_geometry(frontend_id, total);
|
||||
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.
|
||||
|
|
@ -1875,6 +1911,231 @@ impl EditorState {
|
|||
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.
|
||||
pub fn prepare_terminal_views(
|
||||
&mut self,
|
||||
|
|
@ -3085,6 +3346,29 @@ const SCROLL_LINES: i32 = 3;
|
|||
/// therefore only appears when a line-number mode reserves a gutter.
|
||||
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.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct WindowPlacement {
|
||||
|
|
@ -3943,44 +4227,64 @@ pub fn paint_frame(
|
|||
let registry = core.registry.clone();
|
||||
let reg = registry.borrow();
|
||||
let aw = &core.windows[&active];
|
||||
let inner_rows = inner_rows(&active_rect);
|
||||
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 cursor = match folds.as_ref() {
|
||||
Some(map) => map.visible_position(aw.text_view.line_at_offset(aw.cursor), aw.cursor),
|
||||
None => aw.cursor,
|
||||
};
|
||||
let disp = aw.text_view.pos_to_display(buf, cursor)?;
|
||||
let row_offset = match folds.as_ref() {
|
||||
window_cursor_cell(aw, buf, folds.as_ref(), active_rect)
|
||||
}
|
||||
|
||||
/// Where one window's caret lands in the cell grid, or `None` when it is
|
||||
/// scrolled out of that window's text area.
|
||||
///
|
||||
/// 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) => {
|
||||
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;
|
||||
if row < top {
|
||||
return None;
|
||||
}
|
||||
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 {
|
||||
return None;
|
||||
}
|
||||
// UX gutter: the terminal caret sits in the text area, past the
|
||||
// reserved gutter strip (mirrors the viewport shift above).
|
||||
// UX gutter: the caret sits in the text area, past the reserved
|
||||
// gutter strip (mirrors the viewport shift in `paint_window_content`).
|
||||
let gutter_w = {
|
||||
let w = aw.gutter_width();
|
||||
if w >= active_rect.size.cols { 0 } else { w }
|
||||
let w = window.gutter_width();
|
||||
if w >= rect.size.cols { 0 } else { w }
|
||||
};
|
||||
let grid_row = active_rect.origin.row + u32::try_from(row_offset).ok()?;
|
||||
let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1);
|
||||
let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col);
|
||||
let grid_row = rect.origin.row + u32::try_from(row_offset).ok()?;
|
||||
let max_col = rect.origin.col + rect.size.cols.saturating_sub(1);
|
||||
let grid_col = (rect.origin.col + gutter_w + disp.col).min(max_col);
|
||||
Some(CellCoord::new(grid_row, grid_col))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -236,6 +236,28 @@ pub struct PanelReconciliation {
|
|||
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
|
||||
/// 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 {
|
||||
|
|
@ -3240,29 +3262,163 @@ impl EditorCore {
|
|||
(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
|
||||
/// sizes with an internally minted epoch; a semantic view stays
|
||||
/// `None` until Stage 2's authenticated declaration. A repeated
|
||||
/// identical size is not a new declaration.
|
||||
pub fn declare_frame_geometry(&mut self, fid: FrontendId, total: crate::cell::CellSize) {
|
||||
/// sizes with an internally minted epoch; a semantic view never takes
|
||||
/// this path at all — it goes through
|
||||
/// [`Self::accept_frame_geometry`], which applies the frontend-owned
|
||||
/// 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 {
|
||||
return;
|
||||
return GeometryUpdate::Rejected;
|
||||
};
|
||||
if view
|
||||
.frame_geometry
|
||||
.is_some_and(|geometry| geometry.total == total)
|
||||
{
|
||||
return;
|
||||
return GeometryUpdate::Duplicate;
|
||||
}
|
||||
let next = view
|
||||
.frame_geometry
|
||||
.map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1));
|
||||
let next = match view.frame_geometry {
|
||||
None => Some(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 {
|
||||
geometry_epoch: next,
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue