//! Move tool Auto-Select: pick the topmost visible, not fully locked layer with pixels at a canvas //! point (Photoshop's options bar "Auto-Select: Layer | Group", ⌘-click with the Move tool, and the //! canvas right-click layer list). Like Photoshop, Auto-Select hits a type layer anywhere inside its //! text's bounds, so a click between letters picks the type rather than the layer under it (#2370). use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_geom::Rect; use serde_json::{Value, json}; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document".into()) } /// Layers with visible pixels at (x, y), topmost first (hidden layers and hidden groups skipped). pub fn layers_at(doc: &Document, x: i32, y: i32) -> Vec { layers_hit(doc, x, y, false) } /// [`layers_at`], but with `type_bounds` a type layer is hit anywhere inside the bounds of its /// rendered text (the gaps between glyphs included), as Photoshop's Auto-Select does. fn layers_hit(doc: &Document, x: i32, y: i32, type_bounds: bool) -> Vec { // A 1×1 read at i32::MAX would be an empty rect (huge `x` params saturate there). if x.checked_add(1).is_none() || y.checked_add(1).is_none() { return Vec::new(); } let rows = doc.walk(); let mut out = Vec::new(); for (path, _, l) in rows.iter().rev() { // The layer and every enclosing group must be visible. if !(1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible)) { continue; } // An artboard clips its layers to the board (#1531): pixels past its edge aren't shown, // so they can't be picked. The board itself is hit anywhere on it, below its layers // (the walk lists them first), as a Photoshop click on an empty spot selects the board. let board = path.first().and_then(|i| doc.layers.get(*i)).and_then(|t| t.artboard()); if board.is_some_and(|a| !a.rect.contains(x, y)) { continue; } if l.artboard().is_some() { out.push(l.id); continue; } if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) { continue; } let alpha = match &l.content { // Fill layers cover the canvas (their mask limits them). LayerContent::Fill(_) => 1.0, // The text's pixel bounds (in document space, so they follow the layer's transform). LayerContent::Text(t) if type_bounds && t.cache.as_ref().is_some_and(|c| c.content_bounds().contains(x, y)) => 1.0, _ => match l.surface() { Some(s) => { let mut px = [[0.0f32; 4]; 1]; s.read_rgba_into(Rect::from_xywh(x, y, 1, 1), &mut px); px[0][3] } None => 0.0, }, }; // As the compositor sees it: density applied, all ones when the mask is disabled. let mask = l.mask.as_ref().map_or(1.0, |m| m.value(x, y)); if alpha * mask > 0.0 { out.push(l.id); } } out } /// The outermost group containing `id` (the layer itself when it isn't in a group). An artboard /// is not a group here: Group mode stops at the outermost group on the board. fn top_group(doc: &Document, id: LayerId) -> LayerId { let Some((path, _, _)) = doc.walk().into_iter().find(|(_, _, l)| l.id == id) else { return id }; let depth = if path.get(..1).and_then(|p| doc.layer_at(p)).is_some_and(|t| t.artboard().is_some()) { 2 } else { 1 }; path.get(..depth).and_then(|p| doc.layer_at(p)).map_or(id, |l| l.id) } /// The layer an Auto-Select click at (x, y) picks (with `group`, its outermost group): the topmost /// hit, a type layer by its text bounds. Like Photoshop, it clicks through a fully locked layer /// (its own Lock All or a locked group's) to the layer under it (#1641); the right-click list /// still shows it. The click (`layer.pickAt`) and the Move tool's rollover highlight both ask /// here, so the outline always names the layer a click takes. pub fn auto_select_target(doc: &Document, x: i32, y: i32, group: bool) -> Option { let hit = layers_hit(doc, x, y, true).into_iter().find(|id| !doc.effective_locks(*id).all)?; Some(if group { top_group(doc, hit) } else { hit }) } fn pick(s: &mut Session, p: &Value) -> Result { let x = p.get("x").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `x`".into() })?.floor() as i32; let y = p.get("y").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `y`".into() })?.floor() as i32; let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); // The right-click list includes type layers between glyphs, like Auto-Select. if p.get("list").and_then(Value::as_bool).unwrap_or(false) { let hits = layers_hit(&doc, x, y, true); let names: Vec = hits.iter().filter_map(|id| doc.layer(*id)).map(|l| json!({"layer": l.id.0, "name": l.name})).collect(); return Ok(json!({ "layers": names })); } let group = p.get("target").and_then(Value::as_str) == Some("group"); let Some(target) = auto_select_target(&doc, x, y, group) else { return Ok(json!({ "layer": null })) }; if p.get("select").and_then(Value::as_bool).unwrap_or(true) { let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); // Like Photoshop, a plain click on one of several selected layers keeps them all // selected, so the drag that follows moves the whole selection. let keeps = mode == "replace" && s.active().is_some_and(|st| st.is_layer_selected(target) && st.selected_layers().len() > 1); if !keeps { s.execute("layer.select", json!({"layer": target.0, "mode": mode}))?; } } 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(_)) || doc.is_background(l.id) || 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.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 visible layers at the point, including text bounds, topmost first)} → {layer} | {layers}"##, enabled: has_doc, journal: false, run: pick, }, ] } #[cfg(test)] mod tests { use super::*; #[test] fn picks_topmost_layer_with_pixels() { let mut s = Session::new(); s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); let bg = s.active().unwrap().doc.layers[0].id; s.execute("layer.new.layer", json!({"name": "A"})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); let a = s.active().unwrap().active_layer.unwrap(); s.execute("layer.new.layer", json!({"name": "B"})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); // Empty B is skipped; A wins inside its square, the Background elsewhere. assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5})).unwrap()["layer"], a.0); assert_eq!(s.active().unwrap().active_layer, Some(a)); assert_eq!(s.execute("layer.pickAt", json!({"x": 30, "y": 30, "select": false})).unwrap()["layer"], bg.0); let list = s.execute("layer.pickAt", json!({"x": 5, "y": 5, "list": true})).unwrap(); assert_eq!(list["layers"].as_array().unwrap().len(), 2); // Hidden layers are ignored. s.execute("layer.setProps", json!({"layer": a.0, "visible": false})).unwrap(); assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "select": false})).unwrap()["layer"], bg.0); } /// Two filled squares: A at (0..10), B at (20..30), both in their own layers. fn two_squares() -> (Session, LayerId, LayerId) { let mut s = Session::new(); s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); let mut ids = Vec::new(); for (name, x) in [("A", 0), ("B", 20)] { s.execute("layer.new.layer", json!({"name": name})).unwrap(); s.execute("select.rect", json!({"x": x, "y": x, "width": 10, "height": 10})).unwrap(); s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); ids.push(s.active().unwrap().active_layer.unwrap()); } s.execute("select.deselect", json!({})).unwrap(); (s, ids[0], ids[1]) } #[test] fn group_target_selects_the_outermost_group() { let (mut s, a, _) = two_squares(); s.execute("layer.select", json!({"layer": a.0})).unwrap(); let inner = s.execute("layer.new.groupFromLayers", json!({"name": "Inner"})).unwrap()["layer"].as_u64().unwrap(); let outer = s.execute("layer.new.groupFromLayers", json!({"name": "Outer"})).unwrap()["layer"].as_u64().unwrap(); assert_ne!(inner, outer); assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "target": "group"})).unwrap()["layer"], outer); assert_eq!(s.active().unwrap().active_layer, Some(LayerId(outer))); // Layer mode reaches through the groups to the pixels. assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "target": "layer"})).unwrap()["layer"], a.0); // Hiding the outer group hides its layers from the pick. s.execute("layer.setProps", json!({"layer": outer, "visible": false})).unwrap(); let bg = s.active().unwrap().doc.layers[0].id; assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "select": false})).unwrap()["layer"], bg.0); } #[test] fn a_click_on_a_selected_layer_keeps_the_multi_selection() { let (mut s, a, b) = two_squares(); s.execute("layer.select", json!({"layer": a.0})).unwrap(); s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap(); assert_eq!(s.active().unwrap().selected_layers().len(), 2); // Clicking A (selected) keeps both so the drag moves both. s.execute("layer.pickAt", json!({"x": 5, "y": 5})).unwrap(); let st = s.active().unwrap(); assert!(st.is_layer_selected(a) && st.is_layer_selected(b)); // Clicking the Background (not selected) replaces the selection. s.execute("layer.pickAt", json!({"x": 35, "y": 5})).unwrap(); assert_eq!(s.active().unwrap().selected_layers().len(), 1); assert!(!s.active().unwrap().is_layer_selected(a)); // Shift-click (add) builds a selection up again. s.execute("layer.pickAt", json!({"x": 5, "y": 5})).unwrap(); s.execute("layer.pickAt", json!({"x": 25, "y": 25, "mode": "add"})).unwrap(); let st = s.active().unwrap(); assert!(st.is_layer_selected(a) && st.is_layer_selected(b)); } #[test] fn a_click_on_an_artboard_picks_the_board_or_the_layers_on_it() { // #1531: an empty spot on a board picked the Background under it, so a Move-tool drag // there never moved the board. let mut s = Session::new(); s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); let bg = s.active().unwrap().doc.layers[0].id; s.execute("layer.new.layer", json!({"name": "A"})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); let a = s.active().unwrap().active_layer.unwrap(); let board = s.execute("layer.new.artboardFromLayers", json!({})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.artboard.set", json!({"width": 30, "height": 30})).unwrap(); // A's pixels past the board's edge are clipped away, so they can't be picked either. s.edit("paint outside", |doc, _| { doc.layer_mut(a).unwrap().surface_mut().unwrap().fill_rect(Rect::new(32, 32, 36, 36), &[1.0, 0.0, 0.0, 1.0]); Ok(()) }) .unwrap(); let pick = |s: &mut Session, x: i32, y: i32, target: &str| s.execute("layer.pickAt", json!({"x": x, "y": y, "target": target})).unwrap()["layer"].clone(); assert_eq!(pick(&mut s, 5, 5, "layer"), a.0, "the layer on the board"); assert_eq!(pick(&mut s, 5, 5, "group"), a.0, "Group mode stops inside the board"); assert_eq!(pick(&mut s, 20, 20, "layer"), board, "an empty spot on the board picks the board"); assert_eq!(s.active().unwrap().active_layer, Some(LayerId(board))); assert_eq!(pick(&mut s, 33, 33, "layer"), bg.0, "off the board: the Background, not A's clipped pixels"); let list = s.execute("layer.pickAt", json!({"x": 5, "y": 5, "list": true})).unwrap(); let ids: Vec = list["layers"].as_array().unwrap().iter().map(|l| l["layer"].as_u64().unwrap()).collect(); assert_eq!(ids, vec![a.0, board, bg.0]); // A hidden board is skipped with its layers. s.execute("layer.setProps", json!({"layer": board, "visible": false})).unwrap(); assert_eq!(pick(&mut s, 5, 5, "layer"), bg.0); } #[test] fn a_fully_locked_layer_is_clicked_through() { // #1641: a locked layer on top was picked, so the drag couldn't reach the layer under it. let (mut s, a, _) = two_squares(); s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); s.execute("select.deselect", json!({})).unwrap(); let top = s.active().unwrap().active_layer.unwrap(); let lock = |s: &mut Session, id: u64, locks: Value| s.execute("layer.setProps", json!({"layer": id, "locks": locks})).unwrap(); let pick = |s: &mut Session| s.execute("layer.pickAt", json!({"x": 5, "y": 5})).unwrap()["layer"].clone(); assert_eq!(pick(&mut s), top.0); // A position lock alone doesn't hide it from Auto-Select. lock(&mut s, top.0, json!({"position": true})); assert_eq!(pick(&mut s), top.0); lock(&mut s, top.0, json!({"all": true})); assert_eq!(pick(&mut s), a.0); assert_eq!(s.active().unwrap().active_layer, Some(a)); // The right-click layer list still names it. let list = s.execute("layer.pickAt", json!({"x": 5, "y": 5, "list": true})).unwrap(); assert_eq!(list["layers"][0]["layer"], top.0); // A locked group locks the layers inside it. lock(&mut s, top.0, json!({"all": false, "position": false})); s.execute("layer.select", json!({"layer": top.0})).unwrap(); let g = s.execute("layer.new.groupFromLayers", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap(); assert_eq!(pick(&mut s), top.0); lock(&mut s, g, json!({"all": true})); assert_eq!(pick(&mut s), a.0); // Nothing unlocked under the point: no pick. lock(&mut s, a.0, json!({"all": true})); let bg = s.active().unwrap().doc.layers[0].id; assert_eq!(pick(&mut s), bg.0); lock(&mut s, bg.0, json!({"all": true})); assert_eq!(pick(&mut s), Value::Null); } #[test] fn auto_select_hits_a_type_layer_between_its_letters() { // #2370: a click in the gap between two glyphs picked the layer under the type. let mut s = Session::new(); s.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); let bg = s.active().unwrap().doc.layers[0].id; let mut text = None; s.edit("type", |doc, _| { // Two "glyphs", (10..14)×(10..20) and (20..24)×(10..20), with a gap between them. let mut cache = photocraft_raster::Surface::new(doc.pixel_format()); for x in [10, 20] { cache.fill_rect(Rect::new(x, 10, x + 4, 20), &[0.0, 0.0, 0.0, 1.0]); } let l = photocraft_doc::Layer::new("T", LayerContent::Text(photocraft_doc::TextLayer { cache: Some(cache), ..Default::default() })); text = Some(l.id); doc.layers.push(l); Ok(()) }) .unwrap(); let t = text.unwrap(); let pick = |s: &mut Session, x: i32, y: i32| s.execute("layer.pickAt", json!({"x": x, "y": y, "select": false})).unwrap()["layer"].clone(); assert_eq!(pick(&mut s, 12, 15), t.0, "on a glyph"); assert_eq!(pick(&mut s, 17, 15), t.0, "between the glyphs: the type layer, not the one under it"); assert_eq!(pick(&mut s, 17, 20), bg.0, "below the text bounds"); // The context menu also reaches this type layer between the glyphs (#4259). let list = s.execute("layer.pickAt", json!({"x": 17, "y": 15, "list": true})).unwrap(); assert_eq!(list["layers"][0]["layer"], t.0); assert_eq!(list["layers"][1]["layer"], bg.0); } #[test] fn auto_select_hits_typed_text_in_a_gap_between_letters() { // The same through the type engine: "I I" leaves a transparent gap inside its bounds. let mut s = Session::new(); s.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); let bg = s.active().unwrap().doc.layers[0].id; let t = s.execute("type.create", json!({"text": "I I", "size": 48, "x": 20, "y": 70})).unwrap()["layer"].as_u64().unwrap(); let doc = s.active().unwrap().doc.clone(); let Some(LayerContent::Text(tl)) = doc.layer(LayerId(t)).map(|l| &l.content) else { panic!("a type layer") }; let cache = tl.cache.as_ref().expect("rendered type"); let b = cache.content_bounds(); // A transparent point on the middle row inside the bounds (none: no font to render with). let y = (b.y0 + b.y1) / 2; let gap = (b.x0..b.x1).find(|&x| { let mut px = [[0.0f32; 4]; 1]; cache.read_rgba_into(Rect::from_xywh(x, y, 1, 1), &mut px); px[0][3] == 0.0 }); let Some(x) = gap else { return }; assert_eq!(layers_at(&doc, x, y), vec![bg], "no type pixels at the gap"); assert_eq!(s.execute("layer.pickAt", json!({"x": x, "y": y, "select": false})).unwrap()["layer"], t); let menu = s.execute("layer.pickAt", json!({"x": x, "y": y, "list": true})).unwrap(); assert_eq!(menu["layers"][0]["layer"], t, "type layer stays above its background in the menu"); assert_eq!(menu["layers"][1]["layer"], bg.0); } #[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(); assert!(s.execute("layer.pickAt", json!({})).is_err()); assert!(s.execute("layer.pickAt", json!({"x": "5", "y": 5})).is_err()); for (x, y) in [(-1e12, 5.0), (1e12, 1e12), (-0.5, -0.5), (40.0, 40.0)] { assert_eq!(s.execute("layer.pickAt", json!({"x": x, "y": y})).unwrap()["layer"], Value::Null, "({x}, {y})"); } let mut empty = Session::new(); assert!(empty.execute("layer.pickAt", json!({"x": 1, "y": 1})).is_err()); } /// A hide-all mask at density 0 hides nothing (the compositor applies density through /// `LayerMask::value`), so the layer stays pickable; at full density it is clicked through. #[test] fn mask_density_is_honoured_when_picking() { let mut s = Session::new(); s.execute("file.new", json!({"width": 20, "height": 20})).unwrap(); let bg = s.active().unwrap().doc.layers[0].id; s.execute("layer.new.layer", json!({"name": "Red"})).unwrap(); s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); let red = s.active().unwrap().active_layer.unwrap(); s.execute("layer.layerMask.hideAll", json!({})).unwrap(); // Full density: the mask hides the layer, so the click reaches the Background. assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "select": false})).unwrap()["layer"], bg.0); s.execute("layer.layerMask.edit", json!({"density": 0})).unwrap(); assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "select": false})).unwrap()["layer"], red.0); let list = s.execute("layer.pickAt", json!({"x": 5, "y": 5, "list": true})).unwrap(); let ids: Vec<_> = list["layers"].as_array().unwrap().iter().map(|l| l["layer"].clone()).collect(); assert_eq!(ids, vec![json!(red.0), json!(bg.0)]); } }