mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
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>
272 lines
12 KiB
Rust
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());
|
|
}
|