use crate::event::Rect;
use crate::types::{Color, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RichTextPopup {
pub id: WidgetId,
pub lines: Vec<StyledText>,
pub line_text: Vec<String>,
#[serde(default)]
pub line_scales: Vec<f32>,
#[serde(default)]
pub scroll_top: usize,
pub max_visible_rows: usize,
#[serde(default)]
pub has_focus: bool,
#[serde(default)]
pub selection: Option<TextSelection>,
#[serde(default)]
pub links: Vec<RichTextLink>,
#[serde(default)]
pub focused_link: Option<usize>,
#[serde(default)]
pub placement: PopupPlacement,
#[serde(default)]
pub padding: f32,
#[serde(default)]
pub fg: Option<Color>,
#[serde(default)]
pub bg: Option<Color>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum PopupPlacement {
#[default]
Above,
Below,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextSelection {
pub start_line: usize,
pub start_col: usize,
pub end_line: usize,
pub end_col: usize,
}
impl TextSelection {
pub fn contains(&self, line: usize, col: usize) -> bool {
if self.start_line == self.end_line {
line == self.start_line && col >= self.start_col && col < self.end_col
} else if line == self.start_line {
col >= self.start_col
} else if line == self.end_line {
col < self.end_col
} else {
line > self.start_line && line < self.end_line
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RichTextLink {
pub line: usize,
pub start_byte: usize,
pub end_byte: usize,
pub url: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RichTextPopupMeasure {
pub content_width: f32,
pub row_height: f32,
pub scale_rows: bool,
}
impl RichTextPopupMeasure {
pub fn new(content_width: f32, row_height: f32) -> Self {
Self {
content_width,
row_height,
scale_rows: false,
}
}
pub fn with_scale_rows(mut self, scale_rows: bool) -> Self {
self.scale_rows = scale_rows;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VisibleRichTextLine {
pub line_idx: usize,
pub bounds: Rect,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PopupScrollbar {
pub track: Rect,
pub thumb: Rect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RichTextPopupHit {
Link(usize),
Char(usize, usize),
ScrollbarTrack,
ScrollbarThumb,
Body,
Outside,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RichTextPopupLayout {
pub bounds: Rect,
pub content_bounds: Rect,
pub visible_lines: Vec<VisibleRichTextLine>,
pub resolved_scroll_offset: usize,
pub scrollbar: Option<PopupScrollbar>,
pub link_hit_regions: Vec<(Rect, usize)>,
}
impl RichTextPopupLayout {
pub fn hit_test(&self, x: f32, y: f32) -> RichTextPopupHit {
if x < self.bounds.x
|| x >= self.bounds.x + self.bounds.width
|| y < self.bounds.y
|| y >= self.bounds.y + self.bounds.height
{
return RichTextPopupHit::Outside;
}
for (rect, idx) in &self.link_hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return RichTextPopupHit::Link(*idx);
}
}
if let Some(sb) = self.scrollbar {
if x >= sb.thumb.x
&& x < sb.thumb.x + sb.thumb.width
&& y >= sb.thumb.y
&& y < sb.thumb.y + sb.thumb.height
{
return RichTextPopupHit::ScrollbarThumb;
}
if x >= sb.track.x
&& x < sb.track.x + sb.track.width
&& y >= sb.track.y
&& y < sb.track.y + sb.track.height
{
return RichTextPopupHit::ScrollbarTrack;
}
}
RichTextPopupHit::Body
}
pub fn char_at(&self, x: f32, y: f32, col_width: f32) -> Option<(usize, usize)> {
for vis in &self.visible_lines {
if y >= vis.bounds.y && y < vis.bounds.y + vis.bounds.height {
let rel_x = (x - vis.bounds.x).max(0.0);
let col = if col_width > 0.0 {
(rel_x / col_width) as usize
} else {
0
};
return Some((vis.line_idx, col));
}
}
None
}
}
impl RichTextPopup {
pub fn layout<W>(
&self,
anchor_x: f32,
anchor_y: f32,
viewport: Rect,
measure: RichTextPopupMeasure,
link_widths: W,
) -> RichTextPopupLayout
where
W: Fn(usize, usize, usize) -> f32,
{
let total_lines = self.lines.len();
let max_rows = self.max_visible_rows.max(1);
let max_scroll = total_lines.saturating_sub(max_rows);
let resolved_scroll_offset = self.scroll_top.min(max_scroll);
let visible_count = total_lines
.saturating_sub(resolved_scroll_offset)
.min(max_rows);
let row_height_at = |line_idx: usize| -> f32 {
let scale = if measure.scale_rows {
self.line_scales
.get(line_idx)
.copied()
.unwrap_or(1.0)
.max(1.0)
} else {
1.0
};
measure.row_height * scale
};
let content_h: f32 = (0..visible_count)
.map(|i| row_height_at(resolved_scroll_offset + i))
.sum();
let pad = self.padding.max(0.0);
let border = 1.0; let outer_w = measure.content_width + pad * 2.0 + border * 2.0;
let outer_h = content_h + pad * 2.0 + border * 2.0;
let prefer_above = self.placement == PopupPlacement::Above;
let above_y = anchor_y - outer_h;
let below_y = anchor_y + measure.row_height;
let y = match (prefer_above, above_y >= viewport.y) {
(true, true) => above_y,
(true, false) => below_y,
(false, _) if below_y + outer_h <= viewport.y + viewport.height => below_y,
(false, _) => above_y.max(viewport.y),
};
let max_x = (viewport.x + viewport.width - outer_w).max(viewport.x);
let x = anchor_x.clamp(viewport.x, max_x);
let max_y = (viewport.y + viewport.height - outer_h).max(viewport.y);
let y = y.clamp(viewport.y, max_y);
let bounds = Rect::new(x, y, outer_w, outer_h);
let content_bounds = Rect::new(
x + border + pad,
y + border + pad,
measure.content_width,
content_h,
);
let mut visible_lines: Vec<VisibleRichTextLine> = Vec::with_capacity(visible_count);
let mut row_y = content_bounds.y;
for i in 0..visible_count {
let line_idx = resolved_scroll_offset + i;
let row_h = row_height_at(line_idx);
visible_lines.push(VisibleRichTextLine {
line_idx,
bounds: Rect::new(content_bounds.x, row_y, content_bounds.width, row_h),
});
row_y += row_h;
}
let scrollbar = if total_lines > max_rows {
let track = Rect::new(
bounds.x + bounds.width - border,
content_bounds.y,
border,
content_bounds.height,
);
let row_main_unit = content_bounds.height / total_lines as f32;
let (thumb_top_offset, thumb_h) = crate::primitives::scrollbar::fit_thumb(
resolved_scroll_offset as f32 * row_main_unit,
content_bounds.height,
max_rows as f32 * row_main_unit,
content_bounds.height,
measure.row_height,
);
let thumb = Rect::new(track.x, track.y + thumb_top_offset, border, thumb_h);
Some(PopupScrollbar { track, thumb })
} else {
None
};
let mut link_hit_regions: Vec<(Rect, usize)> = Vec::new();
for vis in &visible_lines {
for (idx, link) in self.links.iter().enumerate() {
if link.line != vis.line_idx {
continue;
}
let pre_w = link_widths(link.line, 0, link.start_byte);
let span_w = link_widths(link.line, link.start_byte, link.end_byte);
let rect = Rect::new(
vis.bounds.x + pre_w,
vis.bounds.y,
span_w,
vis.bounds.height,
);
link_hit_regions.push((rect, idx));
}
}
RichTextPopupLayout {
bounds,
content_bounds,
visible_lines,
resolved_scroll_offset,
scrollbar,
link_hit_regions,
}
}
}
#[cfg(any(feature = "win", all(feature = "macos", target_os = "macos")))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{RichTextPopup, RichTextPopupLayout, TextSelection};
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
use crate::{Point, Rect};
pub(crate) const SB_WIDTH: f32 = 8.0;
pub(crate) const SB_INSET: f32 = 1.0;
fn selection_byte_range(
sel: TextSelection,
line_idx: usize,
line_text: &str,
) -> (usize, usize) {
if line_idx < sel.start_line || line_idx > sel.end_line {
return (0, 0);
}
let (start_col, end_col) = if sel.start_line == sel.end_line {
(sel.start_col, sel.end_col)
} else if line_idx == sel.start_line {
(sel.start_col, line_text.chars().count())
} else if line_idx == sel.end_line {
(0, sel.end_col)
} else {
(0, line_text.chars().count())
};
if end_col <= start_col {
return (0, 0);
}
(
crate::text_util::char_to_byte_idx(line_text, start_col),
crate::text_util::char_to_byte_idx(line_text, end_col),
)
}
pub(crate) fn paint(
popup: &RichTextPopup,
layout: &RichTextPopupLayout,
surface: &mut dyn PaintSurface,
theme: &Theme,
) -> Vec<(Rect, String)> {
let bounds = layout.bounds;
if bounds.width <= 0.0 || bounds.height <= 0.0 {
return Vec::new();
}
let bg = popup.bg.unwrap_or(theme.hover_bg);
let fg = popup.fg.unwrap_or(theme.hover_fg);
let border = if popup.has_focus {
theme.link_fg
} else {
theme.hover_border
};
surface.surface_fill_rect(bounds, bg);
surface.surface_stroke_rect(bounds, border, 1.0);
let content = layout.content_bounds;
surface.surface_push_clip(content);
let mut link_rects: Vec<(Rect, String)> = Vec::new();
for vis in &layout.visible_lines {
let line_idx = vis.line_idx;
let raw_text = popup
.line_text
.get(line_idx)
.map(String::as_str)
.unwrap_or("");
let Some(styled) = popup.lines.get(line_idx) else {
continue;
};
let (sel_start, sel_end) = popup
.selection
.map(|sel| selection_byte_range(sel, line_idx, raw_text))
.unwrap_or((0, 0));
if sel_end > sel_start {
let pre_w = surface.surface_measure_text(&raw_text[..sel_start]).0;
let sel_w = surface
.surface_measure_text(&raw_text[sel_start..sel_end])
.0;
let sel_rect = Rect::new(
vis.bounds.x + pre_w,
vis.bounds.y,
sel_w.max(1.0),
vis.bounds.height,
);
surface.surface_fill_rect(sel_rect, popup.fg.unwrap_or(theme.foreground));
}
let focused_underline_range = if popup.has_focus {
popup.focused_link.and_then(|idx| {
popup
.links
.get(idx)
.filter(|link| link.line == line_idx)
.map(|link| (link.start_byte, link.end_byte))
})
} else {
None
};
let mut byte_pos = 0usize;
let mut x = vis.bounds.x;
for span in &styled.spans {
let start = byte_pos;
let end = byte_pos + span.text.len();
let in_selection = sel_end > sel_start && start >= sel_start && end <= sel_end;
let color = if in_selection {
popup.bg.unwrap_or(theme.background)
} else {
span.fg.unwrap_or(fg)
};
let (w, _) = surface.surface_measure_text_styled(&span.text, span.bold);
let rect = Rect::new(x, vis.bounds.y, w.max(1.0), vis.bounds.height);
surface.surface_draw_text_run_styled(
rect,
&span.text,
color,
span.bold,
span.italic,
false,
1.0,
);
if let Some((us, ue)) = focused_underline_range {
if start < ue && end > us {
let uy = vis.bounds.y + vis.bounds.height - 2.0;
surface.surface_draw_line(
Point::new(x, uy),
Point::new(x + w, uy),
border,
1.0,
);
}
}
x += w;
byte_pos = end;
}
for link in popup.links.iter().filter(|l| l.line == line_idx) {
let pre_w = surface.surface_measure_text(&raw_text[..link.start_byte]).0;
let span_w = surface
.surface_measure_text(&raw_text[link.start_byte..link.end_byte])
.0;
let rect = Rect::new(
vis.bounds.x + pre_w,
vis.bounds.y,
span_w.max(1.0),
vis.bounds.height,
);
link_rects.push((rect, link.url.clone()));
}
}
surface.surface_pop_clip();
if let Some(sb) = layout.scrollbar {
let sb_x = bounds.x + bounds.width - SB_WIDTH - SB_INSET;
let track = Rect::new(sb_x, sb.track.y, SB_WIDTH, sb.track.height);
surface.surface_fill_rect(track, theme.muted_fg.with_alpha(0.3));
let thumb_top_off = sb.thumb.y - sb.track.y;
let thumb = Rect::new(
sb_x + 1.0,
sb.track.y + thumb_top_off,
SB_WIDTH - 2.0,
sb.thumb.height,
);
surface.surface_fill_rect(thumb, border);
}
link_rects
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::StyledText;
fn popup_with_scales(scales: &[f32]) -> RichTextPopup {
let n = scales.len();
RichTextPopup {
id: WidgetId::new("rtp:test"),
lines: (0..n)
.map(|i| StyledText::plain(format!("line{i}")))
.collect(),
line_text: (0..n).map(|i| format!("line{i}")).collect(),
line_scales: scales.to_vec(),
scroll_top: 0,
max_visible_rows: 20,
has_focus: false,
selection: None,
links: Vec::new(),
focused_link: None,
placement: PopupPlacement::Below,
padding: 0.0,
fg: None,
bg: None,
}
}
fn vp() -> Rect {
Rect::new(0.0, 0.0, 1000.0, 1000.0)
}
#[test]
fn scale_rows_false_keeps_flat_geometry() {
let popup = popup_with_scales(&[2.0, 1.5, 1.0]);
let measure = RichTextPopupMeasure::new(50.0, 10.0); let layout = popup.layout(0.0, 0.0, vp(), measure, |_, s, e| (e - s) as f32);
for (i, vis) in layout.visible_lines.iter().enumerate() {
assert!(
(vis.bounds.height - 10.0).abs() < f32::EPSILON,
"row {i} height should be flat 10.0, got {}",
vis.bounds.height
);
let expected_y = layout.content_bounds.y + i as f32 * 10.0;
assert!(
(vis.bounds.y - expected_y).abs() < f32::EPSILON,
"row {i} y should be {expected_y}, got {}",
vis.bounds.y
);
}
assert!((layout.content_bounds.height - 30.0).abs() < f32::EPSILON);
}
#[test]
fn scale_rows_true_reserves_proportional_height() {
let popup = popup_with_scales(&[2.0, 1.0]);
let measure = RichTextPopupMeasure::new(50.0, 10.0).with_scale_rows(true);
let layout = popup.layout(0.0, 0.0, vp(), measure, |_, s, e| (e - s) as f32);
let r0 = layout.visible_lines[0].bounds;
let r1 = layout.visible_lines[1].bounds;
assert!(
(r0.height - 20.0).abs() < f32::EPSILON,
"H1 row should be 2 * 10 = 20 tall, got {}",
r0.height
);
assert!(
(r1.y - (r0.y + r0.height)).abs() < f32::EPSILON,
"body row must start exactly below the H1 row (y={}, expected {})",
r1.y,
r0.y + r0.height
);
assert!(
(r1.height - 10.0).abs() < f32::EPSILON,
"body row should be 1 * 10 = 10 tall, got {}",
r1.height
);
assert!(
(layout.content_bounds.height - 30.0).abs() < f32::EPSILON,
"content height should sum scaled rows (30), got {}",
layout.content_bounds.height
);
}
#[test]
fn scale_rows_true_sums_all_three_heading_levels() {
let popup = popup_with_scales(&[2.0, 1.5, 1.2, 1.0]);
let measure = RichTextPopupMeasure::new(50.0, 10.0).with_scale_rows(true);
let layout = popup.layout(0.0, 0.0, vp(), measure, |_, s, e| (e - s) as f32);
let heights: Vec<f32> = layout
.visible_lines
.iter()
.map(|v| v.bounds.height)
.collect();
assert_eq!(heights.len(), 4);
let expected = [20.0_f32, 15.0, 12.0, 10.0];
for (got, want) in heights.iter().zip(expected) {
assert!((got - want).abs() < 1e-4, "row height {got} != {want}");
}
let mut acc = layout.content_bounds.y;
for (i, vis) in layout.visible_lines.iter().enumerate() {
assert!((vis.bounds.y - acc).abs() < 1e-4, "row {i} y mismatch");
acc += vis.bounds.height;
}
assert!((layout.content_bounds.height - 57.0).abs() < 1e-4); }
#[test]
fn scale_below_one_is_clamped() {
let popup = popup_with_scales(&[0.5]);
let measure = RichTextPopupMeasure::new(50.0, 10.0).with_scale_rows(true);
let layout = popup.layout(0.0, 0.0, vp(), measure, |_, s, e| (e - s) as f32);
assert!((layout.visible_lines[0].bounds.height - 10.0).abs() < f32::EPSILON);
}
#[test]
fn missing_scale_entry_defaults_to_one() {
let mut popup = popup_with_scales(&[2.0]);
popup.lines.push(StyledText::plain("line1"));
popup.line_text.push("line1".to_string());
let measure = RichTextPopupMeasure::new(50.0, 10.0).with_scale_rows(true);
let layout = popup.layout(0.0, 0.0, vp(), measure, |_, s, e| (e - s) as f32);
assert!((layout.visible_lines[0].bounds.height - 20.0).abs() < f32::EPSILON);
assert!((layout.visible_lines[1].bounds.height - 10.0).abs() < f32::EPSILON);
}
fn make_rich_popup(lines: usize, max_visible: usize, scroll: usize) -> RichTextPopup {
let line_text: Vec<String> = (0..lines).map(|i| format!("line {i:02}")).collect();
let lines_styled: Vec<StyledText> = line_text
.iter()
.map(|s| StyledText::plain(s.clone()))
.collect();
RichTextPopup {
id: WidgetId::new("hover"),
lines: lines_styled,
line_text,
line_scales: Vec::new(),
scroll_top: scroll,
max_visible_rows: max_visible,
has_focus: false,
selection: None,
links: Vec::new(),
focused_link: None,
placement: PopupPlacement::Above,
padding: 1.0,
fg: None,
bg: None,
}
}
#[test]
fn rich_text_popup_layout_visible_lines_window() {
let p = make_rich_popup(30, 10, 5);
let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
let layout = p.layout(
10.0,
100.0,
viewport,
RichTextPopupMeasure::new(80.0, 1.0),
|_, s, e| (e - s) as f32,
);
assert_eq!(layout.visible_lines.len(), 10);
assert_eq!(layout.visible_lines[0].line_idx, 5);
assert_eq!(layout.visible_lines[9].line_idx, 14);
assert_eq!(layout.resolved_scroll_offset, 5);
}
#[test]
fn rich_text_popup_layout_clamps_scroll_past_end() {
let p = make_rich_popup(30, 10, 999);
let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
let layout = p.layout(
0.0,
0.0,
viewport,
RichTextPopupMeasure::new(80.0, 1.0),
|_, s, e| (e - s) as f32,
);
assert_eq!(layout.resolved_scroll_offset, 20);
assert_eq!(layout.visible_lines.first().unwrap().line_idx, 20);
assert_eq!(layout.visible_lines.last().unwrap().line_idx, 29);
}
#[test]
fn rich_text_popup_scrollbar_present_when_overflow() {
let p = make_rich_popup(50, 10, 0);
let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
let layout = p.layout(
0.0,
0.0,
viewport,
RichTextPopupMeasure::new(80.0, 1.0),
|_, s, e| (e - s) as f32,
);
let sb = layout.scrollbar.expect("scrollbar should exist");
assert!(sb.thumb.height > 0.0);
assert!(sb.thumb.height < sb.track.height);
let p2 = make_rich_popup(5, 10, 0);
let layout2 = p2.layout(
0.0,
0.0,
viewport,
RichTextPopupMeasure::new(80.0, 1.0),
|_, s, e| (e - s) as f32,
);
assert!(layout2.scrollbar.is_none());
}
#[test]
fn rich_text_popup_link_hit_regions_for_visible_lines() {
let mut p = make_rich_popup(20, 10, 5);
p.links.push(RichTextLink {
line: 7,
start_byte: 5,
end_byte: 10,
url: "https://example.com".to_string(),
});
p.links.push(RichTextLink {
line: 2,
start_byte: 0,
end_byte: 4,
url: "off-screen".to_string(),
});
let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
let layout = p.layout(
10.0,
100.0,
viewport,
RichTextPopupMeasure::new(80.0, 1.0),
|_, s, e| (e - s) as f32,
);
assert_eq!(layout.link_hit_regions.len(), 1);
let (_, idx) = &layout.link_hit_regions[0];
assert_eq!(*idx, 0);
}
#[test]
fn text_selection_contains_single_and_multi_line() {
let single = TextSelection {
start_line: 3,
start_col: 5,
end_line: 3,
end_col: 10,
};
assert!(single.contains(3, 5));
assert!(single.contains(3, 9));
assert!(!single.contains(3, 10));
assert!(!single.contains(2, 5));
assert!(!single.contains(4, 5));
let multi = TextSelection {
start_line: 2,
start_col: 4,
end_line: 5,
end_col: 3,
};
assert!(!multi.contains(2, 3));
assert!(multi.contains(2, 4));
assert!(multi.contains(3, 0));
assert!(multi.contains(4, 100));
assert!(multi.contains(5, 0));
assert!(multi.contains(5, 2));
assert!(!multi.contains(5, 3));
assert!(!multi.contains(6, 0));
}
}