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}