From 24b80b4ee3666a98e19bb14d0a99e9e70ad0fdb1 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:52:08 -0400 Subject: [PATCH] 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 --- crates/ui-egui/src/panels/left.rs | 20 ++++++++++-------- crates/ui-egui/src/tests_panels.rs | 33 ++++++++++++++---------------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 64b3a98b1..98182936f 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -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); } diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 364fdc2be..98b6da647 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -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 { 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"); }