use eframe::egui::{Color32, Visuals};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
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,
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 {
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,
];
pub fn names() -> Vec<&'static str> {
Self::ALL.iter().map(|ctor| ctor().name).collect()
}
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)
}
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),
}
}
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),
}
}
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),
}
}
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),
}
}
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),
}
}
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),
edge: Color32::from_rgb(70, 70, 74),
text: Color32::from_rgb(236, 230, 218),
text_dim: Color32::from_rgb(140, 138, 134),
accent: Color32::from_rgb(196, 30, 38),
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),
}
}
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
}
pub fn status_fill(&self, status: &str) -> Color32 {
match status {
"pass" => GREEN,
"fail" => RED,
"stalled" => Color32::from_rgb(230, 150, 60),
"skip" => Color32::from_rgb(150, 150, 160),
"ignored" => Color32::from_rgb(120, 120, 130),
_ => self.node_fill, }
}
pub fn health_color(&self, score: f64) -> Color32 {
let t = (score / 100.0).clamp(0.0, 1.0) as f32;
if t < 0.5 {
lerp(RED, Color32::from_rgb(235, 185, 70), t / 0.5)
} else {
lerp(Color32::from_rgb(235, 185, 70), GREEN, (t - 0.5) / 0.5)
}
}
pub fn info(&self) -> Color32 { self.accent }
pub fn status_color(&self, status: &str) -> Color32 {
let s = status.to_ascii_lowercase();
if s.starts_with("succeeded_dry_run") || s == "dry_run" || s == "warn" {
AMBER
} else if s.starts_with("succeeded") || s == "ok" || s == "pass" || s == "passed" || s == "done" || s == "green" {
GREEN
} else if s.starts_with("failed_bench") {
Color32::from_rgb(222, 130, 60) } else if s.starts_with("fail") || s == "error" || s == "red" || s == "stalled" {
RED
} else if s == "running" || s == "in_progress" || s == "inprogress" {
AMBER
} else {
self.text_dim
}
}
pub fn heat(&self, t: f32) -> Color32 {
lerp(self.edge, self.accent, t.clamp(0.0, 1.0))
}
pub fn zebra(&self, odd: bool) -> Color32 {
if odd {
self.node_fill.linear_multiply(0.5)
} else {
self.bg.linear_multiply(1.0)
}
}
pub fn hover(&self) -> Color32 {
self.accent.linear_multiply(0.07)
}
pub fn selection(&self) -> Color32 {
self.accent
}
pub fn gridline(&self) -> Color32 {
self.edge
}
}
pub const AMBER: Color32 = Color32::from_rgb(228, 170, 70);
pub const GREEN: Color32 = Color32::from_rgb(80, 190, 120);
pub const RED: Color32 = Color32::from_rgb(222, 78, 78);
fn lerp(a: Color32, b: Color32, t: f32) -> Color32 {
let t = t.clamp(0.0, 1.0);
let m = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
Color32::from_rgb(m(a.r(), b.r()), m(a.g(), b.g()), m(a.b(), b.b()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_themes_unique_and_lookupable() {
let names = Theme::names();
assert_eq!(names.len(), Theme::ALL.len());
let mut s = names.clone();
s.sort_unstable();
s.dedup();
assert_eq!(s.len(), names.len(), "theme names unique");
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!(Theme::by_name("nonesuch").is_none());
}
#[test]
fn status_fill_maps_statuses() {
let t = Theme::default();
assert_eq!(t.status_fill("pass"), GREEN);
assert_eq!(t.status_fill("fail"), RED);
assert_ne!(t.status_fill("skip"), GREEN);
assert_eq!(t.status_fill(""), t.node_fill);
}
#[test]
fn status_color_maps_release_verdicts() {
let t = Theme::default();
assert_eq!(t.status_color("succeeded"), GREEN);
assert_eq!(t.status_color("ok"), GREEN);
assert_eq!(t.status_color("pass"), GREEN);
assert_eq!(t.status_color("succeeded_dry_run"), AMBER);
assert_eq!(t.status_color("running"), AMBER);
assert_eq!(t.status_color("failed_test"), RED);
assert_eq!(t.status_color("stalled"), RED);
assert_ne!(t.status_color("failed_bench"), RED);
assert_eq!(t.status_color("whatever"), t.text_dim);
assert_eq!(Theme::sci_fi().status_color("???"), Theme::sci_fi().text_dim);
}
#[test]
fn chrome_helpers_follow_the_palette() {
let a = Theme::default();
let b = Theme::cyberpunk_neon();
assert_ne!(a.gridline(), b.gridline(), "gridline shifts per palette");
assert_ne!(a.selection(), b.selection(), "selection shifts per palette");
assert_ne!(a.heat(1.0), b.heat(1.0), "heat-hot end shifts per palette");
assert_eq!(a.heat(0.0), a.edge);
assert_eq!(a.heat(1.0), a.accent);
}
#[test]
fn health_ramp_runs_red_to_green() {
let t = Theme::default();
assert_eq!(t.health_color(0.0), RED);
assert_eq!(t.health_color(100.0), GREEN);
let mid = t.health_color(50.0);
assert_ne!(mid, RED);
assert_ne!(mid, GREEN);
}
}