diff --git a/__pycache__/asteroid.cpython-310.pyc b/__pycache__/asteroid.cpython-310.pyc new file mode 100644 index 0000000..f710e2f Binary files /dev/null and b/__pycache__/asteroid.cpython-310.pyc differ diff --git a/__pycache__/asteroid_spawner.cpython-310.pyc b/__pycache__/asteroid_spawner.cpython-310.pyc new file mode 100644 index 0000000..67e952a Binary files /dev/null and b/__pycache__/asteroid_spawner.cpython-310.pyc differ diff --git a/__pycache__/retro.cpython-310.pyc b/__pycache__/retro.cpython-310.pyc new file mode 100644 index 0000000..c785294 Binary files /dev/null and b/__pycache__/retro.cpython-310.pyc differ diff --git a/__pycache__/spaceship.cpython-310.pyc b/__pycache__/spaceship.cpython-310.pyc new file mode 100644 index 0000000..002f91b Binary files /dev/null and b/__pycache__/spaceship.cpython-310.pyc differ diff --git a/asteroid.py b/asteroid.py index 257354b..1338ae8 100644 --- a/asteroid.py +++ b/asteroid.py @@ -2,3 +2,22 @@ # ------------ # By MWC Contributors # This module defines an asteroid agent class. + +class Asteroid: + character = 'O' + + def __init__(self, position): + self.position = position + + def play_turn(self, game): + if game.turn_number % 2 == 0: + x, y = self.position + if y == 25 - 1: + game.remove_agent_by_name(self.name) + else: + ship = game.get_agent_by_name('ship') + new_position = (x, y + 1) + if new_position == ship.position: + game.end() + else: + self.position = new_position \ No newline at end of file diff --git a/asteroid_spawner.py b/asteroid_spawner.py index 92e542d..65eae7f 100644 --- a/asteroid_spawner.py +++ b/asteroid_spawner.py @@ -2,3 +2,22 @@ # ------------------- # By MWC Contributors # This module defines an AsteroidSpawner agent class. + +from random import randint +from asteroid import Asteroid + +class AsteroidSpawner: + display = False + + def __init__(self, board_size): + width, height = board_size + self.board_width = width + + def play_turn(self, game): + game.state['score'] += 1 + if self.should_spawn_asteroid(game.turn_number): + asteroid = Asteroid((randint(0, self.board_width - 1), 0)) + game.add_agent(asteroid) + + def should_spawn_asteroid(self, turn_number): + return randint(0, 1000) < turn_number \ No newline at end of file diff --git a/blessed/__init__.py b/blessed/__init__.py new file mode 100644 index 0000000..e7b00b5 --- /dev/null +++ b/blessed/__init__.py @@ -0,0 +1,23 @@ +""" +A thin, practical wrapper around terminal capabilities in Python. + +http://pypi.python.org/pypi/blessed +""" +# std imports +import sys as _sys +import platform as _platform + +# isort: off +if _platform.system() == 'Windows': + from blessed.win_terminal import Terminal +else: + from blessed.terminal import Terminal # type: ignore + +if (3, 0, 0) <= _sys.version_info[:3] < (3, 2, 3): + # Good till 3.2.10 + # Python 3.x < 3.2.3 has a bug in which tparm() erroneously takes a string. + raise ImportError('Blessed needs Python 3.2.3 or greater for Python 3 ' + 'support due to http://bugs.python.org/issue10570.') + +__all__ = ('Terminal',) +__version__ = "1.20.0" diff --git a/blessed/__init__.py:Zone.Identifier b/blessed/__init__.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/__pycache__/__init__.cpython-310.pyc b/blessed/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..280d455 Binary files /dev/null and b/blessed/__pycache__/__init__.cpython-310.pyc differ diff --git a/blessed/__pycache__/_capabilities.cpython-310.pyc b/blessed/__pycache__/_capabilities.cpython-310.pyc new file mode 100644 index 0000000..03f3f94 Binary files /dev/null and b/blessed/__pycache__/_capabilities.cpython-310.pyc differ diff --git a/blessed/__pycache__/color.cpython-310.pyc b/blessed/__pycache__/color.cpython-310.pyc new file mode 100644 index 0000000..41bfcd6 Binary files /dev/null and b/blessed/__pycache__/color.cpython-310.pyc differ diff --git a/blessed/__pycache__/colorspace.cpython-310.pyc b/blessed/__pycache__/colorspace.cpython-310.pyc new file mode 100644 index 0000000..47dae6a Binary files /dev/null and b/blessed/__pycache__/colorspace.cpython-310.pyc differ diff --git a/blessed/__pycache__/formatters.cpython-310.pyc b/blessed/__pycache__/formatters.cpython-310.pyc new file mode 100644 index 0000000..897175b Binary files /dev/null and b/blessed/__pycache__/formatters.cpython-310.pyc differ diff --git a/blessed/__pycache__/keyboard.cpython-310.pyc b/blessed/__pycache__/keyboard.cpython-310.pyc new file mode 100644 index 0000000..f4c093a Binary files /dev/null and b/blessed/__pycache__/keyboard.cpython-310.pyc differ diff --git a/blessed/__pycache__/sequences.cpython-310.pyc b/blessed/__pycache__/sequences.cpython-310.pyc new file mode 100644 index 0000000..5d8bfb5 Binary files /dev/null and b/blessed/__pycache__/sequences.cpython-310.pyc differ diff --git a/blessed/__pycache__/terminal.cpython-310.pyc b/blessed/__pycache__/terminal.cpython-310.pyc new file mode 100644 index 0000000..d0b49b1 Binary files /dev/null and b/blessed/__pycache__/terminal.cpython-310.pyc differ diff --git a/blessed/_capabilities.py b/blessed/_capabilities.py new file mode 100644 index 0000000..c4df54b --- /dev/null +++ b/blessed/_capabilities.py @@ -0,0 +1,168 @@ +"""Terminal capability builder patterns.""" +# std imports +import re +from collections import OrderedDict + +__all__ = ( + 'CAPABILITY_DATABASE', + 'CAPABILITIES_RAW_MIXIN', + 'CAPABILITIES_ADDITIVES', + 'CAPABILITIES_CAUSE_MOVEMENT', +) + +CAPABILITY_DATABASE = OrderedDict(( + ('bell', ('bel', {})), + ('carriage_return', ('cr', {})), + ('change_scroll_region', ('csr', {'nparams': 2})), + ('clear_all_tabs', ('tbc', {})), + ('clear_screen', ('clear', {})), + ('clr_bol', ('el1', {})), + ('clr_eol', ('el', {})), + ('clr_eos', ('clear_eos', {})), + ('column_address', ('hpa', {'nparams': 1})), + ('cursor_address', ('cup', {'nparams': 2, 'match_grouped': True})), + ('cursor_down', ('cud1', {})), + ('cursor_home', ('home', {})), + ('cursor_invisible', ('civis', {})), + ('cursor_left', ('cub1', {})), + ('cursor_normal', ('cnorm', {})), + ('cursor_report', ('u6', {'nparams': 2, 'match_grouped': True})), + ('cursor_right', ('cuf1', {})), + ('cursor_up', ('cuu1', {})), + ('cursor_visible', ('cvvis', {})), + ('delete_character', ('dch1', {})), + ('delete_line', ('dl1', {})), + ('enter_blink_mode', ('blink', {})), + ('enter_bold_mode', ('bold', {})), + ('enter_dim_mode', ('dim', {})), + ('enter_fullscreen', ('smcup', {})), + ('enter_standout_mode', ('standout', {})), + ('enter_superscript_mode', ('superscript', {})), + ('enter_susimpleript_mode', ('susimpleript', {})), + ('enter_underline_mode', ('underline', {})), + ('erase_chars', ('ech', {'nparams': 1})), + ('exit_alt_charset_mode', ('rmacs', {})), + ('exit_am_mode', ('rmam', {})), + ('exit_attribute_mode', ('sgr0', {})), + ('exit_ca_mode', ('rmcup', {})), + ('exit_fullscreen', ('rmcup', {})), + ('exit_insert_mode', ('rmir', {})), + ('exit_standout_mode', ('rmso', {})), + ('exit_underline_mode', ('rmul', {})), + ('flash_hook', ('hook', {})), + ('flash_screen', ('flash', {})), + ('insert_line', ('il1', {})), + ('keypad_local', ('rmkx', {})), + ('keypad_xmit', ('smkx', {})), + ('meta_off', ('rmm', {})), + ('meta_on', ('smm', {})), + ('orig_pair', ('op', {})), + ('parm_down_cursor', ('cud', {'nparams': 1})), + ('parm_left_cursor', ('cub', {'nparams': 1, 'match_grouped': True})), + ('parm_dch', ('dch', {'nparams': 1})), + ('parm_delete_line', ('dl', {'nparams': 1})), + ('parm_ich', ('ich', {'nparams': 1})), + ('parm_index', ('indn', {'nparams': 1})), + ('parm_insert_line', ('il', {'nparams': 1})), + ('parm_right_cursor', ('cuf', {'nparams': 1, 'match_grouped': True})), + ('parm_rindex', ('rin', {'nparams': 1})), + ('parm_up_cursor', ('cuu', {'nparams': 1})), + ('print_screen', ('mc0', {})), + ('prtr_off', ('mc4', {})), + ('prtr_on', ('mc5', {})), + ('reset_1string', ('r1', {})), + ('reset_2string', ('r2', {})), + ('reset_3string', ('r3', {})), + ('restore_cursor', ('rc', {})), + ('row_address', ('vpa', {'nparams': 1})), + ('save_cursor', ('sc', {})), + ('scroll_forward', ('ind', {})), + ('scroll_reverse', ('rev', {})), + ('set0_des_seq', ('s0ds', {})), + ('set1_des_seq', ('s1ds', {})), + ('set2_des_seq', ('s2ds', {})), + ('set3_des_seq', ('s3ds', {})), + # this 'color' is deceiving, but often matching, and a better match + # than set_a_attributes1 or set_a_foreground. + ('color', ('_foreground_color', {'nparams': 1, 'match_any': True, + 'numeric': 1})), + ('set_a_foreground', ('color', {'nparams': 1, 'match_any': True, + 'numeric': 1})), + ('set_a_background', ('on_color', {'nparams': 1, 'match_any': True, + 'numeric': 1})), + ('set_tab', ('hts', {})), + ('tab', ('ht', {})), + ('italic', ('sitm', {})), + ('no_italic', ('sitm', {})), +)) + +CAPABILITIES_RAW_MIXIN = { + 'bell': re.escape('\a'), + 'carriage_return': re.escape('\r'), + 'cursor_left': re.escape('\b'), + 'cursor_report': re.escape('\x1b') + r'\[(\d+)\;(\d+)R', + 'cursor_right': re.escape('\x1b') + r'\[C', + 'exit_attribute_mode': re.escape('\x1b') + r'\[m', + 'parm_left_cursor': re.escape('\x1b') + r'\[(\d+)D', + 'parm_right_cursor': re.escape('\x1b') + r'\[(\d+)C', + 'restore_cursor': re.escape(r'\x1b\[u'), + 'save_cursor': re.escape(r'\x1b\[s'), + 'scroll_forward': re.escape('\n'), + 'set0_des_seq': re.escape('\x1b(B'), + 'tab': re.escape('\t'), +} +_ANY_NOTESC = '[^' + re.escape('\x1b') + ']*' + +CAPABILITIES_ADDITIVES = { + 'link': ('link', + re.escape('\x1b') + r'\]8;' + _ANY_NOTESC + ';' + + _ANY_NOTESC + re.escape('\x1b') + '\\\\'), + 'color256': ('color', re.escape('\x1b') + r'\[38;5;\d+m'), + 'on_color256': ('on_color', re.escape('\x1b') + r'\[48;5;\d+m'), + 'color_rgb': ('color_rgb', re.escape('\x1b') + r'\[38;2;\d+;\d+;\d+m'), + 'on_color_rgb': ('on_color_rgb', re.escape('\x1b') + r'\[48;2;\d+;\d+;\d+m'), + 'shift_in': ('', re.escape('\x0f')), + 'shift_out': ('', re.escape('\x0e')), + # sgr(...) outputs strangely, use the basic ANSI/EMCA-48 codes here. + 'set_a_attributes1': ( + 'sgr', re.escape('\x1b') + r'\[\d+m'), + 'set_a_attributes2': ( + 'sgr', re.escape('\x1b') + r'\[\d+\;\d+m'), + 'set_a_attributes3': ( + 'sgr', re.escape('\x1b') + r'\[\d+\;\d+\;\d+m'), + 'set_a_attributes4': ( + 'sgr', re.escape('\x1b') + r'\[\d+\;\d+\;\d+\;\d+m'), + # this helps where xterm's sgr0 includes set0_des_seq, we'd + # rather like to also match this immediate substring. + 'sgr0': ('sgr0', re.escape('\x1b') + r'\[m'), + 'backspace': ('', re.escape('\b')), + 'ascii_tab': ('', re.escape('\t')), + 'clr_eol': ('', re.escape('\x1b[K')), + 'clr_eol0': ('', re.escape('\x1b[0K')), + 'clr_bol': ('', re.escape('\x1b[1K')), + 'clr_eosK': ('', re.escape('\x1b[2K')), +} + +CAPABILITIES_CAUSE_MOVEMENT = ( + 'ascii_tab', + 'backspace', + 'carriage_return', + 'clear_screen', + 'column_address', + 'cursor_address', + 'cursor_down', + 'cursor_home', + 'cursor_left', + 'cursor_right', + 'cursor_up', + 'enter_fullscreen', + 'exit_fullscreen', + 'parm_down_cursor', + 'parm_left_cursor', + 'parm_right_cursor', + 'parm_up_cursor', + 'restore_cursor', + 'row_address', + 'scroll_forward', + 'tab', +) diff --git a/blessed/_capabilities.py:Zone.Identifier b/blessed/_capabilities.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/_capabilities.pyi b/blessed/_capabilities.pyi new file mode 100644 index 0000000..04c59c3 --- /dev/null +++ b/blessed/_capabilities.pyi @@ -0,0 +1,7 @@ +# std imports +from typing import Any, Dict, Tuple, OrderedDict + +CAPABILITY_DATABASE: OrderedDict[str, Tuple[str, Dict[str, Any]]] +CAPABILITIES_RAW_MIXIN: Dict[str, str] +CAPABILITIES_ADDITIVES: Dict[str, Tuple[str, str]] +CAPABILITIES_CAUSE_MOVEMENT: Tuple[str, ...] diff --git a/blessed/_capabilities.pyi:Zone.Identifier b/blessed/_capabilities.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/color.py b/blessed/color.py new file mode 100644 index 0000000..482fc0e --- /dev/null +++ b/blessed/color.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +""" +Sub-module providing color functions. + +References, + +- https://en.wikipedia.org/wiki/Color_difference +- http://www.easyrgb.com/en/math.php +- Measuring Colour by R.W.G. Hunt and M.R. Pointer +""" + +# std imports +from math import cos, exp, sin, sqrt, atan2 + +# isort: off +try: + from functools import lru_cache +except ImportError: + # lru_cache was added in Python 3.2 + from backports.functools_lru_cache import lru_cache + + +def rgb_to_xyz(red, green, blue): + """ + Convert standard RGB color to XYZ color. + + :arg int red: RGB value of Red. + :arg int green: RGB value of Green. + :arg int blue: RGB value of Blue. + :returns: Tuple (X, Y, Z) representing XYZ color + :rtype: tuple + + D65/2° standard illuminant + """ + rgb = [] + for val in red, green, blue: + val /= 255.0 + if val > 0.04045: + val = pow((val + 0.055) / 1.055, 2.4) + else: + val /= 12.92 + val *= 100 + rgb.append(val) + + red, green, blue = rgb # pylint: disable=unbalanced-tuple-unpacking + x_val = red * 0.4124 + green * 0.3576 + blue * 0.1805 + y_val = red * 0.2126 + green * 0.7152 + blue * 0.0722 + z_val = red * 0.0193 + green * 0.1192 + blue * 0.9505 + + return x_val, y_val, z_val + + +def xyz_to_lab(x_val, y_val, z_val): + """ + Convert XYZ color to CIE-Lab color. + + :arg float x_val: XYZ value of X. + :arg float y_val: XYZ value of Y. + :arg float z_val: XYZ value of Z. + :returns: Tuple (L, a, b) representing CIE-Lab color + :rtype: tuple + + D65/2° standard illuminant + """ + xyz = [] + for val, ref in (x_val, 95.047), (y_val, 100.0), (z_val, 108.883): + val /= ref + val = pow(val, 1 / 3.0) if val > 0.008856 else 7.787 * val + 16 / 116.0 + xyz.append(val) + + x_val, y_val, z_val = xyz # pylint: disable=unbalanced-tuple-unpacking + cie_l = 116 * y_val - 16 + cie_a = 500 * (x_val - y_val) + cie_b = 200 * (y_val - z_val) + + return cie_l, cie_a, cie_b + + +@lru_cache(maxsize=256) +def rgb_to_lab(red, green, blue): + """ + Convert RGB color to CIE-Lab color. + + :arg int red: RGB value of Red. + :arg int green: RGB value of Green. + :arg int blue: RGB value of Blue. + :returns: Tuple (L, a, b) representing CIE-Lab color + :rtype: tuple + + D65/2° standard illuminant + """ + return xyz_to_lab(*rgb_to_xyz(red, green, blue)) + + +def dist_rgb(rgb1, rgb2): + """ + Determine distance between two rgb colors. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + This works by treating RGB colors as coordinates in three dimensional + space and finding the closest point within the configured color range + using the formula:: + + d^2 = (r2 - r1)^2 + (g2 - g1)^2 + (b2 - b1)^2 + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + return sum(pow(rgb1[idx] - rgb2[idx], 2) for idx in (0, 1, 2)) + + +def dist_rgb_weighted(rgb1, rgb2): + """ + Determine the weighted distance between two rgb colors. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + Similar to a standard distance formula, the values are weighted + to approximate human perception of color differences + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + red_mean = (rgb1[0] + rgb2[0]) / 2.0 + + return ((2 + red_mean / 256) * pow(rgb1[0] - rgb2[0], 2) + + 4 * pow(rgb1[1] - rgb2[1], 2) + + (2 + (255 - red_mean) / 256) * pow(rgb1[2] - rgb2[2], 2)) + + +def dist_cie76(rgb1, rgb2): + """ + Determine distance between two rgb colors using the CIE94 algorithm. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + l_1, a_1, b_1 = rgb_to_lab(*rgb1) + l_2, a_2, b_2 = rgb_to_lab(*rgb2) + return pow(l_1 - l_2, 2) + pow(a_1 - a_2, 2) + pow(b_1 - b_2, 2) + + +def dist_cie94(rgb1, rgb2): + # pylint: disable=too-many-locals + """ + Determine distance between two rgb colors using the CIE94 algorithm. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + l_1, a_1, b_1 = rgb_to_lab(*rgb1) + l_2, a_2, b_2 = rgb_to_lab(*rgb2) + + s_l = k_l = k_c = k_h = 1 + k_1 = 0.045 + k_2 = 0.015 + + delta_l = l_1 - l_2 + delta_a = a_1 - a_2 + delta_b = b_1 - b_2 + c_1 = sqrt(a_1 ** 2 + b_1 ** 2) + c_2 = sqrt(a_2 ** 2 + b_2 ** 2) + delta_c = c_1 - c_2 + delta_h = sqrt(delta_a ** 2 + delta_b ** 2 + delta_c ** 2) + s_c = 1 + k_1 * c_1 + s_h = 1 + k_2 * c_1 + + return ((delta_l / (k_l * s_l)) ** 2 + # pylint: disable=superfluous-parens + (delta_c / (k_c * s_c)) ** 2 + + (delta_h / (k_h * s_h)) ** 2) + + +def dist_cie2000(rgb1, rgb2): + # pylint: disable=too-many-locals + """ + Determine distance between two rgb colors using the CIE2000 algorithm. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + s_l = k_l = k_c = k_h = 1 + + l_1, a_1, b_1 = rgb_to_lab(*rgb1) + l_2, a_2, b_2 = rgb_to_lab(*rgb2) + + delta_l = l_2 - l_1 + l_mean = (l_1 + l_2) / 2 + + c_1 = sqrt(a_1 ** 2 + b_1 ** 2) + c_2 = sqrt(a_2 ** 2 + b_2 ** 2) + c_mean = (c_1 + c_2) / 2 + delta_c = c_1 - c_2 + + g_x = sqrt(c_mean ** 7 / (c_mean ** 7 + 25 ** 7)) + h_1 = atan2(b_1, a_1 + (a_1 / 2) * (1 - g_x)) % 360 + h_2 = atan2(b_2, a_2 + (a_2 / 2) * (1 - g_x)) % 360 + + if 0 in (c_1, c_2): + delta_h_prime = 0 + h_mean = h_1 + h_2 + else: + delta_h_prime = h_2 - h_1 + if abs(delta_h_prime) <= 180: + h_mean = (h_1 + h_2) / 2 + else: + if h_2 <= h_1: + delta_h_prime += 360 + else: + delta_h_prime -= 360 + h_mean = (h_1 + h_2 + 360) / 2 if h_1 + h_2 < 360 else (h_1 + h_2 - 360) / 2 + + delta_h = 2 * sqrt(c_1 * c_2) * sin(delta_h_prime / 2) + + t_x = (1 - + 0.17 * cos(h_mean - 30) + + 0.24 * cos(2 * h_mean) + + 0.32 * cos(3 * h_mean + 6) - + 0.20 * cos(4 * h_mean - 63)) + + s_l = 1 + (0.015 * (l_mean - 50) ** 2) / sqrt(20 + (l_mean - 50) ** 2) + s_c = 1 + 0.045 * c_mean + s_h = 1 + 0.015 * c_mean * t_x + r_t = -2 * g_x * sin(abs(60 * exp(-1 * abs((delta_h - 275) / 25) ** 2))) + + delta_l = delta_l / (k_l * s_l) + delta_c = delta_c / (k_c * s_c) + delta_h = delta_h / (k_h * s_h) + + return delta_l ** 2 + delta_c ** 2 + delta_h ** 2 + r_t * delta_c * delta_h + + +COLOR_DISTANCE_ALGORITHMS = {'rgb': dist_rgb, + 'rgb-weighted': dist_rgb_weighted, + 'cie76': dist_cie76, + 'cie94': dist_cie94, + 'cie2000': dist_cie2000} diff --git a/blessed/color.py:Zone.Identifier b/blessed/color.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/color.pyi b/blessed/color.pyi new file mode 100644 index 0000000..ece82e3 --- /dev/null +++ b/blessed/color.pyi @@ -0,0 +1,17 @@ +# std imports +from typing import Dict, Tuple, Callable + +_RGB = Tuple[int, int, int] + +def rgb_to_xyz(red: int, green: int, blue: int) -> Tuple[float, float, float]: ... +def xyz_to_lab( + x_val: float, y_val: float, z_val: float +) -> Tuple[float, float, float]: ... +def rgb_to_lab(red: int, green: int, blue: int) -> Tuple[float, float, float]: ... +def dist_rgb(rgb1: _RGB, rgb2: _RGB) -> float: ... +def dist_rgb_weighted(rgb1: _RGB, rgb2: _RGB) -> float: ... +def dist_cie76(rgb1: _RGB, rgb2: _RGB) -> float: ... +def dist_cie94(rgb1: _RGB, rgb2: _RGB) -> float: ... +def dist_cie2000(rgb1: _RGB, rgb2: _RGB) -> float: ... + +COLOR_DISTANCE_ALGORITHMS: Dict[str, Callable[[_RGB, _RGB], float]] diff --git a/blessed/color.pyi:Zone.Identifier b/blessed/color.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/colorspace.py b/blessed/colorspace.py new file mode 100644 index 0000000..f95bfd9 --- /dev/null +++ b/blessed/colorspace.py @@ -0,0 +1,973 @@ +""" +Color reference data. + +References, + +- https://github.com/freedesktop/xorg-rgb/blob/master/rgb.txt +- https://github.com/ThomasDickey/xterm-snapshots/blob/master/256colres.h +- https://github.com/ThomasDickey/xterm-snapshots/blob/master/XTerm-col.ad +- https://en.wikipedia.org/wiki/ANSI_escape_code#Colors +- https://gist.github.com/XVilka/8346728 +- https://devblogs.microsoft.com/commandline/24-bit-color-in-the-windows-console/ +- http://jdebp.uk/Softwares/nosh/guide/TerminalCapabilities.html +""" + +# std imports +import collections + +__all__ = ( + 'CGA_COLORS', + 'RGBColor', + 'RGB_256TABLE', + 'X11_COLORNAMES_TO_RGB', +) + +CGA_COLORS = {'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'} + + +class RGBColor(collections.namedtuple("RGBColor", ["red", "green", "blue"])): + """Named tuple for an RGB color definition.""" + + def __str__(self): + return '#{0:02x}{1:02x}{2:02x}'.format(*self) + + +#: X11 Color names to (XTerm-defined) RGB values from xorg-rgb/rgb.txt +X11_COLORNAMES_TO_RGB = { + 'aliceblue': RGBColor(240, 248, 255), + 'antiquewhite': RGBColor(250, 235, 215), + 'antiquewhite1': RGBColor(255, 239, 219), + 'antiquewhite2': RGBColor(238, 223, 204), + 'antiquewhite3': RGBColor(205, 192, 176), + 'antiquewhite4': RGBColor(139, 131, 120), + 'aqua': RGBColor(0, 255, 255), + 'aquamarine': RGBColor(127, 255, 212), + 'aquamarine1': RGBColor(127, 255, 212), + 'aquamarine2': RGBColor(118, 238, 198), + 'aquamarine3': RGBColor(102, 205, 170), + 'aquamarine4': RGBColor(69, 139, 116), + 'azure': RGBColor(240, 255, 255), + 'azure1': RGBColor(240, 255, 255), + 'azure2': RGBColor(224, 238, 238), + 'azure3': RGBColor(193, 205, 205), + 'azure4': RGBColor(131, 139, 139), + 'beige': RGBColor(245, 245, 220), + 'bisque': RGBColor(255, 228, 196), + 'bisque1': RGBColor(255, 228, 196), + 'bisque2': RGBColor(238, 213, 183), + 'bisque3': RGBColor(205, 183, 158), + 'bisque4': RGBColor(139, 125, 107), + 'black': RGBColor(0, 0, 0), + 'blanchedalmond': RGBColor(255, 235, 205), + 'blue': RGBColor(0, 0, 255), + 'blue1': RGBColor(0, 0, 255), + 'blue2': RGBColor(0, 0, 238), + 'blue3': RGBColor(0, 0, 205), + 'blue4': RGBColor(0, 0, 139), + 'blueviolet': RGBColor(138, 43, 226), + 'brown': RGBColor(165, 42, 42), + 'brown1': RGBColor(255, 64, 64), + 'brown2': RGBColor(238, 59, 59), + 'brown3': RGBColor(205, 51, 51), + 'brown4': RGBColor(139, 35, 35), + 'burlywood': RGBColor(222, 184, 135), + 'burlywood1': RGBColor(255, 211, 155), + 'burlywood2': RGBColor(238, 197, 145), + 'burlywood3': RGBColor(205, 170, 125), + 'burlywood4': RGBColor(139, 115, 85), + 'cadetblue': RGBColor(95, 158, 160), + 'cadetblue1': RGBColor(152, 245, 255), + 'cadetblue2': RGBColor(142, 229, 238), + 'cadetblue3': RGBColor(122, 197, 205), + 'cadetblue4': RGBColor(83, 134, 139), + 'chartreuse': RGBColor(127, 255, 0), + 'chartreuse1': RGBColor(127, 255, 0), + 'chartreuse2': RGBColor(118, 238, 0), + 'chartreuse3': RGBColor(102, 205, 0), + 'chartreuse4': RGBColor(69, 139, 0), + 'chocolate': RGBColor(210, 105, 30), + 'chocolate1': RGBColor(255, 127, 36), + 'chocolate2': RGBColor(238, 118, 33), + 'chocolate3': RGBColor(205, 102, 29), + 'chocolate4': RGBColor(139, 69, 19), + 'coral': RGBColor(255, 127, 80), + 'coral1': RGBColor(255, 114, 86), + 'coral2': RGBColor(238, 106, 80), + 'coral3': RGBColor(205, 91, 69), + 'coral4': RGBColor(139, 62, 47), + 'cornflowerblue': RGBColor(100, 149, 237), + 'cornsilk': RGBColor(255, 248, 220), + 'cornsilk1': RGBColor(255, 248, 220), + 'cornsilk2': RGBColor(238, 232, 205), + 'cornsilk3': RGBColor(205, 200, 177), + 'cornsilk4': RGBColor(139, 136, 120), + 'crimson': RGBColor(220, 20, 60), + 'cyan': RGBColor(0, 255, 255), + 'cyan1': RGBColor(0, 255, 255), + 'cyan2': RGBColor(0, 238, 238), + 'cyan3': RGBColor(0, 205, 205), + 'cyan4': RGBColor(0, 139, 139), + 'darkblue': RGBColor(0, 0, 139), + 'darkcyan': RGBColor(0, 139, 139), + 'darkgoldenrod': RGBColor(184, 134, 11), + 'darkgoldenrod1': RGBColor(255, 185, 15), + 'darkgoldenrod2': RGBColor(238, 173, 14), + 'darkgoldenrod3': RGBColor(205, 149, 12), + 'darkgoldenrod4': RGBColor(139, 101, 8), + 'darkgray': RGBColor(169, 169, 169), + 'darkgreen': RGBColor(0, 100, 0), + 'darkgrey': RGBColor(169, 169, 169), + 'darkkhaki': RGBColor(189, 183, 107), + 'darkmagenta': RGBColor(139, 0, 139), + 'darkolivegreen': RGBColor(85, 107, 47), + 'darkolivegreen1': RGBColor(202, 255, 112), + 'darkolivegreen2': RGBColor(188, 238, 104), + 'darkolivegreen3': RGBColor(162, 205, 90), + 'darkolivegreen4': RGBColor(110, 139, 61), + 'darkorange': RGBColor(255, 140, 0), + 'darkorange1': RGBColor(255, 127, 0), + 'darkorange2': RGBColor(238, 118, 0), + 'darkorange3': RGBColor(205, 102, 0), + 'darkorange4': RGBColor(139, 69, 0), + 'darkorchid': RGBColor(153, 50, 204), + 'darkorchid1': RGBColor(191, 62, 255), + 'darkorchid2': RGBColor(178, 58, 238), + 'darkorchid3': RGBColor(154, 50, 205), + 'darkorchid4': RGBColor(104, 34, 139), + 'darkred': RGBColor(139, 0, 0), + 'darksalmon': RGBColor(233, 150, 122), + 'darkseagreen': RGBColor(143, 188, 143), + 'darkseagreen1': RGBColor(193, 255, 193), + 'darkseagreen2': RGBColor(180, 238, 180), + 'darkseagreen3': RGBColor(155, 205, 155), + 'darkseagreen4': RGBColor(105, 139, 105), + 'darkslateblue': RGBColor(72, 61, 139), + 'darkslategray': RGBColor(47, 79, 79), + 'darkslategray1': RGBColor(151, 255, 255), + 'darkslategray2': RGBColor(141, 238, 238), + 'darkslategray3': RGBColor(121, 205, 205), + 'darkslategray4': RGBColor(82, 139, 139), + 'darkslategrey': RGBColor(47, 79, 79), + 'darkturquoise': RGBColor(0, 206, 209), + 'darkviolet': RGBColor(148, 0, 211), + 'deeppink': RGBColor(255, 20, 147), + 'deeppink1': RGBColor(255, 20, 147), + 'deeppink2': RGBColor(238, 18, 137), + 'deeppink3': RGBColor(205, 16, 118), + 'deeppink4': RGBColor(139, 10, 80), + 'deepskyblue': RGBColor(0, 191, 255), + 'deepskyblue1': RGBColor(0, 191, 255), + 'deepskyblue2': RGBColor(0, 178, 238), + 'deepskyblue3': RGBColor(0, 154, 205), + 'deepskyblue4': RGBColor(0, 104, 139), + 'dimgray': RGBColor(105, 105, 105), + 'dimgrey': RGBColor(105, 105, 105), + 'dodgerblue': RGBColor(30, 144, 255), + 'dodgerblue1': RGBColor(30, 144, 255), + 'dodgerblue2': RGBColor(28, 134, 238), + 'dodgerblue3': RGBColor(24, 116, 205), + 'dodgerblue4': RGBColor(16, 78, 139), + 'firebrick': RGBColor(178, 34, 34), + 'firebrick1': RGBColor(255, 48, 48), + 'firebrick2': RGBColor(238, 44, 44), + 'firebrick3': RGBColor(205, 38, 38), + 'firebrick4': RGBColor(139, 26, 26), + 'floralwhite': RGBColor(255, 250, 240), + 'forestgreen': RGBColor(34, 139, 34), + 'fuchsia': RGBColor(255, 0, 255), + 'gainsboro': RGBColor(220, 220, 220), + 'ghostwhite': RGBColor(248, 248, 255), + 'gold': RGBColor(255, 215, 0), + 'gold1': RGBColor(255, 215, 0), + 'gold2': RGBColor(238, 201, 0), + 'gold3': RGBColor(205, 173, 0), + 'gold4': RGBColor(139, 117, 0), + 'goldenrod': RGBColor(218, 165, 32), + 'goldenrod1': RGBColor(255, 193, 37), + 'goldenrod2': RGBColor(238, 180, 34), + 'goldenrod3': RGBColor(205, 155, 29), + 'goldenrod4': RGBColor(139, 105, 20), + 'gray': RGBColor(190, 190, 190), + 'gray0': RGBColor(0, 0, 0), + 'gray1': RGBColor(3, 3, 3), + 'gray10': RGBColor(26, 26, 26), + 'gray100': RGBColor(255, 255, 255), + 'gray11': RGBColor(28, 28, 28), + 'gray12': RGBColor(31, 31, 31), + 'gray13': RGBColor(33, 33, 33), + 'gray14': RGBColor(36, 36, 36), + 'gray15': RGBColor(38, 38, 38), + 'gray16': RGBColor(41, 41, 41), + 'gray17': RGBColor(43, 43, 43), + 'gray18': RGBColor(46, 46, 46), + 'gray19': RGBColor(48, 48, 48), + 'gray2': RGBColor(5, 5, 5), + 'gray20': RGBColor(51, 51, 51), + 'gray21': RGBColor(54, 54, 54), + 'gray22': RGBColor(56, 56, 56), + 'gray23': RGBColor(59, 59, 59), + 'gray24': RGBColor(61, 61, 61), + 'gray25': RGBColor(64, 64, 64), + 'gray26': RGBColor(66, 66, 66), + 'gray27': RGBColor(69, 69, 69), + 'gray28': RGBColor(71, 71, 71), + 'gray29': RGBColor(74, 74, 74), + 'gray3': RGBColor(8, 8, 8), + 'gray30': RGBColor(77, 77, 77), + 'gray31': RGBColor(79, 79, 79), + 'gray32': RGBColor(82, 82, 82), + 'gray33': RGBColor(84, 84, 84), + 'gray34': RGBColor(87, 87, 87), + 'gray35': RGBColor(89, 89, 89), + 'gray36': RGBColor(92, 92, 92), + 'gray37': RGBColor(94, 94, 94), + 'gray38': RGBColor(97, 97, 97), + 'gray39': RGBColor(99, 99, 99), + 'gray4': RGBColor(10, 10, 10), + 'gray40': RGBColor(102, 102, 102), + 'gray41': RGBColor(105, 105, 105), + 'gray42': RGBColor(107, 107, 107), + 'gray43': RGBColor(110, 110, 110), + 'gray44': RGBColor(112, 112, 112), + 'gray45': RGBColor(115, 115, 115), + 'gray46': RGBColor(117, 117, 117), + 'gray47': RGBColor(120, 120, 120), + 'gray48': RGBColor(122, 122, 122), + 'gray49': RGBColor(125, 125, 125), + 'gray5': RGBColor(13, 13, 13), + 'gray50': RGBColor(127, 127, 127), + 'gray51': RGBColor(130, 130, 130), + 'gray52': RGBColor(133, 133, 133), + 'gray53': RGBColor(135, 135, 135), + 'gray54': RGBColor(138, 138, 138), + 'gray55': RGBColor(140, 140, 140), + 'gray56': RGBColor(143, 143, 143), + 'gray57': RGBColor(145, 145, 145), + 'gray58': RGBColor(148, 148, 148), + 'gray59': RGBColor(150, 150, 150), + 'gray6': RGBColor(15, 15, 15), + 'gray60': RGBColor(153, 153, 153), + 'gray61': RGBColor(156, 156, 156), + 'gray62': RGBColor(158, 158, 158), + 'gray63': RGBColor(161, 161, 161), + 'gray64': RGBColor(163, 163, 163), + 'gray65': RGBColor(166, 166, 166), + 'gray66': RGBColor(168, 168, 168), + 'gray67': RGBColor(171, 171, 171), + 'gray68': RGBColor(173, 173, 173), + 'gray69': RGBColor(176, 176, 176), + 'gray7': RGBColor(18, 18, 18), + 'gray70': RGBColor(179, 179, 179), + 'gray71': RGBColor(181, 181, 181), + 'gray72': RGBColor(184, 184, 184), + 'gray73': RGBColor(186, 186, 186), + 'gray74': RGBColor(189, 189, 189), + 'gray75': RGBColor(191, 191, 191), + 'gray76': RGBColor(194, 194, 194), + 'gray77': RGBColor(196, 196, 196), + 'gray78': RGBColor(199, 199, 199), + 'gray79': RGBColor(201, 201, 201), + 'gray8': RGBColor(20, 20, 20), + 'gray80': RGBColor(204, 204, 204), + 'gray81': RGBColor(207, 207, 207), + 'gray82': RGBColor(209, 209, 209), + 'gray83': RGBColor(212, 212, 212), + 'gray84': RGBColor(214, 214, 214), + 'gray85': RGBColor(217, 217, 217), + 'gray86': RGBColor(219, 219, 219), + 'gray87': RGBColor(222, 222, 222), + 'gray88': RGBColor(224, 224, 224), + 'gray89': RGBColor(227, 227, 227), + 'gray9': RGBColor(23, 23, 23), + 'gray90': RGBColor(229, 229, 229), + 'gray91': RGBColor(232, 232, 232), + 'gray92': RGBColor(235, 235, 235), + 'gray93': RGBColor(237, 237, 237), + 'gray94': RGBColor(240, 240, 240), + 'gray95': RGBColor(242, 242, 242), + 'gray96': RGBColor(245, 245, 245), + 'gray97': RGBColor(247, 247, 247), + 'gray98': RGBColor(250, 250, 250), + 'gray99': RGBColor(252, 252, 252), + 'green': RGBColor(0, 255, 0), + 'green1': RGBColor(0, 255, 0), + 'green2': RGBColor(0, 238, 0), + 'green3': RGBColor(0, 205, 0), + 'green4': RGBColor(0, 139, 0), + 'greenyellow': RGBColor(173, 255, 47), + 'grey': RGBColor(190, 190, 190), + 'grey0': RGBColor(0, 0, 0), + 'grey1': RGBColor(3, 3, 3), + 'grey10': RGBColor(26, 26, 26), + 'grey100': RGBColor(255, 255, 255), + 'grey11': RGBColor(28, 28, 28), + 'grey12': RGBColor(31, 31, 31), + 'grey13': RGBColor(33, 33, 33), + 'grey14': RGBColor(36, 36, 36), + 'grey15': RGBColor(38, 38, 38), + 'grey16': RGBColor(41, 41, 41), + 'grey17': RGBColor(43, 43, 43), + 'grey18': RGBColor(46, 46, 46), + 'grey19': RGBColor(48, 48, 48), + 'grey2': RGBColor(5, 5, 5), + 'grey20': RGBColor(51, 51, 51), + 'grey21': RGBColor(54, 54, 54), + 'grey22': RGBColor(56, 56, 56), + 'grey23': RGBColor(59, 59, 59), + 'grey24': RGBColor(61, 61, 61), + 'grey25': RGBColor(64, 64, 64), + 'grey26': RGBColor(66, 66, 66), + 'grey27': RGBColor(69, 69, 69), + 'grey28': RGBColor(71, 71, 71), + 'grey29': RGBColor(74, 74, 74), + 'grey3': RGBColor(8, 8, 8), + 'grey30': RGBColor(77, 77, 77), + 'grey31': RGBColor(79, 79, 79), + 'grey32': RGBColor(82, 82, 82), + 'grey33': RGBColor(84, 84, 84), + 'grey34': RGBColor(87, 87, 87), + 'grey35': RGBColor(89, 89, 89), + 'grey36': RGBColor(92, 92, 92), + 'grey37': RGBColor(94, 94, 94), + 'grey38': RGBColor(97, 97, 97), + 'grey39': RGBColor(99, 99, 99), + 'grey4': RGBColor(10, 10, 10), + 'grey40': RGBColor(102, 102, 102), + 'grey41': RGBColor(105, 105, 105), + 'grey42': RGBColor(107, 107, 107), + 'grey43': RGBColor(110, 110, 110), + 'grey44': RGBColor(112, 112, 112), + 'grey45': RGBColor(115, 115, 115), + 'grey46': RGBColor(117, 117, 117), + 'grey47': RGBColor(120, 120, 120), + 'grey48': RGBColor(122, 122, 122), + 'grey49': RGBColor(125, 125, 125), + 'grey5': RGBColor(13, 13, 13), + 'grey50': RGBColor(127, 127, 127), + 'grey51': RGBColor(130, 130, 130), + 'grey52': RGBColor(133, 133, 133), + 'grey53': RGBColor(135, 135, 135), + 'grey54': RGBColor(138, 138, 138), + 'grey55': RGBColor(140, 140, 140), + 'grey56': RGBColor(143, 143, 143), + 'grey57': RGBColor(145, 145, 145), + 'grey58': RGBColor(148, 148, 148), + 'grey59': RGBColor(150, 150, 150), + 'grey6': RGBColor(15, 15, 15), + 'grey60': RGBColor(153, 153, 153), + 'grey61': RGBColor(156, 156, 156), + 'grey62': RGBColor(158, 158, 158), + 'grey63': RGBColor(161, 161, 161), + 'grey64': RGBColor(163, 163, 163), + 'grey65': RGBColor(166, 166, 166), + 'grey66': RGBColor(168, 168, 168), + 'grey67': RGBColor(171, 171, 171), + 'grey68': RGBColor(173, 173, 173), + 'grey69': RGBColor(176, 176, 176), + 'grey7': RGBColor(18, 18, 18), + 'grey70': RGBColor(179, 179, 179), + 'grey71': RGBColor(181, 181, 181), + 'grey72': RGBColor(184, 184, 184), + 'grey73': RGBColor(186, 186, 186), + 'grey74': RGBColor(189, 189, 189), + 'grey75': RGBColor(191, 191, 191), + 'grey76': RGBColor(194, 194, 194), + 'grey77': RGBColor(196, 196, 196), + 'grey78': RGBColor(199, 199, 199), + 'grey79': RGBColor(201, 201, 201), + 'grey8': RGBColor(20, 20, 20), + 'grey80': RGBColor(204, 204, 204), + 'grey81': RGBColor(207, 207, 207), + 'grey82': RGBColor(209, 209, 209), + 'grey83': RGBColor(212, 212, 212), + 'grey84': RGBColor(214, 214, 214), + 'grey85': RGBColor(217, 217, 217), + 'grey86': RGBColor(219, 219, 219), + 'grey87': RGBColor(222, 222, 222), + 'grey88': RGBColor(224, 224, 224), + 'grey89': RGBColor(227, 227, 227), + 'grey9': RGBColor(23, 23, 23), + 'grey90': RGBColor(229, 229, 229), + 'grey91': RGBColor(232, 232, 232), + 'grey92': RGBColor(235, 235, 235), + 'grey93': RGBColor(237, 237, 237), + 'grey94': RGBColor(240, 240, 240), + 'grey95': RGBColor(242, 242, 242), + 'grey96': RGBColor(245, 245, 245), + 'grey97': RGBColor(247, 247, 247), + 'grey98': RGBColor(250, 250, 250), + 'grey99': RGBColor(252, 252, 252), + 'honeydew': RGBColor(240, 255, 240), + 'honeydew1': RGBColor(240, 255, 240), + 'honeydew2': RGBColor(224, 238, 224), + 'honeydew3': RGBColor(193, 205, 193), + 'honeydew4': RGBColor(131, 139, 131), + 'hotpink': RGBColor(255, 105, 180), + 'hotpink1': RGBColor(255, 110, 180), + 'hotpink2': RGBColor(238, 106, 167), + 'hotpink3': RGBColor(205, 96, 144), + 'hotpink4': RGBColor(139, 58, 98), + 'indianred': RGBColor(205, 92, 92), + 'indianred1': RGBColor(255, 106, 106), + 'indianred2': RGBColor(238, 99, 99), + 'indianred3': RGBColor(205, 85, 85), + 'indianred4': RGBColor(139, 58, 58), + 'indigo': RGBColor(75, 0, 130), + 'ivory': RGBColor(255, 255, 240), + 'ivory1': RGBColor(255, 255, 240), + 'ivory2': RGBColor(238, 238, 224), + 'ivory3': RGBColor(205, 205, 193), + 'ivory4': RGBColor(139, 139, 131), + 'khaki': RGBColor(240, 230, 140), + 'khaki1': RGBColor(255, 246, 143), + 'khaki2': RGBColor(238, 230, 133), + 'khaki3': RGBColor(205, 198, 115), + 'khaki4': RGBColor(139, 134, 78), + 'lavender': RGBColor(230, 230, 250), + 'lavenderblush': RGBColor(255, 240, 245), + 'lavenderblush1': RGBColor(255, 240, 245), + 'lavenderblush2': RGBColor(238, 224, 229), + 'lavenderblush3': RGBColor(205, 193, 197), + 'lavenderblush4': RGBColor(139, 131, 134), + 'lawngreen': RGBColor(124, 252, 0), + 'lemonchiffon': RGBColor(255, 250, 205), + 'lemonchiffon1': RGBColor(255, 250, 205), + 'lemonchiffon2': RGBColor(238, 233, 191), + 'lemonchiffon3': RGBColor(205, 201, 165), + 'lemonchiffon4': RGBColor(139, 137, 112), + 'lightblue': RGBColor(173, 216, 230), + 'lightblue1': RGBColor(191, 239, 255), + 'lightblue2': RGBColor(178, 223, 238), + 'lightblue3': RGBColor(154, 192, 205), + 'lightblue4': RGBColor(104, 131, 139), + 'lightcoral': RGBColor(240, 128, 128), + 'lightcyan': RGBColor(224, 255, 255), + 'lightcyan1': RGBColor(224, 255, 255), + 'lightcyan2': RGBColor(209, 238, 238), + 'lightcyan3': RGBColor(180, 205, 205), + 'lightcyan4': RGBColor(122, 139, 139), + 'lightgoldenrod': RGBColor(238, 221, 130), + 'lightgoldenrod1': RGBColor(255, 236, 139), + 'lightgoldenrod2': RGBColor(238, 220, 130), + 'lightgoldenrod3': RGBColor(205, 190, 112), + 'lightgoldenrod4': RGBColor(139, 129, 76), + 'lightgoldenrodyellow': RGBColor(250, 250, 210), + 'lightgray': RGBColor(211, 211, 211), + 'lightgreen': RGBColor(144, 238, 144), + 'lightgrey': RGBColor(211, 211, 211), + 'lightpink': RGBColor(255, 182, 193), + 'lightpink1': RGBColor(255, 174, 185), + 'lightpink2': RGBColor(238, 162, 173), + 'lightpink3': RGBColor(205, 140, 149), + 'lightpink4': RGBColor(139, 95, 101), + 'lightsalmon': RGBColor(255, 160, 122), + 'lightsalmon1': RGBColor(255, 160, 122), + 'lightsalmon2': RGBColor(238, 149, 114), + 'lightsalmon3': RGBColor(205, 129, 98), + 'lightsalmon4': RGBColor(139, 87, 66), + 'lightseagreen': RGBColor(32, 178, 170), + 'lightskyblue': RGBColor(135, 206, 250), + 'lightskyblue1': RGBColor(176, 226, 255), + 'lightskyblue2': RGBColor(164, 211, 238), + 'lightskyblue3': RGBColor(141, 182, 205), + 'lightskyblue4': RGBColor(96, 123, 139), + 'lightslateblue': RGBColor(132, 112, 255), + 'lightslategray': RGBColor(119, 136, 153), + 'lightslategrey': RGBColor(119, 136, 153), + 'lightsteelblue': RGBColor(176, 196, 222), + 'lightsteelblue1': RGBColor(202, 225, 255), + 'lightsteelblue2': RGBColor(188, 210, 238), + 'lightsteelblue3': RGBColor(162, 181, 205), + 'lightsteelblue4': RGBColor(110, 123, 139), + 'lightyellow': RGBColor(255, 255, 224), + 'lightyellow1': RGBColor(255, 255, 224), + 'lightyellow2': RGBColor(238, 238, 209), + 'lightyellow3': RGBColor(205, 205, 180), + 'lightyellow4': RGBColor(139, 139, 122), + 'lime': RGBColor(0, 255, 0), + 'limegreen': RGBColor(50, 205, 50), + 'linen': RGBColor(250, 240, 230), + 'magenta': RGBColor(255, 0, 255), + 'magenta1': RGBColor(255, 0, 255), + 'magenta2': RGBColor(238, 0, 238), + 'magenta3': RGBColor(205, 0, 205), + 'magenta4': RGBColor(139, 0, 139), + 'maroon': RGBColor(176, 48, 96), + 'maroon1': RGBColor(255, 52, 179), + 'maroon2': RGBColor(238, 48, 167), + 'maroon3': RGBColor(205, 41, 144), + 'maroon4': RGBColor(139, 28, 98), + 'mediumaquamarine': RGBColor(102, 205, 170), + 'mediumblue': RGBColor(0, 0, 205), + 'mediumorchid': RGBColor(186, 85, 211), + 'mediumorchid1': RGBColor(224, 102, 255), + 'mediumorchid2': RGBColor(209, 95, 238), + 'mediumorchid3': RGBColor(180, 82, 205), + 'mediumorchid4': RGBColor(122, 55, 139), + 'mediumpurple': RGBColor(147, 112, 219), + 'mediumpurple1': RGBColor(171, 130, 255), + 'mediumpurple2': RGBColor(159, 121, 238), + 'mediumpurple3': RGBColor(137, 104, 205), + 'mediumpurple4': RGBColor(93, 71, 139), + 'mediumseagreen': RGBColor(60, 179, 113), + 'mediumslateblue': RGBColor(123, 104, 238), + 'mediumspringgreen': RGBColor(0, 250, 154), + 'mediumturquoise': RGBColor(72, 209, 204), + 'mediumvioletred': RGBColor(199, 21, 133), + 'midnightblue': RGBColor(25, 25, 112), + 'mintcream': RGBColor(245, 255, 250), + 'mistyrose': RGBColor(255, 228, 225), + 'mistyrose1': RGBColor(255, 228, 225), + 'mistyrose2': RGBColor(238, 213, 210), + 'mistyrose3': RGBColor(205, 183, 181), + 'mistyrose4': RGBColor(139, 125, 123), + 'moccasin': RGBColor(255, 228, 181), + 'navajowhite': RGBColor(255, 222, 173), + 'navajowhite1': RGBColor(255, 222, 173), + 'navajowhite2': RGBColor(238, 207, 161), + 'navajowhite3': RGBColor(205, 179, 139), + 'navajowhite4': RGBColor(139, 121, 94), + 'navy': RGBColor(0, 0, 128), + 'navyblue': RGBColor(0, 0, 128), + 'oldlace': RGBColor(253, 245, 230), + 'olive': RGBColor(128, 128, 0), + 'olivedrab': RGBColor(107, 142, 35), + 'olivedrab1': RGBColor(192, 255, 62), + 'olivedrab2': RGBColor(179, 238, 58), + 'olivedrab3': RGBColor(154, 205, 50), + 'olivedrab4': RGBColor(105, 139, 34), + 'orange': RGBColor(255, 165, 0), + 'orange1': RGBColor(255, 165, 0), + 'orange2': RGBColor(238, 154, 0), + 'orange3': RGBColor(205, 133, 0), + 'orange4': RGBColor(139, 90, 0), + 'orangered': RGBColor(255, 69, 0), + 'orangered1': RGBColor(255, 69, 0), + 'orangered2': RGBColor(238, 64, 0), + 'orangered3': RGBColor(205, 55, 0), + 'orangered4': RGBColor(139, 37, 0), + 'orchid': RGBColor(218, 112, 214), + 'orchid1': RGBColor(255, 131, 250), + 'orchid2': RGBColor(238, 122, 233), + 'orchid3': RGBColor(205, 105, 201), + 'orchid4': RGBColor(139, 71, 137), + 'palegoldenrod': RGBColor(238, 232, 170), + 'palegreen': RGBColor(152, 251, 152), + 'palegreen1': RGBColor(154, 255, 154), + 'palegreen2': RGBColor(144, 238, 144), + 'palegreen3': RGBColor(124, 205, 124), + 'palegreen4': RGBColor(84, 139, 84), + 'paleturquoise': RGBColor(175, 238, 238), + 'paleturquoise1': RGBColor(187, 255, 255), + 'paleturquoise2': RGBColor(174, 238, 238), + 'paleturquoise3': RGBColor(150, 205, 205), + 'paleturquoise4': RGBColor(102, 139, 139), + 'palevioletred': RGBColor(219, 112, 147), + 'palevioletred1': RGBColor(255, 130, 171), + 'palevioletred2': RGBColor(238, 121, 159), + 'palevioletred3': RGBColor(205, 104, 137), + 'palevioletred4': RGBColor(139, 71, 93), + 'papayawhip': RGBColor(255, 239, 213), + 'peachpuff': RGBColor(255, 218, 185), + 'peachpuff1': RGBColor(255, 218, 185), + 'peachpuff2': RGBColor(238, 203, 173), + 'peachpuff3': RGBColor(205, 175, 149), + 'peachpuff4': RGBColor(139, 119, 101), + 'peru': RGBColor(205, 133, 63), + 'pink': RGBColor(255, 192, 203), + 'pink1': RGBColor(255, 181, 197), + 'pink2': RGBColor(238, 169, 184), + 'pink3': RGBColor(205, 145, 158), + 'pink4': RGBColor(139, 99, 108), + 'plum': RGBColor(221, 160, 221), + 'plum1': RGBColor(255, 187, 255), + 'plum2': RGBColor(238, 174, 238), + 'plum3': RGBColor(205, 150, 205), + 'plum4': RGBColor(139, 102, 139), + 'powderblue': RGBColor(176, 224, 230), + 'purple': RGBColor(160, 32, 240), + 'purple1': RGBColor(155, 48, 255), + 'purple2': RGBColor(145, 44, 238), + 'purple3': RGBColor(125, 38, 205), + 'purple4': RGBColor(85, 26, 139), + 'rebeccapurple': RGBColor(102, 51, 153), + 'red': RGBColor(255, 0, 0), + 'red1': RGBColor(255, 0, 0), + 'red2': RGBColor(238, 0, 0), + 'red3': RGBColor(205, 0, 0), + 'red4': RGBColor(139, 0, 0), + 'rosybrown': RGBColor(188, 143, 143), + 'rosybrown1': RGBColor(255, 193, 193), + 'rosybrown2': RGBColor(238, 180, 180), + 'rosybrown3': RGBColor(205, 155, 155), + 'rosybrown4': RGBColor(139, 105, 105), + 'royalblue': RGBColor(65, 105, 225), + 'royalblue1': RGBColor(72, 118, 255), + 'royalblue2': RGBColor(67, 110, 238), + 'royalblue3': RGBColor(58, 95, 205), + 'royalblue4': RGBColor(39, 64, 139), + 'saddlebrown': RGBColor(139, 69, 19), + 'salmon': RGBColor(250, 128, 114), + 'salmon1': RGBColor(255, 140, 105), + 'salmon2': RGBColor(238, 130, 98), + 'salmon3': RGBColor(205, 112, 84), + 'salmon4': RGBColor(139, 76, 57), + 'sandybrown': RGBColor(244, 164, 96), + 'seagreen': RGBColor(46, 139, 87), + 'seagreen1': RGBColor(84, 255, 159), + 'seagreen2': RGBColor(78, 238, 148), + 'seagreen3': RGBColor(67, 205, 128), + 'seagreen4': RGBColor(46, 139, 87), + 'seashell': RGBColor(255, 245, 238), + 'seashell1': RGBColor(255, 245, 238), + 'seashell2': RGBColor(238, 229, 222), + 'seashell3': RGBColor(205, 197, 191), + 'seashell4': RGBColor(139, 134, 130), + 'sienna': RGBColor(160, 82, 45), + 'sienna1': RGBColor(255, 130, 71), + 'sienna2': RGBColor(238, 121, 66), + 'sienna3': RGBColor(205, 104, 57), + 'sienna4': RGBColor(139, 71, 38), + 'silver': RGBColor(192, 192, 192), + 'skyblue': RGBColor(135, 206, 235), + 'skyblue1': RGBColor(135, 206, 255), + 'skyblue2': RGBColor(126, 192, 238), + 'skyblue3': RGBColor(108, 166, 205), + 'skyblue4': RGBColor(74, 112, 139), + 'slateblue': RGBColor(106, 90, 205), + 'slateblue1': RGBColor(131, 111, 255), + 'slateblue2': RGBColor(122, 103, 238), + 'slateblue3': RGBColor(105, 89, 205), + 'slateblue4': RGBColor(71, 60, 139), + 'slategray': RGBColor(112, 128, 144), + 'slategray1': RGBColor(198, 226, 255), + 'slategray2': RGBColor(185, 211, 238), + 'slategray3': RGBColor(159, 182, 205), + 'slategray4': RGBColor(108, 123, 139), + 'slategrey': RGBColor(112, 128, 144), + 'snow': RGBColor(255, 250, 250), + 'snow1': RGBColor(255, 250, 250), + 'snow2': RGBColor(238, 233, 233), + 'snow3': RGBColor(205, 201, 201), + 'snow4': RGBColor(139, 137, 137), + 'springgreen': RGBColor(0, 255, 127), + 'springgreen1': RGBColor(0, 255, 127), + 'springgreen2': RGBColor(0, 238, 118), + 'springgreen3': RGBColor(0, 205, 102), + 'springgreen4': RGBColor(0, 139, 69), + 'steelblue': RGBColor(70, 130, 180), + 'steelblue1': RGBColor(99, 184, 255), + 'steelblue2': RGBColor(92, 172, 238), + 'steelblue3': RGBColor(79, 148, 205), + 'steelblue4': RGBColor(54, 100, 139), + 'tan': RGBColor(210, 180, 140), + 'tan1': RGBColor(255, 165, 79), + 'tan2': RGBColor(238, 154, 73), + 'tan3': RGBColor(205, 133, 63), + 'tan4': RGBColor(139, 90, 43), + 'teal': RGBColor(0, 128, 128), + 'thistle': RGBColor(216, 191, 216), + 'thistle1': RGBColor(255, 225, 255), + 'thistle2': RGBColor(238, 210, 238), + 'thistle3': RGBColor(205, 181, 205), + 'thistle4': RGBColor(139, 123, 139), + 'tomato': RGBColor(255, 99, 71), + 'tomato1': RGBColor(255, 99, 71), + 'tomato2': RGBColor(238, 92, 66), + 'tomato3': RGBColor(205, 79, 57), + 'tomato4': RGBColor(139, 54, 38), + 'turquoise': RGBColor(64, 224, 208), + 'turquoise1': RGBColor(0, 245, 255), + 'turquoise2': RGBColor(0, 229, 238), + 'turquoise3': RGBColor(0, 197, 205), + 'turquoise4': RGBColor(0, 134, 139), + 'violet': RGBColor(238, 130, 238), + 'violetred': RGBColor(208, 32, 144), + 'violetred1': RGBColor(255, 62, 150), + 'violetred2': RGBColor(238, 58, 140), + 'violetred3': RGBColor(205, 50, 120), + 'violetred4': RGBColor(139, 34, 82), + 'webgray': RGBColor(128, 128, 128), + 'webgreen': RGBColor(0, 128, 0), + 'webgrey': RGBColor(128, 128, 128), + 'webmaroon': RGBColor(128, 0, 0), + 'webpurple': RGBColor(128, 0, 128), + 'wheat': RGBColor(245, 222, 179), + 'wheat1': RGBColor(255, 231, 186), + 'wheat2': RGBColor(238, 216, 174), + 'wheat3': RGBColor(205, 186, 150), + 'wheat4': RGBColor(139, 126, 102), + 'white': RGBColor(255, 255, 255), + 'whitesmoke': RGBColor(245, 245, 245), + 'x11gray': RGBColor(190, 190, 190), + 'x11green': RGBColor(0, 255, 0), + 'x11grey': RGBColor(190, 190, 190), + 'x11maroon': RGBColor(176, 48, 96), + 'x11purple': RGBColor(160, 32, 240), + 'yellow': RGBColor(255, 255, 0), + 'yellow1': RGBColor(255, 255, 0), + 'yellow2': RGBColor(238, 238, 0), + 'yellow3': RGBColor(205, 205, 0), + 'yellow4': RGBColor(139, 139, 0), + 'yellowgreen': RGBColor(154, 205, 50) +} + +#: Curses color indices of 8, 16, and 256-color terminals +RGB_256TABLE = ( + RGBColor(0, 0, 0), + RGBColor(205, 0, 0), + RGBColor(0, 205, 0), + RGBColor(205, 205, 0), + RGBColor(0, 0, 238), + RGBColor(205, 0, 205), + RGBColor(0, 205, 205), + RGBColor(229, 229, 229), + RGBColor(127, 127, 127), + RGBColor(255, 0, 0), + RGBColor(0, 255, 0), + RGBColor(255, 255, 0), + RGBColor(92, 92, 255), + RGBColor(255, 0, 255), + RGBColor(0, 255, 255), + RGBColor(255, 255, 255), + RGBColor(0, 0, 0), + RGBColor(0, 0, 95), + RGBColor(0, 0, 135), + RGBColor(0, 0, 175), + RGBColor(0, 0, 215), + RGBColor(0, 0, 255), + RGBColor(0, 95, 0), + RGBColor(0, 95, 95), + RGBColor(0, 95, 135), + RGBColor(0, 95, 175), + RGBColor(0, 95, 215), + RGBColor(0, 95, 255), + RGBColor(0, 135, 0), + RGBColor(0, 135, 95), + RGBColor(0, 135, 135), + RGBColor(0, 135, 175), + RGBColor(0, 135, 215), + RGBColor(0, 135, 255), + RGBColor(0, 175, 0), + RGBColor(0, 175, 95), + RGBColor(0, 175, 135), + RGBColor(0, 175, 175), + RGBColor(0, 175, 215), + RGBColor(0, 175, 255), + RGBColor(0, 215, 0), + RGBColor(0, 215, 95), + RGBColor(0, 215, 135), + RGBColor(0, 215, 175), + RGBColor(0, 215, 215), + RGBColor(0, 215, 255), + RGBColor(0, 255, 0), + RGBColor(0, 255, 95), + RGBColor(0, 255, 135), + RGBColor(0, 255, 175), + RGBColor(0, 255, 215), + RGBColor(0, 255, 255), + RGBColor(95, 0, 0), + RGBColor(95, 0, 95), + RGBColor(95, 0, 135), + RGBColor(95, 0, 175), + RGBColor(95, 0, 215), + RGBColor(95, 0, 255), + RGBColor(95, 95, 0), + RGBColor(95, 95, 95), + RGBColor(95, 95, 135), + RGBColor(95, 95, 175), + RGBColor(95, 95, 215), + RGBColor(95, 95, 255), + RGBColor(95, 135, 0), + RGBColor(95, 135, 95), + RGBColor(95, 135, 135), + RGBColor(95, 135, 175), + RGBColor(95, 135, 215), + RGBColor(95, 135, 255), + RGBColor(95, 175, 0), + RGBColor(95, 175, 95), + RGBColor(95, 175, 135), + RGBColor(95, 175, 175), + RGBColor(95, 175, 215), + RGBColor(95, 175, 255), + RGBColor(95, 215, 0), + RGBColor(95, 215, 95), + RGBColor(95, 215, 135), + RGBColor(95, 215, 175), + RGBColor(95, 215, 215), + RGBColor(95, 215, 255), + RGBColor(95, 255, 0), + RGBColor(95, 255, 95), + RGBColor(95, 255, 135), + RGBColor(95, 255, 175), + RGBColor(95, 255, 215), + RGBColor(95, 255, 255), + RGBColor(135, 0, 0), + RGBColor(135, 0, 95), + RGBColor(135, 0, 135), + RGBColor(135, 0, 175), + RGBColor(135, 0, 215), + RGBColor(135, 0, 255), + RGBColor(135, 95, 0), + RGBColor(135, 95, 95), + RGBColor(135, 95, 135), + RGBColor(135, 95, 175), + RGBColor(135, 95, 215), + RGBColor(135, 95, 255), + RGBColor(135, 135, 0), + RGBColor(135, 135, 95), + RGBColor(135, 135, 135), + RGBColor(135, 135, 175), + RGBColor(135, 135, 215), + RGBColor(135, 135, 255), + RGBColor(135, 175, 0), + RGBColor(135, 175, 95), + RGBColor(135, 175, 135), + RGBColor(135, 175, 175), + RGBColor(135, 175, 215), + RGBColor(135, 175, 255), + RGBColor(135, 215, 0), + RGBColor(135, 215, 95), + RGBColor(135, 215, 135), + RGBColor(135, 215, 175), + RGBColor(135, 215, 215), + RGBColor(135, 215, 255), + RGBColor(135, 255, 0), + RGBColor(135, 255, 95), + RGBColor(135, 255, 135), + RGBColor(135, 255, 175), + RGBColor(135, 255, 215), + RGBColor(135, 255, 255), + RGBColor(175, 0, 0), + RGBColor(175, 0, 95), + RGBColor(175, 0, 135), + RGBColor(175, 0, 175), + RGBColor(175, 0, 215), + RGBColor(175, 0, 255), + RGBColor(175, 95, 0), + RGBColor(175, 95, 95), + RGBColor(175, 95, 135), + RGBColor(175, 95, 175), + RGBColor(175, 95, 215), + RGBColor(175, 95, 255), + RGBColor(175, 135, 0), + RGBColor(175, 135, 95), + RGBColor(175, 135, 135), + RGBColor(175, 135, 175), + RGBColor(175, 135, 215), + RGBColor(175, 135, 255), + RGBColor(175, 175, 0), + RGBColor(175, 175, 95), + RGBColor(175, 175, 135), + RGBColor(175, 175, 175), + RGBColor(175, 175, 215), + RGBColor(175, 175, 255), + RGBColor(175, 215, 0), + RGBColor(175, 215, 95), + RGBColor(175, 215, 135), + RGBColor(175, 215, 175), + RGBColor(175, 215, 215), + RGBColor(175, 215, 255), + RGBColor(175, 255, 0), + RGBColor(175, 255, 95), + RGBColor(175, 255, 135), + RGBColor(175, 255, 175), + RGBColor(175, 255, 215), + RGBColor(175, 255, 255), + RGBColor(215, 0, 0), + RGBColor(215, 0, 95), + RGBColor(215, 0, 135), + RGBColor(215, 0, 175), + RGBColor(215, 0, 215), + RGBColor(215, 0, 255), + RGBColor(215, 95, 0), + RGBColor(215, 95, 95), + RGBColor(215, 95, 135), + RGBColor(215, 95, 175), + RGBColor(215, 95, 215), + RGBColor(215, 95, 255), + RGBColor(215, 135, 0), + RGBColor(215, 135, 95), + RGBColor(215, 135, 135), + RGBColor(215, 135, 175), + RGBColor(215, 135, 215), + RGBColor(215, 135, 255), + RGBColor(215, 175, 0), + RGBColor(215, 175, 95), + RGBColor(215, 175, 135), + RGBColor(215, 175, 175), + RGBColor(215, 175, 215), + RGBColor(215, 175, 255), + RGBColor(215, 215, 0), + RGBColor(215, 215, 95), + RGBColor(215, 215, 135), + RGBColor(215, 215, 175), + RGBColor(215, 215, 215), + RGBColor(215, 215, 255), + RGBColor(215, 255, 0), + RGBColor(215, 255, 95), + RGBColor(215, 255, 135), + RGBColor(215, 255, 175), + RGBColor(215, 255, 215), + RGBColor(215, 255, 255), + RGBColor(255, 0, 0), + RGBColor(255, 0, 135), + RGBColor(255, 0, 95), + RGBColor(255, 0, 175), + RGBColor(255, 0, 215), + RGBColor(255, 0, 255), + RGBColor(255, 95, 0), + RGBColor(255, 95, 95), + RGBColor(255, 95, 135), + RGBColor(255, 95, 175), + RGBColor(255, 95, 215), + RGBColor(255, 95, 255), + RGBColor(255, 135, 0), + RGBColor(255, 135, 95), + RGBColor(255, 135, 135), + RGBColor(255, 135, 175), + RGBColor(255, 135, 215), + RGBColor(255, 135, 255), + RGBColor(255, 175, 0), + RGBColor(255, 175, 95), + RGBColor(255, 175, 135), + RGBColor(255, 175, 175), + RGBColor(255, 175, 215), + RGBColor(255, 175, 255), + RGBColor(255, 215, 0), + RGBColor(255, 215, 95), + RGBColor(255, 215, 135), + RGBColor(255, 215, 175), + RGBColor(255, 215, 215), + RGBColor(255, 215, 255), + RGBColor(255, 255, 0), + RGBColor(255, 255, 95), + RGBColor(255, 255, 135), + RGBColor(255, 255, 175), + RGBColor(255, 255, 215), + RGBColor(255, 255, 255), + RGBColor(8, 8, 8), + RGBColor(18, 18, 18), + RGBColor(28, 28, 28), + RGBColor(38, 38, 38), + RGBColor(48, 48, 48), + RGBColor(58, 58, 58), + RGBColor(68, 68, 68), + RGBColor(78, 78, 78), + RGBColor(88, 88, 88), + RGBColor(98, 98, 98), + RGBColor(108, 108, 108), + RGBColor(118, 118, 118), + RGBColor(128, 128, 128), + RGBColor(138, 138, 138), + RGBColor(148, 148, 148), + RGBColor(158, 158, 158), + RGBColor(168, 168, 168), + RGBColor(178, 178, 178), + RGBColor(188, 188, 188), + RGBColor(198, 198, 198), + RGBColor(208, 208, 208), + RGBColor(218, 218, 218), + RGBColor(228, 228, 228), + RGBColor(238, 238, 238), +) diff --git a/blessed/colorspace.py:Zone.Identifier b/blessed/colorspace.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/colorspace.pyi b/blessed/colorspace.pyi new file mode 100644 index 0000000..a799cd0 --- /dev/null +++ b/blessed/colorspace.pyi @@ -0,0 +1,12 @@ +# std imports +from typing import Set, Dict, Tuple, NamedTuple + +CGA_COLORS: Set[str] + +class RGBColor(NamedTuple): + red: int + green: int + blue: int + +X11_COLORNAMES_TO_RGB: Dict[str, RGBColor] +RGB_256TABLE: Tuple[RGBColor, ...] diff --git a/blessed/colorspace.pyi:Zone.Identifier b/blessed/colorspace.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/formatters.py b/blessed/formatters.py new file mode 100644 index 0000000..31c1097 --- /dev/null +++ b/blessed/formatters.py @@ -0,0 +1,496 @@ +"""Sub-module providing sequence-formatting functions.""" +# std imports +import platform + +# 3rd party +import six + +# local +from blessed.colorspace import CGA_COLORS, X11_COLORNAMES_TO_RGB + +# isort: off +# curses +if platform.system() == 'Windows': + import jinxed as curses # pylint: disable=import-error +else: + import curses + + +def _make_colors(): + """ + Return set of valid colors and their derivatives. + + :rtype: set + :returns: Color names with prefixes + """ + colors = set() + # basic CGA foreground color, background, high intensity, and bold + # background ('iCE colors' in my day). + for cga_color in CGA_COLORS: + colors.add(cga_color) + colors.add('on_' + cga_color) + colors.add('bright_' + cga_color) + colors.add('on_bright_' + cga_color) + + # foreground and background VGA color + for vga_color in X11_COLORNAMES_TO_RGB: + colors.add(vga_color) + colors.add('on_' + vga_color) + return colors + + +#: Valid colors and their background (on), bright, and bright-background +#: derivatives. +COLORS = _make_colors() + +#: Attributes that may be compounded with colors, by underscore, such as +#: 'reverse_indigo'. +COMPOUNDABLES = set('bold underline reverse blink italic standout'.split()) + + +class ParameterizingString(six.text_type): + r""" + A Unicode string which can be called as a parameterizing termcap. + + For example:: + + >>> from blessed import Terminal + >>> term = Terminal() + >>> color = ParameterizingString(term.color, term.normal, 'color') + >>> color(9)('color #9') + u'\x1b[91mcolor #9\x1b(B\x1b[m' + """ + + def __new__(cls, cap, normal=u'', name=u''): + # pylint: disable = missing-return-doc, missing-return-type-doc + """ + Class constructor accepting 3 positional arguments. + + :arg str cap: parameterized string suitable for curses.tparm() + :arg str normal: terminating sequence for this capability (optional). + :arg str name: name of this terminal capability (optional). + """ + new = six.text_type.__new__(cls, cap) + new._normal = normal + new._name = name + return new + + def __call__(self, *args): + """ + Returning :class:`FormattingString` instance for given parameters. + + Return evaluated terminal capability (self), receiving arguments + ``*args``, followed by the terminating sequence (self.normal) into + a :class:`FormattingString` capable of being called. + + :raises TypeError: Mismatch between capability and arguments + :raises curses.error: :func:`curses.tparm` raised an exception + :rtype: :class:`FormattingString` or :class:`NullCallableString` + :returns: Callable string for given parameters + """ + try: + # Re-encode the cap, because tparm() takes a bytestring in Python + # 3. However, appear to be a plain Unicode string otherwise so + # concats work. + attr = curses.tparm(self.encode('latin1'), *args).decode('latin1') + return FormattingString(attr, self._normal) + except TypeError as err: + # If the first non-int (i.e. incorrect) arg was a string, suggest + # something intelligent: + if args and isinstance(args[0], six.string_types): + raise TypeError( + "Unknown terminal capability, %r, or, TypeError " + "for arguments %r: %s" % (self._name, args, err)) + # Somebody passed a non-string; I don't feel confident + # guessing what they were trying to do. + raise + except curses.error as err: + # ignore 'tparm() returned NULL', you won't get any styling, + # even if does_styling is True. This happens on win32 platforms + # with http://www.lfd.uci.edu/~gohlke/pythonlibs/#curses installed + if "tparm() returned NULL" not in six.text_type(err): + raise + return NullCallableString() + + +class ParameterizingProxyString(six.text_type): + r""" + A Unicode string which can be called to proxy missing termcap entries. + + This class supports the function :func:`get_proxy_string`, and mirrors + the behavior of :class:`ParameterizingString`, except that instead of + a capability name, receives a format string, and callable to filter the + given positional ``*args`` of :meth:`ParameterizingProxyString.__call__` + into a terminal sequence. + + For example:: + + >>> from blessed import Terminal + >>> term = Terminal('screen') + >>> hpa = ParameterizingString(term.hpa, term.normal, 'hpa') + >>> hpa(9) + u'' + >>> fmt = u'\x1b[{0}G' + >>> fmt_arg = lambda *arg: (arg[0] + 1,) + >>> hpa = ParameterizingProxyString((fmt, fmt_arg), term.normal, 'hpa') + >>> hpa(9) + u'\x1b[10G' + """ + + def __new__(cls, fmt_pair, normal=u'', name=u''): + # pylint: disable = missing-return-doc, missing-return-type-doc + """ + Class constructor accepting 4 positional arguments. + + :arg tuple fmt_pair: Two element tuple containing: + - format string suitable for displaying terminal sequences + - callable suitable for receiving __call__ arguments for formatting string + :arg str normal: terminating sequence for this capability (optional). + :arg str name: name of this terminal capability (optional). + """ + assert isinstance(fmt_pair, tuple), fmt_pair + assert callable(fmt_pair[1]), fmt_pair[1] + new = six.text_type.__new__(cls, fmt_pair[0]) + new._fmt_args = fmt_pair[1] + new._normal = normal + new._name = name + return new + + def __call__(self, *args): + """ + Returning :class:`FormattingString` instance for given parameters. + + Arguments are determined by the capability. For example, ``hpa`` + (move_x) receives only a single integer, whereas ``cup`` (move) + receives two integers. See documentation in terminfo(5) for the + given capability. + + :rtype: FormattingString + :returns: Callable string for given parameters + """ + return FormattingString(self.format(*self._fmt_args(*args)), + self._normal) + + +class FormattingString(six.text_type): + r""" + A Unicode string which doubles as a callable. + + This is used for terminal attributes, so that it may be used both + directly, or as a callable. When used directly, it simply emits + the given terminal sequence. When used as a callable, it wraps the + given (string) argument with the 2nd argument used by the class + constructor:: + + >>> from blessed import Terminal + >>> term = Terminal() + >>> style = FormattingString(term.bright_blue, term.normal) + >>> print(repr(style)) + u'\x1b[94m' + >>> style('Big Blue') + u'\x1b[94mBig Blue\x1b(B\x1b[m' + """ + + def __new__(cls, sequence, normal=u''): + # pylint: disable = missing-return-doc, missing-return-type-doc + """ + Class constructor accepting 2 positional arguments. + + :arg str sequence: terminal attribute sequence. + :arg str normal: terminating sequence for this attribute (optional). + """ + new = six.text_type.__new__(cls, sequence) + new._normal = normal + return new + + def __call__(self, *args): + """ + Return ``text`` joined by ``sequence`` and ``normal``. + + :raises TypeError: Not a string type + :rtype: str + :returns: Arguments wrapped in sequence and normal + """ + # Jim Allman brings us this convenience of allowing existing + # unicode strings to be joined as a call parameter to a formatting + # string result, allowing nestation: + # + # >>> t.red('This is ', t.bold('extremely'), ' dangerous!') + for idx, ucs_part in enumerate(args): + if not isinstance(ucs_part, six.string_types): + expected_types = ', '.join(_type.__name__ for _type in six.string_types) + raise TypeError( + "TypeError for FormattingString argument, " + "%r, at position %s: expected type %s, " + "got %s" % (ucs_part, idx, expected_types, + type(ucs_part).__name__)) + postfix = u'' + if self and self._normal: + postfix = self._normal + _refresh = self._normal + self + args = [_refresh.join(ucs_part.split(self._normal)) + for ucs_part in args] + + return self + u''.join(args) + postfix + + +class FormattingOtherString(six.text_type): + r""" + A Unicode string which doubles as a callable for another sequence when called. + + This is used for the :meth:`~.Terminal.move_up`, ``down``, ``left``, and ``right()`` + family of functions:: + + >>> from blessed import Terminal + >>> term = Terminal() + >>> move_right = FormattingOtherString(term.cuf1, term.cuf) + >>> print(repr(move_right)) + u'\x1b[C' + >>> print(repr(move_right(666))) + u'\x1b[666C' + >>> print(repr(move_right())) + u'\x1b[C' + """ + + def __new__(cls, direct, target): + # pylint: disable = missing-return-doc, missing-return-type-doc + """ + Class constructor accepting 2 positional arguments. + + :arg str direct: capability name for direct formatting, eg ``('x' + term.right)``. + :arg str target: capability name for callable, eg ``('x' + term.right(99))``. + """ + new = six.text_type.__new__(cls, direct) + new._callable = target + return new + + def __getnewargs__(self): + # return arguments used for the __new__ method upon unpickling. + return six.text_type.__new__(six.text_type, self), self._callable + + def __call__(self, *args): + """Return ``text`` by ``target``.""" + return self._callable(*args) if args else self + + +class NullCallableString(six.text_type): + """ + A dummy callable Unicode alternative to :class:`FormattingString`. + + This is used for colors on terminals that do not support colors, it is just a basic form of + unicode that may also act as a callable. + """ + + def __new__(cls): + """Class constructor.""" + return six.text_type.__new__(cls, u'') + + def __call__(self, *args): + """ + Allow empty string to be callable, returning given string, if any. + + When called with an int as the first arg, return an empty Unicode. An + int is a good hint that I am a :class:`ParameterizingString`, as there + are only about half a dozen string-returning capabilities listed in + terminfo(5) which accept non-int arguments, they are seldom used. + + When called with a non-int as the first arg (no no args at all), return + the first arg, acting in place of :class:`FormattingString` without + any attributes. + """ + if not args or isinstance(args[0], int): + # As a NullCallableString, even when provided with a parameter, + # such as t.color(5), we must also still be callable, fe: + # + # >>> t.color(5)('shmoo') + # + # is actually simplified result of NullCallable()() on terminals + # without color support, so turtles all the way down: we return + # another instance. + return NullCallableString() + return u''.join(args) + + +def get_proxy_string(term, attr): + """ + Proxy and return callable string for proxied attributes. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str attr: terminal capability name that may be proxied. + :rtype: None or :class:`ParameterizingProxyString`. + :returns: :class:`ParameterizingProxyString` for some attributes + of some terminal types that support it, where the terminfo(5) + database would otherwise come up empty, such as ``move_x`` + attribute for ``term.kind`` of ``screen``. Otherwise, None. + """ + # normalize 'screen-256color', or 'ansi.sys' to its basic names + term_kind = next(iter(_kind for _kind in ('screen', 'ansi',) + if term.kind.startswith(_kind)), term) + _proxy_table = { # pragma: no cover + 'screen': { + # proxy move_x/move_y for 'screen' terminal type, used by tmux(1). + 'hpa': ParameterizingProxyString( + (u'\x1b[{0}G', lambda *arg: (arg[0] + 1,)), term.normal, attr), + 'vpa': ParameterizingProxyString( + (u'\x1b[{0}d', lambda *arg: (arg[0] + 1,)), term.normal, attr), + }, + 'ansi': { + # proxy show/hide cursor for 'ansi' terminal type. There is some + # demand for a richly working ANSI terminal type for some reason. + 'civis': ParameterizingProxyString( + (u'\x1b[?25l', lambda *arg: ()), term.normal, attr), + 'cnorm': ParameterizingProxyString( + (u'\x1b[?25h', lambda *arg: ()), term.normal, attr), + 'hpa': ParameterizingProxyString( + (u'\x1b[{0}G', lambda *arg: (arg[0] + 1,)), term.normal, attr), + 'vpa': ParameterizingProxyString( + (u'\x1b[{0}d', lambda *arg: (arg[0] + 1,)), term.normal, attr), + 'sc': '\x1b[s', + 'rc': '\x1b[u', + } + } + return _proxy_table.get(term_kind, {}).get(attr, None) + + +def split_compound(compound): + """ + Split compound formating string into segments. + + >>> split_compound('bold_underline_bright_blue_on_red') + ['bold', 'underline', 'bright_blue', 'on_red'] + + :arg str compound: a string that may contain compounds, separated by + underline (``_``). + :rtype: list + :returns: List of formating string segments + """ + merged_segs = [] + # These occur only as prefixes, so they can always be merged: + mergeable_prefixes = ['on', 'bright', 'on_bright'] + for segment in compound.split('_'): + if merged_segs and merged_segs[-1] in mergeable_prefixes: + merged_segs[-1] += '_' + segment + else: + merged_segs.append(segment) + return merged_segs + + +def resolve_capability(term, attr): + """ + Resolve a raw terminal capability using :func:`tigetstr`. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str attr: terminal capability name. + :returns: string of the given terminal capability named by ``attr``, + which may be empty (u'') if not found or not supported by the + given :attr:`~.Terminal.kind`. + :rtype: str + """ + if not term.does_styling: + return u'' + val = curses.tigetstr(term._sugar.get(attr, attr)) # pylint: disable=protected-access + # Decode sequences as latin1, as they are always 8-bit bytes, so when + # b'\xff' is returned, this is decoded as u'\xff'. + return u'' if val is None else val.decode('latin1') + + +def resolve_color(term, color): + """ + Resolve a simple color name to a callable capability. + + This function supports :func:`resolve_attribute`. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str color: any string found in set :const:`COLORS`. + :returns: a string class instance which emits the terminal sequence + for the given color, and may be used as a callable to wrap the + given string with such sequence. + :returns: :class:`NullCallableString` when + :attr:`~.Terminal.number_of_colors` is 0, + otherwise :class:`FormattingString`. + :rtype: :class:`NullCallableString` or :class:`FormattingString` + """ + # pylint: disable=protected-access + if term.number_of_colors == 0: + return NullCallableString() + + # fg/bg capabilities terminals that support 0-256+ colors. + vga_color_cap = (term._background_color if 'on_' in color else + term._foreground_color) + + base_color = color.rsplit('_', 1)[-1] + if base_color in CGA_COLORS: + # curses constants go up to only 7, so add an offset to get at the + # bright colors at 8-15: + offset = 8 if 'bright_' in color else 0 + base_color = color.rsplit('_', 1)[-1] + attr = 'COLOR_%s' % (base_color.upper(),) + fmt_attr = vga_color_cap(getattr(curses, attr) + offset) + return FormattingString(fmt_attr, term.normal) + + assert base_color in X11_COLORNAMES_TO_RGB, ( + 'color not known', base_color) + rgb = X11_COLORNAMES_TO_RGB[base_color] + + # downconvert X11 colors to CGA, EGA, or VGA color spaces + if term.number_of_colors <= 256: + fmt_attr = vga_color_cap(term.rgb_downconvert(*rgb)) + return FormattingString(fmt_attr, term.normal) + + # Modern 24-bit color terminals are written pretty basically. The + # foreground and background sequences are: + # - ^[38;2;;;m + # - ^[48;2;;;m + fgbg_seq = ('48' if 'on_' in color else '38') + assert term.number_of_colors == 1 << 24 + fmt_attr = u'\x1b[' + fgbg_seq + ';2;{0};{1};{2}m' + return FormattingString(fmt_attr.format(*rgb), term.normal) + + +def resolve_attribute(term, attr): + """ + Resolve a terminal attribute name into a capability class. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str attr: Sugary, ordinary, or compound formatted terminal + capability, such as "red_on_white", "normal", "red", or + "bold_on_black". + :returns: a string class instance which emits the terminal sequence + for the given terminal capability, or may be used as a callable to + wrap the given string with such sequence. + :returns: :class:`NullCallableString` when + :attr:`~.Terminal.number_of_colors` is 0, + otherwise :class:`FormattingString`. + :rtype: :class:`NullCallableString` or :class:`FormattingString` + """ + if attr in COLORS: + return resolve_color(term, attr) + + # A direct compoundable, such as `bold' or `on_red'. + if attr in COMPOUNDABLES: + sequence = resolve_capability(term, attr) + return FormattingString(sequence, term.normal) + + # Given `bold_on_red', resolve to ('bold', 'on_red'), RECURSIVE + # call for each compounding section, joined and returned as + # a completed completed FormattingString. + formatters = split_compound(attr) + if all((fmt in COLORS or fmt in COMPOUNDABLES) for fmt in formatters): + resolution = (resolve_attribute(term, fmt) for fmt in formatters) + return FormattingString(u''.join(resolution), term.normal) + + # otherwise, this is our end-game: given a sequence such as 'csr' + # (change scrolling region), return a ParameterizingString instance, + # that when called, performs and returns the final string after curses + # capability lookup is performed. + tparm_capseq = resolve_capability(term, attr) + if not tparm_capseq: + # and, for special terminals, such as 'screen', provide a Proxy + # ParameterizingString for attributes they do not claim to support, + # but actually do! (such as 'hpa' and 'vpa'). + proxy = get_proxy_string(term, + term._sugar.get(attr, attr)) # pylint: disable=protected-access + if proxy is not None: + return proxy + + return ParameterizingString(tparm_capseq, term.normal, attr) diff --git a/blessed/formatters.py:Zone.Identifier b/blessed/formatters.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/formatters.pyi b/blessed/formatters.pyi new file mode 100644 index 0000000..32a3dc2 --- /dev/null +++ b/blessed/formatters.pyi @@ -0,0 +1,70 @@ +# std imports +from typing import (Any, + Set, + List, + Type, + Tuple, + Union, + TypeVar, + Callable, + NoReturn, + Optional, + overload) + +# local +from .terminal import Terminal + +COLORS: Set[str] +COMPOUNDABLES: Set[str] + +_T = TypeVar("_T") + +class ParameterizingString(str): + def __new__(cls: Type[_T], cap: str, normal: str = ..., name: str = ...) -> _T: ... + @overload + def __call__( + self, *args: int + ) -> Union["FormattingString", "NullCallableString"]: ... + @overload + def __call__(self, *args: str) -> NoReturn: ... + +class ParameterizingProxyString(str): + def __new__( + cls: Type[_T], + fmt_pair: Tuple[str, Callable[..., Tuple[object, ...]]], + normal: str = ..., + name: str = ..., + ) -> _T: ... + def __call__(self, *args: Any) -> "FormattingString": ... + +class FormattingString(str): + def __new__(cls: Type[_T], sequence: str, normal: str = ...) -> _T: ... + @overload + def __call__(self, *args: int) -> NoReturn: ... + @overload + def __call__(self, *args: str) -> str: ... + +class FormattingOtherString(str): + def __new__( + cls: Type[_T], direct: ParameterizingString, target: ParameterizingString = ... + ) -> _T: ... + def __call__(self, *args: Union[int, str]) -> str: ... + +class NullCallableString(str): + def __new__(cls: Type[_T]) -> _T: ... + @overload + def __call__(self, *args: int) -> "NullCallableString": ... + @overload + def __call__(self, *args: str) -> str: ... + +def get_proxy_string( + term: Terminal, attr: str +) -> Optional[ParameterizingProxyString]: ... +def split_compound(compound: str) -> List[str]: ... +def resolve_capability(term: Terminal, attr: str) -> str: ... +def resolve_color( + term: Terminal, color: str +) -> Union[NullCallableString, FormattingString]: ... +def resolve_attribute( + term: Terminal, attr: str +) -> Union[ParameterizingString, FormattingString]: ... diff --git a/blessed/formatters.pyi:Zone.Identifier b/blessed/formatters.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/keyboard.py b/blessed/keyboard.py new file mode 100644 index 0000000..31cc98c --- /dev/null +++ b/blessed/keyboard.py @@ -0,0 +1,451 @@ +"""Sub-module providing 'keyboard awareness'.""" + +# std imports +import re +import time +import platform +from collections import OrderedDict + +# 3rd party +import six + +# isort: off +# curses +if platform.system() == 'Windows': + # pylint: disable=import-error + import jinxed as curses + from jinxed.has_key import _capability_names as capability_names +else: + import curses + from curses.has_key import _capability_names as capability_names + + +class Keystroke(six.text_type): + """ + A unicode-derived class for describing a single keystroke. + + A class instance describes a single keystroke received on input, + which may contain multiple characters as a multibyte sequence, + which is indicated by properties :attr:`is_sequence` returning + ``True``. + + When the string is a known sequence, :attr:`code` matches terminal + class attributes for comparison, such as ``term.KEY_LEFT``. + + The string-name of the sequence, such as ``u'KEY_LEFT'`` is accessed + by property :attr:`name`, and is used by the :meth:`__repr__` method + to display a human-readable form of the Keystroke this class + instance represents. It may otherwise by joined, split, or evaluated + just as as any other unicode string. + """ + + def __new__(cls, ucs='', code=None, name=None): + """Class constructor.""" + new = six.text_type.__new__(cls, ucs) + new._name = name + new._code = code + return new + + @property + def is_sequence(self): + """Whether the value represents a multibyte sequence (bool).""" + return self._code is not None + + def __repr__(self): + """Docstring overwritten.""" + return (six.text_type.__repr__(self) if self._name is None else + self._name) + __repr__.__doc__ = six.text_type.__doc__ + + @property + def name(self): + """String-name of key sequence, such as ``u'KEY_LEFT'`` (str).""" + return self._name + + @property + def code(self): + """Integer keycode value of multibyte sequence (int).""" + return self._code + + +def get_curses_keycodes(): + """ + Return mapping of curses key-names paired by their keycode integer value. + + :rtype: dict + :returns: Dictionary of (name, code) pairs for curses keyboard constant + values and their mnemonic name. Such as code ``260``, with the value of + its key-name identity, ``u'KEY_LEFT'``. + """ + _keynames = [attr for attr in dir(curses) + if attr.startswith('KEY_')] + return {keyname: getattr(curses, keyname) for keyname in _keynames} + + +def get_keyboard_codes(): + """ + Return mapping of keycode integer values paired by their curses key-name. + + :rtype: dict + :returns: Dictionary of (code, name) pairs for curses keyboard constant + values and their mnemonic name. Such as key ``260``, with the value of + its identity, ``u'KEY_LEFT'``. + + These keys are derived from the attributes by the same of the curses module, + with the following exceptions: + + * ``KEY_DELETE`` in place of ``KEY_DC`` + * ``KEY_INSERT`` in place of ``KEY_IC`` + * ``KEY_PGUP`` in place of ``KEY_PPAGE`` + * ``KEY_PGDOWN`` in place of ``KEY_NPAGE`` + * ``KEY_ESCAPE`` in place of ``KEY_EXIT`` + * ``KEY_SUP`` in place of ``KEY_SR`` + * ``KEY_SDOWN`` in place of ``KEY_SF`` + + This function is the inverse of :func:`get_curses_keycodes`. With the + given override "mixins" listed above, the keycode for the delete key will + map to our imaginary ``KEY_DELETE`` mnemonic, effectively erasing the + phrase ``KEY_DC`` from our code vocabulary for anyone that wishes to use + the return value to determine the key-name by keycode. + """ + keycodes = OrderedDict(get_curses_keycodes()) + keycodes.update(CURSES_KEYCODE_OVERRIDE_MIXIN) + # merge _CURSES_KEYCODE_ADDINS added to our module space + keycodes.update( + (name, value) for name, value in globals().copy().items() if name.startswith('KEY_') + ) + + # invert dictionary (key, values) => (values, key), preferring the + # last-most inserted value ('KEY_DELETE' over 'KEY_DC'). + return dict(zip(keycodes.values(), keycodes.keys())) + + +def _alternative_left_right(term): + r""" + Determine and return mapping of left and right arrow keys sequences. + + :arg blessed.Terminal term: :class:`~.Terminal` instance. + :rtype: dict + :returns: Dictionary of sequences ``term._cuf1``, and ``term._cub1``, + valued as ``KEY_RIGHT``, ``KEY_LEFT`` (when appropriate). + + This function supports :func:`get_terminal_sequences` to discover + the preferred input sequence for the left and right application keys. + + It is necessary to check the value of these sequences to ensure we do not + use ``u' '`` and ``u'\b'`` for ``KEY_RIGHT`` and ``KEY_LEFT``, + preferring their true application key sequence, instead. + """ + # pylint: disable=protected-access + keymap = {} + if term._cuf1 and term._cuf1 != u' ': + keymap[term._cuf1] = curses.KEY_RIGHT + if term._cub1 and term._cub1 != u'\b': + keymap[term._cub1] = curses.KEY_LEFT + return keymap + + +def get_keyboard_sequences(term): + r""" + Return mapping of keyboard sequences paired by keycodes. + + :arg blessed.Terminal term: :class:`~.Terminal` instance. + :returns: mapping of keyboard unicode sequences paired by keycodes + as integer. This is used as the argument ``mapper`` to + the supporting function :func:`resolve_sequence`. + :rtype: OrderedDict + + Initialize and return a keyboard map and sequence lookup table, + (sequence, keycode) from :class:`~.Terminal` instance ``term``, + where ``sequence`` is a multibyte input sequence of unicode + characters, such as ``u'\x1b[D'``, and ``keycode`` is an integer + value, matching curses constant such as term.KEY_LEFT. + + The return value is an OrderedDict instance, with their keys + sorted longest-first. + """ + # A small gem from curses.has_key that makes this all possible, + # _capability_names: a lookup table of terminal capability names for + # keyboard sequences (fe. kcub1, key_left), keyed by the values of + # constants found beginning with KEY_ in the main curses module + # (such as KEY_LEFT). + # + # latin1 encoding is used so that bytes in 8-bit range of 127-255 + # have equivalent chr() and unichr() values, so that the sequence + # of a kermit or avatar terminal, for example, remains unchanged + # in its byte sequence values even when represented by unicode. + # + sequence_map = dict(( + (seq.decode('latin1'), val) + for (seq, val) in ( + (curses.tigetstr(cap), val) + for (val, cap) in capability_names.items() + ) if seq + ) if term.does_styling else ()) + + sequence_map.update(_alternative_left_right(term)) + sequence_map.update(DEFAULT_SEQUENCE_MIXIN) + + # This is for fast lookup matching of sequences, preferring + # full-length sequence such as ('\x1b[D', KEY_LEFT) + # over simple sequences such as ('\x1b', KEY_EXIT). + return OrderedDict(( + (seq, sequence_map[seq]) for seq in sorted( + sequence_map.keys(), key=len, reverse=True))) + + +def get_leading_prefixes(sequences): + """ + Return a set of proper prefixes for given sequence of strings. + + :arg iterable sequences + :rtype: set + :return: Set of all string prefixes + + Given an iterable of strings, all textparts leading up to the final + string is returned as a unique set. This function supports the + :meth:`~.Terminal.inkey` method by determining whether the given + input is a sequence that **may** lead to a final matching pattern. + + >>> prefixes(['abc', 'abdf', 'e', 'jkl']) + set([u'a', u'ab', u'abd', u'j', u'jk']) + """ + return {seq[:i] for seq in sequences for i in range(1, len(seq))} + + +def resolve_sequence(text, mapper, codes): + r""" + Return a single :class:`Keystroke` instance for given sequence ``text``. + + :arg str text: string of characters received from terminal input stream. + :arg OrderedDict mapper: unicode multibyte sequences, such as ``u'\x1b[D'`` + paired by their integer value (260) + :arg dict codes: a :type:`dict` of integer values (such as 260) paired + by their mnemonic name, such as ``'KEY_LEFT'``. + :rtype: Keystroke + :returns: Keystroke instance for the given sequence + + The given ``text`` may extend beyond a matching sequence, such as + ``u\x1b[Dxxx`` returns a :class:`Keystroke` instance of attribute + :attr:`Keystroke.sequence` valued only ``u\x1b[D``. It is up to + calls to determine that ``xxx`` remains unresolved. + """ + for sequence, code in mapper.items(): + if text.startswith(sequence): + return Keystroke(ucs=sequence, code=code, name=codes[code]) + return Keystroke(ucs=text and text[0] or u'') + + +def _time_left(stime, timeout): + """ + Return time remaining since ``stime`` before given ``timeout``. + + This function assists determining the value of ``timeout`` for + class method :meth:`~.Terminal.kbhit` and similar functions. + + :arg float stime: starting time for measurement + :arg float timeout: timeout period, may be set to None to + indicate no timeout (where None is always returned). + :rtype: float or int + :returns: time remaining as float. If no time is remaining, + then the integer ``0`` is returned. + """ + return max(0, timeout - (time.time() - stime)) if timeout else timeout + + +def _read_until(term, pattern, timeout): + """ + Convenience read-until-pattern function, supporting :meth:`~.get_location`. + + :arg blessed.Terminal term: :class:`~.Terminal` instance. + :arg float timeout: timeout period, may be set to None to indicate no + timeout (where 0 is always returned). + :arg str pattern: target regular expression pattern to seek. + :rtype: tuple + :returns: tuple in form of ``(match, str)``, *match* + may be :class:`re.MatchObject` if pattern is discovered + in input stream before timeout has elapsed, otherwise + None. ``str`` is any remaining text received exclusive + of the matching pattern). + + The reason a tuple containing non-matching data is returned, is that the + consumer should push such data back into the input buffer by + :meth:`~.Terminal.ungetch` if any was received. + + For example, when a user is performing rapid input keystrokes while its + terminal emulator surreptitiously responds to this in-band sequence, we + must ensure any such keyboard data is well-received by the next call to + term.inkey() without delay. + """ + stime = time.time() + match, buf = None, u'' + + # first, buffer all pending data. pexpect library provides a + # 'searchwindowsize' attribute that limits this memory region. We're not + # concerned about OOM conditions: only (human) keyboard input and terminal + # response sequences are expected. + + while True: # pragma: no branch + # block as long as necessary to ensure at least one character is + # received on input or remaining timeout has elapsed. + ucs = term.inkey(timeout=_time_left(stime, timeout)) + # while the keyboard buffer is "hot" (has input), we continue to + # aggregate all awaiting data. We do this to ensure slow I/O + # calls do not unnecessarily give up within the first 'while' loop + # for short timeout periods. + while ucs: + buf += ucs + ucs = term.inkey(timeout=0) + + match = re.search(pattern=pattern, string=buf) + if match is not None: + # match + break + + if timeout is not None and not _time_left(stime, timeout): + # timeout + break + + return match, buf + + +#: Though we may determine *keynames* and codes for keyboard input that +#: generate multibyte sequences, it is also especially useful to aliases +#: a few basic ASCII characters such as ``KEY_TAB`` instead of ``u'\t'`` for +#: uniformity. +#: +#: Furthermore, many key-names for application keys enabled only by context +#: manager :meth:`~.Terminal.keypad` are surprisingly absent. We inject them +#: here directly into the curses module. +_CURSES_KEYCODE_ADDINS = ( + 'TAB', + 'KP_MULTIPLY', + 'KP_ADD', + 'KP_SEPARATOR', + 'KP_SUBTRACT', + 'KP_DECIMAL', + 'KP_DIVIDE', + 'KP_EQUAL', + 'KP_0', + 'KP_1', + 'KP_2', + 'KP_3', + 'KP_4', + 'KP_5', + 'KP_6', + 'KP_7', + 'KP_8', + 'KP_9') + +_LASTVAL = max(get_curses_keycodes().values()) +for keycode_name in _CURSES_KEYCODE_ADDINS: + _LASTVAL += 1 + globals()['KEY_' + keycode_name] = _LASTVAL + +#: In a perfect world, terminal emulators would always send exactly what +#: the terminfo(5) capability database plans for them, accordingly by the +#: value of the ``TERM`` name they declare. +#: +#: But this isn't a perfect world. Many vt220-derived terminals, such as +#: those declaring 'xterm', will continue to send vt220 codes instead of +#: their native-declared codes, for backwards-compatibility. +#: +#: This goes for many: rxvt, putty, iTerm. +#: +#: These "mixins" are used for *all* terminals, regardless of their type. +#: +#: Furthermore, curses does not provide sequences sent by the keypad, +#: at least, it does not provide a way to distinguish between keypad 0 +#: and numeric 0. +DEFAULT_SEQUENCE_MIXIN = ( + # these common control characters (and 127, ctrl+'?') mapped to + # an application key definition. + (six.unichr(10), curses.KEY_ENTER), + (six.unichr(13), curses.KEY_ENTER), + (six.unichr(8), curses.KEY_BACKSPACE), + (six.unichr(9), KEY_TAB), # noqa # pylint: disable=undefined-variable + (six.unichr(27), curses.KEY_EXIT), + (six.unichr(127), curses.KEY_BACKSPACE), + + (u"\x1b[A", curses.KEY_UP), + (u"\x1b[B", curses.KEY_DOWN), + (u"\x1b[C", curses.KEY_RIGHT), + (u"\x1b[D", curses.KEY_LEFT), + (u"\x1b[1;2A", curses.KEY_SR), + (u"\x1b[1;2B", curses.KEY_SF), + (u"\x1b[1;2C", curses.KEY_SRIGHT), + (u"\x1b[1;2D", curses.KEY_SLEFT), + (u"\x1b[F", curses.KEY_END), + (u"\x1b[H", curses.KEY_HOME), + # not sure where these are from .. please report + (u"\x1b[K", curses.KEY_END), + (u"\x1b[U", curses.KEY_NPAGE), + (u"\x1b[V", curses.KEY_PPAGE), + + # keys sent after term.smkx (keypad_xmit) is emitted, source: + # http://www.xfree86.org/current/ctlseqs.html#PC-Style%20Function%20Keys + # http://fossies.org/linux/rxvt/doc/rxvtRef.html#KeyCodes + # + # keypad, numlock on + (u"\x1bOM", curses.KEY_ENTER), # noqa return + (u"\x1bOj", KEY_KP_MULTIPLY), # noqa * # pylint: disable=undefined-variable + (u"\x1bOk", KEY_KP_ADD), # noqa + # pylint: disable=undefined-variable + (u"\x1bOl", KEY_KP_SEPARATOR), # noqa , # pylint: disable=undefined-variable + (u"\x1bOm", KEY_KP_SUBTRACT), # noqa - # pylint: disable=undefined-variable + (u"\x1bOn", KEY_KP_DECIMAL), # noqa . # pylint: disable=undefined-variable + (u"\x1bOo", KEY_KP_DIVIDE), # noqa / # pylint: disable=undefined-variable + (u"\x1bOX", KEY_KP_EQUAL), # noqa = # pylint: disable=undefined-variable + (u"\x1bOp", KEY_KP_0), # noqa 0 # pylint: disable=undefined-variable + (u"\x1bOq", KEY_KP_1), # noqa 1 # pylint: disable=undefined-variable + (u"\x1bOr", KEY_KP_2), # noqa 2 # pylint: disable=undefined-variable + (u"\x1bOs", KEY_KP_3), # noqa 3 # pylint: disable=undefined-variable + (u"\x1bOt", KEY_KP_4), # noqa 4 # pylint: disable=undefined-variable + (u"\x1bOu", KEY_KP_5), # noqa 5 # pylint: disable=undefined-variable + (u"\x1bOv", KEY_KP_6), # noqa 6 # pylint: disable=undefined-variable + (u"\x1bOw", KEY_KP_7), # noqa 7 # pylint: disable=undefined-variable + (u"\x1bOx", KEY_KP_8), # noqa 8 # pylint: disable=undefined-variable + (u"\x1bOy", KEY_KP_9), # noqa 9 # pylint: disable=undefined-variable + + # keypad, numlock off + (u"\x1b[1~", curses.KEY_FIND), # find + (u"\x1b[2~", curses.KEY_IC), # insert (0) + (u"\x1b[3~", curses.KEY_DC), # delete (.), "Execute" + (u"\x1b[4~", curses.KEY_SELECT), # select + (u"\x1b[5~", curses.KEY_PPAGE), # pgup (9) + (u"\x1b[6~", curses.KEY_NPAGE), # pgdown (3) + (u"\x1b[7~", curses.KEY_HOME), # home + (u"\x1b[8~", curses.KEY_END), # end + (u"\x1b[OA", curses.KEY_UP), # up (8) + (u"\x1b[OB", curses.KEY_DOWN), # down (2) + (u"\x1b[OC", curses.KEY_RIGHT), # right (6) + (u"\x1b[OD", curses.KEY_LEFT), # left (4) + (u"\x1b[OF", curses.KEY_END), # end (1) + (u"\x1b[OH", curses.KEY_HOME), # home (7) + + # The vt220 placed F1-F4 above the keypad, in place of actual + # F1-F4 were local functions (hold screen, print screen, + # set up, data/talk, break). + (u"\x1bOP", curses.KEY_F1), + (u"\x1bOQ", curses.KEY_F2), + (u"\x1bOR", curses.KEY_F3), + (u"\x1bOS", curses.KEY_F4), +) + +#: Override mixins for a few curses constants with easier +#: mnemonics: there may only be a 1:1 mapping when only a +#: keycode (int) is given, where these phrases are preferred. +CURSES_KEYCODE_OVERRIDE_MIXIN = ( + ('KEY_DELETE', curses.KEY_DC), + ('KEY_INSERT', curses.KEY_IC), + ('KEY_PGUP', curses.KEY_PPAGE), + ('KEY_PGDOWN', curses.KEY_NPAGE), + ('KEY_ESCAPE', curses.KEY_EXIT), + ('KEY_SUP', curses.KEY_SR), + ('KEY_SDOWN', curses.KEY_SF), + ('KEY_UP_LEFT', curses.KEY_A1), + ('KEY_UP_RIGHT', curses.KEY_A3), + ('KEY_CENTER', curses.KEY_B2), + ('KEY_BEGIN', curses.KEY_BEG), +) + +__all__ = ('Keystroke', 'get_keyboard_codes', 'get_keyboard_sequences',) diff --git a/blessed/keyboard.py:Zone.Identifier b/blessed/keyboard.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/keyboard.pyi b/blessed/keyboard.pyi new file mode 100644 index 0000000..ae76393 --- /dev/null +++ b/blessed/keyboard.pyi @@ -0,0 +1,28 @@ +# std imports +from typing import Set, Dict, Type, Mapping, TypeVar, Iterable, Optional, OrderedDict + +# local +from .terminal import Terminal + +_T = TypeVar("_T") + +class Keystroke(str): + def __new__( + cls: Type[_T], + ucs: str = ..., + code: Optional[int] = ..., + name: Optional[str] = ..., + ) -> _T: ... + @property + def is_sequence(self) -> bool: ... + @property + def name(self) -> Optional[str]: ... + @property + def code(self) -> Optional[int]: ... + +def get_keyboard_codes() -> Dict[int, str]: ... +def get_keyboard_sequences(term: Terminal) -> OrderedDict[str, int]: ... +def get_leading_prefixes(sequences: Iterable[str]) -> Set[str]: ... +def resolve_sequence( + text: str, mapper: Mapping[str, int], codes: Mapping[int, str] +) -> Keystroke: ... diff --git a/blessed/keyboard.pyi:Zone.Identifier b/blessed/keyboard.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/py.typed b/blessed/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/blessed/py.typed:Zone.Identifier b/blessed/py.typed:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/sequences.py b/blessed/sequences.py new file mode 100644 index 0000000..a5fe0d6 --- /dev/null +++ b/blessed/sequences.py @@ -0,0 +1,461 @@ +# -*- coding: utf-8 -*- +"""Module providing 'sequence awareness'.""" +# std imports +import re +import math +import textwrap + +# 3rd party +import six +from wcwidth import wcwidth + +# local +from blessed._capabilities import CAPABILITIES_CAUSE_MOVEMENT + +__all__ = ('Sequence', 'SequenceTextWrapper', 'iter_parse', 'measure_length') + + +class Termcap(object): + """Terminal capability of given variable name and pattern.""" + + def __init__(self, name, pattern, attribute): + """ + Class initializer. + + :arg str name: name describing capability. + :arg str pattern: regular expression string. + :arg str attribute: :class:`~.Terminal` attribute used to build + this terminal capability. + """ + self.name = name + self.pattern = pattern + self.attribute = attribute + self._re_compiled = None + + def __repr__(self): + # pylint: disable=redundant-keyword-arg + return ''.format(self=self) + + @property + def named_pattern(self): + """Regular expression pattern for capability with named group.""" + # pylint: disable=redundant-keyword-arg + return '(?P<{self.name}>{self.pattern})'.format(self=self) + + @property + def re_compiled(self): + """Compiled regular expression pattern for capability.""" + if self._re_compiled is None: + self._re_compiled = re.compile(self.pattern) + return self._re_compiled + + @property + def will_move(self): + """Whether capability causes cursor movement.""" + return self.name in CAPABILITIES_CAUSE_MOVEMENT + + def horizontal_distance(self, text): + """ + Horizontal carriage adjusted by capability, may be negative. + + :rtype: int + :arg str text: for capabilities *parm_left_cursor*, + *parm_right_cursor*, provide the matching sequence + text, its interpreted distance is returned. + + :returns: 0 except for matching ' + """ + value = { + 'cursor_left': -1, + 'backspace': -1, + 'cursor_right': 1, + 'tab': 8, + 'ascii_tab': 8, + }.get(self.name) + if value is not None: + return value + + unit = { + 'parm_left_cursor': -1, + 'parm_right_cursor': 1 + }.get(self.name) + if unit is not None: + value = int(self.re_compiled.match(text).group(1)) + return unit * value + + return 0 + + # pylint: disable=too-many-arguments + @classmethod + def build(cls, name, capability, attribute, nparams=0, + numeric=99, match_grouped=False, match_any=False, + match_optional=False): + r""" + Class factory builder for given capability definition. + + :arg str name: Variable name given for this pattern. + :arg str capability: A unicode string representing a terminal + capability to build for. When ``nparams`` is non-zero, it + must be a callable unicode string (such as the result from + ``getattr(term, 'bold')``. + :arg str attribute: The terminfo(5) capability name by which this + pattern is known. + :arg int nparams: number of positional arguments for callable. + :arg int numeric: Value to substitute into capability to when generating pattern + :arg bool match_grouped: If the numeric pattern should be + grouped, ``(\d+)`` when ``True``, ``\d+`` default. + :arg bool match_any: When keyword argument ``nparams`` is given, + *any* numeric found in output is suitable for building as + pattern ``(\d+)``. Otherwise, only the first matching value of + range *(numeric - 1)* through *(numeric + 1)* will be replaced by + pattern ``(\d+)`` in builder. + :arg bool match_optional: When ``True``, building of numeric patterns + containing ``(\d+)`` will be built as optional, ``(\d+)?``. + :rtype: blessed.sequences.Termcap + :returns: Terminal capability instance for given capability definition + """ + _numeric_regex = r'\d+' + if match_grouped: + _numeric_regex = r'(\d+)' + if match_optional: + _numeric_regex = r'(\d+)?' + numeric = 99 if numeric is None else numeric + + # basic capability attribute, not used as a callable + if nparams == 0: + return cls(name, re.escape(capability), attribute) + + # a callable capability accepting numeric argument + _outp = re.escape(capability(*(numeric,) * nparams)) + if not match_any: + for num in range(numeric - 1, numeric + 2): + if str(num) in _outp: + pattern = _outp.replace(str(num), _numeric_regex) + return cls(name, pattern, attribute) + + if match_grouped: + pattern = re.sub(r'(\d+)', lambda x: _numeric_regex, _outp) + else: + pattern = re.sub(r'\d+', lambda x: _numeric_regex, _outp) + return cls(name, pattern, attribute) + + +class SequenceTextWrapper(textwrap.TextWrapper): + """Docstring overridden.""" + + def __init__(self, width, term, **kwargs): + """ + Class initializer. + + This class supports the :meth:`~.Terminal.wrap` method. + """ + self.term = term + textwrap.TextWrapper.__init__(self, width, **kwargs) + + def _wrap_chunks(self, chunks): + """ + Sequence-aware variant of :meth:`textwrap.TextWrapper._wrap_chunks`. + + :raises ValueError: ``self.width`` is not a positive integer + :rtype: list + :returns: text chunks adjusted for width + + This simply ensures that word boundaries are not broken mid-sequence, as standard python + textwrap would incorrectly determine the length of a string containing sequences, and may + also break consider sequences part of a "word" that may be broken by hyphen (``-``), where + this implementation corrects both. + """ + lines = [] + if self.width <= 0 or not isinstance(self.width, int): + raise ValueError( + "invalid width {0!r}({1!r}) (must be integer > 0)" + .format(self.width, type(self.width))) + + term = self.term + drop_whitespace = not hasattr(self, 'drop_whitespace' + ) or self.drop_whitespace + chunks.reverse() + while chunks: + cur_line = [] + cur_len = 0 + indent = self.subsequent_indent if lines else self.initial_indent + width = self.width - len(indent) + if drop_whitespace and ( + Sequence(chunks[-1], term).strip() == '' and lines): + del chunks[-1] + while chunks: + chunk_len = Sequence(chunks[-1], term).length() + if cur_len + chunk_len > width: + break + cur_line.append(chunks.pop()) + cur_len += chunk_len + if chunks and Sequence(chunks[-1], term).length() > width: + self._handle_long_word(chunks, cur_line, cur_len, width) + if drop_whitespace and ( + cur_line and Sequence(cur_line[-1], term).strip() == ''): + del cur_line[-1] + if cur_line: + lines.append(indent + u''.join(cur_line)) + return lines + + def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): + """ + Sequence-aware :meth:`textwrap.TextWrapper._handle_long_word`. + + This simply ensures that word boundaries are not broken mid-sequence, as standard python + textwrap would incorrectly determine the length of a string containing sequences, and may + also break consider sequences part of a "word" that may be broken by hyphen (``-``), where + this implementation corrects both. + """ + # Figure out when indent is larger than the specified width, and make + # sure at least one character is stripped off on every pass + space_left = 1 if width < 1 else width - cur_len + # If we're allowed to break long words, then do so: put as much + # of the next chunk onto the current line as will fit. + + if self.break_long_words: + term = self.term + chunk = reversed_chunks[-1] + idx = nxt = 0 + for text, _ in iter_parse(term, chunk): + nxt += len(text) + if Sequence(chunk[:nxt], term).length() > space_left: + break + idx = nxt + cur_line.append(chunk[:idx]) + reversed_chunks[-1] = chunk[idx:] + + # Otherwise, we have to preserve the long word intact. Only add + # it to the current line if there's nothing already there -- + # that minimizes how much we violate the width constraint. + elif not cur_line: + cur_line.append(reversed_chunks.pop()) + + # If we're not allowed to break long words, and there's already + # text on the current line, do nothing. Next time through the + # main loop of _wrap_chunks(), we'll wind up here again, but + # cur_len will be zero, so the next line will be entirely + # devoted to the long word that we can't handle right now. + + +SequenceTextWrapper.__doc__ = textwrap.TextWrapper.__doc__ + + +class Sequence(six.text_type): + """ + A "sequence-aware" version of the base :class:`str` class. + + This unicode-derived class understands the effect of escape sequences + of printable length, allowing a properly implemented :meth:`rjust`, + :meth:`ljust`, :meth:`center`, and :meth:`length`. + """ + + def __new__(cls, sequence_text, term): + # pylint: disable = missing-return-doc, missing-return-type-doc + """ + Class constructor. + + :arg str sequence_text: A string that may contain sequences. + :arg blessed.Terminal term: :class:`~.Terminal` instance. + """ + new = six.text_type.__new__(cls, sequence_text) + new._term = term + return new + + def ljust(self, width, fillchar=u' '): + """ + Return string containing sequences, left-adjusted. + + :arg int width: Total width given to left-adjust ``text``. If + unspecified, the width of the attached terminal is used (default). + :arg str fillchar: String for padding right-of ``text``. + :returns: String of ``text``, left-aligned by ``width``. + :rtype: str + """ + rightside = fillchar * int( + (max(0.0, float(width.__index__() - self.length()))) / float(len(fillchar))) + return u''.join((self, rightside)) + + def rjust(self, width, fillchar=u' '): + """ + Return string containing sequences, right-adjusted. + + :arg int width: Total width given to right-adjust ``text``. If + unspecified, the width of the attached terminal is used (default). + :arg str fillchar: String for padding left-of ``text``. + :returns: String of ``text``, right-aligned by ``width``. + :rtype: str + """ + leftside = fillchar * int( + (max(0.0, float(width.__index__() - self.length()))) / float(len(fillchar))) + return u''.join((leftside, self)) + + def center(self, width, fillchar=u' '): + """ + Return string containing sequences, centered. + + :arg int width: Total width given to center ``text``. If + unspecified, the width of the attached terminal is used (default). + :arg str fillchar: String for padding left and right-of ``text``. + :returns: String of ``text``, centered by ``width``. + :rtype: str + """ + split = max(0.0, float(width.__index__()) - self.length()) / 2 + leftside = fillchar * int( + (max(0.0, math.floor(split))) / float(len(fillchar))) + rightside = fillchar * int( + (max(0.0, math.ceil(split))) / float(len(fillchar))) + return u''.join((leftside, self, rightside)) + + def truncate(self, width): + """ + Truncate a string in a sequence-aware manner. + + Any printable characters beyond ``width`` are removed, while all + sequences remain in place. Horizontal Sequences are first expanded + by :meth:`padd`. + + :arg int width: The printable width to truncate the string to. + :rtype: str + :returns: String truncated to at most ``width`` printable characters. + """ + output = "" + current_width = 0 + target_width = width.__index__() + parsed_seq = iter_parse(self._term, self.padd()) + + # Retain all text until non-cap width reaches desired width + for text, cap in parsed_seq: + if not cap: + # use wcwidth clipped to 0 because it can sometimes return -1 + current_width += max(wcwidth(text), 0) + if current_width > target_width: + break + output += text + + # Return with remaining caps appended + return output + ''.join(text for text, cap in parsed_seq if cap) + + def length(self): + r""" + Return the printable length of string containing sequences. + + Strings containing ``term.left`` or ``\b`` will cause "overstrike", + but a length less than 0 is not ever returned. So ``_\b+`` is a + length of 1 (displays as ``+``), but ``\b`` alone is simply a + length of 0. + + Some characters may consume more than one cell, mainly those CJK + Unified Ideographs (Chinese, Japanese, Korean) defined by Unicode + as half or full-width characters. + + For example: + + >>> from blessed import Terminal + >>> from blessed.sequences import Sequence + >>> term = Terminal() + >>> msg = term.clear + term.red(u'コンニチハ') + >>> Sequence(msg, term).length() + 10 + + .. note:: Although accounted for, strings containing sequences such + as ``term.clear`` will not give accurate returns, it is not + considered lengthy (a length of 0). + """ + # because control characters may return -1, "clip" their length to 0. + return sum(max(wcwidth(w_char), 0) for w_char in self.padd(strip=True)) + + def strip(self, chars=None): + """ + Return string of sequences, leading and trailing whitespace removed. + + :arg str chars: Remove characters in chars instead of whitespace. + :rtype: str + :returns: string of sequences with leading and trailing whitespace removed. + """ + return self.strip_seqs().strip(chars) + + def lstrip(self, chars=None): + """ + Return string of all sequences and leading whitespace removed. + + :arg str chars: Remove characters in chars instead of whitespace. + :rtype: str + :returns: string of sequences with leading removed. + """ + return self.strip_seqs().lstrip(chars) + + def rstrip(self, chars=None): + """ + Return string of all sequences and trailing whitespace removed. + + :arg str chars: Remove characters in chars instead of whitespace. + :rtype: str + :returns: string of sequences with trailing removed. + """ + return self.strip_seqs().rstrip(chars) + + def strip_seqs(self): + """ + Return ``text`` stripped of only its terminal sequences. + + :rtype: str + :returns: Text with terminal sequences removed + """ + return self.padd(strip=True) + + def padd(self, strip=False): + """ + Return non-destructive horizontal movement as destructive spacing. + + :arg bool strip: Strip terminal sequences + :rtype: str + :returns: Text adjusted for horizontal movement + """ + outp = '' + for text, cap in iter_parse(self._term, self): + if not cap: + outp += text + continue + + value = cap.horizontal_distance(text) + if value > 0: + outp += ' ' * value + elif value < 0: + outp = outp[:value] + elif not strip: + outp += text + return outp + + +def iter_parse(term, text): + """ + Generator yields (text, capability) for characters of ``text``. + + value for ``capability`` may be ``None``, where ``text`` is + :class:`str` of length 1. Otherwise, ``text`` is a full + matching sequence of given capability. + """ + for match in term._caps_compiled_any.finditer(text): # pylint: disable=protected-access + name = match.lastgroup + value = match.group(name) + if name == 'MISMATCH': + yield (value, None) + else: + yield value, term.caps[name] + + +def measure_length(text, term): + """ + .. deprecated:: 1.12.0. + + :rtype: int + :returns: Length of the first sequence in the string + """ + try: + text, capability = next(iter_parse(term, text)) + if capability: + return len(text) + except StopIteration: + return 0 + return 0 diff --git a/blessed/sequences.py:Zone.Identifier b/blessed/sequences.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/sequences.pyi b/blessed/sequences.pyi new file mode 100644 index 0000000..4460b7a --- /dev/null +++ b/blessed/sequences.pyi @@ -0,0 +1,55 @@ +# std imports +import textwrap +from typing import Any, Type, Tuple, Pattern, TypeVar, Iterator, Optional, SupportsIndex + +# local +from .terminal import Terminal + +_T = TypeVar("_T") + +class Termcap: + name: str = ... + pattern: str = ... + attribute: str = ... + def __init__(self, name: str, pattern: str, attribute: str) -> None: ... + @property + def named_pattern(self) -> str: ... + @property + def re_compiled(self) -> Pattern[str]: ... + @property + def will_move(self) -> bool: ... + def horizontal_distance(self, text: str) -> int: ... + @classmethod + def build( + cls, + name: str, + capability: str, + attribute: str, + nparams: int = ..., + numeric: int = ..., + match_grouped: bool = ..., + match_any: bool = ..., + match_optional: bool = ..., + ) -> "Termcap": ... + +class SequenceTextWrapper(textwrap.TextWrapper): + term: Terminal = ... + def __init__(self, width: int, term: Terminal, **kwargs: Any) -> None: ... + +class Sequence(str): + def __new__(cls: Type[_T], sequence_text: str, term: Terminal) -> _T: ... + def ljust(self, width: SupportsIndex, fillchar: str = ...) -> str: ... + def rjust(self, width: SupportsIndex, fillchar: str = ...) -> str: ... + def center(self, width: SupportsIndex, fillchar: str = ...) -> str: ... + def truncate(self, width: SupportsIndex) -> str: ... + def length(self) -> int: ... + def strip(self, chars: Optional[str] = ...) -> str: ... + def lstrip(self, chars: Optional[str] = ...) -> str: ... + def rstrip(self, chars: Optional[str] = ...) -> str: ... + def strip_seqs(self) -> str: ... + def padd(self, strip: bool = ...) -> str: ... + +def iter_parse( + term: Terminal, text: str +) -> Iterator[Tuple[str, Optional[Termcap]]]: ... +def measure_length(text: str, term: Terminal) -> int: ... diff --git a/blessed/sequences.pyi:Zone.Identifier b/blessed/sequences.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/terminal.py b/blessed/terminal.py new file mode 100644 index 0000000..7b6165b --- /dev/null +++ b/blessed/terminal.py @@ -0,0 +1,1552 @@ +# -*- coding: utf-8 -*- +# pylint: disable=too-many-lines +"""Module containing :class:`Terminal`, the primary API entry point.""" +# std imports +import os +import re +import sys +import time +import codecs +import locale +import select +import struct +import platform +import warnings +import functools +import contextlib +import collections + +# local +from .color import COLOR_DISTANCE_ALGORITHMS +from .keyboard import (_time_left, + _read_until, + resolve_sequence, + get_keyboard_codes, + get_leading_prefixes, + get_keyboard_sequences) +from .sequences import Termcap, Sequence, SequenceTextWrapper +from .colorspace import RGB_256TABLE +from .formatters import (COLORS, + COMPOUNDABLES, + FormattingString, + NullCallableString, + ParameterizingString, + FormattingOtherString, + split_compound, + resolve_attribute, + resolve_capability) +from ._capabilities import CAPABILITY_DATABASE, CAPABILITIES_ADDITIVES, CAPABILITIES_RAW_MIXIN + +# isort: off + +# Alias py2 exception to py3 +if sys.version_info[:2] < (3, 3): + InterruptedError = select.error # pylint: disable=redefined-builtin + + +HAS_TTY = True +if platform.system() == 'Windows': + IS_WINDOWS = True + import jinxed as curses # pylint: disable=import-error + from jinxed.win32 import get_console_input_encoding # pylint: disable=import-error +else: + IS_WINDOWS = False + import curses + + try: + import fcntl + import termios + import tty + except ImportError: + _TTY_METHODS = ('setraw', 'cbreak', 'kbhit', 'height', 'width') + _MSG_NOSUPPORT = ( + "One or more of the modules: 'termios', 'fcntl', and 'tty' " + "are not found on your platform '{platform}'. " + "The following methods of Terminal are dummy/no-op " + "unless a deriving class overrides them: {tty_methods}." + .format(platform=platform.system(), + tty_methods=', '.join(_TTY_METHODS))) + warnings.warn(_MSG_NOSUPPORT) + HAS_TTY = False + +_CUR_TERM = None # See comments at end of file + + +class Terminal(object): + """ + An abstraction for color, style, positioning, and input in the terminal. + + This keeps the endless calls to ``tigetstr()`` and ``tparm()`` out of your code, acts + intelligently when somebody pipes your output to a non-terminal, and abstracts over the + complexity of unbuffered keyboard input. It uses the terminfo database to remain portable across + terminal types. + """ + # pylint: disable=too-many-instance-attributes,too-many-public-methods + # Too many public methods (28/20) + # Too many instance attributes (12/7) + + #: Sugary names for commonly-used capabilities + _sugar = { + 'save': 'sc', + 'restore': 'rc', + 'clear_eol': 'el', + 'clear_bol': 'el1', + 'clear_eos': 'ed', + 'enter_fullscreen': 'smcup', + 'exit_fullscreen': 'rmcup', + 'move': 'cup', + 'move_yx': 'cup', + 'move_x': 'hpa', + 'move_y': 'vpa', + 'hide_cursor': 'civis', + 'normal_cursor': 'cnorm', + 'reset_colors': 'op', + 'normal': 'sgr0', + 'reverse': 'rev', + 'italic': 'sitm', + 'no_italic': 'ritm', + 'shadow': 'sshm', + 'no_shadow': 'rshm', + 'standout': 'smso', + 'no_standout': 'rmso', + 'subscript': 'ssubm', + 'no_subscript': 'rsubm', + 'superscript': 'ssupm', + 'no_superscript': 'rsupm', + 'underline': 'smul', + 'no_underline': 'rmul', + 'cursor_report': 'u6', + 'cursor_request': 'u7', + 'terminal_answerback': 'u8', + 'terminal_enquire': 'u9', + } + + def __init__(self, kind=None, stream=None, force_styling=False): + """ + Initialize the terminal. + + :arg str kind: A terminal string as taken by :func:`curses.setupterm`. + Defaults to the value of the ``TERM`` environment variable. + + .. note:: Terminals withing a single process must share a common + ``kind``. See :obj:`_CUR_TERM`. + + :arg file stream: A file-like object representing the Terminal output. + Defaults to the original value of :obj:`sys.__stdout__`, like + :func:`curses.initscr` does. + + If ``stream`` is not a tty, empty Unicode strings are returned for + all capability values, so things like piping your program output to + a pipe or file does not emit terminal sequences. + + :arg bool force_styling: Whether to force the emission of capabilities + even if :obj:`sys.__stdout__` does not seem to be connected to a + terminal. If you want to force styling to not happen, use + ``force_styling=None``. + + This comes in handy if users are trying to pipe your output through + something like ``less -r`` or build systems which support decoding + of terminal sequences. + """ + # pylint: disable=global-statement,too-many-branches + global _CUR_TERM + self.errors = ['parameters: kind=%r, stream=%r, force_styling=%r' % + (kind, stream, force_styling)] + self._normal = None # cache normal attr, preventing recursive lookups + # we assume our input stream to be line-buffered until either the + # cbreak of raw context manager methods are entered with an attached tty. + self._line_buffered = True + + self._stream = stream + self._keyboard_fd = None + self._init_descriptor = None + self._is_a_tty = False + self.__init__streams() + + if IS_WINDOWS and self._init_descriptor is not None: + self._kind = kind or curses.get_term(self._init_descriptor) + else: + self._kind = kind or os.environ.get('TERM', 'dumb') or 'dumb' + + self._does_styling = False + if force_styling is None and self.is_a_tty: + self.errors.append('force_styling is None') + elif force_styling or self.is_a_tty: + self._does_styling = True + + if self.does_styling: + # Initialize curses (call setupterm), so things like tigetstr() work. + try: + curses.setupterm(self._kind, self._init_descriptor) + except curses.error as err: + msg = 'Failed to setupterm(kind={0!r}): {1}'.format(self._kind, err) + warnings.warn(msg) + self.errors.append(msg) + self._kind = None + self._does_styling = False + else: + if _CUR_TERM is None or self._kind == _CUR_TERM: + _CUR_TERM = self._kind + else: + # termcap 'kind' is immutable in a python process! Once + # initialized by setupterm, it is unsupported by the + # 'curses' module to change the terminal type again. If you + # are a downstream developer and you need this + # functionality, consider sub-processing, instead. + warnings.warn( + 'A terminal of kind "%s" has been requested; due to an' + ' internal python curses bug, terminal capabilities' + ' for a terminal of kind "%s" will continue to be' + ' returned for the remainder of this process.' % ( + self._kind, _CUR_TERM,)) + + self.__init__color_capabilities() + self.__init__capabilities() + self.__init__keycodes() + + def __init__streams(self): + # pylint: disable=too-complex,too-many-branches + # Agree to disagree ! + stream_fd = None + + # Default stream is stdout + if self._stream is None: + self._stream = sys.__stdout__ + + if not hasattr(self._stream, 'fileno'): + self.errors.append('stream has no fileno method') + elif not callable(self._stream.fileno): + self.errors.append('stream.fileno is not callable') + else: + try: + stream_fd = self._stream.fileno() + except ValueError as err: + # The stream is not a file, such as the case of StringIO, or, when it has been + # "detached", such as might be the case of stdout in some test scenarios. + self.errors.append('Unable to determine output stream file descriptor: %s' % err) + else: + self._is_a_tty = os.isatty(stream_fd) + if not self._is_a_tty: + self.errors.append('stream not a TTY') + + # Keyboard valid as stdin only when output stream is stdout or stderr and is a tty. + if self._stream in (sys.__stdout__, sys.__stderr__): + try: + self._keyboard_fd = sys.__stdin__.fileno() + except (AttributeError, ValueError) as err: + self.errors.append('Unable to determine input stream file descriptor: %s' % err) + else: + # _keyboard_fd only non-None if both stdin and stdout is a tty. + if not self.is_a_tty: + self.errors.append('Output stream is not a TTY') + self._keyboard_fd = None + elif not os.isatty(self._keyboard_fd): + self.errors.append('Input stream is not a TTY') + self._keyboard_fd = None + else: + self.errors.append('Output stream is not a default stream') + + # The descriptor to direct terminal initialization sequences to. + self._init_descriptor = stream_fd + if stream_fd is None: + try: + self._init_descriptor = sys.__stdout__.fileno() + except ValueError as err: + self.errors.append('Unable to determine __stdout__ file descriptor: %s' % err) + + def __init__color_capabilities(self): + self._color_distance_algorithm = 'cie2000' + if not self.does_styling: + self.number_of_colors = 0 + elif IS_WINDOWS or os.environ.get('COLORTERM') in ('truecolor', '24bit'): + self.number_of_colors = 1 << 24 + else: + self.number_of_colors = max(0, curses.tigetnum('colors') or -1) + + def __clear_color_capabilities(self): + for cached_color_cap in set(dir(self)) & COLORS: + delattr(self, cached_color_cap) + + def __init__capabilities(self): + # important that we lay these in their ordered direction, so that our + # preferred, 'color' over 'set_a_attributes1', for example. + self.caps = collections.OrderedDict() + + # some static injected patterns, esp. without named attribute access. + for name, (attribute, pattern) in CAPABILITIES_ADDITIVES.items(): + self.caps[name] = Termcap(name, pattern, attribute) + + for name, (attribute, kwds) in CAPABILITY_DATABASE.items(): + if self.does_styling: + # attempt dynamic lookup + cap = getattr(self, attribute) + if cap: + self.caps[name] = Termcap.build( + name, cap, attribute, **kwds) + continue + + # fall-back + pattern = CAPABILITIES_RAW_MIXIN.get(name) + if pattern: + self.caps[name] = Termcap(name, pattern, attribute) + + # make a compiled named regular expression table + self.caps_compiled = re.compile( + '|'.join(cap.pattern for name, cap in self.caps.items())) + + # for tokenizer, the '.lastgroup' is the primary lookup key for + # 'self.caps', unless 'MISMATCH'; then it is an unmatched character. + self._caps_compiled_any = re.compile('|'.join( + cap.named_pattern for name, cap in self.caps.items() + ) + '|(?P.)') + self._caps_unnamed_any = re.compile('|'.join( + '({0})'.format(cap.pattern) for name, cap in self.caps.items() + ) + '|(.)') + + def __init__keycodes(self): + # Initialize keyboard data determined by capability. + # Build database of int code <=> KEY_NAME. + self._keycodes = get_keyboard_codes() + + # Store attributes as: self.KEY_NAME = code. + for key_code, key_name in self._keycodes.items(): + setattr(self, key_name, key_code) + + # Build database of sequence <=> KEY_NAME. + self._keymap = get_keyboard_sequences(self) + + # build set of prefixes of sequences + self._keymap_prefixes = get_leading_prefixes(self._keymap) + + # keyboard stream buffer + self._keyboard_buf = collections.deque() + + if self._keyboard_fd is not None: + # set input encoding and initialize incremental decoder + + if IS_WINDOWS: + self._encoding = get_console_input_encoding() \ + or locale.getpreferredencoding() or 'UTF-8' + else: + self._encoding = locale.getpreferredencoding() or 'UTF-8' + + try: + self._keyboard_decoder = codecs.getincrementaldecoder(self._encoding)() + except LookupError as err: + # encoding is illegal or unsupported, use 'UTF-8' + warnings.warn('LookupError: {0}, defaulting to UTF-8 for keyboard.'.format(err)) + self._encoding = 'UTF-8' + self._keyboard_decoder = codecs.getincrementaldecoder(self._encoding)() + + def __getattr__(self, attr): + r""" + Return a terminal capability as Unicode string. + + For example, ``term.bold`` is a unicode string that may be prepended + to text to set the video attribute for bold, which should also be + terminated with the pairing :attr:`normal`. This capability + returns a callable, so you can use ``term.bold("hi")`` which + results in the joining of ``(term.bold, "hi", term.normal)``. + + Compound formatters may also be used. For example:: + + >>> term.bold_blink_red_on_green("merry x-mas!") + + For a parameterized capability such as ``move`` (or ``cup``), pass the + parameters as positional arguments:: + + >>> term.move(line, column) + + See the manual page `terminfo(5) + `_ for a + complete list of capabilities and their arguments. + """ + if not self._does_styling: + return NullCallableString() + # Fetch the missing 'attribute' into some kind of curses-resolved + # capability, and cache by attaching to this Terminal class instance. + # + # Note that this will prevent future calls to __getattr__(), but + # that's precisely the idea of the cache! + val = resolve_attribute(self, attr) + setattr(self, attr, val) + return val + + @property + def kind(self): + """ + Read-only property: Terminal kind determined on class initialization. + + :rtype: str + """ + return self._kind + + @property + def does_styling(self): + """ + Read-only property: Whether this class instance may emit sequences. + + :rtype: bool + """ + return self._does_styling + + @property + def is_a_tty(self): + """ + Read-only property: Whether :attr:`~.stream` is a terminal. + + :rtype: bool + """ + return self._is_a_tty + + @property + def height(self): + """ + Read-only property: Height of the terminal (in number of lines). + + :rtype: int + """ + return self._height_and_width().ws_row + + @property + def width(self): + """ + Read-only property: Width of the terminal (in number of columns). + + :rtype: int + """ + return self._height_and_width().ws_col + + @property + def pixel_height(self): + """ + Read-only property: Height ofthe terminal (in pixels). + + :rtype: int + """ + return self._height_and_width().ws_ypixel + + @property + def pixel_width(self): + """ + Read-only property: Width of terminal (in pixels). + + :rtype: int + """ + return self._height_and_width().ws_xpixel + + @staticmethod + def _winsize(fd): + """ + Return named tuple describing size of the terminal by ``fd``. + + If the given platform does not have modules :mod:`termios`, + :mod:`fcntl`, or :mod:`tty`, window size of 80 columns by 25 + rows is always returned. + + :arg int fd: file descriptor queries for its window size. + :raises IOError: the file descriptor ``fd`` is not a terminal. + :rtype: WINSZ + :returns: named tuple describing size of the terminal + + WINSZ is a :class:`collections.namedtuple` instance, whose structure + directly maps to the return value of the :const:`termios.TIOCGWINSZ` + ioctl return value. The return parameters are: + + - ``ws_row``: width of terminal by its number of character cells. + - ``ws_col``: height of terminal by its number of character cells. + - ``ws_xpixel``: width of terminal by pixels (not accurate). + - ``ws_ypixel``: height of terminal by pixels (not accurate). + """ + if HAS_TTY: + # pylint: disable=protected-access + data = fcntl.ioctl(fd, termios.TIOCGWINSZ, WINSZ._BUF) + return WINSZ(*struct.unpack(WINSZ._FMT, data)) + return WINSZ(ws_row=25, ws_col=80, ws_xpixel=0, ws_ypixel=0) + + def _height_and_width(self): + """ + Return a tuple of (terminal height, terminal width). + + If :attr:`stream` or :obj:`sys.__stdout__` is not a tty or does not + support :func:`fcntl.ioctl` of :const:`termios.TIOCGWINSZ`, a window + size of 80 columns by 25 rows is returned for any values not + represented by environment variables ``LINES`` and ``COLUMNS``, which + is the default text mode of IBM PC compatibles. + + :rtype: WINSZ + :returns: Named tuple specifying the terminal size + + WINSZ is a :class:`collections.namedtuple` instance, whose structure + directly maps to the return value of the :const:`termios.TIOCGWINSZ` + ioctl return value. The return parameters are: + + - ``ws_row``: height of terminal by its number of cell rows. + - ``ws_col``: width of terminal by its number of cell columns. + - ``ws_xpixel``: width of terminal by pixels (not accurate). + - ``ws_ypixel``: height of terminal by pixels (not accurate). + + .. note:: the peculiar (height, width, width, height) order, which + matches the return order of TIOCGWINSZ! + """ + for fd in (self._init_descriptor, sys.__stdout__): + try: + if fd is not None: + return self._winsize(fd) + except (IOError, OSError, ValueError, TypeError): # pylint: disable=overlapping-except + pass + + return WINSZ(ws_row=int(os.getenv('LINES', '25')), + ws_col=int(os.getenv('COLUMNS', '80')), + ws_xpixel=None, + ws_ypixel=None) + + def _query_response(self, query_str, response_re, timeout): + """ + Sends a query string to the terminal and waits for a response. + + :arg str query_str: Query string written to output + :arg str response_re: Regular expression matching query response + :arg float timeout: Return after time elapsed in seconds + :return: re.match object for response_re or None if not found + :rtype: re.Match + """ + # Avoid changing user's desired raw or cbreak mode if already entered, + # by entering cbreak mode ourselves. This is necessary to receive user + # input without awaiting a human to press the return key. This mode + # also disables echo, which we should also hide, as our input is an + # sequence that is not meaningful for display as an output sequence. + + ctx = None + try: + if self._line_buffered: + ctx = self.cbreak() + ctx.__enter__() # pylint: disable=no-member + + # Emit the query sequence, + self.stream.write(query_str) + self.stream.flush() + + # Wait for response + match, data = _read_until(term=self, + pattern=response_re, + timeout=timeout) + + # Exclude response from subsequent input + if match: + data = data[:match.start()] + data[match.end():] + + # re-buffer keyboard data, if any + self.ungetch(data) + + finally: + if ctx is not None: + ctx.__exit__(None, None, None) # pylint: disable=no-member + + return match + + @contextlib.contextmanager + def location(self, x=None, y=None): + """ + Context manager for temporarily moving the cursor. + + :arg int x: horizontal position, from left, *0*, to right edge of screen, *self.width - 1*. + :arg int y: vertical position, from top, *0*, to bottom of screen, *self.height - 1*. + :return: a context manager. + :rtype: Iterator + + Move the cursor to a certain position on entry, do any kind of I/O, and upon exit + let you print stuff there, then return the cursor to its original position: + + + .. code-block:: python + + term = Terminal() + with term.location(y=0, x=0): + for row_num in range(term.height-1): + print('Row #{row_num}') + print(term.clear_eol + 'Back to original location.') + + Specify ``x`` to move to a certain column, ``y`` to move to a certain + row, both, or neither. If you specify neither, only the saving and + restoration of cursor position will happen. This can be useful if you + simply want to restore your place after doing some manual cursor + movement. + + Calls cannot be nested: only one should be entered at a time. + + .. note:: The argument order *(x, y)* differs from the return value order *(y, x)* + of :meth:`get_location`, or argument order *(y, x)* of :meth:`move`. This is + for API Compaibility with the blessings library, sorry for the trouble! + """ + # pylint: disable=invalid-name + # Invalid argument name "x" + + # Save position and move to the requested column, row, or both: + self.stream.write(self.save) + if x is not None and y is not None: + self.stream.write(self.move(y, x)) + elif x is not None: + self.stream.write(self.move_x(x)) + elif y is not None: + self.stream.write(self.move_y(y)) + try: + self.stream.flush() + yield + finally: + # Restore original cursor position: + self.stream.write(self.restore) + self.stream.flush() + + def get_location(self, timeout=None): + r""" + Return tuple (row, column) of cursor position. + + :arg float timeout: Return after time elapsed in seconds with value ``(-1, -1)`` indicating + that the remote end did not respond. + :rtype: tuple + :returns: cursor position as tuple in form of ``(y, x)``. When a timeout is specified, + always ensure the return value is checked for ``(-1, -1)``. + + The location of the cursor is determined by emitting the ``u7`` terminal capability, or + VT100 `Query Cursor Position + `_ + when such capability is undefined, which elicits a response from a reply string described by + capability ``u6``, or again VT100's definition of ``\x1b[%i%d;%dR`` when undefined. + + The ``(y, x)`` return value matches the parameter order of the :meth:`move_xy` capability. + The following sequence should cause the cursor to not move at all:: + + >>> term = Terminal() + >>> term.move_yx(*term.get_location())) + + And the following should assert True with a terminal: + + >>> term = Terminal() + >>> given_y, given_x = 10, 20 + >>> with term.location(y=given_y, x=given_x): + ... result_y, result_x = term.get_location() + ... + >>> assert given_x == result_x, (given_x, result_x) + >>> assert given_y == result_y, (given_y, result_y) + """ + # Local lines attached by termios and remote login protocols such as + # ssh and telnet both provide a means to determine the window + # dimensions of a connected client, but **no means to determine the + # location of the cursor**. + # + # from https://invisible-island.net/ncurses/terminfo.src.html, + # + # > The System V Release 4 and XPG4 terminfo format defines ten string + # > capabilities for use by applications, .... In this file, + # > we use certain of these capabilities to describe functions which + # > are not covered by terminfo. The mapping is as follows: + # > + # > u9 terminal enquire string (equiv. to ANSI/ECMA-48 DA) + # > u8 terminal answerback description + # > u7 cursor position request (equiv. to VT100/ANSI/ECMA-48 DSR 6) + # > u6 cursor position report (equiv. to ANSI/ECMA-48 CPR) + + response_str = getattr(self, self.caps['cursor_report'].attribute) or u'\x1b[%i%d;%dR' + match = self._query_response( + self.u7 or u'\x1b[6n', self.caps['cursor_report'].re_compiled, timeout + ) + + if match: + # return matching sequence response, the cursor location. + row, col = (int(val) for val in match.groups()) + + # Per https://invisible-island.net/ncurses/terminfo.src.html + # The cursor position report () string must contain two + # scanf(3)-style %d format elements. The first of these must + # correspond to the Y coordinate and the second to the %d. + # If the string contains the sequence %i, it is taken as an + # instruction to decrement each value after reading it (this is + # the inverse sense from the cup string). + if u'%i' in response_str: + row -= 1 + col -= 1 + return row, col + + # We chose to return an illegal value rather than an exception, + # favoring that users author function filters, such as max(0, y), + # rather than crowbarring such logic into an exception handler. + return -1, -1 + + def get_fgcolor(self, timeout=None): + """ + Return tuple (r, g, b) of foreground color. + + :arg float timeout: Return after time elapsed in seconds with value ``(-1, -1, -1)`` + indicating that the remote end did not respond. + :rtype: tuple + :returns: foreground color as tuple in form of ``(r, g, b)``. When a timeout is specified, + always ensure the return value is checked for ``(-1, -1, -1)``. + + The foreground color is determined by emitting an `OSC 10 color query + `_. + """ + match = self._query_response( + u'\x1b]10;?\x07', + re.compile(u'\x1b]10;rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)\x07'), + timeout + ) + + return tuple(int(val, 16) for val in match.groups()) if match else (-1, -1, -1) + + def get_bgcolor(self, timeout=None): + """ + Return tuple (r, g, b) of background color. + + :arg float timeout: Return after time elapsed in seconds with value ``(-1, -1, -1)`` + indicating that the remote end did not respond. + :rtype: tuple + :returns: background color as tuple in form of ``(r, g, b)``. When a timeout is specified, + always ensure the return value is checked for ``(-1, -1, -1)``. + + The background color is determined by emitting an `OSC 11 color query + `_. + """ + match = self._query_response( + u'\x1b]11;?\x07', + re.compile(u'\x1b]11;rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)\x07'), + timeout + ) + + return tuple(int(val, 16) for val in match.groups()) if match else (-1, -1, -1) + + @contextlib.contextmanager + def fullscreen(self): + """ + Context manager that switches to secondary screen, restoring on exit. + + Under the hood, this switches between the primary screen buffer and + the secondary one. The primary one is saved on entry and restored on + exit. Likewise, the secondary contents are also stable and are + faithfully restored on the next entry:: + + with term.fullscreen(): + main() + + .. note:: There is only one primary and one secondary screen buffer. + :meth:`fullscreen` calls cannot be nested, only one should be + entered at a time. + """ + self.stream.write(self.enter_fullscreen) + self.stream.flush() + try: + yield + finally: + self.stream.write(self.exit_fullscreen) + self.stream.flush() + + @contextlib.contextmanager + def hidden_cursor(self): + """ + Context manager that hides the cursor, setting visibility on exit. + + with term.hidden_cursor(): + main() + + .. note:: :meth:`hidden_cursor` calls cannot be nested: only one + should be entered at a time. + """ + self.stream.write(self.hide_cursor) + self.stream.flush() + try: + yield + finally: + self.stream.write(self.normal_cursor) + self.stream.flush() + + def move_xy(self, x, y): + """ + A callable string that moves the cursor to the given ``(x, y)`` screen coordinates. + + :arg int x: horizontal position, from left, *0*, to right edge of screen, *self.width - 1*. + :arg int y: vertical position, from top, *0*, to bottom of screen, *self.height - 1*. + :rtype: ParameterizingString + :returns: Callable string that moves the cursor to the given coordinates + """ + # this is just a convenience alias to the built-in, but hidden 'move' + # attribute -- we encourage folks to use only (x, y) positional + # arguments, or, if they must use (y, x), then use the 'move_yx' + # alias. + return self.move(y, x) + + def move_yx(self, y, x): + """ + A callable string that moves the cursor to the given ``(y, x)`` screen coordinates. + + :arg int y: vertical position, from top, *0*, to bottom of screen, *self.height - 1*. + :arg int x: horizontal position, from left, *0*, to right edge of screen, *self.width - 1*. + :rtype: ParameterizingString + :returns: Callable string that moves the cursor to the given coordinates + """ + return self.move(y, x) + + @property + def move_left(self): + """Move cursor 1 cells to the left, or callable string for n>1 cells.""" + return FormattingOtherString(self.cub1, ParameterizingString(self.cub)) + + @property + def move_right(self): + """Move cursor 1 or more cells to the right, or callable string for n>1 cells.""" + return FormattingOtherString(self.cuf1, ParameterizingString(self.cuf)) + + @property + def move_up(self): + """Move cursor 1 or more cells upwards, or callable string for n>1 cells.""" + return FormattingOtherString(self.cuu1, ParameterizingString(self.cuu)) + + @property + def move_down(self): + """Move cursor 1 or more cells downwards, or callable string for n>1 cells.""" + return FormattingOtherString(self.cud1, ParameterizingString(self.cud)) + + @property + def color(self): + """ + A callable string that sets the foreground color. + + :rtype: ParameterizingString + + The capability is unparameterized until called and passed a number, at which point it + returns another string which represents a specific color change. This second string can + further be called to color a piece of text and set everything back to normal afterward. + + This should not be used directly, but rather a specific color by name or + :meth:`~.Terminal.color_rgb` value. + """ + if self.does_styling: + return ParameterizingString(self._foreground_color, self.normal, 'color') + + return NullCallableString() + + def color_rgb(self, red, green, blue): + """ + Provides callable formatting string to set foreground color to the specified RGB color. + + :arg int red: RGB value of Red. + :arg int green: RGB value of Green. + :arg int blue: RGB value of Blue. + :rtype: FormattingString + :returns: Callable string that sets the foreground color + + If the terminal does not support RGB color, the nearest supported + color will be determined using :py:attr:`color_distance_algorithm`. + """ + if self.number_of_colors == 1 << 24: + # "truecolor" 24-bit + fmt_attr = u'\x1b[38;2;{0};{1};{2}m'.format(red, green, blue) + return FormattingString(fmt_attr, self.normal) + + # color by approximation to 256 or 16-color terminals + color_idx = self.rgb_downconvert(red, green, blue) + return FormattingString(self._foreground_color(color_idx), self.normal) + + @property + def on_color(self): + """ + A callable capability that sets the background color. + + :rtype: ParameterizingString + """ + if self.does_styling: + return ParameterizingString(self._background_color, self.normal, 'on_color') + + return NullCallableString() + + def on_color_rgb(self, red, green, blue): + """ + Provides callable formatting string to set background color to the specified RGB color. + + :arg int red: RGB value of Red. + :arg int green: RGB value of Green. + :arg int blue: RGB value of Blue. + :rtype: FormattingString + :returns: Callable string that sets the foreground color + + If the terminal does not support RGB color, the nearest supported + color will be determined using :py:attr:`color_distance_algorithm`. + """ + if self.number_of_colors == 1 << 24: + fmt_attr = u'\x1b[48;2;{0};{1};{2}m'.format(red, green, blue) + return FormattingString(fmt_attr, self.normal) + + color_idx = self.rgb_downconvert(red, green, blue) + return FormattingString(self._background_color(color_idx), self.normal) + + def formatter(self, value): + """ + Provides callable formatting string to set color and other text formatting options. + + :arg str value: Sugary, ordinary, or compound formatted terminal capability, + such as "red_on_white", "normal", "red", or "bold_on_black". + :rtype: :class:`FormattingString` or :class:`NullCallableString` + :returns: Callable string that sets color and other text formatting options + + Calling ``term.formatter('bold_on_red')`` is equivalent to ``term.bold_on_red``, but a + string that is not a valid text formatter will return a :class:`NullCallableString`. + This is intended to allow validation of text formatters without the possibility of + inadvertently returning another terminal capability. + """ + formatters = split_compound(value) + if all((fmt in COLORS or fmt in COMPOUNDABLES) for fmt in formatters): + return getattr(self, value) + + return NullCallableString() + + def rgb_downconvert(self, red, green, blue): + """ + Translate an RGB color to a color code of the terminal's color depth. + + :arg int red: RGB value of Red (0-255). + :arg int green: RGB value of Green (0-255). + :arg int blue: RGB value of Blue (0-255). + :rtype: int + :returns: Color code of downconverted RGB color + """ + # Though pre-computing all 1 << 24 options is memory-intensive, a pre-computed + # "k-d tree" of 256 (x,y,z) vectors of a colorspace in 3 dimensions, such as a + # cone of HSV, or simply 255x255x255 RGB square, any given rgb value is just a + # nearest-neighbor search of 256 points, which k-d should be much faster by + # sub-dividing / culling search points, rather than our "search all 256 points + # always" approach. + fn_distance = COLOR_DISTANCE_ALGORITHMS[self.color_distance_algorithm] + color_idx = 7 + shortest_distance = None + for cmp_depth, cmp_rgb in enumerate(RGB_256TABLE): + cmp_distance = fn_distance(cmp_rgb, (red, green, blue)) + if shortest_distance is None or cmp_distance < shortest_distance: + shortest_distance = cmp_distance + color_idx = cmp_depth + if cmp_depth >= self.number_of_colors: + break + return color_idx + + @property + def normal(self): + """ + A capability that resets all video attributes. + + :rtype: str + + ``normal`` is an alias for ``sgr0`` or ``exit_attribute_mode``. Any + styling attributes previously applied, such as foreground or + background colors, reverse video, or bold are reset to defaults. + """ + if self._normal: + return self._normal + self._normal = resolve_capability(self, 'normal') + return self._normal + + def link(self, url, text, url_id=''): + """ + Display ``text`` that when touched or clicked, navigates to ``url``. + + Optional ``url_id`` may be specified, so that non-adjacent cells can reference a single + target, all cells painted with the same "id" will highlight on hover, rather than any + individual one, as described in "Hovering and underlining the id parameter" of gist + https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda. + + :param str url: Hyperlink URL. + :param str text: Clickable text. + :param str url_id: Optional 'id'. + :rtype: str + :returns: String of ``text`` as a hyperlink to ``url``. + """ + assert len(url) < 2000, (len(url), url) + if url_id: + assert len(str(url_id)) < 250, (len(str(url_id)), url_id) + params = 'id={0}'.format(url_id) + else: + params = '' + if not self.does_styling: + return text + return ('\x1b]8;{0};{1}\x1b\\{2}' + '\x1b]8;;\x1b\\'.format(params, url, text)) + + @property + def stream(self): + """ + Read-only property: stream the terminal outputs to. + + This is a convenience attribute. It is used internally for implied + writes performed by context managers :meth:`~.hidden_cursor`, + :meth:`~.fullscreen`, :meth:`~.location`, and :meth:`~.keypad`. + """ + return self._stream + + @property + def number_of_colors(self): + """ + Number of colors supported by terminal. + + Common return values are 0, 8, 16, 256, or 1 << 24. + + This may be used to test whether the terminal supports colors, + and at what depth, if that's a concern. + + If this property is assigned a value of 88, the value 16 will be saved. This is due to the + the rarity of 88 color support and the inconsistency of behavior between implementations. + + Assigning this property to a value other than 0, 4, 8, 16, 88, 256, or 1 << 24 will + raise an :py:exc:`AssertionError`. + """ + return self._number_of_colors + + @number_of_colors.setter + def number_of_colors(self, value): + assert value in (0, 4, 8, 16, 88, 256, 1 << 24) + # Because 88 colors is rare and we can't guarantee consistent behavior, + # when 88 colors is detected, it is treated as 16 colors + self._number_of_colors = 16 if value == 88 else value + self.__clear_color_capabilities() + + @property + def color_distance_algorithm(self): + """ + Color distance algorithm used by :meth:`rgb_downconvert`. + + The slowest, but most accurate, 'cie2000', is default. Other available options are 'rgb', + 'rgb-weighted', 'cie76', and 'cie94'. + """ + return self._color_distance_algorithm + + @color_distance_algorithm.setter + def color_distance_algorithm(self, value): + assert value in COLOR_DISTANCE_ALGORITHMS + self._color_distance_algorithm = value + self.__clear_color_capabilities() + + @property + def _foreground_color(self): + """ + Convenience capability to support :attr:`~.on_color`. + + Prefers returning sequence for capability ``setaf``, "Set foreground color to #1, using ANSI + escape". If the given terminal does not support such sequence, fallback to returning + attribute ``setf``, "Set foreground color #1". + """ + return self.setaf or self.setf + + @property + def _background_color(self): + """ + Convenience capability to support :attr:`~.on_color`. + + Prefers returning sequence for capability ``setab``, "Set background color to #1, using ANSI + escape". If the given terminal does not support such sequence, fallback to returning + attribute ``setb``, "Set background color #1". + """ + return self.setab or self.setb + + def ljust(self, text, width=None, fillchar=u' '): + """ + Left-align ``text``, which may contain terminal sequences. + + :arg str text: String to be aligned + :arg int width: Total width to fill with aligned text. If + unspecified, the whole width of the terminal is filled. + :arg str fillchar: String for padding the right of ``text`` + :rtype: str + :returns: String of ``text``, left-aligned by ``width``. + """ + # Left justification is different from left alignment, but we continue + # the vocabulary error of the str method for polymorphism. + if width is None: + width = self.width + return Sequence(text, self).ljust(width, fillchar) + + def rjust(self, text, width=None, fillchar=u' '): + """ + Right-align ``text``, which may contain terminal sequences. + + :arg str text: String to be aligned + :arg int width: Total width to fill with aligned text. If + unspecified, the whole width of the terminal is used. + :arg str fillchar: String for padding the left of ``text`` + :rtype: str + :returns: String of ``text``, right-aligned by ``width``. + """ + if width is None: + width = self.width + return Sequence(text, self).rjust(width, fillchar) + + def center(self, text, width=None, fillchar=u' '): + """ + Center ``text``, which may contain terminal sequences. + + :arg str text: String to be centered + :arg int width: Total width in which to center text. If + unspecified, the whole width of the terminal is used. + :arg str fillchar: String for padding the left and right of ``text`` + :rtype: str + :returns: String of ``text``, centered by ``width`` + """ + if width is None: + width = self.width + return Sequence(text, self).center(width, fillchar) + + def truncate(self, text, width=None): + r""" + Truncate ``text`` to maximum ``width`` printable characters, retaining terminal sequences. + + :arg str text: Text to truncate + :arg int width: The maximum width to truncate it to + :rtype: str + :returns: ``text`` truncated to at most ``width`` printable characters + + >>> term.truncate(u'xyz\x1b[0;3m', 2) + u'xy\x1b[0;3m' + """ + if width is None: + width = self.width + return Sequence(text, self).truncate(width) + + def length(self, text): + u""" + Return printable length of a string containing sequences. + + :arg str text: String to measure. May contain terminal sequences. + :rtype: int + :returns: The number of terminal character cells the string will occupy + when printed + + Wide characters that consume 2 character cells are supported: + + >>> term = Terminal() + >>> term.length(term.clear + term.red(u'コンニチハ')) + 10 + + .. note:: Sequences such as 'clear', which is considered as a + "movement sequence" because it would move the cursor to + (y, x)(0, 0), are evaluated as a printable length of + *0*. + """ + return Sequence(text, self).length() + + def strip(self, text, chars=None): + r""" + Return ``text`` without sequences and leading or trailing whitespace. + + :rtype: str + :returns: Text with leading and trailing whitespace removed + + >>> term.strip(u' \x1b[0;3m xyz ') + u'xyz' + """ + return Sequence(text, self).strip(chars) + + def rstrip(self, text, chars=None): + r""" + Return ``text`` without terminal sequences or trailing whitespace. + + :rtype: str + :returns: Text with terminal sequences and trailing whitespace removed + + >>> term.rstrip(u' \x1b[0;3m xyz ') + u' xyz' + """ + return Sequence(text, self).rstrip(chars) + + def lstrip(self, text, chars=None): + r""" + Return ``text`` without terminal sequences or leading whitespace. + + :rtype: str + :returns: Text with terminal sequences and leading whitespace removed + + >>> term.lstrip(u' \x1b[0;3m xyz ') + u'xyz ' + """ + return Sequence(text, self).lstrip(chars) + + def strip_seqs(self, text): + r""" + Return ``text`` stripped of only its terminal sequences. + + :rtype: str + :returns: Text with terminal sequences removed + + >>> term.strip_seqs(u'\x1b[0;3mxyz') + u'xyz' + >>> term.strip_seqs(term.cuf(5) + term.red(u'test')) + u' test' + + .. note:: Non-destructive sequences that adjust horizontal distance + (such as ``\b`` or ``term.cuf(5)``) are replaced by destructive + space or erasing. + """ + return Sequence(text, self).strip_seqs() + + def split_seqs(self, text, maxsplit=0): + r""" + Return ``text`` split by individual character elements and sequences. + + :arg str text: String containing sequences + :arg int maxsplit: When maxsplit is nonzero, at most maxsplit splits + occur, and the remainder of the string is returned as the final element + of the list (same meaning is argument for :func:`re.split`). + :rtype: list[str] + :returns: List of sequences and individual characters + + >>> term.split_seqs(term.underline(u'xyz')) + ['\x1b[4m', 'x', 'y', 'z', '\x1b(B', '\x1b[m'] + + >>> term.split_seqs(term.underline(u'xyz'), 1) + ['\x1b[4m', r'xyz\x1b(B\x1b[m'] + """ + pattern = self._caps_unnamed_any + result = [] + for idx, match in enumerate(re.finditer(pattern, text)): + result.append(match.group()) + if maxsplit and idx == maxsplit: + remaining = text[match.end():] + if remaining: + result[-1] += remaining + break + return result + + def wrap(self, text, width=None, **kwargs): + r""" + Text-wrap a string, returning a list of wrapped lines. + + :arg str text: Unlike :func:`textwrap.wrap`, ``text`` may contain + terminal sequences, such as colors, bold, or underline. By + default, tabs in ``text`` are expanded by + :func:`string.expandtabs`. + :arg int width: Unlike :func:`textwrap.wrap`, ``width`` will + default to the width of the attached terminal. + :arg \**kwargs: See :py:class:`textwrap.TextWrapper` + :rtype: list + :returns: List of wrapped lines + + See :class:`textwrap.TextWrapper` for keyword arguments that can + customize wrapping behaviour. + """ + width = self.width if width is None else width + wrapper = SequenceTextWrapper(width=width, term=self, **kwargs) + lines = [] + for line in text.splitlines(): + lines.extend(iter(wrapper.wrap(line)) if line.strip() else (u'',)) + + return lines + + def getch(self): + """ + Read, decode, and return the next byte from the keyboard stream. + + :rtype: unicode + :returns: a single unicode character, or ``u''`` if a multi-byte + sequence has not yet been fully received. + + This method name and behavior mimics curses ``getch(void)``, and + it supports :meth:`inkey`, reading only one byte from + the keyboard string at a time. This method should always return + without blocking if called after :meth:`kbhit` has returned True. + + Implementors of alternate input stream methods should override + this method. + """ + assert self._keyboard_fd is not None + byte = os.read(self._keyboard_fd, 1) + return self._keyboard_decoder.decode(byte, final=False) + + def ungetch(self, text): + """ + Buffer input data to be discovered by next call to :meth:`~.inkey`. + + :arg str text: String to be buffered as keyboard input. + """ + self._keyboard_buf.extendleft(text) + + def kbhit(self, timeout=None): + """ + Return whether a keypress has been detected on the keyboard. + + This method is used by :meth:`inkey` to determine if a byte may + be read using :meth:`getch` without blocking. The standard + implementation simply uses the :func:`select.select` call on stdin. + + :arg float timeout: When ``timeout`` is 0, this call is + non-blocking, otherwise blocking indefinitely until keypress + is detected when None (default). When ``timeout`` is a + positive number, returns after ``timeout`` seconds have + elapsed (float). + :rtype: bool + :returns: True if a keypress is awaiting to be read on the keyboard + attached to this terminal. When input is not a terminal, False is + always returned. + """ + stime = time.time() + ready_r = [None, ] + check_r = [self._keyboard_fd] if self._keyboard_fd is not None else [] + + while HAS_TTY: + try: + ready_r, _, _ = select.select(check_r, [], [], timeout) + except InterruptedError: + # Beginning with python3.5, IntrruptError is no longer thrown + # https://www.python.org/dev/peps/pep-0475/ + # + # For previous versions of python, we take special care to + # retry select on InterruptedError exception, namely to handle + # a custom SIGWINCH handler. When installed, it would cause + # select() to be interrupted with errno 4 (EAGAIN). + # + # Just as in python3.5, it is ignored, and a new timeout value + # is derived from the previous unless timeout becomes negative. + # because the signal handler has blocked beyond timeout, then + # False is returned. Otherwise, when timeout is None, we + # continue to block indefinitely (default). + if timeout is not None: + # subtract time already elapsed, + timeout -= time.time() - stime + if timeout > 0: + continue + # no time remains after handling exception (rare) + ready_r = [] # pragma: no cover + break # pragma: no cover + else: + break + + return False if self._keyboard_fd is None else check_r == ready_r + + @contextlib.contextmanager + def cbreak(self): + """ + Allow each keystroke to be read immediately after it is pressed. + + This is a context manager for :func:`tty.setcbreak`. + + This context manager activates 'rare' mode, the opposite of 'cooked' + mode: On entry, :func:`tty.setcbreak` mode is activated disabling + line-buffering of keyboard input and turning off automatic echo of + input as output. + + .. note:: You must explicitly print any user input you would like + displayed. If you provide any kind of editing, you must handle + backspace and other line-editing control functions in this mode + as well! + + **Normally**, characters received from the keyboard cannot be read + by Python until the *Return* key is pressed. Also known as *cooked* or + *canonical input* mode, it allows the tty driver to provide + line-editing before shuttling the input to your program and is the + (implicit) default terminal mode set by most unix shells before + executing programs. + + Technically, this context manager sets the :mod:`termios` attributes + of the terminal attached to :obj:`sys.__stdin__`. + + .. note:: :func:`tty.setcbreak` sets ``VMIN = 1`` and ``VTIME = 0``, + see http://www.unixwiz.net/techtips/termios-vmin-vtime.html + """ + if HAS_TTY and self._keyboard_fd is not None: + # Save current terminal mode: + save_mode = termios.tcgetattr(self._keyboard_fd) + save_line_buffered = self._line_buffered + tty.setcbreak(self._keyboard_fd, termios.TCSANOW) + try: + self._line_buffered = False + yield + finally: + # Restore prior mode: + termios.tcsetattr(self._keyboard_fd, + termios.TCSAFLUSH, + save_mode) + self._line_buffered = save_line_buffered + else: + yield + + @contextlib.contextmanager + def raw(self): + r""" + A context manager for :func:`tty.setraw`. + + Although both :meth:`break` and :meth:`raw` modes allow each keystroke + to be read immediately after it is pressed, Raw mode disables + processing of input and output. + + In cbreak mode, special input characters such as ``^C`` or ``^S`` are + interpreted by the terminal driver and excluded from the stdin stream. + In raw mode these values are receive by the :meth:`inkey` method. + + Because output processing is not done, the newline ``'\n'`` is not + enough, you must also print carriage return to ensure that the cursor + is returned to the first column:: + + with term.raw(): + print("printing in raw mode", end="\r\n") + """ + if HAS_TTY and self._keyboard_fd is not None: + # Save current terminal mode: + save_mode = termios.tcgetattr(self._keyboard_fd) + save_line_buffered = self._line_buffered + tty.setraw(self._keyboard_fd, termios.TCSANOW) + try: + self._line_buffered = False + yield + finally: + # Restore prior mode: + termios.tcsetattr(self._keyboard_fd, + termios.TCSAFLUSH, + save_mode) + self._line_buffered = save_line_buffered + else: + yield + + @contextlib.contextmanager + def keypad(self): + r""" + Context manager that enables directional keypad input. + + On entrying, this puts the terminal into "keyboard_transmit" mode by + emitting the keypad_xmit (smkx) capability. On exit, it emits + keypad_local (rmkx). + + On an IBM-PC keyboard with numeric keypad of terminal-type *xterm*, + with numlock off, the lower-left diagonal key transmits sequence + ``\\x1b[F``, translated to :class:`~.Terminal` attribute + ``KEY_END``. + + However, upon entering :meth:`keypad`, ``\\x1b[OF`` is transmitted, + translating to ``KEY_LL`` (lower-left key), allowing you to determine + diagonal direction keys. + """ + try: + self.stream.write(self.smkx) + self.stream.flush() + yield + finally: + self.stream.write(self.rmkx) + self.stream.flush() + + def inkey(self, timeout=None, esc_delay=0.35): + """ + Read and return the next keyboard event within given timeout. + + Generally, this should be used inside the :meth:`raw` context manager. + + :arg float timeout: Number of seconds to wait for a keystroke before + returning. When ``None`` (default), this method may block + indefinitely. + :arg float esc_delay: To distinguish between the keystroke of + ``KEY_ESCAPE``, and sequences beginning with escape, the parameter + ``esc_delay`` specifies the amount of time after receiving escape + (``chr(27)``) to seek for the completion of an application key + before returning a :class:`~.Keystroke` instance for + ``KEY_ESCAPE``. + :rtype: :class:`~.Keystroke`. + :returns: :class:`~.Keystroke`, which may be empty (``u''``) if + ``timeout`` is specified and keystroke is not received. + + .. note:: When used without the context manager :meth:`cbreak`, or + :meth:`raw`, :obj:`sys.__stdin__` remains line-buffered, and this + function will block until the return key is pressed! + + .. note:: On Windows, a 10 ms sleep is added to the key press detection loop to reduce CPU + load. Due to the behavior of :py:func:`time.sleep` on Windows, this will actually + result in a 15.6 ms delay when using the default `time resolution + `_. + Decreasing the time resolution will reduce this to 10 ms, while increasing it, which + is rarely done, will have a perceptable impact on the behavior. + """ + resolve = functools.partial(resolve_sequence, + mapper=self._keymap, + codes=self._keycodes) + + stime = time.time() + + # re-buffer previously received keystrokes, + ucs = u'' + while self._keyboard_buf: + ucs += self._keyboard_buf.pop() + + # receive all immediately available bytes + while self.kbhit(timeout=0): + ucs += self.getch() + + # decode keystroke, if any + ks = resolve(text=ucs) + + # so long as the most immediately received or buffered keystroke is + # incomplete, (which may be a multibyte encoding), block until until + # one is received. + while not ks and self.kbhit(timeout=_time_left(stime, timeout)): + ucs += self.getch() + ks = resolve(text=ucs) + + # handle escape key (KEY_ESCAPE) vs. escape sequence (like those + # that begin with \x1b[ or \x1bO) up to esc_delay when + # received. This is not optimal, but causes least delay when + # "meta sends escape" is used, or when an unsupported sequence is + # sent. + # + # The statement, "ucs in self._keymap_prefixes" has an effect on + # keystrokes such as Alt + Z ("\x1b[z" with metaSendsEscape): because + # no known input sequences begin with such phrasing to allow it to be + # returned more quickly than esc_delay otherwise blocks for. + if ks.code == self.KEY_ESCAPE: + esctime = time.time() + while (ks.code == self.KEY_ESCAPE and + ucs in self._keymap_prefixes and + self.kbhit(timeout=_time_left(esctime, esc_delay))): + ucs += self.getch() + ks = resolve(text=ucs) + + # buffer any remaining text received + self.ungetch(ucs[len(ks):]) + return ks + + +class WINSZ(collections.namedtuple('WINSZ', ( + 'ws_row', 'ws_col', 'ws_xpixel', 'ws_ypixel'))): + """ + Structure represents return value of :const:`termios.TIOCGWINSZ`. + + .. py:attribute:: ws_row + + rows, in characters + + .. py:attribute:: ws_col + + columns, in characters + + .. py:attribute:: ws_xpixel + + horizontal size, pixels + + .. py:attribute:: ws_ypixel + + vertical size, pixels + """ + #: format of termios structure + _FMT = 'hhhh' + #: buffer of termios structure appropriate for ioctl argument + _BUF = '\x00' * struct.calcsize(_FMT) + + +#: _CUR_TERM = None +#: From libcurses/doc/ncurses-intro.html (ESR, Thomas Dickey, et. al):: +#: +#: "After the call to setupterm(), the global variable cur_term is set to +#: point to the current structure of terminal capabilities. By calling +#: setupterm() for each terminal, and saving and restoring cur_term, it +#: is possible for a program to use two or more terminals at once." +#: +#: However, if you study Python's ``./Modules/_cursesmodule.c``, you'll find:: +#: +#: if (!initialised_setupterm && setupterm(termstr,fd,&err) == ERR) { +#: +#: Python - perhaps wrongly - will not allow for re-initialisation of new +#: terminals through :func:`curses.setupterm`, so the value of cur_term cannot +#: be changed once set: subsequent calls to :func:`curses.setupterm` have no +#: effect. +#: +#: Therefore, the :attr:`Terminal.kind` of each :class:`Terminal` is +#: essentially a singleton. This global variable reflects that, and a warning +#: is emitted if somebody expects otherwise. diff --git a/blessed/terminal.py:Zone.Identifier b/blessed/terminal.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/terminal.pyi b/blessed/terminal.pyi new file mode 100644 index 0000000..bff670b --- /dev/null +++ b/blessed/terminal.pyi @@ -0,0 +1,108 @@ +# std imports +from typing import IO, Any, List, Tuple, Union, Optional, OrderedDict, SupportsIndex, ContextManager + +# local +from .keyboard import Keystroke +from .sequences import Termcap +from .formatters import (FormattingString, + NullCallableString, + ParameterizingString, + FormattingOtherString) + +HAS_TTY: bool + +class Terminal: + caps: OrderedDict[str, Termcap] + errors: List[str] = ... + def __init__( + self, + kind: Optional[str] = ..., + stream: Optional[IO[str]] = ..., + force_styling: bool = ..., + ) -> None: ... + def __getattr__( + self, attr: str + ) -> Union[NullCallableString, ParameterizingString, FormattingString]: ... + @property + def kind(self) -> str: ... + @property + def does_styling(self) -> bool: ... + @property + def is_a_tty(self) -> bool: ... + @property + def height(self) -> int: ... + @property + def width(self) -> int: ... + @property + def pixel_height(self) -> int: ... + @property + def pixel_width(self) -> int: ... + def location( + self, x: Optional[int] = ..., y: Optional[int] = ... + ) -> ContextManager[None]: ... + def get_location(self, timeout: Optional[float] = ...) -> Tuple[int, int]: ... + def get_fgcolor(self, timeout: Optional[float] = ...) -> Tuple[int, int, int]: ... + def get_bgcolor(self, timeout: Optional[float] = ...) -> Tuple[int, int, int]: ... + def fullscreen(self) -> ContextManager[None]: ... + def hidden_cursor(self) -> ContextManager[None]: ... + def move_xy(self, x: int, y: int) -> ParameterizingString: ... + def move_yx(self, y: int, x: int) -> ParameterizingString: ... + @property + def move_left(self) -> FormattingOtherString: ... + @property + def move_right(self) -> FormattingOtherString: ... + @property + def move_up(self) -> FormattingOtherString: ... + @property + def move_down(self) -> FormattingOtherString: ... + @property + def color(self) -> Union[NullCallableString, ParameterizingString]: ... + def color_rgb(self, red: int, green: int, blue: int) -> FormattingString: ... + @property + def on_color(self) -> Union[NullCallableString, ParameterizingString]: ... + def on_color_rgb(self, red: int, green: int, blue: int) -> FormattingString: ... + def formatter(self, value: str) -> Union[NullCallableString, FormattingString]: ... + def rgb_downconvert(self, red: int, green: int, blue: int) -> int: ... + @property + def normal(self) -> str: ... + def link(self, url: str, text: str, url_id: str = ...) -> str: ... + @property + def stream(self) -> IO[str]: ... + @property + def number_of_colors(self) -> int: ... + @number_of_colors.setter + def number_of_colors(self, value: int) -> None: ... + @property + def color_distance_algorithm(self) -> str: ... + @color_distance_algorithm.setter + def color_distance_algorithm(self, value: str) -> None: ... + def ljust( + self, text: str, width: Optional[SupportsIndex] = ..., fillchar: str = ... + ) -> str: ... + def rjust( + self, text: str, width: Optional[SupportsIndex] = ..., fillchar: str = ... + ) -> str: ... + def center( + self, text: str, width: Optional[SupportsIndex] = ..., fillchar: str = ... + ) -> str: ... + def truncate(self, text: str, width: Optional[SupportsIndex] = ...) -> str: ... + def length(self, text: str) -> int: ... + def strip(self, text: str, chars: Optional[str] = ...) -> str: ... + def rstrip(self, text: str, chars: Optional[str] = ...) -> str: ... + def lstrip(self, text: str, chars: Optional[str] = ...) -> str: ... + def strip_seqs(self, text: str) -> str: ... + def split_seqs(self, text: str, maxsplit: int) -> List[str]: ... + def wrap( + self, text: str, width: Optional[int] = ..., **kwargs: Any + ) -> List[str]: ... + def getch(self) -> str: ... + def ungetch(self, text: str) -> None: ... + def kbhit(self, timeout: Optional[float] = ...) -> bool: ... + def cbreak(self) -> ContextManager[None]: ... + def raw(self) -> ContextManager[None]: ... + def keypad(self) -> ContextManager[None]: ... + def inkey( + self, timeout: Optional[float] = ..., esc_delay: float = ... + ) -> Keystroke: ... + +class WINSZ: ... diff --git a/blessed/terminal.pyi:Zone.Identifier b/blessed/terminal.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/win_terminal.py b/blessed/win_terminal.py new file mode 100644 index 0000000..267e028 --- /dev/null +++ b/blessed/win_terminal.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +"""Module containing Windows version of :class:`Terminal`.""" + +from __future__ import absolute_import + +# std imports +import time +import msvcrt # pylint: disable=import-error +import contextlib + +# 3rd party +from jinxed import win32 # pylint: disable=import-error + +# local +from .terminal import WINSZ +from .terminal import Terminal as _Terminal + + +class Terminal(_Terminal): + """Windows subclass of :class:`Terminal`.""" + + def getch(self): + r""" + Read, decode, and return the next byte from the keyboard stream. + + :rtype: unicode + :returns: a single unicode character, or ``u''`` if a multi-byte + sequence has not yet been fully received. + + For versions of Windows 10.0.10586 and later, the console is expected + to be in ENABLE_VIRTUAL_TERMINAL_INPUT mode and the default method is + called. + + For older versions of Windows, msvcrt.getwch() is used. If the received + character is ``\x00`` or ``\xe0``, the next character is + automatically retrieved. + """ + if win32.VTMODE_SUPPORTED: + return super(Terminal, self).getch() + + rtn = msvcrt.getwch() + if rtn in ('\x00', '\xe0'): + rtn += msvcrt.getwch() + return rtn + + def kbhit(self, timeout=None): + """ + Return whether a keypress has been detected on the keyboard. + + This method is used by :meth:`inkey` to determine if a byte may + be read using :meth:`getch` without blocking. This is implemented + by wrapping msvcrt.kbhit() in a timeout. + + :arg float timeout: When ``timeout`` is 0, this call is + non-blocking, otherwise blocking indefinitely until keypress + is detected when None (default). When ``timeout`` is a + positive number, returns after ``timeout`` seconds have + elapsed (float). + :rtype: bool + :returns: True if a keypress is awaiting to be read on the keyboard + attached to this terminal. + """ + end = time.time() + (timeout or 0) + while True: + + if msvcrt.kbhit(): + return True + + if timeout is not None and end < time.time(): + break + + time.sleep(0.01) # Sleep to reduce CPU load + return False + + @staticmethod + def _winsize(fd): + """ + Return named tuple describing size of the terminal by ``fd``. + + :arg int fd: file descriptor queries for its window size. + :rtype: WINSZ + :returns: named tuple describing size of the terminal + + WINSZ is a :class:`collections.namedtuple` instance, whose structure + directly maps to the return value of the :const:`termios.TIOCGWINSZ` + ioctl return value. The return parameters are: + + - ``ws_row``: width of terminal by its number of character cells. + - ``ws_col``: height of terminal by its number of character cells. + - ``ws_xpixel``: width of terminal by pixels (not accurate). + - ``ws_ypixel``: height of terminal by pixels (not accurate). + """ + window = win32.get_terminal_size(fd) + return WINSZ(ws_row=window.lines, ws_col=window.columns, + ws_xpixel=0, ws_ypixel=0) + + @contextlib.contextmanager + def cbreak(self): + """ + Allow each keystroke to be read immediately after it is pressed. + + This is a context manager for ``jinxed.w32.setcbreak()``. + + .. note:: You must explicitly print any user input you would like + displayed. If you provide any kind of editing, you must handle + backspace and other line-editing control functions in this mode + as well! + + **Normally**, characters received from the keyboard cannot be read + by Python until the *Return* key is pressed. Also known as *cooked* or + *canonical input* mode, it allows the tty driver to provide + line-editing before shuttling the input to your program and is the + (implicit) default terminal mode set by most unix shells before + executing programs. + """ + if self._keyboard_fd is not None: + + filehandle = msvcrt.get_osfhandle(self._keyboard_fd) + + # Save current terminal mode: + save_mode = win32.get_console_mode(filehandle) + save_line_buffered = self._line_buffered + win32.setcbreak(filehandle) + try: + self._line_buffered = False + yield + finally: + win32.set_console_mode(filehandle, save_mode) + self._line_buffered = save_line_buffered + + else: + yield + + @contextlib.contextmanager + def raw(self): + """ + A context manager for ``jinxed.w32.setcbreak()``. + + Although both :meth:`break` and :meth:`raw` modes allow each keystroke + to be read immediately after it is pressed, Raw mode disables + processing of input and output. + + In cbreak mode, special input characters such as ``^C`` are + interpreted by the terminal driver and excluded from the stdin stream. + In raw mode these values are receive by the :meth:`inkey` method. + """ + if self._keyboard_fd is not None: + + filehandle = msvcrt.get_osfhandle(self._keyboard_fd) + + # Save current terminal mode: + save_mode = win32.get_console_mode(filehandle) + save_line_buffered = self._line_buffered + win32.setraw(filehandle) + try: + self._line_buffered = False + yield + finally: + win32.set_console_mode(filehandle, save_mode) + self._line_buffered = save_line_buffered + + else: + yield diff --git a/blessed/win_terminal.py:Zone.Identifier b/blessed/win_terminal.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/win_terminal.pyi b/blessed/win_terminal.pyi new file mode 100644 index 0000000..275f16f --- /dev/null +++ b/blessed/win_terminal.pyi @@ -0,0 +1,11 @@ +# std imports +from typing import Optional, ContextManager + +# local +from .terminal import Terminal as _Terminal + +class Terminal(_Terminal): + def getch(self) -> str: ... + def kbhit(self, timeout: Optional[float] = ...) -> bool: ... + def cbreak(self) -> ContextManager[None]: ... + def raw(self) -> ContextManager[None]: ... diff --git a/blessed/win_terminal.pyi:Zone.Identifier b/blessed/win_terminal.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/example_game.py b/example_game.py new file mode 100644 index 0000000..ba996f5 --- /dev/null +++ b/example_game.py @@ -0,0 +1,7 @@ +from retro.game import Game +from retro.agent import ArrowKeyAgent + +agent = ArrowKeyAgent() +state = {} +game = Game([agent], state) +game.play() \ No newline at end of file diff --git a/nav_game.py b/nav_game.py index 191d83d..aa4cad3 100644 --- a/nav_game.py +++ b/nav_game.py @@ -2,3 +2,13 @@ # ------------ # By MWC Contributors # This class implements a simple game where a spaceship avoids asteroids. + +from retro.game import Game +from spaceship import Spaceship +from asteroid_spawner import AsteroidSpawner + +board_size = (25, 25) +ship = Spaceship(board_size) +spawner = AsteroidSpawner(board_size) +game = Game([ship, spawner], {"score": 0}, board_size=board_size) +game.play() \ No newline at end of file diff --git a/retro/__pycache__/agent.cpython-310.pyc b/retro/__pycache__/agent.cpython-310.pyc new file mode 100644 index 0000000..1b9a2fb Binary files /dev/null and b/retro/__pycache__/agent.cpython-310.pyc differ diff --git a/retro/__pycache__/errors.cpython-310.pyc b/retro/__pycache__/errors.cpython-310.pyc new file mode 100644 index 0000000..a08c919 Binary files /dev/null and b/retro/__pycache__/errors.cpython-310.pyc differ diff --git a/retro/__pycache__/game.cpython-310.pyc b/retro/__pycache__/game.cpython-310.pyc new file mode 100644 index 0000000..0562a83 Binary files /dev/null and b/retro/__pycache__/game.cpython-310.pyc differ diff --git a/retro/__pycache__/graph.cpython-310.pyc b/retro/__pycache__/graph.cpython-310.pyc new file mode 100644 index 0000000..479253c Binary files /dev/null and b/retro/__pycache__/graph.cpython-310.pyc differ diff --git a/retro/__pycache__/validation.cpython-310.pyc b/retro/__pycache__/validation.cpython-310.pyc new file mode 100644 index 0000000..3b72699 Binary files /dev/null and b/retro/__pycache__/validation.cpython-310.pyc differ diff --git a/retro/__pycache__/view.cpython-310.pyc b/retro/__pycache__/view.cpython-310.pyc new file mode 100644 index 0000000..120b5df Binary files /dev/null and b/retro/__pycache__/view.cpython-310.pyc differ diff --git a/retro/agent.py b/retro/agent.py new file mode 100644 index 0000000..4dd57cf --- /dev/null +++ b/retro/agent.py @@ -0,0 +1,99 @@ +class Agent: + """Represents a character in the game. To create an Agent, define a new + class with some of the attributes and methods below. You may change any of + the Agent's attributes at any time, and the result will immediately be + visible in the game. + + After you create your Agents, add them to the ``Game``, either when it is created + or using ``Game.add_agent`` later on. Then the Game will take care of calling + the Agent's methods at the appropriate times. + + Attributes: + position: (Required) The character's ``(int, int)`` position on the game + board. + character: (Required unless display is ``False``.) A one-character string + which will be displayed at the Agent's position on the game board. + name: (Optional) If an agent has a name, it must be unique within the game. + Agent names can be used to look up agents with + :py:meth:`retro.game.Game.get_agent_by_name`. + color (str): (Optional) The agent's color. + `Available colors `_. + display: (Optional) When ``False``, the Agent will not be displayed on the + board. This is useful when you want to create an agent which will be displayed + later, or when you want to create an agent which acts on the Game indirectly, + for example by spawning other Agents. Defaults to True. + z: (Optional) When multiple Agents have the same position on the board, the + Agent with the highest ``z`` value will be displayed. + The Game is played on a two-dimensional (x, y) board, but you can think of + ``z`` as a third "up" dimension. Defaults to 0. + """ + character = "*" + position = (0, 0) + name = "agent" + color = "white_on_black" + display = True + z = 0 + + def play_turn(self, game): + """If an Agent has this method, it will be called once + each turn. + + Arguments: + game (Game): The game which is currently being played will be + passed to the Agent, in case it needs to check anything about + the game or make any changes. + """ + pass + + def handle_keystroke(self, keystroke, game): + """If an Agent has a this method, it will be called every + time a key is pressed in the game. + + Arguments: + keystroke (blessed.keyboard.Keystroke): The key which was pressed. You can + compare a Keystroke with a string (e.g. ``if keystroke == 'q'``) to check + whether it is a regular letter, number, or symbol on the keyboard. You can + check special keys using the keystroke's name + (e.g. ``if keystroke.name == "KEY_RIGHT"``). Run your game in debug mode to + see the names of keystrokes. + game (Game): The game which is currently being played will be + passed to the Agent, in case it needs to check anything about + the game or make any changes. + + """ + pass + +class ArrowKeyAgent: + """A simple agent which can be moved around with the arrow keys. + """ + name = "ArrowKeyAgent" + character = "*" + position = (0,0) + display = True + z = 0 + + def play_turn(self, game): + pass + + def handle_keystroke(self, keystroke, game): + """Moves the agent's position if the keystroke is one of the arrow keys. + One by one, checks the keystroke's name against each arrow key. + Then uses :py:meth:`try_to_move` to check whether the move is on the + game's board before moving. + """ + x, y = self.position + if keystroke.name == "KEY_RIGHT": + self.try_to_move((x + 1, y), game) + elif keystroke.name == "KEY_UP": + self.try_to_move((x, y - 1), game) + elif keystroke.name == "KEY_LEFT": + self.try_to_move((x - 1, y), game) + elif keystroke.name == "KEY_DOWN": + self.try_to_move((x, y + 1), game) + + def try_to_move(self, position, game): + """Moves to the position if it is on the game board. + """ + if game.on_board(position): + self.position = position + game.log(f"Position: {self.position}") diff --git a/retro/agent.py:Zone.Identifier b/retro/agent.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/errors.py b/retro/errors.py new file mode 100644 index 0000000..d95a2d6 --- /dev/null +++ b/retro/errors.py @@ -0,0 +1,40 @@ +class GameError(Exception): + pass + +class AgentWithNameAlreadyExists(GameError): + def __init__(self, name): + message = f"There is already an agent named {agent.name} in the game" + super().__init__(message) + +class AgentNotFoundByName(GameError): + def __init__(self, name): + message = f"There is no agent named {agent.name} in the game" + super().__init__(message) + +class AgentNotInGame(GameError): + def __init__(self, agent): + name = agent.name or f"anonymous {agent.__class__.__name__}" + message = f"Agent {name} is not in the game" + super().__init__(message) + +class IllegalMove(GameError): + def __init__(self, agent, position): + message = f"Agent {agent.name} tried to move to {position}" + super().__init__(message) + +class GraphError(GameError): + pass + +class TerminalTooSmall(GameError): + BORDER_X = 2 + BORDER_Y = 3 + STATE_HEIGHT = 5 + + def __init__(self, width=None, width_needed=None, height=None, height_needed=None): + if width is not None and width_needed is not None and width_needed < width: + err = f"The terminal width ({width}) is less than the required {width_needed}." + super().__init__(err) + elif height is not None and height_needed is not None and height_needed < height: + err = f"The terminal height ({height}) is less than the required {height_needed}." + else: + raise ValueError(f"TerminalTooSmall called with illegal values.") diff --git a/retro/errors.py:Zone.Identifier b/retro/errors.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/examples/__pycache__/debug.cpython-310.pyc b/retro/examples/__pycache__/debug.cpython-310.pyc new file mode 100644 index 0000000..3174801 Binary files /dev/null and b/retro/examples/__pycache__/debug.cpython-310.pyc differ diff --git a/retro/examples/debug.py b/retro/examples/debug.py new file mode 100644 index 0000000..afbd490 --- /dev/null +++ b/retro/examples/debug.py @@ -0,0 +1,6 @@ +from retro.game import Game +from retro.agent import ArrowKeyAgent + +game = Game([ArrowKeyAgent()], {}, debug=True) +game.play() + diff --git a/retro/examples/debug.py:Zone.Identifier b/retro/examples/debug.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/examples/nav.py b/retro/examples/nav.py new file mode 100644 index 0000000..1c06e31 --- /dev/null +++ b/retro/examples/nav.py @@ -0,0 +1,139 @@ +from random import randint +from retro.game import Game + +HEIGHT = 25 +WIDTH = 25 + +class Spaceship: + """A player-controlled agent which moves left and right, dodging asteroids. + Spaceship is a pretty simple class. The ship's character is ``^``, and + its position starts at the bottom center of the screen. + """ + name = "ship" + character = '^' + position = (WIDTH // 2, HEIGHT - 1) + color = "black_on_skyblue1" + + def handle_keystroke(self, keystroke, game): + """When the + left or arrow key is pressed, it moves left or right. If the ship's + new position is empty, it moves to that position. If the new position + is occupied (by an asteroid!) the game ends. + """ + x, y = self.position + if keystroke.name in ("KEY_LEFT", "KEY_RIGHT"): + if keystroke.name == "KEY_LEFT": + new_position = (x - 1, y) + else: + new_position = (x + 1, y) + if game.on_board(new_position): + if game.is_empty(new_position): + self.position = new_position + else: + self.explode() + game.end() + + def explode(self): + """Sets the ship's character to ``*`` and its color to red. + """ + self.color = "crimson_on_skyblue1" + self.character = '*' + +class Asteroid: + """When Asteroids are spawned, they fall down the screen until they + reach the bottom row and are removed. + An Asteroid's position is set when it is created. + Whenever an asteroid moves, it + checks whether it has it the ship. + """ + character = 'O' + color = "deepskyblue1_on_skyblue1" + + def __init__(self, position): + self.position = position + + def play_turn(self, game): + """Nothing happens unless + ``game.turn_number`` is divisible by 2. The result is that asteroids + only move on even-numbered turns. If the asteroid is at the bottom of + the screen, it has run its course and should be removed from the game. + Otherwise, the asteroid's new position is one space down from its old + position. If the asteroid's new position is the same as the ship's + position, the game ends. + """ + if game.turn_number % 2 == 0: + self.set_color() + x, y = self.position + if y == HEIGHT - 1: + game.remove_agent(self) + else: + ship = game.get_agent_by_name('ship') + new_position = (x, y + 1) + if new_position == ship.position: + ship.explode() + game.end() + else: + self.position = new_position + + def set_color(self): + """To add to the game's drama, asteroids gradually become visible as they + fall down the screen. This method calculates the ratio of the asteroid's + position compared to the screen height--0 is the top of the screen and 1 is + the bottom ot the screen. Then sets the asteroid's color depending on the + ratio. (`Available colors `_) + """ + x, y = self.position + ratio = y / HEIGHT + if ratio < 0.2: + self.color = "deepskyblue1_on_skyblue1" + elif ratio < 0.4: + self.color = "deepskyblue2_on_skyblue1" + elif ratio < 0.6: + self.color = "deepskyblue3_on_skyblue1" + else: + self.color = "deepskyblue4_on_skyblue1" + +class AsteroidSpawner: + """An agent which is not displayed on the board, but which constantly spawns + asteroids. + """ + display = False + + def play_turn(self, game): + """Adds 1 to the game score and then uses + :py:meth:`~retro.examples.nav.should_spawn_asteroid` to decide whether to + spawn an asteroid. When :py:meth:`~retro.examples.nav.should_spawn_asteroid` + comes back ``True``, creates a new instance of + :py:class:`~retro.examples.nav.Asteroid` at a random position along the + top of the screen and adds the asteroid to the game. + """ + game.state['score'] += 1 + if self.should_spawn_asteroid(game.turn_number): + asteroid = Asteroid((randint(0, WIDTH - 1), 0)) + game.add_agent(asteroid) + + def should_spawn_asteroid(self, turn_number): + """Decides whether to spawn an asteroid. + Uses a simple but effective algorithm to make the game get + progressively more difficult: choose a random number and return + ``True`` if the number is less than the current turn number. At + the beginning of the game, few asteroids will be spawned. As the + turn number climbs toward 1000, asteroids are spawned almost + every turn. + + Arguments: + turn_number (int): The current turn in the game. + """ + return randint(0, 1000) < turn_number + +if __name__ == '__main__': + ship = Spaceship() + spawner = AsteroidSpawner() + game = Game( + [ship, spawner], + {"score": 0}, + board_size=(WIDTH, HEIGHT), + color="deepskyblue4_on_skyblue1", + ) + game.play() + diff --git a/retro/examples/nav.py:Zone.Identifier b/retro/examples/nav.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/examples/simple.py b/retro/examples/simple.py new file mode 100644 index 0000000..2af4218 --- /dev/null +++ b/retro/examples/simple.py @@ -0,0 +1,7 @@ +from retro.game import Game +from retro.agent import ArrowKeyAgent + +agent = ArrowKeyAgent() +state = {} +game = Game([agent], state) +game.play() diff --git a/retro/examples/simple.py:Zone.Identifier b/retro/examples/simple.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/examples/snake.py b/retro/examples/snake.py new file mode 100644 index 0000000..173baaf --- /dev/null +++ b/retro/examples/snake.py @@ -0,0 +1,198 @@ +from random import randint +from retro.game import Game + +class Apple: + """An agent representing the Apple. + Note how Apple doesn't have ``play_turn`` or + ``handle_keystroke`` methods: the Apple doesn't need to do + anything in this game. It just sits there waiting to get + eaten. + + Attributes: + name: "Apple" + character: '@' + color: "red_on_black" (`Here's documentation on how colors + work `_ + position: (0, 0). The Apple will choose a random position + as soon as the game starts, but it needs an initial + position to be assigned. + + """ + name = "Apple" + character = '@' + color = "red_on_black" + position = (0, 0) + + def relocate(self, game): + """Sets position to a random empty position. This method is + called whenever the snake's head touches the apple. + + Arguments: + game (Game): The current game. + """ + self.position = self.random_empty_position(game) + + def random_empty_position(self, game): + """Returns a randomly-selected empty position. Uses a very + simple algorithm: Get the game's board size, choose a + random x-value between 0 and the board width, and choose + a random y-value between 0 and the board height. Now use + the game to check whether any Agents are occupying this + position. If so, keep randomly choosing a new position + until the position is empty. + """ + bw, bh = game.board_size + occupied_positions = game.get_agents_by_position() + while True: + position = (randint(0, bw-1), randint(0, bh-1)) + if position not in occupied_positions: + return position + +class SnakeHead: + """An Agent representing the snake's head. When the game starts, you control + the snake head using the arrow keys. The SnakeHead always has a direction, and + will keep moving in that direction every turn. When you press an arrow key, + you change the SnakeHead's direction. + + Attributes: + name: "Snake head" + position: (0,0) + character: ``'v'`` Depending on the snake head's direction, its character + changes to ``'<'``, ``'^'``, ``'>'``, or ``'v'``. + next_segment: Initially ``None``, this is a reference to a SnakeBodySegment. + growing: When set to True, the snake will grow a new segment on its next move. + """ + RIGHT = (1, 0) + UP = (0, -1) + LEFT = (-1, 0) + DOWN = (0, 1) + name = "Snake head" + position = (0, 0) + direction = DOWN + character = 'v' + next_segment = None + growing = False + + def play_turn(self, game): + """On each turn, the snake head uses its position and direction to figure out + its next position. If the snake head is able to move there (it's on the board and + not occuppied by part of the snake's body), it moves. + + Then, if the snake head is on the Apple, the Apple moves to a new random position + and ``growing`` is set to True. + + Now we need to deal with two situations. First, if ``next_segment`` is not None, there is + a SnakeBodySegment attached to the head. We need the body to follow the head, + so we call ``self.next_segment.move``, passing the head's old position + (this will be the body's new position), a reference to the game, and a value for + ``growing``. If the snake needs to grow, we need to pass this information along + the body until it reaches the tail--this is where the next segment will be attached. + + If there is no ``next_segment`` but ``self.growing`` is True, it's time to add + a body! We set ``self.next_segment`` to a new SnakeBodySegment, set its + position to the head's old position, and add it to the game. We also add 1 to the + game's score. + """ + x, y = self.position + dx, dy = self.direction + if self.can_move((x+dx, y+dy), game): + self.position = (x+dx, y+dy) + if self.is_on_apple(self.position, game): + apple = game.get_agent_by_name("Apple") + apple.relocate(game) + self.growing = True + if self.next_segment: + self.next_segment.move((x, y), game, growing=self.growing) + elif self.growing: + self.next_segment = SnakeBodySegment(1, (x, y)) + game.add_agent(self.next_segment) + game.state['score'] += 1 + self.growing = False + + def handle_keystroke(self, keystroke, game): + """Checks whether one of the arrow keys has been pressed. + If so, sets the SnakeHead's direction and character. + """ + x, y = self.position + if keystroke.name == "KEY_RIGHT": + self.direction = self.RIGHT + self.character = '>' + elif keystroke.name == "KEY_UP": + self.direction = self.UP + self.character = '^' + elif keystroke.name == "KEY_LEFT": + self.direction = self.LEFT + self.character = '<' + elif keystroke.name == "KEY_DOWN": + self.direction = self.DOWN + self.character = 'v' + + def can_move(self, position, game): + on_board = game.on_board(position) + empty = game.is_empty(position) + on_apple = self.is_on_apple(position, game) + return on_board and (empty or on_apple) + + def is_on_apple(self, position, game): + apple = game.get_agent_by_name("Apple") + return apple.position == position + +class SnakeBodySegment: + """Finally, we need an Agent for the snake's body segments. + SnakeBodySegment doesn't have ``play_turn`` or ``handle_keystroke`` methods because + it never does anything on its own. It only moves when the SnakeHead, or the previous + segment, tells it to move. + + Arguments: + segment_id (int): Keeps track of how far back this segment is from the head. + This is used to give the segment a unique name, and also to keep track + of how many points the player earns for eating the next apple. + position (int, int): The initial position. + + Attributes: + character: '*' + next_segment: Initially ``None``, this is a reference to a SnakeBodySegment + when this segment is not the last one in the snake's body. + + """ + character = '*' + next_segment = None + + def __init__(self, segment_id, position): + self.segment_id = segment_id + self.name = f"Snake body segment {segment_id}" + self.position = position + + def move(self, new_position, game, growing=False): + """When SnakeHead moves, it sets off a chain reaction, moving all its + body segments. Whenever the head or a body segment has another segment + (``next_segment``), it calls that segment's ``move`` method. + + This method updates the SnakeBodySegment's position. Then, if + ``self.next_segment`` is not None, calls that segment's ``move`` method. + If there is no next segment and ``growing`` is True, then we set + ``self.next_segment`` to a new SnakeBodySegment in this segment's old + position, and update the game's score. + + Arguments: + new_position (int, int): The new position. + game (Game): A reference to the current game. + growing (bool): (Default False) When True, the snake needs to + add a new segment. + """ + old_position = self.position + self.position = new_position + if self.next_segment: + self.next_segment.move(old_position, game, growing=growing) + elif growing: + self.next_segment = SnakeBodySegment(self.segment_id + 1, old_position) + game.add_agent(self.next_segment) + game.state['score'] += self.segment_id + 1 + +if __name__ == '__main__': + head = SnakeHead() + apple = Apple() + game = Game([head, apple], {'score': 0}, board_size=(32, 16), framerate=12) + apple.relocate(game) + game.play() + diff --git a/retro/examples/snake.py:Zone.Identifier b/retro/examples/snake.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/game.py b/retro/game.py new file mode 100644 index 0000000..ea2fafb --- /dev/null +++ b/retro/game.py @@ -0,0 +1,216 @@ +from collections import defaultdict +from signal import signal, SIGWINCH +from time import sleep, perf_counter +from blessed import Terminal +from retro.view import View +from retro.validation import ( + validate_agent, + validate_state, + validate_agent_name, + validate_position, +) +from retro.errors import ( + AgentWithNameAlreadyExists, + AgentNotFoundByName, + IllegalMove, +) + +class Game: + """ + Creates a playable game. + You will use Game to create games, but don't need to read or understand how + this class works. The main work in creating a + + Arguments: + agents (list): A list of agents to add to the game. + state (dict): A dict containing the game's initial state. + board_size (int, int): (Optional) The two-dimensional size of the game board. D + debug (bool): (Optional) Turn on debug mode, showing log messages while playing. + framerate (int): (Optional) The target number of frames per second at which the + game should run. + color (str): (Optional) The game's background color scheme. `Available colors `_. + + :: + + # This example will create a simple game. + from retro.game import Game + from retro.agent import ArrowKeyAgent + + agents = [ArrowKeyAgent()] + state = {} + game = Game(agents, state) + game.play() + + """ + STATE_HEIGHT = 5 + EXIT_CHARACTERS = ("KEY_ENTER", "KEY_ESCAPE") + + def __init__(self, agents, state, board_size=(64, 32), debug=False, framerate=24, + color="white_on_black"): + self.log_messages = [] + self.agents_by_name = {} + self.agents = [] + self.state = validate_state(state) + self.board_size = board_size + self.debug = debug + self.framerate = framerate + self.turn_number = 0 + self.color = color + for agent in agents: + self.add_agent(agent) + + def play(self): + """Starts the game. + """ + self.playing = True + terminal = Terminal() + with terminal.fullscreen(), terminal.hidden_cursor(), terminal.cbreak(): + view = View(terminal, color=self.color) + while self.playing: + turn_start_time = perf_counter() + self.turn_number += 1 + self.keys_pressed = self.collect_keystrokes(terminal) + if self.debug and self.keys_pressed: + self.log("Keys: " + ', '.join(k.name or str(k) for k in self.keys_pressed)) + for agent in self.agents: + if hasattr(agent, 'handle_keystroke'): + for key in self.keys_pressed: + agent.handle_keystroke(key, self) + if hasattr(agent, 'play_turn'): + agent.play_turn(self) + if getattr(agent, 'display', True): + if not self.on_board(agent.position): + raise IllegalMove(agent, agent.position) + view.render(self) + turn_end_time = perf_counter() + time_elapsed_in_turn = turn_end_time - turn_start_time + time_remaining_in_turn = max(0, 1/self.framerate - time_elapsed_in_turn) + sleep(time_remaining_in_turn) + while True: + if terminal.inkey().name in self.EXIT_CHARACTERS: + break + + def collect_keystrokes(self, terminal): + keys = set() + while True: + key = terminal.inkey(0.001) + if key: + keys.add(key) + else: + break + return keys + + def log(self, message): + """Write a log message. + Log messages are only shown when debug mode is on. + They can be very useful for debugging. + + Arguments: + message (str): The message to log. + """ + self.log_messages.append((self.turn_number, message)) + + def end(self): + """Ends the game. No more turns will run. + """ + self.playing = False + + def add_agent(self, agent): + """Adds an agent to the game. + Whenever you want to add a new agent during the game, you must add it to + the game using this method. + + Arguments: + agent: An instance of an agent class. + """ + validate_agent(agent) + if getattr(agent, "display", True) and not self.on_board(agent.position): + raise IllegalMove(agent, agent.position) + if hasattr(agent, "name"): + if agent.name in self.agents_by_name: + raise AgentWithNameAlreadyExists(agent.name) + self.agents_by_name[agent.name] = agent + self.agents.append(agent) + + def get_agent_by_name(self, name): + """Looks up an agent by name. + This is useful when one agent needs to interact with another agent. + + Arguments: + name (str): The agent's name. If there is no agent with this name, + you will get an error. + + Returns: + An agent. + """ + validate_agent_name(name) + if name in self.agents_by_name: + return self.agents_by_name[name] + else: + raise AgentNotFoundByName(name) + + def is_empty(self, position): + """Checks whether a position is occupied by any agents. + + Arguments: + position (int, int): The position to check. + + Returns: + A bool + """ + return position not in self.get_agents_by_position() + + def get_agents_by_position(self): + """Returns a dict where each key is a position (e.g. (10, 20)) and + each value is a list containing all the agents at that position. + This is useful when an agent needs to find out which other agents are + on the same space or nearby. + """ + positions = defaultdict(list) + for agent in self.agents: + if getattr(agent, "display", True): + validate_position(agent.position) + positions[agent.position].append(agent) + return positions + + def remove_agent(self, agent): + """Removes an agent from the game. + + Arguments: + agent (Agent): the agent to remove. + """ + if agent not in self.agents: + raise AgentNotInGame(agent) + else: + self.agents.remove(agent) + if hasattr(agent, "name"): + self.agents_by_name.pop(agent.name) + + def remove_agent_by_name(self, name): + """Removes an agent from the game. + + Arguments: + name (str): the agent's name. + """ + validate_agent_name(name) + if name not in self.agents_by_name: + raise AgentNotFoundByName(name) + agent = self.agents_by_name.pop(name) + self.agents.remove(agent) + + def on_board(self, position): + """Checks whether a position is on the game board. + + Arguments: + position (int, int): The position to check + + Returns: + A bool + """ + validate_position(position) + x, y = position + bx, by = self.board_size + return x >= 0 and x < bx and y >= 0 and y < by + + + diff --git a/retro/game.py:Zone.Identifier b/retro/game.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/graph.py b/retro/graph.py new file mode 100644 index 0000000..994212e --- /dev/null +++ b/retro/graph.py @@ -0,0 +1,162 @@ +from retro.errors import GraphError + +class Graph: + def __init__(self, vertices=None, edges=None): + self.vertices = vertices or [] + self.edges = edges or [] + + def __str__(self): + return '\n'.join(str(e) for e in self.edges) + + def get_or_create_vertex(self, x, y): + for v in self.vertices: + if x == v.x and y == v.y: + return v + for e in self.edges: + if e.crosses(x, y): + return self.split_edge(e, x, y) + v = Vertex(x, y) + self.vertices.append(v) + return v + + def get_or_create_edge(self, x0, y0, x1, y1): + v0 = self.get_or_create_vertex(x0, y0) + v1 = self.get_or_create_vertex(x1, y1) + new_edge = Edge(v0, v1) + for e in self.edges: + if e == new_edge: + new_edge.remove() + return e + return new_edge + + def split_edge(self, edge, x, y): + """ + Splits an edge by inserting a new vertex along the edge. + """ + if not edge.crosses(x, y): + raise GraphError(f"Can't split edge {edge} at ({x}, {y})") + self.remove_edge(edge) + v = Vertex(x, y) + self.vertices.append(v) + self.edges.append(Edge(edge.begin, v)) + self.edges.append(Edge(v, edge.end)) + + def remove_edge(self, edge): + if edge not in self.edges: + raise GraphError(f"Edge {edge} is not in the graph") + self.edges.remove(edge) + edge.begin.edges.remove(edge) + edge.end.edges.remove(edge) + + def render(self, terminal): + for v in self.vertices: + v.render(terminal) + for e in self.edges: + e.render(terminal) + +class Vertex: + CHARACTERS = { + "0000": " ", + "0001": "═", + "0010": "║", + "0011": "╗", + "0100": "═", + "0101": "═", + "0110": "╔", + "0111": "╦", + "1000": "║", + "1001": "╝", + "1010": "║", + "1011": "╣", + "1100": "╚", + "1101": "╩", + "1110": "╠", + "1111": "╬", + } + def __init__(self, x, y): + self.x = x + self.y = y + self.edges = [] + + def __str__(self): + return f"({self.x}, {self.y})" + + def __eq__(self, other): + return self.x == other.x and self.y == other.y + + def neighbors(self): + vertices = [] + for edge in self.edges: + if self == edge.begin: + vertices.append(edge.end) + else: + vertices.append(edge.begin) + return vertices + + def render(self, terminal): + print(terminal.move_xy(self.x, self.y) + self.get_character()) + + def get_character(self): + u = self.has_up_edge() + r = self.has_right_edge() + d = self.has_down_edge() + l = self.has_left_edge() + code = ''.join([str(int(direction)) for direction in [u, r, d, l]]) + return self.CHARACTERS[code] + + def has_up_edge(self): + return any([v.x == self.x and v.y < self.y for v in self.neighbors()]) + + def has_right_edge(self): + return any([v.y == self.y and self.x < v.x for v in self.neighbors()]) + + def has_down_edge(self): + return any([v.x == self.x and self.y < v.y for v in self.neighbors()]) + + def has_left_edge(self): + return any([v.y == self.y and v.x < self.x for v in self.neighbors()]) + +class Edge: + def __init__(self, begin, end): + if not isinstance(begin, Vertex) or not isinstance(end, Vertex): + raise ValueError("Tried to initialize an Edge with a non-vertex") + if begin.x < end.x or begin.y < end.y: + self.begin = begin + self.end = end + else: + self.begin = end + self.end = begin + if not (self.is_horizontal() or self.is_vertical()): + raise ValueError("Edges must be horizontal or vertical.") + if self.is_horizontal() and self.is_vertical(): + raise ValueError("Self-edges are not allowed.") + self.begin.edges.append(self) + self.end.edges.append(self) + + def __str__(self): + return f"{self.begin} -> {self.end}" + + def render(self, terminal): + if self.is_horizontal(): + with terminal.location(self.begin.x + 1, self.begin.y): + line = "═" * (self.end.x - self.begin.x - 1) + print(line) + else: + for y in range(self.begin.y + 1, self.end.y): + print(terminal.move_xy(self.begin.x, y) + "║") + + def is_horizontal(self): + return self.begin.y == self.end.y + + def is_vertical(self): + return self.begin.x == self.end.x + + def crosses(self, x, y): + if self.is_horizontal(): + return self.begin.y == y and self.begin.x < x and x < self.end.x + else: + return self.begin.x == x and self.begin.y < y and y < self.end.y + + def remove(self): + self.begin.edges.remove(self) + self.end.edges.remove(self) diff --git a/retro/graph.py:Zone.Identifier b/retro/graph.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/grid.py b/retro/grid.py new file mode 100644 index 0000000..03742b6 --- /dev/null +++ b/retro/grid.py @@ -0,0 +1,5 @@ +from retro.graph import Vertex, Edge, Graph + +class Grid: + def __init__(self): + self.graph = Graph diff --git a/retro/grid.py:Zone.Identifier b/retro/grid.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/validation.py b/retro/validation.py new file mode 100644 index 0000000..51377e0 --- /dev/null +++ b/retro/validation.py @@ -0,0 +1,44 @@ + +def validate_agent(agent): + if hasattr(agent, "name"): + validate_agent_name(agent.name) + if getattr(agent, 'display', True): + validate_position(agent.position) + if not hasattr(agent, "character"): + raise ValueError(f"Agent {agent.name} must have a character") + return agent + +def validate_state(state): + if not isinstance(state, dict): + raise TypeError(f"State is {type(state)}, but must be a dict.") + for key, value in state.items(): + if is_mutable(value): + raise ValueError(f"State must be immutable, but state[{key}] is {value}") + return state + +def validate_agent_name(name): + if not isinstance(name, str): + raise TypeError(f"Agent names must be strings") + return name + +def validate_position(position): + if not isinstance(position, tuple): + raise TypeError(f"Position is {type(position)}, but must be a tuple.") + if not len(position) == 2: + raise ValueError(f"Position is {position}. Must be a tuple of two integers.") + if not isinstance(position[0], int) and isinstance(position[1], int): + raise TypeError(f"Position is {position}. Must be a tuple of two integers.") + return position + +def is_mutable(obj): + if isinstance(obj, (int, float, bool, str, None)): + return False + elif isinstance(obj, tuple): + return all(is_mutable(element) for element in obj) + else: + return True + + + + + diff --git a/retro/validation.py:Zone.Identifier b/retro/validation.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/view.py b/retro/view.py new file mode 100644 index 0000000..5c37665 --- /dev/null +++ b/retro/view.py @@ -0,0 +1,127 @@ +from retro.graph import Vertex, Edge, Graph +from retro.errors import TerminalTooSmall + +class View: + BORDER_X = 2 + BORDER_Y = 3 + STATE_HEIGHT = 5 + DEBUG_WIDTH = 60 + + def __init__(self, terminal, color='white_on_black'): + self.terminal = terminal + self.color = color + + def render(self, game): + self.render_layout(game) + ox, oy = self.get_board_origin_coords(game) + self.render_state(game) + if game.debug: + self.render_debug_log(game) + for agent in sorted(game.agents, key=lambda a: getattr(a, 'z', 0)): + if getattr(agent, 'display', True): + ax, ay = agent.position + if hasattr(agent, 'color'): + color = self.get_color(agent.color) + print(self.terminal.move_xy(ox + ax, oy + ay) + color(agent.character)) + else: + print(self.terminal.move_xy(ox + ax, oy + ay) + agent.character) + + def render_layout(self, game): + bw, bh = game.board_size + self.check_terminal_size(game) + self.clear_screen() + layout_graph = self.get_layout_graph(game) + layout_graph.render(self.terminal) + + def clear_screen(self): + print(self.terminal.home + self.get_color(self.color) + self.terminal.clear) + + def get_color(self, color_string): + if not hasattr(self.terminal, color_string): + msg = ( + f"{color_string} is not a supported color." + "See https://blessed.readthedocs.io/en/latest/colors.html" + ) + raise ValueError(msg) + return getattr(self.terminal, color_string) + + def render_state(self, game): + bw, bh = game.board_size + ox, oy = self.get_state_origin_coords(game) + for i, key in enumerate(sorted(game.state.keys())): + msg = f"{key}: {game.state[key]}"[:bw] + print(self.terminal.move_xy(ox, oy + i) + msg) + + def render_debug_log(self, game): + bw, bh = game.board_size + debug_height = bh + self.STATE_HEIGHT + ox, oy = self.get_debug_origin_coords(game) + for i, (turn_number, message) in enumerate(game.log_messages[-debug_height:]): + msg = f"{turn_number}. {message}"[:self.DEBUG_WIDTH] + print(self.terminal.move_xy(ox, oy + i) + msg) + + def get_layout_graph(self, game): + bw, bh = game.board_size + sh = self.STATE_HEIGHT + ox, oy = self.get_board_origin_coords(game) + + vertices = [ + Vertex(ox - 1, oy - 1), + Vertex(ox + bw, oy - 1), + Vertex(ox + bw, oy + bh), + Vertex(ox + bw, oy + bh + sh), + Vertex(ox - 1, oy + bh + sh), + Vertex(ox - 1, oy + bh) + ] + edges = [ + Edge(vertices[0], vertices[1]), + Edge(vertices[1], vertices[2]), + Edge(vertices[2], vertices[3]), + Edge(vertices[3], vertices[4]), + Edge(vertices[4], vertices[5]), + Edge(vertices[5], vertices[0]), + Edge(vertices[5], vertices[2]), + ] + graph = Graph(vertices, edges) + if game.debug: + dw = self.DEBUG_WIDTH + graph.vertices.append(Vertex(ox + bw + dw, oy - 1)) + graph.vertices.append(Vertex(ox + bw + dw, oy + bh + sh)) + graph.edges.append(Edge(graph.vertices[1], graph.vertices[6])) + graph.edges.append(Edge(graph.vertices[6], graph.vertices[7])) + graph.edges.append(Edge(graph.vertices[3], graph.vertices[7])) + return graph + + def check_terminal_size(self, game): + bw, bh = game.board_size + width_needed = bw + self.BORDER_X + height_needed = bh + self.BORDER_Y + self.STATE_HEIGHT + if self.terminal.width < width_needed: + raise TerminalTooSmall(width=self.terminal.width, width_needed=width_needed) + elif self.terminal.height < height_needed: + raise TerminalTooSmall(height=self.terminal.height, height_needed=height_needed) + + def board_origin(self, game): + x, y = self.get_board_origin_coords(game) + return self.terminal.move_xy(x, y) + + def get_board_origin_coords(self, game): + bw, bh = game.board_size + margin_top = (self.terminal.height - bh - self.BORDER_Y) // 2 + if game.debug: + margin_left = (self.terminal.width - bw - self.DEBUG_WIDTH - self.BORDER_X) // 2 + else: + margin_left = (self.terminal.width - bw - self.BORDER_X) // 2 + return margin_left, margin_top + + def get_state_origin_coords(self, game): + bw, bh = game.board_size + ox, oy = self.get_board_origin_coords(game) + return ox, oy + bh + 1 + + def get_debug_origin_coords(self, game): + bw, bh = game.board_size + ox, oy = self.get_board_origin_coords(game) + return ox + bw + 1, oy + + diff --git a/retro/view.py:Zone.Identifier b/retro/view.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/spaceship.py b/spaceship.py index 9f3552a..36538a9 100644 --- a/spaceship.py +++ b/spaceship.py @@ -2,3 +2,24 @@ # ------------ # By MWC Contributors # This module defines a spaceship agent class. + +class Spaceship: + name = "ship" + character = '^' + + def __init__(self, board_size): + board_width, board_height = board_size + self.position = (board_width // 2, board_height - 1) + + def handle_keystroke(self, keystroke, game): + x, y = self.position + if keystroke.name in ("KEY_LEFT", "KEY_RIGHT"): + if keystroke.name == "KEY_LEFT": + new_position = (x - 1, y) + else: + new_position = (x + 1, y) + if game.on_board(new_position): + if game.is_empty(new_position): + self.position = new_position + else: + game.end() \ No newline at end of file