Skip to main content

retroglyph_ui/camera/
mod.rs

1//! A scrolling viewport into a world larger than the screen.
2//!
3//! [`Camera`](crate::camera::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//! [`TileCamera`](crate::camera::TileCamera) is the tile-map sibling of `Camera`: it paces
38//! panning and clamping in tiles rather than cells, and projects one tile to a `zoom.width() x
39//! zoom.height()` block of screen cells instead of one-to-one, the shape a tile map with a
40//! user-controlled zoom level needs (every tile is several cells across) rather than the
41//! roguelike shape the rest of this module doc describes.
42//!
43//! See the `12_dungeon_scroll` example for `Camera` in action:
44//! <https://main.retroglyph.dev/examples/12_dungeon_scroll/terminal/>.
45//!
46//! [`Grid::from_charmap`](retroglyph_core::grid::Grid::from_charmap) builds a styled grid from an ASCII map or
47//! level string, one tile per character; combined with a [`Camera`](crate::camera::Camera) and multi-layer compositing,
48//! this is how a scrolling roguelike loads and follows a map larger than the screen (see the
49//! `11_sokoban` example for `from_charmap` itself, and `15_outpost_dashboard` for a `Camera` used
50//! alongside a UI).
51//!
52//! # Example
53//!
54//! ```
55//! use retroglyph_core::grid::{Pos, Rect, Size};
56//! use retroglyph_ui::camera::Camera;
57//!
58//! // A 10x10 viewport onto a 100x100 world.
59//! let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size::new(100, 100));
60//! cam.center_on(Pos::new(50, 50));
61//! assert_eq!(cam.origin(), Pos::new(45, 45));
62//! assert_eq!(cam.world_to_screen(Pos::new(50, 50)), Some(Pos::new(5, 5)));
63//! // Near an edge the view clamps rather than showing past the world.
64//! cam.center_on(Pos::new(1, 1));
65//! assert_eq!(cam.origin(), Pos::new(0, 0));
66//! ```
67
68use retroglyph_core::grid::{HasSize, Pos, Rect, Size};
69
70mod tile;
71mod transform;
72
73/// A rectangular viewport onto a larger world, with world/screen conversions.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct Camera {
76    requested: Rect,
77    fit: ViewportFit,
78    viewport: Rect,
79    world: Size,
80    origin: Pos,
81}
82
83/// Which shape [`Camera::viewport`](Camera::viewport) is derived from `requested` with: set by
84/// [`set_viewport`](Camera::set_viewport) and [`set_viewport_fitted`](Camera::set_viewport_fitted)
85/// respectively, and re-applied by [`set_world`](Camera::set_world) so a world change re-derives
86/// `viewport` instead of leaving it stale against the previous world.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum ViewportFit {
89    /// `viewport` is `requested` unchanged.
90    Exact,
91    /// `viewport` is `requested` shrunk to the world's size on any axis where the world is
92    /// smaller, and centered within `requested`.
93    Letterbox,
94}
95
96/// Shrinks `rect` to `world`'s size on any axis where `world` is smaller, and centers the shrunk
97/// rect within `rect`. Identity when `world` is at least as large as `rect` on both axes.
98fn letterbox(rect: Rect, world: Size) -> Rect {
99    let width = rect.width().min(world.width());
100    let height = rect.height().min(world.height());
101    let x = rect.left().saturating_add((rect.width() - width) / 2);
102    let y = rect.top().saturating_add((rect.height() - height) / 2);
103    Rect::new(x, y, width, height)
104}
105
106impl Camera {
107    /// Create a camera drawing into `viewport` (screen cells) over a world of
108    /// `world` cells. The initial origin is `(0, 0)`; call
109    /// [`center_on`](Self::center_on) to follow a target.
110    #[must_use]
111    pub const fn new(viewport: Rect, world: Size) -> Self {
112        Self {
113            requested: viewport,
114            fit: ViewportFit::Exact,
115            viewport,
116            world,
117            origin: Pos::new(0, 0),
118        }
119    }
120
121    /// The screen rectangle the world is drawn into.
122    #[must_use]
123    pub const fn viewport(&self) -> Rect {
124        self.viewport
125    }
126
127    /// The world dimensions.
128    #[must_use]
129    pub const fn world(&self) -> Size {
130        self.world
131    }
132
133    /// The world cell shown at the viewport's top-left corner.
134    #[must_use]
135    pub const fn origin(&self) -> Pos {
136        self.origin
137    }
138
139    /// Replace the viewport (for example after a terminal resize), keeping the
140    /// world unchanged and re-clamping the origin so it stays in bounds.
141    ///
142    /// Never panics: a `viewport` larger than `world` re-clamps the origin to `(0, 0)` via
143    /// [`saturating_sub`](u16::saturating_sub) rather than underflowing.
144    pub fn set_viewport(&mut self, viewport: Rect) {
145        self.requested = viewport;
146        self.fit = ViewportFit::Exact;
147        self.apply_viewport();
148    }
149
150    /// Replace the world dimensions (for example when a level changes), keeping the requested
151    /// viewport rect unchanged and re-deriving [`viewport`](Self::viewport) and `origin` against
152    /// the new world.
153    ///
154    /// If the camera was last positioned with
155    /// [`set_viewport_fitted`](Self::set_viewport_fitted), this re-runs that letterboxing against
156    /// the new world too, so [`viewport`](Self::viewport) stays correct without calling
157    /// `set_viewport_fitted` again.
158    ///
159    /// Never panics: the re-clamp uses the same saturating arithmetic as
160    /// [`set_viewport`](Self::set_viewport).
161    ///
162    /// # Examples
163    ///
164    /// ```
165    /// use retroglyph_core::grid::{Pos, Rect, Size};
166    /// use retroglyph_ui::camera::Camera;
167    ///
168    /// let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size::new(100, 100));
169    /// cam.center_on(Pos::new(50, 50));
170    /// assert_eq!(cam.origin(), Pos::new(45, 45));
171    ///
172    /// // Shrinking the world re-clamps the origin so it stays in bounds.
173    /// cam.set_world(Size::new(20, 20));
174    /// assert_eq!(cam.world(), Size::new(20, 20));
175    /// assert_eq!(cam.origin(), Pos::new(10, 10));
176    /// ```
177    pub fn set_world(&mut self, world: Size) {
178        self.world = world;
179        self.apply_viewport();
180    }
181
182    /// Re-derives `viewport` from `requested` and `fit`, then re-clamps `origin` against it.
183    /// The single funnel [`set_viewport`](Self::set_viewport),
184    /// [`set_viewport_fitted`](Self::set_viewport_fitted), and [`set_world`](Self::set_world) all
185    /// go through, so `viewport` is always derived fresh rather than a stale snapshot from
186    /// whichever of those ran last.
187    fn apply_viewport(&mut self) {
188        self.viewport = match self.fit {
189            ViewportFit::Exact => self.requested,
190            ViewportFit::Letterbox => letterbox(self.requested, self.world),
191        };
192        self.set_origin(self.origin);
193    }
194
195    /// Replace the viewport like [`set_viewport`](Self::set_viewport), but shrink it to the
196    /// world's size on any axis where the world is smaller, and center the shrunk rect within
197    /// `viewport` rather than pinning it to the top-left.
198    ///
199    /// A viewport at least as large as the world on both axes lands exactly on the world with no
200    /// slack, so `origin` is `(0, 0)` and [`viewport`](Self::viewport) reports that centered
201    /// rect, not `viewport` itself; hit-testing via [`screen_to_world`](Self::screen_to_world)
202    /// therefore only recognizes screen positions actually over the world, not the letterboxed
203    /// margin. This is the fix for the pinned-to-the-corner behaviour
204    /// [`set_viewport`](Self::set_viewport) has for a world smaller than the viewport: a fixed
205    /// board, a generated map of fixed dimensions, or a minimap drawn into a terminal whose size
206    /// the app does not control.
207    ///
208    /// Odd leftover slack rounds down, the same way [`center_on`](Self::center_on) rounds: any
209    /// extra cell of margin lands on the right or bottom, not the left or top.
210    ///
211    /// Never panics: all arithmetic is saturating, so a `viewport` narrower than it is tall (or
212    /// vice versa) relative to `world` cannot underflow.
213    ///
214    /// # Examples
215    ///
216    /// ```
217    /// use retroglyph_core::grid::{Pos, Rect, Size};
218    /// use retroglyph_ui::camera::Camera;
219    ///
220    /// // A 20x20 viewport at (2, 2) over a 5x5 world: the effective viewport shrinks to 5x5
221    /// // and centers within the given rect, instead of pinning to (2, 2).
222    /// let mut cam = Camera::new(Rect::new(0, 0, 1, 1), Size::new(5, 5));
223    /// cam.set_viewport_fitted(Rect::new(2, 2, 20, 20));
224    /// assert_eq!(cam.viewport(), Rect::new(9, 9, 5, 5));
225    /// assert_eq!(cam.origin(), Pos::new(0, 0));
226    ///
227    /// // A viewport already no larger than the world on both axes behaves like `set_viewport`:
228    /// // no shrinking, no centering.
229    /// let mut cam = Camera::new(Rect::new(0, 0, 1, 1), Size::new(100, 100));
230    /// cam.set_viewport_fitted(Rect::new(0, 0, 10, 10));
231    /// assert_eq!(cam.viewport(), Rect::new(0, 0, 10, 10));
232    /// ```
233    pub fn set_viewport_fitted(&mut self, viewport: Rect) {
234        self.requested = viewport;
235        self.fit = ViewportFit::Letterbox;
236        self.apply_viewport();
237    }
238
239    /// Center the view on `target` (world coords), clamped to the world edges so
240    /// the viewport never scrolls past `[0, world)`.
241    ///
242    /// Never panics, even for a `target` outside `[0, world)`: the offset and clamp are both
243    /// computed with saturating arithmetic.
244    pub fn center_on(&mut self, target: Pos) {
245        self.set_origin(Pos::new(
246            target.x.saturating_sub(self.viewport.width() / 2),
247            target.y.saturating_sub(self.viewport.height() / 2),
248        ));
249    }
250
251    /// Set the top-left world cell directly, clamped to the world edges so `origin` never
252    /// scrolls past `[0, world)`, the same invariant [`center_on`](Self::center_on) maintains.
253    ///
254    /// This is the primitive [`center_on`](Self::center_on) and [`scroll_by`](Self::scroll_by)
255    /// both clamp through, and what a save/restore of camera state needs: [`origin`](Self::origin)
256    /// is otherwise read-only.
257    ///
258    /// Never panics: the clamp is [`Rect::clamp_within`], which uses
259    /// [`saturating_sub`](u16::saturating_sub) internally, so it cannot underflow even for a
260    /// `viewport` larger than `world`.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use retroglyph_core::grid::{Pos, Rect, Size};
266    /// use retroglyph_ui::camera::Camera;
267    ///
268    /// let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size::new(100, 100));
269    /// cam.set_origin(Pos::new(50, 50));
270    /// assert_eq!(cam.origin(), Pos::new(50, 50));
271    ///
272    /// // Clamped to `world - viewport`, same as `center_on`.
273    /// cam.set_origin(Pos::new(200, 200));
274    /// assert_eq!(cam.origin(), Pos::new(90, 90));
275    /// ```
276    pub fn set_origin(&mut self, origin: Pos) {
277        self.origin = Rect::from_tl_size(origin, self.viewport.size())
278            .clamp_within(self.world.to_rect())
279            .top_left();
280    }
281
282    /// Scroll the view by a signed cell delta, clamped to the world edges like
283    /// [`set_origin`](Self::set_origin).
284    ///
285    /// This is the method a drag or a scroll wheel wants: unlike [`center_on`](Self::center_on),
286    /// which reinterprets its argument as a new target to center on, `scroll_by` moves `origin`
287    /// directly, so there is exactly one clamp between the input delta and the visible result.
288    /// A caller that instead clamps its own running "center" position to `[0, world)` and feeds
289    /// it through `center_on` every frame is clamping against a wider range than `center_on`'s
290    /// own `[0, world - viewport]`, which leaves slack: dragging past an edge no longer moves
291    /// the origin, but the caller's tracked position keeps moving, so dragging back "sticks"
292    /// until it works through that slack before the view responds again.
293    ///
294    /// Never panics: the delta is applied in `i32` and saturates at `0` or `u16::MAX` before the
295    /// world-edge clamp in [`set_origin`](Self::set_origin) runs, so neither a very large
296    /// negative nor positive `dx`/`dy` can overflow or underflow `u16`.
297    ///
298    /// # Examples
299    ///
300    /// ```
301    /// use retroglyph_core::grid::{Pos, Rect, Size};
302    /// use retroglyph_ui::camera::Camera;
303    ///
304    /// let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size::new(100, 100));
305    /// cam.scroll_by(5, 3);
306    /// assert_eq!(cam.origin(), Pos::new(5, 3));
307    ///
308    /// // Clamped at the world edge, same as `center_on`: no negative or past-`world` origin.
309    /// cam.scroll_by(-100, -100);
310    /// assert_eq!(cam.origin(), Pos::new(0, 0));
311    /// ```
312    pub fn scroll_by(&mut self, dx: i32, dy: i32) {
313        self.set_origin(self.origin.saturating_add_signed(ixy::Pos::new(dx, dy)));
314    }
315}
316
317/// The tile-map sibling of [`Camera`](crate::camera::Camera).
318///
319/// Panning and clamping are paced in tiles rather than screen cells, and
320/// [`tile_rect`](Self::tile_rect)/[`tile_surface`](Self::tile_surface) project one tile to a
321/// `zoom.width() x zoom.height()` block of screen cells instead of `Camera`'s
322/// one-cell-to-one-cell picture.
323///
324/// Internally this owns a `Camera` counted in tiles instead of screen cells, so panning,
325/// clamping, and centering are exactly `Camera`'s own logic, applied to a tile grid; zoom only
326/// enters when projecting a tile to its screen-cell footprint.
327///
328/// # Examples
329///
330/// ```
331/// use retroglyph_core::grid::{Pos, Rect, Size};
332/// use retroglyph_ui::camera::TileCamera;
333///
334/// // A 12x8 screen viewport onto a 20x20 tile world, at 3x2 screen cells per tile.
335/// let mut cam = TileCamera::new(Rect::new(0, 0, 12, 8), Size::new(20, 20), Size::new(3, 2));
336/// cam.center_on(Pos::new(10, 10));
337/// assert_eq!(cam.tile_rect(Pos::new(10, 10)).width(), 3);
338/// ```
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub struct TileCamera {
341    tiles: Camera,
342    viewport: Rect,
343    zoom: Size,
344}
345
346impl TileCamera {
347    /// Create a tile camera drawing into `viewport` (screen cells) over a `world` of tiles, at
348    /// `zoom` screen cells per tile. The initial origin is `(0, 0)`; call
349    /// [`center_on`](Self::center_on) to follow a target.
350    ///
351    /// A zero `zoom` axis clamps to `1`, the same as [`set_zoom`](Self::set_zoom).
352    #[must_use]
353    pub fn new(viewport: Rect, world: Size, zoom: Size) -> Self {
354        let zoom = clamp_zoom(zoom);
355        Self {
356            tiles: Camera::new(tile_viewport(viewport, zoom), world),
357            viewport,
358            zoom,
359        }
360    }
361
362    /// The screen rectangle the world is drawn into.
363    #[must_use]
364    pub const fn viewport(&self) -> Rect {
365        self.viewport
366    }
367
368    /// The world dimensions, in tiles.
369    #[must_use]
370    pub const fn world(&self) -> Size {
371        self.tiles.world()
372    }
373
374    /// The tile shown at the viewport's top-left corner.
375    #[must_use]
376    pub const fn origin(&self) -> Pos {
377        self.tiles.origin()
378    }
379
380    /// How many screen cells one tile covers, per axis. `(1, 1)` by default: one tile to one
381    /// screen cell, same as an un-zoomed [`Camera`](crate::camera::Camera).
382    #[must_use]
383    pub const fn zoom(&self) -> Size {
384        self.zoom
385    }
386
387    /// Set how many screen cells one tile covers, per axis, re-deriving the tile-unit viewport
388    /// from the current screen [`viewport`](Self::viewport) and re-clamping the origin so it
389    /// stays in bounds for the new tile-count viewport.
390    ///
391    /// A zero width or height clamps to `1`: a zero-size tile has no meaningful footprint to
392    /// draw into, so this refuses to create one rather than silently making every tile
393    /// disappear.
394    ///
395    /// # Examples
396    ///
397    /// ```
398    /// use retroglyph_core::grid::{Rect, Size};
399    /// use retroglyph_ui::camera::TileCamera;
400    ///
401    /// let mut cam = TileCamera::new(Rect::new(0, 0, 10, 10), Size::new(20, 20), Size::new(1, 1));
402    /// cam.set_zoom(Size::new(3, 2));
403    /// assert_eq!(cam.zoom(), Size::new(3, 2));
404    ///
405    /// // A zero axis clamps to 1 rather than producing an empty tile footprint.
406    /// cam.set_zoom(Size::new(0, 4));
407    /// assert_eq!(cam.zoom(), Size::new(1, 4));
408    /// ```
409    pub fn set_zoom(&mut self, zoom: Size) {
410        self.zoom = clamp_zoom(zoom);
411        self.tiles
412            .set_viewport(tile_viewport(self.viewport, self.zoom));
413    }
414
415    /// Replace the viewport (for example after a terminal resize), keeping the world and zoom
416    /// unchanged and re-clamping the origin so it stays in bounds.
417    pub fn set_viewport(&mut self, viewport: Rect) {
418        self.viewport = viewport;
419        self.tiles.set_viewport(tile_viewport(viewport, self.zoom));
420    }
421
422    /// Replace the world dimensions, in tiles, keeping the viewport and zoom unchanged and
423    /// re-clamping the origin so it stays in bounds.
424    pub fn set_world(&mut self, world: Size) {
425        self.tiles.set_world(world);
426    }
427
428    /// Center the view on `target` (a tile position), clamped to the world edges so the
429    /// viewport never scrolls past `[0, world)` tiles. The tile equivalent of
430    /// [`Camera::center_on`](crate::camera::Camera::center_on).
431    pub fn center_on(&mut self, target: Pos) {
432        self.tiles.center_on(target);
433    }
434
435    /// Set the top-left tile directly, clamped to the world edges like
436    /// [`center_on`](Self::center_on). The tile equivalent of
437    /// [`Camera::set_origin`](crate::camera::Camera::set_origin).
438    pub fn set_origin(&mut self, origin: Pos) {
439        self.tiles.set_origin(origin);
440    }
441
442    /// Scroll the view by a signed tile delta, clamped to the world edges like
443    /// [`set_origin`](Self::set_origin). The tile equivalent of
444    /// [`Camera::scroll_by`](crate::camera::Camera::scroll_by).
445    pub fn scroll_by(&mut self, dx: i32, dy: i32) {
446        self.tiles.scroll_by(dx, dy);
447    }
448
449    /// The tile rectangle currently visible, in tile coordinates, clamped to world bounds. The
450    /// tile equivalent of [`Camera::visible_bounds`](crate::camera::Camera::visible_bounds).
451    #[must_use]
452    pub fn visible_bounds(&self) -> Rect {
453        self.tiles.visible_bounds()
454    }
455}
456
457/// A zero width or height clamps to `1`: a zero-size tile has no meaningful footprint.
458const fn clamp_zoom(zoom: Size) -> Size {
459    let width = if zoom.width == 0 { 1 } else { zoom.width };
460    let height = if zoom.height == 0 { 1 } else { zoom.height };
461    Size::new(width, height)
462}
463
464/// The tile-unit viewport an inner [`Camera`] paces its panning and clamping against: the
465/// screen `viewport`'s size in tiles, at a fixed `(0, 0)` position, since only the size (not
466/// the position) of a `Camera`'s viewport affects its clamp/origin math.
467fn tile_viewport(viewport: Rect, zoom: Size) -> Rect {
468    Rect::from_tl_size(Pos::new(0, 0), viewport.size() / zoom)
469}
470
471#[cfg(test)]
472pub(super) const fn cam() -> Camera {
473    Camera::new(Rect::new(0, 0, 10, 10), Size::new(100, 100))
474}
475
476#[cfg(test)]
477pub(super) fn tile_cam() -> TileCamera {
478    TileCamera::new(
479        Rect::new(0, 0, 10, 10),
480        Size::new(100, 100),
481        Size::new(1, 1),
482    )
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn centers_in_the_interior() {
491        let mut c = cam();
492        c.center_on(Pos::new(50, 50));
493        assert_eq!(c.origin(), Pos::new(45, 45));
494        assert_eq!(c.world_to_screen(Pos::new(50, 50)), Some(Pos::new(5, 5)));
495        assert_eq!(c.screen_to_world(Pos::new(5, 5)), Some(Pos::new(50, 50)));
496    }
497
498    #[test]
499    fn clamps_at_the_low_edge() {
500        let mut c = cam();
501        c.center_on(Pos::new(1, 1));
502        assert_eq!(c.origin(), Pos::new(0, 0));
503        assert_eq!(c.world_to_screen(Pos::new(1, 1)), Some(Pos::new(1, 1)));
504    }
505
506    #[test]
507    fn clamps_at_the_high_edge() {
508        let mut c = cam();
509        c.center_on(Pos::new(99, 99));
510        // origin = min(99 - 5, 100 - 10) = min(94, 90) = 90.
511        assert_eq!(c.origin(), Pos::new(90, 90));
512        assert_eq!(c.world_to_screen(Pos::new(99, 99)), Some(Pos::new(9, 9)));
513    }
514
515    #[test]
516    fn world_smaller_than_viewport_pins_origin() {
517        let mut c = Camera::new(Rect::new(2, 2, 20, 20), Size::new(5, 5));
518        c.center_on(Pos::new(3, 3));
519        assert_eq!(c.origin(), Pos::new(0, 0));
520        let visible = c.visible_bounds();
521        assert_eq!((visible.width(), visible.height()), (5, 5));
522        // Cells map into the viewport, offset by its top-left.
523        assert_eq!(c.world_to_screen(Pos::new(0, 0)), Some(Pos::new(2, 2)));
524        // A cell inside the viewport but outside the (smaller) world: rejected, matching
525        // `visible_bounds` and `screen_to_world`, not silently mapped past the world edge.
526        assert_eq!(c.world_to_screen(Pos::new(7, 7)), None);
527    }
528
529    #[test]
530    fn set_world_re_clamps_the_origin_when_the_world_shrinks() {
531        let mut c = cam();
532        c.center_on(Pos::new(50, 50));
533        assert_eq!(c.origin(), Pos::new(45, 45));
534
535        c.set_world(Size::new(20, 20));
536        assert_eq!(c.world(), Size::new(20, 20));
537        // clamp_within(world 20x20) clamps origin down from 45 to 20 - viewport(10) = 10.
538        assert_eq!(c.origin(), Pos::new(10, 10));
539    }
540
541    #[test]
542    fn set_world_leaves_an_in_bounds_origin_unchanged_when_the_world_grows() {
543        let mut c = cam();
544        c.center_on(Pos::new(50, 50));
545        assert_eq!(c.origin(), Pos::new(45, 45));
546
547        c.set_world(Size::new(200, 200));
548        assert_eq!(c.world(), Size::new(200, 200));
549        assert_eq!(c.origin(), Pos::new(45, 45));
550    }
551
552    #[test]
553    fn set_world_pins_origin_to_zero_when_the_new_world_is_smaller_than_the_viewport() {
554        let mut c = cam();
555        c.center_on(Pos::new(50, 50));
556
557        c.set_world(Size::new(5, 5));
558        assert_eq!(c.origin(), Pos::new(0, 0));
559    }
560
561    #[test]
562    fn set_viewport_fitted_shrinks_and_centers_a_world_smaller_on_both_axes() {
563        let mut c = Camera::new(Rect::new(0, 0, 1, 1), Size::new(5, 5));
564        c.set_viewport_fitted(Rect::new(2, 2, 20, 20));
565        assert_eq!(c.viewport(), Rect::new(9, 9, 5, 5));
566        assert_eq!(c.origin(), Pos::new(0, 0));
567        assert_eq!(c.visible_bounds(), Rect::new(0, 0, 5, 5));
568        assert_eq!(c.world_to_screen(Pos::new(0, 0)), Some(Pos::new(9, 9)));
569    }
570
571    #[test]
572    fn set_viewport_fitted_shrinks_only_the_axis_that_is_smaller() {
573        // World is smaller than the viewport on x only.
574        let mut c = Camera::new(Rect::new(0, 0, 1, 1), Size::new(5, 100));
575        c.set_viewport_fitted(Rect::new(0, 0, 20, 10));
576        assert_eq!(c.viewport(), Rect::new(7, 0, 5, 10));
577    }
578
579    #[test]
580    fn set_viewport_fitted_rounds_odd_slack_toward_the_right_and_bottom() {
581        let mut c = Camera::new(Rect::new(0, 0, 1, 1), Size::new(4, 4));
582        c.set_viewport_fitted(Rect::new(0, 0, 9, 9));
583        // 9 - 4 = 5 of slack, split 2/3: two columns/rows left and top, three right and bottom.
584        assert_eq!(c.viewport(), Rect::new(2, 2, 4, 4));
585    }
586
587    #[test]
588    fn set_world_re_derives_a_fitted_viewport_instead_of_leaving_it_stale() {
589        let mut c = Camera::new(Rect::new(2, 2, 20, 20), Size::new(5, 5));
590        c.set_viewport_fitted(Rect::new(2, 2, 20, 20));
591        assert_eq!(c.viewport(), Rect::new(9, 9, 5, 5));
592
593        // A larger world no longer needs letterboxing: `viewport` re-expands to the originally
594        // requested rect instead of staying pinned to the previous world's letterboxed rect.
595        c.set_world(Size::new(100, 100));
596        assert_eq!(c.viewport(), Rect::new(2, 2, 20, 20));
597    }
598
599    #[test]
600    fn set_viewport_reclamps_the_origin_when_the_viewport_grows() {
601        let mut c = cam(); // 10x10 viewport, 100x100 world.
602        c.center_on(Pos::new(99, 99)); // origin (90, 90).
603        c.set_viewport(Rect::new(0, 0, 40, 40));
604        assert_eq!(c.origin(), Pos::new(60, 60)); // 100 - 40.
605        assert_eq!(c.visible_bounds(), Rect::new(60, 60, 40, 40));
606    }
607
608    #[test]
609    fn zero_size_camera_is_inert() {
610        let c = Camera::new(Rect::new(0, 0, 0, 0), Size::new(0, 0));
611        assert_eq!(c.visible_bounds(), Rect::new(0, 0, 0, 0));
612        assert_eq!(c.cells().count(), 0);
613        assert_eq!(c.world_to_screen(Pos::new(0, 0)), None);
614        assert_eq!(c.screen_to_world(Pos::new(0, 0)), None);
615
616        // A zero-size world under a normal viewport behaves the same way: nothing to show.
617        // `world_to_screen` only checks against the viewport, not `world`, so it is
618        // `screen_to_world` (which does check `world`) that actually guards this case.
619        let zero_world = Camera::new(Rect::new(0, 0, 10, 10), Size::new(0, 0));
620        assert_eq!(zero_world.visible_bounds(), Rect::new(0, 0, 0, 0));
621        assert_eq!(zero_world.cells().count(), 0);
622        assert_eq!(zero_world.screen_to_world(Pos::new(0, 0)), None);
623    }
624
625    #[test]
626    fn set_viewport_fitted_matches_set_viewport_when_the_world_is_not_smaller() {
627        let mut a = Camera::new(Rect::new(0, 0, 1, 1), Size::new(100, 100));
628        a.set_viewport_fitted(Rect::new(0, 0, 10, 10));
629
630        let mut b = Camera::new(Rect::new(0, 0, 1, 1), Size::new(100, 100));
631        b.set_viewport(Rect::new(0, 0, 10, 10));
632
633        assert_eq!(a.viewport(), b.viewport());
634        assert_eq!(a.origin(), b.origin());
635    }
636
637    #[test]
638    fn set_viewport_fitted_saturates_instead_of_overflowing() {
639        let mut c = Camera::new(Rect::new(0, 0, 1, 1), Size::new(5, 5));
640        c.set_viewport_fitted(Rect::new(65_530, 0, 1_000, 10));
641        assert_eq!(c.viewport(), Rect::new(u16::MAX, 2, 5, 5));
642    }
643
644    #[test]
645    fn scroll_by_moves_the_origin_by_the_delta() {
646        let mut c = cam();
647        c.scroll_by(5, 3);
648        assert_eq!(c.origin(), Pos::new(5, 3));
649        c.scroll_by(-2, 1);
650        assert_eq!(c.origin(), Pos::new(3, 4));
651    }
652
653    #[test]
654    fn scroll_by_clamps_at_the_low_edge_without_overshooting() {
655        let mut c = cam();
656        c.scroll_by(-1000, -1000);
657        assert_eq!(c.origin(), Pos::new(0, 0));
658    }
659
660    #[test]
661    fn scroll_by_clamps_at_the_high_edge_without_overshooting() {
662        let mut c = cam(); // viewport 10x10, world 100x100: max origin is (90, 90).
663        c.scroll_by(1000, 1000);
664        assert_eq!(c.origin(), Pos::new(90, 90));
665    }
666
667    #[test]
668    fn scroll_by_has_no_dead_zone_reversing_direction_past_an_edge() {
669        // The bug `scroll_by` replaces: a caller that clamps its own "center" to `[0, world)`
670        // and re-derives the origin via `center_on` every frame accumulates slack once the
671        // center clamps past what `center_on`'s own `[0, world - viewport]` clamp allows, so
672        // reversing direction doesn't move the origin until that slack is used up. `scroll_by`
673        // has one clamp on `origin` itself, so the very next opposite-direction scroll moves it.
674        let mut c = cam(); // viewport 10x10, world 100x100: max origin is (90, 90).
675        c.scroll_by(1000, 0); // drive past the edge; origin clamps to (90, 0).
676        assert_eq!(c.origin(), Pos::new(90, 0));
677        c.scroll_by(-1, 0); // reverse by a single cell.
678        assert_eq!(
679            c.origin(),
680            Pos::new(89, 0),
681            "a single reversed cell must move the origin"
682        );
683    }
684
685    #[test]
686    fn set_origin_places_the_origin_exactly_when_in_bounds() {
687        let mut c = cam();
688        c.set_origin(Pos::new(12, 34));
689        assert_eq!(c.origin(), Pos::new(12, 34));
690    }
691
692    #[test]
693    fn set_origin_clamps_to_the_world_edge() {
694        let mut c = cam(); // viewport 10x10, world 100x100: max origin is (90, 90).
695        c.set_origin(Pos::new(95, 200));
696        assert_eq!(c.origin(), Pos::new(90, 90));
697    }
698
699    #[test]
700    fn set_origin_never_underflows_when_the_viewport_exceeds_the_world() {
701        let mut c = Camera::new(Rect::new(0, 0, 20, 20), Size::new(5, 5));
702        c.set_origin(Pos::new(3, 3));
703        assert_eq!(c.origin(), Pos::new(0, 0));
704    }
705
706    #[test]
707    fn center_on_and_set_origin_agree_on_the_clamped_result() {
708        // `center_on` now routes through `set_origin`; this pins that composition down.
709        let mut a = cam();
710        a.center_on(Pos::new(99, 99));
711
712        let mut b = cam();
713        b.set_origin(Pos::new(94, 94)); // 99 - viewport.width() / 2 = 94.
714
715        assert_eq!(a.origin(), b.origin());
716    }
717}