943 lines
34 KiB
Rust
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)));
|
|
}
|
|
}
|