use std::borrow::Cow;
use teksilo_canvas::svg::{SvgDrawOp, SvgIcon};
use teksilo_canvas::{
AnimatedIcon, AnimatedQuadClass, Canvas, Path, PathCommand, Point, RasterIcon, Rect, Size,
SizeProposal,
};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::animated_quad::{AnimatedQuadHandle, AnimatedQuadKind};
use teksilo_core::color_prop::ColorProp;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
use teksilo_tokens::{Color, Easing, TextRole};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IconMode {
Tintable,
FullColor,
}
#[derive(Debug, Clone)]
enum IconSource {
Path(Path),
Svg(SvgIcon),
Raster {
name: String,
icon: RasterIcon,
upload_pixels: Vec<u8>,
},
Animated {
name: String,
icon: AnimatedIcon,
frame_upload_pixels: Vec<Vec<u8>>,
frame_signal: Option<Signal<f32>>,
sprite_atlas: Option<SpriteAtlas>,
anim_handle: Option<AnimatedQuadHandle>,
},
}
#[derive(Debug, Clone)]
struct SpriteAtlas {
name: String,
pixels: Vec<u8>,
width: u32,
height: u32,
cols: u32,
rows: u32,
}
pub struct IconWidget {
source: IconSource,
design_size: f32,
display_size: f32,
color: ColorProp,
mode: IconMode,
follow_text_scale: bool,
}
fn auto_name(prefix: &str, ptr: usize) -> String {
format!("_icon_{prefix}_{ptr:x}")
}
fn prepare_pixels(icon: &RasterIcon, mode: IconMode) -> Vec<u8> {
match mode {
IconMode::Tintable => icon.to_alpha_mask().pixels().to_vec(),
IconMode::FullColor => icon.pixels().to_vec(),
}
}
impl IconWidget {
pub fn from_path(path: Path, size: f32) -> Self {
Self {
source: IconSource::Path(path),
design_size: size,
display_size: size,
color: ColorProp::TextRole(TextRole::Primary),
mode: IconMode::Tintable,
follow_text_scale: false,
}
}
pub fn checkmark(size: f32) -> Self {
let mut path = Path::new();
let s = size;
path.move_to(Point::new(s * 0.2, s * 0.5));
path.line_to(Point::new(s * 0.4, s * 0.75));
path.line_to(Point::new(s * 0.8, s * 0.25));
Self::from_path(path, size)
}
pub fn dash(size: f32) -> Self {
let s = size;
let y_mid = s * 0.5;
let half_thickness = s * 0.06; let mut path = Path::new();
path.move_to(Point::new(s * 0.2, y_mid - half_thickness));
path.line_to(Point::new(s * 0.8, y_mid - half_thickness));
path.line_to(Point::new(s * 0.8, y_mid + half_thickness));
path.line_to(Point::new(s * 0.2, y_mid + half_thickness));
path.close();
Self::from_path(path, size)
}
pub fn radio_dot(size: f32) -> Self {
let s = size;
let path = Path::circle(Point::new(s * 0.5, s * 0.5), s * 0.25);
Self::from_path(path, size)
}
pub fn chevron_down(size: f32) -> Self {
let mut path = Path::new();
let s = size;
path.move_to(Point::new(s * 0.25, s * 0.35));
path.line_to(Point::new(s * 0.5, s * 0.65));
path.line_to(Point::new(s * 0.75, s * 0.35));
Self::from_path(path, size)
}
pub fn chevron_right(size: f32) -> Self {
let mut path = Path::new();
let s = size;
path.move_to(Point::new(s * 0.35, s * 0.25));
path.line_to(Point::new(s * 0.65, s * 0.5));
path.line_to(Point::new(s * 0.35, s * 0.75));
Self::from_path(path, size)
}
pub fn chevron_left(size: f32) -> Self {
let mut path = Path::new();
let s = size;
path.move_to(Point::new(s * 0.65, s * 0.25));
path.line_to(Point::new(s * 0.35, s * 0.5));
path.line_to(Point::new(s * 0.65, s * 0.75));
Self::from_path(path, size)
}
pub fn chevron_up(size: f32) -> Self {
let mut path = Path::new();
let s = size;
path.move_to(Point::new(s * 0.25, s * 0.65));
path.line_to(Point::new(s * 0.5, s * 0.35));
path.line_to(Point::new(s * 0.75, s * 0.65));
Self::from_path(path, size)
}
pub fn from_svg(svg_str: &str) -> Self {
match SvgIcon::parse(svg_str) {
Ok(icon) => Self::from_svg_icon(&icon),
Err(_e) => {
#[cfg(debug_assertions)]
eprintln!("teksilo: SVG parse error: {_e}");
Self::from_path(Path::new(), 0.0)
}
}
}
pub fn from_svg_icon(icon: &SvgIcon) -> Self {
let vb_size = icon.width().max(icon.height());
Self {
source: IconSource::Svg(icon.clone()),
design_size: vb_size,
display_size: vb_size,
color: ColorProp::TextRole(TextRole::Primary),
mode: IconMode::Tintable,
follow_text_scale: false,
}
}
pub fn from_png(data: &'static [u8], size: f32) -> Self {
match RasterIcon::decode_png(data) {
Ok(icon) => {
let name = auto_name("png", data.as_ptr() as usize);
let mode = IconMode::Tintable;
let upload_pixels = prepare_pixels(&icon, mode);
Self {
source: IconSource::Raster {
name,
icon,
upload_pixels,
},
design_size: size,
display_size: size,
color: ColorProp::TextRole(TextRole::Primary),
mode,
follow_text_scale: false,
}
}
Err(_e) => {
#[cfg(debug_assertions)]
eprintln!("teksilo: PNG decode error: {_e}");
Self::from_path(Path::new(), size)
}
}
}
pub fn from_webp(data: &'static [u8], size: f32) -> Self {
let mode = IconMode::Tintable;
if let Ok(anim) = AnimatedIcon::decode_webp(data) {
let name = auto_name("webp", data.as_ptr() as usize);
let frame_upload_pixels: Vec<Vec<u8>> = anim
.frames()
.iter()
.map(|f| prepare_pixels(f, mode))
.collect();
return Self {
source: IconSource::Animated {
name,
icon: anim,
frame_upload_pixels,
frame_signal: None,
sprite_atlas: None,
anim_handle: None,
},
design_size: size,
display_size: size,
color: ColorProp::TextRole(TextRole::Primary),
mode,
follow_text_scale: false,
};
}
match RasterIcon::decode_webp(data) {
Ok(icon) => {
let name = auto_name("webp", data.as_ptr() as usize);
let upload_pixels = prepare_pixels(&icon, mode);
Self {
source: IconSource::Raster {
name,
icon,
upload_pixels,
},
design_size: size,
display_size: size,
color: ColorProp::TextRole(TextRole::Primary),
mode,
follow_text_scale: false,
}
}
Err(_e) => {
#[cfg(debug_assertions)]
eprintln!("teksilo: WebP decode error: {_e}");
Self::from_path(Path::new(), size)
}
}
}
pub fn from_raster(icon: &RasterIcon, size: f32) -> Self {
let name = format!("_icon_raster_{:p}", icon as *const RasterIcon);
let mode = IconMode::Tintable;
let upload_pixels = prepare_pixels(icon, mode);
Self {
source: IconSource::Raster {
name,
icon: icon.clone(),
upload_pixels,
},
design_size: size,
display_size: size,
color: ColorProp::TextRole(TextRole::Primary),
mode,
follow_text_scale: false,
}
}
pub fn from_animated(icon: &AnimatedIcon, size: f32) -> Self {
let name = format!("_icon_anim_{:p}", icon as *const AnimatedIcon);
let mode = IconMode::Tintable;
let frame_upload_pixels: Vec<Vec<u8>> = icon
.frames()
.iter()
.map(|f| prepare_pixels(f, mode))
.collect();
Self {
source: IconSource::Animated {
name,
icon: icon.clone(),
frame_upload_pixels,
frame_signal: None,
sprite_atlas: None,
anim_handle: None,
},
design_size: size,
display_size: size,
color: ColorProp::TextRole(TextRole::Primary),
mode,
follow_text_scale: false,
}
}
pub fn mode(mut self, mode: IconMode) -> Self {
if self.mode == mode {
return self;
}
self.mode = mode;
match &mut self.source {
IconSource::Raster {
icon,
upload_pixels,
..
} => {
*upload_pixels = prepare_pixels(icon, mode);
}
IconSource::Animated {
icon,
frame_upload_pixels,
sprite_atlas,
anim_handle,
..
} => {
*frame_upload_pixels = icon
.frames()
.iter()
.map(|f| prepare_pixels(f, mode))
.collect();
*sprite_atlas = None;
*anim_handle = None;
}
IconSource::Path(_) | IconSource::Svg(_) => {}
}
self
}
pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
self.color = color.into();
self
}
pub fn icon_size(mut self, size: f32) -> Self {
self.display_size = size;
self
}
pub fn follow_text_scale(mut self, follow: bool) -> Self {
self.follow_text_scale = follow;
self
}
pub(crate) fn display_size(&self) -> f32 {
self.display_size
}
fn scaled_path(&self, bounds: Rect) -> Path {
let path = match &self.source {
IconSource::Path(p) => p,
_ => return Path::new(),
};
if path.is_empty() {
return path.clone();
}
let scale_x = bounds.width / self.design_size;
let scale_y = bounds.height / self.design_size;
let offset_x = bounds.x;
let offset_y = bounds.y;
let mut scaled = Path::new();
for cmd in &path.commands {
match *cmd {
PathCommand::MoveTo(p) => {
scaled.move_to(Point::new(
p.x * scale_x + offset_x,
p.y * scale_y + offset_y,
));
}
PathCommand::LineTo(p) => {
scaled.line_to(Point::new(
p.x * scale_x + offset_x,
p.y * scale_y + offset_y,
));
}
PathCommand::QuadTo { control, to } => {
scaled.quad_to(
Point::new(
control.x * scale_x + offset_x,
control.y * scale_y + offset_y,
),
Point::new(to.x * scale_x + offset_x, to.y * scale_y + offset_y),
);
}
PathCommand::CubicTo {
control1,
control2,
to,
} => {
scaled.cubic_to(
Point::new(
control1.x * scale_x + offset_x,
control1.y * scale_y + offset_y,
),
Point::new(
control2.x * scale_x + offset_x,
control2.y * scale_y + offset_y,
),
Point::new(to.x * scale_x + offset_x, to.y * scale_y + offset_y),
);
}
PathCommand::ArcTo {
rect,
start_angle,
sweep_angle,
} => {
scaled.arc_to(
Rect::new(
rect.x * scale_x + offset_x,
rect.y * scale_y + offset_y,
rect.width * scale_x,
rect.height * scale_y,
),
start_angle,
sweep_angle,
);
}
PathCommand::Close => {
scaled.close();
}
}
}
scaled
}
fn paint_raster(
&self,
bounds: Rect,
canvas: &mut Canvas,
name: &str,
width: u32,
height: u32,
upload_pixels: &[u8],
color: Color,
) {
if !canvas.has_pending_image(name) {
canvas.ensure_image_registered(name, width, height, Cow::Owned(upload_pixels.to_vec()));
}
match self.mode {
IconMode::Tintable => canvas.draw_tinted_image(bounds, name, color),
IconMode::FullColor => canvas.draw_image(bounds, name),
}
}
}
impl std::fmt::Debug for IconWidget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IconWidget")
.field("display_size", &self.display_size)
.field("mode", &self.mode)
.finish()
}
}
impl Widget for IconWidget {
fn build(
&mut self,
ctx: &mut teksilo_core::build_context::BuildContext,
) -> Vec<teksilo_core::widget_id::WidgetId> {
{
let self_id = ctx.self_id();
let registry = ctx.binding_registry();
self.color.register_if_bound(
self_id,
registry,
teksilo_core::binding::BindingLevel::RepaintOnly,
);
}
let mode = self.mode;
let icon_color = self.color.clone();
if let IconSource::Animated {
name,
icon,
frame_upload_pixels,
frame_signal,
sprite_atlas,
anim_handle,
} = &mut self.source
{
if ctx.prefers_reduced_motion() {
*frame_signal = None;
*sprite_atlas = None;
*anim_handle = None;
} else {
if sprite_atlas.is_none() {
*sprite_atlas = build_sprite_atlas(name, icon, frame_upload_pixels);
}
if let Some(atlas) = sprite_atlas.as_ref() {
let tint = match mode {
IconMode::Tintable => Some(icon_color),
IconMode::FullColor => None,
};
*anim_handle = Some(ctx.animated_quad(AnimatedQuadKind::SpriteCycle {
image_name: atlas.name.clone(),
frame_count: icon.frame_count() as u32,
cols: atlas.cols,
rows: atlas.rows,
period: icon.total_duration(),
tint,
}));
*frame_signal = None;
} else {
let signal = ctx.animated_signal(0.0);
{
let self_id = ctx.self_id();
let registry = ctx.binding_registry();
signal.bind_to(
self_id,
registry,
teksilo_core::binding::BindingLevel::RepaintOnly,
);
}
let frame_count = icon.frame_count() as f32;
let period = icon.total_duration();
signal.animate_looping(
frame_count,
period,
Easing::Linear,
Some(std::time::Duration::from_millis(33)),
);
*frame_signal = Some(signal);
}
}
}
Vec::new()
}
fn layout_response(
&self,
_proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
let size = if self.follow_text_scale {
self.display_size * ctx.text_scale
} else {
self.display_size
};
Size::new(size, size).into()
}
fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
let color = self.color.resolve(ctx.theme, ctx.effective_enabled);
match &self.source {
IconSource::Path(_) => {
if color.a() > 0.0 {
let scaled = self.scaled_path(bounds);
if !scaled.is_empty() {
canvas.fill_path(&scaled, color);
}
}
}
IconSource::Svg(icon) => {
if color.a() <= 0.0 {
return;
}
if self.mode == IconMode::FullColor && !icon.is_monochrome() {
for op in icon.draw_ops_in_rect(bounds, color) {
match op {
SvgDrawOp::Fill {
path,
fill_rule,
paint,
} => canvas.fill_path_with_rule(&path, paint, fill_rule),
SvgDrawOp::Stroke { path, style, paint } => {
canvas.stroke_path_with_paint(&path, paint, style)
}
}
}
return;
}
let fill = icon.to_path_in_rect(bounds);
if !fill.is_empty() {
canvas.fill_path(&fill, color);
}
for (path, rule, opacity) in icon.extra_fills_in_rect(bounds) {
let c = color.with_alpha(color.a() * opacity);
if !path.is_empty() && c.a() > 0.0 {
canvas.fill_path_with_rule(&path, c, rule);
}
}
for (path, style, opacity) in icon.stroked_paths_in_rect(bounds) {
let c = color.with_alpha(color.a() * opacity);
if !path.is_empty() && c.a() > 0.0 {
canvas.stroke_path(&path, c, style);
}
}
}
IconSource::Raster {
name,
icon,
upload_pixels,
} => {
self.paint_raster(
bounds,
canvas,
name,
icon.width(),
icon.height(),
upload_pixels,
color,
);
}
IconSource::Animated {
name,
icon,
frame_upload_pixels,
frame_signal,
sprite_atlas,
anim_handle,
} => {
if let (Some(atlas), Some(handle)) = (sprite_atlas, anim_handle) {
canvas.ensure_image_registered(
atlas.name.clone(),
atlas.width,
atlas.height,
std::borrow::Cow::Owned(atlas.pixels.clone()),
);
canvas.draw_animated_quad(
bounds,
handle.slot(),
AnimatedQuadClass::Sprite {
image_name: atlas.name.clone(),
},
);
return;
}
let idx = frame_signal
.as_ref()
.map(|s| (s.get() as usize).min(icon.frame_count().saturating_sub(1)))
.unwrap_or(0);
let frame_name = format!("{name}_f{idx}");
let frame = &icon.frames()[idx];
let pixels = &frame_upload_pixels[idx];
self.paint_raster(
bounds,
canvas,
&frame_name,
frame.width(),
frame.height(),
pixels,
color,
);
}
}
}
fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
}
}
fn build_sprite_atlas(
name: &str,
icon: &AnimatedIcon,
frame_pixels: &[Vec<u8>],
) -> Option<SpriteAtlas> {
let frames = icon.frames();
if frames.is_empty() {
return None;
}
let frame_w = frames[0].width();
let frame_h = frames[0].height();
if frame_w == 0 || frame_h == 0 {
return None;
}
let n = frames.len() as u32;
let cols = (n as f32).sqrt().ceil() as u32;
let rows = n.div_ceil(cols);
let atlas_w = cols * frame_w;
let atlas_h = rows * frame_h;
let mut pixels = vec![0u8; (atlas_w * atlas_h * 4) as usize];
for (i, cell) in frame_pixels.iter().enumerate() {
let i = i as u32;
let col = i % cols;
let row = i / cols;
let dst_x = col * frame_w;
let dst_y = row * frame_h;
for y in 0..frame_h {
let src_start = (y * frame_w * 4) as usize;
let src_end = src_start + (frame_w * 4) as usize;
if src_end > cell.len() {
break; }
let dst_start = (((dst_y + y) * atlas_w + dst_x) * 4) as usize;
let dst_end = dst_start + (frame_w * 4) as usize;
pixels[dst_start..dst_end].copy_from_slice(&cell[src_start..src_end]);
}
}
Some(SpriteAtlas {
name: format!("{name}_sprite_atlas"),
pixels,
width: atlas_w,
height: atlas_h,
cols,
rows,
})
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_core::widget_tree::WidgetTree;
#[test]
fn icon_intrinsic_size() {
let mut tree = WidgetTree::new();
let icon = tree.add(IconWidget::checkmark(24.0));
tree.layout(SizeProposal::unspecified());
let b = tree.bounds(icon);
assert!((b.width - 24.0).abs() < 0.01);
assert!((b.height - 24.0).abs() < 0.01);
}
#[test]
fn icon_custom_size() {
let mut tree = WidgetTree::new();
let icon = tree.add(IconWidget::chevron_down(16.0));
tree.layout(SizeProposal::unspecified());
let b = tree.bounds(icon);
assert!((b.width - 16.0).abs() < 0.01);
assert!((b.height - 16.0).abs() < 0.01);
}
#[test]
fn follow_text_scale_grows_with_user_scale() {
let mut tree = WidgetTree::new();
let scaled = tree.add(IconWidget::checkmark(20.0).follow_text_scale(true));
let fixed = tree.add(IconWidget::checkmark(20.0));
tree.set_user_text_scale(2.0);
tree.layout(SizeProposal::unspecified());
let bs = tree.bounds(scaled);
let bf = tree.bounds(fixed);
assert!(
(bs.width - 40.0).abs() < 0.01,
"opted-in icon should double: {bs:?}"
);
assert!(
(bf.width - 20.0).abs() < 0.01,
"default icon must not scale: {bf:?}"
);
}
#[test]
fn icon_paints_path() {
let mut tree = WidgetTree::new();
tree.add(IconWidget::checkmark(24.0).color(Color::BLACK));
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert!(!frame.paths.is_empty(), "icon should render a path");
}
#[test]
fn empty_path_does_not_paint() {
let mut tree = WidgetTree::new();
tree.add(IconWidget::from_path(Path::new(), 24.0).color(Color::BLACK));
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert!(frame.paths.is_empty(), "empty path should not render");
}
#[test]
fn icon_from_svg() {
let svg = r#"<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>"#;
let mut tree = WidgetTree::new();
tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert!(!frame.paths.is_empty(), "SVG icon should render a path");
}
#[test]
fn icon_from_svg_line_style_renders_stroke_not_fill() {
let svg = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
</svg>"#;
let mut tree = WidgetTree::new();
tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert_eq!(
frame.paths.len(),
1,
"line-style SVG should render exactly one (stroked) path"
);
assert!(
frame.paths[0].stroke_style.width > 0.0,
"the rendered path must be stroked, not filled"
);
}
#[test]
fn icon_from_svg_evenodd_emits_evenodd_path_entry() {
let svg = r#"<svg viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M2 2L22 2L22 22Z"/>
</svg>"#;
let mut tree = WidgetTree::new();
tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert_eq!(frame.paths.len(), 1, "evenodd icon renders one path");
assert_eq!(
frame.paths[0].fill_rule,
teksilo_canvas::FillRule::EvenOdd,
"the fill rule must reach the PathEntry"
);
}
#[test]
fn icon_from_svg_invalid_fallback() {
let mut tree = WidgetTree::new();
tree.add(IconWidget::from_svg("<not-svg>"));
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert!(frame.paths.is_empty());
}
#[test]
fn icon_mode_default_is_tintable() {
let icon = IconWidget::checkmark(24.0);
assert_eq!(icon.mode, IconMode::Tintable);
}
#[test]
fn icon_mode_can_be_set() {
let icon = IconWidget::checkmark(24.0).mode(IconMode::FullColor);
assert_eq!(icon.mode, IconMode::FullColor);
}
#[test]
fn raster_icon_paints_image() {
let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
let mut tree = WidgetTree::new();
tree.add(IconWidget::from_raster(&icon, 24.0).color(Color::BLACK));
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert!(
!frame.images.is_empty(),
"raster icon should render an image"
);
}
#[test]
fn raster_icon_tintable_has_tint() {
let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
let mut tree = WidgetTree::new();
tree.add(
IconWidget::from_raster(&icon, 24.0)
.color(Color::from_hex("#FF0000"))
.mode(IconMode::Tintable),
);
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert!(
frame.images[0].tint.is_some(),
"tintable icon should have tint"
);
}
#[test]
fn raster_icon_fullcolor_no_tint() {
let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
let mut tree = WidgetTree::new();
tree.add(IconWidget::from_raster(&icon, 24.0).mode(IconMode::FullColor));
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert!(
frame.images[0].tint.is_none(),
"full-color icon should not have tint"
);
}
fn path_icon_color(frame: &teksilo_canvas::RenderFrame) -> [f32; 4] {
frame
.paths
.first()
.map(|p| p.color)
.expect("path icon should render at least one path")
}
#[test]
fn role_based_icon_uses_text_disabled_when_self_disabled() {
let mut tree = WidgetTree::new();
let theme = teksilo_core::presets::intui::light();
tree.set_theme(theme.clone());
let icon = tree.add(IconWidget::checkmark(24.0));
tree.enabled_when(icon, false);
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
let color = path_icon_color(&frame);
let expected = theme.colors.text_disabled.to_array();
assert_eq!(
color, expected,
"default-role IconWidget under enabled_when(false) must paint at text_disabled, got {color:?}"
);
}
#[test]
fn role_based_icon_uses_text_primary_when_self_enabled() {
let mut tree = WidgetTree::new();
let theme = teksilo_core::presets::intui::light();
tree.set_theme(theme.clone());
tree.add(IconWidget::checkmark(24.0));
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
let color = path_icon_color(&frame);
let expected = theme.colors.text_primary.to_array();
assert_eq!(
color, expected,
"default-role IconWidget without enabled_state must paint at text_primary, got {color:?}"
);
}
#[test]
fn role_based_icon_flips_when_bound_signal_flips_without_rebuild() {
use teksilo_core::signal::Signal;
let mut tree = WidgetTree::new();
let theme = teksilo_core::presets::intui::light();
tree.set_theme(theme.clone());
let is_enabled = Signal::new(true);
let icon = tree.add(IconWidget::checkmark(24.0));
tree.enabled_when(icon, is_enabled.clone());
tree.layout(SizeProposal::exact(24.0, 24.0));
let primary = theme.colors.text_primary.to_array();
let disabled = theme.colors.text_disabled.to_array();
assert_eq!(path_icon_color(&tree.render()), primary, "starts primary");
is_enabled.set(false);
tree.layout(SizeProposal::exact(24.0, 24.0));
assert_eq!(
path_icon_color(&tree.render()),
disabled,
"after flipping signal to false the leaf must repaint at the disabled color"
);
is_enabled.set(true);
tree.layout(SizeProposal::exact(24.0, 24.0));
assert_eq!(
path_icon_color(&tree.render()),
primary,
"flipping back to true must re-resolve to primary"
);
}
#[test]
fn explicit_color_does_not_dim_when_disabled() {
let mut tree = WidgetTree::new();
let red = Color::from_hex("#FF0000");
let icon = tree.add(IconWidget::checkmark(24.0).color(red));
tree.enabled_when(icon, false);
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
let color = path_icon_color(&frame);
assert_eq!(
color,
red.to_array(),
"explicit-color icons must NOT auto-dim when disabled — caller picked the literal, framework respects it"
);
}
#[test]
fn full_color_svg_keeps_its_own_colors_in_document_order() {
let svg = r##"<svg viewBox="0 0 24 24">
<rect width="24" height="24" fill="#5865F2"/>
<circle cx="12" cy="12" r="6" fill="#FFFFFF"/>
</svg>"##;
let mut tree = WidgetTree::new();
tree.add(
IconWidget::from_svg(svg)
.icon_size(24.0)
.mode(IconMode::FullColor)
.color(Color::from_hex("#FF0000")),
);
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert_eq!(frame.paths.len(), 2, "one path per authored shape");
assert_eq!(frame.paths[0].color, Color::from_hex("#5865F2").to_array());
assert_eq!(frame.paths[1].color, Color::from_hex("#FFFFFF").to_array());
}
#[test]
fn tintable_mode_still_merges_a_colored_svg_into_one_tinted_path() {
let svg = r##"<svg viewBox="0 0 24 24">
<rect width="24" height="24" fill="#5865F2"/>
<circle cx="12" cy="12" r="6" fill="#FFFFFF"/>
</svg>"##;
let mut tree = WidgetTree::new();
tree.add(
IconWidget::from_svg(svg)
.icon_size(24.0)
.color(Color::from_hex("#FF0000")),
);
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert_eq!(frame.paths.len(), 1, "both shapes merge into one fill");
assert_eq!(
frame.paths[0].color,
Color::from_hex("#FF0000").to_array(),
"tintable ignores the artwork's colors and takes the widget's"
);
}
#[test]
fn current_color_follows_the_widget_inside_full_color_artwork() {
let svg = r##"<svg viewBox="0 0 24 24">
<rect width="24" height="24" fill="#5865F2"/>
<rect width="8" height="8" fill="currentColor"/>
</svg>"##;
let mut tree = WidgetTree::new();
let accent = Color::from_hex("#00FF00");
tree.add(
IconWidget::from_svg(svg)
.icon_size(24.0)
.mode(IconMode::FullColor)
.color(accent),
);
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
assert_eq!(frame.paths[0].color, Color::from_hex("#5865F2").to_array());
assert_eq!(frame.paths[1].color, accent.to_array());
}
#[test]
fn a_gradient_fill_reaches_the_renderer_as_a_gradient() {
let svg = r##"<svg viewBox="0 0 24 24">
<linearGradient id="g">
<stop offset="0" stop-color="#FF0000"/>
<stop offset="1" stop-color="#0000FF"/>
</linearGradient>
<rect width="24" height="24" fill="url(#g)"/>
</svg>"##;
let mut tree = WidgetTree::new();
tree.add(
IconWidget::from_svg(svg)
.icon_size(24.0)
.mode(IconMode::FullColor)
.color(Color::BLACK),
);
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
match &frame.paths[0].paint_data {
teksilo_canvas::PaintData::LinearGradient { start, end, stops } => {
assert_eq!(stops.len(), 2);
assert_eq!(stops[0].color.to_array(), [1.0, 0.0, 0.0, 1.0]);
assert!(start[0].abs() < 0.01);
assert!((end[0] - 24.0).abs() < 0.01, "end {end:?}");
}
other => panic!("expected a linear gradient paint, got {other:?}"),
}
}
#[test]
fn a_gradient_stroke_is_rebased_onto_the_expanded_stroke_bounds() {
let svg = r##"<svg viewBox="0 0 24 24">
<linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="24" y2="0">
<stop offset="0" stop-color="#FF0000"/>
<stop offset="1" stop-color="#0000FF"/>
</linearGradient>
<rect x="2" y="2" width="20" height="20" fill="none" stroke="url(#g)" stroke-width="4"/>
</svg>"##;
let mut tree = WidgetTree::new();
tree.add(
IconWidget::from_svg(svg)
.icon_size(24.0)
.mode(IconMode::FullColor)
.color(Color::BLACK),
);
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
let entry = &frame.paths[0];
assert!(entry.stroke_style.width > 0.0, "must be a stroke");
match &entry.paint_data {
teksilo_canvas::PaintData::LinearGradient { start, end, .. } => {
assert!((start[0] - 2.0).abs() < 0.05, "start {start:?}");
assert!((end[0] - 26.0).abs() < 0.05, "end {end:?}");
}
other => panic!("expected a linear gradient stroke, got {other:?}"),
}
}
#[test]
fn the_widget_alpha_dims_full_color_artwork() {
let svg =
r##"<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="#FF0000"/></svg>"##;
let mut tree = WidgetTree::new();
tree.add(
IconWidget::from_svg(svg)
.icon_size(24.0)
.mode(IconMode::FullColor)
.color(Color::new(0.0, 0.0, 0.0, 0.5)),
);
tree.layout(SizeProposal::exact(24.0, 24.0));
let frame = tree.render();
let c = frame.paths[0].color;
assert_eq!([c[0], c[1], c[2]], [1.0, 0.0, 0.0], "the red must survive");
assert!((c[3] - 0.5).abs() < 1e-5, "…at half alpha, got {}", c[3]);
}
}