Skip to main content

retroglyph_window/
geometry.rs

1//! Cell and surface pixel geometry shared by the graphical backends.
2
3use retroglyph_core::grid::Pos;
4
5/// The pixel geometry of a fixed cell grid: a glyph size and an integer scale.
6///
7/// The single code embodiment of [`Presenter::cell_size`](crate::Presenter::cell_size)'s
8/// contract: physical pixels, `glyph x scale`, never DPI-auto-scaled.
9///
10/// Every graphical backend stores one of these and returns [`cell_size`](Self::cell_size) from
11/// `Presenter::cell_size`, rather than re-deriving `glyph_w * scale` (and `cols * cell_w` for the
12/// surface) per backend in its own integer types, which lets the shared rule drift.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct CellGeometry {
15    /// Glyph width in unscaled font pixels.
16    pub glyph_w: u8,
17    /// Glyph height in unscaled font pixels.
18    pub glyph_h: u8,
19    /// Integer pixel scale: each glyph pixel becomes a `scale x scale` block of physical pixels.
20    pub scale: u16,
21}
22
23impl CellGeometry {
24    /// A geometry for `glyph_w x glyph_h` glyphs drawn at integer `scale`.
25    #[must_use]
26    pub const fn new(glyph_w: u8, glyph_h: u8, scale: u16) -> Self {
27        Self {
28            glyph_w,
29            glyph_h,
30            scale,
31        }
32    }
33
34    /// Cell size in physical pixels: `(glyph_w * scale, glyph_h * scale)`.
35    ///
36    /// The single embodiment of `Presenter::cell_size`'s "physical pixels, glyph x scale" contract.
37    #[must_use]
38    pub const fn cell_size(&self) -> (u32, u32) {
39        // `as` (not `u32::from`) because this is a `const fn` and `From` isn't const-callable; both
40        // casts are lossless widenings (u8/u16 -> u32).
41        (
42            self.glyph_w as u32 * self.scale as u32,
43            self.glyph_h as u32 * self.scale as u32,
44        )
45    }
46
47    /// Surface size in physical pixels for a `cols x rows` grid: `(cols * cell_w, rows * cell_h)`.
48    #[must_use]
49    pub const fn surface_size(&self, cols: u16, rows: u16) -> (u32, u32) {
50        let (cell_w, cell_h) = self.cell_size();
51        (cols as u32 * cell_w, rows as u32 * cell_h)
52    }
53
54    /// Converts physical pixel coordinates to a grid cell [`Pos`], using this geometry's
55    /// [`cell_size`](Self::cell_size).
56    ///
57    /// Clamps to `u16::MAX` so out-of-bounds cursor positions (negative or extremely large)
58    /// don't panic: the caller is responsible for bounds-checking against the terminal size.
59    #[must_use]
60    pub fn pixel_to_cell(&self, x: f64, y: f64) -> Pos {
61        let (cell_w, cell_h) = self.cell_size();
62        Pos {
63            x: pixel_to_cell_axis(x, cell_w),
64            y: pixel_to_cell_axis(y, cell_h),
65        }
66    }
67}
68
69/// Divides one clamped, non-negative pixel axis by a cell dimension, saturating to `u16::MAX`.
70///
71/// Shared by [`CellGeometry::pixel_to_cell`] and
72/// [`translate_pixel_to_cell`](crate::winit::translate::translate_pixel_to_cell) so the
73/// clamp/divide/saturate rule lives in exactly one place.
74#[must_use]
75#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
76pub(crate) fn pixel_to_cell_axis(px: f64, cell: u32) -> u16 {
77    // .max(0.0) guards against negatives before the f64→u32 cast.
78    // .min(u16::MAX as u32) guarantees the u32→u16 cast never truncates.
79    let index =
80        u32::checked_div(px.max(0.0) as u32, cell).map_or(0, |v| v.min(u32::from(u16::MAX)));
81    u16::try_from(index).unwrap_or(u16::MAX)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::CellGeometry;
87    use retroglyph_core::grid::Pos;
88
89    #[test]
90    fn cell_size_is_glyph_times_scale() {
91        assert_eq!(CellGeometry::new(8, 16, 1).cell_size(), (8, 16));
92        assert_eq!(CellGeometry::new(8, 16, 2).cell_size(), (16, 32));
93        assert_eq!(CellGeometry::new(6, 12, 3).cell_size(), (18, 36));
94    }
95
96    #[test]
97    fn surface_size_is_grid_times_cell() {
98        // 80x25 grid of 8x16 cells at scale 1, then scale 2.
99        assert_eq!(CellGeometry::new(8, 16, 1).surface_size(80, 25), (640, 400));
100        assert_eq!(
101            CellGeometry::new(8, 16, 2).surface_size(80, 25),
102            (1280, 800)
103        );
104    }
105
106    #[test]
107    fn zero_grid_is_zero_surface() {
108        assert_eq!(CellGeometry::new(8, 16, 2).surface_size(0, 0), (0, 0));
109    }
110
111    // ── pixel_to_cell ─────────────────────────────────────────────────────────
112
113    #[test]
114    fn pixel_to_cell_basic() {
115        // 8×16 cells: pixel (20, 48) → col 2, row 3
116        let geometry = CellGeometry::new(8, 16, 1);
117        assert_eq!(geometry.pixel_to_cell(20.0, 48.0), Pos { x: 2, y: 3 });
118    }
119
120    #[test]
121    fn pixel_to_cell_origin() {
122        let geometry = CellGeometry::new(8, 16, 1);
123        assert_eq!(geometry.pixel_to_cell(0.0, 0.0), Pos { x: 0, y: 0 });
124    }
125
126    #[test]
127    fn pixel_to_cell_negative_coords_clamp_to_zero() {
128        // Cursor briefly outside the window can produce negative physical coords.
129        let geometry = CellGeometry::new(8, 16, 1);
130        assert_eq!(geometry.pixel_to_cell(-5.0, -10.0), Pos { x: 0, y: 0 });
131    }
132
133    #[test]
134    fn pixel_to_cell_zero_cell_size_returns_origin() {
135        // Degenerate case: glyph size 0 (backend not yet initialised with a valid font).
136        let geometry = CellGeometry::new(0, 0, 1);
137        assert_eq!(geometry.pixel_to_cell(100.0, 200.0), Pos { x: 0, y: 0 });
138    }
139
140    #[test]
141    fn pixel_to_cell_accounts_for_scale() {
142        // 8×16 glyphs at scale 2 → 16×32 cells: pixel (20, 48) → col 1, row 1.
143        let geometry = CellGeometry::new(8, 16, 2);
144        assert_eq!(geometry.pixel_to_cell(20.0, 48.0), Pos { x: 1, y: 1 });
145    }
146
147    #[test]
148    fn pixel_to_cell_clamps_to_u16_max() {
149        // A huge pixel coordinate must not overflow u16.
150        let geometry = CellGeometry::new(1, 1, 1);
151        assert_eq!(
152            geometry.pixel_to_cell(f64::from(u32::MAX), f64::from(u32::MAX)),
153            Pos {
154                x: u16::MAX,
155                y: u16::MAX
156            }
157        );
158    }
159}