use eframe::egui::{self, Color32, Rounding, Stroke, Style, Visuals};
pub mod spacing {
pub const XS: f32 = 4.0;
pub const SM: f32 = 8.0;
pub const MD: f32 = 12.0;
pub const LG: f32 = 16.0;
pub const XL: f32 = 24.0;
pub const XXL: f32 = 32.0;
}
pub mod rounding {
pub const CARD: f32 = 8.0;
pub const BUTTON: f32 = 4.0;
pub const SMALL: f32 = 2.0;
pub const NONE: f32 = 0.0;
}
pub mod shadow {
use super::Color32;
use eframe::egui::Shadow;
const SHADOW_WARM: Color32 = Color32::from_rgba_premultiplied(40, 30, 20, 255);
pub fn subtle() -> Shadow {
Shadow {
offset: [0.0, 1.0].into(),
blur: 3.0,
spread: 0.0,
color: Color32::from_rgba_premultiplied(
SHADOW_WARM.r(),
SHADOW_WARM.g(),
SHADOW_WARM.b(),
12,
),
}
}
pub fn medium() -> Shadow {
Shadow {
offset: [0.0, 2.0].into(),
blur: 8.0,
spread: 0.0,
color: Color32::from_rgba_premultiplied(
SHADOW_WARM.r(),
SHADOW_WARM.g(),
SHADOW_WARM.b(),
18,
),
}
}
pub fn elevated() -> Shadow {
Shadow {
offset: [0.0, 4.0].into(),
blur: 16.0,
spread: 0.0,
color: Color32::from_rgba_premultiplied(
SHADOW_WARM.r(),
SHADOW_WARM.g(),
SHADOW_WARM.b(),
24,
),
}
}
#[cfg(test)]
pub fn shadow_warm_color() -> Color32 {
SHADOW_WARM
}
}
pub mod colors {
use super::Color32;
pub const BACKGROUND: Color32 = Color32::from_rgb(250, 249, 247);
pub const SURFACE: Color32 = Color32::from_rgb(255, 255, 255);
pub const SURFACE_ELEVATED: Color32 = Color32::from_rgb(255, 255, 255);
pub const SURFACE_HOVER: Color32 = Color32::from_rgb(245, 243, 239);
pub const SURFACE_SELECTED: Color32 = Color32::from_rgb(238, 235, 229);
pub const TEXT_PRIMARY: Color32 = Color32::from_rgb(28, 28, 30);
pub const TEXT_SECONDARY: Color32 = Color32::from_rgb(99, 99, 102);
pub const TEXT_MUTED: Color32 = Color32::from_rgb(142, 142, 147);
pub const TEXT_DISABLED: Color32 = Color32::from_rgb(174, 174, 178);
pub const BORDER: Color32 = Color32::from_rgb(232, 229, 222);
pub const BORDER_FOCUSED: Color32 = Color32::from_rgb(205, 200, 190);
pub const SEPARATOR: Color32 = Color32::from_rgb(232, 229, 222);
pub const ACCENT: Color32 = Color32::from_rgb(0, 122, 255);
pub const ACCENT_HOVER: Color32 = Color32::from_rgb(0, 111, 230);
pub const ACCENT_ACTIVE: Color32 = Color32::from_rgb(0, 100, 210);
pub const ACCENT_SUBTLE: Color32 = Color32::from_rgb(230, 244, 255);
pub const STATUS_RUNNING: Color32 = Color32::from_rgb(0, 149, 255);
pub const STATUS_SUCCESS: Color32 = Color32::from_rgb(52, 199, 89);
pub const STATUS_WARNING: Color32 = Color32::from_rgb(255, 149, 0);
pub const STATUS_ERROR: Color32 = Color32::from_rgb(255, 59, 48);
pub const STATUS_IDLE: Color32 = Color32::from_rgb(142, 142, 147);
pub const STATUS_CORRECTING: Color32 = Color32::from_rgb(255, 94, 58);
pub const STATUS_RUNNING_BG: Color32 = Color32::from_rgb(230, 244, 255);
pub const STATUS_SUCCESS_BG: Color32 = Color32::from_rgb(232, 250, 238);
pub const STATUS_WARNING_BG: Color32 = Color32::from_rgb(255, 244, 230);
pub const STATUS_ERROR_BG: Color32 = Color32::from_rgb(255, 235, 234);
pub const STATUS_IDLE_BG: Color32 = Color32::from_rgb(245, 243, 239);
pub const STATUS_CORRECTING_BG: Color32 = Color32::from_rgb(255, 237, 230);
}
pub fn configure_visuals() -> Visuals {
let mut visuals = Visuals::light();
visuals.window_fill = colors::SURFACE;
visuals.panel_fill = colors::BACKGROUND;
visuals.faint_bg_color = colors::SURFACE_HOVER;
visuals.extreme_bg_color = colors::SURFACE;
visuals.code_bg_color = Color32::from_rgb(248, 246, 242);
visuals.selection.bg_fill = colors::ACCENT_SUBTLE;
visuals.selection.stroke = Stroke::new(1.0, colors::ACCENT);
visuals.hyperlink_color = colors::ACCENT;
visuals.window_shadow = shadow::elevated();
visuals.popup_shadow = shadow::medium();
visuals.window_stroke = Stroke::new(1.0, colors::BORDER);
visuals.window_rounding = Rounding::same(rounding::CARD);
visuals.menu_rounding = Rounding::same(rounding::BUTTON);
visuals.text_cursor.stroke = Stroke::new(2.0, colors::ACCENT);
configure_widget_visuals(&mut visuals);
visuals
}
fn configure_widget_visuals(visuals: &mut Visuals) {
visuals.widgets.noninteractive.bg_fill = colors::SURFACE;
visuals.widgets.noninteractive.weak_bg_fill = colors::SURFACE_HOVER;
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, colors::BORDER);
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, colors::TEXT_PRIMARY);
visuals.widgets.noninteractive.rounding = Rounding::same(rounding::BUTTON);
visuals.widgets.inactive.bg_fill = colors::SURFACE;
visuals.widgets.inactive.weak_bg_fill = colors::SURFACE;
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, colors::BORDER);
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, colors::TEXT_PRIMARY);
visuals.widgets.inactive.rounding = Rounding::same(rounding::BUTTON);
visuals.widgets.hovered.bg_fill = colors::SURFACE_HOVER;
visuals.widgets.hovered.weak_bg_fill = colors::SURFACE_HOVER;
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, colors::BORDER_FOCUSED);
visuals.widgets.hovered.fg_stroke = Stroke::new(1.5, colors::TEXT_PRIMARY);
visuals.widgets.hovered.rounding = Rounding::same(rounding::BUTTON);
visuals.widgets.active.bg_fill = colors::SURFACE_SELECTED;
visuals.widgets.active.weak_bg_fill = colors::SURFACE_SELECTED;
visuals.widgets.active.bg_stroke = Stroke::new(1.0, colors::ACCENT);
visuals.widgets.active.fg_stroke = Stroke::new(2.0, colors::TEXT_PRIMARY);
visuals.widgets.active.rounding = Rounding::same(rounding::BUTTON);
visuals.widgets.open.bg_fill = colors::SURFACE;
visuals.widgets.open.weak_bg_fill = colors::SURFACE_HOVER;
visuals.widgets.open.bg_stroke = Stroke::new(1.0, colors::ACCENT);
visuals.widgets.open.fg_stroke = Stroke::new(1.0, colors::TEXT_PRIMARY);
visuals.widgets.open.rounding = Rounding::same(rounding::BUTTON);
}
pub fn configure_style() -> Style {
let default_style = Style::default();
let mut style_spacing = default_style.spacing.clone();
style_spacing.item_spacing = egui::vec2(spacing::SM, spacing::XS);
style_spacing.window_margin = egui::Margin::same(spacing::LG);
style_spacing.button_padding = egui::vec2(spacing::MD, 6.0);
style_spacing.menu_margin = egui::Margin::same(spacing::SM);
style_spacing.indent = spacing::LG;
style_spacing.scroll = egui::style::ScrollStyle {
floating: true,
bar_width: spacing::SM,
floating_allocated_width: 0.0,
bar_inner_margin: spacing::XS,
bar_outer_margin: spacing::XS,
..Default::default()
};
style_spacing.combo_width = 100.0;
let mut interaction = default_style.interaction.clone();
interaction.selectable_labels = true;
interaction.multi_widget_text_select = true;
Style {
visuals: configure_visuals(),
spacing: style_spacing,
interaction,
animation_time: 0.1,
..Default::default()
}
}
pub fn init(ctx: &egui::Context) {
ctx.set_style(configure_style());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spacing_scale() {
assert!(spacing::XS < spacing::SM);
assert!(spacing::SM < spacing::MD);
assert!(spacing::MD < spacing::LG);
assert!(spacing::LG < spacing::XL);
assert!(spacing::XL < spacing::XXL);
}
#[test]
fn test_shadows() {
let subtle = shadow::subtle();
let medium = shadow::medium();
let elevated = shadow::elevated();
assert!(subtle.color.a() <= 15);
assert!(medium.color.a() <= 20);
assert!(elevated.color.a() <= 30);
assert!(subtle.blur < medium.blur);
assert!(medium.blur < elevated.blur);
let warm = shadow::shadow_warm_color();
assert!(warm.r() > warm.g() && warm.g() > warm.b());
}
#[test]
fn test_text_contrast() {
let text_lum = colors::TEXT_PRIMARY.r() as u32
+ colors::TEXT_PRIMARY.g() as u32
+ colors::TEXT_PRIMARY.b() as u32;
let bg_lum = colors::BACKGROUND.r() as u32
+ colors::BACKGROUND.g() as u32
+ colors::BACKGROUND.b() as u32;
let surface_lum =
colors::SURFACE.r() as u32 + colors::SURFACE.g() as u32 + colors::SURFACE.b() as u32;
assert!(
bg_lum - text_lum > 400,
"Need contrast > 400 against background"
);
assert!(
surface_lum - text_lum > 500,
"Need contrast > 500 against surface"
);
}
#[test]
fn test_configure_visuals() {
let visuals = configure_visuals();
assert!(!visuals.dark_mode);
assert_eq!(visuals.window_fill, colors::SURFACE);
assert_eq!(visuals.panel_fill, colors::BACKGROUND);
assert_eq!(visuals.window_rounding, Rounding::same(rounding::CARD));
assert_eq!(visuals.widgets.hovered.bg_fill, colors::SURFACE_HOVER);
assert_eq!(visuals.widgets.active.bg_fill, colors::SURFACE_SELECTED);
assert_eq!(visuals.selection.bg_fill, colors::ACCENT_SUBTLE);
}
#[test]
fn test_configure_style() {
let style = configure_style();
assert!(style.animation_time > 0.0 && style.animation_time <= 0.2);
assert!(style.spacing.scroll.floating);
assert_eq!(style.visuals.window_fill, colors::SURFACE);
}
#[test]
fn test_warm_color_palette() {
let warm_colors = [
("BACKGROUND", colors::BACKGROUND),
("SURFACE_HOVER", colors::SURFACE_HOVER),
("SURFACE_SELECTED", colors::SURFACE_SELECTED),
("BORDER", colors::BORDER),
("SEPARATOR", colors::SEPARATOR),
];
for (name, color) in warm_colors {
assert!(
color.r() >= color.g() && color.g() >= color.b(),
"{} should have warm tones (R >= G >= B), got RGB({}, {}, {})",
name,
color.r(),
color.g(),
color.b()
);
}
assert_eq!(colors::BACKGROUND, Color32::from_rgb(250, 249, 247));
}
#[test]
fn test_status_colors_distinct() {
let status_colors = [
colors::STATUS_RUNNING,
colors::STATUS_SUCCESS,
colors::STATUS_WARNING,
colors::STATUS_ERROR,
colors::STATUS_IDLE,
];
for i in 0..status_colors.len() {
for j in (i + 1)..status_colors.len() {
assert_ne!(status_colors[i], status_colors[j]);
}
}
}
}