use core::cell::{Cell, RefCell};
use crate::compat::{format, vec, Box, HashMap, MiniToString, Rc, String, Vec};
use crate::core::Color;
use crate::style::selector::{ElementRef, RuleIndexKey};
use crate::style::{
BorderStyle, CornerRadii, PseudoState, Selector, StyleRule, StyleSheet, WidgetStyle,
};
use crate::widget::WidgetKind;
#[derive(Debug, Clone)]
enum CachedDeclValue {
Color(Color),
Length(u32),
Lengths(Vec<u32>),
Float(f32),
Gradient(crate::style::Gradient),
Shadow(crate::style::Shadow),
}
struct DeclCacheEntry {
text: String,
value: CachedDeclValue,
last_used: u64,
}
struct InlineDeclEntry {
text: String,
declarations: Rc<Vec<(String, String)>>,
last_used: u64,
}
thread_local! {
#[cfg_attr(alloc_frugal, allow(clippy::missing_const_for_thread_local))]
static DECL_CACHE: RefCell<HashMap<(u8, u64), DeclCacheEntry>> =
RefCell::new(HashMap::new());
#[cfg_attr(alloc_frugal, allow(clippy::missing_const_for_thread_local))]
static INLINE_DECL_CACHE: RefCell<HashMap<u64, InlineDeclEntry>> =
RefCell::new(HashMap::new());
static CACHE_TICK: Cell<u64> = const { Cell::new(0) };
}
const DECL_CACHE_LIMIT: usize = 512;
const INLINE_DECL_CACHE_LIMIT: usize = 64;
fn next_cache_tick() -> u64 {
CACHE_TICK.with(|tick| {
let next = tick.get().wrapping_add(1);
tick.set(next);
next
})
}
fn declaration_hash(text: &str) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for byte in text.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CssCombinator {
Descendant,
Child,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CssSelector {
Universal,
Kind(String),
Class(String),
Id(String),
State(PseudoState),
And(Vec<CssSelector>),
Descendant(Box<CssSelector>, Box<CssSelector>),
Child(Box<CssSelector>, Box<CssSelector>),
}
impl CssSelector {
pub fn specificity(&self) -> u32 {
match self {
CssSelector::Universal => 0,
CssSelector::Kind(_) => 1,
CssSelector::State(_) => 1,
CssSelector::Class(_) => 10,
CssSelector::Id(_) => 100,
CssSelector::And(selectors) => selectors.iter().map(|s| s.specificity()).sum(),
CssSelector::Descendant(ancestor, subject) | CssSelector::Child(ancestor, subject) => {
ancestor.specificity() + subject.specificity()
}
}
}
pub fn to_selector(&self) -> Option<Selector> {
Some(match self {
CssSelector::Universal => Selector::Universal,
CssSelector::Kind(name) => Selector::Kind(widget_kind_from_str(name)?),
CssSelector::Class(name) => Selector::Class(name.clone()),
CssSelector::Id(id) => Selector::Id(id.clone()),
CssSelector::State(state) => Selector::State(*state),
CssSelector::And(selectors) => Selector::And(
selectors.iter().map(CssSelector::to_selector).collect::<Option<Vec<_>>>()?,
),
CssSelector::Descendant(ancestor, subject) => Selector::Descendant {
ancestor: Box::new(ancestor.to_selector()?),
subject: Box::new(subject.to_selector()?),
},
CssSelector::Child(parent, subject) => Selector::Child {
parent: Box::new(parent.to_selector()?),
subject: Box::new(subject.to_selector()?),
},
})
}
pub(crate) fn index_key(&self) -> RuleIndexKey<'_> {
match self {
CssSelector::Kind(name) => RuleIndexKey::Kind(name),
CssSelector::Class(name) => RuleIndexKey::Class(name),
CssSelector::Id(id) => RuleIndexKey::Id(id),
CssSelector::Universal | CssSelector::State(_) => RuleIndexKey::Any,
CssSelector::And(selectors) => {
if let Some(id) = selectors.iter().find_map(|selector| match selector {
CssSelector::Id(id) => Some(id.as_str()),
_ => None,
}) {
return RuleIndexKey::Id(id);
}
if let Some(class) = selectors.iter().find_map(|selector| match selector {
CssSelector::Class(class) => Some(class.as_str()),
_ => None,
}) {
return RuleIndexKey::Class(class);
}
if let Some(kind) = selectors.iter().find_map(|selector| match selector {
CssSelector::Kind(kind) => Some(kind.as_str()),
_ => None,
}) {
return RuleIndexKey::Kind(kind);
}
RuleIndexKey::Any
}
CssSelector::Descendant(_, subject) | CssSelector::Child(_, subject) => {
subject.index_key()
}
}
}
pub fn matches(
&self,
kind: &str,
class: Option<&str>,
id: Option<&str>,
state: Option<PseudoState>,
) -> bool {
self.matches_in(kind, class, id, state, &[])
}
pub fn matches_in(
&self,
kind: &str,
class: Option<&str>,
id: Option<&str>,
state: Option<PseudoState>,
ancestors: &[ElementRef<'_>],
) -> bool {
match self {
CssSelector::Universal => true,
CssSelector::Kind(k) => crate::widget::capability::coercion::same_key(k, kind),
CssSelector::Class(c) => class == Some(c.as_str()),
CssSelector::Id(i) => id == Some(i.as_str()),
CssSelector::State(s) => state == Some(*s),
CssSelector::And(selectors) => {
selectors.iter().all(|s| s.matches_in(kind, class, id, state, ancestors))
}
CssSelector::Descendant(ancestor, subject) => {
subject.matches_in(kind, class, id, state, ancestors)
&& ancestors.iter().any(|candidate| {
ancestor.matches_in(
candidate.kind.css_name(),
candidate.class,
candidate.id,
candidate.state,
&[],
)
})
}
CssSelector::Child(parent, subject) => {
subject.matches_in(kind, class, id, state, ancestors)
&& ancestors.last().is_some_and(|candidate| {
parent.matches_in(
candidate.kind.css_name(),
candidate.class,
candidate.id,
candidate.state,
&[],
)
})
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CssDeclaration {
pub property: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CssRule {
pub selector_text: String,
pub declarations: Vec<CssDeclaration>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedCssRule {
pub selector_text: String,
pub selector: Option<CssSelector>,
pub specificity: u32,
pub declarations: Vec<CssDeclaration>,
}
fn widget_kind_from_str(name: &str) -> Option<WidgetKind> {
use crate::widget::capability::coercion::same_key;
CSS_SUPPORTED_KINDS.iter().copied().find(|kind| same_key(kind.css_name(), name))
}
const CSS_SUPPORTED_KINDS: &[WidgetKind] = &[
WidgetKind::Window,
WidgetKind::Frame,
WidgetKind::Label,
WidgetKind::Button,
WidgetKind::CheckBox,
WidgetKind::RadioButton,
WidgetKind::GroupBox,
WidgetKind::Panel,
WidgetKind::Line,
WidgetKind::Arc,
WidgetKind::Meter,
WidgetKind::Slider,
WidgetKind::Spinner,
WidgetKind::Roller,
WidgetKind::Switch,
WidgetKind::LineEdit,
WidgetKind::ComboBox,
WidgetKind::SpinBox,
WidgetKind::ListBox,
WidgetKind::ScrollBar,
WidgetKind::ScrollArea,
WidgetKind::ProgressBar,
WidgetKind::ImageView,
WidgetKind::MiniChart,
WidgetKind::MiniCanvas,
WidgetKind::Keyboard,
WidgetKind::Dropdown,
#[cfg(widgets_unstripped)]
WidgetKind::ToggleButton,
#[cfg(widgets_unstripped)]
WidgetKind::TabWidget,
#[cfg(widgets_unstripped)]
WidgetKind::TabBar,
#[cfg(widgets_unstripped)]
WidgetKind::StackedWidget,
#[cfg(widgets_unstripped)]
WidgetKind::Splitter,
#[cfg(widgets_unstripped)]
WidgetKind::StatusBar,
#[cfg(widgets_unstripped)]
WidgetKind::MenuBar,
#[cfg(widgets_unstripped)]
WidgetKind::Menu,
#[cfg(widgets_unstripped)]
WidgetKind::MenuButton,
#[cfg(widgets_unstripped)]
WidgetKind::ToolBar,
#[cfg(widgets_unstripped)]
WidgetKind::ToolButton,
#[cfg(widgets_unstripped)]
WidgetKind::Badge,
#[cfg(widgets_unstripped)]
WidgetKind::Rating,
#[cfg(widgets_unstripped)]
WidgetKind::Divider,
#[cfg(widgets_unstripped)]
WidgetKind::ProgressCircle,
#[cfg(widgets_unstripped)]
WidgetKind::LCDNumber,
#[cfg(widgets_unstripped)]
WidgetKind::SkeletonLoader,
#[cfg(full_widgets)]
WidgetKind::RadarChart,
#[cfg(full_widgets)]
WidgetKind::Heatmap,
#[cfg(full_widgets)]
WidgetKind::KanbanBoard,
#[cfg(full_widgets)]
WidgetKind::Cascader,
#[cfg(full_widgets)]
WidgetKind::QueryBuilder,
#[cfg(full_widgets)]
WidgetKind::EmojiPicker,
#[cfg(full_widgets)]
WidgetKind::Mention,
];
const KNOWN_PROPERTIES: &[&str] = &[
"color",
"text-color",
"foreground",
"background",
"background-color",
"border-color",
"border-width",
"border-style",
"border-radius",
"font-size",
"font-weight",
"font-family",
"padding",
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
"margin",
"margin-top",
"margin-right",
"margin-bottom",
"margin-left",
"opacity",
"background-gradient",
"shadow",
"touch-target",
"touch-target-size",
];
enum ValueSink<'a> {
Apply(&'a mut WidgetStyle),
Check,
}
impl ValueSink<'_> {
fn text_color(&mut self, color: Color) {
if let Self::Apply(style) = self {
style.text_color = Some(color);
}
}
fn background_color(&mut self, color: Color) {
if let Self::Apply(style) = self {
style.background_color = Some(color);
}
}
fn border_color(&mut self, color: Color) {
if let Self::Apply(style) = self {
style.border_color = Some(color);
}
}
fn border_width(&mut self, width: u32) {
if let Self::Apply(style) = self {
style.border_width = Some(width);
}
}
fn border_style(&mut self, border_style: BorderStyle) {
if let Self::Apply(style) = self {
style.border_style = Some(border_style);
}
}
fn corner_radii(&mut self, radii: CornerRadii) {
if let Self::Apply(style) = self {
style.corner_radii = Some(radii);
if radii.is_uniform() {
style.border_radius = Some(radii.top_left);
}
}
}
fn padding(&mut self, padding: crate::style::Padding) {
if let Self::Apply(style) = self {
style.padding = padding;
}
}
fn padding_top(&mut self, value: u32) {
if let Self::Apply(style) = self {
style.padding.top = value;
}
}
fn padding_right(&mut self, value: u32) {
if let Self::Apply(style) = self {
style.padding.right = value;
}
}
fn padding_bottom(&mut self, value: u32) {
if let Self::Apply(style) = self {
style.padding.bottom = value;
}
}
fn padding_left(&mut self, value: u32) {
if let Self::Apply(style) = self {
style.padding.left = value;
}
}
fn margin(&mut self, margin: crate::style::Margin) {
if let Self::Apply(style) = self {
style.margin = margin;
}
}
fn margin_top(&mut self, value: u32) {
if let Self::Apply(style) = self {
style.margin.top = value;
}
}
fn margin_right(&mut self, value: u32) {
if let Self::Apply(style) = self {
style.margin.right = value;
}
}
fn margin_bottom(&mut self, value: u32) {
if let Self::Apply(style) = self {
style.margin.bottom = value;
}
}
fn margin_left(&mut self, value: u32) {
if let Self::Apply(style) = self {
style.margin.left = value;
}
}
fn opacity(&mut self, opacity: f32) {
if let Self::Apply(style) = self {
style.opacity = Some(opacity);
}
}
fn gradient(&mut self, gradient: crate::style::Gradient) {
if let Self::Apply(style) = self {
style.background_gradient = Some(gradient);
}
}
fn shadow(&mut self, shadow: Option<crate::style::Shadow>) {
if let Self::Apply(style) = self {
style.shadow = shadow;
}
}
fn touch_target(&mut self, size: crate::core::Size) {
if let Self::Apply(style) = self {
style.touch_target = Some(size);
}
}
fn font(&mut self, update: impl FnOnce(&mut crate::core::Font)) {
if let Self::Apply(style) = self {
match &mut style.font {
Some(font) => update(font),
None => {
let mut font = crate::core::Font::default_ui();
update(&mut font);
style.font = Some(font);
}
}
}
}
}
pub struct CssParser;
impl CssParser {
pub fn parse(css: &str) -> Result<StyleSheet, String> {
let mut sheet = StyleSheet::new();
let rules = Self::parse_rules(css)?;
for rule in &rules {
let Some(selector) =
Self::parse_selector(&rule.selector_text).and_then(|cs| cs.to_selector())
else {
log::warn!(
"CSS rule selector {:?} names no known widget kind and was not registered as a \
rule",
rule.selector_text
);
continue;
};
let rule_entry = StyleRule::new(selector, &rule.selector_text);
sheet.add_rule(rule_entry);
}
Ok(sheet)
}
pub fn parse_rules(css: &str) -> Result<Vec<CssRule>, String> {
let mut rules = Vec::new();
let mut pos = 0;
let chars: Vec<char> = css.chars().collect();
while pos < chars.len() {
pos = Self::skip_whitespace_and_comments(&chars, pos);
if pos >= chars.len() {
break;
}
let start = pos;
while pos < chars.len() && chars[pos] != '{' {
pos += 1;
}
if pos >= chars.len() {
return Err(
"CSS rule is unterminated: the `{` that opens the declaration block is \
missing at end of input"
.to_string(),
);
}
let selector_text: String = chars[start..pos].iter().collect();
let selector_text = selector_text.trim().to_string();
if selector_text.is_empty() {
return Err(
"CSS rule has an empty selector before `{`; every rule needs a selector \
such as `Button` or `.primary`"
.to_string(),
);
}
pos += 1;
let decl_start = pos;
while pos < chars.len() && chars[pos] != '}' {
pos += 1;
}
if pos >= chars.len() {
return Err(
"CSS rule is unterminated: the declaration block is missing its closing \
`}` at end of input"
.to_string(),
);
}
let decl_text: String = chars[decl_start..pos].iter().collect();
pos += 1;
let declarations = Self::parse_declarations(&decl_text);
rules.push(CssRule { selector_text, declarations });
}
Ok(rules)
}
pub fn parse_prepared_rules(css: &str) -> Result<Vec<ParsedCssRule>, String> {
Ok(Self::parse_rules(css)?
.into_iter()
.map(|rule| {
let selector = Self::parse_selector(&rule.selector_text);
let specificity = selector.as_ref().map_or(0, CssSelector::specificity);
ParsedCssRule {
selector_text: rule.selector_text,
selector,
specificity,
declarations: rule.declarations,
}
})
.collect())
}
pub fn parse_selector(text: &str) -> Option<CssSelector> {
let text = text.trim();
if text.is_empty() || text == "*" {
return Some(CssSelector::Universal);
}
let (parts, combinators) = Self::split_combinators(text);
if combinators.len() != parts.len().saturating_sub(1) || parts.is_empty() {
return None;
}
let mut parts = parts.into_iter();
let mut selector = Self::parse_compound(&parts.next()?)?;
let mut combinators = combinators.into_iter();
for part in parts {
let subject = Self::parse_compound(&part)?;
selector = match combinators.next()? {
CssCombinator::Descendant => {
CssSelector::Descendant(Box::new(selector), Box::new(subject))
}
CssCombinator::Child => CssSelector::Child(Box::new(selector), Box::new(subject)),
};
}
Some(selector)
}
fn parse_compound(text: &str) -> Option<CssSelector> {
let text = text.trim();
if text.is_empty() {
return None;
}
if text == "*" {
return Some(CssSelector::Universal);
}
let parts = Self::split_selector_parts(text);
let mut selectors = Vec::new();
for part in parts {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some(class_name) = part.strip_prefix('.') {
selectors.push(CssSelector::Class(class_name.to_string()));
} else if let Some(id_name) = part.strip_prefix('#') {
selectors.push(CssSelector::Id(id_name.to_string()));
} else if let Some(state_name) = part.strip_prefix(':') {
let state = Self::parse_pseudo_state(state_name)?;
selectors.push(CssSelector::State(state));
} else if part == "*" {
selectors.push(CssSelector::Universal);
} else {
selectors.push(CssSelector::Kind(part.to_string()));
}
}
match selectors.len() {
0 => None,
1 => Some(selectors.into_iter().next().unwrap()),
_ => Some(CssSelector::And(selectors)),
}
}
fn split_combinators(text: &str) -> (Vec<String>, Vec<CssCombinator>) {
let mut parts = Vec::new();
let mut joins = Vec::new();
let mut current = String::new();
let mut pending_descendant = false;
let flush = |current: &mut String, parts: &mut Vec<String>| {
let trimmed = current.trim();
if !trimmed.is_empty() {
parts.push(trimmed.to_string());
}
current.clear();
};
for ch in text.chars() {
match ch {
'>' => {
flush(&mut current, &mut parts);
pending_descendant = false;
joins.push(CssCombinator::Child);
}
c if c.is_whitespace() => {
if !current.trim().is_empty() {
flush(&mut current, &mut parts);
pending_descendant = true;
}
}
c => {
if pending_descendant && !parts.is_empty() {
joins.push(CssCombinator::Descendant);
pending_descendant = false;
}
current.push(c);
}
}
}
flush(&mut current, &mut parts);
(parts, joins)
}
fn split_selector_parts(text: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
for ch in text.chars() {
if ch == '.' || ch == '#' || ch == ':' {
if !current.is_empty() {
parts.push(current);
current = String::new();
}
current.push(ch);
} else if ch.is_whitespace() {
if !current.is_empty() {
parts.push(current);
current = String::new();
}
} else {
current.push(ch);
}
}
if !current.is_empty() {
parts.push(current);
}
parts
}
fn parse_pseudo_state(name: &str) -> Option<PseudoState> {
PseudoState::from_state_suffix(name)
}
fn parse_declarations(text: &str) -> Vec<CssDeclaration> {
let mut decls = Vec::new();
for line in text.split(';') {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(idx) = line.find(':') {
let property = line[..idx].trim().to_string();
let value = line[idx + 1..].trim().to_string();
if !property.is_empty() && !value.is_empty() {
decls.push(CssDeclaration { property, value });
}
}
}
decls
}
pub fn apply_declarations(
declarations: &[CssDeclaration],
style: &mut WidgetStyle,
) -> Result<(), String> {
for decl in declarations {
Self::apply_one(decl, style)?;
}
style.reconcile_shadow_into_surface();
Ok(())
}
pub fn apply_declaration_text(text: &str, style: &mut WidgetStyle) -> Result<(), String> {
if text.contains(';') {
return Self::apply_declarations_text(text, style);
}
let (property, value) = Self::split_declaration(text)?;
Self::apply_value(property, value, style)?;
style.reconcile_shadow_into_surface();
Ok(())
}
pub fn apply_declarations_text(text: &str, style: &mut WidgetStyle) -> Result<(), String> {
let Ok(declarations) = Self::cached_inline_declarations(text) else {
for segment in text.split(';') {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
Self::apply_declaration_text(segment, style)?;
}
return Ok(());
};
for (property, value) in declarations.iter() {
Self::apply_value(property, value, style)?;
style.reconcile_shadow_into_surface();
}
Ok(())
}
fn cached_inline_declarations(text: &str) -> Result<Rc<Vec<(String, String)>>, String> {
let hash = declaration_hash(text);
let hit = INLINE_DECL_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let entry = cache.get_mut(&hash)?;
if entry.text != text {
return None;
}
entry.last_used = next_cache_tick();
Some(Rc::clone(&entry.declarations))
});
if let Some(hit) = hit {
return Ok(hit);
}
let mut declarations = Vec::new();
for segment in text.split(';') {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let (property, value) = Self::split_declaration(segment)?;
declarations.push((String::from(property), String::from(value)));
}
let declarations = Rc::new(declarations);
INLINE_DECL_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
if cache.len() >= INLINE_DECL_CACHE_LIMIT {
let victim =
cache.iter().min_by_key(|(_, entry)| entry.last_used).map(|(key, _)| *key);
if let Some(victim) = victim {
cache.remove(&victim);
}
}
cache.insert(
hash,
InlineDeclEntry {
text: String::from(text),
declarations: Rc::clone(&declarations),
last_used: next_cache_tick(),
},
);
});
Ok(declarations)
}
fn split_declaration(text: &str) -> Result<(&str, &str), String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Err("empty style declaration".to_string());
}
let Some(index) = trimmed.find(':') else {
return Err(format!(
"style declaration {trimmed:?} has no ':' separator; expected \"property: value\""
));
};
let property = trimmed[..index].trim();
let value = trimmed[index + 1..].trim();
if property.is_empty() {
return Err(format!("style declaration {trimmed:?} has an empty property name"));
}
if value.is_empty() {
return Err(format!(
"style declaration {trimmed:?} has an empty value for property {property:?}"
));
}
Ok((property, value))
}
pub fn is_known_property(property: &str) -> bool {
KNOWN_PROPERTIES.iter().any(|known| known.eq_ignore_ascii_case(property))
}
pub fn known_properties() -> &'static [&'static str] {
KNOWN_PROPERTIES
}
fn cached_decl<T: Clone>(
kind: u8,
value: &str,
parse: impl Fn(&str) -> Result<CachedDeclValue, String>,
extract: impl Fn(&CachedDeclValue) -> Option<T>,
) -> Result<T, String> {
let key = (kind, declaration_hash(value));
let hit = DECL_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let entry = cache.get_mut(&key)?;
if entry.text != value {
return None;
}
entry.last_used = next_cache_tick();
Some(entry.value.clone())
});
if let Some(parsed) = hit {
if let Some(extracted) = extract(&parsed) {
return Ok(extracted);
}
}
let parsed = parse(value)?;
let extracted = extract(&parsed).ok_or_else(|| {
format!("internal: declaration {value:?} did not produce its expected value kind")
})?;
DECL_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
if cache.len() >= DECL_CACHE_LIMIT {
let victim =
cache.iter().min_by_key(|(_, entry)| entry.last_used).map(|(key, _)| *key);
if let Some(victim) = victim {
cache.remove(&victim);
}
}
cache.insert(
key,
DeclCacheEntry {
text: String::from(value),
value: parsed,
last_used: next_cache_tick(),
},
);
});
Ok(extracted)
}
fn cached_color(value: &str) -> Result<Color, String> {
Self::cached_decl(
0,
value,
|value| Self::parse_color(value).map(CachedDeclValue::Color),
|parsed| match parsed {
CachedDeclValue::Color(color) => Some(*color),
_ => None,
},
)
}
fn cached_length(value: &str) -> Result<u32, String> {
Self::cached_decl(
1,
value,
|value| Self::parse_length(value).map(CachedDeclValue::Length),
|parsed| match parsed {
CachedDeclValue::Length(length) => Some(*length),
_ => None,
},
)
}
fn cached_lengths(value: &str, count: usize) -> Result<Vec<u32>, String> {
Self::cached_decl(
2 + count as u8,
value,
|value| Self::parse_space_separated_lengths(value, count).map(CachedDeclValue::Lengths),
|parsed| match parsed {
CachedDeclValue::Lengths(lengths) => Some(lengths.clone()),
_ => None,
},
)
}
fn cached_float(value: &str) -> Result<f32, String> {
Self::cached_decl(
8,
value.trim(),
|value| Self::parse_float(value).map(CachedDeclValue::Float),
|parsed| match parsed {
CachedDeclValue::Float(number) => Some(*number),
_ => None,
},
)
}
fn cached_gradient(value: &str) -> Result<crate::style::Gradient, String> {
Self::cached_decl(
9,
value,
|value| Self::parse_gradient(value).map(CachedDeclValue::Gradient),
|parsed| match parsed {
CachedDeclValue::Gradient(gradient) => Some(gradient.clone()),
_ => None,
},
)
}
fn cached_shadow(value: &str) -> Result<crate::style::Shadow, String> {
Self::cached_decl(
10,
value,
|value| Self::parse_shadow(value).map(CachedDeclValue::Shadow),
|parsed| match parsed {
CachedDeclValue::Shadow(shadow) => Some(shadow.clone()),
_ => None,
},
)
}
#[cfg(test)]
fn clear_decl_cache() {
DECL_CACHE.with(|cache| cache.borrow_mut().clear());
}
#[cfg(test)]
fn decl_cache_len() -> usize {
DECL_CACHE.with(|cache| cache.borrow().len())
}
#[cfg(test)]
fn decl_cache_contains(value: &str) -> bool {
let key = (1u8, declaration_hash(value));
DECL_CACHE.with(|cache| cache.borrow().get(&key).is_some_and(|entry| entry.text == value))
}
fn apply_one(decl: &CssDeclaration, style: &mut WidgetStyle) -> Result<(), String> {
Self::apply_value(&decl.property, &decl.value, style)
}
pub fn validate_declaration(declaration: &CssDeclaration) -> Result<(), String> {
Self::apply_value_with(&declaration.property, &declaration.value, &mut ValueSink::Check)
}
fn apply_value(property: &str, value: &str, style: &mut WidgetStyle) -> Result<(), String> {
Self::apply_value_with(property, value, &mut ValueSink::Apply(style))
}
fn apply_value_with(
property: &str,
value: &str,
sink: &mut ValueSink<'_>,
) -> Result<(), String> {
if Self::apply_value_spelled(property, value, sink)? {
return Ok(());
}
if property.bytes().any(|byte| byte.is_ascii_uppercase()) {
let lowered = property.to_ascii_lowercase();
if Self::apply_value_spelled(&lowered, value, sink)? {
return Ok(());
}
}
Ok(())
}
fn apply_value_spelled(
property: &str,
value: &str,
sink: &mut ValueSink<'_>,
) -> Result<bool, String> {
match property {
"color" | "text-color" | "foreground" => {
sink.text_color(Self::cached_color(value)?);
}
"background" | "background-color" => {
sink.background_color(Self::cached_color(value)?);
}
"border-color" => {
sink.border_color(Self::cached_color(value)?);
}
"border-width" => {
sink.border_width(Self::cached_length(value)?);
}
"border-style" => {
sink.border_style(Self::parse_border_style(value)?);
}
"border-radius" => {
let vals = Self::cached_lengths(value, 4)?;
sink.corner_radii(CornerRadii::new(vals[0], vals[1], vals[2], vals[3]));
}
"padding" => {
let vals = Self::cached_lengths(value, 4)?;
sink.padding(crate::style::Padding::new(vals[0], vals[1], vals[2], vals[3]));
}
"padding-top" => sink.padding_top(Self::cached_length(value)?),
"padding-right" => sink.padding_right(Self::cached_length(value)?),
"padding-bottom" => sink.padding_bottom(Self::cached_length(value)?),
"padding-left" => sink.padding_left(Self::cached_length(value)?),
"margin" => {
let vals = Self::cached_lengths(value, 4)?;
sink.margin(crate::style::Margin::new(vals[0], vals[1], vals[2], vals[3]));
}
"margin-top" => sink.margin_top(Self::cached_length(value)?),
"margin-right" => sink.margin_right(Self::cached_length(value)?),
"margin-bottom" => sink.margin_bottom(Self::cached_length(value)?),
"margin-left" => sink.margin_left(Self::cached_length(value)?),
"opacity" => {
let val =
Self::parse_float(value).map_err(|_| format!("Invalid opacity: {value}"))?;
sink.opacity(crate::style::normalized_opacity(val));
}
"font-size" => {
let size = Self::cached_float(value)?;
sink.font(|font| {
font.set_size(size);
});
}
"font-weight" => {
let weight = Self::parse_font_weight(value)?;
sink.font(|font| *font = font.with_weight_value(weight));
}
"font-family" => {
let family = value.trim().trim_matches('"').trim_matches('\'').to_string();
sink.font(|font| {
font.set_family(family.clone());
});
}
"background-gradient" => {
sink.gradient(Self::cached_gradient(value)?);
}
"shadow" => {
if value.trim().eq_ignore_ascii_case("none") {
sink.shadow(None);
} else {
sink.shadow(Some(Self::cached_shadow(value)?));
}
}
"touch-target" | "touch-target-size" => {
let vals = Self::cached_lengths(value, 2)?;
sink.touch_target(crate::core::Size::new(vals[0], vals[1]));
}
_ => return Ok(false),
}
Ok(true)
}
fn parse_border_style(value: &str) -> Result<BorderStyle, String> {
let text = value.trim();
if text.eq_ignore_ascii_case("solid") {
Ok(BorderStyle::Solid)
} else if text.eq_ignore_ascii_case("dashed") {
Ok(BorderStyle::Dashed)
} else if text.eq_ignore_ascii_case("dotted") {
Ok(BorderStyle::Dotted)
} else if text.eq_ignore_ascii_case("none") {
Ok(BorderStyle::None)
} else {
Err(format!(
"unsupported border-style '{text}': expected `solid`, `dashed`, `dotted` or `none`"
))
}
}
fn parse_font_weight(value: &str) -> Result<u16, String> {
let text = value.trim();
let weight = if text.eq_ignore_ascii_case("normal") {
400
} else if text.eq_ignore_ascii_case("bold") {
700
} else if text.eq_ignore_ascii_case("lighter") {
300
} else if text.eq_ignore_ascii_case("bolder") {
700
} else {
let numeric: u16 = text.parse().map_err(|_| {
format!(
"invalid font-weight '{text}': expected 100..=900 or `normal`/`bold`/\
`lighter`/`bolder`"
)
})?;
let rounded = ((u32::from(numeric) + 50) / 100) * 100;
rounded.clamp(100, 900) as u16
};
Ok(weight)
}
pub fn parse_color(value: &str) -> Result<Color, String> {
let v = value.trim();
if let Some(hex) = v.strip_prefix('#') {
Self::parse_hex_color(hex)
} else if v.starts_with("rgb") {
Self::parse_rgb_color(v)
} else {
match v.to_lowercase().as_str() {
"red" => Ok(Color::rgba(255, 0, 0, 255)),
"green" | "lime" => Ok(Color::rgba(0, 255, 0, 255)),
"blue" => Ok(Color::rgba(0, 0, 255, 255)),
"white" => Ok(Color::rgba(255, 255, 255, 255)),
"black" => Ok(Color::rgba(0, 0, 0, 255)),
"gray" | "grey" => Ok(Color::rgba(128, 128, 128, 255)),
"yellow" => Ok(Color::rgba(255, 255, 0, 255)),
"cyan" | "aqua" => Ok(Color::rgba(0, 255, 255, 255)),
"magenta" | "fuchsia" => Ok(Color::rgba(255, 0, 255, 255)),
"orange" => Ok(Color::rgba(255, 165, 0, 255)),
"purple" => Ok(Color::rgba(128, 0, 128, 255)),
"navy" => Ok(Color::rgba(0, 0, 128, 255)),
"teal" => Ok(Color::rgba(0, 128, 128, 255)),
"maroon" => Ok(Color::rgba(128, 0, 0, 255)),
"olive" => Ok(Color::rgba(128, 128, 0, 255)),
"transparent" => Ok(Color::rgba(0, 0, 0, 0)),
_ => Err(format!(
"unknown color '{v}'; use a #RGB/#RRGGBB/#RRGGBBAA literal or one of \
the named colors (black, white, red, green, blue, yellow, cyan, magenta, \
gray, orange, purple, pink, brown, navy, teal, maroon, olive, transparent)"
)),
}
}
}
fn parse_hex_color(hex: &str) -> Result<Color, String> {
let hex = hex.trim();
if !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(format!("Invalid hex: {hex}"));
}
let (r, g, b, a) = match hex.len() {
3 => {
let r = u8::from_str_radix(&hex[0..1], 16)
.map_err(|_| format!("Invalid hex: {hex}"))?
* 17;
let g = u8::from_str_radix(&hex[1..2], 16)
.map_err(|_| format!("Invalid hex: {hex}"))?
* 17;
let b = u8::from_str_radix(&hex[2..3], 16)
.map_err(|_| format!("Invalid hex: {hex}"))?
* 17;
(r, g, b, 255)
}
6 => {
let r = u8::from_str_radix(&hex[0..2], 16)
.map_err(|_| format!("Invalid hex: {hex}"))?;
let g = u8::from_str_radix(&hex[2..4], 16)
.map_err(|_| format!("Invalid hex: {hex}"))?;
let b = u8::from_str_radix(&hex[4..6], 16)
.map_err(|_| format!("Invalid hex: {hex}"))?;
(r, g, b, 255)
}
8 => {
let r = u8::from_str_radix(&hex[0..2], 16)
.map_err(|_| format!("Invalid hex: {hex}"))?;
let g = u8::from_str_radix(&hex[2..4], 16)
.map_err(|_| format!("Invalid hex: {hex}"))?;
let b = u8::from_str_radix(&hex[4..6], 16)
.map_err(|_| format!("Invalid hex: {hex}"))?;
let a = u8::from_str_radix(&hex[6..8], 16)
.map_err(|_| format!("Invalid hex: {hex}"))?;
(r, g, b, a)
}
_ => return Err(format!("Invalid hex color length: #{hex}")),
};
Ok(Color::rgba(r, g, b, a))
}
fn parse_rgb_color(v: &str) -> Result<Color, String> {
let inner = v.trim_start_matches("rgba").trim_start_matches("rgb");
let inner = inner.trim_start_matches('(').trim_end_matches(')').trim();
let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect();
match parts.len() {
3 => {
let r: u8 = parts[0].parse().map_err(|_| format!("Invalid rgb: {v}"))?;
let g: u8 = parts[1].parse().map_err(|_| format!("Invalid rgb: {v}"))?;
let b: u8 = parts[2].parse().map_err(|_| format!("Invalid rgb: {v}"))?;
Ok(Color::rgba(r, g, b, 255))
}
4 => {
let r: u8 = parts[0].parse().map_err(|_| format!("Invalid rgba: {v}"))?;
let g: u8 = parts[1].parse().map_err(|_| format!("Invalid rgba: {v}"))?;
let b: u8 = parts[2].parse().map_err(|_| format!("Invalid rgba: {v}"))?;
let a: f32 = parts[3].parse().map_err(|_| format!("Invalid rgba: {v}"))?;
Ok(Color::rgba(r, g, b, (a * 255.0).round() as u8))
}
_ => Err(format!("Invalid rgb(a) format: {v}")),
}
}
fn parse_length(value: &str) -> Result<u32, String> {
let v = value.trim();
let num_str = v.trim_end_matches("px").trim_end_matches("pt").trim_end_matches("em").trim();
let f: f32 = num_str.parse().map_err(|_| format!("Invalid length: {value}"))?;
Ok(f.max(0.0) as u32)
}
fn parse_float(value: &str) -> Result<f32, String> {
let v = value.trim().trim_end_matches("px").trim_end_matches("pt").trim();
v.parse::<f32>().map_err(|_| format!("Invalid number: {value}"))
}
fn parse_space_separated_lengths(value: &str, count: usize) -> Result<Vec<u32>, String> {
let parts: Vec<&str> = value.split_whitespace().collect();
let parsed: Vec<u32> =
parts.iter().map(|p| Self::parse_length(p)).collect::<Result<Vec<_>, _>>()?;
match parsed.len() {
1 => Ok(vec![parsed[0]; count]),
2 if count == 2 => Ok(parsed),
2 => Ok(vec![parsed[0], parsed[1], parsed[0], parsed[1]]),
3 => Ok(vec![parsed[0], parsed[1], parsed[2], parsed[1]]),
4 => Ok(parsed),
_ => Err(format!("Expected 1-4 values for spacing, got {}", parts.len())),
}
}
fn parse_gradient(value: &str) -> Result<crate::style::Gradient, String> {
let v = value.trim();
if v.eq_ignore_ascii_case("none") {
return Err(
"'none' is not a gradient; omit the declaration to leave a background colour, or \
write `background-color: transparent`"
.to_string(),
);
}
let inner = v
.strip_prefix("linear-gradient(")
.or_else(|| v.strip_prefix("linear("))
.and_then(|rest| rest.strip_suffix(')'))
.ok_or_else(|| {
format!(
"unsupported gradient '{v}': only `linear-gradient(<angle>deg, <color> [<pos>], \
...)` is supported (radial and conic ramps need geometry the theme schema does \
not carry)"
)
})?;
let mut parts = inner.split(',').map(str::trim).filter(|p| !p.is_empty());
let mut angle = 180.0_f32;
let mut stops: Vec<crate::style::GradientStop> = Vec::new();
let mut pending: Vec<(crate::core::Color, Option<f32>)> = Vec::new();
for part in parts.by_ref() {
if let Some(deg) = part.strip_suffix("deg") {
angle = deg.trim().parse::<f32>().map_err(|_| {
format!("invalid gradient angle in '{v}': '{part}' is not a number of degrees")
})?;
continue;
}
let mut tokens = part.split_whitespace();
let color_token =
tokens.next().ok_or_else(|| format!("gradient stop in '{v}' has no colour"))?;
let color = Self::parse_color(color_token)?;
let position = match tokens.next() {
Some(raw) => Some(Self::parse_stop_position(raw)?),
None => None,
};
pending.push((color, position));
}
if pending.len() < 2 {
return Err(format!(
"gradient '{v}' needs at least two colour stops, found {}",
pending.len()
));
}
let last = pending.len() - 1;
for (index, (color, position)) in pending.into_iter().enumerate() {
let resolved = position.unwrap_or(index as f32 / last as f32);
stops.push(crate::style::GradientStop::new(resolved, color));
}
Ok(crate::style::Gradient::linear(
crate::core::Point::new(0, 0),
crate::core::Point::new(0, 0),
)
.with_stops(stops)
.with_angle(angle))
}
fn parse_stop_position(value: &str) -> Result<f32, String> {
let v = value.trim();
let fraction = if let Some(percent) = v.strip_suffix('%') {
percent.trim().parse::<f32>().map_err(|_| {
format!("invalid gradient stop position '{value}': not a percentage")
})? / 100.0
} else {
v.parse::<f32>().map_err(|_| {
format!("invalid gradient stop position '{value}': not a number or percentage")
})?
};
Ok(fraction.clamp(0.0, 1.0))
}
fn parse_shadow(value: &str) -> Result<crate::style::Shadow, String> {
let v = value.trim();
let parts: Vec<&str> = v.split_whitespace().collect();
if parts.len() != 4 {
return Err(format!(
"shadow '{v}' must be `<offset-x> <offset-y> <blur> <color>`, found {} part(s)",
parts.len()
));
}
let (color, lengths): (crate::core::Color, &[&str]) =
if let Ok(color) = Self::parse_color(parts[3]) {
(color, &parts[..3])
} else if let Ok(color) = Self::parse_color(parts[0]) {
(color, &parts[1..])
} else {
return Err(format!(
"shadow '{v}' has no recognisable colour; expected a #RGB/#RRGGBB/#RRGGBBAA \
literal or a named colour"
));
};
let offset_x = Self::parse_signed_length(lengths[0])?;
let offset_y = Self::parse_signed_length(lengths[1])?;
let blur = Self::parse_length(lengths[2])?;
Ok(crate::style::Shadow { x: offset_x, y: offset_y, blur, color })
}
fn parse_signed_length(value: &str) -> Result<i32, String> {
let v = value.trim();
let num_str = v.trim_end_matches("px").trim_end_matches("pt").trim_end_matches("em").trim();
let f: f32 = num_str.parse().map_err(|_| format!("Invalid length: {value}"))?;
Ok(f.round() as i32)
}
fn skip_whitespace_and_comments(chars: &[char], mut pos: usize) -> usize {
while pos < chars.len() {
if pos + 1 < chars.len() && chars[pos] == '/' && chars[pos + 1] == '*' {
pos += 2;
while pos + 1 < chars.len() && !(chars[pos] == '*' && chars[pos + 1] == '/') {
pos += 1;
}
pos += 2; continue;
}
if pos + 1 < chars.len() && chars[pos] == '/' && chars[pos + 1] == '/' {
pos += 2;
while pos < chars.len() && chars[pos] != '\n' {
pos += 1;
}
pos += 1;
continue;
}
if !chars[pos].is_whitespace() {
break;
}
pos += 1;
}
pos
}
}
impl CssParser {
pub fn parse_and_apply(
css: &str,
kind: &str,
class: Option<&str>,
id: Option<&str>,
state: Option<PseudoState>,
style: &mut WidgetStyle,
) -> Result<(), String> {
let rules = Self::parse_rules(css)?;
for rule in &rules {
if let Some(selector) = Self::parse_selector(&rule.selector_text) {
if selector.matches(kind, class, id, state) {
Self::apply_declarations(&rule.declarations, style)?;
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unknown_pseudo_class_makes_the_selector_match_nothing() {
assert!(CssParser::parse_selector("Button:hoover").is_none());
assert!(CssParser::parse_selector("Button:focus-within").is_none());
assert!(CssParser::parse_selector("Button:hover").is_some());
}
#[test]
fn a_non_ascii_hex_colour_is_rejected_without_panicking() {
for value in ["#日本", "#日aaaaa", "#zzzzzz", "#fffz"] {
assert!(CssParser::parse_color(value).is_err(), "{value} must be rejected");
}
}
#[test]
fn specificity_ranks_a_class_above_a_kind() {
let kind = CssParser::parse_selector("Button").expect("kind selector");
let class = CssParser::parse_selector("Button.primary").expect("class selector");
let id = CssParser::parse_selector("#ok").expect("id selector");
assert!(kind.specificity() < class.specificity());
assert!(class.specificity() < id.specificity());
}
#[test]
fn test_parse_simple_rule() {
let css = "Button { color: #333; font-size: 14px; }";
let rules = CssParser::parse_rules(css).unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].selector_text, "Button");
assert_eq!(rules[0].declarations.len(), 2);
}
#[test]
fn test_parse_class_selector() {
let css = ".primary { background: #0066cc; }";
let rules = CssParser::parse_rules(css).unwrap();
assert_eq!(rules[0].selector_text, ".primary");
}
#[test]
fn test_parse_id_selector() {
let css = "#submit { color: red; }";
let rules = CssParser::parse_rules(css).unwrap();
assert_eq!(rules[0].selector_text, "#submit");
}
#[test]
fn test_parse_pseudo_selector() {
let css = "Button:hover { background: blue; }";
let rules = CssParser::parse_rules(css).unwrap();
assert_eq!(rules[0].selector_text, "Button:hover");
}
#[test]
fn the_validation_states_parse_as_pseudo_classes() {
for (name, expected) in [
("error", PseudoState::Error),
("invalid", PseudoState::Error),
("warning", PseudoState::Warning),
("success", PseudoState::Success),
("valid", PseudoState::Success),
] {
assert_eq!(
CssParser::parse_pseudo_state(name),
Some(expected),
"`{name}` must resolve to the state the theme's `\"<kind>:<state>\"` keys use"
);
let css = format!("LineEdit:{name} {{ background: blue; }}");
let rules = CssParser::parse_rules(&css)
.unwrap_or_else(|e| panic!("LineEdit:{name} failed to parse: {e:?}"));
assert_eq!(rules.len(), 1, "LineEdit:{name}");
assert_eq!(rules[0].selector_text, format!("LineEdit:{name}"));
}
}
#[test]
fn an_unknown_pseudo_class_is_rejected_rather_than_normal() {
assert_eq!(CssParser::parse_pseudo_state("erorr"), None);
assert_eq!(CssParser::parse_pseudo_state("normal"), Some(PseudoState::Normal));
assert_ne!(CssParser::parse_pseudo_state("erorr"), Some(PseudoState::Normal));
}
#[test]
fn test_parse_multiple_rules() {
let css = r#"
Button { color: black; }
Label { color: gray; font-size: 12px; }
.warn { color: red; }
"#;
let rules = CssParser::parse_rules(css).unwrap();
assert_eq!(rules.len(), 3);
}
#[test]
fn test_parse_hex_color_3() {
let c = CssParser::parse_color("#f00").unwrap();
assert_eq!(c, Color::rgba(255, 0, 0, 255));
}
#[test]
fn test_parse_hex_color_6() {
let c = CssParser::parse_color("#ff0000").unwrap();
assert_eq!(c, Color::rgba(255, 0, 0, 255));
}
#[test]
fn test_parse_hex_color_8() {
let c = CssParser::parse_color("#ff000080").unwrap();
assert_eq!(c, Color::rgba(255, 0, 0, 128));
}
#[test]
fn test_parse_rgb_color() {
let c = CssParser::parse_color("rgb(255, 0, 0)").unwrap();
assert_eq!(c, Color::rgba(255, 0, 0, 255));
}
#[test]
fn test_parse_rgba_color() {
let c = CssParser::parse_color("rgba(255, 0, 0, 0.5)").unwrap();
assert_eq!(c, Color::rgba(255, 0, 0, 128));
}
#[test]
fn test_parse_named_colors() {
let c = CssParser::parse_color("red").unwrap();
assert_eq!(c, Color::rgba(255, 0, 0, 255));
let c = CssParser::parse_color("white").unwrap();
assert_eq!(c, Color::rgba(255, 255, 255, 255));
let c = CssParser::parse_color("transparent").unwrap();
assert_eq!(c, Color::rgba(0, 0, 0, 0));
}
#[test]
fn test_parse_length() {
assert_eq!(CssParser::parse_length("10px").unwrap(), 10);
assert_eq!(CssParser::parse_length("0").unwrap(), 0);
assert_eq!(CssParser::parse_length("5.5px").unwrap(), 5);
}
#[test]
fn test_parse_spacing_shorthand_1() {
let vals = CssParser::parse_space_separated_lengths("10px", 4).unwrap();
assert_eq!(vals, vec![10, 10, 10, 10]);
}
#[test]
fn test_parse_spacing_shorthand_2() {
let vals = CssParser::parse_space_separated_lengths("10px 20px", 4).unwrap();
assert_eq!(vals, vec![10, 20, 10, 20]);
}
#[test]
fn test_parse_spacing_shorthand_4() {
let vals = CssParser::parse_space_separated_lengths("1px 2px 3px 4px", 4).unwrap();
assert_eq!(vals, vec![1, 2, 3, 4]);
}
#[test]
fn declaration_values_are_converted_once_per_distinct_text() {
DECL_CACHE.with(|cache| cache.borrow_mut().clear());
let mut style = WidgetStyle::default();
CssParser::apply_declarations_text("background: #123456; padding: 4px 8px", &mut style)
.expect("valid inline CSS");
let first = DECL_CACHE.with(|cache| cache.borrow().len());
CssParser::apply_declarations_text("background: #123456; padding: 4px 8px", &mut style)
.expect("same inline CSS");
assert_eq!(
DECL_CACHE.with(|cache| cache.borrow().len()),
first,
"a repeated apply must reuse the converted values"
);
assert_eq!(style.background_color, Some(Color::rgba(0x12, 0x34, 0x56, 255)));
}
#[test]
fn test_apply_color_declaration() {
let mut style = WidgetStyle::default();
let decl = CssDeclaration { property: "color".into(), value: "#333".into() };
CssParser::apply_declarations(&[decl], &mut style).unwrap();
assert_eq!(style.text_color, Some(Color::rgba(51, 51, 51, 255)));
}
#[test]
fn test_apply_background_declaration() {
let mut style = WidgetStyle::default();
let decl = CssDeclaration { property: "background".into(), value: "#0066cc".into() };
CssParser::apply_declarations(&[decl], &mut style).unwrap();
assert_eq!(style.background_color, Some(Color::rgba(0, 102, 204, 255)));
}
#[test]
fn test_parse_complex_stylesheet() {
let css = r#"
/* Button base style */
Button {
color: #333;
font-size: 14px;
padding: 8px 16px;
border-radius: 4px;
}
/* Primary variant */
.primary {
background: #0066cc;
color: white;
}
/* Hover state */
Button:hover {
background: #0052a3;
}
/* Disabled state */
Button:disabled {
opacity: 0.5;
}
"#;
let rules = CssParser::parse_rules(css).unwrap();
assert_eq!(rules.len(), 4);
let mut style = WidgetStyle::default();
for rule in &rules {
if let Some(sel) = CssParser::parse_selector(&rule.selector_text) {
if sel.matches("Button", Some("primary"), None, None) {
CssParser::apply_declarations(&rule.declarations, &mut style).unwrap();
}
}
}
assert_eq!(style.border_radius, Some(4));
assert_eq!(style.background_color, Some(Color::rgba(0, 102, 204, 255)));
}
#[test]
fn test_selector_parser() {
let sel = CssParser::parse_selector("Button").unwrap();
assert_eq!(sel, CssSelector::Kind("Button".to_string()));
let sel = CssParser::parse_selector(".primary").unwrap();
assert_eq!(sel, CssSelector::Class("primary".to_string()));
let sel = CssParser::parse_selector("#main").unwrap();
assert_eq!(sel, CssSelector::Id("main".to_string()));
let sel = CssParser::parse_selector(":hover").unwrap();
assert_eq!(sel, CssSelector::State(PseudoState::Hover));
}
#[test]
fn test_parse_empty_returns_no_rules() {
let rules = CssParser::parse_rules("").unwrap();
assert!(rules.is_empty());
}
#[test]
fn test_parse_error_unclosed_brace() {
let result = CssParser::parse_rules("Button { color: red; ");
assert!(result.is_err());
}
#[test]
fn test_parse_comments_are_ignored() {
let css = r#"
/* this is a comment */
Button { color: red; }
/* another comment */
Label { color: blue; }
"#;
let rules = CssParser::parse_rules(css).unwrap();
assert_eq!(rules.len(), 2);
}
#[test]
fn test_opacity_declaration() {
let mut style = WidgetStyle::default();
let decl = CssDeclaration { property: "opacity".into(), value: "0.5".into() };
CssParser::apply_declarations(&[decl], &mut style).unwrap();
assert!((style.opacity.unwrap() - 0.5).abs() < 0.01);
}
#[test]
fn opacity_declaration_normalizes_non_finite_values() {
let mut style = WidgetStyle::default();
CssParser::apply_declaration_text("opacity: NaN", &mut style).expect("NaN parses as f32");
assert_eq!(style.opacity, Some(1.0), "NaN must normalise to opaque, not stay NaN");
let mut style = WidgetStyle::default();
CssParser::apply_declaration_text("opacity: inf", &mut style).expect("+inf parses as f32");
assert_eq!(style.opacity, Some(1.0));
let mut style = WidgetStyle::default();
CssParser::apply_declaration_text("opacity: -inf", &mut style).expect("-inf parses as f32");
assert_eq!(style.opacity, Some(0.0));
let mut style = WidgetStyle::default();
CssParser::apply_declaration_text("opacity: 2", &mut style).expect("finite out of range");
assert_eq!(style.opacity, Some(1.0));
}
#[test]
fn test_margin_shorthand() {
let mut style = WidgetStyle::default();
let decl = CssDeclaration { property: "margin".into(), value: "10px 20px".into() };
CssParser::apply_declarations(&[decl], &mut style).unwrap();
assert_eq!(style.margin.top, 10);
assert_eq!(style.margin.right, 20);
assert_eq!(style.margin.bottom, 10);
assert_eq!(style.margin.left, 20);
}
#[test]
fn unknown_kind_selects_nothing() {
let selector = CssParser::parse_selector("NotAWidget").expect("the text parses");
assert_eq!(selector.to_selector(), None);
}
#[test]
fn known_kind_still_resolves() {
let selector = CssParser::parse_selector("Button").expect("the text parses");
assert_eq!(selector.to_selector(), Some(Selector::Kind(WidgetKind::Button)));
}
#[test]
fn every_kind_selector_spelling_resolves() {
#[allow(unused_mut)]
let mut cases: alloc::vec::Vec<(&str, WidgetKind)> = alloc::vec::Vec::from([
("Button", WidgetKind::Button),
("button", WidgetKind::Button),
("CheckBox", WidgetKind::CheckBox),
("RadioButton", WidgetKind::RadioButton),
("LineEdit", WidgetKind::LineEdit),
("ScrollArea", WidgetKind::ScrollArea),
("ScrollBar", WidgetKind::ScrollBar),
("ProgressBar", WidgetKind::ProgressBar),
("GroupBox", WidgetKind::GroupBox),
("ImageView", WidgetKind::ImageView),
("ListBox", WidgetKind::ListBox),
("SpinBox", WidgetKind::SpinBox),
("ComboBox", WidgetKind::ComboBox),
]);
#[cfg(widgets_unstripped)]
{
cases.extend_from_slice(&[
("MenuBar", WidgetKind::MenuBar),
("menu_bar", WidgetKind::MenuBar),
("menubar", WidgetKind::MenuBar),
("ToolBar", WidgetKind::ToolBar),
("tool_bar", WidgetKind::ToolBar),
("StatusBar", WidgetKind::StatusBar),
("status_bar", WidgetKind::StatusBar),
("TabWidget", WidgetKind::TabWidget),
("MenuButton", WidgetKind::MenuButton),
("Splitter", WidgetKind::Splitter),
]);
}
for (spelling, expected) in cases {
assert_eq!(
widget_kind_from_str(spelling),
Some(expected),
"{spelling:?} must resolve to {expected:?}"
);
}
assert_eq!(widget_kind_from_str("NotAWidget"), None);
}
#[test]
fn a_kind_selector_accepts_the_same_spellings_the_factory_does() {
let widget_kind = "MenuBar";
for spelling in ["MenuBar", "menubar", "menu_bar", "MENU-BAR"] {
let selector = CssSelector::Kind(spelling.to_string());
assert!(
selector.matches(widget_kind, None, None, None),
"selector {spelling:?} must match the widget kind {widget_kind:?}"
);
}
assert!(!CssSelector::Kind("menu".to_string()).matches(widget_kind, None, None, None));
assert!(!CssSelector::Kind("tool_bar".to_string()).matches(widget_kind, None, None, None));
}
#[test]
fn a_separator_spelled_kind_selector_applies_its_rule() {
for spelling in ["menu_bar", "MenuBar", "menubar"] {
let css = format!("{spelling} {{ background-color: #ff0000; }}");
let mut style = WidgetStyle::default();
CssParser::parse_and_apply(&css, "MenuBar", None, None, None, &mut style)
.expect("the rule must parse");
assert_eq!(
style.background_color,
Some(Color::rgb(255, 0, 0)),
"the {spelling:?} rule must apply to a MenuBar"
);
}
}
#[test]
fn an_unknown_kind_never_becomes_a_window() {
let selector = CssParser::parse_selector("Stepper").expect("the text parses");
assert_ne!(
selector.to_selector(),
Some(Selector::Kind(WidgetKind::Window)),
"an unknown name must not be reinterpreted as Window"
);
}
#[test]
fn a_compound_selector_with_an_unknown_kind_selects_nothing() {
let selector = CssParser::parse_selector("NotAWidget.primary").expect("the text parses");
assert_eq!(selector.to_selector(), None);
}
#[test]
fn parse_skips_only_the_rule_with_an_unknown_kind() {
let css = "Button { color: #111; }\nStepper { color: #222; }";
let sheet = CssParser::parse(css).expect("the sheet parses");
let kinds: Vec<_> = sheet.rules().iter().map(|rule| rule.selector.clone()).collect();
assert_eq!(kinds, vec![Selector::Kind(WidgetKind::Button)], "only the known rule lands");
}
#[test]
fn the_style_fields_a_stylesheet_should_set_are_settable() {
let declarations = [
CssDeclaration {
property: "background-gradient".into(),
value: "linear-gradient(90deg, #ff0000, #0000ff)".into(),
},
CssDeclaration { property: "shadow".into(), value: "2px 3px 4px #112233".into() },
CssDeclaration { property: "touch-target".into(), value: "44px 44px".into() },
];
let mut style = WidgetStyle::default();
CssParser::apply_declarations(&declarations, &mut style).expect("all three must apply");
let gradient = style.background_gradient.expect("a gradient must be set");
assert_eq!(gradient.stops.len(), 2, "two stops, evenly distributed");
assert_eq!(gradient.stops[0].color, Color::rgba(255, 0, 0, 255));
assert_eq!(gradient.stops[1].color, Color::rgba(0, 0, 255, 255));
assert_eq!(gradient.stops[0].position, 0.0);
assert_eq!(gradient.stops[1].position, 1.0);
let shadow = style.shadow.expect("a shadow must be set");
assert_eq!((shadow.x, shadow.y, shadow.blur), (2, 3, 4));
assert_eq!(shadow.color, Color::rgba(0x11, 0x22, 0x33, 255));
assert_eq!(style.touch_target, Some(crate::core::Size::new(44, 44)));
}
#[test]
fn gradient_stop_positions_are_honoured() {
let decl = CssDeclaration {
property: "background-gradient".into(),
value: "linear-gradient(#ff0000 0%, #00ff00 25%, #0000ff)".into(),
};
let mut style = WidgetStyle::default();
CssParser::apply_declarations(&[decl], &mut style).expect("apply");
let stops = style.background_gradient.expect("gradient").stops;
assert_eq!(stops.len(), 3);
assert_eq!(stops[0].position, 0.0);
assert_eq!(stops[1].position, 0.25);
assert_eq!(stops[2].position, 1.0);
}
#[test]
fn shadow_none_clears_a_shadow() {
let mut style = WidgetStyle::default();
let set = CssDeclaration { property: "shadow".into(), value: "1px 1px 2px #000000".into() };
CssParser::apply_declarations(&[set], &mut style).expect("apply");
assert!(style.shadow.is_some());
let clear = CssDeclaration { property: "shadow".into(), value: "none".into() };
CssParser::apply_declarations(&[clear], &mut style).expect("apply");
assert_eq!(style.shadow, None, "`none` must clear the shadow");
}
#[test]
fn a_negative_shadow_offset_is_preserved() {
let decl =
CssDeclaration { property: "shadow".into(), value: "-2px 2px 4px #000000".into() };
let mut style = WidgetStyle::default();
CssParser::apply_declarations(&[decl], &mut style).expect("apply");
let shadow = style.shadow.expect("shadow");
assert_eq!(shadow.x, -2, "a negative offset must not be clamped to 0");
assert_eq!(shadow.y, 2);
}
#[test]
fn a_shadow_may_lead_with_its_colour() {
let decl =
CssDeclaration { property: "shadow".into(), value: "#000000 1px 2px 3px".into() };
let mut style = WidgetStyle::default();
CssParser::apply_declarations(&[decl], &mut style).expect("apply");
let shadow = style.shadow.expect("shadow");
assert_eq!((shadow.x, shadow.y, shadow.blur), (1, 2, 3));
assert_eq!(shadow.color, Color::rgba(0, 0, 0, 255));
}
#[test]
fn an_authored_shadow_reaches_the_painted_surface() {
let decl =
CssDeclaration { property: "shadow".into(), value: "3px 4px 6px #000080".into() };
let mut style = WidgetStyle::default();
CssParser::apply_declarations(&[decl], &mut style).expect("apply");
let surface = style.surface.expect("applying a shadow must leave a surface behind");
let painted = surface
.shadow(Color::BLACK)
.expect("the authored shadow must be the one the renderer asks for");
assert_eq!(
(painted.x, painted.y, painted.blur),
(3, 4, 6),
"the four authored numbers must survive to the render layer: {painted:?}"
);
assert_eq!(
painted.color,
Color::rgba(0, 0, 128, 255),
"including the colour, which the elevation ladder could not have expressed"
);
}
#[test]
fn clearing_a_shadow_leaves_the_surfaces_own_shadow_alone() {
let mut style = WidgetStyle::default();
CssParser::apply_declaration_text("shadow: 1px 1px 2px #000000", &mut style)
.expect("apply");
CssParser::apply_declaration_text("shadow: none", &mut style).expect("clear");
assert!(style.shadow.is_none(), "`none` clears the authored value");
let surface = style.surface.expect("the surface survives");
assert!(
surface.custom_shadow.is_none(),
"and the override goes with it, so the level's own shadow applies"
);
}
#[test]
fn malformed_gradient_and_shadow_values_are_reported() {
let cases = [
("background-gradient", "radial-gradient(#ff0000, #0000ff)"),
("background-gradient", "linear-gradient(#ff0000)"),
("background-gradient", "not a gradient"),
("shadow", "1px 2px #000000"),
("shadow", "1px 2px 3px not-a-colour"),
];
for (property, value) in cases {
let decl = CssDeclaration { property: property.into(), value: value.into() };
let mut style = WidgetStyle::default();
assert!(
CssParser::apply_declarations(&[decl], &mut style).is_err(),
"{property}: {value:?} should be rejected, not silently ignored"
);
}
}
#[test]
fn an_unsupported_gradient_geometry_says_so() {
let decl = CssDeclaration {
property: "background-gradient".into(),
value: "radial-gradient(#ff0000, #0000ff)".into(),
};
let mut style = WidgetStyle::default();
let error = CssParser::apply_declarations(&[decl], &mut style).expect_err("must reject");
assert!(error.contains("linear-gradient"), "{error}");
assert!(
error.contains("radial") || error.contains("geometry"),
"the message must name the unsupported geometry: {error}"
);
}
#[test]
fn known_property_list_matches_what_apply_one_handles() {
for property in CssParser::known_properties() {
assert!(CssParser::is_known_property(property), "{property} should be known");
let before = WidgetStyle::default();
let mut after = before.clone();
let value = sample_value(property);
CssParser::apply_declaration_text(&format!("{property}: {value}"), &mut after)
.unwrap_or_else(|error| panic!("{property} should parse, got: {error}"));
assert_ne!(
format!("{after:?}"),
format!("{before:?}"),
"{property} is listed as known but applying it changed nothing"
);
let declaration =
CssDeclaration { property: String::from(*property), value: String::from(value) };
assert!(
CssParser::validate_declaration(&declaration).is_ok(),
"{property} must validate the same value it applies"
);
}
assert!(CssParser::is_known_property("Color"));
let mut cased = WidgetStyle::default();
CssParser::apply_declaration_text("Color: #123456", &mut cased)
.expect("a case variant is a declaration like any other");
assert_eq!(cased.text_color, Some(Color::rgba(0x12, 0x34, 0x56, 255)));
assert!(!CssParser::is_known_property("backgrond-color"));
let mut style = WidgetStyle::default();
let error = CssParser::apply_declaration_text("not-a-declaration", &mut style)
.expect_err("a declaration with no ':' must be refused");
assert!(error.contains("':'"), "the error should name the missing separator: {error}");
let error = CssParser::apply_declaration_text("background-color:", &mut style)
.expect_err("an empty value must be refused");
assert!(error.contains("empty value"), "the error should say the value is empty: {error}");
assert!(CssParser::apply_declaration_text(" ", &mut style).is_err());
}
fn sample_value(property: &str) -> &'static str {
match property {
"color" | "text-color" | "foreground" | "background" | "background-color"
| "border-color" => "#123456",
"border-width" | "border-radius" | "padding-top" | "padding-right"
| "padding-bottom" | "padding-left" | "margin-top" | "margin-right"
| "margin-bottom" | "margin-left" => "2",
"border-style" => "dashed",
"font-size" => "16",
"font-weight" => "700",
"font-family" => "Inter",
"padding" | "margin" | "touch-target" | "touch-target-size" => "2 2",
"opacity" => "0.5",
"background-gradient" => "linear-gradient(90deg, #FFF, #000)",
"shadow" => "0 1 2 #000",
other => panic!("the lock needs a sample value for `{other}`"),
}
}
#[test]
fn border_radius_shorthand_maps_one_to_four_values() {
let apply = |value: &str| {
let mut style = WidgetStyle::default();
CssParser::apply_declaration_text(&format!("border-radius: {value}"), &mut style)
.unwrap_or_else(|error| panic!("border-radius: {value} should parse: {error}"));
style
};
let one = apply("4px");
assert_eq!(one.resolved_corner_radii(), crate::style::CornerRadii::all(4));
assert_eq!(one.border_radius, Some(4), "the scalar keeps describing a uniform shape");
let two = apply("4px 8px");
assert_eq!(
two.resolved_corner_radii(),
crate::style::CornerRadii::new(4, 8, 4, 8),
"two values are (top/bottom, left/right)"
);
let three = apply("4px 8px 12px");
assert_eq!(
three.resolved_corner_radii(),
crate::style::CornerRadii::new(4, 8, 12, 8),
"three values are (top-left, top-right/bottom-left, bottom-right)"
);
let four = apply("1px 2px 3px 4px");
assert_eq!(
four.resolved_corner_radii(),
crate::style::CornerRadii::new(1, 2, 3, 4),
"four values are clockwise from the top-left"
);
assert_eq!(
four.border_radius, None,
"a non-uniform shape cannot be written into the scalar honestly"
);
}
#[test]
fn font_weight_sets_the_fonts_weight() {
for (value, expected) in
[("700", 700u16), ("400", 400), ("bold", 700), ("normal", 400), ("450", 500)]
{
let mut style = WidgetStyle::default();
CssParser::apply_declaration_text(&format!("font-weight: {value}"), &mut style)
.unwrap_or_else(|error| panic!("font-weight: {value} should parse: {error}"));
let font = style.font.expect("a weight needs a font to live on");
assert_eq!(font.weight(), expected, "font-weight: {value}");
}
let error =
CssParser::apply_declaration_text("font-weight: heavy", &mut WidgetStyle::default())
.expect_err("an unsupported weight word must be refused");
assert!(error.contains("font-weight"), "{error}");
}
#[test]
fn border_style_sets_the_border_style() {
for (value, expected) in [
("solid", crate::style::BorderStyle::Solid),
("dashed", crate::style::BorderStyle::Dashed),
("Dotted", crate::style::BorderStyle::Dotted),
("none", crate::style::BorderStyle::None),
] {
let mut style = WidgetStyle::default();
CssParser::apply_declaration_text(&format!("border-style: {value}"), &mut style)
.unwrap_or_else(|error| panic!("border-style: {value} should parse: {error}"));
assert_eq!(style.border_style, Some(expected), "border-style: {value}");
}
let error =
CssParser::apply_declaration_text("border-style: wavy", &mut WidgetStyle::default())
.expect_err("an unsupported line style must be refused");
assert!(error.contains("border-style"), "{error}");
}
#[test]
fn the_declaration_cache_evicts_one_entry_instead_of_clearing() {
CssParser::clear_decl_cache();
for value in 0..(DECL_CACHE_LIMIT + 8) {
let declaration = format!("border-width: {value}px");
CssParser::apply_declaration_text(&declaration, &mut WidgetStyle::default())
.unwrap_or_else(|error| panic!("{declaration} should parse: {error}"));
}
assert_eq!(
CssParser::decl_cache_len(),
DECL_CACHE_LIMIT,
"the cache keeps its capacity; a whole-table clear would leave only the tail"
);
assert!(
CssParser::decl_cache_contains(&format!("{}px", DECL_CACHE_LIMIT + 7)),
"the newest value must stay warm"
);
assert!(!CssParser::decl_cache_contains("0px"), "the oldest value must be the one evicted");
let touched = format!("{}px", DECL_CACHE_LIMIT + 1);
CssParser::apply_declaration_text(
&format!("border-width: {touched}"),
&mut WidgetStyle::default(),
)
.expect("a cache hit");
CssParser::apply_declaration_text("border-width: 99999px", &mut WidgetStyle::default())
.expect("one more value, forcing an eviction");
assert!(
CssParser::decl_cache_contains(&touched),
"a recently used entry must survive eviction"
);
}
#[test]
fn combinators_parse_as_chains() {
let descendant = CssParser::parse_selector("Panel Button").expect("a descendant chain");
assert_eq!(
descendant,
CssSelector::Descendant(
Box::new(CssSelector::Kind("Panel".to_string())),
Box::new(CssSelector::Kind("Button".to_string())),
)
);
let child = CssParser::parse_selector("Panel > Button").expect("a child chain");
assert_eq!(
child,
CssSelector::Child(
Box::new(CssSelector::Kind("Panel".to_string())),
Box::new(CssSelector::Kind("Button".to_string())),
)
);
assert_eq!(CssParser::parse_selector("Panel>Button"), Some(child));
}
#[test]
fn a_compound_is_not_split_by_the_combinator_parser() {
let parsed = CssParser::parse_selector("Panel.primary > Button:hover").expect("a chain");
assert_eq!(
parsed,
CssSelector::Child(
Box::new(CssSelector::And(vec![
CssSelector::Kind("Panel".to_string()),
CssSelector::Class("primary".to_string()),
])),
Box::new(CssSelector::And(vec![
CssSelector::Kind("Button".to_string()),
CssSelector::State(PseudoState::Hover),
])),
)
);
}
#[test]
fn a_chain_without_a_subject_is_refused() {
assert_eq!(CssParser::parse_selector("Panel >"), None);
assert_eq!(CssParser::parse_selector("> Button"), None);
}
#[test]
fn a_parsed_chain_converts_and_carries_its_weight() {
let parsed = CssParser::parse_selector("Panel > Button").expect("a chain");
let selector = parsed.to_selector().expect("both kinds are known");
assert_eq!(selector.specificity(), 2);
assert!(matches!(selector, Selector::Child { .. }));
}
}