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:
parent
3d0da77c80
commit
0b662ecb9a
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user