1use std::collections::HashMap;
8
9use ratatui::style::Color as RColor;
10
11use crate::box_model::{BorderStyle, BoxEdges, Length};
12use crate::color::Color;
13use crate::error::{CssError, Result};
14use crate::media::{MediaContext, MediaQuery};
15
16#[derive(Debug, Clone, PartialEq)]
30pub enum Token {
31 Color(Color),
32 Length(Length),
33 BoxEdges(BoxEdges),
34 BorderStyle(BorderStyle),
35 Var { name: String },
38}
39
40impl From<Color> for Token {
41 fn from(c: Color) -> Self {
42 Token::Color(c)
43 }
44}
45
46impl From<Length> for Token {
47 fn from(l: Length) -> Self {
48 Token::Length(l)
49 }
50}
51
52impl From<BoxEdges> for Token {
53 fn from(e: BoxEdges) -> Self {
54 Token::BoxEdges(e)
55 }
56}
57
58impl From<BorderStyle> for Token {
59 fn from(b: BorderStyle) -> Self {
60 Token::BorderStyle(b)
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69enum TokenKind {
70 Color,
71 Length,
72 BoxEdges,
73 BorderStyle,
74}
75
76impl TokenKind {
77 fn compatible_with(self, tok: &Token) -> bool {
84 match tok {
85 Token::Var { .. } => true,
86 Token::Color(_) => self == TokenKind::Color,
87 Token::Length(Length::Cells(_)) => {
88 self == TokenKind::Length || self == TokenKind::BoxEdges
89 }
90 Token::Length(_) => self == TokenKind::Length,
91 Token::BoxEdges(_) => self == TokenKind::BoxEdges,
92 Token::BorderStyle(_) => self == TokenKind::BorderStyle,
93 }
94 }
95}
96
97impl From<&str> for Token {
103 fn from(s: &str) -> Self {
104 Token::Color(Color::from(s))
105 }
106}
107
108impl From<String> for Token {
109 fn from(s: String) -> Self {
110 Token::from(s.as_str())
111 }
112}
113
114#[derive(Debug, Clone, Default, PartialEq)]
126pub struct ThemeTokens {
127 vars: HashMap<String, Token>,
128 media_vars: Vec<(MediaQuery, HashMap<String, Token>)>,
129}
130
131impl ThemeTokens {
132 pub fn new() -> Self {
133 Self::default()
134 }
135
136 pub fn set<T: Into<Token>>(mut self, name: impl Into<String>, value: T) -> Self {
138 self.vars.insert(name.into(), value.into());
139 self
140 }
141
142 pub fn insert<T: Into<Token>>(&mut self, name: impl Into<String>, value: T) {
144 self.vars.insert(name.into(), value.into());
145 }
146
147 pub fn insert_media<T: Into<Token>>(
153 &mut self,
154 query: MediaQuery,
155 name: impl Into<String>,
156 value: T,
157 ) {
158 let key = name.into();
162 for (q, map) in &mut self.media_vars {
163 if q == &query {
164 map.insert(key.clone(), value.into());
165 return;
166 }
167 }
168 let mut map = HashMap::new();
169 map.insert(key, value.into());
170 self.media_vars.push((query, map));
171 }
172
173 pub fn set_media<T: Into<Token>>(
175 mut self,
176 query: MediaQuery,
177 name: impl Into<String>,
178 value: T,
179 ) -> Self {
180 self.insert_media(query, name, value);
181 self
182 }
183
184 pub fn get(&self, name: &str) -> Option<&Token> {
186 self.vars.get(name)
187 }
188
189 pub fn is_defined(&self, name: &str) -> bool {
193 if self.vars.contains_key(name) {
194 return true;
195 }
196 self.media_vars
197 .iter()
198 .any(|(_, map)| map.contains_key(name))
199 }
200
201 pub fn get_color(&self, name: &str) -> Option<&Color> {
207 let mut cur = name;
208 for _ in 0..32 {
209 match self.vars.get(cur)? {
210 Token::Color(c) => return Some(c),
211 Token::Var { name: next } => cur = next,
212 _ => return None,
214 }
215 }
216 None
217 }
218
219 pub fn get_length(&self, name: &str) -> Option<&Length> {
223 let mut cur = name;
224 for _ in 0..32 {
225 match self.vars.get(cur)? {
226 Token::Length(l) => return Some(l),
227 Token::Var { name: next } => cur = next,
228 _ => return None,
230 }
231 }
232 None
233 }
234
235 pub fn get_box_edges(&self, name: &str) -> Option<&BoxEdges> {
238 let mut cur = name;
239 for _ in 0..32 {
240 match self.vars.get(cur)? {
241 Token::BoxEdges(e) => return Some(e),
242 Token::Var { name: next } => cur = next,
243 _ => return None,
244 }
245 }
246 None
247 }
248
249 pub fn get_border_style(&self, name: &str) -> Option<&BorderStyle> {
252 let mut cur = name;
253 for _ in 0..32 {
254 match self.vars.get(cur)? {
255 Token::BorderStyle(b) => return Some(b),
256 Token::Var { name: next } => cur = next,
257 _ => return None,
258 }
259 }
260 None
261 }
262
263 pub fn get_color_with(&self, name: &str, media: &MediaContext) -> Option<Color> {
274 self.resolve_color_with(name, media, 0)
275 }
276
277 pub fn get_length_with(&self, name: &str, media: &MediaContext) -> Option<Length> {
281 self.resolve_length_with(name, media, 0)
282 }
283
284 pub fn get_box_edges_with(&self, name: &str, media: &MediaContext) -> Option<BoxEdges> {
287 self.resolve_box_edges_with(name, media, 0)
288 }
289
290 pub fn get_border_style_with(&self, name: &str, media: &MediaContext) -> Option<BorderStyle> {
294 self.resolve_border_style_with(name, media, 0)
295 }
296
297 fn resolve_color_with(&self, name: &str, media: &MediaContext, depth: u8) -> Option<Color> {
304 if depth > 32 {
305 return None;
306 }
307 if let Some(map) = self.best_media_map(name, media, TokenKind::Color) {
311 match map.get(name).expect("best_media_map guarantees map[name] present") {
314 Token::Color(c) => return Some(c.clone()),
315 Token::Var { name: next } => {
316 return self.resolve_color_with(next, media, depth + 1);
317 }
318 _ => return None,
320 }
321 }
322 match self.vars.get(name)? {
324 Token::Color(c) => Some(c.clone()),
325 Token::Var { name: next } => self.resolve_color_with(next, media, depth + 1),
326 _ => None,
327 }
328 }
329
330 fn resolve_length_with(&self, name: &str, media: &MediaContext, depth: u8) -> Option<Length> {
334 if depth > 32 {
335 return None;
336 }
337 if let Some(map) = self.best_media_map(name, media, TokenKind::Length) {
338 match map.get(name).expect("best_media_map guarantees map[name] present") {
339 Token::Length(l) => return Some(l.clone()),
340 Token::Var { name: next } => {
341 return self.resolve_length_with(next, media, depth + 1);
342 }
343 _ => return None,
344 }
345 }
346 match self.vars.get(name)? {
347 Token::Length(l) => Some(l.clone()),
348 Token::Var { name: next } => self.resolve_length_with(next, media, depth + 1),
349 _ => None,
350 }
351 }
352
353 fn resolve_box_edges_with(
357 &self,
358 name: &str,
359 media: &MediaContext,
360 depth: u8,
361 ) -> Option<BoxEdges> {
362 if depth > 32 {
363 return None;
364 }
365 if let Some(map) = self.best_media_map(name, media, TokenKind::BoxEdges) {
366 return match map.get(name).expect("best_media_map guarantees map[name] present") {
367 Token::BoxEdges(e) => Some(*e),
368 Token::Length(Length::Cells(n)) => Some(BoxEdges::uniform(*n)),
369 Token::Var { name: next } => {
370 self.resolve_box_edges_with(next, media, depth + 1)
371 }
372 _ => None,
373 };
374 }
375 match self.vars.get(name)? {
376 Token::BoxEdges(e) => Some(*e),
377 Token::Length(Length::Cells(n)) => Some(BoxEdges::uniform(*n)),
378 Token::Var { name: next } => self.resolve_box_edges_with(next, media, depth + 1),
379 _ => None,
380 }
381 }
382
383 fn resolve_border_style_with(
385 &self,
386 name: &str,
387 media: &MediaContext,
388 depth: u8,
389 ) -> Option<BorderStyle> {
390 if depth > 32 {
391 return None;
392 }
393 if let Some(map) = self.best_media_map(name, media, TokenKind::BorderStyle) {
394 match map.get(name).expect("best_media_map guarantees map[name] present") {
395 Token::BorderStyle(b) => return Some(*b),
396 Token::Var { name: next } => {
397 return self.resolve_border_style_with(next, media, depth + 1);
398 }
399 _ => return None,
400 }
401 }
402 match self.vars.get(name)? {
403 Token::BorderStyle(b) => Some(*b),
404 Token::Var { name: next } => self.resolve_border_style_with(next, media, depth + 1),
405 _ => None,
406 }
407 }
408
409 fn best_media_map(
427 &self,
428 name: &str,
429 media: &MediaContext,
430 kind: TokenKind,
431 ) -> Option<&HashMap<String, Token>> {
432 let mut best: Option<(&HashMap<String, Token>, usize)> = None;
433 for (query, map) in &self.media_vars {
434 let spec = match query.matching_specificity(media) {
436 Some(s) => s,
437 None => continue,
438 };
439 let tok = match map.get(name) {
441 Some(t) => t,
442 None => continue,
443 };
444 if !kind.compatible_with(tok) {
445 continue;
446 }
447 match best {
452 Some((_, cur_spec)) if cur_spec > spec => {}
453 _ => best = Some((map, spec)),
454 }
455 }
456 best.map(|(map, _)| map)
457 }
458
459 pub fn merge(&mut self, other: &ThemeTokens) {
463 for (k, v) in &other.vars {
464 self.vars.insert(k.clone(), v.clone());
465 }
466 for (q, map) in &other.media_vars {
467 self.media_vars.push((q.clone(), map.clone()));
468 }
469 }
470
471 pub fn is_empty(&self) -> bool {
472 self.vars.is_empty()
473 }
474
475 pub fn len(&self) -> usize {
476 self.vars.len()
477 }
478}
479
480pub fn resolve_strict(color: &Color, tokens: &ThemeTokens) -> Result<RColor> {
494 resolve_strict_with_media(color, tokens, &MediaContext::default())
495}
496
497pub fn resolve(color: &Color, tokens: &ThemeTokens) -> RColor {
501 resolve_with_media(color, tokens, &MediaContext::default())
502}
503
504pub fn resolve_strict_with_media(
508 color: &Color,
509 tokens: &ThemeTokens,
510 media: &MediaContext,
511) -> Result<RColor> {
512 resolve_inner(color, tokens, media, 0)
513}
514
515pub fn resolve_with_media(color: &Color, tokens: &ThemeTokens, media: &MediaContext) -> RColor {
518 resolve_strict_with_media(color, tokens, media).unwrap_or(RColor::Reset)
519}
520
521fn resolve_inner(
522 color: &Color,
523 tokens: &ThemeTokens,
524 media: &MediaContext,
525 depth: u8,
526) -> Result<RColor> {
527 if depth > 32 {
528 return Err(CssError::circular_variable(
529 "var() reference chain too deep (depth > 32)",
530 ));
531 }
532 match color {
533 Color::Literal(c) => Ok(*c),
534 Color::Reset => Ok(RColor::Reset),
535 Color::Inherit => Ok(RColor::Reset),
536 Color::Var { name, fallback } => match tokens.get_color_with(name, media) {
537 Some(referent) => resolve_inner(&referent, tokens, media, depth + 1),
538 None => match fallback {
539 Some(fb) => resolve_inner(fb, tokens, media, depth + 1),
540 None => Err(CssError::undefined_variable(name.clone())),
541 },
542 },
543 }
544}
545
546pub fn resolve_length_strict(length: &Length, tokens: &ThemeTokens) -> Result<Length> {
556 resolve_length_strict_with_media(length, tokens, &MediaContext::default())
557}
558
559pub fn resolve_length(length: &Length, tokens: &ThemeTokens) -> Length {
563 resolve_length_with_media(length, tokens, &MediaContext::default())
564}
565
566pub fn resolve_length_strict_with_media(
570 length: &Length,
571 tokens: &ThemeTokens,
572 media: &MediaContext,
573) -> Result<Length> {
574 resolve_length_inner(length, tokens, media, 0)
575}
576
577pub fn resolve_length_with_media(
580 length: &Length,
581 tokens: &ThemeTokens,
582 media: &MediaContext,
583) -> Length {
584 resolve_length_strict_with_media(length, tokens, media).unwrap_or(Length::Auto)
585}
586
587fn resolve_length_inner(
588 length: &Length,
589 tokens: &ThemeTokens,
590 media: &MediaContext,
591 depth: u8,
592) -> Result<Length> {
593 if depth > 32 {
594 return Err(CssError::circular_variable(
595 "var() reference chain too deep (depth > 32)",
596 ));
597 }
598 match length {
599 Length::Auto | Length::Cells(_) | Length::Percent(_) | Length::Min(_) | Length::Max(_) => {
600 Ok(length.clone())
601 }
602 Length::Var { name, fallback } => match tokens.get_length_with(name, media) {
603 Some(referent) => resolve_length_inner(&referent, tokens, media, depth + 1),
604 None => match fallback {
605 Some(fb) => resolve_length_inner(fb, tokens, media, depth + 1),
606 None => Err(CssError::undefined_variable(name.clone())),
607 },
608 },
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615
616 #[test]
617 fn resolves_simple_var() {
618 let tokens = ThemeTokens::new().set("accent", Color::literal(RColor::Blue));
619 let c = Color::var("accent");
620 assert_eq!(resolve_strict(&c, &tokens).unwrap(), RColor::Blue);
621 }
622
623 #[test]
624 fn resolves_chain() {
625 let tokens = ThemeTokens::new()
626 .set("accent", Color::var("blue"))
627 .set("blue", Color::literal(RColor::Blue));
628 assert_eq!(resolve_strict(&Color::var("accent"), &tokens).unwrap(), RColor::Blue);
629 }
630
631 #[test]
632 fn uses_fallback() {
633 let tokens = ThemeTokens::new();
634 let c = Color::Var { name: "missing".into(), fallback: Some(Box::new(Color::literal(RColor::Red))) };
635 assert_eq!(resolve_strict(&c, &tokens).unwrap(), RColor::Red);
636 }
637
638 #[test]
639 fn undefined_is_error_strict_but_reset_lenient() {
640 let tokens = ThemeTokens::new();
641 assert!(resolve_strict(&Color::var("nope"), &tokens).is_err());
642 assert_eq!(resolve(&Color::var("nope"), &tokens), RColor::Reset);
643 }
644
645 #[test]
646 fn token_table_holds_length() {
647 let tokens = ThemeTokens::new().set("w", Length::Cells(22));
648 assert_eq!(tokens.get_length("w"), Some(&Length::Cells(22)));
649 assert_eq!(tokens.get_color("w"), None);
651 let tokens = ThemeTokens::new().set("c", Color::literal(RColor::Blue));
653 assert_eq!(tokens.get_color("c"), Some(&Color::literal(RColor::Blue)));
654 assert_eq!(tokens.get_length("c"), None);
655 }
656
657 #[test]
658 fn length_var_resolves_strict() {
659 let tokens = ThemeTokens::new().set("w", Length::Cells(22));
660 assert_eq!(
661 resolve_length_strict(&Length::Var { name: "w".into(), fallback: None }, &tokens).unwrap(),
662 Length::Cells(22)
663 );
664 }
665
666 #[test]
667 fn length_var_chain() {
668 let tokens = ThemeTokens::new()
669 .set("w", Length::Var { name: "w2".into(), fallback: None })
670 .set("w2", Length::Cells(10));
671 assert_eq!(
672 resolve_length_strict(&Length::Var { name: "w".into(), fallback: None }, &tokens).unwrap(),
673 Length::Cells(10)
674 );
675 }
676
677 #[test]
678 fn length_var_undefined_degrades_to_auto_lenient() {
679 let tokens = ThemeTokens::new();
680 assert!(resolve_length_strict(&Length::Var { name: "nope".into(), fallback: None }, &tokens).is_err());
681 assert_eq!(
682 resolve_length(&Length::Var { name: "nope".into(), fallback: None }, &tokens),
683 Length::Auto
684 );
685 }
686
687 #[test]
688 fn length_var_mistype_degrades_to_auto_lenient() {
689 let tokens = ThemeTokens::new().set("c", Color::literal(RColor::Blue));
691 assert_eq!(
692 resolve_length(&Length::Var { name: "c".into(), fallback: None }, &tokens),
693 Length::Auto
694 );
695 }
696
697 #[test]
698 fn length_var_undefined_uses_fallback() {
699 let tokens = ThemeTokens::new();
702 let l = Length::Var {
703 name: "missing".into(),
704 fallback: Some(Box::new(Length::Cells(7))),
705 };
706 assert_eq!(resolve_length_strict(&l, &tokens).unwrap(), Length::Cells(7));
707 assert_eq!(resolve_length(&l, &tokens), Length::Cells(7));
708 }
709
710 fn mq(s: &str) -> MediaQuery {
715 MediaQuery::parse(s).unwrap()
716 }
717 fn ctx(cols: u16) -> MediaContext {
718 MediaContext {
719 cols,
720 ..Default::default()
721 }
722 }
723
724 #[test]
725 fn get_color_with_uses_media_override_when_matching() {
726 let tokens = ThemeTokens::new()
727 .set("accent", Color::literal(RColor::Red))
728 .set_media(
729 mq("(min-width: 80)"),
730 "accent",
731 Color::literal(RColor::Blue),
732 );
733 assert_eq!(
735 tokens.get_color_with("accent", &ctx(100)),
736 Some(Color::literal(RColor::Blue))
737 );
738 assert_eq!(
740 tokens.get_color_with("accent", &ctx(60)),
741 Some(Color::literal(RColor::Red))
742 );
743 assert_eq!(
745 tokens.get_color("accent"),
746 Some(&Color::literal(RColor::Red))
747 );
748 }
749
750 #[test]
751 fn get_color_with_falls_back_when_override_is_for_a_different_name() {
752 let tokens = ThemeTokens::new()
754 .set("accent", Color::literal(RColor::Red))
755 .set_media(
756 mq("(min-width: 80)"),
757 "other",
758 Color::literal(RColor::Green),
759 );
760 assert_eq!(
761 tokens.get_color_with("accent", &ctx(100)),
762 Some(Color::literal(RColor::Red)),
763 "override for --other must not shadow --accent"
764 );
765 }
766
767 #[test]
768 fn get_color_with_last_matching_override_wins() {
769 let tokens = ThemeTokens::new()
771 .set("accent", Color::literal(RColor::Red))
772 .set_media(mq("(min-width: 50)"), "accent", Color::literal(RColor::Green))
773 .set_media(mq("(min-width: 80)"), "accent", Color::literal(RColor::Blue));
774 assert_eq!(
775 tokens.get_color_with("accent", &ctx(100)),
776 Some(Color::literal(RColor::Blue)),
777 "last-matching media override wins by source order"
778 );
779 assert_eq!(
781 tokens.get_color_with("accent", &ctx(60)),
782 Some(Color::literal(RColor::Green))
783 );
784 }
785
786 #[test]
787 fn get_color_with_chains_through_media_var() {
788 let tokens = ThemeTokens::new().set_media(
790 mq("(min-width: 80)"),
791 "x",
792 Token::Var { name: "y".to_string() },
793 );
794 let tokens = tokens.set_media(
795 mq("(min-width: 80)"),
796 "y",
797 Color::literal(RColor::Magenta),
798 );
799 assert_eq!(
800 tokens.get_color_with("x", &ctx(100)),
801 Some(Color::literal(RColor::Magenta)),
802 "media-gated var() chain resolves through both media entries"
803 );
804 assert_eq!(tokens.get_color_with("x", &ctx(40)), None);
806 }
807
808 #[test]
809 fn get_length_with_uses_media_override() {
810 let tokens = ThemeTokens::new()
811 .set("w", Length::Cells(5))
812 .set_media(mq("(min-width: 80)"), "w", Length::Cells(50));
813 assert_eq!(tokens.get_length_with("w", &ctx(100)), Some(Length::Cells(50)));
814 assert_eq!(tokens.get_length_with("w", &ctx(40)), Some(Length::Cells(5)));
815 assert_eq!(tokens.get_length("w"), Some(&Length::Cells(5)));
817 }
818
819 #[test]
820 fn insert_media_accumulates_same_query_into_one_map() {
821 let q = mq("(min-width: 80)");
824 let mut tokens = ThemeTokens::new();
825 tokens.insert_media(q.clone(), "a", Color::literal(RColor::Red));
826 tokens.insert_media(q.clone(), "b", Color::literal(RColor::Green));
827 assert_eq!(tokens.get_color_with("a", &ctx(100)), Some(Color::literal(RColor::Red)));
829 assert_eq!(tokens.get_color_with("b", &ctx(100)), Some(Color::literal(RColor::Green)));
830 tokens.insert_media(q, "a", Color::literal(RColor::Blue));
832 assert_eq!(tokens.get_color_with("a", &ctx(100)), Some(Color::literal(RColor::Blue)));
833 }
834
835 #[test]
836 fn is_defined_checks_default_and_all_media_maps() {
837 let mut tokens = ThemeTokens::new();
838 tokens.insert("default_only", Color::literal(RColor::Red));
839 tokens.insert_media(mq("(min-width: 80)"), "media_only", Color::literal(RColor::Red));
840 assert!(tokens.is_defined("default_only"));
841 assert!(tokens.is_defined("media_only"));
842 assert!(!tokens.is_defined("neither"));
843 }
844
845 #[test]
846 fn resolve_with_media_gates_var_against_context() {
847 let tokens = ThemeTokens::new()
850 .set("accent", Color::literal(RColor::Red))
851 .set_media(mq("(min-width: 80)"), "accent", Color::literal(RColor::Blue));
852 assert_eq!(resolve(&Color::var("accent"), &tokens), RColor::Red);
854 assert_eq!(
856 resolve_with_media(&Color::var("accent"), &tokens, &ctx(100)),
857 RColor::Blue
858 );
859 assert_eq!(
861 resolve_with_media(&Color::var("accent"), &tokens, &ctx(40)),
862 RColor::Red
863 );
864 }
865
866 #[test]
867 fn resolve_length_with_media_gates_var_against_context() {
868 let tokens = ThemeTokens::new()
869 .set("w", Length::Cells(5))
870 .set_media(mq("(min-width: 80)"), "w", Length::Cells(50));
871 assert_eq!(
872 resolve_length_with_media(&Length::Var { name: "w".into(), fallback: None }, &tokens, &ctx(100)),
873 Length::Cells(50)
874 );
875 assert_eq!(
876 resolve_length_with_media(&Length::Var { name: "w".into(), fallback: None }, &tokens, &ctx(40)),
877 Length::Cells(5)
878 );
879 }
880
881 #[test]
882 fn merge_merges_media_vars_too() {
883 let other = ThemeTokens::new()
884 .set("a", Color::literal(RColor::Red))
885 .set_media(mq("(min-width: 80)"), "a", Color::literal(RColor::Blue));
886 let mut mine = ThemeTokens::new();
887 mine.merge(&other);
888 assert_eq!(mine.get_color("a"), Some(&Color::literal(RColor::Red)));
889 assert_eq!(mine.get_color_with("a", &ctx(100)), Some(Color::literal(RColor::Blue)));
890 }
891
892 #[test]
897 fn get_color_with_picks_more_specific_override() {
898 let tokens = ThemeTokens::new()
905 .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Red))
906 .set_media(mq("(min-width: 80) and (color)"), "x", Color::literal(RColor::Blue));
907 assert_eq!(
908 tokens.get_color_with("x", &ctx(100)),
909 Some(Color::literal(RColor::Blue)),
910 "the 2-condition override is more specific and wins"
911 );
912 }
913
914 #[test]
915 fn get_color_with_specificity_tie_falls_back_to_source_order() {
916 let tokens = ThemeTokens::new()
919 .set_media(mq("(min-width: 50)"), "x", Color::literal(RColor::Red))
920 .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Blue));
921 assert_eq!(
923 tokens.get_color_with("x", &ctx(100)),
924 Some(Color::literal(RColor::Blue)),
925 "equal specificity → later source-order wins"
926 );
927 }
928
929 #[test]
930 fn get_color_with_less_specific_does_not_override_more_specific() {
931 let tokens = ThemeTokens::new()
934 .set_media(mq("(min-width: 80) and (color)"), "x", Color::literal(RColor::Blue))
935 .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Red));
936 assert_eq!(
937 tokens.get_color_with("x", &ctx(100)),
938 Some(Color::literal(RColor::Blue)),
939 "more-specific wins regardless of source position"
940 );
941 }
942
943 #[test]
944 fn get_color_with_single_override_unchanged() {
945 let tokens = ThemeTokens::new()
947 .set("x", Color::literal(RColor::Red))
948 .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Blue));
949 assert_eq!(tokens.get_color_with("x", &ctx(100)), Some(Color::literal(RColor::Blue)));
950 assert_eq!(tokens.get_color_with("x", &ctx(40)), Some(Color::literal(RColor::Red)));
952 }
953
954 #[test]
955 fn get_color_with_no_override_falls_back_to_default() {
956 let tokens = ThemeTokens::new().set("x", Color::literal(RColor::Red));
958 assert_eq!(tokens.get_color_with("x", &ctx(100)), Some(Color::literal(RColor::Red)));
959 assert_eq!(tokens.get_color_with("x", &ctx(40)), Some(Color::literal(RColor::Red)));
960 }
961
962 #[test]
963 fn get_color_with_specificity_var_chain() {
964 let tokens = ThemeTokens::new()
968 .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Red))
970 .set_media(mq("(min-width: 80)"), "y", Color::literal(RColor::Magenta))
971 .set_media(mq("(min-width: 80) and (color)"), "x", Token::Var { name: "y".into() });
973 assert_eq!(
976 tokens.get_color_with("x", &ctx(100)),
977 Some(Color::literal(RColor::Magenta)),
978 "more-specific var() chain resolves through the same media context"
979 );
980 }
981
982 #[test]
983 fn get_length_with_picks_more_specific_override() {
984 let tokens = ThemeTokens::new()
986 .set_media(mq("(min-width: 80)"), "w", Length::Cells(5))
987 .set_media(mq("(min-width: 80) and (color)"), "w", Length::Cells(50));
988 assert_eq!(
989 tokens.get_length_with("w", &ctx(100)),
990 Some(Length::Cells(50)),
991 "more-specific length override wins"
992 );
993 }
994
995 #[test]
996 fn get_color_with_more_specific_skipped_when_it_binds_different_name() {
997 let tokens = ThemeTokens::new()
1001 .set_media(mq("(min-width: 80)"), "x", Color::literal(RColor::Red))
1002 .set_media(mq("(min-width: 80) and (color)"), "other", Color::literal(RColor::Blue));
1003 assert_eq!(
1004 tokens.get_color_with("x", &ctx(100)),
1005 Some(Color::literal(RColor::Red)),
1006 "a more-specific query that does not bind `x` does not shadow `x`"
1007 );
1008 assert_eq!(
1010 tokens.get_color_with("other", &ctx(100)),
1011 Some(Color::literal(RColor::Blue))
1012 );
1013 }
1014
1015 #[test]
1020 fn get_box_edges_resolves_named_token() {
1021 let tokens = ThemeTokens::new().set("pad", BoxEdges::uniform(2));
1022 assert_eq!(tokens.get_box_edges("pad"), Some(&BoxEdges::uniform(2)));
1023 assert_eq!(
1025 tokens.get_box_edges_with("pad", &MediaContext::default()),
1026 Some(BoxEdges::uniform(2))
1027 );
1028 }
1029
1030 #[test]
1031 fn get_box_edges_follows_var_chain() {
1032 let edges = BoxEdges { top: 1, right: 2, bottom: 3, left: 4 };
1033 let tokens = ThemeTokens::new()
1034 .set("pad", Token::Var { name: "pad2".into() })
1035 .set("pad2", edges);
1036 assert_eq!(tokens.get_box_edges("pad"), Some(&edges));
1037 }
1038
1039 #[test]
1040 fn get_border_style_resolves_named_token() {
1041 let tokens = ThemeTokens::new().set("bs", BorderStyle::Rounded);
1042 assert_eq!(tokens.get_border_style("bs"), Some(&BorderStyle::Rounded));
1043 assert_eq!(
1044 tokens.get_border_style_with("bs", &MediaContext::default()),
1045 Some(BorderStyle::Rounded)
1046 );
1047 }
1048
1049 #[test]
1050 fn get_border_style_follows_var_chain() {
1051 let tokens = ThemeTokens::new()
1052 .set("bs", Token::Var { name: "bs2".into() })
1053 .set("bs2", BorderStyle::Double);
1054 assert_eq!(tokens.get_border_style("bs"), Some(&BorderStyle::Double));
1055 }
1056
1057 #[test]
1058 fn get_box_edges_with_media_specificity_override() {
1059 let tokens = ThemeTokens::new()
1061 .set_media(
1062 mq("(min-width: 80)"),
1063 "pad",
1064 BoxEdges::uniform(1),
1065 )
1066 .set_media(
1067 mq("(min-width: 80) and (color)"),
1068 "pad",
1069 BoxEdges::uniform(2),
1070 );
1071 assert_eq!(
1072 tokens.get_box_edges_with("pad", &ctx(100)),
1073 Some(BoxEdges::uniform(2)),
1074 "more-specific media override wins for box-edges"
1075 );
1076 }
1077
1078 #[test]
1079 fn get_border_style_with_media_override() {
1080 let tokens = ThemeTokens::new()
1081 .set("bs", BorderStyle::Single)
1082 .set_media(mq("(min-width: 80)"), "bs", BorderStyle::Rounded);
1083 assert_eq!(
1084 tokens.get_border_style_with("bs", &ctx(100)),
1085 Some(BorderStyle::Rounded)
1086 );
1087 assert_eq!(
1088 tokens.get_border_style_with("bs", &ctx(40)),
1089 Some(BorderStyle::Single)
1090 );
1091 }
1092
1093 #[test]
1094 fn box_edges_token_is_not_a_color() {
1095 let tokens = ThemeTokens::new().set("pad", BoxEdges::uniform(1));
1097 assert_eq!(tokens.get_color("pad"), None);
1098 }
1099
1100 #[test]
1101 fn border_style_token_is_not_a_length() {
1102 let tokens = ThemeTokens::new().set("bs", BorderStyle::Rounded);
1103 assert_eq!(tokens.get_length("bs"), None);
1104 }
1105}