Merge branch 'feature/inactive-shader-tint' of https://github.com/xiaobai050/kitty

This commit is contained in:
Kovid Goyal 2026-09-16 11:36:26 +05:30
commit d20a0acb3a
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
8 changed files with 119 additions and 5 deletions

View file

@ -204,6 +204,10 @@ Detailed list of changes
- Support for :doc:`/custom-shaders` for adding various graphical effects (:iss:`10344`)
- Custom shaders: Allow tinting inactive windows toward a configurable color,
preserving the tab bar and active window padding. Support static effects
without periodic redraws, and fix re-enabling a previously disabled shader.
- Various throughput performance improvements for a 15-35% real world improvement depending on workload
- Add :opt:`window_border_radius` for rounded window borders (:pull:`10421`)

View file

@ -79,6 +79,31 @@ mouse shader. In the first group we have the crt shader and its color has been
customised. The second group defines the spotlight shader and specifies exactly
when the spotlight animation should start and stop.
For example, to blend inactive windows toward gray instead of black, save the
following as :file:`shaders/dim-gray.pipeline` in your kitty config directory and
set ``custom_shaders dim-gray`` and ``inactive_text_alpha 1`` in :file:`kitty.conf`::
startgroup
animation_step 0
var float HIGHLIGHT_INTENSITY = 0
var float BORDER_WIDTH = 0
var float INACTIVE_DIM = 0.74
var float3 INACTIVE_DIM_COLOR = float3(0.2158605)
var bool DIM_CENTRAL_AREA_ONLY = true
var bool INCLUDE_WINDOW_PADDING = true
shaders focus-highlight
endgroup
This uses the same shader as ``dim-inactive-windows``. ``INACTIVE_DIM`` is the
fraction of the original color retained: ``0.74`` blends in 26% of the target
color. ``INACTIVE_DIM_COLOR`` is in linear RGB; ``0.2158605`` corresponds to
sRGB ``#808080``. The default target is black, preserving the existing dimming
effect. The two optional flags preserve the tab bar and include the active
window's padding in its unchanged area. Explicit cell backgrounds and images
are blended together with text, while transparency and colors reported to
terminal applications are preserved. ``animation_step 0`` makes this static
effect redraw only when content or shader events change.
Anatomy of a custom shader
----------------------------
@ -246,7 +271,9 @@ Directives inside a group:
How many milliseconds between animation frames. Lower values produce
smoother animation at the cost of more GPU draws. Defaults to ``50`` ms
(20 fps). The value is clamped to be no smaller than
:opt:`repaint_delay`.
:opt:`repaint_delay`. Set this to ``0`` for static effects that should
redraw only when content or shader events change, without periodic frames.
Animation stop events and duration limits still apply.
``attach``
Mark this group as *attached* to the preceding group. An attached group

View file

@ -957,13 +957,18 @@ prepare_to_render_os_window(
events |= (1u << SHADER_ANIM_EVENT_TAB_CHANGE) | (1u << SHADER_ANIM_EVENT_WINDOW_FOCUS_IN) | (1u << SHADER_ANIM_EVENT_WINDOW_FOCUS_OUT);
if (*active_window_id && *active_window_id != os_window->last_active_window_id)
events |= (1u << SHADER_ANIM_EVENT_WINDOW_FOCUS_IN) | (1u << SHADER_ANIM_EVENT_WINDOW_FOCUS_OUT);
const bool was_active = os_window->has_active_custom_shaders;
const bool was_animating = os_window->shader_anim_min_step < MONOTONIC_T_MAX;
const bool animation_ended = now >= os_window->shader_anim_next_end_at;
monotonic_t min_step = update_custom_shader_animations(events, now, os_window);
os_window->shader_anim_event_registry = 0;
if (os_window->has_active_custom_shaders) {
os_window->needs_layers = true;
needs_render = true;
if (events || min_step < MONOTONIC_T_MAX) needs_render = true;
}
if (was_active != os_window->has_active_custom_shaders || was_animating || animation_ended) needs_render = true;
if (min_step < MONOTONIC_T_MAX) set_maximum_wait(min_step);
if (os_window->shader_anim_next_end_at < MONOTONIC_T_MAX) set_maximum_wait(os_window->shader_anim_next_end_at - now);
}
return needs_render || was_previously_rendered_with_layers != os_window->needs_layers;
}

View file

@ -2433,6 +2433,7 @@ run_custom_end_shader(OSWindow *os_window, float sx, float sy, monotonic_t now)
float mouse_pos[4];
float mouse_button_pressed[4];
float active_window_geometry[4];
float active_window_padding[4];
float bell_window_geometry[4];
float central_area[4];
float cursor_trail_corners_x[4];
@ -2475,6 +2476,13 @@ run_custom_end_shader(OSWindow *os_window, float sx, float sy, monotonic_t now)
if (t->num_windows) {
Window *w = t->windows + t->active_window;
active_win_geom = w->render_data.geometry;
if (os_window->viewport_width && os_window->viewport_height) {
const float width = (float)os_window->viewport_width, height = (float)os_window->viewport_height;
d->active_window_padding[0] = (float)(w->padding.left + w->size_mismatch_padding.left) / width;
d->active_window_padding[1] = (float)(w->padding.bottom + w->size_mismatch_padding.bottom) / height;
d->active_window_padding[2] = (float)(w->padding.right + w->size_mismatch_padding.right) / width;
d->active_window_padding[3] = (float)(w->padding.top + w->size_mismatch_padding.top) / height;
}
Screen *s = w->render_data.screen;
if (s) {
active_bg = colorprofile_to_color(s->color_profile, s->color_profile->overridden.default_bg, s->color_profile->configured.default_bg).rgb;
@ -2990,7 +2998,7 @@ transfer_pipeline_to_struct(PyObject *pg, CustomShaderPipeline *p) {
PyObject *astep = PyDict_GetItemString(g, "animation_step");
cg->animation_step = (astep && PyLong_Check(astep)) ? (monotonic_t)PyLong_AsLong(astep) : ANIMATION_SAMPLE_WAIT;
cg->animation_step = MAX(cg->animation_step, OPT(repaint_delay));
cg->animation_step = cg->animation_step == 0 ? MONOTONIC_T_MAX : MAX(cg->animation_step, OPT(repaint_delay));
PyObject *ae_events = PyDict_GetItemString(g, "animation_end_events");
if (is_seq(ae_events)) cg->animation_end_events = parse_anim_event_sequence(ae_events);

View file

@ -16,14 +16,28 @@ static const float HIGHLIGHT_INTENSITY = 0.30;
static const float BORDER_WIDTH = 0.04;
// Peak additive brightness multiplier at the window border edge.
static const float BORDER_GLOW = 0.55;
// Dim factor applied to pixels outside the active window (1.0 = unchanged, 0.0 = black).
// Fraction of the original color retained outside the active window (1.0 = unchanged, 0.0 = blend target).
static const float INACTIVE_DIM = 0.3;
// Blend target in linear RGB; black preserves the original dimming behavior.
static const float3 INACTIVE_DIM_COLOR = float3(0.0);
// Keep the tab bar outside the effect when requested by a pipeline.
static const bool DIM_CENTRAL_AREA_ONLY = false;
// Include the active window's padding in its unchanged/highlighted area.
static const bool INCLUDE_WINDOW_PADDING = false;
public float4
fragment_main(float4 color, KittyTextures t, KittyCustomShaderData d) {
if (DIM_CENTRAL_AREA_ONLY) {
float4 area = d.central_area;
if (any(t.pos < area.xy) || any(t.pos > area.xy + area.zw)) return color;
}
float4 g = d.active_window_geometry;
// Pass through unchanged if no active window geometry is known.
if (g.z <= 0.0 || g.w <= 0.0) return color;
if (INCLUDE_WINDOW_PADDING) {
g.xy -= d.active_window_padding.xy;
g.zw += d.active_window_padding.xy + d.active_window_padding.zw;
}
float2 uv = t.pos;
float2 winMin = g.xy;
@ -48,5 +62,5 @@ fragment_main(float4 color, KittyTextures t, KittyCustomShaderData d) {
}
// Slightly dim inactive areas to draw the eye to the focused window.
float dim = lerp(1.0, INACTIVE_DIM, strength);
return float4(color.rgb * dim, color.a);
return float4(lerp(INACTIVE_DIM_COLOR, color.rgb, dim), color.a);
}

View file

@ -22,6 +22,9 @@ public struct KittyCustomShaderData {
// .xy = lower-left corner of the window, .zw = width and height.
// All components are zero when no active window was rendered this frame.
public float4 active_window_geometry;
// Effective padding of the active window, including cell-size compensation.
// .xy = left/bottom, .zw = right/top, in viewport UV units.
public float4 active_window_padding;
// Geometry of the most recently belled window in UV co-ordinates (origin bottom-left).
// .xy = lower-left corner of the window, .zw = width and height.
// If the belled window is not currently visible in the active tab/layout, equals central_area.

View file

@ -274,6 +274,7 @@ class LoadShaderPrograms:
slot_pipelines = pmap.get(slot)
if not slot_pipelines:
compile_program(prog, (), (), {}, allow_recompile)
self.last_built_custom_shaders.pop(prog, None)
else:
try:
pipeline = merge_pipelines(slot_pipelines)
@ -283,6 +284,7 @@ class LoadShaderPrograms:
except Exception as e:
log_error(f'Failed to build custom shader for slot {slot} with error: {e}')
compile_program(prog, (), (), {}, allow_recompile)
self.last_built_custom_shaders.pop(prog, None)
else:
try:
if self.last_built_custom_shaders.get(prog) != (vert, frag, metadata):
@ -291,6 +293,7 @@ class LoadShaderPrograms:
except Exception as e:
log_error(f'Failed to load custom shader for slot {slot} with error: {e}')
compile_program(prog, (), (), {}, allow_recompile)
self.last_built_custom_shaders.pop(prog, None)
do(CUSTOM_END_PROGRAM, 'end')
compile_program(-2, (), (), {}) # initialize programs

View file

@ -8,10 +8,12 @@ import os
import shutil
import subprocess
import tempfile
from unittest.mock import patch
from kitty.constants import slangc
from kitty.shaders.slang import (
EntryPoint,
LoadShaderPrograms,
SlangFile,
Stage,
build_custom_shader_pipeline_glsl,
@ -33,6 +35,54 @@ _SUPPORT_SHADER_NAMES = frozenset(('types', 'pipeline'))
class TestSlang(BaseTest):
def test_inactive_window_tint_pipeline(self):
if not shutil.which(slangc()[0]):
self.skipTest(f'slangc ({slangc()[0]}) not found in PATH')
pipeline = parse_pipeline_definition(
"""
startgroup
animation_step 0
var float HIGHLIGHT_INTENSITY = 0
var float BORDER_WIDTH = 0
var float INACTIVE_DIM = 0.74
var float3 INACTIVE_DIM_COLOR = float3(0.2158605)
var bool DIM_CENTRAL_AREA_ONLY = true
var bool INCLUDE_WINDOW_PADDING = true
shaders focus-highlight
endgroup
""".splitlines(),
'dim-gray',
)
with tempfile.TemporaryDirectory() as cache_dir:
clear_caches()
try:
vertex, fragment, metadata = build_custom_shader_pipeline_glsl(pipeline, cache_dir=cache_dir)
finally:
clear_caches()
self.assertTrue(vertex)
self.assertTrue(fragment)
self.ae(metadata['pipeline']['groups'][0]['animation_step'], 0)
def test_custom_shader_reenable(self):
from kitty.fast_data_types import CUSTOM_END_PROGRAM
from kitty.options.types import defaults
loader = LoadShaderPrograms()
loader.last_built_custom_shaders = {}
pipeline = parse_pipeline_definition(['startgroup', 'animation_step 0', 'shaders focus-highlight', 'endgroup'], 'test')
self.ae(pipeline['groups'][0]['animation_step'], 0)
with (
patch('kitty.shaders.slang.parse_pipeline', return_value=pipeline),
patch('kitty.shaders.slang.build_custom_shader_pipeline_glsl', return_value=('vertex', 'fragment', {})),
patch('kitty.shaders.slang.compile_program') as compile_program,
):
for shaders in (('test',), (), ('test',)):
loader.opts = defaults._replace(custom_shaders=shaders)
loader.compile_custom_shaders(allow_recompile=True)
operations = [c.args[1] for c in compile_program.call_args_list if c.args[0] == CUSTOM_END_PROGRAM]
self.ae(operations, [('vertex',), (), ('vertex',)])
def test_slang_parser(self):
def check(src: str, expected: SlangFile) -> None:
actual = parse_slang_text(src)