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}