///|
/// Semantic roles used by reusable design themes.
pub(all) enum ThemeRole {
Background
Surface
Primary
Secondary
Accent
Text
Muted
} derive(Eq, Debug, ToJson)
///|
/// A named color theme with semantic roles and a source palette.
pub(all) struct ColorTheme {
name : String
background : Color
surface : Color
primary : Color
secondary : Color
accent : Color
text : Color
muted : Color
palette : Palette
} derive(Eq, Debug, ToJson)
///|
/// Builds a theme from a palette, using robust fallbacks for short palettes.
pub fn ColorTheme::new(name : String, palette : Palette) -> ColorTheme {
let fallback = Color::rgb8(128, 128, 128)
let at = fn(index) { palette.at(index).unwrap_or(fallback) }
{
name,
background: at(0),
surface: at(1),
primary: at(2),
secondary: at(3),
accent: at(4),
text: best_text_color(at(0)),
muted: at(1).mix(at(0), 0.5),
palette,
}
}
///|
pub fn ColorTheme::with_role(
self : ColorTheme,
role : ThemeRole,
color : Color,
) -> ColorTheme {
match role {
Background => { ..self, background: color }
Surface => { ..self, surface: color }
Primary => { ..self, primary: color }
Secondary => { ..self, secondary: color }
Accent => { ..self, accent: color }
Text => { ..self, text: color }
Muted => { ..self, muted: color }
}
}
///|
pub fn ColorTheme::color(self : ColorTheme, role : ThemeRole) -> Color {
match role {
Background => self.background
Surface => self.surface
Primary => self.primary
Secondary => self.secondary
Accent => self.accent
Text => self.text
Muted => self.muted
}
}
///|
pub fn ColorTheme::to_css(
self : ColorTheme,
prefix? : String = "--theme",
) -> String {
let builder = StringBuilder()
builder.write_string(":root {\n")
builder.write_string(" \{prefix}-background: \{self.background.to_hex()};\n")
builder.write_string(" \{prefix}-surface: \{self.surface.to_hex()};\n")
builder.write_string(" \{prefix}-primary: \{self.primary.to_hex()};\n")
builder.write_string(" \{prefix}-secondary: \{self.secondary.to_hex()};\n")
builder.write_string(" \{prefix}-accent: \{self.accent.to_hex()};\n")
builder.write_string(" \{prefix}-text: \{self.text.to_hex()};\n")
builder.write_string(" \{prefix}-muted: \{self.muted.to_hex()};\n")
builder.write_string("}\n")
builder.to_string()
}
///|
/// Interpolation spaces supported by a color ramp.
pub(all) enum ColorRampSpace {
Rgb
Hsl
Luminance
} derive(Eq, Debug, ToJson)
///|
/// A positioned color ramp for fields, charts and procedural surfaces.
pub(all) struct ColorRamp {
stops : Array[Color]
positions : Array[Double]
space : ColorRampSpace
} derive(Eq, Debug, ToJson)
///|
pub fn ColorRamp::new(
stops : Array[Color],
space? : ColorRampSpace = Rgb,
) -> ColorRamp {
let positions = []
if stops.length() == 1 {
positions.push(0.0)
} else if stops.length() > 1 {
for index in 0.. Color {
if self.stops.length() == 0 {
Color::rgb8(0, 0, 0)
} else if self.stops.length() == 1 {
self.stops[0]
} else {
let target = value.max(0.0).min(1.0)
let mut index = 0
for candidate in 0..= self.stops.length() - 1 {
self.stops[self.stops.length() - 1]
} else {
let start = self.positions[index]
let end = self.positions[index + 1]
let amount = if end == start {
0.0
} else {
(target - start) / (end - start)
}
interpolate_color(
self.stops[index],
self.stops[index + 1],
amount,
self.space,
)
}
}
}
///|
pub fn ColorRamp::sample(self : ColorRamp, count : Int) -> Array[Color] {
let result = []
if count > 0 {
for index in 0.. ColorRamp {
let colors = []
for offset in 1..<=self.stops.length() {
colors.push(self.stops[self.stops.length() - offset])
}
ColorRamp::new(colors, space=self.space)
}
///|
fn hue_wrap(value : Double) -> Double {
let cycle = 2.0 * @math.PI
let wrapped = value % cycle
if wrapped < 0.0 {
wrapped + cycle
} else {
wrapped
}
}
///|
fn interpolate_hue(left : Double, right : Double, amount : Double) -> Double {
let delta = (right - left + @math.PI) % (2.0 * @math.PI) - @math.PI
hue_wrap(left + delta * amount)
}
///|
fn interpolate_color(
left : Color,
right : Color,
amount : Double,
space : ColorRampSpace,
) -> Color {
let t = amount.max(0.0).min(1.0)
match space {
Rgb => left.mix(right, t)
Hsl => {
let (lh, ls, ll) = color_to_hsl(left)
let (rh, rs, rl) = color_to_hsl(right)
Color::hsl(
interpolate_hue(lh, rh, t),
ls + (rs - ls) * t,
ll + (rl - ll) * t,
alpha=left.a + (right.a - left.a) * t,
)
}
Luminance => {
let luminance = left.luminance() +
(right.luminance() - left.luminance()) * t
let mixed = left.mix(right, t)
recolor_luminance(mixed, luminance)
}
}
}
///|
/// Converts an sRGB color to hue, saturation and lightness.
pub fn color_to_hsl(color : Color) -> (Double, Double, Double) {
let maximum = color.r.max(color.g).max(color.b)
let minimum = color.r.min(color.g).min(color.b)
let lightness = (maximum + minimum) / 2.0
if maximum == minimum {
(0.0, 0.0, lightness)
} else {
let delta = maximum - minimum
let saturation = if lightness > 0.5 {
delta / (2.0 - maximum - minimum)
} else {
delta / (maximum + minimum)
}
let hue = if maximum == color.r {
(color.g - color.b) / delta + (if color.g < color.b { 6.0 } else { 0.0 })
} else if maximum == color.g {
(color.b - color.r) / delta + 2.0
} else {
(color.r - color.g) / delta + 4.0
}
(hue * @math.PI / 3.0, saturation, lightness)
}
}
///|
/// Returns a color with approximately the requested relative luminance.
pub fn recolor_luminance(color : Color, target : Double) -> Color {
let current = color.luminance()
if current <= 0.000001 {
Color::rgba(
target.max(0.0).min(1.0),
target.max(0.0).min(1.0),
target.max(0.0).min(1.0),
a=color.a,
)
} else {
let scale = target.max(0.0).min(1.0) / current
Color::rgba(color.r * scale, color.g * scale, color.b * scale, a=color.a)
}
}
///|
/// Selects black or white text with the stronger contrast ratio.
pub fn best_text_color(background : Color) -> Color {
let light = Color::rgb8(255, 255, 255)
let dark = Color::rgb8(0, 0, 0)
if background.contrast_ratio(light) >= background.contrast_ratio(dark) {
light
} else {
dark
}
}
///|
/// Returns whether a color is visually light under the sRGB luminance model.
pub fn is_light_color(color : Color, threshold? : Double = 0.5) -> Bool {
color.luminance() >= threshold
}
///|
/// Creates a palette ordered from dark to light by luminance.
pub fn sort_palette_by_luminance(palette : Palette) -> Palette {
let colors = palette.colors.copy()
for i in 0.. Palette {
let colors = if palette.colors.length() == 0 {
[]
} else if palette.colors.length() == 1 || samples_per_gap <= 1 {
palette.colors.copy()
} else {
let result = []
for index in 0..<(palette.colors.length() - 1) {
let left = palette.colors[index]
let right = palette.colors[index + 1]
for offset in 0.. Palette {
let colors = []
if count > 0 {
for index in 0.. Color {
color.with_alpha(color.a * (1.0 - amount.max(0.0).min(1.0)))
}
///|
/// Blends a foreground over a background with an explicit opacity.
pub fn blend_opacity(
background : Color,
foreground : Color,
opacity : Double,
) -> Color {
background.over(
foreground.with_alpha(foreground.a * opacity.max(0.0).min(1.0)),
)
}
///|
/// Returns the closest palette entry and its squared RGB distance.
pub fn nearest_palette_color(
palette : Palette,
target : Color,
) -> (Int, Double)? {
if palette.colors.length() == 0 {
None
} else {
let mut best_index = 0
let mut best_distance = color_distance_squared(palette.colors[0], target)
for index in 1.. Double {
let dr = left.r - right.r
let dg = left.g - right.g
let db = left.b - right.b
dr * dr + dg * dg + db * db
}
///|
/// Quantizes a color to the nearest entry of a palette.
pub fn quantize_color(color : Color, palette : Palette) -> Color {
match nearest_palette_color(palette, color) {
Some((index, _)) => palette.colors[index]
None => color
}
}
///|
/// A contrast audit result for a foreground/background pair collection.
pub(all) struct ColorAudit {
minimum_ratio : Double
maximum_ratio : Double
pair_count : Int
passing_pairs : Int
passes : Bool
} derive(Eq, Debug, ToJson)
///|
/// Audits all theme text roles against their corresponding surfaces.
pub fn audit_theme(theme : ColorTheme, minimum_ratio : Double) -> ColorAudit {
let pairs = [
(theme.background, theme.text),
(theme.surface, theme.text),
(theme.primary, best_text_color(theme.primary)),
(theme.secondary, best_text_color(theme.secondary)),
(theme.accent, best_text_color(theme.accent)),
]
let mut minimum = 1000.0
let mut maximum = 0.0
let mut passing = 0
for pair in pairs {
let background = pair.0
let foreground = pair.1
let ratio = background.contrast_ratio(foreground)
minimum = minimum.min(ratio)
maximum = maximum.max(ratio)
if ratio >= minimum_ratio {
passing += 1
}
}
{
minimum_ratio: minimum,
maximum_ratio: maximum,
pair_count: pairs.length(),
passing_pairs: passing,
passes: passing == pairs.length(),
}
}