Skip to main content

quadraui/primitives/
rich_text_popup.rs

1//! `RichTextPopup` primitive: an interactive bordered popup with
2//! styled multi-line content, optional scroll, optional clickable
3//! links, and optional text selection. Used for LSP hover with
4//! markdown bodies, error popups with links to documentation,
5//! and similar "open document inside the editor" surfaces.
6//!
7//! # Why not Tooltip?
8//!
9//! [`Tooltip`][crate::Tooltip] is for *static* hint text that isn't
10//! meant to be interacted with — it has no scroll, no selection, no
11//! focus state. The editor-hover use case needs all three (long doc
12//! strings scroll; users copy text out; keyboard navigation Tabs
13//! through links). Splitting them keeps Tooltip's API simple for the
14//! many simple consumers and gives this richer surface its own type.
15//!
16//! # Backend contract
17//!
18//! **Modal-ish overlay.** Render as a bordered box at the resolved
19//! position. The popup intercepts clicks landing inside it
20//! (selection drag, link clicks, focus). Clicks outside follow app
21//! policy — typical pattern is "mouse motion outside dismisses
22//! after a short delay; click outside dismisses immediately."
23//!
24//! Per-line content is supplied as [`StyledText`] — backends already
25//! know how to render those. The primitive's job is layout (where
26//! does the box go? which lines are visible after scroll?
27//! scrollbar bounds?) plus hit-test (which line/col does this
28//! click land on? which link?).
29//!
30//! # Tree-sitter syntax highlighting in code blocks
31//!
32//! The primitive doesn't parse markdown or call into tree-sitter —
33//! adapters pre-resolve those into `StyledText` spans (one span per
34//! contiguous run sharing colour + bold/italic). Code-block tokens
35//! become spans with `fg = Some(syntax_color)`. The primitive just
36//! paints what it's given.
37//!
38//! # Font role (editor vs chrome)
39//!
40//! This primitive is classified [`crate::ChromePrimitive::RichTextPopup`]
41//! — by default every backend paints its content in the chrome (UI)
42//! font via [`crate::Backend::draw_rich_text_popup`], same as before a
43//! choice existed. A consumer rendering editor content (hover docs,
44//! signatures, diagnostics) that wants the popup measured and painted
45//! with the editor's monospace metrics instead — keeping link
46//! hit-testing in sync with the glyphs actually painted — calls
47//! [`crate::Backend::draw_rich_text_popup_with_font_role`] with
48//! [`crate::FontRole::Editor`] instead. The role is requested per call,
49//! not stored on this struct: adding a required field here would break
50//! every downstream consumer that constructs `RichTextPopup` with an
51//! exhaustive literal (see `CLAUDE.md`'s *Downstream consumers*
52//! section), so the extra method — alongside the old one, not replacing
53//! it — is the non-breaking shape, mirroring
54//! [`crate::Backend::draw_tooltip_with_chrome`]'s [`crate::TooltipChrome`]
55//! precedent.
56
57use crate::event::Rect;
58use crate::types::{Color, StyledText, WidgetId};
59use serde::{Deserialize, Serialize};
60
61/// Declarative description of a rich-text popup.
62///
63/// # Examples
64///
65/// ```
66/// use quadraui::{RichTextPopup, RichTextPopupHit, RichTextPopupMeasure, Rect, StyledText, WidgetId};
67///
68/// let popup = RichTextPopup {
69///     id: WidgetId::new("popup:hover"),
70///     lines: vec![StyledText::plain("fn main() -> ()")],
71///     line_text: vec!["fn main() -> ()".to_string()],
72///     line_scales: vec![],
73///     scroll_top: 0,
74///     max_visible_rows: 10,
75///     has_focus: true,
76///     selection: None,
77///     links: vec![],
78///     focused_link: None,
79///     placement: Default::default(),
80///     padding: 1.0,
81///     fg: None,
82///     bg: None,
83/// };
84///
85/// let viewport = Rect::new(0.0, 0.0, 80.0, 24.0);
86/// let measure = RichTextPopupMeasure::new(40.0, 1.0);
87/// let layout = popup.layout(5.0, 5.0, viewport, measure, |_, start, end| (end - start) as f32);
88///
89/// let inside = (layout.content_bounds.x + 1.0, layout.content_bounds.y + 0.5);
90/// assert_eq!(layout.hit_test(inside.0, inside.1), RichTextPopupHit::Body);
91/// assert_eq!(layout.char_at(inside.0, inside.1, 1.0), Some((0, 1)));
92/// ```
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct RichTextPopup {
95    pub id: WidgetId,
96    /// One styled row per line. The styling carries colour + bold +
97    /// italic + underline; backends should respect all four.
98    pub lines: Vec<StyledText>,
99    /// Raw text per line (parallel to `lines`). Used by [`Self::char_at`]
100    /// to map a click position to a `(line, col)` for selection
101    /// extraction. Backends don't render this directly.
102    pub line_text: Vec<String>,
103    /// Optional per-line font-size scale (parallel to `lines` when
104    /// non-empty; missing entries default to `1.0`). Adapters set
105    /// `> 1.0` for markdown heading rows so they render larger.
106    /// Backends apply via Pango font scale attr (GTK) or skip (TUI
107    /// can't change cell size mid-render).
108    #[serde(default)]
109    pub line_scales: Vec<f32>,
110    /// Index of the topmost visible line (0 = no scroll).
111    #[serde(default)]
112    pub scroll_top: usize,
113    /// Maximum number of lines visible at once. Determines scrollbar
114    /// presence + thumb sizing. Apps choose a value; typical
115    /// vimcode hover popup uses 20.
116    pub max_visible_rows: usize,
117    /// True when the popup has keyboard focus — backends should
118    /// render a focused border colour (typically `theme.md_link`).
119    #[serde(default)]
120    pub has_focus: bool,
121    /// Active selection, normalised so `(start_line, start_col) <=
122    /// (end_line, end_col)`. Backends invert fg/bg for characters
123    /// inside the range when painting.
124    #[serde(default)]
125    pub selection: Option<TextSelection>,
126    /// Clickable link spans. Used by backends to underline focused
127    /// link characters and to translate clicks to "open URL" intents.
128    #[serde(default)]
129    pub links: Vec<RichTextLink>,
130    /// Index into `links` of the keyboard-focused link. Backends
131    /// underline only this link's characters.
132    #[serde(default)]
133    pub focused_link: Option<usize>,
134    /// Preferred placement relative to the anchor (above by default;
135    /// flips to below when there's no room).
136    #[serde(default)]
137    pub placement: PopupPlacement,
138    /// Border + content padding in cell/pixel units.
139    #[serde(default)]
140    pub padding: f32,
141    /// Override foreground colour for default-styled text. `None` =
142    /// theme `hover_fg`.
143    #[serde(default)]
144    pub fg: Option<Color>,
145    /// Override background colour. `None` = theme `hover_bg`.
146    #[serde(default)]
147    pub bg: Option<Color>,
148}
149
150/// Preferred placement of the popup relative to its anchor.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
152pub enum PopupPlacement {
153    /// Above the anchor cell (default — matches vimcode editor hover).
154    #[default]
155    Above,
156    /// Below the anchor cell.
157    Below,
158}
159
160/// A normalised text selection inside a `RichTextPopup`.
161///
162/// Backends should ensure `start_line < end_line` or
163/// `start_line == end_line && start_col <= end_col` before storing.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165pub struct TextSelection {
166    pub start_line: usize,
167    pub start_col: usize,
168    pub end_line: usize,
169    pub end_col: usize,
170}
171
172impl TextSelection {
173    /// True iff `(line, col)` is inside the (normalised) selection.
174    /// `col` is the character column on `line`.
175    pub fn contains(&self, line: usize, col: usize) -> bool {
176        if self.start_line == self.end_line {
177            line == self.start_line && col >= self.start_col && col < self.end_col
178        } else if line == self.start_line {
179            col >= self.start_col
180        } else if line == self.end_line {
181            col < self.end_col
182        } else {
183            line > self.start_line && line < self.end_line
184        }
185    }
186}
187
188/// A clickable link within the popup content.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct RichTextLink {
191    /// Line index in `RichTextPopup.lines`.
192    pub line: usize,
193    /// Inclusive byte offset within `line_text[line]`.
194    pub start_byte: usize,
195    /// Exclusive byte offset within `line_text[line]`.
196    pub end_byte: usize,
197    /// URL or other target the app opens when the link is clicked.
198    pub url: String,
199}
200
201// ── D6 Layout API ───────────────────────────────────────────────────────────
202
203/// Per-line measurement supplied by the backend.
204#[derive(Debug, Clone, Copy, PartialEq)]
205pub struct RichTextPopupMeasure {
206    /// Width of the popup CONTENT (without borders).
207    pub content_width: f32,
208    /// Height of one *unscaled* row in the backend's unit (cells / pixels).
209    pub row_height: f32,
210    /// Whether the backend renders per-line font scale (see
211    /// [`RichTextPopup::line_scales`] and
212    /// [`Backend::scales_text_rows`][crate::Backend::scales_text_rows]).
213    /// When `true`, [`RichTextPopup::layout`] reserves
214    /// `row_height * line_scales[i]` for each row so scaled headings
215    /// don't overlap. When `false` (fixed-cell backends like TUI), every
216    /// row is exactly `row_height` regardless of scale. Set it from
217    /// `backend.scales_text_rows()` so consumer code stays
218    /// backend-neutral.
219    pub scale_rows: bool,
220}
221
222impl RichTextPopupMeasure {
223    /// Construct a measure with `scale_rows = false` (fixed-height rows).
224    /// Use [`Self::with_scale_rows`] to opt a scaling backend in.
225    pub fn new(content_width: f32, row_height: f32) -> Self {
226        Self {
227            content_width,
228            row_height,
229            scale_rows: false,
230        }
231    }
232
233    /// Set whether scaled rows reserve proportionally more height.
234    /// Pass `backend.scales_text_rows()`.
235    pub fn with_scale_rows(mut self, scale_rows: bool) -> Self {
236        self.scale_rows = scale_rows;
237        self
238    }
239}
240
241/// Resolved position of one visible row inside the popup.
242#[derive(Debug, Clone, Copy, PartialEq)]
243pub struct VisibleRichTextLine {
244    /// Index into `RichTextPopup.lines`.
245    pub line_idx: usize,
246    /// Bounds of the row in viewport coordinates.
247    pub bounds: Rect,
248}
249
250/// Bounds of the scrollbar's track and thumb (when scrolling is needed).
251#[derive(Debug, Clone, Copy, PartialEq)]
252pub struct PopupScrollbar {
253    pub track: Rect,
254    pub thumb: Rect,
255}
256
257/// Classification of a hit-test result.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub enum RichTextPopupHit {
260    /// Click landed on a link — carries the link index.
261    Link(usize),
262    /// Click landed on a regular character — carries `(line, col)`.
263    Char(usize, usize),
264    /// Click landed on the scrollbar track outside the thumb (jump-scroll).
265    ScrollbarTrack,
266    /// Click landed on the scrollbar thumb (start drag).
267    ScrollbarThumb,
268    /// Click landed on the popup body but not a specific feature.
269    Body,
270    /// Click landed outside the popup.
271    Outside,
272}
273
274/// Fully-resolved popup layout.
275#[derive(Debug, Clone, PartialEq)]
276pub struct RichTextPopupLayout {
277    /// Full bounds of the popup box (incl border).
278    pub bounds: Rect,
279    /// Content area inside the borders (where lines render).
280    pub content_bounds: Rect,
281    /// Visible lines after applying `scroll_top` + `max_visible_rows`.
282    pub visible_lines: Vec<VisibleRichTextLine>,
283    /// Resolved scroll offset (clamped to valid range).
284    pub resolved_scroll_offset: usize,
285    /// Scrollbar bounds when content overflows; `None` otherwise.
286    pub scrollbar: Option<PopupScrollbar>,
287    /// Per-link character hit zones (computed from `links` + visible
288    /// rows + measured char widths). Each entry is `(rect, link_idx)`.
289    pub link_hit_regions: Vec<(Rect, usize)>,
290}
291
292impl RichTextPopupLayout {
293    /// Hit-test a viewport position. Returns the most specific hit:
294    /// link > scrollbar > char > body > outside.
295    pub fn hit_test(&self, x: f32, y: f32) -> RichTextPopupHit {
296        // Outside the box entirely.
297        if x < self.bounds.x
298            || x >= self.bounds.x + self.bounds.width
299            || y < self.bounds.y
300            || y >= self.bounds.y + self.bounds.height
301        {
302            return RichTextPopupHit::Outside;
303        }
304        // Link?
305        for (rect, idx) in &self.link_hit_regions {
306            if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
307                return RichTextPopupHit::Link(*idx);
308            }
309        }
310        // Scrollbar?
311        if let Some(sb) = self.scrollbar {
312            if x >= sb.thumb.x
313                && x < sb.thumb.x + sb.thumb.width
314                && y >= sb.thumb.y
315                && y < sb.thumb.y + sb.thumb.height
316            {
317                return RichTextPopupHit::ScrollbarThumb;
318            }
319            if x >= sb.track.x
320                && x < sb.track.x + sb.track.width
321                && y >= sb.track.y
322                && y < sb.track.y + sb.track.height
323            {
324                return RichTextPopupHit::ScrollbarTrack;
325            }
326        }
327        // Body — caller can refine via `char_at` if it cares about (line, col).
328        RichTextPopupHit::Body
329    }
330
331    /// Map a viewport position to the `(line, col)` of the character
332    /// underneath. `col_width` is the backend's per-character advance
333    /// (cell = 1, pixel font = char width). Returns `None` if outside
334    /// any visible row.
335    ///
336    /// Used during selection drag — the app records selection start
337    /// on mouse-down and updates the end on each mouse-move.
338    pub fn char_at(&self, x: f32, y: f32, col_width: f32) -> Option<(usize, usize)> {
339        for vis in &self.visible_lines {
340            if y >= vis.bounds.y && y < vis.bounds.y + vis.bounds.height {
341                let rel_x = (x - vis.bounds.x).max(0.0);
342                let col = if col_width > 0.0 {
343                    (rel_x / col_width) as usize
344                } else {
345                    0
346                };
347                return Some((vis.line_idx, col));
348            }
349        }
350        None
351    }
352}
353
354impl RichTextPopup {
355    /// Compute the full popup layout at `(anchor_x, anchor_y)`.
356    ///
357    /// The anchor is typically the top-left of the editor cell the
358    /// popup describes. Placement choice puts the popup above (with
359    /// fallback to below) or below (with fallback to above) per
360    /// `placement`.
361    ///
362    /// `viewport` clamps the popup; if both placements overflow,
363    /// the popup is pinned to the viewport edge.
364    ///
365    /// `measure` supplies content-area width and per-row height.
366    /// `link_widths(line, byte_range) -> width` returns the rendered
367    /// width of an arbitrary substring on a line — used to compute
368    /// link hit regions in pixel-unit backends. TUI passes
369    /// `|_, range| (range.end - range.start) as f32`.
370    pub fn layout<W>(
371        &self,
372        anchor_x: f32,
373        anchor_y: f32,
374        viewport: Rect,
375        measure: RichTextPopupMeasure,
376        link_widths: W,
377    ) -> RichTextPopupLayout
378    where
379        W: Fn(usize, usize, usize) -> f32,
380    {
381        let total_lines = self.lines.len();
382        let max_rows = self.max_visible_rows.max(1);
383        // Clamp scroll FIRST so a stale `scroll_top` past `max_scroll`
384        // still produces a valid visible window (last full screen).
385        let max_scroll = total_lines.saturating_sub(max_rows);
386        let resolved_scroll_offset = self.scroll_top.min(max_scroll);
387        let visible_count = total_lines
388            .saturating_sub(resolved_scroll_offset)
389            .min(max_rows);
390
391        // Per-row height. Fixed-cell backends (`scale_rows == false`,
392        // e.g. TUI) keep every row at `row_height`; scaling backends
393        // (GTK) reserve `row_height * line_scales[i]` so larger heading
394        // glyphs don't overlap the rows below. Scales below 1.0 are
395        // clamped so a stray small scale can't shrink a row.
396        let row_height_at = |line_idx: usize| -> f32 {
397            let scale = if measure.scale_rows {
398                self.line_scales
399                    .get(line_idx)
400                    .copied()
401                    .unwrap_or(1.0)
402                    .max(1.0)
403            } else {
404                1.0
405            };
406            measure.row_height * scale
407        };
408
409        // Total content height = sum of the visible window's row heights
410        // (varies with which rows are scrolled into view when scales differ).
411        let content_h: f32 = (0..visible_count)
412            .map(|i| row_height_at(resolved_scroll_offset + i))
413            .sum();
414
415        let pad = self.padding.max(0.0);
416        let border = 1.0; // 1 cell / 1 pixel each side
417        let outer_w = measure.content_width + pad * 2.0 + border * 2.0;
418        let outer_h = content_h + pad * 2.0 + border * 2.0;
419
420        // Placement: above the anchor when there's room, otherwise below.
421        let prefer_above = self.placement == PopupPlacement::Above;
422        let above_y = anchor_y - outer_h;
423        let below_y = anchor_y + measure.row_height;
424        let y = match (prefer_above, above_y >= viewport.y) {
425            (true, true) => above_y,
426            (true, false) => below_y,
427            (false, _) if below_y + outer_h <= viewport.y + viewport.height => below_y,
428            (false, _) => above_y.max(viewport.y),
429        };
430        // Clamp x so the popup stays inside viewport horizontally.
431        let max_x = (viewport.x + viewport.width - outer_w).max(viewport.x);
432        let x = anchor_x.clamp(viewport.x, max_x);
433        // Clamp y similarly.
434        let max_y = (viewport.y + viewport.height - outer_h).max(viewport.y);
435        let y = y.clamp(viewport.y, max_y);
436
437        let bounds = Rect::new(x, y, outer_w, outer_h);
438        let content_bounds = Rect::new(
439            x + border + pad,
440            y + border + pad,
441            measure.content_width,
442            content_h,
443        );
444
445        // Visible lines — accumulate `y` by each row's (possibly scaled)
446        // height so rows never overlap on scaling backends.
447        let mut visible_lines: Vec<VisibleRichTextLine> = Vec::with_capacity(visible_count);
448        let mut row_y = content_bounds.y;
449        for i in 0..visible_count {
450            let line_idx = resolved_scroll_offset + i;
451            let row_h = row_height_at(line_idx);
452            visible_lines.push(VisibleRichTextLine {
453                line_idx,
454                bounds: Rect::new(content_bounds.x, row_y, content_bounds.width, row_h),
455            });
456            row_y += row_h;
457        }
458
459        // Scrollbar (1 cell / pixel wide at the right border).
460        let scrollbar = if total_lines > max_rows {
461            let track = Rect::new(
462                bounds.x + bounds.width - border,
463                content_bounds.y,
464                border,
465                content_bounds.height,
466            );
467            // Thumb geometry via the one canonical formula
468            // instead of a hand-rolled restatement of it: scroll is
469            // row-indexed here, so `scroll_rows -> main-axis units` uses
470            // the same "row_main_unit" conversion `multi_section_view`'s
471            // `compute_thumb_bounds` does, then `fit_thumb` sizes and
472            // positions the thumb with `measure.row_height` as the
473            // minimum thumb length (never smaller than one row).
474            let row_main_unit = content_bounds.height / total_lines as f32;
475            let (thumb_top_offset, thumb_h) = crate::primitives::scrollbar::fit_thumb(
476                resolved_scroll_offset as f32 * row_main_unit,
477                content_bounds.height,
478                max_rows as f32 * row_main_unit,
479                content_bounds.height,
480                measure.row_height,
481            );
482            let thumb = Rect::new(track.x, track.y + thumb_top_offset, border, thumb_h);
483            Some(PopupScrollbar { track, thumb })
484        } else {
485            None
486        };
487
488        // Per-link hit regions for clickable spans on visible rows.
489        let mut link_hit_regions: Vec<(Rect, usize)> = Vec::new();
490        for vis in &visible_lines {
491            for (idx, link) in self.links.iter().enumerate() {
492                if link.line != vis.line_idx {
493                    continue;
494                }
495                let pre_w = link_widths(link.line, 0, link.start_byte);
496                let span_w = link_widths(link.line, link.start_byte, link.end_byte);
497                let rect = Rect::new(
498                    vis.bounds.x + pre_w,
499                    vis.bounds.y,
500                    span_w,
501                    vis.bounds.height,
502                );
503                link_hit_regions.push((rect, idx));
504            }
505        }
506
507        RichTextPopupLayout {
508            bounds,
509            content_bounds,
510            visible_lines,
511            resolved_scroll_offset,
512            scrollbar,
513            link_hit_regions,
514        }
515    }
516}
517
518// Shared painting implementation for macOS and Windows rasterisers.
519// GTK uses a separate implementation in `gtk::rich_text_popup::draw_rich_text_popup`
520// because it shapes entire lines with Pango's `AttrList` to avoid glyph-position
521// drift from kerning/ligatures that occurs when spans are shaped and advanced separately.
522//
523// macOS and Windows both use per-span manual x-advance (the approach below),
524// so consolidating them closes gaps between the implementations:
525//
526// - Selection background + inverted foreground colour.
527// - Bold span styling.
528// - Focused-link underline (whole-span approximation).
529// - Content-area clipping to prevent overlong lines from bleeding past the border.
530// - Scrollbar width + track opacity: wider bar with translucent track.
531//
532// Font scaling per row (via `RichTextPopup::line_scales`) is not implemented
533// — it requires per-row font swaps that would need backend-specific capabilities.
534#[cfg(any(feature = "win", all(feature = "macos", target_os = "macos")))]
535#[allow(dead_code)]
536pub(crate) mod native_surface_paint {
537    use super::{RichTextPopup, RichTextPopupLayout, TextSelection};
538    use crate::paint_surface::PaintSurface;
539    use crate::theme::Theme;
540    use crate::{Point, Rect};
541
542    /// Visible width of the rich-text-popup scrollbar, shared by
543    /// [`paint`] and each backend's own hit-testing constant of the
544    /// same value (`gtk`/`macos::rich_text_popup::RICH_TEXT_POPUP_SB_WIDTH`)
545    /// — wider than [`RichTextPopupLayout::scrollbar`]'s bare 1-unit
546    /// track so the bar is paint+click-friendly. See this module's doc
547    /// for the drift this closes on Windows.
548    pub(crate) const SB_WIDTH: f32 = 8.0;
549    /// Inset between the scrollbar's right edge and the popup's own
550    /// right border. See [`SB_WIDTH`]'s doc.
551    pub(crate) const SB_INSET: f32 = 1.0;
552
553    /// Translate a `TextSelection` (char columns) into the byte range
554    /// this line contributes to the selection. Returns `(0, 0)` when
555    /// the line is outside the selection. Ported verbatim from
556    /// `gtk::rich_text_popup`'s (and `win::rich_text_popup`'s identical
557    /// copy of the) private helper of the same name.
558    fn selection_byte_range(
559        sel: TextSelection,
560        line_idx: usize,
561        line_text: &str,
562    ) -> (usize, usize) {
563        if line_idx < sel.start_line || line_idx > sel.end_line {
564            return (0, 0);
565        }
566        let (start_col, end_col) = if sel.start_line == sel.end_line {
567            (sel.start_col, sel.end_col)
568        } else if line_idx == sel.start_line {
569            (sel.start_col, line_text.chars().count())
570        } else if line_idx == sel.end_line {
571            (0, sel.end_col)
572        } else {
573            (0, line_text.chars().count())
574        };
575        if end_col <= start_col {
576            return (0, 0);
577        }
578        (
579            crate::text_util::char_to_byte_idx(line_text, start_col),
580            crate::text_util::char_to_byte_idx(line_text, end_col),
581        )
582    }
583
584    /// Paint a [`RichTextPopup`] at its resolved `layout` onto
585    /// `surface` — background, border (accent when `popup.has_focus`),
586    /// per-visible-line styled spans (selection-bg + inverted fg,
587    /// bold, focused-link underline), and the scrollbar when present.
588    /// Returns the per-link hit rectangles `(Rect, url)` computed from
589    /// `surface`'s own glyph measurements — more accurate than
590    /// `layout.link_hit_regions`, whose widths come from the host's
591    /// `link_widths` measure closure rather than this backend's actual
592    /// glyph advances (mirrors `gtk::draw_rich_text_popup`'s
593    /// `index_to_pos`-derived link rects for the same reason).
594    ///
595    /// See this module's doc for why `gtk::rich_text_popup` does not
596    /// call this — GTK's own paint is not migrated.
597    pub(crate) fn paint(
598        popup: &RichTextPopup,
599        layout: &RichTextPopupLayout,
600        surface: &mut dyn PaintSurface,
601        theme: &Theme,
602    ) -> Vec<(Rect, String)> {
603        let bounds = layout.bounds;
604        if bounds.width <= 0.0 || bounds.height <= 0.0 {
605            return Vec::new();
606        }
607
608        let bg = popup.bg.unwrap_or(theme.hover_bg);
609        let fg = popup.fg.unwrap_or(theme.hover_fg);
610        let border = if popup.has_focus {
611            theme.link_fg
612        } else {
613            theme.hover_border
614        };
615
616        surface.surface_fill_rect(bounds, bg);
617        surface.surface_stroke_rect(bounds, border, 1.0);
618
619        let content = layout.content_bounds;
620        surface.surface_push_clip(content);
621
622        let mut link_rects: Vec<(Rect, String)> = Vec::new();
623
624        for vis in &layout.visible_lines {
625            let line_idx = vis.line_idx;
626            let raw_text = popup
627                .line_text
628                .get(line_idx)
629                .map(String::as_str)
630                .unwrap_or("");
631            let Some(styled) = popup.lines.get(line_idx) else {
632                continue;
633            };
634
635            let (sel_start, sel_end) = popup
636                .selection
637                .map(|sel| selection_byte_range(sel, line_idx, raw_text))
638                .unwrap_or((0, 0));
639            if sel_end > sel_start {
640                // Selection bg is painted as a single rect spanning the
641                // byte range's measured width, ahead of the text itself.
642                let pre_w = surface.surface_measure_text(&raw_text[..sel_start]).0;
643                let sel_w = surface
644                    .surface_measure_text(&raw_text[sel_start..sel_end])
645                    .0;
646                let sel_rect = Rect::new(
647                    vis.bounds.x + pre_w,
648                    vis.bounds.y,
649                    sel_w.max(1.0),
650                    vis.bounds.height,
651                );
652                surface.surface_fill_rect(sel_rect, popup.fg.unwrap_or(theme.foreground));
653            }
654
655            let focused_underline_range = if popup.has_focus {
656                popup.focused_link.and_then(|idx| {
657                    popup
658                        .links
659                        .get(idx)
660                        .filter(|link| link.line == line_idx)
661                        .map(|link| (link.start_byte, link.end_byte))
662                })
663            } else {
664                None
665            };
666
667            let mut byte_pos = 0usize;
668            let mut x = vis.bounds.x;
669            for span in &styled.spans {
670                let start = byte_pos;
671                let end = byte_pos + span.text.len();
672                let in_selection = sel_end > sel_start && start >= sel_start && end <= sel_end;
673                let color = if in_selection {
674                    popup.bg.unwrap_or(theme.background)
675                } else {
676                    span.fg.unwrap_or(fg)
677                };
678                let (w, _) = surface.surface_measure_text_styled(&span.text, span.bold);
679                let rect = Rect::new(x, vis.bounds.y, w.max(1.0), vis.bounds.height);
680                surface.surface_draw_text_run_styled(
681                    rect,
682                    &span.text,
683                    color,
684                    span.bold,
685                    span.italic,
686                    false,
687                    1.0,
688                );
689
690                // Underline the whole span when it overlaps the focused
691                // link's byte range. Coarser than GTK's per-substring
692                // underline (which uses Pango's `index_to_pos` to
693                // underline exactly the link's own characters within a
694                // mixed span), but avoids re-slicing `span.text` at
695                // arbitrary byte offsets that aren't guaranteed to land
696                // on this span's own char boundaries.
697                if let Some((us, ue)) = focused_underline_range {
698                    if start < ue && end > us {
699                        let uy = vis.bounds.y + vis.bounds.height - 2.0;
700                        surface.surface_draw_line(
701                            Point::new(x, uy),
702                            Point::new(x + w, uy),
703                            border,
704                            1.0,
705                        );
706                    }
707                }
708
709                x += w;
710                byte_pos = end;
711            }
712
713            for link in popup.links.iter().filter(|l| l.line == line_idx) {
714                let pre_w = surface.surface_measure_text(&raw_text[..link.start_byte]).0;
715                let span_w = surface
716                    .surface_measure_text(&raw_text[link.start_byte..link.end_byte])
717                    .0;
718                let rect = Rect::new(
719                    vis.bounds.x + pre_w,
720                    vis.bounds.y,
721                    span_w.max(1.0),
722                    vis.bounds.height,
723                );
724                link_rects.push((rect, link.url.clone()));
725            }
726        }
727
728        surface.surface_pop_clip();
729
730        if let Some(sb) = layout.scrollbar {
731            let sb_x = bounds.x + bounds.width - SB_WIDTH - SB_INSET;
732            let track = Rect::new(sb_x, sb.track.y, SB_WIDTH, sb.track.height);
733            surface.surface_fill_rect(track, theme.muted_fg.with_alpha(0.3));
734            let thumb_top_off = sb.thumb.y - sb.track.y;
735            let thumb = Rect::new(
736                sb_x + 1.0,
737                sb.track.y + thumb_top_off,
738                SB_WIDTH - 2.0,
739                sb.thumb.height,
740            );
741            surface.surface_fill_rect(thumb, border);
742        }
743
744        link_rects
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    use crate::types::StyledText;
752
753    /// Build a popup with `n` body lines and the given per-line scales.
754    fn popup_with_scales(scales: &[f32]) -> RichTextPopup {
755        let n = scales.len();
756        RichTextPopup {
757            id: WidgetId::new("rtp:test"),
758            lines: (0..n)
759                .map(|i| StyledText::plain(format!("line{i}")))
760                .collect(),
761            line_text: (0..n).map(|i| format!("line{i}")).collect(),
762            line_scales: scales.to_vec(),
763            scroll_top: 0,
764            max_visible_rows: 20,
765            has_focus: false,
766            selection: None,
767            links: Vec::new(),
768            focused_link: None,
769            placement: PopupPlacement::Below,
770            padding: 0.0,
771            fg: None,
772            bg: None,
773        }
774    }
775
776    fn vp() -> Rect {
777        Rect::new(0.0, 0.0, 1000.0, 1000.0)
778    }
779
780    #[test]
781    fn scale_rows_false_keeps_flat_geometry() {
782        // Even with heading scales present, a fixed-cell backend lays
783        // every row out at exactly `row_height` (pre-fix behaviour).
784        let popup = popup_with_scales(&[2.0, 1.5, 1.0]);
785        let measure = RichTextPopupMeasure::new(50.0, 10.0); // scale_rows = false
786        let layout = popup.layout(0.0, 0.0, vp(), measure, |_, s, e| (e - s) as f32);
787        for (i, vis) in layout.visible_lines.iter().enumerate() {
788            assert!(
789                (vis.bounds.height - 10.0).abs() < f32::EPSILON,
790                "row {i} height should be flat 10.0, got {}",
791                vis.bounds.height
792            );
793            let expected_y = layout.content_bounds.y + i as f32 * 10.0;
794            assert!(
795                (vis.bounds.y - expected_y).abs() < f32::EPSILON,
796                "row {i} y should be {expected_y}, got {}",
797                vis.bounds.y
798            );
799        }
800        // content height = 3 flat rows.
801        assert!((layout.content_bounds.height - 30.0).abs() < f32::EPSILON);
802    }
803
804    #[test]
805    fn scale_rows_true_reserves_proportional_height() {
806        // H1 (2.0x) then body (1.0x): the first row is twice as tall and
807        // the second row starts below it (no overlap).
808        let popup = popup_with_scales(&[2.0, 1.0]);
809        let measure = RichTextPopupMeasure::new(50.0, 10.0).with_scale_rows(true);
810        let layout = popup.layout(0.0, 0.0, vp(), measure, |_, s, e| (e - s) as f32);
811
812        let r0 = layout.visible_lines[0].bounds;
813        let r1 = layout.visible_lines[1].bounds;
814        assert!(
815            (r0.height - 20.0).abs() < f32::EPSILON,
816            "H1 row should be 2 * 10 = 20 tall, got {}",
817            r0.height
818        );
819        assert!(
820            (r1.y - (r0.y + r0.height)).abs() < f32::EPSILON,
821            "body row must start exactly below the H1 row (y={}, expected {})",
822            r1.y,
823            r0.y + r0.height
824        );
825        assert!(
826            (r1.height - 10.0).abs() < f32::EPSILON,
827            "body row should be 1 * 10 = 10 tall, got {}",
828            r1.height
829        );
830        // content height = 20 (H1) + 10 (body) = 30.
831        assert!(
832            (layout.content_bounds.height - 30.0).abs() < f32::EPSILON,
833            "content height should sum scaled rows (30), got {}",
834            layout.content_bounds.height
835        );
836    }
837
838    #[test]
839    fn scale_rows_true_sums_all_three_heading_levels() {
840        // H1/H2/H3/body = 2.0/1.5/1.2/1.0 over a 10px base.
841        let popup = popup_with_scales(&[2.0, 1.5, 1.2, 1.0]);
842        let measure = RichTextPopupMeasure::new(50.0, 10.0).with_scale_rows(true);
843        let layout = popup.layout(0.0, 0.0, vp(), measure, |_, s, e| (e - s) as f32);
844        let heights: Vec<f32> = layout
845            .visible_lines
846            .iter()
847            .map(|v| v.bounds.height)
848            .collect();
849        assert_eq!(heights.len(), 4);
850        let expected = [20.0_f32, 15.0, 12.0, 10.0];
851        for (got, want) in heights.iter().zip(expected) {
852            assert!((got - want).abs() < 1e-4, "row height {got} != {want}");
853        }
854        // Cumulative y positions and total content height.
855        let mut acc = layout.content_bounds.y;
856        for (i, vis) in layout.visible_lines.iter().enumerate() {
857            assert!((vis.bounds.y - acc).abs() < 1e-4, "row {i} y mismatch");
858            acc += vis.bounds.height;
859        }
860        assert!((layout.content_bounds.height - 57.0).abs() < 1e-4); // 20+15+12+10
861    }
862
863    #[test]
864    fn scale_below_one_is_clamped() {
865        // A stray sub-1.0 scale must not shrink a row below row_height.
866        let popup = popup_with_scales(&[0.5]);
867        let measure = RichTextPopupMeasure::new(50.0, 10.0).with_scale_rows(true);
868        let layout = popup.layout(0.0, 0.0, vp(), measure, |_, s, e| (e - s) as f32);
869        assert!((layout.visible_lines[0].bounds.height - 10.0).abs() < f32::EPSILON);
870    }
871
872    #[test]
873    fn missing_scale_entry_defaults_to_one() {
874        // Fewer line_scales than lines: missing entries are unscaled.
875        let mut popup = popup_with_scales(&[2.0]);
876        popup.lines.push(StyledText::plain("line1"));
877        popup.line_text.push("line1".to_string());
878        // line_scales has only one entry; line 1 has no scale.
879        let measure = RichTextPopupMeasure::new(50.0, 10.0).with_scale_rows(true);
880        let layout = popup.layout(0.0, 0.0, vp(), measure, |_, s, e| (e - s) as f32);
881        assert!((layout.visible_lines[0].bounds.height - 20.0).abs() < f32::EPSILON);
882        assert!((layout.visible_lines[1].bounds.height - 10.0).abs() < f32::EPSILON);
883    }
884
885    // ── RichTextPopup primitive tests ────────────────────────────────────
886
887    fn make_rich_popup(lines: usize, max_visible: usize, scroll: usize) -> RichTextPopup {
888        let line_text: Vec<String> = (0..lines).map(|i| format!("line {i:02}")).collect();
889        let lines_styled: Vec<StyledText> = line_text
890            .iter()
891            .map(|s| StyledText::plain(s.clone()))
892            .collect();
893        RichTextPopup {
894            id: WidgetId::new("hover"),
895            lines: lines_styled,
896            line_text,
897            line_scales: Vec::new(),
898            scroll_top: scroll,
899            max_visible_rows: max_visible,
900            has_focus: false,
901            selection: None,
902            links: Vec::new(),
903            focused_link: None,
904            placement: PopupPlacement::Above,
905            padding: 1.0,
906            fg: None,
907            bg: None,
908        }
909    }
910
911    #[test]
912    fn rich_text_popup_layout_visible_lines_window() {
913        let p = make_rich_popup(30, 10, 5);
914        let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
915        let layout = p.layout(
916            10.0,
917            100.0,
918            viewport,
919            RichTextPopupMeasure::new(80.0, 1.0),
920            |_, s, e| (e - s) as f32,
921        );
922        // Visible window starts at scroll_top=5 and shows 10 rows (capped by total).
923        assert_eq!(layout.visible_lines.len(), 10);
924        assert_eq!(layout.visible_lines[0].line_idx, 5);
925        assert_eq!(layout.visible_lines[9].line_idx, 14);
926        assert_eq!(layout.resolved_scroll_offset, 5);
927    }
928
929    #[test]
930    fn rich_text_popup_layout_clamps_scroll_past_end() {
931        // 30 lines, 10 visible at a time → max scroll is 20.
932        // Asking for scroll=999 should clamp.
933        let p = make_rich_popup(30, 10, 999);
934        let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
935        let layout = p.layout(
936            0.0,
937            0.0,
938            viewport,
939            RichTextPopupMeasure::new(80.0, 1.0),
940            |_, s, e| (e - s) as f32,
941        );
942        assert_eq!(layout.resolved_scroll_offset, 20);
943        assert_eq!(layout.visible_lines.first().unwrap().line_idx, 20);
944        assert_eq!(layout.visible_lines.last().unwrap().line_idx, 29);
945    }
946
947    #[test]
948    fn rich_text_popup_scrollbar_present_when_overflow() {
949        let p = make_rich_popup(50, 10, 0);
950        let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
951        let layout = p.layout(
952            0.0,
953            0.0,
954            viewport,
955            RichTextPopupMeasure::new(80.0, 1.0),
956            |_, s, e| (e - s) as f32,
957        );
958        let sb = layout.scrollbar.expect("scrollbar should exist");
959        // Thumb size proportional to visible/total = 10/50 = 1/5 of track.
960        assert!(sb.thumb.height > 0.0);
961        assert!(sb.thumb.height < sb.track.height);
962        // No scrollbar when content fits.
963        let p2 = make_rich_popup(5, 10, 0);
964        let layout2 = p2.layout(
965            0.0,
966            0.0,
967            viewport,
968            RichTextPopupMeasure::new(80.0, 1.0),
969            |_, s, e| (e - s) as f32,
970        );
971        assert!(layout2.scrollbar.is_none());
972    }
973
974    #[test]
975    fn rich_text_popup_link_hit_regions_for_visible_lines() {
976        let mut p = make_rich_popup(20, 10, 5);
977        // Add a link on visible line 7 (visible_lines[2]).
978        p.links.push(RichTextLink {
979            line: 7,
980            start_byte: 5,
981            end_byte: 10,
982            url: "https://example.com".to_string(),
983        });
984        // Add a link on hidden line 2 (above scroll_top=5).
985        p.links.push(RichTextLink {
986            line: 2,
987            start_byte: 0,
988            end_byte: 4,
989            url: "off-screen".to_string(),
990        });
991        let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
992        let layout = p.layout(
993            10.0,
994            100.0,
995            viewport,
996            RichTextPopupMeasure::new(80.0, 1.0),
997            |_, s, e| (e - s) as f32,
998        );
999        // Only the visible link gets a hit region.
1000        assert_eq!(layout.link_hit_regions.len(), 1);
1001        let (_, idx) = &layout.link_hit_regions[0];
1002        assert_eq!(*idx, 0);
1003    }
1004
1005    #[test]
1006    fn text_selection_contains_single_and_multi_line() {
1007        let single = TextSelection {
1008            start_line: 3,
1009            start_col: 5,
1010            end_line: 3,
1011            end_col: 10,
1012        };
1013        assert!(single.contains(3, 5));
1014        assert!(single.contains(3, 9));
1015        assert!(!single.contains(3, 10));
1016        assert!(!single.contains(2, 5));
1017        assert!(!single.contains(4, 5));
1018
1019        let multi = TextSelection {
1020            start_line: 2,
1021            start_col: 4,
1022            end_line: 5,
1023            end_col: 3,
1024        };
1025        assert!(!multi.contains(2, 3));
1026        assert!(multi.contains(2, 4));
1027        assert!(multi.contains(3, 0));
1028        assert!(multi.contains(4, 100));
1029        assert!(multi.contains(5, 0));
1030        assert!(multi.contains(5, 2));
1031        assert!(!multi.contains(5, 3));
1032        assert!(!multi.contains(6, 0));
1033    }
1034}