Skip to main content

appcore_filemaker/
style.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: style.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 style contracts and behavior for this crate.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{ErrorCode, FileMakerError, Result, Unit};
18
19/// Format-neutral color retained until export.
20#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(tag = "space", rename_all = "snake_case")]
22pub enum Color {
23    /// Eight-bit RGB.
24    Rgb {
25        /// Red channel.
26        r: u8,
27        /// Green channel.
28        g: u8,
29        /// Blue channel.
30        b: u8,
31    },
32    /// Eight-bit RGB with alpha.
33    Rgba {
34        /// Red channel.
35        r: u8,
36        /// Green channel.
37        g: u8,
38        /// Blue channel.
39        b: u8,
40        /// Alpha channel.
41        a: u8,
42    },
43    /// Eight-bit grayscale.
44    Gray {
45        /// Gray channel.
46        value: u8,
47    },
48    /// CMYK channels in millionths.
49    Cmyk {
50        /// Cyan.
51        c: u32,
52        /// Magenta.
53        m: u32,
54        /// Yellow.
55        y: u32,
56        /// Black.
57        k: u32,
58    },
59}
60
61impl Color {
62    /// Parses hex, stable named colors, and integer `rgb`, `rgba`, `gray`, or
63    /// millionth-channel `cmyk` functions.
64    pub fn parse(source: &str) -> Result<Self> {
65        match source {
66            "black" => return Ok(Self::Rgb { r: 0, g: 0, b: 0 }),
67            "white" => {
68                return Ok(Self::Rgb {
69                    r: 255,
70                    g: 255,
71                    b: 255,
72                })
73            }
74            "red" => return Ok(Self::Rgb { r: 255, g: 0, b: 0 }),
75            "green" => return Ok(Self::Rgb { r: 0, g: 128, b: 0 }),
76            "blue" => return Ok(Self::Rgb { r: 0, g: 0, b: 255 }),
77            "transparent" => {
78                return Ok(Self::Rgba {
79                    r: 0,
80                    g: 0,
81                    b: 0,
82                    a: 0,
83                })
84            }
85            _ => {
86                if source.ends_with(')') {
87                    return parse_function_color(source);
88                }
89            }
90        }
91        let hex = source
92            .strip_prefix('#')
93            .ok_or_else(|| style_error("invalid color syntax"))?;
94        match hex.len() {
95            3 => Ok(Self::Rgb {
96                r: duplicate_nibble(hex, 0)?,
97                g: duplicate_nibble(hex, 1)?,
98                b: duplicate_nibble(hex, 2)?,
99            }),
100            6 => Ok(Self::Rgb {
101                r: hex_byte(hex, 0)?,
102                g: hex_byte(hex, 2)?,
103                b: hex_byte(hex, 4)?,
104            }),
105            8 => Ok(Self::Rgba {
106                r: hex_byte(hex, 0)?,
107                g: hex_byte(hex, 2)?,
108                b: hex_byte(hex, 4)?,
109                a: hex_byte(hex, 6)?,
110            }),
111            _ => Err(style_error("hex color requires 3, 6, or 8 digits")),
112        }
113    }
114
115    /// Validates channel ranges not enforced by their storage type.
116    pub fn validate(self) -> Result<()> {
117        if let Self::Cmyk { c, m, y, k } = self {
118            if [c, m, y, k].iter().any(|channel| *channel > 1_000_000) {
119                return Err(style_error("CMYK channels must be at most 1000000"));
120            }
121        }
122        Ok(())
123    }
124
125    /// Converts to RGBA for exporters that do not preserve CMYK.
126    #[must_use]
127    pub fn to_rgba(self) -> [u8; 4] {
128        match self {
129            Self::Rgb { r, g, b } => [r, g, b, 255],
130            Self::Rgba { r, g, b, a } => [r, g, b, a],
131            Self::Gray { value } => [value, value, value, 255],
132            Self::Cmyk { c, m, y, k } => {
133                let convert = |channel: u32| {
134                    let combined = channel.saturating_add(k).min(1_000_000);
135                    ((1_000_000 - combined) * 255 / 1_000_000) as u8
136                };
137                [convert(c), convert(m), convert(y), 255]
138            }
139        }
140    }
141}
142
143/// Partial style layer.
144#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
145pub struct Style {
146    /// Fill.
147    pub fill: Option<Color>,
148    /// Stroke.
149    pub stroke: Option<Color>,
150    /// Stroke width.
151    pub stroke_width: Option<Unit>,
152    /// Opacity in millionths.
153    pub opacity: Option<u32>,
154    /// Explicit font asset name.
155    pub font: Option<String>,
156    /// Font size.
157    pub font_size: Option<Unit>,
158    /// Text foreground.
159    pub color: Option<Color>,
160}
161
162/// Conditional partial style retained until typed data binding.
163#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
164pub struct ElementStyleRule {
165    /// Deterministic boolean expression.
166    pub when: String,
167    /// Partial style applied when the expression is truthy.
168    pub style: Style,
169}
170
171impl Style {
172    /// Validates field ranges.
173    pub fn validate(&self) -> Result<()> {
174        for color in [self.fill, self.stroke, self.color].into_iter().flatten() {
175            color.validate()?;
176        }
177        if self.opacity.is_some_and(|value| value > 1_000_000)
178            || self.stroke_width.is_some_and(|value| value < Unit::ZERO)
179            || self.font_size.is_some_and(|value| value <= Unit::ZERO)
180        {
181            return Err(style_error("style range is invalid"));
182        }
183        Ok(())
184    }
185
186    pub(crate) fn overlay(&mut self, next: &Self) {
187        if next.fill.is_some() {
188            self.fill = next.fill;
189        }
190        if next.stroke.is_some() {
191            self.stroke = next.stroke;
192        }
193        if next.stroke_width.is_some() {
194            self.stroke_width = next.stroke_width;
195        }
196        if next.opacity.is_some() {
197            self.opacity = next.opacity;
198        }
199        if next.font.is_some() {
200            self.font.clone_from(&next.font);
201        }
202        if next.font_size.is_some() {
203            self.font_size = next.font_size;
204        }
205        if next.color.is_some() {
206            self.color = next.color;
207        }
208    }
209}
210
211fn parse_function_color(source: &str) -> Result<Color> {
212    let (name, values) = source
213        .split_once('(')
214        .ok_or_else(|| style_error("invalid functional color syntax"))?;
215    let values = values
216        .strip_suffix(')')
217        .ok_or_else(|| style_error("invalid functional color syntax"))?
218        .split(',')
219        .map(|value| {
220            value
221                .trim()
222                .parse::<u32>()
223                .map_err(|_| style_error("functional color channels must be unsigned integers"))
224        })
225        .collect::<Result<Vec<_>>>()?;
226    match (name, values.as_slice()) {
227        ("rgb", [r, g, b]) => Ok(Color::Rgb {
228            r: channel_u8(*r)?,
229            g: channel_u8(*g)?,
230            b: channel_u8(*b)?,
231        }),
232        ("rgba", [r, g, b, a]) => Ok(Color::Rgba {
233            r: channel_u8(*r)?,
234            g: channel_u8(*g)?,
235            b: channel_u8(*b)?,
236            a: channel_u8(*a)?,
237        }),
238        ("gray", [value]) => Ok(Color::Gray {
239            value: channel_u8(*value)?,
240        }),
241        ("cmyk", [c, m, y, k]) if [c, m, y, k].iter().all(|value| **value <= 1_000_000) => {
242            Ok(Color::Cmyk {
243                c: *c,
244                m: *m,
245                y: *y,
246                k: *k,
247            })
248        }
249        ("cmyk", _) => Err(style_error("CMYK requires four channels up to 1000000")),
250        _ => Err(style_error("unsupported functional color syntax")),
251    }
252}
253
254fn channel_u8(value: u32) -> Result<u8> {
255    u8::try_from(value).map_err(|_| style_error("RGB/Gray channels must be at most 255"))
256}
257
258/// Fully resolved style.
259#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
260pub struct ComputedStyle {
261    /// Fill.
262    pub fill: Option<Color>,
263    /// Stroke.
264    pub stroke: Option<Color>,
265    /// Stroke width.
266    pub stroke_width: Unit,
267    /// Opacity in millionths.
268    pub opacity: u32,
269    /// Explicit font asset name.
270    pub font: Option<String>,
271    /// Font size.
272    pub font_size: Unit,
273    /// Text foreground.
274    pub color: Color,
275}
276
277impl ComputedStyle {
278    /// Validates the resolved style before it crosses an exporter boundary.
279    pub fn validate(&self) -> Result<()> {
280        for color in [self.fill, self.stroke, Some(self.color)]
281            .into_iter()
282            .flatten()
283        {
284            color.validate()?;
285        }
286        if self.opacity > 1_000_000
287            || self.stroke_width < Unit::ZERO
288            || self.font_size <= Unit::ZERO
289            || self
290                .font
291                .as_ref()
292                .is_some_and(|font| font.is_empty() || font.len() > 128)
293        {
294            return Err(style_error("computed style range is invalid"));
295        }
296        Ok(())
297    }
298}
299
300/// Named layers for the normative style cascade.
301#[derive(Clone, Debug, Default)]
302pub struct StyleCascade {
303    /// Engine defaults.
304    pub defaults: Style,
305    /// Active theme.
306    pub theme: Style,
307    /// Template-level style.
308    pub template: Style,
309    /// Component layer.
310    pub component: Style,
311    /// Data-rule layer.
312    pub data_rule: Style,
313    /// Runtime override layer.
314    pub runtime: Style,
315    /// Export-specific override layer.
316    pub export: Style,
317}
318
319impl StyleCascade {
320    /// Computes defaults → theme → template → component → data → runtime → export.
321    pub fn compute(&self) -> Result<ComputedStyle> {
322        let mut merged = Style::default();
323        for layer in [
324            &self.defaults,
325            &self.theme,
326            &self.template,
327            &self.component,
328            &self.data_rule,
329            &self.runtime,
330            &self.export,
331        ] {
332            layer.validate()?;
333            merged.overlay(layer);
334        }
335        Ok(ComputedStyle {
336            fill: merged.fill,
337            stroke: merged.stroke,
338            stroke_width: merged.stroke_width.unwrap_or(Unit::ZERO),
339            opacity: merged.opacity.unwrap_or(1_000_000),
340            font: merged.font,
341            font_size: merged.font_size.unwrap_or(Unit::points(12)?),
342            color: merged.color.unwrap_or(Color::Rgb { r: 0, g: 0, b: 0 }),
343        })
344    }
345}
346
347/// Resolves `$token` references with bounded parent traversal.
348pub fn resolve_token(
349    token: &str,
350    themes: &BTreeMap<String, BTreeMap<String, String>>,
351    active_theme: &str,
352    max_depth: usize,
353) -> Result<String> {
354    let key = token
355        .strip_prefix('$')
356        .ok_or_else(|| style_error("token must begin with `$`"))?;
357    let mut current = active_theme;
358    for _ in 0..max_depth {
359        let theme = themes
360            .get(current)
361            .ok_or_else(|| style_error("theme was not found"))?;
362        if let Some(value) = theme.get(key) {
363            return Ok(value.clone());
364        }
365        current = theme.get("$extends").map_or("", String::as_str);
366        if current.is_empty() {
367            break;
368        }
369    }
370    Err(style_error(format!("token `{token}` was not resolved")))
371}
372
373fn duplicate_nibble(hex: &str, offset: usize) -> Result<u8> {
374    let value = u8::from_str_radix(&hex[offset..=offset], 16)
375        .map_err(|_| style_error("invalid hex color digit"))?;
376    Ok(value * 17)
377}
378
379fn hex_byte(hex: &str, offset: usize) -> Result<u8> {
380    u8::from_str_radix(&hex[offset..offset + 2], 16)
381        .map_err(|_| style_error("invalid hex color digit"))
382}
383
384fn style_error(message: impl Into<String>) -> FileMakerError {
385    FileMakerError::new(ErrorCode::SchemaField, message)
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn runtime_layer_overrides_template_layer() {
394        let cascade = StyleCascade {
395            template: Style {
396                fill: Some(Color::parse("#ff0000").unwrap()),
397                ..Style::default()
398            },
399            runtime: Style {
400                fill: Some(Color::parse("blue").unwrap()),
401                ..Style::default()
402            },
403            ..StyleCascade::default()
404        };
405        assert_eq!(
406            cascade.compute().unwrap().fill,
407            Some(Color::Rgb { r: 0, g: 0, b: 255 })
408        );
409    }
410}