From e88be40e8cda6e67be5c6d5b2c88743673ca240e Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 11:46:03 -0400 Subject: [PATCH] =?UTF-8?q?LR-CROP-STRAIGHTEN:=20rotation=20pointer=20revi?= =?UTF-8?q?ew=20fixes:=20=E2=8C=98=20crosshair,=20readout=20on=20the=20can?= =?UTF-8?q?vas,=20no=20lag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ui-egui/src/panels/detail.rs | 33 +++++++++++++++++++------ crates/ui-egui/src/tests_crop_rotate.rs | 33 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 4ca158abd..7c14a2bc2 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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); diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests_crop_rotate.rs index 977cb4eb6..3ef63f421 100644 --- a/crates/ui-egui/src/tests_crop_rotate.rs +++ b/crates/ui-egui/src/tests_crop_rotate.rs @@ -114,3 +114,36 @@ fn the_straighten_value_says_it_can_be_typed() { h.painted_text().iter().filter(|t| t.contains("lick")).collect::>() ); } + +/// 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:?}"); +}