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}