From 9b32f181097132e0a1f346f407bb4ce3477ee577 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 8 Mar 2020 08:39:26 +0530 Subject: [PATCH] Refactor remote control commands into individual modules Also add type information --- docs/conf.py | 17 +- kittens/panel/main.py | 27 +- kittens/resize_window/main.py | 13 +- kitty/boss.py | 9 +- kitty/cli_stub.py | 15 +- kitty/cmds.py | 1465 ---------------------------- kitty/complete.py | 21 +- kitty/conf/definition.py | 5 +- kitty/constants.py | 10 +- kitty/fast_data_types.pyi | 8 + kitty/main.py | 3 +- kitty/rc/__init__.py | 0 kitty/rc/base.py | 179 ++++ kitty/rc/close_tab.py | 49 + kitty/rc/close_window.py | 47 + kitty/rc/create_marker.py | 58 ++ kitty/rc/detach_tab.py | 59 ++ kitty/rc/detach_window.py | 66 ++ kitty/rc/disable_ligatures.py | 60 ++ kitty/rc/focus_tab.py | 52 + kitty/rc/focus_window.py | 55 ++ kitty/rc/get_colors.py | 57 ++ kitty/rc/get_text.py | 67 ++ kitty/rc/goto_layout.py | 56 ++ kitty/rc/kitten.py | 52 + kitty/rc/last_used_layout.py | 48 + kitty/rc/launch.py | 95 ++ kitty/rc/ls.py | 40 + kitty/rc/new_window.py | 124 +++ kitty/rc/remove_marker.py | 48 + kitty/rc/resize_window.py | 69 ++ kitty/rc/scroll_window.py | 70 ++ kitty/rc/send_text.py | 132 +++ kitty/rc/set_background_image.py | 116 +++ kitty/rc/set_background_opacity.py | 58 ++ kitty/rc/set_colors.py | 97 ++ kitty/rc/set_font_size.py | 54 + kitty/rc/set_tab_title.py | 50 + kitty/rc/set_window_title.py | 59 ++ kitty/remote_control.py | 42 +- kitty/shell.py | 32 +- 41 files changed, 2044 insertions(+), 1540 deletions(-) delete mode 100644 kitty/cmds.py create mode 100644 kitty/rc/__init__.py create mode 100644 kitty/rc/base.py create mode 100644 kitty/rc/close_tab.py create mode 100644 kitty/rc/close_window.py create mode 100644 kitty/rc/create_marker.py create mode 100644 kitty/rc/detach_tab.py create mode 100644 kitty/rc/detach_window.py create mode 100644 kitty/rc/disable_ligatures.py create mode 100644 kitty/rc/focus_tab.py create mode 100644 kitty/rc/focus_window.py create mode 100644 kitty/rc/get_colors.py create mode 100644 kitty/rc/get_text.py create mode 100644 kitty/rc/goto_layout.py create mode 100644 kitty/rc/kitten.py create mode 100644 kitty/rc/last_used_layout.py create mode 100644 kitty/rc/launch.py create mode 100644 kitty/rc/ls.py create mode 100644 kitty/rc/new_window.py create mode 100644 kitty/rc/remove_marker.py create mode 100644 kitty/rc/resize_window.py create mode 100644 kitty/rc/scroll_window.py create mode 100644 kitty/rc/send_text.py create mode 100644 kitty/rc/set_background_image.py create mode 100644 kitty/rc/set_background_opacity.py create mode 100644 kitty/rc/set_colors.py create mode 100644 kitty/rc/set_font_size.py create mode 100644 kitty/rc/set_tab_title.py create mode 100644 kitty/rc/set_window_title.py diff --git a/docs/conf.py b/docs/conf.py index c0d20060d..7816b3a42 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -258,16 +258,17 @@ if you specify a program-to-run you can use the special placeholder f.write(option_spec_as_rst(appname='kitty').replace( 'kitty --to', 'kitty @ --to')) as_rst = partial(option_spec_as_rst, heading_char='_') - from kitty.remote_control import global_options_spec, cli_msg, cmap, all_commands + from kitty.rc.base import all_command_names, command_for_name + from kitty.remote_control import global_options_spec, cli_msg with open('generated/cli-kitty-at.rst', 'w') as f: p = partial(print, file=f) p('kitty @\n' + '-' * 80) p('.. program::', 'kitty @') p('\n\n' + as_rst( global_options_spec, message=cli_msg, usage='command ...', appname='kitty @')) - from kitty.cmds import cli_params_for - for cmd_name in all_commands: - func = cmap[cmd_name] + from kitty.rc.base import cli_params_for + for cmd_name in all_command_names(): + func = command_for_name(cmd_name) p(f'.. _at_{func.name}:\n') p('kitty @', func.name + '\n' + '-' * 120) p('.. program::', 'kitty @', func.name) @@ -286,8 +287,8 @@ if you specify a program-to-run you can use the special placeholder # }}} -def write_remote_control_protocol_docs(): # {{{ - from kitty.cmds import cmap +def write_remote_control_protocol_docs() -> None: # {{{ + from kitty.rc.base import all_command_names, command_for_name field_pat = re.compile(r'\s*([a-zA-Z0-9_+]+)\s*:\s*(.+)') def format_cmd(p, name, cmd): @@ -319,8 +320,8 @@ def write_remote_control_protocol_docs(): # {{{ with open(f'generated/rc.rst', 'w') as f: p = partial(print, file=f) - for name in sorted(cmap): - cmd = cmap[name] + for name in sorted(all_command_names()): + cmd = command_for_name(name) if not cmd.__doc__: continue name = name.replace('_', '-') diff --git a/kittens/panel/main.py b/kittens/panel/main.py index cdf9d9e1d..fe5e5c5e8 100644 --- a/kittens/panel/main.py +++ b/kittens/panel/main.py @@ -6,6 +6,7 @@ import os import shutil import subprocess import sys +from typing import List, Tuple from kitty.cli import parse_args from kitty.cli_stub import PanelCLIOptions @@ -43,19 +44,19 @@ Syntax: :italic:`name=value`. For example: :option:`kitty +kitten panel -o` font '''.format -args = None +args = PanelCLIOptions() help_text = 'Use a command line program to draw a GPU accelerated panel on your X11 desktop' usage = 'program-to-run' -def parse_panel_args(args): +def parse_panel_args(args: List[str]) -> Tuple[PanelCLIOptions, List[str]]: return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten panel', result_class=PanelCLIOptions) -def call_xprop(*cmd, silent=False): - cmd = ['xprop'] + list(cmd) +def call_xprop(*cmd: str, silent=False): + cmd_ = ['xprop'] + list(cmd) try: - cp = subprocess.run(cmd, stdout=subprocess.DEVNULL if silent else None) + cp = subprocess.run(cmd_, stdout=subprocess.DEVNULL if silent else None) except FileNotFoundError: raise SystemExit('You must have the xprop program installed') if cp.returncode != 0: @@ -94,29 +95,33 @@ def create_right_strut(win_id, width, height): create_strut(win_id, right=width, right_end_y=height) +window_width = window_height = 0 + + def setup_x11_window(win_id): call_xprop( '-id', str(win_id), '-format', '_NET_WM_WINDOW_TYPE', '32a', '-set', '_NET_WM_WINDOW_TYPE', '_NET_WM_WINDOW_TYPE_DOCK' ) func = globals()['create_{}_strut'.format(args.edge)] - func(win_id, initial_window_size_func.width, initial_window_size_func.height) + func(win_id, window_width, window_height) def initial_window_size_func(opts, *a): from kitty.fast_data_types import glfw_primary_monitor_size, set_smallest_allowed_resize def initial_window_size(cell_width, cell_height, dpi_x, dpi_y, xscale, yscale): + global window_width, window_height monitor_width, monitor_height = glfw_primary_monitor_size() if args.edge in {'top', 'bottom'}: - h = initial_window_size_func.height = cell_height * args.lines + 1 - initial_window_size_func.width = monitor_width + h = window_height = cell_height * args.lines + 1 + window_width = monitor_width set_smallest_allowed_resize(100, h) else: - w = initial_window_size_func.width = cell_width * args.columns + 1 - initial_window_size_func.height = monitor_height + w = window_width = cell_width * args.columns + 1 + window_height = monitor_height set_smallest_allowed_resize(w, 100) - return initial_window_size_func.width, initial_window_size_func.height + return window_width, window_height return initial_window_size diff --git a/kittens/resize_window/main.py b/kittens/resize_window/main.py index fbfccb0a3..e0c6b218b 100644 --- a/kittens/resize_window/main.py +++ b/kittens/resize_window/main.py @@ -4,10 +4,11 @@ import sys +from typing import Optional from kitty.cli import parse_args -from kitty.cli_stub import ResizeCLIOptions -from kitty.cmds import cmap, parse_subcommand_cli +from kitty.cli_stub import RCOptions, ResizeCLIOptions +from kitty.rc.base import parse_subcommand_cli, command_for_name from kitty.constants import version from kitty.key_encoding import CTRL, RELEASE, key_defs as K from kitty.remote_control import encode_send, parse_rc_args @@ -16,7 +17,7 @@ from ..tui.handler import Handler from ..tui.loop import Loop from ..tui.operations import styled -global_opts = None +global_opts = RCOptions() ESCAPE = K['ESCAPE'] N = K['N'] S = K['S'] @@ -26,7 +27,7 @@ W = K['W'] class Resize(Handler): - print_on_fail = None + print_on_fail: Optional[str] = None def __init__(self, opts): self.opts = opts @@ -40,7 +41,7 @@ class Resize(Handler): self.draw_screen() def do_window_resize(self, is_decrease=False, is_horizontal=True, reset=False, multiplier=1): - resize_window = cmap['resize-window'] + resize_window = command_for_name('resize_window') increment = self.opts.horizontal_increment if is_horizontal else self.opts.vertical_increment increment *= multiplier if is_decrease: @@ -48,7 +49,7 @@ class Resize(Handler): axis = 'reset' if reset else ('horizontal' if is_horizontal else 'vertical') cmdline = [resize_window.name, '--self', '--increment={}'.format(increment), '--axis=' + axis] opts, items = parse_subcommand_cli(resize_window, cmdline) - payload = resize_window(global_opts, opts, items) + payload = resize_window.message_to_kitty(global_opts, opts, items) send = {'cmd': resize_window.name, 'version': version, 'payload': payload, 'no_response': False} self.write(encode_send(send)) diff --git a/kitty/boss.py b/kitty/boss.py index d98a73bb8..6457264fd 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -1164,10 +1164,11 @@ class Boss: self.show_error(_('Errors in kitty.conf'), msg) def set_colors(self, *args): - from .cmds import parse_subcommand_cli, cmd_set_colors, set_colors - opts, items = parse_subcommand_cli(cmd_set_colors, ['set-colors'] + list(args)) - payload = cmd_set_colors(None, opts, items) - set_colors(self, self.active_window, payload) + from kitty.rc.base import parse_subcommand_cli, command_for_name + c = command_for_name('set_colors') + opts, items = parse_subcommand_cli(c, ['set-colors'] + list(args)) + payload = c.message_to_kitty(None, opts, items) + c.response_from_kitty(self, self.active_window, payload) def _move_window_to(self, window=None, target_tab_id=None, target_os_window_id=None): window = window or self.active_window diff --git a/kitty/cli_stub.py b/kitty/cli_stub.py index 15d2b32c7..fc196fca0 100644 --- a/kitty/cli_stub.py +++ b/kitty/cli_stub.py @@ -9,7 +9,7 @@ class CLIOptions: LaunchCLIOptions = AskCLIOptions = ClipboardCLIOptions = DiffCLIOptions = CLIOptions HintsCLIOptions = IcatCLIOptions = PanelCLIOptions = ResizeCLIOptions = CLIOptions -ErrorCLIOptions = UnicodeCLIOptions = CLIOptions +ErrorCLIOptions = UnicodeCLIOptions = RCOptions = CLIOptions def generate_stub() -> None: @@ -26,6 +26,9 @@ def generate_stub() -> None: from .launch import options_spec do(options_spec(), 'LaunchCLIOptions') + from .remote_control import global_options_spec + do(global_options_spec(), 'RCOptions') + from kittens.ask.main import option_text do(option_text(), 'AskCLIOptions') @@ -38,8 +41,8 @@ def generate_stub() -> None: from kittens.hints.main import OPTIONS do(OPTIONS(), 'HintsCLIOptions') - from kittens.icat.main import OPTIONS - do(OPTIONS, 'IcatCLIOptions') + from kittens.icat.main import options_spec + do(options_spec(), 'IcatCLIOptions') from kittens.panel.main import OPTIONS do(OPTIONS(), 'PanelCLIOptions') @@ -53,6 +56,12 @@ def generate_stub() -> None: from kittens.unicode_input.main import OPTIONS do(OPTIONS(), 'UnicodeCLIOptions') + from kitty.rc.base import all_command_names, command_for_name + for cmd_name in all_command_names(): + cmd = command_for_name(cmd_name) + if cmd.options_spec: + do(cmd.options_spec, cmd.__class__.__name__ + 'RCOptions') + save_type_stub(text, __file__) diff --git a/kitty/cmds.py b/kitty/cmds.py deleted file mode 100644 index ebcb3a884..000000000 --- a/kitty/cmds.py +++ /dev/null @@ -1,1465 +0,0 @@ -#!/usr/bin/env python3 -# vim:fileencoding=utf-8 -# License: GPL v3 Copyright: 2018, Kovid Goyal - -import json -import os -import sys -from contextlib import suppress -from typing import Any, BinaryIO, Callable, Dict, List, Optional - -from .cli import ( - get_defaults_from_seq, parse_args, parse_option_spec -) -from .config import parse_config, parse_send_text_bytes -from .constants import appname -from .fast_data_types import focus_os_window -from .launch import ( - launch as do_launch, options_spec as launch_options_spec, - parse_launch_args -) -from .tabs import SpecialWindow -from .utils import natsort_ints - -no_response = object() - - -class MatchError(ValueError): - - hide_traceback = True - - def __init__(self, expression, target='windows'): - ValueError.__init__(self, 'No matching {} for expression: {}'.format(target, expression)) - - -class OpacityError(ValueError): - - hide_traceback = True - - -class UnknownLayout(ValueError): - - hide_traceback = True - - -CommandFunction = Callable[[Any, Any, List[str]], Optional[Dict[str, Any]]] -cmap: Dict[str, CommandFunction] = {} - - -def cmd( - short_desc, - desc=None, - options_spec=None, - no_response=False, - argspec='...', - string_return_is_error=False, - args_count=None, - args_completion=None -): - - if options_spec: - defaults = None - - def get_defaut_value(name, missing=None): - nonlocal defaults - if defaults is None: - defaults = get_defaults_from_seq(parse_option_spec(options_spec)[0]) - return defaults.get(name, missing) - else: - def get_defaut_value(name, missing=None): - return missing - - def payload_get(payload, key, opt_name=None): - ans = payload.get(key, payload_get) - if ans is not payload_get: - return ans - return get_defaut_value(opt_name or key) - - def w(func): - func.short_desc = short_desc - func.argspec = argspec - func.desc = desc or short_desc - func.name = func.__name__[4:].replace('_', '-') - func.options_spec = options_spec - func.is_cmd = True - func.impl = lambda: globals()[func.__name__[4:]] - func.no_response = no_response - func.string_return_is_error = string_return_is_error - func.args_count = 0 if not argspec else args_count - func.get_default = get_defaut_value - func.payload_get = payload_get - func.args_completion = args_completion - cmap[func.name] = func - return func - return w - - -MATCH_WINDOW_OPTION = '''\ ---match -m -The window to match. Match specifications are of the form: -:italic:`field:regexp`. Where field can be one of: id, title, pid, cwd, cmdline, num, env. -You can use the :italic:`ls` command to get a list of windows. Note that for -numeric fields such as id, pid and num the expression is interpreted as a number, -not a regular expression. The field num refers to the window position in the current tab, -starting from zero and counting clockwise (this is the same as the order in which the -windows are reported by the :italic:`ls` command). The window id of the current window -is available as the KITTY_WINDOW_ID environment variable. When using the :italic:`env` field -to match on environment variables you can specify only the environment variable name or a name -and value, for example, :italic:`env:MY_ENV_VAR=2` -''' -MATCH_TAB_OPTION = '''\ ---match -m -The tab to match. Match specifications are of the form: -:italic:`field:regexp`. Where field can be one of: id, title, pid, cwd, env, cmdline. -You can use the :italic:`ls` command to get a list of tabs. Note that for -numeric fields such as id and pid the expression is interpreted as a number, -not a regular expression. When using title or id, first a matching tab is -looked for and if not found a matching window is looked for, and the tab -for that window is used. -''' - - -def windows_for_payload(boss, window, payload): - if payload.get('all'): - windows = tuple(boss.all_windows) - else: - windows = (window or boss.active_window,) - if payload.get('match_window'): - windows = tuple(boss.match_windows(payload['match_window'])) - if not windows: - raise MatchError(payload['match_window']) - if payload.get('match_tab'): - tabs = tuple(boss.match_tabs(payload['match_tab'])) - if not tabs: - raise MatchError(payload['match_tab'], 'tabs') - for tab in tabs: - windows += tuple(tab) - return windows - - -# ls {{{ -@cmd( - 'List all tabs/windows', - 'List all windows. The list is returned as JSON tree. The top-level is a list of' - ' operating system {appname} windows. Each OS window has an :italic:`id` and a list' - ' of :italic:`tabs`. Each tab has its own :italic:`id`, a :italic:`title` and a list of :italic:`windows`.' - ' Each window has an :italic:`id`, :italic:`title`, :italic:`current working directory`, :italic:`process id (PID)`, ' - ' :italic:`command-line` and :italic:`environment` of the process running in the window.\n\n' - 'You can use these criteria to select windows/tabs for the other commands.'.format(appname=appname), - argspec='' -) -def cmd_ls(global_opts, opts, args): - ''' - No payload - ''' - pass - - -def ls(boss, window): - data = list(boss.list_os_windows()) - data = json.dumps(data, indent=2, sort_keys=True) - return data -# }}} - - -# set_font_size {{{ -@cmd( - 'Set the font size in the active top-level OS window', - 'Sets the font size to the specified size, in pts. Note' - ' that in kitty all sub-windows in the same OS window' - ' must have the same font size. A value of zero' - ' resets the font size to default. Prefixing the value' - ' with a + or - increments the font size by the specified' - ' amount.', - argspec='FONT_SIZE', args_count=1, options_spec='''\ ---all -a -type=bool-set -By default, the font size is only changed in the active OS window, -this option will cause it to be changed in all OS windows. -''') -def cmd_set_font_size(global_opts, opts, args): - ''' - size+: The new font size in pts (a positive number) - all: Boolean whether to change font size in the current window or all windows - increment_op: The string ``+`` or ``-`` to interpret size as an increment - ''' - if not args: - raise SystemExit('No font size specified') - fs = args[0] - inc = fs[0] if fs and fs[0] in '+-' else None - return {'size': abs(float(fs)), 'all': opts.all, 'increment_op': inc} - - -def set_font_size(boss, window, payload): - boss.change_font_size( - cmd_set_font_size.payload_get(payload, 'all'), - payload.get('increment_op', None), payload['size']) -# }}} - - -# send_text {{{ -@cmd( - 'Send arbitrary text to specified windows', - 'Send arbitrary text to specified windows. The text follows Python' - ' escaping rules. So you can use escapes like :italic:`\\x1b` to send control codes' - ' and :italic:`\\u21fa` to send unicode characters. If you use the :option:`kitty @ send-text --match` option' - ' the text will be sent to all matched windows. By default, text is sent to' - ' only the currently active window.', - options_spec=MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--match-tab -t') + '''\n ---stdin -type=bool-set -Read the text to be sent from :italic:`stdin`. Note that in this case the text is sent as is, -not interpreted for escapes. If stdin is a terminal, you can press Ctrl-D to end reading. - - ---from-file -Path to a file whose contents you wish to send. Note that in this case the file contents -are sent as is, not interpreted for escapes. -''', - no_response=True, - argspec='[TEXT TO SEND]' -) -def cmd_send_text(global_opts, opts, args): - ''' - text+: The text being sent - is_binary+: If False text is interpreted as a python string literal instead of plain text - match: A string indicating the window to send text to - match_tab: A string indicating the tab to send text to - ''' - limit = 1024 - ret = {'match': opts.match, 'is_binary': False, 'match_tab': opts.match_tab} - - def pipe(): - ret['is_binary'] = True - if sys.stdin.isatty(): - import select - fd = sys.stdin.fileno() - keep_going = True - while keep_going: - rd = select.select([fd], [], [])[0] - if not rd: - break - data = os.read(fd, limit) - if not data: - break # eof - data = data.decode('utf-8') - if '\x04' in data: - data = data[:data.index('\x04')] - keep_going = False - ret['text'] = data - yield ret - else: - while True: - data = sys.stdin.read(limit) - if not data: - break - ret['text'] = data[:limit] - yield ret - - def chunks(text): - ret['is_binary'] = False - while text: - ret['text'] = text[:limit] - yield ret - text = text[limit:] - - def file_pipe(path): - ret['is_binary'] = True - with open(path, encoding='utf-8') as f: - while True: - data = f.read(limit) - if not data: - break - ret['text'] = data - yield ret - - sources = [] - if opts.stdin: - sources.append(pipe()) - - if opts.from_file: - sources.append(file_pipe(opts.from_file)) - - text = ' '.join(args) - sources.append(chunks(text)) - - def chain(): - for src in sources: - yield from src - return chain() - - -def send_text(boss, window, payload): - windows = [boss.active_window] - pg = cmd_send_text.payload_get - match = pg(payload, 'match') - if match: - windows = tuple(boss.match_windows(match)) - mt = pg(payload, 'match_tab') - if mt: - windows = [] - tabs = tuple(boss.match_tabs(mt)) - if not tabs: - raise MatchError(payload['match_tab'], 'tabs') - for tab in tabs: - windows += tuple(tab) - data = payload['text'].encode('utf-8') if payload['is_binary'] else parse_send_text_bytes(payload['text']) - for window in windows: - if window is not None: - window.write_to_child(data) -# }}} - - -# set_window_title {{{ -@cmd( - 'Set the window title', - 'Set the title for the specified window(s). If you use the :option:`kitty @ set-window-title --match` option' - ' the title will be set for all matched windows. By default, only the window' - ' in which the command is run is affected. If you do not specify a title, the' - ' last title set by the child process running in the window will be used.', - options_spec=''' ---temporary -type=bool-set -By default, if you use :italic:`set-window-title` the title will be permanently changed -and programs running in the window will not be able to change it again. If you -want to allow other programs to change it afterwards, use this option. - ''' + '\n\n' + MATCH_WINDOW_OPTION, - argspec='TITLE ...' -) -def cmd_set_window_title(global_opts, opts, args): - ''' - title+: The new title - match: Which windows to change the title in - temporary: Boolean indicating if the change is temporary or permanent - ''' - return {'title': ' '.join(args), 'match': opts.match, 'temporary': opts.temporary} - - -def set_window_title(boss, window, payload): - windows = [window or boss.active_window] - pg = cmd_set_window_title.payload_get - match = pg(payload, 'match') - if match: - windows = tuple(boss.match_windows(match)) - if not windows: - raise MatchError(match) - for window in windows: - if window: - if pg(payload, 'temporary'): - window.override_title = None - window.title_changed(payload['title']) - else: - window.set_title(payload['title']) - -# }}} - - -# set_tab_title {{{ -@cmd( - 'Set the tab title', - 'Set the title for the specified tab(s). If you use the :option:`kitty @ set-tab-title --match` option' - ' the title will be set for all matched tabs. By default, only the tab' - ' in which the command is run is affected. If you do not specify a title, the' - ' title of the currently active window in the tab is used.', - options_spec=MATCH_TAB_OPTION, - argspec='TITLE ...' -) -def cmd_set_tab_title(global_opts, opts, args): - ''' - title+: The new title - match: Which tab to change the title of - ''' - return {'title': ' '.join(args), 'match': opts.match} - - -def set_tab_title(boss, window, payload): - pg = cmd_set_tab_title.payload_get - match = pg(payload, 'match') - if match: - tabs = tuple(boss.match_tabs(match)) - if not tabs: - raise MatchError(match, 'tabs') - else: - tabs = [boss.tab_for_window(window) if window else boss.active_tab] - for tab in tabs: - if tab: - tab.set_title(payload['title']) -# }}} - - -# create_marker {{{ -@cmd( - 'Create a marker that highlights specified text', - 'Create a marker which can highlight text in the specified window. For example: ' - 'create_marker text 1 ERROR. For full details see: https://sw.kovidgoyal.net/kitty/marks.html', - options_spec=MATCH_WINDOW_OPTION + '''\n ---self -type=bool-set -If specified apply marker to the window this command is run in, rather than the active window. -''', - argspec='MARKER SPECIFICATION' -) -def cmd_create_marker(global_opts, opts, args): - ''' - match: Which window to detach - self: Boolean indicating whether to detach the window the command is run in - marker_spec: A list or arguments that define the marker specification, for example: ['text', '1', 'ERROR'] - ''' - from .config import parse_marker_spec - if len(args) < 2: - raise ValueError('Invalid marker specification: {}'.format(' '.join(args))) - parse_marker_spec(args[0], args[1:]) - return {'match': opts.match, 'self': opts.self, 'marker_spec': args} - - -def create_marker(boss, window, payload): - pg = cmd_create_marker.payload_get - match = pg(payload, 'match') - if match: - windows = tuple(boss.match_windows(match)) - if not windows: - raise MatchError(match) - else: - windows = [window if window and pg(payload, 'self') else boss.active_window] - args = pg(payload, 'marker_spec') - - for window in windows: - window.set_marker(args) - -# }}} - - -# remove_marker {{{ -@cmd( - 'Remove the currently set marker, if any.', - options_spec=MATCH_WINDOW_OPTION + '''\n ---self -type=bool-set -If specified apply marker to the window this command is run in, rather than the active window. -''', - argspec='' -) -def cmd_remove_marker(global_opts, opts, args): - ''' - match: Which window to detach - self: Boolean indicating whether to detach the window the command is run in - ''' - return {'match': opts.match, 'self': opts.self} - - -def remove_marker(boss, window, payload): - pg = cmd_create_marker.payload_get - match = pg(payload, 'match') - if match: - windows = tuple(boss.match_windows(match)) - if not windows: - raise MatchError(match) - else: - windows = [window if window and pg(payload, 'self') else boss.active_window] - - for window in windows: - window.remove_marker() - -# }}} - - -# detach_window {{{ -@cmd( - 'Detach a window and place it in a different/new tab', - 'Detach the specified window and either move it into a new tab, a new OS window' - ' or add it to the specified tab. Use the special value :code:`new` for --target-tab' - ' to move to a new tab. If no target tab is specified the window is moved to a new OS window.', - options_spec=MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--target-tab -t') + '''\n ---self -type=bool-set -If specified detach the window this command is run in, rather than the active window. -''', - argspec='' -) -def cmd_detach_window(global_opts, opts, args): - ''' - match: Which window to detach - target: Which tab to move the detached window to - self: Boolean indicating whether to detach the window the command is run in - ''' - return {'match': opts.match, 'target': opts.target_tab, 'self': opts.self} - - -def detach_window(boss, window, payload): - pg = cmd_detach_window.payload_get - match = pg(payload, 'match') - if match: - windows = tuple(boss.match_windows(match)) - if not windows: - raise MatchError(match) - else: - windows = [window if window and pg(payload, 'self') else boss.active_window] - match = pg(payload, 'target_tab') - kwargs = {} - if match: - if match == 'new': - kwargs['target_tab_id'] = 'new' - else: - tabs = tuple(boss.match_tabs(match)) - if not tabs: - raise MatchError(match, 'tabs') - kwargs['target_tab_id'] = tabs[0].id - if not kwargs: - kwargs['target_os_window_id'] = 'new' - - for window in windows: - boss._move_window_to(window=window, **kwargs) - -# }}} - - -# detach_tab {{{ -@cmd( - 'Detach a tab and place it in a different/new OS Window', - 'Detach the specified tab and either move it into a new OS window' - ' or add it to the OS Window containing the tab specified by --target-tab', - options_spec=MATCH_TAB_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--target-tab -t') + '''\n ---self -type=bool-set -If specified detach the tab this command is run in, rather than the active tab. -''', - argspec='' -) -def cmd_detach_tab(global_opts, opts, args): - ''' - match: Which tab to detach - target: Which OS Window to move the detached tab to - self: Boolean indicating whether to detach the tab the command is run in - ''' - return {'match': opts.match, 'target': opts.target_tab, 'self': opts.self} - - -def detach_tab(boss, window, payload): - pg = cmd_detach_tab.payload_get - match = pg(payload, 'match') - if match: - tabs = tuple(boss.match_tabs(match)) - if not tabs: - raise MatchError(match) - else: - tabs = [window.tabref() if pg(payload, 'self') and window and window.tabref() else boss.active_tab] - match = pg(payload, 'target_tab') - kwargs = {} - if match: - targets = tuple(boss.match_tabs(match)) - if not targets: - raise MatchError(match, 'tabs') - kwargs['target_os_window_id'] = targets[0].os_window_id - - for tab in tabs: - boss._move_tab_to(tab=tab, **kwargs) - -# }}} - - -# goto_layout {{{ -@cmd( - 'Set the window layout', - 'Set the window layout in the specified tab (or the active tab if not specified).' - ' You can use special match value :italic:`all` to set the layout in all tabs.', - options_spec=MATCH_TAB_OPTION, - argspec='LAYOUT_NAME' -) -def cmd_goto_layout(global_opts, opts, args): - ''' - layout+: The new layout name - match: Which tab to change the layout of - ''' - try: - return {'layout': args[0], 'match': opts.match} - except IndexError: - raise SystemExit('No layout specified') - - -def goto_layout(boss, window, payload): - pg = cmd_goto_layout.payload_get - match = pg(payload, 'match') - if match: - if match == 'all': - tabs = tuple(boss.all_tabs) - else: - tabs = tuple(boss.match_tabs(match)) - if not tabs: - raise MatchError(match, 'tabs') - else: - tabs = [boss.tab_for_window(window) if window else boss.active_tab] - for tab in tabs: - if tab: - try: - tab.goto_layout(payload['layout'], raise_exception=True) - except ValueError: - raise UnknownLayout('The layout {} is unknown or disabled'.format(payload['layout'])) -# }}} - - -# last_used_layout {{{ -@cmd( - 'Switch to the last used layout', - 'Switch to the last used window layout in the specified tab (or the active tab if not specified).' - ' You can use special match value :italic:`all` to set the layout in all tabs.', - options_spec=MATCH_TAB_OPTION, -) -def cmd_last_used_layout(global_opts, opts, args): - ''' - match: Which tab to change the layout of - ''' - return {'match': opts.match} - - -def last_used_layout(boss, window, payload): - pg = cmd_last_used_layout.payload_get - match = pg(payload, 'match') - if match: - if match == 'all': - tabs = tuple(boss.all_tabs) - else: - tabs = tuple(boss.match_tabs(match)) - if not tabs: - raise MatchError(match, 'tabs') - else: - tabs = [boss.tab_for_window(window) if window else boss.active_tab] - for tab in tabs: - if tab: - tab.last_used_layout() -# }}} - - -# close_window {{{ -@cmd( - 'Close the specified window(s)', - options_spec=MATCH_WINDOW_OPTION + '''\n ---self -type=bool-set -If specified close the window this command is run in, rather than the active window. -''', - argspec='' -) -def cmd_close_window(global_opts, opts, args): - ''' - match: Which window to close - self: Boolean indicating whether to close the window the command is run in - ''' - return {'match': opts.match, 'self': opts.self} - - -def close_window(boss, window, payload): - pg = cmd_close_window.payload_get - match = pg(payload, 'match') - if match: - windows = tuple(boss.match_windows(match)) - if not windows: - raise MatchError(match) - else: - windows = [window if window and pg(payload, 'self') else boss.active_window] - for window in windows: - if window: - boss.close_window(window) -# }}} - - -# resize_window {{{ -@cmd( - 'Resize the specified window', - 'Resize the specified window in the current layout. Note that not all layouts can resize all windows in all directions.', - options_spec=MATCH_WINDOW_OPTION + '''\n ---increment -i -type=int -default=2 -The number of cells to change the size by, can be negative to decrease the size. - - ---axis -a -type=choices -choices=horizontal,vertical,reset -default=horizontal -The axis along which to resize. If :italic:`horizontal`, it will make the window wider or narrower by the specified increment. -If :italic:`vertical`, it will make the window taller or shorter by the specified increment. The special value :italic:`reset` will -reset the layout to its default configuration. - - ---self -type=bool-set -If specified resize the window this command is run in, rather than the active window. -''', - argspec='', - string_return_is_error=True -) -def cmd_resize_window(global_opts, opts, args): - ''' - match: Which window to resize - self: Boolean indicating whether to close the window the command is run in - increment: Integer specifying the resize increment - axis: One of :code:`horizontal, vertical` or :code:`reset` - ''' - return {'match': opts.match, 'increment': opts.increment, 'axis': opts.axis, 'self': opts.self} - - -def resize_window(boss, window, payload): - pg = cmd_resize_window.payload_get - match = pg(payload, 'match') - if match: - windows = tuple(boss.match_windows(match)) - if not windows: - raise MatchError(match) - else: - windows = [window if window and pg(payload, 'self') else boss.active_window] - resized = False - if windows and windows[0]: - resized = boss.resize_layout_window( - windows[0], increment=pg(payload, 'increment'), is_horizontal=pg(payload, 'axis') == 'horizontal', - reset=pg(payload, 'axis') == 'reset' - ) - return resized -# }}} - - -# close_tab {{{ -@cmd( - 'Close the specified tab(s)', - options_spec=MATCH_TAB_OPTION + '''\n ---self -type=bool-set -If specified close the tab this command is run in, rather than the active tab. -''', - argspec='' -) -def cmd_close_tab(global_opts, opts, args): - ''' - match: Which tab to close - self: Boolean indicating whether to close the window the command is run in - ''' - return {'match': opts.match, 'self': opts.self} - - -def close_tab(boss, window, payload): - pg = cmd_close_tab.payload_get - match = pg(payload, 'match') - if match: - tabs = tuple(boss.match_tabs(match)) - if not tabs: - raise MatchError(match, 'tabs') - else: - tabs = [boss.tab_for_window(window) if window and pg(payload, 'self') else boss.active_tab] - for tab in tabs: - if window: - if tab: - boss.close_tab(tab) -# }}} - - -# new_window {{{ -@cmd( - 'Open new window', - 'Open a new window in the specified tab. If you use the :option:`kitty @ new-window --match` option' - ' the first matching tab is used. Otherwise the currently active tab is used.' - ' Prints out the id of the newly opened window (unless :option:`--no-response` is used). Any command line arguments' - ' are assumed to be the command line used to run in the new window, if none' - ' are provided, the default shell is run. For example:\n' - ':italic:`kitty @ new-window --title Email mutt`', - options_spec=MATCH_TAB_OPTION + '''\n ---title -The title for the new window. By default it will use the title set by the -program running in it. - - ---cwd -The initial working directory for the new window. Defaults to whatever -the working directory for the kitty process you are talking to is. - - ---keep-focus -type=bool-set -Keep the current window focused instead of switching to the newly opened window - - ---window-type -default=kitty -choices=kitty,os -What kind of window to open. A kitty window or a top-level OS window. - - ---new-tab -type=bool-set -Open a new tab - - ---tab-title -When using --new-tab set the title of the tab. - - ---no-response -type=bool-set -default=false -Don't wait for a response giving the id of the newly opened window. Note that -using this option means that you will not be notified of failures and that -the id of the new window will not be printed out. -''', - argspec='[CMD ...]' -) -def cmd_new_window(global_opts, opts, args): - ''' - args+: The command line to run in the new window, as a list, use an empty list to run the default shell - match: The tab to open the new window in - title: Title for the new window - cwd: Working directory for the new window - tab_title: Title for the new tab - window_type: One of :code:`kitty` or :code:`os` - keep_focus: Boolean indicating whether the current window should retain focus or not - ''' - if opts.no_response: - global_opts.no_command_response = True - return {'match': opts.match, 'title': opts.title, 'cwd': opts.cwd, - 'new_tab': opts.new_tab, 'tab_title': opts.tab_title, - 'window_type': opts.window_type, 'no_response': opts.no_response, - 'keep_focus': opts.keep_focus, 'args': args or []} - - -def new_window(boss, window, payload): - pg = cmd_new_window.payload_get - w = SpecialWindow(cmd=payload['args'] or None, override_title=pg(payload, 'title'), cwd=pg(payload, 'cwd')) - old_window = boss.active_window - if pg(payload, 'new_tab'): - boss._new_tab(w) - tab = boss.active_tab - if pg(payload, 'tab_title'): - tab.set_title(pg(payload, 'tab_title')) - wid = boss.active_window.id - if pg(payload, 'keep_focus') and old_window: - boss.set_active_window(old_window) - return None if pg(payload, 'no_response') else str(wid) - - if pg(payload, 'window_type') == 'os': - boss._new_os_window(w) - wid = boss.active_window.id - if pg(payload, 'keep_focus') and old_window: - os_window_id = boss.set_active_window(old_window) - if os_window_id: - focus_os_window(os_window_id) - return None if pg(payload, 'no_response') else str(wid) - - match = pg(payload, 'match') - if match: - tabs = tuple(boss.match_tabs(match)) - if not tabs: - raise MatchError(match, 'tabs') - else: - tabs = [boss.active_tab] - tab = tabs[0] - w = tab.new_special_window(w) - if pg(payload, 'keep_focus') and old_window: - boss.set_active_window(old_window) - return None if pg(payload, 'no_response') else str(w.id) -# }}} - - -# launch {{{ -@cmd( - 'Run an arbitrary process in a new window/tab', - ' Prints out the id of the newly opened window. Any command line arguments' - ' are assumed to be the command line used to run in the new window, if none' - ' are provided, the default shell is run. For example:' - ' :italic:`kitty @ launch --title Email mutt`.', - options_spec=MATCH_TAB_OPTION + '\n\n' + '''\ ---no-response -type=bool-set -Do not print out the id of the newly created window. - - ---self -type=bool-set -If specified the tab containing the window this command is run in is used -instead of the active tab - ''' + '\n\n' + launch_options_spec().replace(':option:`launch', ':option:`kitty @ launch'), - argspec='[CMD ...]' -) -def cmd_launch(global_opts, opts, args): - ''' - args+: The command line to run in the new window, as a list, use an empty list to run the default shell - match: The tab to open the new window in - window_title: Title for the new window - cwd: Working directory for the new window - env: List of environment variables of the form NAME=VALUE - tab_title: Title for the new tab - type: The type of window to open - keep_focus: Boolean indicating whether the current window should retain focus or not - copy_colors: Boolean indicating whether to copy the colors from the current window - copy_cmdline: Boolean indicating whether to copy the cmdline from the current window - copy_env: Boolean indicating whether to copy the environ from the current window - location: Where in the tab to open the new window - allow_remote_control: Boolean indicating whether to allow remote control from the new window - stdin_source: Where to get stdin for thew process from - stdin_add_formatting: Boolean indicating whether to add formatting codes to stdin - stdin_add_line_wrap_markers: Boolean indicating whether to add line wrap markers to stdin - no_response: Boolean indicating whether to send back the window id - marker: Specification for marker for new window, for example: "text 1 ERROR" - ''' - if opts.no_response: - global_opts.no_command_response = True - ans = {'args': args or []} - for attr, val in opts.__dict__.items(): - ans[attr] = val - return ans - - -class GlobalOpts: - pass - - -def launch(boss, window, payload): - pg = cmd_launch.payload_get - default_opts = parse_launch_args()[0] - opts = GlobalOpts() - for key, default_value in default_opts.__dict__.items(): - setattr(opts, key, payload.get(key, default_value)) - match = pg(payload, 'match') - if match: - tabs = tuple(boss.match_tabs(match)) - if not tabs: - raise MatchError(match, 'tabs') - else: - tabs = [boss.active_tab] - if pg(payload, 'self') and window and window.tabref(): - tabs = [window.tabref()] - tab = tabs[0] - w = do_launch(boss, opts, pg(payload, 'args') or None, target_tab=tab) - return None if pg(payload, 'no_response') else str(getattr(w, 'id', 0)) -# }}} - - -# focus_window {{{ -@cmd( - 'Focus the specified window', - 'Focus the specified window, if no window is specified, focus the window this command is run inside.', - argspec='', - options_spec=MATCH_WINDOW_OPTION + '''\n\n ---no-response -type=bool-set -default=false -Don't wait for a response from kitty. This means that even if no matching window is found, -the command will exit with a success code. -''' -) -def cmd_focus_window(global_opts, opts, args): - ''' - match: The window to focus - ''' - if opts.no_response: - global_opts.no_command_response = True - return {'match': opts.match, 'no_response': opts.no_response} - - -def focus_window(boss, window, payload): - pg = cmd_focus_window.payload_get - windows = [window or boss.active_window] - match = pg(payload, 'match') - if match: - windows = tuple(boss.match_windows(match)) - if not windows: - raise MatchError(match) - for window in windows: - if window: - os_window_id = boss.set_active_window(window) - if os_window_id: - focus_os_window(os_window_id, True) - break -# }}} - - -# scroll_window {{{ -@cmd( - 'Scroll the specified window', - 'Scroll the specified window, if no window is specified, scroll the window this command is run inside.' - ' SCROLL_AMOUNT can be either the keywords :code:`start` or :code:`end` or an' - ' argument of the form [unit][+-]. For example, 30 will scroll down 30 lines and 2p- will' - ' scroll up 2 pages.', - argspec='SCROLL_AMOUNT', - options_spec=MATCH_WINDOW_OPTION -) -def cmd_scroll_window(global_opts, opts, args): - ''' - amount+: The amount to scroll, a two item list with the first item being \ - either a number or the keywords, start and end. \ - And the second item being either 'p' for pages or 'l' for lines. - match: The window to scroll - ''' - amt = args[0] - ans = {'match': opts.match} - if amt in ('start', 'end'): - ans['amount'] = amt, None - else: - pages = 'p' in amt - amt = amt.replace('p', '') - mult = -1 if amt.endswith('-') else 1 - amt = int(amt.replace('-', '')) - ans['amount'] = [amt * mult, 'p' if pages else 'l'] - return ans - - -def scroll_window(boss, window, payload): - pg = cmd_scroll_window.payload_get - windows = [window or boss.active_window] - match = pg(payload, 'match') - amt = pg(payload, 'amount') - if match: - windows = tuple(boss.match_windows(match)) - if not windows: - raise MatchError(match) - for window in windows: - if window: - if amt[0] in ('start', 'end'): - getattr(window, {'start': 'scroll_home'}.get(amt[0], 'scroll_end'))() - else: - amt, unit = amt - unit = 'page' if unit == 'p' else 'line' - direction = 'up' if amt < 0 else 'down' - func = getattr(window, 'scroll_{}_{}'.format(unit, direction)) - for i in range(abs(amt)): - func() -# }}} - - -# focus_tab {{{ -@cmd( - 'Focus the specified tab', - 'The active window in the specified tab will be focused.', - options_spec=MATCH_TAB_OPTION + ''' - ---no-response -type=bool-set -default=false -Don't wait for a response indicating the success of the action. Note that -using this option means that you will not be notified of failures. -''', - argspec='', -) -def cmd_focus_tab(global_opts, opts, args): - ''' - match: The tab to focus - ''' - if opts.no_response: - global_opts.no_command_response = True - return {'match': opts.match} - - -def focus_tab(boss, window, payload): - pg = cmd_focus_tab.payload_get - match = pg(payload, 'match') - tabs = tuple(boss.match_tabs(match)) - if not tabs: - raise MatchError(match, 'tabs') - tab = tabs[0] - boss.set_active_tab(tab) -# }}} - - -# get_text {{{ -@cmd( - 'Get text from the specified window', - options_spec=MATCH_WINDOW_OPTION + '''\n ---extent -default=screen -choices=screen, all, selection -What text to get. The default of screen means all text currently on the screen. all means -all the screen+scrollback and selection means currently selected text. - - ---ansi -type=bool-set -By default, only plain text is returned. If you specify this flag, the text will -include the formatting escape codes for colors/bold/italic/etc. Note that when -getting the current selection, the result is always plain text. - - ---self -type=bool-set -If specified get text from the window this command is run in, rather than the active window. -''', - argspec='' -) -def cmd_get_text(global_opts, opts, args): - ''' - match: The tab to focus - extent: One of :code:`screen`, :code:`all`, or :code:`selection` - ansi: Boolean, if True send ANSI formatting codes - self: Boolean, if True use window command was run in - ''' - return {'match': opts.match, 'extent': opts.extent, 'ansi': opts.ansi, 'self': opts.self} - - -def get_text(boss, window, payload): - pg = cmd_get_text.payload_get - match = pg(payload, 'match') - if match: - windows = tuple(boss.match_windows(match)) - if not windows: - raise MatchError(match) - else: - windows = [window if window and pg(payload, 'self') else boss.active_window] - window = windows[0] - if pg(payload, 'extent') == 'selection': - ans = window.text_for_selection() - else: - ans = window.as_text(as_ansi=bool(pg(payload, 'ansi')), add_history=pg(payload, 'extent') == 'all') - return ans -# }}} - - -# set_colors {{{ -@cmd( - 'Set terminal colors', - 'Set the terminal colors for the specified windows/tabs (defaults to active window). You can either specify the path to a conf file' - ' (in the same format as kitty.conf) to read the colors from or you can specify individual colors,' - ' for example: kitty @ set-colors foreground=red background=white', - options_spec='''\ ---all -a -type=bool-set -By default, colors are only changed for the currently active window. This option will -cause colors to be changed in all windows. - - ---configured -c -type=bool-set -Also change the configured colors (i.e. the colors kitty will use for new -windows or after a reset). - - ---reset -type=bool-set -Restore all colors to the values they had at kitty startup. Note that if you specify -this option, any color arguments are ignored and --configured and --all are implied. -''' + '\n\n' + MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--match-tab -t'), - argspec='COLOR_OR_FILE ...' -) -def cmd_set_colors(global_opts, opts, args): - ''' - colors+: An object mapping names to colors as 24-bit RGB integers - cursor_text_color: A 24-bit color for text under the cursor - match_window: Window to change colors in - match_tab: Tab to change colors in - all: Boolean indicating change colors everywhere or not - configured: Boolean indicating whether to change the configured colors. Must be True if reset is True - reset: Boolean indicating colors should be reset to startup values - ''' - from .rgb import color_as_int, Color - colors, cursor_text_color = {}, False - if not opts.reset: - for spec in args: - if '=' in spec: - colors.update(parse_config((spec.replace('=', ' '),))) - else: - with open(os.path.expanduser(spec), encoding='utf-8', errors='replace') as f: - colors.update(parse_config(f)) - cursor_text_color = colors.pop('cursor_text_color', False) - colors = {k: color_as_int(v) for k, v in colors.items() if isinstance(v, Color)} - return { - 'match_window': opts.match, 'match_tab': opts.match_tab, - 'all': opts.all or opts.reset, 'configured': opts.configured or opts.reset, - 'colors': colors, 'reset': opts.reset, 'cursor_text_color': cursor_text_color - } - - -def set_colors(boss, window, payload): - pg = cmd_set_colors.payload_get - from .rgb import color_as_int, Color - windows = windows_for_payload(boss, window, payload) - if pg(payload, 'reset'): - payload['colors'] = {k: color_as_int(v) for k, v in boss.startup_colors.items()} - payload['cursor_text_color'] = boss.startup_cursor_text_color - profiles = tuple(w.screen.color_profile for w in windows) - from .fast_data_types import patch_color_profiles - cursor_text_color = payload.get('cursor_text_color', False) - if isinstance(cursor_text_color, (tuple, list, Color)): - cursor_text_color = color_as_int(Color(*cursor_text_color)) - patch_color_profiles(payload['colors'], cursor_text_color, profiles, pg(payload, 'configured')) - boss.patch_colors(payload['colors'], cursor_text_color, pg(payload, 'configured')) - default_bg_changed = 'background' in payload['colors'] - for w in windows: - if default_bg_changed: - boss.default_bg_changed_for(w.id) - w.refresh() -# }}} - - -# get_colors {{{ -@cmd( - 'Get terminal colors', - 'Get the terminal colors for the specified window (defaults to active window). Colors will be output to stdout in the same syntax as used for kitty.conf', - options_spec='''\ ---configured -c -type=bool-set -Instead of outputting the colors for the specified window, output the currently -configured colors. - -''' + '\n\n' + MATCH_WINDOW_OPTION -) -def cmd_get_colors(global_opts, opts, args): - ''' - match: The window to get the colors for - configured: Boolean indicating whether to get configured or current colors - ''' - return {'configured': opts.configured, 'match': opts.match} - - -def get_colors(boss, window, payload): - from .rgb import Color, color_as_sharp, color_from_int - pg = cmd_get_colors.payload_get - ans = {k: getattr(boss.opts, k) for k in boss.opts if isinstance(getattr(boss.opts, k), Color)} - if not pg(payload, 'configured'): - windows = (window or boss.active_window,) - if pg(payload, 'match'): - windows = tuple(boss.match_windows(pg(payload, 'match'))) - if not windows: - raise MatchError(pg(payload, 'match')) - ans.update({k: color_from_int(v) for k, v in windows[0].current_colors.items()}) - all_keys = natsort_ints(ans) - maxlen = max(map(len, all_keys)) - return '\n'.join(('{:%ds} {}' % maxlen).format(key, color_as_sharp(ans[key])) for key in all_keys) -# }}} - - -# set_background_opacity {{{ -@cmd( - 'Set the background_opacity', - 'Set the background opacity for the specified windows. This will only work if you have turned on' - ' :opt:`dynamic_background_opacity` in :file:`kitty.conf`. The background opacity affects all kitty windows in a' - ' single os_window. For example: kitty @ set-background-opacity 0.5', - options_spec='''\ ---all -a -type=bool-set -By default, colors are only changed for the currently active window. This option will -cause colors to be changed in all windows. - -''' + '\n\n' + MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--match-tab -t'), - argspec='OPACITY', - args_count=1 -) -def cmd_set_background_opacity(global_opts, opts, args): - ''' - opacity+: A number between 0.1 and 1 - match_window: Window to change opacity in - match_tab: Tab to change opacity in - all: Boolean indicating operate on all windows - ''' - opacity = max(0.1, min(float(args[0]), 1.0)) - return { - 'opacity': opacity, 'match_window': opts.match, - 'all': opts.all, 'match_tab': opts.match_tab - } - - -def set_background_opacity(boss, window, payload): - if not boss.opts.dynamic_background_opacity: - raise OpacityError('You must turn on the dynamic_background_opacity option in kitty.conf to be able to set background opacity') - windows = windows_for_payload(boss, window, payload) - for os_window_id in {w.os_window_id for w in windows}: - boss._set_os_window_background_opacity(os_window_id, payload['opacity']) -# }}} - - -# set_background_image {{{ -@cmd( - 'Set the background_image', - 'Set the background image for the specified OS windows. You must specify the path to a PNG image that' - ' will be used as the background. If you specify the special value "none" then any existing image will' - ' be removed.', - options_spec='''\ ---all -a -type=bool-set -By default, background image is only changed for the currently active OS window. This option will -cause the image to be changed in all windows. - - ---configured -c -type=bool-set -Change the configured background image which is used for new OS windows. - - ---layout -type=choices -choices=tiled,scaled,mirror-tiled,configured -How the image should be displayed. The value of configured will use the configured value. - - -''' + '\n\n' + MATCH_WINDOW_OPTION, - argspec='PATH_TO_PNG_IMAGE', - args_count=1, - args_completion={'files': ('PNG Images', ('*.png',))} -) -def cmd_set_background_image(global_opts, opts, args): - ''' - data+: Chunk of at most 512 bytes of PNG data, base64 encoded. Must send an empty chunk to indicate end of image. \ - Or the special value - to indicate image must be removed. - img_id+: Unique uuid (as string) used for chunking - match: Window to change opacity in - layout: The image layout - all: Boolean indicating operate on all windows - configured: Boolean indicating if the configured value should be changed - ''' - import imghdr - from uuid import uuid4 - from base64 import standard_b64encode - if not args: - raise SystemExit('Must specify path to PNG image') - path = args[0] - ret = {'match': opts.match, 'configured': opts.configured, 'layout': opts.layout, 'all': opts.all, 'img_id': str(uuid4())} - if path.lower() == 'none': - ret['data'] = '-' - return ret - if imghdr.what(path) != 'png': - raise SystemExit('{} is not a PNG image'.format(path)) - - def file_pipe(path): - with open(path, 'rb') as f: - while True: - data = f.read(512) - if not data: - break - ret['data'] = standard_b64encode(data).decode('ascii') - yield ret - ret['data'] = '' - yield ret - return file_pipe(path) - - -class SetBackgroundImage: - - current_img_id: Optional[str] = None - current_file_obj: Optional[BinaryIO] = None - - def __call__(self, boss, window, payload): - from base64 import standard_b64decode - import tempfile - pg = cmd_set_background_image.payload_get - data = pg(payload, 'data') - if data != '-': - img_id = pg(payload, 'img_id') - if img_id != set_background_image.current_img_id: - set_background_image.current_img_id = img_id - set_background_image.current_file_obj = tempfile.NamedTemporaryFile() - if data: - set_background_image.current_file_obj.write(standard_b64decode(data)) - return no_response - - windows = windows_for_payload(boss, window, payload) - os_windows = tuple({w.os_window_id for w in windows}) - layout = pg(payload, 'layout') - if data == '-': - path = None - else: - f = set_background_image.current_file_obj - path = f.name - set_background_image.current_file_obj = None - f.flush() - - try: - boss.set_background_image(path, os_windows, pg(payload, 'configured'), layout) - except ValueError as err: - err.hide_traceback = True - raise - - -set_background_image = SetBackgroundImage() -# }}} - - -# disable_ligatures {{{ -@cmd( - 'Control ligature rendering', - 'Control ligature rendering for the specified windows/tabs (defaults to active window). The STRATEGY' - ' can be one of: never, always, cursor', - options_spec='''\ ---all -a -type=bool-set -By default, ligatures are only affected in the active window. This option will -cause ligatures to be changed in all windows. - -''' + '\n\n' + MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--match-tab -t'), - argspec='STRATEGY' -) -def cmd_disable_ligatures(global_opts, opts, args): - ''' - strategy+: One of :code:`never`, :code:`always` or :code:`cursor` - match_window: Window to change opacity in - match_tab: Tab to change opacity in - all: Boolean indicating operate on all windows - ''' - if not args: - raise SystemExit( - 'You must specify the STRATEGY for disabling ligatures, must be one of' - ' never, always or cursor') - strategy = args[0] - if strategy not in ('never', 'always', 'cursor'): - raise SystemExit('{} is not a valid disable_ligatures strategy'.format('strategy')) - return { - 'strategy': strategy, 'match_window': opts.match, 'match_tab': opts.match_tab, - 'all': opts.all, - } - - -def disable_ligatures(boss, window, payload): - windows = windows_for_payload(boss, window, payload) - boss.disable_ligatures_in(windows, payload['strategy']) -# }}} - - -# kitten {{{ -@cmd( - 'Run a kitten', - 'Run a kitten over the specified window (active window by default).' - ' The :italic:`kitten_name` can be either the name of a builtin kitten' - ' or the path to a python file containing a custom kitten. If a relative path' - ' is used it is searched for in the kitty config directory.', - options_spec=MATCH_WINDOW_OPTION, - argspec='kitten_name', -) -def cmd_kitten(global_opts, opts, args): - ''' - kitten+: The name of the kitten to run - args: Arguments to pass to the kitten as a list - match: The window to run the kitten over - ''' - if len(args) < 1: - raise SystemExit('Must specify kitten name') - return {'match': opts.match, 'args': list(args)[1:], 'kitten': args[0]} - - -def kitten(boss, window, payload): - windows = [window or boss.active_window] - pg = cmd_kitten.payload_get - match = pg(payload, 'match') - if match: - windows = tuple(boss.match_windows(match)) - if not windows: - raise MatchError(match) - for window in windows: - if window: - boss._run_kitten(payload['kitten'], args=tuple(payload.get('args', ())), window=window) - break -# }}} - - -def cli_params_for(func): - return (func.options_spec or '\n').format, func.argspec, func.desc, '{} @ {}'.format(appname, func.name) - - -class SubCommandOptions: - pass - - -def parse_subcommand_cli(func, args): - opts, items = parse_args(args[1:], *cli_params_for(func), result_class=SubCommandOptions) - if func.args_count is not None and func.args_count != len(items): - if func.args_count == 0: - raise SystemExit('Unknown extra argument(s) supplied to {}'.format(func.name)) - raise SystemExit('Must specify exactly {} argument(s) for {}'.format(func.args_count, func.name)) - return opts, items - - -def display_subcommand_help(func): - with suppress(SystemExit): - parse_args(['--help'], (func.options_spec or '\n').format, func.argspec, func.desc, func.name, result_class=SubCommandOptions) diff --git a/kitty/complete.py b/kitty/complete.py index a8db1fcad..ee1d2ea26 100644 --- a/kitty/complete.py +++ b/kitty/complete.py @@ -5,11 +5,13 @@ import os import shlex import sys +from functools import lru_cache +from typing import Tuple -from kittens.runner import get_kitten_cli_docs, all_kitten_names +from kittens.runner import all_kitten_names, get_kitten_cli_docs from .cli import options_for_completion, parse_option_spec -from .cmds import cmap +from .rc.base import all_command_names, command_for_name from .shell import options_for_cmd ''' @@ -47,6 +49,11 @@ class Completions: self.files_groups = set() +@lru_cache(maxsize=2) +def remote_control_command_names() -> Tuple[str, ...]: + return tuple(sorted(x.replace('_', '-') for x in all_command_names())) + + # Shell specific code {{{ @@ -164,7 +171,7 @@ def fish_output_serializer(ans): def completions_for_first_word(ans, prefix, entry_points, namespaced_entry_points): - cmds = ['@' + c for c in cmap] + cmds = ['@' + c for c in remote_control_command_names()] ans.match_groups['Entry points'] = { k: None for k in list(entry_points) + cmds + ['+' + k for k in namespaced_entry_points] @@ -266,9 +273,9 @@ def complete_remote_command(ans, cmd_name, words, new_word): aliases, alias_map = options_for_cmd(cmd_name) if not alias_map: return - args_completion = cmap[cmd_name].args_completion args_completer = None - if 'files' in args_completion: + args_completion = command_for_name(cmd_name).args_completion + if args_completion and 'files' in args_completion: args_completer = remote_files_completer(args_completion['files']) complete_alias_map(ans, words, new_word, alias_map, complete_args=args_completer) @@ -390,14 +397,14 @@ def find_completions(words, new_word, entry_points, namespaced_entry_points): if words[0] == '@': if len(words) == 1 or (len(words) == 2 and not new_word): prefix = words[1] if len(words) > 1 else '' - ans.match_groups['Remote control commands'] = {c: None for c in cmap if c.startswith(prefix)} + ans.match_groups['Remote control commands'] = {c: None for c in remote_control_command_names() if c.startswith(prefix)} else: complete_remote_command(ans, words[1], words[2:], new_word) return ans if words[0].startswith('@'): if len(words) == 1 and not new_word: prefix = words[0] - ans.match_groups['Remote control commands'] = {'@' + c: None for c in cmap if c.startswith(prefix)} + ans.match_groups['Remote control commands'] = {'@' + c: None for c in remote_control_command_names() if c.startswith(prefix)} else: complete_remote_command(ans, words[0][1:], words[1:], new_word) if words[0] == '+': diff --git a/kitty/conf/definition.py b/kitty/conf/definition.py index 69d553cbe..9cbb06937 100644 --- a/kitty/conf/definition.py +++ b/kitty/conf/definition.py @@ -336,7 +336,10 @@ def save_type_stub(text: str, fpath: str) -> None: import os fpath += 'i' preamble = '# Update this file by running: python {}\n\n'.format(os.path.relpath(os.path.abspath(fpath))) - existing = open(fpath).read() + try: + existing = open(fpath).read() + except FileNotFoundError: + existing = '' current = preamble + text if existing != current: open(fpath, 'w').write(current) diff --git a/kitty/constants.py b/kitty/constants.py index 368e58068..d53b7a5da 100644 --- a/kitty/constants.py +++ b/kitty/constants.py @@ -2,14 +2,14 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal +import errno import os import pwd import sys -import errno from collections import namedtuple from contextlib import suppress from functools import lru_cache -from typing import Set +from typing import Optional, Set appname = 'kitty' version = (0, 16, 0) @@ -170,3 +170,9 @@ def is_wayland(opts=None): supports_primary_selection = not is_macos + + +def running_in_kitty(set_val: Optional[bool] = None) -> bool: + if set_val is not None: + setattr(running_in_kitty, 'ans', set_val) + return getattr(running_in_kitty, 'ans', False) diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 1a353c326..60dfaab6d 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -489,6 +489,14 @@ def parse_font_feature(str) -> bytes: pass +def glfw_primary_monitor_size() -> Tuple[int, int]: + pass + + +def set_smallest_allowed_resize(width: int, height: int) -> None: + pass + + def set_default_window_icon(data: bytes, width: int, height: int) -> None: pass diff --git a/kitty/main.py b/kitty/main.py index bc8e8d726..28a8e24c5 100644 --- a/kitty/main.py +++ b/kitty/main.py @@ -16,7 +16,7 @@ from .cli_stub import CLIOptions from .config import cached_values_for, initial_window_size_func from .constants import ( appname, beam_cursor_data_file, config_dir, glfw_path, is_macos, - is_wayland, kitty_exe, logo_data_file + is_wayland, kitty_exe, logo_data_file, running_in_kitty ) from .fast_data_types import ( GLFW_IBEAM_CURSOR, create_os_window, free_font_data, glfw_init, @@ -254,6 +254,7 @@ def set_locale(): def _main(): + running_in_kitty(True) with suppress(AttributeError): # python compiled without threading sys.setswitchinterval(1000.0) # we have only a single python thread diff --git a/kitty/rc/__init__.py b/kitty/rc/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kitty/rc/base.py b/kitty/rc/base.py new file mode 100644 index 000000000..b75d63ff5 --- /dev/null +++ b/kitty/rc/base.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +from contextlib import suppress +from typing import ( + TYPE_CHECKING, Any, Callable, Dict, FrozenSet, Generator, List, NoReturn, + Optional, Tuple, Union +) + +from kitty.cli import get_defaults_from_seq, parse_args, parse_option_spec +from kitty.cli_stub import RCOptions +from kitty.constants import appname, running_in_kitty + +if TYPE_CHECKING: + from kitty.boss import Boss + from kitty.window import Window + Boss, Window +else: + Boss = Window = None + + +class NoResponse: + pass + + +class MatchError(ValueError): + + hide_traceback = True + + def __init__(self, expression, target='windows'): + ValueError.__init__(self, 'No matching {} for expression: {}'.format(target, expression)) + + +class OpacityError(ValueError): + + hide_traceback = True + + +class UnknownLayout(ValueError): + + hide_traceback = True + + +no_response = NoResponse() +ResponseType = Optional[Union[bool, str]] +CmdReturnType = Union[Dict[str, Any], List, Tuple, str, int, float, bool] +CmdGenerator = Generator[CmdReturnType, None, None] +PayloadType = Optional[Union[CmdReturnType, CmdGenerator]] +PayloadGetType = Callable[[str, str], Any] +ArgsType = List[str] + + +MATCH_WINDOW_OPTION = '''\ +--match -m +The window to match. Match specifications are of the form: +:italic:`field:regexp`. Where field can be one of: id, title, pid, cwd, cmdline, num, env. +You can use the :italic:`ls` command to get a list of windows. Note that for +numeric fields such as id, pid and num the expression is interpreted as a number, +not a regular expression. The field num refers to the window position in the current tab, +starting from zero and counting clockwise (this is the same as the order in which the +windows are reported by the :italic:`ls` command). The window id of the current window +is available as the KITTY_WINDOW_ID environment variable. When using the :italic:`env` field +to match on environment variables you can specify only the environment variable name or a name +and value, for example, :italic:`env:MY_ENV_VAR=2` +''' +MATCH_TAB_OPTION = '''\ +--match -m +The tab to match. Match specifications are of the form: +:italic:`field:regexp`. Where field can be one of: id, title, pid, cwd, env, cmdline. +You can use the :italic:`ls` command to get a list of tabs. Note that for +numeric fields such as id and pid the expression is interpreted as a number, +not a regular expression. When using title or id, first a matching tab is +looked for and if not found a matching window is looked for, and the tab +for that window is used. +''' + + +def windows_for_payload(boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> Tuple['Window', ...]: + if payload_get('all'): + windows = tuple(boss.all_windows) + else: + windows = (window or boss.active_window,) + if payload_get('match_window'): + windows = tuple(boss.match_windows(payload_get('match_window'))) + if not windows: + raise MatchError(payload_get('match_window')) + if payload_get('match_tab'): + tabs = tuple(boss.match_tabs(payload_get('match_tab'))) + if not tabs: + raise MatchError(payload_get('match_tab'), 'tabs') + for tab in tabs: + windows += tuple(tab) + return windows + + +class RemoteCommand: + + name: str = '' + short_desc: str = '' + desc: str = '' + argspec: str = '...' + options_spec: Optional[str] = None + no_response: bool = False + string_return_is_error: bool = False + args_count: Optional[int] = None + args_completion: Optional[Dict[str, Tuple[str, Tuple[str, ...]]]] = None + defaults: Optional[Dict[str, Any]] = None + + def __init__(self): + self.desc = self.desc or self.short_desc + self.name = self.__class__.__module__.split('.')[-1].replace('_', '-') + self.args_count = 0 if not self.argspec else self.args_count + + def fatal(self, msg: str) -> NoReturn: + if running_in_kitty(): + raise Exception(msg) + raise SystemExit(msg) + + def get_default(self, name: str, missing: Any = None) -> Any: + if self.options_spec: + if self.defaults is None: + self.defaults = get_defaults_from_seq(parse_option_spec(self.options_spec)[0]) + return self.defaults.get(name, missing) + return missing + + def payload_get(self, payload: Dict[str, Any], key: str, opt_name: Optional[str] = None) -> Any: + payload_get = object() + ans = payload.get(key, payload_get) + if ans is not payload_get: + return ans + return self.get_default(opt_name or key) + + def message_to_kitty(self, global_opts: RCOptions, opts: Any, args: ArgsType) -> PayloadType: + raise NotImplementedError() + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + raise NotImplementedError() + + +def cli_params_for(command: RemoteCommand) -> Tuple[Callable[[], str], str, str, str]: + return (command.options_spec or '\n').format, command.argspec, command.desc, '{} @ {}'.format(appname, command.name) + + +def parse_subcommand_cli(command: RemoteCommand, args: ArgsType) -> Tuple[Any, ArgsType]: + opts, items = parse_args(args[1:], *cli_params_for(command)) + if command.args_count is not None and command.args_count != len(items): + if command.args_count == 0: + raise SystemExit('Unknown extra argument(s) supplied to {}'.format(command.name)) + raise SystemExit('Must specify exactly {} argument(s) for {}'.format(command.args_count, command.name)) + return opts, items + + +def display_subcommand_help(func): + with suppress(SystemExit): + parse_args(['--help'], (func.options_spec or '\n').format, func.argspec, func.desc, func.name) + + +def command_for_name(cmd_name: str) -> RemoteCommand: + from importlib import import_module + cmd_name = cmd_name.replace('-', '_') + try: + m = import_module(f'kitty.rc.{cmd_name}') + except ImportError: + raise KeyError(f'{cmd_name} is not a known kitty remote control command') + return getattr(m, cmd_name) + + +def all_command_names() -> FrozenSet[str]: + try: + from importlib.resources import contents + except ImportError: + from importlib_resources import contents + + def ok(name: str) -> bool: + root, _, ext = name.rpartition('.') + return ext in ('py', 'pyc', 'pyo') and root and root not in ('base', '__init__') + + return frozenset({x.rpartition('.')[0] for x in filter(ok, contents('kitty.rc'))}) diff --git a/kitty/rc/close_tab.py b/kitty/rc/close_tab.py new file mode 100644 index 000000000..551bb030d --- /dev/null +++ b/kitty/rc/close_tab.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_TAB_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import CloseTabRCOptions as CLIOptions + + +class CloseTab(RemoteCommand): + + ''' + match: Which tab to close + self: Boolean indicating whether to close the window the command is run in + ''' + + short_desc = 'Close the specified tab(s)' + options_spec = MATCH_TAB_OPTION + '''\n +--self +type=bool-set +If specified close the tab this command is run in, rather than the active tab. +''' + argspec = '' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + return {'match': opts.match, 'self': opts.self} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + tabs = tuple(boss.match_tabs(match)) + if not tabs: + raise MatchError(match, 'tabs') + else: + tabs = [boss.tab_for_window(window) if window and payload_get('self') else boss.active_tab] + for tab in tabs: + if window: + if tab: + boss.close_tab(tab) + + +close_tab = CloseTab() diff --git a/kitty/rc/close_window.py b/kitty/rc/close_window.py new file mode 100644 index 000000000..54eea0da0 --- /dev/null +++ b/kitty/rc/close_window.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import CloseWindowRCOptions as CLIOptions + + +class CloseWindow(RemoteCommand): + ''' + match: Which window to close + self: Boolean indicating whether to close the window the command is run in + ''' + + short_desc = 'Close the specified window(s)' + options_spec = MATCH_WINDOW_OPTION + '''\n +--self +type=bool-set +If specified close the window this command is run in, rather than the active window. +''' + argspec = '' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + return {'match': opts.match, 'self': opts.self} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + windows = tuple(boss.match_windows(match)) + if not windows: + raise MatchError(match) + else: + windows = [window if window and payload_get('self') else boss.active_window] + for window in windows: + if window: + boss.close_window(window) + + +close_window = CloseWindow() diff --git a/kitty/rc/create_marker.py b/kitty/rc/create_marker.py new file mode 100644 index 000000000..d07432247 --- /dev/null +++ b/kitty/rc/create_marker.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +from typing import TYPE_CHECKING + +from kitty.config import parse_marker_spec + +from .base import ( + MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import CreateMarkerRCOptions as CLIOptions + + +class CreateMarker(RemoteCommand): + + ''' + match: Which window to detach + self: Boolean indicating whether to detach the window the command is run in + marker_spec: A list or arguments that define the marker specification, for example: ['text', '1', 'ERROR'] + ''' + + short_desc = 'Create a marker that highlights specified text' + desc = ( + 'Create a marker which can highlight text in the specified window. For example: ' + 'create_marker text 1 ERROR. For full details see: https://sw.kovidgoyal.net/kitty/marks.html' + ) + options_spec = MATCH_WINDOW_OPTION + '''\n +--self +type=bool-set +If specified apply marker to the window this command is run in, rather than the active window. +''' + argspec = 'MARKER SPECIFICATION' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + if len(args) < 2: + self.fatal('Invalid marker specification: {}'.format(' '.join(args))) + parse_marker_spec(args[0], args[1:]) + return {'match': opts.match, 'self': opts.self, 'marker_spec': args} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + windows = tuple(boss.match_windows(match)) + if not windows: + raise MatchError(match) + else: + windows = [window if window and payload_get('self') else boss.active_window] + args = payload_get('marker_spec') + + for window in windows: + window.set_marker(args) + + +create_marker = CreateMarker() diff --git a/kitty/rc/detach_tab.py b/kitty/rc/detach_tab.py new file mode 100644 index 000000000..d333c1ffe --- /dev/null +++ b/kitty/rc/detach_tab.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_TAB_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import DetachTabRCOptions as CLIOptions + + +class DetachTab(RemoteCommand): + + ''' + match: Which tab to detach + target: Which OS Window to move the detached tab to + self: Boolean indicating whether to detach the tab the command is run in + ''' + + short_desc = 'Detach a tab and place it in a different/new OS Window' + desc = ( + 'Detach the specified tab and either move it into a new OS window' + ' or add it to the OS Window containing the tab specified by --target-tab' + ) + options_spec = MATCH_TAB_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--target-tab -t') + '''\n +--self +type=bool-set +If specified detach the tab this command is run in, rather than the active tab. +''' + argspec = '' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + return {'match': opts.match, 'target': opts.target_tab, 'self': opts.self} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + tabs = tuple(boss.match_tabs(match)) + if not tabs: + raise MatchError(match) + else: + tabs = [window.tabref() if payload_get('self') and window and window.tabref() else boss.active_tab] + match = payload_get('target_tab') + kwargs = {} + if match: + targets = tuple(boss.match_tabs(match)) + if not targets: + raise MatchError(match, 'tabs') + kwargs['target_os_window_id'] = targets[0].os_window_id + + for tab in tabs: + boss._move_tab_to(tab=tab, **kwargs) + + +detach_tab = DetachTab() diff --git a/kitty/rc/detach_window.py b/kitty/rc/detach_window.py new file mode 100644 index 000000000..c5d53fb61 --- /dev/null +++ b/kitty/rc/detach_window.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_TAB_OPTION, MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, + PayloadGetType, PayloadType, RCOptions, RemoteCommand, ResponseType, + Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import DetachWindowRCOptions as CLIOptions + + +class DetachWindow(RemoteCommand): + + ''' + match: Which window to detach + target: Which tab to move the detached window to + self: Boolean indicating whether to detach the window the command is run in + ''' + + short_desc = 'Detach a window and place it in a different/new tab' + desc = ( + 'Detach the specified window and either move it into a new tab, a new OS window' + ' or add it to the specified tab. Use the special value :code:`new` for --target-tab' + ' to move to a new tab. If no target tab is specified the window is moved to a new OS window.' + ) + options_spec = MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--target-tab -t') + '''\n +--self +type=bool-set +If specified detach the window this command is run in, rather than the active window. +''' + argspec = '' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + return {'match': opts.match, 'target': opts.target_tab, 'self': opts.self} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + windows = tuple(boss.match_windows(match)) + if not windows: + raise MatchError(match) + else: + windows = [window if window and payload_get('self') else boss.active_window] + match = payload_get('target_tab') + kwargs = {} + if match: + if match == 'new': + kwargs['target_tab_id'] = 'new' + else: + tabs = tuple(boss.match_tabs(match)) + if not tabs: + raise MatchError(match, 'tabs') + kwargs['target_tab_id'] = tabs[0].id + if not kwargs: + kwargs['target_os_window_id'] = 'new' + + for window in windows: + boss._move_window_to(window=window, **kwargs) + + +detach_window = DetachWindow() diff --git a/kitty/rc/disable_ligatures.py b/kitty/rc/disable_ligatures.py new file mode 100644 index 000000000..fbab742e7 --- /dev/null +++ b/kitty/rc/disable_ligatures.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_TAB_OPTION, MATCH_WINDOW_OPTION, ArgsType, Boss, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window, + windows_for_payload +) + +if TYPE_CHECKING: + from kitty.cli_stub import DisableLigaturesRCOptions as CLIOptions + + +class DisableLigatures(RemoteCommand): + + ''' + strategy+: One of :code:`never`, :code:`always` or :code:`cursor` + match_window: Window to change opacity in + match_tab: Tab to change opacity in + all: Boolean indicating operate on all windows + ''' + + short_desc = 'Control ligature rendering' + desc = ( + 'Control ligature rendering for the specified windows/tabs (defaults to active window). The STRATEGY' + ' can be one of: never, always, cursor' + ) + options_spec = '''\ +--all -a +type=bool-set +By default, ligatures are only affected in the active window. This option will +cause ligatures to be changed in all windows. + +''' + '\n\n' + MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--match-tab -t') + argspec = 'STRATEGY' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + if not args: + self.fatal( + 'You must specify the STRATEGY for disabling ligatures, must be one of' + ' never, always or cursor') + strategy = args[0] + if strategy not in ('never', 'always', 'cursor'): + self.fatal('{} is not a valid disable_ligatures strategy'.format('strategy')) + return { + 'strategy': strategy, 'match_window': opts.match, 'match_tab': opts.match_tab, + 'all': opts.all, + } + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + windows = windows_for_payload(boss, window, payload_get) + boss.disable_ligatures_in(windows, payload_get('strategy')) +# }}} + + +disable_ligatures = DisableLigatures() diff --git a/kitty/rc/focus_tab.py b/kitty/rc/focus_tab.py new file mode 100644 index 000000000..59eb80e06 --- /dev/null +++ b/kitty/rc/focus_tab.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_TAB_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import FocusTabRCOptions as CLIOptions + + +class FocusTab(RemoteCommand): + + ''' + match: The tab to focus + ''' + + short_desc = 'Focus the specified tab' + desc = 'The active window in the specified tab will be focused.' + options_spec = MATCH_TAB_OPTION + ''' + +--no-response +type=bool-set +default=false +Don't wait for a response indicating the success of the action. Note that +using this option means that you will not be notified of failures. +''' + argspec = '' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + if opts.no_response: + global_opts.no_command_response = True + return {'match': opts.match} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + tabs = tuple(boss.match_tabs(match)) + else: + tabs = [boss.tab_for_window(window) if window else boss.active_tab] + if not tabs: + raise MatchError(match, 'tabs') + tab = tabs[0] + boss.set_active_tab(tab) + + +focus_tab = FocusTab() diff --git a/kitty/rc/focus_window.py b/kitty/rc/focus_window.py new file mode 100644 index 000000000..c87568708 --- /dev/null +++ b/kitty/rc/focus_window.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from kitty.fast_data_types import focus_os_window + +from .base import ( + MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import FocusWindowRCOptions as CLIOptions + + +class FocusWindow(RemoteCommand): + ''' + match: The window to focus + ''' + + short_desc = 'Focus the specified window' + desc = 'Focus the specified window, if no window is specified, focus the window this command is run inside.' + argspec = '' + options_spec = MATCH_WINDOW_OPTION + '''\n\n +--no-response +type=bool-set +default=false +Don't wait for a response from kitty. This means that even if no matching window is found, +the command will exit with a success code. +''' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + if opts.no_response: + global_opts.no_command_response = True + return {'match': opts.match, 'no_response': opts.no_response} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + windows = [window or boss.active_window] + match = payload_get('match') + if match: + windows = tuple(boss.match_windows(match)) + if not windows: + raise MatchError(match) + for window in windows: + if window: + os_window_id = boss.set_active_window(window) + if os_window_id: + focus_os_window(os_window_id, True) + break + + +focus_window = FocusWindow() diff --git a/kitty/rc/get_colors.py b/kitty/rc/get_colors.py new file mode 100644 index 000000000..5ee2c387d --- /dev/null +++ b/kitty/rc/get_colors.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +from typing import TYPE_CHECKING + +from kitty.rgb import Color, color_as_sharp, color_from_int +from kitty.utils import natsort_ints + +from .base import ( + MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import GetColorsRCOptions as CLIOptions + + +class GetColors(RemoteCommand): + + ''' + match: The window to get the colors for + configured: Boolean indicating whether to get configured or current colors + ''' + + short_desc = 'Get terminal colors' + desc = ( + 'Get the terminal colors for the specified window (defaults to active window). ' + 'Colors will be output to stdout in the same syntax as used for kitty.conf' + ) + options_spec = '''\ +--configured -c +type=bool-set +Instead of outputting the colors for the specified window, output the currently +configured colors. + +''' + '\n\n' + MATCH_WINDOW_OPTION + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + return {'configured': opts.configured, 'match': opts.match} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + ans = {k: getattr(boss.opts, k) for k in boss.opts if isinstance(getattr(boss.opts, k), Color)} + if not payload_get('configured'): + windows = (window or boss.active_window,) + if payload_get('match'): + windows = tuple(boss.match_windows(payload_get('match'))) + if not windows: + raise MatchError(payload_get('match')) + ans.update({k: color_from_int(v) for k, v in windows[0].current_colors.items()}) + all_keys = natsort_ints(ans) + maxlen = max(map(len, all_keys)) + return '\n'.join(('{:%ds} {}' % maxlen).format(key, color_as_sharp(ans[key])) for key in all_keys) +# }}} + + +get_colors = GetColors() diff --git a/kitty/rc/get_text.py b/kitty/rc/get_text.py new file mode 100644 index 000000000..ffcf22ae3 --- /dev/null +++ b/kitty/rc/get_text.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import GetTextRCOptions as CLIOptions + + +class GetText(RemoteCommand): + + ''' + match: The tab to focus + extent: One of :code:`screen`, :code:`all`, or :code:`selection` + ansi: Boolean, if True send ANSI formatting codes + self: Boolean, if True use window command was run in + ''' + + short_desc = 'Get text from the specified window' + options_spec = MATCH_WINDOW_OPTION + '''\n +--extent +default=screen +choices=screen, all, selection +What text to get. The default of screen means all text currently on the screen. all means +all the screen+scrollback and selection means currently selected text. + + +--ansi +type=bool-set +By default, only plain text is returned. If you specify this flag, the text will +include the formatting escape codes for colors/bold/italic/etc. Note that when +getting the current selection, the result is always plain text. + + +--self +type=bool-set +If specified get text from the window this command is run in, rather than the active window. +''' + argspec = '' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + return {'match': opts.match, 'extent': opts.extent, 'ansi': opts.ansi, 'self': opts.self} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + windows = tuple(boss.match_windows(match)) + if not windows: + raise MatchError(match) + else: + windows = [window if window and payload_get('self') else boss.active_window] + window = windows[0] + if payload_get('extent') == 'selection': + ans = window.text_for_selection() + else: + ans = window.as_text(as_ansi=bool(payload_get('ansi')), add_history=payload_get('extent') == 'all') + return ans + + +get_text = GetText() diff --git a/kitty/rc/goto_layout.py b/kitty/rc/goto_layout.py new file mode 100644 index 000000000..29ed0f6eb --- /dev/null +++ b/kitty/rc/goto_layout.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_TAB_OPTION, ArgsType, Boss, MatchError, PayloadGetType, PayloadType, + RCOptions, RemoteCommand, ResponseType, UnknownLayout, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import GotoLayoutRCOptions as CLIOptions + + +class GotoLayout(RemoteCommand): + + ''' + layout+: The new layout name + match: Which tab to change the layout of + ''' + + short_desc = 'Set the window layout' + desc = ( + 'Set the window layout in the specified tab (or the active tab if not specified).' + ' You can use special match value :italic:`all` to set the layout in all tabs.' + ) + options_spec = MATCH_TAB_OPTION + argspec = 'LAYOUT_NAME' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + try: + return {'layout': args[0], 'match': opts.match} + except IndexError: + raise self.fatal('No layout specified') + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + if match == 'all': + tabs = tuple(boss.all_tabs) + else: + tabs = tuple(boss.match_tabs(match)) + if not tabs: + raise MatchError(match, 'tabs') + else: + tabs = [boss.tab_for_window(window) if window else boss.active_tab] + for tab in tabs: + if tab: + try: + tab.goto_layout(payload_get('layout'), raise_exception=True) + except ValueError: + raise UnknownLayout('The layout {} is unknown or disabled'.format(payload_get('layout'))) + + +goto_layout = GotoLayout() diff --git a/kitty/rc/kitten.py b/kitty/rc/kitten.py new file mode 100644 index 000000000..78f6141a5 --- /dev/null +++ b/kitty/rc/kitten.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import KittenRCOptions as CLIOptions + + +class Kitten(RemoteCommand): + + ''' + kitten+: The name of the kitten to run + args: Arguments to pass to the kitten as a list + match: The window to run the kitten over + ''' + + short_desc = 'Run a kitten' + desc = ( + 'Run a kitten over the specified window (active window by default).' + ' The :italic:`kitten_name` can be either the name of a builtin kitten' + ' or the path to a python file containing a custom kitten. If a relative path' + ' is used it is searched for in the kitty config directory.' + ) + options_spec = MATCH_WINDOW_OPTION + argspec = 'kitten_name' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + if len(args) < 1: + self.fatal('Must specify kitten name') + return {'match': opts.match, 'args': list(args)[1:], 'kitten': args[0]} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + windows = [window or boss.active_window] + match = payload_get('match') + if match: + windows = tuple(boss.match_windows(match)) + if not windows: + raise MatchError(match) + for window in windows: + if window: + boss._run_kitten(payload_get('kitten'), args=tuple(payload_get('args') or ()), window=window) + break + + +kitten = Kitten() diff --git a/kitty/rc/last_used_layout.py b/kitty/rc/last_used_layout.py new file mode 100644 index 000000000..249418f2b --- /dev/null +++ b/kitty/rc/last_used_layout.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_TAB_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import LastUsedLayoutRCOptions as CLIOptions + + +class LastUsedLayout(RemoteCommand): + ''' + match: Which tab to change the layout of + ''' + + short_desc = 'Switch to the last used layout' + desc = ( + 'Switch to the last used window layout in the specified tab (or the active tab if not specified).' + ' You can use special match value :italic:`all` to set the layout in all tabs.' + ) + options_spec = MATCH_TAB_OPTION + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + return {'match': opts.match} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + if match == 'all': + tabs = tuple(boss.all_tabs) + else: + tabs = tuple(boss.match_tabs(match)) + if not tabs: + raise MatchError(match, 'tabs') + else: + tabs = [boss.tab_for_window(window) if window else boss.active_tab] + for tab in tabs: + if tab: + tab.last_used_layout() + + +last_used_layout = LastUsedLayout() diff --git a/kitty/rc/launch.py b/kitty/rc/launch.py new file mode 100644 index 000000000..5d830453b --- /dev/null +++ b/kitty/rc/launch.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from kitty.launch import ( + LaunchCLIOptions, launch as do_launch, options_spec as launch_options_spec, + parse_launch_args +) + +from .base import ( + MATCH_TAB_OPTION, ArgsType, Boss, MatchError, PayloadGetType, PayloadType, + RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import LaunchRCOptions as CLIOptions + + +class Launch(RemoteCommand): + + ''' + args+: The command line to run in the new window, as a list, use an empty list to run the default shell + match: The tab to open the new window in + window_title: Title for the new window + cwd: Working directory for the new window + env: List of environment variables of the form NAME=VALUE + tab_title: Title for the new tab + type: The type of window to open + keep_focus: Boolean indicating whether the current window should retain focus or not + copy_colors: Boolean indicating whether to copy the colors from the current window + copy_cmdline: Boolean indicating whether to copy the cmdline from the current window + copy_env: Boolean indicating whether to copy the environ from the current window + location: Where in the tab to open the new window + allow_remote_control: Boolean indicating whether to allow remote control from the new window + stdin_source: Where to get stdin for thew process from + stdin_add_formatting: Boolean indicating whether to add formatting codes to stdin + stdin_add_line_wrap_markers: Boolean indicating whether to add line wrap markers to stdin + no_response: Boolean indicating whether to send back the window id + marker: Specification for marker for new window, for example: "text 1 ERROR" + ''' + + short_desc = 'Run an arbitrary process in a new window/tab' + desc = ( + ' Prints out the id of the newly opened window. Any command line arguments' + ' are assumed to be the command line used to run in the new window, if none' + ' are provided, the default shell is run. For example:' + ' :italic:`kitty @ launch --title Email mutt`.' + ) + options_spec = MATCH_TAB_OPTION + '\n\n' + '''\ +--no-response +type=bool-set +Do not print out the id of the newly created window. + + +--self +type=bool-set +If specified the tab containing the window this command is run in is used +instead of the active tab + ''' + '\n\n' + launch_options_spec().replace(':option:`launch', ':option:`kitty @ launch') + argspec = '[CMD ...]' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + if opts.no_response: + global_opts.no_command_response = True + ans = {'args': args or []} + for attr, val in opts.__dict__.items(): + ans[attr] = val + return ans + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + default_opts = parse_launch_args()[0] + opts = LaunchCLIOptions() + for key, default_value in default_opts.__dict__.items(): + val = payload_get(key) + if val is None: + val = default_value + setattr(opts, key, val) + match = payload_get('match') + if match: + tabs = tuple(boss.match_tabs(match)) + if not tabs: + raise MatchError(match, 'tabs') + else: + tabs = [boss.active_tab] + if payload_get('self') and window and window.tabref(): + tabs = [window.tabref()] + tab = tabs[0] + w = do_launch(boss, opts, payload_get('args') or None, target_tab=tab) + return None if payload_get('no_response') else str(getattr(w, 'id', 0)) + + +launch = Launch() diff --git a/kitty/rc/ls.py b/kitty/rc/ls.py new file mode 100644 index 000000000..a954d413a --- /dev/null +++ b/kitty/rc/ls.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +import json +from typing import Any + +from kitty.constants import appname + +from .base import ( + ArgsType, Boss, PayloadGetType, PayloadType, RCOptions, RemoteCommand, + ResponseType, Window +) + + +class LS(RemoteCommand): + ''' + No payload + ''' + + short_desc = 'List all tabs/windows' + desc = ( + 'List all windows. The list is returned as JSON tree. The top-level is a list of' + f' operating system {appname} windows. Each OS window has an :italic:`id` and a list' + ' of :italic:`tabs`. Each tab has its own :italic:`id`, a :italic:`title` and a list of :italic:`windows`.' + ' Each window has an :italic:`id`, :italic:`title`, :italic:`current working directory`, :italic:`process id (PID)`, ' + ' :italic:`command-line` and :italic:`environment` of the process running in the window.\n\n' + 'You can use these criteria to select windows/tabs for the other commands.' + ) + argspec = '' + + def message_to_kitty(self, global_opts: RCOptions, opts: Any, args: ArgsType) -> PayloadType: + pass + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + data = list(boss.list_os_windows()) + return json.dumps(data, indent=2, sort_keys=True) + + +ls = LS() diff --git a/kitty/rc/new_window.py b/kitty/rc/new_window.py new file mode 100644 index 000000000..1a6e72c0b --- /dev/null +++ b/kitty/rc/new_window.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +from typing import TYPE_CHECKING + +from kitty.fast_data_types import focus_os_window +from kitty.tabs import SpecialWindow + +from .base import ( + MATCH_TAB_OPTION, ArgsType, Boss, MatchError, PayloadGetType, PayloadType, + RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import NewWindowRCOptions as CLIOptions + + +class NewWindow(RemoteCommand): + + ''' + args+: The command line to run in the new window, as a list, use an empty list to run the default shell + match: The tab to open the new window in + title: Title for the new window + cwd: Working directory for the new window + tab_title: Title for the new tab + window_type: One of :code:`kitty` or :code:`os` + keep_focus: Boolean indicating whether the current window should retain focus or not + ''' + + short_desc = 'Open new window' + desc = ( + 'Open a new window in the specified tab. If you use the :option:`kitty @ new-window --match` option' + ' the first matching tab is used. Otherwise the currently active tab is used.' + ' Prints out the id of the newly opened window (unless :option:`--no-response` is used). Any command line arguments' + ' are assumed to be the command line used to run in the new window, if none' + ' are provided, the default shell is run. For example:\n' + ':italic:`kitty @ new-window --title Email mutt`' + ) + options_spec = MATCH_TAB_OPTION + '''\n +--title +The title for the new window. By default it will use the title set by the +program running in it. + + +--cwd +The initial working directory for the new window. Defaults to whatever +the working directory for the kitty process you are talking to is. + + +--keep-focus +type=bool-set +Keep the current window focused instead of switching to the newly opened window + + +--window-type +default=kitty +choices=kitty,os +What kind of window to open. A kitty window or a top-level OS window. + + +--new-tab +type=bool-set +Open a new tab + + +--tab-title +When using --new-tab set the title of the tab. + + +--no-response +type=bool-set +default=false +Don't wait for a response giving the id of the newly opened window. Note that +using this option means that you will not be notified of failures and that +the id of the new window will not be printed out. +''' + argspec = '[CMD ...]' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + if opts.no_response: + global_opts.no_command_response = True + return {'match': opts.match, 'title': opts.title, 'cwd': opts.cwd, + 'new_tab': opts.new_tab, 'tab_title': opts.tab_title, + 'window_type': opts.window_type, 'no_response': opts.no_response, + 'keep_focus': opts.keep_focus, 'args': args or []} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + w = SpecialWindow(cmd=payload_get('args') or None, override_title=payload_get('title'), cwd=payload_get('cwd')) + old_window = boss.active_window + if payload_get('new_tab'): + boss._new_tab(w) + tab = boss.active_tab + if payload_get('tab_title'): + tab.set_title(payload_get('tab_title')) + wid = boss.active_window.id + if payload_get('keep_focus') and old_window: + boss.set_active_window(old_window) + return None if payload_get('no_response') else str(wid) + + if payload_get('window_type') == 'os': + boss._new_os_window(w) + wid = boss.active_window.id + if payload_get('keep_focus') and old_window: + os_window_id = boss.set_active_window(old_window) + if os_window_id: + focus_os_window(os_window_id) + return None if payload_get('no_response') else str(wid) + + match = payload_get('match') + if match: + tabs = tuple(boss.match_tabs(match)) + if not tabs: + raise MatchError(match, 'tabs') + else: + tabs = [boss.active_tab] + tab = tabs[0] + w = tab.new_special_window(w) + if payload_get('keep_focus') and old_window: + boss.set_active_window(old_window) + return None if payload_get('no_response') else str(w.id) + + +new_window = NewWindow() diff --git a/kitty/rc/remove_marker.py b/kitty/rc/remove_marker.py new file mode 100644 index 000000000..8b1076160 --- /dev/null +++ b/kitty/rc/remove_marker.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import RemoveMarkerRCOptions as CLIOptions + + +class RemoveMarker(RemoteCommand): + + ''' + match: Which window to remove the marker from + self: Boolean indicating whether to detach the window the command is run in + ''' + + short_desc = 'Remove the currently set marker, if any.' + options_spec = MATCH_WINDOW_OPTION + '''\n +--self +type=bool-set +If specified apply marker to the window this command is run in, rather than the active window. +''' + argspec = '' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + return {'match': opts.match, 'self': opts.self} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + windows = tuple(boss.match_windows(match)) + if not windows: + raise MatchError(match) + else: + windows = [window if window and payload_get('self') else boss.active_window] + + for window in windows: + window.remove_marker() + + +remove_marker = RemoveMarker() diff --git a/kitty/rc/resize_window.py b/kitty/rc/resize_window.py new file mode 100644 index 000000000..07d5d2996 --- /dev/null +++ b/kitty/rc/resize_window.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import ResizeWindowRCOptions as CLIOptions + + +class ResizeWindow(RemoteCommand): + ''' + match: Which window to resize + self: Boolean indicating whether to close the window the command is run in + increment: Integer specifying the resize increment + axis: One of :code:`horizontal, vertical` or :code:`reset` + ''' + + short_desc = 'Resize the specified window' + desc = 'Resize the specified window in the current layout. Note that not all layouts can resize all windows in all directions.' + options_spec = MATCH_WINDOW_OPTION + '''\n +--increment -i +type=int +default=2 +The number of cells to change the size by, can be negative to decrease the size. + + +--axis -a +type=choices +choices=horizontal,vertical,reset +default=horizontal +The axis along which to resize. If :italic:`horizontal`, it will make the window wider or narrower by the specified increment. +If :italic:`vertical`, it will make the window taller or shorter by the specified increment. The special value :italic:`reset` will +reset the layout to its default configuration. + + +--self +type=bool-set +If specified resize the window this command is run in, rather than the active window. +''' + argspec = '' + string_return_is_error = True + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + return {'match': opts.match, 'increment': opts.increment, 'axis': opts.axis, 'self': opts.self} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + windows = tuple(boss.match_windows(match)) + if not windows: + raise MatchError(match) + else: + windows = [window if window and payload_get('self') else boss.active_window] + resized = False + if windows and windows[0]: + resized = boss.resize_layout_window( + windows[0], increment=payload_get('increment'), is_horizontal=payload_get('axis') == 'horizontal', + reset=payload_get('axis') == 'reset' + ) + return resized + + +resize_window = ResizeWindow() diff --git a/kitty/rc/scroll_window.py b/kitty/rc/scroll_window.py new file mode 100644 index 000000000..ad204a5ff --- /dev/null +++ b/kitty/rc/scroll_window.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import ScrollWindowRCOptions as CLIOptions + + +class ScrollWindow(RemoteCommand): + + ''' + amount+: The amount to scroll, a two item list with the first item being \ + either a number or the keywords, start and end. \ + And the second item being either 'p' for pages or 'l' for lines. + match: The window to scroll + ''' + + short_desc = 'Scroll the specified window' + desc = ( + 'Scroll the specified window, if no window is specified, scroll the window this command is run inside.' + ' SCROLL_AMOUNT can be either the keywords :code:`start` or :code:`end` or an' + ' argument of the form [unit][+-]. For example, 30 will scroll down 30 lines and 2p- will' + ' scroll up 2 pages.' + ) + argspec = 'SCROLL_AMOUNT' + options_spec = MATCH_WINDOW_OPTION + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + amt = args[0] + ans = {'match': opts.match} + if amt in ('start', 'end'): + ans['amount'] = amt, None + else: + pages = 'p' in amt + amt = amt.replace('p', '') + mult = -1 if amt.endswith('-') else 1 + amt = int(amt.replace('-', '')) + ans['amount'] = [amt * mult, 'p' if pages else 'l'] + return ans + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + windows = [window or boss.active_window] + match = payload_get('match') + amt = payload_get('amount') + if match: + windows = tuple(boss.match_windows(match)) + if not windows: + raise MatchError(match) + for window in windows: + if window: + if amt[0] in ('start', 'end'): + getattr(window, {'start': 'scroll_home'}.get(amt[0], 'scroll_end'))() + else: + amt, unit = amt + unit = 'page' if unit == 'p' else 'line' + direction = 'up' if amt < 0 else 'down' + func = getattr(window, 'scroll_{}_{}'.format(unit, direction)) + for i in range(abs(amt)): + func() + + +scroll_window = ScrollWindow() diff --git a/kitty/rc/send_text.py b/kitty/rc/send_text.py new file mode 100644 index 000000000..be6337f2c --- /dev/null +++ b/kitty/rc/send_text.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +import os +import sys +from typing import TYPE_CHECKING + +from kitty.config import parse_send_text_bytes + +from .base import ( + MATCH_TAB_OPTION, MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, + PayloadGetType, PayloadType, RCOptions, RemoteCommand, ResponseType, + Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import SendTextRCOptions as CLIOptions + + +class SendText(RemoteCommand): + ''' + text+: The text being sent + is_binary+: If False text is interpreted as a python string literal instead of plain text + match: A string indicating the window to send text to + match_tab: A string indicating the tab to send text to + ''' + short_desc = 'Send arbitrary text to specified windows' + desc = ( + 'Send arbitrary text to specified windows. The text follows Python' + ' escaping rules. So you can use escapes like :italic:`\\x1b` to send control codes' + ' and :italic:`\\u21fa` to send unicode characters. If you use the :option:`kitty @ send-text --match` option' + ' the text will be sent to all matched windows. By default, text is sent to' + ' only the currently active window.' + ) + options_spec = MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--match-tab -t') + '''\n +--stdin +type=bool-set +Read the text to be sent from :italic:`stdin`. Note that in this case the text is sent as is, +not interpreted for escapes. If stdin is a terminal, you can press Ctrl-D to end reading. + + +--from-file +Path to a file whose contents you wish to send. Note that in this case the file contents +are sent as is, not interpreted for escapes. +''' + no_response = True + argspec = '[TEXT TO SEND]' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + limit = 1024 + ret = {'match': opts.match, 'is_binary': False, 'match_tab': opts.match_tab} + + def pipe(): + ret['is_binary'] = True + if sys.stdin.isatty(): + import select + fd = sys.stdin.fileno() + keep_going = True + while keep_going: + rd = select.select([fd], [], [])[0] + if not rd: + break + data = os.read(fd, limit) + if not data: + break # eof + data = data.decode('utf-8') + if '\x04' in data: + data = data[:data.index('\x04')] + keep_going = False + ret['text'] = data + yield ret + else: + while True: + data = sys.stdin.read(limit) + if not data: + break + ret['text'] = data[:limit] + yield ret + + def chunks(text): + ret['is_binary'] = False + while text: + ret['text'] = text[:limit] + yield ret + text = text[limit:] + + def file_pipe(path): + ret['is_binary'] = True + with open(path, encoding='utf-8') as f: + while True: + data = f.read(limit) + if not data: + break + ret['text'] = data + yield ret + + sources = [] + if opts.stdin: + sources.append(pipe()) + + if opts.from_file: + sources.append(file_pipe(opts.from_file)) + + text = ' '.join(args) + sources.append(chunks(text)) + + def chain(): + for src in sources: + yield from src + return chain() + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + windows = [boss.active_window] + match = payload_get('match') + if match: + windows = tuple(boss.match_windows(match)) + mt = payload_get('match_tab') + if mt: + windows = [] + tabs = tuple(boss.match_tabs(mt)) + if not tabs: + raise MatchError(payload_get('match_tab'), 'tabs') + for tab in tabs: + windows += tuple(tab) + data = payload_get('text').encode('utf-8') if payload_get('is_binary') else parse_send_text_bytes(payload_get('text')) + for window in windows: + if window is not None: + window.write_to_child(data) + + +send_text = SendText() diff --git a/kitty/rc/set_background_image.py b/kitty/rc/set_background_image.py new file mode 100644 index 000000000..b3f2585ce --- /dev/null +++ b/kitty/rc/set_background_image.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +import imghdr +import tempfile +from base64 import standard_b64decode, standard_b64encode +from typing import TYPE_CHECKING, BinaryIO, Optional +from uuid import uuid4 + +from .base import ( + MATCH_WINDOW_OPTION, ArgsType, Boss, PayloadGetType, PayloadType, + RCOptions, RemoteCommand, ResponseType, Window, no_response, + windows_for_payload +) + +if TYPE_CHECKING: + from kitty.cli_stub import SetBackgroundImageRCOptions as CLIOptions + + +class SetBackgroundImage(RemoteCommand): + + ''' + data+: Chunk of at most 512 bytes of PNG data, base64 encoded. Must send an empty chunk to indicate end of image. \ + Or the special value - to indicate image must be removed. + img_id+: Unique uuid (as string) used for chunking + match: Window to change opacity in + layout: The image layout + all: Boolean indicating operate on all windows + configured: Boolean indicating if the configured value should be changed + ''' + + short_desc = 'Set the background_image' + desc = ( + 'Set the background image for the specified OS windows. You must specify the path to a PNG image that' + ' will be used as the background. If you specify the special value "none" then any existing image will' + ' be removed.' + ) + options_spec = '''\ +--all -a +type=bool-set +By default, background image is only changed for the currently active OS window. This option will +cause the image to be changed in all windows. + + +--configured -c +type=bool-set +Change the configured background image which is used for new OS windows. + + +--layout +type=choices +choices=tiled,scaled,mirror-tiled,configured +How the image should be displayed. The value of configured will use the configured value. + + +''' + '\n\n' + MATCH_WINDOW_OPTION + argspec = 'PATH_TO_PNG_IMAGE' + args_count = 1 + args_completion = {'files': ('PNG Images', ('*.png',))} + current_img_id: Optional[str] = None + current_file_obj: Optional[BinaryIO] = None + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + if not args: + self.fatal('Must specify path to PNG image') + path = args[0] + ret = {'match': opts.match, 'configured': opts.configured, 'layout': opts.layout, 'all': opts.all, 'img_id': str(uuid4())} + if path.lower() == 'none': + ret['data'] = '-' + return ret + if imghdr.what(path) != 'png': + self.fatal('{} is not a PNG image'.format(path)) + + def file_pipe(path): + with open(path, 'rb') as f: + while True: + data = f.read(512) + if not data: + break + ret['data'] = standard_b64encode(data).decode('ascii') + yield ret + ret['data'] = '' + yield ret + return file_pipe(path) + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + data = payload_get('data') + if data != '-': + img_id = payload_get('img_id') + if img_id != set_background_image.current_img_id: + set_background_image.current_img_id = img_id + set_background_image.current_file_obj = tempfile.NamedTemporaryFile() + if data: + set_background_image.current_file_obj.write(standard_b64decode(data)) + return no_response + + windows = windows_for_payload(boss, window, payload_get) + os_windows = tuple({w.os_window_id for w in windows}) + layout = payload_get('layout') + if data == '-': + path = None + else: + f = set_background_image.current_file_obj + path = f.name + set_background_image.current_file_obj = None + f.flush() + + try: + boss.set_background_image(path, os_windows, payload_get('configured'), layout) + except ValueError as err: + err.hide_traceback = True + raise + + +set_background_image = SetBackgroundImage() diff --git a/kitty/rc/set_background_opacity.py b/kitty/rc/set_background_opacity.py new file mode 100644 index 000000000..8686cda96 --- /dev/null +++ b/kitty/rc/set_background_opacity.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_TAB_OPTION, MATCH_WINDOW_OPTION, ArgsType, Boss, OpacityError, + PayloadGetType, PayloadType, RCOptions, RemoteCommand, ResponseType, + Window, windows_for_payload +) + +if TYPE_CHECKING: + from kitty.cli_stub import SetBackgroundOpacityRCOptions as CLIOptions + + +class SetBackgroundOpacity(RemoteCommand): + + ''' + opacity+: A number between 0.1 and 1 + match_window: Window to change opacity in + match_tab: Tab to change opacity in + all: Boolean indicating operate on all windows + ''' + + short_desc = 'Set the background_opacity' + desc = ( + 'Set the background opacity for the specified windows. This will only work if you have turned on' + ' :opt:`dynamic_background_opacity` in :file:`kitty.conf`. The background opacity affects all kitty windows in a' + ' single os_window. For example: kitty @ set-background-opacity 0.5' + ) + options_spec = '''\ +--all -a +type=bool-set +By default, colors are only changed for the currently active window. This option will +cause colors to be changed in all windows. + +''' + '\n\n' + MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--match-tab -t') + argspec = 'OPACITY' + args_count = 1 + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + opacity = max(0.1, min(float(args[0]), 1.0)) + return { + 'opacity': opacity, 'match_window': opts.match, + 'all': opts.all, 'match_tab': opts.match_tab + } + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + if not boss.opts.dynamic_background_opacity: + raise OpacityError('You must turn on the dynamic_background_opacity option in kitty.conf to be able to set background opacity') + windows = windows_for_payload(boss, window, payload_get) + for os_window_id in {w.os_window_id for w in windows}: + boss._set_os_window_background_opacity(os_window_id, payload_get('opacity')) + + +set_background_opacity = SetBackgroundOpacity() diff --git a/kitty/rc/set_colors.py b/kitty/rc/set_colors.py new file mode 100644 index 000000000..d21d60a02 --- /dev/null +++ b/kitty/rc/set_colors.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +import os +from typing import TYPE_CHECKING + +from kitty.config import parse_config +from kitty.fast_data_types import patch_color_profiles +from kitty.rgb import Color, color_as_int + +from .base import ( + MATCH_TAB_OPTION, MATCH_WINDOW_OPTION, ArgsType, Boss, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window, + windows_for_payload +) + +if TYPE_CHECKING: + from kitty.cli_stub import SetColorsRCOptions as CLIOptions + + +class SetColors(RemoteCommand): + + ''' + colors+: An object mapping names to colors as 24-bit RGB integers + cursor_text_color: A 24-bit color for text under the cursor + match_window: Window to change colors in + match_tab: Tab to change colors in + all: Boolean indicating change colors everywhere or not + configured: Boolean indicating whether to change the configured colors. Must be True if reset is True + reset: Boolean indicating colors should be reset to startup values + ''' + + short_desc = 'Set terminal colors' + desc = ( + 'Set the terminal colors for the specified windows/tabs (defaults to active window). You can either specify the path to a conf file' + ' (in the same format as kitty.conf) to read the colors from or you can specify individual colors,' + ' for example: kitty @ set-colors foreground=red background=white' + ) + options_spec = '''\ +--all -a +type=bool-set +By default, colors are only changed for the currently active window. This option will +cause colors to be changed in all windows. + + +--configured -c +type=bool-set +Also change the configured colors (i.e. the colors kitty will use for new +windows or after a reset). + + +--reset +type=bool-set +Restore all colors to the values they had at kitty startup. Note that if you specify +this option, any color arguments are ignored and --configured and --all are implied. +''' + '\n\n' + MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--match-tab -t') + argspec = 'COLOR_OR_FILE ...' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + colors, cursor_text_color = {}, False + if not opts.reset: + for spec in args: + if '=' in spec: + colors.update(parse_config((spec.replace('=', ' '),))) + else: + with open(os.path.expanduser(spec), encoding='utf-8', errors='replace') as f: + colors.update(parse_config(f)) + cursor_text_color = colors.pop('cursor_text_color', False) + colors = {k: color_as_int(v) for k, v in colors.items() if isinstance(v, Color)} + return { + 'match_window': opts.match, 'match_tab': opts.match_tab, + 'all': opts.all or opts.reset, 'configured': opts.configured or opts.reset, + 'colors': colors, 'reset': opts.reset, 'cursor_text_color': cursor_text_color + } + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + windows = windows_for_payload(boss, window, payload_get) + colors = payload_get('colors') + cursor_text_color = payload_get('cursor_text_color') or False + if payload_get('reset'): + colors = {k: color_as_int(v) for k, v in boss.startup_colors.items()} + cursor_text_color = boss.startup_cursor_text_color + profiles = tuple(w.screen.color_profile for w in windows) + if isinstance(cursor_text_color, (tuple, list, Color)): + cursor_text_color = color_as_int(Color(*cursor_text_color)) + patch_color_profiles(colors, cursor_text_color, profiles, payload_get('configured')) + boss.patch_colors(colors, cursor_text_color, payload_get('configured')) + default_bg_changed = 'background' in colors + for w in windows: + if default_bg_changed: + boss.default_bg_changed_for(w.id) + w.refresh() + + +set_colors = SetColors() diff --git a/kitty/rc/set_font_size.py b/kitty/rc/set_font_size.py new file mode 100644 index 000000000..a17ec2fb0 --- /dev/null +++ b/kitty/rc/set_font_size.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +from typing import TYPE_CHECKING + +from .base import ( + ArgsType, Boss, PayloadGetType, PayloadType, RCOptions, RemoteCommand, + ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import SetFontSizeRCOptions as CLIOptions + + +class SetFontSize(RemoteCommand): + ''' + size+: The new font size in pts (a positive number) + all: Boolean whether to change font size in the current window or all windows + increment_op: The string ``+`` or ``-`` to interpret size as an increment + ''' + + short_desc = 'Set the font size in the active top-level OS window' + desc = ( + 'Sets the font size to the specified size, in pts. Note' + ' that in kitty all sub-windows in the same OS window' + ' must have the same font size. A value of zero' + ' resets the font size to default. Prefixing the value' + ' with a + or - increments the font size by the specified' + ' amount.' + ) + argspec = 'FONT_SIZE' + args_count = 1 + options_spec = '''\ +--all -a +type=bool-set +By default, the font size is only changed in the active OS window, +this option will cause it to be changed in all OS windows. +''' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + if not args: + self.fatal('No font size specified') + fs = args[0] + inc = fs[0] if fs and fs[0] in '+-' else None + return {'size': abs(float(fs)), 'all': opts.all, 'increment_op': inc} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + boss.change_font_size( + payload_get('all'), + payload_get('increment_op'), payload_get('size')) + + +set_font_size = SetFontSize() diff --git a/kitty/rc/set_tab_title.py b/kitty/rc/set_tab_title.py new file mode 100644 index 000000000..389865633 --- /dev/null +++ b/kitty/rc/set_tab_title.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_TAB_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import SetTabTitleRCOptions as CLIOptions + + +class SetTabTitle(RemoteCommand): + + ''' + title+: The new title + match: Which tab to change the title of + ''' + + short_desc = 'Set the tab title' + desc = ( + 'Set the title for the specified tab(s). If you use the :option:`kitty @ set-tab-title --match` option' + ' the title will be set for all matched tabs. By default, only the tab' + ' in which the command is run is affected. If you do not specify a title, the' + ' title of the currently active window in the tab is used.' + ) + options_spec = MATCH_TAB_OPTION + argspec = 'TITLE ...' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + return {'title': ' '.join(args), 'match': opts.match} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + match = payload_get('match') + if match: + tabs = tuple(boss.match_tabs(match)) + if not tabs: + raise MatchError(match, 'tabs') + else: + tabs = [boss.tab_for_window(window) if window else boss.active_tab] + for tab in tabs: + if tab: + tab.set_title(payload_get('title')) + + +set_tab_title = SetTabTitle() diff --git a/kitty/rc/set_window_title.py b/kitty/rc/set_window_title.py new file mode 100644 index 000000000..23e96e931 --- /dev/null +++ b/kitty/rc/set_window_title.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + +from typing import TYPE_CHECKING + +from .base import ( + MATCH_WINDOW_OPTION, ArgsType, Boss, MatchError, PayloadGetType, + PayloadType, RCOptions, RemoteCommand, ResponseType, Window +) + +if TYPE_CHECKING: + from kitty.cli_stub import SetWindowTitleRCOptions as CLIOptions + + +class SetWindowTitle(RemoteCommand): + + ''' + title+: The new title + match: Which windows to change the title in + temporary: Boolean indicating if the change is temporary or permanent + ''' + + short_desc = 'Set the window title' + desc = ( + 'Set the title for the specified window(s). If you use the :option:`kitty @ set-window-title --match` option' + ' the title will be set for all matched windows. By default, only the window' + ' in which the command is run is affected. If you do not specify a title, the' + ' last title set by the child process running in the window will be used.' + ) + options_spec = '''\ +--temporary +type=bool-set +By default, if you use :italic:`set-window-title` the title will be permanently changed +and programs running in the window will not be able to change it again. If you +want to allow other programs to change it afterwards, use this option. + ''' + '\n\n' + MATCH_WINDOW_OPTION + argspec = 'TITLE ...' + + def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType: + return {'title': ' '.join(args), 'match': opts.match, 'temporary': opts.temporary} + + def response_from_kitty(self, boss: 'Boss', window: 'Window', payload_get: PayloadGetType) -> ResponseType: + windows = [window or boss.active_window] + match = payload_get('match') + if match: + windows = tuple(boss.match_windows(match)) + if not windows: + raise MatchError(match) + for window in windows: + if window: + if payload_get('temporary'): + window.override_title = None + window.title_changed(payload_get('title')) + else: + window.set_title(payload_get('title')) + + +set_window_title = SetWindowTitle() diff --git a/kitty/remote_control.py b/kitty/remote_control.py index bd8fc4557..fa9b10b83 100644 --- a/kitty/remote_control.py +++ b/kitty/remote_control.py @@ -9,13 +9,16 @@ import sys import types from contextlib import suppress from functools import partial +from typing import Any, List, Optional from .cli import emph, parse_args -from .cmds import ( - cmap, no_response as no_response_sentinel, parse_subcommand_cli -) +from .cli_stub import RCOptions from .constants import appname, version from .fast_data_types import read_command_response +from .rc.base import ( + all_command_names, command_for_name, no_response as no_response_sentinel, + parse_subcommand_cli +) from .utils import TTYIO, parse_address_spec @@ -27,11 +30,14 @@ def handle_cmd(boss, window, cmd): if no_response: return return {'ok': False, 'error': 'The kitty client you are using to send remote commands is newer than this kitty instance. This is not supported.'} - c = cmap[cmd['cmd']] - func = partial(c.impl(), boss, window) - payload = cmd.get('payload') + c = command_for_name(cmd['cmd']) + payload = cmd.get('payload') or {} + + def payload_get(key: str, opt_name: Optional[str] = None) -> Any: + return c.payload_get(payload, key, opt_name) + try: - ans = func() if payload is None else func(payload) + ans = c.response_from_kitty(boss, window, payload_get) except Exception: if no_response: # don't report errors if --no-response was used return @@ -130,7 +136,6 @@ def do_io(to, send, no_response): return response -all_commands = tuple(sorted(cmap)) cli_msg = ( 'Control {appname} by sending it commands. Set the' ' :opt:`allow_remote_control` option to yes in :file:`kitty.conf` for this' @@ -138,12 +143,9 @@ cli_msg = ( ).format(appname=appname) -class RCOptions: - pass - - -def parse_rc_args(args): - cmds = (' :green:`{}`\n {}'.format(cmap[c].name, cmap[c].short_desc) for c in all_commands) +def parse_rc_args(args: List[str]): + cmap = {name: command_for_name(name) for name in sorted(all_command_names())} + cmds = (' :green:`{}`\n {}'.format(cmd.name, cmd.short_desc) for c, cmd in cmap.items()) msg = cli_msg + ( '\n\n:title:`Commands`:\n{cmds}\n\n' 'You can get help for each individual command by using:\n' @@ -161,12 +163,12 @@ def main(args): return cmd = items[0] try: - func = cmap[cmd] + c = command_for_name(cmd) except KeyError: raise SystemExit('{} is not a known command. Known commands are: {}'.format( - emph(cmd), ', '.join(all_commands))) - opts, items = parse_subcommand_cli(func, items) - payload = func(global_opts, opts, items) + emph(cmd), ', '.join(x.replace('_', '-') for x in all_command_names()))) + opts, items = parse_subcommand_cli(c, items) + payload = c.message_to_kitty(global_opts, opts, items) send = { 'cmd': cmd, 'version': version, @@ -176,7 +178,7 @@ def main(args): if global_opts.no_command_response is not None: no_response = global_opts.no_command_response else: - no_response = func.no_response + no_response = c.no_response send['no_response'] = no_response if not global_opts.to and 'KITTY_LISTEN_ON' in os.environ: global_opts.to = os.environ['KITTY_LISTEN_ON'] @@ -189,6 +191,6 @@ def main(args): raise SystemExit(response['error']) data = response.get('data') if data is not None: - if func.string_return_is_error and isinstance(data, str): + if c.string_return_is_error and isinstance(data, str): raise SystemExit(data) print(data) diff --git a/kitty/shell.py b/kitty/shell.py index 1a03f5a4e..30b63d809 100644 --- a/kitty/shell.py +++ b/kitty/shell.py @@ -7,17 +7,25 @@ import readline import shlex import sys import traceback -from functools import lru_cache from contextlib import suppress +from functools import lru_cache +from typing import Dict, Tuple from .cli import ( - emph, green, italic, parse_option_spec, print_help_for_seq, title + OptionDict, emph, green, italic, parse_option_spec, print_help_for_seq, + title ) -from .cmds import cmap, display_subcommand_help, parse_subcommand_cli from .constants import cache_dir, is_macos, version +from .rc.base import ( + all_command_names, command_for_name, display_subcommand_help, + parse_subcommand_cli +) -all_commands = tuple(sorted(cmap)) -match_commands = tuple(sorted(all_commands + ('exit', 'help', 'quit'))) + +@lru_cache(maxsize=2) +def match_commands() -> Tuple[str, ...]: + all_commands = tuple(sorted(all_command_names())) + return tuple(sorted(all_commands + ('exit', 'help', 'quit'))) def init_readline(readline): @@ -33,16 +41,16 @@ def init_readline(readline): def cmd_names_matching(prefix): - for cmd in match_commands: + for cmd in match_commands(): if not prefix or cmd.startswith(prefix): yield cmd + ' ' @lru_cache() -def options_for_cmd(cmd): +def options_for_cmd(cmd: str) -> Tuple[Tuple[str, ...], Dict[str, OptionDict]]: alias_map = {} try: - func = cmap[cmd] + func = command_for_name(cmd) except KeyError: return (), alias_map if not func.options_spec: @@ -106,8 +114,8 @@ def print_help(which=None): print('Control kitty by sending it commands.') print() print(title('Commands') + ':') - for cmd in all_commands: - c = cmap[cmd] + for cmd in all_command_names(): + c = command_for_name(cmd) print(' ', green(c.name)) print(' ', c.short_desc) print(' ', green('exit')) @@ -115,7 +123,7 @@ def print_help(which=None): print('\nUse help {} for help on individual commands'.format(italic('command'))) else: try: - func = cmap[which] + func = command_for_name(which) except KeyError: if which == 'exit': print('Exit this shell') @@ -170,7 +178,7 @@ def real_main(global_opts): cmd = cmdline[0].lower() try: - func = cmap[cmd] + func = command_for_name(cmd) except KeyError: if cmd in ('exit', 'quit'): break