Skip to main content

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}