From 8ff45f5237c6bc83305dc84a23fba0d403504775 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:59:12 -0400 Subject: [PATCH] Sidebar: dragging albums or photos near its top or bottom edge scrolls it Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/left.rs | 31 +++++++++++++ crates/ui-egui/src/tests_panels.rs | 72 ++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 98182936f..8863e5d18 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -179,6 +179,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let counts = app.caches.counts(&app.session.catalog); let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted); egui::ScrollArea::both().id_salt("left-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + drag_auto_scroll(app, ui); // rows are as wide as the widest one needs (last frame), at least the panel let wide = viewport.width().max(content_width(ui.ctx())); ui.set_min_width(wide); @@ -751,6 +752,36 @@ fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool { ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p)) } +/// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it. +const SCROLL_EDGE: f32 = 36.0; +/// The fastest the sidebar scrolls for a drag (points per second). +const SCROLL_MAX_SPEED: f32 = 700.0; + +/// How fast the sidebar scrolls (points per second, positive = down) for a dragged item with the +/// pointer at `y`, the visible part being `top..bottom`: none in the middle, faster the nearer to +/// the edge, the top speed at and past it. +pub fn auto_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 { + let depth = |dist: f32| ((SCROLL_EDGE - dist) / SCROLL_EDGE).clamp(0.0, 1.0); + SCROLL_MAX_SPEED * (depth(bottom - y) - depth(y - top)) +} + +/// While an album, or photos from the grid, are dragged over the sidebar: scrolls it when the +/// pointer is near its top or bottom edge, so rows beyond the visible part can be reached. Call +/// inside the scroll area. +fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) { + if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() { + return; + } + let view = ui.clip_rect(); + let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return }; + let speed = auto_scroll_speed(p.y, view.top(), view.bottom()); + if speed != 0.0 { + // a positive delta moves the content down: the view goes up + ui.scroll_with_delta_animation(vec2(0.0, -speed * ui.input(|i| i.stable_dt).min(0.1)), egui::style::ScrollAnimation::none()); + ui.ctx().request_repaint(); + } +} + /// How long a dragged album must rest on a closed folder before it opens. const HOVER_OPEN_SECS: f64 = 0.6; diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 98b6da647..66eb360e1 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -596,6 +596,78 @@ 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"); } +/// Holding a dragged album near the top or bottom edge of the sidebar scrolls it that way; in the +/// middle, or beside the sidebar, nothing scrolls. (Each drag ends with Esc: nothing is dropped.) +#[test] +fn a_dragged_album_scrolls_the_sidebar_at_its_edges() { + let mut t = album_tree(); + for i in 0..60 { + t.h.app.session.execute("album.create", &json!({"name": format!("Z{i:02}")})).unwrap(); + } + t.h.step(); + t.h.step(); + let last = t.id_of("Z59"); + let panel = widget(&t.h, "panel:left_panel"); + let (cx, top, bottom) = (panel.center().x, panel.top(), panel.bottom()); + let start = t.row(last).top(); + assert!(start > bottom, "the last album starts below the visible sidebar: {start} vs {bottom}"); + // a row in the middle of the visible sidebar: where a drag starts + let pick = |t: &AlbumTree| { + let rows = t.h.app.widgets.iter().filter(|(w, r)| w.starts_with("source:album:") && r.top() > top + 120.0 && r.bottom() < bottom - 120.0); + rows.map(|(_, r)| r.center()).next().expect("a visible album row") + }; + let esc = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let hold = |t: &mut AlbumTree, to: egui::Pos2| { + let from = pick(t); + t.drag_with(from, &[(to, 90)], vec![esc(true), esc(false)]); + }; + hold(&mut t, egui::pos2(cx, panel.center().y)); + assert_eq!(t.row(last).top(), start, "no scrolling in the middle"); + hold(&mut t, egui::pos2(panel.right() + 200.0, bottom - 6.0)); + assert_eq!(t.row(last).top(), start, "no scrolling beside the sidebar"); + hold(&mut t, egui::pos2(cx, bottom - 6.0)); + let scrolled = t.row(last).top(); + assert!(scrolled < start - 100.0, "the bottom edge scrolls down: {start} -> {scrolled}"); + hold(&mut t, egui::pos2(cx, top + 70.0)); + let back = t.row(last).top(); + assert!(back > scrolled + 100.0, "the top edge scrolls back up: {scrolled} -> {back}"); + assert!(t.h.app.ui.dragging_album.is_none()); +} + +/// Photos dragged from the grid scroll the sidebar the same way, to reach an album further down. +#[test] +fn dragged_photos_scroll_the_sidebar_at_its_edges() { + let mut t = album_tree(); + for i in 0..60 { + t.h.app.session.execute("album.create", &json!({"name": format!("Z{i:02}")})).unwrap(); + } + t.h.step(); + t.h.step(); + let (last, panel) = (t.id_of("Z59"), widget(&t.h, "panel:left_panel")); + let start = t.row(last).top(); + let first = t.h.app.session.visible_cloned()[0].0; + let thumb = widget(&t.h, &format!("thumb:{first}")).center(); + t.drag(thumb, &[(egui::pos2(panel.center().x, panel.bottom() - 6.0), 90)]); + assert!(t.row(last).top() < start - 100.0, "scrolled down: {start} -> {}", t.row(last).top()); + assert!(t.h.app.ui.dragging_photos.is_none(), "the drag ended"); +} + +/// How fast the sidebar scrolls for a pointer at `y` (+ down) is zero in the middle, ramps up +/// within the edge band and stays at the top speed past the edge. +#[test] +fn auto_scroll_speed_ramps_up_at_the_edges() { + use crate::panels::left::auto_scroll_speed; + let (top, bottom) = (100.0, 700.0); + assert_eq!(auto_scroll_speed(400.0, top, bottom), 0.0); + assert_eq!(auto_scroll_speed(top + 60.0, top, bottom), 0.0, "outside the band"); + let (slow, fast, past) = + (auto_scroll_speed(bottom - 30.0, top, bottom), auto_scroll_speed(bottom - 5.0, top, bottom), auto_scroll_speed(bottom + 80.0, top, bottom)); + assert!(0.0 < slow && slow < fast && fast <= past, "{slow} {fast} {past}"); + assert_eq!(past, auto_scroll_speed(bottom + 400.0, top, bottom), "capped"); + let up = auto_scroll_speed(top + 5.0, top, bottom); + assert!(up < 0.0 && (up + fast).abs() < 1e-3, "the same ramp upwards: {up} vs {fast}"); +} + /// 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.