facett-core/src/theme.rs
//! **Theming.** A palette the custom-painted components (graph/depgraph/map)
//! read from the egui context, plus matching egui `Visuals` for the standard
//! widgets (pop/table/pipeline). Call [`set_theme`] once on the context and every
//! facet follows. Ships a [`Theme::default`] look, a [`Theme::sci_fi`]
//! neon-on-near-black look, and a switchable family of striking palettes
//! ([`Theme::nordic_aurora`], [`Theme::cyberpunk_neon`], [`Theme::amber_crt`],
//! [`Theme::deep_space`], [`Theme::hugin_noir`]). Enumerate them with
//! [`Theme::ALL`] / [`Theme::by_name`] to build a picker.
use egui::{Color32, Context, Id, Ui, Visuals};
/// The facett palette. All custom-painted colours come from here.
///
/// `glow` is the colour the [`effects`](crate::effects) module blooms with
/// (layered alpha strokes). It defaults to `accent` for the legacy palettes via
/// the constructors below, but a striking theme may pick a distinct glow.
#[derive(Clone, Copy, Debug)]
pub struct Theme {
pub name: &'static str,
pub bg: Color32,
pub node_fill: Color32,
pub node_stroke: Color32,
pub edge: Color32,
pub text: Color32,
pub text_dim: Color32,
pub accent: Color32,
pub point: Color32,
pub panel_bg: Color32,
pub panel_stroke: Color32,
/// The colour [`effects`](crate::effects) blooms/shimmers with.
pub glow: Color32,
}
impl Default for Theme {
fn default() -> Self {
Self {
name: "default",
bg: Color32::from_rgb(18, 18, 24),
node_fill: Color32::from_rgb(30, 30, 40),
node_stroke: Color32::from_gray(120),
edge: Color32::from_gray(90),
text: Color32::from_gray(220),
text_dim: Color32::from_gray(150),
accent: Color32::from_rgb(120, 210, 255),
point: Color32::from_rgb(90, 200, 140),
panel_bg: Color32::from_rgba_unmultiplied(16, 26, 42, 236),
panel_stroke: Color32::from_rgb(80, 130, 180),
glow: Color32::from_rgb(120, 210, 255),
}
}
}
impl Theme {
/// Every named theme, in picker order. Drives a UI/CLI palette switcher.
pub const ALL: &'static [fn() -> Theme] = &[
Theme::default,
Theme::sci_fi,
Theme::nordic_aurora,
Theme::cyberpunk_neon,
Theme::amber_crt,
Theme::deep_space,
Theme::hugin_noir,
];
/// The names of every theme in [`Theme::ALL`] — handy for a CLI `--theme`
/// flag or a dropdown without constructing each palette.
pub fn names() -> Vec<&'static str> {
Self::ALL.iter().map(|ctor| ctor().name).collect()
}
/// Look a theme up by its `name` (case-insensitive; `-`/`_`/spaces are
/// interchangeable, so `"nordic aurora"`, `"nordic-aurora"`, `"Nordic_Aurora"`
/// all match). `None` if unknown.
pub fn by_name(name: &str) -> Option<Theme> {
let norm = |s: &str| s.to_ascii_lowercase().replace(['-', ' '], "_");
let want = norm(name);
Self::ALL.iter().map(|ctor| ctor()).find(|t| norm(t.name) == want)
}
/// Neon-on-near-black — a sci-fi HUD look (cyan/magenta on deep blue-black).
pub fn sci_fi() -> Self {
Self {
name: "sci-fi",
bg: Color32::from_rgb(6, 10, 18),
node_fill: Color32::from_rgb(14, 22, 38),
node_stroke: Color32::from_rgb(0, 200, 220),
edge: Color32::from_rgb(60, 110, 170),
text: Color32::from_rgb(180, 235, 255),
text_dim: Color32::from_rgb(90, 130, 175),
accent: Color32::from_rgb(0, 255, 225),
point: Color32::from_rgb(80, 255, 170),
panel_bg: Color32::from_rgba_unmultiplied(8, 16, 28, 240),
panel_stroke: Color32::from_rgb(0, 200, 220),
glow: Color32::from_rgb(0, 255, 225),
}
}
/// **Nordic aurora** — deep fjord-night blue with green/teal aurora ribbons
/// and a cold violet accent. Calm but luminous; the glow is aurora-green.
pub fn nordic_aurora() -> Self {
Self {
name: "nordic-aurora",
bg: Color32::from_rgb(10, 18, 28),
node_fill: Color32::from_rgb(18, 32, 44),
node_stroke: Color32::from_rgb(80, 220, 180),
edge: Color32::from_rgb(50, 110, 120),
text: Color32::from_rgb(214, 240, 234),
text_dim: Color32::from_rgb(120, 165, 165),
accent: Color32::from_rgb(120, 230, 200),
point: Color32::from_rgb(150, 130, 240),
panel_bg: Color32::from_rgba_unmultiplied(12, 24, 34, 238),
panel_stroke: Color32::from_rgb(70, 180, 160),
glow: Color32::from_rgb(90, 255, 190),
}
}
/// **Cyberpunk neon** — hot magenta + electric cyan on bruised purple-black.
/// Maximum night-city pop; the glow is magenta.
pub fn cyberpunk_neon() -> Self {
Self {
name: "cyberpunk-neon",
bg: Color32::from_rgb(14, 8, 22),
node_fill: Color32::from_rgb(26, 14, 38),
node_stroke: Color32::from_rgb(0, 240, 255),
edge: Color32::from_rgb(120, 40, 140),
text: Color32::from_rgb(245, 220, 255),
text_dim: Color32::from_rgb(160, 110, 180),
accent: Color32::from_rgb(255, 50, 200),
point: Color32::from_rgb(0, 240, 255),
panel_bg: Color32::from_rgba_unmultiplied(20, 10, 30, 240),
panel_stroke: Color32::from_rgb(255, 50, 200),
glow: Color32::from_rgb(255, 60, 210),
}
}
/// **Amber CRT** — a warm phosphor terminal: amber-on-black with a dim
/// scan-line brown. Cosy retro; the glow is amber.
pub fn amber_crt() -> Self {
Self {
name: "amber-crt",
bg: Color32::from_rgb(14, 10, 4),
node_fill: Color32::from_rgb(28, 20, 8),
node_stroke: Color32::from_rgb(255, 176, 64),
edge: Color32::from_rgb(120, 80, 24),
text: Color32::from_rgb(255, 200, 110),
text_dim: Color32::from_rgb(170, 120, 50),
accent: Color32::from_rgb(255, 176, 64),
point: Color32::from_rgb(255, 214, 130),
panel_bg: Color32::from_rgba_unmultiplied(20, 14, 6, 240),
panel_stroke: Color32::from_rgb(200, 130, 40),
glow: Color32::from_rgb(255, 190, 90),
}
}
/// **Deep space** — near-black indigo void, starlight-white text, and a
/// nebula magenta/blue accent. Vast and quiet; the glow is nebula-violet.
pub fn deep_space() -> Self {
Self {
name: "deep-space",
bg: Color32::from_rgb(6, 7, 16),
node_fill: Color32::from_rgb(16, 18, 34),
node_stroke: Color32::from_rgb(130, 150, 255),
edge: Color32::from_rgb(54, 60, 110),
text: Color32::from_rgb(226, 230, 248),
text_dim: Color32::from_rgb(128, 138, 180),
accent: Color32::from_rgb(150, 130, 255),
point: Color32::from_rgb(110, 200, 255),
panel_bg: Color32::from_rgba_unmultiplied(10, 12, 26, 240),
panel_stroke: Color32::from_rgb(90, 100, 190),
glow: Color32::from_rgb(170, 140, 255),
}
}
/// **Hugin noir** — the raven's own palette: raven-black ground, bone-white
/// text, a single blood-red accent. Stark and editorial; the glow is blood.
pub fn hugin_noir() -> Self {
Self {
name: "hugin-noir",
bg: Color32::from_rgb(10, 10, 11),
node_fill: Color32::from_rgb(22, 22, 24),
node_stroke: Color32::from_rgb(232, 226, 214), // bone white
edge: Color32::from_rgb(70, 70, 74),
text: Color32::from_rgb(236, 230, 218), // bone white
text_dim: Color32::from_rgb(140, 138, 134),
accent: Color32::from_rgb(196, 30, 38), // blood
point: Color32::from_rgb(196, 30, 38),
panel_bg: Color32::from_rgba_unmultiplied(16, 16, 17, 242),
panel_stroke: Color32::from_rgb(150, 24, 30),
glow: Color32::from_rgb(220, 40, 48),
}
}
/// egui `Visuals` matching the palette — themes the standard widgets
/// (buttons, progress bars, grids) to fit.
pub fn visuals(&self) -> Visuals {
let mut v = Visuals::dark();
v.override_text_color = Some(self.text);
v.hyperlink_color = self.accent;
v.panel_fill = self.bg;
v.window_fill = self.panel_bg;
v.extreme_bg_color = self.bg;
v.faint_bg_color = self.node_fill;
v.selection.bg_fill = self.accent.linear_multiply(0.35);
v.selection.stroke.color = self.accent;
v.widgets.noninteractive.bg_fill = self.node_fill;
v.widgets.inactive.bg_fill = self.node_fill;
v.widgets.hovered.bg_stroke.color = self.accent;
v.widgets.active.bg_stroke.color = self.accent;
v
}
}
const THEME_ID: &str = "facett_theme";
/// Store `theme` on the context **and** apply its egui `Visuals`. Call once per
/// frame (cheap) or whenever the theme changes; every facet picks it up.
pub fn set_theme(ctx: &Context, theme: Theme) {
ctx.set_visuals(theme.visuals());
ctx.data_mut(|d| d.insert_temp(Id::new(THEME_ID), theme));
}
/// Publish only the flat palette onto the context — the colour source every
/// custom-painted facet reads via [`theme`] — **without** overwriting the egui
/// `Visuals`. Used by the rich [`crate::look::Theme::apply`], which installs its
/// own complete `Style` (so the legacy `set_theme`'s `override_text_color` is not
/// re-imposed, per §27).
pub fn publish_palette(ctx: &Context, theme: Theme) {
ctx.data_mut(|d| d.insert_temp(Id::new(THEME_ID), theme));
}
/// The theme stored on the ui's context (or [`Theme::default`] if none).
///
/// **Readable-data side effect (LAW 6):** every call records the palette it hands
/// out into the [`probe`] thread-local, so a headless test can read back *which
/// palette a component actually consumed* during its paint — proof the component
/// read the active theme rather than a private default. `theme(ui)` is the single
/// universal consumption point for every custom-painted facet, so instrumenting it
/// once gives uniform, per-component palette-consumption evidence.
pub fn theme(ui: &Ui) -> Theme {
let t = ui.data(|d| d.get_temp::<Theme>(Id::new(THEME_ID))).unwrap_or_default();
probe::record(t.name);
t
}
/// **Palette-consumption probe** — the machine-readable witness that a component
/// consumed the active [`Theme`]. [`theme`] records the palette name it returns
/// here; a headless test [`reset`](probe::reset)s the probe, renders one component,
/// then reads [`painted`](probe::painted) to assert the component painted with the
/// palette it was given (not `default`). Thread-local so parallel test cells don't
/// interfere; cheap (a `Cell`), and a no-op cost for production renders that never
/// read it.
pub mod probe {
use std::cell::Cell;
thread_local! {
static PAINTED: Cell<Option<&'static str>> = const { Cell::new(None) };
}
/// Record the palette a [`theme`](super::theme) call handed out. Called from
/// inside `theme(ui)`; the *last* palette read this paint wins (a facet may read
/// the theme several times — they're all the active palette).
pub fn record(name: &'static str) {
PAINTED.with(|p| p.set(Some(name)));
}
/// Clear the probe before rendering a component, so [`painted`] reflects only
/// that component's paint.
pub fn reset() {
PAINTED.with(|p| p.set(None));
}
/// The palette name the most recent [`theme`](super::theme) call handed out on
/// this thread, or `None` if nothing has read the theme since [`reset`]. A
/// component that paints with the active palette returns `Some(palette_name)`.
pub fn painted() -> Option<&'static str> {
PAINTED.with(|p| p.get())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_and_read_theme_round_trips() {
let ctx = Context::default();
set_theme(&ctx, Theme::sci_fi());
// read inside a ui pass
let mut got = "";
ctx.run_ui(egui::RawInput::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| {
got = theme(ui).name;
});
}).textures_delta.clear();
assert_eq!(got, "sci-fi");
}
#[test]
fn all_themes_have_unique_names_and_are_lookupable() {
let names = Theme::names();
assert_eq!(names.len(), Theme::ALL.len());
// names are unique
let mut sorted = names.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), names.len(), "theme names must be unique");
// the legacy looks are still present
assert!(names.contains(&"default"));
assert!(names.contains(&"sci-fi"));
// every theme round-trips through by_name, including fuzzy forms
for n in &names {
assert_eq!(Theme::by_name(n).map(|t| t.name), Some(*n));
}
assert_eq!(Theme::by_name("Nordic Aurora").map(|t| t.name), Some("nordic-aurora"));
assert_eq!(Theme::by_name("AMBER_CRT").map(|t| t.name), Some("amber-crt"));
assert!(Theme::by_name("nonesuch").is_none());
}
}