use std::cell::RefCell;
use std::rc::Rc;
use rlvgl_core::event::Event;
use rlvgl_core::font::{FontLineMetrics, FontMetrics, GlyphInfo, shape_text_ltr};
use rlvgl_core::renderer::Renderer;
use rlvgl_core::style::StyleBuilder;
use rlvgl_core::widget::{Color, Rect, Widget};
use rlvgl_widgets::scroll_view::ScrollView;
const W: usize = 480;
const H: usize = 800;
const BG: u32 = 0xFF10_1018;
struct Buffer {
pixels: Vec<u32>,
}
impl Buffer {
fn new() -> Self {
Self {
pixels: vec![BG; W * H],
}
}
fn at(&self, x: i32, y: i32) -> u32 {
self.pixels[y as usize * W + x as usize]
}
}
impl Renderer for Buffer {
fn fill_rect(&mut self, rect: Rect, color: Color) {
let argb = color.to_argb8888();
for y in rect.y.max(0)..(rect.y + rect.height).min(H as i32) {
for x in rect.x.max(0)..(rect.x + rect.width).min(W as i32) {
self.pixels[y as usize * W + x as usize] = argb;
}
}
}
fn draw_text(&mut self, _position: (i32, i32), _text: &str, _color: Color) {}
}
struct Cell {
bounds: Rect,
color: Color,
}
impl Cell {
fn new(bounds: Rect, color: Color) -> Rc<RefCell<Self>> {
Rc::new(RefCell::new(Self { bounds, color }))
}
}
impl Widget for Cell {
fn bounds(&self) -> Rect {
self.bounds
}
fn draw(&self, renderer: &mut dyn Renderer) {
renderer.fill_rect(self.bounds, self.color);
}
fn handle_event(&mut self, _event: &Event) -> bool {
false
}
}
fn row_color(row: i32) -> Color {
Color(40 + row as u8 * 30, 80, 200 - row as u8 * 25, 255)
}
struct ProbeFont;
impl FontMetrics for ProbeFont {
fn glyph_metrics(&self, ch: char) -> Option<GlyphInfo> {
(ch == 'G').then_some(GlyphInfo {
advance_fp16: 16,
bearing_x: 0,
bearing_y: 4,
width: 4,
height: 4,
})
}
fn line_metrics(&self) -> FontLineMetrics {
FontLineMetrics {
line_height: 4,
ascent: 4,
descent: 0,
}
}
fn glyph_coverage_row(&self, _ch: char, row: u16, x_offset: u16, coverage: &mut [u8]) -> bool {
if row >= 4 {
return false;
}
for (idx, alpha) in coverage.iter_mut().enumerate() {
let x = x_offset + u16::try_from(idx).unwrap_or(u16::MAX);
*alpha = match x {
1 | 2 => 255,
_ => 0,
};
}
true
}
}
struct ShapedTextProbe {
bounds: Rect,
color: Color,
}
impl ShapedTextProbe {
fn new(bounds: Rect, color: Color) -> Rc<RefCell<Self>> {
Rc::new(RefCell::new(Self { bounds, color }))
}
}
impl Widget for ShapedTextProbe {
fn bounds(&self) -> Rect {
self.bounds
}
fn draw(&self, renderer: &mut dyn Renderer) {
let font = ProbeFont;
let baseline = self.bounds.y + font.line_metrics().ascent as i32;
let shaped = shape_text_ltr(&font, "G", (self.bounds.x, baseline), 0);
renderer.draw_text_shaped(&shaped, (0, 0), self.color);
}
fn handle_event(&mut self, _event: &Event) -> bool {
false
}
}
#[test]
fn spike_unclipped_tree_path_bleeds_past_parent_bounds() {
let parent = Rect {
x: 100,
y: 100,
width: 200,
height: 200,
};
let child = Cell::new(
Rect {
x: 120,
y: 250,
width: 50,
height: 100,
},
Color(255, 0, 0, 255),
);
let mut frame = Buffer::new();
frame.fill_rect(parent, Color(0, 0, 0, 255));
child.borrow().draw(&mut frame);
assert_eq!(
frame.at(130, 320),
Color(255, 0, 0, 255).to_argb8888(),
"spike: child bleeds past the parent edge on the unclipped path"
);
}
#[test]
fn text_coverage_is_clipped_by_scroll_viewport_at_top_and_bottom() {
let viewport = Rect {
x: 10,
y: 10,
width: 20,
height: 20,
};
let mut view = ScrollView::new(viewport, 100);
view.style = StyleBuilder::new().bg_color(Color(0, 0, 0, 255)).build();
view.add_child(ShapedTextProbe::new(
Rect {
x: 2,
y: -1,
width: 4,
height: 4,
},
Color(255, 0, 0, 255),
));
view.add_child(ShapedTextProbe::new(
Rect {
x: 2,
y: 19,
width: 4,
height: 4,
},
Color(255, 0, 0, 255),
));
let mut frame = Buffer::new();
view.draw(&mut frame);
let visible_red_x = viewport.x + 4;
assert_eq!(
frame.at(viewport.x + 3, viewport.y - 1),
BG,
"top overhang is clipped to viewport"
);
assert_eq!(
frame.at(viewport.x + 3, viewport.y + viewport.height),
BG,
"bottom overhang is clipped to viewport"
);
let red = Color(255, 0, 0, 255);
let red_argb = red.to_argb8888();
let view_bg = Color(0, 0, 0, 255).to_argb8888();
assert_eq!(
frame.at(visible_red_x, viewport.y),
red_argb,
"top-straddling glyph contributes at first visible row"
);
assert_eq!(
frame.at(visible_red_x, viewport.y + viewport.height - 1),
red_argb,
"bottom-straddling glyph contributes at last visible row"
);
assert_eq!(
frame.at(visible_red_x - 2, viewport.y),
view_bg,
"coverage mask leaves first column untouched"
);
assert_eq!(
frame.at(visible_red_x + 2, viewport.y),
view_bg,
"coverage mask leaves last column untouched"
);
}
#[test]
fn children_crop_exactly_at_all_four_viewport_edges() {
let viewport = Rect {
x: 100,
y: 100,
width: 200,
height: 200,
};
let mut view = ScrollView::new(viewport, 200);
view.style = StyleBuilder::new().bg_color(Color(0, 0, 0, 255)).build();
let red = Color(255, 0, 0, 255);
for bounds in [
Rect {
x: -50,
y: 80,
width: 100,
height: 40,
}, Rect {
x: 150,
y: 80,
width: 100,
height: 40,
}, Rect {
x: 20,
y: -30,
width: 40,
height: 60,
}, Rect {
x: 20,
y: 170,
width: 40,
height: 60,
}, ] {
view.add_child(Cell::new(bounds, red));
}
let mut frame = Buffer::new();
view.draw(&mut frame);
let inside = |x: i32, y: i32| {
x >= viewport.x
&& x < viewport.x + viewport.width
&& y >= viewport.y
&& y < viewport.y + viewport.height
};
let red_argb = red.to_argb8888();
for y in 0..H as i32 {
for x in 0..W as i32 {
if frame.at(x, y) == red_argb {
assert!(inside(x, y), "bleed at ({x}, {y})");
}
}
}
assert_eq!(frame.at(100, 190), red_argb, "left edge interior");
assert_eq!(frame.at(299, 190), red_argb, "right edge interior");
assert_eq!(frame.at(130, 100), red_argb, "top edge interior");
assert_eq!(frame.at(130, 299), red_argb, "bottom edge interior");
assert_eq!(frame.at(99, 190), BG, "no bleed past left");
assert_eq!(frame.at(300, 190), BG, "no bleed past right");
assert_eq!(frame.at(130, 99), BG, "no bleed past top");
assert_eq!(frame.at(130, 300), BG, "no bleed past bottom");
}
fn driving_case_view() -> (ScrollView, Rect) {
let viewport = Rect {
x: 40,
y: 100,
width: 400,
height: 500,
};
let mut view = ScrollView::new(viewport, 1000);
view.style = StyleBuilder::new().bg_color(Color(0, 0, 0, 255)).build();
for row in 0..5 {
for col in 0..2 {
view.add_child(Cell::new(
Rect {
x: col * 200,
y: row * 200,
width: 200,
height: 200,
},
row_color(row),
));
}
}
(view, viewport)
}
fn reference_frame(viewport: Rect, scroll_y: i32) -> Buffer {
let mut frame = Buffer::new();
frame.fill_rect(viewport, Color(0, 0, 0, 255));
for row in 0..5 {
for col in 0..2 {
let screen = Rect {
x: viewport.x + col * 200,
y: viewport.y + row * 200 - scroll_y,
width: 200,
height: 200,
};
if let Some(visible) = screen.intersect(viewport) {
frame.fill_rect(visible, row_color(row));
}
}
}
frame
}
#[test]
fn scroll_reveals_hidden_row_with_clean_partial_rows() {
let (mut view, viewport) = driving_case_view();
let mut frame = Buffer::new();
view.draw(&mut frame);
assert_eq!(frame.pixels, reference_frame(viewport, 0).pixels);
assert_eq!(
frame.at(viewport.x + 10, viewport.y + 499),
row_color(2).to_argb8888(),
"row 2 partially visible at the bottom edge"
);
view.scroll_by(350);
let mut frame = Buffer::new();
view.draw(&mut frame);
assert_eq!(frame.pixels, reference_frame(viewport, 350).pixels);
let top_row_color = frame.at(viewport.x + 10, viewport.y);
assert_eq!(
top_row_color,
row_color(1).to_argb8888(),
"partial row 1 at top"
);
let bottom_row_color = frame.at(viewport.x + 10, viewport.y + 499);
assert_eq!(
bottom_row_color,
row_color(4).to_argb8888(),
"row 4 revealed at the bottom edge (350 + 500 = 850 ∈ row 4)"
);
assert_eq!(frame.at(viewport.x + 10, viewport.y - 1), BG);
assert_eq!(frame.at(viewport.x + 10, viewport.y + 500), BG);
let (mut absolute, _) = driving_case_view();
absolute.scroll_to(350);
let mut frame_abs = Buffer::new();
absolute.draw(&mut frame_abs);
assert_eq!(frame.pixels, frame_abs.pixels);
}
#[test]
fn scroll_clamps_to_content_extent() {
let (mut view, viewport) = driving_case_view();
assert_eq!(view.max_scroll(), 500);
view.scroll_by(10_000);
assert_eq!(view.scroll_y(), 500);
let mut frame = Buffer::new();
view.draw(&mut frame);
assert_eq!(frame.pixels, reference_frame(viewport, 500).pixels);
view.scroll_by(-10_000);
assert_eq!(view.scroll_y(), 0);
}
#[test]
fn take_dirty_reports_viewport_only_on_offset_change() {
let (mut view, viewport) = driving_case_view();
assert_eq!(view.take_dirty(), None, "clean at construction");
view.scroll_by(100);
assert_eq!(
view.take_dirty(),
Some(viewport),
"scroll dirties the viewport"
);
assert_eq!(view.take_dirty(), None, "drained");
view.scroll_to(100);
assert_eq!(view.take_dirty(), None, "same offset is a no-op");
view.scroll_by(0);
assert_eq!(view.take_dirty(), None, "zero delta is a no-op");
view.scroll_to(500);
view.scroll_by(10_000);
assert_eq!(
view.take_dirty(),
Some(viewport),
"one viewport rect per scroll burst"
);
view.scroll_by(1); assert_eq!(view.take_dirty(), None, "clamped overshoot is a no-op");
}
struct EventLog {
bounds: Rect,
seen: Vec<Event>,
}
impl Widget for EventLog {
fn bounds(&self) -> Rect {
self.bounds
}
fn draw(&self, _renderer: &mut dyn Renderer) {}
fn handle_event(&mut self, event: &Event) -> bool {
self.seen.push(event.clone());
false
}
}
#[test]
fn pointer_events_translate_to_content_space_and_gate_on_viewport() {
let viewport = Rect {
x: 100,
y: 100,
width: 200,
height: 200,
};
let child = Rc::new(RefCell::new(EventLog {
bounds: Rect {
x: 0,
y: 0,
width: 200,
height: 1000,
},
seen: Vec::new(),
}));
let mut view = ScrollView::new(viewport, 1000);
view.add_child(child.clone());
view.scroll_by(300);
view.handle_event(&Event::PressRelease { x: 150, y: 180 });
view.handle_event(&Event::PressRelease { x: 10, y: 10 });
view.handle_event(&Event::Tick);
let seen = &child.borrow().seen;
assert_eq!(
seen.as_slice(),
&[Event::PressRelease { x: 50, y: 380 }, Event::Tick,]
);
}