Skip to main content

sequoia_view/
rich_text.rs

1use std::rc::Rc;
2
3use sequoia_core::Edges;
4use sequoia_core::{Color, Length, Size};
5use sequoia_input::{CursorIcon, Event, EventPhase, PointerButton, PointerEvent};
6use sequoia_layout::{LayoutCtx, LayoutStyle};
7use sequoia_text::{LetterSpacing, LineHeight, RichAlign, TextSpan, TextVAlign, WrapMode};
8
9use crate::view::{EventCtx, PaintCtx, View};
10use crate::views::{Unit, text_wrap_limit};
11
12pub struct RichText {
13    spans: Box<dyn Fn() -> Vec<TextSpan>>,
14    font_size: Option<f32>,
15    color: Option<Color>,
16    wrap_width: Option<f32>,
17    wrap_mode: WrapMode,
18    align: RichAlign,
19    align_y: TextVAlign,
20    line_height: LineHeight,
21    max_lines: Option<usize>,
22    font_scale: f32,
23    auto_wrap: bool,
24    letter_spacing: f32,
25    on_span_click: Option<Rc<dyn Fn(usize)>>,
26    style: LayoutStyle,
27}
28
29impl RichText {
30    pub fn new(spans: Vec<TextSpan>) -> Self {
31        Self {
32            spans: Box::new(move || spans.clone()),
33            font_size: None,
34            color: None,
35            wrap_width: None,
36            wrap_mode: WrapMode::default(),
37            align: RichAlign::Left,
38            align_y: TextVAlign::Top,
39            line_height: LineHeight::default(),
40            max_lines: None,
41            font_scale: 1.0,
42            auto_wrap: false,
43            letter_spacing: 0.0,
44            on_span_click: None,
45            style: LayoutStyle {
46                width: Length::Fit,
47                height: Length::Fit,
48                ..LayoutStyle::default()
49            },
50        }
51    }
52
53    pub fn with(spans: impl Fn() -> Vec<TextSpan> + 'static) -> Self {
54        Self {
55            spans: Box::new(spans),
56            font_size: None,
57            color: None,
58            wrap_width: None,
59            wrap_mode: WrapMode::default(),
60            align: RichAlign::Left,
61            align_y: TextVAlign::Top,
62            line_height: LineHeight::default(),
63            max_lines: None,
64            font_scale: 1.0,
65            auto_wrap: false,
66            letter_spacing: 0.0,
67            on_span_click: None,
68            style: LayoutStyle {
69                width: Length::Fit,
70                height: Length::Fit,
71                ..LayoutStyle::default()
72            },
73        }
74    }
75
76    pub fn spans(&self) -> Vec<TextSpan> {
77        (self.spans)()
78    }
79
80    pub fn span(mut self, span: TextSpan) -> Self {
81        let prev = std::mem::replace(&mut self.spans, Box::new(Vec::new));
82        self.spans = Box::new(move || {
83            let mut out = prev();
84            out.push(span.clone());
85            out
86        });
87        self
88    }
89
90    pub fn size(mut self, size: f32) -> Self {
91        self.font_size = Some(size);
92        self
93    }
94
95    pub fn color(mut self, color: Color) -> Self {
96        self.color = Some(color);
97        self
98    }
99
100    pub fn wrap_width(mut self, width: f32) -> Self {
101        self.wrap_width = if width > 0.0 { Some(width) } else { None };
102        self
103    }
104
105    pub fn wrap_mode(mut self, mode: WrapMode) -> Self {
106        self.wrap_mode = mode;
107        self
108    }
109
110    /// Enables wrapping at the width available from the parent container.
111    pub fn wrap(mut self) -> Self {
112        self.auto_wrap = true;
113        self
114    }
115
116    /// Sets the line height as a multiple of the font size.
117    pub fn line_height(mut self, factor: f32) -> Self {
118        if factor.is_finite() && factor > 0.0 {
119            self.line_height = LineHeight::Factor(factor);
120        }
121        self
122    }
123
124    /// Sets an absolute line height in logical pixels.
125    pub fn line_height_px(mut self, px: f32) -> Self {
126        if px.is_finite() && px > 0.0 {
127            self.line_height = LineHeight::Absolute(px);
128        }
129        self
130    }
131
132    /// Sets vertical alignment within the text box.
133    pub fn align_y(mut self, align: TextVAlign) -> Self {
134        self.align_y = align;
135        self
136    }
137
138    /// Limits visible wrapped text to `lines`, appending an ellipsis.
139    pub fn max_lines(mut self, lines: usize) -> Self {
140        self.max_lines = if lines == 0 { None } else { Some(lines) };
141        self
142    }
143
144    /// Scales the resolved font size without changing layout styles.
145    pub fn font_scale(mut self, scale: f32) -> Self {
146        if scale.is_finite() && scale > 0.0 {
147            self.font_scale = scale;
148        }
149        self
150    }
151
152    pub fn align(mut self, align: RichAlign) -> Self {
153        self.align = align;
154        self
155    }
156
157    pub fn centered(mut self) -> Self {
158        self.align = RichAlign::Center;
159        self
160    }
161
162    pub fn right_aligned(mut self) -> Self {
163        self.align = RichAlign::Right;
164        self
165    }
166
167    pub fn letter_spacing(mut self, spacing: LetterSpacing) -> Self {
168        self.letter_spacing = if spacing.is_finite() { spacing } else { 0.0 };
169        self
170    }
171
172    pub fn on_span_click(mut self, handler: impl Fn(usize) + 'static) -> Self {
173        self.on_span_click = Some(Rc::new(handler));
174        self
175    }
176
177    pub fn width(mut self, width: impl Into<Length>) -> Self {
178        self.style = self.style.width(width.into());
179        self
180    }
181
182    pub fn height(mut self, height: impl Into<Length>) -> Self {
183        self.style = self.style.height(height.into());
184        self
185    }
186
187    pub fn min_width(mut self, v: f32) -> Self {
188        self.style = self.style.min_width(v);
189        self
190    }
191
192    pub fn max_width(mut self, v: f32) -> Self {
193        self.style = self.style.max_width(v);
194        self
195    }
196
197    pub fn min_height(mut self, v: f32) -> Self {
198        self.style = self.style.min_height(v);
199        self
200    }
201
202    pub fn max_height(mut self, v: f32) -> Self {
203        self.style = self.style.max_height(v);
204        self
205    }
206
207    pub fn flex(mut self, weight: f32) -> Self {
208        self.style = self.style.flex(weight);
209        self
210    }
211
212    pub fn expand(self) -> Self {
213        self.flex(1.0)
214    }
215
216    pub fn fill_width(mut self) -> Self {
217        self.style = self.style.width(Length::Fill(1.0));
218        self
219    }
220
221    pub fn fill_height(mut self) -> Self {
222        self.style = self.style.height(Length::Fill(1.0));
223        self
224    }
225
226    pub fn fill_size(self) -> Self {
227        self.fill_width().fill_height()
228    }
229
230    pub fn padding(mut self, padding: impl Into<Edges>) -> Self {
231        self.style = self.style.padding(padding);
232        self
233    }
234
235    pub fn pad(self, padding: impl Into<Edges>) -> Self {
236        self.padding(padding)
237    }
238
239    pub fn padding_xy(mut self, x: f32, y: f32) -> Self {
240        self.style = self.style.padding_xy(x, y);
241        self
242    }
243
244    fn resolved(&self, theme_size: f32) -> (f32, f32) {
245        (
246            self.font_size.unwrap_or(theme_size) * self.font_scale,
247            self.wrap_width.unwrap_or(0.0),
248        )
249    }
250}
251
252impl View for RichText {
253    fn body(&self) -> impl View + 'static {
254        let _ = (self.spans)();
255        Unit
256    }
257
258    fn is_leaf(&self) -> bool {
259        true
260    }
261
262    fn clip_self(&self) -> bool {
263        true
264    }
265
266    fn cursor_icon(&self, _state: sequoia_style::VisualState) -> CursorIcon {
267        if self.on_span_click.is_some() {
268            CursorIcon::Pointer
269        } else {
270            CursorIcon::Default
271        }
272    }
273
274    fn layout_style(&self) -> LayoutStyle {
275        self.style
276    }
277
278    fn intrinsic_size(&self, ctx: &mut LayoutCtx) -> Option<Size> {
279        let (size, _wrap) = self.resolved(ctx.default_font_size());
280        let spans = (self.spans)();
281        let pad = self.style.padding;
282        let auto_wrap = if self.auto_wrap {
283            text_wrap_limit(self.style, ctx.available_width())
284        } else {
285            None
286        };
287        let mut width = self.wrap_width.or(auto_wrap).unwrap_or(0.0);
288        if self.max_lines.is_some() && width <= 0.0 {
289            width = ctx
290                .rich_size_lh(
291                    &spans,
292                    size,
293                    0.0,
294                    self.wrap_mode,
295                    self.letter_spacing,
296                    self.align,
297                    self.line_height,
298                )
299                .width
300                .max(1.0);
301        }
302        let shown = match self.max_lines {
303            Some(max_lines) => ctx.rich_elide_lines(
304                &spans,
305                size,
306                width,
307                self.wrap_mode,
308                self.letter_spacing,
309                self.align,
310                self.line_height,
311                max_lines,
312            ),
313            None => spans,
314        };
315        let text = ctx.rich_size_lh(
316            &shown,
317            size,
318            width,
319            self.wrap_mode,
320            self.letter_spacing,
321            self.align,
322            self.line_height,
323        );
324        Some(Size::new(
325            text.width + pad.horizontal_sum(),
326            text.height + pad.vertical_sum(),
327        ))
328    }
329
330    fn baseline_offset(&self, ctx: &mut LayoutCtx, width: f32) -> Option<f32> {
331        let (size, wrap) = self.resolved(ctx.default_font_size());
332        let spans = (self.spans)();
333        let pad = self.style.padding;
334        let width = if wrap > 0.0 {
335            wrap
336        } else {
337            (width - pad.horizontal_sum()).max(0.0)
338        };
339        let baseline = ctx.rich_first_baseline(
340            &spans,
341            size,
342            width,
343            self.wrap_mode,
344            self.letter_spacing,
345            self.align,
346            self.line_height,
347        );
348        Some(pad.top + baseline)
349    }
350
351    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
352        if ctx.phase != EventPhase::Target {
353            return;
354        }
355        let Some(handler) = &self.on_span_click else {
356            return;
357        };
358        if let Event::Pointer(PointerEvent::Down {
359            button: PointerButton::Left,
360            pos,
361            ..
362        }) = event
363        {
364            let size = self.font_size.unwrap_or_else(|| {
365                ctx.environment
366                    .get::<sequoia_style::Theme>()
367                    .map(|t| t.font_size)
368                    .unwrap_or(14.0)
369            }) * self.font_scale;
370            let spans = (self.spans)();
371            let pad = self.style.padding;
372            let wrap = self.wrap_width.unwrap_or_else(|| {
373                if self.auto_wrap {
374                    (ctx.rect.size.width - pad.horizontal_sum()).max(0.0)
375                } else {
376                    0.0
377                }
378            });
379            let shown = match self.max_lines {
380                Some(max_lines) => ctx.text.elide_rich_lines(
381                    &spans,
382                    size,
383                    wrap,
384                    self.wrap_mode,
385                    self.letter_spacing,
386                    self.align,
387                    self.line_height,
388                    max_lines,
389                ),
390                None => spans,
391            };
392            let rich = ctx.text.shape_rich_full_lh(
393                &shown,
394                size,
395                wrap,
396                self.wrap_mode,
397                self.letter_spacing,
398                self.align,
399                self.line_height,
400            );
401            let text_h = rich.height();
402            let first_baseline = rich.line(0).map(|line| line.baseline).unwrap_or(0.0);
403            let block_top = match self.align_y {
404                TextVAlign::Baseline => pad.top - first_baseline,
405                TextVAlign::Top => pad.top,
406                TextVAlign::Center => {
407                    pad.top + (ctx.rect.size.height - pad.vertical_sum() - text_h) * 0.5
408                }
409                TextVAlign::Bottom => {
410                    pad.top + (ctx.rect.size.height - pad.vertical_sum() - text_h)
411                }
412                _ => pad.top,
413            };
414            let hit = rich.hit_point(
415                pos.x - ctx.rect.left() - pad.left,
416                pos.y - ctx.rect.top() - block_top,
417            );
418            if let Some(span) = hit.span {
419                handler(span);
420                ctx.handled();
421            }
422        }
423    }
424
425    fn paint_self(&self, ctx: &mut PaintCtx) {
426        let theme = ctx.theme();
427        let (size, wrap) = self.resolved(theme.font_size);
428        let color = self.color.unwrap_or(theme.text);
429        let spans = (self.spans)();
430        if spans.iter().all(|s| s.text.is_empty()) {
431            return;
432        }
433        let pad = self.style.padding;
434        let content_w = (ctx.rect.size.width - pad.horizontal_sum()).max(0.0);
435        let wrap = if wrap > 0.0 {
436            wrap
437        } else if self.auto_wrap {
438            content_w
439        } else {
440            0.0
441        };
442        let shown = match self.max_lines {
443            Some(max_lines) => ctx.elide_rich_lines(
444                &spans,
445                size,
446                wrap,
447                self.wrap_mode,
448                self.letter_spacing,
449                self.align,
450                self.line_height,
451                max_lines,
452            ),
453            None => spans,
454        };
455        let rich = ctx.shape_rich_full_lh(
456            &shown,
457            size,
458            wrap,
459            self.wrap_mode,
460            self.letter_spacing,
461            self.align,
462            self.line_height,
463        );
464        let text_h = rich.height();
465        let first_baseline = rich.line(0).map(|line| line.baseline).unwrap_or(0.0);
466        let block_top = match self.align_y {
467            TextVAlign::Baseline => ctx.rect.top() + pad.top - first_baseline,
468            TextVAlign::Top => ctx.rect.top() + pad.top,
469            TextVAlign::Center => {
470                ctx.rect.top()
471                    + pad.top
472                    + (ctx.rect.size.height - pad.vertical_sum() - text_h) * 0.5
473            }
474            TextVAlign::Bottom => {
475                ctx.rect.top() + pad.top + (ctx.rect.size.height - pad.vertical_sum() - text_h)
476            }
477            _ => ctx.rect.top() + pad.top,
478        };
479        ctx.draw_rich(&rich, (ctx.rect.left() + pad.left, block_top), color);
480    }
481}