Skip to main content

zeus_theme/
lib.rs

1use egui::{Color32, Context, Frame, Id, LayerId, Order, Rect, Style};
2use std::sync::{Arc, RwLock};
3
4const PANIC_MSG: &str = "Custom theme not supported, use Theme::from_custom() instead";
5
6pub mod editor;
7pub mod hsla;
8pub mod themes;
9pub mod utils;
10pub mod visuals;
11pub mod window;
12
13pub use editor::ThemeEditor;
14use themes::*;
15pub use visuals::*;
16
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ThemeKind {
20   Dark,
21
22   /// Inspired by the https://github.com/tokyo-night/tokyo-night-vscode-theme
23   /// 
24   /// With some slight palette adjustments
25   TokyoNight,
26
27   /// WIP
28   // Light,
29
30   /// A custom theme
31   Custom,
32}
33
34impl ThemeKind {
35   pub fn to_str(&self) -> &str {
36      match self {
37         ThemeKind::Dark => "Dark",
38         ThemeKind::TokyoNight => "Tokyo Night",
39         // ThemeKind::Light => "Light",
40         ThemeKind::Custom => "Custom",
41      }
42   }
43
44   pub fn to_vec() -> Vec<Self> {
45      vec![Self::Dark, Self::TokyoNight]
46   }
47}
48
49#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
50#[derive(Debug, Clone)]
51pub struct Theme {
52   /// True if the theme is dark
53   pub dark_mode: bool,
54   #[cfg_attr(feature = "serde", serde(skip))]
55   pub overlay_manager: OverlayManager,
56
57   /// True if a tint is recomended to be applied to images
58   /// to soften the contrast between the image and the background
59   ///
60   /// This is usually true for themes with very dark background
61   pub image_tint_recommended: bool,
62   pub kind: ThemeKind,
63   pub style: Style,
64   pub colors: ThemeColors,
65   pub text_sizes: TextSizes,
66   /// Used for [Frame] not native windows
67   pub window_frame: Frame,
68   /// Base container frame for major UI sections.
69   pub frame1: Frame,
70   /// Frame for nested elements, like individual list items.
71   pub frame2: Frame,
72
73   pub frame1_visuals: FrameVisuals,
74   pub frame2_visuals: FrameVisuals,
75}
76
77impl PartialEq for Theme {
78   fn eq(&self, other: &Self) -> bool {
79      self.dark_mode == other.dark_mode
80         && self.kind == other.kind
81         && self.style == other.style
82         && self.colors == other.colors
83         && self.text_sizes == other.text_sizes
84         && self.window_frame == other.window_frame
85         && self.frame1 == other.frame1
86         && self.frame2 == other.frame2
87         && self.frame1_visuals == other.frame1_visuals
88         && self.frame2_visuals == other.frame2_visuals
89   }
90}
91
92impl Eq for Theme {}
93
94impl Theme {
95   /// Panics if the kind is [ThemeKind::Custom]
96   ///
97   /// Use [Theme::from_custom()] instead
98   pub fn new(kind: ThemeKind) -> Self {
99      let theme = match kind {
100         ThemeKind::Dark => dark::theme(),
101         ThemeKind::TokyoNight => tokyo_night::theme(),
102         // ThemeKind::Light => light::theme(),
103         ThemeKind::Custom => panic!("{}", PANIC_MSG),
104      };
105
106      theme
107   }
108
109   /// Keep derived frame colors in sync with a palette change.
110   ///
111   /// Only updates a color if it still matches the previous palette slot
112   /// (e.g. `frame1.fill == old.widget_bg`). Custom colors and structural
113   /// frame properties (margins, rounding, shadow offsets) are left alone.
114   pub fn remap_derived_frames(&mut self, old: &ThemeColors) {
115      let new = self.colors;
116      if !frame_palette_changed(old, &new) {
117         return;
118      }
119
120      remap_frame(
121         &mut self.window_frame,
122         old.title_bar,
123         new.title_bar,
124         old.border,
125         new.border,
126      );
127      remap_frame(
128         &mut self.frame1,
129         old.widget_bg,
130         new.widget_bg,
131         old.border,
132         new.border,
133      );
134      remap_frame(
135         &mut self.frame2,
136         old.bg,
137         new.bg,
138         old.border,
139         new.border,
140      );
141      remap_frame_visuals(
142         &mut self.frame1_visuals,
143         old.hover,
144         new.hover,
145         old.widget_bg,
146         new.widget_bg,
147         old.highlight,
148         new.highlight,
149      );
150      remap_frame_visuals(
151         &mut self.frame2_visuals,
152         old.hover,
153         new.hover,
154         old.bg,
155         new.bg,
156         old.highlight,
157         new.highlight,
158      );
159   }
160
161   pub fn button_visuals(&self) -> ButtonVisuals {
162      match self.kind {
163         ThemeKind::Dark => self.colors.button_visuals,
164         ThemeKind::TokyoNight => self.colors.button_visuals,
165         // ThemeKind::Light => self.colors.button_visuals,
166         ThemeKind::Custom => panic!("{}", PANIC_MSG),
167      }
168   }
169
170   pub fn label_visuals(&self) -> LabelVisuals {
171      match self.kind {
172         ThemeKind::Dark => self.colors.label_visuals,
173         ThemeKind::TokyoNight => self.colors.label_visuals,
174         // ThemeKind::Light => self.colors.label_visuals,
175         ThemeKind::Custom => panic!("{}", PANIC_MSG),
176      }
177   }
178
179   pub fn combo_box_visuals(&self) -> ComboBoxVisuals {
180      match self.kind {
181         ThemeKind::Dark => self.colors.combo_box_visuals,
182         ThemeKind::TokyoNight => self.colors.combo_box_visuals,
183         // ThemeKind::Light => self.colors.combo_box_visuals,
184         ThemeKind::Custom => panic!("{}", PANIC_MSG),
185      }
186   }
187
188   pub fn text_edit_visuals(&self) -> TextEditVisuals {
189      match self.kind {
190         ThemeKind::Dark => self.colors.text_edit_visuals,
191         ThemeKind::TokyoNight => self.colors.text_edit_visuals,
192         // ThemeKind::Light => self.colors.text_edit_visuals,
193         ThemeKind::Custom => panic!("{}", PANIC_MSG),
194      }
195   }
196
197   /// Install this theme into the given egui context
198   pub fn install(self, ctx: &Context) {
199      let unchanged =
200         ctx.data(|d| d.get_temp::<Theme>(Self::storage_id()).is_some_and(|t| t == self));
201
202      if unchanged {
203         return;
204      }
205
206      ctx.set_global_style(self.style.clone());
207      ctx.data_mut(|d| d.insert_temp(Self::storage_id(), self));
208   }
209
210   /// Read the current theme from the context
211   /// if it exists, otherwise return the default theme
212   pub fn current(ctx: &Context) -> Theme {
213      ctx.data(|d| {
214         d.get_temp::<Theme>(Self::storage_id())
215            .unwrap_or_else(|| Theme::new(ThemeKind::TokyoNight))
216      })
217   }
218
219   fn storage_id() -> Id {
220      Id::new("zeus::theme")
221   }
222}
223
224fn frame_palette_changed(old: &ThemeColors, new: &ThemeColors) -> bool {
225   old.title_bar != new.title_bar
226      || old.bg != new.bg
227      || old.widget_bg != new.widget_bg
228      || old.hover != new.hover
229      || old.highlight != new.highlight
230      || old.border != new.border
231}
232
233fn remap_if_eq(slot: &mut Color32, old: Color32, new: Color32) {
234   if *slot == old {
235      *slot = new;
236   }
237}
238
239fn remap_frame(
240   frame: &mut Frame,
241   old_fill: Color32,
242   new_fill: Color32,
243   old_border: Color32,
244   new_border: Color32,
245) {
246   remap_if_eq(&mut frame.fill, old_fill, new_fill);
247   remap_if_eq(&mut frame.stroke.color, old_border, new_border);
248   remap_if_eq(&mut frame.shadow.color, old_border, new_border);
249}
250
251fn remap_frame_visuals(
252   visuals: &mut FrameVisuals,
253   old_hover: Color32,
254   new_hover: Color32,
255   old_click: Color32,
256   new_click: Color32,
257   old_highlight: Color32,
258   new_highlight: Color32,
259) {
260   remap_if_eq(&mut visuals.bg_on_hover, old_hover, new_hover);
261   remap_if_eq(&mut visuals.bg_on_click, old_click, new_click);
262   remap_if_eq(
263      &mut visuals.border_on_hover.1,
264      old_highlight,
265      new_highlight,
266   );
267   remap_if_eq(
268      &mut visuals.border_on_click.1,
269      old_highlight,
270      new_highlight,
271   );
272}
273
274/// This is the color palette of the theme
275#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
276#[derive(Copy, Clone, Debug, PartialEq, Eq)]
277pub struct ThemeColors {
278   pub button_visuals: ButtonVisuals,
279
280   pub label_visuals: LabelVisuals,
281
282   pub combo_box_visuals: ComboBoxVisuals,
283
284   pub text_edit_visuals: TextEditVisuals,
285
286   /// The color for the title bar of the app (if using custom window frame)
287   pub title_bar: Color32,
288
289   /// Main BG color of the theme
290   pub bg: Color32,
291
292   /// Widget BG color
293   ///
294   /// This is the color of the widget backgrounds
295   pub widget_bg: Color32,
296
297   /// The color to use when hovering over a widget
298   pub hover: Color32,
299
300   /// Main text color
301   pub text: Color32,
302
303   /// Muted text color
304   ///
305   /// For example a hint inside a text field
306   pub text_muted: Color32,
307
308   /// Highlight color
309   pub highlight: Color32,
310
311   /// Border color
312   pub border: Color32,
313
314   /// Accent color
315   pub accent: Color32,
316
317   /// Error color
318   ///
319   /// Can be used to indicate something bad or to highlight a dangerous action
320   pub error: Color32,
321
322   /// Warning color
323   pub warning: Color32,
324
325   /// Success color
326   ///
327   /// Can be used to indicate something good or to highlight a successful action
328   pub success: Color32,
329
330   /// Info color
331   ///
332   /// Can be used for hyperlinks or to highlight something important
333   pub info: Color32,
334}
335
336#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
337#[derive(Clone, Default, Debug, PartialEq)]
338pub struct TextSizes {
339   pub very_small: f32,
340   pub small: f32,
341   pub normal: f32,
342   pub large: f32,
343   pub very_large: f32,
344   pub heading: f32,
345}
346
347impl TextSizes {
348   pub fn new(
349      very_small: f32,
350      small: f32,
351      normal: f32,
352      large: f32,
353      very_large: f32,
354      heading: f32,
355   ) -> Self {
356      Self {
357         very_small,
358         small,
359         normal,
360         large,
361         very_large,
362         heading,
363      }
364   }
365}
366
367#[derive(Clone, Debug, Default)]
368pub struct OverlayManager(Arc<RwLock<OverlayCounter>>);
369
370impl OverlayManager {
371   pub fn new() -> Self {
372      Self(Arc::new(RwLock::new(OverlayCounter::new())))
373   }
374
375   pub fn tint_0(&self) -> Color32 {
376      Color32::from_black_alpha(40)
377   }
378
379   pub fn tint_1(&self) -> Color32 {
380      Color32::from_black_alpha(60)
381   }
382
383   pub fn tint_2(&self) -> Color32 {
384      Color32::from_black_alpha(80)
385   }
386
387   pub fn tint_3(&self) -> Color32 {
388      Color32::from_black_alpha(100)
389   }
390
391   pub fn counter(&self) -> u8 {
392      self.0.read().unwrap().counter()
393   }
394
395   pub fn order(&self) -> Order {
396      self.0.read().unwrap().order()
397   }
398
399   pub fn paint_background(&self) {
400      self.0.write().unwrap().paint_background()
401   }
402
403   pub fn paint_middle(&self) {
404      self.0.write().unwrap().paint_middle()
405   }
406
407   pub fn paint_foreground(&self) {
408      self.0.write().unwrap().paint_foreground()
409   }
410
411   pub fn paint_tooltip(&self) {
412      self.0.write().unwrap().paint_tooltip()
413   }
414
415   pub fn paint_debug(&self) {
416      self.0.write().unwrap().paint_debug()
417   }
418
419   /// Call this when you open a window
420   pub fn window_opened(&self) {
421      self.0.write().unwrap().window_opened();
422   }
423
424   /// Call this when you close a window
425   pub fn window_closed(&self) {
426      self.0.write().unwrap().window_closed();
427   }
428
429   pub fn recommended_order(&self) -> Order {
430      self.0.read().unwrap().recommended_order()
431   }
432
433   pub fn calculate_alpha(&self) -> u8 {
434      self.0.read().unwrap().calculate_alpha()
435   }
436
437   /// Returns the tint color based on the counter
438   pub fn overlay_tint(&self) -> Color32 {
439      self.0.read().unwrap().overlay_tint()
440   }
441
442   /// Paints a full-screen darkening overlay up to Foreground layer if needed
443   ///
444   /// If `recommend_order` is true, it will choose an order based on the counter
445   pub fn paint_overlay(&self, ctx: &Context, recommend_order: bool) {
446      self.0.read().unwrap().paint_overlay(ctx, recommend_order);
447   }
448
449   /// Paints an overlay at a specific screen position
450   pub fn paint_overlay_at(&self, ctx: &Context, rect: Rect, order: Order, id: Id, tint: Color32) {
451      self.0.read().unwrap().paint_overlay_at(ctx, rect, order, id, tint);
452   }
453}
454
455#[derive(Clone, Debug)]
456struct OverlayCounter {
457   counter: u8,
458   order: Order,
459}
460
461impl Default for OverlayCounter {
462   fn default() -> Self {
463      Self::new()
464   }
465}
466
467impl OverlayCounter {
468   pub fn new() -> Self {
469      Self {
470         counter: 0,
471         order: Order::Background,
472      }
473   }
474
475   pub fn counter(&self) -> u8 {
476      self.counter
477   }
478
479   pub fn order(&self) -> Order {
480      self.order
481   }
482
483   fn paint_background(&mut self) {
484      self.order = Order::Background;
485   }
486
487   fn paint_middle(&mut self) {
488      self.order = Order::Middle;
489   }
490
491   fn paint_foreground(&mut self) {
492      self.order = Order::Foreground;
493   }
494
495   fn paint_tooltip(&mut self) {
496      self.order = Order::Tooltip;
497   }
498
499   fn paint_debug(&mut self) {
500      self.order = Order::Debug;
501   }
502
503   fn window_opened(&mut self) {
504      self.counter += 1;
505   }
506
507   fn window_closed(&mut self) {
508      if self.counter > 0 {
509         self.counter -= 1;
510      }
511   }
512
513   fn calculate_alpha(&self) -> u8 {
514      let counter = self.counter;
515
516      if counter == 0 {
517         return 0;
518      }
519
520      let mut a = 80;
521      for _ in 1..counter {
522         a += 40;
523      }
524
525      a
526   }
527
528   fn overlay_tint(&self) -> Color32 {
529      let counter = self.counter();
530
531      if counter == 1 {
532         return Color32::from_black_alpha(80);
533      }
534
535      let alpha = self.calculate_alpha();
536      Color32::from_black_alpha(alpha)
537   }
538
539   fn recommended_order(&self) -> Order {
540      if self.counter() == 1 {
541         Order::Background
542      } else if self.counter() == 2 {
543         Order::Middle
544      } else {
545         Order::Foreground
546      }
547   }
548
549   fn paint_overlay(&self, ctx: &Context, recommend_order: bool) {
550      let counter = self.counter();
551      if counter == 0 {
552         return;
553      }
554
555      let order = if recommend_order {
556         if counter == 1 {
557            Order::Background
558         } else if counter == 2 {
559            Order::Middle
560         } else {
561            Order::Foreground
562         }
563      } else {
564         self.order()
565      };
566
567      let layer_id = LayerId::new(order, Id::new("darkening_overlay"));
568
569      let painter = ctx.layer_painter(layer_id);
570      painter.rect_filled(ctx.content_rect(), 0.0, self.overlay_tint());
571   }
572
573   pub fn paint_overlay_at(&self, ctx: &Context, rect: Rect, order: Order, id: Id, tint: Color32) {
574      let layer_id = LayerId::new(order, id);
575
576      let painter = ctx.layer_painter(layer_id);
577      painter.rect_filled(rect, 0.0, tint);
578   }
579}
580
581#[cfg(test)]
582mod tests {
583   use super::*;
584   use egui::{Margin, Stroke};
585
586   #[test]
587   fn custom_frame_fill_survives_palette_remap() {
588      let mut theme = Theme::new(ThemeKind::Dark);
589      let old = theme.colors;
590      let custom = Color32::from_rgb(255, 0, 0);
591      theme.frame1.fill = custom;
592
593      theme.colors.widget_bg = Color32::from_rgb(1, 2, 3);
594      theme.remap_derived_frames(&old);
595
596      assert_eq!(theme.frame1.fill, custom);
597   }
598
599   #[test]
600   fn palette_change_updates_unmodified_frame_fill() {
601      let mut theme = Theme::new(ThemeKind::Dark);
602      let old = theme.colors;
603      assert_eq!(theme.frame1.fill, old.widget_bg);
604
605      let next = Color32::from_rgb(1, 2, 3);
606      theme.colors.widget_bg = next;
607      theme.remap_derived_frames(&old);
608
609      assert_eq!(theme.frame1.fill, next);
610   }
611
612   #[test]
613   fn palette_remap_preserves_frame_structure() {
614      let mut theme = Theme::new(ThemeKind::Dark);
615      let old = theme.colors;
616      theme.frame1.inner_margin = Margin::same(42);
617      theme.frame1.stroke = Stroke::new(3.0, old.border);
618
619      theme.colors.widget_bg = Color32::from_rgb(9, 9, 9);
620      theme.colors.border = Color32::from_rgb(8, 8, 8);
621      theme.remap_derived_frames(&old);
622
623      assert_eq!(theme.frame1.inner_margin, Margin::same(42));
624      assert_eq!(theme.frame1.stroke.width, 3.0);
625      assert_eq!(
626         theme.frame1.stroke.color,
627         Color32::from_rgb(8, 8, 8)
628      );
629      assert_eq!(theme.frame1.fill, Color32::from_rgb(9, 9, 9));
630   }
631}