LR-CROP-STRAIGHTEN: rotation pointer review fixes: ⌘ crosshair, readout on the canvas, no lag

From review:
- With ⌘ held a drag draws a level line, but outside the crop box the
  pointer showed the rotation arrow (the crosshair was set, then
  overwritten in the same frame). The crop tool now leaves the
  straighten crosshair alone while ⌘ is held and nothing is rotating.
- The angle readout ran past the canvas (and the window) near its
  right or bottom edge. It now flips to the pointer's other side and is
  kept inside the canvas (readout_rect).
- The readout showed the angle from before this frame's rotation. It is
  now drawn after the drag is applied, with the angle just set.

Headless tests: ⌘ held outside the box gives the crosshair and no
glyph; rotating at the canvas's corner keeps the readout on the canvas.

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 959cfa65f3
commit e88be40e8c
2 changed files with 58 additions and 8 deletions
+25 -8
View File
@@ -1251,7 +1251,9 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
// 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 {
// with ⌘ held a drag draws a level line (the straighten crosshair, set above), not a rotation
let straightening = !rotating && ui.input(|i| i.modifiers.command);
if let Some(hq) = pointer.filter(|_| !straightening) {
let near = handles.iter().position(|h| h.distance(hq) < 12.0).filter(|_| !rotating);
let cursor = match near {
Some(0 | 2) => egui::CursorIcon::ResizeNwSe,
@@ -1266,10 +1268,10 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
}
};
ui.ctx().set_cursor_icon(cursor);
if rotating {
angle_readout(ui, hq, d.crop.geometry.angle);
}
}
// the angle next to the pointer, drawn once this frame's rotation is applied (below)
let readout_at = pointer.filter(|_| rotating);
let mut shown_angle = d.crop.geometry.angle;
// double-click inside the crop box applies the crop (same as Return / Done)
if resp.double_clicked()
&& let Some(q) = resp.interact_pointer_pos()
@@ -1310,7 +1312,8 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
let c = map.screen(Point::new(0.5, 0.5));
let a = (q - c).angle();
let ang = (start_angle + (a - a0).to_degrees() as f64).clamp(-45.0, 45.0);
let _ = app.run("crop.straighten", json!({"angle": (ang * 100.0).round() / 100.0}));
shown_angle = (ang * 100.0).round() / 100.0;
let _ = app.run("crop.straighten", json!({"angle": shown_angle}));
}
_ => {}
}
@@ -1319,6 +1322,9 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
app.gesture = None;
let _ = app.run("develop.endInteraction", json!({}));
}
if let Some(at) = readout_at {
angle_readout(ui, at, shown_angle, map.rect);
}
let _ = id;
}
@@ -1331,6 +1337,16 @@ pub(crate) fn crop_angle_label(angle: f64) -> String {
format!("{shown}°")
}
/// Where the angle readout of `size` goes for a pointer `at`: below right of it, flipped to the
/// left / above where that would leave `bounds`, then kept inside them.
fn readout_rect(at: Pos2, size: egui::Vec2, bounds: Rect) -> Rect {
let x = if at.x + 18.0 + size.x > bounds.right() { at.x - 18.0 - size.x } else { at.x + 18.0 };
let y = if at.y + 14.0 + size.y > bounds.bottom() { at.y - 14.0 - size.y } else { at.y + 14.0 };
let x = x.clamp(bounds.left(), (bounds.right() - size.x).max(bounds.left()));
let y = y.clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top()));
Rect::from_min_size(pos2(x, y), size)
}
/// 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)))
@@ -1345,12 +1361,13 @@ fn rotate_cursor(ui: &egui::Ui, at: Pos2) {
register(ui.ctx(), "cropRotateCursor", r);
}
/// While rotating: the angle, next to the pointer.
fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64) {
/// While rotating: the angle, next to the pointer, kept on the canvas (`bounds`): on the pointer's
/// other side when it would run past an edge.
fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) {
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));
let rect = readout_rect(at, galley.size() + vec2(12.0, 6.0), bounds);
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);
+33
View File
@@ -114,3 +114,36 @@ fn the_straighten_value_says_it_can_be_typed() {
h.painted_text().iter().filter(|t| t.contains("lick")).collect::<Vec<_>>()
);
}
/// With ⌘ held a drag draws a level line instead of rotating, so the pointer is the crosshair, not
/// the rotation arrow.
#[test]
fn command_held_shows_the_straighten_crosshair() {
let mut h = crop_tool();
let img = h.app.image_rect.expect("the photo on screen");
h.events.push(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND));
h.events.push(egui::Event::PointerMoved(egui::pos2(img.left() - 24.0, img.center().y)));
h.settle(SETTLE);
assert_eq!(h.last_cursor, egui::CursorIcon::Crosshair);
assert!(!has(&h, "cropRotateCursor"));
}
/// 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]
fn the_readout_stays_on_the_canvas() {
let mut h = crop_tool();
let img = h.app.image_rect.expect("the photo on screen");
let canvas = h.app.canvas_rect.expect("the canvas");
let corner = canvas.right_bottom() - egui::vec2(4.0, 4.0);
let (x, y) = ((corner.x - img.left()) / img.width(), (corner.y - img.top()) / img.height());
let r = h.request(
"ui.pointer",
json!({"events": [{"kind": "down", "x": x, "y": y - 0.1}, {"kind": "drag", "x": x, "y": y - 0.05}, {"kind": "drag", "x": x, "y": y}]}),
T,
);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
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:?}");
}