Files
monobiome/monobiome/scheme.py

255 lines
7.4 KiB
Python

from functools import cache
from collections.abc import Callable
from coloraide import Color
from monobiome.util import oklch_distance
from monobiome.palette import compute_hlc_map
from monobiome.constants import (
accent_h_map,
monotone_h_map,
)
@cache
def compute_dma_map(
dT: float,
metric: Callable | None = None
) -> dict[str, dict]:
"""
For threshold `dT`, compute the nearest accent shades that exceed that
threshold for every monotone shade.
Returns: map of minimum constraint satisfying accent colors for monotone
spectra
{
"alpine": {
"oklch( ... )": {
"red": *nearest oklch >= dT from M base*,
...
},
...
},
...
}
"""
if metric is None:
metric = oklch_distance
oklch_hlc_map = compute_hlc_map("oklch")
oklch_color_map = {
c_name: [Color(c_str) for c_str in c_str_dict.values()]
for c_name, c_str_dict in oklch_hlc_map.items()
}
dT_mL_acol_map = {}
for m_name in monotone_h_map:
mL_acol_map = {}
m_colors = oklch_color_map[m_name]
for m_color in m_colors:
acol_min_map = {}
for a_name in accent_h_map:
a_colors = oklch_color_map[a_name]
oklch_dists = filter(
lambda d: (d[1] - dT) >= 0,
[
(ac, metric(m_color, ac))
for ac in a_colors
]
)
oklch_dists = list(oklch_dists)
if oklch_dists:
min_a_color = min(oklch_dists, key=lambda t: t[1])[0]
acol_min_map[a_name] = min_a_color
# make sure the current monotone level has *all* accents; o/w
# ignore
if len(acol_min_map) < len(accent_h_map):
continue
mL = m_color.coords()[0]
mL_acol_map[int(mL*100)] = acol_min_map
dT_mL_acol_map[m_name] = mL_acol_map
return dT_mL_acol_map
def generate_scheme_groups(
mode: str,
biome: str,
metric: str,
distance: float,
l_base: int,
l_step: int,
fg_gap: int,
grey_gap: int,
term_fg_gap: int,
accent_color_map: dict[str, str],
) -> tuple[dict[str, str], ...]:
"""
Parameters:
mode: one of ["dark", "light"]
biome: biome setting
metric: one of ["wcag", "oklch", "lightness"]
"""
metric_map = {
"wcag": lambda mc,ac: ac.contrast(mc, method='wcag21'),
"oklch": oklch_distance,
"lightness": lambda mc,ac: abs(mc.coords()[0]-ac.coords()[0])*100,
}
metric_func = metric_map[metric]
dT_mL_acol_map = compute_dma_map(distance, metric=metric_func)
Lma_map = {
m_name: mL_acol_dict[l_base]
for m_name, mL_acol_dict in dT_mL_acol_map.items()
if l_base in mL_acol_dict
}
# the `mL_acol_dict` only includes lightnesses where all accent colors were
# within threshold. Coverage here will be partial if, at the `mL`, there is
# some monotone base that doesn't have all accents within threshold. This
# can happen at the edge, e.g., alpine@L15 has all accents w/in the
# distance, but the red accent was too far under tundra@L15, so there's no
# entry. This particular case is fairly rare; it's more likely that *all*
# monotones are undefined. Either way, both such cases lead to partial
# scheme coverage.
if len(Lma_map) < len(monotone_h_map):
print(f"Warning: partial scheme coverage for {l_base=}@{distance=}")
if biome not in Lma_map:
print(f"Biome {biome} unable to meet {metric} constraints")
accent_colors = Lma_map.get(biome, {})
meta_pairs = [
("mode", mode),
("biome", biome),
("metric", metric),
("distance", distance),
("l_base", l_base),
("l_step", l_step),
("fg_gap", fg_gap),
("grey_gap", grey_gap),
("term_fg_gap", term_fg_gap),
]
# note how selection_bg steps up by `l_step`, selection_fg steps down by
# `l_step` (from their respective bases)
term_pairs = [
("background", f"f{{{{{biome}.l{l_base}}}}}"),
("selection_bg", f"f{{{{{biome}.l{l_base+l_step}}}}}"),
("selection_fg", f"f{{{{{biome}.l{l_base+term_fg_gap-l_step}}}}}"),
("foreground", f"f{{{{{biome}.l{l_base+term_fg_gap}}}}}"),
("cursor", f"f{{{{{biome}.l{l_base+term_fg_gap-l_step}}}}}"),
("cursor_text", f"f{{{{{biome}.l{l_base+l_step}}}}}"),
]
monotone_pairs = []
monotone_pairs += [
(f"bg{i}", f"f{{{{{biome}.l{l_base+i*l_step}}}}}")
for i in range(4)
]
monotone_pairs += [
(f"fg{3-i}", f"f{{{{{biome}.l{fg_gap+l_base+i*l_step}}}}}")
for i in range(4)
]
accent_pairs = [
("black", f"f{{{{{biome}.l{l_base}}}}}"),
("grey", f"f{{{{{biome}.l{l_base+grey_gap}}}}}"),
("white", f"f{{{{{biome}.l{l_base+term_fg_gap-2*l_step}}}}}"),
]
for color_name, mb_accent in accent_color_map.items():
aL = int(100*accent_colors[mb_accent].coords()[0])
accent_pairs.append(
(
color_name,
f"f{{{{{mb_accent}.l{aL}}}}}"
)
)
return meta_pairs, term_pairs, monotone_pairs, accent_pairs
def generate_scheme(
mode: str,
biome: str,
metric: str,
distance: float,
l_base: int,
l_step: int,
fg_gap: int,
grey_gap: int,
term_fg_gap: int,
full_color_map: dict[str, str],
term_color_map: dict[str, str],
vim_color_map: dict[str, str],
) -> str:
l_sys = l_base
l_app = l_base + l_step
term_bright_offset = 10
# negate gaps if mode is light
if mode == "light":
l_step *= -1
fg_gap *= -1
grey_gap *= -1
term_fg_gap *= -1
term_bright_offset *= -1
meta, _, mt, ac = generate_scheme_groups(
mode, biome, metric, distance,
l_sys, l_step,
fg_gap, grey_gap, term_fg_gap,
full_color_map
)
_, term, _, term_norm_ac = generate_scheme_groups(
mode, biome, metric, distance,
l_app, l_step,
fg_gap, grey_gap, term_fg_gap,
term_color_map
)
_, _, _, term_bright_ac = generate_scheme_groups(
mode, biome, metric, distance,
l_app + term_bright_offset, l_step,
fg_gap, grey_gap, term_fg_gap,
term_color_map
)
_, _, vim_mt, vim_ac = generate_scheme_groups(
mode, biome, metric, distance,
l_app, l_step,
fg_gap, grey_gap, term_fg_gap,
vim_color_map
)
def pair_strings(pair_list: list[tuple[str, str]]) -> list[str]:
return [
f"{lhs:<12} = \"{rhs}\""
for lhs, rhs in pair_list
]
scheme_pairs = []
scheme_pairs += pair_strings(meta)
scheme_pairs += pair_strings(mt)
scheme_pairs += pair_strings(ac)
scheme_pairs += ["", "[term]"]
scheme_pairs += pair_strings(term)
scheme_pairs += ["", "[term.normal]"]
scheme_pairs += pair_strings(term_norm_ac)
scheme_pairs += ["", "[term.bright]"]
scheme_pairs += pair_strings(term_bright_ac)
scheme_pairs += ["", "[vim]"]
scheme_pairs += pair_strings(vim_mt)
scheme_pairs += pair_strings(vim_ac)
return "\n".join(scheme_pairs)