Skip to main content

retroglyph_ui/camera/
transform.rs

1//! Coordinate conversions between world and screen space.
2
3use super::Camera;
4use retroglyph_core::grid::{HasSize, Pos, Rect};
5use retroglyph_core::surface::Surface;
6
7impl Camera {
8    /// The world rectangle currently visible, clamped to world bounds.
9    ///
10    /// Never panics: the clamp against `world` uses
11    /// [`saturating_sub`](u16::saturating_sub), so it cannot underflow even if `origin` is
12    /// somehow past `world`'s edge.
13    ///
14    /// # Examples
15    ///
16    /// ```
17    /// use retroglyph_core::grid::{Pos, Rect, Size};
18    /// use retroglyph_ui::Camera;
19    ///
20    /// // A 10x10 viewport near the bottom-right corner of a 12x12 world: the origin clamps
21    /// // to (2, 2), so the visible rect is narrower than the viewport rather than reading
22    /// // past the world edge.
23    /// let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size::new(12, 12));
24    /// cam.center_on(Pos::new(11, 11));
25    /// assert_eq!(cam.origin(), Pos::new(2, 2));
26    /// assert_eq!(cam.visible_bounds(), Rect::new(2, 2, 10, 10));
27    ///
28    /// // A world smaller than the viewport: the visible rect is the whole world, not the
29    /// // full viewport size.
30    /// let small = Camera::new(Rect::new(0, 0, 20, 20), Size::new(5, 5));
31    /// assert_eq!(small.visible_bounds(), Rect::new(0, 0, 5, 5));
32    /// ```
33    #[must_use]
34    pub fn visible_bounds(&self) -> Rect {
35        Rect::from_tl_size(self.origin, self.viewport.size()).intersect(self.world.to_rect())
36    }
37
38    /// Map a world position to its screen position, or `None` if it is outside
39    /// [`visible_bounds`](Self::visible_bounds): the viewport, clamped to the world.
40    #[must_use]
41    pub const fn world_to_screen(&self, world: Pos) -> Option<Pos> {
42        if world.x < self.origin.x || world.y < self.origin.y {
43            return None;
44        }
45        if world.x >= self.world.width || world.y >= self.world.height {
46            return None;
47        }
48        let dx = world.x - self.origin.x;
49        let dy = world.y - self.origin.y;
50        if dx >= self.viewport.width() || dy >= self.viewport.height() {
51            return None;
52        }
53        Some(Pos::new(
54            self.viewport.left().saturating_add(dx),
55            self.viewport.top().saturating_add(dy),
56        ))
57    }
58
59    /// Map a world position to its screen position, without culling: the result may fall
60    /// outside the viewport (negative, or past its far edge) instead of coming back `None`.
61    ///
62    /// [`world_to_screen`](Self::world_to_screen) is the right call when the only question is
63    /// "is this single cell visible" (a minimap dot, a cursor). It falls short for anything
64    /// wider than one cell (a hex, an iso diamond, a multi-cell sprite) where the *anchor*
65    /// can be off-viewport while part of the content is still visible. This is the signed
66    /// sibling for that case: it hands back the same math `world_to_screen` computes, minus the
67    /// culling, ready for [`Surface::put_signed`](retroglyph_core::surface::Surface::put_signed) to clip.
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// use retroglyph_core::grid::{Pos, Rect, Size};
73    /// use retroglyph_ui::Camera;
74    ///
75    /// let mut cam = Camera::new(Rect::new(0, 0, 10, 10), Size::new(100, 100));
76    /// cam.center_on(Pos::new(50, 50));
77    ///
78    /// // Inside the viewport: matches `world_to_screen`.
79    /// assert_eq!(cam.world_to_offset(Pos::new(50, 50)), (5, 5));
80    ///
81    /// // A multi-cell sprite's top-left anchor two cells left of the viewport: negative, not
82    /// // `None`, so a caller can still hand this to `Surface::put_signed` and let the visible
83    /// // half draw.
84    /// assert_eq!(cam.world_to_offset(Pos::new(43, 50)), (-2, 5));
85    /// ```
86    #[must_use]
87    pub const fn world_to_offset(&self, world: Pos) -> (i32, i32) {
88        let dx = world.x as i32 - self.origin.x as i32;
89        let dy = world.y as i32 - self.origin.y as i32;
90        (
91            self.viewport.left() as i32 + dx,
92            self.viewport.top() as i32 + dy,
93        )
94    }
95
96    /// A view of `surface` in this camera's world coordinate space, clipped to
97    /// [`visible_bounds`](Self::visible_bounds): [`Surface::clip_translate`](retroglyph_core::surface::Surface::clip_translate) to the visible
98    /// rect, by [`origin`](Self::origin).
99    ///
100    /// The returned surface's `put`, `put_signed`, `print`, and the rest of `Surface`'s
101    /// coordinate-taking methods all take world coordinates directly, and anything that lands
102    /// outside `visible_bounds` (including a multi-cell draw anchored off-screen, or - for a
103    /// world smaller than the viewport - the dead margin past the world edge) is dropped by the
104    /// surface's own bounds check, the same way [`world_to_offset`] composes with
105    /// [`Surface::put_signed`](retroglyph_core::surface::Surface::put_signed) by hand. This is that composition done once instead of at every
106    /// call site.
107    ///
108    /// Clipping to `visible_bounds` rather than [`viewport`](Self::viewport) directly matches
109    /// [`world_to_screen`](Self::world_to_screen) and [`screen_to_world`](Self::screen_to_world):
110    /// a world smaller than the viewport (under plain [`set_viewport`](Self::set_viewport), not
111    /// [`set_viewport_fitted`](Self::set_viewport_fitted)) shrinks the clip to the world's size
112    /// instead of leaving the viewport's dead margin drawable.
113    ///
114    /// [`world_to_offset`]: Self::world_to_offset
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use retroglyph_core::color::Style;
120    /// use retroglyph_core::grid::{Grid, Pos, Rect, Size};
121    /// use retroglyph_core::surface::Surface;
122    /// use retroglyph_ui::Camera;
123    ///
124    /// let mut grid = Grid::new(20, 20);
125    /// let mut root = Surface::new(&mut grid, Rect::new(0, 0, 20, 20), 0);
126    ///
127    /// let mut cam = Camera::new(Rect::new(5, 5, 10, 10), Size::new(100, 100));
128    /// cam.center_on(Pos::new(50, 50));
129    ///
130    /// let mut world = cam.surface(&mut root);
131    /// // Drawn in world coordinates: (50, 50) is the centered target, landing at the
132    /// // viewport's center cell (10, 10) in grid space.
133    /// world.put(Pos::new(50, 50), '@', Style::default());
134    /// // A world position outside the viewport is dropped, not a panic or a manual guard.
135    /// world.put(Pos::new(0, 0), 'X', Style::default());
136    ///
137    /// assert_eq!(grid[Pos::new(10, 10)].glyph(), '@');
138    /// ```
139    ///
140    /// A world smaller than the viewport: drawing into the dead margin past the world edge is
141    /// dropped, not written past the world into unused grid cells.
142    ///
143    /// ```
144    /// use retroglyph_core::color::Style;
145    /// use retroglyph_core::grid::{Grid, Pos, Rect, Size};
146    /// use retroglyph_core::surface::Surface;
147    /// use retroglyph_ui::Camera;
148    ///
149    /// let mut grid = Grid::new(20, 20);
150    /// let mut root = Surface::new(&mut grid, Rect::new(0, 0, 20, 20), 0);
151    ///
152    /// // A 20x20 viewport over a 5x5 world: `visible_bounds` is only 5x5, not the full
153    /// // viewport, so the clip shrinks to match.
154    /// let cam = Camera::new(Rect::new(0, 0, 20, 20), Size::new(5, 5));
155    ///
156    /// let mut world = cam.surface(&mut root);
157    /// world.put(Pos::new(0, 0), '@', Style::default());
158    /// // Inside the viewport but past the (smaller) world's edge: dropped.
159    /// world.put(Pos::new(10, 10), 'X', Style::default());
160    ///
161    /// assert_eq!(grid[Pos::new(0, 0)].glyph(), '@');
162    /// assert_eq!(grid[Pos::new(10, 10)].glyph(), ' ');
163    /// ```
164    #[must_use]
165    pub fn surface<'a>(&self, surface: &'a mut Surface<'_>) -> Surface<'a> {
166        let visible = self.visible_bounds();
167        let area = Rect::new(
168            self.viewport.left(),
169            self.viewport.top(),
170            visible.width(),
171            visible.height(),
172        );
173        surface.clip_translate(area, (i32::from(self.origin.x), i32::from(self.origin.y)))
174    }
175
176    /// Map a screen position back to a world position, or `None` if it is
177    /// outside the viewport or beyond the world (useful for mouse picking).
178    ///
179    /// # Examples
180    ///
181    /// ```
182    /// use retroglyph_core::grid::{Pos, Rect, Size};
183    /// use retroglyph_ui::Camera;
184    ///
185    /// let mut cam = Camera::new(Rect::new(5, 5, 10, 10), Size::new(100, 100));
186    /// cam.center_on(Pos::new(50, 50));
187    ///
188    /// // Inside the viewport: maps back to the world cell under it.
189    /// assert_eq!(cam.screen_to_world(Pos::new(5, 5)), Some(Pos::new(45, 45)));
190    ///
191    /// // Off the viewport entirely (the viewport starts at x = 5): `None`, not a clamp.
192    /// assert_eq!(cam.screen_to_world(Pos::new(0, 0)), None);
193    /// ```
194    #[must_use]
195    pub fn screen_to_world(&self, screen: Pos) -> Option<Pos> {
196        if !self.viewport.contains_pos(screen) {
197            return None;
198        }
199        // Safe without saturating: `origin` is only ever written by `center_on`/`set_viewport`,
200        // both of which clamp it via `Rect::clamp_within` to `[0, world - view]`, and
201        // `contains_pos` above guarantees the offset is `< view`, so the sum is `< world <=
202        // u16::MAX`.
203        let wx = self.origin.x + (screen.x - self.viewport.left());
204        let wy = self.origin.y + (screen.y - self.viewport.top());
205        if wx >= self.world.width() || wy >= self.world.height() {
206            return None;
207        }
208        Some(Pos::new(wx, wy))
209    }
210
211    /// Map a screen position back to a world position, without culling: the result may fall
212    /// outside the viewport or outside `[0, world)`, instead of coming back `None`.
213    ///
214    /// [`screen_to_world`](Self::screen_to_world) is the right call when the only question is
215    /// "which world cell is under this screen position" (mouse picking, a single-cell cursor).
216    /// It falls short once a gesture can leave the viewport or the world mid-flight: a pointer
217    /// drag that overshoots the edge, or a rubber-band selection rect that extends past it, has
218    /// no `Pos` to report and no way to compute a world-space delta. This is the signed sibling
219    /// for that case: it hands back the same math `screen_to_world` computes, minus the culling.
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(5, 5, 10, 10), Size::new(100, 100));
228    /// cam.center_on(Pos::new(50, 50));
229    ///
230    /// // Inside the viewport: matches `screen_to_world`.
231    /// assert_eq!(cam.screen_to_world_signed(Pos::new(5, 5)), (45, 45));
232    ///
233    /// // Off the viewport entirely (the viewport starts at x = 5): negative, not `None`, so a
234    /// // caller mid-drag can still compute a world-space delta.
235    /// assert_eq!(cam.screen_to_world_signed(Pos::new(0, 0)), (40, 40));
236    /// ```
237    #[must_use]
238    pub const fn screen_to_world_signed(&self, screen: Pos) -> (i32, i32) {
239        let dx = screen.x as i32 - self.viewport.left() as i32;
240        let dy = screen.y as i32 - self.viewport.top() as i32;
241        (self.origin.x as i32 + dx, self.origin.y as i32 + dy)
242    }
243
244    /// Iterate the visible cells as `(world, screen)` position pairs, in
245    /// row-major order. Only cells that exist in the world are yielded, so the
246    /// caller can fill the rest of the viewport with a background.
247    #[must_use = "iterators are lazy and do nothing unless consumed"]
248    pub fn cells(&self) -> impl Iterator<Item = (Pos, Pos)> {
249        let vis = self.visible_bounds();
250        let vp = self.viewport;
251        let origin = self.origin;
252        (vis.top()..vis.bottom()).flat_map(move |wy| {
253            (vis.left()..vis.right()).map(move |wx| {
254                let screen = Pos::new(
255                    vp.left().saturating_add(wx - origin.x),
256                    vp.top().saturating_add(wy - origin.y),
257                );
258                (Pos::new(wx, wy), screen)
259            })
260        })
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::super::cam;
267    use super::*;
268    use retroglyph_core::grid::Size;
269
270    #[test]
271    fn screen_to_world_is_none_outside_the_viewport_or_past_the_world_edge() {
272        let mut c = cam();
273        c.center_on(Pos::new(50, 50)); // shows world [45, 55).
274        // Off the viewport entirely: the viewport starts at x = 0, so a negative screen
275        // position never reaches `contains_pos`.
276        assert_eq!(c.screen_to_world(Pos::new(20, 20)), None);
277
278        // Inside the viewport, but the mapped world position is past the world edge: a 5x5
279        // world with a 10x10 viewport pinned to (0, 0) leaves the bottom-right quadrant of
280        // the viewport mapping past `world`.
281        let small = Camera::new(Rect::new(0, 0, 10, 10), Size::new(5, 5));
282        assert_eq!(small.screen_to_world(Pos::new(9, 9)), None);
283    }
284
285    #[test]
286    fn offscreen_positions_return_none() {
287        let mut c = cam();
288        c.center_on(Pos::new(50, 50)); // shows world [45,55)
289        assert_eq!(c.world_to_screen(Pos::new(44, 50)), None);
290        assert_eq!(c.world_to_screen(Pos::new(55, 50)), None);
291    }
292
293    #[test]
294    fn world_to_screen_rejects_a_zero_size_world() {
295        let c = Camera::new(Rect::new(0, 0, 10, 10), Size::new(0, 0));
296        // An empty world has no cells to map, matching `screen_to_world`'s equivalent None.
297        assert_eq!(c.world_to_screen(Pos::new(0, 0)), None);
298        assert_eq!(c.screen_to_world(Pos::new(0, 0)), None);
299    }
300
301    #[test]
302    fn screen_to_world_returns_none_past_the_world_edge_within_the_viewport() {
303        // A 20x20 viewport over a 5x5 world: the origin pins to (0, 0), so the viewport has a
304        // dead margin past (5, 5) that is inside the viewport but outside the world.
305        let c = Camera::new(Rect::new(2, 2, 20, 20), Size::new(5, 5));
306        // Inside the viewport, but past the world edge: the mouse-picking case the guard exists
307        // for, not `None` from missing the viewport.
308        assert_eq!(c.screen_to_world(Pos::new(10, 10)), None);
309        // Just inside the world edge still resolves normally.
310        assert_eq!(c.screen_to_world(Pos::new(6, 6)), Some(Pos::new(4, 4)));
311    }
312
313    #[test]
314    fn cells_only_yields_cells_that_exist_in_the_world() {
315        use alloc::vec::Vec;
316
317        // A 20x20 viewport over a 5x5 world: the clamp in `visible_bounds` is doing real work
318        // here, unlike the full-viewport case above.
319        let c = Camera::new(Rect::new(0, 0, 20, 20), Size::new(5, 5));
320        let pairs: Vec<_> = c.cells().collect();
321        assert_eq!(pairs.len(), 25); // 5x5 world, not the 20x20 viewport.
322        assert_eq!(pairs[0], (Pos::new(0, 0), Pos::new(0, 0)));
323        assert_eq!(pairs[24], (Pos::new(4, 4), Pos::new(4, 4)));
324    }
325
326    #[test]
327    fn surface_clips_to_the_letterboxed_viewport_after_set_viewport_fitted() {
328        use retroglyph_core::color::Style;
329        use retroglyph_core::grid::Grid;
330
331        let mut grid = Grid::new(20, 20);
332        let mut root = Surface::new(&mut grid, Rect::new(0, 0, 20, 20), 0);
333
334        let mut c = Camera::new(Rect::new(0, 0, 1, 1), Size::new(5, 5));
335        c.set_viewport_fitted(Rect::new(0, 0, 20, 20));
336        assert_eq!(c.viewport(), Rect::new(7, 7, 5, 5)); // shrunk to the world and centered.
337
338        let mut world = c.surface(&mut root);
339        // The world origin lands at the letterboxed viewport's own top-left, not the grid's.
340        world.put(Pos::new(0, 0), '@', Style::default());
341        // Outside the shrunk viewport (but still inside the un-fitted 20x20 rect passed in):
342        // dropped, the same as `world_to_screen` returning `None` for it, not drawn into the
343        // dead margin.
344        world.put(Pos::new(10, 10), 'X', Style::default());
345
346        assert_eq!(grid[Pos::new(7, 7)].glyph(), '@');
347        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' '); // untouched margin cell.
348    }
349
350    #[test]
351    fn cells_yields_visible_world_and_screen_pairs() {
352        use alloc::vec::Vec;
353
354        let mut c = cam();
355        c.center_on(Pos::new(50, 50));
356        let pairs: Vec<_> = c.cells().collect();
357        assert_eq!(pairs.len(), 100); // 10x10 viewport, world larger
358        assert_eq!(pairs[0], (Pos::new(45, 45), Pos::new(0, 0)));
359        assert_eq!(pairs[99], (Pos::new(54, 54), Pos::new(9, 9)));
360    }
361
362    #[test]
363    fn world_to_offset_matches_world_to_screen_when_visible() {
364        let mut c = cam();
365        c.center_on(Pos::new(50, 50));
366        assert_eq!(c.world_to_offset(Pos::new(50, 50)), (5, 5));
367        assert_eq!(c.world_to_screen(Pos::new(50, 50)), Some(Pos::new(5, 5)));
368    }
369
370    #[test]
371    fn world_to_offset_goes_negative_past_the_low_edge_instead_of_culling() {
372        let mut c = cam();
373        c.center_on(Pos::new(50, 50)); // shows world [45, 55).
374        assert_eq!(c.world_to_offset(Pos::new(44, 50)), (-1, 5));
375        // The same position through `world_to_screen`: culled, not negative.
376        assert_eq!(c.world_to_screen(Pos::new(44, 50)), None);
377    }
378
379    #[test]
380    fn world_to_offset_goes_past_the_far_edge_instead_of_culling() {
381        let mut c = cam();
382        c.center_on(Pos::new(50, 50)); // shows world [45, 55).
383        assert_eq!(c.world_to_offset(Pos::new(55, 50)), (10, 5));
384        assert_eq!(c.world_to_screen(Pos::new(55, 50)), None);
385    }
386
387    #[test]
388    fn world_to_offset_includes_a_non_zero_viewport_origin() {
389        let mut c = Camera::new(Rect::new(5, 5, 10, 10), Size::new(100, 100));
390        c.center_on(Pos::new(50, 50));
391        assert_eq!(c.world_to_offset(Pos::new(50, 50)), (10, 10));
392    }
393
394    #[test]
395    fn screen_to_world_signed_matches_screen_to_world_when_visible() {
396        let mut c = Camera::new(Rect::new(5, 5, 10, 10), Size::new(100, 100));
397        c.center_on(Pos::new(50, 50));
398        assert_eq!(c.screen_to_world_signed(Pos::new(5, 5)), (45, 45));
399        assert_eq!(c.screen_to_world(Pos::new(5, 5)), Some(Pos::new(45, 45)));
400    }
401
402    #[test]
403    fn screen_to_world_signed_goes_negative_before_the_viewport_instead_of_culling() {
404        let mut c = Camera::new(Rect::new(5, 5, 10, 10), Size::new(100, 100));
405        c.center_on(Pos::new(50, 50)); // origin (45, 45), viewport starts at (5, 5).
406        assert_eq!(c.screen_to_world_signed(Pos::new(0, 0)), (40, 40));
407        // The same screen position through `screen_to_world`: culled, not negative.
408        assert_eq!(c.screen_to_world(Pos::new(0, 0)), None);
409    }
410
411    #[test]
412    fn screen_to_world_signed_goes_past_the_world_edge_instead_of_culling() {
413        let mut c = cam(); // viewport (0, 0, 10, 10), world 100x100.
414        c.center_on(Pos::new(99, 99));
415        assert_eq!(c.origin(), Pos::new(90, 90)); // origin clamps so origin + viewport = world.
416        // One column/row past the viewport's own far edge, so past the world edge too:
417        // `screen_to_world` culls (out of viewport), the signed sibling keeps counting.
418        assert_eq!(c.screen_to_world_signed(Pos::new(10, 10)), (100, 100));
419        assert_eq!(c.screen_to_world(Pos::new(10, 10)), None);
420    }
421
422    #[test]
423    fn surface_draws_a_multi_cell_anchor_that_is_off_viewport() {
424        use retroglyph_core::color::Style;
425        use retroglyph_core::grid::Grid;
426
427        // The scenario retroglyph#614 could not express: a two-cell-wide sprite whose anchor
428        // sits one world column left of the visible range, so only its right half is on screen.
429        let mut grid = Grid::new(20, 20);
430        let mut root = Surface::new(&mut grid, Rect::new(0, 0, 20, 20), 0);
431
432        let mut c = cam(); // viewport (0, 0, 10, 10), world 100x100.
433        c.center_on(Pos::new(50, 50)); // shows world [45, 55).
434
435        let mut view = c.surface(&mut root);
436        // The anchor: one column left of the visible world range. `world_to_screen` would cull
437        // this entirely, so a caller stuck with it could not draw the sprite's visible half
438        // either. Drawn in world coordinates through `Camera::surface`, it is just off-grid and
439        // silently dropped, like any other out-of-bounds `put`.
440        view.put(Pos::new(44, 50), '[', Style::default());
441        // The sprite's other half: the viewport's own leftmost visible column.
442        view.put(Pos::new(45, 50), ']', Style::default());
443
444        assert_eq!(grid[Pos::new(0, 5)].glyph(), ']');
445    }
446
447    #[test]
448    fn surface_print_and_print_line_wrap_at_the_viewport_not_one_step_after_the_origin() {
449        use alloc::vec;
450        use retroglyph_core::color::Style;
451        use retroglyph_core::grid::Grid;
452        use retroglyph_core::text::{Line, Span};
453
454        // retroglyph#991: `print`/`print_line`'s wrap threshold used to compare a
455        // translated-space column against an area-local one, so it fired one grapheme after
456        // `origin_offset.0` on any surface `Camera::surface` produces, instead of at the
457        // viewport's own width.
458        let mut grid = Grid::new(20, 20);
459        let mut c = Camera::new(Rect::new(5, 5, 10, 10), Size::new(100, 100));
460        c.center_on(Pos::new(50, 50)); // origin (45, 45): world (50, 50) is local (5, 5).
461
462        {
463            let mut root = Surface::new(&mut grid, Rect::new(0, 0, 20, 20), 0);
464            c.surface(&mut root)
465                .print(Pos::new(50, 50), "abc", Style::default());
466        }
467        // Written across row 10 starting at column 10, not down column 10 one glyph per row.
468        assert_eq!(grid[Pos::new(10, 10)].glyph(), 'a');
469        assert_eq!(grid[Pos::new(11, 10)].glyph(), 'b');
470        assert_eq!(grid[Pos::new(12, 10)].glyph(), 'c');
471        assert_eq!(grid[Pos::new(10, 11)].glyph(), ' ');
472
473        let line = Line::from(vec![Span::raw("de"), Span::raw("fg")]);
474        {
475            let mut root = Surface::new(&mut grid, Rect::new(0, 0, 20, 20), 0);
476            c.surface(&mut root).print_line(Pos::new(50, 51), &line);
477        }
478        // Both spans still land; the old bug's `break` fired on the very first span.
479        assert_eq!(grid[Pos::new(10, 11)].glyph(), 'd');
480        assert_eq!(grid[Pos::new(11, 11)].glyph(), 'e');
481        assert_eq!(grid[Pos::new(12, 11)].glyph(), 'f');
482        assert_eq!(grid[Pos::new(13, 11)].glyph(), 'g');
483    }
484
485    #[test]
486    fn world_to_screen_saturates_instead_of_overflowing() {
487        let c = Camera::new(Rect::new(65_530, 0, 10, 10), Size::new(100, 100));
488        assert_eq!(
489            c.world_to_screen(Pos::new(9, 0)),
490            Some(Pos::new(u16::MAX, 0))
491        );
492    }
493
494    #[test]
495    fn cells_saturates_instead_of_overflowing() {
496        let c = Camera::new(Rect::new(65_530, 0, 10, 10), Size::new(100, 100));
497        let (_, screen) = c
498            .cells()
499            .find(|(world, _)| *world == Pos::new(9, 0))
500            .expect("world (9, 0) is within the visible bounds");
501        assert_eq!(screen, Pos::new(u16::MAX, 0));
502    }
503
504    #[test]
505    fn surface_clips_to_the_world_not_the_viewport_when_the_world_is_smaller() {
506        use retroglyph_core::color::Style;
507        use retroglyph_core::grid::Grid;
508
509        // A 20x20 viewport over a 5x5 world: `set_viewport` (not `set_viewport_fitted`) pins
510        // the origin at (0, 0) and leaves the dead margin to the right and bottom of the world.
511        let mut grid = Grid::new(20, 20);
512        let mut root = Surface::new(&mut grid, Rect::new(0, 0, 20, 20), 0);
513        let c = Camera::new(Rect::new(0, 0, 20, 20), Size::new(5, 5));
514
515        let mut view = c.surface(&mut root);
516        view.put(Pos::new(0, 0), '@', Style::default());
517        // Inside the viewport but past the (smaller) world's edge: dropped, matching
518        // `world_to_screen`/`visible_bounds`, not reaching the dead margin past the world.
519        view.put(Pos::new(10, 10), 'X', Style::default());
520
521        assert_eq!(grid[Pos::new(0, 0)].glyph(), '@');
522        assert_eq!(grid[Pos::new(10, 10)].glyph(), ' ');
523    }
524}