1use frust::Theme;
57use frust::authoring::text::{FontWeight, TextContext, TextLayout, TextStyle};
58use frust::authoring::{
59 AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
60 LayoutCtx, PaintCtx, PaintScene, Role, SemanticsCtx, View, Widget, any,
61};
62use kurbo::{Point, Rect, Size, Vec2};
63use peniko::{Brush, Color};
64
65const DOT_SIZE: f64 = 8.0;
70const LABEL_MIN_SIZE: f64 = 18.0;
73const LABEL_H_PAD: f64 = 6.0;
76const LABEL_V_PAD: f64 = 2.0;
79const LABEL_CORNER_RADIUS: f64 = 10.0;
84const LABEL_FONT_SIZE: f32 = 10.0;
86const DEFAULT_OFFSET: Vec2 = Vec2::new(8.0, -6.0);
90const DEFAULT_MAX_COUNT: u32 = 99;
94
95const FALLBACK_CONTAINER: Color = Color::from_rgb8(0xF9, 0xDE, 0xDC);
98const FALLBACK_LABEL: Color = Color::from_rgb8(0x41, 0x0E, 0x0B);
101
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub enum BadgeAlignment {
106 TopLeft,
108 TopCenter,
110 #[default]
112 TopRight,
113}
114
115fn format_count(count: u32, max: u32) -> String {
118 if count > max {
119 format!("{max}+")
120 } else {
121 count.to_string()
122 }
123}
124
125#[derive(Clone, Debug, PartialEq)]
130enum ResolvedBadge {
131 None,
134 Dot,
135 Label(String),
136}
137
138fn resolve(show_dot: bool, count: Option<u32>, max_count: u32) -> ResolvedBadge {
139 match (show_dot, count) {
140 (false, None) => ResolvedBadge::None,
141 (true, _) => ResolvedBadge::Dot,
142 (false, Some(count)) => ResolvedBadge::Label(format_count(count, max_count)),
143 }
144}
145
146fn label_style(theme: Option<&Theme>) -> TextStyle {
153 let mut style = TextStyle {
154 weight: FontWeight::SEMI_BOLD,
155 ..TextStyle::new(LABEL_FONT_SIZE, Color::BLACK)
156 };
157 if let Some(theme) = theme {
158 style.family = theme.type_scale.label_small.family.clone();
159 }
160 style
161}
162
163struct LabelRun {
172 content: String,
173 style: TextStyle,
175 layout: Option<TextLayout>,
176}
177
178impl LabelRun {
179 fn new() -> Self {
180 Self {
181 content: String::new(),
182 style: label_style(None),
183 layout: None,
184 }
185 }
186
187 fn set_content(&mut self, content: &str) {
188 if self.content != content {
189 self.content = content.to_string();
190 self.layout = None;
191 }
192 }
193
194 fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
196 if self.style != *style {
197 self.style = style.clone();
198 self.layout = None;
199 }
200 if let Some(layout) = &self.layout {
201 return layout.size();
202 }
203 let text_ctx = ctx.text_context::<TextContext>();
204 let laid = text_ctx.layout(&self.content, &self.style, None);
205 let size = laid.size();
206 self.layout = Some(laid);
207 size
208 }
209
210 fn size(&self) -> Size {
211 self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
212 }
213
214 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
216 let Some(layout) = &self.layout else {
217 return;
218 };
219 for mut run in layout.to_scene_runs(origin) {
220 run.brush = Brush::Solid(color);
221 scene.draw_glyph_run(run);
222 }
223 }
224}
225
226pub struct BadgeView<State: 'static> {
228 child: AnyView<State>,
229 show_dot: bool,
230 count: Option<u32>,
231 max_count: u32,
232 alignment: BadgeAlignment,
233 offset: Option<Vec2>,
234 background_color: Option<Color>,
235 foreground_color: Option<Color>,
236 semantic_label: Option<String>,
237}
238
239pub fn badge<State: 'static, V: View<State>>(child: V) -> BadgeView<State> {
243 BadgeView {
244 child: any(child),
245 show_dot: false,
246 count: None,
247 max_count: DEFAULT_MAX_COUNT,
248 alignment: BadgeAlignment::default(),
249 offset: None,
250 background_color: None,
251 foreground_color: None,
252 semantic_label: None,
253 }
254}
255
256impl<State: 'static> BadgeView<State> {
257 pub fn dot(mut self) -> Self {
259 self.show_dot = true;
260 self
261 }
262
263 pub fn count(mut self, count: u32) -> Self {
266 self.count = Some(count);
267 self
268 }
269
270 pub fn max_count(mut self, max_count: u32) -> Self {
273 self.max_count = max_count;
274 self
275 }
276
277 pub fn alignment(mut self, alignment: BadgeAlignment) -> Self {
280 self.alignment = alignment;
281 self
282 }
283
284 pub fn offset(mut self, offset: Vec2) -> Self {
288 self.offset = Some(offset);
289 self
290 }
291
292 pub fn background_color(mut self, color: Color) -> Self {
294 self.background_color = Some(color);
295 self
296 }
297
298 pub fn foreground_color(mut self, color: Color) -> Self {
300 self.foreground_color = Some(color);
301 self
302 }
303
304 pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
308 self.semantic_label = Some(label.into());
309 self
310 }
311}
312
313pub struct BadgeWidget {
315 content: ChildPod,
316 resolved: ResolvedBadge,
317 alignment: BadgeAlignment,
318 offset: Option<Vec2>,
319 background_color: Option<Color>,
320 foreground_color: Option<Color>,
321 semantic_label: Option<String>,
322 count: Option<u32>,
323 label_run: LabelRun,
324 indicator_size: Size,
325 indicator_offset: Point,
326}
327
328impl<State: 'static> View<State> for BadgeView<State> {
329 type Element = BadgeWidget;
330
331 fn build(&self, ctx: &mut BuildCtx<'_>) -> BadgeWidget {
332 BadgeWidget {
333 content: frust::authoring::build_child(&self.child, ctx),
334 resolved: resolve(self.show_dot, self.count, self.max_count),
335 alignment: self.alignment,
336 offset: self.offset,
337 background_color: self.background_color,
338 foreground_color: self.foreground_color,
339 semantic_label: self.semantic_label.clone(),
340 count: self.count,
341 label_run: LabelRun::new(),
342 indicator_size: Size::ZERO,
343 indicator_offset: Point::ZERO,
344 }
345 }
346
347 fn rebuild(
348 &self,
349 prev: &Self,
350 element: &mut BadgeWidget,
351 ctx: &mut BuildCtx<'_>,
352 ) -> ChangeFlags {
353 let mut flags = ChangeFlags::NONE;
354
355 let next_resolved = resolve(self.show_dot, self.count, self.max_count);
356 if element.resolved != next_resolved {
357 element.resolved = next_resolved;
358 flags |= ChangeFlags::LAYOUT;
359 }
360 if element.alignment != self.alignment || element.offset != self.offset {
361 element.alignment = self.alignment;
362 element.offset = self.offset;
363 flags |= ChangeFlags::LAYOUT;
364 }
365 if element.background_color != self.background_color
366 || element.foreground_color != self.foreground_color
367 {
368 element.background_color = self.background_color;
369 element.foreground_color = self.foreground_color;
370 flags |= ChangeFlags::PAINT;
371 }
372 element.semantic_label = self.semantic_label.clone();
373 element.count = self.count;
374
375 flags |=
376 frust::authoring::rebuild_child(&prev.child, &self.child, &mut element.content, ctx);
377 flags
378 }
379
380 fn teardown(&self, element: &mut BadgeWidget, ctx: &mut BuildCtx<'_>) {
381 frust::authoring::teardown_child(&self.child, &mut element.content, ctx);
382 }
383}
384
385impl Widget for BadgeWidget {
386 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
387 let content_size = self.content.layout_child(ctx, bc);
388
389 let ResolvedBadge::None = self.resolved else {
390 self.indicator_size = match &self.resolved {
391 ResolvedBadge::None => unreachable!("handled by the let-else arm above"),
392 ResolvedBadge::Dot => Size::new(DOT_SIZE, DOT_SIZE),
393 ResolvedBadge::Label(text) => {
394 self.label_run.set_content(text);
395 let style = label_style(Theme::from_layout_ctx(ctx));
396 let text_size = self.label_run.shape(ctx, &style);
397 Size::new(
398 (text_size.width + LABEL_H_PAD * 2.0).max(LABEL_MIN_SIZE),
399 (text_size.height + LABEL_V_PAD * 2.0).max(LABEL_MIN_SIZE),
400 )
401 }
402 };
403
404 let offset = self.offset.unwrap_or(DEFAULT_OFFSET);
405 let dx = match self.alignment {
406 BadgeAlignment::TopLeft => -offset.x,
407 BadgeAlignment::TopCenter => (content_size.width - self.indicator_size.width) / 2.0,
408 BadgeAlignment::TopRight => {
409 content_size.width - self.indicator_size.width + offset.x
410 }
411 };
412 let indicator_origin = Point::new(dx, offset.y);
413
414 let content_rect = Rect::from_origin_size(Point::ZERO, content_size);
415 let indicator_rect = Rect::from_origin_size(indicator_origin, self.indicator_size);
416 let union = content_rect.union(indicator_rect);
417
418 let size = bc.constrain(union.size());
419
420 let shift_axis = |wanted: f64, available: f64| {
424 if wanted <= 0.0 || available <= 0.0 {
425 0.0
426 } else {
427 wanted.min(available)
428 }
429 };
430 let shift = Vec2::new(
431 shift_axis(-union.x0, size.width - content_size.width),
432 shift_axis(-union.y0, size.height - content_size.height),
433 );
434
435 self.content.set_origin(Point::ZERO + shift);
436 self.indicator_offset = indicator_origin + shift;
437
438 return size;
439 };
440
441 self.content.set_origin(Point::ZERO);
443 bc.constrain(content_size)
444 }
445
446 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
447 self.content.paint_child(ctx, scene);
448
449 let theme = Theme::from_paint_ctx(ctx);
450 let origin = ctx.origin() + self.indicator_offset.to_vec2();
451 match &self.resolved {
452 ResolvedBadge::None => {}
453 ResolvedBadge::Dot => {
454 let bg = self
455 .background_color
456 .unwrap_or_else(|| resolve_container(theme));
457 scene.fill_rounded_rect(
458 origin,
459 self.indicator_size,
460 self.indicator_size.width / 2.0,
461 bg,
462 );
463 }
464 ResolvedBadge::Label(_) => {
465 let bg = self
466 .background_color
467 .unwrap_or_else(|| resolve_container(theme));
468 let fg = self
469 .foreground_color
470 .unwrap_or_else(|| resolve_label(theme));
471 scene.fill_rounded_rect(origin, self.indicator_size, LABEL_CORNER_RADIUS, bg);
472 let text_size = self.label_run.size();
473 let text_origin = Point::new(
474 origin.x + (self.indicator_size.width - text_size.width) / 2.0,
475 origin.y + (self.indicator_size.height - text_size.height) / 2.0,
476 );
477 self.label_run.paint(text_origin, fg, scene);
478 }
479 }
480 }
481
482 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
483 frust::authoring::route_event_single(&mut self.content, ctx, event)
484 }
485
486 fn semantics(&self, ctx: &mut SemanticsCtx) {
487 if !matches!(self.resolved, ResolvedBadge::None) {
488 ctx.push_node(Role::Label, |node| {
489 let label = self
490 .semantic_label
491 .clone()
492 .unwrap_or_else(|| match self.count {
493 Some(count) => format!("Notifications: {count}"),
494 None => "Notifications".to_string(),
495 });
496 node.set_value(label.as_str());
497 });
498 }
499 self.content.semantics_child(ctx);
500 }
501
502 frust::authoring::visit_children!(content);
503}
504
505fn resolve_container(theme: Option<&Theme>) -> Color {
508 match theme {
509 Some(theme) => theme.scheme().error_container,
510 None => FALLBACK_CONTAINER,
511 }
512}
513
514fn resolve_label(theme: Option<&Theme>) -> Color {
517 match theme {
518 Some(theme) => theme.scheme().on_error_container,
519 None => FALLBACK_LABEL,
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use frust::authoring::text::FontFamily;
527 use frust_widgets::test_support::leaf_any;
528 use std::any::Any;
529
530 fn build<S: 'static>(view: &BadgeView<S>) -> BadgeWidget {
531 let mut counter = 0u64;
532 View::<S>::build(view, &mut BuildCtx::new(&mut counter))
533 }
534
535 #[test]
538 fn format_count_shows_the_exact_value_at_or_under_the_max() {
539 assert_eq!(format_count(0, 99), "0");
540 assert_eq!(format_count(99, 99), "99");
541 }
542
543 #[test]
544 fn format_count_switches_to_overflow_past_the_max() {
545 assert_eq!(format_count(100, 99), "99+");
546 assert_eq!(format_count(150, 99), "99+");
547 }
548
549 #[test]
550 fn format_count_honors_a_custom_max() {
551 assert_eq!(format_count(999, 999), "999");
552 assert_eq!(format_count(1000, 999), "999+");
553 }
554
555 #[test]
558 fn resolve_is_none_with_neither_dot_nor_count() {
559 assert_eq!(resolve(false, None, 99), ResolvedBadge::None);
560 }
561
562 #[test]
563 fn resolve_dot_wins_over_count() {
564 assert_eq!(resolve(true, Some(3), 99), ResolvedBadge::Dot);
565 }
566
567 #[test]
568 fn resolve_count_without_dot_is_a_label() {
569 assert_eq!(
570 resolve(false, Some(3), 99),
571 ResolvedBadge::Label("3".to_string())
572 );
573 }
574
575 #[test]
578 fn no_badge_reports_the_child_s_own_size_with_zero_origin() {
579 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0));
580 let mut w = build(&view);
581 let mut ctx = LayoutCtx::new();
582 let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
583 assert_eq!(size, Size::new(40.0, 40.0));
584 assert_eq!(w.content.origin(), Point::ZERO);
585 }
586
587 #[test]
590 fn dot_badge_reserves_room_for_the_indicator() {
591 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
592 let mut w = build(&view);
593 let mut ctx = LayoutCtx::new();
594 let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
595 assert_eq!(size, Size::new(48.0, 46.0));
599 }
600
601 #[test]
602 fn dot_badge_shifts_content_to_keep_it_inside_the_reported_box() {
603 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
604 let mut w = build(&view);
605 let mut ctx = LayoutCtx::new();
606 w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
607 assert_eq!(w.content.origin(), Point::new(0.0, 6.0));
610 }
611
612 #[test]
615 fn top_right_places_the_indicator_past_the_child_s_right_edge() {
616 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
617 let mut w = build(&view);
618 let mut ctx = LayoutCtx::new();
619 w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
620 assert_eq!(w.indicator_offset.x, 40.0 - 8.0 + 8.0);
621 }
622
623 #[test]
624 fn top_left_places_the_indicator_left_of_the_shifted_content() {
625 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0))
626 .dot()
627 .alignment(BadgeAlignment::TopLeft);
628 let mut w = build(&view);
629 let mut ctx = LayoutCtx::new();
630 w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
631 assert_eq!(w.indicator_offset.x, 0.0);
637 assert_eq!(w.content.origin().x, 8.0);
638 }
639
640 #[test]
641 fn top_center_ignores_offset_dx_and_centers_on_the_child() {
642 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0))
643 .dot()
644 .alignment(BadgeAlignment::TopCenter);
645 let mut w = build(&view);
646 let mut ctx = LayoutCtx::new();
647 w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
648 assert_eq!(w.indicator_offset.x, 16.0);
652 }
653
654 #[test]
657 fn numeric_label_is_at_least_the_theme_s_minimum_pill_size() {
658 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
659 let mut w = build(&view);
660 let mut tcx = TextContext::new();
661 let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
662 w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
663 assert!(w.indicator_size.width >= LABEL_MIN_SIZE);
664 assert!(w.indicator_size.height >= LABEL_MIN_SIZE);
665 }
666
667 #[test]
668 fn overflowing_count_paints_the_max_plus_label() {
669 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(150);
670 let mut w = build(&view);
671 assert_eq!(
672 resolve(false, Some(150), DEFAULT_MAX_COUNT),
673 ResolvedBadge::Label("99+".to_string())
674 );
675 let mut tcx = TextContext::new();
676 let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
677 w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
678 assert_eq!(w.resolved, ResolvedBadge::Label("99+".to_string()));
679 }
680
681 #[derive(Default)]
684 struct Recorder {
685 rounded_rects: Vec<(Point, Size, f64, Color)>,
686 }
687
688 impl PaintScene for Recorder {
689 fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
690 fn draw_text(&mut self, _origin: Point, _text: &str) {}
691 fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
692 self.rounded_rects.push((origin, size, radius, color));
693 }
694 }
695
696 #[test]
697 fn no_badge_paints_no_indicator() {
698 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0));
699 let mut w = build(&view);
700 let mut ctx = LayoutCtx::new();
701 let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
702 let mut rec = Recorder::default();
703 let mut pctx = PaintCtx::new(Point::ZERO, size);
704 w.paint(&mut pctx, &mut rec);
705 assert!(rec.rounded_rects.is_empty());
706 }
707
708 #[test]
709 fn dot_paints_a_full_circle_radius_with_the_unthemed_fallback() {
710 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
711 let mut w = build(&view);
712 let mut ctx = LayoutCtx::new();
713 let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
714 let mut rec = Recorder::default();
715 let mut pctx = PaintCtx::new(Point::ZERO, size);
716 w.paint(&mut pctx, &mut rec);
717 assert_eq!(rec.rounded_rects.len(), 1);
718 let (origin, indicator_size, radius, color) = rec.rounded_rects[0];
719 assert_eq!(indicator_size, Size::new(DOT_SIZE, DOT_SIZE));
720 assert_eq!(radius, DOT_SIZE / 2.0);
721 assert_eq!(color, FALLBACK_CONTAINER);
722 assert_eq!(origin, w.indicator_offset);
723 }
724
725 fn theme_with_label_small(family: FontFamily) -> Theme {
729 let mut theme = crate::baseline();
730 theme.type_scale.label_small.family = family;
731 theme
732 }
733
734 fn layout_themed(w: &mut BadgeWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
737 let mut ctx =
738 LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
739 w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
740 }
741
742 #[test]
743 fn layout_takes_the_label_family_from_label_small() {
744 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
745 let mut w = build(&view);
746 let mut tcx = TextContext::new();
747 let probe = FontFamily::named("Badge Role Probe");
748 layout_themed(
749 &mut w,
750 &mut tcx,
751 Some(&theme_with_label_small(probe.clone())),
752 );
753 assert_eq!(w.label_run.style.family, probe);
754 assert_eq!(w.label_run.style.size, LABEL_FONT_SIZE);
756 assert_eq!(w.label_run.style.weight, FontWeight::SEMI_BOLD);
757 }
758
759 #[test]
760 fn without_a_theme_the_label_keeps_the_unthemed_style() {
761 let unthemed = TextStyle {
762 weight: FontWeight::SEMI_BOLD,
763 ..TextStyle::new(LABEL_FONT_SIZE, Color::BLACK)
764 };
765 assert_eq!(label_style(None), unthemed);
766 assert_eq!(unthemed.family, FontFamily::SystemUi);
767
768 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
769 let mut w = build(&view);
770 let mut tcx = TextContext::new();
771 layout_themed(&mut w, &mut tcx, None);
772 assert_eq!(w.label_run.style, unthemed);
773 }
774
775 #[test]
776 fn a_theme_swap_reshapes_the_cached_label() {
777 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
778 let mut w = build(&view);
779 let mut tcx = TextContext::new();
780 let first = theme_with_label_small(FontFamily::named("Badge Swap Probe A"));
781 layout_themed(&mut w, &mut tcx, Some(&first));
782
783 let settled = tcx.shape_cache_stats();
785 layout_themed(&mut w, &mut tcx, Some(&first));
786 assert_eq!(
787 tcx.shape_cache_stats(),
788 settled,
789 "an unchanged theme reshapes nothing"
790 );
791
792 let second = theme_with_label_small(FontFamily::named("Badge Swap Probe B"));
793 layout_themed(&mut w, &mut tcx, Some(&second));
794 assert!(
795 tcx.shape_cache_stats().shapes > settled.shapes,
796 "a family swap must reshape the label, not serve the old face"
797 );
798 }
799
800 #[cfg(feature = "bundled-fonts")]
802 fn shaped_font(w: &BadgeWidget) -> Vec<u8> {
803 let layout = w.label_run.layout.as_ref().expect("the label was shaped");
804 let runs = layout.to_scene_runs(Point::ZERO);
805 let run = runs.first().expect("the label shaped to at least one run");
806 run.font.font().data.as_ref().to_vec()
807 }
808
809 #[cfg(feature = "bundled-fonts")]
810 #[test]
811 fn a_theme_swap_between_registered_families_changes_the_shaped_face() {
812 let (flex, mono) = (crate::tokens::font_data()[0], crate::tokens::font_data()[1]);
814 let mut tcx = TextContext::new();
815 for bytes in [flex, mono] {
816 tcx.register_fonts(bytes.to_vec())
817 .expect("the bundled faces must register");
818 }
819 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
820 let mut w = build(&view);
821
822 let flex_theme =
823 theme_with_label_small(FontFamily::named(crate::tokens::ROBOTO_FLEX_FAMILY));
824 layout_themed(&mut w, &mut tcx, Some(&flex_theme));
825 assert!(
826 shaped_font(&w) == flex,
827 "labelSmall's family shapes the label"
828 );
829
830 let mono_theme =
831 theme_with_label_small(FontFamily::named(crate::tokens::ROBOTO_MONO_FAMILY));
832 layout_themed(&mut w, &mut tcx, Some(&mono_theme));
833 assert!(
834 shaped_font(&w) == mono,
835 "after a theme swap the label must shape in the new family"
836 );
837 }
838
839 #[test]
840 fn explicit_colors_win_over_the_unthemed_fallback() {
841 let bg = Color::from_rgb8(0x11, 0x22, 0x33);
842 let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot().background_color(bg);
843 let mut w = build(&view);
844 let mut ctx = LayoutCtx::new();
845 let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
846 let mut rec = Recorder::default();
847 let mut pctx = PaintCtx::new(Point::ZERO, size);
848 w.paint(&mut pctx, &mut rec);
849 assert_eq!(rec.rounded_rects[0].3, bg);
850 }
851}