Merge pull request #10840 from TrevorBrown-dev/feat/gradient-overlay-drag

Layer Style: a drag on the image moves a Gradient Overlay (#10763)
This commit is contained in:
David Mann authored and GitHub committed 2026-11-07 16:11:57 +01:00
commit e3f3465fe8
7 files changed
+284 -1

No files matched your search

+1
View File
@@ -122,6 +122,7 @@ Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
| Date | Entry |
|---|---|
| 2026-11-02 | Layer Style: with Gradient Overlay selected, a drag on the image moves the gradient (its centre as a fraction of the layer's bounds, `params.offset`), as a drag moves a shadow (#10763) |
| 2026-11-02 | Load Actions: Mac Roman step names with é ä ö ü Ä or ° read as Mac Roman instead of windows-1252 or Cyrillic (#10774): windows-1252's rare 0x80–0x9F letters and a lone Cyrillic letter in a Latin word no longer count as common text, the degree sign does, and the file's own Unicode text settles a word-initial rare letter and breaks exact ties toward windows-1252 or Mac Roman |
| 2026-10-21 | Linux can use font-free Adwaita controls when System Title Bar is selected, while the integrated CSD title bar remains the default (as in #4439). Saved preferences are respected; startup and preference-dialog regressions cover both choices (#6266). |
| 2026-10-22 | Print paper sizes: はがき (100 × 148 mm), 往復はがき (148 × 200), JIS B5 (182 × 257), JIS B4 (257 × 364), A6, L判 (89 × 127), 2L判 (127 × 178), KG判 (102 × 152), 六切 (203 × 254) and 四切 (254 × 305) are selectable in the Print dialog and `file.print`, matched by their customary names (`はがき`, `L判`, `JIS B5`…), and the PDF page takes their exact size. Existing papers unchanged. |
+9
View File
@@ -676,6 +676,15 @@ fn overlay_gradient(g: &mut Gradient, p: &Value) {
if let Some(x) = b_of(p, "reverse") {
g.reverse = x;
}
// The gradient's centre as a fraction of the layer's bounds, `[x, y]` (0: centred); a drag on
// the image with the Layer Style dialog on the page moves it (#10763).
if let Some(a) = p.get("offset").and_then(Value::as_array)
&& let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64))
&& x.is_finite()
&& y.is_finite()
{
g.offset = (x.clamp(-4.0, 4.0) as f32, y.clamp(-4.0, 4.0) as f32);
}
}
/// Applies the params present in `p` onto an existing effect, in the command's
@@ -200,6 +200,16 @@ mod tests {
assert_eq!(gradient.angle, 90.0);
assert!(gradient.reverse);
assert_eq!(gradient.scale, 0.75);
// The offset (#10763) is geometry too: a fraction of the layer's bounds, left alone by
// junk and clamped.
overlay(&mut gradient, &mut source, &json!({"offset": [0.25, -0.1]}));
assert_eq!(gradient.offset, (0.25, -0.1));
assert_eq!(gradient.stops, expected);
overlay(&mut gradient, &mut source, &json!({"offset": "far"}));
overlay(&mut gradient, &mut source, &json!({"offset": [f64::NAN, 0.0]}));
assert_eq!(gradient.offset, (0.25, -0.1));
overlay(&mut gradient, &mut source, &json!({"offset": [100.0, -100.0]}));
assert_eq!(gradient.offset, (4.0, -4.0));
}
#[test]
+32 -1
View File
@@ -720,6 +720,19 @@ fn handle_dialog_canvas_input(app: &mut PhotocraftApp, input: DialogCanvasInput<
// Layer Style on a Drop or Inner Shadow page: a drag on the image moves the shadow (#5701).
let shadow_drag =
!picking && app.ui.dialogs.last().is_some_and(|d| d.kind == crate::state::DialogKind::LayerStyle && crate::layer_style::drags_shadow(&d.fields));
// Layer Style on a Gradient Overlay page: a drag on the image moves the gradient (#10763), as
// long as the dialog's layer is there to move it on.
let gradient_drag = !picking
&& app.ui.dialogs.last().is_some_and(|d| {
d.kind == crate::state::DialogKind::LayerStyle
&& crate::layer_style::drags_gradient(&d.fields)
&& crate::layer_style::gradient_layer_bounds(&app.session, &d.fields).is_some()
});
// Off that page (another one chosen under a held press, say) the stored drag is over: the
// page chosen again starts from the offset shown.
if !gradient_drag && let Some(d) = app.ui.dialogs.last_mut().filter(|d| d.kind == crate::state::DialogKind::LayerStyle) {
crate::layer_style::end_gradient_drag(&mut d.fields);
}
// ⌘/Ctrl-click with Curves open: a point at the pixel's tone (#7325), whatever the tool. The
// press's own modifiers decide, and only the press adds: a drag would add one per frame.
let curve_point_press = (input.primary && !input.middle && crate::adjust_dialog::curves_on_top(app))
@@ -734,7 +747,14 @@ fn handle_dialog_canvas_input(app: &mut PhotocraftApp, input: DialogCanvasInput<
.flatten()
.map(|m| crate::workspace_ui::sticky_mods(app, m))
.filter(|m| m.command);
let dialog_hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag && curve_point_press.is_none();
let dialog_hand = app.ui.tool == Tool::Hand
&& !picking
&& !range_picking
&& !refine_brushing
&& !pinning
&& !shadow_drag
&& !gradient_drag
&& curve_point_press.is_none();
// ⌘Space / ⌥Space zoom under the dialog as they do without one, before Space pans (#6460).
// With the Hand tool, ⌘ alone is Zoom In (#6434); under Curves that press is the dialog's
// point (#7325), not a zoom.
@@ -771,6 +791,17 @@ fn handle_dialog_canvas_input(app: &mut PhotocraftApp, input: DialogCanvasInput<
crate::layer_style::drag_shadow(&mut d.fields, start, [f64::from(total.x), f64::from(total.y)]);
}
}
} else if gradient_drag && !input.middle && crate::dialogs::free_pointer_over(input.ctx, input.rect).is_some() {
input.ctx.set_cursor_icon(egui::CursorIcon::Move);
if let Some(p) = crate::dialogs::free_press(input.ctx, input.rect) {
let (start, origin) = input.ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.press_origin().unwrap_or(p)));
let total = input.xf.unmap_vec(p - origin) / (input.view.zoom / input.ppp);
// The gradient's offset is a fraction of the layer's bounds, as the renderer paints it.
let bounds = app.ui.dialogs.last().and_then(|d| crate::layer_style::gradient_layer_bounds(&app.session, &d.fields));
if let (Some(bounds), Some(d)) = (bounds, app.ui.dialogs.last_mut()) {
crate::layer_style::drag_gradient(&mut d.fields, start, [f64::from(total.x), f64::from(total.y)], bounds);
}
}
} else if picking && let Some(p) = crate::dialogs::free_pointer_over(input.ctx, input.rect) {
if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise {
input.ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
+77
View File
@@ -324,6 +324,7 @@ fn values_of(e: &Effect, light: f32) -> Value {
set(&mut v, gradient_field::KEY, gradient_field::stops_json(stops));
set(&mut v, gradient_field::OPACITY_KEY, gradient_field::opacity_json(&g.opacity_stops));
set(&mut v, "reverse", json!(g.reverse));
set(&mut v, "offset", json!([g.offset.0, g.offset.1]));
set(
&mut v,
"style",
@@ -535,6 +536,82 @@ pub fn drag_shadow(f: &mut Map<String, Value>, start: bool, total: [f64; 2]) {
set_param(f, &id, "distance", json!(d.round().clamp(0.0, 30000.0)));
}
/// Whether the selected page is a Gradient Overlay, which a drag on the image moves while the
/// dialog is open, as in Photoshop (#10763; [`drag_gradient`]).
pub fn drags_gradient(f: &Map<String, Value>) -> bool {
let selected = f.get("selected").and_then(Value::as_str).unwrap_or_default();
entry(f, selected).and_then(|e| e.get("kind")).and_then(Value::as_str) == Some("gradientOverlay")
}
/// Ends a gradient drag's stored origin ([`drag_gradient`]): the canvas calls this whenever its
/// gradient branch is inactive on a Layer Style dialog (another page chosen under a held press,
/// say), so a Gradient Overlay page chosen again starts from the offset shown, not from an origin
/// and a baseline taken before the pointer travelled on another page.
pub fn end_gradient_drag(f: &mut Map<String, Value>) {
f.remove("__gradientDrag");
}
/// The width and height of the layer the dialog `f` is open on, what a Gradient Overlay's offset
/// is a fraction of (`photocraft_compose::layer_bounds`, what the renderer paints against): `None`
/// once that layer is gone or while it is empty (a gradient paints nothing there), when a drag on
/// the image has nothing to move and the Hand tool pans.
pub fn gradient_layer_bounds(session: &photocraft_engine::Session, f: &Map<String, Value>) -> Option<[f64; 2]> {
let id = f.get("layer").and_then(Value::as_u64)?;
let st = session.active()?;
let b = photocraft_compose::layer_bounds(st.doc.layer(photocraft_doc::LayerId(id))?, st.doc.bounds());
let [w, h] = [f64::from(b.width()), f64::from(b.height())];
(w > 0.0 && h > 0.0).then_some([w, h])
}
/// A drag on the image with a Gradient Overlay page selected: `start` is true on the press (it
/// records the gradient's offset then, as does a change of the selected instance under a held
/// press), `total` the pointer's movement since in document pixels
/// and `bounds` the layer's width and height. The gradient's centre moves with the pointer, kept
/// as a fraction of the layer's bounds (`offset`, what the renderer takes), to a thousandth.
pub fn drag_gradient(f: &mut Map<String, Value>, start: bool, total: [f64; 2], bounds: [f64; 2]) {
const KEY: &str = "__gradientDrag";
let Some(id) = f.get("selected").and_then(Value::as_str).map(str::to_string).filter(|_| drags_gradient(f)) else { return };
let Some(e) = entry(f, &id) else { return };
// The offset the gradient shows: `offset` as stored when its first two elements are finite
// numbers (the only form the engine applies, and all it reads of a longer array), else the
// effect's own (its `fx` snapshot), else the centre
// (None: never set); within ±4 of the layer's size, as the renderer keeps it, so the drag
// starts from where the gradient is seen.
let pair = |v: Option<&Value>| {
let a = v?.as_array()?;
let (x, y) = (a.first()?.as_f64()?, a.get(1)?.as_f64()?);
(x.is_finite() && y.is_finite()).then(|| [x.clamp(-4.0, 4.0), y.clamp(-4.0, 4.0)])
};
let shown = pair(e.get("params").and_then(|p| p.get("offset"))).or_else(|| pair(e.get("fx").and_then(|fx| fx.pointer("/GradientOverlay/gradient/offset"))));
// The origin: the offset shown, the instance it is for and the pointer's total then. Taken on
// the press, when there is none, and when the page changed under a held press (another
// instance selected, or a Gradient Overlay page chosen during a press that began elsewhere):
// that instance then moves by the pointer's travel from this moment, not from the press.
let read = |v: Option<&Value>| {
let a = v?.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])
};
if start || f.get(KEY).and_then(|o| o.get("id")).and_then(Value::as_str) != Some(id.as_str()) {
f.insert(KEY.into(), json!({"id": id, "origin": shown.unwrap_or([0.0, 0.0]), "base": total}));
}
let Some(origin) = f.get(KEY) else { return };
let (Some([x0, y0]), Some([bx, by])) = (read(origin.get("origin")), read(origin.get("base"))) else { return };
let [w, h] = [bounds[0].max(1.0), bounds[1].max(1.0)];
// Kept within what the renderer takes (±4 of the layer's size), so the next drag starts from
// where the gradient shows.
let (x, y) = ((x0 + (total[0] - bx) / w).clamp(-4.0, 4.0), (y0 + (total[1] - by) / h).clamp(-4.0, 4.0));
if !(x.is_finite() && y.is_finite()) {
return;
}
// A press that has not moved the gradient (or a drag back to where it began, with nothing
// written since) leaves the offset as it is: a stored one to its full precision, none where
// there was none.
if x == x0 && y == y0 && shown.is_none_or(|s| s == [x0, y0]) {
return;
}
set_param(f, &id, "offset", json!([(x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0]));
}
pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map<String, Value> {
initial_fields_with(layer, select, light, Map::new())
}
@@ -33,6 +33,160 @@ fn dragging_on_the_image_moves_the_selected_shadow() {
assert_eq!(params(&f, "fx1"), (120.0, 0.0));
}
/// Review: the drag starts from the offset the gradient shows: a stored pair beyond ±4 at the
/// clamp, a malformed pair at the effect's own offset (its `fx` snapshot) or, without one, at the
/// centre, since the engine applies `offset` only when it is two finite numbers.
#[test]
fn a_drag_starts_from_the_offset_the_gradient_shows() {
let mut f = Map::new();
f.insert("selected".into(), json!("fx1"));
f.insert(
"effects".into(),
json!([
{"id": "fx1", "kind": "gradientOverlay", "on": true, "params": {"offset": [5.0, 0.0]}},
{"id": "fx2", "kind": "gradientOverlay", "on": true, "params": {"offset": [0.5, null]}},
{"id": "fx3", "kind": "gradientOverlay", "on": true, "params": {"offset": [0.5, null]}, "fx": {"GradientOverlay": {"gradient": {"offset": [0.2, 0.3]}}}},
{"id": "fx4", "kind": "gradientOverlay", "on": true, "params": {"offset": [0.1, 0.2, 9]}},
]),
);
let offset = |f: &Map<String, Value>, id: &str| entry(f, id).unwrap()["params"].get("offset").cloned();
// Stored at 5, shown at 4: a short drag left moves it from 4, not from 5.
drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx1"), Some(json!([5.0, 0.0])), "a plain press leaves it");
drag_gradient(&mut f, false, [-20.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx1"), Some(json!([3.9, 0.0])));
// A malformed pair without a snapshot: the renderer shows the centre, so the drag starts there.
f.insert("selected".into(), json!("fx2"));
drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx2"), Some(json!([0.5, null])), "a plain press leaves it");
drag_gradient(&mut f, false, [50.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx2"), Some(json!([0.25, 0.0])));
// With a snapshot: the renderer shows the effect's own offset, so the drag starts there.
f.insert("selected".into(), json!("fx3"));
drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]);
drag_gradient(&mut f, false, [50.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx3"), Some(json!([0.45, 0.3])));
// Three elements: the first two count, as the engine reads them.
f.insert("selected".into(), json!("fx4"));
drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx4"), Some(json!([0.1, 0.2, 9])), "a plain press leaves it");
drag_gradient(&mut f, false, [50.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx4"), Some(json!([0.35, 0.2])));
}
/// Review: with the dialog's layer gone, a drag on the image has nothing to move, so the Hand
/// tool pans as usual: `gradient_layer_bounds` finds the dialog's layer, or nothing.
#[test]
fn the_gradient_drag_needs_its_layer() {
let mut s = session();
let empty = s.active().unwrap().active_layer.unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
let mut f = Map::new();
f.insert("layer".into(), json!(id.0));
assert_eq!(gradient_layer_bounds(&s, &f), Some([64.0, 64.0]), "the dialog's layer");
// An empty layer paints no gradient (review): nothing to move, the Hand tool pans.
f.insert("layer".into(), json!(empty.0));
assert_eq!(gradient_layer_bounds(&s, &f), None, "an empty layer");
f.insert("layer".into(), json!(id.0 + 1000));
assert_eq!(gradient_layer_bounds(&s, &f), None, "a layer that is gone");
f.remove("layer");
assert_eq!(gradient_layer_bounds(&s, &f), None, "a dialog without a layer");
}
/// Review: the selected instance changing under a held press, or a Gradient Overlay page chosen
/// during a press that began on another page, moves that instance from its own offset by the
/// pointer's travel from that moment, not from the press.
#[test]
fn the_drag_rebases_when_the_selected_effect_changes() {
let mut f = Map::new();
f.insert("selected".into(), json!("fx3"));
f.insert(
"effects".into(),
json!([
{"id": "fx1", "kind": "gradientOverlay", "on": true, "params": {}},
{"id": "fx2", "kind": "gradientOverlay", "on": true, "params": {"offset": [0.1, 0.1]}},
{"id": "fx3", "kind": "dropShadow", "on": true, "params": {}},
]),
);
let offset = |f: &Map<String, Value>, id: &str| entry(f, id).unwrap()["params"].get("offset").cloned();
// The press began on the shadow page: nothing for the gradient, until its page is chosen.
drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]);
drag_gradient(&mut f, false, [40.0, 0.0], [200.0, 100.0]);
f.insert("selected".into(), json!("fx1"));
drag_gradient(&mut f, false, [40.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx1"), None, "nothing moved since the page was chosen");
drag_gradient(&mut f, false, [60.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx1"), Some(json!([0.1, 0.0])), "20 px on from the switch");
// Another instance selected mid-press: it moves by the travel from here, from its own offset.
f.insert("selected".into(), json!("fx2"));
drag_gradient(&mut f, false, [60.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx2"), Some(json!([0.1, 0.1])), "nothing moved yet");
drag_gradient(&mut f, false, [80.0, 20.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx2"), Some(json!([0.2, 0.3])));
assert_eq!(offset(&f, "fx1"), Some(json!([0.1, 0.0])), "the first one stays");
// The page left and the same instance chosen again under the held press (the canvas ends the
// stored drag while off the page): it starts over from the offset shown, not from the old
// origin and baseline, so the travel made meanwhile doesn't land on it.
f.insert("selected".into(), json!("fx3"));
end_gradient_drag(&mut f);
f.insert("selected".into(), json!("fx2"));
drag_gradient(&mut f, false, [120.0, 20.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx2"), Some(json!([0.2, 0.3])), "nothing moved since the page came back");
drag_gradient(&mut f, false, [140.0, 20.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx2"), Some(json!([0.3, 0.3])));
}
/// #10763: with a Gradient Overlay page selected, a drag on the image moves the gradient: its
/// centre follows the pointer as a fraction of the layer's bounds.
#[test]
fn dragging_on_the_image_moves_the_selected_gradient_overlay() {
let mut f = Map::new();
f.insert("selected".into(), json!("fx1"));
f.insert(
"effects".into(),
json!([
{"id": "fx1", "kind": "gradientOverlay", "on": true, "params": {"angle": 90.0, "scale": 100.0}},
{"id": "fx2", "kind": "gradientOverlay", "on": true, "params": {"angle": 90.0, "offset": [0.1, 0.1]}},
]),
);
assert!(drags_gradient(&f));
let offset = |f: &Map<String, Value>, id: &str| entry(f, id).unwrap()["params"].get("offset").cloned();
// The press keeps the gradient where it is (centred, no offset yet, and none written); 50 × 25
// px on a 200 × 100 layer move it a quarter of the way each.
drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx1"), None, "a plain press writes nothing");
drag_gradient(&mut f, false, [50.0, 25.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx1"), Some(json!([0.25, 0.25])));
assert_eq!(offset(&f, "fx2"), Some(json!([0.1, 0.1])), "another instance is left alone");
// Back and past the start: the way the pointer went, from where the drag began.
drag_gradient(&mut f, false, [-100.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx1"), Some(json!([-0.5, 0.0])));
// A new drag starts from the offset the last one left.
drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]);
drag_gradient(&mut f, false, [20.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx1"), Some(json!([-0.4, 0.0])));
// A press that moves nothing keeps a precise offset as it is; a drag that comes back to where
// it began still writes, since the gradient moved in between. The offset stays within ±4.
f.insert("selected".into(), json!("fx2"));
set_param(&mut f, "fx2", "offset", json!([0.123456, 0.1]));
drag_gradient(&mut f, true, [0.0, 0.0], [200.0, 100.0]);
drag_gradient(&mut f, false, [0.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx2"), Some(json!([0.123456, 0.1])), "nothing moved: nothing rounded");
drag_gradient(&mut f, false, [20.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx2"), Some(json!([0.223, 0.1])));
drag_gradient(&mut f, false, [0.0, 0.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx2"), Some(json!([0.123, 0.1])), "back at the start: the start is written");
drag_gradient(&mut f, false, [100_000.0, -100_000.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx2"), Some(json!([4.0, -4.0])), "clamped as the renderer clamps");
// Not on a gradient page: nothing moves.
f.insert("selected".into(), json!(BLENDING));
assert!(!drags_gradient(&f));
drag_gradient(&mut f, true, [50.0, 50.0], [200.0, 100.0]);
assert_eq!(offset(&f, "fx1"), Some(json!([-0.4, 0.0])));
}
fn session() -> photocraft_engine::Session {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
+1
View File
@@ -76,6 +76,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- A save dialog's answer that PhotoCraft changes, by giving a name without an extension the suggested file's or by lowercasing the extension (Preferences › File Handling › Lowercase Extension), opens a replace prompt when a file exists under the changed name: a dialog with `__replaceSaved`, `message`, `detail` and `path` (the file the save would replace). `ui.dialog.confirm` writes the file; `ui.dialog.cancel` ends the save without writing, as Cancel in the save dialog does. The file dialog counts as open until the prompt is answered. A name that differs only in letter case and reaches the same file (Windows and macOS by default) does not open it.
- Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor`
- Gradient Overlay exposes `params.method` (`smooth`, `perceptual`, `linear`, `classic`). `custom` denotes an existing curve without a recorded method and preserves it. Choosing a method retains all recorded source colour stops. If no source record exists (including baked PSD imports), choosing a method rebuilds from the two end colours, matching legacy command behaviour; the old curve stays unchanged until that choice. Later endpoint edits rebuild the colour curve using the recorded method. The source stops, smoothness and method survive dialog reopening, undo/redo and native `.pcraft` saves. Saving a style preset through "New Style…" may lose the multi-stop source curve: it passes only params to `style.presets.new`, and `effect_from_params` rebuilds the effect from the default gradient. PSD export continues to write the baked appearance; reopening that PSD shows Custom because editable interpolation metadata is not exported yet. Opacity interpolation and the multi-stop editor are separate work.
- Gradient Overlay's `params.offset` is the gradient's centre as a fraction of the layer's bounds (`[x, y]`, `[0, 0]` centred, as the renderer takes it). With the dialog on a Gradient Overlay page, a drag on the image moves it by the pointer's movement over the layer's size, as a drag on a shadow page moves the shadow (#10763).
- Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart.
- Select › Select and Mask… (`ui.menu.invoke {id: "select.selectAndMask"}`, Ctrl+Alt+R) opens Photoshop's workspace; `ui.inspect` reports it as `distort.selectAndMask`. Drive it with `ui.menu.invoke {id: "select.selectAndMask", params: {ui: {…}}}`: `tool` (`quick|refineEdge|brush|object|lasso|polygonalLasso|hand|zoom`), `mode` (`add|subtract`), `size`, `hardness`, `sampleAllLayers`, `hideView` (true shows the image without the view, as holding X does), `stroke: {points: [[x,y],…], alt?}` (a drag with the current tool in document px: Object Selection takes a click or the rectangle's corners, the lassos the polygon), `selectSubject`, `clearSelection`, `invert`, the settings (`view`, `opacity`, `showEdge`, `showOriginal`, `realTimeRefinement`, `highQualityPreview`, `overlayColor`, `indicates`, `radius`, `smartRadius`, `smooth`, `feather`, `contrast`, `shiftEdge`, `decontaminate`, `amount`, `output`, `remember`), `undo`, `redo`, `reset`, `waitPreview`, `commit` (OK: `select.refineEdge` with the workspace's `edits` and `refineStrokes`, one history step) and `cancel`. The reply describes the workspace (`edits`, `strokes`, `selectedProxyPixels`, `previewReady`, …).
- Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`).