pytermgui.input

File providing the getch() function to easily read character inputs.

Credits:

  • Original getch implementation: Danny Yoo (https://code.activestate.com/recipes/134892)
  • Modern additions & idea: kcsaff (https://github.com/kcsaff/getkey)
View Source
"""
File providing the getch() function to easily read character inputs.

Credits:
- Original getch implementation: Danny Yoo (https://code.activestate.com/recipes/134892)
- Modern additions & idea:       kcsaff (https://github.com/kcsaff/getkey)
"""

# pylint doesn't see the C source
# pylint: disable=c-extension-no-member, no-name-in-module, used-before-assignment

from __future__ import annotations

import os
import sys
import signal

from typing import (
    IO,
    Any,
    Union,
    AnyStr,
    Optional,
    KeysView,
    Generator,
    ItemsView,
    ValuesView,
)

from select import select
from contextlib import contextmanager
from codecs import getincrementaldecoder

from .exceptions import TimeoutException

__all__ = ["Keys", "getch", "getch_timeout", "keys"]


@contextmanager
def timeout(duration: float) -> Generator[None, None, None]:
    """Allows context to run for a certain amount of time, quits it once it's up.

    Note that this should never be run on Windows, as the required signals are not
    present. Whenever this function is run, there should be a preliminary OS check,
    to avoid running into issues on unsupported machines.
    """

    def _raise_timeout(*_, **__):
        raise TimeoutException("The action has timed out.")

    try:
        # set the timeout handler
        signal.signal(signal.SIGALRM, _raise_timeout)
        signal.setitimer(signal.ITIMER_REAL, duration)
        yield

    except TimeoutException:
        pass

    finally:
        signal.alarm(0)


def _is_ready(file: IO[AnyStr]) -> bool:
    """Determines if IO object is reading to read.

    Args:
        file: An IO object of any type.

    Returns:
        A boolean describing whether the object has unread
        content.
    """

    result = select([file], [], [], 0.0)
    return len(result[0]) > 0


class _GetchUnix:
    """Getch implementation for UNIX systems."""

    def __init__(self) -> None:
        """Initializes object."""

        if sys.stdin.encoding is not None:
            self.decode = getincrementaldecoder(sys.stdin.encoding)().decode
        else:
            self.decode = lambda item: item

    def _read(self, num: int) -> str:
        """Reads characters from sys.stdin.

        Args:
            num: How many characters should be read.

        Returns:
            The characters read.
        """

        buff = ""
        while len(buff) < num:
            char = os.read(sys.stdin.fileno(), 1)

            try:
                buff += self.decode(char)
            except UnicodeDecodeError:
                buff += str(char)

        return buff

    def get_chars(self) -> Generator[str, None, None]:
        """Yields characters while there are some available.

        Yields:
            Any available characters.
        """

        descriptor = sys.stdin.fileno()
        old_settings = termios.tcgetattr(descriptor)
        tty.setcbreak(descriptor)

        try:
            yield self._read(1)

            while _is_ready(sys.stdin):
                yield self._read(1)

        finally:
            # reset terminal state, set echo on
            termios.tcsetattr(descriptor, termios.TCSADRAIN, old_settings)

    def __call__(self) -> str:
        """Returns all characters that can be read."""

        buff = "".join(self.get_chars())
        return buff


class _GetchWindows:
    """Getch implementation for Windows."""

    @staticmethod
    def _ensure_str(string: AnyStr) -> str:
        """Ensures return value is always a `str` and not `bytes`.

        Args:
            string: Any string or bytes object.

        Returns:
            The string argument, converted to `str`.
        """

        if isinstance(string, bytes):
            return string.decode("utf-8")

        return string

    def get_chars(self) -> str:
        """Reads characters from sys.stdin.

        Returns:
            All read characters.
        """

        # We need to type: ignore these on non-windows machines,
        # as the library does not exist.

        # Return empty string if there is no input to get
        if not msvcrt.kbhit():  # type: ignore
            return ""

        char = msvcrt.getch()  # type: ignore
        if char == b"\xe0":
            char = "\x1b"

        buff = self._ensure_str(char)

        while msvcrt.kbhit():  # type: ignore
            char = msvcrt.getch()  # type: ignore
            buff += self._ensure_str(char)

        return buff

    def __call__(self) -> str:
        """Returns all characters that can be read.

        Returns:
            All readable characters.
        """

        buff = self.get_chars()
        return buff


class Keys:
    """Class for easy access to key-codes.

    The keys for CTRL_{ascii_letter}-s can be generated with
    the following code:

    ```python3
    for i, letter in enumerate(ascii_lowercase):
        key = f"CTRL_{letter.upper()}"
        code = chr(i+1).encode('unicode_escape').decode('utf-8')

        print(key, code)
    ```
    """

    def __init__(self, platform_keys: dict[str, str], platform: str) -> None:
        """Initialize Keys object.

        Args:
            platform_keys: A dictionary of platform-specific keys.
            platform: The platform the program is running on.
        """

        self._keys = {
            "SPACE": " ",
            "ESC": "\x1b",
            # The ALT character in key combinations is the same as ESC
            "ALT": "\x1b",
            "TAB": "\t",
            "ENTER": "\n",
            "RETURN": "\n",
            "CTRL_A": "\x01",
            "CTRL_B": "\x02",
            "CTRL_C": "\x03",
            "CTRL_D": "\x04",
            "CTRL_E": "\x05",
            "CTRL_F": "\x06",
            "CTRL_G": "\x07",
            "CTRL_H": "\x08",
            "CTRL_I": "\t",
            "CTRL_J": "\n",
            "CTRL_K": "\x0b",
            "CTRL_L": "\x0c",
            "CTRL_M": "\r",
            "CTRL_N": "\x0e",
            "CTRL_O": "\x0f",
            "CTRL_P": "\x10",
            "CTRL_Q": "\x11",
            "CTRL_R": "\x12",
            "CTRL_S": "\x13",
            "CTRL_T": "\x14",
            "CTRL_U": "\x15",
            "CTRL_V": "\x16",
            "CTRL_W": "\x17",
            "CTRL_X": "\x18",
            "CTRL_Y": "\x19",
            "CTRL_Z": "\x1a",
        }

        self.platform = platform

        if platform_keys is not None:
            for key, code in platform_keys.items():
                if key == "name":
                    self.name = code
                    continue

                self._keys[key] = code

    def __getattr__(self, attr: str) -> str:
        """Gets attr from self._keys."""

        if attr == "ANY_KEY":
            return attr

        return self._keys.get(attr, "")

    def get_name(self, key: str, default: Optional[str] = None) -> Optional[str]:
        """Gets canonical name of a key code.

        Args:
            key: The key to get the name of.
            default: The return value to substitute if no canonical name could be
                found. Defaults to None.

        Returns:
            The canonical name if one can be found, default otherwise.
        """

        for name, value in self._keys.items():
            if key == value:
                return name

        return default

    def values(self) -> ValuesView[str]:
        """Returns values() of self._keys."""

        return self._keys.values()

    def keys(self) -> KeysView[str]:
        """Returns keys() of self._keys."""

        return self._keys.keys()

    def items(self) -> ItemsView[str, str]:
        """Returns items() of self._keys."""

        return self._keys.items()


_getch: Union[_GetchWindows, _GetchUnix]

keys: Keys
"""Instance storing platform specific key codes."""

try:
    import msvcrt

    # TODO: Add shift+arrow keys
    _platform_keys = {
        "ESC": "\x1b",
        "LEFT": "\x1bK",
        "RIGHT": "\x1bM",
        "UP": "\x1bH",
        "DOWN": "\x1bP",
        "ENTER": "\r",
        "RETURN": "\r",
        "BACKSPACE": "\x08",
        "F1": "\x00;",
        "F2": "\x00<",
        "F3": "\x00=",
        "F4": "\x00>",
        "F5": "\x00?",
        "F6": "\x00@",
        "F7": "\x00A",
        "F8": "\x00B",
        "F9": "\x00C",
        "F10": "\x00D",
        "F11": "\xe0\x85",
        "F12": "\xe0\x86",
    }

    _getch = _GetchWindows()
    keys = Keys(_platform_keys, "nt")

except ImportError as import_error:
    if not os.name == "posix":
        raise NotImplementedError(
            f"Platform {os.name} is not supported."
        ) from import_error

    import termios
    import tty

    _platform_keys = {
        "name": "posix",
        "UP": "\x1b[A",
        "DOWN": "\x1b[B",
        "RIGHT": "\x1b[C",
        "LEFT": "\x1b[D",
        "SHIFT_UP": "\x1b[1;2A",
        "SHIFT_DOWN": "\x1b[1;2B",
        "SHIFT_RIGHT": "\x1b[1;2C",
        "SHIFT_LEFT": "\x1b[1;2D",
        "BACKSPACE": "\x7f",
        "INSERT": "\x1b[2~",
        "DELETE": "\x1b[3~",
        "BACKTAB": "\x1b[Z",
        "F1": "\x1b[11~",
        "F2": "\x1b[12~",
        "F3": "\x1b[13~",
        "F4": "\x1b[14~",
        "F5": "\x1b[15~",
        "F6": "\x1b[17~",
        "F7": "\x1b[18~",
        "F8": "\x1b[19~",
        "F9": "\x1b[20~",
        "F10": "\x1b[21~",
        "F11": "\x1b[23~",
        "F12": "\x1b[24~",
    }

    _getch = _GetchUnix()
    keys = Keys(_platform_keys, "posix")


def getch(printable: bool = False, interrupts: bool = True) -> Any:
    """Wrapper to call the platform-appropriate character getter.

    Args:
        printable: When set, printable versions of the input are returned.
        interrupts: If not set, `KeyboardInterrupt` is silenced and `chr(3)`
            (`CTRL_C`) is returned.
    """

    try:
        key = _getch()

        # msvcrt.getch returns CTRL_C as a character, unlike UNIX systems
        # where an interrupt is raised. Thus, we need to manually raise
        # the interrupt.
        if key == chr(3):
            raise KeyboardInterrupt

    except KeyboardInterrupt as error:
        if interrupts:
            raise KeyboardInterrupt("Unhandled interrupt") from error

        key = chr(3)

    if printable:
        key = key.encode("unicode_escape").decode("utf-8")

    return key


def getch_timeout(
    duration: float, default: str = "", printable: bool = False, interrupts: bool = True
) -> Any:
    """Calls `getch`, returns `default` if timeout passes before getting input.

    No timeout is applied on Windows systems, as there is no support for `SIGALRM`.

    Args:
        timeout: How long the call should wait for input.
        default: The value to return if timeout occured.
    """

    if isinstance(_getch, _GetchWindows):
        return getch()

    with timeout(duration):
        return getch(printable=printable, interrupts=interrupts)

    return default
#   class Keys:
View Source
class Keys:
    """Class for easy access to key-codes.

    The keys for CTRL_{ascii_letter}-s can be generated with
    the following code:

    ```python3
    for i, letter in enumerate(ascii_lowercase):
        key = f"CTRL_{letter.upper()}"
        code = chr(i+1).encode('unicode_escape').decode('utf-8')

        print(key, code)
    ```
    """

    def __init__(self, platform_keys: dict[str, str], platform: str) -> None:
        """Initialize Keys object.

        Args:
            platform_keys: A dictionary of platform-specific keys.
            platform: The platform the program is running on.
        """

        self._keys = {
            "SPACE": " ",
            "ESC": "\x1b",
            # The ALT character in key combinations is the same as ESC
            "ALT": "\x1b",
            "TAB": "\t",
            "ENTER": "\n",
            "RETURN": "\n",
            "CTRL_A": "\x01",
            "CTRL_B": "\x02",
            "CTRL_C": "\x03",
            "CTRL_D": "\x04",
            "CTRL_E": "\x05",
            "CTRL_F": "\x06",
            "CTRL_G": "\x07",
            "CTRL_H": "\x08",
            "CTRL_I": "\t",
            "CTRL_J": "\n",
            "CTRL_K": "\x0b",
            "CTRL_L": "\x0c",
            "CTRL_M": "\r",
            "CTRL_N": "\x0e",
            "CTRL_O": "\x0f",
            "CTRL_P": "\x10",
            "CTRL_Q": "\x11",
            "CTRL_R": "\x12",
            "CTRL_S": "\x13",
            "CTRL_T": "\x14",
            "CTRL_U": "\x15",
            "CTRL_V": "\x16",
            "CTRL_W": "\x17",
            "CTRL_X": "\x18",
            "CTRL_Y": "\x19",
            "CTRL_Z": "\x1a",
        }

        self.platform = platform

        if platform_keys is not None:
            for key, code in platform_keys.items():
                if key == "name":
                    self.name = code
                    continue

                self._keys[key] = code

    def __getattr__(self, attr: str) -> str:
        """Gets attr from self._keys."""

        if attr == "ANY_KEY":
            return attr

        return self._keys.get(attr, "")

    def get_name(self, key: str, default: Optional[str] = None) -> Optional[str]:
        """Gets canonical name of a key code.

        Args:
            key: The key to get the name of.
            default: The return value to substitute if no canonical name could be
                found. Defaults to None.

        Returns:
            The canonical name if one can be found, default otherwise.
        """

        for name, value in self._keys.items():
            if key == value:
                return name

        return default

    def values(self) -> ValuesView[str]:
        """Returns values() of self._keys."""

        return self._keys.values()

    def keys(self) -> KeysView[str]:
        """Returns keys() of self._keys."""

        return self._keys.keys()

    def items(self) -> ItemsView[str, str]:
        """Returns items() of self._keys."""

        return self._keys.items()

Class for easy access to key-codes.

The keys for CTRL_{ascii_letter}-s can be generated with the following code:

for i, letter in enumerate(ascii_lowercase):
    key = f"CTRL_{letter.upper()}"
    code = chr(i+1).encode('unicode_escape').decode('utf-8')

    print(key, code)
#   Keys(platform_keys: dict[str, str], platform: str)
View Source
    def __init__(self, platform_keys: dict[str, str], platform: str) -> None:
        """Initialize Keys object.

        Args:
            platform_keys: A dictionary of platform-specific keys.
            platform: The platform the program is running on.
        """

        self._keys = {
            "SPACE": " ",
            "ESC": "\x1b",
            # The ALT character in key combinations is the same as ESC
            "ALT": "\x1b",
            "TAB": "\t",
            "ENTER": "\n",
            "RETURN": "\n",
            "CTRL_A": "\x01",
            "CTRL_B": "\x02",
            "CTRL_C": "\x03",
            "CTRL_D": "\x04",
            "CTRL_E": "\x05",
            "CTRL_F": "\x06",
            "CTRL_G": "\x07",
            "CTRL_H": "\x08",
            "CTRL_I": "\t",
            "CTRL_J": "\n",
            "CTRL_K": "\x0b",
            "CTRL_L": "\x0c",
            "CTRL_M": "\r",
            "CTRL_N": "\x0e",
            "CTRL_O": "\x0f",
            "CTRL_P": "\x10",
            "CTRL_Q": "\x11",
            "CTRL_R": "\x12",
            "CTRL_S": "\x13",
            "CTRL_T": "\x14",
            "CTRL_U": "\x15",
            "CTRL_V": "\x16",
            "CTRL_W": "\x17",
            "CTRL_X": "\x18",
            "CTRL_Y": "\x19",
            "CTRL_Z": "\x1a",
        }

        self.platform = platform

        if platform_keys is not None:
            for key, code in platform_keys.items():
                if key == "name":
                    self.name = code
                    continue

                self._keys[key] = code

Initialize Keys object.

Args
  • platform_keys: A dictionary of platform-specific keys.
  • platform: The platform the program is running on.
#   def get_name(self, key: str, default: Optional[str] = None) -> Optional[str]:
View Source
    def get_name(self, key: str, default: Optional[str] = None) -> Optional[str]:
        """Gets canonical name of a key code.

        Args:
            key: The key to get the name of.
            default: The return value to substitute if no canonical name could be
                found. Defaults to None.

        Returns:
            The canonical name if one can be found, default otherwise.
        """

        for name, value in self._keys.items():
            if key == value:
                return name

        return default

Gets canonical name of a key code.

Args
  • key: The key to get the name of.
  • default: The return value to substitute if no canonical name could be found. Defaults to None.
Returns

The canonical name if one can be found, default otherwise.

#   def values(self) -> ValuesView[str]:
View Source
    def values(self) -> ValuesView[str]:
        """Returns values() of self._keys."""

        return self._keys.values()

Returns values() of self._keys.

#   def keys(self) -> KeysView[str]:
View Source
    def keys(self) -> KeysView[str]:
        """Returns keys() of self._keys."""

        return self._keys.keys()

Returns keys() of self._keys.

#   def items(self) -> ItemsView[str, str]:
View Source
    def items(self) -> ItemsView[str, str]:
        """Returns items() of self._keys."""

        return self._keys.items()

Returns items() of self._keys.

#   def getch(printable: bool = False, interrupts: bool = True) -> Any:
View Source
def getch(printable: bool = False, interrupts: bool = True) -> Any:
    """Wrapper to call the platform-appropriate character getter.

    Args:
        printable: When set, printable versions of the input are returned.
        interrupts: If not set, `KeyboardInterrupt` is silenced and `chr(3)`
            (`CTRL_C`) is returned.
    """

    try:
        key = _getch()

        # msvcrt.getch returns CTRL_C as a character, unlike UNIX systems
        # where an interrupt is raised. Thus, we need to manually raise
        # the interrupt.
        if key == chr(3):
            raise KeyboardInterrupt

    except KeyboardInterrupt as error:
        if interrupts:
            raise KeyboardInterrupt("Unhandled interrupt") from error

        key = chr(3)

    if printable:
        key = key.encode("unicode_escape").decode("utf-8")

    return key

Wrapper to call the platform-appropriate character getter.

Args
  • printable: When set, printable versions of the input are returned.
  • interrupts: If not set, KeyboardInterrupt is silenced and chr(3) (CTRL_C) is returned.
#   def getch_timeout( duration: float, default: str = '', printable: bool = False, interrupts: bool = True ) -> Any:
View Source
def getch_timeout(
    duration: float, default: str = "", printable: bool = False, interrupts: bool = True
) -> Any:
    """Calls `getch`, returns `default` if timeout passes before getting input.

    No timeout is applied on Windows systems, as there is no support for `SIGALRM`.

    Args:
        timeout: How long the call should wait for input.
        default: The value to return if timeout occured.
    """

    if isinstance(_getch, _GetchWindows):
        return getch()

    with timeout(duration):
        return getch(printable=printable, interrupts=interrupts)

    return default

Calls getch, returns default if timeout passes before getting input.

No timeout is applied on Windows systems, as there is no support for SIGALRM.

Args
  • timeout: How long the call should wait for input.
  • default: The value to return if timeout occured.

Instance storing platform specific key codes.