From 747ac85e7c98ea36b12d12bb97b5f480618f57e7 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 28 Mar 2020 12:13:42 +0530 Subject: [PATCH] Add an :option:`launch --watcher` option that allows defining callbacks that are called for various events in the window's life-cycle Fixes #2440 --- docs/changelog.rst | 3 +++ docs/launch.rst | 29 +++++++++++++++++++++++++++++ kitty/cli.py | 4 ++++ kitty/launch.py | 35 +++++++++++++++++++++++++++++++---- kitty/tabs.py | 10 +++++++--- kitty/window.py | 34 ++++++++++++++++++++++++++++++---- 6 files changed, 104 insertions(+), 11 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9f1ebd34e..6ca24a053 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -7,6 +7,9 @@ To update |kitty|, :doc:`follow the instructions `. 0.17.2 [future] -------------------- +- Add an :option:`launch --watcher` option that allows defining callbacks + that are called for various events in the window's life-cycle (:iss:`2440`) + - Fix a regression in 0.17 that broke drawing of borders with non-minimal borders (:iss:`2474`) diff --git a/docs/launch.rst b/docs/launch.rst index 224521c78..206229214 100644 --- a/docs/launch.rst +++ b/docs/launch.rst @@ -61,6 +61,35 @@ currently active kitty window. For example:: map f1 launch my-program @active-kitty-window-id +Watching launched windows +--------------------------- + +The :option:`launch --watcher` option allows you to specify python functions +that will be called at specific events, such as when the window is resized or +closed. Simply specify the path to a python module that specifies callback +functions for the events you are interested in, for example: + +.. code-block:: python + + def on_resize(boss, window, data): + # Here data will contain old_geometry and new_geometry + + def on_close(boss, window, data): + # called when window is closed, typically when the program running in + # it exits. + + +Every callback is passed a reference to the global ``Boss`` object as well as +the ``Window`` object the action is occurring on. The ``data`` object is +mapping that contains event dependent data. Some useful methods and attributes +for the ``Window`` object are: ``as_text(as_ans=False, add_history=False, +add_wrap_markers=False, alternate_screen=False)`` with which you can get the +contents of the window and its scrollback buffer. Similarly, +``window.child.pid`` is the PID of the processes that was launched +in the window and ``window.id`` is the internal kitty ``id`` of the +window. + + Syntax reference ------------------ diff --git a/kitty/cli.py b/kitty/cli.py index 45d40f312..e27048652 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -121,6 +121,10 @@ def file(x: str) -> str: return italic(x) +def doc(x: str) -> str: + return f'https://sw.kovidgoyal.net/kitty/{x}.html' + + OptionSpecSeq = List[Union[str, OptionDict]] diff --git a/kitty/launch.py b/kitty/launch.py index fe997f3b2..41ecddff7 100644 --- a/kitty/launch.py +++ b/kitty/launch.py @@ -6,14 +6,15 @@ from functools import lru_cache from typing import Any, Dict, List, Optional, Sequence, Tuple +from .boss import Boss from .child import Child from .cli import parse_args from .cli_stub import LaunchCLIOptions +from .constants import resolve_custom_file from .fast_data_types import set_clipboard_string -from .utils import set_primary_selection -from .window import Window -from .boss import Boss from .tabs import Tab +from .utils import set_primary_selection +from .window import Watchers, Window try: from typing import TypedDict @@ -148,6 +149,14 @@ defaults to :code:`kitty`. --os-window-name Set the WM_NAME property on X11 for the newly created OS Window when using :option:`launch --type`=os-window. Defaults to :option:`launch --os-window-class`. + + +--watcher -w +type=list +Path to a python file. Appropriately named functions in this file will be called +for various events, such as when the window is resized or closed. See the section +on watchers in the launch command documentation :doc:`launch`. Relative paths are +resolved relative to the kitty config directory. ''' @@ -192,6 +201,23 @@ def tab_for_window(boss: Boss, opts: LaunchCLIOptions, target_tab: Optional[Tab] return tab +def load_watch_modules(opts: LaunchCLIOptions) -> Optional[Watchers]: + if not opts.watcher: + return None + import runpy + ans = Watchers() + for path in opts.watcher: + path = resolve_custom_file(path) + m = runpy.run_path(path, run_name='__kitty_watcher__') + w = m.get('on_close') + if callable(w): + ans.on_close.append(w) + w = m.get('on_resize') + if callable(w): + ans.on_resize.append(w) + return ans + + class LaunchKwds(TypedDict): allow_remote_control: bool @@ -274,7 +300,8 @@ def launch(boss: Boss, opts: LaunchCLIOptions, args: List[str], target_tab: Opti else: tab = tab_for_window(boss, opts, target_tab) if tab is not None: - new_window: Window = tab.new_window(env=env or None, **kw) + watchers = load_watch_modules(opts) + new_window: Window = tab.new_window(env=env or None, watchers=watchers or None, **kw) if opts.keep_focus and active: boss.set_active_window(active, switch_os_window_if_needed=True) return new_window diff --git a/kitty/tabs.py b/kitty/tabs.py index 797f5e346..d52996943 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -26,7 +26,7 @@ from .layout import ( from .options_stub import Options from .tab_bar import TabBar, TabBarData from .utils import log_error, resolved_shell -from .window import Window, WindowDict +from .window import Window, WindowDict, Watchers from .typing import TypedDict, SessionTab, SessionType @@ -345,11 +345,15 @@ class Tab: # {{{ location: Optional[str] = None, copy_colors_from: Optional[Window] = None, allow_remote_control: bool = False, - marker: Optional[str] = None + marker: Optional[str] = None, + watchers: Optional[Watchers] = None ) -> Window: child = self.launch_child( use_shell=use_shell, cmd=cmd, stdin=stdin, cwd_from=cwd_from, cwd=cwd, env=env, allow_remote_control=allow_remote_control) - window = Window(self, child, self.opts, self.args, override_title=override_title, copy_colors_from=copy_colors_from) + window = Window( + self, child, self.opts, self.args, override_title=override_title, + copy_colors_from=copy_colors_from, watchers=watchers + ) if overlay_for is not None: overlaid = next(w for w in self.windows if w.id == overlay_for) window.overlay_for = overlay_for diff --git a/kitty/window.py b/kitty/window.py index 541a37105..44c2dc7c8 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -10,8 +10,8 @@ from collections import deque from enum import IntEnum from itertools import chain from typing import ( - Any, Callable, Deque, Dict, List, Optional, Pattern, Sequence, Tuple, - Union + Any, Callable, Deque, Dict, Iterable, List, Optional, Pattern, Sequence, + Tuple, Union ) from .child import ProcessDesc @@ -32,7 +32,7 @@ from .keys import defines, extended_key_event, keyboard_mode_name from .options_stub import Options from .rgb import to_color from .terminfo import get_capabilities -from .typing import ChildType, TabType, TypedDict +from .typing import BossType, ChildType, TabType, TypedDict from .utils import ( color_as_int, get_primary_selection, load_shaders, open_cmd, open_url, parse_color_set, read_shell_environment, sanitize_title, @@ -77,6 +77,19 @@ DYNAMIC_COLOR_CODES = { DYNAMIC_COLOR_CODES.update({k+100: v for k, v in DYNAMIC_COLOR_CODES.items()}) +class Watcher: + + def __call__(self, boss: BossType, window: 'Window', data: Dict[str, Any]) -> None: + pass + + +class Watchers: + + def __init__(self) -> None: + self.on_resize: List[Watcher] = [] + self.on_close: List[Watcher] = [] + + def calculate_gl_geometry(window_geometry: WindowGeometry, viewport_width: int, viewport_height: int, cell_width: int, cell_height: int) -> ScreenGeometry: dx, dy = 2 * cell_width / viewport_width, 2 * cell_height / viewport_height xmargin = window_geometry.left / viewport_width @@ -181,8 +194,10 @@ class Window: opts: Options, args: CLIOptions, override_title: Optional[str] = None, - copy_colors_from: Optional['Window'] = None + copy_colors_from: Optional['Window'] = None, + watchers: Optional[Watchers] = None ): + self.watchers = watchers or Watchers() self.action_on_close: Optional[Callable] = None self.action_on_removal: Optional[Callable] = None self.current_marker_spec: Optional[Tuple[str, Union[str, Tuple[Tuple[int, str], ...]]]] = None @@ -303,6 +318,7 @@ class Window: if not self.pty_resized_once: self.pty_resized_once = True self.child.mark_terminal_ready() + self.call_watchers(self.watchers.on_resize, {'old_geometry': self.geometry, 'new_geometry': new_geometry}) else: sg = self.update_position(new_geometry) self.geometry = g = new_geometry @@ -536,7 +552,17 @@ class Window: return ''.join((l.rstrip() or '\n') for l in lines) return ''.join(lines) + def call_watchers(self, which: Iterable[Watcher], data: Dict[str, Any]) -> None: + boss = get_boss() + for w in which: + try: + w(boss, self, data) + except Exception: + import traceback + traceback.print_exc() + def destroy(self) -> None: + self.call_watchers(self.watchers.on_close, {}) self.destroyed = True if hasattr(self, 'screen'): # Remove cycles so that screen is de-allocated immediately