Merge branch 'min-contrast-ratio' of https://github.com/arne314/kitty

This commit is contained in:
Kovid Goyal
2025-03-19 21:02:29 +05:30
9 changed files with 301 additions and 18 deletions

View File

@@ -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}

View File

@@ -1,4 +1,5 @@
#pragma kitty_include_shader <alpha_blend.glsl>
#pragma kitty_include_shader <hsluv.glsl>
#pragma kitty_include_shader <linear2srgb.glsl>
#pragma kitty_include_shader <cell_defines.glsl>
@@ -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:
//

View File

@@ -22,6 +22,7 @@ from ..typing import Protocol
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')

210
kitty/hsluv.glsl Normal file
View File

@@ -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));
}

View File

@@ -265,21 +265,31 @@ Then adjust the second parameter until it looks good. Then switch to a light the
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 <https://en.wikipedia.org/wiki/Web_Content_Accessibility_Guidelines>`
a value of :code:`4.5 ratio` can be provided.
The algorithm is implemented using :link:`HSLuv <https://www.hsluv.org/>` 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() # }}}

10
kitty/options/parse.py generated
View File

@@ -19,10 +19,10 @@ from kitty.options.utils import (
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 @@ class Parser:
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)

View File

@@ -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'

View File

@@ -26,6 +26,7 @@ from kitty.conf.utils import (
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)

View File

@@ -131,8 +131,8 @@ null_replacer = MultiReplacer()
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 @@ class LoadShaderPrograms:
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 @@ class LoadShaderPrograms:
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)