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(&doc, width, self.default_font_size, resolver));
103 self.layout_width = width;
104 }
105}
106
107impl Widget for RichTextView {
108 fn type_name(&self) -> &'static str {
109 "RichTextView"
110 }
111 fn bounds(&self) -> Rect {
112 self.bounds
113 }
114 fn set_bounds(&mut self, b: Rect) {
115 self.bounds = b;
116 }
117 fn children(&self) -> &[Box<dyn Widget>] {
118 &self.children
119 }
120 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
121 &mut self.children
122 }
123
124 /// Read-only: never independently hittable, so pointer events reach an
125 /// enclosing scroll container unimpeded.
126 fn hit_test(&self, _: Point) -> bool {
127 false
128 }
129
130 fn layout(&mut self, available: Size) -> Size {
131 let width = available.width.max(1.0);
132 self.ensure_layout(width);
133 // Report the measured content width (which is at least `width`) so a
134 // block wider than the slot is surfaced to the parent instead of being
135 // reported as exactly the available width.
136 Size::new(self.content_width().max(width), self.content_height())
137 }
138
139 fn measure_min_height(&self, available_w: f64) -> f64 {
140 // Compute a throwaway layout at the requested width.
141 let resolver: &FontResolver = &*self.resolver;
142 let doc = self.doc.borrow();
143 layout_doc(&doc, available_w.max(1.0), self.default_font_size, resolver).height
144 }
145
146 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
147 self.ensure_layout(self.bounds.width.max(1.0));
148 let Some(layout) = &self.layout else {
149 return;
150 };
151 let h = self.bounds.height;
152 let default_color = ctx.visuals().text_color;
153
154 // Walk blocks/lines top-down, tracking distance from the document top.
155 let mut y_top = 0.0f64;
156 for block in &layout.blocks {
157 // List marker sits on the first line's baseline.
158 if let (Some(marker), Some(font), Some(first)) =
159 (&block.marker, &block.marker_font, block.lines.first())
160 {
161 let baseline_up = h - (y_top + first.baseline_from_top);
162 let color = block
163 .lines
164 .first()
165 .and_then(|l| l.fragments.first())
166 .and_then(|f| f.style.text_color)
167 .unwrap_or(default_color);
168 ctx.set_font(Arc::clone(font));
169 ctx.set_font_size(block.marker_font_size);
170 ctx.set_fill_color(color);
171 ctx.fill_text(marker, block.marker_x, baseline_up);
172 }
173
174 for line in &block.lines {
175 let line_top = y_top;
176 let baseline_up = h - (line_top + line.baseline_from_top);
177 let line_bottom_up = h - (line_top + line.height);
178 let text_x0 = block.text_left + line.align_dx;
179
180 for frag in &line.fragments {
181 let fx = text_x0 + frag.x;
182
183 // Highlight fill behind the fragment's line cell.
184 if let Some(hl) = frag.style.highlight {
185 ctx.set_fill_color(hl);
186 ctx.begin_path();
187 ctx.rect(fx, line_bottom_up, frag.width, line.height);
188 ctx.fill();
189 }
190
191 let color = frag.style.text_color.unwrap_or(default_color);
192 ctx.set_font(Arc::clone(&frag.font));
193 ctx.set_font_size(frag.font_size);
194 ctx.set_fill_color(color);
195 ctx.fill_text(&frag.text, fx, baseline_up);
196
197 // Decorations.
198 if frag.style.underline || frag.style.strikethrough {
199 ctx.set_stroke_color(color);
200 ctx.set_line_width(1.0_f64.max(frag.font_size * 0.06));
201 }
202 if frag.style.underline {
203 let uy = baseline_up - (frag.font_size * 0.12).max(1.5);
204 stroke_hline(ctx, fx, fx + frag.width, uy);
205 }
206 if frag.style.strikethrough {
207 let sy = baseline_up + frag.font_size * 0.28;
208 stroke_hline(ctx, fx, fx + frag.width, sy);
209 }
210 }
211 y_top += line.height;
212 }
213 }
214 }
215
216 fn margin(&self) -> Insets {
217 self.base.margin
218 }
219 fn widget_base(&self) -> Option<&WidgetBase> {
220 Some(&self.base)
221 }
222 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
223 Some(&mut self.base)
224 }
225 fn h_anchor(&self) -> HAnchor {
226 self.base.h_anchor
227 }
228 fn v_anchor(&self) -> VAnchor {
229 self.base.v_anchor
230 }
231
232 fn on_event(&mut self, _: &Event) -> EventResult {
233 EventResult::Ignored
234 }
235}
236
237/// Stroke a 1px-ish horizontal line at Y-up `y` from `x0` to `x1`.
238fn stroke_hline(ctx: &mut dyn DrawCtx, x0: f64, x1: f64, y: f64) {
239 ctx.begin_path();
240 ctx.move_to(x0, y);
241 ctx.line_to(x1, y);
242 ctx.stroke();
243}
244
245/// One-line resolver for the common single-font case: every run is shaped and
246/// measured with `font`, regardless of its [`InlineStyle`].
247///
248/// This is the fastest way to get a *working* editor on screen — pass it one
249/// [`Font`] and you have a functional [`RichTextView`] or
250/// [`RichTextEdit`](super::RichTextEdit). Bold / italic / family requests all
251/// fall back to `font`, so styled text still edits and lays out correctly; it
252/// simply renders in the one typeface until you supply a resolver that returns
253/// real variant faces (see the resolver contract below).
254///
255/// # Resolver contract
256///
257/// A resolver is any `Fn(&InlineStyle) -> Arc<Font>`. It maps a run's *style* to
258/// the concrete [`Font`] used to **shape and measure** that run. It is called
259/// once per run during layout. What each input field means:
260///
261/// * [`bold`](InlineStyle::bold) / [`italic`](InlineStyle::italic) — return the
262/// matching real face if you have one (e.g. an Italic `.ttf`). The layout
263/// engine applies **no** faux-bold / faux-slant, so bold/italic only *look*
264/// different when the resolver hands back a genuinely different face.
265/// * [`font_family`](InlineStyle::font_family) — `Some(name)` selects a family;
266/// `None` means "the app default". Map unknown families to your base font.
267/// * [`font_size`](InlineStyle::font_size) — **ignore this.** Size is applied by
268/// the layout engine, not the resolver; a resolver picks the *typeface* only.
269/// * `underline` / `strikethrough` / `text_color` / `highlight` — decorations
270/// the painter draws; not the resolver's concern.
271///
272/// **Returning the base font for any style is always safe** — text still shapes,
273/// measures, wraps, and edits; it just won't show that style's typeface. Build
274/// up from there face by face.
275///
276/// For a full multi-font resolver backed by a system-font catalog with real
277/// bold/italic faces, see the demo's `make_resolver` in
278/// `demo-ui/src/windows/rich_text_demo`.
279pub fn single_font_resolver(font: Arc<Font>) -> SharedResolver {
280 Rc::new(move |_: &InlineStyle| Arc::clone(&font))
281}
282
283/// Deprecated alias for [`single_font_resolver`], kept for source compatibility.
284///
285/// Prefer [`single_font_resolver`], whose name states the single-typeface intent
286/// and whose docs spell out the full resolver contract.
287pub fn uniform_resolver(font: Arc<Font>) -> SharedResolver {
288 single_font_resolver(font)
289}