diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index b95d7b056..ef3683eb1 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -988,7 +988,7 @@ fn label_badge_rect(photo: Rect, pane: Rect, text: egui::Vec2, top_inset: f32) - 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; + let mut y = canvas.top() + info_overlay_top(app, &t); 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) @@ -999,10 +999,12 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li p.galley(at, g.clone(), Color32::WHITE); y += g.size().y + INFO_OVERLAY_GAP; } + y = overlay_badges(p, &t, pos2(canvas.left() + 14.0, y), photo); 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. +/// Where the info overlay's text starts below the canvas' top (without window controls over it), +/// and the gap after each line. const INFO_OVERLAY_TOP: f32 = 12.0; const INFO_OVERLAY_GAP: f32 = 3.0; @@ -1011,14 +1013,22 @@ 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. +/// How far below the canvas' top the info overlay's text and badges reach (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)| { + let text = lines.iter().enumerate().fold(info_overlay_top(app, &t), |y, (i, l)| { y + p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER).size().y + INFO_OVERLAY_GAP - }) + }); + text + if has_overlay_badges(photo) { OVERLAY_BADGES_H } else { 0.0 } +} + +/// Where the info overlay's text starts below the canvas' top: the full-screen preview has no top +/// bar, so on macOS it starts below the window controls that sit over the photo there (#2460). +fn info_overlay_top(app: &LightcraftApp, t: &Tokens) -> f32 { + let controls = app.ui.fullscreen && app.integrated_titlebar && !app.window_is_fullscreen; + if controls { t.top_bar_h } else { INFO_OVERLAY_TOP } } /// The info overlay's lines for `photo`, or `None` when it is off. @@ -1036,6 +1046,45 @@ fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Some(lines) } +/// The height of the info overlay's rating and flag row. +const OVERLAY_BADGES_H: f32 = 17.0; + +fn has_overlay_badges(photo: &lightcraft_catalog::Photo) -> bool { + photo.rating > 0 || photo.flag != lightcraft_catalog::Flag::None +} + +/// The star rating and flag under the info overlay's lines, when the photo has either; returns +/// the y below them. +fn overlay_badges(p: &egui::Painter, t: &Tokens, at: Pos2, photo: &lightcraft_catalog::Photo) -> f32 { + use crate::icons::{Icon, paint}; + use lightcraft_catalog::Flag; + let flag = match photo.flag { + Flag::Pick => Some((Icon::FlagPickFilled, Color32::WHITE)), + Flag::Reject => Some((Icon::FlagReject, t.reject)), + Flag::None => None, + }; + if !has_overlay_badges(photo) { + return at.y; + } + let stars = photo.rating.min(5); + let mut icons: Vec<(Icon, Color32)> = vec![(Icon::StarFilled, Color32::WHITE); stars as usize]; + icons.extend(flag); + let side = 12.0; + let mut x = at.x; + for (i, (icon, color)) in icons.into_iter().enumerate() { + // the flag stands a little apart from the stars + if i > 0 && i == stars as usize { + x += 5.0; + } + let r = Rect::from_min_size(pos2(x, at.y + 1.0), vec2(side, side)); + paint(p, r.translate(vec2(1.0, 1.0)), icon, Color32::from_black_alpha(200)); + paint(p, r, icon, color); + x += side + 2.0; + } + register(p.ctx(), "canvas:infoOverlayBadges", Rect::from_min_max(at, pos2(x, at.y + side + 2.0))); + at.y + OVERLAY_BADGES_H +} + /// What the user did to a face box this frame. enum RegionEdit { /// The × was clicked. @@ -3462,4 +3511,83 @@ mod info_overlay_tests { app.ui.info_overlay = InfoOverlay::Basic; assert_eq!(painted_lines(&app, &photo), Vec::::new(), "nothing chosen, nothing shown"); } + + fn demo_app() -> (LightcraftApp, lightcraft_catalog::Photo) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + app.ui.info_overlay = crate::state::InfoOverlay::Basic; + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + (app, photo) + } + + /// The overlay's text shapes and the rects it registered, painted on a canvas at the window's corner. + fn paint_overlay(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> (Vec, Vec<(String, Rect)>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo)); + out.textures_delta.clear(); + fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { + match shape { + egui::epaint::Shape::Text(t) => out.push(t.clone()), + egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut all = Vec::new(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); + (all, crate::widgets::take_registry(&ctx)) + } + + fn name_top(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> f32 { + let (texts, _) = paint_overlay(app, photo); + texts.iter().filter(|t| t.galley.job.text == photo.file_name).map(|t| t.pos.y).fold(f32::INFINITY, f32::min) + } + + /// #2460: in the full-screen preview on macOS the window controls stay at the top-left, so the + /// file name starts below them; in macOS full screen they are gone and it keeps the corner. + #[test] + fn the_file_name_clears_the_macos_window_controls_in_the_full_screen_preview() { + let (mut app, photo) = demo_app(); + app.ui.fullscreen = true; + assert_eq!(name_top(&app, &photo), 12.0, "no window controls over the photo"); + app.integrated_titlebar = true; + let bar = Tokens::get(&egui::Context::default()).top_bar_h; + assert!(name_top(&app, &photo) >= bar, "below the window controls"); + app.window_is_fullscreen = true; + assert_eq!(name_top(&app, &photo), 12.0, "macOS full screen hides the window controls"); + app.window_is_fullscreen = false; + app.ui.fullscreen = false; + assert_eq!(name_top(&app, &photo), 12.0, "the loupe's canvas starts below the top bar"); + } + + /// #2460: a rated or flagged photo shows its stars and flag under the date and size. + #[test] + fn the_info_overlay_shows_the_rating_and_flag_under_the_date() { + let (_, mut photo) = demo_app(); + let (app, _) = demo_app(); + photo.rating = 0; + photo.flag = lightcraft_catalog::Flag::None; + let badges = |photo: &lightcraft_catalog::Photo| { + paint_overlay(&app, photo).1.into_iter().find(|(id, _)| id == "canvas:infoOverlayBadges").map(|(_, r)| r) + }; + assert_eq!(badges(&photo), None, "nothing to show"); + photo.rating = 3; + let stars = badges(&photo).expect("three stars"); + photo.flag = lightcraft_catalog::Flag::Pick; + let flagged = badges(&photo).expect("three stars and a flag"); + assert!(flagged.width() > stars.width(), "the flag follows the stars: {stars:?} {flagged:?}"); + let (texts, _) = paint_overlay(&app, &photo); + let date_bottom = texts.iter().map(|t| t.pos.y + t.galley.size().y).fold(0.0, f32::max); + assert!(flagged.top() >= date_bottom, "under the date and size: {flagged:?}, text ends at {date_bottom}"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut extent = 0.0; + ctx.run_ui(raw, |ui| extent = info_overlay_extent(&app, ui.painter(), &photo)).textures_delta.clear(); + assert!(extent >= flagged.bottom(), "a label below the overlay clears the badges: {extent} < {flagged:?}"); + photo.rating = 0; + photo.flag = lightcraft_catalog::Flag::Reject; + assert!(badges(&photo).is_some(), "a rejected photo shows its flag alone"); + } } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 99f17561e..8307ef1f0 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -135,7 +135,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys, the bottom bar and the Info panel's stars and colour labels act on the active photo, each tile's caption shows its colour label, hover × removes | -| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay`, `cmd:view.viewOptions` | off / Info 1 (by default file name, capture date, size) / Info 2 (file name, exposure, camera + lens), each three lines chosen in View Options; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | +| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay`, `cmd:view.viewOptions`, `crates/ui-egui/src/panels/detail.rs` (`info_overlay`) | off / Info 1 (by default file name, capture date, size) / Info 2 (file name, exposure, camera + lens), each three lines chosen in View Options; ⌘I cycles (I in full screen; elsewhere I stays the Info panel); the star rating and flag show under the lines when the photo has them, and in the full-screen preview on macOS the overlay starts below the window controls (issue #2460) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | @@ -798,6 +798,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | LR-VIEW-INFOOVERLAY: the info overlay shows the star rating and flag, and in the full-screen preview on macOS it starts below the window controls (issue #2460); status unchanged | | 2026-10-21 | patch | LR-IMP-ADD-DIALOG: reconcile #2362 with current main; deleted-album guard and hierarchical picker retained; counts unchanged | | 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged | | 2026-10-21 | patch | KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the slider's row; leaving it (even between two frames) or Tab focus alone does not arm them (issue #1355); status unchanged |