Skip to main content

ftui_layout/
responsive_layout.rs

1#![forbid(unsafe_code)]
2
3//! Responsive layout switching: different [`Flex`] configurations per breakpoint.
4//!
5//! [`ResponsiveLayout`] maps [`Breakpoint`] tiers to [`Flex`] layouts. When
6//! splitting an area, it auto-detects the current breakpoint from the area
7//! width and resolves the appropriate layout using [`Responsive`] inheritance.
8//!
9//! # Usage
10//!
11//! ```ignore
12//! use ftui_layout::{Flex, Constraint, Breakpoint, Breakpoints, ResponsiveLayout};
13//!
14//! // Mobile: single column. Desktop: sidebar + content.
15//! let layout = ResponsiveLayout::new(
16//!         Flex::vertical()
17//!             .constraints([Constraint::Fill, Constraint::Fill]),
18//!     )
19//!     .at(Breakpoint::Md,
20//!         Flex::horizontal()
21//!             .constraints([Constraint::Fixed(30), Constraint::Fill]),
22//!     );
23//!
24//! let area = ftui_core::geometry::Rect::new(0, 0, 120, 40);
25//! let result = layout.split(area);
26//! assert_eq!(result.breakpoint, Breakpoint::Lg);
27//! assert_eq!(result.rects.len(), 2);
28//! ```
29//!
30//! # Invariants
31//!
32//! 1. The base layout (`Xs`) always has a value (enforced by constructor).
33//! 2. Breakpoint resolution inherits from smaller tiers (via [`Responsive`]).
34//! 3. `split()` auto-detects breakpoint from area width.
35//! 4. `split_for()` uses an explicit breakpoint (no auto-detection).
36//! 5. Result count may differ between breakpoints (caller must handle this).
37//!
38//! # Failure Modes
39//!
40//! - Empty area: delegates to [`Flex::split`] (returns zero-sized rects).
41//! - Breakpoint changes mid-session: caller must handle state transitions
42//!   (e.g., re-mapping children). Use [`ResponsiveSplit::breakpoint`] to
43//!   detect changes.
44
45use super::{Breakpoint, Breakpoints, Flex, Rect, Responsive};
46
47// ---------------------------------------------------------------------------
48// Types
49// ---------------------------------------------------------------------------
50
51/// Result of a responsive layout split.
52#[derive(Debug, Clone, PartialEq)]
53pub struct ResponsiveSplit {
54    /// The breakpoint that was active for this split.
55    pub breakpoint: Breakpoint,
56    /// The resulting layout rectangles.
57    pub rects: Vec<Rect>,
58}
59
60/// A breakpoint-aware layout that switches [`Flex`] configuration at different
61/// terminal widths.
62///
63/// Wraps [`Responsive<Flex>`] with auto-detection of breakpoints from area
64/// width. Each breakpoint tier can define a completely different layout
65/// (direction, constraints, gaps, margins).
66#[derive(Debug, Clone)]
67pub struct ResponsiveLayout {
68    /// Per-breakpoint Flex configurations.
69    layouts: Responsive<Flex>,
70    /// Breakpoint thresholds for width classification.
71    breakpoints: Breakpoints,
72}
73
74// ---------------------------------------------------------------------------
75// Construction
76// ---------------------------------------------------------------------------
77
78impl ResponsiveLayout {
79    /// Create a responsive layout with a base layout for `Xs`.
80    ///
81    /// All larger breakpoints inherit this layout until explicitly overridden.
82    #[must_use]
83    pub fn new(base: Flex) -> Self {
84        Self {
85            layouts: Responsive::new(base),
86            breakpoints: Breakpoints::DEFAULT,
87        }
88    }
89
90    /// Set the layout for a specific breakpoint (builder pattern).
91    #[must_use]
92    pub fn at(mut self, bp: Breakpoint, layout: Flex) -> Self {
93        self.layouts.set(bp, layout);
94        self
95    }
96
97    /// Override the breakpoint thresholds (builder pattern).
98    ///
99    /// Defaults to [`Breakpoints::DEFAULT`] (60/90/120/160).
100    #[must_use]
101    pub fn with_breakpoints(mut self, breakpoints: Breakpoints) -> Self {
102        self.breakpoints = breakpoints;
103        self
104    }
105
106    /// Set the layout for a specific breakpoint (mutating).
107    pub fn set(&mut self, bp: Breakpoint, layout: Flex) {
108        self.layouts.set(bp, layout);
109    }
110
111    /// Clear the override for a specific breakpoint, reverting to inheritance.
112    ///
113    /// Clearing `Xs` is a no-op.
114    pub fn clear(&mut self, bp: Breakpoint) {
115        self.layouts.clear(bp);
116    }
117}
118
119// ---------------------------------------------------------------------------
120// Splitting
121// ---------------------------------------------------------------------------
122
123impl ResponsiveLayout {
124    /// Split the area using auto-detected breakpoint from width.
125    ///
126    /// Classifies `area.width` into a [`Breakpoint`], resolves the
127    /// corresponding [`Flex`], and splits the area.
128    #[must_use]
129    pub fn split(&self, area: Rect) -> ResponsiveSplit {
130        let bp = self.breakpoints.classify_width(area.width);
131        self.split_for(bp, area)
132    }
133
134    /// Split the area using an explicit breakpoint.
135    ///
136    /// Use this when you already know the active breakpoint (e.g., from
137    /// a shared app-level breakpoint state).
138    #[must_use]
139    pub fn split_for(&self, bp: Breakpoint, area: Rect) -> ResponsiveSplit {
140        let flex = self.layouts.resolve(bp);
141        ResponsiveSplit {
142            breakpoint: bp,
143            rects: flex.split(area),
144        }
145    }
146
147    /// Get the active breakpoint for a given width.
148    #[must_use]
149    pub fn classify(&self, width: u16) -> Breakpoint {
150        self.breakpoints.classify_width(width)
151    }
152
153    /// Get the Flex configuration for a given breakpoint.
154    #[must_use]
155    pub fn layout_for(&self, bp: Breakpoint) -> &Flex {
156        self.layouts.resolve(bp)
157    }
158
159    /// Whether a specific breakpoint has an explicit (non-inherited) layout.
160    #[must_use]
161    pub fn has_explicit(&self, bp: Breakpoint) -> bool {
162        self.layouts.has_explicit(bp)
163    }
164
165    /// Get the breakpoint thresholds.
166    #[must_use]
167    pub fn breakpoints(&self) -> Breakpoints {
168        self.breakpoints
169    }
170
171    /// Number of rects that would be produced for a given breakpoint.
172    ///
173    /// Useful for pre-allocating or checking layout changes without
174    /// performing the full split.
175    #[must_use]
176    pub fn constraint_count(&self, bp: Breakpoint) -> usize {
177        self.layouts.resolve(bp).constraint_count()
178    }
179
180    /// Check if a width change would cause a breakpoint transition.
181    ///
182    /// Returns `Some((old, new))` if the breakpoint changed, `None` otherwise.
183    #[must_use]
184    pub fn detect_transition(
185        &self,
186        old_width: u16,
187        new_width: u16,
188    ) -> Option<(Breakpoint, Breakpoint)> {
189        let old_bp = self.breakpoints.classify_width(old_width);
190        let new_bp = self.breakpoints.classify_width(new_width);
191        if old_bp != new_bp {
192            Some((old_bp, new_bp))
193        } else {
194            None
195        }
196    }
197}
198
199// ---------------------------------------------------------------------------
200// Tests
201// ---------------------------------------------------------------------------
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::Constraint;
207
208    fn single_column() -> Flex {
209        Flex::vertical().constraints([Constraint::Fill])
210    }
211
212    fn two_column() -> Flex {
213        Flex::horizontal().constraints([Constraint::Fixed(30), Constraint::Fill])
214    }
215
216    fn three_column() -> Flex {
217        Flex::horizontal().constraints([
218            Constraint::Fixed(25),
219            Constraint::Fill,
220            Constraint::Fixed(25),
221        ])
222    }
223
224    fn area(w: u16, h: u16) -> Rect {
225        Rect::new(0, 0, w, h)
226    }
227
228    #[test]
229    fn base_layout_at_all_breakpoints() {
230        let layout = ResponsiveLayout::new(single_column());
231        for bp in Breakpoint::ALL {
232            let result = layout.split_for(bp, area(80, 24));
233            assert_eq!(result.rects.len(), 1);
234        }
235    }
236
237    #[test]
238    fn switches_at_breakpoint() {
239        let layout = ResponsiveLayout::new(single_column()).at(Breakpoint::Md, two_column());
240
241        // Xs (width < 60): single column
242        let result = layout.split(area(50, 24));
243        assert_eq!(result.breakpoint, Breakpoint::Xs);
244        assert_eq!(result.rects.len(), 1);
245
246        // Md (width 90-119): two columns
247        let result = layout.split(area(100, 24));
248        assert_eq!(result.breakpoint, Breakpoint::Md);
249        assert_eq!(result.rects.len(), 2);
250    }
251
252    #[test]
253    fn inherits_from_smaller() {
254        let layout = ResponsiveLayout::new(single_column()).at(Breakpoint::Md, two_column());
255
256        // Lg inherits from Md
257        let result = layout.split(area(130, 24));
258        assert_eq!(result.breakpoint, Breakpoint::Lg);
259        assert_eq!(result.rects.len(), 2);
260    }
261
262    #[test]
263    fn three_tier_layout() {
264        let layout = ResponsiveLayout::new(single_column())
265            .at(Breakpoint::Sm, two_column())
266            .at(Breakpoint::Lg, three_column());
267
268        assert_eq!(layout.split(area(40, 24)).rects.len(), 1); // Xs
269        assert_eq!(layout.split(area(70, 24)).rects.len(), 2); // Sm
270        assert_eq!(layout.split(area(100, 24)).rects.len(), 2); // Md inherits Sm
271        assert_eq!(layout.split(area(130, 24)).rects.len(), 3); // Lg
272        assert_eq!(layout.split(area(170, 24)).rects.len(), 3); // Xl inherits Lg
273    }
274
275    #[test]
276    fn split_for_ignores_width() {
277        let layout = ResponsiveLayout::new(single_column()).at(Breakpoint::Lg, two_column());
278
279        // Even though area is narrow, split_for uses the explicit breakpoint.
280        let result = layout.split_for(Breakpoint::Lg, area(40, 24));
281        assert_eq!(result.breakpoint, Breakpoint::Lg);
282        assert_eq!(result.rects.len(), 2);
283    }
284
285    #[test]
286    fn custom_breakpoints() {
287        let layout = ResponsiveLayout::new(single_column())
288            .at(Breakpoint::Sm, two_column())
289            .with_breakpoints(Breakpoints::new(40, 80, 120));
290
291        // Width 50 ≥ 40 → Sm (custom threshold)
292        let result = layout.split(area(50, 24));
293        assert_eq!(result.breakpoint, Breakpoint::Sm);
294        assert_eq!(result.rects.len(), 2);
295    }
296
297    #[test]
298    fn detect_transition_some() {
299        let layout = ResponsiveLayout::new(single_column());
300
301        // 50→100 crosses from Xs to Md (default breakpoints: sm=60, md=90)
302        let transition = layout.detect_transition(50, 100);
303        assert!(transition.is_some());
304        let (old, new) = transition.unwrap();
305        assert_eq!(old, Breakpoint::Xs);
306        assert_eq!(new, Breakpoint::Md);
307    }
308
309    #[test]
310    fn detect_transition_none() {
311        let layout = ResponsiveLayout::new(single_column());
312
313        // 70→80 stays within Sm
314        assert!(layout.detect_transition(70, 80).is_none());
315    }
316
317    #[test]
318    fn classify_width() {
319        let layout = ResponsiveLayout::new(single_column());
320        assert_eq!(layout.classify(40), Breakpoint::Xs);
321        assert_eq!(layout.classify(60), Breakpoint::Sm);
322        assert_eq!(layout.classify(90), Breakpoint::Md);
323        assert_eq!(layout.classify(120), Breakpoint::Lg);
324        assert_eq!(layout.classify(160), Breakpoint::Xl);
325    }
326
327    #[test]
328    fn constraint_count() {
329        let layout = ResponsiveLayout::new(single_column())
330            .at(Breakpoint::Md, two_column())
331            .at(Breakpoint::Lg, three_column());
332
333        assert_eq!(layout.constraint_count(Breakpoint::Xs), 1);
334        assert_eq!(layout.constraint_count(Breakpoint::Sm), 1); // Inherits Xs
335        assert_eq!(layout.constraint_count(Breakpoint::Md), 2);
336        assert_eq!(layout.constraint_count(Breakpoint::Lg), 3);
337    }
338
339    #[test]
340    fn layout_for_access() {
341        let layout = ResponsiveLayout::new(single_column()).at(Breakpoint::Md, two_column());
342
343        let flex = layout.layout_for(Breakpoint::Md);
344        assert_eq!(flex.constraint_count(), 2);
345    }
346
347    #[test]
348    fn has_explicit_check() {
349        let layout = ResponsiveLayout::new(single_column()).at(Breakpoint::Lg, two_column());
350
351        assert!(layout.has_explicit(Breakpoint::Xs));
352        assert!(!layout.has_explicit(Breakpoint::Sm));
353        assert!(!layout.has_explicit(Breakpoint::Md));
354        assert!(layout.has_explicit(Breakpoint::Lg));
355    }
356
357    #[test]
358    fn set_mutating() {
359        let mut layout = ResponsiveLayout::new(single_column());
360        layout.set(Breakpoint::Xl, three_column());
361        assert_eq!(layout.constraint_count(Breakpoint::Xl), 3);
362    }
363
364    #[test]
365    fn clear_reverts_to_inheritance() {
366        let mut layout = ResponsiveLayout::new(single_column()).at(Breakpoint::Md, two_column());
367
368        assert_eq!(layout.constraint_count(Breakpoint::Md), 2);
369        layout.clear(Breakpoint::Md);
370        assert_eq!(layout.constraint_count(Breakpoint::Md), 1); // Inherits Xs
371    }
372
373    #[test]
374    fn empty_area_returns_zero_rects() {
375        let layout = ResponsiveLayout::new(two_column());
376        let result = layout.split(area(0, 0));
377        assert_eq!(result.breakpoint, Breakpoint::Xs);
378        // Flex::split returns default rects for empty area
379        assert_eq!(result.rects.len(), 2);
380        assert!(result.rects.iter().all(|r| r.width == 0 && r.height == 0));
381    }
382
383    #[test]
384    fn rect_dimensions_correct() {
385        let layout = ResponsiveLayout::new(
386            Flex::horizontal().constraints([Constraint::Fixed(20), Constraint::Fill]),
387        );
388
389        let result = layout.split(area(100, 30));
390        assert_eq!(result.rects[0].width, 20);
391        assert_eq!(result.rects[0].height, 30);
392        assert_eq!(result.rects[1].width, 80);
393        assert_eq!(result.rects[1].height, 30);
394    }
395
396    #[test]
397    fn breakpoints_accessor() {
398        let bps = Breakpoints::new(50, 80, 110);
399        let layout = ResponsiveLayout::new(single_column()).with_breakpoints(bps);
400        assert_eq!(layout.breakpoints(), bps);
401    }
402
403    #[test]
404    fn responsive_split_debug() {
405        let split = ResponsiveSplit {
406            breakpoint: Breakpoint::Md,
407            rects: vec![Rect::new(0, 0, 50, 24)],
408        };
409        let dbg = format!("{:?}", split);
410        assert!(dbg.contains("Md"));
411    }
412}