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}