use std::sync::Arc;
use frust_core::accesskit::Role;
use frust_core::{
BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View,
Widget,
};
use frust_theme::Theme;
use kurbo::{Affine, BezPath, Size};
use peniko::{Brush, Color};
const DEFAULT_SIZE: f64 = 24.0;
const DEFAULT_COLOR: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
#[derive(Clone, Copy, Debug)]
pub struct IconSource {
pub d: &'static str,
pub design: f64,
}
#[derive(Debug)]
struct PathGeom {
path: BezPath,
design: f64,
}
#[derive(Clone, Debug)]
enum IconRepr {
Svg { d: &'static str, design: f64 },
Path(Arc<PathGeom>),
}
#[derive(Clone, Debug)]
pub struct IconData {
repr: IconRepr,
}
impl IconData {
pub fn from_path(path: BezPath, design_size: f64) -> Self {
Self {
repr: IconRepr::Path(Arc::new(PathGeom {
path,
design: design_size,
})),
}
}
pub fn resolve(&self) -> (BezPath, f64) {
match &self.repr {
IconRepr::Svg { d, design } => {
let path = BezPath::from_svg(d).unwrap_or_else(|e| {
panic!(
"icon SVG path failed to parse (wiring bug — a malformed \
generated IconSource): {e}"
)
});
(path, *design)
}
IconRepr::Path(geom) => (geom.path.clone(), geom.design),
}
}
pub fn same(&self, other: &IconData) -> bool {
match (&self.repr, &other.repr) {
(
IconRepr::Svg {
d: a,
design: design_a,
},
IconRepr::Svg {
d: b,
design: design_b,
},
) => a == b && design_a == design_b,
(IconRepr::Path(a), IconRepr::Path(b)) => Arc::ptr_eq(a, b),
_ => false,
}
}
}
impl From<IconSource> for IconData {
fn from(source: IconSource) -> Self {
IconData {
repr: IconRepr::Svg {
d: source.d,
design: source.design,
},
}
}
}
impl From<&IconSource> for IconData {
fn from(source: &IconSource) -> Self {
IconData::from(*source)
}
}
fn resolve_default_color(theme: Option<&Theme>) -> Color {
match theme {
Some(theme) => theme.scheme().on_surface,
None => DEFAULT_COLOR,
}
}
pub struct IconView {
data: IconData,
size: f64,
color: Option<Color>,
label: Option<String>,
}
pub fn icon(data: impl Into<IconData>) -> IconView {
IconView {
data: data.into(),
size: DEFAULT_SIZE,
color: None,
label: None,
}
}
#[allow(non_snake_case)]
pub fn Icon(data: impl Into<IconData>) -> IconView {
icon(data)
}
impl IconView {
pub fn size(mut self, size: f64) -> Self {
self.size = size;
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
}
impl<State: 'static> View<State> for IconView {
type Element = IconWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> IconWidget {
let (base_path, design) = self.data.resolve();
IconWidget {
data: self.data.clone(),
base_path,
design,
size: self.size,
laid_out_size: Size::new(self.size, self.size),
color: self.color,
label: self.label.clone(),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut IconWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
if !prev.data.same(&self.data) {
let (base_path, design) = self.data.resolve();
element.data = self.data.clone();
element.base_path = base_path;
element.design = design;
flags |= ChangeFlags::PAINT;
}
if prev.size != self.size {
element.size = self.size;
element.laid_out_size = Size::new(self.size, self.size);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if prev.color != self.color {
element.color = self.color;
flags |= ChangeFlags::PAINT;
}
if prev.label != self.label {
element.label = self.label.clone();
}
flags
}
}
pub struct IconWidget {
data: IconData,
base_path: BezPath,
design: f64,
size: f64,
laid_out_size: Size,
color: Option<Color>,
label: Option<String>,
}
impl Widget for IconWidget {
fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let laid_out = bc.constrain(Size::new(self.size, self.size));
self.laid_out_size = laid_out;
laid_out
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let color = self
.color
.unwrap_or_else(|| resolve_default_color(Theme::from_paint_ctx(ctx)));
let scale = if self.design > 0.0 {
self.laid_out_size.width.min(self.laid_out_size.height) / self.design
} else {
1.0
};
let glyph_side = self.design * scale;
let dx = (self.laid_out_size.width - glyph_side) / 2.0;
let dy = (self.laid_out_size.height - glyph_side) / 2.0;
let scaled = Affine::translate((dx, dy)) * Affine::scale(scale) * self.base_path.clone();
scene.fill_path(ctx.origin(), &scaled, &Brush::Solid(color));
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
if let Some(label) = &self.label {
ctx.push_node(Role::Image, |node| {
node.set_label(label.as_str());
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust_core::BuildCtx;
use kurbo::{Point, Rect, Shape};
const MATERIAL_DESIGN_BOX: f64 = 24.0;
fn square_data() -> IconData {
let path = Rect::new(0.0, 0.0, MATERIAL_DESIGN_BOX, MATERIAL_DESIGN_BOX).to_path(0.1);
IconData::from_path(path, MATERIAL_DESIGN_BOX)
}
fn build(view: &IconView) -> IconWidget {
let mut counter = 0u64;
<IconView as View<()>>::build(view, &mut BuildCtx::new(&mut counter))
}
#[derive(Default)]
struct RecordingScene {
fills: Vec<(Point, Rect, Color)>,
}
impl PaintScene for RecordingScene {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_path(&mut self, origin: Point, path: &BezPath, brush: &Brush) {
let color = match brush {
Brush::Solid(c) => *c,
_ => Color::TRANSPARENT,
};
self.fills.push((origin, path.bounding_box(), color));
}
}
fn paint_rec(w: &mut IconWidget, origin: Point, theme: Option<&Theme>) -> RecordingScene {
let mut rec = RecordingScene::default();
let mut ctx = match theme {
Some(t) => PaintCtx::new(origin, Size::new(w.size, w.size)).with_theme(t),
None => PaintCtx::new(origin, Size::new(w.size, w.size)),
};
w.paint(&mut ctx, &mut rec);
rec
}
#[test]
fn loose_constraints_use_the_requested_size() {
let view = icon(super::super::icons::HOME).size(28.0);
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
assert_eq!(size, Size::new(28.0, 28.0));
}
#[test]
fn tight_constraints_win_over_the_requested_size() {
let view = icon(super::super::icons::HOME).size(28.0);
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(40.0, 40.0)));
assert_eq!(size, Size::new(40.0, 40.0));
}
#[test]
fn default_size_is_24() {
let view = icon(super::super::icons::HOME);
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
assert_eq!(size, Size::new(24.0, 24.0));
}
#[test]
fn paint_scales_the_design_box_to_the_requested_size() {
let view = icon(square_data()).size(48.0);
let mut w = build(&view);
let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
assert_eq!(rec.fills.len(), 1);
let (origin, bbox, _) = rec.fills[0];
assert_eq!(origin, Point::new(3.0, 5.0));
assert!((bbox.width() - 48.0).abs() < 1e-6, "path scaled to size");
assert!((bbox.height() - 48.0).abs() < 1e-6);
assert!((bbox.x0 - 0.0).abs() < 1e-6);
assert!((bbox.y0 - 0.0).abs() < 1e-6);
}
#[test]
fn paint_natural_size_after_layout_is_unchanged() {
let view = icon(square_data()).size(48.0);
let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let laid_out = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
assert_eq!(laid_out, Size::new(48.0, 48.0));
let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
let (origin, bbox, _) = rec.fills[0];
assert_eq!(origin, Point::new(3.0, 5.0));
assert!((bbox.width() - 48.0).abs() < 1e-6, "path scaled to size");
assert!((bbox.height() - 48.0).abs() < 1e-6);
assert!((bbox.x0 - 0.0).abs() < 1e-6);
assert!((bbox.y0 - 0.0).abs() < 1e-6);
}
#[test]
fn paint_fills_a_tight_larger_box_centered() {
let view = icon(square_data()); let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let laid_out = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(34.0, 34.0)));
assert_eq!(laid_out, Size::new(34.0, 34.0));
let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
let (origin, bbox, _) = rec.fills[0];
assert_eq!(origin, Point::new(3.0, 5.0));
assert!(
(bbox.width() - 34.0).abs() < 1e-6,
"glyph must fill the laid-out box (34), not the requested size (24); got {}",
bbox.width()
);
assert!((bbox.height() - 34.0).abs() < 1e-6);
assert!((bbox.x0 - 0.0).abs() < 1e-6);
assert!((bbox.y0 - 0.0).abs() < 1e-6);
}
#[test]
fn paint_fills_a_tight_smaller_box_without_overdraw() {
let view = icon(square_data()); let mut w = build(&view);
let mut lctx = LayoutCtx::new();
let laid_out = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(16.0, 16.0)));
assert_eq!(laid_out, Size::new(16.0, 16.0));
let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
let (origin, bbox, _) = rec.fills[0];
assert_eq!(origin, Point::new(3.0, 5.0));
assert!(
(bbox.width() - 16.0).abs() < 1e-6,
"glyph must shrink to the laid-out box (16), not overdraw at the \
requested size (24); got {}",
bbox.width()
);
assert!((bbox.height() - 16.0).abs() < 1e-6);
assert!((bbox.x0 - 0.0).abs() < 1e-6);
assert!((bbox.y0 - 0.0).abs() < 1e-6);
}
#[test]
fn user_supplied_path_renders() {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((10.0, 0.0));
path.line_to((10.0, 10.0));
path.close_path();
let view = icon(IconData::from_path(path, 10.0)).size(20.0);
let mut w = build(&view);
let rec = paint_rec(&mut w, Point::ZERO, None);
assert_eq!(rec.fills.len(), 1, "a user path emits one filled path");
let (_, bbox, _) = rec.fills[0];
assert!((bbox.width() - 20.0).abs() < 1e-6);
}
#[test]
fn explicit_color_wins_over_theme() {
let explicit = Color::from_rgb8(0xAB, 0xCD, 0xEF);
let view = icon(square_data()).color(explicit);
let mut w = build(&view);
let theme = Theme::neutral();
let rec = paint_rec(&mut w, Point::ZERO, Some(&theme));
assert_eq!(rec.fills[0].2, explicit);
}
#[test]
fn themed_default_resolves_on_surface() {
let view = icon(square_data());
let mut w = build(&view);
let theme = Theme::neutral();
let rec = paint_rec(&mut w, Point::ZERO, Some(&theme));
assert_eq!(rec.fills[0].2, theme.scheme().on_surface);
}
#[test]
fn unthemed_default_uses_fallback_constant() {
let view = icon(square_data());
let mut w = build(&view);
let rec = paint_rec(&mut w, Point::ZERO, None);
assert_eq!(rec.fills[0].2, DEFAULT_COLOR);
}
#[test]
fn every_generated_icon_source_parses() {
for source in super::super::icons::ALL {
let data: IconData = (*source).into();
let (path, design) = data.resolve();
assert_eq!(design, MATERIAL_DESIGN_BOX);
assert!(
!path.elements().is_empty(),
"generated icon `{}` parsed to an empty path",
source.d
);
}
}
#[test]
fn the_icon_button_arc_additions_exist_and_are_in_all() {
let additions = super::super::icons::ALL;
for (name, d) in [
("ARROW_UPWARD", super::super::icons::ARROW_UPWARD.d),
("ARROW_DOWNWARD", super::super::icons::ARROW_DOWNWARD.d),
("ARROW_FORWARD", super::super::icons::ARROW_FORWARD.d),
("CONTENT_COPY", super::super::icons::CONTENT_COPY.d),
] {
assert!(
additions.iter().any(|source| source.d == d),
"icons::{name} is missing from icons::ALL"
);
}
}
#[test]
fn generated_home_icon_is_usable_end_to_end() {
let view = icon(super::super::icons::HOME).size(28.0);
let mut w = build(&view);
let rec = paint_rec(&mut w, Point::ZERO, None);
assert_eq!(rec.fills.len(), 1);
assert_eq!(rec.fills[0].2, DEFAULT_COLOR);
}
const BBOX_TOLERANCE: f64 = 2.0;
#[test]
fn every_generated_icon_bbox_lands_in_design_box() {
let mut max_bleed: f64 = f64::NEG_INFINITY;
let mut worst: Option<&str> = None;
for source in super::super::icons::ALL {
let path = BezPath::from_svg(source.d).unwrap_or_else(|e| {
panic!("icon SVG path failed to parse: {e} (d={:?})", source.d)
});
let bbox = path.bounding_box();
let design = source.design;
assert!(
bbox.x0 >= -BBOX_TOLERANCE
&& bbox.y0 >= -BBOX_TOLERANCE
&& bbox.x1 <= design + BBOX_TOLERANCE
&& bbox.y1 <= design + BBOX_TOLERANCE,
"icon `{}` bbox ({:.4},{:.4})-({:.4},{:.4}) lands outside the \
{design}x{design} design box beyond the {BBOX_TOLERANCE}px \
tolerance",
source.d,
bbox.x0,
bbox.y0,
bbox.x1,
bbox.y1
);
assert!(
bbox.width() + bbox.height() > design / 4.0,
"icon `{}` bbox ({:.4},{:.4})-({:.4},{:.4}) is degenerately \
small for a {design}x{design} design box — suspect a \
zero-scale bug",
source.d,
bbox.x0,
bbox.y0,
bbox.x1,
bbox.y1
);
let bleed = [-bbox.x0, -bbox.y0, bbox.x1 - design, bbox.y1 - design, 0.0]
.into_iter()
.fold(f64::NEG_INFINITY, f64::max);
if bleed > max_bleed {
max_bleed = bleed;
worst = Some(source.d);
}
}
if let Some(d) = worst {
println!("max bbox bleed observed: {max_bleed:.4}px (icon d={d:?})");
}
}
#[test]
fn rebuild_adopts_a_new_size_and_flags_layout() {
let mut counter = 0u64;
let prev = icon(square_data()).size(24.0);
let mut w = <IconView as View<()>>::build(&prev, &mut BuildCtx::new(&mut counter));
let next = icon(square_data()).size(32.0);
let flags =
<IconView as View<()>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert_eq!(w.size, 32.0);
assert!(flags.needs_layout());
assert!(flags.needs_paint());
}
}