diff --git a/rich-tui.code-workspace b/rich-tui.code-workspace index a5b381852..c660e8cee 100644 --- a/rich-tui.code-workspace +++ b/rich-tui.code-workspace @@ -8,6 +8,9 @@ } ], "settings": { - "python.pythonPath": "/Users/willmcgugan/Venvs/textual/bin/python" + "python.pythonPath": "/Users/willmcgugan/Venvs/textual/bin/python", + "cSpell.words": [ + "tcgetattr" + ] } } \ No newline at end of file diff --git a/src/textual/_ansi_sequences.py b/src/textual/_ansi_sequences.py new file mode 100644 index 000000000..96cabd2e6 --- /dev/null +++ b/src/textual/_ansi_sequences.py @@ -0,0 +1,300 @@ +from typing import Dict, Tuple, Union + +from .keys import Keys + +# Mapping of vt100 escape codes to Keys. +ANSI_SEQUENCES: Dict[str, Union[Keys, Tuple[Keys, ...]]] = { + # Control keys. + "\x00": Keys.ControlAt, # Control-At (Also for Ctrl-Space) + "\x01": Keys.ControlA, # Control-A (home) + "\x02": Keys.ControlB, # Control-B (emacs cursor left) + "\x03": Keys.ControlC, # Control-C (interrupt) + "\x04": Keys.ControlD, # Control-D (exit) + "\x05": Keys.ControlE, # Control-E (end) + "\x06": Keys.ControlF, # Control-F (cursor forward) + "\x07": Keys.ControlG, # Control-G + "\x08": Keys.ControlH, # Control-H (8) (Identical to '\b') + "\x09": Keys.ControlI, # Control-I (9) (Identical to '\t') + "\x0a": Keys.ControlJ, # Control-J (10) (Identical to '\n') + "\x0b": Keys.ControlK, # Control-K (delete until end of line; vertical tab) + "\x0c": Keys.ControlL, # Control-L (clear; form feed) + "\x0d": Keys.ControlM, # Control-M (13) (Identical to '\r') + "\x0e": Keys.ControlN, # Control-N (14) (history forward) + "\x0f": Keys.ControlO, # Control-O (15) + "\x10": Keys.ControlP, # Control-P (16) (history back) + "\x11": Keys.ControlQ, # Control-Q + "\x12": Keys.ControlR, # Control-R (18) (reverse search) + "\x13": Keys.ControlS, # Control-S (19) (forward search) + "\x14": Keys.ControlT, # Control-T + "\x15": Keys.ControlU, # Control-U + "\x16": Keys.ControlV, # Control-V + "\x17": Keys.ControlW, # Control-W + "\x18": Keys.ControlX, # Control-X + "\x19": Keys.ControlY, # Control-Y (25) + "\x1a": Keys.ControlZ, # Control-Z + "\x1b": Keys.Escape, # Also Control-[ + "\x9b": Keys.ShiftEscape, + "\x1c": Keys.ControlBackslash, # Both Control-\ (also Ctrl-| ) + "\x1d": Keys.ControlSquareClose, # Control-] + "\x1e": Keys.ControlCircumflex, # Control-^ + "\x1f": Keys.ControlUnderscore, # Control-underscore (Also for Ctrl-hyphen.) + # ASCII Delete (0x7f) + # Vt220 (and Linux terminal) send this when pressing backspace. We map this + # to ControlH, because that will make it easier to create key bindings that + # work everywhere, with the trade-off that it's no longer possible to + # handle backspace and control-h individually for the few terminals that + # support it. (Most terminals send ControlH when backspace is pressed.) + # See: http://www.ibb.net/~anne/keyboard.html + "\x7f": Keys.ControlH, + # -- + # Various + "\x1b[1~": Keys.Home, # tmux + "\x1b[2~": Keys.Insert, + "\x1b[3~": Keys.Delete, + "\x1b[4~": Keys.End, # tmux + "\x1b[5~": Keys.PageUp, + "\x1b[6~": Keys.PageDown, + "\x1b[7~": Keys.Home, # xrvt + "\x1b[8~": Keys.End, # xrvt + "\x1b[Z": Keys.BackTab, # shift + tab + "\x1b\x09": Keys.BackTab, # Linux console + "\x1b[~": Keys.BackTab, # Windows console + # -- + # Function keys. + "\x1bOP": Keys.F1, + "\x1bOQ": Keys.F2, + "\x1bOR": Keys.F3, + "\x1bOS": Keys.F4, + "\x1b[[A": Keys.F1, # Linux console. + "\x1b[[B": Keys.F2, # Linux console. + "\x1b[[C": Keys.F3, # Linux console. + "\x1b[[D": Keys.F4, # Linux console. + "\x1b[[E": Keys.F5, # Linux console. + "\x1b[11~": Keys.F1, # rxvt-unicode + "\x1b[12~": Keys.F2, # rxvt-unicode + "\x1b[13~": Keys.F3, # rxvt-unicode + "\x1b[14~": Keys.F4, # rxvt-unicode + "\x1b[15~": Keys.F5, + "\x1b[17~": Keys.F6, + "\x1b[18~": Keys.F7, + "\x1b[19~": Keys.F8, + "\x1b[20~": Keys.F9, + "\x1b[21~": Keys.F10, + "\x1b[23~": Keys.F11, + "\x1b[24~": Keys.F12, + "\x1b[25~": Keys.F13, + "\x1b[26~": Keys.F14, + "\x1b[28~": Keys.F15, + "\x1b[29~": Keys.F16, + "\x1b[31~": Keys.F17, + "\x1b[32~": Keys.F18, + "\x1b[33~": Keys.F19, + "\x1b[34~": Keys.F20, + # Xterm + "\x1b[1;2P": Keys.F13, + "\x1b[1;2Q": Keys.F14, + # '\x1b[1;2R': Keys.F15, # Conflicts with CPR response. + "\x1b[1;2S": Keys.F16, + "\x1b[15;2~": Keys.F17, + "\x1b[17;2~": Keys.F18, + "\x1b[18;2~": Keys.F19, + "\x1b[19;2~": Keys.F20, + "\x1b[20;2~": Keys.F21, + "\x1b[21;2~": Keys.F22, + "\x1b[23;2~": Keys.F23, + "\x1b[24;2~": Keys.F24, + # -- + # Control + function keys. + "\x1b[1;5P": Keys.ControlF1, + "\x1b[1;5Q": Keys.ControlF2, + # "\x1b[1;5R": Keys.ControlF3, # Conflicts with CPR response. + "\x1b[1;5S": Keys.ControlF4, + "\x1b[15;5~": Keys.ControlF5, + "\x1b[17;5~": Keys.ControlF6, + "\x1b[18;5~": Keys.ControlF7, + "\x1b[19;5~": Keys.ControlF8, + "\x1b[20;5~": Keys.ControlF9, + "\x1b[21;5~": Keys.ControlF10, + "\x1b[23;5~": Keys.ControlF11, + "\x1b[24;5~": Keys.ControlF12, + "\x1b[1;6P": Keys.ControlF13, + "\x1b[1;6Q": Keys.ControlF14, + # "\x1b[1;6R": Keys.ControlF15, # Conflicts with CPR response. + "\x1b[1;6S": Keys.ControlF16, + "\x1b[15;6~": Keys.ControlF17, + "\x1b[17;6~": Keys.ControlF18, + "\x1b[18;6~": Keys.ControlF19, + "\x1b[19;6~": Keys.ControlF20, + "\x1b[20;6~": Keys.ControlF21, + "\x1b[21;6~": Keys.ControlF22, + "\x1b[23;6~": Keys.ControlF23, + "\x1b[24;6~": Keys.ControlF24, + # -- + # Tmux (Win32 subsystem) sends the following scroll events. + "\x1b[62~": Keys.ScrollUp, + "\x1b[63~": Keys.ScrollDown, + "\x1b[200~": Keys.BracketedPaste, # Start of bracketed paste. + # -- + # Sequences generated by numpad 5. Not sure what it means. (It doesn't + # appear in 'infocmp'. Just ignore. + "\x1b[E": Keys.Ignore, # Xterm. + "\x1b[G": Keys.Ignore, # Linux console. + # -- + # Meta/control/escape + pageup/pagedown/insert/delete. + "\x1b[3;2~": Keys.ShiftDelete, # xterm, gnome-terminal. + "\x1b[5;2~": Keys.ShiftPageUp, + "\x1b[6;2~": Keys.ShiftPageDown, + "\x1b[2;3~": (Keys.Escape, Keys.Insert), + "\x1b[3;3~": (Keys.Escape, Keys.Delete), + "\x1b[5;3~": (Keys.Escape, Keys.PageUp), + "\x1b[6;3~": (Keys.Escape, Keys.PageDown), + "\x1b[2;4~": (Keys.Escape, Keys.ShiftInsert), + "\x1b[3;4~": (Keys.Escape, Keys.ShiftDelete), + "\x1b[5;4~": (Keys.Escape, Keys.ShiftPageUp), + "\x1b[6;4~": (Keys.Escape, Keys.ShiftPageDown), + "\x1b[3;5~": Keys.ControlDelete, # xterm, gnome-terminal. + "\x1b[5;5~": Keys.ControlPageUp, + "\x1b[6;5~": Keys.ControlPageDown, + "\x1b[3;6~": Keys.ControlShiftDelete, + "\x1b[5;6~": Keys.ControlShiftPageUp, + "\x1b[6;6~": Keys.ControlShiftPageDown, + "\x1b[2;7~": (Keys.Escape, Keys.ControlInsert), + "\x1b[5;7~": (Keys.Escape, Keys.ControlPageDown), + "\x1b[6;7~": (Keys.Escape, Keys.ControlPageDown), + "\x1b[2;8~": (Keys.Escape, Keys.ControlShiftInsert), + "\x1b[5;8~": (Keys.Escape, Keys.ControlShiftPageDown), + "\x1b[6;8~": (Keys.Escape, Keys.ControlShiftPageDown), + # -- + # Arrows. + # (Normal cursor mode). + "\x1b[A": Keys.Up, + "\x1b[B": Keys.Down, + "\x1b[C": Keys.Right, + "\x1b[D": Keys.Left, + "\x1b[H": Keys.Home, + "\x1b[F": Keys.End, + # Tmux sends following keystrokes when control+arrow is pressed, but for + # Emacs ansi-term sends the same sequences for normal arrow keys. Consider + # it a normal arrow press, because that's more important. + # (Application cursor mode). + "\x1bOA": Keys.Up, + "\x1bOB": Keys.Down, + "\x1bOC": Keys.Right, + "\x1bOD": Keys.Left, + "\x1bOF": Keys.End, + "\x1bOH": Keys.Home, + # Shift + arrows. + "\x1b[1;2A": Keys.ShiftUp, + "\x1b[1;2B": Keys.ShiftDown, + "\x1b[1;2C": Keys.ShiftRight, + "\x1b[1;2D": Keys.ShiftLeft, + "\x1b[1;2F": Keys.ShiftEnd, + "\x1b[1;2H": Keys.ShiftHome, + # Meta + arrow keys. Several terminals handle this differently. + # The following sequences are for xterm and gnome-terminal. + # (Iterm sends ESC followed by the normal arrow_up/down/left/right + # sequences, and the OSX Terminal sends ESCb and ESCf for "alt + # arrow_left" and "alt arrow_right." We don't handle these + # explicitly, in here, because would could not distinguish between + # pressing ESC (to go to Vi navigation mode), followed by just the + # 'b' or 'f' key. These combinations are handled in + # the input processor.) + "\x1b[1;3A": (Keys.Escape, Keys.Up), + "\x1b[1;3B": (Keys.Escape, Keys.Down), + "\x1b[1;3C": (Keys.Escape, Keys.Right), + "\x1b[1;3D": (Keys.Escape, Keys.Left), + "\x1b[1;3F": (Keys.Escape, Keys.End), + "\x1b[1;3H": (Keys.Escape, Keys.Home), + # Alt+shift+number. + "\x1b[1;4A": (Keys.Escape, Keys.ShiftDown), + "\x1b[1;4B": (Keys.Escape, Keys.ShiftUp), + "\x1b[1;4C": (Keys.Escape, Keys.ShiftRight), + "\x1b[1;4D": (Keys.Escape, Keys.ShiftLeft), + "\x1b[1;4F": (Keys.Escape, Keys.ShiftEnd), + "\x1b[1;4H": (Keys.Escape, Keys.ShiftHome), + # Control + arrows. + "\x1b[1;5A": Keys.ControlUp, # Cursor Mode + "\x1b[1;5B": Keys.ControlDown, # Cursor Mode + "\x1b[1;5C": Keys.ControlRight, # Cursor Mode + "\x1b[1;5D": Keys.ControlLeft, # Cursor Mode + "\x1b[1;5F": Keys.ControlEnd, + "\x1b[1;5H": Keys.ControlHome, + # Tmux sends following keystrokes when control+arrow is pressed, but for + # Emacs ansi-term sends the same sequences for normal arrow keys. Consider + # it a normal arrow press, because that's more important. + "\x1b[5A": Keys.ControlUp, + "\x1b[5B": Keys.ControlDown, + "\x1b[5C": Keys.ControlRight, + "\x1b[5D": Keys.ControlLeft, + "\x1bOc": Keys.ControlRight, # rxvt + "\x1bOd": Keys.ControlLeft, # rxvt + # Control + shift + arrows. + "\x1b[1;6A": Keys.ControlShiftDown, + "\x1b[1;6B": Keys.ControlShiftUp, + "\x1b[1;6C": Keys.ControlShiftRight, + "\x1b[1;6D": Keys.ControlShiftLeft, + "\x1b[1;6F": Keys.ControlShiftEnd, + "\x1b[1;6H": Keys.ControlShiftHome, + # Control + Meta + arrows. + "\x1b[1;7A": (Keys.Escape, Keys.ControlDown), + "\x1b[1;7B": (Keys.Escape, Keys.ControlUp), + "\x1b[1;7C": (Keys.Escape, Keys.ControlRight), + "\x1b[1;7D": (Keys.Escape, Keys.ControlLeft), + "\x1b[1;7F": (Keys.Escape, Keys.ControlEnd), + "\x1b[1;7H": (Keys.Escape, Keys.ControlHome), + # Meta + Shift + arrows. + "\x1b[1;8A": (Keys.Escape, Keys.ControlShiftDown), + "\x1b[1;8B": (Keys.Escape, Keys.ControlShiftUp), + "\x1b[1;8C": (Keys.Escape, Keys.ControlShiftRight), + "\x1b[1;8D": (Keys.Escape, Keys.ControlShiftLeft), + "\x1b[1;8F": (Keys.Escape, Keys.ControlShiftEnd), + "\x1b[1;8H": (Keys.Escape, Keys.ControlShiftHome), + # Meta + arrow on (some?) Macs when using iTerm defaults (see issue #483). + "\x1b[1;9A": (Keys.Escape, Keys.Up), + "\x1b[1;9B": (Keys.Escape, Keys.Down), + "\x1b[1;9C": (Keys.Escape, Keys.Right), + "\x1b[1;9D": (Keys.Escape, Keys.Left), + # -- + # Control/shift/meta + number in mintty. + # (c-2 will actually send c-@ and c-6 will send c-^.) + "\x1b[1;5p": Keys.Control0, + "\x1b[1;5q": Keys.Control1, + "\x1b[1;5r": Keys.Control2, + "\x1b[1;5s": Keys.Control3, + "\x1b[1;5t": Keys.Control4, + "\x1b[1;5u": Keys.Control5, + "\x1b[1;5v": Keys.Control6, + "\x1b[1;5w": Keys.Control7, + "\x1b[1;5x": Keys.Control8, + "\x1b[1;5y": Keys.Control9, + "\x1b[1;6p": Keys.ControlShift0, + "\x1b[1;6q": Keys.ControlShift1, + "\x1b[1;6r": Keys.ControlShift2, + "\x1b[1;6s": Keys.ControlShift3, + "\x1b[1;6t": Keys.ControlShift4, + "\x1b[1;6u": Keys.ControlShift5, + "\x1b[1;6v": Keys.ControlShift6, + "\x1b[1;6w": Keys.ControlShift7, + "\x1b[1;6x": Keys.ControlShift8, + "\x1b[1;6y": Keys.ControlShift9, + "\x1b[1;7p": (Keys.Escape, Keys.Control0), + "\x1b[1;7q": (Keys.Escape, Keys.Control1), + "\x1b[1;7r": (Keys.Escape, Keys.Control2), + "\x1b[1;7s": (Keys.Escape, Keys.Control3), + "\x1b[1;7t": (Keys.Escape, Keys.Control4), + "\x1b[1;7u": (Keys.Escape, Keys.Control5), + "\x1b[1;7v": (Keys.Escape, Keys.Control6), + "\x1b[1;7w": (Keys.Escape, Keys.Control7), + "\x1b[1;7x": (Keys.Escape, Keys.Control8), + "\x1b[1;7y": (Keys.Escape, Keys.Control9), + "\x1b[1;8p": (Keys.Escape, Keys.ControlShift0), + "\x1b[1;8q": (Keys.Escape, Keys.ControlShift1), + "\x1b[1;8r": (Keys.Escape, Keys.ControlShift2), + "\x1b[1;8s": (Keys.Escape, Keys.ControlShift3), + "\x1b[1;8t": (Keys.Escape, Keys.ControlShift4), + "\x1b[1;8u": (Keys.Escape, Keys.ControlShift5), + "\x1b[1;8v": (Keys.Escape, Keys.ControlShift6), + "\x1b[1;8w": (Keys.Escape, Keys.ControlShift7), + "\x1b[1;8x": (Keys.Escape, Keys.ControlShift8), + "\x1b[1;8y": (Keys.Escape, Keys.ControlShift9), +} diff --git a/src/textual/_linux_driver.py b/src/textual/_linux_driver.py new file mode 100644 index 000000000..ff06c4b74 --- /dev/null +++ b/src/textual/_linux_driver.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import asyncio +import os +from codecs import getincrementaldecoder +import selectors +import sys +import logging +import termios +import tty +from typing import Any, TYPE_CHECKING +from threading import Event, Thread + +if TYPE_CHECKING: + from rich.console import Console + + +from . import events +from .driver import Driver +from ._types import MessageTarget +from ._xterm_parser import XTermParser + + +log = logging.getLogger("rich") + + +class LinuxDriver(Driver): + def __init__(self, console: "Console", target: "MessageTarget") -> None: + super().__init__(console, target) + self.fileno = sys.stdin.fileno() + self.attrs_before: list[Any] | None = None + self.exit_event = Event() + self._key_thread: Thread | None = None + + def enable_mouse_support(self) -> None: + write = self.console.file.write + write("\x1b[?1000h") + write("\x1b[?1015h") + write("\x1b[?1006h") + self.console.file.flush() + + # Note: E.g. lxterminal understands 1000h, but not the urxvt or sgr + # extensions. + + def disable_mouse_support(self) -> None: + write = self.console.file.write + write("\x1b[?1000l") + write("\x1b[?1015l") + write("\x1b[?1006l") + self.console.file.flush() + + def start_application_mode(self): + self.console.set_alt_screen(True) + self.enable_mouse_support() + try: + self.attrs_before = termios.tcgetattr(self.fileno) + except termios.error: + # Ignore attribute errors. + self.attrs_before = None + + try: + newattr = termios.tcgetattr(self.fileno) + except termios.error: + pass + else: + + newattr[tty.LFLAG] = self._patch_lflag(newattr[tty.LFLAG]) + newattr[tty.IFLAG] = self._patch_iflag(newattr[tty.IFLAG]) + + # VMIN defines the number of characters read at a time in + # non-canonical mode. It seems to default to 1 on Linux, but on + # Solaris and derived operating systems it defaults to 4. (This is + # because the VMIN slot is the same as the VEOF slot, which + # defaults to ASCII EOT = Ctrl-D = 4.) + newattr[tty.CC][termios.VMIN] = 1 + + termios.tcsetattr(self.fileno, termios.TCSANOW, newattr) + + self.console.show_cursor(False) + self.console.file.write("\033[?1003h\n") + + self._key_thread = Thread( + target=self.run_input_thread, args=(asyncio.get_event_loop(),) + ) + self._key_thread.start() + + @classmethod + def _patch_lflag(cls, attrs: int) -> int: + return attrs & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG) + + @classmethod + def _patch_iflag(cls, attrs: int) -> int: + return attrs & ~( + # Disable XON/XOFF flow control on output and input. + # (Don't capture Ctrl-S and Ctrl-Q.) + # Like executing: "stty -ixon." + termios.IXON + | termios.IXOFF + | + # Don't translate carriage return into newline on input. + termios.ICRNL + | termios.INLCR + | termios.IGNCR + ) + + def stop_application_mode(self) -> None: + self.console.set_alt_screen(False) + self.console.show_cursor(True) + + self.exit_event.set() + # if self._key_thread is not None: + # self._key_thread.join() + if self.attrs_before is not None: + try: + termios.tcsetattr(self.fileno, termios.TCSANOW, self.attrs_before) + except termios.error: + pass + self.disable_mouse_support() + + def run_input_thread(self, loop) -> None: + def send_event(event: events.Event) -> None: + asyncio.run_coroutine_threadsafe( + self._target.post_message(event), + loop=loop, + ) + + selector = selectors.DefaultSelector() + selector.register(self.fileno, selectors.EVENT_READ) + + fileno = self.fileno + parser = XTermParser() + + utf8_decoder = getincrementaldecoder("utf-8")().decode + decode = utf8_decoder + read = os.read + + log.debug("started key thread") + while not self.exit_event.is_set(): + selector_events = selector.select(0.1) + for _selector_key, mask in selector_events: + unicode_data = decode(read(fileno, 1024)) + log.debug(repr(unicode_data)) + for key in parser.feed(unicode_data): + send_event(events.Key(self._target, key=key)) + + +if __name__ == "__main__": + from time import sleep + from rich.console import Console + from . import events + + console = Console() + + from .app import App + + class MyApp(App): + async def on_startup(self, event: events.Startup) -> None: + self.set_timer(5, callback=self.close_messages) + + MyApp.run() diff --git a/src/textual/_parser.py b/src/textual/_parser.py index ebbf9f67f..7754865bd 100644 --- a/src/textual/_parser.py +++ b/src/textual/_parser.py @@ -1,4 +1,5 @@ from collections import deque +import io from typing import ( Callable, Deque, @@ -26,7 +27,7 @@ class Awaitable: """Raise any ParseErrors""" -class _ReadBytes(Awaitable): +class _Read(Awaitable): __slots__ = ["remaining"] def __init__(self, count: int) -> None: @@ -36,14 +37,31 @@ class _ReadBytes(Awaitable): return f"_ReadBytes({self.remaining})" +class _Read1(Awaitable): + __slots__: list[str] = [] + + +class _ReadUntil(Awaitable): + __slots__ = ["sep", "max_bytes"] + + def __init__(self, sep, max_bytes=None): + self.sep = sep + self.max_bytes = max_bytes + + T = TypeVar("T") +TokenCallback = Callable[[T], None] + + class Parser(Generic[T]): - read = _ReadBytes + read = _Read + read1 = _Read1 + read_until = _ReadUntil def __init__(self) -> None: - self._buffer = bytearray() + self._buffer = io.StringIO() self._eof = False self._tokens: Deque[T] = deque() self._gen = self.parse(self._tokens.append) @@ -57,62 +75,92 @@ class Parser(Generic[T]): self._gen = self.parse(self._tokens.append) self._awaiting = next(self._gen) - def feed(self, data: bytes) -> Iterable[T]: + def feed(self, data: str) -> Iterable[T]: + if self._eof: - raise ParseError("end of file reached") + raise ParseError("end of file reached") from None if not data: self._eof = True try: - self._gen.send(self._buffer[:]) + self._gen.send(self._buffer.getvalue()) except StopIteration: raise ParseError("end of file reached") from None while self._tokens: yield self._tokens.popleft() - del self._buffer[:] + self._buffer.truncate(0) return - # self._gen.throw(ParseError("unexpected eof of file")) _buffer = self._buffer pos = 0 + tokens = self._tokens + popleft = tokens.popleft + + while tokens: + yield popleft() + while pos < len(data): - if isinstance(self._awaiting, _ReadBytes): + _awaiting = self._awaiting + if isinstance(_awaiting, _Read1): + self._awaiting = self._gen.send(data[pos : pos + 1]) + pos += 1 - remaining = self._awaiting.remaining + elif isinstance(_awaiting, _Read): + remaining = _awaiting.remaining chunk = data[pos : pos + remaining] chunk_size = len(chunk) pos += chunk_size - try: - self._awaiting.validate(chunk) - except ParseError as error: - self._awaiting = self._gen.throw(error) - continue - _buffer.extend(chunk) + _buffer.write(chunk) remaining -= chunk_size if remaining: - self._awaiting.remaining = remaining + _awaiting.remaining = remaining else: - self._awaiting = self._gen.send(_buffer[:]) - del _buffer[:] + _awaiting = self._gen.send(_buffer.getvalue()) + _buffer.truncate(0) - while self._tokens: - yield self._tokens.popleft() + elif isinstance(_awaiting, _ReadUntil): + chunk = data[pos:] + _buffer.write(chunk) + sep = _awaiting.sep + sep_index = _buffer.getvalue().find(sep) - def parse(self, on_token: Callable[[T], None]) -> Generator[Awaitable, bytes, None]: + if sep_index == -1: + pos += len(chunk) + if ( + _awaiting.max_bytes is not None + and _buffer.tell() > _awaiting.max_bytes + ): + self._gen.throw(ParseError(f"expected {sep}")) + else: + sep_index += len(sep) + if ( + _awaiting.max_bytes is not None + and sep_index > _awaiting.max_bytes + ): + self._gen.throw(ParseError(f"expected {sep}")) + data = _buffer.getvalue()[sep_index:] + pos = 0 + self._awaiting = self._gen.send(_buffer.getvalue()[:sep_index]) + _buffer.truncate(0) + + while tokens: + yield popleft() + + def parse(self, on_token: Callable[[T], None]) -> Generator[Awaitable, T, None]: return yield if __name__ == "__main__": - data = b"Where there is a Will there is a way!" + data = "Where there is a Will there is a way!" - class TestParser(Parser[bytes]): + class TestParser(Parser[str]): def parse( - self, on_token: Callable[[bytes], None] - ) -> Generator[Awaitable, bytes, None]: - while data := (yield self.read(3)): - print("-", data) + self, on_token: Callable[[str], None] + ) -> Generator[Awaitable, str, None]: + on_token((yield self.read_until("a"))) + while data := (yield self.read1()): on_token(data) test_parser = TestParser() @@ -121,6 +169,6 @@ if __name__ == "__main__": for n in range(0, len(data), 2): for token in test_parser.feed(data[n : n + 2]): - print(bytes(token)) - for token in test_parser.feed(b""): - print(bytes(token)) + print(token) + for token in test_parser.feed(""): + print(token) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py new file mode 100644 index 000000000..bae38cdce --- /dev/null +++ b/src/textual/_xterm_parser.py @@ -0,0 +1,35 @@ +from typing import Iterable, Generator + +from .keys import Keys +from ._parser import Awaitable, Parser, TokenCallback +from ._ansi_sequences import ANSI_SEQUENCES + + +class XTermParser(Parser[Keys]): + def parse(self, on_token: TokenCallback) -> Generator[Awaitable, Keys, None]: + + ESC = "\x1b" + read1 = self.read1 + get_ansi_sequence = ANSI_SEQUENCES.get + while not self.is_eof: + character: str = yield read1() + if character == ESC: + sequence = character + while True: + sequence += yield read1() + keys = get_ansi_sequence(sequence, None) + if keys is not None: + on_token(keys) + break + else: + on_token(character) + + +if __name__ == "__main__": + parser = XTermParser() + + import os + import sys + + for token in parser.feed(sys.stdin.read(20)): + print(token) diff --git a/src/textual/app.py b/src/textual/app.py index e807bad05..3e3e37226 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -4,7 +4,7 @@ import asyncio import logging import signal -from typing import Any, ClassVar +from typing import Any, ClassVar, Type from rich.control import Control from rich.repr import rich_repr, RichReprResult @@ -14,7 +14,8 @@ from rich.console import Console from . import events from ._context import active_app -from .driver import Driver, CursesDriver +from .driver import Driver +from ._linux_driver import LinuxDriver from .message_pump import MessagePump from .view import View, LayoutView @@ -33,13 +34,15 @@ class App(MessagePump): def __init__( self, console: Console = None, - view: View = None, screen: bool = True, + driver: Type[Driver] = None, + view: View = None, title: str = "Megasoma Application", ): super().__init__() self.console = console or get_console() self._screen = screen + self.driver = driver or LinuxDriver self.title = title self.view = view or LayoutView() self.children: set[MessagePump] = set() @@ -48,9 +51,11 @@ class App(MessagePump): yield "title", self.title @classmethod - def run(cls, console: Console = None, screen: bool = True): + def run( + cls, console: Console = None, screen: bool = True, driver: Type[Driver] = None + ): async def run_app() -> None: - app = cls(console=console, screen=screen) + app = cls(console=console, screen=screen, driver=driver) await app.process_messages() asyncio.run(run_app()) @@ -61,9 +66,15 @@ class App(MessagePump): asyncio.run_coroutine_threadsafe(self.post_message(event), loop=loop) async def process_messages(self) -> None: + log.debug("driver=%r", self.driver) loop = asyncio.get_event_loop() - driver = CursesDriver(self.console, self) - driver.start_application_mode() + driver = self.driver(self.console, self) + try: + driver.start_application_mode() + except Exception: + log.exception("error starting application mode") + raise + loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interupt) active_app.set(self) diff --git a/src/textual/driver.py b/src/textual/driver.py index b1af8af83..3729f2f4a 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from abc import ABC, abstractmethod import asyncio import logging @@ -28,14 +30,22 @@ class Driver(ABC): self._target = target @abstractmethod - def start_application_mode(self): + def start_application_mode(self) -> None: ... @abstractmethod - def stop_application_mode(self): + def stop_application_mode(self) -> None: ... +class LinuxDriver(Driver): + def start_application_mode(self): + pass + + def stop_application_mode(self): + pass + + class CursesDriver(Driver): _MOUSE_PRESSED = [ diff --git a/src/textual/events.py b/src/textual/events.py index 7405af3fc..88da3e671 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -10,6 +10,7 @@ from rich.repr import rich_repr, RichReprResult from .message import Message from ._types import Callback, MessageTarget +from .keys import Keys if TYPE_CHECKING: @@ -122,20 +123,15 @@ class Shutdown(Event, type=EventType.SHUTDOWN): @rich_repr class Key(Event, type=EventType.KEY, bubble=True): - __slots__ = ["code"] + __slots__ = ["key"] - def __init__(self, sender: MessageTarget, code: int) -> None: + def __init__(self, sender: MessageTarget, key: Keys | str) -> None: super().__init__(sender) - self.code = code + self.key = key.value if isinstance(key, Keys) else key def __rich_repr__(self) -> RichReprResult: - yield "code", self.code yield "key", self.key - @property - def key(self) -> str: - return chr(self.code) - @rich_repr class Move(Event, type=EventType.MOVE): diff --git a/src/textual/keys.py b/src/textual/keys.py new file mode 100644 index 000000000..f7957bc9f --- /dev/null +++ b/src/textual/keys.py @@ -0,0 +1,198 @@ +from enum import Enum + +# Adapted from prompt toolkit https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/keys.py + + +class Keys(str, Enum): + """ + List of keys for use in key bindings. + + Note that this is an "StrEnum", all values can be compared against + strings. + """ + + value: str + + Escape = "escape" # Also Control-[ + ShiftEscape = "shift+escape" + + ControlAt = "ctrl+@" # Also Control-Space. + + ControlA = "ctrl+a" + ControlB = "ctrl+b" + ControlC = "ctrl+c" + ControlD = "ctrl+d" + ControlE = "ctrl+e" + ControlF = "ctrl+f" + ControlG = "ctrl+g" + ControlH = "ctrl+h" + ControlI = "ctrl+i" # Tab + ControlJ = "ctrl+j" # Newline + ControlK = "ctrl+k" + ControlL = "ctrl+l" + ControlM = "ctrl+m" # Carriage return + ControlN = "ctrl+n" + ControlO = "ctrl+o" + ControlP = "ctrl+p" + ControlQ = "ctrl+q" + ControlR = "ctrl+r" + ControlS = "ctrl+s" + ControlT = "ctrl+t" + ControlU = "ctrl+u" + ControlV = "ctrl+v" + ControlW = "ctrl+w" + ControlX = "ctrl+x" + ControlY = "ctrl+y" + ControlZ = "ctrl+z" + + Control1 = "ctrl+1" + Control2 = "ctrl+2" + Control3 = "ctrl+3" + Control4 = "ctrl+4" + Control5 = "ctrl+5" + Control6 = "ctrl+6" + Control7 = "ctrl+7" + Control8 = "ctrl+8" + Control9 = "ctrl+9" + Control0 = "ctrl+0" + + ControlShift1 = "ctrl+shift+1" + ControlShift2 = "ctrl+shift+2" + ControlShift3 = "ctrl+shift+3" + ControlShift4 = "ctrl+shift+4" + ControlShift5 = "ctrl+shift+5" + ControlShift6 = "ctrl+shift+6" + ControlShift7 = "ctrl+shift+7" + ControlShift8 = "ctrl+shift+8" + ControlShift9 = "ctrl+shift+9" + ControlShift0 = "ctrl+shift+0" + + ControlBackslash = "ctrl+\\" + ControlSquareClose = "ctrl+]" + ControlCircumflex = "ctrl+^" + ControlUnderscore = "ctrl+_" + + Left = "left" + Right = "right" + Up = "up" + Down = "down" + Home = "home" + End = "end" + Insert = "insert" + Delete = "delete" + PageUp = "pageup" + PageDown = "pagedown" + + ControlLeft = "ctrl+left" + ControlRight = "ctrl+right" + ControlUp = "ctrl+up" + ControlDown = "ctrl+down" + ControlHome = "ctrl+home" + ControlEnd = "ctrl+end" + ControlInsert = "ctrl+insert" + ControlDelete = "ctrl+delete" + ControlPageUp = "ctrl+pageup" + ControlPageDown = "ctrl+pagedown" + + ShiftLeft = "shift+left" + ShiftRight = "shift+right" + ShiftUp = "shift+up" + ShiftDown = "shift+down" + ShiftHome = "shift+home" + ShiftEnd = "shift+end" + ShiftInsert = "shift+insert" + ShiftDelete = "shift+delete" + ShiftPageUp = "shift+pageup" + ShiftPageDown = "shift+pagedown" + + ControlShiftLeft = "ctrl+shift+left" + ControlShiftRight = "ctrl+shift+right" + ControlShiftUp = "ctrl+shift+up" + ControlShiftDown = "ctrl+shift+down" + ControlShiftHome = "ctrl+shift+home" + ControlShiftEnd = "ctrl+shift+end" + ControlShiftInsert = "ctrl+shift+insert" + ControlShiftDelete = "ctrl+shift+delete" + ControlShiftPageUp = "ctrl+shift+pageup" + ControlShiftPageDown = "ctrl+shift+pagedown" + + BackTab = "shift+tab" # shift + tab + + F1 = "f1" + F2 = "f2" + F3 = "f3" + F4 = "f4" + F5 = "f5" + F6 = "f6" + F7 = "f7" + F8 = "f8" + F9 = "f9" + F10 = "f10" + F11 = "f11" + F12 = "f12" + F13 = "f13" + F14 = "f14" + F15 = "f15" + F16 = "f16" + F17 = "f17" + F18 = "f18" + F19 = "f19" + F20 = "f20" + F21 = "f21" + F22 = "f22" + F23 = "f23" + F24 = "f24" + + ControlF1 = "ctrl+f1" + ControlF2 = "ctrl+f2" + ControlF3 = "ctrl+f3" + ControlF4 = "ctrl+f4" + ControlF5 = "ctrl+f5" + ControlF6 = "ctrl+f6" + ControlF7 = "ctrl+f7" + ControlF8 = "ctrl+f8" + ControlF9 = "ctrl+f9" + ControlF10 = "ctrl+f10" + ControlF11 = "ctrl+f11" + ControlF12 = "ctrl+f12" + ControlF13 = "ctrl+f13" + ControlF14 = "ctrl+f14" + ControlF15 = "ctrl+f15" + ControlF16 = "ctrl+f16" + ControlF17 = "ctrl+f17" + ControlF18 = "ctrl+f18" + ControlF19 = "ctrl+f19" + ControlF20 = "ctrl+f20" + ControlF21 = "ctrl+f21" + ControlF22 = "ctrl+f22" + ControlF23 = "ctrl+f23" + ControlF24 = "ctrl+f24" + + # Matches any key. + Any = "" + + # Special. + ScrollUp = "" + ScrollDown = "" + + CPRResponse = "" + Vt100MouseEvent = "" + WindowsMouseEvent = "" + BracketedPaste = "" + + # For internal use: key which is ignored. + # (The key binding for this key should not do anything.) + Ignore = "" + + # Some 'Key' aliases (for backwardshift+compatibility). + ControlSpace = ControlAt + Tab = ControlI + Enter = ControlM + Backspace = ControlH + + # ShiftControl was renamed to ControlShift in + # 888fcb6fa4efea0de8333177e1bbc792f3ff3c24 (20 Feb 2020). + ShiftControlLeft = ControlShiftLeft + ShiftControlRight = ControlShiftRight + ShiftControlHome = ControlShiftHome + ShiftControlEnd = ControlShiftEnd diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 044e6d698..f9b70bc72 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -147,6 +147,7 @@ class MessagePump: except Exception: log.exception("error getting message") break + log.debug("%r -> %r", message, self) # Combine any pending messages that may supersede this one while True: pending = self.peek_message()