1use crate::TextStyle;
9
10#[derive(Clone, Debug)]
14pub struct Span {
15 pub text: String,
17 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
27fn 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#[derive(Debug, Default)]
43pub struct RichText {
44 spans: Vec<Span>,
45}
46
47impl RichText {
48 pub fn new() -> Self {
50 Self::default()
51 }
52
53 pub fn push_span(&mut self, span: Span) {
55 self.spans.push(span);
56 }
57
58 pub fn spans(&self) -> &[Span] {
60 &self.spans
61 }
62
63 pub fn text(&self) -> String {
65 self.spans.iter().map(|s| s.text.as_str()).collect()
66 }
67
68 pub fn split_at(&mut self, byte_offset: usize) {
73 let mut cursor = 0usize;
74 let mut split_idx: Option<(usize, usize)> = None; 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 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 pub fn apply_style_range(
125 &mut self,
126 start: usize,
127 end: usize,
128 style_fn: impl Fn(&mut TextStyle),
129 ) {
130 self.split_at(start);
132 self.split_at(end);
133
134 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
147impl 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#[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 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); rt.split_at(5); 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 assert!(rt.spans()[0].style.bold);
230 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 assert_eq!(rt.spans().len(), 2);
248 }
249}