Skip to main content

retroglyph_ui/camera/
mod.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`](retroglyph_core::grid::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//! ```text
10//!  world space (Size)                      screen space (terminal cells)
11//!  +--------------------------------+
12//!  |                                |       viewport (a Rect on screen)
13//!  |        origin                  |       +------------------+
14//!  |          x----------------+    |       | (vp.left, vp.top)|
15//!  |          | visible_bounds |    |  ==>  |   +----------+   |
16//!  |          |   (clamped to  |    |       |   | drawn    |   |
17//!  |          |    the world)  |    |       |   |  cells   |   |
18//!  |          +----------------+    |       |   +----------+   |
19//!  |                                |       +------------------+
20//!  +--------------------------------+
21//!
22//!  origin = world cell shown at the viewport's top-left, clamped to [0, world - viewport].
23//!  world_to_screen(w) = viewport.top_left + (w - origin), culled to visible_bounds.
24//!  screen_to_world(s) = origin + (s - viewport.top_left), culled to the world.
25//! ```
26//!
27//! Centering clamps to the world edges (the "scrolling map" convention): the
28//! viewport never scrolls past `[0, world)`, so the target stays centered
29//! except near the edges, where it drifts toward the corner. A world smaller
30//! than the viewport pins the origin at `(0, 0)`, with all the slack on the
31//! right and bottom of the given viewport rect; use
32//! [`set_viewport_fitted`](crate::camera::Camera::set_viewport_fitted) instead of
33//! [`set_viewport`](crate::camera::Camera::set_viewport) when a world that may be smaller
34//! than its viewport (a fixed board, a generated map, a minimap) should be
35//! letterboxed and centered instead.
36//!
37//! See the `12_dungeon_scroll` example for `Camera` in action:
38//! <https://main.retroglyph.dev/examples/12_dungeon_scroll/terminal/>.
39//!
40//! [`Grid::from_charmap`](retroglyph_core::grid::Grid::from_charmap) builds a styled grid from an ASCII map or
41//! level string, one tile per character; combined with a [`Camera`] and multi-layer compositing,
42//! this is how a scrolling roguelike loads and follows a map larger than the screen (see the
43//! `11_sokoban` example for `from_charmap` itself, and `15_outpost_dashboard` for a `Camera` used
44//! alongside a UI).
45//!
46//! # Example
47//!
48//! ```
49//! use retroglyph_core::grid::{Pos, Rect, Size};
50//! use retroglyph_ui::Camera;
51//!
52//! // A 10x10 viewport onto a 100x100 world.
53//! let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size::new(100, 100));
54//! cam.center_on(Pos::new(50, 50));
55//! assert_eq!(cam.origin(), Pos::new(45, 45));
56//! assert_eq!(cam.world_to_screen(Pos::new(50, 50)), Some(Pos::new(5, 5)));
57//! // Near an edge the view clamps rather than showing past the world.
58//! cam.center_on(Pos::new(1, 1));
59//! assert_eq!(cam.origin(), Pos::new(0, 0));
60//! ```
61
62use retroglyph_core::grid::{HasSize, Pos, Rect, Size};
63
64mod transform;
65
66/// A rectangular viewport onto a larger world, with world/screen conversions.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct Camera {
69    viewport: Rect,
70    world: Size,
71    origin: Pos,
72}
73
74impl Camera {
75    /// Create a camera drawing into `viewport` (screen cells) over a world of
76    /// `world` cells. The initial origin is `(0, 0)`; call
77    /// [`center_on`](Self::center_on) to follow a target.
78    #[must_use]
79    pub const fn new(viewport: Rect, world: Size) -> Self {
80        Self {
81            viewport,
82            world,
83            origin: Pos::new(0, 0),
84        }
85    }
86
87    /// The screen rectangle the world is drawn into.
88    #[must_use]
89    pub const fn viewport(&self) -> Rect {
90        self.viewport
91    }
92
93    /// The world dimensions.
94    #[must_use]
95    pub const fn world(&self) -> Size {
96        self.world
97    }
98
99    /// The world cell shown at the viewport's top-left corner.
100    #[must_use]
101    pub const fn origin(&self) -> Pos {
102        self.origin
103    }
104
105    /// Replace the viewport (for example after a terminal resize), keeping the
106    /// world unchanged and re-clamping the origin so it stays in bounds.
107    ///
108    /// Never panics: a `viewport` larger than `world` re-clamps the origin to `(0, 0)` via
109    /// [`saturating_sub`](u16::saturating_sub) rather than underflowing.
110    pub fn set_viewport(&mut self, viewport: Rect) {
111        self.viewport = viewport;
112        self.set_origin(self.origin);
113    }
114
115    /// Replace the world dimensions (for example when a level changes), keeping the viewport
116    /// unchanged and re-clamping the origin so it stays in bounds.
117    ///
118    /// If the camera was last positioned with
119    /// [`set_viewport_fitted`](Self::set_viewport_fitted), this does not re-run that letterboxing
120    /// against the new world; call `set_viewport_fitted` again afterward if the new world may be
121    /// smaller than the viewport on either axis.
122    ///
123    /// Never panics: the re-clamp uses the same saturating arithmetic as
124    /// [`set_viewport`](Self::set_viewport).
125    ///
126    /// # Examples
127    ///
128    /// ```
129    /// use retroglyph_core::grid::{Pos, Rect, Size};
130    /// use retroglyph_ui::Camera;
131    ///
132    /// let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size::new(100, 100));
133    /// cam.center_on(Pos::new(50, 50));
134    /// assert_eq!(cam.origin(), Pos::new(45, 45));
135    ///
136    /// // Shrinking the world re-clamps the origin so it stays in bounds.
137    /// cam.set_world(Size::new(20, 20));
138    /// assert_eq!(cam.world(), Size::new(20, 20));
139    /// assert_eq!(cam.origin(), Pos::new(10, 10));
140    /// ```
141    pub fn set_world(&mut self, world: Size) {
142        self.world = world;
143        self.origin = Rect::from_tl_size(self.origin, self.viewport.size())
144            .clamp_within(world.to_rect())
145            .top_left();
146    }
147
148    /// Replace the viewport like [`set_viewport`](Self::set_viewport), but shrink it to the
149    /// world's size on any axis where the world is smaller, and center the shrunk rect within
150    /// `viewport` rather than pinning it to the top-left.
151    ///
152    /// A viewport at least as large as the world on both axes lands exactly on the world with no
153    /// slack, so `origin` is `(0, 0)` and [`viewport`](Self::viewport) reports that centered
154    /// rect, not `viewport` itself; hit-testing via [`screen_to_world`](Self::screen_to_world)
155    /// therefore only recognizes screen positions actually over the world, not the letterboxed
156    /// margin. This is the fix for the pinned-to-the-corner behaviour
157    /// [`set_viewport`](Self::set_viewport) has for a world smaller than the viewport: a fixed
158    /// board, a generated map of fixed dimensions, or a minimap drawn into a terminal whose size
159    /// the app does not control.
160    ///
161    /// Odd leftover slack rounds down, the same way [`center_on`](Self::center_on) rounds: any
162    /// extra cell of margin lands on the right or bottom, not the left or top.
163    ///
164    /// Never panics: all arithmetic is saturating, so a `viewport` narrower than it is tall (or
165    /// vice versa) relative to `world` cannot underflow.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use retroglyph_core::grid::{Pos, Rect, Size};
171    /// use retroglyph_ui::Camera;
172    ///
173    /// // A 20x20 viewport at (2, 2) over a 5x5 world: the effective viewport shrinks to 5x5
174    /// // and centers within the given rect, instead of pinning to (2, 2).
175    /// let mut cam = Camera::new(Rect::new(0, 0, 1, 1), Size::new(5, 5));
176    /// cam.set_viewport_fitted(Rect::new(2, 2, 20, 20));
177    /// assert_eq!(cam.viewport(), Rect::new(9, 9, 5, 5));
178    /// assert_eq!(cam.origin(), Pos::new(0, 0));
179    ///
180    /// // A viewport already no larger than the world on both axes behaves like `set_viewport`:
181    /// // no shrinking, no centering.
182    /// let mut cam = Camera::new(Rect::new(0, 0, 1, 1), Size::new(100, 100));
183    /// cam.set_viewport_fitted(Rect::new(0, 0, 10, 10));
184    /// assert_eq!(cam.viewport(), Rect::new(0, 0, 10, 10));
185    /// ```
186    pub fn set_viewport_fitted(&mut self, viewport: Rect) {
187        let width = viewport.width().min(self.world.width());
188        let height = viewport.height().min(self.world.height());
189        let x = viewport
190            .left()
191            .saturating_add((viewport.width() - width) / 2);
192        let y = viewport
193            .top()
194            .saturating_add((viewport.height() - height) / 2);
195        self.set_viewport(Rect::new(x, y, width, height));
196    }
197
198    /// Center the view on `target` (world coords), clamped to the world edges so
199    /// the viewport never scrolls past `[0, world)`.
200    ///
201    /// Never panics, even for a `target` outside `[0, world)`: the offset and clamp are both
202    /// computed with saturating arithmetic.
203    pub fn center_on(&mut self, target: Pos) {
204        self.set_origin(Pos::new(
205            target.x.saturating_sub(self.viewport.width() / 2),
206            target.y.saturating_sub(self.viewport.height() / 2),
207        ));
208    }
209
210    /// Set the top-left world cell directly, clamped to the world edges so `origin` never
211    /// scrolls past `[0, world)`, the same invariant [`center_on`](Self::center_on) maintains.
212    ///
213    /// This is the primitive [`center_on`](Self::center_on) and [`scroll_by`](Self::scroll_by)
214    /// both clamp through, and what a save/restore of camera state needs: [`origin`](Self::origin)
215    /// is otherwise read-only.
216    ///
217    /// Never panics: the clamp is [`Rect::clamp_within`], which uses
218    /// [`saturating_sub`](u16::saturating_sub) internally, so it cannot underflow even for a
219    /// `viewport` larger than `world`.
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use retroglyph_core::grid::{Pos, Rect, Size};
225    /// use retroglyph_ui::Camera;
226    ///
227    /// let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size::new(100, 100));
228    /// cam.set_origin(Pos::new(50, 50));
229    /// assert_eq!(cam.origin(), Pos::new(50, 50));
230    ///
231    /// // Clamped to `world - viewport`, same as `center_on`.
232    /// cam.set_origin(Pos::new(200, 200));
233    /// assert_eq!(cam.origin(), Pos::new(90, 90));
234    /// ```
235    pub fn set_origin(&mut self, origin: Pos) {
236        self.origin = Rect::from_tl_size(origin, self.viewport.size())
237            .clamp_within(self.world.to_rect())
238            .top_left();
239    }
240
241    /// Scroll the view by a signed cell delta, clamped to the world edges like
242    /// [`set_origin`](Self::set_origin).
243    ///
244    /// This is the method a drag or a scroll wheel wants: unlike [`center_on`](Self::center_on),
245    /// which reinterprets its argument as a new target to center on, `scroll_by` moves `origin`
246    /// directly, so there is exactly one clamp between the input delta and the visible result.
247    /// A caller that instead clamps its own running "center" position to `[0, world)` and feeds
248    /// it through `center_on` every frame is clamping against a wider range than `center_on`'s
249    /// own `[0, world - viewport]`, which leaves slack: dragging past an edge no longer moves
250    /// the origin, but the caller's tracked position keeps moving, so dragging back "sticks"
251    /// until it works through that slack before the view responds again.
252    ///
253    /// Never panics: the delta is applied in `i32` and saturates at `0` or `u16::MAX` before the
254    /// world-edge clamp in [`set_origin`](Self::set_origin) runs, so neither a very large
255    /// negative nor positive `dx`/`dy` can overflow or underflow `u16`.
256    ///
257    /// # Examples
258    ///
259    /// ```
260    /// use retroglyph_core::grid::{Pos, Rect, Size};
261    /// use retroglyph_ui::Camera;
262    ///
263    /// let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size::new(100, 100));
264    /// cam.scroll_by(5, 3);
265    /// assert_eq!(cam.origin(), Pos::new(5, 3));
266    ///
267    /// // Clamped at the world edge, same as `center_on`: no negative or past-`world` origin.
268    /// cam.scroll_by(-100, -100);
269    /// assert_eq!(cam.origin(), Pos::new(0, 0));
270    /// ```
271    pub fn scroll_by(&mut self, dx: i32, dy: i32) {
272        self.set_origin(self.origin.saturating_add_signed(ixy::Pos::new(dx, dy)));
273    }
274}
275
276#[cfg(test)]
277pub(super) const fn cam() -> Camera {
278    Camera::new(Rect::new(0, 0, 10, 10), Size::new(100, 100))
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn centers_in_the_interior() {
287        let mut c = cam();
288        c.center_on(Pos::new(50, 50));
289        assert_eq!(c.origin(), Pos::new(45, 45));
290        assert_eq!(c.world_to_screen(Pos::new(50, 50)), Some(Pos::new(5, 5)));
291        assert_eq!(c.screen_to_world(Pos::new(5, 5)), Some(Pos::new(50, 50)));
292    }
293
294    #[test]
295    fn clamps_at_the_low_edge() {
296        let mut c = cam();
297        c.center_on(Pos::new(1, 1));
298        assert_eq!(c.origin(), Pos::new(0, 0));
299        assert_eq!(c.world_to_screen(Pos::new(1, 1)), Some(Pos::new(1, 1)));
300    }
301
302    #[test]
303    fn clamps_at_the_high_edge() {
304        let mut c = cam();
305        c.center_on(Pos::new(99, 99));
306        // origin = min(99 - 5, 100 - 10) = min(94, 90) = 90.
307        assert_eq!(c.origin(), Pos::new(90, 90));
308        assert_eq!(c.world_to_screen(Pos::new(99, 99)), Some(Pos::new(9, 9)));
309    }
310
311    #[test]
312    fn world_smaller_than_viewport_pins_origin() {
313        let mut c = Camera::new(Rect::new(2, 2, 20, 20), Size::new(5, 5));
314        c.center_on(Pos::new(3, 3));
315        assert_eq!(c.origin(), Pos::new(0, 0));
316        let visible = c.visible_bounds();
317        assert_eq!((visible.width(), visible.height()), (5, 5));
318        // Cells map into the viewport, offset by its top-left.
319        assert_eq!(c.world_to_screen(Pos::new(0, 0)), Some(Pos::new(2, 2)));
320        // A cell inside the viewport but outside the (smaller) world: rejected, matching
321        // `visible_bounds` and `screen_to_world`, not silently mapped past the world edge.
322        assert_eq!(c.world_to_screen(Pos::new(7, 7)), None);
323    }
324
325    #[test]
326    fn set_world_re_clamps_the_origin_when_the_world_shrinks() {
327        let mut c = cam();
328        c.center_on(Pos::new(50, 50));
329        assert_eq!(c.origin(), Pos::new(45, 45));
330
331        c.set_world(Size::new(20, 20));
332        assert_eq!(c.world(), Size::new(20, 20));
333        // clamp_within(world 20x20) clamps origin down from 45 to 20 - viewport(10) = 10.
334        assert_eq!(c.origin(), Pos::new(10, 10));
335    }
336
337    #[test]
338    fn set_world_leaves_an_in_bounds_origin_unchanged_when_the_world_grows() {
339        let mut c = cam();
340        c.center_on(Pos::new(50, 50));
341        assert_eq!(c.origin(), Pos::new(45, 45));
342
343        c.set_world(Size::new(200, 200));
344        assert_eq!(c.world(), Size::new(200, 200));
345        assert_eq!(c.origin(), Pos::new(45, 45));
346    }
347
348    #[test]
349    fn set_world_pins_origin_to_zero_when_the_new_world_is_smaller_than_the_viewport() {
350        let mut c = cam();
351        c.center_on(Pos::new(50, 50));
352
353        c.set_world(Size::new(5, 5));
354        assert_eq!(c.origin(), Pos::new(0, 0));
355    }
356
357    #[test]
358    fn set_viewport_fitted_shrinks_and_centers_a_world_smaller_on_both_axes() {
359        let mut c = Camera::new(Rect::new(0, 0, 1, 1), Size::new(5, 5));
360        c.set_viewport_fitted(Rect::new(2, 2, 20, 20));
361        assert_eq!(c.viewport(), Rect::new(9, 9, 5, 5));
362        assert_eq!(c.origin(), Pos::new(0, 0));
363        assert_eq!(c.visible_bounds(), Rect::new(0, 0, 5, 5));
364        assert_eq!(c.world_to_screen(Pos::new(0, 0)), Some(Pos::new(9, 9)));
365    }
366
367    #[test]
368    fn set_viewport_fitted_shrinks_only_the_axis_that_is_smaller() {
369        // World is smaller than the viewport on x only.
370        let mut c = Camera::new(Rect::new(0, 0, 1, 1), Size::new(5, 100));
371        c.set_viewport_fitted(Rect::new(0, 0, 20, 10));
372        assert_eq!(c.viewport(), Rect::new(7, 0, 5, 10));
373    }
374
375    #[test]
376    fn set_viewport_fitted_rounds_odd_slack_toward_the_right_and_bottom() {
377        let mut c = Camera::new(Rect::new(0, 0, 1, 1), Size::new(4, 4));
378        c.set_viewport_fitted(Rect::new(0, 0, 9, 9));
379        // 9 - 4 = 5 of slack, split 2/3: two columns/rows left and top, three right and bottom.
380        assert_eq!(c.viewport(), Rect::new(2, 2, 4, 4));
381    }
382
383    #[test]
384    fn set_viewport_reclamps_the_origin_when_the_viewport_grows() {
385        let mut c = cam(); // 10x10 viewport, 100x100 world.
386        c.center_on(Pos::new(99, 99)); // origin (90, 90).
387        c.set_viewport(Rect::new(0, 0, 40, 40));
388        assert_eq!(c.origin(), Pos::new(60, 60)); // 100 - 40.
389        assert_eq!(c.visible_bounds(), Rect::new(60, 60, 40, 40));
390    }
391
392    #[test]
393    fn zero_size_camera_is_inert() {
394        let c = Camera::new(Rect::new(0, 0, 0, 0), Size::new(0, 0));
395        assert_eq!(c.visible_bounds(), Rect::new(0, 0, 0, 0));
396        assert_eq!(c.cells().count(), 0);
397        assert_eq!(c.world_to_screen(Pos::new(0, 0)), None);
398        assert_eq!(c.screen_to_world(Pos::new(0, 0)), None);
399
400        // A zero-size world under a normal viewport behaves the same way: nothing to show.
401        // `world_to_screen` only checks against the viewport, not `world`, so it is
402        // `screen_to_world` (which does check `world`) that actually guards this case.
403        let zero_world = Camera::new(Rect::new(0, 0, 10, 10), Size::new(0, 0));
404        assert_eq!(zero_world.visible_bounds(), Rect::new(0, 0, 0, 0));
405        assert_eq!(zero_world.cells().count(), 0);
406        assert_eq!(zero_world.screen_to_world(Pos::new(0, 0)), None);
407    }
408
409    #[test]
410    fn set_viewport_fitted_matches_set_viewport_when_the_world_is_not_smaller() {
411        let mut a = Camera::new(Rect::new(0, 0, 1, 1), Size::new(100, 100));
412        a.set_viewport_fitted(Rect::new(0, 0, 10, 10));
413
414        let mut b = Camera::new(Rect::new(0, 0, 1, 1), Size::new(100, 100));
415        b.set_viewport(Rect::new(0, 0, 10, 10));
416
417        assert_eq!(a.viewport(), b.viewport());
418        assert_eq!(a.origin(), b.origin());
419    }
420
421    #[test]
422    fn set_viewport_fitted_saturates_instead_of_overflowing() {
423        let mut c = Camera::new(Rect::new(0, 0, 1, 1), Size::new(5, 5));
424        c.set_viewport_fitted(Rect::new(65_530, 0, 1_000, 10));
425        assert_eq!(c.viewport(), Rect::new(u16::MAX, 2, 5, 5));
426    }
427
428    #[test]
429    fn scroll_by_moves_the_origin_by_the_delta() {
430        let mut c = cam();
431        c.scroll_by(5, 3);
432        assert_eq!(c.origin(), Pos::new(5, 3));
433        c.scroll_by(-2, 1);
434        assert_eq!(c.origin(), Pos::new(3, 4));
435    }
436
437    #[test]
438    fn scroll_by_clamps_at_the_low_edge_without_overshooting() {
439        let mut c = cam();
440        c.scroll_by(-1000, -1000);
441        assert_eq!(c.origin(), Pos::new(0, 0));
442    }
443
444    #[test]
445    fn scroll_by_clamps_at_the_high_edge_without_overshooting() {
446        let mut c = cam(); // viewport 10x10, world 100x100: max origin is (90, 90).
447        c.scroll_by(1000, 1000);
448        assert_eq!(c.origin(), Pos::new(90, 90));
449    }
450
451    #[test]
452    fn scroll_by_has_no_dead_zone_reversing_direction_past_an_edge() {
453        // The bug `scroll_by` replaces: a caller that clamps its own "center" to `[0, world)`
454        // and re-derives the origin via `center_on` every frame accumulates slack once the
455        // center clamps past what `center_on`'s own `[0, world - viewport]` clamp allows, so
456        // reversing direction doesn't move the origin until that slack is used up. `scroll_by`
457        // has one clamp on `origin` itself, so the very next opposite-direction scroll moves it.
458        let mut c = cam(); // viewport 10x10, world 100x100: max origin is (90, 90).
459        c.scroll_by(1000, 0); // drive past the edge; origin clamps to (90, 0).
460        assert_eq!(c.origin(), Pos::new(90, 0));
461        c.scroll_by(-1, 0); // reverse by a single cell.
462        assert_eq!(
463            c.origin(),
464            Pos::new(89, 0),
465            "a single reversed cell must move the origin"
466        );
467    }
468
469    #[test]
470    fn set_origin_places_the_origin_exactly_when_in_bounds() {
471        let mut c = cam();
472        c.set_origin(Pos::new(12, 34));
473        assert_eq!(c.origin(), Pos::new(12, 34));
474    }
475
476    #[test]
477    fn set_origin_clamps_to_the_world_edge() {
478        let mut c = cam(); // viewport 10x10, world 100x100: max origin is (90, 90).
479        c.set_origin(Pos::new(95, 200));
480        assert_eq!(c.origin(), Pos::new(90, 90));
481    }
482
483    #[test]
484    fn set_origin_never_underflows_when_the_viewport_exceeds_the_world() {
485        let mut c = Camera::new(Rect::new(0, 0, 20, 20), Size::new(5, 5));
486        c.set_origin(Pos::new(3, 3));
487        assert_eq!(c.origin(), Pos::new(0, 0));
488    }
489
490    #[test]
491    fn center_on_and_set_origin_agree_on_the_clamped_result() {
492        // `center_on` now routes through `set_origin`; this pins that composition down.
493        let mut a = cam();
494        a.center_on(Pos::new(99, 99));
495
496        let mut b = cam();
497        b.set_origin(Pos::new(94, 94)); // 99 - viewport.width() / 2 = 94.
498
499        assert_eq!(a.origin(), b.origin());
500    }
501}