mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 00:26:57 +01:00
Integration: keep the filter mask target to commands that edit it (#3297)
Redone on the PR head 053e1e0d0 (the author's main merge); the earlier
fix-up f7af09d0f carried the first six points.
With a smart object's filter mask targeted, every routed command got
"target":"filterMask" and the filter-mask precondition instead of its own:
- Cmd+T and Edit > Transform > Flip/Rotate failed ("can't transform a
filter mask on its own"); now the smart object transforms, as in
Photoshop (lone_target -> Ok(None), and transforms get no target).
- Move (layer.translate) re-rendered the whole filter stack on every
nudge, and failed for contents that can't render.
- Convert for Smart Filters became enabled and nested the smart object;
Apply Image, Last Filter, Liquify, the lens filters, Vanishing Point,
the render filters and Desaturate ignore the target.
`follows_filter_mask` now leaves these out, in the shell, in
inject_target, in the precondition and in the re-render hook; Paste
keeps its own precondition.
- With the filter mask shown (Alt-click), clicking the layer, layer-mask
or vector-mask thumbnail (or ui.set maskTarget/vectorMaskTarget/
filterMaskTarget:false) left the view on (and ui.set maskTarget:false
left the filter mask targeted), so strokes kept landing in
the filter mask; those now end the view (CodeRabbit, panels.rs).
- ui.set filterMaskTarget:true on a layer without smart filters, or with
a single channel targeted, cleared the mask target and answered ok; it
is now refused before anything changes (CodeRabbit, control.rs).
- Curves on a targeted filter mask showed colour channels and the smart
object's histogram; it is now a gray plane with the mask's histogram
(GraySurface::FilterMask).
New on this head:
- Filters with a parameter schema (Gaussian Blur, High Pass, ...)
refused "target":"filterMask" ("`target` must be pixels, mask,
quickMask or {channel}"), so blurring the filter mask, the usual way to
feather it, failed from the menu, the dialog and agents. The filter
validation now accepts it (blurring_the_filter_mask_feathers_it).
- Delete with a selection and the filter mask targeted ran Edit > Clear
on the smart object ("not a pixel layer"); it now fills the filter mask
with the background colour there, as Photoshop does. With no selection
Photoshop deletes the layer, which Delete already did
(delete_with_the_filter_mask_targeted_edits_the_filter_mask).
- docs/control-protocol.md: the ui.set line lost its closing parenthesis
in the main merge.
Co-authored-by: Valentin Klein <271991477+vangel76@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
053e1e0d03
commit
1de1e9eb58
14 files changed
+385
-41
No files matched your search
@@ -212,6 +212,8 @@ pub enum GraySurface {
|
||||
Alpha(usize),
|
||||
/// The Quick Mask.
|
||||
QuickMask,
|
||||
/// The active smart object's filter mask (FILE-215-3).
|
||||
FilterMask,
|
||||
}
|
||||
|
||||
/// Isolated Curves channel for the active document, matching [`inject_target`].
|
||||
@@ -246,6 +248,7 @@ pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Opt
|
||||
Some(IsolatedCurveChannel { name: "Quick Mask".into(), key: "", hist: 0, tint: false, translate: true, gray: Some(GraySurface::QuickMask) })
|
||||
}
|
||||
ChannelTarget::LayerMask => layer_mask_curve_channel(st),
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => filter_mask_curve_channel(st),
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st),
|
||||
_ => None,
|
||||
}
|
||||
@@ -258,6 +261,15 @@ fn layer_mask_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
|
||||
Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) })
|
||||
}
|
||||
|
||||
/// The Curves target when the active smart object's filter mask is targeted (its thumbnail on the
|
||||
/// Smart Filters row, FILE-215-3): a gray plane, though the mask may not exist until the first edit.
|
||||
pub fn filter_mask_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
|
||||
let layer = st.active_layer.and_then(|id| st.doc.layer(id))?;
|
||||
crate::smart_cmds::filter_mask_paintable(layer).ok()?;
|
||||
let name = format!("{} Filter Mask", layer.name);
|
||||
Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::FilterMask) })
|
||||
}
|
||||
|
||||
fn edit_mode(doc: &Document) -> ColorMode {
|
||||
doc.pixel_format().mode
|
||||
}
|
||||
@@ -698,8 +710,11 @@ fn edits_mask(id: &str) -> bool {
|
||||
/// layer needs a mask. None when another precondition applies.
|
||||
pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option<std::result::Result<(), String>> {
|
||||
match target_of(p) {
|
||||
// Every pixel command that follows the target paints, fills or filters the filter mask.
|
||||
Target::FilterMask if routed(id) => Some(crate::active_layer_of(s).and_then(crate::smart_cmds::filter_mask_paintable)),
|
||||
// The commands that paint, fill, filter or adjust the filter mask need a smart object with
|
||||
// smart filters, not a pixel layer. Paste keeps its own check (something to paste).
|
||||
Target::FilterMask if follows_filter_mask(id) && !PASTE_IDS.contains(&id) => {
|
||||
Some(crate::active_layer_of(s).and_then(crate::smart_cmds::filter_mask_paintable))
|
||||
}
|
||||
Target::Mask if edits_mask(id) => {
|
||||
Some(crate::active_layer_of(s).and_then(|l| if l.mask.is_some() { Ok(()) } else { Err("the active layer has no layer mask".into()) }))
|
||||
}
|
||||
@@ -715,15 +730,67 @@ pub fn follows_target(id: &str) -> bool {
|
||||
routed(id) || id == "select.float" || id == "layer.translate"
|
||||
}
|
||||
|
||||
/// Whether command `id` edits a smart object's targeted filter mask (`"target":"filterMask"`):
|
||||
/// every routed command but those that act on the smart object itself while its filter mask is
|
||||
/// targeted (Edit › Transform, Apply Image, Convert for Smart Filters, Last Filter, Liquify, the
|
||||
/// lens filters and Vanishing Point, which add a smart filter, the render filters that make a new
|
||||
/// layer, and Desaturate), so neither the shell nor the engine sends those the filter mask.
|
||||
pub fn follows_filter_mask(id: &str) -> bool {
|
||||
routed(id)
|
||||
&& !id.starts_with("edit.transform.")
|
||||
&& !matches!(
|
||||
id,
|
||||
"image.applyImage"
|
||||
| "image.adjustments.desaturate"
|
||||
| "filter.vanishingPoint"
|
||||
| "filter.render.flame"
|
||||
| "filter.render.tree"
|
||||
| "filter.render.pictureFrame"
|
||||
| "filter.convertForSmartFilters"
|
||||
| "filter.lastFilter"
|
||||
| crate::distort_cmds::LIQUIFY
|
||||
| crate::lens_cmds::LENS
|
||||
| crate::lens_cmds::WIDE
|
||||
| crate::lens_cmds::RAW
|
||||
)
|
||||
}
|
||||
|
||||
/// The paint and retouch tools that follow the targeted channel or mask.
|
||||
const PAINT_IDS: &[&str] = &[
|
||||
"paint.stroke",
|
||||
"paint.pencil",
|
||||
"paint.bucket",
|
||||
"paint.gradient",
|
||||
"paint.mixerBrush",
|
||||
"paint.cloneStamp",
|
||||
"paint.patternStamp",
|
||||
"paint.healingBrush",
|
||||
"paint.spotHealing",
|
||||
"paint.dodge",
|
||||
"paint.burn",
|
||||
"paint.sponge",
|
||||
"paint.blur",
|
||||
"paint.sharpen",
|
||||
"paint.smudge",
|
||||
"paint.historyBrush",
|
||||
"paint.redEye",
|
||||
"paint.remove",
|
||||
];
|
||||
|
||||
/// Pasting into a targeted mask or channel (#1035).
|
||||
const PASTE_IDS: &[&str] = &["edit.paste", "edit.pasteSpecial.pasteInPlace", "edit.pasteSpecial.pasteInto", "edit.pasteSpecial.pasteOutside"];
|
||||
|
||||
/// Commands whose target follows the Channels panel.
|
||||
pub(crate) fn routed(id: &str) -> bool {
|
||||
id.starts_with("filter.")
|
||||
|| id == "plugin.run"
|
||||
|| id.starts_with("image.adjustments.")
|
||||
|| matches!(id, "paint.stroke" | "paint.pencil" | "paint.bucket" | "paint.gradient" | "paint.mixerBrush" | "edit.fill" | "image.applyImage")
|
||||
|| matches!(id, "edit.fill" | "image.applyImage")
|
||||
|| crate::fill_key_cmds::IDS.contains(&id)
|
||||
|| PAINT_IDS.contains(&id)
|
||||
// Pasting into a targeted mask or channel (#1035); Copy takes a targeted mask (#2928).
|
||||
|| matches!(id, "edit.copy" | "edit.paste" | "edit.pasteSpecial.pasteInPlace" | "edit.pasteSpecial.pasteInto" | "edit.pasteSpecial.pasteOutside")
|
||||
|| id == "edit.copy"
|
||||
|| PASTE_IDS.contains(&id)
|
||||
// Edit › Transform › Rotate/Flip turn a targeted mask or channel (#2835).
|
||||
|| matches!(
|
||||
id,
|
||||
@@ -733,22 +800,6 @@ pub(crate) fn routed(id: &str) -> bool {
|
||||
| "edit.transform.flipHorizontal"
|
||||
| "edit.transform.flipVertical"
|
||||
)
|
||||
|| matches!(
|
||||
id,
|
||||
"paint.cloneStamp"
|
||||
| "paint.patternStamp"
|
||||
| "paint.healingBrush"
|
||||
| "paint.spotHealing"
|
||||
| "paint.dodge"
|
||||
| "paint.burn"
|
||||
| "paint.sponge"
|
||||
| "paint.blur"
|
||||
| "paint.sharpen"
|
||||
| "paint.smudge"
|
||||
| "paint.historyBrush"
|
||||
| "paint.redEye"
|
||||
| "paint.remove"
|
||||
)
|
||||
}
|
||||
|
||||
/// Fill in `"target"` from the targeted channel (or Quick Mask mode) when the caller gave none.
|
||||
@@ -762,7 +813,13 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value {
|
||||
ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"),
|
||||
ChannelTarget::LayerMask => json!("mask"),
|
||||
// Viewing the active layer's mask (⌥-click): pixel commands edit the mask.
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => json!("filterMask"),
|
||||
// A shown filter mask takes only the commands that edit it; the rest act on the smart object.
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => {
|
||||
if !follows_filter_mask(id) {
|
||||
return params;
|
||||
}
|
||||
json!("filterMask")
|
||||
}
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() => json!("mask"),
|
||||
_ => return params,
|
||||
};
|
||||
@@ -775,10 +832,11 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// The smart object whose filter mask a command with `params` edits (`"target":"filterMask"`):
|
||||
/// its `"layer"` param, else the active layer.
|
||||
pub(crate) fn filter_mask_layer(s: &Session, params: &Value) -> Option<LayerId> {
|
||||
if target_of(params) != Target::FilterMask {
|
||||
/// The smart object whose filter mask command `id` with `params` edits (`"target":"filterMask"`):
|
||||
/// its `"layer"` param, else the active layer. None for a command that doesn't edit the filter
|
||||
/// mask, so moving the smart object doesn't re-render its filters.
|
||||
pub(crate) fn filter_mask_layer(s: &Session, id: &str, params: &Value) -> Option<LayerId> {
|
||||
if target_of(params) != Target::FilterMask || !follows_filter_mask(id) {
|
||||
return None;
|
||||
}
|
||||
params.get("layer").and_then(Value::as_u64).map(LayerId).or_else(|| s.active()?.active_layer)
|
||||
|
||||
@@ -161,9 +161,9 @@ pub(crate) fn validate_params(id: &str, params: &Value) -> Result<()> {
|
||||
"layer" => (value.as_u64().is_some(), "an unsigned layer id".into()),
|
||||
"coalesce" => (value.is_string(), "a string".into()),
|
||||
"target" => (
|
||||
value.as_str().is_some_and(|s| matches!(s, "pixels" | "mask" | "quickMask"))
|
||||
value.as_str().is_some_and(|s| matches!(s, "pixels" | "mask" | "filterMask" | "quickMask"))
|
||||
|| value.as_object().is_some_and(|o| o.len() == 1 && o.get("channel").and_then(Value::as_u64).is_some()),
|
||||
"pixels, mask, quickMask or {\"channel\":index}".into(),
|
||||
"pixels, mask, filterMask, quickMask or {\"channel\":index}".into(),
|
||||
),
|
||||
_ => return Err(bad(format!("unknown parameter `{key}`"))),
|
||||
}
|
||||
|
||||
@@ -820,7 +820,7 @@ impl Session {
|
||||
let expand_new_fx = crate::commands::layer_panel_option(self, "expandNewEffects", true);
|
||||
self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string);
|
||||
self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params);
|
||||
self.filter_mask_layer = crate::channel_cmds::filter_mask_layer(self, &run_params);
|
||||
self.filter_mask_layer = crate::channel_cmds::filter_mask_layer(self, id, &run_params);
|
||||
self.jobs.spawn = background;
|
||||
// Commands this one runs on its own behalf journal after this mark (#3067).
|
||||
let nested_from = self.journal.len();
|
||||
|
||||
@@ -128,3 +128,74 @@ fn an_explicit_layer_checks_filter_mask_eligibility() {
|
||||
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &before));
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
|
||||
/// With its filter mask targeted, the commands that don't edit a filter mask act on the smart
|
||||
/// object as without the target: Edit › Transform flips it, and Convert for Smart Filters stays
|
||||
/// disabled (it would nest the smart object).
|
||||
#[test]
|
||||
fn the_filter_mask_target_leaves_the_smart_object_commands_alone() {
|
||||
let mut s = session(8);
|
||||
paint(&mut s);
|
||||
convert(&mut s);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
||||
stroke(&mut s, Some("filterMask")).unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
assert!(
|
||||
matches!(s.execute("filter.convertForSmartFilters", json!({"target": "filterMask"})), Err(EngineError::Disabled(..))),
|
||||
"the layer is already a smart object"
|
||||
);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
let before = active_smart(&s).transform;
|
||||
s.execute("edit.transform.flipHorizontal", json!({"target": "filterMask"})).unwrap();
|
||||
assert_ne!(active_smart(&s).transform, before, "the smart object flips");
|
||||
// A shown filter mask doesn't send them the target either.
|
||||
s.execute("view.layerMask", json!({"mode": "gray", "mask": "filter"})).unwrap();
|
||||
assert_eq!(crate::channel_cmds::inject_target(&s, "edit.transform.flipHorizontal", json!({})), json!({}));
|
||||
assert_eq!(crate::channel_cmds::inject_target(&s, "filter.convertForSmartFilters", json!({})), json!({}));
|
||||
assert_eq!(crate::channel_cmds::inject_target(&s, "paint.stroke", json!({}))["target"], "filterMask");
|
||||
// Curves on the shown filter mask edits that gray plane, not the layer mask or the colours.
|
||||
let curve = crate::channel_cmds::isolated_curve_channel(s.active().unwrap()).expect("a gray target");
|
||||
assert_eq!(curve.gray, Some(crate::channel_cmds::GraySurface::FilterMask));
|
||||
let flipped = active_smart(&s).transform;
|
||||
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
|
||||
assert_ne!(active_smart(&s).transform, flipped, "the smart object flips again");
|
||||
// Moving the smart object doesn't re-render its filters as a filter-mask edit.
|
||||
let p = json!({"dx": 2, "target": "filterMask"});
|
||||
assert_eq!(crate::channel_cmds::filter_mask_layer(&s, "layer.translate", &p), None);
|
||||
assert!(crate::channel_cmds::filter_mask_layer(&s, "paint.stroke", &p).is_some());
|
||||
// Every other routed command edits it and re-renders the object: Equalize too, though it
|
||||
// takes no layer mask target by default.
|
||||
for cmd in ["image.adjustments.shadowsHighlights", "plugin.run", "filter.blur.average"] {
|
||||
assert!(crate::channel_cmds::filter_mask_layer(&s, cmd, &p).is_some(), "{cmd}");
|
||||
}
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.execute("image.adjustments.equalize", json!({"target": "filterMask"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
|
||||
assert_eq!(active_smart(&s).smart_filters.len(), 1, "no new smart filter: the filter mask was equalized");
|
||||
}
|
||||
|
||||
/// Gaussian Blur with the filter mask targeted softens the mask's edge (the usual way to feather
|
||||
/// it), re-renders the smart object, and adds no second smart filter.
|
||||
#[test]
|
||||
fn blurring_the_filter_mask_feathers_it() {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(depth);
|
||||
paint(&mut s);
|
||||
convert(&mut s);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
||||
stroke(&mut s, Some("filterMask")).unwrap();
|
||||
let hard = active_smart(&s).filter_mask.unwrap();
|
||||
let edge = (9..W).find(|&x| hard.value(x, 20) == 1.0).expect("white right of the stroke");
|
||||
let px = flat(&s);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3, "target": "filterMask"})).unwrap();
|
||||
let soft = active_smart(&s).filter_mask.unwrap();
|
||||
let v = soft.value(edge, 20);
|
||||
assert!(v > 0.0 && v < 1.0, "depth {depth}: the mask edge is soft, got {v}");
|
||||
assert_eq!(soft.value(30, 20), 1.0, "depth {depth}: far from the stroke stays white");
|
||||
assert_eq!(active_smart(&s).smart_filters.len(), 1, "depth {depth}: no second smart filter");
|
||||
assert_ne!(flat(&s), px, "depth {depth}: the smart object is re-rendered");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "depth {depth}: one step");
|
||||
}
|
||||
}
|
||||
@@ -194,8 +194,8 @@ pub fn lone_target<'a>(doc: &'a Document, layer: Option<LayerId>, p: &Value) ->
|
||||
let m = l.mask.as_ref().ok_or_else(|| EngineError::Other("layer has no mask".into()))?;
|
||||
Ok((!m.linked).then_some(&m.surface))
|
||||
}
|
||||
// A filter mask is not transformed on its own (it follows its smart object).
|
||||
Target::FilterMask => Err(EngineError::Other("Free Transform can't transform a filter mask on its own".into())),
|
||||
// A filter mask is not transformed on its own: the smart object transforms, as in Photoshop.
|
||||
Target::FilterMask => Ok(None),
|
||||
Target::Alpha(i) => doc
|
||||
.channels
|
||||
.get(i)
|
||||
|
||||
@@ -307,10 +307,14 @@ pub(crate) fn remember(app: &mut PhotocraftApp, fields: &Map<String, Value>) {
|
||||
}
|
||||
|
||||
/// The Curves dialog's single-channel target: the Channels panel's, plus the Layers panel's mask
|
||||
/// selection — both ways of selecting a mask must give the same dropdown (#5335).
|
||||
/// selection — both ways of selecting a mask must give the same dropdown (#5335). A targeted
|
||||
/// filter mask is a gray plane too (FILE-215-3).
|
||||
fn isolated(app: &PhotocraftApp) -> Option<photocraft_engine::channel_cmds::IsolatedCurveChannel> {
|
||||
let mask = app.layer_mask_targeted();
|
||||
app.session.active().and_then(|st| photocraft_engine::channel_cmds::isolated_curve_channel_for(st, mask))
|
||||
let st = app.session.active()?;
|
||||
if app.filter_mask_targeted() {
|
||||
return photocraft_engine::channel_cmds::filter_mask_curve_channel(st);
|
||||
}
|
||||
photocraft_engine::channel_cmds::isolated_curve_channel_for(st, app.layer_mask_targeted())
|
||||
}
|
||||
|
||||
/// The colour mode the Levels/Curves dialog seeds and parses in: a mask, saved channel or
|
||||
@@ -1210,4 +1214,28 @@ mod tests {
|
||||
fn popup_channels(h: &mut Harness<'static, PhotocraftApp>) -> Vec<String> {
|
||||
h.query_all_by_role(egui::accesskit::Role::ComboBox).filter_map(|n| n.value().map(|s| s.to_string())).collect()
|
||||
}
|
||||
|
||||
/// FILE-215-3: Curves on a targeted filter mask edits a gray plane, and its histogram is the
|
||||
/// filter mask's, not the smart object's colours.
|
||||
#[test]
|
||||
fn curves_on_a_targeted_filter_mask_is_a_gray_plane() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||||
app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
app.run("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
|
||||
let id = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.select_filter_mask_target(Some(id.0));
|
||||
assert!(app.filter_mask_targeted());
|
||||
let c = isolated(&app).expect("a gray target");
|
||||
assert_eq!(c.gray, Some(photocraft_engine::channel_cmds::GraySurface::FilterMask));
|
||||
// Its histogram counts the filter mask's levels: half black after a stroke.
|
||||
app.run("paint.stroke", json!({"points": [[8, 0], [8, 31]], "size": 16, "hardness": 1, "color": "#000000", "target": "filterMask"})).unwrap();
|
||||
let surface = photocraft_engine::channel_cmds::GraySurface::FilterMask;
|
||||
let st = app.session.active().unwrap();
|
||||
let h = tone::compute_histograms(&st.doc, HistSource::Channel { layer: id, surface }, photocraft_doc::adjust::ToneSpace::Rgb);
|
||||
assert!(h.ch[0][0] > 0 && h.ch[0][255] > 0, "black and white levels");
|
||||
assert_eq!(curve_mode(&app, "curves"), photocraft_doc::ColorMode::Grayscale);
|
||||
}
|
||||
}
|
||||
@@ -585,8 +585,20 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
let mask_target = bool_field(p, "maskTarget")?;
|
||||
let updates_open = bool_field(p, "updatesOpen")?;
|
||||
let vector_mask_target = bool_field(p, "vectorMaskTarget")?;
|
||||
// Target the active smart object's filter mask (FILE-215-3).
|
||||
// Target the active smart object's filter mask (FILE-215-3): it needs smart filters.
|
||||
let filter_mask_target = bool_field(p, "filterMaskTarget")?;
|
||||
if filter_mask_target == Some(true) {
|
||||
let st = app.session.active().ok_or("filterMaskTarget needs an open document")?;
|
||||
let l = st.active_layer.and_then(|id| st.doc.layer(id)).ok_or("filterMaskTarget needs an active layer")?;
|
||||
if !matches!(&l.content, photocraft_doc::LayerContent::Smart(sm) if !sm.smart_filters.is_empty()) {
|
||||
return Err("filterMaskTarget needs an active smart object with smart filters".into());
|
||||
}
|
||||
use photocraft_engine::channel_cmds::ChannelTarget;
|
||||
let composite = matches!(st.channel_view.target, ChannelTarget::Composite | ChannelTarget::LayerMask | ChannelTarget::VectorMask);
|
||||
if !composite || st.doc.quick_mask.is_some() {
|
||||
return Err("filterMaskTarget can't be set while a single channel or Quick Mask is targeted".into());
|
||||
}
|
||||
}
|
||||
let selection_mode = uint_field(p, "selectionMode")?;
|
||||
let dock_tabs = merged_object(&app.ui.dock_tabs, p.get("dockTabs"), "dockTabs")?;
|
||||
let dock = whole_object(&app.ui.dock, p.get("dock"), "dock")?;
|
||||
@@ -747,6 +759,8 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
app.ui.panels = v;
|
||||
}
|
||||
if let Some(m) = mask_target {
|
||||
app.end_filter_mask_view();
|
||||
app.ui.filter_mask_target = None;
|
||||
app.ui.mask_target = m;
|
||||
app.ui.properties_layer = None;
|
||||
app.ui.vector_mask_target &= !m;
|
||||
@@ -756,6 +770,8 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
app.ui.updates_open = open;
|
||||
}
|
||||
if let Some(m) = vector_mask_target {
|
||||
app.end_filter_mask_view();
|
||||
app.ui.filter_mask_target = None;
|
||||
app.ui.vector_mask_target = m;
|
||||
app.ui.mask_target &= !m;
|
||||
app.sync_mask_targets();
|
||||
@@ -3335,4 +3351,71 @@ mod tests {
|
||||
let shift_alt = key_modifiers(&mut app, &ctx, json!({"key":"N","shift":true,"alt":true}));
|
||||
assert!(shift_alt.shift && shift_alt.alt && !shift_alt.command && !shift_alt.ctrl, "{shift_alt:?}");
|
||||
}
|
||||
|
||||
/// FILE-215-3: `filterMaskTarget` on a layer without a filter mask is refused before anything
|
||||
/// changes, so the layer mask stays targeted.
|
||||
#[test]
|
||||
fn ui_set_filter_mask_target_needs_a_smart_object_with_filters() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||||
app.run("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"maskTarget": true}))["ok"], true);
|
||||
let bad = call(&mut app, &ctx, "ui.set", json!({"filterMaskTarget": true}));
|
||||
assert_eq!(bad["ok"], false, "{bad}");
|
||||
assert!(app.ui.mask_target, "the layer mask stays targeted");
|
||||
assert_eq!(app.ui.filter_mask_target, None);
|
||||
// A smart object with smart filters takes it.
|
||||
app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
app.run("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
|
||||
// Not while a single colour channel is targeted: nothing would end up targeted.
|
||||
app.session.active_mut().unwrap().channel_view.target = photocraft_engine::channel_cmds::ChannelTarget::Color(0);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"filterMaskTarget": true}))["ok"], false);
|
||||
app.session.active_mut().unwrap().channel_view.target = photocraft_engine::channel_cmds::ChannelTarget::Composite;
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"filterMaskTarget": true}))["ok"], true);
|
||||
assert!(app.filter_mask_targeted());
|
||||
// Untargeting it ends its view, which would otherwise keep it targeted.
|
||||
app.run("paint.stroke", json!({"points": [[4, 4]], "size": 4, "hardness": 1, "color": "#000000", "target": "filterMask"})).unwrap();
|
||||
app.run("view.layerMask", json!({"mode": "gray", "mask": "filter"})).unwrap();
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"filterMaskTarget": false}))["ok"], true);
|
||||
assert!(!app.filter_mask_targeted());
|
||||
assert!(photocraft_engine::mask_view_cmds::current(app.session.active().unwrap()).is_none());
|
||||
// Targeting the pixels untargets it too.
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"filterMaskTarget": true}))["ok"], true);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"maskTarget": false}))["ok"], true);
|
||||
assert!(!app.filter_mask_targeted());
|
||||
assert_eq!(crate::canvas::paint_target(&app), json!("pixels"));
|
||||
}
|
||||
|
||||
/// FILE-215-3: with the filter mask shown (⌥-click its thumbnail), targeting the layer mask
|
||||
/// ends that view, and the brush paints the layer mask.
|
||||
#[test]
|
||||
fn ui_set_mask_target_ends_a_filter_mask_view() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||||
app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
app.run("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
|
||||
app.run("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
app.run("paint.stroke", json!({"points": [[4, 4]], "size": 4, "hardness": 1, "color": "#000000", "target": "filterMask"})).unwrap();
|
||||
app.run("view.layerMask", json!({"mode": "gray", "mask": "filter"})).unwrap();
|
||||
app.sync_mask_targets();
|
||||
assert!(app.filter_mask_targeted());
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"maskTarget": true}))["ok"], true);
|
||||
assert!(photocraft_engine::mask_view_cmds::current(app.session.active().unwrap()).is_none(), "the filter mask view ends");
|
||||
assert!(app.layer_mask_targeted());
|
||||
assert!(!app.filter_mask_targeted());
|
||||
assert_eq!(crate::canvas::paint_target(&app), json!("mask"));
|
||||
let target = crate::canvas::paint_target(&app);
|
||||
app.run("paint.stroke", json!({"points": [[20, 20]], "size": 4, "hardness": 1, "color": "#000000", "target": target})).unwrap();
|
||||
let st = app.session.active().unwrap();
|
||||
let l = st.doc.layer(st.active_layer.unwrap()).unwrap();
|
||||
assert_eq!(l.mask.as_ref().unwrap().value(20, 20), 0.0, "the stroke went into the layer mask");
|
||||
let photocraft_doc::LayerContent::Smart(sm) = &l.content else { panic!("smart object") };
|
||||
assert_eq!(sm.filter_mask.as_ref().unwrap().value(20, 20), 1.0, "not into the filter mask");
|
||||
}
|
||||
}
|
||||
@@ -2599,8 +2599,9 @@ impl PhotocraftApp {
|
||||
if !photocraft_engine::channel_cmds::follows_target(id) || params.get("target").is_some() {
|
||||
return params;
|
||||
}
|
||||
// A filter mask is painted, filled and filtered in place; it doesn't float.
|
||||
let target = if self.filter_mask_targeted() && id != "select.float" {
|
||||
// A filter mask is painted, filled and filtered in place; it doesn't float or move, and
|
||||
// Edit › Transform, Apply Image and the dialog filters act on the smart object.
|
||||
let target = if self.filter_mask_targeted() && photocraft_engine::channel_cmds::follows_filter_mask(id) {
|
||||
"filterMask"
|
||||
} else if self.layer_mask_targeted() {
|
||||
"mask"
|
||||
@@ -2644,8 +2645,23 @@ impl PhotocraftApp {
|
||||
colors_off && !alpha_on
|
||||
}
|
||||
|
||||
/// Explicitly targeting a filter mask ends any conflicting mask view.
|
||||
/// Targeting the pixels, the layer mask or the vector mask ends a shown filter mask (⌥-click
|
||||
/// on its thumbnail): while it shows, [`Self::sync_mask_targets`] keeps the filter mask
|
||||
/// targeted, so strokes would keep landing there.
|
||||
pub(crate) fn end_filter_mask_view(&mut self) {
|
||||
let Some(v) = self.session.active().and_then(photocraft_engine::mask_view_cmds::current).filter(|v| v.filter) else { return };
|
||||
let off = serde_json::json!({"layer": v.layer.0, "mode": "off", "mask": "filter"});
|
||||
if let Err(err) = self.session.execute(photocraft_engine::mask_view_cmds::ID, off) {
|
||||
log::warn!("could not end the filter mask view: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly targeting a filter mask ends any conflicting mask view; untargeting it ends its
|
||||
/// own view too.
|
||||
pub(crate) fn select_filter_mask_target(&mut self, layer: Option<u64>) {
|
||||
if layer.is_none() {
|
||||
self.end_filter_mask_view();
|
||||
}
|
||||
if layer.is_some() {
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
if st.channel_view.layer_mask.is_some_and(|v| !v.filter || Some(v.layer.0) != layer) {
|
||||
|
||||
@@ -2942,6 +2942,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
crate::type_tool::commit(app);
|
||||
}
|
||||
if id == "ui.maskTarget" {
|
||||
app.end_filter_mask_view();
|
||||
app.ui.mask_target = p.as_bool().unwrap_or(false);
|
||||
app.ui.vector_mask_target = false;
|
||||
app.ui.filter_mask_target = None;
|
||||
@@ -2949,6 +2950,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
continue;
|
||||
}
|
||||
if id == "ui.vectorMaskTarget" {
|
||||
app.end_filter_mask_view();
|
||||
app.ui.vector_mask_target = p.as_bool().unwrap_or(false);
|
||||
app.ui.mask_target = false;
|
||||
app.ui.filter_mask_target = None;
|
||||
@@ -3363,9 +3365,10 @@ fn layer_row(
|
||||
let on_mask = mask_rect.zip(pos).is_some_and(|(r, p)| r.expand(2.0).contains(p));
|
||||
let on_vector = pos.and_then(|p| masks.hit(p)) == Some(crate::mask_thumbs_ui::MaskKind::Vector);
|
||||
// Clicking the layer thumbnail leaves mask view (#196).
|
||||
let viewing = app.session.active().and_then(photocraft_engine::mask_view_cmds::current).is_some_and(|v| v.layer == l.id);
|
||||
if on_thumb && viewing {
|
||||
actions.push((photocraft_engine::mask_view_cmds::ID.into(), json!({"layer": l.id.0, "mode": "off"})));
|
||||
let viewing = app.session.active().and_then(photocraft_engine::mask_view_cmds::current).filter(|v| v.layer == l.id);
|
||||
if let Some(v) = viewing.filter(|_| on_thumb) {
|
||||
let mask = if v.filter { "filter" } else { "layer" };
|
||||
actions.push((photocraft_engine::mask_view_cmds::ID.into(), json!({"layer": l.id.0, "mode": "off", "mask": mask})));
|
||||
}
|
||||
let content_less = matches!(l.content, LayerContent::Adjustment(_) | LayerContent::Fill(_));
|
||||
if on_vector {
|
||||
|
||||
@@ -287,6 +287,12 @@ pub(crate) fn delete_key_command(app: &PhotocraftApp, id: String) -> String {
|
||||
return id;
|
||||
}
|
||||
let Some(st) = app.session.active() else { return id };
|
||||
// A targeted filter mask (FILE-215-3) takes a selection's Delete as a layer mask does: the
|
||||
// selection fills with the background colour there. With no selection Photoshop deletes the
|
||||
// layer, as below.
|
||||
if app.filter_mask_targeted() && st.doc.selection.is_some() {
|
||||
return "edit.fillBackground".into();
|
||||
}
|
||||
let composite = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::Composite && st.doc.quick_mask.is_none();
|
||||
let has_mask = st.active_layer.and_then(|l| st.doc.layer(l)).is_some_and(|l| l.mask.is_some());
|
||||
let colors_off = st.channel_view.visible_colors(photocraft_engine::channel_cmds::color_count(&st.doc)) == 0;
|
||||
|
||||
@@ -572,6 +572,41 @@ fn delete_with_the_mask_targeted_edits_the_mask_not_the_layer() {
|
||||
}
|
||||
}
|
||||
|
||||
/// FILE-215-3: with a smart object's filter mask targeted, Delete with a selection fills the filter
|
||||
/// mask with the background colour there (not Edit › Clear on the smart object); with no
|
||||
/// selection Photoshop deletes the layer, as it does with the pixels targeted.
|
||||
#[test]
|
||||
fn delete_with_the_filter_mask_targeted_edits_the_filter_mask() {
|
||||
let mut h = harness();
|
||||
put_focus(&mut h, Place::Canvas);
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
|
||||
let layer = s.active().unwrap().active_layer.unwrap();
|
||||
let sel = s.active().unwrap().doc.selection.as_ref().expect("the harness keeps a selection").content_bounds();
|
||||
let (x, y) = ((sel.x0 + sel.x1) / 2, (sel.y0 + sel.y1) / 2);
|
||||
h.state_mut().select_filter_mask_target(Some(layer.0));
|
||||
assert!(h.state().filter_mask_targeted());
|
||||
h.run_steps(2);
|
||||
let smart = |h: &Harness<'_, PhotocraftApp>| match h.state().session.active().unwrap().doc.layer(layer).map(|l| &l.content) {
|
||||
Some(photocraft_doc::LayerContent::Smart(sm)) => Some(sm.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let filters = smart(&h).unwrap().smart_filters.len();
|
||||
let bg = h.state().session.tools.background;
|
||||
press(&mut h, "Delete");
|
||||
assert_eq!(logged(&h), ["edit.fillBackground"]);
|
||||
let sm = smart(&h).expect("the smart object stays");
|
||||
assert_eq!(sm.filter_mask.as_ref().map(|m| m.value(x, y)), Some(bg[0]), "the filter mask takes the background colour");
|
||||
assert_eq!(bg[0], 0.0, "a targeted mask's background colour is black");
|
||||
assert_eq!(sm.smart_filters.len(), filters);
|
||||
h.state_mut().session.execute("select.deselect", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
press(&mut h, "Delete");
|
||||
assert_eq!(logged(&h), ["layer.delete"]);
|
||||
assert!(smart(&h).is_none(), "no selection deletes the layer, as in Photoshop");
|
||||
}
|
||||
|
||||
/// Every selected layer goes, pixel layers too (Photoshop deletes, it doesn't clear the layer).
|
||||
#[test]
|
||||
fn delete_without_a_selection_deletes_every_selected_layer() {
|
||||
|
||||
@@ -801,4 +801,44 @@ mod tests {
|
||||
assert_eq!(smart(&app, doc).smart_filters.len(), 1);
|
||||
assert!(photocraft_compose::flatten(&shown).px == photocraft_compose::flatten(doc).px, "OK gives what the preview showed");
|
||||
}
|
||||
|
||||
/// FILE-215-3: with the filter mask targeted, Move and Edit › Transform act on the smart object
|
||||
/// (no filter-mask target, so a move doesn't re-render the filters), and painting and filters
|
||||
/// still edit the filter mask.
|
||||
#[test]
|
||||
fn only_filter_mask_edits_carry_the_filter_mask_target() {
|
||||
let mut app = PhotocraftApp::new(Session::new(), Services::default());
|
||||
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||||
app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
app.run("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
|
||||
let id = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.select_filter_mask_target(Some(id.0));
|
||||
assert!(app.filter_mask_targeted());
|
||||
for cmd in [
|
||||
"layer.translate",
|
||||
"select.float",
|
||||
"edit.transform.rotate180",
|
||||
"filter.convertForSmartFilters",
|
||||
"image.applyImage",
|
||||
"filter.liquify",
|
||||
"filter.lastFilter",
|
||||
"image.adjustments.desaturate",
|
||||
"filter.render.flame",
|
||||
] {
|
||||
assert!(app.with_mask_target(cmd, json!({"dx": 1})).get("target").is_none(), "{cmd}");
|
||||
}
|
||||
for cmd in [
|
||||
"paint.stroke",
|
||||
"filter.blur.gaussianBlur",
|
||||
"edit.fill",
|
||||
"edit.paste",
|
||||
"image.adjustments.invert",
|
||||
"image.adjustments.equalize",
|
||||
"filter.blur.average",
|
||||
] {
|
||||
assert_eq!(app.with_mask_target(cmd, json!({}))["target"], "filterMask", "{cmd}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ fn tag(source: HistSource, space: ToneSpace) -> u64 {
|
||||
photocraft_engine::channel_cmds::GraySurface::LayerMask => (3, layer.0),
|
||||
photocraft_engine::channel_cmds::GraySurface::Alpha(i) => (4, i as u64),
|
||||
photocraft_engine::channel_cmds::GraySurface::QuickMask => (5, layer.0),
|
||||
photocraft_engine::channel_cmds::GraySurface::FilterMask => (6, layer.0),
|
||||
},
|
||||
};
|
||||
let s = match space {
|
||||
@@ -96,6 +97,9 @@ pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace)
|
||||
photocraft_engine::channel_cmds::GraySurface::LayerMask => doc.layer(layer).and_then(|l| l.mask.as_ref()).map(|m| &m.surface),
|
||||
photocraft_engine::channel_cmds::GraySurface::Alpha(i) => doc.channels.get(i).map(|c| &c.surface),
|
||||
photocraft_engine::channel_cmds::GraySurface::QuickMask => doc.quick_mask.as_ref().map(|c| &c.surface),
|
||||
photocraft_engine::channel_cmds::GraySurface::FilterMask => {
|
||||
doc.layer(layer).and_then(|l| photocraft_engine::mask_view_cmds::viewed_mask(l, true)).map(|m| &m.surface)
|
||||
}
|
||||
};
|
||||
if let Some(s) = surf
|
||||
&& let Some(bin) = h.first_mut()
|
||||
|
||||
@@ -47,7 +47,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI)
|
||||
- `engine.commands`: list commands with enablement; the `result` is a JSON array of command objects (see [Command list replies](#command-list-replies))
|
||||
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], size: [w, h]?, list: {collapsed, filter, renaming, showName, showStroke, showTip, scale}}` while the Brush Preset picker is open (coordinates in screen points; `size` is the picker's content size once its corner grip was dragged, `null` before that, and the drag remembers both dimensions; the three `show*` flags pick the parts of each preset's card — at least one is always on; `scale` is the footer slider, 0.15–2 with 1 the standard, multiplying the cards' width and height — the heights stop at half size and the name and tip-size text hold their 1.0 size — and dropping the tips' size numbers at 0.40 and below; the parts and the scale are remembered across restarts, while the search, collapsed groups and a rename in progress are not). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, filterMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background?, mode?, ramp?, webSafeRamp?}`, merged into the Color panel's state: `background` is whether it edits the background colour, `mode` its panel-menu display (`hueCube`, `brightnessCube`, `colorWheel`, `grayscaleSlider`, `rgbSliders`, `hsbSliders`, `cmykSliders`, `labSliders` or `webColorSliders`), `ramp` the colour ramp under the sliders (`rgbSpectrum`, `cmykSpectrum`, `grayscaleRamp` or `currentColors`) and `webSafeRamp` Make Ramp Web Safe; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again; `filterMaskTarget` targets the active smart object's filter mask, so painting, fills, filters and adjustments edit it (`"target":"filterMask"`)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, filterMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerName?, brushPickerStroke?, brushPickerTip?, brushPickerScale?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` is `list` or `grid` for the Brushes panel; `brushPickerName`, `brushPickerStroke` and `brushPickerTip` are the Brush Preset picker's card parts (booleans, at least one on), and `brushPickerScale` its footer card-scale slider (0.15 to 2, 1 the standard); `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"], tabOrder: {"layers": ["Paths", "Layers", "Channels"]}}`, the right-dock groups top to bottom, their heights in points, the groups collapsed to their tab strip and each group's tab order left to right by panel name (a group left out keeps its default order, unknown names are ignored and tabs not listed follow in their default order; `dockTabs` keeps indexing tabs in their default order); `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background?, mode?, ramp?, webSafeRamp?}`, merged into the Color panel's state: `background` is whether it edits the background colour, `mode` its panel-menu display (`hueCube`, `brightnessCube`, `colorWheel`, `grayscaleSlider`, `rgbSliders`, `hsbSliders`, `cmykSliders`, `labSliders` or `webColorSliders`), `ramp` the colour ramp under the sliders (`rgbSpectrum`, `cmykSpectrum`, `grayscaleRamp` or `currentColors`) and `webSafeRamp` Make Ramp Web Safe; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again; `filterMaskTarget` targets the active smart object's filter mask (an error without smart filters), so painting, fills, filters and adjustments edit it (`"target":"filterMask"`) while Move and Edit › Transform still act on the smart object)
|
||||
- `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state.
|
||||
- `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once. Window ends with the open documents as `window.document.<index>` (tab order, the active one checked, #2991); invoking one brings that document to the front and replies `{document: index}`
|
||||
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
|
||||
|
||||
Reference in new issue
Block a user