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}