use std::{cell::RefCell, rc::Rc};
use cranpose_app_shell::AppShell;
use cranpose_core::location_key;
use cranpose_foundation::lazy::{LazyItems, LazyListScope, LazyListState, rememberLazyListState};
use cranpose_ui::{
Color, LinearArrangement, Modifier, TextStyle, composable,
widgets::{Box, BoxSpec, LazyColumn, LazyColumnSpec, Text},
};
use crate::support;
const FRAME_WIDTH: u32 = 320;
const FRAME_HEIGHT: u32 = 480;
const ROW_HEIGHT: f32 = 72.0;
const ROW_GAP: f32 = 10.0;
#[composable]
fn ColoredRow(index: usize) {
let fill = match index % 4 {
0 => Color(0.85, 0.25, 0.25, 1.0),
1 => Color(0.20, 0.55, 0.90, 1.0),
2 => Color(0.25, 0.75, 0.40, 1.0),
_ => Color(0.90, 0.75, 0.20, 1.0),
};
Box(
Modifier::empty()
.fill_max_width()
.height(ROW_HEIGHT)
.background(fill),
BoxSpec::new(),
move || {
Text(
format!("row {index}"),
Modifier::empty(),
TextStyle::default(),
);
},
);
}
struct Harness {
shell: AppShell<cranpose_render_wgpu::WgpuRenderer>,
list_state: Rc<RefCell<Option<LazyListState>>>,
}
impl Harness {
fn new(renderer: cranpose_render_wgpu::WgpuRenderer) -> Self {
let root_key = location_key(file!(), line!(), column!());
let list_state: Rc<RefCell<Option<LazyListState>>> = Rc::new(RefCell::new(None));
let list_state_for_app = Rc::clone(&list_state);
let mut shell = AppShell::new(renderer, root_key, move || {
let state = rememberLazyListState();
*list_state_for_app.borrow_mut() = Some(state);
Box(
Modifier::empty()
.size_points(FRAME_WIDTH as f32, FRAME_HEIGHT as f32)
.background(Color(0.05, 0.06, 0.08, 1.0)),
BoxSpec::new(),
move || {
LazyColumn(
Modifier::empty().fill_max_size(),
state,
LazyColumnSpec::new()
.vertical_arrangement(LinearArrangement::SpacedBy(ROW_GAP)),
|scope| {
scope.items(LazyItems::new(40).key(|i: usize| i as u64), ColoredRow);
},
);
},
);
});
shell.set_viewport(FRAME_WIDTH as f32, FRAME_HEIGHT as f32);
shell.set_buffer_size(FRAME_WIDTH, FRAME_HEIGHT);
shell.update();
Self { shell, list_state }
}
fn capture(&mut self) -> cranpose_render_wgpu::CapturedFrame {
self.shell
.renderer()
.capture_frame(FRAME_WIDTH, FRAME_HEIGHT)
.expect("frame capture should succeed")
}
fn scroll(&mut self, delta: f32) -> f32 {
let state = self
.list_state
.borrow()
.as_ref()
.copied()
.expect("list state captured");
let consumed = self
.shell
.debug_enter_app_context(|| state.dispatch_scroll_delta(delta));
self.shell.update();
consumed
}
}
fn blue_row_top(frame: &cranpose_render_wgpu::CapturedFrame, x: u32) -> Option<u32> {
(0..frame.height).find(|&y| {
let offset = ((y * frame.width + x) * 4) as usize;
let (r, g, b) = (
frame.pixels[offset],
frame.pixels[offset + 1],
frame.pixels[offset + 2],
);
b > 180 && g > 100 && g < 180 && r < 100
})
}
#[test]
fn a_scrolled_frame_moves_its_pixels_by_the_consumed_delta() {
let (_lock, renderer) = support::headless_renderer_parts().expect("headless renderer");
let mut harness = Harness::new(renderer);
harness.scroll(-1.0);
let before = harness.capture();
let before_top = blue_row_top(&before, FRAME_WIDTH / 2)
.expect("the second (blue) row must be visible before the scroll");
let consumed = harness.scroll(-30.0);
assert!(
consumed.abs() > 20.0,
"the scroll must consume most of the delta, consumed={consumed}"
);
let after = harness.capture();
let after_top = blue_row_top(&after, FRAME_WIDTH / 2)
.expect("the blue row must still be visible after the scroll");
let moved = before_top as i64 - after_top as i64;
let expected = consumed.abs().round() as i64;
assert!(
(moved - expected).abs() <= 1,
"the presented pixels must move with the scroll: blue row top \
{before_top} -> {after_top} (moved {moved}px, consumed {expected}px)"
);
}