From 0c932b6958e4acf368844ccbcf4ca18fef995e4d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 02:18:17 +0200 Subject: [PATCH] LRC-DEV-VIEWOPTIONS: View Options (Cmd+J) chooses the lines of the loupe's Info 1 and Info 2 --- crates/ui-egui/locales/it.json | 12 ++- crates/ui-egui/src/headless.rs | 28 +++++++ crates/ui-egui/src/menubar.rs | 2 + crates/ui-egui/src/menus.rs | 22 +++++ crates/ui-egui/src/panels/detail.rs | 111 +++++++++++++++++++------- crates/ui-egui/src/panels/dialogs.rs | 7 +- crates/ui-egui/src/panels/settings.rs | 43 +++++++++- crates/ui-egui/src/state.rs | 101 +++++++++++++++++++++++ docs/parity-checklist.md | 10 +-- 9 files changed, 298 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 382fb80c9..f74a4b7ba 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -1990,5 +1990,15 @@ "Test Face Model": "Testa il modello dei volti", "This build cannot run recognition models: they can be added and chosen, not used.": "Questa versione non può eseguire modelli di riconoscimento: si possono aggiungere e scegliere, non usare.", "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli dei volti, quindi l'elenco dei modelli non è disponibile. (L'app desktop ce l'ha.)", - "Unnamed Faces": "Volti senza nome" + "Unnamed Faces": "Volti senza nome", + "View Options…": "Opzioni di visualizzazione…", + "View Options": "Opzioni di visualizzazione", + "Loupe View": "Vista lente", + "Show Info Overlay": "Mostra sovrapposizione informazioni", + "Loupe Info 1": "Info lente 1", + "Loupe Info 2": "Info lente 2", + "Use Defaults": "Usa predefiniti", + "Dimensions": "Dimensioni", + "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 1d8b58401..fc04b9c3e 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -4343,6 +4343,34 @@ mod tests { } } + /// ⌘J opens View ▸ View Options; its menus choose Info 1's and Info 2's lines, Use Defaults puts + /// them back, and `view.viewOptions` sets them for an agent (refusing a line it doesn't know). + #[test] + fn view_options_choose_the_info_overlay_lines() { + use crate::state::{LoupeInfo, LoupeInfoField as F}; + 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); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ViewOptions)); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "combo:viewOptionsInfo1Line2"}), t)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsInfo1Line2-lens"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.info1, [F::FileName, F::Lens, F::Dimensions]); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:viewOptionsDefaults1"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.loupe_info.info1, LoupeInfo::INFO1); + + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["camera", "none", "fileName"]}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName]); + let r = h.request("engine.execute", json!({"command": "view.viewOptions", "params": {"info2": ["rating", "none", "none"]}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.ui.settings.loupe_info.info2, [F::Camera, F::None, F::FileName], "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/menubar.rs b/crates/ui-egui/src/menubar.rs index c5e4e45c7..30c2e3177 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -178,6 +178,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "compare.swap", "compare.makeSelect", + "---", + "view.viewOptions", ], ), ( diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 743bd4600..6234f950d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -129,6 +129,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), + ("view.viewOptions", "View Options…", Some("Cmd+J"), "View"), ("view.chooseLayoutImage", "Choose Layout Image…", None, "View"), ("view.layoutImage", "Show Layout Image", Some("Cmd+Alt+O"), "View"), ("view.layoutImageOpacity", "Layout Image Opacity", None, ""), @@ -708,6 +709,27 @@ 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 + let mut info = app.ui.settings.loupe_info; + let mut set = false; + for (key, lines) in [("info1", &mut info.info1), ("info2", &mut info.info2)] { + let Some(v) = p.get(key) else { continue }; + match serde_json::from_value(v.clone()) { + Ok(v) => *lines = v, + Err(_) => { + return Some(Err(format!("`{key}`: three of none|fileName|captureDate|dimensions|exposure|camera|lens|cameraAndLens"))); + } + } + set = true; + } + if set { + app.ui.settings.loupe_info = info; + } else { + app.ui.dialog = Some(Dialog::ViewOptions); + } + Ok(json!({"loupeInfo": app.ui.settings.loupe_info})) + } "view.chooseLayoutImage" => { let path = match p.get("path").and_then(Value::as_str) { Some(path) => path.trim().to_string(), diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 94462e6dc..32a6b74e5 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -910,40 +910,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } -/// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date -/// and size, or with the camera and exposure. +/// The info overlay at the canvas' top left (`view.infoOverlay`): the lines View Options chose for +/// Info 1 or Info 2 (`view.viewOptions`). fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { use crate::state::InfoOverlay; - let lines: Vec = match app.ui.info_overlay { + let fields = match app.ui.info_overlay { InfoOverlay::Off => return, - 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)] - } - InfoOverlay::Exposure => { - let m = &photo.meta; - let mut exp = Vec::new(); - if !m.shutter.is_empty() { - exp.push(format!("{} s", m.shutter)); - } - if let Some(a) = m.aperture { - exp.push(format!("f/{a:.1}")); - } - if let Some(i) = m.iso { - exp.push(format!("ISO {i}")); - } - if let Some(f) = m.focal_mm { - exp.push(format!("{f:.0} mm")); - } - let camera = [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join(" · "); - let mut v = vec![photo.file_name.clone()]; - v.push(if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") }); - if !camera.is_empty() { - v.push(camera); - } - v - } + InfoOverlay::Basic => &app.ui.settings.loupe_info.info1, + InfoOverlay::Exposure => &app.ui.settings.loupe_info.info2, }; + let lines: Vec = fields.iter().filter_map(|f| info_line(*f, photo)).collect(); + if lines.is_empty() { + return; + } let t = Tokens::get(p.ctx()); let mut y = canvas.top() + 12.0; for (i, l) in lines.iter().enumerate() { @@ -1181,6 +1160,39 @@ fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) { ui.interact(r, egui::Id::new("preview-only-pill"), Sense::hover()).on_hover_text(crate::widgets::preview_only_explanation(reason)); } +/// One line of the info overlay for `photo`; `None` when the field is None or the photo has +/// nothing to show for it. +fn info_line(field: crate::state::LoupeInfoField, photo: &lightcraft_catalog::Photo) -> Option { + use crate::state::LoupeInfoField as F; + let m = &photo.meta; + let text = match field { + F::None => return None, + F::FileName => photo.file_name.clone(), + F::CaptureDate => pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)), + F::Dimensions => format!("{} × {}", photo.width, photo.height), + F::Exposure => { + let mut exp = Vec::new(); + if !m.shutter.is_empty() { + exp.push(format!("{} s", m.shutter)); + } + if let Some(a) = m.aperture { + exp.push(format!("f/{a:.1}")); + } + if let Some(i) = m.iso { + exp.push(format!("ISO {i}")); + } + if let Some(f) = m.focal_mm { + exp.push(format!("{f:.0} mm")); + } + if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") } + } + F::Camera => m.camera.clone(), + F::Lens => m.lens.clone(), + F::CameraAndLens => [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join(" · "), + }; + (!text.is_empty()).then_some(text) +} + /// `2026-09-30T12:00:00` → `2026-09-30 12:00`. fn pretty_date(iso: &str) -> String { let d = iso.replacen('T', " ", 1); @@ -3111,4 +3123,43 @@ mod info_overlay_tests { assert_eq!(copies[1], Color32::WHITE, "the text on top"); assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]); } + + /// 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(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.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(); + let mut lines = Vec::new(); + for c in &out.shapes { + if let egui::epaint::Shape::Text(t) = &c.shape + && t.override_text_color.unwrap_or(t.fallback_color) == Color32::WHITE + { + lines.push(t.galley.job.text.clone()); + } + } + lines + } + + /// View ▸ View Options: Info 1 and Info 2 each show the three lines chosen for them, in that + /// order, without the lines set to None. + #[test] + fn the_info_overlay_shows_the_lines_chosen_in_view_options() { + use crate::state::{InfoOverlay, LoupeInfoField as F}; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + let size = format!("{} × {}", photo.width, photo.height); + let date = pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)); + app.ui.settings.loupe_info.info1 = [F::Dimensions, F::None, F::FileName]; + app.ui.settings.loupe_info.info2 = [F::CaptureDate, F::FileName, F::None]; + app.ui.info_overlay = InfoOverlay::Basic; + assert_eq!(painted_lines(&app, &photo), vec![size.clone(), photo.file_name.clone()], "Info 1"); + app.ui.info_overlay = InfoOverlay::Exposure; + assert_eq!(painted_lines(&app, &photo), vec![date, photo.file_name.clone()], "Info 2"); + app.ui.settings.loupe_info.info1 = [F::None; 3]; + app.ui.info_overlay = InfoOverlay::Basic; + assert_eq!(painted_lines(&app, &photo), Vec::::new(), "nothing chosen, nothing shown"); + } } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 5562e94d1..f31d4b1c5 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -273,6 +273,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::SamModel { .. } => "Download the SAM 3 Model?", Dialog::About => "About LightCraft", Dialog::Shortcuts => "Keyboard Shortcuts", + Dialog::ViewOptions => "View Options", } .to_string(); let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(16, 12)); @@ -1315,6 +1316,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), + Dialog::ViewOptions => crate::panels::settings::view_options(app, ui, &t), Dialog::Print => crate::print_view::body(app, ui, &t), }); ui.add_space(4.0); @@ -1935,7 +1937,9 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("mask.deleteEffect", json!({"names": names})), Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } => Ok(serde_json::Value::Null), + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } | Dialog::ViewOptions => { + Ok(serde_json::Value::Null) + } } } @@ -2315,5 +2319,6 @@ fn only_shows(dlg: &Dialog) -> bool { | Dialog::AllMetadata { .. } | Dialog::WhatsNew | Dialog::ManageEffects { .. } + | Dialog::ViewOptions ) } diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 682e2acb2..22d74b53c 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -685,8 +685,49 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); row(ui, t, crate::i18n::tr("Info overlay"), |ui| { use crate::state::InfoOverlay as I; - choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "File & date"), (I::Exposure, "Exposure")], &mut app.ui.info_overlay); + choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); }); + hint(ui, t, crate::i18n::tr("View → View Options chooses the lines Info 1 and Info 2 show.")); +} + +/// 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. +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")); + let mut show = app.ui.info_overlay != I::Off; + if check(ui, "viewOptions.showInfo", &mut show, "Show Info Overlay") { + app.ui.info_overlay = if show { I::Basic } else { I::Off }; + } + ui.add_enabled_ui(show, |ui| { + ui.horizontal(|ui| { + choices(ui, "viewOptionsInfo", &[(I::Basic, "Info 1"), (I::Exposure, "Info 2")], &mut app.ui.info_overlay); + }); + }); + for (n, title, defaults) in [(1, "Loupe Info 1", LoupeInfo::INFO1), (2, "Loupe Info 2", LoupeInfo::INFO2)] { + heading(ui, t, crate::i18n::tr(title)); + let lines = if n == 1 { &mut app.ui.settings.loupe_info.info1 } else { &mut app.ui.settings.loupe_info.info2 }; + for (i, line) in lines.iter_mut().enumerate() { + let id = format!("viewOptionsInfo{n}Line{}", i + 1); + let r = crate::widgets::combo_box(&id).width(220.0).selected_text(crate::i18n::tr(line.label())).show_ui(ui, |ui| { + for f in F::ALL { + let key = serde_json::to_value(f).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + let r = ui.selectable_label(line == f, crate::i18n::tr(f.label())); + register(ui.ctx(), format!("button:{id}-{key}"), r.rect); + if r.clicked() { + *line = *f; + } + } + }); + register(ui.ctx(), format!("combo:{id}"), r.response.rect); + } + let r = ui.add_enabled(*lines != defaults, egui::Button::new(crate::i18n::tr("Use Defaults"))); + register(ui.ctx(), format!("button:viewOptionsDefaults{n}"), r.rect); + if r.clicked() { + *lines = defaults; + } + } } // ----------------------------------------------------------------------------------- Slideshow diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f8c8cc0d4..03d760cb4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -199,6 +199,75 @@ impl InfoOverlay { } } +/// One line of the loupe's info overlay, chosen in View ▸ View Options (Lightroom Classic's Loupe +/// Info 1 / Loupe Info 2). A line with nothing to show for a photo is left out. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LoupeInfoField { + #[default] + None, + FileName, + /// The capture date and time (the import date when the photo has none). + CaptureDate, + /// Width × height in pixels. + Dimensions, + /// Shutter speed, aperture, ISO and focal length. + Exposure, + Camera, + Lens, + /// Camera and lens on one line. + CameraAndLens, +} + +impl LoupeInfoField { + /// Every field, in the order View Options lists them. + pub const ALL: &'static [LoupeInfoField] = &[ + LoupeInfoField::None, + LoupeInfoField::FileName, + LoupeInfoField::CaptureDate, + LoupeInfoField::Dimensions, + LoupeInfoField::Exposure, + LoupeInfoField::Camera, + LoupeInfoField::Lens, + LoupeInfoField::CameraAndLens, + ]; + + pub fn label(self) -> &'static str { + match self { + LoupeInfoField::None => "None", + LoupeInfoField::FileName => "File Name", + LoupeInfoField::CaptureDate => "Capture Date", + LoupeInfoField::Dimensions => "Dimensions", + LoupeInfoField::Exposure => "Exposure", + LoupeInfoField::Camera => "Camera", + LoupeInfoField::Lens => "Lens", + LoupeInfoField::CameraAndLens => "Camera & Lens", + } + } +} + +/// The three lines each mode of the info overlay shows: Info 1 ([`InfoOverlay::Basic`]) and +/// Info 2 ([`InfoOverlay::Exposure`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LoupeInfo { + pub info1: [LoupeInfoField; 3], + pub info2: [LoupeInfoField; 3], +} + +impl LoupeInfo { + /// Info 1's lines before they are changed (and after Use Defaults). + pub const INFO1: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::CaptureDate, LoupeInfoField::Dimensions]; + /// Info 2's lines before they are changed (and after Use Defaults). + pub const INFO2: [LoupeInfoField; 3] = [LoupeInfoField::FileName, LoupeInfoField::Exposure, LoupeInfoField::CameraAndLens]; +} + +impl Default for LoupeInfo { + fn default() -> Self { + LoupeInfo { info1: LoupeInfo::INFO1, info2: LoupeInfo::INFO2 } + } +} + /// View ▸ Loupe Overlay ▸ Layout Image (Lightroom Classic's loupe overlay): a graphic with /// transparency, such as a magazine cover's masthead and cover lines, drawn over the photo in the /// loupe (`panels::layout_overlay`). Saved with the UI state, so the layout stays chosen across @@ -391,6 +460,9 @@ pub struct AppSettings { /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point /// when the photo has one (`view.zoomToFocus`). pub zoom_to_focus: bool, + /// View ▸ View Options: the lines the loupe's info overlay shows in Info 1 and Info 2. + #[serde(deserialize_with = "lenient")] + pub loupe_info: LoupeInfo, } impl Default for AppSettings { @@ -420,6 +492,7 @@ impl Default for AppSettings { keyword_chips: KeywordChips::default(), focus_point: false, zoom_to_focus: true, + loupe_info: LoupeInfo::default(), } } } @@ -1238,6 +1311,9 @@ pub enum Dialog { }, About, Shortcuts, + /// View ▸ View Options (⌘J): the info overlay and the lines of Info 1 and Info 2. Changes apply + /// as they are made. + ViewOptions, } fn yes() -> bool { @@ -1508,3 +1584,28 @@ fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result } Ok(values) } + +#[cfg(test)] +mod loupe_info_tests { + use super::*; + + /// The lines chosen in View Options are saved with the app's settings; a settings file from + /// before they existed, or with a line this build doesn't know, gets the default lines and + /// keeps the rest of its settings. + #[test] + fn view_options_lines_are_saved_with_the_settings() { + let mut settings = AppSettings::default(); + settings.loupe_info.info2 = [LoupeInfoField::Lens, LoupeInfoField::None, LoupeInfoField::FileName]; + let saved = serde_json::to_value(&settings).unwrap(); + assert_eq!(saved["loupeInfo"]["info2"], serde_json::json!(["lens", "none", "fileName"])); + 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()); + 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()); + assert!(!newer.film_names, "the rest of the settings are still read"); + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4e0531793..c5f4d80cb 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | -| Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | -| **Total** | 419 | 61 | 59 | 35 | 194/201 (97%) | 148/161 (92%) | +| Lightroom Classic extras | 40 | 24 | 22 | 8 | — | 24/26 (92%) | +| **Total** | 419 | 62 | 58 | 35 | 194/201 (97%) | 148/161 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.4%** of 539 in-scope rows — P0 98.3% of 201 · P1 95.0% of 161 · P2 55.7% of 176. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.5%** of 539 in-scope rows — P0 98.3% of 201 · P1 95.0% of 161 · P2 56.0% of 176. ## Top gaps and shortcut notes @@ -137,7 +137,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 act on the active photo, hover × removes | -| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘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` | 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-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` | | @@ -729,7 +729,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 | ⬜ | | | +| 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-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 | ⬜ | | |