mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
Merge PR #2735: LR-BEHAV-DRAGDROP: a drop's destination is named with its parent when another folder or album has the same name
Conflicts in docs/parity-checklist.md docs/whats-new.md: kept both sides (union).
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
//! How a drop's destination is named. A name alone is enough until two rows share it (two folders
|
||||
//! called `Home` on different disks, an album and another album of that name in a folder): then
|
||||
//! the label carries where the row sits, so the person can tell which one a drop would reach in
|
||||
//! the dragged label, the confirmation and the toast.
|
||||
|
||||
use lightcraft_catalog::{Album, AlbumId, Catalog, FolderNode};
|
||||
|
||||
/// Longest parent path shown, in characters: longer ones keep their last names behind `…/`.
|
||||
const MAX_PARENT: usize = 40;
|
||||
|
||||
/// The last names of `parent` that fit [`MAX_PARENT`], behind `…/` when names were left out.
|
||||
fn shorten(parent: &str) -> String {
|
||||
if parent.chars().count() <= MAX_PARENT {
|
||||
return parent.to_string();
|
||||
}
|
||||
let mut kept: Vec<&str> = Vec::new();
|
||||
let mut len = 2; // the `…/`
|
||||
for name in parent.rsplit(['/', '\\']).filter(|n| !n.is_empty()) {
|
||||
let add = name.chars().count() + usize::from(!kept.is_empty());
|
||||
if len + add > MAX_PARENT && !kept.is_empty() {
|
||||
break;
|
||||
}
|
||||
len += add;
|
||||
kept.push(name);
|
||||
}
|
||||
kept.reverse();
|
||||
format!("…/{}", kept.join("/"))
|
||||
}
|
||||
|
||||
/// The last name of a folder's path.
|
||||
fn base(path: &str) -> Option<&str> {
|
||||
path.rsplit(['/', '\\']).find(|n| !n.is_empty())
|
||||
}
|
||||
|
||||
/// The name a Folders row shows: a disk's own (`photos on tokyo`, where its path ends in a mount token such as
|
||||
/// `smb-share:server=tokyo,share=photos`), any other folder's last name.
|
||||
fn shown(n: &FolderNode) -> &str {
|
||||
if n.volume { &n.name } else { base(&n.path).unwrap_or(&n.name) }
|
||||
}
|
||||
|
||||
/// The row of the Folders tree at `path`.
|
||||
fn node_at<'a>(nodes: &'a [FolderNode], path: &str) -> Option<&'a FolderNode> {
|
||||
nodes.iter().find_map(|n| if n.path == path { Some(n) } else { node_at(&n.children, path) })
|
||||
}
|
||||
|
||||
/// The paths of the other rows of the Folders tree that are folders shown as `name`.
|
||||
fn same_named<'a>(nodes: &'a [FolderNode], path: &str, name: &str, out: &mut Vec<&'a str>) {
|
||||
for n in nodes {
|
||||
if n.selectable && n.path != path && shown(n) == name {
|
||||
out.push(&n.path);
|
||||
}
|
||||
same_named(&n.children, path, name, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder a path sits in, without the trailing separator; `None` at the root of a disk.
|
||||
fn parent(path: &str) -> Option<&str> {
|
||||
let trimmed = path.trim_end_matches(['/', '\\']);
|
||||
trimmed.rfind(['/', '\\']).filter(|at| *at > 0).map(|at| &trimmed[..at])
|
||||
}
|
||||
|
||||
/// How the folder at `path` is called as a destination: the name its row shows, and, when another row of the
|
||||
/// Folders tree has that name, the folder it is in (`Home (/Users/me/Pictures)`). A long parent is
|
||||
/// cut to its last names, unless that would make it read like another folder's of the same name
|
||||
/// (one the cut path is the end of, or that is cut to the same names): then it is given whole.
|
||||
pub fn folder(tree: &[FolderNode], path: &str) -> String {
|
||||
let name = node_at(tree, path).map_or_else(|| base(path).unwrap_or(path), shown);
|
||||
let mut others = Vec::new();
|
||||
same_named(tree, path, name, &mut others);
|
||||
if others.is_empty() {
|
||||
return name.to_string();
|
||||
}
|
||||
let Some(parent) = parent(path) else { return name.to_string() };
|
||||
let short = shorten(parent);
|
||||
// cut, it would read like another folder's path that ends in the same names (a mirror on another disk;
|
||||
// compared with one kind of separator, as `shorten` writes it)
|
||||
let tail = short.strip_prefix("…/");
|
||||
let clash = tail.is_some_and(|tail| {
|
||||
others.iter().filter_map(|o| self::parent(o)).any(|o| {
|
||||
let o = o.replace('\\', "/");
|
||||
o != parent.replace('\\', "/") && (o == tail || o.strip_suffix(tail).is_some_and(|head| head.ends_with('/')))
|
||||
})
|
||||
});
|
||||
format!("{name} ({})", if clash { parent } else { &short })
|
||||
}
|
||||
|
||||
/// The folders an album sits in, outermost first (`Travel 2026 › Trips`); empty at the top level.
|
||||
fn album_parents(cat: &Catalog, album: &Album) -> String {
|
||||
let mut names = Vec::new();
|
||||
let mut at: Option<AlbumId> = album.parent;
|
||||
// (a cycle can't be saved, but a walk must end whatever the data holds)
|
||||
while let Some(id) = at.filter(|_| names.len() < 64) {
|
||||
let Some(parent) = cat.album(id) else { break };
|
||||
names.push(parent.name.as_str());
|
||||
at = parent.parent;
|
||||
}
|
||||
names.reverse();
|
||||
names.join(" › ")
|
||||
}
|
||||
|
||||
/// How `album` is called as a destination: its name, and, when another album or album folder has
|
||||
/// that name somewhere else, the folders it is in (`Garden (Travel 2026)`). Twins in the same place
|
||||
/// have nothing to tell them apart by, and keep the plain name; so does one at the top level.
|
||||
pub fn album(cat: &Catalog, album: &Album) -> String {
|
||||
if !cat.albums().any(|o| o.id != album.id && o.name == album.name && o.parent != album.parent) {
|
||||
return album.name.clone();
|
||||
}
|
||||
let parents = album_parents(cat, album);
|
||||
if parents.is_empty() { album.name.clone() } else { format!("{} ({parents})", album.name) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn node(path: &str, children: Vec<FolderNode>) -> FolderNode {
|
||||
FolderNode {
|
||||
name: base(path).unwrap_or("/").into(),
|
||||
path: path.into(),
|
||||
count: 1,
|
||||
own: 1,
|
||||
volume: false,
|
||||
selectable: true,
|
||||
label: None,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_name_nobody_shares_stays_plain() {
|
||||
let tree = vec![node("/a", vec![node("/a/Home", vec![]), node("/a/Trip", vec![])])];
|
||||
assert_eq!(folder(&tree, "/a/Home"), "Home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_folders_with_one_name_are_told_apart_by_where_they_are() {
|
||||
let tree = vec![node("/a", vec![node("/a/Home", vec![])]), node("/b/pics", vec![node("/b/pics/Home", vec![])])];
|
||||
assert_eq!(folder(&tree, "/a/Home"), "Home (/a)");
|
||||
assert_eq!(folder(&tree, "/b/pics/Home"), "Home (/b/pics)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_of_the_same_name_that_cannot_be_dropped_on_does_not_count() {
|
||||
let mut other = node("/b/Home", vec![]);
|
||||
other.selectable = false;
|
||||
let tree = vec![node("/a/Home", vec![]), other];
|
||||
assert_eq!(folder(&tree, "/a/Home"), "Home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_disks_with_one_name_are_told_apart_by_where_they_are_mounted() {
|
||||
let mut a = node("/Volumes/photos", vec![]);
|
||||
let mut b = node("/mnt/photos", vec![]);
|
||||
a.volume = true;
|
||||
b.volume = true;
|
||||
let tree = vec![a, b];
|
||||
assert_eq!(folder(&tree, "/Volumes/photos"), "photos (/Volumes)");
|
||||
assert_eq!(folder(&tree, "/mnt/photos"), "photos (/mnt)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disk_is_named_as_its_row_shows_it_not_by_its_mount_token() {
|
||||
let mut gvfs = node("/run/user/1000/gvfs/smb-share:server=tokyo,share=photos", vec![]);
|
||||
gvfs.volume = true;
|
||||
gvfs.name = "photos on tokyo".into();
|
||||
let tree = vec![gvfs, node("/a/Home", vec![])];
|
||||
assert_eq!(folder(&tree, "/run/user/1000/gvfs/smb-share:server=tokyo,share=photos"), "photos on tokyo");
|
||||
// two rows showing that name still say where each is
|
||||
let mut other = node("/mnt/photos on tokyo", vec![]);
|
||||
other.volume = true;
|
||||
let mut again = tree[0].clone();
|
||||
again.children.clear();
|
||||
let tree = vec![again, other];
|
||||
assert_eq!(folder(&tree, "/mnt/photos on tokyo"), "photos on tokyo (/mnt)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_parent_keeps_its_last_names() {
|
||||
let deep = "/Volumes/tokyo/vault/photos/Asian/Japanese/Archive/2019";
|
||||
let tree = vec![node("/x/Home", vec![]), node(&format!("{deep}/Home"), vec![])];
|
||||
let label = folder(&tree, &format!("{deep}/Home"));
|
||||
assert_eq!(label, "Home (…/photos/Asian/Japanese/Archive/2019)");
|
||||
assert!(shorten("/a/b").len() < 10);
|
||||
// one name longer than the room is kept whole rather than dropped
|
||||
let long = "x".repeat(60);
|
||||
assert_eq!(shorten(&format!("/p/{long}")), format!("…/{long}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_long_parents_that_differ_only_near_the_start_are_given_whole() {
|
||||
let a = "/Volumes/NAS-A/Photography/Archive/Lightroom/2019";
|
||||
let b = "/Volumes/NAS-B/Photography/Archive/Lightroom/2019";
|
||||
let tree = vec![node(&format!("{a}/Home"), vec![]), node(&format!("{b}/Home"), vec![])];
|
||||
// cut to its last names the two would read the same
|
||||
assert_eq!(shorten(a), shorten(b));
|
||||
assert_eq!(folder(&tree, &format!("{a}/Home")), format!("Home ({a})"));
|
||||
assert_eq!(folder(&tree, &format!("{b}/Home")), format!("Home ({b})"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_parent_is_given_whole_when_a_short_path_ends_in_the_same_names() {
|
||||
let mirror = "/Volumes/Backup/Users/me/Pictures/Lightroom/2019";
|
||||
let home = "/Users/me/Pictures/Lightroom/2019";
|
||||
let tree = vec![node(&format!("{mirror}/Home"), vec![]), node(&format!("{home}/Home"), vec![])];
|
||||
assert_eq!(folder(&tree, &format!("{mirror}/Home")), format!("Home ({mirror})"));
|
||||
// (the short one is shown whole already)
|
||||
assert_eq!(folder(&tree, &format!("{home}/Home")), format!("Home ({home})"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_windows_parent_is_cut_at_its_backslashes() {
|
||||
let deep = r"C:\Users\me\Pictures\Lightroom\Archive\2019";
|
||||
let tree = vec![node("/x/Home", vec![]), node(&format!("{deep}\\Home"), vec![])];
|
||||
assert_eq!(folder(&tree, &format!("{deep}\\Home")), "Home (…/me/Pictures/Lightroom/Archive/2019)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_long_windows_parents_that_end_in_the_same_names_are_given_whole() {
|
||||
let backup = r"D:\Backup\Users\me\Pictures\Lightroom\Archive\2019";
|
||||
let home = r"C:\Users\me\Pictures\Lightroom\Archive\2019";
|
||||
let tree = vec![node(&format!("{backup}\\Home"), vec![]), node(&format!("{home}\\Home"), vec![])];
|
||||
assert_eq!(folder(&tree, &format!("{backup}\\Home")), format!("Home ({backup})"));
|
||||
assert_eq!(folder(&tree, &format!("{home}\\Home")), format!("Home ({home})"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_at_the_root_of_a_disk_has_no_parent_to_show() {
|
||||
let tree = vec![node("/Home", vec![]), node("/b/Home", vec![])];
|
||||
assert_eq!(folder(&tree, "/Home"), "Home");
|
||||
}
|
||||
|
||||
fn album_in(cat: &mut Catalog, name: &str, parent: Option<AlbumId>, folder: bool) -> AlbumId {
|
||||
let id = cat.alloc_album_id();
|
||||
let mut a = Album::new(id, name);
|
||||
a.parent = parent;
|
||||
a.folder = folder;
|
||||
cat.apply(lightcraft_catalog::Op::AddAlbum { album: a }).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn albums_with_one_name_in_different_folders_say_which() {
|
||||
let mut cat = Catalog::new();
|
||||
let trips = album_in(&mut cat, "Trips", None, true);
|
||||
let a = album_in(&mut cat, "Garden", Some(trips), false);
|
||||
let b = album_in(&mut cat, "Garden", None, false);
|
||||
let solo = album_in(&mut cat, "Portfolio", Some(trips), false);
|
||||
assert_eq!(album(&cat, cat.album(a).unwrap()), "Garden (Trips)");
|
||||
// the one at the top level is the plain name, so the two differ
|
||||
assert_eq!(album(&cat, cat.album(b).unwrap()), "Garden");
|
||||
assert_eq!(album(&cat, cat.album(solo).unwrap()), "Portfolio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn albums_with_one_name_in_the_same_place_cannot_be_told_apart_and_stay_plain() {
|
||||
let mut cat = Catalog::new();
|
||||
let a = album_in(&mut cat, "Garden", None, false);
|
||||
album_in(&mut cat, "Garden", None, false);
|
||||
assert_eq!(album(&cat, cat.album(a).unwrap()), "Garden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn albums_with_one_name_twice_in_one_folder_stay_plain_but_say_so_against_another_folder() {
|
||||
let mut cat = Catalog::new();
|
||||
let trips = album_in(&mut cat, "Trips", None, true);
|
||||
let a = album_in(&mut cat, "Garden", Some(trips), false);
|
||||
album_in(&mut cat, "Garden", Some(trips), false);
|
||||
assert_eq!(album(&cat, cat.album(a).unwrap()), "Garden");
|
||||
let elsewhere = album_in(&mut cat, "Garden", None, false);
|
||||
assert_eq!(album(&cat, cat.album(a).unwrap()), "Garden (Trips)");
|
||||
assert_eq!(album(&cat, cat.album(elsewhere).unwrap()), "Garden");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub mod brush_live;
|
||||
pub mod canvas_keys;
|
||||
mod command_error;
|
||||
pub mod control;
|
||||
pub mod destination;
|
||||
pub mod dialogs;
|
||||
pub mod docking;
|
||||
mod drop_overlay;
|
||||
|
||||
@@ -405,16 +405,19 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response
|
||||
/// Adds the dragged photos to the album and toasts how many went in (and how many Local ones were skipped).
|
||||
fn drop_photos_on_album(app: &mut LightcraftApp, ui: &egui::Ui, a: &Album, ids: Vec<u64>) {
|
||||
match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) {
|
||||
Ok(r) => app.toast(ui.ctx(), added_message(a, &r)),
|
||||
Ok(r) => {
|
||||
let name = crate::destination::album(&app.session.catalog, a);
|
||||
app.toast(ui.ctx(), added_message(&name, &r));
|
||||
}
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
}
|
||||
|
||||
/// "Added N photos to “album”", plus a note when Local photos were skipped.
|
||||
fn added_message(a: &Album, r: &serde_json::Value) -> String {
|
||||
fn added_message(name: &str, r: &serde_json::Value) -> String {
|
||||
let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0);
|
||||
let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0);
|
||||
let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n);
|
||||
let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, name, n = n);
|
||||
if skipped > 0 {
|
||||
msg.push_str(" · ");
|
||||
msg.push_str(&skipped_note(skipped));
|
||||
|
||||
@@ -380,7 +380,8 @@ fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::R
|
||||
return;
|
||||
}
|
||||
spring_open(ui, n, open_id, open);
|
||||
let name = std::path::Path::new(&n.path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| n.name.clone());
|
||||
// (the label, the confirmation and the toast say which folder when another row has the name)
|
||||
let name = crate::destination::folder(&app.caches.folder_tree(&app.session.catalog), &n.path);
|
||||
let photos: Vec<_> = ids.iter().filter_map(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(*id))).collect();
|
||||
let refused = if !n.selectable {
|
||||
Some(crate::i18n::tr("This row covers more than one disk: drop on a folder inside it"))
|
||||
@@ -637,6 +638,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F
|
||||
.on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites"))
|
||||
.clicked()
|
||||
{
|
||||
let name = crate::destination::folder(&app.caches.folder_tree(&app.session.catalog), path);
|
||||
move_photos_here(app, ui.ctx(), None, path, &name);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
@@ -346,3 +346,50 @@ fn a_question_left_open_while_another_library_opens_moves_nothing() {
|
||||
assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Folders `a/home` and `b/home` (two of one name) and `trip`, with a photo in each.
|
||||
fn twin_folders(tag: &str) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
|
||||
let root = std::env::temp_dir().join(format!("lc-folder-twin-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (trip, a, b) = (root.join("trip"), root.join("a").join("home"), root.join("b").join("home"));
|
||||
for (dir, file) in [(&trip, "beach.png"), (&a, "one.png"), (&b, "two.png")] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 120, 160, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(dir.join(file), png).unwrap();
|
||||
}
|
||||
(root, trip, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_folders_with_one_name_are_told_apart_in_the_label_the_question_and_the_toast() {
|
||||
let (root, trip, a, b) = twin_folders("label");
|
||||
let mut h = app(&[(trip.join("beach.png"), false), (a.join("one.png"), false), (b.join("two.png"), false)]);
|
||||
let toggle = widget(&h, &format!("libraryFolderToggle:{}", folder_key(&root))).unwrap();
|
||||
press(&mut h, toggle.center());
|
||||
release(&mut h, toggle.center());
|
||||
let beach = id_of(&h, "beach.png");
|
||||
let from = widget(&h, &format!("thumb:{beach}")).unwrap().center();
|
||||
press(&mut h, from);
|
||||
// When the photo is brought over each of the two: the label names the folder each one is in
|
||||
let (row_a, row_b) = (folder_row(&h, &a).expect("a/home"), folder_row(&h, &b).expect("b/home"));
|
||||
glide(&mut h, from, row_a.center());
|
||||
// (the label is read from the pointer as the frame before saw it)
|
||||
h.step();
|
||||
let label_a = hint(&h).expect("a label over a/home");
|
||||
glide(&mut h, row_a.center(), row_b.center());
|
||||
h.step();
|
||||
let label_b = hint(&h).expect("a label over b/home");
|
||||
assert!(label_a.starts_with("Move 1 photo to “home (") && label_a.ends_with("/a)”"), "{label_a}");
|
||||
assert!(label_b.starts_with("Move 1 photo to “home (") && label_b.ends_with("/b)”"), "{label_b}");
|
||||
// and the question asked on the release says the same
|
||||
release(&mut h, row_b.center());
|
||||
let Some(crate::state::Dialog::MovePhotos { name, .. }) = &h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) };
|
||||
assert!(name.starts_with("home (") && name.ends_with("/b)"), "{name}");
|
||||
// and so does the toast
|
||||
h.step();
|
||||
click(&mut h, "button:dialogOk");
|
||||
assert!(toast(&h).starts_with("Moved 1 photo to “home (") && toast(&h).contains("/b)”"), "{}", toast(&h));
|
||||
assert!(b.join("beach.png").is_file(), "moved into the one that was asked about");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Lightroom parity checklist
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-VIEW-FILMSTRIP: photos drag from the filmstrip to the same sidebar targets as from the grid; LRC-LIB-LIBFOLDERS: a deep row's count follows its name in a narrow panel; LR-FILT-FLAG / LRC-LIB-FILTERBAR: the flag toggles combine, Picked + Unflagged, #2588; LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; 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; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; 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; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-BEHAV-DRAGDROP: a drop's destination is named with its parent when another folder or album has the same name; LR-VIEW-FILMSTRIP: photos drag from the filmstrip to the same sidebar targets as from the grid; LRC-LIB-LIBFOLDERS: a deep row's count follows its name in a narrow panel; LR-FILT-FLAG / LRC-LIB-FILTERBAR: the flag toggles combine, Picked + Unflagged, #2588; LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; 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; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; 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; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; 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; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **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
|
||||
@@ -467,7 +467,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) |
|
||||
| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) |
|
||||
| LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) |
|
||||
| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (a drop-target card while they hover, then the import review); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) |
|
||||
| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`), `crates/ui-egui/src/destination.rs` | files → app (a drop-target card while they hover, then the import review); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar); when another folder has the same name, the dragged label, the Move question and the toast name the folder it is in (`home (/a)`); an album drop's toast does the same for an album of that name in another album folder (two in the same place have nothing to tell them apart and keep the plain name) |
|
||||
| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels.rs` | |
|
||||
| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests/dialog_fit.rs`, `crates/ui-egui/src/tests/dialogs.rs` (`a_click_outside_closes_the_dialog_when_settings_allow_it`) | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / OK row stay on screen; lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window; Settings → Dialogs → Close a dialog by clicking outside it (off by default, so a stray click never throws away a dialog's choices): a click pressed and released on the dimmed backdrop closes the dialog like Esc and doesn't reach the panels below, while a drag that ends outside (a slider pulled past the edge) or a click that closes a dropdown keeps it; the import review never closes this way (`clicked_backdrop`) |
|
||||
| LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`), `crates/ui-egui/src/sidebar.rs` (`SidebarLayout`), `crates/ui-egui/src/tests/panels.rs` | click the Albums, Local, By Date, Folders or Keywords header (or drive `ui.clickWidget` `sidebarSection:<albums\|local\|byDate\|folders\|keywords>`) to fold or unfold the section; a chevron before the title shows the state; the choice is kept across restarts (`sidebar` in the UI state; the `collapsedSidebar` list of older settings files is still read). the click itself is UI-only, like the By Date / Keywords disclosure triangles, and `cmd:view.sidebarSection` `{section, collapsed}` folds and unfolds for agents; photos dropped on a folded Albums section have no target |
|
||||
@@ -967,3 +967,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-17 | minor | LR-VIEW-PHOTOGRID, LR-VIEW-SQUAREGRID, LR-VIEW-FILMSTRIP, LR-VIEW-SURVEY, LR-VIEW-COMPARE, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: selection rings are theme tokens (3:1 or more against the grid; the active photo's 3 pt two-tone ring against the others' 1.5 pt), so the active photo is no longer a white ring lost on the light themes' grid; Compare, Reference and Survey frame their active photo and the People view its chosen person the same way; statuses unchanged |
|
||||
| 2026-10-21 | minor | LRC-LIB-LIBFOLDERS: in a narrow panel, the count (and a label's colour dot) of a deeply indented sidebar row follows its name, wholly past the panel's edge (scrolling sideways reaches it), instead of printing over the folder's icon and name; status unchanged (✅) |
|
||||
| 2026-10-23 | patch | LR-VIEW-FILMSTRIP: filmstrip cells drag photos with the grid's drag (selection, drop targets, count label; in Compare an unselected photo drags alone); a click works as before (in Compare it goes to the active side). Statuses and feature counts unchanged. |
|
||||
| 2026-10-23 | minor | LR-BEHAV-DRAGDROP: a drop's destination is named with the folder it is in when another folder or album has the same name; status unchanged (✅) |
|
||||
|
||||
@@ -101,6 +101,16 @@
|
||||
takes just that photo and selects it. In the Compare view, a photo that isn't selected is dragged alone and the
|
||||
selection, the active photo and the two photos on screen stay as they were. Clicking a filmstrip photo works as
|
||||
before: outside Compare it selects the photo, and in Compare a plain click puts the photo into the active side.
|
||||
### Dropping photos on a folder or album that shares its name
|
||||
- When photos are dragged onto a Folders row and another folder has the same name (two folders called `Home` on
|
||||
different disks), the label at the pointer, the Move Photos question and the message afterwards name the folder
|
||||
it is in, for example `Home (/Volumes/Archive/Photos)`, so you can tell which one you are about to move them
|
||||
into. Long parent paths keep their last names behind `…/`, unless that would read like another folder's path of the
|
||||
same name (a mirror on another disk): then the whole path is shown. A name that nothing else uses stays as it was, except that a network share's row is now
|
||||
named as the Folders list shows it (`photos on tokyo`) instead of by the last part of its mount path.
|
||||
- Photos dropped on an album that shares its name with an album in another folder say so too (`Garden (Travel
|
||||
2026)`). Two albums with the same name in the same place have nothing to tell them apart by and keep the plain
|
||||
name.
|
||||
|
||||
## 2026-10-22
|
||||
|
||||
|
||||
Reference in New Issue
Block a user