Merge PR #2317: #2267: the Before label stays in view when zoomed in (stacked on #2274)

crates/ui-egui/src/tests_zoom.rs: both new Before-label tests, with #1968's comment wording on the crop test

docs/whats-new.md: both entries under 2026-10-19
This commit is contained in:
aiongg
2026-10-22 02:37:40 +00:00
3 changed files with 164 additions and 27 deletions
+109 -27
View File
@@ -749,6 +749,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60));
}
let shown;
// the Before label's pill, painted once the window renders are down (they would cover it)
let mut before_badge: Option<(Rect, std::sync::Arc<egui::Galley>)> = None;
// below what else lives in the canvas' top-left corner: the filter pill, the info overlay
let pill_inset = if !fullscreen && filter_pill_shown(app) { FILTER_PILL_INSET + FILTER_PILL_HEIGHT } else { 0.0 };
let badge_top_inset = pill_inset.max(info_overlay_extent(app, &p, &photo));
if split {
// each view stays in its pane: zoomed in, a photo reaches past it (and would cover the
// other view's margins with its whole-frame render, magnified and blurred)
@@ -761,18 +766,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim);
} else if show_before {
shown = draw(Slot::Before, img_rect, canvas);
// on the photo itself, so it gets the hover label's dark pill to stay readable over light pictures
// on the photo itself, so it gets the hover label's dark pill to stay readable over light
// pictures; painted after the window renders below, which would cover it zoomed in
let g = p.layout_no_wrap(crate::i18n::tr("Before").to_string(), t.font(12.0), Color32::WHITE);
let bg = label_badge_rect(img_rect, g.size());
p.rect_filled(bg, 4.0, Color32::from_black_alpha(160));
p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE);
before_badge = Some((label_badge_rect(img_rect, canvas, g.size(), badge_top_inset), g));
} else if let Some((_key, label)) = &hover_key
&& let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id)
{
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 = label_badge_rect(hover_rect, g.size());
let bg = label_badge_rect(hover_rect, canvas, g.size(), badge_top_inset);
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";
@@ -823,6 +827,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let clip = crate::region::window_clip(mode, v.before, v.visible, canvas, img_rect);
draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view);
}
if let Some((bg, g)) = before_badge {
p.rect_filled(bg, 4.0, Color32::from_black_alpha(160));
p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE);
register(ui.ctx(), "canvas:beforeLabel", bg);
}
// the line of a wipe over both
if app.ui.before_after == BeforeAfter::Split {
p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE));
@@ -961,17 +970,60 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
/// The dark pill behind a label drawn over the photo (`Before`, a hovered preset's name): at the
/// photo's top left, inset 8 points, with the `text` size plus padding.
fn label_badge_rect(photo: Rect, text: egui::Vec2) -> Rect {
Rect::from_min_size(photo.left_top() + vec2(8.0, 8.0), text + vec2(16.0, 8.0))
/// photo's top left, inset 8 points, with the `text` size plus padding. Zoomed in, the photo
/// reaches past its `pane` (the canvas): the pill then sits at the pane's top left, so the label
/// stays in view (issue #2267: at 100 % it left the screen with the photo's corner), below the
/// `top_inset` the filter pill takes in that corner, and never past the pane's right edge.
fn label_badge_rect(photo: Rect, pane: Rect, text: egui::Vec2, top_inset: f32) -> Rect {
let size = text + vec2(16.0, 8.0);
let left = photo.left().max(pane.left()).min(pane.right() - size.x - 16.0).max(pane.left());
let corner = pos2(left, photo.top().max(pane.top() + top_inset));
Rect::from_min_size(corner + vec2(8.0, 8.0), size)
}
/// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date
/// and size, or with the camera and exposure.
fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) {
let Some(lines) = info_overlay_lines(app, photo) else { return };
let t = Tokens::get(p.ctx());
let mut y = canvas.top() + INFO_OVERLAY_TOP;
for (i, l) in lines.iter().enumerate() {
// laid out uncoloured: `galley` only fills placeholder text with its colour, so a white
// layout made the shadow a second white copy (#1690)
let g = p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER);
let at = pos2(canvas.left() + 14.0, y);
// a soft shadow keeps the text readable on bright photos
p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200));
p.galley(at, g.clone(), Color32::WHITE);
y += g.size().y + INFO_OVERLAY_GAP;
}
register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y)));
}
/// Where the info overlay's text starts below the canvas' top, and the gap after each line.
const INFO_OVERLAY_TOP: f32 = 12.0;
const INFO_OVERLAY_GAP: f32 = 3.0;
/// The info overlay's font for line `i` (the file name is the first, larger line).
fn info_overlay_font(t: &Tokens, i: usize) -> egui::FontId {
if i == 0 { t.semibold(15.0) } else { t.font(12.5) }
}
/// How far below the canvas' top the info overlay's text reaches (0 when it is off), so a label
/// drawn in that corner can sit below it.
fn info_overlay_extent(app: &LightcraftApp, p: &egui::Painter, photo: &lightcraft_catalog::Photo) -> f32 {
let Some(lines) = info_overlay_lines(app, photo) else { return 0.0 };
let t = Tokens::get(p.ctx());
lines.iter().enumerate().fold(INFO_OVERLAY_TOP, |y, (i, l)| {
y + p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER).size().y + INFO_OVERLAY_GAP
})
}
/// The info overlay's lines for `photo`, or `None` when it is off.
fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Option<Vec<String>> {
use crate::state::InfoOverlay;
let lines: Vec<String> = match app.ui.info_overlay {
InfoOverlay::Off => return,
InfoOverlay::Off => return None,
InfoOverlay::Basic => {
let date = photo.captured.as_deref().unwrap_or(&photo.imported);
vec![photo.file_name.clone(), format!("{} · {} × {}", pretty_date(date), photo.width, photo.height)]
@@ -1000,20 +1052,7 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li
v
}
};
let t = Tokens::get(p.ctx());
let mut y = canvas.top() + 12.0;
for (i, l) in lines.iter().enumerate() {
let font = if i == 0 { t.semibold(15.0) } else { t.font(12.5) };
// laid out uncoloured: `galley` only fills placeholder text with its colour, so a white
// layout made the shadow a second white copy (#1690)
let g = p.layout_no_wrap(l.clone(), font, Color32::PLACEHOLDER);
let at = pos2(canvas.left() + 14.0, y);
// a soft shadow keeps the text readable on bright photos
p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200));
p.galley(at, g.clone(), Color32::WHITE);
y += g.size().y + 3.0;
}
register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y)));
Some(lines)
}
/// What the user did to a face box this frame.
@@ -1189,6 +1228,16 @@ fn region_overlay(
edit
}
/// The filter pill's inset from the canvas' top-left corner and its height (the same inset from
/// the left as from the top, so the pill sits in the corner, #1645).
const FILTER_PILL_INSET: f32 = 3.0;
const FILTER_PILL_HEIGHT: f32 = 20.0;
/// Whether [`filter_pill`] has something to show (a filter is active).
fn filter_pill_shown(app: &LightcraftApp) -> bool {
!lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).is_empty()
}
/// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous
/// follow them, so a filter must never be invisible here. A click clears them all. It sits in the
/// canvas margin, so the photo does not move.
@@ -1202,10 +1251,9 @@ fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) {
let label =
chips.iter().map(|c| crate::panels::chips::display_label(c, &app.session.filter, &app.session.catalog)).collect::<Vec<_>>().join(" · ");
let g = p.layout_no_wrap(crate::i18n::tr_format!("Filtered: {label}", label = label), t.font(12.0), Color32::from_gray(225));
// the same inset from the left as from the top, so the pill sits in the corner (#1645)
let inset = 3.0;
let inset = FILTER_PILL_INSET;
let w = (g.size().x + 40.0).min((canvas.width() - 2.0 * inset).max(60.0));
let r = Rect::from_min_size(canvas.min + vec2(inset, inset), vec2(w, 20.0));
let r = Rect::from_min_size(canvas.min + vec2(inset, inset), vec2(w, FILTER_PILL_HEIGHT));
let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click());
crate::widgets::announce(&resp, crate::i18n::tr("Filters"));
register(ui.ctx(), "loupe:filters", r);
@@ -2773,13 +2821,47 @@ mod preview_geometry_tests {
#[test]
fn before_label_badge_wraps_the_text() {
let text = vec2(40.0, 14.0);
let pane = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
let photo = Rect::from_min_size(pos2(20.0, 30.0), vec2(400.0, 300.0));
let bg = label_badge_rect(photo, text);
let bg = label_badge_rect(photo, pane, text, 0.0);
assert_eq!(bg.min, pos2(28.0, 38.0));
assert_eq!(bg.size(), vec2(56.0, 22.0));
assert!(photo.contains_rect(bg));
}
/// Zoomed in, the photo's corner is off the canvas: the pill moves to the canvas' corner
/// instead of leaving with it (issue #2267), on whichever axes the photo reaches past.
#[test]
fn before_label_badge_stays_in_the_pane_when_the_photo_reaches_past_it() {
let text = vec2(40.0, 14.0);
let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0));
let both = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, 0.0);
assert_eq!(both.min, pos2(108.0, 58.0));
assert!(pane.contains_rect(both));
let left_only = label_badge_rect(Rect::from_min_size(pos2(-900.0, 120.0), vec2(3000.0, 400.0)), pane, text, 0.0);
assert_eq!(left_only.min, pos2(108.0, 128.0));
assert!(pane.contains_rect(left_only));
}
/// With a filter active, the pill in the canvas' corner has that corner: the label sits below
/// it, whether the photo is past the pane or just under the pill. A photo lower down is left
/// alone. A wide label near the pane's right edge stays inside the pane.
#[test]
fn before_label_badge_keeps_clear_of_the_filter_pill_and_the_right_edge() {
let text = vec2(40.0, 14.0);
let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0));
let inset = super::FILTER_PILL_INSET + super::FILTER_PILL_HEIGHT;
let past = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, inset);
assert_eq!(past.min, pos2(108.0, 50.0 + inset + 8.0));
let under = label_badge_rect(Rect::from_min_size(pos2(200.0, 60.0), vec2(400.0, 300.0)), pane, text, inset);
assert_eq!(under.min.y, 50.0 + inset + 8.0, "a photo whose corner is under the pill moves down too");
let lower = label_badge_rect(Rect::from_min_size(pos2(200.0, 200.0), vec2(400.0, 300.0)), pane, text, inset);
assert_eq!(lower.min, pos2(208.0, 208.0));
let wide = label_badge_rect(Rect::from_min_size(pos2(860.0, 200.0), vec2(400.0, 300.0)), pane, text, 0.0);
assert!(pane.contains_rect(wide), "{wide:?}");
assert_eq!(wide.max.x, pane.right() - 8.0, "8 points from the pane's right edge, like the left inset");
}
#[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));
+51
View File
@@ -109,6 +109,57 @@ fn zoomed_before_after_views_stay_in_their_panes() {
}
}
/// The "Before" label (\ or the Before view) at 100 %: the photo's corner is off screen, and the
/// label used to leave with it (issue #2267). It now sits at the canvas' corner, in view.
#[test]
fn zoomed_in_the_before_label_stays_in_view() {
use crate::state::{BeforeAfter, Zoom};
let mut h = detail();
h.app.ui.before_after = BeforeAfter::Original;
let label = |h: &mut Headless| -> egui::Rect {
let w = h.request("ui.widgets", json!({"filter": "canvas:beforeLabel"}), T);
let r = w["result"][0]["rect"].as_array().unwrap_or_else(|| panic!("the Before label is registered: {w}"));
let v: Vec<f32> = r.iter().filter_map(|x| x.as_f64()).map(|x| x as f32).collect();
egui::Rect::from_min_size(egui::pos2(v[0], v[1]), egui::vec2(v[2], v[3]))
};
h.app.ui.zoom = Zoom::Fit;
h.settle(SETTLE);
let (img, canvas) = (h.app.image_rect.expect("photo on screen"), h.app.canvas_rect.expect("canvas"));
let fitted = label(&mut h);
assert_eq!(fitted.min, img.min + egui::vec2(8.0, 8.0), "at Fit the label sits on the photo's corner");
h.app.ui.zoom = Zoom::Percent(100.0);
h.app.ui.pan = (0.3, 0.4);
h.settle(SETTLE);
let img = h.app.image_rect.expect("photo on screen");
assert!(img.min.x < canvas.min.x && img.min.y < canvas.min.y, "100 % reaches past the canvas ({img:?} in {canvas:?})");
let zoomed = label(&mut h);
assert!(canvas.contains_rect(zoomed), "the label stays in view: {zoomed:?} in {canvas:?}");
assert_eq!(zoomed.min, canvas.min + egui::vec2(8.0, 8.0));
}
/// With the info overlay on, zoomed in, the Before label sits below the overlay's lines instead
/// of under them (both live in the canvas' top-left corner).
#[test]
fn zoomed_in_the_before_label_keeps_clear_of_the_info_overlay() {
use crate::state::{BeforeAfter, InfoOverlay, Zoom};
let mut h = detail();
h.app.ui.before_after = BeforeAfter::Original;
h.app.ui.info_overlay = InfoOverlay::Exposure;
h.app.ui.zoom = Zoom::Percent(100.0);
h.app.ui.pan = (0.3, 0.4);
h.settle(SETTLE);
let rect = |h: &mut Headless, id: &str| -> egui::Rect {
let w = h.request("ui.widgets", json!({"filter": id}), T);
let r = w["result"][0]["rect"].as_array().unwrap_or_else(|| panic!("{id} is registered: {w}"));
let v: Vec<f32> = r.iter().filter_map(|x| x.as_f64()).map(|x| x as f32).collect();
egui::Rect::from_min_size(egui::pos2(v[0], v[1]), egui::vec2(v[2], v[3]))
};
let (badge, overlay) = (rect(&mut h, "canvas:beforeLabel"), rect(&mut h, "canvas:infoOverlay"));
let canvas = h.app.canvas_rect.expect("canvas");
assert!(badge.top() >= overlay.bottom(), "the label {badge:?} is below the overlay {overlay:?}");
assert!(canvas.contains_rect(badge), "and still in view: {badge:?} in {canvas:?}");
}
/// A crop of no size (or a hair wide), from a sidecar, gave the loupe a NaN aspect:
/// the photo was laid out at a NaN size, which egui's debug checks stop on (a panic in a debug
/// build) and nothing could be drawn or clicked. It now lays out at a finite size, fitted and at
+4
View File
@@ -2,6 +2,10 @@
## 2026-10-19
### The Before label stays in view when zoomed in
- Zoomed in, the "Before" label (\ key or the Before view) now sits at the canvas' corner instead of leaving the
screen with the photo's corner (issue #2267).
### Synchronize Folder on a folder that is up to date
- When the scan finds nothing to import, relink, remove or read, the Synchronize Folder dialog says the library is up
to date with the folder and has one Close button. It used to show four greyed choices, two of them still ticked,