1use std::fmt;
4
5#[allow(unused_imports)]
6use std::borrow::Cow;
7
8#[allow(unused_imports)]
9use crate::model::icons::{icon_name, system_icon_set, system_icon_theme};
10use crate::model::{AnimatedIcon, IconData, IconProvider, IconRole, IconSet};
11#[allow(unused_imports)]
12use crate::model::{bundled_icon_by_name, bundled_icon_svg};
13
14#[derive(Debug)]
32#[non_exhaustive]
33pub enum IconId<'a> {
34 Role(IconRole),
36 Name(&'a str),
38 Custom(&'a dyn IconProvider),
40}
41
42impl From<IconRole> for IconId<'_> {
43 fn from(role: IconRole) -> Self {
44 IconId::Role(role)
45 }
46}
47
48impl<'a> From<&'a str> for IconId<'a> {
49 fn from(name: &'a str) -> Self {
50 IconId::Name(name)
51 }
52}
53
54impl<'a> From<&'a dyn IconProvider> for IconId<'a> {
55 fn from(provider: &'a dyn IconProvider) -> Self {
56 IconId::Custom(provider)
57 }
58}
59
60impl<'a, T: IconProvider> From<&'a T> for IconId<'a> {
63 fn from(provider: &'a T) -> Self {
64 IconId::Custom(provider)
65 }
66}
67
68#[derive(Debug)]
120#[must_use]
121pub struct FreedesktopLoader<'a> {
122 #[allow(dead_code)]
124 id: IconId<'a>,
125 #[allow(dead_code)]
126 size: u16,
127 #[allow(dead_code)]
128 fg_color: Option<[u8; 3]>,
129 #[allow(dead_code)]
130 theme: Option<&'a str>,
131}
132
133impl<'a> FreedesktopLoader<'a> {
134 pub fn new(id: impl Into<IconId<'a>>) -> Self {
136 Self {
137 id: id.into(),
138 size: 24,
139 fg_color: None,
140 theme: None,
141 }
142 }
143
144 pub fn size(mut self, size: u16) -> Self {
146 self.size = size;
147 self
148 }
149
150 pub fn color(mut self, rgb: [u8; 3]) -> Self {
152 self.fg_color = Some(rgb);
153 self
154 }
155
156 pub fn color_opt(mut self, rgb: Option<[u8; 3]>) -> Self {
158 self.fg_color = rgb;
159 self
160 }
161
162 pub fn theme(mut self, theme: &'a str) -> Self {
166 self.theme = Some(theme);
167 self
168 }
169
170 #[must_use]
185 pub fn load(self) -> Option<IconData> {
186 self.load_with(system_icon_theme)
187 }
188
189 #[allow(unused_variables)]
192 fn load_with(self, detect: impl FnOnce() -> crate::Result<String>) -> Option<IconData> {
193 #[cfg(all(target_os = "linux", feature = "system-icons"))]
194 {
195 let detected;
196 let theme: Option<&str> = match self.theme {
197 Some(t) => Some(t),
198 None => {
199 detected = detect().ok();
200 detected.as_deref()
201 }
202 };
203 match self.id {
204 IconId::Role(role) => {
205 let name = icon_name(role, IconSet::Freedesktop)?;
206 crate::freedesktop::load_freedesktop_icon_by_name(
207 name,
208 theme?,
209 self.size,
210 self.fg_color,
211 )
212 }
213 IconId::Name(name) => crate::freedesktop::load_freedesktop_icon_by_name(
214 name,
215 theme?,
216 self.size,
217 self.fg_color,
218 ),
219 IconId::Custom(provider) => {
220 if let Some(theme) = theme
221 && let Some(name) = provider.icon_name(IconSet::Freedesktop)
222 && let Some(data) = crate::freedesktop::load_freedesktop_icon_by_name(
223 name,
224 theme,
225 self.size,
226 self.fg_color,
227 )
228 {
229 return Some(data);
230 }
231 provider.icon_svg(IconSet::Freedesktop).map(IconData::Svg)
232 }
233 }
234 }
235 #[cfg(not(all(target_os = "linux", feature = "system-icons")))]
236 {
237 None
238 }
239 }
240
241 #[must_use]
253 #[allow(unused_variables)]
254 pub fn load_indicator(theme: Option<&str>) -> Option<AnimatedIcon> {
255 #[cfg(all(target_os = "linux", feature = "system-icons"))]
256 {
257 crate::freedesktop::load_freedesktop_spinner(theme)
258 }
259 #[cfg(not(all(target_os = "linux", feature = "system-icons")))]
260 {
261 None
262 }
263 }
264}
265
266#[derive(Debug)]
271#[must_use]
272pub struct SfSymbolsLoader<'a> {
273 #[allow(dead_code)] id: IconId<'a>,
275 #[allow(dead_code)] fg_color: Option<[u8; 3]>,
277}
278
279impl<'a> SfSymbolsLoader<'a> {
280 pub fn new(id: impl Into<IconId<'a>>) -> Self {
283 Self {
284 id: id.into(),
285 fg_color: None,
286 }
287 }
288
289 pub fn color(mut self, rgb: [u8; 3]) -> Self {
292 self.fg_color = Some(rgb);
293 self
294 }
295
296 pub fn color_opt(mut self, rgb: Option<[u8; 3]>) -> Self {
298 self.fg_color = rgb;
299 self
300 }
301
302 #[must_use]
306 #[allow(unused_variables)]
307 pub fn load(self) -> Option<IconData> {
308 #[cfg(all(target_os = "macos", feature = "system-icons"))]
309 {
310 match self.id {
311 IconId::Role(role) => {
312 let name = icon_name(role, IconSet::SfSymbols)?;
313 crate::sficons::load_sf_icon_by_name(name, self.fg_color)
314 }
315 IconId::Name(n) => crate::sficons::load_sf_icon_by_name(n, self.fg_color),
316 IconId::Custom(p) => {
317 if let Some(n) = p.icon_name(IconSet::SfSymbols)
318 && let Some(data) = crate::sficons::load_sf_icon_by_name(n, self.fg_color)
319 {
320 return Some(data);
321 }
322 p.icon_svg(IconSet::SfSymbols).map(IconData::Svg)
323 }
324 }
325 }
326 #[cfg(not(all(target_os = "macos", feature = "system-icons")))]
327 {
328 None
329 }
330 }
331 }
333
334#[derive(Debug)]
341#[must_use]
342pub struct SegoeIconsLoader<'a> {
343 #[allow(dead_code)] id: IconId<'a>,
345 #[allow(dead_code)] fg_color: Option<[u8; 3]>,
347}
348
349impl<'a> SegoeIconsLoader<'a> {
350 pub fn new(id: impl Into<IconId<'a>>) -> Self {
353 Self {
354 id: id.into(),
355 fg_color: None,
356 }
357 }
358
359 pub fn color(mut self, rgb: [u8; 3]) -> Self {
362 self.fg_color = Some(rgb);
363 self
364 }
365
366 pub fn color_opt(mut self, rgb: Option<[u8; 3]>) -> Self {
368 self.fg_color = rgb;
369 self
370 }
371
372 #[must_use]
376 #[allow(unused_variables)]
377 pub fn load(self) -> Option<IconData> {
378 #[cfg(all(target_os = "windows", feature = "system-icons"))]
379 {
380 match self.id {
381 IconId::Role(role) => {
382 let name = icon_name(role, IconSet::SegoeIcons)?;
383 crate::winicons::load_windows_icon_by_name(name, self.fg_color)
384 }
385 IconId::Name(n) => crate::winicons::load_windows_icon_by_name(n, self.fg_color),
386 IconId::Custom(p) => {
387 if let Some(n) = p.icon_name(IconSet::SegoeIcons)
388 && let Some(data) =
389 crate::winicons::load_windows_icon_by_name(n, self.fg_color)
390 {
391 return Some(data);
392 }
393 p.icon_svg(IconSet::SegoeIcons).map(IconData::Svg)
394 }
395 }
396 }
397 #[cfg(not(all(target_os = "windows", feature = "system-icons")))]
398 {
399 None
400 }
401 }
402 }
404
405#[derive(Debug)]
409#[must_use]
410pub struct MaterialLoader<'a> {
411 #[allow(dead_code)] id: IconId<'a>,
413}
414
415impl<'a> MaterialLoader<'a> {
416 pub fn new(id: impl Into<IconId<'a>>) -> Self {
418 Self { id: id.into() }
419 }
420
421 #[must_use]
425 #[allow(unused_variables)]
426 pub fn load(self) -> Option<IconData> {
427 #[cfg(feature = "material-icons")]
428 {
429 match self.id {
430 IconId::Role(role) => bundled_icon_svg(role, IconSet::Material)
431 .map(|b| IconData::Svg(Cow::Borrowed(b))),
432 IconId::Name(n) => bundled_icon_by_name(n, IconSet::Material)
433 .map(|b| IconData::Svg(Cow::Borrowed(b))),
434 IconId::Custom(p) => {
435 if let Some(n) = p.icon_name(IconSet::Material)
436 && let Some(b) = bundled_icon_by_name(n, IconSet::Material)
437 {
438 return Some(IconData::Svg(Cow::Borrowed(b)));
439 }
440 p.icon_svg(IconSet::Material).map(IconData::Svg)
441 }
442 }
443 }
444 #[cfg(not(feature = "material-icons"))]
445 {
446 None
447 }
448 }
449
450 #[must_use]
454 pub fn load_indicator() -> Option<AnimatedIcon> {
455 #[cfg(feature = "material-icons")]
456 {
457 Some(crate::spinners::material_spinner())
458 }
459 #[cfg(not(feature = "material-icons"))]
460 {
461 None
462 }
463 }
464}
465
466#[derive(Debug)]
468#[must_use]
469pub struct LucideLoader<'a> {
470 #[allow(dead_code)] id: IconId<'a>,
472}
473
474impl<'a> LucideLoader<'a> {
475 pub fn new(id: impl Into<IconId<'a>>) -> Self {
477 Self { id: id.into() }
478 }
479
480 #[must_use]
484 #[allow(unused_variables)]
485 pub fn load(self) -> Option<IconData> {
486 #[cfg(feature = "lucide-icons")]
487 {
488 match self.id {
489 IconId::Role(role) => {
490 bundled_icon_svg(role, IconSet::Lucide).map(|b| IconData::Svg(Cow::Borrowed(b)))
491 }
492 IconId::Name(n) => bundled_icon_by_name(n, IconSet::Lucide)
493 .map(|b| IconData::Svg(Cow::Borrowed(b))),
494 IconId::Custom(p) => {
495 if let Some(n) = p.icon_name(IconSet::Lucide)
496 && let Some(b) = bundled_icon_by_name(n, IconSet::Lucide)
497 {
498 return Some(IconData::Svg(Cow::Borrowed(b)));
499 }
500 p.icon_svg(IconSet::Lucide).map(IconData::Svg)
501 }
502 }
503 }
504 #[cfg(not(feature = "lucide-icons"))]
505 {
506 None
507 }
508 }
509
510 #[must_use]
514 pub fn load_indicator() -> Option<AnimatedIcon> {
515 #[cfg(feature = "lucide-icons")]
516 {
517 Some(crate::spinners::lucide_spinner())
518 }
519 #[cfg(not(feature = "lucide-icons"))]
520 {
521 None
522 }
523 }
524}
525
526#[must_use]
537pub fn load_icon<'a>(id: impl Into<IconId<'a>>, set: IconSet) -> Option<IconData> {
538 let id = id.into();
539 match set {
540 IconSet::Freedesktop => FreedesktopLoader::new(id).load(),
541 IconSet::SfSymbols => SfSymbolsLoader::new(id).load(),
542 IconSet::SegoeIcons => SegoeIconsLoader::new(id).load(),
543 IconSet::Material => MaterialLoader::new(id).load(),
544 IconSet::Lucide => LucideLoader::new(id).load(),
545 }
546}
547
548#[must_use]
556pub fn load_icon_indicator(set: IconSet) -> Option<AnimatedIcon> {
557 match set {
558 IconSet::Freedesktop => FreedesktopLoader::load_indicator(None),
559 IconSet::Material => MaterialLoader::load_indicator(),
560 IconSet::Lucide => LucideLoader::load_indicator(),
561 IconSet::SfSymbols | IconSet::SegoeIcons => None,
562 }
563}
564
565#[must_use]
587pub fn colorize_monochrome_svg(svg: &[u8], color: crate::color::Rgba) -> Vec<u8> {
588 let hex = format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b);
589
590 let Ok(svg_str) = std::str::from_utf8(svg) else {
591 return svg.to_vec();
592 };
593
594 let replaced = if svg_str.contains("currentColor") {
596 svg_str.replace("currentColor", &hex)
597 } else {
598 svg_str.to_owned()
599 };
600
601 let fill_hex = format!("fill=\"{hex}\"");
603 let replaced = replaced
604 .replace("fill=\"black\"", &fill_hex)
605 .replace("fill=\"#000000\"", &fill_hex)
606 .replace("fill=\"#000\"", &fill_hex);
607 let stroke_hex = format!("stroke=\"{hex}\"");
608 let replaced = replaced
609 .replace("stroke=\"black\"", &stroke_hex)
610 .replace("stroke=\"#000000\"", &stroke_hex)
611 .replace("stroke=\"#000\"", &stroke_hex);
612
613 if replaced != svg_str {
614 return replaced.into_bytes();
615 }
616
617 if let Some(pos) = svg_str.find("<svg")
620 && let Some(tail) = svg_str.get(pos..)
621 && let Some(close) = tail.find('>')
622 {
623 let tag_end = pos.saturating_add(close);
624 if let Some(tag) = svg_str.get(pos..tag_end)
625 && !tag.contains("fill=")
626 {
627 let is_self_closing = tag_end > 0
629 && svg_str
630 .as_bytes()
631 .get(tag_end.saturating_sub(1))
632 .is_some_and(|&b| b == b'/');
633 let inject_pos = if is_self_closing {
634 tag_end.saturating_sub(1)
635 } else {
636 tag_end
637 };
638 if let Some(before) = svg_str.get(..inject_pos)
639 && let Some(after) = svg_str.get(inject_pos..)
640 {
641 let mut result = String::with_capacity(svg_str.len().saturating_add(20));
642 result.push_str(before);
643 result.push_str(&format!(" fill=\"{hex}\""));
644 result.push_str(after);
645 return result.into_bytes();
646 }
647 }
648 }
649
650 svg.to_vec()
652}
653
654#[cfg(target_os = "linux")]
666pub(crate) fn icon_base_dirs() -> &'static [std::path::PathBuf] {
667 static DIRS: std::sync::OnceLock<Vec<std::path::PathBuf>> = std::sync::OnceLock::new();
668 DIRS.get_or_init(xdg_icon_base_dirs)
669}
670
671#[cfg(target_os = "linux")]
678fn xdg_icon_base_dirs() -> Vec<std::path::PathBuf> {
679 use std::path::PathBuf;
680
681 let Some(home) = std::env::home_dir() else {
682 return Vec::new();
683 };
684 let data_dirs: Vec<PathBuf> = std::env::var_os("XDG_DATA_DIRS")
685 .map(|dirs| {
686 std::env::split_paths(&dirs)
687 .filter(|dir| dir.is_absolute())
688 .collect::<Vec<_>>()
689 })
690 .filter(|dirs| !dirs.is_empty())
691 .unwrap_or_else(|| {
692 vec![
693 PathBuf::from("/usr/local/share"),
694 PathBuf::from("/usr/share"),
695 ]
696 });
697 let data_home = std::env::var_os("XDG_DATA_HOME")
698 .map(PathBuf::from)
699 .filter(|dir| dir.is_absolute())
700 .unwrap_or_else(|| home.join(".local/share"));
701 let mut dirs: Vec<PathBuf> = data_dirs.iter().map(|dir| dir.join("icons")).collect();
702 dirs.push(data_home.join("icons"));
703 dirs.push(home.join(".icons"));
704 dirs.retain(|dir| dir.exists());
705 dirs
706}
707
708#[cfg(target_os = "linux")]
711fn is_theme_name(name: &str) -> bool {
712 use std::path::{Component, Path};
713
714 let mut components = Path::new(name).components();
715 matches!(
716 (components.next(), components.next()),
717 (Some(Component::Normal(_)), None)
718 )
719}
720
721#[cfg(target_os = "linux")]
724pub(crate) fn has_theme_index(theme: &str, bases: &[std::path::PathBuf]) -> bool {
725 is_theme_name(theme)
726 && bases
727 .iter()
728 .any(|base| base.join(theme).join("index.theme").exists())
729}
730
731#[must_use]
739pub fn is_freedesktop_theme_available(theme: &str) -> bool {
740 #[cfg(target_os = "linux")]
741 {
742 has_theme_index(theme, icon_base_dirs())
743 }
744 #[cfg(not(target_os = "linux"))]
745 {
746 let _ = theme;
747 false
748 }
749}
750
751#[derive(Debug, Clone, PartialEq, Eq)]
762pub enum IconSetChoice {
763 Default(String),
775
776 System,
785
786 Freedesktop(String),
792
793 Material,
795
796 Lucide,
798}
799
800impl fmt::Display for IconSetChoice {
801 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
802 match self {
803 Self::Default(name) => write!(f, "default ({name})"),
804 Self::System => f.write_str(&system_choice_label(&system_icon_theme())),
805 Self::Freedesktop(name) => write!(f, "{name}"),
806 Self::Material => write!(f, "Material (bundled)"),
807 Self::Lucide => write!(f, "Lucide (bundled)"),
808 }
809 }
810}
811
812fn system_choice_label(detected: &crate::Result<String>) -> String {
815 match detected {
816 Ok(name) => format!("system ({name})"),
817 Err(e) => format!("system (unavailable: {e})"),
818 }
819}
820
821impl IconSetChoice {
822 #[must_use]
830 pub fn effective_icon_set(&self, theme_icon_set: IconSet) -> IconSet {
831 match self {
832 Self::Default(_) => theme_icon_set,
833 Self::System => system_icon_set(),
834 Self::Freedesktop(_) => IconSet::Freedesktop,
835 Self::Material => IconSet::Material,
836 Self::Lucide => IconSet::Lucide,
837 }
838 }
839
840 #[must_use]
852 pub fn freedesktop_theme(&self) -> Option<&str> {
853 match self {
854 Self::Default(name) | Self::Freedesktop(name) => Some(name),
855 Self::System | Self::Material | Self::Lucide => None,
856 }
857 }
858
859 #[must_use]
864 pub fn follows_preset(&self) -> bool {
865 matches!(self, Self::Default(_))
866 }
867}
868
869#[must_use]
880pub fn default_icon_choice(icon_set: IconSet, icon_theme: Option<&str>) -> IconSetChoice {
881 let Some(theme) = icon_theme else {
882 return IconSetChoice::System;
883 };
884 let available = match icon_set {
889 IconSet::Material | IconSet::Lucide => true,
890 IconSet::Freedesktop => is_freedesktop_theme_available(theme),
891 IconSet::SfSymbols | IconSet::SegoeIcons => true,
892 };
893 if available {
894 IconSetChoice::Default(theme.to_string())
895 } else {
896 IconSetChoice::System
897 }
898}
899
900#[must_use]
919pub fn list_freedesktop_themes() -> Vec<String> {
920 #[cfg(target_os = "linux")]
921 {
922 use std::collections::BTreeSet;
923 use std::io::BufRead;
924
925 let mut themes = BTreeSet::new();
926
927 for icon_dir in icon_base_dirs() {
928 let entries = match std::fs::read_dir(icon_dir) {
929 Ok(e) => e,
930 Err(_) => continue,
931 };
932 for entry in entries.filter_map(|e| e.ok()) {
933 let path = entry.path();
934 if !path.is_dir() {
935 continue;
936 }
937 let name = match entry.file_name().into_string() {
938 Ok(n) => n,
939 Err(_) => continue,
940 };
941
942 if name == "hicolor" || name == "default" {
945 continue;
946 }
947
948 let index_path = path.join("index.theme");
949 let file = match std::fs::File::open(&index_path) {
950 Ok(f) => f,
951 Err(_) => continue,
952 };
953
954 let reader = std::io::BufReader::new(file);
957 let has_directories = reader
958 .lines()
959 .map_while(Result::ok)
960 .any(|line| line.starts_with("Directories="));
961
962 if has_directories {
963 themes.insert(name);
964 }
965 }
966 }
967
968 themes.into_iter().collect()
969 }
970 #[cfg(not(target_os = "linux"))]
971 {
972 Vec::new()
973 }
974}
975
976#[cfg(test)]
981#[allow(clippy::unwrap_used, clippy::expect_used)]
982mod load_icon_tests {
983 use super::*;
984
985 #[cfg(all(target_os = "linux", feature = "system-icons"))]
986 fn undetected() -> crate::Result<String> {
987 Err(crate::Error::PlatformUnsupported {
988 platform: "the test's failing detection",
989 })
990 }
991
992 #[test]
995 #[cfg(all(target_os = "linux", feature = "system-icons"))]
996 fn freedesktop_loader_without_a_detected_theme_loads_nothing() {
997 let custom: &dyn IconProvider = &IconRole::ActionCopy;
998 for id in [
999 IconId::Role(IconRole::ActionCopy),
1000 IconId::Name("edit-copy"),
1001 IconId::Custom(custom),
1002 ] {
1003 assert!(
1004 FreedesktopLoader::new(id).load_with(undetected).is_none(),
1005 "an icon loaded though no icon theme was detected"
1006 );
1007 }
1008 }
1009
1010 #[test]
1014 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1015 fn freedesktop_loader_without_a_detected_theme_gives_a_providers_own_svg() {
1016 #[derive(Debug)]
1017 struct AppIcon;
1018 impl IconProvider for AppIcon {
1019 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1020 Some("edit-copy")
1021 }
1022 fn icon_svg(&self, set: IconSet) -> Option<Cow<'static, [u8]>> {
1023 (set == IconSet::Freedesktop).then_some(Cow::Borrowed(b"<svg>app</svg>"))
1024 }
1025 }
1026
1027 let provider: &dyn IconProvider = &AppIcon;
1028 assert_eq!(
1029 FreedesktopLoader::new(provider).load_with(undetected),
1030 Some(IconData::Svg(Cow::Borrowed(b"<svg>app</svg>"))),
1031 "the provider's own SVG did not load without a detected icon theme"
1032 );
1033 }
1034
1035 #[test]
1038 #[cfg(feature = "material-icons")]
1039 fn a_provider_is_passed_to_a_loader_by_reference() {
1040 #[derive(Debug)]
1041 struct AppIcon;
1042 impl IconProvider for AppIcon {
1043 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1044 None
1045 }
1046 fn icon_svg(&self, set: IconSet) -> Option<Cow<'static, [u8]>> {
1047 (set == IconSet::Material).then_some(Cow::Borrowed(b"<svg>app</svg>"))
1048 }
1049 }
1050
1051 assert_eq!(
1052 MaterialLoader::new(&AppIcon).load(),
1053 Some(IconData::Svg(Cow::Borrowed(b"<svg>app</svg>"))),
1054 "a provider passed by reference did not load its own SVG"
1055 );
1056 }
1057
1058 #[test]
1060 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1061 fn freedesktop_loader_with_a_theme_does_not_detect() {
1062 let runs = std::cell::Cell::new(0);
1063 let _ = FreedesktopLoader::new("edit-copy")
1064 .theme("Adwaita")
1065 .load_with(|| {
1066 runs.set(runs.get() + 1);
1067 undetected()
1068 });
1069 assert_eq!(runs.get(), 0, "an explicit theme ran the detection");
1070 }
1071
1072 #[test]
1073 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1074 fn freedesktop_loader_theme_override_honored_for_name_lookup() {
1075 let result = FreedesktopLoader::new("format-text-rich")
1082 .theme("Adwaita")
1083 .size(24)
1084 .load();
1085 assert!(
1086 result.is_some(),
1087 "theme('Adwaita') must resolve 'format-text-rich' in Adwaita, regardless of system theme"
1088 );
1089 }
1090
1091 #[test]
1092 #[cfg(feature = "material-icons")]
1093 fn load_icon_material_returns_svg() {
1094 let result = MaterialLoader::new(IconRole::ActionCopy).load();
1095 assert!(result.is_some(), "material ActionCopy should return Some");
1096 match result.unwrap() {
1097 IconData::Svg(ref cow) => {
1098 let s = String::from_utf8_lossy(cow);
1099 assert!(s.contains("<svg"), "should contain SVG data");
1100 }
1101 _ => panic!("expected IconData::Svg for bundled material icon"),
1102 }
1103 }
1104
1105 #[test]
1106 #[cfg(feature = "lucide-icons")]
1107 fn load_icon_lucide_returns_svg() {
1108 let result = LucideLoader::new(IconRole::ActionCopy).load();
1109 assert!(result.is_some(), "lucide ActionCopy should return Some");
1110 match result.unwrap() {
1111 IconData::Svg(ref cow) => {
1112 let s = String::from_utf8_lossy(cow);
1113 assert!(s.contains("<svg"), "should contain SVG data");
1114 }
1115 _ => panic!("expected IconData::Svg for bundled lucide icon"),
1116 }
1117 }
1118
1119 #[test]
1120 #[cfg(feature = "material-icons")]
1121 fn load_icon_unknown_theme_no_cross_set_fallback() {
1122 let result = FreedesktopLoader::new(IconRole::ActionCopy).load();
1126 let _ = result;
1130 }
1131
1132 #[test]
1133 #[cfg(feature = "material-icons")]
1134 fn load_icon_all_roles_material() {
1135 let mut some_count = 0;
1137 for role in IconRole::ALL {
1138 if MaterialLoader::new(role).load().is_some() {
1139 some_count += 1;
1140 }
1141 }
1142 assert_eq!(
1144 some_count, 42,
1145 "Material should cover all 42 roles via bundled SVGs"
1146 );
1147 }
1148
1149 #[test]
1150 #[cfg(feature = "lucide-icons")]
1151 fn load_icon_all_roles_lucide() {
1152 let mut some_count = 0;
1153 for role in IconRole::ALL {
1154 if LucideLoader::new(role).load().is_some() {
1155 some_count += 1;
1156 }
1157 }
1158 assert_eq!(
1160 some_count, 42,
1161 "Lucide should cover all 42 roles via bundled SVGs"
1162 );
1163 }
1164
1165 #[test]
1166 fn sf_symbols_loader_stores_the_colour() {
1167 let loader = SfSymbolsLoader::new(IconRole::ActionCopy).color([10, 20, 30]);
1168 assert_eq!(loader.fg_color, Some([10, 20, 30]));
1169 let loader = loader.color_opt(None);
1170 assert_eq!(loader.fg_color, None);
1171 let loader = loader.color_opt(Some([40, 50, 60]));
1172 assert_eq!(loader.fg_color, Some([40, 50, 60]));
1173 assert_eq!(SfSymbolsLoader::new(IconRole::ActionCopy).fg_color, None);
1174 }
1175
1176 #[test]
1177 fn segoe_icons_loader_stores_the_colour() {
1178 let loader = SegoeIconsLoader::new(IconRole::ActionCopy).color([10, 20, 30]);
1179 assert_eq!(loader.fg_color, Some([10, 20, 30]));
1180 let loader = loader.color_opt(None);
1181 assert_eq!(loader.fg_color, None);
1182 let loader = loader.color_opt(Some([40, 50, 60]));
1183 assert_eq!(loader.fg_color, Some([40, 50, 60]));
1184 assert_eq!(SegoeIconsLoader::new(IconRole::ActionCopy).fg_color, None);
1185 }
1186
1187 #[test]
1188 fn load_icon_unrecognized_set_no_features() {
1189 let _result = SfSymbolsLoader::new(IconRole::ActionCopy).load();
1191 }
1193
1194 #[test]
1195 #[cfg(feature = "material-icons")]
1196 fn bundled_icon_load_produces_cow_borrowed() {
1197 let result = MaterialLoader::new(IconRole::ActionCopy).load();
1198 assert!(
1199 matches!(result, Some(IconData::Svg(Cow::Borrowed(_)))),
1200 "bundled icon should produce Some(IconData::Svg(Cow::Borrowed(_)))"
1201 );
1202 }
1203}
1204
1205#[cfg(test)]
1206#[allow(clippy::unwrap_used, clippy::expect_used)]
1207mod load_system_icon_by_name_tests {
1208 use super::*;
1209
1210 #[test]
1211 #[cfg(feature = "material-icons")]
1212 fn system_icon_by_name_material() {
1213 let result = MaterialLoader::new("content_copy").load();
1214 assert!(
1215 result.is_some(),
1216 "content_copy should be found in Material set"
1217 );
1218 assert!(matches!(result.unwrap(), IconData::Svg(_)));
1219 }
1220
1221 #[test]
1222 #[cfg(feature = "lucide-icons")]
1223 fn system_icon_by_name_lucide() {
1224 let result = LucideLoader::new("copy").load();
1225 assert!(result.is_some(), "copy should be found in Lucide set");
1226 assert!(matches!(result.unwrap(), IconData::Svg(_)));
1227 }
1228
1229 #[test]
1230 #[cfg(feature = "material-icons")]
1231 fn system_icon_by_name_unknown_returns_none() {
1232 let result = MaterialLoader::new("nonexistent_xyz").load();
1233 assert!(result.is_none(), "nonexistent name should return None");
1234 }
1235
1236 #[test]
1237 fn system_icon_by_name_sf_on_linux_returns_none() {
1238 #[cfg(not(target_os = "macos"))]
1240 {
1241 let result = SfSymbolsLoader::new("doc.on.doc").load();
1242 assert!(
1243 result.is_none(),
1244 "SF Symbols should return None on non-macOS"
1245 );
1246 }
1247 }
1248}
1249
1250#[cfg(test)]
1251#[allow(clippy::unwrap_used, clippy::expect_used)]
1252mod load_custom_icon_tests {
1253 use super::*;
1254
1255 #[test]
1256 #[cfg(feature = "material-icons")]
1257 fn custom_icon_with_icon_role_material() {
1258 let provider: &dyn IconProvider = &IconRole::ActionCopy;
1259 let result = MaterialLoader::new(provider).load();
1260 assert!(
1261 result.is_some(),
1262 "IconRole::ActionCopy should load via material"
1263 );
1264 }
1265
1266 #[test]
1267 #[cfg(feature = "lucide-icons")]
1268 fn custom_icon_with_icon_role_lucide() {
1269 let provider: &dyn IconProvider = &IconRole::ActionCopy;
1270 let result = LucideLoader::new(provider).load();
1271 assert!(
1272 result.is_some(),
1273 "IconRole::ActionCopy should load via lucide"
1274 );
1275 }
1276
1277 #[test]
1278 fn custom_icon_no_cross_set_fallback() {
1279 #[derive(Debug)]
1281 struct NullProvider;
1282 impl IconProvider for NullProvider {
1283 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1284 None
1285 }
1286 fn icon_svg(&self, _set: IconSet) -> Option<Cow<'static, [u8]>> {
1287 None
1288 }
1289 }
1290
1291 let provider: &dyn IconProvider = &NullProvider;
1292 let result = MaterialLoader::new(provider).load();
1293 assert!(
1294 result.is_none(),
1295 "NullProvider should return None (no cross-set fallback)"
1296 );
1297 }
1298
1299 #[test]
1300 fn custom_icon_unknown_set_uses_system() {
1301 #[derive(Debug)]
1303 struct NullProvider;
1304 impl IconProvider for NullProvider {
1305 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1306 None
1307 }
1308 fn icon_svg(&self, _set: IconSet) -> Option<Cow<'static, [u8]>> {
1309 None
1310 }
1311 }
1312
1313 let provider: &dyn IconProvider = &NullProvider;
1315 let _result = FreedesktopLoader::new(provider).load();
1316 }
1317
1318 #[test]
1319 #[cfg(feature = "material-icons")]
1320 fn custom_icon_via_dyn_dispatch() {
1321 let boxed: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1322 let provider: &dyn IconProvider = &*boxed;
1323 let result = MaterialLoader::new(provider).load();
1324 assert!(
1325 result.is_some(),
1326 "dyn dispatch through Box<dyn IconProvider> should work"
1327 );
1328 }
1329
1330 #[test]
1331 #[cfg(feature = "material-icons")]
1332 fn custom_icon_bundled_svg_fallback() {
1333 #[derive(Debug)]
1335 struct SvgOnlyProvider;
1336 impl IconProvider for SvgOnlyProvider {
1337 fn icon_name(&self, _set: IconSet) -> Option<&str> {
1338 None
1339 }
1340 fn icon_svg(&self, _set: IconSet) -> Option<Cow<'static, [u8]>> {
1341 Some(Cow::Borrowed(b"<svg>test</svg>"))
1342 }
1343 }
1344
1345 let provider: &dyn IconProvider = &SvgOnlyProvider;
1346 let result = MaterialLoader::new(provider).load();
1347 assert!(
1348 result.is_some(),
1349 "provider with icon_svg should return Some"
1350 );
1351 match result.unwrap() {
1352 IconData::Svg(ref cow) => {
1353 assert_eq!(cow.as_ref(), b"<svg>test</svg>");
1354 }
1355 _ => panic!("expected IconData::Svg"),
1356 }
1357 }
1358}
1359
1360#[cfg(test)]
1361#[allow(clippy::unwrap_used, clippy::expect_used)]
1362mod loading_indicator_tests {
1363 use super::*;
1364
1365 #[test]
1368 #[cfg(feature = "lucide-icons")]
1369 fn loading_indicator_lucide_returns_frames() {
1370 let anim = LucideLoader::load_indicator();
1371 assert!(anim.is_some(), "lucide should return Some");
1372 let anim = anim.unwrap();
1373 assert!(
1374 matches!(anim, AnimatedIcon::Frames(_)),
1375 "lucide should be pre-rotated Frames"
1376 );
1377 if let AnimatedIcon::Frames(data) = &anim {
1378 assert_eq!(data.frames().len(), 24);
1379 assert_eq!(data.frame_duration_ms().get(), 42);
1380 }
1381 }
1382
1383 #[test]
1386 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1387 fn loading_indicator_freedesktop_depends_on_theme() {
1388 let anim = FreedesktopLoader::load_indicator(None);
1389 if let Some(anim) = anim {
1391 match anim {
1392 AnimatedIcon::Frames(data) => {
1393 assert!(
1394 !data.frames().is_empty(),
1395 "Frames variant should have at least one frame"
1396 );
1397 }
1398 AnimatedIcon::Transform(_) => {
1399 }
1401 }
1402 }
1403 }
1404
1405 #[test]
1407 fn loading_indicator_freedesktop_does_not_panic() {
1408 let _result = FreedesktopLoader::load_indicator(None);
1409 }
1410
1411 #[test]
1414 #[cfg(feature = "lucide-icons")]
1415 fn lucide_spinner_is_frames() {
1416 let anim = crate::spinners::lucide_spinner();
1417 assert!(
1418 matches!(anim, AnimatedIcon::Frames(_)),
1419 "lucide should be pre-rotated Frames"
1420 );
1421 }
1422}
1423
1424#[cfg(test)]
1427#[allow(clippy::unwrap_used, clippy::expect_used)]
1428mod icon_loader_tests {
1429 use super::*;
1430
1431 #[test]
1432 #[cfg(feature = "material-icons")]
1433 fn icon_loader_basic_role() {
1434 let icon = MaterialLoader::new(IconRole::ActionCopy).load();
1435 assert!(icon.is_some());
1436 }
1437
1438 #[test]
1439 #[cfg(feature = "material-icons")]
1440 fn icon_loader_by_name() {
1441 let icon = MaterialLoader::new("content_copy").load();
1442 assert!(icon.is_some());
1443 }
1444
1445 #[test]
1446 #[cfg(feature = "material-icons")]
1447 fn icon_loader_custom_provider() {
1448 let provider: &dyn IconProvider = &IconRole::ActionCopy;
1449 let icon = MaterialLoader::new(provider).load();
1450 assert!(icon.is_some());
1451 }
1452
1453 #[test]
1454 #[cfg(all(target_os = "linux", feature = "system-icons"))]
1455 fn freedesktop_loader_builder_chain_compiles() {
1456 let _ = FreedesktopLoader::new(IconRole::ActionSave)
1460 .size(48)
1461 .color([0, 0, 0])
1462 .theme("Adwaita")
1463 .load();
1464 }
1465
1466 #[test]
1467 #[cfg(feature = "material-icons")]
1468 fn icon_loader_load_indicator() {
1469 let anim = MaterialLoader::load_indicator();
1470 assert!(anim.is_some());
1471 }
1472
1473 #[test]
1474 fn load_icon_free_fn_dispatches() {
1475 let _ = load_icon(IconRole::ActionCopy, IconSet::SfSymbols);
1478 }
1479
1480 #[test]
1481 fn load_icon_indicator_free_fn_dispatches() {
1482 assert!(load_icon_indicator(IconSet::SfSymbols).is_none());
1484 assert!(load_icon_indicator(IconSet::SegoeIcons).is_none());
1485 }
1486}
1487
1488#[cfg(all(test, feature = "svg-rasterize"))]
1489#[allow(clippy::unwrap_used, clippy::expect_used)]
1490mod spinner_rasterize_tests {
1491 use super::*;
1492
1493 #[test]
1494 #[cfg(feature = "lucide-icons")]
1495 fn lucide_spinner_icon_rasterizes() {
1496 let anim = crate::spinners::lucide_spinner();
1497 if let AnimatedIcon::Frames(data) = &anim {
1498 let first = data.frames().first();
1499 if let IconData::Svg(cow) = first {
1500 let result = crate::rasterize::rasterize_svg(cow, 24);
1501 assert!(result.is_ok(), "lucide loader should rasterize");
1502 if let Ok(IconData::Rgba { data, .. }) = &result {
1503 assert!(
1504 data.iter().any(|&b| b != 0),
1505 "lucide loader rasterized to empty image"
1506 );
1507 }
1508 } else {
1509 panic!("lucide spinner frame should be Svg");
1510 }
1511 } else {
1512 panic!("lucide spinner should be Frames");
1513 }
1514 }
1515}
1516
1517#[cfg(test)]
1522mod icon_set_choice_tests {
1523 use super::*;
1524
1525 #[test]
1526 fn system_choice_names_the_detected_theme() {
1527 assert_eq!(
1528 system_choice_label(&Ok("breeze-dark".to_string())),
1529 "system (breeze-dark)"
1530 );
1531 }
1532
1533 #[test]
1534 fn system_choice_shows_a_failed_detection_as_unavailable() {
1535 let label = system_choice_label(&Err(crate::Error::PlatformUnsupported {
1536 platform: "the test's failing detection",
1537 }));
1538 assert_eq!(
1539 label,
1540 "system (unavailable: platform not supported: the test's failing detection)"
1541 );
1542 }
1543
1544 #[test]
1545 fn test_icon_set_choice_display_default() {
1546 let choice = IconSetChoice::Default("Adwaita".to_string());
1547 assert_eq!(choice.to_string(), "default (Adwaita)");
1548 }
1549
1550 #[test]
1551 fn test_icon_set_choice_display_material() {
1552 assert_eq!(IconSetChoice::Material.to_string(), "Material (bundled)");
1553 }
1554
1555 #[test]
1556 fn test_icon_set_choice_display_lucide() {
1557 assert_eq!(IconSetChoice::Lucide.to_string(), "Lucide (bundled)");
1558 }
1559
1560 #[test]
1561 fn test_icon_set_choice_display_freedesktop() {
1562 let choice = IconSetChoice::Freedesktop("breeze".to_string());
1563 assert_eq!(choice.to_string(), "breeze");
1564 }
1565
1566 #[test]
1567 fn test_icon_set_choice_follows_preset() {
1568 assert!(IconSetChoice::Default("Adwaita".to_string()).follows_preset());
1569 assert!(!IconSetChoice::System.follows_preset());
1570 assert!(!IconSetChoice::Freedesktop("breeze".to_string()).follows_preset());
1571 assert!(!IconSetChoice::Material.follows_preset());
1572 assert!(!IconSetChoice::Lucide.follows_preset());
1573 }
1574
1575 #[test]
1576 fn test_icon_set_choice_effective_icon_set() {
1577 let choice = IconSetChoice::Default("Adwaita".to_string());
1579 assert_eq!(
1580 choice.effective_icon_set(IconSet::Freedesktop),
1581 IconSet::Freedesktop
1582 );
1583 assert_eq!(
1584 choice.effective_icon_set(IconSet::Material),
1585 IconSet::Material
1586 );
1587
1588 let sys = IconSetChoice::System.effective_icon_set(IconSet::Material);
1590 assert_eq!(sys, system_icon_set());
1591
1592 let choice = IconSetChoice::Freedesktop("breeze".to_string());
1594 assert_eq!(
1595 choice.effective_icon_set(IconSet::Material),
1596 IconSet::Freedesktop
1597 );
1598
1599 assert_eq!(
1601 IconSetChoice::Material.effective_icon_set(IconSet::Freedesktop),
1602 IconSet::Material
1603 );
1604 assert_eq!(
1605 IconSetChoice::Lucide.effective_icon_set(IconSet::Freedesktop),
1606 IconSet::Lucide
1607 );
1608 }
1609
1610 #[test]
1611 fn test_icon_set_choice_freedesktop_theme() {
1612 assert_eq!(
1613 IconSetChoice::Default("Adwaita".to_string()).freedesktop_theme(),
1614 Some("Adwaita")
1615 );
1616 assert_eq!(
1617 IconSetChoice::Freedesktop("breeze".to_string()).freedesktop_theme(),
1618 Some("breeze")
1619 );
1620 assert_eq!(IconSetChoice::System.freedesktop_theme(), None);
1621 assert_eq!(IconSetChoice::Material.freedesktop_theme(), None);
1622 assert_eq!(IconSetChoice::Lucide.freedesktop_theme(), None);
1623 }
1624
1625 #[test]
1626 fn test_default_icon_choice_none() {
1627 assert_eq!(
1629 default_icon_choice(IconSet::Freedesktop, None),
1630 IconSetChoice::System
1631 );
1632 assert_eq!(
1633 default_icon_choice(IconSet::Material, None),
1634 IconSetChoice::System
1635 );
1636 }
1637
1638 #[test]
1639 fn test_default_icon_choice_bundled() {
1640 assert_eq!(
1642 default_icon_choice(IconSet::Material, Some("any-theme")),
1643 IconSetChoice::Default("any-theme".to_string())
1644 );
1645 assert_eq!(
1646 default_icon_choice(IconSet::Lucide, Some("any-theme")),
1647 IconSetChoice::Default("any-theme".to_string())
1648 );
1649 }
1650
1651 #[test]
1652 fn test_list_freedesktop_themes_no_panic() {
1653 let themes = list_freedesktop_themes();
1655 #[cfg(not(target_os = "linux"))]
1658 assert!(themes.is_empty());
1659 let _ = themes;
1661 }
1662}
1663
1664#[cfg(test)]
1665#[allow(
1666 clippy::unwrap_used,
1667 clippy::expect_used,
1668 clippy::panic,
1669 clippy::indexing_slicing,
1670 reason = "a test fails by panicking"
1671)]
1672mod colorize_tests {
1673 use super::colorize_monochrome_svg;
1674 use crate::color::Rgba;
1675
1676 const BLUE: Rgba = Rgba::new(51, 102, 204, 255);
1677 const RED: Rgba = Rgba::new(255, 0, 0, 255);
1678 const GREEN: Rgba = Rgba::new(20, 184, 82, 255);
1679
1680 #[test]
1681 fn colorize_svg_replaces_fill_black() {
1682 let svg = b"<svg><path fill=\"black\" d=\"M0 0h24v24H0z\"/></svg>";
1683 let result_str = String::from_utf8(colorize_monochrome_svg(svg, BLUE)).unwrap();
1684 assert!(
1685 !result_str.contains("fill=\"black\""),
1686 "fill=\"black\" should be replaced, got: {result_str}"
1687 );
1688 assert!(
1689 result_str.contains("fill=\"#3366cc\""),
1690 "should contain the hex fill, got: {result_str}"
1691 );
1692 }
1693
1694 #[test]
1695 fn colorize_svg_replaces_fill_hex_black() {
1696 let svg = b"<svg><rect fill=\"#000000\" width=\"24\" height=\"24\"/></svg>";
1697 let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1698 assert!(
1699 !result_str.contains("#000000"),
1700 "fill=\"#000000\" should be replaced, got: {result_str}"
1701 );
1702 }
1703
1704 #[test]
1705 fn colorize_svg_replaces_fill_short_hex_black() {
1706 let svg = b"<svg><rect fill=\"#000\" width=\"24\" height=\"24\"/></svg>";
1707 let result_str = String::from_utf8(colorize_monochrome_svg(svg, GREEN)).unwrap();
1708 assert!(
1709 !result_str.contains("fill=\"#000\""),
1710 "fill=\"#000\" should be replaced, got: {result_str}"
1711 );
1712 }
1713
1714 #[test]
1715 fn colorize_svg_current_color_still_works() {
1716 let svg = b"<svg><path stroke=\"currentColor\" d=\"M0 0\"/></svg>";
1717 let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1718 assert!(
1719 !result_str.contains("currentColor"),
1720 "currentColor should be replaced"
1721 );
1722 assert!(
1723 result_str.contains("#ff0000"),
1724 "should contain the hex colour"
1725 );
1726 }
1727
1728 #[test]
1729 fn colorize_svg_implicit_black_still_works() {
1730 let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>";
1732 let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1733 assert!(
1734 result_str.contains("fill=\"#ff0000\""),
1735 "should inject fill into root svg tag, got: {result_str}"
1736 );
1737 }
1738
1739 #[test]
1740 fn colorize_svg_non_utf8_returns_original() {
1741 let mut svg = b"<svg><path fill=\"black\" d=\"M0 0\"/>".to_vec();
1743 svg.push(0xFF); svg.extend_from_slice(b"</svg>");
1745 let result = colorize_monochrome_svg(&svg, RED);
1746 assert_eq!(result, svg, "non-UTF-8 input should be returned unchanged");
1747 }
1748
1749 #[test]
1750 fn colorize_svg_replaces_stroke_black() {
1751 let svg = b"<svg><path stroke=\"black\" d=\"M0 0h24\"/></svg>";
1752 let result_str = String::from_utf8(colorize_monochrome_svg(svg, BLUE)).unwrap();
1753 assert!(
1754 !result_str.contains("stroke=\"black\""),
1755 "stroke=\"black\" should be replaced, got: {result_str}"
1756 );
1757 assert!(
1758 result_str.contains("stroke=\"#3366cc\""),
1759 "should contain the hex stroke, got: {result_str}"
1760 );
1761 }
1762
1763 #[test]
1764 fn colorize_svg_replaces_stroke_hex_black() {
1765 let svg = b"<svg><line stroke=\"#000000\" x1=\"0\" y1=\"0\" x2=\"24\" y2=\"24\"/></svg>";
1766 let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1767 assert!(
1768 !result_str.contains("#000000"),
1769 "stroke=\"#000000\" should be replaced"
1770 );
1771 }
1772
1773 #[test]
1774 fn colorize_self_closing_svg_produces_valid_xml() {
1775 let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" />";
1777 let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1778 assert!(
1779 result_str.contains("fill=\"#ff0000\""),
1780 "should inject fill, got: {result_str}"
1781 );
1782 assert!(
1783 !result_str.contains("/ fill="),
1784 "fill must be before '/', got: {result_str}"
1785 );
1786 assert!(
1787 result_str.trim().ends_with("/>"),
1788 "should remain self-closing, got: {result_str}"
1789 );
1790 }
1791
1792 #[test]
1793 fn colorize_svg_with_fill_white_root() {
1794 let svg = b"<svg fill=\"white\"><path/></svg>";
1796 let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1797 assert!(
1798 result_str.contains("fill=\"white\""),
1799 "fill=\"white\" should be preserved, got: {result_str}"
1800 );
1801 }
1802
1803 #[test]
1804 fn colorize_svg_with_fill_none_root() {
1805 let svg = b"<svg fill=\"none\"><path stroke=\"black\"/></svg>";
1807 let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1808 assert!(
1809 !result_str.contains("stroke=\"black\""),
1810 "stroke=\"black\" should be replaced, got: {result_str}"
1811 );
1812 assert!(
1813 result_str.contains("stroke=\"#ff0000\""),
1814 "should contain the hex stroke, got: {result_str}"
1815 );
1816 }
1817
1818 #[test]
1820 fn colorize_svg_discards_alpha() {
1821 let svg = b"<svg><path fill=\"black\"/></svg>";
1822 let translucent = Rgba::new(255, 0, 0, 40);
1823 assert_eq!(
1824 colorize_monochrome_svg(svg, translucent),
1825 colorize_monochrome_svg(svg, RED)
1826 );
1827 }
1828}