1use egui::{Color32, Context, Id, Ui, Visuals};
11
12#[derive(Clone, Copy, Debug)]
18pub struct Theme {
19 pub name: &'static str,
20 pub bg: Color32,
21 pub node_fill: Color32,
22 pub node_stroke: Color32,
23 pub edge: Color32,
24 pub text: Color32,
25 pub text_dim: Color32,
26 pub accent: Color32,
27 pub point: Color32,
28 pub panel_bg: Color32,
29 pub panel_stroke: Color32,
30 pub glow: Color32,
32}
33
34impl Default for Theme {
35 fn default() -> Self {
36 Self {
37 name: "default",
38 bg: Color32::from_rgb(18, 18, 24),
39 node_fill: Color32::from_rgb(30, 30, 40),
40 node_stroke: Color32::from_gray(120),
41 edge: Color32::from_gray(90),
42 text: Color32::from_gray(220),
43 text_dim: Color32::from_gray(150),
44 accent: Color32::from_rgb(120, 210, 255),
45 point: Color32::from_rgb(90, 200, 140),
46 panel_bg: Color32::from_rgba_unmultiplied(16, 26, 42, 236),
47 panel_stroke: Color32::from_rgb(80, 130, 180),
48 glow: Color32::from_rgb(120, 210, 255),
49 }
50 }
51}
52
53impl Theme {
54 pub const ALL: &'static [fn() -> Theme] = &[
56 Theme::default,
57 Theme::sci_fi,
58 Theme::nordic_aurora,
59 Theme::cyberpunk_neon,
60 Theme::amber_crt,
61 Theme::deep_space,
62 Theme::hugin_noir,
63 ];
64
65 pub fn names() -> Vec<&'static str> {
68 Self::ALL.iter().map(|ctor| ctor().name).collect()
69 }
70
71 pub fn by_name(name: &str) -> Option<Theme> {
75 let norm = |s: &str| s.to_ascii_lowercase().replace(['-', ' '], "_");
76 let want = norm(name);
77 Self::ALL.iter().map(|ctor| ctor()).find(|t| norm(t.name) == want)
78 }
79
80 pub fn sci_fi() -> Self {
82 Self {
83 name: "sci-fi",
84 bg: Color32::from_rgb(6, 10, 18),
85 node_fill: Color32::from_rgb(14, 22, 38),
86 node_stroke: Color32::from_rgb(0, 200, 220),
87 edge: Color32::from_rgb(60, 110, 170),
88 text: Color32::from_rgb(180, 235, 255),
89 text_dim: Color32::from_rgb(90, 130, 175),
90 accent: Color32::from_rgb(0, 255, 225),
91 point: Color32::from_rgb(80, 255, 170),
92 panel_bg: Color32::from_rgba_unmultiplied(8, 16, 28, 240),
93 panel_stroke: Color32::from_rgb(0, 200, 220),
94 glow: Color32::from_rgb(0, 255, 225),
95 }
96 }
97
98 pub fn nordic_aurora() -> Self {
101 Self {
102 name: "nordic-aurora",
103 bg: Color32::from_rgb(10, 18, 28),
104 node_fill: Color32::from_rgb(18, 32, 44),
105 node_stroke: Color32::from_rgb(80, 220, 180),
106 edge: Color32::from_rgb(50, 110, 120),
107 text: Color32::from_rgb(214, 240, 234),
108 text_dim: Color32::from_rgb(120, 165, 165),
109 accent: Color32::from_rgb(120, 230, 200),
110 point: Color32::from_rgb(150, 130, 240),
111 panel_bg: Color32::from_rgba_unmultiplied(12, 24, 34, 238),
112 panel_stroke: Color32::from_rgb(70, 180, 160),
113 glow: Color32::from_rgb(90, 255, 190),
114 }
115 }
116
117 pub fn cyberpunk_neon() -> Self {
120 Self {
121 name: "cyberpunk-neon",
122 bg: Color32::from_rgb(14, 8, 22),
123 node_fill: Color32::from_rgb(26, 14, 38),
124 node_stroke: Color32::from_rgb(0, 240, 255),
125 edge: Color32::from_rgb(120, 40, 140),
126 text: Color32::from_rgb(245, 220, 255),
127 text_dim: Color32::from_rgb(160, 110, 180),
128 accent: Color32::from_rgb(255, 50, 200),
129 point: Color32::from_rgb(0, 240, 255),
130 panel_bg: Color32::from_rgba_unmultiplied(20, 10, 30, 240),
131 panel_stroke: Color32::from_rgb(255, 50, 200),
132 glow: Color32::from_rgb(255, 60, 210),
133 }
134 }
135
136 pub fn amber_crt() -> Self {
139 Self {
140 name: "amber-crt",
141 bg: Color32::from_rgb(14, 10, 4),
142 node_fill: Color32::from_rgb(28, 20, 8),
143 node_stroke: Color32::from_rgb(255, 176, 64),
144 edge: Color32::from_rgb(120, 80, 24),
145 text: Color32::from_rgb(255, 200, 110),
146 text_dim: Color32::from_rgb(170, 120, 50),
147 accent: Color32::from_rgb(255, 176, 64),
148 point: Color32::from_rgb(255, 214, 130),
149 panel_bg: Color32::from_rgba_unmultiplied(20, 14, 6, 240),
150 panel_stroke: Color32::from_rgb(200, 130, 40),
151 glow: Color32::from_rgb(255, 190, 90),
152 }
153 }
154
155 pub fn deep_space() -> Self {
158 Self {
159 name: "deep-space",
160 bg: Color32::from_rgb(6, 7, 16),
161 node_fill: Color32::from_rgb(16, 18, 34),
162 node_stroke: Color32::from_rgb(130, 150, 255),
163 edge: Color32::from_rgb(54, 60, 110),
164 text: Color32::from_rgb(226, 230, 248),
165 text_dim: Color32::from_rgb(128, 138, 180),
166 accent: Color32::from_rgb(150, 130, 255),
167 point: Color32::from_rgb(110, 200, 255),
168 panel_bg: Color32::from_rgba_unmultiplied(10, 12, 26, 240),
169 panel_stroke: Color32::from_rgb(90, 100, 190),
170 glow: Color32::from_rgb(170, 140, 255),
171 }
172 }
173
174 pub fn hugin_noir() -> Self {
177 Self {
178 name: "hugin-noir",
179 bg: Color32::from_rgb(10, 10, 11),
180 node_fill: Color32::from_rgb(22, 22, 24),
181 node_stroke: Color32::from_rgb(232, 226, 214), edge: Color32::from_rgb(70, 70, 74),
183 text: Color32::from_rgb(236, 230, 218), text_dim: Color32::from_rgb(140, 138, 134),
185 accent: Color32::from_rgb(196, 30, 38), point: Color32::from_rgb(196, 30, 38),
187 panel_bg: Color32::from_rgba_unmultiplied(16, 16, 17, 242),
188 panel_stroke: Color32::from_rgb(150, 24, 30),
189 glow: Color32::from_rgb(220, 40, 48),
190 }
191 }
192
193 pub fn visuals(&self) -> Visuals {
196 let mut v = Visuals::dark();
197 v.override_text_color = Some(self.text);
198 v.hyperlink_color = self.accent;
199 v.panel_fill = self.bg;
200 v.window_fill = self.panel_bg;
201 v.extreme_bg_color = self.bg;
202 v.faint_bg_color = self.node_fill;
203 v.selection.bg_fill = self.accent.linear_multiply(0.35);
204 v.selection.stroke.color = self.accent;
205 v.widgets.noninteractive.bg_fill = self.node_fill;
206 v.widgets.inactive.bg_fill = self.node_fill;
207 v.widgets.hovered.bg_stroke.color = self.accent;
208 v.widgets.active.bg_stroke.color = self.accent;
209 v
210 }
211}
212
213const THEME_ID: &str = "facett_theme";
214
215pub fn set_theme(ctx: &Context, theme: Theme) {
218 ctx.set_visuals(theme.visuals());
219 ctx.data_mut(|d| d.insert_temp(Id::new(THEME_ID), theme));
220}
221
222pub fn theme(ui: &Ui) -> Theme {
231 let t = ui.data(|d| d.get_temp::<Theme>(Id::new(THEME_ID))).unwrap_or_default();
232 probe::record(t.name);
233 t
234}
235
236pub mod probe {
244 use std::cell::Cell;
245
246 thread_local! {
247 static PAINTED: Cell<Option<&'static str>> = const { Cell::new(None) };
248 }
249
250 pub fn record(name: &'static str) {
254 PAINTED.with(|p| p.set(Some(name)));
255 }
256
257 pub fn reset() {
260 PAINTED.with(|p| p.set(None));
261 }
262
263 pub fn painted() -> Option<&'static str> {
267 PAINTED.with(|p| p.get())
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn set_and_read_theme_round_trips() {
277 let ctx = Context::default();
278 set_theme(&ctx, Theme::sci_fi());
279 let mut got = "";
281 let _ = ctx.run(egui::RawInput::default(), |ctx| {
282 egui::CentralPanel::default().show(ctx, |ui| {
283 got = theme(ui).name;
284 });
285 });
286 assert_eq!(got, "sci-fi");
287 }
288
289 #[test]
290 fn all_themes_have_unique_names_and_are_lookupable() {
291 let names = Theme::names();
292 assert_eq!(names.len(), Theme::ALL.len());
293 let mut sorted = names.clone();
295 sorted.sort_unstable();
296 sorted.dedup();
297 assert_eq!(sorted.len(), names.len(), "theme names must be unique");
298 assert!(names.contains(&"default"));
300 assert!(names.contains(&"sci-fi"));
301 for n in &names {
303 assert_eq!(Theme::by_name(n).map(|t| t.name), Some(*n));
304 }
305 assert_eq!(Theme::by_name("Nordic Aurora").map(|t| t.name), Some("nordic-aurora"));
306 assert_eq!(Theme::by_name("AMBER_CRT").map(|t| t.name), Some("amber-crt"));
307 assert!(Theme::by_name("nonesuch").is_none());
308 }
309}