waterui_text/
styled.rs

1use core::{fmt::Display, mem::take, ops::Add};
2
3use crate::{
4    font::{Font, FontWeight},
5    text,
6};
7use alloc::{string::String, vec, vec::Vec};
8use core::ops::AddAssign;
9use nami::impl_constant;
10use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag};
11use waterui_color::Color;
12use waterui_core::{Str, View};
13
14/// A set of text attributes for rich text formatting.
15#[derive(Debug, Clone, Default)]
16pub struct Style {
17    /// The font to use.
18    pub font: Font,
19    /// The foreground (text) color.
20    pub foreground: Option<Color>,
21    /// The background color.
22    pub background: Option<Color>,
23    /// Whether the text is italic.
24    pub italic: bool,
25    /// Whether the text has an underline.
26    pub underline: bool,
27    /// Whether the text has a strikethrough.
28    pub strikethrough: bool,
29}
30
31impl Style {
32    /// Creates a new default `Style`.
33    #[must_use]
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    /// Sets the font.
39    #[must_use]
40    pub fn font(mut self, font: impl Into<Font>) -> Self {
41        self.font = font.into();
42        self
43    }
44
45    /// Sets the text color.
46    #[must_use]
47    pub fn foreground(mut self, color: impl Into<Color>) -> Self {
48        self.foreground = Some(color.into());
49        self
50    }
51
52    /// Sets the background color.
53    #[must_use]
54    pub fn background(mut self, color: impl Into<Color>) -> Self {
55        self.background = Some(color.into());
56        self
57    }
58
59    /// Sets the font weight.
60    #[must_use]
61    pub fn weight(mut self, weight: FontWeight) -> Self {
62        self.font = self.font.weight(weight);
63        self
64    }
65
66    /// Sets the bold style.
67    /// Equal to calling `self.weight(FontWeight::Bold)`.
68    #[must_use]
69    pub fn bold(mut self) -> Self {
70        self.font = self.font.bold();
71        self
72    }
73
74    /// Sets the font size in points.
75    #[must_use]
76    pub fn size(mut self, size: f32) -> Self {
77        self.font = self.font.size(size);
78        self
79    }
80
81    /// Sets the italic style.
82    #[must_use]
83    pub const fn italic(mut self) -> Self {
84        self.italic = true;
85        self
86    }
87
88    /// Disables the italic style.
89    #[must_use]
90    pub const fn not_italic(mut self) -> Self {
91        self.italic = false;
92        self
93    }
94
95    /// Sets the underline style.
96    #[must_use]
97    pub const fn underline(mut self) -> Self {
98        self.underline = true;
99        self
100    }
101
102    /// Disables the underline style.
103    #[must_use]
104    pub const fn not_underline(mut self) -> Self {
105        self.underline = false;
106        self
107    }
108
109    /// Sets the strikethrough style.
110    #[must_use]
111    pub const fn strikethrough(mut self) -> Self {
112        self.strikethrough = true;
113        self
114    }
115
116    /// Disables the strikethrough style.
117    #[must_use]
118    pub const fn not_strikethrough(mut self) -> Self {
119        self.strikethrough = false;
120        self
121    }
122}
123
124/// A string with associated text attributes for rich text formatting.
125#[derive(Debug, Clone, Default)]
126pub struct StyledStr {
127    chunks: Vec<(Str, Style)>,
128}
129
130impl StyledStr {
131    /// Creates a new empty `StyledStr`.
132    #[must_use]
133    pub const fn empty() -> Self {
134        Self { chunks: Vec::new() }
135    }
136
137    /// Creates a styled string from a subset of Markdown.
138    ///
139    /// Supported features include headings, bold, and italic text. Other
140    /// Markdown constructs are preserved as plain text.
141    #[must_use]
142    pub fn from_markdown(markdown: &str) -> Self {
143        let options =
144            Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH;
145        let parser = Parser::new_ext(markdown, options);
146
147        let mut builder = MarkdownInlineBuilder::new();
148        let mut pending_block_break = false;
149
150        for event in parser {
151            match event {
152                Event::Start(tag) => match tag {
153                    Tag::Heading { level, .. } => {
154                        if pending_block_break || !builder.is_empty() {
155                            builder.push_text("\n\n");
156                        }
157                        pending_block_break = false;
158                        builder.enter_with(move |_| heading_style(level));
159                    }
160                    Tag::Paragraph => {
161                        if pending_block_break || !builder.is_empty() {
162                            builder.push_text("\n\n");
163                        }
164                        pending_block_break = false;
165                    }
166                    Tag::Emphasis => builder.enter_emphasis(),
167                    Tag::Strong => builder.enter_strong(),
168                    Tag::CodeBlock(kind) => {
169                        if pending_block_break || !builder.is_empty() {
170                            builder.push_text("\n\n");
171                        }
172                        pending_block_break = false;
173                        if let CodeBlockKind::Fenced(info) = kind
174                            && !info.is_empty()
175                        {
176                            builder.push_text(info.as_ref());
177                            builder.push_text(":\n");
178                        }
179                    }
180                    Tag::List(_) | Tag::Item => {
181                        if pending_block_break || !builder.is_empty() {
182                            builder.push_text("\n");
183                        }
184                        pending_block_break = false;
185                    }
186                    _ => {}
187                },
188                Event::End(tag) => match tag {
189                    pulldown_cmark::TagEnd::Heading(_) => {
190                        builder.exit();
191                        pending_block_break = true;
192                    }
193                    pulldown_cmark::TagEnd::Paragraph
194                    | pulldown_cmark::TagEnd::CodeBlock
195                    | pulldown_cmark::TagEnd::List(_) => {
196                        pending_block_break = true;
197                    }
198                    pulldown_cmark::TagEnd::Emphasis | pulldown_cmark::TagEnd::Strong => {
199                        builder.exit();
200                    }
201                    _ => {}
202                },
203                Event::Text(text)
204                | Event::Code(text)
205                | Event::Html(text)
206                | Event::FootnoteReference(text)
207                | Event::InlineMath(text)
208                | Event::DisplayMath(text)
209                | Event::InlineHtml(text) => {
210                    if pending_block_break && !builder.is_empty() {
211                        builder.push_text("\n\n");
212                        pending_block_break = false;
213                    }
214                    builder.push_text(text.as_ref());
215                }
216                Event::SoftBreak => builder.push_soft_break(),
217                Event::HardBreak => builder.push_hard_break(),
218                Event::Rule => {
219                    builder.push_text("\n\n——\n\n");
220                    pending_block_break = false;
221                }
222                Event::TaskListMarker(checked) => {
223                    if pending_block_break && !builder.is_empty() {
224                        builder.push_text("\n");
225                        pending_block_break = false;
226                    }
227                    builder.push_text(if checked { "[x] " } else { "[ ] " });
228                }
229            }
230        }
231
232        builder.finish()
233    }
234
235    /// Creates a plain attributed string with a single unstyled chunk.
236    #[must_use]
237    pub fn plain(text: impl Into<Str>) -> Self {
238        let mut s = Self::empty();
239        s.push(text.into(), Style::default());
240        s
241    }
242
243    /// Adds a new text chunk with the specified style.
244    pub fn push(&mut self, text: impl Into<Str>, style: Style) {
245        let text = text.into();
246        self.chunks.push((text, style));
247    }
248
249    /// Appends text to the last chunk, or creates a new chunk if empty.
250    pub fn push_str(&mut self, text: impl Into<Str>) {
251        let text = text.into();
252        if let Some(last) = self.chunks.last_mut() {
253            let (last_text, _) = last;
254            last_text.add_assign(text);
255        } else {
256            self.chunks.push((text, Style::default()));
257        }
258    }
259
260    /// Returns the total length of the attributed string.
261    #[must_use]
262    pub fn len(&self) -> usize {
263        self.chunks.iter().map(|(text, _)| text.len()).sum()
264    }
265
266    /// Checks if the attributed string is empty.
267    #[must_use]
268    pub const fn is_empty(&self) -> bool {
269        self.chunks.is_empty()
270    }
271
272    /// Converts the attributed string into its plain representation.
273    #[must_use]
274    pub fn to_plain(&self) -> Str {
275        if self.chunks.len() == 1 {
276            return self.chunks[0].0.clone();
277        }
278
279        let mut result = String::new();
280        for (text, _) in &self.chunks {
281            result.push_str(text);
282        }
283        result.into()
284    }
285
286    /// Consumes the attributed string and returns its constituent chunks.
287    #[must_use]
288    pub fn into_chunks(self) -> Vec<(Str, Style)> {
289        self.chunks
290    }
291
292    /// Sets the style for all text in this styled text.
293    #[must_use]
294    pub fn set_style(self, style: &Style) -> Self {
295        self.apply_style(|s| *s = style.clone())
296    }
297
298    fn apply_style(mut self, f: impl Fn(&mut Style)) -> Self {
299        if self.chunks.is_empty() {
300            return self;
301        }
302        let old_chunks = core::mem::take(&mut self.chunks);
303        for (text, mut style) in old_chunks {
304            f(&mut style);
305            self.push(text, style);
306        }
307        self
308    }
309
310    /// Sets the font size for all chunks.
311    #[must_use]
312    pub fn size(self, size: f32) -> Self {
313        self.apply_style(|s| *s = take(s).size(size))
314    }
315
316    /// Sets the font for all chunks.
317    #[must_use]
318    pub fn font(self, font: &Font) -> Self {
319        self.apply_style(|s| s.font = font.clone())
320    }
321
322    /// Sets the foreground color for all chunks.
323    #[must_use]
324    pub fn foreground(self, color: impl Into<Color>) -> Self {
325        let color = color.into();
326        self.apply_style(|s| s.foreground = Some(color.clone()))
327    }
328
329    /// Sets the background color for all chunks.
330    #[must_use]
331    pub fn background_color(self, color: impl Into<Color>) -> Self {
332        let color = color.into();
333        self.apply_style(|s| s.background = Some(color.clone()))
334    }
335
336    /// Sets the font weight for all chunks.
337    #[must_use]
338    pub fn weight(self, weight: FontWeight) -> Self {
339        self.apply_style(|s| {
340            *s = take(s).weight(weight);
341        })
342    }
343
344    /// Sets the font to bold for all chunks.
345    #[must_use]
346    pub fn bold(self) -> Self {
347        self.weight(FontWeight::Bold)
348    }
349
350    /// Sets the italic style for all chunks.
351    #[must_use]
352    pub fn italic(self, italic: bool) -> Self {
353        self.apply_style(|s| s.italic = italic)
354    }
355
356    /// Sets the underline style for all chunks.
357    #[must_use]
358    pub fn underline(self, underline: bool) -> Self {
359        self.apply_style(|s| s.underline = underline)
360    }
361
362    /// Sets the strikethrough style for all chunks.
363    #[must_use]
364    pub fn strikethrough(self, strikethrough: bool) -> Self {
365        self.apply_style(|s| s.strikethrough = strikethrough)
366    }
367}
368
369/// Utility builder that incrementally constructs a [`StyledStr`] from Markdown
370/// events. The builder keeps track of the active style stack and merges
371/// contiguous text runs that share the same styling.
372#[derive(Debug, Clone)]
373pub struct MarkdownInlineBuilder {
374    base_style: Style,
375    stack: Vec<Style>,
376    buffer: String,
377    result: StyledStr,
378}
379
380impl Default for MarkdownInlineBuilder {
381    fn default() -> Self {
382        Self::new()
383    }
384}
385
386impl MarkdownInlineBuilder {
387    /// Creates a new builder using the default style.
388    #[must_use]
389    pub fn new() -> Self {
390        Self::with_base_style(Style::default())
391    }
392
393    /// Creates a new builder with a custom base style.
394    #[must_use]
395    pub fn with_base_style(style: Style) -> Self {
396        Self {
397            base_style: style.clone(),
398            stack: vec![style],
399            buffer: String::new(),
400            result: StyledStr::empty(),
401        }
402    }
403
404    fn current_style(&self) -> Style {
405        self.stack.last().cloned().unwrap_or_else(Style::default)
406    }
407
408    fn flush(&mut self) {
409        if self.buffer.is_empty() {
410            return;
411        }
412
413        let text = take(&mut self.buffer);
414        self.result.push(text, self.current_style());
415    }
416
417    /// Appends raw text to the builder.
418    pub fn push_text(&mut self, text: &str) {
419        if !text.is_empty() {
420            self.buffer.push_str(text);
421        }
422    }
423
424    /// Appends a soft break (space) to the builder.
425    pub fn push_soft_break(&mut self) {
426        self.buffer.push(' ');
427    }
428
429    /// Appends a hard break (newline) to the builder.
430    pub fn push_hard_break(&mut self) {
431        self.buffer.push('\n');
432    }
433
434    /// Starts a new styled span using the closure to derive the child style.
435    pub fn enter_with(&mut self, f: impl FnOnce(Style) -> Style) {
436        self.flush();
437        let style = self.current_style();
438        self.stack.push(f(style));
439    }
440
441    /// Exits the most recently entered styled span.
442    pub fn exit(&mut self) {
443        self.flush();
444        if self.stack.len() > 1 {
445            self.stack.pop();
446        }
447    }
448
449    /// Enters an italic styled span.
450    pub fn enter_emphasis(&mut self) {
451        self.enter_with(Style::italic);
452    }
453
454    /// Enters a bold styled span.
455    pub fn enter_strong(&mut self) {
456        self.enter_with(Style::bold);
457    }
458
459    /// Returns `true` if the builder has emitted no content yet.
460    #[must_use]
461    pub const fn is_empty(&self) -> bool {
462        self.result.is_empty() && self.buffer.is_empty()
463    }
464
465    /// Returns the base style used by the builder.
466    #[must_use]
467    pub fn base_style(&self) -> Style {
468        self.base_style.clone()
469    }
470
471    /// Takes the currently buffered content, resetting the builder to the base
472    /// style. If no content has been emitted, `None` is returned.
473    #[must_use]
474    pub fn take(&mut self) -> Option<StyledStr> {
475        self.flush();
476
477        if self.result.is_empty() {
478            return None;
479        }
480
481        let mut output = StyledStr::empty();
482        core::mem::swap(&mut output, &mut self.result);
483        self.stack.truncate(1);
484        if let Some(first) = self.stack.first_mut() {
485            *first = self.base_style.clone();
486        }
487
488        Some(output)
489    }
490
491    /// Consumes the builder and returns the final `StyledStr`.
492    #[must_use]
493    pub fn finish(mut self) -> StyledStr {
494        self.flush();
495        self.result
496    }
497}
498
499/// Returns the default style applied to Markdown headings.
500#[must_use]
501pub fn heading_style(level: HeadingLevel) -> Style {
502    use crate::font::{Body, Caption, Footnote, Headline, Subheadline, Title};
503
504    let font: Font = match level {
505        HeadingLevel::H1 => Headline.into(),
506        HeadingLevel::H2 => Title.into(),
507        HeadingLevel::H3 => Subheadline.into(),
508        HeadingLevel::H4 => Body.into(),
509        HeadingLevel::H5 => Caption.into(),
510        HeadingLevel::H6 => Footnote.into(),
511    };
512
513    Style::default().font(font).bold()
514}
515
516impl View for StyledStr {
517    fn body(self, _env: &waterui_core::Environment) -> impl waterui_core::View {
518        text(self)
519    }
520}
521
522impl Add for StyledStr {
523    type Output = Self;
524
525    fn add(mut self, rhs: Self) -> Self::Output {
526        for (text, style) in rhs.chunks {
527            self.push(text, style);
528        }
529        self
530    }
531}
532
533impl Add<&'static str> for StyledStr {
534    type Output = Self;
535
536    fn add(mut self, rhs: &'static str) -> Self::Output {
537        self.push(rhs, Style::default());
538        self
539    }
540}
541
542impl Extend<(Str, Style)> for StyledStr {
543    fn extend<T: IntoIterator<Item = (Str, Style)>>(&mut self, iter: T) {
544        for (text, style) in iter {
545            self.push(text, style);
546        }
547    }
548}
549
550impl From<Str> for StyledStr {
551    fn from(value: Str) -> Self {
552        Self::plain(value)
553    }
554}
555
556impl From<&'static str> for StyledStr {
557    fn from(value: &'static str) -> Self {
558        Self::plain(value)
559    }
560}
561
562impl From<String> for StyledStr {
563    fn from(value: String) -> Self {
564        Self::plain(value)
565    }
566}
567
568impl Display for StyledStr {
569    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
570        f.write_str(&self.to_plain())
571    }
572}
573
574impl_constant!(Style, StyledStr);
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn parses_emphasis_markdown() {
582        let styled = StyledStr::from_markdown("Hello *world*!");
583        let chunks = styled.into_chunks();
584        assert_eq!(chunks.len(), 3);
585        assert_eq!(chunks[0].0.as_str(), "Hello ");
586        assert_eq!(chunks[1].0.as_str(), "world");
587        assert!(chunks[1].1.italic);
588        assert_eq!(chunks[2].0.as_str(), "!");
589    }
590
591    #[test]
592    fn parses_heading_markdown() {
593        let styled = StyledStr::from_markdown("# Title");
594        let chunks = styled.into_chunks();
595        assert_eq!(chunks.len(), 1);
596        assert_eq!(chunks[0].0.as_str(), "Title");
597    }
598}