fenestra_core/breakpoints.rs
1//! Width breakpoints for constraints-aware layout.
2//!
3//! Tailwind's logical-pixel thresholds (`sm` 640, `md` 768, `lg` 1024,
4//! `xl` 1280, `2xl` 1536), exposed two ways: classify a width into a named
5//! [`Breakpoint`] band with [`Breakpoint::at`], or ask a yes/no question with
6//! the [`Breakpoints`] helpers ([`Breakpoints::up`] and friends). Pair either
7//! with [`App::view_at`](crate::App::view_at) for window-size breakpoints, or
8//! with [`responsive`](crate::responsive) for a container's own size.
9//!
10//! ```
11//! use fenestra_core::{Breakpoint, Breakpoints};
12//!
13//! assert_eq!(Breakpoint::at(390.0), Breakpoint::Base); // phone
14//! assert_eq!(Breakpoint::at(800.0), Breakpoint::Md); // tablet
15//! assert!(Breakpoints::up(1280.0, Breakpoint::Lg)); // desktop ≥ lg
16//! assert!(Breakpoints::is_md(800.0));
17//! ```
18
19/// The named width bands from Tailwind, mobile-first (smallest to largest).
20/// [`Self::at`] classifies a logical-pixel width into the largest band whose
21/// minimum width it meets.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub enum Breakpoint {
24 /// The unprefixed base band, below `sm` (`< 640px`).
25 Base,
26 /// `>= 640px`.
27 Sm,
28 /// `>= 768px`.
29 Md,
30 /// `>= 1024px`.
31 Lg,
32 /// `>= 1280px`.
33 Xl,
34 /// `>= 1536px` (Tailwind's `2xl`).
35 Xxl,
36}
37
38impl Breakpoint {
39 /// The minimum width (logical px) at which this band begins; [`Self::Base`]
40 /// begins at `0.0`.
41 pub const fn min_width(self) -> f32 {
42 match self {
43 Self::Base => 0.0,
44 Self::Sm => 640.0,
45 Self::Md => 768.0,
46 Self::Lg => 1024.0,
47 Self::Xl => 1280.0,
48 Self::Xxl => 1536.0,
49 }
50 }
51
52 /// Classifies a width into the largest band whose [`Self::min_width`] it
53 /// meets: `639.0 → Base`, `640.0 → Sm`, `2000.0 → Xxl`. A negative or NaN
54 /// width falls through to [`Self::Base`].
55 pub fn at(width: f32) -> Self {
56 if width >= Self::Xxl.min_width() {
57 Self::Xxl
58 } else if width >= Self::Xl.min_width() {
59 Self::Xl
60 } else if width >= Self::Lg.min_width() {
61 Self::Lg
62 } else if width >= Self::Md.min_width() {
63 Self::Md
64 } else if width >= Self::Sm.min_width() {
65 Self::Sm
66 } else {
67 Self::Base
68 }
69 }
70}
71
72/// Yes/no width queries against the [`Breakpoint`] thresholds — the boolean
73/// counterpart of [`Breakpoint::at`], for [`view_at`](crate::App::view_at) /
74/// [`responsive`](crate::responsive) consumers that want a flag rather than a
75/// band. A zero-sized namespace, never constructed.
76pub struct Breakpoints;
77
78impl Breakpoints {
79 /// `width` is at or above `bp`'s minimum — Tailwind's `md:` ("md and up").
80 pub fn up(width: f32, bp: Breakpoint) -> bool {
81 width >= bp.min_width()
82 }
83
84 /// `width` is strictly below `bp`'s minimum — Tailwind's `max-md:`.
85 pub fn down(width: f32, bp: Breakpoint) -> bool {
86 width < bp.min_width()
87 }
88
89 /// `width` falls in exactly this band (`>= bp` and below the next one up).
90 pub fn only(width: f32, bp: Breakpoint) -> bool {
91 Breakpoint::at(width) == bp
92 }
93
94 /// At or above the `sm` threshold (`>= 640px`).
95 pub fn is_sm(width: f32) -> bool {
96 Self::up(width, Breakpoint::Sm)
97 }
98
99 /// At or above the `md` threshold (`>= 768px`).
100 pub fn is_md(width: f32) -> bool {
101 Self::up(width, Breakpoint::Md)
102 }
103
104 /// At or above the `lg` threshold (`>= 1024px`).
105 pub fn is_lg(width: f32) -> bool {
106 Self::up(width, Breakpoint::Lg)
107 }
108
109 /// At or above the `xl` threshold (`>= 1280px`).
110 pub fn is_xl(width: f32) -> bool {
111 Self::up(width, Breakpoint::Xl)
112 }
113
114 /// At or above the `2xl` threshold (`>= 1536px`).
115 pub fn is_xxl(width: f32) -> bool {
116 Self::up(width, Breakpoint::Xxl)
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::{Breakpoint, Breakpoints};
123
124 #[test]
125 fn at_classifies_each_band_at_and_below_its_edge() {
126 // Just below each edge stays in the lower band; exactly the edge enters.
127 assert_eq!(Breakpoint::at(0.0), Breakpoint::Base);
128 assert_eq!(Breakpoint::at(639.9), Breakpoint::Base);
129 assert_eq!(Breakpoint::at(640.0), Breakpoint::Sm);
130 assert_eq!(Breakpoint::at(767.9), Breakpoint::Sm);
131 assert_eq!(Breakpoint::at(768.0), Breakpoint::Md);
132 assert_eq!(Breakpoint::at(1024.0), Breakpoint::Lg);
133 assert_eq!(Breakpoint::at(1280.0), Breakpoint::Xl);
134 assert_eq!(Breakpoint::at(1535.9), Breakpoint::Xl);
135 assert_eq!(Breakpoint::at(1536.0), Breakpoint::Xxl);
136 assert_eq!(Breakpoint::at(4000.0), Breakpoint::Xxl);
137 }
138
139 #[test]
140 fn degenerate_widths_fall_to_base() {
141 assert_eq!(Breakpoint::at(-100.0), Breakpoint::Base);
142 assert_eq!(Breakpoint::at(f32::NAN), Breakpoint::Base);
143 }
144
145 #[test]
146 fn up_down_only_agree_with_at() {
147 assert!(Breakpoints::up(768.0, Breakpoint::Md));
148 assert!(!Breakpoints::up(767.0, Breakpoint::Md));
149 assert!(Breakpoints::down(767.0, Breakpoint::Md));
150 assert!(!Breakpoints::down(768.0, Breakpoint::Md));
151 assert!(Breakpoints::only(800.0, Breakpoint::Md));
152 assert!(!Breakpoints::only(1024.0, Breakpoint::Md));
153 // The named shorthands mirror `up`.
154 assert!(Breakpoints::is_md(768.0));
155 assert!(!Breakpoints::is_lg(1023.0));
156 assert!(Breakpoints::is_xxl(1536.0));
157 // The bands are ordered, so `Ord` matches threshold order.
158 assert!(Breakpoint::Base < Breakpoint::Sm);
159 assert!(Breakpoint::Lg < Breakpoint::Xxl);
160 }
161}