Merge pull request #13031 from ljkx/feat/smudge-tool-mode

Smudge: Mode dropdown in the options bar (#12959)
This commit is contained in:
David Mann authored and GitHub committed 2026-11-11 02:59:37 +01:00
commit 2772ac1e77
8 files changed
+499 -29

No files matched your search

+3 -1
View File
@@ -383,7 +383,9 @@ pub fn specs() -> Vec<CommandSpec> {
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,
+26 -7
View File
@@ -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<BlendMode> {
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;
@@ -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}");
}
}
+86 -19
View File
@@ -354,6 +354,42 @@ pub struct Smudge {
alpha: Option<usize>,
/// Carried colour (premultiplied when `alpha` is set).
carry: Option<Vec<f32>>,
/// 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<f32>>,
}
/// 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<usize>, 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<Vec<f32>>, 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<f32>)> = 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;
}
}
}
+116
View File
@@ -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<egui_kittest::Node<'a>> {
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<f32> = 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<BlendMode> = 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);
}
+3
View File
@@ -92,6 +92,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).
@@ -492,6 +494,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(),
+32 -1
View File
@@ -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}),
@@ -117,6 +121,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.
@@ -956,6 +980,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
}
}
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.
@@ -1142,6 +1169,10 @@ mod cam_options_bar_tests;
#[path = "../burn_bar_tooltips_tests.rs"]
mod burn_bar_tooltips_tests;
#[cfg(test)]
#[path = "../smudge_mode_bar_tests.rs"]
mod smudge_mode_bar_tests;
#[cfg(test)]
#[path = "../sponge_flow_field_tests.rs"]
mod sponge_flow_field_tests;
+1 -1
View File
@@ -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, sponge_flow, vibrance),
Tool::Slice => reset!(slice_style, slice_width, slice_height),