Sidebar: dragging albums or photos near its top or bottom edge scrolls it

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 5fefe91640
commit 8ff45f5237
2 changed files with 103 additions and 0 deletions
+31
View File
@@ -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;
+72
View File
@@ -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.