LR-CROP-STRAIGHTEN: an Angle field in the Crop panel for an exact rotation (#534)

The issue asks for a numeric field for an exact, repeatable rotation.
Typing into the Straighten value worked, but it was hidden behind a
hover hint. The Crop panel now has an Angle field under Straighten (a
drag value in degrees, −45° to 45°, two decimals): click and type, or
drag it. A drag is one undo step, like the slider's
(begin / endInteraction). "Angle" is translated in all seven locales.

Headless tests: typing -3.25 into the field sets exactly -3.25°; a
six-step drag on it adds one undo step (it added five before the
interaction was wrapped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 12:10:50 -04:00
co-authored by Claude Opus 5.5
parent e88be40e8c
commit b401b56da4
9 changed files with 71 additions and 7 deletions
+2 -1
View File
@@ -1888,5 +1888,6 @@
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Click to type a value": "Klicken, um einen Wert einzugeben"
"Click to type a value": "Klicken, um einen Wert einzugeben",
"Angle": "Winkel"
}
+2 -1
View File
@@ -1733,5 +1733,6 @@
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Click to type a value": "Hacer clic para escribir un valor"
"Click to type a value": "Hacer clic para escribir un valor",
"Angle": "Ángulo"
}
+2 -1
View File
@@ -1733,5 +1733,6 @@
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Click to type a value": "クリックして値を入力"
"Click to type a value": "クリックして値を入力",
"Angle": "角度"
}
+2 -1
View File
@@ -1733,5 +1733,6 @@
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Click to type a value": "Clique para digitar um valor"
"Click to type a value": "Clique para digitar um valor",
"Angle": "Ângulo"
}
+2 -1
View File
@@ -1406,5 +1406,6 @@
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Click to type a value": "Щёлкните, чтобы ввести значение"
"Click to type a value": "Щёлкните, чтобы ввести значение",
"Angle": "Угол"
}
+2 -1
View File
@@ -1733,5 +1733,6 @@
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Click to type a value": "点击以输入数值"
"Click to type a value": "点击以输入数值",
"Angle": "角度"
}
+2 -1
View File
@@ -1733,5 +1733,6 @@
"Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed",
"ONNX model": "ONNX model",
"commercial use subject to GPL conditions": "commercial use subject to GPL conditions",
"Click to type a value": "按一下以輸入數值"
"Click to type a value": "按一下以輸入數值",
"Angle": "角度"
}
+26
View File
@@ -185,6 +185,32 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if let Some(spec) = ang {
let out = slider(ui, &spec, d.crop.geometry.angle, true, Some("Straighten"));
super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v})));
// an exact angle, typed (issue #534): a field of its own, besides the slider's value
padded(ui, |ui| {
ui.horizontal(|ui| {
ui.label(crate::i18n::tr("Angle"));
let mut angle = d.crop.geometry.angle;
let r = ui.add(
egui::DragValue::new(&mut angle)
.range(spec.min..=spec.max)
.clamp_existing_to_range(false)
.speed(0.05)
.fixed_decimals(2)
.suffix("°"),
);
crate::widgets::register(ui.ctx(), "cropAngleField", r.rect);
// a drag on the field is one undo step, like the slider's
if r.drag_started() {
let _ = app.run("develop.beginInteraction", json!({"label": "Straighten"}));
}
if r.changed() && angle.is_finite() {
let _ = app.run("crop.straighten", json!({"angle": (angle.clamp(spec.min, spec.max) * 100.0).round() / 100.0}));
}
if r.drag_stopped() {
let _ = app.run("develop.endInteraction", json!({}));
}
});
});
}
padded(ui, |ui| {
ui.horizontal(|ui| {
+31
View File
@@ -128,6 +128,25 @@ fn command_held_shows_the_straighten_crosshair() {
assert!(!has(&h, "cropRotateCursor"));
}
/// The Crop panel has an angle field: click it, type an exact angle, Return.
#[test]
fn the_crop_panel_has_an_angle_field() {
let mut h = crop_tool();
let r = h.request("ui.clickWidget", json!({"id": "cropAngleField"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
for key in ["a", "Backspace"] {
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": "-3.25"}), 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) + 3.25).abs() < 1e-9, "typed -3.25°, got {}", angle(&h));
}
/// Rotating with the pointer at the canvas's corner keeps the angle readout on the canvas (and so
/// on screen), rather than spilling past its edge.
#[test]
@@ -147,3 +166,15 @@ fn the_readout_stays_on_the_canvas() {
let readout = h.app.widgets.iter().find(|(w, _)| w == "cropAngleReadout").map(|(_, r)| *r).expect("the readout while rotating");
assert!(canvas.contains_rect(readout), "{readout:?} in {canvas:?}");
}
/// Dragging the angle field changes the angle as one undo step, like the slider.
#[test]
fn dragging_the_angle_field_is_one_undo_step() {
let mut h = crop_tool();
let undo = h.app.session.undo.len();
let r = h.request("ui.dragWidget", json!({"id": "cropAngleField", "dx": 40.0, "dy": 0.0, "steps": 6}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(angle(&h) > 0.0, "dragged right: {}", angle(&h));
assert_eq!(h.app.session.undo.len(), undo + 1, "one step for the whole drag");
}