Skip to main content

rlvgl_widgets/
span.rs

1//! LVGL-parity rich-text span widget (LPAR-14b).
2//!
3//! [`Spangroup`] holds an ordered list of [`Span`] segments — each a `(text,
4//! style)` pair — and lays them out as a contiguous inline flow.
5//!
6//! # Text measurement (LPAR-08 reuse, not fork)
7//!
8//! All measurement and wrapping uses the shared LPAR-08 primitives from
9//! `core::font`:
10//!
11//! 1. All segment texts are concatenated into one `String`, recording each
12//!    segment's start byte offset.
13//! 2. `wrap_greedy_ltr` is called **once** on the concatenated string.
14//! 3. Each [`WrappedLine`](rlvgl_core::font::WrappedLine) byte range is mapped
15//!    back to the owning [`Span`] segments for per-span styled drawing.
16//!
17//! No per-segment advance arithmetic is performed — that is the forbidden
18//! parallel measurement that LPAR-13 §5.F corrected.
19
20use alloc::string::String;
21use alloc::vec::Vec;
22
23use rlvgl_core::draw::draw_widget_bg;
24use rlvgl_core::event::Event;
25use rlvgl_core::font::{FontId, FontMetrics, WidgetFont, shape_text_ltr, wrap_greedy_ltr};
26use rlvgl_core::renderer::{ClipRenderer, Renderer};
27use rlvgl_core::style::Style;
28use rlvgl_core::widget::{Color, Rect, Widget};
29
30// ---------------------------------------------------------------------------
31// SpanId
32// ---------------------------------------------------------------------------
33
34/// Opaque identifier for a segment within a [`Spangroup`].
35///
36/// Registration policy: **Expert Review**.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct SpanId(pub u16);
39
40/// Sentinel [`SpanId`] meaning "no span".
41pub const SPAN_NONE: SpanId = SpanId(u16::MAX);
42
43// ---------------------------------------------------------------------------
44// SpanStyle
45// ---------------------------------------------------------------------------
46
47/// Per-segment visual style.
48///
49/// Registration policy: **Specification Required** for new fields.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct SpanStyle {
52    /// Foreground color used when drawing this segment's glyphs.
53    pub color: Color,
54    /// Optional font override; `None` uses the `Spangroup`'s default font.
55    pub font_id: Option<FontId>,
56}
57
58impl SpanStyle {
59    /// Create a plain-color span style with the default font.
60    pub fn with_color(color: Color) -> Self {
61        Self {
62            color,
63            font_id: None,
64        }
65    }
66}
67
68// ---------------------------------------------------------------------------
69// SpanOverflow
70// ---------------------------------------------------------------------------
71
72/// Overflow behavior when content exceeds `bounds.height`.
73///
74/// Registration policy: **Specification Required**.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub enum SpanOverflow {
77    /// Clip at `bounds.height` (default).
78    #[default]
79    Clip,
80    /// Allow the widget to expand vertically to fit all content.
81    ///
82    /// In v1 this affects only how `content_height` is reported; the widget
83    /// does not alter its own `bounds` automatically.
84    Expand,
85}
86
87// ---------------------------------------------------------------------------
88// Span segment (internal)
89// ---------------------------------------------------------------------------
90
91/// One inline text segment within a [`Spangroup`].
92pub struct Span {
93    /// The generation-counter id assigned at append time.
94    id: SpanId,
95    /// Displayed text for this segment.
96    text: String,
97    /// Visual style (color + optional font).
98    style: SpanStyle,
99}
100
101// ---------------------------------------------------------------------------
102// Spangroup
103// ---------------------------------------------------------------------------
104
105/// LVGL-parity rich-text inline flow widget.
106///
107/// Segments are concatenated logically and wrapped together using the shared
108/// LPAR-08 `wrap_greedy_ltr` measurement, then each wrapped portion is drawn
109/// with the style of the owning segment.
110///
111/// # Navigation
112///
113/// `Spangroup` has no interactive state; it is a pure display widget.
114pub struct Spangroup {
115    bounds: Rect,
116    spans: Vec<Span>,
117    next_id: u16,
118    overflow: SpanOverflow,
119    /// Background and border style.
120    pub style: Style,
121    /// Font assignment for this widget (FONT-00 §5); resolves to `FONT_6X10`
122    /// when unset.
123    font: WidgetFont,
124}
125
126impl Spangroup {
127    /// Create an empty `Spangroup` at `bounds`.
128    pub fn new(bounds: Rect) -> Self {
129        Self {
130            bounds,
131            spans: Vec::new(),
132            next_id: 0,
133            overflow: SpanOverflow::default(),
134            style: Style::default(),
135            font: WidgetFont::new(),
136        }
137    }
138
139    // ── Span management ───────────────────────────────────────────────────
140
141    /// Append a segment with `text` and `style`; return its [`SpanId`].
142    pub fn add_span(&mut self, text: &str, style: SpanStyle) -> SpanId {
143        if self.next_id == u16::MAX {
144            return SPAN_NONE;
145        }
146        let id = SpanId(self.next_id);
147        self.next_id += 1;
148        self.spans.push(Span {
149            id,
150            text: String::from(text),
151            style,
152        });
153        id
154    }
155
156    /// Remove the segment with `id`.
157    pub fn remove_span(&mut self, id: SpanId) {
158        self.spans.retain(|s| s.id != id);
159    }
160
161    /// Return the number of segments.
162    pub fn span_count(&self) -> usize {
163        self.spans.len()
164    }
165
166    /// Clear all segments.
167    pub fn clear_spans(&mut self) {
168        self.spans.clear();
169        self.next_id = 0;
170    }
171
172    /// Replace the text of the segment identified by `id`.
173    pub fn set_span_text(&mut self, id: SpanId, text: &str) {
174        if let Some(s) = self.spans.iter_mut().find(|s| s.id == id) {
175            s.text = String::from(text);
176        }
177    }
178
179    /// Replace the style of the segment identified by `id`.
180    pub fn set_span_style(&mut self, id: SpanId, style: SpanStyle) {
181        if let Some(s) = self.spans.iter_mut().find(|s| s.id == id) {
182            s.style = style;
183        }
184    }
185
186    // ── Overflow ──────────────────────────────────────────────────────────
187
188    /// Set the overflow behavior.
189    pub fn set_overflow(&mut self, mode: SpanOverflow) {
190        self.overflow = mode;
191    }
192
193    /// Return the overflow behavior.
194    pub fn overflow(&self) -> SpanOverflow {
195        self.overflow
196    }
197
198    /// Assign the font used to render this widget (FONT-00 §5); resolves to
199    /// `FONT_6X10` when unset.
200    pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
201        self.font.set(font);
202    }
203
204    // ── Content height query ──────────────────────────────────────────────
205
206    /// Return the total height of the laid-out content using the default font.
207    ///
208    /// Useful for sizing the widget before placement.
209    pub fn content_height(&self) -> i32 {
210        let font = self.font.resolve();
211        let concat = self.concat_text();
212        let wrapped = wrap_greedy_ltr(font, &concat, self.bounds.width.max(1), 0, 0);
213        wrapped.used_height
214    }
215
216    // ── Private helpers ───────────────────────────────────────────────────
217
218    /// Concatenate all segment texts and return `(concatenated, start_offsets)`.
219    ///
220    /// `start_offsets[i]` is the byte offset of `spans[i]` in the concatenated
221    /// string.  This is the only allocation in the layout pass; it is performed
222    /// once per draw call.
223    fn concat_with_offsets(&self) -> (String, Vec<usize>) {
224        let mut concat = String::new();
225        let mut offsets = Vec::with_capacity(self.spans.len());
226        for span in &self.spans {
227            offsets.push(concat.len());
228            concat.push_str(&span.text);
229        }
230        (concat, offsets)
231    }
232
233    /// Concatenate all segment texts (used for measurement).
234    fn concat_text(&self) -> String {
235        let mut s = String::new();
236        for span in &self.spans {
237            s.push_str(&span.text);
238        }
239        s
240    }
241
242    /// Given a byte offset into the concatenated string, return the index of the
243    /// owning span (the span whose `[start, start + len)` range contains `offset`).
244    fn span_index_for_offset(offsets: &[usize], total_len: usize, offset: usize) -> usize {
245        // Binary search: find the last start_offset <= offset.
246        let mut lo = 0usize;
247        let mut hi = offsets.len();
248        while lo < hi {
249            let mid = (lo + hi) / 2;
250            if offsets[mid] <= offset {
251                lo = mid + 1;
252            } else {
253                hi = mid;
254            }
255        }
256        // lo is one past the owning span index.
257        let idx = lo.saturating_sub(1);
258        // Clamp to valid range (`idx` is `usize`, so a `.max(0)` is a no-op).
259        idx.min(offsets.len().saturating_sub(1))
260            .min(if total_len == 0 { 0 } else { offsets.len() - 1 })
261    }
262}
263
264impl Widget for Spangroup {
265    fn bounds(&self) -> Rect {
266        self.bounds
267    }
268
269    fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
270        Some(&mut self.font)
271    }
272
273    fn set_bounds(&mut self, bounds: Rect) {
274        // Re-wrap is implicit on the next draw call since we compute layout
275        // each draw from the current bounds.
276        self.bounds = bounds;
277    }
278
279    fn draw(&self, renderer: &mut dyn Renderer) {
280        // Part::MAIN — background
281        draw_widget_bg(renderer, self.bounds, &self.style);
282
283        if self.spans.is_empty() || self.bounds.width <= 0 || self.bounds.height <= 0 {
284            return;
285        }
286
287        let font = self.font.resolve();
288        let metrics = font.line_metrics();
289
290        // Step 1: Concatenate all segment texts once, recording start offsets.
291        let (concat, offsets) = self.concat_with_offsets();
292        if concat.is_empty() {
293            return;
294        }
295        let total_len = concat.len();
296
297        // Step 2: Wrap the concatenated text ONCE using the SHARED greedy-wrap.
298        // This is the load-bearing LPAR-08 reuse point; no per-span advance arithmetic.
299        let wrapped = wrap_greedy_ltr(font, &concat, self.bounds.width.max(1), 0, 0);
300
301        let mut clipped = ClipRenderer::new(renderer, self.bounds);
302        let mut line_y = self.bounds.y + metrics.ascent as i32;
303
304        // Step 3: For each wrapped line, map byte ranges back to spans and draw.
305        for line in &wrapped.lines {
306            // Skip lines that overflow in Clip mode.
307            if self.overflow == SpanOverflow::Clip
308                && line_y - metrics.ascent as i32 >= self.bounds.y + self.bounds.height
309            {
310                break;
311            }
312
313            // Walk through the line's byte range, splitting by span boundaries.
314            let mut pos = line.start;
315            let line_end = line.end;
316
317            // x cursor within the line (used for advancing x across span pieces).
318            let mut x_cursor = self.bounds.x;
319
320            while pos < line_end {
321                let span_idx = Self::span_index_for_offset(&offsets, total_len, pos);
322                let span = &self.spans[span_idx];
323
324                // This span runs from offsets[span_idx] to either the next span start
325                // or the end of the concatenated string.
326                let span_end_in_concat = if span_idx + 1 < offsets.len() {
327                    offsets[span_idx + 1]
328                } else {
329                    total_len
330                };
331
332                // Clip to the current line range.
333                let piece_end = line_end.min(span_end_in_concat);
334                let piece = &concat[pos..piece_end];
335
336                if !piece.is_empty() {
337                    // Shape this piece using the span's color.
338                    // The font_id override is deferred (v1 uses FONT_6X10 for all spans).
339                    let shaped = shape_text_ltr(font, piece, (x_cursor, line_y), 0);
340                    let color = span.style.color.with_alpha(self.style.alpha);
341                    clipped.draw_text_shaped(&shaped, (0, 0), color);
342                    // Advance x by the shaped advance.
343                    x_cursor += (shaped.total_advance_fp16 + 8) >> 4;
344                }
345
346                pos = piece_end;
347            }
348
349            line_y += metrics.line_height as i32;
350        }
351    }
352
353    fn handle_event(&mut self, _event: &Event) -> bool {
354        false
355    }
356}
357
358// ---------------------------------------------------------------------------
359// Tests
360// ---------------------------------------------------------------------------
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use alloc::vec;
366    use rlvgl_core::bitmap_font::FONT_6X10;
367
368    fn r(x: i32, y: i32, w: i32, h: i32) -> Rect {
369        Rect {
370            x,
371            y,
372            width: w,
373            height: h,
374        }
375    }
376
377    fn red() -> SpanStyle {
378        SpanStyle::with_color(Color(255, 0, 0, 255))
379    }
380
381    fn blue() -> SpanStyle {
382        SpanStyle::with_color(Color(0, 0, 255, 255))
383    }
384
385    #[test]
386    fn add_and_count_spans() {
387        let mut sg = Spangroup::new(r(0, 0, 200, 100));
388        let a = sg.add_span("hello", red());
389        let b = sg.add_span(" world", blue());
390        assert_eq!(sg.span_count(), 2);
391        assert_ne!(a, SPAN_NONE);
392        assert_ne!(b, SPAN_NONE);
393        assert_ne!(a, b);
394    }
395
396    #[test]
397    fn remove_span() {
398        let mut sg = Spangroup::new(r(0, 0, 200, 100));
399        let a = sg.add_span("foo", red());
400        sg.add_span("bar", blue());
401        sg.remove_span(a);
402        assert_eq!(sg.span_count(), 1);
403    }
404
405    #[test]
406    fn clear_spans() {
407        let mut sg = Spangroup::new(r(0, 0, 200, 100));
408        sg.add_span("a", red());
409        sg.add_span("b", blue());
410        sg.clear_spans();
411        assert_eq!(sg.span_count(), 0);
412    }
413
414    #[test]
415    fn set_span_text_and_style() {
416        let mut sg = Spangroup::new(r(0, 0, 200, 100));
417        let id = sg.add_span("initial", red());
418        sg.set_span_text(id, "updated");
419        assert_eq!(sg.spans[0].text, "updated");
420        sg.set_span_style(id, blue());
421        assert_eq!(sg.spans[0].style, blue());
422    }
423
424    #[test]
425    fn wrap_uses_shared_measurement_not_per_span_arithmetic() {
426        // With a very narrow width (1 px), wrap_greedy_ltr must produce multiple
427        // lines even when spans span the wrap boundary.  We verify that
428        // the concat-and-wrap approach produces more than one line, which would
429        // be impossible if per-span arithmetic were used (it would see each span
430        // as a separate block).
431        let font: &dyn FontMetrics = &FONT_6X10;
432        let mut sg = Spangroup::new(r(0, 0, 1, 200)); // very narrow
433        sg.add_span("ab", red());
434        sg.add_span("cd", blue());
435        let concat = sg.concat_text();
436        // The shared measurement must wrap this across multiple lines.
437        let wrapped = wrap_greedy_ltr(font, &concat, 1, 0, 0);
438        assert!(
439            wrapped.lines.len() > 1,
440            "expected wrap across span boundary; got {} lines",
441            wrapped.lines.len()
442        );
443    }
444
445    #[test]
446    fn span_index_for_offset_basic() {
447        // offsets = [0, 5, 10] for spans of length 5, 5, ...
448        let offsets = vec![0usize, 5, 10];
449        let total = 15;
450        assert_eq!(Spangroup::span_index_for_offset(&offsets, total, 0), 0);
451        assert_eq!(Spangroup::span_index_for_offset(&offsets, total, 4), 0);
452        assert_eq!(Spangroup::span_index_for_offset(&offsets, total, 5), 1);
453        assert_eq!(Spangroup::span_index_for_offset(&offsets, total, 9), 1);
454        assert_eq!(Spangroup::span_index_for_offset(&offsets, total, 10), 2);
455        assert_eq!(Spangroup::span_index_for_offset(&offsets, total, 14), 2);
456    }
457
458    #[test]
459    fn content_height_non_zero_for_non_empty_spans() {
460        let mut sg = Spangroup::new(r(0, 0, 100, 100));
461        sg.add_span("Some text here", red());
462        assert!(sg.content_height() > 0);
463    }
464
465    #[test]
466    fn set_bounds_updates_geometry() {
467        let mut sg = Spangroup::new(r(0, 0, 100, 100));
468        sg.set_bounds(r(10, 20, 200, 150));
469        assert_eq!(sg.bounds(), r(10, 20, 200, 150));
470    }
471
472    #[test]
473    fn overflow_clip_vs_expand() {
474        let mut sg = Spangroup::new(r(0, 0, 100, 100));
475        assert_eq!(sg.overflow(), SpanOverflow::Clip);
476        sg.set_overflow(SpanOverflow::Expand);
477        assert_eq!(sg.overflow(), SpanOverflow::Expand);
478    }
479}