Skip to main content

embedded_gui/widgets/
rich_text_node.rs

1//! Multi-Span Rich Text Flow Nodes.
2//!
3//! Provides `RichTextNodeWidget` and `TextSpan` for rendering structured multi-span
4//! text blocks with inline badges and styled tags.
5
6use embedded_graphics_core::{draw_target::DrawTarget, pixelcolor::Rgb565};
7use heapless::Vec;
8
9use crate::{
10    geometry::Rect,
11    render::RenderCtx,
12    style::Style,
13    widget::{PropertyKey, PropertyValue, Widget},
14};
15
16/// Error indicating rich text node span capacity exceeded.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub struct RichTextError;
19
20/// An individual styled text span.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub struct TextSpan<'a> {
23    pub text: &'a str,
24    pub color: Rgb565,
25    pub background: Option<Rgb565>,
26}
27
28impl<'a> TextSpan<'a> {
29    pub const fn plain(text: &'a str, color: Rgb565) -> Self {
30        Self {
31            text,
32            color,
33            background: None,
34        }
35    }
36
37    pub const fn badge(text: &'a str, color: Rgb565, bg: Rgb565) -> Self {
38        Self {
39            text,
40            color,
41            background: Some(bg),
42        }
43    }
44}
45
46/// Multi-Span Rich Text Node widget supporting inline tag badges and colored text flows.
47#[derive(Clone, Debug, PartialEq)]
48pub struct RichTextNodeWidget<'a, const MAX_SPANS: usize = 6> {
49    pub spans: Vec<TextSpan<'a>, MAX_SPANS>,
50    pub line_spacing: u8,
51}
52
53impl<'a, const MAX_SPANS: usize> RichTextNodeWidget<'a, MAX_SPANS> {
54    pub const fn new() -> Self {
55        Self {
56            spans: Vec::new(),
57            line_spacing: 4,
58        }
59    }
60
61    pub fn push_span(&mut self, span: TextSpan<'a>) -> Result<(), RichTextError> {
62        self.spans.push(span).map_err(|_| RichTextError)
63    }
64
65    pub fn render<D, C>(&self, ctx: &mut RenderCtx<'_, D, C>, bounds: Rect) -> Result<(), D::Error>
66    where
67        D: DrawTarget<Color = Rgb565>,
68        C: crate::render::Compositor<D>,
69    {
70        let mut cursor_x = bounds.x;
71        let mut cursor_y = bounds.y;
72        let font_w = 6;
73        let font_h = 10;
74
75        for span in &self.spans {
76            let span_len = span.text.len() as i32;
77            let span_px_w = span_len * font_w;
78
79            // Simple line wrap if exceeding bounds width
80            if cursor_x > bounds.x && cursor_x + span_px_w > bounds.right() {
81                cursor_x = bounds.x;
82                cursor_y += font_h + self.line_spacing as i32;
83            }
84
85            if let Some(bg) = span.background {
86                let badge_rect = Rect::new(
87                    cursor_x - 2,
88                    cursor_y - 1,
89                    span_px_w as u32 + 4,
90                    font_h as u32 + 2,
91                );
92                ctx.fill_rounded_rect(badge_rect, 2, bg)?;
93            }
94
95            ctx.draw_text(cursor_x, cursor_y, span.text, span.color)?;
96            cursor_x += span_px_w + 6;
97        }
98
99        Ok(())
100    }
101}
102
103impl<'a, const MAX_SPANS: usize> Default for RichTextNodeWidget<'a, MAX_SPANS> {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl<'a, const MAX_SPANS: usize> Widget for RichTextNodeWidget<'a, MAX_SPANS> {
110    fn render_widget_bounds(&self, _bounds: Rect, _style: &Style) {}
111
112    fn get_property(&self, _key: PropertyKey) -> Option<PropertyValue<'_>> {
113        None
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::framebuffer::Framebuffer;
121
122    #[test]
123    fn test_rich_text_node_render() {
124        let mut node = RichTextNodeWidget::<4>::new();
125        assert!(
126            node.push_span(TextSpan::badge(
127                "ALERT",
128                Rgb565::new(31, 63, 31),
129                Rgb565::new(31, 0, 0),
130            ))
131            .is_ok()
132        );
133        assert!(
134            node.push_span(TextSpan::plain(
135                "Sensor reading nominal",
136                Rgb565::new(31, 63, 31),
137            ))
138            .is_ok()
139        );
140
141        let mut fb = Framebuffer::<24000>::new(200, 40);
142        let mut ctx = RenderCtx::new(&mut fb, Rect::new(0, 0, 200, 40));
143        assert!(node.render(&mut ctx, Rect::new(0, 0, 200, 40)).is_ok());
144    }
145}