diff --git a/kitty/colors.c b/kitty/colors.c index 4eb3b4120..f662d3906 100644 --- a/kitty/colors.c +++ b/kitty/colors.c @@ -8,6 +8,9 @@ #include "state.h" #include #include "colors.h" +#include +float strtof_l(const char *restrict nptr, char **restrict endptr, locale_t locale); +locale_t c_locale; static uint32_t FG_BG_256[256] = { @@ -776,8 +779,311 @@ contrast(Color* self, PyObject *o) { return PyFloat_FromDouble(rgb_contrast(self->color, other->color)); } +static char +hexchar_to_int(char c) { + if ('0' <= c && c <= '9') return c - '0'; + if ('a' <= c && c <= 'f') return c - 'a' + 10; + if ('A' <= c && c <= 'F') return c - 'A' + 10; + return -1; +} + +static bool +parse_base16_uchar(const char *hex, unsigned char *out) { + const char hi = hexchar_to_int(hex[0]); + const char lo = hexchar_to_int(hex[1]); + if (hi < 0 || lo < 0) return false; + *out = (unsigned char)((hi << 4) | lo); + return true; +} + +static bool +parse_double(const char *src, double *out) { + char *endptr; + errno = 0; + *out = strtod_l(src, &endptr, c_locale); + return endptr != src && *endptr == 0 && errno == 0; +} + +static bool +parse_single_color(const char *c, size_t len, unsigned char *out) { + char buf[2]; + if (len == 1) { buf[0] = c[0]; buf[1] = c[0]; c = buf; } + return parse_base16_uchar(c, out); +} + +static PyObject* +parse_sharp(const char *spec, size_t len) { + unsigned char r, g, b; + switch(len) { + case 3: + if (!parse_single_color(spec, 1, &r) || !parse_single_color(spec + 1, 1, &g) || !parse_single_color(spec + 2, 1, &b)) Py_RETURN_NONE; + break; + case 6: case 9: case 12: + if (!parse_single_color(spec, 2, &r) || !parse_single_color(spec + len/3, 2, &g) || !parse_single_color(spec + 2 * len / 3, 2, &b)) Py_RETURN_NONE; + break; + default: + Py_RETURN_NONE; + } + return (PyObject*)alloc_color(r, g, b, 0); +} + +static PyObject* +parse_rgb(const char *spec, size_t len) { + char buf[32]; + if (len >= sizeof(buf)) Py_RETURN_NONE; + memcpy(buf, spec, len); buf[len] = 0; + unsigned char r, g, b; char *tok; +#define p(buf, out) if (!(tok = strtok(buf, "/")) || !parse_single_color(tok, strlen(tok), &out)) Py_RETURN_NONE; + p(buf, r); p(NULL, g); p(NULL, b); +#undef p + return (PyObject*)alloc_color(r, g, b, 0); +} + +static unsigned char as8bit(double f) { return (unsigned char)((MAX(0., MIN(f, 1.))) * 255.); } + +static bool +parse_single_intensity(const char *s, unsigned char *out) { + double f; if (!parse_double(s, &f)) return false; + *out = as8bit(f); + return true; +} + +static PyObject* +parse_rgbi(const char *spec, size_t len) { + char buf[256]; + if (len >= sizeof(buf)) Py_RETURN_NONE; + memcpy(buf, spec, len); buf[len] = 0; + unsigned char r, g, b; char *tok; +#define p(buf, out) if (!(tok = strtok(buf, "/")) || !parse_single_intensity(tok, &out)) Py_RETURN_NONE; + p(buf, r); p(NULL, g); p(NULL, b); +#undef p + return (PyObject*)alloc_color(r, g, b, 0); +} + +static bool +parse_double_intensity(char *s, double *out, double percentage_divider) { + size_t l = strlen(s); + if (l == 0) return false; + double divisor = 1; + if (s[l-1] == '%') { s[l-1] = 0; divisor = percentage_divider; } + if (!parse_double(s, out)) return false; + *out /= divisor; + return true; +} + +static double clamp(const double f) { return MAX(0, MIN(f, 1)); } + +static double +linear_to_srgb(double c) { return c <= 0.0031308 ? c * 12.92 : (1.055 * pow(c, (1 / 2.4)) - 0.055); } + +static double degrees_to_radians(double degrees) { return degrees * (M_PI / 180); } +static double radians_to_degrees(double radians) { return 180 * radians / M_PI; } + +static void +oklch_to_srgb(double l, double c, double h, double *r, double *g, double *b) { + // Convert OKLCH to OKLab + const double h_rad = degrees_to_radians(h); + const double a = c * cos(h_rad); + const double lb = c * sin(h_rad); + // Convert OKLab to Linear sRGB + // Using the OKLab to Linear sRGB transformation + const double l_ = l + 0.3963377774 * a + 0.2158037573 * lb; + const double m_ = l - 0.1055613458 * a - 0.0638541728 * lb; + const double s_ = l - 0.0894841775 * a - 1.2914855480 * lb; + + const double l_lin = l_ * l_ * l_; + const double m_lin = m_ * m_ * m_; + const double s_lin = s_ * s_ * s_; + + const double r_lin = +4.0767416621 * l_lin - 3.3077115913 * m_lin + 0.2309699292 * s_lin; + const double g_lin = -1.2684380046 * l_lin + 2.6097574011 * m_lin - 0.3413193965 * s_lin; + const double b_lin = -0.0041960863 * l_lin - 0.7034186147 * m_lin + 1.7076147010 * s_lin; + + *r = linear_to_srgb(clamp(r_lin)); *g = linear_to_srgb(clamp(g_lin)); *b = linear_to_srgb(clamp(b_lin)); +} + +static double srgb_to_linear(double c) { return c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4); } + + +static void +srgb_to_oklab(double r, double g, double b, double *l, double *a, double *lb) { + // Convert sRGB to linear sRGB + const double r_lin = srgb_to_linear(r); + const double g_lin = srgb_to_linear(g); + const double b_lin = srgb_to_linear(b); + + // Convert Linear sRGB to OKLab (inverse of oklch_to_srgb) + const double l_lin = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin; + const double m_lin = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin; + const double s_lin = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin; + + const double l_ = l_lin != 0 ? copysign(pow(fabs(l_lin), 1./3.), l_lin) : 0; + const double m_ = m_lin != 0 ? copysign(pow(fabs(m_lin), 1./3.), m_lin) : 0; + const double s_ = s_lin != 0 ? copysign(pow(fabs(s_lin), 1./3.), s_lin) : 0; + + // OKLab coordinates + *l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_; + *a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; + *lb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; +} + +static double +distance(double x_l, double x_a, double x_b, double y_l, double y_a, double y_b) { + return sqrt((x_l - y_l)*(x_l - y_l) + (x_a - y_a)*(x_a - y_a) + (x_b - y_b)*(x_b - y_b)); +} + +static void +oklch_to_srgb_gamut_map(double l, double c, double h, double *r, double *g, double *b) { + // Edge cases: pure black or white don't need gamut mapping + if (!isfinite(l) || !isfinite(c) || !isfinite(h) || l <= 0) { *r = 0; *g = 0; *b = 0; return; } + if (l >= 1) { *r = 1; *g = 1; *b = 1; return; } + // Constants from CSS Color Module Level 4 + static const double JND = 0.02; // Just Noticeable Difference threshold (2% in deltaEOK) + static const double MIN_CONVERGENCE = 0.0001; // Binary search precision (0.01% chroma) + static const double EPSILON = 0.00001; // Small value for doubleing point comparisons + + // If chroma is very small, color is essentially achromatic + if (c < EPSILON) { *r = linear_to_srgb(l); *g = *r; *b = *r; return; } + // Try the original color first + oklch_to_srgb(l, c, h, r, g, b); +#define in_gamut(r,g,b) (0. <= r && r <= 1. && 0. <= g && g <= 1. && 0. <= b && b <= 1.) + if (in_gamut(*r,*g,*b)) return; + // Binary search for maximum in-gamut chroma + double low_chroma = 0, high_chroma = c, r_test, g_test, b_test, r_clipped, g_clipped, b_clipped; + + // Convert original color to OKLab for deltaE calculations + while ((high_chroma - low_chroma) > MIN_CONVERGENCE) { + double mid_chroma = (high_chroma + low_chroma) * 0.5; + // Try this chroma value + oklch_to_srgb(l, mid_chroma, h, &r_test, &g_test, &b_test); + // Check if in gamut (before clipping) + if (in_gamut(r_test, g_test, b_test)) { + // In gamut - try higher chroma + low_chroma = mid_chroma; + } else { + // Out of gamut - clip and check deltaE + r_clipped = clamp(r_test); g_clipped = clamp(g_test); b_clipped = clamp(b_test); + + // Convert both to OKLab for comparison + double l_test, a_test, lb_test, l_clipped, a_clipped, lb_clipped; + srgb_to_oklab(r_test, g_test, b_test, &l_test, &a_test, &lb_test); + srgb_to_oklab(r_clipped, g_clipped, b_clipped, &l_clipped, &a_clipped, &lb_clipped); + + // Calculate perceptual difference + double de = distance(l_test, a_test, lb_test, l_clipped, a_clipped, lb_clipped); + + if (de < JND) { + // Difference is imperceptible - accept this chroma + low_chroma = mid_chroma; + } else { + // Difference is noticeable - reduce chroma more + high_chroma = mid_chroma; + } + } + } + // Use the final chroma value and clip to ensure in-gamut + oklch_to_srgb(l, low_chroma, h, r, g, b); + *r = clamp(*r); *g = clamp(*g); *b = clamp(*b); +#undef in_gamut +} + +static double +f_inv(double t) { + static const double delta = 6. / 29.; + return t > delta ? t*t*t : 3 * delta * delta * (t - 4. / 29.); +} + + +static void +lab_to_oklch(double l, double a, double b, double *okl, double *c, double *h) { + const double y = (l + 16.) / 116.; + const double x = a / 500. + y; + const double z = y - b / 200.; + const double x_val = 0.95047 * f_inv(x); + const double y_val = f_inv(y); + const double z_val = 1.08883 * f_inv(z); + + // XYZ to Linear sRGB (don't clip here to preserve out-of-gamut info) + const double r_lin = +3.2404542 * x_val - 1.5371385 * y_val - 0.4985314 * z_val; + const double g_lin = -0.9692660 * x_val + 1.8760108 * y_val + 0.0415560 * z_val; + const double b_lin = +0.0556434 * x_val - 0.2040259 * y_val + 1.0572252 * z_val; + + // Convert linear sRGB to sRGB gamma + const double r_srgb = r_lin >= 0 ? linear_to_srgb(r_lin) : 0; + const double g_srgb = g_lin >= 0 ? linear_to_srgb(g_lin) : 0; + const double b_srgb = b_lin >= 0 ? linear_to_srgb(b_lin) : 0; + + // Convert to OKLab + double a_ok, b_ok; + srgb_to_oklab(r_srgb, g_srgb, b_srgb, okl, &a_ok, &b_ok); + // Convert OKLab to OKLCH + *c = sqrt(a_ok * a_ok + b_ok * b_ok); + *h = fmod(radians_to_degrees(atan2(b_ok, a_ok)), 360.f); +} + +static PyObject* +parse_oklch(const char *spec, size_t len) { + if (len < 10 || spec[--len] != ')') Py_RETURN_NONE; + if (spec[0] != 'k' || spec[1] != 'l' || spec[2] != 'c' || spec[3] != 'h' || spec[4] != '(') Py_RETURN_NONE; + spec += 5; len -= 5; + char buf[256]; if (len >= sizeof(buf)) Py_RETURN_NONE; + memcpy(buf, spec, len); buf[len] = 0; + double l, c, h; char *tok; +#define p(buf, out) if (!(tok = strtok(buf, " ,")) || !parse_double_intensity(tok, &out, 100)) Py_RETURN_NONE; + p(buf, l); p(NULL, c); p(NULL, h); +#undef p + // Clamp to reasonable ranges + l = clamp(l); + c = MAX(0.f, c); // Chroma is unbounded but we don't clamp high end + h = fmod(h, 360); // Wrap hue to 0-360 + double r, g, b; + oklch_to_srgb_gamut_map(l, c, h, &r, &g, &b); + return (PyObject*)alloc_color(as8bit(r), as8bit(g), as8bit(b), 0); +} + +static PyObject* +parse_lab(const char *spec, size_t len) { + if (len < 8 || spec[--len] != ')') Py_RETURN_NONE; + if (spec[0] != 'a' || spec[1] != 'b' || spec[2] != '(') Py_RETURN_NONE; + spec += 3; len -= 3; + char buf[256]; if (len >= sizeof(buf)) Py_RETURN_NONE; + memcpy(buf, spec, len); buf[len] = 0; + double l, a, b; char *tok; +#define p(buf, out) if (!(tok = strtok(buf, " ,")) || !parse_double_intensity(tok, &out, 1)) Py_RETURN_NONE; + p(buf, l); p(NULL, a); p(NULL, b); +#undef p + // Clamp to reasonable ranges + double okl, c, h, r, g, bb; + lab_to_oklch(MAX(0., MIN(l, 100.)), a, b, &okl, &c, &h); + oklch_to_srgb_gamut_map(okl, c, h, &r, &g, &bb); + return (PyObject*)alloc_color(as8bit(r), as8bit(g), as8bit(bb), 0); +} + +static PyObject* +parse_color(PyTypeObject *type UNUSED, PyObject *pspec) { + if (!PyUnicode_Check(pspec)) { PyErr_SetString(PyExc_TypeError, "spec must be a string"); return NULL; } + Py_ssize_t len; + const char *spec = PyUnicode_AsUTF8AndSize(pspec, &len); + if (len < 4) Py_RETURN_NONE; + switch (spec[0]) { + case '#': return parse_sharp(spec + 1, len - 1); + case 'r': + if (spec[1] != 'g' || spec[2] != 'b' || len < 6) Py_RETURN_NONE; + switch(spec[3]) { + case ':': return parse_rgb(spec + 4, len - 4); + case 'i': + if (spec[4] == 'i' && spec[5] == ':') return parse_rgbi(spec + 5, len - 5); + } + Py_RETURN_NONE; + case 'o': return parse_oklch(spec + 1, len - 1); + case 'l': return parse_lab(spec + 1, len - 1); + } + Py_RETURN_NONE; +} + static PyMethodDef color_methods[] = { METHODB(contrast, METH_O), + METHODB(parse_color, METH_O | METH_CLASS), {NULL} /* Sentinel */ }; @@ -816,6 +1122,7 @@ static PyMethodDef module_methods[] = { }; int init_ColorProfile(PyObject *module) {\ + c_locale = newlocale(LC_NUMERIC_MASK, "C", (locale_t)0); if (PyType_Ready(&ColorProfile_Type) < 0) return 0; if (PyModule_AddObject(module, "ColorProfile", (PyObject *)&ColorProfile_Type) != 0) return 0; Py_INCREF(&ColorProfile_Type); diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 8d2f0e560..3a4e97eed 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -740,6 +740,9 @@ def patch_global_colors(spec: Dict[str, Optional[int]], configured: bool) -> Non class Color: + @classmethod + def parse_color(cls, spec: str) -> Color | None: ... + @property def rgb(self) -> int: pass @@ -1071,6 +1074,7 @@ def mark_tab_bar_dirty(os_window_id: int, should_be_shown: bool) -> None: def is_tab_bar_visible(os_window_id: int) -> bool: ... + def detach_window(os_window_id: int, tab_id: int, window_id: int) -> None: pass diff --git a/kitty/rgb.py b/kitty/rgb.py index c35e5a973..3bfcd5438 100644 --- a/kitty/rgb.py +++ b/kitty/rgb.py @@ -1,10 +1,6 @@ #!/usr/bin/env python # License: GPL v3 Copyright: 2017, Kovid Goyal -import math -import re -from contextlib import suppress - from .fast_data_types import Color @@ -20,42 +16,6 @@ def alpha_blend(top_color: Color, bottom_color: Color, alpha: float) -> Color: ) -def parse_single_color(c: str) -> int: - if len(c) == 1: - c += c - return int(c[:2], 16) - - -def parse_sharp(spec: str) -> Color | None: - if len(spec) in (3, 6, 9, 12): - part_len = len(spec) // 3 - colors = re.findall(fr'[a-fA-F0-9]{{{part_len}}}', spec) - return Color(*map(parse_single_color, colors)) - return None - - -def parse_rgb(spec: str) -> Color | None: - colors = spec.split('/') - if len(colors) == 3: - return Color(*map(parse_single_color, colors)) - return None - - -def parse_single_intensity(x: str) -> int: - val = float(x) - # Validate for NaN and infinity - if not math.isfinite(val): - return 0 - return int(max(0, min(abs(val), 1)) * 255) - - -def parse_rgbi(spec: str) -> Color | None: - colors = spec.split('/') - if len(colors) == 3: - return Color(*map(parse_single_intensity, colors)) - return None - - def color_from_int(x: int) -> Color: return Color((x >> 16) & 255, (x >> 8) & 255, x & 255) @@ -72,381 +32,6 @@ def color_as_sgr(x: Color) -> str: return x.as_sgr -# Color space conversion functions - -def srgb_to_linear(c: float) -> float: - """Convert sRGB component (0-1) to linear light""" - if c <= 0.04045: - return c / 12.92 - return math.pow((c + 0.055) / 1.055, 2.4) - - -def linear_to_srgb(c: float) -> float: - """Convert linear light component (0-1) to sRGB""" - if c <= 0.0031308: - return c * 12.92 - return 1.055 * math.pow(c, (1 / 2.4)) - 0.055 - - -def oklch_to_srgb(l: float, c: float, h: float) -> tuple[float, float, float]: # noqa: E741 - """Convert OKLCH to sRGB RGB (0-1) - - OKLCH is a perceptual color space based on OKLab. - L: Lightness (0-1, typically 0-1) - C: Chroma (0-0.4, unbounded but practical max ~0.4) - H: Hue (0-360 degrees) - - Conversion path: OKLCH -> OKLab -> Linear sRGB -> sRGB - """ - # Convert OKLCH to OKLab - h_rad = math.radians(h) - a = c * math.cos(h_rad) - b = c * math.sin(h_rad) - - # Convert OKLab to Linear sRGB - # Using the OKLab to Linear sRGB transformation - l_ = l + 0.3963377774 * a + 0.2158037573 * b - m_ = l - 0.1055613458 * a - 0.0638541728 * b - s_ = l - 0.0894841775 * a - 1.2914855480 * b - - l_lin = l_ * l_ * l_ - m_lin = m_ * m_ * m_ - s_lin = s_ * s_ * s_ - - r_lin = +4.0767416621 * l_lin - 3.3077115913 * m_lin + 0.2309699292 * s_lin - g_lin = -1.2684380046 * l_lin + 2.6097574011 * m_lin - 0.3413193965 * s_lin - b_lin = -0.0041960863 * l_lin - 0.7034186147 * m_lin + 1.7076147010 * s_lin - - # Clip to valid range - r_lin = max(0.0, min(1.0, r_lin)) - g_lin = max(0.0, min(1.0, g_lin)) - b_lin = max(0.0, min(1.0, b_lin)) - - # Convert linear sRGB to sRGB - return (linear_to_srgb(r_lin), linear_to_srgb(g_lin), linear_to_srgb(b_lin)) - - -def srgb_to_oklab(r: float, g: float, b: float) -> tuple[float, float, float]: - """Convert sRGB RGB (0-1) to OKLab - - Reverse conversion from sRGB to OKLab. - Needed for deltaE calculations in gamut mapping. - - Conversion path: sRGB -> Linear sRGB -> OKLab - """ - # Convert sRGB to linear sRGB - r_lin = srgb_to_linear(r) - g_lin = srgb_to_linear(g) - b_lin = srgb_to_linear(b) - - # Convert Linear sRGB to OKLab (inverse of oklch_to_srgb) - l_lin = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin - m_lin = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin - s_lin = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin - - l_ = math.copysign(abs(l_lin) ** (1/3), l_lin) if l_lin != 0 else 0 - m_ = math.copysign(abs(m_lin) ** (1/3), m_lin) if m_lin != 0 else 0 - s_ = math.copysign(abs(s_lin) ** (1/3), s_lin) if s_lin != 0 else 0 - - # OKLab coordinates - l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_ # noqa: E741 - a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_ - b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_ - - return (l, a, b) - - -def deltaE_ok(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float: - """Calculate deltaE in OKLab space (Euclidean distance) - - This is the color difference metric used in CSS Color Module Level 4 - for gamut mapping. It measures perceptual difference between two colors. - - Args: - lab1: First color in OKLab coordinates (L, a, b) - lab2: Second color in OKLab coordinates (L, a, b) - - Returns: - Perceptual color difference (deltaE OK) - """ - return math.sqrt( - (lab1[0] - lab2[0]) ** 2 + - (lab1[1] - lab2[1]) ** 2 + - (lab1[2] - lab2[2]) ** 2 - ) - - -def oklch_to_srgb_gamut_map(l: float, c: float, h: float) -> tuple[float, float, float]: # noqa: E741 - """Convert OKLCH to sRGB with CSS Color Module Level 4 gamut mapping - - For colors outside the sRGB gamut, this uses binary search chroma reduction - to find the maximum displayable chroma while preserving lightness and hue. - - This implements the algorithm from CSS Color Module Level 4 Section 13: - https://www.w3.org/TR/css-color-4/#css-gamut-mapping - - Args: - l: Lightness (0-1) - c: Chroma (0-0.4+, unbounded) - h: Hue (0-360 degrees) - - Returns: - tuple: sRGB values (r, g, b) in range 0-1 - """ - # Validate for NaN and infinity as a safety check - if not (math.isfinite(l) and math.isfinite(c) and math.isfinite(h)): - return (0.0, 0.0, 0.0) # Fallback to black - - # Constants from CSS Color Module Level 4 - JND = 0.02 # Just Noticeable Difference threshold (2% in deltaEOK) - MIN_CONVERGENCE = 0.0001 # Binary search precision (0.01% chroma) - EPSILON = 0.00001 # Small value for floating point comparisons - - # Edge cases: pure black or white don't need gamut mapping - if l <= 0.0: - return (0.0, 0.0, 0.0) - if l >= 1.0: - return (1.0, 1.0, 1.0) - - # If chroma is very small, color is essentially achromatic - if c < EPSILON: - gray = linear_to_srgb(l) - return (gray, gray, gray) - - # Try the original color first - r, g, b = oklch_to_srgb(l, c, h) - - # Check if already in gamut (no clipping needed) - if 0.0 <= r <= 1.0 and 0.0 <= g <= 1.0 and 0.0 <= b <= 1.0: - return (r, g, b) - - # Binary search for maximum in-gamut chroma - low_chroma = 0.0 - high_chroma = c - - # Convert original color to OKLab for deltaE calculations - - while (high_chroma - low_chroma) > MIN_CONVERGENCE: - mid_chroma = (high_chroma + low_chroma) * 0.5 - - # Try this chroma value - r_test, g_test, b_test = oklch_to_srgb(l, mid_chroma, h) - - # Check if in gamut (before clipping) - in_gamut = (0.0 <= r_test <= 1.0 and - 0.0 <= g_test <= 1.0 and - 0.0 <= b_test <= 1.0) - - if in_gamut: - # In gamut - try higher chroma - low_chroma = mid_chroma - else: - # Out of gamut - clip and check deltaE - r_clipped = max(0.0, min(1.0, r_test)) - g_clipped = max(0.0, min(1.0, g_test)) - b_clipped = max(0.0, min(1.0, b_test)) - - # Convert both to OKLab for comparison - test_lab = srgb_to_oklab(r_test, g_test, b_test) - clipped_lab = srgb_to_oklab(r_clipped, g_clipped, b_clipped) - - # Calculate perceptual difference - de = deltaE_ok(test_lab, clipped_lab) - - if de < JND: - # Difference is imperceptible - accept this chroma - low_chroma = mid_chroma - else: - # Difference is noticeable - reduce chroma more - high_chroma = mid_chroma - - # Use the final chroma value and clip to ensure in-gamut - r_final, g_final, b_final = oklch_to_srgb(l, low_chroma, h) - return ( - max(0.0, min(1.0, r_final)), - max(0.0, min(1.0, g_final)), - max(0.0, min(1.0, b_final)) - ) - - -def lab_to_srgb(l: float, a: float, b: float) -> tuple[float, float, float]: # noqa: E741 - """Convert CIE LAB to sRGB RGB (0-1) - - LAB is a device-independent color space. - L: Lightness (0-100) - a: Green-red axis (-128 to +127, typically -100 to +100) - b: Blue-yellow axis (-128 to +127, typically -100 to +100) - - Conversion path: LAB -> XYZ -> Linear sRGB -> sRGB - """ - # LAB to XYZ (using D65 illuminant) - y = (l + 16) / 116 - x = a / 500 + y - z = y - b / 200 - - def f_inv(t: float) -> float: - delta = 6 / 29 - if t > delta: - return t ** 3 - return 3 * delta * delta * (t - 4 / 29) - - # D65 white point - x_n, y_n, z_n = 0.95047, 1.00000, 1.08883 - - x_val = x_n * f_inv(x) - y_val = y_n * f_inv(y) - z_val = z_n * f_inv(z) - - # XYZ to Linear sRGB - r_lin = +3.2404542 * x_val - 1.5371385 * y_val - 0.4985314 * z_val - g_lin = -0.9692660 * x_val + 1.8760108 * y_val + 0.0415560 * z_val - b_lin = +0.0556434 * x_val - 0.2040259 * y_val + 1.0572252 * z_val - - # Clip to valid range - r_lin = max(0.0, min(1.0, r_lin)) - g_lin = max(0.0, min(1.0, g_lin)) - b_lin = max(0.0, min(1.0, b_lin)) - - # Convert linear sRGB to sRGB - return (linear_to_srgb(r_lin), linear_to_srgb(g_lin), linear_to_srgb(b_lin)) - - -def lab_to_oklch(l_lab: float, a_lab: float, b_lab: float) -> tuple[float, float, float]: - """Convert CIE LAB to OKLCH - - Conversion path: LAB -> XYZ -> Linear sRGB -> sRGB -> OKLab -> OKLCH - """ - # First convert LAB to sRGB (unclipped to preserve out-of-gamut values) - # LAB to XYZ (using D65 illuminant) - y = (l_lab + 16) / 116 - x = a_lab / 500 + y - z = y - b_lab / 200 - - def f_inv(t: float) -> float: - delta = 6 / 29 - if t > delta: - return t ** 3 - return 3 * delta * delta * (t - 4 / 29) - - # D65 white point - x_n, y_n, z_n = 0.95047, 1.00000, 1.08883 - - x_val = x_n * f_inv(x) - y_val = y_n * f_inv(y) - z_val = z_n * f_inv(z) - - # XYZ to Linear sRGB (don't clip here to preserve out-of-gamut info) - r_lin = +3.2404542 * x_val - 1.5371385 * y_val - 0.4985314 * z_val - g_lin = -0.9692660 * x_val + 1.8760108 * y_val + 0.0415560 * z_val - b_lin = +0.0556434 * x_val - 0.2040259 * y_val + 1.0572252 * z_val - - # Convert linear sRGB to sRGB gamma - r_srgb = linear_to_srgb(max(0.0, r_lin)) if r_lin >= 0 else 0.0 - g_srgb = linear_to_srgb(max(0.0, g_lin)) if g_lin >= 0 else 0.0 - b_srgb = linear_to_srgb(max(0.0, b_lin)) if b_lin >= 0 else 0.0 - - # Convert to OKLab - l_ok, a_ok, b_ok = srgb_to_oklab(r_srgb, g_srgb, b_srgb) - - # Convert OKLab to OKLCH - c = math.sqrt(a_ok * a_ok + b_ok * b_ok) - h = math.degrees(math.atan2(b_ok, a_ok)) % 360 - - return (l_ok, c, h) - - -# Color parsing functions for new formats - -def parse_oklch(spec: str) -> Color | None: - """Parse OKLCH color: oklch(l c h) or oklch(l, c, h) - L: 0-1 (lightness) - C: 0-0.4 (chroma, unbounded but practical max) - H: 0-360 (hue in degrees) - """ - # Remove parentheses and split - spec = spec.strip('()') - parts = [p.strip().rstrip('%,') for p in re.split(r'[,\s]+', spec) if p.strip()] - - if len(parts) != 3: - return None - - try: - l = float(parts[0]) # noqa: E741 - c = float(parts[1]) - h = float(parts[2]) - - # Validate for NaN and infinity - if not (math.isfinite(l) and math.isfinite(c) and math.isfinite(h)): - return None - - # Handle percentages for L - if '%' in parts[0]: - l = l / 100.0 # noqa: E741 - - # Clamp to reasonable ranges - l = max(0.0, min(1.0, l)) # noqa: E741 - c = max(0.0, c) # Chroma is unbounded but we don't clamp high end - h = h % 360 # Wrap hue to 0-360 - - # Convert OKLCH to sRGB with gamut mapping - # This uses CSS Color Module Level 4 algorithm for out-of-gamut colors - r, g, b = oklch_to_srgb_gamut_map(l, c, h) - - return Color( - int(r * 255), - int(g * 255), - int(b * 255) - ) - except (ValueError, OverflowError): - return None - - -def parse_lab(spec: str) -> Color | None: - """Parse LAB color: lab(l a b) or lab(l, a, b) - L: 0-100 (lightness) - a: -128 to 127 (green-red) - b: -128 to 127 (blue-yellow) - - Uses CSS Color Module Level 4 gamut mapping for out-of-gamut colors. - Conversion path: LAB -> OKLCH -> gamut-mapped sRGB - This preserves perceptual characteristics better than simple clipping. - """ - # Remove parentheses and split - spec = spec.strip('()') - parts = [p.strip().rstrip('%,') for p in re.split(r'[,\s]+', spec) if p.strip()] - - if len(parts) != 3: - return None - - try: - l = float(parts[0]) # noqa: E741 - a = float(parts[1]) - b = float(parts[2]) - - # Validate for NaN and infinity - if not (math.isfinite(l) and math.isfinite(a) and math.isfinite(b)): - return None - - # Clamp L to 0-100 - l = max(0.0, min(100.0, l)) # noqa: E741 - - # Convert LAB to OKLCH, then use gamut mapping to sRGB - # This is better than simple LAB -> sRGB clipping as it preserves - # perceptual properties (lightness and hue) while reducing chroma - l_ok, c, h = lab_to_oklch(l, a, b) - - # Apply gamut mapping in OKLCH space (reduces chroma if needed) - r, g, b = oklch_to_srgb_gamut_map(l_ok, c, h) - - return Color( - int(r * 255), - int(g * 255), - int(b * 255) - ) - except (ValueError, OverflowError): - return None - - def to_color(raw: str, validate: bool = False) -> Color | None: # See man XParseColor # Strip inline comments (e.g., "oklch(...) # comment") @@ -458,26 +43,11 @@ def to_color(raw: str, validate: bool = False) -> Color | None: raw = raw.partition(' ')[0] else: # For non-hex colors, strip everything after # - raw = raw.partition('#')[0] - x = raw.strip().lower() + raw = raw.partition('#')[0].strip() + x = raw.lower() if ans := color_names.get(x): return ans - val: Color | None = None - with suppress(Exception): - match raw[0]: - case '#': - val = parse_sharp(raw[1:]) - case 'o': - val = parse_oklch(x[6:]) - case 'l': - val = parse_lab(x[4:]) - case 'r': - k, _, v = raw.partition(':') - if k == 'rgb': - val = parse_rgb(v) - elif k == 'rgbi': - val = parse_rgbi(v) - if val is None and validate: + if (val := Color.parse_color(x)) is None and validate: raise ValueError(f'Invalid color name: {raw!r}') return val diff --git a/kitty_tests/datatypes.py b/kitty_tests/datatypes.py index 970a71b2f..5bab06088 100644 --- a/kitty_tests/datatypes.py +++ b/kitty_tests/datatypes.py @@ -65,10 +65,10 @@ class TestDataTypes(BaseTest): def c(spec, r=0, g=0, b=0, a=0): c = to_color(spec) - self.ae(c.red, r) - self.ae(c.green, g) - self.ae(c.blue, b) - self.ae(c.alpha, a) + self.ae(c.red, r, spec) + self.ae(c.green, g, spec) + self.ae(c.blue, b, spec) + self.ae(c.alpha, a, spec) c('#eee', 0xee, 0xee, 0xee) c('#234567', 0x23, 0x45, 0x67)