Merge pull request #11643 from davidmann4/integration-batch-19

Integration batch 19: epic PRs (WIP)
This commit is contained in:
David Mann
2026-11-06 22:48:55 +01:00
committed by GitHub
71 changed files with 2848 additions and 158 deletions
+1
View File
@@ -745,6 +745,7 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
| "path.clippingPath.set"
| "path.rename"
| "path.stroke"
| "path.toShape"
| "type.attachToPath"
| "type.fillPath"
| "type.detachFromPath"
+15 -4
View File
@@ -882,7 +882,7 @@ fn build() -> Vec<CommandSpec> {
"Gradient…",
["Layer", "New Fill Layer"],
None,
r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask)}"##,
r##"{"from":"#rrggbb","to":"#rrggbb","angle":deg=90,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask; else the selection is the layer mask),"stops"?,"transparency"?,"midpoints"?,"scale"?,"offset"?,"dither"?,"align"? (as gradient.fill.set; stops replace from/to)}"##,
has_doc,
|s, p| {
let a = color_param("layer.newFillLayer.gradient", p, "from", s.tools.foreground)?;
@@ -891,15 +891,26 @@ fn build() -> Vec<CommandSpec> {
let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear"));
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
// The rest of a recorded Gradient Fill (a loaded action's Make): applied as
// `gradient.fill.set` would, in the same step.
let rest: serde_json::Map<String, Value> = ["stops", "transparency", "midpoints", "scale", "offset", "dither", "align"]
.iter()
.filter_map(|k| p.get(*k).map(|v| ((*k).to_string(), v.clone())))
.collect();
let (fg, bg) = (s.tools.foreground, s.tools.background);
let id = s.edit("New Gradient Fill Layer", |doc, active| {
let fill = Fill::gradient(
let mut fill = Fill::gradient(
vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))],
angle,
1.0,
style,
reverse,
)
.in_mode(doc.mode);
);
if !rest.is_empty() {
let l = Layer::new(String::new(), LayerContent::Fill(fill.clone()));
fill = crate::gradient_fill_cmds::apply_set(&l, &fill, doc.bounds(), &Value::Object(rest.clone()), fg, bg)?;
}
let fill = fill.in_mode(doc.mode);
let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill));
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?;
let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto);
+2
View File
@@ -849,6 +849,7 @@ pub(super) const EVENTS: &[Event] = &[
"layer.new.layerViaCut",
"layer.newFillLayer.solidColor",
"layer.newFillLayer.pattern",
"layer.newFillLayer.gradient",
"layer.newAdjustmentLayer.levels",
"layer.newAdjustmentLayer.curves",
"layer.newAdjustmentLayer.hueSaturation",
@@ -893,6 +894,7 @@ pub(super) const EVENTS: &[Event] = &[
"layer.setLabelColor",
"layer.setAdjustment",
"layer.fill.pattern.set",
"gradient.fill.set",
"layer.new.layerFromBackground",
"layer.layerStyle.blendingOptions",
"layer.smartFilter.blendingOptions",
+414 -19
View File
@@ -1,11 +1,11 @@
use super::adjustments::{HUE_RANGES, adjustment_by_class, adjustment_by_suffix, adjustment_steps, levels_complete};
use super::descriptors::{
PCT, PX, Target, ang, class, class_ref, code, color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, get_ref, get_text, jbool,
jnum, jstr, layer_ref, layer_target, long, name_ref, pct, px, ref_class, target, target_ref, text, trimmed,
PCT, PX, Target, ang, boolean, class, class_ref, code, color_desc, color_hex, desc, enm, enumerated, get_bool, get_enum, get_list, get_num, get_obj,
get_ref, get_text, jbool, jnum, jstr, layer_ref, layer_target, long, name_ref, num, pct, px, ref_class, target, target_ref, text, trimmed,
};
use super::fields::{Mapped, none, step, steps};
use super::steps_layer::quick_mask_ref;
use super::tables::{LABEL_COLORS, MODES, blend_label, blend_ps, lookup, lookup_back};
use super::tables::{CHANNELS, LABEL_COLORS, MODES, blend_label, blend_ps, channel_of, channel_ref, lookup, lookup_back};
use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json};
/// A `Md ` value as a blend-mode label, only when it is a `BlnM` enumeration: another
@@ -78,6 +78,354 @@ fn pattern_desc(p: &Value) -> Option<Descriptor> {
Some(t)
}
/// Gradient Fill layers: `GrdT` ↔ `style`.
const GRADIENT_STYLES: &[(&str, &str)] = &[("Lnr", "linear"), ("Rdl", "radial"), ("Angl", "angle"), ("Rflt", "reflected"), ("Dmnd", "diamond")];
/// The `gradientLayer` keys [`gradient_params`] reads; a descriptor with any other key stays a
/// kept Photoshop step.
const GRADIENT_LAYER_KEYS: &[&str] = &["Grad", "Type", "Angl", "Rvrs", "Dthr", "Algn", "Scl ", "Ofst", "gradientsInterpolationMethod"];
/// The hidden param an imported Gradient Fill step keeps its recorded `gradientLayer` descriptor
/// in, so Save Actions writes it back unchanged while its params are the ones it loaded with.
const GRADIENT_DESCRIPTOR: &str = "__atnDescriptor";
/// A `gradientLayer` descriptor as the params of `layer.newFillLayer.gradient` /
/// `gradient.fill.set`. Midpoint k belongs to the segment that ends at stop k + 1, as the PSD
/// reader reads it; all-default midpoints give `[]`, which clears custom ones on a Set. None for
/// what the fill does not play: a Noise gradient, a smoothness below 100 %, a non-classic
/// interpolation, a transparency midpoint, stops out of order or values out of range.
fn gradient_params(t: &Descriptor) -> Option<Map<String, Value>> {
if t.items.iter().any(|(k, _)| !GRADIENT_LAYER_KEYS.iter().any(|g| k.is(g))) {
return None;
}
let mut p = Map::new();
if let Some(s) = t.get("Type") {
p.insert("style".into(), lookup(GRADIENT_STYLES, &enumerated(s)?)?.into());
}
if let Some(a) = t.get("Angl") {
p.insert("angle".into(), num(a)?.into());
}
for (key, param) in [("Rvrs", "reverse"), ("Dthr", "dither"), ("Algn", "align")] {
if let Some(v) = t.get(key) {
p.insert(param.into(), boolean(v)?.into());
}
}
if let Some(s) = t.get("Scl ") {
p.insert("scale".into(), num(s)?.into());
}
if let Some(o) = t.get("Ofst") {
let D::Descriptor(o) = o else { return None };
p.insert("offset".into(), json!([get_num(o, "Hrzn")?, get_num(o, "Vrtc")?]));
}
if t.get("gradientsInterpolationMethod").is_some_and(|m| enumerated(m).as_deref() != Some("Gcls")) {
return None;
}
let g = get_obj(t, "Grad")?;
if get_enum(g, "GrdF").as_deref() != Some("CstS") || g.get("Intr").is_some_and(|i| num(i) != Some(4096.0)) {
return None;
}
let (mut stops, mut mids, mut previous) = (Vec::new(), Vec::new(), 0.0);
for (i, s) in get_list(g, "Clrs").filter(|c| (2..=1024).contains(&c.len()))?.iter().enumerate() {
let D::Descriptor(s) = s else { return None };
let color = match get_enum(s, "Type").as_deref() {
None | Some("UsrS") => json!(color_hex(get_obj(s, "Clr ")?)?),
Some("FrgC") => json!("foreground"),
Some("BckC") => json!("background"),
Some(_) => return None,
};
let at = get_num(s, "Lctn")?;
if !(previous..=4096.0).contains(&at) {
return None;
}
previous = at;
stops.push(json!([at / 4096.0, color]));
if i > 0 {
// The dialog's midpoints are 5..95 %; playback would clamp anything else.
let mid = get_num(s, "Mdpn").unwrap_or(50.0);
if !(5.0..=95.0).contains(&mid) {
return None;
}
mids.push(mid / 100.0);
}
}
p.insert("stops".into(), Value::Array(stops));
if let Some(trns) = get_list(g, "Trns") {
let (mut out, mut previous) = (Vec::new(), 0.0);
for s in trns {
let D::Descriptor(s) = s else { return None };
if get_num(s, "Mdpn").is_some_and(|m| m != 50.0) {
return None;
}
// Playback would clamp and sort what is out of range or out of order.
let (at, opacity) = (get_num(s, "Lctn")?, get_num(s, "Opct")?);
if !(previous..=4096.0).contains(&at) || !(0.0..=100.0).contains(&opacity) {
return None;
}
previous = at;
out.push(json!([at / 4096.0, opacity]));
}
p.insert("transparency".into(), Value::Array(out));
}
if mids.iter().all(|m| *m == 0.5) {
mids.clear();
}
p.insert("midpoints".into(), json!(mids));
Some(p)
}
/// [`gradient_params`] plus the recorded descriptor, for Save Actions.
fn gradient_step(t: &Descriptor) -> Option<Value> {
let mut p = gradient_params(t)?;
p.insert(GRADIENT_DESCRIPTOR.into(), photocraft_paint::tile::b64_encode(&t.to_bytes()).into());
Some(Value::Object(p))
}
/// The `gradientLayer` descriptor for a Gradient Fill step: the recorded one while the params are
/// still what it imports as, else one written from the params. That needs every stop as
/// `#rrggbb` on a 1/4096 location, whole-percent midpoints and, for a Set (`set`), the
/// transparency stops: Photoshop's Set replaces the whole gradient, where the command keeps the
/// layer's own transparency.
fn gradient_desc(p: &Value, set: bool) -> Option<Descriptor> {
if let Some(t) = jstr(p, GRADIENT_DESCRIPTOR).and_then(photocraft_paint::tile::b64_decode).and_then(|b| Descriptor::from_bytes(&b).ok()) {
let (mut a, mut b) = (Value::Object(gradient_params(&t)?), p.clone());
b.as_object_mut()?.remove(GRADIENT_DESCRIPTOR);
super::convert::normalise(&mut a);
super::convert::normalise(&mut b);
if a == b {
return Some(t);
}
}
const KEYS: &[&str] = &["style", "angle", "reverse", "dither", "align", "scale", "offset", "stops", "transparency", "midpoints", GRADIENT_DESCRIPTOR];
if p.as_object()?.iter().any(|(k, v)| !v.is_null() && !KEYS.contains(&k.as_str())) || (set && p.get("transparency").is_none()) {
return None;
}
let location = |v: &Value| {
let t = v.as_f64()? * 4096.0;
((0.0..=4096.0).contains(&t) && t.fract() == 0.0).then(|| long(t))
};
let stops = p.get("stops")?.as_array().filter(|s| (2..=1024).contains(&s.len()))?;
let mids: Vec<f64> = match p.get("midpoints") {
None | Some(Value::Null) => Vec::new(),
Some(m) => m.as_array()?.iter().map(Value::as_f64).collect::<Option<_>>()?,
};
if mids.len() >= stops.len() {
return None;
}
let (mut clrs, mut previous) = (Vec::new(), 0.0);
for (i, s) in stops.iter().enumerate() {
let [at, color] = s.as_array()?.as_slice() else { return None };
if at.as_f64()? < previous {
return None;
}
previous = at.as_f64()?;
// A foreground or background stop is left out: Photoshop records the colour it had too.
let color = color_desc(color.as_str()?)?;
let mid = if i == 0 { 50.0 } else { mids.get(i - 1).copied().unwrap_or(0.5) * 100.0 };
if (mid - mid.round()).abs() > 1e-9 || !(5.0..=95.0).contains(&mid.round()) {
return None;
}
clrs.push(D::Descriptor(
desc("Clrt").with("Clr ", D::Descriptor(color)).with("Type", enm("Clry", "UsrS")).with("Lctn", location(at)?).with("Mdpn", long(mid)),
));
}
let default_trns = vec![json!([0, 100]), json!([1, 100])];
let trns: Option<Vec<D>> = p
.get("transparency")
.and_then(Value::as_array)
.unwrap_or(&default_trns)
.iter()
.map(|s| {
let [at, o] = s.as_array()?.as_slice() else { return None };
Some(D::Descriptor(desc("TrnS").with("Opct", pct(o.as_f64()?)).with("Lctn", location(at)?).with("Mdpn", long(50.0))))
})
.collect();
let grad = desc("Grdn")
.with("Nm ", text("Custom"))
.with("GrdF", enm("GrdF", "CstS"))
.with("Intr", D::Double(4096.0))
.with("Clrs", D::List(clrs))
.with("Trns", D::List(trns?));
let mut t = desc("gradientLayer");
if let Some(a) = jnum(p, "angle") {
t = t.with("Angl", ang(a));
}
if let Some(s) = jstr(p, "style") {
t = t.with("Type", enm("GrdT", lookup_back(GRADIENT_STYLES, s)?));
}
for (param, key) in [("reverse", "Rvrs"), ("dither", "Dthr"), ("align", "Algn")] {
if let Some(v) = p.get(param).filter(|v| !v.is_null()) {
t = t.with(key, D::Boolean(v.as_bool()?));
}
}
if let Some(s) = jnum(p, "scale") {
t = t.with("Scl ", pct(s));
}
if let Some(o) = p.get("offset").filter(|v| !v.is_null()) {
let [x, y] = o.as_array()?.as_slice() else { return None };
t = t.with("Ofst", D::Descriptor(desc("Pnt ").with("Hrzn", pct(x.as_f64()?)).with("Vrtc", pct(y.as_f64()?))));
}
Some(t.with("Grad", D::Descriptor(grad)))
}
/// `layer.newFillLayer.gradient` as a Make of a `contentLayer`. The two-colour form (`from`,
/// `to`) becomes the stops it plays as; without them it plays the toolbox colours, which
/// Photoshop records with the colour they had.
fn gradient_make_to(p: &Value) -> Option<Descriptor> {
let mut p = p.clone();
let o = p.as_object_mut()?;
if o.remove("path").is_some_and(|v| !v.is_null()) {
return None;
}
// Stops replace `from` and `to` when the step plays, so those two are left out beside them.
match (o.remove("from").filter(|v| !v.is_null()), o.remove("to").filter(|v| !v.is_null())) {
_ if o.contains_key("stops") => {}
(None, None) => {}
(Some(a), Some(b)) => {
o.insert("stops".into(), json!([[0, a], [1, b]]));
}
_ => return None,
}
let t = gradient_desc(&p, false)?;
Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))))
}
/// `layer.layerStyle.blendingOptions` as a Set of the target layer.
fn blending_options_to(p: &Value) -> Option<Descriptor> {
let o = p.as_object()?;
let blend_if = p.get("blendIf");
let mut t = desc("Lyr ");
if blend_if.is_some() {
// With Blend If, the dialog's opacities and mode are in the same `set`; without it,
// Photoshop's `set` of them loads as `layer.setProps`.
for (param, key) in [("opacity", "Opct"), ("fillOpacity", "fillOpacity")] {
if let Some(v) = jnum(p, param) {
t = t.with(key, pct(v));
}
}
if let Some(m) = jstr(p, "blend") {
t = t.with("Md ", enm("BlnM", blend_ps(m)?));
}
}
for (key, param) in ADVANCED_BLENDING {
if let Some(b) = jbool(p, param) {
t = t.with(key, D::Boolean(b));
}
}
if let Some(b) = blend_if {
t = t.with("Blnd", blend_if_to(b)?);
if let Some(fx) = jstr(p, EFFECTS_SCALE) {
let fx = photocraft_paint::tile::b64_decode(fx).and_then(|b| Descriptor::from_bytes(&b).ok())?;
if !scale_only(&fx) {
return None;
}
t = t.with("Lefx", D::Descriptor(fx));
}
}
// Only these export: Photoshop records the dialog's other options differently.
if t.items.is_empty() || t.items.len() != o.len() {
return None;
}
Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t)))
}
/// Blend If: a `Blnd` list's `Chnl` ↔ `blendIf` channel (`Gry` is the composite).
fn blend_if_channel(r: &[ReferenceItem]) -> Option<&'static str> {
match target(r) {
Target::Ordinal(o) if o == "Cmps" => Some("gray"),
Target::Ordinal(o) => channel_of(&o),
_ => None,
}
}
/// The slider keys of one `Blnd` entry: This Layer, then Underlying Layer, each black low and
/// high, white low and high.
const BLEND_IF_KEYS: [[&str; 4]; 2] = [["SrcB", "Srcl", "SrcW", "Srcm"], ["DstB", "Dstl", "DstW", "Dstt"]];
/// A `Blnd` list as `layer.layerStyle.blendingOptions`' `blendIf`. Each entry's sliders are
/// whole levels 0..255 (also in a 16-bit document); Underlying Layer may be missing. None for
/// anything else, so the step stays a kept Photoshop step.
fn blend_if_from(list: &[D]) -> Option<Value> {
let mut out = Vec::with_capacity(list.len());
for e in list {
let D::Descriptor(e) = e else { return None };
if e.items.iter().any(|(k, _)| !k.is("Chnl") && !BLEND_IF_KEYS.iter().flatten().any(|b| k.is(b))) {
return None;
}
let channel = blend_if_channel(get_ref(e, "Chnl")?)?;
let range = |keys: &[&str; 4]| -> Option<Option<Value>> {
let v: Vec<Option<i32>> = keys
.iter()
.map(|k| match e.get(k) {
Some(D::Integer(i)) if (0..=255).contains(i) => Some(*i),
_ => None,
})
.collect();
match v.iter().flatten().count() {
0 if keys.iter().all(|k| e.get(k).is_none()) => Some(None),
// In order, as the command takes them; an inverted range stays a Photoshop step.
4 => {
let q: Vec<i32> = v.into_iter().flatten().collect();
q.windows(2).all(|w| w[0] <= w[1]).then(|| Some(json!(q)))
}
_ => None,
}
};
let mut m = json!({"channel": channel, "thisLayer": range(&BLEND_IF_KEYS[0])??});
if let Some(u) = range(&BLEND_IF_KEYS[1])? {
m["underlying"] = u;
}
out.push(m);
}
(!out.is_empty()).then_some(Value::Array(out))
}
/// `blendIf` back as a `Blnd` list: named channels and whole levels only (an index or `null`
/// reset has no Photoshop form).
fn blend_if_to(v: &Value) -> Option<D> {
let entries = match v {
Value::Array(a) => a.iter().collect::<Vec<_>>(),
Value::Object(_) => vec![v],
_ => return None,
};
let mut list = Vec::with_capacity(entries.len());
for e in entries {
let o = e.as_object()?;
if o.keys().any(|k| !matches!(k.as_str(), "channel" | "thisLayer" | "underlying")) {
return None;
}
let channel = match e.get("channel") {
None | Some(Value::Null) => "gray",
Some(Value::String(c)) if c == "grey" => "gray",
Some(Value::String(c)) => c.as_str(),
_ => return None,
};
lookup_back(CHANNELS, channel)?;
let mut d = desc("Blnd").with("Chnl", channel_ref(Some(channel)));
for (param, keys) in [("thisLayer", &BLEND_IF_KEYS[0]), ("underlying", &BLEND_IF_KEYS[1])] {
let Some(r) = e.get(param) else {
if param == "thisLayer" {
return None;
}
continue;
};
let r: Vec<f64> = r.as_array()?.iter().map(Value::as_f64).collect::<Option<_>>()?;
let q = match r.as_slice() {
[b, w] => [*b, *b, *w, *w],
[a, b, c, w] => [*a, *b, *c, *w],
_ => return None,
};
if !q.iter().all(|x| (0.0..=255.0).contains(x) && x.fract() == 0.0) || !q.windows(2).all(|w| w[0] <= w[1]) {
return None;
}
for (k, x) in keys.iter().zip(q) {
d = d.with(k, long(x));
}
}
list.push(D::Descriptor(d));
}
Some(D::List(list))
}
/// Layer › Layer Mask: the `UsrM` value of an Add Layer Mask `Mk` ↔ its command.
const MASK_USING: &[(&str, &str)] = &[
("RvlA", "layer.layerMask.revealAll"),
@@ -303,6 +651,11 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped {
Some(p) => step("layer.newFillLayer.pattern", Value::Object(p)),
None => Mapped::Unsupported { harmless: false },
},
// A name, opacity, mode or clipping beside `Type` would neither play nor save back.
Some(t) if t.class_id.is("gradientLayer") => match gradient_step(t) {
Some(p) if using.is_some_and(|u| u.items.iter().all(|(k, _)| k.is("Type"))) => step("layer.newFillLayer.gradient", p),
_ => Mapped::Unsupported { harmless: false },
},
_ => Mapped::Unsupported { harmless: false },
},
Some("layerSection") => {
@@ -432,6 +785,7 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
let t = pattern_desc(p)?;
desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))))
}
"layer.newFillLayer.gradient" => gradient_make_to(p)?,
"layer.duplicate" => {
// Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`).
// Anything else falls through to the `Dplc` event below.
@@ -658,6 +1012,25 @@ const LOCKS: &[(&str, &str)] = &[
("protectAll", "all"),
];
/// The Blending Options keys besides the Advanced checkboxes that `layer.layerStyle.blendingOptions`
/// takes from a `set` of the layer: opacity, fill opacity, mode and Blend If.
const BLENDING_DIALOG: &[&str] = &["Opct", "fillOpacity", "Md ", "Blnd"];
/// The hidden `layer.layerStyle.blendingOptions` param that keeps the `Lefx` = {Scl} the Layer
/// Style dialog's OK records beside Blend If, so Save Actions writes the Set back unchanged.
const EFFECTS_SCALE: &str = "__atnEffectsScale";
/// A `Lefx` holding only the effects' scale: what the dialog's OK records on a layer without
/// effects. It changes nothing.
fn scale_only(fx: &Descriptor) -> bool {
fx.items.iter().all(|(k, _)| k.is("Scl "))
}
/// A `T` key that is such a `Lefx`.
fn is_scale_only(k: &Id, v: &D) -> bool {
matches!(v, D::Descriptor(fx) if k.is("Lefx") && scale_only(fx))
}
/// Blending Options' Advanced checkboxes ↔ `layer.layerStyle.blendingOptions` params.
const ADVANCED_BLENDING: &[(&str, &str)] = &[("blendClipped", "blendClippedLayersAsGroup"), ("transparencyShapesLayer", "transparencyShapesLayer")];
@@ -739,6 +1112,32 @@ fn layer_set_from(d: &Descriptor, r: &[ReferenceItem]) -> Mapped {
advanced.insert((*param).into(), b.into());
}
}
if let Some(list) = get_list(t, "Blnd") {
// Effects beside Blend If have no step here: keep the recorded step rather than play
// and save it back without them.
if t.items.iter().any(|(k, v)| k.is("Lefx") && !is_scale_only(k, v)) {
return unsupported;
}
let Some(b) = blend_if_from(list) else { return unsupported };
advanced.insert("blendIf".into(), b);
// The Blending Options dialog's OK: with nothing but its own options, the opacities and
// the mode go with Blend If into one step, saved back as the one `set`.
if t.items.iter().all(|(k, v)| BLENDING_DIALOG.iter().any(|b| k.is(b)) || ADVANCED_BLENDING.iter().any(|(a, _)| k.is(a)) || is_scale_only(k, v)) {
if let Some(D::Descriptor(fx)) = t.get("Lefx") {
advanced.insert(EFFECTS_SCALE.into(), photocraft_paint::tile::b64_encode(&fx.to_bytes()).into());
}
for (key, param) in [("Opct", "opacity"), ("fillOpacity", "fillOpacity")] {
if let Some(v) = get_num(t, key) {
advanced.insert(param.into(), v.into());
}
}
p.remove("opacity");
p.remove("fill");
if let Some(b) = p.remove("blend") {
advanced.insert("blend".into(), b);
}
}
}
if !advanced.is_empty() {
if !targeted {
return unsupported;
@@ -751,10 +1150,7 @@ fn layer_set_from(d: &Descriptor, r: &[ReferenceItem]) -> Mapped {
// The Layer Style dialog's OK on a layer without effects records only the effects'
// scale (`Lefx` = {Scl}); an empty `T` is an OK that changed nothing. Neither changes
// the document.
let no_effect = t.items.iter().all(|(k, val)| match val {
D::Descriptor(fx) if k.is("Lefx") => fx.items.iter().all(|(fk, _)| fk.is("Scl ")),
_ => false,
});
let no_effect = t.items.iter().all(|(k, val)| is_scale_only(k, val));
if !v.is_empty() {
steps(v)
} else if no_effect {
@@ -808,6 +1204,12 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped {
Some(p) if !p.is_empty() => step("layer.fill.pattern.set", Value::Object(p)),
_ => unsupported,
},
// Photoshop's Set replaces the whole gradient; one without transparency stops would
// keep the layer's own on play, and could not be written back once edited.
Some(t) if t.class_id.is("gradientLayer") => match gradient_step(t).filter(|p| p.get("transparency").is_some()) {
Some(p) => step("gradient.fill.set", p),
None => unsupported,
},
_ => unsupported,
},
(Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => {
@@ -954,21 +1356,14 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
}
desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(pattern_desc(p)?))
}
"layer.layerStyle.blendingOptions" => {
let o = p.as_object()?;
let mut t = desc("Lyr ");
for (key, param) in ADVANCED_BLENDING {
if let Some(b) = jbool(p, param) {
t = t.with(key, D::Boolean(b));
}
}
// Only the Advanced checkboxes export: Photoshop records the dialog's other options
// differently.
if t.items.is_empty() || t.items.len() != o.len() {
"gradient.fill.set" => {
// A named layer or dragged handles have no place in the event.
if ["layer", "from", "to"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
return None;
}
desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t))
desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(gradient_desc(p, true)?))
}
"layer.layerStyle.blendingOptions" => blending_options_to(p)?,
"layer.smartFilter.blendingOptions" => {
if p.get("layer").is_some() {
return None;
+367
View File
@@ -1056,6 +1056,11 @@ fn every_mapped_command_exports_and_imports_back() {
("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})),
("layer.smartFilter.blendingOptions", json!({"index": 1, "opacity": 0.5, "blend": "Darken"})),
("layer.layerStyle.blendingOptions", json!({"blendClippedLayersAsGroup": false, "transparencyShapesLayer": false})),
("layer.layerStyle.blendingOptions", json!({"blendIf": [{"channel": "gray", "thisLayer": [0, 30, 200, 255], "underlying": [0, 0, 255, 255]}]})),
(
"layer.layerStyle.blendingOptions",
json!({"opacity": 40, "fillOpacity": 70, "blend": "Multiply", "blendIf": [{"channel": "blue", "thisLayer": [5, 5, 250, 250]}]}),
),
("layer.createClippingMask", json!({})),
("layer.lockLayers", json!({"transparency": true})),
("filter.stylize.diffuse", json!({"mode": "normal", "seed": 321654})),
@@ -1200,6 +1205,9 @@ fn every_mapped_command_exports_and_imports_back() {
"filter.sharpen.sharpenMore",
// Saves back only as the descriptor it was loaded from (loaded_camera_raw_filter_steps_save_back_unchanged).
"filter.cameraRaw",
// Load with the recorded descriptor riding along (gradient_fill_steps_export_and_import_back).
"gradient.fill.set",
"layer.newFillLayer.gradient",
];
let unexpected: Vec<&str> = missing.iter().copied().filter(|id| !allowed.contains(id)).collect();
assert!(unexpected.is_empty(), "add round-trip cases for {unexpected:?}");
@@ -6284,6 +6292,365 @@ fn shear_nonfinite_coordinates_stay_kept() {
}
}
/// #10771: a Gradient Fill as Photoshop records it: three stops, a transparency stop at 40 % and
/// one midpoint per stop (`Mdpn` of stop k is the segment that ends at it).
fn recorded_gradient(mids: [i32; 3]) -> Descriptor {
let stop = |hex: &str, at: i32, mid: i32| {
D::Descriptor(
desc("Clrt")
.with("Clr ", D::Descriptor(color_desc(hex).unwrap()))
.with("Type", enm("Clry", "UsrS"))
.with("Lctn", D::Integer(at))
.with("Mdpn", D::Integer(mid)),
)
};
let trn = |at: i32, o: f64| D::Descriptor(desc("TrnS").with("Opct", pct(o)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50)));
let grad = desc("Grdn")
.with("Nm ", text("Custom"))
.with("GrdF", enm("GrdF", "CstS"))
.with("Intr", D::Double(4096.0))
.with("Clrs", D::List(vec![stop("#ff0000", 0, mids[0]), stop("#00ff00", 2048, mids[1]), stop("#0000ff", 4096, mids[2])]))
.with("Trns", D::List(vec![trn(0, 100.0), trn(4096, 40.0)]));
desc("gradientLayer").with("Angl", ang(30.0)).with("Type", enm("GrdT", "Rdl ")).with("Grad", D::Descriptor(grad))
}
fn gradient_set(t: Descriptor) -> AtnItem {
item("setd", "Set", Some(desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(t))))
}
fn gradient_make(t: Descriptor) -> AtnItem {
let d = desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t))));
item("Mk ", "Make", Some(d))
}
/// The first saved step of `action` (strict save).
fn saved_first(s: &mut Session) -> (AtnEvent, Option<Descriptor>) {
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
assert_eq!(r["skipped"], json!([]), "{r}");
let saved = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
let i = &saved.actions[0].items[0];
(i.event.clone(), i.descriptor.clone())
}
fn without_hidden(p: &Value) -> Value {
let mut p = p.clone();
p.as_object_mut().unwrap().remove("__atnDescriptor");
normalise(&mut p);
p
}
fn active_fill(s: &Session) -> photocraft_doc::Fill {
let st = s.active().unwrap();
match &st.doc.layer(st.active_layer.unwrap()).unwrap().content {
photocraft_doc::LayerContent::Fill(f) => f.clone(),
c => panic!("not a fill layer: {}", c.kind_name()),
}
}
/// #10771: a recorded Set of a Gradient Fill loads as `gradient.fill.set` with its midpoints on
/// the segment they belong to, plays them, and saves back as the recorded descriptor.
#[test]
fn gradient_fill_set_imports_midpoints_plays_and_saves_back_unchanged() {
let recorded = recorded_gradient([50, 25, 70]);
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded.clone())])).unwrap())})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
let (id, p) = s.actions.list[0].steps[0].clone();
assert_eq!(id, "gradient.fill.set");
let mut expected = json!({
"style": "radial", "angle": 30,
"stops": [[0, "#ff0000"], [0.5, "#00ff00"], [1, "#0000ff"]],
"transparency": [[0, 100], [1, 40]],
"midpoints": [0.25, 0.7]
});
normalise(&mut expected);
assert_eq!(without_hidden(&p), expected);
assert_eq!(
saved_first(&mut s),
(AtnEvent::Code(*b"setd"), Some(desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(recorded))))
);
// It plays on a Gradient Fill layer.
s.execute("file.new", json!({"width": 16, "height": 12})).unwrap();
s.execute("layer.newFillLayer.gradient", json!({"from": "#000000", "to": "#ffffff"})).unwrap();
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let photocraft_doc::Fill::Gradient { stops, midpoints, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") };
assert_eq!(stops.len(), 3);
assert_eq!(midpoints, vec![0.25, 0.7]);
assert_eq!(opacity_stops.len(), 2);
// Default midpoints load as `[]`, which clears the custom ones when played.
let mut s2 = Session::new();
s2.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded_gradient([50, 50, 50]))])).unwrap())})).unwrap();
assert_eq!(s2.actions.list[0].steps[0].1["midpoints"], json!([]));
s.actions = s2.actions;
s.execute("actions.play", json!({"action": "A"})).unwrap();
let photocraft_doc::Fill::Gradient { midpoints, .. } = active_fill(&s) else { panic!("not a gradient") };
assert!(midpoints.is_empty() || midpoints.iter().all(|m| *m == 0.5), "{midpoints:?}");
}
/// #10771: an edited loaded step writes a fresh descriptor from its params, midpoints included.
#[test]
fn edited_gradient_fill_set_writes_a_fresh_descriptor() {
let mut s = Session::new();
s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_set(recorded_gradient([50, 25, 70]))])).unwrap())})).unwrap();
s.actions.list[0].steps[0].1["midpoints"] = json!([0.4, 0.6]);
let (_, d) = saved_first(&mut s);
let t = get_obj(d.as_ref().unwrap(), "T ").unwrap();
let clrs = get_list(get_obj(t, "Grad").unwrap(), "Clrs").unwrap();
let mids: Vec<f64> = clrs.iter().map(|c| if let D::Descriptor(c) = c { get_num(c, "Mdpn").unwrap() } else { panic!() }).collect();
assert_eq!(mids, vec![50.0, 40.0, 60.0]);
// Its own import is the edited params.
let Mapped::Steps(steps) = (event_for(&AtnEvent::Code(*b"setd")).unwrap().from)(d.as_ref().unwrap()) else { panic!("did not import") };
let mut edited = without_hidden(&s.actions.list[0].steps[0].1);
edited["midpoints"] = json!([0.4, 0.6]);
normalise(&mut edited);
assert_eq!(without_hidden(&steps[0].1), edited);
}
/// #10771: a recorded Make of a Gradient Fill layer plays its stops, transparency and midpoints
/// and saves back unchanged.
#[test]
fn gradient_fill_make_imports_plays_and_saves_back_unchanged() {
let recorded = recorded_gradient([50, 25, 70]);
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![gradient_make(recorded.clone())])).unwrap())})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
assert_eq!(s.actions.list[0].steps[0].0, "layer.newFillLayer.gradient");
let (event, d) = saved_first(&mut s);
assert_eq!(event, AtnEvent::Code(*b"Mk "));
assert_eq!(get_obj(get_obj(d.as_ref().unwrap(), "Usng").unwrap(), "Type"), Some(&recorded));
for depth in [8, 16] {
s.execute("file.new", json!({"width": 16, "height": 12, "depth": depth})).unwrap();
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let photocraft_doc::Fill::Gradient { stops, midpoints, opacity_stops, .. } = active_fill(&s) else { panic!("not a gradient") };
assert_eq!((stops.len(), midpoints, opacity_stops.len()), (3, vec![0.25, 0.7], 2));
// One undo step.
s.execute("edit.undo", json!({})).unwrap();
assert!(!matches!(s.active().unwrap().doc.layers.last().unwrap().content, photocraft_doc::LayerContent::Fill(_)));
}
}
/// #10771: PhotoCraft's own Gradient Fill steps export and load back as the same fill.
#[test]
fn gradient_fill_steps_export_and_import_back() {
let set = json!({"style": "linear", "angle": 45, "stops": [[0, "#ff0000"], [0.25, "#00ff00"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 50]], "midpoints": [0.3, 0.6]});
let item = export_item("gradient.fill.set", &set, "S").unwrap();
let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") };
let mut expected = set.clone();
normalise(&mut expected);
assert_eq!((steps[0].0.as_str(), without_hidden(&steps[0].1)), ("gradient.fill.set", expected));
// The two-colour Make plays as the same two stops at full opacity.
let item = export_item("layer.newFillLayer.gradient", &json!({"from": "#ff0000", "to": "#0000ff", "angle": 45}), "S").unwrap();
let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") };
let mut expected = json!({"angle": 45, "stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]], "midpoints": []});
normalise(&mut expected);
assert_eq!((steps[0].0.as_str(), without_hidden(&steps[0].1)), ("layer.newFillLayer.gradient", expected));
// Stops replace `from` and `to` when it plays, so a Make with all three exports its stops.
let p = json!({"from": "#ff0000", "to": "#0000ff", "stops": [[0, "#00ff00"], [1, "#ffffff"]], "transparency": [[0, 100], [1, 100]]});
let item = export_item("layer.newFillLayer.gradient", &p, "S").unwrap();
let Mapped::Steps(steps) = (event_for(&item.event).unwrap().from)(item.descriptor.as_ref().unwrap()) else { panic!("did not import") };
assert_eq!(steps[0].1["stops"], json!([[0.0, "#00ff00"], [1.0, "#ffffff"]]));
// What the event cannot hold is refused: a Set without its transparency, toolbox colours,
// dragged handles, a path, a midpoint off a whole percent.
for (id, p) in [
("gradient.fill.set", json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]]})),
("gradient.fill.set", json!({"stops": [[0, "foreground"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]]})),
("gradient.fill.set", json!({"from": [0, 0], "stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]]})),
("gradient.fill.set", json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "transparency": [[0, 100], [1, 100]], "midpoints": [0.333]})),
("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "path": "work"})),
("layer.newFillLayer.gradient", json!({"from": "#ff0000"})),
] {
assert!(export_item(id, &p, "S").is_none(), "{id}: {p}");
}
}
/// #10771: a Noise gradient, smoothness below 100 % or a transparency midpoint stay Photoshop
/// steps rather than playing as a different fill.
#[test]
fn gradient_fills_the_command_cannot_play_stay_photoshop_steps() {
// `with` appends; these replace the recorded value.
fn replaced(mut d: Descriptor, key: &str, v: D) -> Descriptor {
d.items.retain(|(k, _)| !k.is(key));
d.with(key, v)
}
let base = recorded_gradient([50, 50, 50]);
let with_grad = |f: &dyn Fn(Descriptor) -> Descriptor| {
let g = get_obj(&base, "Grad").unwrap().clone();
replaced(base.clone(), "Grad", D::Descriptor(f(g)))
};
let trns_mid = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(0)).with("Mdpn", D::Integer(30));
let trns_end = desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(4096)).with("Mdpn", D::Integer(50));
let trns = |at: i32, o: f64| D::Descriptor(desc("TrnS").with("Opct", pct(o)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50)));
for t in [
// A midpoint outside the dialog's 5..95 % would play clamped.
recorded_gradient([50, 2, 50]),
recorded_gradient([50, 50, 97]),
// Transparency stops out of range or out of order would play clamped and sorted.
with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(0, 100.0), trns(5000, 40.0)]))),
with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(0, 150.0), trns(4096, 40.0)]))),
with_grad(&|g| replaced(g, "Trns", D::List(vec![trns(4096, 100.0), trns(0, 40.0)]))),
// A Set without transparency stops would keep the layer's own.
with_grad(&|mut g| {
g.items.retain(|(k, _)| !k.is("Trns"));
g
}),
with_grad(&|g| replaced(g, "GrdF", enm("GrdF", "ClNs"))),
with_grad(&|g| replaced(g, "Intr", D::Double(2048.0))),
with_grad(&|g| replaced(g, "Trns", D::List(vec![D::Descriptor(trns_mid.clone()), D::Descriptor(trns_end.clone())]))),
base.clone().with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Perc")),
] {
let imp = import(&atn::write(&one_action(vec![gradient_set(t)])).unwrap()).unwrap();
assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP);
}
// A Make whose new layer also gets a name, opacity, mode or clipping: the command plays none
// of them, so the step stays as recorded.
for (key, v) in [("Nm ", text("Sky")), ("Opct", pct(50.0)), ("Md ", enm("BlnM", "Mltp")), ("Grup", D::Boolean(true))] {
let using = desc("contentLayer").with("Type", D::Descriptor(base.clone())).with(key, v);
let make = item("Mk ", "Make", Some(desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(using))));
let imp = import(&atn::write(&one_action(vec![make])).unwrap()).unwrap();
assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP, "{key}");
}
}
fn blend_if_entry(channel: &str, this: [i32; 4], under: Option<[i32; 4]>) -> D {
let mut d = desc("Blnd").with("Chnl", channel_ref(Some(channel)));
for (k, v) in ["SrcB", "Srcl", "SrcW", "Srcm"].into_iter().zip(this) {
d = d.with(k, D::Integer(v));
}
if let Some(u) = under {
for (k, v) in ["DstB", "Dstl", "DstW", "Dstt"].into_iter().zip(u) {
d = d.with(k, D::Integer(v));
}
}
D::Descriptor(d)
}
fn layer_set(t: Descriptor) -> AtnItem {
item("setd", "Set", Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t))))
}
fn blend_layers(depth: u8) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
s
}
fn active_info(s: &Session) -> Value {
let st = s.active().unwrap();
crate::inspect::layer(st.doc.layer(st.active_layer.unwrap()).unwrap())
}
/// #10771: Blend If loads as `layer.layerStyle.blendingOptions` and plays (it used to be a
/// silent no-op), on 8- and 16-bit documents, Gray and a colour channel, split sliders.
#[test]
fn blend_if_imports_and_plays() {
let blnd = D::List(vec![blend_if_entry("gray", [0, 30, 200, 255], Some([10, 10, 240, 250])), blend_if_entry("red", [20, 40, 255, 255], None)]);
for depth in [8, 16] {
let mut s = blend_layers(depth);
let loaded = s
.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Blnd", blnd.clone()))])).unwrap())}))
.unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
let mut expected = json!({"blendIf": [{"channel": "gray", "thisLayer": [0, 30, 200, 255], "underlying": [10, 10, 240, 250]}, {"channel": "red", "thisLayer": [20, 40, 255, 255]}]});
normalise(&mut expected);
let (id, p) = &s.actions.list[0].steps[0];
assert_eq!((id.as_str(), p), ("layer.layerStyle.blendingOptions", &expected));
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let b = active_info(&s)["blendIf"].clone();
assert_eq!(b[0], json!({"channel": 0, "thisLayer": [0, 30, 200, 255], "underlying": [10, 10, 240, 250]}), "{b}");
assert_eq!(b[1], json!({"channel": 1, "thisLayer": [20, 40, 255, 255], "underlying": [0, 0, 255, 255]}), "{b}");
// It saves back as the same `Blnd`.
let (_, d) = saved_first(&mut s);
assert_eq!(get_obj(d.as_ref().unwrap(), "T ").unwrap().get("Blnd"), Some(&blnd));
}
}
/// #10771: the Blending Options dialog's OK (opacity, fill, mode and Blend If in one Set) is one
/// step that applies them all; a Set that also renames splits into setProps and Blend If.
#[test]
fn blend_if_with_the_dialog_options_is_one_step_and_a_rename_splits() {
let blnd = D::List(vec![blend_if_entry("gray", [0, 0, 128, 255], None)]);
let dialog = desc("Lyr ").with("Opct", pct(40.0)).with("fillOpacity", pct(70.0)).with("Md ", enm("BlnM", "Mltp")).with("Blnd", blnd.clone());
let mut s = blend_layers(8);
s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(dialog.clone())])).unwrap())})).unwrap();
let steps = &s.actions.list[0].steps;
assert_eq!(steps.len(), 1, "{steps:?}");
assert_eq!(steps[0].0, "layer.layerStyle.blendingOptions");
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let info = active_info(&s);
assert!((info["opacity"].as_f64().unwrap() - 0.4).abs() < 1e-3, "{info}");
assert_eq!(info["blend"], json!("Multiply"), "{info}");
assert_eq!(info["blendIf"][0]["thisLayer"], json!([0, 0, 128, 255]), "{info}");
let (_, d) = saved_first(&mut s);
assert_eq!(get_obj(d.as_ref().unwrap(), "T "), Some(&dialog));
// With a rename the Set is not the dialog's: the name and opacity go through setProps.
let mut s = blend_layers(8);
s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Nm ", text("Renamed")).with("Opct", pct(40.0)).with("Blnd", blnd))])).unwrap())}))
.unwrap();
let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect();
assert!(ids.contains(&"layer.setProps") && ids.contains(&"layer.layerStyle.blendingOptions"), "{ids:?}");
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let info = active_info(&s);
assert_eq!(info["name"], json!("Renamed"));
assert_eq!(info["blendIf"][0]["thisLayer"], json!([0, 0, 128, 255]), "{info}");
}
/// #10771: the Layer Style dialog's OK on a layer without effects also records the effects'
/// scale (`Lefx` = {Scl}). With Blend If it is still the dialog's one step and saves back with it.
#[test]
fn blend_if_with_the_recorded_effects_scale_saves_back_unchanged() {
let blnd = D::List(vec![blend_if_entry("gray", [10, 20, 200, 230], Some([0, 0, 255, 255]))]);
let dialog = desc("Lyr ").with("Opct", pct(80.0)).with("Blnd", blnd.clone()).with("Lefx", D::Descriptor(desc("Lefx").with("Scl ", pct(100.0))));
let mut s = blend_layers(8);
s.execute("actions.load", json!({"data": b64(&atn::write(&one_action(vec![layer_set(dialog.clone())])).unwrap())})).unwrap();
let steps = &s.actions.list[0].steps;
assert_eq!(steps.len(), 1, "{steps:?}");
assert_eq!(steps[0].0, "layer.layerStyle.blendingOptions");
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
let info = active_info(&s);
assert!((info["opacity"].as_f64().unwrap() - 0.8).abs() < 1e-3, "{info}");
assert_eq!(info["blendIf"][0]["thisLayer"], json!([10, 20, 200, 230]), "{info}");
let (_, d) = saved_first(&mut s);
assert_eq!(get_obj(d.as_ref().unwrap(), "T "), Some(&dialog));
// A `Lefx` with an effect in it is not the scale alone: the step is not the dialog's.
let with_fx = desc("Lyr ").with("Blnd", blnd).with("Lefx", D::Descriptor(desc("Lefx").with("Scl ", pct(100.0)).with("DrSh", D::Descriptor(desc("DrSh")))));
let imp = import(&atn::write(&one_action(vec![layer_set(with_fx)])).unwrap()).unwrap();
assert!(imp.actions[0].steps.iter().all(|(_, p)| p.get("__atnEffectsScale").is_none()), "{:?}", imp.actions[0].steps);
// Nor can it play without the shadow: it stays the recorded step.
assert_eq!(imp.actions[0].steps.iter().map(|(c, _)| c.as_str()).collect::<Vec<_>>(), [PHOTOSHOP_STEP], "{:?}", imp.actions[0].steps);
}
/// #10771: Blend If entries that are not whole levels, out of order, with half the Underlying
/// sliders or an unknown key stay Photoshop steps.
#[test]
fn blend_if_the_command_cannot_play_stays_a_photoshop_step() {
let with = |key: &str| match blend_if_entry("gray", [0, 0, 255, 255], None) {
D::Descriptor(d) => D::Descriptor(d.with(key, D::Integer(3))),
e => e,
};
let (half, unknown) = (with("DstB"), with("Zzzz"));
let inverted = blend_if_entry("gray", [0, 200, 100, 255], None);
for e in [blend_if_entry("gray", [0, 0, 300, 255], None), inverted, half, unknown] {
let imp = import(&atn::write(&one_action(vec![layer_set(desc("Lyr ").with("Blnd", D::List(vec![e])))])).unwrap()).unwrap();
assert_eq!(imp.actions[0].steps[0].0, PHOTOSHOP_STEP);
}
for p in [
json!({"blendIf": null}),
json!({"blendIf": {"channel": 2, "thisLayer": [0, 255]}}),
json!({"blendIf": {"channel": "gray", "thisLayer": [200, 100]}}),
json!({"blendIf": {"channel": "gray", "underlying": [0, 255]}}),
] {
assert!(export_item("layer.layerStyle.blendingOptions", &p, "S").is_none(), "{p}");
}
}
/// Save one action of steps through `actions.save`, returning the skipped list and the steps
/// `actions.load` reads back.
fn save_and_reload(steps: Vec<(String, Value)>) -> (Value, Vec<(String, Value)>) {
+11 -1
View File
@@ -80,7 +80,7 @@ pub(crate) use paths::{is_work, path_mut};
pub(crate) use shape::{bake_path, map_path, map_shape, shift_shape, with_shape};
use shape::{
can_paste_shape_fill, can_paste_shape_stroke, combine_shapes, copy_shape_fill, copy_shape_stroke, has_shape_fill, has_shape_stroke, merge_components,
paste_shape_fill, paste_shape_stroke, selection_to_shape, shape_create, shape_edit, shape_info, shape_rasterize,
paste_shape_fill, paste_shape_stroke, path_to_shape, selection_to_shape, shape_create, shape_edit, shape_info, shape_rasterize,
};
pub use shape::{preview_shape_stroke, refresh_shape, shape_path, transform_shape, translate_vectors};
use vector_mask::{toggle_vector_mask, vector_mask_add, vector_mask_delete, vector_mask_edit, vector_mask_info, vector_mask_rasterize, with};
@@ -230,6 +230,16 @@ pub fn specs() -> Vec<CommandSpec> {
run: |s, p| path_to_selection_with_mode(s, p, "subtract"),
journal: true,
},
spec!(
"path.toShape",
"Make Shape from Path",
[],
leak(format!(
r##"{{"name":str|"work"|"layer"="work" (the Work Path becomes the shape's path; a saved path stays) | "path":{{subpaths}} (inline; not with name),"consumeWorkPath":bool=false (with path: it was drawn into the Work Path, which goes),{fill}=foreground,{stroke}=none,"layerName":str?}} → shape.info"##
)),
has_doc,
path_to_shape
),
spec!("select.toWorkPath", "Make Work Path", [], r##"{"tolerance":0.5..10 px=2} → {subpaths,knots}"##, has_selection, select_to_work_path),
spec!(
"path.fill",
+67 -10
View File
@@ -432,16 +432,7 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
let l = live_from_params(CMD, kind, p, None)?;
(vector::shapes::live_path(&l), Some(l))
};
let fg = s.tools.foreground;
let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]);
let fill = match p.get("fill") {
Some(v) => parse_fill(v).map_err(|e| bad(CMD, e))?,
None => Some(Fill::Solid(fgc)),
};
let stroke = match p.get("stroke") {
Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(CMD, e))?,
None => None,
};
let (fill, stroke) = new_fill_stroke(s, CMD, p)?;
vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?;
// Add to an existing shape layer with a path operation (Shape tool in combine/subtract… mode).
if let Some(target) = p.get("addTo").and_then(Value::as_u64) {
@@ -471,6 +462,72 @@ pub(super) fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
shape_info(s, id)
}
/// A new shape layer's `fill` (default the foreground colour) and `stroke` (default none).
fn new_fill_stroke(s: &Session, cmd: &str, p: &Value) -> Result<(Option<Fill>, Option<photocraft_doc::ShapeStroke>)> {
let fg = s.tools.foreground;
let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]);
let fill = match p.get("fill") {
Some(v) => parse_fill(v).map_err(|e| bad(cmd, e))?,
None => Some(Fill::Solid(fgc)),
};
let stroke = match p.get("stroke") {
Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(cmd, e))?,
None => None,
};
Ok((fill, stroke))
}
/// The Pen options bar's Make: Shape: a new shape layer, as one History step, from the work path
/// or a saved path (`name`), or from an inline `path` (a Pen path still being drawn). The Work
/// Path becomes the shape's path, so it is gone afterwards; a saved path stays. An inline `path`
/// leaves the document's paths alone unless `consumeWorkPath` says it was drawn into the Work
/// Path. Unlike the other path commands, `"work"` never falls back to the active layer's path:
/// that would duplicate the selected shape or vector mask.
pub(super) fn path_to_shape(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "path.toShape";
let name = match p.get("name") {
None | Some(Value::Null) => None,
Some(Value::String(n)) => Some(n.as_str()),
Some(_) => return Err(bad(CMD, "`name` must be a path name: \"work\", \"layer\" or a saved path's name")),
};
let consume_flag = match p.get("consumeWorkPath") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(CMD, "`consumeWorkPath` must be true or false")),
};
let (path, consume_work) = match p.get("path").filter(|v| !v.is_null()) {
Some(_) if name.is_some() => return Err(bad(CMD, "give `name` or `path`, not both")),
Some(v) => (parse_path(v).map_err(|e| bad(CMD, e))?, consume_flag),
None if super::is_work(name) => {
let d = s.active().ok_or(EngineError::NoDocument)?;
(d.doc.work_path.clone().ok_or_else(|| bad(CMD, "there is no Work Path"))?, true)
}
None => (super::resolve_path(s, name)?, false),
};
if !path.subpaths.iter().any(|sp| sp.knots.len() >= 2) {
return Err(bad(CMD, "the path has no segment to make a shape from"));
}
let (fill, stroke) = new_fill_stroke(s, CMD, p)?;
vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?;
let layer_name = match p.get("layerName") {
None | Some(Value::Null) => None,
Some(Value::String(n)) if !n.trim().is_empty() => Some(n.clone()),
Some(_) => return Err(bad(CMD, "`layerName` must be a name that isn't empty")),
};
let mut sh = ShapeLayer { path, fill, stroke, ..Default::default() };
let id = s.edit("New Shape Layer", |doc, active| {
refresh_shape(doc, &mut sh);
let name = layer_name.unwrap_or_else(|| doc.next_layer_name(default_name("path")));
let id = crate::layer_place::insert_new(doc, *active, Layer::new(name, LayerContent::Shape(sh)), crate::layer_place::Place::AutoEditable);
if consume_work {
doc.work_path = None;
}
*active = Some(id);
Ok(id)
})?;
shape_info(s, id)
}
pub(super) fn shape_edit(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "shape.edit";
let id = layer_id(s, p)?;
+108
View File
@@ -403,6 +403,114 @@ fn path_to_selection_matches_analytic_shape() {
assert!(m[100 * 200 + 160] > 0.2 && m[100 * 200 + 160] < 0.8);
}
#[test]
fn path_to_shape_makes_a_shape_layer_from_the_work_path_in_one_step() {
let mut s = session(80, 80, 8);
let square = json!({"subpaths": [{"knots": [[10, 10], [50, 10], [50, 50], [10, 50]]}]});
s.execute("path.set", json!({"path": square})).unwrap();
let history = s.active().unwrap().history.past_len();
let info = s.execute("path.toShape", json!({"fill": "#ff0000"})).unwrap();
let id = LayerId(info["layer"].as_u64().unwrap());
let d = doc(&s);
let LayerContent::Shape(sh) = &d.layer(id).unwrap().content else { panic!("not a shape layer") };
assert_eq!(sh.path.subpaths[0].knots.len(), 4);
assert_eq!(sh.fill, Some(Fill::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0))));
// The Work Path became the shape's path.
assert!(d.work_path.is_none());
assert_eq!(s.active().unwrap().active_layer, Some(id));
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
s.undo();
assert!(doc(&s).layer(id).is_none());
assert!(doc(&s).work_path.is_some());
}
#[test]
fn path_to_shape_keeps_a_saved_path() {
let mut s = session(80, 80, 8);
let tri = json!({"subpaths": [{"knots": [[10, 10], [50, 10], [30, 50]]}]});
s.execute("path.set", json!({"name": "Tri", "path": tri})).unwrap();
let info = s.execute("path.toShape", json!({"name": "Tri", "layerName": "Roof"})).unwrap();
assert_eq!(info["name"], "Roof");
assert_eq!(doc(&s).paths[0].name, "Tri");
}
#[test]
fn path_to_shape_rejects_bad_input_without_changing_history() {
let mut s = session(80, 80, 8);
assert!(s.execute("path.toShape", json!({})).is_err(), "no path");
s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60]]}]}})).unwrap();
let before = doc(&s).clone();
let history = s.active().unwrap().history.past_len();
for p in [
json!({"name": "missing"}),
json!({"name": 42}),
json!({"name": ["work"]}),
json!({"fill": "not a colour"}),
json!({"fill": 42}),
json!({"stroke": {"width": -1}}),
json!({"stroke": {"width": 1e100}}),
json!({"stroke": true}),
] {
assert!(s.execute("path.toShape", p.clone()).is_err(), "accepted {p}");
assert_eq!(*doc(&s), before, "{p} modified the document");
assert_eq!(s.active().unwrap().history.past_len(), history, "{p} changed history");
}
}
/// With no Work Path, `path.toShape` doesn't take the active shape layer's path: that would only
/// duplicate the shape.
#[test]
fn path_to_shape_without_a_work_path_does_not_copy_the_active_shape() {
let mut s = session(80, 80, 8);
s.execute("shape.create", json!({"rect": [10, 10, 30, 30]})).unwrap();
let layers = doc(&s).layers.len();
for p in [json!({}), json!({"name": "work"})] {
assert!(s.execute("path.toShape", p.clone()).is_err(), "{p} made a shape");
}
assert_eq!(doc(&s).layers.len(), layers);
}
/// An inline `path` (a Pen path still being drawn) makes the shape and leaves the Work Path alone,
/// unless it was drawn into the Work Path (`consumeWorkPath`).
#[test]
fn path_to_shape_takes_an_inline_path() {
let mut s = session(80, 80, 8);
s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60]]}]}})).unwrap();
let drawn = json!({"subpaths": [{"knots": [[5, 5], [30, 5], [30, 30]]}]});
let history = s.active().unwrap().history.past_len();
let info = s.execute("path.toShape", json!({"path": drawn})).unwrap();
let id = LayerId(info["layer"].as_u64().unwrap());
let LayerContent::Shape(sh) = &doc(&s).layer(id).unwrap().content else { panic!("not a shape layer") };
assert_eq!(sh.path.subpaths[0].knots[0].anchor, Point::new(5.0, 5.0));
assert!(doc(&s).work_path.is_some(), "the Work Path stays");
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
s.execute("path.toShape", json!({"path": drawn, "consumeWorkPath": true})).unwrap();
assert!(doc(&s).work_path.is_none());
}
#[test]
fn path_to_shape_rejects_paths_and_names_it_cannot_use() {
let mut s = session(80, 80, 8);
s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12], [60, 12], [60, 60]]}]}})).unwrap();
let before = doc(&s).clone();
let drawn = json!({"subpaths": [{"knots": [[5, 5], [30, 5], [30, 30]]}]});
for p in [
json!({"path": {"subpaths": [{"knots": [[5, 5]]}]}}),
json!({"path": {"subpaths": []}}),
json!({"path": 42}),
json!({"path": drawn, "name": "work"}),
json!({"path": drawn, "consumeWorkPath": "yes"}),
json!({"layerName": 5}),
json!({"layerName": ""}),
json!({"layerName": " "}),
] {
assert!(s.execute("path.toShape", p.clone()).is_err(), "accepted {p}");
assert_eq!(*doc(&s), before, "{p} modified the document");
}
s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[12, 12]]}]}})).unwrap();
assert!(s.execute("path.toShape", json!({})).is_err(), "a one-anchor Work Path");
}
#[test]
fn path_make_selection_rejects_invalid_options_without_changing_history() {
let mut s = session(80, 80, 8);
+4 -2
View File
@@ -99,10 +99,12 @@ fn finish(app: &mut PhotocraftApp, mut d: Drag, click: Option<bool>) {
crate::vector_ui::freeform_finish_owned(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight()), owner);
}
}
// Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead.
// Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead. In Path
// mode a click adds nothing: the dialog makes a shape layer, which Path mode never draws,
// and the click's few pixels of travel would add a speck of a subpath to the work path.
t if crate::vector_ui::is_shape_tool(t)
&& crate::shape_dialog::is_click(d.start, &d.points, app.point_zoom())
&& crate::shape_dialog::open(app, t, d.start).is_some() => {}
&& (crate::vector_ui::draws_path(&app.ui.tool_options) || crate::shape_dialog::open(app, t, d.start).is_some()) => {}
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape(app, t, d.start, [end[0], end[1]], d.live),
Tool::PathSelection => crate::vector_ui::path_selection_finish(app, d.start, [end[0], end[1]]),
Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask => crate::type_tool::pointer_up(app, d.start, [end[0], end[1]]),
+1 -1
View File
@@ -890,7 +890,7 @@ fn ui_set_drives_the_layers_panel_artboard_filter() {
assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard);
let bad = call(&mut app, &ctx, "ui.set", json!({"layerFilter": {"by": "artboards"}}));
assert_eq!(bad["ok"], false, "{bad}");
assert!(bad.to_string().contains("kind, name, effect, mode, attribute, color, smartObject or artboard"), "{bad}");
assert!(bad.to_string().contains("kind, name, effect, mode, attribute, color, smartObject, artboard or group"), "{bad}");
assert_eq!(app.ui.layer_filter_by, FilterBy::Artboard, "a bad value changed nothing");
}
+4 -2
View File
@@ -361,10 +361,12 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
return true;
}
if app.distort.select_mask.is_some() {
// Enter closes a Polygonal Lasso polygon, else OK; Esc drops one, else Cancel.
// Enter closes a Polygonal Lasso polygon, else OK; Esc ends a brush resize (with
// Control+Alt, its macOS gesture, still held too), else drops a polygon, else Cancel.
let resize_esc = |ctx: &egui::Context| ctx.input_mut(|i| i.consume_key(Modifiers::CTRL | Modifiers::ALT, Key::Escape));
if enter(ctx) {
crate::select_mask_ui::enter(app);
} else if esc(ctx) {
} else if esc(ctx) || (crate::select_mask_ui::resizing(app) && resize_esc(ctx)) {
crate::select_mask_ui::escape(app);
}
return true;
+88 -8
View File
@@ -426,6 +426,9 @@ pub struct SelectMaskWorkspace {
/// Document px per reduced-copy px (High Quality Preview uses 1).
k: u32,
pressed: bool,
/// An on-canvas brush resize in progress (#10497): the tool it resizes, where it began on
/// screen, the size and hardness then, and the button that drags it.
resize: Option<(SmTool, Pos2, f64, f32, egui::PointerButton)>,
/// The device's texture side limit, as the context reports it each frame.
texture_limit: usize,
image_tex: Option<TextureHandle>,
@@ -482,6 +485,18 @@ impl SelectMaskWorkspace {
}
}
/// Esc mid-resize: the brush goes back to its size and hardness when the drag began, as in
/// Photoshop. egui drops the drag on Esc, so the rest of it does nothing.
fn escape_resize(&mut self) {
let Some((tool, _, size, hardness, _)) = self.resize.take() else { return };
match tool {
SmTool::Quick => self.quick_size = size,
SmTool::RefineEdge => self.refine_size = size,
_ => self.brush_size = size,
}
self.hardness = hardness;
}
fn pick(&mut self, t: SmTool) {
self.tool = t;
match t {
@@ -761,6 +776,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result<(), String> {
hide_view: false,
k,
pressed: false,
resize: None,
texture_limit: 2048,
image_tex: None,
zoom: 0.0,
@@ -877,9 +893,16 @@ pub fn enter(app: &mut PhotocraftApp) -> bool {
false
}
/// Esc: drops a Polygonal Lasso polygon being drawn, else Cancel.
/// A brush resize is in progress (Esc then ends it, with Control+Alt still held too).
pub fn resizing(app: &PhotocraftApp) -> bool {
app.distort.select_mask.as_ref().is_some_and(|w| w.resize.is_some())
}
/// Esc: ends a brush resize as it began, else drops a Polygonal Lasso polygon being drawn, else
/// Cancel.
pub fn escape(app: &mut PhotocraftApp) {
match app.distort.select_mask.as_mut() {
Some(w) if w.resize.is_some() => w.escape_resize(),
Some(w) if w.poly.is_some() => w.poly = None,
_ => cancel(app),
}
@@ -1752,13 +1775,28 @@ fn paint_cursor(ctx: &egui::Context, w: &mut SelectMaskWorkspace, clip: &egui::P
ctx.set_cursor_icon(egui::CursorIcon::Grab);
} else if w.tool.brushes() {
let r = (w.size() as f32 * 0.5 * v.zoom).max(2.0);
clip.circle_stroke(hp, r, Stroke::new(1.5, Color32::BLACK));
clip.circle_stroke(hp, r, Stroke::new(0.75, Color32::WHITE));
let add = w.add != alt;
for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] {
clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s);
if add {
clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s);
// While its size is dragged the circle stays where the drag began, with the
// Diameter/Hardness readout beside the pointer, as on the main canvas (#10497).
let resize = w.resize.filter(|&(tool, ..)| tool == w.tool);
let center = resize.map_or(hp, |(_, anchor, ..)| anchor);
clip.circle_stroke(center, r, Stroke::new(1.5, Color32::BLACK));
clip.circle_stroke(center, r, Stroke::new(0.75, Color32::WHITE));
if resize.is_some() {
let hardness = if w.tool == SmTool::Brush { format!("{}%", w.hardness.round() as i64) } else { "—".into() };
crate::canvas::draw_readout(
ctx,
"select-mask-brush-resize-readout",
hp,
["Diameter:", "Hardness:"],
[format!("{} px", w.size().round() as i64), hardness],
);
} else {
let add = w.add != alt;
for s in [Stroke::new(2.5, Color32::from_black_alpha(140)), Stroke::new(1.0, Color32::from_white_alpha(220))] {
clip.line_segment([hp - vec2(3.0, 0.0), hp + vec2(3.0, 0.0)], s);
if add {
clip.line_segment([hp - vec2(0.0, 3.0), hp + vec2(0.0, 3.0)], s);
}
}
}
ctx.set_cursor_icon(egui::CursorIcon::None);
@@ -1785,6 +1823,48 @@ fn canvas_input(
events: &mut Vec<ToolEvent>,
mods: &mut egui::Modifiers,
) {
// Alt+right-drag, or Control+Alt-drag, resizes the brush instead of selecting (#10497), from
// the press on, so its first frame never starts a stroke. Alt+left-drag still subtracts.
let (secondary, now) = ui.input(|i| (i.pointer.button_down(egui::PointerButton::Secondary), i.modifiers));
let gesture = if secondary { crate::brush_resize::is_right_gesture(now) } else { crate::brush_resize::is_gesture(now) };
// A resize keeps its drag until its button is let go: Space or the middle button may pan
// meanwhile, and if another tool is picked mid-drag the rest of it neither resizes nor
// selects with that tool.
let held = |button| ui.input(|i| i.pointer.button_down(button));
if let Some((tool, .., button)) = w.resize
&& (pan || tool != w.tool)
{
if !held(button) {
w.resize = None;
}
if !pan {
ctx.request_repaint();
return;
}
}
if !pan && w.tool.brushes() && !w.pressed && (w.resize.is_some() || (resp.is_pointer_button_down_on() && gesture)) {
if w.resize.is_none()
&& let Some(p) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos())
{
let button = if secondary { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
w.resize = Some((w.tool, p, w.size(), w.hardness, button));
}
if let (Some((_, anchor, size0, hardness0, _)), Some(p)) = (w.resize, resp.interact_pointer_pos()) {
let d = p - anchor;
let zoom = if w.zoom.is_finite() && w.zoom > 0.0 { w.zoom } else { 1.0 };
let (size, hardness) = crate::brush_resize::resized((size0 as f32, hardness0 / 100.0), f64::from(d.x / zoom), f64::from(d.y / zoom), zoom);
w.set_size(f64::from(size));
// Only the Brush tool has a hardness; Quick Selection and Refine Edge change size only.
if w.tool == SmTool::Brush {
w.hardness = (hardness * 100.0).round();
}
}
if resp.drag_stopped() || w.resize.is_some_and(|(.., button)| !held(button)) {
w.resize = None;
}
ctx.request_repaint();
return;
}
if pan && resp.dragged() {
let dl = resp.drag_delta();
w.center = [w.center[0] - f64::from(dl.x / w.zoom), w.center[1] - f64::from(dl.y / w.zoom)];
@@ -808,3 +808,292 @@ fn sparse_large_preview_counts_coverage_without_a_dense_canvas_mask() {
assert_eq!(selected_pixels(Some(&inverted), smaller), 999_999);
assert_eq!(selected_pixels(None, area), 0);
}
/// The workspace in a harness on `tool`, and a point over the image.
fn resize_harness(tool: &str) -> (egui_kittest::Harness<'static, PhotocraftApp>, egui::Pos2) {
let mut app = app_with_selection();
open(&mut app).unwrap();
ui(&mut app, json!({"tool": tool}));
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.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::distort_ui::keys(app, &ctx);
keys(app, &ctx);
show(app, &ctx);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(4);
(h, egui::Pos2::new(LEFT_W + 300.0, BAR_H + TAB_H + 300.0))
}
/// Presses `button` at `at` with `modifiers` held.
fn pointer_down(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, at: egui::Pos2, button: egui::PointerButton, modifiers: Modifiers) {
h.event(egui::Event::ModifiersChanged(modifiers));
h.event(egui::Event::PointerMoved(at));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers });
h.run_steps(1);
}
/// Moves the held pointer from `from` by `by`, in four steps.
fn pointer_move(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, from: egui::Pos2, by: egui::Vec2) {
for k in 1..=4 {
h.event(egui::Event::PointerMoved(from + by * (k as f32 / 4.0)));
h.run_steps(1);
}
}
fn tap_key(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, key: Key, pressed: bool) {
h.event(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
}
/// A whole drag of `button` from `at` by `by` with `modifiers` held.
fn pointer_drag(h: &mut egui_kittest::Harness<'_, PhotocraftApp>, at: egui::Pos2, button: egui::PointerButton, modifiers: Modifiers, by: egui::Vec2) {
pointer_down(h, at, button, modifiers);
pointer_move(h, at, by);
h.event(egui::Event::PointerButton { pos: at + by, button, pressed: false, modifiers });
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
/// Alt+right-drag (and Control+Alt-drag) on the image resizes the current brush tool instead of
/// selecting, as on the main canvas (#10497): only that tool's size changes, the Brush's hardness
/// follows vertical motion, nothing is painted, and the next Alt-drag still subtracts.
#[test]
fn alt_right_drag_resizes_the_workspace_brush() {
use egui::PointerButton;
let (mut h, over) = resize_harness("brush");
let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom;
pointer_drag(&mut h, over, PointerButton::Secondary, Modifiers::ALT, vec2(40.0, -60.0));
let w = h.state().distort.select_mask.as_ref().unwrap();
let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round();
assert_eq!(w.brush_size, want, "horizontal motion sets the diameter (zoom {zoom})");
assert_eq!(w.hardness, 70.0, "upward motion softens the brush, 200 points for 100 %");
assert_eq!((w.quick_size, w.refine_size), (30.0, 30.0), "the other tools keep their sizes");
assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "resizing selected nothing");
assert!(w.resize.is_none(), "the gesture ends with the button");
// Alt with the left button is still the subtracting stroke.
pointer_drag(&mut h, over, PointerButton::Primary, Modifiers::ALT, vec2(30.0, 0.0));
let w = h.state().distort.select_mask.as_ref().unwrap();
// A Brush stroke is an edit (Quick Selection's are strokes).
assert_eq!(w.edits.len(), 1, "Alt+left-drag selects");
assert_eq!(w.edits[0]["mode"], json!("subtract"));
assert_eq!(w.brush_size, want, "and doesn't resize");
// Control+Alt-drag (the macOS gesture) resizes Quick Selection on its own.
h.state_mut().distort.select_mask.as_mut().unwrap().pick(SmTool::Quick);
pointer_drag(&mut h, over, PointerButton::Primary, Modifiers::CTRL | Modifiers::ALT, vec2(-10.0, 0.0));
let w = h.state().distort.select_mask.as_ref().unwrap();
assert_eq!(w.quick_size, (30.0 - 2.0 * f64::from(10.0 / zoom)).round().max(1.0));
assert_eq!((w.brush_size, w.hardness), (want, 70.0), "the Brush keeps its size and hardness");
assert!(w.strokes.is_empty() && w.edits.len() == 1, "resizing Quick Selection selected nothing");
}
/// Space pressed mid-resize pans, and the button released before Space still ends the resize
/// (CodeRabbit on #10559): a Space-drag that goes on after Space comes up paints, as any
/// Space-drag does, instead of resizing the brush from the old anchor.
#[test]
fn a_resize_released_while_space_pans_lets_the_next_drag_paint() {
use egui::PointerButton;
let (mut h, over) = resize_harness("brush");
let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom;
pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT);
pointer_move(&mut h, over, vec2(40.0, 0.0));
tap_key(&mut h, Key::Space, true);
h.event(egui::Event::PointerButton { pos: over + vec2(40.0, 0.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT });
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round();
assert_eq!(h.state().distort.select_mask.as_ref().unwrap().brush_size, want);
// Space+drag pans; Space comes up mid-drag and the drag goes on.
pointer_down(&mut h, over, PointerButton::Primary, Modifiers::NONE);
pointer_move(&mut h, over, vec2(0.0, 20.0));
tap_key(&mut h, Key::Space, false);
pointer_move(&mut h, over + vec2(0.0, 20.0), vec2(30.0, 0.0));
h.event(egui::Event::PointerButton { pos: over + vec2(30.0, 20.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let w = h.state().distort.select_mask.as_ref().unwrap();
assert_eq!(w.brush_size, want, "the drag doesn't resize the brush");
assert_eq!(w.edits.len(), 1, "the drag's rest paints");
assert_eq!(w.edits[0]["mode"], json!("add"));
assert!(w.resize.is_none());
}
/// A resize belongs to the tool it began on (CodeRabbit on #10559): if another tool is picked
/// mid-drag, the rest of the drag neither resizes that tool nor selects with it.
#[test]
fn picking_another_tool_mid_resize_neither_resizes_nor_selects() {
use egui::PointerButton;
let (mut h, over) = resize_harness("brush");
let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom;
let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round();
// Alt+right-drag the Brush larger, let go of Alt, press W (Quick Selection) and drag on.
pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT);
pointer_move(&mut h, over, vec2(40.0, 0.0));
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
tap_key(&mut h, Key::W, true);
tap_key(&mut h, Key::W, false);
assert_eq!(h.state().distort.select_mask.as_ref().unwrap().tool, SmTool::Quick);
pointer_move(&mut h, over + vec2(40.0, 0.0), vec2(40.0, 0.0));
h.event(egui::Event::PointerButton { pos: over + vec2(80.0, 0.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let w = h.state().distort.select_mask.as_ref().unwrap();
assert_eq!((w.brush_size, w.quick_size), (want, 30.0), "Quick Selection keeps its size");
assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "nothing was selected");
// The same with the Lasso (not a brush) picked mid-drag: the rest of the drag draws no lasso.
tap_key(&mut h, Key::B, true);
tap_key(&mut h, Key::B, false);
pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT);
pointer_move(&mut h, over, vec2(20.0, 0.0));
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
tap_key(&mut h, Key::L, true);
tap_key(&mut h, Key::L, false);
assert_eq!(h.state().distort.select_mask.as_ref().unwrap().tool, SmTool::Lasso);
pointer_move(&mut h, over + vec2(20.0, 0.0), vec2(0.0, 60.0));
pointer_move(&mut h, over + vec2(20.0, 60.0), vec2(-60.0, 0.0));
h.event(egui::Event::PointerButton { pos: over + vec2(-40.0, 60.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let w = h.state().distort.select_mask.as_ref().unwrap();
assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "the Lasso selected nothing");
assert!(w.resize.is_none(), "the gesture ends with the button");
}
/// A resize ends when its own button comes up, even while the middle button pans (CodeRabbit on
/// #10559): a left-drag begun before the middle button is let go doesn't resize the brush from
/// the old anchor when the pan ends.
#[test]
fn a_resize_released_while_the_middle_button_pans_ends_with_its_button() {
use egui::PointerButton;
let (mut h, over) = resize_harness("brush");
let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom;
let want = (30.0 + 2.0 * f64::from(40.0 / zoom)).round();
pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT);
pointer_move(&mut h, over, vec2(40.0, 0.0));
let at = over + vec2(40.0, 0.0);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::ALT });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT });
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
assert_eq!(h.state().distort.select_mask.as_ref().unwrap().brush_size, want);
// The left button goes down while the middle one still pans, then the middle one comes up
// and the left drag goes on.
pointer_move(&mut h, at, vec2(0.0, 20.0));
let at = at + vec2(0.0, 20.0);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
pointer_move(&mut h, at, vec2(30.0, -20.0));
let at = at + vec2(30.0, -20.0);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(1);
pointer_move(&mut h, at, vec2(60.0, -40.0));
h.event(egui::Event::PointerButton { pos: at + vec2(60.0, -40.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let w = h.state().distort.select_mask.as_ref().unwrap();
assert_eq!((w.brush_size, w.hardness), (want, 100.0), "the later drag doesn't resize the brush");
assert!(w.resize.is_none(), "the gesture ended with its button");
}
/// Esc mid-resize puts the brush back and ends only the resize, as in Photoshop (FredApps on
/// #10559): the workspace stays open, the rest of that drag neither resizes nor selects, and the
/// next drag selects as usual.
#[test]
fn escape_mid_resize_restores_the_brush_and_keeps_the_workspace() {
use egui::PointerButton;
let (mut h, over) = resize_harness("brush");
pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT);
pointer_move(&mut h, over, vec2(40.0, -60.0));
let w = h.state().distort.select_mask.as_ref().unwrap();
assert!(w.resize.is_some() && w.brush_size > 30.0 && w.hardness < 100.0, "the drag resizes");
// Esc with Alt and the right button still held.
h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::ALT });
h.run_steps(1);
h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::ALT });
h.run_steps(1);
let w = h.state().distort.select_mask.as_ref().expect("Esc ends the resize, not the workspace");
assert_eq!((w.brush_size, w.hardness), (30.0, 100.0), "the brush is as it was");
assert!(w.resize.is_none());
// The drag goes on with Alt and the right button held: it does nothing.
pointer_move(&mut h, over + vec2(40.0, -60.0), vec2(40.0, 40.0));
h.event(egui::Event::PointerButton { pos: over + vec2(80.0, -20.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT });
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
let w = h.state().distort.select_mask.as_ref().unwrap();
assert_eq!((w.brush_size, w.hardness), (30.0, 100.0), "the rest of the drag doesn't resize");
assert!(w.strokes.is_empty() && w.edits.is_empty() && w.undo.is_empty(), "nor select");
assert!(w.resize.is_none());
// The next drag selects with the brush as it was.
pointer_drag(&mut h, over, PointerButton::Primary, Modifiers::NONE, vec2(30.0, 0.0));
let w = h.state().distort.select_mask.as_ref().unwrap();
assert_eq!((w.brush_size, w.hardness), (30.0, 100.0), "the next drag doesn't resize");
assert_eq!(w.edits.len(), 1, "it selects");
assert_eq!(w.edits[0]["mode"], json!("add"));
// Control+Alt-drag (the macOS gesture) on Quick Selection, Esc with both still held.
let ctrl_alt = Modifiers::CTRL | Modifiers::ALT;
h.state_mut().distort.select_mask.as_mut().unwrap().pick(SmTool::Quick);
pointer_down(&mut h, over, PointerButton::Primary, ctrl_alt);
pointer_move(&mut h, over, vec2(30.0, 0.0));
assert!(h.state().distort.select_mask.as_ref().unwrap().quick_size > 30.0, "the drag resizes");
h.event(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: ctrl_alt });
h.run_steps(1);
let w = h.state().distort.select_mask.as_ref().expect("the workspace stays open");
assert_eq!(w.quick_size, 30.0, "Quick Selection's size is as it was");
assert!(w.resize.is_none());
pointer_move(&mut h, over + vec2(30.0, 0.0), vec2(30.0, 0.0));
h.event(egui::Event::PointerButton { pos: over + vec2(60.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: ctrl_alt });
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
let w = h.state().distort.select_mask.as_ref().unwrap();
assert_eq!(w.quick_size, 30.0, "the rest of the drag doesn't resize");
assert!(w.strokes.is_empty() && w.edits.len() == 1, "nor select");
}
/// The brush circles drawn last frame (the black outline's centre and radius).
fn brush_circles(h: &egui_kittest::Harness<'_, PhotocraftApp>) -> Vec<(egui::Pos2, f32)> {
h.output()
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Circle(s) if s.stroke == Stroke::new(1.5, Color32::BLACK) => Some((s.center, s.radius)),
_ => None,
})
.collect()
}
/// While the brush is resized its circle stays where the drag began, at the new size, and the
/// Diameter/Hardness readout follows the pointer, as on the main canvas (FredApps on #10559).
#[test]
fn the_resize_circle_stays_put_while_the_readout_follows_the_pointer() {
use egui::PointerButton;
let (mut h, over) = resize_harness("brush");
let zoom = h.state().distort.select_mask.as_ref().unwrap().zoom;
let readout = || egui::Id::unique("select-mask-brush-resize-readout");
h.event(egui::Event::PointerMoved(over));
h.run_steps(2);
assert_eq!(brush_circles(&h), [(over, 15.0 * zoom)], "hovering, the circle is at the pointer");
pointer_down(&mut h, over, PointerButton::Secondary, Modifiers::ALT);
for by in [vec2(40.0, -60.0), vec2(60.0, -60.0)] {
pointer_move(&mut h, over, by);
// The cursor is drawn before the frame's input, so it shows the new size a frame later.
h.run_steps(1);
let at = over + by;
let w = h.state().distort.select_mask.as_ref().unwrap();
let (size, hardness) = (w.brush_size, w.hardness);
assert_eq!(brush_circles(&h), [(over, size as f32 * 0.5 * zoom)], "the circle stays at the anchor at the new size ({by:?})");
let rect = h.ctx.memory(|m| m.area_rect(readout())).expect("the readout shows");
assert_eq!(rect.min, at + vec2(16.0, 18.0), "the readout is beside the pointer");
assert!(h.query_by_label(&format!("{} px", size.round() as i64)).is_some(), "it shows the diameter");
assert!(h.query_by_label(&format!("{}%", hardness.round() as i64)).is_some(), "and the hardness");
}
h.event(egui::Event::PointerButton { pos: over + vec2(60.0, -60.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::ALT });
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
let size = h.state().distort.select_mask.as_ref().unwrap().brush_size;
assert_eq!(brush_circles(&h), [(over + vec2(60.0, -60.0), size as f32 * 0.5 * zoom)], "after the drag the circle follows the pointer again");
assert!(h.query_by_label("Diameter:").is_none(), "and the readout is gone");
}
@@ -354,3 +354,35 @@ fn right_click_picks_the_unit_of_that_field_only_and_it_is_not_remembered() {
let s = sides(&frame(&egui::Context::default(), &app, &mut f, vec![]));
assert_eq!(shown(&s), [("1.76".to_string(), "cm".to_string()), ("3.53".to_string(), "cm".to_string())]);
}
/// CodeRabbit on #9456: in Path mode a click opens no Create dialog, whose OK would make a shape
/// layer; Path mode never draws one (a drag adds its outline to the work path).
#[test]
fn a_path_mode_click_opens_no_create_dialog() {
for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon] {
let mut app = app();
app.ui.tool_options.shape_mode = "path".into();
let before = layers(&app);
press(&mut app, tool, [20.4, 30.2], [20.4, 30.2]);
assert!(app.ui.dialogs.is_empty(), "{tool:?}: no Create dialog in Path mode");
assert_eq!(layers(&app), before, "{tool:?}: and no layer");
}
}
/// CodeRabbit on #9456: a Path-mode click that moved a little, still within the click slop, adds
/// nothing to the work path either. Its box would round to a 1 × 1 or 2 × 2 px outline.
#[test]
fn a_path_mode_click_that_moved_a_little_adds_no_subpath() {
let work = |app: &PhotocraftApp| app.session.active().unwrap().doc.work_path.as_ref().map_or(0, |p| p.subpaths.len());
for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line] {
for end in [[20.8, 30.8], [21.8, 31.9]] {
let mut app = app();
app.ui.tool_options.shape_mode = "path".into();
let before = layers(&app);
press(&mut app, tool, [20.2, 30.2], end);
assert_eq!(work(&app), 0, "{tool:?} to {end:?}: no subpath");
assert!(app.ui.dialogs.is_empty(), "{tool:?} to {end:?}: no Create dialog");
assert_eq!(layers(&app), before, "{tool:?} to {end:?}: and no layer");
}
}
}
+3 -2
View File
@@ -680,7 +680,7 @@
Working Magenta Plate لوح الأرجواني العامل
Working Yellow Plate لوح الأصفر العامل
Working Black Plate لوح الأسود العامل
Working CMY Plates لوح CMY العامل
Working CMY Plates ألواح CMY العاملة
Legacy Macintosh RGB (Gamma 1.8) RGB القديم لنظام Macintosh (Gamma 1.8)
Internet Standard RGB (sRGB) RGB القياسي للإنترنت (sRGB)
Monitor RGB RGB الشاشة
@@ -764,7 +764,7 @@
Consolidate All to Tabs توحيد الكل في علامات تبويب
Float in Window تعويم في نافذة
Float All in Windows تعويم الكل في نوافذ
New Window نافذة جديدة للمستند
New Window نافذة جديدة
Workspace مساحة العمل
Essentials الأساسيات
Graphic and Web الرسومات والويب
@@ -2306,6 +2306,7 @@
Punctuation علامات الترقيم
Make Work Path إنشاء مسار عمل
Make Selection إنشاء تحديد
Make Shape إنشاء شكل
Fill Path تعبئة المسار
Stroke Path رسم حدود للمسار
Delete Path حذف المسار
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -623,7 +623,7 @@
Working Magenta Plate Radna magenta ploča
Working Yellow Plate Radna žuta ploča
Working Black Plate Radna crna ploča
Working CMY Plates Radna CMY ploča
Working CMY Plates Radne CMY ploče
Legacy Macintosh RGB (Gamma 1.8) Naslijeđeni Macintosh RGB (Gamma 1.8)
Internet Standard RGB (sRGB) Internet standardni RGB (sRGB)
Monitor RGB RGB monitora
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -876,7 +876,7 @@
Consolidate All to Tabs Sloučit vše do záložek
Float in Window Uvolnit do plovoucího okna
Float All in Windows Uvolnit vše do plovoucích oken
New Window Nové okno dokumentu
New Window Nové okno
Workspace Pracovní prostor
Essentials Základy
Graphic and Web Grafika a web
@@ -3239,6 +3239,7 @@ cameraData Lens Objektiv
Magnetic Lasso Magnetické laso
Make Výrobce
Make Selection Vytvořit výběr
Make Shape Vytvořit tvar
Make Work Path Vytvořit pracovní cestu
Manual Ručně
Map Document Dokument mapy
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -1214,6 +1214,7 @@
Path to Illustrator… Sti til Illustrator…
Make Work Path Opret arbejdssti
Make Selection Opret markering
Make Shape Opret form
Fill Path Udfyld sti
Stroke Path Lav kontur på sti
Clipping Path Udklipningssti
@@ -2061,7 +2062,7 @@
Vertical Centers Lodrette midtpunkter
Video Frames to Layers… Videobilleder til lag…
WIA Support… WIA-support…
Working CMY Plates Arbejds-CMY-plade
Working CMY Plates Arbejds-CMY-plader
# tl! literals (third pass)
Absolute Absolut
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -2
View File
@@ -755,7 +755,7 @@
Working Magenta Plate Arbeits-Magenta-Druckplatte
Working Yellow Plate Arbeits-Gelb-Druckplatte
Working Black Plate Arbeits-Schwarz-Druckplatte
Working CMY Plates Arbeits-CMY-Druckplatte
Working CMY Plates Arbeits-CMY-Druckplatten
Legacy Macintosh RGB (Gamma 1.8) Legacy-Macintosh-RGB (Gamma 1.8)
Internet Standard RGB (sRGB) Internet-Standard-RGB (sRGB)
Monitor RGB Monitor-RGB
@@ -839,7 +839,7 @@
Consolidate All to Tabs Alle in Registerkarten zusammenfassen
Float in Window In Fenster schweben lassen
Float All in Windows Alle in Fenstern schweben lassen
New Window Neues Fenster für Dokument
New Window Neues Fenster
Workspace Arbeitsbereich
Essentials Essentials
Graphic and Web Grafik und Web
@@ -2381,6 +2381,7 @@
Punctuation Zeichensetzung
Make Work Path Arbeitspfad erzeugen
Make Selection Auswahl erzeugen
Make Shape Form erzeugen
Fill Path Pfad füllen
Stroke Path Pfad nachziehen
Delete Path Pfad löschen
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2797,6 +2797,7 @@
Make Κατασκευαστής
Make Default Ορισμός ως προεπιλογής
Make Selection Δημιουργία επιλογής
Make Shape Δημιουργία σχήματος
Make Work Path Δημιουργία διαδρομής εργασίας
Marker Size Μέγεθος δείκτη
Master Κύριο
Can't render this file because it contains an unexpected character in line 47 and column 53.
+2 -1
View File
@@ -720,7 +720,7 @@
Working Magenta Plate Placa magenta de trabajo
Working Yellow Plate Placa amarilla de trabajo
Working Black Plate Placa negra de trabajo
Working CMY Plates Placa CMY de trabajo
Working CMY Plates Placas CMY de trabajo
Legacy Macintosh RGB (Gamma 1.8) RGB heredado de Macintosh (Gamma 1.8)
Internet Standard RGB (sRGB) RGB estándar de Internet (sRGB)
Monitor RGB RGB del monitor
@@ -2353,6 +2353,7 @@
Punctuation Puntuación
Make Work Path Crear trazado de trabajo
Make Selection Crear selección
Make Shape Crear forma
Fill Path Rellenar trazado
Stroke Path Contornear trazado
Delete Path Eliminar trazado
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2 -1
View File
@@ -1215,6 +1215,7 @@
Path to Illustrator… Polku Illustratoriin…
Make Work Path Luo työpolku
Make Selection Luo valinta
Make Shape Luo muoto
Fill Path Täytä polku
Stroke Path Piirrä polun reunaviiva
Clipping Path Leikkauspolku
@@ -2062,7 +2063,7 @@
Vertical Centers Pystykeskikohdat
Video Frames to Layers… Videoruudut tasoiksi…
WIA Support… WIA-tuki…
Working CMY Plates Työ-CMY-levy
Working CMY Plates Työ-CMY-levyt
# tl! literals (third pass)
Absolute Absoluuttinen
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -736,7 +736,7 @@
Working Magenta Plate Plaque magenta de travail
Working Yellow Plate Plaque jaune de travail
Working Black Plate Plaque noire de travail
Working CMY Plates Plaque CMJ de travail
Working CMY Plates Plaques CMJ de travail
Legacy Macintosh RGB (Gamma 1.8) RVB Macintosh (ancien) (Gamma 1.8)
Internet Standard RGB (sRGB) RVB standard d'Internet (sRGB)
Monitor RGB RVB du moniteur
@@ -2493,6 +2493,7 @@
Path to Illustrator… Tracé vers Illustrator…
Make Work Path Créer un tracé de travail
Make Selection Créer une sélection
Make Shape Créer une forme
Fill Path Remplir le tracé
Stroke Path Contourner le tracé
Clipping Path Tracé de détourage
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -160,6 +160,8 @@
Layer Mask लेयर मास्क
Hide All सभी छिपाएँ
Reveal Selection चयन दिखाएँ
Reveal in Finder Finder में दिखाएँ
Reveal in Explorer Explorer में दिखाएँ
Hide Selection चयन छिपाएँ
From Transparency पारदर्शिता से
Apply लागू करें
@@ -350,6 +352,7 @@
Aligned संरेखित
Fit in View दृश्य में फ़िट करें
Camera Raw Camera Raw
Camera Raw… Camera Raw…
cameraRaw Amount मात्रा
Amount मात्रा
cameraRaw Aqua एक्वा
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3443,6 +3443,7 @@ cameraRaw Yellow Sárga
Magnetic Lasso Mágneses lasszó
Magnetic Lasso Tool Mágneses lasszó
Make Selection Kijelölés készítése
Make Shape Alakzat készítése
Make work path from selection Munkaútvonal készítése a kijelölésből
Manual Kézi
manual kézi
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -825,7 +825,7 @@
Consolidate All to Tabs Gabungkan Semua ke Tab
Float in Window Mengambang di Jendela
Float All in Windows Mengambangkan Semua di Jendela
New Window Jendela Baru untuk Dokumen
New Window Jendela Baru
Workspace Ruang Kerja
Essentials Esensial
Graphic and Web Grafis dan Web
@@ -2501,6 +2501,7 @@
Path to Illustrator… Path ke Illustrator…
Make Work Path Buat Work Path
Make Selection Buat Seleksi
Make Shape Buat Bentuk
Fill Path Isi Path
Stroke Path Stroke Path
Clipping Path Clipping Path
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2 -1
View File
@@ -763,7 +763,7 @@
Working Magenta Plate Lastra magenta di lavoro
Working Yellow Plate Lastra gialla di lavoro
Working Black Plate Lastra nera di lavoro
Working CMY Plates Lastra CMY di lavoro
Working CMY Plates Lastre CMY di lavoro
Legacy Macintosh RGB (Gamma 1.8) RGB legacy Macintosh (Gamma 1.8)
Internet Standard RGB (sRGB) RGB standard Internet (sRGB)
Monitor RGB RGB monitor
@@ -2400,6 +2400,7 @@
Punctuation Punteggiatura
Make Work Path Crea tracciato di lavoro
Make Selection Crea selezione
Make Shape Crea forma
Fill Path Riempi tracciato
Stroke Path Traccia tracciato
Delete Path Elimina tracciato
Can't render this file because it contains an unexpected character in line 79 and column 53.
+2 -1
View File
@@ -870,7 +870,7 @@
Consolidate All to Tabs すべてをタブに統合
Float in Window ウィンドウ内に浮動
Float All in Windows すべてをウィンドウ内に浮動
New Window ドキュメントの新規ウィンドウ
New Window 新規ウィンドウ
Workspace ワークスペース
Essentials 基本
Graphic and Web グラフィックと Web
@@ -3041,6 +3041,7 @@ fontWeight Italic 斜体
Magnetic Lasso マグネット投げ縄
Make 作成
Make Selection 選択範囲を作成
Make Shape シェイプを作成
Make Work Path 作業用パスを作成
Match Color カラーを一致
Mezzotint メゾティント
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -825,7 +825,7 @@
Consolidate All to Tabs 모두 탭으로 통합
Float in Window 별도 창으로 띄우기
Float All in Windows 모두 별도 창으로 띄우기
New Window 문서의 새 창
New Window 새 창
Workspace 작업 영역
Essentials 필수
Graphic and Web 그래픽 및 웹
@@ -3588,6 +3588,7 @@ curves Smooth 매끄럽게
Edit Selected Guides 선택한 안내선 편집
Clear Selected Guides 선택한 안내선 지우기
Make Selection 선택 영역 만들기
Make Shape 모양 만들기
Bicubic Automatic 쌍입방 자동
Bicubic Smoother 쌍입방 더 부드럽게
Bicubic Sharper 쌍입방 더 선명하게
Can't render this file because it contains an unexpected character in line 63 and column 53.
+2 -1
View File
@@ -1214,6 +1214,7 @@
Path to Illustrator… Bane til Illustrator…
Make Work Path Opprett arbeidsbane
Make Selection Opprett utvalg
Make Shape Opprett form
Fill Path Fyll bane
Stroke Path Strek opp bane
Clipping Path Klippebane
@@ -2061,7 +2062,7 @@
Vertical Centers Loddrette sentre
Video Frames to Layers… Videorammer til lag…
WIA Support… WIA-støtte…
Working CMY Plates Arbeidsplate for CMY
Working CMY Plates Arbeidsplater for CMY
# tl! literals (third pass)
Absolute Absolutt
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -707,7 +707,7 @@
Working Magenta Plate Werkplaat magenta
Working Yellow Plate Werkplaat geel
Working Black Plate Werkplaat zwart
Working CMY Plates Werkplaat CMY
Working CMY Plates CMY-werkplaten
Legacy Macintosh RGB (Gamma 1.8) Verouderde Macintosh RGB (Gamma 1.8)
Internet Standard RGB (sRGB) Internetstandaard RGB (sRGB)
Monitor RGB Monitor-RGB
@@ -2323,6 +2323,7 @@
Punctuation Interpunctie
Make Work Path Werkpad maken
Make Selection Selectie maken
Make Shape Vorm maken
Fill Path Pad vullen
Stroke Path Pad omlijnen
Delete Path Pad verwijderen
Can't render this file because it contains an unexpected character in line 8 and column 34.
+3 -2
View File
@@ -752,7 +752,7 @@
Working Magenta Plate Robocza płyta magenty
Working Yellow Plate Robocza płyta żółci
Working Black Plate Robocza płyta czerni
Working CMY Plates Robocza płyta CMY
Working CMY Plates Robocze płyty CMY
Legacy Macintosh RGB (Gamma 1.8) Starszy Macintosh RGB (Gamma 1.8)
Internet Standard RGB (sRGB) Standardowy RGB dla Internetu (sRGB)
Monitor RGB RGB monitora
@@ -846,7 +846,7 @@
Consolidate All to Tabs Połącz wszystko w karty
Float in Window Odłącz do okna
Float All in Windows Odłącz wszystko do okien
New Window Nowe okno dla dokumentu
New Window Nowe okno
Workspace Obszar roboczy
Essentials Podstawowe
Graphic and Web Grafika i Internet
@@ -2525,6 +2525,7 @@
Path to Illustrator… Ścieżka do Illustratora…
Make Work Path Utwórz ścieżkę roboczą
Make Selection Utwórz zaznaczenie
Make Shape Utwórz kształt
Fill Path Wypełnij ścieżkę
Stroke Path Obrysuj ścieżkę
Clipping Path Ścieżka przycinająca
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -730,7 +730,7 @@
Working Magenta Plate Chapa magenta de trabalho
Working Yellow Plate Chapa amarela de trabalho
Working Black Plate Chapa preta de trabalho
Working CMY Plates Chapa CMY de trabalho
Working CMY Plates Chapas CMY de trabalho
Legacy Macintosh RGB (Gamma 1.8) RGB Macintosh legado (Gamma 1.8)
Internet Standard RGB (sRGB) RGB padrão da Internet (sRGB)
Monitor RGB RGB do monitor
@@ -2487,6 +2487,7 @@
Path to Illustrator… Demarcador para o Illustrator…
Make Work Path Criar demarcador de trabalho
Make Selection Fazer seleção
Make Shape Fazer forma
Fill Path Preencher demarcador
Stroke Path Traçar demarcador
Clipping Path Demarcador de corte
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -1346,6 +1346,7 @@
Path to Illustrator… Контур в Illustrator…
Make Work Path Создать рабочий контур
Make Selection Создать выделение
Make Shape Создать фигуру
Fill Path Залить контур
Stroke Path Обвести контур
Clipping Path Обтравочный контур
@@ -2159,7 +2160,7 @@
Vertical Centers Центры по вертикали
Video Frames to Layers… Видеокадры в слои…
WIA Support… Поддержка WIA…
Working CMY Plates Рабочая голубая форма
Working CMY Plates Рабочие формы CMY
Absolute Абсолютный
Action Операция
Adaptive Адаптивный
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -756,7 +756,7 @@
Working Magenta Plate Radna magenta ploča
Working Yellow Plate Radna žuta ploča
Working Black Plate Radna crna ploča
Working CMY Plates Radna CMY ploča
Working CMY Plates Radne CMY ploče
Legacy Macintosh RGB (Gamma 1.8) Stari Macintosh RGB (Gamma 1.8)
Internet Standard RGB (sRGB) Internet standard RGB (sRGB)
Monitor RGB RGB monitora
@@ -2509,6 +2509,7 @@
Path to Illustrator… Putanja u Illustrator…
Make Work Path Napravi radnu putanju
Make Selection Napravi selekciju
Make Shape Napravi oblik
Fill Path Popuni putanju
Stroke Path Ocrtaj putanju
Clipping Path Putanja isecanja
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -1212,6 +1212,7 @@
Path to Illustrator… Bana till Illustrator…
Make Work Path Skapa arbetsbana
Make Selection Skapa markering
Make Shape Skapa form
Fill Path Fyll bana
Stroke Path Linje på bana
Clipping Path Urklippsbana
@@ -2059,7 +2060,7 @@
Vertical Centers Lodräta mittpunkter
Video Frames to Layers… Videobildrutor till lager…
WIA Support… WIA-stöd…
Working CMY Plates CMY-arbetsplåt
Working CMY Plates CMY-arbetsplåtar
# tl! literals (third pass)
Absolute Absolut
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -8,6 +8,16 @@ const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered");
const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered");
const HI: fn() -> Lang = || Lang::from_code("hi").expect("hi registered");
#[test]
fn hindi_has_the_reveal_and_camera_raw_rows() {
assert_eq!(tr(HI(), "Reveal in Explorer"), "Explorer में दिखाएँ");
assert_eq!(tr(HI(), "Reveal in Finder"), "Finder में दिखाएँ");
assert_eq!(tr(HI(), "Camera Raw…"), "Camera Raw…");
let (rows, errors) = parse_entries(include_str!("hi.tsv"));
assert!(errors.is_empty(), "{errors:?}");
assert!(rows.iter().any(|(ctx, src, _)| ctx.is_empty() && src == "Camera Raw…"), "Camera Raw… is a catalog row, not a fallback");
}
#[test]
fn hindi_tags_resolve() {
for tag in ["hi", "hi-IN", "hi_IN", "hi_IN.UTF-8"] {
+2 -1
View File
@@ -672,7 +672,7 @@
Working Magenta Plate Çalışma Macenta Plakası
Working Yellow Plate Çalışma Sarı Plakası
Working Black Plate Çalışma Siyah Plakası
Working CMY Plates Çalışma CMY Plakası
Working CMY Plates Çalışma CMY Plakaları
Legacy Macintosh RGB (Gamma 1.8) Eski Macintosh RGB (Gamma 1.8)
Internet Standard RGB (sRGB) İnternet Standardı RGB (sRGB)
Monitor RGB Monitör RGB
@@ -2278,6 +2278,7 @@
Punctuation Noktalama işaretleri
Make Work Path Çalışma Yolu Oluştur
Make Selection Seçim Yap
Make Shape Şekil Yap
Fill Path Yolu Doldur
Stroke Path Kontur Yolu
Delete Path Yolu Sil
Can't render this file because it contains an unexpected character in line 8 and column 34.
+3 -2
View File
@@ -651,7 +651,7 @@
Match Location Узгодити розташування
Match Rotation Узгодити поворот
Match All Узгодити все
New Window Нове вікно для документа
New Window Нове вікно
Workspace Робочий простір
Extensions Розширення
Panels Панелі
@@ -1314,6 +1314,7 @@
Path to Illustrator… Контур до Illustrator…
Make Work Path Створити робочий контур
Make Selection Створити виділення
Make Shape Створити фігуру
Fill Path Залити контур
Stroke Path Обвести контур
Clipping Path Відсічний контур
@@ -2129,7 +2130,7 @@
Vertical Centers Вертикальні центри
Video Frames to Layers… Відеокадри в шари…
WIA Support… Підтримка WIA…
Working CMY Plates Робоча форма CMY
Working CMY Plates Робочі форми CMY
Absolute Абсолютний
Action Дія
Adaptive Адаптивний
Can't render this file because it contains an unexpected character in line 152 and column 53.
+2 -1
View File
@@ -823,7 +823,7 @@
Modifier Keys Phím bổ trợ
Motion Chuyển động
Navigator Điều hướng
New Window Cửa sổ mới cho tài liệu
New Window Cửa sổ mới
New Workspace… Không gian làm việc mới…
Next Theme Chủ đề tiếp theo
Options Thanh tùy chọn
@@ -1398,6 +1398,7 @@
✓ Color Indicates Selected Areas ✓ Màu chỉ vùng được chọn
Work Path Đường dẫn làm việc
Make Selection Tạo vùng chọn
Make Shape Tạo hình
Fill Path Tô đường dẫn
Stroke Path Đi nét đường dẫn
Save Path Lưu đường dẫn
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -1
View File
@@ -854,7 +854,7 @@
Consolidate All to Tabs 将所有窗口合并为选项卡
Float in Window 在窗口中浮动
Float All in Windows 使所有文档在窗口中浮动
New Window 为文档新建窗口
New Window 新建窗口
Workspace 工作区
Essentials 基本功能
Graphic and Web 图形与 Web
@@ -3039,6 +3039,7 @@ curves Smooth 平滑
Edit Selected Guides 编辑所选参考线
Clear Selected Guides 清除所选参考线
Make Selection 建立选区
Make Shape 建立形状
Bicubic Automatic 双三次(自动)
Bicubic Smoother 双三次(较平滑)
Bicubic Sharper 双三次(较锐利)
Can't render this file because it contains an unexpected character in line 63 and column 53.
+2 -1
View File
@@ -853,7 +853,7 @@
Consolidate All to Tabs 全部合併至標籤頁
Float in Window 在視窗中浮動
Float All in Windows 全部在視窗中浮動
New Window 為文件新增視窗
New Window 新增視窗
Workspace 工作區
Essentials 基本功能
Graphic and Web 繪圖和網頁
@@ -3000,6 +3000,7 @@ curves Smooth 平滑
Edit Selected Guides 編輯所選參考線
Clear Selected Guides 清除所選參考線
Make Selection 建立選取範圍
Make Shape 建立形狀
Bicubic Automatic 雙三次 (自動)
Bicubic Smoother 雙三次 (較平滑)
Bicubic Sharper 雙三次 (較銳利)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -2
View File
@@ -851,7 +851,7 @@ fn handle_escape_and_view_keys(app: &mut PhotocraftApp, ctx: &egui::Context) {
// and inline type returned above, transform, pen, lasso and crop consumed it, and these own
// theirs later in the frame: the palette, the canvas and layer menus, the brush picker,
// Rotate View, Adaptive Wide Angle, the Filter Gallery, the TIFF prompt, any open egui popup
// and the toolbar tool flyout. A canvas drag in progress (a marquee being drawn) keeps it too.
// and the toolbar tool and "…" flyouts. A canvas drag in progress (a marquee being drawn) keeps it too.
let esc_taken = app.drag.is_some()
|| app.ui.palette_open
|| app.ui.canvas_tool_menu.is_some()
@@ -863,7 +863,8 @@ fn handle_escape_and_view_keys(app: &mut PhotocraftApp, ctx: &egui::Context) {
|| app.smart_svg.modal_open()
|| app.distort.gallery.is_some()
|| ctx.any_popup_open()
|| ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::unique("tool-flyout")).is_some());
|| ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::unique("tool-flyout")).is_some())
|| crate::panels::toolbar::more_flyout_open(ctx);
// Both full screen modes (with and without the menu bar) return to Standard, as in Photoshop
// (#4116), through the menu command so the window and the Screen Mode check follow.
if !esc_taken && app.ui.view.screen_mode != "standard" && pressed(Key::Escape) {
+14
View File
@@ -1275,6 +1275,20 @@ fn escape_with_the_palette_open_does_not_deselect() {
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to the palette");
}
/// The toolbar's "…" flyout owns Esc (it closes there, `toolbar_more_slot_flyout_tests`), so the
/// selection stays.
#[test]
fn escape_with_the_toolbar_more_flyout_open_does_not_deselect() {
let mut h = harness();
h.state_mut().run("select.all", json!({})).unwrap();
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("toolbar-more-flyout-open"), true));
h.run_steps(1);
assert!(crate::panels::toolbar::more_flyout_open(&h.ctx));
h.key_press(Key::Escape);
h.run_steps(2);
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to the flyout");
}
/// Targa Options owns Esc (it cancels the save), so the selection stays.
#[test]
fn escape_with_targa_options_open_does_not_deselect() {
@@ -122,9 +122,51 @@ fn each_size_pick_sets_the_panel_option_and_saves_it() {
}
}
/// Picking the size that is already checked closes the menu and leaves the saved options as they
/// were. The harness cannot observe a skipped write directly, so this asserts the saved value is
/// unchanged.
#[test]
fn picking_the_checked_size_closes_the_menu_and_changes_nothing() {
let mut h = panel(true);
h.state_mut().ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large;
h.run_steps(2);
crate::panels::layers_panel_ui::persist_options(h.state_mut());
let saved = |h: &Harness<'static, PhotocraftApp>| h.state().session.prefs().dialogs.get("ui.layersPanelOptions").cloned();
let before = saved(&h);
assert!(before.is_some(), "an entry is saved to begin with");
open_menu(&mut h);
assert!(checked(&h, "Large Thumbnails"));
pick(&mut h, "Large Thumbnails");
assert!(!menu_open(&h), "the menu closes");
assert_eq!(size(&h), LayerThumbnailSize::Large);
assert_eq!(saved(&h), before, "the saved options are unchanged");
}
/// The saved entry can be missing (a preferences reset, an older file) while the panel still
/// shows the size; picking the checked row writes it back.
#[test]
fn picking_the_checked_size_restores_a_missing_saved_entry() {
let mut h = panel(true);
h.state_mut().ui.layers_panel_options.thumbnail_size = LayerThumbnailSize::Large;
h.run_steps(2);
h.state_mut().session.prefs.edit(|p| {
p.dialogs.remove("ui.layersPanelOptions");
});
let saved = |h: &Harness<'static, PhotocraftApp>| h.state().session.prefs().dialogs.get("ui.layersPanelOptions").cloned();
assert!(saved(&h).is_none(), "the saved entry is gone");
open_menu(&mut h);
assert!(checked(&h, "Large Thumbnails"));
pick(&mut h, "Large Thumbnails");
assert!(!menu_open(&h), "the menu closes");
let restored = saved(&h).expect("the pick writes the entry back");
assert_eq!(restored, serde_json::to_value(&h.state().ui.layers_panel_options).unwrap());
}
#[test]
fn each_clip_pick_sets_the_preference() {
let mut h = panel(true);
h.state_mut().run("prefs.set", json!({"path": "interface.clipThumbnailsToLayerBounds", "value": false})).unwrap();
h.run_steps(2);
assert!(!clip(&h));
open_menu(&mut h);
pick(&mut h, "Clip Thumbnails to Layer Bounds");
@@ -155,8 +197,12 @@ fn a_left_click_on_the_empty_area_still_deselects_and_a_right_click_does_not() {
#[test]
fn with_no_document_nothing_opens() {
// The Layers panel is not drawn without a document, so take its rect from the same layout
// with one open.
let rect = crate::panel_docking::probe::panel_rect(&panel(true).ctx, "layers").expect("the Layers panel is drawn");
let mut h = panel(false);
let p = pos2(1100.0, 400.0);
let p = rect.center();
assert!(rect.contains(p));
click(&mut h, p, PointerButton::Secondary);
assert!(!menu_open(&h));
}
+2
View File
@@ -812,6 +812,8 @@ mod tool_shift_cycle_order_tests;
mod tool_shortcut_tests;
#[cfg(test)]
mod toolbar_group_order_tests;
#[cfg(test)]
mod toolbar_more_slot_flyout_tests;
#[cfg(test)]
mod pixel_aspect_tests;
@@ -257,3 +257,48 @@ fn shift_click_adds_shape_layers_one_after_another() {
selected.sort_by_key(|l| l.0);
assert_eq!(selected, ids);
}
/// Opens B's transform controls (Show Transform Controls on) and resizes the box to twice B's
/// size from its top-left corner, so it covers (40..72)².
fn resized_box() -> (PhotocraftApp, LayerId, LayerId) {
let (mut app, a, b) = app();
app.ui.tool_options.move_show_transform = true;
crate::transform_tool::begin(&mut app, &egui::Context::default()).unwrap();
let t = app.ui.transform.as_mut().unwrap();
t.quad[2] = [t.quad[2][0] + 16.0, t.quad[2][1] + 16.0];
t.quad[1][0] += 16.0;
t.quad[3][1] += 16.0;
(app, a, b)
}
/// A plain click (press and release without moving) at document point `p`.
fn click(app: &mut PhotocraftApp, p: [f64; 2]) {
crate::canvas::tool_event(app, ToolEvent::Down { x: p[0], y: p[1], pressure: 1.0 }, egui::Modifiers::NONE);
crate::canvas::tool_event(app, ToolEvent::Up { x: p[0], y: p[1] }, egui::Modifiers::NONE);
}
/// #8208, #11100, as measured in the reference application: a click outside a resized box on
/// another layer's pixels commits the resize as one Free Transform step and keeps the transformed
/// layer selected; the second click on that layer is the one that picks it.
#[test]
fn a_click_on_another_layer_accepts_a_resized_box_and_the_next_one_picks_it() {
let (mut app, a, b) = resized_box();
click(&mut app, [16.0, 16.0]);
assert!(app.ui.transform.is_none(), "the click on A accepted the box");
assert_eq!(bounds(&app, b), Rect::new(40, 40, 72, 72), "B keeps its resize");
let last = app.session.active().unwrap().history.entries().last().map(ToString::to_string);
assert_eq!(last.as_deref(), Some("Free Transform"));
assert_eq!(active(&app), Some(b), "the accepting click leaves B selected");
click(&mut app, [16.0, 16.0]);
assert_eq!(active(&app), Some(a), "the second click picks A");
}
/// #11100: a click outside the box where only the Background has pixels keeps it open, B still
/// selected.
#[test]
fn a_click_on_the_background_alone_keeps_the_resized_box_open() {
let (mut app, _, b) = resized_box();
click(&mut app, [30.0, 30.0]);
assert!(app.ui.transform.is_some(), "the box stays open");
assert_eq!(active(&app), Some(b));
}
+28 -8
View File
@@ -3,8 +3,8 @@
//! picks one effect kind (#10880), Mode picks one blend mode, Attribute one layer attribute or
//! its opposite (#10881), Color one layer colour label (#5898), Smart Object toggles linked,
//! missing and embedded smart objects, Artboard lists only the artboards that hold the selected
//! layers. The criteria live in `UiState` (`layer_filter*`), so the control channel can read and
//! set them.
//! layers, Group every group with its contents (#10883). The criteria live in `UiState`
//! (`layer_filter*`), so the control channel can read and set them.
use std::collections::HashSet;
@@ -28,6 +28,7 @@ pub enum FilterBy {
Color,
SmartObject,
Artboard,
Group,
}
/// The filter criteria, copied out of `UiState` for one frame of the Layers panel.
@@ -46,7 +47,8 @@ pub struct Filter {
/// The linked files the Smart Object filter's Missing toggle lists
/// ([`photocraft_engine::Session::missing_links`]).
pub missing: HashSet<String>,
/// The rows the Artboard filter lists ([`board_rows`]); empty lists every row.
/// The rows the Artboard filter ([`board_rows`]) or the Group filter ([`group::listed`])
/// lists; empty lists every row under Artboard and none under Group.
pub board_rows: HashSet<LayerId>,
/// The empty layers the Attribute filter's Empty and Not Empty use
/// ([`attribute::empty_layers`]); `None` reads each layer's pixels.
@@ -83,6 +85,9 @@ impl Filter {
if f.by == FilterBy::Artboard {
f.board_rows = board_rows(doc, selection);
}
if f.by == FilterBy::Group {
f.board_rows = group::listed(doc);
}
if f.by == FilterBy::Attribute {
f.empty = attribute::empty_layers(app, f.attribute);
}
@@ -99,7 +104,7 @@ impl Filter {
}
/// Whether the panel is filtered. Like Photoshop, Effect, Mode, Attribute and Color always
/// filter (by Bevel & Emboss, by Normal, by Visible and by no colour at first); Kind, Name and
/// filter (by Bevel & Emboss, by Normal, by Visible and by no colour at first), and so does Group; Kind, Name and
/// Smart Object only once a toggle is on or some text is typed; Artboard only while some layers
/// are selected and each of them is in an artboard.
pub fn active(&self) -> bool {
@@ -109,9 +114,16 @@ impl Filter {
FilterBy::Effect | FilterBy::Mode | FilterBy::Attribute | FilterBy::Color => true,
FilterBy::SmartObject => !self.smart.is_empty(),
FilterBy::Artboard => !self.board_rows.is_empty(),
FilterBy::Group => true,
}
}
/// Whether the panel lists the contents of closed groups: while it is filtered, except by
/// Group, which lists whole groups and keeps each one open or closed as it is (#10883).
pub fn show_all(&self) -> bool {
self.active() && self.by != FilterBy::Group
}
/// Whether `l` is listed under this filter.
pub fn shows(&self, l: &Layer) -> bool {
if !self.active() {
@@ -133,6 +145,7 @@ impl Filter {
})
}
FilterBy::Artboard => self.board_rows.contains(&l.id),
FilterBy::Group => self.board_rows.contains(&l.id),
}
}
}
@@ -180,12 +193,12 @@ pub const SMART: [&str; 3] = ["linked", "missing", "embedded"];
/// `ui.set {layerFilter: {by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}}`:
/// `current` with the given fields replaced, or an error naming the bad field (nothing changes
/// then). `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject` or
/// `artboard`; `kinds` a list of [`KINDS`]; `effect` an [`effect::EffectKind`] id; `mode` one of
/// then). `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject`,
/// `artboard` or `group`; `kinds` a list of [`KINDS`]; `effect` an [`effect::EffectKind`] id; `mode` one of
/// the layer blend modes such as `Multiply` (not Pass Through, which the Mode list doesn't offer,
/// #10878); `attribute` an [`attribute::Attribute`] id; `color` a label colour id (`none`, `red`,
/// … `gray`, [`LabelColor::id`]); `smart` a list of [`SMART`]. Artboard has no criteria of its
/// own: the selected layers pick the artboards.
/// own: the selected layers pick the artboards; nor has Group.
pub fn patched(current: &UiState, v: &serde_json::Value) -> Result<Filter, String> {
let Some(o) = v.as_object() else { return Err("layerFilter must be an object".into()) };
if let Some(k) = o.keys().find(|k| !["by", "kinds", "name", "effect", "mode", "attribute", "color", "smart"].contains(&k.as_str())) {
@@ -194,7 +207,7 @@ pub fn patched(current: &UiState, v: &serde_json::Value) -> Result<Filter, Strin
let mut f = Filter::of(current);
if let Some(by) = o.get("by") {
f.by = serde_json::from_value(by.clone())
.map_err(|_| "layerFilter.by must be kind, name, effect, mode, attribute, color, smartObject or artboard".to_string())?;
.map_err(|_| "layerFilter.by must be kind, name, effect, mode, attribute, color, smartObject, artboard or group".to_string())?;
}
if let Some(kinds) = o.get("kinds") {
let list = kinds.as_array().ok_or("layerFilter.kinds must be a list")?;
@@ -287,6 +300,7 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
(FilterBy::Color, tl!("Color")),
(FilterBy::SmartObject, tl!("Smart Object")),
(FilterBy::Artboard, tl!("Artboard")),
(FilterBy::Group, tl!("Group")),
];
widgets::dropdown(ui, "layer-filter-by", tl!("Kind"), &mut app.ui.layer_filter_by, &by, 64.0);
ui.add_space(4.0);
@@ -347,12 +361,15 @@ pub fn bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
// Artboard has no controls: the selected layers pick the artboards ([`board_rows`]).
FilterBy::Artboard => {}
// Group has no controls either: it lists every group ([`group::listed`]).
FilterBy::Group => {}
}
});
selection::follow(app, ui.ctx());
}
pub mod effect;
mod group;
pub mod selection;
#[cfg(test)]
@@ -369,3 +386,6 @@ mod tests;
#[cfg(test)]
mod kind_mode_tests;
#[cfg(test)]
mod group_tests;
@@ -0,0 +1,22 @@
//! The Layers panel's Group filter (#10883): every group with its whole subtree; layers in no
//! group are hidden. An artboard row is not a group here, but the groups inside it are.
use std::collections::HashSet;
use photocraft_doc::{Document, Layer, LayerContent, LayerId};
/// The ids of the layers the Group filter lists. The tree is walked once, with a stack rather
/// than recursion, so a deep document can't overflow it.
pub fn listed(doc: &Document) -> HashSet<LayerId> {
let mut out = HashSet::new();
let mut pending: Vec<(&Layer, bool)> = doc.layers.iter().map(|l| (l, false)).collect();
while let Some((l, inside)) = pending.pop() {
let artboard = matches!(&l.content, LayerContent::Group(g) if g.artboard.is_some());
let inside = inside || (l.is_group() && !artboard);
if inside {
out.insert(l.id);
}
pending.extend(l.children().unwrap_or_default().iter().map(|c| (c, inside)));
}
out
}
@@ -0,0 +1,144 @@
//! #10883: the Layers panel's Group filter.
use photocraft_engine::Session;
use serde_json::json;
use super::{Filter, FilterBy};
use crate::PhotocraftApp;
use crate::state::UiState;
fn new_session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
s
}
/// The app with `s` open and the Layers panel filtered by Group.
fn app(s: Session) -> PhotocraftApp {
let mut app = PhotocraftApp::new(s, crate::Services::default());
app.ui.layer_filter_by = FilterBy::Group;
app
}
fn id(s: &Session, name: &str) -> u64 {
s.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.name == name).map(|(_, _, l)| l.id.0).unwrap_or_else(|| panic!("no layer {name}"))
}
fn select(s: &mut Session, name: &str) {
let layer = id(s, name);
s.execute("layer.select", json!({"layer": layer})).unwrap();
}
/// A new layer above the one selected (inside its group, if it is in one).
fn add(s: &mut Session, name: &str) {
s.execute("layer.new.layer", json!({"name": name})).unwrap();
}
/// Groups the layer `name`.
fn group(s: &mut Session, name: &str, group_name: &str) {
select(s, name);
s.execute("layer.groupLayers", json!({"name": group_name})).unwrap();
}
fn set_expanded(s: &mut Session, name: &str, expanded: bool) {
let layer = id(s, name);
s.execute("layer.setExpanded", json!({"layer": layer, "expanded": expanded})).unwrap();
}
/// The rows the panel draws, top to bottom, two spaces per level: the rows and the filter the
/// Layers panel uses for the frame.
fn listed(app: &PhotocraftApp) -> Vec<String> {
let doc = &app.session.active().unwrap().doc;
let f = Filter::of_app(app);
crate::layer_tree_ui::panel_rows(app, doc).into_iter().filter(|(_, l)| f.shows(l)).map(|(depth, l)| format!("{}{}", " ".repeat(depth), l.name)).collect()
}
/// The selected layers' names.
fn selected(app: &PhotocraftApp) -> Vec<String> {
let st = app.session.active().unwrap();
st.selected_layers().iter().filter_map(|id| st.doc.layer(*id)).map(|l| l.name.clone()).collect()
}
/// Top Pixel · Outer { Inner { Deep } } · Background.
fn nested() -> Session {
let mut s = new_session();
add(&mut s, "Top Pixel");
add(&mut s, "Deep");
group(&mut s, "Deep", "Inner");
group(&mut s, "Inner", "Outer");
s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap();
s
}
#[test]
fn lists_nested_groups_with_their_contents_and_hides_loose_layers() {
let app = app(nested());
assert!(Filter::of_app(&app).active(), "Group filters at once");
assert_eq!(listed(&app), ["Outer", " Inner", " Deep"]);
}
#[test]
fn collapsed_groups_stay_collapsed() {
let mut app = app(nested());
set_expanded(&mut app.session, "Inner", false);
assert_eq!(listed(&app), ["Outer", " Inner"], "a collapsed nested group hides its children");
set_expanded(&mut app.session, "Outer", false);
assert_eq!(listed(&app), ["Outer"], "a collapsed group hides everything inside");
set_expanded(&mut app.session, "Outer", true);
assert_eq!(listed(&app), ["Outer", " Inner"], "each group keeps its own state");
}
#[test]
fn an_artboard_is_not_listed_but_its_groups_are() {
let mut s = new_session();
add(&mut s, "G child");
group(&mut s, "G child", "Plain G");
select(&mut s, "Background");
add(&mut s, "A child");
s.execute("layer.new.artboardFromLayers", json!({"name": "Artboard 1"})).unwrap();
select(&mut s, "A child");
add(&mut s, "GA child");
group(&mut s, "GA child", "G in AB");
s.execute("layer.setExpanded", json!({"all": true, "expanded": true})).unwrap();
let mut app = app(s);
assert_eq!(listed(&app), [" G in AB", " GA child", "Plain G", " G child"]);
set_expanded(&mut app.session, "Artboard 1", false);
assert_eq!(listed(&app), ["Plain G", " G child"], "a collapsed artboard hides the groups inside it");
}
#[test]
fn the_list_does_not_depend_on_the_selection() {
let mut app = app(nested());
select(&mut app.session, "Deep");
let with_deep = listed(&app);
app.session.execute("select.deselectLayers", json!({})).unwrap();
assert_eq!(listed(&app), with_deep, "same list with nothing selected");
}
#[test]
fn applying_it_keeps_a_selected_layer_in_a_group_and_deselects_a_loose_one() {
let mut app = app(nested());
select(&mut app.session, "Deep");
super::selection::deselect_hidden(&mut app);
assert_eq!(selected(&app), ["Deep"], "a listed layer stays selected");
select(&mut app.session, "Top Pixel");
super::selection::deselect_hidden(&mut app);
assert!(selected(&app).is_empty(), "a layer in no group is hidden, so it is deselected");
}
#[test]
fn group_is_set_by_name_through_the_control_channel() {
let ui = UiState::default();
let f = super::patched(&ui, &json!({"by": "group"})).unwrap();
assert_eq!(f.by, FilterBy::Group);
let err = super::patched(&ui, &json!({"by": "nope"})).unwrap_err();
assert!(err.contains("group"), "the error lists group: {err}");
}
#[test]
fn lists_empty_groups_and_nothing_without_groups() {
let mut app = app(new_session());
assert!(listed(&app).is_empty(), "no group: nothing is listed");
app.session.execute("layer.new.group", json!({"name": "Empty Group"})).unwrap();
assert_eq!(listed(&app), ["Empty Group"], "an empty group is listed as a group row");
}
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn display_rows(doc: &Document, show_all: bool) -> Vec<(usize, &Layer)> {
/// Mask session's working text, which is no layer to the user: Photoshop shows only the mask
/// overlay while you type (#3036).
pub fn panel_rows<'a>(app: &PhotocraftApp, doc: &'a Document) -> Vec<(usize, &'a Layer)> {
let mut rows = display_rows(doc, crate::layer_filter_ui::Filter::of(&app.ui).active());
let mut rows = display_rows(doc, crate::layer_filter_ui::Filter::of(&app.ui).show_all());
if let Some(working) = app.ui.text_edit.as_ref().filter(|ed| ed.mask).map(|ed| ed.layer) {
rows.retain(|(_, l)| l.id.0 != working);
}
+5 -1
View File
@@ -9,9 +9,13 @@ use crate::state::{LayerThumbnailContents, LayerThumbnailSize, LayersPanelDialog
const OPTIONS_PREF: &str = "ui.layersPanelOptions";
/// Save independently of Workspace > Remember Workspace Changes, as Photoshop does.
/// Save independently of Workspace > Remember Workspace Changes, as Photoshop does. A pick of the
/// current value writes nothing unless the saved entry is missing or stale.
pub fn persist_options(app: &mut PhotocraftApp) {
if let Ok(value) = serde_json::to_value(&app.ui.layers_panel_options) {
if app.session.prefs().dialogs.get(OPTIONS_PREF) == Some(&value) {
return;
}
app.session.prefs.edit(|p| {
p.dialogs.insert(OPTIONS_PREF.into(), value);
});
+64 -2
View File
@@ -93,6 +93,63 @@ pub(super) fn tool_flyout_release(row: Rect, pointer: Option<Pos2>, released: bo
released && from_button && pointer.is_some_and(|p| row.contains(p))
}
fn more_flyout_id() -> egui::Id {
egui::Id::unique("toolbar-more-flyout-open")
}
/// Whether the "…" button's flyout is open: it owns Escape then (`shortcuts::handle`).
pub(crate) fn more_flyout_open(ctx: &egui::Context) -> bool {
ctx.data(|d| d.get_temp::<bool>(more_flyout_id())).unwrap_or(false)
}
/// Right-click on the "…" button opens a flyout with one row, "Edit Toolbar…", drawn like a tool
/// flyout. The row runs `edit.toolbar` and closes the flyout; Escape or a press outside closes it
/// without running anything.
fn more_slot_flyout(app: &mut PhotocraftApp, ui: &mut egui::Ui, button: &egui::Response, t: &Tokens) {
let open_id = more_flyout_id();
let open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
if button.secondary_clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
ui.ctx().request_repaint();
return;
}
if !open {
return;
}
let (pivot, pos) = if crate::direction::is_rtl() {
(egui::Align2::RIGHT_BOTTOM, button.rect.left_bottom() - vec2(6.0, 0.0))
} else {
(egui::Align2::LEFT_BOTTOM, button.rect.right_bottom() + vec2(6.0, 0.0))
};
let area = egui::Area::new(egui::Id::unique("toolbar-more-flyout")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
let label = tl!("Edit Toolbar…");
let label_w = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(12.5), t.text).size().x;
let (r, row) = ui.allocate_exact_size(vec2((label_w + 24.0).max(180.0), 26.0), Sense::click());
row.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
if row.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
ui.painter().text(pos2(r.left() + 12.0, r.center().y), Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text);
row.clicked()
})
});
if area.inner.inner {
ui.data_mut(|d| d.remove::<bool>(open_id));
let ctx = ui.ctx().clone();
if let Err(e) = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({})) {
app.ui.status = e;
app.ui.status_error = true;
}
return;
}
let pressed_outside =
ui.input(|i| i.pointer.any_pressed() && i.pointer.press_origin().is_some_and(|p| !area.response.rect.contains(p) && !button.rect.contains(p)));
if pressed_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.remove::<bool>(open_id));
}
}
pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let (w1, bx, m) = if t.pro { (40.0, 30.0, 5i8) } else { (50.0, 36.0, 7i8) };
@@ -351,9 +408,14 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
});
}
}
if icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…")).clicked() {
let _ = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({}));
let more = icons::button(ui, "ellipsis", bx, false, tl!("Edit Toolbar…"));
if more.clicked()
&& let Err(e) = crate::menus::invoke(app, &ctx, "edit.toolbar", json!({}))
{
app.ui.status = e;
app.ui.status_error = true;
}
more_slot_flyout(app, ui, &more, &t);
reveal_focused_control(ui);
});
ui.add_space(if t.pro { 8.0 } else { 14.0 });
+4
View File
@@ -96,6 +96,9 @@ pub struct ToolOptions {
/// draws it as "combine"), "combine", "subtract", "intersect" or "exclude".
#[serde(default = "default_pen_path_op")]
pub pen_path_op: String,
/// Shape tools: "shape" (a shape layer) or "path" (the work path), as Photoshop's tool mode
/// (#9453). Shape by default, where the Pen's own mode defaults to Path.
pub shape_mode: String,
/// Shape tools: fill with the foreground colour, stroke width (0 = none), rectangle corner
/// radius, polygon sides, line weight.
pub shape_fill: bool,
@@ -434,6 +437,7 @@ impl Default for ToolOptions {
enhance_edge: false,
vector_mode: "path".into(),
pen_path_op: default_pen_path_op(),
shape_mode: "shape".into(),
shape_fill: true,
stroke_width: 0.0,
shape_stroke: Default::default(),
@@ -0,0 +1,125 @@
//! Right-click on the toolbar's "…" button opens a one-row flyout, "Edit Toolbar…"; the row runs
//! `edit.toolbar`, Escape or a press outside closes it, and a left click still opens Edit Toolbar.
use egui::{Event, Pos2, Rect, vec2};
use crate::PhotocraftApp;
use crate::panels::toolbar::{TOOL_SECTIONS, toolbar};
use crate::theme::Tokens;
fn frame(app: &mut PhotocraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>) {
let mut out = ctx.run_ui(
egui::RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() },
|ui| toolbar(app, ui),
);
out.textures_delta.clear();
}
fn setup() -> (PhotocraftApp, egui::Context, Pos2) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]);
frame(&mut app, &ctx, 0.0, vec![]);
frame(&mut app, &ctx, 0.1, vec![]);
let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 };
let mut buttons: Vec<Rect> = ctx.viewport(|v| {
v.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(bx, bx) && w.sense.senses_click()).map(|w| w.rect).collect()
});
buttons.sort_by(|a, b| a.top().total_cmp(&b.top()));
// The "…" button sits right below the last tool slot.
let slots = TOOL_SECTIONS.iter().map(|s| s.len()).sum::<usize>();
let at = buttons[slots].center();
(app, ctx, at)
}
fn button(pos: Pos2, which: egui::PointerButton, pressed: bool) -> Event {
Event::PointerButton { pos, button: which, pressed, modifiers: Default::default() }
}
fn right_click(app: &mut PhotocraftApp, ctx: &egui::Context, at: Pos2, t: f64) {
frame(app, ctx, t, vec![Event::PointerMoved(at), button(at, egui::PointerButton::Secondary, true)]);
frame(app, ctx, t + 0.05, vec![button(at, egui::PointerButton::Secondary, false)]);
frame(app, ctx, t + 0.1, vec![]);
}
fn open(ctx: &egui::Context) -> bool {
ctx.data(|d| d.get_temp::<bool>(egui::Id::unique("toolbar-more-flyout-open"))).unwrap_or(false)
}
fn flyout(ctx: &egui::Context) -> Option<Rect> {
ctx.memory(|m| m.area_rect(egui::Id::unique("toolbar-more-flyout")))
}
#[test]
fn right_click_on_more_opens_a_flyout_with_one_row() {
let (mut app, ctx, at) = setup();
assert!(!open(&ctx));
right_click(&mut app, &ctx, at, 1.0);
assert!(open(&ctx));
let rect = flyout(&ctx).expect("the flyout is drawn");
assert!(rect.height() < 50.0, "one 26 point row, not a list: {rect:?}");
assert!(app.ui.dialogs.is_empty(), "opening the flyout runs nothing");
}
#[test]
fn choosing_edit_toolbar_runs_it_and_closes_the_flyout() {
let (mut app, ctx, at) = setup();
right_click(&mut app, &ctx, at, 1.0);
let row = flyout(&ctx).unwrap().center();
frame(&mut app, &ctx, 1.95, vec![Event::PointerMoved(row)]);
frame(&mut app, &ctx, 2.0, vec![button(row, egui::PointerButton::Primary, true)]);
frame(&mut app, &ctx, 2.05, vec![]);
frame(&mut app, &ctx, 2.1, vec![button(row, egui::PointerButton::Primary, false)]);
frame(&mut app, &ctx, 2.15, vec![]);
assert_eq!(app.ui.dialogs.len(), 1, "edit.toolbar opened its dialog");
assert!(!open(&ctx));
}
#[test]
fn escape_closes_the_flyout_without_running_anything() {
let (mut app, ctx, at) = setup();
right_click(&mut app, &ctx, at, 1.0);
let key = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
frame(&mut app, &ctx, 2.0, vec![key]);
assert!(!open(&ctx));
assert!(app.ui.dialogs.is_empty());
}
#[test]
fn a_press_outside_closes_the_flyout_without_running_anything() {
let (mut app, ctx, at) = setup();
right_click(&mut app, &ctx, at, 1.0);
let away = Pos2::new(900.0, 900.0);
frame(&mut app, &ctx, 2.0, vec![Event::PointerMoved(away), button(away, egui::PointerButton::Primary, true)]);
frame(&mut app, &ctx, 2.05, vec![button(away, egui::PointerButton::Primary, false)]);
assert!(!open(&ctx));
assert!(app.ui.dialogs.is_empty());
}
#[test]
fn a_left_click_still_opens_edit_toolbar_directly() {
let (mut app, ctx, at) = setup();
frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), button(at, egui::PointerButton::Primary, true)]);
frame(&mut app, &ctx, 1.05, vec![button(at, egui::PointerButton::Primary, false)]);
frame(&mut app, &ctx, 1.1, vec![]);
assert_eq!(app.ui.dialogs.len(), 1);
assert!(!open(&ctx), "no flyout for a left click");
}
#[test]
fn a_failed_edit_toolbar_from_the_flyout_reports_its_error_in_the_status_bar() {
let (mut app, ctx, at) = setup();
app.automation_input = true;
app.services.automation_command = Some(Box::new(|id, _| if id == "edit.toolbar" { Err("toolbar edits denied".into()) } else { Ok(()) }));
right_click(&mut app, &ctx, at, 1.0);
let row = flyout(&ctx).unwrap().center();
frame(&mut app, &ctx, 1.95, vec![Event::PointerMoved(row)]);
frame(&mut app, &ctx, 2.0, vec![button(row, egui::PointerButton::Primary, true)]);
frame(&mut app, &ctx, 2.05, vec![]);
frame(&mut app, &ctx, 2.1, vec![button(row, egui::PointerButton::Primary, false)]);
frame(&mut app, &ctx, 2.15, vec![]);
assert!(app.ui.dialogs.is_empty(), "the denied command opened nothing");
assert!(!open(&ctx));
assert_eq!(app.ui.status, "toolbar edits denied");
assert!(app.ui.status_error);
}
+20 -2
View File
@@ -202,7 +202,25 @@ pub fn abandon(app: &mut PhotocraftApp) {
}
}
/// Arrow keys with the Move tool (or while Free Transform is active): nudge 1 px, ⇧ 10 px;
/// The document pixels one plain arrow press moves layers by with the Move tool: one screen
/// pixel, as in Photoshop (#9511). Zoomed out that is several document pixels (2 at 50 %, 4 at
/// 25 %; 1 at 66.7 %: whole pixels only), zoomed in still one. No cap: the zoom bottoms out
/// with the document's shorter side 2 px on screen (`zoom_levels::min`), so a step is at most
/// half that side. ⇧'s 10 px don't scale. Free Transform keeps 1 px.
fn screen_pixel_step(app: &PhotocraftApp) -> f64 {
let zoom = f64::from(app.current_zoom());
if app.ui.transform.is_some() || !zoom.is_finite() || zoom <= 0.0 {
return 1.0;
}
// A ladder rung is a hair off in f32 (0.1 % is 1 / 999.99995): snap a reciprocal within f32
// precision of a whole number to it, and floor any other zoom (0.3333334 is 2, not 3).
let (r, whole) = (1.0 / zoom, (1.0 / zoom).round());
let steps = if (r - whole).abs() <= r * f64::from(f32::EPSILON) { whole } else { r.floor() };
steps.max(1.0)
}
/// Arrow keys with the Move tool (or while Free Transform is active): nudge one screen pixel
/// (1 px at 100 %, [`screen_pixel_step`]), ⇧ 10 px;
/// ⌥ duplicates the layers first. ⌘-arrows (Ctrl off the Mac) do the same from most other tools,
/// where ⌘ is the temporary Move tool (`hold_keys::cmd_nudges`, #2474). Returns true when a key
/// was used.
@@ -222,7 +240,7 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
// Most specific first: `consume_key` ignores extra ⇧ / ⌥ (`matches_logically`).
for mods in [Modifiers::SHIFT | Modifiers::ALT, Modifiers::SHIFT, Modifiers::ALT, Modifiers::NONE] {
if ctx.input_mut(|i| i.consume_key(base | mods, key)) {
let k = if mods.shift { 10.0 } else { 1.0 };
let k = if mods.shift { 10.0 } else { screen_pixel_step(app) };
app.nudge_bundle.now = ctx.input(|i| i.time);
nudge(app, ux * k, uy * k, mods.alt);
return true;
@@ -335,6 +335,28 @@ fn a_run_of_arrow_nudges_is_one_history_step() {
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(20, 18, 36, 34), "and one Redo does it again");
}
/// #9511: zoomed out, where each press moves several document pixels, a run of nudges is still
/// one History state.
#[test]
fn a_run_of_arrow_nudges_is_one_history_step_zoomed_out() {
let mut app = app_with_layer();
let id = app.session.active().unwrap().active_layer.unwrap();
app.ui.views[0].zoom = 0.5;
let h0 = history(&app);
// Twelve taps of 2 px each at 50 %, then ⇧ for ten more px: still the same run.
for i in 0..12 {
assert!(press_at(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE, 5.0 + 0.2 * f64::from(i)));
}
assert!(press_at(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT, 7.6));
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(32, 18, 48, 34));
assert_eq!(history(&app), h0 + 1, "thirteen presses, one state");
assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Nudge"));
assert!(app.session.undo());
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24), "one Undo takes back the run");
assert!(app.session.redo());
assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(32, 18, 48, 34), "and one Redo does it again");
}
/// A pause longer than Nudge Bundle Pause starts a new run; a longer pause setting keeps it.
#[test]
fn a_pause_ends_the_run() {
@@ -500,6 +522,19 @@ fn free_transform_nudges_bundle_into_one_undo_step() {
assert_eq!(quad(&app), moved(2.0, 0.0));
}
/// #9511: Free Transform keeps Photoshop's 1 document pixel per arrow press at any zoom; only
/// the Move tool's nudge is one screen pixel.
#[test]
fn free_transform_nudges_one_document_pixel_zoomed_out() {
let mut app = app_with_layer();
app.ui.views[0].zoom = 0.5;
let ctx = egui::Context::default();
crate::transform_tool::begin(&mut app, &ctx).unwrap();
let q0 = app.ui.transform.as_ref().unwrap().quad;
assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE));
assert_eq!(app.ui.transform.as_ref().unwrap().quad, q0.map(|q| [q[0] + 1.0, q[1]]), "→ at 50 % moves the box 1 px");
}
/// Arrow keys while editing text must not nudge layers, leaving the keys to the type tool (#5355).
#[test]
fn arrow_keys_ignored_during_text_edit() {
@@ -523,3 +558,37 @@ fn arrow_keys_ignored_during_text_edit() {
assert!(!press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::ALT));
assert_eq!(bounds(&app, id), b0, "arrow keys do not nudge while editing text");
}
/// #9511: as in Photoshop, a plain arrow nudge is one screen pixel, so zoomed out it moves the
/// layer several document pixels (floor of 1 / zoom: 4 at 25 %, 2 at 50 %, 1 at 66.7 %, 1 at
/// 100 % and above, up to 1600 %); ⇧ is 10 document pixels at any zoom.
#[test]
fn a_nudge_is_one_screen_pixel() {
for (zoom, step) in [(0.25, 4), (0.5, 2), (2.0 / 3.0, 1), (1.0, 1), (2.0, 1), (16.0, 1)] {
let mut app = app_with_layer();
let id = app.session.active().unwrap().active_layer.unwrap();
app.ui.views[0].zoom = zoom;
assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE));
assert_eq!(left_edge(&app, id), 8 + step, "→ at {zoom}");
assert!(press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::SHIFT));
assert_eq!(left_edge(&app, id), 8 + step - 10, "⇧← at {zoom}");
}
}
/// #9511: below 0.1 % (a large document zooms out until its shorter side is 2 px) the nudge is
/// still one screen pixel: 6000 document pixels at a 12,000 × 12,000 px document's lowest zoom,
/// not capped at 1000. The ladder's f32 rungs (0.1 %, 0.2 %) still give whole steps (1000,
/// not 999).
#[test]
fn a_nudge_is_one_screen_pixel_below_a_tenth_of_a_percent() {
let lowest = crate::zoom_levels::min([12_000, 12_000]);
// 0.3333334 is just above a third, off the ladder (pinch, Zoom tool drag): 2.9999994 screen
// pixels' worth floors to 2, while the ladder's own f32 third still gives 3 (#10438 review).
for (zoom, step) in [(0.002, 500), (0.001, 1000), (0.0005, 2000), (lowest, 6000), (0.333_333_4, 2), (1.0 / 3.0, 3)] {
let mut app = app_with_layer();
let id = app.session.active().unwrap().active_layer.unwrap();
app.ui.views[0].zoom = zoom;
assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE));
assert_eq!(left_edge(&app, id), 8 + step, "→ at {zoom}");
}
}
+37 -31
View File
@@ -32,7 +32,9 @@ fn selected_shape(session: &photocraft_engine::Session) -> Option<SelectedShape>
}
}
pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) {
/// The Shape tools' options. `paint` shows Fill and Stroke: off in Path mode, where a drag draws
/// only an outline, but the tool's geometry (radius, sides, weight, custom shape) stays.
pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool, paint: bool) {
let t = Tokens::get(ui.ctx());
let lbl = |ui: &mut egui::Ui, s: &str| {
let label = ui.label(egui::RichText::new(s).color(t.text_dim).size(12.0));
@@ -40,7 +42,8 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool
crate::widgets::scrub_option_label(ui, &label);
};
let o = &mut app.ui.tool_options;
let target = selected_shape(&app.session);
// Path mode edits only the next outline: an active shape layer is not the target.
let target = if paint { selected_shape(&app.session) } else { None };
// Fill and Stroke edit the selected shape layer too (#3347), and still set the next shape's.
let (fg, bg) = (app.session.tools.foreground, app.session.tools.background);
let (mut has_fill, fill, stroke) = match &target {
@@ -61,40 +64,43 @@ pub(crate) fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool
let gesture = paint_gesture(ui, gesture_key);
let key = |k: &str| target.as_ref().map(|s| format!("shape-options-{:?}-{}-{k}:{}:{gesture}", s.doc, s.layer.0, gesture_key.value()));
let mut edit: Option<Value> = None;
lbl(ui, tl!("Fill:"));
if crate::widgets::checkbox(ui, &mut has_fill, tl!("Fill")).changed() {
o.shape_fill = has_fill;
edit = Some(json!({"fill": if has_fill { o.shape_fill_gradient.as_ref().map_or_else(|| json!(fg), paint_param) } else { Value::Null }}));
}
let fill_color = swatch(ui, &lib, fill.as_ref(), fg, tl!("Fill color"), &mut gradients);
lbl(ui, tl!("Stroke:"));
let stroke_color = swatch(ui, &lib, stroke.as_ref().map(|s| &s.paint), bg, tl!("Stroke color"), &mut gradients);
if crate::widgets::value_field(ui, &mut width, 0.0..=288.0, "px", 58.0).accessible_name(tl!("Stroke:")).changed() {
o.stroke_width = width;
// A 0 px "change" (an arrow step clamped at the minimum) must not add a stroke.
if stroke.is_some() || width > 0.0 {
let mut p = json!({"width": width});
let (mut fill_color, mut stroke_color) = (None, None);
if paint {
lbl(ui, tl!("Fill:"));
if crate::widgets::checkbox(ui, &mut has_fill, tl!("Fill")).changed() {
o.shape_fill = has_fill;
edit = Some(json!({"fill": if has_fill { o.shape_fill_gradient.as_ref().map_or_else(|| json!(fg), paint_param) } else { Value::Null }}));
}
fill_color = swatch(ui, &lib, fill.as_ref(), fg, tl!("Fill color"), &mut gradients);
lbl(ui, tl!("Stroke:"));
stroke_color = swatch(ui, &lib, stroke.as_ref().map(|s| &s.paint), bg, tl!("Stroke color"), &mut gradients);
if crate::widgets::value_field(ui, &mut width, 0.0..=288.0, "px", 58.0).accessible_name(tl!("Stroke:")).changed() {
o.stroke_width = width;
// A 0 px "change" (an arrow step clamped at the minimum) must not add a stroke.
if stroke.is_some() || width > 0.0 {
let mut p = json!({"width": width});
if stroke.is_none() {
p["color"] = o.shape_stroke_gradient.as_ref().map_or_else(|| json!(bg), paint_param);
}
edit = Some(json!({"stroke": p, "coalesce": key("stroke-w")}));
}
}
let preview = target.as_ref().filter(|s| s.stroke.is_some()).map(|s| (s.doc, s.layer));
if let Some(change) =
crate::shape_stroke_ui::button(ui, "tool-stroke", &style, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, preview, stroke_enabled)
{
let mut p = change.options.params();
if !stroke_enabled {
p["width"] = json!(3.0);
}
if stroke.is_none() {
p["color"] = o.shape_stroke_gradient.as_ref().map_or_else(|| json!(bg), paint_param);
}
edit = Some(json!({"stroke": p, "coalesce": key("stroke-w")}));
edit = Some(json!({"stroke": p, "coalesce": change.coalesce}));
o.shape_stroke = change.options;
o.stroke_width = enabled_stroke_width(o.stroke_width);
}
}
let preview = target.as_ref().filter(|s| s.stroke.is_some()).map(|s| (s.doc, s.layer));
if let Some(change) =
crate::shape_stroke_ui::button(ui, "tool-stroke", &style, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, preview, stroke_enabled)
{
let mut p = change.options.params();
if !stroke_enabled {
p["width"] = json!(3.0);
}
if stroke.is_none() {
p["color"] = o.shape_stroke_gradient.as_ref().map_or_else(|| json!(bg), paint_param);
}
edit = Some(json!({"stroke": p, "coalesce": change.coalesce}));
o.shape_stroke = change.options;
o.stroke_width = enabled_stroke_width(o.stroke_width);
}
match tool {
Tool::Rectangle => {
crate::widgets::vline(ui, 22.0);
+2 -2
View File
@@ -218,7 +218,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] {
Tool::CurvaturePen => reset!(vector_mode, pen_path_op),
Tool::FreeformPen => reset!(vector_mode, pen_path_op, curve_fit),
Tool::Line => reset!(
vector_mode,
shape_mode,
shape_fill,
stroke_width,
shape_stroke,
@@ -234,7 +234,7 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] {
arrow_concavity,
),
Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::CustomShape => {
reset!(vector_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight)
reset!(shape_mode, shape_fill, stroke_width, shape_stroke, shape_fill_gradient, shape_stroke_gradient, corner_radius, polygon_sides, line_weight)
}
Tool::ColorSampler
| Tool::Ruler
@@ -83,6 +83,30 @@ fn on_pasteboard(app: &PhotocraftApp, p: [f64; 2]) -> bool {
app.session.active().is_some_and(|st| p[0] < 0.0 || p[1] < 0.0 || p[0] >= f64::from(st.doc.size.width) || p[1] >= f64::from(st.doc.size.height))
}
/// Whether a document point lands on pixels of a layer the box doesn't move: the frontmost hit
/// there, skipping the moved layers and fully locked ones as Auto-Select does, is a visible
/// top-level layer that is not the Background. As measured in the reference application
/// (#11100, #8208), a click there commits a layer transform and leaves the selection as it was;
/// a second click picks that layer. Not measured, so the box stays open as before: Transform
/// Selection, a path or mask target, and hidden, locked or grouped layers (also when a grouped
/// layer covers a top-level one at the click).
fn on_other_layer(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> bool {
if t.selection || t.path.is_some() || t.target.is_some() || !p[0].is_finite() || !p[1].is_finite() {
return false;
}
let Some(st) = app.session.active() else { return false };
let doc = &st.doc;
// The layers the commit moves: `edit.transform` takes the link group along as it is then, so a
// layer linked after the box opened counts as moved too.
let moved = photocraft_engine::transform_cmds::box_targets(doc, &super::begin::moving_layers(t));
let moved = |id: photocraft_doc::LayerId| id.0 == t.layer || moved.contains(&id);
// `as` saturates out-of-range floats; `layers_at` returns nothing off the canvas.
photocraft_engine::pick_cmds::layers_at(doc, p[0].floor() as i32, p[1].floor() as i32)
.into_iter()
.find(|id| !moved(*id) && !doc.effective_locks(*id).all)
.is_some_and(|id| !doc.is_background(id) && doc.layers.iter().any(|l| l.id == id && l.artboard().is_none()))
}
/// Pointer input while transforming. Returns false when no transform is active.
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
let Some(t) = app.ui.transform.clone() else { return false };
@@ -118,16 +142,18 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
if matches!(ev, ToolEvent::Up { .. }) {
pv.gesture = None;
}
// Clicking the pasteboard (outside the document) commits; a click outside the box on the
// image does nothing and the box stays open (#11100). Dragging the rotation zone and
// Option-clicking to move the pivot keep their existing behavior.
// Clicking the pasteboard (outside the document) or another layer's pixels commits; a
// click outside the box where only the Background has pixels does nothing and the box
// stays open (#11100). The commit doesn't pick the layer under the click: as in the
// reference application, that takes a second click (#8208). Dragging the rotation
// zone and Option-clicking to move the pivot keep their existing behavior.
// The press decides: a click that starts on the image and jitters past the edge
// before the release is still a click on the image.
if matches!(ev, ToolEvent::Up { .. })
&& let Some(g) = g
&& accepts_outside_click(g, [x, y], tol)
{
if on_pasteboard(app, g.start) {
if on_pasteboard(app, g.start) || on_other_layer(app, &t, g.start) {
commit(app);
} else if let Some(s) = app.ui.transform.as_mut() {
// Jitter before the release may have turned the box a little: put it back.
+4 -2
View File
@@ -30,7 +30,7 @@ pub(crate) use curvature::curvature_anchor_at;
pub use curvature::{curvature_down, curvature_move, curvature_toggle, curvature_up};
#[cfg(test)]
use options::{corner_radii_patch, solid_paint};
pub(crate) use options::{enabled_stroke_width, paint_gesture};
pub(crate) use options::{draws_path, enabled_stroke_width, paint_gesture};
pub use options::{options_bar, shape_properties};
pub use overlay::draw_overlay;
pub(crate) use overlay::selected_saved_path_index;
@@ -41,7 +41,9 @@ pub(crate) use overlay::{curve_points, path_lines, selected_saved_path, shows_an
pub(crate) use overlay::{draw_anchor, draw_handle, draw_outline};
#[cfg(test)]
use path_ops::target_path;
pub use path_ops::{active_path_name, path_selection_finish, path_to_selection, pick_shape_at, selected_document_path, takes_path_mask, with_active_path};
pub use path_ops::{
active_path_name, make_shape, path_selection_finish, path_to_selection, pick_shape_at, selected_document_path, takes_path_mask, with_active_path,
};
pub(crate) use path_ops::{
active_shape_path, active_type_path, delete_key_deletes_work_path, deselect_loaded_path, free_transform_path, targeted_vector_mask, work_path_shown,
};
+27 -3
View File
@@ -108,13 +108,32 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
app.ui.status = e;
app.ui.status_error = true;
}
// Make: Shape turns the path into a shape layer with the shape fill and stroke (#10934).
let has_shape_path = super::path_ops::shape_source_path(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2);
if ui.add_enabled(has_shape_path, egui::Button::new(tl!("Make Shape"))).clicked()
&& let Err(e) = super::make_shape(app)
{
app.ui.status = e;
app.ui.status_error = true;
}
}
return true;
}
let mut mode = "shape".to_string();
crate::widgets::dropdown(ui, "shape-mode", tl!("Mode"), &mut mode, &[("shape".to_string(), tl!("Shape"))], 80.0);
if is_shape_tool(tool) {
// Shape draws a shape layer, Path adds the outline to the work path (#9453).
let opts = [("shape".to_string(), tl!("Shape")), ("path".to_string(), tl!("Path"))];
crate::widgets::dropdown(ui, "shape-mode", tl!("Mode"), &mut app.ui.tool_options.shape_mode, &opts, 80.0);
} else {
// Path Selection and Direct Selection edit the selected shape layer: the Shape tools'
// Path mode isn't theirs to show or change.
let mut mode = "shape".to_string();
crate::widgets::dropdown(ui, "shape-mode", tl!("Mode"), &mut mode, &[("shape".to_string(), tl!("Shape"))], 80.0);
}
crate::widgets::vline(ui, 22.0);
crate::shape_options::options_bar(app, ui, tool);
// The Shape tools' Path mode hides Fill and Stroke (an outline has no paint); the tool's
// geometry, the Line's arrowheads included, stays.
let paint = !(is_shape_tool(tool) && draws_path(&app.ui.tool_options));
crate::shape_options::options_bar(app, ui, tool, paint);
// Photoshop-style arrowhead settings at the right of Line options.
if tool == Tool::Line {
crate::widgets::vline(ui, 22.0);
@@ -146,6 +165,11 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
true
}
/// Whether the Shape tools are in Path mode: a drag adds its outline to the work path.
pub(crate) fn draws_path(o: &crate::state::ToolOptions) -> bool {
o.shape_mode == "path"
}
/// Whether the active layer supplies the selected-shape options.
pub(super) fn selected_shape(session: &photocraft_engine::Session) -> Option<photocraft_doc::LayerId> {
let st = session.active()?;
@@ -1041,3 +1041,61 @@ fn a_refused_popover_swatch_is_not_a_recently_used_colour() {
assert_eq!(recent, ["#00ff00"], "properties={properties}");
}
}
/// CodeRabbit on #9456: Path mode hides Fill and Stroke (a path has no paint), but each tool's
/// geometry stays in the options bar, as in Photoshop's Path tool mode.
#[test]
fn path_mode_keeps_the_geometry_options_and_hides_paint() {
for (tool, geometry) in [(Tool::Rectangle, "Radius:"), (Tool::Polygon, "Sides:"), (Tool::Line, "Weight:"), (Tool::CustomShape, "Shape:")] {
let mut app = app("rgb", 8);
app.ui.tool = tool;
app.ui.tool_options.shape_mode = "path".into();
let h = harness(app, false);
assert!(h.query_by_label(geometry).is_some(), "{tool:?}: {geometry} stays in Path mode");
for paint in ["Fill:", "Stroke:", "Fill color", "Stroke color", "Stroke Options"] {
assert!(h.query_by_label(paint).is_none(), "{tool:?}: {paint} is hidden in Path mode");
}
}
}
/// CodeRabbit on #9456: in Path mode the Radius field sets the next outline's radius only; it
/// must not edit a shape layer that happens to be active.
#[test]
fn path_mode_radius_leaves_the_active_shape_layer_alone() {
let mut app = app("rgb", 8);
app.run("shape.create", json!({"kind": "rect", "rect": [20, 20, 60, 40], "radii": 10})).unwrap();
let radii = |app: &PhotocraftApp| match &shape(app).live {
Some(photocraft_doc::LiveShape::Rect { radii, .. }) => *radii,
other => panic!("expected a live rectangle, got {other:?}"),
};
assert_eq!(radii(&app), [10.0; 4]);
app.ui.tool_options.corner_radius = 3.0;
app.ui.tool_options.shape_mode = "path".into();
let history = app.session.active().unwrap().history.past_len();
let mut h = harness(app, false);
h.get_all_by_role(egui::accesskit::Role::SpinButton).next().expect("the radius").click();
h.run();
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
h.event(Event::Text("20".into()));
h.key_press(Key::Enter);
h.run_steps(3);
assert_eq!(h.state().ui.tool_options.corner_radius, 20.0, "the next outline's radius");
assert_eq!(radii(h.state()), [10.0; 4], "the shape layer keeps its radius");
assert_eq!(h.state().session.active().unwrap().history.past_len(), history, "no edit");
}
/// CodeRabbit on #9456: the Shape tools' Path mode is theirs alone. With a shape selected, Path
/// Selection and Direct Selection keep their Fill and Stroke controls whatever that mode is.
#[test]
fn selection_tools_ignore_the_shape_tools_path_mode() {
for tool in [Tool::PathSelection, Tool::DirectSelection] {
let mut app = app("rgb", 8);
app.run("shape.create", json!({"rect": [20, 20, 60, 40]})).unwrap();
app.ui.tool = tool;
app.ui.tool_options.shape_mode = "path".into();
let h = harness(app, false);
for label in ["Fill color", "Stroke color"] {
assert!(h.query_by_label(label).is_some(), "{tool:?}: {label} stays");
}
}
}
@@ -73,6 +73,42 @@ pub fn path_to_selection(app: &mut PhotocraftApp, params: Value) -> Result<Value
Ok(r)
}
/// The Pen options bar's Make Shape (Path mode, #10934): a new shape layer from the path being
/// drawn, or else from the Work Path or saved path that is the target path, with the shape tools'
/// fill and stroke, as one History step. A path being drawn is not committed: the shape takes what
/// the commit would have made (with a vector mask targeted, only the drawn path, and the mask
/// stays), see [`super::pen::make_shape_from_draft`]. A shape layer's own path or vector mask
/// isn't copied. The Work Path becomes the shape's path, so it is no longer selected in the Paths
/// panel.
pub fn make_shape(app: &mut PhotocraftApp) -> Result<Value, String> {
super::cancel_stale_pen(app);
if let Some(mut pen) = app.ui.pen.clone().filter(|p| p.knots.len() >= 2) {
if pen.curvature {
super::curvature::curvature_refresh(&mut pen, false);
}
let r = super::pen::make_shape_from_draft(app, &pen)?;
app.ui.pen = None;
app.ui.pen_doc = None;
if app.ui.direct_selection.target == Some(crate::direct_select::PathRef::Pen) {
app.ui.direct_selection = Default::default();
}
return Ok(r);
}
let name =
shape_source_path(app).ok_or_else(|| tl!("No path to make a shape from: draw one with the Pen tool or select one in the Paths panel.").to_string())?;
let r = app.run("path.toShape", json!({"name": name, "fill": super::shapes::fill_param(app), "stroke": super::shapes::stroke_param(app)}))?;
if name == "work" && app.ui.selected_path.as_deref() == Some("work") {
app.ui.selected_path = None;
}
Ok(r)
}
/// The path Make Shape makes a shape from without a draft: [`active_path_name`] when it is the
/// Work Path or a saved path, never a layer's own path (that would duplicate the shape or mask).
pub fn shape_source_path(app: &PhotocraftApp) -> Option<String> {
active_path_name(app).filter(|n| n != "layer")
}
/// After the Pen or Path Selection tool made a selection from the path `name` (⌘↩, the canvas
/// menu's Make Selection…), Photoshop deselects it in the Paths panel and stops drawing it; the
/// path itself stays (#6079). The Work Path is hidden too when a saved path was loaded, so no
+87 -10
View File
@@ -445,10 +445,7 @@ fn commit_path_current(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) ->
// path selected, a new Work Path replaces the old one. The drawn path ends up selected.
// A resumed draft whose subpath changed meanwhile joins the path it came from, as a new
// subpath, even with nothing selected in the Paths panel.
let exists = |n: &String| app.session.active().is_some_and(|st| path_exists(&st.doc, n));
let resumed_from =
pen.resumes.as_ref().filter(|r| r.layer.is_none() && app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone());
let target = resumed_from.filter(exists).or_else(|| app.ui.selected_path.clone().filter(exists));
let target = draft_target(app, pen);
let name = target.clone().unwrap_or_else(|| "work".into());
let mut params = json!({"name": name, "path": path});
if target.is_some() {
@@ -464,6 +461,44 @@ fn commit_path_current(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) ->
true
}
/// The document path a Path-mode draft joins when committed (not into a vector mask): the path it
/// resumed, else the path selected in the Paths panel. `None`: it becomes a new Work Path.
fn draft_target(app: &PhotocraftApp, pen: &PenPath) -> Option<String> {
let exists = |n: &String| app.session.active().is_some_and(|st| path_exists(&st.doc, n));
let resumed_from =
pen.resumes.as_ref().filter(|r| r.layer.is_none() && app.session.active().is_some_and(|st| st.doc.id.0 == r.doc)).map(|r| r.path.clone());
resumed_from.filter(exists).or_else(|| app.ui.selected_path.clone().filter(exists))
}
/// What Make Shape turns a Path-mode draft into, without committing it: the path the commit would
/// leave (the draft joined to the path it goes into, with the Path Operation, or alone as a new
/// Work Path), and whether that is the Work Path, which the shape then takes. With a vector mask
/// targeted, the draft alone: the mask stays as it was. `None` with no document.
pub(super) fn draft_shape_path(app: &PhotocraftApp, pen: &PenPath) -> Option<(Value, bool)> {
let drawn = pen_to_json(pen, false);
let doc = &app.session.active()?.doc;
if pen.resumes.as_ref().is_some_and(|r| r.layer.is_some()) || targeted_vector_mask(app).is_some() {
return Some((drawn, false));
}
if let Some(r) = pen.resumes.as_ref().filter(|r| r.doc == doc.id.0)
&& let Some(p) = with_resumed_subpath(doc, r, &drawn)
{
return Some((p, r.path == "work"));
}
// With nothing to join, the commit's new Work Path would replace the old one.
let Some(name) = draft_target(app, pen) else { return Some((drawn, true)) };
let existing = doc_path(doc, &name)?;
let mut p = photocraft_engine::vector_cmds::path_json(existing);
let op = pen_path_op(&app.ui.tool_options).unwrap_or("combine");
if let (Some(subs), Some(new)) = (p.get_mut("subpaths").and_then(Value::as_array_mut), drawn.get("subpaths").and_then(Value::as_array)) {
subs.extend(new.iter().cloned().map(|mut sp| {
sp["op"] = json!(op);
sp
}));
}
Some((p, name == "work"))
}
/// Does `doc` have the path the Paths panel calls `name`: "work", or a saved path's name?
fn path_exists(doc: &Document, name: &str) -> bool {
doc_path(doc, name).is_some()
@@ -780,7 +815,21 @@ fn fit_with_straight_segments(trace: &[[f64; 2]], straight: &[usize], fit: f64,
use super::{PenOwner, cancel_stale_pen, capture_pen_owner};
fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> Result<(), String> {
let Some(owner) = pen.owner.as_ref().filter(|o| o.valid(app)) else { return Err("Pen draft target is stale; no path was changed".into()) };
let Some(owner) = pen.owner.clone().filter(|o| o.valid(app)) else { return Err("Pen draft target is stale; no path was changed".into()) };
let result = as_owner(app, &owner, |app| {
let result = commit_path_current(app, pen, closed);
if result && matches!(owner.target, super::PenTarget::Shape { .. } | super::PenTarget::VectorMask(_)) {
app.ui.selected_path = Some("layer".into());
}
result
});
if result { Ok(()) } else { Err(app.ui.status.clone()) }
}
/// Run `f` with the options, colours, active layer, Paths panel selection and vector mask target
/// the draft's `owner` captured when drawing began, then put the current ones back (the Paths
/// panel selection stays as `f` left it).
fn as_owner<R>(app: &mut PhotocraftApp, owner: &PenOwner, f: impl FnOnce(&mut PhotocraftApp) -> R) -> R {
let options = std::mem::replace(&mut app.ui.tool_options, owner.options.clone());
let vector_mask = std::mem::replace(&mut app.ui.vector_mask_target, owner.vector_mask);
app.ui.selected_path = match &owner.target {
@@ -800,10 +849,7 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> Result<(
photocraft_engine::channel_cmds::ChannelTarget::Composite
};
}
let result = commit_path_current(app, pen, closed);
if result && matches!(owner.target, super::PenTarget::Shape { .. } | super::PenTarget::VectorMask(_)) {
app.ui.selected_path = Some("layer".into());
}
let result = f(app);
if let (Some(st), Some(channel)) = (app.session.active_mut(), channel) {
st.channel_view.target = channel;
}
@@ -811,5 +857,36 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) -> Result<(
app.session.tools.background = background;
app.ui.tool_options = options;
app.ui.vector_mask_target = vector_mask;
if result { Ok(()) } else { Err(app.ui.status.clone()) }
result
}
/// Make Shape on a Pen draft (#10934), as one History step: a draft begun in Shape mode is
/// committed (it is a shape already, and a resumed subpath keeps its layer's other subpaths);
/// otherwise a new shape layer, with the shape tools' fill and stroke, of what the commit would
/// have made, worked out as the commit does, with the settings drawing began with.
pub(super) fn make_shape_from_draft(app: &mut PhotocraftApp, pen: &PenPath) -> Result<Value, String> {
let owner = pen.owner.clone();
if owner.as_ref().is_some_and(|o| !o.valid(app)) {
return Err("Pen draft target is stale; no path was changed".into());
}
if owner.as_ref().is_some_and(|o| matches!(o.target, super::PenTarget::Shape { .. })) {
return try_pen_commit(app, false).map(|()| Value::Null);
}
let selected = app.ui.selected_path.clone();
let params = |app: &mut PhotocraftApp| {
let (path, consume) = draft_shape_path(app, pen).ok_or_else(|| tl!("No document").to_string())?;
let params = json!({"path": path, "consumeWorkPath": consume, "fill": fill_param(app), "stroke": stroke_param(app)});
Ok::<_, String>((params, consume))
};
let made = match &owner {
Some(o) => as_owner(app, o, params),
None => params(app),
};
app.ui.selected_path = selected;
let (params, consume) = made?;
let r = app.run("path.toShape", params);
if r.is_ok() && consume && app.ui.selected_path.as_deref() == Some("work") {
app.ui.selected_path = None;
}
r
}
@@ -348,3 +348,197 @@ fn freeform_refuses_a_locked_shape_before_starting_a_stroke() {
assert!(app.drag.is_some(), "{lock}: unlocked stroke can start");
}
}
/// #10934: the options bar's Make Shape (Path mode) turns the Work Path into a shape layer filled
/// with the foreground colour, as one History step; the Work Path is the shape's path afterwards.
/// A path still being drawn is finished first. With no path the button is disabled.
#[test]
fn make_shape_turns_the_work_path_into_a_shape_layer() {
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 400.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
ui.horizontal(|ui| super::options_bar(app, ui, Tool::Pen));
}
},
app(),
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run_steps(4);
assert!(h.get_by_label("Make Shape").accesskit_node().is_disabled(), "no path yet");
square(h.state_mut(), 20.0, 20.0, 100.0);
let drawn = work(h.state());
let (count, steps) = (layers(h.state()), h.state().session.active().unwrap().history.past_len());
h.run_steps(2);
h.get_by_label("Make Shape").click();
h.run_steps(3);
let app = h.state();
assert_eq!(layers(app), count + 1);
assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1, "one History step");
let (id, path) = shape_path(app);
assert_eq!(path, drawn);
let st = app.session.active().unwrap();
assert!(st.doc.work_path.is_none());
let LayerContent::Shape(sh) = &st.doc.layer(id).unwrap().content else { panic!("not a shape layer") };
let fg = app.session.tools.foreground;
assert_eq!(sh.fill, Some(photocraft_doc::Fill::Solid(photocraft_color::Color::rgba(fg[0], fg[1], fg[2], fg[3]))));
// An open path still being drawn becomes the shape too, still in one History step.
for (x, y) in [(10.0, 150.0), (60.0, 150.0), (60.0, 190.0)] {
pen_down(h.state_mut(), x, y, false);
pen_up(h.state_mut());
}
let steps = h.state().session.active().unwrap().history.past_len();
h.run_steps(2);
h.get_by_label("Make Shape").click();
h.run_steps(3);
let app = h.state();
assert!(app.ui.pen.is_none());
assert_eq!(layers(app), count + 2);
assert_eq!(shape_path(app).1.subpaths[0].knots.len(), 3);
assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1, "one History step while drawing");
assert!(app.session.active().unwrap().doc.work_path.is_none());
}
/// Make Shape while drawing with a vector mask targeted makes a shape of the drawn path alone and
/// leaves the mask as it was: the draft isn't committed into the mask first.
#[test]
fn make_shape_while_drawing_leaves_a_targeted_vector_mask_alone() {
let mut app = app();
let id = app.session.active().unwrap().active_layer.unwrap().0;
app.run("layer.vectorMask.add", json!({"layer": id, "path": {"subpaths": [{"closed": true, "knots": [[0, 0], [100, 0], [100, 100], [0, 100]]}]}})).unwrap();
app.ui.vector_mask_target = true;
let mask = app.session.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone();
for (x, y) in [(10.0, 150.0), (60.0, 150.0), (60.0, 190.0)] {
pen_down(&mut app, x, y, false);
pen_up(&mut app);
}
let steps = app.session.active().unwrap().history.past_len();
super::make_shape(&mut app).unwrap();
let st = app.session.active().unwrap();
assert_eq!(st.doc.layer(LayerId(id)).unwrap().vector_mask, mask, "the mask changed");
assert_eq!(st.history.past_len(), steps + 1);
let (_, path) = shape_path(&app);
assert_eq!(path.subpaths.len(), 1);
assert_eq!(path.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(10.0, 150.0));
}
/// Make Shape while drawing into a selected saved path: the shape is the saved path plus the
/// drawing, as the commit would have made it, and the saved path stays as it was.
#[test]
fn make_shape_while_drawing_into_a_saved_path_keeps_it() {
let mut app = app();
square(&mut app, 20.0, 20.0, 50.0);
app.run("path.rename", json!({"name": "work", "to": "Box"})).unwrap();
app.ui.selected_path = Some("Box".into());
let saved = app.session.active().unwrap().doc.paths[0].path.clone();
for (x, y) in [(10.0, 150.0), (60.0, 150.0), (60.0, 190.0)] {
pen_down(&mut app, x, y, false);
pen_up(&mut app);
}
super::make_shape(&mut app).unwrap();
let st = app.session.active().unwrap();
assert_eq!(st.doc.paths[0].path, saved);
assert_eq!(shape_path(&app).1.subpaths.len(), saved.subpaths.len() + 1);
}
/// Make Shape on a saved path selected in the Paths panel keeps that path.
#[test]
fn make_shape_keeps_a_saved_path() {
let mut app = app();
square(&mut app, 20.0, 20.0, 50.0);
app.run("path.rename", json!({"name": "work", "to": "Box"})).unwrap();
app.ui.selected_path = Some("Box".into());
super::make_shape(&mut app).unwrap();
let st = app.session.active().unwrap();
assert_eq!(st.doc.paths.first().map(|p| p.name.as_str()), Some("Box"));
assert_eq!(shape_path(&app).1, st.doc.paths[0].path);
}
/// Clicks an open path with the Pen through `points`, without finishing it.
fn draw(app: &mut PhotocraftApp, points: &[(f64, f64)]) {
for &(x, y) in points {
pen_down(app, x, y, false);
pen_up(app);
}
}
/// Make Shape while drawing into a saved path uses the path drawing began with, as the commit
/// does, not one selected in the Paths panel since.
#[test]
fn make_shape_while_drawing_uses_the_path_drawing_began_with() {
let mut app = app();
square(&mut app, 20.0, 20.0, 50.0);
app.run("path.rename", json!({"name": "work", "to": "Box"})).unwrap();
app.run("path.set", json!({"name": "Other", "path": {"subpaths": [{"closed": true, "knots": [[100, 100], [150, 100], [150, 150]]}]}})).unwrap();
app.ui.selected_path = Some("Box".into());
let doc_paths = app.session.active().unwrap().doc.paths.clone();
draw(&mut app, &[(10.0, 150.0), (60.0, 150.0), (60.0, 190.0)]);
app.ui.selected_path = Some("Other".into());
super::make_shape(&mut app).unwrap();
let box_path = &doc_paths.iter().find(|p| p.name == "Box").unwrap().path;
let shape = shape_path(&app).1;
assert_eq!(shape.subpaths.len(), box_path.subpaths.len() + 1, "Box and the drawing");
assert_eq!(shape.subpaths[0], box_path.subpaths[0]);
assert_eq!(app.session.active().unwrap().doc.paths, doc_paths, "the saved paths stay as they were");
assert_eq!(app.ui.selected_path.as_deref(), Some("Other"), "the Paths panel selection stays");
}
/// With no Work Path and no saved path selected, Make Shape has nothing to make a shape from: the
/// selected shape layer's own path isn't copied into a second shape layer.
#[test]
fn make_shape_doesnt_copy_a_shape_layers_own_path() {
let mut app = app();
app.ui.tool_options.vector_mode = "shape".into();
square(&mut app, 20.0, 20.0, 50.0);
app.ui.tool_options.vector_mode = "path".into();
assert_eq!(super::active_path_name(&app).as_deref(), Some("layer"));
let count = layers(&app);
let steps = app.session.active().unwrap().history.past_len();
assert!(super::make_shape(&mut app).is_err());
assert_eq!(layers(&app), count);
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
}
/// Make Shape while drawing a new path with an unselected Work Path: the commit would have
/// replaced the Work Path with the drawing, so the shape takes the drawing and no Work Path is
/// left behind.
#[test]
fn make_shape_while_drawing_a_new_work_path_replaces_the_old_one() {
let mut app = app();
square(&mut app, 20.0, 20.0, 50.0);
app.ui.selected_path = None;
draw(&mut app, &[(10.0, 150.0), (60.0, 150.0), (60.0, 190.0)]);
super::make_shape(&mut app).unwrap();
let st = app.session.active().unwrap();
assert!(st.doc.work_path.is_none(), "the old Work Path is gone, as after a commit and Make Shape");
let shape = shape_path(&app).1;
assert_eq!(shape.subpaths.len(), 1);
assert_eq!(shape.subpaths[0].knots[0].anchor, photocraft_geom::Point::new(10.0, 150.0));
}
/// A draft begun in Shape mode that carries on an open subpath of a shape layer, with the mode
/// switched to Path before Make Shape: the layer keeps its other subpaths, extended in one step,
/// as the commit does, and no second layer copies part of it.
#[test]
fn make_shape_on_a_resumed_shape_layer_draft_keeps_its_other_subpaths() {
let mut app = app();
app.ui.tool_options.vector_mode = "shape".into();
draw(&mut app, &[(20.0, 20.0), (80.0, 20.0)]);
assert!(super::pen_commit(&mut app, false));
app.ui.tool_options.pen_path_op = "combine".into();
square(&mut app, 100.0, 100.0, 40.0);
let (id, before) = shape_path(&app);
assert_eq!(before.subpaths.len(), 2);
let count = layers(&app);
let steps = app.session.active().unwrap().history.past_len();
draw(&mut app, &[(80.0, 20.0), (80.0, 80.0)]);
assert!(app.ui.pen.as_ref().is_some_and(|p| p.resumes.is_some()), "drawing carries on the open subpath");
app.ui.tool_options.vector_mode = "path".into();
super::make_shape(&mut app).unwrap();
assert_eq!(layers(&app), count, "no second layer");
let (now, after) = shape_path(&app);
assert_eq!(now, id);
assert_eq!(after.subpaths.len(), 2, "the square stays");
assert_eq!(after.subpaths[0].knots.len(), 3, "the open subpath, extended");
assert_eq!(after.subpaths[1], before.subpaths[1]);
assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1);
}
+83 -11
View File
@@ -1,4 +1,4 @@
use super::options::solid_paint;
use super::options::{draws_path, solid_paint};
use super::overlay::path_lines;
use super::{Affine, Color32, PhotocraftApp, Tokens, Tool, ToolOptions, Value, ViewXform, json, paint_param};
@@ -100,6 +100,29 @@ pub fn shape_bounds(o: &ToolOptions, tool: Tool, start: [f64; 2], end: [f64; 2],
/// Finish a Shape-tool drag: ⇧ constrains proportions, ⌥ draws from the centre.
pub fn finish_shape(app: &mut PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) {
if draws_path(&app.ui.tool_options) {
// Path mode: the outline joins the work path (one more subpath), and no layer is made.
let Some(outline) = shape_outline(app, tool, start, end, mods) else { return };
// Appended here rather than with `path.set`'s `op`, which would turn a custom shape's
// subtracted subpaths (holes) into combined ones; the shape goes in as one component.
// A new work path takes the outline's fill rule.
let mut path = app
.session
.active()
.and_then(|st| st.doc.work_path.clone())
.unwrap_or_else(|| photocraft_doc::vector::Path { fill_rule: outline.fill_rule, ..Default::default() });
let outline = as_one_component(&outline, path.fill_rule).unwrap_or(outline);
path.subpaths.extend(outline.subpaths);
let r = app.run("path.set", json!({"name": "work", "path": photocraft_engine::vector_cmds::path_json(&path)}));
match r {
Ok(_) => app.ui.selected_path = Some("work".into()),
Err(e) => {
app.ui.status = e;
app.ui.status_error = true;
}
}
return;
}
let Some(mut p) = shape_geometry(&app.ui.tool_options, tool, start, end, mods) else { return };
if tool == Tool::CustomShape {
// ⇧ keeps the shape's proportions (the rect is already squared).
@@ -127,22 +150,71 @@ pub fn create_shape(app: &mut PhotocraftApp, mut geometry: Value) -> Result<Valu
app.run("shape.create", geometry)
}
/// The preview and command both use the full float tool colours, including transparent paint.
pub(super) fn shape_preview(app: &PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option<photocraft_doc::ShapeLayer> {
let o = &app.ui.tool_options;
let geometry = shape_geometry(o, tool, start, end, mods)?;
let path = if tool == Tool::CustomShape {
/// A custom shape as one shape component, the way Photoshop adds it to a path: the subpaths
/// after the first join it, holes wound against it so that `rule` cuts them, so its holes cut
/// the shape only and not the work path under it. `None` for a single subpath, and when joined
/// it would cover a different area (an intersect or exclude subpath, or a cut reaching past
/// the shape, like the Crescent Moon's).
fn as_one_component(outline: &photocraft_doc::vector::Path, rule: photocraft_doc::vector::FillRule) -> Option<photocraft_doc::vector::Path> {
use photocraft_doc::vector::{Path, PathOp, Subpath};
// Clockwise on screen (y down) or not, from the flattened outline's signed area.
let clockwise = |s: &Subpath| {
let pts = photocraft_vector::flatten_subpath(s, 0.25).pts;
let twice_area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum();
twice_area > 0.0
};
let (first, rest) = outline.subpaths.split_first()?;
if rest.is_empty() {
return None;
}
let outer = clockwise(first);
let mut joined = Path { fill_rule: rule, ..outline.clone() };
for s in &mut joined.subpaths[1..] {
let want = match s.op {
PathOp::Join => continue,
PathOp::Combine => outer,
PathOp::Subtract => !outer,
PathOp::Intersect | PathOp::Exclude => return None,
};
if clockwise(s) != want {
s.knots.reverse();
for k in &mut s.knots {
std::mem::swap(&mut k.in_ctrl, &mut k.out_ctrl);
}
}
s.op = PathOp::Join;
}
// Compare the two areas at a small fixed size.
const SIDE: f64 = 128.0;
let rect = photocraft_geom::Rect::from_xywh(0, 0, SIDE as u32, SIDE as u32);
let cover = |p: &Path| photocraft_vector::path_coverage(&photocraft_engine::presets::shapes::fit(p, [0.0, 0.0, SIDE, SIDE], true), rect);
let same = cover(outline).iter().zip(cover(&joined)).all(|(a, b)| (a - b).abs() <= 0.5);
same.then_some(joined)
}
/// The outline a Shape-tool drag draws, as a path (a custom shape fitted to the drag's box).
fn shape_outline(app: &PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option<photocraft_doc::vector::Path> {
let geometry = shape_geometry(&app.ui.tool_options, tool, start, end, mods)?;
if tool == Tool::CustomShape {
let rect = geometry.get("rect").and_then(|r| serde_json::from_value::<[f64; 4]>(r.clone()).ok())?;
let groups = photocraft_engine::presets::shapes::all_groups(&app.session);
let shape = groups.iter().flat_map(|g| &g.items).find(|s| s.name == app.ui.presets_ui.shape())?;
photocraft_engine::presets::shapes::fit(&shape.path, rect, mods.shift)
Some(photocraft_engine::presets::shapes::fit(&shape.path, rect, mods.shift))
} else {
photocraft_engine::vector_cmds::shape_path(&geometry).ok()?
};
photocraft_engine::vector_cmds::shape_path(&geometry).ok()
}
}
/// The preview and command both use the full float tool colours, including transparent paint.
/// In Path mode only the outline shows, as the drag draws no layer.
pub(super) fn shape_preview(app: &PhotocraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option<photocraft_doc::ShapeLayer> {
let o = &app.ui.tool_options;
let path = shape_outline(app, tool, start, end, mods)?;
let paint = !draws_path(o);
Some(photocraft_doc::ShapeLayer {
path,
fill: o.shape_fill.then(|| tool_paint(app, false)),
stroke: (o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, tool_paint(app, true))),
fill: (paint && o.shape_fill).then(|| tool_paint(app, false)),
stroke: (paint && o.stroke_width > 0.0).then(|| o.shape_stroke.stroke(o.stroke_width, tool_paint(app, true))),
..Default::default()
})
}
+111
View File
@@ -500,6 +500,117 @@ fn shape_drag_creates_layers_with_modifiers() {
assert_eq!(app.session.active().unwrap().doc.layers.len(), 4);
}
/// #9453: in Path mode a Shape-tool drag adds its outline to the work path, one subpath per
/// drag, and makes no layer, as Photoshop's Path tool mode does. Shape stays the default.
#[test]
fn path_mode_shape_drags_add_to_the_work_path() {
let mut app = app();
assert_eq!(app.ui.tool_options.shape_mode, "shape", "Shape by default, as in Photoshop");
let layers = app.session.active().unwrap().doc.layers.len();
app.ui.tool_options.shape_mode = "path".into();
finish_shape(&mut app, Tool::EllipseShape, [20.0, 20.0], [70.0, 50.0], egui::Modifiers::NONE);
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), layers, "no layer");
let work = doc.work_path.as_ref().expect("a work path");
assert_eq!(work.subpaths.len(), 1);
assert!(work.subpaths[0].closed);
let b = work.control_bounds().unwrap();
assert!((b.0 - 20.0).abs() < 0.5 && (b.1 - 20.0).abs() < 0.5 && (b.2 - 70.0).abs() < 0.5 && (b.3 - 50.0).abs() < 0.5, "{b:?}");
assert_eq!(app.ui.selected_path.as_deref(), Some("work"));
finish_shape(&mut app, Tool::Rectangle, [100.0, 100.0], [140.0, 130.0], egui::Modifiers::NONE);
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.work_path.as_ref().unwrap().subpaths.len(), 2, "a second drag adds a subpath");
assert_eq!(doc.layers.len(), layers);
// Back in Shape mode a drag makes a shape layer again.
app.ui.tool_options.shape_mode = "shape".into();
finish_shape(&mut app, Tool::Rectangle, [10.0, 150.0], [60.0, 180.0], egui::Modifiers::NONE);
assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1);
}
/// CodeRabbit on #9456: Path mode adds a Custom Shape to the work path as one shape component,
/// its hole joined to it, as Photoshop does (the Ring's hole no longer turns into a combined
/// subpath, nor stays a subtracted one that would cut the work path under it).
#[test]
fn path_mode_custom_shape_joins_its_hole_to_it() {
use photocraft_doc::PathOp;
let mut app = app();
app.ui.tool_options.shape_mode = "path".into();
finish_shape(&mut app, Tool::Rectangle, [100.0, 100.0], [140.0, 130.0], egui::Modifiers::NONE);
app.ui.presets_ui.custom_shape = "Ring".into();
finish_shape(&mut app, Tool::CustomShape, [20.0, 20.0], [70.0, 70.0], egui::Modifiers::NONE);
let ops: Vec<PathOp> = app.session.active().unwrap().doc.work_path.as_ref().unwrap().subpaths.iter().map(|s| s.op).collect();
assert_eq!(ops, [PathOp::Combine, PathOp::Combine, PathOp::Join], "the rectangle, the ring and its hole");
assert_eq!(work_covered(&app, 45, 45), 0.0, "the ring's hole");
assert_eq!(work_covered(&app, 22, 45), 1.0, "the ring");
}
/// CodeRabbit on #9456, checked in Photoshop 2025: a Ring drawn in Path mode over a rectangle
/// cuts its hole in the ring only; the rectangle stays whole where the hole overlaps it.
#[test]
fn path_mode_custom_shape_hole_leaves_the_work_path_under_it() {
let mut app = app();
app.ui.tool_options.shape_mode = "path".into();
finish_shape(&mut app, Tool::Rectangle, [20.0, 20.0], [120.0, 120.0], egui::Modifiers::NONE);
app.ui.presets_ui.custom_shape = "Ring".into();
finish_shape(&mut app, Tool::CustomShape, [80.0, 80.0], [180.0, 180.0], egui::Modifiers::NONE);
assert_eq!(work_covered(&app, 50, 50), 1.0, "the rectangle");
assert_eq!(work_covered(&app, 110, 110), 1.0, "the rectangle under the ring's hole");
assert_eq!(work_covered(&app, 130, 130), 0.0, "the ring's hole");
assert_eq!(work_covered(&app, 170, 130), 1.0, "the ring");
}
/// Every built-in custom shape drawn alone in Path mode covers what the shape does, joined into
/// one component or, where that would change its area (the Crescent Moon's cut reaches past
/// the disc), kept as it is.
#[test]
fn path_mode_custom_shapes_keep_their_area() {
let mut app = app();
app.ui.tool_options.shape_mode = "path".into();
let rect = photocraft_geom::Rect::from_xywh(0, 0, 200, 200);
for g in photocraft_engine::presets::shapes::builtin() {
for shape in g.items {
app.run("path.delete", json!({"name": "work"})).ok();
app.ui.presets_ui.custom_shape = shape.name.clone();
finish_shape(&mut app, Tool::CustomShape, [10.0, 10.0], [190.0, 190.0], egui::Modifiers::NONE);
let work = app.session.active().unwrap().doc.work_path.clone().expect("a work path");
let want = photocraft_engine::presets::shapes::fit(&shape.path, [10.0, 10.0, 180.0, 180.0], false);
let (a, b) = (photocraft_vector::path_coverage(&work, rect), photocraft_vector::path_coverage(&want, rect));
let off = a.iter().zip(&b).filter(|(a, b)| (*a - *b).abs() > 0.5).count();
assert_eq!(off, 0, "{}: {off} pixels differ", shape.name);
}
}
}
/// Coverage of the work path at pixel `(x, y)`.
fn work_covered(app: &PhotocraftApp, x: i32, y: i32) -> f32 {
let path = app.session.active().unwrap().doc.work_path.clone().expect("a work path");
photocraft_vector::fill_rasterizer(&path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::from_xywh(x, y, 1, 1))[0]
}
/// CodeRabbit on #9456: a custom shape that starts the work path brings its fill rule, so an
/// even-odd star keeps its empty centre.
#[test]
fn path_mode_custom_shape_starts_the_work_path_with_its_fill_rule() {
let mut app = app();
app.ui.tool_options.shape_mode = "path".into();
// A pentagram drawn in one stroke: even-odd leaves the pentagon in the middle empty.
let star: Vec<[f64; 2]> = (0..5)
.map(|k| {
let a = std::f64::consts::TAU * (k * 2) as f64 / 5.0 - std::f64::consts::FRAC_PI_2;
[50.0 + 50.0 * a.cos(), 50.0 + 50.0 * a.sin()]
})
.collect();
let r = app.run("shape.presets.new", json!({"name": "Even-Odd Star", "path": {"fillRule": "evenodd", "subpaths": [{"knots": star}]}}));
assert!(r.is_ok(), "{r:?}");
app.ui.presets_ui.custom_shape = "Even-Odd Star".into();
finish_shape(&mut app, Tool::CustomShape, [20.0, 20.0], [120.0, 120.0], egui::Modifiers::NONE);
let work = app.session.active().unwrap().doc.work_path.clone().expect("a work path");
assert_eq!(work.fill_rule, photocraft_doc::vector::FillRule::EvenOdd);
let b = work.control_bounds().unwrap();
let (cx, cy) = (((b.0 + b.2) / 2.0) as i32, ((b.1 + b.3) / 2.0) as i32);
assert_eq!(work_covered(&app, cx, cy), 0.0, "the star's centre stays empty");
}
#[test]
fn shift_pressed_during_a_shape_drag_constrains_it() {
// #668: ⇧ only counted when held at the press; Photoshop constrains when it is pressed
@@ -72,6 +72,105 @@ fn a_click_on_the_pasteboard_commits_one_free_transform_step() {
}
}
/// #11100, measured in the reference application: a click outside the box on another layer's
/// pixels commits one Free Transform step, and the transformed layer stays the only selected one
/// (the click doesn't pick the layer under it).
#[test]
fn a_click_on_another_layers_pixels_commits_and_keeps_the_selection() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap();
app.sync_views();
app.ui.extras.rulers = false;
app.ui.extras.snap = false;
// "Other" has a blue block at x 120..160, y 120..160; "Paint" a red one at x 20..60, y 20..60.
for (name, at, color) in [("Other", 120, "#0000ff"), ("Paint", 20, "#ff0000")] {
let id = app.run("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap();
app.run("layer.select", json!({"layer": id})).unwrap();
app.run("select.rect", json!({"x": at, "y": at, "width": 40, "height": 40})).unwrap();
app.run("edit.fill", json!({"contents": "color", "color": color})).unwrap();
app.run("select.deselect", json!({})).unwrap();
}
let paint = app.session.active().unwrap().active_layer.unwrap();
crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap();
let t = app.ui.transform.as_mut().unwrap();
t.quad = t.quad.map(|[x, y]| [x + 10.0, y]);
let before = steps(&app);
click(&mut app, [140.0, 140.0]);
assert!(app.ui.transform.is_none(), "committed");
assert_eq!(steps(&app), before + 1);
assert_eq!(app.session.active().unwrap().history.entries().last().map(ToString::to_string).as_deref(), Some("Free Transform"));
assert_eq!(app.session.active().unwrap().selected_layers(), vec![paint], "the selection doesn't change");
}
/// A layer linked to the transformed one after the box opened moves with the commit
/// (`edit.transform` takes the link group along), so a click on its pixels is not a click on
/// another layer: the box stays open.
#[test]
fn a_click_on_a_layer_linked_after_the_box_opened_keeps_it_open() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap();
app.sync_views();
app.ui.extras.rulers = false;
app.ui.extras.snap = false;
let mut ids = Vec::new();
for (name, at, color) in [("Other", 120, "#0000ff"), ("Paint", 20, "#ff0000")] {
let id = app.run("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap();
app.run("layer.select", json!({"layer": id})).unwrap();
app.run("select.rect", json!({"x": at, "y": at, "width": 40, "height": 40})).unwrap();
app.run("edit.fill", json!({"contents": "color", "color": color})).unwrap();
app.run("select.deselect", json!({})).unwrap();
ids.push(LayerId(id));
}
crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap();
let t = app.ui.transform.as_mut().unwrap();
t.quad = t.quad.map(|[x, y]| [x + 10.0, y]);
app.session
.edit("Link Layers", |doc, _| {
for id in &ids {
doc.layer_mut(*id).unwrap().link_group = Some(7);
}
Ok(())
})
.unwrap();
click(&mut app, [140.0, 140.0]);
assert!(app.ui.transform.is_some(), "the linked layer moves with the box: the click doesn't commit");
}
/// Only the frontmost layer under the click counts: where a grouped layer (not measured, so the
/// box stays open there) covers a top-level layer's pixels, the click doesn't commit through the
/// covered layer.
#[test]
fn a_click_on_a_grouped_layer_covering_another_layer_keeps_the_box_open() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap();
app.sync_views();
app.ui.extras.rulers = false;
app.ui.extras.snap = false;
// "Paint" has a red block at x 20..60, y 20..60; "Other" a blue one at x 120..160, y 120..160,
// covered by a green one on "Cover", which then goes into a group.
let mut ids = Vec::new();
for (name, at, color) in [("Paint", 20, "#ff0000"), ("Other", 120, "#0000ff"), ("Cover", 120, "#00ff00")] {
let id = app.run("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap();
app.run("layer.select", json!({"layer": id})).unwrap();
app.run("select.rect", json!({"x": at, "y": at, "width": 40, "height": 40})).unwrap();
app.run("edit.fill", json!({"contents": "color", "color": color})).unwrap();
app.run("select.deselect", json!({})).unwrap();
ids.push(id);
}
app.run("layer.groupLayers", json!({})).unwrap();
let doc = &app.session.active().unwrap().doc;
assert!(doc.layers.iter().all(|l| l.id.0 != ids[2]), "Cover is grouped");
assert!(doc.layers.iter().any(|l| l.id.0 == ids[1]), "Other is top-level");
app.run("layer.select", json!({"layer": ids[0]})).unwrap();
crate::menus::invoke(&mut app, &egui::Context::default(), "edit.freeTransform", json!({})).unwrap();
let t = app.ui.transform.as_mut().unwrap();
t.quad = t.quad.map(|[x, y]| [x + 10.0, y]);
let before = steps(&app);
click(&mut app, [140.0, 140.0]);
assert!(app.ui.transform.is_some(), "the box stays open");
assert_eq!(steps(&app), before);
}
/// The press decides where a click lands: one on the image that jitters past the canvas edge
/// before the release keeps the box open, one on the pasteboard that jitters onto the image commits.
#[test]
+17 -2
View File
@@ -146,7 +146,7 @@ row; the action still adds only one Undo entry.
| `CtTL` | cutToLayer | Layer Via Cut | `layer.new.layerViaCut` |
| `LnsF` | lensFlare | Lens Flare | `filter.render.lensFlare` |
| `Lvls` | levels | Levels | `image.adjustments.levels`, `image.autoTone`, `image.autoContrast` |
| `Mk ` | make | Make | `image.calculations`, `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape`, `type.createWorkPath` |
| `Mk ` | make | Make | `image.calculations`, `layer.layerStyle.createLayer`, `layer.new.layer`, `layer.new.group`, `layer.groupLayers`, `layer.duplicate`, `layer.new.layerViaCopy`, `layer.new.layerViaCut`, `layer.newFillLayer.solidColor`, `layer.newFillLayer.pattern`, `layer.newFillLayer.gradient`, `layer.newAdjustmentLayer.levels`, `layer.newAdjustmentLayer.curves`, `layer.newAdjustmentLayer.hueSaturation`, `layer.newAdjustmentLayer.brightnessContrast`, `layer.newAdjustmentLayer.threshold`, `layer.newAdjustmentLayer.posterize`, `layer.newAdjustmentLayer.invert`, `layer.newAdjustmentLayer.colorBalance`, `layer.newAdjustmentLayer.vibrance`, `layer.newAdjustmentLayer.exposure`, `layer.newAdjustmentLayer.photoFilter`, `layer.newAdjustmentLayer.blackWhite`, `layer.newAdjustmentLayer.channelMixer`, `layer.newAdjustmentLayer.selectiveColor`, `layer.newAdjustmentLayer.gradientMap`, `layer.layerMask.revealAll`, `layer.layerMask.hideAll`, `layer.layerMask.revealSelection`, `layer.layerMask.hideSelection`, `file.new`, `file.newFromClipboard`, `history.newSnapshot`, `type.convertToShape`, `type.createWorkPath` |
| (string ID only) | animationFramesFromLayers | Make Frames from Layers | |
| (string ID only) | matchColor | Match Color | `image.adjustments.matchColor` |
| `Mxm ` | maximum | Maximum | `filter.other.maximum` |
@@ -189,7 +189,7 @@ row; the action still adds only one Undo entry.
| (string ID only) | smartBrushWorkspace | Select and Mask | `select.refineEdge` |
| (string ID only) | autoCutout | Select Subject | `select.subject` |
| `SlcC` | selectiveColor | Selective Color | `image.adjustments.selectiveColor` |
| `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` |
| `setd` | set | Set | `select.all`, `select.deselect`, `select.reselect`, `select.loadSelection`, `select.rect`, `select.lasso`, `select.editInQuickMaskMode`, `tools.setColors`, `layer.setProps`, `layer.renameLayer`, `layer.setLabelColor`, `layer.setAdjustment`, `layer.fill.pattern.set`, `gradient.fill.set`, `layer.new.layerFromBackground`, `layer.layerStyle.blendingOptions`, `layer.smartFilter.blendingOptions` |
| (string ID only) | adaptCorrect | Shadows/Highlights | `image.adjustments.shadowsHighlights` |
| `Shrp` | sharpen | Sharpen | `filter.sharpen.sharpen` |
| `ShrE` | sharpenEdges | Sharpen Edges | `filter.sharpen.sharpenEdges` |
@@ -752,6 +752,21 @@ differences that do not change what a step does.
`forceNotify`) and default flags (`MkVs` false, `Adjs` false) are not written back.
- An adjustment layer's values equal to the defaults are not written back (other values are).
- Colours are kept as 8-bit values (51.0031 becomes 51); gradient names become "Custom".
- A loaded Gradient Fill Make or Set (`gradientLayer`) saves back as the recorded descriptor until
it is edited. Otherwise it is written from the params: `#rrggbb` stops on 1/4096 locations and
whole-percent midpoints; a Set also needs its transparency stops, as the event replaces the whole
gradient. Midpoint k is the `Mdpn` of stop k + 1 (the first stop's is 50). A Noise gradient,
smoothness below 100 %, a non-classic interpolation, a transparency midpoint, a midpoint outside
5..95 %, transparency stops out of range or out of order, and a Set without transparency stops stay
Photoshop steps.
- Blend If (`Blnd` in a layer Set) loads as `layer.layerStyle.blendingOptions`. With only the
Blending Options dialog's keys (`Opct`, `fillOpacity`, `Md `, `Blnd`, the Advanced checkboxes, and
the `Lefx` = {`Scl `} its OK records on a layer without effects) the Set is one step and saves back
as one Set; with any other key, those go through `layer.setProps`. Slider values are whole levels
0..255 in order, `SrcB`/`Srcl`/`SrcW`/`Srcm` for This Layer and `DstB`/`Dstl`/`DstW`/`Dstt` for
Underlying Layer (black low, black high, white low, white high; measured in the dialog). The gray
channel saves as `Chnl.Gry`; a `Chnl.Cmps` entry loads as gray and saves as `Gry`, as the
application's own dialog writes it back.
- Levels and Curves write the composite as `Chnl.Cmps` (the application skips `Ordn.Trgt`, #8543).
### Classic Gradient recordings
+1 -1
View File
@@ -55,7 +55,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`.
- `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`.
- `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=<file.csv>` / `--stroke-trace <file.csv>`): the control channel doesn't write files outside the automation write root
- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject` or `artboard` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is one of the 27 layer blend modes (`Multiply`, `Screen`), not Pass Through, which the Mode list doesn't offer (#10878); `attribute` (#10881) is `visible`, `locked`, `empty`, `linked`, `clipped`, `layerMask`, `vectorMask`, `layerEffects` or `advancedBlending`, or one of their opposites `notVisible`, `notLocked`, `notEmpty`, `notLinked`, `notClipped`, `noLayerMask`, `noVectorMask`, `noLayerEffects`, `noAdvancedBlending`; `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode, Attribute and Color always filter, by Bevel & Emboss, by Normal, by Visible and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Fields left out keep their value; a bad one is an error and changes nothing.
- `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, effect?, mode?, attribute?, color?, smart?}`, any subset. `by` is `kind`, `name`, `effect`, `mode`, `attribute`, `color`, `smartObject`, `artboard` or `group` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `effect` (#10880) is `bevelEmboss`, `stroke`, `innerShadow`, `innerGlow`, `satin`, `overlay` (Color, Gradient or Pattern Overlay), `outerGlow` or `dropShadow`, and lists the layers with that effect turned on; `mode` is one of the 27 layer blend modes (`Multiply`, `Screen`), not Pass Through, which the Mode list doesn't offer (#10878); `attribute` (#10881) is `visible`, `locked`, `empty`, `linked`, `clipped`, `layerMask`, `vectorMask`, `layerEffects` or `advancedBlending`, or one of their opposites `notVisible`, `notLocked`, `notEmpty`, `notLinked`, `notClipped`, `noLayerMask`, `noVectorMask`, `noLayerEffects`, `noAdvancedBlending`; `color` is a layer colour label id (`none`, `red`, `orange`, `yellow`, `green`, `seafoam`, `blue`, `indigo`, `magenta`, `fuchsia`, `violet`, `gray`; #5898); `smart` lists the Smart Object toggles that are on (`linked`: linked to a file, `missing`: linked to a file that was not there when the app last looked, `embedded`). Kind, Name and Smart Object filter only once a toggle is on or text is typed; Effect, Mode, Attribute and Color always filter, by Bevel & Emboss, by Normal, by Visible and by no colour at first, as in Photoshop. Artboard has no field of its own: it lists the artboards that hold the selected layers, with everything inside them (closed groups stay closed), and does not filter when nothing is selected or a selected layer is in no artboard. Group (#10883) has no field of its own either and filters at once: it lists every group that is not an artboard with everything inside it (the groups inside an artboard too), and hides the layers that are in no group; each group stays open or closed as it is. Fields left out keep their value; a bad one is an error and changes nothing.
- `ui.set` also accepts the Histogram panel: `histogramPanel` `{view?, channel?, showChannelsInColor?, showStatistics?, source?}` (any subset, kept with the preferences): `view` is `compact`, `expanded` or `allChannels`; `channel` is `composite`, `red`, `green`, `blue`, `cyan`, `magenta`, `yellow`, `black`, `a`, `b`, `luminosity` or `colors` (one the document's mode lacks shows as its composite); `source` is `entireImage`, `selectedLayer` or `adjustmentComposite` (Entire Image is counted in Compact View, when the document has one layer, or when no adjustment layer is selected for Adjustment Composite). `histogramRange` is `[from, to]` (levels 0..255) or `null`: the dragged tonal range whose Count and Percentile the statistics show. `histogramRefresh: true` is Uncached Refresh: the current state is recounted at cache level 1. `ui.inspect` › `histogram` reports the options, the channel and source as they apply, the channels the document offers, whether the shown histogram is `current` (counted for the document's revision, source and cache level the panel wants now), its `cacheLevel`, `maxCacheLevel` (Preferences › Performance › Cache Levels), `preview` (an adjustment's preview is counted) and `stats` (`mean`, `stdDev`, `median`, `pixels`, `level`, `count`, `percentile`, `clipped` percent at 0 and 255). Histograms are counted while the panel is shown.
- Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back.
- Brush Pose's `pose.overrideTiltX` and `pose.overrideTiltY` are independent booleans. For example, `engine.execute {"command":"tools.setBrush","params":{"pose":{"enabled":true,"tiltX":45,"overrideTiltX":true,"overrideTiltY":false}}}` fixes X at 45° while Y follows the stylus. Pose tilts are degrees (±90); the Brush Settings panel displays percentages (±100). Old stored `overrideTilt` values migrate to both axes. In command patches, `overrideTilt` sets both axes unless an explicit axis field in the same patch overrides it. `brush.get` includes both axis flags and the legacy combined readback (`overrideTilt` is true only when both flags are true). Axis-only journals omit that derived combined key so replay retains the other axis. Present flag values must be booleans; null and other types return an error.