Get automatic theme switching working

This commit is contained in:
Kovid Goyal
2024-11-07 17:31:44 +05:30
parent e485b3b4a3
commit 96c1a5c4d1
7 changed files with 158 additions and 61 deletions

View File

@@ -583,6 +583,7 @@ def load_ref_map() -> dict[str, dict[str, str]]:
def generate_constants() -> str: def generate_constants() -> str:
from kittens.hints.main import DEFAULT_REGEX from kittens.hints.main import DEFAULT_REGEX
from kittens.query_terminal.main import all_queries from kittens.query_terminal.main import all_queries
from kitty.colors import ThemeFile
from kitty.config import option_names_for_completion from kitty.config import option_names_for_completion
from kitty.fast_data_types import FILE_TRANSFER_CODE from kitty.fast_data_types import FILE_TRANSFER_CODE
from kitty.options.utils import allowed_shell_integration_values, url_style_map from kitty.options.utils import allowed_shell_integration_values, url_style_map
@@ -638,6 +639,9 @@ Select_by_word_characters: `{Options.select_by_word_characters}`, Wheel_scroll_m
Shell: "{Options.shell}", Url_excluded_characters: "{Options.url_excluded_characters}", Shell: "{Options.shell}", Url_excluded_characters: "{Options.url_excluded_characters}",
}} }}
const OptionNames = {option_names} const OptionNames = {option_names}
const DarkThemeFileName = "{ThemeFile.dark.value}"
const LightThemeFileName = "{ThemeFile.light.value}"
const NoPreferenceThemeFileName = "{ThemeFile.no_preference.value}"
''' # }}} ''' # }}}

View File

@@ -5,6 +5,7 @@ package themes
import ( import (
"fmt" "fmt"
"io" "io"
"kitty"
"maps" "maps"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -521,6 +522,24 @@ func (self *handler) on_accepting_key_event(ev *loop.KeyEvent) error {
self.lp.Quit(0) self.lp.Quit(0)
return nil return nil
} }
scheme := func(name string) error {
ev.Handled = true
self.themes_list.CurrentTheme().SaveInFile(utils.ConfigDir(), name)
self.update_recent()
self.lp.Quit(0)
return nil
}
if ev.MatchesCaseInsensitiveTextOrKey("d") || ev.MatchesPressOrRepeat("shift+d") {
return scheme(kitty.DarkThemeFileName)
}
if ev.MatchesCaseInsensitiveTextOrKey("l") || ev.MatchesPressOrRepeat("shift+l") {
return scheme(kitty.LightThemeFileName)
}
if ev.MatchesCaseInsensitiveTextOrKey("n") || ev.MatchesPressOrRepeat("shift+n") {
return scheme(kitty.NoPreferenceThemeFileName)
}
return nil return nil
} }
@@ -558,6 +577,15 @@ func (self *handler) draw_accepting_screen() {
self.lp.Printf(` %slace the theme file in %s but do not modify %s`, ac("P"), utils.ConfigDir(), kc) self.lp.Printf(` %slace the theme file in %s but do not modify %s`, ac("P"), utils.ConfigDir(), kc)
self.lp.Println() self.lp.Println()
self.lp.Println() self.lp.Println()
self.lp.Printf(` Save as colors to use when the OS switches to:`)
self.lp.Println()
self.lp.Printf(` %sark mode`, ac("D"))
self.lp.Println()
self.lp.Printf(` %sight mode`, ac("L"))
self.lp.Println()
self.lp.Printf(` %so preference mode`, ac("N"))
self.lp.Println()
self.lp.Println()
self.lp.Printf(` %sbort and return to list of themes`, ac("A")) self.lp.Printf(` %sbort and return to list of themes`, ac("A"))
self.lp.Println() self.lp.Println()
self.lp.Println() self.lp.Println()

View File

@@ -19,7 +19,6 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
Callable, Callable,
Literal,
Optional, Optional,
Union, Union,
) )
@@ -36,7 +35,7 @@ from .clipboard import (
set_clipboard_string, set_clipboard_string,
set_primary_selection, set_primary_selection,
) )
from .colors import ColorsSpec, TransparentBackgroundColors, patch_options_with_color_spec, theme_colors from .colors import ColorSchemes, theme_colors
from .conf.utils import BadLine, KeyAction, to_cmdline from .conf.utils import BadLine, KeyAction, to_cmdline
from .config import common_opts_as_dict, prepare_config_file_for_editing from .config import common_opts_as_dict, prepare_config_file_for_editing
from .constants import ( from .constants import (
@@ -93,7 +92,6 @@ from .fast_data_types import (
monotonic, monotonic,
os_window_focus_counters, os_window_focus_counters,
os_window_font_size, os_window_font_size,
patch_global_colors,
redirect_mouse_handling, redirect_mouse_handling,
ring_bell, ring_bell,
run_with_activation_token, run_with_activation_token,
@@ -2638,20 +2636,6 @@ class Boss:
window.screen.disable_ligatures = strategy window.screen.disable_ligatures = strategy
window.refresh() window.refresh()
def patch_colors(self, spec: ColorsSpec, transparent_background_colors: TransparentBackgroundColors, configured: bool = False) -> None:
opts = get_options()
if configured:
patch_options_with_color_spec(opts, spec, transparent_background_colors)
for tm in self.all_tab_managers:
tm.tab_bar.patch_colors(spec)
tm.tab_bar.layout()
tm.mark_tab_bar_dirty()
t = tm.active_tab
if t is not None:
t.relayout_borders()
set_os_window_chrome(tm.os_window_id)
patch_global_colors(spec, configured)
def apply_new_options(self, opts: Options) -> None: def apply_new_options(self, opts: Options) -> None:
from .fonts.box_drawing import set_scale from .fonts.box_drawing import set_scale
# Update options storage # Update options storage
@@ -3062,9 +3046,8 @@ class Boss:
def sanitize_url_for_dispay_to_user(self, url: str) -> str: def sanitize_url_for_dispay_to_user(self, url: str) -> str:
return sanitize_url_for_dispay_to_user(url) return sanitize_url_for_dispay_to_user(url)
def on_system_color_scheme_change(self, appearance: Literal['light', 'dark', 'no_preference'], is_initial_value: bool) -> None: def on_system_color_scheme_change(self, appearance: ColorSchemes, is_initial_value: bool) -> None:
if not is_initial_value and appearance != 'no_preference': theme_colors.on_system_color_scheme_change(appearance, is_initial_value)
theme_colors.on_system_color_scheme_change(appearance)
@ac('win', ''' @ac('win', '''
Toggle to the tab matching the specified expression Toggle to the tab matching the specified expression

View File

@@ -3,35 +3,60 @@
import os import os
from contextlib import suppress from contextlib import suppress
from typing import Iterable, Literal, Optional, Union from enum import Enum
from typing import Iterable, Literal, Optional, Sequence, Union
from .config import parse_config from .config import parse_config
from .constants import config_dir from .constants import config_dir
from .fast_data_types import Color, get_boss, glfw_get_system_color_theme from .fast_data_types import Color, get_boss, get_options, glfw_get_system_color_theme, patch_color_profiles, patch_global_colors, set_os_window_chrome
from .options.types import Options, nullable_colors from .options.types import Options, nullable_colors
from .rgb import color_from_int from .rgb import color_from_int
from .typing import WindowType
ColorsSpec = dict[str, Optional[int]] ColorsSpec = dict[str, Optional[int]]
TransparentBackgroundColors = tuple[tuple[Color, float], ...] TransparentBackgroundColors = tuple[tuple[Color, float], ...]
ColorSchemes = Literal['light', 'dark', 'no_preference']
class ThemeFile(Enum):
dark: str = 'dark-theme.auto.conf'
light: str = 'light-theme.auto.conf'
no_preference: str = 'no-preference-theme.auto.conf'
class ThemeColors: class ThemeColors:
dark_mtime: float = -1 dark_mtime: int = -1
light_mtime: float = -1 light_mtime: int = -1
applied_theme: Literal['light', 'dark', ''] = '' no_preference_mtime: int = -1
applied_theme: Literal['light', 'dark', 'no_preference', ''] = ''
def refresh(self) -> None: def refresh(self) -> bool:
with suppress(FileNotFoundError), open(os.path.join(config_dir, 'dark-theme.conf')) as f: found = False
mtime = os.stat(f.fileno()).st_mtime with suppress(FileNotFoundError):
if mtime > self.dark_mtime: for x in os.scandir(config_dir):
self.dark_spec, self.dark_tbc = parse_colors((f,)) if x.name == ThemeFile.dark.value:
self.dark_mtime = mtime mtime = x.stat().st_mtime_ns
with suppress(FileNotFoundError), open(os.path.join(config_dir, 'light-theme.conf')) as f: if mtime > self.dark_mtime:
mtime = os.stat(f.fileno()).st_mtime with open(x.path) as f:
if mtime > self.light_mtime: self.dark_spec, self.dark_tbc = parse_colors((f,))
self.light_spec, self.light_tbc = parse_colors((f,)) self.dark_mtime = mtime
self.light_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 = parse_colors((f,))
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 = parse_colors((f,))
self.no_preference_mtime = mtime
found = True
return found
@property @property
def has_dark_theme(self) -> bool: def has_dark_theme(self) -> bool:
@@ -41,24 +66,56 @@ class ThemeColors:
def has_light_theme(self) -> bool: def has_light_theme(self) -> bool:
return self.light_mtime > -1 return self.light_mtime > -1
def patch_opts(self, opts: Options) -> None: @property
def has_no_preference_theme(self) -> bool:
return self.no_preference_mtime > -1
def patch_opts(self, opts: Options, debug_rendering: bool = False) -> None:
from .utils import log_error
if debug_rendering:
log_error('Querying system for current color scheme')
which = glfw_get_system_color_theme() which = glfw_get_system_color_theme()
if debug_rendering:
log_error('Current system color scheme:', which)
if which == 'dark' and self.has_dark_theme: if which == 'dark' and self.has_dark_theme:
patch_options_with_color_spec(opts, self.dark_spec, self.dark_tbc) patch_options_with_color_spec(opts, self.dark_spec, self.dark_tbc)
self.applied_theme = 'dark' if debug_rendering:
log_error(f'Applied {which} color theme')
self.applied_theme = which
elif which == 'light' and self.has_light_theme: elif which == 'light' and self.has_light_theme:
patch_options_with_color_spec(opts, self.light_spec, self.light_tbc) patch_options_with_color_spec(opts, self.light_spec, self.light_tbc)
self.applied_theme = 'light' if debug_rendering:
log_error(f'Applied {which} color theme')
self.applied_theme = which
elif which == 'no_preference' and self.has_no_preference_theme:
patch_options_with_color_spec(opts, self.no_preference_spec, self.no_preference_tbc)
if debug_rendering:
log_error(f'Applied {which} color theme')
self.applied_theme = which
def on_system_color_scheme_change(self, new_value: Literal['light', 'dark']) -> bool: def on_system_color_scheme_change(self, new_value: ColorSchemes, is_initial_value: bool = False) -> bool:
if is_initial_value:
return False
from .utils import log_error
self.refresh()
boss = get_boss() boss = get_boss()
if new_value == 'dark' and self.has_dark_theme: if new_value == 'dark' and self.has_dark_theme:
boss.patch_colors(self.dark_spec, self.dark_tbc, True) patch_colors(self.dark_spec, self.dark_tbc, True)
self.applied_theme = 'dark' self.applied_theme = new_value
if boss.args.debug_rendering:
log_error(f'Applied color theme {new_value}')
return True return True
if new_value == 'light' and self.has_light_theme: if new_value == 'light' and self.has_light_theme:
boss.patch_colors(self.light_spec, self.light_tbc, True) patch_colors(self.light_spec, self.light_tbc, True)
self.applied_theme = 'light' 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)
self.applied_theme = new_value
if boss.args.debug_rendering:
log_error(f'Applied color theme {new_value}')
return True return True
return False return False
@@ -92,7 +149,6 @@ def parse_colors(args: Iterable[Union[str, Iterable[str]]]) -> tuple[ColorsSpec,
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) -> None:
for k, v in spec.items(): for k, v in spec.items():
if hasattr(opts, k): if hasattr(opts, k):
if v is None: if v is None:
@@ -101,3 +157,33 @@ def patch_options_with_color_spec(opts: Options, spec: ColorsSpec, transparent_b
else: else:
setattr(opts, k, color_from_int(v)) setattr(opts, k, color_from_int(v))
opts.transparent_background_colors = transparent_background_colors opts.transparent_background_colors = transparent_background_colors
def patch_colors(
spec: ColorsSpec, transparent_background_colors: TransparentBackgroundColors, configured: bool = False,
windows: Optional[Sequence[WindowType]] = None
) -> None:
boss = get_boss()
if windows is None:
windows = tuple(boss.all_windows)
profiles = tuple(w.screen.color_profile for w in windows if w)
patch_color_profiles(spec, transparent_background_colors, profiles, configured)
opts = get_options()
if configured:
patch_options_with_color_spec(opts, spec, transparent_background_colors)
for tm in get_boss().all_tab_managers:
tm.tab_bar.patch_colors(spec)
tm.tab_bar.layout()
tm.mark_tab_bar_dirty()
t = tm.active_tab
if t is not None:
t.relayout_borders()
set_os_window_chrome(tm.os_window_id)
patch_global_colors(spec, configured)
default_bg_changed = 'background' in spec
boss = get_boss()
for w in windows:
if w:
if default_bg_changed:
boss.default_bg_changed_for(w.id)
w.refresh()

View File

@@ -249,8 +249,8 @@ class AppRunner:
def __call__(self, opts: Options, args: CLIOptions, bad_lines: Sequence[BadLine] = (), talk_fd: int = -1) -> None: def __call__(self, opts: Options, args: CLIOptions, bad_lines: Sequence[BadLine] = (), talk_fd: int = -1) -> None:
set_scale(opts.box_drawing_scale) set_scale(opts.box_drawing_scale)
set_options(opts, is_wayland(), args.debug_rendering, args.debug_font_fallback) set_options(opts, is_wayland(), args.debug_rendering, args.debug_font_fallback)
theme_colors.refresh() if theme_colors.refresh():
theme_colors.patch_opts(opts) theme_colors.patch_opts(opts, args.debug_rendering)
try: try:
set_font_family(opts, add_builtin_nerd_font=True) set_font_family(opts, add_builtin_nerd_font=True)
_run_app(opts, args, bad_lines, talk_fd) _run_app(opts, args, bad_lines, talk_fd)

View File

@@ -5,7 +5,7 @@
from typing import TYPE_CHECKING, Dict, Optional from typing import TYPE_CHECKING, Dict, Optional
from kitty.cli import emph from kitty.cli import emph
from kitty.fast_data_types import Color, patch_color_profiles from kitty.fast_data_types import Color
from .base import ( from .base import (
MATCH_TAB_OPTION, MATCH_TAB_OPTION,
@@ -87,25 +87,18 @@ this option, any color arguments are ignored and :option:`kitten @ set-colors --
return ans return ans
def response_from_kitty(self, boss: Boss, window: Optional[Window], payload_get: PayloadGetType) -> ResponseType: def response_from_kitty(self, boss: Boss, window: Optional[Window], payload_get: PayloadGetType) -> ResponseType:
from kitty.colors import patch_colors
windows = self.windows_for_payload(boss, window, payload_get) windows = self.windows_for_payload(boss, window, payload_get)
colors: Dict[str, int | None] = payload_get('colors') colors: Dict[str, int | None] = payload_get('colors')
tbc = colors.get('transparent_background_colors') tbc = colors.get('transparent_background_colors')
if payload_get('reset'): if payload_get('reset'):
colors = {k: None if v is None else int(v) for k, v in boss.color_settings_at_startup.items()} colors = {k: None if v is None else int(v) for k, v in boss.color_settings_at_startup.items()}
profiles = tuple(w.screen.color_profile for w in windows if w)
if tbc: if tbc:
from kitty.options.utils import transparent_background_colors from kitty.options.utils import transparent_background_colors
parsed_tbc = transparent_background_colors(str(tbc)) parsed_tbc = transparent_background_colors(str(tbc))
else: else:
parsed_tbc = () parsed_tbc = ()
patch_color_profiles(colors, parsed_tbc, profiles, payload_get('configured')) patch_colors(colors, parsed_tbc, bool(payload_get('configured')), windows=windows)
boss.patch_colors(colors, parsed_tbc, payload_get('configured'))
default_bg_changed = 'background' in colors
for w in windows:
if w:
if default_bg_changed:
boss.default_bg_changed_for(w.id)
w.refresh()
return None return None

View File

@@ -586,15 +586,18 @@ func (self *Theme) SaveInDir(dirpath string) (err error) {
return utils.AtomicUpdateFile(path, bytes.NewReader(utils.UnsafeStringToBytes(code)), 0o644) return utils.AtomicUpdateFile(path, bytes.NewReader(utils.UnsafeStringToBytes(code)), 0o644)
} }
func (self *Theme) SaveInConf(config_dir, reload_in, config_file_name string) (err error) { func (self *Theme) SaveInFile(config_dir, config_file_name string) (err error) {
_ = os.MkdirAll(config_dir, 0o755) _ = os.MkdirAll(config_dir, 0o755)
path := filepath.Join(config_dir, `current-theme.conf`) path := filepath.Join(config_dir, config_file_name)
code, err := self.Code() code, err := self.Code()
if err != nil { if err != nil {
return err return err
} }
err = utils.AtomicUpdateFile(path, bytes.NewReader(utils.UnsafeStringToBytes(code)), 0o644) return utils.AtomicUpdateFile(path, bytes.NewReader(utils.UnsafeStringToBytes(code)), 0o644)
if err != nil { }
func (self *Theme) SaveInConf(config_dir, reload_in, config_file_name string) (err error) {
if err = self.SaveInFile(config_dir, `current-theme.conf`); err != nil {
return err return err
} }
confpath := config_file_name confpath := config_file_name