Blur Gallery: Field Blur pins on the image (#3373) (#3801)

* Blur Gallery: Field Blur pins on the image (#3373)

Field Blur opened a parameter dialog with one centre, though the engine
already blends any number of pins. It now works as Photoshop's Blur
Gallery workspace: the image under the dialog takes the pointer. A
click adds a pin and selects it, a drag moves a pin, and a drag around
the selected pin's ring turns its blur up or down (one turn is the
0-500 px range; the arc shows the amount). Delete or Backspace removes
the selected pin. The dialog shows the selected pin's Blur and Preview,
and opens at the right edge, beside the image, so the pins stay in view.

The pins live in the dialog's fields (`pins`, `__selected`), so the
existing filter preview follows every change, OK applies
filter.blurGallery.fieldBlur with them in one step, and automation
drives it with ui.dialog.set. Pins are fractions of the selection's
bounds when there is one, else the canvas, as the engine reads them.
Proxy previews now scale Blur Gallery pins' pixel blur like other pixel
params.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(vi): translate the Field Blur pin strings

main added the Vietnamese catalog after this branch was cut, so
every_tl_literal_is_translated failed on the merge for "No pin
selected" and the pin hint. Terms follow the catalog's own: ghim
(pin), Bấm (click), Delete kept as the key name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* Blur Gallery: Alt+Cancel Reset starts again from one pin

main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema
defaults, and the schema has none for Field Blur's pins, so Reset
left the workspace with no pins. blur_pins::reset now puts back the
opening pin (in the middle, selected), and reset_fields runs it for
the Blur Gallery. init is reset plus capturing the bounds, which
Reset keeps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(hu, tr): translate the Field Blur pin strings

main added complete Hungarian and Turkish catalogs after this branch
was cut. Terms follow each catalog: tű / Mezőelmosás (informal
Hungarian), pim / Alan Bulanıklığı (formal Turkish).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE

* i18n(sr): translate the Field Blur pin strings

Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Timothy DalyandClaude Opus 5.5 authored and GitHub committed 2026-10-13 13:10:11 -04:00
1 parent 50631b54f7
commit be7a1f80fc
27 files changed
+592 -3

No files matched your search

+311
View File
@@ -0,0 +1,311 @@
//! Filter › Blur Gallery › Field Blur… as Photoshop's workspace (#3373): pins on the image, each
//! with its own blur, interpolated between them. While the dialog is open the image under it takes
//! the pointer: a click on the image adds a pin (and selects it), a drag moves a pin, and a drag on
//! the selected pin's ring turns its blur up or down (one turn = the full 0–500 px; the arc shows
//! the amount). Delete or Backspace removes the selected pin. The dialog shows the selected pin's
//! Blur and the Preview switch; OK runs `filter.blurGallery.fieldBlur` with the `pins`.
//!
//! Everything lives in the dialog's fields, so automation drives it with `ui.dialog.set`: `pins`
//! (`[{x, y, blur}]`, x and y as fractions of the filter's bounds) and `__selected`. The bounds are
//! the engine's: the selection's when there is one, else the canvas.
use egui::{Color32, Pos2, Stroke, vec2};
use serde_json::{Map, Value, json};
use crate::PhotocraftApp;
use crate::canvas::ViewXform;
pub const COMMAND: &str = "filter.blurGallery.fieldBlur";
/// Field Blur's range, in pixels.
const MAX_BLUR: f64 = 500.0;
/// Photoshop's first pin.
const DEFAULT_BLUR: f64 = 15.0;
/// More pins than anyone places by hand; keeps the params and the per-pixel loop bounded.
pub const MAX_PINS: usize = 64;
/// Screen radii (points): the pin's dot, how near a press must be to grab it, and its ring.
const DOT: f32 = 5.0;
const GRAB: f32 = 9.0;
const RING: f32 = 24.0;
const RING_BAND: f32 = 5.0;
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.get("__command").and_then(Value::as_str) == Some(COMMAND)
}
/// Turn a freshly opened Field Blur dialog into the workspace: one pin in the middle of the
/// filter's bounds with the remembered blur, selected.
pub fn init(app: &mut PhotocraftApp, fields: &mut Map<String, Value>) {
reset(fields);
let bounds = app.session.active().map(|st| (st.doc.id, st.doc.bounds(), st.doc.selection.clone()));
if let Some((doc, canvas, selection)) = bounds {
let b = selection.map(|s| app.cached_bounds(u64::MAX - doc.0, &s)).filter(|b| !b.is_empty()).unwrap_or(canvas);
fields.insert("__bounds".into(), json!([b.x0, b.y0, b.x1, b.y1]));
}
}
/// One pin in the middle with the dialog's blur, selected. Alt+Cancel (Reset) runs this alone, so
/// the bounds captured on opening stay.
pub fn reset(fields: &mut Map<String, Value>) {
let blur = fields.get("blur").and_then(Value::as_f64).filter(|b| b.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, MAX_BLUR);
fields.remove("centerX");
fields.remove("centerY");
fields.insert("blur".into(), json!(blur));
fields.insert("pins".into(), json!([{"x": 0.5, "y": 0.5, "blur": blur}]));
fields.insert("__selected".into(), json!(0));
}
/// Where the dialog first opens (offset from the window's top-left): at the right edge, beside the
/// image, as Photoshop docks its Blur Tools panel, so the pins under it stay in view. Moved, it
/// reopens where it was left, like any dialog.
pub fn first_place(fields: &Map<String, Value>, ctx: &egui::Context) -> Option<egui::Vec2> {
owns(fields).then(|| vec2((ctx.content_rect().width() - 480.0).max(0.0), 96.0))
}
/// One pin: position as fractions of the bounds, blur in pixels.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pin {
pub x: f64,
pub y: f64,
pub blur: f64,
}
/// The pins in `fields`, cleaned (finite, in range, at most [`MAX_PINS`]).
pub fn pins(fields: &Map<String, Value>) -> Vec<Pin> {
let Some(list) = fields.get("pins").and_then(Value::as_array) else { return Vec::new() };
let num = |v: &Value, k: &str, d: f64| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(d);
list.iter()
.take(MAX_PINS)
.map(|v| Pin { x: num(v, "x", 0.5).clamp(0.0, 1.0), y: num(v, "y", 0.5).clamp(0.0, 1.0), blur: num(v, "blur", DEFAULT_BLUR).clamp(0.0, MAX_BLUR) })
.collect()
}
fn set_pins(fields: &mut Map<String, Value>, pins: &[Pin]) {
let list: Vec<Value> = pins.iter().map(|p| json!({"x": p.x, "y": p.y, "blur": p.blur})).collect();
fields.insert("pins".into(), Value::Array(list));
}
/// The selected pin's index, if it exists.
pub fn selected(fields: &Map<String, Value>) -> Option<usize> {
let n = pins(fields).len();
fields.get("__selected").and_then(Value::as_u64).map(|i| i as usize).filter(|i| *i < n)
}
fn select(fields: &mut Map<String, Value>, i: Option<usize>) {
fields.insert("__selected".into(), i.map_or(Value::Null, |i| json!(i)));
// The dialog's Blur (remembered for the next Field Blur) follows the selected pin.
if let Some(p) = i.and_then(|i| pins(fields).get(i).copied()) {
fields.insert("blur".into(), json!(p.blur));
}
}
/// The filter's bounds in document pixels `[x0, y0, x1, y1]`.
fn bounds(fields: &Map<String, Value>) -> Option<[f64; 4]> {
let b = fields.get("__bounds")?.as_array()?;
let v = |i: usize| b.get(i).and_then(Value::as_f64);
let r = [v(0)?, v(1)?, v(2)?, v(3)?];
(r[2] > r[0] && r[3] > r[1]).then_some(r)
}
fn to_doc(b: [f64; 4], p: Pin) -> [f64; 2] {
[b[0] + p.x * (b[2] - b[0]), b[1] + p.y * (b[3] - b[1])]
}
fn from_doc(b: [f64; 4], d: [f64; 2]) -> (f64, f64) {
(((d[0] - b[0]) / (b[2] - b[0])).clamp(0.0, 1.0), ((d[1] - b[1]) / (b[3] - b[1])).clamp(0.0, 1.0))
}
/// Remove the selected pin; the one before it (or the new first) is selected.
pub fn remove_selected(fields: &mut Map<String, Value>) -> bool {
let Some(i) = selected(fields) else { return false };
let mut list = pins(fields);
list.remove(i);
set_pins(fields, &list);
select(fields, if list.is_empty() { None } else { Some(i.saturating_sub(1)) });
true
}
/// The dialog body: the selected pin's Blur, how to place pins, and Preview.
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let t = crate::theme::Tokens::get(ui.ctx());
match selected(f) {
Some(i) => {
let mut list = pins(f);
if let Some(pin) = list.get_mut(i) {
let mut v = pin.blur as f32;
if crate::widgets::slider_row(ui, tl!("Blur"), &mut v, 0.0..=MAX_BLUR as f32, "px", None).changed() {
pin.blur = f64::from(v.round()).clamp(0.0, MAX_BLUR);
set_pins(f, &list);
select(f, Some(i));
}
}
}
None => {
ui.label(egui::RichText::new(tl!("No pin selected")).color(t.text_dim));
}
}
ui.add_space(4.0);
ui.add(
egui::Label::new(
egui::RichText::new(tl!("Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin."))
.color(t.text_dim)
.size(11.5),
)
.wrap(),
);
// Delete removes the selected pin, unless a field is being typed in.
let typing = ui.ctx().memory(|m| m.focused().is_some());
if !typing && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace)) {
remove_selected(f);
ui.ctx().request_repaint();
}
if let Some(mut preview) = f.get("__preview").and_then(Value::as_bool) {
ui.add_space(4.0);
crate::widgets::checkbox(ui, &mut preview, tl!("Preview"));
f.insert("__preview".into(), json!(preview));
}
}
/// What a press at screen point `at` grabs: the selected pin's ring, a pin, or nothing (a new pin).
#[derive(Clone, Copy, Debug, PartialEq)]
enum Grab {
Ring(usize),
Pin(usize),
New,
}
fn grab(fields: &Map<String, Value>, xf: &ViewXform, at: Pos2) -> Grab {
let Some(b) = bounds(fields) else { return Grab::New };
let list = pins(fields);
let screen = |p: Pin| {
let d = to_doc(b, p);
xf.to_screen(d[0] as f32, d[1] as f32)
};
if let Some(i) = selected(fields)
&& let Some(p) = list.get(i)
&& (screen(*p).distance(at) - RING).abs() <= RING_BAND
{
return Grab::Ring(i);
}
list.iter()
.enumerate()
.map(|(i, p)| (i, screen(*p).distance(at)))
.filter(|(_, d)| *d <= GRAB)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map_or(Grab::New, |(i, _)| Grab::Pin(i))
}
/// The pointer on the free image under the dialog: `over` is where it is, `press` where the
/// primary button is held (`dialogs::free_press`), both in screen points. Returns whether the
/// workspace took it.
pub fn canvas(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, over: Pos2, press: Option<Pos2>) -> bool {
let Some(d) = app.ui.dialogs.last_mut().filter(|d| owns(&d.fields)) else { return false };
let f = &mut d.fields;
let Some(b) = bounds(f) else { return false };
let (start, down) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down()));
ctx.set_cursor_icon(match grab(f, xf, over) {
Grab::Ring(_) => egui::CursorIcon::Alias,
Grab::Pin(_) => egui::CursorIcon::Grab,
Grab::New => egui::CursorIcon::Crosshair,
});
if !down && press.is_none() {
f.remove("__drag");
return true;
}
let Some(at) = press else { return true };
let doc = xf.to_doc(at);
let mut list = pins(f);
if start {
let drag = match grab(f, xf, at) {
Grab::Ring(i) => {
let c = list.get(i).map(|p| to_doc(b, *p)).unwrap_or(doc);
json!({"ring": i, "angle": (doc[1] - c[1]).atan2(doc[0] - c[0])})
}
Grab::Pin(i) => {
select(f, Some(i));
let c = list.get(i).map(|p| to_doc(b, *p)).unwrap_or(doc);
json!({"pin": i, "dx": c[0] - doc[0], "dy": c[1] - doc[1]})
}
Grab::New if list.len() < MAX_PINS && (b[0]..=b[2]).contains(&doc[0]) && (b[1]..=b[3]).contains(&doc[1]) => {
// A new pin takes the selected pin's blur, as the dialog's Blur shows it.
let blur = f.get("blur").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(DEFAULT_BLUR).clamp(0.0, MAX_BLUR);
let (x, y) = from_doc(b, doc);
list.push(Pin { x, y, blur });
set_pins(f, &list);
select(f, Some(list.len() - 1));
json!({"pin": list.len() - 1, "dx": 0.0, "dy": 0.0})
}
Grab::New => return true,
};
f.insert("__drag".into(), drag);
ctx.request_repaint();
return true;
}
let Some(drag) = f.get("__drag").cloned() else { return true };
if let Some(i) = drag.get("pin").and_then(Value::as_u64).map(|i| i as usize)
&& let Some(p) = list.get_mut(i)
{
let num = |k: &str| drag.get(k).and_then(Value::as_f64).unwrap_or(0.0);
(p.x, p.y) = from_doc(b, [doc[0] + num("dx"), doc[1] + num("dy")]);
set_pins(f, &list);
} else if let Some(i) = drag.get("ring").and_then(Value::as_u64).map(|i| i as usize)
&& let Some(p) = list.get_mut(i)
{
// The angle turned since the last frame, the short way round: clockwise raises the blur.
let c = to_doc(b, *p);
let a = (doc[1] - c[1]).atan2(doc[0] - c[0]);
let last = drag.get("angle").and_then(Value::as_f64).unwrap_or(a);
let turn = (a - last + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
p.blur = (p.blur + turn / std::f64::consts::TAU * MAX_BLUR).clamp(0.0, MAX_BLUR);
set_pins(f, &list);
select(f, Some(i));
f.insert("__drag".into(), json!({"ring": i, "angle": a}));
}
ctx.request_repaint();
true
}
/// Draw the pins over the image while the workspace is open: a dot per pin (filled when
/// selected) and, round the selected one, its ring with the blur as an arc from the top.
pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
let Some(d) = app.ui.dialogs.last().filter(|d| owns(&d.fields)) else { return };
let Some(b) = bounds(&d.fields) else { return };
let sel = selected(&d.fields);
let (dark, light) = (Stroke::new(3.0, Color32::from_black_alpha(150)), Stroke::new(1.5, Color32::from_white_alpha(235)));
for (i, p) in pins(&d.fields).into_iter().enumerate() {
let c = to_doc(b, p);
let c = xf.to_screen(c[0] as f32, c[1] as f32);
if sel == Some(i) {
painter.circle_stroke(c, RING, dark);
painter.circle_stroke(c, RING, Stroke::new(1.0, Color32::from_white_alpha(140)));
let frac = (p.blur / MAX_BLUR) as f32;
let steps = (frac * 64.0).ceil().max(1.0) as usize;
let arc: Vec<Pos2> = (0..=steps)
.map(|k| {
let a = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * frac * k as f32 / steps as f32;
c + vec2(a.cos(), a.sin()) * RING
})
.collect();
if frac > 0.0 {
painter.add(egui::Shape::line(arc.clone(), Stroke::new(4.5, Color32::from_black_alpha(150))));
painter.add(egui::Shape::line(arc, Stroke::new(2.5, Color32::WHITE)));
}
painter.circle(c, DOT, Color32::WHITE, Stroke::new(1.0, Color32::from_black_alpha(180)));
} else {
painter.circle_stroke(c, DOT, dark);
painter.circle_stroke(c, DOT, light);
}
}
}
/// Preview proxies run at 1/`k` of the document: the pins' pixel blur scales with them.
pub fn scale_for_proxy(params: &mut Value, k: u32) {
if k <= 1 {
return;
}
let Some(list) = params.get_mut("pins").and_then(Value::as_array_mut) else { return };
for pin in list {
if let Some(b) = pin.get("blur").and_then(Value::as_f64) {
pin["blur"] = json!(b / f64::from(k));
}
}
}
+193
View File
@@ -0,0 +1,193 @@
//! #3373: Field Blur's pins are placed, dragged and tuned on the image under the dialog, the
//! preview follows them, and OK applies them.
use egui::{Modifiers, PointerButton, Pos2, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
use crate::PhotocraftApp;
use crate::blur_pins::{COMMAND, Pin, pins, selected};
use crate::canvas::ViewXform;
/// A 400×300 document at 400 %, so it fills the window round the dialog, with Field Blur open.
fn harness(selection: bool) -> Harness<'static, PhotocraftApp> {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
if selection {
app.run("select.rect", json!({"x": 100, "y": 50, "width": 200, "height": 100})).unwrap();
}
app.sync_views();
app.ui.extras.rulers = false;
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::shortcuts::handle(app, &ctx);
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
crate::dialogs::show(app, &ctx);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(4);
let v = &mut h.state_mut().ui.views[0];
(v.zoom, v.center, v.fit_pending) = (4.0, [200.0, 150.0], false);
crate::filter_dialog::open(h.state_mut(), COMMAND).unwrap();
h.run_steps(4);
h
}
fn fields(h: &Harness<'static, PhotocraftApp>) -> serde_json::Map<String, serde_json::Value> {
h.state().ui.dialogs.last().unwrap().fields.clone()
}
fn xf(h: &Harness<'static, PhotocraftApp>) -> ViewXform {
let app = h.state();
let v = &app.ui.views[0];
ViewXform {
rect: crate::rulers::content_rect(app, app.last_canvas_rect),
zoom: v.zoom,
center: v.center,
flip: false,
rotation: v.rotation,
aspect: app.ui.view.display_aspect(),
}
}
fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool) {
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: Modifiers::NONE });
h.run_steps(1);
}
fn move_to(h: &mut Harness<'static, PhotocraftApp>, p: Pos2) {
h.event(egui::Event::PointerMoved(p));
h.run_steps(1);
}
fn drag(h: &mut Harness<'static, PhotocraftApp>, path: &[Pos2]) {
move_to(h, path[0]);
button(h, path[0], true);
for p in &path[1..] {
move_to(h, *p);
}
button(h, *path.last().unwrap(), false);
h.run_steps(2);
}
/// Where pin `p` is on screen.
fn on_screen(h: &Harness<'static, PhotocraftApp>, p: Pin, bounds: [f64; 4]) -> Pos2 {
let (x, y) = (bounds[0] + p.x * (bounds[2] - bounds[0]), bounds[1] + p.y * (bounds[3] - bounds[1]));
xf(h).to_screen(x as f32, y as f32)
}
#[test]
fn it_opens_with_one_selected_pin_in_the_middle_of_the_bounds() {
let h = harness(false);
let f = fields(&h);
assert_eq!(pins(&f), vec![Pin { x: 0.5, y: 0.5, blur: 15.0 }]);
assert_eq!(selected(&f), Some(0));
assert_eq!(f["__bounds"], json!([0, 0, 400, 300]));
assert!(!f.contains_key("centerX") && !f.contains_key("centerY"), "the pins replace the single centre");
// With a selection the pins are fractions of its bounds, as the engine reads them.
let h = harness(true);
assert_eq!(fields(&h)["__bounds"], json!([100, 50, 300, 150]));
}
#[test]
fn pins_are_added_moved_tuned_and_removed_on_the_image() {
let mut h = harness(false);
let r = h.state().last_canvas_rect;
// A click on the free image (left of the dialog) adds a pin there, selected.
let a = pos2(r.left() + 80.0, r.bottom() - 80.0);
move_to(&mut h, a);
button(&mut h, a, true);
button(&mut h, a, false);
h.run_steps(2);
let f = fields(&h);
let list = pins(&f);
assert_eq!(list.len(), 2, "{list:?}");
assert_eq!(selected(&f), Some(1));
let at = xf(&h).to_doc(a);
assert!((list[1].x - at[0] / 400.0).abs() < 1e-3 && (list[1].y - at[1] / 300.0).abs() < 1e-3, "{:?} vs {at:?}", list[1]);
assert_eq!(list[1].blur, 15.0, "a new pin takes the selected pin's blur");
// Dragging the pin moves it by the pointer's movement (4 screen points per pixel at 400 %).
let b = a + vec2(40.0, -20.0);
drag(&mut h, &[a, a + vec2(20.0, -10.0), b]);
let moved = pins(&fields(&h))[1];
assert!((moved.x - (list[1].x + 10.0 / 400.0)).abs() < 1e-3 && (moved.y - (list[1].y - 5.0 / 300.0)).abs() < 1e-3, "{moved:?}");
assert!(fields(&h).get("__drag").is_none(), "the drag ends on release");
// A quarter turn clockwise on its ring adds a quarter of the 0–500 px range.
let c = on_screen(&h, moved, [0.0, 0.0, 400.0, 300.0]);
let ring: Vec<Pos2> = (0..=6).map(|k| c + vec2(24.0 * (k as f32 * 0.2618).cos(), 24.0 * (k as f32 * 0.2618).sin())).collect();
drag(&mut h, &ring);
let tuned = pins(&fields(&h))[1];
assert!((tuned.blur - 140.0).abs() < 2.0, "15 + 125 px: {}", tuned.blur);
assert!((tuned.x - moved.x).abs() < 1e-9, "the ring doesn't move the pin");
assert_eq!(fields(&h)["blur"], json!(tuned.blur), "the dialog's Blur follows the selected pin");
// The preview runs the filter with the pins (computed by the canvas's GPU path in the app;
// asked for directly here, as the headless harness has no GPU).
assert!(crate::canvas::ensure_filter_preview(h.state_mut(), 0, &egui::Context::default()).is_some());
let preview = h.state().filter_preview.as_ref().expect("a live preview");
assert_eq!(preview.key.params["pins"].as_array().map(Vec::len), Some(2));
// Delete removes the selected pin; OK applies the rest in one step.
h.key_press(egui::Key::Delete);
h.run_steps(2);
assert_eq!(pins(&fields(&h)).len(), 1);
assert_eq!(selected(&fields(&h)), Some(0));
let id = h.state().ui.dialogs.last().unwrap().id;
let steps = h.state().session.active().unwrap().history.past_len();
crate::dialogs::confirm(h.state_mut(), id).unwrap();
let (cmd, p) = h.state().session.journal.last().cloned().unwrap();
assert_eq!(cmd, COMMAND);
assert_eq!(p["pins"], json!([{"x": 0.5, "y": 0.5, "blur": 15.0}]), "{p}");
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1);
}
/// A click outside the bounds (beside a selection) adds no pin; pins are capped.
#[test]
fn pins_stay_inside_the_bounds_and_are_capped() {
let mut h = harness(true);
let r = h.state().last_canvas_rect;
let outside = pos2(r.left() + 30.0, r.top() + 30.0);
assert!(xf(&h).to_doc(outside)[0] < 100.0, "left of the selection");
move_to(&mut h, outside);
button(&mut h, outside, true);
button(&mut h, outside, false);
h.run_steps(2);
assert_eq!(pins(&fields(&h)).len(), 1);
let many: Vec<_> = (0..200).map(|i| json!({"x": f64::from(i) / 200.0, "y": 0.5, "blur": 1e9})).collect();
h.state_mut().ui.dialogs.last_mut().unwrap().fields.insert("pins".into(), json!(many));
let list = pins(&fields(&h));
assert_eq!(list.len(), crate::blur_pins::MAX_PINS);
assert!(list.iter().all(|p| p.blur == 500.0));
}
#[test]
fn proxy_previews_scale_the_pins_blur() {
let mut p = json!({"pins": [{"x": 0.2, "y": 0.3, "blur": 40.0}, {"x": 0.9, "y": 0.9, "blur": 0.0}]});
crate::blur_pins::scale_for_proxy(&mut p, 4);
assert_eq!(p, json!({"pins": [{"x": 0.2, "y": 0.3, "blur": 10.0}, {"x": 0.9, "y": 0.9, "blur": 0.0}]}));
crate::blur_pins::scale_for_proxy(&mut p, 1);
assert_eq!(p["pins"][0]["blur"], json!(10.0));
}
/// Alt+Cancel → Reset (#3871) starts the workspace again from one pin in the middle, as when it
/// opened, instead of leaving it with no pins; the bounds captured on opening stay.
#[test]
fn alt_reset_starts_again_from_one_pin() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let id = crate::filter_dialog::open(&mut app, COMMAND).unwrap();
let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();
let bounds = fields["__bounds"].clone();
fields.insert("pins".into(), json!([{"x": 0.1, "y": 0.2, "blur": 40.0}, {"x": 0.8, "y": 0.7, "blur": 90.0}]));
fields.insert("__selected".into(), json!(1));
assert!(crate::dialog_alt_reset::supported(&fields));
crate::dialog_alt_reset::apply(&mut app, &egui::Context::default(), &mut fields);
assert_eq!(pins(&fields), [Pin { x: 0.5, y: 0.5, blur: 15.0 }]);
assert_eq!(selected(&fields), Some(0));
assert_eq!(fields["__bounds"], bounds);
assert!(!fields.contains_key("centerX") && !fields.contains_key("centerY"), "{fields:?}");
}
+10 -1
View File
@@ -3214,7 +3214,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// Select and Mask's Refine Edge Brush paints on the image.
let refine_brushing =
primary && app.ui.dialogs.last().is_some_and(|d| crate::select_mask_view::owns(&d.fields) && crate::select_mask_view::brush_on(&d.fields));
let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing;
// Field Blur's pins are placed and dragged on the image (`blur_pins`).
let pinning = primary && app.ui.dialogs.last().is_some_and(|d| crate::blur_pins::owns(&d.fields));
let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning;
if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) {
let d = xf.unmap_vec(d) / (view.zoom / ppp);
view.center[0] -= d.x;
@@ -3238,6 +3240,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
crate::color_picker_ui::sample_at(app, d[0], d[1]);
}
}
} else if pinning
&& !middle
&& let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect)
{
let press = crate::dialogs::free_press(&ctx, rect);
crate::blur_pins::canvas(app, &ctx, &xf, p, press);
} else if refine_brushing
&& !middle
&& let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect)
@@ -4445,6 +4453,7 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
crate::tool_feedback::draw_ants(painter, &pts, false);
}
crate::crop_straighten::draw(app, painter, |p| xf.to_screen(p[0] as f32, p[1] as f32), hover);
crate::blur_pins::draw(app, painter, xf);
if let Some(c) = app.ui.crop_rect {
let deg = crate::crop_ui::angle(app);
let shield = crate::crop_shield::current(app, painter.ctx());
+2 -1
View File
@@ -157,7 +157,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// effects, extends down and right instead of re-centring.
let pinned: Option<egui::Vec2> = ctx.data(|m| m.get_temp(id));
let place = place_id(&d);
let start = pinned.or_else(|| ctx.data_mut(|m| m.get_persisted::<egui::Vec2>(place)));
let start = pinned.or_else(|| ctx.data_mut(|m| m.get_persisted::<egui::Vec2>(place))).or_else(|| crate::blur_pins::first_place(&d.fields, ctx));
let mut drag = egui::Vec2::ZERO;
let mut sizing = false;
let area = match start {
@@ -305,6 +305,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Image Size and Canvas Size open on Width, selected (#2329).
DialogKind::Command if fields.contains_key("__sizing") => first_field_focused(ui, id, |ui| crate::sizing::body(ui, &mut fields)),
DialogKind::Command if crate::adjust_dialog::owns(&fields) => crate::adjust_dialog::body(app, ui, &mut fields),
DialogKind::Command if crate::blur_pins::owns(&fields) => crate::blur_pins::body(ui, &mut fields),
DialogKind::Command if fields.contains_key("__filter") => {
// Color Settings: the monitor profile in use can change while it is open.
if fields.get("__command").and_then(Value::as_str) == Some("edit.colorSettings") {
+11
View File
@@ -435,6 +435,10 @@ pub fn reset_fields(app: &PhotocraftApp, fields: &mut Map<String, Value>) {
}
}
apply_command_defaults(app, &command, fields);
// The schema has no default for the Blur Gallery's pins: start again from the opening one.
if command == crate::blur_pins::COMMAND {
crate::blur_pins::reset(fields);
}
}
pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
@@ -454,6 +458,9 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
}
restore_remembered(app, command, spec.params, &mut fields);
apply_command_defaults(app, command, &mut fields);
if command == crate::blur_pins::COMMAND {
crate::blur_pins::init(app, &mut fields);
}
if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) {
// The document picker lists every open document (params refer to them by index).
let names: Vec<String> = app.session.documents().iter().map(|d| d.doc.name.clone()).collect();
@@ -906,6 +913,10 @@ pub(crate) fn preview_document_for_target(
}
}
}
// Blur Gallery pins carry their blur in document pixels too.
if command.starts_with("filter.blurGallery.") {
crate::blur_pins::scale_for_proxy(&mut p, run_k);
}
s.execute(command, p).ok()?;
s.active().map(|d| crate::proxy::proxy_document(&d.doc, reduce_k))
}
+3
View File
@@ -559,6 +559,9 @@
Blur Rozostření
Blur Gallery Galerie rozostření
Field Blur… Rozostření pole…
# Field Blur pins (#3373)
No pin selected Není vybrán žádný špendlík
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknutím do obrazu přidáte špendlík; tažením špendlík přesunete, tažením jeho prstence změníte rozostření. Delete odstraní vybraný špendlík.
Iris Blur… Rozostření clonou…
Tilt-Shift… Tilt-shift…
Path Blur… Rozostření podél cesty…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -558,6 +558,9 @@
Blur Weichzeichnen
Blur Gallery Weichzeichnen-Galerie
Field Blur… Feldweichzeichner…
# Field Blur pins (#3373)
No pin selected Kein Pin ausgewählt
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klicken Sie ins Bild, um einen Pin hinzuzufügen; ziehen Sie einen Pin, um ihn zu verschieben, oder seinen Ring, um die Unschärfe zu ändern. Entf entfernt den ausgewählten Pin.
Iris Blur… Irisweichzeichner…
Tilt-Shift… Tilt-Shift…
Path Blur… Pfadweichzeichner…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -2398,6 +2398,9 @@
Fibers… Ίνες…
Field Blur Θόλωση πεδίου
Field Blur… Θόλωση πεδίου…
# Field Blur pins (#3373)
No pin selected Δεν έχει επιλεγεί καρφίτσα
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Κάντε κλικ στην εικόνα για να προσθέσετε καρφίτσα· σύρετε μια καρφίτσα για να τη μετακινήσετε ή τον δακτύλιό της για να αλλάξετε τη θόλωση. Το Delete αφαιρεί την επιλεγμένη καρφίτσα.
File Info Πληροφορίες αρχείου
File Settings Ρυθμίσεις αρχείου
Fill Path Γέμισμα διαδρομής
Can't render this file because it contains an unexpected character in line 1029 and column 18.
+3
View File
@@ -555,6 +555,9 @@
Blur Desenfocar
Blur Gallery Galería de desenfoque
Field Blur… Desenfoque de campo…
# Field Blur pins (#3373)
No pin selected Ningún pin seleccionado
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Haga clic en la imagen para añadir un pin; arrastre un pin para moverlo o su anillo para cambiar el desenfoque. Suprimir elimina el pin seleccionado.
Iris Blur… Desenfoque de iris…
Tilt-Shift… Cambio de inclinación…
Path Blur… Desenfoque de trazado…
Can't render this file because it contains an unexpected character in line 4 and column 47.
+3
View File
@@ -543,6 +543,9 @@
Blur Flou
Blur Gallery Galerie de flous
Field Blur… Flou de champ…
# Field Blur pins (#3373)
No pin selected Aucune épingle sélectionnée
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Cliquez dans l’image pour ajouter une épingle ; faites glisser une épingle pour la déplacer, ou son anneau pour modifier le flou. Suppr supprime l’épingle sélectionnée.
Iris Blur… Flou d'iris…
Tilt-Shift… Inclinaison-décalage…
Path Blur… Flou de tracé…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -561,6 +561,8 @@
Facet Lapolás
Fibers… Szálak…
Field Blur… Mezőelmosás…
No pin selected Nincs kijelölt tű
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kattints a képre tű hozzáadásához; húzd a tűt az áthelyezéséhez, vagy a gyűrűjét az elmosás módosításához. A Delete törli a kijelölt tűt.
Fill Path… Útvonal kitöltése…
Fill Screen Képernyő kitöltése
Filter Szűrő
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -536,6 +536,9 @@
Blur Blur
Blur Gallery Galeri Blur
Field Blur… Field Blur…
# Field Blur pins (#3373)
No pin selected Tidak ada pin yang dipilih
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik gambar untuk menambah pin; seret pin untuk memindahkannya, atau cincinnya untuk mengubah blur. Delete menghapus pin yang dipilih.
Iris Blur… Iris Blur…
Tilt-Shift… Tilt-Shift…
Path Blur… Path Blur…
Can't render this file because it contains an unexpected character in line 4 and column 47.
+3
View File
@@ -551,6 +551,9 @@
Blur Sfocatura
Blur Gallery Galleria sfocatura
Field Blur… Sfocatura campo…
# Field Blur pins (#3373)
No pin selected Nessuna puntina selezionata
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Fai clic sull’immagine per aggiungere una puntina; trascina una puntina per spostarla o il suo anello per cambiare la sfocatura. Canc rimuove la puntina selezionata.
Iris Blur… Sfocatura diaframma…
Tilt-Shift… Miniatura…
Path Blur… Sfocatura tracciato…
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
551
552
553
554
555
556
557
558
559
+3
View File
@@ -553,6 +553,9 @@
Blur ぼかし
Blur Gallery ぼかしギャラリー
Field Blur… フィールドぼかし…
# Field Blur pins (#3373)
No pin selected ピンが選択されていません
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 画像をクリックしてピンを追加します。ピンをドラッグして移動し、リングをドラッグしてぼかしを変更します。Delete キーで選択したピンを削除します。
Iris Blur… 虹彩絞りぼかし…
Tilt-Shift… チルトシフト…
Path Blur… パスぼかし…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -540,6 +540,9 @@
Blur 흐림 효과
Blur Gallery 흐림 효과 갤러리
Field Blur… 필드 흐림 효과…
# Field Blur pins (#3373)
No pin selected 선택한 핀 없음
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 이미지를 클릭하여 핀을 추가합니다. 핀을 드래그하여 이동하고, 링을 드래그하여 흐림 정도를 바꿉니다. Delete 키로 선택한 핀을 삭제합니다.
Iris Blur… 조리개 흐림 효과…
Tilt-Shift… 기울기-이동…
Path Blur… 패스 흐림 효과…
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
540
541
542
543
544
545
546
547
548
+3
View File
@@ -543,6 +543,9 @@
Blur Vervagen
Blur Gallery Vervagingsgalerie
Field Blur… Veldvervaging…
# Field Blur pins (#3373)
No pin selected Geen speld geselecteerd
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Klik in de afbeelding om een speld toe te voegen; sleep een speld om hem te verplaatsen, of zijn ring om de vervaging te wijzigen. Delete verwijdert de geselecteerde speld.
Iris Blur… Irisvervaging…
Tilt-Shift… Tilt-shift…
Path Blur… Padvervaging…
Can't render this file because it contains an unexpected character in line 8 and column 34.
+3
View File
@@ -552,6 +552,9 @@
Blur Rozmycie
Blur Gallery Galeria rozmyć
Field Blur… Rozmycie pola…
# Field Blur pins (#3373)
No pin selected Nie zaznaczono pinezki
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknij obraz, aby dodać pinezkę; przeciągnij pinezkę, aby ją przesunąć, lub jej pierścień, aby zmienić rozmycie. Delete usuwa zaznaczoną pinezkę.
Iris Blur… Rozmycie irysowe…
Tilt-Shift… Tilt-shift…
Path Blur… Rozmycie ścieżki…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -537,6 +537,9 @@
Blur Desfoque
Blur Gallery Galeria de desfoque
Field Blur… Desfoque de campo…
# Field Blur pins (#3373)
No pin selected Nenhum alfinete selecionado
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Clique na imagem para adicionar um alfinete; arraste um alfinete para movê-lo ou o anel dele para alterar o desfoque. Delete remove o alfinete selecionado.
Iris Blur… Desfoque de íris…
Tilt-Shift… Tilt-shift…
Path Blur… Desfoque de demarcador…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -343,6 +343,9 @@
Oil Paint… Масляная живопись…
Blur Gallery Галерея размытия
Field Blur… Размытие по полю…
# Field Blur pins (#3373)
No pin selected Булавка не выбрана
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Щелкните изображение, чтобы добавить булавку; перетащите булавку, чтобы переместить ее, или ее кольцо, чтобы изменить размытие. Delete удаляет выбранную булавку.
Iris Blur… Диафрагменное размытие…
Tilt-Shift… Наклон-сдвиг…
Path Blur… Размытие по контуру…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -533,6 +533,8 @@
Blur Zamućenje
Blur Gallery Galerija zamućenja
Field Blur… Zamućenje polja…
No pin selected Nijedna čioda nije izabrana
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Kliknite na sliku da dodate čiodu; prevucite čiodu da je pomerite ili njen prsten da promenite zamućenje. Delete uklanja izabranu čiodu.
Iris Blur… Iris zamućenje…
Tilt-Shift… Tilt-shift…
Path Blur… Zamućenje duž putanje…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -532,6 +532,8 @@
Blur Bulanıklaştırma
Blur Gallery Bulanıklaştırma Galerisi
Field Blur… Alan Bulanıklığı…
No pin selected Seçili pim yok
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Pim eklemek için görüntüye tıklayın; taşımak için pimi, bulanıklığını değiştirmek için halkasını sürükleyin. Delete seçili pimi siler.
Iris Blur… İris Bulanıklığı…
Tilt-Shift… Tilt-Shift…
Path Blur… Yol Bulanıklığı…
Can't render this file because it contains an unexpected character in line 8 and column 34.
+3
View File
@@ -321,6 +321,9 @@
Oil Paint… Олійний живопис…
Blur Gallery Галерея розмиття
Field Blur… Розмиття поля…
# Field Blur pins (#3373)
No pin selected Шпильку не вибрано
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Клацніть зображення, щоб додати шпильку; перетягніть шпильку, щоб перемістити її, або її кільце, щоб змінити розмиття. Delete видаляє вибрану шпильку.
Iris Blur… Розмиття діафрагми…
Tilt-Shift… Нахил і зсув…
Path Blur… Розмиття вздовж контуру…
1 # Ukrainian (uk) catalog. UTF-8: context<TAB>English source<TAB>translation.
321
322
323
324
325
326
327
328
329
+3
View File
@@ -508,6 +508,9 @@
Facet Mặt cắt
Fibers… Sợi…
Field Blur… Làm mờ vùng…
# Field Blur pins (#3373)
No pin selected Chưa chọn ghim nào
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. Bấm vào ảnh để thêm ghim; kéo ghim để di chuyển, hoặc kéo vòng của ghim để đổi độ mờ. Delete xóa ghim đã chọn.
Filter Gallery… Thư viện bộ lọc…
Find Edges Tìm cạnh
Flame… Ngọn lửa…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -558,6 +558,9 @@
Blur 模糊
Blur Gallery 模糊库
Field Blur… 场景模糊…
# Field Blur pins (#3373)
No pin selected 未选择图钉
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 单击图像以添加图钉;拖动图钉可移动它,拖动其圆环可更改模糊程度。按 Delete 键删除所选图钉。
Iris Blur… 光圈模糊…
Tilt-Shift… 移轴模糊…
Path Blur… 路径模糊…
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
558
559
560
561
562
563
564
565
566
+3
View File
@@ -563,6 +563,9 @@
Blur 模糊
Blur Gallery 模糊收藏館
Field Blur… 區域模糊…
# Field Blur pins (#3373)
No pin selected 未選取圖釘
Click the image to add a pin; drag a pin to move it, or its ring to change its blur. Delete removes the selected pin. 按一下影像即可新增圖釘;拖移圖釘可移動它,拖移其圓環可變更模糊程度。按 Delete 鍵刪除所選圖釘。
Iris Blur… 光圈模糊…
Tilt-Shift… 傾斜偏移…
Path Blur… 路徑模糊…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -27,6 +27,7 @@ pub mod artboard_ui;
pub(crate) mod background_redraw;
mod blend_keys;
pub(crate) mod blend_preview;
pub mod blur_pins;
mod brand;
pub(crate) mod brush_cursor;
pub mod brush_panel;
@@ -2520,6 +2521,9 @@ mod new_layer_button_tests;
#[cfg(test)]
mod smart_filter_rows_tests;
#[cfg(test)]
mod blur_pins_tests;
#[cfg(test)]
mod move_outline_tests;
+4 -1
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
@@ -70,6 +70,8 @@ what an agent can't finish alone.
(TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Move's live preview
of the result (TOOL-213-4; Transform On Drop and Content-Aware Scale through the transform box,
TOOL-214-7, are done).
Blur Gallery: Field Blur has its on-canvas pins (2026-10-11, #3373); Iris, Tilt-Shift, Spin and
Path Blur still edit their pins as parameter dialogs.
- **Evidence:** scorecard rows above, all partial or missing.
- **Impact:** these are the tools retouchers and compositors use daily; a slider dialog is not a
substitute.
@@ -273,6 +275,7 @@ what an agent can't finish alone.
| 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed |
| 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed |
| 2026-10-12 | minor | Refreshed against main: G5 items fixed by #2480, #2327, #2609, #2684, #2675, #2556, #2396, #2527, #2625 and TOOL-214-5/#2442; G7 TOOL-214-7 and Transform On Drop done; G2 #2377 fixed (#2417); G9 UI scale (UI-217-9) and Stamp Visible (UI-217-22) done, prefs 27 of 156; G10 #2225 done (#2482); G13 Khmer, Lao, Myanmar fallback (#3259); G14 CR3 line; G17 Windows ARM64 done (DIST-220-2) |
| 2026-10-11 | minor | G7: Field Blur pins are placed, dragged and tuned on the image (#3373); the other Blur Gallery filters remain dialogs |
| 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open |
| 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open |
| 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted |