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 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Fabio Lussi
2026-10-11 05:20:00 -04:00
committed by GitHub
co-authored by Fabio Lussi Claude Opus 5.5 Brandon Thomas
parent d8217b3476
commit 073e1cc1d2
37 changed files with 631 additions and 38 deletions
+2 -1
View File
@@ -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) | <https://github.com/rsms/inter> | 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 | <https://github.com/JetBrains/JetBrainsMono> | 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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/feathericons/feather> 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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `icons/heart.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
+2 -2
View File
@@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 55 tools</h4>
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
<h4>🧰 56 tools</h4>
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
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
+19
View File
@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 10 3 3" />
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
<path d="M2.5 9A6 6 0 0 1 8 3" />
<path d="m6 1.5 2 1.5-2 1.5" />
<path d="M21.5 15a6 6 0 0 1-5.5 6" />
<path d="m18 19.5-2 1.5 2 1.5" />
</svg>

After

Width:  |  Height:  |  Size: 544 B

+24 -4
View File
@@ -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<Value> {
Ok(damage_json(s, dmg))
}
fn color_replacement(s: &mut Session, p: &Value) -> Result<Value> {
/// `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<T: serde::de::DeserializeOwned>(p: &Value, cmd: &str, k: &str) -> Result<Option<T>> {
@@ -673,7 +679,11 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<crate::retouch_cmds::LiveDab> {
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<CommandSpec> {
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
+54
View File
@@ -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::<Vec<_>>();
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);
+63 -9
View File
@@ -788,7 +788,7 @@ enum DabTool {
}
/// One dab's edit of the working copy.
type DabEffect = Box<dyn FnMut(&mut Region, &Footprint)>;
pub(crate) type DabEffect = Box<dyn FnMut(&mut Region, &Footprint)>;
impl DabTool {
/// The tool for `cmd` and its history label.
@@ -919,6 +919,13 @@ pub struct LiveDab {
sel: Option<Surface>,
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<LayerId>,
halo: i32,
effect: impl FnOnce(&Surface) -> DabEffect,
) -> Result<Self> {
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> {
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))
}
}
+9
View File
@@ -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<Dab> {
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
+13 -6
View File
@@ -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)]
+22 -4
View File
@@ -467,8 +467,19 @@ fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec<f64
/// Tools whose strokes the engine renders while they are drawn (`LiveStroke`).
pub(crate) fn strokes_live(tool: Tool) -> 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<LiveStroke> {
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,
@@ -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");
}
+1 -1
View File
@@ -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;
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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 Ανάμειξη
Can't render this file because it contains an unexpected character in line 973 and column 18.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -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
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2042
2043
2044
2045
2046
2047
2048
+1
View File
@@ -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 ウェットのジッター
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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 / 호환성
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2080
2081
2082
2083
2084
2085
2086
2087
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -515,6 +515,7 @@
Magnetic Lasso Магнитное лассо
Mixer Brush Смешивающая кисть
Mixer Brush Tool Инструмент «Смешивающая кисть»
Color Replacement Tool Инструмент «Замена цвета»
History Brush Кисть истории
Art History Brush Кисть истории искусств
Pattern Stamp Узорный штамп
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -494,6 +494,7 @@
Magnetic Lasso Магнітне ласо
Mixer Brush Змішувальний пензель
Mixer Brush Tool Інструмент «Змішувальний пензель»
Color Replacement Tool Інструмент «Заміна кольору»
History Brush Пензель історії
Art History Brush Художній пензель історії
Pattern Stamp Візерунковий штамп
1 # Ukrainian (uk) catalog. UTF-8: context<TAB>English source<TAB>translation.
494
495
496
497
498
499
500
+2
View File
@@ -1198,6 +1198,8 @@
Midtones from: 中间调下限:
Midtones to: 中间调上限:
Mixer Brush Tool 混合画笔工具
Color Replacement Tool 颜色替换工具
Color Replacement 颜色替换
Minimum Depth 最小深度
Minimum Diameter 最小直径
Minimum Roundness 最小圆度
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
1198
1199
1200
1201
1202
1203
1204
1205
+2
View File
@@ -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 濕度快速變換
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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")),
+1
View File
@@ -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",
+2
View File
@@ -2045,6 +2045,8 @@ mod color_swatch_tests;
#[cfg(test)]
mod input_tests;
#[cfg(test)]
mod color_replacement_tests;
#[cfg(test)]
mod pencil_tests;
+40 -2
View File
@@ -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<Rect> = 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");
+6 -2
View File
@@ -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));
}
+55 -2
View File
@@ -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"));
+22 -3
View File
@@ -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());
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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. |
+7
View File
@@ -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"