mod contrast;
pub use contrast::{
AA, AA_LARGE, AAA, contrast_x100, derive_content, derive_content_for, luminance,
};
use crate::color::Color;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ColorScheme {
Light,
Dark,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[repr(usize)]
pub enum Role {
Base100,
Base200,
Base300,
BaseContent,
Primary,
PrimaryContent,
Secondary,
SecondaryContent,
Accent,
AccentContent,
Neutral,
NeutralContent,
Info,
InfoContent,
Success,
SuccessContent,
Warning,
WarningContent,
Error,
ErrorContent,
}
impl Role {
pub const COUNT: usize = 20;
pub const SURFACES: [Role; 11] = [
Role::Base100,
Role::Base200,
Role::Base300,
Role::Primary,
Role::Secondary,
Role::Accent,
Role::Neutral,
Role::Info,
Role::Success,
Role::Warning,
Role::Error,
];
pub const fn content(self) -> Role {
match self {
Role::Base100 | Role::Base200 | Role::Base300 => Role::BaseContent,
Role::Primary => Role::PrimaryContent,
Role::Secondary => Role::SecondaryContent,
Role::Accent => Role::AccentContent,
Role::Neutral => Role::NeutralContent,
Role::Info => Role::InfoContent,
Role::Success => Role::SuccessContent,
Role::Warning => Role::WarningContent,
Role::Error => Role::ErrorContent,
other => other,
}
}
pub const fn is_content(self) -> bool {
matches!(
self,
Role::BaseContent
| Role::PrimaryContent
| Role::SecondaryContent
| Role::AccentContent
| Role::NeutralContent
| Role::InfoContent
| Role::SuccessContent
| Role::WarningContent
| Role::ErrorContent
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Radius {
Selector,
Field,
Box,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Metrics {
pub radius_selector: i32,
pub radius_field: i32,
pub radius_box: i32,
pub size_selector: i32,
pub size_field: i32,
pub border: i32,
}
impl Metrics {
pub const DEFAULT: Self = Self {
radius_selector: 8,
radius_field: 6,
radius_box: 12,
size_selector: 20,
size_field: 36,
border: 1,
};
pub const TOUCH: Self = Self {
radius_selector: 10,
radius_field: 8,
radius_box: 16,
size_selector: 28,
size_field: 48,
border: 2,
};
pub fn scaled(self, scale: f32) -> Metrics {
let s = |v: i32| ((v as f32) * scale + 0.5) as i32;
Metrics {
radius_selector: s(self.radius_selector),
radius_field: s(self.radius_field),
radius_box: s(self.radius_box),
size_selector: s(self.size_selector),
size_field: s(self.size_field),
border: s(self.border).max(1),
}
}
pub const fn radius(self, which: Radius) -> i32 {
match which {
Radius::Selector => self.radius_selector,
Radius::Field => self.radius_field,
Radius::Box => self.radius_box,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ContrastFailure {
pub surface: Role,
pub content: Role,
pub ratio_x100: u32,
pub required: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Theme {
pub name: &'static str,
pub scheme: ColorScheme,
pub metrics: Metrics,
pub depth: u8,
colors: [Color; Role::COUNT],
}
impl Theme {
#[allow(clippy::too_many_arguments)]
pub const fn from_seeds(
name: &'static str,
scheme: ColorScheme,
base: Color,
primary: Color,
secondary: Color,
accent: Color,
neutral: Color,
info: Color,
success: Color,
warning: Color,
error: Color,
) -> Theme {
Theme::from_seeds_at(
name, scheme, AA, base, primary, secondary, accent, neutral, info, success, warning,
error,
)
}
#[allow(clippy::too_many_arguments)]
pub const fn from_seeds_at(
name: &'static str,
scheme: ColorScheme,
target: u32,
base: Color,
primary: Color,
secondary: Color,
accent: Color,
neutral: Color,
info: Color,
success: Color,
warning: Color,
error: Color,
) -> Theme {
let toward = if luminance(base) < 3000 {
Color::WHITE
} else {
Color::BLACK
};
let base_200 = base.mix(toward, 18);
let base_300 = base.mix(toward, 36);
Theme {
name,
scheme,
metrics: Metrics::DEFAULT,
depth: 0,
colors: [
base,
base_200,
base_300,
derive_content_for(&[base, base_200, base_300], target),
primary,
derive_content(primary, target),
secondary,
derive_content(secondary, target),
accent,
derive_content(accent, target),
neutral,
derive_content(neutral, target),
info,
derive_content(info, target),
success,
derive_content(success, target),
warning,
derive_content(warning, target),
error,
derive_content(error, target),
],
}
}
#[inline]
pub const fn color(self, role: Role) -> Color {
self.colors[role as usize]
}
#[inline]
pub const fn content_of(self, role: Role) -> Color {
self.colors[role.content() as usize]
}
#[inline]
pub const fn pair(self, role: Role) -> (Color, Color) {
(self.color(role), self.content_of(role))
}
#[inline]
pub const fn radius(self, which: Radius) -> i32 {
self.metrics.radius(which)
}
pub const fn with_metrics(mut self, metrics: Metrics) -> Theme {
self.metrics = metrics;
self
}
pub fn scaled(self, scale: f32) -> Theme {
let metrics = self.metrics.scaled(scale);
self.with_metrics(metrics)
}
pub const fn with_depth(mut self, depth: u8) -> Theme {
self.depth = depth;
self
}
pub const fn with_color(mut self, role: Role, color: Color) -> Theme {
self.colors[role as usize] = color;
self
}
pub fn validate(self, target: u32) -> Result<(), ContrastFailure> {
let mut worst: Option<ContrastFailure> = None;
for surface in Role::SURFACES {
let ratio = contrast_x100(self.color(surface), self.content_of(surface));
if ratio < target {
let failure = ContrastFailure {
surface,
content: surface.content(),
ratio_x100: ratio,
required: target,
};
if worst.is_none_or(|w| ratio < w.ratio_x100) {
worst = Some(failure);
}
}
}
match worst {
Some(f) => Err(f),
None => Ok(()),
}
}
}
pub const DARK: Theme = Theme::from_seeds(
"dark",
ColorScheme::Dark,
Color::from_rgb888(0x1E1E2E),
Color::from_rgb888(0x89B4FA),
Color::from_rgb888(0xF5C2E7),
Color::from_rgb888(0x94E2D5),
Color::from_rgb888(0x585B70),
Color::from_rgb888(0x89DCEB),
Color::from_rgb888(0xA6E3A1),
Color::from_rgb888(0xF9E2AF),
Color::from_rgb888(0xF38BA8),
);
pub const LIGHT: Theme = Theme::from_seeds(
"light",
ColorScheme::Light,
Color::from_rgb888(0xEFF1F5),
Color::from_rgb888(0x1E66F5),
Color::from_rgb888(0xEA76CB),
Color::from_rgb888(0x179299),
Color::from_rgb888(0x6C6F85),
Color::from_rgb888(0x209FB5),
Color::from_rgb888(0x40A02B),
Color::from_rgb888(0xDF8E1D),
Color::from_rgb888(0xD20F39),
);
pub const HIGH_CONTRAST: Theme = Theme::from_seeds_at(
"high-contrast",
ColorScheme::Dark,
AAA,
Color::from_rgb888(0x000000),
Color::from_rgb888(0xFFFF00),
Color::from_rgb888(0x00FFFF),
Color::from_rgb888(0xFF80FF),
Color::from_rgb888(0xC0C0C0),
Color::from_rgb888(0x00FFFF),
Color::from_rgb888(0x00FF00),
Color::from_rgb888(0xFFFF00),
Color::from_rgb888(0xFF8080),
)
.with_metrics(Metrics::TOUCH);
impl Theme {
pub const DARK: Theme = DARK;
pub const LIGHT: Theme = LIGHT;
pub const HIGH_CONTRAST: Theme = HIGH_CONTRAST;
pub const BUILT_IN: [Theme; 3] = [LIGHT, DARK, HIGH_CONTRAST];
}
impl Default for Theme {
fn default() -> Self {
DARK
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn built_in_themes_are_readable() {
for theme in Theme::BUILT_IN {
if let Err(f) = theme.validate(AA) {
panic!(
"theme {:?}: {:?} on {:?} is {}:1, needs {}:1",
theme.name,
f.content,
f.surface,
f.ratio_x100 as f32 / 100.0,
f.required as f32 / 100.0
);
}
}
}
#[test]
fn high_contrast_clears_the_strictest_target() {
HIGH_CONTRAST
.validate(AAA)
.expect("high-contrast must clear AAA");
}
#[test]
fn every_role_maps_to_its_own_slot() {
let mut seen = [false; Role::COUNT];
for surface in Role::SURFACES {
for role in [surface, surface.content()] {
let i = role as usize;
assert!(i < Role::COUNT);
seen[i] = true;
}
}
assert!(
seen.iter().all(|&s| s),
"a role is unreachable via SURFACES"
);
}
#[test]
fn content_roles_are_their_own_content() {
for surface in Role::SURFACES {
let content = surface.content();
assert!(content.is_content(), "{surface:?} paired with a surface");
assert_eq!(content.content(), content);
}
}
#[test]
fn the_three_base_surfaces_share_one_content_colour() {
for base in [Role::Base100, Role::Base200, Role::Base300] {
assert_eq!(base.content(), Role::BaseContent);
}
}
#[test]
fn recessed_surfaces_are_distinct_from_the_main_one() {
for theme in Theme::BUILT_IN {
let (b1, b2, b3) = (
theme.color(Role::Base100),
theme.color(Role::Base200),
theme.color(Role::Base300),
);
assert_ne!(b1, b2, "{}: base-200 collapsed onto base-100", theme.name);
assert_ne!(b2, b3, "{}: base-300 collapsed onto base-200", theme.name);
}
}
#[test]
fn a_black_base_ramps_lighter_rather_than_nowhere() {
let t = HIGH_CONTRAST;
assert!(luminance(t.color(Role::Base200)) > luminance(t.color(Role::Base100)));
assert!(luminance(t.color(Role::Base300)) > luminance(t.color(Role::Base200)));
}
#[test]
fn a_light_base_ramps_darker() {
let t = LIGHT;
assert!(luminance(t.color(Role::Base200)) < luminance(t.color(Role::Base100)));
assert!(luminance(t.color(Role::Base300)) < luminance(t.color(Role::Base200)));
}
#[test]
fn validate_catches_a_deliberately_broken_theme() {
let broken = DARK.with_color(Role::PrimaryContent, DARK.color(Role::Primary));
let failure = broken.validate(AA).expect_err("must be caught");
assert_eq!(failure.surface, Role::Primary);
assert_eq!(failure.ratio_x100, 100);
}
#[test]
fn validate_reports_the_worst_offender() {
let broken = DARK
.with_color(Role::InfoContent, DARK.color(Role::Info))
.with_color(Role::ErrorContent, Color::from_rgb888(0x808080));
let failure = broken.validate(AA).expect_err("must be caught");
assert_eq!(failure.surface, Role::Info, "should report the 1:1 pair");
}
#[test]
fn metrics_scale_and_never_lose_the_border() {
let m = Metrics::DEFAULT.scaled(2.0);
assert_eq!(m.size_field, 72);
assert_eq!(m.radius_box, 24);
assert_eq!(Metrics::DEFAULT.scaled(0.1).border, 1);
}
#[test]
fn touch_targets_are_large_enough_to_hit() {
const { assert!(Metrics::TOUCH.size_field >= 44) };
}
#[test]
fn pair_agrees_with_the_individual_lookups() {
let (bg, fg) = DARK.pair(Role::Success);
assert_eq!(bg, DARK.color(Role::Success));
assert_eq!(fg, DARK.color(Role::SuccessContent));
}
#[test]
fn builders_are_const_usable() {
const CUSTOM: Theme = DARK.with_depth(3).with_metrics(Metrics::TOUCH);
assert_eq!(CUSTOM.depth, 3);
assert_eq!(CUSTOM.radius(Radius::Box), Metrics::TOUCH.radius_box);
}
}