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