Skip to main content

oxiui_text/
rich.rs

1//! Rich text span model.
2//!
3//! [`RichText`] holds a sequence of [`Span`]s, each carrying its own
4//! [`TextStyle`].  The type supports splitting spans at byte boundaries,
5//! merging adjacent spans that share the same style, and applying a style
6//! mutation over a byte range.
7
8use crate::TextStyle;
9
10// ── Span ──────────────────────────────────────────────────────────────────────
11
12/// A styled fragment of text.
13#[derive(Clone, Debug)]
14pub struct Span {
15    /// The text content of this span.
16    pub text: String,
17    /// The rendering style of this span.
18    pub style: TextStyle,
19}
20
21impl PartialEq for Span {
22    fn eq(&self, other: &Self) -> bool {
23        self.text == other.text && span_styles_equal(&self.style, &other.style)
24    }
25}
26
27/// Lightweight structural equality for [`TextStyle`].  Two styles are
28/// considered equal when all layout-affecting fields match.
29fn span_styles_equal(a: &TextStyle, b: &TextStyle) -> bool {
30    a.font_family == b.font_family
31        && (a.font_size - b.font_size).abs() < f32::EPSILON
32        && a.bold == b.bold
33        && a.italic == b.italic
34        && a.color == b.color
35        && (a.letter_spacing - b.letter_spacing).abs() < f32::EPSILON
36        && (a.line_height - b.line_height).abs() < f32::EPSILON
37}
38
39// ── RichText ──────────────────────────────────────────────────────────────────
40
41/// A sequence of [`Span`]s that together form a rich text document.
42#[derive(Debug, Default)]
43pub struct RichText {
44    spans: Vec<Span>,
45}
46
47impl RichText {
48    /// Create an empty [`RichText`].
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Append a span to the end of the rich text.
54    pub fn push_span(&mut self, span: Span) {
55        self.spans.push(span);
56    }
57
58    /// Borrow the span list.
59    pub fn spans(&self) -> &[Span] {
60        &self.spans
61    }
62
63    /// Return the concatenated plain text of all spans.
64    pub fn text(&self) -> String {
65        self.spans.iter().map(|s| s.text.as_str()).collect()
66    }
67
68    /// Split a span at `byte_offset` (relative to the whole rich text).
69    ///
70    /// After the call the span that straddles `byte_offset` is replaced by two
71    /// spans.  Offsets that fall exactly on a span boundary are no-ops.
72    pub fn split_at(&mut self, byte_offset: usize) {
73        let mut cursor = 0usize;
74        let mut split_idx: Option<(usize, usize)> = None; // (span_index, local_offset)
75
76        for (i, span) in self.spans.iter().enumerate() {
77            let span_end = cursor + span.text.len();
78            if cursor < byte_offset && byte_offset < span_end {
79                split_idx = Some((i, byte_offset - cursor));
80                break;
81            }
82            cursor = span_end;
83        }
84
85        if let Some((idx, local)) = split_idx {
86            let original = self.spans.remove(idx);
87            let (left_text, right_text) = original.text.split_at(local);
88            let left = Span {
89                text: left_text.to_owned(),
90                style: original.style.clone(),
91            };
92            let right = Span {
93                text: right_text.to_owned(),
94                style: original.style,
95            };
96            self.spans.insert(idx, right);
97            self.spans.insert(idx, left);
98        }
99    }
100
101    /// Merge adjacent spans that have identical style into a single span.
102    pub fn merge_adjacent(&mut self) {
103        if self.spans.len() < 2 {
104            return;
105        }
106        let mut merged: Vec<Span> = Vec::with_capacity(self.spans.len());
107        for span in self.spans.drain(..) {
108            if let Some(last) = merged.last_mut() {
109                if span_styles_equal(&last.style, &span.style) {
110                    last.text.push_str(&span.text);
111                    continue;
112                }
113            }
114            merged.push(span);
115        }
116        self.spans = merged;
117    }
118
119    /// Apply a style mutation to all spans (or portions thereof) within
120    /// `[start, end)` byte offsets (relative to the full rich-text string).
121    ///
122    /// Spans are split at the boundaries as needed, then `style_fn` is called
123    /// on each span fully contained in the range.
124    pub fn apply_style_range(
125        &mut self,
126        start: usize,
127        end: usize,
128        style_fn: impl Fn(&mut TextStyle),
129    ) {
130        // First, ensure splits at the exact boundaries.
131        self.split_at(start);
132        self.split_at(end);
133
134        // Now mutate every span within [start, end).
135        let mut cursor = 0usize;
136        for span in &mut self.spans {
137            let span_start = cursor;
138            let span_end = cursor + span.text.len();
139            if span_start >= start && span_end <= end {
140                style_fn(&mut span.style);
141            }
142            cursor = span_end;
143        }
144    }
145}
146
147// ── Trait impls ───────────────────────────────────────────────────────────────
148
149impl From<&str> for RichText {
150    fn from(s: &str) -> Self {
151        let mut rt = RichText::new();
152        rt.push_span(Span {
153            text: s.to_owned(),
154            style: TextStyle::new(16.0),
155        });
156        rt
157    }
158}
159
160impl std::fmt::Display for RichText {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        for span in &self.spans {
163            f.write_str(&span.text)?;
164        }
165        Ok(())
166    }
167}
168
169// ── Tests ─────────────────────────────────────────────────────────────────────
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn rich_text_from_str() {
177        let rt = RichText::from("hello");
178        assert_eq!(rt.text(), "hello");
179    }
180
181    #[test]
182    fn rich_text_display() {
183        let rt = RichText::from("hi");
184        assert_eq!(format!("{rt}"), "hi");
185    }
186
187    #[test]
188    fn rich_text_push_span() {
189        let mut rt = RichText::new();
190        rt.push_span(Span {
191            text: "foo".to_owned(),
192            style: TextStyle::new(16.0),
193        });
194        rt.push_span(Span {
195            text: "bar".to_owned(),
196            style: TextStyle::new(16.0),
197        });
198        assert_eq!(rt.text(), "foobar");
199        assert_eq!(rt.spans().len(), 2);
200    }
201
202    #[test]
203    fn rich_text_split_and_merge() {
204        // "hello" split at 3 → ["hel", "lo"]; merge → ["hello"]
205        let mut rt = RichText::from("hello");
206        assert_eq!(rt.spans().len(), 1);
207        rt.split_at(3);
208        assert_eq!(rt.spans().len(), 2);
209        assert_eq!(rt.text(), "hello");
210        rt.merge_adjacent();
211        assert_eq!(rt.spans().len(), 1);
212        assert_eq!(rt.text(), "hello");
213    }
214
215    #[test]
216    fn rich_text_split_at_boundary_is_noop() {
217        let mut rt = RichText::from("hello");
218        let before = rt.spans().len();
219        rt.split_at(0); // exact start — no-op
220        rt.split_at(5); // exact end — no-op
221        assert_eq!(rt.spans().len(), before);
222    }
223
224    #[test]
225    fn rich_text_apply_style_range() {
226        let mut rt = RichText::from("hello world");
227        rt.apply_style_range(0, 5, |s| s.bold = true);
228        // First span should now be bold.
229        assert!(rt.spans()[0].style.bold);
230        // Remaining span(s) should not be bold.
231        assert!(!rt.spans().iter().skip(1).any(|s| s.style.bold));
232    }
233
234    #[test]
235    fn rich_text_merge_adjacent_different_styles() {
236        let mut rt = RichText::new();
237        rt.push_span(Span {
238            text: "a".to_owned(),
239            style: TextStyle::new(16.0).bold(),
240        });
241        rt.push_span(Span {
242            text: "b".to_owned(),
243            style: TextStyle::new(16.0),
244        });
245        rt.merge_adjacent();
246        // Different styles → stays as 2 spans.
247        assert_eq!(rt.spans().len(), 2);
248    }
249}