diff --git a/kittens/tui/handler.py b/kittens/tui/handler.py index f388745e5..ac2e78ba7 100644 --- a/kittens/tui/handler.py +++ b/kittens/tui/handler.py @@ -10,14 +10,19 @@ class Handler: image_manager_class = None - def _initialize(self, screen_size, quit_loop, wakeup, start_job, debug, image_manager=None): - self.screen_size, self.quit_loop = screen_size, quit_loop - self.wakeup = wakeup + def _initialize(self, screen_size, term_manager, schedule_write, tui_loop, debug, image_manager=None): + self.screen_size = screen_size + self._term_manager = term_manager + self._tui_loop = tui_loop + self._schedule_write = schedule_write self.debug = debug - self.start_job = start_job self.cmd = commander(self) self.image_manager = image_manager + @property + def asyncio_loop(self): + return self._tui_loop.asycio_loop + def add_shortcut(self, action, key, mods=None, is_text=False): if not hasattr(self, '_text_shortcuts'): self._text_shortcuts, self._key_shortcuts = {}, {} @@ -38,7 +43,6 @@ class Handler: self.initialize() def __exit__(self, *a): - del self.write_buf[:] del self.debug.fobj self.finalize() if self.image_manager is not None: @@ -53,8 +57,11 @@ class Handler: def on_resize(self, screen_size): self.screen_size = screen_size + def quit_loop(self, return_code=None): + self._tui_loop.quit(return_code) + def on_term(self): - self.quit_loop(1) + self._tui_loop.quit(1) def on_text(self, text, in_bracketed_paste=False): pass @@ -71,12 +78,6 @@ class Handler: def on_eot(self): pass - def on_wakeup(self): - pass - - def on_job_done(self, job_id, job_result): - pass - def on_kitty_cmd_response(self, response): pass @@ -86,7 +87,7 @@ class Handler: def write(self, data): if isinstance(data, str): data = data.encode('utf-8') - self.write_buf.append(data) + self._schedule_write(data) def print(self, *args, sep=' ', end='\r\n'): data = sep.join(map(str, args)) + end diff --git a/kittens/tui/loop.py b/kittens/tui/loop.py index 6d63b9bae..6f8b11e5c 100644 --- a/kittens/tui/loop.py +++ b/kittens/tui/loop.py @@ -2,6 +2,7 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2018, Kovid Goyal +import asyncio import codecs import io import os @@ -10,13 +11,12 @@ import selectors import signal import sys from collections import namedtuple -from contextlib import closing, contextmanager +from contextlib import contextmanager from functools import partial -from queue import Empty, Queue +from kitty.constants import is_macos from kitty.fast_data_types import ( - close_tty, normal_tty, open_tty, parse_input_from_terminal, raw_tty, - safe_pipe + close_tty, normal_tty, open_tty, parse_input_from_terminal, raw_tty ) from kitty.key_encoding import ( ALT, CTRL, PRESS, RELEASE, REPEAT, SHIFT, C, D, backspace_key, @@ -74,29 +74,6 @@ class TermManager: del self.tty_fd, self.original_termios -class SignalManager: - - def __init__(self, on_sigwinch, on_sigterm, on_sigint): - self.on_sigwinch = on_sigwinch - self.on_sigterm = on_sigterm - self.on_sigint = on_sigint - - def __enter__(self): - for x in ('winch', 'term', 'int'): - attr = 'on_sig' + x - handler = getattr(self, attr) - old_handler = signal.signal(getattr(signal, 'SIG' + x.upper()), handler) - setattr(self, attr, old_handler) - - def __exit__(self, *a): - for x in ('winch', 'term', 'int'): - attr = 'on_sig' + x - val = getattr(self, attr) - if val is None: - val = signal.SIG_DFL - signal.signal(getattr(signal, 'SIG' + x.upper()), val) - - LEFT, MIDDLE, RIGHT, FOURTH, FIFTH = 1, 2, 4, 8, 16 DRAG = REPEAT MouseEvent = namedtuple('MouseEvent', 'x y type buttons mods') @@ -150,22 +127,38 @@ class UnhandledException(Handler): def on_interrupt(self): self.quit_loop(1) + on_eot = on_term = on_interrupt - def on_eot(self): - self.quit_loop(1) + +class SignalManager: + + def __init__(self, loop, on_winch, on_interrupt, on_term): + self.asycio_loop = loop + self.on_winch, self.on_interrupt, self.on_term = on_winch, on_interrupt, on_term + + def __enter__(self): + tuple(map(lambda x: self.asycio_loop.add_signal_handler(*x), ( + (signal.SIGWINCH, self.on_winch), + (signal.SIGINT, self.on_interrupt), + (signal.SIGTERM, self.on_term) + ))) + + def __exit__(self, *a): + tuple(map(self.asycio_loop.remove_signal_handler, ( + signal.SIGWINCH, signal.SIGINT, signal.SIGTERM))) class Loop: def __init__(self, sanitize_bracketed_paste='[\x03\x04\x0e\x0f\r\x07\x7f\x8d\x8e\x8f\x90\x9b\x9d\x9e\x9f]'): - self.wakeup_read_fd, self.wakeup_write_fd = safe_pipe() - # For some reason on macOS the DefaultSelector fails when tty_fd is - # open('/dev/tty') - self.sel = s = selectors.PollSelector() - s.register(self.wakeup_read_fd, selectors.EVENT_READ) + if is_macos: + # On macOS PTY devices are not supported by the KqueueSelector + self.asycio_loop = asyncio.SelectorEventLoop(selectors.PollSelector()) + asyncio.set_event_loop(self.asycio_loop) + else: + self.asycio_loop = asyncio.get_event_loop() self.return_code = 0 - self.read_allowed = True self.read_buf = '' self.decoder = codecs.getincrementaldecoder('utf-8')('ignore') try: @@ -178,28 +171,8 @@ class Loop: self.sanitize_bracketed_paste = bool(sanitize_bracketed_paste) if self.sanitize_bracketed_paste: self.sanitize_ibp_pat = re.compile(sanitize_bracketed_paste) - self.jobs_queue = Queue() - - def start_job(self, job_id, func, *args, **kw): - from threading import Thread - t = Thread(target=partial(self._run_job, job_id, func), args=args, kwargs=kw, name='LoopJob') - t.daemon = True - t.start() - - def _run_job(self, job_id, func, *args, **kw): - try: - result = func(*args, **kw) - except Exception as err: - import traceback - entry = {'id': job_id, 'exception': err, 'tb': traceback.format_exc()} - else: - entry = {'id': job_id, 'result': result} - self.jobs_queue.put(entry) - self._wakeup_write(b'j') def _read_ready(self, handler, fd): - if not self.read_allowed: - return try: data = os.read(fd, io.DEFAULT_BUFFER_SIZE) except BlockingIOError: @@ -219,6 +192,7 @@ class Loop: finally: del self.handler + # terminal input callbacks {{{ def _on_text(self, text): if self.in_bracketed_paste and self.sanitize_bracketed_paste: text = self.sanitize_ibp_pat.sub('', text) @@ -292,127 +266,98 @@ class Loop: elif apc.startswith('G'): if self.handler.image_manager is not None: self.handler.image_manager.handle_response(apc) + # }}} def _write_ready(self, handler, fd): - if len(handler.write_buf) > self.iov_limit: - handler.write_buf[self.iov_limit - 1] = b''.join(handler.write_buf[self.iov_limit - 1:]) - del handler.write_buf[self.iov_limit:] - sizes = tuple(map(len, handler.write_buf)) + if len(self.write_buf) > self.iov_limit: + self.write_buf[self.iov_limit - 1] = b''.join(self.write_buf[self.iov_limit - 1:]) + del self.write_buf[self.iov_limit:] + sizes = tuple(map(len, self.write_buf)) try: - written = os.writev(fd, handler.write_buf) + written = os.writev(fd, self.write_buf) except BlockingIOError: return if not written: raise EOFError('The output stream is closed') if written >= sum(sizes): - handler.write_buf = [] + self.write_buf = [] + self.asycio_loop.remove_writer(fd) + self.waiting_for_writes = False else: consumed = 0 - for i, buf in enumerate(handler.write_buf): + for i, buf in enumerate(self.write_buf): if not written: break if len(buf) <= written: written -= len(buf) consumed += 1 continue - handler.write_buf[i] = buf[written:] + self.write_buf[i] = buf[written:] break - del handler.write_buf[:consumed] - - def _wakeup_ready(self, handler, fd): - data = os.read(fd, 1024) - if b'r' in data: - self._get_screen_size.changed = True - handler.on_resize(self._get_screen_size()) - if b't' in data: - handler.on_term() - if b'i' in data: - handler.on_interrupt() - if b'j' in data: - while True: - try: - entry = self.jobs_queue.get_nowait() - except Empty: - break - else: - job_id = entry.pop('id') - handler.on_job_done(job_id, entry) - if b'1' in data: - handler.on_wakeup() - - def _wakeup_write(self, val): - while not os.write(self.wakeup_write_fd, val): - pass - - def _on_sigwinch(self, signum, frame): - self._wakeup_write(b'r') - - def _on_sigterm(self, signum, frame): - self._wakeup_write(b't') - - def _on_sigint(self, signum, frame): - self._wakeup_write(b'i') + del self.write_buf[:consumed] def quit(self, return_code=None): - self.read_allowed = False if return_code is not None: self.return_code = return_code + self.asycio_loop.stop() - def wakeup(self): - self._wakeup_write(b'1') + def loop_impl(self, handler, term_manager, image_manager=None): + self.write_buf = [] + tty_fd = term_manager.tty_fd + tb = None + self.waiting_for_writes = True - def _modify_output_selector(self, tty_fd, waiting_for_write): - events = selectors.EVENT_READ - if waiting_for_write: - events |= selectors.EVENT_WRITE - self.sel.modify(tty_fd, events) + def schedule_write(data): + self.write_buf.append(data) + if not self.waiting_for_writes: + self.asycio_loop.add_writer(tty_fd, self._write_ready, handler, tty_fd) + self.waiting_for_writes = True - def loop_impl(self, handler, tty_fd, image_manager=None, waiting_for_write=True): - read_ready, write_ready, wakeup_ready = self._read_ready, self._write_ready, self._wakeup_ready - select = self.sel.select - handler._initialize(self._get_screen_size(), self.quit, self.wakeup, self.start_job, debug, image_manager) + def handle_exception(loop, context): + nonlocal tb + loop.stop() + tb = context['message'] + exc = context.get('exception') + if exc is not None: + import traceback + tb += '\n' + ''.join(traceback.format_exception(exc.__class__, exc, exc.__traceback__)) + + self.asycio_loop.set_exception_handler(handle_exception) + handler._initialize(self._get_screen_size(), term_manager, schedule_write, self, debug, image_manager) with handler: - while True: - has_data_to_write = bool(handler.write_buf) - if not has_data_to_write and not self.read_allowed: - break - if has_data_to_write != waiting_for_write: - waiting_for_write = has_data_to_write - self._modify_output_selector(tty_fd, waiting_for_write) - for key, mask in select(): - fd = key.fd - if fd == tty_fd: - if mask & selectors.EVENT_READ: - read_ready(handler, fd) - if mask & selectors.EVENT_WRITE: - write_ready(handler, fd) - else: - wakeup_ready(handler, fd) + self.asycio_loop.add_reader( + tty_fd, self._read_ready, handler, tty_fd) + self.asycio_loop.add_writer( + tty_fd, self._write_ready, handler, tty_fd) + self.asycio_loop.run_forever() + self.asycio_loop.remove_reader(tty_fd) + if self.waiting_for_writes: + self.asycio_loop.remove_writer(tty_fd) + return tb def loop(self, handler): tb = None - signal_manager = SignalManager(self._on_sigwinch, self._on_sigterm, self._on_sigint) - with closing(self.sel), TermManager() as term_manager, signal_manager: - self.sel.register(term_manager.tty_fd, selectors.EVENT_READ | selectors.EVENT_WRITE) + + def _on_sigwinch(): + self._get_screen_size.changed = True + handler.on_resize(self._get_screen_size()) + + signal_manager = SignalManager(self.asycio_loop, _on_sigwinch, handler.on_interrupt, handler.on_term) + with TermManager() as term_manager, signal_manager: self._get_screen_size = screen_size_function(term_manager.tty_fd) - handler.write_buf = [] - handler._term_manager = term_manager image_manager = None if handler.image_manager_class is not None: image_manager = handler.image_manager_class(handler) try: - self.loop_impl(handler, term_manager.tty_fd, image_manager) + tb = self.loop_impl(handler, term_manager, image_manager) except Exception: import traceback tb = traceback.format_exc() - self.return_code = 1 - term_manager.extra_finalize = b''.join(handler.write_buf).decode('utf-8') + term_manager.extra_finalize = b''.join(self.write_buf).decode('utf-8') if tb is not None: + self.return_code = 1 self._report_error_loop(tb, term_manager) def _report_error_loop(self, tb, term_manager): - handler = UnhandledException(tb) - handler.write_buf = [] - handler._term_manager = term_manager - self.loop_impl(handler, term_manager.tty_fd, waiting_for_write=False) + self.loop_impl(UnhandledException(tb), term_manager)