Add an :option:launch --watcher option that allows defining callbacks that are called for various events in the window's life-cycle

Fixes #2440
This commit is contained in:
Kovid Goyal 2020-03-28 12:13:42 +05:30
parent 8c23f9e526
commit 747ac85e7c
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
6 changed files with 104 additions and 11 deletions

View File

@ -7,6 +7,9 @@ To update |kitty|, :doc:`follow the instructions <binary>`.
0.17.2 [future] 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 - Fix a regression in 0.17 that broke drawing of borders with non-minimal
borders (:iss:`2474`) borders (:iss:`2474`)

View File

@ -61,6 +61,35 @@ currently active kitty window. For example::
map f1 launch my-program @active-kitty-window-id 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 Syntax reference
------------------ ------------------

View File

@ -121,6 +121,10 @@ def file(x: str) -> str:
return italic(x) return italic(x)
def doc(x: str) -> str:
return f'https://sw.kovidgoyal.net/kitty/{x}.html'
OptionSpecSeq = List[Union[str, OptionDict]] OptionSpecSeq = List[Union[str, OptionDict]]

View File

@ -6,14 +6,15 @@
from functools import lru_cache from functools import lru_cache
from typing import Any, Dict, List, Optional, Sequence, Tuple from typing import Any, Dict, List, Optional, Sequence, Tuple
from .boss import Boss
from .child import Child from .child import Child
from .cli import parse_args from .cli import parse_args
from .cli_stub import LaunchCLIOptions from .cli_stub import LaunchCLIOptions
from .constants import resolve_custom_file
from .fast_data_types import set_clipboard_string 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 .tabs import Tab
from .utils import set_primary_selection
from .window import Watchers, Window
try: try:
from typing import TypedDict from typing import TypedDict
@ -148,6 +149,14 @@ defaults to :code:`kitty`.
--os-window-name --os-window-name
Set the WM_NAME property on X11 for the newly created OS Window when using 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`. :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 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): class LaunchKwds(TypedDict):
allow_remote_control: bool allow_remote_control: bool
@ -274,7 +300,8 @@ def launch(boss: Boss, opts: LaunchCLIOptions, args: List[str], target_tab: Opti
else: else:
tab = tab_for_window(boss, opts, target_tab) tab = tab_for_window(boss, opts, target_tab)
if tab is not None: 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: if opts.keep_focus and active:
boss.set_active_window(active, switch_os_window_if_needed=True) boss.set_active_window(active, switch_os_window_if_needed=True)
return new_window return new_window

View File

@ -26,7 +26,7 @@ from .layout import (
from .options_stub import Options from .options_stub import Options
from .tab_bar import TabBar, TabBarData from .tab_bar import TabBar, TabBarData
from .utils import log_error, resolved_shell from .utils import log_error, resolved_shell
from .window import Window, WindowDict from .window import Window, WindowDict, Watchers
from .typing import TypedDict, SessionTab, SessionType from .typing import TypedDict, SessionTab, SessionType
@ -345,11 +345,15 @@ class Tab: # {{{
location: Optional[str] = None, location: Optional[str] = None,
copy_colors_from: Optional[Window] = None, copy_colors_from: Optional[Window] = None,
allow_remote_control: bool = False, allow_remote_control: bool = False,
marker: Optional[str] = None marker: Optional[str] = None,
watchers: Optional[Watchers] = None
) -> Window: ) -> Window:
child = self.launch_child( 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) 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: if overlay_for is not None:
overlaid = next(w for w in self.windows if w.id == overlay_for) overlaid = next(w for w in self.windows if w.id == overlay_for)
window.overlay_for = overlay_for window.overlay_for = overlay_for

View File

@ -10,8 +10,8 @@ from collections import deque
from enum import IntEnum from enum import IntEnum
from itertools import chain from itertools import chain
from typing import ( from typing import (
Any, Callable, Deque, Dict, List, Optional, Pattern, Sequence, Tuple, Any, Callable, Deque, Dict, Iterable, List, Optional, Pattern, Sequence,
Union Tuple, Union
) )
from .child import ProcessDesc from .child import ProcessDesc
@ -32,7 +32,7 @@ from .keys import defines, extended_key_event, keyboard_mode_name
from .options_stub import Options from .options_stub import Options
from .rgb import to_color from .rgb import to_color
from .terminfo import get_capabilities from .terminfo import get_capabilities
from .typing import ChildType, TabType, TypedDict from .typing import BossType, ChildType, TabType, TypedDict
from .utils import ( from .utils import (
color_as_int, get_primary_selection, load_shaders, open_cmd, open_url, color_as_int, get_primary_selection, load_shaders, open_cmd, open_url,
parse_color_set, read_shell_environment, sanitize_title, 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()}) 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: 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 dx, dy = 2 * cell_width / viewport_width, 2 * cell_height / viewport_height
xmargin = window_geometry.left / viewport_width xmargin = window_geometry.left / viewport_width
@ -181,8 +194,10 @@ class Window:
opts: Options, opts: Options,
args: CLIOptions, args: CLIOptions,
override_title: Optional[str] = None, 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_close: Optional[Callable] = None
self.action_on_removal: Optional[Callable] = None self.action_on_removal: Optional[Callable] = None
self.current_marker_spec: Optional[Tuple[str, Union[str, Tuple[Tuple[int, str], ...]]]] = 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: if not self.pty_resized_once:
self.pty_resized_once = True self.pty_resized_once = True
self.child.mark_terminal_ready() self.child.mark_terminal_ready()
self.call_watchers(self.watchers.on_resize, {'old_geometry': self.geometry, 'new_geometry': new_geometry})
else: else:
sg = self.update_position(new_geometry) sg = self.update_position(new_geometry)
self.geometry = g = 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((l.rstrip() or '\n') for l in lines)
return ''.join(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: def destroy(self) -> None:
self.call_watchers(self.watchers.on_close, {})
self.destroyed = True self.destroyed = True
if hasattr(self, 'screen'): if hasattr(self, 'screen'):
# Remove cycles so that screen is de-allocated immediately # Remove cycles so that screen is de-allocated immediately