LR-BEHAV-DRAGDROP: drag photos from the grid onto an album

Dragging a grid cell drags the selection (the cell joins it, or replaces it when it wasn't
selected); a "N photos" badge follows the pointer, album rows highlight under it, and a drop
adds the photos to the album. Tested end to end with ui.drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 23:46:13 -04:00
co-authored by Claude Opus 5.5
parent e7c8adf36e
commit 64d7ffe3a7
6 changed files with 88 additions and 3 deletions
+30
View File
@@ -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() {
+1
View File
@@ -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;
+27
View File
@@ -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.
+23
View File
@@ -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() {
+4
View File
@@ -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<Vec<u64>>,
/// 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(),
+3 -3
View File
@@ -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%) |
<!-- /parity:summary -->
## 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` | |