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}