use egui::{Color32, CornerRadius, Shadow, Stroke, Visuals};
use makeover_geometry::{Density, Gap, Step, Surface, Text};
use crate::Palette;
const FOCUS_STROKE: f32 = 1.5;
#[must_use]
pub fn is_light(palette: &Palette) -> bool {
let channel = |c: u8| {
let c = f32::from(c) / 255.0;
if c <= 0.040_45 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
};
let page = palette.page;
let luminance =
0.2126 * channel(page.r()) + 0.7152 * channel(page.g()) + 0.0722 * channel(page.b());
luminance > 0.5
}
fn mix(from: Color32, to: Color32, at: f32) -> Color32 {
let channel = |from: u8, to: u8| {
let (from, to) = (f32::from(from), f32::from(to));
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "a mix of two channel values is inside 0..=255 by construction"
)]
let mixed = (from + (to - from) * at).round() as u8;
mixed
};
Color32::from_rgb(
channel(from.r(), to.r()),
channel(from.g(), to.g()),
channel(from.b(), to.b()),
)
}
#[must_use]
pub fn visuals(palette: &Palette, radius: CornerRadius) -> Visuals {
let mut visuals = if is_light(palette) {
Visuals::light()
} else {
Visuals::dark()
};
visuals.panel_fill = palette.overlay;
visuals.window_fill = palette.overlay;
visuals.extreme_bg_color = palette.well;
visuals.faint_bg_color = mix(palette.page, palette.overlay, 0.3);
visuals.selection.bg_fill = mix(palette.page, palette.action, 0.3);
visuals.selection.stroke = Stroke::new(FOCUS_STROKE, palette.action);
visuals.widgets.noninteractive.bg_fill = palette.overlay;
visuals.widgets.inactive.bg_fill = mix(palette.overlay, palette.sunken, 0.3);
visuals.widgets.hovered.bg_fill = palette.sunken;
visuals.widgets.active.bg_fill = mix(palette.sunken, palette.content, 0.15);
visuals.widgets.noninteractive.weak_bg_fill = visuals.widgets.noninteractive.bg_fill;
visuals.widgets.inactive.weak_bg_fill = visuals.widgets.inactive.bg_fill;
visuals.widgets.hovered.weak_bg_fill = visuals.widgets.hovered.bg_fill;
visuals.widgets.active.weak_bg_fill = visuals.widgets.active.bg_fill;
visuals.widgets.open.weak_bg_fill = visuals.widgets.inactive.bg_fill;
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, palette.content_secondary);
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, palette.content);
visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, palette.content);
visuals.widgets.active.fg_stroke = Stroke::new(1.0, palette.content);
visuals.widgets.open.fg_stroke = Stroke::new(1.0, palette.content);
visuals.window_stroke = Stroke::new(1.0, palette.border);
visuals.widgets.inactive.bg_stroke =
Stroke::new(0.5, mix(palette.border, palette.overlay, 0.3));
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, palette.border);
visuals.widgets.active.bg_stroke = Stroke::new(1.0, palette.action);
visuals.widgets.noninteractive.bg_stroke =
Stroke::new(0.5, mix(palette.border, palette.overlay, 0.4));
visuals.widgets.noninteractive.corner_radius = radius;
visuals.widgets.inactive.corner_radius = radius;
visuals.widgets.hovered.corner_radius = radius;
visuals.widgets.active.corner_radius = radius;
visuals.widgets.open.corner_radius = radius;
visuals.window_corner_radius = radius;
visuals.menu_corner_radius = radius;
let shadow = Shadow {
offset: [2, 2],
blur: 0,
spread: 0,
color: palette.elevation,
};
visuals.window_shadow = shadow;
visuals.popup_shadow = shadow;
visuals.widgets.hovered.expansion = 0.0;
visuals.widgets.active.expansion = 0.0;
visuals
}
const TARGET: f32 = 24.0;
#[must_use]
pub fn spacing(surface: &Surface, density: Density) -> egui::style::Spacing {
#[expect(
clippy::cast_precision_loss,
reason = "a gap is a small whole number of points; the scale tops out at 32"
)]
let gap = |g: Gap| surface.gap(g, density) as f32;
let default = egui::style::Spacing::default();
egui::style::Spacing {
item_spacing: egui::vec2(gap(Gap::Peer), gap(Gap::Bound)),
button_padding: egui::vec2(gap(Gap::Peer), surface.resolve(Step::Hair.ratio())),
window_margin: egui::vec2(gap(Gap::Group), gap(Gap::Group)).into(),
indent: gap(Gap::Pane),
interact_size: egui::vec2(default.interact_size.x, TARGET),
..default
}
}
#[must_use]
pub fn text_styles(surface: &Surface) -> std::collections::BTreeMap<egui::TextStyle, egui::FontId> {
use egui::{FontFamily, FontId, TextStyle};
let size = |role: Text| surface.resolve(role.ratio());
[
(
TextStyle::Small,
FontId::new(size(Text::Fine), FontFamily::Proportional),
),
(
TextStyle::Body,
FontId::new(size(Text::Body), FontFamily::Proportional),
),
(
TextStyle::Button,
FontId::new(size(Text::Body), FontFamily::Proportional),
),
(
TextStyle::Monospace,
FontId::new(size(Text::Note), FontFamily::Monospace),
),
(
TextStyle::Heading,
FontId::new(size(Text::Head), FontFamily::Proportional),
),
]
.into()
}
#[must_use]
pub fn style(
palette: &Palette,
surface: &Surface,
density: Density,
radius: CornerRadius,
) -> egui::Style {
egui::Style {
visuals: visuals(palette, radius),
spacing: spacing(surface, density),
text_styles: text_styles(surface),
..egui::Style::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn palette(page: Color32, content: Color32) -> Palette {
Palette {
page,
raised: Color32::from_rgb(2, 2, 2),
overlay: Color32::from_rgb(3, 3, 3),
well: Color32::from_rgb(4, 4, 4),
sunken: Color32::from_rgb(5, 5, 5),
bevel_light: Color32::from_rgb(6, 6, 6),
bevel_dark: Color32::from_rgb(7, 7, 7),
elevation: Color32::from_rgb(8, 8, 8),
content,
content_secondary: Color32::from_rgb(10, 10, 10),
content_muted: Color32::from_rgb(11, 11, 11),
action: Color32::from_rgb(12, 12, 12),
content_on_action: Color32::from_rgb(250, 250, 250),
danger: Color32::from_rgb(13, 13, 13),
success: Color32::from_rgb(14, 14, 14),
warning: Color32::from_rgb(15, 15, 15),
info: Color32::from_rgb(16, 16, 16),
border: Color32::from_rgb(17, 17, 17),
info_surface: Color32::from_rgb(18, 18, 18),
success_surface: Color32::from_rgb(19, 19, 19),
warning_surface: Color32::from_rgb(20, 20, 20),
danger_surface: Color32::from_rgb(21, 21, 21),
row_stripe: Color32::from_rgb(22, 22, 22),
row_hover: Color32::from_rgb(23, 23, 23),
row_rule: Color32::from_rgb(24, 24, 24),
row_selected: Color32::from_rgb(25, 25, 25),
}
}
fn light() -> Palette {
palette(Color32::from_rgb(200, 200, 200), Color32::BLACK)
}
fn dark() -> Palette {
palette(
Color32::from_rgb(26, 27, 38),
Color32::from_rgb(192, 202, 245),
)
}
fn surface() -> Surface {
Surface {
base: f32::from(makeover_geometry::DEFAULT_BASE_PX),
quantum: 1.0,
}
}
#[test]
fn a_light_page_starts_from_the_light_preset_and_a_dark_one_does_not() {
assert!(is_light(&light()));
assert!(!is_light(&dark()));
assert!(!visuals(&light(), CornerRadius::ZERO).dark_mode);
assert!(visuals(&dark(), CornerRadius::ZERO).dark_mode);
}
#[test]
fn a_saturated_page_is_judged_by_luminance_and_not_by_an_average() {
let blue = palette(Color32::from_rgb(0, 0, 255), Color32::WHITE);
assert!(!is_light(&blue));
}
#[test]
fn every_widget_state_carries_a_weak_fill_as_well_as_a_fill() {
let v = visuals(&light(), CornerRadius::ZERO);
for (name, w) in [
("noninteractive", &v.widgets.noninteractive),
("inactive", &v.widgets.inactive),
("hovered", &v.widgets.hovered),
("active", &v.widgets.active),
] {
assert_eq!(w.weak_bg_fill, w.bg_fill, "{name} disagrees with itself");
}
}
#[test]
fn the_pressed_fill_is_not_the_action_colour() {
let p = light();
let v = visuals(&p, CornerRadius::ZERO);
assert_ne!(v.widgets.active.bg_fill, p.action);
assert_eq!(v.widgets.active.fg_stroke.color, p.content);
assert_eq!(v.selection.stroke.color, p.action);
}
#[test]
fn nothing_swells_under_the_cursor() {
let v = visuals(&light(), CornerRadius::ZERO);
assert!(v.widgets.hovered.expansion.abs() < f32::EPSILON);
assert!(v.widgets.active.expansion.abs() < f32::EPSILON);
}
#[test]
fn a_control_clears_the_target_floor_and_text_clears_the_readable_one() {
let s = spacing(&surface(), Density::Pointer);
assert!(
s.interact_size.y >= 24.0,
"interact_size {} is under the target floor",
s.interact_size.y
);
let styles = text_styles(&surface());
for (role, font) in &styles {
assert!(
font.size >= 11.0,
"{role:?} is {} points, under the readable floor",
font.size
);
}
}
#[test]
fn the_scale_orders_the_text_roles() {
let styles = text_styles(&surface());
let size = |role: &egui::TextStyle| styles[role].size;
assert!(size(&egui::TextStyle::Small) < size(&egui::TextStyle::Body));
assert!(size(&egui::TextStyle::Heading) > size(&egui::TextStyle::Body));
}
#[test]
fn the_composed_style_is_the_three_pieces() {
let p = light();
let radius = CornerRadius::same(4);
let composed = style(&p, &surface(), Density::Pointer, radius);
assert_eq!(composed.visuals, visuals(&p, radius));
assert_eq!(composed.spacing, spacing(&surface(), Density::Pointer));
assert_eq!(composed.text_styles, text_styles(&surface()));
}
}