use egui::{Color32, CornerRadius, FontFamily, FontId, Margin, Stroke, TextStyle, Theme, Vec2};
use crate::theme::WidgetPalette;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThemeMode {
Light,
Dark,
#[default]
System,
}
impl ThemeMode {
fn preference(self) -> egui::ThemePreference {
match self {
ThemeMode::Light => egui::ThemePreference::Light,
ThemeMode::Dark => egui::ThemePreference::Dark,
ThemeMode::System => egui::ThemePreference::System,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Surfaces {
pub app: Color32,
pub panel: Color32,
pub raised: Color32,
pub sunken: Color32,
pub row_alt: Color32,
pub hover: Color32,
pub pressed: Color32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextColors {
pub primary: Color32,
pub secondary: Color32,
pub disabled: Color32,
pub on_accent: Color32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StatusColors {
pub info: Color32,
pub success: Color32,
pub warning: Color32,
pub danger: Color32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Radii {
pub sm: u8,
pub md: u8,
pub lg: u8,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpaceScale {
pub xxs: f32,
pub xs: f32,
pub sm: f32,
pub md: f32,
pub lg: f32,
pub xl: f32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TypeScale {
pub small: f32,
pub body: f32,
pub button: f32,
pub heading: f32,
pub title: f32,
pub mono: f32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tokens {
pub dark: bool,
pub surface: Surfaces,
pub text: TextColors,
pub status: StatusColors,
pub accent: Color32,
pub accent_hover: Color32,
pub selection: Color32,
pub selection_text: Color32,
pub focus: Color32,
pub border: Color32,
pub border_strong: Color32,
pub separator: Color32,
pub radius: Radii,
pub space: SpaceScale,
pub type_scale: TypeScale,
pub control_height: f32,
}
impl Default for Tokens {
fn default() -> Self {
Self::dark()
}
}
const SPACE: SpaceScale = SpaceScale { xxs: 2.0, xs: 4.0, sm: 6.0, md: 8.0, lg: 12.0, xl: 16.0 };
const TYPE: TypeScale = TypeScale { small: 11.0, body: 13.0, button: 13.0, heading: 15.0, title: 18.0, mono: 12.5 };
const RADII: Radii = Radii { sm: 3, md: 5, lg: 8 };
impl Tokens {
pub const fn dark() -> Self {
Tokens {
dark: true,
surface: Surfaces {
app: Color32::from_rgb(0x12, 0x12, 0x14),
panel: Color32::from_rgb(0x1e, 0x1e, 0x21),
raised: Color32::from_rgb(0x28, 0x28, 0x2c),
sunken: Color32::from_rgb(0x15, 0x15, 0x17),
row_alt: Color32::from_rgb(0x22, 0x22, 0x25),
hover: Color32::from_rgb(0x30, 0x30, 0x35),
pressed: Color32::from_rgb(0x3c, 0x3c, 0x42),
},
text: TextColors {
primary: Color32::from_rgb(0xe4, 0xe4, 0xe7),
secondary: Color32::from_rgb(0xa1, 0xa1, 0xaa),
disabled: Color32::from_rgb(0x63, 0x63, 0x6b),
on_accent: Color32::WHITE,
},
status: StatusColors {
info: Color32::from_rgb(0x60, 0xa5, 0xfa),
success: Color32::from_rgb(0x4a, 0xde, 0x80),
warning: Color32::from_rgb(0xfb, 0xbf, 0x24),
danger: Color32::from_rgb(0xf8, 0x71, 0x71),
},
accent: Color32::from_rgb(0x3b, 0x82, 0xf6),
accent_hover: Color32::from_rgb(0x60, 0x9c, 0xf8),
selection: Color32::from_rgb(0x1e, 0x4f, 0x9c),
selection_text: Color32::WHITE,
focus: Color32::from_rgb(0x7c, 0xb4, 0xff),
border: Color32::from_rgb(0x3a, 0x3a, 0x40),
border_strong: Color32::from_rgb(0x58, 0x58, 0x60),
separator: Color32::from_rgb(0x2e, 0x2e, 0x33),
radius: RADII,
space: SPACE,
type_scale: TYPE,
control_height: 24.0,
}
}
pub const fn light() -> Self {
Tokens {
dark: false,
surface: Surfaces {
app: Color32::from_rgb(0xe4, 0xe4, 0xe7),
panel: Color32::from_rgb(0xf4, 0xf4, 0xf5),
raised: Color32::WHITE,
sunken: Color32::WHITE,
row_alt: Color32::from_rgb(0xec, 0xec, 0xef),
hover: Color32::from_rgb(0xe2, 0xe2, 0xe6),
pressed: Color32::from_rgb(0xd0, 0xd0, 0xd6),
},
text: TextColors {
primary: Color32::from_rgb(0x18, 0x18, 0x1b),
secondary: Color32::from_rgb(0x52, 0x52, 0x5b),
disabled: Color32::from_rgb(0xa1, 0xa1, 0xaa),
on_accent: Color32::WHITE,
},
status: StatusColors {
info: Color32::from_rgb(0x25, 0x63, 0xeb),
success: Color32::from_rgb(0x16, 0xa3, 0x4a),
warning: Color32::from_rgb(0xb4, 0x53, 0x09),
danger: Color32::from_rgb(0xdc, 0x26, 0x26),
},
accent: Color32::from_rgb(0x25, 0x63, 0xeb),
accent_hover: Color32::from_rgb(0x1d, 0x4e, 0xd8),
selection: Color32::from_rgb(0xbf, 0xd7, 0xfe),
selection_text: Color32::from_rgb(0x0b, 0x1f, 0x45),
focus: Color32::from_rgb(0x25, 0x63, 0xeb),
border: Color32::from_rgb(0xc8, 0xc8, 0xcf),
border_strong: Color32::from_rgb(0x9a, 0x9a, 0xa3),
separator: Color32::from_rgb(0xd9, 0xd9, 0xde),
radius: RADII,
space: SPACE,
type_scale: TYPE,
control_height: 24.0,
}
}
pub const fn for_theme(theme: Theme) -> Self {
match theme {
Theme::Dark => Self::dark(),
Theme::Light => Self::light(),
}
}
pub const fn theme(&self) -> Theme {
if self.dark { Theme::Dark } else { Theme::Light }
}
pub fn with_accent(mut self, accent: Color32) -> Self {
let [r, g, b, _] = accent.to_array();
self.accent = accent;
self.accent_hover = if self.dark { lerp(accent, Color32::WHITE, 0.18) } else { lerp(accent, Color32::BLACK, 0.15) };
self.focus = if self.dark { lerp(accent, Color32::WHITE, 0.35) } else { accent };
self.selection = if self.dark { lerp(Color32::from_rgb(r, g, b), self.surface.panel, 0.45) } else { lerp(accent, Color32::WHITE, 0.72) };
self
}
pub fn font(size: f32) -> FontId {
FontId::new(size, FontFamily::Proportional)
}
pub fn mono(&self) -> FontId {
FontId::new(self.type_scale.mono, FontFamily::Monospace)
}
pub fn interact_fill(&self, response: &egui::Response) -> Color32 {
if response.is_pointer_button_down_on() {
self.surface.pressed
} else if response.hovered() {
self.surface.hover
} else {
self.surface.sunken
}
}
pub fn visuals(&self) -> egui::Visuals {
let mut v = if self.dark { egui::Visuals::dark() } else { egui::Visuals::light() };
let s = &self.surface;
v.panel_fill = s.panel;
v.window_fill = s.raised;
v.extreme_bg_color = s.sunken;
v.text_edit_bg_color = Some(s.sunken);
v.faint_bg_color = s.row_alt;
v.code_bg_color = s.sunken;
v.override_text_color = Some(self.text.primary);
v.weak_text_color = Some(self.text.secondary);
v.hyperlink_color = self.accent;
v.warn_fg_color = self.status.warning;
v.error_fg_color = self.status.danger;
v.selection.bg_fill = self.selection;
v.selection.stroke = Stroke::new(1.0, self.selection_text);
v.window_stroke = Stroke::new(1.0, self.border);
v.window_corner_radius = CornerRadius::same(self.radius.lg);
v.menu_corner_radius = CornerRadius::same(self.radius.md);
let shadow_alpha = if self.dark { 140 } else { 40 };
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 14, spread: 0, color: Color32::from_black_alpha(shadow_alpha) };
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(shadow_alpha) };
WidgetPalette { noninteractive: s.panel, inactive: s.sunken, hovered: s.hover, active: s.pressed, open: s.hover }.apply_to(
&mut v.widgets,
CornerRadius::same(self.radius.md),
Stroke::new(1.0, self.text.primary),
);
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, self.separator);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, self.text.primary);
v.widgets.inactive.bg_stroke = Stroke::new(1.0, self.border);
v.widgets.hovered.bg_stroke = Stroke::new(1.0, self.border_strong);
v.widgets.active.bg_stroke = Stroke::new(1.0, self.focus);
v.widgets.open.bg_stroke = Stroke::new(1.0, self.border_strong);
v.text_cursor.stroke = Stroke::new(2.0, self.accent);
v
}
pub fn style_into(&self, style: &mut egui::Style) {
let sp = &self.space;
style.spacing.item_spacing = Vec2::new(sp.md, sp.xs);
style.spacing.button_padding = Vec2::new(sp.md, sp.xxs + 1.0);
style.spacing.interact_size.y = self.control_height;
style.spacing.menu_margin = Margin::same(sp.xs as i8);
style.spacing.window_margin = Margin::same(sp.lg as i8);
style.spacing.indent = sp.xl;
let t = &self.type_scale;
style.text_styles.insert(TextStyle::Small, Self::font(t.small));
style.text_styles.insert(TextStyle::Body, Self::font(t.body));
style.text_styles.insert(TextStyle::Button, Self::font(t.button));
style.text_styles.insert(TextStyle::Heading, Self::font(t.heading));
style.text_styles.insert(TextStyle::Monospace, FontId::new(t.mono, FontFamily::Monospace));
style.visuals = self.visuals();
}
}
fn lerp(a: Color32, b: Color32, t: f32) -> Color32 {
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round().clamp(0.0, 255.0) as u8;
Color32::from_rgba_unmultiplied(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b()), mix(a.a(), b.a()))
}
fn tokens_id(theme: Theme) -> egui::Id {
egui::Id::new(("ashlar_ui::tokens", theme))
}
fn extension_id<T: 'static>(theme: Theme) -> egui::Id {
egui::Id::new(("ashlar_ui::tokens::extension", theme, std::any::TypeId::of::<T>()))
}
pub fn apply(ctx: &egui::Context, tokens: &Tokens) {
let theme = tokens.theme();
ctx.style_mut_of(theme, |style| tokens.style_into(style));
ctx.data_mut(|d| d.insert_temp(tokens_id(theme), *tokens));
}
pub fn install(ctx: &egui::Context, light: &Tokens, dark: &Tokens, mode: ThemeMode) {
apply(ctx, &Tokens { dark: false, ..*light });
apply(ctx, &Tokens { dark: true, ..*dark });
set_mode(ctx, mode);
}
pub fn set_mode(ctx: &egui::Context, mode: ThemeMode) {
ctx.set_theme(mode.preference());
}
pub fn mode(ctx: &egui::Context) -> ThemeMode {
match ctx.options(|o| o.theme_preference) {
egui::ThemePreference::Light => ThemeMode::Light,
egui::ThemePreference::Dark => ThemeMode::Dark,
egui::ThemePreference::System => ThemeMode::System,
}
}
pub const INTERFACE_SCALE: std::ops::RangeInclusive<f32> = 0.5..=4.0;
pub fn set_interface_scale(ctx: &egui::Context, scale: f32) {
if scale.is_finite() {
ctx.set_zoom_factor(scale.clamp(*INTERFACE_SCALE.start(), *INTERFACE_SCALE.end()));
}
}
pub fn current(ctx: &egui::Context) -> Tokens {
let theme = ctx.theme();
ctx.data(|d| d.get_temp::<Tokens>(tokens_id(theme))).unwrap_or(Tokens::for_theme(theme))
}
pub fn set_extension<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, theme: Theme, value: T) {
ctx.data_mut(|d| d.insert_temp(extension_id::<T>(theme), value));
}
pub fn extension<T: Clone + Send + Sync + 'static>(ctx: &egui::Context) -> Option<T> {
let theme = ctx.theme();
ctx.data(|d| d.get_temp::<T>(extension_id::<T>(theme)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_styles_its_own_theme_and_current_follows_mode() {
let ctx = egui::Context::default();
let dark = Tokens::dark().with_accent(Color32::from_rgb(200, 40, 120));
install(&ctx, &Tokens::light(), &dark, ThemeMode::Dark);
assert_eq!(current(&ctx).accent, Color32::from_rgb(200, 40, 120));
assert_eq!(ctx.style_of(Theme::Dark).visuals.hyperlink_color, Color32::from_rgb(200, 40, 120));
assert_eq!(ctx.style_of(Theme::Light).visuals.panel_fill, Tokens::light().surface.panel);
set_mode(&ctx, ThemeMode::Light);
assert_eq!(mode(&ctx), ThemeMode::Light);
assert_eq!(current(&ctx), Tokens::light());
assert_eq!(ctx.global_style().visuals.panel_fill, Tokens::light().surface.panel);
assert_eq!(ctx.global_style().spacing.interact_size.y, Tokens::light().control_height);
assert_eq!(ctx.global_style().text_styles.get(&TextStyle::Body), Some(&Tokens::font(13.0)));
}
#[test]
fn install_forces_each_set_onto_its_theme() {
let ctx = egui::Context::default();
install(&ctx, &Tokens::dark(), &Tokens::dark(), ThemeMode::Light);
assert!(!current(&ctx).dark);
assert!(!ctx.style_of(Theme::Light).visuals.dark_mode);
assert!(ctx.style_of(Theme::Dark).visuals.dark_mode);
}
#[test]
fn system_mode_follows_the_reported_os_theme() {
let ctx = egui::Context::default();
install(&ctx, &Tokens::light(), &Tokens::dark(), ThemeMode::System);
for (os, dark) in [(Theme::Light, false), (Theme::Dark, true)] {
let mut out = ctx.run_ui(egui::RawInput { system_theme: Some(os), ..Default::default() }, |_| {});
out.textures_delta.clear();
assert_eq!(current(&ctx).dark, dark);
}
}
#[test]
fn extensions_are_per_theme_and_per_type() {
#[derive(Clone, Copy, PartialEq, Debug)]
struct Meter(Color32);
let ctx = egui::Context::default();
set_mode(&ctx, ThemeMode::Dark);
assert_eq!(extension::<Meter>(&ctx), None);
set_extension(&ctx, Theme::Dark, Meter(Color32::RED));
set_extension(&ctx, Theme::Light, Meter(Color32::GREEN));
assert_eq!(extension::<Meter>(&ctx), Some(Meter(Color32::RED)));
set_mode(&ctx, ThemeMode::Light);
assert_eq!(extension::<Meter>(&ctx), Some(Meter(Color32::GREEN)));
}
#[test]
fn interface_scale_is_bounded() {
let ctx = egui::Context::default();
let scaled = |scale: f32| {
set_interface_scale(&ctx, scale);
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
ctx.zoom_factor()
};
assert_eq!(scaled(1.5), 1.5);
assert_eq!(scaled(f32::NAN), 1.5);
assert_eq!(scaled(100.0), 4.0);
assert_eq!(scaled(0.0), 0.5);
}
#[test]
fn accent_derivations_stay_in_range() {
for base in [Tokens::light(), Tokens::dark()] {
let t = base.with_accent(Color32::from_rgb(255, 255, 0));
assert_eq!(t.accent, Color32::from_rgb(255, 255, 0));
assert_ne!(t.selection, base.selection);
}
assert_eq!(lerp(Color32::BLACK, Color32::WHITE, f32::NAN), Color32::BLACK);
assert_eq!(lerp(Color32::BLACK, Color32::WHITE, 2.0), Color32::WHITE);
}
}