1use frust_core::accesskit::Role;
10use frust_core::{
11 BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View,
12 Widget,
13};
14use frust_text::{
15 FontFamily, FontStyle, FontWeight, LineHeight, TextAlign, TextContext, TextLayout,
16 TextOverflow, TextStyle,
17};
18use frust_theme::{Theme, TypeScale};
19use kurbo::Size;
20use peniko::Color;
21
22#[allow(clippy::enum_variant_names)]
40#[derive(Clone, Copy, PartialEq, Eq, Debug)]
41pub enum ThemeTextColor {
42 OnSurface,
44 OnPrimary,
47 OnSurfaceVariant,
51 OnPrimaryContainer,
54 Error,
57}
58
59#[derive(Clone, Copy, PartialEq, Eq, Debug)]
78pub enum ThemeTextType {
79 DisplayLarge,
81 DisplayMedium,
83 DisplaySmall,
85 HeadlineLarge,
87 HeadlineMedium,
89 HeadlineSmall,
91 TitleLarge,
93 TitleMedium,
95 TitleSmall,
97 BodyLarge,
99 BodyMedium,
101 BodySmall,
103 LabelLarge,
105 LabelMedium,
107 LabelSmall,
109 DisplayLargeEmphasized,
111 DisplayMediumEmphasized,
113 DisplaySmallEmphasized,
115 HeadlineLargeEmphasized,
117 HeadlineMediumEmphasized,
119 HeadlineSmallEmphasized,
121 TitleLargeEmphasized,
123 TitleMediumEmphasized,
125 TitleSmallEmphasized,
127 BodyLargeEmphasized,
129 BodyMediumEmphasized,
131 BodySmallEmphasized,
133 LabelLargeEmphasized,
135 LabelMediumEmphasized,
137 LabelSmallEmphasized,
139}
140
141impl ThemeTextType {
142 pub fn style_in(self, scale: &TypeScale) -> &TextStyle {
148 match self {
149 Self::DisplayLarge => &scale.display_large,
150 Self::DisplayMedium => &scale.display_medium,
151 Self::DisplaySmall => &scale.display_small,
152 Self::HeadlineLarge => &scale.headline_large,
153 Self::HeadlineMedium => &scale.headline_medium,
154 Self::HeadlineSmall => &scale.headline_small,
155 Self::TitleLarge => &scale.title_large,
156 Self::TitleMedium => &scale.title_medium,
157 Self::TitleSmall => &scale.title_small,
158 Self::BodyLarge => &scale.body_large,
159 Self::BodyMedium => &scale.body_medium,
160 Self::BodySmall => &scale.body_small,
161 Self::LabelLarge => &scale.label_large,
162 Self::LabelMedium => &scale.label_medium,
163 Self::LabelSmall => &scale.label_small,
164 Self::DisplayLargeEmphasized => &scale.display_large_emphasized,
165 Self::DisplayMediumEmphasized => &scale.display_medium_emphasized,
166 Self::DisplaySmallEmphasized => &scale.display_small_emphasized,
167 Self::HeadlineLargeEmphasized => &scale.headline_large_emphasized,
168 Self::HeadlineMediumEmphasized => &scale.headline_medium_emphasized,
169 Self::HeadlineSmallEmphasized => &scale.headline_small_emphasized,
170 Self::TitleLargeEmphasized => &scale.title_large_emphasized,
171 Self::TitleMediumEmphasized => &scale.title_medium_emphasized,
172 Self::TitleSmallEmphasized => &scale.title_small_emphasized,
173 Self::BodyLargeEmphasized => &scale.body_large_emphasized,
174 Self::BodyMediumEmphasized => &scale.body_medium_emphasized,
175 Self::BodySmallEmphasized => &scale.body_small_emphasized,
176 Self::LabelLargeEmphasized => &scale.label_large_emphasized,
177 Self::LabelMediumEmphasized => &scale.label_medium_emphasized,
178 Self::LabelSmallEmphasized => &scale.label_small_emphasized,
179 }
180 }
181}
182
183pub struct TextView {
200 content: String,
201 style: TextStyle,
202 color_explicit: bool,
206 role: ThemeTextColor,
208 family_explicit: bool,
211 family_role: Option<ThemeTextType>,
214 max_lines: Option<usize>,
217 overflow: TextOverflow,
221}
222
223pub fn text(content: impl Into<String>) -> TextView {
227 TextView {
228 content: content.into(),
229 style: TextStyle::default(),
230 color_explicit: false,
231 role: ThemeTextColor::OnSurface,
232 family_explicit: false,
233 family_role: None,
234 max_lines: None,
235 overflow: TextOverflow::default(),
236 }
237}
238
239impl TextView {
240 pub fn size(mut self, size: f32) -> Self {
242 self.style.size = size;
243 self
244 }
245
246 pub fn color(mut self, color: Color) -> Self {
249 self.style.color = color;
250 self.color_explicit = true;
251 self
252 }
253
254 pub fn weight(mut self, weight: FontWeight) -> Self {
256 self.style.weight = weight;
257 self
258 }
259
260 pub fn family(mut self, family: FontFamily) -> Self {
263 self.style.family = family;
264 self.family_explicit = true;
265 self
266 }
267
268 pub fn italic(mut self) -> Self {
270 self.style.style = FontStyle::Italic;
271 self
272 }
273
274 pub fn letter_spacing(mut self, letter_spacing: f32) -> Self {
276 self.style.letter_spacing = letter_spacing;
277 self
278 }
279
280 pub fn line_height(mut self, line_height: LineHeight) -> Self {
282 self.style.line_height = line_height;
283 self
284 }
285
286 pub fn align(mut self, align: TextAlign) -> Self {
293 self.style.align = align;
294 self
295 }
296
297 pub fn style(mut self, style: TextStyle) -> Self {
301 self.style = style;
302 self.color_explicit = true;
303 self.family_explicit = true;
304 self
305 }
306
307 pub fn max_lines(mut self, max_lines: usize) -> Self {
311 self.max_lines = Some(max_lines);
312 self
313 }
314
315 pub fn overflow(mut self, overflow: TextOverflow) -> Self {
318 self.overflow = overflow;
319 self
320 }
321
322 pub fn themed_role(mut self, role: ThemeTextColor) -> Self {
330 self.role = role;
331 self
332 }
333
334 pub fn themed_family(mut self, role: ThemeTextType) -> Self {
347 self.family_role = Some(role);
348 self
349 }
350}
351
352impl<State: 'static> View<State> for TextView {
353 type Element = TextWidget;
354
355 fn build(&self, _ctx: &mut BuildCtx<'_>) -> TextWidget {
356 TextWidget {
357 content: self.content.clone(),
358 style: self.style.clone(),
359 color_explicit: self.color_explicit,
360 role: self.role,
361 family_explicit: self.family_explicit,
362 family_role: self.family_role,
363 max_lines: self.max_lines,
364 overflow: self.overflow,
365 layout: None,
366 laid_out_max_width: None,
367 laid_out_style: None,
368 }
369 }
370
371 fn rebuild(
372 &self,
373 prev: &Self,
374 element: &mut TextWidget,
375 _ctx: &mut BuildCtx<'_>,
376 ) -> ChangeFlags {
377 let mut flags = ChangeFlags::NONE;
378 if prev.content != self.content {
379 element.content = self.content.clone();
380 element.layout = None; flags |= ChangeFlags::LAYOUT;
382 }
383 if prev.style != self.style {
384 element.style = self.style.clone();
385 element.layout = None;
386 flags |= ChangeFlags::LAYOUT;
387 }
388 if prev.color_explicit != self.color_explicit || prev.role != self.role {
391 element.color_explicit = self.color_explicit;
392 element.role = self.role;
393 element.layout = None;
394 flags |= ChangeFlags::LAYOUT;
395 }
396 if prev.family_explicit != self.family_explicit || prev.family_role != self.family_role {
399 element.family_explicit = self.family_explicit;
400 element.family_role = self.family_role;
401 element.layout = None;
402 flags |= ChangeFlags::LAYOUT;
403 }
404 if prev.max_lines != self.max_lines || prev.overflow != self.overflow {
408 element.max_lines = self.max_lines;
409 element.overflow = self.overflow;
410 element.layout = None;
411 flags |= ChangeFlags::LAYOUT;
412 }
413 flags
414 }
415}
416
417pub struct TextWidget {
420 content: String,
421 style: TextStyle,
422 color_explicit: bool,
424 role: ThemeTextColor,
426 family_explicit: bool,
428 family_role: Option<ThemeTextType>,
431 max_lines: Option<usize>,
433 overflow: TextOverflow,
435 layout: Option<TextLayout>,
438 laid_out_max_width: Option<f32>,
444 laid_out_style: Option<TextStyle>,
451}
452
453impl TextWidget {
454 fn effective_style(&self, theme: Option<&Theme>) -> TextStyle {
463 let mut style = self.style.clone();
464 let Some(theme) = theme else {
465 return style;
466 };
467 if !self.color_explicit {
468 let scheme = theme.scheme();
469 style.color = match self.role {
470 ThemeTextColor::OnSurface => scheme.on_surface,
471 ThemeTextColor::OnPrimary => scheme.on_primary,
472 ThemeTextColor::OnSurfaceVariant => scheme.on_surface_variant,
473 ThemeTextColor::OnPrimaryContainer => scheme.on_primary_container,
474 ThemeTextColor::Error => scheme.error,
475 };
476 }
477 if !self.family_explicit
478 && let Some(role) = self.family_role
479 {
480 style.family = role.style_in(&theme.type_scale).family.clone();
481 }
482 style
483 }
484}
485
486impl Widget for TextWidget {
487 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
488 let max_width = {
492 let w = bc.max().width;
493 if w.is_finite() { Some(w as f32) } else { None }
494 };
495 let style = self.effective_style(Theme::from_layout_ctx(ctx));
498
499 if let Some(cached) = &self.layout
510 && self.laid_out_max_width == max_width
511 && self.laid_out_style.as_ref() == Some(&style)
512 {
513 return bc.constrain(cached.size());
514 }
515
516 let text_ctx = ctx.text_context::<TextContext>();
517 let layout = text_ctx.layout_bounded(
518 &self.content,
519 &style,
520 max_width,
521 self.max_lines,
522 self.overflow,
523 );
524 let size = bc.constrain(layout.size());
525 self.layout = Some(layout);
526 self.laid_out_max_width = max_width;
527 self.laid_out_style = Some(style);
528 size
529 }
530
531 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
532 if let Some(layout) = &self.layout {
533 for run in layout.to_scene_runs(ctx.origin()) {
534 scene.draw_glyph_run(run);
535 }
536 }
537 }
538
539 fn semantics(&self, ctx: &mut SemanticsCtx) {
540 ctx.push_node(Role::Label, |node| {
544 node.set_value(self.content.as_str());
545 });
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn builder_methods_compose() {
555 let view = text("x")
556 .size(20.0)
557 .weight(FontWeight::MEDIUM)
558 .italic()
559 .family(FontFamily::named("Inter"))
560 .letter_spacing(2.0)
561 .line_height(LineHeight::Absolute(30.0))
562 .align(TextAlign::Center)
563 .color(Color::from_rgb8(1, 2, 3))
564 .max_lines(2)
565 .overflow(frust_text::TextOverflow::Ellipsis);
566
567 assert_eq!(view.style.size, 20.0);
568 assert_eq!(view.style.weight, FontWeight::MEDIUM);
569 assert_eq!(view.style.style, FontStyle::Italic);
570 assert_eq!(view.style.family, FontFamily::named("Inter"));
571 assert_eq!(view.style.letter_spacing, 2.0);
572 assert_eq!(view.style.line_height, LineHeight::Absolute(30.0));
573 assert_eq!(view.style.align, TextAlign::Center);
574 assert_eq!(view.style.color, Color::from_rgb8(1, 2, 3));
575 assert_eq!(view.max_lines, Some(2));
576 assert_eq!(view.overflow, frust_text::TextOverflow::Ellipsis);
577 }
578
579 #[test]
580 fn default_max_lines_and_overflow_are_unbounded_clip() {
581 let view = text("x");
582 assert_eq!(view.max_lines, None);
583 assert_eq!(view.overflow, frust_text::TextOverflow::Clip);
584 }
585
586 #[test]
587 fn bulk_style_setter_replaces_whole_style() {
588 let custom = TextStyle {
589 size: 40.0,
590 ..TextStyle::default()
591 };
592 let view = text("x").style(custom.clone());
593 assert_eq!(view.style, custom);
594 }
595
596 #[test]
601 fn every_role_reads_its_own_type_scale_slot() {
602 macro_rules! each_role {
603 ($($role:ident => $slot:ident),+ $(,)?) => {$({
604 let mut scale = TypeScale::neutral(&TextStyle::default());
605 let probe = FontFamily::named(stringify!($slot));
606 scale.$slot.family = probe.clone();
607 assert_eq!(
608 ThemeTextType::$role.style_in(&scale).family,
609 probe,
610 "`{}` must read `{}`",
611 stringify!($role),
612 stringify!($slot)
613 );
614 })+};
615 }
616 each_role!(
617 DisplayLarge => display_large,
618 DisplayMedium => display_medium,
619 DisplaySmall => display_small,
620 HeadlineLarge => headline_large,
621 HeadlineMedium => headline_medium,
622 HeadlineSmall => headline_small,
623 TitleLarge => title_large,
624 TitleMedium => title_medium,
625 TitleSmall => title_small,
626 BodyLarge => body_large,
627 BodyMedium => body_medium,
628 BodySmall => body_small,
629 LabelLarge => label_large,
630 LabelMedium => label_medium,
631 LabelSmall => label_small,
632 DisplayLargeEmphasized => display_large_emphasized,
633 DisplayMediumEmphasized => display_medium_emphasized,
634 DisplaySmallEmphasized => display_small_emphasized,
635 HeadlineLargeEmphasized => headline_large_emphasized,
636 HeadlineMediumEmphasized => headline_medium_emphasized,
637 HeadlineSmallEmphasized => headline_small_emphasized,
638 TitleLargeEmphasized => title_large_emphasized,
639 TitleMediumEmphasized => title_medium_emphasized,
640 TitleSmallEmphasized => title_small_emphasized,
641 BodyLargeEmphasized => body_large_emphasized,
642 BodyMediumEmphasized => body_medium_emphasized,
643 BodySmallEmphasized => body_small_emphasized,
644 LabelLargeEmphasized => label_large_emphasized,
645 LabelMediumEmphasized => label_medium_emphasized,
646 LabelSmallEmphasized => label_small_emphasized,
647 );
648 }
649
650 use frust_core::{BoxConstraints, LayoutCtx, PaintCtx, PaintScene, Widget};
653 use frust_scene::GlyphRun;
654 use frust_text::TextContext;
655 use frust_theme::Theme;
656 use kurbo::{Point, Size};
657 use peniko::Brush;
658 use std::any::Any;
659
660 #[derive(Default)]
662 struct GlyphRecorder {
663 colors: Vec<Color>,
664 }
665
666 impl PaintScene for GlyphRecorder {
667 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
668 fn draw_text(&mut self, _o: Point, _t: &str) {}
669 fn draw_glyph_run(&mut self, run: GlyphRun) {
670 if let Brush::Solid(color) = run.brush {
671 self.colors.push(color);
672 }
673 }
674 }
675
676 fn painted_color(view: TextView, theme: Option<&Theme>) -> Color {
680 let mut widget = View::<()>::build(&view, &mut frust_core::BuildCtx::new(&mut 0u64));
681 let mut tcx = TextContext::new();
682 let theme_any = theme.map(|t| t as &dyn Any);
683 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), theme_any);
684 widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
685 let mut rec = GlyphRecorder::default();
686 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(200.0, 100.0));
687 widget.paint(&mut pctx, &mut rec);
688 *rec.colors.first().expect("one glyph run painted")
689 }
690
691 #[test]
692 fn unthemed_text_keeps_black_default() {
693 assert_eq!(painted_color(text("x"), None), Color::BLACK);
696 }
697
698 #[test]
699 fn themed_text_defaults_to_on_surface() {
700 let theme = Theme::neutral();
701 assert_eq!(
702 painted_color(text("x"), Some(&theme)),
703 theme.scheme().on_surface
704 );
705 }
706
707 #[test]
708 fn on_primary_role_resolves_to_on_primary() {
709 let theme = Theme::neutral();
710 let view = text("x").themed_role(ThemeTextColor::OnPrimary);
711 assert_eq!(painted_color(view, Some(&theme)), theme.scheme().on_primary);
712 }
713
714 #[test]
715 fn on_surface_variant_role_resolves_to_on_surface_variant() {
716 let theme = Theme::neutral();
717 let view = text("x").themed_role(ThemeTextColor::OnSurfaceVariant);
718 assert_eq!(
719 painted_color(view, Some(&theme)),
720 theme.scheme().on_surface_variant
721 );
722 }
723
724 #[test]
725 fn on_primary_container_role_resolves_to_on_primary_container() {
726 let theme = Theme::neutral();
727 let view = text("x").themed_role(ThemeTextColor::OnPrimaryContainer);
728 assert_eq!(
729 painted_color(view, Some(&theme)),
730 theme.scheme().on_primary_container
731 );
732 }
733
734 #[test]
735 fn error_role_resolves_to_error() {
736 let theme = Theme::neutral();
737 let view = text("x").themed_role(ThemeTextColor::Error);
738 assert_eq!(painted_color(view, Some(&theme)), theme.scheme().error);
739 }
740
741 #[test]
742 fn explicit_color_wins_over_theme() {
743 let theme = Theme::neutral();
745 let custom = Color::from_rgb8(1, 2, 3);
746 assert_eq!(painted_color(text("x").color(custom), Some(&theme)), custom);
747 }
748
749 #[test]
752 fn unchanged_layout_pass_skips_reshaping_entirely() {
753 let view = text("Hello from Frust");
761 let mut widget = View::<()>::build(&view, &mut frust_core::BuildCtx::new(&mut 0u64));
762 let mut tcx = TextContext::new();
763 let bc = BoxConstraints::loose(Size::new(200.0, 100.0));
764 {
765 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
766 widget.layout(&mut lctx, &bc);
767 widget.layout(&mut lctx, &bc);
768 widget.layout(&mut lctx, &bc);
769 }
770 let stats = tcx.shape_cache_stats();
771 assert_eq!(stats.shapes, 1, "shaping must run exactly once");
772 assert_eq!(
773 stats.hits, 0,
774 "an unchanged layout pass must not consult the text context at all"
775 );
776 assert_eq!(stats.line_breaks, 0);
777 }
778
779 #[test]
780 fn width_change_rebreaks_without_reshaping() {
781 let view = text("Hello from Frust, the pure Rust mobile UI toolkit");
785 let mut widget = View::<()>::build(&view, &mut frust_core::BuildCtx::new(&mut 0u64));
786 let mut tcx = TextContext::new();
787 {
788 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
789 widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
790 widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(120.0, 100.0)));
791 }
792 let stats = tcx.shape_cache_stats();
793 assert_eq!(stats.shapes, 1, "the width change must not re-shape");
794 assert_eq!(stats.line_breaks, 1, "the width change re-breaks once");
795 }
796
797 fn painted_runs(view: TextView, bc: BoxConstraints) -> Vec<GlyphRun> {
803 let mut widget = View::<()>::build(&view, &mut frust_core::BuildCtx::new(&mut 0u64));
804 let mut tcx = TextContext::new();
805 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
806 widget.layout(&mut lctx, &bc);
807 let mut rec = GlyphRunRecorder::default();
808 let mut pctx = PaintCtx::new(Point::ZERO, bc.max());
809 widget.paint(&mut pctx, &mut rec);
810 rec.runs
811 }
812
813 #[derive(Default)]
816 struct GlyphRunRecorder {
817 runs: Vec<GlyphRun>,
818 }
819
820 impl PaintScene for GlyphRunRecorder {
821 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
822 fn draw_text(&mut self, _o: Point, _t: &str) {}
823 fn draw_glyph_run(&mut self, run: GlyphRun) {
824 self.runs.push(run);
825 }
826 }
827
828 fn line_min_x(runs: &[GlyphRun]) -> Vec<f32> {
832 let mut by_y: Vec<(f32, f32)> = Vec::new();
833 for run in runs {
834 for g in &run.glyphs {
835 match by_y.iter_mut().find(|(y, _)| (*y - g.y).abs() < 0.01) {
836 Some((_, min_x)) => *min_x = min_x.min(g.x),
837 None => by_y.push((g.y, g.x)),
838 }
839 }
840 }
841 by_y.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
842 by_y.into_iter().map(|(_, x)| x).collect()
843 }
844
845 #[test]
846 fn text_view_align_centers_and_right_aligns_wrapped_lines() {
847 let content = "A\nBBBBBBBBBB";
852 let bc = BoxConstraints::loose(Size::new(400.0, 200.0));
853
854 let start_x = line_min_x(&painted_runs(text(content), bc));
855 let center_x = line_min_x(&painted_runs(text(content).align(TextAlign::Center), bc));
856 let right_x = line_min_x(&painted_runs(text(content).align(TextAlign::Right), bc));
857
858 assert_eq!(start_x.len(), 2, "expected two hard-broken lines");
859 assert_eq!(center_x.len(), 2);
860 assert_eq!(right_x.len(), 2);
861
862 assert!(
863 start_x[0].abs() < 0.5 && start_x[1].abs() < 0.5,
864 "default (start) alignment must hug the left edge: {start_x:?}"
865 );
866 assert!(
867 center_x[0] > center_x[1] + 1.0,
868 "the shorter line must center further right than the longer one: {center_x:?}"
869 );
870 assert!(
871 right_x[0] > right_x[1] + 1.0,
872 "the shorter line's right-aligned left edge must sit further right: {right_x:?}"
873 );
874 }
875
876 use frust_text::TextOverflow;
879
880 #[test]
881 fn max_lines_truncates_wrapped_content_to_one_line() {
882 let content = "Hello from Frust, the pure Rust mobile UI toolkit";
883 let bc = BoxConstraints::loose(Size::new(80.0, 200.0));
884
885 let unbounded = line_min_x(&painted_runs(text(content), bc));
886 assert!(
887 unbounded.len() > 1,
888 "fixture sanity: expected this phrase to wrap at 80px, got {} line(s)",
889 unbounded.len()
890 );
891
892 let bounded = line_min_x(&painted_runs(
893 text(content).max_lines(1).overflow(TextOverflow::Ellipsis),
894 bc,
895 ));
896 assert_eq!(
897 bounded.len(),
898 1,
899 "max_lines(1) must render exactly one line"
900 );
901 }
902
903 #[test]
904 fn clip_overflow_also_drops_extra_lines() {
905 let content = "Hello from Frust, the pure Rust mobile UI toolkit";
908 let bc = BoxConstraints::loose(Size::new(80.0, 200.0));
909
910 let clipped = line_min_x(&painted_runs(text(content).max_lines(1), bc));
911 assert_eq!(clipped.len(), 1);
912 }
913
914 #[test]
915 fn changing_max_lines_between_rebuilds_invalidates_the_cached_shape() {
916 let content = "Hello from Frust, the pure Rust mobile UI toolkit";
922 let bc = BoxConstraints::loose(Size::new(80.0, 200.0));
923
924 let view_a = text(content).max_lines(1).overflow(TextOverflow::Ellipsis);
925 let mut widget = View::<()>::build(&view_a, &mut frust_core::BuildCtx::new(&mut 0u64));
926 {
927 let mut tcx = TextContext::new();
928 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
929 widget.layout(&mut lctx, &bc);
930 }
931
932 let view_b = text(content).max_lines(2).overflow(TextOverflow::Ellipsis);
933 View::<()>::rebuild(
934 &view_b,
935 &view_a,
936 &mut widget,
937 &mut frust_core::BuildCtx::new(&mut 0u64),
938 );
939
940 let mut tcx = TextContext::new();
941 let mut lctx = LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), None);
942 widget.layout(&mut lctx, &bc);
943 let mut rec = GlyphRunRecorder::default();
944 let mut pctx = PaintCtx::new(Point::ZERO, bc.max());
945 widget.paint(&mut pctx, &mut rec);
946 assert_eq!(
947 line_min_x(&rec.runs).len(),
948 2,
949 "the rebuild must have re-shaped against the new max_lines(2), \
950 not replayed the max_lines(1) cache"
951 );
952 }
953
954 #[test]
967 fn theme_swap_with_no_view_change_repaints_new_glyph_color() {
968 use frust_core::{FrameTime, RenderRoot};
969
970 fn logic(_state: &mut ()) -> TextView {
971 text("label").themed_role(ThemeTextColor::OnPrimary)
972 }
973
974 let mut root: RenderRoot<(), TextView> = RenderRoot::new();
975 let mut state = ();
976
977 let mut theme_a = Theme::neutral();
978 theme_a.brightness = frust_theme::Brightness::Light;
979 let color_a = theme_a.scheme().on_primary;
980 root.set_theme(Box::new(theme_a));
981 root.rebuild(&mut logic, &mut state);
982
983 let mut tcx = TextContext::new();
984 root.layout_with_text(Size::new(200.0, 100.0), &mut tcx as &mut dyn Any);
985 let mut rec = GlyphRecorder::default();
986 root.paint(&mut rec, FrameTime::ZERO);
987 assert_eq!(
988 *rec.colors.first().expect("glyph run painted"),
989 color_a,
990 "sanity: first paint reflects theme A's on_primary role"
991 );
992
993 let mut theme_b = Theme::neutral();
999 theme_b.brightness = frust_theme::Brightness::Dark;
1000 let color_b = theme_b.scheme().on_primary;
1001 assert_ne!(
1002 color_a, color_b,
1003 "fixture sanity: themes must actually differ"
1004 );
1005 root.set_theme(Box::new(theme_b));
1006
1007 root.layout_with_text(Size::new(200.0, 100.0), &mut tcx as &mut dyn Any);
1008 let mut rec2 = GlyphRecorder::default();
1009 root.paint(&mut rec2, FrameTime::ZERO);
1010 assert_eq!(
1011 *rec2.colors.first().expect("glyph run painted"),
1012 color_b,
1013 "a bare theme swap (no view change) must re-resolve the themed glyph \
1014 color at the next layout, since the color is baked into the cached \
1015 TextLayout at layout time, not read fresh at paint time"
1016 );
1017 }
1018
1019 const TUFFY: &[u8] = include_bytes!("../../frust-text/tests/fonts/Tuffy-Subset.ttf");
1035
1036 const UNREGISTERED: &str = "Frust No Such Family";
1039
1040 fn tuffy_context() -> TextContext {
1042 let mut tcx = TextContext::new();
1043 tcx.register_fonts(TUFFY.to_vec())
1044 .expect("the Tuffy test face registers");
1045 tcx
1046 }
1047
1048 fn role_theme(body_large: &str, label_large: &str) -> Theme {
1051 let mut theme = Theme::neutral();
1052 theme.type_scale.body_large.family = FontFamily::named(body_large);
1053 theme.type_scale.label_large.family = FontFamily::named(label_large);
1054 theme
1055 }
1056
1057 fn painted_runs_in(
1060 view: &TextView,
1061 theme: Option<&Theme>,
1062 tcx: &mut TextContext,
1063 ) -> Vec<GlyphRun> {
1064 let mut widget = View::<()>::build(view, &mut frust_core::BuildCtx::new(&mut 0u64));
1065 let mut lctx =
1066 LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1067 widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(200.0, 100.0)));
1068 let mut rec = GlyphRunRecorder::default();
1069 let mut pctx = PaintCtx::new(Point::ZERO, Size::new(200.0, 100.0));
1070 widget.paint(&mut pctx, &mut rec);
1071 rec.runs
1072 }
1073
1074 fn all_runs_are(runs: &[GlyphRun], face: &[u8]) -> bool {
1076 !runs.is_empty() && runs.iter().all(|r| r.font.font().data.as_ref() == face)
1077 }
1078
1079 fn no_run_is(runs: &[GlyphRun], face: &[u8]) -> bool {
1081 !runs.is_empty() && runs.iter().all(|r| r.font.font().data.as_ref() != face)
1082 }
1083
1084 #[test]
1085 fn a_themed_family_resolves_the_theme_roles_family() {
1086 let mut tcx = tuffy_context();
1087 let body = text("Hello").themed_family(ThemeTextType::BodyLarge);
1088 let label = text("Hello").themed_family(ThemeTextType::LabelLarge);
1089
1090 let body_is_tuffy = role_theme("Tuffy", UNREGISTERED);
1094 assert!(
1095 all_runs_are(
1096 &painted_runs_in(&body, Some(&body_is_tuffy), &mut tcx),
1097 TUFFY
1098 ),
1099 "BodyLarge must shape in the theme's body_large family (Tuffy)"
1100 );
1101 assert!(
1102 no_run_is(
1103 &painted_runs_in(&label, Some(&body_is_tuffy), &mut tcx),
1104 TUFFY
1105 ),
1106 "LabelLarge must not read the body_large slot"
1107 );
1108
1109 let label_is_tuffy = role_theme(UNREGISTERED, "Tuffy");
1110 assert!(
1111 all_runs_are(
1112 &painted_runs_in(&label, Some(&label_is_tuffy), &mut tcx),
1113 TUFFY
1114 ),
1115 "LabelLarge must shape in the theme's label_large family (Tuffy)"
1116 );
1117 assert!(
1118 no_run_is(
1119 &painted_runs_in(&body, Some(&label_is_tuffy), &mut tcx),
1120 TUFFY
1121 ),
1122 "BodyLarge must not read the label_large slot"
1123 );
1124
1125 assert!(
1128 no_run_is(
1129 &painted_runs_in(&text("Hello"), Some(&body_is_tuffy), &mut tcx),
1130 TUFFY
1131 ),
1132 "a text that never opted in must not pick up a type-scale family"
1133 );
1134 }
1135
1136 #[test]
1137 fn an_explicit_family_or_style_wins_over_a_themed_family_in_either_order() {
1138 let theme = role_theme(UNREGISTERED, UNREGISTERED);
1141 let mut tcx = tuffy_context();
1142 let tuffy = FontFamily::named("Tuffy");
1143 let views = [
1144 text("Hello")
1145 .themed_family(ThemeTextType::BodyLarge)
1146 .family(tuffy.clone()),
1147 text("Hello")
1148 .family(tuffy.clone())
1149 .themed_family(ThemeTextType::BodyLarge),
1150 text("Hello")
1151 .style(TextStyle {
1152 family: tuffy.clone(),
1153 ..TextStyle::default()
1154 })
1155 .themed_family(ThemeTextType::BodyLarge),
1156 ];
1157 for (i, view) in views.iter().enumerate() {
1158 let runs = painted_runs_in(view, Some(&theme), &mut tcx);
1159 assert!(
1160 all_runs_are(&runs, TUFFY),
1161 "case {i}: an explicit family must win over the BodyLarge role"
1162 );
1163 }
1164 }
1165
1166 #[test]
1167 fn an_explicit_color_does_not_suppress_the_themed_family() {
1168 let theme = role_theme("Tuffy", UNREGISTERED);
1171 let mut tcx = tuffy_context();
1172 let custom = Color::from_rgb8(1, 2, 3);
1173 let runs = painted_runs_in(
1174 &text("Hello")
1175 .color(custom)
1176 .themed_family(ThemeTextType::BodyLarge),
1177 Some(&theme),
1178 &mut tcx,
1179 );
1180 assert!(
1181 all_runs_are(&runs, TUFFY),
1182 "an explicit color must not stop the family resolving from BodyLarge"
1183 );
1184 assert!(
1185 runs.iter()
1186 .all(|r| matches!(r.brush, Brush::Solid(c) if c == custom)),
1187 "the explicit color must still win over the themed color role"
1188 );
1189 }
1190
1191 #[test]
1192 fn without_a_theme_or_a_role_the_family_is_exactly_the_style_default() {
1193 let view = text("Hello").themed_family(ThemeTextType::BodyLarge);
1195 let widget = View::<()>::build(&view, &mut frust_core::BuildCtx::new(&mut 0u64));
1196 assert_eq!(
1197 widget.effective_style(None),
1198 TextStyle::default(),
1199 "no theme: a themed family must leave the style untouched (SystemUi)"
1200 );
1201 assert_eq!(widget.effective_style(None).family, FontFamily::SystemUi);
1202
1203 let theme = role_theme("Tuffy", "Tuffy");
1204 let plain = View::<()>::build(&text("Hello"), &mut frust_core::BuildCtx::new(&mut 0u64));
1205 assert_eq!(
1206 plain.effective_style(Some(&theme)).family,
1207 FontFamily::SystemUi,
1208 "no role: a theme must not change the family of a text that never opted in"
1209 );
1210
1211 let mut tcx = tuffy_context();
1213 let opted_in = painted_runs_in(&view, None, &mut tcx);
1214 let never = painted_runs_in(&text("Hello"), None, &mut tcx);
1215 let faces = |runs: &[GlyphRun]| -> Vec<Vec<u8>> {
1216 runs.iter()
1217 .map(|r| r.font.font().data.as_ref().to_vec())
1218 .collect()
1219 };
1220 assert_eq!(faces(&opted_in), faces(&never));
1221 }
1222
1223 #[test]
1224 fn changing_the_family_role_on_rebuild_invalidates_the_cached_shape() {
1225 let theme = role_theme(UNREGISTERED, "Tuffy");
1226 let mut tcx = tuffy_context();
1227 let bc = BoxConstraints::loose(Size::new(200.0, 100.0));
1228 let paint = |widget: &mut TextWidget, tcx: &mut TextContext| {
1229 let mut lctx =
1230 LayoutCtx::with_resources(Some(tcx as &mut dyn Any), Some(&theme as &dyn Any));
1231 widget.layout(&mut lctx, &bc);
1232 let mut rec = GlyphRunRecorder::default();
1233 widget.paint(&mut PaintCtx::new(Point::ZERO, bc.max()), &mut rec);
1234 rec.runs
1235 };
1236
1237 let view_a = text("Hello").themed_family(ThemeTextType::BodyLarge);
1238 let mut widget = View::<()>::build(&view_a, &mut frust_core::BuildCtx::new(&mut 0u64));
1239 assert!(no_run_is(&paint(&mut widget, &mut tcx), TUFFY));
1240
1241 let view_b = text("Hello").themed_family(ThemeTextType::LabelLarge);
1242 let flags = View::<()>::rebuild(
1243 &view_b,
1244 &view_a,
1245 &mut widget,
1246 &mut frust_core::BuildCtx::new(&mut 0u64),
1247 );
1248 assert!(flags.needs_layout(), "a role change must request layout");
1249 assert!(
1250 all_runs_are(&paint(&mut widget, &mut tcx), TUFFY),
1251 "the rebuild must re-shape in LabelLarge's family (Tuffy), not \
1252 replay the BodyLarge cache"
1253 );
1254 }
1255
1256 #[test]
1257 fn a_type_scale_family_swap_with_no_view_change_reshapes() {
1258 use frust_core::{FrameTime, RenderRoot};
1264
1265 fn logic(_state: &mut ()) -> TextView {
1266 text("label").themed_family(ThemeTextType::BodyLarge)
1267 }
1268 #[derive(Default)]
1269 struct FaceRecorder {
1270 faces: Vec<Vec<u8>>,
1271 }
1272 impl PaintScene for FaceRecorder {
1273 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1274 fn draw_text(&mut self, _o: Point, _t: &str) {}
1275 fn draw_glyph_run(&mut self, run: GlyphRun) {
1276 self.faces.push(run.font.font().data.as_ref().to_vec());
1277 }
1278 }
1279 let paint_faces = |root: &mut RenderRoot<(), TextView>, tcx: &mut TextContext| {
1280 root.layout_with_text(Size::new(200.0, 100.0), tcx as &mut dyn Any);
1281 let mut rec = FaceRecorder::default();
1282 root.paint(&mut rec, FrameTime::ZERO);
1283 rec.faces
1284 };
1285
1286 let mut root: RenderRoot<(), TextView> = RenderRoot::new();
1287 let mut tcx = tuffy_context();
1288 root.set_theme(Box::new(role_theme(UNREGISTERED, UNREGISTERED)));
1289 root.rebuild(&mut logic, &mut ());
1290 let before = paint_faces(&mut root, &mut tcx);
1291 assert!(!before.is_empty() && before.iter().all(|f| f != TUFFY));
1292
1293 root.set_theme(Box::new(role_theme("Tuffy", UNREGISTERED)));
1294 let after = paint_faces(&mut root, &mut tcx);
1295 assert!(
1296 !after.is_empty() && after.iter().all(|f| f == TUFFY),
1297 "a bare type-scale family swap must re-shape the opted-in text in the \
1298 new family"
1299 );
1300 }
1301}