Skip to main content

appcore_filemaker/
constraints.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: constraints.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded constraints contracts and behavior for this crate.
12
13use serde::{Deserialize, Serialize};
14
15use crate::{ErrorCode, FileMakerError, Length, Result, Size, Unit};
16
17/// Alignment of an element inside its resolved container.
18#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum Alignment {
21    /// Align to the container's leading edge.
22    Start,
23    /// Center on the selected axis.
24    Center,
25    /// Align to the container's trailing edge.
26    End,
27}
28
29/// Distribution of fixed-size children along a flow's primary axis.
30#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum Distribution {
33    /// Start at the leading edge and retain the declared gap.
34    #[default]
35    Start,
36    /// Center the complete flow.
37    Center,
38    /// Place the complete flow at the trailing edge.
39    End,
40    /// Distribute remaining space only between children.
41    SpaceBetween,
42    /// Distribute remaining space around children using half edge spaces.
43    SpaceAround,
44    /// Distribute equal space at edges and between children.
45    SpaceEvenly,
46}
47
48/// Bounded size intent applied before measurement and collision.
49#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
50#[serde(default, deny_unknown_fields)]
51pub struct LayoutConstraints {
52    /// Minimum width.
53    pub min_width: Option<Length>,
54    /// Preferred width used when `width` is absent.
55    pub preferred_width: Option<Length>,
56    /// Maximum width.
57    pub max_width: Option<Length>,
58    /// Minimum height.
59    pub min_height: Option<Length>,
60    /// Preferred height used when `height` is absent.
61    pub preferred_height: Option<Length>,
62    /// Maximum height.
63    pub max_height: Option<Length>,
64    /// Width divided by height, in millionths.
65    pub aspect_ratio: Option<u32>,
66}
67
68impl LayoutConstraints {
69    pub(crate) fn validate(&self) -> Result<()> {
70        if self.aspect_ratio == Some(0)
71            || [
72                self.min_width,
73                self.preferred_width,
74                self.max_width,
75                self.min_height,
76                self.preferred_height,
77                self.max_height,
78            ]
79            .into_iter()
80            .flatten()
81            .any(|length| matches!(length, Length::Auto))
82        {
83            return Err(constraint_error(
84                "layout constraints require concrete lengths and a positive aspect ratio",
85            ));
86        }
87        Ok(())
88    }
89}
90
91pub(crate) fn resolve_constrained_size(
92    explicit_width: Option<Length>,
93    explicit_height: Option<Length>,
94    constraints: LayoutConstraints,
95    container: Size,
96    default_size: Size,
97    logical_unit: Unit,
98) -> Result<Size> {
99    constraints.validate()?;
100    let width_intent = resolve_optional(explicit_width, container.width, logical_unit)?.or(
101        resolve_optional(constraints.preferred_width, container.width, logical_unit)?,
102    );
103    let height_intent = resolve_optional(explicit_height, container.height, logical_unit)?.or(
104        resolve_optional(constraints.preferred_height, container.height, logical_unit)?,
105    );
106    let width_range = resolve_range(
107        constraints.min_width,
108        constraints.max_width,
109        container.width,
110        logical_unit,
111    )?;
112    let height_range = resolve_range(
113        constraints.min_height,
114        constraints.max_height,
115        container.height,
116        logical_unit,
117    )?;
118    let mut width = clamp(width_intent.unwrap_or(default_size.width), width_range)?;
119    let mut height = clamp(height_intent.unwrap_or(default_size.height), height_range)?;
120    if let Some(ratio) = constraints.aspect_ratio {
121        match (width_intent, height_intent) {
122            (Some(_), Some(_)) => validate_ratio(width, height, ratio)?,
123            (None, Some(_)) => {
124                width = clamp(height.checked_scale(i64::from(ratio))?, width_range)?;
125                validate_ratio(width, height, ratio)?;
126            }
127            (Some(_), None) | (None, None) => {
128                height = clamp(
129                    Unit::from_ratio(i128::from(width.raw()), i128::from(ratio))?,
130                    height_range,
131                )?;
132                validate_ratio(width, height, ratio)?;
133            }
134        }
135    }
136    Size::new(width, height)
137}
138
139fn resolve_optional(
140    value: Option<Length>,
141    percent_base: Unit,
142    logical_unit: Unit,
143) -> Result<Option<Unit>> {
144    value.map_or(Ok(None), |length| {
145        length.resolve(percent_base, logical_unit)
146    })
147}
148
149fn resolve_range(
150    minimum: Option<Length>,
151    maximum: Option<Length>,
152    percent_base: Unit,
153    logical_unit: Unit,
154) -> Result<(Option<Unit>, Option<Unit>)> {
155    let minimum = resolve_optional(minimum, percent_base, logical_unit)?;
156    let maximum = resolve_optional(maximum, percent_base, logical_unit)?;
157    if minimum.zip(maximum).is_some_and(|(min, max)| min > max) {
158        return Err(constraint_error(
159            "minimum layout constraint exceeds maximum",
160        ));
161    }
162    Ok((minimum, maximum))
163}
164
165fn clamp(value: Unit, range: (Option<Unit>, Option<Unit>)) -> Result<Unit> {
166    let value = range.0.map_or(value, |minimum| value.max(minimum));
167    let value = range.1.map_or(value, |maximum| value.min(maximum));
168    if value < Unit::ZERO {
169        return Err(constraint_error(
170            "resolved constraint size cannot be negative",
171        ));
172    }
173    Ok(value)
174}
175
176fn validate_ratio(width: Unit, height: Unit, ratio: u32) -> Result<()> {
177    if height <= Unit::ZERO {
178        return Err(constraint_error("aspect ratio requires positive height"));
179    }
180    let expected = height.checked_scale(i64::from(ratio))?;
181    if width.raw().abs_diff(expected.raw()) > 1 {
182        return Err(constraint_error(
183            "resolved min/max constraints conflict with aspect ratio",
184        ));
185    }
186    Ok(())
187}
188
189fn constraint_error(message: impl Into<String>) -> FileMakerError {
190    FileMakerError::new(ErrorCode::LayoutInvalid, message)
191}