Albums: drop beside a row to place it by hand; Sort A–Z goes back to by name

Dropping on the edge of a row places the album there (album.reorder) with
a line showing where; a folder's middle still means inside. A closed folder
opens only when the drop would go inside it. Sort Contents A–Z and Sort
Albums A–Z (album.sort) appear once a folder is ordered by hand.

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 8036f98aca
commit 085c25b10b
2 changed files with 258 additions and 37 deletions
+139 -35
View File
@@ -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<Album> = 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<Option<AlbumId>, Vec<Album>>;
/// `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<AlbumId>, 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<AlbumId>, 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<AlbumId>
}
}
/// 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)})) {
/// 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<serde_json::Value, String>) {
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<u64> = 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::<f64>(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<AlbumId>) {
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<AlbumId>, before: Option<AlbumId>) {
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<AlbumId>, before: Option<AlbumId>, 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<AlbumDrop> {
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::<f64>(hover_id)).is_some() {
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 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::<f64>(hover_id)).is_some() {
ui.data_mut(|d| d.remove_temp::<f64>(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<u64> = 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::<f64>(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() {
+119 -2
View File
@@ -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<u64>, folders: bool) -> Vec<String> {
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]