diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 463be4fb1..4b9aeffa2 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -163,7 +163,7 @@ pub fn select(s: &mut Session, p: &Value) -> Result { Ok(json!({"selected": selected(s).iter().map(|l| l.0).collect::>()})) } -fn is_locked_background(l: &Layer) -> bool { +pub(crate) fn is_locked_background(l: &Layer) -> bool { l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_)) } diff --git a/crates/engine/src/pick_cmds.rs b/crates/engine/src/pick_cmds.rs index 9500ba3ae..ccb3b4ca1 100644 --- a/crates/engine/src/pick_cmds.rs +++ b/crates/engine/src/pick_cmds.rs @@ -115,17 +115,90 @@ fn pick(s: &mut Session, p: &Value) -> Result { Ok(json!({ "layer": target.0 })) } +/// Layers whose content touches document rect `r`, top to bottom: visible, not fully locked, not +/// the locked Background (as Select › All Layers). A layer on an artboard counts only where the +/// board shows it. With `group`, each counts as its outermost group (deduplicated). +pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec { + let mut out = Vec::new(); + if r.is_empty() { + return out; + } + let canvas = doc.bounds(); + for (path, _, l) in doc.walk().iter().rev() { + if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) + || crate::layer_multi_cmds::is_locked_background(l) + || doc.effective_locks(l.id).all + || !(1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible)) + { + continue; + } + let mut b = photocraft_compose::layer_bounds(l, canvas); + if let Some(board) = path.first().and_then(|i| doc.layers.get(*i)).and_then(|t| t.artboard()) { + b = b.intersect(&board.rect); + } + if b.is_empty() || b.intersect(&r).is_empty() { + continue; + } + let id = if group { top_group(doc, l.id) } else { l.id }; + if !out.contains(&id) { + out.push(id); + } + } + out +} + +/// `layer.selectInRect`: the Move tool's marquee with Auto-Select, dragged from an empty spot. +fn select_in_rect(s: &mut Session, p: &Value) -> Result { + let num = |k: &str| { + p.get(k) + .and_then(Value::as_f64) + .filter(|v| v.is_finite()) + .ok_or_else(|| EngineError::BadParams { cmd: "layer.selectInRect".into(), msg: format!("missing or invalid `{k}`") }) + }; + let (x, y, w, h) = (num("x")?, num("y")?, num("width")?, num("height")?); + let px = |v: f64| v.clamp(f64::from(i32::MIN / 2), f64::from(i32::MAX / 2)) as i32; + // A rect dragged up or left has a negative size. + let r = Rect::new(px(x.min(x + w).floor()), px(y.min(y + h).floor()), px(x.max(x + w).ceil()), px(y.max(y + h).ceil())); + let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); + let group = p.get("target").and_then(Value::as_str) == Some("group"); + let hits = layers_in_rect(&doc, r, group); + let add = match p.get("mode").and_then(Value::as_str).unwrap_or("replace") { + "replace" => false, + "add" => true, + other => return Err(EngineError::BadParams { cmd: "layer.selectInRect".into(), msg: format!("unknown mode `{other}`") }), + }; + // Nothing touched leaves the layer selection as it is. + if !hits.is_empty() { + let mut ids = if add { s.active().map(|st| st.selected_layers()).unwrap_or_default() } else { Vec::new() }; + ids.extend(hits.iter().filter(|id| !ids.contains(id)).copied().collect::>()); + crate::layer_multi_cmds::set_selection(s, ids, hits.first().copied(), hits.first().copied())?; + } + Ok(json!({ "layers": hits.iter().map(|id| id.0).collect::>() })) +} + pub fn specs() -> Vec { - vec![CommandSpec { - id: "layer.pickAt", - label: "Auto-Select Layer", - menu: &[], - shortcut: None, - params: r##"{"x":px,"y":px,"target":"layer|group"="layer","select":bool=true,"mode":"replace|toggle|add"="replace","list":bool=false (return every layer with pixels there, topmost first)} → {layer} | {layers}"##, - enabled: has_doc, - journal: false, - run: pick, - }] + vec![ + CommandSpec { + id: "layer.selectInRect", + label: "Select Layers in Marquee", + menu: &[], + shortcut: None, + params: r##"{"x":px,"y":px,"width":px,"height":px,"target":"layer|group"="layer","mode":"replace|add"="replace"} → {layers} (the layers whose content touches the rect, topmost first; none leaves the selection as it is)"##, + enabled: has_doc, + journal: true, + run: select_in_rect, + }, + CommandSpec { + id: "layer.pickAt", + label: "Auto-Select Layer", + menu: &[], + shortcut: None, + params: r##"{"x":px,"y":px,"target":"layer|group"="layer","select":bool=true,"mode":"replace|toggle|add"="replace","list":bool=false (return every layer with pixels there, topmost first)} → {layer} | {layers}"##, + enabled: has_doc, + journal: false, + run: pick, + }, + ] } #[cfg(test)] @@ -330,6 +403,37 @@ mod tests { assert_eq!(s.execute("layer.pickAt", json!({"x": x, "y": y, "select": false})).unwrap()["layer"], t); } + #[test] + fn a_marquee_selects_the_layers_it_touches() { + let (mut s, a, b) = two_squares(); + s.execute("layer.new.layer", json!({"name": "Empty"})).unwrap(); + let sel = |s: &mut Session, r: [f64; 4], target: &str, mode: &str| { + let p = json!({"x": r[0], "y": r[1], "width": r[2], "height": r[3], "target": target, "mode": mode}); + let hit: Vec = s.execute("layer.selectInRect", p).unwrap()["layers"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + let mut now: Vec = s.active().unwrap().selected_layers().iter().map(|l| l.0).collect(); + now.sort_unstable(); + (hit, now) + }; + // Touching A's corner only (dragged up and left); never the empty layer or the Background. + assert_eq!(sel(&mut s, [-5.0, -5.0, 6.0, 6.0], "layer", "replace"), (vec![a.0], vec![a.0])); + assert_eq!(sel(&mut s, [50.0, 50.0, -27.0, -27.0], "layer", "replace"), (vec![b.0], vec![b.0])); + // ⇧ adds; a box over nothing leaves the selection alone. + assert_eq!(sel(&mut s, [0.0, 0.0, 5.0, 5.0], "layer", "add"), (vec![a.0], vec![a.0, b.0])); + assert_eq!(sel(&mut s, [11.0, 11.0, 8.0, 8.0], "layer", "replace"), (vec![], vec![a.0, b.0])); + assert_eq!(sel(&mut s, [-10.0, -10.0, 60.0, 60.0], "layer", "replace"), (vec![b.0, a.0], vec![a.0, b.0])); + // Group mode selects the outermost group; a hidden or fully locked layer is skipped. + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + let g = s.execute("layer.new.groupFromLayers", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(sel(&mut s, [0.0, 0.0, 40.0, 40.0], "group", "replace").0, vec![b.0, g]); + s.execute("layer.setProps", json!({"layer": b.0, "locks": {"all": true}})).unwrap(); + s.execute("layer.setProps", json!({"layer": g, "visible": false})).unwrap(); + assert_eq!(sel(&mut s, [0.0, 0.0, 40.0, 40.0], "layer", "replace").0, Vec::::new()); + for bad in [json!({}), json!({"x": 0, "y": 0, "width": f64::NAN, "height": 1}), json!({"x": 0, "y": 0, "width": 1, "height": 1, "mode": "x"})] { + assert!(s.execute("layer.selectInRect", bad).is_err()); + } + assert!(s.execute("layer.selectInRect", json!({"x": -1e300, "y": 1e300, "width": 1e308, "height": -1e308})).is_ok()); + } + #[test] fn hostile_params_are_errors_or_misses_not_panics() { let (mut s, _, _) = two_squares(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 20a92d947..2e7a459a7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3129,6 +3129,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // The pending crop frame shows on its own document only, not on the other tiles (#1918). let foreign_crop = if app.session.active_index() == Some(idx) { None } else { app.ui.crop_rect.take() }; draw_drag_preview(app, &painter, &xf); + if app.session.active_index() == Some(idx) { + crate::move_box::draw(app, &painter, &xf); + } if foreign_crop.is_some() { app.ui.crop_rect = foreign_crop; } @@ -4063,6 +4066,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } // View › Snap / Snap To and smart guides (snap_ui.rs). let raw = ev; + // The Move tool's marquee selecting layers takes its moves and release unsnapped (move_box.rs). + if crate::move_box::pointer(app, raw) { + return; + } // A press anywhere but on the floating piece (or with ⇧ / ⌥, to draw) drops it first; the // Move tool (⌘ with a painting tool too, `event_tool`) drags it from anywhere. if let ToolEvent::Down { x, y, .. } = raw @@ -4181,7 +4188,14 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) let target = app.ui.tool_options.move_target.clone(); let mode = if mods.shift { "add" } else { "replace" }; let before = app.session.active().map(|st| st.selected_layers()); - let _ = app.run("layer.pickAt", json!({"x": x, "y": y, "target": target, "mode": mode})); + let picked = app.run("layer.pickAt", json!({"x": x, "y": y, "target": target, "mode": mode})); + // No pixels there at all (an empty spot, or off the canvas): drag a marquee that + // selects the layers it touches instead (move_box.rs). A fully locked layer's + // pixels still say it is locked once the pointer moves (`move_lock`). + if picked.is_ok_and(|v| v["layer"].is_null()) && crate::move_box::empty_at(app, [x, y]) { + crate::move_box::begin(app, [x, y], mods.shift); + return; + } // Snapping (and the drag's box) started before the pick: point it at what moves. if app.session.active().map(|st| st.selected_layers()) != before { crate::snap_ui::retarget_move(app, [x, y], mods); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 546d339dc..b167bb5ac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -103,6 +103,7 @@ pub mod menu_catalog; pub mod menu_nav; pub mod menus; pub mod monitor_status; +pub mod move_box; pub mod move_hover; pub mod move_lock; pub mod move_mods; @@ -599,6 +600,8 @@ pub struct PhotocraftApp { info_sample: Option<(InfoSampleKey, [f32; 4])>, /// Guide being dragged (from a ruler or with the Move tool). pub(crate) guide_drag: Option, + /// Move tool marquee selecting layers (`move_box`). + pub(crate) layer_box: Option, /// Crop tool gesture in progress (see `crop_ui`). pub(crate) crop: crop_ui::CropState, /// Type tool layout cache: ((doc, revision, layer), layout). @@ -718,6 +721,7 @@ impl PhotocraftApp { type_transform_preview: None, type_font_preview: None, guide_drag: None, + layer_box: None, crop: Default::default(), hover_doc: None, clone_preview: None, diff --git a/crates/ui-egui/src/move_box.rs b/crates/ui-egui/src/move_box.rs new file mode 100644 index 000000000..f826992a3 --- /dev/null +++ b/crates/ui-egui/src/move_box.rs @@ -0,0 +1,117 @@ +//! Move tool marquee (#2844), as in Photoshop: with Auto-Select on (or ⌘ held with it off), a drag +//! that starts where no layer would be picked (an empty, transparent spot, or the pasteboard +//! outside the canvas) draws a box instead of moving anything. Releasing selects every layer whose +//! content touches the box (`layer.selectInRect`; Auto-Select: Group selects their outermost +//! groups), ⇧ adds them to the layers already selected. A click there selects nothing. + +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, ViewXform}; + +/// The marquee being dragged, in document pixels. +#[derive(Clone, Copy, Debug)] +pub(crate) struct LayerBox { + start: [f64; 2], + end: [f64; 2], + /// ⇧ at the press: add to the layer selection. + add: bool, +} + +/// A Move-tool press that Auto-Select found nothing under: start the marquee at `p`. The press +/// started a Move gesture's snapping and ⇧/⌥ handling, which a marquee has none of. +pub(crate) fn begin(app: &mut PhotocraftApp, p: [f64; 2], add: bool) { + app.layer_box = Some(LayerBox { start: p, end: p, add }); + app.prefs_rt.snap = None; + app.prefs_rt.snap_lines.clear(); + app.move_mods = Default::default(); +} + +/// No visible layer has pixels at document point `p` (locked ones included). +pub(crate) fn empty_at(app: &mut PhotocraftApp, p: [f64; 2]) -> bool { + app.run("layer.pickAt", json!({"x": p[0], "y": p[1], "list": true})).is_ok_and(|v| v["layers"].as_array().is_some_and(Vec::is_empty)) +} + +/// The pointer moves or is released while a marquee is dragged (unsnapped, unconstrained): true +/// when the event was the marquee's. +pub(crate) fn pointer(app: &mut PhotocraftApp, ev: ToolEvent) -> bool { + let Some(b) = app.layer_box.as_mut() else { return false }; + match ev { + // A press never continues a marquee (one left over from a lost release). + ToolEvent::Down { .. } => { + app.layer_box = None; + false + } + ToolEvent::Move { x, y, .. } => { + b.end = [x, y]; + true + } + ToolEvent::Up { x, y } => { + let b = LayerBox { end: [x, y], ..*b }; + app.layer_box = None; + let (w, h) = (b.end[0] - b.start[0], b.end[1] - b.start[1]); + if w == 0.0 || h == 0.0 { + return true; + } + let target = app.ui.tool_options.move_target.clone(); + let mode = if b.add { "add" } else { "replace" }; + let p = json!({"x": b.start[0], "y": b.start[1], "width": w, "height": h, "target": target, "mode": mode}); + if let Err(e) = app.run("layer.selectInRect", p) { + app.ui.status = e; + app.ui.status_error = true; + } + true + } + } +} + +/// The marquee as marching ants. +pub(crate) fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + let Some(b) = app.layer_box else { return }; + let r = egui::Rect::from_two_pos(xf.to_screen(b.start[0] as f32, b.start[1] as f32), xf.to_screen(b.end[0] as f32, b.end[1] as f32)); + crate::tool_feedback::draw_ants(painter, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], true); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::PhotocraftApp; + use crate::canvas::{ToolEvent, tool_event}; + use crate::state::Tool; + + fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2], m: egui::Modifiers) { + tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m); + } + + /// #2844: a drag from the pasteboard selects the layers its box touches (⇧ adds) and moves + /// nothing. + #[test] + fn a_drag_from_an_empty_spot_selects_the_layers_it_touches() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + app.sync_views(); + let mut ids = Vec::new(); + for x in [8, 40] { + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.execute("select.rect", json!({"x": x, "y": 8, "width": 16, "height": 16})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + ids.push(app.session.active().unwrap().active_layer.unwrap()); + } + app.session.execute("select.deselect", json!({})).unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_auto_select = true; + drag(&mut app, [-10.0, -10.0], [10.0, 10.0], egui::Modifiers::NONE); + let st = app.session.active().unwrap(); + assert_eq!(st.selected_layers(), vec![ids[0]], "the box touched the first square only"); + assert_eq!(st.doc.layer(ids[1]).unwrap().surface().unwrap().content_bounds().x0, 40, "nothing moved"); + assert!(app.layer_box.is_none()); + // ⇧ adds the second square, dragged up and left from beyond the right edge. + drag(&mut app, [70.0, 30.0], [50.0, 20.0], egui::Modifiers::SHIFT); + let mut sel = app.session.active().unwrap().selected_layers(); + sel.sort_unstable_by_key(|l| l.0); + assert_eq!(sel, ids); + } +} diff --git a/docs/scorecard.md b/docs/scorecard.md index 301898415..625f68e8e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -148,7 +148,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Opens the generic select.refineEdge dialog (menus.rs:308) | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447) | -| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline done (Preferences › Tools › Show Highlight on Rollover, #2834); box-select layers, double-click fit and drag to a tab open | +| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open | | TOOL-214-5 | Right-click menu during Free Transform (scale, rotate, skew, warp, flip, rotate 90/180) | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas_tool_menu.rs TRANSFORM_MENU: modes, Content-Aware Scale / Puppet Warp greyed, Rotate 180 / 90 CW / 90 CCW and Flip turn the open box (transform_tool::intercept, #2832) | | TOOL-214-6 | Puppet Warp mesh follows the object outline (constrained triangulation) | missing | [#214](https://github.com/storytold/photocraft/issues/214) | algo/puppet.rs:136 builds a block-grid mesh | | TOOL-214-7 | Content-Aware Scale through the Free Transform box with a live proxy preview | partial | [#214](https://github.com/storytold/photocraft/issues/214) | edit.contentAwareScale (edit_menu_cmds.rs:899) behind a parameter dialog | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index b5445e2a4..7b28e9010 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -136,7 +136,7 @@ id = "TOOL-214-4" target = "Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab" status = "partial" issue = 214 -note = "Auto-Select hover outline done (Preferences › Tools › Show Highlight on Rollover, #2834); box-select layers, double-click fit and drag to a tab open" +note = "Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open" [[item]] id = "TOOL-214-5"