use crate::ecs::{Entity, World};
use crate::render::path::Path;
use crate::render::renderer::Renderer;
use crate::types::{Dimension, Fixed, Rect, Transform};
use crate::ui::theme::{ColorToken, ThemedColor};
use crate::ui::view::{View, ViewCtx};
#[derive(Clone, Debug, crate::Component)]
pub struct Icon {
pub path: Path,
pub color: ThemedColor,
pub size: Dimension,
pub viewbox: Fixed,
pub scale: Fixed,
}
impl Default for Icon {
fn default() -> Self {
Self::new(Path::new())
}
}
impl Icon {
pub fn new(path: impl Into<Path>) -> Self {
Self {
path: path.into(),
color: ThemedColor::Token(ColorToken::OnSurface),
size: Dimension::Auto,
viewbox: Fixed::from_int(24),
scale: Fixed::ONE,
}
}
pub fn with_color(mut self, color: ThemedColor) -> Self {
self.color = color;
self
}
pub fn with_size(mut self, size: Dimension) -> Self {
self.size = size;
self
}
pub fn with_viewbox(mut self, viewbox: Fixed) -> Self {
self.viewbox = viewbox;
self
}
pub fn with_scale(mut self, scale: Fixed) -> Self {
self.scale = scale;
self
}
}
fn icon_size(icon: &Icon, rect: &Rect) -> Option<Fixed> {
let available = rect.w.min(rect.h);
let size_px = icon.size.resolve_or(available, available);
if icon.viewbox <= Fixed::ZERO || size_px <= Fixed::ZERO || icon.scale <= Fixed::ZERO {
return None;
}
let rendered_size = size_px * icon.scale;
Some(rendered_size)
}
fn icon_transform(icon: &Icon, rect: &Rect, parent: Transform) -> Option<Transform> {
super::image::vector_transform(rect, icon.viewbox, icon_size(icon, rect)?, parent)
}
fn icon_render(
renderer: &mut dyn Renderer,
world: &World,
entity: Entity,
rect: &Rect,
ctx: &mut ViewCtx,
) {
let Some(icon) = world.get::<Icon>(entity) else {
return;
};
let theme = ctx.theme(world);
let color = icon.color.resolve_in(theme, ctx.state);
let Some(transform) = icon_transform(icon, rect, ctx.transform) else {
return;
};
super::image::draw_vector_transformed(renderer, ctx, &icon.path, color, transform);
}
pub fn view() -> View {
View::new("Icon", 70, icon_render).with_filter::<Icon>()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::anim::{Motion, MotionComponent, Spring, run_motion};
use crate::ecs::{DeltaTimeMs, MonoClock};
use crate::render::path::PathCmd;
use crate::types::{Color, Point};
use crate::ui::dirty::Dirty;
use alloc::borrow::Cow;
static CLOSED_PATH: &[PathCmd] = &[PathCmd::Close];
#[test]
fn icon_new_defaults_match_design() {
let icon = Icon::new(Path::from_static(CLOSED_PATH));
assert_eq!(icon.size, Dimension::Auto);
assert_eq!(icon.viewbox, Fixed::from_int(24));
assert_eq!(icon.scale, Fixed::ONE);
assert!(matches!(
icon.color,
ThemedColor::Token(ColorToken::OnSurface)
));
}
#[test]
fn icon_builders_chain() {
let icon = Icon::new(Path::from_static(CLOSED_PATH))
.with_color(ThemedColor::Raw(Color::rgb(255, 0, 0)))
.with_size(Dimension::Px(Fixed::from_int(32)))
.with_viewbox(Fixed::from_int(16))
.with_scale(Fixed::from_int(2));
assert_eq!(icon.size, Dimension::Px(Fixed::from_int(32)));
assert_eq!(icon.viewbox, Fixed::from_int(16));
assert_eq!(icon.scale, Fixed::from_int(2));
assert!(matches!(icon.color, ThemedColor::Raw(_)));
}
#[test]
fn icon_clone_keeps_path_borrowed() {
static CMDS: &[PathCmd] = &[
PathCmd::MoveTo(Point {
x: Fixed::ZERO,
y: Fixed::ZERO,
}),
PathCmd::Close,
];
let icon = Icon::new(Path::from_static(CMDS));
let cloned = icon.clone();
assert!(matches!(cloned.path.cmds, Cow::Borrowed(_)));
}
#[test]
fn explicit_icon_size_is_centered_inside_its_layout_rect() {
let icon =
Icon::new(Path::from_static(CLOSED_PATH)).with_size(Dimension::Px(Fixed::from_int(24)));
let rect = Rect {
x: Fixed::from_int(10),
y: Fixed::from_int(20),
w: Fixed::from_int(44),
h: Fixed::from_int(60),
};
let transform = icon_transform(&icon, &rect, Transform::IDENTITY).unwrap();
assert_eq!(transform.tx, Fixed::from_int(20));
assert_eq!(transform.ty, Fixed::from_int(38));
assert_eq!(transform.m00, Fixed::ONE);
assert_eq!(transform.m11, Fixed::ONE);
}
#[test]
fn animated_icon_scale_remains_centered() {
let icon = Icon::new(Path::from_static(CLOSED_PATH))
.with_size(Dimension::Px(Fixed::from_int(24)))
.with_scale(Fixed::from_ratio(3, 2));
let rect = Rect {
x: Fixed::ZERO,
y: Fixed::ZERO,
w: Fixed::from_int(60),
h: Fixed::from_int(60),
};
let transform = icon_transform(&icon, &rect, Transform::IDENTITY).unwrap();
assert_eq!(transform.tx, Fixed::from_int(12));
assert_eq!(transform.ty, Fixed::from_int(12));
assert_eq!(transform.m00, Fixed::from_ratio(3, 2));
assert_eq!(transform.m11, Fixed::from_ratio(3, 2));
}
struct IconScale(Motion);
impl crate::ecs::Component for IconScale {}
impl MotionComponent for IconScale {
fn motion(&self) -> &Motion {
&self.0
}
fn motion_mut(&mut self) -> &mut Motion {
&mut self.0
}
}
fn tick(world: &mut World) {
run_motion::<IconScale>(world, |world, entity, value| {
if let Some(icon) = world.get_mut::<Icon>(entity) {
icon.scale = value;
}
world.insert(entity, Dirty);
});
}
#[test]
fn animating_scale_leaves_size_dimension_untouched() {
fn clock() -> u64 {
0
}
let mut world = World::new();
world.insert_resource(MonoClock::new(clock));
world.insert_resource(DeltaTimeMs(16));
let icon = Icon::new(Path::from_static(CLOSED_PATH))
.with_size(Dimension::Percent(Fixed::from_int(50)))
.with_scale(Fixed::ONE);
let entity = world.spawn_empty();
world.insert(entity, icon);
world.insert(
entity,
IconScale(Spring::new(Fixed::ONE, Fixed::from_int(2), 100, Fixed::ZERO).into()),
);
for _ in 0..20 {
tick(&mut world);
}
let after = world.get::<Icon>(entity).expect("icon survives");
assert!(
matches!(after.size, Dimension::Percent(_)),
"size variant must stay Percent after animation, got {:?}",
after.size,
);
assert!(
after.scale > Fixed::ONE,
"scale should have advanced toward 2.0, got {:?}",
after.scale,
);
}
}