use ftui_core::geometry::Size;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SizeConstraints {
pub min: Size,
pub preferred: Size,
pub max: Option<Size>,
}
impl SizeConstraints {
pub const ZERO: Self = Self {
min: Size::ZERO,
preferred: Size::ZERO,
max: None,
};
#[inline]
pub const fn exact(size: Size) -> Self {
Self {
min: size,
preferred: size,
max: Some(size),
}
}
#[inline]
pub const fn at_least(min: Size, preferred: Size) -> Self {
Self {
min,
preferred,
max: None,
}
}
pub fn clamp(&self, size: Size) -> Size {
let max = self.max.unwrap_or(Size::MAX);
let width = if size.width < self.min.width {
self.min.width
} else if size.width > max.width {
max.width
} else {
size.width
};
let height = if size.height < self.min.height {
self.min.height
} else if size.height > max.height {
max.height
} else {
size.height
};
Size::new(width, height)
}
#[inline]
pub fn is_satisfied_by(&self, size: Size) -> bool {
let max = self.max.unwrap_or(Size::MAX);
size.width >= self.min.width
&& size.height >= self.min.height
&& size.width <= max.width
&& size.height <= max.height
}
pub fn intersect(&self, other: &SizeConstraints) -> SizeConstraints {
let min_width = self.min.width.max(other.min.width);
let min_height = self.min.height.max(other.min.height);
let max = match (self.max, other.max) {
(Some(a), Some(b)) => Some(Size::new(a.width.min(b.width), a.height.min(b.height))),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
let preferred_width = self.preferred.width.max(other.preferred.width);
let preferred_height = self.preferred.height.max(other.preferred.height);
let preferred = Size::new(preferred_width, preferred_height);
SizeConstraints {
min: Size::new(min_width, min_height),
preferred,
max,
}
}
}
impl Default for SizeConstraints {
fn default() -> Self {
Self::ZERO
}
}
pub trait MeasurableWidget {
fn measure(&self, available: Size) -> SizeConstraints {
let _ = available; SizeConstraints::ZERO
}
fn has_intrinsic_size(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn size_constraints_zero_is_default() {
assert_eq!(SizeConstraints::default(), SizeConstraints::ZERO);
}
#[test]
fn size_constraints_exact() {
let c = SizeConstraints::exact(Size::new(10, 5));
assert_eq!(c.min, Size::new(10, 5));
assert_eq!(c.preferred, Size::new(10, 5));
assert_eq!(c.max, Some(Size::new(10, 5)));
}
#[test]
fn size_constraints_at_least() {
let c = SizeConstraints::at_least(Size::new(5, 2), Size::new(10, 4));
assert_eq!(c.min, Size::new(5, 2));
assert_eq!(c.preferred, Size::new(10, 4));
assert_eq!(c.max, None);
}
#[test]
fn size_constraints_clamp_below_min() {
let c = SizeConstraints {
min: Size::new(5, 2),
preferred: Size::new(10, 5),
max: Some(Size::new(20, 10)),
};
assert_eq!(c.clamp(Size::new(3, 1)), Size::new(5, 2));
}
#[test]
fn size_constraints_clamp_in_range() {
let c = SizeConstraints {
min: Size::new(5, 2),
preferred: Size::new(10, 5),
max: Some(Size::new(20, 10)),
};
assert_eq!(c.clamp(Size::new(15, 7)), Size::new(15, 7));
}
#[test]
fn size_constraints_clamp_above_max() {
let c = SizeConstraints {
min: Size::new(5, 2),
preferred: Size::new(10, 5),
max: Some(Size::new(20, 10)),
};
assert_eq!(c.clamp(Size::new(30, 20)), Size::new(20, 10));
}
#[test]
fn size_constraints_clamp_no_max() {
let c = SizeConstraints {
min: Size::new(5, 2),
preferred: Size::new(10, 5),
max: None,
};
assert_eq!(c.clamp(Size::new(1000, 500)), Size::new(1000, 500));
assert_eq!(c.clamp(Size::new(2, 1)), Size::new(5, 2));
}
#[test]
fn size_constraints_is_satisfied_by() {
let c = SizeConstraints {
min: Size::new(5, 2),
preferred: Size::new(10, 5),
max: Some(Size::new(20, 10)),
};
assert!(c.is_satisfied_by(Size::new(10, 5)));
assert!(c.is_satisfied_by(Size::new(5, 2))); assert!(c.is_satisfied_by(Size::new(20, 10)));
assert!(!c.is_satisfied_by(Size::new(4, 2))); assert!(!c.is_satisfied_by(Size::new(5, 1))); assert!(!c.is_satisfied_by(Size::new(21, 10))); assert!(!c.is_satisfied_by(Size::new(20, 11))); }
#[test]
fn size_constraints_is_satisfied_by_no_max() {
let c = SizeConstraints {
min: Size::new(5, 2),
preferred: Size::new(10, 5),
max: None,
};
assert!(c.is_satisfied_by(Size::new(1000, 500))); assert!(!c.is_satisfied_by(Size::new(4, 2))); }
#[test]
fn size_constraints_intersect_both_bounded() {
let a = SizeConstraints {
min: Size::new(5, 2),
preferred: Size::new(10, 5),
max: Some(Size::new(20, 10)),
};
let b = SizeConstraints {
min: Size::new(8, 3),
preferred: Size::new(12, 6),
max: Some(Size::new(15, 8)),
};
let c = a.intersect(&b);
assert_eq!(c.min, Size::new(8, 3));
assert_eq!(c.max, Some(Size::new(15, 8)));
assert_eq!(c.preferred, Size::new(12, 6));
}
#[test]
fn size_constraints_intersect_one_unbounded() {
let bounded = SizeConstraints {
min: Size::new(5, 2),
preferred: Size::new(10, 5),
max: Some(Size::new(20, 10)),
};
let unbounded = SizeConstraints {
min: Size::new(8, 1),
preferred: Size::new(15, 3),
max: None,
};
let c = bounded.intersect(&unbounded);
assert_eq!(c.min, Size::new(8, 2)); assert_eq!(c.max, Some(Size::new(20, 10))); assert_eq!(c.preferred, Size::new(15, 5)); }
#[test]
fn size_constraints_intersect_both_unbounded() {
let a = SizeConstraints::at_least(Size::new(5, 2), Size::new(10, 5));
let b = SizeConstraints::at_least(Size::new(8, 3), Size::new(12, 6));
let c = a.intersect(&b);
assert_eq!(c.min, Size::new(8, 3));
assert_eq!(c.max, None);
assert_eq!(c.preferred, Size::new(12, 6));
}
struct PlainWidget;
impl MeasurableWidget for PlainWidget {}
#[test]
fn default_measure_returns_zero() {
let widget = PlainWidget;
assert_eq!(widget.measure(Size::MAX), SizeConstraints::ZERO);
}
#[test]
fn default_has_no_intrinsic_size() {
let widget = PlainWidget;
assert!(!widget.has_intrinsic_size());
}
struct FixedSizeWidget {
width: u16,
height: u16,
}
impl MeasurableWidget for FixedSizeWidget {
fn measure(&self, _available: Size) -> SizeConstraints {
SizeConstraints::exact(Size::new(self.width, self.height))
}
fn has_intrinsic_size(&self) -> bool {
true
}
}
#[test]
fn custom_widget_measure() {
let widget = FixedSizeWidget {
width: 20,
height: 5,
};
let c = widget.measure(Size::MAX);
assert_eq!(c.min, Size::new(20, 5));
assert_eq!(c.preferred, Size::new(20, 5));
assert_eq!(c.max, Some(Size::new(20, 5)));
}
#[test]
fn custom_widget_has_intrinsic_size() {
let widget = FixedSizeWidget {
width: 10,
height: 3,
};
assert!(widget.has_intrinsic_size());
}
#[test]
fn measure_is_pure_same_input_same_output() {
let widget = FixedSizeWidget {
width: 15,
height: 4,
};
let available = Size::new(100, 50);
let a = widget.measure(available);
let b = widget.measure(available);
assert_eq!(a, b, "measure() must be pure");
}
#[test]
fn size_constraints_invariant_min_le_preferred() {
let c = SizeConstraints {
min: Size::new(5, 2),
preferred: Size::new(10, 5),
max: Some(Size::new(20, 10)),
};
assert!(
c.min.width <= c.preferred.width,
"min.width must <= preferred.width"
);
assert!(
c.min.height <= c.preferred.height,
"min.height must <= preferred.height"
);
}
#[test]
fn size_constraints_invariant_preferred_le_max() {
let c = SizeConstraints {
min: Size::new(5, 2),
preferred: Size::new(10, 5),
max: Some(Size::new(20, 10)),
};
if let Some(max) = c.max {
assert!(
c.preferred.width <= max.width,
"preferred.width must <= max.width"
);
assert!(
c.preferred.height <= max.height,
"preferred.height must <= max.height"
);
}
}
mod property_tests {
use super::*;
use crate::paragraph::Paragraph;
use ftui_text::Text;
use proptest::prelude::*;
fn size_strategy() -> impl Strategy<Value = Size> {
(0u16..200, 0u16..100).prop_map(|(w, h)| Size::new(w, h))
}
fn text_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9 ]{0,200}".prop_map(|s| s.to_string())
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn paragraph_min_le_preferred(text in text_strategy(), available in size_strategy()) {
let para = Paragraph::new(Text::raw(text));
let c = para.measure(available);
prop_assert!(c.min.width <= c.preferred.width,
"min.width {} > preferred.width {}", c.min.width, c.preferred.width);
prop_assert!(c.min.height <= c.preferred.height,
"min.height {} > preferred.height {}", c.min.height, c.preferred.height);
}
#[test]
fn constraints_preferred_le_max(
min_w in 0u16..50,
min_h in 0u16..20,
pref_w in 1u16..100,
pref_h in 1u16..60,
max_w in 1u16..150,
max_h in 1u16..80,
input in size_strategy(),
) {
let min = Size::new(min_w, min_h);
let preferred = Size::new(pref_w.max(min_w), pref_h.max(min_h));
let max = Size::new(max_w.max(preferred.width), max_h.max(preferred.height));
let c = SizeConstraints {
min,
preferred,
max: Some(max),
};
let clamped = c.clamp(input);
prop_assert!(clamped.width <= max.width);
prop_assert!(clamped.height <= max.height);
prop_assert!(c.preferred.width <= max.width);
prop_assert!(c.preferred.height <= max.height);
}
#[test]
fn paragraph_measure_is_pure(text in text_strategy(), available in size_strategy()) {
let para = Paragraph::new(Text::raw(text));
let c1 = para.measure(available);
let c2 = para.measure(available);
prop_assert_eq!(c1, c2);
}
#[test]
fn paragraph_min_constant(text in text_strategy(), a in size_strategy(), b in size_strategy()) {
let para = Paragraph::new(Text::raw(text));
let c1 = para.measure(a);
let c2 = para.measure(b);
prop_assert_eq!(c1.min, c2.min);
}
#[test]
fn clamp_is_idempotent(
min_w in 0u16..50, min_h in 0u16..20,
pref_w in 1u16..120, pref_h in 1u16..80,
max_w in 1u16..200, max_h in 1u16..120,
input in size_strategy(),
) {
let min = Size::new(min_w, min_h);
let preferred = Size::new(pref_w.max(min_w), pref_h.max(min_h));
let max = Size::new(max_w.max(preferred.width), max_h.max(preferred.height));
let c = SizeConstraints { min, preferred, max: Some(max) };
let clamped = c.clamp(input);
let clamped_again = c.clamp(clamped);
prop_assert_eq!(clamped, clamped_again);
}
}
}
}