Skip to main content

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}