LRC-DEV-VIEWOPTIONS: the info overlay can show briefly when the photo changes

View Options had no Show Briefly When Photo Changes. A tick box under
Loupe Info 1 and one under Loupe Info 2 now choose the Info that shows
for 3 seconds each time the loupe changes to another photo while the
info overlay is off. Ticking one unticks the other. An overlay that is
on stays on and shows its own Info. view.viewOptions takes
briefly (off, basic or exposure), and the choice is saved with the app
settings; a settings file without it reads as off.
This commit is contained in:
dotCooCoo
2026-10-24 03:23:50 -07:00
parent 23f54803e6
commit 758c5e170a
7 changed files with 153 additions and 7 deletions
+1
View File
@@ -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."
}
+47
View File
@@ -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]
+9 -1
View File
@@ -742,7 +742,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(json!({"infoOverlay": app.ui.info_overlay}))
}
"view.viewOptions" => {
// `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<Re
}
set = true;
}
if let Some(v) = p.get("briefly") {
match serde_json::from_value(v.clone()) {
Ok(v) => 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 {
+56 -1
View File
@@ -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<Vec<String>> {
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<lightcraft_catalog::Photo> = 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<String> {
let ctx = egui::Context::default();
+8 -2
View File
@@ -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 };
}
}
}
+31 -2
View File
@@ -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<lightcraft_catalog::PhotoId>,
/// 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<Box<crate::panels::slideshow::Run>>,
/// 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());
+1 -1
View File
@@ -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 | ⬜ | | |