const MAX_SCROLLABLE_HEIGHT_PX: f64 = 16_000_000.0;
#[derive(Clone, Debug, Default)]
pub struct HorizontalViewport {
pub scroll_left: f64,
pub container_width: f64,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ViewportState {
pub start_row: u64,
pub visible_rows: usize,
pub last_emitted: Option<(u64, usize)>,
}
impl ViewportState {
#[must_use]
pub fn should_emit(&self) -> bool {
self.last_emitted != Some((self.start_row, self.visible_rows))
}
#[must_use]
pub fn with_emitted(self) -> Self {
Self {
last_emitted: Some((self.start_row, self.visible_rows)),
..self
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewportRange {
pub first_row: u64,
pub row_count: usize,
}
pub fn compute_viewport(
scroll_top_px: f64,
container_height_px: f64,
row_height_px: f64,
total_rows: u64,
) -> ViewportRange {
compute_viewport_with_overscan(
scroll_top_px,
container_height_px,
row_height_px,
total_rows,
0,
)
}
pub fn compute_viewport_with_overscan(
scroll_top_px: f64,
container_height_px: f64,
row_height_px: f64,
total_rows: u64,
overscan: usize,
) -> ViewportRange {
if total_rows == 0 || row_height_px <= 0.0 || container_height_px <= 0.0 {
return ViewportRange {
first_row: 0,
row_count: 0,
};
}
let virtual_scroll_top = scroll_top_to_virtual_offset_px(
scroll_top_px,
container_height_px,
row_height_px,
total_rows,
);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let first_visible = (virtual_scroll_top / row_height_px).floor() as u64;
let first_row = first_visible.saturating_sub(overscan as u64);
let first_row = first_row.min(total_rows.saturating_sub(1));
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let visible = (container_height_px / row_height_px).ceil() as usize;
let visible = visible + 1 + 2 * overscan;
#[allow(clippy::cast_possible_truncation)]
let remaining = (total_rows - first_row) as usize;
let row_count = visible.min(remaining);
ViewportRange {
first_row,
row_count,
}
}
pub fn total_height_px(total_rows: u64, row_height_px: f64) -> f64 {
#[allow(clippy::cast_precision_loss)]
let result = total_rows as f64 * row_height_px;
result
}
pub fn scrollable_height_px(total_rows: u64, row_height_px: f64) -> f64 {
total_height_px(total_rows, row_height_px).min(MAX_SCROLLABLE_HEIGHT_PX)
}
pub fn scroll_top_to_virtual_offset_px(
scroll_top_px: f64,
container_height_px: f64,
row_height_px: f64,
total_rows: u64,
) -> f64 {
if total_rows == 0 || row_height_px <= 0.0 || container_height_px <= 0.0 {
return 0.0;
}
let actual_total_height = total_height_px(total_rows, row_height_px);
let scrollable_height = scrollable_height_px(total_rows, row_height_px);
let max_virtual_scroll_top = (actual_total_height - container_height_px).max(0.0);
let max_scroll_top = (scrollable_height - container_height_px).max(0.0);
if max_virtual_scroll_top <= 0.0 || max_scroll_top <= 0.0 {
return 0.0;
}
let clamped_scroll_top = scroll_top_px.clamp(0.0, max_scroll_top);
(clamped_scroll_top / max_scroll_top) * max_virtual_scroll_top
}
pub fn virtual_offset_to_scroll_top_px(
virtual_offset_px: f64,
container_height_px: f64,
row_height_px: f64,
total_rows: u64,
) -> f64 {
if total_rows == 0 || row_height_px <= 0.0 || container_height_px <= 0.0 {
return 0.0;
}
let actual_total_height = total_height_px(total_rows, row_height_px);
let scrollable_height = scrollable_height_px(total_rows, row_height_px);
let max_virtual_scroll_top = (actual_total_height - container_height_px).max(0.0);
let max_scroll_top = (scrollable_height - container_height_px).max(0.0);
if max_virtual_scroll_top <= 0.0 || max_scroll_top <= 0.0 {
return 0.0;
}
let clamped_virtual_offset = virtual_offset_px.clamp(0.0, max_virtual_scroll_top);
(clamped_virtual_offset / max_virtual_scroll_top) * max_scroll_top
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
#[test]
fn horizontal_viewport_default() {
let hv = HorizontalViewport::default();
assert!((hv.scroll_left - 0.0).abs() < f64::EPSILON);
assert!((hv.container_width - 0.0).abs() < f64::EPSILON);
}
#[test]
fn default_always_emits() {
let vp = ViewportState::default();
assert!(vp.should_emit());
}
#[test]
fn same_values_after_emit_suppresses() {
let vp = ViewportState {
start_row: 0,
visible_rows: 20,
last_emitted: None,
};
let vp2 = vp.with_emitted();
assert!(!vp2.should_emit());
}
#[test]
fn changed_start_row_emits() {
let vp = ViewportState {
start_row: 0,
visible_rows: 20,
last_emitted: None,
}
.with_emitted();
let vp2 = ViewportState {
start_row: 5,
visible_rows: 20,
last_emitted: vp.last_emitted,
};
assert!(vp2.should_emit());
}
#[test]
fn changed_visible_rows_emits() {
let vp = ViewportState {
start_row: 0,
visible_rows: 20,
last_emitted: None,
}
.with_emitted();
let vp2 = ViewportState {
start_row: 0,
visible_rows: 25,
last_emitted: vp.last_emitted,
};
assert!(vp2.should_emit());
}
#[test]
fn with_emitted_is_pure() {
let vp = ViewportState {
start_row: 7,
visible_rows: 15,
last_emitted: None,
};
let vp2 = vp.with_emitted();
assert_eq!(vp.last_emitted, None);
assert_eq!(vp2.last_emitted, Some((7, 15)));
}
#[test]
fn zero_rows_returns_zero_range() {
let vp = compute_viewport(0.0, 600.0, 28.0, 0);
assert_eq!(vp.first_row, 0);
assert_eq!(vp.row_count, 0);
}
#[test]
fn basic_viewport() {
let vp = compute_viewport(0.0, 280.0, 28.0, 1000);
assert_eq!(vp.first_row, 0);
assert_eq!(vp.row_count, 11);
}
#[test]
fn scrolled_halfway() {
let vp = compute_viewport(2800.0, 280.0, 28.0, 1000);
assert_eq!(vp.first_row, 100);
assert_eq!(vp.row_count, 11);
}
#[test]
fn scroll_past_end_clamps() {
let vp = compute_viewport(1_000_000.0, 280.0, 28.0, 100);
assert_eq!(vp.first_row, 90);
assert_eq!(vp.row_count, 10);
}
#[test]
fn total_height() {
assert!((total_height_px(1000, 28.0) - 28000.0).abs() < f64::EPSILON);
}
#[test]
fn scrollable_height_caps_large_datasets() {
assert_eq!(
scrollable_height_px(10_000_000, 24.0),
MAX_SCROLLABLE_HEIGHT_PX
);
}
#[test]
fn large_dataset_scroll_roundtrip_stays_within_one_row() {
let virtual_offset = 120_000_000.0;
let scroll_top = virtual_offset_to_scroll_top_px(virtual_offset, 240.0, 24.0, 10_000_000);
let roundtrip = scroll_top_to_virtual_offset_px(scroll_top, 240.0, 24.0, 10_000_000);
assert!((roundtrip - virtual_offset).abs() < 24.0);
}
#[test]
fn compute_viewport_handles_scaled_scroll_ranges() {
let scroll_top = scrollable_height_px(10_000_000, 24.0) / 2.0;
let vp = compute_viewport(scroll_top, 240.0, 24.0, 10_000_000);
assert!(vp.first_row > 4_500_000);
assert!(vp.first_row < 5_500_000);
assert_eq!(vp.row_count, 11);
}
#[test]
fn overscan_expands_range() {
let vp = compute_viewport_with_overscan(280.0, 280.0, 28.0, 1000, 5);
assert_eq!(vp.first_row, 5);
assert_eq!(vp.row_count, 21);
}
#[test]
fn overscan_clamps_at_start() {
let vp = compute_viewport_with_overscan(0.0, 280.0, 28.0, 1000, 5);
assert_eq!(vp.first_row, 0);
}
#[test]
fn overscan_clamps_at_end() {
let vp = compute_viewport_with_overscan(27860.0, 280.0, 28.0, 1000, 5);
assert!(vp.first_row + vp.row_count as u64 <= 1000);
}
#[test]
fn zero_overscan_is_equivalent() {
let a = compute_viewport(500.0, 600.0, 28.0, 1000);
let b = compute_viewport_with_overscan(500.0, 600.0, 28.0, 1000, 0);
assert_eq!(a, b);
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn viewport_is_always_in_bounds(
scroll_top in 0.0f64..1_000_000.0f64,
container_height in 100.0f64..2000.0f64,
row_height in 1.0f64..200.0f64,
total_rows in 0u64..10_000_000u64,
) {
let vp = compute_viewport(scroll_top, container_height, row_height, total_rows);
prop_assert!(vp.first_row + vp.row_count as u64 <= total_rows);
}
#[test]
fn viewport_row_count_is_nonzero_when_data_exists(
scroll_top in 0.0f64..1_000_000.0f64,
container_height in 100.0f64..2000.0f64,
row_height in 1.0f64..200.0f64,
total_rows in 1u64..10_000_000u64,
) {
let vp = compute_viewport(scroll_top, container_height, row_height, total_rows);
prop_assert!(vp.row_count >= 1);
}
#[test]
fn viewport_first_row_never_exceeds_total(
scroll_top in 0.0f64..1_000_000_000.0f64,
container_height in 100.0f64..2000.0f64,
row_height in 1.0f64..200.0f64,
total_rows in 1u64..10_000_000u64,
) {
let vp = compute_viewport(scroll_top, container_height, row_height, total_rows);
prop_assert!(vp.first_row < total_rows);
}
#[test]
fn overscan_viewport_always_in_bounds(
scroll_top in 0.0f64..1_000_000.0f64,
container_height in 100.0f64..2000.0f64,
row_height in 1.0f64..200.0f64,
total_rows in 0u64..10_000_000u64,
overscan in 0usize..20usize,
) {
let vp = compute_viewport_with_overscan(
scroll_top, container_height, row_height, total_rows, overscan,
);
prop_assert!(vp.first_row + vp.row_count as u64 <= total_rows);
}
}
}