diff --git a/lightningbeam-ui/lightningbeam-editor/assets/styles.css b/lightningbeam-ui/lightningbeam-editor/assets/styles.css index 8bc6516..7599b9b 100644 --- a/lightningbeam-ui/lightningbeam-editor/assets/styles.css +++ b/lightningbeam-ui/lightningbeam-editor/assets/styles.css @@ -1,38 +1,103 @@ /* Lightningbeam Editor Styles * CSS with variables and selector-based theming + * + * Tier 1: :root variables (design tokens) + * Tier 2: Class selectors (.button, .layer-header) + * Tier 3: Compound/contextual (#timeline .layer-header) + * + * Pseudo-states: .hover, .selected, .active, .pressed, .disabled */ /* ============================================ - LIGHT MODE VARIABLES + LIGHT MODE VARIABLES (design tokens) ============================================ */ :root { - /* Base colors */ - --bg-primary: #f6f6f6; - --bg-secondary: #ccc; + /* Semantic text colors */ + --text-primary: #0f0f0f; + --text-secondary: #666; + --text-tertiary: #999; + --text-disabled: #bbb; + --text-on-accent: #fff; + + /* App backgrounds */ + --bg-app: #e0e0e0; --bg-panel: #aaa; --bg-header: #ddd; + --bg-surface: #ccc; + --bg-surface-raised: #ddd; + --bg-surface-sunken: #bbb; + + /* Legacy compat aliases */ + --bg-primary: #f6f6f6; + --bg-secondary: #ccc; --background-color: #ccc; --foreground-color: #ddd; --highlight: #ddd; --shadow: #999; --shade: #aaa; - /* Text colors */ - --text-primary: #0f0f0f; - --text-secondary: #666; - --text-tertiary: #999; - - /* Border colors */ + /* Borders */ + --border-subtle: #bbb; + --border-default: #999; + --border-strong: #555; + /* Legacy aliases */ --border-light: #bbb; --border-medium: #999; --border-dark: #555; - /* UI backgrounds */ + /* Accent */ + --accent: #396cd8; + --accent-hover: #4a7de9; + --accent-active: #2a5cc8; + + /* Grid */ --grid-bg: #555; --grid-hover: #666; + /* Layer type colors */ + --layer-vector: #ffb464; + --layer-audio: #64b4ff; + --layer-midi: #64ff96; + --layer-video: #b464ff; + --layer-effect: #ff64b4; + --layer-group: #00b4b4; + --layer-raster: #a064c8; + + /* Status */ + --status-error: #dc3232; + --status-warning: #dcc832; + --status-success: #32c850; + --scrubber: #cc2222; + + /* Piano */ + --piano-white-key: #ffffff; + --piano-white-key-pressed: #6496ff; + --piano-black-key: #000000; + --piano-black-key-pressed: #3264c8; + --piano-key-border: #000000; + --piano-white-label: #333333; + --piano-black-label: #ffffffb2; + --piano-sustain-on: #64c864; + --piano-sustain-off: #808080; + + /* Toolbar */ + --tool-selected-bg: #466496; + --tool-unselected-bg: #999; + --tool-hover-border: #b4b4b4; + --tool-selected-border: #6496ff; + --tool-arrow-color: #c8c8c8; + --color-swatch-border: #555; + --checkerboard-light: #b4b4b4; + --checkerboard-dark: #787878; + /* Dimensions */ --header-height: 40px; + --layer-height: 60px; + --ruler-height: 30px; + --layer-header-width: 200px; + --border-radius: 4px; + --font-size-small: 11px; + --font-size-default: 13px; --pane-border-width: 1px; } @@ -41,35 +106,73 @@ ============================================ */ @media (prefers-color-scheme: dark) { :root { - /* Base colors */ + /* Semantic text colors */ + --text-primary: #f6f6f6; + --text-secondary: #aaa; + --text-tertiary: #777; + --text-disabled: #555; + --text-on-accent: #fff; + + /* App backgrounds */ + --bg-app: #2a2a2a; + --bg-panel: #222; + --bg-header: #353535; + --bg-surface: #2f2f2f; + --bg-surface-raised: #3f3f3f; + --bg-surface-sunken: #1a1a1a; + + /* Legacy compat aliases */ --bg-primary: #2f2f2f; --bg-secondary: #3f3f3f; - --bg-panel: #222222; - --bg-header: #444; --background-color: #333; --foreground-color: #888; --highlight: #4f4f4f; --shadow: #111; --shade: #222; - /* Text colors */ - --text-primary: #f6f6f6; - --text-secondary: #aaa; - --text-tertiary: #777; - - /* Border colors */ + /* Borders */ + --border-subtle: #333; + --border-default: #444; + --border-strong: #555; + /* Legacy aliases */ --border-light: #555; --border-medium: #444; --border-dark: #333; - /* UI backgrounds */ + /* Accent */ + --accent: #396cd8; + --accent-hover: #4a7de9; + --accent-active: #2a5cc8; + + /* Grid */ --grid-bg: #0f0f0f; --grid-hover: #1a1a1a; + + /* Piano */ + --piano-white-key: #ffffff; + --piano-white-key-pressed: #6496ff; + --piano-black-key: #000000; + --piano-black-key-pressed: #3264c8; + --piano-key-border: #000000; + --piano-white-label: #333333; + --piano-black-label: #ffffffb2; + --piano-sustain-on: #64c864; + --piano-sustain-off: #808080; + + /* Toolbar */ + --tool-selected-bg: #466496; + --tool-unselected-bg: #323232; + --tool-hover-border: #b4b4b4; + --tool-selected-border: #6496ff; + --tool-arrow-color: #c8c8c8; + --color-swatch-border: #505050; + --checkerboard-light: #b4b4b4; + --checkerboard-dark: #787878; } } /* ============================================ - COMPONENT STYLES (applies to both modes) + COMPONENT STYLES ============================================ */ /* Pane headers */ @@ -77,22 +180,22 @@ background-color: var(--bg-header); color: var(--text-primary); height: var(--header-height); - border-color: var(--border-medium); + border-color: var(--border-default); } /* Pane content areas */ .pane-content { background-color: var(--bg-primary); - border-color: var(--border-light); + border-color: var(--border-subtle); } -/* General panel */ +/* Generic panel */ .panel { background-color: var(--bg-panel); - border-color: var(--border-medium); + border-color: var(--border-default); } -/* Grid backgrounds */ +/* Grid */ .grid { background-color: var(--grid-bg); } @@ -101,7 +204,10 @@ background-color: var(--grid-hover); } -/* Specific pane IDs */ +/* ============================================ + PANE-SPECIFIC STYLES + ============================================ */ + #stage { background-color: var(--bg-primary); } @@ -123,7 +229,22 @@ border-color: #4d4d4d; } -/* Timeline specific elements */ +#shader-editor { + background-color: #19191e; +} + +#outliner { + background-color: #283219; +} + +#piano-roll { + background-color: #1e1e23; +} + +/* ============================================ + TIMELINE ELEMENTS + ============================================ */ + .timeline-background { background-color: var(--shade); } @@ -137,8 +258,8 @@ } .timeline-scrubber { - background-color: #cc2222; - border-color: #cc2222; + background-color: var(--scrubber); + border-color: var(--scrubber); } .timeline-layer-active { @@ -157,18 +278,75 @@ background-color: var(--foreground-color); } -/* Buttons */ +.timeline-note { + background-color: #000000; +} + +/* Layer type badges */ +.layer-type-vector { + background-color: var(--layer-vector); +} + +.layer-type-audio { + background-color: var(--layer-audio); +} + +.layer-type-midi { + background-color: var(--layer-midi); +} + +.layer-type-video { + background-color: var(--layer-video); +} + +.layer-type-effect { + background-color: var(--layer-effect); +} + +.layer-type-group { + background-color: var(--layer-group); +} + +.layer-type-raster { + background-color: var(--layer-raster); +} + +/* ============================================ + BUTTONS + ============================================ */ + .button { background-color: var(--bg-primary); color: var(--text-primary); - border-color: var(--border-medium); + border-color: var(--border-default); } -.button-hover { - border-color: #396cd8; +.button.hover { + border-color: var(--accent); } -/* Text */ +.button.selected { + background-color: var(--accent); + border-color: var(--accent); +} + +/* Tool buttons */ +.tool-button { + background-color: var(--tool-unselected-bg); +} + +.tool-button.selected { + background-color: var(--tool-selected-bg); +} + +.tool-button.hover { + border-color: var(--tool-hover-border); +} + +/* ============================================ + TEXT + ============================================ */ + .text-primary { color: var(--text-primary); } @@ -176,3 +354,81 @@ .text-secondary { color: var(--text-secondary); } + +.text-disabled { + color: var(--text-disabled); +} + +/* ============================================ + STATUS + ============================================ */ + +.status-error { + color: var(--status-error); +} + +.status-warning { + color: var(--status-warning); +} + +.status-success { + color: var(--status-success); +} + +/* ============================================ + PANE CHROME (borders, icon buttons) + ============================================ */ + +.pane-chrome { + background-color: #232323; + border-color: #505050; +} + +.pane-chrome-separator { + border-color: #323232; +} + +.pane-icon-button { + background-color: #323232c8; +} + +/* ============================================ + NODE EDITOR + ============================================ */ + +#node-editor .grid { + background-color: #373737; +} + +/* ============================================ + VIRTUAL PIANO + ============================================ */ + +.piano-white-key { + background-color: var(--piano-white-key); + border-color: var(--piano-key-border); + color: var(--piano-white-label); +} + +.piano-white-key.pressed { + background-color: var(--piano-white-key-pressed); + color: #000000; +} + +.piano-black-key { + background-color: var(--piano-black-key); + color: var(--piano-black-label); +} + +.piano-black-key.pressed { + background-color: var(--piano-black-key-pressed); + color: #ffffff; +} + +.sustain-indicator.active { + color: var(--piano-sustain-on); +} + +.sustain-indicator { + color: var(--piano-sustain-off); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 2ce9e91..081cd72 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -19,6 +19,7 @@ mod menu; use menu::{MenuAction, MenuSystem}; mod theme; +mod theme_render; use theme::{Theme, ThemeMode}; mod waveform_gpu; @@ -5377,6 +5378,7 @@ impl eframe::App for EditorApp { // Main pane area (editor mode) let mut layout_action: Option = None; let mut clipboard_consumed = false; + let mut css_debug_regions: Vec = Vec::new(); egui::CentralPanel::default().show(ctx, |ui| { let available_rect = ui.available_rect_before_wrap(); @@ -5520,6 +5522,8 @@ impl eframe::App for EditorApp { commit_raster_floating_if_any: &mut self.commit_raster_floating_if_any, pending_node_group: &mut self.pending_node_group, pending_node_ungroup: &mut self.pending_node_ungroup, + css_debug_regions: &mut css_debug_regions, + css_debug_overlay: self.debug_overlay_visible, #[cfg(debug_assertions)] test_mode: &mut self.test_mode, #[cfg(debug_assertions)] @@ -6009,6 +6013,55 @@ impl eframe::App for EditorApp { self.audio_controller.as_ref(), ); debug_overlay::render_debug_overlay(ctx, &stats); + + // CSS Inspector: show CSS context for element under cursor + if !css_debug_regions.is_empty() { + if let Some(pointer_pos) = ctx.input(|i| i.pointer.hover_pos()) { + // Find the smallest region containing the pointer + let mut best: Option<&panes::CssDebugRegion> = None; + for region in &css_debug_regions { + if region.rect.contains(pointer_pos) { + if best.map_or(true, |b| region.rect.area() < b.rect.area()) { + best = Some(region); + } + } + } + if let Some(region) = best { + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Tooltip, + egui::Id::new("css_inspector"), + )); + // Highlight border + painter.rect_stroke( + region.rect, + 0.0, + egui::Stroke::new(2.0, egui::Color32::from_rgb(0, 200, 255)), + egui::StrokeKind::Outside, + ); + // Tooltip with CSS context + let context_str = region.context.join(" "); + let resolved = self.theme.resolve_with_provenance(®ion.context.iter().map(|s| *s).collect::>(), ctx); + let mut tooltip_lines = vec![context_str]; + for (prop, sel) in &resolved.provenance { + tooltip_lines.push(format!("{}: (from {})", prop, sel)); + } + let tooltip_text = tooltip_lines.join("\n"); + + let tooltip_pos = pointer_pos + egui::vec2(16.0, 16.0); + let galley = painter.layout_no_wrap( + tooltip_text, + egui::FontId::monospace(11.0), + egui::Color32::from_rgb(200, 240, 255), + ); + let tooltip_rect = egui::Rect::from_min_size( + tooltip_pos, + galley.size() + egui::vec2(12.0, 8.0), + ); + painter.rect_filled(tooltip_rect, 4.0, egui::Color32::from_black_alpha(220)); + painter.galley(tooltip_pos + egui::vec2(6.0, 4.0), galley, egui::Color32::WHITE); + } + } + } } // Render custom cursor overlay (on top of everything including debug overlay) @@ -6342,22 +6395,20 @@ fn render_pane( ); // Draw header background - ui.painter().rect_filled( - header_rect, - 0.0, - egui::Color32::from_rgb(35, 35, 35), - ); + let header_bg = ctx.shared.theme.bg_color(&[".pane-header"], ui.ctx(), egui::Color32::from_rgb(35, 35, 35)); + ui.painter().rect_filled(header_rect, 0.0, header_bg); // Draw content background let bg_color = if let Some(pane_type) = pane_type { - pane_color(pane_type) + let pane_id = pane_type_css_id(pane_type); + ctx.shared.theme.bg_color(&[pane_id, ".pane-content"], ui.ctx(), pane_color(pane_type)) } else { egui::Color32::from_rgb(40, 40, 40) }; ui.painter().rect_filled(content_rect, 0.0, bg_color); // Draw border around entire pane - let border_color = egui::Color32::from_gray(80); + let border_color = ctx.shared.theme.border_color(&[".pane-chrome"], ui.ctx(), egui::Color32::from_gray(80)); let border_width = 1.0; ui.painter().rect_stroke( rect, @@ -6367,10 +6418,11 @@ fn render_pane( ); // Draw header separator line + let sep_color = ctx.shared.theme.border_color(&[".pane-chrome-separator"], ui.ctx(), egui::Color32::from_gray(50)); ui.painter().hline( rect.x_range(), header_rect.max.y, - egui::Stroke::new(1.0, egui::Color32::from_gray(50)), + egui::Stroke::new(1.0, sep_color), ); // Render icon button in header (left side) @@ -6382,11 +6434,8 @@ fn render_pane( ); // Draw icon button background - ui.painter().rect_filled( - icon_button_rect, - 4.0, - egui::Color32::from_rgba_premultiplied(50, 50, 50, 200), - ); + let icon_btn_bg = ctx.shared.theme.bg_color(&[".pane-icon-button"], ui.ctx(), egui::Color32::from_rgba_premultiplied(50, 50, 50, 200)); + ui.painter().rect_filled(icon_button_rect, 4.0, icon_btn_bg); // Load and render icon if available if let Some(pane_type) = pane_type { @@ -6749,6 +6798,23 @@ fn pane_color(pane_type: PaneType) -> egui::Color32 { } } +/// CSS ID selector for a pane type (e.g., PaneType::Stage -> "#stage") +fn pane_type_css_id(pane_type: PaneType) -> &'static str { + match pane_type { + PaneType::Stage => "#stage", + PaneType::Timeline => "#timeline", + PaneType::Toolbar => "#toolbar", + PaneType::Infopanel => "#infopanel", + PaneType::Outliner => "#outliner", + PaneType::PianoRoll => "#piano-roll", + PaneType::VirtualPiano => "#virtual-piano", + PaneType::NodeEditor => "#node-editor", + PaneType::PresetBrowser => "#preset-browser", + PaneType::AssetLibrary => "#asset-library", + PaneType::ScriptEditor => "#shader-editor", + } +} + /// Split a pane node into a horizontal or vertical grid with two copies of the pane fn split_node(root: &mut LayoutNode, path: &NodePath, is_horizontal: bool, percent: f32) { if path.is_empty() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index fa21d7c..befbdb5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -683,6 +683,26 @@ struct FolderContextMenuState { position: egui::Pos2, } +/// Get background color for an interactive item based on its state +fn item_state_bg( + theme: &crate::theme::Theme, + ctx: &egui::Context, + pane_ctx: &str, + is_dragging: bool, + is_selected: bool, + is_hovered: bool, +) -> egui::Color32 { + if is_dragging { + theme.bg_color(&[pane_ctx, ".item", ".dragging"], ctx, egui::Color32::from_rgb(80, 100, 120)) + } else if is_selected { + theme.bg_color(&[pane_ctx, ".item", ".selected"], ctx, egui::Color32::from_rgb(60, 80, 100)) + } else if is_hovered { + theme.bg_color(&[pane_ctx, ".item", ".hover"], ctx, egui::Color32::from_rgb(45, 45, 45)) + } else { + theme.bg_color(&[pane_ctx, ".item"], ctx, egui::Color32::from_rgb(35, 35, 35)) + } +} + pub struct AssetLibraryPane { /// Current search filter text search_filter: String, @@ -1335,7 +1355,7 @@ impl AssetLibraryPane { // Background let bg_style = shared.theme.style(".panel-header", ui.ctx()); let bg_color = bg_style - .background_color + .background_color() .unwrap_or(egui::Color32::from_rgb(30, 30, 30)); ui.painter().rect_filled(search_rect, 0.0, bg_color); @@ -1366,9 +1386,9 @@ impl AssetLibraryPane { let list_selected = self.view_mode == AssetViewMode::List; let list_response = ui.allocate_rect(list_button_rect, egui::Sense::click()); let list_bg = if list_selected { - egui::Color32::from_rgb(70, 90, 110) + shared.theme.bg_color(&["#asset-library", ".view-toggle", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 90, 110)) } else if list_response.hovered() { - egui::Color32::from_rgb(50, 50, 50) + shared.theme.bg_color(&["#asset-library", ".view-toggle", ".hover"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50)) } else { egui::Color32::TRANSPARENT }; @@ -1376,9 +1396,9 @@ impl AssetLibraryPane { // Draw list icon (three horizontal lines) let list_icon_color = if list_selected { - egui::Color32::WHITE + shared.theme.text_color(&["#asset-library", ".view-toggle", ".selected"], ui.ctx(), egui::Color32::WHITE) } else { - egui::Color32::from_gray(150) + shared.theme.text_color(&["#asset-library", ".view-toggle"], ui.ctx(), egui::Color32::from_gray(150)) }; let line_spacing = 4.0; let line_width = 10.0; @@ -1402,9 +1422,9 @@ impl AssetLibraryPane { let grid_selected = self.view_mode == AssetViewMode::Grid; let grid_response = ui.allocate_rect(grid_button_rect, egui::Sense::click()); let grid_bg = if grid_selected { - egui::Color32::from_rgb(70, 90, 110) + shared.theme.bg_color(&["#asset-library", ".view-toggle", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 90, 110)) } else if grid_response.hovered() { - egui::Color32::from_rgb(50, 50, 50) + shared.theme.bg_color(&["#asset-library", ".view-toggle", ".hover"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50)) } else { egui::Color32::TRANSPARENT }; @@ -1412,9 +1432,9 @@ impl AssetLibraryPane { // Draw grid icon (2x2 squares) let grid_icon_color = if grid_selected { - egui::Color32::WHITE + shared.theme.text_color(&["#asset-library", ".view-toggle", ".selected"], ui.ctx(), egui::Color32::WHITE) } else { - egui::Color32::from_gray(150) + shared.theme.text_color(&["#asset-library", ".view-toggle"], ui.ctx(), egui::Color32::from_gray(150)) }; let square_size = 4.0; let square_gap = 2.0; @@ -1444,7 +1464,7 @@ impl AssetLibraryPane { egui::Align2::LEFT_TOP, "Search:", egui::FontId::proportional(14.0), - egui::Color32::from_gray(180), + shared.theme.text_color(&["#asset-library", ".search-label"], ui.ctx(), egui::Color32::from_gray(180)), ); // Text field using IME-safe widget (leave room for view toggle buttons) @@ -1473,7 +1493,7 @@ impl AssetLibraryPane { // Background let bg_style = shared.theme.style(".panel-content", ui.ctx()); let bg_color = bg_style - .background_color + .background_color() .unwrap_or(egui::Color32::from_rgb(40, 40, 40)); ui.painter().rect_filled(tabs_rect, 0.0, bg_color); @@ -1490,7 +1510,7 @@ impl AssetLibraryPane { // Tab background let tab_bg = if is_selected { - egui::Color32::from_rgb(60, 60, 60) + shared.theme.bg_color(&["#asset-library", ".category-tab", ".selected"], ui.ctx(), egui::Color32::from_rgb(60, 60, 60)) } else { egui::Color32::TRANSPARENT }; @@ -1508,7 +1528,7 @@ impl AssetLibraryPane { let text_color = if is_selected { indicator_color } else { - egui::Color32::from_gray(150) + shared.theme.text_color(&["#asset-library", ".category-tab"], ui.ctx(), egui::Color32::from_gray(150)) }; ui.painter().text( @@ -1552,7 +1572,7 @@ impl AssetLibraryPane { // Background let bg_style = shared.theme.style(".panel-header", ui.ctx()); let bg_color = bg_style - .background_color + .background_color() .unwrap_or(egui::Color32::from_rgb(25, 25, 25)); ui.painter().rect_filled(rect, 0.0, bg_color); @@ -1600,11 +1620,11 @@ impl AssetLibraryPane { // Determine color based on state let text_color = if is_last { - egui::Color32::WHITE + shared.theme.text_color(&["#asset-library", ".breadcrumb", ".active"], ui.ctx(), egui::Color32::WHITE) } else if response.hovered() { - egui::Color32::from_rgb(100, 150, 255) + shared.theme.text_color(&["#asset-library", ".breadcrumb", ".hover"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255)) } else { - egui::Color32::from_rgb(150, 150, 150) + shared.theme.text_color(&["#asset-library", ".breadcrumb"], ui.ctx(), egui::Color32::from_rgb(150, 150, 150)) }; // Draw text @@ -1639,7 +1659,7 @@ impl AssetLibraryPane { egui::Align2::LEFT_CENTER, ">", egui::FontId::proportional(12.0), - egui::Color32::from_rgb(100, 100, 100), + shared.theme.text_color(&["#asset-library", ".breadcrumb-separator"], ui.ctx(), egui::Color32::from_rgb(100, 100, 100)), ); x_offset += 16.0; } @@ -1718,15 +1738,7 @@ impl AssetLibraryPane { let is_being_dragged = shared.dragging_asset.as_ref().map(|d| d.clip_id == asset.id).unwrap_or(false); // Item background - let item_bg = if is_being_dragged { - egui::Color32::from_rgb(80, 100, 120) - } else if is_selected { - egui::Color32::from_rgb(60, 80, 100) - } else if response.hovered() { - egui::Color32::from_rgb(45, 45, 45) - } else { - egui::Color32::from_rgb(35, 35, 35) - }; + let item_bg = item_state_bg(shared.theme, ui.ctx(), "#asset-library", is_being_dragged, is_selected, response.hovered()); ui.painter().rect_filled(item_rect, 4.0, item_bg); // Thumbnail area @@ -1960,12 +1972,11 @@ impl AssetLibraryPane { // Background let bg_color = if is_drop_hover { - // Highlight as drop target - egui::Color32::from_rgb(60, 100, 140) + shared.theme.bg_color(&["#asset-library", ".folder-item", ".drop-target"], ui.ctx(), egui::Color32::from_rgb(60, 100, 140)) } else if response.hovered() { - egui::Color32::from_rgb(50, 50, 50) + shared.theme.bg_color(&["#asset-library", ".folder-item", ".hover"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50)) } else { - egui::Color32::from_rgb(35, 35, 35) + shared.theme.bg_color(&["#asset-library", ".folder-item"], ui.ctx(), egui::Color32::from_rgb(35, 35, 35)) }; ui.painter().rect_filled(item_rect, 0.0, bg_color); @@ -1974,7 +1985,7 @@ impl AssetLibraryPane { ui.painter().rect_stroke( item_rect, 0.0, - egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 180, 255)), + egui::Stroke::new(2.0, shared.theme.border_color(&["#asset-library", ".folder-item", ".drop-target"], ui.ctx(), egui::Color32::from_rgb(100, 180, 255))), egui::StrokeKind::Middle, ); } @@ -2018,7 +2029,7 @@ impl AssetLibraryPane { egui::Align2::LEFT_CENTER, &folder.name, egui::FontId::proportional(13.0), - egui::Color32::WHITE, + shared.theme.text_color(&["#asset-library", ".folder-item", ".name"], ui.ctx(), egui::Color32::WHITE), ); } @@ -2029,7 +2040,7 @@ impl AssetLibraryPane { egui::Align2::RIGHT_CENTER, count_text, egui::FontId::proportional(11.0), - egui::Color32::from_rgb(150, 150, 150), + shared.theme.text_color(&["#asset-library", ".folder-item", ".count"], ui.ctx(), egui::Color32::from_rgb(150, 150, 150)), ); // Handle drop: move asset to folder @@ -2131,12 +2142,11 @@ impl AssetLibraryPane { // Background let bg_color = if is_drop_hover { - // Highlight as drop target - egui::Color32::from_rgb(60, 100, 140) + shared.theme.bg_color(&["#asset-library", ".folder-item", ".drop-target"], ui.ctx(), egui::Color32::from_rgb(60, 100, 140)) } else if response.hovered() { - egui::Color32::from_rgb(50, 50, 50) + shared.theme.bg_color(&["#asset-library", ".folder-item", ".hover"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50)) } else { - egui::Color32::from_rgb(35, 35, 35) + shared.theme.bg_color(&["#asset-library", ".folder-item"], ui.ctx(), egui::Color32::from_rgb(35, 35, 35)) }; ui.painter().rect_filled(rect, 4.0, bg_color); @@ -2145,7 +2155,7 @@ impl AssetLibraryPane { ui.painter().rect_stroke( rect, 4.0, - egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 180, 255)), + egui::Stroke::new(2.0, shared.theme.border_color(&["#asset-library", ".folder-item", ".drop-target"], ui.ctx(), egui::Color32::from_rgb(100, 180, 255))), egui::StrokeKind::Middle, ); } @@ -2176,7 +2186,7 @@ impl AssetLibraryPane { egui::Align2::CENTER_CENTER, name, egui::FontId::proportional(10.0), - egui::Color32::WHITE, + shared.theme.text_color(&["#asset-library", ".folder-item", ".name"], ui.ctx(), egui::Color32::WHITE), ); // Item count @@ -2185,7 +2195,7 @@ impl AssetLibraryPane { egui::Align2::CENTER_CENTER, format!("{} items", folder.item_count), egui::FontId::proportional(9.0), - egui::Color32::from_rgb(150, 150, 150), + shared.theme.text_color(&["#asset-library", ".folder-item", ".count"], ui.ctx(), egui::Color32::from_rgb(150, 150, 150)), ); // Handle drop: move asset to folder @@ -2251,19 +2261,11 @@ impl AssetLibraryPane { .unwrap_or(false); // Text colors - let text_color = egui::Color32::from_gray(200); - let secondary_text_color = egui::Color32::from_gray(120); + let text_color = shared.theme.text_color(&["#asset-library", ".text-primary"], ui.ctx(), egui::Color32::from_gray(200)); + let secondary_text_color = shared.theme.text_color(&["#asset-library", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(120)); // Item background - let item_bg = if is_being_dragged { - egui::Color32::from_rgb(80, 100, 120) - } else if is_selected { - egui::Color32::from_rgb(60, 80, 100) - } else if response.hovered() { - egui::Color32::from_rgb(45, 45, 45) - } else { - egui::Color32::from_rgb(35, 35, 35) - }; + let item_bg = item_state_bg(shared.theme, ui.ctx(), "#asset-library", is_being_dragged, is_selected, response.hovered()); ui.painter().rect_filled(item_rect, 3.0, item_bg); // Category color indicator bar @@ -2437,15 +2439,7 @@ impl AssetLibraryPane { .unwrap_or(false); // Background - let bg_color = if is_being_dragged { - egui::Color32::from_rgb(80, 100, 120) - } else if is_selected { - egui::Color32::from_rgb(60, 80, 100) - } else if response.hovered() { - egui::Color32::from_rgb(50, 50, 50) - } else { - egui::Color32::from_rgb(35, 35, 35) - }; + let bg_color = item_state_bg(shared.theme, ui.ctx(), "#asset-library", is_being_dragged, is_selected, response.hovered()); ui.painter().rect_filled(rect, 4.0, bg_color); // Thumbnail @@ -2617,7 +2611,7 @@ impl AssetLibraryPane { // Background let bg_style = shared.theme.style(".panel-content", ui.ctx()); let bg_color = bg_style - .background_color + .background_color() .unwrap_or(egui::Color32::from_rgb(25, 25, 25)); ui.painter().rect_filled(rect, 0.0, bg_color); @@ -2722,15 +2716,7 @@ impl AssetLibraryPane { .unwrap_or(false); // Item background - let item_bg = if is_being_dragged { - egui::Color32::from_rgb(80, 100, 120) // Highlight when dragging - } else if is_selected { - egui::Color32::from_rgb(60, 80, 100) - } else if response.hovered() { - egui::Color32::from_rgb(45, 45, 45) - } else { - egui::Color32::from_rgb(35, 35, 35) - }; + let item_bg = item_state_bg(shared.theme, ui.ctx(), "#asset-library", is_being_dragged, is_selected, response.hovered()); ui.painter().rect_filled(item_rect, 3.0, item_bg); // Category color indicator bar @@ -3030,7 +3016,7 @@ impl AssetLibraryPane { // Background let bg_style = shared.theme.style(".panel-content", ui.ctx()); let bg_color = bg_style - .background_color + .background_color() .unwrap_or(egui::Color32::from_rgb(25, 25, 25)); ui.painter().rect_filled(rect, 0.0, bg_color); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index ec79d55..65176ad 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -881,11 +881,8 @@ impl PaneRenderer for InfopanelPane { shared: &mut SharedPaneState, ) { // Background - ui.painter().rect_filled( - rect, - 0.0, - egui::Color32::from_rgb(30, 35, 40), - ); + let bg = shared.theme.bg_color(&["#infopanel", ".pane-content"], ui.ctx(), egui::Color32::from_rgb(30, 35, 40)); + ui.painter().rect_filled(rect, 0.0, bg); // Create scrollable area for content let content_rect = rect.shrink(8.0); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index f226e72..e6f742e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -141,6 +141,12 @@ pub fn find_sampled_audio_track(document: &lightningbeam_core::document::Documen None } +/// CSS debug region for the CSS inspector overlay (F3) +pub struct CssDebugRegion { + pub rect: egui::Rect, + pub context: Vec<&'static str>, +} + /// Shared state that all panes can access pub struct SharedPaneState<'a> { pub tool_icon_cache: &'a mut crate::ToolIconCache, @@ -285,6 +291,10 @@ pub struct SharedPaneState<'a> { pub pending_node_group: &'a mut bool, /// Set by MenuAction::Group (ungroup variant) when focus is Nodes — consumed by node graph pane pub pending_node_ungroup: &'a mut bool, + /// CSS debug regions for the CSS inspector overlay (F3) + pub css_debug_regions: &'a mut Vec, + /// Whether the CSS debug overlay is active + pub css_debug_overlay: bool, /// Test mode state for event recording (debug builds only) #[cfg(debug_assertions)] pub test_mode: &'a mut crate::test_mode::TestModeState, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index 263b18f..c3cd4bc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -2269,12 +2269,12 @@ impl crate::panes::PaneRenderer for NodeGraphPane { if self.track_id.is_none() || self.backend.is_none() { // Show message that no valid track is selected let painter = ui.painter(); - let bg_color = egui::Color32::from_gray(30); + let bg_color = shared.theme.bg_color(&["#node-editor", ".pane-content"], ui.ctx(), egui::Color32::from_gray(30)); painter.rect_filled(rect, 0.0, bg_color); let text = "Select a MIDI or Audio track to view its node graph"; let font_id = egui::FontId::proportional(16.0); - let text_color = egui::Color32::from_gray(150); + let text_color = shared.theme.text_color(&["#node-editor", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(150)); let galley = painter.layout_no_wrap(text.to_string(), font_id, text_color); let text_pos = rect.center() - galley.size() / 2.0; @@ -2351,8 +2351,8 @@ impl crate::panes::PaneRenderer for NodeGraphPane { let bg_style = shared.theme.style(".node-graph-background", ui.ctx()); let grid_style = shared.theme.style(".node-graph-grid", ui.ctx()); - let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_gray(45)); - let grid_color = grid_style.background_color.unwrap_or(egui::Color32::from_gray(55)); + let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_gray(45)); + let grid_color = grid_style.background_color().unwrap_or(egui::Color32::from_gray(55)); // Draw breadcrumb bar when editing a subgraph let breadcrumb_height = if self.in_subgraph() { 28.0 } else { 0.0 }; @@ -2363,10 +2363,12 @@ impl crate::panes::PaneRenderer for NodeGraphPane { egui::vec2(rect.width(), breadcrumb_height), ); let painter = ui.painter(); - painter.rect_filled(breadcrumb_rect, 0.0, egui::Color32::from_gray(35)); + let bc_bg = shared.theme.bg_color(&["#node-editor", ".pane-header"], ui.ctx(), egui::Color32::from_gray(35)); + painter.rect_filled(breadcrumb_rect, 0.0, bc_bg); + let bc_border = shared.theme.border_color(&["#node-editor", ".pane-header"], ui.ctx(), egui::Color32::from_gray(60)); painter.line_segment( [breadcrumb_rect.left_bottom(), breadcrumb_rect.right_bottom()], - egui::Stroke::new(1.0, egui::Color32::from_gray(60)), + egui::Stroke::new(1.0, bc_border), ); // Draw clickable breadcrumb segments @@ -2378,9 +2380,9 @@ impl crate::panes::PaneRenderer for NodeGraphPane { for (i, segment) in segments.iter().enumerate() { let is_last = i == segments.len() - 1; let text_color = if is_last { - egui::Color32::from_gray(220) + shared.theme.text_color(&["#node-editor", ".text-primary"], ui.ctx(), egui::Color32::from_gray(220)) } else { - egui::Color32::from_rgb(100, 180, 255) + shared.theme.text_color(&["#node-editor", ".text-secondary"], ui.ctx(), egui::Color32::from_rgb(100, 180, 255)) }; let font_id = egui::FontId::proportional(13.0); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/outliner.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/outliner.rs index c901fbc..bd03d16 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/outliner.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/outliner.rs @@ -22,22 +22,20 @@ impl PaneRenderer for OutlinerPane { ui: &mut egui::Ui, rect: egui::Rect, _path: &NodePath, - _shared: &mut SharedPaneState, + shared: &mut SharedPaneState, ) { // Placeholder rendering - ui.painter().rect_filled( - rect, - 0.0, - egui::Color32::from_rgb(40, 50, 30), - ); + let bg = shared.theme.bg_color(&["#outliner", ".pane-content"], ui.ctx(), egui::Color32::from_rgb(40, 50, 30)); + ui.painter().rect_filled(rect, 0.0, bg); let text = "Outliner\n(TODO: Implement layer tree)"; + let text_color = shared.theme.text_color(&["#outliner", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(150)); ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(16.0), - egui::Color32::from_gray(150), + text_color, ); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index fb563db..21c3db5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -290,7 +290,8 @@ impl PianoRollPane { let painter = ui.painter_at(rect); // Background - painter.rect_filled(rect, 0.0, Color32::from_rgb(30, 30, 35)); + let bg = shared.theme.bg_color(&["#piano-roll", ".pane-content"], ui.ctx(), Color32::from_rgb(30, 30, 35)); + painter.rect_filled(rect, 0.0, bg); // Render grid (clipped to grid area) let grid_painter = ui.painter_at(grid_rect); @@ -1450,7 +1451,8 @@ impl PianoRollPane { let painter = ui.painter_at(rect); // Background - painter.rect_filled(rect, 0.0, Color32::from_rgb(20, 20, 25)); + let spec_bg = shared.theme.bg_color(&["#piano-roll", ".pane-content"], ui.ctx(), Color32::from_rgb(20, 20, 25)); + painter.rect_filled(rect, 0.0, spec_bg); // Dot grid background (visible where the spectrogram doesn't draw) let grid_painter = ui.painter_at(view_rect); @@ -1633,10 +1635,14 @@ impl PianoRollPane { impl PaneRenderer for PianoRollPane { fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { ui.horizontal(|ui| { + let header_text = shared.theme.text_color(&["#piano-roll", ".pane-header"], ui.ctx(), Color32::from_gray(180)); + let header_secondary = shared.theme.text_color(&["#piano-roll", ".text-secondary"], ui.ctx(), Color32::from_gray(140)); + let header_accent = shared.theme.text_color(&["#piano-roll", ".status-success"], ui.ctx(), Color32::from_rgb(143, 252, 143)); + // Pane title ui.label( egui::RichText::new("Piano Roll") - .color(Color32::from_gray(180)) + .color(header_text) .size(11.0), ); ui.separator(); @@ -1644,7 +1650,7 @@ impl PaneRenderer for PianoRollPane { // Zoom ui.label( egui::RichText::new(format!("{:.0}px/s", self.pixels_per_second)) - .color(Color32::from_gray(140)) + .color(header_secondary) .size(10.0), ); @@ -1653,7 +1659,7 @@ impl PaneRenderer for PianoRollPane { ui.separator(); ui.label( egui::RichText::new(format!("{} selected", self.selected_note_indices.len())) - .color(Color32::from_rgb(143, 252, 143)) + .color(header_accent) .size(10.0), ); } @@ -1669,7 +1675,7 @@ impl PaneRenderer for PianoRollPane { let n = &resolved[idx]; ui.label( egui::RichText::new(format!("{} vel:{}", Self::note_name(n.note), n.velocity)) - .color(Color32::from_gray(140)) + .color(header_secondary) .size(10.0), ); } @@ -1691,7 +1697,7 @@ impl PaneRenderer for PianoRollPane { ui.separator(); ui.label( egui::RichText::new("Gamma") - .color(Color32::from_gray(140)) + .color(header_secondary) .size(10.0), ); ui.add( diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs index fd5e4fd..5f31e08 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/preset_browser.rs @@ -338,7 +338,7 @@ impl PaneRenderer for PresetBrowserPane { // Background let bg_style = shared.theme.style(".pane-content", ui.ctx()); - let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_rgb(47, 47, 47)); + let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_rgb(47, 47, 47)); ui.painter().rect_filled(rect, 0.0, bg_color); let text_color = shared.theme.style(".text-primary", ui.ctx()) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs index 95c34c1..946f125 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs @@ -744,7 +744,8 @@ impl PaneRenderer for ShaderEditorPane { } // Background - ui.painter().rect_filled(rect, 0.0, egui::Color32::from_rgb(25, 25, 30)); + let bg = shared.theme.bg_color(&["#shader-editor", ".pane-content"], ui.ctx(), egui::Color32::from_rgb(25, 25, 30)); + ui.painter().rect_filled(rect, 0.0, bg); let content_rect = rect.shrink(8.0); let mut content_ui = ui.new_child( diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 9602b87..be88f89 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -7939,13 +7939,14 @@ impl PaneRenderer for StagePane { ui.painter().add(cb); // Show camera info overlay + let info_color = shared.theme.text_color(&["#stage", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); ui.painter().text( rect.min + egui::vec2(10.0, 10.0), egui::Align2::LEFT_TOP, format!("Vello Stage (zoom: {:.2}, pan: {:.0},{:.0})", self.zoom, self.pan_offset.x, self.pan_offset.y), egui::FontId::proportional(14.0), - egui::Color32::from_gray(200), + info_color, ); // Render breadcrumb navigation when inside a movie clip diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index ee03654..09159a4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -462,6 +462,21 @@ fn find_sampled_audio_track_for_clip( None } +/// Get layer type display name and color for an AnyLayer +fn layer_type_info(layer: &AnyLayer) -> (&'static str, egui::Color32) { + match layer { + AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)), + AnyLayer::Audio(al) => match al.audio_layer_type { + AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)), + AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)), + }, + AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)), + AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)), + AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)), + AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)), + } +} + impl TimelinePane { pub fn new() -> Self { Self { @@ -1008,7 +1023,7 @@ impl TimelinePane { // Background let bg_style = theme.style(".timeline-background", ui.ctx()); - let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_rgb(34, 34, 34)); + let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_rgb(34, 34, 34)); painter.rect_filled(rect, 0.0, bg_color); let text_style = theme.style(".text-primary", ui.ctx()); @@ -1027,7 +1042,7 @@ impl TimelinePane { painter.line_segment( [rect.min + egui::vec2(x, rect.height() - 10.0), rect.min + egui::vec2(x, rect.height())], - egui::Stroke::new(1.0, egui::Color32::from_gray(100)), + egui::Stroke::new(1.0, theme.text_color(&["#timeline", ".ruler-tick"], ui.ctx(), egui::Color32::from_gray(100))), ); painter.text( rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP, @@ -1041,7 +1056,7 @@ impl TimelinePane { painter.line_segment( [rect.min + egui::vec2(minor_x, rect.height() - 5.0), rect.min + egui::vec2(minor_x, rect.height())], - egui::Stroke::new(1.0, egui::Color32::from_gray(60)), + egui::Stroke::new(1.0, theme.text_color(&["#timeline", ".ruler-tick-minor"], ui.ctx(), egui::Color32::from_gray(60))), ); } } @@ -1111,7 +1126,7 @@ impl TimelinePane { if x >= 0.0 && x <= rect.width() { let painter = ui.painter(); let scrubber_style = theme.style(".timeline-scrubber", ui.ctx()); - let scrubber_color = scrubber_style.background_color.unwrap_or(egui::Color32::from_rgb(204, 34, 34)); + let scrubber_color = scrubber_style.background_color().unwrap_or(egui::Color32::from_rgb(204, 34, 34)); // Red vertical line painter.line_segment( @@ -1159,7 +1174,7 @@ impl TimelinePane { // Get note color from theme CSS (fallback to black) let note_style = theme.style(".timeline-midi-note", ctx); - let note_color = note_style.background_color.unwrap_or(egui::Color32::BLACK); + let note_color = note_style.background_color().unwrap_or(egui::Color32::BLACK); // Build a map of active notes (note_number -> note_on_timestamp) // to calculate durations when we encounter note-offs @@ -1269,7 +1284,7 @@ impl TimelinePane { ) { // Background for header column let header_style = theme.style(".timeline-header", ui.ctx()); - let header_bg = header_style.background_color.unwrap_or(egui::Color32::from_rgb(17, 17, 17)); + let header_bg = header_style.background_color().unwrap_or(egui::Color32::from_rgb(17, 17, 17)); ui.painter().rect_filled( rect, 0.0, @@ -1279,13 +1294,14 @@ impl TimelinePane { // Theme colors for active/inactive layers let active_style = theme.style(".timeline-layer-active", ui.ctx()); let inactive_style = theme.style(".timeline-layer-inactive", ui.ctx()); - let active_color = active_style.background_color.unwrap_or(egui::Color32::from_rgb(79, 79, 79)); - let inactive_color = inactive_style.background_color.unwrap_or(egui::Color32::from_rgb(51, 51, 51)); + let active_color = active_style.background_color().unwrap_or(egui::Color32::from_rgb(79, 79, 79)); + let inactive_color = inactive_style.background_color().unwrap_or(egui::Color32::from_rgb(51, 51, 51)); // Get text color from theme let text_style = theme.style(".text-primary", ui.ctx()); let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); - let secondary_text_color = egui::Color32::from_gray(150); + let secondary_style = theme.style(".text-secondary", ui.ctx()); + let secondary_text_color = secondary_style.text_color.unwrap_or(egui::Color32::from_gray(150)); // Build virtual row list (accounts for group expansion) let all_rows = build_timeline_rows(context_layers); @@ -1331,35 +1347,15 @@ impl TimelinePane { let (layer_id, layer_name, layer_type, type_color) = match row { TimelineRow::Normal(layer) => { let data = layer.layer(); - let (lt, tc) = match layer { - AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)), - AnyLayer::Audio(al) => match al.audio_layer_type { - AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)), - AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)), - }, - AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)), - AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)), - AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)), - AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)), - }; + let (lt, tc) = layer_type_info(layer); (layer.id(), data.name.clone(), lt, tc) } TimelineRow::CollapsedGroup { group, .. } => { - (group.layer.id, group.layer.name.clone(), "Group", egui::Color32::from_rgb(0, 180, 180)) + (group.layer.id, group.layer.name.clone(), "Group", theme.bg_color(&["#timeline", ".layer-type-group"], ui.ctx(), egui::Color32::from_rgb(0, 180, 180))) } TimelineRow::GroupChild { child, .. } => { let data = child.layer(); - let (lt, tc) = match child { - AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)), - AnyLayer::Audio(al) => match al.audio_layer_type { - AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)), - AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)), - }, - AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)), - AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)), - AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)), - AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)), - }; + let (lt, tc) = layer_type_info(child); (child.id(), data.name.clone(), lt, tc) } }; @@ -1388,7 +1384,7 @@ impl TimelinePane { let group_color = match row { TimelineRow::GroupChild { .. } | TimelineRow::CollapsedGroup { .. } => { // Solid dark teal for the group gutter - egui::Color32::from_rgb(0, 50, 50) + theme.bg_color(&["#timeline", ".group-gutter"], ui.ctx(), egui::Color32::from_rgb(0, 50, 50)) } _ => header_bg, }; @@ -1399,7 +1395,7 @@ impl TimelinePane { egui::pos2(header_rect.min.x + indent - 2.0, y), egui::vec2(2.0, LAYER_HEIGHT), ); - ui.painter().rect_filled(accent_rect, 0.0, egui::Color32::from_rgb(0, 180, 180)); + ui.painter().rect_filled(accent_rect, 0.0, theme.bg_color(&["#timeline", ".group-accent"], ui.ctx(), egui::Color32::from_rgb(0, 180, 180))); // Draw collapse triangle on first child row (painted, not text) if let TimelineRow::GroupChild { show_collapse: true, .. } = row { @@ -1412,7 +1408,7 @@ impl TimelinePane { egui::pos2(cx + s, cy - s * 0.6), egui::pos2(cx, cy + s * 0.6), ]; - ui.painter().add(egui::Shape::convex_polygon(tri, egui::Color32::from_gray(180), egui::Stroke::NONE)); + ui.painter().add(egui::Shape::convex_polygon(tri, theme.text_color(&["#timeline", ".collapse-triangle"], ui.ctx(), egui::Color32::from_gray(180)), egui::Stroke::NONE)); } // Make the ENTIRE gutter clickable for collapse on any GroupChild row @@ -1448,7 +1444,7 @@ impl TimelinePane { egui::pos2(cx - s * 0.6, cy + s), egui::pos2(cx + s * 0.6, cy), ]; - ui.painter().add(egui::Shape::convex_polygon(tri, egui::Color32::from_gray(180), egui::Stroke::NONE)); + ui.painter().add(egui::Shape::convex_polygon(tri, theme.text_color(&["#timeline", ".collapse-triangle"], ui.ctx(), egui::Color32::from_gray(180)), egui::Stroke::NONE)); // Clickable area for expand let chevron_rect = egui::Rect::from_min_size( @@ -1562,9 +1558,9 @@ impl TimelinePane { let cam_text = if camera_enabled { "📹" } else { "📷" }; let button = egui::Button::new(cam_text) .fill(if camera_enabled { - egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100) + theme.bg_color(&["#timeline", ".btn-toggle", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100)) } else { - egui::Color32::from_gray(40) + theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) }) .stroke(egui::Stroke::NONE); ui.add(button) @@ -1573,9 +1569,9 @@ impl TimelinePane { let mute_text = if is_muted { "🔇" } else { "🔊" }; let button = egui::Button::new(mute_text) .fill(if is_muted { - egui::Color32::from_rgba_unmultiplied(255, 100, 100, 100) + theme.bg_color(&["#timeline", ".btn-mute", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(255, 100, 100, 100)) } else { - egui::Color32::from_gray(40) + theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) }) .stroke(egui::Stroke::NONE); ui.add(button) @@ -1606,9 +1602,9 @@ impl TimelinePane { let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(solo_button_rect), |ui| { let button = egui::Button::new("🎧") .fill(if is_soloed { - egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100) + theme.bg_color(&["#timeline", ".btn-solo", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100)) } else { - egui::Color32::from_gray(40) + theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) }) .stroke(egui::Stroke::NONE); ui.add(button) @@ -1630,9 +1626,9 @@ impl TimelinePane { let lock_text = if is_locked { "🔒" } else { "🔓" }; let button = egui::Button::new(lock_text) .fill(if is_locked { - egui::Color32::from_rgba_unmultiplied(200, 150, 100, 100) + theme.bg_color(&["#timeline", ".btn-lock", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(200, 150, 100, 100)) } else { - egui::Color32::from_gray(40) + theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) }) .stroke(egui::Stroke::NONE); ui.add(button) @@ -1732,7 +1728,7 @@ impl TimelinePane { egui::Align2::CENTER_CENTER, "Gain", egui::FontId::proportional(9.0), - egui::Color32::from_gray(140), + theme.text_color(&["#timeline", ".gain-label"], ui.ctx(), egui::Color32::from_gray(140)), ); } } @@ -1763,11 +1759,11 @@ impl TimelinePane { let clamped = level.min(1.0); let filled_width = meter_rect.width() * clamped; let color = if clamped > 0.9 { - egui::Color32::from_rgb(220, 50, 50) + theme.bg_color(&["#timeline", ".vu-meter", ".clip"], ui.ctx(), egui::Color32::from_rgb(220, 50, 50)) } else if clamped > 0.7 { - egui::Color32::from_rgb(220, 200, 50) + theme.bg_color(&["#timeline", ".vu-meter", ".warn"], ui.ctx(), egui::Color32::from_rgb(220, 200, 50)) } else { - egui::Color32::from_rgb(50, 200, 80) + theme.bg_color(&["#timeline", ".vu-meter", ".normal"], ui.ctx(), egui::Color32::from_rgb(50, 200, 80)) }; let filled = egui::Rect::from_min_size( meter_rect.left_top(), @@ -1783,7 +1779,7 @@ impl TimelinePane { egui::pos2(header_rect.min.x, header_rect.max.y), egui::pos2(header_rect.max.x, header_rect.max.y), ], - egui::Stroke::new(1.0, egui::Color32::from_gray(20)), + egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))), ); } @@ -1817,34 +1813,14 @@ impl TimelinePane { }; let (drag_name, drag_type_str, drag_type_color) = match dragged_row { TimelineRow::Normal(layer) => { - let (lt, tc) = match layer { - AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)), - AnyLayer::Audio(al) => match al.audio_layer_type { - AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)), - AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)), - }, - AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)), - AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)), - AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)), - AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(100, 200, 255)), - }; + let (lt, tc) = layer_type_info(layer); (layer.layer().name.clone(), lt, tc) } TimelineRow::CollapsedGroup { group, .. } => { - (group.layer.name.clone(), "Group", egui::Color32::from_rgb(0, 180, 180)) + (group.layer.name.clone(), "Group", theme.bg_color(&["#timeline", ".layer-type-group"], ui.ctx(), egui::Color32::from_rgb(0, 180, 180))) } TimelineRow::GroupChild { child, .. } => { - let (lt, tc) = match child { - AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)), - AnyLayer::Audio(al) => match al.audio_layer_type { - AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)), - AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)), - }, - AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)), - AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)), - AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)), - AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(100, 200, 255)), - }; + let (lt, tc) = layer_type_info(child); (child.layer().name.clone(), lt, tc) } }; @@ -1878,7 +1854,7 @@ impl TimelinePane { // Separator line at bottom ui.painter().line_segment( [egui::pos2(float_rect.min.x, float_rect.max.y), egui::pos2(float_rect.max.x, float_rect.max.y)], - egui::Stroke::new(1.0, egui::Color32::from_gray(20)), + egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))), ); } } @@ -1889,7 +1865,7 @@ impl TimelinePane { egui::pos2(rect.max.x, rect.min.y), egui::pos2(rect.max.x, rect.max.y), ], - egui::Stroke::new(1.0, egui::Color32::from_gray(20)), + egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))), ); } @@ -1923,8 +1899,8 @@ impl TimelinePane { // Theme colors for active/inactive layers let active_style = theme.style(".timeline-row-active", ui.ctx()); let inactive_style = theme.style(".timeline-row-inactive", ui.ctx()); - let active_color = active_style.background_color.unwrap_or(egui::Color32::from_rgb(85, 85, 85)); - let inactive_color = inactive_style.background_color.unwrap_or(egui::Color32::from_rgb(136, 136, 136)); + let active_color = active_style.background_color().unwrap_or(egui::Color32::from_rgb(85, 85, 85)); + let inactive_color = inactive_style.background_color().unwrap_or(egui::Color32::from_rgb(136, 136, 136)); // Build a map of clip_instance_id -> InstanceGroup for linked clip previews let mut instance_to_group: std::collections::HashMap = std::collections::HashMap::new(); @@ -2038,7 +2014,7 @@ impl TimelinePane { painter.line_segment( [egui::pos2(rect.min.x + x, y), egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)], - egui::Stroke::new(1.0, egui::Color32::from_gray(30)), + egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".grid-line"], ui.ctx(), egui::Color32::from_gray(30))), ); } time += interval; @@ -2058,7 +2034,7 @@ impl TimelinePane { painter.line_segment( [egui::pos2(rect.min.x + x, y), egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)], - egui::Stroke::new(if is_measure_boundary { 1.5 } else { 1.0 }, egui::Color32::from_gray(gray)), + egui::Stroke::new(if is_measure_boundary { 1.5 } else { 1.0 }, theme.border_color(&["#timeline", ".grid-line"], ui.ctx(), egui::Color32::from_gray(gray))), ); } } @@ -2102,14 +2078,14 @@ impl TimelinePane { let any_selected = child_clips.iter().any(|(_, ci)| selection.contains_clip_instance(&ci.id)); // Draw each merged span as a teal bar (brighter when selected) let teal = if any_selected { - egui::Color32::from_rgb(30, 190, 190) + theme.bg_color(&["#timeline", ".group-bar", ".selected"], ui.ctx(), egui::Color32::from_rgb(30, 190, 190)) } else { - egui::Color32::from_rgb(0, 150, 150) + theme.bg_color(&["#timeline", ".group-bar"], ui.ctx(), egui::Color32::from_rgb(0, 150, 150)) }; let bright_teal = if any_selected { - egui::Color32::from_rgb(150, 255, 255) + theme.text_color(&["#timeline", ".group-bar", ".selected"], ui.ctx(), egui::Color32::from_rgb(150, 255, 255)) } else { - egui::Color32::from_rgb(100, 220, 220) + theme.text_color(&["#timeline", ".group-bar"], ui.ctx(), egui::Color32::from_rgb(100, 220, 220)) }; for (s, e) in &merged { let sx = self.time_to_x(*s); @@ -2344,7 +2320,7 @@ impl TimelinePane { egui::pos2(layer_rect.min.x, layer_rect.max.y), egui::pos2(layer_rect.max.x, layer_rect.max.y), ], - egui::Stroke::new(1.0, egui::Color32::from_gray(20)), + egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))), ); continue; // Skip normal clip rendering for collapsed groups } @@ -2641,39 +2617,15 @@ impl TimelinePane { let visible_end_x = end_x.min(rect.width()); // Choose color based on layer type - let (clip_color, bright_color) = match layer { - lightningbeam_core::layer::AnyLayer::Vector(_) => ( - egui::Color32::from_rgb(220, 150, 80), // Orange - egui::Color32::from_rgb(255, 210, 150), // Bright orange - ), - lightningbeam_core::layer::AnyLayer::Audio(audio_layer) => { - match audio_layer.audio_layer_type { - lightningbeam_core::layer::AudioLayerType::Midi => ( - egui::Color32::from_rgb(100, 200, 150), // Green - egui::Color32::from_rgb(150, 255, 200), // Bright green - ), - lightningbeam_core::layer::AudioLayerType::Sampled => ( - egui::Color32::from_rgb(80, 150, 220), // Blue - egui::Color32::from_rgb(150, 210, 255), // Bright blue - ), - } - } - lightningbeam_core::layer::AnyLayer::Video(_) => ( - egui::Color32::from_rgb(150, 80, 220), // Purple - egui::Color32::from_rgb(200, 150, 255), // Bright purple - ), - lightningbeam_core::layer::AnyLayer::Effect(_) => ( - egui::Color32::from_rgb(220, 80, 160), // Pink - egui::Color32::from_rgb(255, 120, 200), // Bright pink - ), - lightningbeam_core::layer::AnyLayer::Group(_) => ( - egui::Color32::from_rgb(0, 150, 150), // Teal - egui::Color32::from_rgb(100, 220, 220), // Bright teal - ), - lightningbeam_core::layer::AnyLayer::Raster(_) => ( - egui::Color32::from_rgb(160, 100, 200), // Purple/violet - egui::Color32::from_rgb(200, 160, 240), // Bright purple/violet - ), + let (clip_color, bright_color) = { + let (_, base_color) = layer_type_info(layer); + // Derive bright version by lightening each channel + let bright = egui::Color32::from_rgb( + (base_color.r() as u16 + 80).min(255) as u8, + (base_color.g() as u16 + 60).min(255) as u8, + (base_color.b() as u16 + 70).min(255) as u8, + ); + (base_color, bright) }; let (row, total_rows) = clip_stacking[clip_instance_index]; @@ -3142,11 +3094,11 @@ impl TimelinePane { egui::pos2(cx, cy + size), egui::pos2(cx - size, cy), ]; - let color = egui::Color32::from_rgb(255, 220, 100); + let color = theme.bg_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(255, 220, 100)); painter.add(egui::Shape::convex_polygon( diamond.to_vec(), color, - egui::Stroke::new(1.0, egui::Color32::from_rgb(180, 150, 50)), + egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(180, 150, 50))), )); } } @@ -3158,7 +3110,7 @@ impl TimelinePane { egui::pos2(layer_rect.min.x, layer_rect.max.y), egui::pos2(layer_rect.max.x, layer_rect.max.y), ], - egui::Stroke::new(1.0, egui::Color32::from_gray(20)), + egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))), ); } @@ -4258,7 +4210,7 @@ impl PaneRenderer for TimelinePane { egui::Sense::hover(), ); // Background - ui.painter().rect_filled(meter_rect, 2.0, egui::Color32::from_gray(30)); + ui.painter().rect_filled(meter_rect, 2.0, shared.theme.bg_color(&["#timeline", ".vu-meter-bg"], ui.ctx(), egui::Color32::from_gray(30))); let levels = [shared.output_level.0.min(1.0), shared.output_level.1.min(1.0)]; for (i, &level) in levels.iter().enumerate() { @@ -4266,11 +4218,11 @@ impl PaneRenderer for TimelinePane { if level > 0.001 { let filled_width = meter_rect.width() * level; let color = if level > 0.9 { - egui::Color32::from_rgb(220, 50, 50) + shared.theme.bg_color(&["#timeline", ".vu-meter", ".clip"], ui.ctx(), egui::Color32::from_rgb(220, 50, 50)) } else if level > 0.7 { - egui::Color32::from_rgb(220, 200, 50) + shared.theme.bg_color(&["#timeline", ".vu-meter", ".warn"], ui.ctx(), egui::Color32::from_rgb(220, 200, 50)) } else { - egui::Color32::from_rgb(50, 200, 80) + shared.theme.bg_color(&["#timeline", ".vu-meter", ".normal"], ui.ctx(), egui::Color32::from_rgb(50, 200, 80)) }; let filled_rect = egui::Rect::from_min_size( egui::pos2(meter_rect.min.x, bar_y), @@ -4413,7 +4365,7 @@ impl PaneRenderer for TimelinePane { // Render spacer above layer headers (same height as ruler) let spacer_style = shared.theme.style(".timeline-spacer", ui.ctx()); - let spacer_bg = spacer_style.background_color.unwrap_or(egui::Color32::from_rgb(17, 17, 17)); + let spacer_bg = spacer_style.background_color().unwrap_or(egui::Color32::from_rgb(17, 17, 17)); ui.painter().rect_filled( header_ruler_spacer, 0.0, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index f192158..daf606b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -83,9 +83,9 @@ impl PaneRenderer for ToolbarPane { // Button background let bg_color = if is_selected { - egui::Color32::from_rgb(70, 100, 150) // Highlighted blue + shared.theme.bg_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 100, 150)) } else { - egui::Color32::from_rgb(50, 50, 50) + shared.theme.bg_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50)) }; ui.painter().rect_filled(button_rect, 4.0, bg_color); @@ -113,7 +113,7 @@ impl PaneRenderer for ToolbarPane { ]; ui.painter().add(egui::Shape::convex_polygon( tri.to_vec(), - egui::Color32::from_gray(200), + shared.theme.text_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_gray(200)), egui::Stroke::NONE, )); } @@ -159,7 +159,7 @@ impl PaneRenderer for ToolbarPane { ui.painter().rect_stroke( button_rect, 4.0, - egui::Stroke::new(2.0, egui::Color32::from_gray(180)), + egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".hover"], ui.ctx(), egui::Color32::from_gray(180))), egui::StrokeKind::Middle, ); } @@ -186,7 +186,7 @@ impl PaneRenderer for ToolbarPane { ui.painter().rect_stroke( button_rect, 4.0, - egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 150, 255)), + egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))), egui::StrokeKind::Middle, ); } @@ -216,12 +216,13 @@ impl PaneRenderer for ToolbarPane { // Raster layers label them "FG" / "BG"; vector layers label them "Stroke" / "Fill". { let stroke_label = if is_raster { "FG" } else { "Stroke" }; + let label_color = shared.theme.text_color(&["#toolbar", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); ui.painter().text( egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0), egui::Align2::CENTER_CENTER, stroke_label, egui::FontId::proportional(14.0), - egui::Color32::from_gray(200), + label_color, ); let stroke_button_rect = egui::Rect::from_min_size( @@ -246,12 +247,13 @@ impl PaneRenderer for ToolbarPane { // Fill/BG color swatch { let fill_label = if is_raster { "BG" } else { "Fill" }; + let label_color = shared.theme.text_color(&["#toolbar", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); ui.painter().text( egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0), egui::Align2::CENTER_CENTER, fill_label, egui::FontId::proportional(14.0), - egui::Color32::from_gray(200), + label_color, ); let fill_button_rect = egui::Rect::from_min_size( diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs index feee43a..4309795 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs @@ -153,6 +153,7 @@ impl VirtualPianoPane { &self, ui: &mut egui::Ui, rect: egui::Rect, + shared: &SharedPaneState, visible_start: u8, visible_end: u8, white_key_width: f32, @@ -173,15 +174,16 @@ impl VirtualPianoPane { egui::vec2(white_key_width - 1.0, white_key_height), ); let color = if self.pressed_notes.contains(¬e) { - egui::Color32::from_rgb(100, 150, 255) + shared.theme.bg_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255)) } else { - egui::Color32::WHITE + shared.theme.bg_color(&[".piano-white-key"], ui.ctx(), egui::Color32::WHITE) }; ui.painter().rect_filled(key_rect, 2.0, color); + let border = shared.theme.border_color(&[".piano-white-key"], ui.ctx(), egui::Color32::BLACK); ui.painter().rect_stroke( key_rect, 2.0, - egui::Stroke::new(1.0, egui::Color32::BLACK), + egui::Stroke::new(1.0, border), egui::StrokeKind::Middle, ); white_pos += 1.0; @@ -204,9 +206,9 @@ impl VirtualPianoPane { egui::vec2(black_key_width, black_key_height), ); let color = if self.pressed_notes.contains(¬e) { - egui::Color32::from_rgb(50, 100, 200) + shared.theme.bg_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(50, 100, 200)) } else { - egui::Color32::BLACK + shared.theme.bg_color(&[".piano-black-key"], ui.ctx(), egui::Color32::BLACK) }; ui.painter().rect_filled(key_rect, 2.0, color); } @@ -232,7 +234,7 @@ impl VirtualPianoPane { self.send_note_off(note, shared); } } - self.render_keyboard_visual_only(ui, rect, visible_start, visible_end, white_key_width, offset_x, white_key_height, black_key_width, black_key_height); + self.render_keyboard_visual_only(ui, rect, shared, visible_start, visible_end, white_key_width, offset_x, white_key_height, black_key_width, black_key_height); return; } @@ -303,16 +305,17 @@ impl VirtualPianoPane { let is_pressed = self.pressed_notes.contains(¬e) || (!black_key_interacted && pointer_over_key && pointer_down); let color = if is_pressed { - egui::Color32::from_rgb(100, 150, 255) // Blue when pressed + shared.theme.bg_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255)) } else { - egui::Color32::WHITE + shared.theme.bg_color(&[".piano-white-key"], ui.ctx(), egui::Color32::WHITE) }; ui.painter().rect_filled(key_rect, 2.0, color); + let border = shared.theme.border_color(&[".piano-white-key"], ui.ctx(), egui::Color32::BLACK); ui.painter().rect_stroke( key_rect, 2.0, - egui::Stroke::new(1.0, egui::Color32::BLACK), + egui::Stroke::new(1.0, border), egui::StrokeKind::Middle, ); @@ -386,9 +389,9 @@ impl VirtualPianoPane { let is_pressed = self.pressed_notes.contains(¬e) || (pointer_over_key && pointer_down); let color = if is_pressed { - egui::Color32::from_rgb(50, 100, 200) // Darker blue when pressed + shared.theme.bg_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(50, 100, 200)) } else { - egui::Color32::BLACK + shared.theme.bg_color(&[".piano-black-key"], ui.ctx(), egui::Color32::BLACK) }; ui.painter().rect_filled(key_rect, 2.0, color); @@ -670,6 +673,7 @@ impl VirtualPianoPane { &self, ui: &mut egui::Ui, rect: egui::Rect, + shared: &SharedPaneState, visible_start: u8, visible_end: u8, white_key_width: f32, @@ -707,9 +711,9 @@ impl VirtualPianoPane { // Check if key is currently pressed let is_pressed = self.pressed_notes.contains(¬e); let color = if is_pressed { - egui::Color32::BLACK + shared.theme.text_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::BLACK) } else { - egui::Color32::from_gray(51) // #333333 + shared.theme.text_color(&[".piano-white-key"], ui.ctx(), egui::Color32::from_gray(51)) }; ui.painter().text( @@ -748,9 +752,9 @@ impl VirtualPianoPane { let is_pressed = self.pressed_notes.contains(¬e); let color = if is_pressed { - egui::Color32::WHITE + shared.theme.text_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::WHITE) } else { - egui::Color32::from_rgba_premultiplied(255, 255, 255, 178) // rgba(255,255,255,0.7) + shared.theme.text_color(&[".piano-black-key"], ui.ctx(), egui::Color32::from_rgba_premultiplied(255, 255, 255, 178)) }; ui.painter().text( @@ -766,7 +770,7 @@ impl VirtualPianoPane { } impl PaneRenderer for VirtualPianoPane { - fn render_header(&mut self, ui: &mut egui::Ui, _shared: &mut SharedPaneState) -> bool { + fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { ui.horizontal(|ui| { ui.label("Octave Shift:"); if ui.button("-").clicked() && self.octave_offset > -2 { @@ -795,9 +799,13 @@ impl PaneRenderer for VirtualPianoPane { // Sustain pedal indicator ui.label("Sustain:"); let sustain_text = if self.sustain_active { - egui::RichText::new("ON").color(egui::Color32::from_rgb(100, 200, 100)) + egui::RichText::new("ON").color( + shared.theme.text_color(&[".sustain-indicator", ".active"], ui.ctx(), egui::Color32::from_rgb(100, 200, 100)) + ) } else { - egui::RichText::new("OFF").color(egui::Color32::GRAY) + egui::RichText::new("OFF").color( + shared.theme.text_color(&[".sustain-indicator"], ui.ctx(), egui::Color32::GRAY) + ) }; ui.label(sustain_text); @@ -863,7 +871,7 @@ impl PaneRenderer for VirtualPianoPane { self.render_keyboard(ui, rect, shared); // Render keyboard labels on top - self.render_key_labels(ui, rect, visible_start, visible_end, white_key_width, offset_x); + self.render_key_labels(ui, rect, shared, visible_start, visible_end, white_key_width, offset_x); } fn name(&self) -> &str { diff --git a/lightningbeam-ui/lightningbeam-editor/src/theme.rs b/lightningbeam-ui/lightningbeam-editor/src/theme.rs index 3779f42..04f02d7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/theme.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/theme.rs @@ -2,10 +2,15 @@ /// /// 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)] @@ -35,29 +40,262 @@ impl ThemeMode { } } +/// 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_color: Option, + pub background: Option, pub border_color: Option, + pub border_width: Option, + pub border_radius: Option, pub text_color: Option, pub width: Option, pub height: Option, - // Add more properties as needed + pub padding: Option, + pub margin: Option, + pub font_size: Option, + pub opacity: Option, +} + +impl Style { + /// Convenience: get background color if the background is Solid + pub fn background_color(&self) -> Option { + 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, + /// 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 = 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, +} + +/// Resolved style with provenance information (for CSS inspector) +#[derive(Debug, Clone, Default)] +pub struct ResolvedStyle { + pub style: Style, + /// property_name -> selector that provided it + pub provenance: HashMap, } #[derive(Debug, Clone)] pub struct Theme { light_variables: HashMap, dark_variables: HashMap, - light_styles: HashMap, - dark_styles: HashMap, + light_rules: Vec, + dark_rules: Vec, current_mode: ThemeMode, + /// Cache: (context_key, is_dark) -> Style + cache: RefCell, bool), Style>>, } impl Theme { - /// Load theme from CSS file + /// Load theme from CSS string pub fn from_css(css: &str) -> Result { + 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 { let stylesheet = StyleSheet::parse( css, ParserOptions::default(), @@ -65,8 +303,9 @@ impl Theme { let mut light_variables = HashMap::new(); let mut dark_variables = HashMap::new(); - let mut light_styles = HashMap::new(); - let mut dark_styles = 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 { @@ -76,7 +315,6 @@ impl Theme { .filter_map(|s| s.to_css_string(PrinterOptions::default()).ok()) .collect::>(); - // Check if this is :root if selectors.iter().any(|s| s.contains(":root")) { extract_css_variables(&style_rule.declarations, &mut light_variables)?; } @@ -104,7 +342,6 @@ impl Theme { } // 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) => { @@ -114,17 +351,25 @@ impl Theme { for selector in selectors { let selector = selector.trim(); - // Only process class and ID selectors 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_styles.insert(selector.to_string(), light_style); + light_rules.push(Rule { + selector: parsed.clone(), + style: light_style, + }); - // Also parse with dark variables (merge dark over light) + // 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_styles.insert(selector.to_string(), dark_style); + dark_rules.push(Rule { + selector: parsed, + style: dark_style, + }); } } } @@ -132,29 +377,26 @@ impl Theme { 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)); + 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::>(); - 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 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_styles.insert(selector.to_string(), style); - eprintln!(" Added dark style for: {}", selector); + dark_rules.push(Rule { + selector: parsed, + style, + }); } } } @@ -168,21 +410,52 @@ impl Theme { Ok(Self { light_variables, dark_variables, - light_styles, - dark_styles, + light_rules, + dark_rules, current_mode: ThemeMode::System, + cache: RefCell::new(HashMap::new()), }) } - /// Load theme from embedded CSS file + /// Load theme from embedded CSS file, optionally merging user stylesheet pub fn load_default() -> Result { let css = include_str!("../assets/styles.css"); - Self::from_css(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) { - self.current_mode = mode; + if self.current_mode != mode { + self.current_mode = mode; + self.cache.borrow_mut().clear(); + } } /// Get the current theme mode @@ -190,38 +463,186 @@ impl Theme { 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 { + /// 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, - }; - - 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() + /// 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::>(), 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 } - /// Check if theme has no styles - #[allow(dead_code)] // Used in tests + /// Resolve with provenance info (for CSS inspector debug overlay) + pub fn resolve_with_provenance(&self, context: &[&str], ctx: &egui::Context) -> ResolvedStyle { + let is_dark = self.is_dark(ctx); + let rules = if is_dark { &self.dark_rules } else { &self.light_rules }; + + let mut matching: Vec<&Rule> = rules + .iter() + .filter(|r| r.selector.matches(context)) + .collect(); + + matching.sort_by_key(|r| r.selector.specificity); + + let mut result = Style::default(); + let mut provenance = HashMap::new(); + + for rule in &matching { + let selector_str = rule.selector.parts.join(" "); + let s = &rule.style; + + if s.background.is_some() { + result.background = s.background.clone(); + provenance.insert("background".to_string(), selector_str.clone()); + } + if s.border_color.is_some() { + result.border_color = s.border_color; + provenance.insert("border-color".to_string(), selector_str.clone()); + } + if s.border_width.is_some() { + result.border_width = s.border_width; + provenance.insert("border-width".to_string(), selector_str.clone()); + } + if s.border_radius.is_some() { + result.border_radius = s.border_radius; + provenance.insert("border-radius".to_string(), selector_str.clone()); + } + if s.text_color.is_some() { + result.text_color = s.text_color; + provenance.insert("color".to_string(), selector_str.clone()); + } + if s.width.is_some() { + result.width = s.width; + provenance.insert("width".to_string(), selector_str.clone()); + } + if s.height.is_some() { + result.height = s.height; + provenance.insert("height".to_string(), selector_str.clone()); + } + if s.padding.is_some() { + result.padding = s.padding; + provenance.insert("padding".to_string(), selector_str.clone()); + } + if s.margin.is_some() { + result.margin = s.margin; + provenance.insert("margin".to_string(), selector_str.clone()); + } + if s.font_size.is_some() { + result.font_size = s.font_size; + provenance.insert("font-size".to_string(), selector_str.clone()); + } + if s.opacity.is_some() { + result.opacity = s.opacity; + provenance.insert("opacity".to_string(), selector_str.clone()); + } + } + + ResolvedStyle { style: result, provenance } + } + + /// 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_styles.is_empty() + self.light_rules.is_empty() } /// Debug: print loaded theme info pub fn debug_print(&self) { - println!("📊 Theme Debug Info:"); + println!("Theme Debug Info:"); println!(" Light variables: {}", self.light_variables.len()); for (k, v) in self.light_variables.iter().take(5) { println!(" --{}: {}", k, v); @@ -230,13 +651,13 @@ impl Theme { 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!(" Light rules: {}", self.light_rules.len()); + for rule in self.light_rules.iter().take(5) { + println!(" {}", rule.selector.parts.join(" ")); } - println!(" Dark styles: {}", self.dark_styles.len()); - for k in self.dark_styles.keys().take(5) { - println!(" {}", k); + println!(" Dark rules: {}", self.dark_rules.len()); + for rule in self.dark_rules.iter().take(5) { + println!(" {}", rule.selector.parts.join(" ")); } } } @@ -269,22 +690,34 @@ fn parse_style_properties( 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); + 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); } @@ -294,6 +727,20 @@ fn parse_style_properties( "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::() { + style.opacity = Some(v); + } + } _ => {} } } @@ -302,17 +749,122 @@ fn parse_style_properties( Ok(style) } +/// Parse a CSS background value (gradient, url, or solid color) +fn parse_background_value(value: &str, variables: &HashMap) -> Option { + 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) -> Option { + // 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::() { + 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::().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) -> Option { 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) } @@ -320,13 +872,11 @@ fn parse_color_value(value: &str, variables: &HashMap) -> Option fn parse_dimension_value(value: &str, variables: &HashMap) -> Option { 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) } @@ -393,4 +943,76 @@ mod tests { 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))); + } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/theme_render.rs b/lightningbeam-ui/lightningbeam-editor/src/theme_render.rs new file mode 100644 index 0000000..2e13cbb --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/theme_render.rs @@ -0,0 +1,106 @@ +/// Theme rendering helpers for painting CSS backgrounds +/// +/// Handles solid colors, linear gradients (via egui Mesh), and image backgrounds. + +use eframe::egui; + +use crate::theme::Background; + +/// Paint a background into the given rect +pub fn paint_background( + painter: &egui::Painter, + rect: egui::Rect, + background: &Background, + rounding: f32, +) { + match background { + Background::Solid(color) => { + painter.rect_filled(rect, rounding, *color); + } + Background::LinearGradient { angle_degrees, stops } => { + paint_linear_gradient(painter, rect, *angle_degrees, stops, rounding); + } + Background::Image { .. } => { + // Image backgrounds require a TextureHandle loaded externally. + // For now, fall back to transparent (no-op). + // TODO: image cache integration + } + } +} + +/// Paint a linear gradient using an egui Mesh with colored vertices +/// +/// Supports arbitrary angles. The gradient direction follows CSS conventions: +/// - 0deg = bottom to top +/// - 90deg = left to right +/// - 180deg = top to bottom (default) +/// - 270deg = right to left +pub fn paint_linear_gradient( + painter: &egui::Painter, + rect: egui::Rect, + angle_degrees: f32, + stops: &[(f32, egui::Color32)], + rounding: f32, +) { + if stops.len() < 2 { + if let Some((_, color)) = stops.first() { + painter.rect_filled(rect, rounding, *color); + } + return; + } + + // Convert CSS angle to a direction vector + // CSS: 0deg = to top, 90deg = to right, 180deg = to bottom + let angle_rad = (angle_degrees - 90.0).to_radians(); + let dir = egui::vec2(angle_rad.cos(), angle_rad.sin()); + + // Project rect corners onto gradient direction to find start/end + let center = rect.center(); + let half_size = rect.size() / 2.0; + + // The gradient line length is the projection of the rect diagonal onto the direction + let gradient_half_len = (half_size.x * dir.x.abs()) + (half_size.y * dir.y.abs()); + + // For simple horizontal/vertical gradients with no rounding, use a mesh directly + if rounding <= 0.0 { + let mut mesh = egui::Mesh::default(); + mesh.texture_id = egui::TextureId::default(); + + // For each consecutive pair of stops, add a quad + for i in 0..stops.len() - 1 { + let (t0, c0) = stops[i]; + let (t1, c1) = stops[i + 1]; + + // Map t to positions along the gradient line + let p0_along = -gradient_half_len + t0 * 2.0 * gradient_half_len; + let p1_along = -gradient_half_len + t1 * 2.0 * gradient_half_len; + + // Perpendicular direction for quad width + let perp = egui::vec2(-dir.y, dir.x); + let perp_extent = (half_size.x * perp.x.abs()) + (half_size.y * perp.y.abs()); + + let base0 = center + dir * p0_along; + let base1 = center + dir * p1_along; + + let v0 = base0 - perp * perp_extent; + let v1 = base0 + perp * perp_extent; + let v2 = base1 + perp * perp_extent; + let v3 = base1 - perp * perp_extent; + + let uv = egui::pos2(0.0, 0.0); + let idx = mesh.vertices.len() as u32; + mesh.vertices.push(egui::epaint::Vertex { pos: v0, uv, color: c0 }); + mesh.vertices.push(egui::epaint::Vertex { pos: v1, uv, color: c0 }); + mesh.vertices.push(egui::epaint::Vertex { pos: v2, uv, color: c1 }); + mesh.vertices.push(egui::epaint::Vertex { pos: v3, uv, color: c1 }); + + mesh.indices.extend_from_slice(&[idx, idx + 1, idx + 2, idx, idx + 2, idx + 3]); + } + + painter.add(egui::Shape::mesh(mesh)); + } else { + // For rounded rects, paint without rounding for now. + // TODO: proper rounded gradient with tessellation or clip mask + paint_linear_gradient(painter, rect, angle_degrees, stops, 0.0); + } +}