Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/theme.rs

943 lines
34 KiB
Rust

/// Theme system for Lightningbeam Editor
///
/// Parses CSS rules from assets/styles.css at runtime
/// and provides type-safe access to styles via selectors.
/// Supports cascading specificity with a 3-tier model:
/// Tier 1: :root CSS variables (design tokens)
/// Tier 2: Class selectors (.label, .button)
/// Tier 3: Compound/contextual (#timeline .label, .layer-header.hover)
use eframe::egui;
use lightningcss::stylesheet::{ParserOptions, PrinterOptions, StyleSheet};
use lightningcss::traits::ToCss;
use std::cell::RefCell;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeMode {
Light,
Dark,
System, // Follow system preference
}
impl ThemeMode {
/// Convert from string ("light", "dark", or "system")
pub fn from_string(s: &str) -> Self {
match s.to_lowercase().as_str() {
"light" => Self::Light,
"dark" => Self::Dark,
_ => Self::System,
}
}
/// Convert to lowercase string
pub fn to_string_lower(&self) -> String {
match self {
Self::Light => "light".to_string(),
Self::Dark => "dark".to_string(),
Self::System => "system".to_string(),
}
}
}
/// Background type for CSS backgrounds
#[derive(Debug, Clone)]
pub enum Background {
Solid(egui::Color32),
LinearGradient {
angle_degrees: f32,
stops: Vec<(f32, egui::Color32)>, // (position 0.0-1.0, color)
},
Image {
url: String,
},
}
/// Style properties that can be applied to UI elements
#[derive(Debug, Clone, Default)]
pub struct Style {
pub background: Option<Background>,
pub border_color: Option<egui::Color32>,
pub border_width: Option<f32>,
pub border_radius: Option<f32>,
pub text_color: Option<egui::Color32>,
pub width: Option<f32>,
pub height: Option<f32>,
pub padding: Option<f32>,
pub margin: Option<f32>,
pub font_size: Option<f32>,
pub opacity: Option<f32>,
}
impl Style {
/// Convenience: get background color if the background is Solid
pub fn background_color(&self) -> Option<egui::Color32> {
match &self.background {
Some(Background::Solid(c)) => Some(*c),
_ => None,
}
}
/// Merge another style on top of this one (other's values take precedence)
pub fn merge_over(&mut self, other: &Style) {
if other.background.is_some() {
self.background = other.background.clone();
}
if other.border_color.is_some() {
self.border_color = other.border_color;
}
if other.border_width.is_some() {
self.border_width = other.border_width;
}
if other.border_radius.is_some() {
self.border_radius = other.border_radius;
}
if other.text_color.is_some() {
self.text_color = other.text_color;
}
if other.width.is_some() {
self.width = other.width;
}
if other.height.is_some() {
self.height = other.height;
}
if other.padding.is_some() {
self.padding = other.padding;
}
if other.margin.is_some() {
self.margin = other.margin;
}
if other.font_size.is_some() {
self.font_size = other.font_size;
}
if other.opacity.is_some() {
self.opacity = other.opacity;
}
}
}
/// Parsed CSS selector with specificity
#[derive(Debug, Clone)]
struct ParsedSelector {
/// The original selector string parts, e.g. ["#timeline", ".label"]
/// For compound selectors like ".layer-header.hover", this is [".layer-header.hover"]
parts: Vec<String>,
/// Specificity: (id_count, class_count, source_order)
specificity: (u32, u32, u32),
}
impl ParsedSelector {
fn parse(selector_str: &str, source_order: u32) -> Self {
let parts: Vec<String> = selector_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
let mut id_count = 0u32;
let mut class_count = 0u32;
for part in &parts {
// Count IDs and classes within each part (compound selectors)
for segment in Self::split_compound(part) {
if segment.starts_with('#') {
id_count += 1;
} else if segment.starts_with('.') {
class_count += 1;
}
}
}
ParsedSelector {
parts,
specificity: (id_count, class_count, source_order),
}
}
/// Split a compound selector like ".layer-header.hover" into [".layer-header", ".hover"]
fn split_compound(s: &str) -> Vec<&str> {
let mut segments = Vec::new();
let mut start = 0;
let bytes = s.as_bytes();
for i in 1..bytes.len() {
if bytes[i] == b'.' || bytes[i] == b'#' {
segments.push(&s[start..i]);
start = i;
}
}
segments.push(&s[start..]);
segments
}
/// Check if this selector matches a given context stack.
/// Context stack is outermost to innermost, e.g. ["#timeline", ".layer-header", ".selected"]
///
/// Key rules:
/// - The LAST selector part must match the target element. The target is
/// identified by the trailing context entries. This prevents
/// `#timeline { background }` from bleeding into child elements.
/// - For compound selectors like `.piano-white-key.pressed`, the segments
/// can be spread across multiple trailing context entries.
/// - Ancestor parts (all but last) use descendant matching in order.
fn matches(&self, context: &[&str]) -> bool {
if self.parts.is_empty() || context.is_empty() {
return false;
}
// The last selector part must match among the trailing context entries.
// Collect all segments from ALL context entries, then check if the last
// selector part's segments are all present. But we also need to ensure
// the match is "anchored" to the tail — at least one segment of the last
// part must come from the very last context entry.
let last_part = &self.parts[self.parts.len() - 1];
let last_segments = Self::split_compound(last_part);
// Gather all class/id segments from all context entries
let all_context_segments: Vec<&str> = context.iter()
.flat_map(|e| Self::split_compound(e))
.collect();
// All segments of the last selector part must be present somewhere in context
if !last_segments.iter().all(|seg| all_context_segments.contains(seg)) {
return false;
}
// At least one segment of the last selector part must appear in the
// LAST context entry (anchors the match to the target element)
let last_ctx_segments = Self::split_compound(context[context.len() - 1]);
if !last_segments.iter().any(|seg| last_ctx_segments.contains(seg)) {
return false;
}
// For single-part selectors, target matched and there are no ancestors.
if self.parts.len() == 1 {
return true;
}
// For multi-part selectors (e.g., "#timeline .label"), match ancestor parts
// in order against context entries from the beginning.
// The last selector part's segments consume some context entries at the end;
// ancestor parts match against earlier entries.
//
// Find how far from the end the last part's segments extend.
let mut remaining_segments: Vec<&str> = last_segments.to_vec();
let mut target_start = context.len();
for i in (0..context.len()).rev() {
let ctx_segs = Self::split_compound(context[i]);
let before_len = remaining_segments.len();
remaining_segments.retain(|seg| !ctx_segs.contains(seg));
if remaining_segments.len() < before_len {
target_start = i;
}
if remaining_segments.is_empty() {
break;
}
}
let ancestor_context = &context[..target_start];
let ancestor_parts = &self.parts[..self.parts.len() - 1];
let mut ctx_idx = 0;
for part in ancestor_parts {
let part_segments = Self::split_compound(part);
let mut found = false;
while ctx_idx < ancestor_context.len() {
if Self::context_entry_contains_all(ancestor_context[ctx_idx], &part_segments) {
found = true;
ctx_idx += 1;
break;
}
ctx_idx += 1;
}
if !found {
return false;
}
}
true
}
/// Check if a context entry contains all the given selector segments
fn context_entry_contains_all(context_entry: &str, selector_segments: &[&str]) -> bool {
let context_segments = Self::split_compound(context_entry);
selector_segments.iter().all(|seg| context_segments.contains(seg))
}
}
/// A CSS rule: selector + style
#[derive(Debug, Clone)]
struct Rule {
selector: ParsedSelector,
style: Style,
}
#[derive(Debug, Clone)]
pub struct Theme {
light_variables: HashMap<String, String>,
dark_variables: HashMap<String, String>,
light_rules: Vec<Rule>,
dark_rules: Vec<Rule>,
current_mode: ThemeMode,
/// Cache: (context_key, is_dark) -> Style
cache: RefCell<HashMap<(Vec<String>, bool), Style>>,
}
impl Theme {
/// Load theme from CSS string
pub fn from_css(css: &str) -> Result<Self, String> {
Self::parse_css(css, 0)
}
/// Parse CSS with a source order offset (for merging multiple stylesheets)
fn parse_css(css: &str, source_order_offset: u32) -> Result<Self, String> {
let stylesheet = StyleSheet::parse(
css,
ParserOptions::default(),
).map_err(|e| format!("Failed to parse CSS: {:?}", e))?;
let mut light_variables = HashMap::new();
let mut dark_variables = HashMap::new();
let mut light_rules = Vec::new();
let mut dark_rules = Vec::new();
let mut source_order = source_order_offset;
// First pass: Extract CSS custom properties from :root
for rule in &stylesheet.rules.0 {
match rule {
lightningcss::rules::CssRule::Style(style_rule) => {
let selectors = style_rule.selectors.0.iter()
.filter_map(|s| s.to_css_string(PrinterOptions::default()).ok())
.collect::<Vec<_>>();
if selectors.iter().any(|s| s.contains(":root")) {
extract_css_variables(&style_rule.declarations, &mut light_variables)?;
}
}
lightningcss::rules::CssRule::Media(media_rule) => {
let media_str = media_rule.query.to_css_string(PrinterOptions::default())
.unwrap_or_default();
if media_str.contains("prefers-color-scheme") && media_str.contains("dark") {
for inner_rule in &media_rule.rules.0 {
if let lightningcss::rules::CssRule::Style(style_rule) = inner_rule {
let selectors = style_rule.selectors.0.iter()
.filter_map(|s| s.to_css_string(PrinterOptions::default()).ok())
.collect::<Vec<_>>();
if selectors.iter().any(|s| s.contains(":root")) {
extract_css_variables(&style_rule.declarations, &mut dark_variables)?;
}
}
}
}
}
_ => {}
}
}
// Second pass: Parse style rules and resolve var() references
for rule in &stylesheet.rules.0 {
match rule {
lightningcss::rules::CssRule::Style(style_rule) => {
let selectors = style_rule.selectors.0.iter()
.filter_map(|s| s.to_css_string(PrinterOptions::default()).ok())
.collect::<Vec<_>>();
for selector in selectors {
let selector = selector.trim();
if selector.starts_with('.') || selector.starts_with('#') {
let parsed = ParsedSelector::parse(selector, source_order);
source_order += 1;
// Parse with light variables
let light_style = parse_style_properties(&style_rule.declarations, &light_variables)?;
light_rules.push(Rule {
selector: parsed.clone(),
style: light_style,
});
// Parse with dark variables (merged over light)
let mut dark_vars = light_variables.clone();
dark_vars.extend(dark_variables.clone());
let dark_style = parse_style_properties(&style_rule.declarations, &dark_vars)?;
dark_rules.push(Rule {
selector: parsed,
style: dark_style,
});
}
}
}
lightningcss::rules::CssRule::Media(media_rule) => {
let media_str = media_rule.query.to_css_string(PrinterOptions::default())
.unwrap_or_default();
if media_str.contains("prefers-color-scheme") && media_str.contains("dark") {
for inner_rule in &media_rule.rules.0 {
if let lightningcss::rules::CssRule::Style(style_rule) = inner_rule {
let selectors = style_rule.selectors.0.iter()
.filter_map(|s| s.to_css_string(PrinterOptions::default()).ok())
.collect::<Vec<_>>();
for selector in selectors {
let selector = selector.trim();
if selector.starts_with('.') || selector.starts_with('#') {
let parsed = ParsedSelector::parse(selector, source_order);
source_order += 1;
let mut vars = light_variables.clone();
vars.extend(dark_variables.clone());
let style = parse_style_properties(&style_rule.declarations, &vars)?;
dark_rules.push(Rule {
selector: parsed,
style,
});
}
}
}
}
}
}
_ => {}
}
}
Ok(Self {
light_variables,
dark_variables,
light_rules,
dark_rules,
current_mode: ThemeMode::System,
cache: RefCell::new(HashMap::new()),
})
}
/// Load theme from embedded CSS file, optionally merging user stylesheet
pub fn load_default() -> Result<Self, String> {
let css = include_str!("../assets/styles.css");
let mut theme = Self::from_css(css)?;
// Try to load user stylesheet from ~/.config/lightningbeam/theme.css
if let Some(user_css_path) = directories::BaseDirs::new()
.map(|d| d.config_dir().join("lightningbeam").join("theme.css"))
{
if user_css_path.exists() {
if let Ok(user_css) = std::fs::read_to_string(&user_css_path) {
// Parse user CSS with higher source order so it overrides defaults
let user_offset = (theme.light_rules.len() + theme.dark_rules.len()) as u32;
match Self::parse_css(&user_css, user_offset) {
Ok(user_theme) => {
// Merge user variables (override defaults)
theme.light_variables.extend(user_theme.light_variables);
theme.dark_variables.extend(user_theme.dark_variables);
// Append user rules (higher source order = higher priority at same specificity)
theme.light_rules.extend(user_theme.light_rules);
theme.dark_rules.extend(user_theme.dark_rules);
}
Err(e) => {
eprintln!("Warning: Failed to parse user theme.css: {}", e);
}
}
}
}
}
Ok(theme)
}
/// Set the current theme mode
pub fn set_mode(&mut self, mode: ThemeMode) {
if self.current_mode != mode {
self.current_mode = mode;
self.cache.borrow_mut().clear();
}
}
/// Get the current theme mode
pub fn mode(&self) -> ThemeMode {
self.current_mode
}
/// Invalidate the cache (call on stylesheet reload or mode change)
pub fn invalidate_cache(&self) {
self.cache.borrow_mut().clear();
}
/// Determine if dark mode is active
fn is_dark(&self, ctx: &egui::Context) -> bool {
match self.current_mode {
ThemeMode::Light => false,
ThemeMode::Dark => true,
ThemeMode::System => ctx.style().visuals.dark_mode,
}
}
/// Cascading resolve — context is outermost to innermost
/// e.g., &["#timeline", ".layer-header", ".selected"]
pub fn resolve(&self, context: &[&str], ctx: &egui::Context) -> Style {
let is_dark = self.is_dark(ctx);
let cache_key = (context.iter().map(|s| s.to_string()).collect::<Vec<_>>(), is_dark);
// Check cache
if let Some(cached) = self.cache.borrow().get(&cache_key) {
return cached.clone();
}
let rules = if is_dark { &self.dark_rules } else { &self.light_rules };
// Collect matching rules and sort by specificity
let mut matching: Vec<&Rule> = rules
.iter()
.filter(|r| r.selector.matches(context))
.collect();
// Sort by specificity: (ids, classes, source_order) — ascending so later = higher priority
matching.sort_by_key(|r| r.selector.specificity);
// Merge in specificity order (lower specificity first, higher overrides)
let mut result = Style::default();
for rule in &matching {
result.merge_over(&rule.style);
}
// Cache the result
self.cache.borrow_mut().insert(cache_key, result.clone());
result
}
/// Convenience: resolve and extract background color with fallback
pub fn bg_color(&self, context: &[&str], ctx: &egui::Context, fallback: egui::Color32) -> egui::Color32 {
self.resolve(context, ctx).background_color().unwrap_or(fallback)
}
/// Convenience: resolve and extract text color with fallback
pub fn text_color(&self, context: &[&str], ctx: &egui::Context, fallback: egui::Color32) -> egui::Color32 {
self.resolve(context, ctx).text_color.unwrap_or(fallback)
}
/// Convenience: resolve and extract border color with fallback
pub fn border_color(&self, context: &[&str], ctx: &egui::Context, fallback: egui::Color32) -> egui::Color32 {
self.resolve(context, ctx).border_color.unwrap_or(fallback)
}
/// Convenience: resolve and extract a dimension with fallback
pub fn dimension(&self, context: &[&str], ctx: &egui::Context, property: &str, fallback: f32) -> f32 {
let style = self.resolve(context, ctx);
match property {
"width" => style.width.unwrap_or(fallback),
"height" => style.height.unwrap_or(fallback),
"padding" => style.padding.unwrap_or(fallback),
"margin" => style.margin.unwrap_or(fallback),
"font-size" => style.font_size.unwrap_or(fallback),
"border-width" => style.border_width.unwrap_or(fallback),
"border-radius" => style.border_radius.unwrap_or(fallback),
"opacity" => style.opacity.unwrap_or(fallback),
_ => fallback,
}
}
/// Paint background for a region (handles solid/gradient/image)
pub fn paint_bg(
&self,
context: &[&str],
ctx: &egui::Context,
painter: &egui::Painter,
rect: egui::Rect,
rounding: f32,
) {
let style = self.resolve(context, ctx);
if let Some(bg) = &style.background {
crate::theme_render::paint_background(painter, rect, bg, rounding);
}
}
/// Get style for a single selector (backward-compat wrapper)
pub fn style(&self, selector: &str, ctx: &egui::Context) -> Style {
self.resolve(&[selector], ctx)
}
/// Get the number of loaded rules
pub fn len(&self) -> usize {
self.light_rules.len()
}
/// Check if theme has no rules
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.light_rules.is_empty()
}
/// Debug: print loaded theme info
pub fn debug_print(&self) {
println!("Theme Debug Info:");
println!(" Light variables: {}", self.light_variables.len());
for (k, v) in self.light_variables.iter().take(5) {
println!(" --{}: {}", k, v);
}
println!(" Dark variables: {}", self.dark_variables.len());
for (k, v) in self.dark_variables.iter().take(5) {
println!(" --{}: {}", k, v);
}
println!(" Light rules: {}", self.light_rules.len());
for rule in self.light_rules.iter().take(5) {
println!(" {}", rule.selector.parts.join(" "));
}
println!(" Dark rules: {}", self.dark_rules.len());
for rule in self.dark_rules.iter().take(5) {
println!(" {}", rule.selector.parts.join(" "));
}
}
}
/// Extract CSS custom properties (--variables) from declarations
fn extract_css_variables(
declarations: &lightningcss::declaration::DeclarationBlock,
variables: &mut HashMap<String, String>,
) -> Result<(), String> {
for property in &declarations.declarations {
if let lightningcss::properties::Property::Custom(_) = property {
let property_css = property.to_css_string(false, PrinterOptions::default())
.map_err(|e| format!("Failed to serialize property: {:?}", e))?;
if let Some((name, value)) = property_css.split_once(':') {
let name = name.trim().strip_prefix("--").unwrap_or(name.trim()).to_string();
let value = value.trim().to_string();
variables.insert(name, value);
}
}
}
Ok(())
}
/// Parse style properties from CSS declarations into a Style struct, resolving var() references
fn parse_style_properties(
declarations: &lightningcss::declaration::DeclarationBlock,
variables: &HashMap<String, String>,
) -> Result<Style, String> {
let mut style = Style::default();
for property in &declarations.declarations {
let prop_str = property.to_css_string(false, PrinterOptions::default())
.map_err(|e| format!("Failed to serialize property: {:?}", e))?;
if let Some((name, value)) = prop_str.split_once(':') {
let name = name.trim();
let value = value.trim().trim_end_matches(';');
match name {
"background-color" => {
if let Some(color) = parse_color_value(value, variables) {
style.background = Some(Background::Solid(color));
}
}
"background" => {
// Try gradient first, then solid color
if let Some(bg) = parse_background_value(value, variables) {
style.background = Some(bg);
}
}
"border-color" | "border-top-color" => {
style.border_color = parse_color_value(value, variables);
}
"border-width" => {
style.border_width = parse_dimension_value(value, variables);
}
"border-radius" => {
style.border_radius = parse_dimension_value(value, variables);
}
"color" => {
style.text_color = parse_color_value(value, variables);
}
"width" => {
style.width = parse_dimension_value(value, variables);
}
"height" => {
style.height = parse_dimension_value(value, variables);
}
"padding" => {
style.padding = parse_dimension_value(value, variables);
}
"margin" => {
style.margin = parse_dimension_value(value, variables);
}
"font-size" => {
style.font_size = parse_dimension_value(value, variables);
}
"opacity" => {
if let Ok(v) = value.trim().parse::<f32>() {
style.opacity = Some(v);
}
}
_ => {}
}
}
}
Ok(style)
}
/// Parse a CSS background value (gradient, url, or solid color)
fn parse_background_value(value: &str, variables: &HashMap<String, String>) -> Option<Background> {
let value = value.trim();
// Check for linear-gradient()
if value.starts_with("linear-gradient(") {
return parse_linear_gradient(value, variables);
}
// Check for url()
if value.starts_with("url(") {
let inner = value.strip_prefix("url(")?.strip_suffix(')')?;
let url = inner.trim().trim_matches('"').trim_matches('\'').to_string();
return Some(Background::Image { url });
}
// Fallback to solid color
parse_color_value(value, variables).map(Background::Solid)
}
/// Parse a linear-gradient() CSS value
fn parse_linear_gradient(value: &str, variables: &HashMap<String, String>) -> Option<Background> {
// linear-gradient(180deg, #333, #222)
// linear-gradient(180deg, #333 0%, #222 100%)
let inner = value.strip_prefix("linear-gradient(")?.strip_suffix(')')?;
let mut parts: Vec<&str> = Vec::new();
let mut depth = 0;
let mut start = 0;
for (i, c) in inner.char_indices() {
match c {
'(' => depth += 1,
')' => depth -= 1,
',' if depth == 0 => {
parts.push(&inner[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&inner[start..]);
if parts.is_empty() {
return None;
}
let mut angle_degrees = 180.0f32; // default: top to bottom
let mut color_start_idx = 0;
// Check if first part is an angle
let first = parts[0].trim();
if first.ends_with("deg") {
if let Ok(angle) = first.strip_suffix("deg").unwrap().trim().parse::<f32>() {
angle_degrees = angle;
color_start_idx = 1;
}
} else if first == "to bottom" {
angle_degrees = 180.0;
color_start_idx = 1;
} else if first == "to top" {
angle_degrees = 0.0;
color_start_idx = 1;
} else if first == "to right" {
angle_degrees = 90.0;
color_start_idx = 1;
} else if first == "to left" {
angle_degrees = 270.0;
color_start_idx = 1;
}
let color_parts = &parts[color_start_idx..];
if color_parts.is_empty() {
return None;
}
let mut stops = Vec::new();
let count = color_parts.len();
for (i, part) in color_parts.iter().enumerate() {
let part = part.trim();
// Check for "color position%" pattern
let (color_str, position) = if let Some(pct_idx) = part.rfind('%') {
// Find the space before the percentage
let before_pct = &part[..pct_idx];
if let Some(space_idx) = before_pct.rfind(' ') {
let color_str = &part[..space_idx];
let pct_str = &part[space_idx + 1..pct_idx];
let pct = pct_str.trim().parse::<f32>().unwrap_or(0.0) / 100.0;
(color_str.trim(), pct)
} else {
(part, i as f32 / (count - 1).max(1) as f32)
}
} else {
(part, i as f32 / (count - 1).max(1) as f32)
};
if let Some(color) = parse_color_value(color_str, variables) {
stops.push((position, color));
}
}
if stops.len() < 2 {
return None;
}
Some(Background::LinearGradient { angle_degrees, stops })
}
/// Parse a CSS color value (hex or var())
fn parse_color_value(value: &str, variables: &HashMap<String, String>) -> Option<egui::Color32> {
let value = value.trim();
if let Some(var_name) = parse_var_reference(value) {
let resolved = variables.get(&var_name)?;
return parse_hex_color(resolved);
}
parse_hex_color(value)
}
/// Parse a CSS dimension value (px or var())
fn parse_dimension_value(value: &str, variables: &HashMap<String, String>) -> Option<f32> {
let value = value.trim();
if let Some(var_name) = parse_var_reference(value) {
let resolved = variables.get(&var_name)?;
return parse_dimension_string(resolved);
}
parse_dimension_string(value)
}
/// Parse a var() reference to get the variable name
fn parse_var_reference(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.starts_with("var(") && trimmed.ends_with(')') {
let inner = trimmed.strip_prefix("var(")?.strip_suffix(')')?;
let var_name = inner.trim().strip_prefix("--")?;
Some(var_name.to_string())
} else {
None
}
}
/// Parse hex color string to egui::Color32
fn parse_hex_color(value: &str) -> Option<egui::Color32> {
let value = value.trim();
if !value.starts_with('#') {
return None;
}
let hex = value.trim_start_matches('#');
match hex.len() {
3 => {
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
Some(egui::Color32::from_rgb(r, g, b))
}
6 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(egui::Color32::from_rgb(r, g, b))
}
8 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
Some(egui::Color32::from_rgba_unmultiplied(r, g, b, a))
}
_ => None,
}
}
/// Parse dimension string (e.g., "50px" or "25")
fn parse_dimension_string(value: &str) -> Option<f32> {
let value = value.trim();
if let Some(stripped) = value.strip_suffix("px") {
stripped.trim().parse::<f32>().ok()
} else {
value.parse::<f32>().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_default_theme() {
let theme = Theme::load_default().expect("Failed to load default theme");
assert!(!theme.is_empty(), "Theme should have styles loaded");
}
#[test]
fn test_selector_matching() {
let sel = ParsedSelector::parse("#timeline .layer-header", 0);
assert!(sel.matches(&["#timeline", ".layer-header"]));
assert!(sel.matches(&["#timeline", ".something", ".layer-header"]));
assert!(!sel.matches(&[".layer-header"]));
assert!(!sel.matches(&["#timeline"]));
}
#[test]
fn test_compound_selector() {
let sel = ParsedSelector::parse(".layer-header.hover", 0);
assert!(sel.matches(&[".layer-header.hover"]));
// Also matches if context has both classes separately at same level?
// No — compound requires the context entry itself to contain both
}
#[test]
fn test_specificity_ordering() {
let s1 = ParsedSelector::parse(".button", 0);
let s2 = ParsedSelector::parse("#timeline .button", 1);
assert!(s1.specificity < s2.specificity);
}
#[test]
fn test_cascade_resolve() {
let css = r#"
:root { --bg: #ff0000; }
.button { background-color: var(--bg); }
#timeline .button { background-color: #00ff00; }
"#;
let theme = Theme::from_css(css).unwrap();
let ctx = egui::Context::default();
// .button alone should get red
let s = theme.resolve(&[".button"], &ctx);
assert_eq!(s.background_color(), Some(egui::Color32::from_rgb(255, 0, 0)));
// #timeline .button should get green (higher specificity)
let s = theme.resolve(&["#timeline", ".button"], &ctx);
assert_eq!(s.background_color(), Some(egui::Color32::from_rgb(0, 255, 0)));
}
#[test]
fn test_parse_linear_gradient() {
let css = r#"
.panel { background: linear-gradient(180deg, #333333, #222222); }
"#;
let theme = Theme::from_css(css).unwrap();
let ctx = egui::Context::default();
let s = theme.resolve(&[".panel"], &ctx);
match &s.background {
Some(Background::LinearGradient { angle_degrees, stops }) => {
assert_eq!(*angle_degrees, 180.0);
assert_eq!(stops.len(), 2);
}
other => panic!("Expected LinearGradient, got {:?}", other),
}
}
#[test]
fn test_style_backward_compat() {
let css = r#"
:root { --bg: #aabbcc; }
.panel { background-color: var(--bg); }
"#;
let theme = Theme::from_css(css).unwrap();
let ctx = egui::Context::default();
let s = theme.style(".panel", &ctx);
assert_eq!(s.background_color(), Some(egui::Color32::from_rgb(0xaa, 0xbb, 0xcc)));
}
}