1use frus_core::{
14 Color, MaskShader, Point, Rect, Scene, ShaderMask, TextAlign, TextBlock, TextOverflow, TextRun,
15 TextSpan, TextStyle,
16};
17use frus_layout::{Dimension, Style};
18
19use crate::interaction::Status;
20use crate::theme::Theme;
21use crate::widget::Widget;
22
23pub struct RichText {
31 span: TextSpan,
32 base: TextStyle,
35 wrap: bool,
37 overflow: TextOverflow,
39 max_lines: Option<usize>,
41 align: TextAlign,
43 shrinkable: bool,
46}
47
48impl RichText {
49 pub fn new(span: TextSpan) -> Self {
51 Self {
52 span,
53 base: TextStyle::new(16.0),
54 wrap: true,
55 overflow: TextOverflow::Clip,
56 max_lines: None,
57 align: TextAlign::Start,
58 shrinkable: false,
59 }
60 }
61
62 pub fn base_style(mut self, style: TextStyle) -> Self {
65 self.base = style;
66 self
67 }
68
69 pub fn wrap(mut self) -> Self {
72 self.wrap = true;
73 self
74 }
75
76 pub fn no_wrap(mut self) -> Self {
78 self.wrap = false;
79 self
80 }
81
82 pub fn align(mut self, align: TextAlign) -> Self {
86 self.align = align;
87 self
88 }
89
90 pub fn max_lines(mut self, max_lines: usize) -> Self {
94 self.max_lines = Some(max_lines.max(1));
95 self.shrinkable = true;
96 self
97 }
98
99 pub fn overflow(mut self, overflow: TextOverflow) -> Self {
102 self.overflow = overflow;
103 self.shrinkable = true;
104 self
105 }
106
107 fn fills(&self) -> bool {
110 self.align != TextAlign::Start
111 }
112
113 fn fitted(&self, runs: Vec<TextRun>, width: f32) -> (Vec<TextRun>, bool) {
120 let Some(max) = self.max_lines else {
121 return (runs, false);
122 };
123 let Some(cut) = frus_text::runs_cut_at(&runs, Some(width), self.wrap, max) else {
124 return (runs, false);
125 };
126 let mut kept: Vec<TextRun> = Vec::new();
127 let mut at = 0usize;
128 for run in runs {
129 let end = at + run.text.len();
130 if cut >= end {
131 at = end;
132 kept.push(run);
133 continue;
134 }
135 if cut > at {
136 let mut head = run.clone();
137 head.text = run.text[..cut - at].to_string();
138 kept.push(head);
139 }
140 break;
141 }
142 if self.overflow == TextOverflow::Ellipsis {
143 if let Some(last) = kept.last_mut() {
144 let style = TextStyle::new(last.size)
145 .weight(last.weight)
146 .color(last.color);
147 let style = if last.italic { style.italic() } else { style };
148 last.text = crate::text::ellipsise(&last.text, &style.resolved(), width);
149 }
150 }
151 (kept, true)
152 }
153
154 fn capped(&self, height: f32) -> f32 {
156 match self.max_lines {
157 Some(max) => height.min(frus_core::fits(
160 self.base.resolved().line_height() * max as f32,
161 )),
162 None => height,
163 }
164 }
165
166 fn fade(&self, bounds: Rect, horizontal: bool) -> ShaderMask {
168 let extent = if horizontal {
169 bounds.width
170 } else {
171 bounds.height
172 };
173 let run = (extent * 0.2).min(self.base.resolved().line_height() * 3.0);
174 let (from, to) = if horizontal {
175 (
176 Point::new(bounds.x + bounds.width - run, bounds.y),
177 Point::new(bounds.x + bounds.width, bounds.y),
178 )
179 } else {
180 (
181 Point::new(bounds.x, bounds.y + bounds.height - run),
182 Point::new(bounds.x, bounds.y + bounds.height),
183 )
184 };
185 ShaderMask::new(MaskShader::Linear {
186 from,
187 to,
188 from_color: Color::WHITE,
189 to_color: Color::WHITE.fade(0.0),
190 })
191 }
192
193 fn runs(&self, fallback: Color, opacity: f32) -> Vec<TextRun> {
196 self.span
197 .flatten(self.base)
198 .into_iter()
199 .map(|(text, style)| TextRun {
200 text,
201 size: style.size,
202 weight: style.weight,
203 italic: style.italic,
204 color: style.color.unwrap_or(fallback).fade(opacity),
205 decoration: style.decoration,
206 decoration_color: style.decoration_color.map(|c| c.fade(opacity)),
209 letter_spacing: style.letter_spacing,
210 })
211 .collect()
212 }
213}
214
215impl<Msg> Widget<Msg> for RichText {
216 fn style(&self) -> Style {
217 let min_width = if self.shrinkable {
218 Dimension::Length(0.0)
219 } else {
220 Dimension::Auto
221 };
222 if self.wrap || self.fills() {
224 return Style {
225 min_width,
226 ..Default::default()
227 };
228 }
229 let measured = frus_text::measure_runs(&self.runs(Color::WHITE, 1.0));
230 Style {
231 width: Dimension::Length(measured.width.ceil()),
232 height: Dimension::Length(self.capped(measured.height).ceil()),
233 min_width,
234 max_width: if self.shrinkable {
235 Dimension::Percent(1.0)
236 } else {
237 Dimension::Auto
238 },
239 ..Default::default()
240 }
241 }
242
243 fn main_axis_floor(&self, _theme: &Theme) -> Option<f32> {
246 if self.shrinkable || !(self.wrap || self.fills()) {
247 return None;
248 }
249 Some(
250 frus_text::measure_runs(&self.runs(Color::WHITE, 1.0))
251 .width
252 .ceil(),
253 )
254 }
255
256 fn text_baseline(&self, _theme: &Theme) -> Option<f32> {
259 frus_text::baseline_of_runs(&self.runs(Color::WHITE, 1.0))
260 }
261
262 fn measure(&self, _theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
263 if !self.wrap && !self.fills() {
264 return None;
265 }
266 let runs = self.runs(Color::WHITE, 1.0);
267 let wrap = self.wrap;
268 let max_lines = self.max_lines;
269 let base = self.base.resolved().size;
270 Some(Box::new(move |max_width, _| {
271 let mut size =
272 frus_text::measure_runs_wrapped(&runs, if wrap { max_width } else { None });
273 if let Some(max) = max_lines {
274 size.height = size
276 .height
277 .min(frus_core::fits(frus_text::line_height(base) * max as f32));
278 }
279 size
280 }))
281 }
282
283 fn measure_key(&self, _theme: &Theme) -> Option<u64> {
284 if !self.wrap && !self.fills() {
285 return None;
286 }
287 use std::hash::{Hash, Hasher};
288 let mut hasher = std::collections::hash_map::DefaultHasher::new();
289 self.span.measure_hash(&mut hasher);
290 let base = self.base.resolved();
291 base.size.to_bits().hash(&mut hasher);
292 base.weight.to_u16().hash(&mut hasher);
293 base.italic.hash(&mut hasher);
294 self.max_lines.hash(&mut hasher);
295 Some(hasher.finish())
296 }
297
298 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
299 &[]
300 }
301
302 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
303 let runs = self.runs(theme.on_surface, status.opacity);
304 let natural = frus_text::measure_runs(&runs).width;
305 let (runs, too_tall) = self.fitted(runs, bounds.width);
306 let too_wide = !self.wrap && natural > bounds.width + 0.5;
308 let over = too_tall || too_wide;
309 let block = TextBlock {
310 width: (self.wrap || self.align != TextAlign::Start).then_some(bounds.width),
311 soft_wrap: self.wrap,
312 align: self.align,
313 };
314 let at = Point::new(bounds.x, bounds.y);
315 let draw = |scene: &mut Scene| {
316 scene.rich_text_block(at, runs.clone(), block);
317 };
318 if !over
319 || self.overflow == TextOverflow::Visible
320 || self.overflow == TextOverflow::Ellipsis
321 {
322 draw(scene);
323 return;
324 }
325 let outer = scene.current_clip();
326 scene.set_clip(outer.intersect(bounds));
327 match self.overflow {
328 TextOverflow::Fade => scene.masked(self.fade(bounds, too_wide && !too_tall), draw),
329 _ => draw(scene),
330 }
331 scene.set_clip(outer);
332 }
333
334 fn on_click(&self) -> Option<Msg> {
335 None
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::interaction::Status;
343 use frus_core::{FontWeight, Primitive};
344
345 #[test]
346 fn paints_resolved_runs_with_theme_colour() {
347 let theme = Theme::default();
348 let rich = RichText::new(
349 TextSpan::new("plain ")
350 .child(TextSpan::new("bold").bold())
351 .child(TextSpan::new("red").color(Color::rgb(1.0, 0.0, 0.0))),
352 )
353 .base_style(theme.text.body_large);
354
355 let mut scene = Scene::new();
356 Widget::<()>::paint(
357 &rich,
358 Rect::new(2.0, 3.0, 300.0, 24.0),
359 Status::default(),
360 &theme,
361 &mut scene,
362 );
363
364 assert_eq!(scene.primitives().len(), 1);
365 match &scene.primitives()[0] {
366 Primitive::RichText { position, runs, .. } => {
367 assert_eq!(*position, Point::new(2.0, 3.0));
368 assert_eq!(runs.len(), 3);
369 assert_eq!(runs[0].size, 16.0);
371 assert_eq!(runs[0].color, theme.on_surface);
372 assert_eq!(runs[1].weight, FontWeight::Bold);
374 assert_eq!(runs[1].size, 16.0);
375 assert_eq!(runs[1].color, theme.on_surface);
376 assert_eq!(runs[2].color, Color::rgb(1.0, 0.0, 0.0));
378 }
379 _ => panic!("expected rich text"),
380 }
381 }
382
383 #[test]
384 fn wrapped_rich_text_measures_and_keys_by_content() {
385 let para = |text: &str| {
386 RichText::new(TextSpan::new(text).child(TextSpan::new(" gras").bold())).wrap()
387 };
388 let rich = para("a rich paragraph long enough to wrap onto several lines");
389 let theme = Theme::default();
391 let measure = Widget::<()>::measure(&rich, &theme).expect("measure closure");
392 let free = measure(None, None);
393 let narrow = measure(Some(120.0), None);
394 assert!(narrow.width <= 120.0);
395 assert!(narrow.height > free.height, "wrapped → taller");
396 assert_ne!(
398 Widget::<()>::measure_key(&rich, &theme),
399 Widget::<()>::measure_key(¶("short"), &theme)
400 );
401 let recolored = RichText::new(
403 TextSpan::new("a rich paragraph long enough to wrap onto several lines")
404 .color(Color::rgb(1.0, 0.0, 0.0))
405 .child(TextSpan::new(" gras").bold()),
406 )
407 .wrap();
408 assert_eq!(
409 Widget::<()>::measure_key(&rich, &Theme::default()),
410 Widget::<()>::measure_key(&recolored, &Theme::default()),
411 "the color must not invalidate the layout"
412 );
413 let plain = RichText::new(TextSpan::new("x")).no_wrap();
416 assert!(Widget::<()>::measure(&plain, &Theme::default()).is_none());
417 assert!(Widget::<()>::measure_key(&plain, &Theme::default()).is_none());
418 }
419
420 #[test]
423 fn a_limit_cuts_the_runs_and_keeps_their_styles() {
424 let span = TextSpan::new("one two three four ")
425 .child(TextSpan::new("five six seven eight").bold())
426 .child(TextSpan::new(" nine ten eleven twelve"));
427 let rich = RichText::new(span)
428 .base_style(TextStyle::new(12.0))
429 .max_lines(2)
430 .overflow(TextOverflow::Ellipsis);
431 let mut scene = Scene::new();
432 Widget::<()>::paint(
433 &rich,
434 Rect::new(0.0, 0.0, 90.0, 40.0),
435 Status::default(),
436 &Theme::default(),
437 &mut scene,
438 );
439 let runs = match &scene.primitives()[0] {
440 Primitive::RichText { runs, .. } => runs.clone(),
441 other => panic!("rich text, not {other:?}"),
442 };
443 let text: String = runs.iter().map(|r| r.text.as_str()).collect();
444 assert!(text.ends_with('…'), "cut and marked: {text:?}");
445 assert!(text.len() < 60, "and shorter than the whole: {text:?}");
446 assert!(
449 runs.iter().any(|r| r.weight == FontWeight::Bold),
450 "the styles survived the cut: {runs:?}"
451 );
452 }
453
454 #[test]
456 fn a_limit_it_does_not_reach_leaves_the_runs_alone() {
457 let rich = RichText::new(TextSpan::new("short").child(TextSpan::new(" and bold").bold()))
458 .base_style(TextStyle::new(12.0))
459 .max_lines(4);
460 let mut scene = Scene::new();
461 Widget::<()>::paint(
462 &rich,
463 Rect::new(0.0, 0.0, 400.0, 40.0),
464 Status::default(),
465 &Theme::default(),
466 &mut scene,
467 );
468 match &scene.primitives()[0] {
469 Primitive::RichText { runs, .. } => {
470 let text: String = runs.iter().map(|r| r.text.as_str()).collect();
471 assert_eq!(text, "short and bold");
472 }
473 other => panic!("rich text, not {other:?}"),
474 }
475 }
476
477 #[test]
480 fn alignment_travels_with_the_width_it_aligns_in() {
481 let painted = |rich: RichText| {
482 let mut scene = Scene::new();
483 Widget::<()>::paint(
484 &rich,
485 Rect::new(0.0, 0.0, 200.0, 40.0),
486 Status::default(),
487 &Theme::default(),
488 &mut scene,
489 );
490 match &scene.primitives()[0] {
491 Primitive::RichText {
492 align, max_width, ..
493 } => (*align, *max_width),
494 other => panic!("rich text, not {other:?}"),
495 }
496 };
497 assert_eq!(
498 painted(RichText::new(TextSpan::new("x")).align(TextAlign::Center)),
499 (TextAlign::Center, Some(200.0))
500 );
501 assert_eq!(
502 painted(RichText::new(TextSpan::new("x")).no_wrap()),
503 (TextAlign::Start, None)
504 );
505 }
506
507 #[test]
508 fn layout_accounts_for_the_largest_run() {
509 let h = |rich: RichText| {
511 Widget::<()>::measure(&rich, &Theme::default()).expect("measured")(None, None).height
512 };
513 let small = h(RichText::new(TextSpan::new("plain")));
514 let tall = h(RichText::new(
515 TextSpan::new("plain").child(TextSpan::new("BIG").size(28.0)),
516 ));
517 assert!(tall > small, "the large run must grow the line");
518 }
519}