diff --git a/src/gtk/mod.rs b/src/gtk/mod.rs index 0f688a6f..5e970885 100644 --- a/src/gtk/mod.rs +++ b/src/gtk/mod.rs @@ -8280,17 +8280,21 @@ impl quadraui::ShellApp for App { let tab_row_h = (lh * 1.6).ceil(); let tab_bar_h = render::tab_bar_height_px(lh, engine.settings.breadcrumbs); let per_window_status = engine.settings.window_status_line; - let wildmenu_px = if engine.wildmenu_items.is_empty() { - 0.0 - } else { - lh - }; - let status_rows = if per_window_status { 1.0 } else { 2.0 }; - let status_bar_h = lh * status_rows + wildmenu_px; let el = render::compute_editor_layout(&engine, h, lh, false); - let editor_area_h = - (h - el.terminal_h - el.debug_toolbar_h - el.separated_status_h - status_bar_h) - .max(0.0); + // `el.status_bar_h` is `compute_editor_layout`'s single source of + // truth for this (identical formula to the `wildmenu_px`/ + // `status_rows` locals this replaced); reusing it here — instead of + // recomputing a second copy — is what makes `editor_area_h` below + // `el.editor_bottom` correctly reserve quickfix's band too. + let status_bar_h = el.status_bar_h; + // `el.editor_bottom` already subtracts quickfix_h/terminal_h/ + // debug_toolbar_h/separated_status_h/status_bar_h from `h` (menu_h + // is 0 for GTK — the menu bar lives outside `main_content_bounds`, + // see `compute_editor_layout`'s `menu_in_viewport` doc). Before + // #670 this was hand-rolled here without the `quickfix_h` term, so + // an open quickfix panel never reserved space and editor content + // painted straight through where the panel now paints. + let editor_area_h = el.editor_bottom.max(0.0); let editor_bounds = WindowRect::new(x, y, w, editor_area_h); // Hand the exact bounds/tab-bar-height this frame painted with to the @@ -8670,9 +8674,244 @@ impl quadraui::ShellApp for App { } } + // ── Draw quickfix panel + bottom panel (terminal/debug output) + + // debug toolbar (#670) ──────────────────────────────────────────── + // Ported from the dead `src/gtk/draw.rs` path (no live callers since + // the #540 Relm4->ShellApp migration) onto `render_content` — same + // class of gap as the editor-popup block above (#669): the + // `screen.quickfix` / `screen.bottom_tabs` / `screen.debug_toolbar` + // fields were (and still are) populated by the engine the whole + // time, only the paint calls were missing. The adapters + // (`quickfix_to_list_view`, `build_bottom_panel_tab_bar`, + // `build_terminal_toolbar`, `build_terminal_draw_data`, + // `debug_output_to_text_display`, `draw_debug_toolbar`) are the same + // `render::` functions TUI's own `TuiShellApp::render_content` + // (`shell_app.rs`) already routes through — only the geometry below + // is GTK-pixel-native, mirroring `compute_editor_layout`'s + // `unit_h = line_height` convention. + // + // Stacking (top to bottom, matching TUI's `bottom_chrome_rects_for_ + // shell_content` v_chunks order exactly): editor | quickfix | + // terminal/debug-output | debug toolbar | separated-status (not + // painted here, #592-C) | status bar. `editor_area_h` above + // (`el.editor_bottom`) already reserves all of this, so these bands + // sit directly below it with no gap and no overlap with `status_y`. + let quickfix_y = y + editor_area_h; + if let Some(ref qf) = screen.quickfix { + // Scroll-to-selection: reserve one row for the header, then keep + // the selected item within the remaining visible rows — matches + // the dead `draw.rs::draw_quickfix_panel`'s behaviour. GTK has no + // persistent `quickfix_scroll_top` field to update from key + // events (unlike TUI's `TuiShellApp`), so this recomputes a + // stateless "keep selection visible" scroll each frame instead. + let visible_rows = ((el.quickfix_h / lh) as usize).saturating_sub(1); + let scroll_top = if visible_rows == 0 { + 0 + } else { + (qf.selected_idx + 1).saturating_sub(visible_rows) + }; + let mut list = render::quickfix_to_list_view(qf); + list.scroll_offset = scroll_top; + backend.draw_list( + quadraui::Rect::new(x as f32, quickfix_y as f32, w as f32, el.quickfix_h as f32), + &list, + ); + } + + let terminal_y = quickfix_y + el.quickfix_h; + if el.terminal_h > 0.0 { + engine + .bottom_panel_geometry + .replace(Some(crate::core::engine::BottomPanelGeometry { + top_y: terminal_y, + height: el.terminal_h, + toolbar_y: lh, + content_y: 2.0 * lh, + content_row_h: lh, + })); + let tab_bar = render::build_bottom_panel_tab_bar( + &screen.bottom_tabs.active, + engine.terminal_open, + !screen.bottom_tabs.output_lines.is_empty(), + ); + let hits = backend.draw_tab_bar( + quadraui::Rect::new(x as f32, terminal_y as f32, w as f32, lh as f32), + &tab_bar, + None, + ); + engine.bottom_tab_bar_hits.replace(Some(hits)); + let content_y = terminal_y + 2.0 * lh; + let content_h = (el.terminal_h - 2.0 * lh).max(0.0); + match screen.bottom_tabs.active { + render::BottomPanelKind::Terminal => { + if let Some(ref term_panel) = screen.bottom_tabs.terminal { + let toolbar_y = terminal_y + lh; + let toolbar_rect = + quadraui::Rect::new(x as f32, toolbar_y as f32, w as f32, lh as f32); + let hits = match render::build_terminal_toolbar(term_panel, &theme) { + render::TerminalToolbar::FindBar(bar) => { + let _ = backend.draw_status_bar(toolbar_rect, &bar, None, None); + // No raw `pango::Layout` is reachable from this + // `&mut dyn Backend`-only signature (same gap + // #669's `editor_hover_popup_paint` doc comment + // hit), so segment widths are approximated by + // char count * `cw` rather than exact glyph + // measurement — affects hit-region precision + // only, not paint. + let sb_layout = bar.layout(w as f32, lh as f32, 16.0, |seg| { + quadraui::StatusSegmentMeasure::new( + seg.text.chars().count() as f32 * cw as f32, + ) + }); + crate::core::engine::TerminalToolbarHits::FindBar { + layout: sb_layout, + origin_x: x, + } + } + render::TerminalToolbar::TabStrip(bar) => { + let hits = backend.draw_tab_bar(toolbar_rect, &bar, None); + crate::core::engine::TerminalToolbarHits::TabStrip(hits) + } + }; + engine.terminal_toolbar_hits.replace(Some(hits)); + + if content_h > 0.0 { + let visible_rows = (content_h / lh) as usize; + let q_area = quadraui::Rect::new( + x as f32, + content_y as f32, + w as f32, + content_h as f32, + ); + let td = render::build_terminal_draw_data( + term_panel, + q_area, + cw as f32, + lh as f32, + visible_rows, + Some(6), + ); + engine.terminal_split_layout.replace(td.split); + if let Some(split) = &td.split { + let left = td.left.as_ref().unwrap(); + let right = td.right.as_ref().unwrap(); + backend.draw_terminal(split.left, left); + backend.draw_terminal(split.right, right); + backend.draw_terminal_divider(quadraui::Rect::new( + split.divider_x, + content_y as f32, + 1.0, + content_h as f32, + )); + } else if let Some(ref term) = td.single { + backend.draw_terminal(q_area, term); + } + let geom = + render::terminal_scrollbar_geometry(term_panel, visible_rows); + let surface_sb = geom.map(|g| { + let sb_w = 6.0; + let sb_x = w - sb_w; + let thumb_t = g.thumb_top_frac * content_h; + let thumb_h = (g.thumb_height_frac * content_h).max(4.0); + quadraui::SurfaceScrollbar { + axis: quadraui::ScrollAxis::Vertical, + track_bounds: quadraui::Rect::new( + (x + sb_x) as f32, + content_y as f32, + sb_w as f32, + content_h as f32, + ), + thumb_bounds: quadraui::Rect::new( + (x + sb_x + 1.0) as f32, + (content_y + thumb_t) as f32, + (sb_w - 2.0) as f32, + thumb_h as f32, + ), + total_items: g.total_items, + visible_items: g.visible_items, + scroll_offset: term_panel.scroll_offset, + inverted: true, + } + }); + engine + .scroll_surfaces + .borrow_mut() + .push(quadraui::ScrollSurface { + id: quadraui::WidgetId::new("terminal_scrollback"), + bounds: q_area, + scrollbar: surface_sb, + }); + } + } + } + render::BottomPanelKind::DebugOutput => { + if content_h > 0.0 { + let td = render::debug_output_to_text_display( + &screen.bottom_tabs.output_lines, + engine.debug_output_scroll, + engine.debug_output_auto_scroll, + ); + let q_rect = quadraui::Rect::new( + x as f32, + content_y as f32, + w as f32, + content_h as f32, + ); + let td_layout = backend.text_display_layout(q_rect, &td); + backend.draw_text_display(q_rect, &td); + let scrollbar = td_layout.scrollbar_bounds.zip(td_layout.thumb_bounds).map( + |(track, thumb)| quadraui::SurfaceScrollbar { + axis: quadraui::ScrollAxis::Vertical, + track_bounds: quadraui::Rect::new( + q_rect.x + track.x, + q_rect.y + track.y, + track.width, + track.height, + ), + thumb_bounds: quadraui::Rect::new( + q_rect.x + thumb.x, + q_rect.y + thumb.y, + thumb.width, + thumb.height, + ), + total_items: td.lines.len(), + visible_items: td_layout.visible_lines.len(), + scroll_offset: td_layout.resolved_scroll_offset, + inverted: false, + }, + ); + engine + .scroll_surfaces + .borrow_mut() + .push(quadraui::ScrollSurface { + id: quadraui::WidgetId::new("debug_output"), + bounds: q_rect, + scrollbar, + }); + } + } + } + } else { + engine.bottom_panel_geometry.replace(None); + } + + let debug_toolbar_y = terminal_y + el.terminal_h; + if screen.debug_toolbar.is_some() { + let rect = quadraui::Rect::new(x as f32, debug_toolbar_y as f32, w as f32, lh as f32); + render::draw_debug_toolbar(backend, &engine, rect); + self.debug_toolbar_y_offset.set(debug_toolbar_y); + self.debug_toolbar_height.set(lh); + } else { + self.debug_toolbar_y_offset.set(0.0); + self.debug_toolbar_height.set(0.0); + } + // ── Draw global status bar / wildmenu ───────────────────────────────── - let status_y = - y + h - el.terminal_h - el.debug_toolbar_h - el.separated_status_h - status_bar_h; + // Simplified to `h - status_bar_h`: quickfix/terminal/debug-toolbar/ + // separated-status all stack *above* this point now (see the + // quickfix/bottom-panel/debug-toolbar block above), so the status + // bar's own band no longer needs to re-subtract them. + let status_y = y + h - status_bar_h; if let Some(ref bar) = screen.global_status_bar { let sb_rect = quadraui::Rect::new(x as f32, status_y as f32, w as f32, lh as f32); let mut frame = QSL::new(); @@ -8886,8 +9125,46 @@ impl quadraui::ShellApp for App { engine.ext_sidebar_system.borrow().render(backend, q_sb); } _ => { - // PANEL_AI and unknowns: not yet migrated to Backend primitives. - } + // PANEL_AI and unknowns: not yet migrated to Backend + // primitives (#670 scoped only the other four panel + // surfaces — quickfix/bottom_tabs/debug_toolbar/ + // panel_hover — and left AI for a follow-up issue; see + // that issue's own "no PANEL_AI arm at all" note). + } + } + + // ── Sidebar-item hover popup (#670) ───────────────────────────── + // Source-control / extension-panel item dwell tooltip, rendered + // markdown. Ported from the dead `draw.rs::draw_panel_hover_popup` + // (raw Cairo/Pango, no live callers) onto the shared + // `quadraui::RichTextPopup` path via `render::panel_hover_popup_ + // paint` — the same primitive TUI's `render_panel_hover_popup` + // already uses. Paints after the panel body above (same + // paint-after-content z-order the dead code used), clamped + // against the full content viewport so it can extend rightward + // into the editor area past the sidebar's own bounds. + self.panel_hover_popup_rect.set(None); + self.panel_hover_link_rects.borrow_mut().clear(); + if screen.panel_hover.is_some() { + let hover_viewport = quadraui::Rect::new(x as f32, y as f32, w as f32, h as f32); + let (links, rect) = render::panel_hover_popup_paint( + backend, + screen, + &theme, + q_sb.x + q_sb.width, + q_sb.y, + hover_viewport, + cw as f32, + lh as f32, + ); + self.panel_hover_popup_rect + .set(rect.map(|(rx, ry, rw, rh)| (rx as f64, ry as f64, rw as f64, rh as f64))); + *self.panel_hover_link_rects.borrow_mut() = links + .into_iter() + .map(|(lx, ly, lw, lh2, url, is_native)| { + (lx as f64, ly as f64, lw as f64, lh2 as f64, url, is_native) + }) + .collect(); } } diff --git a/src/gtk/testing.rs b/src/gtk/testing.rs index 574ba4a6..63c5b5d7 100644 --- a/src/gtk/testing.rs +++ b/src/gtk/testing.rs @@ -94,6 +94,10 @@ pub(super) struct Harness { /// (#669). #[allow(clippy::type_complexity)] pub editor_hover_popup_rect: Rc>>, + /// Sidebar-item hover popup bounds `(x, y, w, h)` the last frame painted, + /// or `None` if that frame drew no panel-hover popup (#670). + #[allow(clippy::type_complexity)] + pub panel_hover_popup_rect: Rc>>, /// The sidebar content rect the last frame painted the active panel into, /// or `None` if the sidebar was hidden. The sidebar twin of /// [`Self::screen_layout`]'s window rects — aim panel clicks at this rather @@ -240,6 +244,7 @@ pub(super) fn harness(engine: Engine, width: i32, height: i32) -> Harness Harness 0.0 && ph > 0.0); } } + +/// Black-box paint proof for the four panel-region surfaces #670 ported from +/// the dead `src/gtk/draw.rs` path onto `render_content`: quickfix, the +/// bottom panel (terminal/debug output), the debug toolbar, and the +/// sidebar-item hover popup. `ai_panel` — the fifth surface #670 originally +/// scoped — turned out to need a genuinely new `render.rs` adapter (no +/// existing `Backend::draw_message_list`-based chrome to port, unlike these +/// four which all had one) and was split into its own follow-up issue per +/// #670's own escape hatch, so it has no test here. +/// +/// Same two-independent-harnesses methodology as `editor_popups` above +/// (see its module doc for why re-rendering one `GtkDriver` isn't safe to +/// compare against itself): each test renders once with the feature off and +/// once with it on, over otherwise-identical engine state, and asserts +/// sampled pixels differ. Each was verified to fail red by temporarily +/// commenting out its paint call in `render_content` and confirming the +/// `assert_region_changed` panic before restoring it. +#[cfg(test)] +mod panel_surfaces { + use super::*; + + fn small_engine() -> Engine { + let mut engine = Engine::new(); + engine + .buffer_mut() + .insert(0, "fn main() {\n println!(\"hi\");\n}\n"); + engine + } + + /// Sample a band spanning the bottom of the whole window — where + /// quickfix/bottom-panel/debug-toolbar all paint, below the editor's + /// windows — across the full width. Wide enough to catch any of these + /// bands regardless of their exact height for a given fixture. + fn bottom_region_pixels( + width: i32, + height: i32, + configure: impl FnOnce(&mut Engine), + ) -> Vec<(u8, u8, u8)> { + let mut engine = small_engine(); + configure(&mut engine); + let mut h = harness(engine, width, height); + let mut px = Vec::new(); + let y0 = (height as f64 * 0.5) as i32; + let mut y = y0; + while y < height { + let mut x = 0; + while x < width { + px.push(h.driver.pixel(x, y)); + x += 3; + } + y += 2; + } + px + } + + fn assert_region_changed(without: &[(u8, u8, u8)], with: &[(u8, u8, u8)], what: &str) { + let differing = with + .iter() + .zip(without.iter()) + .filter(|(a, b)| a != b) + .count(); + assert!( + differing > 0, + "{what} must paint new pixels in the bottom chrome region; \ + {differing}/{} sampled pixels differed", + with.len() + ); + } + + fn make_qf_item(path: &str) -> crate::core::project_search::ProjectMatch { + crate::core::project_search::ProjectMatch { + file: std::path::PathBuf::from(path), + line: 1, + col: 1, + line_text: "fn main() {".to_string(), + } + } + + // Note: these three all compare *two open/visible* states rather than + // open-vs-closed. Opening any of these panels reserves its band by + // shrinking `editor_area_h` (the #670 layout fix), so an open-vs-closed + // comparison sampled over this region would pass even with the actual + // `Backend::draw_*` call deleted — the mere reservation swaps "editor + // text" for "panel background", which alone is enough to make sampled + // pixels differ. Comparing two same-reservation states that differ only + // in *content* isolates the paint call itself: it can only differ if + // that content is actually painted. Verified each still goes red by + // temporarily deleting its `Backend::draw_*` call and confirming the + // `assert_region_changed` panic, then restoring it. + + #[test] + fn quickfix_panel_paints() { + let region = |selected: usize| { + bottom_region_pixels(1400, 900, move |e| { + e.quickfix_items = vec![make_qf_item("a.rs"), make_qf_item("b.rs")]; + e.quickfix_open = true; + e.quickfix_has_focus = true; + e.quickfix_selected = selected; + }) + }; + assert_region_changed( + ®ion(0), + ®ion(1), + "moving the quickfix selection between two open, reserved-identically panels", + ); + } + + #[test] + fn bottom_terminal_panel_paints() { + // Same `terminal_open` (so `el.terminal_h` — and therefore + // `editor_area_h` — is identical either way); only the *active* tab + // and its content differ: the terminal grid (`Backend::draw_terminal`) + // vs. the debug-output text display (`Backend::draw_text_display`), + // plus which tab is highlighted in the strip `Backend::draw_tab_bar` + // paints above them. + let region = |kind: crate::render::BottomPanelKind| { + bottom_region_pixels(1400, 900, move |e| { + e.terminal_open = true; + e.session.terminal_panel_rows = 10; + e.dap_output_lines = vec!["stack trace line 1".to_string(), "line 2".to_string()]; + e.bottom_panel_kind = kind; + }) + }; + assert_region_changed( + ®ion(crate::render::BottomPanelKind::Terminal), + ®ion(crate::render::BottomPanelKind::DebugOutput), + "switching the bottom panel's active tab between terminal and debug output", + ); + } + + #[test] + fn debug_toolbar_paints() { + // Same `debug_toolbar_visible` (so the reserved band is identical); + // only the buttons' `enabled` state differs — several toggle from + // disabled to enabled once a session is active and stopped, which + // `render::debug_toolbar` reflects as different button styling. + let region = |session_active: bool| { + bottom_region_pixels(1400, 900, move |e| { + e.debug_toolbar_visible = true; + if session_active { + e.dap_session_active = true; + e.dap_stopped_thread = Some(1); + } + }) + }; + assert_region_changed( + ®ion(false), + ®ion(true), + "an active+stopped debug session changing the toolbar's button states", + ); + } + + /// Unlike the other three surfaces here (all in the fixed bottom-of- + /// window band), the panel-hover popup anchors next to whatever sidebar + /// item triggered it — near the *top* of the sidebar for `item_index: + /// 0`, not the bottom — so `bottom_region_pixels` can't see it. Instead + /// this samples the popup's own cached bounds (`panel_hover_popup_rect`, + /// the same cache a future click handler would read) against an + /// identical harness with no popup open, proving both that a popup + /// caches non-empty bounds and that painting them actually changed + /// pixels there — not just that the cache field was written. + #[test] + fn panel_hover_popup_paints_and_caches_bounds() { + let mut engine = small_engine(); + engine.show_panel_hover( + "source_control", + "item0", + 0, + "**M** `src/main.rs` — modified", + ); + let mut with_h = harness(engine, 1400, 900); + let (px, py, pw, ph) = with_h + .panel_hover_popup_rect + .get() + .expect("panel hover popup must cache its bounds for a future click handler"); + assert!(pw > 0.0 && ph > 0.0); + + let mut without_h = harness(small_engine(), 1400, 900); + assert!( + without_h.panel_hover_popup_rect.get().is_none(), + "no popup rect should be cached when none is open" + ); + + let (x0, x1) = (px as i32, (px + pw) as i32); + let (y0, y1) = (py as i32, (py + ph) as i32); + let mut differing = 0; + let mut total = 0; + let mut y = y0; + while y < y1 { + let mut x = x0; + while x < x1 { + total += 1; + if with_h.driver.pixel(x, y) != without_h.driver.pixel(x, y) { + differing += 1; + } + x += 3; + } + y += 2; + } + assert!( + differing > 0, + "panel hover popup must paint new pixels within its own cached bounds; \ + {differing}/{total} sampled pixels differed" + ); + } +} diff --git a/src/render.rs b/src/render.rs index 4773b304..18b37dce 100644 --- a/src/render.rs +++ b/src/render.rs @@ -2051,6 +2051,186 @@ pub fn panel_hover_to_quadraui_rich_text( } } +/// Vertical anchor (top of the hovered row, in the caller's line units) for +/// [`panel_hover_popup_paint`]. Lifted from the now-dead +/// `src/gtk/draw.rs::draw_panel_hover_popup`'s source-control section walk +/// (#670) so both backends can share it instead of GTK re-deriving its own +/// copy. The non-source-control branch is generalized to take an explicit +/// `sidebar_top_y` rather than assuming the sidebar starts at row/pixel 0 — +/// true in the pre-#552 single-DA GTK architecture that dead code was +/// written against, no longer true now that a title-bar row can sit above +/// the sidebar. +fn panel_hover_anchor_y( + screen: &ScreenLayout, + hover: &PanelHoverPopupData, + sidebar_top_y: f32, + unit_h: f32, +) -> f32 { + if hover.panel_name != "source_control" { + return sidebar_top_y + unit_h + hover.item_index as f32 * unit_h; + } + // SC layout: `section_top` is read from the cached `SidebarPanelLayout` + // (`sc_sections_start_y`, already an absolute coordinate — see its own + // field doc) so this doesn't re-derive it; falls back to a one-frame-lag + // estimate from the commit box's line count when that cache is still + // empty (e.g. the very first frame the SC panel is shown). + let item_height = (unit_h * 1.4).round(); + let section_top = screen + .source_control + .as_ref() + .and_then(|sc| sc.sc_sections_start_y) + .unwrap_or_else(|| { + let gap = (unit_h * 0.3).round(); + let commit_rows = screen + .source_control + .as_ref() + .map(|sc| sc.commit_message.split('\n').count().max(1)) + .unwrap_or(1) as f32; + unit_h + gap + commit_rows * unit_h + unit_h + }); + let Some(ref sc) = screen.source_control else { + return section_top + hover.item_index as f32 * unit_h; + }; + // Walk sections to find the accumulated Y offset for the hovered flat + // index. Headers occupy one row; expanded items occupy `item_height` + // each. Staged + Unstaged always show; Worktrees only when there's more + // than one; Log always shows — mirrors the SC sidebar's own section + // list. + let show_worktrees = sc.worktrees.len() > 1; + let mut sections: Vec<(usize, bool)> = vec![ + (sc.staged.len(), sc.sections_expanded[0]), + (sc.unstaged.len(), sc.sections_expanded[1]), + ]; + if show_worktrees { + sections.push((sc.worktrees.len(), sc.sections_expanded[2])); + } + sections.push((sc.log.len(), sc.sections_expanded[3])); + + let mut y_off = section_top; + let mut fi = 0usize; + 'outer: for &(count, expanded) in §ions { + if fi == hover.item_index { + break; + } + y_off += unit_h; + fi += 1; + if expanded { + for _ in 0..count { + if fi == hover.item_index { + break 'outer; + } + y_off += item_height; + fi += 1; + } + } + } + y_off +} + +/// Paint the sidebar-item hover popup (source-control / extension-panel item +/// dwell tooltip, rendered markdown) through the shared +/// `quadraui::RichTextPopup` primitive — the panel-hover twin of +/// [`editor_hover_popup_paint`] (#670). GTK previously hand-rolled this in +/// raw Cairo/Pango (`src/gtk/draw.rs::draw_panel_hover_popup`, no live +/// callers since the #540 Relm4->ShellApp migration); this instead reuses +/// the same `RichTextPopup` / `Backend::draw_rich_text_popup` path TUI's +/// `tui_main::panels::render_panel_hover_popup` already routes through, so +/// the two backends can't drift on the markdown rendering itself — only the +/// anchor geometry (source-control section walk vs. uniform per-row offset) +/// is backend-specific, and that's shared too via [`panel_hover_anchor_y`]. +/// +/// `unit_w` / `unit_h` are `1.0, 1.0` for TUI (cell-native) or `char_width, +/// line_height` in pixels for GTK. `popup_x` / `sidebar_top_y` / `viewport` +/// must already be expressed in that same space. As with +/// `editor_hover_popup_paint`, `RichTextPopup::layout`'s own +/// `PopupPlacement::Below` clamping against `viewport` means callers don't +/// need to pre-check whether the popup fits — it re-clamps precisely. +/// +/// Returns `(link_rects, popup_bounds)` in the caller's units. Link rects +/// carry a trailing `is_native` flag — `true` for the source-control panel's +/// trusted links (open directly), `false` for extension-provided ones +/// (confirm before opening) — mirroring `Msg::PanelHoverClick`'s two +/// branches. +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +pub fn panel_hover_popup_paint( + backend: &mut dyn quadraui::Backend, + screen: &ScreenLayout, + theme: &Theme, + popup_x: f32, + sidebar_top_y: f32, + viewport: quadraui::Rect, + unit_w: f32, + unit_h: f32, +) -> ( + Vec<(f32, f32, f32, f32, String, bool)>, + Option<(f32, f32, f32, f32)>, +) { + let Some(ref hover) = screen.panel_hover else { + return (vec![], None); + }; + if hover.rendered.lines.is_empty() { + return (vec![], None); + } + let is_native = hover.panel_name == "source_control"; + let popup = panel_hover_to_quadraui_rich_text(hover, theme); + let max_len = popup + .line_text + .iter() + .map(|t| t.chars().count()) + .max() + .unwrap_or(10) as f32; + let avail_w = (viewport.x + viewport.width - popup_x).max(10.0 * unit_w); + let content_w = ((max_len + 2.0) * unit_w) + .max(10.0 * unit_w) + .min((avail_w - 2.0 * unit_w).max(10.0 * unit_w)); + let anchor_y = panel_hover_anchor_y(screen, hover, sidebar_top_y, unit_h); + let measure = quadraui::RichTextPopupMeasure::new(content_w, unit_h); + // `Placement::Below` adds one row height to the anchor, so subtract it + // here to land the box's top border exactly on `anchor_y` — same trick + // `editor_hover_popup_paint`/TUI's `render_panel_hover_popup` use. + let layout = popup.layout( + popup_x, + anchor_y - unit_h, + viewport, + measure, + |line_idx, start_byte, end_byte| { + popup + .line_text + .get(line_idx) + .map(|t| { + t[start_byte.min(t.len())..end_byte.min(t.len())] + .chars() + .count() as f32 + }) + .unwrap_or(0.0) + * unit_w + }, + ); + + backend.draw_rich_text_popup(&popup, &layout); + + let link_rects: Vec<(f32, f32, f32, f32, String, bool)> = layout + .link_hit_regions + .iter() + .map(|(rect, idx)| { + let url = popup + .links + .get(*idx) + .map(|l| l.url.clone()) + .unwrap_or_default(); + (rect.x, rect.y, rect.width, rect.height, url, is_native) + }) + .collect(); + + let popup_rect = Some(( + layout.bounds.x, + layout.bounds.y, + layout.bounds.width, + layout.bounds.height, + )); + (link_rects, popup_rect) +} + // ─── AiPanelData ───────────────────────────────────────────────────────────── /// A single message in the AI conversation history, pre-formatted for rendering.