Refactor the kittens tui loop to use asyncio

Gets us timers and various jobs for free, and makes it easier to
integrate with libraries that use asyncio from the larger python
ecosystem.
This commit is contained in:
Kovid Goyal 2018-07-15 15:27:35 +05:30
parent 3d0da77c80
commit 0b662ecb9a
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
2 changed files with 97 additions and 151 deletions

View File

@ -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

View File

@ -2,6 +2,7 @@
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
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)