use crate::geometry::{Position, Rect, Size};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Viewport {
offset: Position,
size: Size,
content_size: Size,
}
impl Viewport {
pub fn new(size: Size) -> Self {
Self {
offset: Position::new(0, 0),
size,
content_size: size,
}
}
#[must_use]
pub fn with_content_size(mut self, content_size: Size) -> Self {
self.content_size = content_size;
self.clamp_offset();
self
}
pub fn offset(&self) -> Position {
self.offset
}
pub fn size(&self) -> Size {
self.size
}
pub fn content_size(&self) -> Size {
self.content_size
}
pub fn scroll_by(&mut self, dx: i32, dy: i32) {
let new_x = i32::from(self.offset.x).saturating_add(dx);
let new_y = i32::from(self.offset.y).saturating_add(dy);
self.offset.x = clamp_to_u16(new_x, self.max_scroll_x());
self.offset.y = clamp_to_u16(new_y, self.max_scroll_y());
}
pub fn scroll_to(&mut self, x: u16, y: u16) {
self.offset.x = x.min(self.max_scroll_x());
self.offset.y = y.min(self.max_scroll_y());
}
pub fn is_visible(&self, rect: Rect) -> bool {
let viewport_rect = Rect::new(
self.offset.x,
self.offset.y,
self.size.width,
self.size.height,
);
viewport_rect.intersects(&rect)
}
pub fn clip_to_viewport(&self, rect: Rect) -> Option<Rect> {
let viewport_rect = Rect::new(
self.offset.x,
self.offset.y,
self.size.width,
self.size.height,
);
let intersection = viewport_rect.intersection(&rect)?;
let local_x = intersection.position.x.saturating_sub(self.offset.x);
let local_y = intersection.position.y.saturating_sub(self.offset.y);
Some(Rect::new(
local_x,
local_y,
intersection.size.width,
intersection.size.height,
))
}
pub fn max_scroll_x(&self) -> u16 {
self.content_size.width.saturating_sub(self.size.width)
}
pub fn max_scroll_y(&self) -> u16 {
self.content_size.height.saturating_sub(self.size.height)
}
fn clamp_offset(&mut self) {
self.offset.x = self.offset.x.min(self.max_scroll_x());
self.offset.y = self.offset.y.min(self.max_scroll_y());
}
}
fn clamp_to_u16(value: i32, max: u16) -> u16 {
if value < 0 {
0
} else if value > i32::from(max) {
max
} else {
value as u16
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_viewport_zero_offset() {
let vp = Viewport::new(Size::new(80, 24));
assert_eq!(vp.offset(), Position::new(0, 0));
assert_eq!(vp.size(), Size::new(80, 24));
assert_eq!(vp.content_size(), Size::new(80, 24));
}
#[test]
fn with_content_size_sets_content() {
let vp = Viewport::new(Size::new(40, 20)).with_content_size(Size::new(100, 50));
assert_eq!(vp.content_size(), Size::new(100, 50));
assert_eq!(vp.size(), Size::new(40, 20));
}
#[test]
fn scroll_down_changes_offset() {
let mut vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(80, 100));
vp.scroll_by(0, 10);
assert_eq!(vp.offset(), Position::new(0, 10));
}
#[test]
fn scroll_right_changes_offset() {
let mut vp = Viewport::new(Size::new(40, 20)).with_content_size(Size::new(200, 20));
vp.scroll_by(15, 0);
assert_eq!(vp.offset(), Position::new(15, 0));
}
#[test]
fn scroll_past_content_clamped_to_max() {
let mut vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(80, 50));
vp.scroll_by(0, 1000);
assert_eq!(vp.offset(), Position::new(0, 26));
}
#[test]
fn scroll_negative_clamped_to_zero() {
let mut vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(80, 100));
vp.scroll_by(0, 10); vp.scroll_by(0, -50); assert_eq!(vp.offset(), Position::new(0, 0));
}
#[test]
fn scroll_to_absolute() {
let mut vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(200, 100));
vp.scroll_to(50, 30);
assert_eq!(vp.offset(), Position::new(50, 30));
}
#[test]
fn scroll_to_clamped() {
let mut vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(100, 50));
vp.scroll_to(1000, 1000);
assert_eq!(vp.offset(), Position::new(20, 26));
}
#[test]
fn is_visible_on_screen_region() {
let mut vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(200, 100));
vp.scroll_to(10, 5);
let rect = Rect::new(15, 10, 20, 5);
assert!(vp.is_visible(rect));
}
#[test]
fn is_visible_off_screen_region() {
let vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(200, 100));
let rect = Rect::new(100, 50, 10, 5);
assert!(!vp.is_visible(rect));
}
#[test]
fn clip_to_viewport_within_bounds() {
let vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(200, 100));
let rect = Rect::new(10, 5, 20, 10);
let clipped = vp.clip_to_viewport(rect);
assert!(clipped.is_some());
match clipped {
Some(r) => {
assert_eq!(r, Rect::new(10, 5, 20, 10));
}
None => unreachable!(),
}
}
#[test]
fn clip_to_viewport_partial_overlap() {
let mut vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(200, 100));
vp.scroll_to(10, 5);
let rect = Rect::new(5, 3, 20, 10);
let clipped = vp.clip_to_viewport(rect);
assert!(clipped.is_some());
match clipped {
Some(r) => {
assert_eq!(r, Rect::new(0, 0, 15, 8));
}
None => unreachable!(),
}
}
#[test]
fn clip_to_viewport_no_overlap() {
let vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(200, 100));
let rect = Rect::new(100, 50, 10, 5);
assert!(vp.clip_to_viewport(rect).is_none());
}
#[test]
fn content_smaller_than_viewport_no_scroll_effect() {
let mut vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(40, 10));
vp.scroll_by(100, 100);
assert_eq!(vp.offset(), Position::new(0, 0));
}
#[test]
fn max_scroll_calculations() {
let vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(200, 100));
assert_eq!(vp.max_scroll_x(), 120); assert_eq!(vp.max_scroll_y(), 76); }
#[test]
fn max_scroll_zero_when_content_fits() {
let vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(80, 24));
assert_eq!(vp.max_scroll_x(), 0);
assert_eq!(vp.max_scroll_y(), 0);
}
#[test]
fn max_scroll_zero_when_content_smaller() {
let vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(40, 10));
assert_eq!(vp.max_scroll_x(), 0);
assert_eq!(vp.max_scroll_y(), 0);
}
#[test]
fn with_content_size_clamps_existing_offset() {
let mut vp = Viewport::new(Size::new(40, 20)).with_content_size(Size::new(200, 100));
vp.scroll_to(100, 60);
let vp = Viewport {
offset: vp.offset(),
size: vp.size(),
content_size: Size::new(60, 30),
};
let vp2 = Viewport::new(vp.size()).with_content_size(Size::new(60, 30));
assert_eq!(vp2.offset(), Position::new(0, 0));
}
#[test]
fn scroll_by_both_axes() {
let mut vp = Viewport::new(Size::new(40, 20)).with_content_size(Size::new(200, 100));
vp.scroll_by(10, 15);
assert_eq!(vp.offset(), Position::new(10, 15));
vp.scroll_by(-5, -3);
assert_eq!(vp.offset(), Position::new(5, 12));
}
#[test]
fn is_visible_edge_cases() {
let vp = Viewport::new(Size::new(80, 24)).with_content_size(Size::new(200, 100));
let rect = Rect::new(79, 0, 10, 1);
assert!(vp.is_visible(rect));
let rect2 = Rect::new(80, 0, 10, 1);
assert!(!vp.is_visible(rect2));
}
}