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(
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}