Skip to main content

ferroui_base/
pixel_size.rs

1//! Represents a size in device pixels.
2
3use std::fmt;
4use std::str::FromStr;
5
6use crate::utilities::{FormatError, SpanStringTokenizer};
7use crate::{Size, Vector};
8
9/// Represents a size in device pixels.
10///
11/// Conversions from floating point saturate at the `i32` range (NaN becomes 0).
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
13pub struct PixelSize {
14    /// The width.
15    pub width: i32,
16    /// The height.
17    pub height: i32,
18}
19
20impl PixelSize {
21    /// A size representing zero.
22    pub const EMPTY: PixelSize = PixelSize::new(0, 0);
23
24    const FROM_SIZE_CEILING_EPSILON: f64 = 1e-6;
25
26    /// Initializes a new instance of the [`PixelSize`] structure.
27    #[inline]
28    pub const fn new(width: i32, height: i32) -> Self {
29        Self { width, height }
30    }
31
32    /// Gets the aspect ratio of the size.
33    #[inline]
34    pub fn aspect_ratio(&self) -> f64 {
35        self.width as f64 / self.height as f64
36    }
37
38    /// Parses a [`PixelSize`] string (`"width, height"`).
39    pub fn parse(s: &str) -> Result<Self, FormatError> {
40        match Self::try_parse(s) {
41            Some(result) => Ok(result),
42            None => Err(FormatError::new("Invalid PixelSize.")),
43        }
44    }
45
46    /// Tries to parse a [`PixelSize`] string. Every kind of malformed input yields
47    /// `None` (the reference implementation raises a format error for some of them,
48    /// e.g. trailing tokens; there is no such distinction here).
49    pub fn try_parse(source: &str) -> Option<Self> {
50        if source.is_empty() {
51            return None;
52        }
53
54        SpanStringTokenizer::with_separator(source, ',', Some("Invalid PixelSize."))
55            .scope(|t| {
56                if let Some(w) = t.try_read_int32()? {
57                    if let Some(h) = t.try_read_int32()? {
58                        return Ok(Some(PixelSize::new(w, h)));
59                    }
60                }
61
62                Ok(None)
63            })
64            .ok()
65            .flatten()
66    }
67
68    /// Returns a boolean indicating whether the size is equal to the other given size.
69    #[inline]
70    pub const fn equals(&self, other: PixelSize) -> bool {
71        self.width == other.width && self.height == other.height
72    }
73
74    /// Returns a new [`PixelSize`] with the same height and the specified width.
75    #[inline]
76    pub const fn with_width(&self, width: i32) -> PixelSize {
77        PixelSize::new(width, self.height)
78    }
79
80    /// Returns a new [`PixelSize`] with the same width and the specified height.
81    #[inline]
82    pub const fn with_height(&self, height: i32) -> PixelSize {
83        PixelSize::new(self.width, height)
84    }
85
86    /// Converts the [`PixelSize`] to a device-independent [`Size`] using the
87    /// specified scaling factor.
88    #[inline]
89    pub fn to_size(&self, scale: f64) -> Size {
90        Size::new(self.width as f64 / scale, self.height as f64 / scale)
91    }
92
93    /// Converts the [`PixelSize`] to a device-independent [`Size`] using the
94    /// specified per-axis scaling factor.
95    #[inline]
96    pub fn to_size_vector(&self, scale: Vector) -> Size {
97        Size::new(self.width as f64 / scale.x, self.height as f64 / scale.y)
98    }
99
100    /// Converts the [`PixelSize`] to a device-independent [`Size`] using the
101    /// specified dots per inch (DPI).
102    #[inline]
103    pub fn to_size_with_dpi(&self, dpi: f64) -> Size {
104        self.to_size(dpi / 96.0)
105    }
106
107    /// Converts the [`PixelSize`] to a device-independent [`Size`] using the
108    /// specified per-axis dots per inch (DPI).
109    #[inline]
110    pub fn to_size_with_dpi_vector(&self, dpi: Vector) -> Size {
111        self.to_size_vector(Vector::new(dpi.x / 96.0, dpi.y / 96.0))
112    }
113
114    /// Converts a [`Size`] to device pixels using the specified scaling factor,
115    /// rounding up.
116    #[inline]
117    pub fn from_size(size: Size, scale: f64) -> PixelSize {
118        PixelSize::new(
119            (size.width * scale).ceil() as i32,
120            (size.height * scale).ceil() as i32,
121        )
122    }
123
124    /// A tolerant ceiling variant of [`from_size`](Self::from_size): a value within
125    /// `1e-6` of an integer snaps to that integer, otherwise it is rounded up.
126    /// This prevents floating-point artifacts (e.g. `24.0000001`) from producing an
127    /// extra pixel while still guaranteeing the pixel size covers the logical size.
128    #[inline]
129    pub fn from_size_ceiling(size: Size, scale: f64) -> PixelSize {
130        PixelSize::new(
131            Self::ceil_with_epsilon(size.width * scale),
132            Self::ceil_with_epsilon(size.height * scale),
133        )
134    }
135
136    #[inline]
137    fn ceil_with_epsilon(value: f64) -> i32 {
138        let rounded = value.round_ties_even();
139        if (value - rounded).abs() < Self::FROM_SIZE_CEILING_EPSILON {
140            return rounded as i32;
141        }
142        value.ceil() as i32
143    }
144
145    /// Converts a [`Size`] to device pixels using the specified per-axis scaling
146    /// factor, rounding up.
147    #[inline]
148    pub fn from_size_vector(size: Size, scale: Vector) -> PixelSize {
149        PixelSize::new(
150            (size.width * scale.x).ceil() as i32,
151            (size.height * scale.y).ceil() as i32,
152        )
153    }
154
155    /// Converts a [`Size`] to device pixels using the specified dots per inch (DPI).
156    #[inline]
157    pub fn from_size_with_dpi(size: Size, dpi: f64) -> PixelSize {
158        PixelSize::from_size(size, dpi / 96.0)
159    }
160
161    /// Converts a [`Size`] to device pixels using the specified per-axis dots per inch (DPI).
162    #[inline]
163    pub fn from_size_with_dpi_vector(size: Size, dpi: Vector) -> PixelSize {
164        PixelSize::from_size_vector(size, Vector::new(dpi.x / 96.0, dpi.y / 96.0))
165    }
166}
167
168impl FromStr for PixelSize {
169    type Err = FormatError;
170    #[inline]
171    fn from_str(s: &str) -> Result<Self, FormatError> {
172        PixelSize::parse(s)
173    }
174}
175
176impl fmt::Display for PixelSize {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        write!(f, "{}, {}", self.width, self.height)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn parse() {
188        // (source, expected, expected error message)
189        let cases: [(&str, PixelSize, Option<&str>); 2] = [
190            ("1024,768", PixelSize::new(1024, 768), None),
191            ("1024x768", PixelSize::default(), Some("Invalid PixelSize.")),
192        ];
193
194        for (source, expected, exception) in cases {
195            let mut error = None;
196            let mut result = PixelSize::default();
197            match PixelSize::parse(source) {
198                Ok(value) => result = value,
199                Err(e) => error = Some(e),
200            }
201
202            assert_eq!(exception, error.as_ref().map(|e| e.message()));
203            assert_eq!(expected, result);
204        }
205    }
206
207    #[test]
208    fn try_parse() {
209        let cases: [(&str, PixelSize); 2] = [
210            ("1024,768", PixelSize::new(1024, 768)),
211            ("1024x768", PixelSize::EMPTY),
212        ];
213
214        for (source, expected) in cases {
215            let result = PixelSize::try_parse(source).unwrap_or(PixelSize::EMPTY);
216
217            assert_eq!(expected, result);
218        }
219    }
220
221    #[test]
222    fn from_size_ceiling_computes_expected_pixels() {
223        let cases: [(i32, f64, i32); 9] = [
224            (10, 1.0, 10),
225            (10, 1.25, 13),
226            (10, 1.5, 15),
227            (10, 1.75, 18),
228            (10, 2.0, 20),
229            (10, 1.125, 12),
230            (8, 1.5, 12),
231            (0, 1.5, 0),
232            (1, 2.5, 3),
233        ];
234
235        for (logical, scale, expected) in cases {
236            let pixel =
237                PixelSize::from_size_ceiling(Size::new(logical as f64, logical as f64), scale);
238
239            assert_eq!(expected, pixel.width);
240            assert_eq!(expected, pixel.height);
241        }
242    }
243
244    #[test]
245    fn from_size_ceiling_snaps_when_within_epsilon() {
246        for scale in [1.5, 2.0, 3.0] {
247            // Pick a logical size where logical * scale is an exact integer; perturbing it by a tiny
248            // amount in either direction must still produce that integer (no spurious +1 from ceiling).
249            const LOGICAL: i32 = 10;
250            let exact = LOGICAL as f64 * scale;
251            let below = exact - 1e-9;
252            let above = exact + 1e-9;
253            let rounded_below = below / scale;
254            let rounded_above = above / scale;
255
256            let p1 = PixelSize::from_size_ceiling(Size::new(rounded_below, rounded_below), scale);
257            let p2 = PixelSize::from_size_ceiling(Size::new(rounded_above, rounded_above), scale);
258
259            assert_eq!(exact as i32, p1.width);
260            assert_eq!(exact as i32, p2.width);
261        }
262    }
263
264    // Additional coverage (not part of the reference suite).
265
266    #[test]
267    fn conversions_and_display() {
268        assert_eq!(PixelSize::new(4, 2).aspect_ratio(), 2.0);
269        assert_eq!(PixelSize::new(4, 2).to_string(), "4, 2");
270        assert_eq!(PixelSize::try_parse(""), None);
271        assert_eq!(PixelSize::try_parse("1,2,3"), None);
272        assert_eq!(PixelSize::try_parse("1"), None);
273        assert_eq!(
274            PixelSize::from_size(Size::new(10.1, 10.0), 1.0),
275            PixelSize::new(11, 10)
276        );
277        assert_eq!(
278            PixelSize::from_size_with_dpi(Size::new(10.0, 10.0), 144.0),
279            PixelSize::new(15, 15)
280        );
281        assert_eq!(PixelSize::new(15, 30).to_size(1.5), Size::new(10.0, 20.0));
282        assert_eq!(
283            PixelSize::new(15, 30).to_size_with_dpi(144.0),
284            Size::new(10.0, 20.0)
285        );
286    }
287}