agg_gui/widgets/rich_text/view.rs
1//! `RichTextView` — a **read-only** widget that paints a laid-out [`RichDoc`].
2//!
3//! Phase 1 scope: no editing, no cursor, no interaction. The widget shares its
4//! document via `Rc<RefCell<RichDoc>>` (phase 2 will wrap that in the existing
5//! undo stack and add a keyboard editor on top). It reports its content height
6//! from `layout`, so it drops cleanly into a [`ScrollView`](crate::widgets::ScrollView).
7//!
8//! Painting walks the [`DocLayout`] top-down and flips
9//! into the framework's Y-up space: per-run font/size/colour via `ctx.fill_text`,
10//! highlight as a filled rect behind the run, underline / strikethrough as 1px
11//! lines at metric-derived offsets, and list markers hung in the left gutter.
12//!
13//! # Caching note
14//!
15//! The layout is recomputed whenever the available width changes. Because the
16//! document lives behind a shared `RefCell`, external mutations do **not**
17//! auto-invalidate; call [`RichTextView::invalidate`] after editing the doc.
18//! Phase 2 should replace this with a document revision counter.
19
20use std::cell::RefCell;
21use std::rc::Rc;
22use std::sync::Arc;
23
24use super::layout::{layout_doc, DocLayout, FontResolver};
25use super::model::{InlineStyle, RichDoc};
26use crate::draw_ctx::DrawCtx;
27use crate::event::{Event, EventResult};
28use crate::geometry::{Point, Rect, Size};
29use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
30use crate::text::Font;
31use crate::widget::Widget;
32
33/// Shared font resolver: maps a run's [`InlineStyle`] to a concrete font.
34pub type SharedResolver = Rc<dyn Fn(&InlineStyle) -> Arc<Font>>;
35
36/// A non-interactive rich-text display.
37pub struct RichTextView {
38 bounds: Rect,
39 children: Vec<Box<dyn Widget>>, // always empty
40 base: WidgetBase,
41 doc: Rc<RefCell<RichDoc>>,
42 resolver: SharedResolver,
43 default_font_size: f64,
44 /// Cached layout + the width it was computed for.
45 layout: Option<DocLayout>,
46 layout_width: f64,
47}
48
49impl RichTextView {
50 /// Create a read-only view over `doc`, resolving run fonts via `resolver`.
51 pub fn new(doc: Rc<RefCell<RichDoc>>, resolver: SharedResolver) -> Self {
52 Self {
53 bounds: Rect::default(),
54 children: Vec::new(),
55 base: WidgetBase::new(),
56 doc,
57 resolver,
58 default_font_size: 16.0,
59 layout: None,
60 layout_width: -1.0,
61 }
62 }
63
64 /// Set the default font size (points) runs inherit when their style leaves
65 /// [`InlineStyle::font_size`](super::model::InlineStyle::font_size) unset.
66 pub fn with_default_font_size(mut self, size: f64) -> Self {
67 self.default_font_size = size;
68 self
69 }
70
71 /// Set the widget's outer margin.
72 pub fn with_margin(mut self, m: Insets) -> Self {
73 self.base.margin = m;
74 self
75 }
76
77 /// Drop the cached layout so the next `layout` recomputes it — call after
78 /// mutating the shared document.
79 pub fn invalidate(&mut self) {
80 self.layout = None;
81 self.layout_width = -1.0;
82 }
83
84 /// Content height at the last laid-out width (0 before first layout).
85 pub fn content_height(&self) -> f64 {
86 self.layout.as_ref().map(|l| l.height).unwrap_or(0.0)
87 }
88
89 /// Measured content width from the last layout (0 before first layout).
90 /// This is `max(available_width, widest_block)`, so a block wider than the
91 /// slot is surfaced rather than silently clipped to the available width.
92 pub fn content_width(&self) -> f64 {
93 self.layout.as_ref().map(|l| l.width).unwrap_or(0.0)
94 }
95
96 fn ensure_layout(&mut self, width: f64) {
97 if self.layout.is_some() && (self.layout_width - width).abs() < 0.5 {
98 return;
99 }
100 let resolver: &FontResolver = &*self.resolver;
101 let doc = self.doc.borrow();
102 self.layout = Some(layout_doc(
103 &doc,
104 width,
105 self.default_font_size,
106 resolver,
107 ));
108 self.layout_width = width;
109 }
110}
111
112impl Widget for RichTextView {
113 fn type_name(&self) -> &'static str {
114 "RichTextView"
115 }
116 fn bounds(&self) -> Rect {
117 self.bounds
118 }
119 fn set_bounds(&mut self, b: Rect) {
120 self.bounds = b;
121 }
122 fn children(&self) -> &[Box<dyn Widget>] {
123 &self.children
124 }
125 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
126 &mut self.children
127 }
128
129 /// Read-only: never independently hittable, so pointer events reach an
130 /// enclosing scroll container unimpeded.
131 fn hit_test(&self, _: Point) -> bool {
132 false
133 }
134
135 fn layout(&mut self, available: Size) -> Size {
136 let width = available.width.max(1.0);
137 self.ensure_layout(width);
138 // Report the measured content width (which is at least `width`) so a
139 // block wider than the slot is surfaced to the parent instead of being
140 // reported as exactly the available width.
141 Size::new(self.content_width().max(width), self.content_height())
142 }
143
144 fn measure_min_height(&self, available_w: f64) -> f64 {
145 // Compute a throwaway layout at the requested width.
146 let resolver: &FontResolver = &*self.resolver;
147 let doc = self.doc.borrow();
148 layout_doc(&doc, available_w.max(1.0), self.default_font_size, resolver).height
149 }
150
151 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
152 self.ensure_layout(self.bounds.width.max(1.0));
153 let Some(layout) = &self.layout else {
154 return;
155 };
156 let h = self.bounds.height;
157 let default_color = ctx.visuals().text_color;
158
159 // Walk blocks/lines top-down, tracking distance from the document top.
160 let mut y_top = 0.0f64;
161 for block in &layout.blocks {
162 // List marker sits on the first line's baseline.
163 if let (Some(marker), Some(font), Some(first)) =
164 (&block.marker, &block.marker_font, block.lines.first())
165 {
166 let baseline_up = h - (y_top + first.baseline_from_top);
167 let color = block
168 .lines
169 .first()
170 .and_then(|l| l.fragments.first())
171 .and_then(|f| f.style.text_color)
172 .unwrap_or(default_color);
173 ctx.set_font(Arc::clone(font));
174 ctx.set_font_size(block.marker_font_size);
175 ctx.set_fill_color(color);
176 ctx.fill_text(marker, block.marker_x, baseline_up);
177 }
178
179 for line in &block.lines {
180 let line_top = y_top;
181 let baseline_up = h - (line_top + line.baseline_from_top);
182 let line_bottom_up = h - (line_top + line.height);
183 let text_x0 = block.text_left + line.align_dx;
184
185 for frag in &line.fragments {
186 let fx = text_x0 + frag.x;
187
188 // Highlight fill behind the fragment's line cell.
189 if let Some(hl) = frag.style.highlight {
190 ctx.set_fill_color(hl);
191 ctx.begin_path();
192 ctx.rect(fx, line_bottom_up, frag.width, line.height);
193 ctx.fill();
194 }
195
196 let color = frag.style.text_color.unwrap_or(default_color);
197 ctx.set_font(Arc::clone(&frag.font));
198 ctx.set_font_size(frag.font_size);
199 ctx.set_fill_color(color);
200 ctx.fill_text(&frag.text, fx, baseline_up);
201
202 // Decorations.
203 if frag.style.underline || frag.style.strikethrough {
204 ctx.set_stroke_color(color);
205 ctx.set_line_width(1.0_f64.max(frag.font_size * 0.06));
206 }
207 if frag.style.underline {
208 let uy = baseline_up - (frag.font_size * 0.12).max(1.5);
209 stroke_hline(ctx, fx, fx + frag.width, uy);
210 }
211 if frag.style.strikethrough {
212 let sy = baseline_up + frag.font_size * 0.28;
213 stroke_hline(ctx, fx, fx + frag.width, sy);
214 }
215 }
216 y_top += line.height;
217 }
218 }
219 }
220
221 fn margin(&self) -> Insets {
222 self.base.margin
223 }
224 fn widget_base(&self) -> Option<&WidgetBase> {
225 Some(&self.base)
226 }
227 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
228 Some(&mut self.base)
229 }
230 fn h_anchor(&self) -> HAnchor {
231 self.base.h_anchor
232 }
233 fn v_anchor(&self) -> VAnchor {
234 self.base.v_anchor
235 }
236
237 fn on_event(&mut self, _: &Event) -> EventResult {
238 EventResult::Ignored
239 }
240}
241
242/// Stroke a 1px-ish horizontal line at Y-up `y` from `x0` to `x1`.
243fn stroke_hline(ctx: &mut dyn DrawCtx, x0: f64, x1: f64, y: f64) {
244 ctx.begin_path();
245 ctx.move_to(x0, y);
246 ctx.line_to(x1, y);
247 ctx.stroke();
248}
249
250/// One-line resolver for the common single-font case: every run is shaped and
251/// measured with `font`, regardless of its [`InlineStyle`].
252///
253/// This is the fastest way to get a *working* editor on screen — pass it one
254/// [`Font`] and you have a functional [`RichTextView`] or
255/// [`RichTextEdit`](super::RichTextEdit). Bold / italic / family requests all
256/// fall back to `font`, so styled text still edits and lays out correctly; it
257/// simply renders in the one typeface until you supply a resolver that returns
258/// real variant faces (see the resolver contract below).
259///
260/// # Resolver contract
261///
262/// A resolver is any `Fn(&InlineStyle) -> Arc<Font>`. It maps a run's *style* to
263/// the concrete [`Font`] used to **shape and measure** that run. It is called
264/// once per run during layout. What each input field means:
265///
266/// * [`bold`](InlineStyle::bold) / [`italic`](InlineStyle::italic) — return the
267/// matching real face if you have one (e.g. an Italic `.ttf`). The layout
268/// engine applies **no** faux-bold / faux-slant, so bold/italic only *look*
269/// different when the resolver hands back a genuinely different face.
270/// * [`font_family`](InlineStyle::font_family) — `Some(name)` selects a family;
271/// `None` means "the app default". Map unknown families to your base font.
272/// * [`font_size`](InlineStyle::font_size) — **ignore this.** Size is applied by
273/// the layout engine, not the resolver; a resolver picks the *typeface* only.
274/// * `underline` / `strikethrough` / `text_color` / `highlight` — decorations
275/// the painter draws; not the resolver's concern.
276///
277/// **Returning the base font for any style is always safe** — text still shapes,
278/// measures, wraps, and edits; it just won't show that style's typeface. Build
279/// up from there face by face.
280///
281/// For a full multi-font resolver backed by a system-font catalog with real
282/// bold/italic faces, see the demo's `make_resolver` in
283/// `demo-ui/src/windows/rich_text_demo`.
284pub fn single_font_resolver(font: Arc<Font>) -> SharedResolver {
285 Rc::new(move |_: &InlineStyle| Arc::clone(&font))
286}
287
288/// Deprecated alias for [`single_font_resolver`], kept for source compatibility.
289///
290/// Prefer [`single_font_resolver`], whose name states the single-typeface intent
291/// and whose docs spell out the full resolver contract.
292pub fn uniform_resolver(font: Arc<Font>) -> SharedResolver {
293 single_font_resolver(font)
294}