From 328bdabe6611dc30aae093e8822b19c82e9764eb Mon Sep 17 00:00:00 2001 From: haranishi <223786623+haranishi@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:17:34 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=88=87=E3=82=8A=E6=8A=9C=E3=81=8D?= =?UTF-8?q?=E3=81=AE=E5=88=9D=E6=9C=9F=E6=AF=94=E7=8E=87=E3=82=92Original?= =?UTF-8?q?=E3=81=A7=E3=83=AD=E3=83=83=E3=82=AF=20#991?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 6ff9b95fb960ae00c10ee1642ffcab1559d86809) --- crates/ui-egui/src/headless.rs | 42 +++++++++++++++++++++++++++++++--- crates/ui-egui/src/state.rs | 10 ++++++-- docs/parity-checklist.md | 3 ++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 3c72d056..d2d3a9fe 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2450,6 +2450,42 @@ mod tests { assert_eq!(h.app.session.develop_of(photo).unwrap(), before); } + #[test] + fn crop_starts_locked_to_original_without_editing_and_remembers_unlock() { + use crate::state::UiState; + for saved in [json!({}), json!({"cropDefaultAspect": null})] { + let mut h = demo([1200.0, 800.0]); + h.app.ui = serde_json::from_value::(saved).unwrap(); + let photos: Vec<_> = + h.app.session.visible_cloned().into_iter().filter(|id| !h.app.session.catalog.photo(*id).unwrap().is_edited()).collect(); + let photo = photos[0]; + h.app.run("library.select", json!({"ids": [photo.0]})).unwrap(); + h.app.run("panel.crop", json!({})).unwrap(); + let before = h.app.session.develop_of(photo).unwrap(); + let p = h.app.session.catalog.photo(photo).unwrap(); + let original = (p.width * 100, p.height * 100); + let undo = h.app.session.undo.len(); + h.settle(SETTLE); + assert_eq!(h.app.crop_tool_aspect(), Some(original)); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); + assert_eq!(h.app.session.undo.len(), undo); + h.app.run("develop.beginInteraction", json!({"label": "Crop"})).unwrap(); + h.app.begin_crop().unwrap(); + h.app.run("crop.drag", json!({"handle": "bottomRight", "from": [0.8, 0.8], "to": [0.7, 0.6]})).unwrap(); + h.app.run("develop.endInteraction", json!({})).unwrap(); + assert_eq!(h.app.session.develop_of(photo).unwrap().crop.aspect, Some(original)); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); + h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap(); + let saved = serde_json::to_value(&h.app.ui).unwrap(); + h.app.ui = serde_json::from_value(saved).unwrap(); + h.app.run("library.select", json!({"ids": [photos[1].0]})).unwrap(); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + assert_eq!(h.app.crop_tool_aspect(), None); + } + } + /// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect. #[test] fn crop_keys() { @@ -2464,12 +2500,12 @@ mod tests { h.request("ui.key", json!({"key": "O", "shift": true}), t); assert_eq!(h.app.ui.crop_overlay_orient, 1); assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping"); - let locked = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().crop.aspect.is_some(); - assert!(!locked(&h)); - h.request("ui.key", json!({"key": "A"}), t); + let locked = |h: &Headless| h.app.crop_tool_aspect().is_some(); assert!(locked(&h)); h.request("ui.key", json!({"key": "A"}), t); assert!(!locked(&h)); + h.request("ui.key", json!({"key": "A"}), t); + assert!(locked(&h)); assert!(!h.app.ui.visualize_spots); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2f389da3..c266f3fc 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -579,7 +579,8 @@ 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. + /// Last chosen aspect, retained across photos and launches; Original until a choice is made. + #[serde(deserialize_with = "deserialize_crop_default_aspect")] pub crop_default_aspect: Option, pub show_filenames: bool, /// What the square grid's caption shows: `filename`, `exposure` (shutter · aperture · ISO · @@ -1106,7 +1107,7 @@ impl Default for UiState { mask_overlay_opacity: 50.0, mask_pins: true, crop_overlay: CropOverlay::Thirds, - crop_default_aspect: None, + crop_default_aspect: Some(serde_json::json!("original")), crop_overlay_orient: 0, show_filenames: true, grid_info: "filename".into(), @@ -1256,6 +1257,11 @@ impl UiState { } } +fn deserialize_crop_default_aspect<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + // Legacy null means no choice yet. An explicitly unlocked crop is saved as "free". + Ok(Option::::deserialize(d)?.or_else(|| Some(serde_json::json!("original")))) +} + fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { let values = Vec::::deserialize(d)?; let mut seen = std::collections::BTreeSet::new(); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4ef5884b..93038407 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -254,7 +254,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), 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 a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step | +| 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, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), 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 a crop tool default, resolving Original per photo and preserving existing crops; without a previous choice (including legacy unset preferences) it starts locked to Original, while an explicit Free choice survives restarts (issue #991); opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step | | LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it to the largest crop of its aspect inside the photo (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | 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`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | @@ -825,6 +825,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-11 | minor | Alt/Option-drag previews on Exposure, Highlights, Shadows, Whites, Blacks and Sharpening (issue #937); LR-EDIT-DETAIL-SHARPEN ✅ → 🟡: Radius and Detail don't change the render | | 2026-10-12 | minor | LR-IMP-FORMATS: Minolta MRW decodes from sensor data (`vendor/mrw.rs`), no longer preview-only | | 2026-10-12 | minor | LR-VIEW-ZOOM: click-zoom uses the clicked Before/After pane; regression coverage for both panes in both arrangements (issue #1035); status unchanged | +| 2026-10-12 | minor | LR-CROP-ASPECT: new and legacy unset tool preferences lock to Original; an explicit Free choice remains unlocked across restarts (issue #991); status unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |