use std::fmt;
use elicitation::Established;
use tracing::instrument;
use crate::{
ContrastPair, SrgbColor, WcagContrastMinimumNormalText, WcagNonTextContrastMinimum,
contrast_ratio,
proof_credentials::{PaletteNonTextVerified, PaletteNormalTextVerified},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(usize)]
pub enum SemanticRole {
Background = 0,
Surface = 1,
Text = 2,
DimText = 3,
Accent = 4,
Error = 5,
Keyword = 6,
StringLit = 7,
Comment = 8,
Number = 9,
}
impl SemanticRole {
pub(crate) const COUNT: usize = 10;
const ALL: [Self; Self::COUNT] = [
Self::Background,
Self::Surface,
Self::Text,
Self::DimText,
Self::Accent,
Self::Error,
Self::Keyword,
Self::StringLit,
Self::Comment,
Self::Number,
];
}
impl fmt::Display for SemanticRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Background => "background",
Self::Surface => "surface",
Self::Text => "text",
Self::DimText => "dim_text",
Self::Accent => "accent",
Self::Error => "error",
Self::Keyword => "keyword",
Self::StringLit => "string_lit",
Self::Comment => "comment",
Self::Number => "number",
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct NormalTextPair {
pub pair: ContrastPair,
pub proof: Established<WcagContrastMinimumNormalText>,
}
#[derive(Debug, Clone, Copy)]
pub struct NonTextPair {
pub pair: ContrastPair,
pub proof: Established<WcagNonTextContrastMinimum>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaletteColors {
pub background: SrgbColor,
pub surface: SrgbColor,
pub text: SrgbColor,
pub dim_text: SrgbColor,
pub accent: SrgbColor,
pub error: SrgbColor,
pub keyword: SrgbColor,
pub string_lit: SrgbColor,
pub comment: SrgbColor,
pub number: SrgbColor,
}
impl PaletteColors {
pub fn get(&self, role: SemanticRole) -> SrgbColor {
match role {
SemanticRole::Background => self.background,
SemanticRole::Surface => self.surface,
SemanticRole::Text => self.text,
SemanticRole::DimText => self.dim_text,
SemanticRole::Accent => self.accent,
SemanticRole::Error => self.error,
SemanticRole::Keyword => self.keyword,
SemanticRole::StringLit => self.string_lit,
SemanticRole::Comment => self.comment,
SemanticRole::Number => self.number,
}
}
}
#[derive(Debug, Clone)]
pub struct Palette {
pub colors: PaletteColors,
pub text_on_bg: NormalTextPair,
pub text_on_surface: NormalTextPair,
pub dim_text_on_bg: NormalTextPair,
pub keyword_on_bg: NormalTextPair,
pub string_on_bg: NormalTextPair,
pub number_on_bg: NormalTextPair,
pub comment_on_bg: NormalTextPair,
pub accent_on_bg: NonTextPair,
pub error_on_bg: NonTextPair,
}
impl Palette {
pub fn color(&self, role: SemanticRole) -> SrgbColor {
self.colors.get(role)
}
}
#[derive(Debug)]
pub enum PaletteBuildError {
Missing(Vec<SemanticRole>),
Contrast(Vec<ContrastReport>),
}
impl fmt::Display for PaletteBuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Missing(roles) => {
write!(f, "missing colour roles: ")?;
for (i, r) in roles.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{r}")?;
}
Ok(())
}
Self::Contrast(reports) => {
writeln!(f, "contrast failures:")?;
for r in reports {
writeln!(f, " {r}")?;
}
Ok(())
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ContrastSuggestion {
pub color: SrgbColor,
pub achieved_ratio: f32,
}
#[derive(Debug, Clone)]
pub struct ContrastReport {
pub foreground_role: SemanticRole,
pub background_role: SemanticRole,
pub ratio: f32,
pub required: f32,
pub suggestion: Option<ContrastSuggestion>,
}
impl fmt::Display for ContrastReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} on {}: {:.2}:1 < {:.1}:1",
self.foreground_role, self.background_role, self.ratio, self.required
)?;
if let Some(s) = self.suggestion {
write!(
f,
" → suggest {} (achieves {:.2}:1)",
s.color.to_hex(),
s.achieved_ratio
)?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct PaletteBuilder {
colors: [Option<SrgbColor>; SemanticRole::COUNT],
}
impl Default for PaletteBuilder {
fn default() -> Self {
Self::new()
}
}
impl PaletteBuilder {
pub fn new() -> Self {
Self {
colors: [None; SemanticRole::COUNT],
}
}
pub fn set(mut self, role: SemanticRole, color: SrgbColor) -> Self {
self.colors[role as usize] = Some(color);
self
}
pub fn contrast_for(&self, fg: SemanticRole, bg: SemanticRole) -> Option<f32> {
let fg_c = self.colors[fg as usize]?;
let bg_c = self.colors[bg as usize]?;
Some(contrast_ratio(&fg_c, &bg_c))
}
pub fn suggest(
&self,
fg: SemanticRole,
bg: SemanticRole,
min_ratio: f32,
) -> Option<ContrastSuggestion> {
let fg_c = self.colors[fg as usize]?;
let bg_c = self.colors[bg as usize]?;
suggest_compliant(fg_c, bg_c, min_ratio)
}
#[instrument(skip(self))]
pub fn build(self) -> Result<Palette, PaletteBuildError> {
let missing: Vec<SemanticRole> = SemanticRole::ALL
.iter()
.copied()
.filter(|&r| self.colors[r as usize].is_none())
.collect();
if !missing.is_empty() {
return Err(PaletteBuildError::Missing(missing));
}
let c = |r: SemanticRole| self.colors[r as usize].unwrap();
let bg = c(SemanticRole::Background);
let surface = c(SemanticRole::Surface);
let text = c(SemanticRole::Text);
let dim_text = c(SemanticRole::DimText);
let accent = c(SemanticRole::Accent);
let error = c(SemanticRole::Error);
let keyword = c(SemanticRole::Keyword);
let string_lit = c(SemanticRole::StringLit);
let comment = c(SemanticRole::Comment);
let number = c(SemanticRole::Number);
let normal_checks: &[(SrgbColor, SemanticRole, SrgbColor, SemanticRole)] = &[
(text, SemanticRole::Text, bg, SemanticRole::Background),
(text, SemanticRole::Text, surface, SemanticRole::Surface),
(
dim_text,
SemanticRole::DimText,
bg,
SemanticRole::Background,
),
(keyword, SemanticRole::Keyword, bg, SemanticRole::Background),
(
string_lit,
SemanticRole::StringLit,
bg,
SemanticRole::Background,
),
(comment, SemanticRole::Comment, bg, SemanticRole::Background),
(number, SemanticRole::Number, bg, SemanticRole::Background),
];
let non_text_checks: &[(SrgbColor, SemanticRole, SrgbColor, SemanticRole)] = &[
(accent, SemanticRole::Accent, bg, SemanticRole::Background),
(error, SemanticRole::Error, bg, SemanticRole::Background),
];
let mut failures: Vec<ContrastReport> = Vec::new();
for &(fg_c, fg_role, bg_c, bg_role) in normal_checks {
let ratio = contrast_ratio(&fg_c, &bg_c);
if ratio < 4.5 {
failures.push(ContrastReport {
foreground_role: fg_role,
background_role: bg_role,
ratio,
required: 4.5,
suggestion: suggest_compliant(fg_c, bg_c, 4.5),
});
}
}
for &(fg_c, fg_role, bg_c, bg_role) in non_text_checks {
let ratio = contrast_ratio(&fg_c, &bg_c);
if ratio < 3.0 {
failures.push(ContrastReport {
foreground_role: fg_role,
background_role: bg_role,
ratio,
required: 3.0,
suggestion: suggest_compliant(fg_c, bg_c, 3.0),
});
}
}
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "palette contrast failures");
return Err(PaletteBuildError::Contrast(failures));
}
let normal_pair = |fg: SrgbColor, bg_c: SrgbColor| NormalTextPair {
pair: ContrastPair {
foreground: fg,
background: bg_c,
ratio: contrast_ratio(&fg, &bg_c).into(),
},
proof: Established::prove(&PaletteNormalTextVerified),
};
let non_text_pair = |fg: SrgbColor, bg_c: SrgbColor| NonTextPair {
pair: ContrastPair {
foreground: fg,
background: bg_c,
ratio: contrast_ratio(&fg, &bg_c).into(),
},
proof: Established::prove(&PaletteNonTextVerified),
};
Ok(Palette {
colors: PaletteColors {
background: bg,
surface,
text,
dim_text,
accent,
error,
keyword,
string_lit,
comment,
number,
},
text_on_bg: normal_pair(text, bg),
text_on_surface: normal_pair(text, surface),
dim_text_on_bg: normal_pair(dim_text, bg),
keyword_on_bg: normal_pair(keyword, bg),
string_on_bg: normal_pair(string_lit, bg),
number_on_bg: normal_pair(number, bg),
comment_on_bg: normal_pair(comment, bg),
accent_on_bg: non_text_pair(accent, bg),
error_on_bg: non_text_pair(error, bg),
})
}
#[instrument(skip(self))]
pub fn build_adjusted(self) -> Palette {
let mut colors = self.colors;
let mut round = 0u32;
loop {
round += 1;
assert!(
round <= 16,
"PaletteBuilder::build_adjusted did not converge"
);
let builder = PaletteBuilder { colors };
match builder.build() {
Ok(palette) => {
tracing::debug!(rounds = round, "palette converged");
return palette;
}
Err(PaletteBuildError::Missing(roles)) => {
panic!(
"PaletteBuilder::build_adjusted: unset roles: {}",
roles
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
.join(", ")
);
}
Err(PaletteBuildError::Contrast(reports)) => {
let mut changed = false;
for report in &reports {
if let Some(s) = report.suggestion {
tracing::debug!(
role = %report.foreground_role,
from = %colors[report.foreground_role as usize]
.map(|c| c.to_hex())
.unwrap_or_default(),
to = %s.color.to_hex(),
was = report.ratio,
now = s.achieved_ratio,
"adjusting colour for compliance"
);
colors[report.foreground_role as usize] = Some(s.color);
changed = true;
} else {
panic!(
"PaletteBuilder::build_adjusted: no suggestion for {} \
(ratio {:.2}, required {:.1}) — background may be mid-gray",
report.foreground_role, report.ratio, report.required
);
}
}
if !changed {
panic!("PaletteBuilder::build_adjusted: no progress made");
}
}
}
}
}
}
fn linearise(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn gamma_encode(c: f32) -> f32 {
if c <= 0.003_130_8 {
c * 12.92
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
fn relative_luminance(c: SrgbColor) -> f32 {
0.2126 * linearise(c.r) + 0.7152 * linearise(c.g) + 0.0722 * linearise(c.b)
}
fn scale_to_luminance(color: SrgbColor, target_lum: f32) -> Option<SrgbColor> {
let lr = linearise(color.r);
let lg = linearise(color.g);
let lb = linearise(color.b);
let current_lum = 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
if current_lum < 1e-7 {
let l = gamma_encode(target_lum.cbrt());
return Some(SrgbColor::new(l, l, l));
}
let scale = target_lum / current_lum;
Some(SrgbColor::new(
gamma_encode((lr * scale).clamp(0.0, 1.0)),
gamma_encode((lg * scale).clamp(0.0, 1.0)),
gamma_encode((lb * scale).clamp(0.0, 1.0)),
))
}
fn srgb_distance(a: SrgbColor, b: SrgbColor) -> f32 {
let dr = a.r - b.r;
let dg = a.g - b.g;
let db = a.b - b.b;
(dr * dr + dg * dg + db * db).sqrt()
}
pub fn suggest_compliant(
candidate: SrgbColor,
background: SrgbColor,
min_ratio: f32,
) -> Option<ContrastSuggestion> {
if contrast_ratio(&candidate, &background) >= min_ratio {
return None;
}
let bg_lum = relative_luminance(background);
let req_lum_lighter = (bg_lum + 0.05) * min_ratio - 0.05;
let req_lum_darker = (bg_lum + 0.05) / min_ratio - 0.05;
let lighter = if req_lum_lighter <= 1.0 {
scale_to_luminance(candidate, req_lum_lighter.max(0.0))
} else {
None
};
let darker = if req_lum_darker >= 0.0 {
scale_to_luminance(candidate, req_lum_darker)
} else {
None
};
let chosen = match (lighter, darker) {
(Some(l), Some(d)) => {
if srgb_distance(l, candidate) <= srgb_distance(d, candidate) {
l
} else {
d
}
}
(Some(l), None) => l,
(None, Some(d)) => d,
(None, None) => return None,
};
Some(ContrastSuggestion {
color: chosen,
achieved_ratio: contrast_ratio(&chosen, &background),
})
}