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        // Visuals.
480        let v = &mut style.visuals;
481        *v = if p.is_dark {
482            Visuals::dark()
483        } else {
484            Visuals::light()
485        };
486        v.dark_mode = p.is_dark;
487        v.override_text_color = Some(p.text);
488        v.panel_fill = p.bg;
489        v.window_fill = p.card;
490        v.window_stroke = Stroke::new(1.0, p.border);
491        v.window_corner_radius = CornerRadius::same(self.card_radius as u8);
492        v.menu_corner_radius = CornerRadius::same(8);
493        v.extreme_bg_color = p.input_bg;
494        v.faint_bg_color = p.depth_tint(p.card, 0.02);
495        v.code_bg_color = p.input_bg;
496        v.hyperlink_color = p.sky;
497        v.warn_fg_color = p.warning;
498        v.error_fg_color = p.danger;
499        v.button_frame = true;
500        v.striped = false;
501
502        v.selection = Selection {
503            bg_fill: with_alpha(p.sky, 70),
504            stroke: Stroke::new(1.0, p.sky),
505        };
506
507        // Widget visuals: we use these for built-in widgets. Elegance
508        // widgets mostly paint themselves, so we keep the stock styling
509        // tidy rather than exact.
510        let control_radius = CornerRadius::same(self.control_radius as u8);
511        v.widgets = Widgets {
512            noninteractive: egui::style::WidgetVisuals {
513                bg_fill: p.card,
514                weak_bg_fill: p.card,
515                bg_stroke: Stroke::new(1.0, p.border),
516                corner_radius: control_radius,
517                fg_stroke: Stroke::new(1.0, p.text),
518                expansion: 0.0,
519            },
520            inactive: egui::style::WidgetVisuals {
521                bg_fill: p.input_bg,
522                weak_bg_fill: p.input_bg,
523                bg_stroke: Stroke::new(1.0, p.border),
524                corner_radius: control_radius,
525                fg_stroke: Stroke::new(1.0, p.text),
526                expansion: 0.0,
527            },
528            hovered: egui::style::WidgetVisuals {
529                bg_fill: p.depth_tint(p.input_bg, 0.04),
530                weak_bg_fill: p.depth_tint(p.input_bg, 0.04),
531                bg_stroke: Stroke::new(1.0, p.text_muted),
532                corner_radius: control_radius,
533                fg_stroke: Stroke::new(1.5, p.text),
534                expansion: 1.0,
535            },
536            active: egui::style::WidgetVisuals {
537                bg_fill: mix(p.input_bg, p.sky, 0.15),
538                weak_bg_fill: mix(p.input_bg, p.sky, 0.15),
539                bg_stroke: Stroke::new(1.0, p.sky),
540                corner_radius: control_radius,
541                fg_stroke: Stroke::new(1.5, p.text),
542                expansion: 1.0,
543            },
544            open: egui::style::WidgetVisuals {
545                bg_fill: p.input_bg,
546                weak_bg_fill: p.input_bg,
547                bg_stroke: Stroke::new(1.0, p.sky),
548                corner_radius: control_radius,
549                fg_stroke: Stroke::new(1.0, p.text),
550                expansion: 0.0,
551            },
552        };
553    }
554
555    /// Create a [`WidgetText`] coloured with the primary text colour and
556    /// sized for body copy.
557    pub fn body_text(&self, text: impl Into<String>) -> WidgetText {
558        egui::RichText::new(text.into())
559            .color(self.palette.text)
560            .size(self.typography.body)
561            .into()
562    }
563
564    /// Create a strong [`WidgetText`] coloured and sized for a heading.
565    pub fn heading_text(&self, text: impl Into<String>) -> WidgetText {
566        egui::RichText::new(text.into())
567            .color(self.palette.text)
568            .size(self.typography.heading)
569            .strong()
570            .into()
571    }
572
573    /// Create a [`WidgetText`] coloured with the muted text colour.
574    pub fn muted_text(&self, text: impl Into<String>) -> WidgetText {
575        egui::RichText::new(text.into())
576            .color(self.palette.text_muted)
577            .size(self.typography.label)
578            .into()
579    }
580
581    /// Create a [`WidgetText`] coloured with the faint (tertiary) text colour.
582    pub fn faint_text(&self, text: impl Into<String>) -> WidgetText {
583        egui::RichText::new(text.into())
584            .color(self.palette.text_faint)
585            .size(self.typography.small)
586            .into()
587    }
588}
589
590impl Default for Theme {
591    fn default() -> Self {
592        Self::slate()
593    }
594}
595
596/// One of the four built-in elegance themes, as a typed enum.
597///
598/// Useful as the bound value for [`ThemeSwitcher`](crate::ThemeSwitcher) or
599/// anywhere you want to remember a theme choice without stringly-typing it.
600/// Marked `#[non_exhaustive]` so future built-in additions won't break
601/// exhaustive matches in downstream code.
602///
603/// ```
604/// # use elegance::BuiltInTheme;
605/// let choice = BuiltInTheme::Frost;
606/// let theme = choice.theme();
607/// assert_eq!(choice.label(), "Frost");
608/// assert!(!theme.palette.is_dark);
609/// ```
610#[non_exhaustive]
611#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
612pub enum BuiltInTheme {
613    /// [`Theme::slate`] — cool dark blue. The default.
614    #[default]
615    Slate,
616    /// [`Theme::charcoal`] — neutral dark grey.
617    Charcoal,
618    /// [`Theme::frost`] — light counterpart to slate.
619    Frost,
620    /// [`Theme::paper`] — light counterpart to charcoal.
621    Paper,
622}
623
624impl BuiltInTheme {
625    /// Display label used by [`ThemeSwitcher`](crate::ThemeSwitcher).
626    pub const fn label(self) -> &'static str {
627        match self {
628            Self::Slate => "Slate",
629            Self::Charcoal => "Charcoal",
630            Self::Frost => "Frost",
631            Self::Paper => "Paper",
632        }
633    }
634
635    /// Resolve to a concrete [`Theme`].
636    pub fn theme(self) -> Theme {
637        match self {
638            Self::Slate => Theme::slate(),
639            Self::Charcoal => Theme::charcoal(),
640            Self::Frost => Theme::frost(),
641            Self::Paper => Theme::paper(),
642        }
643    }
644
645    /// All four built-in themes in their canonical display order: dark
646    /// variants first (Slate, Charcoal), then light (Frost, Paper).
647    pub const fn all() -> [BuiltInTheme; 4] {
648        [Self::Slate, Self::Charcoal, Self::Frost, Self::Paper]
649    }
650}
651
652// --- colour utilities ------------------------------------------------------
653
654#[inline]
655const fn rgb(r: u8, g: u8, b: u8) -> Color32 {
656    Color32::from_rgb(r, g, b)
657}
658
659pub(crate) fn with_alpha(c: Color32, alpha: u8) -> Color32 {
660    Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), alpha)
661}
662
663/// Run `f` with the `Ui`'s visuals temporarily mutable. Any changes made
664/// inside the closure are reverted when it returns, so widgets can paint
665/// nested egui primitives with themed visuals without leaking those
666/// mutations to sibling widgets.
667pub(crate) fn with_themed_visuals<R>(ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R {
668    let saved = ui.visuals().clone();
669    let result = f(ui);
670    *ui.visuals_mut() = saved;
671    result
672}
673
674/// Apply the shared "input-like frame" visuals to `v`: every widget state
675/// gets the same `bg_fill` / `weak_bg_fill` / `corner_radius`, and each
676/// state's border stroke follows the elegance convention
677/// (inactive → border, hovered → text_muted, active/open → sky).
678///
679/// Callers layer their variant-specific tweaks on top — text edits add
680/// `extreme_bg_color` + selection colours, selects add per-state
681/// `fg_stroke` + `override_text_color`.
682pub(crate) fn themed_input_visuals(v: &mut Visuals, theme: &Theme, bg_fill: Color32) {
683    let p = &theme.palette;
684    let radius = CornerRadius::same(theme.control_radius as u8);
685    for w in [
686        &mut v.widgets.inactive,
687        &mut v.widgets.hovered,
688        &mut v.widgets.active,
689        &mut v.widgets.open,
690    ] {
691        w.bg_fill = bg_fill;
692        w.weak_bg_fill = bg_fill;
693        w.corner_radius = radius;
694        // egui defaults hovered/active expansion to 1.0 (widgets "pop" outward on
695        // hover). That's fine for buttons but reads as jitter on text inputs —
696        // the border visibly jumps on every mouse hover, and any overlaid marker
697        // (e.g. the dirty bar) has to jitter with it. Keep inputs frame-stable.
698        w.expansion = 0.0;
699    }
700    v.widgets.inactive.bg_stroke = Stroke::new(1.0, p.border);
701    v.widgets.hovered.bg_stroke = Stroke::new(1.0, p.text_muted);
702    v.widgets.active.bg_stroke = Stroke::new(1.5, p.sky);
703    v.widgets.open.bg_stroke = Stroke::new(1.5, p.sky);
704}
705
706/// Lay out `text` as a proportional-font galley with `Color32::PLACEHOLDER`
707/// baked in. The placeholder colour lets `painter.galley(..., fallback_color)`
708/// actually control the rendered colour — otherwise `WidgetText::into_galley`
709/// bakes `visuals.override_text_color` (or `strong_text_color` when `strong`
710/// is set) into the galley and silently overrides the fallback.
711pub(crate) fn placeholder_galley(
712    ui: &egui::Ui,
713    text: &str,
714    font_size: f32,
715    strong: bool,
716    wrap_width: f32,
717) -> std::sync::Arc<egui::Galley> {
718    let mut rt = egui::RichText::new(text)
719        .size(font_size)
720        .color(Color32::PLACEHOLDER);
721    if strong {
722        rt = rt.strong();
723    }
724    egui::WidgetText::from(rt).into_galley(
725        ui,
726        Some(egui::TextWrapMode::Extend),
727        wrap_width,
728        egui::FontSelection::FontId(egui::FontId::proportional(font_size)),
729    )
730}
731
732/// Linear mix between `a` and `b`; `t = 0.0` returns `a`, `t = 1.0` returns `b`.
733pub(crate) fn mix(a: Color32, b: Color32, t: f32) -> Color32 {
734    let t = t.clamp(0.0, 1.0);
735    let lerp = |x: u8, y: u8| -> u8 {
736        let xf = x as f32;
737        let yf = y as f32;
738        (xf + (yf - xf) * t).round().clamp(0.0, 255.0) as u8
739    };
740    Color32::from_rgba_unmultiplied(
741        lerp(a.r(), b.r()),
742        lerp(a.g(), b.g()),
743        lerp(a.b(), b.b()),
744        lerp(a.a().max(1), b.a().max(1)),
745    )
746}