diff --git a/crates/engine/src/cmd/transform_cmds.rs b/crates/engine/src/cmd/transform_cmds.rs index f0d71505676..cf9004bbac8 100644 --- a/crates/engine/src/cmd/transform_cmds.rs +++ b/crates/engine/src/cmd/transform_cmds.rs @@ -272,9 +272,9 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { } /// The layers Free Transform moves with the Layers panel's selection, bottom-to-top: with several -/// layers selected, every one that no selected group already carries, as in Photoshop (#6501), but -/// the Background, which Show Transform Controls doesn't frame either. With one layer selected (or -/// only the Background besides it), that layer; the active layer when nothing else is. +/// layers selected, every one that no selected group already carries, as in Photoshop (#6501). The +/// Background stays among them, so such a box refuses as locked (#11101, [`background_among`]). +/// With one layer selected, that layer; the active layer when nothing else is. pub fn selection_targets(st: &crate::DocState) -> Vec { targets_of(&st.doc, &st.selected_layers(), st.active_layer) } @@ -285,14 +285,23 @@ pub(crate) fn targets_of(doc: &Document, selected: &[LayerId], active: Option = crate::layer_multi_cmds::top_level(doc, selected).into_iter().filter(|id| !doc.is_background(*id)).collect(); + let ids = crate::layer_multi_cmds::top_level(doc, selected); if ids.is_empty() { active.into_iter().collect() } else { ids } } +/// The Background when it is among several layers one box would move: Photoshop refuses that Free +/// Transform with its locked-layer alert rather than leaving the Background out (#11101). +pub fn background_among(doc: &Document, ids: &[LayerId]) -> Option { + if ids.len() < 2 { + return None; + } + ids.iter().copied().find(|id| doc.is_background(*id)) +} + /// The layers one box over `targets` moves: `targets` with the layers linked to them, as the Move /// tool takes them along. In Photoshop transforming one linked layer transforms the whole link -/// group (#11217). A linked Background joins only when it is a target itself: `targets_of` -/// leaves it out of a box over other layers, and a link doesn't bring it back in. +/// group (#11217). A linked Background joins only when it is a target itself: a link doesn't +/// bring it into a box over other layers. pub fn box_targets(doc: &Document, targets: &[LayerId]) -> Vec { let mut ids = crate::layer_multi_cmds::move_targets(doc, targets); ids.retain(|id| targets.contains(id) || !doc.is_background(*id)); @@ -874,7 +883,14 @@ fn transform(s: &mut Session, p: &Value) -> Result { (Some(ids), _) => ids, _ if lone.is_some() => Vec::new(), (None, Some(id)) => vec![id], - (None, None) => selection_targets(st), + (None, None) => { + let ids = selection_targets(st); + if let Some(bg) = background_among(&st.doc, &ids) { + let name = st.doc.layer(bg).map_or("Background", |l| l.name.as_str()); + return Err(EngineError::Other(format!("layer \"{name}\" is locked"))); + } + ids + } }; if let Some(missing) = ids.iter().find(|id| st.doc.layer(**id).is_none()) { return Err(EngineError::NoLayer(*missing)); @@ -1016,7 +1032,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], shortcut: None, - params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"stepName":"Transform Path"? (History step name; the shape tools' bounding box sends it; default "Free Transform"; any other value is refused),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, + params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers, as Photoshop's Free Transform with several selected; refused as locked when the Background is among several),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px?,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center" (relative alternative: scale and rotate about this point, then move),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"stepName":"Transform Path"? (History step name; the shape tools' bounding box sends it; default "Free Transform"; any other value is refused),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, enabled: has_layer, journal: true, run: transform, diff --git a/crates/engine/src/cmd/transform_cmds/multi_tests.rs b/crates/engine/src/cmd/transform_cmds/multi_tests.rs index 329775e7a35..a50e4239fdb 100644 --- a/crates/engine/src/cmd/transform_cmds/multi_tests.rs +++ b/crates/engine/src/cmd/transform_cmds/multi_tests.rs @@ -91,13 +91,17 @@ fn the_layers_param_names_the_layers_and_a_listed_group_carries_its_children() { } #[test] -fn the_background_stays_out_of_a_selection_of_several() { +fn the_background_among_a_selection_of_several_refuses_as_locked() { + // Photoshop shows its locked-layer alert rather than leaving the Background out (#11101). let (mut s, paint, text) = two_layers(); let bg = s.active().unwrap().doc.layers[0].id; s.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap(); - let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap(); - assert_eq!(r["layers"], json!([paint.0, text.0])); let st = s.active().unwrap(); + let (before, revision) = (st.doc.clone(), st.revision); + let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap_err().to_string(); + assert!(err.contains("\"Background\" is locked"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision, "nothing moved, not even {paint:?} or {text:?}"); assert!(st.doc.is_background(bg), "still the Background"); assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200)); } diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 12f32740460..6babd70eb08 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -304,6 +304,16 @@ impl PhotocraftApp { // snapshot and advance its revision; a replaced snapshot without a newer revision is a // reopened document, even with the same ID. let document = self.session.active().map(|st| (st.doc.id, st.revision, std::sync::Arc::downgrade(&st.doc))); + // Crop's Fill goes back to its default entry when another document that was already open + // becomes active (a tab switch, or closing the active one), as in the reference + // application. A new or opened document isn't in `view_docs` yet, so it keeps the setting, + // as do tool switches, commits and cancels, which leave the active document alone (#10894). + if let (Some((id, ..)), Some((old_id, ..))) = (&document, &self.path_document) + && id != old_id + && self.view_docs.contains(id) + { + self.ui.tool_options.crop_content_aware = false; + } let same_document = match (&self.path_document, &document) { (Some((old_id, old_revision, old_source)), Some((id, revision, source))) => old_id == id && (revision > old_revision || old_source.ptr_eq(source)), (None, None) => true, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c1be96a5b08..6889313084d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -118,6 +118,7 @@ use textures::{clamp_texture_side, preview_bounds, texture_factor}; use textures::{ensure_gpu, redraw_focus, visible_doc_rect}; pub use tool_input::{ToolEvent, selection_drag_kind, tool_event}; pub(crate) use tool_input::{command_guide_at, command_moves_layer, event_tool}; +pub(crate) use transform_controls::ControlsPress; pub(crate) use transform_controls::{LAYER_EDGES, layer_edges_rect}; use transform_controls::{begin_transform_controls_at, draw_transform_controls, transform_controls_cursor, transform_controls_rect, transform_controls_turn}; #[cfg(test)] @@ -1036,12 +1037,13 @@ fn handle_canvas_tool_stroke( if buttons.started && let Some(p) = ui.input(|i| i.pointer.press_origin()).filter(|p| rect.contains(*p)).or(response.interact_pointer_pos()) { - if tool == Tool::Move && app.ui.transform.is_none() { - begin_transform_controls_at(app, ctx, xf, p); + // A press the controls refused (the Background among the layers) goes nowhere else (#11101). + let refused = tool == Tool::Move && app.ui.transform.is_none() && begin_transform_controls_at(app, ctx, xf, p) == ControlsPress::Refused; + if !refused { + let d = xf.to_doc(p); + let press_mods = if capture_press { ctx.data(|d| d.get_temp(tool_press)).unwrap_or(mods) } else { mods }; + tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods); } - let d = xf.to_doc(p); - let press_mods = if capture_press { ctx.data(|d| d.get_temp(tool_press)).unwrap_or(mods) } else { mods }; - tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, press_mods); } // A stroke started on the press takes its moves from the press on, not only once egui // recognises a drag (past its click distance): otherwise the first few points are lost @@ -1200,10 +1202,10 @@ fn handle_canvas_tool_clicks( app.ui.status = e; } } + // A click the transform controls refused (the Background among the layers) goes + // nowhere else (#11101). + _ if tool == Tool::Move && app.ui.transform.is_none() && begin_transform_controls_at(app, ctx, xf, p) == ControlsPress::Refused => {} _ => { - if tool == Tool::Move && app.ui.transform.is_none() { - begin_transform_controls_at(app, ctx, xf, p); - } // If this tool was not handled on pointer-down, fall back to a // click-sized gesture while preserving pen pressure (mouse stays at 1). tool_event(app, ToolEvent::Down { x: d[0], y: d[1], pressure: app.stylus.pressure() }, click_mods); diff --git a/crates/ui-egui/src/canvas/paint.rs b/crates/ui-egui/src/canvas/paint.rs index 0f3a92edef7..a091602313f 100644 --- a/crates/ui-egui/src/canvas/paint.rs +++ b/crates/ui-egui/src/canvas/paint.rs @@ -192,7 +192,7 @@ pub(crate) fn marching_ants_line_shapes(painter: &egui::Painter, lines: impl Ite /// Photoshop crop overlay: the `shield` outside (`crop_shield::fill`; none when disabled), bright /// frame, the composition `guides` (unit-square polylines, `crop_overlay::guides`), corner and /// edge handles. -fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option, guides: &[crate::crop_overlay::Polyline]) { +fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option, guides: &[crate::crop_overlay::Polyline], look: crate::crop_overlay::FrameLook) { let clip = painter.clip_rect(); if let Some(dim) = shield { for band in [ @@ -204,9 +204,8 @@ fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option, guide painter.rect_filled(band, 0.0, dim); } } - painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle); + let h = crop_frame_outline(painter, [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], look); draw_crop_guides(painter, guides, |a, b| r.min + r.size() * vec2(a, b)); - let h = Stroke::new(3.0, Color32::WHITE); let l = 14.0f32.min(r.width() / 3.0).min(r.height() / 3.0); for (c, dx, dy) in [(r.left_top(), 1.0, 1.0), (r.right_top(), -1.0, 1.0), (r.left_bottom(), 1.0, -1.0), (r.right_bottom(), -1.0, -1.0)] { painter.line_segment([c, c + vec2(l * dx, 0.0)], h); @@ -222,7 +221,13 @@ fn crop_overlay(painter: &egui::Painter, r: Rect, shield: Option, guide /// [`crop_overlay`] for a turned frame (or a rotated view): `q` is the frame's top-left, top-right, /// bottom-right and bottom-left corner on screen, a parallelogram. -fn crop_overlay_turned(painter: &egui::Painter, q: [Pos2; 4], shield: Option, guides: &[crate::crop_overlay::Polyline]) { +fn crop_overlay_turned( + painter: &egui::Painter, + q: [Pos2; 4], + shield: Option, + guides: &[crate::crop_overlay::Polyline], + look: crate::crop_overlay::FrameLook, +) { let (u, v) = (q[1] - q[0], q[3] - q[0]); let (lu, lv) = (u.length(), v.length()); if !(lu.is_finite() && lv.is_finite()) || lu < 1e-3 || lv < 1e-3 { @@ -246,9 +251,8 @@ fn crop_overlay_turned(painter: &egui::Painter, q: [Pos2; 4], shield: Option Stroke { + use crate::crop_overlay::FrameLook; + let grey = Stroke::new(2.0, crate::theme::Tokens::get(painter.ctx()).text_faint); + match look { + FrameLook::Classic => { + painter.add(egui::Shape::closed_line(q.to_vec(), Stroke::new(1.0, Color32::WHITE))); + Stroke::new(3.0, Color32::WHITE) + } + FrameLook::Untouched => { + // Dashed like a selection outline: 4 px black, then 4 px light grey. Static rather + // than marching, so an untouched frame doesn't keep the canvas repainting. + // Each edge is cut to the visible area first: zoomed far in, a frame's edges run + // thousands of points off screen, and dashing them all would make a shape per dash. + let view = painter.clip_rect().expand(4.0); + for i in 0..4 { + let (a, b) = (q[i], q[(i + 1) % 4]); + if let Some([from, to]) = clip_segment(a, b, view) { + painter.line_segment([from, to], Stroke::new(1.0, Color32::from_gray(210))); + let offset = dash_offset(a, from, DASH); + painter.extend(egui::Shape::dashed_line_with_offset(&[from, to], Stroke::new(1.0, Color32::BLACK), &[DASH], &[DASH], offset)); + } + } + grey + } + FrameLook::Started => { + painter.add(egui::Shape::closed_line(q.to_vec(), Stroke::new(1.0, Color32::from_white_alpha(220)))); + grey + } + } +} + +/// Length of the untouched frame's dashes and of the gaps between them. +const DASH: f32 = 4.0; + +/// Where the dashes of an edge cut to the view begin, so they keep the places they have counted +/// from the edge's `corner`: started afresh at the cut, the pattern slid along the edge as the view +/// panned (noted in review). The dashes start every `2 * dash` from the corner; `dashes_from_line` +/// can only begin on a dash, so a dash the cut divides starts a period later -- that piece is in +/// the margin the view is grown by, out of sight. +fn dash_offset(corner: Pos2, start: Pos2, dash: f32) -> f32 { + let period = 2.0 * dash; + (period - (start - corner).length() % period) % period +} + +/// The part of the segment `a`–`b` inside `r` (Liang–Barsky), or None when none of it is. +fn clip_segment(a: Pos2, b: Pos2, r: Rect) -> Option<[Pos2; 2]> { + let d = b - a; + let (mut t0, mut t1) = (0.0f32, 1.0f32); + for (p, q) in [(-d.x, a.x - r.min.x), (d.x, r.max.x - a.x), (-d.y, a.y - r.min.y), (d.y, r.max.y - a.y)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + continue; + } + let t = q / p; + if p < 0.0 { + t0 = t0.max(t); + } else { + t1 = t1.min(t); + } + if t0 > t1 { + return None; + } + } + (t0.is_finite() && t1.is_finite()).then(|| [a + d * t0, a + d * t1]) +} + /// The crop overlay's guides, each unit-square point placed on screen by `at`. fn draw_crop_guides(painter: &egui::Painter, guides: &[crate::crop_overlay::Polyline], at: impl Fn(f32, f32) -> Pos2) { let thin = Stroke::new(1.0, Color32::from_white_alpha(90)); @@ -480,10 +556,17 @@ pub(super) fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: r, shield, &crate::crop_overlay::current_guides_for_frame(app, [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], painter.clip_rect()), + crate::crop_overlay::frame_look(app), ); } else { let q = crate::crop_ui::corners(c, deg).map(|p| xf.to_screen(p[0] as f32, p[1] as f32)); - crop_overlay_turned(painter, q, shield, &crate::crop_overlay::current_guides_for_frame(app, q, painter.clip_rect())); + crop_overlay_turned( + painter, + q, + shield, + &crate::crop_overlay::current_guides_for_frame(app, q, painter.clip_rect()), + crate::crop_overlay::frame_look(app), + ); } // The frame's angle beside the pointer while it turns (#1792). if let (Some(a), Some(h)) = (crate::crop_ui::turning(app), hover) { @@ -630,3 +713,76 @@ pub(super) fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter _ => {} } } + +#[cfg(test)] +mod clip_segment_tests { + use super::{DASH, Pos2, Rect, clip_segment, dash_offset, pos2}; + + /// Where each dash of the edge from `corner`, cut to `view`, begins, as distances from the corner. + fn dash_starts(corner: Pos2, end: Pos2, view: Rect) -> Vec { + let Some([from, to]) = clip_segment(corner, end, view) else { return Vec::new() }; + let shapes = + egui::Shape::dashed_line_with_offset(&[from, to], egui::Stroke::new(1.0, egui::Color32::BLACK), &[DASH], &[DASH], dash_offset(corner, from, DASH)); + shapes + .iter() + .filter_map(|s| match s { + egui::Shape::LineSegment { points, .. } => Some((points[0] - corner).length()), + _ => None, + }) + .collect() + } + + /// The dashes of an edge cut to the view stay where they are counted from the frame's corner, + /// however the view sits on the edge, so panning does not slide them along it (noted in review). + #[test] + fn the_dashes_keep_their_places_as_the_view_pans() { + let (corner, end) = (pos2(0.0, 50.0), pos2(10_000.0, 50.0)); + for left in [0.0f32, 1.0, 3.3, 4.0, 5.9, 123.45, 4_001.7] { + let view = Rect::from_min_max(pos2(left, 0.0), pos2(left + 300.0, 100.0)); + let starts = dash_starts(corner, end, view); + assert!(!starts.is_empty(), "the view at {left} shows dashes"); + for d in starts { + let off = d % (2.0 * DASH); + assert!(off < 0.01 || off > 2.0 * DASH - 0.01, "view at {left}: a dash starts {d} from the corner, off the pattern by {off}"); + } + } + } + + fn view() -> Rect { + Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0)) + } + + /// Within a hundredth of a point, far below a device pixel: f32 on edges tens of thousands of + /// points long can't do better, and the screen doesn't need it to. + fn near(a: Pos2, b: Pos2) -> bool { + (a - b).length() < 1e-2 + } + + #[test] + fn a_segment_inside_is_kept_whole() { + let [a, b] = clip_segment(pos2(10.0, 10.0), pos2(90.0, 40.0), view()).unwrap(); + assert!(near(a, pos2(10.0, 10.0)) && near(b, pos2(90.0, 40.0))); + } + + #[test] + fn a_segment_running_far_off_screen_is_cut_to_the_view() { + // A frame edge at 3200 % runs tens of thousands of points past the view. + let [a, b] = clip_segment(pos2(-40_000.0, 20.0), pos2(40_000.0, 20.0), view()).unwrap(); + assert!(near(a, pos2(0.0, 20.0)) && near(b, pos2(100.0, 20.0)), "{a:?} {b:?}"); + let [a, b] = clip_segment(pos2(50.0, -1000.0), pos2(50.0, 1000.0), view()).unwrap(); + assert!(near(a, pos2(50.0, 0.0)) && near(b, pos2(50.0, 50.0))); + } + + #[test] + fn a_slanted_segment_through_a_corner_is_cut_on_both_sides() { + let [a, b] = clip_segment(pos2(-10.0, -10.0), pos2(60.0, 60.0), view()).unwrap(); + assert!(near(a, pos2(0.0, 0.0)) && near(b, pos2(50.0, 50.0)), "{a:?} {b:?}"); + } + + #[test] + fn a_segment_outside_the_view_draws_nothing() { + assert!(clip_segment(pos2(-50.0, 10.0), pos2(-5.0, 40.0), view()).is_none(), "left of it"); + assert!(clip_segment(pos2(10.0, 60.0), pos2(90.0, 90.0), view()).is_none(), "below it"); + assert!(clip_segment(pos2(-10.0, 70.0), pos2(70.0, 200.0), view()).is_none(), "past a corner"); + } +} diff --git a/crates/ui-egui/src/canvas/selection.rs b/crates/ui-egui/src/canvas/selection.rs index b3e37d1f182..5845e4bcf1e 100644 --- a/crates/ui-egui/src/canvas/selection.rs +++ b/crates/ui-egui/src/canvas/selection.rs @@ -11,7 +11,8 @@ pub enum SelCursor { Cut, /// Arrow with a second arrow: ⌥ lifts a copy of them instead. Copy, - /// Plain arrow: a floating piece, moved again by a plain drag until it is dropped. + /// Plain arrow: a floating piece, moved again by the Move tool (⌘ with a marquee) until it is + /// dropped. Piece, /// Hollow arrowhead: the selected pixels are being dragged. Dragging, diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index 08aff7dd70f..d678929e478 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -109,6 +109,11 @@ fn preflight_tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modi Some((armed, mods)) } +/// The handlers a pointer event meets before the tool: dropping a floating piece the press won't +/// drag, snapping, and the overlays and modifier gestures (Free Transform, sticky keys, brush +/// resize, the ⌥ eyedropper, path and shape widgets…) that take the event for themselves. `None`: +/// one of them handled it; otherwise the event as it came and as filtered (snapped, the Move +/// tool's axis lock), and the modifiers with the sticky keys applied. fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers, armed: bool) -> Option<(ToolEvent, ToolEvent, egui::Modifiers)> { // View › Snap / Snap To and smart guides (snap_ui.rs). let raw = ev; @@ -116,8 +121,9 @@ fn filter_event_before_tool(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui:: if crate::move_box::pointer(app, raw) { return None; } - // 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. + // A press that doesn't drag the floating piece drops it first: off it, with ⇧ / ⌥ to draw, or a + // plain selection-tool press on it, which moves the outline only (#11368). The Move tool (⌘ + // with a painting tool too, `event_tool`) drags it from anywhere. if let ToolEvent::Down { x, y, .. } = raw && app.session.active().is_some_and(|st| photocraft_engine::float_cmds::floating(st).is_some()) && !crate::move_ui::moves_selected_pixels_with(app, event_tool(app, mods)) @@ -673,12 +679,13 @@ fn covers(sel: &photocraft_raster::Surface, p: [f64; 2]) -> bool { } /// Does a press with `tool` at `p` drag the selection rather than draw a new one? `Some(true)` -/// moves the floating piece (⌘ cuts one first, ⌘⌥ copies; a floating piece moves with a plain -/// drag; the Polygonal Lasso and Magic Wand need ⌘, as a plain press there places or samples), -/// `Some(false)` just the outline (no ⇧ / ⌥ and the options bar on New Selection, so a combining -/// drag still draws). The Single Row / Single Column Marquee drag the outline too (#11392); a click -/// without moving, inside the selection or on a floating piece (dropped first), still selects the -/// line under the pointer (`finish_gesture`). +/// moves the floating piece (⌘ cuts one first, ⌘⌥ copies; the Polygonal Lasso and Magic Wand need +/// ⌘, as a plain press there places or samples), `Some(false)` just the outline (no ⇧ / ⌥ and the +/// options bar on New Selection, so a combining drag still draws). A plain drag on a floating piece +/// moves the outline too, as in Photoshop: the press drops the piece first (#11368). The Single Row +/// / Single Column Marquee drag the outline too (#11392); a click without moving, inside the +/// selection or on a floating piece (dropped first), still selects the line under the pointer +/// (`finish_gesture`). pub fn selection_drag_kind(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: egui::Modifiers) -> Option { // Click-driven selection tools (a press places a point or samples): only ⌘ drags the // selection, and not while a polygon is being drawn. @@ -690,11 +697,18 @@ pub fn selection_drag_kind(app: &PhotocraftApp, tool: Tool, p: [f64; 2], mods: e // ⌘ cuts, ⌘⌥ copies (`select.float`'s `copy`). let cut = mods.command && !mods.shift; if let Some(f) = app.session.active().and_then(photocraft_engine::float_cmds::floating) { - // A press on the piece's outline (where its ants are) drags it; one in the margin the cut - // left of the selection starts a new selection (#7538). + // A ⌘ press on the piece's outline (where its ants are) drags it; a plain one drags the + // outline, dropping the piece where it is first (`filter_event_before_tool`, #11368); one + // in the margin the cut left of the selection starts a new selection (#7538). let at = [p[0] - f64::from(f.offset.0), p[1] - f64::from(f.offset.1)]; let on = f.parts.outline().map_or_else(|| inside_selection(app, at), |o| covers(o, at)); - return (on && !mods.shift && (!mods.alt || mods.command) && (cut || !clicky)).then_some(true); + if !on { + return None; + } + if cut { + return Some(true); + } + return (!clicky && !mods.command && selection_mode(app, mods) == "replace").then_some(false); } if !inside_selection(app, p) { return None; diff --git a/crates/ui-egui/src/canvas/transform_controls.rs b/crates/ui-egui/src/canvas/transform_controls.rs index c63d1d1c769..97325bdb407 100644 --- a/crates/ui-egui/src/canvas/transform_controls.rs +++ b/crates/ui-egui/src/canvas/transform_controls.rs @@ -22,7 +22,9 @@ pub(crate) fn layer_edges_rect(app: &PhotocraftApp, idx: usize) -> Option