diff --git a/crates/algo/src/tone.rs b/crates/algo/src/tone.rs index 8592b3dda..2632d4a29 100644 --- a/crates/algo/src/tone.rs +++ b/crates/algo/src/tone.rs @@ -61,7 +61,14 @@ pub use shadows_highlights::shadows_highlights; /// Returns the selection mask (what the dialog previews). pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: f32, saturation: f32, lightness: f32) -> Vec { let mask = crate::selection::color_range(px, color, fuzziness); - for (q, &k) in px.iter_mut().zip(&mask) { + replace_color_masked(px, &mask, hue, saturation, lightness); + mask +} + +/// Replace Color's hue, saturation and lightness shift, applied to each pixel as far as `mask` +/// (`0..=1`, one per pixel: the colour match, localized or not) covers it. +pub fn replace_color_masked(px: &mut [[f32; 4]], mask: &[f32], hue: f32, saturation: f32, lightness: f32) { + for (q, &k) in px.iter_mut().zip(mask) { if k <= 0.0 { continue; } @@ -74,7 +81,6 @@ pub fn replace_color(px: &mut [[f32; 4]], color: [f32; 3], fuzziness: f32, hue: q[i] = (c[i] + (o[i] - c[i]) * k).clamp(0.0, 1.0); } } - mask } /// Lab mean and standard deviation of the opaque pixels, `[L, a, b]` each. diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 738ef921f..c1393bb94 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -334,14 +334,54 @@ fn shadows_highlights_params(p: &Value) -> ShadowsHighlights { } fn replace_color(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "image.adjustments.replaceColor"; let c = crate::commands::color_param(p, "color", s.tools.foreground); let fuzz = num(p, "fuzziness", 40.0).clamp(0.0, 200.0); let (h, sat, l) = (num(p, "hue", 0.0).clamp(-180.0, 180.0), num(p, "saturation", 0.0).clamp(-100.0, 100.0), num(p, "lightness", 0.0).clamp(-100.0, 100.0)); - rgba_edit(s, "Replace Color", p, |px, _| { - tone::replace_color(px, [c[0], c[1], c[2]], fuzz, h, sat, l); + // Localized Color Clusters, as in Select › Color Range: the colour only matches within Range + // (a percentage of the canvas's longer side) of where it was picked. + let points = sample_points(p).map_err(|m| bad(CMD, m))?; + let localized = if p.get("localized").and_then(Value::as_bool).unwrap_or(false) { + if points.is_empty() { + return Err(bad(CMD, "localized clusters need eyedropper `points` (where the colour was picked)")); + } + let canvas = s.active().ok_or(EngineError::NoDocument)?.doc.bounds(); + Some(num(p, "range", 100.0).clamp(0.0, 100.0) / 100.0 * canvas.width().max(canvas.height()) as f32) + } else { + None + }; + rgba_edit(s, "Replace Color", p, |px, r| match localized { + Some(radius) => { + let samples: Vec<_> = points + .iter() + .map(|&(x, y)| photocraft_algo::selection::RangeSample { color: [c[0], c[1], c[2]], at: Some((x - r.x0 as f32, y - r.y0 as f32)) }) + .collect(); + let mask = photocraft_algo::selection::color_range_samples(px, r.width() as usize, &samples, fuzz, Some(radius)); + tone::replace_color_masked(px, &mask, h, sat, l); + } + None => { + tone::replace_color(px, [c[0], c[1], c[2]], fuzz, h, sat, l); + } }) } +/// The `points` of a command (`[[x, y], …]` in document pixels), or a message saying what is wrong. +fn sample_points(p: &Value) -> std::result::Result, String> { + let Some(pts) = p.get("points") else { return Ok(Vec::new()) }; + let pts = pts.as_array().ok_or("`points` must be [[x, y], …]")?; + pts.iter() + .map(|pt| { + pt.as_array() + .and_then(|a| match a.as_slice() { + [x, y] => Some((x.as_f64()? as f32, y.as_f64()? as f32)), + _ => None, + }) + .filter(|(x, y)| x.is_finite() && y.is_finite()) + .ok_or_else(|| format!("bad point {pt} in `points` (want [x, y])")) + }) + .collect() +} + /// Straight RGBA pixels and an optional coverage mask for a document's layer (or its composite). /// Pixels plus an optional coverage mask. type Sample = (Vec<[f32; 4]>, Option>); @@ -454,7 +494,7 @@ pub fn specs() -> Vec { "image.adjustments.replaceColor", "Replace Color…", ["Image", "Adjustments"], - r##"{"color":color,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##, + r##"{"localized":bool=false,"color":color,"fuzziness":0..200=40,"range":0..100=100,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"points":[[x,y],…]?} (color: "#rrggbb", default the foreground colour; Localized Color Clusters: "points" where the colour was picked, "range" % of the longer side)"##, has_pixels, replace_color ), diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 9f8f07db6..8fa053e86 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -503,3 +503,38 @@ fn a_targeted_mask_enables_editing_it_on_any_layer() { assert!(s.execute("image.adjustments.invert", mask).is_err()); assert_eq!(rgba(&s, 23, 3), pixels); } + +#[test] +fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() { + // The same cream on the left (x < 8) and the right (x ≥ 40), grey between. + let cream = [0.9, 0.88, 0.79, 1.0]; + let mut s = session(8, "rgb"); + paint(&mut s, |x, _| if !(8..40).contains(&x) { cream } else { [0.5, 0.5, 0.5, 1.0] }); + let base = |s: &Session| (rgba(s, 3, 16), rgba(s, 44, 16), rgba(s, 20, 16)); + let (left, right, grey) = base(&s); + let shift = json!({"color": "#e6e0c9", "fuzziness": 20, "hue": 180, "saturation": 60}); + // Without clusters the colour is replaced everywhere (unchanged behaviour). + s.execute("image.adjustments.replaceColor", shift.clone()).unwrap(); + assert!(changed(rgba(&s, 3, 16), left) > 0.05 && changed(rgba(&s, 44, 16), right) > 0.05); + s.execute("edit.undo", json!({})).unwrap(); + // Picked on the left with Range 50 % (24 px): only the left cream changes. + let mut local = shift.clone(); + local["points"] = json!([[2, 16]]); + local["localized"] = json!(true); + local["range"] = json!(50); + s.execute("image.adjustments.replaceColor", local.clone()).unwrap(); + assert!(changed(rgba(&s, 3, 16), left) > 0.05, "near the sample"); + assert_eq!(rgba(&s, 44, 16), right, "the same colour far from the sample"); + assert_eq!(rgba(&s, 20, 16), grey); + s.execute("edit.undo", json!({})).unwrap(); + // Inside a selection that doesn't start at the origin the sample stays where it was picked. + s.execute("select.rect", json!({"x": 1, "y": 4, "width": 46, "height": 24})).unwrap(); + s.execute("image.adjustments.replaceColor", local).unwrap(); + assert!(changed(rgba(&s, 3, 16), left) > 0.05); + assert_eq!(rgba(&s, 44, 16), right); + // Clusters need a place to be near. + let err = s.execute("image.adjustments.replaceColor", json!({"localized": true})).unwrap_err(); + assert!(err.to_string().contains("points"), "{err}"); + let err = s.execute("image.adjustments.replaceColor", json!({"points": [[1]]})).unwrap_err(); + assert!(err.to_string().contains("bad point"), "{err}"); +} diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 4c19b8943..90073a550 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -545,6 +545,10 @@ pub(crate) fn label(key: &str) -> String { } pub(crate) fn source_label(key: &str) -> String { + // Photoshop's name for the localized colour matching (Replace Color, Color Range). + if key == "localized" { + return "Localized Color Clusters".into(); + } // camelCase → "Camel Case" let mut s = String::new(); for (i, ch) in key.chars().enumerate() { @@ -616,6 +620,8 @@ pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64) - && let Some(d) = app.ui.dialog_mut(id) { d.fields.insert("color".into(), json!(crate::color_picker_ui::hex(rgb))); + // Where it was picked: Localized Color Clusters match the colour near this point (#4457). + d.fields.insert("points".into(), json!([[x.floor(), y.floor()]])); } true } @@ -645,6 +651,8 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { f.insert(p.key, json!(count)); continue; } + // Replace Color's Range only applies with Localized Color Clusters on, as in Photoshop. + let enabled = !(cmd == REPLACE_COLOR && p.key == "range") || f.get("localized").and_then(Value::as_bool) == Some(true); let unit = if is_pixel_param_of(&cmd, &p.key) { "px" } else if p.key == "angle" { @@ -654,28 +662,33 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { } else { "" }; - if uses_angle_dial(&cmd, &p.key) { - crate::widgets::angle_dial_row(ui, &label(&p.key), &mut v, min..=max); - ui.add_space(4.0); - } else if uses_logarithmic_slider(min, max) { - // Keep one numeric field for direct entry and one log-scaled slider for - // wide ranges. slider_row would add a second, linear slider (#146). - ui.horizontal(|ui| { - ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0); - }); - }); - let mut lv = v.clamp(min, max).ln(); - if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() { - v = lv.exp(); + ui.scope(|ui| { + if !enabled { + ui.disable(); } - ui.add_space(4.0); - } else { - // A double-click on the slider puts it back to the filter's default. - let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None }; - crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures); - } + if uses_angle_dial(&cmd, &p.key) { + crate::widgets::angle_dial_row(ui, &label(&p.key), &mut v, min..=max); + ui.add_space(4.0); + } else if uses_logarithmic_slider(min, max) { + // Keep one numeric field for direct entry and one log-scaled slider for + // wide ranges. slider_row would add a second, linear slider (#146). + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0); + }); + }); + let mut lv = v.clamp(min, max).ln(); + if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() { + v = lv.exp(); + } + ui.add_space(4.0); + } else { + // A double-click on the slider puts it back to the filter's default. + let gestures = crate::widgets::RowGestures { reset: Some(default), wheel_step: None }; + crate::widgets::slider_row_with(ui, &label(&p.key), &mut v, min..=max, unit, None, gestures); + } + }); // Small ranges (0–1 thresholds and centres) keep the two decimals their field shows. let scale = if max - min <= 10.0 { 100.0 } else { 10.0 }; f.insert(p.key, json!((v * scale).round() / scale)); @@ -2169,6 +2182,8 @@ mod tests { assert!(color_eyedropper_armed(&app)); assert!(color_eyedropper_sample(&mut app, 4.0, 4.0)); assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); + // #4457: where it was picked, for Localized Color Clusters. + assert_eq!(app.ui.dialogs.last().unwrap().fields["points"], json!([[4.0, 4.0]])); // Off-image: the click is consumed, nothing changes and the eyedropper stays armed. assert!(color_eyedropper_sample(&mut app, -1.0, 4.0)); assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); @@ -2199,4 +2214,37 @@ mod tests { h.run_steps(2); assert!(!color_eyedropper_armed(h.state())); } + + /// #4457: Localized Color Clusters and its Range, as in Photoshop's dialog; the sampled point + /// and both settings reach the command. + #[test] + fn replace_color_localized_color_clusters() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + // Left half red, right half red too: localized on the left point, the right half stays. + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 40, "height": 10, "background": "#ff0000"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), REPLACE_COLOR).unwrap(); + h.run_steps(3); + let _ = h.get_by_label("Localized Color Clusters"); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(true)); + assert!(color_eyedropper_sample(h.state_mut(), 2.0, 5.0)); + h.get_by_label("Localized Color Clusters").click(); + h.run_steps(2); + let f = h.state().ui.dialogs.last().unwrap().fields.clone(); + assert_eq!((f["localized"].clone(), f["points"].clone()), (json!(true), json!([[2.0, 5.0]]))); + // The command with the dialog's fields, small range and a hue shift: only near (2, 5). + let mut p: Map = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); + p.insert("range".into(), json!(10)); + p.insert("hue".into(), json!(120)); + h.state_mut().run(REPLACE_COLOR, Value::Object(p)).unwrap(); + let doc = &h.state().session.active().unwrap().doc; + let px = |x: i32| doc.layers[0].surface().unwrap().read_region(photocraft_geom::Rect::new(x, 5, x + 1, 6)); + assert!(px(2)[1] > 0.5, "near the point: shifted to green {:?}", px(2)); + assert!(px(38)[0] > 0.9 && px(38)[1] < 0.1, "far away: still red {:?}", px(38)); + } }