Skip to main content

astrelis_ui/
constraint.rs

1//! Advanced constraint expressions for responsive UI layouts.
2//!
3//! This module provides CSS-like constraint expressions:
4//! - `calc()` - Arithmetic expressions like `calc(100% - 40px)`
5//! - `min()` - Minimum value: `min(50%, 400px)`
6//! - `max()` - Maximum value: `max(200px, 30%)`
7//! - `clamp()` - Bounded value: `clamp(100px, 50%, 800px)`
8//!
9//! # Examples
10//!
11//! ```ignore
12//! use astrelis_ui::constraint::{Constraint, CalcExpr};
13//!
14//! // calc(100% - 40px)
15//! let width = Constraint::Calc(Box::new(
16//!     CalcExpr::Sub(
17//!         Box::new(CalcExpr::Value(Constraint::Percent(100.0))),
18//!         Box::new(CalcExpr::Value(Constraint::Px(40.0))),
19//!     )
20//! ));
21//!
22//! // min(50%, 400px)
23//! let min_width = Constraint::Min(vec![
24//!     Constraint::Percent(50.0),
25//!     Constraint::Px(400.0),
26//! ]);
27//!
28//! // clamp(100px, 50%, 800px)
29//! let clamped = Constraint::Clamp {
30//!     min: Box::new(Constraint::Px(100.0)),
31//!     val: Box::new(Constraint::Percent(50.0)),
32//!     max: Box::new(Constraint::Px(800.0)),
33//! };
34//! ```
35
36/// A constraint expression representing a responsive dimension value.
37///
38/// Constraints can be simple values (pixels, percentages, viewport units)
39/// or complex expressions (calc, min, max, clamp).
40#[derive(Debug, Clone, PartialEq, Default)]
41pub enum Constraint {
42    /// Fixed pixel value.
43    Px(f32),
44
45    /// Percentage of parent dimension.
46    Percent(f32),
47
48    /// Automatic sizing based on content.
49    #[default]
50    Auto,
51
52    /// Percentage of viewport width.
53    Vw(f32),
54
55    /// Percentage of viewport height.
56    Vh(f32),
57
58    /// Percentage of minimum viewport dimension.
59    Vmin(f32),
60
61    /// Percentage of maximum viewport dimension.
62    Vmax(f32),
63
64    /// Calculated expression (arithmetic on other constraints).
65    Calc(Box<CalcExpr>),
66
67    /// Minimum of multiple constraints.
68    Min(Vec<Constraint>),
69
70    /// Maximum of multiple constraints.
71    Max(Vec<Constraint>),
72
73    /// Clamped value between min and max.
74    Clamp {
75        /// Minimum value.
76        min: Box<Constraint>,
77        /// Preferred value.
78        val: Box<Constraint>,
79        /// Maximum value.
80        max: Box<Constraint>,
81    },
82}
83
84impl Constraint {
85    /// Create a pixel constraint.
86    #[inline]
87    pub fn px(value: f32) -> Self {
88        Self::Px(value)
89    }
90
91    /// Create a percentage constraint.
92    #[inline]
93    pub fn percent(value: f32) -> Self {
94        Self::Percent(value)
95    }
96
97    /// Create a viewport width constraint.
98    #[inline]
99    pub fn vw(value: f32) -> Self {
100        Self::Vw(value)
101    }
102
103    /// Create a viewport height constraint.
104    #[inline]
105    pub fn vh(value: f32) -> Self {
106        Self::Vh(value)
107    }
108
109    /// Create a viewport min constraint.
110    #[inline]
111    pub fn vmin(value: f32) -> Self {
112        Self::Vmin(value)
113    }
114
115    /// Create a viewport max constraint.
116    #[inline]
117    pub fn vmax(value: f32) -> Self {
118        Self::Vmax(value)
119    }
120
121    /// Create a calc expression constraint.
122    pub fn calc(expr: CalcExpr) -> Self {
123        Self::Calc(Box::new(expr.simplify()))
124    }
125
126    /// Create a minimum constraint.
127    pub fn min(values: Vec<Constraint>) -> Self {
128        debug_assert!(!values.is_empty(), "min() requires at least one value");
129        Self::Min(values)
130    }
131
132    /// Create a maximum constraint.
133    pub fn max(values: Vec<Constraint>) -> Self {
134        debug_assert!(!values.is_empty(), "max() requires at least one value");
135        Self::Max(values)
136    }
137
138    /// Create a clamp constraint.
139    pub fn clamp(min: Constraint, val: Constraint, max: Constraint) -> Self {
140        Self::Clamp {
141            min: Box::new(min),
142            val: Box::new(val),
143            max: Box::new(max),
144        }
145    }
146
147    /// Check if this constraint is a simple (non-expression) value.
148    pub fn is_simple(&self) -> bool {
149        matches!(
150            self,
151            Self::Px(_)
152                | Self::Percent(_)
153                | Self::Auto
154                | Self::Vw(_)
155                | Self::Vh(_)
156                | Self::Vmin(_)
157                | Self::Vmax(_)
158        )
159    }
160
161    /// Check if this constraint contains any viewport units.
162    pub fn has_viewport_units(&self) -> bool {
163        match self {
164            Self::Vw(_) | Self::Vh(_) | Self::Vmin(_) | Self::Vmax(_) => true,
165            Self::Calc(expr) => expr.has_viewport_units(),
166            Self::Min(values) | Self::Max(values) => values.iter().any(|c| c.has_viewport_units()),
167            Self::Clamp { min, val, max } => {
168                min.has_viewport_units() || val.has_viewport_units() || max.has_viewport_units()
169            }
170            _ => false,
171        }
172    }
173
174    /// Check if this constraint contains percentages (requires parent size).
175    pub fn has_percentages(&self) -> bool {
176        match self {
177            Self::Percent(_) => true,
178            Self::Calc(expr) => expr.has_percentages(),
179            Self::Min(values) | Self::Max(values) => values.iter().any(|c| c.has_percentages()),
180            Self::Clamp { min, val, max } => {
181                min.has_percentages() || val.has_percentages() || max.has_percentages()
182            }
183            _ => false,
184        }
185    }
186}
187
188impl From<f32> for Constraint {
189    fn from(value: f32) -> Self {
190        Self::Px(value)
191    }
192}
193
194// =============================================================================
195// Length conversions (for backward compatibility)
196// =============================================================================
197
198impl From<crate::length::Length> for Constraint {
199    fn from(length: crate::length::Length) -> Self {
200        match length {
201            crate::length::Length::Px(v) => Self::Px(v),
202            crate::length::Length::Percent(v) => Self::Percent(v),
203            crate::length::Length::Auto => Self::Auto,
204            crate::length::Length::Vw(v) => Self::Vw(v),
205            crate::length::Length::Vh(v) => Self::Vh(v),
206            crate::length::Length::Vmin(v) => Self::Vmin(v),
207            crate::length::Length::Vmax(v) => Self::Vmax(v),
208        }
209    }
210}
211
212impl From<crate::length::LengthAuto> for Constraint {
213    fn from(length: crate::length::LengthAuto) -> Self {
214        match length {
215            crate::length::LengthAuto::Px(v) => Self::Px(v),
216            crate::length::LengthAuto::Percent(v) => Self::Percent(v),
217            crate::length::LengthAuto::Auto => Self::Auto,
218            crate::length::LengthAuto::Vw(v) => Self::Vw(v),
219            crate::length::LengthAuto::Vh(v) => Self::Vh(v),
220            crate::length::LengthAuto::Vmin(v) => Self::Vmin(v),
221            crate::length::LengthAuto::Vmax(v) => Self::Vmax(v),
222        }
223    }
224}
225
226impl From<crate::length::LengthPercentage> for Constraint {
227    fn from(length: crate::length::LengthPercentage) -> Self {
228        match length {
229            crate::length::LengthPercentage::Px(v) => Self::Px(v),
230            crate::length::LengthPercentage::Percent(v) => Self::Percent(v),
231            crate::length::LengthPercentage::Vw(v) => Self::Vw(v),
232            crate::length::LengthPercentage::Vh(v) => Self::Vh(v),
233            crate::length::LengthPercentage::Vmin(v) => Self::Vmin(v),
234            crate::length::LengthPercentage::Vmax(v) => Self::Vmax(v),
235        }
236    }
237}
238
239// =============================================================================
240// Taffy conversions
241// =============================================================================
242
243impl Constraint {
244    /// Convert to Taffy Dimension.
245    ///
246    /// # Note
247    /// This only works for simple constraints (Px, Percent, Auto).
248    /// For viewport units (Vw, Vh, Vmin, Vmax), you must resolve them first
249    /// using `ConstraintResolver::resolve()` with a viewport context.
250    /// For complex constraints (Calc, Min, Max, Clamp), you must resolve
251    /// them first using `ConstraintResolver::resolve()`.
252    ///
253    /// # Panics
254    /// Panics if called on viewport-relative units or complex constraints.
255    /// Use `try_to_dimension()` for fallible conversion.
256    pub fn to_dimension(&self) -> taffy::Dimension {
257        match self {
258            Constraint::Px(v) => taffy::Dimension::Length(*v),
259            Constraint::Percent(v) => taffy::Dimension::Percent(*v / 100.0),
260            Constraint::Auto => taffy::Dimension::Auto,
261            Constraint::Vw(_) | Constraint::Vh(_) | Constraint::Vmin(_) | Constraint::Vmax(_) => {
262                panic!(
263                    "Viewport-relative constraints must be resolved to pixels before converting to Taffy dimension. \
264                     Use ConstraintResolver::resolve() first."
265                );
266            }
267            Constraint::Calc(_)
268            | Constraint::Min(_)
269            | Constraint::Max(_)
270            | Constraint::Clamp { .. } => {
271                panic!(
272                    "Complex constraints (calc/min/max/clamp) must be resolved to pixels before converting to Taffy dimension. \
273                     Use ConstraintResolver::resolve() first."
274                );
275            }
276        }
277    }
278
279    /// Try to convert to Taffy Dimension.
280    ///
281    /// Returns `None` for viewport units and complex constraints that need resolution.
282    pub fn try_to_dimension(&self) -> Option<taffy::Dimension> {
283        match self {
284            Constraint::Px(v) => Some(taffy::Dimension::Length(*v)),
285            Constraint::Percent(v) => Some(taffy::Dimension::Percent(*v / 100.0)),
286            Constraint::Auto => Some(taffy::Dimension::Auto),
287            _ => None,
288        }
289    }
290
291    /// Try to convert to Taffy LengthPercentageAuto.
292    ///
293    /// Returns `None` for viewport units and complex constraints that need resolution.
294    pub fn try_to_length_percentage_auto(&self) -> Option<taffy::LengthPercentageAuto> {
295        match self {
296            Constraint::Px(v) => Some(taffy::LengthPercentageAuto::Length(*v)),
297            Constraint::Percent(v) => Some(taffy::LengthPercentageAuto::Percent(*v / 100.0)),
298            Constraint::Auto => Some(taffy::LengthPercentageAuto::Auto),
299            _ => None,
300        }
301    }
302
303    /// Try to convert to Taffy LengthPercentage.
304    ///
305    /// Returns `None` for Auto, viewport units, and complex constraints.
306    pub fn try_to_length_percentage(&self) -> Option<taffy::LengthPercentage> {
307        match self {
308            Constraint::Px(v) => Some(taffy::LengthPercentage::Length(*v)),
309            Constraint::Percent(v) => Some(taffy::LengthPercentage::Percent(*v / 100.0)),
310            _ => None,
311        }
312    }
313
314    /// Convert to Taffy LengthPercentageAuto.
315    ///
316    /// # Panics
317    /// Panics if called on viewport-relative units or complex constraints.
318    pub fn to_length_percentage_auto(&self) -> taffy::LengthPercentageAuto {
319        match self {
320            Constraint::Px(v) => taffy::LengthPercentageAuto::Length(*v),
321            Constraint::Percent(v) => taffy::LengthPercentageAuto::Percent(*v / 100.0),
322            Constraint::Auto => taffy::LengthPercentageAuto::Auto,
323            Constraint::Vw(_) | Constraint::Vh(_) | Constraint::Vmin(_) | Constraint::Vmax(_) => {
324                panic!("Viewport-relative constraints must be resolved to pixels first.");
325            }
326            Constraint::Calc(_)
327            | Constraint::Min(_)
328            | Constraint::Max(_)
329            | Constraint::Clamp { .. } => {
330                panic!("Complex constraints must be resolved to pixels first.");
331            }
332        }
333    }
334
335    /// Convert to Taffy LengthPercentage.
336    ///
337    /// # Panics
338    /// Panics if called on Auto, viewport-relative units, or complex constraints.
339    pub fn to_length_percentage(&self) -> taffy::LengthPercentage {
340        match self {
341            Constraint::Px(v) => taffy::LengthPercentage::Length(*v),
342            Constraint::Percent(v) => taffy::LengthPercentage::Percent(*v / 100.0),
343            Constraint::Auto => panic!("Auto is not valid for LengthPercentage"),
344            Constraint::Vw(_) | Constraint::Vh(_) | Constraint::Vmin(_) | Constraint::Vmax(_) => {
345                panic!("Viewport-relative constraints must be resolved to pixels first.");
346            }
347            Constraint::Calc(_)
348            | Constraint::Min(_)
349            | Constraint::Max(_)
350            | Constraint::Clamp { .. } => {
351                panic!("Complex constraints must be resolved to pixels first.");
352            }
353        }
354    }
355
356    /// Check if this constraint requires viewport resolution.
357    ///
358    /// Returns true if this constraint needs to be resolved with a viewport context
359    /// (either because it uses viewport units or is a complex expression).
360    pub fn needs_resolution(&self) -> bool {
361        self.try_to_dimension().is_none()
362    }
363}
364
365impl From<Constraint> for taffy::Dimension {
366    fn from(constraint: Constraint) -> Self {
367        constraint.to_dimension()
368    }
369}
370
371/// A calculation expression AST node.
372///
373/// Used inside `Constraint::Calc` to represent arithmetic operations.
374#[derive(Debug, Clone, PartialEq)]
375pub enum CalcExpr {
376    /// A terminal constraint value.
377    Value(Constraint),
378
379    /// Addition of two expressions.
380    Add(Box<CalcExpr>, Box<CalcExpr>),
381
382    /// Subtraction of two expressions.
383    Sub(Box<CalcExpr>, Box<CalcExpr>),
384
385    /// Multiplication by a scalar.
386    Mul(Box<CalcExpr>, f32),
387
388    /// Division by a scalar.
389    Div(Box<CalcExpr>, f32),
390}
391
392impl CalcExpr {
393    /// Create a value expression.
394    pub fn value(constraint: Constraint) -> Self {
395        Self::Value(constraint)
396    }
397
398    /// Simplify the expression by constant folding.
399    ///
400    /// This optimizes expressions like `px(10) + px(20)` to `px(30)`.
401    pub fn simplify(self) -> Self {
402        match self {
403            // Add: try to fold if both sides are Px
404            Self::Add(lhs, rhs) => {
405                let lhs = lhs.simplify();
406                let rhs = rhs.simplify();
407
408                match (&lhs, &rhs) {
409                    // px + px = px
410                    (Self::Value(Constraint::Px(a)), Self::Value(Constraint::Px(b))) => {
411                        Self::Value(Constraint::Px(a + b))
412                    }
413                    // 0 + x = x
414                    (Self::Value(Constraint::Px(0.0)), _) => rhs,
415                    // x + 0 = x
416                    (_, Self::Value(Constraint::Px(0.0))) => lhs,
417                    _ => Self::Add(Box::new(lhs), Box::new(rhs)),
418                }
419            }
420
421            // Sub: try to fold if both sides are Px
422            Self::Sub(lhs, rhs) => {
423                let lhs = lhs.simplify();
424                let rhs = rhs.simplify();
425
426                match (&lhs, &rhs) {
427                    // px - px = px
428                    (Self::Value(Constraint::Px(a)), Self::Value(Constraint::Px(b))) => {
429                        Self::Value(Constraint::Px(a - b))
430                    }
431                    // x - 0 = x
432                    (_, Self::Value(Constraint::Px(0.0))) => lhs,
433                    _ => Self::Sub(Box::new(lhs), Box::new(rhs)),
434                }
435            }
436
437            // Mul: fold scalar multiplication
438            Self::Mul(expr, scalar) => {
439                let expr = expr.simplify();
440
441                match &expr {
442                    // px * scalar = px
443                    Self::Value(Constraint::Px(v)) => Self::Value(Constraint::Px(v * scalar)),
444                    // x * 1 = x
445                    _ if (scalar - 1.0).abs() < f32::EPSILON => expr,
446                    // x * 0 = 0
447                    _ if scalar.abs() < f32::EPSILON => Self::Value(Constraint::Px(0.0)),
448                    _ => Self::Mul(Box::new(expr), scalar),
449                }
450            }
451
452            // Div: fold scalar division
453            Self::Div(expr, scalar) => {
454                let expr = expr.simplify();
455
456                match &expr {
457                    // px / scalar = px
458                    Self::Value(Constraint::Px(v)) => Self::Value(Constraint::Px(v / scalar)),
459                    // x / 1 = x
460                    _ if (scalar - 1.0).abs() < f32::EPSILON => expr,
461                    _ => Self::Div(Box::new(expr), scalar),
462                }
463            }
464
465            // Value: no simplification needed
466            Self::Value(c) => Self::Value(c),
467        }
468    }
469
470    /// Check if this expression contains any viewport units.
471    pub fn has_viewport_units(&self) -> bool {
472        match self {
473            Self::Value(c) => c.has_viewport_units(),
474            Self::Add(lhs, rhs) | Self::Sub(lhs, rhs) => {
475                lhs.has_viewport_units() || rhs.has_viewport_units()
476            }
477            Self::Mul(expr, _) | Self::Div(expr, _) => expr.has_viewport_units(),
478        }
479    }
480
481    /// Check if this expression contains percentages.
482    pub fn has_percentages(&self) -> bool {
483        match self {
484            Self::Value(c) => c.has_percentages(),
485            Self::Add(lhs, rhs) | Self::Sub(lhs, rhs) => {
486                lhs.has_percentages() || rhs.has_percentages()
487            }
488            Self::Mul(expr, _) | Self::Div(expr, _) => expr.has_percentages(),
489        }
490    }
491}
492
493impl From<Constraint> for CalcExpr {
494    fn from(constraint: Constraint) -> Self {
495        Self::Value(constraint)
496    }
497}
498
499impl From<f32> for CalcExpr {
500    fn from(value: f32) -> Self {
501        Self::Value(Constraint::Px(value))
502    }
503}
504
505// Operator implementations for ergonomic calc expressions
506
507impl std::ops::Add for CalcExpr {
508    type Output = Self;
509
510    fn add(self, rhs: Self) -> Self::Output {
511        Self::Add(Box::new(self), Box::new(rhs))
512    }
513}
514
515impl std::ops::Sub for CalcExpr {
516    type Output = Self;
517
518    fn sub(self, rhs: Self) -> Self::Output {
519        Self::Sub(Box::new(self), Box::new(rhs))
520    }
521}
522
523impl std::ops::Mul<f32> for CalcExpr {
524    type Output = Self;
525
526    fn mul(self, rhs: f32) -> Self::Output {
527        Self::Mul(Box::new(self), rhs)
528    }
529}
530
531impl std::ops::Div<f32> for CalcExpr {
532    type Output = Self;
533
534    fn div(self, rhs: f32) -> Self::Output {
535        Self::Div(Box::new(self), rhs)
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn test_constraint_constructors() {
545        assert_eq!(Constraint::px(100.0), Constraint::Px(100.0));
546        assert_eq!(Constraint::percent(50.0), Constraint::Percent(50.0));
547        assert_eq!(Constraint::vw(80.0), Constraint::Vw(80.0));
548        assert_eq!(Constraint::vh(60.0), Constraint::Vh(60.0));
549    }
550
551    #[test]
552    fn test_constraint_is_simple() {
553        assert!(Constraint::Px(100.0).is_simple());
554        assert!(Constraint::Percent(50.0).is_simple());
555        assert!(Constraint::Auto.is_simple());
556        assert!(Constraint::Vw(50.0).is_simple());
557
558        let calc = Constraint::calc(CalcExpr::Value(Constraint::Px(100.0)));
559        assert!(!calc.is_simple());
560
561        let min = Constraint::min(vec![Constraint::Px(100.0)]);
562        assert!(!min.is_simple());
563    }
564
565    #[test]
566    fn test_constraint_has_viewport_units() {
567        assert!(!Constraint::Px(100.0).has_viewport_units());
568        assert!(!Constraint::Percent(50.0).has_viewport_units());
569        assert!(Constraint::Vw(50.0).has_viewport_units());
570        assert!(Constraint::Vh(50.0).has_viewport_units());
571        assert!(Constraint::Vmin(50.0).has_viewport_units());
572        assert!(Constraint::Vmax(50.0).has_viewport_units());
573
574        let calc = Constraint::calc(CalcExpr::Value(Constraint::Vw(50.0)));
575        assert!(calc.has_viewport_units());
576
577        let min = Constraint::min(vec![Constraint::Px(100.0), Constraint::Vw(50.0)]);
578        assert!(min.has_viewport_units());
579    }
580
581    #[test]
582    fn test_calc_expr_simplify_add() {
583        // px + px = px
584        let expr = CalcExpr::Add(
585            Box::new(CalcExpr::Value(Constraint::Px(10.0))),
586            Box::new(CalcExpr::Value(Constraint::Px(20.0))),
587        );
588        assert_eq!(expr.simplify(), CalcExpr::Value(Constraint::Px(30.0)));
589
590        // 0 + x = x
591        let expr = CalcExpr::Add(
592            Box::new(CalcExpr::Value(Constraint::Px(0.0))),
593            Box::new(CalcExpr::Value(Constraint::Percent(50.0))),
594        );
595        assert_eq!(expr.simplify(), CalcExpr::Value(Constraint::Percent(50.0)));
596    }
597
598    #[test]
599    fn test_calc_expr_simplify_sub() {
600        // px - px = px
601        let expr = CalcExpr::Sub(
602            Box::new(CalcExpr::Value(Constraint::Px(30.0))),
603            Box::new(CalcExpr::Value(Constraint::Px(10.0))),
604        );
605        assert_eq!(expr.simplify(), CalcExpr::Value(Constraint::Px(20.0)));
606    }
607
608    #[test]
609    fn test_calc_expr_simplify_mul() {
610        // px * scalar = px
611        let expr = CalcExpr::Mul(Box::new(CalcExpr::Value(Constraint::Px(10.0))), 3.0);
612        assert_eq!(expr.simplify(), CalcExpr::Value(Constraint::Px(30.0)));
613
614        // x * 1 = x
615        let expr = CalcExpr::Mul(Box::new(CalcExpr::Value(Constraint::Percent(50.0))), 1.0);
616        assert_eq!(expr.simplify(), CalcExpr::Value(Constraint::Percent(50.0)));
617    }
618
619    #[test]
620    fn test_calc_expr_operators() {
621        let a = CalcExpr::from(Constraint::Percent(100.0));
622        let b = CalcExpr::from(Constraint::Px(40.0));
623
624        // calc(100% - 40px)
625        let result = (a - b).simplify();
626        match result {
627            CalcExpr::Sub(lhs, rhs) => {
628                assert_eq!(*lhs, CalcExpr::Value(Constraint::Percent(100.0)));
629                assert_eq!(*rhs, CalcExpr::Value(Constraint::Px(40.0)));
630            }
631            _ => panic!("Expected Sub expression"),
632        }
633    }
634}