mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
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:
7 files changed
+284
-1
No files matched your search
@@ -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. |
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]`).
|
||||
|
||||
Reference in new issue
Block a user