diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index c005182ba..d700d7c2b 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -591,6 +591,36 @@ mod tests { assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64)); } + /// Dragging grid photos onto an album row adds them to the album. + #[test] + fn drag_photos_onto_an_album() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "view.leftPanel"}), t); + let r = h.request("engine.execute", json!({"command": "album.create", "params": {"name": "Dropped"}}), t); + let album = r["result"]["id"].as_u64().unwrap_or_else(|| panic!("{r}")); + h.settle(SETTLE); + let rect = |h: &mut Headless, id: &str| { + let w = h.request("ui.widgets", json!({"filter": id}), t); + let r = w["result"] + .as_array() + .and_then(|a| a.iter().find(|x| x["id"] == id)) + .map(|x| x["rect"].clone()) + .unwrap_or_else(|| panic!("{id}: {w}")); + let f = |i: usize| r[i].as_f64().unwrap(); + (f(0) + f(2) / 2.0, f(1) + f(3) / 2.0) + }; + let first = h.app.session.visible_cloned()[0].0; + let (x, y) = rect(&mut h, &format!("thumb:{first}")); + let (tx, ty) = rect(&mut h, &format!("source:album:{album}")); + let r = h.request("ui.drag", json!({"x": x, "y": y, "toX": tx, "toY": ty, "steps": 12}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let members = h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.clone(); + assert_eq!(members, vec![lightcraft_catalog::PhotoId(first)]); + assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); + } + /// While cropping: O cycles the guides, ⇧O mirrors them, A locks / unlocks the aspect. #[test] fn crop_keys() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 53a465ca8..749a51593 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -470,6 +470,7 @@ impl LightcraftApp { panels::dialogs::show(self, &ctx); import::progress(self, &ctx); export_task::poll(self, &ctx); + panels::grid::drag_feedback(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); self.perf.frame_ms = now_ms() - t0; diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 951998198..2c93d17d9 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -377,9 +377,36 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square let _ = app.run("library.select", json!({"ids": [id.0]})); let _ = app.run("view.detail", json!({})); } + // drag the selection (this photo joins it, or replaces it when it wasn't selected) + if resp.drag_started() { + if !app.session.selection.contains(id) { + let _ = app.run("library.select", json!({"ids": [id.0]})); + } + app.ui.dragging_photos = Some(app.session.selection.ids.iter().map(|p| p.0).collect()); + } resp.context_menu(|ui| context_menu(app, ui, id)); } +/// 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). +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())); + if released || !down { + app.ui.dragging_photos = None; + return; + } + let Some(pos) = pos else { return }; + let t = Tokens::get(ctx); + let n = ids.len(); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + 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(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(Color32::WHITE).font(t.semibold(12.0))); + }); + }); +} + pub use super::filterbar::label_color; /// "Set Color Label" items (coloured dot + the label's name), shared by context menus. diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 6c07804db..ae0bc19ab 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -179,6 +179,9 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album }; let n = app.session.catalog.album_count(a.id); let mut resp = row(app, ui, &format!("album:{}", a.id.0), icon, &a.name, Some(n), sel, indent); + if !a.is_smart() { + drop_target(app, ui, &resp, a); + } if let Some(rules) = &a.smart { resp = resp.on_hover_text(format!("Smart album: {}", rules.describe())); } @@ -190,6 +193,26 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent } } +/// An album row while photos are dragged from the grid: highlighted under the pointer; a +/// release there adds them. +fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) { + let Some(ids) = app.ui.dragging_photos.clone() else { return }; + let over = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| resp.rect.contains(p)); + if !over { + 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 ui.input(|i| i.pointer.any_released()) { + let n = ids.len(); + match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { + Ok(_) => app.toast(ui.ctx(), format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name)), + Err(e) => app.toast(ui.ctx(), e), + } + app.ui.dragging_photos = None; + } +} + fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { resp.context_menu(|ui| { if !a.folder && !a.is_smart() && ui.button("Add Selected Photos").clicked() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f7a8829a7..6491ec9b7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -212,6 +212,9 @@ pub struct UiState { /// Close the window on the next frame (File → Quit). #[serde(skip)] pub quit: bool, + /// Photos being dragged from the grid (dropped on an album to add them). + #[serde(skip)] + pub dragging_photos: Option>, /// Selected curve channel in the Curve flyout. pub curve_channel: String, /// Selected mixer mode: "hue" | "saturation" | "luminance" | "all". @@ -413,6 +416,7 @@ impl Default for UiState { focus_search: false, renaming_mask: None, quit: false, + dragging_photos: None, curve_channel: "parametric".into(), mixer_mode: "hue".into(), grading_mode: "3way".into(), diff --git a/docs/parity.md b/docs/parity.md index e6f599026..f958f5b4a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,11 +44,11 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 11 | 2 | 4 | 1 | 8/8 (100%) | 3/5 (60%) | +| X. Cross-cutting behaviours (BEHAV) | 12 | 1 | 4 | 1 | 8/8 (100%) | 4/5 (80%) | | Y. Menus | 65 | 9 | 11 | 8 | 46/47 (98%) | 17/23 (74%) | | Z. Keyboard shortcuts (desktop) | 61 | 12 | 7 | 1 | 49/52 (94%) | 12/23 (52%) | | Lightroom Classic extras | 7 | 19 | 53 | 9 | — | 6/21 (29%) | -| **Total** | 295 | 64 | 141 | 37 | 192/198 (97%) | 93/144 (65%) | +| **Total** | 296 | 63 | 141 | 37 | 192/198 (97%) | 94/144 (65%) | ## Top gaps @@ -477,7 +477,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `crates/preview/src/lib.rs` (`JobPool`), `crates/ui-egui/src/export_task.rs`, `crates/ui-egui/src/import.rs` | renders off the UI thread; exports started from the UI run on a worker thread with a progress panel and Cancel (`ui.inspect` → `export`); imports show a progress window. No combined activity centre | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback | -| LR-BEHAV-DRAGDROP | Drag and drop | P1 | 🟡 | `crates/ui-egui/src/lib.rs` | files → app only; no photos → albums | +| 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`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |