diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 065d1cfc5..64b3a98b1 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -234,18 +234,27 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.button(crate::i18n::tr("Create Folder…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None }); } + // only once the albums were put in an order by hand + if app.session.catalog.album_children_are_ordered(None) { + ui.separator(); + if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() { + let _ = app.run("album.sort", json!({})); + } + } }); // the album just made: the folders down to it open, once (also when the section is shut) let reveal = app.ui.reveal_album.take(); if albums_open { - let albums: Vec = app.session.catalog.albums().cloned().collect(); + let kids: AlbumKids = + app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect(); + let cat = &app.session.catalog; let mut open_to = Vec::new(); - let mut cur = reveal.map(AlbumId).and_then(|id| albums.iter().find(|a| a.id == id)).and_then(|a| a.parent); + let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent); while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) { open_to.push(p); - cur = albums.iter().find(|a| a.id == p).and_then(|a| a.parent); + cur = cat.album(p).and_then(|a| a.parent); } - albums_tree(app, ui, &albums, None, 0.0, &open_to); + albums_tree(app, ui, &kids, None, 0.0, &open_to); top_level_drop_zone(app, ui); } ui.add_space(10.0); @@ -670,10 +679,12 @@ fn browse_all_photos(app: &mut LightcraftApp, on: bool) { } } +/// The albums inside each folder (`None`: the top level), in the order they are listed. +type AlbumKids = std::collections::HashMap, Vec>; + /// `open_to`: folders forced open this frame (the way down to an album that was just made). -fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent: Option, indent: f32, open_to: &[AlbumId]) { - let mut kids: Vec<&Album> = all.iter().filter(|a| a.parent == parent).collect(); - kids.sort_by_key(|a| (!a.folder, a.name.to_lowercase())); +fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, parent: Option, indent: f32, open_to: &[AlbumId]) { + let Some(kids) = all.get(&parent) else { return }; for a in kids { if a.folder { let open_id = egui::Id::new(("folder-open", a.id.0)); @@ -684,11 +695,11 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent } 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)); + let has_children = all.get(&Some(a.id)).is_some_and(|v| !v.is_empty()); 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)) { + if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open), indent) { ui.data_mut(|d| d.insert_temp(open_id, true)); } let mut toggled = resp.clicked(); @@ -719,6 +730,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent if resp.drag_started() { app.ui.dragging_album = Some(a.id.0); } + album_drag_over(app, ui, &resp, a, None, indent); if !a.is_smart() { drop_target(app, ui, &resp, a); } @@ -752,10 +764,10 @@ fn can_drop_album(app: &LightcraftApp, dragged: AlbumId, target: Option } } -/// 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) { - match app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)})) { +/// Ends a drag that ran `result`: the folders down to the album open so it shows in its new place. +fn finish_drop(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, result: Result) { + forget_hover(app, ui.ctx()); + match result { Ok(_) => { app.ui.reveal_album = Some(dragged.0); ui.ctx().request_repaint(); @@ -765,24 +777,103 @@ fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: 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)); +/// Forgets how long the pointer rested on each folder (a new drag starts counting again). +fn forget_hover(app: &LightcraftApp, ctx: &egui::Context) { + let ids: Vec = app.session.catalog.albums().filter(|a| a.folder).map(|a| a.id.0).collect(); + ctx.data_mut(|d| { + for id in ids { + d.remove_temp::(egui::Id::new(("album-hover", id))); + } + }); +} + +/// Moves the dragged album into a folder (`album.move`, one undo step; `None`: the top level). +fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: Option) { + let r = app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)})); + finish_drop(app, ui, dragged, r); +} + +/// Places the dragged album in `parent` before the sibling `before` (`None`: last of its kind), by +/// hand (`album.reorder`, one undo step). +fn place_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, parent: Option, before: Option) { + let r = app.run("album.reorder", json!({"id": dragged.0, "parent": parent.map(|p| p.0), "before": before.map(|b| b.0)})); + finish_drop(app, ui, dragged, r); +} + +/// What dropping the dragged album on a row would do. +#[derive(Clone, Copy, Debug, PartialEq)] +enum AlbumDrop { + /// Into the folder. + Into(AlbumId), + /// Beside the row: in `parent`, before `before` (`None`: last of its kind); the line is drawn + /// above the row (`above`) or below it. + Place { parent: Option, before: Option, above: bool }, +} + +/// What dropping `dragged` at `frac` (0 = top edge, 1 = bottom edge) of the row of `target` does, +/// if anything. An album over a folder goes inside it; a folder over a folder goes inside by its +/// middle and beside it by its edges; an album over an album goes before it by its top half and +/// after it by its bottom half; a folder over an album does nothing. Placing where it already +/// is, or inside itself, is nothing too. `expanded`: `target` is a folder shown open. +fn album_drop_at(app: &LightcraftApp, dragged: AlbumId, target: &Album, frac: f32, expanded: bool) -> Option { + let cat = &app.session.catalog; + let d = cat.album(dragged)?; + let into = || can_drop_album(app, dragged, Some(target.id)).then_some(AlbumDrop::Into(target.id)); + if d.folder != target.folder { + return if target.folder { into() } else { None }; + } + if target.id == dragged { + return None; + } + let after = frac >= if target.folder { 0.7 } else { 0.5 }; + // the middle of a folder, and the bottom of an open one (its first child is next), mean inside + if target.folder && frac >= 0.3 && (!after || expanded) { + return into(); + } + let parent = target.parent; + if parent.is_some_and(|p| is_within(app, p, dragged)) { + return None; + } + let sibs = cat.album_children(parent); + let at = sibs.iter().position(|a| a.id == target.id)?; + let was = sibs.iter().position(|a| a.id == dragged); + if !after { + (was.map(|w| w + 1) != Some(at)).then_some(AlbumDrop::Place { parent, before: Some(target.id), above: true }) + } else { + let next = sibs.iter().skip(at + 1).find(|a| a.id != dragged).filter(|a| a.folder == target.folder).map(|a| a.id); + (was != Some(at + 1)).then_some(AlbumDrop::Place { parent, before: next, above: false }) + } +} + +/// A row while an album is dragged: a folder outlined under the pointer when the drop would go +/// inside it, a line above or below the row when it would go beside it, and the move or placement +/// done on release. A closed folder with something inside (`open`) opens once the pointer rests on +/// it; returns whether it did. +fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, row: &Album, open: Option<&mut bool>, indent: f32) -> bool { + let hover_id = egui::Id::new(("album-hover", row.id.0)); let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false }; - if !pointer_over(ui, resp.rect) { + let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over(ui, resp.rect)); + let Some(pos) = pos else { if ui.data(|d| d.get_temp::(hover_id)).is_some() { ui.data_mut(|d| d.remove_temp::(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 expanded = open.as_deref().copied().unwrap_or(false); + let frac = ((pos.y - resp.rect.top()) / resp.rect.height().max(1.0)).clamp(0.0, 1.0); + let drop = album_drop_at(app, dragged, row, frac, expanded); let mut opened = false; - if let Some(open) = open + // only a drop that goes inside opens the folder: on its edges the rows would shift under the pointer + let inside = matches!(drop, Some(AlbumDrop::Into(_))); + if !inside && ui.data(|d| d.get_temp::(hover_id)).is_some() { + ui.data_mut(|d| d.remove_temp::(hover_id)); + } + if inside + && let Some(open) = open && !*open { + let now = ui.input(|i| i.time); + let since = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(hover_id, || now)); if now - since >= HOVER_OPEN_SECS { *open = true; opened = true; @@ -790,12 +881,25 @@ fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Resp 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.primary_released()) { - drop_album(app, ui, dragged, Some(folder.id)); + let t = Tokens::get(ui.ctx()); + let released = ui.input(|i| i.pointer.primary_released()); + match drop { + Some(AlbumDrop::Into(folder)) => { + 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 { + drop_album(app, ui, dragged, Some(folder)); + } } + Some(AlbumDrop::Place { parent, before, above }) => { + let y = if above { resp.rect.top() + 0.5 } else { resp.rect.bottom() - 0.5 }; + let (x0, x1) = (resp.rect.left() + 18.0 + indent, ui.clip_rect().right() - 8.0); + ui.painter().circle_stroke(pos2(x0, y), 3.0, egui::Stroke::new(1.5, t.accent)); + ui.painter().line_segment([pos2(x0 + 3.0, y), pos2(x1.max(x0 + 3.0), y)], egui::Stroke::new(2.0, t.accent)); + if released { + place_album(app, ui, dragged, parent, before); + } + } + None => {} } opened } @@ -827,13 +931,7 @@ pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.input(|i| (i.pointer.primary_released(), i.pointer.primary_down(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); if released || !down || esc { app.ui.dragging_album = None; - // forget how long the pointer rested on each folder - let ids: Vec = app.session.catalog.albums().filter(|a| a.folder).map(|a| a.id.0).collect(); - ctx.data_mut(|d| { - for id in ids { - d.remove_temp::(egui::Id::new(("album-hover", id))); - } - }); + forget_hover(app, ctx); return; } let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return }; @@ -883,6 +981,12 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { } } }); + if app.session.catalog.album_children_are_ordered(Some(a.id)) + && ui.button(crate::i18n::tr("Sort Contents A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() + { + let _ = app.run("album.sort", json!({"parent": a.id.0})); + ui.close(); + } ui.separator(); } if !a.folder && !a.is_smart() && ui.button(crate::i18n::tr("Add Selected Photos")).clicked() { diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index c9ee05ca4..364fdc2be 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -427,6 +427,19 @@ impl AlbumTree { self.h.step(); self.h.step(); } + /// Drag `id` to `frac` of the way down the row of `onto` (0 = its top edge, 1 = its bottom edge). + fn drag_to(&mut self, id: u64, onto: u64, frac: f32) { + let (from, r) = (self.row(id).center(), self.row(onto)); + self.drag(from, &[(egui::pos2(r.left() + 100.0, r.top() + frac * r.height()), 3)]); + } + fn id_of(&self, name: &str) -> u64 { + self.h.app.session.catalog.albums().find(|a| a.name == name).unwrap_or_else(|| panic!("no album {name}")).id.0 + } + /// The names inside `parent`, folders or albums only. + fn kids(&self, parent: Option, folders: bool) -> Vec { + let cat = &self.h.app.session.catalog; + cat.album_children(parent.map(lightcraft_catalog::AlbumId)).iter().filter(|a| a.folder == folders).map(|a| a.name.clone()).collect() + } 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)]); @@ -446,9 +459,10 @@ fn dragging_an_album_onto_a_folder_moves_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 + // refused: onto itself, into its own subfolder; onto a plain album it only goes beside it t.drag_row(t.best, t.loose); - assert_eq!(t.parent(t.best), Some(t.trips), "a plain album is no destination"); + assert_eq!(t.parent(t.best), Some(t.archive), "a plain album is no destination: Best lands beside Loose, not inside"); + assert_eq!(t.parent(t.loose), Some(t.archive)); 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"); @@ -483,6 +497,109 @@ fn an_album_drag_ignores_other_buttons_and_esc_cancels_it() { assert_eq!(t.parent(t.loose), Some(t.archive)); } +/// Given the top-level albums Garden, Loose, Night Sky, Portfolio listed by name, when an album is +/// dropped on the top or bottom half of another, then it is placed before or after it and the +/// list is ordered by hand from then on. +#[test] +fn dropping_on_the_edge_of_an_album_places_it_there() { + let mut t = album_tree(); + let id = |t: &AlbumTree, name: &str| t.h.app.session.catalog.albums().find(|a| a.name == name).unwrap().id.0; + let (garden, night, portfolio) = (id(&t, "Garden"), id(&t, "Night Sky"), id(&t, "Portfolio")); + assert_eq!(t.kids(None, false), ["Garden", "Loose", "Night Sky", "Portfolio"]); + t.drag_to(portfolio, garden, 0.2); + assert_eq!(t.kids(None, false), ["Portfolio", "Garden", "Loose", "Night Sky"], "before Garden"); + t.drag_to(garden, night, 0.8); + assert_eq!(t.kids(None, false), ["Portfolio", "Loose", "Night Sky", "Garden"], "after Night Sky"); + assert!(t.h.app.session.catalog.album_children_are_ordered(None)); + // one undo step per drop + t.h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(t.kids(None, false), ["Portfolio", "Garden", "Loose", "Night Sky"]); + assert!(t.h.app.ui.dragging_album.is_none()); +} + +/// Dropping beside an album in another folder moves the album there, at that place. +#[test] +fn dropping_beside_an_album_in_another_folder_moves_it_there() { + let mut t = album_tree(); + t.drag_to(t.loose, t.best, 0.2); + assert_eq!(t.parent(t.loose), Some(t.trips)); + assert_eq!(t.kids(Some(t.trips), false), ["Loose", "Best"]); +} + +/// Where nothing would change, nothing is done: no edit, so no hand order appears. +#[test] +fn dropping_where_it_already_is_changes_nothing() { + let mut t = album_tree(); + let garden = t.id_of("Garden"); + assert_eq!(t.kids(None, false)[..2], ["Garden", "Loose"]); + t.drag_to(t.loose, garden, 0.8); + t.drag_to(garden, t.loose, 0.2); + assert!(!t.h.app.session.catalog.album_children_are_ordered(None), "Loose already follows Garden"); + // a real placement orders the albums; the same drop again then changes nothing + t.drag_to(t.loose, garden, 0.2); + assert!(t.h.app.session.catalog.album_children_are_ordered(None)); + let snapshot = t.h.app.session.catalog.to_snapshot(); + t.drag_to(t.loose, garden, 0.2); + assert_eq!(t.h.app.session.catalog.to_snapshot(), snapshot); +} + +/// The bottom of an open folder is inside it; from the bottom edge of a closed one a folder goes +/// after it (last of the folders); a folder over an album does nothing. +#[test] +fn folders_go_after_a_closed_folder_and_inside_an_open_one() { + let mut t = album_tree(); + t.drag_to(t.archive, t.trips, 0.9); + assert_eq!(t.parent(t.archive), Some(t.trips), "the bottom of an open folder is inside it"); + let travel = t.id_of("Travel 2026"); + assert_eq!(t.kids(None, true), ["Travel 2026", "Trips"]); + 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(); + t.drag_to(travel, t.trips, 0.9); + assert_eq!(t.kids(None, true), ["Trips", "Travel 2026"], "after Trips, the last folder"); + assert_eq!(t.parent(travel), None); + // folders are not placed among albums + t.drag_to(t.trips, t.loose, 0.5); + assert_eq!(t.kids(None, true), ["Trips", "Travel 2026"]); +} + +/// Resting on a closed folder's edge (where the drop goes beside it) does not open it: the rows +/// would shift under the pointer. Only a drop that goes inside opens it. +#[test] +fn hovering_a_folders_edge_does_not_open_it() { + 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(); + let (from, r) = (t.row(t.archive).center(), t.row(t.trips)); + t.drag(from, &[(egui::pos2(r.left() + 100.0, r.top() + 0.9 * r.height()), 90)]); + assert_eq!(t.parent(t.archive), None, "dropped after Trips, not inside it"); + assert_eq!(t.kids(None, true), ["Travel 2026", "Trips", "Archive"]); +} + +/// Folders are placed among folders by their edges; their middle still means "inside"; an album +/// over a folder is always "inside"; where nothing would change nothing is done or ordered. +#[test] +fn folders_are_placed_by_their_edges_and_entered_by_their_middle() { + let mut t = album_tree(); + assert_eq!(t.kids(None, true), ["Archive", "Travel 2026", "Trips"]); + t.drag_to(t.trips, t.archive, 0.1); + assert_eq!(t.kids(None, true), ["Trips", "Archive", "Travel 2026"], "Trips before Archive"); + assert_eq!(t.parent(t.trips), None); + t.drag_to(t.sub, t.archive, 0.5); + assert_eq!(t.parent(t.sub), Some(t.archive), "the middle of a folder is inside it"); + // an album anywhere over a folder row goes inside, even at its edge + t.drag_to(t.loose, t.archive, 0.05); + assert_eq!(t.parent(t.loose), Some(t.archive)); + // already in place: no change, so no hand order is made + let mut t = album_tree(); + let travel = t.h.app.session.catalog.albums().find(|a| a.name == "Travel 2026").unwrap().id.0; + t.drag_to(travel, t.trips, 0.1); + assert!(!t.h.app.session.catalog.album_children_are_ordered(None), "Travel 2026 already sits right before Trips"); +} + /// 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]