mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 19:47:35 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ef31741fbb
commit
3ec6b72871
@@ -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)]);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user