use std::{cell::Cell, rc::Rc};
use cranpose_app_shell::AppShell;
use cranpose_core::location_key;
use cranpose_testing::{robot::TestRenderer, robot_assertions::find_semantics_node};
use cranpose_ui::{
Box, BoxSpec, HorizontalPager, Modifier, PageSize, PagerSpec, PagerState, VerticalPager,
rememberPagerState,
};
const WIDTH: f32 = 300.0;
const HEIGHT: f32 = 200.0;
const PAGES: usize = 5;
struct Pager {
shell: AppShell<TestRenderer>,
state: Rc<Cell<Option<PagerState>>>,
now_ms: i64,
frame_ns: u64,
}
impl Pager {
fn new(horizontal: bool) -> Self {
Self::with(horizontal, PagerSpec::default())
}
fn with(horizontal: bool, spec: PagerSpec) -> Self {
let state = Rc::new(Cell::new(None));
let captured = Rc::clone(&state);
let mut shell = AppShell::new(
TestRenderer::default(),
location_key(file!(), line!(), column!()),
move || {
let pager = rememberPagerState(0);
captured.set(Some(pager));
let size = Modifier::empty().size_points(WIDTH, HEIGHT);
let page = |page: usize| {
Box(
Modifier::empty()
.fill_max_size()
.content_description(format!("page {page}")),
BoxSpec::default(),
|| {},
);
};
if horizontal {
HorizontalPager(size, pager, spec, PAGES, page);
} else {
VerticalPager(size, pager, spec, PAGES, page);
}
},
);
shell.set_viewport(WIDTH, HEIGHT);
shell.set_buffer_size(WIDTH as u32, HEIGHT as u32);
shell.set_semantics_enabled(true);
let mut pager = Self {
shell,
state,
now_ms: 1_000_000,
frame_ns: 16_000_000,
};
pager.frames(2);
pager
}
fn state(&self) -> PagerState {
self.state.get().expect("composed pager")
}
fn frames(&mut self, count: usize) {
for _ in 0..count {
self.frame_ns += 16_000_000;
self.shell.update_at_frame_time_nanos(self.frame_ns);
}
}
fn drag(&mut self, horizontal: bool, from: f32, to: f32, millis: i64) {
let point = |main: f32| {
if horizontal {
(main, HEIGHT / 2.0)
} else {
(WIDTH / 2.0, main)
}
};
let (x, y) = point(from);
self.shell.set_cursor_at_time(x, y, Some(self.now_ms));
self.shell.pointer_pressed_at_time(Some(self.now_ms));
let steps = 10;
for step in 1..=steps {
self.now_ms += millis / steps;
let (x, y) = point(from + (to - from) * step as f32 / steps as f32);
self.shell.set_cursor_at_time(x, y, Some(self.now_ms));
self.frames(1);
}
self.now_ms += 8;
let (x, y) = point(to);
self.shell
.pointer_released_at_position_time(x, y, Some(self.now_ms));
self.frames(90);
}
fn start(&mut self, horizontal: bool, page: usize) -> Option<f32> {
let tree = self.shell.semantics_tree().expect("semantics");
let name = format!("page {page}");
find_semantics_node(tree.root(), &|node| {
node.description.as_deref() == Some(name.as_str())
})
.map(|node| {
if horizontal {
node.bounds.x
} else {
node.bounds.y
}
})
}
fn reader_page(&mut self, horizontal: bool, forward: bool) -> bool {
let tree = self.shell.semantics_tree().expect("semantics");
let scroll_by =
find_semantics_node(tree.root(), &|node| node.details().scroll_by.is_some())
.and_then(|node| node.details().scroll_by.clone())
.expect("the pager scrolls for a reader");
let main = if horizontal { WIDTH } else { HEIGHT };
let step = if forward { 0.9 * main } else { -0.9 * main };
let (dx, dy) = if horizontal { (step, 0.0) } else { (0.0, step) };
let taken = self
.shell
.app_context()
.enter(|| cranpose_core::run_in_mutable_snapshot(|| scroll_by.invoke(dx, dy)))
.expect("reader action");
self.frames(90);
taken
}
fn rests_on(&mut self, horizontal: bool, page: usize) -> bool {
self.state().current_page() == page
&& self
.start(horizontal, page)
.is_some_and(|start| start.abs() < 0.5)
}
}
#[test]
fn a_slow_drag_past_half_a_page_turns_it_and_one_short_of_half_goes_back() {
let mut pager = Pager::new(true);
pager.drag(true, 250.0, 200.0, 1_000);
assert!(pager.rests_on(true, 0), "a short slow drag goes back");
pager.drag(true, 250.0, 80.0, 1_000);
assert!(pager.rests_on(true, 1), "past half a page turns it");
}
#[test]
fn a_quick_flick_turns_the_page_however_short() {
let mut pager = Pager::new(true);
pager.drag(true, 250.0, 220.0, 30);
assert!(pager.rests_on(true, 1), "a flick toward the next page");
pager.drag(true, 80.0, 110.0, 30);
assert!(pager.rests_on(true, 0), "a flick back");
}
#[test]
fn a_long_fast_fling_turns_one_page() {
let mut pager = Pager::new(true);
pager.drag(true, 290.0, 10.0, 40);
assert!(pager.rests_on(true, 1));
}
#[test]
fn scroll_to_page_shows_the_page_and_a_mid_drag_reports_its_fraction() {
let mut pager = Pager::new(true);
pager.state().scroll_to_page(3);
pager.frames(2);
assert!(pager.rests_on(true, 3));
pager
.shell
.set_cursor_at_time(250.0, 100.0, Some(pager.now_ms));
pager.shell.pointer_pressed_at_time(Some(pager.now_ms));
for x in [240.0, 220.0, 190.0] {
pager.now_ms += 100;
pager.shell.set_cursor_at_time(x, 100.0, Some(pager.now_ms));
pager.frames(1);
}
let state = pager.state();
assert_eq!(state.current_page(), 3);
assert!(
(state.current_page_offset_fraction() - 0.2).abs() < 0.05,
"60 of 300 pixels is a fifth of a page, read {}",
state.current_page_offset_fraction()
);
}
#[test]
fn a_vertical_pager_turns_its_pages_up() {
let mut pager = Pager::new(false);
pager.drag(false, 180.0, 60.0, 1_000);
assert!(pager.rests_on(false, 1));
}
#[test]
fn animate_scroll_to_page_springs_there_and_a_press_stops_it_where_it_is() {
let mut pager = Pager::new(true);
pager.state().animate_scroll_to_page(3);
pager.frames(4);
let moving = pager.state().lazy_list_state().first_visible_item_index();
assert!(
moving < 3,
"the spring takes frames, at page {moving} after four"
);
pager.frames(120);
assert!(pager.rests_on(true, 3));
pager.state().animate_scroll_to_page(0);
pager.frames(4);
pager
.shell
.set_cursor_at_time(150.0, 100.0, Some(pager.now_ms));
pager.shell.pointer_pressed_at_time(Some(pager.now_ms));
let held = pager
.state()
.lazy_list_state()
.first_visible_item_scroll_offset();
let held_page = pager.state().lazy_list_state().first_visible_item_index();
pager.frames(30);
assert_eq!(
(
pager.state().lazy_list_state().first_visible_item_index(),
pager
.state()
.lazy_list_state()
.first_visible_item_scroll_offset()
),
(held_page, held),
"the press stopped the spring"
);
pager.now_ms += 16;
pager
.shell
.pointer_released_at_position_time(150.0, 100.0, Some(pager.now_ms));
pager.frames(90);
let rested = pager.state().current_page();
assert!(
rested > 0 && pager.rests_on(true, rested),
"it settles where it stopped, on {rested}"
);
}
#[test]
fn a_screen_reader_page_turns_whole_pages() {
for horizontal in [true, false] {
let mut pager = Pager::new(horizontal);
assert!(pager.reader_page(horizontal, true));
assert!(pager.rests_on(horizontal, 1), "forward to the next page");
assert!(pager.reader_page(horizontal, true));
assert!(pager.rests_on(horizontal, 2));
assert!(pager.reader_page(horizontal, false));
assert!(pager.rests_on(horizontal, 1), "back to the page before");
assert!(pager.reader_page(horizontal, false));
assert!(pager.rests_on(horizontal, 0));
assert!(
!pager.reader_page(horizontal, false),
"nothing before the first page"
);
assert!(pager.rests_on(horizontal, 0));
}
}
#[test]
fn fixed_pages_with_spacing_show_the_next_page_and_turn_by_a_page_and_a_gap() {
let spec = PagerSpec {
page_size: PageSize::Fixed(200.0),
page_spacing: 10.0,
};
let mut pager = Pager::with(true, spec);
let start = |pager: &mut Pager, page| {
pager
.start(true, page)
.map(|start| (start * 100.0).round() / 100.0)
};
assert_eq!(start(&mut pager, 1), Some(210.0), "the next page beside");
pager.drag(true, 250.0, 130.0, 1_000);
assert!(pager.rests_on(true, 1), "past half of 210 pixels turns it");
assert_eq!(start(&mut pager, 2), Some(210.0));
assert!(pager.state().current_page_offset_fraction().abs() < 0.001);
}