Unify colors via theme CSS variables; theme egui visuals

Add Theme::var(name, ctx) (CSS custom-property getter) and Theme::apply_to_egui(ctx)
which maps the palette onto egui's global Visuals so standard widgets share the
theme colors. Add a mobile::palette::Palette built from the theme each frame and
replace the duplicated per-file C_* color constants across all mobile modules
(omni, topbar, stack, inspector, transport, intent, shell) with it. Add --scrim
and --accent-* category vars to styles.css. Apply the theme visuals every frame in
update(). Mobile and the main UI now share one CSS-variable-driven palette that
responds to light/dark/user themes.
This commit is contained in:
Skyler Lehmkuhl 2026-07-01 07:03:07 -04:00
parent f13a127c9d
commit 943ff7c15f
11 changed files with 255 additions and 154 deletions

View File

@ -99,6 +99,14 @@
--font-size-small: 11px;
--font-size-default: 13px;
--pane-border-width: 1px;
/* Modal backdrop (translucent) + intent/category accents (shared by light & dark) */
--scrim: #10141ab0;
--accent-coral: #e8826b;
--accent-cyan: #54c3e8;
--accent-amber: #f4a340;
--accent-pink: #c75b8a;
--accent-violet: #8a6ec0;
}
/* ============================================

View File

@ -5343,6 +5343,10 @@ impl eframe::App for EditorApp {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let _frame_start = std::time::Instant::now();
// Apply the theme's palette to egui's global visuals so the standard widgets (dialogs, pane
// chrome, text) share the same colors as the mobile UI and respond to light/dark/user themes.
self.theme.apply_to_egui(ctx);
// === Raster fault-in (Phase 3 paging) ===
// The canvas records raster keyframe ids whose `raw_pixels` weren't resident
// (it can't mutate the document while rendering). Drain that sink here, BEFORE

View File

@ -6,16 +6,10 @@ use eframe::egui;
use lightningbeam_core::pane::PaneType;
use lightningbeam_core::selection::FocusSelection;
use super::{surface, MobileState, MOBILE_NS};
use super::{surface, MobileState, Palette, MOBILE_NS};
use crate::panes::{NodePath, SharedPaneState};
use crate::RenderContext;
const C_SHEET: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37);
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x95, 0xa1);
const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3);
const C_CYAN: egui::Color32 = egui::Color32::from_rgb(0x54, 0xc3, 0xe8);
const GRAB_H: f32 = 16.0;
const HEAD_H: f32 = 30.0;
const CHIP_H: f32 = 30.0;
@ -104,14 +98,15 @@ pub fn render(
region_h: f32,
rc: &mut RenderContext,
state: &mut MobileState,
pal: &Palette,
) {
// Sheet background with rounded top.
ui.painter().rect_filled(
rect,
egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 },
C_SHEET,
pal.surface_alt,
);
ui.painter().hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, C_LINE));
ui.painter().hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, pal.line));
// Grab handle — drag to resize the sheet.
let grab = egui::Rect::from_min_max(
@ -120,7 +115,7 @@ pub fn render(
);
let gresp = ui.interact(grab, ui.id().with("mobile_inspector_grab"), egui::Sense::drag());
let pill = egui::Rect::from_center_size(grab.center(), egui::vec2(34.0, 4.0));
ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { C_CYAN } else { C_LINE });
ui.painter().rect_filled(pill, 2.0, if gresp.hovered() || gresp.dragged() { pal.accent } else { pal.line });
if gresp.dragged() && region_h > 1.0 {
state.inspector_frac = (state.inspector_frac - gresp.drag_delta().y / region_h).clamp(0.2, 0.85);
}
@ -139,7 +134,7 @@ pub fn render(
egui::Align2::LEFT_CENTER,
title(&rc.shared),
egui::FontId::proportional(13.0),
C_BRIGHT,
pal.text,
);
let close = egui::Rect::from_min_size(egui::pos2(head.right() - 36.0, head.top()), egui::vec2(36.0, HEAD_H));
let cresp = ui.interact(close, ui.id().with("mobile_inspector_close"), egui::Sense::click());
@ -148,7 +143,7 @@ pub fn render(
egui::Align2::CENTER_CENTER,
super::icons::X,
super::icons::font(16.0),
if cresp.hovered() { C_BRIGHT } else { C_DIM },
if cresp.hovered() { pal.text } else { pal.text_dim },
);
if cresp.clicked() {
rc.shared.selection.clear();
@ -162,14 +157,14 @@ pub fn render(
let galley = ui.painter().layout_no_wrap(
chip.label.to_string(),
egui::FontId::proportional(11.0),
C_BRIGHT,
pal.text,
);
let w = galley.size().x + 18.0;
let chip_rect = egui::Rect::from_min_size(egui::pos2(cx, chip_y + 4.0), egui::vec2(w, CHIP_H - 8.0));
let resp = ui.interact(chip_rect, ui.id().with(("mobile_inspector_chip", i)), egui::Sense::click());
ui.painter().rect_filled(chip_rect, 11.0, if resp.hovered() { C_LINE } else { C_SHEET });
ui.painter().rect_stroke(chip_rect, 11.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside);
ui.painter().galley(chip_rect.center() - galley.size() * 0.5, galley, C_BRIGHT);
ui.painter().rect_filled(chip_rect, 11.0, if resp.hovered() { pal.line } else { pal.surface_alt });
ui.painter().rect_stroke(chip_rect, 11.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
ui.painter().galley(chip_rect.center() - galley.size() * 0.5, galley, pal.text);
if resp.clicked() {
let (top, count) = chip.window;
state.window_top = top;

View File

@ -7,16 +7,9 @@
use eframe::egui;
use super::icons;
use super::{icons, Palette};
use crate::EditorApp;
const C_DEVICE: egui::Color32 = egui::Color32::from_rgb(0x14, 0x16, 0x1b);
const C_CARD: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c);
const C_CARD_HOT: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37);
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3);
const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x95, 0xa1);
/// One intent card.
struct Intent {
label: &'static str,
@ -28,12 +21,8 @@ struct Intent {
window: (usize, usize),
}
fn intents() -> [Intent; 6] {
let coral = egui::Color32::from_rgb(0xe8, 0x82, 0x6b);
let cyan = egui::Color32::from_rgb(0x54, 0xc3, 0xe8);
let amber = egui::Color32::from_rgb(0xf4, 0xa3, 0x40);
let pink = egui::Color32::from_rgb(0xc7, 0x5b, 0x8a);
let violet = egui::Color32::from_rgb(0x6a, 0x46, 0x90);
fn intents(pal: &Palette) -> [Intent; 6] {
let [coral, cyan, amber, pink, violet] = pal.accents;
[
// Stage indices (see super::STACK): Stage=2, Timeline=3, PianoRoll=4, VirtualPiano=5.
Intent { label: "Draw", icon: icons::BRUSH, accent: coral, focus: 5, window: (2, 1) },
@ -41,13 +30,14 @@ fn intents() -> [Intent; 6] {
Intent { label: "Compose", icon: icons::MUSIC, accent: amber, focus: 2, window: (3, 3) },
Intent { label: "Record", icon: icons::MIC, accent: pink, focus: 2, window: (3, 3) },
Intent { label: "Edit video", icon: icons::CLAPPERBOARD, accent: violet, focus: 1, window: (2, 2) },
Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: C_DIM, focus: 0, window: (2, 2) },
Intent { label: "Blank", icon: icons::SQUARE_DASHED, accent: pal.text_dim, focus: 0, window: (2, 2) },
]
}
pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
let pal = Palette::from_theme(&app.theme, ctx);
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(C_DEVICE))
.frame(egui::Frame::NONE.fill(pal.bg))
.show(ctx, |ui| {
let rect = ui.available_rect_before_wrap();
let margin = 16.0;
@ -60,7 +50,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
egui::Align2::LEFT_TOP,
"Start something",
egui::FontId::proportional(22.0),
C_BRIGHT,
pal.text,
);
// Vertical budget: ~2/3 for the intent grid, ~1/3 for the recent list.
@ -72,7 +62,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
// 2×3 grid of intent cards filling the grid budget.
let col_w = (right - left - gap) / 2.0;
let card_h = (grid_h - 2.0 * gap) / 3.0;
for (i, intent) in intents().iter().enumerate() {
for (i, intent) in intents(&pal).iter().enumerate() {
let col = (i % 2) as f32;
let row = (i / 2) as f32;
let cx = left + col * (col_w + gap);
@ -81,8 +71,8 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
let resp = ui.interact(card, ui.id().with(("mobile_intent", i)), egui::Sense::click());
let p = ui.painter();
p.rect_filled(card, 12.0, if resp.hovered() { C_CARD_HOT } else { C_CARD });
p.rect_stroke(card, 12.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside);
p.rect_filled(card, 12.0, if resp.hovered() { pal.surface_alt } else { pal.surface });
p.rect_stroke(card, 12.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
p.text(
egui::pos2(card.center().x, card.top() + card.height() * 0.40),
egui::Align2::CENTER_CENTER,
@ -95,7 +85,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
egui::Align2::CENTER_CENTER,
intent.label,
egui::FontId::proportional(15.0),
C_BRIGHT,
pal.text,
);
if resp.clicked() {
@ -113,7 +103,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
egui::Align2::LEFT_TOP,
"Recent",
egui::FontId::proportional(13.0),
C_DIM,
pal.text_dim,
);
let list_top = recent_top + 22.0;
let recents = app.config.get_recent_files();
@ -123,7 +113,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
egui::Align2::LEFT_TOP,
"No recent projects",
egui::FontId::proportional(12.0),
C_DIM,
pal.text_dim,
);
} else {
let row_h = 38.0;
@ -138,14 +128,14 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
let resp =
ui.interact(row_rect, ui.id().with(("mobile_recent", j)), egui::Sense::click());
let p = ui.painter();
p.rect_filled(row_rect, 8.0, if resp.hovered() { C_CARD_HOT } else { C_CARD });
p.rect_stroke(row_rect, 8.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside);
p.rect_filled(row_rect, 8.0, if resp.hovered() { pal.surface_alt } else { pal.surface });
p.rect_stroke(row_rect, 8.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
p.text(
egui::pos2(row_rect.left() + 12.0, row_rect.center().y),
egui::Align2::LEFT_CENTER,
icons::FOLDER_OPEN,
icons::font(15.0),
C_DIM,
pal.text_dim,
);
let name = path
.file_name()
@ -156,7 +146,7 @@ pub fn render(app: &mut EditorApp, ctx: &egui::Context) {
egui::Align2::LEFT_CENTER,
&name,
egui::FontId::proportional(13.0),
C_BRIGHT,
pal.text,
);
if resp.clicked() {
chosen = Some(path.clone());

View File

@ -21,11 +21,14 @@ pub mod icons;
mod inspector;
pub mod intent;
mod omni;
mod palette;
mod stack;
mod surface;
mod topbar;
mod transport;
use palette::Palette;
/// Reserved sentinel namespace for mobile pane-instance paths. Desktop layout paths are built
/// from small child indices, so prefixing with `usize::MAX` guarantees mobile slots never alias a
/// real layout path in the shared `pane_instances` map.
@ -203,9 +206,10 @@ pub fn render_mobile_shell(
rc: &mut RenderContext,
state: &mut MobileState,
) {
// Background (wireframe device color; bands paint over most of it).
ui.painter()
.rect_filled(available_rect, 0.0, egui::Color32::from_rgb(0x14, 0x16, 0x1b));
let pal = Palette::from_theme(rc.shared.theme, ui.ctx());
// Background (device color; bands paint over most of it).
ui.painter().rect_filled(available_rect, 0.0, pal.bg);
let topbar_rect = egui::Rect::from_min_max(
available_rect.min,
@ -243,19 +247,19 @@ pub fn render_mobile_shell(
region
};
stack::render(ui, stack_rect, rc, state);
stack::render(ui, stack_rect, rc, state, &pal);
if inspector_shown {
let sheet_rect = egui::Rect::from_min_max(egui::pos2(region.left(), sheet_top), region.max);
inspector::render(ui, sheet_rect, region.height(), rc, state);
inspector::render(ui, sheet_rect, region.height(), rc, state, &pal);
}
// Transport floor: drawn last = always on top, the persistent spine.
transport::render(ui, transport_rect, &mut rc.shared);
transport::render(ui, transport_rect, &mut rc.shared, &pal);
// Omnibutton FAB (radial tool menu) — drawn above the stack region, on top of everything else.
omni::render(ui, region, rc, state);
omni::render(ui, region, rc, state, &pal);
// Top bar (filename + ⌕ palette + ⋯ commands). Its menus overlay the whole shell, so it's last.
topbar::render(ui, topbar_rect, available_rect, rc, state);
topbar::render(ui, topbar_rect, available_rect, rc, state, &pal);
}

View File

@ -9,18 +9,10 @@ use lightningbeam_core::layer::{AnyLayer, LayerType};
use lightningbeam_core::selection::FocusSelection;
use lightningbeam_core::tool::Tool;
use super::{icons, MobileState};
use super::{icons, MobileState, Palette};
use crate::menu::MenuAction;
use crate::RenderContext;
const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40);
const C_DARK: egui::Color32 = egui::Color32::from_rgb(0x1b, 0x13, 0x0a);
const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c);
const C_PANEL2: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37);
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3);
const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x95, 0xa1);
const FAB_R: f32 = 26.0;
const IR: f32 = 20.0;
const PRIMARY: [Tool; 14] = [
@ -106,7 +98,7 @@ fn close_all(state: &mut MobileState) {
state.omni_create_open = false;
}
pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) {
pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) {
let ctx_tools: Vec<Tool> = Tool::for_layer_type(active_layer_type(rc)).to_vec();
let primary: Vec<Tool> = PRIMARY
.iter()
@ -140,7 +132,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta
enabled: true,
})
.collect();
let (close, clicked) = draw_grid(ui, region, "Tools", &cells);
let (close, clicked) = draw_grid(ui, region, "Tools", &cells, pal);
if let Some(i) = clicked {
*rc.shared.selected_tool = ctx_tools[i];
close_all(state);
@ -159,7 +151,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta
enabled: it.3,
})
.collect();
let (close, clicked) = draw_grid(ui, region, "New", &cells);
let (close, clicked) = draw_grid(ui, region, "New", &cells, pal);
if let Some(i) = clicked {
rc.shared.pending_menu_actions.push(items[i].2);
close_all(state);
@ -169,8 +161,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta
} else if state.omni_open {
// Radial: scrim, then tool petals + special petals (more / new).
let scrim = ui.interact(region, ui.id().with("mobile_omni_scrim"), egui::Sense::click());
ui.painter()
.rect_filled(region, 0.0, egui::Color32::from_rgba_premultiplied(8, 10, 14, 140));
ui.painter().rect_filled(region, 0.0, pal.scrim);
if scrim.clicked() {
state.omni_open = false;
}
@ -194,7 +185,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta
let t = primary[k];
let resp = ui.interact(rect, ui.id().with(("mobile_omni_tool", k)), egui::Sense::click());
let selected = *rc.shared.selected_tool == t;
petal(ui, c, if selected { C_AMBER } else { petal_bg(&resp) }, tool_icon(t), if selected { C_DARK } else { C_BRIGHT });
petal(ui, c, if selected { pal.accent } else { petal_bg(&resp, pal) }, tool_icon(t), if selected { pal.on_accent } else { pal.text }, pal);
if resp.clicked() {
*rc.shared.selected_tool = t;
state.omni_open = false;
@ -203,16 +194,16 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta
match specials[k - primary.len()] {
Special::More => {
let resp = ui.interact(rect, ui.id().with("mobile_omni_more"), egui::Sense::click());
petal(ui, c, petal_bg(&resp), icons::MENU, C_BRIGHT);
petal(ui, c, petal_bg(&resp, pal), icons::MENU, pal.text, pal);
if resp.clicked() {
state.omni_grid_open = true;
}
}
Special::New => {
let resp = ui.interact(rect, ui.id().with("mobile_omni_new"), egui::Sense::click());
petal(ui, c, C_AMBER, icons::PLUS, C_DARK);
petal(ui, c, pal.accent, icons::PLUS, pal.on_accent, pal);
if resp.hovered() {
ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.5, C_BRIGHT));
ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.5, pal.text));
}
if resp.clicked() {
state.omni_create_open = true;
@ -226,7 +217,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta
// The FAB itself (on top). Closed → current tool icon; open → ✕.
let fab_rect = egui::Rect::from_center_size(fab_center, egui::vec2(FAB_R * 2.0, FAB_R * 2.0));
let fresp = ui.interact(fab_rect, ui.id().with("mobile_omni_fab"), egui::Sense::click());
ui.painter().circle_filled(fab_center, FAB_R, C_AMBER);
ui.painter().circle_filled(fab_center, FAB_R, pal.accent);
let open = state.omni_open || state.omni_grid_open || state.omni_create_open;
let glyph = if open {
icons::X
@ -236,7 +227,7 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta
icons::PLUS // off-Stage: a create button
};
ui.painter()
.text(fab_center, egui::Align2::CENTER_CENTER, glyph, icons::font(22.0), C_DARK);
.text(fab_center, egui::Align2::CENTER_CENTER, glyph, icons::font(22.0), pal.on_accent);
if fresp.clicked() {
if open {
close_all(state);
@ -248,17 +239,17 @@ pub fn render(ui: &mut egui::Ui, region: egui::Rect, rc: &mut RenderContext, sta
}
}
fn petal_bg(resp: &egui::Response) -> egui::Color32 {
fn petal_bg(resp: &egui::Response, pal: &Palette) -> egui::Color32 {
if resp.hovered() {
C_LINE
pal.line
} else {
C_PANEL2
pal.surface_alt
}
}
fn petal(ui: &egui::Ui, c: egui::Pos2, bg: egui::Color32, glyph: &str, fg: egui::Color32) {
fn petal(ui: &egui::Ui, c: egui::Pos2, bg: egui::Color32, glyph: &str, fg: egui::Color32, pal: &Palette) {
ui.painter().circle_filled(c, IR, bg);
ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.0, C_LINE));
ui.painter().circle_stroke(c, IR, egui::Stroke::new(1.0, pal.line));
ui.painter().text(c, egui::Align2::CENTER_CENTER, glyph, icons::font(18.0), fg);
}
@ -270,20 +261,19 @@ struct Cell {
}
/// A modal grid sheet of icon+label cells. Returns (backdrop-tapped, Some(clicked-enabled-index)).
fn draw_grid(ui: &mut egui::Ui, region: egui::Rect, title: &str, cells: &[Cell]) -> (bool, Option<usize>) {
fn draw_grid(ui: &mut egui::Ui, region: egui::Rect, title: &str, cells: &[Cell], pal: &Palette) -> (bool, Option<usize>) {
let scrim = ui.interact(region, ui.id().with(("mobile_omni_gridscrim", title)), egui::Sense::click());
ui.painter()
.rect_filled(region, 0.0, egui::Color32::from_rgba_premultiplied(8, 10, 14, 170));
ui.painter().rect_filled(region, 0.0, pal.scrim);
let panel = region.shrink2(egui::vec2(16.0, 40.0));
ui.painter().rect_filled(panel, 14.0, C_PANEL);
ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside);
ui.painter().rect_filled(panel, 14.0, pal.surface);
ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
ui.painter().text(
egui::pos2(panel.left() + 16.0, panel.top() + 18.0),
egui::Align2::LEFT_CENTER,
title,
egui::FontId::proportional(14.0),
C_BRIGHT,
pal.text,
);
let cols = 4usize;
@ -310,20 +300,20 @@ fn draw_grid(ui: &mut egui::Ui, region: egui::Rect, title: &str, cells: &[Cell])
ui.interact(r, ui.id().with(("mobile_omni_cell_off", title, i)), egui::Sense::hover())
};
let bg = if cell.selected {
C_AMBER
pal.accent
} else if cell.enabled && resp.hovered() {
C_PANEL2
pal.surface_alt
} else {
C_PANEL
pal.surface
};
ui.painter().rect_filled(r, 10.0, bg);
ui.painter().rect_stroke(r, 10.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside);
ui.painter().rect_stroke(r, 10.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
let fg = if cell.selected {
C_DARK
pal.on_accent
} else if cell.enabled {
C_BRIGHT
pal.text
} else {
C_LINE
pal.line
};
ui.painter().text(
egui::pos2(r.center().x, r.top() + r.height() * 0.38),
@ -337,7 +327,7 @@ fn draw_grid(ui: &mut egui::Ui, region: egui::Rect, title: &str, cells: &[Cell])
egui::Align2::CENTER_CENTER,
cell.label,
egui::FontId::proportional(9.5),
if cell.selected { C_DARK } else if cell.enabled { C_DIM } else { C_LINE },
if cell.selected { pal.on_accent } else if cell.enabled { pal.text_dim } else { pal.line },
);
if cell.enabled && resp.clicked() {
clicked = Some(i);

View File

@ -0,0 +1,59 @@
//! The mobile UI's semantic color palette, sourced from the shared theme CSS variables so it
//! matches the main UI and responds to light/dark/user themes. Built once per frame from the active
//! [`Theme`](crate::theme::Theme) and passed (by `Copy`) into the mobile render functions, replacing
//! the per-file hardcoded color constants.
use eframe::egui::{Color32, Context};
use crate::theme::Theme;
#[derive(Clone, Copy)]
pub struct Palette {
/// App/device background behind everything.
pub bg: Color32,
/// Panels, cards, bands, sheets.
pub surface: Color32,
/// Raised / hovered surface.
pub surface_alt: Color32,
/// Band headers, the top bar.
pub header: Color32,
/// Borders and dividers.
pub line: Color32,
/// Primary text / icons.
pub text: Color32,
/// Secondary (dim) text / icons.
pub text_dim: Color32,
/// Selection / active accent.
pub accent: Color32,
/// Text / icons drawn on top of `accent`.
pub on_accent: Color32,
/// Modal backdrop (translucent).
pub scrim: Color32,
/// Decorative category accents (intent picker, chips): coral, cyan, amber, pink, violet.
pub accents: [Color32; 5],
}
impl Palette {
pub fn from_theme(theme: &Theme, ctx: &Context) -> Self {
let c = |name: &str, fb: Color32| theme.var(name, ctx).unwrap_or(fb);
Self {
bg: c("bg-app", Color32::from_rgb(0x2a, 0x2a, 0x2a)),
surface: c("bg-panel", Color32::from_rgb(0x22, 0x22, 0x22)),
surface_alt: c("bg-surface-raised", Color32::from_rgb(0x3f, 0x3f, 0x3f)),
header: c("bg-header", Color32::from_rgb(0x35, 0x35, 0x35)),
line: c("border-default", Color32::from_rgb(0x44, 0x44, 0x44)),
text: c("text-primary", Color32::from_rgb(0xf6, 0xf6, 0xf6)),
text_dim: c("text-secondary", Color32::from_rgb(0xaa, 0xaa, 0xaa)),
accent: c("accent", Color32::from_rgb(0x39, 0x6c, 0xd8)),
on_accent: c("text-on-accent", Color32::WHITE),
scrim: c("scrim", Color32::from_rgba_unmultiplied(0x10, 0x14, 0x1a, 0xb0)),
accents: [
c("accent-coral", Color32::from_rgb(0xe8, 0x82, 0x6b)),
c("accent-cyan", Color32::from_rgb(0x54, 0xc3, 0xe8)),
c("accent-amber", Color32::from_rgb(0xf4, 0xa3, 0x40)),
c("accent-pink", Color32::from_rgb(0xc7, 0x5b, 0x8a)),
c("accent-violet", Color32::from_rgb(0x8a, 0x6e, 0xc0)),
],
}
}
}

View File

@ -18,7 +18,7 @@
use eframe::egui;
use super::{icons, slot_path, LayoutAnim, MobileState, StackDrag, StackPane, STACK};
use super::{icons, slot_path, LayoutAnim, MobileState, Palette, StackDrag, StackPane, STACK};
use crate::RenderContext;
const N: usize = STACK.len();
@ -34,12 +34,6 @@ const BTN_W: f32 = 44.0;
/// Max pixels of drag to complete a window transition (so you don't drag half the screen).
const TRIGGER_MAX: f32 = 150.0;
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40);
const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x7c, 0x86, 0x93);
const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3);
const C_HEADER: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c);
/// A draggable boundary of the stack.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Handle {
@ -345,7 +339,7 @@ fn interp_layout(
/// The interaction (drag) target for a band's boundary is its full-width header bar. Bands are
/// laid out in the area above a reserved bottom footer (the BottomEdge handle). Band 0's header is
/// the TopEdge handle; band i's header (i≥1) is the divider above it; the footer is the BottomEdge.
pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) {
pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) {
let top = state.window_top;
let count = state.window_count;
@ -437,24 +431,24 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state
brect.left_top(),
egui::pos2(brect.right(), brect.top() + HEADER_H.min(brect.height())),
);
draw_header(ui, hr, STACK[*slot], state.show_instruments, fullscreen);
draw_header(ui, hr, STACK[*slot], state.show_instruments, fullscreen, pal);
}
draw_footer(ui, footer_rect, top + count >= N);
draw_footer(ui, footer_rect, top + count >= N, pal);
// 3) Interactions on the resting header/footer rects (added last → they win the press).
handle_interactions(ui, &rest_bands, content_area, footer_rect, trigger, now, state);
}
fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: bool, fullscreen: bool) {
fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: bool, fullscreen: bool, pal: &Palette) {
let p = ui.painter();
// Rounded top corners so the header reads as a tab atop the pane; square at the bottom where
// it meets the pane content.
p.rect_filled(
hr,
egui::CornerRadius { nw: HEADER_RADIUS, ne: HEADER_RADIUS, sw: 0, se: 0 },
C_HEADER,
pal.header,
);
p.hline(hr.x_range(), hr.bottom(), egui::Stroke::new(1.0, C_LINE));
p.hline(hr.x_range(), hr.bottom(), egui::Stroke::new(1.0, pal.line));
let cy = hr.center().y;
// Grip glyph on the left (Lucide).
p.text(
@ -462,14 +456,14 @@ fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: b
egui::Align2::CENTER_CENTER,
icons::GRIP_HORIZONTAL,
icons::font(16.0),
C_DIM,
pal.text_dim,
);
p.text(
egui::pos2(hr.left() + 32.0, cy),
egui::Align2::LEFT_CENTER,
sp.label(show_instruments),
egui::FontId::proportional(15.0),
C_BRIGHT,
pal.text,
);
// Right-side buttons: fullscreen / restore rightmost, Node/Instrument toggle just left of it.
p.text(
@ -477,7 +471,7 @@ fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: b
egui::Align2::CENTER_CENTER,
if fullscreen { icons::MINIMIZE } else { icons::MAXIMIZE },
icons::font(17.0),
C_DIM,
pal.text_dim,
);
if sp == StackPane::NodeInstrument {
p.text(
@ -485,16 +479,16 @@ fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: b
egui::Align2::CENTER_CENTER,
icons::ARROW_LEFT_RIGHT,
icons::font(17.0),
C_AMBER,
pal.accent,
);
}
}
fn draw_footer(ui: &egui::Ui, fr: egui::Rect, at_end: bool) {
fn draw_footer(ui: &egui::Ui, fr: egui::Rect, at_end: bool, pal: &Palette) {
let p = ui.painter();
p.rect_filled(fr, 0.0, C_HEADER);
p.hline(fr.x_range(), fr.top(), egui::Stroke::new(1.0, C_LINE));
let col = if at_end { C_LINE } else { C_DIM };
p.rect_filled(fr, 0.0, pal.header);
p.hline(fr.x_range(), fr.top(), egui::Stroke::new(1.0, pal.line));
let col = if at_end { pal.line } else { pal.text_dim };
let cy = fr.center().y;
let galley = p.layout_no_wrap(
"pull up for more".to_string(),

View File

@ -5,15 +5,10 @@
use eframe::egui;
use super::{icons, MobileState};
use super::{icons, MobileState, Palette};
use crate::menu::{MenuAction, MenuDef, MenuItemDef};
use crate::RenderContext;
const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c);
const C_PANEL2: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37);
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3);
const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x8b, 0x95, 0xa1);
const BTN: f32 = 40.0;
/// Curated overflow (⋯) commands.
@ -56,9 +51,10 @@ pub fn render(
full: egui::Rect,
rc: &mut RenderContext,
state: &mut MobileState,
pal: &Palette,
) {
ui.painter().rect_filled(bar, 0.0, C_PANEL);
ui.painter().hline(bar.x_range(), bar.bottom(), egui::Stroke::new(1.0, C_LINE));
ui.painter().rect_filled(bar, 0.0, pal.header);
ui.painter().hline(bar.x_range(), bar.bottom(), egui::Stroke::new(1.0, pal.line));
// Filename (or app name when unsaved).
let name = rc
@ -73,7 +69,7 @@ pub fn render(
egui::Align2::LEFT_CENTER,
name,
egui::FontId::proportional(14.0),
C_BRIGHT,
pal.text,
);
// Right cluster: ⌕ (palette) then ⋯ (overflow).
@ -82,7 +78,7 @@ pub fn render(
let sresp = ui.interact(palette_rect, ui.id().with("mobile_topbar_search"), egui::Sense::click());
ui.painter().text(palette_rect.center(), egui::Align2::CENTER_CENTER, icons::SEARCH, icons::font(17.0),
if sresp.hovered() || state.palette_open { C_BRIGHT } else { C_DIM });
if sresp.hovered() || state.palette_open { pal.text } else { pal.text_dim });
if sresp.clicked() {
state.palette_open = !state.palette_open;
state.overflow_open = false;
@ -91,48 +87,48 @@ pub fn render(
let oresp = ui.interact(overflow_rect, ui.id().with("mobile_topbar_overflow"), egui::Sense::click());
ui.painter().text(overflow_rect.center(), egui::Align2::CENTER_CENTER, icons::ELLIPSIS, icons::font(18.0),
if oresp.hovered() || state.overflow_open { C_BRIGHT } else { C_DIM });
if oresp.hovered() || state.overflow_open { pal.text } else { pal.text_dim });
if oresp.clicked() {
state.overflow_open = !state.overflow_open;
state.palette_open = false;
}
if state.overflow_open {
render_overflow(ui, full, rc, state);
render_overflow(ui, full, rc, state, pal);
} else if state.palette_open {
render_palette(ui, full, rc, state);
render_palette(ui, full, rc, state, pal);
}
}
/// Common modal scrim + panel. Returns (backdrop-tapped, panel inner rect).
fn open_panel(ui: &mut egui::Ui, full: egui::Rect, id: &str) -> (bool, egui::Rect) {
fn open_panel(ui: &mut egui::Ui, full: egui::Rect, id: &str, pal: &Palette) -> (bool, egui::Rect) {
let scrim = ui.interact(full, ui.id().with(("mobile_topbar_scrim", id)), egui::Sense::click());
ui.painter().rect_filled(full, 0.0, egui::Color32::from_rgba_premultiplied(8, 10, 14, 170));
ui.painter().rect_filled(full, 0.0, pal.scrim);
let panel = egui::Rect::from_min_max(
egui::pos2(full.left() + 16.0, full.top() + 44.0),
egui::pos2(full.right() - 16.0, full.bottom() - 60.0),
);
ui.painter().rect_filled(panel, 14.0, C_PANEL);
ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, C_LINE), egui::StrokeKind::Inside);
ui.painter().rect_filled(panel, 14.0, pal.surface);
ui.painter().rect_stroke(panel, 14.0, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
(scrim.clicked(), panel)
}
fn command_button(ui: &mut egui::Ui, r: egui::Rect, label: &str, key: (&str, usize)) -> bool {
fn command_button(ui: &mut egui::Ui, r: egui::Rect, label: &str, key: (&str, usize), pal: &Palette) -> bool {
let resp = ui.interact(r, ui.id().with(("mobile_cmd", key.0, key.1)), egui::Sense::click());
ui.painter().rect_filled(r, 8.0, if resp.hovered() { C_PANEL2 } else { C_PANEL });
ui.painter().hline(r.x_range(), r.bottom(), egui::Stroke::new(1.0, C_LINE));
ui.painter().rect_filled(r, 8.0, if resp.hovered() { pal.surface_alt } else { pal.surface });
ui.painter().hline(r.x_range(), r.bottom(), egui::Stroke::new(1.0, pal.line));
ui.painter().text(
egui::pos2(r.left() + 14.0, r.center().y),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(13.0),
C_BRIGHT,
pal.text,
);
resp.clicked()
}
fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) {
let (backdrop, panel) = open_panel(ui, full, "overflow");
fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) {
let (backdrop, panel) = open_panel(ui, full, "overflow", pal);
let mut close = backdrop;
let items = overflow_items();
let row_h = 44.0;
@ -145,7 +141,7 @@ fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext,
if r.bottom() > inner.bottom() {
break;
}
if command_button(ui, r, label, ("of", i)) {
if command_button(ui, r, label, ("of", i), pal) {
rc.shared.pending_menu_actions.push(*action);
close = true;
}
@ -155,8 +151,8 @@ fn render_overflow(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext,
}
}
fn render_palette(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) {
let (backdrop, panel) = open_panel(ui, full, "palette");
fn render_palette(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) {
let (backdrop, panel) = open_panel(ui, full, "palette", pal);
let mut close = backdrop;
let inner = panel.shrink(8.0);
@ -184,7 +180,7 @@ fn render_palette(ui: &mut egui::Ui, full: egui::Rect, rc: &mut RenderContext, s
if r.bottom() > inner.bottom() {
break;
}
if command_button(ui, r, label, ("pal", i)) {
if command_button(ui, r, label, ("pal", i), pal) {
rc.shared.pending_menu_actions.push(*action);
close = true;
}

View File

@ -3,19 +3,13 @@
use eframe::egui;
use super::icons;
use super::{icons, Palette};
use crate::panes::SharedPaneState;
const C_PANEL: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c);
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40);
const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3);
const C_DARK: egui::Color32 = egui::Color32::from_rgb(0x1b, 0x13, 0x0a);
pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) {
pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState, pal: &Palette) {
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, C_PANEL);
painter.hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, C_LINE));
painter.rect_filled(rect, 0.0, pal.surface);
painter.hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, pal.line));
let cy = rect.center().y;
@ -24,14 +18,14 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState)
let btn_center = egui::pos2(rect.left() + 20.0 + btn_r, cy);
let btn_rect = egui::Rect::from_center_size(btn_center, egui::vec2(btn_r * 2.0, btn_r * 2.0));
let btn_resp = ui.interact(btn_rect, ui.id().with("mobile_transport_play"), egui::Sense::click());
painter.circle_filled(btn_center, btn_r, C_AMBER);
painter.circle_filled(btn_center, btn_r, pal.accent);
let glyph = if *shared.is_playing { icons::PAUSE } else { icons::PLAY };
painter.text(
btn_center,
egui::Align2::CENTER_CENTER,
glyph,
icons::font(16.0),
C_DARK,
pal.on_accent,
);
if btn_resp.clicked() {
*shared.is_playing = !*shared.is_playing;
@ -55,7 +49,7 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState)
egui::Align2::LEFT_CENTER,
&tc,
egui::FontId::monospace(13.0),
C_BRIGHT,
pal.text,
);
let tc_width = 78.0;
@ -71,18 +65,18 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState)
ui.id().with("mobile_transport_scrub"),
egui::Sense::click_and_drag(),
);
painter.rect_filled(scrub_rect, 3.0, C_LINE);
painter.rect_filled(scrub_rect, 3.0, pal.line);
let frac = (*shared.playback_time / duration).clamp(0.0, 1.0) as f32;
let filled = egui::Rect::from_min_max(
scrub_rect.min,
egui::pos2(scrub_rect.left() + scrub_rect.width() * frac, scrub_rect.bottom()),
);
painter.rect_filled(filled, 3.0, C_AMBER.gamma_multiply(0.5));
painter.rect_filled(filled, 3.0, pal.accent.gamma_multiply(0.5));
let head_x = scrub_rect.left() + scrub_rect.width() * frac;
painter.vline(
head_x,
(scrub_rect.top() - 4.0)..=(scrub_rect.bottom() + 4.0),
egui::Stroke::new(2.0, C_AMBER),
egui::Stroke::new(2.0, pal.accent),
);
if (scrub_resp.dragged() || scrub_resp.clicked()) && scrub_rect.width() > 0.0 {

View File

@ -519,6 +519,73 @@ impl Theme {
self.resolve(context, ctx).border_color.unwrap_or(fallback)
}
/// Look up a CSS custom property (variable) as a color, respecting the active light/dark mode.
/// `name` is given without the leading `--` (e.g. `"bg-surface"`). Dark overrides light.
pub fn var(&self, name: &str, ctx: &egui::Context) -> Option<egui::Color32> {
let mut vars = self.light_variables.clone();
if self.is_dark(ctx) {
vars.extend(self.dark_variables.clone());
}
let raw = vars.get(name)?.clone();
parse_color_value(&raw, &vars)
}
/// Apply the theme's core palette variables to egui's global `Visuals`, so the standard egui
/// widgets (buttons, text fields, dialogs, pane chrome) share the same colors as the mobile UI.
/// Cheap enough to call every frame; respects the active light/dark mode.
pub fn apply_to_egui(&self, ctx: &egui::Context) {
let is_dark = self.is_dark(ctx);
let v = |name: &str, fb: egui::Color32| self.var(name, ctx).unwrap_or(fb);
let g = |n: u8| egui::Color32::from_gray(n);
let text = v("text-primary", if is_dark { g(230) } else { g(20) });
let bg_app = v("bg-app", if is_dark { g(42) } else { g(224) });
let panel = v("bg-panel", bg_app);
let surface = v("bg-surface", panel);
let raised = v("bg-surface-raised", surface);
let sunken = v("bg-surface-sunken", bg_app);
let border = v("border-default", if is_dark { g(68) } else { g(153) });
let accent = v("accent", egui::Color32::from_rgb(0x39, 0x6c, 0xd8));
let on_accent = v("text-on-accent", egui::Color32::WHITE);
let stroke = |c: egui::Color32| egui::Stroke::new(1.0, c);
let mut visuals = if is_dark { egui::Visuals::dark() } else { egui::Visuals::light() };
visuals.panel_fill = panel;
visuals.window_fill = panel;
visuals.window_stroke = stroke(border);
visuals.extreme_bg_color = sunken;
visuals.faint_bg_color = surface;
visuals.override_text_color = Some(text);
visuals.hyperlink_color = accent;
let w = &mut visuals.widgets;
w.noninteractive.bg_fill = panel;
w.noninteractive.weak_bg_fill = panel;
w.noninteractive.bg_stroke = stroke(border);
w.noninteractive.fg_stroke = stroke(text);
w.inactive.bg_fill = surface;
w.inactive.weak_bg_fill = surface;
w.inactive.bg_stroke = stroke(border);
w.inactive.fg_stroke = stroke(text);
w.hovered.bg_fill = raised;
w.hovered.weak_bg_fill = raised;
w.hovered.bg_stroke = stroke(border);
w.hovered.fg_stroke = stroke(text);
w.active.bg_fill = accent;
w.active.weak_bg_fill = accent;
w.active.bg_stroke = stroke(accent);
w.active.fg_stroke = stroke(on_accent);
w.open.bg_fill = surface;
w.open.weak_bg_fill = surface;
w.open.bg_stroke = stroke(border);
w.open.fg_stroke = stroke(text);
visuals.selection.bg_fill = accent.linear_multiply(0.4);
visuals.selection.stroke = stroke(on_accent);
ctx.set_visuals(visuals);
}
/// Convenience: resolve and extract a dimension with fallback
#[allow(dead_code)]
pub fn dimension(&self, context: &[&str], ctx: &egui::Context, property: &str, fallback: f32) -> f32 {