diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 759fe53d0..4726742de 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -2344,6 +2344,7 @@ "Loupe Info 1": "Info lente 1", "Loupe Info 2": "Info lente 2", "Use Defaults": "Usa predefiniti", + "Show briefly when photo changes": "Mostra brevemente al cambio di foto", "Camera & Lens": "Fotocamera e obiettivo", "View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2." } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index c33ba0998..b219138b3 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -4608,6 +4608,53 @@ mod tests { assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName], "a refused change changes nothing"); } + /// View Options' Show Briefly When Photo Changes: a tick box under each Info (ticking one + /// unticks the other) and `view.viewOptions {briefly}`; with the overlay off, the loupe shows + /// the chosen Info after → changes the photo, and hides it again a few seconds later. + #[test] + fn show_briefly_when_photo_changes_in_view_options() { + use crate::state::{BRIEF_INFO_SECS, InfoOverlay}; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + assert_eq!(h.request("ui.key", json!({"key": "j", "cmd": true}), t)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly2"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Exposure); + assert_eq!(h.request("ui.clickWidget", json!({"id": "check:viewOptions.briefly1"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Basic, "one Info at most"); + h.app.ui.dialog = None; + h.step(); + + let overlay = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay"); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off); + h.time += BRIEF_INFO_SECS + 1.0; + h.step(); + h.step(); + assert!(!overlay(&h), "hidden once the time has passed"); + let before = h.app.session.active(); + h.request("ui.key", json!({"key": "right"}), t); + assert_ne!(h.app.session.active(), before); + h.step(); + assert!(overlay(&h), "shown after → changes the photo"); + h.time += BRIEF_INFO_SECS + 1.0; + h.step(); + h.step(); + assert!(!overlay(&h), "and hidden again"); + + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "off"}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["loupeInfo"]["briefly"], "off", "{r}"); + h.request("ui.key", json!({"key": "right"}), t); + h.step(); + assert!(!overlay(&h), "off: nothing shows when the photo changes"); + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"briefly": "info3"}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.briefly, InfoOverlay::Off, "a refused change changes nothing"); + } + /// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the /// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 747cfbccb..7641272b9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -742,7 +742,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // `info1` / `info2` set the lines for an agent; without them the window opens + // `info1` / `info2` set the lines, and `briefly` sets the Info that Show Briefly When Photo + // Changes shows, for an agent; without them the window opens let mut info = app.ui.settings.loupe_info; let mut set = false; for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { @@ -755,6 +756,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option info.briefly = v, + Err(_) => return Some(Err("`briefly`: off|basic|exposure (neither, Info 1 or Info 2)".into())), + } + set = true; + } if set { app.ui.settings.loupe_info = info; } else { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 082508d08..20a39a2fa 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -340,6 +340,25 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { r } +/// View Options' Show Briefly When Photo Changes: note when the loupe changes to photo `id`, and +/// whether the brief info overlay shows this frame (until [`BRIEF_INFO_SECS`] have passed; a +/// repaint is asked for then, so it hides without input). +/// +/// [`BRIEF_INFO_SECS`]: crate::state::BRIEF_INFO_SECS +fn brief_info(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { + let now = ctx.input(|i| i.time); + let brief = &mut app.ui.brief_info; + if brief.photo != Some(id) { + brief.photo = Some(id); + brief.since = now; + } + let left = crate::state::BRIEF_INFO_SECS - (now - brief.since); + brief.shown = app.ui.settings.loupe_info.briefly != crate::state::InfoOverlay::Off && left > 0.0; + if brief.shown { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); + } +} + /// Whether an overlay can be rendered on a region tile without changing what the tile means. /// Mask overlays (one mask, or every visible mask at once) are evaluated from the same mask /// alphas as the full render; the other diagnostic overlays either need whole-frame context or @@ -475,6 +494,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { return; }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; + brief_info(app, ui.ctx(), id); let d = (*photo.develop).clone(); // the full-screen preview shows the photo only: no tool overlays let right = if fullscreen { RightPanel::None } else { app.ui.right }; @@ -1024,7 +1044,10 @@ fn info_overlay_extent(app: &LightcraftApp, p: &egui::Painter, photo: &lightcraf /// The info overlay's lines for `photo`, or `None` when it is off. fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Option> { use crate::state::InfoOverlay; - let fields = match app.ui.info_overlay { + // with the overlay off, Show Briefly When Photo Changes shows its Info for a moment + let brief = app.ui.info_overlay == InfoOverlay::Off && app.ui.brief_info.shown; + let mode = if brief { app.ui.settings.loupe_info.briefly } else { app.ui.info_overlay }; + let fields = match mode { InfoOverlay::Off => return None, InfoOverlay::Basic => &app.ui.settings.loupe_info.info1, InfoOverlay::Exposure => &app.ui.settings.loupe_info.info2, @@ -3435,6 +3458,38 @@ mod info_overlay_tests { assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]); } + /// View Options' Show Briefly When Photo Changes: with the overlay off, the chosen Info shows + /// for `BRIEF_INFO_SECS` after the loupe changes photo, then hides; an overlay that is on stays. + #[test] + fn show_briefly_shows_the_info_after_the_photo_changes() { + use crate::state::{BRIEF_INFO_SECS, InfoOverlay}; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + let photos: Vec = app.session.catalog.photos().take(2).map(|p| (**p).clone()).collect(); + assert_eq!(photos.len(), 2, "the demo catalog has two photos"); + let ctx = egui::Context::default(); + // the loupe shows photo `i` at `time`: the lines the overlay paints then + let at = |app: &mut LightcraftApp, i: usize, time: f64| { + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, time, vec![]); + let mut out = ctx.run_ui(raw, |ui| brief_info(app, ui.ctx(), photos[i].id)); + out.textures_delta.clear(); + painted_lines(app, &photos[i]) + }; + let name = |i: usize| photos[i].file_name.clone(); + assert!(at(&mut app, 0, 1.0).is_empty(), "off by default"); + app.ui.settings.loupe_info.briefly = InfoOverlay::Exposure; + let info2 = at(&mut app, 1, 2.0); + assert_eq!(info2.first(), Some(&name(1)), "Info 2 shows when the photo changes: {info2:?}"); + assert!(!at(&mut app, 1, 2.0 + BRIEF_INFO_SECS - 0.5).is_empty(), "still up"); + assert!(at(&mut app, 1, 2.0 + BRIEF_INFO_SECS + 0.1).is_empty(), "then hidden"); + assert_eq!(at(&mut app, 0, 10.0).first(), Some(&name(0)), "and shown again for the next photo"); + app.ui.settings.loupe_info.briefly = InfoOverlay::Basic; + let info1 = at(&mut app, 1, 20.0); + assert_eq!(info1.len(), 3, "Info 1's three lines: {info1:?}"); + assert_ne!(info1, info2, "Info 1, not Info 2"); + app.ui.info_overlay = InfoOverlay::Exposure; + assert_eq!(at(&mut app, 1, 30.0), info2, "an overlay that is on shows its own Info, and stays"); + } + /// The lines the overlay paints, top to bottom (each once: the shadow copies left out). fn painted_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Vec { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2f844f4ae..b539358ff 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -691,8 +691,8 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } /// View ▸ View Options (⌘J), Lightroom Classic's Loupe View tab: whether the info overlay shows and -/// which, and the three lines each of Info 1 and Info 2 shows (`view.viewOptions`). Changes apply -/// as they are made. +/// which, the three lines each of Info 1 and Info 2 shows, and which of them shows briefly when the +/// photo changes (`view.viewOptions`). Changes apply as they are made. pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { use crate::state::{InfoOverlay as I, LoupeInfo, LoupeInfoField as F}; heading(ui, t, crate::i18n::tr("Loupe View")); @@ -727,6 +727,12 @@ pub(super) fn view_options(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Token if r.clicked() { *lines = defaults; } + // one Info at most shows briefly: ticking one unticks the other + let info = if n == 1 { I::Basic } else { I::Exposure }; + let mut briefly = app.ui.settings.loupe_info.briefly == info; + if check(ui, &format!("viewOptions.briefly{n}"), &mut briefly, "Show briefly when photo changes") { + app.ui.settings.loupe_info.briefly = if briefly { info } else { I::Off }; + } } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8cb37d3ef..11462a155 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -247,12 +247,31 @@ impl LoupeInfoField { } /// The three lines each mode of the info overlay shows: Info 1 ([`InfoOverlay::Basic`]) and -/// Info 2 ([`InfoOverlay::Exposure`]). +/// Info 2 ([`InfoOverlay::Exposure`]), and which of them Show Briefly When Photo Changes shows. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct LoupeInfo { pub info1: [LoupeInfoField; 3], pub info2: [LoupeInfoField; 3], + /// Show Briefly When Photo Changes: while the info overlay is off, this Info shows for + /// [`BRIEF_INFO_SECS`] each time the loupe changes to another photo ([`InfoOverlay::Off`]: + /// neither). + pub briefly: InfoOverlay, +} + +/// How long Show Briefly When Photo Changes shows the info overlay (seconds). +pub const BRIEF_INFO_SECS: f64 = 3.0; + +/// When the loupe last changed photo, for Show Briefly When Photo Changes +/// ([`LoupeInfo::briefly`]). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct BriefInfo { + /// The photo the loupe showed last. + pub photo: Option, + /// When the loupe changed to it (seconds on egui's clock). + pub since: f64, + /// The brief info overlay shows this frame. + pub shown: bool, } impl LoupeInfo { @@ -264,7 +283,7 @@ impl LoupeInfo { impl Default for LoupeInfo { fn default() -> Self { - LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2 } + LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2, briefly: InfoOverlay::Off } } } @@ -967,6 +986,9 @@ pub struct UiState { pub slideshow: Option>, /// Info overlay on the loupe. pub info_overlay: InfoOverlay, + /// When the loupe last changed photo (View Options' Show Briefly When Photo Changes). + #[serde(skip)] + pub brief_info: BriefInfo, /// The loupe's layout image overlay (View ▸ Loupe Overlay). pub layout_image: LayoutImage, /// Navigator mini map in the loupe while zoomed in. @@ -1472,6 +1494,7 @@ impl Default for UiState { keyword_list_selected: None, dragging_keyword: None, info_overlay: InfoOverlay::Off, + brief_info: BriefInfo::default(), layout_image: LayoutImage::default(), navigator: true, settings: AppSettings::default(), @@ -1619,13 +1642,19 @@ mod loupe_info_tests { fn view_options_lines_are_saved_with_the_settings() { let mut settings = AppSettings::default(); settings.loupe_info.info2 = [LoupeInfoField::Lens, LoupeInfoField::None, LoupeInfoField::FileName]; + settings.loupe_info.briefly = InfoOverlay::Exposure; let saved = serde_json::to_value(&settings).unwrap(); assert_eq!(saved["loupeInfo"]["info2"], serde_json::json!(["lens", "none", "fileName"])); + assert_eq!(saved["loupeInfo"]["briefly"], "exposure"); let read: AppSettings = serde_json::from_value(saved).unwrap(); assert_eq!(read.loupe_info, settings.loupe_info); let old: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false})).unwrap(); assert_eq!(old.loupe_info, LoupeInfo::default()); + // lines saved before Show Briefly When Photo Changes existed are kept, with it off + let lines: AppSettings = serde_json::from_value(serde_json::json!({"loupeInfo": {"info2": ["lens", "none", "fileName"]}})).unwrap(); + assert_eq!(lines.loupe_info.info2, settings.loupe_info.info2); + assert_eq!(lines.loupe_info.briefly, InfoOverlay::Off); let newer: AppSettings = serde_json::from_value(serde_json::json!({"filmNames": false, "loupeInfo": {"info1": ["rating", "fileName", "none"]}})).unwrap(); assert_eq!(newer.loupe_info, LoupeInfo::default()); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9790523e1..ed14924fa 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -730,7 +730,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-DEV-TAT | Targeted adjustment tools | P1 | ✅ | `cmd:develop.targeted` (`target`: curve / hue / sat / lum) | | | LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ✅ | `cmd:library.preferences` (`camera`, `import.perCamera`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import: raw default and per-camera presets | | LRC-DEV-PROCESS | Process versions, Update to Current Process | P1 | ✅ | `cmd:develop.updateProcess`, `cmd:develop.reset`, `cmd:develop.get`, `crates/develop/src/settings.rs`, `crates/pipeline/src/finish.rs`, `docs/process-versions.md` | each photo records the rendering process its settings are interpreted by: settings saved before this existed load as V1, new photos and Reset get the latest, virtual copies inherit, copy / paste / sync / presets keep each photo's own; Photo ▸ Update to Current Process (one undo step, enabled while a selected photo is on an older process); a number from a newer LightCraft is kept and renders with the newest known process. Only V1 exists so far: this is what lets a retuned default tone (#146) or stronger Light sliders (#196) ship without changing existing edits | -| LRC-DEV-VIEWOPTIONS | Develop view options | P2 | 🟡 | `cmd:view.viewOptions`, `crates/ui-egui/src/panels/settings.rs` (`view_options`) | View ▸ View Options… (⌘J), the Loupe View part: Show Info Overlay with Info 1 / Info 2, and the three lines of each (none, file name, capture date, dimensions, exposure, camera, lens, camera + lens) with Use Defaults; applied as they change, saved with the app settings; `view.viewOptions {info1, info2}` sets them for agents. Not yet: the Grid View tab, more fields (ratings, metadata, copy name), "show briefly when the photo changes", the loading-message and video-frame options | +| LRC-DEV-VIEWOPTIONS | Develop view options | P2 | 🟡 | `cmd:view.viewOptions`, `crates/ui-egui/src/panels/settings.rs` (`view_options`) | View ▸ View Options… (⌘J), the Loupe View part: Show Info Overlay with Info 1 / Info 2, and the three lines of each (none, file name, capture date, dimensions, exposure, camera, lens, camera + lens) with Use Defaults; Show Briefly When Photo Changes under each Info (one at most): with the overlay off, that Info shows for 3 s each time the loupe changes photo; applied as they change, saved with the app settings; `view.viewOptions {info1, info2, briefly}` sets them for agents. Not yet: the Grid View tab, more fields (ratings, metadata, copy name), the loading-message and video-frame options | | LRC-DEV-LOUPEOVERLAY | Loupe overlay: layout image | P2 | 🟡 | `cmd:view.chooseLayoutImage`, `cmd:view.layoutImage`, `cmd:view.layoutImageOpacity`, `cmd:view.layoutImageMatte`, `crates/ui-egui/src/panels/layout_overlay.rs` | View ▸ Loupe Overlay: a PNG with transparency (a cover layout, a banner's safe areas) fitted and centred over the loupe photo, following zoom and pan; opacity and matte (the photo darkened outside the graphic's box) in steps; ⌥⌘O shows / hides it; read off the UI thread; saved with the UI state. Not yet: the Grid and Guides overlays, moving and resizing the layout with ⌘ held, free opacity / matte sliders | | LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | | LRC-MAP-VIEW | Map view | P2 | ⬜ | | |