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 })
210 .collect()
211 }
212}
213
214impl<Msg> Widget<Msg> for RichText {
215 fn style(&self) -> Style {
216 let min_width = if self.shrinkable {
217 Dimension::Length(0.0)
218 } else {
219 Dimension::Auto
220 };
221 if self.wrap || self.fills() {
223 return Style {
224 min_width,
225 ..Default::default()
226 };
227 }
228 let measured = frus_text::measure_runs(&self.runs(Color::WHITE, 1.0));
229 Style {
230 width: Dimension::Length(measured.width.ceil()),
231 height: Dimension::Length(self.capped(measured.height).ceil()),
232 min_width,
233 max_width: if self.shrinkable {
234 Dimension::Percent(1.0)
235 } else {
236 Dimension::Auto
237 },
238 ..Default::default()
239 }
240 }
241
242 fn main_axis_floor(&self, _theme: &Theme) -> Option<f32> {
245 if self.shrinkable || !(self.wrap || self.fills()) {
246 return None;
247 }
248 Some(
249 frus_text::measure_runs(&self.runs(Color::WHITE, 1.0))
250 .width
251 .ceil(),
252 )
253 }
254
255 fn text_baseline(&self, _theme: &Theme) -> Option<f32> {
258 frus_text::baseline_of_runs(&self.runs(Color::WHITE, 1.0))
259 }
260
261 fn measure(&self, _theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
262 if !self.wrap && !self.fills() {
263 return None;
264 }
265 let runs = self.runs(Color::WHITE, 1.0);
266 let wrap = self.wrap;
267 let max_lines = self.max_lines;
268 let base = self.base.resolved().size;
269 Some(Box::new(move |max_width, _| {
270 let mut size =
271 frus_text::measure_runs_wrapped(&runs, if wrap { max_width } else { None });
272 if let Some(max) = max_lines {
273 size.height = size
275 .height
276 .min(frus_core::fits(frus_text::line_height(base) * max as f32));
277 }
278 size
279 }))
280 }
281
282 fn measure_key(&self, _theme: &Theme) -> Option<u64> {
283 if !self.wrap && !self.fills() {
284 return None;
285 }
286 use std::hash::{Hash, Hasher};
287 let mut hasher = std::collections::hash_map::DefaultHasher::new();
288 self.span.measure_hash(&mut hasher);
289 let base = self.base.resolved();
290 base.size.to_bits().hash(&mut hasher);
291 base.weight.to_u16().hash(&mut hasher);
292 base.italic.hash(&mut hasher);
293 self.max_lines.hash(&mut hasher);
294 Some(hasher.finish())
295 }
296
297 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
298 &[]
299 }
300
301 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
302 let runs = self.runs(theme.on_surface, status.opacity);
303 let natural = frus_text::measure_runs(&runs).width;
304 let (runs, too_tall) = self.fitted(runs, bounds.width);
305 let too_wide = !self.wrap && natural > bounds.width + 0.5;
307 let over = too_tall || too_wide;
308 let block = TextBlock {
309 width: (self.wrap || self.align != TextAlign::Start).then_some(bounds.width),
310 soft_wrap: self.wrap,
311 align: self.align,
312 };
313 let at = Point::new(bounds.x, bounds.y);
314 let draw = |scene: &mut Scene| {
315 scene.rich_text_block(at, runs.clone(), block);
316 };
317 if !over
318 || self.overflow == TextOverflow::Visible
319 || self.overflow == TextOverflow::Ellipsis
320 {
321 draw(scene);
322 return;
323 }
324 let outer = scene.current_clip();
325 scene.set_clip(outer.intersect(bounds));
326 match self.overflow {
327 TextOverflow::Fade => scene.masked(self.fade(bounds, too_wide && !too_tall), draw),
328 _ => draw(scene),
329 }
330 scene.set_clip(outer);
331 }
332
333 fn on_click(&self) -> Option<Msg> {
334 None
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::interaction::Status;
342 use frus_core::{FontWeight, Primitive};
343
344 #[test]
345 fn paints_resolved_runs_with_theme_colour() {
346 let theme = Theme::default();
347 let rich = RichText::new(
348 TextSpan::new("plain ")
349 .child(TextSpan::new("bold").bold())
350 .child(TextSpan::new("red").color(Color::rgb(1.0, 0.0, 0.0))),
351 )
352 .base_style(theme.text.body_large);
353
354 let mut scene = Scene::new();
355 Widget::<()>::paint(
356 &rich,
357 Rect::new(2.0, 3.0, 300.0, 24.0),
358 Status::default(),
359 &theme,
360 &mut scene,
361 );
362
363 assert_eq!(scene.primitives().len(), 1);
364 match &scene.primitives()[0] {
365 Primitive::RichText { position, runs, .. } => {
366 assert_eq!(*position, Point::new(2.0, 3.0));
367 assert_eq!(runs.len(), 3);
368 assert_eq!(runs[0].size, 16.0);
370 assert_eq!(runs[0].color, theme.on_surface);
371 assert_eq!(runs[1].weight, FontWeight::Bold);
373 assert_eq!(runs[1].size, 16.0);
374 assert_eq!(runs[1].color, theme.on_surface);
375 assert_eq!(runs[2].color, Color::rgb(1.0, 0.0, 0.0));
377 }
378 _ => panic!("expected rich text"),
379 }
380 }
381
382 #[test]
383 fn wrapped_rich_text_measures_and_keys_by_content() {
384 let para = |text: &str| {
385 RichText::new(TextSpan::new(text).child(TextSpan::new(" gras").bold())).wrap()
386 };
387 let rich = para("a rich paragraph long enough to wrap onto several lines");
388 let theme = Theme::default();
390 let measure = Widget::<()>::measure(&rich, &theme).expect("measure closure");
391 let free = measure(None, None);
392 let narrow = measure(Some(120.0), None);
393 assert!(narrow.width <= 120.0);
394 assert!(narrow.height > free.height, "wrapped → taller");
395 assert_ne!(
397 Widget::<()>::measure_key(&rich, &theme),
398 Widget::<()>::measure_key(¶("short"), &theme)
399 );
400 let recolored = RichText::new(
402 TextSpan::new("a rich paragraph long enough to wrap onto several lines")
403 .color(Color::rgb(1.0, 0.0, 0.0))
404 .child(TextSpan::new(" gras").bold()),
405 )
406 .wrap();
407 assert_eq!(
408 Widget::<()>::measure_key(&rich, &Theme::default()),
409 Widget::<()>::measure_key(&recolored, &Theme::default()),
410 "the color must not invalidate the layout"
411 );
412 let plain = RichText::new(TextSpan::new("x")).no_wrap();
415 assert!(Widget::<()>::measure(&plain, &Theme::default()).is_none());
416 assert!(Widget::<()>::measure_key(&plain, &Theme::default()).is_none());
417 }
418
419 #[test]
422 fn a_limit_cuts_the_runs_and_keeps_their_styles() {
423 let span = TextSpan::new("one two three four ")
424 .child(TextSpan::new("five six seven eight").bold())
425 .child(TextSpan::new(" nine ten eleven twelve"));
426 let rich = RichText::new(span)
427 .base_style(TextStyle::new(12.0))
428 .max_lines(2)
429 .overflow(TextOverflow::Ellipsis);
430 let mut scene = Scene::new();
431 Widget::<()>::paint(
432 &rich,
433 Rect::new(0.0, 0.0, 90.0, 40.0),
434 Status::default(),
435 &Theme::default(),
436 &mut scene,
437 );
438 let runs = match &scene.primitives()[0] {
439 Primitive::RichText { runs, .. } => runs.clone(),
440 other => panic!("rich text, not {other:?}"),
441 };
442 let text: String = runs.iter().map(|r| r.text.as_str()).collect();
443 assert!(text.ends_with('…'), "cut and marked: {text:?}");
444 assert!(text.len() < 60, "and shorter than the whole: {text:?}");
445 assert!(
448 runs.iter().any(|r| r.weight == FontWeight::Bold),
449 "the styles survived the cut: {runs:?}"
450 );
451 }
452
453 #[test]
455 fn a_limit_it_does_not_reach_leaves_the_runs_alone() {
456 let rich = RichText::new(TextSpan::new("short").child(TextSpan::new(" and bold").bold()))
457 .base_style(TextStyle::new(12.0))
458 .max_lines(4);
459 let mut scene = Scene::new();
460 Widget::<()>::paint(
461 &rich,
462 Rect::new(0.0, 0.0, 400.0, 40.0),
463 Status::default(),
464 &Theme::default(),
465 &mut scene,
466 );
467 match &scene.primitives()[0] {
468 Primitive::RichText { runs, .. } => {
469 let text: String = runs.iter().map(|r| r.text.as_str()).collect();
470 assert_eq!(text, "short and bold");
471 }
472 other => panic!("rich text, not {other:?}"),
473 }
474 }
475
476 #[test]
479 fn alignment_travels_with_the_width_it_aligns_in() {
480 let painted = |rich: RichText| {
481 let mut scene = Scene::new();
482 Widget::<()>::paint(
483 &rich,
484 Rect::new(0.0, 0.0, 200.0, 40.0),
485 Status::default(),
486 &Theme::default(),
487 &mut scene,
488 );
489 match &scene.primitives()[0] {
490 Primitive::RichText {
491 align, max_width, ..
492 } => (*align, *max_width),
493 other => panic!("rich text, not {other:?}"),
494 }
495 };
496 assert_eq!(
497 painted(RichText::new(TextSpan::new("x")).align(TextAlign::Center)),
498 (TextAlign::Center, Some(200.0))
499 );
500 assert_eq!(
501 painted(RichText::new(TextSpan::new("x")).no_wrap()),
502 (TextAlign::Start, None)
503 );
504 }
505
506 #[test]
507 fn layout_accounts_for_the_largest_run() {
508 let h = |rich: RichText| {
510 Widget::<()>::measure(&rich, &Theme::default()).expect("measured")(None, None).height
511 };
512 let small = h(RichText::new(TextSpan::new("plain")));
513 let tall = h(RichText::new(
514 TextSpan::new("plain").child(TextSpan::new("BIG").size(28.0)),
515 ));
516 assert!(tall > small, "the large run must grow the line");
517 }
518}