Skip to main content

i_slint_core/
styled_text.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4#[derive(Clone, Debug, PartialEq)]
5/// Styles that can be applied to text spans
6#[allow(missing_docs, dead_code)]
7pub(crate) enum Style {
8    Emphasis,
9    Strong,
10    Strikethrough,
11    Code,
12    Link,
13    Underline,
14    Color(crate::Color),
15}
16
17#[derive(Clone, Debug, PartialEq)]
18/// A style and a text span
19pub(crate) struct FormattedSpan {
20    /// Span of text to style
21    pub(crate) range: core::ops::Range<usize>,
22    /// The style to apply
23    pub(crate) style: Style,
24}
25
26#[cfg(feature = "std")]
27#[derive(Clone, Debug)]
28enum ListItemType {
29    Ordered(u64),
30    Unordered,
31}
32
33/// A section of styled text, split up by a linebreak
34#[derive(Clone, Debug, PartialEq)]
35pub(crate) struct StyledTextParagraph {
36    /// The raw paragraph text
37    pub(crate) text: alloc::string::String,
38    /// Formatting styles and spans
39    pub(crate) formatting: alloc::vec::Vec<FormattedSpan>,
40    /// Locations of clickable links within the paragraph
41    pub(crate) links: alloc::vec::Vec<(core::ops::Range<usize>, alloc::string::String)>,
42}
43
44/// Error type returned by `StyledText::parse`
45#[cfg(feature = "std")]
46#[derive(Debug, thiserror::Error)]
47#[non_exhaustive]
48pub enum StyledTextError<'a> {
49    /// Spans are unbalanced: stack already empty when popped
50    #[error("Spans are unbalanced: stack already empty when popped")]
51    Pop,
52    /// Spans are unbalanced: stack contained items at end of function
53    #[error("Spans are unbalanced: stack contained items at end of function")]
54    NotEmpty,
55    /// Paragraph not started
56    #[error("Paragraph not started")]
57    ParagraphNotStarted,
58    /// Unimplemented markdown tag
59    #[error("Unimplemented: {:?}", .0)]
60    UnimplementedTag(pulldown_cmark::Tag<'a>),
61    /// Unimplemented markdown event
62    #[error("Unimplemented: {:?}", .0)]
63    UnimplementedEvent(pulldown_cmark::Event<'a>),
64    /// Unimplemented html event
65    #[error("Unimplemented: {}", .0)]
66    UnimplementedHtmlEvent(alloc::string::String),
67    /// Unimplemented html tag
68    #[error("Unimplemented html tag: {}", .0)]
69    UnimplementedHtmlTag(alloc::string::String),
70    /// Unimplemented html attribute
71    #[error("Unexpected {} attribute in html {}", .0, .1)]
72    UnexpectedAttribute(alloc::string::String, alloc::string::String),
73    /// Missing color attribute in html
74    #[error("Missing color attribute in html {}", .0)]
75    MissingColor(alloc::string::String),
76    /// Closing html tag doesn't match the opening tag
77    #[error("Closing html tag doesn't match the opening tag. Expected {}, got {}", .0, .1)]
78    ClosingTagMismatch(&'a str, alloc::string::String),
79}
80
81/// Styled text that has been parsed and seperated into paragraphs
82#[repr(transparent)]
83#[derive(Debug, PartialEq, Clone, Default)]
84pub struct StyledText {
85    /// Paragraphs of styled text
86    pub(crate) paragraphs: crate::SharedVector<StyledTextParagraph>,
87}
88
89#[cfg(feature = "std")]
90impl StyledText {
91    /// Parse a markdown string as styled text
92    pub fn parse(string: &str) -> Result<Self, StyledTextError<'_>> {
93        let parser =
94            pulldown_cmark::Parser::new_ext(string, pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
95
96        let mut paragraphs = alloc::vec::Vec::new();
97        let mut list_state_stack: alloc::vec::Vec<Option<u64>> = alloc::vec::Vec::new();
98        let mut style_stack = alloc::vec::Vec::new();
99        let mut current_url = None;
100
101        let begin_paragraph = |paragraphs: &mut alloc::vec::Vec<StyledTextParagraph>,
102                               indentation: u32,
103                               list_item_type: Option<ListItemType>| {
104            let mut text = alloc::string::String::with_capacity(indentation as usize * 4);
105            for _ in 0..indentation {
106                text.push_str("    ");
107            }
108            match list_item_type {
109                Some(ListItemType::Unordered) => {
110                    if indentation % 3 == 0 {
111                        text.push_str("• ")
112                    } else if indentation % 3 == 1 {
113                        text.push_str("◦ ")
114                    } else {
115                        text.push_str("▪ ")
116                    }
117                }
118                Some(ListItemType::Ordered(num)) => text.push_str(&alloc::format!("{}. ", num)),
119                None => {}
120            };
121            paragraphs.push(StyledTextParagraph {
122                text,
123                formatting: Default::default(),
124                links: Default::default(),
125            });
126        };
127
128        for event in parser {
129            let indentation = list_state_stack.len().saturating_sub(1) as _;
130
131            match event {
132                pulldown_cmark::Event::SoftBreak | pulldown_cmark::Event::HardBreak => {
133                    begin_paragraph(&mut paragraphs, indentation, None);
134                }
135                pulldown_cmark::Event::End(pulldown_cmark::TagEnd::List(_)) => {
136                    if list_state_stack.pop().is_none() {
137                        return Err(StyledTextError::Pop);
138                    }
139                }
140                pulldown_cmark::Event::End(
141                    pulldown_cmark::TagEnd::Paragraph | pulldown_cmark::TagEnd::Item,
142                ) => {}
143                pulldown_cmark::Event::Start(tag) => {
144                    let style = match tag {
145                        pulldown_cmark::Tag::Paragraph => {
146                            begin_paragraph(&mut paragraphs, indentation, None);
147                            continue;
148                        }
149                        pulldown_cmark::Tag::Item => {
150                            begin_paragraph(
151                                &mut paragraphs,
152                                indentation,
153                                Some(match list_state_stack.last().copied() {
154                                    Some(Some(index)) => ListItemType::Ordered(index),
155                                    _ => ListItemType::Unordered,
156                                }),
157                            );
158                            if let Some(state) = list_state_stack.last_mut() {
159                                *state = state.map(|state| state + 1);
160                            }
161                            continue;
162                        }
163                        pulldown_cmark::Tag::List(index) => {
164                            list_state_stack.push(index);
165                            continue;
166                        }
167                        pulldown_cmark::Tag::Strong => Style::Strong,
168                        pulldown_cmark::Tag::Emphasis => Style::Emphasis,
169                        pulldown_cmark::Tag::Strikethrough => Style::Strikethrough,
170                        pulldown_cmark::Tag::Link { dest_url, .. } => {
171                            current_url = Some(dest_url);
172                            Style::Link
173                        }
174
175                        pulldown_cmark::Tag::Heading { .. }
176                        | pulldown_cmark::Tag::Image { .. }
177                        | pulldown_cmark::Tag::DefinitionList
178                        | pulldown_cmark::Tag::DefinitionListTitle
179                        | pulldown_cmark::Tag::DefinitionListDefinition
180                        | pulldown_cmark::Tag::TableHead
181                        | pulldown_cmark::Tag::TableRow
182                        | pulldown_cmark::Tag::TableCell
183                        | pulldown_cmark::Tag::HtmlBlock
184                        | pulldown_cmark::Tag::Superscript
185                        | pulldown_cmark::Tag::Subscript
186                        | pulldown_cmark::Tag::Table(_)
187                        | pulldown_cmark::Tag::MetadataBlock(_)
188                        | pulldown_cmark::Tag::BlockQuote(_)
189                        | pulldown_cmark::Tag::CodeBlock(_)
190                        | pulldown_cmark::Tag::FootnoteDefinition(_) => {
191                            return Err(StyledTextError::UnimplementedTag(tag));
192                        }
193                    };
194
195                    style_stack.push((
196                        style,
197                        paragraphs.last().ok_or(StyledTextError::ParagraphNotStarted)?.text.len(),
198                    ));
199                }
200                pulldown_cmark::Event::Text(text) => {
201                    paragraphs
202                        .last_mut()
203                        .ok_or(StyledTextError::ParagraphNotStarted)?
204                        .text
205                        .push_str(&text);
206                }
207                pulldown_cmark::Event::End(_) => {
208                    let (style, start) = if let Some(value) = style_stack.pop() {
209                        value
210                    } else {
211                        return Err(StyledTextError::Pop);
212                    };
213
214                    let paragraph =
215                        paragraphs.last_mut().ok_or(StyledTextError::ParagraphNotStarted)?;
216                    let end = paragraph.text.len();
217
218                    if let Some(url) = current_url.take() {
219                        paragraph.links.push((start..end, url.into()));
220                    }
221
222                    paragraph.formatting.push(FormattedSpan { range: start..end, style });
223                }
224                pulldown_cmark::Event::Code(text) => {
225                    let paragraph =
226                        paragraphs.last_mut().ok_or(StyledTextError::ParagraphNotStarted)?;
227                    let start = paragraph.text.len();
228                    paragraph.text.push_str(&text);
229                    paragraph.formatting.push(FormattedSpan {
230                        range: start..paragraph.text.len(),
231                        style: Style::Code,
232                    });
233                }
234                pulldown_cmark::Event::InlineHtml(html) => {
235                    if html.starts_with("</") {
236                        let (style, start) = if let Some(value) = style_stack.pop() {
237                            value
238                        } else {
239                            return Err(StyledTextError::Pop);
240                        };
241
242                        let expected_tag = match &style {
243                            Style::Color(_) => "</font>",
244                            Style::Underline => "</u>",
245                            other => std::unreachable!(
246                                "Got unexpected closing style {:?} with html {}. This error should have been caught earlier.",
247                                other,
248                                html
249                            ),
250                        };
251
252                        if (&*html) != expected_tag {
253                            return Err(StyledTextError::ClosingTagMismatch(
254                                expected_tag,
255                                (&*html).into(),
256                            ));
257                        }
258
259                        let paragraph =
260                            paragraphs.last_mut().ok_or(StyledTextError::ParagraphNotStarted)?;
261                        let end = paragraph.text.len();
262                        paragraph.formatting.push(FormattedSpan { range: start..end, style });
263                    } else {
264                        let mut expecting_color_attribute = false;
265
266                        for token in htmlparser::Tokenizer::from(&*html) {
267                            match token {
268                                Ok(htmlparser::Token::ElementStart { local: tag_type, .. }) => {
269                                    match &*tag_type {
270                                        "u" => {
271                                            style_stack.push((
272                                                Style::Underline,
273                                                paragraphs
274                                                    .last()
275                                                    .ok_or(StyledTextError::ParagraphNotStarted)?
276                                                    .text
277                                                    .len(),
278                                            ));
279                                        }
280                                        "font" => {
281                                            expecting_color_attribute = true;
282                                        }
283                                        _ => {
284                                            return Err(StyledTextError::UnimplementedHtmlTag(
285                                                (&*tag_type).into(),
286                                            ));
287                                        }
288                                    }
289                                }
290                                Ok(htmlparser::Token::Attribute {
291                                    local: key,
292                                    value: Some(value),
293                                    ..
294                                }) => match &*key {
295                                    "color" => {
296                                        if !expecting_color_attribute {
297                                            return Err(StyledTextError::UnexpectedAttribute(
298                                                (&*key).into(),
299                                                (&*html).into(),
300                                            ));
301                                        }
302                                        expecting_color_attribute = false;
303
304                                        let value =
305                                            i_slint_common::color_parsing::parse_color_literal(
306                                                &*value,
307                                            )
308                                            .or_else(|| {
309                                                i_slint_common::color_parsing::named_colors()
310                                                    .get(&*value)
311                                                    .copied()
312                                            })
313                                            .expect("invalid color value");
314
315                                        style_stack.push((
316                                            Style::Color(crate::Color::from_argb_encoded(value)),
317                                            paragraphs
318                                                .last()
319                                                .ok_or(StyledTextError::ParagraphNotStarted)?
320                                                .text
321                                                .len(),
322                                        ));
323                                    }
324                                    _ => {
325                                        return Err(StyledTextError::UnexpectedAttribute(
326                                            (&*key).into(),
327                                            (&*html).into(),
328                                        ));
329                                    }
330                                },
331                                Ok(htmlparser::Token::ElementEnd { .. }) => {}
332                                _ => {
333                                    return Err(StyledTextError::UnimplementedHtmlEvent(
334                                        alloc::format!("{:?}", token),
335                                    ));
336                                }
337                            }
338                        }
339
340                        if expecting_color_attribute {
341                            return Err(StyledTextError::MissingColor((&*html).into()));
342                        }
343                    }
344                }
345                pulldown_cmark::Event::Rule
346                | pulldown_cmark::Event::TaskListMarker(_)
347                | pulldown_cmark::Event::FootnoteReference(_)
348                | pulldown_cmark::Event::InlineMath(_)
349                | pulldown_cmark::Event::DisplayMath(_)
350                | pulldown_cmark::Event::Html(_) => {
351                    return Err(StyledTextError::UnimplementedEvent(event));
352                }
353            }
354        }
355
356        if !style_stack.is_empty() {
357            return Err(StyledTextError::NotEmpty);
358        }
359
360        Ok(StyledText { paragraphs: (&paragraphs[..]).into() })
361    }
362}
363
364#[test]
365fn markdown_parsing() {
366    assert_eq!(
367        StyledText::parse("hello *world*").unwrap().paragraphs,
368        [StyledTextParagraph {
369            text: "hello world".into(),
370            formatting: alloc::vec![FormattedSpan { range: 6..11, style: Style::Emphasis }],
371            links: alloc::vec::Vec::new()
372        }]
373    );
374
375    assert_eq!(
376        StyledText::parse(
377            "
378- line 1
379- line 2
380            "
381        )
382        .unwrap()
383        .paragraphs,
384        [
385            StyledTextParagraph {
386                text: "• line 1".into(),
387                formatting: alloc::vec::Vec::new(),
388                links: alloc::vec::Vec::new()
389            },
390            StyledTextParagraph {
391                text: "• line 2".into(),
392                formatting: alloc::vec::Vec::new(),
393                links: alloc::vec::Vec::new()
394            }
395        ]
396    );
397
398    assert_eq!(
399        StyledText::parse(
400            "
4011. a
4022. b
4034. c
404        "
405        )
406        .unwrap()
407        .paragraphs,
408        [
409            StyledTextParagraph {
410                text: "1. a".into(),
411                formatting: alloc::vec::Vec::new(),
412                links: alloc::vec::Vec::new()
413            },
414            StyledTextParagraph {
415                text: "2. b".into(),
416                formatting: alloc::vec::Vec::new(),
417                links: alloc::vec::Vec::new()
418            },
419            StyledTextParagraph {
420                text: "3. c".into(),
421                formatting: alloc::vec::Vec::new(),
422                links: alloc::vec::Vec::new()
423            }
424        ]
425    );
426
427    assert_eq!(
428        StyledText::parse(
429            "
430Normal _italic_ **strong** ~~strikethrough~~ `code`
431new *line*
432"
433        )
434        .unwrap()
435        .paragraphs,
436        [
437            StyledTextParagraph {
438                text: "Normal italic strong strikethrough code".into(),
439                formatting: alloc::vec![
440                    FormattedSpan { range: 7..13, style: Style::Emphasis },
441                    FormattedSpan { range: 14..20, style: Style::Strong },
442                    FormattedSpan { range: 21..34, style: Style::Strikethrough },
443                    FormattedSpan { range: 35..39, style: Style::Code }
444                ],
445                links: alloc::vec::Vec::new()
446            },
447            StyledTextParagraph {
448                text: "new line".into(),
449                formatting: alloc::vec![FormattedSpan { range: 4..8, style: Style::Emphasis },],
450                links: alloc::vec::Vec::new()
451            }
452        ]
453    );
454
455    assert_eq!(
456        StyledText::parse(
457            "
458- root
459  - child
460    - grandchild
461      - great grandchild
462"
463        )
464        .unwrap()
465        .paragraphs,
466        [
467            StyledTextParagraph {
468                text: "• root".into(),
469                formatting: alloc::vec::Vec::new(),
470                links: alloc::vec::Vec::new()
471            },
472            StyledTextParagraph {
473                text: "    ◦ child".into(),
474                formatting: alloc::vec::Vec::new(),
475                links: alloc::vec::Vec::new()
476            },
477            StyledTextParagraph {
478                text: "        ▪ grandchild".into(),
479                formatting: alloc::vec::Vec::new(),
480                links: alloc::vec::Vec::new()
481            },
482            StyledTextParagraph {
483                text: "            • great grandchild".into(),
484                formatting: alloc::vec::Vec::new(),
485                links: alloc::vec::Vec::new()
486            },
487        ]
488    );
489
490    assert_eq!(
491        StyledText::parse("hello [*world*](https://example.com)").unwrap().paragraphs,
492        [StyledTextParagraph {
493            text: "hello world".into(),
494            formatting: alloc::vec![
495                FormattedSpan { range: 6..11, style: Style::Emphasis },
496                FormattedSpan { range: 6..11, style: Style::Link }
497            ],
498            links: alloc::vec![(6..11, "https://example.com".into())]
499        }]
500    );
501
502    assert_eq!(
503        StyledText::parse("<u>hello world</u>").unwrap().paragraphs,
504        [StyledTextParagraph {
505            text: "hello world".into(),
506            formatting: alloc::vec![FormattedSpan { range: 0..11, style: Style::Underline },],
507            links: alloc::vec::Vec::new()
508        }]
509    );
510
511    assert_eq!(
512        StyledText::parse(r#"<font color="blue">hello world</font>"#).unwrap().paragraphs,
513        [StyledTextParagraph {
514            text: "hello world".into(),
515            formatting: alloc::vec![FormattedSpan {
516                range: 0..11,
517                style: Style::Color(crate::Color::from_rgb_u8(0, 0, 255))
518            },],
519            links: alloc::vec::Vec::new()
520        }]
521    );
522
523    assert_eq!(
524        StyledText::parse(r#"<u><font color="red">hello world</font></u>"#).unwrap().paragraphs,
525        [StyledTextParagraph {
526            text: "hello world".into(),
527            formatting: alloc::vec![
528                FormattedSpan {
529                    range: 0..11,
530                    style: Style::Color(crate::Color::from_rgb_u8(255, 0, 0))
531                },
532                FormattedSpan { range: 0..11, style: Style::Underline },
533            ],
534            links: alloc::vec::Vec::new()
535        }]
536    );
537}
538
539pub fn get_raw_text(styled_text: &StyledText) -> alloc::borrow::Cow<'_, str> {
540    match styled_text.paragraphs.as_slice() {
541        [] => "".into(),
542        [paragraph] => paragraph.text.as_str().into(),
543        _ => {
544            let mut result = alloc::string::String::new();
545            for paragraph in styled_text.paragraphs.iter() {
546                if !result.is_empty() {
547                    result.push('\n');
548                }
549                result.push_str(paragraph.text.as_str());
550            }
551            result.into()
552        }
553    }
554}
555
556/// Bindings for cbindgen
557#[cfg(feature = "ffi")]
558pub mod ffi {
559    #![allow(unsafe_code)]
560
561    use super::*;
562
563    #[unsafe(no_mangle)]
564    /// Create a new default styled text
565    pub unsafe extern "C" fn slint_styled_text_new(out: *mut StyledText) {
566        unsafe {
567            core::ptr::write(out, Default::default());
568        }
569    }
570
571    #[unsafe(no_mangle)]
572    /// Destroy the shared string
573    pub unsafe extern "C" fn slint_styled_text_drop(text: *const StyledText) {
574        unsafe {
575            core::ptr::read(text);
576        }
577    }
578
579    #[unsafe(no_mangle)]
580    /// Returns true if \a a is equal to \a b; otherwise returns false.
581    pub extern "C" fn slint_styled_text_eq(a: &StyledText, b: &StyledText) -> bool {
582        a == b
583    }
584
585    #[unsafe(no_mangle)]
586    /// Clone the styled text
587    pub unsafe extern "C" fn slint_styled_text_clone(out: *mut StyledText, ss: &StyledText) {
588        unsafe { core::ptr::write(out, ss.clone()) }
589    }
590}
591
592pub fn escape_markdown(text: &str) -> alloc::string::String {
593    let mut out = alloc::string::String::with_capacity(text.len());
594
595    for c in text.chars() {
596        match c {
597            '*' => out.push_str("\\*"),
598            '<' => out.push_str("&lt;"),
599            '>' => out.push_str("&gt;"),
600            '_' => out.push_str("\\_"),
601            '#' => out.push_str("\\#"),
602            '-' => out.push_str("\\-"),
603            '`' => out.push_str("\\`"),
604            '&' => out.push_str("\\&"),
605            _ => out.push(c),
606        }
607    }
608
609    out
610}
611
612pub fn parse_markdown(_text: &str) -> StyledText {
613    #[cfg(feature = "std")]
614    {
615        StyledText::parse(_text).unwrap()
616    }
617    #[cfg(not(feature = "std"))]
618    Default::default()
619}