use frust::Theme;
use frust::authoring::text::{FontWeight, TextContext, TextLayout, TextStyle};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
LayoutCtx, PaintCtx, PaintScene, Role, SemanticsCtx, View, Widget, any,
};
use kurbo::{Point, Rect, Size, Vec2};
use peniko::{Brush, Color};
const DOT_SIZE: f64 = 8.0;
const LABEL_MIN_SIZE: f64 = 18.0;
const LABEL_H_PAD: f64 = 6.0;
const LABEL_V_PAD: f64 = 2.0;
const LABEL_CORNER_RADIUS: f64 = 10.0;
const LABEL_FONT_SIZE: f32 = 10.0;
const DEFAULT_OFFSET: Vec2 = Vec2::new(8.0, -6.0);
const DEFAULT_MAX_COUNT: u32 = 99;
const FALLBACK_CONTAINER: Color = Color::from_rgb8(0xF9, 0xDE, 0xDC);
const FALLBACK_LABEL: Color = Color::from_rgb8(0x41, 0x0E, 0x0B);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BadgeAlignment {
TopLeft,
TopCenter,
#[default]
TopRight,
}
fn format_count(count: u32, max: u32) -> String {
if count > max {
format!("{max}+")
} else {
count.to_string()
}
}
#[derive(Clone, Debug, PartialEq)]
enum ResolvedBadge {
None,
Dot,
Label(String),
}
fn resolve(show_dot: bool, count: Option<u32>, max_count: u32) -> ResolvedBadge {
match (show_dot, count) {
(false, None) => ResolvedBadge::None,
(true, _) => ResolvedBadge::Dot,
(false, Some(count)) => ResolvedBadge::Label(format_count(count, max_count)),
}
}
fn label_style(theme: Option<&Theme>) -> TextStyle {
let mut style = TextStyle {
weight: FontWeight::SEMI_BOLD,
..TextStyle::new(LABEL_FONT_SIZE, Color::BLACK)
};
if let Some(theme) = theme {
style.family = theme.type_scale.label_small.family.clone();
}
style
}
struct LabelRun {
content: String,
style: TextStyle,
layout: Option<TextLayout>,
}
impl LabelRun {
fn new() -> Self {
Self {
content: String::new(),
style: label_style(None),
layout: None,
}
}
fn set_content(&mut self, content: &str) {
if self.content != content {
self.content = content.to_string();
self.layout = None;
}
}
fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
if self.style != *style {
self.style = style.clone();
self.layout = None;
}
if let Some(layout) = &self.layout {
return layout.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout(&self.content, &self.style, None);
let size = laid.size();
self.layout = Some(laid);
size
}
fn size(&self) -> Size {
self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
}
fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let Some(layout) = &self.layout else {
return;
};
for mut run in layout.to_scene_runs(origin) {
run.brush = Brush::Solid(color);
scene.draw_glyph_run(run);
}
}
}
pub struct BadgeView<State: 'static> {
child: AnyView<State>,
show_dot: bool,
count: Option<u32>,
max_count: u32,
alignment: BadgeAlignment,
offset: Option<Vec2>,
background_color: Option<Color>,
foreground_color: Option<Color>,
semantic_label: Option<String>,
}
pub fn badge<State: 'static, V: View<State>>(child: V) -> BadgeView<State> {
BadgeView {
child: any(child),
show_dot: false,
count: None,
max_count: DEFAULT_MAX_COUNT,
alignment: BadgeAlignment::default(),
offset: None,
background_color: None,
foreground_color: None,
semantic_label: None,
}
}
impl<State: 'static> BadgeView<State> {
pub fn dot(mut self) -> Self {
self.show_dot = true;
self
}
pub fn count(mut self, count: u32) -> Self {
self.count = Some(count);
self
}
pub fn max_count(mut self, max_count: u32) -> Self {
self.max_count = max_count;
self
}
pub fn alignment(mut self, alignment: BadgeAlignment) -> Self {
self.alignment = alignment;
self
}
pub fn offset(mut self, offset: Vec2) -> Self {
self.offset = Some(offset);
self
}
pub fn background_color(mut self, color: Color) -> Self {
self.background_color = Some(color);
self
}
pub fn foreground_color(mut self, color: Color) -> Self {
self.foreground_color = Some(color);
self
}
pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
self.semantic_label = Some(label.into());
self
}
}
pub struct BadgeWidget {
content: ChildPod,
resolved: ResolvedBadge,
alignment: BadgeAlignment,
offset: Option<Vec2>,
background_color: Option<Color>,
foreground_color: Option<Color>,
semantic_label: Option<String>,
count: Option<u32>,
label_run: LabelRun,
indicator_size: Size,
indicator_offset: Point,
}
impl<State: 'static> View<State> for BadgeView<State> {
type Element = BadgeWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> BadgeWidget {
BadgeWidget {
content: frust::authoring::build_child(&self.child, ctx),
resolved: resolve(self.show_dot, self.count, self.max_count),
alignment: self.alignment,
offset: self.offset,
background_color: self.background_color,
foreground_color: self.foreground_color,
semantic_label: self.semantic_label.clone(),
count: self.count,
label_run: LabelRun::new(),
indicator_size: Size::ZERO,
indicator_offset: Point::ZERO,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut BadgeWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
let next_resolved = resolve(self.show_dot, self.count, self.max_count);
if element.resolved != next_resolved {
element.resolved = next_resolved;
flags |= ChangeFlags::LAYOUT;
}
if element.alignment != self.alignment || element.offset != self.offset {
element.alignment = self.alignment;
element.offset = self.offset;
flags |= ChangeFlags::LAYOUT;
}
if element.background_color != self.background_color
|| element.foreground_color != self.foreground_color
{
element.background_color = self.background_color;
element.foreground_color = self.foreground_color;
flags |= ChangeFlags::PAINT;
}
element.semantic_label = self.semantic_label.clone();
element.count = self.count;
flags |=
frust::authoring::rebuild_child(&prev.child, &self.child, &mut element.content, ctx);
flags
}
fn teardown(&self, element: &mut BadgeWidget, ctx: &mut BuildCtx<'_>) {
frust::authoring::teardown_child(&self.child, &mut element.content, ctx);
}
}
impl Widget for BadgeWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let content_size = self.content.layout_child(ctx, bc);
let ResolvedBadge::None = self.resolved else {
self.indicator_size = match &self.resolved {
ResolvedBadge::None => unreachable!("handled by the let-else arm above"),
ResolvedBadge::Dot => Size::new(DOT_SIZE, DOT_SIZE),
ResolvedBadge::Label(text) => {
self.label_run.set_content(text);
let style = label_style(Theme::from_layout_ctx(ctx));
let text_size = self.label_run.shape(ctx, &style);
Size::new(
(text_size.width + LABEL_H_PAD * 2.0).max(LABEL_MIN_SIZE),
(text_size.height + LABEL_V_PAD * 2.0).max(LABEL_MIN_SIZE),
)
}
};
let offset = self.offset.unwrap_or(DEFAULT_OFFSET);
let dx = match self.alignment {
BadgeAlignment::TopLeft => -offset.x,
BadgeAlignment::TopCenter => (content_size.width - self.indicator_size.width) / 2.0,
BadgeAlignment::TopRight => {
content_size.width - self.indicator_size.width + offset.x
}
};
let indicator_origin = Point::new(dx, offset.y);
let content_rect = Rect::from_origin_size(Point::ZERO, content_size);
let indicator_rect = Rect::from_origin_size(indicator_origin, self.indicator_size);
let union = content_rect.union(indicator_rect);
let size = bc.constrain(union.size());
let shift_axis = |wanted: f64, available: f64| {
if wanted <= 0.0 || available <= 0.0 {
0.0
} else {
wanted.min(available)
}
};
let shift = Vec2::new(
shift_axis(-union.x0, size.width - content_size.width),
shift_axis(-union.y0, size.height - content_size.height),
);
self.content.set_origin(Point::ZERO + shift);
self.indicator_offset = indicator_origin + shift;
return size;
};
self.content.set_origin(Point::ZERO);
bc.constrain(content_size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
self.content.paint_child(ctx, scene);
let theme = Theme::from_paint_ctx(ctx);
let origin = ctx.origin() + self.indicator_offset.to_vec2();
match &self.resolved {
ResolvedBadge::None => {}
ResolvedBadge::Dot => {
let bg = self
.background_color
.unwrap_or_else(|| resolve_container(theme));
scene.fill_rounded_rect(
origin,
self.indicator_size,
self.indicator_size.width / 2.0,
bg,
);
}
ResolvedBadge::Label(_) => {
let bg = self
.background_color
.unwrap_or_else(|| resolve_container(theme));
let fg = self
.foreground_color
.unwrap_or_else(|| resolve_label(theme));
scene.fill_rounded_rect(origin, self.indicator_size, LABEL_CORNER_RADIUS, bg);
let text_size = self.label_run.size();
let text_origin = Point::new(
origin.x + (self.indicator_size.width - text_size.width) / 2.0,
origin.y + (self.indicator_size.height - text_size.height) / 2.0,
);
self.label_run.paint(text_origin, fg, scene);
}
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
frust::authoring::route_event_single(&mut self.content, ctx, event)
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
if !matches!(self.resolved, ResolvedBadge::None) {
ctx.push_node(Role::Label, |node| {
let label = self
.semantic_label
.clone()
.unwrap_or_else(|| match self.count {
Some(count) => format!("Notifications: {count}"),
None => "Notifications".to_string(),
});
node.set_value(label.as_str());
});
}
self.content.semantics_child(ctx);
}
frust::authoring::visit_children!(content);
}
fn resolve_container(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().error_container,
None => FALLBACK_CONTAINER,
}
}
fn resolve_label(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().on_error_container,
None => FALLBACK_LABEL,
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::text::FontFamily;
use frust_widgets::test_support::leaf_any;
use std::any::Any;
fn build<S: 'static>(view: &BadgeView<S>) -> BadgeWidget {
let mut counter = 0u64;
View::<S>::build(view, &mut BuildCtx::new(&mut counter))
}
#[test]
fn format_count_shows_the_exact_value_at_or_under_the_max() {
assert_eq!(format_count(0, 99), "0");
assert_eq!(format_count(99, 99), "99");
}
#[test]
fn format_count_switches_to_overflow_past_the_max() {
assert_eq!(format_count(100, 99), "99+");
assert_eq!(format_count(150, 99), "99+");
}
#[test]
fn format_count_honors_a_custom_max() {
assert_eq!(format_count(999, 999), "999");
assert_eq!(format_count(1000, 999), "999+");
}
#[test]
fn resolve_is_none_with_neither_dot_nor_count() {
assert_eq!(resolve(false, None, 99), ResolvedBadge::None);
}
#[test]
fn resolve_dot_wins_over_count() {
assert_eq!(resolve(true, Some(3), 99), ResolvedBadge::Dot);
}
#[test]
fn resolve_count_without_dot_is_a_label() {
assert_eq!(
resolve(false, Some(3), 99),
ResolvedBadge::Label("3".to_string())
);
}
#[test]
fn no_badge_reports_the_child_s_own_size_with_zero_origin() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0));
let mut w = build(&view);
let mut ctx = LayoutCtx::new();
let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert_eq!(size, Size::new(40.0, 40.0));
assert_eq!(w.content.origin(), Point::ZERO);
}
#[test]
fn dot_badge_reserves_room_for_the_indicator() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
let mut w = build(&view);
let mut ctx = LayoutCtx::new();
let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert_eq!(size, Size::new(48.0, 46.0));
}
#[test]
fn dot_badge_shifts_content_to_keep_it_inside_the_reported_box() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
let mut w = build(&view);
let mut ctx = LayoutCtx::new();
w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert_eq!(w.content.origin(), Point::new(0.0, 6.0));
}
#[test]
fn top_right_places_the_indicator_past_the_child_s_right_edge() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
let mut w = build(&view);
let mut ctx = LayoutCtx::new();
w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert_eq!(w.indicator_offset.x, 40.0 - 8.0 + 8.0);
}
#[test]
fn top_left_places_the_indicator_left_of_the_shifted_content() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0))
.dot()
.alignment(BadgeAlignment::TopLeft);
let mut w = build(&view);
let mut ctx = LayoutCtx::new();
w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert_eq!(w.indicator_offset.x, 0.0);
assert_eq!(w.content.origin().x, 8.0);
}
#[test]
fn top_center_ignores_offset_dx_and_centers_on_the_child() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0))
.dot()
.alignment(BadgeAlignment::TopCenter);
let mut w = build(&view);
let mut ctx = LayoutCtx::new();
w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert_eq!(w.indicator_offset.x, 16.0);
}
#[test]
fn numeric_label_is_at_least_the_theme_s_minimum_pill_size() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert!(w.indicator_size.width >= LABEL_MIN_SIZE);
assert!(w.indicator_size.height >= LABEL_MIN_SIZE);
}
#[test]
fn overflowing_count_paints_the_max_plus_label() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(150);
let mut w = build(&view);
assert_eq!(
resolve(false, Some(150), DEFAULT_MAX_COUNT),
ResolvedBadge::Label("99+".to_string())
);
let mut tcx = TextContext::new();
let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
assert_eq!(w.resolved, ResolvedBadge::Label("99+".to_string()));
}
#[derive(Default)]
struct Recorder {
rounded_rects: Vec<(Point, Size, f64, Color)>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
fn draw_text(&mut self, _origin: Point, _text: &str) {}
fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
self.rounded_rects.push((origin, size, radius, color));
}
}
#[test]
fn no_badge_paints_no_indicator() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0));
let mut w = build(&view);
let mut ctx = LayoutCtx::new();
let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, size);
w.paint(&mut pctx, &mut rec);
assert!(rec.rounded_rects.is_empty());
}
#[test]
fn dot_paints_a_full_circle_radius_with_the_unthemed_fallback() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
let mut w = build(&view);
let mut ctx = LayoutCtx::new();
let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, size);
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.rounded_rects.len(), 1);
let (origin, indicator_size, radius, color) = rec.rounded_rects[0];
assert_eq!(indicator_size, Size::new(DOT_SIZE, DOT_SIZE));
assert_eq!(radius, DOT_SIZE / 2.0);
assert_eq!(color, FALLBACK_CONTAINER);
assert_eq!(origin, w.indicator_offset);
}
fn theme_with_label_small(family: FontFamily) -> Theme {
let mut theme = crate::baseline();
theme.type_scale.label_small.family = family;
theme
}
fn layout_themed(w: &mut BadgeWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
let mut ctx =
LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
}
#[test]
fn layout_takes_the_label_family_from_label_small() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
let mut w = build(&view);
let mut tcx = TextContext::new();
let probe = FontFamily::named("Badge Role Probe");
layout_themed(
&mut w,
&mut tcx,
Some(&theme_with_label_small(probe.clone())),
);
assert_eq!(w.label_run.style.family, probe);
assert_eq!(w.label_run.style.size, LABEL_FONT_SIZE);
assert_eq!(w.label_run.style.weight, FontWeight::SEMI_BOLD);
}
#[test]
fn without_a_theme_the_label_keeps_the_unthemed_style() {
let unthemed = TextStyle {
weight: FontWeight::SEMI_BOLD,
..TextStyle::new(LABEL_FONT_SIZE, Color::BLACK)
};
assert_eq!(label_style(None), unthemed);
assert_eq!(unthemed.family, FontFamily::SystemUi);
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
let mut w = build(&view);
let mut tcx = TextContext::new();
layout_themed(&mut w, &mut tcx, None);
assert_eq!(w.label_run.style, unthemed);
}
#[test]
fn a_theme_swap_reshapes_the_cached_label() {
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
let mut w = build(&view);
let mut tcx = TextContext::new();
let first = theme_with_label_small(FontFamily::named("Badge Swap Probe A"));
layout_themed(&mut w, &mut tcx, Some(&first));
let settled = tcx.shape_cache_stats();
layout_themed(&mut w, &mut tcx, Some(&first));
assert_eq!(
tcx.shape_cache_stats(),
settled,
"an unchanged theme reshapes nothing"
);
let second = theme_with_label_small(FontFamily::named("Badge Swap Probe B"));
layout_themed(&mut w, &mut tcx, Some(&second));
assert!(
tcx.shape_cache_stats().shapes > settled.shapes,
"a family swap must reshape the label, not serve the old face"
);
}
#[cfg(feature = "bundled-fonts")]
fn shaped_font(w: &BadgeWidget) -> Vec<u8> {
let layout = w.label_run.layout.as_ref().expect("the label was shaped");
let runs = layout.to_scene_runs(Point::ZERO);
let run = runs.first().expect("the label shaped to at least one run");
run.font.font().data.as_ref().to_vec()
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn a_theme_swap_between_registered_families_changes_the_shaped_face() {
let (flex, mono) = (crate::tokens::font_data()[0], crate::tokens::font_data()[1]);
let mut tcx = TextContext::new();
for bytes in [flex, mono] {
tcx.register_fonts(bytes.to_vec())
.expect("the bundled faces must register");
}
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
let mut w = build(&view);
let flex_theme =
theme_with_label_small(FontFamily::named(crate::tokens::ROBOTO_FLEX_FAMILY));
layout_themed(&mut w, &mut tcx, Some(&flex_theme));
assert!(
shaped_font(&w) == flex,
"labelSmall's family shapes the label"
);
let mono_theme =
theme_with_label_small(FontFamily::named(crate::tokens::ROBOTO_MONO_FAMILY));
layout_themed(&mut w, &mut tcx, Some(&mono_theme));
assert!(
shaped_font(&w) == mono,
"after a theme swap the label must shape in the new family"
);
}
#[test]
fn explicit_colors_win_over_the_unthemed_fallback() {
let bg = Color::from_rgb8(0x11, 0x22, 0x33);
let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot().background_color(bg);
let mut w = build(&view);
let mut ctx = LayoutCtx::new();
let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
let mut rec = Recorder::default();
let mut pctx = PaintCtx::new(Point::ZERO, size);
w.paint(&mut pctx, &mut rec);
assert_eq!(rec.rounded_rects[0].3, bg);
}
}