// 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.set_bars(!cue); scrolling.set_cue(cue); let rows: Vec = (0..80) .map(|i| GestureRow { title: format!("Gesture {i:02}").into(), touch: "Flick".into(), pointer: "Drag".into(), ..Default::default() }) .collect(); app.global::() .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 { 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" ); }