From b4a192a54e1c1c9ca6753abcf24260b0b751722d Mon Sep 17 00:00:00 2001 From: Stano Bustor Date: Thu, 8 Oct 2026 16:00:00 +0200 Subject: [PATCH] UI: preserve preview aspect ratio across views Keep embedded and decoded preview proportions consistent in the justified grid, photo views, alternate detail previews, and culling previews. Co-Authored-By: GPT-6 Luna --- crates/ui-egui/src/panels/compare.rs | 19 +++++---- crates/ui-egui/src/panels/detail.rs | 60 +++++++++++++++++++++++++--- crates/ui-egui/src/panels/grid.rs | 8 +--- 3 files changed, 68 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 07abae24b..c0b8d47a7 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -171,23 +171,28 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo } let p = ui.painter_at(img_area); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + let mut display = img; if let Some(tex) = app.renderer.textures.get(&slot).filter(|x| x.photo == id) { - p.image(tex.tex.id(), img, uv, Color32::WHITE); + let texture_aspect = tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32; + display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan); + p.image(tex.tex.id(), display, uv, Color32::WHITE); } else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) { - p.image(tex.tex.id(), img, uv, Color32::WHITE); + let texture_aspect = tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32; + display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan); + p.image(tex.tex.id(), display, uv, Color32::WHITE); } else { p.rect_filled(img.intersect(img_area), 0.0, Color32::from_gray(38)); } if photo.flag == Flag::Reject { - p.rect_filled(img, 0.0, Color32::from_black_alpha(110)); + p.rect_filled(display, 0.0, Color32::from_black_alpha(110)); } let active = app.session.selection.active == Some(id); if active { - p.rect_stroke(img.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); + p.rect_stroke(display.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); } // caption: label · file name · stars · flag // caption right under the image (at the bottom when zoomed in) - let cap_top = (img.bottom() + 2.0).min(img_area.bottom()); + let cap_top = (display.bottom() + 2.0).min(img_area.bottom()); let cap = Rect::from_min_max(pos2(img.left().max(area.left()), cap_top), pos2(area.right(), cap_top + caption_h)); let pt = ui.painter(); let mut x = cap.left() + 4.0; @@ -217,8 +222,8 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject), _ => {} } - register(ui.ctx(), format!("cull:{}", id.0), img); - (img, resp) + register(ui.ctx(), format!("cull:{}", id.0), display); + (display, resp) } fn slot_index(s: Slot) -> u8 { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 3b3622bed..7ef1571ae 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -125,6 +125,14 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], Rect::from_center_size(c, vec2(w, h)) } +/// Fit a decoded image inside the frame calculated from catalog metadata. Unsupported RAWs can +/// only show their embedded camera JPEG, whose crop and aspect ratio can differ from that metadata. +pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect { + let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); + let scale = (area.width() / w).min(area.height() / h); + Rect::from_center_size(area.center(), vec2(w * scale, h * scale)) +} + /// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { let t = if *anim { 0.22 } else { 0.0 }; @@ -191,7 +199,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { _ => vec![area], }; let main_area = *areas.last().unwrap_or(&area); - let target_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan); + let visible_texture_aspect = app + .renderer + .textures + .get(&Slot::Main) + .filter(|t| t.photo == id) + .or_else(|| app.renderer.textures.get(&Slot::Preview).filter(|t| t.photo == id)) + .or_else(|| app.renderer.thumb(id)) + .map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32); + let display_aspect = visible_texture_aspect.unwrap_or(aspect); + let target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); let img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect); app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) @@ -262,7 +279,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { return "none"; }; - p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let image_rect = fit_texture_rect(r, tex.size); + p.image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); what }; // soft proofing: a paper-white surround and the proof's name, as Lightroom shows it @@ -284,9 +302,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else if let Some((_key, label)) = &hover_key && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id) { - p.image(tex.tex.id(), img_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + let hover_rect = fit_texture_rect(img_rect, tex.size); + p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE); - let bg = Rect::from_min_size(img_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0)); + let bg = Rect::from_min_size(hover_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0)); p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); shown = "hover"; @@ -314,7 +333,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.ui.before_after == BeforeAfter::Split { let mid = img_rect.center().x; if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { - let left = Rect::from_min_max(img_rect.min, pos2(mid, img_rect.bottom())); + let before_rect = fit_texture_rect(img_rect, tex.size); + let left = Rect::from_min_max(before_rect.min, pos2(before_rect.center().x, before_rect.bottom())); p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE); } p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE)); @@ -322,7 +342,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.ui.before_after == BeforeAfter::SplitTopBottom { let mid = img_rect.center().y; if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { - let top = Rect::from_min_max(img_rect.min, pos2(img_rect.right(), mid)); + let before_rect = fit_texture_rect(img_rect, tex.size); + let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.center().y)); p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE); } p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE)); @@ -1608,6 +1629,33 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: } } +#[cfg(test)] +mod preview_geometry_tests { + use super::{fit_rect, fit_texture_rect}; + use crate::state::Zoom; + use egui::{Rect, pos2, vec2}; + + #[test] + fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() { + let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); + let image = fit_texture_rect(frame, [100, 200]); + + assert!((image.width() / image.height() - 0.5).abs() < 1e-6); + assert_eq!(image.center(), frame.center()); + assert!(frame.contains_rect(image)); + } + + #[test] + fn portrait_preview_aspect_fits_detail_area_without_stretching() { + let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); + let image = fit_rect(area, 0.5, Zoom::Fit, [4000, 6000], 1.0, (0.5, 0.5)); + + assert!((image.width() / image.height() - 0.5).abs() < 1e-6); + assert_eq!(image.size(), vec2(100.0, 200.0)); + assert_eq!(image.center(), area.center()); + } +} + /// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto. /// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle. /// `held`: drawn with ⌘ held in the crop tool, which stays active afterwards. diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7dc07602a..de5b1d0ea 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -499,12 +499,8 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square request_thumb(app, id, size, if onscreen { 10 } else { 5 }); if let Some(tex) = app.renderer.thumb(id) { let [tw, th] = tex.size; - let fit = if square { - let s = (img_rect.width() / tw as f32).min(img_rect.height() / th as f32); - Rect::from_center_size(img_rect.center(), vec2(tw as f32 * s, th as f32 * s)) - } else { - img_rect - }; + // Preserve the decoded preview's ratio when it differs from the catalog dimensions. + let fit = super::detail::fit_texture_rect(img_rect, [tw, th]); p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); if active { p.rect_stroke(fit.expand(if square { 2.0 } else { 0.0 }), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);