Select › Color Range: the dialog, Select presets, eyedroppers, Localized Color Clusters, Invert (#80)
* Select › Color Range: Select presets, samples, localized clusters, invert select.colorRange only did one sampled colour. Add the dialog's Select presets (Reds … Magentas, Highlights / Midtones / Shadows, Out of Gamut), several samples (colors / eyedropper points), the Subtract from Sample eyedropper (subtractPoints), Localized Color Clusters with Range, tonal Range, and Invert. Bad params are rejected without touching the selection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Select › Color Range… opens a real dialog The menu item ran select.colorRange immediately with the foreground colour. It now opens a Color Range dialog: Select (Sampled Colors, the hue families, Highlights / Midtones / Shadows, Out Of Gamut), Fuzziness, Localized Color Clusters with Range, the tonal range sliders, the three eyedroppers (on the preview; Shift adds, Alt subtracts), a grayscale selection / image preview, Invert, OK and Cancel. Controls a mode doesn't use are disabled. The preview and OK run the same engine command, so the GUI, CLI and MCP select identically; OK is one history step and Cancel leaves the selection untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Dialogs shrink back when their body gets shorter The OK / Cancel row was laid out right-to-left with Align::Center, so it filled the height left over from the previous frame's size and the dialog could only ever grow. In the Color Range dialog, switching from Midtones (two extra sliders) to Out Of Gamut or Sampled Colors left an empty band around the buttons. Aligning the row to the top keeps it exactly as tall as the buttons, so the dialog fits its content again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: storybored379 <storybored379@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4f8da9365a
commit
f5f0837aab
|
|
@ -370,6 +370,100 @@ pub fn color_range(px: &[[f32; 4]], color: [f32; 3], fuzziness: f32) -> Vec<f32>
|
|||
.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<f32>) -> Vec<f32> {
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
(0..w * h).map(|i| if (5..15).contains(&(i % w)) && (5..15).contains(&(i / w)) { 1.0 } else { 0.0 }).collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Vec<sel::RangeSample>> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
|||
"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();
|
||||
|
|
|
|||
|
|
@ -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<String, Value>, k: &str, d: &'a str) -> &'a str {
|
||||
f.get(k).and_then(Value::as_str).unwrap_or(d)
|
||||
}
|
||||
fn num(f: &Map<String, Value>, k: &str, d: f32) -> f32 {
|
||||
f.get(k).and_then(Value::as_f64).map_or(d, |v| v as f32)
|
||||
}
|
||||
fn flag(f: &Map<String, Value>, k: &str) -> bool {
|
||||
f.get(k).and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
fn points(f: &Map<String, Value>, 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> Result<Value, String> {
|
||||
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<Document>,
|
||||
image: Option<egui::TextureHandle>,
|
||||
/// Hash of the params the mask texture shows.
|
||||
mask_key: u64,
|
||||
mask: Option<egui::TextureHandle>,
|
||||
}
|
||||
|
||||
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<Vec<f32>> {
|
||||
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<String, Value>) -> 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<Color32> = 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<String, Value>, 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<String, Value>) {
|
||||
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<String, Value>, 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Value, String> {
|
|||
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<Value, String> {
|
|||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<filter_dialog::FilterPreview>,
|
||||
/// Select › Color Range dialog preview (proxy document + mask / image textures).
|
||||
pub(crate) color_range: Option<color_range_ui::Preview>,
|
||||
/// Synthetic input events queued by automation (`ui.click`, `ui.key`, …), injected next frame.
|
||||
pub(crate) synthetic: Vec<egui::Event>,
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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()) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue