From 3b72cd5723c7b66a8092658ea8bc5df3b038992f Mon Sep 17 00:00:00 2001 From: Schluffe Date: Wed, 30 Sep 2026 23:13:20 +0200 Subject: [PATCH] Add color mapping --- README.md | 29 ++++ src/analysis/mapping.rs | 239 ++++++++++++++++++++++++++++++ src/analysis/mod.rs | 1 + src/app.rs | 312 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 568 insertions(+), 13 deletions(-) create mode 100644 src/analysis/mapping.rs diff --git a/README.md b/README.md index dd9fac6..f4c3ffe 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,35 @@ cargo run --release -- /path/to/screenshot.png 5. **Save crop PNG** writes a uniquely named PNG in the system temporary directory and displays its path. Drop an image or click **Open image…** to browse for a file in KDE's file picker. +## Color grouping and mapping + +**Group similar colors** is enabled by default in the **Palette**. Nearby shades +from scaling or compression share a row showing their combined percentage and +shade count. Adjust **Tolerance** (default 8, range 0–32) to control the maximum +difference in each RGB channel from the group's most frequent color. Groups do +not chain together through intermediate shades. Turn grouping off or set tolerance +to zero for the exact palette. Hover a row to highlight its pixels in magenta in +the crop preview; this temporary highlight is never exported. + +Freeze a capture or import an image, then click **Map** beside a palette row. +In **Color mapping**, click its replacement swatch to choose another color or enter +RGB values. Every original shade in that group maps to the chosen replacement. +Add multiple mappings to try a new palette. Each mapping keeps its original group +members even if you later change tolerance or crop. If a new mapping overlaps an +older one, the new mapping owns those shades; the older mapping keeps any others. +Replacements are simultaneous, so swapping two colors works without cascading. +Alpha is preserved and fully transparent pixels are left untouched. + +Toggle **Preview mappings** to compare with the original palette. Remove an +individual mapping with **×** or use **Reset mappings**. Mappings survive crop +changes, pause during live capture, and return when frozen again. Opening a new +image or choosing a new capture source clears them. + +Mappings apply before the preview adjustments or vision filter. Source preview, +analysis, copied pixel values, and **Save crop PNG** still use the original. +**Save mapped crop PNG** exports the crop with replacements, preserving alpha +and excluding preview adjustments and filters. + ## Color analysis and vision checks The **Color analysis** tab has four distributions: diff --git a/src/analysis/mapping.rs b/src/analysis/mapping.rs new file mode 100644 index 0000000..d58caf6 --- /dev/null +++ b/src/analysis/mapping.rs @@ -0,0 +1,239 @@ +//! Palette grouping and simultaneous replacement using original RGB values. +use super::Swatch; +use std::collections::HashMap; + +#[derive(Clone, Debug)] +pub struct ColorGroup { + pub rgb: [u8; 3], + pub count: u64, + pub members: Vec<[u8; 3]>, +} + +/// Most frequent colors seed groups. Every member is within `tolerance` of +/// its seed in each RGB channel; nearby shades cannot form unbounded chains. +/// Spatial buckets keep large palettes from requiring an all-pairs search. +pub fn group_palette(palette: &[Swatch], tolerance: u8) -> Vec { + if tolerance == 0 { + return palette + .iter() + .map(|s| ColorGroup { + rgb: s.rgb, + count: s.count, + members: vec![s.rgb], + }) + .collect(); + } + let mut sorted: Vec<_> = palette.iter().collect(); + sorted.sort_by_key(|s| (std::cmp::Reverse(s.count), s.rgb)); + let mut groups: Vec = Vec::new(); + let mut buckets = HashMap::<[i16; 3], usize>::new(); + let side = i16::from(tolerance) + 1; + for swatch in sorted { + let bucket = swatch.rgb.map(|v| i16::from(v) / side); + let mut best = None; + for x in -1..=1 { + for y in -1..=1 { + for z in -1..=1 { + if let Some(&index) = + buckets.get(&[bucket[0] + x, bucket[1] + y, bucket[2] + z]) + { + let seed = groups[index].rgb; + let delta = + std::array::from_fn::<_, 3, _>(|i| seed[i].abs_diff(swatch.rgb[i])); + if delta.iter().all(|&d| d <= tolerance) { + let distance: u32 = delta.iter().map(|&d| u32::from(d).pow(2)).sum(); + let candidate = (distance, index); + if best.is_none_or(|old| candidate < old) { + best = Some(candidate); + } + } + } + } + } + } + if let Some((_, index)) = best { + groups[index].count += swatch.count; + groups[index].members.push(swatch.rgb); + } else { + buckets.insert(bucket, groups.len()); + groups.push(ColorGroup { + rgb: swatch.rgb, + count: swatch.count, + members: vec![swatch.rgb], + }); + } + } + groups.sort_by_key(|g| (std::cmp::Reverse(g.count), g.rgb)); + groups +} + +impl ColorGroup { + pub fn highlight_rgba(&self, rgba: &[u8]) -> Vec { + let members: std::collections::HashSet<_> = self.members.iter().copied().collect(); + let mut output = rgba.to_vec(); + for pixel in output.as_chunks_mut::<4>().0 { + if pixel[3] == 0 { + continue; + } + if members.contains(&[pixel[0], pixel[1], pixel[2]]) { + pixel[..3].copy_from_slice(&[255, 0, 255]); + } else { + for channel in &mut pixel[..3] { + *channel /= 4; + } + } + } + output + } +} + +pub struct ColorMapping { + pub source: [u8; 3], + pub target: [u8; 3], + pub members: Vec<[u8; 3]>, +} + +#[derive(Default)] +pub struct ColorMappings(pub Vec); + +impl ColorMappings { + pub fn insert_group(&mut self, group: &ColorGroup) { + let members: std::collections::HashSet<_> = group.members.iter().copied().collect(); + // The latest explicit mapping owns overlapping colors. Keep unaffected + // members of earlier mappings, and remove entries that become empty. + for entry in &mut self.0 { + entry.members.retain(|rgb| !members.contains(rgb)); + } + self.0.retain(|entry| !entry.members.is_empty()); + self.0.push(ColorMapping { + source: group.rgb, + target: group.rgb, + members: group.members.clone(), + }); + } + + #[cfg(test)] + pub fn insert(&mut self, source: [u8; 3], target: [u8; 3]) { + self.insert_group(&ColorGroup { + rgb: source, + count: 1, + members: vec![source], + }); + self.0.last_mut().unwrap().target = target; + } + + pub fn apply_rgba(&self, rgba: &[u8]) -> Vec { + let lookup: HashMap<_, _> = self + .0 + .iter() + .flat_map(|entry| entry.members.iter().map(move |&rgb| (rgb, entry.target))) + .collect(); + let mut output = rgba.to_vec(); + for pixel in output.as_chunks_mut::<4>().0 { + if pixel[3] != 0 + && let Some(target) = lookup.get(&[pixel[0], pixel[1], pixel[2]]) + { + pixel[..3].copy_from_slice(target); + } + } + output + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn swatch(rgb: [u8; 3], count: u64) -> Swatch { + Swatch { + rgb, + count, + opaque_count: count, + } + } + + #[test] + fn grouping_combines_counts_without_chaining_and_keeps_exact_mode() { + let palette = vec![ + swatch([100, 0, 0], 100), + swatch([105, 0, 0], 3), + swatch([110, 0, 0], 2), + swatch([0, 255, 0], 1), + ]; + let groups = group_palette(&palette, 5); + assert_eq!(groups.len(), 3); + assert_eq!(groups[0].members, [[100, 0, 0], [105, 0, 0]]); + assert_eq!(groups[0].count, 103); + assert_eq!(groups.iter().map(|g| g.count).sum::(), 106); + assert_eq!(group_palette(&palette, 0).len(), 4); + let mut reversed = palette; + reversed.reverse(); + assert_eq!(group_palette(&reversed, 5)[0].members, groups[0].members); + } + + #[test] + fn group_mapping_uses_fixed_members_and_latest_mapping_owns_overlap() { + let groups = group_palette(&[swatch([255, 0, 0], 10), swatch([252, 2, 1], 1)], 4); + let mut mappings = ColorMappings::default(); + mappings.insert_group(&groups[0]); + mappings.0[0].target = [0, 0, 255]; + assert_eq!(mappings.apply_rgba(&[252, 2, 1, 128]), [0, 0, 255, 128]); + mappings.insert([252, 2, 1], [0, 255, 0]); + assert_eq!(mappings.0[0].members, [[255, 0, 0]]); + assert_eq!( + mappings.apply_rgba(&[252, 2, 1, 255, 255, 0, 0, 255]), + [0, 255, 0, 255, 0, 0, 255, 255] + ); + } + + #[test] + fn highlight_matches_group_members_and_preserves_transparency() { + let group = ColorGroup { + rgb: [250, 0, 0], + count: 2, + members: vec![[250, 0, 0], [253, 2, 0]], + }; + assert_eq!( + group.highlight_rgba(&[253, 2, 0, 128, 0, 0, 252, 255, 250, 0, 0, 0]), + [255, 0, 255, 128, 0, 0, 63, 255, 250, 0, 0, 0] + ); + } + + #[test] + fn dense_palette_preserves_every_color_and_respects_tolerance() { + let palette: Vec<_> = (0..=255) + .flat_map(|r| (0..=255).step_by(8).map(move |g| swatch([r, g, 128], 1))) + .collect(); + let groups = group_palette(&palette, 8); + let members: std::collections::HashSet<_> = groups + .iter() + .flat_map(|g| g.members.iter().copied()) + .collect(); + assert_eq!(members.len(), palette.len()); + assert_eq!( + groups.iter().map(|g| g.count).sum::(), + palette.len() as u64 + ); + for group in groups { + assert!( + group + .members + .iter() + .all(|rgb| (0..3).all(|i| rgb[i].abs_diff(group.rgb[i]) <= 8)) + ); + } + } + + #[test] + fn replacements_are_exact_simultaneous_and_preserve_alpha() { + let mut mappings = ColorMappings::default(); + mappings.insert([255, 0, 0], [0, 255, 0]); + mappings.insert([0, 255, 0], [255, 0, 0]); + let source = [255, 0, 0, 255, 0, 255, 0, 128, 254, 0, 0, 255, 255, 0, 0, 0]; + assert_eq!( + mappings.apply_rgba(&source), + [0, 255, 0, 255, 255, 0, 0, 128, 254, 0, 0, 255, 255, 0, 0, 0] + ); + assert_eq!(ColorMappings::default().apply_rgba(&source), source); + } +} diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs index 58ee6d5..f91ef1c 100644 --- a/src/analysis/mod.rs +++ b/src/analysis/mod.rs @@ -1,6 +1,7 @@ //! Analysis always uses source pixels, never the scaled preview. use std::collections::HashMap; pub mod adjustments; +pub mod mapping; pub mod vision; pub const LEVEL_BINS: usize = 20; diff --git a/src/app.rs b/src/app.rs index 837f129..3f19aa9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,7 @@ use crate::{ analysis::{ self, Analysis, Region, adjustments::Adjustments, + mapping::{ColorGroup, ColorMappings, group_palette}, vision::{self, Vision}, }, capture::{self, Frame, SharedCapture, Status}, @@ -22,6 +23,13 @@ pub struct WhosHueApp { simulated_texture: Option, vision_mode: Vision, adjustments: Adjustments, + mappings: ColorMappings, + group_colors: bool, + group_tolerance: u8, + palette_groups: Vec, + hovered_group: Option, + highlight_texture: Option, + mappings_enabled: bool, vision_panel: bool, channel: ui::Channel, region: Option, @@ -55,6 +63,13 @@ impl WhosHueApp { simulated_texture: None, vision_mode: Vision::Original, adjustments: Adjustments::default(), + mappings: ColorMappings::default(), + group_colors: true, + group_tolerance: 8, + palette_groups: Vec::new(), + hovered_group: None, + highlight_texture: None, + mappings_enabled: true, vision_panel: false, channel: ui::Channel::Hue, region: None, @@ -79,12 +94,15 @@ impl WhosHueApp { self.stop(); // A new selection always opens KDE's picker; never silently restore it. self.capture = Some(capture::spawn(None)); + self.mappings = ColorMappings::default(); + self.mappings_enabled = true; self.frame = None; self.texture = None; self.crop_texture = None; self.simulated_texture = None; self.region = None; self.analysis = Analysis::default(); + self.rebuild_groups(); self.frozen = false; self.drag_origin = None; self.message.clear(); @@ -133,6 +151,7 @@ impl WhosHueApp { let region = self.region.unwrap_or_else(|| Region::full(frame)); let pixels = region.crop(frame); self.analysis = analysis::analyze(&pixels); + self.rebuild_groups(); Self::texture( ctx, &mut self.crop_texture, @@ -145,14 +164,69 @@ impl WhosHueApp { } } + fn rebuild_groups(&mut self) { + self.palette_groups = group_palette( + &self.analysis.palette, + if self.group_colors { + self.group_tolerance + } else { + 0 + }, + ); + self.hovered_group = None; + self.highlight_texture = None; + } + + fn highlight_group(&mut self, ctx: &egui::Context, index: Option) { + if self.hovered_group == index { + return; + } + self.hovered_group = index; + self.highlight_texture = None; + if let Some(index) = index + && let Some(frame) = &self.frame + { + let region = self.region.unwrap_or_else(|| Region::full(frame)); + let pixels = self.palette_groups[index].highlight_rgba(®ion.crop(frame)); + Self::texture( + ctx, + &mut self.highlight_texture, + "group-highlight", + region.width, + region.height, + &pixels, + ); + } + } + + fn can_map(&self) -> bool { + self.frame.is_some() && self.frozen && (self.drag_origin.is_none() || self.was_frozen) + } + + fn mappings_active(&self) -> bool { + self.can_map() && self.mappings_enabled && !self.mappings.0.is_empty() + } + + fn mapped_crop(&self, region: Region, frame: &Frame) -> Vec { + let crop = region.crop(frame); + if self.mappings_active() { + self.mappings.apply_rgba(&crop) + } else { + crop + } + } + fn refresh_simulation(&mut self, ctx: &egui::Context) { - if self.vision_mode == Vision::Original && self.adjustments == Adjustments::default() { + if self.vision_mode == Vision::Original + && self.adjustments == Adjustments::default() + && !self.mappings_active() + { self.simulated_texture = None; return; } if let Some(frame) = &self.frame { let region = self.region.unwrap_or_else(|| Region::full(frame)); - let crop = region.crop(frame); + let crop = self.mapped_crop(region, frame); let pixels = if self.vision_mode == Vision::Original { self.adjustments.apply_rgba(&crop) } else { @@ -179,6 +253,10 @@ impl WhosHueApp { } self.stop(); self.region = None; + self.drag_origin = None; + self.frozen = true; + self.mappings = ColorMappings::default(); + self.mappings_enabled = true; self.set_frame( ctx, Frame { @@ -208,7 +286,7 @@ impl WhosHueApp { }); } - fn export(&mut self) { + fn export(&mut self, mapped: bool) { if let Some(frame) = &self.frame { let r = self.region.unwrap_or_else(|| Region::full(frame)); // Unique output preserves earlier captures. @@ -219,7 +297,11 @@ impl WhosHueApp { let path = std::env::temp_dir().join(format!("whoshue-{stamp}.png")); self.message = match image::save_buffer( &path, - &r.crop(frame), + &if mapped { + self.mapped_crop(r, frame) + } else { + r.crop(frame) + }, r.width, r.height, image::ColorType::Rgba8, @@ -406,15 +488,22 @@ impl WhosHueApp { self.vision_mode.label() }, ); - ui.small("Pixel values and PNG use original colors"); + ui.small(if self.highlight_texture.is_some() { + "Highlighted group · matching pixels shown in magenta" + } else if self.mappings_active() { + "Mapped preview · pixel inspection and analysis use original colors" + } else { + "Pixel values and Save crop PNG use original colors" + }); self.crop_inspector(ui, region); }); } fn crop_inspector(&self, ui: &mut egui::Ui, region: Region) { let Some(texture) = self - .simulated_texture + .highlight_texture .as_ref() + .or(self.simulated_texture.as_ref()) .or(self.crop_texture.as_ref()) else { return; @@ -448,7 +537,82 @@ impl WhosHueApp { } } + fn mapping_controls(&mut self, ui: &mut egui::Ui) { + ui.heading("Color mapping"); + let can_map = self.can_map(); + if !can_map { + ui.small("Freeze the capture or open an image to map colors."); + } + let mut changed = false; + ui.add_enabled_ui(can_map, |ui| { + ui.horizontal(|ui| { + changed |= ui + .checkbox(&mut self.mappings_enabled, "Preview mappings") + .changed(); + if ui + .add_enabled( + !self.mappings.0.is_empty(), + egui::Button::new("Reset mappings"), + ) + .clicked() + { + self.mappings.0.clear(); + changed = true; + } + }); + ui.small("Choose Map beside a palette color or group."); + let mut remove = None; + egui::ScrollArea::vertical() + .id_salt("mappings") + .max_height(150.0) + .show(ui, |ui| { + for (index, entry) in self.mappings.0.iter_mut().enumerate() { + let source = entry.source; + let target = &mut entry.target; + ui.push_id(index, |ui| { + ui.horizontal(|ui| { + let [r, g, b] = source; + ui.colored_label(Color32::from_rgb(r, g, b), "■"); + ui.monospace(format!("#{r:02X}{g:02X}{b:02X} →")); + changed |= ui.color_edit_button_srgb(target).on_hover_text(format!("Replaces {} original shades. Group membership stays fixed when tolerance changes.", entry.members.len())).changed(); + let [r, g, b] = *target; + ui.monospace(format!("#{r:02X}{g:02X}{b:02X}")); + if ui + .small_button("×") + .on_hover_text("Remove mapping") + .clicked() + { + remove = Some(index); + } + }); + }); + } + }); + if let Some(source) = remove { + self.mappings.0.remove(source); + changed = true; + } + if ui + .add_enabled( + self.mappings_active(), + egui::Button::new("Save mapped crop PNG"), + ) + .on_hover_text( + "Save exact color replacements, without preview filters or adjustments.", + ) + .clicked() + { + self.export(true); + } + }); + if changed { + self.refresh_simulation(ui.ctx()); + } + ui.separator(); + } + fn sidebar(&mut self, ui: &mut egui::Ui) { + self.mapping_controls(ui); ui.horizontal(|ui| { ui.selectable_value(&mut self.vision_panel, false, "Color analysis"); let count: usize = self @@ -466,6 +630,7 @@ impl WhosHueApp { }); ui.separator(); if self.vision_panel { + self.highlight_group(ui.ctx(), None); let old_mode = self.vision_mode; egui::ScrollArea::vertical() .id_salt("vision_warnings") @@ -496,22 +661,37 @@ impl WhosHueApp { } ui.small(self.channel.description()); ui.separator(); - ui.heading("Exact palette"); + ui.heading("Palette"); + let mut regroup = ui + .checkbox(&mut self.group_colors, "Group similar colors") + .changed(); + if self.group_colors { + regroup |= ui.add(egui::Slider::new(&mut self.group_tolerance, 0..=32).text("Tolerance")) + .on_hover_text("Maximum difference in each RGB channel from the group's most frequent color. Zero keeps every color separate.").changed(); + } + if regroup { + self.rebuild_groups(); + } + ui.small("Hover a row to highlight matching pixels. Existing mappings keep their original shades."); ui.label(format!( - "{} colors · {} nontransparent pixels", + "{} groups / {} colors · {} pixels", + self.palette_groups.len(), self.analysis.palette.len(), self.analysis.pixels )); ui.small("Most frequent first · click a swatch to copy hex"); + let can_map = self.can_map(); + let mut map_color = None; + let mut hovered = None; // Virtual rows keep large true-color captures responsive. egui::ScrollArea::vertical().id_salt("palette").show_rows( ui, 24.0, - self.analysis.palette.len(), + self.palette_groups.len(), |ui, rows| { for index in rows { - let swatch = &self.analysis.palette[index]; - ui.horizontal(|ui| { + let swatch = &self.palette_groups[index]; + let row = ui.horizontal(|ui| { let [r, g, b] = swatch.rgb; let hex = format!("#{r:02X}{g:02X}{b:02X}"); let (rect, response) = @@ -522,10 +702,16 @@ impl WhosHueApp { ui.ctx().copy_text(hex.clone()); } ui.monospace(&hex); + let already_mapped = self.mappings.0.iter().any(|entry| entry.members == swatch.members); + if ui.add_enabled(can_map && !already_mapped, egui::Button::new(if already_mapped { "Mapped" } else { "Map" })) + .on_hover_text("Map every shade in this group. Overlapping colors move to this mapping.").clicked() { + map_color = Some(index); + } ui.label(format!( - "{:.1}%", + "{:.2}%", 100.0 * swatch.count as f64 / self.analysis.pixels.max(1) as f64 )); + ui.small(format!("{} shades", swatch.members.len())); let [h, s, v] = analysis::hsv(swatch.rgb); response.on_hover_text(format!( "RGB {r}, {g}, {b}\nHSV {h:.1}°, {:.1}%, {:.1}%\n{} pixels", @@ -534,9 +720,18 @@ impl WhosHueApp { swatch.count )); }); + if row.response.contains_pointer() { + hovered = Some(index); + } } }, ); + self.highlight_group(ui.ctx(), hovered); + if let Some(index) = map_color { + self.mappings.insert_group(&self.palette_groups[index]); + self.mappings_enabled = true; + self.refresh_simulation(ui.ctx()); + } } } @@ -590,16 +785,18 @@ impl eframe::App for WhosHueApp { .clicked() { self.frozen = !self.frozen; + self.refresh_simulation(&ctx); } if ui .add_enabled(has_frame, egui::Button::new("Save crop PNG")) .clicked() { - self.export(); + self.export(false); } if self.capture.is_some() && ui.button("Stop sharing").clicked() { self.stop(); self.frozen = true; + self.refresh_simulation(&ctx); } }); if let Some(capture) = &self.capture { @@ -649,6 +846,91 @@ mod tests { } } + #[test] + fn grouped_preview_and_highlight_reset_when_recropping() { + let ctx = egui::Context::default(); + let mut app = WhosHueApp::empty(Config::default()); + app.frozen = true; + app.set_frame( + &ctx, + Frame { + seq: 1, + width: 3, + height: 1, + rgba: vec![250, 0, 0, 255, 253, 2, 0, 128, 0, 0, 255, 255], + }, + ); + let index = app + .palette_groups + .iter() + .position(|g| g.members.len() == 2) + .unwrap(); + app.mappings.insert_group(&app.palette_groups[index]); + app.mappings.0[0].target = [0, 255, 0]; + app.highlight_group(&ctx, Some(index)); + assert_eq!(app.highlight_texture.as_ref().unwrap().size(), [3, 1]); + app.group_tolerance = 0; + app.rebuild_groups(); + assert!(app.highlight_texture.is_none()); + assert_eq!(app.palette_groups.len(), 3); + app.region = Some(Region { + x: 1, + y: 0, + width: 1, + height: 1, + }); + app.analyze(&ctx); + assert_eq!( + app.mapped_crop(app.region.unwrap(), app.frame.as_ref().unwrap()), + [0, 255, 0, 128] + ); + assert_eq!(app.analysis.palette[0].rgb, [253, 2, 0]); + app.highlight_group(&ctx, Some(0)); + app.highlight_group(&ctx, None); + assert!(app.highlight_texture.is_none()); + } + + #[test] + fn mappings_follow_crop_and_pause_when_live_without_changing_source() { + let ctx = egui::Context::default(); + let mut app = WhosHueApp::empty(Config::default()); + app.set_frame(&ctx, sample(1, 4)); + app.frozen = true; + app.mappings.insert([255, 0, 0], [0, 0, 255]); + app.region = Some(Region { + x: 1, + y: 0, + width: 1, + height: 1, + }); + app.analyze(&ctx); + let region = app.region.unwrap(); + assert_eq!( + app.mapped_crop(region, app.frame.as_ref().unwrap()), + [0, 0, 255, 255] + ); + assert_eq!(app.simulated_texture.as_ref().unwrap().size(), [1, 1]); + assert_eq!(app.analysis.palette[0].rgb, [255, 0, 0]); + assert_eq!(app.frame.as_ref().unwrap().rgba, sample(1, 4).rgba); + app.mappings_enabled = false; + app.refresh_simulation(&ctx); + assert!(app.simulated_texture.is_none()); + assert_eq!( + app.mapped_crop(region, app.frame.as_ref().unwrap()), + [255, 0, 0, 255] + ); + app.mappings_enabled = true; + app.frozen = false; + app.refresh_simulation(&ctx); + assert!(app.simulated_texture.is_none()); + app.frozen = true; + app.refresh_simulation(&ctx); + assert!(app.simulated_texture.is_some()); + app.mappings.0.clear(); + app.refresh_simulation(&ctx); + assert!(app.simulated_texture.is_none()); + } + #[test] fn frozen_frame_survives_updates_and_resize_resets_crop() { let ctx = egui::Context::default(); @@ -715,6 +997,7 @@ mod tests { .as_nanos(); let path = std::env::temp_dir().join(format!("whoshue picker test ü {stamp}.png")); image::save_buffer(&path, &[0, 255, 0, 255], 1, 1, image::ColorType::Rgba8).unwrap(); + app.mappings.insert([255, 0, 0], [0, 0, 255]); let (sender, receiver) = tokio::sync::oneshot::channel(); app.file_picker = Some(receiver); sender.send(Ok(Some(path.clone()))).unwrap(); @@ -724,6 +1007,7 @@ mod tests { assert!(shared.lock().unwrap().stop); assert!(app.capture.is_none()); assert!(app.frozen); + assert!(app.mappings.0.is_empty()); assert_eq!(app.frame.as_ref().unwrap().rgba, [0, 255, 0, 255]); } @@ -793,6 +1077,8 @@ mod tests { rgba: vec![255, 0, 0, 255, 0, 173, 0, 255], }, ); + app.frozen = true; + app.mappings.insert([255, 0, 0], [0, 0, 255]); app.adjustments.hue = 45.0; for mode in [Vision::Original, Vision::Invert] .into_iter()