diff --git a/pyte/__init__.py b/pyte/__init__.py index 35d0a98..8b534a9 100644 --- a/pyte/__init__.py +++ b/pyte/__init__.py @@ -22,9 +22,11 @@ :license: LGPL, see LICENSE for more details. """ -__all__ = ("Screen", "DiffScreen", "HistoryScreen", "DebugScreen", +__all__ = ("KeyboardFlags", + "Screen", "DiffScreen", "HistoryScreen", "DebugScreen", "Stream", "ByteStream") +from .keyboard import KeyboardFlags from .screens import Screen, DiffScreen, HistoryScreen, DebugScreen from .streams import Stream, ByteStream diff --git a/pyte/escape.py b/pyte/escape.py index 7dfd6b8..cdfc3b4 100644 --- a/pyte/escape.py +++ b/pyte/escape.py @@ -150,3 +150,8 @@ #: *Horizontal position adjust*: Same as :data:`CHA`. HPA = "'" + +#: *Progressive enhancement event*: Shell queries or sends flags to configure +#: alternative keyboard escape sequences and key codes. +#: see: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement +PE = "u" diff --git a/pyte/keyboard.py b/pyte/keyboard.py new file mode 100644 index 0000000..2e54e50 --- /dev/null +++ b/pyte/keyboard.py @@ -0,0 +1,94 @@ +from __future__ import annotations +from enum import IntFlag + + +class KeyboardFlags(IntFlag): + DEFAULT = 0 + """ + All progressive enhancements disabled + """ + + DISAMBIGUATE_ESCAPE_CODES = 1 + """ + This type of progressive enhancement (0b1) fixes the problem of some legacy + key press encodings overlapping with other control codes. For instance, + pressing the Esc key generates the byte 0x1b which also is used to indicate + the start of an escape code. Similarly pressing the key alt+[ will generate + the bytes used for CSI control codes. + + Turning on this flag will cause the terminal to report the Esc, alt+key, + ctrl+key, ctrl+alt+key, shift+alt+key keys using CSI u sequences instead of + legacy ones. Here key is any ASCII key as described in Legacy text keys. + Additionally, all non text keypad keys will be reported as separate keys + with CSI u encoding, using dedicated numbers from the table below. + + With this flag turned on, all key events that do not generate text are + represented in one of the following two forms: + + .. code: + + CSI number; modifier u + CSI 1; modifier [~ABCDEFHPQS] + + This makes it very easy to parse key events in an application. In + particular, ctrl+c will no longer generate the SIGINT signal, but instead + be delivered as a CSI u escape code. This has the nice side effect of + making it much easier to integrate into the application event loop. The + only exceptions are the Enter, Tab and Backspace keys which still generate + the same bytes as in legacy mode this is to allow the user to type and + execute commands in the shell such as reset after a program that sets this + mode crashes without clearing it. Note that the Lock modifiers are not + reported for text producing keys, to keep them useable in legacy programs. + To get lock modifiers for all keys use the Report all keys as escape codes + enhancement. + """ + + REPORT_EVENT_TYPES = 2 + """ + This progressive enhancement (0b10) causes the terminal to report key + repeat and key release events. Normally only key press events are reported + and key repeat events are treated as key press events. See Event types for + details on how these are reported. + """ + + REPORT_ALTERNATE_KEYS = 4 + """ + This progressive enhancement (0b100) causes the terminal to report + alternate key values in addition to the main value, to aid in shortcut + matching. See Key codes for details on how these are reported. Note that + this flag is a pure enhancement to the form of the escape code used to + represent key events, only key events represented as escape codes due to + the other enhancements in effect will be affected by this enhancement. In + other words, only if a key event was already going to be represented as an + escape code due to one of the other enhancements will this enhancement + affect it. + """ + + REPORT_ALL_KEYS_AS_ESCAPE_CODES = 8 + """ + Key events that generate text, such as plain key presses without modifiers, + result in just the text being sent, in the legacy protocol. There is no way + to be notified of key repeat/release events. These types of events are + needed for some applications, such as games (think of movement using the + WASD keys). + + This progressive enhancement (0b1000) turns on key reporting even for key + events that generate text. When it is enabled, text will not be sent, + instead only key events are sent. If the text is needed as well, combine + with the Report associated text enhancement below. + + Additionally, with this mode, events for pressing modifier keys are + reported. Note that all keys are reported as escape codes, including Enter, + Tab, Backspace etc. Note that this enhancement implies all keys are + automatically disambiguated as well, since they are represented in their + canonical escape code form. + """ + + REPORT_ASSOCIATED_TEXT = 16 + """ + This progressive enhancement (0b10000) additionally causes key events that + generate text to be reported as CSI u escape codes with the text embedded + in the escape code. See Text as code points above for details on the + mechanism. Note that this flag is an enhancement to Report all keys as + escape codes and is undefined if used without it. + """ diff --git a/pyte/screens.py b/pyte/screens.py index 384542f..8558a07 100644 --- a/pyte/screens.py +++ b/pyte/screens.py @@ -45,6 +45,7 @@ graphics as g, modes as mo ) +from .keyboard import KeyboardFlags from .streams import Stream if TYPE_CHECKING: @@ -56,11 +57,13 @@ KT = TypeVar("KT") VT = TypeVar("VT") + class Margins(NamedTuple): """A container for screen's scroll margins.""" top: int bottom: int + class Savepoint(NamedTuple): """A container for savepoint, created on :data:`~pyte.escape.DECSC`.""" cursor: Cursor @@ -226,6 +229,7 @@ def __init__(self, columns: int, lines: int) -> None: self.reset() self.mode = _DEFAULT_MODE.copy() self.margins: Margins | None = None + self._keyboard_flags: list[KeyboardFlags] = [KeyboardFlags.DEFAULT] def __repr__(self) -> str: return ("{}({}, {})".format(self.__class__.__name__, @@ -443,6 +447,58 @@ def reset_mode(self, *modes: int, **kwargs: Any) -> None: if mo.DECTCEM in mode_list: self.cursor.hidden = True + @property + def keyboard_flags(self) -> KeyboardFlags: + """Keyboard flags of current stack level. + + Keyboard flags are to be used by terminal implementations to decide + how to encode keyboard events sent to shell applications. + """ + return self._keyboard_flags[-1] + + def set_keyboard_flags(self, *args: int, private: bool = False, operator: str = "") -> None: + """Handle progressive enhancement events. + + Assign keyboard flags for shells supporting "progressive enhancements". + see: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement + """ + if private: + # CSI ? u + # progressive enhancment state query + # report flags of current stack level + self.write_process_input(str(self.keyboard_flags)) + elif operator == "=": + # assign/set/reset flags + # CSI = u + # CSI = mode u + # CSI = flags ; mode u + flags = KeyboardFlags.DEFAULT if len(args) == 0 else KeyboardFlags(args[0]) + mode = 1 if len(args) < 2 else args[1] + if mode == 1: + # set all set and reset all unset bits + self._keyboard_flags[-1] = KeyboardFlags(flags) + elif mode == 2: + # set all set and retain all unset bits + self._keyboard_flags[-1] = self._keyboard_flags[-1] | flags + elif mode == 3: + # reset all set and retain all unset bits + self._keyboard_flags[-1] = self._keyboard_flags[-1] & ~flags + elif operator == ">": + # push flags onto stack + # CSI > u + # CSI > flags u + flags = KeyboardFlags.DEFAULT if len(args) == 0 else KeyboardFlags(args[0]) + if len(self._keyboard_flags) < 99: + self._keyboard_flags.append(flags) + elif operator == "<": + # pop flags from stack + # CSI < u + # CSI < count u + count = 1 if len(args) == 0 else args[0] + self._keyboard_flags = self._keyboard_flags[:-count] + if len(self._keyboard_flags) == 0: + self._keyboard_flags = [KeyboardFlags.DEFAULT] + def define_charset(self, code: str, mode: str) -> None: """Define ``G0`` or ``G1`` charset. diff --git a/pyte/streams.py b/pyte/streams.py index f728780..b01bbd6 100644 --- a/pyte/streams.py +++ b/pyte/streams.py @@ -122,7 +122,8 @@ class Stream: esc.SGR: "select_graphic_rendition", esc.DSR: "report_device_status", esc.DECSTBM: "set_margins", - esc.HPA: "cursor_to_column" + esc.HPA: "cursor_to_column", + esc.PE: "set_keyboard_flags", } #: A set of all events dispatched by the stream. @@ -322,10 +323,17 @@ def create_dispatcher(mapping: Mapping[str, str]) -> dict[str, Callable[..., Non params = [] current = "" private = False + operator = "" while True: char = yield None if char == "?": private = True + elif char in "<>=": + # may indicate secondary device attribute query + # see: https://vt100.net/docs/vt510-rm/DA2.html + # may be part of progressive enhencement + # see: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement + operator = char elif char in ALLOWED_IN_CSI: basic_dispatch[char]() elif char in SP_OR_GT: @@ -352,6 +360,8 @@ def create_dispatcher(mapping: Mapping[str, str]) -> dict[str, Callable[..., Non else: if private: csi_dispatch[char](*params, private=True) + elif operator: + csi_dispatch[char](*params, operator=operator) else: csi_dispatch[char](*params) break # CSI is finished. diff --git a/tests/test_screen.py b/tests/test_screen.py index 7626f61..cd0a4d5 100644 --- a/tests/test_screen.py +++ b/tests/test_screen.py @@ -4,7 +4,7 @@ import pyte from pyte import modes as mo, control as ctrl, graphics as g -from pyte.screens import Char +from pyte.screens import Char, KeyboardFlags # Test helpers. @@ -1583,3 +1583,36 @@ def test_screen_set_icon_name_title(): screen.set_title(text) assert screen.title == text + + +def test_progressive_enhancements(): + screen = pyte.Screen(10, 1) + assert screen.keyboard_flags == KeyboardFlags.DEFAULT + + # assign flags + screen.set_keyboard_flags(5, operator="=") + assert screen.keyboard_flags == KeyboardFlags.DISAMBIGUATE_ESCAPE_CODES \ + | KeyboardFlags.REPORT_ALTERNATE_KEYS + + # set flags + screen.set_keyboard_flags(16, 2, operator="=") + assert screen.keyboard_flags == KeyboardFlags.DISAMBIGUATE_ESCAPE_CODES \ + | KeyboardFlags.REPORT_ALTERNATE_KEYS | KeyboardFlags.REPORT_ASSOCIATED_TEXT + + # reset flags + screen.set_keyboard_flags(16, 3, operator="=") + assert screen.keyboard_flags == KeyboardFlags.DISAMBIGUATE_ESCAPE_CODES \ + | KeyboardFlags.REPORT_ALTERNATE_KEYS + + # push flags to stack + screen.set_keyboard_flags(16, operator=">") + assert screen.keyboard_flags == KeyboardFlags.REPORT_ASSOCIATED_TEXT + + # pop flags and expect bits from stack level 0 to be reported + screen.set_keyboard_flags(operator="<") + assert screen.keyboard_flags == KeyboardFlags.DISAMBIGUATE_ESCAPE_CODES \ + | KeyboardFlags.REPORT_ALTERNATE_KEYS + + # pop stack level 0, resets flags to default + screen.set_keyboard_flags(operator="<") + assert screen.keyboard_flags == KeyboardFlags.DEFAULT diff --git a/tests/test_stream.py b/tests/test_stream.py index 23eb357..07373e8 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -4,7 +4,7 @@ import pyte from pyte import charsets as cs, control as ctrl, escape as esc - +from pyte import KeyboardFlags class counter: def __init__(self): @@ -332,3 +332,40 @@ def test_byte_stream_select_other_charset(): # c) enable utf-8 stream.select_other_charset("G") assert stream.use_utf8 + + +def test_progressive_enhancements(): + screen = pyte.Screen(10, 1) + stream = pyte.Stream(screen) + assert screen.keyboard_flags == KeyboardFlags.DEFAULT + + # assign flags + stream.feed(ctrl.CSI + "=5u") + assert screen.keyboard_flags == KeyboardFlags.DISAMBIGUATE_ESCAPE_CODES \ + | KeyboardFlags.REPORT_ALTERNATE_KEYS + + # set flags + stream.feed(ctrl.CSI + "=16;2u") + assert screen.keyboard_flags == KeyboardFlags.DISAMBIGUATE_ESCAPE_CODES \ + | KeyboardFlags.REPORT_ALTERNATE_KEYS | KeyboardFlags.REPORT_ASSOCIATED_TEXT + + # reset flags + stream.feed(ctrl.CSI + "=16;3u") + assert screen.keyboard_flags == KeyboardFlags.DISAMBIGUATE_ESCAPE_CODES \ + | KeyboardFlags.REPORT_ALTERNATE_KEYS + + # push flags to stack + stream.feed(ctrl.CSI + ">16u") + assert screen.keyboard_flags == KeyboardFlags.REPORT_ASSOCIATED_TEXT + + # pop flags and expect bits from stack level 0 to be reported + stream.feed(ctrl.CSI + "<1u") + assert screen.keyboard_flags == KeyboardFlags.DISAMBIGUATE_ESCAPE_CODES \ + | KeyboardFlags.REPORT_ALTERNATE_KEYS + + # pop stack level 0, resets flags to default + stream.feed(ctrl.CSI + "