Skip to main content

ftui_widgets/
measurable.rs

1//! Intrinsic sizing support for widgets.
2//!
3//! This module provides the [`MeasurableWidget`] trait for widgets that can report
4//! their intrinsic dimensions, enabling content-aware layout like `Constraint::FitContent`.
5//!
6//! # Overview
7//!
8//! Not all widgets need intrinsic sizing—many simply fill whatever space they're given.
9//! But some widgets have natural dimensions based on their content:
10//!
11//! - A [`Paragraph`](crate::paragraph::Paragraph) knows how wide its text is
12//! - A [`Block`](crate::block::Block) knows its minimum border/padding requirements
13//! - A [`List`](crate::list::List) knows how many items it contains
14//!
15//! # Size Constraints
16//!
17//! [`SizeConstraints`] captures the full sizing semantics:
18//!
19//! - **min**: Minimum size below which the widget clips or becomes unusable
20//! - **preferred**: Size that best displays the content
21//! - **max**: Maximum useful size (beyond this, extra space is wasted)
22//!
23//! # Example
24//!
25//! ```ignore
26//! use ftui_core::geometry::Size;
27//! use ftui_widgets::{MeasurableWidget, SizeConstraints, Widget};
28//!
29//! struct Label {
30//!     text: String,
31//! }
32//!
33//! impl MeasurableWidget for Label {
34//!     fn measure(&self, _available: Size) -> SizeConstraints {
35//!         let width = ftui_text::display_width(self.text.as_str()) as u16;
36//!         SizeConstraints {
37//!             min: Size::new(1, 1),           // At least show something
38//!             preferred: Size::new(width, 1), // Ideal: full text on one line
39//!             max: Some(Size::new(width, 1)), // No benefit from extra space
40//!         }
41//!     }
42//!
43//!     fn has_intrinsic_size(&self) -> bool {
44//!         true // This widget's size depends on content
45//!     }
46//! }
47//! ```
48//!
49//! # Invariants
50//!
51//! Implementations must maintain these invariants:
52//!
53//! 1. `min <= preferred <= max.unwrap_or(∞)` for both width and height
54//! 2. `measure()` must be pure: same input → same output
55//! 3. `measure()` should be O(content_length) worst case
56//!
57//! # Backwards Compatibility
58//!
59//! Widgets that don't implement `MeasurableWidget` explicitly get a default
60//! implementation that returns `SizeConstraints::ZERO` and `has_intrinsic_size() = false`,
61//! indicating they fill available space.
62
63use ftui_core::geometry::Size;
64
65/// Size constraints returned by measure operations.
66///
67/// Captures the full sizing semantics for a widget:
68/// - **min**: Minimum usable size (content clips below this)
69/// - **preferred**: Ideal size for content display
70/// - **max**: Maximum useful size (no benefit beyond this)
71///
72/// # Invariants
73///
74/// The following must hold:
75/// - `min.width <= preferred.width <= max.map_or(u16::MAX, |m| m.width)`
76/// - `min.height <= preferred.height <= max.map_or(u16::MAX, |m| m.height)`
77///
78/// # Example
79///
80/// ```
81/// use ftui_core::geometry::Size;
82/// use ftui_widgets::SizeConstraints;
83///
84/// // A 10x3 text block with some flexibility
85/// let constraints = SizeConstraints {
86///     min: Size::new(5, 1),       // Can shrink to 5 chars, 1 line
87///     preferred: Size::new(10, 3), // Ideal display
88///     max: Some(Size::new(20, 5)), // No benefit beyond this
89/// };
90///
91/// // Clamp an allocation to these constraints
92/// let allocated = Size::new(8, 2);
93/// let clamped = constraints.clamp(allocated);
94/// assert_eq!(clamped, Size::new(8, 2)); // Within range, unchanged
95/// ```
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
97pub struct SizeConstraints {
98    /// Minimum size below which the widget is unusable or clips content.
99    pub min: Size,
100    /// Preferred size that best displays content.
101    pub preferred: Size,
102    /// Maximum useful size. `None` means unbounded (widget can use all available space).
103    pub max: Option<Size>,
104}
105
106impl SizeConstraints {
107    /// Zero constraints (no minimum, no preferred, unbounded maximum).
108    ///
109    /// This is the default for widgets that fill available space.
110    pub const ZERO: Self = Self {
111        min: Size::ZERO,
112        preferred: Size::ZERO,
113        max: None,
114    };
115
116    /// Create constraints with exact sizing (min = preferred = max).
117    ///
118    /// Use this for widgets with a fixed, known size.
119    #[inline]
120    pub const fn exact(size: Size) -> Self {
121        Self {
122            min: size,
123            preferred: size,
124            max: Some(size),
125        }
126    }
127
128    /// Create constraints with a minimum and preferred size, unbounded maximum.
129    #[inline]
130    pub const fn at_least(min: Size, preferred: Size) -> Self {
131        Self {
132            min,
133            preferred,
134            max: None,
135        }
136    }
137
138    /// Clamp a given size to these constraints.
139    ///
140    /// The result will be:
141    /// - At least `min.width` x `min.height`
142    /// - At most `max.width` x `max.height` (if max is set)
143    ///
144    /// # Example
145    ///
146    /// ```
147    /// use ftui_core::geometry::Size;
148    /// use ftui_widgets::SizeConstraints;
149    ///
150    /// let c = SizeConstraints {
151    ///     min: Size::new(5, 2),
152    ///     preferred: Size::new(10, 5),
153    ///     max: Some(Size::new(20, 10)),
154    /// };
155    ///
156    /// // Below minimum
157    /// assert_eq!(c.clamp(Size::new(3, 1)), Size::new(5, 2));
158    ///
159    /// // Within range
160    /// assert_eq!(c.clamp(Size::new(15, 7)), Size::new(15, 7));
161    ///
162    /// // Above maximum
163    /// assert_eq!(c.clamp(Size::new(30, 20)), Size::new(20, 10));
164    /// ```
165    pub fn clamp(&self, size: Size) -> Size {
166        let max = self.max.unwrap_or(Size::MAX);
167
168        // Use const-compatible clamping
169        let width = if size.width < self.min.width {
170            self.min.width
171        } else if size.width > max.width {
172            max.width
173        } else {
174            size.width
175        };
176
177        let height = if size.height < self.min.height {
178            self.min.height
179        } else if size.height > max.height {
180            max.height
181        } else {
182            size.height
183        };
184
185        Size::new(width, height)
186    }
187
188    /// Check if these constraints are satisfied by the given size.
189    ///
190    /// Returns `true` if `size` is within the min/max bounds.
191    #[inline]
192    pub fn is_satisfied_by(&self, size: Size) -> bool {
193        let max = self.max.unwrap_or(Size::MAX);
194        size.width >= self.min.width
195            && size.height >= self.min.height
196            && size.width <= max.width
197            && size.height <= max.height
198    }
199
200    /// Combine two constraints by taking the maximum minimums and minimum maximums.
201    ///
202    /// Useful when a widget has multiple children and needs to satisfy all constraints.
203    pub fn intersect(&self, other: &SizeConstraints) -> SizeConstraints {
204        let min_width = self.min.width.max(other.min.width);
205        let min_height = self.min.height.max(other.min.height);
206
207        let max = match (self.max, other.max) {
208            (Some(a), Some(b)) => Some(Size::new(a.width.min(b.width), a.height.min(b.height))),
209            (Some(a), None) => Some(a),
210            (None, Some(b)) => Some(b),
211            (None, None) => None,
212        };
213
214        // Preferred is the max of minimums clamped to max
215        let preferred_width = self.preferred.width.max(other.preferred.width);
216        let preferred_height = self.preferred.height.max(other.preferred.height);
217        let preferred = Size::new(preferred_width, preferred_height);
218
219        SizeConstraints {
220            min: Size::new(min_width, min_height),
221            preferred,
222            max,
223        }
224    }
225}
226
227impl Default for SizeConstraints {
228    fn default() -> Self {
229        Self::ZERO
230    }
231}
232
233/// A widget that can report its intrinsic dimensions.
234///
235/// Implement this trait for widgets whose size depends on their content.
236/// Widgets that simply fill available space can use the default implementation.
237///
238/// # Semantics
239///
240/// - `measure(&self, available)` returns the size constraints given the available space
241/// - `has_intrinsic_size()` returns `true` if measure() provides meaningful constraints
242///
243/// # Invariants
244///
245/// Implementations must ensure:
246///
247/// 1. **Monotonicity**: `min <= preferred <= max.unwrap_or(∞)`
248/// 2. **Purity**: Same inputs produce identical outputs (no side effects)
249/// 3. **Performance**: O(content_length) worst case
250///
251/// # Example
252///
253/// ```ignore
254/// use ftui_core::geometry::Size;
255/// use ftui_widgets::{MeasurableWidget, SizeConstraints};
256///
257/// struct Icon {
258///     glyph: char,
259/// }
260///
261/// impl MeasurableWidget for Icon {
262///     fn measure(&self, _available: Size) -> SizeConstraints {
263///         // Icons are always 1x1 (or 2x1 for wide chars)
264///         let mut buf = [0u8; 4];
265///         let glyph = self.glyph.encode_utf8(&mut buf);
266///         let width = ftui_text::grapheme_width(glyph) as u16;
267///         SizeConstraints::exact(Size::new(width, 1))
268///     }
269///
270///     fn has_intrinsic_size(&self) -> bool {
271///         true
272///     }
273/// }
274/// ```
275pub trait MeasurableWidget {
276    /// Measure the widget given available space.
277    ///
278    /// # Arguments
279    ///
280    /// - `available`: Maximum space the widget could occupy. Use this for:
281    ///   - Text wrapping calculations (wrap at available.width)
282    ///   - Proportional sizing (e.g., "50% of available width")
283    ///
284    /// # Returns
285    ///
286    /// [`SizeConstraints`] describing the widget's min/preferred/max sizes.
287    ///
288    /// # Default Implementation
289    ///
290    /// Returns `SizeConstraints::ZERO`, indicating the widget fills available space.
291    fn measure(&self, available: Size) -> SizeConstraints {
292        let _ = available; // Suppress unused warning
293        SizeConstraints::ZERO
294    }
295
296    /// Quick check: does this widget have content-dependent sizing?
297    ///
298    /// Widgets returning `false` can skip `measure()` calls when only chrome
299    /// (borders, padding) matters. This is a performance optimization.
300    ///
301    /// # Returns
302    ///
303    /// - `true`: Widget size depends on content (call `measure()`)
304    /// - `false`: Widget fills available space (skip `measure()`)
305    ///
306    /// # Default Implementation
307    ///
308    /// Returns `false` for backwards compatibility with existing widgets.
309    fn has_intrinsic_size(&self) -> bool {
310        false
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    // --- SizeConstraints tests ---
319
320    #[test]
321    fn size_constraints_zero_is_default() {
322        assert_eq!(SizeConstraints::default(), SizeConstraints::ZERO);
323    }
324
325    #[test]
326    fn size_constraints_exact() {
327        let c = SizeConstraints::exact(Size::new(10, 5));
328        assert_eq!(c.min, Size::new(10, 5));
329        assert_eq!(c.preferred, Size::new(10, 5));
330        assert_eq!(c.max, Some(Size::new(10, 5)));
331    }
332
333    #[test]
334    fn size_constraints_at_least() {
335        let c = SizeConstraints::at_least(Size::new(5, 2), Size::new(10, 4));
336        assert_eq!(c.min, Size::new(5, 2));
337        assert_eq!(c.preferred, Size::new(10, 4));
338        assert_eq!(c.max, None);
339    }
340
341    #[test]
342    fn size_constraints_clamp_below_min() {
343        let c = SizeConstraints {
344            min: Size::new(5, 2),
345            preferred: Size::new(10, 5),
346            max: Some(Size::new(20, 10)),
347        };
348        assert_eq!(c.clamp(Size::new(3, 1)), Size::new(5, 2));
349    }
350
351    #[test]
352    fn size_constraints_clamp_in_range() {
353        let c = SizeConstraints {
354            min: Size::new(5, 2),
355            preferred: Size::new(10, 5),
356            max: Some(Size::new(20, 10)),
357        };
358        assert_eq!(c.clamp(Size::new(15, 7)), Size::new(15, 7));
359    }
360
361    #[test]
362    fn size_constraints_clamp_above_max() {
363        let c = SizeConstraints {
364            min: Size::new(5, 2),
365            preferred: Size::new(10, 5),
366            max: Some(Size::new(20, 10)),
367        };
368        assert_eq!(c.clamp(Size::new(30, 20)), Size::new(20, 10));
369    }
370
371    #[test]
372    fn size_constraints_clamp_no_max() {
373        let c = SizeConstraints {
374            min: Size::new(5, 2),
375            preferred: Size::new(10, 5),
376            max: None,
377        };
378        // Without max, large values are preserved
379        assert_eq!(c.clamp(Size::new(1000, 500)), Size::new(1000, 500));
380        // But still clamped to min
381        assert_eq!(c.clamp(Size::new(2, 1)), Size::new(5, 2));
382    }
383
384    #[test]
385    fn size_constraints_is_satisfied_by() {
386        let c = SizeConstraints {
387            min: Size::new(5, 2),
388            preferred: Size::new(10, 5),
389            max: Some(Size::new(20, 10)),
390        };
391
392        assert!(c.is_satisfied_by(Size::new(10, 5)));
393        assert!(c.is_satisfied_by(Size::new(5, 2))); // At min
394        assert!(c.is_satisfied_by(Size::new(20, 10))); // At max
395
396        assert!(!c.is_satisfied_by(Size::new(4, 2))); // Below min width
397        assert!(!c.is_satisfied_by(Size::new(5, 1))); // Below min height
398        assert!(!c.is_satisfied_by(Size::new(21, 10))); // Above max width
399        assert!(!c.is_satisfied_by(Size::new(20, 11))); // Above max height
400    }
401
402    #[test]
403    fn size_constraints_is_satisfied_by_no_max() {
404        let c = SizeConstraints {
405            min: Size::new(5, 2),
406            preferred: Size::new(10, 5),
407            max: None,
408        };
409
410        assert!(c.is_satisfied_by(Size::new(1000, 500))); // Any large size is fine
411        assert!(!c.is_satisfied_by(Size::new(4, 2))); // Still respects min
412    }
413
414    #[test]
415    fn size_constraints_intersect_both_bounded() {
416        let a = SizeConstraints {
417            min: Size::new(5, 2),
418            preferred: Size::new(10, 5),
419            max: Some(Size::new(20, 10)),
420        };
421        let b = SizeConstraints {
422            min: Size::new(8, 3),
423            preferred: Size::new(12, 6),
424            max: Some(Size::new(15, 8)),
425        };
426        let c = a.intersect(&b);
427
428        // Min is max of minimums
429        assert_eq!(c.min, Size::new(8, 3));
430        // Max is min of maximums
431        assert_eq!(c.max, Some(Size::new(15, 8)));
432        // Preferred is max of preferreds
433        assert_eq!(c.preferred, Size::new(12, 6));
434    }
435
436    #[test]
437    fn size_constraints_intersect_one_unbounded() {
438        let bounded = SizeConstraints {
439            min: Size::new(5, 2),
440            preferred: Size::new(10, 5),
441            max: Some(Size::new(20, 10)),
442        };
443        let unbounded = SizeConstraints {
444            min: Size::new(8, 1),
445            preferred: Size::new(15, 3),
446            max: None,
447        };
448        let c = bounded.intersect(&unbounded);
449
450        assert_eq!(c.min, Size::new(8, 2)); // Max of mins
451        assert_eq!(c.max, Some(Size::new(20, 10))); // Bounded wins
452        assert_eq!(c.preferred, Size::new(15, 5)); // Max of preferreds
453    }
454
455    #[test]
456    fn size_constraints_intersect_both_unbounded() {
457        let a = SizeConstraints::at_least(Size::new(5, 2), Size::new(10, 5));
458        let b = SizeConstraints::at_least(Size::new(8, 3), Size::new(12, 6));
459        let c = a.intersect(&b);
460
461        assert_eq!(c.min, Size::new(8, 3));
462        assert_eq!(c.max, None);
463        assert_eq!(c.preferred, Size::new(12, 6));
464    }
465
466    // --- MeasurableWidget default implementation tests ---
467
468    struct PlainWidget;
469
470    impl MeasurableWidget for PlainWidget {}
471
472    #[test]
473    fn default_measure_returns_zero() {
474        let widget = PlainWidget;
475        assert_eq!(widget.measure(Size::MAX), SizeConstraints::ZERO);
476    }
477
478    #[test]
479    fn default_has_no_intrinsic_size() {
480        let widget = PlainWidget;
481        assert!(!widget.has_intrinsic_size());
482    }
483
484    // --- Custom implementation tests ---
485
486    struct FixedSizeWidget {
487        width: u16,
488        height: u16,
489    }
490
491    impl MeasurableWidget for FixedSizeWidget {
492        fn measure(&self, _available: Size) -> SizeConstraints {
493            SizeConstraints::exact(Size::new(self.width, self.height))
494        }
495
496        fn has_intrinsic_size(&self) -> bool {
497            true
498        }
499    }
500
501    #[test]
502    fn custom_widget_measure() {
503        let widget = FixedSizeWidget {
504            width: 20,
505            height: 5,
506        };
507        let c = widget.measure(Size::MAX);
508
509        assert_eq!(c.min, Size::new(20, 5));
510        assert_eq!(c.preferred, Size::new(20, 5));
511        assert_eq!(c.max, Some(Size::new(20, 5)));
512    }
513
514    #[test]
515    fn custom_widget_has_intrinsic_size() {
516        let widget = FixedSizeWidget {
517            width: 10,
518            height: 3,
519        };
520        assert!(widget.has_intrinsic_size());
521    }
522
523    // --- Invariant tests (property-like) ---
524
525    #[test]
526    fn measure_is_pure_same_input_same_output() {
527        let widget = FixedSizeWidget {
528            width: 15,
529            height: 4,
530        };
531        let available = Size::new(100, 50);
532
533        let a = widget.measure(available);
534        let b = widget.measure(available);
535
536        assert_eq!(a, b, "measure() must be pure");
537    }
538
539    #[test]
540    fn size_constraints_invariant_min_le_preferred() {
541        // Verify a well-formed SizeConstraints
542        let c = SizeConstraints {
543            min: Size::new(5, 2),
544            preferred: Size::new(10, 5),
545            max: Some(Size::new(20, 10)),
546        };
547
548        assert!(
549            c.min.width <= c.preferred.width,
550            "min.width must <= preferred.width"
551        );
552        assert!(
553            c.min.height <= c.preferred.height,
554            "min.height must <= preferred.height"
555        );
556    }
557
558    #[test]
559    fn size_constraints_invariant_preferred_le_max() {
560        let c = SizeConstraints {
561            min: Size::new(5, 2),
562            preferred: Size::new(10, 5),
563            max: Some(Size::new(20, 10)),
564        };
565
566        if let Some(max) = c.max {
567            assert!(
568                c.preferred.width <= max.width,
569                "preferred.width must <= max.width"
570            );
571            assert!(
572                c.preferred.height <= max.height,
573                "preferred.height must <= max.height"
574            );
575        }
576    }
577
578    // --- Property tests (proptest) ---
579
580    mod property_tests {
581        use super::*;
582        use crate::paragraph::Paragraph;
583        use ftui_text::Text;
584        use proptest::prelude::*;
585
586        fn size_strategy() -> impl Strategy<Value = Size> {
587            (0u16..200, 0u16..100).prop_map(|(w, h)| Size::new(w, h))
588        }
589
590        fn text_strategy() -> impl Strategy<Value = String> {
591            "[a-zA-Z0-9 ]{0,200}".prop_map(|s| s.to_string())
592        }
593
594        proptest! {
595            #![proptest_config(ProptestConfig::with_cases(256))]
596
597            // Invariant: min <= preferred for both dimensions.
598            #[test]
599            fn paragraph_min_le_preferred(text in text_strategy(), available in size_strategy()) {
600                let para = Paragraph::new(Text::raw(text));
601                let c = para.measure(available);
602                prop_assert!(c.min.width <= c.preferred.width,
603                    "min.width {} > preferred.width {}", c.min.width, c.preferred.width);
604                prop_assert!(c.min.height <= c.preferred.height,
605                    "min.height {} > preferred.height {}", c.min.height, c.preferred.height);
606            }
607
608            // Invariant: preferred <= max when max is bounded.
609            #[test]
610            fn constraints_preferred_le_max(
611                min_w in 0u16..50,
612                min_h in 0u16..20,
613                pref_w in 1u16..100,
614                pref_h in 1u16..60,
615                max_w in 1u16..150,
616                max_h in 1u16..80,
617                input in size_strategy(),
618            ) {
619                let min = Size::new(min_w, min_h);
620                let preferred = Size::new(pref_w.max(min_w), pref_h.max(min_h));
621                let max = Size::new(max_w.max(preferred.width), max_h.max(preferred.height));
622
623                let c = SizeConstraints {
624                    min,
625                    preferred,
626                    max: Some(max),
627                };
628
629                // Clamp should never exceed max.
630                let clamped = c.clamp(input);
631                prop_assert!(clamped.width <= max.width);
632                prop_assert!(clamped.height <= max.height);
633
634                // Preferred is always <= max.
635                prop_assert!(c.preferred.width <= max.width);
636                prop_assert!(c.preferred.height <= max.height);
637            }
638
639            // Invariant: measure() is pure for the same inputs.
640            #[test]
641            fn paragraph_measure_is_pure(text in text_strategy(), available in size_strategy()) {
642                let para = Paragraph::new(Text::raw(text));
643                let c1 = para.measure(available);
644                let c2 = para.measure(available);
645                prop_assert_eq!(c1, c2);
646            }
647
648            // Invariant: min size does not depend on available size.
649            #[test]
650            fn paragraph_min_constant(text in text_strategy(), a in size_strategy(), b in size_strategy()) {
651                let para = Paragraph::new(Text::raw(text));
652                let c1 = para.measure(a);
653                let c2 = para.measure(b);
654                prop_assert_eq!(c1.min, c2.min);
655            }
656
657            // Invariant: clamp is idempotent.
658            #[test]
659            fn clamp_is_idempotent(
660                min_w in 0u16..50, min_h in 0u16..20,
661                pref_w in 1u16..120, pref_h in 1u16..80,
662                max_w in 1u16..200, max_h in 1u16..120,
663                input in size_strategy(),
664            ) {
665                let min = Size::new(min_w, min_h);
666                let preferred = Size::new(pref_w.max(min_w), pref_h.max(min_h));
667                let max = Size::new(max_w.max(preferred.width), max_h.max(preferred.height));
668                let c = SizeConstraints { min, preferred, max: Some(max) };
669
670                let clamped = c.clamp(input);
671                let clamped_again = c.clamp(clamped);
672                prop_assert_eq!(clamped, clamped_again);
673            }
674        }
675    }
676}