From aca4544af5fb574a7038453f867b1830d6a7f251 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:15:19 +0100 Subject: [PATCH] Smudge: Mode dropdown in the options bar (#12959) The Smudge options bar gets Mode: (Normal, Darken, Lighten, Hue, Saturation, Color, Luminosity; tooltip "Painting Mode"), first after the brush controls. `paint.smudge` takes `mode` and refuses any other name; the stroke sends it and Reset Tool sets Normal. With a Mode other than Normal each dab's smudged colour is blended with the pixel from before the stroke the way a paint colour would be, and laid down through the tip's coverage. 8-bit RGB only: other colour modes and depths were not measured and keep smudging as Normal. Blur and Sharpen are unchanged. Co-Authored-By: Claude Fable 5.2 --- crates/engine/src/cmd/retouch_cmds.rs | 4 +- crates/engine/src/cmd/retouch_cmds/dab.rs | 33 ++- .../src/cmd/retouch_cmds/smudge_mode_tests.rs | 232 ++++++++++++++++++ crates/paint/src/retouch.rs | 105 ++++++-- crates/ui-egui/src/smudge_mode_bar_tests.rs | 116 +++++++++ crates/ui-egui/src/state/tool_options.rs | 3 + crates/ui-egui/src/tools/retouch_ui.rs | 33 ++- crates/ui-egui/src/tools/tool_reset.rs | 2 +- 8 files changed, 499 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/cmd/retouch_cmds/smudge_mode_tests.rs create mode 100644 crates/ui-egui/src/smudge_mode_bar_tests.rs diff --git a/crates/engine/src/cmd/retouch_cmds.rs b/crates/engine/src/cmd/retouch_cmds.rs index 3b99d2d5db..3e444fda5b 100644 --- a/crates/engine/src/cmd/retouch_cmds.rs +++ b/crates/engine/src/cmd/retouch_cmds.rs @@ -383,7 +383,9 @@ pub fn specs() -> Vec { label: "Smudge", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false"#), + params: brush_params!( + r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false,"mode":"normal|darken|lighten|hue|saturation|color|luminosity"="normal" (how the smudged colour replaces the pixel; 8-bit RGB, else Normal)"# + ), enabled: has_pixel_layer, run: |s, p| dab_cmd(s, p, "paint.smudge"), journal: true, diff --git a/crates/engine/src/cmd/retouch_cmds/dab.rs b/crates/engine/src/cmd/retouch_cmds/dab.rs index c9b4c27eb3..14a2e96204 100644 --- a/crates/engine/src/cmd/retouch_cmds/dab.rs +++ b/crates/engine/src/cmd/retouch_cmds/dab.rs @@ -1,8 +1,8 @@ use super::sampling::{AllLayers, sample_all_layers}; use super::{ - BrushSettings, Document, EngineError, Footprint, IndexedParallelIterator, LayerId, ParallelIterator, ParallelSliceMut, PixelFormat, Rect, Region, Result, - Session, Smudge, Stroke, StrokePoint, Surface, ToneRange, Value, alpha_index, apply_dab_stroke, bad, damage_json, dodge_burn, flag, from_rgba_into, - has_pixel_layer, json, local_blur, local_sharpen, num, parse_brush, run_stroke, sponge, string, to_rgba, + BlendMode, BrushSettings, Document, EngineError, Footprint, IndexedParallelIterator, LayerId, ParallelIterator, ParallelSliceMut, PixelFormat, Rect, + Region, Result, Session, Smudge, Stroke, StrokePoint, Surface, ToneRange, Value, alpha_index, apply_dab_stroke, bad, blend_from_str, damage_json, + dodge_burn, flag, from_rgba_into, has_pixel_layer, json, local_blur, local_sharpen, num, parse_brush, run_stroke, sponge, string, to_rgba, }; // --------------------------------------------------------------------------------------------- @@ -129,7 +129,18 @@ pub(super) enum DabTool { Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool }, Sponge { saturate: bool, vibrance: bool }, Focus { sharpen: bool, strength: f32, protect: bool, sigma: f32 }, - Smudge { strength: f32, finger: Option<[f32; 4]>, max_size: f32 }, + Smudge { strength: f32, finger: Option<[f32; 4]>, max_size: f32, mode: BlendMode }, +} + +/// `paint.smudge`'s `mode` (#12959): the options bar's seven Painting Modes, Normal when absent. +fn smudge_mode(p: &Value, cmd: &str) -> Result { + use BlendMode::{Color, Darken, Hue, Lighten, Luminosity, Normal, Saturation}; + let Some(v) = p.get("mode") else { return Ok(Normal) }; + let named = v.as_str().and_then(blend_from_str).filter(|m| matches!(m, Normal | Darken | Lighten | Hue | Saturation | Color | Luminosity)); + named.ok_or_else(|| { + let given = v.as_str().map_or_else(|| v.to_string(), str::to_owned); + bad(cmd, format!("unknown mode `{given}` (normal|darken|lighten|hue|saturation|color|luminosity)")) + }) } /// One dab's edit of the working copy. @@ -200,7 +211,8 @@ impl DabTool { } "paint.smudge" => { let finger = flag(p, "fingerPainting", false).then_some(s.tools.foreground); - (Self::Smudge { strength: num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0, finger, max_size: stroke.brush.size }, "Smudge Tool") + let strength = num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0; + (Self::Smudge { strength, finger, max_size: stroke.brush.size, mode: smudge_mode(p, cmd)? }, "Smudge Tool") } o => return Err(bad(o, "not a sequential-dab tool")), }) @@ -255,10 +267,13 @@ impl DabTool { }); }) } - Self::Smudge { strength, finger, max_size } => { + Self::Smudge { strength, finger, max_size, mode } => { let finger_px = finger.map(|fg| photocraft_raster::from_rgba(&fmt, fg)); + // The Mode was measured in 8-bit RGB (#12959); other colour modes and depths smudge as Normal. + let measured = fmt.mode == photocraft_color::ColorMode::Rgb && fmt.sample == photocraft_color::SampleType::U8; + let mode = if measured { mode } else { BlendMode::Normal }; // Premultiplied mixing: transparent pixels carry no colour, so no dark fringes. - let mut sm = Smudge::new(strength, finger_px, max_size).with_alpha(alpha_index(&fmt)); + let mut sm = Smudge::new(strength, finger_px, max_size).with_alpha(alpha_index(&fmt)).with_mode(mode); Box::new(move |work, fp| sm.dab(work, fp)) } } @@ -700,3 +715,7 @@ mod dodge_refuses_indexed_tests; #[cfg(test)] #[path = "sharpen_refuses_indexed_tests.rs"] mod sharpen_refuses_indexed_tests; + +#[cfg(test)] +#[path = "smudge_mode_tests.rs"] +mod smudge_mode_tests; diff --git a/crates/engine/src/cmd/retouch_cmds/smudge_mode_tests.rs b/crates/engine/src/cmd/retouch_cmds/smudge_mode_tests.rs new file mode 100644 index 0000000000..acfa963b8d --- /dev/null +++ b/crates/engine/src/cmd/retouch_cmds/smudge_mode_tests.rs @@ -0,0 +1,232 @@ +//! #12959: `paint.smudge` takes the options bar's Mode (Normal, Darken, Lighten, Hue, Saturation, +//! Color, Luminosity) and blends the smudged colour with the pixel from before the stroke the way +//! a paint colour would be. The reference drags are 100 px along a row across a colour boundary, +//! Strength 50 %, a 20 px hard tip; here the boundary is at x 100 instead of x 400. + +use serde_json::json; + +use super::*; + +const MODES: [&str; 7] = ["normal", "darken", "lighten", "hue", "saturation", "color", "luminosity"]; +const RED: [i32; 3] = [200, 40, 40]; +const BLUE: [i32; 3] = [40, 40, 200]; +const BLACK: [i32; 3] = [0, 0, 0]; +const WHITE: [i32; 3] = [255, 255, 255]; +/// The measured positions (x 400 · 410 · 420 · 430 · 440 · 455 in the reference document). +const XS: [i32; 6] = [100, 110, 120, 130, 140, 155]; + +/// A 200 × 60 document of `depth` and `mode` whose left half is `left` and right half `right`. +fn two_tone(depth: u64, mode: &str, left: [i32; 3], right: [i32; 3]) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "depth": depth, "mode": mode})).unwrap(); + s.edit("setup", |doc, active| { + let b = doc.bounds(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let mut data = Vec::new(); + for _y in b.y0..b.y1 { + for x in b.x0..b.x1 { + let c = if x < 100 { left } else { right }; + data.extend(photocraft_raster::from_rgba(&fmt, [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0])); + } + } + surf.write_region(b, &data); + Ok(()) + }) + .unwrap(); + s +} + +fn stroke(mode: Option<&str>) -> Value { + let mut p = json!({"points": [[50, 30], [150, 30]], "size": 20, "hardness": 100, "strength": 50}); + if let Some(mode) = mode { + p["mode"] = json!(mode); + } + p +} + +/// The 8-bit RGB of the active layer at a pixel. +fn rgb(s: &Session, x: i32, y: i32) -> [i32; 3] { + let d = s.active().unwrap(); + let c = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y); + [0, 1, 2].map(|i| (c[i] * 255.0).round() as i32) +} + +/// Every pixel of the active layer. +fn image(s: &Session) -> Vec<[i32; 3]> { + (0..60).flat_map(|y| (0..200).map(move |x| (x, y))).map(|(x, y)| rgb(s, x, y)).collect() +} + +/// The document after the reference drag in `mode`. +fn dragged(left: [i32; 3], right: [i32; 3], mode: &str) -> Session { + let mut s = two_tone(8, "rgb", left, right); + s.execute("paint.smudge", stroke(Some(mode))).unwrap_or_else(|e| panic!("{mode}: {e}")); + s +} + +fn near(got: [i32; 3], want: [i32; 3], tol: i32) -> bool { + got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol) +} + +#[test] +fn mode_takes_the_seven_names_and_refuses_anything_else() { + for mode in MODES { + let s = dragged(RED, BLUE, mode); + // The options bar sends the capitalised label. + let label = format!("{}{}", mode[..1].to_uppercase(), &mode[1..]); + assert_eq!(image(&s), image(&dragged(RED, BLUE, &label)), "{mode} and {label} are the same mode"); + } + for bad in [json!("multiply"), json!("screen"), json!("dissolve"), json!("clear"), json!("bogus"), json!(""), json!(3), json!(null)] { + let mut s = two_tone(8, "rgb", RED, BLUE); + let (before, steps) = (image(&s), s.active().unwrap().history.past_len()); + let mut p = stroke(None); + p["mode"] = bad.clone(); + let err = s.execute("paint.smudge", p.clone()).unwrap_err().to_string(); + assert!(err.contains("unknown mode") && err.contains("normal|darken|lighten|hue|saturation|color|luminosity"), "{bad}: {err}"); + assert_eq!(image(&s), before, "{bad}: nothing smudged"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{bad}: no history"); + assert!(LiveDab::begin(&s, "paint.smudge", &p).is_err(), "{bad}: the live stroke is refused too"); + } +} + +#[test] +fn no_mode_and_normal_are_todays_smudge() { + let mut plain = two_tone(8, "rgb", RED, BLUE); + plain.execute("paint.smudge", stroke(None)).unwrap(); + assert_eq!(image(&plain), image(&dragged(RED, BLUE, "normal"))); + assert_eq!(rgb(&plain, 100, 30), RED, "red is dragged onto the boundary"); +} + +#[test] +fn darken_keeps_the_minimum_and_lighten_the_maximum() { + let normal = dragged(RED, BLUE, "normal"); + let darken = dragged(RED, BLUE, "darken"); + let lighten = dragged(RED, BLUE, "lighten"); + assert_eq!(rgb(&darken, 100, 30), [40, 40, 40]); + assert_eq!(rgb(&lighten, 100, 30), [200, 40, 200]); + let original = image(&two_tone(8, "rgb", RED, BLUE)); + for ((d, l), o) in image(&darken).iter().zip(image(&lighten)).zip(&original) { + assert!((0..3).all(|c| d[c] <= o[c]), "Darken never lightens a channel: {d:?} from {o:?}"); + assert!((0..3).all(|c| l[c] >= o[c]), "Lighten never darkens a channel: {l:?} from {o:?}"); + } + // Along the trail each is the smudged colour's minimum / maximum with the original blue. + for x in XS { + let n = rgb(&normal, x, 30); + assert!(near(rgb(&darken, x, 30), [n[0].min(BLUE[0]), n[1].min(BLUE[1]), n[2].min(BLUE[2])], 2), "darken at {x}"); + assert!(near(rgb(&lighten, x, 30), [n[0].max(BLUE[0]), n[1].max(BLUE[1]), n[2].max(BLUE[2])], 2), "lighten at {x}"); + } + assert_ne!(image(&darken), image(&normal)); + assert_ne!(image(&lighten), image(&normal)); +} + +#[test] +fn component_modes_match_the_reference_at_the_boundary() { + // x 400 of the reference's drag A. + for (mode, want) in [("hue", [170, 10, 10]), ("saturation", [49, 49, 130]), ("color", [170, 10, 10]), ("luminosity", [70, 70, 230])] { + let s = dragged(RED, BLUE, mode); + let got = rgb(&s, 100, 30); + assert!(near(got, want, 4), "{mode}: {got:?}, the reference has {want:?}"); + // The later columns follow the decay: the end of the trail is the untouched blue. + assert!(near(rgb(&s, 155, 30), BLUE, 3), "{mode}: the trail has faded at its end"); + assert_eq!(rgb(&s, 100, 5), BLUE, "{mode}: off the stroke nothing changes"); + } +} + +#[test] +fn component_modes_take_one_component_from_the_smudged_colour() { + let lum = |c: [i32; 3]| 0.3 * c[0] as f32 + 0.59 * c[1] as f32 + 0.11 * c[2] as f32; + let sat = |c: [i32; 3]| c.iter().max().unwrap() - c.iter().min().unwrap(); + for x in XS { + // Hue, Saturation and Color keep the original blue's luminosity. + for mode in ["hue", "saturation", "color"] { + let got = rgb(&dragged(RED, BLUE, mode), x, 30); + assert!((lum(got) - lum(BLUE)).abs() <= 3.0, "{mode} at {x}: luminosity of {got:?}"); + } + // Luminosity keeps its hue and saturation: still a blue with equal red and green. + let got = rgb(&dragged(RED, BLUE, "luminosity"), x, 30); + assert!(got[0] == got[1] && got[2] > got[0] && (sat(got) - sat(BLUE)).abs() <= 2, "luminosity at {x}: {got:?}"); + } +} + +#[test] +fn black_into_white_changes_only_in_normal_darken_and_luminosity() { + let original = image(&two_tone(8, "rgb", BLACK, WHITE)); + for mode in ["lighten", "hue", "saturation", "color"] { + assert_eq!(image(&dragged(BLACK, WHITE, mode)), original, "{mode} leaves the drag unchanged"); + } + let normal = dragged(BLACK, WHITE, "normal"); + assert_ne!(image(&normal), original); + for mode in ["darken", "luminosity"] { + let s = dragged(BLACK, WHITE, mode); + for x in XS { + let (got, want) = (rgb(&s, x, 30), rgb(&normal, x, 30)); + assert!(got[0] == got[1] && got[1] == got[2], "{mode} at {x}: grey ({got:?})"); + assert!(near(got, want, 2), "{mode} at {x}: {got:?}, Normal has {want:?}"); + } + assert_eq!(rgb(&s, 100, 30), BLACK, "{mode}: black reaches the boundary"); + assert_eq!(rgb(&s, 155, 30), WHITE, "{mode}: the end of the trail is white"); + } +} + +#[test] +fn the_live_stroke_previews_the_mode() { + // The canvas draws the stroke with the tool it commits, parsed from the same params. + let s = two_tone(8, "rgb", RED, BLUE); + let parsed = |p: &Value| DabTool::parse(&s, "paint.smudge", p, &parse_brush(&s, p, "paint.smudge").unwrap().0).unwrap().0; + for mode in ["darken", "hue"] { + let p = stroke(Some(mode)); + assert_ne!(parsed(&p), parsed(&stroke(None)), "{mode} is part of the tool"); + assert!(LiveDab::begin(&s, "paint.smudge", &p).is_ok(), "{mode}: live"); + } + assert_eq!(parsed(&stroke(Some("normal"))), parsed(&stroke(None))); +} + +#[test] +fn unmeasured_colour_modes_and_depths_smudge_as_normal() { + for (depth, colour) in [(16, "rgb"), (32, "rgb"), (8, "gray"), (8, "cmyk")] { + let mut normal = two_tone(depth, colour, RED, BLUE); + normal.execute("paint.smudge", stroke(None)).unwrap(); + assert_ne!(image(&normal), image(&two_tone(depth, colour, RED, BLUE)), "{depth}-bit {colour}: smudged"); + for mode in ["darken", "hue", "luminosity"] { + let mut s = two_tone(depth, colour, RED, BLUE); + s.execute("paint.smudge", stroke(Some(mode))).unwrap_or_else(|e| panic!("{depth}/{colour}/{mode}: {e}")); + assert_eq!(image(&s), image(&normal), "{depth}-bit {colour} {mode}: today's smudge"); + } + } +} + +#[test] +fn a_transparent_pixel_takes_the_smudged_colour() { + // Nothing to blend with where the layer was empty: the smudge lands there as in Normal. + let alpha = |s: &Session, x: i32| { + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, 30)[3] + }; + let run = |mode: &str| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 60, "background": "transparent"})).unwrap(); + s.execute("paint.pencil", json!({"points": [[0, 30], [99, 30]], "size": 40, "color": "#c82828"})).unwrap(); + s.execute("paint.smudge", stroke(Some(mode))).unwrap(); + s + }; + let normal = run("normal"); + assert!(alpha(&normal, 125) > 0.1 && alpha(&normal, 190) == 0.0, "the fixture drags red into the empty half"); + for mode in ["darken", "lighten", "hue", "saturation", "color", "luminosity"] { + let s = run(mode); + for x in [125, 130, 135] { + assert!((alpha(&s, x) - alpha(&normal, x)).abs() < 0.02, "{mode} at {x}: alpha as Normal"); + assert!(near(rgb(&s, x, 30), rgb(&normal, x, 30), 3), "{mode} at {x}: {:?} vs {:?}", rgb(&s, x, 30), rgb(&normal, x, 30)); + } + } +} + +#[test] +fn blur_and_sharpen_keep_ignoring_a_mode() { + for cmd in ["paint.blur", "paint.sharpen"] { + let mut plain = two_tone(8, "rgb", RED, BLUE); + plain.execute(cmd, stroke(None)).unwrap(); + let mut with = two_tone(8, "rgb", RED, BLUE); + with.execute(cmd, stroke(Some("darken"))).unwrap(); + assert_eq!(image(&with), image(&plain), "{cmd}"); + } +} diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index ec66e32f84..1494ba9994 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -354,6 +354,42 @@ pub struct Smudge { alpha: Option, /// Carried colour (premultiplied when `alpha` is set). carry: Option>, + /// Painting Mode (#12959): how the smudged colour replaces the pixel, see [`Smudge::with_mode`]. + mode: BlendMode, + /// For a Mode other than Normal: the pixels as they were before the stroke first reached + /// them, in cells of [`ORIGIN_CELL`]² × (R, G, B, alpha); a NaN red marks a pixel not reached yet. + origin: std::collections::HashMap<(i32, i32), Vec>, +} + +/// Side of the cells of [`Smudge`]'s pre-stroke pixels (dense cells, as a map lookup per pixel is slow). +const ORIGIN_CELL: i32 = 64; + +/// Mix the carried colour `carried` (premultiplied when `alpha` is set) into `px` at `k`. +#[inline] +fn lay_down(px: &mut [f32], carried: &[f32], alpha: Option, k: f32) { + match alpha { + Some(a) => { + // Back to straight for the mix (premultiplied lerp towards the carried colour). + let ca = carried[a]; + let mut buf = [0.0f32; 16]; + let m = carried.len().min(buf.len()); + buf[..m].copy_from_slice(&carried[..m]); + let src = &mut buf[..m]; + if ca > 1e-6 { + for (c, v) in src.iter_mut().enumerate() { + if c != a { + *v /= ca; + } + } + } + mix_premultiplied(px, src, a, k); + } + None => { + for (p, c) in px.iter_mut().zip(carried) { + *p += (*c - *p) * k; + } + } + } } impl Smudge { @@ -364,7 +400,15 @@ impl Smudge { } pub fn new(strength: f32, finger: Option>, max_brush_size: f32) -> Self { - Self { strength: strength.clamp(0.0, 1.0), finger, radius: Self::sampling_radius(max_brush_size), alpha: None, carry: None } + Self { + strength: strength.clamp(0.0, 1.0), + finger, + radius: Self::sampling_radius(max_brush_size), + alpha: None, + carry: None, + mode: BlendMode::Normal, + origin: Default::default(), + } } /// Mix in premultiplied space using alpha channel `a` (the format's [`alpha_index`]). @@ -373,6 +417,17 @@ impl Smudge { self } + /// The options bar's Mode (#12959). The smudged colour (the pixel mixed with the carried colour + /// at `strength`, as Normal writes it under a full tip) is blended with the pixel as it was + /// before the stroke, the way a paint colour would be, and laid down through the tip's + /// coverage: Darken keeps the darker of the two per channel, Hue takes the smudged colour's + /// hue with the original saturation and luminosity. The next dab picks up what was written. + /// For working copies whose first three channels are R, G, B; alpha is smudged as in Normal. + pub fn with_mode(mut self, mode: BlendMode) -> Self { + self.mode = mode; + self + } + /// Apply one dab to the working copy. pub fn dab(&mut self, work: &mut Region, fp: &Footprint) { let (r, n) = (self.radius, work.ch); @@ -383,6 +438,8 @@ impl Smudge { let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); let wr = work.rect; let alpha = self.alpha.filter(|a| *a < n); + let blend = (self.mode != BlendMode::Normal && (3..=16).contains(&n) && alpha.is_none_or(|a| a >= 3)).then_some(self.mode); + let mut cell: Option<((i32, i32), &mut Vec)> = None; let carry = self.carry.get_or_insert_with(|| { let mut c = vec![0.0f32; side * side * n]; match &self.finger { @@ -411,33 +468,43 @@ impl Smudge { // Lay down the carried colour. for y in fp.rect.y0.max(wr.y0)..fp.rect.y1.min(wr.y1) { for x in fp.rect.x0.max(wr.x0)..fp.rect.x1.min(wr.x1) { - let k = fp.at(x, y) * self.strength; + let cov = fp.at(x, y); + let k = cov * self.strength; let (qx, qy) = (x - cx + r, y - cy + r); if k <= 0.0 || qx < 0 || qy < 0 || qx >= side as i32 || qy >= side as i32 { continue; } let q = (qy as usize * side + qx as usize) * n; let px = work.px_mut(x, y); + let Some(mode) = blend else { + lay_down(px, &carry[q..q + n], alpha, k); + continue; + }; + let key = (x.div_euclid(ORIGIN_CELL), y.div_euclid(ORIGIN_CELL)); + if cell.as_ref().map(|c| c.0) != Some(key) { + cell = Some((key, self.origin.entry(key).or_insert_with(|| vec![f32::NAN; (ORIGIN_CELL * ORIGIN_CELL * 4) as usize]))); + } + let i = ((y.rem_euclid(ORIGIN_CELL) * ORIGIN_CELL + x.rem_euclid(ORIGIN_CELL)) * 4) as usize; + let Some(first) = cell.as_mut().and_then(|c| c.1.get_mut(i..i + 4)) else { continue }; + if first[0].is_nan() { + // First touch: the working pixel is still the one from before the stroke. + first.copy_from_slice(&[px[0], px[1], px[2], alpha.map_or(1.0, |a| px[a])]); + } + let (backdrop, backdrop_alpha) = ([first[0], first[1], first[2]], first[3]); + let mut buf = [0.0f32; 16]; + let smudged = &mut buf[..n]; + smudged.copy_from_slice(px); + lay_down(smudged, &carry[q..q + n], alpha, self.strength); + // A transparent pixel has no colour to blend with: there the smudged colour stays. + let out = photocraft_color::blend::blend_rgb(mode, backdrop, [smudged[0], smudged[1], smudged[2]]); + for c in 0..3 { + smudged[c] += (out[c] - smudged[c]) * backdrop_alpha; + } match alpha { - Some(a) => { - // Back to straight for the mix (premultiplied lerp towards the carried colour). - let ca = carry[q + a]; - let mut buf = [0.0f32; 16]; - let m = n.min(buf.len()); - buf[..m].copy_from_slice(&carry[q..q + m]); - let src = &mut buf[..m]; - if ca > 1e-6 { - for (c, v) in src.iter_mut().enumerate() { - if c != a { - *v /= ca; - } - } - } - mix_premultiplied(px, src, a, k); - } + Some(a) => mix_premultiplied(px, smudged, a, cov), None => { for c in 0..n { - px[c] += (carry[q + c] - px[c]) * k; + px[c] += (smudged[c] - px[c]) * cov; } } } diff --git a/crates/ui-egui/src/smudge_mode_bar_tests.rs b/crates/ui-egui/src/smudge_mode_bar_tests.rs new file mode 100644 index 0000000000..03ba7a1160 --- /dev/null +++ b/crates/ui-egui/src/smudge_mode_bar_tests.rs @@ -0,0 +1,116 @@ +//! #12959: the Smudge options bar's Mode dropdown (Normal, Darken, Lighten, Hue, Saturation, +//! Color, Luminosity), its tooltip, what the stroke sends and what Reset Tool does. Blur and +//! Sharpen share the bar's branch and keep theirs. + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_color::BlendMode; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +const NAMES: [&str; 7] = ["Normal", "Darken", "Lighten", "Hue", "Saturation", "Color", "Luminosity"]; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 80, "height": 80})).unwrap(); + app.ui.tool = tool; + app +} + +fn bar(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder() + .with_size(vec2(1600.0, 400.0)) + .with_step_dt(1.0 / 60.0) + .with_context_setup(|ctx| PhotocraftApp::setup_context(ctx, crate::theme::ThemeKind::ProMedium)) + .build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app(tool)); + h.run_steps(4); + h +} + +/// The bar's Mode dropdown, if it has one. +fn mode<'a>(h: &'a Harness<'static, PhotocraftApp>) -> Option> { + h.query_all_by_role(Role::ComboBox).find(|n| n.accesskit_node().label().as_deref() == Some("Mode:")) +} + +#[test] +fn the_smudge_bar_lists_the_seven_modes_in_order_and_starts_at_normal() { + let mut h = bar(Tool::Smudge); + assert_eq!(h.state().ui.tool_options.smudge_mode, BlendMode::Normal); + let dropdown = mode(&h).expect("Smudge has a Mode dropdown"); + assert_eq!(dropdown.accesskit_node().value().as_deref(), Some("Normal")); + // It comes before Strength, right after the brush controls. + let strength = h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().label().as_deref() == Some("Strength:")).expect("Strength"); + assert!(dropdown.rect().right() <= strength.rect().left(), "Mode is left of Strength"); + dropdown.click(); + h.run_steps(4); + assert!(egui::Popup::is_any_open(&h.ctx), "the list opens"); + let tops: Vec = NAMES.iter().map(|name| h.query_all_by_label(name).map(|n| n.rect().top()).fold(f32::MIN, f32::max)).collect(); + assert!(tops.windows(2).all(|w| w[0] < w[1]), "the entries are in the reference order: {tops:?}"); + for other in ["Multiply", "Screen", "Overlay", "Difference", "Dissolve"] { + assert!(h.query_by_label(other).is_none(), "{other} is not a Smudge mode"); + } + h.get_by_label("Luminosity").click(); + h.run_steps(4); + assert_eq!(h.state().ui.tool_options.smudge_mode, BlendMode::Luminosity); +} + +#[test] +fn the_list_has_a_line_above_darken_and_above_hue() { + assert_eq!(crate::retouch_ui::SMUDGE_MODES.map(BlendMode::label), NAMES); + let grouped: Vec = crate::retouch_ui::SMUDGE_MODES.into_iter().filter(|m| crate::retouch_ui::smudge_mode_starts_group(*m)).collect(); + assert_eq!(grouped, [BlendMode::Darken, BlendMode::Hue]); +} + +#[test] +fn hovering_the_dropdown_shows_painting_mode() { + let mut h = bar(Tool::Smudge); + assert!(h.query_by_label("Painting Mode").is_none(), "no tooltip before hover"); + let center = mode(&h).expect("Mode").rect().center(); + h.hover_at(center); + h.run_steps(60); + assert!(h.query_by_label("Painting Mode").is_some(), "the tooltip shows"); +} + +#[test] +fn blur_and_sharpen_keep_their_bar() { + for tool in [Tool::Blur, Tool::Sharpen] { + let h = bar(tool); + assert!(mode(&h).is_none(), "{tool:?} has no Mode dropdown here"); + } +} + +#[test] +fn the_stroke_sends_the_mode_and_only_smudge_does() { + let mut app = app(Tool::Smudge); + let (cmd, p) = crate::retouch_ui::dab_params(&app, Tool::Smudge).unwrap(); + assert_eq!((cmd, &p["mode"]), ("paint.smudge", &json!("Normal"))); + for (mode, name) in crate::retouch_ui::SMUDGE_MODES.into_iter().zip(NAMES) { + app.ui.tool_options.smudge_mode = mode; + let (_, p) = crate::retouch_ui::dab_params(&app, Tool::Smudge).unwrap(); + assert_eq!(p["mode"], json!(name)); + // The engine takes every name the bar sends. + let mut stroke = p.clone(); + stroke["points"] = json!([[20, 40], [60, 40]]); + app.run(cmd, stroke).unwrap_or_else(|e| panic!("{name}: {e}")); + for tool in [Tool::Blur, Tool::Sharpen] { + let (_, p) = crate::retouch_ui::dab_params(&app, tool).unwrap(); + assert!(p.get("mode").is_none(), "{tool:?} sends no mode"); + } + } +} + +#[test] +fn reset_tool_sets_normal_and_other_tools_leave_it() { + let mut app = app(Tool::Smudge); + app.ui.tool_options.smudge_mode = BlendMode::Hue; + for tool in [Tool::Blur, Tool::Sharpen, Tool::Sponge] { + crate::tool_reset::reset_options(&mut app.ui.tool_options, tool); + assert_eq!(app.ui.tool_options.smudge_mode, BlendMode::Hue, "resetting {tool:?} keeps the Smudge mode"); + } + crate::tool_reset::reset_options(&mut app.ui.tool_options, Tool::Smudge); + assert_eq!(app.ui.tool_options.smudge_mode, BlendMode::Normal); +} diff --git a/crates/ui-egui/src/state/tool_options.rs b/crates/ui-egui/src/state/tool_options.rs index 5290558d61..ec5d682e33 100644 --- a/crates/ui-egui/src/state/tool_options.rs +++ b/crates/ui-egui/src/state/tool_options.rs @@ -89,6 +89,8 @@ pub struct ToolOptions { pub strength: f32, pub protect_detail: bool, pub finger_painting: bool, + /// Smudge › Mode (#12959): Normal, Darken, Lighten, Hue, Saturation, Color or Luminosity. + pub smudge_mode: photocraft_color::BlendMode, /// Quick Selection enhance edge. pub enhance_edge: bool, /// Pen: "path" (work path) or "shape" (shape layer). @@ -484,6 +486,7 @@ impl Default for ToolOptions { strength: 50.0, protect_detail: true, finger_painting: false, + smudge_mode: photocraft_color::BlendMode::Normal, enhance_edge: false, vector_mode: "path".into(), pen_path_op: default_pen_path_op(), diff --git a/crates/ui-egui/src/tools/retouch_ui.rs b/crates/ui-egui/src/tools/retouch_ui.rs index e35a35b984..40cd36ccf7 100644 --- a/crates/ui-egui/src/tools/retouch_ui.rs +++ b/crates/ui-egui/src/tools/retouch_ui.rs @@ -4,6 +4,7 @@ //! and the clone-source marker. use egui::{Color32, Stroke, vec2}; +use photocraft_color::BlendMode; use serde_json::{Value, json}; use crate::PhotocraftApp; @@ -106,7 +107,10 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st Some(match tool { Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all.blur})), Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all.sharpen})), - Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all.smudge})), + Tool::Smudge => ( + "paint.smudge", + json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all.smudge, "mode": o.smudge_mode.label()}), + ), Tool::Dodge | Tool::Burn => ( if tool == Tool::Dodge { "paint.dodge" } else { "paint.burn" }, json!({"range": o.tone_range, "exposure": o.exposure, "protectTones": o.protect_tones}), @@ -116,6 +120,26 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st }) } +/// The Smudge tool's Painting Modes in the reference application's order (#12959); its list draws +/// a line above Darken and above Hue. +pub(crate) const SMUDGE_MODES: [BlendMode; 7] = + [BlendMode::Normal, BlendMode::Darken, BlendMode::Lighten, BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity]; + +/// Whether the Smudge Mode list draws a line above `mode`. +pub(crate) fn smudge_mode_starts_group(mode: BlendMode) -> bool { + matches!(mode, BlendMode::Darken | BlendMode::Hue) +} + +/// The Smudge options bar's Mode dropdown (#12959), first in the bar after the brush controls. +fn smudge_mode_dropdown(ui: &mut egui::Ui, mode: &mut BlendMode) { + opt(ui, tl!("Mode:")); + let opts: Vec<(BlendMode, &str)> = SMUDGE_MODES.iter().map(|m| (*m, m.label())).collect(); + crate::widgets::dropdown_wheel_grouped(ui, "smudge-mode", tl!("Mode"), mode, &opts, 100.0, |m| smudge_mode_starts_group(*m)); + if let Some(button) = ui.ctx().read_response(ui.make_persistent_id(egui::IdSalt::new("smudge-mode"))) { + button.on_hover_text(tl!("Painting Mode")); + } +} + /// `paint.colorReplacement` params from the options bar (without `points`): shared by the live /// preview and the commit. The replacement `color` is the foreground, recorded so a replayed /// stroke paints the same colour; Background Swatch sampling matches the background colour. @@ -947,6 +971,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::checkbox(ui, &mut o.vibrance, tl!("Vibrance")); } Tool::Blur | Tool::Sharpen | Tool::Smudge => { + if tool == Tool::Smudge { + smudge_mode_dropdown(ui, &mut o.smudge_mode); + } let label = tl!("Strength:"); opt(ui, label); // Measured for Blur (#12650), Sharpen (#12949) and Smudge (#12962); a copy, so a shared fraction is not rounded. @@ -1132,3 +1159,7 @@ mod cam_options_bar_tests; #[cfg(test)] #[path = "../burn_bar_tooltips_tests.rs"] mod burn_bar_tooltips_tests; + +#[cfg(test)] +#[path = "../smudge_mode_bar_tests.rs"] +mod smudge_mode_bar_tests; diff --git a/crates/ui-egui/src/tools/tool_reset.rs b/crates/ui-egui/src/tools/tool_reset.rs index fa746e9cf8..e3c73e82b9 100644 --- a/crates/ui-egui/src/tools/tool_reset.rs +++ b/crates/ui-egui/src/tools/tool_reset.rs @@ -237,7 +237,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::ArtHistoryBrush => reset!(art_history_style, art_history_area, art_history_tolerance), Tool::PatternStamp => reset!(pattern_stamp_aligned, pattern_stamp_impressionist, pattern_stamp_scale, pattern_stamp_angle), Tool::Blur | Tool::Sharpen => reset!(strength, protect_detail), - Tool::Smudge => reset!(strength, finger_painting), + Tool::Smudge => reset!(strength, finger_painting, smudge_mode), Tool::Dodge | Tool::Burn => reset!(tone_range, exposure, protect_tones), Tool::Sponge => reset!(sponge_mode, vibrance), Tool::Slice => reset!(slice_style, slice_width, slice_height),