diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8e38d1d57e..b3693f7d8f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3312,7 +3312,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: primary && app.ui.dialogs.last().is_some_and(|d| crate::select_mask_view::owns(&d.fields) && crate::select_mask_view::brush_on(&d.fields)); // Field Blur's pins are placed and dragged on the image (`blur_pins`). let pinning = primary && app.ui.dialogs.last().is_some_and(|d| crate::blur_pins::owns(&d.fields)); - let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning; + // Layer Style on a Drop or Inner Shadow page: a drag on the image moves the shadow (#5701). + let shadow_drag = + !picking && app.ui.dialogs.last().is_some_and(|d| d.kind == crate::state::DialogKind::LayerStyle && crate::layer_style::drags_shadow(&d.fields)); + let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing && !pinning && !shadow_drag; if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; @@ -3320,6 +3323,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, true)); } else if free_hover && (space_pan || hand) { ctx.set_cursor_icon(crate::tool_cursor::hand(&ctx, false)); + } else if shadow_drag && !middle && crate::dialogs::free_pointer_over(&ctx, rect).is_some() { + ctx.set_cursor_icon(egui::CursorIcon::Move); + if let Some(p) = crate::dialogs::free_press(&ctx, rect) { + let (start, origin) = ctx.input(|i| (i.pointer.primary_pressed(), i.pointer.press_origin().unwrap_or(p))); + let total = xf.unmap_vec(p - origin) / (view.zoom / ppp); + if let Some(d) = app.ui.dialogs.last_mut() { + crate::layer_style::drag_shadow(&mut d.fields, start, [f64::from(total.x), f64::from(total.y)]); + } + } } else if picking && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect) { if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { ctx.set_cursor_icon(egui::CursorIcon::Crosshair); diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index 3a1807cf18..d876c18278 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -461,6 +461,39 @@ fn remove_instance(f: &mut Map, id: &str) { /// the layer's order), the blending options and the selected page. `select` /// names a kind to open: its first instance is selected (and enabled), or one /// is created. `light` is the document's global light angle. +/// Whether the selected page is a Drop Shadow or Inner Shadow, which a drag on the image moves +/// while the dialog is open, as in Photoshop (#5701; [`drag_shadow`]). +pub fn drags_shadow(f: &Map) -> bool { + let selected = f.get("selected").and_then(Value::as_str).unwrap_or_default(); + entry(f, selected).and_then(|e| e.get("kind")).and_then(Value::as_str).is_some_and(|k| matches!(k, "dropShadow" | "innerShadow")) +} + +/// A drag on the image with a shadow page selected: `start` is true on the press (it records the +/// shadow's angle and distance then), `total` the pointer's movement since in document pixels. +/// The shadow's offset follows the pointer, kept as Photoshop's whole-degree angle and whole-pixel +/// distance (offset = (−cos angle, sin angle) × distance, the light opposite the shadow); with +/// Use Global Light the shared angle moves too ([`set_param`]). +pub fn drag_shadow(f: &mut Map, start: bool, total: [f64; 2]) { + const KEY: &str = "__shadowDrag"; + let Some(id) = f.get("selected").and_then(Value::as_str).map(str::to_string).filter(|_| drags_shadow(f)) else { return }; + let Some(p) = entry(f, &id).and_then(|e| e.get("params")) else { return }; + if start || f.get(KEY).is_none() { + let angle = p.get("angle").and_then(Value::as_f64).unwrap_or(120.0); + let distance = p.get("distance").and_then(Value::as_f64).unwrap_or(0.0); + f.insert(KEY.into(), json!([angle, distance])); + } + let Some([angle, distance]) = f.get(KEY).and_then(Value::as_array).and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])) else { return }; + let a = angle.to_radians(); + let (ox, oy) = (-a.cos() * distance + total[0], a.sin() * distance + total[1]); + let d = ox.hypot(oy); + if !d.is_finite() { + return; + } + let new_angle = if d >= 0.5 { oy.atan2(-ox).to_degrees().round() } else { angle }; + set_param(f, &id, "angle", json!(new_angle)); + set_param(f, &id, "distance", json!(d.round().clamp(0.0, 30000.0))); +} + pub fn initial_fields(layer: &Layer, select: Option<&str>, light: f32) -> Map { let mut f = Map::new(); f.insert("layer".into(), json!(layer.id.0)); @@ -1346,6 +1379,38 @@ mod tests { use super::*; use photocraft_doc::StrokeFx; + /// #5701: with a shadow page selected, a drag on the image moves the shadow: its offset + /// follows the pointer, as whole degrees and pixels, the global light angle with it. + #[test] + fn dragging_on_the_image_moves_the_selected_shadow() { + let shadow = |id: &str, kind: &str, global: bool| json!({"id": id, "kind": kind, "on": true, "params": {"angle": 120.0, "distance": 10.0, "useGlobalLight": global}}); + let mut f = Map::new(); + f.insert("selected".into(), json!("fx1")); + f.insert("globalLight".into(), json!(120.0)); + f.insert("effects".into(), json!([shadow("fx1", "dropShadow", true), shadow("fx2", "innerShadow", true), shadow("fx3", "dropShadow", false)])); + assert!(drags_shadow(&f)); + let params = |f: &Map, id: &str| { + let p = &entry(f, id).unwrap()["params"]; + (p["angle"].as_f64().unwrap(), p["distance"].as_f64().unwrap()) + }; + // The press keeps the shadow where it is: (5, 8.66) px. + drag_shadow(&mut f, true, [0.0, 0.0]); + assert_eq!(params(&f, "fx1"), (120.0, 10.0)); + // Dragged to (15, 0): straight right of the layer, the light from the left (180°). + drag_shadow(&mut f, false, [10.0, -8.660_254]); + assert_eq!(params(&f, "fx1"), (180.0, 15.0)); + assert_eq!(params(&f, "fx2").0, 180.0, "the global light angle is shared"); + assert_eq!(params(&f, "fx3"), (120.0, 10.0), "an instance with its own angle is left alone"); + // Back onto the layer: no offset, and the angle the drag started with. + drag_shadow(&mut f, false, [-5.0, -8.660_254]); + assert_eq!(params(&f, "fx1"), (120.0, 0.0)); + // Not on a shadow page: nothing moves. + f.insert("selected".into(), json!(BLENDING)); + assert!(!drags_shadow(&f)); + drag_shadow(&mut f, true, [50.0, 50.0]); + assert_eq!(params(&f, "fx1"), (120.0, 0.0)); + } + fn session() -> photocraft_engine::Session { let mut s = photocraft_engine::Session::new(); s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();