//! 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 { h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) } /// How the Folders section names `dir` in widget ids: with `/` separators, on Windows too. fn folder_key(dir: &Path) -> String { dir.to_string_lossy().replace('\\', "/") } fn folder_row(h: &Headless, dir: &Path) -> Option { widget(h, &format!("source:libfolder:{}", folder_key(dir))) } 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 { h.view.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::unique("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::>()) }); 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:{}", folder_key(&root))).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:{}", folder_key(&root))); 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:{}", folder_key(&root))); 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:{}", folder_key(&root))); 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); }