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}