diff --git a/kitty/cell_defines.glsl b/kitty/cell_defines.glsl index 0bc4f4a34..c6467c795 100644 --- a/kitty/cell_defines.glsl +++ b/kitty/cell_defines.glsl @@ -7,6 +7,8 @@ #define HAS_TRANSPARENCY {TRANSPARENT} #define FG_OVERRIDE {FG_OVERRIDE} #define FG_OVERRIDE_THRESHOLD {FG_OVERRIDE_THRESHOLD} +#define APPLY_MIN_CONTRAST_RATIO {APPLY_MIN_CONTRAST_RATIO} +#define MIN_CONTRAST_RATIO {MIN_CONTRAST_RATIO} #define TEXT_NEW_GAMMA {TEXT_NEW_GAMMA} #define DECORATION_SHIFT {DECORATION_SHIFT} diff --git a/kitty/cell_fragment.glsl b/kitty/cell_fragment.glsl index 5319a0904..df908a008 100644 --- a/kitty/cell_fragment.glsl +++ b/kitty/cell_fragment.glsl @@ -1,4 +1,5 @@ #pragma kitty_include_shader +#pragma kitty_include_shader #pragma kitty_include_shader #pragma kitty_include_shader @@ -87,6 +88,24 @@ vec3 fg_override(float under_luminance, float over_lumininace, vec3 over) { return original_level * over + override_level * vec3(step(under_luminance, 0.5f)); } #endif +#if (APPLY_MIN_CONTRAST_RATIO == 1) +float contrast_ratio(float under_luminance, float over_luminance) { + return clamp((max(under_luminance, over_luminance) + 0.05f) / (min(under_luminance, over_luminance) + 0.05f), 1.f, 21.f); +} +vec3 apply_min_contrast_ratio(float under_luminance, float over_luminance, vec3 under, vec3 over) { + float ratio = contrast_ratio(under_luminance, over_luminance); + vec3 diff = abs(under - over); + vec3 over_hsluv = rgbToHsluv(over); + float target_lum_a = clamp((under_luminance + 0.05f) * MIN_CONTRAST_RATIO - 0.05f, 0.f, 1.f); + float target_lum_b = clamp((under_luminance + 0.05f) / MIN_CONTRAST_RATIO - 0.05f, 0.f, 1.f); + vec3 result_a = clamp(hsluvToRgb(vec3(over_hsluv.x, over_hsluv.y, target_lum_a * 100.f)), 0.f, 1.f); + vec3 result_b = clamp(hsluvToRgb(vec3(over_hsluv.x, over_hsluv.y, target_lum_b * 100.f)), 0.f, 1.f); + float result_a_ratio = contrast_ratio(under_luminance, dot(result_a, Y)); + float result_b_ratio = contrast_ratio(under_luminance, dot(result_b, Y)); + vec3 result = mix(result_a, result_b, step(result_a_ratio, result_b_ratio)); + return mix(result, over, max(step(diff.r + diff.g + diff.g, 0.001f), step(MIN_CONTRAST_RATIO, ratio))); +} +#endif vec4 foreground_contrast(vec4 over, vec3 under) { float under_luminance = dot(under, Y); @@ -96,6 +115,10 @@ vec4 foreground_contrast(vec4 over, vec3 under) { over.rgb = fg_override(under_luminance, over_lumininace, over.rgb); over_lumininace = dot(over.rgb, Y); #endif +#if (APPLY_MIN_CONTRAST_RATIO == 1) + over.rgb = apply_min_contrast_ratio(under_luminance, over_lumininace, under, over.rgb); + over_lumininace = dot(over.rgb, Y); +#endif // Apply additional gamma-adjustment scaled by the luminance difference, the darker the foreground the more adjustment we apply. // A multiplicative contrast is also available to increase saturation. @@ -111,6 +134,10 @@ vec4 foreground_contrast_incorrect(vec4 over, vec3 under) { #if (FG_OVERRIDE == 1) over.rgb = fg_override(under_luminance, over_lumininace, over.rgb); over_lumininace = dot(over.rgb, Y); +#endif +#if (APPLY_MIN_CONTRAST_RATIO == 1) + over.rgb = apply_min_contrast_ratio(under_luminance, over_lumininace, under, over.rgb); + over_lumininace = dot(over.rgb, Y); #endif // This is the original gamma-incorrect rendering, it is the solution of the following equation: // diff --git a/kitty/conf/utils.py b/kitty/conf/utils.py index 72d925559..ab9018808 100644 --- a/kitty/conf/utils.py +++ b/kitty/conf/utils.py @@ -22,6 +22,7 @@ from ..utils import expandvars, log_error, shlex_split key_pat = re.compile(r'([a-zA-Z][a-zA-Z0-9_-]*)\s+(.+)$') +number_unit_pat = re.compile(r'\s*([-+]?\d+\.?\d*)\s*([^\d\s]*)?') ItemParser = Callable[[str, str, dict[str, Any]], bool] T = TypeVar('T') @@ -66,6 +67,19 @@ def unit_float(x: ConvertibleToNumbers) -> float: return max(0, min(float(x), 1)) +def number_with_unit(x: str, default_unit: str = '') -> tuple[float, str]: + mat = number_unit_pat.match(x) + exception = None + if mat is not None: + try: + value, unit = float(mat.group(1)), mat.group(2) + unit = default_unit if not unit else unit + return value, unit + except Exception as e: + exception = e + raise ValueError(f'Invalid number with unit config option provided: {x if exception is None else exception}') + + def to_bool(x: str) -> bool: return x.lower() in ('y', 'yes', 'true') diff --git a/kitty/hsluv.glsl b/kitty/hsluv.glsl new file mode 100644 index 000000000..14c13e935 --- /dev/null +++ b/kitty/hsluv.glsl @@ -0,0 +1,210 @@ +/* +HSLUV-GLSL v4.2 +HSLUV is a human-friendly alternative to HSL. ( http://www.hsluv.org ) +GLSL port by William Malo ( https://github.com/williammalo ) +Put this code in your fragment shader. +*/ + +// stripped down and optimized (branchless) version + +float divide(float num, float denom) { + return num / (abs(denom) + 1e-15) * sign(denom); +} + +vec3 divide(vec3 num, vec3 denom) { + return num / (abs(denom) + 1e-15) * sign(denom); +} + +vec3 hsluv_intersectLineLine(vec3 line1x, vec3 line1y, vec3 line2x, vec3 line2y) { + return (line1y - line2y) / (line2x - line1x); +} + +vec3 hsluv_distanceFromPole(vec3 pointx,vec3 pointy) { + return sqrt(pointx*pointx + pointy*pointy); +} + +vec3 hsluv_lengthOfRayUntilIntersect(float theta, vec3 x, vec3 y) { + vec3 len = divide(y, sin(theta) - x * cos(theta)); + len = mix(len, vec3(1000.0), step(len, vec3(0.0))); + return len; +} + +float hsluv_maxSafeChromaForL(float L){ + mat3 m2 = mat3( + 3.2409699419045214 ,-0.96924363628087983 , 0.055630079696993609, + -1.5373831775700935 , 1.8759675015077207 ,-0.20397695888897657 , + -0.49861076029300328 , 0.041555057407175613, 1.0569715142428786 + ); + float sub0 = L + 16.0; + float sub1 = sub0 * sub0 * sub0 * .000000641; + float sub2 = mix(L / 903.2962962962963, sub1, step(0.0088564516790356308, sub1)); + + vec3 top1 = (284517.0 * m2[0] - 94839.0 * m2[2]) * sub2; + vec3 bottom = (632260.0 * m2[2] - 126452.0 * m2[1]) * sub2; + vec3 top2 = (838422.0 * m2[2] + 769860.0 * m2[1] + 731718.0 * m2[0]) * L * sub2; + + vec3 bounds0x = top1 / bottom; + vec3 bounds0y = top2 / bottom; + + vec3 bounds1x = top1 / (bottom+126452.0); + vec3 bounds1y = (top2-769860.0*L) / (bottom+126452.0); + + vec3 xs0 = hsluv_intersectLineLine(bounds0x, bounds0y, -1.0/bounds0x, vec3(0.0) ); + vec3 xs1 = hsluv_intersectLineLine(bounds1x, bounds1y, -1.0/bounds1x, vec3(0.0) ); + + vec3 lengths0 = hsluv_distanceFromPole( xs0, bounds0y + xs0 * bounds0x ); + vec3 lengths1 = hsluv_distanceFromPole( xs1, bounds1y + xs1 * bounds1x ); + + return min(lengths0.r, + min(lengths1.r, + min(lengths0.g, + min(lengths1.g, + min(lengths0.b, + lengths1.b))))); +} + +float hsluv_maxChromaForLH(float L, float H) { + + float hrad = radians(H); + + mat3 m2 = mat3( + 3.2409699419045214 ,-0.96924363628087983 , 0.055630079696993609, + -1.5373831775700935 , 1.8759675015077207 ,-0.20397695888897657 , + -0.49861076029300328 , 0.041555057407175613, 1.0569715142428786 + ); + float sub1 = pow(L + 16.0, 3.0) / 1560896.0; + float sub2 = mix(L / 903.2962962962963, sub1, step(0.0088564516790356308, sub1)); + + vec3 top1 = (284517.0 * m2[0] - 94839.0 * m2[2]) * sub2; + vec3 bottom = (632260.0 * m2[2] - 126452.0 * m2[1]) * sub2; + vec3 top2 = (838422.0 * m2[2] + 769860.0 * m2[1] + 731718.0 * m2[0]) * L * sub2; + + vec3 bound0x = top1 / bottom; + vec3 bound0y = top2 / bottom; + + vec3 bound1x = top1 / (bottom+126452.0); + vec3 bound1y = (top2-769860.0*L) / (bottom+126452.0); + + vec3 lengths0 = hsluv_lengthOfRayUntilIntersect(hrad, bound0x, bound0y ); + vec3 lengths1 = hsluv_lengthOfRayUntilIntersect(hrad, bound1x, bound1y ); + + return min(lengths0.r, + min(lengths1.r, + min(lengths0.g, + min(lengths1.g, + min(lengths0.b, + lengths1.b))))); +} + +vec3 hsluv_fromLinear(vec3 c) { + return mix(c * 12.92, 1.055 * pow(max(c, vec3(0)), vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c)); +} + +vec3 hsluv_toLinear(vec3 c) { + return mix(c / 12.92, pow(max((c + 0.055) / (1.0 + 0.055), vec3(0)), vec3(2.4)), step(0.04045, c)); +} + +float hsluv_yToL(float Y){ + return mix(Y * 903.2962962962963, 116.0 * pow(max(Y, 0), 1.0 / 3.0) - 16.0, step(0.0088564516790356308, Y)); +} + +float hsluv_lToY(float L) { + return mix(L / 903.2962962962963, pow((max(L, 0) + 16.0) / 116.0, 3.0), step(8.0, L)); +} + +vec3 xyzToRgb(vec3 tuple) { + const mat3 m = mat3( + 3.2409699419045214 ,-1.5373831775700935 ,-0.49861076029300328 , + -0.96924363628087983 , 1.8759675015077207 , 0.041555057407175613, + 0.055630079696993609,-0.20397695888897657, 1.0569715142428786 ); + return hsluv_fromLinear(tuple*m); +} + +vec3 rgbToXyz(vec3 tuple) { + const mat3 m = mat3( + 0.41239079926595948 , 0.35758433938387796, 0.18048078840183429 , + 0.21263900587151036 , 0.71516867876775593, 0.072192315360733715, + 0.019330818715591851, 0.11919477979462599, 0.95053215224966058 + ); + return hsluv_toLinear(tuple) * m; +} + +vec3 xyzToLuv(vec3 tuple){ + float X = tuple.x; + float Y = tuple.y; + float Z = tuple.z; + + float L = hsluv_yToL(Y); + float div = 1. / max(dot(tuple, vec3(1, 15, 3)), 1e-15); + + return vec3( + 1., + (52. * (X*div) - 2.57179), + (117.* (Y*div) - 6.08816) + ) * L; +} + + +vec3 luvToXyz(vec3 tuple) { + float L = tuple.x; + + float U = divide(tuple.y, 13.0 * L) + 0.19783000664283681; + float V = divide(tuple.z, 13.0 * L) + 0.468319994938791; + + float Y = hsluv_lToY(L); + float X = 2.25 * U * Y / V; + float Z = (3./V - 5.)*Y - (X/3.); + + return vec3(X, Y, Z); +} + +vec3 luvToLch(vec3 tuple) { + float L = tuple.x; + float U = tuple.y; + float V = tuple.z; + + float C = length(tuple.yz); + float H = degrees(atan(V,U)); + H += 360.0 * step(H, 0.0); + + return vec3(L, C, H); +} + +vec3 lchToLuv(vec3 tuple) { + float hrad = radians(tuple.b); + return vec3( + tuple.r, + cos(hrad) * tuple.g, + sin(hrad) * tuple.g + ); +} + +vec3 hsluvToLch(vec3 tuple) { + tuple.g *= hsluv_maxChromaForLH(tuple.b, tuple.r) * .01; + return tuple.bgr; +} + +vec3 lchToHsluv(vec3 tuple) { + tuple.g = divide(tuple.g, hsluv_maxChromaForLH(tuple.r, tuple.b) * .01); + return tuple.bgr; +} + +vec3 lchToRgb(vec3 tuple) { + return xyzToRgb(luvToXyz(lchToLuv(tuple))); +} + +vec3 rgbToLch(vec3 tuple) { + return luvToLch(xyzToLuv(rgbToXyz(tuple))); +} + +vec3 hsluvToRgb(vec3 tuple) { + return lchToRgb(hsluvToLch(tuple)); +} + +vec3 rgbToHsluv(vec3 tuple) { + return lchToHsluv(rgbToLch(tuple)); +} + +vec3 luvToRgb(vec3 tuple){ + return xyzToRgb(luvToXyz(tuple)); +} diff --git a/kitty/options/definition.py b/kitty/options/definition.py index dc4585b70..0f37f051e 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -265,21 +265,31 @@ and adjust the first parameter until the perceived thickness matches the dark theme. ''') -opt('text_fg_override_threshold', 0, option_type='float', long_text=''' -The minimum accepted difference in luminance between the foreground and background -color, below which kitty will override the foreground color. It is percentage -ranging from :code:`0` to :code:`100`. If the difference in luminance of the -foreground and background is below this threshold, the foreground color will be set -to white if the background is dark or black if the background is light. The default -value is :code:`0`, which means no overriding is performed. Useful when working with applications +opt('text_fg_override_threshold', '0 %', option_type='text_fg_override_threshold', long_text=''' +A setting to prevent low contrast scenarios, configurable in two different modes (suffix :code:` %` and suffix :code:` ratio`). +The default value is :code:`0`, which means no overriding is performed. Useful when working with applications that use colors that do not contrast well with your preferred color scheme. +A value with the suffix :code:` %` represents the minimum accepted difference in luminance between the foreground and background +color, below which kitty will override the foreground color. It is percentage +ranging from :code:`0 %` to :code:`100 %`. If the difference in luminance of the +foreground and background is below this threshold, the foreground color will be set +to white if the background is dark or black if the background is light. + +A value with the suffix :code:` ratio` represents the minimum accepted contrast ratio between the foreground and background color. +Possible values range from :code:`0.0 ratio` to :code:`21.0 ratio`. +To for example meet :link:`WCAG level AA ` +a value of :code:`4.5 ratio` can be provided. +The algorithm is implemented using :link:`HSLuv ` which enables it to change +the perceived lightness of a color just as much as needed without really changing its hue and saturation. + WARNING: Some programs use characters (such as block characters) for graphics display and may expect to be able to set the foreground and background to the same color (or similar colors). If you see unexpected stripes, dots, lines, incorrect color, no color where you expect color, or any kind of graphic display problem try setting :opt:`text_fg_override_threshold` to :code:`0` to -see if this is the cause of the problem. +see if this is the cause of the problem or consider the minimum contrast ratio (negative value) +over the minimum difference as it implements a basic workaround for this scenario. ''') egr() # }}} diff --git a/kitty/options/parse.py b/kitty/options/parse.py index 5ef090a26..7b046bc36 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -19,10 +19,10 @@ pointer_shape_when_dragging, remote_control_password, resize_debounce_time, scrollback_lines, scrollback_pager_history_size, shell_integration, store_multiple, symbol_map, tab_activity_symbol, tab_bar_edge, tab_bar_margin_height, tab_bar_min_tabs, tab_fade, tab_font_style, tab_separator, - tab_title_template, titlebar_color, to_cursor_shape, to_cursor_unfocused_shape, to_font_size, - to_layout_names, to_modifiers, transparent_background_colors, underline_exclusion, url_prefixes, - url_style, visual_bell_duration, visual_window_select_characters, window_border_width, - window_logo_scale, window_size + tab_title_template, text_fg_override_threshold, titlebar_color, to_cursor_shape, + to_cursor_unfocused_shape, to_font_size, to_layout_names, to_modifiers, + transparent_background_colors, underline_exclusion, url_prefixes, url_style, visual_bell_duration, + visual_window_select_characters, window_border_width, window_logo_scale, window_size ) @@ -1327,7 +1327,7 @@ def text_composition_strategy(self, val: str, ans: dict[str, typing.Any]) -> Non ans['text_composition_strategy'] = str(val) def text_fg_override_threshold(self, val: str, ans: dict[str, typing.Any]) -> None: - ans['text_fg_override_threshold'] = float(val) + ans['text_fg_override_threshold'] = text_fg_override_threshold(val) def touch_scroll_multiplier(self, val: str, ans: dict[str, typing.Any]) -> None: ans['touch_scroll_multiplier'] = float(val) diff --git a/kitty/options/types.py b/kitty/options/types.py index 0909b44e6..3079bc7dd 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -614,7 +614,7 @@ class Options: term: str = 'xterm-kitty' terminfo_type: choices_for_terminfo_type = 'path' text_composition_strategy: str = 'platform' - text_fg_override_threshold: float = 0.0 + text_fg_override_threshold: tuple[float, str] = (0.0, '%') touch_scroll_multiplier: float = 1.0 transparent_background_colors: tuple[tuple[kitty.fast_data_types.Color, float], ...] = () undercurl_style: choices_for_undercurl_style = 'thin-sparse' diff --git a/kitty/options/utils.py b/kitty/options/utils.py index cb8b7b92c..a4972a7fd 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -26,6 +26,7 @@ KeyAction, KeyFuncWrapper, currently_parsing, + number_with_unit, percent, positive_float, positive_int, @@ -752,6 +753,13 @@ def active_tab_title_template(x: str) -> str | None: return None if x == 'none' else x +def text_fg_override_threshold(x: str) -> tuple[float, str]: + value, unit = number_with_unit(x, default_unit='%') + if unit not in ['%', 'ratio']: + raise ValueError(f'The unit {unit} is not a valid choice for text_fg_override_threshold') + return value, unit + + ClearOn = Literal['next', 'focus'] default_clear_on: tuple[ClearOn, ...] = 'focus', 'next' all_clear_on = get_args(ClearOn) diff --git a/kitty/shaders.py b/kitty/shaders.py index d3f80f73e..92b1cebff 100644 --- a/kitty/shaders.py +++ b/kitty/shaders.py @@ -131,8 +131,8 @@ def __call__(self, src: str) -> str: class LoadShaderPrograms: - text_fg_override_threshold: float = 0 + text_fg_override_threshold_unit: str = '%' text_old_gamma: bool = False semi_transparent: bool = False cell_program_replacer: MultiReplacer = null_replacer @@ -140,7 +140,10 @@ class LoadShaderPrograms: @property def needs_recompile(self) -> bool: opts = get_options() - return opts.text_fg_override_threshold != self.text_fg_override_threshold or (opts.text_composition_strategy == 'legacy') != self.text_old_gamma + return ( + opts.text_fg_override_threshold != (self.text_fg_override_threshold, self.text_fg_override_threshold_unit) + or (opts.text_composition_strategy == 'legacy') != self.text_old_gamma + ) def recompile_if_needed(self) -> None: if self.needs_recompile: @@ -150,7 +153,14 @@ def __call__(self, semi_transparent: bool = False, allow_recompile: bool = False self.semi_transparent = semi_transparent opts = get_options() self.text_old_gamma = opts.text_composition_strategy == 'legacy' - self.text_fg_override_threshold = max(0, min(opts.text_fg_override_threshold, 100)) * 0.01 + + self.text_fg_override_threshold, self.text_fg_override_threshold_unit = opts.text_fg_override_threshold + match self.text_fg_override_threshold_unit: + case '%': + self.text_fg_override_threshold = max(0, min(self.text_fg_override_threshold, 100.0)) * 0.01 + case 'ratio': + self.text_fg_override_threshold = max(0, min(self.text_fg_override_threshold, 21.0)) + cell = program_for('cell') if self.cell_program_replacer is null_replacer: self.cell_program_replacer = MultiReplacer( @@ -168,7 +178,9 @@ def resolve_cell_defines(which: str, src: str) -> str: r['WHICH_PHASE'] = f'PHASE_{which}' r['TRANSPARENT'] = '1' if semi_transparent else '0' r['FG_OVERRIDE_THRESHOLD'] = str(self.text_fg_override_threshold) - r['FG_OVERRIDE'] = '1' if self.text_fg_override_threshold != 0. else '0' + r['FG_OVERRIDE'] = str(int(bool(self.text_fg_override_threshold_unit == '%'))) + r['MIN_CONTRAST_RATIO'] = str(self.text_fg_override_threshold) + r['APPLY_MIN_CONTRAST_RATIO'] = str(int(bool(self.text_fg_override_threshold_unit == 'ratio'))) r['TEXT_NEW_GAMMA'] = '0' if self.text_old_gamma else '1' return self.cell_program_replacer(src)