Merge PR #1173: LRC-LIB-REFVIEW: fix Escape and catalog-local reference lifecycle

docs/parity-checklist.md: header and revision rows from both sides
This commit is contained in:
aiongg committed 2026-10-22 02:39:58 +00:00
commit d0583f77ee
9 files changed
+217 -22

No files matched your search

+25 -9
View File
@@ -588,12 +588,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"view.reference" => {
// the reference: the one set before, else the active photo (the next one becomes active)
let vis = app.session.visible_cloned();
let reference = app
.ui
.reference
.filter(|r| app.session.catalog.photo(lightcraft_catalog::PhotoId(*r)).is_some())
.or_else(|| app.session.active().map(|a| a.0));
let Some(r) = reference else { return Some(Err("select a photo to use as the reference".into())) };
let live = |id: u64| crate::panels::compare::live(app, lightcraft_catalog::PhotoId(id));
let active = app.session.active().map(|a| a.0);
let reference = app.ui.reference.filter(|r| live(*r)).or_else(|| active.filter(|a| live(*a)));
let Some(r) = reference else {
return Some(Err(match active {
Some(a) => format!("photo {a} is in Recently Deleted: restore it to use it as the reference"),
None => "select a photo to use as the reference".into(),
}));
};
app.ui.reference = Some(r);
if app.session.active().is_none_or(|a| a.0 == r)
&& let Some(i) = vis.iter().position(|x| x.0 == r)
@@ -605,8 +608,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(json!({"reference": r, "active": app.session.active().map(|a| a.0)}))
}
"photo.setReference" => {
let id = p.get("id").and_then(Value::as_u64).or_else(|| app.session.active().map(|a| a.0));
app.ui.reference = id;
// only an absent (or null) `id` means the active photo; any other non-id is refused
let id = match p.get("id").filter(|v| !v.is_null()) {
Some(v) => match v.as_u64() {
Some(id) => Some(id),
None => return Some(Err(format!("photo.setReference: `id` must be a photo id, not {v}"))),
},
None => app.session.active().map(|a| a.0),
};
let Some(id) = id else { return Some(Err("select a photo to use as the reference".into())) };
match app.session.catalog.photo(lightcraft_catalog::PhotoId(id)) {
None => return Some(Err(format!("no photo {id}"))),
Some(p) if p.deleted => return Some(Err(format!("photo {id} is in Recently Deleted: restore it to use it as the reference"))),
Some(_) => {}
}
app.ui.reference = Some(id);
Ok(json!({"reference": id}))
}
"compare.swap" => crate::panels::compare::swap(app),
@@ -637,7 +653,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
panel(app, &ctx, RightPanel::Crop, "Crop, Rotate, Geometry");
} else if app.ui.view == ViewMode::People && app.ui.person_page.is_some() {
app.ui.person_page = None;
} else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) {
} else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey | ViewMode::Reference) {
app.ui.view = ViewMode::Detail;
} else if app.ui.view == ViewMode::Detail {
// a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid
+15 -4
View File
@@ -23,11 +23,15 @@ pub fn culling(app: &LightcraftApp) -> bool {
/// The (select, candidate) pair, repaired or chosen if needed: the active photo against the next
/// selected photo, else against its neighbour in the view.
/// A photo that can be shown or pinned: in the catalog and not in Recently Deleted.
pub fn live(app: &LightcraftApp, id: PhotoId) -> bool {
app.session.catalog.photo(id).is_some_and(|p| !p.deleted)
}
pub fn compare_pair(app: &mut LightcraftApp) -> Option<(PhotoId, PhotoId)> {
let exists = |app: &LightcraftApp, id: PhotoId| app.session.catalog.photo(id).is_some_and(|p| !p.deleted);
if let Some((a, b)) = app.ui.compare {
let (a, b) = (PhotoId(a), PhotoId(b));
if a != b && exists(app, a) && exists(app, b) {
if a != b && live(app, a) && live(app, b) {
return Some((a, b));
}
}
@@ -450,10 +454,17 @@ pub fn prepare(app: &mut LightcraftApp) {
app.renderer.release(Slot::Region);
app.renderer.release(Slot::RegionBefore);
}
if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) {
// Reference with no live pin draws no panes: release them as other views do.
let reference_shown = app.ui.reference.is_some_and(|r| live(app, PhotoId(r)));
if app.ui.view == ViewMode::Compare || (app.ui.view == ViewMode::Reference && reference_shown) {
app.session.media.set_full_capacity(WINDOW_PANES as usize);
return;
}
if app.ui.view == ViewMode::Reference {
// the reference (pane 0) and the active photo beside it (pane 1)
app.renderer.release(Slot::Compare(0));
app.renderer.release(Slot::Compare(1));
}
app.session.media.set_full_capacity(1);
for i in 0..WINDOW_PANES {
if app.compare_windows[i as usize].take().is_some() || app.renderer.textures.contains_key(&Slot::CompareRegion(i)) {
@@ -578,7 +589,7 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) {
/// the Edit panel works on, so a look can be matched by eye.
pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let canvas = area_and_filmstrip(app, ui);
let reference = app.ui.reference.map(PhotoId).filter(|r| app.session.catalog.photo(*r).is_some());
let reference = app.ui.reference.map(PhotoId).filter(|r| live(app, *r));
let (Some(r), Some(active)) = (reference, app.session.active()) else {
super::empty_message(ui, canvas, "No reference photo", "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)");
return;
+8 -2
View File
@@ -1091,8 +1091,14 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
Err(e) => app.toast(ui.ctx(), e),
}
}
if ui.button(crate::i18n::tr("Set as Reference Photo")).clicked() {
let _ = app.run("photo.setReference", json!({"id": id.0}));
let live = super::compare::live(app, id);
let set_reference = ui
.add_enabled(live, egui::Button::new(crate::i18n::tr("Set as Reference Photo")))
.on_disabled_hover_text(crate::i18n::tr("Restore the photo to use it as the reference"));
if set_reference.clicked()
&& let Err(e) = app.run("photo.setReference", json!({"id": id.0}))
{
app.toast(ui.ctx(), e);
}
ui.separator();
crate::widgets::menu_button(ui, crate::i18n::tr("Set Rating"), |ui| {
+21 -3
View File
@@ -1170,7 +1170,6 @@ fn switch_library(app: &mut LightcraftApp, path: &str) -> Result<Value, String>
app.ui.dialog = None;
}
app.renderer.forget_all();
app.ui.compare = None;
if let Err(err) = opened {
let Some(prev) = previous_dir else { return Err(err) };
return match app.session.open_library(&prev, false) {
@@ -1178,6 +1177,13 @@ fn switch_library(app: &mut LightcraftApp, path: &str) -> Result<Value, String>
Err(e) => Err(format!("{err}; reopening the previous library {} failed too: {e}", prev.display())),
};
}
// Photo IDs are local to a catalog: pins cannot follow them into another library (a refused
// or failed switch reopens the same catalog, so its pins stay).
app.ui.reference = None;
app.ui.compare = None;
if app.ui.view == crate::state::ViewMode::Reference {
app.ui.view = crate::state::ViewMode::PhotoGrid;
}
app.ui.settings.library_path = path.clone();
if let Some(prev) = previous.filter(|p| p != &path && !app.ui.settings.recent_libraries.contains(p)) {
remember_library(&mut app.ui.settings.recent_libraries, &prev);
@@ -1352,7 +1358,8 @@ mod tests {
use serde_json::json;
fn app_with_import() -> LightcraftApp {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default());
app.run("photo.setReference", json!({})).unwrap();
app.import = Some(crate::import::ImportTask::new(vec!["a.jpg".into()], json!({"mode": "add"}), 0, false));
app
}
@@ -1360,17 +1367,21 @@ mod tests {
#[test]
fn open_library_is_refused_while_an_import_runs() {
let mut app = app_with_import();
let pin = app.ui.reference;
let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err();
assert!(err.contains("import to finish"), "{err}");
assert!(app.import.is_some());
assert_eq!(app.ui.reference, pin, "a refused Open Library keeps the reference pin");
}
#[test]
fn new_library_is_refused_while_an_import_runs() {
let mut app = app_with_import();
let pin = app.ui.reference;
let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err();
assert!(err.contains("import to finish"), "{err}");
assert!(app.import.is_some());
assert_eq!(app.ui.reference, pin, "a refused New Library keeps the reference pin");
}
/// The menu items that switch libraries are greyed out meanwhile, like during a Lightroom import.
@@ -1390,11 +1401,18 @@ mod tests {
fn open_library_is_refused_while_an_import_scan_runs() {
let dir = std::env::temp_dir().join(format!("lc-ui-switchscan-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default());
app.run("photo.setReference", json!({})).unwrap();
let pin = app.ui.reference;
crate::import::open(&mut app, vec![dir.to_string_lossy().into_owned()]).unwrap();
assert!(app.scan.is_some());
let err = open_library(&mut app, &json!({"path": "other"})).unwrap_err();
assert!(err.contains("import to finish"), "{err}");
assert_eq!(app.ui.reference, pin, "a refused Open Library during a scan keeps the reference pin");
let err = new_library(&mut app, &json!({"path": "other"})).unwrap_err();
assert!(err.contains("import to finish"), "{err}");
assert_eq!(app.ui.reference, pin, "a refused New Library during a scan keeps the reference pin");
assert!(app.scan.is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}
+75
View File
@@ -209,3 +209,78 @@ fn a_compare_pane_says_when_its_render_failed() {
assert!(h.step_until(SETTLE, failed), "the failure is said");
assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("compare:rendering:")));
}
#[test]
fn reference_escape_leaves_overlays_then_the_view_without_changing_edits() {
use crate::state::{LightsOut, ViewMode};
let mut h = demo();
let reference = h.app.session.visible_cloned()[0];
h.app.run("library.select", json!({"ids": [reference.0]})).unwrap();
h.app.run("view.reference", json!({})).unwrap();
let active = h.app.session.active().unwrap();
h.app.run("develop.set", json!({"control": "light.exposure", "value": 0.7})).unwrap();
h.app.ui.lights_out = LightsOut::Dim;
h.request("ui.key", json!({"key": "Escape"}), T);
assert_eq!((h.app.ui.view, h.app.ui.lights_out), (ViewMode::Reference, LightsOut::On));
h.request("ui.key", json!({"key": "Escape"}), T);
assert_eq!(h.app.ui.view, ViewMode::Detail);
assert_eq!(h.app.ui.reference, Some(reference.0), "leaving the view keeps the pinned photo");
assert_eq!(h.app.session.active(), Some(active));
assert_eq!(h.app.session.develop_of(active).unwrap().light.exposure, 0.7);
h.request("ui.key", json!({"key": "Escape"}), T);
assert_eq!(h.app.ui.view, ViewMode::PhotoGrid);
}
#[test]
fn invalid_or_deleted_reference_is_refused_without_replacing_the_pin() {
let mut h = demo();
let ids = h.app.session.visible_cloned();
h.app.run("library.select", json!({"ids": [ids[0].0]})).unwrap();
h.app.run("photo.setReference", json!({})).unwrap();
h.app.run("photo.delete", json!({"ids": [ids[1].0]})).unwrap();
for id in [u64::MAX, ids[1].0] {
let r = h.app.run("photo.setReference", json!({"id": id}));
assert!(r.is_err(), "unavailable photo {id} must not be pinned");
assert_eq!(h.app.ui.reference, Some(ids[0].0));
}
let deleted = h.app.run("photo.setReference", json!({"id": ids[1].0})).unwrap_err();
assert!(deleted.contains("Recently Deleted"), "the caller learns to restore it: {deleted}");
// a malformed id is refused, not taken for "the active photo"
h.app.run("library.select", json!({"ids": [ids[2].0]})).unwrap();
for id in [json!(ids[2].0.to_string()), json!(-1), json!(1.5), json!([ids[2].0])] {
assert!(h.app.run("photo.setReference", json!({"id": id})).is_err(), "{id}");
assert_eq!(h.app.ui.reference, Some(ids[0].0));
}
h.app.run("photo.setReference", json!({"id": null})).unwrap();
assert_eq!(h.app.ui.reference, Some(ids[2].0), "a null id means the active photo");
h.app.run("photo.setReference", json!({"id": ids[0].0})).unwrap();
h.app.run("library.select", json!({"ids": []})).unwrap();
assert!(h.app.run("photo.setReference", json!({})).is_err());
assert_eq!(h.app.ui.reference, Some(ids[0].0));
}
#[test]
fn deleting_the_pinned_reference_stops_drawing_it_and_reentry_uses_a_live_photo() {
let mut h = demo();
let reference = h.app.session.visible_cloned()[0];
h.app.run("library.select", json!({"ids": [reference.0]})).unwrap();
h.app.run("view.reference", json!({})).unwrap();
h.settle(SETTLE);
let texture = h.app.renderer.textures.get(&crate::render::Slot::Compare(0)).unwrap().tex.id();
let drawn = |h: &Headless| h.view.painted_shapes().iter().any(|s| matches!(s, egui::epaint::Shape::Mesh(mesh) if mesh.texture_id == texture));
assert!(drawn(&h), "the live reference is shown before deletion");
let active = h.app.session.active().unwrap();
h.app.run("photo.delete", json!({"ids": [reference.0]})).unwrap();
h.settle(SETTLE);
assert!(!drawn(&h), "Recently Deleted photos must not remain on the reference canvas");
assert!(!h.app.renderer.textures.contains_key(&crate::render::Slot::Compare(0)), "the deleted reference's pane is released");
assert!(!h.app.renderer.textures.contains_key(&crate::render::Slot::Compare(1)), "and the active photo's pane beside it");
assert!(h.app.compare_windows[0].is_none());
let r = h.app.run("view.reference", json!({})).unwrap();
assert_eq!(r["reference"], active.0);
assert_eq!(h.app.ui.reference, Some(active.0));
h.app.run("photo.restore", json!({"ids": [reference.0]})).unwrap();
h.app.run("photo.setReference", json!({"id": reference.0})).unwrap();
h.settle(SETTLE);
assert!(h.app.renderer.textures.get(&crate::render::Slot::Compare(0)).is_some_and(|t| t.photo == reference));
}
@@ -183,6 +183,40 @@ fn missing_recent_libraries_are_greyed_out_and_pruned_and_the_list_is_capped() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reference_photo_ids_do_not_cross_library_boundaries() {
let dir = temp("reference-switch");
let (first, second) = (dir.join("first"), dir.join("second"));
let mut session = lightcraft_engine::Session::new().with_fs();
session.open_library(&second, true).unwrap();
let new_photo = session.visible_cloned()[0];
session.close_library().unwrap();
session.open_library(&first, true).unwrap();
let old_reference = session.visible_cloned()[1];
let mut app = LightcraftApp::new(session, Services::default());
app.run("library.select", json!({"ids": [old_reference.0]})).unwrap();
app.run("photo.setReference", json!({})).unwrap();
app.run("view.reference", json!({})).unwrap();
let held = lightcraft_catalog::LibraryLock::acquire(&second, "another program").unwrap();
assert!(app.run("app.openLibrary", json!({"path": second.to_string_lossy()})).is_err());
assert_eq!(app.ui.reference, Some(old_reference.0), "a refused switch keeps the live pin");
assert_eq!(app.ui.view, crate::state::ViewMode::Reference);
drop(held);
app.run("app.openLibrary", json!({"path": second.to_string_lossy()})).unwrap();
assert!(app.session.catalog.photo(old_reference).is_some(), "the second library reuses this numeric ID");
assert_eq!(app.ui.reference, None, "the first library's pin must not acquire a different photo");
assert_eq!(app.ui.view, crate::state::ViewMode::PhotoGrid, "an empty Reference view is not left on screen");
app.run("library.select", json!({"ids": [new_photo.0]})).unwrap();
let r = app.run("view.reference", json!({})).unwrap();
assert_eq!(r["reference"], new_photo.0);
app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Empty"})).unwrap();
assert_eq!(app.ui.reference, None, "New Library clears the old reference too");
assert!(app.run("view.reference", json!({})).is_err(), "an empty library cannot pin a reference");
assert_eq!(app.ui.reference, None);
app.session.close_library().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// A library another program has open is refused before the current one is closed: the current
/// library stays open with its photos, and the window title keeps its name.
#[test]
+4 -3
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; earlier: LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; earlier: LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -696,11 +696,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-VIEWS | Grid / loupe / compare / survey / people | P1 | 🟡 | `cmd:view.photoGrid`, `cmd:view.squareGrid`, `cmd:view.detail`, `cmd:view.compare`, `cmd:view.survey`, `cmd:view.gridInfo` | grid, square grid, loupe, compare, survey, second window; square-grid captions: file name, exposure or capture date (View ▸ Grid Info; no people view; picking one in Photo Grid switches to Square Grid) |
| LRC-LIB-COMPARE | Compare view | P1 | ✅ | `cmd:view.compare` | ⇧C (C is Crop here); swap, make select; a filmstrip click replaces the active side, a ⌘/Ctrl- or Shift-click changes the selection without touching the pair; zoom always linked; each pane says “Rendering…” or that the photo can't be opened, as the loupe does (see LR-VIEW-COMPARE) |
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does |
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does. Escape → Detail without losing edits; pins are catalog-local; missing/deleted photos refused and deleted references no longer drawn (Windows regression/native smoke verified) |
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; on-demand main-file pages up to 16 GiB with existing table/record bounds (WAL still capped at 1 GiB, issue #798); background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). An image whose file, folder or root folder record is missing from the catalog, or whose original path is empty, is skipped with its virtual copies instead of failing the whole import; the report lists them as data (`skipped`: image id and reason) and the result window counts them in the UI language (PR #2230). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) |
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) |
| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key |
| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); Open/New refused during imports and their folder scans; a library that can't be opened leaves the current one and its reference pin intact; a successful switch clears the catalog-local reference pin; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key |
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was lLine truncated
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) |
@@ -881,6 +881,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-13 | minor | LR-META-INFO: Nikon DSLR raws show their lens from the maker note; status unchanged (✅) |
| 2026-10-13 | patch | LR-IMP-LOCAL: browsing a Local folder again forgets the untouched records of files deleted or moved by another app (issue #1392); status unchanged |
| 2026-10-13 | minor | LR-MERGE-PANO: a panorama keeps its photos' resolution (up to 512 MP / 65,000 px) instead of a fixed 40 MP, held to what fits in memory (issue #1455); status unchanged |
| 2026-10-21 | minor | LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; reference pins stay within their catalog, unavailable or malformed photo ids refused, deleted reference not drawn and its pane released, a switch leaves Reference view; statuses unchanged |
| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references |
| 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) |
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |
+20 -1
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (zoom row: soft proofing sharp at 1:1; canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier
keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in
@@ -126,6 +126,24 @@ unnamed was in the way.
Not done: brush strokes and red-eye ellipses still need a pointer. Nobody has tried the app with
a real screen reader on any platform yet.
## Reference view lifecycle
Escape leaves Reference for Detail, preserving the active photo and its edits; a second Escape
returns to the grid. Existing dialogs, tools and Lights Out retain their higher-priority Escape
handling. The reference stays pinned when leaving the view, but a successful Open/New Library
clears it: numeric photo IDs are local to each catalog. A refused switch keeps the current pin,
including Open/New refused while an import or its folder scan is running.
`photo.setReference` refuses missing or Recently Deleted photos without replacing the current
pin. Deleting a pinned reference stops drawing it; re-entering Reference falls back to a live
active photo. Restoring the photo permits pinning it again. An empty catalog cannot supply one.
Verification: four new behavioral regressions failed on upstream `76b5377` before the fix.
The patched Windows x64 app on RTX 5080 / DX12 passed Escape, edit preservation, delete/restore,
and New/Open Library checks using isolated catalogs. Saved exposure survived reopening. Native
1400 × 900 captures did not fall back to headless rendering. Control-channel input does not
establish OS keyboard delivery or cross-platform acceptance.
## Revision history
| Date | Change | Summary |
@@ -138,5 +156,6 @@ a real screen reader on any platform yet.
| 2026-10-13 | minor | Shortcuts: Home / End and Shift+Left / Right in the two library grids, Cmd+O (Open Library…) and Shift+H (Visualize HDR) get bindings and keyboard tests; percentages and the dated counts above are unchanged |
| 2026-10-14 | minor | Zoom row: soft proofing gets zoom windows; LR-VIEW-ZOOM gap closed |
| 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h |
| 2026-10-21 | minor | Reference Escape exits to Detail; catalog-local pins and missing/deleted reference handling verified with regressions and native Windows smoke |
| 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing |
| 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist |
+15
View File
@@ -1,5 +1,20 @@
# What's new in LightCraft
## 2026-10-21
### Reference view reliability
- Escape now leaves Reference for Detail without changing the active photo or its edits; the next
Escape returns to the grid. A dialog, tool or Lights Out still handles Escape first.
- Opening or creating a different library clears the pinned reference. Photo IDs belong to one
catalog; a pin no longer silently becomes another library's photo with the same numeric ID.
- Missing or Recently Deleted photos cannot be pinned. Deleting the pinned reference stops drawing
it; re-entering Reference chooses a live photo, and restoring the deleted photo makes it pinnable again.
In Recently Deleted, Set as Reference Photo is greyed out, and a refusal says to restore the photo.
- Switching library from Reference view goes to the grid instead of an empty Reference canvas. A refused
or failed switch keeps both the reference and the Compare pair.
- `photo.setReference` refuses an `id` that is not a photo id (a string, a negative or fractional
number) instead of pinning the active photo; only an absent or null `id` means the active photo.
## 2026-10-19
### The Before label stays in view when zoomed in