From 28f03e231e3256f4478b1ca59ae13f66cb410453 Mon Sep 17 00:00:00 2001 From: deathaxe Date: Fri, 18 Apr 2025 12:37:15 +0200 Subject: [PATCH 1/5] Add support for keypad mode escape sequences This commit adds support for `DECKPAM` and `DECKPNM` escape sequences, to set keypad mode. If such escape sequence is found `Stream` emits `Screen.set_keypad_mode()`, which sets `Screen.keypad_mode` flag to one of `KeypadMode` enum values. An enumeration is used to provide more expressive values for type checking as a boolean would be able to express. Related specification: - https://vt100.net/docs/vt510-rm/DECKPAM.html - https://vt100.net/docs/vt510-rm/DECKPNM.html --- pyte/__init__.py | 4 ++-- pyte/escape.py | 11 +++++++++++ pyte/screens.py | 31 +++++++++++++++++++++++++++++++ pyte/streams.py | 7 +++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/pyte/__init__.py b/pyte/__init__.py index e004680..02b41a0 100644 --- a/pyte/__init__.py +++ b/pyte/__init__.py @@ -22,13 +22,13 @@ :license: LGPL, see LICENSE for more details. """ -__all__ = ("Screen", "DiffScreen", "HistoryScreen", "DebugScreen", +__all__ = ("Screen", "DiffScreen", "HistoryScreen", "DebugScreen", "KeypadMode", "Stream", "ByteStream") import io from typing import Union -from .screens import Screen, DiffScreen, HistoryScreen, DebugScreen +from .screens import Screen, DiffScreen, HistoryScreen, DebugScreen, KeypadMode from .streams import Stream, ByteStream diff --git a/pyte/escape.py b/pyte/escape.py index 7dfd6b8..1aa4ca8 100644 --- a/pyte/escape.py +++ b/pyte/escape.py @@ -11,6 +11,9 @@ :license: LGPL, see LICENSE for more details. """ +# non-CSI escape sequences. +# ------------------------- + #: *Reset*. RIS = "c" @@ -38,6 +41,14 @@ #: selection. If none were saved, move cursor to home position. DECRC = "8" +#: *Set keypad application mode*: Keypad sends control sequences (NUMLOCK off) +#: see: https://vt100.net/docs/vt510-rm/DECKPAM.html +DECKPAM = "=" + +#: *Set keypad numeric mode*: Keypad sends numbers (NUMLOCK ON). +#: see: https://vt100.net/docs/vt510-rm/DECKPNM.html +DECKPNM = ">" + # "Sharp" escape sequences. # ------------------------- diff --git a/pyte/screens.py b/pyte/screens.py index 7145a7b..c0d25e2 100644 --- a/pyte/screens.py +++ b/pyte/screens.py @@ -34,6 +34,7 @@ import unicodedata import warnings from collections import deque, defaultdict +from enum import IntEnum from functools import lru_cache from typing import Any, Dict, List, NamedTuple, Optional, Set, TextIO, TypeVar from collections.abc import Callable, Generator, Sequence @@ -43,6 +44,7 @@ from . import ( charsets as cs, control as ctrl, + escape as esc, graphics as g, modes as mo ) @@ -53,11 +55,21 @@ KT = TypeVar("KT") VT = TypeVar("VT") + +class KeypadMode(IntEnum): + """Supported keypad modes""" + NUMERIC = 0 + """Keypad sends numbers (default).""" + APPLICATION = 1 + """Keypad sends control sequences.""" + + 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 @@ -274,6 +286,8 @@ def reset(self) -> None: self.g0_charset = cs.LAT1_MAP self.g1_charset = cs.VT100_MAP + self.keypad_mode: KeypadMode = KeypadMode.NUMERIC + # From ``man terminfo`` -- "... hardware tabs are initially # set every `n` spaces when the terminal is powered up. Since # we aim to support VT102 / VT220 and linux -- we use n = 8. @@ -440,6 +454,23 @@ def reset_mode(self, *modes: int, **kwargs: Any) -> None: if mo.DECTCEM in mode_list: self.cursor.hidden = True + def set_keypad_mode(self, mode: str) -> None: + """Set keypad mode + + DECKPAM enables the keypad to send application sequences. + DECKPNM enables the keypad to send numeric characters to the host. + + Default: Send numeric keypad characters. + + :param: mode + ``esc.DECKPAM`` - application mode + ``esc.DECKPNM`` - numeric mode + """ + if mode == esc.DECKPAM: + self.keypad_mode = KeypadMode.APPLICATION + else: + self.keypad_mode = KeypadMode.NUMERIC + 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 401e814..6f04c2a 100644 --- a/pyte/streams.py +++ b/pyte/streams.py @@ -129,6 +129,7 @@ class Stream: events = frozenset(itertools.chain( basic.values(), escape.values(), sharp.values(), csi.values(), ["define_charset"], + ["set_keypad_mode"], ["set_icon_name", "set_title"], # OSC. ["draw", "debug"])) @@ -239,6 +240,7 @@ def _parser_fsm(self) -> ParserGenerator: debug = listener.debug ESC, CSI_C1 = ctrl.ESC, ctrl.CSI_C1 + KEYPAD_MODE = esc.DECKPAM + esc.DECKPNM OSC_C1 = ctrl.OSC_C1 SP_OR_GT = ctrl.SP + ">" NUL_OR_DEL = ctrl.NUL + ctrl.DEL @@ -293,6 +295,11 @@ def create_dispatcher(mapping: Mapping[str, str]) -> dict[str, Callable[..., Non # See http://www.cl.cam.ac.uk/~mgk25/unicode.html#term # for the why on the UTF-8 restriction. listener.define_charset(code, mode=char) + elif char in KEYPAD_MODE: + # See https://vt100.net/docs/vt510-rm/DECKPAM.html + # DECKPAM enables the keypad to send application sequences. + # DECKPNM enables the keypad to send numeric characters to the host. + listener.set_keypad_mode(mode=char) else: escape_dispatch[char]() continue # Don't go to CSI. From dd675ca0b28e6c7edb45cfaf745e969e9c69c4a4 Mon Sep 17 00:00:00 2001 From: deathaxe Date: Fri, 18 Apr 2025 14:06:55 +0200 Subject: [PATCH 2/5] Add unittests --- tests/test_screen.py | 24 +++++++++++++++++++++++- tests/test_stream.py | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/tests/test_screen.py b/tests/test_screen.py index 7626f61..af0924f 100644 --- a/tests/test_screen.py +++ b/tests/test_screen.py @@ -3,7 +3,7 @@ import pytest import pyte -from pyte import modes as mo, control as ctrl, graphics as g +from pyte import escape as esc, modes as mo, control as ctrl, graphics as g from pyte.screens import Char @@ -301,6 +301,28 @@ def test_set_mode(): assert screen.cursor.hidden +def test_set_keypad_mode(): + screen = pyte.Screen(2, 1) + # test numeric mode being the default + assert screen.keypad_mode == pyte.KeypadMode.NUMERIC + # test setting application mode + screen.set_keypad_mode(esc.DECKPAM) + assert screen.keypad_mode == pyte.KeypadMode.APPLICATION + # test setting numeric mode + screen.set_keypad_mode(esc.DECKPNM) + assert screen.keypad_mode == pyte.KeypadMode.NUMERIC + + +def test_numeric_keypad_mode_on_reset(): + screen = pyte.Screen(2, 1) + # test setting application mode + screen.set_keypad_mode(esc.DECKPAM) + assert screen.keypad_mode == pyte.KeypadMode.APPLICATION + # test numeric mode after reset + screen.reset() + assert screen.keypad_mode == pyte.KeypadMode.NUMERIC + + def test_draw(): # ``DECAWM`` on (default). screen = pyte.Screen(3, 3) diff --git a/tests/test_stream.py b/tests/test_stream.py index 23eb357..bdf3626 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -332,3 +332,27 @@ def test_byte_stream_select_other_charset(): # c) enable utf-8 stream.select_other_charset("G") assert stream.use_utf8 + + +@pytest.mark.parametrize("mode", [esc.DECKPAM, esc.DECKPNM]) +def test_set_keypad_mode_args(mode): + """Verify escape sequences being passed to event handler.""" + handler = argcheck() + screen = pyte.Screen(80, 1) + setattr(screen, 'set_keypad_mode', handler) + + stream = pyte.Stream(screen) + stream.feed(ctrl.ESC + mode) + assert handler.count == 1 + assert handler.kwargs == {"mode": mode} + + +def test_set_keypad_mode(): + """Verify escape sequences correctly applying kaypad mode.""" + screen = pyte.Screen(80, 1) + stream = pyte.Stream(screen) + assert screen.keypad_mode == pyte.KeypadMode.NUMERIC + stream.feed(ctrl.ESC + esc.DECKPAM) + assert screen.keypad_mode == pyte.KeypadMode.APPLICATION + stream.feed(ctrl.ESC + esc.DECKPNM) + assert screen.keypad_mode == pyte.KeypadMode.NUMERIC From ba5338ba232ad3f9f0d1e123888c0abe0010e67d Mon Sep 17 00:00:00 2001 From: deathaxe Date: Fri, 18 Apr 2025 14:31:26 +0200 Subject: [PATCH 3/5] Fix typo --- tests/test_stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_stream.py b/tests/test_stream.py index bdf3626..92ec810 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -348,7 +348,7 @@ def test_set_keypad_mode_args(mode): def test_set_keypad_mode(): - """Verify escape sequences correctly applying kaypad mode.""" + """Verify escape sequences correctly applying keypad mode.""" screen = pyte.Screen(80, 1) stream = pyte.Stream(screen) assert screen.keypad_mode == pyte.KeypadMode.NUMERIC From fc70a8776827ff0690f97c7b9f5f2cc0f45a9185 Mon Sep 17 00:00:00 2001 From: deathaxe Date: Fri, 18 Apr 2025 14:48:15 +0200 Subject: [PATCH 4/5] Verify empty buffer --- tests/test_stream.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_stream.py b/tests/test_stream.py index 92ec810..8bd652c 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -356,3 +356,5 @@ def test_set_keypad_mode(): assert screen.keypad_mode == pyte.KeypadMode.APPLICATION stream.feed(ctrl.ESC + esc.DECKPNM) assert screen.keypad_mode == pyte.KeypadMode.NUMERIC + # verify nothing added to buffer + assert screen.display[0] == " " * 80 From 0fc36c5b191abd06b17945fa257f3c5c72f0ff94 Mon Sep 17 00:00:00 2001 From: deathaxe Date: Wed, 23 Apr 2025 18:14:36 +0200 Subject: [PATCH 5/5] Move KeypadMode to dedicated keyboard module --- pyte/__init__.py | 6 ++++-- pyte/keyboard.py | 10 ++++++++++ pyte/screens.py | 10 +--------- 3 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 pyte/keyboard.py diff --git a/pyte/__init__.py b/pyte/__init__.py index 02b41a0..e57112f 100644 --- a/pyte/__init__.py +++ b/pyte/__init__.py @@ -22,13 +22,15 @@ :license: LGPL, see LICENSE for more details. """ -__all__ = ("Screen", "DiffScreen", "HistoryScreen", "DebugScreen", "KeypadMode", +__all__ = ("KeypadMode", + "Screen", "DiffScreen", "HistoryScreen", "DebugScreen", "Stream", "ByteStream") import io from typing import Union -from .screens import Screen, DiffScreen, HistoryScreen, DebugScreen, KeypadMode +from .keyboard import KeypadMode +from .screens import Screen, DiffScreen, HistoryScreen, DebugScreen from .streams import Stream, ByteStream diff --git a/pyte/keyboard.py b/pyte/keyboard.py new file mode 100644 index 0000000..6b73905 --- /dev/null +++ b/pyte/keyboard.py @@ -0,0 +1,10 @@ +from __future__ import annotations +from enum import IntEnum + + +class KeypadMode(IntEnum): + """Supported keypad modes""" + NUMERIC = 0 + """Keypad sends numbers (default).""" + APPLICATION = 1 + """Keypad sends control sequences.""" diff --git a/pyte/screens.py b/pyte/screens.py index c0d25e2..66a8d05 100644 --- a/pyte/screens.py +++ b/pyte/screens.py @@ -34,7 +34,6 @@ import unicodedata import warnings from collections import deque, defaultdict -from enum import IntEnum from functools import lru_cache from typing import Any, Dict, List, NamedTuple, Optional, Set, TextIO, TypeVar from collections.abc import Callable, Generator, Sequence @@ -48,6 +47,7 @@ graphics as g, modes as mo ) +from .keyboard import KeypadMode from .streams import Stream wcwidth: Callable[[str], int] = lru_cache(maxsize=4096)(_wcwidth) @@ -56,14 +56,6 @@ VT = TypeVar("VT") -class KeypadMode(IntEnum): - """Supported keypad modes""" - NUMERIC = 0 - """Keypad sends numbers (default).""" - APPLICATION = 1 - """Keypad sends control sequences.""" - - class Margins(NamedTuple): """A container for screen's scroll margins.""" top: int