mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-07 12:13:10 +00:00
Merge pull request #11643 from davidmann4/integration-batch-19
Integration batch 19: epic PRs (WIP)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)>) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]]),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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"] {
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user