mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user