neiam
/icon-studio
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Make the icon follow the palette when the theme changes
b869c97 · gmorell · 2026-09-22 04:13
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