monobiome 1.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- monobiome/__init__.py +3 -0
- monobiome/__main__.py +19 -0
- monobiome/cli/__init__.py +32 -0
- monobiome/cli/palette.py +51 -0
- monobiome/cli/scheme.py +155 -0
- monobiome/constants.py +123 -0
- monobiome/curve.py +77 -0
- monobiome/data/parameters.toml +65 -0
- monobiome/palette.py +54 -0
- monobiome/plotting.py +176 -0
- monobiome/scheme.py +254 -0
- monobiome/util.py +35 -0
- monobiome-1.3.1.dist-info/METADATA +231 -0
- monobiome-1.3.1.dist-info/RECORD +17 -0
- monobiome-1.3.1.dist-info/WHEEL +5 -0
- monobiome-1.3.1.dist-info/entry_points.txt +2 -0
- monobiome-1.3.1.dist-info/top_level.txt +1 -0
monobiome/__init__.py
ADDED
monobiome/__main__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from monobiome.cli import create_parser, configure_logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def main() -> None:
|
|
5
|
+
parser = create_parser()
|
|
6
|
+
args = parser.parse_args()
|
|
7
|
+
|
|
8
|
+
# skim off log level to handle higher-level option
|
|
9
|
+
if hasattr(args, "log_level") and args.log_level is not None:
|
|
10
|
+
configure_logging(args.log_level)
|
|
11
|
+
|
|
12
|
+
if "func" in args:
|
|
13
|
+
args.func(args)
|
|
14
|
+
else:
|
|
15
|
+
parser.print_help()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__":
|
|
19
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import argparse
|
|
3
|
+
|
|
4
|
+
from monobiome.cli import scheme, palette
|
|
5
|
+
|
|
6
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
def configure_logging(log_level: int) -> None:
|
|
9
|
+
"""
|
|
10
|
+
Configure logger's logging level.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
logger.setLevel(log_level)
|
|
14
|
+
|
|
15
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
description="Accent modeling CLI",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--log-level",
|
|
21
|
+
type=int,
|
|
22
|
+
metavar="int",
|
|
23
|
+
choices=[10, 20, 30, 40, 50],
|
|
24
|
+
help="Log level: 10=DEBUG, 20=INFO, 30=WARNING, 40=ERROR, 50=CRITICAL",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
subparsers = parser.add_subparsers(help="subcommand help")
|
|
28
|
+
|
|
29
|
+
palette.register_parser(subparsers)
|
|
30
|
+
scheme.register_parser(subparsers)
|
|
31
|
+
|
|
32
|
+
return parser
|
monobiome/cli/palette.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from monobiome.util import _SubparserType
|
|
5
|
+
from monobiome.palette import generate_palette
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def register_parser(subparsers: _SubparserType) -> None:
|
|
9
|
+
parser = subparsers.add_parser(
|
|
10
|
+
"palette",
|
|
11
|
+
help="generate primary palette"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"-n",
|
|
16
|
+
"--notation",
|
|
17
|
+
type=str,
|
|
18
|
+
default="hex",
|
|
19
|
+
choices=["hex", "oklch"],
|
|
20
|
+
help="Color notation to export (either hex or oklch)",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-f",
|
|
24
|
+
"--format",
|
|
25
|
+
type=str,
|
|
26
|
+
default="toml",
|
|
27
|
+
choices=["json", "toml"],
|
|
28
|
+
help="Format of palette file (either JSON or TOML)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"-o",
|
|
32
|
+
"--output",
|
|
33
|
+
type=str,
|
|
34
|
+
help="Output file to write palette content",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
parser.set_defaults(func=handle_palette)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def handle_palette(args: argparse.Namespace) -> None:
|
|
41
|
+
notation = args.notation
|
|
42
|
+
file_format = args.format
|
|
43
|
+
output = args.output
|
|
44
|
+
|
|
45
|
+
palette_text = generate_palette(notation, file_format)
|
|
46
|
+
|
|
47
|
+
if output is None:
|
|
48
|
+
print(palette_text)
|
|
49
|
+
else:
|
|
50
|
+
with Path(output).open("w") as f:
|
|
51
|
+
f.write(palette_text)
|
monobiome/cli/scheme.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from monobiome.util import _SubparserType
|
|
5
|
+
from monobiome.scheme import generate_scheme
|
|
6
|
+
from monobiome.constants import monotone_h_map
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_parser(subparsers: _SubparserType) -> None:
|
|
10
|
+
parser = subparsers.add_parser(
|
|
11
|
+
"scheme",
|
|
12
|
+
help="create scheme variants"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"mode",
|
|
17
|
+
type=str,
|
|
18
|
+
choices=["dark", "light"],
|
|
19
|
+
help="Scheme mode (light or dark)"
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"biome",
|
|
23
|
+
type=str,
|
|
24
|
+
choices=list(monotone_h_map.keys()),
|
|
25
|
+
help="Biome setting for scheme."
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"-m",
|
|
29
|
+
"--metric",
|
|
30
|
+
type=str,
|
|
31
|
+
default="oklch",
|
|
32
|
+
choices=["wcag", "oklch", "lightness"],
|
|
33
|
+
help="Metric to use for measuring swatch distances."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# e.g., wcag=4.5; oklch=0.40; lightness=40
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"-d",
|
|
39
|
+
"--distance",
|
|
40
|
+
type=float,
|
|
41
|
+
default=0.40,
|
|
42
|
+
help="Distance threshold for specified metric",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"-o",
|
|
46
|
+
"--output",
|
|
47
|
+
type=str,
|
|
48
|
+
help="Output file to write scheme content",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# these params remain rooted in lightness; no need to accommodate metric
|
|
52
|
+
# given these are monotone adjustments. You *could* consider rooting these
|
|
53
|
+
# in metric units, but along monotones, distance=lightness and WCAG isn't a
|
|
54
|
+
# particularly good measure of perceptual distinction, so we'd prefer the
|
|
55
|
+
# former.
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"-l",
|
|
58
|
+
"--l-base",
|
|
59
|
+
type=int,
|
|
60
|
+
default=20,
|
|
61
|
+
help="Minimum lightness level (default: 20)",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--l-step",
|
|
65
|
+
type=int,
|
|
66
|
+
default=5,
|
|
67
|
+
help="Lightness step size (default: 5)",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# gaps
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--fg-gap",
|
|
73
|
+
type=int,
|
|
74
|
+
default=50,
|
|
75
|
+
help="Foreground lightness gap (default: 50)",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--grey-gap",
|
|
79
|
+
type=int,
|
|
80
|
+
default=30,
|
|
81
|
+
help="Grey lightness gap (default: 30)",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--term-fg-gap",
|
|
85
|
+
type=int,
|
|
86
|
+
default=65,
|
|
87
|
+
help="Terminal foreground lightness gap (default: 60)",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
parser.set_defaults(func=handle_scheme)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def handle_scheme(args: argparse.Namespace) -> None:
|
|
94
|
+
output = args.output
|
|
95
|
+
|
|
96
|
+
mode = args.mode
|
|
97
|
+
biome = args.biome
|
|
98
|
+
metric = args.metric
|
|
99
|
+
distance = args.distance
|
|
100
|
+
l_base = args.l_base
|
|
101
|
+
l_step = args.l_step
|
|
102
|
+
fg_gap = args.fg_gap
|
|
103
|
+
grey_gap = args.grey_gap
|
|
104
|
+
term_fg_gap = args.term_fg_gap
|
|
105
|
+
|
|
106
|
+
full_color_map = {
|
|
107
|
+
"red": "red",
|
|
108
|
+
"orange": "orange",
|
|
109
|
+
"yellow": "yellow",
|
|
110
|
+
"green": "green",
|
|
111
|
+
"cyan": "cyan",
|
|
112
|
+
"blue": "blue",
|
|
113
|
+
"violet": "violet",
|
|
114
|
+
"magenta": "orange",
|
|
115
|
+
}
|
|
116
|
+
term_color_map = {
|
|
117
|
+
"red": "red",
|
|
118
|
+
"yellow": "yellow",
|
|
119
|
+
"green": "green",
|
|
120
|
+
"cyan": "blue",
|
|
121
|
+
"blue": "blue",
|
|
122
|
+
"magenta": "orange",
|
|
123
|
+
}
|
|
124
|
+
vim_color_map = {
|
|
125
|
+
"red": "red",
|
|
126
|
+
"orange": "orange",
|
|
127
|
+
"yellow": "yellow",
|
|
128
|
+
"green": "green",
|
|
129
|
+
"cyan": "green",
|
|
130
|
+
"blue": "blue",
|
|
131
|
+
"violet": "blue",
|
|
132
|
+
"magenta": "red",
|
|
133
|
+
}
|
|
134
|
+
# vim_color_map = full_color_map
|
|
135
|
+
|
|
136
|
+
scheme_text = generate_scheme(
|
|
137
|
+
mode,
|
|
138
|
+
biome,
|
|
139
|
+
metric,
|
|
140
|
+
distance,
|
|
141
|
+
l_base,
|
|
142
|
+
l_step,
|
|
143
|
+
fg_gap,
|
|
144
|
+
grey_gap,
|
|
145
|
+
term_fg_gap,
|
|
146
|
+
full_color_map,
|
|
147
|
+
term_color_map,
|
|
148
|
+
vim_color_map,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if output is None:
|
|
152
|
+
print(scheme_text)
|
|
153
|
+
else:
|
|
154
|
+
with Path(output).open("w") as f:
|
|
155
|
+
f.write(scheme_text)
|
monobiome/constants.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from monobiome.curve import (
|
|
7
|
+
l_maxC_h,
|
|
8
|
+
bezier_y_at_x,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
parameters_file = files("monobiome.data") / "parameters.toml"
|
|
12
|
+
parameters = tomllib.load(parameters_file.open("rb"))
|
|
13
|
+
|
|
14
|
+
L_min: int = parameters.get("L_min", 10)
|
|
15
|
+
L_max: int = parameters.get("L_max", 98)
|
|
16
|
+
L_step: int = parameters.get("L_step", 5)
|
|
17
|
+
|
|
18
|
+
L_points: list[int] = list(range(L_min, L_max+1))
|
|
19
|
+
|
|
20
|
+
# L-space just affects accuracy of chroma max
|
|
21
|
+
L_space = np.arange(0, 100 + L_step, L_step)
|
|
22
|
+
|
|
23
|
+
monotone_C_map = parameters.get("monotone_C_map", {})
|
|
24
|
+
h_weights = parameters.get("h_weights", {})
|
|
25
|
+
h_L_offsets = parameters.get("h_L_offsets", {})
|
|
26
|
+
h_C_offsets = parameters.get("h_C_offsets", {})
|
|
27
|
+
monotone_h_map = parameters.get("monotone_h_map", {})
|
|
28
|
+
accent_h_map = parameters.get("accent_h_map", {})
|
|
29
|
+
h_map = {**monotone_h_map, **accent_h_map}
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
Compute chroma maxima at provided lightness levels across hues.
|
|
33
|
+
|
|
34
|
+
A map with max chroma values for each hue across lightness space
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
"red": [ Cmax@L=10, Cmax@L=11, Cmax@L=12, ... ],
|
|
38
|
+
"orange": [ Cmax@L=10, Cmax@L=11, Cmax@L=12, ... ],
|
|
39
|
+
...
|
|
40
|
+
}
|
|
41
|
+
"""
|
|
42
|
+
Lspace_Cmax_Hmap = {
|
|
43
|
+
h_str: [l_maxC_h(_L, _h) for _L in L_space]
|
|
44
|
+
for h_str, _h in h_map.items()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
Set QBR curves, *unbounded* chroma curves for all hues
|
|
50
|
+
|
|
51
|
+
1. Raw bezier chroma values for each hue across the lightness space
|
|
52
|
+
|
|
53
|
+
Lpoints_Cqbr_Hmap = {
|
|
54
|
+
"red": [ Bezier@L=10, Bezier@L=11, Bezier@L=12, ... ],
|
|
55
|
+
...
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
2. Three bezier control points for each hue's chroma curve
|
|
59
|
+
|
|
60
|
+
QBR_ctrl_Hmap = {
|
|
61
|
+
"red": np.array([
|
|
62
|
+
[ x1, y1 ],
|
|
63
|
+
[ x2, y2 ],
|
|
64
|
+
[ x3, y3 ]
|
|
65
|
+
]),
|
|
66
|
+
...
|
|
67
|
+
}
|
|
68
|
+
"""
|
|
69
|
+
Lpoints_Cqbr_Hmap = {}
|
|
70
|
+
QBR_ctrl_Hmap = {}
|
|
71
|
+
|
|
72
|
+
for h_str, _h in monotone_h_map.items():
|
|
73
|
+
Lpoints_Cqbr_Hmap[h_str] = np.array(
|
|
74
|
+
[monotone_C_map[h_str]]*len(L_points)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for h_str, _h in accent_h_map.items():
|
|
78
|
+
Lspace_Cmax = Lspace_Cmax_Hmap[h_str]
|
|
79
|
+
|
|
80
|
+
# get L value of max chroma; will be a bezier control
|
|
81
|
+
L_Cmax_idx = np.argmax(Lspace_Cmax)
|
|
82
|
+
L_Cmax = L_space[L_Cmax_idx]
|
|
83
|
+
|
|
84
|
+
# offset control point by any preset x-shift
|
|
85
|
+
L_Cmax += h_L_offsets[h_str]
|
|
86
|
+
|
|
87
|
+
# and get max C at the L offset
|
|
88
|
+
Cmax = l_maxC_h(L_Cmax, _h)
|
|
89
|
+
|
|
90
|
+
# set 3 control points; shift by any global linear offest
|
|
91
|
+
C_offset = h_C_offsets.get(h_str, 0)
|
|
92
|
+
|
|
93
|
+
p_0 = np.array([0, 0])
|
|
94
|
+
p_Cmax = np.array([L_Cmax, Cmax + C_offset])
|
|
95
|
+
p_100 = np.array([100, 0])
|
|
96
|
+
|
|
97
|
+
B_L_points = bezier_y_at_x(
|
|
98
|
+
p_0, p_Cmax, p_100,
|
|
99
|
+
h_weights.get(h_str, 1),
|
|
100
|
+
L_points
|
|
101
|
+
)
|
|
102
|
+
Lpoints_Cqbr_Hmap[h_str] = B_L_points
|
|
103
|
+
QBR_ctrl_Hmap[h_str] = np.vstack([p_0, p_Cmax, p_100])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
Bezier chroma values, but bounded to attainable gamut colors (bezier fit
|
|
108
|
+
can produce invalid chroma values)
|
|
109
|
+
|
|
110
|
+
h_L_points_Cstar = {
|
|
111
|
+
"red": [ bounded-bezier@L=10, bounded-bezier@L=11, ... ],
|
|
112
|
+
...
|
|
113
|
+
}
|
|
114
|
+
"""
|
|
115
|
+
Lpoints_Cstar_Hmap = {}
|
|
116
|
+
|
|
117
|
+
for h_str, L_points_C in Lpoints_Cqbr_Hmap.items():
|
|
118
|
+
_h = h_map[h_str]
|
|
119
|
+
|
|
120
|
+
Lpoints_Cstar_Hmap[h_str] = [
|
|
121
|
+
max(0, min(_C, l_maxC_h(_L, _h)))
|
|
122
|
+
for _L, _C in zip(L_points, L_points_C, strict=True)
|
|
123
|
+
]
|
monobiome/curve.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from functools import cache
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from coloraide import Color
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def quad_bezier_rational(
|
|
8
|
+
P0: float,
|
|
9
|
+
P1: float,
|
|
10
|
+
P2: float,
|
|
11
|
+
w: float,
|
|
12
|
+
t: np.array,
|
|
13
|
+
) -> np.array:
|
|
14
|
+
"""
|
|
15
|
+
Compute the point values of a quadratic rational Bezier curve.
|
|
16
|
+
|
|
17
|
+
Uses `P0`, `P1`, and `P2` as the three control points of the curve. `w`
|
|
18
|
+
controls the weight toward the middle control point ("sharpness" of the
|
|
19
|
+
curve"), and `t` is the number of sample points used along the curve.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
t = np.asarray(t)[:, None]
|
|
23
|
+
num = (1-t)**2*P0 + 2*w*(1-t)*t*P1 + t**2*P2
|
|
24
|
+
den = (1-t)**2 + 2*w*(1-t)*t + t**2
|
|
25
|
+
|
|
26
|
+
return num / den
|
|
27
|
+
|
|
28
|
+
def bezier_y_at_x(
|
|
29
|
+
P0: float,
|
|
30
|
+
P1: float,
|
|
31
|
+
P2: float,
|
|
32
|
+
w: float,
|
|
33
|
+
x: float,
|
|
34
|
+
n: int = 400,
|
|
35
|
+
) -> np.array:
|
|
36
|
+
"""
|
|
37
|
+
For the provided QBR parameters, provide the curve value at the given
|
|
38
|
+
input.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
t = np.linspace(0, 1, n)
|
|
42
|
+
B = quad_bezier_rational(P0, P1, P2, w, t)
|
|
43
|
+
x_vals, y_vals = B[:, 0], B[:, 1]
|
|
44
|
+
|
|
45
|
+
return np.interp(x, x_vals, y_vals)
|
|
46
|
+
|
|
47
|
+
@cache
|
|
48
|
+
def l_maxC_h(
|
|
49
|
+
_l: float,
|
|
50
|
+
_h: float,
|
|
51
|
+
space: str = 'srgb',
|
|
52
|
+
eps: float = 1e-6,
|
|
53
|
+
tol: float = 1e-9
|
|
54
|
+
) -> float:
|
|
55
|
+
"""
|
|
56
|
+
Binary search for max attainable OKLCH chroma at fixed lightness and hue.
|
|
57
|
+
|
|
58
|
+
Parameters:
|
|
59
|
+
_l: lightness
|
|
60
|
+
_h: hue
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Max in-gamut chroma at provided lightness and hue
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def chroma_in_gamut(_c: float) -> bool:
|
|
67
|
+
color = Color('oklch', [_l/100, _c, _h])
|
|
68
|
+
return color.convert(space).in_gamut(tolerance=tol)
|
|
69
|
+
|
|
70
|
+
lo, hi = 0.0, 0.1
|
|
71
|
+
while chroma_in_gamut(hi):
|
|
72
|
+
hi *= 2
|
|
73
|
+
while hi - lo > eps:
|
|
74
|
+
m = (lo + hi) / 2
|
|
75
|
+
lo, hi = (m, hi) if chroma_in_gamut(m) else (lo, m)
|
|
76
|
+
|
|
77
|
+
return lo
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
L_min = 10
|
|
2
|
+
L_max = 98
|
|
3
|
+
L_step = 5
|
|
4
|
+
|
|
5
|
+
[monotone_C_map]
|
|
6
|
+
alpine = 0
|
|
7
|
+
badlands = 0.011
|
|
8
|
+
chaparral = 0.011
|
|
9
|
+
savanna = 0.011
|
|
10
|
+
grassland = 0.011
|
|
11
|
+
reef = 0.011
|
|
12
|
+
tundra = 0.011
|
|
13
|
+
heathland = 0.011
|
|
14
|
+
moorland = 0.011
|
|
15
|
+
|
|
16
|
+
[h_weights]
|
|
17
|
+
red = 3.0
|
|
18
|
+
orange = 3.8
|
|
19
|
+
yellow = 3.8
|
|
20
|
+
green = 3.8
|
|
21
|
+
cyan = 3.8
|
|
22
|
+
blue = 3.6
|
|
23
|
+
violet = 3.0
|
|
24
|
+
magenta = 3.6
|
|
25
|
+
|
|
26
|
+
[h_L_offsets]
|
|
27
|
+
red = 0
|
|
28
|
+
orange = -5.5
|
|
29
|
+
yellow = -13.5
|
|
30
|
+
green = -12.5
|
|
31
|
+
cyan = -10.5
|
|
32
|
+
blue = 9
|
|
33
|
+
violet = 7
|
|
34
|
+
magenta = 2
|
|
35
|
+
|
|
36
|
+
[h_C_offsets]
|
|
37
|
+
red = 0
|
|
38
|
+
orange = -0.015
|
|
39
|
+
yellow = -0.052
|
|
40
|
+
green = -0.08
|
|
41
|
+
cyan = -0.009
|
|
42
|
+
blue = -0.01
|
|
43
|
+
violet = -0.047
|
|
44
|
+
magenta = -0.1
|
|
45
|
+
|
|
46
|
+
[monotone_h_map]
|
|
47
|
+
alpine = 0
|
|
48
|
+
badlands = 29
|
|
49
|
+
chaparral = 62.5
|
|
50
|
+
savanna = 104
|
|
51
|
+
grassland = 148
|
|
52
|
+
reef = 205
|
|
53
|
+
tundra = 262
|
|
54
|
+
heathland = 306
|
|
55
|
+
moorland = 350
|
|
56
|
+
|
|
57
|
+
[accent_h_map]
|
|
58
|
+
red = 29
|
|
59
|
+
orange = 62.5
|
|
60
|
+
yellow = 104
|
|
61
|
+
green = 148
|
|
62
|
+
cyan = 205
|
|
63
|
+
blue = 262
|
|
64
|
+
violet = 306
|
|
65
|
+
magenta = 350
|
monobiome/palette.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from functools import cache
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from coloraide import Color
|
|
6
|
+
|
|
7
|
+
from monobiome.constants import (
|
|
8
|
+
h_map,
|
|
9
|
+
L_points,
|
|
10
|
+
Lpoints_Cstar_Hmap,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@cache
|
|
15
|
+
def compute_hlc_map(notation: str) -> dict[str, dict[int, str]]:
|
|
16
|
+
hlc_map = {}
|
|
17
|
+
|
|
18
|
+
for h_str, Lpoints_Cstar in Lpoints_Cstar_Hmap.items():
|
|
19
|
+
_h = h_map[h_str]
|
|
20
|
+
hlc_map[h_str] = {}
|
|
21
|
+
|
|
22
|
+
for _l, _c in zip(L_points, Lpoints_Cstar, strict=True):
|
|
23
|
+
oklch = Color('oklch', [_l/100, _c, _h])
|
|
24
|
+
|
|
25
|
+
if notation == "hex":
|
|
26
|
+
srgb = oklch.convert('srgb')
|
|
27
|
+
c_str = srgb.to_string(hex=True)
|
|
28
|
+
elif notation == "oklch":
|
|
29
|
+
ol, oc, oh = oklch.convert('oklch').coords()
|
|
30
|
+
c_str = f"oklch({ol*100:.1f}% {oc:.4f} {oh:.1f})"
|
|
31
|
+
|
|
32
|
+
hlc_map[h_str][_l] = c_str
|
|
33
|
+
|
|
34
|
+
return hlc_map
|
|
35
|
+
|
|
36
|
+
def generate_palette(
|
|
37
|
+
notation: str,
|
|
38
|
+
file_format: str,
|
|
39
|
+
) -> str:
|
|
40
|
+
mb_version = version("monobiome")
|
|
41
|
+
hlc_map = compute_hlc_map(notation)
|
|
42
|
+
|
|
43
|
+
if file_format == "json":
|
|
44
|
+
hlc_map["version"] = mb_version
|
|
45
|
+
return json.dumps(hlc_map, indent=4)
|
|
46
|
+
else:
|
|
47
|
+
toml_lines = [f"version = {mb_version}", ""]
|
|
48
|
+
for _h, _lc_map in hlc_map.items():
|
|
49
|
+
toml_lines.append(f"[{_h}]")
|
|
50
|
+
for _l, _c in _lc_map.items():
|
|
51
|
+
toml_lines.append(f'l{_l} = "{_c}"')
|
|
52
|
+
toml_lines.append("")
|
|
53
|
+
|
|
54
|
+
return "\n".join(toml_lines)
|
monobiome/plotting.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
|
|
4
|
+
from monobiome.constants import (
|
|
5
|
+
h_map,
|
|
6
|
+
L_space,
|
|
7
|
+
L_points,
|
|
8
|
+
accent_h_map,
|
|
9
|
+
monotone_h_map,
|
|
10
|
+
Lspace_Cmax_Hmap,
|
|
11
|
+
Lpoints_Cstar_Hmap,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def plot_hue_chroma_bounds() -> None:
|
|
16
|
+
name_h_map = {}
|
|
17
|
+
ax_h_map = {}
|
|
18
|
+
fig, axes = plt.subplots(
|
|
19
|
+
len(monotone_h_map),
|
|
20
|
+
1,
|
|
21
|
+
sharex=True,
|
|
22
|
+
sharey=True,
|
|
23
|
+
figsize=(4, 10)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
for i, h_str in enumerate(Lpoints_Cstar_Hmap):
|
|
27
|
+
_h = h_map[h_str]
|
|
28
|
+
|
|
29
|
+
l_space_Cmax = Lspace_Cmax_Hmap[h_str]
|
|
30
|
+
l_points_Cstar = Lpoints_Cstar_Hmap[h_str]
|
|
31
|
+
|
|
32
|
+
if _h not in ax_h_map:
|
|
33
|
+
ax_h_map[_h] = axes[i]
|
|
34
|
+
ax = ax_h_map[_h]
|
|
35
|
+
|
|
36
|
+
if _h not in name_h_map:
|
|
37
|
+
name_h_map[_h] = []
|
|
38
|
+
name_h_map[_h].append(h_str)
|
|
39
|
+
|
|
40
|
+
# plot Cmax and Cstar
|
|
41
|
+
ax.plot(L_space, l_space_Cmax, c="g", alpha=0.3, label="Cmax")
|
|
42
|
+
|
|
43
|
+
cstar_label = f"{'accent' if h_str in accent_h_map else 'monotone'} C*"
|
|
44
|
+
ax.plot(L_points, l_points_Cstar, alpha=0.7, label=cstar_label)
|
|
45
|
+
|
|
46
|
+
ax.title.set_text(f"Hue [${_h}$] - {'|'.join(name_h_map[_h])}")
|
|
47
|
+
|
|
48
|
+
axes[-1].set_xlabel("Lightness (%)")
|
|
49
|
+
axes[-1].set_xticks([L_points[0], L_points[-1]])
|
|
50
|
+
|
|
51
|
+
fig.tight_layout()
|
|
52
|
+
fig.subplots_adjust(top=0.9)
|
|
53
|
+
|
|
54
|
+
handles, labels = axes[-1].get_legend_handles_labels()
|
|
55
|
+
unique = dict(zip(labels, handles))
|
|
56
|
+
fig.legend(unique.values(), unique.keys(), loc='lower center', bbox_to_anchor=(0.5, -0.06), ncol=3)
|
|
57
|
+
|
|
58
|
+
plt.suptitle("$C^*$ curves for hue groups")
|
|
59
|
+
plt.show()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def plot_hue_chroma_star() -> None:
|
|
63
|
+
fig, ax = plt.subplots(1, 1, figsize=(8, 6))
|
|
64
|
+
|
|
65
|
+
# uncomment to preview 5 core term colors
|
|
66
|
+
colors = accent_h_map.keys()
|
|
67
|
+
#colors = set(["red", "orange", "yellow", "green", "blue"])
|
|
68
|
+
|
|
69
|
+
for h_str in Lpoints_Cstar_Hmap:
|
|
70
|
+
if h_str not in accent_h_map or h_str not in colors:
|
|
71
|
+
continue
|
|
72
|
+
ax.fill_between(
|
|
73
|
+
L_points,
|
|
74
|
+
Lpoints_Cstar_Hmap[h_str],
|
|
75
|
+
alpha=0.2,
|
|
76
|
+
color='grey',
|
|
77
|
+
label=h_str
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
x, y = L_points, Lpoints_Cstar_Hmap[h_str]
|
|
81
|
+
n = int(0.45*len(x))
|
|
82
|
+
ax.text(x[n], y[n]-0.01, h_str, rotation=10, va='center', ha='left')
|
|
83
|
+
|
|
84
|
+
ax.set_xlabel("Lightness (%)")
|
|
85
|
+
ax.set_xticks([L_points[0], 45, 50, 55, 60, 65, 70, L_points[-1]])
|
|
86
|
+
plt.suptitle("$C^*$ curves (v1.4.0)")
|
|
87
|
+
fig.show()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def palette_image(palette, cell_size=40, keys=None):
|
|
91
|
+
if keys is None:
|
|
92
|
+
names = list(palette.keys())
|
|
93
|
+
else:
|
|
94
|
+
names = keys
|
|
95
|
+
|
|
96
|
+
row_count = len(names)
|
|
97
|
+
col_counts = [len(palette[n]) for n in names]
|
|
98
|
+
max_cols = max(col_counts)
|
|
99
|
+
|
|
100
|
+
h = row_count * cell_size
|
|
101
|
+
w = max_cols * cell_size
|
|
102
|
+
img = np.ones((h, w, 3), float)
|
|
103
|
+
|
|
104
|
+
lightness_keys_per_row = []
|
|
105
|
+
|
|
106
|
+
for r, name in enumerate(names):
|
|
107
|
+
shades = palette[name]
|
|
108
|
+
keys = sorted(shades.keys())
|
|
109
|
+
lightness_keys_per_row.append(keys)
|
|
110
|
+
for c, k in enumerate(keys):
|
|
111
|
+
col = Color(shades[k]).convert("srgb").fit(method="clip")
|
|
112
|
+
rgb = [col["r"], col["g"], col["b"]]
|
|
113
|
+
r0, r1 = r * cell_size, (r + 1) * cell_size
|
|
114
|
+
c0, c1 = c * cell_size, (c + 1) * cell_size
|
|
115
|
+
img[r0:r1, c0:c1, :] = rgb
|
|
116
|
+
|
|
117
|
+
return img, names, lightness_keys_per_row, cell_size, max_cols
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def show_palette(palette, cell_size=40, keys=None):
|
|
121
|
+
img, names, keys, cell_size, max_cols = palette_image(palette, cell_size, keys=keys)
|
|
122
|
+
|
|
123
|
+
fig_w = img.shape[1] / 100
|
|
124
|
+
fig_h = img.shape[0] / 100
|
|
125
|
+
fig, ax = plt.subplots(figsize=(fig_w, fig_h))
|
|
126
|
+
|
|
127
|
+
ax.imshow(img, interpolation="none", origin="upper")
|
|
128
|
+
ax.set_xticks([])
|
|
129
|
+
|
|
130
|
+
ytick_pos = [(i + 0.5) * cell_size for i in range(len(names))]
|
|
131
|
+
ax.set_yticks(ytick_pos)
|
|
132
|
+
ax.set_yticklabels(names)
|
|
133
|
+
|
|
134
|
+
ax.set_ylim(img.shape[0], 0) # ensures rows render correctly without half-cells
|
|
135
|
+
|
|
136
|
+
plt.show()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
from monobiome.constants import OKLCH_hL_dict
|
|
141
|
+
|
|
142
|
+
keys = [
|
|
143
|
+
"alpine",
|
|
144
|
+
"badlands",
|
|
145
|
+
"chaparral",
|
|
146
|
+
"savanna",
|
|
147
|
+
"grassland",
|
|
148
|
+
"reef",
|
|
149
|
+
"tundra",
|
|
150
|
+
"heathland",
|
|
151
|
+
"moorland",
|
|
152
|
+
"orange",
|
|
153
|
+
"yellow",
|
|
154
|
+
"green",
|
|
155
|
+
"cyan",
|
|
156
|
+
"blue",
|
|
157
|
+
"violet",
|
|
158
|
+
"magenta",
|
|
159
|
+
"red",
|
|
160
|
+
]
|
|
161
|
+
term_keys = [
|
|
162
|
+
"alpine",
|
|
163
|
+
"badlands",
|
|
164
|
+
"chaparral",
|
|
165
|
+
"savanna",
|
|
166
|
+
"grassland",
|
|
167
|
+
"tundra",
|
|
168
|
+
"red",
|
|
169
|
+
"orange",
|
|
170
|
+
"yellow",
|
|
171
|
+
"green",
|
|
172
|
+
"blue",
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
show_palette(OKLCH_hL_dict, cell_size=25, keys=keys)
|
|
176
|
+
# show_palette(OKLCH_hL_dict, cell_size=1, keys=term_keys)
|
monobiome/scheme.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
from functools import cache
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
|
|
4
|
+
from coloraide import Color
|
|
5
|
+
|
|
6
|
+
from monobiome.util import oklch_distance
|
|
7
|
+
from monobiome.palette import compute_hlc_map
|
|
8
|
+
from monobiome.constants import (
|
|
9
|
+
accent_h_map,
|
|
10
|
+
monotone_h_map,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@cache
|
|
15
|
+
def compute_dma_map(
|
|
16
|
+
dT: float,
|
|
17
|
+
metric: Callable | None = None
|
|
18
|
+
) -> dict[str, dict]:
|
|
19
|
+
"""
|
|
20
|
+
For threshold `dT`, compute the nearest accent shades that exceed that
|
|
21
|
+
threshold for every monotone shade.
|
|
22
|
+
|
|
23
|
+
Returns: map of minimum constraint satisfying accent colors for monotone
|
|
24
|
+
spectra
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
"alpine": {
|
|
28
|
+
"oklch( ... )": {
|
|
29
|
+
"red": *nearest oklch >= dT from M base*,
|
|
30
|
+
...
|
|
31
|
+
},
|
|
32
|
+
...
|
|
33
|
+
},
|
|
34
|
+
...
|
|
35
|
+
}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
if metric is None:
|
|
39
|
+
metric = oklch_distance
|
|
40
|
+
|
|
41
|
+
oklch_hlc_map = compute_hlc_map("oklch")
|
|
42
|
+
oklch_color_map = {
|
|
43
|
+
c_name: [Color(c_str) for c_str in c_str_dict.values()]
|
|
44
|
+
for c_name, c_str_dict in oklch_hlc_map.items()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
dT_mL_acol_map = {}
|
|
48
|
+
for m_name in monotone_h_map:
|
|
49
|
+
mL_acol_map = {}
|
|
50
|
+
m_colors = oklch_color_map[m_name]
|
|
51
|
+
|
|
52
|
+
for m_color in m_colors:
|
|
53
|
+
acol_min_map = {}
|
|
54
|
+
|
|
55
|
+
for a_name in accent_h_map:
|
|
56
|
+
a_colors = oklch_color_map[a_name]
|
|
57
|
+
oklch_dists = filter(
|
|
58
|
+
lambda d: (d[1] - dT) >= 0,
|
|
59
|
+
[
|
|
60
|
+
(ac, metric(m_color, ac))
|
|
61
|
+
for ac in a_colors
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
oklch_dists = list(oklch_dists)
|
|
65
|
+
if oklch_dists:
|
|
66
|
+
min_a_color = min(oklch_dists, key=lambda t: t[1])[0]
|
|
67
|
+
acol_min_map[a_name] = min_a_color
|
|
68
|
+
|
|
69
|
+
# make sure the current monotone level has *all* accents; o/w
|
|
70
|
+
# ignore
|
|
71
|
+
if len(acol_min_map) < len(accent_h_map):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
mL = m_color.coords()[0]
|
|
75
|
+
mL_acol_map[int(mL*100)] = acol_min_map
|
|
76
|
+
dT_mL_acol_map[m_name] = mL_acol_map
|
|
77
|
+
|
|
78
|
+
return dT_mL_acol_map
|
|
79
|
+
|
|
80
|
+
def generate_scheme_groups(
|
|
81
|
+
mode: str,
|
|
82
|
+
biome: str,
|
|
83
|
+
metric: str,
|
|
84
|
+
distance: float,
|
|
85
|
+
l_base: int,
|
|
86
|
+
l_step: int,
|
|
87
|
+
fg_gap: int,
|
|
88
|
+
grey_gap: int,
|
|
89
|
+
term_fg_gap: int,
|
|
90
|
+
accent_color_map: dict[str, str],
|
|
91
|
+
) -> tuple[dict[str, str], ...]:
|
|
92
|
+
"""
|
|
93
|
+
Parameters:
|
|
94
|
+
mode: one of ["dark", "light"]
|
|
95
|
+
biome: biome setting
|
|
96
|
+
metric: one of ["wcag", "oklch", "lightness"]
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
metric_map = {
|
|
100
|
+
"wcag": lambda mc,ac: ac.contrast(mc, method='wcag21'),
|
|
101
|
+
"oklch": oklch_distance,
|
|
102
|
+
"lightness": lambda mc,ac: abs(mc.coords()[0]-ac.coords()[0])*100,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
metric_func = metric_map[metric]
|
|
106
|
+
dT_mL_acol_map = compute_dma_map(distance, metric=metric_func)
|
|
107
|
+
Lma_map = {
|
|
108
|
+
m_name: mL_acol_dict[l_base]
|
|
109
|
+
for m_name, mL_acol_dict in dT_mL_acol_map.items()
|
|
110
|
+
if l_base in mL_acol_dict
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# the `mL_acol_dict` only includes lightnesses where all accent colors were
|
|
114
|
+
# within threshold. Coverage here will be partial if, at the `mL`, there is
|
|
115
|
+
# some monotone base that doesn't have all accents within threshold. This
|
|
116
|
+
# can happen at the edge, e.g., alpine@L15 has all accents w/in the
|
|
117
|
+
# distance, but the red accent was too far under tundra@L15, so there's no
|
|
118
|
+
# entry. This particular case is fairly rare; it's more likely that *all*
|
|
119
|
+
# monotones are undefined. Either way, both such cases lead to partial
|
|
120
|
+
# scheme coverage.
|
|
121
|
+
if len(Lma_map) < len(monotone_h_map):
|
|
122
|
+
print(f"Warning: partial scheme coverage for {l_base=}@{distance=}")
|
|
123
|
+
if biome not in Lma_map:
|
|
124
|
+
print(f"Biome {biome} unable to meet {metric} constraints")
|
|
125
|
+
accent_colors = Lma_map.get(biome, {})
|
|
126
|
+
|
|
127
|
+
meta_pairs = [
|
|
128
|
+
("mode", mode),
|
|
129
|
+
("biome", biome),
|
|
130
|
+
("metric", metric),
|
|
131
|
+
("distance", distance),
|
|
132
|
+
("l_base", l_base),
|
|
133
|
+
("l_step", l_step),
|
|
134
|
+
("fg_gap", fg_gap),
|
|
135
|
+
("grey_gap", grey_gap),
|
|
136
|
+
("term_fg_gap", term_fg_gap),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
# note how selection_bg steps up by `l_step`, selection_fg steps down by
|
|
140
|
+
# `l_step` (from their respective bases)
|
|
141
|
+
term_pairs = [
|
|
142
|
+
("background", f"f{{{{{biome}.l{l_base}}}}}"),
|
|
143
|
+
("selection_bg", f"f{{{{{biome}.l{l_base+l_step}}}}}"),
|
|
144
|
+
("selection_fg", f"f{{{{{biome}.l{l_base+term_fg_gap-l_step}}}}}"),
|
|
145
|
+
("foreground", f"f{{{{{biome}.l{l_base+term_fg_gap}}}}}"),
|
|
146
|
+
("cursor", f"f{{{{{biome}.l{l_base+term_fg_gap-l_step}}}}}"),
|
|
147
|
+
("cursor_text", f"f{{{{{biome}.l{l_base+l_step}}}}}"),
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
monotone_pairs = []
|
|
151
|
+
monotone_pairs += [
|
|
152
|
+
(f"bg{i}", f"f{{{{{biome}.l{l_base+i*l_step}}}}}")
|
|
153
|
+
for i in range(4)
|
|
154
|
+
]
|
|
155
|
+
monotone_pairs += [
|
|
156
|
+
(f"fg{3-i}", f"f{{{{{biome}.l{fg_gap+l_base+i*l_step}}}}}")
|
|
157
|
+
for i in range(4)
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
accent_pairs = [
|
|
161
|
+
("black", f"f{{{{{biome}.l{l_base}}}}}"),
|
|
162
|
+
("grey", f"f{{{{{biome}.l{l_base+grey_gap}}}}}"),
|
|
163
|
+
("white", f"f{{{{{biome}.l{l_base+term_fg_gap-2*l_step}}}}}"),
|
|
164
|
+
]
|
|
165
|
+
for color_name, mb_accent in accent_color_map.items():
|
|
166
|
+
aL = int(100*accent_colors[mb_accent].coords()[0])
|
|
167
|
+
accent_pairs.append(
|
|
168
|
+
(
|
|
169
|
+
color_name,
|
|
170
|
+
f"f{{{{{mb_accent}.l{aL}}}}}"
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return meta_pairs, term_pairs, monotone_pairs, accent_pairs
|
|
175
|
+
|
|
176
|
+
def generate_scheme(
|
|
177
|
+
mode: str,
|
|
178
|
+
biome: str,
|
|
179
|
+
metric: str,
|
|
180
|
+
distance: float,
|
|
181
|
+
l_base: int,
|
|
182
|
+
l_step: int,
|
|
183
|
+
fg_gap: int,
|
|
184
|
+
grey_gap: int,
|
|
185
|
+
term_fg_gap: int,
|
|
186
|
+
full_color_map: dict[str, str],
|
|
187
|
+
term_color_map: dict[str, str],
|
|
188
|
+
vim_color_map: dict[str, str],
|
|
189
|
+
) -> str:
|
|
190
|
+
l_sys = l_base
|
|
191
|
+
l_app = l_base + l_step
|
|
192
|
+
|
|
193
|
+
term_bright_offset = 10
|
|
194
|
+
|
|
195
|
+
# negate gaps if mode is light
|
|
196
|
+
if mode == "light":
|
|
197
|
+
l_step *= -1
|
|
198
|
+
fg_gap *= -1
|
|
199
|
+
grey_gap *= -1
|
|
200
|
+
term_fg_gap *= -1
|
|
201
|
+
term_bright_offset *= -1
|
|
202
|
+
|
|
203
|
+
meta, _, mt, ac = generate_scheme_groups(
|
|
204
|
+
mode, biome, metric, distance,
|
|
205
|
+
l_sys, l_step,
|
|
206
|
+
fg_gap, grey_gap, term_fg_gap,
|
|
207
|
+
full_color_map
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
_, term, _, term_norm_ac = generate_scheme_groups(
|
|
211
|
+
mode, biome, metric, distance,
|
|
212
|
+
l_app, l_step,
|
|
213
|
+
fg_gap, grey_gap, term_fg_gap,
|
|
214
|
+
term_color_map
|
|
215
|
+
)
|
|
216
|
+
_, _, _, term_bright_ac = generate_scheme_groups(
|
|
217
|
+
mode, biome, metric, distance,
|
|
218
|
+
l_app + term_bright_offset, l_step,
|
|
219
|
+
fg_gap, grey_gap, term_fg_gap,
|
|
220
|
+
term_color_map
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
_, _, vim_mt, vim_ac = generate_scheme_groups(
|
|
224
|
+
mode, biome, metric, distance,
|
|
225
|
+
l_app, l_step,
|
|
226
|
+
fg_gap, grey_gap, term_fg_gap,
|
|
227
|
+
vim_color_map
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def pair_strings(pair_list: list[tuple[str, str]]) -> list[str]:
|
|
231
|
+
return [
|
|
232
|
+
f"{lhs:<12} = \"{rhs}\""
|
|
233
|
+
for lhs, rhs in pair_list
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
scheme_pairs = []
|
|
237
|
+
scheme_pairs += pair_strings(meta)
|
|
238
|
+
scheme_pairs += pair_strings(mt)
|
|
239
|
+
scheme_pairs += pair_strings(ac)
|
|
240
|
+
|
|
241
|
+
scheme_pairs += ["", "[term]"]
|
|
242
|
+
scheme_pairs += pair_strings(term)
|
|
243
|
+
|
|
244
|
+
scheme_pairs += ["", "[term.normal]"]
|
|
245
|
+
scheme_pairs += pair_strings(term_norm_ac)
|
|
246
|
+
|
|
247
|
+
scheme_pairs += ["", "[term.bright]"]
|
|
248
|
+
scheme_pairs += pair_strings(term_bright_ac)
|
|
249
|
+
|
|
250
|
+
scheme_pairs += ["", "[vim]"]
|
|
251
|
+
scheme_pairs += pair_strings(vim_mt)
|
|
252
|
+
scheme_pairs += pair_strings(vim_ac)
|
|
253
|
+
|
|
254
|
+
return "\n".join(scheme_pairs)
|
monobiome/util.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from types import GenericAlias
|
|
3
|
+
from argparse import ArgumentParser, _SubParsersAction
|
|
4
|
+
|
|
5
|
+
from coloraide import Color
|
|
6
|
+
|
|
7
|
+
_SubParsersAction.__class_getitem__ = classmethod(GenericAlias)
|
|
8
|
+
_SubparserType = _SubParsersAction[ArgumentParser]
|
|
9
|
+
|
|
10
|
+
def oklch_distance(xc: Color, yc: Color) -> float:
|
|
11
|
+
"""
|
|
12
|
+
Compute the distance between two colors in OKLCH space.
|
|
13
|
+
|
|
14
|
+
Note: `xc` and `yc` are presumed to be OKLCH colors already, such that
|
|
15
|
+
`.coords()` yields an `(l, c, h)` triple directly rather than first
|
|
16
|
+
requiring conversion. When we can make this assumption, we save roughly an
|
|
17
|
+
order of magnitude in runtime.
|
|
18
|
+
|
|
19
|
+
1. `xc.distance(yc, space="oklch")`: 500k evals takes ~2s
|
|
20
|
+
2. This method: 500k evals takes ~0.2s
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
l1, c1, h1 = xc.coords()
|
|
24
|
+
l2, c2, h2 = yc.coords()
|
|
25
|
+
|
|
26
|
+
rad1 = h1 / 180 * math.pi
|
|
27
|
+
rad2 = h2 / 180 * math.pi
|
|
28
|
+
x1, y1 = c1 * math.cos(rad1), c1 * math.sin(rad1)
|
|
29
|
+
x2, y2 = c2 * math.cos(rad2), c2 * math.sin(rad2)
|
|
30
|
+
|
|
31
|
+
dx = x1 - x2
|
|
32
|
+
dy = y1 - y2
|
|
33
|
+
dz = l1 - l2
|
|
34
|
+
|
|
35
|
+
return (dx**2 + dy**2 + dz**2)**0.5
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: monobiome
|
|
3
|
+
Version: 1.3.1
|
|
4
|
+
Summary: Monobiome color palette
|
|
5
|
+
Project-URL: Homepage, https://doc.olog.io/monobiome
|
|
6
|
+
Project-URL: Documentation, https://doc.olog.io/monobiome
|
|
7
|
+
Project-URL: Repository, https://git.olog.io/olog/monobiome
|
|
8
|
+
Project-URL: Issues, https://git.olog.io/olog/monobiome/issues
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: coloraide>=5.1
|
|
12
|
+
Requires-Dist: imageio[ffmpeg]>=2.37.2
|
|
13
|
+
Requires-Dist: ipython>=9.6.0
|
|
14
|
+
Requires-Dist: kaleido>=1.1.0
|
|
15
|
+
Requires-Dist: matplotlib>=3.10.7
|
|
16
|
+
Requires-Dist: nbformat>=5.10.4
|
|
17
|
+
Requires-Dist: numpy>=2.3.4
|
|
18
|
+
Requires-Dist: pillow>=12.0.0
|
|
19
|
+
Requires-Dist: plotly>=6.3.1
|
|
20
|
+
Requires-Dist: scipy>=1.16.2
|
|
21
|
+
|
|
22
|
+
# Monobiome
|
|
23
|
+
`monobiome` is a minimal, balanced color palette for use in terminals and text
|
|
24
|
+
editors. It was designed in OKLCH space to achieve perceptual uniformity across
|
|
25
|
+
all hues at various levels of luminance, and does so for _five_ monotone bases
|
|
26
|
+
and _five_ accent colors (plus one gray "default"). Each of the monotone base
|
|
27
|
+
colors (named according to a natural biome whose colors they loosely resemble)
|
|
28
|
+
are designed to achieve identical contrast with the accents, and thus any one
|
|
29
|
+
of the options can be selected to change the feeling of the palette without
|
|
30
|
+
sacrificing readability.
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
_(Preview of default light and dark theme variants)_
|
|
34
|
+
|
|
35
|
+
See screenshots for the full set of theme variants in [THEMES](THEMES.md) (also
|
|
36
|
+
discussed below).
|
|
37
|
+
|
|
38
|
+
The name "monobiome" connects the palette to its two key sources of
|
|
39
|
+
inspiration:
|
|
40
|
+
|
|
41
|
+
- `mono-`: `monobiome` is inspired by the [`monoindustrial` theme][1], and
|
|
42
|
+
attempts to extend and balance its accents while retaining similar color
|
|
43
|
+
identities.
|
|
44
|
+
- `-biome`: the desire for several distinct monotone options entailed finding a
|
|
45
|
+
way to ground the subtle color variations that were needed, and I liked the
|
|
46
|
+
idea of tying the choices to naturally occurring environmental variation like
|
|
47
|
+
Earth's biomes (even if it is a very loose affiliation, e.g., green-ish =
|
|
48
|
+
grass, basically).
|
|
49
|
+
|
|
50
|
+
## Palette
|
|
51
|
+
The `monobiome` palette consists of four monotone bases and five accent colors,
|
|
52
|
+
each of which is anchored by hue and spread uniformly across lightness levels
|
|
53
|
+
15 to 95 (in OKLCH space).
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+
The chroma curve for each accent is carefully designed to vary smoothly across
|
|
58
|
+
the lightness spectrum, with the goal of retaining strong color identity in all
|
|
59
|
+
settings. Additionally, as alluded to above, the (WCAG 2) contrast ratio
|
|
60
|
+
between any choice of monotone background at a given lightness level and the
|
|
61
|
+
accent colors is virtually identical ($\pm 0.1$). Put another way, the relative
|
|
62
|
+
contrast between accents depends only on the _lightness_ of the background
|
|
63
|
+
monotone, not its hue. *(Note that this is not generally the case; at a fixed
|
|
64
|
+
lightness level, the contrast between two colors depends on their hue.)*
|
|
65
|
+
|
|
66
|
+
## Concrete themes
|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
70
|
+
*(Light and dark theme splits of Alpine and Tundra biomes)*
|
|
71
|
+
|
|
72
|
+
Themes are derived from the `monobiome` palette by varying both the monotone
|
|
73
|
+
hue (the "biome") and the extent of the background/foreground lightness (the
|
|
74
|
+
"harshness"). This is done for both light and dark schemes, and in each case
|
|
75
|
+
accent colors are selected at a lightness level that ensures each meet a
|
|
76
|
+
minimum contrast relative to the primary background. The following diagram
|
|
77
|
+
shows each of the 36 resulting combinations:
|
|
78
|
+
|
|
79
|
+

|
|
80
|
+
|
|
81
|
+
The "soft" harshness level uses monotone shades closer to the mid-shade
|
|
82
|
+
(lightness level 55), whereas "hard" harshness uses shades further from it.
|
|
83
|
+
Once the biome and harshness level are chosen, we're left with a bounded
|
|
84
|
+
monotone range over which common theme elements can be defined. For example,
|
|
85
|
+
the following demonstrates how background and foreground elements are chosen
|
|
86
|
+
for the `monobiome` Vim themes:
|
|
87
|
+
|
|
88
|
+

|
|
92
|
+
|
|
93
|
+
Note how theme elements are mapped onto the general identifiers `bg0-bg3` for
|
|
94
|
+
backgrounds, `fg0-fg3` for foregrounds, and `gray` for a central gray tone. The
|
|
95
|
+
relative properties (lightness differences, contrast ratios) between colors
|
|
96
|
+
assigned to these identifiers are preserved regardless of biome or harshness
|
|
97
|
+
(e.g., `bg3` and `gray` are _always_ separated by 20 lightness points in any
|
|
98
|
+
theme). As a result, applying `monobiome` themes to specific applications can
|
|
99
|
+
effectively boil down to defining a single "relative template" that uses these
|
|
100
|
+
identifiers, after which any of the 36 theme options can applied immediately.
|
|
101
|
+
|
|
102
|
+
Read more about how themes are created in [DESIGN](DESIGN.md).
|
|
103
|
+
|
|
104
|
+
# Usage
|
|
105
|
+
This repo provides the 36 theme files for `kitty`, `vim`/`neovim`, and `fzf` in
|
|
106
|
+
the `app-config/` directory. You can also find raw palette colors in
|
|
107
|
+
`colors/monobiome.toml` if you want to use them to define themes for other
|
|
108
|
+
applications.
|
|
109
|
+
|
|
110
|
+
Each of the files in the `app-config/` directory are named according to
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
<harshness>-<biome>-monobiome-<mode>.<ext>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For example, `monobiome-tundra-dark-soft.vim` is the Vim theme file for the
|
|
117
|
+
dark `tundra` variant with the soft harshness level.
|
|
118
|
+
|
|
119
|
+
## Applications
|
|
120
|
+
- `kitty`
|
|
121
|
+
|
|
122
|
+
Find `kitty` themes in `app-config/kitty`. Themes can be activated in your
|
|
123
|
+
`kitty.conf` with
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
include <theme-file>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Themes are generated using the [`kitty` theme
|
|
130
|
+
template](templates/apps/kitty/templates/active.theme).
|
|
131
|
+
|
|
132
|
+
- `vim`/`neovim`
|
|
133
|
+
|
|
134
|
+
Find `vim`/`neovim` themes in `app-config/nvim`. Themes can be activated by placing a
|
|
135
|
+
theme file on Vim's runtime path and setting it in your `.vimrc`/`init.vim`
|
|
136
|
+
with
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
colorscheme <theme-name>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Themes are generated using the [`vim` theme
|
|
143
|
+
template](templates/apps/nvim/templates/theme.vim).
|
|
144
|
+
|
|
145
|
+
- `fzf`
|
|
146
|
+
|
|
147
|
+
In `app-config/fzf`, you can find scripts that can be ran to export FZF theme
|
|
148
|
+
variables. In your shell config (e.g., `.bashrc` or `.zshrc`), you can source
|
|
149
|
+
these files to apply them in your terminal:
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
source <theme-file>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Themes are generated using the [`fzf` theme
|
|
156
|
+
template](templates/apps/fzf/templates/active.theme).
|
|
157
|
+
|
|
158
|
+
- Firefox
|
|
159
|
+
|
|
160
|
+
Firefox themes for all monotone backgrounds are publicly listed as [Mozilla
|
|
161
|
+
add-ons][2], and switch between light/dark schemes based on system settings.
|
|
162
|
+
You can also download raw XPI files for each theme in `app-config/firefox/`,
|
|
163
|
+
each of which is generated using the [Firefox `manifest.json`
|
|
164
|
+
template](templates/apps/firefox/templates/none-dark.manifest.json).
|
|
165
|
+
|
|
166
|
+
Static [light][4] and [dark][5] are additionally available.
|
|
167
|
+
|
|
168
|
+

|
|
169
|
+
|
|
170
|
+
# Switching themes
|
|
171
|
+
[`symconf`][3] is a general-purpose application config manager that can be used
|
|
172
|
+
to generate all `monobiome` variants from a single palette file, and set themes
|
|
173
|
+
for all apps at once. You can find example theme templates in
|
|
174
|
+
`templates/groups/theme`, which provide general theme variables you can use in
|
|
175
|
+
your own config templates.
|
|
176
|
+
|
|
177
|
+
For instance, in an app like `kitty`, you can define a template like
|
|
178
|
+
|
|
179
|
+
```conf
|
|
180
|
+
# base settings
|
|
181
|
+
background f{{theme.term.background}}
|
|
182
|
+
foreground f{{theme.term.foreground}}
|
|
183
|
+
|
|
184
|
+
selection_background f{{theme.term.selection_bg}}
|
|
185
|
+
selection_foreground f{{theme.term.selection_fg}}
|
|
186
|
+
|
|
187
|
+
cursor f{{theme.term.cursor}}
|
|
188
|
+
cursor_text_color f{{theme.term.cursor_text_color}}
|
|
189
|
+
|
|
190
|
+
# black
|
|
191
|
+
color0 f{{theme.term.normal.black}}
|
|
192
|
+
color8 f{{theme.term.bright.black}}
|
|
193
|
+
|
|
194
|
+
# red
|
|
195
|
+
color1 f{{theme.term.normal.red}}
|
|
196
|
+
color9 f{{theme.term.bright.red}}
|
|
197
|
+
|
|
198
|
+
# green
|
|
199
|
+
color2 f{{theme.term.normal.green}}
|
|
200
|
+
color10 f{{theme.term.bright.green}}
|
|
201
|
+
|
|
202
|
+
# yellow
|
|
203
|
+
color3 f{{theme.term.normal.yellow}}
|
|
204
|
+
color11 f{{theme.term.bright.yellow}}
|
|
205
|
+
|
|
206
|
+
# blue
|
|
207
|
+
color4 f{{theme.term.normal.blue}}
|
|
208
|
+
color12 f{{theme.term.bright.blue}}
|
|
209
|
+
|
|
210
|
+
# purple (red)
|
|
211
|
+
color5 f{{theme.term.normal.purple}}
|
|
212
|
+
color13 f{{theme.term.bright.purple}}
|
|
213
|
+
|
|
214
|
+
# cyan (blue)
|
|
215
|
+
color6 f{{theme.term.normal.cyan}}
|
|
216
|
+
color14 f{{theme.term.bright.cyan}}
|
|
217
|
+
|
|
218
|
+
## white
|
|
219
|
+
color7 f{{theme.term.normal.white}}
|
|
220
|
+
color15 f{{theme.term.bright.white}}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
and use `symconf` to dynamically fill these variables based on a selected
|
|
224
|
+
biome/harshness/mode. This can be done for any app config file.
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
[1]: https://github.com/isa/TextMate-Themes/blob/master/monoindustrial.tmTheme
|
|
228
|
+
[2]: https://addons.mozilla.org/en-US/firefox/collections/18495484/monobiome/
|
|
229
|
+
[3]: https://github.com/ologio/symconf
|
|
230
|
+
[4]: https://addons.mozilla.org/en-US/firefox/collections/18495484/monobiome-light/
|
|
231
|
+
[5]: https://addons.mozilla.org/en-US/firefox/collections/18495484/monobiome-dark/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
monobiome/__init__.py,sha256=ChC5YFLK0kgvi0MJwD68LtZgUMJVCtNbtY32UQTvdA4,75
|
|
2
|
+
monobiome/__main__.py,sha256=k2Pu_FeAsW_wnE55Y-JJaRP_GgxAZEjW3z_Kmk3O8C8,431
|
|
3
|
+
monobiome/constants.py,sha256=w4H9V2Tp2ZK3ddcjtBdDtokZdj2Y1ssW3RSopxqP7rw,3105
|
|
4
|
+
monobiome/curve.py,sha256=tw44OoRGDSxTzljxJkgifWhCTEj05TBYnw4jOdrNgfA,1748
|
|
5
|
+
monobiome/palette.py,sha256=fReZD1Aa7xOKQQgnYB8qRxszZy1nq9XLqjUIblENsvI,1489
|
|
6
|
+
monobiome/plotting.py,sha256=1eAJY-0PLtq2r4ZHYCY3YYnVlCVu7PXteAcs1zV4irY,4679
|
|
7
|
+
monobiome/scheme.py,sha256=CFP_WqTk-CwbgpVt2E_TczC9cZymAh_lqbkWmN4AsOg,7541
|
|
8
|
+
monobiome/util.py,sha256=qHLC-azOgslJcW1tNNX5TVeG3RPGpleUO2s9Nu1rbjY,1068
|
|
9
|
+
monobiome/cli/__init__.py,sha256=wtBhzdyyRy0-WM4fUpDESJBiedYy8MbwousVCdangUE,774
|
|
10
|
+
monobiome/cli/palette.py,sha256=i3baWZs4Sverbd79YMBPYnH0P2rT0i7z3ZAtO_UBWNw,1219
|
|
11
|
+
monobiome/cli/scheme.py,sha256=CkwtGBCPUEPt2TnK_WDQBg8TKTw_hjGoQtbjjBlNmH0,3725
|
|
12
|
+
monobiome/data/parameters.toml,sha256=7ru0j_1G5rNFWc7AFKSHJUpyL_I2qdZYeDFt6q5wtQw,801
|
|
13
|
+
monobiome-1.3.1.dist-info/METADATA,sha256=n_gLP_F0ghbpq3eco0ULz37Lmc1GnhFhsIPgHGnhNn0,8947
|
|
14
|
+
monobiome-1.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
15
|
+
monobiome-1.3.1.dist-info/entry_points.txt,sha256=LpqkPxdTacTY_TaRn8eczICmPbVlXdRSoMqaxSfVxh4,54
|
|
16
|
+
monobiome-1.3.1.dist-info/top_level.txt,sha256=ZA2wgRkPoG4xG0rSjyHKkuG8cdSHRr1U_DcrplXoi3A,10
|
|
17
|
+
monobiome-1.3.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
monobiome
|