Skip to main content

retroglyph_core/
camera.rs

1//! A scrolling viewport into a world larger than the screen.
2//!
3//! [`Camera`] is pure geometry: it converts between world coordinates (cells in
4//! some large space) and screen coordinates (cells in a [`Rect`] on the
5//! terminal), and reports which world cells are currently visible. It holds no
6//! rendering opinion, so it works with any drawing style and is testable
7//! without a backend.
8//!
9//! Centering clamps to the world edges (the "scrolling map" convention): the
10//! viewport never scrolls past `[0, world)`, so the target stays centered
11//! except near the edges, where it drifts toward the corner. A world smaller
12//! than the viewport pins the origin at `(0, 0)`.
13//!
14//! # Example
15//!
16//! ```
17//! use retroglyph_core::{Camera, Pos, Rect, Size};
18//!
19//! // A 10x10 viewport onto a 100x100 world.
20//! let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size { width: 100, height: 100 });
21//! cam.center_on(Pos::new(50, 50));
22//! assert_eq!(cam.origin(), Pos::new(45, 45));
23//! assert_eq!(cam.world_to_screen(Pos::new(50, 50)), Some(Pos::new(5, 5)));
24//! // Near an edge the view clamps rather than showing past the world.
25//! cam.center_on(Pos::new(1, 1));
26//! assert_eq!(cam.origin(), Pos::new(0, 0));
27//! ```
28
29use crate::grid::{Pos, Rect, Size};
30
31/// A rectangular viewport onto a larger world, with world/screen conversions.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct Camera {
34    viewport: Rect,
35    world: Size,
36    origin: Pos,
37}
38
39impl Camera {
40    /// Create a camera drawing into `viewport` (screen cells) over a world of
41    /// `world` cells. The initial origin is `(0, 0)`; call
42    /// [`center_on`](Self::center_on) to follow a target.
43    #[must_use]
44    pub const fn new(viewport: Rect, world: Size) -> Self {
45        Self {
46            viewport,
47            world,
48            origin: Pos::new(0, 0),
49        }
50    }
51
52    /// The screen rectangle the world is drawn into.
53    #[must_use]
54    pub const fn viewport(&self) -> Rect {
55        self.viewport
56    }
57
58    /// The world dimensions.
59    #[must_use]
60    pub const fn world(&self) -> Size {
61        self.world
62    }
63
64    /// The world cell shown at the viewport's top-left corner.
65    #[must_use]
66    pub const fn origin(&self) -> Pos {
67        self.origin
68    }
69
70    /// Replace the viewport (for example after a terminal resize), keeping the
71    /// world unchanged and re-clamping the origin so it stays in bounds.
72    pub fn set_viewport(&mut self, viewport: Rect) {
73        self.viewport = viewport;
74        self.origin = Pos::new(
75            self.origin
76                .x
77                .min(max_origin(viewport.width(), self.world.width)),
78            self.origin
79                .y
80                .min(max_origin(viewport.height(), self.world.height)),
81        );
82    }
83
84    /// Center the view on `target` (world coords), clamped to the world edges so
85    /// the viewport never scrolls past `[0, world)`.
86    pub fn center_on(&mut self, target: Pos) {
87        self.origin = Pos::new(
88            center_axis(target.x, self.viewport.width(), self.world.width),
89            center_axis(target.y, self.viewport.height(), self.world.height),
90        );
91    }
92
93    /// The world rectangle currently visible, clamped to world bounds.
94    #[must_use]
95    pub fn visible_bounds(&self) -> Rect {
96        let w = self
97            .viewport
98            .width()
99            .min(self.world.width.saturating_sub(self.origin.x));
100        let h = self
101            .viewport
102            .height()
103            .min(self.world.height.saturating_sub(self.origin.y));
104        Rect::new(self.origin.x, self.origin.y, w, h)
105    }
106
107    /// Map a world position to its screen position, or `None` if it is outside
108    /// the visible viewport.
109    #[must_use]
110    pub const fn world_to_screen(&self, world: Pos) -> Option<Pos> {
111        if world.x < self.origin.x || world.y < self.origin.y {
112            return None;
113        }
114        let dx = world.x - self.origin.x;
115        let dy = world.y - self.origin.y;
116        if dx >= self.viewport.width() || dy >= self.viewport.height() {
117            return None;
118        }
119        Some(Pos::new(
120            self.viewport.left() + dx,
121            self.viewport.top() + dy,
122        ))
123    }
124
125    /// Map a screen position back to a world position, or `None` if it is
126    /// outside the viewport or beyond the world (useful for mouse picking).
127    #[must_use]
128    pub fn screen_to_world(&self, screen: Pos) -> Option<Pos> {
129        if !self.viewport.contains_pos(screen) {
130            return None;
131        }
132        let wx = self.origin.x + (screen.x - self.viewport.left());
133        let wy = self.origin.y + (screen.y - self.viewport.top());
134        if wx >= self.world.width || wy >= self.world.height {
135            return None;
136        }
137        Some(Pos::new(wx, wy))
138    }
139
140    /// Iterate the visible cells as `(world, screen)` position pairs, in
141    /// row-major order. Only cells that exist in the world are yielded, so the
142    /// caller can fill the rest of the viewport with a background.
143    #[must_use = "iterators are lazy and do nothing unless consumed"]
144    pub fn cells(&self) -> impl Iterator<Item = (Pos, Pos)> + '_ {
145        let vis = self.visible_bounds();
146        let vp = self.viewport;
147        let origin = self.origin;
148        (vis.top()..vis.bottom()).flat_map(move |wy| {
149            (vis.left()..vis.right()).map(move |wx| {
150                let screen = Pos::new(vp.left() + (wx - origin.x), vp.top() + (wy - origin.y));
151                (Pos::new(wx, wy), screen)
152            })
153        })
154    }
155}
156
157/// The largest in-bounds origin for a `view`-wide window over `[0, world)`.
158/// Zero when the world is no larger than the view.
159const fn max_origin(view: u16, world: u16) -> u16 {
160    world.saturating_sub(view)
161}
162
163/// Origin that centers `target` in a `view`-wide window, clamped to bounds.
164fn center_axis(target: u16, view: u16, world: u16) -> u16 {
165    target.saturating_sub(view / 2).min(max_origin(view, world))
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn cam() -> Camera {
173        Camera::new(
174            Rect::new(0, 0, 10, 10),
175            Size {
176                width: 100,
177                height: 100,
178            },
179        )
180    }
181
182    #[test]
183    fn centers_in_the_interior() {
184        let mut c = cam();
185        c.center_on(Pos::new(50, 50));
186        assert_eq!(c.origin(), Pos::new(45, 45));
187        assert_eq!(c.world_to_screen(Pos::new(50, 50)), Some(Pos::new(5, 5)));
188        assert_eq!(c.screen_to_world(Pos::new(5, 5)), Some(Pos::new(50, 50)));
189    }
190
191    #[test]
192    fn clamps_at_the_low_edge() {
193        let mut c = cam();
194        c.center_on(Pos::new(1, 1));
195        assert_eq!(c.origin(), Pos::new(0, 0));
196        assert_eq!(c.world_to_screen(Pos::new(1, 1)), Some(Pos::new(1, 1)));
197    }
198
199    #[test]
200    fn clamps_at_the_high_edge() {
201        let mut c = cam();
202        c.center_on(Pos::new(99, 99));
203        // origin = min(99 - 5, 100 - 10) = min(94, 90) = 90.
204        assert_eq!(c.origin(), Pos::new(90, 90));
205        assert_eq!(c.world_to_screen(Pos::new(99, 99)), Some(Pos::new(9, 9)));
206    }
207
208    #[test]
209    fn offscreen_positions_return_none() {
210        let mut c = cam();
211        c.center_on(Pos::new(50, 50)); // shows world [45,55)
212        assert_eq!(c.world_to_screen(Pos::new(44, 50)), None);
213        assert_eq!(c.world_to_screen(Pos::new(55, 50)), None);
214    }
215
216    #[test]
217    fn world_smaller_than_viewport_pins_origin() {
218        let mut c = Camera::new(
219            Rect::new(2, 2, 20, 20),
220            Size {
221                width: 5,
222                height: 5,
223            },
224        );
225        c.center_on(Pos::new(3, 3));
226        assert_eq!(c.origin(), Pos::new(0, 0));
227        let visible = c.visible_bounds();
228        assert_eq!((visible.width(), visible.height()), (5, 5));
229        // Cells map into the viewport, offset by its top-left.
230        assert_eq!(c.world_to_screen(Pos::new(0, 0)), Some(Pos::new(2, 2)));
231    }
232
233    #[test]
234    fn cells_yields_visible_world_and_screen_pairs() {
235        let mut c = cam();
236        c.center_on(Pos::new(50, 50));
237        let pairs: Vec<_> = c.cells().collect();
238        assert_eq!(pairs.len(), 100); // 10x10 viewport, world larger
239        assert_eq!(pairs[0], (Pos::new(45, 45), Pos::new(0, 0)));
240        assert_eq!(pairs[99], (Pos::new(54, 54), Pos::new(9, 9)));
241    }
242}