Compare commits

...

7 Commits

Author SHA1 Message Date
Lendemor
bebe49a6a5 Merge branch 'main' into lendemor/add_custom_palette_function 2024-09-16 09:39:10 -07:00
Lendemor
be58a4c17e Merge branch 'main' into lendemor/add_custom_palette_function 2024-09-10 14:17:38 +02:00
Lendemor
68999a6f7c make coloraide dev dependencies 2024-09-06 18:30:20 +02:00
Lendemor
f36d1a0226 Merge branch 'main' into lendemor/add_custom_palette_function 2024-09-03 19:07:00 +02:00
Lendemor
d56e2ae532 remove coloraide dep 2024-09-03 19:00:57 +02:00
Lendemor
e93279890c add coloraide dependency for custom palette 2024-08-24 13:00:09 +02:00
Lendemor
d885cf6a66 add generate palette function in experimental 2024-08-24 12:54:14 +02:00
5 changed files with 4438 additions and 13 deletions

40
poetry.lock generated
View File

@ -363,6 +363,17 @@ files = [
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "coloraide"
version = "3.3.1"
description = "A color library for Python."
optional = false
python-versions = ">=3.8"
files = [
{file = "coloraide-3.3.1-py3-none-any.whl", hash = "sha256:b09183ea0f85cb48c9fabd9f77daf09d681d6a4cecad55793e413d27f5fcea42"},
{file = "coloraide-3.3.1.tar.gz", hash = "sha256:bb945bc61aa08ac0927fdaec5a1052a8b30e64e5e31c4c94f1e706a7617297d8"},
]
[[package]]
name = "colorama"
version = "0.4.6"
@ -582,13 +593,13 @@ test = ["pytest (>=6)"]
[[package]]
name = "fastapi"
version = "0.114.1"
version = "0.114.2"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{file = "fastapi-0.114.1-py3-none-any.whl", hash = "sha256:5d4746f6e4b7dff0b4f6b6c6d5445645285f662fe75886e99af7ee2d6b58bb3e"},
{file = "fastapi-0.114.1.tar.gz", hash = "sha256:1d7bbbeabbaae0acb0c22f0ab0b040f642d3093ca3645f8c876b6f91391861d8"},
{file = "fastapi-0.114.2-py3-none-any.whl", hash = "sha256:44474a22913057b1acb973ab90f4b671ba5200482e7622816d79105dcece1ac5"},
{file = "fastapi-0.114.2.tar.gz", hash = "sha256:0adb148b62edb09e8c6eeefa3ea934e8f276dabc038c5a82989ea6346050c3da"},
]
[package.dependencies]
@ -775,13 +786,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "identify"
version = "2.6.0"
version = "2.6.1"
description = "File identification library for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0"},
{file = "identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf"},
{file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"},
{file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"},
]
[package.extras]
@ -789,15 +800,18 @@ license = ["ukkonen"]
[[package]]
name = "idna"
version = "3.8"
version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.6"
files = [
{file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"},
{file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"},
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "importlib-metadata"
version = "8.5.0"
@ -1592,13 +1606,13 @@ testing = ["pytest", "pytest-cov", "wheel"]
[[package]]
name = "platformdirs"
version = "4.3.2"
version = "4.3.3"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
{file = "platformdirs-4.3.2-py3-none-any.whl", hash = "sha256:eb1c8582560b34ed4ba105009a4badf7f6f85768b30126f351328507b2beb617"},
{file = "platformdirs-4.3.2.tar.gz", hash = "sha256:9e5e27a08aa095dd127b9f2e764d74254f482fef22b0970773bfba79d091ab8c"},
{file = "platformdirs-4.3.3-py3-none-any.whl", hash = "sha256:50a5450e2e84f44539718293cbb1da0a0885c9d14adf21b77bae4e66fc99d9b5"},
{file = "platformdirs-4.3.3.tar.gz", hash = "sha256:d4e0b7d8ec176b341fb03cb11ca12d0276faa8c485f9cd218f613840463fc2c0"},
]
[package.extras]
@ -3009,4 +3023,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
content-hash = "7a8990e432a404802c3ace9a81c3c8c33cdd60596f26cdc38e2de424cb1126dd"
content-hash = "1ff3b9e0623564e88c348df348e2d9bc1e6f76c21961ded3102441067f02c1a0"

View File

@ -82,6 +82,7 @@ asynctest = ">=0.13.0,<1.0"
pre-commit = {version = ">=3.2.1", python = ">=3.8,<4.0"}
selenium = ">=4.11.0,<5.0"
pytest-benchmark = ">=4.0.0,<5.0"
coloraide=">=3.3.1"
[tool.poetry.scripts]
reflex = "reflex.reflex:cli"

View File

@ -0,0 +1,199 @@
"""A module for generating Bezier easing functions."""
# These values are established by empiricism with tests (tradeoff: performance VS precision)
NEWTON_ITERATIONS = 4
NEWTON_MIN_SLOPE = 0.001
SUBDIVISION_PRECISION = 0.0000001
SUBDIVISION_MAX_ITERATIONS = 10
kSplineTableSize = 11
kSampleStepSize = 1.0 / (kSplineTableSize - 1.0)
def A(aA1, aA2):
"""Calculate A.
Args:
aA1: The first value.
aA2: The second value.
Returns:
The calculated value.
"""
return 1.0 - 3.0 * aA2 + 3.0 * aA1
def B(aA1, aA2):
"""Calculate B.
Args:
aA1: The first value.
aA2: The second value.
Returns:
The calculated value.
"""
return 3.0 * aA2 - 6.0 * aA1
def C(aA1):
"""Calculate C.
Args:
aA1: The first value.
Returns:
The calculated value.
"""
return 3.0 * aA1
def calcBezier(aT, aA1, aA2):
"""Calculate Bezier.
Args:
aT: The time.
aA1: The first value.
aA2: The second value.
Returns:
x(t) given t, x1, and x2, or y(t) given t, y1, and y2.
"""
return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT
def getSlope(aT, aA1, aA2):
"""Calculate slope.
Args:
aT: The time.
aA1: The first value.
aA2: The second value.
Returns:
dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2.
"""
return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1)
def binarySubdivide(aX, aA, aB, mX1, mX2):
"""Perform a binary subdivide.
Args:
aX: The x value.
aA: The a value.
aB: The b value.
mX1: The x1 value.
mX2: The x2 value.
Returns:
The t value.
"""
current_x = aA
current_t = 0
i = 0
while True:
i += 1
if i >= SUBDIVISION_MAX_ITERATIONS:
break
current_t = aA + (aB - aA) / 2.0
current_x = calcBezier(current_t, mX1, mX2) - aX
if current_x > 0.0:
aB = current_t
else:
aA = current_t
if abs(current_x) <= SUBDIVISION_PRECISION:
break
return current_t
def newtonRaphsonIterate(aX, aGuessT, mX1, mX2):
"""Perform a Newton-Raphson iteration.
Args:
aX: The x value.
aGuessT: The guess value.
mX1: The x1 value.
mX2: The x2 value.
Returns:
The t value.
"""
for _ in range(NEWTON_ITERATIONS):
current_slope = getSlope(aGuessT, mX1, mX2)
if current_slope == 0.0:
return aGuessT
current_x = calcBezier(aGuessT, mX1, mX2) - aX
aGuessT -= current_x / current_slope
return aGuessT
def LinearEasing(x):
"""Linear easing function.
Args:
x: The x value.
Returns:
The x value.
"""
return x
def bezier(mX1, mY1, mX2, mY2):
"""Generate a Bezier easing function.
Args:
mX1: The x1 value.
mY1: The y1 value.
mX2: The x2 value.
mY2: The y2 value.
Raises:
ValueError: If the x values are not in the [0, 1] range.
Returns:
The Bezier easing function.
"""
if not (0 <= mX1 <= 1 and 0 <= mX2 <= 1):
raise ValueError("bezier x values must be in [0, 1] range")
if mX1 == mY1 and mX2 == mY2:
return LinearEasing
# Precompute samples table
sampleValues = [
calcBezier(i * kSampleStepSize, mX1, mX2) for i in range(kSplineTableSize)
]
def getTForX(aX):
intervalStart = 0.0
currentSample = 1
lastSample = kSplineTableSize - 1
while currentSample != lastSample and sampleValues[currentSample] <= aX:
intervalStart += kSampleStepSize
currentSample += 1
currentSample -= 1
# Interpolate to provide an initial guess for t
dist = (aX - sampleValues[currentSample]) / (
sampleValues[currentSample + 1] - sampleValues[currentSample]
)
guessForT = intervalStart + dist * kSampleStepSize
initialSlope = getSlope(guessForT, mX1, mX2)
if initialSlope >= NEWTON_MIN_SLOPE:
return newtonRaphsonIterate(aX, guessForT, mX1, mX2)
elif initialSlope == 0.0:
return guessForT
else:
return binarySubdivide(
aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2
)
def BezierEasing(x):
if x == 0 or x == 1:
return x
return calcBezier(getTForX(x), mY1, mY2)
return BezierEasing

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,608 @@
"""A module for generating Radix colors.
Converted from https://github.com/radix-ui/website/blob/main/components/generateRadixColors.tsx
"""
import math
from reflex.utils import console
try:
from coloraide import Color
except ImportError:
console.error(
"coloraide is not installed. Please install it to use this module. `pip install coloraide`"
)
exit(1)
from .bezier import bezier
from .default_colors import DEFAULT_DARK_COLORS, DEFAULT_LIGHT_COLORS
ArrayOf12 = list[float]
gray_scale_names = ["gray", "mauve", "slate", "sage", "olive", "sand"]
scale_names = gray_scale_names + [
"tomato",
"red",
"ruby",
"crimson",
"pink",
"plum",
"purple",
"violet",
"iris",
"indigo",
"blue",
"cyan",
"teal",
"jade",
"green",
"grass",
"brown",
"orange",
"sky",
"mint",
"lime",
"yellow",
"amber",
]
def get_base_colors(appearance: str):
"""Get the base colors from the default dicts.
Args:
appearance: The appearance of the colors.
Returns:
dict: The base colors.
"""
raw_colors = DEFAULT_LIGHT_COLORS if appearance == "light" else DEFAULT_DARK_COLORS
colors = {}
for color_name, color_scale in raw_colors.items():
f_scale = []
for scale in color_scale:
scale["coords"] = [
float("nan") if x is None else x for x in scale["coords"]
]
f_scale.append(Color(scale))
colors[color_name] = f_scale
return colors
light_colors = get_base_colors("light")
dark_colors = get_base_colors("dark")
light_gray_colors = {name: light_colors[name] for name in gray_scale_names}
dark_gray_colors = {name: dark_colors[name] for name in gray_scale_names}
def generate_radix_colors(
appearance: str, accent: str, gray: str, background: str
) -> dict:
"""Generate Radix colors.
Args:
appearance: The appearance of the colors.
accent: The accent color.
gray: The gray color.
background: The background color.
Returns:
dict: The generated colors.
"""
all_scales = light_colors if appearance == "light" else dark_colors
gray_scales = light_gray_colors if appearance == "light" else dark_gray_colors
background_color = Color(background).convert("oklch")
gray_base_color = Color(gray).convert("oklch")
gray_scale_colors = get_scale_from_color(
gray_base_color, gray_scales, background_color
)
accent_base_color = Color(accent).convert("oklch")
accent_scale_colors = get_scale_from_color(
accent_base_color, all_scales, background_color
)
background_hex = background_color.convert("srgb").to_string(hex=True)
accent_base_hex = accent_base_color.convert("srgb").to_string(hex=True)
if accent_base_hex == "#000000" or accent_base_hex == "#ffffff":
accent_scale_colors = [color.clone() for color in gray_scale_colors]
accent9_color, accent_contrast_color = get_step9_colors(
accent_scale_colors, accent_base_color
)
accent_scale_colors[8] = accent9_color
accent_scale_colors[9] = get_button_hover_color(
accent9_color, [accent_scale_colors]
)
# Limit saturation of the text colors
accent_scale_colors[10] = accent_scale_colors[10].set(
"oklch.c",
min(
max(
accent_scale_colors[8].get("oklch.c"),
accent_scale_colors[7].get("oklch.c"),
),
accent_scale_colors[10].get("oklch.c"),
),
)
accent_scale_colors[11] = accent_scale_colors[11].set(
"oklch.c",
min(
max(
accent_scale_colors[8].get("oklch.c"),
accent_scale_colors[7].get("oklch.c"),
),
accent_scale_colors[11].get("oklch.c"),
),
)
accent_scale_hex = [
color.convert("srgb").to_string(hex=True) for color in accent_scale_colors
]
accent_scale_wide_gamut = [to_oklch_string(color) for color in accent_scale_colors]
accent_scale_alpha_hex = [
get_alpha_color_srgb(color, background_hex) for color in accent_scale_hex
]
accent_scale_alpha_wide_gamut_string = [
get_alpha_color_p3(color, background_hex) for color in accent_scale_hex
]
accent_contrast_color_hex = accent_contrast_color.convert("srgb").to_string(
hex=True
)
gray_scale_hex = [
color.convert("srgb").to_string(hex=True) for color in gray_scale_colors
]
gray_scale_wide_gamut = [to_oklch_string(color) for color in gray_scale_colors]
gray_scale_alpha_hex = [
get_alpha_color_srgb(color, background_hex) for color in gray_scale_hex
]
gray_scale_alpha_wide_gamut_string = [
get_alpha_color_p3(color, background_hex) for color in gray_scale_hex
]
accent_surface_hex = (
get_alpha_color_srgb(accent_scale_hex[1], background_hex, 0.8)
if appearance == "light"
else get_alpha_color_srgb(accent_scale_hex[1], background_hex, 0.5)
)
accent_surface_wide_gamut_string = (
get_alpha_color_p3(accent_scale_wide_gamut[1], background_hex, 0.8)
if appearance == "light"
else get_alpha_color_p3(accent_scale_wide_gamut[1], background_hex, 0.5)
)
return {
"accentScale": accent_scale_hex,
"accentScaleAlpha": accent_scale_alpha_hex,
"accentScaleWideGamut": accent_scale_wide_gamut,
"accentScaleAlphaWideGamut": accent_scale_alpha_wide_gamut_string,
"accentContrast": accent_contrast_color_hex,
"grayScale": gray_scale_hex,
"grayScaleAlpha": gray_scale_alpha_hex,
"grayScaleWideGamut": gray_scale_wide_gamut,
"grayScaleAlphaWideGamut": gray_scale_alpha_wide_gamut_string,
"graySurface": "#ffffffcc" if appearance == "light" else "rgba(0, 0, 0, 0.05)",
"graySurfaceWideGamut": "color(display-p3 1 1 1 / 80%)"
if appearance == "light"
else "color(display-p3 0 0 0 / 5%)",
"accentSurface": accent_surface_hex,
"accentSurfaceWideGamut": accent_surface_wide_gamut_string,
"background": background_hex,
}
def get_step9_colors(
scale: list[Color], accent_base_color: Color
) -> tuple[Color, Color]:
"""Get the step 9 colors.
Args:
scale: The scale of colors.
accent_base_color: The accent base color.
Returns:
The step 9 colors.
"""
reference_background_color = scale[0]
distance = accent_base_color.delta_e(reference_background_color) * 100
if distance < 25:
return scale[8], get_text_color(scale[8])
return accent_base_color, get_text_color(accent_base_color)
def get_button_hover_color(source: Color, scales: list[list[Color]]) -> Color:
"""Get the button hover color.
Args:
source: The source color.
scales: The scales of colors.
Returns:
The button hover color.
"""
L, C, H = source["lightness"], source["chroma"], source["hue"]
new_L = L - 0.03 / (L + 0.1) if L > 0.4 else L + 0.03 / (L + 0.1)
new_C = C * 0.93 if L > 0.4 and not math.isnan(H) else C
button_hover_color = Color("oklch", [new_L, new_C, H])
closest_color = button_hover_color
min_distance = float("inf")
for scale in scales:
for color in scale:
distance = button_hover_color.delta_e(color)
if distance < min_distance:
min_distance = distance
closest_color = color
button_hover_color["chroma"] = closest_color["chroma"]
button_hover_color["hue"] = closest_color["hue"]
return button_hover_color
def get_text_color(background: Color) -> Color:
"""Get the text color.
Args:
background: The background color.
Returns:
The text color.
"""
white = Color("oklch", [1, 0, 0])
if abs(white.contrast(background)) < 40:
_, C, H = background["lightness"], background["chroma"], background["hue"]
return Color("oklch", [0.25, max(0.08 * C, 0.04), H])
return white
def get_alpha_color(
target_rgb: list[float],
background_rgb: list[float],
rgb_precision: int,
alpha_precision: int,
target_alpha: float | None = None,
) -> tuple[float, float, float, float]:
"""Get the alpha color.
Args:
target_rgb: The target RGB.
background_rgb: The background RGB.
rgb_precision: The RGB precision.
alpha_precision: The alpha precision.
target_alpha: The target alpha.
Raises:
ValueError: If the color is undefined.
Returns:
The alpha color.
"""
tr, tg, tb = [round(c * rgb_precision) for c in target_rgb]
br, bg, bb = [round(c * rgb_precision) for c in background_rgb]
if any(c is None for c in [tr, tg, tb, br, bg, bb]):
raise ValueError("Color is undefined")
desired_rgb = 0
if tr > br or tg > bg or tb > bb:
desired_rgb = rgb_precision
alpha_r = (tr - br) / (desired_rgb - br)
alpha_g = (tg - bg) / (desired_rgb - bg)
alpha_b = (tb - bb) / (desired_rgb - bb)
is_pure_gray = all(alpha == alpha_r for alpha in [alpha_r, alpha_g, alpha_b])
if not target_alpha and is_pure_gray:
v = desired_rgb / rgb_precision
return v, v, v, alpha_r
def clamp_rgb(n):
return 0 if n is None else min(rgb_precision, max(0, n))
def clamp_a(n):
return 0 if n is None else min(alpha_precision, max(0, n))
max_alpha = (
target_alpha if target_alpha is not None else max(alpha_r, alpha_g, alpha_b)
)
A = clamp_a(math.ceil(max_alpha * alpha_precision)) / alpha_precision
R = clamp_rgb(((br * (1 - A) - tr) / A) * -1)
G = clamp_rgb(((bg * (1 - A) - tg) / A) * -1)
B = clamp_rgb(((bb * (1 - A) - tb) / A) * -1)
R, G, B = map(math.ceil, [R, G, B])
blended_r = blend_alpha(R, A, br)
blended_g = blend_alpha(G, A, bg)
blended_b = blend_alpha(B, A, bb)
if desired_rgb == 0:
if tr <= br and tr != blended_r:
R += 1 if tr > blended_r else -1
if tg <= bg and tg != blended_g:
G += 1 if tg > blended_g else -1
if tb <= bb and tb != blended_b:
B += 1 if tb > blended_b else -1
if desired_rgb == rgb_precision:
if tr >= br and tr != blended_r:
R += 1 if tr > blended_r else -1
if tg >= bg and tg != blended_g:
G += 1 if tg > blended_g else -1
if tb >= bb and tb != blended_b:
B += 1 if tb > blended_b else -1
R /= rgb_precision
G /= rgb_precision
B /= rgb_precision
return R, G, B, A
def blend_alpha(foreground, alpha, background, _round=True) -> float:
"""Blend the alpha.
Args:
foreground: The foreground.
alpha: The alpha.
background: The background.
_round: Whether to round the result.
Returns:
The blended alpha.
"""
if _round:
return round(background * (1 - alpha)) + round(foreground * alpha)
return background * (1 - alpha) + foreground * alpha
def get_alpha_color_srgb(
target_color: str, background_color: str, target_alpha: float | None = None
) -> str:
"""Get the alpha color in srgb.
Args:
target_color: The target color.
background_color: The background color.
target_alpha: The target alpha.
Returns:
The alpha color.
"""
r, g, b, a = get_alpha_color(
Color(target_color).convert("srgb").coords(),
Color(background_color).convert("srgb").coords(),
255,
255,
target_alpha,
)
return Color("srgb", [r, g, b], a).to_string(format="hex")
def get_alpha_color_p3(
target_color: str, background_color: str, target_alpha: float | None = None
) -> str:
"""Get the alpha color in display-p3.
Args:
target_color: The target color.
background_color: The background color.
target_alpha: The target alpha.
Returns:
The alpha color.
"""
r, g, b, a = get_alpha_color(
Color(target_color).convert("display-p3").coords(),
Color(background_color).convert("display-p3").coords(),
255,
1000,
target_alpha,
)
return Color("display-p3", [r, g, b], a).to_string(precision=4)
def format_hex(s: str) -> str:
"""Format shortform hex to longform.
Args:
s: The hex color.
Returns:
The formatted hex color.
"""
if not s.startswith("#"):
return s
if len(s) == 4:
return f"#{s[1]}{s[1]}{s[2]}{s[2]}{s[3]}{s[3]}"
if len(s) == 5:
return f"#{s[1]}{s[1]}{s[2]}{s[2]}{s[3]}{s[3]}{s[4]}{s[4]}"
return s
dark_mode_easing = [1, 0, 1, 0]
light_mode_easing = [0, 2, 0, 2]
def to_oklch_string(color: Color) -> str:
"""Convert a color to an oklch string for CSS.
Args:
color: The color to convert.
Returns:
The oklch string.
"""
L = round(color["lightness"] * 100, 1)
return f"oklch({L}% {color['chroma']:.4f} {color['hue']:.4f})"
def get_scale_from_color(
source: Color, scales: dict[str, list[Color]], background_color: Color
) -> list[Color]:
"""Get a scale from a color.
Args:
source: The source color.
scales: The scales of colors.
background_color: The background color.
Returns:
The generated scale.
"""
all_colors = []
for name, scale in scales.items():
for color in scale:
distance = source.delta_e(color)
all_colors.append({"scale": name, "distance": distance, "color": color})
all_colors.sort(key=lambda x: x["distance"])
# Remove non-unique scales
closest_colors = []
seen_scales = set()
for color in all_colors:
if color["scale"] not in seen_scales:
closest_colors.append(color)
seen_scales.add(color["scale"])
# Handle gray scales
gray_scale_names = ["gray", "mauve", "slate", "sage", "olive", "sand"]
all_are_grays = all(color["scale"] in gray_scale_names for color in closest_colors)
if not all_are_grays and closest_colors[0]["scale"] in gray_scale_names:
while closest_colors[1]["scale"] in gray_scale_names:
del closest_colors[1]
color_a = closest_colors[0]
color_b = closest_colors[1]
# Calculate triangle sides
a = color_b["distance"]
b = color_a["distance"]
c = color_a["color"].delta_e(color_b["color"])
# Calculate angles
cos_a = (b**2 + c**2 - a**2) / (2 * b * c)
rad_a = math.acos(cos_a)
sin_a = math.sin(rad_a)
cos_b = (a**2 + c**2 - b**2) / (2 * a * c)
rad_b = math.acos(cos_b)
sin_b = math.sin(rad_b)
# Calculate tangents
tan_c1 = cos_a / sin_a
tan_c2 = cos_b / sin_b
# Calculate ratio
ratio = max(0, tan_c1 / tan_c2) * 0.5
# Mix scales
scale_a = scales[color_a["scale"]]
scale_b = scales[color_b["scale"]]
scale = [
Color.mix(scale_a[i], scale_b[i], ratio).convert("oklch") for i in range(12)
]
# Find base color
base_color = min(scale, key=lambda color: source.delta_e(color))
# Adjust chroma ratio
ratio_c = source.get("oklch.c") / base_color.get("oklch.c")
# Modify hue and chroma of the scale
for color in scale:
color = color.set(
"oklch.c", min(source.get("oklch.c") * 1.5, color.get("oklch.c") * ratio_c)
)
color = color.set("oklch.h", source.get("oklch.h"))
# Handle light and dark modes
if scale[0].get("oklch.l") > 0.5: # Light mode
lightness_scale = [color.get("oklch.l") for color in scale]
background_l = max(0, min(1, background_color.get("oklch.l")))
new_lightness_scale = transpose_progression_start(
background_l, lightness_scale, light_mode_easing
)
new_lightness_scale = new_lightness_scale[1:] # Remove the added step
for i, lightness in enumerate(new_lightness_scale):
scale[i] = scale[i].set("oklch.l", lightness)
else: # Dark mode
ease = list(dark_mode_easing)
reference_background_color_l = scale[0].get("oklch.l")
background_color_l = max(0, min(1, background_color.get("oklch.l")))
ratio_l = background_color_l / reference_background_color_l
if ratio_l > 1:
max_ratio = 1.5
for i in range(len(ease)):
meta_ratio = (ratio_l - 1) * (max_ratio / (max_ratio - 1))
ease[i] = ( # type: ignore
0 if ratio_l > max_ratio else max(0, ease[i] * (1 - meta_ratio))
)
lightness_scale = [color.get("oklch.l") for color in scale]
background_l = background_color.get("oklch.l")
new_lightness_scale = transpose_progression_start(
background_l, lightness_scale, ease
)
for i, lightness in enumerate(new_lightness_scale):
scale[i] = scale[i].set("oklch.l", lightness)
return scale
def transpose_progression_start(to: float, arr: list, curve: list) -> list[float]:
"""Transpose a progression to a new start point.
Args:
to: The new start point.
arr: The progression.
curve: The bezier curve.
Returns:
The transposed progression.
"""
last_index = len(arr) - 1
diff = arr[0] - to
fn = bezier(*curve)
return [n - diff * fn(1 - i / last_index) for i, n in enumerate(arr)]
def transpose_progression_end(
to: float, arr: list[float], curve: list[float]
) -> list[float]:
"""Transpose a progression to a new end point.
Args:
to: The new end point.
arr: The progression.
curve: The bezier curve.
Returns:
The transposed progression.
"""
last_index = len(arr) - 1
diff = arr[-1] - to
fn = bezier(*curve)
return [n - diff * fn(i / last_index) for i, n in enumerate(arr)]