1use core::fmt;
32
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
37pub struct PhysicalSize {
38 pub width: u32,
40 pub height: u32,
42}
43
44impl PhysicalSize {
45 #[inline]
47 #[must_use]
48 pub const fn new(width: u32, height: u32) -> Self {
49 Self { width, height }
50 }
51
52 #[inline]
58 #[must_use]
59 pub const fn is_empty(self) -> bool {
60 self.width == 0 || self.height == 0
61 }
62
63 #[inline]
65 #[must_use]
66 pub const fn area(self) -> u64 {
67 self.width as u64 * self.height as u64
68 }
69
70 #[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 #[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#[derive(Clone, Copy, Debug, Default, PartialEq)]
103pub struct LogicalSize {
104 pub width: f64,
106 pub height: f64,
108}
109
110impl LogicalSize {
111 #[inline]
113 #[must_use]
114 pub const fn new(width: f64, height: f64) -> Self {
115 Self { width, height }
116 }
117
118 #[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 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#[derive(Clone, Copy, Debug, Default, PartialEq)]
155pub struct PhysicalPoint {
156 pub x: f64,
158 pub y: f64,
160}
161
162impl PhysicalPoint {
163 pub const ORIGIN: Self = Self { x: 0.0, y: 0.0 };
165
166 #[inline]
168 #[must_use]
169 pub const fn new(x: f64, y: f64) -> Self {
170 Self { x, y }
171 }
172
173 #[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 #[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#[derive(Clone, Copy, Debug, Default, PartialEq)]
196pub struct LogicalPoint {
197 pub x: f64,
199 pub y: f64,
201}
202
203impl LogicalPoint {
204 #[inline]
206 #[must_use]
207 pub const fn new(x: f64, y: f64) -> Self {
208 Self { x, y }
209 }
210}
211
212#[derive(Clone, Copy, Debug, Default, PartialEq)]
215pub struct LogicalRect {
216 pub origin: LogicalPoint,
218 pub size: LogicalSize,
220}
221
222impl LogicalRect {
223 #[inline]
225 #[must_use]
226 pub const fn new(origin: LogicalPoint, size: LogicalSize) -> Self {
227 Self { origin, size }
228 }
229}
230
231#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
239pub struct PhysicalRect {
240 pub x: i32,
242 pub y: i32,
244 pub width: u32,
246 pub height: u32,
248}
249
250impl PhysicalRect {
251 #[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 #[inline]
265 #[must_use]
266 pub const fn size(self) -> PhysicalSize {
267 PhysicalSize::new(self.width, self.height)
268 }
269
270 #[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 #[must_use]
303 pub fn rounded(self, radius: u32) -> Vec<Self> {
304 if self.width == 0 || self.height == 0 {
305 return Vec::new();
306 }
307 let radius = radius.min(self.width / 2).min(self.height / 2);
308 let insets: Vec<u32> = (0..radius).map(|row| corner_inset(radius, row)).collect();
309 let mut rects: Vec<Self> = Vec::new();
310 let mut push = |top: u32, height: u32, inset: u32| {
311 if height == 0 {
312 return;
313 }
314 let row = Self::new(
315 self.x.saturating_add_unsigned(inset),
316 self.y.saturating_add_unsigned(top),
317 self.width - 2 * inset,
318 height,
319 );
320 match rects.last_mut() {
321 Some(last)
322 if last.x == row.x
323 && last.width == row.width
324 && i64::from(last.y) + i64::from(last.height) == i64::from(row.y) =>
325 {
326 last.height += height;
327 }
328 _ => rects.push(row),
329 }
330 };
331 for (row, &inset) in (0..radius).zip(&insets) {
332 push(row, 1, inset);
333 }
334 push(radius, self.height - 2 * radius, 0);
335 for (row, &inset) in (0..radius).zip(&insets).rev() {
336 push(self.height - 1 - row, 1, inset);
337 }
338 rects
339 }
340}
341
342fn corner_inset(radius: u32, row: u32) -> u32 {
351 let (radius, d) = (u64::from(radius), u64::from(radius - row - 1));
352 let squared = radius * radius - d * d;
353 let root = squared.isqrt();
354 let ceil = if root * root == squared {
355 root
356 } else {
357 root + 1
358 };
359 u32::try_from(radius - ceil).expect("the inset is at most the radius, which is a u32")
360}
361
362impl fmt::Display for PhysicalRect {
363 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364 write!(f, "{}x{}+{}+{}", self.width, self.height, self.x, self.y)
365 }
366}
367
368#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
376pub struct AspectRatio {
377 numerator: u32,
378 denominator: u32,
379}
380
381impl AspectRatio {
382 pub const WIDESCREEN: Self = Self {
384 numerator: 16,
385 denominator: 9,
386 };
387 pub const CLASSIC: Self = Self {
389 numerator: 4,
390 denominator: 3,
391 };
392 pub const SQUARE: Self = Self {
394 numerator: 1,
395 denominator: 1,
396 };
397
398 #[must_use]
405 pub const fn new(width: u32, height: u32) -> Self {
406 assert!(
407 width > 0 && height > 0,
408 "aspect ratio terms must be positive"
409 );
410 Self {
411 numerator: width,
412 denominator: height,
413 }
414 }
415
416 #[inline]
418 #[must_use]
419 pub const fn numerator(self) -> u32 {
420 self.numerator
421 }
422
423 #[inline]
425 #[must_use]
426 pub const fn denominator(self) -> u32 {
427 self.denominator
428 }
429
430 #[inline]
432 #[must_use]
433 pub fn as_f64(self) -> f64 {
434 f64::from(self.numerator) / f64::from(self.denominator)
435 }
436
437 #[must_use]
447 pub fn fit(self, bounds: PhysicalSize) -> PhysicalSize {
448 if bounds.is_empty() {
449 return PhysicalSize::new(1, 1);
450 }
451 let bounds_ratio = u64::from(bounds.width) * u64::from(self.denominator);
454 let target_ratio = u64::from(bounds.height) * u64::from(self.numerator);
455 if bounds_ratio > target_ratio {
456 let width = target_ratio / u64::from(self.denominator);
458 PhysicalSize::new(width.max(1) as u32, bounds.height)
459 } else {
460 let height = bounds_ratio / u64::from(self.numerator);
462 PhysicalSize::new(bounds.width, height.max(1) as u32)
463 }
464 }
465}
466
467impl fmt::Display for AspectRatio {
468 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469 write!(f, "{}:{}", self.numerator, self.denominator)
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn scale_conversions_round_trip_at_integer_scales() {
479 let logical = LogicalSize::new(1280.0, 720.0);
480 assert_eq!(logical.to_physical(1.0), PhysicalSize::new(1280, 720));
481 assert_eq!(logical.to_physical(2.0), PhysicalSize::new(2560, 1440));
482 assert_eq!(
483 PhysicalSize::new(2560, 1440).to_logical(2.0),
484 LogicalSize::new(1280.0, 720.0)
485 );
486 }
487
488 #[test]
489 fn fractional_scale_rounds_and_never_produces_zero() {
490 assert_eq!(
492 LogicalSize::new(1280.0, 720.0).to_physical(1.25),
493 PhysicalSize::new(1600, 900)
494 );
495 assert_eq!(
497 LogicalSize::new(0.0, 0.0).to_physical(1.0),
498 PhysicalSize::new(1, 1)
499 );
500 assert_eq!(
501 LogicalSize::new(f64::NAN, 4.0).to_physical(1.0),
502 PhysicalSize::new(1, 4)
503 );
504 assert_eq!(
508 LogicalSize::new(1e300, 4.0).to_physical(1e300),
509 PhysicalSize::new(u32::MAX, u32::MAX)
510 );
511 }
512
513 #[test]
514 fn empty_sizes_are_detectable_rather_than_clamped_away() {
515 let minimized = PhysicalSize::new(0, 0);
519 assert!(minimized.is_empty());
520 assert_eq!(minimized.aspect(), None);
521 assert_eq!(minimized.area(), 0);
522 assert!(!PhysicalSize::new(1, 1).is_empty());
523 assert_eq!(PhysicalSize::new(3840, 2160).area(), 8_294_400);
524 assert_eq!(PhysicalSize::new(1920, 1080).to_string(), "1920x1080px");
525 assert_eq!(LogicalSize::new(16.0, 9.0).to_string(), "16x9dp");
526 }
527
528 #[test]
529 fn letterbox_fit_is_exact_for_the_common_ratios() {
530 let fitted = AspectRatio::WIDESCREEN.fit(PhysicalSize::new(1920, 1200));
532 assert_eq!(fitted, PhysicalSize::new(1920, 1080));
533
534 let fitted = AspectRatio::WIDESCREEN.fit(PhysicalSize::new(2560, 1080));
536 assert_eq!(fitted, PhysicalSize::new(1920, 1080));
537
538 let exact = PhysicalSize::new(1280, 720);
541 let mut size = exact;
542 for _ in 0..1_000 {
543 size = AspectRatio::WIDESCREEN.fit(size);
544 }
545 assert_eq!(size, exact);
546
547 assert_eq!(
548 AspectRatio::CLASSIC.fit(PhysicalSize::new(1000, 1000)),
549 PhysicalSize::new(1000, 750)
550 );
551 assert_eq!(
552 AspectRatio::SQUARE.fit(PhysicalSize::new(1000, 400)),
553 PhysicalSize::new(400, 400)
554 );
555 assert_eq!(
556 AspectRatio::WIDESCREEN.fit(PhysicalSize::new(0, 0)),
557 PhysicalSize::new(1, 1)
558 );
559 assert_eq!(
561 AspectRatio::new(1000, 1).fit(PhysicalSize::new(10, 10)),
562 PhysicalSize::new(10, 1)
563 );
564 }
565
566 #[test]
567 fn aspect_ratio_accessors_and_display() {
568 let ratio = AspectRatio::new(21, 9);
569 assert_eq!(ratio.numerator(), 21);
570 assert_eq!(ratio.denominator(), 9);
571 assert!((ratio.as_f64() - 21.0 / 9.0).abs() < 1e-12);
572 assert_eq!(ratio.to_string(), "21:9");
573 assert_eq!(AspectRatio::WIDESCREEN, AspectRatio::new(16, 9));
574 }
575
576 #[test]
577 #[should_panic(expected = "aspect ratio terms must be positive")]
578 fn a_degenerate_aspect_ratio_is_rejected() {
579 let _ = AspectRatio::new(16, 0);
580 }
581
582 #[test]
583 fn points_convert_and_hit_test() {
584 let point = PhysicalPoint::new(200.0, 100.0);
585 assert_eq!(point.to_logical(2.0), LogicalPoint::new(100.0, 50.0));
586 assert!(point.is_inside(PhysicalSize::new(640, 360)));
587 assert!(!point.is_inside(PhysicalSize::new(100, 100)));
588 assert!(!PhysicalPoint::new(-1.0, 0.0).is_inside(PhysicalSize::new(640, 360)));
589 assert_eq!(PhysicalPoint::ORIGIN, PhysicalPoint::default());
590 assert_eq!(LogicalPoint::new(0.0, 0.0), LogicalPoint::default());
591 }
592
593 #[test]
594 fn monitor_rectangles_handle_negative_origins() {
595 let left = PhysicalRect::new(-1920, 0, 1920, 1080);
597 assert!(left.contains(-1000, 500));
598 assert!(!left.contains(10, 500));
599 assert_eq!(left.size(), PhysicalSize::new(1920, 1080));
600 assert_eq!(left.to_string(), "1920x1080+-1920+0");
601 }
602
603 #[test]
604 fn tiled_monitors_partition_the_desktop() {
605 let first = PhysicalRect::new(0, 0, 1920, 1080);
609 let second = PhysicalRect::new(1920, 0, 1920, 1080);
610 for (x, y) in [(0, 0), (1919, 0), (0, 1079), (1919, 1079)] {
611 assert!(first.contains(x, y), "({x},{y}) is on the first monitor");
612 assert!(!second.contains(x, y));
613 }
614 for (x, y) in [(1920, 0), (3839, 1079)] {
615 assert!(second.contains(x, y), "({x},{y}) is on the second monitor");
616 assert!(!first.contains(x, y), "the shared edge belongs to one");
617 }
618 assert!(!first.contains(0, 1080));
620 assert!(!second.contains(3840, 0));
621 }
622
623 #[test]
624 fn hit_testing_a_far_away_point_does_not_overflow() {
625 let negative_origin = PhysicalRect::new(-1, 0, 1920, 1080);
627 assert!(!negative_origin.contains(i32::MAX, 0));
628 assert!(!PhysicalRect::new(i32::MIN, 0, 1920, 1080).contains(i32::MAX, 0));
629 let widest = PhysicalRect::new(i32::MIN, 0, u32::MAX, 1080);
632 assert!(widest.contains(i32::MAX - 1, 0));
633 assert!(!widest.contains(i32::MAX, 0));
634 }
635
636 fn corner_covers(radius: i64, x: i64, y: i64) -> bool {
641 if x >= radius || y >= radius {
642 return true;
643 }
644 let (dx, dy) = (radius - x - 1, radius - y - 1);
645 dx * dx + dy * dy < radius * radius
646 }
647
648 #[test]
649 fn a_rounded_rectangle_is_exactly_the_pixels_its_corners_cover() {
650 for (rect, radius) in [
651 (PhysicalRect::new(0, 0, 40, 30), 10),
652 (PhysicalRect::new(-7, 13, 31, 64), 6),
653 (PhysicalRect::new(5, 5, 20, 20), 10),
654 (PhysicalRect::new(0, 0, 9, 50), 30),
656 (PhysicalRect::new(0, 0, 12, 8), 1),
657 (PhysicalRect::new(3, 4, 12, 8), 0),
658 ] {
659 let rects = rect.rounded(radius);
660 let r = i64::from(radius.min(rect.width / 2).min(rect.height / 2));
661 let (width, height) = (i64::from(rect.width), i64::from(rect.height));
662 for y in 0..height {
663 for x in 0..width {
664 let covered = corner_covers(r, x.min(width - 1 - x), y.min(height - 1 - y));
665 let px = i32::try_from(i64::from(rect.x) + x).expect("small");
666 let py = i32::try_from(i64::from(rect.y) + y).expect("small");
667 let hits = rects.iter().filter(|part| part.contains(px, py)).count();
668 assert_eq!(
669 hits,
670 usize::from(covered),
671 "{rect} r{radius}: pixel ({x},{y}) in {hits} of {rects:?}"
672 );
673 }
674 }
675 for part in &rects {
677 assert!(part.x >= rect.x && part.y >= rect.y, "{part} in {rect}");
678 assert!(
679 i64::from(part.x) + i64::from(part.width) <= i64::from(rect.x) + width
680 && i64::from(part.y) + i64::from(part.height) <= i64::from(rect.y) + height,
681 "{part} in {rect}"
682 );
683 }
684 }
685 }
686
687 #[test]
688 fn a_rounded_corner_insets_rows_by_the_known_amounts_and_merges_equal_ones() {
689 let rects = PhysicalRect::new(0, 0, 100, 60).rounded(10);
693 let insets: Vec<(i32, u32)> = rects.iter().map(|part| (part.x, part.height)).collect();
694 assert_eq!(
695 insets,
696 [
697 (5, 1),
698 (4, 1),
699 (2, 2),
700 (1, 1),
701 (0, 50),
702 (1, 1),
703 (2, 2),
704 (4, 1),
705 (5, 1)
706 ]
707 );
708 assert_eq!(rects[4], PhysicalRect::new(0, 5, 100, 50));
709 let square = PhysicalRect::new(3, 4, 12, 8);
711 assert_eq!(square.rounded(0), [square]);
712 assert!(PhysicalRect::new(0, 0, 0, 8).rounded(4).is_empty());
713 }
714}