LR-CROP-STRAIGHTEN: a rotation pointer and an angle readout in the crop tool (#534)

Dragging outside the crop box rotates the photo, but the pointer there
was egui's generic Alias arrow, so nothing showed that a drag would
rotate (issue #534).

- Outside the box, and throughout a rotation drag, the system cursor is
  hidden and a curved double arrow (new code-drawn Icon::RotateDrag) is
  drawn at the pointer on a dark disc, on the tooltip layer
  (`cropRotateCursor`). No system cursor has a rotate shape.
- While rotating, the angle is shown next to the pointer
  (`cropAngleReadout`), formatted like the Straighten value
  (crop_angle_label, reusing widgets::shown_value), every zero as
  "0.00°".
- Headless tests: the cursor outside vs inside the box, the readout
  during a drag and gone after it, the label format, and typing an
  exact angle into the Straighten value (which already worked; now
  guarded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 12:10:44 -04:00
co-authored by Claude Opus 5.5
parent ef31741fbb
commit 3ec6b72871
5 changed files with 163 additions and 7 deletions
+8
View File
@@ -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)]);
+2
View File
@@ -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)]
+51 -6
View File
@@ -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) {
+101
View File
@@ -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");
}
+1 -1
View File
@@ -153,7 +153,7 @@ pub fn typed_value(spec: &ControlSpec, text: &str) -> Option<f64> {
}
/// 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 }
}