From 57f54868debcdd3a7efebc0041872bd8220d422a Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:03:09 +0400 Subject: [PATCH 001/154] Update documentation for render state and windows --- crates/ui-egui/src/region.rs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe47f97b..8fe29e135 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -25,7 +25,7 @@ pub struct RegionView { pub key: u64, pub full: (usize, usize), pub window: PixelWindow, - /// Hash of the develop settings it was rendered with. + /// Hash of the render state it was rendered with (develop settings plus any window-safe overlay). pub settings: u64, } @@ -72,9 +72,9 @@ pub struct ViewSizes { pub texture_side: usize, /// 1.0, or less while a slider is dragged (a draft of the whole-frame render). pub draft_scale: f32, - /// Whether a window can be drawn over the whole-frame render (not with an overlay, soft - /// proofing, or a read too far for one window): if not, the whole frame is rendered at the - /// size it is drawn. + /// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay + /// that needs whole-frame context, soft proofing, or a read too far for one window). Mask + /// overlays are window-safe. pub windows: bool, } @@ -97,9 +97,9 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long }; let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 }; if !v.windows { - // the whole frame at the size it is drawn (an overlay or soft proof has no window); while a - // slider drags, a canvas-sized draft as with windows: a drag at 1:1 must not render the - // photo's every pixel each frame + // the whole frame at the size it is drawn (soft proof or a whole-frame diagnostic has no + // window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must + // not render the photo's every pixel each frame let long = if scale < 1.0 { drawn_long.min(canvas_long) } else { drawn_long }; let main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side); return LoupePlan { main_edge, window_edge: None }; @@ -281,8 +281,9 @@ mod tests { assert_eq!(drag.window_edge, still.window_edge); } - // Given no window (an overlay, soft proofing), the view at rest renders the whole frame at the - // size it is drawn, but a slider drag drafts at the canvas size like the windowed path + // Given no window (a whole-frame diagnostic or soft proofing), the view at rest renders the + // whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the + // windowed path #[test] fn without_a_window_a_drag_drafts_at_the_canvas_size() { let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, ..sizes(24000.0, 2800.0, 6000) }; @@ -331,8 +332,8 @@ mod tests { } } - // Given a mode whose picture can't be a window (a mask overlay, soft proofing, a spot read too - // far for one render), the whole-frame render is as big as the view, as it was before windows + // Given a mode whose picture can't be a window (soft proofing, a whole-frame diagnostic, or a + // spot read too far for one render), the whole-frame render is as big as the view #[test] fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) }); From 330f824c316aac0703074ec6461849094600316f Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:03:42 +0400 Subject: [PATCH 002/154] masking: keep zoom and pan across mask interactions (detail) Added a function to check if an overlay supports window rendering. Updated overlay handling in various contexts to ensure compatibility with new overlay support logic. --- crates/ui-egui/src/panels/detail.rs | 41 ++++++++++++++++++----------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 2e708da66..b2f760f2f 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -214,9 +214,17 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { r } +/// Whether an overlay can be rendered on a region tile without changing what the tile means. +/// Mask overlays are evaluated from the same mask alpha as the full render; the other diagnostic +/// overlays either need whole-frame context or deliberately replace the loupe. +pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { + matches!(overlay, lightcraft_pipeline::Overlay::None | lightcraft_pipeline::Overlay::Mask { .. }) +} + /// What a window render of the loupe needs to know about the view it belongs to. struct WindowCtx { id: PhotoId, + overlay: lightcraft_pipeline::Overlay, /// Long edge of the frame the windows are cut from (`None`: no windows). frame_edge: Option, drawn_long: f32, @@ -282,9 +290,14 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt app.window_refused = Some((c.id, c.look, frame_edge)); return None; }; - // a drag renders drafts of the window, as it does of the whole frame + // a drag renders drafts of the window, as it does of the whole frame. The Before tile stays + // plain; the After tile carries the same mask overlay as the whole-frame render. let job = if c.interacting { job.draft() } else { job }; - let look = job.settings.hash64(); + let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay }; + let job = job.with_overlay(overlay); + // The overlay is part of the tile's identity too, so changing mask view/colour never leaves a + // stale region tile on screen while its replacement renders. + let look = job.settings.hash64() ^ overlay.key().rotate_left(17); let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look }; // windows of other photos, looks and zooms of this side can't be drawn any more app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting)); @@ -393,15 +406,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let drawn_long = target_rect.width().max(target_rect.height()) * ppp; // the whole frame at about canvas size; the zoomed part of it is a window render (below) let look = d.hash64(); - // (modes that draw another picture over the loupe, or reads too far for one window, have the - // whole frame rendered at the size it is drawn, as they did before windows) + let overlay = view_overlay(app, &d); + // Diagnostic views that need whole-frame context, soft proofing, or reads too far for one + // window still render the whole frame at the size it is drawn. A mask overlay is window-safe. let sizes = crate::region::ViewSizes { drawn_long, canvas_long: canvas.width().max(canvas.height()) * ppp, native_long, texture_side, draft_scale: scale, - windows: !app.ui.soft_proof && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None, + windows: !app.ui.soft_proof && overlay_supports_window(overlay), }; let mut plan = crate::region::plan(&app.ui.settings, sizes); if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { @@ -420,7 +434,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; - let job = job.with_overlay(view_overlay(app, &d)).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); + let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); app.renderer.request(Slot::Main, job, 100); } // hovering a preset or profile: the photo with that look, shown instead of the loupe render @@ -468,17 +482,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.renderer.request(Slot::Before, job, 90); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more - // than 100 % (the GPU magnifies beyond that), for each side that is shown. Not while hovering a - // look or showing an overlay (those draw another image), nor where the shown picture isn't the - // frame's own (an unsupported raw's embedded JPEG with another crop: window coordinates are - // the frame's). - let plain_view = hover_key.is_none() - && !app.ui.soft_proof - && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None - && crate::region::same_aspect(display_aspect, aspect); + // than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays use + // the same window; diagnostic overlays that need whole-frame context do not. + let windowable_view = + hover_key.is_none() && !app.ui.soft_proof && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect); let window_ctx = WindowCtx { id, - frame_edge: plan.window_edge.filter(|_| plain_view), + overlay, + frame_edge: plan.window_edge.filter(|_| windowable_view), drawn_long, aspect, ppp, From 128539a93985f2addabf4979e6ec7aeb90307e9f Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:04:11 +0400 Subject: [PATCH 003/154] masks: improve sky mask edges and horizon handling Refactor sky masking logic to use rasterize_sky function for improved clarity and maintainability. --- crates/pipeline/src/masks.rs | 104 +++++++++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 16 deletions(-) diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 4777c8782..0ea77c300 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -228,21 +228,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R *v = 1.0 - smooth(tol * 0.5, tol, d); } } - MaskShape::Sky => { - // Classical sky heuristic until the segmenter lands (M12): bright, smooth, blue-ish or - // unsaturated, and connected to the top of the frame. - let m = frame.out_to_norm(w, h); - for (i, v) in out.data.iter_mut().enumerate() { - let (x, y) = (i % w, i / w); - let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); - let c = img.data[i].map(|v| v * gain); - let l = log_l.data[i] + ev; - let blue = (c[2] - c[0]).max(0.0) / (c[2] + 1e-4); - let top = 1.0 - smooth(0.25, 0.7, n.y as f32); - *v = top * smooth(-3.5, -1.0, l) * (0.4 + 0.6 * smooth(0.0, 0.3, blue).max(smooth(0.0, 1.5, l))); - } - smooth_plane(&mut out, 0.01 * frame_px(frame, w)); - } + MaskShape::Sky => rasterize_sky(frame, w, h, img, log_l, ev, &mut out), MaskShape::Object { seg, detail, edge, .. } | MaskShape::Prompt { seg, detail, edge, .. } if seg.is_some() || !detail.is_empty() => { // Edge: below 0 a steeper transition (up to 8× the logits), above 0 feathered (up to // 2 % of the long edge, so previews and exports match) @@ -342,6 +328,68 @@ fn smooth_plane(p: &mut Plane, sigma: f32) { *p = lightcraft_raster::blur::gaussian(p, sigma.max(0.5)); } +/// Classical sky fallback for photos without a semantic sky matte. +/// +/// The old score had a 0.4 floor for every bright pixel and faded every candidate from 25 % to +/// 70 % down the frame, which both admitted bright non-sky near the horizon and removed real sky +/// before it reached the horizon. It then used a broad Gaussian (1 % of the long edge), smearing +/// the selection across branches, wires and the horizon. Keep this heuristic local/window-stable: +/// a true flood fill to the top cannot be reproduced by a zoom-window render without evaluating +/// the missing part of the frame. +fn rasterize_sky(frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32, out: &mut Plane) { + if w == 0 || h == 0 { + return; + } + let m = frame.out_to_norm(w, h); + let gain = ev.exp2(); + for (i, v) in out.data.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + let c = img.data[i].map(|v| (v * gain).max(0.0)); + let l = log_l.data[i] + ev; + let hi = c[0].max(c[1]).max(c[2]); + let lo = c[0].min(c[1]).min(c[2]); + let sat = (hi - lo) / (hi + 1e-4); + let blue = (c[2] - c[0].max(c[1])).max(0.0) / (c[2] + 1e-4); + let py = n.y as f32; + + let bright = smooth(-3.5, -0.7, l); + let blue_sky = smooth(0.01, 0.18, blue); + // Bright, low-chroma cloud/overcast remains possible, but unlike the old brightness floor + // it gets a stronger vertical prior so neutral ground does not qualify as readily. + let neutral_sky = (1.0 - smooth(0.08, 0.24, sat)) * smooth(-2.8, -0.5, l) * (1.0 - smooth(0.50, 0.80, py)); + // Blue sky is allowed much closer to the horizon than before; only the bottom of the frame + // is strongly discouraged. + let height = 0.15 + 0.85 * (1.0 - smooth(0.66, 0.94, py)); + let score = bright * blue_sky.max(0.85 * neutral_sky) * height; + *v = smooth(0.16, 0.55, score); + } + + // Feather at a quarter of the old width, but only across locally smooth luminance and where + // the unblurred classifier has support. A dark wire/branch therefore stays out instead of + // being filled by the blur; the horizon does not bleed into a strong luminance edge. + let sigma = 0.0025 * frame.output_long(w, h) as f32; + let blurred = lightcraft_raster::blur::gaussian(out, sigma.max(0.5)); + let original = std::mem::replace(out, blurred); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let l = log_l.data[i]; + let edge = (l - log_l.get_clamped(x as isize - 1, y as isize)) + .abs() + .max((l - log_l.get_clamped(x as isize + 1, y as isize)).abs()) + .max((l - log_l.get_clamped(x as isize, y as isize - 1)).abs()) + .max((l - log_l.get_clamped(x as isize, y as isize + 1)).abs()); + let flat = 1.0 - smooth(0.08, 0.35, edge); + let a = original.data[i]; + let support = smooth(0.02, 0.20, a); + let mix = flat * (0.15 + 0.85 * support); + let b = out.data[i]; + out.data[i] = (a + (b - a) * mix).clamp(0.0, 1.0); + } + } +} + /// The union (max) of `mattes` (0..255, over the EXIF-oriented source) at output resolution, /// sampled like the image: user orientation, lens / perspective warp, crop and flips; area-averaged /// first when the output is much smaller than the matte, bilinear otherwise. @@ -809,6 +857,30 @@ mod tests { assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); } + #[test] + fn sky_heuristic_reaches_the_horizon_without_filling_thin_objects() { + let (w, h, horizon) = (200, 120, 72); + let img = Rgb32f::from_fn(w, h, |x, y| { + let wire = y == 34 && x > 20 && x < w - 20; + let branch = (96..=98).contains(&x) && y < horizon; + if wire || branch { + [0.04; 3] + } else if y < horizon { + [0.35, 0.60, 1.10] + } else { + [0.55, 0.38, 0.20] + } + }); + let l = img.map(crate::local::log_lum); + let a = shape_alpha(&MaskShape::Sky, &frame(w, h), w, h, &img, &l, 0.0, None); + + assert!(a.get(30, horizon - 3) > 0.8, "sky reaches the horizon: {}", a.get(30, horizon - 3)); + assert!(a.get(30, horizon + 3) < 0.1, "warm ground stays out: {}", a.get(30, horizon + 3)); + assert!(a.get(80, 34) < 0.2, "thin wire stays out: {}", a.get(80, 34)); + assert!(a.get(97, 50) < 0.2, "thin branch stays out: {}", a.get(97, 50)); + assert!(a.get(90, 50) > 0.8 && a.get(104, 50) > 0.8, "sky beside the branch remains selected"); + } + /// A half-resolution matte selecting the left `frac` of a `w × h` source. fn left_matte(w: usize, h: usize, frac: f64) -> Image { Image::from_fn(w / 2, h / 2, |x, _| if (x as f64 + 0.5) < frac * (w / 2) as f64 { 255 } else { 0 }) @@ -867,4 +939,4 @@ mod tests { let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] }; assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None)); } -} + } From 06550937e89afcaf607f5840aae86eba0b74220a Mon Sep 17 00:00:00 2001 From: Daniel Spruce Date: Fri, 9 Oct 2026 16:16:14 +1100 Subject: [PATCH 004/154] 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 e6929269f..ca422c352 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 705818911..523e5a066 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 239212d79..76b8f3984 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 83b3df292..a85e17b4f 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 4c27755b5..948f5df98 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` | | From 01fc7a5e752ef686ae42a12b6ba5410aa3b270d4 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:01:03 +0200 Subject: [PATCH 005/154] UI: star rating previews the hovered rating, Info tab and bottom bar (#647) --- crates/ui-egui/src/widgets.rs | 49 +++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4281c28ec..54fc662d4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -488,27 +488,32 @@ pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option { let t = Tokens::get(ui.ctx()); let mut out = None; - let hover_n = { - let mut h = None; - for i in 0..5u8 { - let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); - register(ui.ctx(), format!("star:{id}:{}", i + 1), r); - if resp.hovered() { - h = Some(i + 1); - } - if resp.clicked() { - out = Some(if rating == i + 1 { 0 } else { i + 1 }); - } - ui.painter().text(r.center(), Align2::CENTER_CENTER, "", t.font(1.0), t.star); - let filled = i < rating; - paint(ui.painter(), r.shrink(size * 0.12), if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon }); + let mut hover_n = None; + let mut rects = [Rect::NOTHING; 5]; + for (i, slot) in (0..5u8).zip(rects.iter_mut()) { + let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); + register(ui.ctx(), format!("star:{id}:{}", i + 1), r); + if resp.hovered() { + hover_n = Some(i + 1); } - h - }; - let _ = hover_n; + if resp.clicked() { + out = Some(if rating == i + 1 { 0 } else { i + 1 }); + } + *slot = r; + } + // Hover previews the rating a click would set: stars 1..=hovered light up. + for (i, r) in (0..5u8).zip(rects) { + let filled = star_lit(i, rating, hover_n); + paint(ui.painter(), r.shrink(size * 0.12), if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon }); + } out } +/// Whether the star at zero-based index `i` is drawn filled: the hovered star's rating previews over the current one. +fn star_lit(i: u8, rating: u8, hover: Option) -> bool { + i < hover.unwrap_or(rating) +} + /// A borderless dropdown button: `text` followed by a painted chevron (no font glyph needed). pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32) -> Response { let t = Tokens::get(ui.ctx()); @@ -527,6 +532,16 @@ pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Co mod tests { use super::*; + /// Issue #647: hovering a star previews the rating a click would set. + #[test] + fn hovered_star_previews_its_rating() { + let lit = |rating, hover| (0..5u8).filter(|&i| star_lit(i, rating, hover)).count(); + assert_eq!(lit(2, None), 2, "no hover shows the rating"); + assert_eq!(lit(2, Some(4)), 4); + assert_eq!(lit(5, Some(1)), 1); + assert_eq!(lit(0, Some(3)), 3); + } + #[test] fn nudges_are_a_sensible_step() { let spec = |id, min, max, step| ControlSpec { From 760819eab3b33e35fb663166bfeb6b43b888a50b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 03:10:35 -0400 Subject: [PATCH 006/154] LR-MASK-SKY: drop the sky heuristic rewrite Rendered the old and new heuristic over the 24 procedural demo scenes: the rewrite sharpens edges on blue skies (beach, dunes, canyon), but drops warm sunset and dark night skies to nothing, punches a hole around the sun, and selects lavender rows, a flower and lit mountains as sky. It also changes the render of every existing Sky mask without a process version. Back to main's heuristic; the masking-view part of this PR stays. Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/masks.rs | 104 ++++++----------------------------- 1 file changed, 16 insertions(+), 88 deletions(-) diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 0ea77c300..4777c8782 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -228,7 +228,21 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R *v = 1.0 - smooth(tol * 0.5, tol, d); } } - MaskShape::Sky => rasterize_sky(frame, w, h, img, log_l, ev, &mut out), + MaskShape::Sky => { + // Classical sky heuristic until the segmenter lands (M12): bright, smooth, blue-ish or + // unsaturated, and connected to the top of the frame. + let m = frame.out_to_norm(w, h); + for (i, v) in out.data.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + let c = img.data[i].map(|v| v * gain); + let l = log_l.data[i] + ev; + let blue = (c[2] - c[0]).max(0.0) / (c[2] + 1e-4); + let top = 1.0 - smooth(0.25, 0.7, n.y as f32); + *v = top * smooth(-3.5, -1.0, l) * (0.4 + 0.6 * smooth(0.0, 0.3, blue).max(smooth(0.0, 1.5, l))); + } + smooth_plane(&mut out, 0.01 * frame_px(frame, w)); + } MaskShape::Object { seg, detail, edge, .. } | MaskShape::Prompt { seg, detail, edge, .. } if seg.is_some() || !detail.is_empty() => { // Edge: below 0 a steeper transition (up to 8× the logits), above 0 feathered (up to // 2 % of the long edge, so previews and exports match) @@ -328,68 +342,6 @@ fn smooth_plane(p: &mut Plane, sigma: f32) { *p = lightcraft_raster::blur::gaussian(p, sigma.max(0.5)); } -/// Classical sky fallback for photos without a semantic sky matte. -/// -/// The old score had a 0.4 floor for every bright pixel and faded every candidate from 25 % to -/// 70 % down the frame, which both admitted bright non-sky near the horizon and removed real sky -/// before it reached the horizon. It then used a broad Gaussian (1 % of the long edge), smearing -/// the selection across branches, wires and the horizon. Keep this heuristic local/window-stable: -/// a true flood fill to the top cannot be reproduced by a zoom-window render without evaluating -/// the missing part of the frame. -fn rasterize_sky(frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32, out: &mut Plane) { - if w == 0 || h == 0 { - return; - } - let m = frame.out_to_norm(w, h); - let gain = ev.exp2(); - for (i, v) in out.data.iter_mut().enumerate() { - let (x, y) = (i % w, i / w); - let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); - let c = img.data[i].map(|v| (v * gain).max(0.0)); - let l = log_l.data[i] + ev; - let hi = c[0].max(c[1]).max(c[2]); - let lo = c[0].min(c[1]).min(c[2]); - let sat = (hi - lo) / (hi + 1e-4); - let blue = (c[2] - c[0].max(c[1])).max(0.0) / (c[2] + 1e-4); - let py = n.y as f32; - - let bright = smooth(-3.5, -0.7, l); - let blue_sky = smooth(0.01, 0.18, blue); - // Bright, low-chroma cloud/overcast remains possible, but unlike the old brightness floor - // it gets a stronger vertical prior so neutral ground does not qualify as readily. - let neutral_sky = (1.0 - smooth(0.08, 0.24, sat)) * smooth(-2.8, -0.5, l) * (1.0 - smooth(0.50, 0.80, py)); - // Blue sky is allowed much closer to the horizon than before; only the bottom of the frame - // is strongly discouraged. - let height = 0.15 + 0.85 * (1.0 - smooth(0.66, 0.94, py)); - let score = bright * blue_sky.max(0.85 * neutral_sky) * height; - *v = smooth(0.16, 0.55, score); - } - - // Feather at a quarter of the old width, but only across locally smooth luminance and where - // the unblurred classifier has support. A dark wire/branch therefore stays out instead of - // being filled by the blur; the horizon does not bleed into a strong luminance edge. - let sigma = 0.0025 * frame.output_long(w, h) as f32; - let blurred = lightcraft_raster::blur::gaussian(out, sigma.max(0.5)); - let original = std::mem::replace(out, blurred); - for y in 0..h { - for x in 0..w { - let i = y * w + x; - let l = log_l.data[i]; - let edge = (l - log_l.get_clamped(x as isize - 1, y as isize)) - .abs() - .max((l - log_l.get_clamped(x as isize + 1, y as isize)).abs()) - .max((l - log_l.get_clamped(x as isize, y as isize - 1)).abs()) - .max((l - log_l.get_clamped(x as isize, y as isize + 1)).abs()); - let flat = 1.0 - smooth(0.08, 0.35, edge); - let a = original.data[i]; - let support = smooth(0.02, 0.20, a); - let mix = flat * (0.15 + 0.85 * support); - let b = out.data[i]; - out.data[i] = (a + (b - a) * mix).clamp(0.0, 1.0); - } - } -} - /// The union (max) of `mattes` (0..255, over the EXIF-oriented source) at output resolution, /// sampled like the image: user orientation, lens / perspective warp, crop and flips; area-averaged /// first when the output is much smaller than the matte, bilinear otherwise. @@ -857,30 +809,6 @@ mod tests { assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); } - #[test] - fn sky_heuristic_reaches_the_horizon_without_filling_thin_objects() { - let (w, h, horizon) = (200, 120, 72); - let img = Rgb32f::from_fn(w, h, |x, y| { - let wire = y == 34 && x > 20 && x < w - 20; - let branch = (96..=98).contains(&x) && y < horizon; - if wire || branch { - [0.04; 3] - } else if y < horizon { - [0.35, 0.60, 1.10] - } else { - [0.55, 0.38, 0.20] - } - }); - let l = img.map(crate::local::log_lum); - let a = shape_alpha(&MaskShape::Sky, &frame(w, h), w, h, &img, &l, 0.0, None); - - assert!(a.get(30, horizon - 3) > 0.8, "sky reaches the horizon: {}", a.get(30, horizon - 3)); - assert!(a.get(30, horizon + 3) < 0.1, "warm ground stays out: {}", a.get(30, horizon + 3)); - assert!(a.get(80, 34) < 0.2, "thin wire stays out: {}", a.get(80, 34)); - assert!(a.get(97, 50) < 0.2, "thin branch stays out: {}", a.get(97, 50)); - assert!(a.get(90, 50) > 0.8 && a.get(104, 50) > 0.8, "sky beside the branch remains selected"); - } - /// A half-resolution matte selecting the left `frac` of a `w × h` source. fn left_matte(w: usize, h: usize, frac: f64) -> Image { Image::from_fn(w / 2, h / 2, |x, _| if (x as f64 + 0.5) < frac * (w / 2) as f64 { 255 } else { 0 }) @@ -939,4 +867,4 @@ mod tests { let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] }; assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None)); } - } +} From 51d98f9256616b1d989412a9370712c782c1f01d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:14:36 +0200 Subject: [PATCH 007/154] Headless sessions honour LIGHTCRAFT_SAM3_DIR for the SAM 3 model folder (#619) --- crates/mcp/src/headless.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index acbecb60b..22902b849 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -30,9 +30,18 @@ impl Drop for Headless { } } +/// Point the session's SAM 3 model folder at `dir` (the `LIGHTCRAFT_SAM3_DIR` override, as the desktop app does); unset or empty keeps the default. +fn sam_dir_from(mut session: Session, dir: Option) -> Session { + if let Some(dir) = dir.filter(|d| !d.is_empty()) { + session.segmenter.dir = Some(std::path::PathBuf::from(dir)); + } + session +} + impl Default for Headless { fn default() -> Self { - Self::new(Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models()) + let session = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); + Self::new(sam_dir_from(session, std::env::var_os("LIGHTCRAFT_SAM3_DIR"))) } } @@ -43,7 +52,8 @@ impl Headless { /// A headless session with the procedurally generated demo library. pub fn demo() -> Self { - Self::new(Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models()) + let session = Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); + Self::new(sam_dir_from(session, std::env::var_os("LIGHTCRAFT_SAM3_DIR"))) } fn photo_or_active(&self, p: &Value) -> Result { @@ -201,6 +211,19 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> mod tests { use super::*; + /// `LIGHTCRAFT_SAM3_DIR` sets the model folder that `segment.model.status` reports (issue #619). + #[test] + fn sam_dir_override_reaches_the_session() { + let dir = std::env::temp_dir().join("lc-mcp-sam3-dir"); + let session = sam_dir_from(Session::new(), Some(dir.clone().into_os_string())); + assert_eq!(session.segmenter.dir.as_deref(), Some(dir.as_path())); + assert_eq!(sam_dir_from(Session::new(), None).segmenter.dir, None); + assert_eq!(sam_dir_from(Session::new(), Some(std::ffi::OsString::new())).segmenter.dir, None); + let mut h = Headless::new(session); + let r = h.call("engine.execute", json!({"command": "segment.model.status", "params": {}})).unwrap(); + assert_eq!(r["dir"], json!(dir.display().to_string()), "{r}"); + } + /// A headless session stamps imports and edits with the system clock, not the engine's fixed test clock. #[test] fn headless_uses_the_system_clock() { From fd5d05e256610d899c6d538cbfd55f88c4499c4a Mon Sep 17 00:00:00 2001 From: Daniel Spruce Date: Sat, 10 Oct 2026 18:14:51 +1100 Subject: [PATCH 008/154] LR-CROP-ASPECT: defer remembered ratio until a crop gesture starts --- crates/ui-egui/src/headless.rs | 49 +++++++++++++++++---------- crates/ui-egui/src/lib.rs | 52 ++++++++++++++++++++--------- crates/ui-egui/src/panels/detail.rs | 8 ++++- crates/ui-egui/src/panels/right.rs | 5 ++- crates/ui-egui/src/state.rs | 3 -- crates/ui-egui/src/tests_masking.rs | 26 +++++++++++++++ docs/parity.md | 2 +- 7 files changed, 105 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index ca422c352..5c5efce35 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1573,31 +1573,46 @@ mod tests { } #[test] - fn crop_remembers_last_aspect_across_photos() { + fn crop_default_browsing_is_read_only_and_drag_is_one_edit() { let mut h = demo([1200.0, 800.0]); - let photos = h.app.session.visible_cloned(); + let photos: Vec<_> = h.app.session.visible_cloned().into_iter().filter(|id| !h.app.session.catalog.photo(*id).unwrap().is_edited()).collect(); + h.app.run("library.select", json!({"ids": [photos[0].0]})).unwrap(); h.app.ui.right = crate::state::RightPanel::Crop; - h.app.run("crop.aspect", json!({"aspect": "original"})).unwrap(); + h.app.run("crop.aspect", json!({"aspect": "1x1"})).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))); + let before = h.app.session.catalog.photo(photo).unwrap().clone(); + let undo = h.app.session.undo.len(); + h.settle(SETTLE); + let after = h.app.session.catalog.photo(photo).unwrap(); + assert_eq!(after.develop, before.develop); + assert_eq!(after.history.len(), before.history.len()); + assert!(!after.is_edited()); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(h.app.crop_tool_aspect(), Some((100, 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); + let photo = photos[2]; + let before = h.app.session.develop_of(photo).unwrap(); + let undo = h.app.session.undo.len(); + 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.7]})).unwrap(); + h.app.run("develop.endInteraction", json!({})).unwrap(); + assert_eq!(h.app.session.develop_of(photo).unwrap().crop.aspect, Some((100, 100))); + 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.ui = serde_json::from_value(saved).unwrap(); - assert_eq!(h.app.ui.crop_default_aspect, Some(json!("original"))); + assert_eq!(h.app.crop_tool_aspect(), Some((100, 100))); + h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap(); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); + h.app.ui.crop_default_aspect = Some(json!("original")); + let p = h.app.session.catalog.photo(photo).unwrap(); + assert_eq!(h.app.crop_tool_aspect(), Some((p.width * 100, p.height * 100))); h.settle(SETTLE); + assert_eq!(h.app.session.develop_of(photo).unwrap(), before); } /// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 523e5a066..5da45c938 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -348,6 +348,15 @@ impl LightcraftApp { if let Some(r) = menus::run_ui_command(self, id, ¶ms) { return r; } + let params = if id == "crop.aspect" + && params.get("aspect").and_then(Value::as_str) == Some("toggle") + && self.crop_tool_aspect().is_some() + && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) + { + serde_json::json!({"aspect": "free"}) + } else { + params + }; 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") { @@ -358,7 +367,6 @@ impl LightcraftApp { .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); @@ -389,25 +397,40 @@ 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; + /// The remembered ratio belongs to the tool until the user starts editing the crop. + pub(crate) fn crop_tool_aspect(&self) -> Option<(u32, u32)> { + let photo = self.session.active()?; + let d = self.session.develop_of(photo)?; + if d.crop != lightcraft_develop::Crop::default() { + return d.crop.aspect; } - let photo = self.session.active(); - if self.ui.crop_default_photo == photo { - return; + match self.ui.crop_default_aspect.as_ref()? { + Value::String(s) if s == "original" => { + let p = self.session.catalog.photo(photo)?; + let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100)); + Some(if d.orientation.swaps_axes() { (h, w) } else { (w, h) }) + } + Value::Array(a) => { + let number = |i: usize| { + let v = a.get(i)?.as_f64()? * 100.0; + (v.is_finite() && v >= 1.0 && v <= f64::from(u32::MAX)).then_some(v.round() as u32) + }; + Some((number(0)?, number(1)?)) + } + _ => None, } - 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()) + } + + /// Called only after a crop gesture begins, inside its undo transaction. + pub(crate) fn begin_crop(&mut self) -> Result<(), String> { + if self.session.interaction.is_some() + && self.session.active().and_then(|id| self.session.develop_of(id)).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(); + self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect})).map_err(|e| e.to_string())?; } + Ok(()) } /// Show a transient toast at the bottom of the canvas (like the reference app's HUD). @@ -727,7 +750,6 @@ 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/detail.rs b/crates/ui-egui/src/panels/detail.rs index 2e708da66..bc8d8202c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1271,7 +1271,13 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons if resp.drag_started() && let Some(q) = resp.interact_pointer_pos() { - let _ = app.run("develop.beginInteraction", json!({"label": "Crop"})); + if let Err(e) = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)) + { + app.ui.status = e; + let _ = app.session.cancel_interaction(); + return; + } + let d = app.session.develop_of(id).unwrap_or_default(); app.gesture = Some(match handles.iter().position(|h| h.distance(q) < 12.0) { Some(h) => Gesture::CropHandle { handle: h as u8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, None if inside(q) => Gesture::CropHandle { handle: 8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle }, diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 76b8f3984..8080f7517 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -129,7 +129,6 @@ 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| { @@ -139,7 +138,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); ui.horizontal(|ui| { ui.label(crate::i18n::tr("Aspect Ratio")); - let cur = aspect_label(d.crop.aspect, original); + let cur = aspect_label(app.crop_tool_aspect(), original); let t = Tokens::get(ui.ctx()); let r = crate::widgets::dropdown(ui, "cropAspect", crate::i18n::tr(&cur), t.font(12.5), t.text); @@ -152,7 +151,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.separator(); custom_aspect(app, ui); }); - let locked = d.crop.aspect.is_some(); + let locked = app.crop_tool_aspect().is_some(); if text_button(ui, "cropLock", if locked { "Locked" } else { "Lock" }, locked).clicked() { let _ = app.run("crop.aspect", json!({"aspect": "toggle"})); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a85e17b4f..992c176f9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -323,8 +323,6 @@ pub struct UiState { 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`. @@ -708,7 +706,6 @@ impl Default for UiState { 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/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 6a5f59e17..e2a2c0cfa 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -308,6 +308,32 @@ fn command_drag_straightens_in_crop() { h.settle(SETTLE); } +#[test] +fn crop_drag_activates_the_remembered_ratio_and_undo_restores_the_photo() { + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = Some(json!([1, 1])); + h.settle(SETTLE); + let before = develop(&h); + let undo = h.app.session.undo.len(); + assert!(before.crop.aspect.is_none()); + let r = h.request( + "ui.pointer", + json!({"events": [ + {"kind": "down", "x": 0.995, "y": 0.995}, + {"kind": "drag", "x": 0.85, "y": 0.85}, + {"kind": "drag", "x": 0.75, "y": 0.75}, + {"kind": "up", "x": 0.75, "y": 0.75} + ]}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(develop(&h).crop.aspect, Some((100, 100))); + assert_eq!(h.app.session.undo.len(), undo + 1); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h), before); +} + #[test] fn double_click_in_crop_box_applies_the_crop() { let mut h = detail("panel.crop"); diff --git a/docs/parity.md b/docs/parity.md index 948f5df98..8ced3e553 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); the last chosen aspect is saved as the default for untouched photos in Crop, resolving Original per photo and preserving existing crops | +| 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 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-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` | | From f9bbeb9d00261c8af5c6e078a12b22bd3e87a0c8 Mon Sep 17 00:00:00 2001 From: Mark Pariente Date: Sat, 10 Oct 2026 00:21:33 -0700 Subject: [PATCH 009/154] LRC-LIB-CATALOG-IMPORT: find moved root folders relative to the catalog A catalog copied from another computer (e.g. Windows to Linux) records root folders at paths that don't exist here, so every original was imported as missing. When a root's absolutePath isn't a folder, use the relativePathFromCatalog Lightroom stores for roots on the catalog's volume, resolved against the catalog's folder, and report the remapping as a warning. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lightroom_catalog.rs | 101 +++++++++++++++++++++---- docs/lightroom-catalog-import.md | 8 +- docs/parity.md | 2 +- 3 files changed, 93 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 6c1c7724d..2519057a3 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -2,7 +2,7 @@ //! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place. //! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders. use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::{ Arc, atomic::{AtomicBool, AtomicUsize, Ordering}, @@ -336,6 +336,29 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result { Ok(committed) } +/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from +/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the +/// catalog's volume, resolved against the catalog's folder, if that is a folder. +fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option { + if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() { + return None; + } + let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative); + let mut out = PathBuf::new(); + for part in dir.components() { + match part { + Component::ParentDir => { + if !out.pop() { + return None; + } + } + Component::CurDir => {} + part => out.push(part), + } + } + out.is_dir().then_some(out) +} + /// Inspect `.lrcat` plus committed WAL pages without modifying either source file. pub fn read(path: &Path) -> Result { let cancel = AtomicBool::new(false); @@ -428,6 +451,21 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, } out }; + let mut root_paths = HashMap::new(); + let mut root_ids: Vec<_> = roots.keys().copied().collect(); + root_ids.sort_unstable(); + for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) { + let absolute = text(root, "absolutePath"); + let resolved = match relocated_root(absolute, text(root, "relativePathFromCatalog"), path) { + Some(found) => { + let found = found.to_string_lossy().to_string(); + warnings.push(format!("root folder {}: {absolute} not found; using {found} (relative to the catalog)", text(root, "name"))); + found + } + None => absolute.to_string(), + }; + root_paths.insert(id, resolved); + } let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?); let mut snapshots = group(optional_table("Adobe_libraryImageDevelopSnapshot", &mut warnings)?); let mut out = CatalogImport { @@ -444,7 +482,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?; let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?; let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?; - let root_path = text(root, "absolutePath"); + let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str); let folder_path = text(folder, "pathFromRoot"); let name = text(file, "idx_filename"); if root_path.is_empty() || name.is_empty() { @@ -1259,19 +1297,9 @@ mod tests { } page[offset + 5..offset + 7].copy_from_slice(&(end as u16).to_be_bytes()); } - #[test] - fn reads_native_sqlite_tables_without_lightroom_or_external_processes() { - use SqlValue::{Integer as I, Null as N, Text as T}; - let tables = [ - ("AgLibraryRootFolder", "id_local INTEGER PRIMARY KEY, absolutePath", vec![N, T("/catalog/".into())]), - ("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]), - ("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]), - ( - "Adobe_images", - "id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick", - vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)], - ), - ]; + /// A one-row-per-table SQLite file: `(name, columns, row)` each. + fn native_catalog(tables: &[(&str, &str, Vec)]) -> Vec { + use SqlValue::{Integer as I, Text as T}; let mut bytes = vec![0u8; 5 * 2048]; bytes[..16].copy_from_slice(b"SQLite format 3\0"); bytes[16..18].copy_from_slice(&2048u16.to_be_bytes()); @@ -1295,6 +1323,30 @@ mod tests { leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row)); } leaf(&mut bytes[..2048], 100, &schema); + bytes + } + + fn photo_tables(root: (&str, &str)) -> Vec<(&'static str, &'static str, Vec)> { + use SqlValue::{Integer as I, Null as N, Text as T}; + vec![ + ( + "AgLibraryRootFolder", + "id_local INTEGER PRIMARY KEY, absolutePath, name, relativePathFromCatalog", + vec![N, T(root.0.into()), T("Photos".into()), T(root.1.into())], + ), + ("AgLibraryFolder", "id_local INTEGER PRIMARY KEY, rootFolder, pathFromRoot", vec![N, I(1), T("Photos/".into())]), + ("AgLibraryFile", "id_local INTEGER PRIMARY KEY, folder, idx_filename", vec![N, I(1), T("test.ARW".into())]), + ( + "Adobe_images", + "id_local INTEGER PRIMARY KEY, id_global, rootFile, fileFormat, fileWidth, fileHeight, rating, pick", + vec![N, T("fixture-image".into()), I(1), T("ARW".into()), I(20), I(10), I(5), I(1)], + ), + ] + } + + #[test] + fn reads_native_sqlite_tables_without_lightroom_or_external_processes() { + let bytes = native_catalog(&photo_tables(("/catalog/", ""))); let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id())); std::fs::write(&path, &bytes).unwrap(); let catalog = read(&path).unwrap(); @@ -1307,6 +1359,25 @@ mod tests { std::fs::remove_file(path).unwrap(); } + #[test] + fn a_root_missing_here_is_found_relative_to_the_catalog() { + let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Lightroom")).unwrap(); + std::fs::create_dir_all(dir.join("RAW")).unwrap(); + let path = dir.join("Lightroom").join("Lightroom.lrcat"); + std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../RAW/")))).unwrap(); + let catalog = read(&path).unwrap(); + assert_eq!(Path::new(&catalog.photos[0].path), dir.join("RAW").join("Photos").join("test.ARW")); + assert!(catalog.warnings.iter().any(|w| w.contains("relative to the catalog")), "{:?}", catalog.warnings); + // a relative path that leads nowhere keeps the recorded one + std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap(); + let catalog = read(&path).unwrap(); + assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); + assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn oversized_optional_table_warns_and_is_skipped() { let mut main = vec![0u8; 2 * 512]; diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 0c18a6521..45b4ad0ed 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -62,5 +62,9 @@ Archive version 1 begins with the 12 bytes `LC-LRARCH\0\x01Z`, followed by zlib- Recovery readers must cap decompression at 32 MiB. JSON retains source identities, photo paths, settings, decoded XMP, keywords, history, snapshots, collection definitions and membership. -The importer uses shared Rust code on macOS, Windows and Linux. If originals move between -computers or volumes, use LightCraft's missing-photo relinking tools to update their paths. +The importer uses shared Rust code on macOS, Windows and Linux. When a root folder's recorded +path isn't a folder on this computer (a catalog copied from another machine), the importer uses +the path relative to the catalog that Lightroom records for root folders on the catalog's volume +(`relativePathFromCatalog`), when that is a folder, and reports the remapping as a warning. Copying +a project folder with its catalog inside it therefore keeps its originals linked. Other moved +originals stay missing; use LightCraft's missing-photo relinking tools to update their paths. diff --git a/docs/parity.md b/docs/parity.md index ad82e3d25..fe77d36f5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -743,7 +743,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | +| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames under a progress window with Cancel, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | From 02a9f3a5fb625ed1957fe0ba64f77e982d4c327b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 03:52:53 -0400 Subject: [PATCH 010/154] LR-CROP-ASPECT: forget a remembered ratio the crop command refuses A bad remembered value (a hand-edited settings file) made begin_crop fail, which cancelled every crop drag on an untouched photo for good. Now the value is dropped with a status message and the drag goes ahead. Regression test in tests_masking.rs. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 6 +++++- crates/ui-egui/src/tests_masking.rs | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 80dcf594f..bc38b1935 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -478,8 +478,12 @@ impl LightcraftApp { && self.session.active().and_then(|id| self.session.develop_of(id)).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.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect})).map_err(|e| e.to_string())?; + // A remembered value the command refuses (a hand-edited settings file) would refuse + // every crop drag on an untouched photo: forget it and let this drag go ahead. + self.ui.crop_default_aspect = None; + self.ui.status = e.to_string(); } Ok(()) } diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 57753bee1..36b9fecd1 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -468,6 +468,28 @@ fn crop_drag_activates_the_remembered_ratio_and_undo_restores_the_photo() { assert_eq!(develop(&h), before); } +/// A remembered ratio the crop command refuses (a hand-edited settings file) is forgotten, and the +/// drag still crops: it must not block every crop of an untouched photo. +#[test] +fn a_bad_remembered_ratio_is_dropped_and_the_drag_still_crops() { + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = Some(json!([0, -3])); + h.settle(SETTLE); + let before = develop(&h); + // a rotating drag outside the box (any crop drag starts cropping); the release on its own + for events in [ + json!([{"kind": "down", "x": -0.05, "y": 0.2}, {"kind": "drag", "x": -0.05, "y": 0.25}, {"kind": "drag", "x": -0.05, "y": 0.3}]), + json!([{"kind": "up", "x": -0.05, "y": 0.3}]), + ] { + let r = h.request("ui.pointer", json!({ "events": events }), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.ui.crop_default_aspect, None, "the bad value is forgotten"); + assert_ne!(develop(&h).crop, before.crop, "the drag cropped"); + assert_eq!(develop(&h).crop.aspect, None); +} + #[test] fn double_click_in_crop_box_applies_the_crop() { let mut h = detail("panel.crop"); From 04f8146d1043883a1efa805975e88e56f940bfe3 Mon Sep 17 00:00:00 2001 From: Alex Chambers Date: Sat, 10 Oct 2026 09:59:22 +0200 Subject: [PATCH 011/154] Issue #99: lock tests: keep the child-process test from overlapping the others tests_lock::second_opener_is_refused_until_the_first_lets_go failed now and then on macOS with InUse(None): the child process that lock_held_by_another_process_is_released_when_it_dies spawns briefly holds copies of the test process's file descriptors, lock files included, until its exec closes them, so a lock dropped in that moment stays held. The three tests that hold a lock in the test process now take one mutex. Under parallel load: 6 of 400 runs failed before, 0 of 400 after. Test-only. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/tests_lock.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/catalog/src/tests_lock.rs b/crates/catalog/src/tests_lock.rs index c885ec490..e4143f1f8 100644 --- a/crates/catalog/src/tests_lock.rs +++ b/crates/catalog/src/tests_lock.rs @@ -5,6 +5,17 @@ use std::path::PathBuf; use crate::lock::{LOCK, OWNER}; use crate::*; +/// Taken by every test here that holds a lock in this process. On Unix, spawning the child of +/// [`lock_held_by_another_process_is_released_when_it_dies`] briefly copies this process's file +/// descriptors, close-on-exec lock files included, until the child's exec closes them. A lock +/// another test drops in that moment stays held by the copy, so acquiring it again could fail with +/// "in use". +static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn serial() -> std::sync::MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + fn temp_dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("lc-lock-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); @@ -14,6 +25,7 @@ fn temp_dir(tag: &str) -> PathBuf { #[test] fn second_opener_is_refused_until_the_first_lets_go() { + let _serial = serial(); let dir = temp_dir("twice"); let first = LibraryLock::acquire(&dir, "LightCraft").unwrap(); assert!(first.held()); @@ -38,6 +50,7 @@ fn second_opener_is_refused_until_the_first_lets_go() { /// Files left behind by a crash (lock file + owner note) don't lock anyone out. #[test] fn leftover_lock_files_are_not_a_lock() { + let _serial = serial(); let dir = temp_dir("stale"); std::fs::write(dir.join(LOCK), b"").unwrap(); std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap(); @@ -52,6 +65,7 @@ fn leftover_lock_files_are_not_a_lock() { /// Another process holds the lock; when it is killed (a crash), the OS releases it. #[test] fn lock_held_by_another_process_is_released_when_it_dies() { + let _serial = serial(); let dir = temp_dir("child"); let exe = std::env::current_exe().unwrap(); let mut child = std::process::Command::new(exe) From 0b1d80f08d8d53baf9e9812d4a6f5c6dc8f5e4c1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 04:04:41 -0400 Subject: [PATCH 012/154] LR-MASK-OVERLAY: test the mask overlay as a zoomed window There was no zoom/pan reset on main to fix: what this PR changes is that a zoomed view with the mask overlay shown is now a canvas-sized frame plus a sharp window carrying the overlay, as without it, instead of a whole-frame render at the zoomed size. Tests: the overlay on a window matches the same pixels of the whole render for radial, refined linear and sky masks in three views (pipeline tests_window.rs), and at 1:1 the loupe uses a window with the overlay, keeps zoom and pan, and swaps tiles when the overlay is hidden or shown (ui-egui tests_preview_limit.rs). Parity row updated. No render output change (no cache bump). Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/tests_window.rs | 34 ++++++++++++++++++++ crates/ui-egui/src/tests_preview_limit.rs | 38 +++++++++++++++++++++++ docs/parity.md | 2 +- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 0ed5e81e1..1d5b15c65 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -204,6 +204,40 @@ fn mask_edge_refinement_does_not_depend_on_the_window() { check_noisy("refine", &s, MID, 3, 0.2); } +// Given a mask overlay (the Masking panel's view of the selected mask), a window of it matches the +// same pixels of the whole render with that overlay, for every view and for drawn, AI and +// refined shapes: the zoomed loupe can show the overlay as a window (issue #547) +#[test] +fn a_mask_overlay_window_matches_the_whole_render() { + use crate::{MaskView, Overlay}; + use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let shapes = [ + ("radial", MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.15, angle: 20.0, feather: 50.0, invert: false }, 0.0), + ("linear refined", MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) }, 100.0), + ("sky", MaskShape::Sky, 0.0), + ]; + for (name, shape, refine) in shapes { + let mut s = DevelopSettings::default(); + s.masks.push(Mask { + id: 3, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, + refine, + ..Default::default() + }); + for view in [MaskView::Color, MaskView::WhiteOnBlack, MaskView::ImageOnBlack] { + let overlay = Overlay::Mask { id: 3, view, color: [255, 0, 0], opacity: 70 }; + let f = render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, ..RenderRequest::fit(W, H) }).image; + let w = + render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, window: Some(MID), ..RenderRequest::fit(W, H) }).image; + let (max, mean) = compare(&f, &w, MID); + // (refine edges differs by a rounding step, as in `mask_edge_refinement_does_not_depend_on_the_window`) + assert!(max <= 3 && mean <= 0.25, "{name} {view:?}: max {max} (≤ 3), mean {mean:.3} (≤ 0.25)"); + } + } +} + // ---- Deep zoom: the wide stages' kernels are far wider than any margin ------------------------- /// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index f48a2e1c3..ae57c5700 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -189,6 +189,44 @@ mod in_the_loupe { assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}"); } + // Given a mask selected with its overlay shown at 1:1 (issue #547), the zoomed view keeps its + // zoom and pan and stays a canvas-sized frame plus a native window carrying the overlay, as + // without it, instead of a whole-frame render at the zoomed size; hiding or showing the + // overlay swaps the window for one with the other look + #[test] + fn a_mask_overlay_at_one_to_one_is_a_window_and_keeps_zoom_and_pan() { + let (mut h, native) = detail(); + for (command, params) in [("panel.masking", json!({})), ("mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2}))] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + } + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.app.ui.pan = (0.3, 0.4); + h.app.ui.mask_overlay = false; + h.settle(SETTLE); + let (zoom, pan) = (h.app.ui.zoom, h.app.ui.pan); + assert_eq!(zoom, crate::state::Zoom::Percent(100.0)); + let plain = h.app.region_view.expect("a window render without the overlay"); + + h.app.ui.mask_overlay = true; + h.settle(SETTLE); + let id = h.app.session.active().expect("a photo"); + let d = h.app.session.develop_of(id).expect("settings"); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::Mask { .. }), "the mask overlay is shown"); + assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "showing the overlay keeps zoom and pan"); + let main = rendered_long_edge(&h); + assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); + let shown = h.app.region_view.expect("a window render with the overlay"); + assert_eq!(shown.full.0.max(shown.full.1), native, "cut from the photo's own pixels"); + assert_eq!(shown.window, plain.window, "the same part of the photo"); + assert_ne!(shown.settings, plain.settings, "the overlay is part of the window's look"); + + h.app.ui.mask_overlay = false; + h.settle(SETTLE); + assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "hiding it keeps them too"); + assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window"); + } + // Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that #[test] fn an_explicit_limit_still_caps_one_to_one() { diff --git a/docs/parity.md b/docs/parity.md index ad82e3d25..4ae97c103 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -365,7 +365,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | | LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | -| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; no auto-show on hover | +| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | From 216efab838a857d5fb168794c3c7fe1579091aac Mon Sep 17 00:00:00 2001 From: Luniiya <86487331+luniiya@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:18:31 +0200 Subject: [PATCH 013/154] UI: Add system appearance and light themes --- Cargo.lock | 1 + Cargo.toml | 3 + apps/lightcraft-web/src/web.rs | 2 + apps/lightcraft/Cargo.toml | 3 + apps/lightcraft/src/appearance.rs | 198 +++++++++++++++++++++++++ apps/lightcraft/src/main.rs | 2 + crates/ui-egui/locales/de.json | 15 +- crates/ui-egui/locales/es.json | 15 +- crates/ui-egui/locales/ja.json | 15 +- crates/ui-egui/locales/pt-br.json | 15 +- crates/ui-egui/locales/ru.json | 15 +- crates/ui-egui/locales/zh-hans.json | 15 +- crates/ui-egui/locales/zh-hant.json | 15 +- crates/ui-egui/src/headless.rs | 10 ++ crates/ui-egui/src/icons.rs | 24 +++ crates/ui-egui/src/lib.rs | 24 +++ crates/ui-egui/src/menubar.rs | 24 +++ crates/ui-egui/src/menus.rs | 42 ++++++ crates/ui-egui/src/panels/dialogs.rs | 12 +- crates/ui-egui/src/panels/settings.rs | 194 +++++++++++++++++++++++- crates/ui-egui/src/panels/topbar.rs | 23 ++- crates/ui-egui/src/state.rs | 93 ++++++++++++ crates/ui-egui/src/tests_appearance.rs | 197 ++++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 165 ++++++++++++++++++++- docs/control-protocol.md | 7 + docs/parity.md | 2 +- docs/whats-new.md | 8 + 27 files changed, 1118 insertions(+), 21 deletions(-) create mode 100644 apps/lightcraft/src/appearance.rs create mode 100644 crates/ui-egui/src/tests_appearance.rs diff --git a/Cargo.lock b/Cargo.lock index 45d8ff15c..a2cbd8250 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2990,6 +2990,7 @@ dependencies = [ "rfd", "serde_json", "winresource", + "zbus", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5f7c85db6..02cd279ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,9 @@ eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK) +# Linux: the desktop's dark/light setting from the XDG settings portal (Settings ▸ Interface ▸ +# Appearance Mode ▸ Auto). Pure Rust; the version accesskit already brings in, pinned to it. +zbus = "=5.19.0" web-time = "1" # web (apps/lightcraft-web) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 9960fdd54..190ad1583 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -229,6 +229,8 @@ fn services(originals: Originals, backend: Option, files: Files, frozen w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string()) })), pick_folder: None, + // the browser reports prefers-color-scheme to egui + system_theme: None, } } diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index e4d7f2b3b..c6219f9da 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -35,5 +35,8 @@ winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +zbus = { workspace = true } + [target.'cfg(target_os = "macos")'.dependencies] muda = { workspace = true } diff --git a/apps/lightcraft/src/appearance.rs b/apps/lightcraft/src/appearance.rs new file mode 100644 index 000000000..74333a301 --- /dev/null +++ b/apps/lightcraft/src/appearance.rs @@ -0,0 +1,198 @@ +//! The desktop's dark/light setting for Settings ▸ Interface ▸ Appearance Mode ▸ Auto, on Linux +//! desktops whose windowing layer reports no theme to egui (many Wayland compositors). +//! +//! The XDG settings portal is read once, then its `SettingChanged` signal is followed on a worker +//! thread that wakes the UI only when the value changes. Nothing is polled. `gsettings` is a +//! one-shot fallback at start-up, run by absolute path, only when the portal gives no answer. +//! Elsewhere egui's own report (`Context::system_theme`) is used. + +#[cfg(target_os = "linux")] +const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; +#[cfg(target_os = "linux")] +const PORTAL_KEY: &str = "color-scheme"; + +#[cfg(target_os = "linux")] +fn portal_proxy() -> Option> { + let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; + zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() +} + +#[cfg(target_os = "linux")] +fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option { + let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?; + decode(portal_code(value)?) +} + +/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest +/// another variant inside it. +#[cfg(target_os = "linux")] +fn portal_code(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + other => return u32::try_from(other).ok(), + } + } + None +} + +/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light. +#[cfg(target_os = "linux")] +fn decode(code: u32) -> Option { + match code { + 1 => Some(egui::Theme::Dark), + 2 => Some(egui::Theme::Light), + _ => None, + } +} + +/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by +/// absolute path, and is never repeated. +#[cfg(target_os = "linux")] +fn gtk_theme() -> Option { + let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; + let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?; + if !output.status.success() { + return None; + } + parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?) +} + +#[cfg(target_os = "linux")] +fn parse_gtk_scheme(s: &str) -> Option { + match s.trim().trim_matches('\'') { + "prefer-dark" => Some(egui::Theme::Dark), + "prefer-light" => Some(egui::Theme::Light), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn code_of(theme: Option) -> u8 { + match theme { + Some(egui::Theme::Dark) => 1, + Some(egui::Theme::Light) => 2, + None => 0, + } +} + +/// Wake the first frame after a slow initial Read as well as subsequent real changes. +#[cfg(target_os = "linux")] +fn publish(value: &std::sync::atomic::AtomicU8, wake: &std::sync::OnceLock, code: u8) { + if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code + && let Some(ctx) = wake.get() + { + ctx.request_repaint(); + } +} + +/// The host's system-appearance service (Linux), or `None` where egui's report is enough. +pub fn service() -> Option { + #[cfg(target_os = "linux")] + { + use std::sync::{ + Arc, OnceLock, + atomic::{AtomicU8, Ordering}, + }; + let value = Arc::new(AtomicU8::new(0)); + let wake: Arc> = Arc::new(OnceLock::new()); + let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake)); + let (ready, wait) = std::sync::mpsc::channel(); + if std::thread::Builder::new() + .name("appearance-portal".into()) + .spawn(move || { + let proxy = portal_proxy(); + // Subscribe before Read so a change during startup is queued rather than lost. + let signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok()); + let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme); + publish(&worker_value, &worker_wake, code_of(initial)); + let _ = ready.send(()); + let Some(signals) = signals else { return }; + // Blocks until the portal sends a signal: no polling. + for message in signals { + let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { + continue; + }; + if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { + continue; + } + let code = code_of(portal_code(changed).and_then(decode)); + publish(&worker_value, &worker_wake, code); + } + }) + .is_err() + { + return None; + } + // The first frame shows the right theme when the portal answers quickly; a slow one + // only delays the switch (the worker wakes the UI when the value changes). + let _ = wait.recv_timeout(std::time::Duration::from_millis(250)); + Some(Box::new(move |ctx| { + let _ = wake.set(ctx.clone()); + decode(u32::from(value.load(Ordering::Relaxed))) + })) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + #[test] + fn delayed_initial_read_repaints_only_on_change() { + use std::sync::{ + Arc, OnceLock, + atomic::{AtomicU8, AtomicUsize, Ordering}, + }; + let ctx = egui::Context::default(); + let settle = |ctx: &egui::Context| { + for _ in 0..8 { + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + } + }; + settle(&ctx); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&calls); + ctx.set_request_repaint_callback(move |_| { + observed.fetch_add(1, Ordering::Relaxed); + }); + let wake = OnceLock::new(); + assert!(wake.set(ctx.clone()).is_ok()); + let value = AtomicU8::new(0); + super::publish(&value, &wake, 2); + assert_eq!(value.load(Ordering::Relaxed), 2); + let after_change = calls.load(Ordering::Relaxed); + assert!(after_change > 0); + settle(&ctx); + let after_settle = calls.load(Ordering::Relaxed); + super::publish(&value, &wake, 2); + assert_eq!(calls.load(Ordering::Relaxed), after_settle); + } + + #[cfg(target_os = "linux")] + #[test] + fn portal_values_and_gsettings_output_decode() { + assert_eq!(super::decode(0), None); + assert_eq!(super::decode(1), Some(egui::Theme::Dark)); + assert_eq!(super::decode(2), Some(egui::Theme::Light)); + assert_eq!(super::decode(9), None); + let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2))))); + assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2)); + let plain = zbus::zvariant::OwnedValue::from(1u32); + assert_eq!(super::portal_code(plain), Some(1)); + let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap(); + assert_eq!(super::portal_code(wrong), None); + assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark)); + assert_eq!(super::parse_gtk_scheme("'default'\n"), None); + assert_eq!(super::parse_gtk_scheme(""), None); + for theme in [None, Some(egui::Theme::Dark), Some(egui::Theme::Light)] { + assert_eq!(super::decode(u32::from(super::code_of(theme))), theme); + } + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 832b158b3..9c5da8162 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -23,6 +23,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod alloc_release; +mod appearance; mod control_server; mod dialog_filter; mod logging; @@ -499,6 +500,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services // the library is a folder on disk: backed up with the user's other files backup_library: None, restore_library: None, + system_theme: appearance::service(), } } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c24b59cc0..81376a649 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1889,5 +1889,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "Erscheinungsbild", + "Appearance Mode": "Erscheinungsmodus", + "Sync with System": "Mit System synchronisieren", + "Light Mode": "Heller Modus", + "Dark Mode": "Dunkler Modus", + "Next Appearance Mode": "Nächster Erscheinungsmodus", + "Light Theme": "Helles Design", + "Dark Theme": "Dunkles Design", + "In Use": "In Verwendung", + "Charcoal": "Anthrazit", + "Midnight": "Mitternacht", + "Silver": "Silber", + "Paper": "Papier" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 387ac9717..6c07bf636 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1734,5 +1734,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "Apariencia", + "Appearance Mode": "Modo de apariencia", + "Sync with System": "Sincronizar con el sistema", + "Light Mode": "Modo claro", + "Dark Mode": "Modo oscuro", + "Next Appearance Mode": "Siguiente modo de apariencia", + "Light Theme": "Tema claro", + "Dark Theme": "Tema oscuro", + "In Use": "En uso", + "Charcoal": "Carbón", + "Midnight": "Medianoche", + "Silver": "Plata", + "Paper": "Papel" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b05baac24..52721325c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1734,5 +1734,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "外観", + "Appearance Mode": "外観モード", + "Sync with System": "システムと同期", + "Light Mode": "ライトモード", + "Dark Mode": "ダークモード", + "Next Appearance Mode": "次の外観モード", + "Light Theme": "ライトテーマ", + "Dark Theme": "ダークテーマ", + "In Use": "使用中", + "Charcoal": "チャコール", + "Midnight": "ミッドナイト", + "Silver": "シルバー", + "Paper": "ペーパー" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index ab7f39734..df1c2d3ce 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1734,5 +1734,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "Aparência", + "Appearance Mode": "Modo de aparência", + "Sync with System": "Sincronizar com o sistema", + "Light Mode": "Modo claro", + "Dark Mode": "Modo escuro", + "Next Appearance Mode": "Próximo modo de aparência", + "Light Theme": "Tema claro", + "Dark Theme": "Tema escuro", + "In Use": "Em uso", + "Charcoal": "Carvão", + "Midnight": "Meia-noite", + "Silver": "Prata", + "Paper": "Papel" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 517e2613f..8aa36cb03 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1407,5 +1407,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "Оформление", + "Appearance Mode": "Режим оформления", + "Sync with System": "Как в системе", + "Light Mode": "Светлый режим", + "Dark Mode": "Тёмный режим", + "Next Appearance Mode": "Следующий режим оформления", + "Light Theme": "Светлая тема", + "Dark Theme": "Тёмная тема", + "In Use": "Используется", + "Charcoal": "Графит", + "Midnight": "Полночь", + "Silver": "Серебро", + "Paper": "Бумага" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 46728b628..d67a5e6a7 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1734,5 +1734,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "外观", + "Appearance Mode": "外观模式", + "Sync with System": "与系统同步", + "Light Mode": "浅色模式", + "Dark Mode": "深色模式", + "Next Appearance Mode": "下一个外观模式", + "Light Theme": "浅色主题", + "Dark Theme": "深色主题", + "In Use": "使用中", + "Charcoal": "炭灰", + "Midnight": "午夜", + "Silver": "银色", + "Paper": "纸白" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 083c98c2e..2bc551d01 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1734,5 +1734,18 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Appearance": "外觀", + "Appearance Mode": "外觀模式", + "Sync with System": "與系統同步", + "Light Mode": "淺色模式", + "Dark Mode": "深色模式", + "Next Appearance Mode": "下一個外觀模式", + "Light Theme": "淺色主題", + "Dark Theme": "深色主題", + "In Use": "使用中", + "Charcoal": "炭灰", + "Midnight": "午夜", + "Silver": "銀色", + "Paper": "紙白" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e6929269f..a910b4175 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2662,10 +2662,20 @@ mod tests { assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail); // Interface: filmstrip names off, grid badges always h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t); + // Appearance cards sit above these controls; scroll the compact settings viewport. + h.request("ui.hoverWidget", json!({"id": "radio:theme-charcoal"}), t); + h.request("ui.scroll", json!({"dy": -350.0}), t); + for _ in 0..4 { + h.step(); + } h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t); assert!(!h.app.ui.settings.film_names); h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t); assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always); + h.request("ui.scroll", json!({"dy": 350.0}), t); + for _ in 0..4 { + h.step(); + } // Performance: the thumbnail cache size goes to the library preferences h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t); h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index acbbba02b..87bbf9f16 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -80,6 +80,12 @@ pub enum Icon { Chat, /// Face boxes on/off: four corner brackets round a small face. FaceBox, + /// Appearance Mode ▸ Auto (follow the system): a monitor on its stand. + Monitor, + /// Appearance Mode ▸ Light: a sun (a disc with eight rays). + Sun, + /// Appearance Mode ▸ Dark: a crescent moon. + Moon, } struct Pen<'a> { @@ -159,6 +165,24 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.line(&[(4.0, 10.0), (16.0, 10.0)]); pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); } + Monitor => { + pen.rect(2.5, 3.5, 17.5, 13.5, 1.5); + pen.line(&[(10.0, 13.5), (10.0, 16.5)]); + pen.line(&[(6.5, 16.5), (13.5, 16.5)]); + } + Sun => { + pen.circle(10.0, 10.0, 3.2); + for i in 0..8 { + let a = (i as f32 * 45.0).to_radians(); + let (c, s) = (a.cos(), a.sin()); + pen.line(&[(10.0 + 5.4 * c, 10.0 + 5.4 * s), (10.0 + 7.4 * c, 10.0 + 7.4 * s)]); + } + } + Moon => { + // outer arc of the disc, then the inner arc of the bite, meeting at the horns + pen.arc(10.0, 10.0, 6.5, 2.9, 263.8); + pen.arc(13.4, 6.4, 5.0, 214.9, 51.8); + } Search => { pen.circle(8.5, 8.5, 5.0); pen.line(&[(12.2, 12.2), (16.5, 16.5)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 705818911..554d2e614 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,8 @@ pub mod theme; pub mod titlebar; pub mod widgets; +#[cfg(test)] +mod tests_appearance; #[cfg(test)] mod tests_curve; #[cfg(test)] @@ -98,6 +100,11 @@ pub type OpenWithFn = Box Result<(), String>>; /// storage); the work may finish asynchronously. pub type HostAction = Box Result>; +/// The system's dark/light appearance where egui doesn't report one (Linux desktops whose +/// windowing layer says nothing, e.g. many Wayland compositors). The host answers from what it +/// last heard and wakes the UI itself when that changes; it is never polled for changes. +pub type SystemThemeFn = Box Option>; + /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { @@ -145,6 +152,8 @@ pub struct Services { pub backup_library: Option, /// File ▸ Restore Library from Backup… (web only; keeps the current library). pub restore_library: Option, + /// The system appearance for Appearance Mode ▸ Auto, ahead of egui's own report. + pub system_theme: Option, } #[derive(Clone, Copy, Debug, Default)] @@ -629,6 +638,20 @@ impl LightcraftApp { } } + /// The system's appearance: the host's answer first, then egui's (none: unknown). + pub fn system_theme(&self, ctx: &egui::Context) -> Option { + self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) + } + + /// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light + /// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise. + pub fn sync_theme(&mut self, ctx: &egui::Context) { + let wanted = self.ui.settings.theme(self.system_theme(ctx)); + if theme::current(ctx) != Some(wanted) { + theme::apply_kind(ctx, wanted); + } + } + /// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it /// on the CPU (photo textures come from the renderer's CPU copies). pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option { @@ -715,6 +738,7 @@ impl LightcraftApp { } else { self.fonts_ready = true; } + self.sync_theme(ctx); panels::notices::logic(self); // closing the window (or Quit) with changes only in memory: retry, else ask first if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 7d964856e..67b61fc54 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -128,6 +128,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.histogram", "view.navigator", "view.infoOverlay", + "@Appearance", "---", "view.fullScreenPreview", "view.enterFullScreen", @@ -455,6 +456,29 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.push(item("dialog.labelNames", Value::Null, "Edit Label Names…", None, true, None)); v } + "Appearance" => { + use crate::state::AppearanceMode; + use crate::theme::ThemeKind; + let s = &app.ui.settings; + let mut v: Vec = [ + ("auto", "Sync with System", AppearanceMode::Auto), + ("light", "Light Mode", AppearanceMode::Light), + ("dark", "Dark Mode", AppearanceMode::Dark), + ] + .into_iter() + .map(|(mode, label, m)| item("view.appearance", json!({"mode": mode}), label, None, true, Some(s.appearance_mode == m))) + .collect(); + v.push(item("view.appearance", Value::Null, "Next Appearance Mode", None, true, None)); + // the saved theme of each family is checked + for group in [[ThemeKind::Charcoal, ThemeKind::Midnight], [ThemeKind::Silver, ThemeKind::Paper]] { + v.push(MenuNode::Separator); + for k in group { + let on = if k.is_dark() { s.dark_theme.kind() == k } else { s.light_theme.kind() == k }; + v.push(item("view.theme", json!({"theme": k.id()}), k.label(), None, true, Some(on))); + } + } + v + } "Grid Info" => ["filename", "exposure", "date"] .into_iter() .zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"]) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5b79548b9..3511dc3ce 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -136,6 +136,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), ("tool.keywordPainter", "Keyword Painter", None, ""), ("view.gridInfo", "Grid Info", None, ""), + // {mode?: auto | light | dark}: Appearance Mode (cycles Auto, Light, Dark when omitted; the + // top bar's appearance button). View ▸ Appearance lists the modes. + ("view.appearance", "Next Appearance Mode", None, ""), + // {theme: charcoal | midnight | silver | paper}: pick a theme and fix the mode to its family. + ("view.theme", "Theme", None, ""), ("dialog.allMetadata", "All Metadata…", None, "Photo"), ("dialog.faceModel", "Add Face Model…", None, ""), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), @@ -253,6 +258,41 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { Some([c(0)?, c(1)?, c(2)?]) } +fn appearance_json(app: &LightcraftApp) -> Value { + let s = &app.ui.settings; + json!({"mode": s.appearance_mode, "darkTheme": s.dark_theme, "lightTheme": s.light_theme}) +} + +/// `view.appearance {mode?}`: set Appearance Mode, or step to the next one (Auto, Light, Dark). +/// The theme follows on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance_command(app: &mut LightcraftApp, p: &Value) -> Result { + use crate::state::AppearanceMode; + let mode = match p.get("mode") { + None | Some(Value::Null) => app.ui.settings.appearance_mode.next(), + Some(v) => match v.as_str() { + Some("auto") => AppearanceMode::Auto, + Some("light") => AppearanceMode::Light, + Some("dark") => AppearanceMode::Dark, + _ => return Err(format!("view.appearance: unknown mode {v} (auto|light|dark)")), + }, + }; + app.ui.settings.appearance_mode = mode; + Ok(appearance_json(app)) +} + +/// `view.theme {theme}`: pick a theme; the mode becomes its family's (Dark or Light). +fn theme_command(app: &mut LightcraftApp, p: &Value) -> Result { + let ids = crate::theme::ThemeKind::ALL.map(|k| k.id()).join("|"); + let Some(id) = p.get("theme").and_then(Value::as_str) else { + return Err(format!("view.theme: `theme` is required ({ids})")); + }; + let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| k.id() == id) else { + return Err(format!("view.theme: unknown theme `{id}` ({ids})")); + }; + app.ui.settings.select_theme(kind); + Ok(appearance_json(app)) +} + /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { if matches!(id, "library.inspectLightroom" | "library.importLightroom") { @@ -310,6 +350,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option appearance_command(app, p), + "view.theme" => theme_command(app, p), "view.gridToggle" => { app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fa2e6ad9e..c102555ef 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -115,11 +115,19 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls // rather than growing the window when the options below the grid get taller (e.g. Copy). let import = matches!(dlg, Dialog::Import { .. }); - let window_id = egui::Id::new(if import { "lightcraft-import-dialog" } else { "lightcraft-dialog" }); + let settings = matches!(dlg, Dialog::Settings { .. }); + let window_id = egui::Id::new(if import { + "lightcraft-import-dialog" + } else if settings { + "lightcraft-settings-dialog" + } else { + "lightcraft-dialog" + }); let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) .collapsible(false) .resizable(import) - .vscroll(import) + .vscroll(import || settings) + .max_size(if settings { (ctx.content_rect().size() - egui::vec2(48.0, 80.0)).max(egui::vec2(240.0, 180.0)) } else { egui::Vec2::splat(f32::INFINITY) }) .resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r }) .frame(frame) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2774a9622..159bbc2a6 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -34,9 +34,8 @@ const LABEL_W: f32 = 150.0; /// The dialog body for `tab` (the tab bar switches `tab`). pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { let t = Tokens::get(ui.ctx()); - ui.set_min_width(560.0); - ui.set_min_height(330.0); - ui.horizontal(|ui| { + ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, 560.0)); + ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 4.0; for (id, label) in TABS { if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() { @@ -533,6 +532,8 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { // ---------------------------------------------------------------------------------- Interface fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let system = app.system_theme(ui.ctx()); + appearance(ui, t, &mut app.ui.settings, system); heading(ui, t, crate::i18n::tr("Filmstrip")); check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges"); @@ -554,6 +555,193 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); } +/// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and +/// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in +/// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::AppSettings, system: Option) { + use crate::state::{AppearanceMode as M, DarkTheme, LightTheme}; + heading(ui, t, crate::i18n::tr("Appearance")); + ui.label(RichText::new(crate::i18n::tr("Appearance Mode")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsAppearance", &[(M::Auto, "Sync with System"), (M::Dark, "Dark Mode"), (M::Light, "Light Mode")], &mut s.appearance_mode); + }); + ui.add_space(4.0); + let light_active = s.theme(system) == s.light_theme.kind(); + let available = ui.available_width(); + let mut cards = |ui: &mut egui::Ui, width: f32| { + ui.spacing_mut().item_spacing.x = 12.0; + let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect(); + theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, width); + let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect(); + theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, width); + }; + if available < 440.0 { + ui.vertical(|ui| cards(ui, available)); + } else { + ui.horizontal_top(|ui| cards(ui, ((available - 12.0) / 2.0).floor())); + } + ui.add_space(2.0); +} + +/// One family's card: its title (and "Active" when it is the one shown), a preview of the +/// selected theme and a radio button per theme. +fn theme_card( + ui: &mut egui::Ui, + t: &Tokens, + title: &str, + active: bool, + options: &[(V, crate::theme::ThemeKind)], + value: &mut V, + width: f32, +) { + egui::Frame::new() + .fill(t.inset) + .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) + .corner_radius(6) + .inner_margin(8) + .show(ui, |ui| { + ui.vertical(|ui| { + let inner = (width - 18.0).clamp(120.0, 240.0); + ui.set_width(inner); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr(title)).font(t.semibold(12.0)).color(t.text)); + if active { + ui.label(RichText::new(crate::i18n::tr("In Use")).size(11.0).color(t.accent)); + } + }); + let shown = options.iter().find(|(v, _)| v == value).or(options.first()).map(|(_, k)| *k).unwrap_or_default(); + theme_preview(ui, shown, inner); + ui.horizontal(|ui| { + for (v, kind) in options { + let r = ui.radio_value(value, *v, crate::i18n::tr(kind.label())); + register(ui.ctx(), format!("radio:theme-{}", kind.id()), r.rect); + } + }); + }); + }); +} + +/// A small LightCraft window painted with `kind`'s tokens: the top bar with its search field, the +/// photo with the filmstrip under it, the Edit panel (histogram and sliders), the tool strip and +/// the bottom bar. +fn theme_preview(ui: &mut egui::Ui, kind: crate::theme::ThemeKind, width: f32) { + use egui::{Color32, Rect, Stroke, StrokeKind, pos2, vec2}; + let p = Tokens::for_kind(kind); + let (rect, _) = ui.allocate_exact_size(vec2(width, (width * 0.56).round().min(130.0)), egui::Sense::hover()); + let painter = ui.painter_at(rect); + let bar = |y: f32, x: f32, w: f32, c: Color32| painter.rect_filled(Rect::from_min_size(pos2(x, y), vec2(w, 2.0)), 1.0, c); + painter.rect_filled(rect, 3.0, p.canvas); + // top bar: sidebar and back icons, the centred search field, icons on the right + let top = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0)); + painter.rect_filled(top, 0.0, p.chrome); + for x in [6.0, 14.0, 20.0] { + painter.rect_filled(Rect::from_min_size(top.min + vec2(x, 4.5), vec2(4.0, 4.0)), 1.0, p.icon); + } + let search = Rect::from_center_size(top.center(), vec2(rect.width() * 0.34, 8.0)); + painter.rect(search, 2.0, p.field, Stroke::new(1.0, p.field_border), StrokeKind::Inside); + bar(search.center().y - 1.0, search.center().x - 9.0, 18.0, p.text_dim); + for i in 0..4 { + painter.rect_filled(Rect::from_min_size(pos2(top.right() - 10.0 - i as f32 * 9.0, top.top() + 4.5), vec2(4.0, 4.0)), 2.0, p.icon); + } + // bottom bar: view buttons, rating stars, a button + let bottom = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 12.0), rect.max); + painter.rect_filled(bottom, 0.0, p.chrome); + for x in [6.0, 13.0, 20.0] { + painter.rect_filled(Rect::from_min_size(bottom.min + vec2(x, 4.0), vec2(4.0, 4.0)), 1.0, p.icon); + } + let stars_x = bottom.center().x - 30.0; + for i in 0..5 { + painter.circle_filled(pos2(stars_x + i as f32 * 5.0, bottom.center().y), 1.4, p.star); + } + let copy = Rect::from_min_size(pos2(stars_x + 28.0, bottom.top() + 2.5), vec2(30.0, 7.0)); + painter.rect(copy, 3.0, p.button, Stroke::new(1.0, p.button_border), StrokeKind::Inside); + // tool strip on the right edge, the first tool (Edit) active + let strip = Rect::from_min_max(pos2(rect.right() - 12.0, top.bottom()), pos2(rect.right(), bottom.top())); + painter.rect_filled(strip, 0.0, p.chrome); + painter.line_segment([strip.left_top(), strip.left_bottom()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let r = Rect::from_min_size(pos2(strip.left() + 3.0, strip.top() + 4.0 + i as f32 * 9.0), vec2(6.0, 6.0)); + if i == 0 { + painter.rect_filled(r.expand(1.5), 1.5, p.tool_active); + } + painter.rect_stroke(r, 1.0, Stroke::new(1.0, if i == 0 { p.text } else { p.icon }), StrokeKind::Inside); + } + // Edit panel: histogram, then labelled sliders + let panel = Rect::from_min_max(pos2(strip.left() - (rect.width() * 0.27).round(), top.bottom()), pos2(strip.left(), bottom.top())); + painter.rect_filled(panel, 0.0, p.chrome); + painter.line_segment([panel.left_top(), panel.left_bottom()], Stroke::new(1.0, p.divider)); + let hist = Rect::from_min_size(panel.min + vec2(4.0, 4.0), vec2(panel.width() - 8.0, 14.0)); + painter.rect_filled(hist, 1.0, p.inset); + let curve = [0.2, 0.55, 0.9, 0.7, 0.5, 0.62, 0.4, 0.3, 0.18, 0.1]; + let mut pts = vec![hist.left_bottom()]; + for (i, h) in curve.iter().enumerate() { + pts.push(pos2(hist.left() + hist.width() * i as f32 / (curve.len() - 1) as f32, hist.bottom() - hist.height() * h)); + } + pts.push(hist.right_bottom()); + // the area under the curve, a column per stretch between two points + for pair in pts.windows(2) { + if let [a, b] = pair { + let r = Rect::from_min_max(pos2(a.x, a.y.max(b.y)), pos2(b.x, hist.bottom())); + painter.rect_filled(r, 0.0, p.track.gamma_multiply(0.5)); + } + } + painter.add(egui::Shape::line(pts, Stroke::new(1.0, p.text_dim))); + let sliders = [0.55, 0.62, 0.35, 0.7, 0.6, 0.45]; + let mut y = hist.bottom() + 7.0; + bar(y, panel.left() + 5.0, 14.0, p.text); + y += 6.0; + for v in sliders { + if y + 7.0 > panel.bottom() { + break; + } + bar(y, panel.left() + 5.0, 12.0, p.text_label); + bar(y, panel.right() - 10.0, 5.0, p.text_dim); + let track_y = y + 5.0; + painter.line_segment([pos2(panel.left() + 5.0, track_y), pos2(panel.right() - 5.0, track_y)], Stroke::new(1.0, p.track)); + painter.circle_filled(pos2(panel.left() + 5.0 + (panel.width() - 10.0) * v, track_y), 1.8, p.thumb); + y += 10.0; + } + // the photo (its own colours: a photo looks the same in every theme) above the filmstrip + let film = Rect::from_min_max(pos2(rect.left(), bottom.top() - 18.0), pos2(panel.left(), bottom.top())); + let stage = Rect::from_min_max(pos2(rect.left(), top.bottom()), pos2(panel.left(), film.top())); + let photo = Rect::from_center_size(stage.center(), vec2(stage.width() * 0.62, stage.height() - 8.0)); + painter.rect_filled(photo, 0.0, Color32::from_rgb(0x6f, 0x8f, 0xb4)); + let horizon = photo.top() + photo.height() * 0.62; + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left(), horizon), + pos2(photo.left() + photo.width() * 0.3, photo.top() + photo.height() * 0.3), + pos2(photo.left() + photo.width() * 0.55, horizon), + ], + Color32::from_rgb(0x4a, 0x55, 0x66), + Stroke::NONE, + )); + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left() + photo.width() * 0.35, horizon), + pos2(photo.left() + photo.width() * 0.68, photo.top() + photo.height() * 0.2), + pos2(photo.right(), horizon), + ], + Color32::from_rgb(0x5a, 0x63, 0x72), + Stroke::NONE, + )); + painter.rect_filled(Rect::from_min_max(pos2(photo.left(), horizon), photo.max), 0.0, Color32::from_rgb(0x3d, 0x5a, 0x3a)); + painter.line_segment([film.left_top(), film.right_top()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let cell = Rect::from_min_size(pos2(film.left() + 4.0 + i as f32 * 17.0, film.top() + 3.0), vec2(15.0, 12.0)); + if cell.right() > film.right() - 2.0 { + break; + } + let selected = i == 1; + painter.rect_filled(cell, 0.0, if selected { p.cell_selected } else { p.cell }); + let thumb = cell.shrink(2.5); + painter.rect_filled(thumb, 0.0, Color32::from_rgb(0x6f + 8 * i as u8, 0x80, 0x8f)); + if selected { + painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, p.pick), StrokeKind::Outside); + } + } +} + // ------------------------------------------------------------------------------- Open Library… /// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index d06a75594..cfdeb27cb 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -53,7 +53,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.integrated_titlebar { window_handle(app, ui, full, left as f32, 12.0); } - let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); + // room for the filter icon and the six icons on the right + let mut sw = 640.0f32.min(full.width() - 540.0).max(200.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; @@ -189,6 +190,26 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } x -= 40.0; } + // Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode + let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); + let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); + let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); + let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip); + register(ui.ctx(), "icon:appearance", r); + paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); + if resp.clicked() { + let _ = app.run("view.appearance", json!({})); + } let _ = Align2::CENTER_CENTER; }); } + +/// The appearance button's icon and the mode's name for its tooltip. +pub fn appearance_icon(mode: crate::state::AppearanceMode) -> (Icon, &'static str) { + use crate::state::AppearanceMode; + match mode { + AppearanceMode::Auto => (Icon::Monitor, "Sync with System"), + AppearanceMode::Light => (Icon::Sun, "Light Mode"), + AppearanceMode::Dark => (Icon::Moon, "Dark Mode"), + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 83b3df292..c610ddd5f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -127,6 +127,67 @@ impl InfoOverlay { } } +/// Whether the interface uses the dark or the light theme (Settings ▸ Interface ▸ Appearance). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AppearanceMode { + /// Follow the operating system's dark/light setting (dark when it gives no answer). + Auto, + /// The dark theme, whatever the system says (the default: LightCraft's look so far). + #[default] + Dark, + Light, +} + +impl AppearanceMode { + /// The header button's order: Auto, Light, Dark, then Auto again. + pub fn next(self) -> AppearanceMode { + match self { + AppearanceMode::Auto => AppearanceMode::Light, + AppearanceMode::Light => AppearanceMode::Dark, + AppearanceMode::Dark => AppearanceMode::Auto, + } + } +} + +/// The theme the dark appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DarkTheme { + #[default] + Charcoal, + Midnight, +} + +/// The theme the light appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightTheme { + #[default] + Silver, + Paper, +} + +impl DarkTheme { + pub const ALL: [DarkTheme; 2] = [DarkTheme::Charcoal, DarkTheme::Midnight]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + DarkTheme::Charcoal => crate::theme::ThemeKind::Charcoal, + DarkTheme::Midnight => crate::theme::ThemeKind::Midnight, + } + } +} + +impl LightTheme { + pub const ALL: [LightTheme; 2] = [LightTheme::Silver, LightTheme::Paper]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + LightTheme::Silver => crate::theme::ThemeKind::Silver, + LightTheme::Paper => crate::theme::ThemeKind::Paper, + } + } +} + /// App-level preferences (Settings dialog). They belong to the app, not a library, and are /// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library /// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. @@ -156,6 +217,12 @@ pub struct AppSettings { pub grid_badges: GridBadges, /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. pub keymap: crate::shortcuts::Keymap, + /// Auto (follow the system), Dark or Light. + pub appearance_mode: AppearanceMode, + /// The theme of the dark appearance. + pub dark_theme: DarkTheme, + /// The theme of the light appearance. + pub light_theme: LightTheme, } impl Default for AppSettings { @@ -172,6 +239,9 @@ impl Default for AppSettings { film_badges: true, grid_badges: GridBadges::Auto, keymap: Default::default(), + appearance_mode: AppearanceMode::Dark, + dark_theme: DarkTheme::Charcoal, + light_theme: LightTheme::Silver, } } } @@ -196,6 +266,29 @@ pub const LOUPE_EDGE_STEP: usize = 64; pub const STANDARD_PREVIEW_EDGE: u32 = 2560; impl AppSettings { + /// The theme to show: the light theme in Light mode, or in Auto when `system` is light; the + /// dark theme otherwise (Auto with no answer from the system included). + pub fn theme(&self, system: Option) -> crate::theme::ThemeKind { + let light = match self.appearance_mode { + AppearanceMode::Light => true, + AppearanceMode::Dark => false, + AppearanceMode::Auto => system == Some(egui::Theme::Light), + }; + if light { self.light_theme.kind() } else { self.dark_theme.kind() } + } + + /// Pick `kind` and fix the mode to its family (the View ▸ Theme items). + pub fn select_theme(&mut self, kind: crate::theme::ThemeKind) { + use crate::theme::ThemeKind; + match kind { + ThemeKind::Charcoal => self.dark_theme = DarkTheme::Charcoal, + ThemeKind::Midnight => self.dark_theme = DarkTheme::Midnight, + ThemeKind::Silver => self.light_theme = LightTheme::Silver, + ThemeKind::Paper => self.light_theme = LightTheme::Paper, + } + self.appearance_mode = if kind.is_dark() { AppearanceMode::Dark } else { AppearanceMode::Light }; + } + /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: /// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at /// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling diff --git a/crates/ui-egui/src/tests_appearance.rs b/crates/ui-egui/src/tests_appearance.rs new file mode 100644 index 000000000..2f4ab65ae --- /dev/null +++ b/crates/ui-egui/src/tests_appearance.rs @@ -0,0 +1,197 @@ +//! Appearance Mode: Auto (follow the system), Dark or Light, each with its own saved theme. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::{AppSettings, AppearanceMode, DarkTheme, LightTheme, UiState}; +use crate::theme::ThemeKind; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() }) +} + +/// One frame of logic on `ctx`: where the theme is applied. +fn frame(app: &mut LightcraftApp, ctx: &egui::Context) { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); + output.textures_delta.clear(); +} + +#[test] +fn new_users_keep_the_dark_look_and_old_settings_load_unchanged() { + let s = AppSettings::default(); + assert_eq!((s.appearance_mode, s.dark_theme, s.light_theme), (AppearanceMode::Dark, DarkTheme::Charcoal, LightTheme::Silver)); + assert_eq!(s.theme(Some(egui::Theme::Light)), ThemeKind::Charcoal, "Auto is opt-in"); + // a ui.json saved before appearance modes existed + let old: UiState = serde_json::from_str(r#"{"settings": {"gridBadges": "always", "confirmDelete": true}}"#).unwrap(); + assert_eq!(old.settings.appearance_mode, AppearanceMode::Dark); + assert_eq!(old.settings.dark_theme, DarkTheme::Charcoal); + assert_eq!(old.settings.light_theme, LightTheme::Silver); + assert!(old.settings.confirm_delete); + // and the Charcoal tokens are the ones LightCraft always had + assert_eq!(crate::theme::Tokens::for_kind(ThemeKind::Charcoal).chrome, crate::theme::Tokens::default().chrome); +} + +#[test] +fn choices_validate_and_survive_a_restart() { + let mut a = app(); + let ctx = egui::Context::default(); + let set = |a: &mut LightcraftApp, p: serde_json::Value| { + let (req, _) = crate::control::ControlRequest::new("ui.set", p); + match crate::control::handle(a, &ctx, &req) { + crate::control::Outcome::Done(reply) => reply, + _ => panic!("expected a reply"), + } + }; + let ok = set(&mut a, json!({"settings": {"appearanceMode": "auto", "darkTheme": "midnight", "lightTheme": "paper"}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Auto); + for bad in [json!({"appearanceMode": "sepia"}), json!({"darkTheme": "paper"}), json!({"lightTheme": "midnight"}), json!({"appearanceMode": 3})] { + let reply = set(&mut a, json!({ "settings": bad })); + assert_eq!(reply["ok"], false, "{bad} accepted"); + } + assert_eq!((a.ui.settings.dark_theme, a.ui.settings.light_theme), (DarkTheme::Midnight, LightTheme::Paper), "a rejected value changes nothing"); + let saved = serde_json::to_string(&a.ui).unwrap(); + assert!(saved.contains(r#""appearanceMode":"auto""#) && saved.contains(r#""darkTheme":"midnight""#) && saved.contains(r#""lightTheme":"paper""#)); + let restarted: UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restarted.settings, a.ui.settings); +} + +#[test] +fn the_button_cycles_auto_light_dark_without_losing_theme_choices() { + let mut a = app(); + let ctx = egui::Context::default(); + a.services.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark))); + a.run("view.theme", json!({"theme": "paper"})).unwrap(); + a.run("view.theme", json!({"theme": "midnight"})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Dark, "a dark theme fixes the mode to Dark"); + for (mode, shown) in + [(AppearanceMode::Auto, ThemeKind::Midnight), (AppearanceMode::Light, ThemeKind::Paper), (AppearanceMode::Dark, ThemeKind::Midnight)] + { + let r = a.run("view.appearance", json!({})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, mode); + assert_eq!(r["darkTheme"], "midnight"); + assert_eq!(r["lightTheme"], "paper"); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(shown)); + assert_eq!(crate::theme::Tokens::get(&ctx).chrome, crate::theme::Tokens::for_kind(shown).chrome); + let icon = match mode { + AppearanceMode::Auto => crate::icons::Icon::Monitor, + AppearanceMode::Light => crate::icons::Icon::Sun, + AppearanceMode::Dark => crate::icons::Icon::Moon, + }; + assert_eq!(crate::panels::topbar::appearance_icon(mode).0, icon); + } + // a light theme from the menu fixes Light + a.run("view.theme", json!({"theme": "silver"})).unwrap(); + assert_eq!( + (a.ui.settings.appearance_mode, a.ui.settings.light_theme, a.ui.settings.dark_theme), + (AppearanceMode::Light, LightTheme::Silver, DarkTheme::Midnight) + ); + // bad parameters are errors, never a panic, and change nothing + for p in [json!({}), json!({"theme": 4}), json!({"theme": "neon"})] { + assert!(a.run("view.theme", p.clone()).is_err(), "{p}"); + } + for p in [json!({"mode": "sepia"}), json!({"mode": 1}), json!({"mode": []})] { + assert!(a.run("view.appearance", p.clone()).is_err(), "{p}"); + } + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Light); + a.run("view.appearance", json!({"mode": "auto"})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Auto); +} + +#[test] +fn auto_follows_the_system_and_falls_back_to_dark() { + use std::sync::{Arc, Mutex}; + let system = Arc::new(Mutex::new(Some(egui::Theme::Light))); + let mut a = app(); + let reported = Arc::clone(&system); + a.services.system_theme = Some(Box::new(move |_| *reported.lock().unwrap())); + a.run("view.appearance", json!({"mode": "auto"})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Silver)); + assert_eq!(ctx.theme(), egui::Theme::Light); + *system.lock().unwrap() = Some(egui::Theme::Dark); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Charcoal)); + assert_eq!(ctx.theme(), egui::Theme::Dark); + // no answer from the host or from egui: dark + *system.lock().unwrap() = None; + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Charcoal)); + // without a host service, egui's report is used + let mut plain = app(); + plain.ui.settings.appearance_mode = AppearanceMode::Auto; + let ctx = egui::Context::default(); + let input = egui::RawInput { system_theme: Some(egui::Theme::Light), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + assert_eq!(plain.system_theme(ui.ctx()), Some(egui::Theme::Light)); + plain.logic(ui.ctx()); + }); + output.textures_delta.clear(); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Silver)); +} + +#[test] +fn view_menu_lists_modes_and_themes_with_checks() { + let mut a = app(); + a.run("view.theme", json!({"theme": "paper"})).unwrap(); + let menus = crate::menubar::menu_bar(&a); + let text = serde_json::to_string(&menus).unwrap(); + for label in ["Appearance", "Sync with System", "Light Mode", "Dark Mode", "Next Appearance Mode", "Charcoal", "Midnight", "Silver", "Paper"] { + assert!(text.contains(&format!("\"{label}\"")), "{label} missing from the menus"); + } + assert!(text.contains(r#""params":{"theme":"paper"}"#)); +} + +#[test] +fn the_top_bar_button_and_settings_cards_drive_the_mode() { + let mut h = Headless::new(app(), [1400.0, 900.0], 1.0); + h.settle(Duration::from_secs(60)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance"}), T)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Auto); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Light); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Silver)); + // Settings ▸ Interface: the mode buttons and the theme radios + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + h.step(); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "radio:theme-paper"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.light_theme, LightTheme::Paper); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Paper)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:settingsAppearance-1"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Dark); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Charcoal)); +} + +#[test] +fn interface_settings_fit_small_windows_and_stack_theme_choices() { + // Desktop windows have a 900×560 minimum; also stress a smaller headless viewport. + for size in [[900.0, 560.0], [640.0, 480.0]] { + let mut h = Headless::new(app(), size, 1.0); + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + for _ in 0..4 { + h.step(); + } + let window = h.app.widgets.iter().find(|(id, _)| id == "dialog:window").unwrap().1; + assert!(window.width() <= size[0], "{size:?}: {window:?}"); + assert!(window.height() <= size[1], "{size:?}: {window:?}"); + let silver = h.app.widgets.iter().find(|(id, _)| id == "radio:theme-silver").unwrap().1; + let mode = h.app.widgets.iter().find(|(id, _)| id == "button:settingsAppearance-0").unwrap().1; + assert!(silver.top() > mode.bottom() + 80.0, "theme choices must sit below their preview"); + assert!(silver.right() <= window.right(), "theme choices stay inside the modal"); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b04ef1be2..a3a2518f3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,5 +1,9 @@ //! Design tokens (colours, sizes, fonts). Values measured from black-box observation of the //! reference app's dark theme (see plan/lightroom/10-observed-ui.md); everything is our own code. +//! +//! Themes come in two families, picked by the Appearance Mode setting (Settings ▸ Interface): +//! - dark: **Charcoal** (the default, the observed dark look) and **Midnight** (near black); +//! - light: **Silver** (light grey chrome) and **Paper** (near white). use std::sync::Arc; @@ -75,6 +79,44 @@ pub struct Tokens { pub film_h: f32, } +/// An interface theme. Each belongs to the dark or the light family. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum ThemeKind { + #[default] + Charcoal, + Midnight, + Silver, + Paper, +} + +impl ThemeKind { + pub const ALL: [ThemeKind; 4] = [ThemeKind::Charcoal, ThemeKind::Midnight, ThemeKind::Silver, ThemeKind::Paper]; + + pub fn is_dark(self) -> bool { + matches!(self, ThemeKind::Charcoal | ThemeKind::Midnight) + } + + /// The theme's id (settings value and command suffix). + pub fn id(self) -> &'static str { + match self { + ThemeKind::Charcoal => "charcoal", + ThemeKind::Midnight => "midnight", + ThemeKind::Silver => "silver", + ThemeKind::Paper => "paper", + } + } + + /// The English display name. + pub fn label(self) -> &'static str { + match self { + ThemeKind::Charcoal => "Charcoal", + ThemeKind::Midnight => "Midnight", + ThemeKind::Silver => "Silver", + ThemeKind::Paper => "Paper", + } + } +} + impl Default for Tokens { fn default() -> Self { Tokens { @@ -118,6 +160,92 @@ impl Default for Tokens { } impl Tokens { + /// The tokens of `kind`. Metrics are shared; only colours change. + pub fn for_kind(kind: ThemeKind) -> Tokens { + let rgb = |v: u32| Color32::from_rgb((v >> 16) as u8, (v >> 8) as u8, v as u8); + let base = Tokens::default(); + match kind { + ThemeKind::Charcoal => base, + ThemeKind::Midnight => Tokens { + chrome: rgb(0x1e1e1e), + canvas: rgb(0x121212), + grid_bg: rgb(0x080808), + cell: rgb(0x121212), + cell_selected: rgb(0x262626), + divider: rgb(0x0e0e0e), + inset: rgb(0x181818), + field: rgb(0x161616), + field_border: rgb(0x333333), + button: rgb(0x191919), + button_border: rgb(0x404040), + hover: rgb(0x2e2e2e), + pressed: rgb(0x343434), + tool_active: rgb(0x343434), + ..base + }, + ThemeKind::Silver => Tokens { + chrome: rgb(0xe6e6e6), + canvas: rgb(0xcfcfcf), + grid_bg: rgb(0xd9d9d9), + cell: rgb(0xcbcbcb), + cell_selected: rgb(0xb4b4b4), + divider: rgb(0xc4c4c4), + inset: rgb(0xdcdcdc), + field: rgb(0xf6f6f6), + field_border: rgb(0xb8b8b8), + button: rgb(0xf2f2f2), + button_border: rgb(0xb0b0b0), + hover: rgb(0xd2d2d2), + pressed: rgb(0xc4c4c4), + tool_active: rgb(0xcacaca), + text: rgb(0x1c1c1c), + text_label: rgb(0x383838), + text_dim: rgb(0x6a6a6a), + text_disabled: rgb(0xa2a2a2), + icon: rgb(0x555555), + track: rgb(0xa8a8a8), + thumb: rgb(0x5e5e5e), + thumb_hover: rgb(0x262626), + accent: rgb(0x0a62d0), + star: rgb(0x3a3a3a), + pick: rgb(0x2a2a2a), + reject: rgb(0xc83434), + caution: rgb(0xb0761a), + ..base + }, + ThemeKind::Paper => Tokens { + chrome: rgb(0xfafafa), + canvas: rgb(0xececec), + grid_bg: rgb(0xf2f2f2), + cell: rgb(0xe8e8e8), + cell_selected: rgb(0xd2d2d2), + divider: rgb(0xe0e0e0), + inset: rgb(0xf0f0f0), + field: rgb(0xffffff), + field_border: rgb(0xcccccc), + button: rgb(0xffffff), + button_border: rgb(0xc6c6c6), + hover: rgb(0xebebeb), + pressed: rgb(0xdedede), + tool_active: rgb(0xe2e2e2), + text: rgb(0x161616), + text_label: rgb(0x343434), + text_dim: rgb(0x707070), + text_disabled: rgb(0xababab), + icon: rgb(0x585858), + track: rgb(0xbdbdbd), + thumb: rgb(0x606060), + thumb_hover: rgb(0x202020), + accent: rgb(0x0a62d0), + star: rgb(0x3a3a3a), + pick: rgb(0x2a2a2a), + reject: rgb(0xc83434), + caution: rgb(0xb0761a), + ..base + }, + } + } + pub fn get(ctx: &egui::Context) -> Tokens { ctx.data(|d| d.get_temp::(egui::Id::NULL)).unwrap_or_default() } @@ -215,10 +343,30 @@ fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { format!("craft-fonts {} {}", f.family, f.style) } +fn theme_id() -> egui::Id { + egui::Id::new("lightcraft-theme") +} + +/// The theme `ctx` is styled with (none before the first [`apply_kind`]). +pub fn current(ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp::(theme_id())) +} + +/// Style `ctx` with the default theme. pub fn apply(ctx: &egui::Context) { - let t = Tokens::default(); - ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t)); - let mut v = Visuals::dark(); + apply_kind(ctx, ThemeKind::default()); +} + +/// Style `ctx` with `kind`. The visuals go to both of egui's styles, so egui following the +/// system's dark/light setting on its own never swaps in its stock look. +pub fn apply_kind(ctx: &egui::Context, kind: ThemeKind) { + let t = Tokens::for_kind(kind); + ctx.data_mut(|d| { + d.insert_temp(egui::Id::NULL, t); + d.insert_temp(theme_id(), kind); + }); + let dark = kind.is_dark(); + let mut v = if dark { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.chrome; v.window_fill = t.chrome; v.extreme_bg_color = t.field; @@ -229,8 +377,9 @@ pub fn apply(ctx: &egui::Context) { v.selection.bg_fill = t.accent.gamma_multiply(0.6); v.selection.stroke = Stroke::new(1.0, t.accent); v.override_text_color = Some(t.text_label); - v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) }; - v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(160) }; + let (popup, window) = if dark { (140, 160) } else { (50, 60) }; + v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(popup) }; + v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(window) }; let w = &mut v.widgets; for (wv, fill) in [ (&mut w.noninteractive, t.chrome), @@ -246,8 +395,10 @@ pub fn apply(ctx: &egui::Context) { } w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider); w.inactive.bg_stroke = Stroke::new(1.0, t.button_border); - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + ctx.set_visuals_of(egui::Theme::Dark, v.clone()); + ctx.set_visuals_of(egui::Theme::Light, v); + ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light }); + ctx.all_styles_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(8.0, 3.0); s.spacing.interact_size.y = 22.0; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4247b559e..fe95e0e32 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -55,6 +55,13 @@ scrolling pans in both axes and respects the operating system's scrolling direct These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference views, and only apply over their image areas. Panning stops at the image edges. +The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, +Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: +"charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. +Both reply `{mode, darkTheme, lightTheme}`; the same values are `settings.appearanceMode`, +`settings.darkTheme` and `settings.lightTheme` in `ui.set`. Auto follows the system (on Linux the +XDG desktop portal, watched for changes rather than polled) and is dark when the system says nothing. + ### When the library can't be saved With a persistent library, every command that changes something is written to the catalog journal (fsynced) before diff --git a/docs/parity.md b/docs/parity.md index 4c27755b5..9217f0ff3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -486,7 +486,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-