use ashlar_motion::Pair;
use ashlar_motion::color::{self, Rgba};
use ashlar_motion::tokens as motion_tokens;
use egui::epaint::Shadow;
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,
}
impl Radii {
pub fn sm(self) -> f32 {
f32::from(self.sm)
}
pub fn md(self) -> f32 {
f32::from(self.md)
}
pub fn lg(self) -> f32 {
f32::from(self.lg)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Space {
Xxs,
Xs,
Sm,
#[default]
Md,
Lg,
Xl,
}
impl SpaceScale {
pub fn get(&self, space: Space) -> f32 {
match space {
Space::Xxs => self.xxs,
Space::Xs => self.xs,
Space::Sm => self.sm,
Space::Md => self.md,
Space::Lg => self.lg,
Space::Xl => self.xl,
}
}
}
#[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,
pub motion: MotionTokens,
pub elevation: Elevation,
pub stroke: Strokes,
}
impl Default for Tokens {
fn default() -> Self {
Self::dark()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MotionTokens {
pub press: Pair,
pub hover: Pair,
pub state: Pair,
pub reflow: Pair,
pub surface: Pair,
pub enter_exit: Pair,
pub tooltip_delay: f32,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Elevation {
pub popup: Shadow,
pub window: Shadow,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Strokes {
pub hairline: f32,
pub focus: f32,
}
const MOTION: MotionTokens = MotionTokens {
press: motion_tokens::INSTANT,
hover: motion_tokens::SNAP,
state: motion_tokens::SETTLE,
reflow: motion_tokens::REFLOW,
surface: motion_tokens::GLIDE,
enter_exit: motion_tokens::DEFAULT,
tooltip_delay: 0.5,
};
const STROKES: Strokes = Strokes { hairline: 1.0, focus: 2.0 };
const fn elevation(shadow_alpha: u8) -> Elevation {
Elevation {
popup: Shadow { offset: [0, 4], blur: 14, spread: 0, color: Color32::from_black_alpha(shadow_alpha) },
window: Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(shadow_alpha) },
}
}
pub const MIN_TEXT_CONTRAST: f32 = 4.5;
pub const MIN_UI_CONTRAST: f32 = 3.0;
pub const TOKEN_SPEC: &[(&str, f32, f32, f32)] = &[
("dark.surface.app", 0.1831, 0.004, 286.0), ("dark.surface.panel", 0.2363, 0.0057, 286.0), ("dark.surface.raised", 0.2785, 0.0073, 285.9), ("dark.surface.sunken", 0.1966, 0.004, 286.0), ("dark.surface.row_alt", 0.2532, 0.0056, 286.0), ("dark.surface.hover", 0.3111, 0.0089, 285.9), ("dark.surface.pressed", 0.3584, 0.0103, 285.9), ("dark.text.primary", 0.9197, 0.004, 286.3), ("dark.text.secondary", 0.7118, 0.0129, 286.1), ("dark.text.disabled", 0.5025, 0.0126, 285.9), ("dark.text.on_accent", 1.0, 0.0, 0.0), ("dark.status.info", 0.7137, 0.1434, 254.6), ("dark.status.success", 0.8003, 0.1821, 151.7), ("dark.status.warning", 0.8369, 0.1644, 84.4), ("dark.status.danger", 0.7106, 0.1661, 22.2), ("dark.accent", 0.5601, 0.1878, 259.8), ("dark.accent_hover", 0.6292, 0.1878, 259.8), ("dark.selection", 0.4404, 0.1358, 259.9), ("dark.selection_text", 1.0, 0.0, 0.0), ("dark.focus", 0.7606, 0.1228, 259.6), ("dark.border", 0.3507, 0.0103, 285.9), ("dark.border_strong", 0.4632, 0.0128, 285.9), ("dark.separator", 0.3032, 0.0089, 285.9), ("light.surface.app", 0.9197, 0.004, 286.3), ("light.surface.panel", 0.9674, 0.0013, 286.4), ("light.surface.raised", 1.0, 0.0, 0.0), ("light.surface.sunken", 1.0, 0.0, 0.0), ("light.surface.row_alt", 0.9439, 0.004, 286.3), ("light.surface.hover", 0.9139, 0.0054, 286.3), ("light.surface.pressed", 0.8594, 0.0082, 286.2), ("light.text.primary", 0.2103, 0.0059, 285.9), ("light.text.secondary", 0.4419, 0.0146, 285.8), ("light.text.disabled", 0.7118, 0.0129, 286.1), ("light.text.on_accent", 1.0, 0.0, 0.0), ("light.status.info", 0.5461, 0.2152, 262.9), ("light.status.success", 0.6271, 0.1699, 149.2), ("light.status.warning", 0.5553, 0.1455, 49.0), ("light.status.danger", 0.5771, 0.2152, 27.3), ("light.accent", 0.5461, 0.2152, 262.9), ("light.accent_hover", 0.4858, 0.2153, 262.9), ("light.selection", 0.8756, 0.0592, 261.9), ("light.selection_text", 0.2504, 0.0791, 262.9), ("light.focus", 0.5461, 0.2152, 262.9), ("light.border", 0.8349, 0.0097, 286.2), ("light.border_strong", 0.6891, 0.013, 286.1), ("light.separator", 0.8868, 0.0068, 286.3), ];
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(0x27, 0x6e, 0xe0),
accent_hover: Color32::from_rgb(0x3d, 0x84, 0xf8),
selection: Color32::from_rgb(0x20, 0x4f, 0x9c),
selection_text: Color32::WHITE,
focus: Color32::from_rgb(0x82, 0xb2, 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,
motion: MOTION,
elevation: elevation(140),
stroke: STROKES,
}
}
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(0x12, 0x4f, 0xd6),
selection: Color32::from_rgb(0xc1, 0xd7, 0xfe),
selection_text: Color32::from_rgb(0x0c, 0x1f, 0x47),
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,
motion: MOTION,
elevation: elevation(40),
stroke: STROKES,
}
}
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_srgba_unmultiplied();
let accent = Color32::from_rgb(r, g, b);
let [l, c, h] = oklch_of(accent);
self.accent = accent;
self.text.on_accent = readable_on(accent);
if self.dark {
self.accent_hover = from_oklch(l + 0.07, c, h);
self.focus = from_oklch((l + 0.2).max(0.76), c.min(0.13), h);
self.selection = from_oklch(0.44, c.min(0.136), h);
self.selection_text = Color32::WHITE;
} else {
self.accent_hover = from_oklch(l - 0.06, c, h);
self.focus = accent;
self.selection = from_oklch(0.875, c.min(0.06), h);
self.selection_text = from_oklch(0.25, c.min(0.08), h);
}
let s = self.surface;
self.focus = push_from(self.focus, &[s.app, s.panel, s.raised, s.sunken], MIN_UI_CONTRAST);
self
}
pub fn high_contrast(mut self) -> Self {
let [lp, _, _] = oklch_of(self.text.primary);
let toward = |c: Color32, t: f32| {
let [l, ch, h] = oklch_of(c);
from_oklch(l + (lp - l) * t, ch, h)
};
self.text.secondary = toward(self.text.secondary, 0.5);
self.text.disabled = toward(self.text.disabled, 0.35);
let s = self.surface;
let surfaces = [s.app, s.panel, s.raised, s.sunken, s.row_alt, s.hover, s.pressed];
self.text.secondary = push_from(self.text.secondary, &surfaces, MIN_TEXT_CONTRAST);
self.separator = self.border;
self.border = self.border_strong;
let step = if self.dark { 0.15 } else { -0.15 };
let [l, ch, h] = oklch_of(self.border_strong);
self.border_strong = push_from(from_oklch(l + step, ch, h), &[s.app, s.panel, s.raised, s.sunken], MIN_UI_CONTRAST);
self.focus = push_from(self.focus, &[s.app, s.panel, s.raised, s.sunken], MIN_TEXT_CONTRAST);
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 interact_fill_animated(&self, ctx: &egui::Context, response: &egui::Response) -> Color32 {
state_color(ctx, response.id, self.interact_fill(response))
}
pub fn mix(&self, to: &Tokens, surface: f32, text: f32) -> Tokens {
let m = |a: Color32, b: Color32| mix_color(a, b, surface);
let tx = |a: Color32, b: Color32| mix_color(a, b, text);
let (a, b) = (&self.surface, &to.surface);
let shadow = |x: Shadow, y: Shadow| Shadow { color: m(x.color, y.color), ..y };
Tokens {
surface: Surfaces {
app: m(a.app, b.app),
panel: m(a.panel, b.panel),
raised: m(a.raised, b.raised),
sunken: m(a.sunken, b.sunken),
row_alt: m(a.row_alt, b.row_alt),
hover: m(a.hover, b.hover),
pressed: m(a.pressed, b.pressed),
},
text: TextColors {
primary: tx(self.text.primary, to.text.primary),
secondary: tx(self.text.secondary, to.text.secondary),
disabled: tx(self.text.disabled, to.text.disabled),
on_accent: tx(self.text.on_accent, to.text.on_accent),
},
status: StatusColors {
info: m(self.status.info, to.status.info),
success: m(self.status.success, to.status.success),
warning: m(self.status.warning, to.status.warning),
danger: m(self.status.danger, to.status.danger),
},
accent: m(self.accent, to.accent),
accent_hover: m(self.accent_hover, to.accent_hover),
selection: m(self.selection, to.selection),
selection_text: tx(self.selection_text, to.selection_text),
focus: m(self.focus, to.focus),
border: m(self.border, to.border),
border_strong: m(self.border_strong, to.border_strong),
separator: m(self.separator, to.separator),
elevation: Elevation {
popup: shadow(self.elevation.popup, to.elevation.popup),
window: shadow(self.elevation.window, to.elevation.window),
},
..*to
}
}
pub fn visuals(&self) -> egui::Visuals {
let mut v = if self.dark { egui::Visuals::dark() } else { egui::Visuals::light() };
let s = &self.surface;
let hair = self.stroke.hairline;
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(hair, self.selection_text);
v.window_stroke = Stroke::new(hair, self.border);
v.window_corner_radius = CornerRadius::same(self.radius.lg);
v.menu_corner_radius = CornerRadius::same(self.radius.md);
v.popup_shadow = self.elevation.popup;
v.window_shadow = self.elevation.window;
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(hair, self.text.primary),
);
v.widgets.noninteractive.bg_stroke = Stroke::new(hair, self.separator);
v.widgets.noninteractive.fg_stroke = Stroke::new(hair, self.text.primary);
v.widgets.inactive.bg_stroke = Stroke::new(hair, self.border);
v.widgets.hovered.bg_stroke = Stroke::new(hair, self.border_strong);
v.widgets.active.bg_stroke = Stroke::new(hair, self.focus);
v.widgets.open.bg_stroke = Stroke::new(hair, self.border_strong);
v.text_cursor.stroke = Stroke::new(self.stroke.focus, 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 delay = self.motion.tooltip_delay;
style.interaction.tooltip_delay = if delay.is_finite() { delay.clamp(0.0, 10.0) } else { 0.5 };
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();
}
}
pub fn state_color(ctx: &egui::Context, id: egui::Id, target: Color32) -> Color32 {
let id = id.with("ashlar_ui::state_color");
if ctx.global_style().animation_time <= 0.0 {
crate::motion::set(ctx, id, rgba(target));
return target;
}
crate::motion::color(ctx, id, target, motion_tokens::INSTANT.enter)
}
fn mix_color(a: Color32, b: Color32, t: f32) -> Color32 {
if t <= 0.0 {
return a;
}
if t >= 1.0 {
return b;
}
let [r, g, bl, al] = color::mix(rgba(a), rgba(b), t).to_srgb8();
Color32::from_rgba_unmultiplied(r, g, bl, al)
}
pub fn contrast_ratio(fg: Color32, bg: Color32) -> f32 {
color::contrast_ratio(rgba(fg), rgba(bg))
}
fn rgba(c: Color32) -> Rgba {
let [r, g, b, a] = c.to_srgba_unmultiplied();
Rgba::from_srgb8(r, g, b, a)
}
fn oklch_of(c: Color32) -> [f32; 3] {
color::to_oklch(rgba(c))
}
fn from_oklch(l: f32, c: f32, h: f32) -> Color32 {
let [r, g, b, _] = color::oklch(l, c, h).to_srgb8();
Color32::from_rgb(r, g, b)
}
fn readable_on(bg: Color32) -> Color32 {
[Color32::WHITE, Color32::from_rgb(0x18, 0x18, 0x1b)]
.into_iter()
.find(|&ink| contrast_ratio(ink, bg) >= MIN_TEXT_CONTRAST)
.unwrap_or(Color32::BLACK)
}
fn push_from(c: Color32, surfaces: &[Color32], min: f32) -> Color32 {
let worst = |c: Color32| surfaces.iter().map(|&s| contrast_ratio(c, s)).fold(f32::INFINITY, f32::min);
if worst(c) >= min || surfaces.is_empty() {
return c;
}
let [l, ch, h] = oklch_of(c);
let mean = surfaces.iter().map(|&s| oklch_of(s)[0]).sum::<f32>() / surfaces.len() as f32;
let dir = if l >= mean { 0.02 } else { -0.02 };
let mut best = c;
for i in 1..=50 {
let next = from_oklch(l + dir * i as f32, ch, h);
best = next;
if worst(next) >= min {
break;
}
}
best
}
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());
}
const SWITCH_SURFACE: ashlar_motion::Spring = motion_tokens::REFLOW.enter;
pub fn set_mode_animated(ctx: &egui::Context, mode: ThemeMode) {
let from = current(ctx);
let restore = switch::finish(ctx);
if let Some((theme, visuals)) = restore {
ctx.style_mut_of(theme, |s| s.visuals = visuals);
}
set_mode(ctx, mode);
let theme = ctx.theme();
if from == of(ctx, theme) {
return;
}
switch::start(ctx, from, theme);
}
mod switch {
use egui::{Color32, Context, Id, Theme, Ui, Visuals};
use super::{SWITCH_SURFACE, Tokens, contrast_ratio, of};
use crate::motion;
use ashlar_motion::{Class, Spring};
pub(super) fn mix_id() -> Id {
Id::new("ashlar_ui::tokens::switch_mix")
}
fn progress_id() -> Id {
Id::new("ashlar_ui::tokens::switch")
}
struct Running {
from: Tokens,
theme: Theme,
saved: Visuals,
}
#[derive(Default)]
struct Plugin {
running: Option<Running>,
}
impl egui::Plugin for Plugin {
fn debug_name(&self) -> &'static str {
"ashlar_ui::tokens::switch"
}
fn on_begin_pass(&mut self, ui: &mut Ui) {
let Some(r) = &self.running else { return };
let ctx = ui.ctx().clone();
if ctx.theme() != r.theme {
if let Some(r) = self.running.take() {
end(&ctx, r);
}
return;
}
let (surface, text) = progress(&ctx);
if surface >= 1.0 && text >= 1.0 {
if let Some(r) = self.running.take() {
end(&ctx, r);
}
return;
}
let mix = blend(&r.from, &of(&ctx, r.theme), surface, text);
ctx.style_mut_of(r.theme, |s| s.visuals = mix.visuals());
ctx.data_mut(|d| d.insert_temp(mix_id(), mix));
}
}
const MIN_SWITCH_CONTRAST: f32 = 3.0;
fn blend(from: &Tokens, to: &Tokens, surface: f32, text: f32) -> Tokens {
let mut mix = from.mix(to, surface, text);
let panel = mix.surface.panel;
let readable = |c: Color32, a: Color32, b: Color32| {
if contrast_ratio(c, panel) >= MIN_SWITCH_CONTRAST {
c
} else if contrast_ratio(b, panel) >= contrast_ratio(a, panel) {
b
} else {
a
}
};
mix.text.primary = readable(mix.text.primary, from.text.primary, to.text.primary);
mix.text.secondary = readable(mix.text.secondary, from.text.secondary, to.text.secondary);
mix
}
fn end(ctx: &Context, r: Running) {
ctx.style_mut_of(r.theme, |s| s.visuals = r.saved);
ctx.data_mut(|d| d.remove::<Tokens>(mix_id()));
}
fn progress(ctx: &Context) -> (f32, f32) {
let text_spring = Spring::new(SWITCH_SURFACE.duration() * 0.5, 0.0);
let surface = motion::value(ctx, progress_id().with("surface"), 1.0_f32, Class::Opacity, SWITCH_SURFACE);
let text = motion::value(ctx, progress_id().with("text"), 1.0_f32, Class::Opacity, text_spring);
(surface, text)
}
pub(super) fn finish(ctx: &Context) -> Option<(Theme, Visuals)> {
let r = ctx.with_plugin(|p: &mut Plugin| p.running.take()).flatten()?;
ctx.data_mut(|d| d.remove::<Tokens>(mix_id()));
Some((r.theme, r.saved))
}
pub(super) fn start(ctx: &Context, from: Tokens, theme: Theme) {
let saved = ctx.style_of(theme).visuals.clone();
let running = Running { from, theme, saved };
motion::set(ctx, progress_id().with("surface"), 0.0_f32);
motion::set(ctx, progress_id().with("text"), 0.0_f32);
let mix = blend(&from, &of(ctx, theme), 0.0, 0.0);
ctx.style_mut_of(theme, |s| s.visuals = mix.visuals());
ctx.data_mut(|d| d.insert_temp(mix_id(), mix));
if ctx.with_plugin(|_: &mut Plugin| ()).is_none() {
ctx.add_plugin(Plugin::default());
}
ctx.with_plugin(|p: &mut Plugin| p.running = Some(running));
crate::wake::wake(ctx, crate::wake::Reason::ANIMATION);
}
}
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 of(ctx: &egui::Context, theme: Theme) -> Tokens {
ctx.data(|d| d.get_temp::<Tokens>(tokens_id(theme))).unwrap_or(Tokens::for_theme(theme))
}
pub fn apply_colors(ctx: &egui::Context, tokens: &Tokens) {
let theme = tokens.theme();
ctx.style_mut_of(theme, |style| style.visuals = tokens.visuals());
ctx.data_mut(|d| d.insert_temp(tokens_id(theme), *tokens));
}
pub fn current(ctx: &egui::Context) -> Tokens {
let theme = ctx.theme();
if let Some(mix) = ctx.data(|d| d.get_temp::<Tokens>(switch::mix_id())).filter(|m| m.theme() == theme) {
return mix;
}
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!(from_oklch(f32::NAN, f32::NAN, f32::NAN), Color32::BLACK);
assert_eq!(from_oklch(2.0, 0.0, 0.0), Color32::WHITE);
assert!(Tokens::dark().with_accent(Color32::from_rgba_unmultiplied(200, 40, 120, 10)).accent.is_opaque());
}
fn named(t: &Tokens) -> Vec<(&'static str, Color32)> {
let s = t.surface;
vec![
("surface.app", s.app),
("surface.panel", s.panel),
("surface.raised", s.raised),
("surface.sunken", s.sunken),
("surface.row_alt", s.row_alt),
("surface.hover", s.hover),
("surface.pressed", s.pressed),
("text.primary", t.text.primary),
("text.secondary", t.text.secondary),
("text.disabled", t.text.disabled),
("text.on_accent", t.text.on_accent),
("status.info", t.status.info),
("status.success", t.status.success),
("status.warning", t.status.warning),
("status.danger", t.status.danger),
("accent", t.accent),
("accent_hover", t.accent_hover),
("selection", t.selection),
("selection_text", t.selection_text),
("focus", t.focus),
("border", t.border),
("border_strong", t.border_strong),
("separator", t.separator),
]
}
fn max_channel_diff(a: Color32, b: Color32) -> u8 {
[a.r().abs_diff(b.r()), a.g().abs_diff(b.g()), a.b().abs_diff(b.b())].into_iter().max().unwrap_or(0)
}
fn surfaces(t: &Tokens) -> [Color32; 7] {
let s = t.surface;
[s.app, s.panel, s.raised, s.sunken, s.row_alt, s.hover, s.pressed]
}
fn random_accents() -> Vec<Color32> {
let mut seed: u32 = 0x9e37_79b9;
(0..64)
.map(|_| {
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
(seed >> 24) as u8
};
Color32::from_rgb(next(), next(), next())
})
.collect()
}
#[test]
fn every_hex_is_its_documented_oklch() {
let mut seen = 0;
for (theme, t) in [("dark", Tokens::dark()), ("light", Tokens::light())] {
for (name, hex) in named(&t) {
let key = format!("{theme}.{name}");
let Some(&(_, l, c, h)) = TOKEN_SPEC.iter().find(|(n, ..)| *n == key) else {
panic!("{key} has no OKLCH spec");
};
let from_spec = from_oklch(l, c, h);
assert!(max_channel_diff(from_spec, hex) <= 1, "{key}: spec gives {from_spec:?}, hex is {hex:?}");
seen += 1;
}
}
assert_eq!(seen, TOKEN_SPEC.len(), "the spec lists a colour no token has");
}
#[test]
fn built_in_accent_states_are_the_oklch_derivation() {
for t in [Tokens::dark(), Tokens::light()] {
let d = t.with_accent(t.accent);
for (name, want, got) in [
("accent_hover", t.accent_hover, d.accent_hover),
("focus", t.focus, d.focus),
("selection", t.selection, d.selection),
("selection_text", t.selection_text, d.selection_text),
("on_accent", t.text.on_accent, d.text.on_accent),
] {
assert!(max_channel_diff(want, got) <= 1, "dark={} {name}: {want:?} vs derived {got:?}", t.dark);
}
}
}
fn assert_contrast_gate(t: &Tokens, label: &str) {
for bg in surfaces(t) {
let p = contrast_ratio(t.text.primary, bg);
assert!(p >= MIN_TEXT_CONTRAST, "{label}: primary on {bg:?} is {p:.2}");
let s = contrast_ratio(t.text.secondary, bg);
assert!(s >= MIN_UI_CONTRAST, "{label}: secondary on {bg:?} is {s:.2}");
}
let a = contrast_ratio(t.text.on_accent, t.accent);
assert!(a >= MIN_TEXT_CONTRAST, "{label}: on_accent on {:?} is {a:.2}", t.accent);
let sel = contrast_ratio(t.selection_text, t.selection);
assert!(sel >= MIN_TEXT_CONTRAST, "{label}: selection text is {sel:.2}");
let s = t.surface;
for bg in [s.app, s.panel, s.raised, s.sunken] {
let f = contrast_ratio(t.focus, bg);
assert!(f >= MIN_UI_CONTRAST, "{label}: focus on {bg:?} is {f:.2}");
}
}
#[test]
fn contrast_gate_holds_for_both_themes_and_random_accents() {
for t in [Tokens::dark(), Tokens::light()] {
assert_contrast_gate(&t, &format!("dark={}", t.dark));
assert_contrast_gate(&t.high_contrast(), &format!("high contrast dark={}", t.dark));
for accent in random_accents() {
let d = t.with_accent(accent);
assert_contrast_gate(&d, &format!("dark={} accent {accent:?}", t.dark));
assert_contrast_gate(&d.high_contrast(), &format!("high contrast dark={} accent {accent:?}", t.dark));
}
}
let yellow = Tokens::dark().with_accent(Color32::from_rgb(255, 230, 0));
assert_ne!(yellow.text.on_accent, Color32::WHITE);
}
#[test]
fn derived_hover_keeps_the_accent_hue() {
let mut checked = 0;
for base in [Tokens::dark(), Tokens::light()] {
for accent in random_accents().into_iter().chain([base.accent, Color32::from_rgb(200, 40, 120)]) {
let [_, c, h] = oklch_of(accent);
if c < 0.04 {
continue;
}
let t = base.with_accent(accent);
let [_, hc, hh] = oklch_of(t.accent_hover);
if hc < 0.04 {
continue; }
let diff = (hh - h + 540.0).rem_euclid(360.0) - 180.0;
assert!(diff.abs() <= 2.0, "dark={} accent {accent:?}: hover hue {hh:.1} vs {h:.1}", base.dark);
checked += 1;
}
}
assert!(checked > 64, "only {checked} accents were chromatic enough to check");
}
#[test]
fn high_contrast_strengthens_text_and_borders() {
for t in [Tokens::dark(), Tokens::light()] {
let hc = t.high_contrast();
for bg in surfaces(&t) {
assert!(contrast_ratio(hc.text.secondary, bg) >= MIN_TEXT_CONTRAST);
assert!(contrast_ratio(hc.text.secondary, bg) > contrast_ratio(t.text.secondary, bg));
}
assert!(contrast_ratio(hc.border_strong, t.surface.panel) >= MIN_UI_CONTRAST);
assert!(contrast_ratio(hc.border, t.surface.panel) > contrast_ratio(t.border, t.surface.panel));
assert_eq!(hc.text.primary, t.text.primary);
assert_eq!(hc.accent, t.accent);
}
}
#[test]
fn motion_elevation_and_stroke_tokens_reach_egui() {
let t = Tokens::dark();
assert_eq!(t.motion.press, ashlar_motion::tokens::INSTANT);
assert_eq!(Some(t.motion.surface), ashlar_motion::tokens::by_name("glide"));
let v = t.visuals();
assert_eq!(v.popup_shadow, t.elevation.popup);
assert_eq!(v.window_shadow, t.elevation.window);
let thick = Tokens { stroke: Strokes { hairline: 2.0, focus: 3.0 }, ..t }.visuals();
assert_eq!(thick.window_stroke.width, 2.0);
assert_eq!(thick.text_cursor.stroke.width, 3.0);
assert!(Tokens::light().elevation.popup.color.a() < t.elevation.popup.color.a());
}
#[test]
fn state_color_eases_settles_and_snaps_without_animation_time() {
let ctx = egui::Context::default();
let id = egui::Id::new("button");
let requests = |ctx: &egui::Context| crate::wake::Scheduler::of(ctx).stats().requests_for(crate::wake::Reason::ANIMATION);
let pass = |t: f64, target: Color32| {
let input = egui::RawInput { time: Some(t), predicted_dt: 1.0 / 60.0, ..Default::default() };
let mut out = Color32::TRANSPARENT;
ctx.run_ui(input, |ui| out = state_color(ui.ctx(), id, target)).textures_delta.clear();
out
};
let (rest, hover) = (Color32::from_rgb(30, 30, 30), Color32::from_rgb(90, 90, 90));
assert_eq!(pass(0.0, rest), rest);
assert_eq!(requests(&ctx), 0);
pass(1.0 / 60.0, hover);
let mid = pass(2.0 / 60.0, hover);
assert!(mid.r() > rest.r() && mid.r() < hover.r(), "{mid:?}");
let mut t = 2.0 / 60.0;
let mut last = mid;
for _ in 0..20 {
t += 1.0 / 60.0;
last = pass(t, hover);
}
assert_eq!(last, hover);
let flat = requests(&ctx);
for _ in 0..100 {
t += 1.0 / 60.0;
pass(t, hover);
}
assert_eq!(requests(&ctx), flat);
crate::motion::set_prefs(&ctx, ashlar_motion::MotionPrefs::REDUCED);
assert_eq!(pass(t + 1.0 / 60.0, rest), rest);
}
#[test]
fn theme_switch_crossfades_text_first_keeps_contrast_and_settles() {
let ctx = egui::Context::default();
let requests = |ctx: &egui::Context| crate::wake::Scheduler::of(ctx).stats().requests_for(crate::wake::Reason::ANIMATION);
let dt = 1.0 / 60.0;
let mut t = 0.0;
let mut pass = |f: &mut dyn FnMut(&egui::Context)| {
let input = egui::RawInput { time: Some(t), predicted_dt: dt as f32, ..Default::default() };
ctx.run_ui(input, |ui| f(ui.ctx())).textures_delta.clear();
t += dt;
};
pass(&mut |c| set_mode(c, ThemeMode::Light));
pass(&mut |_| {});
let light = current(&ctx);
let dark = of(&ctx, Theme::Dark);
let dark_visuals = ctx.style_of(Theme::Dark).visuals.clone();
pass(&mut |c| set_mode_animated(c, ThemeMode::Dark));
let mut blended = false;
let mut text_led = false;
let mut worst = f32::INFINITY;
for _ in 0..30 {
let mut now = light;
pass(&mut |c| now = current(c));
if now != light && now != dark {
blended = true;
}
if now.text.primary == dark.text.primary && now.surface.panel != dark.surface.panel {
text_led = true;
}
worst = worst.min(contrast_ratio(now.text.primary, now.surface.panel));
}
assert!(blended, "no blended frame");
assert!(text_led, "text did not lead its background");
assert!(worst >= 3.0, "primary text contrast fell to {worst}");
for _ in 0..30 {
pass(&mut |_| {});
}
assert_eq!(current(&ctx), dark);
assert_eq!(ctx.style_of(Theme::Dark).visuals, dark_visuals);
let flat = requests(&ctx);
for _ in 0..100 {
pass(&mut |_| {});
}
assert_eq!(requests(&ctx), flat);
}
}