use ratatui::layout::Rect;
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct BoundingBox {
pub min_x: f64,
pub min_y: f64,
pub max_x: f64,
pub max_y: f64,
}
impl BoundingBox {
pub fn new(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
Self {
min_x,
min_y,
max_x,
max_y,
}
}
pub fn width(&self) -> f64 {
self.max_x - self.min_x
}
pub fn height(&self) -> f64 {
self.max_y - self.min_y
}
}
impl Default for BoundingBox {
fn default() -> Self {
Self {
min_x: 0.0,
min_y: 0.0,
max_x: 0.0,
max_y: 0.0,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct Viewport2D {
offset_x: f64,
offset_y: f64,
zoom: f64,
content_bbox: BoundingBox,
viewport_width: f64,
viewport_height: f64,
}
impl Default for Viewport2D {
fn default() -> Self {
Self::new()
}
}
impl Viewport2D {
const MIN_ZOOM: f64 = 0.25;
const MAX_ZOOM: f64 = 4.0;
const ZOOM_STEP: f64 = 1.25;
const PAN_STEP: f64 = 5.0;
pub fn new() -> Self {
Self {
offset_x: 0.0,
offset_y: 0.0,
zoom: 1.0,
content_bbox: BoundingBox::default(),
viewport_width: 80.0,
viewport_height: 24.0,
}
}
pub fn offset_x(&self) -> f64 {
self.offset_x
}
pub fn offset_y(&self) -> f64 {
self.offset_y
}
pub fn zoom(&self) -> f64 {
self.zoom
}
pub fn pan(&mut self, dx: f64, dy: f64) {
self.offset_x += dx;
self.offset_y += dy;
}
pub fn pan_step(&mut self, dx: f64, dy: f64) {
let step = Self::PAN_STEP / self.zoom;
self.offset_x += dx * step;
self.offset_y += dy * step;
}
pub fn zoom_in(&mut self) {
self.zoom = (self.zoom * Self::ZOOM_STEP).min(Self::MAX_ZOOM);
}
pub fn zoom_out(&mut self) {
self.zoom = (self.zoom / Self::ZOOM_STEP).max(Self::MIN_ZOOM);
}
pub fn viewport_size(&self) -> (f64, f64) {
(self.viewport_width, self.viewport_height)
}
pub fn set_viewport_size(&mut self, width: u16, height: u16) {
self.viewport_width = f64::from(width);
self.viewport_height = f64::from(height);
}
pub fn content_bounds(&self) -> &BoundingBox {
&self.content_bbox
}
pub fn set_content_bounds(&mut self, bbox: BoundingBox) {
self.content_bbox = bbox;
}
pub fn fit_to_content(&mut self) {
let cw = self.content_bbox.width();
let ch = self.content_bbox.height();
if cw <= 0.0 || ch <= 0.0 {
self.offset_x = 0.0;
self.offset_y = 0.0;
self.zoom = 1.0;
return;
}
let padding = 2.0;
let available_w = (self.viewport_width - padding * 2.0).max(1.0);
let available_h = (self.viewport_height - padding * 2.0).max(1.0);
let zoom_x = available_w / cw;
let zoom_y = available_h / ch;
self.zoom = zoom_x.min(zoom_y).clamp(Self::MIN_ZOOM, Self::MAX_ZOOM);
self.offset_x = self.content_bbox.min_x - padding / self.zoom;
self.offset_y = self.content_bbox.min_y - padding / self.zoom;
}
pub fn ensure_visible(&mut self, x: f64, y: f64, w: f64, h: f64) {
let vw = self.viewport_width / self.zoom;
let vh = self.viewport_height / self.zoom;
if x < self.offset_x {
self.offset_x = x - 1.0;
}
if x + w > self.offset_x + vw {
self.offset_x = x + w - vw + 1.0;
}
if y < self.offset_y {
self.offset_y = y - 1.0;
}
if y + h > self.offset_y + vh {
self.offset_y = y + h - vh + 1.0;
}
}
pub fn is_visible(&self, x: f64, y: f64, w: f64, h: f64) -> bool {
let vw = self.viewport_width / self.zoom;
let vh = self.viewport_height / self.zoom;
x + w > self.offset_x
&& x < self.offset_x + vw
&& y + h > self.offset_y
&& y < self.offset_y + vh
}
pub fn to_screen(&self, gx: f64, gy: f64, area: Rect) -> (i32, i32) {
let sx = ((gx - self.offset_x) * self.zoom) as i32 + area.x as i32;
let sy = ((gy - self.offset_y) * self.zoom) as i32 + area.y as i32;
(sx, sy)
}
pub fn to_graph(&self, sx: u16, sy: u16, area: Rect) -> (f64, f64) {
let gx = (f64::from(sx) - f64::from(area.x)) / self.zoom + self.offset_x;
let gy = (f64::from(sy) - f64::from(area.y)) / self.zoom + self.offset_y;
(gx, gy)
}
pub fn needs_scroll(&self) -> bool {
let cw = self.content_bbox.width() * self.zoom;
let ch = self.content_bbox.height() * self.zoom;
cw > self.viewport_width || ch > self.viewport_height
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_viewport() {
let vp = Viewport2D::new();
assert_eq!(vp.offset_x(), 0.0);
assert_eq!(vp.offset_y(), 0.0);
assert_eq!(vp.zoom(), 1.0);
}
#[test]
fn test_pan() {
let mut vp = Viewport2D::new();
vp.pan(10.0, 20.0);
assert_eq!(vp.offset_x(), 10.0);
assert_eq!(vp.offset_y(), 20.0);
}
#[test]
fn test_zoom_clamp() {
let mut vp = Viewport2D::new();
for _ in 0..20 {
vp.zoom_in();
}
assert!(vp.zoom() <= Viewport2D::MAX_ZOOM);
for _ in 0..20 {
vp.zoom_out();
}
assert!(vp.zoom() >= Viewport2D::MIN_ZOOM);
}
#[test]
fn test_fit_to_content() {
let mut vp = Viewport2D::new();
vp.set_viewport_size(80, 24);
vp.set_content_bounds(BoundingBox::new(0.0, 0.0, 160.0, 48.0));
vp.fit_to_content();
assert!(vp.zoom() < 1.0);
assert!(vp.zoom() > 0.0);
}
#[test]
fn test_fit_empty_content() {
let mut vp = Viewport2D::new();
vp.set_viewport_size(80, 24);
vp.set_content_bounds(BoundingBox::new(0.0, 0.0, 0.0, 0.0));
vp.fit_to_content();
assert_eq!(vp.zoom(), 1.0);
assert_eq!(vp.offset_x(), 0.0);
}
#[test]
fn test_ensure_visible() {
let mut vp = Viewport2D::new();
vp.set_viewport_size(80, 24);
assert!(!vp.is_visible(100.0, 50.0, 10.0, 3.0));
vp.ensure_visible(100.0, 50.0, 10.0, 3.0);
assert!(vp.is_visible(100.0, 50.0, 10.0, 3.0));
}
#[test]
fn test_coordinate_roundtrip() {
let vp = Viewport2D::new();
let area = Rect::new(5, 3, 80, 24);
let (sx, sy) = vp.to_screen(20.0, 10.0, area);
let (gx, gy) = vp.to_graph(sx as u16, sy as u16, area);
assert!((gx - 20.0).abs() < 0.001);
assert!((gy - 10.0).abs() < 0.001);
}
#[test]
fn test_needs_scroll() {
let mut vp = Viewport2D::new();
vp.set_viewport_size(80, 24);
vp.set_content_bounds(BoundingBox::new(0.0, 0.0, 40.0, 10.0));
assert!(!vp.needs_scroll());
vp.set_content_bounds(BoundingBox::new(0.0, 0.0, 200.0, 100.0));
assert!(vp.needs_scroll());
}
#[test]
fn test_visibility_check() {
let mut vp = Viewport2D::new();
vp.set_viewport_size(80, 24);
assert!(vp.is_visible(10.0, 5.0, 20.0, 3.0));
assert!(vp.is_visible(-5.0, -2.0, 20.0, 5.0));
assert!(!vp.is_visible(200.0, 100.0, 10.0, 3.0));
assert!(!vp.is_visible(80.0, 0.0, 10.0, 3.0));
}
#[test]
fn test_bounding_box() {
let bbox = BoundingBox::new(10.0, 20.0, 50.0, 60.0);
assert_eq!(bbox.width(), 40.0);
assert_eq!(bbox.height(), 40.0);
}
}