From ec46e227828c69a71eab7c142aa244a669f0d486 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 2 Mar 2026 10:32:19 -0500 Subject: [PATCH 1/4] update css handling --- .../lightningbeam-editor/assets/styles.css | 328 +++++++- .../lightningbeam-editor/src/main.rs | 92 ++- .../src/panes/asset_library.rs | 130 ++- .../src/panes/infopanel.rs | 7 +- .../lightningbeam-editor/src/panes/mod.rs | 10 + .../src/panes/node_graph/mod.rs | 18 +- .../src/panes/outliner.rs | 12 +- .../src/panes/piano_roll.rs | 20 +- .../src/panes/preset_browser.rs | 2 +- .../src/panes/shader_editor.rs | 3 +- .../lightningbeam-editor/src/panes/stage.rs | 3 +- .../src/panes/timeline.rs | 200 ++--- .../lightningbeam-editor/src/panes/toolbar.rs | 16 +- .../src/panes/virtual_piano.rs | 46 +- .../lightningbeam-editor/src/theme.rs | 744 ++++++++++++++++-- .../lightningbeam-editor/src/theme_render.rs | 106 +++ 16 files changed, 1375 insertions(+), 362 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/src/theme_render.rs 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); + } +} From 6b3a286cafcbb77dc3fe61aa7bd6024f8f574f1b Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 2 Mar 2026 10:51:58 -0500 Subject: [PATCH 2/4] css fixes --- .../lightningbeam-editor/src/main.rs | 57 +------------- .../lightningbeam-editor/src/panes/mod.rs | 10 --- .../lightningbeam-editor/src/theme.rs | 76 ------------------- 3 files changed, 3 insertions(+), 140 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 081cd72..f459cd3 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -5378,7 +5378,6 @@ 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(); @@ -5522,8 +5521,6 @@ 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)] @@ -6014,54 +6011,6 @@ impl eframe::App for EditorApp { ); 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) @@ -6399,9 +6348,9 @@ fn render_pane( ui.painter().rect_filled(header_rect, 0.0, header_bg); // Draw content background - let bg_color = if let Some(pane_type) = 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)) + let pane_id = pane_type.map(pane_type_css_id); + let bg_color = if let Some(pane_id) = pane_id { + ctx.shared.theme.bg_color(&[pane_id, ".pane-content"], ui.ctx(), pane_color(pane_type.unwrap())) } else { egui::Color32::from_rgb(40, 40, 40) }; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index e6f742e..f226e72 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -141,12 +141,6 @@ 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, @@ -291,10 +285,6 @@ 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/theme.rs b/lightningbeam-ui/lightningbeam-editor/src/theme.rs index 04f02d7..638c676 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/theme.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/theme.rs @@ -269,14 +269,6 @@ struct Rule { 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, @@ -510,74 +502,6 @@ impl Theme { result } - /// 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) From b4c7a459909086c1f9445b2a4761bc5fb7400fcf Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 2 Mar 2026 11:58:13 -0500 Subject: [PATCH 3/4] fix NAM model loading --- daw-backend/src/audio/engine.rs | 13 +- daw-backend/src/audio/node_graph/graph.rs | 15 +- .../src/audio/node_graph/nodes/adsr.rs | 131 +++++++++++++++--- .../src/audio/node_graph/nodes/amp_sim.rs | 10 ++ .../audio/node_graph/nodes/bundled_models.rs | 50 +++++++ daw-backend/src/audio/node_graph/nodes/mod.rs | 1 + .../src/panes/node_graph/graph_data.rs | 2 + .../src/panes/node_graph/mod.rs | 42 +----- nam-ffi/src/lib.rs | 45 +++--- 9 files changed, 231 insertions(+), 78 deletions(-) create mode 100644 daw-backend/src/audio/node_graph/nodes/bundled_models.rs diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index b07f4ac..b50a31c 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1710,6 +1710,7 @@ impl Engine { Command::AmpSimLoadModel(track_id, node_id, model_path) => { use crate::audio::node_graph::nodes::AmpSimNode; + eprintln!("[AmpSim] Loading model: {:?} for track {:?} node {}", model_path, track_id, node_id); let graph = match self.project.get_track_mut(track_id) { Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph), Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph), @@ -1719,8 +1720,16 @@ impl Engine { let node_idx = NodeIndex::new(node_id as usize); if let Some(graph_node) = graph.get_graph_node_mut(node_idx) { if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::() { - if let Err(e) = amp_sim.load_model(&model_path) { - eprintln!("Failed to load NAM model: {}", e); + let result = if let Some(bundled_name) = model_path.strip_prefix("bundled:") { + eprintln!("[AmpSim] Loading bundled model: {}", bundled_name); + amp_sim.load_bundled_model(bundled_name) + } else { + eprintln!("[AmpSim] Loading model from file: {}", model_path); + amp_sim.load_model(&model_path) + }; + match &result { + Ok(()) => eprintln!("[AmpSim] Model loaded successfully"), + Err(e) => eprintln!("[AmpSim] Failed to load NAM model: {}", e), } } } diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index 51df5d8..c5bb6b2 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -1140,11 +1140,20 @@ impl AudioGraph { if let Some(ref model_path) = serialized_node.nam_model_path { if serialized_node.node_type == "AmpSim" { use crate::audio::node_graph::nodes::AmpSimNode; - let resolved_path = resolve_sample_path(model_path); + eprintln!("[AmpSim] Preset restore: nam_model_path={:?}", model_path); if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) { if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::() { - if let Err(e) = amp_sim.load_model(&resolved_path) { - eprintln!("Warning: failed to load NAM model {}: {}", resolved_path, e); + let result = if let Some(bundled_name) = model_path.strip_prefix("bundled:") { + eprintln!("[AmpSim] Preset: loading bundled model {:?}", bundled_name); + amp_sim.load_bundled_model(bundled_name) + } else { + let resolved_path = resolve_sample_path(model_path); + eprintln!("[AmpSim] Preset: loading from file {:?}", resolved_path); + amp_sim.load_model(&resolved_path) + }; + match &result { + Ok(()) => eprintln!("[AmpSim] Preset: model loaded successfully"), + Err(e) => eprintln!("[AmpSim] Preset: failed to load NAM model: {}", e), } } } diff --git a/daw-backend/src/audio/node_graph/nodes/adsr.rs b/daw-backend/src/audio/node_graph/nodes/adsr.rs index b594c1a..b4bdbe9 100644 --- a/daw-backend/src/audio/node_graph/nodes/adsr.rs +++ b/daw-backend/src/audio/node_graph/nodes/adsr.rs @@ -5,6 +5,7 @@ const PARAM_ATTACK: u32 = 0; const PARAM_DECAY: u32 = 1; const PARAM_SUSTAIN: u32 = 2; const PARAM_RELEASE: u32 = 3; +const PARAM_CURVE: u32 = 4; #[derive(Debug, Clone, Copy, PartialEq)] enum EnvelopeStage { @@ -15,6 +16,19 @@ enum EnvelopeStage { Release, } +/// Curve shape for envelope segments +#[derive(Debug, Clone, Copy, PartialEq)] +enum CurveType { + Linear, + Exponential, +} + +impl CurveType { + fn from_f32(v: f32) -> Self { + if v >= 0.5 { CurveType::Exponential } else { CurveType::Linear } + } +} + /// ADSR Envelope Generator /// Outputs a CV signal (0.0-1.0) based on gate input and ADSR parameters pub struct ADSRNode { @@ -23,8 +37,15 @@ pub struct ADSRNode { decay: f32, // seconds sustain: f32, // level (0.0-1.0) release: f32, // seconds + curve: CurveType, stage: EnvelopeStage, level: f32, // current envelope level + /// For exponential curves: the coefficient per sample (computed on stage entry) + exp_coeff: f32, + /// For exponential curves: the base level when the stage started + exp_base: f32, + /// For exponential curves: the target level + exp_target: f32, gate_was_high: bool, inputs: Vec, outputs: Vec, @@ -48,6 +69,7 @@ impl ADSRNode { Parameter::new(PARAM_DECAY, "Decay", 0.001, 5.0, 0.1, ParameterUnit::Time), Parameter::new(PARAM_SUSTAIN, "Sustain", 0.0, 1.0, 0.7, ParameterUnit::Generic), Parameter::new(PARAM_RELEASE, "Release", 0.001, 5.0, 0.2, ParameterUnit::Time), + Parameter::new(PARAM_CURVE, "Curve", 0.0, 1.0, 0.0, ParameterUnit::Generic), ]; Self { @@ -56,8 +78,12 @@ impl ADSRNode { decay: 0.1, sustain: 0.7, release: 0.2, + curve: CurveType::Linear, stage: EnvelopeStage::Idle, level: 0.0, + exp_coeff: 0.0, + exp_base: 0.0, + exp_target: 0.0, gate_was_high: false, inputs, outputs, @@ -89,6 +115,7 @@ impl AudioNode for ADSRNode { PARAM_DECAY => self.decay = value.clamp(0.001, 5.0), PARAM_SUSTAIN => self.sustain = value.clamp(0.0, 1.0), PARAM_RELEASE => self.release = value.clamp(0.001, 5.0), + PARAM_CURVE => self.curve = CurveType::from_f32(value), _ => {} } } @@ -99,6 +126,7 @@ impl AudioNode for ADSRNode { PARAM_DECAY => self.decay, PARAM_SUSTAIN => self.sustain, PARAM_RELEASE => self.release, + PARAM_CURVE => match self.curve { CurveType::Linear => 0.0, CurveType::Exponential => 1.0 }, _ => 0.0, } } @@ -130,9 +158,23 @@ impl AudioNode for ADSRNode { if gate_high && !self.gate_was_high { // Note on: Start attack self.stage = EnvelopeStage::Attack; + if self.curve == CurveType::Exponential { + // For exponential attack, compute coefficient for ~5 time constants + // We overshoot the target slightly so the curve reaches 1.0 naturally + let samples = self.attack * sample_rate_f32; + self.exp_coeff = (-5.0 / samples).exp(); + self.exp_base = self.level; + self.exp_target = 1.0; + } } else if !gate_high && self.gate_was_high { // Note off: Start release self.stage = EnvelopeStage::Release; + if self.curve == CurveType::Exponential { + let samples = self.release * sample_rate_f32; + self.exp_coeff = (-5.0 / samples).exp(); + self.exp_base = self.level; + self.exp_target = 0.0; + } } self.gate_was_high = gate_high; @@ -142,22 +184,51 @@ impl AudioNode for ADSRNode { self.level = 0.0; } EnvelopeStage::Attack => { - // Rise from current level to 1.0 - let increment = 1.0 / (self.attack * sample_rate_f32); - self.level += increment; - if self.level >= 1.0 { - self.level = 1.0; - self.stage = EnvelopeStage::Decay; + match self.curve { + CurveType::Linear => { + let increment = 1.0 / (self.attack * sample_rate_f32); + self.level += increment; + if self.level >= 1.0 { + self.level = 1.0; + self.stage = EnvelopeStage::Decay; + } + } + CurveType::Exponential => { + // Asymptotic approach: level moves toward overshoot target + // Using target of 1.0 + small overshoot so we actually reach 1.0 + let overshoot_target = 1.0 + (1.0 - self.exp_base) * 0.01; + self.level = overshoot_target - (overshoot_target - self.level) * self.exp_coeff; + if self.level >= 1.0 { + self.level = 1.0; + self.stage = EnvelopeStage::Decay; + // Set up decay exponential + let samples = self.decay * sample_rate_f32; + self.exp_coeff = (-5.0 / samples).exp(); + self.exp_base = 1.0; + self.exp_target = self.sustain; + } + } } } EnvelopeStage::Decay => { - // Fall from 1.0 to sustain level let target = self.sustain; - let decrement = (1.0 - target) / (self.decay * sample_rate_f32); - self.level -= decrement; - if self.level <= target { - self.level = target; - self.stage = EnvelopeStage::Sustain; + match self.curve { + CurveType::Linear => { + let decrement = (1.0 - target) / (self.decay * sample_rate_f32); + self.level -= decrement; + if self.level <= target { + self.level = target; + self.stage = EnvelopeStage::Sustain; + } + } + CurveType::Exponential => { + // Exponential decay toward sustain level + self.level = target + (self.level - target) * self.exp_coeff; + if (self.level - target).abs() < 0.001 { + self.level = target; + self.stage = EnvelopeStage::Sustain; + } + } } } EnvelopeStage::Sustain => { @@ -165,12 +236,23 @@ impl AudioNode for ADSRNode { self.level = self.sustain; } EnvelopeStage::Release => { - // Fall from current level to 0.0 - let decrement = self.level / (self.release * sample_rate_f32); - self.level -= decrement; - if self.level <= 0.001 { - self.level = 0.0; - self.stage = EnvelopeStage::Idle; + match self.curve { + CurveType::Linear => { + let decrement = self.level / (self.release * sample_rate_f32); + self.level -= decrement; + if self.level <= 0.001 { + self.level = 0.0; + self.stage = EnvelopeStage::Idle; + } + } + CurveType::Exponential => { + // Exponential decay toward 0 + self.level *= self.exp_coeff; + if self.level <= 0.001 { + self.level = 0.0; + self.stage = EnvelopeStage::Idle; + } + } } } } @@ -183,6 +265,9 @@ impl AudioNode for ADSRNode { fn reset(&mut self) { self.stage = EnvelopeStage::Idle; self.level = 0.0; + self.exp_coeff = 0.0; + self.exp_base = 0.0; + self.exp_target = 0.0; self.gate_was_high = false; } @@ -201,9 +286,13 @@ impl AudioNode for ADSRNode { decay: self.decay, sustain: self.sustain, release: self.release, - stage: EnvelopeStage::Idle, // Reset state - level: 0.0, // Reset level - gate_was_high: false, // Reset gate + curve: self.curve, + stage: EnvelopeStage::Idle, + level: 0.0, + exp_coeff: 0.0, + exp_base: 0.0, + exp_target: 0.0, + gate_was_high: false, inputs: self.inputs.clone(), outputs: self.outputs.clone(), parameters: self.parameters.clone(), diff --git a/daw-backend/src/audio/node_graph/nodes/amp_sim.rs b/daw-backend/src/audio/node_graph/nodes/amp_sim.rs index fddb720..142a556 100644 --- a/daw-backend/src/audio/node_graph/nodes/amp_sim.rs +++ b/daw-backend/src/audio/node_graph/nodes/amp_sim.rs @@ -65,6 +65,16 @@ impl AmpSimNode { Ok(()) } + /// Load a bundled NAM model by name (e.g. "BossSD1"). + pub fn load_bundled_model(&mut self, name: &str) -> Result<(), String> { + let mut model = super::bundled_models::load_bundled_model(name) + .ok_or_else(|| format!("Unknown bundled model: {}", name))??; + model.set_max_buffer_size(1024); + self.model = Some(model); + self.model_path = Some(format!("bundled:{}", name)); + Ok(()) + } + /// Get the loaded model path (for preset serialization). pub fn model_path(&self) -> Option<&str> { self.model_path.as_deref() diff --git a/daw-backend/src/audio/node_graph/nodes/bundled_models.rs b/daw-backend/src/audio/node_graph/nodes/bundled_models.rs new file mode 100644 index 0000000..bb65107 --- /dev/null +++ b/daw-backend/src/audio/node_graph/nodes/bundled_models.rs @@ -0,0 +1,50 @@ +use nam_ffi::NamModel; + +struct BundledModel { + name: &'static str, + filename: &'static str, + data: &'static [u8], +} + +const BUNDLED_MODELS: &[BundledModel] = &[ + BundledModel { + name: "BossSD1", + filename: "BossSD1-WaveNet.nam", + data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/BossSD1-WaveNet.nam"), + }, + BundledModel { + name: "DeluxeReverb", + filename: "DeluxeReverb.nam", + data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/DeluxeReverb.nam"), + }, + BundledModel { + name: "DingwallBass", + filename: "DingwallBass.nam", + data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/DingwallBass.nam"), + }, + BundledModel { + name: "Rhythm", + filename: "Rhythm.nam", + data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/Rhythm.nam"), + }, +]; + +/// Return display names of all bundled NAM models. +pub fn bundled_model_names() -> Vec<&'static str> { + BUNDLED_MODELS.iter().map(|m| m.name).collect() +} + +/// Load a bundled NAM model by display name. +/// Returns `None` if the name isn't found, `Some(Err(...))` on load failure. +pub fn load_bundled_model(name: &str) -> Option> { + eprintln!("[NAM] load_bundled_model: looking up {:?}", name); + let model = BUNDLED_MODELS.iter().find(|m| m.name == name)?; + eprintln!("[NAM] Found bundled model: name={}, filename={}, data_len={}", model.name, model.filename, model.data.len()); + Some( + NamModel::from_bytes(model.filename, model.data) + .map_err(|e| { + eprintln!("[NAM] from_bytes failed for {}: {}", model.filename, e); + e.to_string() + }), + ) +} diff --git a/daw-backend/src/audio/node_graph/nodes/mod.rs b/daw-backend/src/audio/node_graph/nodes/mod.rs index 2e75c6a..1b0825d 100644 --- a/daw-backend/src/audio/node_graph/nodes/mod.rs +++ b/daw-backend/src/audio/node_graph/nodes/mod.rs @@ -1,4 +1,5 @@ mod amp_sim; +pub mod bundled_models; mod adsr; mod arpeggiator; mod audio_input; diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs index bca106e..8f80465 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs @@ -515,6 +515,8 @@ impl NodeTemplateTrait for NodeTemplate { ValueType::float_param(0.7, 0.0, 1.0, "", 2, None), InputParamKind::ConstantOnly, true); graph.add_input_param(node_id, "Release".into(), DataType::CV, ValueType::float_param(0.2, 0.001, 5.0, " s", 3, None), InputParamKind::ConstantOnly, true); + graph.add_input_param(node_id, "Curve".into(), DataType::CV, + ValueType::float_param(0.0, 0.0, 1.0, "", 4, Some(&["Linear", "Exponential"])), InputParamKind::ConstantOnly, true); graph.add_output_param(node_id, "Envelope Out".into(), DataType::CV); } NodeTemplate::Lfo => { 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 c3cd4bc..32a33ef 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -2499,42 +2499,14 @@ impl crate::panes::PaneRenderer for NodeGraphPane { .collect(); self.user_state.available_scripts.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase())); - // Bundled NAM models — discover once and cache + // Bundled NAM models — populate from embedded registry if self.user_state.available_nam_models.is_empty() { - let bundled_dirs = [ - std::env::current_exe().ok() - .and_then(|p| p.parent().map(|d| d.join("models"))) - .unwrap_or_default(), - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../vendor/NeuralAudio/Utils/Models"), - ]; - for dir in &bundled_dirs { - if let Ok(canon) = dir.canonicalize() { - if canon.is_dir() { - for entry in std::fs::read_dir(&canon).into_iter().flatten().flatten() { - let path = entry.path(); - if path.extension().map_or(false, |e| e == "nam") { - let stem = path.file_stem() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_default(); - // Skip LSTM variants (performance alternates, not separate amps) - if stem.ends_with("-LSTM") { - continue; - } - // Clean up display name: remove "-WaveNet" suffix - let name = stem.strip_suffix("-WaveNet") - .unwrap_or(&stem) - .to_string(); - self.user_state.available_nam_models.push(NamModelInfo { - name, - path: path.to_string_lossy().to_string(), - is_bundled: true, - }); - } - } - break; // use first directory found - } - } + for name in daw_backend::audio::node_graph::nodes::bundled_models::bundled_model_names() { + self.user_state.available_nam_models.push(NamModelInfo { + name: name.to_string(), + path: format!("bundled:{}", name), + is_bundled: true, + }); } self.user_state.available_nam_models.sort_by(|a, b| a.name.cmp(&b.name)); } diff --git a/nam-ffi/src/lib.rs b/nam-ffi/src/lib.rs index d6d8366..afc0858 100644 --- a/nam-ffi/src/lib.rs +++ b/nam-ffi/src/lib.rs @@ -1,14 +1,13 @@ use std::path::Path; -#[cfg(windows)] -type WChar = u16; -#[cfg(not(windows))] -type WChar = u32; - #[allow(dead_code)] mod ffi { - use super::WChar; - use std::os::raw::{c_float, c_int}; + use std::os::raw::{c_char, c_float, c_int}; + + #[cfg(windows)] + type PathChar = u16; // wchar_t on Windows + #[cfg(not(windows))] + type PathChar = c_char; // char on Linux/macOS #[repr(C)] pub struct NeuralModel { @@ -16,7 +15,7 @@ mod ffi { } unsafe extern "C" { - pub fn CreateModelFromFile(model_path: *const WChar) -> *mut NeuralModel; + pub fn CreateModelFromFile(model_path: *const PathChar) -> *mut NeuralModel; pub fn DeleteModel(model: *mut NeuralModel); pub fn SetLSTMLoadMode(load_mode: c_int); @@ -58,24 +57,36 @@ pub struct NamModel { } impl NamModel { + /// Load a model from in-memory bytes by writing to a temp file first. + /// The NAM C API only supports file-based loading. + pub fn from_bytes(name: &str, data: &[u8]) -> Result { + let dir = std::env::temp_dir().join("lightningbeam-nam"); + eprintln!("[NAM] from_bytes: name={}, data_len={}, temp_dir={}", name, data.len(), dir.display()); + std::fs::create_dir_all(&dir) + .map_err(|e| NamError::ModelLoadFailed(format!("create_dir_all failed: {}", e)))?; + let file_path = dir.join(name); + std::fs::write(&file_path, data) + .map_err(|e| NamError::ModelLoadFailed(format!("write failed: {}", e)))?; + eprintln!("[NAM] Wrote {} bytes to {}", data.len(), file_path.display()); + Self::from_file(&file_path) + } + pub fn from_file(path: &Path) -> Result { - let wide: Vec = { + let ptr = unsafe { #[cfg(windows)] { use std::os::windows::ffi::OsStrExt; - path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() + let wide: Vec = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); + ffi::CreateModelFromFile(wide.as_ptr()) } #[cfg(not(windows))] { - path.to_string_lossy() - .chars() - .map(|c| c as WChar) - .chain(std::iter::once(0)) - .collect() + use std::ffi::CString; + let c_path = CString::new(path.to_string_lossy().as_bytes()) + .map_err(|_| NamError::ModelLoadFailed(path.display().to_string()))?; + ffi::CreateModelFromFile(c_path.as_ptr()) } }; - - let ptr = unsafe { ffi::CreateModelFromFile(wide.as_ptr()) }; if ptr.is_null() { return Err(NamError::ModelLoadFailed(path.display().to_string())); } From 5c555bf7e1bd4ed7a0ea7647ac1cc001f4be892b Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Mon, 2 Mar 2026 11:58:29 -0500 Subject: [PATCH 4/4] add electric guitar preset --- .../electric-guitar/electric-guitar.json | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 src/assets/instruments/guitar/electric-guitar/electric-guitar.json diff --git a/src/assets/instruments/guitar/electric-guitar/electric-guitar.json b/src/assets/instruments/guitar/electric-guitar/electric-guitar.json new file mode 100644 index 0000000..5748421 --- /dev/null +++ b/src/assets/instruments/guitar/electric-guitar/electric-guitar.json @@ -0,0 +1,204 @@ +{ + "metadata": { + "name": "Electric Guitar", + "description": "Synthesized electric guitar with exponential pluck envelope through a tube amp sim", + "author": "Lightningbeam", + "version": 2, + "tags": ["guitar", "electric", "amp", "pluck"] + }, + "midi_targets": [0], + "output_node": 4, + "nodes": [ + { + "id": 0, + "node_type": "MidiInput", + "name": "MIDI In", + "parameters": {}, + "position": [100.0, 150.0] + }, + { + "id": 1, + "node_type": "VoiceAllocator", + "name": "Voice Allocator", + "parameters": { + "0": 6.0 + }, + "position": [400.0, 150.0], + "template_graph": { + "metadata": { + "name": "Voice Template", + "description": "Per-voice electric guitar synth patch with stacked oscillators and sub octave", + "author": "Lightningbeam", + "version": 3, + "tags": [] + }, + "midi_targets": [0], + "output_node": 11, + "nodes": [ + { + "id": 0, + "node_type": "TemplateInput", + "name": "Template Input", + "parameters": {}, + "position": [-200.0, 0.0] + }, + { + "id": 1, + "node_type": "MidiToCV", + "name": "MIDI→CV", + "parameters": {}, + "position": [100.0, 0.0] + }, + { + "id": 2, + "node_type": "Constant", + "name": "Octave (-1)", + "parameters": { + "0": 1.0 + }, + "position": [100.0, 350.0] + }, + { + "id": 3, + "node_type": "Math", + "name": "Sub Oct V/Oct", + "parameters": { + "0": 1.0 + }, + "position": [300.0, 300.0] + }, + { + "id": 4, + "node_type": "Oscillator", + "name": "Fundamental (Triangle)", + "parameters": { + "0": 220.0, + "1": 0.4, + "2": 3.0 + }, + "position": [500.0, -200.0] + }, + { + "id": 5, + "node_type": "Oscillator", + "name": "Harmonics (Saw)", + "parameters": { + "0": 220.0, + "1": 0.18, + "2": 1.0 + }, + "position": [500.0, 0.0] + }, + { + "id": 6, + "node_type": "Oscillator", + "name": "Sub (-1 oct, Sine)", + "parameters": { + "0": 110.0, + "1": 0.35, + "2": 0.0 + }, + "position": [500.0, 200.0] + }, + { + "id": 7, + "node_type": "Mixer", + "name": "Osc Mix", + "parameters": { + "0": 1.0, + "1": 1.0, + "2": 1.0 + }, + "position": [800.0, 0.0] + }, + { + "id": 8, + "node_type": "ADSR", + "name": "Pluck Env", + "parameters": { + "0": 0.002, + "1": 4.7, + "2": 0.0, + "3": 0.3, + "4": 1.0 + }, + "position": [500.0, 450.0] + }, + { + "id": 9, + "node_type": "Gain", + "name": "VCA", + "parameters": { + "0": 1.0 + }, + "position": [1100.0, 0.0] + }, + { + "id": 10, + "node_type": "Gain", + "name": "Drive", + "parameters": { + "0": 1.4 + }, + "position": [1100.0, 200.0] + }, + { + "id": 11, + "node_type": "TemplateOutput", + "name": "Template Output", + "parameters": {}, + "position": [1400.0, 0.0] + } + ], + "connections": [ + { "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 }, + { "from_node": 1, "from_port": 0, "to_node": 4, "to_port": 0 }, + { "from_node": 1, "from_port": 0, "to_node": 5, "to_port": 0 }, + { "from_node": 1, "from_port": 0, "to_node": 3, "to_port": 0 }, + { "from_node": 2, "from_port": 0, "to_node": 3, "to_port": 1 }, + { "from_node": 3, "from_port": 0, "to_node": 6, "to_port": 0 }, + { "from_node": 1, "from_port": 1, "to_node": 8, "to_port": 0 }, + { "from_node": 4, "from_port": 0, "to_node": 7, "to_port": 0 }, + { "from_node": 5, "from_port": 0, "to_node": 7, "to_port": 1 }, + { "from_node": 6, "from_port": 0, "to_node": 7, "to_port": 2 }, + { "from_node": 7, "from_port": 0, "to_node": 9, "to_port": 0 }, + { "from_node": 8, "from_port": 0, "to_node": 9, "to_port": 1 }, + { "from_node": 9, "from_port": 0, "to_node": 10, "to_port": 0 }, + { "from_node": 10, "from_port": 0, "to_node": 11, "to_port": 0 } + ] + } + }, + { + "id": 2, + "node_type": "AmpSim", + "name": "Tube Amp", + "parameters": {}, + "position": [700.0, 150.0], + "nam_model_path": "bundled:BossSD1" + }, + { + "id": 3, + "node_type": "Reverb", + "name": "Room", + "parameters": { + "0": 0.8, + "1": 0.4, + "2": 0.5 + }, + "position": [1000.0, 150.0] + }, + { + "id": 4, + "node_type": "AudioOutput", + "name": "Out", + "parameters": {}, + "position": [1300.0, 150.0] + } + ], + "connections": [ + { "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 }, + { "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 }, + { "from_node": 2, "from_port": 0, "to_node": 3, "to_port": 0 }, + { "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 0 } + ] +}