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