edit.transform without a "target" (or "layer") param now follows the Channels panel: a targeted alpha channel or the Quick Mask transforms instead of the layer. Edit › Transform › Rotate 180°/90° and Flip Horizontal/Vertical are routed to the panel target like the paint and filter commands, so they turn a targeted alpha channel, the Quick Mask or an unlinked layer mask within its frame (one undo step). The Background no longer greys the transforms when a channel is targeted. Flips scorecard row BUG-207-4. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d79fc5e8ef
commit
7609834df8
|
|
@ -569,6 +569,15 @@ fn routed(id: &str) -> bool {
|
|||
|| crate::fill_key_cmds::IDS.contains(&id)
|
||||
// Pasting into a targeted mask or channel (#1035).
|
||||
|| matches!(id, "edit.paste" | "edit.pasteSpecial.pasteInPlace" | "edit.pasteSpecial.pasteInto" | "edit.pasteSpecial.pasteOutside")
|
||||
// Edit › Transform › Rotate/Flip turn a targeted mask or channel (#2835).
|
||||
|| matches!(
|
||||
id,
|
||||
"edit.transform.rotate180"
|
||||
| "edit.transform.rotate90Cw"
|
||||
| "edit.transform.rotate90Ccw"
|
||||
| "edit.transform.flipHorizontal"
|
||||
| "edit.transform.flipVertical"
|
||||
)
|
||||
|| matches!(
|
||||
id,
|
||||
"paint.cloneStamp"
|
||||
|
|
|
|||
|
|
@ -333,11 +333,21 @@ fn clouds(s: &mut Session, p: &Value, difference: bool) -> Result<Value> {
|
|||
|
||||
// ---------- transform presets ----------
|
||||
|
||||
fn transform_preset(s: &mut Session, kind: &str) -> Result<Value> {
|
||||
/// The presets' params: like Free Transform, they follow the Channels and Layers panel target.
|
||||
const TRANSFORM_TARGET: &str =
|
||||
r#"{"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask turns alone)}"#;
|
||||
|
||||
fn transform_preset(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?;
|
||||
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let b = crate::transform_cmds::transform_bounds(&d.doc, l);
|
||||
// A targeted alpha channel, the Quick Mask or an unlinked layer mask turns by itself (#2835).
|
||||
let (b, mut params) = match crate::transform_cmds::lone_target(&d.doc, d.active_layer, p)? {
|
||||
Some(surf) => (crate::transform_cmds::target_bounds(&d.doc, surf), json!({"target": p.get("target")})),
|
||||
None => {
|
||||
let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?;
|
||||
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
(crate::transform_cmds::transform_bounds(&d.doc, l), json!({"layer": id.0}))
|
||||
}
|
||||
};
|
||||
if b.is_empty() {
|
||||
return Err(EngineError::Other("nothing to transform".into()));
|
||||
}
|
||||
|
|
@ -351,7 +361,9 @@ fn transform_preset(s: &mut Session, kind: &str) -> Result<Value> {
|
|||
"flipHorizontal" => [-1.0, 0.0, 0.0, 1.0, sx, 0.0],
|
||||
_ => [1.0, 0.0, 0.0, -1.0, 0.0, sy],
|
||||
};
|
||||
s.execute("edit.transform", json!({"layer": id.0, "matrix": m, "interpolation": "nearest"}))
|
||||
params["matrix"] = json!(m);
|
||||
params["interpolation"] = json!("nearest");
|
||||
s.execute("edit.transform", params)
|
||||
}
|
||||
|
||||
// ---------- selection / clipboard ----------
|
||||
|
|
@ -723,14 +735,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_pixels,
|
||||
stroke
|
||||
),
|
||||
spec!("edit.transform.rotate180", "Rotate 180°", &["Edit", "Transform"], None, "{}", has_layer, |s, _| transform_preset(s, "rotate180")),
|
||||
spec!("edit.transform.rotate90Cw", "Rotate 90° Clockwise", &["Edit", "Transform"], None, "{}", has_layer, |s, _| transform_preset(s, "rotate90Cw")),
|
||||
spec!("edit.transform.rotate90Ccw", "Rotate 90° Counter Clockwise", &["Edit", "Transform"], None, "{}", has_layer, |s, _| transform_preset(
|
||||
spec!("edit.transform.rotate180", "Rotate 180°", &["Edit", "Transform"], None, TRANSFORM_TARGET, has_layer, |s, p| transform_preset(
|
||||
s,
|
||||
"rotate90Ccw"
|
||||
p,
|
||||
"rotate180"
|
||||
)),
|
||||
spec!("edit.transform.rotate90Cw", "Rotate 90° Clockwise", &["Edit", "Transform"], None, TRANSFORM_TARGET, has_layer, |s, p| transform_preset(
|
||||
s,
|
||||
p,
|
||||
"rotate90Cw"
|
||||
)),
|
||||
spec!("edit.transform.rotate90Ccw", "Rotate 90° Counter Clockwise", &["Edit", "Transform"], None, TRANSFORM_TARGET, has_layer, |s, p| {
|
||||
transform_preset(s, p, "rotate90Ccw")
|
||||
}),
|
||||
spec!("edit.transform.flipHorizontal", "Flip Horizontal", &["Edit", "Transform"], None, TRANSFORM_TARGET, has_layer, |s, p| transform_preset(
|
||||
s,
|
||||
p,
|
||||
"flipHorizontal"
|
||||
)),
|
||||
spec!("edit.transform.flipVertical", "Flip Vertical", &["Edit", "Transform"], None, TRANSFORM_TARGET, has_layer, |s, p| transform_preset(
|
||||
s,
|
||||
p,
|
||||
"flipVertical"
|
||||
)),
|
||||
spec!("edit.transform.flipHorizontal", "Flip Horizontal", &["Edit", "Transform"], None, "{}", has_layer, |s, _| transform_preset(s, "flipHorizontal")),
|
||||
spec!("edit.transform.flipVertical", "Flip Vertical", &["Edit", "Transform"], None, "{}", has_layer, |s, _| transform_preset(s, "flipVertical")),
|
||||
spec!(
|
||||
"edit.pasteSpecial.pasteInto",
|
||||
"Paste Into",
|
||||
|
|
|
|||
|
|
@ -391,7 +391,21 @@ fn quad_param(p: &Value) -> Option<[[f64; 2]; 4]> {
|
|||
Some(q)
|
||||
}
|
||||
|
||||
/// `p`, with the Channels panel's targeted alpha channel or Quick Mask as its `"target"` when it
|
||||
/// names neither a target nor a layer (#2835).
|
||||
fn with_panel_target(s: &Session, p: &Value) -> Value {
|
||||
let mut p = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if p.get("target").is_none()
|
||||
&& p.get("layer").is_none()
|
||||
&& let (Some(m), Some(Value::Object(t))) = (p.as_object_mut(), crate::channel_cmds::targeted_channel(s))
|
||||
{
|
||||
m.extend(t);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = &with_panel_target(s, p);
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let id = match p.get("layer").and_then(Value::as_u64) {
|
||||
Some(v) => Some(LayerId(v)),
|
||||
|
|
@ -752,6 +766,36 @@ mod tests {
|
|||
assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "layer untouched");
|
||||
}
|
||||
|
||||
/// The Channels panel targets an alpha channel: Edit › Transform › Flip Horizontal and a
|
||||
/// 50% `edit.transform` (no `target` param) work on the channel, not the layer (BUG-207-4).
|
||||
#[test]
|
||||
fn transforms_follow_the_targeted_alpha_channel() {
|
||||
let mut s = session();
|
||||
s.execute("channel.new", json!({})).unwrap();
|
||||
s.edit("paint channel", |doc, _| {
|
||||
let ch = &mut doc.channels[0].surface;
|
||||
ch.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]);
|
||||
ch.fill_rect(Rect::new(10, 10, 12, 20), &[0.5]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("channel.target", json!({"channel": 0})).unwrap();
|
||||
let layer = |s: &Session| crate::active_layer_of(s).unwrap().surface().unwrap().read_region(Rect::new(0, 0, 100, 100));
|
||||
let (pixels, past) = (layer(&s), s.active().unwrap().history.past_len());
|
||||
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
|
||||
// 8-bit channel: 0.5 is stored as 128/255.
|
||||
let ch = |s: &Session, x, y| (s.active().unwrap().doc.channels[0].surface.sample_channel(x, y, 0) * 10.0).round() / 10.0;
|
||||
assert_eq!((ch(&s, 19, 15), ch(&s, 10, 15)), (0.5, 1.0), "the channel flips within its frame");
|
||||
assert!(layer(&s) == pixels, "the layer is untouched");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one undo step");
|
||||
s.execute("edit.transform", json!({"matrix": [0.5, 0, 0, 0.5, 0, 0], "interpolation": "nearest"})).unwrap();
|
||||
assert_eq!((ch(&s, 7, 7), ch(&s, 15, 15)), (1.0, 0.0), "the channel scales to 50%");
|
||||
assert!(layer(&s) == pixels, "the layer is untouched");
|
||||
s.undo();
|
||||
s.undo();
|
||||
assert_eq!((ch(&s, 10, 15), ch(&s, 19, 15)), (0.5, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_targets_fail_gracefully() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -121,6 +121,11 @@ pub fn disabled(app: &crate::PhotocraftApp, id: &str) -> bool {
|
|||
if transform && crate::vector_ui::free_transform_path(app).is_some() {
|
||||
return false;
|
||||
}
|
||||
// So does a targeted alpha channel or the Quick Mask: it transforms instead of the layer (#2835).
|
||||
let target = crate::canvas::paint_target(app);
|
||||
if transform && (target.is_object() || target == "quickMask") {
|
||||
return false;
|
||||
}
|
||||
// Editable type can be scaled, rotated and skewed but not distorted (#2630): Photoshop greys
|
||||
// Distort and Perspective until the type is rasterized. A targeted unlinked mask or channel of
|
||||
// the type layer is pixels, and can be.
|
||||
|
|
@ -177,6 +182,12 @@ mod tests {
|
|||
// A selection makes Free Transform available on the Background.
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
assert!(!off(&s, "edit.freeTransform"));
|
||||
// So does a targeted alpha channel, which transforms instead of the Background (#2835).
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("channel.new", json!({})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": 0})).unwrap();
|
||||
let app = crate::PhotocraftApp::new(s, crate::Services::default());
|
||||
assert!(!disabled(&app, "edit.freeTransform") && !disabled(&app, "edit.transform.flipHorizontal"));
|
||||
}
|
||||
|
||||
/// With several layers selected, Rasterize is judged by the whole selection, not by the layer
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
|||
| [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) |
|
||||
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
|
||||
| [Distribution](#distribution) | 4 | 4 | 3 | |
|
||||
| [Open bugs](#open-bugs) | 22 | 2 | 8 | |
|
||||
| [Open bugs](#open-bugs) | 23 | 2 | 7 | |
|
||||
|
||||
## Performance
|
||||
|
||||
|
|
@ -297,7 +297,7 @@ Never-crash attribute on 28 of 28 crate roots.
|
|||
| BUG-207-1 | Retouch tools respect the paint target (mask, alpha channel, Quick Mask) | done | [#207](https://github.com/storytold/photocraft/issues/207) | run_stroke paints channel_cmds::target_surface; retouch_strokes_paint_the_targeted_mask (retouch_ui.rs) and engine retouch_cmds/tests.rs (e2444ce, #234) |
|
||||
| BUG-207-2 | Sample All Layers applies to Blur, Sharpen and Smudge | done | [#207](https://github.com/storytold/photocraft/issues/207) | retouch_ui.rs passes sampleAllLayers to paint.blur/sharpen/smudge; sample_all_layers_reaches_the_command (e2444ce, #234) |
|
||||
| BUG-207-3 | Smudge mixes premultiplied RGBA (no dark fringe at transparent edges) | done | [#207](https://github.com/storytold/photocraft/issues/207) | paint/src/retouch.rs premultiplies; smudge_into_transparency_leaves_no_dark_fringe (e2444ce, #234) |
|
||||
| BUG-207-4 | edit.transform transforms a targeted alpha channel | missing | [#207](https://github.com/storytold/photocraft/issues/207) | transform_cmds.rs transform() has no target handling |
|
||||
| BUG-207-4 | edit.transform transforms a targeted alpha channel | done | [#207](https://github.com/storytold/photocraft/issues/207) | edit.transform follows a targeted alpha channel or Quick Mask (explicit target since #242, Channels panel target with no target/layer param since #2835); Edit > Transform > Rotate/Flip are routed to the target too; transforms_follow_the_targeted_alpha_channel |
|
||||
| BUG-207-5 | Tests: per-tool mask/channel targeting, Sample All Layers, no smudge fringe | missing | [#207](https://github.com/storytold/photocraft/issues/207) | Only smudge_drags_colour_along_the_stroke (retouch.rs:407) |
|
||||
| BUG-208-1 | Shift while dragging a Rect/Elliptical Marquee constrains to a square/circle | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_corners applies Shift via Drag::marquee_mods; marquee_tests.rs::shift_during_the_drag_draws_a_square_and_the_readout_follows covers the drag |
|
||||
| BUG-208-2 | Alt while dragging a marquee draws from the centre | done | [#208](https://github.com/storytold/photocraft/issues/208) | canvas.rs marquee_corners centres Alt drags; marquee_tests.rs::alt_draws_from_the_centre_and_shift_alt_a_centred_circle verifies rectangles and ellipses |
|
||||
|
|
|
|||
|
|
@ -184,9 +184,9 @@ note = "paint/src/retouch.rs premultiplies; smudge_into_transparency_leaves_no_d
|
|||
[[item]]
|
||||
id = "BUG-207-4"
|
||||
target = "edit.transform transforms a targeted alpha channel"
|
||||
status = "missing"
|
||||
status = "done"
|
||||
issue = 207
|
||||
note = "transform_cmds.rs transform() has no target handling"
|
||||
note = "edit.transform follows a targeted alpha channel or Quick Mask (explicit target since #242, Channels panel target with no target/layer param since #2835); Edit > Transform > Rotate/Flip are routed to the target too; transforms_follow_the_targeted_alpha_channel"
|
||||
|
||||
[[item]]
|
||||
id = "BUG-207-5"
|
||||
|
|
|
|||
Loading…
Reference in New Issue