LRC-DEV-VIEWOPTIONS: View Options (Cmd+J) chooses the lines of the loupe's Info 1 and Info 2

This commit is contained in:
Marco Arnulfo
2026-10-18 19:41:59 +02:00
parent 31a677fe3d
commit 0c932b6958
9 changed files with 298 additions and 38 deletions
+11 -1
View File
@@ -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."
}
+28
View File
@@ -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]
+2
View File
@@ -178,6 +178,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
"---",
"compare.swap",
"compare.makeSelect",
"---",
"view.viewOptions",
],
),
(
+22
View File
@@ -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<Re
app.toast(&ctx, crate::i18n::tr(label));
Ok(json!({"infoOverlay": app.ui.info_overlay}))
}
"view.viewOptions" => {
// `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(),
+81 -30
View File
@@ -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<String> = 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::<Vec<_>>().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<String> = 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<String> {
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::<Vec<_>>().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<String> {
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::<String>::new(), "nothing chosen, nothing shown");
}
}
+6 -1
View File
@@ -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<serde_jso
Dialog::DeleteEffects { names } => 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
)
}
+42 -1
View File
@@ -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
+101
View File
@@ -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");
}
}
+5 -5
View File
@@ -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.
<!-- /parity:summary -->
## 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:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) 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 | ⬜ | | |