Make the icon follow the palette when the theme changes

b869c97 · gmorell · 2026-09-22 04:13

4 files +172 -27
Message
{commit_body(@commit)}

Files changed

modified src/app.rs
+68 −27
@@ -29,6 +29,16 @@ fn to_image(pm: &tiny_skia::Pixmap) -> egui::ColorImage {
29 29 egui::ColorImage::from_rgba_unmultiplied([pm.width() as usize, pm.height() as usize], &buf)
30 30 }
31 31
32 +/// Re-resolves a colour into `theme`: same role where it exists, otherwise
33 +/// `default_role`, otherwise the theme's first swatch.
34 +fn rebind(c: NamedColor, theme: &'static Theme, default_role: &str) -> NamedColor {
35 + theme
36 + .get(c.role)
37 + .or_else(|| theme.get(default_role))
38 + .or_else(|| theme.swatches().first().copied())
39 + .unwrap_or(c)
40 +}
41 +
32 42 fn swatch_color(c: NamedColor) -> Color32 {
33 43 Color32::from_rgb(c.rgb[0], c.rgb[1], c.rgb[2])
34 44 }
@@ -62,7 +72,9 @@ pub struct Studio {
62 72 text_value: String,
63 73
64 74 /// Which swatches and backgrounds feed the combination grid.
65 − grid_colors: Vec<usize>,
75 + /// Palette roles, not indices: swatch lists differ between themes, so
76 + /// an index means something different after a theme change.
77 + grid_colors: Vec<String>,
66 78 grid_backgrounds: Vec<Background>,
67 79 grid_masked: bool,
68 80
@@ -113,7 +125,7 @@ impl Studio {
113 125 icon_query: "lock".into(),
114 126 text_mode: false,
115 127 text_value: "1234".into(),
116 − grid_colors: vec![0, 1, 2],
128 + grid_colors: vec!["primary".into(), "accent".into(), "base-200".into()],
117 129 grid_backgrounds: vec![
118 130 Background::Solid,
119 131 Background::DiagonalSplit,
@@ -148,18 +160,13 @@ impl Studio {
148 160 }
149 161
150 162 fn session(&self) -> Session {
151 − let swatches = self.theme().swatches();
152 163 Session {
153 164 spec: self.spec.clone(),
154 165 palette: self.theme().name.to_string(),
155 166 export_name: self.export_name.clone(),
156 167 export_dir: self.export_dir.clone(),
157 168 grid_masked: self.grid_masked,
158 − grid_colors: self
159 − .grid_colors
160 − .iter()
161 − .filter_map(|i| swatches.get(*i).map(|c| c.role.to_string()))
162 − .collect(),
169 + grid_colors: self.grid_colors.clone(),
163 170 grid_backgrounds: self.grid_backgrounds.clone(),
164 171 }
165 172 }
@@ -179,14 +186,7 @@ impl Studio {
179 186 if let Some(i) = palette::THEMES.iter().position(|t| t.name == s.palette) {
180 187 self.theme_idx = i;
181 188 }
182 − // Grid colours are stored as roles; map them back to swatch indices in
183 − // the palette that is now selected.
184 − let swatches = self.theme().swatches();
185 − self.grid_colors = s
186 − .grid_colors
187 − .iter()
188 − .filter_map(|role| swatches.iter().position(|c| c.role == role))
189 − .collect();
189 + self.grid_colors = s.grid_colors.clone();
190 190 self.grid_backgrounds = s.grid_backgrounds;
191 191 self.grid_masked = s.grid_masked;
192 192 self.export_name = s.export_name;
@@ -356,6 +356,39 @@ impl Studio {
356 356 });
357 357 }
358 358
359 + /// Switches palette, carrying the current choices across by role.
360 + ///
361 + /// Changing theme has to move the colours with it, or the swatches update
362 + /// and the icon stays exactly as it was. A role the new palette does not
363 + /// define falls back to `default_role` so the slot still changes, rather
364 + /// than silently keeping a colour from the palette you just left.
365 + pub fn set_theme(&mut self, idx: usize) {
366 + if idx >= palette::THEMES.len() || idx == self.theme_idx {
367 + return;
368 + }
369 + self.theme_idx = idx;
370 + let theme = self.theme();
371 + self.spec.fg_color = rebind(self.spec.fg_color, theme, "base-content");
372 + self.spec.bg_a = rebind(self.spec.bg_a, theme, "primary");
373 + self.spec.bg_b = rebind(self.spec.bg_b, theme, "accent");
374 + }
375 +
376 + /// The spec as currently configured; exposed so the theme behaviour can be
377 + /// tested without standing up a window.
378 + pub fn spec(&self) -> &IconSpec {
379 + &self.spec
380 + }
381 +
382 + /// Sets the background-A colour directly. Exists so tests can put a role
383 + /// into the spec that only some palettes define.
384 + pub fn set_theme_color_for_test(&mut self, c: NamedColor) {
385 + self.spec.bg_a = c;
386 + }
387 +
388 + pub fn theme_name(&self) -> &'static str {
389 + self.theme().name
390 + }
391 +
359 392 fn theme(&self) -> &'static Theme {
360 393 &palette::THEMES[self.theme_idx]
361 394 }
@@ -421,9 +454,13 @@ impl Studio {
421 454 ComboBox::from_label("Palette")
422 455 .selected_text(theme_names[self.theme_idx])
423 456 .show_ui(ui, |ui| {
457 + let mut pick = self.theme_idx;
424 458 for (i, n) in theme_names.iter().enumerate() {
425 − ui.selectable_value(&mut self.theme_idx, i, *n);
459 + ui.selectable_value(&mut pick, i, *n);
426 460 }
461 + // Assigned through set_theme rather than bound directly, so the
462 + // colours follow the palette.
463 + self.set_theme(pick);
427 464 });
428 465 ui.small(if self.theme().light {
429 466 "light scheme"
@@ -567,13 +604,13 @@ impl Studio {
567 604
568 605 ui.collapsing("Colours in play", |ui| {
569 606 ui.horizontal_wrapped(|ui| {
570 − for (i, c) in swatches.iter().enumerate() {
571 − let mut on = self.grid_colors.contains(&i);
607 + for c in swatches.iter() {
608 + let mut on = self.grid_colors.iter().any(|r| r == c.role);
572 609 if ui.checkbox(&mut on, "").changed() {
573 610 if on {
574 − self.grid_colors.push(i);
611 + self.grid_colors.push(c.role.to_string());
575 612 } else {
576 − self.grid_colors.retain(|x| *x != i);
613 + self.grid_colors.retain(|r| r != c.role);
577 614 }
578 615 }
579 616 let (rect, resp) =
@@ -600,13 +637,17 @@ impl Studio {
600 637
601 638 // Every background style against every ordered colour pair drawn from
602 639 // the selection — "all the combinations the choices give you".
640 + // Roles the current theme does not define are skipped rather than
641 + // dropped, so switching away and back keeps the selection.
642 + let chosen: Vec<NamedColor> = self
643 + .grid_colors
644 + .iter()
645 + .filter_map(|role| swatches.iter().find(|c| c.role == role).copied())
646 + .collect();
603 647 let mut pairs: Vec<(NamedColor, NamedColor)> = Vec::new();
604 − for &i in &self.grid_colors {
605 − for &j in &self.grid_colors {
606 − if i == j {
607 − continue;
608 − }
609 − if let (Some(a), Some(b)) = (swatches.get(i), swatches.get(j)) {
648 + for a in &chosen {
649 + for b in &chosen {
650 + if a.role != b.role {
610 651 pairs.push((*a, *b));
611 652 }
612 653 }
added tests/theming.rs
+104 −0
@@ -0,0 +1,104 @@
1 +//! Switching palette has to move the icon's colours with it.
2 +
3 +use icon_studio::app::Studio;
4 +use icon_studio::palette;
5 +
6 +fn index_of(name: &str) -> usize {
7 + palette::THEMES
8 + .iter()
9 + .position(|t| t.name == name)
10 + .unwrap_or_else(|| panic!("no theme named {name}"))
11 +}
12 +
13 +#[test]
14 +fn changing_theme_changes_the_colours() {
15 + let mut s = Studio::new();
16 + let before = (s.spec().fg_color.rgb, s.spec().bg_a.rgb, s.spec().bg_b.rgb);
17 + assert_eq!(s.theme_name(), "Casabeza");
18 +
19 + s.set_theme(index_of("forest"));
20 + assert_eq!(s.theme_name(), "forest");
21 +
22 + let after = (s.spec().fg_color.rgb, s.spec().bg_a.rgb, s.spec().bg_b.rgb);
23 + assert_ne!(before, after, "colours should follow the palette");
24 +}
25 +
26 +#[test]
27 +fn roles_are_preserved_across_a_theme_change() {
28 + let mut s = Studio::new();
29 + let roles = (
30 + s.spec().fg_color.role,
31 + s.spec().bg_a.role,
32 + s.spec().bg_b.role,
33 + );
34 +
35 + s.set_theme(index_of("sky"));
36 +
37 + assert_eq!(
38 + (
39 + s.spec().fg_color.role,
40 + s.spec().bg_a.role,
41 + s.spec().bg_b.role
42 + ),
43 + roles,
44 + "a role defined by both palettes should carry across"
45 + );
46 +}
47 +
48 +#[test]
49 +fn colours_match_the_theme_that_is_selected() {
50 + let mut s = Studio::new();
51 + for theme in palette::THEMES.iter() {
52 + s.set_theme(index_of(theme.name));
53 + for c in [s.spec().fg_color, s.spec().bg_a, s.spec().bg_b] {
54 + if let Some(defined) = theme.get(c.role) {
55 + assert_eq!(
56 + c.rgb, defined.rgb,
57 + "{}: {} should be the palette's value",
58 + theme.name, c.role
59 + );
60 + }
61 + }
62 + }
63 +}
64 +
65 +#[test]
66 +fn a_role_the_new_palette_lacks_still_moves() {
67 + // `info` exists in the dms themes but not in Casabeza. Switching to
68 + // Casabeza must not leave a colour from the palette we just left.
69 + let mut s = Studio::new();
70 + s.set_theme(index_of("sky"));
71 + let sky = palette::THEMES[index_of("sky")];
72 + let Some(info) = sky.get("info") else {
73 + return; // nothing to test if the palette drops the role
74 + };
75 +
76 + s.set_theme_color_for_test(info);
77 + assert_eq!(s.spec().bg_a.rgb, info.rgb);
78 +
79 + s.set_theme(index_of("Casabeza"));
80 + let casabeza = palette::THEMES[index_of("Casabeza")];
81 + assert!(casabeza.get("info").is_none(), "precondition");
82 + assert_ne!(
83 + s.spec().bg_a.rgb,
84 + info.rgb,
85 + "should fall back into the new palette, not keep the old colour"
86 + );
87 + assert_eq!(
88 + s.spec().bg_a.rgb,
89 + casabeza.get("primary").unwrap().rgb,
90 + "should fall back to the slot's default role"
91 + );
92 +}
93 +
94 +#[test]
95 +fn switching_theme_is_idempotent() {
96 + let mut s = Studio::new();
97 + s.set_theme(index_of("clays"));
98 + let once = (s.spec().fg_color.rgb, s.spec().bg_a.rgb, s.spec().bg_b.rgb);
99 + s.set_theme(index_of("clays"));
100 + assert_eq!(
101 + once,
102 + (s.spec().fg_color.rgb, s.spec().bg_a.rgb, s.spec().bg_b.rgb)
103 + );
104 +}

Parents: af1c436