397 lines
15 KiB
Rust
397 lines
15 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.
|
|
|
|
use eframe::egui;
|
|
use lightningcss::stylesheet::{ParserOptions, PrinterOptions, StyleSheet};
|
|
use lightningcss::traits::ToCss;
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Style properties that can be applied to UI elements
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Style {
|
|
pub background_color: Option<egui::Color32>,
|
|
pub border_color: Option<egui::Color32>,
|
|
pub text_color: Option<egui::Color32>,
|
|
pub width: Option<f32>,
|
|
pub height: Option<f32>,
|
|
// Add more properties as needed
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Theme {
|
|
light_variables: HashMap<String, String>,
|
|
dark_variables: HashMap<String, String>,
|
|
light_styles: HashMap<String, Style>,
|
|
dark_styles: HashMap<String, Style>,
|
|
current_mode: ThemeMode,
|
|
}
|
|
|
|
impl Theme {
|
|
/// Load theme from CSS file
|
|
pub fn from_css(css: &str) -> 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_styles = HashMap::new();
|
|
let mut dark_styles = HashMap::new();
|
|
|
|
// 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<_>>();
|
|
|
|
// Check if this is :root
|
|
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
|
|
// We need to parse selectors TWICE - once with light variables, once with dark variables
|
|
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();
|
|
// Only process class and ID selectors
|
|
if selector.starts_with('.') || selector.starts_with('#') {
|
|
// Parse with light variables
|
|
let light_style = parse_style_properties(&style_rule.declarations, &light_variables)?;
|
|
light_styles.insert(selector.to_string(), light_style);
|
|
|
|
// Also parse with dark variables (merge dark 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_styles.insert(selector.to_string(), dark_style);
|
|
}
|
|
}
|
|
}
|
|
lightningcss::rules::CssRule::Media(media_rule) => {
|
|
let media_str = media_rule.query.to_css_string(PrinterOptions::default())
|
|
.unwrap_or_default();
|
|
|
|
eprintln!("🔍 Found media query: {}", media_str);
|
|
eprintln!(" Contains {} rules", media_rule.rules.0.len());
|
|
|
|
if media_str.contains("prefers-color-scheme") && media_str.contains("dark") {
|
|
eprintln!(" ✓ This is a dark mode media query!");
|
|
for (i, inner_rule) in media_rule.rules.0.iter().enumerate() {
|
|
eprintln!(" Rule {}: {:?}", i, std::mem::discriminant(inner_rule));
|
|
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<_>>();
|
|
|
|
eprintln!(" Found selectors: {:?}", selectors);
|
|
|
|
for selector in selectors {
|
|
let selector = selector.trim();
|
|
if selector.starts_with('.') || selector.starts_with('#') {
|
|
// Merge dark and light variables (dark overrides light)
|
|
let mut vars = light_variables.clone();
|
|
vars.extend(dark_variables.clone());
|
|
let style = parse_style_properties(&style_rule.declarations, &vars)?;
|
|
dark_styles.insert(selector.to_string(), style);
|
|
eprintln!(" Added dark style for: {}", selector);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Ok(Self {
|
|
light_variables,
|
|
dark_variables,
|
|
light_styles,
|
|
dark_styles,
|
|
current_mode: ThemeMode::System,
|
|
})
|
|
}
|
|
|
|
/// Load theme from embedded CSS file
|
|
pub fn load_default() -> Result<Self, String> {
|
|
let css = include_str!("../assets/styles.css");
|
|
Self::from_css(css)
|
|
}
|
|
|
|
/// Set the current theme mode
|
|
pub fn set_mode(&mut self, mode: ThemeMode) {
|
|
self.current_mode = mode;
|
|
}
|
|
|
|
/// Get the current theme mode
|
|
pub fn mode(&self) -> ThemeMode {
|
|
self.current_mode
|
|
}
|
|
|
|
/// Get style for a selector (e.g., ".panel" or "#timeline-header")
|
|
pub fn style(&self, selector: &str, ctx: &egui::Context) -> Style {
|
|
let is_dark = match self.current_mode {
|
|
ThemeMode::Light => false,
|
|
ThemeMode::Dark => true,
|
|
ThemeMode::System => ctx.style().visuals.dark_mode,
|
|
};
|
|
|
|
if is_dark {
|
|
// Try dark style first, fall back to light style
|
|
self.dark_styles.get(selector).cloned()
|
|
.or_else(|| self.light_styles.get(selector).cloned())
|
|
.unwrap_or_default()
|
|
} else {
|
|
self.light_styles.get(selector).cloned().unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
/// Get the number of loaded selectors
|
|
pub fn len(&self) -> usize {
|
|
self.light_styles.len()
|
|
}
|
|
|
|
/// Check if theme has no styles
|
|
#[allow(dead_code)] // Used in tests
|
|
pub fn is_empty(&self) -> bool {
|
|
self.light_styles.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 styles: {}", self.light_styles.len());
|
|
for k in self.light_styles.keys().take(5) {
|
|
println!(" {}", k);
|
|
}
|
|
println!(" Dark styles: {}", self.dark_styles.len());
|
|
for k in self.dark_styles.keys().take(5) {
|
|
println!(" {}", k);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
// Convert property to CSS string and parse
|
|
let prop_str = property.to_css_string(false, PrinterOptions::default())
|
|
.map_err(|e| format!("Failed to serialize property: {:?}", e))?;
|
|
|
|
// Parse property name and value
|
|
if let Some((name, value)) = prop_str.split_once(':') {
|
|
let name = name.trim();
|
|
let value = value.trim().trim_end_matches(';');
|
|
|
|
match name {
|
|
"background-color" => {
|
|
style.background_color = parse_color_value(value, variables);
|
|
}
|
|
"border-color" | "border-top-color" => {
|
|
style.border_color = parse_color_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);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(style)
|
|
}
|
|
|
|
/// 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();
|
|
|
|
// Check if it's a var() reference
|
|
if let Some(var_name) = parse_var_reference(value) {
|
|
let resolved = variables.get(&var_name)?;
|
|
return parse_hex_color(resolved);
|
|
}
|
|
|
|
// Try to parse as direct hex color
|
|
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();
|
|
|
|
// Check if it's a var() reference
|
|
if let Some(var_name) = parse_var_reference(value) {
|
|
let resolved = variables.get(&var_name)?;
|
|
return parse_dimension_string(resolved);
|
|
}
|
|
|
|
// Try to parse as direct dimension
|
|
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");
|
|
}
|
|
}
|