diff --git a/__main__.py b/__main__.py index 9963964a2..667e86044 100644 --- a/__main__.py +++ b/__main__.py @@ -15,21 +15,35 @@ def list_fonts(args): main(args) +def remote_control(args): + from kitty.remote_control import main + main(args) + + +def namespaced(args): + func = namespaced_entry_points[args[0]] + func(args[1:]) + + +entry_points = { + 'icat': icat, + 'list-fonts': list_fonts, + '+icat': icat, + '+list-fonts': list_fonts, + '@': remote_control, + '+': namespaced, +} +namespaced_entry_points = {k: v for k, v in entry_points.items() if k[0] not in '+@'} + + def main(): first_arg = '' if len(sys.argv) < 2 else sys.argv[1] - if first_arg in ('icat', '+icat'): - icat(sys.argv[1:]) - elif first_arg in ('list-fonts', '+list-fonts'): - list_fonts(sys.argv[1:]) - elif first_arg == '+' and len(sys.argv) > 2: - q = sys.argv[2] - if q == 'icat': - icat(sys.argv[2:]) - elif q == 'list-fonts': - list_fonts(sys.argv[2:]) - else: + func = entry_points.get(first_arg) + if func is None: from kitty.main import main main() + else: + func(sys.argv[1:]) if __name__ == '__main__': diff --git a/kitty/boss.py b/kitty/boss.py index 3e62a870e..8c51b35aa 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -125,6 +125,16 @@ class Boss: else: safe_print('Unknown message received from peer, ignoring') + def handle_remote_cmd(self, cmd, window=None): + response = None + if self.opts.allow_remote_control: + pass + else: + response = {'ok': False, 'err': 'NOT_ALLOWED'} + if response is not None: + if window is not None: + window.send_cmd_response(response) + def on_child_death(self, window_id): window = self.window_id_map.pop(window_id, None) if window is None: diff --git a/kitty/cli.py b/kitty/cli.py index a218728e4..7fdb89ed3 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -206,7 +206,7 @@ def prettify(text): def sub(m): t = m.group(2) for ch in m.group(1): - t = {'C': cyan, '_': italic, '*': bold}[ch](t) + t = {'C': cyan, '_': italic, '*': bold, 'G': green, 'T': title}[ch](t) return t text = re.sub(r'[|]([a-zA-Z_*]+?) (.+?)[|]', sub, text) @@ -263,18 +263,25 @@ def print_help_for_seq(seq, usage, message, appname): if leading_indent is None: leading_indent = indent j = '\n' + (' ' * indent) - a((' ' * leading_indent) + j.join(wrap(text, limit=linesz - indent))) + lines = [] + for l in text.splitlines(): + if l: + lines.extend(wrap(l, limit=linesz - indent)) + else: + lines.append('') + a((' ' * leading_indent) + j.join(lines)) usage = usage or '[program-to-run ...]' a('{}: {} [options] {}'.format(title('Usage'), bold(yellow(appname)), usage)) a('') message = message or ( - 'Run the |_ {appname}| terminal emulator. You can also specify the |_ program| to run inside |_ {appname}| as normal' + 'Run the |G {appname}| terminal emulator. You can also specify the |_ program| to run inside |_ {appname}| as normal' ' arguments following the |_ options|. For example: {appname} /bin/sh' ).format(appname=appname) wa(prettify(message)) a('') - a('{}:'.format(title('Options'))) + if seq: + a('{}:'.format(title('Options'))) for opt in seq: if isinstance(opt, str): a('{}:'.format(title(opt))) diff --git a/kitty/config.py b/kitty/config.py index 1bcaf5378..d8528953a 100644 --- a/kitty/config.py +++ b/kitty/config.py @@ -256,6 +256,7 @@ url_style.map = dict(((v, i) for i, v in enumerate('none single double curly'.sp type_map = { + 'allow_remote_control': to_bool, 'adjust_line_height': adjust_line_height, 'scrollback_lines': positive_int, 'scrollback_pager': shlex.split, diff --git a/kitty/kitty.conf b/kitty/kitty.conf index 6bb1251fc..e7ee06f18 100644 --- a/kitty/kitty.conf +++ b/kitty/kitty.conf @@ -153,6 +153,13 @@ open_url_with default # rectangular block with the mouse) rectangle_select_modifiers ctrl+alt + +# Allow other programs to control kitty. If you turn this on other programs can +# control all aspects of kitty, including sending text to kitty windows, +# opening new windows, closing windows, reading the content of windows, etc. +# Note that this even works over ssh connections. +allow_remote_control no + # Choose whether to use the system implementation of wcwidth() (used to # control how many cells a character is rendered in). If you use the system # implementation, then kitty and any programs running in it will agree. The diff --git a/kitty/parser.c b/kitty/parser.c index 142c75ed4..29d883277 100644 --- a/kitty/parser.c +++ b/kitty/parser.c @@ -727,6 +727,17 @@ dispatch_csi(Screen *screen, PyObject DUMP_UNUSED *dump_callback) { // }}} // DCS mode {{{ + +static inline bool +startswith(const uint32_t *string, size_t sz, const char *prefix) { + size_t l = strlen(prefix); + if (sz < l) return false; + for (size_t i = 0; i < l; i++) { + if (string[i] != (unsigned char)prefix[i]) return false; + } + return true; +} + static inline void dispatch_dcs(Screen *screen, PyObject DUMP_UNUSED *dump_callback) { if (screen->parser_buf_pos < 2) return; @@ -739,11 +750,23 @@ dispatch_dcs(Screen *screen, PyObject DUMP_UNUSED *dump_callback) { REPORT_OSC2(screen_request_capabilities, (char)screen->parser_buf[0], string); screen_request_capabilities(screen, (char)screen->parser_buf[0], string); Py_DECREF(string); - } + } else PyErr_Clear(); } else { REPORT_ERROR("Unrecognized DCS %c code: 0x%x", (char)screen->parser_buf[0], screen->parser_buf[1]); } break; + case '@': + if (startswith(screen->parser_buf + 1, screen->parser_buf_pos - 2, "kitty-cmd{")) { + PyObject *cmd = PyUnicode_FromKindAndData(PyUnicode_4BYTE_KIND, screen->parser_buf + 11, screen->parser_buf_pos - 11); + if (cmd != NULL) { + REPORT_OSC2(screen_handle_cmd, (char)screen->parser_buf[0], cmd); + screen_handle_cmd(screen, cmd); + Py_DECREF(cmd); + } else PyErr_Clear(); + } else { + REPORT_ERROR("Unrecognized DCS @ code: 0x%x", screen->parser_buf[1]); + } + break; default: REPORT_ERROR("Unrecognized DCS code: 0x%x", screen->parser_buf[0]); break; diff --git a/kitty/remote_control.py b/kitty/remote_control.py new file mode 100644 index 000000000..08626e142 --- /dev/null +++ b/kitty/remote_control.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2018, Kovid Goyal + +import json +import sys +from functools import partial + +from kitty.cli import emph, parse_args +from kitty.constants import appname, version + + +def cmd(short_desc, desc=None, options_spec=None): + + def w(func): + func.short_desc = short_desc + func.desc = desc or short_desc + func.name = func.__name__[4:] + func.options_spec = options_spec + return func + return w + + +def parse_subcommand_cli(func, args): + opts, items = parse_args(args[1:], func.options_spec or '\n'.format, '...', func.desc, '{} @ {}'.format(appname, func.name)) + return opts, items + + +@cmd('List all windows') +def cmd_ls(global_opts, opts, args): + pass + + +global_options_spec = partial('''\ + +'''.format, appname=appname) + + +def main(args): + cmap = {k[4:]: v for k, v in globals().items() if k.startswith('cmd_')} + all_commands = tuple(sorted(cmap)) + cmds = (' |G {}|\n {}'.format(cmap[c].name, cmap[c].short_desc) for c in all_commands) + msg = ( + 'Control {appname} by sending it commands. Add' + ' |_ allow_remote_control yes| to kitty.conf for this' + ' to work.\n\n|T Commands|:\n{cmds}\n\n' + 'You can get help for each individual command by using:\n' + '{appname} @ |_ command| -h' + ).format(appname=appname, cmds='\n'.join(cmds)) + + global_opts, items = parse_args(args[1:], global_options_spec, 'command ...', msg, '{} @'.format(appname)) + + if not items: + raise SystemExit('You must specify a command') + cmd = items[0] + try: + func = cmap[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) + send = { + 'cmd': cmd, + 'version': version, + } + if payload is not None: + send['payload'] = payload + send = ('@kitty-cmd' + json.dumps(send)).encode('ascii') + if not sys.stdout.isatty(): + raise SystemExit('stdout is not a terminal') + sys.stdout.buffer.write(b'\x1bP' + send + b'\x1b\\') + sys.stdout.flush() diff --git a/kitty/screen.c b/kitty/screen.c index 2a5bcb325..524d33f4c 100644 --- a/kitty/screen.c +++ b/kitty/screen.c @@ -1141,6 +1141,11 @@ set_color_table_color(Screen *self, unsigned int code, PyObject *color) { else { CALLBACK("set_color_table_color", "IO", code, color); } } +void +screen_handle_cmd(Screen *self, PyObject *cmd) { + CALLBACK("handle_remote_cmd", "O", cmd); +} + void screen_request_capabilities(Screen *self, char c, PyObject *q) { static char buf[128]; diff --git a/kitty/screen.h b/kitty/screen.h index 5c0c67757..a9686e400 100644 --- a/kitty/screen.h +++ b/kitty/screen.h @@ -106,6 +106,7 @@ void screen_delete_characters(Screen *self, unsigned int count); void screen_erase_characters(Screen *self, unsigned int count); void screen_set_margins(Screen *self, unsigned int top, unsigned int bottom); void screen_change_charset(Screen *, uint32_t to); +void screen_handle_cmd(Screen *, PyObject *cmd); void screen_designate_charset(Screen *, uint32_t which, uint32_t as); void screen_use_latin1(Screen *, bool); void set_title(Screen *self, PyObject*); diff --git a/kitty/window.py b/kitty/window.py index 4d0f0a62d..3c0d71ff2 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -2,6 +2,7 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal +import json import sys import weakref from collections import deque @@ -258,6 +259,12 @@ class Window: def request_capabilities(self, q): self.screen.send_escape_code_to_child(DCS, get_capabilities(q)) + def handle_remote_cmd(self, cmd): + get_boss().handle_remote_cmd(cmd, self) + + def send_cmd_response(self, response): + self.screen.send_escape_code_to_child(DCS, '@kitty-cmd' + json.dumps(response)) + # }}} def text_for_selection(self):