Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-15 01:26:47 -04:00
co-authored by Claude Opus 5.5
31 changed files with 984 additions and 23 deletions
+2
View File
@@ -1246,6 +1246,8 @@ mod tests_mask_effects;
#[cfg(test)]
mod tests_merge;
#[cfg(test)]
mod tests_move_to_share;
#[cfg(test)]
mod tests_organize;
#[cfg(test)]
mod tests_persist;
+25 -6
View File
@@ -433,12 +433,31 @@ impl MoveFs for RealFs {
}
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
use std::io::Write;
let bytes = std::fs::read(a)?;
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?;
f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| {
let _ = std::fs::remove_file(b);
})
if self.exists(b) {
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display())));
}
// Streamed (a large raw or video is never read into memory whole), into a hidden name
// beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so
// the partial file can't be removed either, leaves no truncated file under the photo's
// name for a retry to take for the photo.
let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
// a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't
// block the next
let partial = (0..1000u32)
.map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id())))
.find(|p| !self.exists(p))
.ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?;
let copied = (|| {
let mut from = std::fs::File::open(a)?;
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?;
std::io::copy(&mut from, &mut f)?;
f.sync_all()
})();
let placed = copied.and_then(|()| self.rename_no_replace(&partial, b));
if placed.is_err() {
let _ = std::fs::remove_file(&partial);
}
placed
}
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
+286
View File
@@ -0,0 +1,286 @@
//! Moving photos to a folder on a NAS or network share (`photo.moveToFolder`, which dropping them
//! on a Folders row runs), where the share can go away between the move and its undo:
//!
//! * The share offline when Undo runs: Undo is refused with the reason and keeps the step;
//! nothing is deleted, the library still points at the files where they are, and once the share
//! is back the same Undo moves them home.
//! * The share read-only when Undo runs: the same.
//! * The connection lost partway through a move: the files already moved go back, the library is
//! unchanged and no history step is left.
//! * One file the share can't give back during Undo: the files already brought back go out again,
//! so every photo points at its file and the step can run once the share answers.
//! * Across devices (a share is another volume): the file is copied, checked and only then
//! removed; the copy goes to a hidden name and takes the photo's name only once complete.
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde_json::json;
use crate::Session;
/// A scratch folder that goes away with the test, however it ends.
struct Scratch(std::path::PathBuf);
impl Scratch {
fn new(tag: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!("lc-share-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("local")).unwrap();
std::fs::create_dir_all(dir.join("share")).unwrap();
Scratch(dir)
}
fn path(&self, rel: &str) -> String {
self.0.join(rel).components().collect::<std::path::PathBuf>().to_string_lossy().to_string()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn add_file(s: &mut Session, path: &str, body: &str) -> PhotoId {
std::fs::write(path, body).unwrap();
let id = s.catalog.alloc_photo_id();
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string();
let p = Photo::new(id, Source::File { path: path.into() }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00");
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
}
fn file_of(s: &Session, id: PhotoId) -> String {
match &s.catalog.photo(id).unwrap().source {
Source::File { path } => path.clone(),
_ => panic!("not a file"),
}
}
/// Two photos on the local disk moved to the share.
fn moved_to_share(dir: &Scratch) -> (Session, PhotoId, PhotoId) {
let mut s = Session::new();
let a = add_file(&mut s, &dir.path("local/a.jpg"), "A");
std::fs::write(dir.path("local/a.xmp"), "<edits/>").unwrap();
let b = add_file(&mut s, &dir.path("local/b.jpg"), "B");
let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("share")})).unwrap();
assert_eq!(r["moved"], 2);
(s, a, b)
}
#[test]
fn undo_with_the_share_offline_is_refused_keeps_the_step_and_works_once_it_is_back() {
let dir = Scratch::new("offline");
let (mut s, a, b) = moved_to_share(&dir);
let steps = s.undo.len();
// the share goes offline: its folder is no longer there
let away = dir.0.join("share-unmounted");
std::fs::rename(dir.0.join("share"), &away).unwrap();
let e = s.execute("edit.undo", &json!({})).unwrap_err().to_string();
assert!(e.contains("can't move the files back"), "says why: {e}");
assert_eq!(s.undo.len(), steps, "the step stays, to undo once the share is back");
// nothing deleted, nothing half-moved, and the library still points at the share
assert!(away.join("a.jpg").is_file() && away.join("a.xmp").is_file() && away.join("b.jpg").is_file());
assert!(!dir.0.join("local/a.jpg").exists() && !dir.0.join("local/b.jpg").exists());
assert_eq!(file_of(&s, a), dir.path("share/a.jpg"));
assert_eq!(file_of(&s, b), dir.path("share/b.jpg"));
// the share is back: the same Undo moves them home, sidecar and all
std::fs::rename(&away, dir.0.join("share")).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(std::fs::read_to_string(dir.path("local/a.jpg")).unwrap(), "A");
assert!(dir.0.join("local/a.xmp").is_file() && dir.0.join("local/b.jpg").is_file());
assert_eq!(file_of(&s, a), dir.path("local/a.jpg"));
assert_eq!(file_of(&s, b), dir.path("local/b.jpg"));
}
#[cfg(unix)]
#[test]
fn undo_from_a_read_only_share_is_refused_and_deletes_nothing() {
use std::os::unix::fs::PermissionsExt;
let dir = Scratch::new("readonly");
let (mut s, a, _) = moved_to_share(&dir);
let steps = s.undo.len();
std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o555)).unwrap();
// running as root ignores permissions: nothing to check there
let root = std::fs::write(dir.0.join("share/probe"), "x").is_ok();
if !root {
assert!(s.execute("edit.undo", &json!({})).is_err());
assert_eq!(s.undo.len(), steps, "the step stays");
assert!(dir.0.join("share/a.jpg").is_file() && dir.0.join("share/b.jpg").is_file(), "nothing deleted");
assert!(!dir.0.join("local/a.jpg").exists(), "no second copy left behind");
assert_eq!(file_of(&s, a), dir.path("share/a.jpg"));
}
std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o755)).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(dir.0.join("local/a.jpg").is_file());
}
/// The model volume spells paths with `/`; the code under test joins them with the host's separator.
fn host(path: &str) -> String {
path.replace('/', std::path::MAIN_SEPARATOR_STR)
}
fn model_session(fs_files: &[&str]) -> (Session, Vec<PhotoId>) {
let mut s = Session::new();
let ids = fs_files
.iter()
.map(|path| {
let id = s.catalog.alloc_photo_id();
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string();
let p = Photo::new(id, Source::File { path: host(path) }, &name, "JPEG", 1, 1, "2026-01-01T00:00:00");
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
})
.collect();
(s, ids)
}
fn plans(s: &Session, ids: &[PhotoId], into: &str) -> Vec<crate::rename::RenamePlan> {
ids.iter()
.map(|id| {
let from = file_of(s, *id);
let name = std::path::Path::new(&from).file_name().unwrap().to_string_lossy().to_string();
crate::rename::RenamePlan {
id: id.0,
from: name.clone(),
to: name.clone(),
from_path: Some(from),
to_path: Some(host(&format!("{into}/{name}"))),
}
})
.collect()
}
/// Every photo points at a file the model volume holds (or, for `offline`, one it held).
fn assert_linked(s: &Session, fs: &crate::rename::tests::FakeFs, offline: &[&str]) {
let files = fs.listing();
for p in s.catalog.photos() {
let Source::File { path } = &p.source else { continue };
let path = path.replace('\\', "/");
assert!(
files.iter().any(|(f, _)| *f == path) || offline.contains(&path.as_str()),
"photo {} points at {path}, which isn't there: {files:?}",
p.id.0
);
}
}
#[test]
fn a_connection_lost_partway_through_a_move_puts_back_what_moved() {
let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B"), ("/local/c.jpg", "C")]);
let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg", "/local/c.jpg"]);
let plans = plans(&s, &ids, "/share");
// the share stops answering when the second file goes
fs.fail.borrow_mut().push("/local/b.jpg".into());
let steps = s.undo.len();
assert!(s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).is_err());
assert_eq!(
fs.listing(),
vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into()), ("/local/c.jpg".into(), "C".into())],
"all back home"
);
assert_eq!(s.undo.len(), steps, "no step for a move that didn't happen");
assert_linked(&s, &fs, &[]);
}
#[test]
fn a_file_the_share_cannot_give_back_rolls_the_whole_undo_back() {
let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B")]);
let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg"]);
let plans = plans(&s, &ids, "/share");
s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).unwrap();
assert_eq!(fs.listing(), vec![("/share/a.jpg".into(), "A".into()), ("/share/b.jpg".into(), "B".into())]);
// b can't be read back: the share answers for a but not for b
fs.fail.borrow_mut().push("/share/b.jpg".into());
let e = s.undo_step_with(&fs).unwrap_err().to_string();
assert!(e.contains("can't move the files back"), "{e}");
// a came back before b failed: it went out again, so the step is whole and every photo
// points at its file, none lost
assert_linked(&s, &fs, &[]);
let contents: Vec<String> = fs.listing().into_iter().map(|(_, c)| c).collect();
assert_eq!(contents, vec!["A".to_string(), "B".to_string()], "both files still exist once: {:?}", fs.listing());
// the share answers again: undoing whatever is left brings both home
fs.fail.borrow_mut().clear();
while s.undo_step_with(&fs).is_ok() && fs.listing().iter().any(|(p, _)| p.starts_with("/share")) {}
assert_eq!(fs.listing(), vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into())]);
assert_linked(&s, &fs, &[]);
}
/// Where this machine has a second file system to write to (Linux's `/dev/shm`), a real move
/// across devices: copied, checked, the original removed, and Undo copies it back. Skipped where
/// there is none (a network share can't be mounted in a test).
#[test]
fn a_move_across_devices_copies_checks_and_removes_and_undoes_the_same_way() {
let dir = Scratch::new("devices");
let Some(other) = other_device(&dir.0) else { return };
let share = other.join(format!("lc-share-other-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&share);
std::fs::create_dir_all(&share).unwrap();
let mut s = Session::new();
let body: String = (0..300_000).map(|i| char::from(b'a' + (i % 26) as u8)).collect();
let a = add_file(&mut s, &dir.path("local/a.jpg"), &body);
let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": share.to_string_lossy()}));
let moved =
r.is_ok() && std::fs::read_to_string(share.join("a.jpg")).ok().as_deref() == Some(body.as_str()) && !dir.0.join("local/a.jpg").exists();
let undone = moved
&& s.execute("edit.undo", &json!({})).is_ok()
&& std::fs::read_to_string(dir.0.join("local/a.jpg")).ok().as_deref() == Some(body.as_str());
let gone_from_share = !share.join("a.jpg").exists();
let _ = std::fs::remove_dir_all(&share);
assert!(moved, "moved across devices: {r:?}");
assert!(undone, "and back");
assert!(gone_from_share, "no copy left on the other device");
}
/// A writable folder on another device than `here`, if there is one.
fn other_device(here: &std::path::Path) -> Option<std::path::PathBuf> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let dev = std::fs::metadata(here).ok()?.dev();
["/dev/shm", "/run/shm"]
.iter()
.map(std::path::PathBuf::from)
.find(|p| {
std::fs::metadata(p).is_ok_and(|m| m.dev() != dev)
&& std::fs::write(p.join(format!(".lc-probe-{}", std::process::id())), b"x").is_ok()
})
.inspect(|p| {
let _ = std::fs::remove_file(p.join(format!(".lc-probe-{}", std::process::id())));
})
}
#[cfg(not(unix))]
{
let _ = here;
None
}
}
#[test]
fn the_copy_across_volumes_is_whole_or_absent_and_never_replaces() {
use crate::rename::MoveFs;
let dir = Scratch::new("copy");
let body: Vec<u8> = (0..(3usize << 20) + 17).map(|i| (i % 251) as u8).collect();
let (from, to) = (dir.0.join("local/big.raw"), dir.0.join("share/big.raw"));
std::fs::write(&from, &body).unwrap();
crate::rename::RealFs.copy_no_replace(&from, &to).unwrap();
assert_eq!(std::fs::read(&to).unwrap(), body);
// nothing left under a hidden name beside it
let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect();
assert_eq!(left, vec![std::ffi::OsString::from("big.raw")], "{left:?}");
// a partial left by a copy the share cut off doesn't block the next copy
let stale = dir.0.join(format!("share/.again.raw.lc-partial-{}-0", std::process::id()));
std::fs::write(&stale, b"cut off").unwrap();
crate::rename::RealFs.copy_no_replace(&from, &dir.0.join("share/again.raw")).unwrap();
assert_eq!(std::fs::read(dir.0.join("share/again.raw")).unwrap(), body);
std::fs::remove_file(&stale).unwrap();
std::fs::remove_file(dir.0.join("share/again.raw")).unwrap();
// a copy that can't start (its source unreadable) leaves nothing at all under the photo's name
let gone = dir.0.join("local/missing.raw");
assert!(crate::rename::RealFs.copy_no_replace(&gone, &dir.0.join("share/missing.raw")).is_err());
assert!(!dir.0.join("share/missing.raw").exists());
let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect();
assert_eq!(left.len(), 1, "no partial file left: {left:?}");
// a name taken is never replaced, and what is there stays
std::fs::write(&to, b"other").unwrap();
assert!(crate::rename::RealFs.copy_no_replace(&from, &to).is_err());
assert_eq!(std::fs::read(&to).unwrap(), b"other");
}
+5 -1
View File
@@ -289,5 +289,9 @@
"Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
"Already in “{}”": "Již v „{}“",
"Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí."
}
+5 -1
View File
@@ -289,5 +289,9 @@
"Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesichter in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-Zusammenfügung mit {mp} hinzugefügt: die volle Größe, {full}, braucht mehr Arbeitsspeicher, als dieser Computer hat",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})",
"Already in “{}”": "Bereits in „{}“",
"Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben",
"Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück."
}
+9
View File
@@ -54,6 +54,9 @@
"Add to Favorites": "Zu Favoriten hinzufügen",
"Add to Local": "Zu „Lokal“ hinzufügen",
"Add to My Photos": "Zu „Meine Fotos“ hinzufügen",
"Can't be moved here": "Hierher nicht verschiebbar",
"This row covers more than one disk: drop on a folder inside it": "Diese Zeile umfasst mehr als ein Laufwerk: auf einem Ordner darin ablegen",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Lokale Fotos sind noch nicht in der Bibliothek: erst zu „Meine Fotos“ hinzufügen, dann verschieben",
"Add to Selected Photos": "Zu ausgewählten Fotos hinzufügen",
"Add to Target Album": "Zu Zielalbum hinzufügen",
"Add to this mask": "Zu dieser Maske hinzufügen",
@@ -289,6 +292,12 @@
"Concrete Cool": "Kühler Beton",
"Confirm before deleting": "Vor dem Löschen bestätigen",
"Confirm before moving photos to Recently Deleted": "Vor dem Verschieben nach „Zuletzt gelöscht“ bestätigen",
"Move Photos": "Fotos verschieben",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Ihre Dateien und XMP-Filialdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.",
"Don't ask again": "Nicht mehr fragen",
"Another library was opened since the photos were dropped: nothing was moved": "Seit dem Ablegen der Fotos wurde eine andere Bibliothek geöffnet: Es wurde nichts verschoben",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet es wieder ein",
"Confirm before moving photos to another folder on disk": "Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen",
"Constrain Crop": "Zuschnitt beschränken",
"Continue": "Fortfahren",
"Continue Without Saving": "Ohne Speichern fortfahren",
+5 -1
View File
@@ -289,5 +289,9 @@
"Renames {n} face{} across the library. The files are not changed.": "Cambia el nombre de {n} cara{} en toda la biblioteca. Los archivos no se modifican.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” ya es una persona. Combinarlas deja una sola; Deshacer las separa de nuevo.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Combinación {what} añadida a {mp}: el tamaño completo, {full}, necesita más memoria de la que tiene este ordenador",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Combinación {what} añadida a {mp}, el tamaño máximo de una panorámica (tamaño completo: {full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Combinación {what} añadida a {mp}, el tamaño máximo de una panorámica (tamaño completo: {full})",
"Already in “{}”": "Ya está en “{}”",
"Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”",
"Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve."
}
+9
View File
@@ -39,6 +39,9 @@
"Add to Favorites": "Añadir a Favoritos",
"Add to Local": "Añadir a Local",
"Add to My Photos": "Añadir a Mis fotos",
"Can't be moved here": "No se pueden mover aquí",
"This row covers more than one disk: drop on a folder inside it": "Esta fila abarca más de un disco: suéltalas en una carpeta dentro de ella",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Las fotos locales aún no están en la biblioteca: primero Añadir a Mis fotos y luego muévelas",
"Add to Selected Photos": "Añadir a las fotos seleccionadas",
"Add to Target Album": "Añadir al álbum de destino",
"Add to this mask": "Añadir a esta máscara",
@@ -278,6 +281,12 @@
"Concrete Cool": "Hormigón frío",
"Confirm before deleting": "Confirmar antes de eliminar",
"Confirm before moving photos to Recently Deleted": "Confirmar antes de mover fotos a Eliminadas recientemente",
"Move Photos": "Mover fotos",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Sus archivos y archivos XMP auxiliares se mueven en el disco. Editar → Deshacer los devuelve.",
"Don't ask again": "No volver a preguntar",
"Another library was opened since the photos were dropped: nothing was moved": "Se abrió otra biblioteca después de soltar las fotos: no se movió nada",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos a otra carpeta del disco lo vuelve a activar",
"Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos a otra carpeta del disco",
"Constrain Crop": "Restringir recorte",
"Continue": "Continuar",
"Continue Without Saving": "Continuar sin guardar",
+5 -1
View File
@@ -289,5 +289,9 @@
"Renames {n} face{} across the library. The files are not changed.": "Renomme {n} visage{} dans la bibliothèque. Les fichiers ne sont pas modifiés.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "« {to} » est déjà une personne. Les combiner n’en fait qu’une ; Annuler les sépare de nouveau.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Fusion de {what} ajoutée en {mp} : la taille complète, {full}, demande plus de mémoire que n’en a cet ordinateur",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Fusion de {what} ajoutée en {mp}, la plus grande taille possible pour un panorama (taille complète : {full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Fusion de {what} ajoutée en {mp}, la plus grande taille possible pour un panorama (taille complète : {full})",
"Already in “{}”": "Déjà dans « {} »",
"Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »",
"Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place."
}
+9
View File
@@ -39,6 +39,9 @@
"Add to Favorites": "Ajouter aux favoris",
"Add to Local": "Ajouter à Local",
"Add to My Photos": "Ajouter à Mes photos",
"Can't be moved here": "Impossible de les déplacer ici",
"This row covers more than one disk: drop on a folder inside it": "Cette ligne couvre plusieurs disques : déposez-les sur un dossier qu’elle contient",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Les photos locales ne sont pas encore dans la bibliothèque : Ajouter à Mes photos d’abord, puis déplacez-les",
"Add to Selected Photos": "Ajouter aux photos sélectionnées",
"Add to Target Album": "Ajouter à l'album cible",
"Add to this mask": "Ajouter à ce masque",
@@ -263,6 +266,12 @@
"Concrete Cool": "Béton froid",
"Confirm before deleting": "Confirmer avant de supprimer",
"Confirm before moving photos to Recently Deleted": "Confirmer avant de déplacer des photos vers Supprimées récemment",
"Move Photos": "Déplacer les photos",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Leurs fichiers et fichiers annexes XMP sont déplacés sur le disque. Modifier → Annuler les remet en place.",
"Don't ask again": "Ne plus demander",
"Another library was opened since the photos were dropped: nothing was moved": "Une autre bibliothèque a été ouverte depuis le dépôt des photos : rien n’a été déplacé",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Réglages → Confirmer avant de déplacer des photos vers un autre dossier sur le disque le réactive",
"Confirm before moving photos to another folder on disk": "Confirmer avant de déplacer des photos vers un autre dossier sur le disque",
"Constrain Crop": "Contraindre le recadrage",
"Continue": "Continuer",
"Continue Without Saving": "Continuer sans enregistrer",
+4
View File
@@ -13,6 +13,7 @@
"Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}",
"Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}",
"All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします",
"Already in “{}”": "すでに「{}」にあります",
"Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用",
"Automatic ({} MB)": "自動({}MB)",
"Click {}:1": "クリック時 {}:1",
@@ -39,7 +40,10 @@
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraftのウィンドウを開けませんでした: {e}\n\n詳しくはログファイルをご覧ください: {path}",
"Matches: {}": "一致条件:{}",
"Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%",
"Move {n} photo{} to “{}”": "{n}枚を「{1}」に移動{0:.0}",
"Move {n} photo{} to “{}”?": "{n}枚を「{1}」に移動しますか?{0:.0}",
"Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n}枚を「{1}」に移動しました。編集 → 取り消す で元に戻せます。{0:.0}",
"Named ({named_n})": "名前付き({named_n})",
"Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)",
"Preset: {name}": "プリセット:{name}",
+9
View File
@@ -40,6 +40,9 @@
"Add to Favorites": "お気に入りに追加",
"Add to Local": "ローカルに追加",
"Add to My Photos": "マイフォトに追加",
"Can't be moved here": "ここには移動できません",
"This row covers more than one disk: drop on a folder inside it": "この行は複数のディスクにまたがっています。中のフォルダにドロップしてください",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "ローカルの写真はまだライブラリにありません。先に「マイフォトに追加」してから移動してください",
"Add to Selected Photos": "選択した写真に追加",
"Add to Target Album": "追加先アルバムに追加",
"Add to this mask": "このマスクに追加",
@@ -259,6 +262,12 @@
"Concrete Cool": "クールコンクリート",
"Confirm before deleting": "削除前に確認",
"Confirm before moving photos to Recently Deleted": "「最近削除した写真」に移動する前に確認",
"Move Photos": "写真を移動",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "ファイルと XMP サイドカーがディスク上で移動します。編集 → 取り消す で元に戻せます。",
"Don't ask again": "今後確認しない",
"Another library was opened since the photos were dropped: nothing was moved": "写真をドロップした後に別のライブラリが開かれました。何も移動していません",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「ディスク上の別のフォルダに移動する前に確認」で元に戻せます",
"Confirm before moving photos to another folder on disk": "ディスク上の別のフォルダに移動する前に確認",
"Constrain Crop": "切り抜きを制限",
"Continue": "続行",
"Contrast": "コントラスト",
+5 -1
View File
@@ -289,5 +289,9 @@
"Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
"Already in “{}”": "Już w „{}”",
"Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je."
}
@@ -17,6 +17,7 @@
"All {n} selected photos move by the same amount.": "Todas as {n} fotos selecionadas se movem pela mesma quantidade.",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Já na biblioteca, em Excluídos recentemente: {0} foto{1} selecionada{1} · clique com o botão direito → Restaurar, ou Excluir permanentemente para importar de novo",
"Already in the library: {} photo{} selected in All Photos": "Já na biblioteca: {0} foto{1} selecionada{1} em Todas as fotos",
"Already in “{}”": "Já está em “{}”",
"Auto ({})": "Automático ({})",
"Auto Sync: edits apply to {n} photos": "Sincronização automática: edições aplicadas a {n} fotos",
"Automatic ({} MB)": "Automático ({} MB)",
@@ -56,7 +57,10 @@
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}",
"Matches: {}": "Correspondências: {}",
"Merging… {stage} {:.0}%": "Mesclando… {stage} {:.0}%",
"Move {n} photo{} to “{}”": "Mover {n} foto{0} para “{1}”",
"Move {n} photo{} to “{}”?": "Mover {n} foto{0} para “{1}”?",
"Move {what} to Recently Deleted?": "Mover {what} para Excluídos recentemente?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} para “{1}”. Editar → Desfazer as traz de volta.",
"Named ({named_n})": "Nomeadas ({named_n})",
"Paste into {n} photo{} — only settings that were copied are pasted": "Colar em {n} foto{} — somente os ajustes copiados são colados",
"Preset: {name}": "Predefinição: {name}",
+9
View File
@@ -41,6 +41,9 @@
"Add to Favorites": "Adicionar aos favoritos",
"Add to Local": "Adicionar a Local",
"Add to My Photos": "Adicionar às Minhas fotos",
"Can't be moved here": "Não podem ser movidas para cá",
"This row covers more than one disk: drop on a folder inside it": "Esta linha abrange mais de um disco: solte em uma pasta dentro dela",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "As fotos locais ainda não estão na biblioteca: primeiro Adicionar às Minhas fotos, depois mova-as",
"Add to Selected Photos": "Adicionar às fotos selecionadas",
"Add to Target Album": "Adicionar ao álbum de destino",
"Add to this mask": "Adicionar a esta máscara",
@@ -271,6 +274,12 @@
"Concrete Cool": "Concreto frio",
"Confirm before deleting": "Confirmar antes de excluir",
"Confirm before moving photos to Recently Deleted": "Confirmar antes de mover as fotos para Excluídos recentemente",
"Move Photos": "Mover fotos",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Os arquivos e os arquivos XMP auxiliares são movidos no disco. Editar → Desfazer os traz de volta.",
"Don't ask again": "Não perguntar novamente",
"Another library was opened since the photos were dropped: nothing was moved": "Outra biblioteca foi aberta depois que as fotos foram soltas: nada foi movido",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos para outra pasta no disco o reativa",
"Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos para outra pasta no disco",
"Constrain Crop": "Restringir recorte",
"Continue": "Continuar",
"Continue Without Saving": "Continuar sem salvar",
+5 -1
View File
@@ -289,5 +289,9 @@
"Renames {n} face{} across the library. The files are not changed.": "Переименовывает {n} лиц в библиотеке. Файлы не изменяются.{:.0}",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже есть среди людей. Объединение оставляет одного человека; отмена снова разделяет их.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: слияние добавлено в размере {mp}: для полного размера ({full}) не хватает памяти этого компьютера",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: слияние добавлено в размере {mp} — это наибольший размер панорамы (полный размер: {full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: слияние добавлено в размере {mp} — это наибольший размер панорамы (полный размер: {full})",
"Already in “{}”": "Уже в «{}»",
"Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их."
}
+9
View File
@@ -24,6 +24,9 @@
"Add to Album": "Добавить в альбом",
"Add to Favorites": "Добавить в избранное",
"Add to My Photos": "Добавить в «Мои фото»",
"Can't be moved here": "Сюда переместить нельзя",
"This row covers more than one disk: drop on a folder inside it": "Эта строка охватывает несколько дисков: перетащите на папку внутри неё",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальных фото ещё нет в библиотеке: сначала «Добавить в «Мои фото»», затем перемещайте",
"Add to Selected Photos": "Добавить к выбранным фото",
"Add to Target Album": "Добавить в целевой альбом",
"Add to this mask": "Добавить к этой маске",
@@ -222,6 +225,12 @@
"Concrete Cool": "Холодный бетон",
"Confirm before deleting": "Подтверждать удаление",
"Confirm before moving photos to Recently Deleted": "Подтверждать перемещение фото в «Недавно удалённые»",
"Move Photos": "Перемещение фото",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Их файлы и XMP-файлы перемещаются на диске. Правка → Отменить вернёт их.",
"Don't ask again": "Больше не спрашивать",
"Another library was opened since the photos were dropped: nothing was moved": "После перетаскивания фото была открыта другая библиотека: ничего не перемещено",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Настройки → «Подтверждать перемещение фото в другую папку на диске» включает его снова",
"Confirm before moving photos to another folder on disk": "Подтверждать перемещение фото в другую папку на диске",
"Constrain Crop": "Ограничить кадрирование",
"Continue Without Saving": "Продолжить без сохранения",
"Contrast": "Контрастность",
+5 -1
View File
@@ -289,5 +289,9 @@
"Renames {n} face{} across the library. The files are not changed.": "Перейменовує {n} облич у бібліотеці. Файли не змінюються.{:.0}",
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже є серед людей. Об’єднання лишає одну людину; скасування знову розділяє їх.",
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: об’єднання додано в розмірі {mp}: для повного розміру ({full}) бракує пам’яті цього комп’ютера",
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: об’єднання додано в розмірі {mp} — це найбільший розмір панорами (повний розмір: {full})"
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: об’єднання додано в розмірі {mp} — це найбільший розмір панорами (повний розмір: {full})",
"Already in “{}”": "Уже в «{}»",
"Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}",
"Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх."
}
+9
View File
@@ -381,6 +381,9 @@
"Add to Favorites": "Додати до улюблених",
"Add to Local": "Додати до локальних",
"Add to My Photos": "Додати до моїх фото",
"Can't be moved here": "Сюди перемістити не можна",
"This row covers more than one disk: drop on a folder inside it": "Цей рядок охоплює кілька дисків: перетягніть на теку всередині нього",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальних фото ще немає в бібліотеці: спершу «Додати до моїх фото», потім переміщуйте",
"Add to Selected Photos": "Додати до вибраних фото",
"Add to Target Album": "Додати до цільового альбому",
"Add to this mask": "Додати до цієї маски",
@@ -552,6 +555,12 @@
"Concrete Cool": "Холодний бетон",
"Confirm before deleting": "Підтверджувати перед видаленням",
"Confirm before moving photos to Recently Deleted": "Підтверджувати перед переміщенням фото до нещодавно видалених",
"Move Photos": "Переміщення фото",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Їхні файли та XMP-файли переміщуються на диску. Редагування → Скасувати поверне їх.",
"Don't ask again": "Більше не питати",
"Another library was opened since the photos were dropped: nothing was moved": "Після перетягування фото було відкрито іншу бібліотеку: нічого не переміщено",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Налаштування → «Підтверджувати перед переміщенням фото до іншої теки на диску» вмикає його знову",
"Confirm before moving photos to another folder on disk": "Підтверджувати перед переміщенням фото до іншої теки на диску",
"Constrain Crop": "Обмежити кадрування",
"Continue": "Продовжити",
"Convert to DNG": "Перетворити на DNG",
@@ -13,6 +13,7 @@
"Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}",
"Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}",
"All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。",
"Already in “{}”": "已在“{}”中",
"Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片",
"Automatic ({} MB)": "自动({0} MB)",
"Click {}:1": "单击 {0}:1",
@@ -39,7 +40,10 @@
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 无法打开窗口:{e}\n\n详细信息见日志文件:{path}",
"Matches: {}": "匹配项:{0}",
"Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%",
"Move {n} photo{} to “{}”": "将 {n} 张照片移到“{1}”{0:.0}",
"Move {n} photo{} to “{}”?": "将 {n} 张照片移到“{1}”?{0:.0}",
"Move {what} to Recently Deleted?": "将 {what} 移到最近删除?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已将 {n} 张照片移到“{1}”{0:.0}。编辑 → 撤销 可移回。",
"Named ({named_n})": "已命名({named_n})",
"Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}",
"Preset: {name}": "预设:{name}",
+9
View File
@@ -41,6 +41,9 @@
"Add to Favorites": "添加到收藏夹",
"Add to Local": "添加到本地",
"Add to My Photos": "添加到我的照片",
"Can't be moved here": "无法移到这里",
"This row covers more than one disk: drop on a folder inside it": "此行涵盖多个磁盘:请拖放到其中的文件夹",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "本地照片尚未在图库中:请先“添加到我的照片”,再移动",
"Add to Selected Photos": "添加到所选照片",
"Add to Target Album": "添加到目标相册",
"Add to this mask": "添加到此蒙版",
@@ -269,6 +272,12 @@
"Concrete Cool": "冷调混凝土",
"Confirm before deleting": "删除前确认",
"Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认",
"Move Photos": "移动照片",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它们的文件和 XMP 附属文件会在磁盘上移动。编辑 → 撤销 可移回。",
"Don't ask again": "不再询问",
"Another library was opened since the photos were dropped: nothing was moved": "拖放照片后打开了另一个图库:未移动任何内容",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "设置 → “将照片移到磁盘上其他文件夹前确认”可重新开启",
"Confirm before moving photos to another folder on disk": "将照片移到磁盘上其他文件夹前确认",
"Constrain Crop": "限制裁剪",
"Continue": "继续",
"Continue Without Saving": "不保存继续",
@@ -14,6 +14,7 @@
"Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}",
"Adding photos… {} of {}": "正在新增照片… {}/{}",
"All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。",
"Already in “{}”": "已在「{}」中",
"Auto ({})": "自動({})",
"Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片",
"Automatic ({} MB)": "自動({} MB)",
@@ -53,7 +54,10 @@
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}",
"Matches: {}": "符合條件:{}",
"Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%",
"Move {n} photo{} to “{}”": "將 {n} 張照片移到「{1}」{0:.0}",
"Move {n} photo{} to “{}”?": "將 {n} 張照片移到「{1}」?{0:.0}",
"Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?",
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已將 {n} 張照片移到「{1}」{0:.0}。編輯 → 復原 可移回。",
"Named ({named_n})": "已命名({named_n})",
"Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定",
"Preset: {name}": "預設集:{name}",
+9
View File
@@ -41,6 +41,9 @@
"Add to Favorites": "加入喜好項目",
"Add to Local": "加入本機",
"Add to My Photos": "加入我的照片",
"Can't be moved here": "無法移到這裡",
"This row covers more than one disk: drop on a folder inside it": "此列涵蓋多個磁碟:請拖放到其中的資料夾",
"Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在圖庫中:請先「加入我的照片」,再移動",
"Add to Selected Photos": "加入選取的照片",
"Add to Target Album": "加入目標相簿",
"Add to this mask": "加入此遮色片",
@@ -269,6 +272,12 @@
"Concrete Cool": "冷調混凝土",
"Confirm before deleting": "刪除前確認",
"Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認",
"Move Photos": "移動照片",
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它們的檔案和 XMP 附屬檔案會在磁碟上移動。編輯 → 復原 可移回。",
"Don't ask again": "不再詢問",
"Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個圖庫:未移動任何內容",
"Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「將照片移至磁碟上其他資料夾前確認」可重新開啟",
"Confirm before moving photos to another folder on disk": "將照片移至磁碟上其他資料夾前確認",
"Constrain Crop": "限制裁切範圍",
"Continue": "繼續",
"Continue Without Saving": "繼續使用但不儲存",
+2
View File
@@ -79,6 +79,8 @@ mod tests_error_kinds;
#[cfg(test)]
mod tests_filmstrip;
#[cfg(test)]
mod tests_folder_drop;
#[cfg(test)]
mod tests_grading;
#[cfg(test)]
mod tests_grid;
+34
View File
@@ -257,6 +257,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Merge { opts } => opts.title(),
Dialog::Settings { .. } => "Settings",
Dialog::ConfirmDelete { .. } => "Delete Photos",
Dialog::MovePhotos { .. } => "Move Photos",
Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog",
Dialog::SaveEffect { .. } => "New Effect Preset",
Dialog::ManageEffects { .. } => "Manage Effect Presets",
@@ -1216,6 +1217,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
}
Dialog::MovePhotos { ids, name, dont_ask, .. } => {
let n = ids.len();
ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n));
ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim));
ui.add_space(4.0);
let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again"));
crate::widgets::register(ui.ctx(), "check:moveDontAsk", c.rect);
c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on"));
}
d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d),
Dialog::LightroomCatalog { folder, catalog } => {
ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?"));
@@ -1338,6 +1348,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::FaceModel { .. } if !informational => "Install",
Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete",
Dialog::SaveEffect { .. } => "Create",
Dialog::MovePhotos { .. } => "Move",
// (not "Merge": that is photo merging, HDR / panorama, in some languages)
Dialog::MoveKeyword { .. } => "Merge Keywords",
Dialog::KeywordSet { .. } => "Save",
@@ -1441,6 +1452,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
crate::panels::grid::toast_skipped(app, ctx, &r);
close = true;
}
// a move says how it went, or why nothing moved
Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => {
if let Dialog::MovePhotos { name, .. } = &dlg {
let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name);
app.toast(ctx, text);
}
close = true;
}
Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => {
app.toast(ctx, e);
close = true;
}
Ok(_) if keeps_open(app, &dlg) => {
if let Dialog::SamModel { error, .. } = &mut dlg {
*error = None;
@@ -1857,6 +1880,17 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
Dialog::Merge { opts } => crate::merge::start_final(app, opts),
Dialog::Import { opts } => crate::import::start(app, opts),
Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})),
Dialog::MovePhotos { ids, folder, dont_ask, library, .. } => {
// another library opened meanwhile (from the menu bar, an agent): these numbers are
// other photos there
if *library != app.session.library_generation() {
return Err(crate::i18n::tr("Another library was opened since the photos were dropped: nothing was moved").to_string());
}
if *dont_ask {
app.ui.settings.ask_before_move = false;
}
crate::panels::left::move_photos(app, Some(ids), folder).map(|n| json!({"moved": n}))
}
// the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it
Dialog::LightroomCatalog { catalog, .. } => {
app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c})))
+13 -5
View File
@@ -906,6 +906,13 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) {
/// While photos are dragged: a badge at the pointer; the drag ends when the button is up
/// (drop targets act on the release frame, before this runs).
/// What the label following dragged photos says this frame instead of how many there are: where a
/// drop would put them (set by the drop target under the pointer, read by [`drag_feedback`]).
pub fn drag_hint(ctx: &egui::Context, text: String) {
let frame = ctx.cumulative_frame_nr();
ctx.data_mut(|d| d.insert_temp(egui::Id::new("drag-photos-hint"), (frame, text)));
}
pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(ids) = &app.ui.dragging_photos else { return };
let (released, down, pos) = ctx.input(|i| (i.pointer.any_released(), i.pointer.any_down(), i.pointer.latest_pos()));
@@ -917,13 +924,14 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let n = ids.len();
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
// a drop target under the pointer says where they would go (only this frame's)
let frame = ctx.cumulative_frame_nr();
let hint = ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).filter(|(f, _)| *f == frame).map(|(_, text)| text);
let text = hint.unwrap_or_else(|| crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n));
egui::Area::new(egui::Id::new("drag-photos")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| {
egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| {
ui.label(
egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n))
.color(Color32::WHITE)
.font(t.semibold(12.0)),
);
let label = ui.add(egui::Label::new(egui::RichText::new(text).color(Color32::WHITE).font(t.semibold(12.0))).extend());
crate::widgets::register(ui.ctx(), "dragPhotos", label.rect);
});
});
}
+130 -4
View File
@@ -1765,6 +1765,7 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s
};
let mark = n.label.map(crate::theme::label_color);
let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark);
folder_drop_target(app, ui, &resp, n, open_id, open);
let mut toggled = false;
if !n.children.is_empty() {
let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path));
@@ -1786,6 +1787,134 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s
row_menu(app, &resp, n);
}
/// How long photos dragged over a closed Folders row wait before it opens, so a folder inside it
/// can be reached.
const SPRING_OPEN_SECONDS: f64 = 0.7;
/// A Folders row while photos are dragged from the grid: a release moves their files there on
/// disk (`photo.moveToFolder`, one undo step). The row lights up and the dragged label says where
/// they will go, as the same gesture onto an album only adds them to it. Held over a closed row,
/// it opens. Photos that can't go there leave it dark (a row covering several disks; Local photos,
/// not in the library) and a release says why; photos already there say so.
fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, n: &FolderNode, open_id: egui::Id, open: &mut bool) {
let Some(ids) = app.ui.dragging_photos.clone() else { return };
// only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header
if !pointer_over_row(ui, resp.rect) {
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());
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"))
} else if photos.iter().any(|p| p.local) {
Some(crate::i18n::tr("Local photos aren't in the library yet: Add to My Photos first, then move them"))
} else {
None
};
let here = |p: &lightcraft_catalog::Photo| match &p.source {
lightcraft_catalog::Source::File { path } => std::path::Path::new(path).parent().is_some_and(|d| same_folder(&d.to_string_lossy(), &n.path)),
_ => false,
};
let all_here = !photos.is_empty() && photos.iter().all(|p| here(p));
let count = photos.len();
let released = ui.input(|i| i.pointer.any_released());
let hint = match (&refused, all_here) {
(Some(_), _) => crate::i18n::tr("Can't be moved here").to_string(),
(None, true) => crate::i18n::tr_format!("Already in “{}”", name),
(None, false) => crate::i18n::tr_format!("Move {n} photo{} to “{}”", if count == 1 { "" } else { "s" }, name, n = count),
};
super::grid::drag_hint(ui.ctx(), hint);
if let Some(why) = refused {
ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed);
if released {
app.toast(ui.ctx(), why);
app.ui.dragging_photos = None;
}
return;
}
if all_here {
if released {
app.ui.dragging_photos = None;
}
return;
}
let t = Tokens::get(ui.ctx());
ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
if released {
app.ui.dragging_photos = None;
// a drop moves files on disk: asked first (as Lightroom Classic does), unless the person
// said not to; the menu's Move Selected Photos Here is asked for by name and goes at once
if app.ui.settings.ask_before_move {
let library = app.session.library_generation();
app.ui.dialog = Some(crate::state::Dialog::MovePhotos { ids, folder: n.path.clone(), name, dont_ask: false, library });
} else {
move_photos_here(app, ui.ctx(), Some(&ids), &n.path, &name);
}
}
}
/// Photos dragged over the closed row `n` for [`SPRING_OPEN_SECONDS`] open it (a row with folders
/// inside it). Restarts on each row the pointer comes to, and when it comes back to one after
/// leaving it (or after another drag).
fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool) {
if *open || n.children.is_empty() {
return;
}
let key = egui::Id::new("folder-spring-open");
let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr());
// held without a break: over this row last frame too
let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) {
Some((path, since, seen)) if path == n.path && seen.saturating_add(1) >= frame => since,
_ => now,
};
ui.data_mut(|d| d.insert_temp(key, (n.path.clone(), since, frame)));
if now - since >= SPRING_OPEN_SECONDS {
*open = true;
set_tree_open(ui, open_id, true);
ui.data_mut(|d| d.remove::<(String, f64, u64)>(key));
} else {
// keep the clock running while the pointer rests
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
}
}
/// Move photos (`ids`, else the selection) into the folder `path` on disk (`photo.moveToFolder`).
/// Photos that leave the view leave the selection, so nothing done next (Delete, an edit) reaches
/// photos no longer on screen. How many moved, or why none did.
pub(crate) fn move_photos(app: &mut LightcraftApp, ids: Option<&[u64]>, path: &str) -> Result<u64, String> {
let mut params = json!({"folder": path});
if let Some(ids) = ids {
params["ids"] = json!(ids);
}
let r = app.run("photo.moveToFolder", params)?;
let visible: std::collections::HashSet<lightcraft_catalog::PhotoId> = app.session.visible_cloned().into_iter().collect();
let sel = &mut app.session.selection;
sel.ids.retain(|id| visible.contains(id));
if sel.active.is_some_and(|a| !visible.contains(&a)) {
sel.active = sel.ids.first().copied();
}
Ok(r["moved"].as_u64().unwrap_or(0))
}
/// What a move of `n` photos into the folder `name` says: how many moved and that Undo moves them
/// back, or that they were there already.
pub(crate) fn moved_text(n: u64, name: &str) -> String {
if n == 0 {
crate::i18n::tr_format!("Already in “{}”", name)
} else {
crate::i18n::tr_format!("Moved {n} photo{} to “{}”. Edit → Undo moves them back.", if n == 1 { "" } else { "s" }, name, n = n)
}
}
/// [`move_photos`], saying how it went in a toast.
fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) {
match move_photos(app, ids, path) {
Ok(n) => app.toast(ctx, moved_text(n, name)),
Err(e) => app.toast(ctx, e),
}
}
/// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the
/// startup disk.
fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
@@ -1931,10 +2060,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()
{
match app.run("photo.moveToFolder", json!({"folder": path})) {
Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])),
Err(e) => app.toast(ui.ctx(), e),
}
move_photos_here(app, ui.ctx(), None, path, &name);
ui.close();
}
if app.services.reveal.is_some()
+1
View File
@@ -149,6 +149,7 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
heading(ui, t, crate::i18n::tr("Culling"));
check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging");
check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted");
check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk");
heading(ui, t, crate::i18n::tr("External Editing"));
row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| {
crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path)
+15
View File
@@ -343,6 +343,9 @@ pub struct AppSettings {
/// `confirmDelete: false` by default (#1239).
#[serde(rename = "askBeforeDelete")]
pub confirm_delete: bool,
/// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask
/// again" turns it off).
pub ask_before_move: bool,
/// GPU rendering allowed (`app.gpu`).
pub gpu: bool,
/// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is
@@ -392,6 +395,7 @@ impl Default for AppSettings {
recent_libraries: Vec::new(),
startup_view: StartupView::Last,
confirm_delete: true,
ask_before_move: true,
gpu: true,
preview_limit: 0,
external_editor: String::new(),
@@ -1182,6 +1186,17 @@ pub enum Dialog {
ConfirmDelete {
count: usize,
},
/// Confirm moving photos dropped on a Folders row into that folder on disk
/// (`photo.moveToFolder`). `name`: the folder's name, as the question says it.
MovePhotos {
ids: Vec<u64>,
folder: String,
name: String,
dont_ask: bool,
/// The library the photos were dropped in (`Session::library_generation`): ids are
/// numbered per library, so in another one they name other photos.
library: u64,
},
/// Confirm taking a folder's photos out of the library (`library.removeFolder`).
RemoveFolder {
path: String,
+343
View File
@@ -0,0 +1,343 @@
//! Photos dragged from the grid onto a Folders row move there on disk: the row lights up, the
//! dragged label says where they will go, a closed folder held under the pointer opens, and a
//! release asks first (unless the person said not to), then moves the files (one undo step).
//! Photos that can't go there, or are there already, say so and stay where they are.
use std::path::{Path, PathBuf};
use egui::{Pos2, Rect};
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
/// Folders `trip` and `home` in a fresh temporary folder, with a photo file in each.
fn folders(tag: &str) -> (PathBuf, PathBuf, PathBuf) {
let root = std::env::temp_dir().join(format!("lc-folder-drop-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (trip, home) = (root.join("trip"), root.join("home"));
for (dir, file) in [(&trip, "beach.png"), (&home, "garden.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, home)
}
/// The photos at `paths` in the catalog (`local`: browsed, not in the library), in the grid with
/// the sidebar out, tall enough for the Folders section.
fn app(paths: &[(PathBuf, bool)]) -> Headless {
use lightcraft_catalog::{Op, Photo, Source};
let mut session = lightcraft_engine::Session::new().with_fs();
for (path, local) in paths {
let id = session.catalog.alloc_photo_id();
let name = path.file_name().unwrap().to_string_lossy().to_string();
let mut p = Photo::new(id, Source::File { path: path.to_string_lossy().into() }, &name, "PNG", 8, 8, "2026-01-01T10:00:00");
p.local = *local;
session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 1600.0], 1.0);
let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true, "sidebar": ["folders"]}), std::time::Duration::from_secs(10));
assert_eq!(r["ok"], true, "{r}");
for _ in 0..6 {
h.step();
}
h
}
fn widget(h: &Headless, id: &str) -> Option<Rect> {
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r)
}
fn folder_row(h: &Headless, dir: &Path) -> Option<Rect> {
widget(h, &format!("source:libfolder:{}", dir.to_string_lossy()))
}
fn id_of(h: &Headless, file: &str) -> u64 {
h.app.session.catalog.photos().find(|p| p.file_name == file).map(|p| p.id.0).unwrap()
}
fn press(h: &mut Headless, at: Pos2) {
h.app.synthetic.push(egui::Event::PointerMoved(at));
h.app.synthetic.push(egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
h.step();
}
/// Move the pointer, pressed, from `from` to `to` over a few frames.
fn glide(h: &mut Headless, from: Pos2, to: Pos2) {
for i in 1..=8 {
h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
}
fn release(h: &mut Headless, at: Pos2) {
h.app.synthetic.push(egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
h.step();
h.step();
}
/// What the dragged label says this frame instead of the count, if a drop target set it.
fn hint(h: &Headless) -> Option<String> {
h.view.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).map(|(_, text)| text)
}
/// Click a widget the way a person does (press and release on it).
fn click(h: &mut Headless, id: &str) {
let at = widget(h, id).unwrap_or_else(|| panic!("no {id}")).center();
press(h, at);
release(h, at);
}
fn toast(h: &Headless) -> String {
h.app.ui.toast.as_ref().map(|(t, _, _)| t.clone()).unwrap_or_default()
}
#[test]
fn photos_dropped_on_a_folder_move_there_on_disk() {
let (root, trip, home) = folders("move");
let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]);
let beach = id_of(&h, "beach.png");
// the two folders sit in one that starts closed: only it is there to drop on
let parent = folder_row(&h, &root).unwrap_or_else(|| {
panic!("the folder holding both: {:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:")).collect::<Vec<_>>())
});
assert!(folder_row(&h, &home).is_none(), "closed");
// Given the beach photo dragged from the grid and held over the closed folder
let from = widget(&h, &format!("thumb:{beach}")).unwrap().center();
press(&mut h, from);
glide(&mut h, from, parent.center());
assert_eq!(h.app.ui.dragging_photos, Some(vec![beach]));
for _ in 0..60 {
h.step();
}
// Then it opens, so a folder inside it can be reached
let target = folder_row(&h, &home).expect("held over, the folder opened");
// When it is brought over home: the label says where it will go
glide(&mut h, parent.center(), target.center());
assert_eq!(hint(&h).as_deref(), Some("Move 1 photo to “home”"));
// and released there: it asks first, nothing moved yet
release(&mut h, target.center());
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. })), "{:?}", h.app.ui.dialog);
assert!(trip.join("beach.png").is_file(), "not before Move");
h.step();
click(&mut h, "button:dialogOk");
// Then the file moved, the photo follows it, and the toast says Undo moves it back
assert!(home.join("beach.png").is_file() && !trip.join("beach.png").exists(), "moved on disk");
let photo = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap();
assert!(matches!(&photo.source, lightcraft_catalog::Source::File { path } if Path::new(path) == home.join("beach.png")));
assert!(toast(&h).starts_with("Moved 1 photo to “home”. Edit → Undo moves them back."), "{}", toast(&h));
assert!(h.app.ui.dragging_photos.is_none(), "the drag ended");
// and one Undo moves it back
h.app.run("edit.undo", json!({})).unwrap();
assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "back where it was");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_photos_are_refused_with_the_reason() {
let (root, trip, home) = folders("local");
// the garden photo imported; the beach one only looked at under Local, where the grid shows it
let mut h = app(&[(home.join("garden.png"), false)]);
let r = h.request(
"engine.execute",
json!({"command": "library.browse", "params": {"path": trip.to_string_lossy()}}),
std::time::Duration::from_secs(10),
);
assert_eq!(r["ok"], true, "{r}");
// (the folder is read in the background)
let shown = |h: &Headless| {
h.app.session.catalog.photos().any(|p| p.file_name == "beach.png") && h.app.widgets.iter().any(|(w, _)| w.starts_with("thumb:"))
};
h.step_until(std::time::Duration::from_secs(20), |h| shown(h));
let beach = id_of(&h, "beach.png");
assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap().local);
let target = folder_row(&h, &home).expect("the imported folder");
let from = widget(&h, &format!("thumb:{beach}")).unwrap().center();
press(&mut h, from);
glide(&mut h, from, target.center());
// Then the row says it can't take them, and a release says why
assert_eq!(hint(&h).as_deref(), Some("Can't be moved here"));
release(&mut h, target.center());
assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "stays");
assert!(toast(&h).contains("Add to My Photos"), "{}", toast(&h));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn photos_already_in_the_folder_stay_put() {
let (root, trip, home) = folders("here");
let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]);
// open the folder holding both
let toggle = widget(&h, &format!("libraryFolderToggle:{}", root.to_string_lossy())).unwrap();
h.app.synthetic.push(egui::Event::PointerMoved(toggle.center()));
h.app.synthetic.push(egui::Event::PointerButton {
pos: toggle.center(),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
h.step();
release(&mut h, toggle.center());
let target = folder_row(&h, &home).expect("opened");
let garden = id_of(&h, "garden.png");
h.app.ui.dragging_photos = Some(vec![garden]);
h.app.synthetic.push(egui::Event::PointerMoved(target.center()));
h.app.synthetic.push(egui::Event::PointerButton {
pos: target.center(),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
h.step();
assert_eq!(hint(&h).as_deref(), Some("Already in “home”"));
let undo = h.app.session.undo.len();
release(&mut h, target.center());
assert!(home.join("garden.png").is_file());
assert_eq!(h.app.session.undo.len(), undo, "nothing done");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn photos_that_leave_the_view_leave_the_selection() {
let (root, trip, home) = folders("selection");
std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap();
let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]);
// shown by folder: trip's two photos, both selected
let r = h.request(
"engine.execute",
json!({"command": "library.source", "params": {"kind": "libraryFolder", "path": trip.to_string_lossy()}}),
std::time::Duration::from_secs(10),
);
assert_eq!(r["ok"], true, "{r}");
let (beach, dunes) = (id_of(&h, "beach.png"), id_of(&h, "dunes.png"));
h.app.session.selection =
lightcraft_engine::Selection { ids: vec![lightcraft_catalog::PhotoId(beach)], active: Some(lightcraft_catalog::PhotoId(beach)) };
for _ in 0..4 {
h.step();
}
let target = folder_row(&h, &home).expect("the other folder is listed beside it");
let from = widget(&h, &format!("thumb:{beach}")).unwrap().center();
press(&mut h, from);
glide(&mut h, from, target.center());
release(&mut h, target.center());
h.step();
click(&mut h, "button:dialogOk");
assert!(home.join("beach.png").is_file(), "moved");
// it left trip's view, and the selection with it: a Delete next can't reach it unseen
assert!(!h.app.session.selection.contains(lightcraft_catalog::PhotoId(beach)), "{:?}", h.app.session.selection);
assert!(h.app.session.visible_cloned().contains(&lightcraft_catalog::PhotoId(dunes)));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_folder_left_and_come_back_to_waits_again_before_opening() {
let (root, trip, home) = folders("spring");
let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]);
let beach = id_of(&h, "beach.png");
let parent = folder_row(&h, &root).unwrap();
let from = widget(&h, &format!("thumb:{beach}")).unwrap().center();
press(&mut h, from);
// over the closed folder for a moment, then away, longer than it waits
glide(&mut h, from, parent.center());
glide(&mut h, parent.center(), from);
for _ in 0..60 {
h.step();
}
// back over it: it doesn't open at once
glide(&mut h, from, parent.center());
assert!(folder_row(&h, &home).is_none(), "came back to: it waits again");
for _ in 0..60 {
h.step();
}
assert!(folder_row(&h, &home).is_some(), "held: it opens");
release(&mut h, parent.center());
let _ = std::fs::remove_dir_all(&root);
}
/// Drag the beach photo from the grid onto `target` and let go.
fn drop_beach(h: &mut Headless, beach: u64, target: Rect) {
let from = widget(h, &format!("thumb:{beach}")).unwrap().center();
press(h, from);
glide(h, from, target.center());
release(h, target.center());
h.step();
}
#[test]
fn cancelling_the_question_moves_nothing() {
let (root, trip, home) = folders("cancel");
let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]);
let beach = id_of(&h, "beach.png");
let target = folder_row(&h, &home).unwrap_or_else(|| folder_row(&h, &root).unwrap());
let target = if folder_row(&h, &home).is_some() {
target
} else {
click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy()));
folder_row(&h, &home).unwrap()
};
let undo = h.app.session.undo.len();
drop_beach(&mut h, beach, target);
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. })));
click(&mut h, "button:dialogCancel");
assert!(h.app.ui.dialog.is_none());
assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved");
assert_eq!(h.app.session.undo.len(), undo);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn dont_ask_again_moves_the_next_drop_at_once() {
let (root, trip, home) = folders("dont-ask");
std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap();
let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]);
click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy()));
let target = folder_row(&h, &home).unwrap();
let beach = id_of(&h, "beach.png");
h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(beach));
drop_beach(&mut h, beach, target);
// the question, with Don't ask again ticked
click(&mut h, "check:moveDontAsk");
click(&mut h, "button:dialogOk");
assert!(home.join("beach.png").is_file());
assert!(!h.app.ui.settings.ask_before_move, "kept in Settings");
// the next drop goes at once
let dunes = id_of(&h, "dunes.png");
h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(dunes));
h.step();
let target = folder_row(&h, &home).unwrap();
drop_beach(&mut h, dunes, target);
assert!(h.app.ui.dialog.is_none(), "not asked");
assert!(home.join("dunes.png").is_file(), "moved at once");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_question_left_open_while_another_library_opens_moves_nothing() {
let (root, trip, home) = folders("other-library");
let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]);
click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy()));
let beach = id_of(&h, "beach.png");
let target = folder_row(&h, &home).unwrap();
drop_beach(&mut h, beach, target);
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. })));
// another library opens meanwhile (the menu bar and agents reach past a dialog)
let other = root.join("other-library");
h.app.session.open_library(other.join("lib"), false).unwrap();
let r = h.request("ui.dialog.confirm", json!({}), std::time::Duration::from_secs(10));
assert_eq!(r["ok"], false, "{r}");
assert!(r["error"].as_str().unwrap_or_default().contains("Another library was opened"), "{r}");
assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved");
let _ = std::fs::remove_dir_all(&root);
}
File diff suppressed because one or more lines are too long