Albums: the Albums header is the way back to the top level

It replaces the strip under the tree, which could be scrolled out of view
and shifted the rows when it appeared; the header is always visible and
nothing moves.

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 922ea25d88
commit 24b80b4ee3
2 changed files with 26 additions and 27 deletions
+11 -9
View File
@@ -208,6 +208,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.add_space(10.0);
// Albums header
let (ar, albums_open) = sidebar_section_header(app, ui, "albums", "Albums");
top_level_drop_target(app, ui, ar);
// the + stays at the visible edge when the sidebar is scrolled sideways
let plus_right = (ar.left() + viewport.max.x).min(ar.right());
let mut hdr = ui.new_child(
@@ -255,7 +256,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
cur = cat.album(p).and_then(|a| a.parent);
}
albums_tree(app, ui, &kids, None, 0.0, &open_to);
top_level_drop_zone(app, ui);
}
ui.add_space(10.0);
local_section(app, ui);
@@ -904,20 +904,22 @@ fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Resp
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) {
/// While an album that sits in a folder is dragged: the "Albums" header takes it back to the top
/// level (outlined, with a hint). The header stays in view however long the tree is, and nothing
/// moves when it appears.
fn top_level_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect) {
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 = pointer_over(ui, r);
register(ui.ctx(), "albumDrop:top", header);
let over = pointer_over(ui, header);
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);
ui.painter().rect_stroke(header.shrink2(vec2(8.0, 3.0)), 4.0, stroke, egui::StrokeKind::Inside);
// right-aligned, left of the + button
let edge = header.right().min(ui.clip_rect().right());
ui.painter().text(pos2(edge - 56.0, header.center().y), Align2::RIGHT_CENTER, crate::i18n::tr("Top Level"), t.font(12.5), t.text_dim);
if over && ui.input(|i| i.pointer.primary_released()) {
drop_album(app, ui, dragged, None);
}
+15 -18
View File
@@ -383,13 +383,9 @@ impl AlbumTree {
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)
/// The middle of the "Albums" header, where an album is dropped to take it to the top level.
fn albums_header(&self) -> egui::Pos2 {
widget(&self.h, "sidebarSection:albums").center()
}
fn parent(&self, id: u64) -> Option<u64> {
self.h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().parent.map(|p| p.0)
@@ -600,22 +596,23 @@ fn folders_are_placed_by_their_edges_and_entered_by_their_middle() {
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.
/// Dragging out of a folder: the "Albums" header is the way back to the top level (it stays in
/// view however long the tree is), only offered for an album that sits in a folder, and the move
/// is one undo step. The drop does not fold the section.
#[test]
fn dragging_an_album_to_the_top_level() {
fn dragging_an_album_to_the_albums_header_takes_it_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!(!has(&t, "albumDrop:top"), "no target unless an album is being dragged");
let (from, header) = (t.row(t.best).center(), t.albums_header());
t.drag(from, &[(header, 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!(!t.h.app.ui.sidebar_section_collapsed("albums"), "dropping on the header does not fold it");
// a top-level album has nowhere to go: dropping it on the header does nothing
let (from, header) = (t.row(t.best).center(), t.albums_header());
t.drag(from, &[(header, 3)]);
assert_eq!(t.parent(t.best), None);
assert!(!t.h.app.ui.sidebar_section_collapsed("albums"));
t.h.app.session.execute("edit.undo", &json!({})).unwrap();
assert_eq!(t.parent(t.best), Some(t.trips), "one undo puts it back");
}