Files
lightcraft/crates/ui-egui/src/tests_scroll.rs
T
Markus FriedelandClaude Opus 5.5 8907f5f56f LR-VIEW-PHOTOGRID: a mouse-wheel notch scrolls a third of the grid or filmstrip (#1481)
One notch scrolls the photo grid by a third of its visible height and the
filmstrip by a third of its width, as in Lightroom Classic, instead of the fixed
100 points a side panel scrolls. It jumps at once instead of gliding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:38:21 +02:00

272 lines
12 KiB
Rust

//! Headless tests of the photo grid's and the filmstrip's scrolling (issue #11): the user's
//! scroll position stays put until the active photo changes; the mouse wheel scrolls the
//! filmstrip sideways. The filmstrip also shows which photos are selected (issue #187).
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn demo(view: &str) -> Headless {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
// big thumbnails: the demo library is several screens tall
let r = h.request("ui.set", json!({"view": view, "thumbSize": 480.0}), T);
assert_eq!(r["ok"], true, "{r}");
let first = h.app.session.visible_cloned()[0].0;
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first]}}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
h
}
/// Run `n` frames (plus a settle: thumbnails and renders finishing must not move anything).
fn idle(h: &mut Headless, n: usize) {
h.settle(SETTLE);
for _ in 0..n {
h.step();
}
}
fn grid_y(h: &Headless) -> f32 {
h.app.grid_scroll.expect("grid drawn")
}
fn film_x(h: &Headless) -> f32 {
h.app.film_scroll.expect("filmstrip drawn")
}
fn widget(h: &Headless, id: &str) -> egui::Rect {
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))
}
#[test]
fn grid_keeps_the_users_scroll_position() {
let mut h = demo("photoGrid");
assert_eq!(grid_y(&h), 0.0);
let canvas = h.app.canvas_rect.expect("grid canvas");
// the mouse wheel scrolls the grid away from the active (first) photo…
h.request("ui.move", json!({"x": canvas.center().x, "y": canvas.center().y}), T);
for _ in 0..4 {
h.request("ui.scroll", json!({"dy": -300.0}), T);
}
idle(&mut h, 120);
let y = grid_y(&h);
assert!(y > 600.0, "the wheel scrolled the grid ({y})");
// …and it stays there: no snap back after a second and more of frames and finished renders
idle(&mut h, 120);
assert_eq!(grid_y(&h), y, "the grid snapped back");
// dragging the scroll bar back up a bit, then releasing it
let x = canvas.right() - 3.0;
let r = h.request("ui.drag", json!({"x": x, "y": canvas.top() + 300.0, "toX": x, "toY": canvas.top() + 200.0}), T);
assert_eq!(r["ok"], true, "{r}");
idle(&mut h, 120);
let y2 = grid_y(&h);
assert!(y2 > 0.0 && (y2 - y).abs() > 1.0, "the scroll bar moved the grid ({y} → {y2})");
// editing the active photo does not yank the grid back either
let r = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T);
assert_eq!(r["ok"], true, "{r}");
idle(&mut h, 60);
assert_eq!(grid_y(&h), y2, "an edit snapped the grid back");
// a new active photo (keyboard) brings it into view
h.request("ui.key", json!({"key": "right"}), T);
idle(&mut h, 60);
let second = h.app.session.visible_cloned()[1].0;
assert_eq!(h.app.session.active().map(|p| p.0), Some(second));
let cell = widget(&h, &format!("thumb:{second}"));
assert!(canvas.contains_rect(cell) || cell.intersects(canvas), "the new active photo is in view ({cell:?} vs {canvas:?})");
assert!(grid_y(&h) < y2, "scrolled up to the second photo");
}
#[test]
fn deleting_photos_does_not_jump_the_grid_to_the_top() {
for view in ["photoGrid", "squareGrid"] {
for count in [1, 3] {
let mut h = demo(view);
let ids = h.app.session.visible_cloned();
let at = ids.len() / 2;
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": &ids[at..at + count]}}), T);
assert_eq!(r["ok"], true, "{r}");
idle(&mut h, 30);
let y = grid_y(&h);
assert!(y > 600.0, "selection is well below the top: {view}, {y}");
let r = h.request("engine.execute", json!({"command": "photo.delete"}), T);
assert_eq!(r["ok"], true, "{r}");
idle(&mut h, 60);
let next = ids[at + count];
assert!(grid_y(&h) > 600.0, "deleting scrolled to the top: {view}, {} → {}", y, grid_y(&h));
assert_eq!(h.app.session.active(), Some(next), "{view}, count {count}");
let canvas = h.app.canvas_rect.unwrap();
assert!(widget(&h, &format!("thumb:{}", next.0)).intersects(canvas), "the next survivor is in view");
let after = grid_y(&h);
idle(&mut h, 60);
assert_eq!(grid_y(&h), after, "idle frames preserve the new position");
}
}
}
#[test]
fn permanently_deleting_photos_keeps_the_current_view_neighbour_in_sight() {
for view in ["photoGrid", "squareGrid", "detail"] {
for count in [1, 3] {
let mut h = demo(view);
for (command, params) in
[("library.selectAll", json!({})), ("photo.delete", json!({})), ("library.source", json!({"kind": "recentlyDeleted"}))]
{
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{r}");
}
let ids = h.app.session.visible_cloned();
let at = ids.len() / 2;
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": &ids[at..at + count]}}), T);
assert_eq!(r["ok"], true, "{r}");
idle(&mut h, 30);
let scroll = |h: &Headless| if view == "detail" { film_x(h) } else { grid_y(h) };
let before = scroll(&h);
assert!(before > 600.0, "selection is far from the beginning: {view}, {before}");
let r = h.request("ui.menu.invoke", json!({"id": "photo.deletePermanently"}), T);
assert_eq!(r["ok"], true, "{r}");
idle(&mut h, 60);
let next = ids[at + count];
assert_eq!(h.app.session.active(), Some(next), "{view}, count {count}");
assert!(scroll(&h) > 600.0, "deleting scrolled to the beginning: {view}, {before} → {}", scroll(&h));
let cell = widget(&h, &format!("{}:{}", if view == "detail" { "film" } else { "thumb" }, next.0));
if view == "detail" {
assert!(cell.left() >= 0.0 && cell.right() <= 1200.0, "the next survivor is in view: {cell:?}");
} else {
assert!(cell.intersects(h.app.canvas_rect.unwrap()), "the next survivor is in view");
}
let after = scroll(&h);
idle(&mut h, 60);
assert_eq!(scroll(&h), after, "idle frames preserve the new position");
}
}
}
#[test]
fn filmstrip_wheel_scrolls_and_keeps_its_position() {
let mut h = demo("detail");
let first = h.app.session.visible_cloned()[0].0;
let x0 = film_x(&h);
let film = widget(&h, &format!("film:{first}"));
// a vertical wheel over the filmstrip scrolls it sideways
h.request("ui.move", json!({"x": 600.0, "y": film.center().y}), T);
for _ in 0..3 {
h.request("ui.scroll", json!({"dy": -200.0}), T);
}
idle(&mut h, 60);
let x = film_x(&h);
assert!(x > x0 + 300.0, "the wheel scrolled the filmstrip ({x0} → {x})");
// a horizontal (trackpad) scroll still works
h.request("ui.scroll", json!({"dx": 100.0}), T);
idle(&mut h, 60);
let x1 = film_x(&h);
assert!(x1 < x - 50.0, "a horizontal scroll moved it back ({x} → {x1})");
// it stays there
idle(&mut h, 120);
assert_eq!(film_x(&h), x1, "the filmstrip snapped back");
// the next photo by keyboard brings the filmstrip to it
h.request("ui.key", json!({"key": "right"}), T);
idle(&mut h, 60);
let second = h.app.session.visible_cloned()[1].0;
assert_eq!(h.app.session.active().map(|p| p.0), Some(second));
assert!(film_x(&h) < x1, "scrolled back to the active photo");
let cell = widget(&h, &format!("film:{second}"));
assert!(cell.left() >= 0.0 && cell.right() <= 1200.0, "the new active photo is in view ({cell:?})");
}
#[test]
fn filmstrip_shows_every_selected_photo() {
let mut h = demo("detail");
let ids = h.app.session.visible_cloned();
let (first, second) = (ids[0].0, ids[1].0);
// a cell's background, left of its thumbnail
let fill = |h: &mut Headless, id: u64| {
let r = widget(h, &format!("film:{id}"));
let img = h.snapshot(SETTLE);
img.pixels[(r.center().y as usize) * img.width() + r.left() as usize + 4]
};
let active = fill(&mut h, first);
let idle = fill(&mut h, second);
assert_ne!(active, idle, "only the active photo is selected");
// Select All: the other photos are selected too, the first stays active
let r = h.request("engine.execute", json!({"command": "library.selectAll"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.active().map(|p| p.0), Some(first));
assert_eq!(fill(&mut h, second), active, "a selected photo that is not the active one looks selected");
// back to one photo; then what a ⌘-click on a filmstrip cell runs (toggle) adds the second
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first]}}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(fill(&mut h, second), idle);
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [second], "mode": "toggle"}}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(fill(&mut h, second), active);
}
/// One notch of a mouse wheel (a line).
fn notch(h: &mut Headless, dy: f32) {
h.events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, dy),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.step();
}
/// A mouse wheel notch scrolls the grid a third of its height, as in Lightroom (two rows of six), not the
/// `theme::WHEEL_LINE_POINTS` a side panel scrolls: at big thumbnails that was a quarter of a row. Exactly
/// a third, not whole rows, so the photos don't land where the last ones were, and at once, without a
/// glide that blurs them. A trackpad still scrolls the points it reports.
#[test]
fn a_wheel_notch_scrolls_a_third_of_the_grid() {
let mut h = demo("photoGrid");
let view = widget(&h, "grid:scroll");
h.request("ui.move", json!({"x": view.center().x, "y": view.center().y}), T);
let y0 = grid_y(&h);
notch(&mut h, -1.0);
// at once, no glide: the frame after the notch draws it there
h.step();
h.step();
let moved = grid_y(&h) - y0;
assert!((moved - view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch scrolled {moved} of {} points", view.height());
idle(&mut h, 120);
assert!((grid_y(&h) - y0 - moved).abs() < 0.5, "and no further: {} after the glide", grid_y(&h) - y0);
let y1 = grid_y(&h);
for _ in 0..2 {
notch(&mut h, -1.0);
}
idle(&mut h, 120);
let moved = grid_y(&h) - y1;
assert!((moved - 2.0 * view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "two notches scrolled {moved} of {} points", view.height());
// back up
let y2 = grid_y(&h);
notch(&mut h, 1.0);
idle(&mut h, 120);
assert!((y2 - grid_y(&h) - view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch up scrolled back a third");
// a trackpad (points) scrolls what it reports
let y3 = grid_y(&h);
h.request("ui.scroll", json!({"dy": -60.0}), T);
idle(&mut h, 120);
assert!((grid_y(&h) - y3 - 60.0).abs() < 1.0, "a 60-point trackpad scroll moved {}", grid_y(&h) - y3);
}
/// The same in the filmstrip: a notch scrolls it sideways by a third of its width (about five photos).
#[test]
fn a_wheel_notch_scrolls_a_third_of_the_filmstrip() {
let mut h = demo("detail");
let strip = widget(&h, "filmstrip");
h.request("ui.move", json!({"x": strip.center().x, "y": strip.center().y}), T);
let x0 = film_x(&h);
notch(&mut h, -1.0);
idle(&mut h, 120);
let moved = film_x(&h) - x0;
assert!((moved - strip.width() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch scrolled {moved} of {} points", strip.width());
}