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}