1use std::sync::Arc;
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 base_name: String,
81 name: String,
84 icon: RasterIcon,
85 upload_pixels: Arc<[u8]>,
86 },
87 Animated {
96 base_name: String,
98 name: String,
100 icon: AnimatedIcon,
101 frame_upload_pixels: Vec<Arc<[u8]>>,
102 frame_signal: Option<Signal<f32>>,
106 sprite_atlas: Option<SpriteAtlas>,
109 anim_handle: Option<AnimatedQuadHandle>,
113 },
114}
115
116#[derive(Debug, Clone)]
121struct SpriteAtlas {
122 name: String,
126 pixels: Arc<[u8]>,
130 width: u32,
131 height: u32,
132 cols: u32,
133 rows: u32,
134}
135
136pub struct IconWidget {
138 source: IconSource,
139 design_size: f32,
142 display_size: f32,
145 color: ColorProp,
148 mode: IconMode,
150 follow_text_scale: bool,
157}
158
159fn auto_name(prefix: &str, ptr: usize) -> String {
162 format!("_icon_{prefix}_{ptr:x}")
163}
164
165fn mode_qualified(base: &str, mode: IconMode) -> String {
168 match mode {
169 IconMode::Tintable => format!("{base}_tint"),
170 IconMode::FullColor => format!("{base}_full"),
171 }
172}
173
174fn prepare_pixels(icon: &RasterIcon, mode: IconMode) -> Arc<[u8]> {
177 match mode {
178 IconMode::Tintable => Arc::clone(icon.to_alpha_mask().shared_pixels()),
179 IconMode::FullColor => Arc::clone(icon.shared_pixels()),
180 }
181}
182
183impl IconWidget {
184 pub fn from_path(path: Path, size: f32) -> Self {
187 Self {
188 source: IconSource::Path(path),
189 design_size: size,
190 display_size: size,
191 color: ColorProp::TextRole(TextRole::Primary),
192 mode: IconMode::Tintable,
193 follow_text_scale: false,
194 }
195 }
196
197 pub fn checkmark(size: f32) -> Self {
199 let mut path = Path::new();
200 let s = size;
201 path.move_to(Point::new(s * 0.2, s * 0.5));
202 path.line_to(Point::new(s * 0.4, s * 0.75));
203 path.line_to(Point::new(s * 0.8, s * 0.25));
204 Self::from_path(path, size)
205 }
206
207 pub fn dash(size: f32) -> Self {
211 let s = size;
212 let y_mid = s * 0.5;
213 let half_thickness = s * 0.06; let mut path = Path::new();
215 path.move_to(Point::new(s * 0.2, y_mid - half_thickness));
216 path.line_to(Point::new(s * 0.8, y_mid - half_thickness));
217 path.line_to(Point::new(s * 0.8, y_mid + half_thickness));
218 path.line_to(Point::new(s * 0.2, y_mid + half_thickness));
219 path.close();
220 Self::from_path(path, size)
221 }
222
223 pub fn radio_dot(size: f32) -> Self {
226 let s = size;
227 let path = Path::circle(Point::new(s * 0.5, s * 0.5), s * 0.25);
228 Self::from_path(path, size)
229 }
230
231 pub fn chevron_down(size: f32) -> Self {
233 let mut path = Path::new();
234 let s = size;
235 path.move_to(Point::new(s * 0.25, s * 0.35));
236 path.line_to(Point::new(s * 0.5, s * 0.65));
237 path.line_to(Point::new(s * 0.75, s * 0.35));
238 Self::from_path(path, size)
239 }
240
241 pub fn chevron_right(size: f32) -> Self {
243 let mut path = Path::new();
244 let s = size;
245 path.move_to(Point::new(s * 0.35, s * 0.25));
246 path.line_to(Point::new(s * 0.65, s * 0.5));
247 path.line_to(Point::new(s * 0.35, s * 0.75));
248 Self::from_path(path, size)
249 }
250
251 pub fn chevron_left(size: f32) -> Self {
253 let mut path = Path::new();
254 let s = size;
255 path.move_to(Point::new(s * 0.65, s * 0.25));
256 path.line_to(Point::new(s * 0.35, s * 0.5));
257 path.line_to(Point::new(s * 0.65, s * 0.75));
258 Self::from_path(path, size)
259 }
260
261 pub fn chevron_up(size: f32) -> Self {
263 let mut path = Path::new();
264 let s = size;
265 path.move_to(Point::new(s * 0.25, s * 0.65));
266 path.line_to(Point::new(s * 0.5, s * 0.35));
267 path.line_to(Point::new(s * 0.75, s * 0.65));
268 Self::from_path(path, size)
269 }
270
271 pub fn from_svg(svg_str: &str) -> Self {
278 match SvgIcon::parse(svg_str) {
279 Ok(icon) => Self::from_svg_icon(&icon),
280 Err(_e) => {
281 #[cfg(debug_assertions)]
282 eprintln!("teksilo: SVG parse error: {_e}");
283 Self::from_path(Path::new(), 0.0)
284 }
285 }
286 }
287
288 pub fn from_svg_icon(icon: &SvgIcon) -> Self {
292 let vb_size = icon.width().max(icon.height());
293 Self {
294 source: IconSource::Svg(icon.clone()),
295 design_size: vb_size,
296 display_size: vb_size,
297 color: ColorProp::TextRole(TextRole::Primary),
298 mode: IconMode::Tintable,
299 follow_text_scale: false,
300 }
301 }
302
303 pub fn from_png(data: &'static [u8], size: f32) -> Self {
307 match RasterIcon::decode_png(data) {
308 Ok(icon) => {
309 let base_name = auto_name("png", data.as_ptr() as usize);
310 let mode = IconMode::Tintable;
311 let upload_pixels = prepare_pixels(&icon, mode);
312 Self {
313 source: IconSource::Raster {
314 name: mode_qualified(&base_name, mode),
315 base_name,
316 icon,
317 upload_pixels,
318 },
319 design_size: size,
320 display_size: size,
321 color: ColorProp::TextRole(TextRole::Primary),
322 mode,
323 follow_text_scale: false,
324 }
325 }
326 Err(_e) => {
327 #[cfg(debug_assertions)]
328 eprintln!("teksilo: PNG decode error: {_e}");
329 Self::from_path(Path::new(), size)
330 }
331 }
332 }
333
334 pub fn from_webp(data: &'static [u8], size: f32) -> Self {
338 let mode = IconMode::Tintable;
339 if let Ok(anim) = AnimatedIcon::decode_webp(data) {
341 let base_name = auto_name("webp", data.as_ptr() as usize);
342 let frame_upload_pixels: Vec<Arc<[u8]>> = anim
343 .frames()
344 .iter()
345 .map(|f| prepare_pixels(f, mode))
346 .collect();
347 return Self {
348 source: IconSource::Animated {
349 name: mode_qualified(&base_name, mode),
350 base_name,
351 icon: anim,
352 frame_upload_pixels,
353 frame_signal: None,
354 sprite_atlas: None,
355 anim_handle: None,
356 },
357 design_size: size,
358 display_size: size,
359 color: ColorProp::TextRole(TextRole::Primary),
360 mode,
361 follow_text_scale: false,
362 };
363 }
364 match RasterIcon::decode_webp(data) {
366 Ok(icon) => {
367 let base_name = auto_name("webp", data.as_ptr() as usize);
368 let upload_pixels = prepare_pixels(&icon, mode);
369 Self {
370 source: IconSource::Raster {
371 name: mode_qualified(&base_name, mode),
372 base_name,
373 icon,
374 upload_pixels,
375 },
376 design_size: size,
377 display_size: size,
378 color: ColorProp::TextRole(TextRole::Primary),
379 mode,
380 follow_text_scale: false,
381 }
382 }
383 Err(_e) => {
384 #[cfg(debug_assertions)]
385 eprintln!("teksilo: WebP decode error: {_e}");
386 Self::from_path(Path::new(), size)
387 }
388 }
389 }
390
391 pub fn from_raster(icon: &RasterIcon, size: f32) -> Self {
401 let base_name = format!("_icon_raster_{}", icon.texture_key());
402 let mode = IconMode::Tintable;
403 let upload_pixels = prepare_pixels(icon, mode);
404 Self {
405 source: IconSource::Raster {
406 name: mode_qualified(&base_name, mode),
407 base_name,
408 icon: icon.clone(),
409 upload_pixels,
410 },
411 design_size: size,
412 display_size: size,
413 color: ColorProp::TextRole(TextRole::Primary),
414 mode,
415 follow_text_scale: false,
416 }
417 }
418
419 pub fn from_animated(icon: &AnimatedIcon, size: f32) -> Self {
425 let first_frame_key = icon.frames().first().map_or(0, RasterIcon::texture_key);
426 let base_name = format!("_icon_anim_{first_frame_key}");
427 let mode = IconMode::Tintable;
428 let frame_upload_pixels: Vec<Arc<[u8]>> = icon
429 .frames()
430 .iter()
431 .map(|f| prepare_pixels(f, mode))
432 .collect();
433 Self {
434 source: IconSource::Animated {
435 name: mode_qualified(&base_name, mode),
436 base_name,
437 icon: icon.clone(),
438 frame_upload_pixels,
439 frame_signal: None,
440 sprite_atlas: None,
441 anim_handle: None,
442 },
443 design_size: size,
444 display_size: size,
445 color: ColorProp::TextRole(TextRole::Primary),
446 mode,
447 follow_text_scale: false,
448 }
449 }
450
451 pub fn mode(mut self, mode: IconMode) -> Self {
454 if self.mode == mode {
455 return self;
456 }
457 self.mode = mode;
458 match &mut self.source {
459 IconSource::Raster {
460 base_name,
461 name,
462 icon,
463 upload_pixels,
464 } => {
465 *name = mode_qualified(base_name, mode);
466 *upload_pixels = prepare_pixels(icon, mode);
467 }
468 IconSource::Animated {
469 base_name,
470 name,
471 icon,
472 frame_upload_pixels,
473 sprite_atlas,
474 anim_handle,
475 ..
476 } => {
477 *name = mode_qualified(base_name, mode);
478 *frame_upload_pixels = icon
479 .frames()
480 .iter()
481 .map(|f| prepare_pixels(f, mode))
482 .collect();
483 *sprite_atlas = None;
487 *anim_handle = None;
488 }
489 IconSource::Path(_) | IconSource::Svg(_) => {}
490 }
491 self
492 }
493
494 pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
501 self.color = color.into();
502 self
503 }
504
505 pub fn icon_size(mut self, size: f32) -> Self {
509 self.display_size = size;
510 self
511 }
512
513 pub fn follow_text_scale(mut self, follow: bool) -> Self {
519 self.follow_text_scale = follow;
520 self
521 }
522
523 pub(crate) fn display_size(&self) -> f32 {
527 self.display_size
528 }
529
530 fn scaled_path(&self, bounds: Rect) -> Path {
535 let path = match &self.source {
536 IconSource::Path(p) => p,
537 _ => return Path::new(),
538 };
539 if path.is_empty() {
540 return path.clone();
541 }
542 let scale_x = bounds.width / self.design_size;
543 let scale_y = bounds.height / self.design_size;
544 let offset_x = bounds.x;
545 let offset_y = bounds.y;
546
547 let mut scaled = Path::new();
548 for cmd in path.commands() {
549 match *cmd {
550 PathCommand::MoveTo(p) => {
551 scaled.move_to(Point::new(
552 p.x * scale_x + offset_x,
553 p.y * scale_y + offset_y,
554 ));
555 }
556 PathCommand::LineTo(p) => {
557 scaled.line_to(Point::new(
558 p.x * scale_x + offset_x,
559 p.y * scale_y + offset_y,
560 ));
561 }
562 PathCommand::QuadTo { control, to } => {
563 scaled.quad_to(
564 Point::new(
565 control.x * scale_x + offset_x,
566 control.y * scale_y + offset_y,
567 ),
568 Point::new(to.x * scale_x + offset_x, to.y * scale_y + offset_y),
569 );
570 }
571 PathCommand::CubicTo {
572 control1,
573 control2,
574 to,
575 } => {
576 scaled.cubic_to(
577 Point::new(
578 control1.x * scale_x + offset_x,
579 control1.y * scale_y + offset_y,
580 ),
581 Point::new(
582 control2.x * scale_x + offset_x,
583 control2.y * scale_y + offset_y,
584 ),
585 Point::new(to.x * scale_x + offset_x, to.y * scale_y + offset_y),
586 );
587 }
588 PathCommand::ArcTo {
589 rect,
590 start_angle,
591 sweep_angle,
592 } => {
593 scaled.arc_to(
594 Rect::new(
595 rect.x * scale_x + offset_x,
596 rect.y * scale_y + offset_y,
597 rect.width * scale_x,
598 rect.height * scale_y,
599 ),
600 start_angle,
601 sweep_angle,
602 );
603 }
604 PathCommand::Close => {
605 scaled.close();
606 }
607 }
608 }
609 scaled
610 }
611
612 fn paint_raster(
614 &self,
615 bounds: Rect,
616 canvas: &mut Canvas,
617 name: &str,
618 width: u32,
619 height: u32,
620 upload_pixels: &Arc<[u8]>,
621 color: Color,
622 ) {
623 if !canvas.has_pending_image(name) {
624 canvas.ensure_shared_image_registered(name, width, height, Arc::clone(upload_pixels));
625 }
626 match self.mode {
627 IconMode::Tintable => canvas.draw_tinted_image(bounds, name, color),
628 IconMode::FullColor => canvas.draw_image(bounds, name),
629 }
630 }
631}
632
633impl std::fmt::Debug for IconWidget {
634 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
635 f.debug_struct("IconWidget")
636 .field("display_size", &self.display_size)
637 .field("mode", &self.mode)
638 .finish()
639 }
640}
641
642impl Widget for IconWidget {
643 fn build(
644 &mut self,
645 ctx: &mut teksilo_core::build_context::BuildContext,
646 ) -> Vec<teksilo_core::widget_id::WidgetId> {
647 {
649 let self_id = ctx.self_id();
650 let registry = ctx.binding_registry();
651 self.color.register_if_bound(
652 self_id,
653 registry,
654 teksilo_core::binding::BindingLevel::RepaintOnly,
655 );
656 }
657
658 let mode = self.mode;
664 let icon_color = self.color.clone();
665 if let IconSource::Animated {
666 name,
667 icon,
668 frame_upload_pixels,
669 frame_signal,
670 sprite_atlas,
671 anim_handle,
672 ..
673 } = &mut self.source
674 {
675 if ctx.prefers_reduced_motion() {
676 *frame_signal = None;
677 *sprite_atlas = None;
678 *anim_handle = None;
679 } else {
680 if sprite_atlas.is_none() {
682 *sprite_atlas = build_sprite_atlas(name, icon, frame_upload_pixels);
683 }
684 if let Some(atlas) = sprite_atlas.as_ref() {
685 let tint = match mode {
690 IconMode::Tintable => Some(icon_color),
691 IconMode::FullColor => None,
692 };
693 *anim_handle = Some(ctx.animated_quad(AnimatedQuadKind::SpriteCycle {
694 image_name: atlas.name.clone(),
695 frame_count: icon.frame_count() as u32,
696 cols: atlas.cols,
697 rows: atlas.rows,
698 period: icon.total_duration(),
699 tint,
700 }));
701 *frame_signal = None;
704 } else {
705 let signal = ctx.animated_signal(0.0);
708 {
709 let self_id = ctx.self_id();
710 let registry = ctx.binding_registry();
711 signal.bind_to(
712 self_id,
713 registry,
714 teksilo_core::binding::BindingLevel::RepaintOnly,
715 );
716 }
717 let frame_count = icon.frame_count() as f32;
718 let period = icon.total_duration();
719 signal.animate_looping(
720 frame_count,
721 period,
722 Easing::Linear,
723 Some(std::time::Duration::from_millis(33)),
724 );
725 *frame_signal = Some(signal);
726 }
727 }
728 }
729
730 Vec::new()
731 }
732
733 fn layout_response(
734 &self,
735 _proposal: SizeProposal,
736 ctx: &LayoutContext,
737 ) -> teksilo_core::widget::LayoutResponse {
738 let size = if self.follow_text_scale {
741 self.display_size * ctx.text_scale
742 } else {
743 self.display_size
744 };
745 Size::new(size, size).into()
746 }
747
748 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
749 let color = self.color.resolve(ctx.theme, ctx.effective_enabled);
750
751 match &self.source {
752 IconSource::Path(_) => {
753 if color.a() > 0.0 {
754 let scaled = self.scaled_path(bounds);
755 if !scaled.is_empty() {
756 canvas.fill_path(&scaled, color);
757 }
758 }
759 }
760 IconSource::Svg(icon) => {
761 if color.a() <= 0.0 {
762 return;
763 }
764 if self.mode == IconMode::FullColor && !icon.is_monochrome() {
770 for op in icon.draw_ops_in_rect(bounds, color) {
771 match op {
772 SvgDrawOp::Fill {
773 path,
774 fill_rule,
775 paint,
776 } => canvas.fill_path_with_rule(&path, paint, fill_rule),
777 SvgDrawOp::Stroke { path, style, paint } => {
778 canvas.stroke_path_with_paint(&path, paint, style)
779 }
780 }
781 }
782 return;
783 }
784
785 let fill = icon.to_path_in_rect(bounds);
789 if !fill.is_empty() {
790 canvas.fill_path(&fill, color);
791 }
792 for (path, rule, opacity) in icon.extra_fills_in_rect(bounds) {
793 let c = color.with_alpha(color.a() * opacity);
794 if !path.is_empty() && c.a() > 0.0 {
795 canvas.fill_path_with_rule(&path, c, rule);
796 }
797 }
798 for (path, style, opacity) in icon.stroked_paths_in_rect(bounds) {
799 let c = color.with_alpha(color.a() * opacity);
800 if !path.is_empty() && c.a() > 0.0 {
801 canvas.stroke_path(&path, c, style);
802 }
803 }
804 }
805 IconSource::Raster {
806 name,
807 icon,
808 upload_pixels,
809 ..
810 } => {
811 self.paint_raster(
812 bounds,
813 canvas,
814 name,
815 icon.width(),
816 icon.height(),
817 upload_pixels,
818 color,
819 );
820 }
821 IconSource::Animated {
822 name,
823 icon,
824 frame_upload_pixels,
825 frame_signal,
826 sprite_atlas,
827 anim_handle,
828 ..
829 } => {
830 if let (Some(atlas), Some(handle)) = (sprite_atlas, anim_handle) {
834 canvas.ensure_shared_image_registered(
837 atlas.name.clone(),
838 atlas.width,
839 atlas.height,
840 Arc::clone(&atlas.pixels),
841 );
842 canvas.draw_animated_quad(
843 bounds,
844 handle.slot(),
845 AnimatedQuadClass::Sprite {
846 image_name: atlas.name.clone(),
847 },
848 );
849 return;
850 }
851 let idx = frame_signal
857 .as_ref()
858 .map(|s| (s.get() as usize).min(icon.frame_count().saturating_sub(1)))
859 .unwrap_or(0);
860 let frame_name = format!("{name}_f{idx}");
861 let frame = &icon.frames()[idx];
862 let pixels = &frame_upload_pixels[idx];
863 self.paint_raster(
864 bounds,
865 canvas,
866 &frame_name,
867 frame.width(),
868 frame.height(),
869 pixels,
870 color,
871 );
872 }
873 }
874 }
875
876 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
877 }
879}
880
881fn build_sprite_atlas(
894 name: &str,
895 icon: &AnimatedIcon,
896 frame_pixels: &[Arc<[u8]>],
897) -> Option<SpriteAtlas> {
898 let frames = icon.frames();
899 if frames.is_empty() {
900 return None;
901 }
902 let frame_w = frames[0].width();
903 let frame_h = frames[0].height();
904 if frame_w == 0 || frame_h == 0 {
905 return None;
906 }
907
908 let n = frames.len() as u32;
909 let cols = (n as f32).sqrt().ceil() as u32;
910 let rows = n.div_ceil(cols);
911 let atlas_w = cols * frame_w;
912 let atlas_h = rows * frame_h;
913 let mut pixels = vec![0u8; (atlas_w * atlas_h * 4) as usize];
914
915 for (i, cell) in frame_pixels.iter().enumerate() {
916 let i = i as u32;
917 let col = i % cols;
918 let row = i / cols;
919 let dst_x = col * frame_w;
920 let dst_y = row * frame_h;
921 for y in 0..frame_h {
924 let src_start = (y * frame_w * 4) as usize;
925 let src_end = src_start + (frame_w * 4) as usize;
926 if src_end > cell.len() {
927 break; }
929 let dst_start = (((dst_y + y) * atlas_w + dst_x) * 4) as usize;
930 let dst_end = dst_start + (frame_w * 4) as usize;
931 pixels[dst_start..dst_end].copy_from_slice(&cell[src_start..src_end]);
932 }
933 }
934
935 Some(SpriteAtlas {
936 name: format!("{name}_sprite_atlas"),
937 pixels: pixels.into(),
938 width: atlas_w,
939 height: atlas_h,
940 cols,
941 rows,
942 })
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948 use teksilo_core::widget_tree::WidgetTree;
949
950 #[test]
951 fn icon_intrinsic_size() {
952 let mut tree = WidgetTree::new();
953 let icon = tree.add(IconWidget::checkmark(24.0));
954 tree.layout(SizeProposal::unspecified());
955 let b = tree.bounds(icon);
956 assert!((b.width - 24.0).abs() < 0.01);
957 assert!((b.height - 24.0).abs() < 0.01);
958 }
959
960 #[test]
961 fn icon_custom_size() {
962 let mut tree = WidgetTree::new();
963 let icon = tree.add(IconWidget::chevron_down(16.0));
964 tree.layout(SizeProposal::unspecified());
965 let b = tree.bounds(icon);
966 assert!((b.width - 16.0).abs() < 0.01);
967 assert!((b.height - 16.0).abs() < 0.01);
968 }
969
970 #[test]
971 fn follow_text_scale_grows_with_user_scale() {
972 let mut tree = WidgetTree::new();
974 let scaled = tree.add(IconWidget::checkmark(20.0).follow_text_scale(true));
975 let fixed = tree.add(IconWidget::checkmark(20.0));
976 tree.set_user_text_scale(2.0);
977 tree.layout(SizeProposal::unspecified());
978 let bs = tree.bounds(scaled);
979 let bf = tree.bounds(fixed);
980 assert!(
981 (bs.width - 40.0).abs() < 0.01,
982 "opted-in icon should double: {bs:?}"
983 );
984 assert!(
985 (bf.width - 20.0).abs() < 0.01,
986 "default icon must not scale: {bf:?}"
987 );
988 }
989
990 #[test]
991 fn icon_paints_path() {
992 let mut tree = WidgetTree::new();
993 tree.add(IconWidget::checkmark(24.0).color(Color::BLACK));
994 tree.layout(SizeProposal::exact(24.0, 24.0));
995 let frame = tree.render();
996 assert!(!frame.paths.is_empty(), "icon should render a path");
997 }
998
999 #[test]
1000 fn empty_path_does_not_paint() {
1001 let mut tree = WidgetTree::new();
1002 tree.add(IconWidget::from_path(Path::new(), 24.0).color(Color::BLACK));
1003 tree.layout(SizeProposal::exact(24.0, 24.0));
1004 let frame = tree.render();
1005 assert!(frame.paths.is_empty(), "empty path should not render");
1006 }
1007
1008 #[test]
1009 fn icon_from_svg() {
1010 let svg = r#"<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
1011 <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
1012 </svg>"#;
1013 let mut tree = WidgetTree::new();
1014 tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
1015 tree.layout(SizeProposal::exact(24.0, 24.0));
1016 let frame = tree.render();
1017 assert!(!frame.paths.is_empty(), "SVG icon should render a path");
1018 }
1019
1020 #[test]
1021 fn icon_from_svg_line_style_renders_stroke_not_fill() {
1022 let svg = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1026 <circle cx="12" cy="12" r="10"/>
1027 </svg>"#;
1028 let mut tree = WidgetTree::new();
1029 tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
1030 tree.layout(SizeProposal::exact(24.0, 24.0));
1031 let frame = tree.render();
1032 assert_eq!(
1033 frame.paths.len(),
1034 1,
1035 "line-style SVG should render exactly one (stroked) path"
1036 );
1037 assert!(
1038 frame.paths[0].stroke_style.width > 0.0,
1039 "the rendered path must be stroked, not filled"
1040 );
1041 }
1042
1043 #[test]
1044 fn icon_from_svg_evenodd_emits_evenodd_path_entry() {
1045 let svg = r#"<svg viewBox="0 0 24 24">
1048 <path fill-rule="evenodd" d="M2 2L22 2L22 22Z"/>
1049 </svg>"#;
1050 let mut tree = WidgetTree::new();
1051 tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
1052 tree.layout(SizeProposal::exact(24.0, 24.0));
1053 let frame = tree.render();
1054 assert_eq!(frame.paths.len(), 1, "evenodd icon renders one path");
1055 assert_eq!(
1056 frame.paths[0].fill_rule,
1057 teksilo_canvas::FillRule::EvenOdd,
1058 "the fill rule must reach the PathEntry"
1059 );
1060 }
1061
1062 #[test]
1063 fn icon_from_svg_invalid_fallback() {
1064 let mut tree = WidgetTree::new();
1065 tree.add(IconWidget::from_svg("<not-svg>"));
1066 tree.layout(SizeProposal::exact(24.0, 24.0));
1067 let frame = tree.render();
1068 assert!(frame.paths.is_empty());
1070 }
1071
1072 #[test]
1073 fn icon_mode_default_is_tintable() {
1074 let icon = IconWidget::checkmark(24.0);
1075 assert_eq!(icon.mode, IconMode::Tintable);
1076 }
1077
1078 #[test]
1079 fn icon_mode_can_be_set() {
1080 let icon = IconWidget::checkmark(24.0).mode(IconMode::FullColor);
1081 assert_eq!(icon.mode, IconMode::FullColor);
1082 }
1083
1084 #[test]
1085 fn raster_icon_paints_image() {
1086 let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
1087 let mut tree = WidgetTree::new();
1088 tree.add(IconWidget::from_raster(&icon, 24.0).color(Color::BLACK));
1089 tree.layout(SizeProposal::exact(24.0, 24.0));
1090 let frame = tree.render();
1091 assert!(
1093 !frame.images.is_empty(),
1094 "raster icon should render an image"
1095 );
1096 }
1097
1098 #[test]
1099 fn raster_icon_tintable_has_tint() {
1100 let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
1101 let mut tree = WidgetTree::new();
1102 tree.add(
1103 IconWidget::from_raster(&icon, 24.0)
1104 .color(Color::from_hex("#FF0000"))
1105 .mode(IconMode::Tintable),
1106 );
1107 tree.layout(SizeProposal::exact(24.0, 24.0));
1108 let frame = tree.render();
1109 assert!(
1110 frame.images[0].tint.is_some(),
1111 "tintable icon should have tint"
1112 );
1113 }
1114
1115 #[test]
1116 fn raster_icon_fullcolor_no_tint() {
1117 let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
1118 let mut tree = WidgetTree::new();
1119 tree.add(IconWidget::from_raster(&icon, 24.0).mode(IconMode::FullColor));
1120 tree.layout(SizeProposal::exact(24.0, 24.0));
1121 let frame = tree.render();
1122 assert!(
1123 frame.images[0].tint.is_none(),
1124 "full-color icon should not have tint"
1125 );
1126 }
1127
1128 #[test]
1129 fn one_raster_icon_in_two_modes_registers_two_textures() {
1130 let icon = RasterIcon::from_raw(vec![200, 100, 50, 255], 1, 1);
1134 let mut tree = WidgetTree::new();
1135 tree.add(IconWidget::from_raster(&icon, 24.0).mode(IconMode::Tintable));
1136 tree.add(IconWidget::from_raster(&icon, 24.0).mode(IconMode::FullColor));
1137 tree.layout(SizeProposal::exact(24.0, 24.0));
1138 let frame = tree.render();
1139 let names: std::collections::BTreeSet<&str> =
1140 frame.images.iter().map(|q| q.name.as_str()).collect();
1141 assert_eq!(names.len(), 2, "one texture per mode: {names:?}");
1142 let pending: std::collections::BTreeSet<&str> = frame
1143 .pending_images
1144 .iter()
1145 .map(|p| p.name.as_str())
1146 .collect();
1147 assert_eq!(pending, names, "each mode registers its own pixels");
1148 }
1149
1150 #[test]
1151 fn switching_mode_renames_the_texture() {
1152 let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
1153 let tint = IconWidget::from_raster(&icon, 24.0);
1154 let full = IconWidget::from_raster(&icon, 24.0).mode(IconMode::FullColor);
1155 let back = IconWidget::from_raster(&icon, 24.0)
1156 .mode(IconMode::FullColor)
1157 .mode(IconMode::Tintable);
1158 let name = |w: &IconWidget| match &w.source {
1159 IconSource::Raster { name, .. } => name.clone(),
1160 other => panic!("expected a raster source, got {other:?}"),
1161 };
1162 assert_ne!(name(&tint), name(&full));
1163 assert_eq!(name(&tint), name(&back));
1164 assert!(name(&tint).contains(&icon.texture_key().to_string()));
1165 }
1166
1167 fn path_icon_color(frame: &teksilo_canvas::RenderFrame) -> [f32; 4] {
1181 frame
1182 .paths
1183 .first()
1184 .map(|p| p.color)
1185 .expect("path icon should render at least one path")
1186 }
1187
1188 #[test]
1189 fn role_based_icon_uses_text_disabled_when_self_disabled() {
1190 let mut tree = WidgetTree::new();
1194 let theme = teksilo_core::presets::intui::light();
1195 tree.set_theme(theme.clone());
1196
1197 let icon = tree.add(IconWidget::checkmark(24.0));
1198 tree.enabled_when(icon, false);
1199 tree.layout(SizeProposal::exact(24.0, 24.0));
1200 let frame = tree.render();
1201 let color = path_icon_color(&frame);
1202
1203 let expected = theme.colors.text_disabled.to_array();
1204 assert_eq!(
1205 color, expected,
1206 "default-role IconWidget under enabled_when(false) must paint at text_disabled, got {color:?}"
1207 );
1208 }
1209
1210 #[test]
1211 fn role_based_icon_uses_text_primary_when_self_enabled() {
1212 let mut tree = WidgetTree::new();
1213 let theme = teksilo_core::presets::intui::light();
1214 tree.set_theme(theme.clone());
1215
1216 tree.add(IconWidget::checkmark(24.0));
1217 tree.layout(SizeProposal::exact(24.0, 24.0));
1218 let frame = tree.render();
1219 let color = path_icon_color(&frame);
1220
1221 let expected = theme.colors.text_primary.to_array();
1222 assert_eq!(
1223 color, expected,
1224 "default-role IconWidget without enabled_state must paint at text_primary, got {color:?}"
1225 );
1226 }
1227
1228 #[test]
1229 fn role_based_icon_flips_when_bound_signal_flips_without_rebuild() {
1230 use teksilo_core::signal::Signal;
1235
1236 let mut tree = WidgetTree::new();
1237 let theme = teksilo_core::presets::intui::light();
1238 tree.set_theme(theme.clone());
1239
1240 let is_enabled = Signal::new(true);
1241 let icon = tree.add(IconWidget::checkmark(24.0));
1242 tree.enabled_when(icon, is_enabled.clone());
1243
1244 tree.layout(SizeProposal::exact(24.0, 24.0));
1245 let primary = theme.colors.text_primary.to_array();
1246 let disabled = theme.colors.text_disabled.to_array();
1247 assert_eq!(path_icon_color(&tree.render()), primary, "starts primary");
1248
1249 is_enabled.set(false);
1250 tree.layout(SizeProposal::exact(24.0, 24.0));
1251 assert_eq!(
1252 path_icon_color(&tree.render()),
1253 disabled,
1254 "after flipping signal to false the leaf must repaint at the disabled color"
1255 );
1256
1257 is_enabled.set(true);
1258 tree.layout(SizeProposal::exact(24.0, 24.0));
1259 assert_eq!(
1260 path_icon_color(&tree.render()),
1261 primary,
1262 "flipping back to true must re-resolve to primary"
1263 );
1264 }
1265
1266 #[test]
1267 fn explicit_color_does_not_dim_when_disabled() {
1268 let mut tree = WidgetTree::new();
1273 let red = Color::from_hex("#FF0000");
1274 let icon = tree.add(IconWidget::checkmark(24.0).color(red));
1275 tree.enabled_when(icon, false);
1276 tree.layout(SizeProposal::exact(24.0, 24.0));
1277 let frame = tree.render();
1278 let color = path_icon_color(&frame);
1279 assert_eq!(
1280 color,
1281 red.to_array(),
1282 "explicit-color icons must NOT auto-dim when disabled — caller picked the literal, framework respects it"
1283 );
1284 }
1285
1286 #[test]
1292 fn full_color_svg_keeps_its_own_colors_in_document_order() {
1293 let svg = r##"<svg viewBox="0 0 24 24">
1294 <rect width="24" height="24" fill="#5865F2"/>
1295 <circle cx="12" cy="12" r="6" fill="#FFFFFF"/>
1296 </svg>"##;
1297 let mut tree = WidgetTree::new();
1298 tree.add(
1301 IconWidget::from_svg(svg)
1302 .icon_size(24.0)
1303 .mode(IconMode::FullColor)
1304 .color(Color::from_hex("#FF0000")),
1305 );
1306 tree.layout(SizeProposal::exact(24.0, 24.0));
1307 let frame = tree.render();
1308
1309 assert_eq!(frame.paths.len(), 2, "one path per authored shape");
1310 assert_eq!(frame.paths[0].color, Color::from_hex("#5865F2").to_array());
1311 assert_eq!(frame.paths[1].color, Color::from_hex("#FFFFFF").to_array());
1312 }
1313
1314 #[test]
1318 fn tintable_mode_still_merges_a_colored_svg_into_one_tinted_path() {
1319 let svg = r##"<svg viewBox="0 0 24 24">
1320 <rect width="24" height="24" fill="#5865F2"/>
1321 <circle cx="12" cy="12" r="6" fill="#FFFFFF"/>
1322 </svg>"##;
1323 let mut tree = WidgetTree::new();
1324 tree.add(
1325 IconWidget::from_svg(svg)
1326 .icon_size(24.0)
1327 .color(Color::from_hex("#FF0000")),
1328 );
1329 tree.layout(SizeProposal::exact(24.0, 24.0));
1330 let frame = tree.render();
1331
1332 assert_eq!(frame.paths.len(), 1, "both shapes merge into one fill");
1333 assert_eq!(
1334 frame.paths[0].color,
1335 Color::from_hex("#FF0000").to_array(),
1336 "tintable ignores the artwork's colors and takes the widget's"
1337 );
1338 }
1339
1340 #[test]
1343 fn current_color_follows_the_widget_inside_full_color_artwork() {
1344 let svg = r##"<svg viewBox="0 0 24 24">
1345 <rect width="24" height="24" fill="#5865F2"/>
1346 <rect width="8" height="8" fill="currentColor"/>
1347 </svg>"##;
1348 let mut tree = WidgetTree::new();
1349 let accent = Color::from_hex("#00FF00");
1350 tree.add(
1351 IconWidget::from_svg(svg)
1352 .icon_size(24.0)
1353 .mode(IconMode::FullColor)
1354 .color(accent),
1355 );
1356 tree.layout(SizeProposal::exact(24.0, 24.0));
1357 let frame = tree.render();
1358 assert_eq!(frame.paths[0].color, Color::from_hex("#5865F2").to_array());
1359 assert_eq!(frame.paths[1].color, accent.to_array());
1360 }
1361
1362 #[test]
1365 fn a_gradient_fill_reaches_the_renderer_as_a_gradient() {
1366 let svg = r##"<svg viewBox="0 0 24 24">
1367 <linearGradient id="g">
1368 <stop offset="0" stop-color="#FF0000"/>
1369 <stop offset="1" stop-color="#0000FF"/>
1370 </linearGradient>
1371 <rect width="24" height="24" fill="url(#g)"/>
1372 </svg>"##;
1373 let mut tree = WidgetTree::new();
1374 tree.add(
1375 IconWidget::from_svg(svg)
1376 .icon_size(24.0)
1377 .mode(IconMode::FullColor)
1378 .color(Color::BLACK),
1379 );
1380 tree.layout(SizeProposal::exact(24.0, 24.0));
1381 let frame = tree.render();
1382 match &frame.paths[0].paint_data {
1383 teksilo_canvas::PaintData::LinearGradient { start, end, stops } => {
1384 assert_eq!(stops.len(), 2);
1385 assert_eq!(stops[0].color.to_array(), [1.0, 0.0, 0.0, 1.0]);
1386 assert!(start[0].abs() < 0.01);
1388 assert!((end[0] - 24.0).abs() < 0.01, "end {end:?}");
1389 }
1390 other => panic!("expected a linear gradient paint, got {other:?}"),
1391 }
1392 }
1393
1394 #[test]
1398 fn a_gradient_stroke_is_rebased_onto_the_expanded_stroke_bounds() {
1399 let svg = r##"<svg viewBox="0 0 24 24">
1400 <linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="24" y2="0">
1401 <stop offset="0" stop-color="#FF0000"/>
1402 <stop offset="1" stop-color="#0000FF"/>
1403 </linearGradient>
1404 <rect x="2" y="2" width="20" height="20" fill="none" stroke="url(#g)" stroke-width="4"/>
1405 </svg>"##;
1406 let mut tree = WidgetTree::new();
1407 tree.add(
1408 IconWidget::from_svg(svg)
1409 .icon_size(24.0)
1410 .mode(IconMode::FullColor)
1411 .color(Color::BLACK),
1412 );
1413 tree.layout(SizeProposal::exact(24.0, 24.0));
1414 let frame = tree.render();
1415
1416 let entry = &frame.paths[0];
1417 assert!(entry.stroke_style.width > 0.0, "must be a stroke");
1418 match &entry.paint_data {
1419 teksilo_canvas::PaintData::LinearGradient { start, end, .. } => {
1420 assert!((start[0] - 2.0).abs() < 0.05, "start {start:?}");
1426 assert!((end[0] - 26.0).abs() < 0.05, "end {end:?}");
1427 }
1428 other => panic!("expected a linear gradient stroke, got {other:?}"),
1429 }
1430 }
1431
1432 #[test]
1436 fn the_widget_alpha_dims_full_color_artwork() {
1437 let svg =
1438 r##"<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="#FF0000"/></svg>"##;
1439 let mut tree = WidgetTree::new();
1440 tree.add(
1441 IconWidget::from_svg(svg)
1442 .icon_size(24.0)
1443 .mode(IconMode::FullColor)
1444 .color(Color::new(0.0, 0.0, 0.0, 0.5)),
1445 );
1446 tree.layout(SizeProposal::exact(24.0, 24.0));
1447 let frame = tree.render();
1448 let c = frame.paths[0].color;
1449 assert_eq!([c[0], c[1], c[2]], [1.0, 0.0, 0.0], "the red must survive");
1450 assert!((c[3] - 0.5).abs() < 1e-5, "…at half alpha, got {}", c[3]);
1451 }
1452}