update css handling
This commit is contained in:
parent
73ef9e3b9c
commit
ec46e22782
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LayoutAction> = None;
|
||||
let mut clipboard_consumed = false;
|
||||
let mut css_debug_regions: Vec<panes::CssDebugRegion> = 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::<Vec<_>>(), 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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<CssDebugRegion>,
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<uuid::Uuid, &lightningbeam_core::instance_group::InstanceGroup> = 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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<egui::Color32>,
|
||||
pub background: Option<Background>,
|
||||
pub border_color: Option<egui::Color32>,
|
||||
pub border_width: Option<f32>,
|
||||
pub border_radius: Option<f32>,
|
||||
pub text_color: Option<egui::Color32>,
|
||||
pub width: Option<f32>,
|
||||
pub height: Option<f32>,
|
||||
// Add more properties as needed
|
||||
pub padding: Option<f32>,
|
||||
pub margin: Option<f32>,
|
||||
pub font_size: Option<f32>,
|
||||
pub opacity: Option<f32>,
|
||||
}
|
||||
|
||||
impl Style {
|
||||
/// Convenience: get background color if the background is Solid
|
||||
pub fn background_color(&self) -> Option<egui::Color32> {
|
||||
match &self.background {
|
||||
Some(Background::Solid(c)) => Some(*c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge another style on top of this one (other's values take precedence)
|
||||
pub fn merge_over(&mut self, other: &Style) {
|
||||
if other.background.is_some() {
|
||||
self.background = other.background.clone();
|
||||
}
|
||||
if other.border_color.is_some() {
|
||||
self.border_color = other.border_color;
|
||||
}
|
||||
if other.border_width.is_some() {
|
||||
self.border_width = other.border_width;
|
||||
}
|
||||
if other.border_radius.is_some() {
|
||||
self.border_radius = other.border_radius;
|
||||
}
|
||||
if other.text_color.is_some() {
|
||||
self.text_color = other.text_color;
|
||||
}
|
||||
if other.width.is_some() {
|
||||
self.width = other.width;
|
||||
}
|
||||
if other.height.is_some() {
|
||||
self.height = other.height;
|
||||
}
|
||||
if other.padding.is_some() {
|
||||
self.padding = other.padding;
|
||||
}
|
||||
if other.margin.is_some() {
|
||||
self.margin = other.margin;
|
||||
}
|
||||
if other.font_size.is_some() {
|
||||
self.font_size = other.font_size;
|
||||
}
|
||||
if other.opacity.is_some() {
|
||||
self.opacity = other.opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed CSS selector with specificity
|
||||
#[derive(Debug, Clone)]
|
||||
struct ParsedSelector {
|
||||
/// The original selector string parts, e.g. ["#timeline", ".label"]
|
||||
/// For compound selectors like ".layer-header.hover", this is [".layer-header.hover"]
|
||||
parts: Vec<String>,
|
||||
/// Specificity: (id_count, class_count, source_order)
|
||||
specificity: (u32, u32, u32),
|
||||
}
|
||||
|
||||
impl ParsedSelector {
|
||||
fn parse(selector_str: &str, source_order: u32) -> Self {
|
||||
let parts: Vec<String> = selector_str
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let mut id_count = 0u32;
|
||||
let mut class_count = 0u32;
|
||||
|
||||
for part in &parts {
|
||||
// Count IDs and classes within each part (compound selectors)
|
||||
for segment in Self::split_compound(part) {
|
||||
if segment.starts_with('#') {
|
||||
id_count += 1;
|
||||
} else if segment.starts_with('.') {
|
||||
class_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParsedSelector {
|
||||
parts,
|
||||
specificity: (id_count, class_count, source_order),
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a compound selector like ".layer-header.hover" into [".layer-header", ".hover"]
|
||||
fn split_compound(s: &str) -> Vec<&str> {
|
||||
let mut segments = Vec::new();
|
||||
let mut start = 0;
|
||||
let bytes = s.as_bytes();
|
||||
for i in 1..bytes.len() {
|
||||
if bytes[i] == b'.' || bytes[i] == b'#' {
|
||||
segments.push(&s[start..i]);
|
||||
start = i;
|
||||
}
|
||||
}
|
||||
segments.push(&s[start..]);
|
||||
segments
|
||||
}
|
||||
|
||||
/// Check if this selector matches a given context stack.
|
||||
/// Context stack is outermost to innermost, e.g. ["#timeline", ".layer-header", ".selected"]
|
||||
///
|
||||
/// Key rules:
|
||||
/// - The LAST selector part must match the target element. The target is
|
||||
/// identified by the trailing context entries. This prevents
|
||||
/// `#timeline { background }` from bleeding into child elements.
|
||||
/// - For compound selectors like `.piano-white-key.pressed`, the segments
|
||||
/// can be spread across multiple trailing context entries.
|
||||
/// - Ancestor parts (all but last) use descendant matching in order.
|
||||
fn matches(&self, context: &[&str]) -> bool {
|
||||
if self.parts.is_empty() || context.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The last selector part must match among the trailing context entries.
|
||||
// Collect all segments from ALL context entries, then check if the last
|
||||
// selector part's segments are all present. But we also need to ensure
|
||||
// the match is "anchored" to the tail — at least one segment of the last
|
||||
// part must come from the very last context entry.
|
||||
let last_part = &self.parts[self.parts.len() - 1];
|
||||
let last_segments = Self::split_compound(last_part);
|
||||
|
||||
// Gather all class/id segments from all context entries
|
||||
let all_context_segments: Vec<&str> = context.iter()
|
||||
.flat_map(|e| Self::split_compound(e))
|
||||
.collect();
|
||||
|
||||
// All segments of the last selector part must be present somewhere in context
|
||||
if !last_segments.iter().all(|seg| all_context_segments.contains(seg)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// At least one segment of the last selector part must appear in the
|
||||
// LAST context entry (anchors the match to the target element)
|
||||
let last_ctx_segments = Self::split_compound(context[context.len() - 1]);
|
||||
if !last_segments.iter().any(|seg| last_ctx_segments.contains(seg)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For single-part selectors, target matched and there are no ancestors.
|
||||
if self.parts.len() == 1 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For multi-part selectors (e.g., "#timeline .label"), match ancestor parts
|
||||
// in order against context entries from the beginning.
|
||||
// The last selector part's segments consume some context entries at the end;
|
||||
// ancestor parts match against earlier entries.
|
||||
//
|
||||
// Find how far from the end the last part's segments extend.
|
||||
let mut remaining_segments: Vec<&str> = last_segments.to_vec();
|
||||
let mut target_start = context.len();
|
||||
for i in (0..context.len()).rev() {
|
||||
let ctx_segs = Self::split_compound(context[i]);
|
||||
let before_len = remaining_segments.len();
|
||||
remaining_segments.retain(|seg| !ctx_segs.contains(seg));
|
||||
if remaining_segments.len() < before_len {
|
||||
target_start = i;
|
||||
}
|
||||
if remaining_segments.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let ancestor_context = &context[..target_start];
|
||||
let ancestor_parts = &self.parts[..self.parts.len() - 1];
|
||||
|
||||
let mut ctx_idx = 0;
|
||||
for part in ancestor_parts {
|
||||
let part_segments = Self::split_compound(part);
|
||||
let mut found = false;
|
||||
while ctx_idx < ancestor_context.len() {
|
||||
if Self::context_entry_contains_all(ancestor_context[ctx_idx], &part_segments) {
|
||||
found = true;
|
||||
ctx_idx += 1;
|
||||
break;
|
||||
}
|
||||
ctx_idx += 1;
|
||||
}
|
||||
if !found {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if a context entry contains all the given selector segments
|
||||
fn context_entry_contains_all(context_entry: &str, selector_segments: &[&str]) -> bool {
|
||||
let context_segments = Self::split_compound(context_entry);
|
||||
selector_segments.iter().all(|seg| context_segments.contains(seg))
|
||||
}
|
||||
}
|
||||
|
||||
/// A CSS rule: selector + style
|
||||
#[derive(Debug, Clone)]
|
||||
struct Rule {
|
||||
selector: ParsedSelector,
|
||||
style: Style,
|
||||
}
|
||||
|
||||
/// 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<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
light_variables: HashMap<String, String>,
|
||||
dark_variables: HashMap<String, String>,
|
||||
light_styles: HashMap<String, Style>,
|
||||
dark_styles: HashMap<String, Style>,
|
||||
light_rules: Vec<Rule>,
|
||||
dark_rules: Vec<Rule>,
|
||||
current_mode: ThemeMode,
|
||||
/// Cache: (context_key, is_dark) -> Style
|
||||
cache: RefCell<HashMap<(Vec<String>, bool), Style>>,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// Load theme from CSS file
|
||||
/// Load theme from CSS string
|
||||
pub fn from_css(css: &str) -> Result<Self, String> {
|
||||
Self::parse_css(css, 0)
|
||||
}
|
||||
|
||||
/// Parse CSS with a source order offset (for merging multiple stylesheets)
|
||||
fn parse_css(css: &str, source_order_offset: u32) -> Result<Self, String> {
|
||||
let stylesheet = StyleSheet::parse(
|
||||
css,
|
||||
ParserOptions::default(),
|
||||
|
|
@ -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::<Vec<_>>();
|
||||
|
||||
// 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::<Vec<_>>();
|
||||
|
||||
eprintln!(" Found selectors: {:?}", selectors);
|
||||
|
||||
for selector in selectors {
|
||||
let selector = selector.trim();
|
||||
if selector.starts_with('.') || selector.starts_with('#') {
|
||||
// Merge dark and light variables (dark overrides light)
|
||||
let 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<Self, String> {
|
||||
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::<Vec<_>>(), is_dark);
|
||||
|
||||
// Check cache
|
||||
if let Some(cached) = self.cache.borrow().get(&cache_key) {
|
||||
return cached.clone();
|
||||
}
|
||||
|
||||
let rules = if is_dark { &self.dark_rules } else { &self.light_rules };
|
||||
|
||||
// Collect matching rules and sort by specificity
|
||||
let mut matching: Vec<&Rule> = rules
|
||||
.iter()
|
||||
.filter(|r| r.selector.matches(context))
|
||||
.collect();
|
||||
|
||||
// Sort by specificity: (ids, classes, source_order) — ascending so later = higher priority
|
||||
matching.sort_by_key(|r| r.selector.specificity);
|
||||
|
||||
// Merge in specificity order (lower specificity first, higher overrides)
|
||||
let mut result = Style::default();
|
||||
for rule in &matching {
|
||||
result.merge_over(&rule.style);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
self.cache.borrow_mut().insert(cache_key, result.clone());
|
||||
result
|
||||
}
|
||||
|
||||
/// 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::<f32>() {
|
||||
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<String, String>) -> Option<Background> {
|
||||
let value = value.trim();
|
||||
|
||||
// Check for linear-gradient()
|
||||
if value.starts_with("linear-gradient(") {
|
||||
return parse_linear_gradient(value, variables);
|
||||
}
|
||||
|
||||
// Check for url()
|
||||
if value.starts_with("url(") {
|
||||
let inner = value.strip_prefix("url(")?.strip_suffix(')')?;
|
||||
let url = inner.trim().trim_matches('"').trim_matches('\'').to_string();
|
||||
return Some(Background::Image { url });
|
||||
}
|
||||
|
||||
// Fallback to solid color
|
||||
parse_color_value(value, variables).map(Background::Solid)
|
||||
}
|
||||
|
||||
/// Parse a linear-gradient() CSS value
|
||||
fn parse_linear_gradient(value: &str, variables: &HashMap<String, String>) -> Option<Background> {
|
||||
// linear-gradient(180deg, #333, #222)
|
||||
// linear-gradient(180deg, #333 0%, #222 100%)
|
||||
let inner = value.strip_prefix("linear-gradient(")?.strip_suffix(')')?;
|
||||
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
let mut depth = 0;
|
||||
let mut start = 0;
|
||||
for (i, c) in inner.char_indices() {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => depth -= 1,
|
||||
',' if depth == 0 => {
|
||||
parts.push(&inner[start..i]);
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
parts.push(&inner[start..]);
|
||||
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut angle_degrees = 180.0f32; // default: top to bottom
|
||||
let mut color_start_idx = 0;
|
||||
|
||||
// Check if first part is an angle
|
||||
let first = parts[0].trim();
|
||||
if first.ends_with("deg") {
|
||||
if let Ok(angle) = first.strip_suffix("deg").unwrap().trim().parse::<f32>() {
|
||||
angle_degrees = angle;
|
||||
color_start_idx = 1;
|
||||
}
|
||||
} else if first == "to bottom" {
|
||||
angle_degrees = 180.0;
|
||||
color_start_idx = 1;
|
||||
} else if first == "to top" {
|
||||
angle_degrees = 0.0;
|
||||
color_start_idx = 1;
|
||||
} else if first == "to right" {
|
||||
angle_degrees = 90.0;
|
||||
color_start_idx = 1;
|
||||
} else if first == "to left" {
|
||||
angle_degrees = 270.0;
|
||||
color_start_idx = 1;
|
||||
}
|
||||
|
||||
let color_parts = &parts[color_start_idx..];
|
||||
if color_parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut stops = Vec::new();
|
||||
let count = color_parts.len();
|
||||
for (i, part) in color_parts.iter().enumerate() {
|
||||
let part = part.trim();
|
||||
// Check for "color position%" pattern
|
||||
let (color_str, position) = if let Some(pct_idx) = part.rfind('%') {
|
||||
// Find the space before the percentage
|
||||
let before_pct = &part[..pct_idx];
|
||||
if let Some(space_idx) = before_pct.rfind(' ') {
|
||||
let color_str = &part[..space_idx];
|
||||
let pct_str = &part[space_idx + 1..pct_idx];
|
||||
let pct = pct_str.trim().parse::<f32>().unwrap_or(0.0) / 100.0;
|
||||
(color_str.trim(), pct)
|
||||
} else {
|
||||
(part, i as f32 / (count - 1).max(1) as f32)
|
||||
}
|
||||
} else {
|
||||
(part, i as f32 / (count - 1).max(1) as f32)
|
||||
};
|
||||
|
||||
if let Some(color) = parse_color_value(color_str, variables) {
|
||||
stops.push((position, color));
|
||||
}
|
||||
}
|
||||
|
||||
if stops.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Background::LinearGradient { angle_degrees, stops })
|
||||
}
|
||||
|
||||
/// Parse a CSS color value (hex or var())
|
||||
fn parse_color_value(value: &str, variables: &HashMap<String, String>) -> Option<egui::Color32> {
|
||||
let value = value.trim();
|
||||
|
||||
// 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<String, String>) -> Option
|
|||
fn parse_dimension_value(value: &str, variables: &HashMap<String, String>) -> Option<f32> {
|
||||
let value = value.trim();
|
||||
|
||||
// Check if it's a var() reference
|
||||
if let Some(var_name) = parse_var_reference(value) {
|
||||
let resolved = variables.get(&var_name)?;
|
||||
return parse_dimension_string(resolved);
|
||||
}
|
||||
|
||||
// Try to parse as direct dimension
|
||||
parse_dimension_string(value)
|
||||
}
|
||||
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue