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

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");
}
}