From 431d844322eafa1a50e87fb541de14c5c6f5572a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 29 Jul 2026 18:30:57 -0400 Subject: [PATCH] feat(bottom-panel): split the GPU document bottom into three boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bottom-panel Stage 2B-3, part 2 of 3: the pixel substrate for the band. `text_area_bottom` was three boundaries wearing one name — its own doc comment called it "the single source for every bottom-of-text computation" — and once a band can be installed they must diverge: status_band_top = max(0, height - status_band_height) geometry_capacity_bottom = max(0, status_band_top - divider_height) document_text_bottom = max(0, status_band_top - installed_band) The census is 29 matches: 20 production call sites, 1 definition, 8 test sites. All 20 were read in their enclosing function and classified individually — 8 status-owned, 12 document-owned. A blanket rewrite that subtracted the band from all of them would move the status chrome with the document and pass an "everything moved" assertion, which is why the classification is per site and the criterion asserts both directions. The three easiest to get wrong keep their named symptoms: document completion placement is document-owned (status-owned would overlap the band), minibuffer candidate clipping is status-owned (the minibuffer is global bufferless chrome anchored to the band, and clipping it at the document boundary would cut it off), and edge scrolling is document-owned (left on the old bottom it would auto-scroll from inside the panel). `geometry_capacity_bottom` reserves the divider even while the panel is absent. That asymmetry is what breaks the first-open cycle: the daemon sizes a panel from the capacity it was told about, so a capacity that ignored the divider would grant a first panel that does not fit once the divider appears beside it. The document loses no pixels until a `Present` frame is really on screen. `PanelBandInset` is a newtype, not an `f32`, because three boundaries here take a pixel height and only one takes this one. Alongside it, the band's own machinery: `PanelBand` with ONE derivation of "is a panel on screen" (`presented()` — retained valid frame, matching geometry epoch, latch clear), the frontend-owned epoch state machine with its fail-closed exhaustion latch, the `Absent`-is-authoritative receipt path, `panel_cell_capacity` (no per-axis cap — a panel may legitimately be wider than a PTY — plus the daemon's virtual status row), the stable normal-face probe for column count, and the divider strip whose paint rect IS its hit rect. `TerminalPaintPlan::build_grid` factors the shared cell planner so a panel and a terminal cannot disagree about a wide-continuation pair; terminal selection spans stay outside it rather than being faked as empty inside. `PANEL_MIN_VERSION` moves into `pmacs-protocol` so the GPU frontend aliases one definition instead of restating 21. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T --- pmacs-gpu/src/attach.rs | 92 +++ pmacs-gpu/src/main.rs | 632 ++++++++++++++++-- pmacs-gpu/src/terminal.rs | 111 ++- pmacs-protocol/src/lib.rs | 4 +- pmacs-protocol/src/panel.rs | 19 + src/protocol.rs | 15 - ...ottom_panel_stage2b_protocol_acceptance.rs | 10 +- 7 files changed, 809 insertions(+), 74 deletions(-) diff --git a/pmacs-gpu/src/attach.rs b/pmacs-gpu/src/attach.rs index 75427fa..21afdbc 100644 --- a/pmacs-gpu/src/attach.rs +++ b/pmacs-gpu/src/attach.rs @@ -355,6 +355,30 @@ fn coalesce_kind(event: &FrontendEvent) -> Option { kind: MouseKind::Drag(_), .. } => Some(3), + // Bottom panel Stage 2B-3 (framing §5.2) — four more tail-only + // tags, each with its own reason. + // + // Geometry is latest-wins because epochs need only INCREASE, not + // be consecutive: the daemon accepts a jump from 3 to 7 and the + // dropped declarations described geometry that no longer exists. + FrontendEvent::FrontendCellGeometry { .. } => Some(4), + // A resize drag coalesces over the complete event including its + // epochs, so a collapsed run cannot mix a new row count with an + // old presentation identity. + FrontendEvent::PanelResizeRows { .. } => Some(5), + // Panel move and drag mirror their terminal twins. Down / Up / + // wheel / context stay LOSSLESS and ordered: repeated left Downs + // are what the daemon's click state reads as a multi-click, and + // Down(Right) is the context-menu gesture, so collapsing either + // silently changes the gesture's meaning. + FrontendEvent::PanelPointer { + kind: MouseKind::Move, + .. + } => Some(6), + FrontendEvent::PanelPointer { + kind: MouseKind::Drag(_), + .. + } => Some(7), _ => None, } } @@ -1004,6 +1028,74 @@ impl AttachClient { }) } + /// Send a `FrontendEvent::FrontendCellGeometry` (Q#BP15a): this + /// frontend's authoritative whole-cell layout capacity. Callers gate on + /// [`Self::session_protocol_version`] `>= 21`. + /// + /// Valid **without** a side window on purpose — the daemon needs + /// columns before it can paint a first panel frame, so gating this on + /// panel presence would deadlock the first open. + pub fn send_frontend_cell_geometry( + &self, + geometry_epoch: u64, + total: CellSize, + ) -> Result<(), TransportError> { + self.send_event(FrontendEvent::FrontendCellGeometry { + frontend_id: self.frontend_id, + geometry_epoch, + total, + }) + } + + /// Send a `FrontendEvent::PanelResizeRows` (Q#BP15a): the fixed panel + /// rows a divider drag is requesting. Callers gate on + /// [`Self::session_protocol_version`] `>= 21`. + /// + /// Both epochs ride along as identities, not geometry: the daemon + /// accepts the request only for the panel it most recently declared, + /// under the geometry it most recently accepted. + pub fn send_panel_resize_rows( + &self, + geometry_epoch: u64, + panel_epoch: u64, + rows: u32, + ) -> Result<(), TransportError> { + self.send_event(FrontendEvent::PanelResizeRows { + frontend_id: self.frontend_id, + geometry_epoch, + panel_epoch, + rows, + }) + } + + /// Send a `FrontendEvent::PanelPointer` (Q#BP16): a gesture + /// hit-tested locally to a panel CELL. Callers gate on + /// [`Self::session_protocol_version`] `>= 21`. + /// + /// `buffer_id` and `panel_epoch` close different holes and neither + /// subsumes the other — the first catches an A→B buffer replacement, + /// the second a close/hide/reopen of the *same* buffer — so both are + /// carried rather than one being derived from the other. + pub fn send_panel_pointer( + &self, + geometry_epoch: u64, + panel_epoch: u64, + buffer_id: BufferId, + coord: CellCoord, + kind: MouseKind, + mods: Modifiers, + ) -> Result<(), TransportError> { + self.send_event(FrontendEvent::PanelPointer { + frontend_id: self.frontend_id, + geometry_epoch, + panel_epoch, + buffer_id, + coord, + kind, + mods, + }) + } + /// Send a `FrontendEvent::MenuPointer` (Q#CM1) — open-menu /// navigation hit-tested locally against the popup we drew. `index` /// is the row the pointer is over (`None` = off the menu); `invoke` diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 9dab1bf..db8b754 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -49,6 +49,7 @@ use pmacs_protocol::{ StyleSegment, StyleSpan, TAB_STOP_COLUMNS, TerminalFrame, UnderlineStyle, cell::{Color as CellColor, Style as CellStyle}, is_builtin_pair_char, is_modeline_face_name, + panel::{PANEL_MIN_VERSION, PanelFrame, PanelFramePayload}, }; use unicode_width::UnicodeWidthChar; use wgpu::MultisampleState; @@ -137,6 +138,14 @@ impl FontMetrics { fn status_band_height(self) -> f32 { BASE_STATUS_BAND_HEIGHT * self.scale } + /// The panel divider strip's thickness (Stage 2 framing §5.3). + /// + /// Scaled like the status band because it is row chrome, not a fixed + /// surface inset. The whole strip is both painted and hit-tested, so + /// paint geometry and drag geometry cannot drift apart. + fn divider_height(self) -> f32 { + BASE_DIVIDER_HEIGHT * self.scale + } fn status_font_size(self) -> f32 { BASE_STATUS_FONT_SIZE * self.scale } @@ -434,6 +443,14 @@ const JUMP_STYLE_HOLD: std::time::Duration = std::time::Duration::from_millis(25 /// bottom — buffer name + modified star on the left, diagnostics / /// cursor / scroll readout on the right. const BASE_STATUS_BAND_HEIGHT: f32 = 26.0; +/// Panel divider (Stage 2 framing §5.3, decided open item): the rule +/// between the document and an installed panel band, at scale 1.0. +/// +/// A 1-2 px rule is adequate decoration but too fragile as a drag target; +/// 4 px still reads as a rule while giving the pointer something to grab. +const BASE_DIVIDER_HEIGHT: f32 = 4.0; +/// Fallback fill for the divider strip when no `ui.divider` face is set. +const DIVIDER_RGBA: [f32; 4] = [0.28, 0.28, 0.36, 1.0]; const STATUS_BAND_BG: [f32; 4] = [0.105, 0.105, 0.145, 1.0]; const STATUS_TEXT_PAD: f32 = 10.0; const BASE_STATUS_FONT_SIZE: f32 = 13.0; @@ -1786,6 +1803,17 @@ struct State { /// explicit: `BufferSnapshot` always leaves terminal mode, a valid /// matching `TerminalFrame` always enters it. terminal: Option, + /// Bottom-panel arc Stage 2B-3: this frontend's panel band — the + /// retained frame, its geometry declaration, the divider drag, and the + /// exhaustion latch. Present on every `State`, inert until a session + /// negotiates the panel wire. + panel: PanelBand, + /// One shaped buffer per planned panel run. + panel_text_buffers: Vec, + /// Whether the negotiated session carries the panel wire at all. + /// + /// Keyed on the NEGOTIATED version, never the `Hello` baseline. + panel_wire: bool, /// The terminal geometry last declared to the daemon, with the /// buffer it described. Suppresses an unchanged re-declaration and /// forces a fresh one after a buffer switch. @@ -1819,6 +1847,113 @@ struct TerminalLocal { plan: TerminalPaintPlan, } +/// Why frame geometry is being (re-)declared (Q#BP2S1). +/// +/// The two arms differ in exactly one way — whether an *identical* +/// [`CellSize`] still advances the epoch — and that difference is the whole +/// reason the epoch is frontend-owned. Collapsing them into one call site +/// reintroduces the bug option 1 was chosen to avoid. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GeometryTrigger { + /// A surface resize, or the first declaration after attach. An + /// identical cell total means nothing the daemon can act on changed, + /// so it is not re-declared. + Surface, + /// A font family, size, or scale change. The cell total may be + /// **identical** while the pixels behind it are not, which is exactly + /// what daemon-side value dedup cannot see — so this always advances. + Metrics, +} + +/// A live divider drag (Q#BP15a, parent acceptance 47). +#[derive(Clone, Copy, Debug)] +struct PanelDrag { + /// Presentation the gesture started against. A drag that outlives its + /// panel is dropped rather than applied to the successor. + panel_epoch: u64, + /// Geometry declaration the gesture is measured against. + geometry_epoch: u64, + /// Rows the panel had when the drag started. + start_rows: u32, + /// Pointer y where the drag started, in surface pixels. + start_y: f32, + /// Last row count actually sent, so a drag that re-crosses the same + /// row boundary does not re-send it. + sent_rows: u32, +} + +/// The GPU frontend's half of the bottom panel (Q#BP15, Q#BP15a, Q#BP16). +struct PanelBand { + /// The last **valid** frame received, retained until an authoritative + /// `Absent`. + /// + /// Silence is not absence: the daemon must send `Absent` explicitly on + /// close *and* on hide, and until it does, this is what paints. An + /// invalid frame is rejected whole and leaves this untouched. + frame: Option, + /// This frontend's monotonic geometry declaration id. `0` means never + /// declared, which the wire rejects. + geometry_epoch: u64, + /// The cell total behind `geometry_epoch`, for the `Surface` dedup. + declared: Option, + /// Terminal exhaustion latch (framing §3.1). + /// + /// Once set: no further declaration is sent, and no retained frame + /// paints or hit-tests **however well its epoch still matches**. The + /// latch is what stops an old `Present` from resurrecting a band under + /// geometry this frontend has disowned; only a fresh session clears + /// it, because only a fresh session builds a fresh `PanelBand`. + exhausted: bool, + /// Live divider drag. One pointer, one gesture. + drag: Option, + /// Whether the pointer is currently over the divider strip, which + /// decides the `RowResize` cursor icon. + hover_divider: bool, + /// Cell-space paint data derived from `frame`, rebuilt on receipt so + /// the render path never re-derives it per frame. + plan: Option, +} + +impl Default for PanelBand { + fn default() -> Self { + Self { + frame: None, + geometry_epoch: 0, + declared: None, + exhausted: false, + drag: None, + hover_divider: false, + plan: None, + } + } +} + +impl PanelBand { + /// **The** single derivation of "is there a band on screen right now". + /// + /// Every consumer goes through this: the band inset the three + /// boundaries are computed from, the painter, the hit-tester, and the + /// drag. 2B-2's review found that two derivations of one panel + /// predicate is precisely how the renderer and the durable state come + /// to disagree, so there is exactly one here too. + /// + /// Three conditions, closing three different holes: + /// + /// * a retained valid frame exists (silence retains, `Absent` clears); + /// * its `geometry_epoch` matches the current declaration — after a + /// new declaration is sent, an older retained frame neither paints + /// nor accepts input until a matching `Present` arrives (parent 41); + /// * the exhaustion latch is clear. + fn presented(&self) -> Option<&PanelFrame> { + if self.exhausted { + return None; + } + self.frame + .as_ref() + .filter(|frame| frame.geometry_epoch == self.geometry_epoch) + } +} + /// Kind-glyph column for a completion row: the LSP /// `CompletionItemKind` numeric code → the single-char glyph the TUI /// popup uses (`crate::completion::CompletionItemKind::glyph`'s @@ -2506,8 +2641,12 @@ impl ApplicationHandler for App { } // Q#M7 — arm/disarm edge auto-scroll from the drag's // vertical position; `about_to_wait` runs the ticks. - state.edge_scroll_dir = - edge_scroll_direction(position.y as f32, state.config.height, state.fm); + state.edge_scroll_dir = edge_scroll_direction( + position.y as f32, + state.config.height, + state.fm, + state.band_inset(), + ); // Drag coalescing (predicted finding #4): pixel-rate // motion only ships when the hit byte changes. let Some(byte) = state.hit_test_source_byte(position.x, position.y) else { @@ -3447,6 +3586,9 @@ impl State { gutter_buffer, gutter_text_renderer, terminal: None, + panel: PanelBand::default(), + panel_text_buffers: Vec::new(), + panel_wire: false, last_terminal_size_sent: None, terminal_frame_error_latched: false, last_terminal_pointer_cell: None, @@ -4683,7 +4825,8 @@ impl State { fn terminal_cell_viewport(&self) -> Option { let (origin_x, origin_y) = Self::terminal_origin(); let width = self.config.width as f32 - origin_x; - let height = text_area_bottom(self.config.height, self.fm) - origin_y; + let height = + document_text_bottom(self.config.height, self.fm, self.band_inset()) - origin_y; crate::terminal::cell_viewport( width, height, @@ -4692,6 +4835,334 @@ impl State { ) } + // ----------------------------------------------------------------- + // Bottom panel band (Stage 2B-3) + // ----------------------------------------------------------------- + + /// Whether this session negotiated the panel wire. + /// + /// Set once from the negotiated session version, never from the + /// `Hello` baseline: the baseline stays at the compatibility floor + /// forever, so reading it here would leave the band permanently dark. + fn set_panel_wire(&mut self, session_protocol_version: u32) { + self.panel_wire = session_protocol_version >= PANEL_MIN_VERSION; + } + + /// The band inset the document boundary is computed from. + /// + /// Routed through [`PanelBand::presented`] rather than re-deriving + /// "is a panel visible" here, because a second derivation of that + /// predicate is how the renderer and the retained state drift apart. + fn band_inset(&self) -> PanelBandInset { + self.panel + .presented() + .map_or(PanelBandInset::ABSENT, |frame| { + PanelBandInset::installed(frame.size.rows, self.fm) + }) + } + + /// The panel band's content rectangle in surface pixels: + /// `(x, y, width, height)`, cells only — the divider sits above `y`. + fn panel_content_rect(&self) -> Option<(f32, f32, f32, f32)> { + let frame = self.panel.presented()?; + let band = PanelBandInset::installed(frame.size.rows, self.fm); + let cells_px = band.px() - self.fm.divider_height(); + if cells_px <= 0.0 { + return None; + } + let top = + document_text_bottom(self.config.height, self.fm, band) + self.fm.divider_height(); + Some(( + TEXT_LEFT, + top, + (self.config.width as f32 - TEXT_LEFT).max(0.0), + cells_px, + )) + } + + /// The divider strip: paint geometry AND hit geometry, one rect. + /// + /// Deliberately the same value for both. The framing decided a 4 px + /// strip precisely so the pointer has a usable target, and deriving + /// the hover band separately from the painted rule is how the two come + /// to disagree by a pixel that the user can see but not grab. + fn panel_divider_rect(&self) -> Option<(f32, f32, f32, f32)> { + let frame = self.panel.presented()?; + let band = PanelBandInset::installed(frame.size.rows, self.fm); + Some(( + 0.0, + document_text_bottom(self.config.height, self.fm, band), + self.config.width as f32, + self.fm.divider_height(), + )) + } + + /// The stable normal-face advance the geometry declaration uses + /// (framing §5.3, A2B-3). + /// + /// **Never [`Self::mono_advance`].** That falls back to the first + /// shaped glyph of the *document* buffer when no `FontFacts` probe has + /// been applied, which would make the panel's column count + /// document-dependent: two frontends with identical metrics showing + /// different files would derive different `total.cols`, and the same + /// frontend's panel width would change when its first glyph did. + /// + /// `None` when the family shapes no width — the caller declares zero + /// usable geometry rather than reaching for a document sample. + fn panel_probe_advance(&mut self) -> Option { + let metrics = Metrics::new(self.fm.code_font_size(), self.fm.code_line_height()); + let family = self.resolved_family.clone(); + probe_mono_advance(&mut self.font_system, &family, metrics) + } + + /// This surface's whole-cell capacity as the daemon's layout model + /// sees it (Q#BP15a's pixel→cell conversion). + /// + /// Zero-sized on any degenerate input, which is the fail-closed arm + /// parent 41 requires: the daemon treats zero columns as + /// non-presentable and the panel hides, rather than a non-finite + /// metric producing an absurd row count and an oversized allocation. + fn declared_cell_total(&mut self) -> CellSize { + let Some(advance) = self.panel_probe_advance() else { + return CellSize::new(0, 0); + }; + let height = (geometry_capacity_bottom(self.config.height, self.fm) - TEXT_TOP).max(0.0); + let width = (self.config.width as f32 - TEXT_LEFT).max(0.0); + crate::terminal::panel_cell_capacity(width, height, advance, self.fm.code_line_height()) + .unwrap_or_else(|| CellSize::new(0, 0)) + } + + /// Advance the geometry declaration if this trigger calls for one, and + /// return what the caller must send. + /// + /// The decision lives here and the *send* lives at the seam that owns + /// the attach client, so the whole state machine — dedup, exhaustion, + /// the latch — is reachable without a daemon. + fn next_geometry_declaration(&mut self, trigger: GeometryTrigger) -> Option<(u64, CellSize)> { + if !self.panel_wire || self.panel.exhausted { + return None; + } + let total = self.declared_cell_total(); + if trigger == GeometryTrigger::Surface + && self.panel.geometry_epoch != 0 + && self.panel.declared == Some(total) + { + return None; + } + let Some(next) = self.panel.geometry_epoch.checked_add(1) else { + // Fail closed, and LATCH. Retaining the last declaration is not + // fail-closed: if the surface then resizes, the daemon would + // keep painting a panel sized to a frame that no longer exists. + // Dropping the retained frame alone is not enough either — an + // old `Present` whose epoch still matched would resurrect a + // band under geometry this frontend has disowned. + self.panel.exhausted = true; + self.panel.frame = None; + self.panel.plan = None; + self.panel.drag = None; + self.panel.hover_divider = false; + return None; + }; + self.panel.geometry_epoch = next; + self.panel.declared = Some(total); + Some((next, total)) + } + + /// Apply an inbound `PanelFrame` payload. + /// + /// Returns `true` when the band's appearance changed, so the caller + /// can request a redraw without guessing. + /// + /// Validation is atomic: a rejected frame leaves the retained one + /// exactly as it was, because `PanelFrame::validate` is pure and runs + /// before any state is touched. + fn apply_panel_payload(&mut self, payload: PanelFramePayload) -> bool { + match payload { + PanelFramePayload::Absent => { + // Authoritative removal, and always safe. Note this does + // NOT clear the geometry declaration: the frontend's frame + // capacity is unchanged by a panel closing, and discarding + // it would force a needless re-declaration before the next + // open. + let had = self.panel.presented().is_some(); + self.panel.frame = None; + self.panel.plan = None; + self.panel.drag = None; + self.panel.hover_divider = false; + had + } + PanelFramePayload::Present(frame) => { + if let Err(error) = frame.validate() { + eprintln!("pmacs-gpu: rejecting invalid panel frame: {error}"); + return false; + } + if self.panel.frame.as_ref() == Some(&frame) { + // A duplicate does no work — not even a reshape. + return false; + } + let plan = TerminalPaintPlan::build_grid( + frame.size, + &frame.cells, + frame.cursor, + Self::terminal_palette(), + ); + self.panel.frame = Some(frame); + self.panel.plan = Some(plan); + self.rebuild_panel_text_buffers(); + true + } + } + } + + /// Reshape one cosmic-text buffer per planned panel run. + /// + /// One buffer per RUN for the same reason terminal mode does it: a + /// row-wide buffer would let a wide or cluster glyph's shaped advance + /// decide where the next column starts, and panel columns belong to + /// the daemon's grid, not to the shaper. + fn rebuild_panel_text_buffers(&mut self) { + let Some(plan) = self.panel.plan.as_ref() else { + self.panel_text_buffers.clear(); + return; + }; + let metrics = Metrics::new(self.fm.code_font_size(), self.fm.code_line_height()); + let advance = self.mono_advance(); + let family = self.resolved_family.clone(); + let runs: Vec<(String, f32, bool, bool)> = plan + .runs + .iter() + .map(|run| { + ( + run.text.clone(), + run.cells as f32 * advance, + run.bold, + run.italic, + ) + }) + .collect(); + let mut buffers = Vec::with_capacity(runs.len()); + for (text, width, bold, italic) in runs { + let mut buffer = Buffer::new(&mut self.font_system, metrics); + buffer.set_wrap(&mut self.font_system, Wrap::None); + buffer.set_size( + &mut self.font_system, + Some(width.max(1.0)), + Some(metrics.line_height), + ); + let attrs = Attrs::new() + .family(Family::Name(&family)) + .weight(if bold { + glyphon::cosmic_text::Weight::BOLD + } else { + glyphon::cosmic_text::Weight::NORMAL + }) + .style(if italic { + glyphon::cosmic_text::Style::Italic + } else { + glyphon::cosmic_text::Style::Normal + }); + buffer.set_text( + &mut self.font_system, + &text, + &attrs, + Shaping::Advanced, + None, + ); + buffer.shape_until_scroll(&mut self.font_system, false); + buffers.push(buffer); + } + self.panel_text_buffers = buffers; + } + + /// Pixel rectangle of a cell run inside the panel band. + fn panel_run_rect(&self, run: crate::terminal::CellRun) -> Option<(f32, f32, f32, f32)> { + let (ox, oy, _, _) = self.panel_content_rect()?; + let advance = self.mono_advance(); + let line = self.fm.code_line_height(); + Some(( + ox + run.start_col as f32 * advance, + oy + run.row as f32 * line, + (run.end_col - run.start_col) as f32 * advance, + line, + )) + } + + /// The band's quad batch: the divider strip, cell backgrounds, and the + /// panel caret, drawn under the band's glyphs. + fn panel_quad_vertex_bytes(&self) -> Vec { + let mut rects = Vec::new(); + if let Some((x, y, w, h)) = self.panel_divider_rect() { + rects.push(MinimapRect { + x, + y, + w, + h, + color: self.face_wash_or("ui.divider", DIVIDER_RGBA), + }); + } + if let Some(plan) = self.panel.plan.as_ref() + && self.panel.presented().is_some() + { + let window_bg = Self::terminal_palette().default_bg; + for bg in &plan.backgrounds { + if bg.color == window_bg { + continue; + } + if let Some((x, y, w, h)) = self.panel_run_rect(bg.run) { + rects.push(MinimapRect { + x, + y, + w, + h, + color: rgb_to_quad(bg.color, 1.0), + }); + } + } + if let Some(cursor) = plan.cursor + && let Some((x, y, w, h)) = self.panel_run_rect(cursor) + { + rects.push(MinimapRect { + x, + y, + w, + h, + color: TERMINAL_CURSOR_RGBA, + }); + } + } + if rects.is_empty() { + return Vec::new(); + } + rects_to_vertex_bytes(&rects, self.config.width, self.config.height) + } + + /// Which panel cell a surface pixel is over, if any (Q#BP16). + /// + /// Returns `None` outside the band's content rect, which is what keeps + /// a document gesture from being reported as a panel one. + fn panel_hit_test(&self, x: f32, y: f32) -> Option { + let frame = self.panel.presented()?; + let (ox, oy, w, h) = self.panel_content_rect()?; + if x < ox || x >= ox + w || y < oy || y >= oy + h { + return None; + } + crate::terminal::hit_test_cell( + x, + y, + (ox, oy), + self.mono_advance(), + self.fm.code_line_height(), + frame.size, + ) + } + + /// Whether a surface pixel is on the divider strip — the exact rect + /// that gets painted. + fn panel_divider_contains(&self, x: f32, y: f32) -> bool { + self.panel_divider_rect() + .is_some_and(|(rx, ry, rw, rh)| x >= rx && x < rx + rw && y >= ry && y < ry + rh) + } + /// Pixel rectangle of a cell run in the terminal grid. fn terminal_run_rect(&self, run: crate::terminal::CellRun) -> (f32, f32, f32, f32) { let (ox, oy) = Self::terminal_origin(); @@ -4966,7 +5437,8 @@ impl State { let cursor_line = line_starts .partition_point(|&s| s <= cursor) .saturating_sub(1); - let visible = estimated_visible_lines(self.config.height, self.fm).max(1); + let visible = + estimated_visible_lines(self.config.height, self.fm, self.band_inset()).max(1); let old = self.scroll_top; if cursor_line < self.scroll_top { self.scroll_top = cursor_line; @@ -5254,6 +5726,7 @@ impl State { self.config.width, self.config.height, self.fm, + self.band_inset(), ) } @@ -5297,9 +5770,11 @@ impl State { self.config.height, self.current_line_starts.len(), self.fm, + self.band_inset(), )?; - let centered = - target.saturating_sub(estimated_visible_lines(self.config.height, self.fm) / 2); + let centered = target.saturating_sub( + estimated_visible_lines(self.config.height, self.fm, self.band_inset()) / 2, + ); let delta = i64::try_from(centered).unwrap_or(i64::MAX) - i64::try_from(self.scroll_top).unwrap_or(i64::MAX); self.scroll_by_lines(delta) @@ -5883,7 +6358,7 @@ impl State { } readout.push_str(&format_scroll_indicator( self.scroll_top, - estimated_visible_lines(self.config.height, self.fm), + estimated_visible_lines(self.config.height, self.fm, self.band_inset()), self.current_line_starts.len(), cursor_row, )); @@ -6025,7 +6500,7 @@ impl State { .map_or(STATUS_BAND_BG, |(quad, _)| quad); let rect = MinimapRect { x: 0.0, - y: text_area_bottom(self.config.height, self.fm), + y: status_band_top(self.config.height, self.fm), w: self.config.width as f32, h: self.fm.status_band_height(), color, @@ -6120,7 +6595,7 @@ impl State { /// candidate-free, or too short for a row. See [`mb_dropdown_window`]. fn mb_visible_window(&self) -> Option<(usize, usize)> { let mb = self.minibuffer.as_ref()?; - let band_top = text_area_bottom(self.config.height, self.fm); + let band_top = status_band_top(self.config.height, self.fm); mb_dropdown_window( mb.candidates.len(), mb.selected.map_or(0, |s| s as usize), @@ -6144,7 +6619,7 @@ impl State { .map(|r| r.line_w) .fold(0.0_f32, f32::max); let width = (widest + 2.0 * MB_DROP_PAD_X).clamp(MB_DROP_MIN_WIDTH, MB_DROP_MAX_WIDTH); - let band_top = text_area_bottom(self.config.height, self.fm); + let band_top = status_band_top(self.config.height, self.fm); let top_y = band_top - count as f32 * self.fm.mb_drop_row_height(); Some((STATUS_TEXT_PAD, top_y, width)) } @@ -6235,7 +6710,7 @@ impl State { // caret-follow residual) counts as scrolled out. let (x, top, line_height) = self.code_byte_px(anchor)?; let y = TEXT_TOP + top; - let bottom = text_area_bottom(self.config.height, self.fm); + let bottom = document_text_bottom(self.config.height, self.fm, self.band_inset()); if y >= bottom || y + line_height <= TEXT_TOP { return None; } @@ -6257,7 +6732,7 @@ impl State { } let sel = comp.selected.map_or(0, |s| s as usize); let (ax, line_top, line_h) = self.completion_anchor_px()?; - let band_top = text_area_bottom(self.config.height, self.fm); + let band_top = document_text_bottom(self.config.height, self.fm, self.band_inset()); let below_px = band_top - (line_top + line_h); let above_px = line_top - TEXT_TOP; let max_below = (below_px / self.fm.mb_drop_row_height()).floor() as usize; @@ -6576,7 +7051,8 @@ impl State { let line_starts = &self.current_line_starts; let n = line_starts.len(); let top = self.scroll_top.min(n.saturating_sub(1)); - let span = estimated_visible_lines(self.config.height, self.fm).max(1) + SCROLL_OVERSCAN; + let span = estimated_visible_lines(self.config.height, self.fm, self.band_inset()).max(1) + + SCROLL_OVERSCAN; let vstart = line_starts[top]; let bottom = top.saturating_add(span).min(n); let vend = if bottom < n { @@ -6698,7 +7174,8 @@ impl State { let height = self.config.height as f32; let code_metrics = Metrics::new(fm.code_font_size(), fm.code_line_height()); let code_width = (self.text_bounds_right() as f32 - self.text_left()).max(0.0); - let code_height = (text_area_bottom(self.config.height, fm) - TEXT_TOP).max(0.0); + let code_height = + (document_text_bottom(self.config.height, fm, self.band_inset()) - TEXT_TOP).max(0.0); let code_layout_changed = self.buffer.metrics() != code_metrics || self.buffer.size() != (Some(code_width), Some(code_height)); self.buffer.set_metrics_and_size( @@ -7251,7 +7728,7 @@ impl State { .map(|run| run.line_w) .fold(0.0_f32, f32::max); let status_left = self.config.width as f32 - STATUS_TEXT_PAD - status_width; - let status_top = text_area_bottom(self.config.height, self.fm) + let status_top = status_band_top(self.config.height, self.fm) + (self.fm.status_band_height() - self.fm.status_line_height()) / 2.0; // UX gutter: the code's left origin (past the gutter) and the // main-text clip-left. Computed here as locals — calling `self.*` @@ -7291,7 +7768,8 @@ impl State { // Clip at the status band (Q#S3): a final // partially-visible line must not bleed // into the band. - bottom: text_area_bottom(self.config.height, self.fm).round() as i32, + bottom: document_text_bottom(self.config.height, self.fm, self.band_inset()) + .round() as i32, }, default_color: Color::rgb(230, 230, 235), custom_glyphs: &[], @@ -7312,7 +7790,7 @@ impl State { scale: 1.0, bounds: TextBounds { left: 0, - top: text_area_bottom(self.config.height, self.fm).round() as i32, + top: status_band_top(self.config.height, self.fm).round() as i32, right: self.config.width.cast_signed(), bottom: self.config.height.cast_signed(), }, @@ -7329,7 +7807,7 @@ impl State { scale: 1.0, bounds: TextBounds { left: 0, - top: text_area_bottom(self.config.height, self.fm).round() as i32, + top: status_band_top(self.config.height, self.fm).round() as i32, // Stop at the right group's actual origin. right: status_left.max(0.0).round() as i32, bottom: self.config.height.cast_signed(), @@ -7359,7 +7837,8 @@ impl State { left: gutter_clip_left, top: 0, right: text_bounds_right, - bottom: text_area_bottom(self.config.height, self.fm).round() as i32, + bottom: document_text_bottom(self.config.height, self.fm, self.band_inset()) + .round() as i32, }, default_color: MATH_INK_COLOR, custom_glyphs: &[], @@ -7390,7 +7869,8 @@ impl State { left: 0, top: 0, right: gutter_clip_left, - bottom: text_area_bottom(self.config.height, self.fm).round() as i32, + bottom: document_text_bottom(self.config.height, self.fm, self.band_inset()) + .round() as i32, }, // Themes Q#TH5: ui.gutter's {fg} mask colors the digits. default_color: gutter_color, @@ -7468,7 +7948,7 @@ impl State { left: x as i32, top: top_y as i32, right: (x + width).round() as i32, - bottom: text_area_bottom(self.config.height, self.fm).round() as i32, + bottom: status_band_top(self.config.height, self.fm).round() as i32, }, // Themes Q#TH5 (round 3 finding 1): the candidate // glyph layer is ui.minibuffer.candidate's GPU site; @@ -7531,6 +8011,10 @@ impl State { // its own cell origin and clipped to its declared footprint. // Per-run areas are the point: a row-wide area would let one // wide glyph's shaped advance shift every column after it. + // Hoisted out of the closure below: the band inset borrows `self` + // immutably, and the closure already holds one. + let document_clip_bottom = + document_text_bottom(self.config.height, self.fm, self.band_inset()).round() as i32; let terminal_areas: Vec