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"); }