diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 321d0d1a..8f263c05 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -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" diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index e38a9d01..0a246168 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -333,11 +333,21 @@ fn clouds(s: &mut Session, p: &Value, difference: bool) -> Result { // ---------- transform presets ---------- -fn transform_preset(s: &mut Session, kind: &str) -> Result { +/// 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 { 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 { "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 { 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", diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 88939fc7..698fa4ac 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -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 { + 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(); diff --git a/crates/ui-egui/src/enable_rules.rs b/crates/ui-egui/src/enable_rules.rs index 05cf7b6a..cee8e547 100644 --- a/crates/ui-egui/src/enable_rules.rs +++ b/crates/ui-egui/src/enable_rules.rs @@ -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 diff --git a/docs/scorecard.md b/docs/scorecard.md index 1d051c4d..30189841 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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 | diff --git a/scorecard/bugs.toml b/scorecard/bugs.toml index e9f5a01e..d5a82da8 100644 --- a/scorecard/bugs.toml +++ b/scorecard/bugs.toml @@ -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"