use crate::compat::{Rc, RefCell, ToString, Vec};
use crate::core::{Alignment, Color, ObjectId, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::{expect_bool, expect_i64, expect_string};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, SimpleRegistry, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
fn translate_content_event(event: &Event, dx: i32, dy: i32) -> Event {
let translate = |point: Point| Point::new(point.x + dx, point.y + dy);
match event {
Event::MouseDown((point, button)) => Event::MouseDown((translate(*point), *button)),
Event::MouseUp((point, button)) => Event::MouseUp((translate(*point), *button)),
Event::MouseMoveLegacy((point, device)) => {
Event::MouseMoveLegacy((translate(*point), *device))
}
Event::MouseMove { pos } => Event::MouseMove { pos: translate(*pos) },
Event::MousePress { pos, button } => {
Event::MousePress { pos: translate(*pos), button: *button }
}
Event::MouseDoubleClick { pos, button } => {
Event::MouseDoubleClick { pos: translate(*pos), button: *button }
}
Event::MouseRelease { pos, button } => {
Event::MouseRelease { pos: translate(*pos), button: *button }
}
Event::MouseEnter { pos } => Event::MouseEnter { pos: translate(*pos) },
Event::MouseLeave { pos } => Event::MouseLeave { pos: translate(*pos) },
Event::PointerPress { pos, button, pressure, tilt_x, tilt_y } => Event::PointerPress {
pos: translate(*pos),
button: *button,
pressure: *pressure,
tilt_x: *tilt_x,
tilt_y: *tilt_y,
},
Event::PointerMove { pos, pressure, tilt_x, tilt_y } => Event::PointerMove {
pos: translate(*pos),
pressure: *pressure,
tilt_x: *tilt_x,
tilt_y: *tilt_y,
},
Event::PointerRelease { pos, button, pressure } => {
Event::PointerRelease { pos: translate(*pos), button: *button, pressure: *pressure }
}
#[cfg(feature = "touch")]
Event::TouchBegin { pos, touch_id } => {
Event::TouchBegin { pos: translate(*pos), touch_id: *touch_id }
}
#[cfg(feature = "touch")]
Event::TouchEnd { pos, touch_id } => {
Event::TouchEnd { pos: translate(*pos), touch_id: *touch_id }
}
#[cfg(feature = "touch")]
Event::TouchMove { pos, touch_id } => {
Event::TouchMove { pos: translate(*pos), touch_id: *touch_id }
}
#[cfg(feature = "touch")]
Event::Tap { pos } => Event::Tap { pos: translate(*pos) },
#[cfg(feature = "touch")]
Event::DoubleTap { pos } => Event::DoubleTap { pos: translate(*pos) },
#[cfg(feature = "touch")]
Event::LongPress { pos } => Event::LongPress { pos: translate(*pos) },
#[cfg(feature = "touch")]
Event::Swipe { start, end, velocity } => {
Event::Swipe { start: translate(*start), end: translate(*end), velocity: *velocity }
}
#[cfg(feature = "touch")]
Event::Drag { pos, touch_id, delta } => {
Event::Drag { pos: translate(*pos), touch_id: *touch_id, delta: *delta }
}
#[cfg(feature = "touch")]
Event::TwoFingerTap { pos } => Event::TwoFingerTap { pos: translate(*pos) },
#[cfg(feature = "touch")]
Event::Fling { pos, velocity, touch_id } => {
Event::Fling { pos: translate(*pos), velocity: *velocity, touch_id: *touch_id }
}
#[cfg(feature = "holographic")]
Event::HolographicTouch { pos, depth, touch_id } => {
Event::HolographicTouch { pos: translate(*pos), depth: *depth, touch_id: *touch_id }
}
_ => event.clone(),
}
}
pub struct ScrollArea {
base: BaseWidget,
widget_resizable: bool,
alignment: Alignment,
horizontal_scroll_bar_policy: ScrollBarPolicy,
vertical_scroll_bar_policy: ScrollBarPolicy,
viewport: Rect,
widget: Option<ObjectId>,
content_size: Size,
registry: Option<Rc<RefCell<SimpleRegistry>>>,
scroll_position: (i32, i32),
sticky_regions: Vec<StickyRegion>,
pub scroll_position_changed: Signal1<(i32, i32)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StickyRegion {
pub group_top: i32,
pub group_bottom: i32,
pub height: u32,
}
impl StickyRegion {
pub fn new(group_top: i32, group_bottom: i32, height: u32) -> Self {
Self { group_top, group_bottom: group_bottom.max(group_top), height }
}
pub fn draw_offset(&self, scroll_y: i32) -> i32 {
let natural = self.group_top - scroll_y;
if natural > 0 {
return natural;
}
let pinned = 0;
let group_bottom_in_view = self.group_bottom - scroll_y;
let band_height = self.height as i32;
if group_bottom_in_view < band_height {
return group_bottom_in_view - band_height;
}
pinned
}
pub fn is_pinned(&self, scroll_y: i32) -> bool {
self.draw_offset(scroll_y) == 0 && self.group_top - scroll_y < 0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollBarPolicy {
AlwaysOn,
AlwaysOff,
#[default]
AsNeeded,
}
fn scroll_bar_policy_to_str(policy: ScrollBarPolicy) -> &'static str {
match policy {
ScrollBarPolicy::AlwaysOn => "always_on",
ScrollBarPolicy::AlwaysOff => "always_off",
ScrollBarPolicy::AsNeeded => "as_needed",
}
}
impl ScrollArea {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::ScrollArea, geometry, "ScrollArea"),
widget_resizable: true,
alignment: Alignment::Center,
horizontal_scroll_bar_policy: ScrollBarPolicy::AsNeeded,
vertical_scroll_bar_policy: ScrollBarPolicy::AsNeeded,
viewport: geometry,
widget: None,
content_size: Size::new(0, 0),
registry: None,
scroll_position: (0, 0),
sticky_regions: Vec::new(),
scroll_position_changed: Signal1::new(),
}
}
pub fn set_registry(&mut self, registry: Rc<RefCell<SimpleRegistry>>) {
self.registry = Some(registry);
}
pub fn registry(&self) -> Option<&Rc<RefCell<SimpleRegistry>>> {
self.registry.as_ref()
}
pub fn widget_resizable(&self) -> bool {
self.widget_resizable
}
pub fn set_widget_resizable(&mut self, resizable: bool) {
self.widget_resizable = resizable;
}
pub fn alignment(&self) -> Alignment {
self.alignment
}
pub fn set_alignment(&mut self, alignment: Alignment) {
self.alignment = alignment;
}
pub fn horizontal_scroll_bar_policy(&self) -> ScrollBarPolicy {
self.horizontal_scroll_bar_policy
}
pub fn set_horizontal_scroll_bar_policy(&mut self, policy: ScrollBarPolicy) {
self.horizontal_scroll_bar_policy = policy;
}
pub fn vertical_scroll_bar_policy(&self) -> ScrollBarPolicy {
self.vertical_scroll_bar_policy
}
pub fn set_vertical_scroll_bar_policy(&mut self, policy: ScrollBarPolicy) {
self.vertical_scroll_bar_policy = policy;
}
pub fn set_widget(&mut self, widget: Option<ObjectId>) {
self.widget = widget;
if let Some(widget_id) = widget {
self.base.add_child(widget_id);
}
}
pub fn widget(&self) -> Option<ObjectId> {
self.widget
}
pub fn add_sticky_region(&mut self, group_top: i32, group_bottom: i32, height: u32) {
if height == 0 {
return;
}
self.sticky_regions.push(StickyRegion::new(group_top, group_bottom, height));
self.base.request_redraw();
}
pub fn clear_sticky_regions(&mut self) {
self.sticky_regions.clear();
self.base.request_redraw();
}
pub fn sticky_regions(&self) -> &[StickyRegion] {
&self.sticky_regions
}
pub fn pinned_sticky_bands(&self) -> Vec<(usize, i32)> {
self.sticky_regions
.iter()
.enumerate()
.filter(|(_, region)| {
region.is_pinned(self.scroll_position.1) || {
region.group_top - self.scroll_position.1 <= 0
}
})
.map(|(index, region)| (index, region.draw_offset(self.scroll_position.1)))
.collect()
}
pub fn viewport(&self) -> Rect {
self.viewport
}
pub fn set_viewport(&mut self, viewport: Rect) {
self.viewport = viewport;
self.set_scroll_position(self.scroll_position.0, self.scroll_position.1);
}
pub fn scroll_position(&self) -> (i32, i32) {
self.scroll_position
}
pub fn set_scroll_position(&mut self, x: i32, y: i32) {
let view_w = self.viewport.width as i32;
let view_h = self.viewport.height as i32;
let content_w = self.content_size.width as i32;
let content_h = self.content_size.height as i32;
let max_x = (content_w - view_w).max(0);
let max_y = (content_h - view_h).max(0);
let clamped = (x.clamp(0, max_x), y.clamp(0, max_y));
if self.scroll_position == clamped {
return;
}
self.scroll_position = clamped;
self.scroll_position_changed.emit(self.scroll_position);
self.base.request_redraw();
}
pub fn set_content_size(&mut self, size: Size) {
self.content_size = size;
self.set_scroll_position(self.scroll_position.0, self.scroll_position.1);
}
pub fn content_size(&self) -> Size {
self.content_size
}
pub fn ensure_visible(&mut self, rect: Rect) {
let mut x = self.scroll_position.0;
let mut y = self.scroll_position.1;
let view_right = x.saturating_add(self.viewport.width as i32);
let view_bottom = y.saturating_add(self.viewport.height as i32);
let rect_right = rect.x.saturating_add(rect.width as i32);
let rect_bottom = rect.y.saturating_add(rect.height as i32);
if rect.x < x {
x = rect.x;
} else if rect_right > view_right {
x = rect_right.saturating_sub(self.viewport.width as i32);
}
if rect.y < y {
y = rect.y;
} else if rect_bottom > view_bottom {
y = rect_bottom.saturating_sub(self.viewport.height as i32);
}
self.set_scroll_position(x, y);
}
pub fn ensure_widget_visible(&mut self, widget_id: ObjectId) {
if Some(widget_id) == self.widget {
let max_x = (self.content_size.width.saturating_sub(self.viewport.width)) as i32;
let max_y = (self.content_size.height.saturating_sub(self.viewport.height)) as i32;
self.set_scroll_position(max_x / 2, max_y / 2);
}
}
pub fn scroll_to_top(&mut self) {
self.set_scroll_position(self.scroll_position.0, 0);
}
pub fn scroll_to_bottom(&mut self) {
if self.content_size.height > self.viewport.height {
self.set_scroll_position(
self.scroll_position.0,
(self.content_size.height - self.viewport.height) as i32,
);
} else {
self.set_scroll_position(self.scroll_position.0, 0);
}
}
pub fn scroll_to_left(&mut self) {
self.set_scroll_position(0, self.scroll_position.1);
}
pub fn scroll_to_right(&mut self) {
if self.content_size.width > self.viewport.width {
self.set_scroll_position(
(self.content_size.width - self.viewport.width) as i32,
self.scroll_position.1,
);
} else {
self.set_scroll_position(0, self.scroll_position.1);
}
}
fn horizontal_scroll_bar_visible(&self) -> bool {
match self.horizontal_scroll_bar_policy {
ScrollBarPolicy::AlwaysOn => true,
ScrollBarPolicy::AlwaysOff => false,
ScrollBarPolicy::AsNeeded => self.content_size.width > self.viewport.width,
}
}
fn vertical_scroll_bar_visible(&self) -> bool {
match self.vertical_scroll_bar_policy {
ScrollBarPolicy::AlwaysOn => true,
ScrollBarPolicy::AlwaysOff => false,
ScrollBarPolicy::AsNeeded => self.content_size.height > self.viewport.height,
}
}
fn update_scroll_bars(&mut self) {
self.base.request_redraw();
}
fn draw_sticky_band(&self, context: &mut RenderContext, band_rect: Rect) {
let style = self.style();
let fill = style.background_color.unwrap_or(Color::rgb(248, 248, 248));
let border = style.border_color.unwrap_or(Color::rgb(200, 200, 200));
context.fill_rect(band_rect, fill);
let bottom = band_rect.y + band_rect.height as i32 - 1;
context.draw_line_stroke(
Point::new(band_rect.x, bottom),
Point::new(band_rect.x + band_rect.width as i32, bottom),
border,
1,
);
}
fn thumb_metrics(track_len: u32, content_len: u32, view_len: u32, scroll: i32) -> (u32, i32) {
let track_len = track_len.max(1);
if view_len == 0 || content_len <= view_len {
return (track_len, 0);
}
let max_scroll = (content_len - view_len) as i32;
let proportional = (track_len as u64 * view_len as u64) / content_len as u64;
let thumb = proportional.clamp(10, track_len as u64) as u32;
let max_thumb_pos = (track_len - thumb) as i32;
let scroll = scroll.clamp(0, max_scroll);
let pos = (max_thumb_pos as i64 * scroll as i64) / max_scroll as i64;
(thumb, pos as i32)
}
}
impl Widget for ScrollArea {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(300, 200)
}
fn set_geometry(&mut self, geometry: Rect) {
self.base.set_geometry(geometry);
self.viewport.width = geometry.width;
self.viewport.height = geometry.height;
self.update_scroll_bars();
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for ScrollArea {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"widget_resizable" => Ok(CapabilityValue::Bool(self.widget_resizable())),
"horizontal_scroll_bar_policy" => Ok(CapabilityValue::String(
scroll_bar_policy_to_str(self.horizontal_scroll_bar_policy()).to_string(),
)),
"vertical_scroll_bar_policy" => Ok(CapabilityValue::String(
scroll_bar_policy_to_str(self.vertical_scroll_bar_policy()).to_string(),
)),
"scroll_position_x" => Ok(CapabilityValue::Int(self.scroll_position().0 as i64)),
"scroll_position_y" => Ok(CapabilityValue::Int(self.scroll_position().1 as i64)),
"sticky_region_count" => Ok(CapabilityValue::UInt(self.sticky_regions().len() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"widget_resizable" => {
self.set_widget_resizable(expect_bool(value)?);
Ok(())
}
"horizontal_scroll_bar_policy" | "vertical_scroll_bar_policy" => {
let token = expect_string(value)?;
let policy = match token.as_str() {
"always_on" => ScrollBarPolicy::AlwaysOn,
"always_off" => ScrollBarPolicy::AlwaysOff,
"as_needed" => ScrollBarPolicy::AsNeeded,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
if name == "horizontal_scroll_bar_policy" {
self.set_horizontal_scroll_bar_policy(policy);
} else {
self.set_vertical_scroll_bar_policy(policy);
}
Ok(())
}
"scroll_position_x" => {
let x = expect_i64(value)? as i32;
let (_, y) = self.scroll_position();
self.set_scroll_position(x, y);
Ok(())
}
"scroll_position_y" => {
let y = expect_i64(value)? as i32;
let (x, _) = self.scroll_position();
self.set_scroll_position(x, y);
Ok(())
}
"sticky_region_count" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"widget_resizable",
"horizontal_scroll_bar_policy",
"vertical_scroll_bar_policy",
"scroll_position_x",
"scroll_position_y",
"sticky_region_count",
BASE_PROPERTY_NAMES
]
}
}
impl EventHandler for ScrollArea {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::Wheel { delta, modifiers: _ } => {
self.set_scroll_position(
self.scroll_position.0 + delta.x * 20,
self.scroll_position.1 + delta.y * 20,
);
}
#[cfg(feature = "touch")]
Event::Swipe { start, end, velocity: _ } => {
let dx = end.x - start.x;
let dy = end.y - start.y;
self.set_scroll_position(self.scroll_position.0 + dx, self.scroll_position.1 + dy);
}
#[cfg(feature = "touch")]
Event::Drag { delta, .. } => {
self.set_scroll_position(
self.scroll_position.0 + delta.x,
self.scroll_position.1 + delta.y,
);
}
_ => { }
}
if let Some(widget_id) = self.widget {
if let Some(ref reg) = self.registry {
let content_event =
translate_content_event(event, self.scroll_position.0, self.scroll_position.1);
reg.borrow_mut().forward_event(widget_id, &content_event);
}
}
}
}
impl Draw for ScrollArea {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let style = self.style();
context.fill_rect(rect, style.background_color.unwrap_or(Color::rgb(255, 255, 255)));
context.draw_rect(rect, style.border_color.unwrap_or(Color::rgb(200, 200, 200)));
context.push_clip(rect.x, rect.y, rect.width, rect.height);
if let Some(widget_id) = self.widget {
if let Some(ref reg) = self.registry {
context.push_offset(-self.scroll_position.0, -self.scroll_position.1);
reg.borrow_mut().draw_widget(widget_id, context);
context.pop_offset();
}
}
for region in &self.sticky_regions {
if region.group_top - self.scroll_position.1 > 0 {
continue;
}
let offset_y = region.draw_offset(self.scroll_position.1);
let band_rect =
Rect::new(rect.x, rect.y + offset_y, rect.width, region.height.min(rect.height));
if band_rect.y + band_rect.height as i32 <= rect.y {
continue;
}
self.draw_sticky_band(context, band_rect);
}
context.pop_clip();
let h_scroll_visible = self.horizontal_scroll_bar_visible();
let v_scroll_visible = self.vertical_scroll_bar_visible();
if h_scroll_visible {
let scroll_bar_height = 16;
let scroll_bar_y = rect.y as f32 + rect.height as f32 - scroll_bar_height as f32;
context.fill_rect(
Rect::new(rect.x, scroll_bar_y as i32, rect.width, scroll_bar_height),
Color::rgb(240, 240, 240),
);
context.draw_rect(
Rect::new(rect.x, scroll_bar_y as i32, rect.width, scroll_bar_height),
Color::rgb(200, 200, 200),
);
let h_track =
rect.width.saturating_sub(if v_scroll_visible { scroll_bar_height } else { 0 });
let (thumb_width, thumb_dx) = Self::thumb_metrics(
h_track,
self.content_size.width,
self.viewport.width.max(1),
self.scroll_position.0,
);
let thumb_x = rect.x + thumb_dx;
context.fill_rect(
Rect::new(thumb_x, scroll_bar_y as i32, thumb_width, scroll_bar_height),
Color::rgb(180, 180, 180),
);
}
if v_scroll_visible {
let scroll_bar_width = 16;
let scroll_bar_x = rect.x as f32 + rect.width as f32 - scroll_bar_width as f32;
context.fill_rect(
Rect::new(scroll_bar_x as i32, rect.y, scroll_bar_width, rect.height),
Color::rgb(240, 240, 240),
);
context.draw_rect(
Rect::new(scroll_bar_x as i32, rect.y, scroll_bar_width, rect.height),
Color::rgb(200, 200, 200),
);
let v_track =
rect.height.saturating_sub(if h_scroll_visible { scroll_bar_width } else { 0 });
let (thumb_height, thumb_dy) = Self::thumb_metrics(
v_track,
self.content_size.height,
self.viewport.height.max(1),
self.scroll_position.1,
);
let thumb_y = rect.y + thumb_dy;
context.fill_rect(
Rect::new(scroll_bar_x as i32, thumb_y, scroll_bar_width, thumb_height),
Color::rgb(180, 180, 180),
);
}
if h_scroll_visible && v_scroll_visible {
let corner_size = 16;
let corner_x = rect.x as f32 + rect.width as f32 - corner_size as f32;
let corner_y = rect.y as f32 + rect.height as f32 - corner_size as f32;
context.fill_rect(
Rect::new(corner_x as i32, corner_y as i32, corner_size as u32, corner_size as u32),
Color::rgb(240, 240, 240),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{Point, Rect};
use crate::render::SoftwarePaintBackend;
#[test]
fn scrollarea_creation_defaults() {
let sa = ScrollArea::new(Rect::new(0, 0, 200, 200));
assert_eq!(sa.geometry(), Rect::new(0, 0, 200, 200));
assert_eq!(sa.viewport(), Rect::new(0, 0, 200, 200));
assert_eq!(sa.scroll_position(), (0, 0));
assert_eq!(sa.horizontal_scroll_bar_policy(), ScrollBarPolicy::AsNeeded);
assert_eq!(sa.vertical_scroll_bar_policy(), ScrollBarPolicy::AsNeeded);
}
#[test]
fn scrollarea_set_scroll_position_clamps_content_space() {
let mut sa = ScrollArea::new(Rect::new(0, 0, 100, 100));
sa.set_content_size(Size::new(300, 260));
sa.set_viewport(Rect::new(0, 0, 100, 100));
sa.set_scroll_position(500, 400);
assert_eq!(sa.scroll_position(), (200, 160));
sa.set_scroll_position(-10, -8);
assert_eq!(sa.scroll_position(), (0, 0));
}
#[test]
fn scrollarea_ensure_visible_updates_scroll_position() {
let mut sa = ScrollArea::new(Rect::new(0, 0, 100, 100));
sa.set_content_size(Size::new(500, 500));
sa.set_viewport(Rect::new(0, 0, 100, 100));
sa.set_scroll_position(100, 120);
sa.ensure_visible(Rect::new(250, 275, 50, 50));
assert_eq!(sa.scroll_position(), (200, 225));
sa.ensure_visible(Rect::new(10, 20, 20, 20));
assert_eq!(sa.scroll_position(), (10, 20));
assert_eq!(sa.viewport(), Rect::new(0, 0, 100, 100));
}
#[test]
fn scrollarea_extent_changes_reclamp_scroll_position() {
let mut sa = ScrollArea::new(Rect::new(0, 0, 100, 100));
sa.set_viewport(Rect::new(0, 0, 100, 100));
sa.set_content_size(Size::new(500, 500));
sa.set_scroll_position(400, 400);
sa.set_content_size(Size::new(150, 160));
assert_eq!(sa.scroll_position(), (50, 60));
sa.set_viewport(Rect::new(0, 0, 200, 200));
assert_eq!(sa.scroll_position(), (0, 0));
}
#[test]
fn scrollarea_ensure_widget_visible_changes_scroll_not_viewport() {
let mut sa = ScrollArea::new(Rect::new(0, 0, 100, 100));
sa.set_content_size(Size::new(500, 500));
sa.set_widget(Some(77));
let viewport = sa.viewport();
sa.ensure_widget_visible(77);
assert_eq!(sa.viewport(), viewport);
assert_eq!(sa.scroll_position(), (200, 200));
}
#[test]
fn thumb_metrics_returns_full_track_when_content_fits() {
let (len, pos) = ScrollArea::thumb_metrics(100, 50, 100, 30);
assert_eq!(len, 100);
assert_eq!(pos, 0);
let (len, pos) = ScrollArea::thumb_metrics(100, 50, 100, -10);
assert_eq!(len, 100);
assert_eq!(pos, 0);
}
#[test]
fn thumb_metrics_proportional_and_tracks_scroll() {
let (len0, pos0) = ScrollArea::thumb_metrics(80, 200, 100, 0);
assert_eq!(len0, 40);
assert_eq!(pos0, 0);
let (len, pos) = ScrollArea::thumb_metrics(80, 200, 100, 50);
assert_eq!(len, 40);
assert_eq!(pos, 20);
let (len, pos) = ScrollArea::thumb_metrics(80, 200, 100, 100);
assert_eq!(len, 40);
assert_eq!(pos, 40);
let (_, pos) = ScrollArea::thumb_metrics(80, 200, 100, -5);
assert_eq!(pos, 0);
let (_, pos) = ScrollArea::thumb_metrics(80, 200, 100, 999);
assert_eq!(pos, 40);
}
#[test]
fn thumb_metrics_never_collapses_below_minimum() {
let (len, _) = ScrollArea::thumb_metrics(1000, 100_000, 100, 0);
assert_eq!(len, 10);
let (len, _) = ScrollArea::thumb_metrics(50, 100_000, 50, 0);
assert_eq!(len, 10);
}
#[test]
fn scrollarea_content_is_translated_by_scroll_position() {
use crate::render::{PaintBackend, RenderContext, SoftwarePaintBackend};
use crate::widget::SimpleRegistry;
use std::cell::RefCell;
use std::rc::Rc;
let size = Size::new(100, 100);
let mut backend = SoftwarePaintBackend::new(size, 1.0);
let stride = 100 * 4;
let pixel = |rgba: &[u8], x: usize, y: usize| {
let idx = y * stride + x * 4;
(rgba[idx], rgba[idx + 1], rgba[idx + 2])
};
let mut sa = ScrollArea::new(Rect::new(0, 0, 100, 100));
sa.set_geometry(Rect::new(0, 0, 100, 100));
sa.set_content_size(Size::new(200, 200));
let reg = Rc::new(RefCell::new(SimpleRegistry::new()));
sa.set_registry(reg.clone());
let child_id = 4242;
sa.set_widget(Some(child_id));
reg.borrow_mut().register(
child_id,
|ctx| {
ctx.fill_rect(Rect::new(60, 60, 30, 30), Color::RED);
},
|_| {},
);
backend.begin_frame(Color::WHITE);
{
let mut ctx = RenderContext::new(&mut backend);
sa.draw(&mut ctx);
}
backend.end_frame();
let rgba = backend.frame_rgba();
assert_eq!(pixel(rgba, 70, 70), (255, 0, 0), "marker visible at content origin");
assert_eq!(pixel(rgba, 10, 10), (255, 255, 255), "empty viewport area stays white");
backend.begin_frame(Color::WHITE);
sa.set_scroll_position(50, 50);
{
let mut ctx = RenderContext::new(&mut backend);
sa.draw(&mut ctx);
}
backend.end_frame();
let rgba = backend.frame_rgba();
assert_eq!(pixel(rgba, 20, 20), (255, 0, 0), "marker translated into view");
assert_eq!(pixel(rgba, 70, 70), (255, 255, 255), "marker scrolled out of its old spot");
}
#[test]
fn scrollarea_forwards_pointer_coordinates_in_content_space() {
use std::sync::{Arc, Mutex};
let mut sa = ScrollArea::new(Rect::new(0, 0, 100, 100));
sa.set_content_size(Size::new(300, 300));
sa.set_scroll_position(40, 25);
let registry = Rc::new(RefCell::new(SimpleRegistry::new()));
sa.set_registry(registry.clone());
let received = Arc::new(Mutex::new(None));
let sink = received.clone();
registry.borrow_mut().register(
99,
|_| {},
move |event| {
if let Event::MousePress { pos, .. } = event {
if let Ok(mut value) = sink.lock() {
*value = Some(*pos);
}
}
},
);
sa.set_widget(Some(99));
sa.handle_event(&Event::mouse_press(10, 12, 1));
assert_eq!(received.lock().ok().and_then(|value| *value), Some(Point::new(50, 37)));
}
fn render(sa: &mut ScrollArea, size: Size) -> Vec<u8> {
use crate::render::PaintBackend;
let mut backend = SoftwarePaintBackend::new(size, 1.0);
backend.begin_frame(Color::WHITE);
let mut context = RenderContext::new(&mut backend);
sa.draw(&mut context);
backend.end_frame();
backend.frame_rgba().to_vec()
}
fn pixel(rgba: &[u8], width: u32, x: u32, y: u32) -> (u8, u8, u8, u8) {
let index = ((y * width + x) * 4) as usize;
(rgba[index], rgba[index + 1], rgba[index + 2], rgba[index + 3])
}
#[test]
fn sticky_region_offset_scrolls_then_pins_then_releases() {
let region = StickyRegion::new(100, 400, 30);
assert_eq!(region.draw_offset(0), 100);
assert_eq!(region.draw_offset(50), 50);
assert!(!region.is_pinned(50));
assert_eq!(region.draw_offset(150), 0);
assert!(region.is_pinned(150));
assert_eq!(region.draw_offset(370), 0);
assert!(region.is_pinned(370));
assert_eq!(region.draw_offset(399), 1 - 30);
assert_eq!(region.draw_offset(380), 20 - 30);
assert_eq!(region.draw_offset(400), 0 - 30);
}
#[test]
fn sticky_region_zero_height_is_ignored() {
let mut sa = ScrollArea::new(Rect::new(0, 0, 200, 200));
sa.add_sticky_region(0, 100, 0);
assert!(sa.sticky_regions().is_empty());
assert_eq!(sa.get("sticky_region_count").unwrap(), CapabilityValue::UInt(0));
}
#[test]
fn sticky_region_registration_and_clearing() {
let mut sa = ScrollArea::new(Rect::new(0, 0, 200, 200));
sa.add_sticky_region(0, 100, 24);
sa.add_sticky_region(100, 200, 24);
assert_eq!(sa.sticky_regions().len(), 2);
assert_eq!(sa.get("sticky_region_count").unwrap(), CapabilityValue::UInt(2));
sa.clear_sticky_regions();
assert!(sa.sticky_regions().is_empty());
}
#[test]
fn sticky_region_count_is_read_only() {
let mut sa = ScrollArea::new(Rect::new(0, 0, 200, 200));
assert_eq!(
sa.set("sticky_region_count", CapabilityValue::UInt(3)),
Err(CapabilityAccessError::ReadOnlyProperty)
);
}
#[test]
fn sticky_bands_are_reported_only_once_their_group_is_entered() {
let mut sa = ScrollArea::new(Rect::new(0, 0, 200, 200));
sa.set_content_size(Size::new(200, 600));
sa.add_sticky_region(0, 200, 24);
sa.add_sticky_region(300, 500, 24);
let bands = sa.pinned_sticky_bands();
assert_eq!(bands.len(), 1);
assert_eq!(bands[0].1, 0, "the first band pins to the viewport top");
sa.set_scroll_position(0, 350);
let bands = sa.pinned_sticky_bands();
assert_eq!(bands.len(), 2);
assert_eq!(bands[1].1, 0);
}
#[test]
fn sticky_band_is_painted_at_the_viewport_top_when_pinned() {
let size = Size::new(200, 200);
let mut sa = ScrollArea::new(Rect::new(0, 0, 200, 200));
sa.set_content_size(Size::new(200, 600));
let without = render(&mut sa, size);
sa.add_sticky_region(100, 400, 24);
sa.set_scroll_position(0, 200);
let with = render(&mut sa, size);
let band_bottom_row = 23;
let border_pixels = (0..size.width)
.filter(|x| {
let px = pixel(&with, size.width, *x, band_bottom_row);
px.0 == 200 && px.1 == 200 && px.2 == 200 && px.3 == 255
})
.count();
assert!(
border_pixels > size.width as usize / 2,
"the pinned band must paint a rule across the viewport top: {border_pixels} pixels"
);
assert_ne!(without, with);
}
#[test]
fn sticky_band_before_its_group_is_not_double_painted() {
let mut sa = ScrollArea::new(Rect::new(0, 0, 200, 200));
sa.set_content_size(Size::new(200, 600));
sa.add_sticky_region(300, 500, 24);
let bands = sa.pinned_sticky_bands();
assert!(bands.is_empty());
}
#[test]
fn sticky_band_fully_scrolled_off_is_not_drawn() {
let mut sa = ScrollArea::new(Rect::new(0, 0, 200, 200));
sa.set_content_size(Size::new(200, 600));
sa.add_sticky_region(0, 100, 24);
sa.set_scroll_position(0, 300);
let bands = sa.pinned_sticky_bands();
assert_eq!(bands.len(), 1);
assert_eq!(bands[0].1, 100 - 300 - 24, "the band is pushed above the viewport");
}
}