diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index f9b719dc..cdb46864 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -370,6 +370,100 @@ pub fn color_range(px: &[[f32; 4]], color: [f32; 3], fuzziness: f32) -> Vec .collect() } +/// One Color Range sample (Sampled Colors): an eyedropper colour, and where it was picked +/// (needed for Localized Color Clusters; area-local pixel coordinates). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RangeSample { + pub color: [f32; 3], + pub at: Option<(f32, f32)>, +} + +/// Color Range › Sampled Colors with several samples (the dialog's "Add to Sample" eyedropper): +/// each pixel takes its best match over the samples, each judged like [`color_range`]. With +/// `localized` (Localized Color Clusters) a sample also fades with distance from where it was +/// picked, reaching 0 at `localized` pixels, so only nearby matching colours are selected; +/// samples without a position aren't localized. `w` is the row width of `px`. +pub fn color_range_samples(px: &[[f32; 4]], w: usize, samples: &[RangeSample], fuzziness: f32, localized: Option) -> Vec { + let f = fuzziness.max(0.0) + 1.0; + px.iter() + .enumerate() + .map(|(i, &[r, g, b, a])| { + if a <= 0.0 { + return 0.0; + } + let (x, y) = ((i % w.max(1)) as f32, (i / w.max(1)) as f32); + samples.iter().fold(0.0f32, |best, s| { + let d = [r, g, b].iter().zip(&s.color).map(|(v, c)| (v - c).abs() * 255.0).fold(0.0f32, f32::max); + let mut k = (1.0 - d / f).clamp(0.0, 1.0); + if let (Some(radius), Some((sx, sy))) = (localized, s.at) { + let dist = ((x - sx).powi(2) + (y - sy).powi(2)).sqrt(); + k *= (1.0 - dist / radius.max(1.0)).clamp(0.0, 1.0); + } + best.max(k) + }) + }) + .collect() +} + +/// Hue (degrees, 0..360) and chroma (max − min) of an RGB colour. +fn hue_chroma([r, g, b]: [f32; 3]) -> (f32, f32) { + let mx = r.max(g).max(b); + let mn = r.min(g).min(b); + let d = mx - mn; + if d <= 0.0 { + return (0.0, 0.0); + } + let h = if mx == r { + ((g - b) / d).rem_euclid(6.0) + } else if mx == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + (h * 60.0, d) +} + +/// Color Range › Select: Reds, Yellows, Greens, Cyans, Blues or Magentas (`center` = 0, 60, +/// 120, 180, 240, 300°). A pixel belongs to a family by how close its hue is (full at the +/// family's hue, none 60° away, so an orange is half red, half yellow) times its chroma, so +/// neutrals are never selected and muted colours only partly. +pub fn hue_range(px: &[[f32; 4]], center: f32) -> Vec { + px.iter() + .map(|&[r, g, b, a]| { + if a <= 0.0 { + return 0.0; + } + let (h, chroma) = hue_chroma([r, g, b].map(|v| v.clamp(0.0, 1.0))); + let dh = (h - center).rem_euclid(360.0); + let dh = dh.min(360.0 - dh); + ((1.0 - dh / 60.0).clamp(0.0, 1.0) * chroma).clamp(0.0, 1.0) + }) + .collect() +} + +/// Color Range › Select: Highlights, Midtones or Shadows. Pixels whose luminance (Rec. 601 +/// weights, 0..255) lies in `lo..=hi` are fully selected; outside, selection falls off linearly +/// to 0 over `falloff` levels (the dialog's Fuzziness). +pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec { + let ramp = falloff.max(1e-3); + px.iter() + .map(|&[r, g, b, a]| { + if a <= 0.0 { + return 0.0; + } + let l = (0.299 * r + 0.587 * g + 0.114 * b).clamp(0.0, 1.0) * 255.0; + let out = if l < lo { + lo - l + } else if l > hi { + l - hi + } else { + 0.0 + }; + (1.0 - out / ramp).clamp(0.0, 1.0) + }) + .collect() +} + /// 1D squared distance transform (Felzenszwalb & Huttenlocher). fn dt1(f: &[f32], out: &mut [f32], v: &mut [usize], z: &mut [f32]) { let n = f.len(); @@ -610,6 +704,69 @@ mod tests { assert_eq!(m[3], 0.0); } + #[test] + fn color_range_several_samples_and_localized() { + let red = [1.0, 0.0, 0.0, 1.0]; + let blue = [0.0, 0.0, 1.0, 1.0]; + let green = [0.0, 1.0, 0.0, 1.0]; + // 4 × 1: red, blue, green, red. + let px = vec![red, blue, green, red]; + let s = |c: [f32; 4], at: Option<(f32, f32)>| RangeSample { color: [c[0], c[1], c[2]], at }; + let m = color_range_samples(&px, 4, &[s(red, None), s(blue, None)], 10.0, None); + assert_eq!(m, vec![1.0, 1.0, 0.0, 1.0]); + // One sample is exactly the single-colour Color Range. + assert_eq!(color_range_samples(&px, 4, &[s(red, None)], 40.0, None), color_range(&px, [1.0, 0.0, 0.0], 40.0)); + // Localized at x = 0 with a 2 px range: the far red pixel (x = 3) drops out, x = 1 would be half. + let m = color_range_samples(&px, 4, &[s(red, Some((0.0, 0.0)))], 10.0, Some(2.0)); + assert_eq!(m, vec![1.0, 0.0, 0.0, 0.0]); + let reds = vec![red; 4]; + let m = color_range_samples(&reds, 4, &[s(red, Some((0.0, 0.0)))], 10.0, Some(2.0)); + assert_eq!(m, vec![1.0, 0.5, 0.0, 0.0]); + // No samples: nothing selected. + assert!(color_range_samples(&px, 4, &[], 10.0, None).iter().all(|v| *v == 0.0)); + } + + #[test] + fn hue_families() { + let px = vec![ + [1.0, 0.0, 0.0, 1.0], // red + [1.0, 0.5, 0.0, 1.0], // orange: between red and yellow + [0.5, 0.5, 0.5, 1.0], // grey + [1.0, 0.5, 0.5, 1.0], // pink: a muted red + [0.0, 0.0, 1.0, 1.0], // blue + [1.0, 0.0, 0.0, 0.0], // transparent + ]; + let reds = hue_range(&px, 0.0); + assert_eq!(reds[0], 1.0); + assert!((reds[1] - 0.5).abs() < 1e-5); + assert_eq!(reds[2], 0.0); + assert!((reds[3] - 0.5).abs() < 1e-5); + assert_eq!(reds[4], 0.0); + assert_eq!(reds[5], 0.0); + let yellows = hue_range(&px, 60.0); + assert!((yellows[1] - 0.5).abs() < 1e-5); + assert_eq!(hue_range(&px, 240.0)[4], 1.0); + // Magentas wrap around 360°. + assert_eq!(hue_range(&[[1.0, 0.0, 1.0, 1.0]], 300.0)[0], 1.0); + assert!((hue_range(&[[1.0, 0.0, 0.5, 1.0]], 300.0)[0] - 0.5).abs() < 1e-5); + } + + #[test] + fn tonal_ranges() { + let g = |v: f32| [v / 255.0, v / 255.0, v / 255.0, 1.0]; + let px = vec![g(0.0), g(60.0), g(75.0), g(128.0), g(200.0), g(255.0)]; + let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4); + // Shadows up to 65, fading over 20 levels. + let sh = tone_range(&px, 0.0, 65.0, 20.0); + assert!(near(&sh, &[1.0, 1.0, 0.5, 0.0, 0.0, 0.0]), "{sh:?}"); + // Highlights from 190. + let h = tone_range(&px, 190.0, 255.0, 20.0); + assert!(near(&h, &[0.0, 0.0, 0.0, 0.0, 1.0, 1.0]), "{h:?}"); + // Midtones 105..150. + let m = tone_range(&px, 105.0, 150.0, 20.0); + assert!(near(&m, &[0.0, 0.0, 0.0, 1.0, 0.0, 0.0]), "{m:?}"); + } + fn square(w: usize, h: usize) -> Vec { (0..w * h).map(|i| if (5..15).contains(&(i % w)) && (5..15).contains(&(i / w)) { 1.0 } else { 0.0 }).collect() } diff --git a/crates/engine/src/selection_cmds.rs b/crates/engine/src/selection_cmds.rs index bd7a9049..4a3ab5bd 100644 --- a/crates/engine/src/selection_cmds.rs +++ b/crates/engine/src/selection_cmds.rs @@ -94,15 +94,132 @@ fn parse_color(p: &Value) -> [f32; 3] { } } +/// Eyedropper samples from `key` (`[[x, y], …]` in document pixels): the colour under each point +/// and where it was picked (area-local, for Localized Color Clusters). +fn eyedropper_samples(p: &Value, key: &str, area: Rect, px: &[[f32; 4]], bad: &impl Fn(String) -> EngineError) -> Result> { + let Some(pts) = p.get(key) else { return Ok(Vec::new()) }; + let pts = pts.as_array().ok_or_else(|| bad(format!("`{key}` must be [[x, y], …]")))?; + let w = area.width() as usize; + let mut out = Vec::with_capacity(pts.len().min(4096)); + for pt in pts { + let xy = pt.as_array().and_then(|a| match a.as_slice() { + [x, y] => Some((x.as_f64()?, y.as_f64()?)), + _ => None, + }); + let Some((x, y)) = xy.filter(|(x, y)| x.is_finite() && y.is_finite()) else { + return Err(bad(format!("bad point {pt} in `{key}` (want [x, y])"))); + }; + let (xi, yi) = (x.floor() as i32, y.floor() as i32); + let q = if area.contains(xi, yi) { px.get((yi - area.y0) as usize * w + (xi - area.x0) as usize) } else { None }; + let Some(&[r, g, b, _]) = q else { + return Err(bad(format!("point [{x}, {y}] in `{key}` is outside the canvas"))); + }; + out.push(sel::RangeSample { color: [r, g, b], at: Some(((xi - area.x0) as f32, (yi - area.y0) as f32)) }); + } + Ok(out) +} + fn color_range(s: &mut Session, p: &Value) -> Result { - let color = if p.get("color").is_some() { - parse_color(p) - } else { - let f = s.tools.foreground; - [f[0], f[1], f[2]] + const CMD: &str = "select.colorRange"; + let bad = |msg: String| EngineError::BadParams { cmd: CMD.into(), msg }; + let preset = p.get("select").map_or(Some("sampledColors"), Value::as_str).ok_or_else(|| bad("`select` must be a string".into()))?; + let invert = b(p, "invert", false); + let fuzz = |d: f32| f(p, "fuzziness", d).max(0.0); + let (area, mut mask) = match preset { + "sampledColors" => { + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + let w = area.width() as usize; + // The dialog's eyedroppers: colours picked on the image, at their positions. + let mut samples = eyedropper_samples(p, "points", area, &px, &bad)?; + let minus = eyedropper_samples(p, "subtractPoints", area, &px, &bad)?; + if let Some(cs) = p.get("colors") { + let cs = cs.as_array().ok_or_else(|| bad("`colors` must be a list of colours".into()))?; + for c in cs { + samples.push(sel::RangeSample { color: parse_color(&json!({ "color": c })), at: None }); + } + } + if p.get("color").is_some() || samples.is_empty() { + let color = if p.get("color").is_some() { + parse_color(p) + } else { + let [r, g, b, _] = s.tools.foreground; + [r, g, b] + }; + samples.push(sel::RangeSample { color, at: None }); + } + // Localized Color Clusters: Range is a percentage of the canvas's longer side. + let localized = if b(p, "localized", false) { + if !samples.iter().chain(&minus).any(|x| x.at.is_some()) { + return Err(bad("localized clusters need eyedropper `points`".into())); + } + Some(f(p, "range", 100.0).clamp(0.0, 100.0) / 100.0 * area.width().max(area.height()) as f32) + } else { + None + }; + let mut mask = sel::color_range_samples(&px, w, &samples, fuzz(40.0), localized); + // The minus eyedropper: colours matching a subtracted sample (same Fuzziness) drop out. + if !minus.is_empty() { + let cut = sel::color_range_samples(&px, w, &minus, fuzz(40.0), localized); + for (m, c) in mask.iter_mut().zip(&cut) { + *m *= 1.0 - c; + } + } + (area, mask) + } + "reds" | "yellows" | "greens" | "cyans" | "blues" | "magentas" => { + let center = match preset { + "reds" => 0.0, + "yellows" => 60.0, + "greens" => 120.0, + "cyans" => 180.0, + "blues" => 240.0, + _ => 300.0, + }; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + (area, sel::hue_range(&px, center)) + } + "highlights" | "midtones" | "shadows" => { + // Range in levels (Highlights / Shadows: one value, Midtones: [low, high]); Fuzziness + // is a percentage of the tonal scale. + let level = |v: &Value| v.as_f64().filter(|x| (0.0..=255.0).contains(x)).map(|x| x as f32); + let r = p.get("tonalRange"); + let (lo, hi) = match (preset, r) { + ("shadows", None) => (0.0, 65.0), + ("highlights", None) => (190.0, 255.0), + (_, None) => (105.0, 150.0), + ("shadows", Some(v)) => (0.0, level(v).ok_or_else(|| bad(format!("tonalRange: want a level 0..255 (got {v})")))?), + ("highlights", Some(v)) => (level(v).ok_or_else(|| bad(format!("tonalRange: want a level 0..255 (got {v})")))?, 255.0), + (_, Some(v)) => v + .as_array() + .and_then(|a| match a.as_slice() { + [l, h] => Some((level(l)?, level(h)?)), + _ => None, + }) + .filter(|(l, h)| l <= h) + .ok_or_else(|| bad(format!("tonalRange: want [low, high] levels 0..255 (got {v})")))?, + }; + let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; + (area, sel::tone_range(&px, lo, hi, fuzz(20.0).min(100.0) / 100.0 * 255.0)) + } + "outOfGamut" => { + // Pixels the current proof setup (View › Proof Setup) can't reproduce, as the gamut + // warning shows them: judged on the composite. + let d = s.active().ok_or(EngineError::NoDocument)?; + let pv = s.color.proof(d.doc.id); + let (m, _) = crate::color_cmds::gamut_mask(&d.doc, &pv.setup, pv.gamut_threshold)?; + (d.doc.bounds(), m.iter().map(|v| f32::from(*v) / 255.0).collect()) + } + other => { + return Err(bad(format!( + "unknown select `{other}` (sampledColors, reds, yellows, greens, cyans, blues, magentas, highlights, midtones, shadows, outOfGamut)" + ))); + } }; - let (area, px) = sample_pixels(s, b(p, "sampleAllLayers", true))?; - let mask = sel::color_range(&px, color, f(p, "fuzziness", 40.0)); + if invert { + for v in &mut mask { + *v = 1.0 - *v; + } + } set_selection(s, "Color Range", area, mask, mode(p)) } @@ -209,7 +326,7 @@ pub fn specs() -> Vec { "select.colorRange", "Color Range…", ["Select"], - r##"{"color":"#rrggbb"=foreground,"fuzziness":0..200=40,"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, + r##"{"select":"sampledColors|reds|yellows|greens|cyans|blues|magentas|highlights|midtones|shadows|outOfGamut"="sampledColors","color":"#rrggbb"=foreground,"colors":["#rrggbb",…]?,"points":[[x,y],…]? (eyedropper samples),"subtractPoints":[[x,y],…]? (minus eyedropper),"fuzziness":0..200=40 (tones: 0..100 %=20),"localized":bool=false,"range":0..100=100 (% of the longer side),"tonalRange":level|[lo,hi] (shadows 65, highlights 190, midtones [105,150]),"invert":bool=false,"sampleAllLayers":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, has_doc, color_range ), @@ -276,6 +393,107 @@ mod tests { assert_eq!(coverage(&s, 20, 20), 0.0); } + /// Red squares (from `session`), a blue square, a black and a mid-grey strip on white. + fn session_colours() -> Session { + let mut s = session(); + s.edit("paint", |doc, _| { + let bg = doc.layers[0].surface_mut().unwrap(); + bg.fill_rect(Rect::new(5, 20, 15, 28), &[0.0, 0.0, 1.0, 1.0]); + bg.fill_rect(Rect::new(20, 20, 25, 28), &[0.0, 0.0, 0.0, 1.0]); + bg.fill_rect(Rect::new(30, 20, 35, 28), &[0.5, 0.5, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + s + } + + #[test] + fn color_range_several_samples_points_and_invert() { + let mut s = session_colours(); + s.execute("select.colorRange", json!({"colors": ["#ff0000", "#0000ff"], "fuzziness": 10})).unwrap(); + assert_eq!((coverage(&s, 10, 10), coverage(&s, 30, 10), coverage(&s, 10, 24)), (1.0, 1.0, 1.0)); + assert_eq!(coverage(&s, 2, 2), 0.0); + // Eyedropper point on the blue square; `invert` flips the result. + s.execute("select.colorRange", json!({"points": [[7, 22]], "fuzziness": 10, "invert": true})).unwrap(); + assert_eq!((coverage(&s, 10, 24), coverage(&s, 10, 10), coverage(&s, 2, 2)), (0.0, 1.0, 1.0)); + // Localized clusters: picked on the left red square, a 25% range (10 px) leaves the right one. + s.execute("select.colorRange", json!({"points": [[10, 10]], "fuzziness": 10, "localized": true, "range": 25})).unwrap(); + assert_eq!(coverage(&s, 10, 10), 1.0); + assert!(coverage(&s, 13, 10) > 0.5); + assert_eq!(coverage(&s, 30, 10), 0.0); + // One undo step per call. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(coverage(&s, 2, 2), 1.0); + } + + #[test] + fn color_range_presets() { + let mut s = session_colours(); + s.execute("select.colorRange", json!({"select": "reds"})).unwrap(); + assert_eq!((coverage(&s, 10, 10), coverage(&s, 30, 10)), (1.0, 1.0)); + assert_eq!((coverage(&s, 10, 24), coverage(&s, 2, 2), coverage(&s, 32, 24)), (0.0, 0.0, 0.0)); + s.execute("select.colorRange", json!({"select": "blues"})).unwrap(); + assert_eq!((coverage(&s, 10, 24), coverage(&s, 10, 10)), (1.0, 0.0)); + s.execute("select.colorRange", json!({"select": "greens"})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none() || coverage(&s, 10, 10) == 0.0); + // Tones: black is a shadow, white a highlight, 50% grey (128) a midtone. + s.execute("select.colorRange", json!({"select": "shadows"})).unwrap(); + assert_eq!((coverage(&s, 22, 24), coverage(&s, 2, 2), coverage(&s, 32, 24)), (1.0, 0.0, 0.0)); + s.execute("select.colorRange", json!({"select": "highlights"})).unwrap(); + assert_eq!((coverage(&s, 22, 24), coverage(&s, 2, 2)), (0.0, 1.0)); + s.execute("select.colorRange", json!({"select": "midtones", "fuzziness": 0})).unwrap(); + assert_eq!((coverage(&s, 32, 24), coverage(&s, 2, 2), coverage(&s, 22, 24)), (1.0, 0.0, 0.0)); + // A wider shadow range takes the grey too. + s.execute("select.colorRange", json!({"select": "shadows", "tonalRange": 140, "fuzziness": 0})).unwrap(); + assert_eq!(coverage(&s, 32, 24), 1.0); + // Modes combine as for the other selection commands. + s.execute("select.colorRange", json!({"select": "reds", "mode": "add"})).unwrap(); + assert_eq!((coverage(&s, 32, 24), coverage(&s, 10, 10)), (1.0, 1.0)); + } + + #[test] + fn color_range_out_of_gamut_matches_the_gamut_warning() { + let mut s = session(); + s.edit("paint", |doc, _| { + // Saturated sRGB blue is outside the default CMYK proof; mid grey is inside. + let bg = doc.layers[0].surface_mut().unwrap(); + bg.fill_rect(Rect::new(0, 20, 20, 30), &[0.0, 0.0, 1.0, 1.0]); + bg.fill_rect(Rect::new(20, 20, 40, 30), &[0.5, 0.5, 0.5, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("select.colorRange", json!({"select": "outOfGamut"})).unwrap(); + assert_eq!((coverage(&s, 5, 25), coverage(&s, 30, 25)), (1.0, 0.0)); + let warn = s.execute("view.gamutWarning", json!({"on": true})).unwrap(); + let sel = s.active().unwrap().doc.selection.clone().unwrap(); + let mut n = 0; + for y in 0..30 { + for x in 0..40 { + n += usize::from(sel.sample_channel(x, y, 0) > 0.5); + } + } + assert_eq!(warn["outOfGamut"].as_u64(), Some(n as u64)); + } + + #[test] + fn color_range_rejects_bad_params() { + let mut s = session_colours(); + for p in [ + json!({"select": "skin"}), + json!({"select": 3}), + json!({"points": [[100, 2]]}), + json!({"points": [[1]]}), + json!({"points": "here"}), + json!({"colors": "#ff0000"}), + json!({"localized": true}), + json!({"select": "midtones", "tonalRange": [200, 100]}), + json!({"select": "shadows", "tonalRange": 300}), + ] { + assert!(s.execute("select.colorRange", p.clone()).is_err(), "{p}"); + } + assert!(s.active().unwrap().doc.selection.is_none()); + } + #[test] fn modify_commands() { let mut s = session(); diff --git a/crates/ui-egui/src/color_range_ui.rs b/crates/ui-egui/src/color_range_ui.rs new file mode 100644 index 00000000..33b0b639 --- /dev/null +++ b/crates/ui-egui/src/color_range_ui.rs @@ -0,0 +1,707 @@ +//! Select › Color Range… as Photoshop lays it out: the Select menu (Sampled Colors, the six hue +//! families, Highlights / Midtones / Shadows, Out Of Gamut), Fuzziness, Localized Color Clusters +//! with Range, the tonal range of Highlights / Midtones / Shadows, the three eyedroppers (sample, +//! add, subtract; they pick on the preview, Shift adds and Alt subtracts), a Selection / Image +//! preview and Invert. Controls a mode doesn't use are disabled, like Photoshop's. +//! +//! Everything lives in the dialog fields (`ui.dialog.set` drives it). The preview and OK run the +//! same engine command, `select.colorRange`, so the GUI, MCP and scripts select identically; the +//! preview runs it on a small proxy of the document, OK on the document itself (one history step). +//! Cancel never touches the document, so the previous selection stays as it was. + +use std::sync::Arc; + +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use photocraft_doc::{DocId, Document}; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::state::DialogKind; +use crate::theme::Tokens; +use crate::widgets; + +/// The engine command the dialog drives. +pub const COMMAND: &str = "select.colorRange"; + +/// Select menu entries in Photoshop's order (engine value, label). Skin Tones is left out: the +/// engine has no skin-tone model. +pub const SELECTS: &[(&str, &str)] = &[ + ("sampledColors", "Sampled Colors"), + ("reds", "Reds"), + ("yellows", "Yellows"), + ("greens", "Greens"), + ("cyans", "Cyans"), + ("blues", "Blues"), + ("magentas", "Magentas"), + ("highlights", "Highlights"), + ("midtones", "Midtones"), + ("shadows", "Shadows"), + ("outOfGamut", "Out Of Gamut"), +]; + +/// Longest side of the preview thumbnail, in points. +const PREVIEW: u32 = 200; + +/// Which controls the current Select mode uses (the rest are disabled or hidden). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Controls { + /// Fuzziness (Sampled Colors: colour distance; tones: falloff in % of the tonal scale). + pub fuzziness: bool, + /// The eyedroppers and Localized Color Clusters (Sampled Colors only). + pub sampling: bool, + /// The Range slider (Localized Color Clusters on). + pub range: bool, + /// The tonal range sliders (Highlights / Midtones / Shadows). + pub tonal: bool, +} + +fn s<'a>(f: &'a Map, k: &str, d: &'a str) -> &'a str { + f.get(k).and_then(Value::as_str).unwrap_or(d) +} +fn num(f: &Map, k: &str, d: f32) -> f32 { + f.get(k).and_then(Value::as_f64).map_or(d, |v| v as f32) +} +fn flag(f: &Map, k: &str) -> bool { + f.get(k).and_then(Value::as_bool).unwrap_or(false) +} +fn points(f: &Map, k: &str) -> Vec<[f64; 2]> { + let pt = |v: &Value| match v.as_array()?.as_slice() { + [x, y] => Some([x.as_f64()?, y.as_f64()?]), + _ => None, + }; + f.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(pt).collect()).unwrap_or_default() +} + +pub fn controls(f: &Map) -> Controls { + let select = s(f, "select", "sampledColors"); + let sampled = select == "sampledColors"; + let tonal = matches!(select, "highlights" | "midtones" | "shadows"); + Controls { fuzziness: sampled || tonal, sampling: sampled, range: sampled && flag(f, "localized"), tonal } +} + +/// Open the dialog with Photoshop's defaults (Sampled Colors, Fuzziness 40, the foreground colour +/// as the sample until the eyedropper picks one). +pub fn open(app: &mut PhotocraftApp) -> u64 { + let label = photocraft_engine::commands::find(COMMAND).map_or("Color Range…", |c| c.label); + let mut f = Map::new(); + f.insert("__colorRange".into(), json!(true)); + f.insert("__label".into(), json!(label)); + f.insert("select".into(), json!("sampledColors")); + f.insert("fuzziness".into(), json!(40.0)); + f.insert("localized".into(), json!(false)); + f.insert("range".into(), json!(100.0)); + f.insert("toneFuzziness".into(), json!(20.0)); + f.insert("shadowsLevel".into(), json!(65.0)); + f.insert("highlightsLevel".into(), json!(190.0)); + f.insert("midtonesLow".into(), json!(105.0)); + f.insert("midtonesHigh".into(), json!(150.0)); + f.insert("invert".into(), json!(false)); + f.insert("points".into(), json!([])); + f.insert("subtractPoints".into(), json!([])); + f.insert("__tool".into(), json!("sample")); + f.insert("__view".into(), json!("selection")); + app.color_range = None; + app.ui.open_dialog(DialogKind::Command, f) +} + +pub fn owns(f: &Map) -> bool { + f.contains_key("__colorRange") +} + +/// The `select.colorRange` params for the dialog's state: only what the Select mode uses. +pub fn params(f: &Map) -> Value { + let select = s(f, "select", "sampledColors"); + let c = controls(f); + let mut p = Map::new(); + p.insert("select".into(), json!(select)); + p.insert("invert".into(), json!(flag(f, "invert"))); + if c.sampling { + p.insert("fuzziness".into(), json!(num(f, "fuzziness", 40.0))); + let (add, sub) = (points(f, "points"), points(f, "subtractPoints")); + let picked = !add.is_empty() || !sub.is_empty(); + if !add.is_empty() { + p.insert("points".into(), json!(add)); + } + if !sub.is_empty() { + p.insert("subtractPoints".into(), json!(sub)); + } + // Localized clusters need a picked position; until then it is the plain colour range. + if c.range && picked { + p.insert("localized".into(), json!(true)); + p.insert("range".into(), json!(num(f, "range", 100.0))); + } + } else if c.tonal { + p.insert("fuzziness".into(), json!(num(f, "toneFuzziness", 20.0))); + let range = match select { + "shadows" => json!(num(f, "shadowsLevel", 65.0)), + "highlights" => json!(num(f, "highlightsLevel", 190.0)), + _ => { + let (lo, hi) = (num(f, "midtonesLow", 105.0), num(f, "midtonesHigh", 150.0)); + json!([lo.min(hi), lo.max(hi)]) + } + }; + p.insert("tonalRange".into(), range); + } + Value::Object(p) +} + +/// OK: run the command on the document (one history step). +pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + app.color_range = None; + app.run(COMMAND, params(f)) +} + +/// Cached preview: the document proxy (per revision) and the textures drawn from it. +pub struct Preview { + doc: DocId, + revision: u64, + /// Proxy scale: proxy pixel = `k` document pixels. + k: u32, + proxy: Arc, + image: Option, + /// Hash of the params the mask texture shows. + mask_key: u64, + mask: Option, +} + +fn hash(text: &str) -> u64 { + text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3)) +} + +/// The selection mask `params` would make, on the proxy: the engine command run on a scratch +/// session (Out Of Gamut uses the document's own proof setup). +fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Option> { + let area = proxy.bounds(); + if params.get("select").and_then(Value::as_str) == Some("outOfGamut") { + let pv = app.session.color.proof(proxy.id); + let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).ok()?; + let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false); + return Some(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect()); + } + let mut p = params.clone(); + // Eyedropper points are in document pixels; the proxy is 1/k the size. + let (w, h) = (f64::from(area.width().max(1)), f64::from(area.height().max(1))); + if let Some(o) = p.as_object_mut() { + for key in ["points", "subtractPoints"] { + if let Some(Value::Array(a)) = o.get_mut(key) { + for v in a.iter_mut() { + if let Some([x, y]) = v.as_array().map(Vec::as_slice).and_then(|s| match s { + [x, y] => Some([x.as_f64()?, y.as_f64()?]), + _ => None, + }) { + *v = json!([(x / f64::from(k)).floor().clamp(0.0, w - 1.0), (y / f64::from(k)).floor().clamp(0.0, h - 1.0)]); + } + } + } + } + } + let mut s = photocraft_engine::Session::new(); + s.tools = app.session.tools.clone(); + let mut doc = proxy.clone(); + doc.selection = None; + s.add_document(doc, None); + if let Some(id) = app.session.active().and_then(|d| d.active_layer) { + let _ = s.select_layer(id); + } + s.execute(COMMAND, p).ok()?; + let d = s.active()?; + Some(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area)) +} + +/// Refresh the cached proxy / textures for the active document and the dialog's params. Returns +/// (texture to show, its size in proxy pixels, k). +fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map) -> Option<(egui::TextureId, [usize; 2], u32)> { + let (doc_id, revision, doc) = { + let st = app.session.active()?; + (st.doc.id, st.revision, st.doc.clone()) + }; + if !matches!(&app.color_range, Some(p) if p.doc == doc_id && p.revision == revision) { + let side = doc.size.width.max(doc.size.height).max(1); + let k = side.div_ceil(PREVIEW).max(1); + let proxy = Arc::new(crate::proxy::proxy_document(&doc, k)); + app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None }); + } + let image_view = s(f, "__view", "selection") == "image"; + let params = params(f); + let key = hash(¶ms.to_string()).max(1); + let (proxy, k) = { + let p = app.color_range.as_ref()?; + (p.proxy.clone(), p.k) + }; + let (w, h) = (proxy.size.width as usize, proxy.size.height as usize); + if image_view { + if app.color_range.as_ref().is_some_and(|p| p.image.is_none()) { + let thumb = photocraft_compose::thumbnail(&proxy, proxy.size.width.max(proxy.size.height)); + let size = [thumb.width as usize, thumb.height as usize]; + if size != [w, h] || thumb.pixels.len() != w * h * 4 { + return None; + } + let img = egui::ColorImage::from_rgba_unmultiplied(size, &thumb.pixels); + let tex = ctx.load_texture("color-range-image", img, egui::TextureOptions::LINEAR); + if let Some(p) = app.color_range.as_mut() { + p.image = Some(tex); + } + } + return app.color_range.as_ref()?.image.as_ref().map(|t| (t.id(), [w, h], k)); + } + if app.color_range.as_ref().is_some_and(|p| p.mask_key != key || p.mask.is_none()) { + let mask = proxy_mask(app, &proxy, k, ¶ms).unwrap_or_else(|| vec![0.0; w * h]); + if mask.len() != w * h { + return None; + } + let px: Vec = mask.iter().map(|v| Color32::from_gray((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)).collect(); + let img = egui::ColorImage::new([w, h], px); + let p = app.color_range.as_mut()?; + match &mut p.mask { + Some(t) => t.set(img, egui::TextureOptions::LINEAR), + None => p.mask = Some(ctx.load_texture("color-range-mask", img, egui::TextureOptions::LINEAR)), + } + p.mask_key = key; + } + app.color_range.as_ref()?.mask.as_ref().map(|t| (t.id(), [w, h], k)) +} + +/// Photoshop-style radio button. +fn radio(ui: &mut egui::Ui, on: bool, label: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + let (r, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click()); + ui.painter().circle_stroke(r.center(), 5.5, Stroke::new(1.2, if on { t.accent } else { t.text_faint })); + if on { + ui.painter().circle_filled(r.center(), 3.0, t.accent); + } + let l = ui.add(egui::Label::new(egui::RichText::new(label).color(t.text_dim)).sense(Sense::click())); + resp.union(l) + }) + .inner +} + +/// Eyedropper button: the pipette icon with a +/− badge; labelled for accessibility. +fn eyedropper(ui: &mut egui::Ui, badge: &str, selected: bool, label: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let r = crate::icons::button(ui, "pipette", 26.0, selected, label); + if !badge.is_empty() { + let at = r.rect.right_bottom() - vec2(5.0, 6.0); + ui.painter().text(at, egui::Align2::CENTER_CENTER, badge, crate::theme::semibold(12.0), if selected { t.accent_text } else { t.text }); + } + r.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, label)); + r +} + +fn set_points(f: &mut Map, k: &str, pts: &[[f64; 2]]) { + f.insert(k.into(), json!(pts)); +} + +/// Dialog body. +pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { + let t = Tokens::get(ui.ctx()); + let c = controls(f); + let select = s(f, "select", "sampledColors").to_string(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Select:").color(t.text_dim)); + let mut cur: &str = &select; + if widgets::dropdown(ui, "color-range-select", &mut cur, SELECTS, 170.0) { + f.insert("select".into(), json!(cur)); + } + }); + ui.add_space(4.0); + ui.add_enabled_ui(c.sampling, |ui| { + let mut on = flag(f, "localized"); + if widgets::checkbox(ui, &mut on, "Localized Color Clusters").changed() { + f.insert("localized".into(), json!(on)); + } + }); + ui.add_space(4.0); + ui.add_enabled_ui(c.fuzziness, |ui| { + if c.tonal { + let mut v = num(f, "toneFuzziness", 20.0); + if widgets::slider_row(ui, "Fuzziness:", &mut v, 0.0..=100.0, "%", None).changed() { + f.insert("toneFuzziness".into(), json!(v.round())); + } + } else { + let mut v = num(f, "fuzziness", 40.0); + if widgets::slider_row(ui, "Fuzziness:", &mut v, 0.0..=200.0, "", None).changed() { + f.insert("fuzziness".into(), json!(v.round())); + } + } + }); + if c.range { + let mut v = num(f, "range", 100.0); + if widgets::slider_row(ui, "Range:", &mut v, 0.0..=100.0, "%", None).changed() { + f.insert("range".into(), json!(v.round())); + } + } + if c.tonal { + let mut level = |ui: &mut egui::Ui, key: &str, label: &str, d: f32| { + let mut v = num(f, key, d); + if widgets::slider_row(ui, label, &mut v, 0.0..=255.0, "", None).changed() { + f.insert(key.into(), json!(v.round())); + } + }; + match select.as_str() { + "shadows" => level(ui, "shadowsLevel", "Shadows up to:", 65.0), + "highlights" => level(ui, "highlightsLevel", "Highlights from:", 190.0), + _ => { + level(ui, "midtonesLow", "Midtones from:", 105.0); + level(ui, "midtonesHigh", "Midtones to:", 150.0); + } + } + } + ui.add_space(6.0); + ui.horizontal_top(|ui| { + // Preview: the selection as a grayscale mask, or the image to pick colours from. + let box_size = vec2(PREVIEW as f32, PREVIEW as f32); + let (frame, resp) = ui.allocate_exact_size(box_size, Sense::click()); + ui.painter().rect_filled(frame, 0.0, t.canvas); + let shown = preview(app, ui.ctx(), f); + if let Some((tex, [w, h], k)) = shown { + let scale = (box_size.x / w.max(1) as f32).min(box_size.y / h.max(1) as f32); + let img_rect = Rect::from_center_size(frame.center(), vec2(w as f32 * scale, h as f32 * scale)); + ui.painter().image(tex, img_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + // Eyedropper on the preview (Photoshop also samples in the preview area). + if let Some(p) = resp.interact_pointer_pos().filter(|p| resp.clicked() && c.sampling && img_rect.contains(*p)) { + let at = [ + ((p.x - img_rect.left()) / scale).floor().max(0.0) as f64 * f64::from(k) + f64::from(k / 2), + ((p.y - img_rect.top()) / scale).floor().max(0.0) as f64 * f64::from(k) + f64::from(k / 2), + ]; + pick(app, f, at, ui.input(|i| i.modifiers)); + } + } + ui.painter().rect_stroke(frame, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), "Color Range preview")); + ui.add_space(10.0); + ui.vertical(|ui| { + ui.add_enabled_ui(c.sampling, |ui| { + let tool = s(f, "__tool", "sample").to_string(); + ui.horizontal(|ui| { + for (id, badge, label) in [("sample", "", "Eyedropper"), ("add", "+", "Add to Sample"), ("subtract", "−", "Subtract from Sample")] { + if eyedropper(ui, badge, tool == id, label).clicked() { + f.insert("__tool".into(), json!(id)); + } + } + }); + let (add, sub) = (points(f, "points").len(), points(f, "subtractPoints").len()); + let what = match (add, sub) { + (0, 0) => "Sample: foreground colour".to_string(), + (a, 0) => format!("{a} sample{}", if a == 1 { "" } else { "s" }), + (a, s) => format!("{a} sample{}, {s} subtracted", if a == 1 { "" } else { "s" }), + }; + ui.label(egui::RichText::new(what).size(11.0).color(t.text_faint)); + if add + sub > 0 && widgets::secondary_button(ui, "Clear Samples", 0.0).clicked() { + set_points(f, "points", &[]); + set_points(f, "subtractPoints", &[]); + } + }); + ui.add_space(8.0); + let mut inv = flag(f, "invert"); + if widgets::checkbox(ui, &mut inv, "Invert").changed() { + f.insert("invert".into(), json!(inv)); + } + }); + }); + ui.add_space(4.0); + let view = s(f, "__view", "selection").to_string(); + ui.horizontal(|ui| { + if radio(ui, view == "selection", "Selection").clicked() { + f.insert("__view".into(), json!("selection")); + } + ui.add_space(8.0); + if radio(ui, view == "image", "Image").clicked() { + f.insert("__view".into(), json!("image")); + } + }); + if app.session.active().is_none() { + ui.label(egui::RichText::new("Open a document to select a colour range.").color(t.text_faint)); + } +} + +/// An eyedropper click at document pixel `at`: the plain eyedropper replaces the samples, the +/// + one (or Shift) adds one, the − one (or Alt) subtracts one. +pub fn pick(app: &PhotocraftApp, f: &mut Map, at: [f64; 2], mods: egui::Modifiers) { + let Some(st) = app.session.active() else { return }; + let (w, h) = (f64::from(st.doc.size.width.max(1)), f64::from(st.doc.size.height.max(1))); + let [x, y] = at; + let at = [x.clamp(0.0, w - 1.0), y.clamp(0.0, h - 1.0)]; + let tool = if mods.shift { + "add" + } else if mods.alt { + "subtract" + } else { + s(f, "__tool", "sample") + }; + match tool { + "add" => { + let mut p = points(f, "points"); + p.push(at); + set_points(f, "points", &p); + } + "subtract" => { + let mut p = points(f, "subtractPoints"); + p.push(at); + set_points(f, "subtractPoints", &p); + } + _ => { + set_points(f, "points", &[at]); + set_points(f, "subtractPoints", &[]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; + use photocraft_doc::{Color, ColorMode, SampleType, Size}; + use photocraft_geom::Rect as GRect; + + /// 40 × 30: red block (0..20, 0..15), blue block (20..40, 0..15), black and white below. + fn app_with_doc() -> PhotocraftApp { + let mut doc = Document::with_background("cr", Size::new(40, 30), ColorMode::Rgb, SampleType::U8, Color::WHITE); + let bg = doc.layers[0].surface_mut().unwrap(); + bg.fill_rect(GRect::new(0, 0, 20, 15), &[1.0, 0.0, 0.0, 1.0]); + bg.fill_rect(GRect::new(20, 0, 40, 15), &[0.0, 0.0, 1.0, 1.0]); + bg.fill_rect(GRect::new(0, 15, 20, 30), &[0.0, 0.0, 0.0, 1.0]); + let mut s = photocraft_engine::Session::new(); + s.add_document(doc, None); + PhotocraftApp::new(s, crate::Services::default()) + } + + fn coverage(app: &PhotocraftApp, x: i32, y: i32) -> f32 { + app.session.active().unwrap().doc.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)) + } + + fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state( + |ui, app| { + crate::menus::menu_bar(app, ui); + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(2); + h + } + + fn dialog_id(app: &PhotocraftApp) -> u64 { + app.ui.dialogs.iter().find(|d| owns(&d.fields)).map(|d| d.id).expect("Color Range dialog open") + } + + fn set(h: &mut Harness<'static, PhotocraftApp>, key: &str, v: Value) { + let id = dialog_id(h.state()); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(key.into(), v); + h.run_steps(3); + } + + /// OK / Cancel are painted buttons without accessibility labels; the dialog's keys do the same. + fn ok(h: &mut Harness<'static, PhotocraftApp>) { + h.key_press(egui::Key::Enter); + } + + fn click(h: &mut Harness<'static, PhotocraftApp>, at: egui::Pos2) { + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + } + + fn disabled(h: &Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_by_label(label).accesskit_node().is_disabled() + } + + #[test] + fn opens_from_the_select_menu_without_touching_the_document() { + let mut h = harness(app_with_doc()); + let rev = h.state().session.active().unwrap().revision; + h.get_by_label("Select").click(); + h.run_steps(2); + h.get_by_label("Color Range…").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.iter().any(|d| owns(&d.fields)), "Select › Color Range… must open the dialog"); + for label in ["Color Range", "Fuzziness:", "Localized Color Clusters", "Invert", "Selection", "Color Range preview", "Add to Sample"] { + assert!(h.query_by_label(label).is_some(), "missing {label}"); + } + assert_eq!(h.query_all_by_label("Image").count(), 2, "the Image menu and the Image preview radio"); + assert_eq!(h.state().session.active().unwrap().revision, rev, "opening must not edit the document"); + // Automation and the generic command dialog get the same dialog. + let ctx = egui::Context::default(); + let r = crate::menus::invoke(h.state_mut(), &ctx, COMMAND, json!({})).unwrap(); + assert!(r.get("dialog").is_some(), "{r}"); + } + + #[test] + fn select_mode_switches_the_enabled_controls() { + let mut h = harness(app_with_doc()); + open(h.state_mut()); + h.run_steps(4); + // Sampled Colors: Fuzziness, eyedroppers and Localized Color Clusters; Range only when localized. + assert!(!disabled(&h, "Fuzziness:") && !disabled(&h, "Localized Color Clusters") && !disabled(&h, "Add to Sample")); + assert!(h.query_by_label("Range:").is_none()); + h.get_by_label("Localized Color Clusters").click(); + h.run_steps(2); + assert!(h.query_by_label("Range:").is_some(), "Range shows with Localized Color Clusters"); + // A hue family: no Fuzziness, eyedroppers or clusters (Photoshop disables them). + set(&mut h, "select", json!("reds")); + assert!(disabled(&h, "Fuzziness:") && disabled(&h, "Localized Color Clusters") && disabled(&h, "Add to Sample")); + assert!(h.query_by_label("Range:").is_none()); + assert!(!disabled(&h, "Invert")); + // Tones: Fuzziness and the tonal range. + set(&mut h, "select", json!("shadows")); + assert!(!disabled(&h, "Fuzziness:") && disabled(&h, "Localized Color Clusters")); + assert!(h.query_by_label("Shadows up to:").is_some()); + set(&mut h, "select", json!("midtones")); + assert!(h.query_by_label("Midtones from:").is_some() && h.query_by_label("Midtones to:").is_some()); + set(&mut h, "select", json!("outOfGamut")); + assert!(disabled(&h, "Fuzziness:") && h.query_by_label("Midtones from:").is_none()); + assert_eq!( + controls(&serde_json::from_value(json!({"select": "highlights"})).unwrap()), + Controls { fuzziness: true, sampling: false, range: false, tonal: true } + ); + } + + #[test] + fn dialog_shrinks_back_when_a_mode_needs_fewer_controls() { + let mut h = harness(app_with_doc()); + open(h.state_mut()); + h.run_steps(4); + let area = egui::Id::new(("dialog", dialog_id(h.state()))); + let height = |h: &Harness<'static, PhotocraftApp>| h.ctx.memory(|m| m.area_rect(area)).map_or(0.0, |r| r.height()); + set(&mut h, "select", json!("outOfGamut")); + let short = height(&h); + set(&mut h, "select", json!("midtones")); + let tall = height(&h); + assert!(tall > short + 40.0, "Midtones adds two sliders: {short} → {tall}"); + set(&mut h, "select", json!("outOfGamut")); + let back = height(&h); + assert!((back - short).abs() < 1.0, "the dialog must shrink back, not keep an empty band above OK: {short} → {tall} → {back}"); + } + + #[test] + fn ok_applies_one_undo_step() { + let mut h = harness(app_with_doc()); + h.state_mut().run("select.rect", json!({"x": 0, "y": 20, "width": 5, "height": 5})).unwrap(); + let past = h.state().session.active().unwrap().history.past_len(); + open(h.state_mut()); + h.run_steps(4); + set(&mut h, "select", json!("reds")); + ok(&mut h); + h.run_steps(2); + assert!(!h.state().ui.dialogs.iter().any(|d| owns(&d.fields)), "OK closes the dialog"); + let app = h.state(); + assert_eq!((coverage(app, 5, 5), coverage(app, 30, 5), coverage(app, 2, 22)), (1.0, 0.0, 0.0)); + assert_eq!(app.session.active().unwrap().history.past_len(), past + 1, "one history step"); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Color Range")); + h.state_mut().run("edit.undo", json!({})).unwrap(); + let app = h.state(); + assert_eq!((coverage(app, 5, 5), coverage(app, 2, 22)), (0.0, 1.0), "undo restores the previous selection"); + } + + #[test] + fn cancel_keeps_the_previous_selection() { + let mut h = harness(app_with_doc()); + h.state_mut().run("select.rect", json!({"x": 0, "y": 20, "width": 5, "height": 5})).unwrap(); + let (rev, past) = { + let st = h.state().session.active().unwrap(); + (st.revision, st.history.past_len()) + }; + open(h.state_mut()); + h.run_steps(4); + set(&mut h, "select", json!("blues")); + h.get_by_label("Invert").click(); + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().ui.dialogs.is_empty()); + let app = h.state(); + let st = app.session.active().unwrap(); + assert_eq!((st.revision, st.history.past_len()), (rev, past)); + assert_eq!((coverage(app, 2, 22), coverage(app, 30, 5), coverage(app, 5, 5)), (1.0, 0.0, 0.0)); + } + + #[test] + fn invert_flips_the_selection() { + let mut h = harness(app_with_doc()); + open(h.state_mut()); + h.run_steps(4); + set(&mut h, "select", json!("reds")); + h.get_by_label("Invert").click(); + h.run_steps(2); + let id = dialog_id(h.state()); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields.get("invert"), Some(&json!(true))); + ok(&mut h); + h.run_steps(2); + let app = h.state(); + assert_eq!((coverage(app, 5, 5), coverage(app, 30, 5), coverage(app, 5, 22), coverage(app, 30, 22)), (0.0, 1.0, 1.0, 1.0)); + } + + #[test] + fn eyedroppers_pick_on_the_preview() { + let mut h = harness(app_with_doc()); + open(h.state_mut()); + h.run_steps(4); + // 40 × 30 at 200 pt: 5 pt per pixel; the image is centred vertically (150 pt tall). + let at = |h: &Harness<'static, PhotocraftApp>, x: f32, y: f32| { + h.get_by_label("Color Range preview").rect().left_top() + egui::vec2(x * 5.0 + 2.5, 25.0 + y * 5.0 + 2.5) + }; + let p = at(&h, 30.0, 5.0); + click(&mut h, p); + h.run_steps(2); + let f = |h: &Harness<'static, PhotocraftApp>| h.state().ui.dialogs.iter().find(|d| owns(&d.fields)).unwrap().fields.clone(); + assert_eq!(points(&f(&h), "points"), vec![[30.0, 5.0]]); + // Add to Sample: the red block too; the selection then covers both. + h.get_by_label("Add to Sample").click(); + h.run_steps(1); + let p = at(&h, 5.0, 5.0); + click(&mut h, p); + h.run_steps(2); + assert_eq!(points(&f(&h), "points").len(), 2); + // Subtract from Sample: take the blue back out. + h.get_by_label("Subtract from Sample").click(); + h.run_steps(1); + let p = at(&h, 35.0, 10.0); + click(&mut h, p); + h.run_steps(2); + assert_eq!(points(&f(&h), "subtractPoints"), vec![[35.0, 10.0]]); + let p = params(&f(&h)); + assert_eq!(p["select"], "sampledColors"); + ok(&mut h); + h.run_steps(2); + let app = h.state(); + assert_eq!((coverage(app, 5, 5), coverage(app, 30, 5), coverage(app, 5, 22)), (1.0, 0.0, 0.0)); + } + + #[test] + fn params_send_only_what_the_mode_uses() { + let mut app = app_with_doc(); + let id = open(&mut app); + let mut f = app.ui.dialog_mut(id).unwrap().fields.clone(); + assert_eq!(params(&f), json!({"select": "sampledColors", "invert": false, "fuzziness": 40.0})); + // Localized needs a picked point. + f.insert("localized".into(), json!(true)); + assert!(params(&f).get("localized").is_none()); + pick(&app, &mut f, [3.0, 4.0], egui::Modifiers::NONE); + pick(&app, &mut f, [99.0, -4.0], egui::Modifiers::SHIFT); + pick(&app, &mut f, [1.0, 1.0], egui::Modifiers::ALT); + assert_eq!( + params(&f), + json!({"select": "sampledColors", "invert": false, "fuzziness": 40.0, "points": [[3.0, 4.0], [39.0, 0.0]], "subtractPoints": [[1.0, 1.0]], "localized": true, "range": 100.0}) + ); + f.insert("select".into(), json!("midtones")); + f.insert("midtonesLow".into(), json!(160.0)); + assert_eq!(params(&f), json!({"select": "midtones", "invert": false, "fuzziness": 20.0, "tonalRange": [150.0, 160.0]})); + f.insert("select".into(), json!("highlights")); + assert_eq!(params(&f)["tonalRange"], json!(190.0)); + f.insert("select".into(), json!("cyans")); + assert_eq!(params(&f), json!({"select": "cyans", "invert": false})); + // Every Select entry is accepted by the engine. + for (v, _) in SELECTS { + f.insert("select".into(), json!(v)); + app.run(COMMAND, params(&f)).unwrap(); + } + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 957b5389..b7a94f18 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -93,6 +93,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::variables_ui::owns(&fields) => crate::variables_ui::body(app, ui, &mut fields), DialogKind::Command if crate::file_ui::owns(&fields) => crate::file_ui::body(app, ui, &mut fields), DialogKind::Command if crate::color_picker_ui::owns(&fields) => crate::color_picker_ui::body(ui, &mut fields), + DialogKind::Command if crate::color_range_ui::owns(&fields) => crate::color_range_ui::body(app, ui, &mut fields), DialogKind::Command if crate::prefs_ui::owns(&fields) => crate::prefs_ui::body(app, ui, &mut fields), DialogKind::Command if fields.contains_key("__export") => crate::export_dialog::body(app, ui, &mut fields), DialogKind::Command if fields.contains_key("__sizing") => crate::sizing::body(ui, &mut fields), @@ -106,7 +107,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } ui.add_space(8.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Align::Min, not Center: a centred row fills the height left over from last frame's + // (larger) size, so a dialog whose body gets shorter would never shrink back. + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { ui.spacing_mut().item_spacing.x = 10.0; if matches!(d.kind, DialogKind::About | DialogKind::Error) { if crate::widgets::primary_button(ui, "OK", 84.0).clicked() { @@ -155,6 +158,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { Some(false) => { app.ui.close_dialog(d.id); app.filter_preview = None; + app.color_range = None; } None => {} } @@ -186,6 +190,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { DialogKind::Command if crate::variables_ui::owns(&d.fields) => crate::variables_ui::confirm(app, &d.fields), DialogKind::Command if crate::file_ui::owns(&d.fields) => crate::file_ui::confirm(app, &d.fields), DialogKind::Command if crate::color_picker_ui::owns(&d.fields) => crate::color_picker_ui::confirm(app, &d.fields), + DialogKind::Command if crate::color_range_ui::owns(&d.fields) => crate::color_range_ui::confirm(app, &d.fields), DialogKind::Command if crate::prefs_ui::owns(&d.fields) => crate::prefs_ui::confirm(app, &d.fields), DialogKind::Command if d.fields.contains_key("__export") => crate::export_dialog::confirm(app, &d.fields), DialogKind::Command => { @@ -200,8 +205,12 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { } /// Open the parameter dialog of `command`: the adjustment editor for `image.adjustments.*`, the -/// schema dialog for filters, otherwise a bare confirm dialog. +/// schema dialog for filters, the Color Range dialog for `select.colorRange`, otherwise a bare +/// confirm dialog. pub fn open_command_dialog(app: &mut PhotocraftApp, command: &str, label: &str) -> u64 { + if command == crate::color_range_ui::COMMAND { + return crate::color_range_ui::open(app); + } if let Some(id) = crate::adjust_dialog::open(app, command) { return id; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ad2c0f9f..6d63b277 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,6 +20,7 @@ pub mod channel_view; pub mod channels_panel; pub mod chrome_ui; pub mod color_picker_ui; +pub mod color_range_ui; pub mod comps_ui; pub mod control; pub mod dialogs; @@ -213,6 +214,8 @@ pub struct PhotocraftApp { /// Selection outline cache: (doc, revision, segments). /// Live filter preview (proxy document with the filter applied). pub(crate) filter_preview: Option, + /// Select › Color Range dialog preview (proxy document + mask / image textures). + pub(crate) color_range: Option, /// Synthetic input events queued by automation (`ui.click`, `ui.key`, …), injected next frame. pub(crate) synthetic: Vec, /// True only while a frame is processing synthetic automation input. It @@ -293,6 +296,7 @@ impl PhotocraftApp { proxy_uploaded: None, outline_cache: None, filter_preview: None, + color_range: None, synthetic: Vec::new(), automation_input: false, channel_thumbs: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index db20638b..28569f61 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -303,6 +303,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: Ok(r) } // Photoshop's "Select and Mask…" is the engine's select.refineEdge. + // Select › Color Range… from the menu: the dialog (with params: the engine directly). + "select.colorRange" if params.as_object().is_none_or(|o| o.is_empty()) => Ok(json!({"dialog": crate::color_range_ui::open(app)})), "select.selectAndMask" => Ok(json!({"dialog": crate::filter_dialog::open(app, "select.refineEdge")})), // Select › Transform Selection from the menu: the interactive box (with params: the engine). "select.transformSelection" if params.as_object().is_none_or(|o| o.is_empty()) => {