1use std::borrow::Cow;
34
35use teksilo_canvas::svg::{SvgDrawOp, SvgIcon};
36use teksilo_canvas::{
37 AnimatedIcon, AnimatedQuadClass, Canvas, Path, PathCommand, Point, RasterIcon, Rect, Size,
38 SizeProposal,
39};
40use teksilo_core::accessibility::AccessNodeBuilder;
41use teksilo_core::animated_quad::{AnimatedQuadHandle, AnimatedQuadKind};
42use teksilo_core::color_prop::ColorProp;
43use teksilo_core::signal::Signal;
44use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
45use teksilo_tokens::{Color, Easing, TextRole};
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum IconMode {
62 Tintable,
64 FullColor,
67}
68
69#[derive(Debug, Clone)]
71enum IconSource {
72 Path(Path),
74 Svg(SvgIcon),
76 Raster {
79 name: String,
80 icon: RasterIcon,
81 upload_pixels: Vec<u8>,
82 },
83 Animated {
92 name: String,
93 icon: AnimatedIcon,
94 frame_upload_pixels: Vec<Vec<u8>>,
95 frame_signal: Option<Signal<f32>>,
99 sprite_atlas: Option<SpriteAtlas>,
102 anim_handle: Option<AnimatedQuadHandle>,
106 },
107}
108
109#[derive(Debug, Clone)]
114struct SpriteAtlas {
115 name: String,
119 pixels: Vec<u8>,
124 width: u32,
125 height: u32,
126 cols: u32,
127 rows: u32,
128}
129
130pub struct IconWidget {
132 source: IconSource,
133 design_size: f32,
136 display_size: f32,
139 color: ColorProp,
142 mode: IconMode,
144 follow_text_scale: bool,
151}
152
153fn auto_name(prefix: &str, ptr: usize) -> String {
156 format!("_icon_{prefix}_{ptr:x}")
157}
158
159fn prepare_pixels(icon: &RasterIcon, mode: IconMode) -> Vec<u8> {
162 match mode {
163 IconMode::Tintable => icon.to_alpha_mask().pixels().to_vec(),
164 IconMode::FullColor => icon.pixels().to_vec(),
165 }
166}
167
168impl IconWidget {
169 pub fn from_path(path: Path, size: f32) -> Self {
172 Self {
173 source: IconSource::Path(path),
174 design_size: size,
175 display_size: size,
176 color: ColorProp::TextRole(TextRole::Primary),
177 mode: IconMode::Tintable,
178 follow_text_scale: false,
179 }
180 }
181
182 pub fn checkmark(size: f32) -> Self {
184 let mut path = Path::new();
185 let s = size;
186 path.move_to(Point::new(s * 0.2, s * 0.5));
187 path.line_to(Point::new(s * 0.4, s * 0.75));
188 path.line_to(Point::new(s * 0.8, s * 0.25));
189 Self::from_path(path, size)
190 }
191
192 pub fn dash(size: f32) -> Self {
196 let s = size;
197 let y_mid = s * 0.5;
198 let half_thickness = s * 0.06; let mut path = Path::new();
200 path.move_to(Point::new(s * 0.2, y_mid - half_thickness));
201 path.line_to(Point::new(s * 0.8, y_mid - half_thickness));
202 path.line_to(Point::new(s * 0.8, y_mid + half_thickness));
203 path.line_to(Point::new(s * 0.2, y_mid + half_thickness));
204 path.close();
205 Self::from_path(path, size)
206 }
207
208 pub fn radio_dot(size: f32) -> Self {
211 let s = size;
212 let path = Path::circle(Point::new(s * 0.5, s * 0.5), s * 0.25);
213 Self::from_path(path, size)
214 }
215
216 pub fn chevron_down(size: f32) -> Self {
218 let mut path = Path::new();
219 let s = size;
220 path.move_to(Point::new(s * 0.25, s * 0.35));
221 path.line_to(Point::new(s * 0.5, s * 0.65));
222 path.line_to(Point::new(s * 0.75, s * 0.35));
223 Self::from_path(path, size)
224 }
225
226 pub fn chevron_right(size: f32) -> Self {
228 let mut path = Path::new();
229 let s = size;
230 path.move_to(Point::new(s * 0.35, s * 0.25));
231 path.line_to(Point::new(s * 0.65, s * 0.5));
232 path.line_to(Point::new(s * 0.35, s * 0.75));
233 Self::from_path(path, size)
234 }
235
236 pub fn chevron_left(size: f32) -> Self {
238 let mut path = Path::new();
239 let s = size;
240 path.move_to(Point::new(s * 0.65, s * 0.25));
241 path.line_to(Point::new(s * 0.35, s * 0.5));
242 path.line_to(Point::new(s * 0.65, s * 0.75));
243 Self::from_path(path, size)
244 }
245
246 pub fn chevron_up(size: f32) -> Self {
248 let mut path = Path::new();
249 let s = size;
250 path.move_to(Point::new(s * 0.25, s * 0.65));
251 path.line_to(Point::new(s * 0.5, s * 0.35));
252 path.line_to(Point::new(s * 0.75, s * 0.65));
253 Self::from_path(path, size)
254 }
255
256 pub fn from_svg(svg_str: &str) -> Self {
263 match SvgIcon::parse(svg_str) {
264 Ok(icon) => Self::from_svg_icon(&icon),
265 Err(_e) => {
266 #[cfg(debug_assertions)]
267 eprintln!("teksilo: SVG parse error: {_e}");
268 Self::from_path(Path::new(), 0.0)
269 }
270 }
271 }
272
273 pub fn from_svg_icon(icon: &SvgIcon) -> Self {
277 let vb_size = icon.width().max(icon.height());
278 Self {
279 source: IconSource::Svg(icon.clone()),
280 design_size: vb_size,
281 display_size: vb_size,
282 color: ColorProp::TextRole(TextRole::Primary),
283 mode: IconMode::Tintable,
284 follow_text_scale: false,
285 }
286 }
287
288 pub fn from_png(data: &'static [u8], size: f32) -> Self {
292 match RasterIcon::decode_png(data) {
293 Ok(icon) => {
294 let name = auto_name("png", data.as_ptr() as usize);
295 let mode = IconMode::Tintable;
296 let upload_pixels = prepare_pixels(&icon, mode);
297 Self {
298 source: IconSource::Raster {
299 name,
300 icon,
301 upload_pixels,
302 },
303 design_size: size,
304 display_size: size,
305 color: ColorProp::TextRole(TextRole::Primary),
306 mode,
307 follow_text_scale: false,
308 }
309 }
310 Err(_e) => {
311 #[cfg(debug_assertions)]
312 eprintln!("teksilo: PNG decode error: {_e}");
313 Self::from_path(Path::new(), size)
314 }
315 }
316 }
317
318 pub fn from_webp(data: &'static [u8], size: f32) -> Self {
322 let mode = IconMode::Tintable;
323 if let Ok(anim) = AnimatedIcon::decode_webp(data) {
325 let name = auto_name("webp", data.as_ptr() as usize);
326 let frame_upload_pixels: Vec<Vec<u8>> = anim
327 .frames()
328 .iter()
329 .map(|f| prepare_pixels(f, mode))
330 .collect();
331 return Self {
332 source: IconSource::Animated {
333 name,
334 icon: anim,
335 frame_upload_pixels,
336 frame_signal: None,
337 sprite_atlas: None,
338 anim_handle: None,
339 },
340 design_size: size,
341 display_size: size,
342 color: ColorProp::TextRole(TextRole::Primary),
343 mode,
344 follow_text_scale: false,
345 };
346 }
347 match RasterIcon::decode_webp(data) {
349 Ok(icon) => {
350 let name = auto_name("webp", data.as_ptr() as usize);
351 let upload_pixels = prepare_pixels(&icon, mode);
352 Self {
353 source: IconSource::Raster {
354 name,
355 icon,
356 upload_pixels,
357 },
358 design_size: size,
359 display_size: size,
360 color: ColorProp::TextRole(TextRole::Primary),
361 mode,
362 follow_text_scale: false,
363 }
364 }
365 Err(_e) => {
366 #[cfg(debug_assertions)]
367 eprintln!("teksilo: WebP decode error: {_e}");
368 Self::from_path(Path::new(), size)
369 }
370 }
371 }
372
373 pub fn from_raster(icon: &RasterIcon, size: f32) -> Self {
376 let name = format!("_icon_raster_{:p}", icon as *const RasterIcon);
377 let mode = IconMode::Tintable;
378 let upload_pixels = prepare_pixels(icon, mode);
379 Self {
380 source: IconSource::Raster {
381 name,
382 icon: icon.clone(),
383 upload_pixels,
384 },
385 design_size: size,
386 display_size: size,
387 color: ColorProp::TextRole(TextRole::Primary),
388 mode,
389 follow_text_scale: false,
390 }
391 }
392
393 pub fn from_animated(icon: &AnimatedIcon, size: f32) -> Self {
396 let name = format!("_icon_anim_{:p}", icon as *const AnimatedIcon);
397 let mode = IconMode::Tintable;
398 let frame_upload_pixels: Vec<Vec<u8>> = icon
399 .frames()
400 .iter()
401 .map(|f| prepare_pixels(f, mode))
402 .collect();
403 Self {
404 source: IconSource::Animated {
405 name,
406 icon: icon.clone(),
407 frame_upload_pixels,
408 frame_signal: None,
409 sprite_atlas: None,
410 anim_handle: None,
411 },
412 design_size: size,
413 display_size: size,
414 color: ColorProp::TextRole(TextRole::Primary),
415 mode,
416 follow_text_scale: false,
417 }
418 }
419
420 pub fn mode(mut self, mode: IconMode) -> Self {
423 if self.mode == mode {
424 return self;
425 }
426 self.mode = mode;
427 match &mut self.source {
428 IconSource::Raster {
429 icon,
430 upload_pixels,
431 ..
432 } => {
433 *upload_pixels = prepare_pixels(icon, mode);
434 }
435 IconSource::Animated {
436 icon,
437 frame_upload_pixels,
438 sprite_atlas,
439 anim_handle,
440 ..
441 } => {
442 *frame_upload_pixels = icon
443 .frames()
444 .iter()
445 .map(|f| prepare_pixels(f, mode))
446 .collect();
447 *sprite_atlas = None;
451 *anim_handle = None;
452 }
453 IconSource::Path(_) | IconSource::Svg(_) => {}
454 }
455 self
456 }
457
458 pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
465 self.color = color.into();
466 self
467 }
468
469 pub fn icon_size(mut self, size: f32) -> Self {
473 self.display_size = size;
474 self
475 }
476
477 pub fn follow_text_scale(mut self, follow: bool) -> Self {
483 self.follow_text_scale = follow;
484 self
485 }
486
487 pub(crate) fn display_size(&self) -> f32 {
491 self.display_size
492 }
493
494 fn scaled_path(&self, bounds: Rect) -> Path {
499 let path = match &self.source {
500 IconSource::Path(p) => p,
501 _ => return Path::new(),
502 };
503 if path.is_empty() {
504 return path.clone();
505 }
506 let scale_x = bounds.width / self.design_size;
507 let scale_y = bounds.height / self.design_size;
508 let offset_x = bounds.x;
509 let offset_y = bounds.y;
510
511 let mut scaled = Path::new();
512 for cmd in &path.commands {
513 match *cmd {
514 PathCommand::MoveTo(p) => {
515 scaled.move_to(Point::new(
516 p.x * scale_x + offset_x,
517 p.y * scale_y + offset_y,
518 ));
519 }
520 PathCommand::LineTo(p) => {
521 scaled.line_to(Point::new(
522 p.x * scale_x + offset_x,
523 p.y * scale_y + offset_y,
524 ));
525 }
526 PathCommand::QuadTo { control, to } => {
527 scaled.quad_to(
528 Point::new(
529 control.x * scale_x + offset_x,
530 control.y * scale_y + offset_y,
531 ),
532 Point::new(to.x * scale_x + offset_x, to.y * scale_y + offset_y),
533 );
534 }
535 PathCommand::CubicTo {
536 control1,
537 control2,
538 to,
539 } => {
540 scaled.cubic_to(
541 Point::new(
542 control1.x * scale_x + offset_x,
543 control1.y * scale_y + offset_y,
544 ),
545 Point::new(
546 control2.x * scale_x + offset_x,
547 control2.y * scale_y + offset_y,
548 ),
549 Point::new(to.x * scale_x + offset_x, to.y * scale_y + offset_y),
550 );
551 }
552 PathCommand::ArcTo {
553 rect,
554 start_angle,
555 sweep_angle,
556 } => {
557 scaled.arc_to(
558 Rect::new(
559 rect.x * scale_x + offset_x,
560 rect.y * scale_y + offset_y,
561 rect.width * scale_x,
562 rect.height * scale_y,
563 ),
564 start_angle,
565 sweep_angle,
566 );
567 }
568 PathCommand::Close => {
569 scaled.close();
570 }
571 }
572 }
573 scaled
574 }
575
576 fn paint_raster(
578 &self,
579 bounds: Rect,
580 canvas: &mut Canvas,
581 name: &str,
582 width: u32,
583 height: u32,
584 upload_pixels: &[u8],
585 color: Color,
586 ) {
587 if !canvas.has_pending_image(name) {
588 canvas.ensure_image_registered(name, width, height, Cow::Owned(upload_pixels.to_vec()));
589 }
590 match self.mode {
591 IconMode::Tintable => canvas.draw_tinted_image(bounds, name, color),
592 IconMode::FullColor => canvas.draw_image(bounds, name),
593 }
594 }
595}
596
597impl std::fmt::Debug for IconWidget {
598 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599 f.debug_struct("IconWidget")
600 .field("display_size", &self.display_size)
601 .field("mode", &self.mode)
602 .finish()
603 }
604}
605
606impl Widget for IconWidget {
607 fn build(
608 &mut self,
609 ctx: &mut teksilo_core::build_context::BuildContext,
610 ) -> Vec<teksilo_core::widget_id::WidgetId> {
611 {
613 let self_id = ctx.self_id();
614 let registry = ctx.binding_registry();
615 self.color.register_if_bound(
616 self_id,
617 registry,
618 teksilo_core::binding::BindingLevel::RepaintOnly,
619 );
620 }
621
622 let mode = self.mode;
628 let icon_color = self.color.clone();
629 if let IconSource::Animated {
630 name,
631 icon,
632 frame_upload_pixels,
633 frame_signal,
634 sprite_atlas,
635 anim_handle,
636 } = &mut self.source
637 {
638 if ctx.prefers_reduced_motion() {
639 *frame_signal = None;
640 *sprite_atlas = None;
641 *anim_handle = None;
642 } else {
643 if sprite_atlas.is_none() {
645 *sprite_atlas = build_sprite_atlas(name, icon, frame_upload_pixels);
646 }
647 if let Some(atlas) = sprite_atlas.as_ref() {
648 let tint = match mode {
653 IconMode::Tintable => Some(icon_color),
654 IconMode::FullColor => None,
655 };
656 *anim_handle = Some(ctx.animated_quad(AnimatedQuadKind::SpriteCycle {
657 image_name: atlas.name.clone(),
658 frame_count: icon.frame_count() as u32,
659 cols: atlas.cols,
660 rows: atlas.rows,
661 period: icon.total_duration(),
662 tint,
663 }));
664 *frame_signal = None;
667 } else {
668 let signal = ctx.animated_signal(0.0);
671 {
672 let self_id = ctx.self_id();
673 let registry = ctx.binding_registry();
674 signal.bind_to(
675 self_id,
676 registry,
677 teksilo_core::binding::BindingLevel::RepaintOnly,
678 );
679 }
680 let frame_count = icon.frame_count() as f32;
681 let period = icon.total_duration();
682 signal.animate_looping(
683 frame_count,
684 period,
685 Easing::Linear,
686 Some(std::time::Duration::from_millis(33)),
687 );
688 *frame_signal = Some(signal);
689 }
690 }
691 }
692
693 Vec::new()
694 }
695
696 fn layout_response(
697 &self,
698 _proposal: SizeProposal,
699 ctx: &LayoutContext,
700 ) -> teksilo_core::widget::LayoutResponse {
701 let size = if self.follow_text_scale {
704 self.display_size * ctx.text_scale
705 } else {
706 self.display_size
707 };
708 Size::new(size, size).into()
709 }
710
711 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
712 let color = self.color.resolve(ctx.theme, ctx.effective_enabled);
713
714 match &self.source {
715 IconSource::Path(_) => {
716 if color.a() > 0.0 {
717 let scaled = self.scaled_path(bounds);
718 if !scaled.is_empty() {
719 canvas.fill_path(&scaled, color);
720 }
721 }
722 }
723 IconSource::Svg(icon) => {
724 if color.a() <= 0.0 {
725 return;
726 }
727 if self.mode == IconMode::FullColor && !icon.is_monochrome() {
733 for op in icon.draw_ops_in_rect(bounds, color) {
734 match op {
735 SvgDrawOp::Fill {
736 path,
737 fill_rule,
738 paint,
739 } => canvas.fill_path_with_rule(&path, paint, fill_rule),
740 SvgDrawOp::Stroke { path, style, paint } => {
741 canvas.stroke_path_with_paint(&path, paint, style)
742 }
743 }
744 }
745 return;
746 }
747
748 let fill = icon.to_path_in_rect(bounds);
752 if !fill.is_empty() {
753 canvas.fill_path(&fill, color);
754 }
755 for (path, rule, opacity) in icon.extra_fills_in_rect(bounds) {
756 let c = color.with_alpha(color.a() * opacity);
757 if !path.is_empty() && c.a() > 0.0 {
758 canvas.fill_path_with_rule(&path, c, rule);
759 }
760 }
761 for (path, style, opacity) in icon.stroked_paths_in_rect(bounds) {
762 let c = color.with_alpha(color.a() * opacity);
763 if !path.is_empty() && c.a() > 0.0 {
764 canvas.stroke_path(&path, c, style);
765 }
766 }
767 }
768 IconSource::Raster {
769 name,
770 icon,
771 upload_pixels,
772 } => {
773 self.paint_raster(
774 bounds,
775 canvas,
776 name,
777 icon.width(),
778 icon.height(),
779 upload_pixels,
780 color,
781 );
782 }
783 IconSource::Animated {
784 name,
785 icon,
786 frame_upload_pixels,
787 frame_signal,
788 sprite_atlas,
789 anim_handle,
790 } => {
791 if let (Some(atlas), Some(handle)) = (sprite_atlas, anim_handle) {
795 canvas.ensure_image_registered(
798 atlas.name.clone(),
799 atlas.width,
800 atlas.height,
801 std::borrow::Cow::Owned(atlas.pixels.clone()),
802 );
803 canvas.draw_animated_quad(
804 bounds,
805 handle.slot(),
806 AnimatedQuadClass::Sprite {
807 image_name: atlas.name.clone(),
808 },
809 );
810 return;
811 }
812 let idx = frame_signal
818 .as_ref()
819 .map(|s| (s.get() as usize).min(icon.frame_count().saturating_sub(1)))
820 .unwrap_or(0);
821 let frame_name = format!("{name}_f{idx}");
822 let frame = &icon.frames()[idx];
823 let pixels = &frame_upload_pixels[idx];
824 self.paint_raster(
825 bounds,
826 canvas,
827 &frame_name,
828 frame.width(),
829 frame.height(),
830 pixels,
831 color,
832 );
833 }
834 }
835 }
836
837 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
838 }
840}
841
842fn build_sprite_atlas(
855 name: &str,
856 icon: &AnimatedIcon,
857 frame_pixels: &[Vec<u8>],
858) -> Option<SpriteAtlas> {
859 let frames = icon.frames();
860 if frames.is_empty() {
861 return None;
862 }
863 let frame_w = frames[0].width();
864 let frame_h = frames[0].height();
865 if frame_w == 0 || frame_h == 0 {
866 return None;
867 }
868
869 let n = frames.len() as u32;
870 let cols = (n as f32).sqrt().ceil() as u32;
871 let rows = n.div_ceil(cols);
872 let atlas_w = cols * frame_w;
873 let atlas_h = rows * frame_h;
874 let mut pixels = vec![0u8; (atlas_w * atlas_h * 4) as usize];
875
876 for (i, cell) in frame_pixels.iter().enumerate() {
877 let i = i as u32;
878 let col = i % cols;
879 let row = i / cols;
880 let dst_x = col * frame_w;
881 let dst_y = row * frame_h;
882 for y in 0..frame_h {
885 let src_start = (y * frame_w * 4) as usize;
886 let src_end = src_start + (frame_w * 4) as usize;
887 if src_end > cell.len() {
888 break; }
890 let dst_start = (((dst_y + y) * atlas_w + dst_x) * 4) as usize;
891 let dst_end = dst_start + (frame_w * 4) as usize;
892 pixels[dst_start..dst_end].copy_from_slice(&cell[src_start..src_end]);
893 }
894 }
895
896 Some(SpriteAtlas {
897 name: format!("{name}_sprite_atlas"),
898 pixels,
899 width: atlas_w,
900 height: atlas_h,
901 cols,
902 rows,
903 })
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909 use teksilo_core::widget_tree::WidgetTree;
910
911 #[test]
912 fn icon_intrinsic_size() {
913 let mut tree = WidgetTree::new();
914 let icon = tree.add(IconWidget::checkmark(24.0));
915 tree.layout(SizeProposal::unspecified());
916 let b = tree.bounds(icon);
917 assert!((b.width - 24.0).abs() < 0.01);
918 assert!((b.height - 24.0).abs() < 0.01);
919 }
920
921 #[test]
922 fn icon_custom_size() {
923 let mut tree = WidgetTree::new();
924 let icon = tree.add(IconWidget::chevron_down(16.0));
925 tree.layout(SizeProposal::unspecified());
926 let b = tree.bounds(icon);
927 assert!((b.width - 16.0).abs() < 0.01);
928 assert!((b.height - 16.0).abs() < 0.01);
929 }
930
931 #[test]
932 fn follow_text_scale_grows_with_user_scale() {
933 let mut tree = WidgetTree::new();
935 let scaled = tree.add(IconWidget::checkmark(20.0).follow_text_scale(true));
936 let fixed = tree.add(IconWidget::checkmark(20.0));
937 tree.set_user_text_scale(2.0);
938 tree.layout(SizeProposal::unspecified());
939 let bs = tree.bounds(scaled);
940 let bf = tree.bounds(fixed);
941 assert!(
942 (bs.width - 40.0).abs() < 0.01,
943 "opted-in icon should double: {bs:?}"
944 );
945 assert!(
946 (bf.width - 20.0).abs() < 0.01,
947 "default icon must not scale: {bf:?}"
948 );
949 }
950
951 #[test]
952 fn icon_paints_path() {
953 let mut tree = WidgetTree::new();
954 tree.add(IconWidget::checkmark(24.0).color(Color::BLACK));
955 tree.layout(SizeProposal::exact(24.0, 24.0));
956 let frame = tree.render();
957 assert!(!frame.paths.is_empty(), "icon should render a path");
958 }
959
960 #[test]
961 fn empty_path_does_not_paint() {
962 let mut tree = WidgetTree::new();
963 tree.add(IconWidget::from_path(Path::new(), 24.0).color(Color::BLACK));
964 tree.layout(SizeProposal::exact(24.0, 24.0));
965 let frame = tree.render();
966 assert!(frame.paths.is_empty(), "empty path should not render");
967 }
968
969 #[test]
970 fn icon_from_svg() {
971 let svg = r#"<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
972 <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
973 </svg>"#;
974 let mut tree = WidgetTree::new();
975 tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
976 tree.layout(SizeProposal::exact(24.0, 24.0));
977 let frame = tree.render();
978 assert!(!frame.paths.is_empty(), "SVG icon should render a path");
979 }
980
981 #[test]
982 fn icon_from_svg_line_style_renders_stroke_not_fill() {
983 let svg = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
987 <circle cx="12" cy="12" r="10"/>
988 </svg>"#;
989 let mut tree = WidgetTree::new();
990 tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
991 tree.layout(SizeProposal::exact(24.0, 24.0));
992 let frame = tree.render();
993 assert_eq!(
994 frame.paths.len(),
995 1,
996 "line-style SVG should render exactly one (stroked) path"
997 );
998 assert!(
999 frame.paths[0].stroke_style.width > 0.0,
1000 "the rendered path must be stroked, not filled"
1001 );
1002 }
1003
1004 #[test]
1005 fn icon_from_svg_evenodd_emits_evenodd_path_entry() {
1006 let svg = r#"<svg viewBox="0 0 24 24">
1009 <path fill-rule="evenodd" d="M2 2L22 2L22 22Z"/>
1010 </svg>"#;
1011 let mut tree = WidgetTree::new();
1012 tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
1013 tree.layout(SizeProposal::exact(24.0, 24.0));
1014 let frame = tree.render();
1015 assert_eq!(frame.paths.len(), 1, "evenodd icon renders one path");
1016 assert_eq!(
1017 frame.paths[0].fill_rule,
1018 teksilo_canvas::FillRule::EvenOdd,
1019 "the fill rule must reach the PathEntry"
1020 );
1021 }
1022
1023 #[test]
1024 fn icon_from_svg_invalid_fallback() {
1025 let mut tree = WidgetTree::new();
1026 tree.add(IconWidget::from_svg("<not-svg>"));
1027 tree.layout(SizeProposal::exact(24.0, 24.0));
1028 let frame = tree.render();
1029 assert!(frame.paths.is_empty());
1031 }
1032
1033 #[test]
1034 fn icon_mode_default_is_tintable() {
1035 let icon = IconWidget::checkmark(24.0);
1036 assert_eq!(icon.mode, IconMode::Tintable);
1037 }
1038
1039 #[test]
1040 fn icon_mode_can_be_set() {
1041 let icon = IconWidget::checkmark(24.0).mode(IconMode::FullColor);
1042 assert_eq!(icon.mode, IconMode::FullColor);
1043 }
1044
1045 #[test]
1046 fn raster_icon_paints_image() {
1047 let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
1048 let mut tree = WidgetTree::new();
1049 tree.add(IconWidget::from_raster(&icon, 24.0).color(Color::BLACK));
1050 tree.layout(SizeProposal::exact(24.0, 24.0));
1051 let frame = tree.render();
1052 assert!(
1054 !frame.images.is_empty(),
1055 "raster icon should render an image"
1056 );
1057 }
1058
1059 #[test]
1060 fn raster_icon_tintable_has_tint() {
1061 let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
1062 let mut tree = WidgetTree::new();
1063 tree.add(
1064 IconWidget::from_raster(&icon, 24.0)
1065 .color(Color::from_hex("#FF0000"))
1066 .mode(IconMode::Tintable),
1067 );
1068 tree.layout(SizeProposal::exact(24.0, 24.0));
1069 let frame = tree.render();
1070 assert!(
1071 frame.images[0].tint.is_some(),
1072 "tintable icon should have tint"
1073 );
1074 }
1075
1076 #[test]
1077 fn raster_icon_fullcolor_no_tint() {
1078 let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
1079 let mut tree = WidgetTree::new();
1080 tree.add(IconWidget::from_raster(&icon, 24.0).mode(IconMode::FullColor));
1081 tree.layout(SizeProposal::exact(24.0, 24.0));
1082 let frame = tree.render();
1083 assert!(
1084 frame.images[0].tint.is_none(),
1085 "full-color icon should not have tint"
1086 );
1087 }
1088
1089 fn path_icon_color(frame: &teksilo_canvas::RenderFrame) -> [f32; 4] {
1103 frame
1104 .paths
1105 .first()
1106 .map(|p| p.color)
1107 .expect("path icon should render at least one path")
1108 }
1109
1110 #[test]
1111 fn role_based_icon_uses_text_disabled_when_self_disabled() {
1112 let mut tree = WidgetTree::new();
1116 let theme = teksilo_core::presets::intui::light();
1117 tree.set_theme(theme.clone());
1118
1119 let icon = tree.add(IconWidget::checkmark(24.0));
1120 tree.enabled_when(icon, false);
1121 tree.layout(SizeProposal::exact(24.0, 24.0));
1122 let frame = tree.render();
1123 let color = path_icon_color(&frame);
1124
1125 let expected = theme.colors.text_disabled.to_array();
1126 assert_eq!(
1127 color, expected,
1128 "default-role IconWidget under enabled_when(false) must paint at text_disabled, got {color:?}"
1129 );
1130 }
1131
1132 #[test]
1133 fn role_based_icon_uses_text_primary_when_self_enabled() {
1134 let mut tree = WidgetTree::new();
1135 let theme = teksilo_core::presets::intui::light();
1136 tree.set_theme(theme.clone());
1137
1138 tree.add(IconWidget::checkmark(24.0));
1139 tree.layout(SizeProposal::exact(24.0, 24.0));
1140 let frame = tree.render();
1141 let color = path_icon_color(&frame);
1142
1143 let expected = theme.colors.text_primary.to_array();
1144 assert_eq!(
1145 color, expected,
1146 "default-role IconWidget without enabled_state must paint at text_primary, got {color:?}"
1147 );
1148 }
1149
1150 #[test]
1151 fn role_based_icon_flips_when_bound_signal_flips_without_rebuild() {
1152 use teksilo_core::signal::Signal;
1157
1158 let mut tree = WidgetTree::new();
1159 let theme = teksilo_core::presets::intui::light();
1160 tree.set_theme(theme.clone());
1161
1162 let is_enabled = Signal::new(true);
1163 let icon = tree.add(IconWidget::checkmark(24.0));
1164 tree.enabled_when(icon, is_enabled.clone());
1165
1166 tree.layout(SizeProposal::exact(24.0, 24.0));
1167 let primary = theme.colors.text_primary.to_array();
1168 let disabled = theme.colors.text_disabled.to_array();
1169 assert_eq!(path_icon_color(&tree.render()), primary, "starts primary");
1170
1171 is_enabled.set(false);
1172 tree.layout(SizeProposal::exact(24.0, 24.0));
1173 assert_eq!(
1174 path_icon_color(&tree.render()),
1175 disabled,
1176 "after flipping signal to false the leaf must repaint at the disabled color"
1177 );
1178
1179 is_enabled.set(true);
1180 tree.layout(SizeProposal::exact(24.0, 24.0));
1181 assert_eq!(
1182 path_icon_color(&tree.render()),
1183 primary,
1184 "flipping back to true must re-resolve to primary"
1185 );
1186 }
1187
1188 #[test]
1189 fn explicit_color_does_not_dim_when_disabled() {
1190 let mut tree = WidgetTree::new();
1195 let red = Color::from_hex("#FF0000");
1196 let icon = tree.add(IconWidget::checkmark(24.0).color(red));
1197 tree.enabled_when(icon, false);
1198 tree.layout(SizeProposal::exact(24.0, 24.0));
1199 let frame = tree.render();
1200 let color = path_icon_color(&frame);
1201 assert_eq!(
1202 color,
1203 red.to_array(),
1204 "explicit-color icons must NOT auto-dim when disabled — caller picked the literal, framework respects it"
1205 );
1206 }
1207
1208 #[test]
1214 fn full_color_svg_keeps_its_own_colors_in_document_order() {
1215 let svg = r##"<svg viewBox="0 0 24 24">
1216 <rect width="24" height="24" fill="#5865F2"/>
1217 <circle cx="12" cy="12" r="6" fill="#FFFFFF"/>
1218 </svg>"##;
1219 let mut tree = WidgetTree::new();
1220 tree.add(
1223 IconWidget::from_svg(svg)
1224 .icon_size(24.0)
1225 .mode(IconMode::FullColor)
1226 .color(Color::from_hex("#FF0000")),
1227 );
1228 tree.layout(SizeProposal::exact(24.0, 24.0));
1229 let frame = tree.render();
1230
1231 assert_eq!(frame.paths.len(), 2, "one path per authored shape");
1232 assert_eq!(frame.paths[0].color, Color::from_hex("#5865F2").to_array());
1233 assert_eq!(frame.paths[1].color, Color::from_hex("#FFFFFF").to_array());
1234 }
1235
1236 #[test]
1240 fn tintable_mode_still_merges_a_colored_svg_into_one_tinted_path() {
1241 let svg = r##"<svg viewBox="0 0 24 24">
1242 <rect width="24" height="24" fill="#5865F2"/>
1243 <circle cx="12" cy="12" r="6" fill="#FFFFFF"/>
1244 </svg>"##;
1245 let mut tree = WidgetTree::new();
1246 tree.add(
1247 IconWidget::from_svg(svg)
1248 .icon_size(24.0)
1249 .color(Color::from_hex("#FF0000")),
1250 );
1251 tree.layout(SizeProposal::exact(24.0, 24.0));
1252 let frame = tree.render();
1253
1254 assert_eq!(frame.paths.len(), 1, "both shapes merge into one fill");
1255 assert_eq!(
1256 frame.paths[0].color,
1257 Color::from_hex("#FF0000").to_array(),
1258 "tintable ignores the artwork's colors and takes the widget's"
1259 );
1260 }
1261
1262 #[test]
1265 fn current_color_follows_the_widget_inside_full_color_artwork() {
1266 let svg = r##"<svg viewBox="0 0 24 24">
1267 <rect width="24" height="24" fill="#5865F2"/>
1268 <rect width="8" height="8" fill="currentColor"/>
1269 </svg>"##;
1270 let mut tree = WidgetTree::new();
1271 let accent = Color::from_hex("#00FF00");
1272 tree.add(
1273 IconWidget::from_svg(svg)
1274 .icon_size(24.0)
1275 .mode(IconMode::FullColor)
1276 .color(accent),
1277 );
1278 tree.layout(SizeProposal::exact(24.0, 24.0));
1279 let frame = tree.render();
1280 assert_eq!(frame.paths[0].color, Color::from_hex("#5865F2").to_array());
1281 assert_eq!(frame.paths[1].color, accent.to_array());
1282 }
1283
1284 #[test]
1287 fn a_gradient_fill_reaches_the_renderer_as_a_gradient() {
1288 let svg = r##"<svg viewBox="0 0 24 24">
1289 <linearGradient id="g">
1290 <stop offset="0" stop-color="#FF0000"/>
1291 <stop offset="1" stop-color="#0000FF"/>
1292 </linearGradient>
1293 <rect width="24" height="24" fill="url(#g)"/>
1294 </svg>"##;
1295 let mut tree = WidgetTree::new();
1296 tree.add(
1297 IconWidget::from_svg(svg)
1298 .icon_size(24.0)
1299 .mode(IconMode::FullColor)
1300 .color(Color::BLACK),
1301 );
1302 tree.layout(SizeProposal::exact(24.0, 24.0));
1303 let frame = tree.render();
1304 match &frame.paths[0].paint_data {
1305 teksilo_canvas::PaintData::LinearGradient { start, end, stops } => {
1306 assert_eq!(stops.len(), 2);
1307 assert_eq!(stops[0].color.to_array(), [1.0, 0.0, 0.0, 1.0]);
1308 assert!(start[0].abs() < 0.01);
1310 assert!((end[0] - 24.0).abs() < 0.01, "end {end:?}");
1311 }
1312 other => panic!("expected a linear gradient paint, got {other:?}"),
1313 }
1314 }
1315
1316 #[test]
1320 fn a_gradient_stroke_is_rebased_onto_the_expanded_stroke_bounds() {
1321 let svg = r##"<svg viewBox="0 0 24 24">
1322 <linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="24" y2="0">
1323 <stop offset="0" stop-color="#FF0000"/>
1324 <stop offset="1" stop-color="#0000FF"/>
1325 </linearGradient>
1326 <rect x="2" y="2" width="20" height="20" fill="none" stroke="url(#g)" stroke-width="4"/>
1327 </svg>"##;
1328 let mut tree = WidgetTree::new();
1329 tree.add(
1330 IconWidget::from_svg(svg)
1331 .icon_size(24.0)
1332 .mode(IconMode::FullColor)
1333 .color(Color::BLACK),
1334 );
1335 tree.layout(SizeProposal::exact(24.0, 24.0));
1336 let frame = tree.render();
1337
1338 let entry = &frame.paths[0];
1339 assert!(entry.stroke_style.width > 0.0, "must be a stroke");
1340 match &entry.paint_data {
1341 teksilo_canvas::PaintData::LinearGradient { start, end, .. } => {
1342 assert!((start[0] - 2.0).abs() < 0.05, "start {start:?}");
1348 assert!((end[0] - 26.0).abs() < 0.05, "end {end:?}");
1349 }
1350 other => panic!("expected a linear gradient stroke, got {other:?}"),
1351 }
1352 }
1353
1354 #[test]
1358 fn the_widget_alpha_dims_full_color_artwork() {
1359 let svg =
1360 r##"<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="#FF0000"/></svg>"##;
1361 let mut tree = WidgetTree::new();
1362 tree.add(
1363 IconWidget::from_svg(svg)
1364 .icon_size(24.0)
1365 .mode(IconMode::FullColor)
1366 .color(Color::new(0.0, 0.0, 0.0, 0.5)),
1367 );
1368 tree.layout(SizeProposal::exact(24.0, 24.0));
1369 let frame = tree.render();
1370 let c = frame.paths[0].color;
1371 assert_eq!([c[0], c[1], c[2]], [1.0, 0.0, 0.0], "the red must survive");
1372 assert!((c[3] - 0.5).abs() < 1e-5, "…at half alpha, got {}", c[3]);
1373 }
1374}