Skip to main content

crcbl_shell/
geom.rs

1//! Sizes, points and rectangles, in the two coordinate spaces a window has.
2//!
3//! # Physical and logical are different types on purpose
4//!
5//! Every windowing bug that outlives a release is a scale-factor bug, and every
6//! scale-factor bug is a physical value used where a logical one was meant (or
7//! the reverse). So the two are separate types and the only way between them is
8//! an explicit conversion that takes the scale factor:
9//!
10//! * **Physical** ([`PhysicalSize`], [`PhysicalPoint`], [`PhysicalRect`]) —
11//!   device pixels. This is what a swapchain is sized in, what a viewport is
12//!   sized in, and what a pointer position is reported in. Never guess it.
13//! * **Logical** ([`LogicalSize`], [`LogicalPoint`]) — scale-independent units.
14//!   This is what a *request* is expressed in ("open a 1280×720 window"), what
15//!   min/max constraints are expressed in, and what the UI lays out in. It is
16//!   `f64` rather than integer because fractional scaling (Wayland's
17//!   `fractional-scale-v1` reports scale in 1/120ths) makes
18//!   `physical / scale` non-integral in general.
19//!
20//! The engine's rule: **events report physical, requests carry logical.** A
21//! window asks for a size in a unit the user recognizes; the renderer allocates
22//! in the unit the GPU recognizes; the shell is the only thing that converts.
23//!
24//! # Rounding
25//!
26//! [`LogicalSize::to_physical`] rounds to nearest and clamps to at least 1×1.
27//! Zero-sized swapchains are invalid in Vulkan, and a minimized window on Win32
28//! genuinely reports 0×0 — so the clamp is not paranoia, it is the difference
29//! between "the window is minimized" and a validation error.
30
31use core::fmt;
32
33/// A size in device pixels.
34///
35/// What the swapchain is created at. See the [module docs](self).
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
37pub struct PhysicalSize {
38    /// Width in device pixels.
39    pub width: u32,
40    /// Height in device pixels.
41    pub height: u32,
42}
43
44impl PhysicalSize {
45    /// A size of `width` × `height` device pixels.
46    #[inline]
47    #[must_use]
48    pub const fn new(width: u32, height: u32) -> Self {
49        Self { width, height }
50    }
51
52    /// Whether either dimension is zero — a minimized or not-yet-configured
53    /// window.
54    ///
55    /// The frame loop uses this to skip rendering rather than to create an
56    /// invalid swapchain.
57    #[inline]
58    #[must_use]
59    pub const fn is_empty(self) -> bool {
60        self.width == 0 || self.height == 0
61    }
62
63    /// Pixel count, as a `u64` so a 16K×16K target does not overflow.
64    #[inline]
65    #[must_use]
66    pub const fn area(self) -> u64 {
67        self.width as u64 * self.height as u64
68    }
69
70    /// This size in logical units at `scale_factor`.
71    #[inline]
72    #[must_use]
73    pub fn to_logical(self, scale_factor: f64) -> LogicalSize {
74        debug_assert!(scale_factor > 0.0, "scale factor must be positive");
75        LogicalSize {
76            width: f64::from(self.width) / scale_factor,
77            height: f64::from(self.height) / scale_factor,
78        }
79    }
80
81    /// The aspect ratio as a float, or `None` for an empty size.
82    #[must_use]
83    pub fn aspect(self) -> Option<f64> {
84        if self.is_empty() {
85            None
86        } else {
87            Some(f64::from(self.width) / f64::from(self.height))
88        }
89    }
90}
91
92impl fmt::Display for PhysicalSize {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        write!(f, "{}x{}px", self.width, self.height)
95    }
96}
97
98/// A size in scale-independent units.
99///
100/// What a window *request* and a size constraint are expressed in. See the
101/// [module docs](self).
102#[derive(Clone, Copy, Debug, Default, PartialEq)]
103pub struct LogicalSize {
104    /// Width in logical units.
105    pub width: f64,
106    /// Height in logical units.
107    pub height: f64,
108}
109
110impl LogicalSize {
111    /// A size of `width` × `height` logical units.
112    #[inline]
113    #[must_use]
114    pub const fn new(width: f64, height: f64) -> Self {
115        Self { width, height }
116    }
117
118    /// This size in device pixels at `scale_factor`.
119    ///
120    /// Rounds to nearest and clamps to at least 1×1; see the [module
121    /// docs](self) for why the clamp exists.
122    #[must_use]
123    pub fn to_physical(self, scale_factor: f64) -> PhysicalSize {
124        debug_assert!(scale_factor > 0.0, "scale factor must be positive");
125        let scale = |value: f64| {
126            let scaled = (value * scale_factor).round();
127            // `clamp` handles infinities correctly (∞ saturates to `u32::MAX`)
128            // but propagates NaN, which has no sensible size — so NaN becomes
129            // the same 1 px a zero would.
130            if scaled.is_nan() {
131                1
132            } else {
133                scaled.clamp(1.0, f64::from(u32::MAX)) as u32
134            }
135        };
136        PhysicalSize {
137            width: scale(self.width),
138            height: scale(self.height),
139        }
140    }
141}
142
143impl fmt::Display for LogicalSize {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        write!(f, "{}x{}dp", self.width, self.height)
146    }
147}
148
149/// A point in device pixels, relative to a window's top-left corner.
150///
151/// `f64` because pointer positions are sub-pixel: Wayland delivers them as
152/// 24.8 fixed point and a 240 Hz mouse on a scaled output genuinely moves less
153/// than a pixel per event. Truncating here would quantize aim.
154#[derive(Clone, Copy, Debug, Default, PartialEq)]
155pub struct PhysicalPoint {
156    /// Distance from the left edge, in device pixels.
157    pub x: f64,
158    /// Distance from the top edge, in device pixels.
159    pub y: f64,
160}
161
162impl PhysicalPoint {
163    /// The window's top-left corner.
164    pub const ORIGIN: Self = Self { x: 0.0, y: 0.0 };
165
166    /// A point at `(x, y)` device pixels.
167    #[inline]
168    #[must_use]
169    pub const fn new(x: f64, y: f64) -> Self {
170        Self { x, y }
171    }
172
173    /// This point in logical units at `scale_factor`.
174    #[inline]
175    #[must_use]
176    pub fn to_logical(self, scale_factor: f64) -> LogicalPoint {
177        debug_assert!(scale_factor > 0.0, "scale factor must be positive");
178        LogicalPoint {
179            x: self.x / scale_factor,
180            y: self.y / scale_factor,
181        }
182    }
183
184    /// Whether the point lies inside a window of `size`.
185    #[must_use]
186    pub fn is_inside(self, size: PhysicalSize) -> bool {
187        self.x >= 0.0
188            && self.y >= 0.0
189            && self.x < f64::from(size.width)
190            && self.y < f64::from(size.height)
191    }
192}
193
194/// A point in scale-independent units, relative to a window's top-left corner.
195#[derive(Clone, Copy, Debug, Default, PartialEq)]
196pub struct LogicalPoint {
197    /// Distance from the left edge, in logical units.
198    pub x: f64,
199    /// Distance from the top edge, in logical units.
200    pub y: f64,
201}
202
203impl LogicalPoint {
204    /// A point at `(x, y)` logical units.
205    #[inline]
206    #[must_use]
207    pub const fn new(x: f64, y: f64) -> Self {
208        Self { x, y }
209    }
210}
211
212/// An axis-aligned rectangle in scale-independent units, relative to a
213/// window's top-left corner.
214#[derive(Clone, Copy, Debug, Default, PartialEq)]
215pub struct LogicalRect {
216    /// The top-left corner.
217    pub origin: LogicalPoint,
218    /// Width and height.
219    pub size: LogicalSize,
220}
221
222impl LogicalRect {
223    /// A rectangle at `origin` of `size`.
224    #[inline]
225    #[must_use]
226    pub const fn new(origin: LogicalPoint, size: LogicalSize) -> Self {
227        Self { origin, size }
228    }
229}
230
231/// An axis-aligned rectangle in device pixels.
232///
233/// Each use names its space. Monitor geometry is in the desktop's, where the
234/// origin is the desktop origin rather than a window corner and coordinates can
235/// be negative (a monitor placed left of the primary one);
236/// [`Shell::set_text_input_area`](crate::Shell::set_text_input_area) is in a
237/// window's client area.
238#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
239pub struct PhysicalRect {
240    /// Left edge, in device pixels of the space the use names.
241    pub x: i32,
242    /// Top edge, in device pixels of the space the use names.
243    pub y: i32,
244    /// Width in device pixels.
245    pub width: u32,
246    /// Height in device pixels.
247    pub height: u32,
248}
249
250impl PhysicalRect {
251    /// A rectangle at `(x, y)` of `width` × `height` device pixels.
252    #[inline]
253    #[must_use]
254    pub const fn new(x: i32, y: i32, width: u32, height: u32) -> Self {
255        Self {
256            x,
257            y,
258            width,
259            height,
260        }
261    }
262
263    /// The rectangle's size.
264    #[inline]
265    #[must_use]
266    pub const fn size(self) -> PhysicalSize {
267        PhysicalSize::new(self.width, self.height)
268    }
269
270    /// Whether a desktop-space point lies inside.
271    ///
272    /// **Half-open**: the left and top edges belong to this rectangle, the
273    /// right and bottom ones do not. Monitors tile edge to edge — the standard
274    /// side-by-side layout is `(0,0,1920,1080)` next to `(1920,0,1920,1080)` —
275    /// so an inclusive far edge would put the first column of the second
276    /// monitor inside *both*, and the monitor lookup in the X11 backend returns
277    /// the first match. Half-open makes the tiling a partition.
278    ///
279    /// The subtraction is widened to `i64` *before* it happens, not after: a
280    /// rectangle at a negative origin and a point near [`i32::MAX`] overflow an
281    /// `i32` difference, which panics in debug and wraps to "inside" in
282    /// release.
283    #[must_use]
284    pub const fn contains(self, x: i32, y: i32) -> bool {
285        x >= self.x
286            && y >= self.y
287            && (x as i64 - self.x as i64) < self.width as i64
288            && (y as i64 - self.y as i64) < self.height as i64
289    }
290}
291
292impl fmt::Display for PhysicalRect {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        write!(f, "{}x{}+{}+{}", self.width, self.height, self.x, self.y)
295    }
296}
297
298/// A width-to-height ratio, exact.
299///
300/// Stored as an integer fraction rather than an `f64` because 16:9 is not
301/// representable in binary floating point, and a window that must stay
302/// aspect-locked across thousands of resize events accumulates that error into
303/// a visible drift. Comparisons and the fit math below stay in integers where
304/// they can.
305#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
306pub struct AspectRatio {
307    numerator: u32,
308    denominator: u32,
309}
310
311impl AspectRatio {
312    /// 16:9.
313    pub const WIDESCREEN: Self = Self {
314        numerator: 16,
315        denominator: 9,
316    };
317    /// 4:3.
318    pub const CLASSIC: Self = Self {
319        numerator: 4,
320        denominator: 3,
321    };
322    /// 1:1.
323    pub const SQUARE: Self = Self {
324        numerator: 1,
325        denominator: 1,
326    };
327
328    /// A ratio of `width : height`.
329    ///
330    /// # Panics
331    ///
332    /// If either term is zero. A degenerate aspect ratio has no correct
333    /// behaviour and silently substituting one hides the caller's bug.
334    #[must_use]
335    pub const fn new(width: u32, height: u32) -> Self {
336        assert!(
337            width > 0 && height > 0,
338            "aspect ratio terms must be positive"
339        );
340        Self {
341            numerator: width,
342            denominator: height,
343        }
344    }
345
346    /// The width term.
347    #[inline]
348    #[must_use]
349    pub const fn numerator(self) -> u32 {
350        self.numerator
351    }
352
353    /// The height term.
354    #[inline]
355    #[must_use]
356    pub const fn denominator(self) -> u32 {
357        self.denominator
358    }
359
360    /// The ratio as a float, for shader constants and letterbox math.
361    #[inline]
362    #[must_use]
363    pub fn as_f64(self) -> f64 {
364        f64::from(self.numerator) / f64::from(self.denominator)
365    }
366
367    /// The largest size with this aspect ratio that fits inside `bounds`.
368    ///
369    /// This is the letterbox computation, and it lives here because
370    /// the windowing rules in `docs/notes/backends.md` make in-renderer letterboxing the
371    /// **universal fallback**: Wayland has no aspect hint at all, and a tiling
372    /// window manager can force any size on any backend, so every consumer that
373    /// aspect-locks needs this math and none of them should write it twice.
374    ///
375    /// Returns a size of at most `bounds` in both axes, and at least 1×1.
376    #[must_use]
377    pub fn fit(self, bounds: PhysicalSize) -> PhysicalSize {
378        if bounds.is_empty() {
379            return PhysicalSize::new(1, 1);
380        }
381        // Compare `bounds.width / bounds.height` against `n / d` without
382        // dividing: cross-multiply in u64, which cannot overflow for u32 terms.
383        let bounds_ratio = u64::from(bounds.width) * u64::from(self.denominator);
384        let target_ratio = u64::from(bounds.height) * u64::from(self.numerator);
385        if bounds_ratio > target_ratio {
386            // Too wide: height is the limit, pillarbox.
387            let width = target_ratio / u64::from(self.denominator);
388            PhysicalSize::new(width.max(1) as u32, bounds.height)
389        } else {
390            // Too tall (or exact): width is the limit, letterbox.
391            let height = bounds_ratio / u64::from(self.numerator);
392            PhysicalSize::new(bounds.width, height.max(1) as u32)
393        }
394    }
395}
396
397impl fmt::Display for AspectRatio {
398    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
399        write!(f, "{}:{}", self.numerator, self.denominator)
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn scale_conversions_round_trip_at_integer_scales() {
409        let logical = LogicalSize::new(1280.0, 720.0);
410        assert_eq!(logical.to_physical(1.0), PhysicalSize::new(1280, 720));
411        assert_eq!(logical.to_physical(2.0), PhysicalSize::new(2560, 1440));
412        assert_eq!(
413            PhysicalSize::new(2560, 1440).to_logical(2.0),
414            LogicalSize::new(1280.0, 720.0)
415        );
416    }
417
418    #[test]
419    fn fractional_scale_rounds_and_never_produces_zero() {
420        // 125% is what `fractional-scale-v1` reports as 150/120.
421        assert_eq!(
422            LogicalSize::new(1280.0, 720.0).to_physical(1.25),
423            PhysicalSize::new(1600, 900)
424        );
425        // A window dragged to nothing still yields a legal swapchain size.
426        assert_eq!(
427            LogicalSize::new(0.0, 0.0).to_physical(1.0),
428            PhysicalSize::new(1, 1)
429        );
430        assert_eq!(
431            LogicalSize::new(f64::NAN, 4.0).to_physical(1.0),
432            PhysicalSize::new(1, 4)
433        );
434        // Overflow saturates rather than wrapping: `1e300 * 1e300` is infinite,
435        // and a wrapped `as u32` would have produced a plausible-looking small
436        // number.
437        assert_eq!(
438            LogicalSize::new(1e300, 4.0).to_physical(1e300),
439            PhysicalSize::new(u32::MAX, u32::MAX)
440        );
441    }
442
443    #[test]
444    fn empty_sizes_are_detectable_rather_than_clamped_away() {
445        // A *reported* size of 0 is real information (minimized), so
446        // `PhysicalSize` never clamps — only the logical→physical conversion
447        // does, because that one feeds a swapchain.
448        let minimized = PhysicalSize::new(0, 0);
449        assert!(minimized.is_empty());
450        assert_eq!(minimized.aspect(), None);
451        assert_eq!(minimized.area(), 0);
452        assert!(!PhysicalSize::new(1, 1).is_empty());
453        assert_eq!(PhysicalSize::new(3840, 2160).area(), 8_294_400);
454        assert_eq!(PhysicalSize::new(1920, 1080).to_string(), "1920x1080px");
455        assert_eq!(LogicalSize::new(16.0, 9.0).to_string(), "16x9dp");
456    }
457
458    #[test]
459    fn letterbox_fit_is_exact_for_the_common_ratios() {
460        // 16:9 content in a 16:10 window letterboxes vertically.
461        let fitted = AspectRatio::WIDESCREEN.fit(PhysicalSize::new(1920, 1200));
462        assert_eq!(fitted, PhysicalSize::new(1920, 1080));
463
464        // 16:9 content in a 21:9 window pillarboxes horizontally.
465        let fitted = AspectRatio::WIDESCREEN.fit(PhysicalSize::new(2560, 1080));
466        assert_eq!(fitted, PhysicalSize::new(1920, 1080));
467
468        // An exact match is unchanged — no drift, which is the reason the ratio
469        // is an integer fraction.
470        let exact = PhysicalSize::new(1280, 720);
471        let mut size = exact;
472        for _ in 0..1_000 {
473            size = AspectRatio::WIDESCREEN.fit(size);
474        }
475        assert_eq!(size, exact);
476
477        assert_eq!(
478            AspectRatio::CLASSIC.fit(PhysicalSize::new(1000, 1000)),
479            PhysicalSize::new(1000, 750)
480        );
481        assert_eq!(
482            AspectRatio::SQUARE.fit(PhysicalSize::new(1000, 400)),
483            PhysicalSize::new(400, 400)
484        );
485        assert_eq!(
486            AspectRatio::WIDESCREEN.fit(PhysicalSize::new(0, 0)),
487            PhysicalSize::new(1, 1)
488        );
489        // Degenerate but legal: never returns a zero dimension.
490        assert_eq!(
491            AspectRatio::new(1000, 1).fit(PhysicalSize::new(10, 10)),
492            PhysicalSize::new(10, 1)
493        );
494    }
495
496    #[test]
497    fn aspect_ratio_accessors_and_display() {
498        let ratio = AspectRatio::new(21, 9);
499        assert_eq!(ratio.numerator(), 21);
500        assert_eq!(ratio.denominator(), 9);
501        assert!((ratio.as_f64() - 21.0 / 9.0).abs() < 1e-12);
502        assert_eq!(ratio.to_string(), "21:9");
503        assert_eq!(AspectRatio::WIDESCREEN, AspectRatio::new(16, 9));
504    }
505
506    #[test]
507    #[should_panic(expected = "aspect ratio terms must be positive")]
508    fn a_degenerate_aspect_ratio_is_rejected() {
509        let _ = AspectRatio::new(16, 0);
510    }
511
512    #[test]
513    fn points_convert_and_hit_test() {
514        let point = PhysicalPoint::new(200.0, 100.0);
515        assert_eq!(point.to_logical(2.0), LogicalPoint::new(100.0, 50.0));
516        assert!(point.is_inside(PhysicalSize::new(640, 360)));
517        assert!(!point.is_inside(PhysicalSize::new(100, 100)));
518        assert!(!PhysicalPoint::new(-1.0, 0.0).is_inside(PhysicalSize::new(640, 360)));
519        assert_eq!(PhysicalPoint::ORIGIN, PhysicalPoint::default());
520        assert_eq!(LogicalPoint::new(0.0, 0.0), LogicalPoint::default());
521    }
522
523    #[test]
524    fn monitor_rectangles_handle_negative_origins() {
525        // A second monitor placed to the left of the primary one.
526        let left = PhysicalRect::new(-1920, 0, 1920, 1080);
527        assert!(left.contains(-1000, 500));
528        assert!(!left.contains(10, 500));
529        assert_eq!(left.size(), PhysicalSize::new(1920, 1080));
530        assert_eq!(left.to_string(), "1920x1080+-1920+0");
531    }
532
533    #[test]
534    fn tiled_monitors_partition_the_desktop() {
535        // The standard side-by-side layout. Every point belongs to exactly one
536        // of the two, which is what a monitor lookup that returns the first
537        // match needs to be correct.
538        let first = PhysicalRect::new(0, 0, 1920, 1080);
539        let second = PhysicalRect::new(1920, 0, 1920, 1080);
540        for (x, y) in [(0, 0), (1919, 0), (0, 1079), (1919, 1079)] {
541            assert!(first.contains(x, y), "({x},{y}) is on the first monitor");
542            assert!(!second.contains(x, y));
543        }
544        for (x, y) in [(1920, 0), (3839, 1079)] {
545            assert!(second.contains(x, y), "({x},{y}) is on the second monitor");
546            assert!(!first.contains(x, y), "the shared edge belongs to one");
547        }
548        // Past the far edge in either axis is outside.
549        assert!(!first.contains(0, 1080));
550        assert!(!second.contains(3840, 0));
551    }
552
553    #[test]
554    fn hit_testing_a_far_away_point_does_not_overflow() {
555        // `x - self.x` overflows an i32 here; the widening must happen first.
556        let negative_origin = PhysicalRect::new(-1, 0, 1920, 1080);
557        assert!(!negative_origin.contains(i32::MAX, 0));
558        assert!(!PhysicalRect::new(i32::MIN, 0, 1920, 1080).contains(i32::MAX, 0));
559        // The widest legal rectangle really does reach that far — and stops one
560        // pixel short of `i32::MIN + u32::MAX`, because the far edge is open.
561        let widest = PhysicalRect::new(i32::MIN, 0, u32::MAX, 1080);
562        assert!(widest.contains(i32::MAX - 1, 0));
563        assert!(!widest.contains(i32::MAX, 0));
564    }
565}