Skip to main content

egui_elements/
theme.rs

1//! Installed look: palette, typography, frames, and per-widget visuals.
2//!
3//! Construct with [`Theme::new`], then [`Theme::install`] so custom widgets
4//! can read visuals from [`egui::Context`]. [`Theme::current`] reads that
5//! stored copy (or falls back to Tokyo Night).
6//!
7//! Built-in palettes live in [`crate::themes`]. Slot roles are documented
8//! in the crate `docs/theme.md`.
9
10use crate::overlay::OverlayManager;
11use crate::themes::{
12   mclaren_650gts_gt3, reverie, shade_sanctuary, tokyo_night, tokyo_night_light, wasp, wasp_light,
13};
14use crate::utils::*;
15use crate::visuals::*;
16use egui::{Color32, Context, Frame, Id, Margin, Style, Vec2, vec2};
17
18#[cfg(feature = "elegance")]
19use elegance::{Palette, Theme as EleganceTheme, Typography as EleganceTypography};
20
21/// Temp-data key used to store the mirrored `egui-elegance` theme.
22#[cfg(feature = "elegance")]
23pub fn elegance_theme_key() -> Id {
24   Id::new("elegance::theme")
25}
26
27/// Theme colors to convert [Theme] to [EleganceTheme]
28#[cfg(feature = "elegance")]
29#[derive(Clone, Copy, PartialEq, Eq)]
30pub struct EleganceThemeKey {
31   dark: bool,
32   bg: Color32,
33   widget_bg: Color32,
34   border: Color32,
35   text: Color32,
36   text_muted: Color32,
37   accent: Color32,
38   info: Color32,
39   success: Color32,
40   error: Color32,
41   warning: Color32,
42}
43
44#[cfg(feature = "elegance")]
45impl EleganceThemeKey {
46   fn from_theme(theme: &Theme) -> Self {
47      let c = &theme.colors;
48      Self {
49         dark: theme.dark,
50         bg: c.bg,
51         widget_bg: c.widget_bg,
52         border: c.border,
53         text: c.text,
54         text_muted: c.text_muted,
55         accent: c.accent,
56         info: c.info,
57         success: c.success,
58         error: c.error,
59         warning: c.warning,
60      }
61   }
62}
63
64/// Built-in palette. See each variant for the source look.
65#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum ThemeKind {
68   /// Based on <https://github.com/tokyo-night/tokyo-night-vscode-theme>
69   TokyoNight,
70   /// Based on <https://github.com/tokyo-night/tokyo-night-vscode-theme> (light)
71   TokyoNightLight,
72   /// Based on the 2015 McLaren 650S GT3
73   McLaren650Gts,
74   /// Based on <https://github.com/santiyounger/Reverie-Obsidian-Theme>
75   Reverie,
76   /// Based on <https://github.com/Elevict/Shade-Sanctuary>
77   ShadeSanctuary,
78   /// Based on <https://github.com/santiyounger/Wasp-Obsidian-Theme> (dark)
79   Wasp,
80   /// Based on <https://github.com/santiyounger/Wasp-Obsidian-Theme> (light)
81   WaspLight,
82}
83
84impl ThemeKind {
85   /// Human-readable name, used by [`crate::utils::theme_switcher`].
86   pub fn to_str(&self) -> &str {
87      match self {
88         ThemeKind::TokyoNight => "Tokyo Night",
89         ThemeKind::TokyoNightLight => "Tokyo Night Light",
90         ThemeKind::McLaren650Gts => "McLaren 650GTS",
91         ThemeKind::Reverie => "Reverie",
92         ThemeKind::ShadeSanctuary => "Shade Sanctuary",
93         ThemeKind::Wasp => "Wasp",
94         ThemeKind::WaspLight => "Wasp Light",
95      }
96   }
97
98   /// Every built-in kind, in switcher order.
99   pub fn to_vec() -> Vec<Self> {
100      vec![
101         Self::TokyoNight,
102         Self::TokyoNightLight,
103         Self::McLaren650Gts,
104         Self::Reverie,
105         Self::ShadeSanctuary,
106         Self::Wasp,
107         Self::WaspLight,
108      ]
109   }
110}
111
112/// Complete look: colors, typography, frames, and widget visuals.
113///
114/// `Theme` is `Clone`. [`Theme::install`] takes `&mut self` (the elegance
115/// path records a key on `self`). Clone before install if you need to keep
116/// an unmodified copy:
117///
118/// ```no_run
119/// # use egui::Context;
120/// # use egui_elements::theme::{Theme, ThemeKind};
121/// # let ctx = Context::default();
122/// let mut theme = Theme::new(ThemeKind::TokyoNight);
123/// theme.install(&ctx);
124/// ```
125#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
126#[derive(Clone)]
127pub struct Theme {
128   /// True if the theme is dark
129   pub dark: bool,
130   /// Tracks open windows so [`OverlayManager::paint_overlay`] can dim the canvas.
131   #[cfg_attr(feature = "serde", serde(skip))]
132   pub overlay_manager: OverlayManager,
133
134   /// True if a tint is recomended to be applied to images
135   /// to soften the contrast between the image and the background
136   ///
137   /// This is usually true for themes with very dark background
138   pub image_tint_recommended: bool,
139
140   /// Which built-in palette this theme was constructed from.
141   pub kind: ThemeKind,
142   /// Semantic color slots.
143   pub colors: ThemeColors,
144   /// Per-widget chrome (button, label, combo, text edit, frames).
145   pub visuals: ThemeVisuals,
146   /// Font sizes used by this crate's widgets and the demo.
147   pub typography: Typography,
148   
149   /// Layout rhythm (gaps between siblings and groups).
150   ///
151   /// Widget chrome padding stays on [`Theme::inner_margin`] /
152   /// [`Theme::button_padding`]. Apps should pick a step from this scale
153   /// instead of one-off `4.0` / `12.0` / `16.0` literals.
154   #[cfg_attr(feature = "serde", serde(default))]
155   pub spacing: ThemeSpacing,
156
157   /// Used for [Frame] not native windows
158   pub window_frame: Frame,
159   /// Base container frame for major UI sections.
160   pub frame1: Frame,
161   /// Frame for nested elements, like individual list items.
162   pub frame2: Frame,
163
164   /// Corner radius for widgets
165   pub corner_radius: u8,
166
167   /// Inner margin for widgets
168   pub inner_margin: i8,
169
170   /// Outer margin for widgets
171   pub outer_margin: i8,
172
173   /// Padding inside buttons
174   ///
175   /// Must use this from [egui::Ui::spacing_mut] to change the padding
176   pub button_padding: Vec2,
177
178   #[cfg(feature = "elegance")]
179   #[cfg_attr(feature = "serde", serde(skip))]
180   /// Snapshot used to skip reinstalling an unchanged elegance theme.
181   pub elegance_key: Option<EleganceThemeKey>,
182}
183
184impl PartialEq for Theme {
185   fn eq(&self, other: &Self) -> bool {
186      self.dark == other.dark
187         && self.kind == other.kind
188         && self.colors == other.colors
189         && self.typography == other.typography
190         && self.spacing == other.spacing
191         && self.window_frame == other.window_frame
192         && self.frame1 == other.frame1
193         && self.frame2 == other.frame2
194         && self.visuals == other.visuals
195   }
196}
197
198impl Eq for Theme {}
199
200impl Theme {
201   /// Construct a built-in theme.
202   pub fn new(kind: ThemeKind) -> Self {
203      let theme = match kind {
204         ThemeKind::TokyoNight => tokyo_night::theme(),
205         ThemeKind::TokyoNightLight => tokyo_night_light::theme(),
206         ThemeKind::McLaren650Gts => mclaren_650gts_gt3::theme(),
207         ThemeKind::Reverie => reverie::theme(),
208         ThemeKind::ShadeSanctuary => shade_sanctuary::theme(),
209         ThemeKind::Wasp => wasp::theme(),
210         ThemeKind::WaspLight => wasp_light::theme(),
211      };
212
213      theme
214   }
215
216   /// Stock [`egui::Style`] for this kind (widgets, selection, window fill).
217   pub fn style(&self) -> Style {
218      match self.kind {
219         ThemeKind::TokyoNight => tokyo_night::style(),
220         ThemeKind::TokyoNightLight => tokyo_night_light::style(),
221         ThemeKind::McLaren650Gts => mclaren_650gts_gt3::style(),
222         ThemeKind::Reverie => reverie::style(),
223         ThemeKind::ShadeSanctuary => shade_sanctuary::style(),
224         ThemeKind::Wasp => wasp::style(),
225         ThemeKind::WaspLight => wasp_light::style(),
226      }
227   }
228
229   /// Frame 1 visuals from [`ThemeVisuals`].
230   pub fn frame1_visuals(&self) -> FrameVisuals {
231      self.visuals.frame1_visuals
232   }
233
234   /// Frame 2 visuals from [`ThemeVisuals`].
235   pub fn frame2_visuals(&self) -> FrameVisuals {
236      self.visuals.frame2_visuals
237   }
238
239   /// Button visuals from [`ThemeVisuals`].
240   pub fn button_visuals(&self) -> ButtonVisuals {
241      self.visuals.button_visuals
242   }
243
244   /// Label visuals from [`ThemeVisuals`] (same shape as button visuals).
245   pub fn label_visuals(&self) -> LabelVisuals {
246      self.visuals.label_visuals
247   }
248
249   /// Combo-box visuals from [`ThemeVisuals`].
250   pub fn combo_box_visuals(&self) -> ComboBoxVisuals {
251      self.visuals.combo_box_visuals
252   }
253
254   /// Text-edit visuals from [`ThemeVisuals`].
255   pub fn text_edit_visuals(&self) -> TextEditVisuals {
256      self.visuals.text_edit_visuals
257   }
258
259   fn storage_id() -> Id {
260      Id::new("elements::theme")
261   }
262
263   fn button_visuals_id() -> Id {
264      Id::new("elements::button_visuals")
265   }
266
267   fn label_visuals_id() -> Id {
268      Id::new("elements::label_visuals")
269   }
270
271   fn combo_box_visuals_id() -> Id {
272      Id::new("elements::combo_box_visuals")
273   }
274
275   fn text_edit_visuals_id() -> Id {
276      Id::new("elements::text_edit_visuals")
277   }
278
279   /// Widget visuals stored by [`Theme::install`], if any.
280   ///
281   /// Widgets resolve in this order: `self.visuals` → context → [`egui::Style`].
282   pub fn button_visuals_from_ctx(ctx: &Context) -> Option<ButtonVisuals> {
283      ctx.data(|d| d.get_temp(Self::button_visuals_id()))
284   }
285
286   /// Same as [`Self::button_visuals_from_ctx`] for labels.
287   pub fn label_visuals_from_ctx(ctx: &Context) -> Option<LabelVisuals> {
288      ctx.data(|d| d.get_temp(Self::label_visuals_id()))
289   }
290
291   /// Same as [`Self::button_visuals_from_ctx`] for combo boxes.
292   pub fn combo_box_visuals_from_ctx(ctx: &Context) -> Option<ComboBoxVisuals> {
293      ctx.data(|d| d.get_temp(Self::combo_box_visuals_id()))
294   }
295
296   /// Same as [`Self::button_visuals_from_ctx`] for text edits.
297   pub fn text_edit_visuals_from_ctx(ctx: &Context) -> Option<TextEditVisuals> {
298      ctx.data(|d| d.get_temp(Self::text_edit_visuals_id()))
299   }
300
301   /// Install this theme into the given egui context.
302   ///
303   /// Widgets then pick up their visuals from `ctx` automatically.
304   pub fn install(&mut self, ctx: &Context) {
305      #[cfg(feature = "elegance")]
306      self.install_elegance_theme(ctx);
307
308      let button_visuals = self.button_visuals();
309      let label_visuals = self.label_visuals();
310      let combo_box_visuals = self.combo_box_visuals();
311      let text_edit_visuals = self.text_edit_visuals();
312      let mut style = self.style();
313      self.apply_spacing(&mut style);
314      let theme = self.clone();
315
316      ctx.set_global_style(style);
317      ctx.data_mut(|d| d.insert_temp(Self::storage_id(), theme));
318
319      ctx.data_mut(|d| d.insert_temp(Self::button_visuals_id(), button_visuals));
320      ctx.data_mut(|d| d.insert_temp(Self::label_visuals_id(), label_visuals));
321      ctx.data_mut(|d| d.insert_temp(Self::combo_box_visuals_id(), combo_box_visuals));
322      ctx.data_mut(|d| d.insert_temp(Self::text_edit_visuals_id(), text_edit_visuals));
323   }
324
325   #[cfg(feature = "elegance")]
326   /// Convert the current theme into an elegance theme and install it
327   ///
328   /// If the elegance theme is already installed, this is no-op.
329   pub fn install_elegance_theme(&mut self, ctx: &Context) {
330      let key = EleganceThemeKey::from_theme(&self);
331      if self.elegance_key == Some(key) {
332         return;
333      }
334
335      let c = &self.colors;
336      let mut pal = if key.dark {
337         Palette::charcoal()
338      } else {
339         Palette::frost()
340      };
341
342      pal.is_dark = key.dark;
343      pal.bg = c.bg;
344      pal.card = c.widget_bg;
345      pal.input_bg = c.widget_bg;
346      pal.border = c.border;
347      pal.text = c.text;
348      pal.text_muted = c.text_muted;
349      pal.text_faint = c.text_muted;
350      pal.focus = c.accent;
351      pal.blue = c.info;
352      pal.green = c.success;
353      pal.green_hover = c.success;
354      pal.red = c.error;
355      pal.red_hover = c.error;
356      pal.amber = c.warning;
357      pal.amber_hover = c.warning;
358      pal.purple = c.accent;
359      pal.purple_hover = c.accent;
360      pal.success = c.success;
361      pal.danger = c.error;
362      pal.warning = c.warning;
363
364      let elegance_typography = EleganceTypography {
365         body: self.typography.normal,
366         button: self.typography.normal,
367         label: self.typography.normal,
368         small: self.typography.small,
369         heading: self.typography.heading,
370         monospace: self.typography.small,
371      };
372
373      let elegance_theme = EleganceTheme {
374         palette: pal,
375         control_radius: self.corner_radius as f32,
376         card_radius: self.corner_radius as f32,
377         card_padding: self.frame1.inner_margin.top as f32,
378         control_padding_y: self.button_padding.y as f32,
379         control_padding_x: self.button_padding.x as f32,
380         typography: elegance_typography,
381      };
382
383      ctx.data_mut(|d| d.insert_temp(elegance_theme_key(), elegance_theme));
384      self.elegance_key = Some(key);
385   }
386
387   /// Read the current theme from the context
388   /// if it exists, otherwise return the default theme
389   pub fn current(ctx: &Context) -> Theme {
390      ctx.data(|d| {
391         d.get_temp::<Theme>(Self::storage_id())
392            .unwrap_or_else(|| Theme::new(ThemeKind::TokyoNight))
393      })
394   }
395
396   /// Push [`Theme::spacing`] and button/window padding onto `style`.
397   ///
398   /// Called from [`Theme::install`]. `style()` from a `ThemeKind` only
399   /// sets colors; layout tokens live on `Theme` so the editor can change
400   /// them without rebuilding the palette.
401   fn apply_spacing(&self, style: &mut Style) {
402      let s = &self.spacing;
403      style.spacing.item_spacing = vec2(s.md, s.md);
404      style.spacing.button_padding = self.button_padding;
405      style.spacing.window_margin = Margin::same(self.inner_margin);
406   }
407
408   /// Keep derived frame colors in sync with a palette change.
409   ///
410   /// Only updates a color if it still matches the previous palette slot
411   /// (e.g. `frame1.fill == old.widget_bg`). Custom colors and structural
412   /// frame properties (margins, rounding, shadow offsets) are left alone.
413   pub fn remap_derived_frames(&mut self, old: &ThemeColors) {
414      let new = self.colors;
415      if !frame_palette_changed(old, &new) {
416         return;
417      }
418
419      remap_frame(
420         &mut self.window_frame,
421         old.title_bar,
422         new.title_bar,
423         old.border,
424         new.border,
425      );
426      remap_frame(
427         &mut self.frame1,
428         old.widget_bg,
429         new.widget_bg,
430         old.border,
431         new.border,
432      );
433      remap_frame(
434         &mut self.frame2,
435         old.bg,
436         new.bg,
437         old.border,
438         new.border,
439      );
440      remap_frame_visuals(
441         &mut self.visuals.frame1_visuals,
442         old.hover,
443         new.hover,
444         old.widget_bg,
445         new.widget_bg,
446         old.highlight,
447         new.highlight,
448      );
449      remap_frame_visuals(
450         &mut self.visuals.frame2_visuals,
451         old.hover,
452         new.hover,
453         old.bg,
454         new.bg,
455         old.highlight,
456         new.highlight,
457      );
458   }
459}
460
461/// Theme visuals
462#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
463#[derive(Copy, Clone, Debug, PartialEq, Eq)]
464pub struct ThemeVisuals {
465   /// Visuals for [`crate::widgets::Button`].
466   pub button_visuals: ButtonVisuals,
467   /// Visuals for [`crate::widgets::Label`].
468   pub label_visuals: LabelVisuals,
469   /// Visuals for [`crate::widgets::ComboBox`].
470   pub combo_box_visuals: ComboBoxVisuals,
471   /// Visuals for [`crate::widgets::SecureTextEdit`].
472   pub text_edit_visuals: TextEditVisuals,
473   /// Hover/click fills for [`Theme::frame1`].
474   pub frame1_visuals: FrameVisuals,
475   /// Hover/click fills for [`Theme::frame2`].
476   pub frame2_visuals: FrameVisuals,
477}
478
479/// This is the color palette of the theme
480#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
481#[derive(Copy, Clone, Debug, PartialEq, Eq)]
482pub struct ThemeColors {
483   /// The color for the title bar of the app (if using custom window frame)
484   pub title_bar: Color32,
485
486   /// Main BG color of the theme
487   pub bg: Color32,
488
489   /// Widget BG color
490   ///
491   /// This is the color of the widget backgrounds
492   pub widget_bg: Color32,
493
494   /// The color to use when hovering over a widget
495   pub hover: Color32,
496
497   /// Main text color
498   pub text: Color32,
499
500   /// Muted text color
501   ///
502   /// For example a hint inside a text field
503   pub text_muted: Color32,
504
505   /// Selected / emphasis fill. Distinct from [`Self::hover`].
506   pub highlight: Color32,
507
508   /// Border color
509   pub border: Color32,
510
511   /// Accent color
512   pub accent: Color32,
513
514   /// Error color
515   ///
516   /// Can be used to indicate something bad or to highlight a dangerous action
517   pub error: Color32,
518
519   /// Warning color
520   pub warning: Color32,
521
522   /// Success color
523   ///
524   /// Can be used to indicate something good or to highlight a successful action
525   pub success: Color32,
526
527   /// Info color
528   ///
529   /// Can be used for hyperlinks or to highlight something important
530   pub info: Color32,
531}
532
533/// Layout rhythm in points. Jobs, not one-off gaps.
534///
535/// | Token | Default | Job |
536/// |-------|---------|-----|
537/// | `xs` | 4 | Tight meta, icon+label, stacked lines in a row |
538/// | `sm` | 8 | Dense lists, control padding y, related captions |
539/// | `md` | 12 | Default [`egui::Spacing::item_spacing`], related group |
540/// | `lg` | 16 | Section padding (header, pane inner) |
541/// | `xl` | 24 | Between groups (heading → gallery) |
542///
543/// Widget chrome (`inner_margin`, `button_padding`) is separate.
544#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
545#[derive(Clone, Copy, Debug, PartialEq)]
546pub struct ThemeSpacing {
547   /// Tight: stacked meta, icon+label.
548   pub xs: f32,
549   /// Dense lists and compact control padding.
550   pub sm: f32,
551   /// Default item spacing / related group.
552   pub md: f32,
553   /// Section padding.
554   pub lg: f32,
555   /// Space between groups.
556   pub xl: f32,
557}
558
559impl Default for ThemeSpacing {
560   fn default() -> Self {
561      Self {
562         xs: 4.0,
563         sm: 8.0,
564         md: 12.0,
565         lg: 16.0,
566         xl: 24.0,
567      }
568   }
569}
570
571impl ThemeSpacing {
572   /// Uniform margin from one step (e.g. `space.margin(space.lg)`).
573   pub fn margin(self, size: f32) -> Margin {
574      Margin::same(size as i8)
575   }
576
577   /// Independent x/y margin (e.g. header `lg` × `md`).
578   pub fn margin_xy(self, x: f32, y: f32) -> Margin {
579      Margin::symmetric(x as i8, y as i8)
580   }
581}
582
583/// Font sizes in points.
584#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
585#[derive(Clone, Default, Debug, PartialEq)]
586pub struct Typography {
587   /// Tiny captions.
588   pub very_small: f32,
589   /// Secondary / hint text.
590   pub small: f32,
591   /// Body and button text.
592   pub normal: f32,
593   /// Emphasized body.
594   pub large: f32,
595   /// Section titles below a heading.
596   pub very_large: f32,
597   /// Page / modal headings.
598   pub heading: f32,
599}