Rust · 5889 bytes Raw Blame History
1 use ratatui::layout::Rect;
2
3 /// Minimum terminal width where each month grid column can keep a readable
4 /// bordered day cell.
5 pub const MIN_MONTH_GRID_WIDTH: u16 = 49;
6 /// Minimum terminal height where the title, weekdays, and six week rows stay
7 /// visible without collapsing the grid into label-only cells.
8 pub const MIN_MONTH_GRID_HEIGHT: u16 = 20;
9 /// Minimum terminal width where a selected week can still show all seven day
10 /// cells with readable day labels.
11 pub const MIN_WEEK_VIEW_WIDTH: u16 = 36;
12 /// Minimum terminal height where a selected week can show a title, weekday
13 /// labels, and one bordered row of day cells.
14 pub const MIN_WEEK_VIEW_HEIGHT: u16 = 6;
15 /// A terminal is treated as portrait when the width is less than twice the
16 /// height. Portrait shape does not block month view when the month thresholds
17 /// fit, but it explains why narrower terminals fall back to selected week view.
18 pub const PORTRAIT_WIDTH_TO_HEIGHT_RATIO: u16 = 2;
19
20 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
21 pub enum ResponsiveMode {
22 MonthGrid,
23 WeekView,
24 DayFallback,
25 }
26
27 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28 pub enum LayoutConstraint {
29 FitsMonthGrid,
30 Portrait,
31 MonthTooNarrow,
32 MonthTooShort,
33 MonthTooNarrowAndShort,
34 WeekTooNarrow,
35 WeekTooShort,
36 WeekTooNarrowAndShort,
37 }
38
39 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
40 pub struct ResponsiveLayout {
41 pub mode: ResponsiveMode,
42 pub constraint: LayoutConstraint,
43 }
44
45 impl ResponsiveLayout {
46 pub const fn for_area(area: Rect) -> Self {
47 if area.width >= MIN_MONTH_GRID_WIDTH && area.height >= MIN_MONTH_GRID_HEIGHT {
48 return Self {
49 mode: ResponsiveMode::MonthGrid,
50 constraint: LayoutConstraint::FitsMonthGrid,
51 };
52 }
53
54 let month_constraint = month_constraint(area);
55 if area.width >= MIN_WEEK_VIEW_WIDTH && area.height >= MIN_WEEK_VIEW_HEIGHT {
56 return Self {
57 mode: ResponsiveMode::WeekView,
58 constraint: month_constraint,
59 };
60 }
61
62 Self::day(week_constraint(area))
63 }
64
65 pub const fn should_render_month_grid(self) -> bool {
66 matches!(self.mode, ResponsiveMode::MonthGrid)
67 }
68
69 pub const fn should_render_week_view(self) -> bool {
70 matches!(self.mode, ResponsiveMode::WeekView)
71 }
72
73 const fn day(constraint: LayoutConstraint) -> Self {
74 Self {
75 mode: ResponsiveMode::DayFallback,
76 constraint,
77 }
78 }
79 }
80
81 const fn month_constraint(area: Rect) -> LayoutConstraint {
82 if is_portrait(area) {
83 return LayoutConstraint::Portrait;
84 }
85
86 match (
87 area.width < MIN_MONTH_GRID_WIDTH,
88 area.height < MIN_MONTH_GRID_HEIGHT,
89 ) {
90 (true, true) => LayoutConstraint::MonthTooNarrowAndShort,
91 (true, false) => LayoutConstraint::MonthTooNarrow,
92 (false, true) => LayoutConstraint::MonthTooShort,
93 (false, false) => LayoutConstraint::FitsMonthGrid,
94 }
95 }
96
97 const fn week_constraint(area: Rect) -> LayoutConstraint {
98 match (
99 area.width < MIN_WEEK_VIEW_WIDTH,
100 area.height < MIN_WEEK_VIEW_HEIGHT,
101 ) {
102 (true, true) => LayoutConstraint::WeekTooNarrowAndShort,
103 (true, false) => LayoutConstraint::WeekTooNarrow,
104 (false, true) => LayoutConstraint::WeekTooShort,
105 (false, false) => month_constraint(area),
106 }
107 }
108
109 const fn is_portrait(area: Rect) -> bool {
110 area.width < area.height.saturating_mul(PORTRAIT_WIDTH_TO_HEIGHT_RATIO)
111 }
112
113 #[cfg(test)]
114 mod tests {
115 use super::*;
116
117 #[test]
118 fn normal_landscape_size_uses_month_grid() {
119 let layout = ResponsiveLayout::for_area(Rect::new(0, 0, 84, 26));
120
121 assert_eq!(layout.mode, ResponsiveMode::MonthGrid);
122 assert_eq!(layout.constraint, LayoutConstraint::FitsMonthGrid);
123 }
124
125 #[test]
126 fn threshold_size_can_use_month_grid() {
127 let layout = ResponsiveLayout::for_area(Rect::new(
128 0,
129 0,
130 MIN_MONTH_GRID_WIDTH,
131 MIN_MONTH_GRID_HEIGHT,
132 ));
133
134 assert!(layout.should_render_month_grid());
135 }
136
137 #[test]
138 fn portrait_size_that_still_fits_month_keeps_month_grid() {
139 let layout = ResponsiveLayout::for_area(Rect::new(0, 0, 60, 40));
140
141 assert_eq!(layout.mode, ResponsiveMode::MonthGrid);
142 assert_eq!(layout.constraint, LayoutConstraint::FitsMonthGrid);
143 }
144
145 #[test]
146 fn moderate_pressure_uses_week_view() {
147 assert_eq!(
148 ResponsiveLayout::for_area(Rect::new(0, 0, 84, MIN_MONTH_GRID_HEIGHT - 1)),
149 ResponsiveLayout {
150 mode: ResponsiveMode::WeekView,
151 constraint: LayoutConstraint::MonthTooShort
152 }
153 );
154 assert_eq!(
155 ResponsiveLayout::for_area(Rect::new(0, 0, MIN_MONTH_GRID_WIDTH - 1, 26)),
156 ResponsiveLayout {
157 mode: ResponsiveMode::WeekView,
158 constraint: LayoutConstraint::Portrait
159 }
160 );
161 }
162
163 #[test]
164 fn week_view_has_its_own_lower_thresholds() {
165 let layout =
166 ResponsiveLayout::for_area(Rect::new(0, 0, MIN_WEEK_VIEW_WIDTH, MIN_WEEK_VIEW_HEIGHT));
167
168 assert!(layout.should_render_week_view());
169 }
170
171 #[test]
172 fn tiny_sizes_use_day_fallback() {
173 assert_eq!(
174 ResponsiveLayout::for_area(Rect::new(0, 0, MIN_WEEK_VIEW_WIDTH - 1, 26)),
175 ResponsiveLayout {
176 mode: ResponsiveMode::DayFallback,
177 constraint: LayoutConstraint::WeekTooNarrow
178 }
179 );
180 assert_eq!(
181 ResponsiveLayout::for_area(Rect::new(0, 0, 84, MIN_WEEK_VIEW_HEIGHT - 1)),
182 ResponsiveLayout {
183 mode: ResponsiveMode::DayFallback,
184 constraint: LayoutConstraint::WeekTooShort
185 }
186 );
187 }
188 }
189