Skip to main content

hypen_engine/portable/
variant.rs

1//! Responsive / interaction-state variant prop key parsing and resolution.
2//!
3//! This is the single canonical implementation of the variant prop-key
4//! contract that every renderer (DOM, Canvas, iOS/SwiftUI, Android/Compose,
5//! desktop) must follow. Like the rest of [`crate::portable`], every function
6//! here is pure (strings in, strings/structs out — no I/O, no clocks).
7//!
8//! # Canonical prop key format
9//!
10//! ```text
11//! <camelBase><variant?><argSuffix>
12//! ```
13//!
14//! * `camelBase` — camelCase applicator name, e.g. `padding`, `backgroundColor`.
15//! * `variant` (optional) — `@<bp>` and/or `:<state>`, in that order when
16//!   combined. `bp` is one of `sm`/`md`/`lg`/`xl`/`2xl`; `state` is one of
17//!   `hover`/`focus`/`active`/`disabled`/`focus-visible`/`focus-within`.
18//!   A combined key like `backgroundColor@md:hover` applies only when BOTH the
19//!   breakpoint is active (viewport width >= its min-width) AND the state is
20//!   active.
21//! * `argSuffix` — `.<index>` (almost always `.0`) or `.<name>` for named args.
22//!
23//! Examples emitted by the engine: `padding.0`, `padding@md.0`,
24//! `backgroundColor:hover.0`, `backgroundColor@md:hover.0`, `padding.top`.
25//!
26//! Note the variant marker sits *between* the base and the trailing `.arg`
27//! suffix. A lookup that forgets the `.arg` suffix (e.g. building `padding@md`
28//! and looking it up directly) will MISS the real key `padding@md.0`. The fix
29//! is [`pick_variant_base`], which returns the *variant-decorated base without
30//! the arg suffix* so callers can feed it to their existing prop getters that
31//! append `.0` themselves.
32//!
33//! # Resolution precedence (lowest to highest; later overrides earlier)
34//!
35//! ```text
36//! base
37//!   < breakpoints in ascending min-width order (sm<md<lg<xl<2xl, only those
38//!     whose min-width <= current width)
39//!   < disabled < hover < focus < active
40//! ```
41//!
42//! This mirrors the iOS reference in
43//! `hypen-renderer-swift/Sources/HypenSwift/Render/VariantSupport.swift`
44//! (`StateAwareModifier.computeEffectiveModifier`).
45
46/// Breakpoint tokens paired with their min-width in CSS pixels, in ascending
47/// order. This ordering is load-bearing: breakpoint precedence follows it.
48pub const BREAKPOINTS: &[(&str, f32)] = &[
49    ("sm", 640.0),
50    ("md", 768.0),
51    ("lg", 1024.0),
52    ("xl", 1280.0),
53    ("2xl", 1536.0),
54];
55
56/// Interaction-state tokens, in ascending precedence order (later overrides
57/// earlier). `focus-visible` / `focus-within` are recognised tokens but are
58/// not assigned a distinct precedence slot here — they sort after the named
59/// `disabled`/`hover`/`focus`/`active` ladder; renderers that distinguish them
60/// can apply their own ordering. The four primary states follow the iOS
61/// reference: disabled < hover < focus < active.
62pub const STATES: &[&str] = &[
63    "disabled",
64    "hover",
65    "focus",
66    "active",
67    "focus-visible",
68    "focus-within",
69];
70
71/// The literal key meaning "base" (no variant) in a value-map form, e.g.
72/// `.padding({ default: 8, md: 16 })`.
73pub const DEFAULT_KEY: &str = "default";
74
75/// True if `tok` is a known breakpoint token (`sm`/`md`/`lg`/`xl`/`2xl`).
76pub fn is_breakpoint(tok: &str) -> bool {
77    BREAKPOINTS.iter().any(|(name, _)| *name == tok)
78}
79
80/// True if `tok` is a known interaction-state token.
81pub fn is_state(tok: &str) -> bool {
82    STATES.contains(&tok)
83}
84
85/// Min-width (CSS px) at which `bp` becomes active, or `None` if not a
86/// breakpoint token.
87pub fn breakpoint_min_width(bp: &str) -> Option<f32> {
88    BREAKPOINTS
89        .iter()
90        .find(|(name, _)| *name == bp)
91        .map(|(_, w)| *w)
92}
93
94/// True if `tok` is a variant token usable as a key in a value-map applicator:
95/// the literal `default`, a breakpoint, or a state.
96pub fn is_variant_token(tok: &str) -> bool {
97    tok == DEFAULT_KEY || is_breakpoint(tok) || is_state(tok)
98}
99
100/// Precedence rank for a state token. Higher wins. Used only to order states
101/// against each other; breakpoints are always ranked below any state.
102fn state_rank(state: &str) -> u32 {
103    match state {
104        "disabled" => 1,
105        "hover" => 2,
106        // focus-visible / focus-within are focus-flavoured states; they sit at
107        // the same precedence slot as `focus` rather than above `active` (the
108        // primary ladder is disabled < hover < focus < active).
109        "focus" | "focus-visible" | "focus-within" => 3,
110        "active" => 4,
111        _ => 0,
112    }
113}
114
115/// A parsed prop key, split into its base, optional variant markers, and
116/// optional arg suffix.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct ParsedKey {
119    /// camelCase applicator base, e.g. `padding`, `backgroundColor`.
120    pub base: String,
121    /// Breakpoint token (`md`, `lg`, …) if the key carried an `@bp` marker.
122    pub breakpoint: Option<String>,
123    /// State token (`hover`, `focus`, …) if the key carried a `:state` marker.
124    pub state: Option<String>,
125    /// Arg suffix after the trailing `.`, e.g. `0` for `padding.0`, or `top`
126    /// for the named-arg key `padding.top`. `None` when the key has no `.`.
127    pub arg: Option<String>,
128}
129
130/// Parse a raw prop key into its components.
131///
132/// Handles every shape:
133/// * plain `padding.0`
134/// * responsive `padding@md.0`
135/// * state `backgroundColor:hover.0`
136/// * combined `backgroundColor@md:hover.0`
137/// * named-arg `padding.top` (no variant)
138/// * bare `padding` (no arg)
139///
140/// `@` and `:` only act as variant markers, and they appear before the trailing
141/// `.arg` suffix. The arg suffix is split off first (everything after the last
142/// `.`), then the remaining head is split on `@` / `:`. Keys never contain
143/// values, so there is no ambiguity with `:` inside a value.
144pub fn parse_prop_key(key: &str) -> ParsedKey {
145    // Peel components off the end, validating each marker token against the known
146    // breakpoint/state sets. An UNRECOGNISED marker is left attached to the base
147    // (so it never matches a real applicator), matching the web `parseVariantKey`
148    // and the native renderers' `parseVariantName`. The canonical key order is
149    // `<base>@<bp>:<state>.<arg>`, so peel arg → state → breakpoint.
150
151    // Arg suffix: everything after the last '.'.
152    let (mut work, arg) = match key.rfind('.') {
153        Some(idx) => (key[..idx].to_string(), Some(key[idx + 1..].to_string())),
154        None => (key.to_string(), None),
155    };
156
157    // State marker `:state` (only when the token is a known state).
158    let mut state = None;
159    if let Some(idx) = work.find(':') {
160        let candidate = work[idx + 1..].to_string();
161        if is_state(&candidate) {
162            state = Some(candidate);
163            work.truncate(idx);
164        }
165    }
166
167    // Breakpoint marker `@bp` (only when the token is a known breakpoint).
168    let mut breakpoint = None;
169    if let Some(idx) = work.find('@') {
170        let candidate = work[idx + 1..].to_string();
171        if is_breakpoint(&candidate) {
172            breakpoint = Some(candidate);
173            work.truncate(idx);
174        }
175    }
176
177    ParsedKey {
178        base: work,
179        breakpoint,
180        state,
181        arg,
182    }
183}
184
185/// Build the variant-decorated base (no arg suffix) for a parsed key, e.g.
186/// `padding@md`, `backgroundColor:hover`, `backgroundColor@md:hover`, or just
187/// `padding`.
188fn decorated_base(parsed: &ParsedKey) -> String {
189    let mut out = parsed.base.clone();
190    if let Some(bp) = &parsed.breakpoint {
191        out.push('@');
192        out.push_str(bp);
193    }
194    if let Some(st) = &parsed.state {
195        out.push(':');
196        out.push_str(st);
197    }
198    out
199}
200
201/// Among `candidate_keys` (a node's raw prop keys, each including its `.arg`
202/// suffix), select those whose parsed base equals `base` and whose variant
203/// markers are currently satisfied (breakpoint active at `viewport_w` AND state
204/// present in `active_states`), then return the WINNER's variant-decorated base
205/// (without the arg suffix) per the documented precedence.
206///
207/// The winner is chosen by precedence (lowest → highest, later wins):
208/// `base < breakpoints ascending < disabled < hover < focus < active`.
209/// A combined `@bp:state` key only qualifies when both halves are satisfied,
210/// and ranks by its state (the higher signal), with its breakpoint min-width
211/// breaking ties between two same-state candidates.
212///
213/// Returns:
214/// * `Some(decorated_base)` for the winning key (e.g. `"padding@md"`).
215/// * `Some(base)` when only the plain base key is present/qualifies.
216/// * `None` when no candidate key matches `base` at all.
217///
218/// The returned value deliberately omits the `.arg` suffix: callers feed it to
219/// their existing prop getters which append `.0` (or the named arg) themselves.
220/// This is what fixes the `.0` mismatch bug.
221pub fn pick_variant_base(
222    base: &str,
223    candidate_keys: &[&str],
224    viewport_w: f32,
225    active_states: &[&str],
226) -> Option<String> {
227    // Precedence score: higher wins.
228    //   plain base                     -> 0
229    //   breakpoint only                -> min-width (640..=1536), well below STATE_BASE
230    //   state (any, maybe + bp)        -> STATE_BASE + state_rank*STATE_STEP + bp_min_width
231    // The breakpoint min-width as a tiebreaker means a more-specific
232    // `@xl:hover` beats `@sm:hover` when both are active. STATE_STEP must stay
233    // strictly larger than the largest breakpoint min-width (1536) so the
234    // bp tiebreak can never leak across state bands — i.e. a high-breakpoint
235    // lower state (e.g. `@2xl:disabled`) must never outrank a plain higher
236    // state (e.g. `:hover`).
237    const STATE_BASE: f32 = 100_000.0;
238    const STATE_STEP: f32 = 10_000.0;
239
240    let mut best: Option<(f32, String)> = None;
241
242    for key in candidate_keys {
243        let parsed = parse_prop_key(key);
244        if parsed.base != base {
245            continue;
246        }
247
248        // Breakpoint must be active (or absent).
249        if let Some(bp) = &parsed.breakpoint {
250            match breakpoint_min_width(bp) {
251                Some(min_w) if viewport_w >= min_w => {}
252                _ => continue, // unknown bp or not active at this width
253            }
254        }
255
256        // State must be active (or absent).
257        if let Some(st) = &parsed.state {
258            if !active_states.contains(&st.as_str()) {
259                continue;
260            }
261        }
262
263        // Compute precedence score.
264        let bp_weight = parsed
265            .breakpoint
266            .as_deref()
267            .and_then(breakpoint_min_width)
268            .unwrap_or(0.0);
269        let score = match (&parsed.breakpoint, &parsed.state) {
270            (None, None) => 0.0,
271            (Some(_), None) => bp_weight,
272            (_, Some(st)) => STATE_BASE + state_rank(st) as f32 * STATE_STEP + bp_weight,
273        };
274
275        let decorated = decorated_base(&parsed);
276        match &best {
277            Some((best_score, _)) if *best_score >= score => {}
278            _ => best = Some((score, decorated)),
279        }
280    }
281
282    best.map(|(_, decorated)| decorated)
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    // ── predicates ──────────────────────────────────────────────────────
290
291    #[test]
292    fn predicates() {
293        assert!(is_breakpoint("md"));
294        assert!(is_breakpoint("2xl"));
295        assert!(!is_breakpoint("hover"));
296        assert!(is_state("hover"));
297        assert!(is_state("focus-visible"));
298        assert!(!is_state("md"));
299        assert_eq!(breakpoint_min_width("md"), Some(768.0));
300        assert_eq!(breakpoint_min_width("2xl"), Some(1536.0));
301        assert_eq!(breakpoint_min_width("nope"), None);
302        assert!(is_variant_token("default"));
303        assert!(is_variant_token("md"));
304        assert!(is_variant_token("hover"));
305        assert!(!is_variant_token("top"));
306        assert!(!is_variant_token("padding"));
307    }
308
309    // ── parse_prop_key ─────────────────────────────────────────────────
310
311    #[test]
312    fn parse_plain() {
313        let p = parse_prop_key("padding.0");
314        assert_eq!(p.base, "padding");
315        assert_eq!(p.breakpoint, None);
316        assert_eq!(p.state, None);
317        assert_eq!(p.arg.as_deref(), Some("0"));
318    }
319
320    #[test]
321    fn parse_breakpoint() {
322        let p = parse_prop_key("padding@md.0");
323        assert_eq!(p.base, "padding");
324        assert_eq!(p.breakpoint.as_deref(), Some("md"));
325        assert_eq!(p.state, None);
326        assert_eq!(p.arg.as_deref(), Some("0"));
327    }
328
329    #[test]
330    fn parse_state() {
331        let p = parse_prop_key("backgroundColor:hover.0");
332        assert_eq!(p.base, "backgroundColor");
333        assert_eq!(p.breakpoint, None);
334        assert_eq!(p.state.as_deref(), Some("hover"));
335        assert_eq!(p.arg.as_deref(), Some("0"));
336    }
337
338    #[test]
339    fn parse_combined() {
340        let p = parse_prop_key("backgroundColor@md:hover.0");
341        assert_eq!(p.base, "backgroundColor");
342        assert_eq!(p.breakpoint.as_deref(), Some("md"));
343        assert_eq!(p.state.as_deref(), Some("hover"));
344        assert_eq!(p.arg.as_deref(), Some("0"));
345    }
346
347    #[test]
348    fn parse_named_arg_no_variant() {
349        let p = parse_prop_key("padding.top");
350        assert_eq!(p.base, "padding");
351        assert_eq!(p.breakpoint, None);
352        assert_eq!(p.state, None);
353        assert_eq!(p.arg.as_deref(), Some("top"));
354    }
355
356    #[test]
357    fn parse_bare_no_arg() {
358        let p = parse_prop_key("padding");
359        assert_eq!(p.base, "padding");
360        assert_eq!(p.breakpoint, None);
361        assert_eq!(p.state, None);
362        assert_eq!(p.arg, None);
363    }
364
365    #[test]
366    fn parse_hyphenated_state() {
367        let p = parse_prop_key("color:focus-visible.0");
368        assert_eq!(p.base, "color");
369        assert_eq!(p.state.as_deref(), Some("focus-visible"));
370        assert_eq!(p.arg.as_deref(), Some("0"));
371    }
372
373    #[test]
374    fn parse_2xl_breakpoint() {
375        let p = parse_prop_key("padding@2xl.0");
376        assert_eq!(p.base, "padding");
377        assert_eq!(p.breakpoint.as_deref(), Some("2xl"));
378        assert_eq!(p.arg.as_deref(), Some("0"));
379    }
380
381    #[test]
382    fn parse_invalid_markers_stay_in_base() {
383        // Unrecognised breakpoint/state tokens are left attached to the base
384        // (so they never match a real applicator), matching the web + native
385        // parsers. The engine never emits such keys; this pins cross-SDK parity.
386        let bp = parse_prop_key("padding@invalid.0");
387        assert_eq!(bp.base, "padding@invalid");
388        assert_eq!(bp.breakpoint, None);
389        assert_eq!(bp.state, None);
390        assert_eq!(bp.arg.as_deref(), Some("0"));
391
392        let st = parse_prop_key("color:bogus.0");
393        assert_eq!(st.base, "color:bogus");
394        assert_eq!(st.state, None);
395        assert_eq!(st.breakpoint, None);
396    }
397
398    // ── pick_variant_base ──────────────────────────────────────────────
399
400    #[test]
401    fn pick_base_only() {
402        let keys = ["padding.0"];
403        assert_eq!(
404            pick_variant_base("padding", &keys, 1000.0, &[]),
405            Some("padding".to_string())
406        );
407    }
408
409    #[test]
410    fn pick_no_match_returns_none() {
411        let keys = ["margin.0"];
412        assert_eq!(pick_variant_base("padding", &keys, 1000.0, &[]), None);
413    }
414
415    #[test]
416    fn pick_breakpoint_active() {
417        let keys = ["padding.0", "padding@md.0"];
418        // width 1000 >= 768, so @md wins over base.
419        assert_eq!(
420            pick_variant_base("padding", &keys, 1000.0, &[]),
421            Some("padding@md".to_string())
422        );
423    }
424
425    #[test]
426    fn pick_breakpoint_inactive() {
427        let keys = ["padding.0", "padding@md.0"];
428        // width 500 < 768, so @md does NOT apply; base wins.
429        assert_eq!(
430            pick_variant_base("padding", &keys, 500.0, &[]),
431            Some("padding".to_string())
432        );
433    }
434
435    #[test]
436    fn pick_ascending_breakpoint_order() {
437        let keys = ["padding.0", "padding@md.0", "padding@lg.0", "padding@xl.0"];
438        // width 1100 >= md(768) and lg(1024) but < xl(1280): lg wins.
439        assert_eq!(
440            pick_variant_base("padding", &keys, 1100.0, &[]),
441            Some("padding@lg".to_string())
442        );
443    }
444
445    #[test]
446    fn pick_hover_overrides_breakpoint() {
447        let keys = ["backgroundColor.0", "backgroundColor@md.0", "backgroundColor:hover.0"];
448        // md active and hover active: hover (a state) outranks breakpoint.
449        assert_eq!(
450            pick_variant_base("backgroundColor", &keys, 1000.0, &["hover"]),
451            Some("backgroundColor:hover".to_string())
452        );
453    }
454
455    #[test]
456    fn pick_active_overrides_hover() {
457        let keys = ["c.0", "c:hover.0", "c:active.0"];
458        assert_eq!(
459            pick_variant_base("c", &keys, 800.0, &["hover", "active"]),
460            Some("c:active".to_string())
461        );
462    }
463
464    #[test]
465    fn pick_disabled_lowest_of_states() {
466        let keys = ["c.0", "c:disabled.0", "c:hover.0"];
467        // both active: hover outranks disabled.
468        assert_eq!(
469            pick_variant_base("c", &keys, 800.0, &["disabled", "hover"]),
470            Some("c:hover".to_string())
471        );
472        // only disabled active: disabled wins over base.
473        assert_eq!(
474            pick_variant_base("c", &keys, 800.0, &["disabled"]),
475            Some("c:disabled".to_string())
476        );
477    }
478
479    #[test]
480    fn pick_combined_requires_both() {
481        let keys = ["c.0", "c@md:hover.0"];
482        // md active but hover NOT active -> combined does not qualify; base wins.
483        assert_eq!(
484            pick_variant_base("c", &keys, 1000.0, &[]),
485            Some("c".to_string())
486        );
487        // hover active but md NOT active (width 500 < 768) -> base wins.
488        assert_eq!(
489            pick_variant_base("c", &keys, 500.0, &["hover"]),
490            Some("c".to_string())
491        );
492        // both active -> combined wins.
493        assert_eq!(
494            pick_variant_base("c", &keys, 1000.0, &["hover"]),
495            Some("c@md:hover".to_string())
496        );
497    }
498
499    #[test]
500    fn pick_state_only_inactive_falls_to_base() {
501        let keys = ["c.0", "c:hover.0"];
502        assert_eq!(
503            pick_variant_base("c", &keys, 800.0, &[]),
504            Some("c".to_string())
505        );
506    }
507
508    #[test]
509    fn pick_combined_outranks_plain_state_via_breakpoint_tiebreak() {
510        // Two hover candidates active; the one carrying a breakpoint is more
511        // specific and wins the tiebreak.
512        let keys = ["c:hover.0", "c@md:hover.0"];
513        assert_eq!(
514            pick_variant_base("c", &keys, 1000.0, &["hover"]),
515            Some("c@md:hover".to_string())
516        );
517    }
518
519    #[test]
520    fn pick_high_breakpoint_lower_state_does_not_leak_across_state_bands() {
521        // Regression: a high-breakpoint lower-precedence state (`@2xl:disabled`)
522        // must NOT outrank a plain higher-precedence state (`:hover`). The bp
523        // tiebreak only orders within a single state band.
524        let keys = ["c:hover.0", "c@2xl:disabled.0"];
525        assert_eq!(
526            pick_variant_base("c", &keys, 1536.0, &["disabled", "hover"]),
527            Some("c:hover".to_string())
528        );
529    }
530
531    #[test]
532    fn pick_focus_visible_ranks_as_focus_not_above_active() {
533        // focus-visible / focus-within behave like focus; active must still win.
534        let keys = ["c:focus-visible.0", "c:active.0"];
535        assert_eq!(
536            pick_variant_base("c", &keys, 0.0, &["focus-visible", "active"]),
537            Some("c:active".to_string())
538        );
539    }
540}