From 073e1cc1d257f5bca7f75b6df5e50232eb8571df Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Sun, 11 Oct 2026 11:20:00 +0200 Subject: [PATCH] Add the Color Replacement tool to the Brush toolbar group (#1045) (#2719) The engine already had paint.colorReplacement, but the desktop UI had no tool for it: no Tool entry, no toolbar slot, no options bar and no stroke routing, so only agents and the CLI could use it. Tool::ColorReplacement joins the Brush (B) flyout after the Mixer Brush, so B / Shift+B cycle to it and ui.set / ui.inspect name it. Its options bar has Mode, Sampling (Continuous / Once / Background Swatch), Limits, Tolerance and Anti-alias, all mapped to the command's params, with the foreground as the replacement colour. Strokes are drawn live through LiveDab, which can now preview the stroke's finishing dab, so the preview at the last move matches the commit. The command refuses a mask, channel or Quick Mask target instead of silently recolouring the layer. New icon derived from Lucide's brush; label translated in all 16 catalogs. Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 3 +- README.md | 4 +- assets/icons/brush-replace.svg | 19 ++ crates/engine/src/brush_cmds.rs | 28 +- crates/engine/src/brush_cmds/tests.rs | 54 ++++ crates/engine/src/retouch_cmds.rs | 72 ++++- crates/paint/src/render.rs | 9 + crates/paint/src/replace.rs | 19 +- crates/ui-egui/src/canvas.rs | 26 +- crates/ui-egui/src/color_replacement_tests.rs | 264 ++++++++++++++++++ crates/ui-egui/src/hidden_layer_tests.rs | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/icon_data.rs | 1 + crates/ui-egui/src/icons.rs | 1 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels.rs | 42 ++- crates/ui-egui/src/pencil_tests.rs | 8 +- crates/ui-egui/src/retouch_ui.rs | 57 +++- crates/ui-egui/src/state.rs | 25 +- docs/scorecard.md | 3 +- docs/target-app-parity.md | 2 +- scorecard/tools.toml | 7 + 37 files changed, 631 insertions(+), 38 deletions(-) create mode 100644 assets/icons/brush-replace.svg create mode 100644 crates/ui-egui/src/color_replacement_tests.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6b5623a115..68312f1ce8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -20,13 +20,14 @@ next to them. | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/painting-symmetry.svg` | Painting symmetry glyph: Lucide `heart` with a central axis | Lucide Icons and Contributors; adapted by @shorbank | Derived from `icons/heart.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | diff --git a/README.md b/README.md index 31fc11634f..1e550c03f0 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 55 tools

- Move · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom +

🧰 56 tools

+ Move · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom

🖌️ A real brush engine

diff --git a/assets/icons/brush-replace.svg b/assets/icons/brush-replace.svg new file mode 100644 index 0000000000..9757f4f39a --- /dev/null +++ b/assets/icons/brush-replace.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 473edbaf02..003accb8da 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -9,7 +9,7 @@ use photocraft_doc::LayerContent; use photocraft_geom::Rect; use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke}; -use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement}; +use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect}; use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -656,8 +656,14 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { Ok(damage_json(s, dmg)) } -fn color_replacement(s: &mut Session, p: &Value) -> Result { +/// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour +/// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha +/// channel or Quick Mask `target` is refused rather than recolouring the layer instead. +fn color_replacement_setup(s: &Session, p: &Value) -> Result<(Stroke, ReplaceSettings, photocraft_doc::LayerId)> { let cmd = "paint.colorReplacement"; + if let Some(t) = p.get("target").filter(|t| t.as_str() != Some("pixels")) { + return Err(bad(cmd, format!("Color Replacement paints layer pixels only (`target` {t} is not \"pixels\")"))); + } let pts = parse_points(p, cmd)?; let brush = resolve_brush(s, p, cmd)?; fn parse(p: &Value, cmd: &str, k: &str) -> Result> { @@ -673,7 +679,11 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { background: s.tools.background, }; let id = layer_id(s, p)?; - let stroke = Stroke { brush, points: pts }; + Ok((Stroke { brush, points: pts }, rs, id)) +} + +fn color_replacement(s: &mut Session, p: &Value) -> Result { + let (stroke, rs, id) = color_replacement_setup(s, p)?; let dmg = s.edit("Color Replacement", |doc, _| { let sel = doc.selection.clone(); let lock = doc.effective_locks(id).transparency; @@ -683,6 +693,16 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { Ok(damage_json(s, dmg)) } +/// A `paint.colorReplacement` stroke shown while it is drawn: the same params, and the same +/// recolouring per dab as the commit, so the preview at the last point is the committed result. +pub fn live_color_replacement(s: &Session, p: &Value) -> Result { + let (stroke, rs, id) = color_replacement_setup(s, p)?; + crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", &json!({}), stroke, Some(id), 0, |pre| { + Box::new(color_replacement_effect(pre.clone(), rs)) + })? + .with_tail_preview() +} + fn brush_json(b: &BrushSettings) -> Value { serde_json::to_value(b).unwrap_or(Value::Null) } @@ -1000,7 +1020,7 @@ pub fn specs() -> Vec { spec!( "paint.colorReplacement", "Color Replacement", - r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?}"##, crate::commands::has_paintable, color_replacement, true diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 0dfafb428a..8da49ebad3 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -159,6 +159,60 @@ fn color_replacement_modes() { assert!(s.execute("paint.colorReplacement", json!({"points": [[1, 1]], "mode": "bogus"})).is_err()); } +/// The live Color Replacement stroke shows what `paint.colorReplacement` commits, at 8 and 16 +/// bits: red-left/blue-right halves, a stroke from red into blue in each sampling mode. The live +/// working copy is stored at the layer's depth between dabs, so 8 bits may round a level apart. +#[test] +fn live_color_replacement_matches_the_commit() { + for depth in [8, 16] { + for sampling in ["continuous", "once", "backgroundSwatch"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 40, "background": "#ff0000", "depth": depth})).unwrap(); + s.execute("select.rect", json!({"x": 40, "y": 0, "width": 40, "height": 40})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#00ff00", "background": "#0000ff"})).unwrap(); + let all = [[10.0, 20.0], [30.0, 20.0], [50.0, 20.0], [70.0, 22.0]]; + let pts = |v: &[[f64; 2]]| v.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect::>(); + let opts = |p: &[[f64; 2]]| json!({"points": p, "size": 12, "hardness": 50, "sampling": sampling, "tolerance": 30, "target": "pixels"}); + let mut live = live_color_replacement(&s, &opts(&all[..1])).unwrap(); + live.push(&pts(&all[1..3])).unwrap(); + live.push(&pts(&all[3..])).unwrap(); + let shown = live.doc.clone(); + s.execute("paint.colorReplacement", opts(&all)).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let (a, b) = (shown.layer(id).unwrap().surface().unwrap(), s.active().unwrap().doc.layer(id).unwrap().surface().unwrap()); + let mut worst = 0.0f32; + // Everywhere, the stroke's finishing dab included (the live stroke previews it). + for y in 0..40 { + for x in 0..80 { + let (got, want) = (a.rgba(x, y), b.rgba(x, y)); + worst = (0..4).map(|c| (got[c] - want[c]).abs()).fold(worst, f32::max); + } + } + assert!(b.rgba(10, 20)[1] > 0.5 || sampling == "backgroundSwatch", "{depth}-bit {sampling}: recoloured"); + assert!(worst <= 2.0 / 255.0, "{depth}-bit {sampling}: live and commit differ by {worst}"); + } + } +} + +/// Color Replacement recolours layer pixels only: a mask, channel or Quick Mask target is +/// refused (it would otherwise recolour the layer behind the user's back). +#[test] +fn color_replacement_refuses_non_pixel_targets() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 20, "background": "#ff0000"})).unwrap(); + let steps = s.active().unwrap().history.entries().len(); + for t in [json!("mask"), json!("quickMask"), json!({"channel": 0}), json!(3)] { + let p = json!({"points": [[10, 10]], "size": 6, "target": t}); + assert!(s.execute("paint.colorReplacement", p.clone()).is_err(), "{t}"); + assert!(live_color_replacement(&s, &p).is_err(), "{t}"); + } + assert_eq!(s.active().unwrap().history.entries().len(), steps); + s.execute("paint.colorReplacement", json!({"points": [[10, 10]], "size": 6, "target": "pixels"})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), steps + 1); +} + #[test] fn presets_save_load_delete_round_trip() { let mut s = session(10, 10); diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 75eedc899d..0da474c22b 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -788,7 +788,7 @@ enum DabTool { } /// One dab's edit of the working copy. -type DabEffect = Box; +pub(crate) type DabEffect = Box; impl DabTool { /// The tool for `cmd` and its history label. @@ -919,6 +919,13 @@ pub struct LiveDab { sel: Option, lock: bool, bounds: Rect, + /// Show the dabs finishing the stroke would add, so the preview at the last point is the + /// commit. Only for effects that a repeated dab doesn't change (Color Replacement): the + /// tail runs on a scratch copy at every push, so an effect that carries paint from dab to + /// dab (Smudge) would drift. + preview_tail: bool, + /// Where the shown tail is. + tail: Rect, } impl LiveDab { @@ -930,11 +937,26 @@ impl LiveDab { } let (stroke, id) = parse_brush(s, p, cmd)?; let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; + let (halo, spacing) = (tool.halo(), stroke.brush.spacing); + Self::with_effect(s, cmd, p, stroke, id, halo, |pre| tool.effect(pre.format(), spacing)) + } + + /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke) + /// at each dab of `stroke`, reading `halo` pixels around it. `p` gives the target. + pub(crate) fn with_effect( + s: &Session, + cmd: &str, + p: &Value, + stroke: Stroke, + id: Option, + halo: i32, + effect: impl FnOnce(&Surface) -> DabEffect, + ) -> Result { let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone(); let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; let pre_surf = surf.clone(); - let effect = tool.effect(pre_surf.format(), stroke.brush.spacing); + let effect = effect(&pre_surf); // `apply_dab_stroke` generates the same dabs (`dabs`: zoom 1, no smoothing scale). let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs(); let mut live = Self { @@ -943,7 +965,7 @@ impl LiveDab { renderer, done: 0, effect, - halo: tool.halo(), + halo, work: pre_surf.clone(), pre_surf, id, @@ -952,11 +974,20 @@ impl LiveDab { sel, lock, bounds: Rect::EMPTY, + preview_tail: false, + tail: Rect::EMPTY, }; live.push(&stroke.points)?; Ok(live) } + /// Also show the stroke's finishing dabs (see `preview_tail`). + pub(crate) fn with_tail_preview(mut self) -> Result { + self.preview_tail = true; + self.push(&[])?; + Ok(self) + } + /// Everything the stroke has touched so far. pub fn bounds(&self) -> Rect { self.bounds @@ -981,14 +1012,37 @@ impl LiveDab { dmg = dmg.union(&r); } self.done += new.len(); - if dmg.is_empty() { - return Ok(dmg); + // The finishing dabs, run on a scratch copy of the working pixels. + let mut tail = None; + if self.preview_tail { + let ds = self.renderer.tail_dabs(); + let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false))); + if !r.is_empty() { + let mut region = Region::read(&self.work, r.inflate(self.halo)); + for (k, d) in ds.iter().enumerate() { + (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); + } + tail = Some((r, region.crop(r))); + } } - self.bounds = self.bounds.union(&dmg); - let (orig, work) = (Region::read(&self.pre_surf, dmg), Region::read(&self.work, dmg)); + // Back to the stroke without the previous tail. + let old = std::mem::replace(&mut self.tail, Rect::EMPTY); + let shown = dmg.union(&old); + let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| *r); + if shown.is_empty() && tail_rect.is_empty() { + return Ok(Rect::EMPTY); + } + self.bounds = self.bounds.union(&dmg).union(&tail_rect); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; - photocraft_paint::retouch::mix_back(surf, &orig, &work, dmg, self.opacity, self.sel.as_ref(), self.lock); - Ok(dmg) + if !shown.is_empty() { + let (orig, work) = (Region::read(&self.pre_surf, shown), Region::read(&self.work, shown)); + photocraft_paint::retouch::mix_back(surf, &orig, &work, shown, self.opacity, self.sel.as_ref(), self.lock); + } + if let Some((r, work)) = tail { + photocraft_paint::retouch::mix_back(surf, &Region::read(&self.pre_surf, r), &work, r, self.opacity, self.sel.as_ref(), self.lock); + self.tail = r; + } + Ok(shown.union(&tail_rect)) } } diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 9d1d7fa5fa..a4683ca756 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -627,6 +627,15 @@ impl StrokeRenderer { self.raster_pending(); } + /// The primary dabs finishing the stroke now would add, without finishing it: for live + /// previews of sequential-dab tools, which run each dab themselves (see [`tail_preview`](Self::tail_preview)). + pub fn tail_dabs(&self) -> Vec { + let mut generator = self.generator.clone(); + let (mut dabs, mut duals) = (Vec::new(), Vec::new()); + generator.finish(&mut dabs, &mut duals); + dabs + } + /// What finishing the stroke now would add (the smoothing catch-up tail to the last point, or /// a lone first dab), for live previews: a renderer holding copies of the coverage tiles the /// tail touches with the tail rendered in, so its `composite` draws exactly those tiles as diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index 53a90835ef..3cb02e9a24 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -8,7 +8,7 @@ use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use crate::Stroke; -use crate::retouch::{Region, apply_dab_stroke}; +use crate::retouch::{Footprint, Region, apply_dab_stroke}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -79,9 +79,16 @@ fn dist(a: &[f32; 4], b: [f32; 3]) -> f32 { /// Replace colours along a stroke. Returns the damaged rectangle. pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &ReplaceSettings, selection: Option<&Surface>, lock_transparency: bool) -> Rect { - let fmt = target.format(); + let effect = color_replacement_effect(target.clone(), rs.clone()); + apply_dab_stroke(target, stroke, selection, lock_transparency, 0, effect) +} + +/// One Color Replacement dab's edit of a working copy, matching against `pre` (the target before +/// the stroke). Shared by [`apply_color_replacement`] and live previews that run the dabs as they +/// come, so both recolour the same pixels. +pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut(&mut Region, &Footprint) { + let fmt = pre.format(); let n = fmt.channels(); - let pre = target.clone(); let mode = match rs.mode { ReplaceMode::Hue => BlendMode::Hue, ReplaceMode::Saturation => BlendMode::Saturation, @@ -92,7 +99,7 @@ pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &Repla let tol = rs.tolerance.clamp(0.0, 1.0); let soft = rs.anti_alias && rs.limits != Limits::FindEdges; let mut once: Option<[f32; 3]> = None; - let match_w = |d: f32| -> f32 { + let match_w = move |d: f32| -> f32 { if d > tol { 0.0 } else if soft && tol > 0.0 { @@ -102,7 +109,7 @@ pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &Repla 1.0 } }; - apply_dab_stroke(target, stroke, selection, lock_transparency, 0, |work: &mut Region, fp| { + move |work: &mut Region, fp: &Footprint| { let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); let sampled = match rs.sampling { Sampling::Continuous => { @@ -187,7 +194,7 @@ pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &Repla px.copy_from_slice(&enc[..n]); } } - }) + } } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 06d52ff886..70f0f0be76 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -467,8 +467,19 @@ fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec bool { - matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser | Tool::Blur | Tool::Sharpen | Tool::Smudge | Tool::Dodge | Tool::Burn | Tool::Sponge) - || live_retouch_command(tool).is_some() + matches!( + tool, + Tool::Brush + | Tool::Pencil + | Tool::Eraser + | Tool::ColorReplacement + | Tool::Blur + | Tool::Sharpen + | Tool::Smudge + | Tool::Dodge + | Tool::Burn + | Tool::Sponge + ) || live_retouch_command(tool).is_some() } /// The retouching tools drawn live (`LiveRetouch`), by the command their live stroke previews. @@ -584,7 +595,12 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { static STROKES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let st = app.session.active()?; let d = app.drag.as_ref()?; - let stroke = if let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) { + let stroke = if d.tool == Tool::ColorReplacement { + // The params `retouch_ui::finish_stroke` commits. + let mut p = crate::retouch_ui::color_replacement_params(app); + p["points"] = json!(d.points); + EngineStroke::Dab(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?) + } else if let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) { // The params `retouch_ui::finish_stroke` commits; Sample All Layers isn't previewed. p["points"] = json!(d.points); p["target"] = paint_target(app); @@ -678,6 +694,7 @@ pub(crate) fn freehand_tool(tool: Tool) -> bool { Tool::Brush | Tool::Pencil | Tool::MixerBrush + | Tool::ColorReplacement | Tool::Eraser | Tool::BackgroundEraser | Tool::HistoryBrush @@ -4572,7 +4589,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { app.last_stroke_end = Some((st.doc.id, end)); } // A live Clone Stamp preview ends here; the commit below replaces it. - if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() { + if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() || d.tool == Tool::ColorReplacement { app.live_stroke = None; } if crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) { @@ -5431,6 +5448,7 @@ mod tests { Tool::Brush, Tool::Pencil, Tool::MixerBrush, + Tool::ColorReplacement, Tool::Eraser, Tool::BackgroundEraser, Tool::CloneStamp, diff --git a/crates/ui-egui/src/color_replacement_tests.rs b/crates/ui-egui/src/color_replacement_tests.rs new file mode 100644 index 0000000000..bfa5037df8 --- /dev/null +++ b/crates/ui-egui/src/color_replacement_tests.rs @@ -0,0 +1,264 @@ +//! The Color Replacement tool (#1045): a canvas stroke dispatches `paint.colorReplacement` with +//! the options bar's Mode, Sampling, Limits, Tolerance and Anti-alias and the foreground colour, +//! drawn live, one History step; the brush-tool gestures, `ui.set` and `ui.inspect`. + +use egui::{Modifiers, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// An 80×40 document at `depth` bits, red on the left half and blue on the right, foreground +/// green and background blue, with the Color Replacement tool picked (12 px, hard, no smoothing). +fn app(depth: u32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 80, "height": 40, "background": "#ff0000", "depth": depth})).unwrap(); + fill(&mut app, [40, 0, 40, 40], "#0000ff"); + app.run("tools.setColors", json!({"foreground": "#00ff00", "background": "#0000ff"})).unwrap(); + app.ui.tool = Tool::ColorReplacement; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"size": 12, "hardness": 1.0, "pressureSize": false, "smoothing": {"amount": 0.0}}})).unwrap(); + let o = &mut app.ui.tool_options; + (o.cr_mode, o.cr_limits, o.cr_tolerance) = ("color".into(), "discontiguous".into(), 30.0); + app +} + +fn fill(app: &mut PhotocraftApp, [x, y, width, height]: [i32; 4], color: &str) { + app.run("select.rect", json!({"x": x, "y": y, "width": width, "height": height})).unwrap(); + app.run("edit.fill", json!({"color": color})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); +} + +fn rgba(app: &PhotocraftApp, x: i32, y: i32) -> [f32; 4] { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y) +} + +fn drag(app: &mut PhotocraftApp, pts: &[(f64, f64)]) { + let m = Modifiers::NONE; + let (x, y) = pts[0]; + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); + for &(x, y) in &pts[1..] { + tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, m); + } + let &(x, y) = pts.last().unwrap(); + tool_event(app, ToolEvent::Up { x, y }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); +} + +/// Red under the brush took the foreground's green hue (Color mode keeps its luminosity). +fn greened(c: [f32; 4]) -> bool { + c[1] > c[0] + 0.05 && c[1] > c[2] + 0.05 +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; +/// From red (left half) across into blue (right half). +const STROKE: [(f64, f64); 4] = [(10.0, 20.0), (30.0, 20.0), (50.0, 20.0), (70.0, 20.0)]; + +#[test] +fn sampling_once_recolours_the_start_colour_and_continuous_follows_the_brush() { + for depth in [8, 16] { + let mut app = app(depth); + app.ui.tool_options.cr_sampling = "once".into(); + drag(&mut app, &STROKE); + let (id, p) = app.session.journal.last().cloned().unwrap(); + assert_eq!(id, "paint.colorReplacement"); + assert_eq!(p["sampling"], "once"); + assert!(greened(rgba(&app, 20, 20)), "{depth}-bit: red under the brush is recoloured: {:?}", rgba(&app, 20, 20)); + assert_eq!(rgba(&app, 20, 5), RED, "{depth}-bit: outside the brush"); + assert_eq!(rgba(&app, 60, 20), BLUE, "{depth}-bit: blue doesn't match the colour sampled once"); + + let mut app = self::app(depth); + app.ui.tool_options.cr_sampling = "continuous".into(); + drag(&mut app, &STROKE); + assert!(greened(rgba(&app, 20, 20)), "{depth}-bit"); + let blue = rgba(&app, 60, 20); + assert!(blue[1] > 0.05 && blue != BLUE, "{depth}-bit: continuous sampling recolours the blue too: {blue:?}"); + } +} + +#[test] +fn background_swatch_recolours_only_the_background_colour() { + for depth in [8, 16] { + let mut app = app(depth); + app.ui.tool_options.cr_sampling = "backgroundSwatch".into(); + drag(&mut app, &STROKE); + assert_eq!(rgba(&app, 20, 20), RED, "{depth}-bit: red isn't the background colour"); + let blue = rgba(&app, 60, 20); + assert!(blue[1] > 0.05 && blue != BLUE, "{depth}-bit: blue (the background swatch) is recoloured: {blue:?}"); + } +} + +#[test] +fn contiguous_limits_skip_a_matching_island_that_discontiguous_recolours() { + for (limits, island_recoloured) in [("contiguous", false), ("discontiguous", true)] { + let mut app = app(8); + fill(&mut app, [0, 0, 80, 40], "#0000ff"); + // A red patch under the brush centre, and a red island inside the brush beyond blue. + fill(&mut app, [16, 16, 8, 8], "#ff0000"); + fill(&mut app, [27, 18, 3, 4], "#ff0000"); + app.run("tools.setBrush", json!({"brush": {"size": 30}})).unwrap(); + app.ui.tool_options.cr_limits = limits.into(); + app.ui.tool_options.cr_sampling = "once".into(); + drag(&mut app, &[(20.0, 20.0)]); + assert!(greened(rgba(&app, 20, 20)), "{limits}: the patch under the centre"); + assert_eq!(greened(rgba(&app, 28, 20)), island_recoloured, "{limits}: the island {:?}", rgba(&app, 28, 20)); + assert_eq!(rgba(&app, 25, 20), BLUE, "{limits}: blue between them"); + } +} + +/// The preview at the last move is the committed stroke: exactly at 32 bits, within one level at +/// 8 (the live stroke keeps its working pixels in the layer's depth between dabs). +#[test] +fn a_stroke_is_drawn_live_and_is_one_history_step() { + for depth in [8, 32] { + live_stroke_at(depth); + } +} + +fn live_stroke_at(depth: u32) { + let mut app = app(depth); + let before = rgba(&app, 20, 20); + let steps = app.session.active().unwrap().history.past_len(); + let m = Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 20.0, pressure: 1.0 }, m); + for &(x, y) in &STROKE[1..] { + tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, m); + } + assert!(app.live_stroke.is_some(), "the canvas shows the stroke while dragging"); + let (shown, _) = crate::canvas::display_doc(&mut app, 0); + let id = app.session.active().unwrap().active_layer.unwrap(); + let shown = shown.layer(id).unwrap().surface().unwrap().clone(); + assert!(greened(shown.rgba(20, 20)), "the preview recolours as it paints"); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 20.0 }, m); + assert!(app.live_stroke.is_none()); + // The preview at the last move is the committed stroke. + let level = if depth == 8 { 1.0 / 255.0 + 1e-6 } else { 0.0 }; + for y in 0..40 { + for x in 0..80 { + let (got, want) = (shown.rgba(x, y), rgba(&app, x, y)); + assert!((0..4).all(|c| (got[c] - want[c]).abs() <= level), "{depth}-bit ({x}, {y}): {got:?} vs {want:?}"); + } + } + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1, "one History step"); + assert_eq!(st.history.entries().last().map(|e| e.to_string()).as_deref(), Some("Color Replacement")); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(rgba(&app, 20, 20), before, "one undo restores the pixels"); + app.run("edit.redo", json!({})).unwrap(); + assert!(greened(rgba(&app, 20, 20)), "redo reapplies the stroke"); +} + +/// A targeted layer mask isn't recoloured, and neither is the layer behind it. +#[test] +fn a_mask_target_is_refused_without_painting() { + let mut app = app(8); + app.run("layer.new.layer", json!({})).unwrap(); + fill(&mut app, [0, 0, 80, 40], "#ff0000"); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = true; + let steps = app.session.active().unwrap().history.past_len(); + let m = Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 20.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 30.0, y: 20.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 20.0 }, m); + assert!(app.ui.status_error && app.ui.status.contains("layer pixels only"), "{}", app.ui.status); + assert_eq!(rgba(&app, 20, 20), RED); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); +} + +/// Every options-bar control reaches `paint.colorReplacement`, with the foreground as `color`. +#[test] +fn the_options_bar_sets_every_command_parameter() { + let mut h = Harness::builder().with_size(vec2(1600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app(8), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + for label in ["Mode:", "Limits:", "Tolerance:", "Anti-alias", "Sampling: Continuous", "Sampling: Once", "Sampling: Background Swatch"] { + assert!(h.get_by_label(label).rect().is_positive(), "missing Color Replacement option {label}"); + } + let combo = |h: &Harness<'static, PhotocraftApp>, current: &str| { + h.get_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.accesskit_node().value().as_deref() == Some(current) || n.accesskit_node().label().as_deref() == Some(current)) + .unwrap() + .click(); + }; + combo(&h, "Color"); + h.run_steps(3); + h.get_by_label("Luminosity").click(); + h.run_steps(3); + combo(&h, "Discontiguous"); + h.run_steps(3); + h.get_by_label("Find Edges").click(); + h.run_steps(3); + h.get_by_label("Sampling: Once").click(); + h.run_steps(3); + h.get_by_label("Anti-alias").click(); + h.run_steps(3); + // The Tolerance field: click into it and type a new value. + h.get_all_by_role(egui::accesskit::Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(30.0)).expect("Tolerance field").click(); + h.run_steps(1); + h.event(egui::Event::Text("55".into())); + h.key_press(egui::Key::Tab); + h.run_steps(2); + let o = &h.state().ui.tool_options; + assert_eq!((o.cr_mode.as_str(), o.cr_limits.as_str(), o.cr_sampling.as_str(), o.cr_anti_alias), ("luminosity", "findEdges", "once", false)); + assert_eq!(o.cr_tolerance, 55.0); + let app = h.state_mut(); + drag(app, &STROKE); + let (id, p) = app.session.journal.last().cloned().unwrap(); + assert_eq!(id, "paint.colorReplacement"); + for (k, v) in + [("mode", json!("luminosity")), ("sampling", json!("once")), ("limits", json!("findEdges")), ("tolerance", json!(55.0)), ("antiAlias", json!(false))] + { + assert_eq!(p[k], v, "{k}: {p}"); + } + // The replacement colour is the foreground. + assert_eq!(app.session.tools.foreground, [0.0, 1.0, 0.0, 1.0]); + assert_eq!(p["color"], json!([0.0, 1.0, 0.0, 1.0]), "{p}"); +} + +#[test] +fn it_paints_like_the_brush_tools() { + assert!(Tool::ColorReplacement.is_brushlike(), "brush cursor, brush chip, [ ] and ⇧[ ⇧]"); + assert!(crate::brush_resize::applies(Tool::ColorReplacement), "Control+Alt drag and Alt+right drag resize it"); + assert!(crate::canvas::freehand_tool(Tool::ColorReplacement)); + let mut a = app(8); + let ctx = egui::Context::default(); + let size = a.session.tools.brush.size; + // ] and [ step the size of the tool's brush. + a.run("tools.increaseBrushSize", json!({})).unwrap(); + assert!(a.session.tools.brush.size > size); + a.run("tools.decreaseBrushSize", json!({})).unwrap(); + assert_eq!(a.session.tools.brush.size, size); + // Alt+right drag resizes and paints nothing. + let events = json!([{"kind": "down", "x": 40, "y": 20}, {"kind": "move", "x": 60, "y": 20}, {"kind": "up", "x": 70, "y": 20}]); + let (req, _rx) = crate::control::ControlRequest::new("ui.pointer", json!({"tool": "colorReplacement", "button": "right", "alt": true, "events": events})); + let _ = crate::control::handle(&mut a, &ctx, &req); + assert!(a.session.tools.brush.size > size, "{}", a.session.tools.brush.size); + assert!(!a.session.journal.iter().any(|(id, _)| id == "paint.colorReplacement"), "nothing painted"); +} + +#[test] +fn ui_set_selects_it_and_inspect_reports_it() { + let mut a = app(8); + a.ui.tool = Tool::Move; + let ctx = egui::Context::default(); + let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({"tool": "colorReplacement"})); + let _ = crate::control::handle(&mut a, &ctx, &req); + assert_eq!(a.ui.tool, Tool::ColorReplacement); + assert_eq!(crate::control::inspect(&a, &ctx)["tool"], "ColorReplacement"); + assert_eq!(Tool::ColorReplacement.key(), 'B'); + assert_eq!(Tool::ColorReplacement.label(), "Color Replacement Tool"); +} diff --git a/crates/ui-egui/src/hidden_layer_tests.rs b/crates/ui-egui/src/hidden_layer_tests.rs index c1753d3e1a..806aadd8a2 100644 --- a/crates/ui-egui/src/hidden_layer_tests.rs +++ b/crates/ui-egui/src/hidden_layer_tests.rs @@ -65,7 +65,7 @@ fn moving_a_hidden_target_layer_is_refused() { #[test] fn painting_a_hidden_layer_is_refused_at_the_press() { - for tool in [Tool::Brush, Tool::Eraser, Tool::PaintBucket, Tool::Gradient, Tool::CloneStamp] { + for tool in [Tool::Brush, Tool::Eraser, Tool::ColorReplacement, Tool::PaintBucket, Tool::Gradient, Tool::CloneStamp] { let (mut app, id) = app(tool); // The live Gradient makes a new Gradient Fill layer; the classic one paints the target. app.ui.tool_options.gradient_classic = true; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f8142bc550..d2c0aabb20 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2061,6 +2061,8 @@ Apply the pen's tilt and rotation to the tip shape Použít náklon a natočení pera na tvar hrotu Mixer Brush Míchací štětec Mixer Brush Tool Nástroj Míchací štětec + Color Replacement Tool Nástroj Nahrazení barvy + Color Replacement Nahrazení barvy Wet Vlhkost Mix Míchání Wetness Jitter Kolísání vlhkosti diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1311a2e60e..ff967f6832 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2028,6 +2028,7 @@ Apply the pen's tilt and rotation to the tip shape Neigung und Drehung des Stifts auf die Spitzenform anwenden Mixer Brush Mischer-Pinsel Mixer Brush Tool Mischer-Pinsel-Werkzeug + Color Replacement Tool Farbersatz-Werkzeug Wet Feucht Mix Mischen Wetness Jitter Feuchtigkeitsschwankung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4821be08a8..2c7ecf9a79 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1898,6 +1898,8 @@ White Balance: As Shot Ισορροπία λευκού: όπως στη λήψη Mixer Brush Πινέλο ανάμειξης (Mixer Brush) Mixer Brush Tool Πινέλο ανάμειξης (Mixer Brush Tool) + Color Replacement Tool Αντικατάσταση χρώματος (Color Replacement Tool) + Color Replacement Αντικατάσταση χρώματος Wet Υγρασία Load Φόρτωση Mix Ανάμειξη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2685492b96..20113fe7ef 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2044,6 +2044,7 @@ Apply the pen's tilt and rotation to the tip shape Aplicar la inclinación y la rotación del lápiz a la forma de la punta Mixer Brush Pincel mezclador Mixer Brush Tool Herramienta Pincel mezclador + Color Replacement Tool Herramienta Sustitución de color Wet Húmedo Mix Mezcla Wetness Jitter Variación de humedad diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index dea5222043..d5c71e0ab3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2545,6 +2545,7 @@ Edit › Paste Édition › Coller Use File › Open, or paste a copied image with {paste}. Utilisez Fichier › Ouvrir, ou collez une image copiée avec {paste}. Mixer Brush Tool Outil Pinceau mélangeur + Color Replacement Tool Outil Remplacement de couleur Invalid {mode} profile Profil {mode} non valide Unnamed {mode} profile Profil {mode} sans nom Show bounding box when dragging layer Afficher le cadre de sélection lors du déplacement du calque diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e86fd8a764..1cebdbc365 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2534,6 +2534,7 @@ Edit › Paste Edit › Tempel Use File › Open, or paste a copied image with {paste}. Gunakan Berkas › Buka, atau tempel gambar yang disalin dengan {paste}. Mixer Brush Tool Alat Kuas Pencampur + Color Replacement Tool Alat Penggantian Warna Invalid {mode} profile Profil {mode} tidak valid Unnamed {mode} profile Profil {mode} tanpa nama Show bounding box when dragging layer Tampilkan kotak pembatas saat menyeret layer diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3b9089bbb9..454f59f0bf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2042,6 +2042,7 @@ Apply the pen's tilt and rotation to the tip shape Applica inclinazione e rotazione penna alla forma punta Mixer Brush Pennello miscelatore Mixer Brush Tool Strumento Pennello miscelatore + Color Replacement Tool Strumento Sostituzione colore Wet Bagnato Mix Miscela Wetness Jitter Variazione bagnato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0c1da09f59..fcc77b5b82 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2045,6 +2045,7 @@ Apply the pen's tilt and rotation to the tip shape ペンの傾きと回転を先端のシェイプに適用します Mixer Brush 混合ブラシ Mixer Brush Tool 混合ブラシツール + Color Replacement Tool 色の置き換えツール Wet ウェット Mix 混合 Wetness Jitter ウェットのジッター diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 97cc92d9e7..5e8e1b8aec 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2080,6 +2080,8 @@ Show bounding box when dragging layer 레이어를 드래그할 때 테두리 상자 표시 System title bar 시스템 제목 표시줄 Mixer Brush Tool 혼합 브러시 도구 + Color Replacement Tool 색상 대체 도구 + Color Replacement 색상 대체 Rendering Mode 렌더링 모드 Automatic (recommended) 자동(권장) CPU / Compatibility CPU / 호환성 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 173af2b176..92f8690035 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2014,6 +2014,7 @@ Apply the pen's tilt and rotation to the tip shape Kanteling en rotatie van de pen toepassen op de vorm van de punt Mixer Brush Mengpenseel Mixer Brush Tool Mengpenseelgereedschap + Color Replacement Tool Kleurvervangingsgereedschap Wet Nat Mix Mengen Wetness Jitter Nattigheidsvariatie diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd69b75992..0290f6603d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2034,6 +2034,7 @@ Apply the pen's tilt and rotation to the tip shape Zastosuj nachylenie i obrót pióra do kształtu końcówki Mixer Brush Pędzel mieszający Mixer Brush Tool Narzędzie Pędzel mieszający + Color Replacement Tool Narzędzie Zamiana koloru Wet Wilgotność Mix Mieszanie Wetness Jitter Rozrzut wilgotności diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 333bdfa393..65add0f7e6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2542,6 +2542,7 @@ Edit › Paste Editar › Colar Use File › Open, or paste a copied image with {paste}. Use Arquivo › Abrir ou cole uma imagem copiada com {paste}. Mixer Brush Tool Ferramenta Pincel misturador + Color Replacement Tool Ferramenta Substituição de cor Invalid {mode} profile Perfil {mode} inválido Unnamed {mode} profile Perfil {mode} sem nome Show bounding box when dragging layer Mostrar caixa delimitadora ao arrastar a camada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7b8c168d89..0b0def8eb2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -515,6 +515,7 @@ Magnetic Lasso Магнитное лассо Mixer Brush Смешивающая кисть Mixer Brush Tool Инструмент «Смешивающая кисть» + Color Replacement Tool Инструмент «Замена цвета» History Brush Кисть истории Art History Brush Кисть истории искусств Pattern Stamp Узорный штамп diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index da29b0059c..5acf28dc06 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -494,6 +494,7 @@ Magnetic Lasso Магнітне ласо Mixer Brush Змішувальний пензель Mixer Brush Tool Інструмент «Змішувальний пензель» + Color Replacement Tool Інструмент «Заміна кольору» History Brush Пензель історії Art History Brush Художній пензель історії Pattern Stamp Візерунковий штамп diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3e72040e02..0528835ffb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1198,6 +1198,8 @@ Midtones from: 中间调下限: Midtones to: 中间调上限: Mixer Brush Tool 混合画笔工具 + Color Replacement Tool 颜色替换工具 + Color Replacement 颜色替换 Minimum Depth 最小深度 Minimum Diameter 最小直径 Minimum Roundness 最小圆度 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 89710fc571..9c39f4f493 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2043,6 +2043,8 @@ Apply the pen's tilt and rotation to the tip shape 將筆的傾斜和旋轉套用至筆尖形狀 Mixer Brush 混合器筆刷 Mixer Brush Tool 混合器筆刷工具 + Color Replacement Tool 顏色取代工具 + Color Replacement 顏色取代 Wet 濕度 Mix 混合 Wetness Jitter 濕度快速變換 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 26449b7b5f..9f4f2f1e23 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -13,6 +13,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("bandage-sparkle", include_bytes!("../../../assets/icons/bandage-sparkle.svg")), ("blend", include_bytes!("../../../assets/icons/blend.svg")), ("brush", include_bytes!("../../../assets/icons/brush.svg")), + ("brush-replace", include_bytes!("../../../assets/icons/brush-replace.svg")), ("brush-cleaning", include_bytes!("../../../assets/icons/brush-cleaning.svg")), ("check", include_bytes!("../../../assets/icons/check.svg")), ("chevron-down", include_bytes!("../../../assets/icons/chevron-down.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 33b290eda3..fbe93757f4 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -92,6 +92,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Brush => "brush", Tool::Pencil => "pencil", Tool::MixerBrush => "palette", + Tool::ColorReplacement => "brush-replace", Tool::Eraser => "eraser", Tool::BackgroundEraser => "eraser-background", Tool::MagicEraser => "eraser-magic", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5b04560094..8b26ec837b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2045,6 +2045,8 @@ mod color_swatch_tests; #[cfg(test)] mod input_tests; +#[cfg(test)] +mod color_replacement_tests; #[cfg(test)] mod pencil_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a0e620766d..e556aa9c66 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -25,7 +25,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[ ], &[ &[Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye], - &[Tool::Brush, Tool::Pencil, Tool::MixerBrush], + &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement], &[Tool::CloneStamp, Tool::PatternStamp], &[Tool::HistoryBrush], &[Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser], @@ -4174,6 +4174,44 @@ mod type_flyout_tests { assert_eq!(app.ui.tool, Tool::PatternStamp); } + #[test] + fn long_press_brush_selects_color_replacement() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Brush)).unwrap(); + let slot = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).nth(index).copied().unwrap(); + // Photoshop's Brush group, Color Replacement after the Mixer Brush. + assert_eq!(slot, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let at = buttons[index].center(); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]); + frame(&mut app, &ctx, 1.36, vec![]); + frame(&mut app, &ctx, 1.4, vec![pointer(at, false)]); + frame(&mut app, &ctx, 1.45, vec![]); + let key = egui::Id::new(("tool-slot", index)); + let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).unwrap(); + // The last of the four 26-point rows. + let row = egui::pos2(menu.left() + 65.0, menu.bottom() - 20.0); + frame(&mut app, &ctx, 2.0, vec![egui::Event::PointerMoved(row), pointer(row, true)]); + frame(&mut app, &ctx, 2.05, vec![pointer(row, false)]); + assert_eq!(app.ui.tool, Tool::ColorReplacement); + assert_eq!(crate::icons::tool_icon(Tool::ColorReplacement), "brush-replace"); + } + #[test] fn rotate_view_is_in_the_hand_flyout() { let hand = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::Hand)).expect("Hand group"); @@ -4213,7 +4251,7 @@ mod toolbar_hidden_tests { let slots: usize = TOOL_SECTIONS.iter().map(|section| section.len()).sum(); hide(&mut app, &Tool::ALL); assert_eq!(tool_buttons(&mut app), all - slots, "every tool slot is gone when every tool is hidden"); - hide(&mut app, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush]); + hide(&mut app, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement]); assert_eq!(tool_buttons(&mut app), all - 1, "a slot whose tools are all hidden is gone"); hide(&mut app, &[Tool::Sponge]); assert_eq!(tool_buttons(&mut app), all, "a slot with a visible tool stays"); diff --git a/crates/ui-egui/src/pencil_tests.rs b/crates/ui-egui/src/pencil_tests.rs index 19255962d9..2b6f461b55 100644 --- a/crates/ui-egui/src/pencil_tests.rs +++ b/crates/ui-egui/src/pencil_tests.rs @@ -215,7 +215,7 @@ fn the_eraser_in_block_mode_erases_a_16_screen_px_square() { } #[test] -fn b_cycles_brush_pencil_and_mixer_brush() { +fn b_cycles_brush_pencil_mixer_brush_and_color_replacement() { let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); @@ -235,6 +235,8 @@ fn b_cycles_brush_pencil_and_mixer_brush() { assert_eq!(h.state().ui.tool, Tool::Pencil); press(&mut h, Modifiers::NONE); assert_eq!(h.state().ui.tool, Tool::MixerBrush); + press(&mut h, Modifiers::NONE); + assert_eq!(h.state().ui.tool, Tool::ColorReplacement); // Use Shift Key for Tool Switch: ⇧B cycles, B keeps the group's tool. h.state_mut().session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = true); press(&mut h, Modifiers::SHIFT); @@ -243,8 +245,10 @@ fn b_cycles_brush_pencil_and_mixer_brush() { assert_eq!(h.state().ui.tool, Tool::Pencil); press(&mut h, Modifiers::SHIFT); assert_eq!(h.state().ui.tool, Tool::MixerBrush); + press(&mut h, Modifiers::SHIFT); + assert_eq!(h.state().ui.tool, Tool::ColorReplacement); press(&mut h, Modifiers::NONE); - assert_eq!(h.state().ui.tool, Tool::MixerBrush); + assert_eq!(h.state().ui.tool, Tool::ColorReplacement); assert_eq!(Tool::from_name("pencil"), Some(Tool::Pencil)); assert_eq!(Tool::from_name("mixerBrush"), Some(Tool::MixerBrush)); } diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 74d8490e20..8e78a5dd98 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -1,6 +1,7 @@ //! UI for the retouching tools (healing, patch, clone, history brush, blur/sharpen/smudge, -//! dodge/burn/sponge, Mixer Brush) and the smart selection tools (Quick Selection, Object -//! Selection): gesture → engine command, options bars, and the clone-source marker. +//! dodge/burn/sponge, Mixer Brush, Color Replacement) and the smart selection tools (Quick +//! Selection, Object Selection): gesture → engine command, options bars, and the clone-source +//! marker. use egui::{Color32, Stroke, vec2}; use serde_json::{Value, json}; @@ -60,6 +61,23 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st }) } +/// `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. +pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { + let o = &app.ui.tool_options; + json!({ + "color": app.session.tools.foreground, + "mode": o.cr_mode, + "sampling": o.cr_sampling, + "limits": o.cr_limits, + "tolerance": o.cr_tolerance, + "antiAlias": o.cr_anti_alias, + // A mask or channel target is refused: the tool recolours layer pixels only. + "target": crate::canvas::paint_target(app), + }) +} + /// Finish a stroke with a retouching tool. Returns false if `tool` isn't one. pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], mods: egui::Modifiers) -> bool { let o = app.ui.tool_options.clone(); @@ -68,6 +86,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type, "sampleAllLayers": o.sample_all_layers})), Tool::Remove => ("paint.remove", json!({"sampleAllLayers": o.sample_all_layers})), Tool::MixerBrush => ("paint.mixerBrush", json!({})), + Tool::ColorReplacement => ("paint.colorReplacement", color_replacement_params(app)), Tool::PatternStamp => { let mut p = json!({ "aligned": o.pattern_stamp_aligned, @@ -406,6 +425,40 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::checkbox(ui, &mut o.finger_painting, tl!("Finger Painting")); } } + Tool::ColorReplacement => { + // Photoshop's options bar: Mode, Sampling (three icon toggles), Limits, Tolerance, Anti-alias. + opt(ui, tl!("Mode:")); + let modes = [ + ("hue".to_string(), tl!("Hue")), + ("saturation".to_string(), tl!("Saturation")), + ("color".to_string(), tl!("Color")), + ("luminosity".to_string(), tl!("Luminosity")), + ]; + crate::widgets::dropdown(ui, "color-replacement-mode", &mut o.cr_mode, &modes, 100.0); + crate::widgets::vline(ui, 22.0); + ui.spacing_mut().item_spacing.x = 2.0; + for (k, icon, tip) in [ + ("continuous", "pipette", tl!("Sampling: Continuous")), + ("once", "circle-dot", tl!("Sampling: Once")), + ("backgroundSwatch", "square", tl!("Sampling: Background Swatch")), + ] { + if crate::brush_picker::named(crate::icons::button(ui, icon, 24.0, o.cr_sampling == k, tip), tip).clicked() { + o.cr_sampling = k.into(); + } + } + ui.spacing_mut().item_spacing.x = 8.0; + crate::widgets::vline(ui, 22.0); + opt(ui, tl!("Limits:")); + let limits = [ + ("discontiguous".to_string(), tl!("Discontiguous")), + ("contiguous".to_string(), tl!("Contiguous")), + ("findEdges".to_string(), tl!("Find Edges")), + ]; + crate::widgets::dropdown(ui, "color-replacement-limits", &mut o.cr_limits, &limits, 110.0); + opt(ui, tl!("Tolerance:")); + crate::widgets::value_field(ui, &mut o.cr_tolerance, 0.0..=100.0, "%", 62.0); + crate::widgets::checkbox(ui, &mut o.cr_anti_alias, tl!("Anti-alias")); + } Tool::HistoryBrush => opt(ui, "Paints from the document's opening state"), Tool::QuickSelection => { crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index deb82b7885..6e9c531611 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -82,6 +82,7 @@ pub enum Tool { Brush, Pencil, MixerBrush, + ColorReplacement, Eraser, BackgroundEraser, MagicEraser, @@ -123,7 +124,7 @@ pub enum Tool { } impl Tool { - pub const ALL: [Tool; 55] = [ + pub const ALL: [Tool; 56] = [ Tool::Move, Tool::RectMarquee, Tool::EllipseMarquee, @@ -141,6 +142,7 @@ impl Tool { Tool::Brush, Tool::Pencil, Tool::MixerBrush, + Tool::ColorReplacement, Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser, @@ -191,6 +193,7 @@ impl Tool { Tool::Brush => "Brush Tool", Tool::Pencil => "Pencil Tool", Tool::MixerBrush => "Mixer Brush Tool", + Tool::ColorReplacement => "Color Replacement Tool", Tool::Eraser => "Eraser Tool", Tool::BackgroundEraser => "Background Eraser Tool", Tool::MagicEraser => "Magic Eraser Tool", @@ -251,6 +254,7 @@ impl Tool { Tool::Brush | Tool::Pencil | Tool::MixerBrush + | Tool::ColorReplacement | Tool::Eraser | Tool::BackgroundEraser | Tool::Remove @@ -272,7 +276,7 @@ impl Tool { match self { Tool::Move => 'V', Tool::RectMarquee | Tool::EllipseMarquee => 'M', - Tool::Brush | Tool::Pencil | Tool::MixerBrush => 'B', + Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::ColorReplacement => 'B', Tool::Eraser | Tool::BackgroundEraser | Tool::MagicEraser => 'E', Tool::Eyedropper | Tool::Ruler | Tool::Note | Tool::Count => 'I', Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso => 'L', @@ -574,6 +578,14 @@ pub struct ToolOptions { pub bg_limits: String, pub bg_tolerance: f32, pub bg_protect_fg: bool, + /// Color Replacement: mode (hue | saturation | color | luminosity), sampling (continuous | + /// once | backgroundSwatch), limits (discontiguous | contiguous | findEdges), tolerance % and + /// anti-alias, as `paint.colorReplacement` takes them. + pub cr_mode: String, + pub cr_sampling: String, + pub cr_limits: String, + pub cr_tolerance: f32, + pub cr_anti_alias: bool, /// Zoom tool › Scrubby Zoom: dragging left/right zooms continuously (else a zoom rectangle). #[serde(default = "yes")] pub zoom_scrubby: bool, @@ -721,6 +733,11 @@ impl Default for ToolOptions { bg_limits: "contiguous".into(), bg_tolerance: 50.0, bg_protect_fg: false, + cr_mode: "color".into(), + cr_sampling: "continuous".into(), + cr_limits: "contiguous".into(), + cr_tolerance: 30.0, + cr_anti_alias: true, zoom_scrubby: true, pencil_auto_erase: false, eraser_mode: default_eraser_mode(), @@ -1204,13 +1221,15 @@ mod tests { assert_eq!(Tool::from_name("Eraser Tool"), Some(Tool::Eraser)); assert_eq!(Tool::from_name("mixerBrush"), Some(Tool::MixerBrush)); assert_eq!(Tool::from_name("Mixer Brush Tool"), Some(Tool::MixerBrush)); + assert_eq!(Tool::from_name("colorReplacement"), Some(Tool::ColorReplacement)); + assert_eq!(Tool::from_name("Color Replacement Tool"), Some(Tool::ColorReplacement)); assert_eq!(Tool::from_name("redEye"), Some(Tool::RedEye)); assert_eq!(Tool::from_name("Red Eye Tool"), Some(Tool::RedEye)); assert_eq!(Tool::RedEye.key(), 'J'); assert!(!Tool::RedEye.is_brushlike()); assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp)); assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp)); - assert_eq!(Tool::ALL.len(), 55); + assert_eq!(Tool::ALL.len(), 56); assert_eq!(Tool::from_name("Remove Tool"), Some(Tool::Remove)); assert_eq!(Tool::Remove.key(), 'J'); assert!(Tool::Remove.is_brushlike()); diff --git a/docs/scorecard.md b/docs/scorecard.md index a1e456fbd4..f1c83e8c52 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | -| [Tools](#tools) | 21 | 17 | 4 | | +| [Tools](#tools) | 22 | 17 | 4 | | | [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 37 of 152 (target 0) | | [Type](#type) | 0 | 3 | 6 | | | [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) | @@ -172,6 +172,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-23 | Crop: Photoshop's default mode (the image turns and moves behind an upright box, Auto Center Preview keeps a resized box's centre in place and centres a drawn one) and Use Classic Mode (P) in the gear menu | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_mode.rs: the crop model (crop_rect + crop_angle) and image.crop are shared by both modes; default mode sets the view's rotation to -crop_angle while the tool shows a frame (picking the tool, in either mode, turns a Rotate View turn upright about the view centre, not restored after Enter/Esc or by P; Classic Mode keeps the camera upright with the frame drawn turned; as Photoshop 25.1), pivoting so the box stays put, moves view and frame together for drags inside and arrow keys, keeps a resized box's centre in place live (Auto Center Preview; a drawn box is centred on release, a moved one never), readout = the image's turn; gear menu order and Classic Mode greying as Photoshop 25.1; CropShield classic_mode / auto_center_preview (ui.set cropShield), P ahead of the Pen key | | TOOL-1053-1 | Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity | done | [#1053](https://github.com/storytold/photocraft/issues/1053) | shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. Gradient fill UI and saved stroke libraries remain open; Line retains filled-bar geometry | | TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Missing: generative AI mode, Remove after each stroke off, live preview of the result | +| TOOL-1045-1 | Color Replacement tool on the toolbar | done | [#1045](https://github.com/storytold/photocraft/issues/1045) | Tool::ColorReplacement in the Brush (B) flyout after the Mixer Brush; Mode/Sampling/Limits/Tolerance/Anti-alias options drive paint.colorReplacement with the foreground colour; live preview (LiveDab with the stroke's finishing dab), one History step; color_replacement_tests.rs | ## Workspace and UI diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 023ac15ee7..c2b1d23f2d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -431,7 +431,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | | UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | -| Tools | 2026-10-09: Pencil, Mixer Brush, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | +| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | | Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | | Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. | | Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 843618e9d0..4c5817ee01 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -298,3 +298,10 @@ target = "Remove tool: paint over an object (or circle it) and it is filled from status = "partial" issue = 213 note = "paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Missing: generative AI mode, Remove after each stroke off, live preview of the result" + +[[item]] +id = "TOOL-1045-1" +target = "Color Replacement tool on the toolbar" +status = "done" +issue = 1045 +note = "Tool::ColorReplacement in the Brush (B) flyout after the Mixer Brush; Mode/Sampling/Limits/Tolerance/Anti-alias options drive paint.colorReplacement with the foreground colour; live preview (LiveDab with the stroke's finishing dab), one History step; color_replacement_tests.rs"