diff --git a/docs/changelog.rst b/docs/changelog.rst index 9c49801ae..8d50f35d7 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -89,6 +89,8 @@ Detailed list of changes - diff kitten: Automatically use dark/light color scheme based on the color scheme of the parent terminal. Can be controlled via the new :opt:`kitten-diff.color_scheme` option. Note that this is a **default behavior change** (:iss:`8170`) +- Allow dynamically generating configuration by running an arbitrary program using the new :code:`geninclude` directive in :file:`kitty.conf` + - When a program running in kitty reports progress of a task display it as a percentage in the tab title. Controlled by the :opt:`tab_title_template` option - When mapping a custom kitten allow using shell escaping for the kitten path (:iss:`8178`) diff --git a/docs/conf.rst b/docs/conf.rst index 842cfefc7..43c376bf9 100644 --- a/docs/conf.rst +++ b/docs/conf.rst @@ -45,13 +45,21 @@ expanded, so :code:`${USER}.conf` becomes :file:`name.conf` if to detect the operating system. It is ``linux``, ``macos`` or ``bsd``. Also, you can use :code:`globinclude` to include files matching a shell glob pattern and :code:`envinclude` to include configuration -from environment variables. For example:: +from environment variables. Finally, you can dynamically generate configuration +by running a program using :code:`geninclude`. For example:: + # Include other.conf include other.conf # Include *.conf files from all subdirs of kitty.d inside the kitty config dir globinclude kitty.d/**/*.conf # Include the *contents* of all env vars starting with KITTY_CONF_ envinclude KITTY_CONF_* + # Run the script dynamic.py placed in the same directory as this config file + # and include its :file:`STDOUT`. Note that Python scripts are fastest + # as they use the embedded Python interpreter, but any executable script + # or program is supported, in any language. Remember to mark the script + # file executable. + geninclude dynamic.py .. note:: Syntax highlighting for :file:`kitty.conf` in vim is available via diff --git a/kitty/conf/utils.py b/kitty/conf/utils.py index 7a6c1177a..4470103f7 100644 --- a/kitty/conf/utils.py +++ b/kitty/conf/utils.py @@ -198,12 +198,67 @@ class NamedLineIterator: return self.lines +def pygeninclude(path: str) -> list[str]: + import io + import runpy + before = sys.stdout + buf = sys.stdout = io.StringIO() + try: + runpy.run_path(path, run_name='__main__') + finally: + sys.stdout = before + return buf.getvalue().splitlines() + + +def geninclude(path: str) -> list[str]: + old = os.environ.get('KITTY_OS') + os.environ['KITTY_OS'] = os_name() + try: + if path.endswith('.py'): + return pygeninclude(path) + import subprocess + cp = subprocess.run([path], stdout=subprocess.PIPE, text=True) + return cp.stdout.splitlines() + finally: + if old is None: + os.environ.pop('KITTY_OS', None) + else: + os.environ['KITTY_OS'] = old + + + +include_keys = 'include', 'globinclude', 'envinclude', 'geninclude' + + +class RecursiveInclude(Exception): + pass + + +class Memory: + + def __init__(self, accumulate_bad_lines: Optional[List[BadLine]]) -> None: + self.s: set[str] = set() + if accumulate_bad_lines is None: + accumulate_bad_lines = [] + self.accumulate_bad_lines = accumulate_bad_lines + + def seen(self, path: str) -> bool: + key = os.path.normpath(path) + if key in self.s: + self.accumulate_bad_lines.append(BadLine(currently_parsing.number, currently_parsing.line.rstrip(), RecursiveInclude( + f'The file {path} has already been included, ignoring'), currently_parsing.file)) + return True + self.s.add(key) + return False + + def parse_line( line: str, parse_conf_item: ItemParser, ans: Dict[str, Any], base_path_for_includes: str, effective_config_lines: Callable[[str, str], None], + memory: Memory, accumulate_bad_lines: Optional[List[BadLine]] = None, ) -> None: line = line.strip() @@ -214,7 +269,7 @@ def parse_line( log_error(f'Ignoring invalid config line: {line!r}') return key, val = m.groups() - if key in ('include', 'globinclude', 'envinclude'): + if key.endswith('include') and key in include_keys: val = expandvars(os.path.expanduser(val.strip()), {'KITTY_OS': os_name()}) if key == 'globinclude': from pathlib import Path @@ -226,7 +281,22 @@ def parse_line( with currently_parsing.set_file(f''): _parse( NamedLineIterator(os.path.join(base_path_for_includes, ''), iter(os.environ[x].splitlines())), - parse_conf_item, ans, accumulate_bad_lines, effective_config_lines, + parse_conf_item, ans, memory, accumulate_bad_lines, effective_config_lines + ) + return + elif key == 'geninclude': + if not os.path.isabs(val): + val = os.path.join(base_path_for_includes, val) + if not memory.seen(val): + try: + lines = geninclude(val) + except Exception: + log_error(f'Could not process geninclude {val}, ignoring') + else: + with currently_parsing.set_file(f''): + _parse( + NamedLineIterator(os.path.join(base_path_for_includes, ''), iter(lines)), + parse_conf_item, ans, memory, accumulate_bad_lines, effective_config_lines ) return else: @@ -234,15 +304,14 @@ def parse_line( val = os.path.join(base_path_for_includes, val) vals = (val,) for val in vals: + if memory.seen(val): + continue try: with open(val, encoding='utf-8', errors='replace') as include: with currently_parsing.set_file(val): - _parse(include, parse_conf_item, ans, accumulate_bad_lines, effective_config_lines) + _parse(include, parse_conf_item, ans, memory, accumulate_bad_lines, effective_config_lines) except FileNotFoundError: - log_error( - 'Could not find included config file: {}, ignoring'. - format(val) - ) + log_error(f'Could not find included config file: {val}, ignoring') except OSError: log_error( 'Could not read from included config file: {}, ignoring'. @@ -260,6 +329,7 @@ def _parse( lines: Iterable[str], parse_conf_item: ItemParser, ans: Dict[str, Any], + memory: Memory, accumulate_bad_lines: Optional[List[BadLine]] = None, effective_config_lines: Optional[Callable[[str, str], None]] = None, ) -> None: @@ -301,7 +371,7 @@ def _parse( next_line = '' try: with currently_parsing.set_line(line, line_num): - parse_line(line, parse_conf_item, ans, base_path_for_includes, effective_config_lines, accumulate_bad_lines) + parse_line(line, parse_conf_item, ans, base_path_for_includes, effective_config_lines, memory, accumulate_bad_lines) except Exception as e: if accumulate_bad_lines is None: raise @@ -317,7 +387,7 @@ def parse_config_base( accumulate_bad_lines: Optional[List[BadLine]] = None, effective_config_lines: Optional[Callable[[str, str], None]] = None, ) -> None: - _parse(lines, parse_conf_item, ans, accumulate_bad_lines, effective_config_lines) + _parse(lines, parse_conf_item, ans, Memory(accumulate_bad_lines), accumulate_bad_lines, effective_config_lines) def merge_dicts(defaults: Dict[str, Any], newvals: Dict[str, Any]) -> Dict[str, Any]: diff --git a/kitty_tests/options.py b/kitty_tests/options.py index 58df6884a..1eba937d8 100644 --- a/kitty_tests/options.py +++ b/kitty_tests/options.py @@ -1,9 +1,12 @@ #!/usr/bin/env python # License: GPL v3 Copyright: 2018, Kovid Goyal +import os +import shutil +import tempfile from kitty.fast_data_types import Color, test_cursor_blink_easing_function -from kitty.options.utils import DELETE_ENV_VAR, EasingFunction +from kitty.options.utils import DELETE_ENV_VAR, EasingFunction, to_color from kitty.utils import log_error from . import BaseTest @@ -15,170 +18,219 @@ class TestConfParsing(BaseTest): super().setUp() self.error_messages = [] log_error.redirect = self.error_messages.append + self.tdir = tempfile.mkdtemp() def tearDown(self): del log_error.redirect + shutil.rmtree(self.tdir) super().tearDown() def test_conf_parsing(self): - from kitty.config import defaults, load_config - from kitty.constants import is_macos - from kitty.fonts import FontModification, ModificationType, ModificationUnit, ModificationValue - from kitty.options.utils import to_modifiers - bad_lines = [] + conf_parsing(self) - def p(*lines, bad_line_num=0): - del bad_lines[:] - del self.error_messages[:] - ans = load_config(overrides=lines, accumulate_bad_lines=bad_lines) - if bad_line_num: - self.ae(len(bad_lines), bad_line_num) - else: - self.assertFalse(bad_lines) - return ans - def keys_for_func(opts, name): - for key, defns in opts.keyboard_modes[''].keymap.items(): - for action in opts.alias_map.resolve_aliases(defns[0].definition): - if action.func == name: - yield key +def conf_parsing(self): + from kitty.config import defaults, load_config + from kitty.constants import is_macos + from kitty.fonts import FontModification, ModificationType, ModificationUnit, ModificationValue + from kitty.options.utils import to_modifiers + bad_lines = [] - opts = p('font_size 11.37', 'clear_all_shortcuts y', 'color23 red') - self.ae(opts.font_size, 11.37) - self.ae(opts.mouse_hide_wait, 0 if is_macos else 3) - self.ae(opts.color23, Color(255, 0, 0)) - self.assertFalse(opts.keyboard_modes[''].keymap) - opts = p('clear_all_shortcuts y', 'map f1 next_window') - self.ae(len(opts.keyboard_modes[''].keymap), 1) - opts = p('clear_all_mouse_actions y', 'mouse_map left click ungrabbed mouse_click_url_or_select') - self.ae(len(opts.mousemap), 1) - opts = p('strip_trailing_spaces always') - self.ae(opts.strip_trailing_spaces, 'always') - self.assertFalse(bad_lines) - opts = p('pointer_shape_when_grabbed XXX', bad_line_num=1) - self.ae(opts.pointer_shape_when_grabbed, defaults.pointer_shape_when_grabbed) - opts = p('modify_font underline_position -2', 'modify_font underline_thickness 150%', 'modify_font size Test -1px') - self.ae(opts.modify_font, { - 'underline_position': FontModification(ModificationType.underline_position, ModificationValue(-2., ModificationUnit.pt)), - 'underline_thickness': FontModification(ModificationType.underline_thickness, ModificationValue(150, ModificationUnit.percent)), - 'size:Test': FontModification(ModificationType.size, ModificationValue(-1., ModificationUnit.pixel), 'Test'), - }) + def p(*lines, bad_line_num=0, num_err=None): + del bad_lines[:] + del self.error_messages[:] + ans = load_config(overrides=lines, accumulate_bad_lines=bad_lines) + if bad_line_num: + self.ae(len(bad_lines), bad_line_num) + else: + self.assertFalse(bad_lines) + if num_err is not None: + self.ae(len(self.error_messages), num_err, '\n'.join(self.error_messages)) + return ans - # test the aliasing options - opts = p('env A=1', 'env B=x$A', 'env C=', 'env D', 'clear_all_shortcuts y', 'kitten_alias a b --moo', 'map f1 kitten a arg') - self.ae(opts.env, {'A': '1', 'B': 'x1', 'C': '', 'D': DELETE_ENV_VAR}) + def keys_for_func(opts, name): + for key, defns in opts.keyboard_modes[''].keymap.items(): + for action in opts.alias_map.resolve_aliases(defns[0].definition): + if action.func == name: + yield key - def ac(which=0): - ka = tuple(opts.keyboard_modes[''].keymap.values())[0][0] - acs = opts.alias_map.resolve_aliases(ka.definition) - return acs[which] + opts = p('font_size 11.37', 'clear_all_shortcuts y', 'color23 red') + self.ae(opts.font_size, 11.37) + self.ae(opts.mouse_hide_wait, 0 if is_macos else 3) + self.ae(opts.color23, Color(255, 0, 0)) + self.assertFalse(opts.keyboard_modes[''].keymap) + opts = p('clear_all_shortcuts y', 'map f1 next_window') + self.ae(len(opts.keyboard_modes[''].keymap), 1) + opts = p('clear_all_mouse_actions y', 'mouse_map left click ungrabbed mouse_click_url_or_select') + self.ae(len(opts.mousemap), 1) + opts = p('strip_trailing_spaces always') + self.ae(opts.strip_trailing_spaces, 'always') + self.assertFalse(bad_lines) + opts = p('pointer_shape_when_grabbed XXX', bad_line_num=1) + self.ae(opts.pointer_shape_when_grabbed, defaults.pointer_shape_when_grabbed) + opts = p('modify_font underline_position -2', 'modify_font underline_thickness 150%', 'modify_font size Test -1px') + self.ae(opts.modify_font, { + 'underline_position': FontModification(ModificationType.underline_position, ModificationValue(-2., ModificationUnit.pt)), + 'underline_thickness': FontModification(ModificationType.underline_thickness, ModificationValue(150, ModificationUnit.percent)), + 'size:Test': FontModification(ModificationType.size, ModificationValue(-1., ModificationUnit.pixel), 'Test'), + }) - ka = ac() - self.ae(ka.func, 'kitten') - self.ae(ka.args, ('b', '--moo', 'arg')) + # test the aliasing options + opts = p('env A=1', 'env B=x$A', 'env C=', 'env D', 'clear_all_shortcuts y', 'kitten_alias a b --moo', 'map f1 kitten a arg') + self.ae(opts.env, {'A': '1', 'B': 'x1', 'C': '', 'D': DELETE_ENV_VAR}) - opts = p('clear_all_shortcuts y', 'kitten_alias hints hints --hi', 'map f1 kitten hints XXX') - ka = ac() - self.ae(ka.func, 'kitten') - self.ae(ka.args, ('hints', '--hi', 'XXX')) + def ac(which=0): + ka = tuple(opts.keyboard_modes[''].keymap.values())[0][0] + acs = opts.alias_map.resolve_aliases(ka.definition) + return acs[which] - opts = p('clear_all_shortcuts y', 'action_alias la launch --moo', 'map f1 la XXX') - ka = ac() - self.ae(ka.func, 'launch') - self.ae(ka.args, ('--moo', 'XXX')) + ka = ac() + self.ae(ka.func, 'kitten') + self.ae(ka.args, ('b', '--moo', 'arg')) - opts = p('clear_all_shortcuts y', 'action_alias one launch --moo', 'action_alias two one recursive', 'map f1 two XXX') - ka = ac() - self.ae(ka.func, 'launch') - self.ae(ka.args, ('--moo', 'recursive', 'XXX')) + opts = p('clear_all_shortcuts y', 'kitten_alias hints hints --hi', 'map f1 kitten hints XXX') + ka = ac() + self.ae(ka.func, 'kitten') + self.ae(ka.args, ('hints', '--hi', 'XXX')) - opts = p('clear_all_shortcuts y', 'action_alias launch two 1', 'action_alias two launch 2', 'map f1 launch 3') - ka = ac() - self.ae(ka.func, 'launch') - self.ae(ka.args, ('2', '1', '3')) + opts = p('clear_all_shortcuts y', 'action_alias la launch --moo', 'map f1 la XXX') + ka = ac() + self.ae(ka.func, 'launch') + self.ae(ka.args, ('--moo', 'XXX')) - opts = p('clear_all_shortcuts y', 'action_alias launch launch --moo', 'map f1 launch XXX') - ka = ac() - self.ae(ka.func, 'launch') - self.ae(ka.args, ('--moo', 'XXX')) + opts = p('clear_all_shortcuts y', 'action_alias one launch --moo', 'action_alias two one recursive', 'map f1 two XXX') + ka = ac() + self.ae(ka.func, 'launch') + self.ae(ka.args, ('--moo', 'recursive', 'XXX')) - opts = p('clear_all_shortcuts y', 'action_alias cfs change_font_size current', 'map f1 cfs +2') - ka = ac() - self.ae(ka.func, 'change_font_size') - self.ae(ka.args, (False, '+', 2.0)) + opts = p('clear_all_shortcuts y', 'action_alias launch two 1', 'action_alias two launch 2', 'map f1 launch 3') + ka = ac() + self.ae(ka.func, 'launch') + self.ae(ka.args, ('2', '1', '3')) - opts = p('clear_all_shortcuts y', 'action_alias la launch --moo', 'map f1 combine : new_window : la ') - self.ae((ac().func, ac(1).func), ('new_window', 'launch')) + opts = p('clear_all_shortcuts y', 'action_alias launch launch --moo', 'map f1 launch XXX') + ka = ac() + self.ae(ka.func, 'launch') + self.ae(ka.args, ('--moo', 'XXX')) - opts = p('clear_all_shortcuts y', 'action_alias cc combine : new_window : launch --moo', 'map f1 cc XXX') - self.ae((ac().func, ac(1).func), ('new_window', 'launch')) - self.ae(ac(1).args, ('--moo', 'XXX')) + opts = p('clear_all_shortcuts y', 'action_alias cfs change_font_size current', 'map f1 cfs +2') + ka = ac() + self.ae(ka.func, 'change_font_size') + self.ae(ka.args, (False, '+', 2.0)) - opts = p('clear_all_shortcuts y', 'action_alias ss kitten "space 1"', 'map f1 ss "space 2"') - self.ae(ac().args, ('space 1', 'space 2')) + opts = p('clear_all_shortcuts y', 'action_alias la launch --moo', 'map f1 combine : new_window : la ') + self.ae((ac().func, ac(1).func), ('new_window', 'launch')) - opts = p('kitty_mod alt') - self.ae(opts.kitty_mod, to_modifiers('alt')) - self.ae(next(keys_for_func(opts, 'next_layout')).mods, opts.kitty_mod) + opts = p('clear_all_shortcuts y', 'action_alias cc combine : new_window : launch --moo', 'map f1 cc XXX') + self.ae((ac().func, ac(1).func), ('new_window', 'launch')) + self.ae(ac(1).args, ('--moo', 'XXX')) - # deprecation handling - opts = p('clear_all_shortcuts y', 'send_text all f1 hello') - self.ae(len(opts.keyboard_modes[''].keymap), 1) - opts = p('macos_hide_titlebar y' if is_macos else 'x11_hide_window_decorations y') - self.assertTrue(opts.hide_window_decorations) - self.ae(len(self.error_messages), 1) + opts = p('clear_all_shortcuts y', 'action_alias ss kitten "space 1"', 'map f1 ss "space 2"') + self.ae(ac().args, ('space 1', 'space 2')) - # line breaks - opts = p(" font", - " \t \t \\_size", - " \\ 12", - "\\.35", - "col", - "\\o", - "\t \t\\r", - "\\25", - " \\ blue") - self.ae(opts.font_size, 12.35) - self.ae(opts.color25, Color(0, 0, 255)) + opts = p('kitty_mod alt') + self.ae(opts.kitty_mod, to_modifiers('alt')) + self.ae(next(keys_for_func(opts, 'next_layout')).mods, opts.kitty_mod) - # cursor_blink_interval - def cb(src, interval=-1, first=EasingFunction(), second=EasingFunction()): - opts = p('cursor_blink_interval ' + src) - self.ae((float(interval), first, second), (float(opts.cursor_blink_interval[0]), opts.cursor_blink_interval[1], opts.cursor_blink_interval[2])) - cb('3', 3) - cb('-2.3', -2.3) - cb('linear', first=EasingFunction('cubic-bezier', cubic_bezier_points=(0, 0.0, 1.0, 1.0))) - cb('linear 19', 19, EasingFunction('cubic-bezier', cubic_bezier_points=(0, 0.0, 1.0, 1.0))) - cb('ease-in-out cubic-bezier(0.1, 0.2, 0.3, 0.4) 11', 11, - EasingFunction('cubic-bezier', cubic_bezier_points=(0.42, 0, 0.58, 1)), - EasingFunction('cubic-bezier', cubic_bezier_points=(0.1, 0.2, 0.3, 0.4)) - ) - cb('step-start', first=EasingFunction('steps', num_steps=1, jump_type='start')) - cb('steps(7, jump-none)', first=EasingFunction('steps', num_steps=7, jump_type='none')) - cb('linear(0, 0.25, 1)', first=EasingFunction('linear', linear_x=(0.0, 0.5, 1.0), linear_y=(0, 0.25, 1.0))) - cb('linear(0, 0.25 75%, 1)', first=EasingFunction('linear', linear_x=(0.0, 0.75, 1.0), linear_y=(0, 0.25, 1.0))) - cb('linear(0, 0.25 25% 75%, 1)', first=EasingFunction('linear', linear_x=(0.0, 0.25, 0.75, 1.0), linear_y=(0, 0.25, 0.25, 1.0))) + # deprecation handling + opts = p('clear_all_shortcuts y', 'send_text all f1 hello') + self.ae(len(opts.keyboard_modes[''].keymap), 1) + opts = p('macos_hide_titlebar y' if is_macos else 'x11_hide_window_decorations y') + self.assertTrue(opts.hide_window_decorations) + self.ae(len(self.error_messages), 1) - # test that easing functions give expected values - def ef(spec, tests, only_single=True, duration=0.5, accuracy=0): - cfv = p('cursor_blink_interval ' + spec).cursor_blink_interval - self.set_options({'cursor_blink_interval': cfv}) - for t, expected in tests.items(): - actual = test_cursor_blink_easing_function(t, only_single, duration) - if abs(actual - expected) > accuracy: - self.ae(expected, actual, f'Failed for {spec=} with {t=}: {expected} != {actual}') + # line breaks + opts = p(" font", + " \t \t \\_size", + " \\ 12", + "\\.35", + "col", + "\\o", + "\t \t\\r", + "\\25", + " \\ blue") + self.ae(opts.font_size, 12.35) + self.ae(opts.color25, Color(0, 0, 255)) - ef('linear', {0:1, 0.25: 0.5, 0.5: 0, 0.75: 0.5, 1: 1}, only_single=False) + # cursor_blink_interval + def cb(src, interval=-1, first=EasingFunction(), second=EasingFunction()): + opts = p('cursor_blink_interval ' + src) + self.ae((float(interval), first, second), (float(opts.cursor_blink_interval[0]), opts.cursor_blink_interval[1], opts.cursor_blink_interval[2])) + cb('3', 3) + cb('-2.3', -2.3) + cb('linear', first=EasingFunction('cubic-bezier', cubic_bezier_points=(0, 0.0, 1.0, 1.0))) + cb('linear 19', 19, EasingFunction('cubic-bezier', cubic_bezier_points=(0, 0.0, 1.0, 1.0))) + cb('ease-in-out cubic-bezier(0.1, 0.2, 0.3, 0.4) 11', 11, + EasingFunction('cubic-bezier', cubic_bezier_points=(0.42, 0, 0.58, 1)), + EasingFunction('cubic-bezier', cubic_bezier_points=(0.1, 0.2, 0.3, 0.4)) + ) + cb('step-start', first=EasingFunction('steps', num_steps=1, jump_type='start')) + cb('steps(7, jump-none)', first=EasingFunction('steps', num_steps=7, jump_type='none')) + cb('linear(0, 0.25, 1)', first=EasingFunction('linear', linear_x=(0.0, 0.5, 1.0), linear_y=(0, 0.25, 1.0))) + cb('linear(0, 0.25 75%, 1)', first=EasingFunction('linear', linear_x=(0.0, 0.75, 1.0), linear_y=(0, 0.25, 1.0))) + cb('linear(0, 0.25 25% 75%, 1)', first=EasingFunction('linear', linear_x=(0.0, 0.25, 0.75, 1.0), linear_y=(0, 0.25, 0.25, 1.0))) - ef('linear(0, 0.25 25% 75%, 1)', {0: 0, 0.25: 0.25, 0.3: 0.25, 0.75: 0.25, 1:1}) - linear_vals = {0: 0, 1: 1, 0.1234: 0.1234, 0.6453: 0.6453} - for spec in ('linear', 'linear(0, 1)', 'cubic-bezier(0, 0, 1, 1)', 'cubic-bezier(0.2, 0.2, 0.7, 0.7)'): - ef(spec, linear_vals) - # test an almost linear function to test cubic bezier implementation - ef('cubic-bezier(0.2, 0.2, 0.7, 0.71)', linear_vals, accuracy=0.01) - ef('cubic-bezier(0.23, 0.2, 0.7, 0.71)', linear_vals, accuracy=0.01) + # test that easing functions give expected values + def ef(spec, tests, only_single=True, duration=0.5, accuracy=0): + cfv = p('cursor_blink_interval ' + spec).cursor_blink_interval + self.set_options({'cursor_blink_interval': cfv}) + for t, expected in tests.items(): + actual = test_cursor_blink_easing_function(t, only_single, duration) + if abs(actual - expected) > accuracy: + self.ae(expected, actual, f'Failed for {spec=} with {t=}: {expected} != {actual}') - ef('steps(5)', {0: 0, 0.1: 0, 0.3: 0.2, 0.9:0.8}) - ef('steps(5, start)', {0: 0.2, 0.1: 0.2, 0.3: 0.4, 0.9:1}) - ef('steps(4, jump-both)', {0: 0.2, 0.1: 0.2, 0.3: 0.4, 0.9:1}) - ef('steps(6, jump-none)', {0: 0, 0.1: 0.0, 0.3: 0.2, 0.9:1}) + ef('linear', {0:1, 0.25: 0.5, 0.5: 0, 0.75: 0.5, 1: 1}, only_single=False) + + ef('linear(0, 0.25 25% 75%, 1)', {0: 0, 0.25: 0.25, 0.3: 0.25, 0.75: 0.25, 1:1}) + linear_vals = {0: 0, 1: 1, 0.1234: 0.1234, 0.6453: 0.6453} + for spec in ('linear', 'linear(0, 1)', 'cubic-bezier(0, 0, 1, 1)', 'cubic-bezier(0.2, 0.2, 0.7, 0.7)'): + ef(spec, linear_vals) + # test an almost linear function to test cubic bezier implementation + ef('cubic-bezier(0.2, 0.2, 0.7, 0.71)', linear_vals, accuracy=0.01) + ef('cubic-bezier(0.23, 0.2, 0.7, 0.71)', linear_vals, accuracy=0.01) + + ef('steps(5)', {0: 0, 0.1: 0, 0.3: 0.2, 0.9:0.8}) + ef('steps(5, start)', {0: 0.2, 0.1: 0.2, 0.3: 0.4, 0.9:1}) + ef('steps(4, jump-both)', {0: 0.2, 0.1: 0.2, 0.3: 0.4, 0.9:1}) + ef('steps(6, jump-none)', {0: 0, 0.1: 0.0, 0.3: 0.2, 0.9:1}) + + # test various include modes + base = os.path.join(self.tdir, 'glob') + os.mkdir(base) + with open(os.path.join(base, 'fg'), 'w') as f: + print('foreground red', file=f) + opts = p(f'include {f.name}', num_err=0) + self.ae(opts.foreground, to_color('red')) + with open(os.path.join(self.tdir, 'bg'), 'w') as f: + print('background white', file=f) + print('globinclude glob/*', file=f) + print('envinclude ENVINCLUDE', file=f) + print('geninclude g.py', file=f) + print('geninclude g', file=f) + with open(os.path.join(self.tdir, 'g.py'), 'w') as g: + print('print("background_opacity .77")', file=g) + print('print("background_blur 77")', file=g) + with open(os.path.join(self.tdir, 'g'), 'w') as g: + print('#!/bin/sh', file=g) + print('echo background_image_linear y', file=g) + print('echo background_image_layout clamped', file=g) + os.chmod(g.fileno(), 0o700) + os.environ['ENVINCLUDE'] = 'cursor yellow' + opts = p(f'include {f.name}', num_err=0) + os.environ.pop('ENVINCLUDE') + self.ae(opts.foreground, to_color('red')) + self.ae(opts.background, to_color('white')) + self.ae(opts.cursor, to_color('yellow')) + self.ae(opts.background_opacity, .77) + self.ae(opts.background_blur, 77) + self.ae(opts.background_image_linear, True) + self.ae(opts.background_image_layout, 'clamped') + with open(os.path.join(self.tdir, 'a'), 'w') as a: + print('background red', file=a) + print('include b', file=a) + with open(os.path.join(self.tdir, 'b'), 'w') as a: + print('foreground red', file=a) + print('include a', file=a) + opts = p(f'include {a.name}', num_err=0, bad_line_num=1) + self.ae(opts.foreground, to_color('red')) + self.ae(opts.background, to_color('red')) diff --git a/tools/config/api.go b/tools/config/api.go index 374dbc971..bfb550b38 100644 --- a/tools/config/api.go +++ b/tools/config/api.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "strconv" "strings" "sync" @@ -61,6 +62,55 @@ var key_pat = sync.OnceValue(func() *regexp.Regexp { return regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9_-]*)\s+(.+)$`) }) +var kitty_os = sync.OnceValue(func() string { + switch runtime.GOOS { + case "linux": + return "linux" + case "freebsd", "netbsd", "openbsd": + return "bsd" + case "darwin": + return "macos" + } + return "unknown" +}) + +func geninclude(path string) (string, error) { + cmd := exec.Command(path) + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, "KITTY_OS="+kitty_os()) + if strings.HasSuffix(path, ".py") && unix.Access(path, unix.X_OK) != nil { + if utils.KittyExe() == "" || strings.HasPrefix(path, ":") { + cmd = exec.Command("python", path) + } else { + cmd = exec.Command(utils.KittyExe(), "+launch", path) + } + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", err + } + if err = cmd.Start(); err != nil { + return "", err + } + data, err := io.ReadAll(stdout) + if err != nil { + return "", err + } + if err = cmd.Wait(); err != nil { + return "", err + } + return utils.UnsafeBytesToString(data), nil +} + +func ExpandVars(x string) string { + return os.Expand(x, func(k string) string { + if k == "KITTY_OS" { + return kitty_os() + } + return os.Getenv(k) + }) +} + func (self *ConfigParser) parse(scanner Scanner, name, base_path_for_includes string, depth int) error { if self.seen_includes[name] { // avoid include loops return nil @@ -90,6 +140,10 @@ func (self *ConfigParser) parse(scanner Scanner, name, base_path_for_includes st next_line := "" var line string + add_bad_line := func(err error) { + self.bad_lines = append(self.bad_lines, ConfigLine{Src_file: name, Line: line, Line_number: lnum, Err: err}) + } + for { if next_line != "" { line = next_line @@ -124,16 +178,15 @@ func (self *ConfigParser) parse(scanner Scanner, name, base_path_for_includes st if line[0] == '#' { if self.CommentsHandler != nil { - err := self.CommentsHandler(line) - if err != nil { - self.bad_lines = append(self.bad_lines, ConfigLine{Src_file: name, Line: line, Line_number: lnum, Err: err}) + if err := self.CommentsHandler(line); err != nil { + add_bad_line(err) } } continue } m := key_pat().FindStringSubmatch(line) if len(m) < 3 { - self.bad_lines = append(self.bad_lines, ConfigLine{Src_file: name, Line: line, Line_number: lnum, Err: fmt.Errorf("Invalid config line: %#v", line)}) + add_bad_line(fmt.Errorf("Invalid config line: %#v", line)) continue } key, val := m[1], m[2] @@ -146,17 +199,18 @@ func (self *ConfigParser) parse(scanner Scanner, name, base_path_for_includes st } switch key { default: - err := self.LineHandler(key, val) - if err != nil { - self.bad_lines = append(self.bad_lines, ConfigLine{Src_file: name, Line: line, Line_number: lnum, Err: err}) + if err := self.LineHandler(key, val); err != nil { + add_bad_line(err) } - case "include", "globinclude", "envinclude": + case "include", "globinclude", "envinclude", "geninclude": var includes []string + val = ExpandVars(val) switch key { case "include": - aval, err := make_absolute(val) - if err == nil { + if aval, err := make_absolute(val); err == nil { includes = []string{aval} + } else { + add_bad_line(err) } case "globinclude": aval, err := make_absolute(val) @@ -164,13 +218,26 @@ func (self *ConfigParser) parse(scanner Scanner, name, base_path_for_includes st matches, err := filepath.Glob(aval) if err == nil { includes = matches + } else { + add_bad_line(err) } + } else { + add_bad_line(err) + } + case "geninclude": + if aval, err := make_absolute(val); err == nil { + if g, err := geninclude(aval); err == nil { + if err := recurse(strings.NewReader(g), "", base_path_for_includes); err != nil { + return err + } + } else { + add_bad_line(err) + } + } else { + add_bad_line(err) } case "envinclude": - env := self.override_env - if env == nil { - env = os.Environ() - } + env := utils.IfElse(self.override_env == nil, os.Environ(), self.override_env) for _, x := range env { key, eval, _ := strings.Cut(x, "=") is_match, err := filepath.Match(val, key) @@ -184,14 +251,12 @@ func (self *ConfigParser) parse(scanner Scanner, name, base_path_for_includes st } if len(includes) > 0 { for _, incpath := range includes { - raw, err := os.ReadFile(incpath) - if err == nil { - err := recurse(bytes.NewReader(raw), incpath, filepath.Dir(incpath)) - if err != nil { + if raw, err := os.ReadFile(incpath); err == nil { + if err := recurse(bytes.NewReader(raw), incpath, filepath.Dir(incpath)); err != nil { return err } } else if !errors.Is(err, fs.ErrNotExist) { - return fmt.Errorf("Failed to process include %#v with error: %w", incpath, err) + add_bad_line(err) } } } diff --git a/tools/config/api_test.go b/tools/config/api_test.go index ac75b46f3..847da2419 100644 --- a/tools/config/api_test.go +++ b/tools/config/api_test.go @@ -24,6 +24,10 @@ func TestConfigParsing(t *testing.T) { t.Fatal(err) } } + w(filepath.Join(tdir, "g.py"), []byte(` +print('gpy 1') +print('gpy 2') +`)) w(conf_file, []byte( `error main # igno @@ -39,6 +43,7 @@ globin \clude sub/c?.c \onf badline +geninclude g.py `)) w(filepath.Join(tdir, "sub/b.conf"), []byte("incb cool\ninclude a.conf")) w(filepath.Join(tdir, "sub/c1.conf"), []byte("inc1 cool")) @@ -62,7 +67,7 @@ badline if err = p.ParseOverrides("over one", "over two"); err != nil { t.Fatal(err) } - diff := cmp.Diff([]string{"a one", "incb cool", "b x", "inc1 cool", "inc2 cool", "env cool", "inc notcool", "over one", "over two"}, parsed_lines) + diff := cmp.Diff([]string{"a one", "incb cool", "b x", "inc1 cool", "inc2 cool", "env cool", "inc notcool", "gpy 1", "gpy 2", "over one", "over two"}, parsed_lines) if diff != "" { t.Fatalf("Unexpected parsed config values:\n%s", diff) }