From 06550937e89afcaf607f5840aae86eba0b74220a Mon Sep 17 00:00:00 2001 From: Daniel Spruce Date: Fri, 9 Oct 2026 16:16:14 +1100 Subject: [PATCH] LR-CROP-ASPECT: remember the last crop aspect as the default --- crates/ui-egui/src/headless.rs | 28 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 33 ++++++++++++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 1 + crates/ui-egui/src/state.rs | 6 ++++++ docs/parity.md | 2 +- 5 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e6929269f9..ca422c352e 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1572,6 +1572,34 @@ mod tests { assert!(ids(&mut h, "notice:previewOnly:").is_empty()); } + #[test] + fn crop_remembers_last_aspect_across_photos() { + let mut h = demo([1200.0, 800.0]); + let photos = h.app.session.visible_cloned(); + h.app.ui.right = crate::state::RightPanel::Crop; + h.app.run("crop.aspect", json!({"aspect": "original"})).unwrap(); + let saved = serde_json::to_value(&h.app.ui).unwrap(); + assert_eq!(saved["cropDefaultAspect"], "original"); + for &photo in &photos[1..3] { + h.app.run("library.select", json!({"ids": [photo.0]})).unwrap(); + h.app.apply_crop_default(); + let p = h.app.session.catalog.photo(photo).unwrap(); + assert_eq!(p.develop.crop.aspect, Some((p.width * 100, p.height * 100))); + } + h.app.run("crop.aspect", json!({"aspect": "1x1"})).unwrap(); + let existing = h.app.session.develop_of(photos[2]).unwrap().crop; + h.app.run("library.select", json!({"ids": [photos[3].0]})).unwrap(); + h.app.apply_crop_default(); + assert_eq!(h.app.session.develop_of(photos[3]).unwrap().crop.aspect, Some((100, 100))); + h.app.run("crop.aspect", json!({"aspect": "free"})).unwrap(); + h.app.run("library.select", json!({"ids": [photos[2].0]})).unwrap(); + h.app.apply_crop_default(); + assert_eq!(h.app.session.develop_of(photos[2]).unwrap().crop, existing); + h.app.ui = serde_json::from_value(saved).unwrap(); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("original"))); + h.settle(SETTLE); + } + /// 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/lib.rs b/crates/ui-egui/src/lib.rs index 7058189111..523e5a066f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -349,6 +349,17 @@ impl LightcraftApp { return r; } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + if r.is_ok() && matches!(id, "crop.aspect" | "crop.rotateAspect") { + self.ui.crop_default_aspect = if id == "crop.aspect" && params.get("aspect").and_then(Value::as_str) == Some("original") { + Some(serde_json::json!("original")) + } else { + self.session + .active() + .and_then(|photo| self.session.develop_of(photo)) + .map(|d| d.crop.aspect.map_or(serde_json::json!("free"), |(w, h)| serde_json::json!([w as f64 / 100.0, h as f64 / 100.0]))) + }; + self.ui.crop_default_photo = self.session.active(); + } if r.is_ok() && id == "photo.label" { let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse); let text = match label { @@ -378,6 +389,27 @@ impl LightcraftApp { r } + /// Initialize the crop tool on untouched photos without replacing existing crops. + pub(crate) fn apply_crop_default(&mut self) { + if self.ui.right != state::RightPanel::Crop { + self.ui.crop_default_photo = None; + return; + } + let photo = self.session.active(); + if self.ui.crop_default_photo == photo { + return; + } + self.ui.crop_default_photo = photo; + if let Some(photo) = photo + && self.session.develop_of(photo).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) + && let Some(aspect) = self.ui.crop_default_aspect.clone() + && aspect != serde_json::json!("free") + && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect})) + { + self.ui.status = e.to_string(); + } + } + /// Show a transient toast at the bottom of the canvas (like the reference app's HUD). /// Advance a running slideshow (wrapping around at the end). fn slideshow_tick(&mut self, ctx: &egui::Context) { @@ -695,6 +727,7 @@ impl LightcraftApp { } fn logic_inner(&mut self, ctx: &egui::Context) { + self.apply_crop_default(); if !self.styled { theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref()); theme::apply(ctx); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 239212d79d..76b8f39843 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -129,6 +129,7 @@ fn custom_aspect(app: &mut LightcraftApp, ui: &mut egui::Ui) { } fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { + app.apply_crop_default(); let d = app.session.develop_of(id).unwrap_or_default(); header(ui, "Crop"); padded(ui, |ui| { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 83b3df2922..a85e17b4fc 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -321,6 +321,10 @@ pub struct UiState { /// Mirroring of the triangle / spiral crop guides (0..4). pub crop_overlay_orient: u8, pub crop_overlay: CropOverlay, + /// Last explicitly chosen aspect, retained across photos and app launches. + pub crop_default_aspect: Option, + #[serde(skip)] + pub crop_default_photo: Option, pub show_filenames: bool, /// What the square grid's caption shows: `filename`, `exposure` (shutter · aperture · ISO · /// focal length) or `date`. @@ -703,6 +707,8 @@ impl Default for UiState { mask_overlay_opacity: 50.0, mask_pins: true, crop_overlay: CropOverlay::Thirds, + crop_default_aspect: None, + crop_default_photo: None, crop_overlay_orient: 0, show_filenames: true, grid_info: "filename".into(), diff --git a/docs/parity.md b/docs/parity.md index 4c27755b52..948f5df988 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -308,7 +308,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) | -| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot) | +| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as the default for untouched photos in Crop, resolving Original per photo and preserving existing crops | | LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically | | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle` | |