diff --git a/src/editor.rs b/src/editor.rs index 935ee4c..1118254 100644 --- a/src/editor.rs +++ b/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 { + 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, + /// Panel caret, or `None` when it is scrolled out of the band. + pub cursor: Option, + /// 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 { + 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)) } diff --git a/src/editor_core.rs b/src/editor_core.rs index 7fd90c6..bd80a3f 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -236,6 +236,28 @@ pub struct PanelReconciliation { pub released_terminal: Option, } +/// 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) -> 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 { + 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 { + 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