Skip to main content

elegance/
theme.rs

1//! Theme: colours, typography, and egui `Style` integration.
2//!
3//! Four built-in palettes ship with the crate, paired as dark/light:
4//! [`Palette::slate`] (cool dark blue — the default) and [`Palette::frost`]
5//! (cool light blue-tinted) form one pair; [`Palette::charcoal`] (neutral
6//! dark grey) and [`Palette::paper`] (neutral warm light) form the other.
7//! Switching between members of a pair keeps layouts pixel-identical and
8//! only swaps luminance.
9
10use egui::{
11    epaint::text::{FontInsert, FontPriority, InsertFontFamily},
12    style::{Selection, Widgets},
13    Color32, Context, CornerRadius, FontData, FontFamily, FontId, Id, Margin, Stroke, Style,
14    TextStyle, Vec2, Visuals, WidgetText,
15};
16
17/// Bundled subset of DejaVu Sans covering the arrow / key / math-ellipsis
18/// glyphs that aren't in egui's default fonts. Registered as a fallback in
19/// both Proportional and Monospace families by [`Theme::install`].
20const SYMBOLS_FONT_BYTES: &[u8] = include_bytes!("../assets/elegance-symbols.ttf");
21const SYMBOLS_FONT_KEY: &str = "elegance-symbols";
22
23/// The six accent colours supported by elegance.
24///
25/// Every accent has a resting and a pressed/hover shade. These drive
26/// [`Button`](crate::Button), the segmented button's `on` state, and any
27/// other accent-tinted widget. Structural treatments like the outline
28/// button are widget options (e.g. [`Button::outline`](crate::Button::outline)),
29/// not accents.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31pub enum Accent {
32    /// Primary blue — the default button accent.
33    Blue,
34    /// Green — affirmative actions (Deploy, Save).
35    Green,
36    /// Red — destructive actions (Delete, Rollback).
37    Red,
38    /// Purple — neutral-positive actions or brand moments.
39    Purple,
40    /// Amber — caution-leaning actions that aren't destructive.
41    Amber,
42    /// Sky — the same colour used for focus rings and active states.
43    Sky,
44}
45
46/// All the colours used by the design system.
47///
48/// You can tweak individual fields before calling [`Theme::install`] if you
49/// want to nudge the default slate look.
50#[derive(Clone, Debug, PartialEq)]
51pub struct Palette {
52    /// Whether this palette is a dark-mode palette.
53    ///
54    /// Drives [`Visuals::dark`] vs [`Visuals::light`] and flips the
55    /// direction of subtle-lift mixes (see [`Palette::depth_tint`]).
56    /// If you build a custom palette, set this to match the luminance of
57    /// `bg` / `card`.
58    pub is_dark: bool,
59
60    /// Overall application background.
61    pub bg: Color32,
62    /// Card / panel surface colour.
63    pub card: Color32,
64    /// Input field background (typically the same as `bg`).
65    pub input_bg: Color32,
66    /// Border colour used for inputs, cards and separators.
67    pub border: Color32,
68
69    /// Primary text colour.
70    pub text: Color32,
71    /// Secondary text (labels, field captions).
72    pub text_muted: Color32,
73    /// Tertiary text (hints, placeholders, disabled-ish).
74    pub text_faint: Color32,
75
76    /// Blue accent, resting state — backs [`Accent::Blue`].
77    pub blue: Color32,
78    /// Blue accent, hover/pressed state.
79    pub blue_hover: Color32,
80    /// Green accent, resting state — backs [`Accent::Green`].
81    pub green: Color32,
82    /// Green accent, hover/pressed state.
83    pub green_hover: Color32,
84    /// Red accent, resting state — backs [`Accent::Red`].
85    pub red: Color32,
86    /// Red accent, hover/pressed state.
87    pub red_hover: Color32,
88    /// Purple accent, resting state — backs [`Accent::Purple`].
89    pub purple: Color32,
90    /// Purple accent, hover/pressed state.
91    pub purple_hover: Color32,
92    /// Amber accent, resting state — backs [`Accent::Amber`].
93    pub amber: Color32,
94    /// Amber accent, hover/pressed state.
95    pub amber_hover: Color32,
96    /// The sky blue used for focus rings, active tabs, and "dirty" input bars.
97    pub sky: Color32,
98
99    /// Success accent used by the status light and flashy feedback.
100    pub success: Color32,
101    /// Danger accent used by the status light and flashy feedback.
102    pub danger: Color32,
103    /// Warning accent used by the "connecting" status light.
104    pub warning: Color32,
105}
106
107impl Palette {
108    /// The default "slate" palette — cool corporate dark blue with a sky
109    /// focus ring. Matches the reference design.
110    pub fn slate() -> Self {
111        Self {
112            is_dark: true,
113            bg: rgb(0x0f, 0x17, 0x2a),
114            card: rgb(0x1e, 0x29, 0x3b),
115            input_bg: rgb(0x0f, 0x17, 0x2a),
116            border: rgb(0x33, 0x41, 0x55),
117
118            text: rgb(0xe2, 0xe8, 0xf0),
119            text_muted: rgb(0x94, 0xa3, 0xb8),
120            text_faint: rgb(0x64, 0x74, 0x8b),
121
122            blue: rgb(0x25, 0x63, 0xeb),
123            blue_hover: rgb(0x1d, 0x4e, 0xd8),
124            green: rgb(0x16, 0xa3, 0x4a),
125            green_hover: rgb(0x15, 0x80, 0x3d),
126            red: rgb(0xdc, 0x26, 0x26),
127            red_hover: rgb(0xb9, 0x1c, 0x1c),
128            purple: rgb(0x7c, 0x3a, 0xed),
129            purple_hover: rgb(0x6d, 0x28, 0xd9),
130            amber: rgb(0xd9, 0x77, 0x06),
131            amber_hover: rgb(0xb4, 0x53, 0x09),
132            sky: rgb(0x38, 0xbd, 0xf8),
133
134            success: rgb(0x4a, 0xde, 0x80),
135            danger: rgb(0xf8, 0x71, 0x71),
136            warning: rgb(0xfb, 0xbf, 0x24),
137        }
138    }
139
140    /// The "charcoal" palette — a neutral dark-grey surface with a
141    /// cyan focus accent. Minimalist and monochrome compared to the
142    /// blue-tinged [`Palette::slate`].
143    pub fn charcoal() -> Self {
144        Self {
145            is_dark: true,
146            bg: rgb(0x0f, 0x0f, 0x10),
147            card: rgb(0x1c, 0x1c, 0x1e),
148            input_bg: rgb(0x0f, 0x0f, 0x10),
149            border: rgb(0x38, 0x38, 0x3a),
150
151            text: rgb(0xfa, 0xfa, 0xfa),
152            text_muted: rgb(0xa1, 0xa1, 0xaa),
153            text_faint: rgb(0x71, 0x71, 0x7a),
154
155            blue: rgb(0x3b, 0x82, 0xf6),
156            blue_hover: rgb(0x25, 0x63, 0xeb),
157            green: rgb(0x22, 0xc5, 0x5e),
158            green_hover: rgb(0x16, 0xa3, 0x4a),
159            red: rgb(0xef, 0x44, 0x44),
160            red_hover: rgb(0xdc, 0x26, 0x26),
161            purple: rgb(0x8b, 0x5c, 0xf6),
162            purple_hover: rgb(0x7c, 0x3a, 0xed),
163            amber: rgb(0xf5, 0x9e, 0x0b),
164            amber_hover: rgb(0xd9, 0x77, 0x06),
165            sky: rgb(0x22, 0xd3, 0xee),
166
167            success: rgb(0x4a, 0xde, 0x80),
168            danger: rgb(0xf8, 0x71, 0x71),
169            warning: rgb(0xfb, 0xbf, 0x24),
170        }
171    }
172
173    /// The "frost" palette — the light-mode counterpart to
174    /// [`Palette::slate`]. Slate-tinted off-white surfaces, deep slate
175    /// text, and the same cool accent family with slightly deepened
176    /// shades so white-on-accent button labels remain legible.
177    pub fn frost() -> Self {
178        Self {
179            is_dark: false,
180            bg: rgb(0xe2, 0xe8, 0xf0),
181            card: rgb(0xf8, 0xfa, 0xfc),
182            input_bg: rgb(0xff, 0xff, 0xff),
183            border: rgb(0x94, 0xa3, 0xb8),
184
185            text: rgb(0x0f, 0x17, 0x2a),
186            text_muted: rgb(0x47, 0x55, 0x69),
187            text_faint: rgb(0x64, 0x74, 0x8b),
188
189            blue: rgb(0x25, 0x63, 0xeb),
190            blue_hover: rgb(0x1d, 0x4e, 0xd8),
191            green: rgb(0x16, 0xa3, 0x4a),
192            green_hover: rgb(0x15, 0x80, 0x3d),
193            red: rgb(0xdc, 0x26, 0x26),
194            red_hover: rgb(0xb9, 0x1c, 0x1c),
195            purple: rgb(0x7c, 0x3a, 0xed),
196            purple_hover: rgb(0x6d, 0x28, 0xd9),
197            amber: rgb(0xd9, 0x77, 0x06),
198            amber_hover: rgb(0xb4, 0x53, 0x09),
199            sky: rgb(0x03, 0x74, 0xb0),
200
201            success: rgb(0x16, 0xa3, 0x4a),
202            danger: rgb(0xdc, 0x26, 0x26),
203            warning: rgb(0xd9, 0x77, 0x06),
204        }
205    }
206
207    /// The "paper" palette — the light-mode counterpart to
208    /// [`Palette::charcoal`]. Warm neutral off-white surfaces with a
209    /// darkened cyan focus accent to match charcoal's cool accent flavour.
210    pub fn paper() -> Self {
211        Self {
212            is_dark: false,
213            bg: rgb(0xec, 0xe9, 0xe4),
214            card: rgb(0xfa, 0xf8, 0xf3),
215            input_bg: rgb(0xff, 0xff, 0xff),
216            border: rgb(0xbc, 0xb6, 0xa8),
217
218            text: rgb(0x1c, 0x1a, 0x16),
219            text_muted: rgb(0x57, 0x52, 0x4a),
220            text_faint: rgb(0x8a, 0x83, 0x77),
221
222            blue: rgb(0x25, 0x63, 0xeb),
223            blue_hover: rgb(0x1d, 0x4e, 0xd8),
224            green: rgb(0x16, 0xa3, 0x4a),
225            green_hover: rgb(0x15, 0x80, 0x3d),
226            red: rgb(0xdc, 0x26, 0x26),
227            red_hover: rgb(0xb9, 0x1c, 0x1c),
228            purple: rgb(0x7c, 0x3a, 0xed),
229            purple_hover: rgb(0x6d, 0x28, 0xd9),
230            amber: rgb(0xd9, 0x77, 0x06),
231            amber_hover: rgb(0xb4, 0x53, 0x09),
232            sky: rgb(0x0c, 0x80, 0x9e),
233
234            success: rgb(0x16, 0xa3, 0x4a),
235            danger: rgb(0xdc, 0x26, 0x26),
236            warning: rgb(0xd9, 0x77, 0x06),
237        }
238    }
239
240    /// Mix `base` toward a "more recessed" colour by factor `t`.
241    ///
242    /// In dark palettes this mixes toward white (adding luminance — a
243    /// subtle *lift*); in light palettes it mixes toward black (removing
244    /// luminance — a subtle *shade*). Either way the result pops slightly
245    /// off the neighbouring surface. Used for hover states on otherwise
246    /// plain fills, and the faint card-ish backgrounds.
247    pub fn depth_tint(&self, base: Color32, t: f32) -> Color32 {
248        let toward = if self.is_dark {
249            Color32::WHITE
250        } else {
251            Color32::BLACK
252        };
253        mix(base, toward, t)
254    }
255
256    /// Resolve the resting fill colour for a given accent.
257    pub fn accent_fill(&self, accent: Accent) -> Color32 {
258        match accent {
259            Accent::Blue => self.blue,
260            Accent::Green => self.green,
261            Accent::Red => self.red,
262            Accent::Purple => self.purple,
263            Accent::Amber => self.amber,
264            Accent::Sky => self.sky,
265        }
266    }
267
268    /// Resolve the hover / pressed fill colour for a given accent.
269    pub fn accent_hover(&self, accent: Accent) -> Color32 {
270        match accent {
271            Accent::Blue => self.blue_hover,
272            Accent::Green => self.green_hover,
273            Accent::Red => self.red_hover,
274            Accent::Purple => self.purple_hover,
275            Accent::Amber => self.amber_hover,
276            Accent::Sky => mix(self.sky, Color32::BLACK, 0.15),
277        }
278    }
279}
280
281/// Typography settings shared by all widgets.
282///
283/// Font sizes are expressed in egui points (equivalent to CSS pixels at
284/// the default zoom level).
285#[derive(Clone, Copy, Debug, PartialEq)]
286pub struct Typography {
287    /// Default body text size.
288    pub body: f32,
289    /// Button label size.
290    pub button: f32,
291    /// Field-label size (the text above a [`TextInput`](crate::TextInput), for example).
292    pub label: f32,
293    /// Secondary text size — hints, captions, badges.
294    pub small: f32,
295    /// Heading size used by [`Card`](crate::Card) titles.
296    pub heading: f32,
297    /// Monospace size used by code-style content.
298    pub monospace: f32,
299}
300
301impl Typography {
302    /// The default typography scale.
303    pub fn elegant() -> Self {
304        Self {
305            body: 14.0,
306            button: 13.5,
307            label: 13.0,
308            small: 12.0,
309            heading: 16.0,
310            monospace: 13.0,
311        }
312    }
313}
314
315/// The full elegance theme — colours + typography + a handful of shapes.
316#[derive(Clone, Debug, PartialEq)]
317pub struct Theme {
318    /// Colour palette driving every widget.
319    pub palette: Palette,
320    /// Font sizes shared across widgets.
321    pub typography: Typography,
322
323    /// Corner radius used for buttons, inputs, selects and segmented buttons.
324    pub control_radius: f32,
325    /// Corner radius used for cards.
326    pub card_radius: f32,
327    /// Inner padding applied to cards.
328    pub card_padding: f32,
329    /// Vertical padding inside buttons and inputs.
330    pub control_padding_y: f32,
331    /// Horizontal padding inside buttons.
332    pub control_padding_x: f32,
333}
334
335impl Theme {
336    /// The default elegance theme: slate palette, elegant typography.
337    pub fn slate() -> Self {
338        Self {
339            palette: Palette::slate(),
340            typography: Typography::elegant(),
341            control_radius: 6.0,
342            card_radius: 10.0,
343            card_padding: 18.0,
344            control_padding_y: 6.5,
345            control_padding_x: 14.0,
346        }
347    }
348
349    /// The "charcoal" theme: neutral dark-grey palette with a cyan
350    /// focus accent. Shares shape and typography with [`Theme::slate`]
351    /// so layouts transfer cleanly between the two.
352    pub fn charcoal() -> Self {
353        Self {
354            palette: Palette::charcoal(),
355            ..Self::slate()
356        }
357    }
358
359    /// The "frost" theme: the light-mode counterpart to
360    /// [`Theme::slate`]. Shares shape and typography so you can toggle
361    /// between the two without any layout shift.
362    pub fn frost() -> Self {
363        Self {
364            palette: Palette::frost(),
365            ..Self::slate()
366        }
367    }
368
369    /// The "paper" theme: the light-mode counterpart to
370    /// [`Theme::charcoal`]. Shares shape and typography so you can toggle
371    /// between the two without any layout shift.
372    pub fn paper() -> Self {
373        Self {
374            palette: Palette::paper(),
375            ..Self::slate()
376        }
377    }
378
379    /// Install the theme into an [`egui::Context`].
380    ///
381    /// This updates `ctx.style()` so that stock widgets (labels, sliders,
382    /// scroll bars, etc.) inherit the palette, registers the bundled
383    /// `Elegance Symbols` font as a lowest-priority Proportional + Monospace
384    /// fallback so glyphs like `→ ⌫ ⋯` render out of the box, and stores
385    /// the theme in context memory so elegance widgets can read it back.
386    ///
387    /// Cheap to call every frame: when the incoming theme equals the one
388    /// already installed, the style and memory writes are skipped. The
389    /// font install is idempotent (by font name) inside egui.
390    ///
391    /// The font registration uses [`Context::add_font`], which appends to
392    /// the existing registry. Host fonts installed via `add_font` — at any
393    /// time, before or after `Theme::install` — coexist with the symbols
394    /// font. A host call to `ctx.set_fonts(...)` after `Theme::install`
395    /// still clobbers the symbols font (and egui's defaults, and anything
396    /// else), but that's inherent to `set_fonts` taking over the registry.
397    pub fn install(self, ctx: &Context) {
398        install_symbols_font(ctx);
399
400        let unchanged = ctx.data(|d| {
401            d.get_temp::<Theme>(Self::storage_id())
402                .is_some_and(|t| t == self)
403        });
404        if unchanged {
405            return;
406        }
407        ctx.global_style_mut(|style| self.apply_to_style(style));
408        ctx.data_mut(|d| d.insert_temp(Self::storage_id(), self));
409    }
410
411    /// Read the currently-installed theme, or return [`Theme::slate`] if
412    /// none has been installed yet.
413    pub fn current(ctx: &Context) -> Theme {
414        ctx.data(|d| {
415            d.get_temp::<Theme>(Self::storage_id())
416                .unwrap_or_else(Theme::slate)
417        })
418    }
419
420    fn storage_id() -> Id {
421        Id::new("elegance::theme")
422    }
423}
424
425fn install_symbols_font(ctx: &Context) {
426    ctx.add_font(FontInsert::new(
427        SYMBOLS_FONT_KEY,
428        FontData::from_static(SYMBOLS_FONT_BYTES),
429        vec![
430            InsertFontFamily {
431                family: FontFamily::Proportional,
432                priority: FontPriority::Lowest,
433            },
434            InsertFontFamily {
435                family: FontFamily::Monospace,
436                priority: FontPriority::Lowest,
437            },
438        ],
439    ));
440}
441
442impl Theme {
443    fn apply_to_style(&self, style: &mut Style) {
444        let p = &self.palette;
445        let t = &self.typography;
446
447        // Text styles.
448        use FontFamily::{Monospace, Proportional};
449        style
450            .text_styles
451            .insert(TextStyle::Heading, FontId::new(t.heading, Proportional));
452        style
453            .text_styles
454            .insert(TextStyle::Body, FontId::new(t.body, Proportional));
455        style
456            .text_styles
457            .insert(TextStyle::Button, FontId::new(t.button, Proportional));
458        style
459            .text_styles
460            .insert(TextStyle::Small, FontId::new(t.small, Proportional));
461        style
462            .text_styles
463            .insert(TextStyle::Monospace, FontId::new(t.monospace, Monospace));
464
465        // Spacing.
466        let sp = &mut style.spacing;
467        sp.item_spacing = Vec2::new(8.0, 6.0);
468        sp.button_padding = Vec2::new(self.control_padding_x, self.control_padding_y);
469        sp.interact_size = Vec2::new(24.0, 24.0);
470        sp.icon_width = 16.0;
471        sp.icon_width_inner = 10.0;
472        sp.icon_spacing = 6.0;
473        sp.combo_width = 120.0;
474        sp.text_edit_width = 180.0;
475        sp.window_margin = Margin::same(10);
476        sp.menu_margin = Margin::same(6);
477        sp.indent = 16.0;
478
479        // Interaction. Override after install via
480        // `ctx.style_mut(|s| s.interaction.tooltip_delay = ...)` to taste.
481        style.interaction.tooltip_delay = 0.35;
482        style.interaction.tooltip_grace_time = 0.2;
483
484        // Visuals.
485        let v = &mut style.visuals;
486        *v = if p.is_dark {
487            Visuals::dark()
488        } else {
489            Visuals::light()
490        };
491        v.dark_mode = p.is_dark;
492        v.override_text_color = Some(p.text);
493        v.panel_fill = p.bg;
494        v.window_fill = p.card;
495        v.window_stroke = Stroke::new(1.0, p.border);
496        v.window_corner_radius = CornerRadius::same(self.card_radius as u8);
497        v.menu_corner_radius = CornerRadius::same(8);
498        v.extreme_bg_color = p.input_bg;
499        v.faint_bg_color = p.depth_tint(p.card, 0.02);
500        v.code_bg_color = p.input_bg;
501        v.hyperlink_color = p.sky;
502        v.warn_fg_color = p.warning;
503        v.error_fg_color = p.danger;
504        v.button_frame = true;
505        v.striped = false;
506
507        v.selection = Selection {
508            bg_fill: with_alpha(p.sky, 70),
509            stroke: Stroke::new(1.0, p.sky),
510        };
511
512        // Widget visuals: we use these for built-in widgets. Elegance
513        // widgets mostly paint themselves, so we keep the stock styling
514        // tidy rather than exact.
515        let control_radius = CornerRadius::same(self.control_radius as u8);
516        v.widgets = Widgets {
517            noninteractive: egui::style::WidgetVisuals {
518                bg_fill: p.card,
519                weak_bg_fill: p.card,
520                bg_stroke: Stroke::new(1.0, p.border),
521                corner_radius: control_radius,
522                fg_stroke: Stroke::new(1.0, p.text),
523                expansion: 0.0,
524            },
525            inactive: egui::style::WidgetVisuals {
526                bg_fill: p.input_bg,
527                weak_bg_fill: p.input_bg,
528                bg_stroke: Stroke::new(1.0, p.border),
529                corner_radius: control_radius,
530                fg_stroke: Stroke::new(1.0, p.text),
531                expansion: 0.0,
532            },
533            hovered: egui::style::WidgetVisuals {
534                bg_fill: p.depth_tint(p.input_bg, 0.04),
535                weak_bg_fill: p.depth_tint(p.input_bg, 0.04),
536                bg_stroke: Stroke::new(1.0, p.text_muted),
537                corner_radius: control_radius,
538                fg_stroke: Stroke::new(1.5, p.text),
539                expansion: 1.0,
540            },
541            active: egui::style::WidgetVisuals {
542                bg_fill: mix(p.input_bg, p.sky, 0.15),
543                weak_bg_fill: mix(p.input_bg, p.sky, 0.15),
544                bg_stroke: Stroke::new(1.0, p.sky),
545                corner_radius: control_radius,
546                fg_stroke: Stroke::new(1.5, p.text),
547                expansion: 1.0,
548            },
549            open: egui::style::WidgetVisuals {
550                bg_fill: p.input_bg,
551                weak_bg_fill: p.input_bg,
552                bg_stroke: Stroke::new(1.0, p.sky),
553                corner_radius: control_radius,
554                fg_stroke: Stroke::new(1.0, p.text),
555                expansion: 0.0,
556            },
557        };
558    }
559
560    /// Create a [`WidgetText`] coloured with the primary text colour and
561    /// sized for body copy.
562    pub fn body_text(&self, text: impl Into<String>) -> WidgetText {
563        egui::RichText::new(text.into())
564            .color(self.palette.text)
565            .size(self.typography.body)
566            .into()
567    }
568
569    /// Create a strong [`WidgetText`] coloured and sized for a heading.
570    pub fn heading_text(&self, text: impl Into<String>) -> WidgetText {
571        egui::RichText::new(text.into())
572            .color(self.palette.text)
573            .size(self.typography.heading)
574            .strong()
575            .into()
576    }
577
578    /// Create a [`WidgetText`] coloured with the muted text colour.
579    pub fn muted_text(&self, text: impl Into<String>) -> WidgetText {
580        egui::RichText::new(text.into())
581            .color(self.palette.text_muted)
582            .size(self.typography.label)
583            .into()
584    }
585
586    /// Create a [`WidgetText`] coloured with the faint (tertiary) text colour.
587    pub fn faint_text(&self, text: impl Into<String>) -> WidgetText {
588        egui::RichText::new(text.into())
589            .color(self.palette.text_faint)
590            .size(self.typography.small)
591            .into()
592    }
593}
594
595impl Default for Theme {
596    fn default() -> Self {
597        Self::slate()
598    }
599}
600
601/// One of the four built-in elegance themes, as a typed enum.
602///
603/// Useful as the bound value for [`ThemeSwitcher`](crate::ThemeSwitcher) or
604/// anywhere you want to remember a theme choice without stringly-typing it.
605/// Marked `#[non_exhaustive]` so future built-in additions won't break
606/// exhaustive matches in downstream code.
607///
608/// ```
609/// # use elegance::BuiltInTheme;
610/// let choice = BuiltInTheme::Frost;
611/// let theme = choice.theme();
612/// assert_eq!(choice.label(), "Frost");
613/// assert!(!theme.palette.is_dark);
614/// ```
615#[non_exhaustive]
616#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
617pub enum BuiltInTheme {
618    /// [`Theme::slate`] — cool dark blue. The default.
619    #[default]
620    Slate,
621    /// [`Theme::charcoal`] — neutral dark grey.
622    Charcoal,
623    /// [`Theme::frost`] — light counterpart to slate.
624    Frost,
625    /// [`Theme::paper`] — light counterpart to charcoal.
626    Paper,
627}
628
629impl BuiltInTheme {
630    /// Display label used by [`ThemeSwitcher`](crate::ThemeSwitcher).
631    pub const fn label(self) -> &'static str {
632        match self {
633            Self::Slate => "Slate",
634            Self::Charcoal => "Charcoal",
635            Self::Frost => "Frost",
636            Self::Paper => "Paper",
637        }
638    }
639
640    /// Resolve to a concrete [`Theme`].
641    pub fn theme(self) -> Theme {
642        match self {
643            Self::Slate => Theme::slate(),
644            Self::Charcoal => Theme::charcoal(),
645            Self::Frost => Theme::frost(),
646            Self::Paper => Theme::paper(),
647        }
648    }
649
650    /// All four built-in themes in their canonical display order: dark
651    /// variants first (Slate, Charcoal), then light (Frost, Paper).
652    pub const fn all() -> [BuiltInTheme; 4] {
653        [Self::Slate, Self::Charcoal, Self::Frost, Self::Paper]
654    }
655}
656
657// --- colour utilities ------------------------------------------------------
658
659#[inline]
660const fn rgb(r: u8, g: u8, b: u8) -> Color32 {
661    Color32::from_rgb(r, g, b)
662}
663
664pub(crate) fn with_alpha(c: Color32, alpha: u8) -> Color32 {
665    Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), alpha)
666}
667
668/// Baseline position within an egui galley as a fraction of the row
669/// height. egui's default proportional fonts place the baseline at
670/// roughly this fraction; aligning two galleys' baselines (rather than
671/// their bottoms) is what HTML's `align-items: baseline` does and reads
672/// noticeably more natural when one glyph run is much smaller than the
673/// other.
674pub(crate) const BASELINE_FRAC: f32 = 0.78;
675
676/// Run `f` with the `Ui`'s visuals temporarily mutable. Any changes made
677/// inside the closure are reverted when it returns, so widgets can paint
678/// nested egui primitives with themed visuals without leaking those
679/// mutations to sibling widgets.
680pub(crate) fn with_themed_visuals<R>(ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R {
681    let saved = ui.visuals().clone();
682    let result = f(ui);
683    *ui.visuals_mut() = saved;
684    result
685}
686
687/// Apply the shared "input-like frame" visuals to `v`: every widget state
688/// gets the same `bg_fill` / `weak_bg_fill` / `corner_radius`, and each
689/// state's border stroke follows the elegance convention
690/// (inactive → border, hovered → text_muted, active/open → sky).
691///
692/// Callers layer their variant-specific tweaks on top — text edits add
693/// `extreme_bg_color` + selection colours, selects add per-state
694/// `fg_stroke` + `override_text_color`.
695pub(crate) fn themed_input_visuals(v: &mut Visuals, theme: &Theme, bg_fill: Color32) {
696    let p = &theme.palette;
697    let radius = CornerRadius::same(theme.control_radius as u8);
698    for w in [
699        &mut v.widgets.inactive,
700        &mut v.widgets.hovered,
701        &mut v.widgets.active,
702        &mut v.widgets.open,
703    ] {
704        w.bg_fill = bg_fill;
705        w.weak_bg_fill = bg_fill;
706        w.corner_radius = radius;
707        // egui defaults hovered/active expansion to 1.0 (widgets "pop" outward on
708        // hover). That's fine for buttons but reads as jitter on text inputs —
709        // the border visibly jumps on every mouse hover, and any overlaid marker
710        // (e.g. the dirty bar) has to jitter with it. Keep inputs frame-stable.
711        w.expansion = 0.0;
712    }
713    v.widgets.inactive.bg_stroke = Stroke::new(1.0, p.border);
714    v.widgets.hovered.bg_stroke = Stroke::new(1.0, p.text_muted);
715    v.widgets.active.bg_stroke = Stroke::new(1.5, p.sky);
716    v.widgets.open.bg_stroke = Stroke::new(1.5, p.sky);
717}
718
719/// Lay out `text` as a proportional-font galley with `Color32::PLACEHOLDER`
720/// baked in. The placeholder colour lets `painter.galley(..., fallback_color)`
721/// actually control the rendered colour — otherwise `WidgetText::into_galley`
722/// bakes `visuals.override_text_color` (or `strong_text_color` when `strong`
723/// is set) into the galley and silently overrides the fallback.
724pub(crate) fn placeholder_galley(
725    ui: &egui::Ui,
726    text: &str,
727    font_size: f32,
728    strong: bool,
729    wrap_width: f32,
730) -> std::sync::Arc<egui::Galley> {
731    let mut rt = egui::RichText::new(text)
732        .size(font_size)
733        .color(Color32::PLACEHOLDER);
734    if strong {
735        rt = rt.strong();
736    }
737    egui::WidgetText::from(rt).into_galley(
738        ui,
739        Some(egui::TextWrapMode::Extend),
740        wrap_width,
741        egui::FontSelection::FontId(egui::FontId::proportional(font_size)),
742    )
743}
744
745/// Linear mix between `a` and `b`; `t = 0.0` returns `a`, `t = 1.0` returns `b`.
746pub(crate) fn mix(a: Color32, b: Color32, t: f32) -> Color32 {
747    let t = t.clamp(0.0, 1.0);
748    let lerp = |x: u8, y: u8| -> u8 {
749        let xf = x as f32;
750        let yf = y as f32;
751        (xf + (yf - xf) * t).round().clamp(0.0, 255.0) as u8
752    };
753    Color32::from_rgba_unmultiplied(
754        lerp(a.r(), b.r()),
755        lerp(a.g(), b.g()),
756        lerp(a.b(), b.b()),
757        lerp(a.a().max(1), b.a().max(1)),
758    )
759}