From c04624d119b39adc5e0cda190260bae4173f40e4 Mon Sep 17 00:00:00 2001 From: jason4short Date: Thu, 8 Oct 2026 18:58:21 -0700 Subject: [PATCH 1/2] Live strokes for the History Brush, Healing Brush and the dab tools (#1040) * Live strokes for the History Brush, Healing Brush and the dab tools The Clone Stamp's live stroke now covers every brush-based retouching tool: - History Brush and Healing Brush: LiveClone becomes LiveRetouch, which paints from the clone source or the History Brush's history state (history_source, shared with the commit). The Healing Brush shows its cloned texture while drawing and blends it on release; it also gets the source preview under the brush. - Dodge, Burn, Sponge, Blur, Sharpen and Smudge: their settings are parsed once into DabTool, whose per-dab effect both the command (dab_cmd) and the new LiveDab use. LiveDab runs each new dab's effect on a working copy of the target, in order, and mixes the changed area back at the brush opacity with mix_back (split out of apply_dab_stroke), as the commit does. Sample All Layers strokes keep the trail (their composite spans the whole stroke). Part of #213 (live previews for brush-based tools). Co-Authored-By: Claude Opus 5.5 (1M context) * Format live retouch test Co-Authored-By: Claude Opus 5.5 (1M context) * Trail test: use Spot Healing, which still previews a trail Co-Authored-By: Claude Opus 5.5 (1M context) * LiveDab::push: read new dabs with get(), no slice-index panic path Rebased onto #1026 as rebased on main; LiveRetouch::begin already reads the first stroke point with first() (resolved in the rebase) and the Healing Brush's own source overlay is dropped with clone_overlay.rs in favour of main's Clone Source preview (#1042). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/engine/src/retouch_cmds.rs | 390 ++++++++++++++++-------- crates/engine/src/retouch_cmds/tests.rs | 3 +- crates/paint/src/retouch.rs | 17 +- crates/ui-egui/src/canvas.rs | 56 +++- crates/ui-egui/src/retouch_ui.rs | 29 +- 5 files changed, 340 insertions(+), 155 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 43f76c3b35..0de1c1b0ea 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -303,10 +303,19 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { Ok(clone_result(dmg, off, aligned, p, &stroke)) } -/// A Clone Stamp stroke shown while it is drawn: the dabs so far composited from the clone source -/// onto a copy of the active document, as `paint.cloneStamp` with the same params commits them -/// (the same coverage, sampling and blend; a smoothing catch-up appears on release). -pub struct LiveClone { +/// What a live retouching stroke paints through its coverage. +enum LivePaint { + /// The clone source (Clone Stamp; the Healing Brush shows its texture while drawing and + /// blends it on release). + Clone { which: SampleLayers, map: crate::presets::clone_source::Mapping, cmd: &'static str }, + /// The History Brush's history state. + History(Surface), +} + +/// A retouching stroke shown while it is drawn: the dabs so far composited onto a copy of the +/// active document as the command with the same params commits them (the same coverage, +/// sampling and blend): Clone Stamp, History Brush, and the Healing Brush's texture. +pub struct LiveRetouch { /// The active document with the stroke so far. pub doc: std::sync::Arc, renderer: photocraft_paint::StrokeRenderer, @@ -314,8 +323,7 @@ pub struct LiveClone { pre_surf: Surface, id: Option, params: Value, - which: SampleLayers, - map: crate::presets::clone_source::Mapping, + paint: LivePaint, mode: BlendMode, opacity: f32, sel: Option, @@ -325,16 +333,25 @@ pub struct LiveClone { tail: Rect, } -impl LiveClone { - /// Start from `paint.cloneStamp` params; their `points` are rendered. - pub fn begin(s: &Session, p: &Value) -> Result { - const CMD: &str = "paint.cloneStamp"; +impl LiveRetouch { + /// Start a live stroke of `cmd` (`paint.cloneStamp`, `paint.healingBrush` or + /// `paint.historyBrush`) from its params; their `points` are rendered. + pub fn begin(s: &Session, cmd: &str, p: &Value) -> Result { has_pixel_layer(s).map_err(EngineError::Other)?; - let (stroke, id) = parse_brush(s, p, CMD)?; - let which = sample_layers(p, CMD)?; - let mode = blend_param(p, CMD)?; - let f = *stroke.points.first().ok_or_else(|| bad(CMD, "`points` is empty"))?; - let (map, _) = crate::presets::clone_source::resolve(s, p, (f.x, f.y), CMD)?; + let (stroke, id) = parse_brush(s, p, cmd)?; + let mode = blend_param(p, cmd)?; + let paint = match cmd { + "paint.cloneStamp" | "paint.healingBrush" => { + let f = *stroke.points.first().ok_or_else(|| bad(cmd, "`points` is empty"))?; + LivePaint::Clone { + which: sample_layers(p, cmd)?, + map: crate::presets::clone_source::resolve(s, p, (f.x, f.y), cmd)?.0, + cmd: if cmd == "paint.healingBrush" { "paint.healingBrush" } else { "paint.cloneStamp" }, + } + } + "paint.historyBrush" => LivePaint::History(history_source(s, id, p, cmd)?), + other => return Err(bad(other, "live retouching strokes are the Clone Stamp, Healing Brush and History Brush")), + }; let pre = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let mut doc = (*pre).clone(); let sel = doc.selection.clone(); @@ -343,8 +360,12 @@ impl LiveClone { // As `stroke_coverage`, which the commit uses. let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); let opacity = stroke.brush.opacity; + let paint = match paint { + LivePaint::History(src) if src.format() != pre_surf.format() => LivePaint::History(src.convert(pre_surf.format())), + other => other, + }; let mut live = - Self { doc: std::sync::Arc::new(doc), renderer, pre, pre_surf, id, params: p.clone(), which, map, mode, opacity, sel, lock, tail: Rect::EMPTY }; + Self { doc: std::sync::Arc::new(doc), renderer, pre, pre_surf, id, params: p.clone(), paint, mode, opacity, sel, lock, tail: Rect::EMPTY }; live.push(&stroke.points)?; Ok(live) } @@ -387,7 +408,10 @@ impl LiveClone { if r.is_empty() { return Ok(r); } - let paint = clone_sample(&self.pre, self.id, &self.pre_surf, self.which, r, &self.map, "paint.cloneStamp")?; + let paint = match &self.paint { + LivePaint::Clone { which, map, cmd } => clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, cmd)?, + LivePaint::History(src) => Region::read(src, r), + }; let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; surf.write_region(r, &self.pre_surf.read_region(r)); Ok(apply_coverage(surf, r, cov, self.opacity, self.sel.as_ref(), self.lock, &paint, self.mode).union(&r)) @@ -455,23 +479,27 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { Ok(clone_result(dmg, off, aligned, p, &stroke)) } -fn history_brush(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "paint.historyBrush"; - let (stroke, id) = parse_brush(s, p, CMD)?; - let mode = blend_param(p, CMD)?; +/// The History Brush's source: the targeted surface (pixels, mask, channel) as it was in history +/// state `state` (default: the oldest state still held, the "Open" snapshot unless trimmed). +fn history_source(s: &Session, id: Option, p: &Value, cmd: &str) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let explicit = p.get("state").and_then(Value::as_u64).map(|v| v as usize); let source_doc = match explicit { Some(i) if i == st.history.past_len() => st.doc.clone(), - Some(i) => st.history.state(i).ok_or_else(|| bad(CMD, format!("no history state {i} (0..={})", st.history.past_len())))?, - // Default: the oldest state still held (the "Open" snapshot unless it was trimmed). + Some(i) => st.history.state(i).ok_or_else(|| bad(cmd, format!("no history state {i} (0..={})", st.history.past_len())))?, None => st.history.state(0).unwrap_or_else(|| st.doc.clone()), }; - // The same target (pixels, mask, channel) as it was in that state. let mut source_doc = (*source_doc).clone(); - let src_surface = crate::channel_cmds::target_surface(&mut source_doc, id, p) + crate::channel_cmds::target_surface(&mut source_doc, id, p) .map(|(surf, _)| surf.clone()) - .map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?; + .map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into())) +} + +fn history_brush(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "paint.historyBrush"; + let (stroke, id) = parse_brush(s, p, CMD)?; + let mode = blend_param(p, CMD)?; + let src_surface = history_source(s, id, p, CMD)?; let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| { let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; @@ -639,37 +667,217 @@ fn tone_range(p: &Value, cmd: &str) -> Result { } } -fn dodge_burn_cmd(s: &mut Session, p: &Value, burn: bool) -> Result { - let cmd = if burn { "paint.burn" } else { "paint.dodge" }; +/// A sequential-dab tool's settings (Dodge, Burn, Sponge, Blur, Sharpen, Smudge), parsed once and +/// turned into its per-dab effect, for the commit and the live preview alike. +#[derive(Clone, Debug)] +enum DabTool { + Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool }, + Sponge { saturate: bool, vibrance: bool }, + Focus { sharpen: bool, strength: f32, protect: bool, sigma: f32 }, + Smudge { strength: f32, finger: Option<[f32; 4]>, max_size: f32 }, +} + +/// One dab's edit of the working copy. +type DabEffect = Box; + +impl DabTool { + /// The tool for `cmd` and its history label. + fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> { + Ok(match cmd { + "paint.dodge" | "paint.burn" => { + let burn = cmd == "paint.burn"; + let tool = Self::Tone { + burn, + range: tone_range(p, cmd)?, + exposure: num(p, "exposure", 50.0).clamp(1.0, 100.0) / 100.0, + protect: flag(p, "protectTones", true), + }; + (tool, if burn { "Burn Tool" } else { "Dodge Tool" }) + } + "paint.sponge" => { + let saturate = match string(p, "mode", "desaturate") { + "desaturate" => false, + "saturate" => true, + o => return Err(bad(cmd, format!("unknown mode `{o}` (desaturate|saturate)"))), + }; + (Self::Sponge { saturate, vibrance: flag(p, "vibrance", true) }, "Sponge Tool") + } + "paint.blur" | "paint.sharpen" => { + let sharpen = cmd == "paint.sharpen"; + let tool = Self::Focus { + sharpen, + strength: num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0, + protect: flag(p, "protectDetail", true), + sigma: (stroke.brush.size * 0.03).clamp(1.0, 4.0), + }; + (tool, if sharpen { "Sharpen Tool" } else { "Blur Tool" }) + } + "paint.smudge" => { + let finger = flag(p, "fingerPainting", false).then_some(s.tools.foreground); + (Self::Smudge { strength: num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0, finger, max_size: stroke.brush.size }, "Smudge Tool") + } + o => return Err(bad(o, "not a sequential-dab tool")), + }) + } + + /// How far around each dab the effect reads (Blur and Sharpen read a neighbourhood). + fn halo(&self) -> i32 { + match self { + Self::Focus { sigma, .. } => (sigma * 3.0).ceil() as i32 + 1, + _ => 0, + } + } + + /// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`. + fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect { + let sp = spacing; + match self.clone() { + Self::Tone { burn, range, exposure, protect } => { + Box::new(move |work, fp| color_dab(&fmt, work, fp, exposure, sp, |c, k| dodge_burn(c, k, range, burn, protect))) + } + // Flow is already in each dab's coverage; a full-flow pass moves colours half-way. + Self::Sponge { saturate, vibrance } => Box::new(move |work, fp| color_dab(&fmt, work, fp, 0.5, sp, |c, k| sponge(c, k, saturate, vibrance))), + Self::Focus { sharpen, strength, protect, sigma } => { + let a = alpha_index(&fmt); + Box::new(move |work, fp| { + let r = fp.rect.intersect(&work.rect); + if r.is_empty() { + return; + } + let (w, h, n) = (work.width(), work.height(), work.ch); + let win = ((r.x0 - work.rect.x0) as usize, (r.y0 - work.rect.y0) as usize, (r.x1 - work.rect.x0) as usize, (r.y1 - work.rect.y0) as usize); + let out = if sharpen { local_sharpen(&work.data, w, h, n, a, win, 1.0, protect) } else { local_blur(&work.data, w, h, n, a, win, sigma) }; + let rw = r.width() as usize; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let k = per_dab(fp.at(x, y) * strength, sp); + if k <= 0.0 { + continue; + } + let o = ((y - r.y0) as usize * rw + (x - r.x0) as usize) * n; + let px = work.px_mut(x, y); + for c in 0..n { + px[c] += (out[o + c] - px[c]) * k; + } + } + } + }) + } + Self::Smudge { strength, finger, max_size } => { + let finger_px = finger.map(|fg| photocraft_raster::from_rgba(&fmt, fg)); + // Premultiplied mixing: transparent pixels carry no colour, so no dark fringes. + let mut sm = Smudge::new(strength, finger_px, max_size).with_alpha(alpha_index(&fmt)); + Box::new(move |work, fp| sm.dab(work, fp)) + } + } + } +} + +/// Dodge, Burn, Sponge, Blur, Sharpen and Smudge: each dab edits a working copy in order +/// (`apply_dab_stroke`). Blur, Sharpen and Smudge can sample all layers. +fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result { let (stroke, id) = parse_brush(s, p, cmd)?; - let range = tone_range(p, cmd)?; - let exposure = num(p, "exposure", 50.0).clamp(1.0, 100.0) / 100.0; - let protect = flag(p, "protectTones", true); - let label = if burn { "Burn Tool" } else { "Dodge Tool" }; - let dmg = run_stroke(s, label, id, p, |_, surf, sel, lock| { + let (tool, label) = DabTool::parse(s, cmd, p, &stroke)?; + let all = matches!(tool, DabTool::Focus { .. } | DabTool::Smudge { .. }) && sample_all_layers(p); + let dmg = run_stroke(s, label, id, p, |pre, surf, sel, lock| { let fmt = surf.format(); - let sp = stroke.brush.spacing; - Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| color_dab(&fmt, work, fp, exposure, sp, |c, k| dodge_burn(c, k, range, burn, protect)))) + let mut effect = tool.effect(fmt, stroke.brush.spacing); + let mut sampler = all.then(|| AllLayers::new(pre, fmt)); + Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.halo(), |work, fp| match &mut sampler { + Some(all) => all.dab(work, fp, |w, f| effect(w, f)), + None => effect(work, fp), + })) })?; Ok(json!({ "damage": damage_json(dmg) })) } -fn sponge_cmd(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "paint.sponge"; - let (stroke, id) = parse_brush(s, p, CMD)?; - let saturate = match string(p, "mode", "desaturate") { - "desaturate" => false, - "saturate" => true, - o => return Err(bad(CMD, format!("unknown mode `{o}` (desaturate|saturate)"))), - }; - let vibrance = flag(p, "vibrance", true); - let dmg = run_stroke(s, "Sponge Tool", id, p, |_, surf, sel, lock| { - let fmt = surf.format(); - let sp = stroke.brush.spacing; - // Flow is already in each dab's coverage; a full-flow pass moves colours half-way. - Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| color_dab(&fmt, work, fp, 0.5, sp, |c, k| sponge(c, k, saturate, vibrance)))) - })?; - Ok(json!({ "damage": damage_json(dmg) })) +/// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab +/// runs the tool's effect on a working copy of the target, in order, and the changed area is +/// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release. +/// Sample All Layers isn't previewed (its composite spans the whole stroke). +pub struct LiveDab { + /// The active document with the stroke so far. + pub doc: std::sync::Arc, + renderer: photocraft_paint::StrokeRenderer, + done: usize, + effect: DabEffect, + halo: i32, + pre_surf: Surface, + work: Surface, + id: Option, + params: Value, + opacity: f32, + sel: Option, + lock: bool, + bounds: Rect, +} + +impl LiveDab { + /// Start a live stroke of `cmd` from its params; their `points` are rendered. + pub fn begin(s: &Session, cmd: &str, p: &Value) -> Result { + has_pixel_layer(s).map_err(EngineError::Other)?; + if sample_all_layers(p) { + return Err(bad(cmd, "Sample All Layers strokes aren't previewed live")); + } + let (stroke, id) = parse_brush(s, p, cmd)?; + let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; + 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); + // `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 { + doc: std::sync::Arc::new(doc), + renderer, + done: 0, + effect, + halo: tool.halo(), + work: pre_surf.clone(), + pre_surf, + id, + params: p.clone(), + opacity: stroke.brush.opacity, + sel, + lock, + bounds: Rect::EMPTY, + }; + live.push(&stroke.points)?; + Ok(live) + } + + /// Everything the stroke has touched so far. + pub fn bounds(&self) -> Rect { + self.bounds + } + + /// Render more points; returns the rectangle that changed. + pub fn push(&mut self, pts: &[StrokePoint]) -> Result { + self.renderer.push(pts); + let ctx = self.renderer.ctx.clone(); + let new: Vec<_> = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec(); + let mut dmg = Rect::EMPTY; + for (k, d) in new.iter().enumerate() { + let r = ctx.dab_rect(d, false); + if r.is_empty() { + continue; + } + let mut region = Region::read(&self.work, r.inflate(self.halo)); + (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); + region.crop(r).write(&mut self.work); + dmg = dmg.union(&r); + } + self.done += new.len(); + if dmg.is_empty() { + return Ok(dmg); + } + self.bounds = self.bounds.union(&dmg); + let (orig, work) = (Region::read(&self.pre_surf, dmg), Region::read(&self.work, dmg)); + 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) + } } /// "Sample All Layers" for Spot Healing, Blur, Sharpen and Smudge (layer pixels only: a mask or @@ -711,74 +919,6 @@ impl<'a> AllLayers<'a> { } } -/// Blur (`sharpen = false`) or Sharpen each dab's footprint in the working copy. -fn focus_cmd(s: &mut Session, p: &Value, sharpen: bool) -> Result { - let cmd = if sharpen { "paint.sharpen" } else { "paint.blur" }; - let (stroke, id) = parse_brush(s, p, cmd)?; - let strength = num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0; - let protect = flag(p, "protectDetail", true); - let sigma = (stroke.brush.size * 0.03).clamp(1.0, 4.0); - let halo = (sigma * 3.0).ceil() as i32 + 1; - let label = if sharpen { "Sharpen Tool" } else { "Blur Tool" }; - let all = sample_all_layers(p); - let dmg = run_stroke(s, label, id, p, |pre, surf, sel, lock| { - let fmt = surf.format(); - let a = alpha_index(&fmt); - let sp = stroke.brush.spacing; - let mut sampler = all.then(|| AllLayers::new(pre, fmt)); - let focus = |work: &mut Region, fp: &Footprint| { - let r = fp.rect.intersect(&work.rect); - if r.is_empty() { - return; - } - let (w, h, n) = (work.width(), work.height(), work.ch); - let win = ((r.x0 - work.rect.x0) as usize, (r.y0 - work.rect.y0) as usize, (r.x1 - work.rect.x0) as usize, (r.y1 - work.rect.y0) as usize); - let out = if sharpen { local_sharpen(&work.data, w, h, n, a, win, 1.0, protect) } else { local_blur(&work.data, w, h, n, a, win, sigma) }; - let rw = r.width() as usize; - for y in r.y0..r.y1 { - for x in r.x0..r.x1 { - let k = per_dab(fp.at(x, y) * strength, sp); - if k <= 0.0 { - continue; - } - let o = ((y - r.y0) as usize * rw + (x - r.x0) as usize) * n; - let px = work.px_mut(x, y); - for c in 0..n { - px[c] += (out[o + c] - px[c]) * k; - } - } - } - }; - Ok(apply_dab_stroke(surf, &stroke, sel, lock, halo, |work, fp| match &mut sampler { - Some(all) => all.dab(work, fp, focus), - None => focus(work, fp), - })) - })?; - Ok(json!({ "damage": damage_json(dmg) })) -} - -fn smudge_cmd(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "paint.smudge"; - let (stroke, id) = parse_brush(s, p, CMD)?; - let strength = num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0; - let finger = flag(p, "fingerPainting", false); - let fg = s.tools.foreground; - let all = sample_all_layers(p); - let dmg = run_stroke(s, "Smudge Tool", id, p, |pre, surf, sel, lock| { - let fmt = surf.format(); - let finger_px = finger.then(|| photocraft_raster::from_rgba(&fmt, fg)); - let max_size = stroke.brush.size; - // Premultiplied mixing: transparent pixels carry no colour, so no dark fringes. - let mut sm = Smudge::new(strength, finger_px, max_size).with_alpha(alpha_index(&fmt)); - let mut sampler = all.then(|| AllLayers::new(pre, fmt)); - Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| match &mut sampler { - Some(all) => all.dab(work, fp, |w, f| sm.dab(w, f)), - None => sm.dab(work, fp), - })) - })?; - Ok(json!({ "damage": damage_json(dmg) })) -} - // --------------------------------------------------------------------------------------------- // Registry // --------------------------------------------------------------------------------------------- @@ -866,7 +1006,7 @@ pub fn specs() -> Vec { shortcut: None, params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), enabled: has_pixel_layer, - run: |s, p| dodge_burn_cmd(s, p, false), + run: |s, p| dab_cmd(s, p, "paint.dodge"), journal: true, }, CommandSpec { @@ -876,7 +1016,7 @@ pub fn specs() -> Vec { shortcut: None, params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#), enabled: has_pixel_layer, - run: |s, p| dodge_burn_cmd(s, p, true), + run: |s, p| dab_cmd(s, p, "paint.burn"), journal: true, }, CommandSpec { @@ -886,7 +1026,7 @@ pub fn specs() -> Vec { shortcut: None, params: brush_params!(r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength)"#), enabled: has_pixel_layer, - run: sponge_cmd, + run: |s, p| dab_cmd(s, p, "paint.sponge"), journal: true, }, CommandSpec { @@ -896,7 +1036,7 @@ pub fn specs() -> Vec { shortcut: None, params: brush_params!(r#","strength":1..100=50,"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, - run: |s, p| focus_cmd(s, p, false), + run: |s, p| dab_cmd(s, p, "paint.blur"), journal: true, }, CommandSpec { @@ -906,7 +1046,7 @@ pub fn specs() -> Vec { shortcut: None, params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, - run: |s, p| focus_cmd(s, p, true), + run: |s, p| dab_cmd(s, p, "paint.sharpen"), journal: true, }, CommandSpec { @@ -916,7 +1056,7 @@ pub fn specs() -> Vec { shortcut: None, params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, - run: smudge_cmd, + run: |s, p| dab_cmd(s, p, "paint.smudge"), journal: true, }, CommandSpec { diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 058722ffe4..aae6d6dc64 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1099,7 +1099,8 @@ fn live_clone_matches_the_commit() { paint_layer(&mut s, texture); let p = json!({"points": [[50, 30], [60, 34], [70, 30], [80, 36]], "source": [20, 20], "size": 14, "hardness": 40, "opacity": 70}); let pts = |v: &[[f64; 2]]| v.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect::>(); - let mut live = LiveClone::begin(&s, &json!({"points": [[50, 30]], "source": [20, 20], "size": 14, "hardness": 40, "opacity": 70})).unwrap(); + let mut live = + LiveRetouch::begin(&s, "paint.cloneStamp", &json!({"points": [[50, 30]], "source": [20, 20], "size": 14, "hardness": 40, "opacity": 70})).unwrap(); live.push(&pts(&[[60.0, 34.0], [70.0, 30.0]])).unwrap(); live.push(&pts(&[[80.0, 36.0]])).unwrap(); let shown = live.doc.clone(); diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index 7e853d837d..30a27836d7 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -240,13 +240,21 @@ pub fn apply_dab_stroke( for (i, d) in ds.iter().enumerate() { effect(&mut work, &ctx.footprint(d, i)); } + mix_back(target, &orig, &work, bounds, stroke.brush.opacity, selection, lock_transparency); + bounds +} + +/// Mix a sequential-dab stroke's working pixels `work` back over the original `orig` at +/// `opacity × selection` inside `rect`, and write that area to `target`: the last step of +/// [`apply_dab_stroke`], shared with live previews that run the dabs as they come. +pub fn mix_back(target: &mut Surface, orig: &Region, work: &Region, rect: Rect, opacity: f32, selection: Option<&Surface>, lock_transparency: bool) { let fmt = target.format(); let a = alpha_index(&fmt); - let mut out = orig.crop(bounds); + let mut out = orig.crop(rect); let n = out.ch; - let opacity = stroke.brush.opacity.clamp(0.0, 1.0); - for y in bounds.y0..bounds.y1 { - for x in bounds.x0..bounds.x1 { + let opacity = opacity.clamp(0.0, 1.0); + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { let k = opacity * selection.map_or(1.0, |s| s.sample_channel(x, y, 0)); if k <= 0.0 { continue; @@ -269,7 +277,6 @@ pub fn apply_dab_stroke( } } out.write(target); - bounds } /// `dst ← lerp(dst, src, k)` in premultiplied space (straight in, straight out; `a` = alpha index). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2acc49e64c..4c9690f38b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -237,37 +237,42 @@ pub(crate) fn draw_readout(ctx: &egui::Context, id: &str, cursor: Pos2, labels: }); } -/// The engine's live stroke of a tool: the Brush, Pencil and Eraser paint dabs, the Clone Stamp -/// composites its source. +/// The engine's live stroke of a tool: the Brush, Pencil and Eraser paint dabs; the Clone Stamp, +/// Healing Brush and History Brush composite their source. enum EngineStroke { Brush(Box), - Clone(Box), + Retouch(photocraft_engine::retouch_cmds::LiveRetouch), + Dab(photocraft_engine::retouch_cmds::LiveDab), } impl EngineStroke { fn doc(&self) -> &std::sync::Arc { match self { Self::Brush(s) => &s.doc, - Self::Clone(s) => &s.doc, + Self::Retouch(s) => &s.doc, + Self::Dab(s) => &s.doc, } } fn bounds(&self) -> DRect { match self { Self::Brush(s) => s.bounds(), - Self::Clone(s) => s.bounds(), + Self::Retouch(s) => s.bounds(), + Self::Dab(s) => s.bounds(), } } fn push(&mut self, pts: &[photocraft_engine::paint::StrokePoint]) -> photocraft_engine::Result { match self { Self::Brush(s) => s.push(pts), - Self::Clone(s) => s.push(pts), + Self::Retouch(s) => s.push(pts), + Self::Dab(s) => s.push(pts), } } /// The jitter seed the commit must reuse (the Clone Stamp's comes from the session brush). fn seed(&self) -> Option { match self { Self::Brush(s) => Some(s.seed), - Self::Clone(_) => None, + Self::Retouch(_) => None, + Self::Dab(_) => None, } } } @@ -316,7 +321,19 @@ fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec bool { - matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser | Tool::CloneStamp) + 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() +} + +/// The retouching tools drawn live (`LiveRetouch`), by the command their live stroke previews. +/// The Healing Brush shows its texture while drawing; the blend comes on release. +fn live_retouch_command(tool: Tool) -> Option<&'static str> { + match tool { + Tool::CloneStamp => Some("paint.cloneStamp"), + Tool::Healing => Some("paint.healingBrush"), + Tool::HistoryBrush => Some("paint.historyBrush"), + _ => None, + } } /// The command a live-stroking tool commits: the Pencil's `paint.pencil`, else `paint.stroke`. @@ -416,12 +433,17 @@ 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 d.tool == Tool::CloneStamp { - // The params `retouch_ui::finish_stroke` commits. - let mut p = crate::retouch_ui::clone_params(app)?; + let stroke = 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); - EngineStroke::Clone(Box::new(photocraft_engine::retouch_cmds::LiveClone::begin(&app.session, &p).ok()?)) + EngineStroke::Dab(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?) + } else if let Some(cmd) = live_retouch_command(d.tool) { + // The params `retouch_ui::finish_stroke` commits. + let mut p = if d.tool == Tool::HistoryBrush { json!({}) } else { crate::retouch_ui::clone_params(app)? }; + p["points"] = json!(d.points); + p["target"] = paint_target(app); + EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?) } else { let p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points)); EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(d.tool), &p).ok()?)) @@ -2738,8 +2760,9 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View draw_marquee_readout(painter.ctx(), xf.to_screen(last[0] as f32, last[1] as f32), marquee_readout(marquee_px(a, b))); } match d.tool { - // The canvas shows the live stroke itself (`LiveStroke`). - t if strokes_live(t) => {} + // The canvas shows the live stroke itself (`LiveStroke`); a stroke that couldn't start one + // (Sample All Layers, say) keeps the trail. + t if strokes_live(t) && app.live_stroke.is_some() => {} t if t.is_brushlike() || t == Tool::QuickSelection => { // Retouching strokes preview as a translucent trail of the brush footprint: a mask, // not a brush-wide egui polyline (which zoomed in tessellates into wedges, #189). @@ -3283,7 +3306,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { app.last_stroke_end = Some((st.doc.id, [end[0], end[1]])); } // A live Clone Stamp preview ends here; the commit below replaces it. - if d.tool == Tool::CloneStamp { + if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() { 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) { @@ -4013,7 +4036,8 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); app.run("file.new", json!({"width": 120, "height": 60})).unwrap(); app.run("tools.setBrush", json!({"brush": {"size": 30}})).unwrap(); - app.ui.tool = Tool::Dodge; + // Spot Healing: the dab tools draw their stroke live instead of a trail. + app.ui.tool = Tool::SpotHealing; tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE); let mut pts = vec![[20.0, 30.0]]; for i in 1..80 { diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index f0e8775b92..62a99f04da 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -39,6 +39,23 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { Some(p) } +/// The command and options-bar params (without `points` and `target`) of a sequential-dab tool: +/// Blur, Sharpen, Smudge, Dodge, Burn, Sponge. Shared by the live preview and the commit. +pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static str, Value)> { + let o = &app.ui.tool_options; + Some(match tool { + Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all_layers})), + Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all_layers})), + Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all_layers})), + Tool::Dodge | Tool::Burn => ( + if tool == Tool::Dodge { "paint.dodge" } else { "paint.burn" }, + json!({"range": o.tone_range, "exposure": o.exposure, "protectTones": o.protect_tones}), + ), + Tool::Sponge => ("paint.sponge", json!({"mode": o.sponge_mode, "vibrance": o.vibrance})), + _ => return None, + }) +} + /// 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(); @@ -78,14 +95,10 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m (if tool == Tool::Healing { "paint.healingBrush" } else { "paint.cloneStamp" }, p) } Tool::HistoryBrush => ("paint.historyBrush", json!({})), - Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all_layers})), - Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all_layers})), - Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all_layers})), - Tool::Dodge | Tool::Burn => ( - if tool == Tool::Dodge { "paint.dodge" } else { "paint.burn" }, - json!({"range": o.tone_range, "exposure": o.exposure, "protectTones": o.protect_tones}), - ), - Tool::Sponge => ("paint.sponge", json!({"mode": o.sponge_mode, "vibrance": o.vibrance})), + Tool::Blur | Tool::Sharpen | Tool::Smudge | Tool::Dodge | Tool::Burn | Tool::Sponge => match dab_params(app, tool) { + Some(cp) => cp, + None => return false, + }, Tool::QuickSelection => { let size = app.session.tools.brush.size; let mode = if mods.alt { "subtract" } else { "add" }; From b58b8df36b2275059755dd190ac5fe986b3134be Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 9 Oct 2026 09:00:44 +0700 Subject: [PATCH 2/2] Non-Unicode arguments: translate the desktop notice (follow-up to #1168) (#1359) * Non-Unicode arguments: translate the notice; keep run's doc comment on run The "Could not open" notice for a path that is not valid Unicode is translated in every shipped language, and the CLI's "Run the CLI" doc comment moves back onto run (run_os has its own). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA * Non-Unicode arguments: Polish translations Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA --------- Co-authored-by: Claude --- apps/photocraft/src/main.rs | 6 ++++-- crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/id.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ko.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ 14 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 146fdaaded..e67a819f59 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -439,8 +439,10 @@ fn main() -> eframe::Result { // Paths on the command line (Linux/Windows file associations, `photocraft a.psd`). app.open_paths(&files); if !unreadable_paths.is_empty() { - let lines = unreadable_paths.iter().map(|p| format!("{p}: the path is not valid Unicode; rename the file and open it again.")).collect(); - photocraft_ui_egui::notices::post(&mut app, "Could not open", lines, true, None); + use photocraft_ui_egui::i18n; + let line = i18n::t("{path}: the path is not valid Unicode; rename the file and open it again."); + let lines = unreadable_paths.iter().map(|p| i18n::fmt(line, &[("path", p.as_str())])).collect(); + photocraft_ui_egui::notices::post(&mut app, i18n::t("Could not open"), lines, true, None); } // Portable marker found but its data folder isn't writable (#228): say where settings went. if let Some(w) = &app_dirs::current().warning { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5a5bbb95c3..0e95bc5ac7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2163,6 +2163,8 @@ cameraRaw Color Barvy Channels: Kanály: Knockout Vyřazení Apply At Canvas Bounds Použít efekt na hranicích plátna + Could not open Nelze otevřít + {path}: the path is not valid Unicode; rename the file and open it again. {path}: cesta není platný Unicode; přejmenujte soubor a otevřete jej znovu. # Fill dialog, layer style contours and Image Size Contents: Obsah: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b7d3c853f2..3e41e5d9e3 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2203,6 +2203,8 @@ Channels: Kanäle: Knockout Aussparung Apply At Canvas Bounds Effekt an Arbeitsflächenbegrenzungen anwenden + Could not open Öffnen nicht möglich + {path}: the path is not valid Unicode; rename the file and open it again. {path}: Der Pfad ist kein gültiges Unicode; benennen Sie die Datei um und öffnen Sie sie erneut. # Fill dialog, layer style contours and Image Size Contents: Inhalt: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e1f4c19b75..840ff8304b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2234,6 +2234,8 @@ cameraRaw Amount Cantidad Channels: Canales: Knockout Cobertura Apply At Canvas Bounds Aplicar efecto en los límites del lienzo + Could not open No se pudo abrir + {path}: the path is not valid Unicode; rename the file and open it again. {path}: la ruta no es Unicode válido; cambia el nombre del archivo y vuelve a abrirlo. # Fill dialog, layer style contours and Image Size Contents: Contenido: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c0de419d37..a37c383d9d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2594,6 +2594,8 @@ cameraRaw Blending Fusion Channels: Couches : Knockout Masquage Apply At Canvas Bounds Appliquer l’effet aux limites de la zone de travail + Could not open Impossible d’ouvrir + {path}: the path is not valid Unicode; rename the file and open it again. {path} : le chemin n’est pas un Unicode valide ; renommez le fichier puis rouvrez-le. # Fill dialog, layer style contours and Image Size Contents: Contenu : diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 86a9d98e05..af653b21f3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2581,6 +2581,8 @@ cameraRaw Blending Pembauran Channels: Channel: Knockout Knockout Apply At Canvas Bounds Terapkan efek di batas kanvas + Could not open Tidak dapat membuka + {path}: the path is not valid Unicode; rename the file and open it again. {path}: jalur bukan Unicode yang valid; ganti nama file lalu buka lagi. # Fill dialog, layer style contours and Image Size Contents: Isi: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 162b3fc104..69ae7fa53a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2237,6 +2237,8 @@ cameraRaw Amount Quantità Add to Swatches Aggiungi ai campioni Convert to Layers Converti in livelli Apply At Canvas Bounds Applica effetto ai limiti dell’area di lavoro + Could not open Impossibile aprire + {path}: the path is not valid Unicode; rename the file and open it again. {path}: il percorso non è Unicode valido; rinomina il file e riaprilo. # Fill dialog, layer style contours and Image Size Contents: Contenuto: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b3f30cd479..c9f725d63d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2146,6 +2146,8 @@ cameraRaw Color カラー Channels: チャンネル: Knockout ノックアウト Apply At Canvas Bounds カンバスの境界に効果を適用 + Could not open 開けませんでした + {path}: the path is not valid Unicode; rename the file and open it again. {path}: パスが有効な Unicode ではありません。ファイル名を変更してから開き直してください。 # Fill dialog, layer style contours and Image Size Contents: 内容: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e10cfeb8c2..1cf49d392b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2753,6 +2753,8 @@ cameraRaw Color 색상 Channels: 채널: Knockout 녹아웃 Apply At Canvas Bounds 캔버스 경계에 효과 적용 + Could not open 열 수 없음 + {path}: the path is not valid Unicode; rename the file and open it again. {path}: 경로가 올바른 유니코드가 아닙니다. 파일 이름을 바꾼 뒤 다시 여세요. Good 좋음 Best 최적 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 59ae547b4b..25c2964942 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3194,6 +3194,8 @@ cameraRaw Roughness Chropowatość More documents Więcej dokumentów Developing {name} Wywoływanie {name} Camera Raw ({name}) Camera Raw ({name}) + Could not open Nie można otworzyć + {path}: the path is not valid Unicode; rename the file and open it again. {path}: ścieżka nie jest prawidłowym Unicode; zmień nazwę pliku i otwórz go ponownie. Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Natywne przeciąganie i upuszczanie plików nie jest jeszcze obsługiwane w Wayland. Użyj Plik › Otwórz albo skopiuj obraz w menedżerze plików i wklej go za pomocą {paste}. To drop files, start PhotoCraft under XWayland: `{command}` Aby upuszczać pliki, uruchom PhotoCraft pod XWayland: `{command}` Edit › Paste Edycja › Wklej diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6d0b464a7f..4111d3250d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2601,6 +2601,8 @@ cameraRaw Whites Brancos Channels: Canais: Knockout Vazado Apply At Canvas Bounds Aplicar efeito nos limites da tela + Could not open Não foi possível abrir + {path}: the path is not valid Unicode; rename the file and open it again. {path}: o caminho não é Unicode válido; renomeie o arquivo e abra-o novamente. # Fill dialog, layer style contours and Image Size Contents: Conteúdo: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9626313bac..8dae0f1af5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2440,6 +2440,8 @@ cameraRaw Roughness Неровность Channels: Каналы: Knockout Просвечивание Apply At Canvas Bounds Применить эффект на границах холста + Could not open Не удалось открыть + {path}: the path is not valid Unicode; rename the file and open it again. {path}: путь не является допустимым Unicode; переименуйте файл и откройте его снова. # Fill dialog, layer style contours and Image Size Contents: Содержимое: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7df29203a8..84f1a0ca85 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2171,6 +2171,8 @@ cameraRaw Blending 混合 Channels: 通道: Knockout 挖空 Apply At Canvas Bounds 应用画布边界的效果 + Could not open 无法打开 + {path}: the path is not valid Unicode; rename the file and open it again. {path}:路径不是有效的 Unicode;请重命名文件后再打开。 # Fill dialog, layer style contours and Image Size Contents: 内容: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8778621279..285ec44075 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2140,6 +2140,8 @@ cameraRaw Blending 混合 Channels: 色版: Knockout 去底色 Apply At Canvas Bounds 套用效果至版面邊界 + Could not open 無法開啟 + {path}: the path is not valid Unicode; rename the file and open it again. {path}:路徑不是有效的 Unicode;請重新命名檔案後再開啟。 # Fill dialog, layer style contours and Image Size Contents: 內容: