Skip to main content

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, Color, Element, Event, Layout, Length, Pixels, Point, Rectangle, Shell, Size, Vector,
12    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        shell: &mut Shell<'_, Message>,
349        _viewport: &Rectangle,
350    ) {
351        let Some(on_link_clicked) = &self.on_link_click else {
352            return;
353        };
354
355        let was_hovered = self.hovered_link.is_some();
356
357        if let Some(position) = cursor.position_in(layout.bounds()) {
358            let state = tree
359                .state
360                .downcast_ref::<State<Link, Renderer::Paragraph>>();
361
362            self.hovered_link = state.paragraph.hit_span(position).and_then(|span| {
363                if self.spans.as_ref().as_ref().get(span)?.link.is_some() {
364                    Some(span)
365                } else {
366                    None
367                }
368            });
369        } else {
370            self.hovered_link = None;
371        }
372
373        if was_hovered != self.hovered_link.is_some() {
374            shell.request_redraw();
375        }
376
377        match event {
378            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
379                let state = tree
380                    .state
381                    .downcast_mut::<State<Link, Renderer::Paragraph>>();
382
383                if self.hovered_link.is_some() {
384                    state.span_pressed = self.hovered_link;
385                    shell.capture_event();
386                }
387            }
388            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
389                let state = tree
390                    .state
391                    .downcast_mut::<State<Link, Renderer::Paragraph>>();
392
393                match state.span_pressed {
394                    Some(span) if Some(span) == self.hovered_link => {
395                        if let Some(link) = self
396                            .spans
397                            .as_ref()
398                            .as_ref()
399                            .get(span)
400                            .and_then(|span| span.link.clone())
401                        {
402                            shell.publish(on_link_clicked(link));
403                        }
404                    }
405                    _ => {}
406                }
407
408                state.span_pressed = None;
409            }
410            _ => {}
411        }
412    }
413
414    fn mouse_interaction(
415        &self,
416        _tree: &Tree,
417        _layout: Layout<'_>,
418        _cursor: mouse::Cursor,
419        _viewport: &Rectangle,
420        _renderer: &Renderer,
421    ) -> mouse::Interaction {
422        if self.hovered_link.is_some() {
423            mouse::Interaction::Pointer
424        } else {
425            mouse::Interaction::None
426        }
427    }
428}
429
430fn layout<Link, Renderer>(
431    state: &mut State<Link, Renderer::Paragraph>,
432    renderer: &Renderer,
433    limits: &layout::Limits,
434    width: Length,
435    height: Length,
436    spans: &[Span<'_, Link, Renderer::Font>],
437    line_height: LineHeight,
438    size: Option<Pixels>,
439    font: Option<Renderer::Font>,
440    align_x: Alignment,
441    align_y: alignment::Vertical,
442    wrapping: Wrapping,
443) -> layout::Node
444where
445    Link: Clone,
446    Renderer: core::text::Renderer,
447{
448    layout::sized(limits, width, height, |limits| {
449        let bounds = limits.max();
450
451        let size = size.unwrap_or_else(|| renderer.default_size());
452        let font = font.unwrap_or_else(|| renderer.default_font());
453
454        let text_with_spans = || core::Text {
455            content: spans,
456            bounds,
457            size,
458            line_height,
459            font,
460            align_x,
461            align_y,
462            shaping: Shaping::Advanced,
463            wrapping,
464            hint_factor: renderer.scale_factor(),
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                hint_factor: renderer.scale_factor(),
482            }) {
483                core::text::Difference::None => {}
484                core::text::Difference::Bounds => {
485                    state.paragraph.resize(bounds);
486                }
487                core::text::Difference::Shape => {
488                    state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
489                }
490            }
491        }
492
493        state.paragraph.min_bounds()
494    })
495}
496
497impl<'a, Link, Message, Theme, Renderer> FromIterator<Span<'a, Link, Renderer::Font>>
498    for Rich<'a, Link, Message, Theme, Renderer>
499where
500    Link: Clone + 'a,
501    Theme: Catalog,
502    Renderer: core::text::Renderer,
503    Renderer::Font: 'a,
504{
505    fn from_iter<T: IntoIterator<Item = Span<'a, Link, Renderer::Font>>>(spans: T) -> Self {
506        Self::with_spans(spans.into_iter().collect::<Vec<_>>())
507    }
508}
509
510impl<'a, Link, Message, Theme, Renderer> From<Rich<'a, Link, Message, Theme, Renderer>>
511    for Element<'a, Message, Theme, Renderer>
512where
513    Message: 'a,
514    Link: Clone + 'a,
515    Theme: Catalog + 'a,
516    Renderer: core::text::Renderer + 'a,
517{
518    fn from(
519        text: Rich<'a, Link, Message, Theme, Renderer>,
520    ) -> Element<'a, Message, Theme, Renderer> {
521        Element::new(text)
522    }
523}