mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
input parsing
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"python.pythonPath": "/Users/willmcgugan/Venvs/textual/bin/python"
|
||||
"python.pythonPath": "/Users/willmcgugan/Venvs/textual/bin/python",
|
||||
"cSpell.words": [
|
||||
"tcgetattr"
|
||||
]
|
||||
}
|
||||
}
|
||||
300
src/textual/_ansi_sequences.py
Normal file
300
src/textual/_ansi_sequences.py
Normal file
@@ -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),
|
||||
}
|
||||
160
src/textual/_linux_driver.py
Normal file
160
src/textual/_linux_driver.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
35
src/textual/_xterm_parser.py
Normal file
35
src/textual/_xterm_parser.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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):
|
||||
|
||||
198
src/textual/keys.py
Normal file
198
src/textual/keys.py
Normal file
@@ -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 = "<any>"
|
||||
|
||||
# Special.
|
||||
ScrollUp = "<scroll-up>"
|
||||
ScrollDown = "<scroll-down>"
|
||||
|
||||
CPRResponse = "<cursor-position-response>"
|
||||
Vt100MouseEvent = "<vt100-mouse-event>"
|
||||
WindowsMouseEvent = "<windowshift+mouse-event>"
|
||||
BracketedPaste = "<bracketed-paste>"
|
||||
|
||||
# For internal use: key which is ignored.
|
||||
# (The key binding for this key should not do anything.)
|
||||
Ignore = "<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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user