From a8f353ba64308ba71ae7b54828c8f4d759504f47 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:24:42 +0000 Subject: [PATCH] Show the remembered crop ratio's box on an untouched photo Fixes #3029 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 27 +++++++++++++++ crates/ui-egui/src/headless/tests.rs | 42 ++++++++++++++++++++++++ crates/ui-egui/src/menus/run.rs | 3 ++ crates/ui-egui/src/panels/detail/crop.rs | 9 +++++ 4 files changed, 81 insertions(+) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index fa10210f0..02da652ca 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -313,6 +313,33 @@ impl LightcraftApp { } } + /// The box the crop tool shows on an untouched photo with a remembered ratio (issue #3029): fitted + /// as `begin_crop` (and Return) will apply it, so the box matches the dropdown before any edit. + pub(crate) fn crop_tool_preview(&self) -> Option { + let photo = self.session.active()?; + let d = self.session.develop_of(photo)?; + if d.crop != lightcraft_develop::Crop::default() || !matches!(self.ui.crop_default_aspect, Some(Value::Array(_))) { + return None; + } + let (aw, ah) = self.crop_tool_aspect()?; + let p = self.session.catalog.photo(photo)?; + let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + let (w, h) = if d.orientation.swaps_axes() { (h, w) } else { (w, h) }; + // turned like the photo's full-frame crop, as `crop.aspect` with `orientation: "crop"` does + let (aw, ah) = if (aw >= ah) != (w >= h) { (ah, aw) } else { (aw, ah) }; + Some(lightcraft_geom::crop_fit_angle(w, h, d.crop.geometry.angle, Some(f64::from(aw) / f64::from(ah.max(1))))) + } + + /// Return on an untouched photo crops it to the remembered ratio's box the tool showed. + pub(crate) fn apply_crop_preview(&mut self) { + if self.crop_tool_preview().is_some() + && let Some(aspect) = self.ui.crop_default_aspect.clone() + && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect, "orientation": "crop"})) + { + self.ui.status = e.to_string(); + } + } + /// Called only after a crop gesture begins, inside its undo transaction. pub(crate) fn begin_crop(&mut self) -> Result<(), String> { // nothing chosen yet: the photo's own shape, as `crop_tool_aspect` shows it diff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs index 13bd7a2ae..bca0c7659 100644 --- a/crates/ui-egui/src/headless/tests.rs +++ b/crates/ui-egui/src/headless/tests.rs @@ -2337,6 +2337,48 @@ fn crop_default_browsing_is_read_only_and_drag_is_one_edit() { assert_eq!(h.app.session.develop_of(photo).unwrap(), before); } +/// Issue #3029: a ratio picked on one photo is the one the crop tool offers on the next. The box +/// drawn on an untouched photo has that ratio too (not the photo's full frame), browsing still +/// doesn't edit the photo, and Return crops it to the box shown. +#[test] +fn crop_box_on_the_next_photo_shows_the_remembered_ratio() { + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + let photos: Vec<_> = h.app.session.visible_cloned().into_iter().filter(|id| !h.app.session.catalog.photo(*id).unwrap().is_edited()).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [photos[0].0]}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.crop"}), t); + h.app.run("crop.aspect", json!({"aspect": "4x5"})).unwrap(); + h.app.run("tool.done", json!({})).unwrap(); + h.settle(SETTLE); + let photo = photos[1]; + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [photo.0]}}), t); + let before = h.app.session.catalog.photo(photo).unwrap().clone(); + let undo = h.app.session.undo.len(); + h.request("engine.execute", json!({"command": "panel.crop"}), t); + h.settle(SETTLE); + let after = h.app.session.catalog.photo(photo).unwrap(); + assert_eq!(after.develop, before.develop, "browsing doesn't edit the photo"); + assert_eq!(after.history.len(), before.history.len()); + assert_eq!(h.app.session.undo.len(), undo); + let (aw, ah) = h.app.crop_tool_aspect().expect("the remembered ratio"); + let shown = f64::from(aw) / f64::from(ah); + let same = |a: f64, b: f64| (a - b).abs() < 0.02 || (1.0 / a - b).abs() < 0.02; + let corner = |h: &Headless, i: usize| { + h.app.widgets.iter().find(|(w, _)| *w == format!("cropHandle:{i}")).map(|(_, r)| r.center()).expect("a corner handle") + }; + let (tl, br) = (corner(&h, 0), corner(&h, 2)); + let drawn = f64::from((br.x - tl.x) / (br.y - tl.y)); + assert!(same(shown, drawn), "the dropdown says {shown:.3} but the crop box is {drawn:.3}"); + // Return crops to the box shown + h.request("engine.execute", json!({"command": "tool.done"}), t); + h.settle(SETTLE); + let p = h.app.session.catalog.photo(photo).unwrap(); + let r = p.develop.crop.geometry.rect; + let cropped = r.width() * f64::from(p.width) / (r.height() * f64::from(p.height)); + assert!(same(shown, cropped), "the dropdown says {shown:.3} but the crop is {cropped:.3}"); +} + /// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect. #[test] fn crop_keys() { diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index c88a50207..c0f17d798 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -957,6 +957,9 @@ fn ui_commands_7(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Val // Return commits a tool panel (crop, remove, red eye, masking): back to Edit use RightPanel::*; if app.ui.dialog.is_none() && matches!(app.ui.right, Crop | Remove | RedEye | Masking) { + if app.ui.right == Crop { + app.apply_crop_preview(); + } let _ = app.session.end_interaction(); app.ui.tool.clear(); app.ui.right = Edit; diff --git a/crates/ui-egui/src/panels/detail/crop.rs b/crates/ui-egui/src/panels/detail/crop.rs index f88b23fdd..73765366d 100644 --- a/crates/ui-egui/src/panels/detail/crop.rs +++ b/crates/ui-egui/src/panels/detail/crop.rs @@ -36,6 +36,15 @@ pub(super) fn crop_overlay( if resp.hovered() && ui.input(|i| i.modifiers.command) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } + // an untouched photo shows the remembered ratio's box, which the first drag (or Return) applies + let preview; + let d = match app.crop_tool_preview() { + Some(geometry) => { + preview = DevelopSettings { crop: lightcraft_develop::Crop { geometry, ..d.crop }, ..d.clone() }; + &preview + } + None => d, + }; let quad = frame_crop_quad(d, frame); let pts: Vec = quad.iter().map(|q| map.screen(*q)).collect(); let p = ui.painter();