iced_widget/text/
rich.rs

1use crate::core::alignment;
2use crate::core::layout;
3use crate::core::mouse;
4use crate::core::renderer;
5use crate::core::text::{Paragraph, Span};
6use crate::core::widget::text::{
7    self, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn, Wrapping,
8};
9use crate::core::widget::tree::{self, Tree};
10use crate::core::{
11    self, Clipboard, Color, Element, Event, Layout, Length, Pixels, Point,
12    Rectangle, Shell, Size, Vector, Widget,
13};
14
15/// A bunch of [`Rich`] text.
16#[allow(missing_debug_implementations)]
17pub struct Rich<
18    'a,
19    Link,
20    Message,
21    Theme = crate::Theme,
22    Renderer = crate::Renderer,
23> where
24    Link: Clone + 'static,
25    Theme: Catalog,
26    Renderer: core::text::Renderer,
27{
28    spans: Box<dyn AsRef<[Span<'a, Link, Renderer::Font>]> + 'a>,
29    size: Option<Pixels>,
30    line_height: LineHeight,
31    width: Length,
32    height: Length,
33    font: Option<Renderer::Font>,
34    align_x: Alignment,
35    align_y: alignment::Vertical,
36    wrapping: Wrapping,
37    class: Theme::Class<'a>,
38    hovered_link: Option<usize>,
39    on_link_click: Option<Box<dyn Fn(Link) -> Message + 'a>>,
40}
41
42impl<'a, Link, Message, Theme, Renderer>
43    Rich<'a, Link, Message, Theme, Renderer>
44where
45    Link: Clone + 'static,
46    Theme: Catalog,
47    Renderer: core::text::Renderer,
48    Renderer::Font: 'a,
49{
50    /// Creates a new empty [`Rich`] text.
51    pub fn new() -> Self {
52        Self {
53            spans: Box::new([]),
54            size: None,
55            line_height: LineHeight::default(),
56            width: Length::Shrink,
57            height: Length::Shrink,
58            font: None,
59            align_x: Alignment::Default,
60            align_y: alignment::Vertical::Top,
61            wrapping: Wrapping::default(),
62            class: Theme::default(),
63            hovered_link: None,
64            on_link_click: None,
65        }
66    }
67
68    /// Creates a new [`Rich`] text with the given text spans.
69    pub fn with_spans(
70        spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
71    ) -> Self {
72        Self {
73            spans: Box::new(spans),
74            ..Self::new()
75        }
76    }
77
78    /// Sets the default size of the [`Rich`] text.
79    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
80        self.size = Some(size.into());
81        self
82    }
83
84    /// Sets the default [`LineHeight`] of the [`Rich`] text.
85    pub fn line_height(mut self, line_height: impl Into<LineHeight>) -> Self {
86        self.line_height = line_height.into();
87        self
88    }
89
90    /// Sets the default font of the [`Rich`] text.
91    pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
92        self.font = Some(font.into());
93        self
94    }
95
96    /// Sets the width of the [`Rich`] text boundaries.
97    pub fn width(mut self, width: impl Into<Length>) -> Self {
98        self.width = width.into();
99        self
100    }
101
102    /// Sets the height of the [`Rich`] text boundaries.
103    pub fn height(mut self, height: impl Into<Length>) -> Self {
104        self.height = height.into();
105        self
106    }
107
108    /// Centers the [`Rich`] text, both horizontally and vertically.
109    pub fn center(self) -> Self {
110        self.align_x(alignment::Horizontal::Center)
111            .align_y(alignment::Vertical::Center)
112    }
113
114    /// Sets the [`alignment::Horizontal`] of the [`Rich`] text.
115    pub fn align_x(mut self, alignment: impl Into<Alignment>) -> Self {
116        self.align_x = alignment.into();
117        self
118    }
119
120    /// Sets the [`alignment::Vertical`] of the [`Rich`] text.
121    pub fn align_y(
122        mut self,
123        alignment: impl Into<alignment::Vertical>,
124    ) -> Self {
125        self.align_y = alignment.into();
126        self
127    }
128
129    /// Sets the [`Wrapping`] strategy of the [`Rich`] text.
130    pub fn wrapping(mut self, wrapping: Wrapping) -> Self {
131        self.wrapping = wrapping;
132        self
133    }
134
135    /// Sets the message that will be produced when a link of the [`Rich`] text
136    /// is clicked.
137    pub fn on_link_click(
138        mut self,
139        on_link_clicked: impl Fn(Link) -> Message + 'a,
140    ) -> Self {
141        self.on_link_click = Some(Box::new(on_link_clicked));
142        self
143    }
144
145    /// Sets the default style of the [`Rich`] text.
146    #[must_use]
147    pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
148    where
149        Theme::Class<'a>: From<StyleFn<'a, Theme>>,
150    {
151        self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
152        self
153    }
154
155    /// Sets the default [`Color`] of the [`Rich`] text.
156    pub fn color(self, color: impl Into<Color>) -> Self
157    where
158        Theme::Class<'a>: From<StyleFn<'a, Theme>>,
159    {
160        self.color_maybe(Some(color))
161    }
162
163    /// Sets the default [`Color`] of the [`Rich`] text, if `Some`.
164    pub fn color_maybe(self, color: Option<impl Into<Color>>) -> Self
165    where
166        Theme::Class<'a>: From<StyleFn<'a, Theme>>,
167    {
168        let color = color.map(Into::into);
169
170        self.style(move |_theme| Style { color })
171    }
172
173    /// Sets the default style class of the [`Rich`] text.
174    #[cfg(feature = "advanced")]
175    #[must_use]
176    pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
177        self.class = class.into();
178        self
179    }
180}
181
182impl<'a, Link, Message, Theme, Renderer> Default
183    for Rich<'a, Link, Message, Theme, Renderer>
184where
185    Link: Clone + 'a,
186    Theme: Catalog,
187    Renderer: core::text::Renderer,
188    Renderer::Font: 'a,
189{
190    fn default() -> Self {
191        Self::new()
192    }
193}
194
195struct State<Link, P: Paragraph> {
196    spans: Vec<Span<'static, Link, P::Font>>,
197    span_pressed: Option<usize>,
198    paragraph: P,
199}
200
201impl<Link, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
202    for Rich<'_, Link, Message, Theme, Renderer>
203where
204    Link: Clone + 'static,
205    Theme: Catalog,
206    Renderer: core::text::Renderer,
207{
208    fn tag(&self) -> tree::Tag {
209        tree::Tag::of::<State<Link, Renderer::Paragraph>>()
210    }
211
212    fn state(&self) -> tree::State {
213        tree::State::new(State::<Link, _> {
214            spans: Vec::new(),
215            span_pressed: None,
216            paragraph: Renderer::Paragraph::default(),
217        })
218    }
219
220    fn size(&self) -> Size<Length> {
221        Size {
222            width: self.width,
223            height: self.height,
224        }
225    }
226
227    fn layout(
228        &self,
229        tree: &mut Tree,
230        renderer: &Renderer,
231        limits: &layout::Limits,
232    ) -> layout::Node {
233        layout(
234            tree.state
235                .downcast_mut::<State<Link, Renderer::Paragraph>>(),
236            renderer,
237            limits,
238            self.width,
239            self.height,
240            self.spans.as_ref().as_ref(),
241            self.line_height,
242            self.size,
243            self.font,
244            self.align_x,
245            self.align_y,
246            self.wrapping,
247        )
248    }
249
250    fn draw(
251        &self,
252        tree: &Tree,
253        renderer: &mut Renderer,
254        theme: &Theme,
255        defaults: &renderer::Style,
256        layout: Layout<'_>,
257        _cursor: mouse::Cursor,
258        viewport: &Rectangle,
259    ) {
260        if !layout.bounds().intersects(viewport) {
261            return;
262        }
263
264        let state = tree
265            .state
266            .downcast_ref::<State<Link, Renderer::Paragraph>>();
267
268        let style = theme.style(&self.class);
269
270        for (index, span) in self.spans.as_ref().as_ref().iter().enumerate() {
271            let is_hovered_link = self.on_link_click.is_some()
272                && Some(index) == self.hovered_link;
273
274            if span.highlight.is_some()
275                || span.underline
276                || span.strikethrough
277                || is_hovered_link
278            {
279                let translation = layout.position() - Point::ORIGIN;
280                let regions = state.paragraph.span_bounds(index);
281
282                if let Some(highlight) = span.highlight {
283                    for bounds in &regions {
284                        let bounds = Rectangle::new(
285                            bounds.position()
286                                - Vector::new(
287                                    span.padding.left,
288                                    span.padding.top,
289                                ),
290                            bounds.size()
291                                + Size::new(
292                                    span.padding.horizontal(),
293                                    span.padding.vertical(),
294                                ),
295                        );
296
297                        renderer.fill_quad(
298                            renderer::Quad {
299                                bounds: bounds + translation,
300                                border: highlight.border,
301                                ..Default::default()
302                            },
303                            highlight.background,
304                        );
305                    }
306                }
307
308                if span.underline || span.strikethrough || is_hovered_link {
309                    let size = span
310                        .size
311                        .or(self.size)
312                        .unwrap_or(renderer.default_size());
313
314                    let line_height = span
315                        .line_height
316                        .unwrap_or(self.line_height)
317                        .to_absolute(size);
318
319                    let color = span
320                        .color
321                        .or(style.color)
322                        .unwrap_or(defaults.text_color);
323
324                    let baseline = translation
325                        + Vector::new(
326                            0.0,
327                            size.0 + (line_height.0 - size.0) / 2.0,
328                        );
329
330                    if span.underline || is_hovered_link {
331                        for bounds in &regions {
332                            renderer.fill_quad(
333                                renderer::Quad {
334                                    bounds: Rectangle::new(
335                                        bounds.position() + baseline
336                                            - Vector::new(0.0, size.0 * 0.08),
337                                        Size::new(bounds.width, 1.0),
338                                    ),
339                                    ..Default::default()
340                                },
341                                color,
342                            );
343                        }
344                    }
345
346                    if span.strikethrough {
347                        for bounds in &regions {
348                            renderer.fill_quad(
349                                renderer::Quad {
350                                    bounds: Rectangle::new(
351                                        bounds.position() + baseline
352                                            - Vector::new(0.0, size.0 / 2.0),
353                                        Size::new(bounds.width, 1.0),
354                                    ),
355                                    ..Default::default()
356                                },
357                                color,
358                            );
359                        }
360                    }
361                }
362            }
363        }
364
365        text::draw(
366            renderer,
367            defaults,
368            layout,
369            &state.paragraph,
370            style,
371            viewport,
372        );
373    }
374
375    fn update(
376        &mut self,
377        tree: &mut Tree,
378        event: &Event,
379        layout: Layout<'_>,
380        cursor: mouse::Cursor,
381        _renderer: &Renderer,
382        _clipboard: &mut dyn Clipboard,
383        shell: &mut Shell<'_, Message>,
384        _viewport: &Rectangle,
385    ) {
386        let Some(on_link_clicked) = &self.on_link_click else {
387            return;
388        };
389
390        let was_hovered = self.hovered_link.is_some();
391
392        if let Some(position) = cursor.position_in(layout.bounds()) {
393            let state = tree
394                .state
395                .downcast_ref::<State<Link, Renderer::Paragraph>>();
396
397            self.hovered_link =
398                state.paragraph.hit_span(position).and_then(|span| {
399                    if self.spans.as_ref().as_ref().get(span)?.link.is_some() {
400                        Some(span)
401                    } else {
402                        None
403                    }
404                });
405        } else {
406            self.hovered_link = None;
407        }
408
409        if was_hovered != self.hovered_link.is_some() {
410            shell.request_redraw();
411        }
412
413        match event {
414            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
415                let state = tree
416                    .state
417                    .downcast_mut::<State<Link, Renderer::Paragraph>>();
418
419                if self.hovered_link.is_some() {
420                    state.span_pressed = self.hovered_link;
421                    shell.capture_event();
422                }
423            }
424            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
425                let state = tree
426                    .state
427                    .downcast_mut::<State<Link, Renderer::Paragraph>>();
428
429                match state.span_pressed {
430                    Some(span) if Some(span) == self.hovered_link => {
431                        if let Some(link) = self
432                            .spans
433                            .as_ref()
434                            .as_ref()
435                            .get(span)
436                            .and_then(|span| span.link.clone())
437                        {
438                            shell.publish(on_link_clicked(link));
439                        }
440                    }
441                    _ => {}
442                }
443
444                state.span_pressed = None;
445            }
446            _ => {}
447        }
448    }
449
450    fn mouse_interaction(
451        &self,
452        _tree: &Tree,
453        _layout: Layout<'_>,
454        _cursor: mouse::Cursor,
455        _viewport: &Rectangle,
456        _renderer: &Renderer,
457    ) -> mouse::Interaction {
458        if self.hovered_link.is_some() {
459            mouse::Interaction::Pointer
460        } else {
461            mouse::Interaction::None
462        }
463    }
464}
465
466fn layout<Link, Renderer>(
467    state: &mut State<Link, Renderer::Paragraph>,
468    renderer: &Renderer,
469    limits: &layout::Limits,
470    width: Length,
471    height: Length,
472    spans: &[Span<'_, Link, Renderer::Font>],
473    line_height: LineHeight,
474    size: Option<Pixels>,
475    font: Option<Renderer::Font>,
476    align_x: Alignment,
477    align_y: alignment::Vertical,
478    wrapping: Wrapping,
479) -> layout::Node
480where
481    Link: Clone,
482    Renderer: core::text::Renderer,
483{
484    layout::sized(limits, width, height, |limits| {
485        let bounds = limits.max();
486
487        let size = size.unwrap_or_else(|| renderer.default_size());
488        let font = font.unwrap_or_else(|| renderer.default_font());
489
490        let text_with_spans = || core::Text {
491            content: spans,
492            bounds,
493            size,
494            line_height,
495            font,
496            align_x,
497            align_y,
498            shaping: Shaping::Advanced,
499            wrapping,
500        };
501
502        if state.spans != spans {
503            state.paragraph =
504                Renderer::Paragraph::with_spans(text_with_spans());
505            state.spans = spans.iter().cloned().map(Span::to_static).collect();
506        } else {
507            match state.paragraph.compare(core::Text {
508                content: (),
509                bounds,
510                size,
511                line_height,
512                font,
513                align_x,
514                align_y,
515                shaping: Shaping::Advanced,
516                wrapping,
517            }) {
518                core::text::Difference::None => {}
519                core::text::Difference::Bounds => {
520                    state.paragraph.resize(bounds);
521                }
522                core::text::Difference::Shape => {
523                    state.paragraph =
524                        Renderer::Paragraph::with_spans(text_with_spans());
525                }
526            }
527        }
528
529        state.paragraph.min_bounds()
530    })
531}
532
533impl<'a, Link, Message, Theme, Renderer>
534    FromIterator<Span<'a, Link, Renderer::Font>>
535    for Rich<'a, Link, Message, Theme, Renderer>
536where
537    Link: Clone + 'a,
538    Theme: Catalog,
539    Renderer: core::text::Renderer,
540    Renderer::Font: 'a,
541{
542    fn from_iter<T: IntoIterator<Item = Span<'a, Link, Renderer::Font>>>(
543        spans: T,
544    ) -> Self {
545        Self::with_spans(spans.into_iter().collect::<Vec<_>>())
546    }
547}
548
549impl<'a, Link, Message, Theme, Renderer>
550    From<Rich<'a, Link, Message, Theme, Renderer>>
551    for Element<'a, Message, Theme, Renderer>
552where
553    Message: 'a,
554    Link: Clone + 'a,
555    Theme: Catalog + 'a,
556    Renderer: core::text::Renderer + 'a,
557{
558    fn from(
559        text: Rich<'a, Link, Message, Theme, Renderer>,
560    ) -> Element<'a, Message, Theme, Renderer> {
561        Element::new(text)
562    }
563}