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::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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue