On Android the desktop scrollbar is off, so every scroller that has one on a desktop (the develop column, the grid, the collections sidebar, Settings, the help sheet and the film list) gave no sign of how long it was or where the view was in it. That is how the black-and-white film stocks came to look deleted when the list could not scroll. ScrollBar now has a second mode, chosen in the one Scrolling global: where bars are off and `cue` is on, it draws the thumb alone, 3 px wide against the edge, while the viewport moves, and fades it 500 ms after the last move. It has no TouchArea, so a flick that starts on it scrolls the content. Rust sets cue on touch-first builds; a desktop build shows it when DR_SCROLL_CUE is set, to look at it without a device. Desktop is otherwise unchanged. Test: on the testing backend's help sheet, a press on the cue moves nothing, a drag starting on it carries the list with the finger, the cue is drawn while the list moves (drag, fling, wheel) and not once it is idle; with bars on there is no cue and the thumb still takes a drag.
236 lines
8.7 KiB
Rust
236 lines
8.7 KiB
Rust
// TRACES: FR-UI-2
|
|
//! A tablet's scrollers show a thin cue while they move, and a flick that
|
|
//! starts on it scrolls the list; a desktop's keep their bar.
|
|
//!
|
|
//! On Android the desktop scrollbar is off, and until the cue a list gave no
|
|
//! sign of how long it was or where the view was in it. The cue is the bar's
|
|
//! thumb without the bar: drawn while the viewport moves, faded out soon
|
|
//! after, and never a control — a finger that lands on it is scrolling the
|
|
//! list, not grabbing anything. Both halves are about behaviour over time and
|
|
//! under a pointer, so this runs the interface rather than reading it.
|
|
//!
|
|
//! It drives the help sheet, whose list is long and needs no library, on
|
|
//! Slint's testing backend: no display, the same event dispatch as a window,
|
|
//! and a mocked clock so the fade can be waited for exactly. The element
|
|
//! queries need the compiler's debug tables, which `build.rs` emits for debug
|
|
//! builds and the `automation` feature, so this is compiled out of a release
|
|
//! test run.
|
|
#![cfg(debug_assertions)]
|
|
|
|
use std::time::Duration;
|
|
|
|
use dr_ui::{AppWindow, GestureRow, Library, Scrolling};
|
|
use i_slint_backend_testing::{init_no_event_loop, mock_elapsed_time, ElementHandle};
|
|
use slint::platform::{PointerEventButton, WindowEvent};
|
|
use slint::{ComponentHandle, LogicalPosition, ModelRc, VecModel};
|
|
|
|
/// The window's size, and the help sheet's card inside it: 520 by 560,
|
|
/// centred (gestures.slint).
|
|
const W: f32 = 1000.;
|
|
const H: f32 = 800.;
|
|
|
|
/// The help sheet open over the library with a list several screens long,
|
|
/// showing bars or the cue.
|
|
fn sheet(cue: bool) -> AppWindow {
|
|
let app = AppWindow::new().expect("the window builds on the testing backend");
|
|
app.window().set_size(slint::LogicalSize::new(W, H));
|
|
let scrolling = app.global::<Scrolling>();
|
|
scrolling.set_bars(!cue);
|
|
scrolling.set_cue(cue);
|
|
let rows: Vec<GestureRow> = (0..80)
|
|
.map(|i| GestureRow {
|
|
title: format!("Gesture {i:02}").into(),
|
|
touch: "Flick".into(),
|
|
pointer: "Drag".into(),
|
|
..Default::default()
|
|
})
|
|
.collect();
|
|
app.global::<Library>()
|
|
.set_library_gestures(ModelRc::new(VecModel::from(rows)));
|
|
app.set_help_open(true);
|
|
app.show().unwrap();
|
|
settle();
|
|
app
|
|
}
|
|
|
|
/// Let flicks, the cue's idle timer and its fade run out, a frame at a time
|
|
/// so each animation starts from the tick its trigger fired on.
|
|
fn settle() {
|
|
for _ in 0..40 {
|
|
mock_elapsed_time(Duration::from_millis(50));
|
|
}
|
|
}
|
|
|
|
/// The help sheet's scrollbar: the one inside its card.
|
|
fn bar(app: &AppWindow) -> ElementHandle {
|
|
let (cx, cy) = ((W - 520.) / 2., (H - 560.) / 2.);
|
|
ElementHandle::find_by_element_type_name(app, "ScrollBar")
|
|
.find(|e| {
|
|
let (at, size) = (e.absolute_position(), e.size());
|
|
size.height > 0.
|
|
&& at.x >= cx
|
|
&& at.x + size.width <= cx + 520.
|
|
&& at.y >= cy
|
|
&& at.y + size.height <= cy + 560.
|
|
})
|
|
.expect("the help sheet's list has a scrollbar")
|
|
}
|
|
|
|
/// A named part of the sheet's scrollbar — `line` (the cue) or `thumb` —
|
|
/// if it is drawn: the queries skip what is not visible.
|
|
fn drawn(app: &AppWindow, id: &str) -> Option<ElementHandle> {
|
|
let b = bar(app);
|
|
let (at, size) = (b.absolute_position(), b.size());
|
|
ElementHandle::find_by_element_id(app, &format!("ScrollBar::{id}")).find(|e| {
|
|
let p = e.absolute_position();
|
|
p.x >= at.x && p.x <= at.x + size.width && p.y >= at.y && p.y <= at.y + size.height
|
|
})
|
|
}
|
|
|
|
fn part(app: &AppWindow, id: &str) -> ElementHandle {
|
|
drawn(app, id).unwrap_or_else(|| panic!("the sheet's scrollbar draws its {id}"))
|
|
}
|
|
|
|
/// How much of the cue shows: nothing when it is not drawn at all.
|
|
fn cue_opacity(app: &AppWindow) -> f32 {
|
|
drawn(app, "line").map_or(0., |e| e.computed_opacity())
|
|
}
|
|
|
|
/// Press at `from` and move to `to` in frame-sized steps, without letting go.
|
|
fn press_and_move(app: &AppWindow, from: LogicalPosition, to: LogicalPosition) {
|
|
press(app, from);
|
|
moves(app, from, to);
|
|
}
|
|
|
|
fn press(app: &AppWindow, at: LogicalPosition) {
|
|
let w = app.window();
|
|
w.dispatch_event(WindowEvent::PointerMoved { position: at });
|
|
w.dispatch_event(WindowEvent::PointerPressed {
|
|
position: at,
|
|
button: PointerEventButton::Left,
|
|
});
|
|
}
|
|
|
|
fn moves(app: &AppWindow, from: LogicalPosition, to: LogicalPosition) {
|
|
let w = app.window();
|
|
let steps = 20;
|
|
for i in 1..=steps {
|
|
let t = i as f32 / steps as f32;
|
|
let at = LogicalPosition::new(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
|
|
mock_elapsed_time(Duration::from_millis(16));
|
|
w.dispatch_event(WindowEvent::PointerMoved { position: at });
|
|
}
|
|
}
|
|
|
|
fn release(app: &AppWindow, at: LogicalPosition) {
|
|
app.window().dispatch_event(WindowEvent::PointerReleased {
|
|
position: at,
|
|
button: PointerEventButton::Left,
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn the_cue_shows_movement_and_takes_no_input_and_the_bar_is_unchanged() {
|
|
init_no_event_loop();
|
|
|
|
// The cue: a tablet.
|
|
let app = sheet(true);
|
|
let b = bar(&app);
|
|
let (top, height) = (b.absolute_position().y, b.size().height);
|
|
assert!(drawn(&app, "thumb").is_none(), "a tablet has no bar");
|
|
assert_eq!(cue_opacity(&app), 0., "at rest the cue is not seen");
|
|
let line = part(&app, "line");
|
|
assert!(
|
|
line.size().width <= 3.,
|
|
"the cue is a thin line, not a bar: {} px",
|
|
line.size().width
|
|
);
|
|
assert_eq!(line.absolute_position().y, top, "the list opens at its top");
|
|
|
|
// A flick that starts on the cue itself, 200 px up the list. The press
|
|
// alone moves nothing — a bar would have paged toward it — and the drag
|
|
// then carries the content with the finger, px for px, which is the
|
|
// Flickable's doing and nothing a scrollbar would do.
|
|
let x = line.absolute_position().x + line.size().width / 2.;
|
|
let from = LogicalPosition::new(x, top + height - 20.);
|
|
let to = LogicalPosition::new(x, top + height - 220.);
|
|
press(&app, from);
|
|
mock_elapsed_time(Duration::from_millis(100));
|
|
assert_eq!(
|
|
part(&app, "line").absolute_position().y,
|
|
top,
|
|
"a press on the cue does nothing by itself"
|
|
);
|
|
moves(&app, from, to);
|
|
// The cue is the thumb's geometry: its height is the visible share of
|
|
// the list, so the list's length and the content's travel follow.
|
|
let thumb = line.size().height;
|
|
let range = height * height / thumb - height;
|
|
let expected = (height - thumb) * 200. / range;
|
|
let moved = part(&app, "line").absolute_position().y - top;
|
|
assert!(
|
|
(moved - expected).abs() < 2.,
|
|
"a drag starting on the cue scrolls the list under it with the finger: \
|
|
the cue moved {moved} px, a 200 px drag moves it {expected}"
|
|
);
|
|
assert_eq!(
|
|
cue_opacity(&app),
|
|
1.,
|
|
"the cue is drawn while the list moves"
|
|
);
|
|
release(&app, to);
|
|
mock_elapsed_time(Duration::from_millis(100));
|
|
assert_eq!(
|
|
cue_opacity(&app),
|
|
1.,
|
|
"the cue stays while the fling carries on and just after"
|
|
);
|
|
settle();
|
|
assert_eq!(cue_opacity(&app), 0., "the cue fades once the list is idle");
|
|
|
|
// A jump the photographer did not drag — here a wheel — shows it too.
|
|
let over = LogicalPosition::new(W / 2., H / 2.);
|
|
app.window().dispatch_event(WindowEvent::PointerScrolled {
|
|
position: over,
|
|
delta_x: 0.,
|
|
delta_y: 120.,
|
|
});
|
|
for _ in 0..4 {
|
|
mock_elapsed_time(Duration::from_millis(50));
|
|
}
|
|
assert_eq!(
|
|
cue_opacity(&app),
|
|
1.,
|
|
"any movement of the view shows the cue"
|
|
);
|
|
settle();
|
|
assert_eq!(cue_opacity(&app), 0.);
|
|
drop(app);
|
|
|
|
// The bar: a desktop, as it was.
|
|
let app = sheet(false);
|
|
let thumb = part(&app, "thumb");
|
|
let b = bar(&app);
|
|
let (top, height) = (b.absolute_position().y, b.size().height);
|
|
assert_eq!(thumb.absolute_position().y, top);
|
|
assert!(drawn(&app, "line").is_none(), "a desktop has no cue");
|
|
|
|
// The thumb dragged down takes the list down: the bar has the press, not
|
|
// the list, which would have been dragged the other way and gone nowhere.
|
|
let x = thumb.absolute_position().x + thumb.size().width / 2.;
|
|
let from = LogicalPosition::new(x, thumb.absolute_position().y + 4.);
|
|
let to = LogicalPosition::new(x, top + height + 40.);
|
|
press_and_move(&app, from, to);
|
|
assert!(
|
|
drawn(&app, "line").is_none(),
|
|
"a desktop draws no cue while it scrolls"
|
|
);
|
|
release(&app, to);
|
|
settle();
|
|
let moved = part(&app, "thumb");
|
|
assert!(
|
|
moved.absolute_position().y + moved.size().height >= top + height - 1.,
|
|
"dragging the thumb to the bottom of its track reaches the end"
|
|
);
|
|
}
|