diff --git a/docs/changelog.rst b/docs/changelog.rst index 9515e1dfe..319bfd63c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 ` 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 ` (: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 `, useful for single instance mode (:iss:`3466`) - Allow configuring the mouse unhide behavior when using :opt:`mouse_hide_wait` (:pull:`8508`) diff --git a/docs/kittens/themes.rst b/docs/kittens/themes.rst index d46f2ba08..f8880823f 100644 --- a/docs/kittens/themes.rst +++ b/docs/kittens/themes.rst @@ -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. diff --git a/kitty/boss.py b/kitty/boss.py index 31eaa1d8c..f77450279 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -3025,8 +3025,11 @@ class Boss: 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 @@ class Boss: 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 diff --git a/kitty/colors.py b/kitty/colors.py index 60f0f21c8..f76a04fa6 100644 --- a/kitty/colors.py +++ b/kitty/colors.py @@ -5,7 +5,7 @@ import os 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 @@ ColorSchemes = Literal['light', 'dark', 'no_preference'] 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 @@ class ThemeColors: 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 @@ class ThemeColors: 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 @@ class ThemeColors: 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 @@ class ThemeColors: 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 @@ class ThemeColors: 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): diff --git a/kitty/main.py b/kitty/main.py index 01ff71a7b..6624718d2 100644 --- a/kitty/main.py +++ b/kitty/main.py @@ -269,9 +269,9 @@ class AppRunner: 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) diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 1e77865cb..fd8d26ff2 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1597,27 +1597,6 @@ depending on how the platform implements it, so use with care. Currently support 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 @@ this option by reloading the config is not supported. ''' ) + +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 @@ How much to tint the background image by the background color. This option 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 @@ How much to tint the background image at the window gaps by the background 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. ''' ) diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 650fe91b3..d9616480b 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -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,) diff --git a/kitty/state.c b/kitty/state.c index e4428d217..ece1296f3 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -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));