Albums: drag an album or folder onto a folder to move it

A folder under the pointer is outlined when the drop is allowed and opens
after resting there; a drop zone under the tree takes an album back to the
top level. The move is album.move, so it is one undo step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-08 21:02:07 -04:00
co-authored by Claude Sonnet 5.5
parent 5443ae28f9
commit aaae39fae0
4 changed files with 272 additions and 3 deletions
+1
View File
@@ -909,6 +909,7 @@ impl LightcraftApp {
export_task::poll(self, &ctx);
pick::poll(self, &ctx);
panels::grid::drag_feedback(self, &ctx);
panels::left::album_drag_feedback(self, &ctx);
panels::toast(self, &ctx);
self.widgets = widgets::take_registry(&ctx);
self.end_frame(t0);
+130 -3
View File
@@ -52,10 +52,27 @@ fn row_named(
count: Option<usize>,
selected: bool,
indent: f32,
) -> egui::Response {
row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click())
}
/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`).
#[allow(clippy::too_many_arguments)]
fn row_sensed(
app: &mut LightcraftApp,
ui: &mut egui::Ui,
id: &str,
icon: Icon,
label: &str,
spoken: Option<&str>,
count: Option<usize>,
selected: bool,
indent: f32,
sense: Sense,
) -> egui::Response {
let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label };
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), sense);
register(ui.ctx(), format!("source:{id}"), r);
let said = spoken.unwrap_or(label);
let name = match count {
@@ -229,6 +246,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
cur = albums.iter().find(|a| a.id == p).and_then(|a| a.parent);
}
albums_tree(app, ui, &albums, None, 0.0, &open_to);
top_level_drop_zone(app, ui);
}
ui.add_space(10.0);
local_section(app, ui);
@@ -664,9 +682,15 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent
open = true;
ui.data_mut(|d| d.insert_temp(open_id, true));
}
let resp = row(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, false, indent);
let resp = row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag());
// a folder is no source, so its row folds it too; the triangle is the same click, aimed
let has_children = all.iter().any(|c| c.parent == Some(a.id));
if resp.drag_started() {
app.ui.dragging_album = Some(a.id.0);
}
if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open)) {
ui.data_mut(|d| d.insert_temp(open_id, true));
}
let mut toggled = resp.clicked();
if has_children {
let tri = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("album-tri", a.id.0)), format!("albumToggle:{}", a.id.0));
@@ -691,7 +715,10 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent
let target =
app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection());
let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() };
let mut resp = row(app, ui, &format!("album:{}", a.id.0), icon, &label, Some(n), sel, indent);
let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag());
if resp.drag_started() {
app.ui.dragging_album = Some(a.id.0);
}
if !a.is_smart() {
drop_target(app, ui, &resp, a);
}
@@ -706,6 +733,106 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent
}
}
/// How long a dragged album must rest on a closed folder before it opens.
const HOVER_OPEN_SECS: f64 = 0.6;
/// Whether the album `dragged` may be dropped into the folder `target` (`None`: the top level):
/// a folder other than where it already is, and not itself or something inside it.
fn can_drop_album(app: &LightcraftApp, dragged: AlbumId, target: Option<AlbumId>) -> bool {
let Some(d) = app.session.catalog.album(dragged) else { return false };
match target {
None => d.parent.is_some(),
Some(t) => app.session.catalog.album(t).is_some_and(|f| f.folder) && d.parent != Some(t) && !is_within(app, t, dragged),
}
}
/// Moves the dragged album (`album.move`, one undo step) and ends the drag; the folders down to it
/// open so it shows in its new place.
fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: Option<AlbumId>) {
match app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)})) {
Ok(_) => {
app.ui.reveal_album = Some(dragged.0);
ui.ctx().request_repaint();
}
Err(e) => app.toast(ui.ctx(), e),
}
app.ui.dragging_album = None;
}
/// A folder row while an album is dragged: outlined under the pointer when the drop is allowed,
/// opened (`open`, when it has something inside) once the pointer rests on it, and the album moved
/// in on release. Returns whether it opened the folder.
fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, folder: &Album, open: Option<&mut bool>) -> bool {
let hover_id = egui::Id::new(("album-hover", folder.id.0));
let Some(dragged) = app.ui.dragging_album.map(AlbumId) else {
ui.data_mut(|d| d.remove_temp::<f64>(hover_id));
return false;
};
if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| resp.rect.contains(p)) {
ui.data_mut(|d| d.remove_temp::<f64>(hover_id));
return false;
}
let now = ui.input(|i| i.time);
let since = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(hover_id, || now));
let mut opened = false;
if let Some(open) = open
&& !*open
{
if now - since >= HOVER_OPEN_SECS {
*open = true;
opened = true;
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
}
}
if can_drop_album(app, dragged, Some(folder.id)) {
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()) {
drop_album(app, ui, dragged, Some(folder.id));
}
}
opened
}
/// While an album that sits in a folder is dragged: a row under the tree that takes it back to the
/// top level.
fn top_level_drop_zone(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return };
if !can_drop_album(app, dragged, None) {
return;
}
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::hover());
register(ui.ctx(), "albumDrop:top", r);
let over = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| r.contains(p));
let stroke = if over { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)) };
ui.painter().rect_stroke(r.shrink2(vec2(8.0, 1.0)), 4.0, stroke, egui::StrokeKind::Inside);
ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Top Level"), t.font(13.5), t.text_dim);
if over && ui.input(|i| i.pointer.any_released()) {
drop_album(app, ui, dragged, None);
}
}
/// While an album is dragged in the sidebar: its name at the pointer; the drag ends when the
/// button is up (the drop targets act on the release frame, before this runs).
pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(id) = app.ui.dragging_album 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_album = None;
return;
}
let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return };
let t = Tokens::get(ctx);
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
egui::Area::new(egui::Id::new("drag-album")).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(name).color(egui::Color32::WHITE).font(t.semibold(12.0)));
});
});
}
/// 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) {
+4
View File
@@ -375,6 +375,9 @@ pub struct UiState {
/// An album just made: the Albums tree opens the folders down to it, once.
#[serde(skip)]
pub reveal_album: Option<u64>,
/// An album or folder row being dragged in the sidebar (dropped on a folder to move it there).
#[serde(skip)]
pub dragging_album: Option<u64>,
/// Selected curve channel in the Curve flyout.
pub curve_channel: String,
/// Selected mixer mode: "hue" | "saturation" | "luminance" | "all".
@@ -675,6 +678,7 @@ impl Default for UiState {
quit: false,
dragging_photos: None,
reveal_album: None,
dragging_album: None,
curve_channel: "parametric".into(),
mixer_mode: "hue".into(),
grading_mode: "3way".into(),
+137
View File
@@ -354,6 +354,143 @@ fn album_dialogs_without_a_parent_still_deserialize() {
assert!(matches!(d, Dialog::SmartRules { parent: None, .. }));
}
/// An album tree to drag in: `Archive` (empty folder), `Trips` ⊃ `Sub` (folder) + `Best`, `Loose`.
struct AlbumTree {
h: Headless,
archive: u64,
trips: u64,
sub: u64,
best: u64,
loose: u64,
}
fn album_tree() -> AlbumTree {
let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true}));
let make = |h: &mut Headless, params: serde_json::Value| h.app.session.execute("album.create", &params).unwrap()["id"].as_u64().unwrap();
let archive = make(&mut h, json!({"name": "Archive", "folder": true}));
let trips = make(&mut h, json!({"name": "Trips", "folder": true}));
let sub = make(&mut h, json!({"name": "Sub", "folder": true, "parent": trips}));
let best = make(&mut h, json!({"name": "Best", "parent": trips}));
let loose = make(&mut h, json!({"name": "Loose"}));
h.step();
h.step();
AlbumTree { h, archive, trips, sub, best, loose }
}
impl AlbumTree {
fn row(&self, id: u64) -> egui::Rect {
let h = &self.h;
let name = ["source:folder:", "source:album:"].iter().map(|p| format!("{p}{id}")).find(|n| h.app.widgets.iter().any(|(w, _)| w == n));
widget(h, &name.unwrap_or_else(|| panic!("no row for album {id}")))
}
/// The middle of the row just under the last row of the Albums tree (where the top-level drop zone is).
fn below_tree(&self) -> egui::Pos2 {
let bottom = (self.h.app.widgets.iter())
.filter(|(w, r)| (w.starts_with("source:album:") || w.starts_with("source:folder:")) && r.left() < 300.0)
.map(|(_, r)| r.bottom())
.fold(0.0, f32::max);
egui::pos2(100.0, bottom + 14.5)
}
fn parent(&self, id: u64) -> Option<u64> {
self.h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().parent.map(|p| p.0)
}
/// Press at `from`, move through each `(point, frames held there)`, release at the last one.
fn drag(&mut self, from: egui::Pos2, path: &[(egui::Pos2, usize)]) {
let mut ev = |e| self.h.app.synthetic.push(e);
ev(egui::Event::PointerMoved(from));
ev(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
let mut at = from;
for (to, hold) in path {
for i in 1..=8 {
at = from + (*to - from) * (i as f32 / 8.0);
ev(egui::Event::PointerMoved(at));
}
for _ in 0..*hold {
ev(egui::Event::PointerMoved(*to));
}
at = *to;
}
ev(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
for _ in 0..400 {
if self.h.app.synthetic.is_empty() {
break;
}
self.h.step();
}
self.h.step();
self.h.step();
}
fn drag_row(&mut self, id: u64, onto: u64) {
let (from, to) = (self.row(id).center(), self.row(onto).center());
self.drag(from, &[(to, 3)]);
}
}
/// Given albums and folders in the sidebar, when an album or folder is dragged onto a folder,
/// then it moves into it (as Move to does); onto a plain album, itself or something inside it, it
/// stays where it was.
#[test]
fn dragging_an_album_onto_a_folder_moves_it() {
let mut t = album_tree();
t.drag_row(t.loose, t.archive);
assert_eq!(t.parent(t.loose), Some(t.archive), "an album into a folder");
assert!(t.h.app.ui.dragging_album.is_none(), "the drag ended");
// it is shown inside its new folder, which opened for it
assert!(t.h.app.widgets.iter().any(|(w, _)| *w == format!("source:album:{}", t.loose)));
t.drag_row(t.sub, t.archive);
assert_eq!(t.parent(t.sub), Some(t.archive), "a folder into a folder");
// refused: onto a plain album, onto itself, into its own subfolder
t.drag_row(t.best, t.loose);
assert_eq!(t.parent(t.best), Some(t.trips), "a plain album is no destination");
let (from, aside) = (t.row(t.archive).center(), t.row(t.archive).center() + egui::vec2(40.0, 0.0));
t.drag(from, &[(aside, 3)]);
assert_eq!(t.parent(t.archive), None, "not onto itself");
t.drag_row(t.archive, t.sub);
assert_eq!(t.parent(t.archive), None, "not into something inside it");
assert!(t.h.app.ui.dragging_album.is_none());
// a plain click still only opens the album
let c = t.row(t.best).center();
t.drag(c, &[(c, 0)]);
assert_eq!(t.h.app.session.source, lightcraft_engine::LibrarySource::Album(lightcraft_catalog::AlbumId(t.best)));
}
/// Dragging out of a folder: the drop zone under the tree puts the album back at the top level;
/// it only shows when there is somewhere to go, and the move is one undo step.
#[test]
fn dragging_an_album_to_the_top_level() {
let mut t = album_tree();
let has = |t: &AlbumTree, id: &str| t.h.app.widgets.iter().any(|(w, _)| w == id);
assert!(!has(&t, "albumDrop:top"), "no zone unless an album is being dragged");
// below the last row of the tree, where the zone appears once an album with a folder is dragged
let below = t.below_tree();
let from = t.row(t.best).center();
t.drag(from, &[(below, 3)]);
assert_eq!(t.parent(t.best), None, "moved out of Trips");
// a top-level album has nowhere to go: dropping it there does nothing
let (from, below) = (t.row(t.best).center(), t.below_tree());
t.drag(from, &[(below, 3)]);
assert_eq!(t.parent(t.best), None);
t.h.app.session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(t.parent(t.best), Some(t.trips), "one undo puts it back");
}
/// Holding a dragged album over a closed folder opens it, so the drop can go into a subfolder.
#[test]
fn a_dragged_album_opens_the_folder_it_hovers_over() {
let mut t = album_tree();
let r = t.h.request("ui.clickWidget", json!({"id": format!("albumToggle:{}", t.trips)}), T);
assert_eq!(r["ok"], true, "{r}");
t.h.step();
t.h.step();
assert!(!t.h.app.widgets.iter().any(|(w, _)| *w == format!("source:folder:{}", t.sub)), "Trips is folded");
// where Sub's row will be once Trips is open: one row below it
let trips = t.row(t.trips);
let inside = trips.center() + egui::vec2(0.0, trips.height());
let from = t.row(t.loose).center();
t.drag(from, &[(trips.center(), 90), (inside, 3)]);
assert_eq!(t.parent(t.loose), Some(t.sub), "dropped on Sub, which was only there after the hover");
}
/// A headless app over a library of file-backed photos that exist only in the catalog.
fn folders_app(paths: &[&str]) -> Headless {
folders_app_sized(paths, [1400.0, 900.0])