Have auto color scheme switching also control background image

Fixes #8603
This commit is contained in:
Kovid Goyal 2025-05-06 09:10:05 +05:30
parent 7045632d2e
commit 80bb9404d5
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
8 changed files with 116 additions and 43 deletions

View file

@ -116,6 +116,8 @@ Detailed list of changes
- **Behavior change**: Now kitty does full grapheme segmentation following the
Unicode 16 spec when splitting text into cells (:iss:`8533`)
- **Behavior change**: The :ref:`automatic color switching functionality <auto_color_scheme>` now also controls background image settings (:iss:`8603`)
- panel kitten: Allow using :option:`kitty +kitten panel --single-instance` to create multiple panels in one process (:iss:`8549`)
- launch: Allow creating desktop panels such as those created by the :doc:`panel kitten </kittens/panel>` (:iss:`8549`)
@ -123,7 +125,7 @@ Detailed list of changes
- Remote control: Allow modifying desktop panels and showing/hiding OS Windows
using the `kitten @ resize-os-window` command (:iss:`8550`)
- Allow starting kitty with the OS window hidden via :option:`kitty --start-as`\=hidden useful for single instance mode (:iss:`3466`)
- Allow starting kitty with the OS window hidden via :option:`kitty --start-as=hidden <kitty --start-as>`, useful for single instance mode (:iss:`3466`)
- Allow configuring the mouse unhide behavior when using :opt:`mouse_hide_wait` (:pull:`8508`)

View file

@ -70,7 +70,8 @@ This works by creating three files: :file:`dark-theme.auto.conf`,
:file:`light-theme.auto.conf` and :file:`no-preference-theme.auto.conf` in the
kitty config directory. When these files exist, kitty queries the OS for its color scheme
and uses the appropriate file. Note that the colors in these files override all other
colors, even those specified using the :option:`kitty --override` command line flag.
colors, and also all background image settings,
even those specified using the :option:`kitty --override` command line flag.
kitty will also automatically change colors when the OS color scheme changes,
for example, during night/day transitions.

View file

@ -3025,8 +3025,11 @@ def chosen(ans: None | int | str) -> None:
self.choose_entry('Choose an OS window to move the tab to', items, chosen)
def set_background_image(self, path: str | None, os_windows: tuple[int, ...], configured: bool, layout: str | None, png_data: bytes = b'') -> None:
set_background_image(path, os_windows, configured, layout, png_data)
def set_background_image(
self, path: str | None, os_windows: tuple[int, ...], configured: bool, layout: str | None, png_data: bytes = b'',
linear_interpolation: bool | None = None, tint: float | None = None, tint_gaps: float | None = None
) -> None:
set_background_image(path, os_windows, configured, layout, png_data, linear_interpolation, tint, tint_gaps)
# Can be called with kitty -o "map f1 send_test_notification"
def send_test_notification(self) -> None:
@ -3050,6 +3053,19 @@ def show_kitty_env_vars(self) -> None:
def close_shared_ssh_connections(self) -> None:
cleanup_ssh_control_masters()
@ac('debug', '''Simulate a change in OS color scheme preference''')
def simulate_color_scheme_preference_change(self, which: str) -> None:
which = which.lower().replace('-', '_')
match which:
case 'light':
self.on_system_color_scheme_change('light', False)
case 'dark':
self.on_system_color_scheme_change('dark', False)
case 'no_preference':
self.on_system_color_scheme_change('no_preference', False)
case _:
self.show_error(_('Unknown color scheme type'), _('{} is not a valid color scheme type').format(which))
def launch_urls(self, *urls: str, no_replace_window: bool = False) -> None:
from .launch import force_window_launch
from .open_actions import actions_for_launch

View file

@ -5,7 +5,7 @@
from collections.abc import Iterable, Sequence
from contextlib import suppress
from enum import Enum
from typing import Literal, Optional
from typing import Literal, Optional, TypedDict
from .config import parse_config
from .constants import config_dir
@ -20,6 +20,14 @@
Colors = tuple[ColorsSpec, TransparentBackgroundColors]
class BackgroundImageOptions(TypedDict, total=False):
background_image: str | None
background_image_layout: str | None
background_image_linear: bool | None
background_tint: float | None
background_tint_gaps: float | None
class ThemeFile(Enum):
dark = 'dark-theme.auto.conf'
light = 'light-theme.auto.conf'
@ -33,6 +41,7 @@ class ThemeColors:
no_preference_mtime: int = -1
applied_theme: Literal['light', 'dark', 'no_preference', ''] = ''
default_colors: ColorsSpec | None = None
default_background_image_options: BackgroundImageOptions| None = None
def get_default_colors(self) -> ColorsSpec:
if self.default_colors is None:
@ -44,14 +53,19 @@ def get_default_colors(self) -> ColorsSpec:
if isinstance(defval, Color):
ans[name] = int(defval)
self.default_colors = ans
self.default_background_image_options: BackgroundImageOptions = {
k: getattr(defaults, k) for k in BackgroundImageOptions.__optional_keys__} # type: ignore
return self.default_colors
def parse_colors(self, f: Iterable[str]) -> Colors:
def parse_colors(self, f: Iterable[str], background_image_options: BackgroundImageOptions | None = None) -> Colors:
# When parsing the theme file we first apply the default theme so that
# all colors are reset to default values first. This is needed for themes
# that don't specify all colors.
spec, tbc = parse_colors((f,))
dc_spec = self.get_default_colors()
if background_image_options is not None and self.default_background_image_options:
background_image_options.update(self.default_background_image_options)
spec, tbc = parse_colors((f,), background_image_options)
ans = dc_spec.copy()
ans.update(spec)
return ans, tbc
@ -64,21 +78,27 @@ def refresh(self) -> bool:
mtime = x.stat().st_mtime_ns
if mtime > self.dark_mtime:
with open(x.path) as f:
self.dark_spec, self.dark_tbc = self.parse_colors(f)
d: BackgroundImageOptions = {}
self.dark_spec, self.dark_tbc = self.parse_colors(f, d)
self.dark_background_image_options = d
self.dark_mtime = mtime
found = True
elif x.name == ThemeFile.light.value:
mtime = x.stat().st_mtime_ns
if mtime > self.light_mtime:
with open(x.path) as f:
self.light_spec, self.light_tbc = self.parse_colors(f)
d = {}
self.light_spec, self.light_tbc = self.parse_colors(f, d)
self.light_background_image_options = d
self.light_mtime = mtime
found = True
elif x.name == ThemeFile.no_preference.value:
mtime = x.stat().st_mtime_ns
if mtime > self.no_preference_mtime:
with open(x.path) as f:
self.no_preference_spec, self.no_preference_tbc = self.parse_colors(f)
d = {}
self.no_preference_spec, self.no_preference_tbc = self.parse_colors(f, d)
self.no_preference_background_image_options = d
self.no_preference_mtime = mtime
found = True
return found
@ -115,14 +135,18 @@ def patch_opts(self, opts: Options, debug_rendering: bool = False) -> None:
if debug_rendering:
log_error('Current system color scheme:', which)
cols: Colors | None = None
bgo: BackgroundImageOptions | None = None
if which == 'dark' and self.has_dark_theme:
cols = self.dark_spec, self.dark_tbc
bgo = self.dark_background_image_options
elif which == 'light' and self.has_light_theme:
cols = self.light_spec, self.light_tbc
bgo = self.light_background_image_options
elif which == 'no_preference' and self.has_no_preference_theme:
cols = self.no_preference_spec, self.no_preference_tbc
bgo = self.no_preference_background_image_options
if cols is not None:
patch_options_with_color_spec(opts, *cols)
patch_options_with_color_spec(opts, *cols, background_image_options=bgo)
patch_global_colors(cols[0], True)
self.applied_theme = which
if debug_rendering:
@ -138,19 +162,23 @@ def apply_theme(self, new_value: ColorSchemes, notify_on_bg_change: bool = True)
from .utils import log_error
boss = get_boss()
if new_value == 'dark' and self.has_dark_theme:
patch_colors(self.dark_spec, self.dark_tbc, True, notify_on_bg_change=notify_on_bg_change)
patch_colors(
self.dark_spec, self.dark_tbc, True, notify_on_bg_change=notify_on_bg_change, background_image_options=self.dark_background_image_options)
self.applied_theme = new_value
if boss.args.debug_rendering:
log_error(f'Applied color theme {new_value}')
return True
if new_value == 'light' and self.has_light_theme:
patch_colors(self.light_spec, self.light_tbc, True, notify_on_bg_change=notify_on_bg_change)
patch_colors(
self.light_spec, self.light_tbc, True, notify_on_bg_change=notify_on_bg_change, background_image_options=self.light_background_image_options)
self.applied_theme = new_value
if boss.args.debug_rendering:
log_error(f'Applied color theme {new_value}')
return True
if new_value == 'no_preference' and self.has_no_preference_theme:
patch_colors(self.no_preference_spec, self.no_preference_tbc, True, notify_on_bg_change=notify_on_bg_change)
patch_colors(
self.no_preference_spec, self.no_preference_tbc, True, notify_on_bg_change=notify_on_bg_change,
background_image_options=self.no_preference_background_image_options)
self.applied_theme = new_value
if boss.args.debug_rendering:
log_error(f'Applied color theme {new_value}')
@ -161,7 +189,7 @@ def apply_theme(self, new_value: ColorSchemes, notify_on_bg_change: bool = True)
theme_colors = ThemeColors()
def parse_colors(args: Iterable[str | Iterable[str]]) -> Colors:
def parse_colors(args: Iterable[str | Iterable[str]], background_image_options: BackgroundImageOptions | None = None) -> Colors:
colors: dict[str, Color | None] = {}
nullable_color_map: dict[str, int | None] = {}
transparent_background_colors = ()
@ -175,6 +203,10 @@ def parse_colors(args: Iterable[str | Iterable[str]]) -> Colors:
else:
conf = parse_config(spec)
transparent_background_colors = conf.pop('transparent_background_colors', ())
if background_image_options is not None:
for key in BackgroundImageOptions.__optional_keys__:
if key in conf:
background_image_options.__setitem__(key, conf[key])
colors.update(conf)
for k in nullable_colors:
q = colors.pop(k, False)
@ -186,7 +218,10 @@ def parse_colors(args: Iterable[str | Iterable[str]]) -> Colors:
return ans, transparent_background_colors
def patch_options_with_color_spec(opts: Options, spec: ColorsSpec, transparent_background_colors: TransparentBackgroundColors) -> None:
def patch_options_with_color_spec(
opts: Options, spec: ColorsSpec, transparent_background_colors: TransparentBackgroundColors,
background_image_options: BackgroundImageOptions | None = None
) -> None:
for k, v in spec.items():
if hasattr(opts, k):
if v is None:
@ -195,11 +230,16 @@ def patch_options_with_color_spec(opts: Options, spec: ColorsSpec, transparent_b
else:
setattr(opts, k, color_from_int(v))
opts.transparent_background_colors = transparent_background_colors
if background_image_options is not None:
for k, bv in background_image_options.items():
if hasattr(opts, k):
setattr(opts, k, bv)
def patch_colors(
spec: ColorsSpec, transparent_background_colors: TransparentBackgroundColors, configured: bool = False,
windows: Sequence[WindowType] | None = None, notify_on_bg_change: bool = True,
background_image_options: BackgroundImageOptions | None = None
) -> None:
boss = get_boss()
if windows is None:
@ -209,7 +249,8 @@ def patch_colors(
patch_color_profiles(spec, transparent_background_colors, profiles, configured)
opts = get_options()
if configured:
patch_options_with_color_spec(opts, spec, transparent_background_colors)
patch_options_with_color_spec(opts, spec, transparent_background_colors, background_image_options)
os_window_ids = set()
for tm in get_boss().all_tab_managers:
tm.tab_bar.patch_colors(spec)
tm.tab_bar.layout()
@ -218,10 +259,17 @@ def patch_colors(
if t is not None:
t.relayout_borders()
set_os_window_chrome(tm.os_window_id)
os_window_ids.add(tm.os_window_id)
patch_global_colors(spec, configured)
default_bg_changed = 'background' in spec
notify_bg = notify_on_bg_change and default_bg_changed
boss = get_boss()
if background_image_options is not None:
boss.set_background_image(
background_image_options.get('background_image'), tuple(os_window_ids), configured,
layout=background_image_options.get('background_image_layout'),
linear_interpolation=background_image_options.get('background_image_linear'), tint=background_image_options.get('background_tint'),
tint_gaps=background_image_options.get('background_tint_gaps'))
for w in windows:
if w:
if notify_bg and w.screen.color_profile.default_bg != bg_colors_before.get(w.id):

View file

@ -269,9 +269,9 @@ def __init__(self) -> None:
self.initial_window_size_func = initial_window_size_func
def __call__(self, opts: Options, args: CLIOptions, bad_lines: Sequence[BadLine] = (), talk_fd: int = -1) -> None:
set_options(opts, is_wayland(), args.debug_rendering, args.debug_font_fallback)
if theme_colors.refresh():
theme_colors.patch_opts(opts, args.debug_rendering)
set_options(opts, is_wayland(), args.debug_rendering, args.debug_font_fallback)
try:
set_font_family(opts, add_builtin_nerd_font=True)
_run_app(opts, args, bad_lines, talk_fd)

View file

@ -1597,27 +1597,6 @@
on macOS and KDE.
''')
opt('background_image', 'none',
option_type='config_or_absolute_path', ctype='!background_image',
long_text='Path to a background image. Must be in PNG/JPEG/WEBP/TIFF/GIF/BMP format.'
)
opt('background_image_layout', 'tiled',
choices=('mirror-tiled', 'scaled', 'tiled', 'clamped', 'centered', 'cscaled'),
ctype='bglayout',
long_text='''
Whether to tile, scale or clamp the background image. The value can be one of
:code:`tiled`, :code:`mirror-tiled`, :code:`scaled`, :code:`clamped`, :code:`centered`
or :code:`cscaled`. The :code:`scaled` and :code:`cscaled` values scale the image to the
window size, with :code:`cscaled` preserving the image aspect ratio.
'''
)
opt('background_image_linear', 'no',
option_type='to_bool', ctype='bool',
long_text='When background image is scaled, whether linear interpolation should be used.'
)
opt('transparent_background_colors', '', option_type='transparent_background_colors', long_text='''
A space separated list of upto 7 colors, with opacity. When the background color of a cell matches one of these colors,
it is rendered semi-transparent using the specified opacity.
@ -1645,6 +1624,31 @@
'''
)
opt('background_image', 'none',
option_type='config_or_absolute_path', ctype='!background_image',
long_text='Path to a background image. Must be in PNG/JPEG/WEBP/TIFF/GIF/BMP format.'
' Note that when using :ref:`auto_color_scheme` this option is overridden by the color scheme file and must be set inside it to take effect.'
)
opt('background_image_layout', 'tiled',
choices=('mirror-tiled', 'scaled', 'tiled', 'clamped', 'centered', 'cscaled'),
ctype='bglayout',
long_text='''
Whether to tile, scale or clamp the background image. The value can be one of
:code:`tiled`, :code:`mirror-tiled`, :code:`scaled`, :code:`clamped`, :code:`centered`
or :code:`cscaled`. The :code:`scaled` and :code:`cscaled` values scale the image to the
window size, with :code:`cscaled` preserving the image aspect ratio.
Note that when using :ref:`auto_color_scheme` this option is overridden by the color scheme file and must be set inside it to take effect.
'''
)
opt('background_image_linear', 'no',
option_type='to_bool', ctype='bool',
long_text='When background image is scaled, whether linear interpolation should be used.'
' Note that when using :ref:`auto_color_scheme` this option is overridden by the color scheme file and must be set inside it to take effect.'
)
opt('background_tint', '0.0',
option_type='unit_float', ctype='float',
long_text='''
@ -1652,6 +1656,7 @@
makes it easier to read the text. Tinting is done using the current background
color for each window. This option applies only if :opt:`background_opacity` is
set and transparent windows are supported or :opt:`background_image` is set.
Note that when using :ref:`auto_color_scheme` this option is overridden by the color scheme file and must be set inside it to take effect.
'''
)
@ -1662,6 +1667,7 @@
color, after applying :opt:`background_tint`. Since this is multiplicative
with :opt:`background_tint`, it can be used to lighten the tint over the window
gaps for a *separated* look.
Note that when using :ref:`auto_color_scheme` this option is overridden by the color scheme file and must be set inside it to take effect.
'''
)

View file

@ -165,7 +165,7 @@ def detach_tab_parse(func: str, rest: str) -> FuncArgsType:
@func_with_args(
'set_background_opacity', 'goto_layout', 'toggle_layout', 'toggle_tab', 'kitty_shell', 'show_kitty_doc',
'set_tab_title', 'push_keyboard_mode', 'dump_lines_with_attrs', 'set_window_title',
'set_tab_title', 'push_keyboard_mode', 'dump_lines_with_attrs', 'set_window_title', 'simulate_color_scheme_preference_change',
)
def simple_parse(func: str, rest: str) -> FuncArgsType:
return func, (rest,)

View file

@ -1248,9 +1248,9 @@ pyset_background_image(PyObject *self UNUSED, PyObject *args, PyObject *kw) {
global_state.bgimage = bgimage;
if (bgimage) bgimage->refcnt++;
OPT(background_image_layout) = layout;
if (pylinear) convert_from_python_background_image_linear(pylinear, &global_state.opts);
if (pytint) convert_from_python_background_tint(pytint, &global_state.opts);
if (pytint_gaps) convert_from_python_background_tint_gaps(pytint_gaps, &global_state.opts);
if (pylinear && pylinear != Py_None) convert_from_python_background_image_linear(pylinear, &global_state.opts);
if (pytint && pytint != Py_None) convert_from_python_background_tint(pytint, &global_state.opts);
if (pytint_gaps && pytint_gaps != Py_None) convert_from_python_background_tint_gaps(pytint_gaps, &global_state.opts);
}
for (Py_ssize_t i = 0; i < PyTuple_GET_SIZE(os_window_ids); i++) {
id_type os_window_id = PyLong_AsUnsignedLongLong(PyTuple_GET_ITEM(os_window_ids, i));