diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index acbbba02b..31d06b06c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -67,6 +67,8 @@ pub enum Icon { Subject, Picker, Rotate, + /// A curved line with an arrow at both ends: drag to rotate, either way (the crop tool's pointer). + RotateDrag, Flip, Invert, Dots, @@ -428,6 +430,12 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(10.0, 10.0, 6.5, 200.0, 470.0); pen.line(&[(3.0, 5.5), (4.0, 9.0), (7.5, 8.0)]); } + RotateDrag => { + // the upper arc of a circle, an arrowhead at each end pointing along it + pen.arc(10.0, 14.0, 7.5, 200.0, 340.0); + pen.line(&[(0.8, 9.6), (3.0, 11.9), (6.1, 11.0)]); + pen.line(&[(19.2, 9.6), (17.0, 11.9), (13.9, 11.0)]); + } Flip => { pen.line(&[(10.0, 2.5), (10.0, 17.5)]); pen.closed(&[(8.0, 5.0), (8.0, 15.0), (3.0, 15.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 705818911..30c400870 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,8 @@ pub mod theme; pub mod titlebar; pub mod widgets; +#[cfg(test)] +mod tests_crop_rotate; #[cfg(test)] mod tests_curve; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 2e708da66..4ca158abd 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1248,16 +1248,27 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let s = to_straight(n, d.crop.geometry.angle, frame); d.crop.geometry.rect.contains(Point::new(s.x.clamp(-1.0, 2.0), s.y)) }; - if let Some(hq) = resp.hover_pos() { - let near = handles.iter().position(|h| h.distance(hq) < 12.0); - ui.ctx().set_cursor_icon(match near { + // while rotating, the pointer may leave the canvas: it still shows rotation and the angle + let rotating = matches!(app.gesture, Some(Gesture::CropRotate { .. })); + let pointer = resp.hover_pos().or_else(|| if rotating { ui.input(|i| i.pointer.latest_pos()) } else { None }); + if let Some(hq) = pointer { + let near = handles.iter().position(|h| h.distance(hq) < 12.0).filter(|_| !rotating); + let cursor = match near { Some(0 | 2) => egui::CursorIcon::ResizeNwSe, Some(1 | 3) => egui::CursorIcon::ResizeNeSw, Some(4 | 6) => egui::CursorIcon::ResizeVertical, Some(_) => egui::CursorIcon::ResizeHorizontal, - None if inside(hq) => egui::CursorIcon::Move, - None => egui::CursorIcon::Alias, - }); + None if inside(hq) && !rotating => egui::CursorIcon::Move, + // a drag here rotates: no system cursor shows that, so draw a curved double arrow + None => { + rotate_cursor(ui, hq); + egui::CursorIcon::None + } + }; + ui.ctx().set_cursor_icon(cursor); + if rotating { + angle_readout(ui, hq, d.crop.geometry.angle); + } } // double-click inside the crop box applies the crop (same as Return / Done) if resp.double_clicked() @@ -1311,6 +1322,40 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let _ = id; } +/// The crop angle as the rotation readout shows it: like the Straighten value, in degrees. +pub(crate) fn crop_angle_label(angle: f64) -> String { + let shown = + lightcraft_develop::controls::find("crop.angle").map_or_else(|| format!("{angle:.2}"), |spec| crate::widgets::shown_value(spec, angle)); + // the slider's format turns a rounded −0.00 into "0": every zero reads as the slider at rest + let shown = if shown == "0" { "0.00".to_string() } else { shown }; + format!("{shown}°") +} + +/// On top of everything (the tooltip layer), so neither the photo nor a panel covers it. +fn top_painter(ui: &egui::Ui, name: &'static str) -> egui::Painter { + ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new(name))) +} + +/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo. +fn rotate_cursor(ui: &egui::Ui, at: Pos2) { + let p = top_painter(ui, "crop-rotate-cursor"); + let r = Rect::from_center_size(at, vec2(22.0, 22.0)); + p.circle_filled(at, 12.0, Color32::from_black_alpha(150)); + crate::icons::paint(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE); + register(ui.ctx(), "cropRotateCursor", r); +} + +/// While rotating: the angle, next to the pointer. +fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64) { + let t = Tokens::get(ui.ctx()); + let p = top_painter(ui, "crop-angle-readout"); + let galley = p.layout_no_wrap(crop_angle_label(angle), t.font(12.5), Color32::WHITE); + let rect = Rect::from_min_size(at + vec2(18.0, 14.0), galley.size() + vec2(12.0, 6.0)); + p.rect_filled(rect, 4.0, Color32::from_black_alpha(170)); + p.galley(rect.min + vec2(6.0, 3.0), galley, Color32::WHITE); + register(ui.ctx(), "cropAngleReadout", rect); +} + /// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are /// stored in lens-corrected (pre-perspective) coordinates, so they stay attached to the image as it warps. fn guided_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings) { diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests_crop_rotate.rs new file mode 100644 index 000000000..f5aca4e72 --- /dev/null +++ b/crates/ui-egui/src/tests_crop_rotate.rs @@ -0,0 +1,101 @@ +//! Rotating in the crop tool (issue #534): outside the crop box the pointer shows that a drag +//! rotates, a drag shows the angle as it changes, and the Straighten value takes an exact angle. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn crop_tool() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + for (method, params) in [("ui.set", json!({"view": "detail"})), ("engine.execute", json!({"command": "panel.crop"}))] { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + h +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn angle(h: &Headless) -> f64 { + let id = h.app.session.active().expect("a photo"); + h.app.session.catalog.photo(id).expect("the photo").develop.crop.geometry.angle +} + +fn hover(h: &mut Headless, p: egui::Pos2) { + let r = h.request("ui.move", json!({"x": p.x, "y": p.y}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +/// Outside the crop box, where a drag rotates, the pointer becomes a curved double arrow drawn at +/// the pointer (no system cursor has one); inside it is the move cursor, with no rotation glyph. +#[test] +fn outside_the_crop_box_the_pointer_shows_rotation() { + let mut h = crop_tool(); + let img = h.app.image_rect.expect("the photo on screen"); + hover(&mut h, egui::pos2(img.left() - 24.0, img.center().y)); + assert_eq!(h.last_cursor, egui::CursorIcon::None, "the system cursor gives way to the glyph"); + assert!(has(&h, "cropRotateCursor"), "the rotation glyph is drawn at the pointer"); + hover(&mut h, img.center()); + assert_eq!(h.last_cursor, egui::CursorIcon::Move); + assert!(!has(&h, "cropRotateCursor")); +} + +/// Dragging outside the box rotates, and the angle is shown next to the pointer while it moves. +#[test] +fn rotating_shows_the_angle() { + let mut h = crop_tool(); + let r = h.request( + "ui.pointer", + json!({"events": [{"kind": "down", "x": -0.05, "y": 0.2}, {"kind": "drag", "x": -0.05, "y": 0.25}, {"kind": "drag", "x": -0.05, "y": 0.3}]}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(angle(&h) != 0.0, "the drag rotated the crop"); + assert!(has(&h, "cropAngleReadout"), "the angle is shown while rotating"); + let r = h.request("ui.pointer", json!({"events": [{"kind": "up", "x": -0.05, "y": 0.3}]}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(!has(&h, "cropAngleReadout"), "and gone once released"); +} + +/// The Straighten value takes an exact angle: click it, type, Enter. +#[test] +fn the_straighten_value_takes_an_exact_angle() { + let mut h = crop_tool(); + let r = h.request("ui.clickWidget", json!({"id": "sliderValue:crop.angle"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + for key in ["a", "Backspace"] { + // select what is there and clear it + let r = h.request("ui.key", json!({"key": key, "cmd": key == "a"}), T); + assert_eq!(r["ok"], true, "{r}"); + } + let r = h.request("ui.text", json!({"text": "2.5"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.key", json!({"key": "Enter"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!((angle(&h) - 2.5).abs() < 1e-9, "typed 2.5°, got {}", angle(&h)); +} + +/// The angle next to the pointer reads like the Straighten value, in degrees. +#[test] +fn the_angle_reads_like_the_straighten_value() { + use crate::panels::detail::crop_angle_label; + assert_eq!(crop_angle_label(0.0), "0.00°", "as the slider shows it at rest"); + assert_eq!(crop_angle_label(2.5), "+2.50°"); + assert_eq!(crop_angle_label(-12.254), "-12.25°"); + assert_eq!(crop_angle_label(-0.001), "0.00°", "no minus zero, and every zero the same"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c98baf41f..8865995f9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -153,7 +153,7 @@ pub fn typed_value(spec: &ControlSpec, text: &str) -> Option { } /// A slider's value as shown next to its label. -fn shown_value(spec: &ControlSpec, v: f64) -> String { +pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String { let shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") }; if shown == "+0" || shown == "-0" { "0".to_string() } else { shown } }