Merge PR #2425: LR-IMP-ADD-DIALOG: preserve album hierarchy in Import Photos

docs/parity-checklist.md: header keeps 2026-10-21 and lists both sides' change notes
This commit is contained in:
aiongg
2026-10-22 02:39:44 +00:00
6 changed files with 453 additions and 37 deletions
+48 -7
View File
@@ -172,13 +172,14 @@ pub struct AlbumPicker<'a> {
value: &'a mut Option<u64>,
entries: &'a [AlbumEntry],
width: f32,
empty_text: Option<String>,
}
impl<'a> AlbumPicker<'a> {
/// `id` must be unique among the pickers on screen (it names their widgets and keeps their
/// state apart); `value` is the album picked (`None`: none yet); `entries` in tree order.
pub fn new(id: impl Into<String>, value: &'a mut Option<u64>, entries: &'a [AlbumEntry]) -> Self {
AlbumPicker { id: id.into(), value, entries, width: 180.0 }
AlbumPicker { id: id.into(), value, entries, width: 180.0, empty_text: None }
}
/// The button's width (180 by default).
@@ -187,13 +188,27 @@ impl<'a> AlbumPicker<'a> {
self
}
/// Text for an empty choice (already localized by the caller); the default is "Choose an album…".
pub fn empty_text(mut self, text: impl Into<String>) -> Self {
self.empty_text = Some(text.into());
self
}
/// The button and, while open, the popup. The response is `changed()` when an album was picked.
pub fn show(self, ui: &mut Ui) -> Response {
let AlbumPicker { id, value, entries, width } = self;
self.show_with_choices(ui, |_| false)
}
/// Draw caller-owned choices before the search (e.g. None / New album in the import review).
/// Return true when an extra choice was picked to close the popup. The caller handles those
/// actions; `changed()` still reports only an album picked from the tree or search.
pub fn show_with_choices(self, ui: &mut Ui, choices: impl FnOnce(&mut Ui) -> bool) -> Response {
let AlbumPicker { id, value, entries, width, empty_text } = self;
let chosen = value.filter(|v| entries.iter().any(|e| e.id == *v && !e.folder));
let text = chosen.map_or_else(|| crate::i18n::tr("Choose an album…").to_string(), |c| path_label(entries, c));
let text = chosen.map_or_else(|| empty_text.unwrap_or_else(|| crate::i18n::tr("Choose an album…").to_string()), |c| path_label(entries, c));
// the name, and a drop-down arrow painted at the right (the UI font has no arrow glyph)
let mut button = ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text).truncate().right_text(" "));
let mut button =
ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text.as_str()).truncate().right_text(" ")).on_hover_text(text);
let arrow_color = crate::theme::Tokens::get(ui.ctx()).icon;
disclosure(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color);
register(ui.ctx(), format!("albumPicker:{id}"), button.rect);
@@ -206,8 +221,15 @@ impl<'a> AlbumPicker<'a> {
let mut state: State = ui.data(|d| d.get_temp(key)).unwrap_or_default();
let mut picked: Option<u64> = None;
egui::Popup::from_toggle_button_response(&button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_min_width(width.max(260.0));
ui.set_max_width(width.max(260.0) + 120.0);
// A wide popup remembered before a window resize must still fit the current viewport.
let margin = egui::Frame::popup(ui.style()).total_margin().sum().x;
let max_width = (ui.ctx().content_rect().width() - margin).max(1.0);
ui.set_min_width(width.max(260.0).min(max_width));
ui.set_max_width((width.max(260.0) + 120.0).min(max_width));
if choices(ui) {
ui.close();
return;
}
let search_box =
ui.add(egui::TextEdit::singleline(&mut state.query).hint_text(crate::i18n::tr("Search albums")).desired_width(f32::INFINITY));
register(ui.ctx(), format!("albumPickerSearch:{id}"), search_box.rect);
@@ -227,7 +249,9 @@ impl<'a> AlbumPicker<'a> {
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover());
let icon = if e.smart { Icon::SmartAlbum } else { Icon::Album };
crate::icons::paint(ui.painter(), r, icon, if e.blocked.is_some() { t.text_disabled } else { t.icon });
let resp = ui.add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label));
let resp = ui
.add_enabled(e.blocked.is_none(), egui::Button::selectable(Some(e.id) == chosen || highlighted, label).truncate())
.on_hover_text(label);
register(ui.ctx(), format!("albumPickerItem:{}:{id}", e.id), resp.rect);
let resp = match &e.blocked {
Some(why) => resp.on_disabled_hover_text(why.as_str()),
@@ -390,6 +414,23 @@ mod tests {
assert_eq!(ids(&[1, 5]), vec![1, 2, 3, 4, 5, 6]);
}
/// Cycles, dangling parents and excessively deep chains finish with bounded paths and depths.
#[test]
fn damaged_parent_chains_are_bounded() {
let mut entries = vec![e(1, "Loop A", Some(2), true, false), e(2, "Loop B", Some(1), true, false), e(3, "Photo", Some(1), false, false)];
set_depths(&mut entries);
assert!(ancestors(&entries, 3).len() <= 3);
assert_eq!(search(&entries, "photo"), vec![2]);
assert_eq!(visible_tree(&entries, &[1, 2]), vec![0, 1, 2]);
let mut deep: Vec<_> = (1..1000).map(|id| e(id, "Deep", Some(id + 1), true, false)).collect();
deep.push(e(1000, "End", None, true, false));
set_depths(&mut deep);
assert!(deep.iter().all(|entry| entry.depth <= 65));
assert!(ancestors(&deep, 1).len() <= 65);
assert!(path_label(&deep, 1).split(" / ").count() <= 66);
assert_eq!(path_label(&[e(1, "Orphan", Some(99), false, false)], 1), "Orphan");
}
/// Big libraries stay quick: 20,000 albums in nested folders are laid out and searched in well
/// under a second (folder paths are worked out once, not per row by scanning every album).
#[test]
+34 -25
View File
@@ -1244,40 +1244,49 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
);
});
}
let albums: Vec<(u64, String)> = {
let mut v: Vec<(u64, String)> =
app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect();
v.sort_by_key(|(_, n)| n.to_lowercase());
v
};
let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None);
albums.retain(|a| a.folder || !a.smart);
// Catalog changes while the review is open must not leave a hidden, unselectable destination.
d.album = d.album.filter(|id| albums.iter().any(|a| a.id == *id && !a.folder));
field(ui, "Album", |ui| {
let cur = match d.album {
Some(a) => albums.iter().find(|x| x.0 == a).map(|x| x.1.clone()).unwrap_or_default(),
None if !d.new_album.is_empty() => crate::i18n::tr("New album").into(),
None => crate::i18n::tr("None").into(),
};
crate::widgets::combo_box("import-album").selected_text(cur).show_ui(ui, |ui| {
if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() {
d.album = None;
ui.vertical(|ui| {
let mut chosen = d.album;
let mut extra = None;
let empty = crate::i18n::tr(if d.new_album.is_empty() { "None" } else { "New album" });
let response = crate::album_picker::AlbumPicker::new("import-album", &mut chosen, &albums)
.width(ui.available_width())
.empty_text(empty)
.show_with_choices(ui, |ui| {
let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None"));
register(ui.ctx(), "importAlbum:none", none.rect);
if none.clicked() {
extra = Some(false);
}
let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…"));
register(ui.ctx(), "importAlbum:new", new.rect);
if new.clicked() {
extra = Some(true);
}
ui.separator();
extra.is_some()
});
if response.changed() {
d.album = chosen;
d.new_album.clear();
}
if ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")).clicked() {
if let Some(new) = extra {
d.album = None;
if d.new_album.is_empty() {
if !new {
d.new_album.clear();
} else if d.new_album.is_empty() {
d.new_album = crate::i18n::tr("Imported Photos").into();
}
}
for (id, name) in &albums {
if ui.selectable_label(d.album == Some(*id), name).clicked() {
d.album = Some(*id);
d.new_album.clear();
}
if d.album.is_none() && !d.new_album.is_empty() {
let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY));
register(ui.ctx(), "field:importAlbumName", r.rect);
}
});
if d.album.is_none() && !d.new_album.is_empty() {
let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY));
register(ui.ctx(), "field:importAlbumName", r.rect);
}
});
field(ui, "Preset", |ui| {
let cur = app
+347 -2
View File
@@ -1,5 +1,5 @@
//! The album picker in the smart-album rule editor: a tree like the sidebar's, a search that
//! narrows it as you type, keys to pick with, and albums that can't be picked shown greyed.
//! The shared album picker in smart rules and the import review: sidebar hierarchy, path search,
//! keyboard choices, and only valid destinations. Import also retains None and New album.
use std::time::Duration;
@@ -12,26 +12,31 @@ use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
/// Dispatch a public command and return its successful engine result.
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{command}: {r}");
r["result"].clone()
}
/// Read the album ID returned by a successful creation command.
fn id(v: serde_json::Value) -> u64 {
v["id"].as_u64().expect("an id")
}
/// Click a registered widget through the public control channel and let frames settle.
fn click(h: &mut Headless, id: &str) {
let r = h.request("ui.clickWidget", json!({"id": id}), T);
assert_eq!(r["ok"], true, "{id}: {r}");
h.settle(SETTLE);
}
/// Check the current frame registry for an exact widget ID.
fn has(h: &Headless, id: &str) -> bool {
h.app.widgets.iter().any(|(w, _)| w == id)
}
/// Read the Album rule value from the active smart-rule editor.
fn value(h: &Headless) -> serde_json::Value {
let Some(crate::state::Dialog::SmartRules { rules, .. }) = &h.app.ui.dialog else { panic!("no rule editor") };
let Some(Rule::Field { value, .. }) = rules.rules.first() else { panic!("no rule") };
@@ -126,3 +131,343 @@ fn typing_after_a_folder_click_still_searches() {
h.settle(SETTLE);
assert!(has(&h, &format!("albumPickerItem:{trip}:rules-0")) && !has(&h, &format!("albumPickerItem:{excluded}:rules-0")), "searching for trip");
}
/// The import review reuses the same tree, while accepting only regular album destinations.
mod import_review {
use super::*;
struct Review {
h: Headless,
folder: u64,
nested: u64,
album: u64,
other: u64,
smart: u64,
}
/// Open the standard import review fixture with nested and same-named albums.
fn review() -> Review {
review_at([1300.0, 950.0], 1.0)
}
/// Build the import review fixture at the requested logical size and display density.
fn review_at(size: [f32; 2], density: f32) -> Review {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, size, density);
let folder = id(exec(&mut h, "album.create", json!({"name": "Travel", "folder": true})));
let nested = id(exec(&mut h, "album.create", json!({"name": "2026", "folder": true, "parent": folder})));
let album = id(exec(&mut h, "album.create", json!({"name": "Favorites", "parent": nested, "addSelected": false})));
let other = id(exec(&mut h, "album.create", json!({"name": "Favorites", "addSelected": false})));
let smart = id(exec(&mut h, "album.createSmart", json!({"name": "Smart travel", "parent": folder, "rules": {"rating": 4}})));
h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(Vec::new())) });
h.settle(SETTLE);
Review { h, folder, nested, album, other, smart }
}
/// Read the active import review without accepting a silently closed dialog.
fn destination(h: &Headless) -> &crate::import::ImportDialog {
let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("import review remains open") };
opts
}
/// #2362: the actual import dialog browses nested folders and remembers the chosen full path.
#[test]
fn import_album_picker_preserves_the_sidebar_hierarchy() {
let Review { mut h, folder, nested, album, other, smart } = review();
click(&mut h, "albumPicker:import-album");
assert!(has(&h, &format!("albumPickerFolder:{folder}:import-album")));
assert!(has(&h, &format!("albumPickerItem:{other}:import-album")));
assert!(!has(&h, &format!("albumPickerItem:{album}:import-album")), "nested album starts folded");
click(&mut h, &format!("albumPickerFolder:{folder}:import-album"));
assert_eq!(destination(&h).album, None, "a folder is for browsing, not a destination");
assert!(!has(&h, &format!("albumPickerItem:{smart}:import-album")), "smart albums cannot receive imports");
click(&mut h, &format!("albumPickerFolder:{nested}:import-album"));
click(&mut h, &format!("albumPickerItem:{album}:import-album"));
assert_eq!(destination(&h).album, Some(album));
assert!(destination(&h).new_album.is_empty());
assert!(h.painted_text().iter().any(|t| t == "Travel / 2026 / Favorites"), "duplicate album names have their full path");
click(&mut h, "albumPicker:import-album");
assert!(has(&h, &format!("albumPickerItem:{album}:import-album")), "selected album's ancestors reopen");
}
/// Type through the public control channel and wait for the search or field to update.
fn text(h: &mut Headless, value: &str) {
let r = h.request("ui.text", json!({"text": value}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE));
}
/// Send a public keyboard event and wait for its resulting UI state.
fn key(h: &mut Headless, key: &str) {
let r = h.request("ui.key", json!({"key": key}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE));
}
/// Replace the pending new album name using normal field selection and typing.
fn replace_name(h: &mut Headless, name: &str) {
click(h, "field:importAlbumName");
let r = h.request("ui.key", json!({"key": "a", "cmd": true}), T);
assert_eq!(r["ok"], true, "{r}");
text(h, name);
}
/// None, New and an existing album replace each other without retaining a stale pending name.
#[test]
fn import_album_picker_retains_none_new_and_existing_transitions() {
let Review { mut h, album, .. } = review();
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
assert_eq!(destination(&h).album, None);
assert_eq!(destination(&h).new_album, "Imported Photos");
replace_name(&mut h, "Été 東京");
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
assert_eq!(destination(&h).new_album, "Été 東京", "choosing New again keeps the typed name");
click(&mut h, "albumPicker:import-album");
text(&mut h, "travel 2026 fav");
key(&mut h, "Enter");
assert_eq!(destination(&h).album, Some(album));
assert!(destination(&h).new_album.is_empty(), "existing album replaces a pending new album");
assert!(!has(&h, "field:importAlbumName"));
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:none");
assert_eq!(destination(&h).album, None);
assert!(destination(&h).new_album.is_empty());
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
replace_name(&mut h, "Leave no orphan album");
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:none");
assert!(destination(&h).new_album.is_empty(), "None clears a pending name too");
}
/// The picker must leave room for a usable name field; a full-width button followed by an
/// inline name field made the resizable dialog grow on every frame, off the native window.
#[test]
fn import_album_picker_keeps_the_new_name_inside_the_dialog() {
let Review { mut h, .. } = review();
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
for _ in 0..20 {
h.step();
}
let name = h.app.widgets.iter().find(|(id, _)| id == "field:importAlbumName").unwrap().1;
assert!(name.width() >= 160.0, "the name field is usable: {name:?}");
assert!(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 950.0)).contains_rect(name), "name stays on screen: {name:?}");
let picker = |h: &Headless| h.app.widgets.iter().find(|(id, _)| id == "albumPicker:import-album").unwrap().1;
let before = picker(&h);
for _ in 0..10 {
h.step();
}
let after = picker(&h);
assert!(
(before.width() - after.width()).abs() < 0.5 && (before.left() - after.left()).abs() < 0.5,
"the dialog stops growing: {before:?} -> {after:?}"
);
}
/// Path search is case-insensitive and Escape preserves review edits and user-provided names.
#[test]
fn import_album_picker_search_escape_and_unicode_names() {
let Review { mut h, folder, album, .. } = review();
let unicode = id(exec(&mut h, "album.create", json!({"name": "La fête 東京", "parent": folder, "addSelected": false})));
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
replace_name(&mut h, "Keep this name");
click(&mut h, "albumPicker:import-album");
text(&mut h, "no such album");
assert!(h.painted_text().iter().any(|t| t == "No albums match"));
key(&mut h, "Enter");
assert!(has(&h, "albumPickerSearch:import-album"), "Enter with no result keeps the picker open");
key(&mut h, "Escape");
assert!(!has(&h, "albumPickerSearch:import-album"));
assert_eq!(destination(&h).new_album, "Keep this name", "Escape leaves the review's edits intact");
click(&mut h, "albumPicker:import-album");
text(&mut h, "FÊTE");
assert!(has(&h, &format!("albumPickerItem:{unicode}:import-album")));
assert!(!has(&h, &format!("albumPickerItem:{album}:import-album")));
key(&mut h, "Enter");
assert_eq!(destination(&h).album, Some(unicode));
assert!(h.painted_text().iter().any(|t| t == "Travel / La fête 東京"), "user names are kept verbatim");
}
/// The catalog custom order wins over alphabetical order in the import picker.
#[test]
fn import_album_picker_follows_custom_sidebar_order() {
let Review { mut h, other, .. } = review();
let last_by_name = id(exec(&mut h, "album.create", json!({"name": "Zebra", "addSelected": false})));
exec(&mut h, "album.reorder", json!({"id": last_by_name, "before": other}));
click(&mut h, "albumPicker:import-album");
let top = |id| h.app.widgets.iter().find(|(w, _)| w == &format!("albumPickerItem:{id}:import-album")).unwrap().1.top();
assert!(top(last_by_name) < top(other), "sidebar order takes priority over alphabetical order");
}
/// Match painted text inside the specified control instead of anywhere in the frame.
fn button_text(h: &Headless, id: &str, expected: &str) -> bool {
let rect = h.app.widgets.iter().find(|(w, _)| w == id).unwrap().1;
h.view.painted_shapes().iter().any(|shape| matches!(shape, egui::Shape::Text(t) if t.galley.job.text == expected && rect.contains(t.pos)))
}
/// Long paths and editable names remain usable across themes, viewport sizes and display densities.
#[test]
fn import_album_picker_fits_long_names_across_themes_sizes_and_densities() {
for (size, density) in [([960.0, 760.0], 1.0), ([1366.0, 900.0], 2.0), ([960.0, 760.0], 2.25)] {
let Review { mut h, folder, .. } = review_at(size, density);
let long = "La fête des photographies – 東京 – ".repeat(12);
let album = id(exec(&mut h, "album.create", json!({"name": long, "parent": folder, "addSelected": false})));
let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("review") };
opts.album = Some(album);
for theme in ["charcoal", "midnight", "paper", "silver"] {
exec(&mut h, "view.theme", json!({"theme": theme}));
let r = h.request("ui.set", json!({"language": "de"}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE));
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size);
let rect = |h: &Headless, name: &str| h.app.widgets.iter().find(|(id, _)| id == name).unwrap().1;
assert!(screen.contains_rect(rect(&h, "albumPicker:import-album")), "{theme} {size:?}: selected long path");
click(&mut h, "albumPicker:import-album");
text(&mut h, "fête");
let row = rect(&h, &format!("albumPickerItem:{album}:import-album"));
assert!(screen.contains_rect(row), "{theme} {size:?}: search result {row:?} inside {screen:?}");
key(&mut h, "Escape");
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:new");
let name = rect(&h, "field:importAlbumName");
assert!(name.width() >= 160.0 && screen.contains_rect(name), "{theme} {size:?}: name {name:?}");
click(&mut h, "albumPicker:import-album");
text(&mut h, "fête");
key(&mut h, "Enter");
assert_eq!(destination(&h).album, Some(album));
}
}
}
/// Every shipped locale paints translated destination choices and initializes a translated new name.
#[test]
fn import_album_picker_localizes_choices_in_every_shipped_language() {
use crate::i18n::Locale;
let Review { mut h, .. } = review();
for locale in Locale::ALL {
for source in
["None", "New album", "New album…", "Imported Photos", "Choose an album…", "Search albums", "No albums yet", "No albums match"]
{
assert!(!locale.tr(source).is_empty());
if *locale != Locale::En {
assert_ne!(locale.tr(source), source, "{locale:?}: {source}");
}
}
let r = h.request("ui.set", json!({"language": locale.code()}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE));
assert!(button_text(&h, "albumPicker:import-album", locale.tr("None")), "{locale:?}: localized empty destination");
click(&mut h, "albumPicker:import-album");
assert!(button_text(&h, "importAlbum:new", locale.tr("New album…")), "{locale:?}: localized new-album option");
click(&mut h, "importAlbum:new");
assert_eq!(destination(&h).new_album, locale.tr("Imported Photos"));
assert!(button_text(&h, "albumPicker:import-album", locale.tr("New album")));
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:none");
}
}
/// An empty catalog still offers None and New without inventing an existing destination.
#[test]
fn import_album_picker_handles_an_empty_catalog() {
let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1000.0, 800.0], 1.0);
h.app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(crate::import::ImportDialog::new(Vec::new())) });
assert!(h.settle(SETTLE));
click(&mut h, "albumPicker:import-album");
assert!(h.painted_text().iter().any(|t| t == "No albums yet"));
click(&mut h, "importAlbum:new");
assert!(has(&h, "field:importAlbumName"));
click(&mut h, "albumPicker:import-album");
click(&mut h, "importAlbum:none");
assert!(destination(&h).new_album.is_empty());
}
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
/// Remove only the uniquely owned fixture directory after the test session releases it.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Write an original procedural PNG in a unique temporary directory for a real import.
fn fixture() -> Fixture {
let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("lc-import-tree-{}-{unique}", std::process::id()));
std::fs::create_dir(&path).unwrap();
let pixels =
lightcraft_raster::Rgba8 { width: 40, height: 30, data: (0..1200).map(|i| [(i % 40 * 6) as u8, 31, (i / 40 * 8) as u8, 255]).collect() };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&pixels), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(path.join("hierarchy.png"), png).unwrap();
Fixture(path)
}
/// A real file scan, the real picker, and the real import worker: the intended album receives
/// the photo, duplicate names never redirect it, and the import is still one undo step.
/// A destination deleted while the review is open falls back to no album before confirmation.
#[test]
fn import_album_picker_delivers_to_the_chosen_destination_and_undoes() {
use lightcraft_catalog::PhotoId;
for choice in ["existing", "new", "none", "deleted"] {
let fixture = fixture();
let Review { mut h, album, other, .. } = review();
let before: std::collections::HashSet<PhotoId> = h.app.session.catalog.photos().map(|p| p.id).collect();
h.app.ui.dialog = None;
exec(&mut h, "file.addPhotos", json!({"paths": [fixture.0.to_string_lossy()], "album": album}));
assert!(h.step_until(SETTLE, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))));
assert!(h.settle(SETTLE));
assert_eq!(destination(&h).album, Some(album), "album-context import keeps its preselection");
click(&mut h, "albumPicker:import-album");
assert!(has(&h, &format!("albumPickerItem:{album}:import-album")), "preselection opens the right ancestors");
match choice {
"existing" => {
text(&mut h, "travel 2026 fav");
key(&mut h, "Enter");
}
"new" => {
click(&mut h, "importAlbum:new");
replace_name(&mut h, "From card");
}
"deleted" => {
key(&mut h, "Escape");
exec(&mut h, "album.delete", json!({"id": album}));
assert!(h.settle(SETTLE));
assert_eq!(destination(&h).album, None, "a deleted destination matches the displayed None choice");
assert!(button_text(&h, "albumPicker:import-album", "None"));
}
_ => click(&mut h, "importAlbum:none"),
}
let undo = h.app.session.undo.len();
let r = h.request("ui.dialog.confirm", json!({}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.step_until(SETTLE, |h| h.app.import.is_none()));
assert!(h.settle(SETTLE));
let added: Vec<PhotoId> = h.app.session.catalog.photos().filter(|p| !before.contains(&p.id)).map(|p| p.id).collect();
assert_eq!(added.len(), 1, "{choice}: exactly the scanned photo is imported");
let receives = |id| h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().photos.contains(&added[0]);
assert!(!receives(other), "same-named album outside the chosen folder is untouched");
match choice {
"existing" => assert!(receives(album)),
"new" => {
let created = h.app.session.catalog.albums().find(|a| a.name == "From card").unwrap();
assert!(created.photos.contains(&added[0]));
assert!(!receives(album));
}
_ => assert!(h.app.session.catalog.albums().all(|a| !a.photos.contains(&added[0]))),
}
assert_eq!(h.app.session.undo.len(), undo + 1, "{choice}: one undo step");
exec(&mut h, "edit.undo", json!({}));
assert!(h.settle(SETTLE));
assert_eq!(h.app.session.catalog.len(), before.len());
if choice == "new" {
assert!(h.app.session.catalog.albums().all(|a| a.name != "From card"), "undo removes the new album too");
}
}
}
}
+16
View File
@@ -293,6 +293,22 @@ one pixel.
(clicks, keys, drags) has played out. Widget ids for `ui.clickWidget` come from `ui.widgets`
(e.g. `button:upright-auto`). A 1600×1000 demo snapshot takes ~1–2 s (debug build).
## Import album picker
In the Import Photos review, `albumPicker:import-album` opens the destination picker.
`albumPickerFolder:<folder-id>:import-album` expands or folds a folder; it never chooses a destination.
`albumPickerItem:<album-id>:import-album` selects a regular album. The picker follows sidebar order,
opens the current choice's ancestors and shows its full path on the closed button. Smart albums
are not import destinations. Type into `albumPickerSearch:import-album` to search folder/album paths;
↑ / ↓ and Enter pick a search result, and Escape closes the picker while keeping the review open.
If the chosen album is deleted while the review is open, the destination returns to None before
confirmation instead of submitting the deleted ID.
`importAlbum:none` clears the destination and any pending new album name. `importAlbum:new` keeps
or initializes that name, edited through `field:importAlbumName`. Choosing an existing album clears
the pending name. `ui.dialog.confirm` starts the same import into the selected album ID; engine
`library.import` parameters `album` and `albumName` are unchanged.
## Panel docking
Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
+4 -1
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (gap 15: Print Page Options — page numbers, page info, crop marks)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (import review album hierarchy, #2362; no ranked gap closed)
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -60,6 +60,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
## Closed recently (kept for calibration)
- 2026-10-20: the import review album picker follows the sidebar folder hierarchy and searches album paths (#2362, LR-IMP-ADD-DIALOG), retaining None/New album and existing destination IDs. This closes a usability subgap; no ranked gap or headline feature count closes.
- 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like
Detail, Before/After, Compare, Reference and mask overlays; 1:1 tested equal to the full-size export.
- 2026-10-11: Sony ILCA-99M2 profile.
@@ -77,6 +79,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged |
| 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix |
| 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain |
| 2026-10-12 | minor | Gap 10: SR2/SRF folder discovery fixed; F828 imports as preview-only. Sensor decoding remains a gap; readiness scores unchanged |
+4 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362) · **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
@@ -71,7 +71,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|---|---|---|---|---|---|
| 2026-10-12 | minor | LR-IMP-FORMATS: Mamiya MEF decodes from sensor data and is imported (`vendor/mef.rs`) |
| 2026-10-12 | minor | LR-IMP-FORMATS: Epson ERF decodes from sensor data (`vendor/erf.rs`), no longer preview-only |
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) |
| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/album_picker.rs`, `crates/ui-egui/src/tests_album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) |
| LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported |
| LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | |
| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/import.rs`) |
@@ -797,6 +797,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| Date | Change | Summary |
|---|---|---|
| 2026-10-21 | patch | LR-IMP-ADD-DIALOG: reconcile #2362 with current main; deleted-album guard and hierarchical picker retained; counts unchanged |
| 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged |
| 2026-10-20 | patch | LR-VIEW-ZOOM: the 20 ms rule clears a slow-drag verdict only on a drag timed at full size; a drag that drafts small leaves it unchanged, as the code does; status unchanged |
| 2026-10-19 | patch | LR-VIEW-ZOOM: the slow slider-drag verdict is described as per photo and control, as the code keys it; status unchanged |
| 2026-10-15 | minor | LR-VIEW-ZOOM: a slow slider-drag verdict needs two drags over 20 ms and expires after 30 s (doubling to 240 s); hidden-window frame times are ignored; the slow set forgets its oldest entry, not all (issue #330); status unchanged |