├── curtsies ├── py.typed ├── __init__.py ├── termformatconstants.py ├── configfile_keynames.py ├── fmtfuncs.py ├── termhelpers.py ├── curtsieskeys.py ├── escseqparse.py ├── formatstringarray.py ├── events.py └── input.py ├── examples ├── readme ├── curses_keys.py ├── times.txt ├── initial_input.py ├── sumtest.py ├── demo_fullscreen_window.py ├── demo_input_paste.py ├── demo_input_timeout.py ├── initial_input_with_cursor.py ├── simple.py ├── demo_fullscreen_with_input.py ├── quickstart.py ├── fps.py ├── demo_scrolling.py ├── testcache.py ├── demo_window.py ├── realtime.py ├── chat.py ├── snake.py ├── gameexample.py ├── tictactoeexample.py ├── tron.py └── tttplaybitboard.py ├── MANIFEST.in ├── .github ├── dependabot.yml └── workflows │ ├── publish-twine.yaml │ ├── lint.yaml │ └── build.yaml ├── docs ├── game.rst ├── requirements.txt ├── gameloop.rst ├── quickstart.rst ├── examples.rst ├── about.rst ├── index.rst ├── FSArray.rst ├── window.rst ├── terminal_output.py ├── Input.rst ├── Makefile ├── ansi.py ├── FmtStr.rst └── conf.py ├── .gitignore ├── setup.py ├── pyproject.toml ├── .git-blame-ignore-revs ├── tests ├── test_configfile_keynames.py ├── test_window.py ├── test_input.py ├── test_events.py └── test_terminal.py ├── setup.cfg ├── LICENSE ├── notes ├── window_resize_notes.txt └── timing_notes.txt ├── setup.md ├── CHANGELOG.md └── README.md /curtsies/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/readme: -------------------------------------------------------------------------------- 1 | These examples also function as manual tests - test them all before doing a release. 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include tests/*.py 3 | include examples/*.py 4 | include curtsies/py.typed 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /docs/game.rst: -------------------------------------------------------------------------------- 1 | Game Loop 2 | ^^^^^^^^^ 3 | 4 | Want to make a realtime game? Use input as a reactor 5 | to schedule frame events! 6 | 7 | .. literalinclude:: ../examples/quickstart.py 8 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | ansi2html==1.0.7 2 | pexpect==3.3 3 | git+git://github.com/thomasballinger/wcwidth.git@fixes 4 | git+git://github.com/thomasballinger/curtsies.git@master 5 | sphinxcontrib-napoleon 6 | -------------------------------------------------------------------------------- /docs/gameloop.rst: -------------------------------------------------------------------------------- 1 | Gameloop Example 2 | ^^^^^^^^^^^^^^^^ 3 | 4 | Use scheduled events for realtime interactive programs: 5 | 6 | .. literalinclude:: ../examples/fps.py 7 | 8 | Paste it into a file and try it out! 9 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ^^^^^^^^^^ 3 | This is what using (nearly every feature of) Curtsies looks like: 4 | 5 | .. literalinclude:: ../examples/quickstart.py 6 | 7 | Paste it into a file and try it out! 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv* 2 | *.pyc 3 | *.swp 4 | *.swo 5 | *.egg-info 6 | *.log 7 | .tox 8 | .coverage 9 | cover 10 | tox.ini 11 | .mypy_cache 12 | 13 | keylog 14 | 15 | .DS_Store 16 | build 17 | dist 18 | docs/_build 19 | -------------------------------------------------------------------------------- /examples/curses_keys.py: -------------------------------------------------------------------------------- 1 | from curtsies import Input 2 | 3 | def main(): 4 | with Input(keynames='curses') as input_generator: 5 | for e in input_generator: 6 | print(repr(e)) 7 | 8 | if __name__ == '__main__': 9 | main() 10 | -------------------------------------------------------------------------------- /curtsies/__init__.py: -------------------------------------------------------------------------------- 1 | """Terminal-formatted strings""" 2 | __version__ = "0.4.2" 3 | 4 | from .window import FullscreenWindow, CursorAwareWindow 5 | from .input import Input 6 | from .termhelpers import Nonblocking, Cbreak, Termmode 7 | from .formatstring import FmtStr, fmtstr 8 | from .formatstringarray import FSArray, fsarray 9 | -------------------------------------------------------------------------------- /examples/times.txt: -------------------------------------------------------------------------------- 1 | 60d306917dc051a2f9b1aa242d260d5940a819de sigints working (perhaps too complicated) all different: 0.938528 all identical: 0.411068 change on character 0.290748 100 iterations 2 | a33c3de0f91e0c68819af4c301dbf7b7cee5d2cf fixes for python3 based on chat example all different: 0.781105 all identical: 0.343822 change on character 0.178675 100 iterations 3 | -------------------------------------------------------------------------------- /examples/initial_input.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from curtsies.input import * 4 | 5 | def main(): 6 | """Ideally we shouldn't lose the first second of events""" 7 | time.sleep(1) 8 | with Input() as input_generator: 9 | for e in input_generator: 10 | print(repr(e)) 11 | if e == '': 12 | break 13 | if __name__ == '__main__': 14 | main() 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import ast 3 | import os 4 | 5 | 6 | def version(): 7 | """Return version string.""" 8 | with open(os.path.join("curtsies", "__init__.py")) as input_file: 9 | for line in input_file: 10 | if line.startswith("__version__"): 11 | return ast.parse(line).body[0].value.s 12 | 13 | 14 | setup( 15 | version=version(), 16 | ) 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 43", 4 | ] 5 | build-backend = "setuptools.build_meta" 6 | 7 | [tool.black] 8 | line-length = 88 9 | target_version = ["py36"] 10 | exclude = ''' 11 | ( 12 | /( 13 | \.git 14 | | build 15 | | curtsies.egg-info 16 | | dist 17 | | examples 18 | | notes 19 | | stubs 20 | )/ 21 | | bootstrap.py 22 | | docs/conf.py 23 | ) 24 | ''' 25 | -------------------------------------------------------------------------------- /examples/sumtest.py: -------------------------------------------------------------------------------- 1 | from curtsies.formatstring import FmtStr, Chunk 2 | import time 3 | 4 | def add_things(n): 5 | part = Chunk('hi', {'fg':36}) 6 | whole = FmtStr(part) 7 | return sum([whole for _ in range(n)], FmtStr()) 8 | 9 | def timeit(n): 10 | t0 = time.time() 11 | add_things(n) 12 | t1 = time.time() 13 | print(n, ':', t1 - t0) 14 | return (t1 - t0) 15 | 16 | if __name__ == '__main__': 17 | ns = range(100, 2000, 100) 18 | 19 | times = [timeit(i) for i in ns] 20 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ^^^^^^^^ 3 | 4 | * `Tic-Tac-Toe `_ 5 | 6 | .. image:: http://i.imgur.com/AucB55B.png 7 | 8 | * `Avoid the X's game `_ 9 | 10 | .. image:: http://i.imgur.com/nv1RQd3.png 11 | 12 | * `Bpython-curtsies uses curtsies `_ 13 | 14 | .. image:: http://i.imgur.com/r7rZiBS.png 15 | :target: http://www.youtube.com/watch?v=lwbpC4IJlyA 16 | -------------------------------------------------------------------------------- /examples/demo_fullscreen_window.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import signal 3 | import logging 4 | 5 | from curtsies import input, fmtstr, FullscreenWindow, CursorAwareWindow, Cbreak 6 | from curtsies import events 7 | 8 | from demo_window import array_size_test 9 | """ 10 | Reads input from user and prints an entire screen, one line less than a full screen, 11 | or one line more than the full screen 12 | """ 13 | 14 | if __name__ == '__main__': 15 | logging.basicConfig(filename='display.log',level=logging.DEBUG) 16 | array_size_test(FullscreenWindow(sys.stdout)) 17 | -------------------------------------------------------------------------------- /examples/demo_input_paste.py: -------------------------------------------------------------------------------- 1 | from curtsies.input import * 2 | 3 | def paste(): 4 | """ 5 | Returns user input, delayed by one second, as a string; creates a paste event for 6 | strings longer than the paste threshold and returns a list of the characters in 7 | the string separated by commas. 8 | """ 9 | with Input() as input_generator: 10 | print("If more than %d chars read in same read a paste event is generated" % input_generator.paste_threshold) 11 | for e in input_generator: 12 | print(repr(e)) 13 | 14 | if e == '': 15 | break 16 | 17 | time.sleep(1) 18 | 19 | if __name__ == '__main__': 20 | paste() 21 | -------------------------------------------------------------------------------- /examples/demo_input_timeout.py: -------------------------------------------------------------------------------- 1 | from curtsies.input import * 2 | 3 | def main(): 4 | """ 5 | Reads and returns user input; after 2 seconds, 1 second, .5 second 6 | and .2 second, respectively, an event -- user input -- is printed, 7 | or "None" is printed if no user input is received. 8 | """ 9 | with Input() as input_generator: 10 | print(repr(input_generator.send(2))) 11 | print(repr(input_generator.send(1))) 12 | print(repr(input_generator.send(.5))) 13 | print(repr(input_generator.send(.2))) 14 | for e in input_generator: 15 | print(repr(e)) 16 | if e == '': 17 | break 18 | if __name__ == '__main__': 19 | main() 20 | -------------------------------------------------------------------------------- /examples/initial_input_with_cursor.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from curtsies import * 4 | 5 | def main(): 6 | """Ideally we shouldn't lose the first second of events""" 7 | with Input() as input_generator: 8 | def extra_bytes_callback(string): 9 | print('got extra bytes', repr(string)) 10 | print('type:', type(string)) 11 | input_generator.unget_bytes(string) 12 | time.sleep(1) 13 | with CursorAwareWindow(extra_bytes_callback=extra_bytes_callback) as window: 14 | window.get_cursor_position() 15 | for e in input_generator: 16 | print(repr(e)) 17 | if e == '': 18 | break 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # This file contains a list of commits that are not likely what you 2 | # are looking for in a blame, such as mass reformatting or renaming. 3 | # 4 | # Use case: 5 | # $ git blame --ignore-revs-file .git-blame-ignore-revs 6 | # 7 | # You can also set this file as the default ignore file for blame 8 | # by running: 9 | # $ git config blame.ignoreRevsFile .git-blame-ignore-revs 10 | # 11 | # When adding commits, write a comment describing its contents 12 | # followed by the 40-character commit ID on a new line. 13 | 14 | # Initial formatting with Black 15 | dbbff8f74453a878fce0de7c7716efbf11e4586c 16 | # Formatting with Black 17 | 2b0522f4b9e38aff93a2a4d66cd388ec6b4ad448 18 | # Formatting with Black 19 | 9280100fc45e3c2dba48d3f63a07be6477b25e33 20 | -------------------------------------------------------------------------------- /curtsies/termformatconstants.py: -------------------------------------------------------------------------------- 1 | """Constants for terminal formatting""" 2 | 3 | from typing import Mapping 4 | 5 | colors = "black", "red", "green", "yellow", "blue", "magenta", "cyan", "gray" 6 | FG_COLORS: Mapping[str, int] = dict(zip(colors, range(30, 38))) 7 | BG_COLORS: Mapping[str, int] = dict(zip(colors, range(40, 48))) 8 | STYLES: Mapping[str, int] = dict( 9 | zip(("bold", "dark", "italic", "underline", "blink", "invert"), (1, 2, 3, 4, 5, 7)) 10 | ) 11 | FG_NUMBER_TO_COLOR: Mapping[int, str] = dict(zip(FG_COLORS.values(), FG_COLORS.keys())) 12 | BG_NUMBER_TO_COLOR: Mapping[int, str] = dict(zip(BG_COLORS.values(), BG_COLORS.keys())) 13 | NUMBER_TO_STYLE = dict(zip(STYLES.values(), STYLES.keys())) 14 | RESET_ALL = 0 15 | RESET_FG = 39 16 | RESET_BG = 49 17 | 18 | 19 | def seq(num: int) -> str: 20 | return f"[{num}m" 21 | -------------------------------------------------------------------------------- /.github/workflows/publish-twine.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: '3.x' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install setuptools wheel "blessed>=1.5" cwcwidth "backports.cached-property; python_version < '3.9'" 20 | - name: Build sdist 21 | run: | 22 | python setup.py sdist 23 | python setup.py bdist_wheel 24 | - name: Publish to PyPI 25 | uses: pypa/gh-action-pypi-publish@release/v1 26 | with: 27 | user: __token__ 28 | password: ${{ secrets.PYPI_PASSWORD }} 29 | -------------------------------------------------------------------------------- /examples/simple.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from curtsies import FullscreenWindow, Input, FSArray 4 | 5 | def main(): 6 | """Returns user input placed randomly on the screen""" 7 | with FullscreenWindow() as window: 8 | print('Press escape to exit') 9 | with Input() as input_generator: 10 | a = FSArray(window.height, window.width) 11 | for c in input_generator: 12 | if c == '': 13 | break 14 | elif c == '': 15 | a = FSArray(window.height, window.width) 16 | else: 17 | row = random.choice(range(window.height)) 18 | column = random.choice(range(window.width-len(repr(c)))) 19 | a[row, column:column+len(repr(c))] = [repr(c)] 20 | window.render_to_terminal(a) 21 | 22 | if __name__ == '__main__': 23 | main() 24 | -------------------------------------------------------------------------------- /examples/demo_fullscreen_with_input.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import signal 3 | 4 | from curtsies import input, Cbreak, FullscreenWindow, fmtstr 5 | 6 | def fullscreen_winch_with_input(): 7 | """ 8 | Monitors user input as well as screen size and acknowledges changes to both. 9 | """ 10 | print('this should be just off-screen') 11 | w = FullscreenWindow(sys.stdout) 12 | def sigwinch_handler(signum, frame): 13 | print('sigwinch! Changed from {!r} to {!r}'.format((rows, columns), (w.height, w.width))) 14 | signal.signal(signal.SIGWINCH, sigwinch_handler) 15 | with w: 16 | with Cbreak(sys.stdin): 17 | for e in input.Input(): 18 | rows, columns = w.height, w.width 19 | a = [fmtstr(((f'.{rows}x{columns}.{e!r}.') * rows)[:columns]) for row in range(rows)] 20 | w.render_to_terminal(a) 21 | 22 | if e == '': 23 | break 24 | 25 | if __name__ == '__main__': 26 | fullscreen_winch_with_input() 27 | 28 | -------------------------------------------------------------------------------- /tests/test_configfile_keynames.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from functools import partial 3 | 4 | from curtsies.configfile_keynames import keymap 5 | from curtsies.events import CURTSIES_NAMES 6 | 7 | 8 | class TestKeymap(unittest.TestCase): 9 | def config(self, mapping, curtsies): 10 | curtsies_names = keymap[mapping] 11 | self.assertTrue( 12 | curtsies in CURTSIES_NAMES.values(), "%r is not a curtsies name" % curtsies 13 | ) 14 | self.assertTrue( 15 | curtsies in curtsies_names, 16 | "config name %r does not contain %r, just %r" 17 | % (mapping, curtsies, curtsies_names), 18 | ) 19 | 20 | def test_simple(self): 21 | self.config("M-m", "") 22 | self.config("M-m", "") 23 | self.config("C-m", "") 24 | self.config("C-[", "") 25 | self.config("C-\\", "") 26 | self.config("C-]", "") 27 | self.config("C-^", "") 28 | self.config("C-_", "") # ??? for bpython compatibility 29 | self.config("F1", "") 30 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = curtsies 3 | description = Curses-like terminal wrapper, with colored strings! 4 | long_description = file: README.md, 5 | long_description_content_type = text/markdown 6 | url = https://github.com/bpython/curtsies 7 | author = Thomas Ballinger 8 | author_email = thomasballinger@gmail.com 9 | license = MIT 10 | license_files = LICENSE 11 | classifiers = 12 | Development Status :: 3 - Alpha 13 | Environment :: Console 14 | Intended Audience :: Developers 15 | License :: OSI Approved :: MIT License 16 | Operating System :: POSIX 17 | Programming Language :: Python 18 | Programming Language :: Python :: 3 19 | 20 | [options] 21 | python_requires = >=3.7 22 | zip_safe = False 23 | packages = curtsies 24 | install_requires = 25 | blessed>=1.5 26 | cwcwidth 27 | backports.cached-property; python_version < "3.8" 28 | tests_require = 29 | pyte 30 | pytest 31 | 32 | [options.package_data] 33 | curtsies = py.typed 34 | 35 | [mypy] 36 | warn_return_any = True 37 | warn_unused_configs = True 38 | mypy_path=stubs 39 | files=curtsies 40 | disallow_untyped_defs = True 41 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Linters 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | black: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | pip install black codespell 18 | - name: Check with black 19 | run: black --check . 20 | 21 | codespell: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: codespell-project/actions-codespell@master 26 | with: 27 | skip: '*.po' 28 | ignore_words_list: te,ot,Manuel 29 | 30 | mypy: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: actions/setup-python@v5 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install mypy 39 | pip install "blessed>=1.5" cwcwidth "backports.cached-property; python_version < '3.9'" pyte 40 | - name: Check with mypy 41 | run: python -m mypy 42 | -------------------------------------------------------------------------------- /examples/quickstart.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from curtsies import FullscreenWindow, Input, FSArray 4 | from curtsies.fmtfuncs import red, bold, green, on_blue, yellow 5 | 6 | if __name__ == '__main__': 7 | print(yellow('this prints normally, not to the alternate screen')) 8 | with FullscreenWindow() as window: 9 | with Input() as input_generator: 10 | msg = red(on_blue(bold('Press escape to exit'))) 11 | a = FSArray(window.height, window.width) 12 | a[0:1, 0:msg.width] = [msg] 13 | window.render_to_terminal(a) 14 | for c in input_generator: 15 | if c == '': 16 | break 17 | elif c == '': 18 | a = FSArray(window.height, window.width) 19 | else: 20 | s = repr(c) 21 | row = random.choice(range(window.height)) 22 | column = random.choice(range(window.width-len(s))) 23 | color = random.choice([red, green, on_blue, yellow]) 24 | a[row, column:column+len(s)] = [color(s)] 25 | window.render_to_terminal(a) 26 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | # run at 7:00 on the first of every month 8 | - cron: '0 7 1 * *' 9 | 10 | jobs: 11 | build: 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [ubuntu-latest, macos-latest] 17 | python-version: 18 | - "3.8" 19 | - "3.9" 20 | - "3.10" 21 | - "3.11" 22 | - "3.12" 23 | - "pypy-3.8" 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install setuptools wheel "blessed>=1.5" cwcwidth "backports.cached-property; python_version < '3.9'" pyte pytest 34 | - name: Build with Python ${{ matrix.python-version }} 35 | run: | 36 | python setup.py build 37 | - name: Test with pytest 38 | run: | 39 | pytest -s --doctest-modules ./curtsies ./tests 40 | -------------------------------------------------------------------------------- /curtsies/configfile_keynames.py: -------------------------------------------------------------------------------- 1 | """Mapping of config file names of keys to curtsies names 2 | 3 | In the style of bpython config files and keymap""" 4 | 5 | from typing import Tuple 6 | 7 | SPECIALS = { 8 | "C-[": "", 9 | "C-^": "", 10 | "C-_": "", 11 | } 12 | 13 | 14 | # TODO make a precalculated version of this 15 | class KeyMap: 16 | """Maps config file key syntax to Curtsies names""" 17 | 18 | def __getitem__(self, key: str) -> Tuple[str, ...]: 19 | if not key: # Unbound key 20 | return () 21 | elif key in SPECIALS: 22 | return (SPECIALS[key],) 23 | elif key[1:] and key[:2] == "C-": 24 | return ("" % key[2:],) 25 | elif key[1:] and key[:2] == "M-": 26 | return ( 27 | "" % key[2:], 28 | "" % key[2:], 29 | ) 30 | elif key[0] == "F" and key[1:].isdigit(): 31 | return ("" % int(key[1:]),) 32 | else: 33 | raise KeyError( 34 | "Configured keymap (%s)" % key + " does not exist in bpython.keys" 35 | ) 36 | 37 | 38 | keymap = KeyMap() 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Thomas Ballinger 4 | Copyright (c) 2020-2023 Sebastian Ramacher 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /curtsies/fmtfuncs.py: -------------------------------------------------------------------------------- 1 | from functools import partial as _partial 2 | from .formatstring import fmtstr 3 | 4 | black = _partial(fmtstr, style="black") 5 | red = _partial(fmtstr, style="red") 6 | green = _partial(fmtstr, style="green") 7 | yellow = _partial(fmtstr, style="yellow") 8 | blue = _partial(fmtstr, style="blue") 9 | magenta = _partial(fmtstr, style="magenta") 10 | cyan = _partial(fmtstr, style="cyan") 11 | gray = _partial(fmtstr, style="gray") 12 | 13 | on_black = _partial(fmtstr, style="on_black") 14 | on_dark = on_black # deprecated, old name of on_black 15 | on_red = _partial(fmtstr, style="on_red") 16 | on_green = _partial(fmtstr, style="on_green") 17 | on_yellow = _partial(fmtstr, style="on_yellow") 18 | on_blue = _partial(fmtstr, style="on_blue") 19 | on_magenta = _partial(fmtstr, style="on_magenta") 20 | on_cyan = _partial(fmtstr, style="on_cyan") 21 | on_gray = _partial(fmtstr, style="on_gray") 22 | 23 | bold = _partial(fmtstr, style="bold") 24 | dark = _partial(fmtstr, style="dark") 25 | italic = _partial(fmtstr, style="italic") 26 | underline = _partial(fmtstr, style="underline") 27 | blink = _partial(fmtstr, style="blink") 28 | invert = _partial(fmtstr, style="invert") 29 | 30 | plain = _partial(fmtstr) 31 | -------------------------------------------------------------------------------- /examples/fps.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from curtsies import FullscreenWindow, Input, FSArray 4 | from curtsies.fmtfuncs import red, bold, green, on_blue, yellow, on_red 5 | import curtsies.events 6 | 7 | class Frame(curtsies.events.ScheduledEvent): 8 | pass 9 | 10 | class World: 11 | def __init__(self): 12 | self.s = 'Hello' 13 | def tick(self): 14 | self.s += '|' 15 | self.s = self.s[max(1, len(self.s)-80):] 16 | def process_event(self, e): 17 | self.s += str(e) 18 | 19 | def realtime(fps=15): 20 | world = World() 21 | dt = 1/fps 22 | 23 | reactor = Input() 24 | schedule_next_frame = reactor.scheduled_event_trigger(Frame) 25 | schedule_next_frame(when=time.time()) 26 | 27 | with reactor: 28 | for e in reactor: 29 | if isinstance(e, Frame): 30 | world.tick() 31 | print(world.s) 32 | when = e.when + dt 33 | while when < time.time(): 34 | when += dt 35 | schedule_next_frame(when) 36 | elif e == '': 37 | break 38 | else: 39 | world.process_event(e) 40 | 41 | if __name__ == "__main__": 42 | realtime() 43 | -------------------------------------------------------------------------------- /docs/about.rst: -------------------------------------------------------------------------------- 1 | About 2 | ^^^^^ 3 | 4 | Resources 5 | ========= 6 | 7 | I've written a little bit about Curtsies on `my blog `_. 8 | 9 | The source and issue tracker for Curtsies are on `Github `_. 10 | 11 | A good place to ask questions about Curtsies is `#bpython on irc.freenode.net `_. 12 | 13 | Authors 14 | ======= 15 | 16 | Curtsies was written by `Thomas Ballinger `_ to create 17 | a frontend for `bpython `_ that preserved terminal history. 18 | 19 | Thanks so much to the many people that have contributed to it! 20 | 21 | * Amber Wilcox-O'Hearn - paired on a refactoring 22 | * Darius Bacon - lots of great code review 23 | * Fei Dong - work on making FmtStr and Chunk immutable 24 | * Julia Evans - help with Python 3 compatibility 25 | * Lea Albaugh - beautiful Curtsies logo 26 | * Rachel King - several bugfixes on blessed use 27 | * Scott Feeney - inspiration for this project - the original title of the project was "scott was right" 28 | * Zach Allaun, Mary Rose Cook, Alex Clemmer - early code review of input and window 29 | * Chase Lambert - API redesign conversation 30 | -------------------------------------------------------------------------------- /examples/demo_scrolling.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import signal 3 | 4 | from curtsies import CursorAwareWindow, input, fmtstr 5 | 6 | rows, columns = '??' 7 | def cursor_winch(): 8 | """ 9 | Reports (signals) change in window dimensions; reports change in position 10 | of cursor 11 | """ 12 | global rows, columns # instead of closure for Python 2 compatibility 13 | print('this should be just off-screen') 14 | w = CursorAwareWindow(sys.stdout, sys.stdin, keep_last_line=False, hide_cursor=False) 15 | def sigwinch_handler(signum, frame): 16 | global rows, columns 17 | dy = w.get_cursor_vertical_diff() 18 | old_rows, old_columns = rows, columns 19 | rows, columns = w.height, w.width 20 | print('sigwinch! Changed from {!r} to {!r}'.format((old_rows, old_columns), (rows, columns))) 21 | print('cursor moved %d lines down' % dy) 22 | w.write(w.t.move_up) 23 | w.write(w.t.move_up) 24 | signal.signal(signal.SIGWINCH, sigwinch_handler) 25 | with w: 26 | for e in input.Input(): 27 | rows, columns = w.height, w.width 28 | a = [fmtstr(((f'.{rows}x{columns}.') * rows)[:columns]) for row in range(rows)] 29 | w.render_to_terminal(a) 30 | if e == '': 31 | break 32 | if __name__ == '__main__': 33 | cursor_winch() 34 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Curtsies documentation 2 | ^^^^^^^^^^^^^^^^^^^^^^ 3 | .. |shoes| image:: http://ballingt.com/assets/curtsies-tritone-small.png 4 | .. |curtsiestitle| image:: http://ballingt.com/assets/curtsiestitle.png 5 | 6 | |curtsiestitle| 7 | 8 | Curtsies is a Python 2.6+ & 3.3+ compatible library for interacting with the terminal. 9 | 10 | :py:class:`~curtsies.FmtStr` objects are strings formatted with 11 | colors and styles displayable in a terminal with `ANSI escape sequences `_. 12 | :py:class:`~curtsies.FSArray` objects contain multiple such strings 13 | with each formatted string on its own row, and 14 | can be superimposed onto each other 15 | to build complex grids of colored and styled characters. 16 | 17 | Such grids of characters can be efficiently rendered to the terminal in alternate screen mode 18 | (no scrollback history, like ``Vim``, ``top`` etc.) by :py:class:`~curtsies.FullscreenWindow` objects 19 | or to the normal history-preserving screen by :py:class:`~curtsies.CursorAwareWindow` objects. 20 | User keyboard input events like pressing the up arrow key are detected by an 21 | :py:class:`~curtsies.Input` object. See the :doc:`quickstart` to get started using 22 | all of these classes. 23 | 24 | .. toctree:: 25 | :maxdepth: 3 26 | 27 | quickstart 28 | FmtStr 29 | FSArray 30 | window 31 | Input 32 | gameloop 33 | examples 34 | about 35 | 36 | -------------------------------------------------------------------------------- /examples/testcache.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | 4 | from curtsies.fmtfuncs import blue, red, bold, on_red 5 | 6 | from curtsies.window import FullscreenWindow 7 | 8 | import time 9 | 10 | if __name__ == '__main__': 11 | 12 | print(blue('hey') + ' ' + red('there') + ' ' + red(bold('you'))) 13 | n = int(sys.argv[1]) if len(sys.argv) > 1 else 100 14 | 15 | with FullscreenWindow() as window: 16 | rows, columns = window.get_term_hw() 17 | t0 = time.time() 18 | for i in range(n): 19 | a = [blue(on_red('qwertyuiop'[i%10]*columns)) for _ in range(rows)] 20 | window.render_to_terminal(a) 21 | t1 = time.time() 22 | t2 = time.time() 23 | for i in range(n): 24 | a = [blue(on_red('q'[i%1]*columns)) for _ in range(rows)] 25 | window.render_to_terminal(a) 26 | t3 = time.time() 27 | t4 = time.time() 28 | a = [blue(on_red('q'*columns)) for _ in range(rows)] 29 | arrays = [] 30 | for i in range(n): 31 | a[i // columns] = a[i // columns].setitem(i % columns, 'x') 32 | arrays.append([fs.copy() for fs in a]) 33 | for i in range(n): 34 | window.render_to_terminal(arrays[i]) 35 | t5 = time.time() 36 | 37 | s = """ all different: %f\tall identical: %f\tchange on character %f\t%d iterations\t""" % (t1 - t0, t3 - t2, t5 - t4, n) 38 | os.system('echo `git log --pretty=oneline -n 1` '+s+' >> times.txt') 39 | print(s) 40 | -------------------------------------------------------------------------------- /tests/test_window.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import sys 3 | 4 | from curtsies.window import BaseWindow, FullscreenWindow, CursorAwareWindow 5 | from io import StringIO 6 | from unittest import skipIf 7 | 8 | 9 | fds_closed = not sys.stdin.isatty() or not sys.stdout.isatty() 10 | 11 | 12 | class FakeFullscreenWindow(FullscreenWindow): 13 | width = property(lambda self: 10) 14 | height = property(lambda self: 4) 15 | 16 | 17 | @skipIf(fds_closed, "blessed Terminal needs streams open") 18 | class TestBaseWindow(unittest.TestCase): 19 | """Pretty pathetic tests for window""" 20 | 21 | def test_window(self): 22 | fakestdout = StringIO() 23 | window = BaseWindow(fakestdout) 24 | window.write("hi") 25 | fakestdout.seek(0) 26 | self.assertEqual(fakestdout.read(), "hi") 27 | 28 | def test_array_from_text(self): 29 | window = BaseWindow() 30 | a = window.array_from_text(".\n.\n.") 31 | self.assertEqual(a.height, 3) 32 | self.assertEqual(a[0], ".") 33 | self.assertEqual(a[1], ".") 34 | 35 | def test_array_from_text_rc(self): 36 | a = BaseWindow.array_from_text_rc("asdfe\nzx\n\n123", 3, 4) 37 | self.assertEqual(a.height, 3) 38 | self.assertEqual(a.width, 4) 39 | self.assertEqual(a[0], "asdf") 40 | self.assertEqual(a[1], "e") 41 | self.assertEqual(a[2], "zx") 42 | 43 | def test_fullscreen_window(self): 44 | fakestdout = StringIO() 45 | window = FullscreenWindow(fakestdout) 46 | window.write("hi") 47 | fakestdout.seek(0) 48 | self.assertEqual(fakestdout.read(), "hi") 49 | 50 | def test_fullscreen_render_to_terminal(self): 51 | fakestdout = StringIO() 52 | window = FakeFullscreenWindow(fakestdout) 53 | window.render_to_terminal(["hello", "hello", "hello"]) 54 | fakestdout.seek(0) 55 | output = fakestdout.read() 56 | self.assertEqual(output.count("hello"), 3) 57 | -------------------------------------------------------------------------------- /notes/window_resize_notes.txt: -------------------------------------------------------------------------------- 1 | 2 | New approach: observe how the where the cursor went. 3 | calculate vertical the cursor and window diffs 4 | 5 | One line taller: 6 | if cursor moves down a line: 7 | move everything down a line 8 | scroll_offset -= 1 9 | top_usable_line += 1 10 | if cursor stays: 11 | everything stays 12 | 13 | One line shorter: 14 | if cursor moves up a line: 15 | move everything up a line 16 | scroll_offset += 1 17 | top_usable_line -= 1 if top_usable_line > 0 18 | if cursor stays: 19 | everything stays 20 | 21 | 22 | 23 | 24 | To make terminal one line taller: 25 | if there is another line of history to show, show that and move everything down a li. 26 | Top usable line goes visually down by one (+=1). Scroll offset -= 1 27 | CURSOR MOVES DOWN 28 | 29 | if there are no more lines of history 30 | if there is junk space at the bottom, absorb it. 31 | Top usable line, scroll offset remain the same 32 | 33 | if cursor is on bottom line, move everything up a line 34 | Top usable line goes up one. Scroll offset goes up one 35 | 36 | To make terminal one line shorter: 37 | if there is at least one line of history offscreen, 38 | move everything up one line. Top usable line moves up (-= 1) (while > 0) 39 | Scroll offset += 1 40 | 41 | if there is no history offscreen 42 | if there is empty space at the bottom, 43 | remove empty space at bottom. Top usable line, scroll offset are the same 44 | 45 | if there is no empty space (cursor is at bottom), move up one line. 46 | Top usable line moves up by one, -= 1 47 | scroll offset += 1, 48 | 49 | if cursor is on top line? (seems to depend on terminal emulator) 50 | 51 | Note: We should probably kill the completion box during resizes, because real terminal 52 | is based on where the cursor is 53 | Note: How does terminal wrapping work? It doesn't change the height 54 | when a line wraps around, so how does addressing even work? 55 | How to make a terminal one 56 | Note: Terminal.app adds a line and puts cursor at bottom when scroll up. 57 | 58 | 59 | Current bug: when window change brings pre-bpython history into visibility, problems 60 | -------------------------------------------------------------------------------- /curtsies/termhelpers.py: -------------------------------------------------------------------------------- 1 | import tty 2 | import termios 3 | import fcntl 4 | import os 5 | 6 | from typing import IO, ContextManager, Type, List, Union, Optional 7 | from types import TracebackType 8 | 9 | _Attr = List[Union[int, List[Union[bytes, int]]]] 10 | 11 | 12 | class Nonblocking(ContextManager): 13 | """ 14 | A context manager for making an input stream nonblocking. 15 | """ 16 | 17 | def __init__(self, stream: IO) -> None: 18 | self.stream = stream 19 | self.fd = self.stream.fileno() 20 | 21 | def __enter__(self) -> None: 22 | self.orig_fl = fcntl.fcntl(self.fd, fcntl.F_GETFL) 23 | fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl | os.O_NONBLOCK) 24 | 25 | def __exit__( 26 | self, 27 | type: Optional[Type[BaseException]] = None, 28 | value: Optional[BaseException] = None, 29 | traceback: Optional[TracebackType] = None, 30 | ) -> None: 31 | fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl) 32 | 33 | 34 | class Termmode(ContextManager): 35 | def __init__(self, stream: IO, attrs: _Attr) -> None: 36 | self.stream = stream 37 | self.attrs = attrs 38 | 39 | def __enter__(self) -> None: 40 | self.original_stty = termios.tcgetattr(self.stream) 41 | termios.tcsetattr(self.stream, termios.TCSANOW, self.attrs) 42 | 43 | def __exit__( 44 | self, 45 | type: Optional[Type[BaseException]] = None, 46 | value: Optional[BaseException] = None, 47 | traceback: Optional[TracebackType] = None, 48 | ) -> None: 49 | termios.tcsetattr(self.stream, termios.TCSANOW, self.original_stty) 50 | 51 | 52 | class Cbreak(ContextManager[Termmode]): 53 | def __init__(self, stream: IO) -> None: 54 | self.stream = stream 55 | 56 | def __enter__(self) -> Termmode: 57 | self.original_stty = termios.tcgetattr(self.stream) 58 | tty.setcbreak(self.stream, termios.TCSANOW) 59 | return Termmode(self.stream, self.original_stty) 60 | 61 | def __exit__( 62 | self, 63 | type: Optional[Type[BaseException]] = None, 64 | value: Optional[BaseException] = None, 65 | traceback: Optional[TracebackType] = None, 66 | ) -> None: 67 | termios.tcsetattr(self.stream, termios.TCSANOW, self.original_stty) 68 | -------------------------------------------------------------------------------- /examples/demo_window.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import signal 3 | import logging 4 | 5 | from curtsies import input, fmtstr, events, FullscreenWindow, CursorAwareWindow 6 | from curtsies import events 7 | 8 | def array_size_test(window): 9 | """Tests arrays one row too small or too large""" 10 | with window as w: 11 | print('a displays a screen worth of input, s one line less, and d one line more') 12 | with input.Input(sys.stdin) as input_generator: 13 | while True: 14 | c = input_generator.next() 15 | rows, columns = w.height, w.width 16 | if c == "": 17 | sys.exit() # same as raise SystemExit() 18 | elif c == "h": 19 | a = w.array_from_text("a for small array") 20 | elif c == "a": 21 | a = [fmtstr(c*columns) for _ in range(rows)] 22 | elif c == "s": 23 | a = [fmtstr(c*columns) for _ in range(rows-1)] 24 | elif c == "d": 25 | a = [fmtstr(c*columns) for _ in range(rows+1)] 26 | elif c == "f": 27 | a = [fmtstr(c*columns) for _ in range(rows-2)] 28 | elif c == "q": 29 | a = [fmtstr(c*columns) for _ in range(1)] 30 | elif c == "w": 31 | a = [fmtstr(c*columns) for _ in range(1)] 32 | elif c == "e": 33 | a = [fmtstr(c*columns) for _ in range(1)] 34 | elif c == "c": 35 | w.write(w.t.move(w.t.height-1, 0)) 36 | w.scroll_down() 37 | elif isinstance(c, events.WindowChangeEvent): 38 | a = w.array_from_text("window just changed to %d rows and %d columns" % (c.rows, c.columns)) 39 | elif c == '': # allows exit without keyboard interrupt 40 | break 41 | elif c == '\x0c': # ctrl-L 42 | [w.write('\n') for _ in range(rows)] 43 | continue 44 | else: 45 | a = w.array_from_text("unknown command") 46 | w.render_to_terminal(a) 47 | 48 | if __name__ == '__main__': 49 | logging.basicConfig(filename='display.log',level=logging.DEBUG) 50 | array_size_test(FullscreenWindow(sys.stdout)) 51 | 52 | -------------------------------------------------------------------------------- /examples/realtime.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import random 3 | import time 4 | 5 | from curtsies import FullscreenWindow, Input, FSArray 6 | from curtsies.fmtfuncs import red, bold, green, on_blue, yellow, on_red 7 | 8 | MAX_FPS = 1000 9 | time_per_frame = 1. / MAX_FPS 10 | 11 | class FrameCounter: 12 | def __init__(self): 13 | self.render_times = [] 14 | self.dt = .5 15 | def frame(self): 16 | self.render_times.append(time.time()) 17 | def fps(self): 18 | now = time.time() 19 | while self.render_times and self.render_times[0] < now - self.dt: 20 | self.render_times.pop(0) 21 | return len(self.render_times) / max(self.dt, now - self.render_times[0] if self.render_times else self.dt) 22 | 23 | def main(): 24 | counter = FrameCounter() 25 | with FullscreenWindow() as window: 26 | print('Press escape to exit') 27 | with Input() as input_generator: 28 | a = FSArray(window.height, window.width) 29 | c = None 30 | for framenum in itertools.count(0): 31 | t0 = time.time() 32 | while True: 33 | t = time.time() 34 | 35 | temp_c = input_generator.send(max(0, t - (t0 + time_per_frame))) 36 | if temp_c is not None: 37 | c = temp_c 38 | 39 | if c is None: 40 | pass 41 | elif c == '': 42 | return 43 | elif c == '': 44 | a = FSArray(window.height, window.width) 45 | else: 46 | row = random.choice(range(window.height)) 47 | column = random.choice(range(window.width-len(c))) 48 | a[row:row+1, column:column+len(c)] = [c] 49 | 50 | c = None 51 | if time_per_frame < t - t0: 52 | break 53 | 54 | row = random.choice(range(window.height)) 55 | column = random.choice(range(window.width)) 56 | a[row:row+1, column:column+1] = [random.choice(".,-'`~")] 57 | 58 | fps = 'FPS: %.1f' % counter.fps() 59 | a[0:1, 0:len(fps)] = [fps] 60 | 61 | window.render_to_terminal(a) 62 | counter.frame() 63 | 64 | if __name__ == '__main__': 65 | main() 66 | -------------------------------------------------------------------------------- /setup.md: -------------------------------------------------------------------------------- 1 | # Development Setup 2 | This is for contributing to Curtsies. If you are just using the Curtsies library, not contributing changes to it, then just install curtsies with `pip install curtsies` and you're off to the races! 3 | 4 | ## Set up for development 5 | To set up a local repository and install the project in [editable mode](https://pip.pypa.io/en/stable/reference/pip_install/#install-editable) so that other Python programs on your computer will import this local version you're editing, run 6 | 7 | $ git clone https://github.com/bpython/curtsies.git 8 | $ cd curtsies 9 | $ pip install -e . 10 | 11 | ## Running Tests 12 | Tests are written using the unittest framework and are run using using [nosetests](https://nose.readthedocs.io/en/latest/). To run all tests, do: 13 | 14 | $ pip install pyte coverage mock nose 15 | $ nosetests . 16 | 17 | ## Style / Formatting 18 | Since curtsies is most commonly used with [bpython](https://github.com/bpython), we adhere to bpython's style, which uses the [black library](https://pypi.org/project/black) to auto-format. 19 | 20 | To auto-format a modified file to curtsies' formatting specifications (which are specified in `pyproject.toml`), run 21 | 22 | $ pip install black 23 | $ black {source_file_or_directory} 24 | 25 | If you are working on VS code, follow these steps to auto format from inside VS code: 26 | 1. Make sure the python extension is installed 27 | 2. Then got to File → Preferences → Settings 28 | 3. Search for “python.formatting.provider” 29 | 4. Change it to 'black' 30 | 5. Optional - Format onSave 31 | - Still in settings search for “editor.formatOnSave” and check the box 32 | - This will auto format your code whenever you save 33 | 6. If you choose not to auto-format on save 34 | - Use Command+Shift+P (on Mac) or Ctrl+Shift+P (Windows and Linux) to open the command palette. 35 | - Type in Format Document and select it to run the auto-formatter 36 | 37 | ## Migrating format changes without ruining git blame 38 | So as to not pollute `git blame` history, for large reformatting or renaming commits, place the 40-character commit ID into the `.git-blame-ignore-revs` file underneath a comment describing the its contents. 39 | 40 | Then, to see a clean and meaningful blame history of a file: 41 | 42 | $ git blame --ignore-revs-file .git-blame-ignore-revs 43 | 44 | You can also configure git (locally) to automatically ignore revision changes with every call to `git blame`: 45 | 46 | $ git config blame.ignoreRevsFile .git-blame-ignore-revs 47 | 48 | 49 | -------------------------------------------------------------------------------- /examples/chat.py: -------------------------------------------------------------------------------- 1 | """A more realtime netcat""" 2 | import sys 3 | import select 4 | import socket 5 | 6 | from curtsies import FullscreenWindow, Input, FSArray 7 | from curtsies.formatstring import linesplit 8 | from curtsies.fmtfuncs import blue, red, green 9 | 10 | class Connection: 11 | def __init__(self, sock): 12 | self.sock = sock 13 | self.received = [] 14 | def fileno(self): 15 | return self.sock.fileno() 16 | def on_read(self): 17 | self.received.append(self.sock.recv(50)) 18 | def render(self): 19 | return linesplit(green(''.join(s.decode('latin-1') for s in self.received)), 80) if self.received else [''] 20 | 21 | def main(host, port): 22 | client = socket.socket() 23 | client.connect((host, port)) 24 | client.setblocking(False) 25 | 26 | conn = Connection(client) 27 | keypresses = [] 28 | 29 | with FullscreenWindow() as window: 30 | with Input() as input_generator: 31 | while True: 32 | a = FSArray(10, 80) 33 | in_text = ''.join(keypresses)[:80] 34 | a[9:10, 0:len(in_text)] = [red(in_text)] 35 | for i, line in zip(reversed(range(2,7)), reversed(conn.render())): 36 | a[i:i+1, 0:len(line)] = [line] 37 | text = 'connected to %s:%d' % (host if len(host) < 50 else host[:50]+'...', port) 38 | a[0:1, 0:len(text)] = [blue(text)] 39 | 40 | window.render_to_terminal(a) 41 | ready_to_read, _, _ = select.select([conn, input_generator], [], []) 42 | for r in ready_to_read: 43 | if r is conn: 44 | r.on_read() 45 | else: 46 | e = input_generator.send(0) 47 | if e == '': 48 | return 49 | elif e == '': 50 | keypresses.append('\n') 51 | client.send((''.join(keypresses)).encode('latin-1')) 52 | keypresses = [] 53 | elif e == '': 54 | keypresses.append(' ') 55 | elif e in ('', ''): 56 | keypresses = keypresses[:-1] 57 | elif e is not None: 58 | keypresses.append(e) 59 | 60 | if __name__ == '__main__': 61 | try: 62 | host, port = sys.argv[1:3] 63 | except ValueError: 64 | print('usage: python chat.py google.com 80') 65 | print('(if you use this example, try typing') 66 | print('GET /') 67 | print('and then hitting enter twice.)') 68 | else: 69 | main(host, int(port)) 70 | -------------------------------------------------------------------------------- /docs/FSArray.rst: -------------------------------------------------------------------------------- 1 | FSArray 2 | ^^^^^^^ 3 | 4 | :py:class:`~curtsies.FSArray` is a two dimensional grid of colored and styled characters. 5 | 6 | FSArray - Example 7 | ================= 8 | 9 | .. python_terminal_session:: 10 | 11 | from curtsies import FSArray, fsarray 12 | from curtsies.fmtfuncs import green, blue, on_green 13 | a = fsarray([u'*' * 10 for _ in range(4)], bg='blue', fg='red') 14 | a.dumb_display() 15 | a[1:3, 3:7] = fsarray([green(u'msg:'), 16 | blue(on_green(u'hey!'))]) 17 | a.dumb_display() 18 | 19 | :py:class:`~curtsies.fsarray` is a convenience function returning a :py:class:`~curtsies.FSArray` constructed from its arguments. 20 | 21 | FSArray - Using 22 | =============== 23 | 24 | :py:class:`~curtsies.FSArray` objects can be composed to build up complex text interfaces:: 25 | 26 | >>> import time 27 | >>> from curtsies import FSArray, fsarray, fmtstr 28 | >>> def clock(): 29 | ... return fsarray([u'::'+fmtstr(u'time')+u'::', 30 | ... fmtstr(time.strftime('%H:%M:%S').decode('ascii'))]) 31 | ... 32 | >>> def square(width, height, char): 33 | ... return fsarray(char*width for _ in range(height)) 34 | ... 35 | >>> a = square(40, 10, u'+') 36 | >>> a[2:8, 2:38] = square(36, 6, u'.') 37 | >>> c = clock() 38 | >>> a[2:4, 30:38] = c 39 | >>> a[6:8, 30:38] = c 40 | >>> message = fmtstr(u'compositing several FSArrays').center(40, u'-') 41 | >>> a[4:5, :] = [message] 42 | >>> 43 | >>> a.dumb_display() 44 | ++++++++++++++++++++++++++++++++++++++++ 45 | ++++++++++++++++++++++++++++++++++++++++ 46 | ++............................::time::++ 47 | ++............................21:59:31++ 48 | ------compositing several FSArrays------ 49 | ++....................................++ 50 | ++............................::time::++ 51 | ++............................21:59:31++ 52 | ++++++++++++++++++++++++++++++++++++++++ 53 | ++++++++++++++++++++++++++++++++++++++++ 54 | 55 | An array like the above might be repeatedly constructed and rendered with a :py:mod:`curtsies.window` object. 56 | 57 | Slicing works like it does with a :py:class:`~curtsies.FmtStr`, but in two dimensions. 58 | :py:class:`~curtsies.FSArray` are *mutable*, so array assignment syntax can be used for natural 59 | compositing as in the above example. 60 | 61 | If you're dealing with terminal output, the *width* of a string becomes more 62 | important than it's *length* (see :ref:`len-vs-width`). 63 | 64 | In the future :py:class:`~curtsies.FSArray` will do slicing and array assignment based on width instead of number of characters, but this is not currently implemented. 65 | 66 | FSArray - API docs 67 | ================== 68 | 69 | .. autofunction:: curtsies.fsarray 70 | 71 | .. autoclass:: curtsies.FSArray 72 | :members: 73 | -------------------------------------------------------------------------------- /docs/window.rst: -------------------------------------------------------------------------------- 1 | Window Objects 2 | ^^^^^^^^^^^^^^ 3 | 4 | .. automodule:: curtsies.window 5 | 6 | Windows successively render 2D grids of text (usually instances of :py:class:`~curtsies.FSArray`) 7 | to the terminal. 8 | 9 | A window owns its output stream - it is assumed (but not enforced) that no additional data is written to this stream between renders, 10 | an assumption which allows for example portions of the screen which do not change between renderings not to be redrawn during a rendering. 11 | 12 | There are two useful window classes, both subclasses of :py:class:`~curtsies.window.BaseWindow`. :py:class:`~curtsies.FullscreenWindow` 13 | renders to the terminal's `alternate screen buffer `_ 14 | (no history preserved, like command line tools ``Vim`` and ``top``) 15 | while :py:class:`~curtsies.CursorAwareWindow` renders to the normal screen. 16 | It is also is capable of querying the terminal for the cursor location, 17 | and uses this functionality to detect how a terminal moves 18 | its contents around during a window size change. 19 | This information can be used to compensate for 20 | this movement and prevent the overwriting of history on the terminal screen. 21 | 22 | Window Objects - Example 23 | ======================== 24 | 25 | >>> from curtsies import FullscreenWindow, fsarray 26 | >>> import time 27 | >>> with FullscreenWindow() as win: 28 | ... win.render_to_terminal(fsarray([u'asdf', u'asdf'])) 29 | ... time.sleep(1) 30 | ... win.render_to_terminal(fsarray([u'asdf', u'qwer'])) 31 | ... time.sleep(1) 32 | 33 | Window Objects - Context 34 | ======================== 35 | 36 | :py:meth:`~curtsies.window.BaseWindow.render_to_terminal` should only be called within the context 37 | of a window. Within the context of an instance of :py:class:`~curtsies.window.BaseWindow` 38 | it's important not to write to the stream the window is using (usually ``sys.stdout``). 39 | Terminal window contents and even cursor position are assumed not to change between renders. 40 | Any change that does occur in cursor position is attributed to movement of content 41 | in response to a window size change and is used to calculate how this content has moved, 42 | which is necessary because this behavior differs between terminal emulators. 43 | 44 | Entering the context of a :py:class:`~curtsies.FullscreenWindow` object hides the cursor and switches to 45 | the alternate terminal screen. Entering the context of a :py:class:`~curtsies.CursorAwareWindow` hides 46 | the cursor, turns on cbreak mode, and records the cursor position. Leaving the context 47 | does more or less the inverse. 48 | 49 | Window Objects - API Docs 50 | ========================= 51 | 52 | .. autoclass:: curtsies.window.BaseWindow 53 | :members: 54 | 55 | .. autoclass:: curtsies.FullscreenWindow 56 | :members: 57 | 58 | .. autoclass:: curtsies.CursorAwareWindow 59 | :members: 60 | 61 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.4.2] - 2023-07-31 4 | - Small type annotation clean ups. 5 | - Publish wheels. 6 | 7 | ## [0.4.1] - 2022-10-05 8 | - Unbreak process suspension with blessed 9 | - Remove xforms. 10 | 11 | ## [0.4.0] - 2022-08-28 12 | - Clean up both `wakeup_fds` 13 | - Drop support for Python 3.6 14 | - Switch to blessed 15 | - Typing: add more annotations 16 | 17 | ## [0.3.10] - 2021-10-08 18 | - Typing: more specify return types for event triggers 19 | - Typing: don't allow Event instances in PasteEvent contents 20 | 21 | ## [0.3.9] - 2021-10-07 22 | - Change typing of `event_trigger(event_type)` to allow a function that returns None 23 | 24 | ## [0.3.7] - 2021-09-27 25 | - Fixed ctrl-c not being reported until another key was pressed in Python 3.5+ 26 | 27 | ## [0.3.5] - 2021-01-24 28 | - Drop supported for Python 2, 3.4 and 3.5. 29 | - Migrate to pytest. Thanks to Paolo Stivanin 30 | - Add new examples. Thanks to rybarczykj 31 | - Improve error messages. Thanks to Etienne Richart 32 | - Replace wcwidth with cwcwidth 33 | 34 | ## [0.3.4] - 2020-07-15 35 | - Prevent crash when embedding in situations including the lldb debugger. Thanks Nathan Lanza! 36 | 37 | ## [0.3.3] - 2020-07-06 38 | - Revert backslash removal, since this broke bpython in 0.3.2 39 | 40 | ## [0.3.2] - 2020-07-04 41 | - Migrate doc generation to Python 3 42 | - Add MyPy typing 43 | - Remove logging level message. Thanks Jack Rybarczyk! 44 | - Assorted fixes: Thanks Armira Nance, Etienne Richart, Evan Allgood, Nathan Lanza, and Vilhelm Prytz! 45 | 46 | ## [0.3.1] - 2020-01-03 47 | - Add "dark" format function 48 | - Add Input option to disable terminal start/stop. Thanks George Kettleborough! 49 | - Fix Py3.6 compatibility. Thanks Po-Chuan Hsieh! 50 | - Assorted fixes, thanks Jakub Wilk and Manuel Mendez! 51 | 52 | ## [0.3.0] - 2018-02-13 53 | - Change name of "dark" color to "black" 54 | - Drop support for Python 2.6 and 3.3 55 | - New FmtStr method width_aware_splitlines which cuts up a FmtStr in linear time 56 | 57 | ## [0.2.12] - 2018-02-12 58 | - Fix accidentally quadratic `width_aware_slice` behavior (fixes bpython #729) 59 | This bug causes bpython to hang on large output. Thanks Ben Wiederhake! 60 | - Allow curtsies to be run on non-main threads (useful for bpython #555) 61 | This should allow bpython to be run in a variety of situations like Django's runserver 62 | - Add function keys for some keyboard/terminal setups 63 | 64 | ## [0.2.11] - 2016-10-22 65 | - Handle unsupported SGR codes (fixes bpython #657) 66 | - Add Ctrl-Delete for some keyboard/terminal setups 67 | - Many doc fixes. Thanks Dan Puttick! 68 | 69 | ## [0.2.10] - 2016-10-10 70 | - Add sequences for home and end (fixes Curtsies #78) 71 | 72 | ## [0.2.9] - 2016-09-07 73 | - Fix #90 again 74 | - Strip ansi escape sequences if parsing fmtstr input fails 75 | - Prevent invalid negative cursor positions in CursorAwareWindow (fixes bpython #607) 76 | - '\x1bOA' changed from ctrl-arrow key to arrow key (fixes bpython #621) 77 | - Alternate codes for F1-F4 (fixes bpython #626) 78 | -------------------------------------------------------------------------------- /examples/snake.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import random 3 | import time 4 | 5 | from curtsies import FullscreenWindow, Input, FSArray 6 | from curtsies.fmtfuncs import red, bold, green, on_blue, yellow, on_red 7 | 8 | key_directions = { 9 | '': (-1, 0), 10 | '': (0,-1), 11 | '': (1, 0), 12 | '': (0, 1), 13 | } 14 | 15 | class Snake: 16 | """Creates a Snake (game) object""" 17 | def __init__(self, height, width): 18 | self.height = height 19 | self.width = width 20 | self.snake_parts = [self.random_spot()] 21 | self.direction = (1, 0) 22 | self.new_apple() 23 | 24 | def random_spot(self): 25 | """Creates a random spot for characters""" 26 | return random.choice(range(self.height)), random.choice(range(self.width)) 27 | 28 | def new_apple(self): 29 | """Places a new apple in the window randomly for the snake to find""" 30 | while True: 31 | self.apple = self.random_spot() 32 | if self.apple not in self.snake_parts: 33 | break 34 | 35 | def advance_snake(self): 36 | """Adds to snake once it obtains an apple""" 37 | self.snake_parts.insert(0, (self.snake_parts[0][0]+self.direction[0], self.snake_parts[0][1]+self.direction[1])) 38 | 39 | def render(self): 40 | 41 | a = FSArray(self.height, self.width) 42 | for row, col in self.snake_parts: 43 | a[row, col] = 'x' 44 | a[self.apple[0], self.apple[1]] = 'o' 45 | return a 46 | 47 | def tick(self, e): 48 | 49 | if (e in key_directions and 50 | abs(key_directions[e][0]) + abs(self.direction[0]) < 2 and 51 | abs(key_directions[e][1]) + abs(self.direction[1]) < 2): 52 | self.direction = key_directions[e] 53 | self.advance_snake() 54 | if self.snake_parts[0] == self.apple: 55 | self.new_apple() 56 | elif ((not (0 <= self.snake_parts[0][0] < self.height and 57 | 0 <= self.snake_parts[0][1] < self.width)) or 58 | self.snake_parts[0] in self.snake_parts[1:]): 59 | return True 60 | else: 61 | self.snake_parts.pop() 62 | 63 | def main(): 64 | """Sets speed for snake and begins game upon receiving input from user""" 65 | MAX_FPS = 4 66 | time_per_frame = lambda: 1. / MAX_FPS 67 | input("Press enter to start") 68 | 69 | with FullscreenWindow() as window: 70 | with Input() as input_generator: 71 | snake = Snake(window.height, window.width) 72 | while True: 73 | c = None 74 | t0 = time.time() 75 | while True: 76 | t = time.time() 77 | temp_c = input_generator.send(max(0, t - (t0 + time_per_frame()))) 78 | if temp_c == '': 79 | return 80 | elif temp_c == '+': 81 | MAX_FPS += 1 82 | elif temp_c == '-': 83 | MAX_FPS = max(1, MAX_FPS - 1) 84 | elif temp_c is not None: 85 | c = temp_c # save this keypress to be used on next tick 86 | if time_per_frame() < t - t0: 87 | break 88 | 89 | if snake.tick(c): 90 | return 91 | window.render_to_terminal(snake.render()) 92 | 93 | if __name__ == '__main__': 94 | main() 95 | -------------------------------------------------------------------------------- /docs/terminal_output.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Sphinx directive for ansi-formatted output 3 | 4 | sphinxcontrib-ansi seems to be the right thing to use, but it's 5 | missing sequences. It does the right thing and remove color when 6 | output format isn't html. This just always outputs raw html. """ 7 | import re 8 | import sys 9 | from textwrap import dedent 10 | 11 | try: 12 | # python2 13 | from StringIO import StringIO as BytesIO 14 | except ImportError: 15 | from io import BytesIO 16 | 17 | from docutils.parsers.rst import Directive 18 | from docutils import nodes 19 | import pexpect 20 | from ansi2html import Ansi2HTMLConverter 21 | 22 | 23 | class python_terminal_block(nodes.literal_block): 24 | pass 25 | 26 | 27 | def htmlize(ansi): 28 | conv = Ansi2HTMLConverter(inline=True, dark_bg=True) 29 | return conv.convert(ansi, full=False) 30 | 31 | 32 | class ANSIHTMLParser(object): 33 | def __call__(self, app, doctree, docname): 34 | handler = self._format_it 35 | if app.builder.name not in ["html", "readthedocs"]: 36 | # strip all color codes in non-html output 37 | handler = self._strip_color_from_block_content 38 | for ansi_block in doctree.traverse(python_terminal_block): 39 | handler(ansi_block) 40 | 41 | def _strip_color_from_block_content(self, block): 42 | content = re.sub("\x1b\\[([^m]+)m", "", block.rawsource) 43 | literal_node = nodes.literal_block(content, content) 44 | block.replace_self(literal_node) 45 | 46 | def _format_it(self, block): 47 | source = block.rawsource 48 | content = htmlize(source) 49 | formatted = "
%s
" % (content,) 50 | raw_node = nodes.raw(formatted, formatted, format="html") 51 | block.replace_self(raw_node) 52 | 53 | 54 | def default_colors_to_resets(s): 55 | """Hack to make sphinxcontrib.ansi recognized sequences""" 56 | return s.replace(b"[39m", b"[0m").replace(b"[49m", b"[0m") 57 | 58 | 59 | def run_lines(lines): 60 | child = pexpect.spawn(sys.executable + " -i") 61 | out = BytesIO() # TODO make this a string? 62 | child.logfile_read = out 63 | # TODO make this detect `...` when it shouldn't be there, forgot a ) 64 | for line in lines: 65 | child.expect([">>> ", "... "]) 66 | child.sendline(line) 67 | child.sendeof() 68 | child.read() 69 | out.seek(0) 70 | output = out.read() 71 | return output[output.index(b">>>") : output.rindex(b">>>")] 72 | 73 | 74 | def get_lines(multiline_string): 75 | lines = dedent(multiline_string).split("\n") 76 | while lines and not lines[0]: 77 | lines.pop(0) 78 | while lines and not lines[-1]: 79 | lines.pop() 80 | return lines 81 | 82 | 83 | class PythonTerminalDirective(Directive): 84 | """Execute the specified python code and insert the output into the document""" 85 | 86 | has_content = True 87 | 88 | def run(self): 89 | text = default_colors_to_resets(run_lines(get_lines("\n".join(self.content)))) 90 | return [python_terminal_block(text.decode("utf8"), text.decode("utf8"))] 91 | 92 | 93 | def setup(app): 94 | app.add_directive("python_terminal_session", PythonTerminalDirective) 95 | app.connect("doctree-resolved", ANSIHTMLParser()) 96 | 97 | 98 | if __name__ == "__main__": 99 | print( 100 | htmlize( 101 | run_lines( 102 | get_lines( 103 | """ 104 | from curtsies.fmtfuncs import blue 105 | blue('hello') 106 | print blue('hello') 107 | """ 108 | ) 109 | ) 110 | ) 111 | ) 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Documentation Status](https://readthedocs.org/projects/curtsies/badge/?version=latest)](https://readthedocs.org/projects/curtsies/?badge=latest) 2 | ![Curtsies Logo](http://ballingt.com/assets/curtsiestitle.png) 3 | 4 | Curtsies is a Python 3.6+ compatible library for interacting with the terminal. 5 | This is what using (nearly every feature of) curtsies looks like: 6 | 7 | ```python 8 | import random 9 | import sys 10 | 11 | from curtsies import FullscreenWindow, Input, FSArray 12 | from curtsies.fmtfuncs import red, bold, green, on_blue, yellow 13 | 14 | print(yellow('this prints normally, not to the alternate screen')) 15 | 16 | with FullscreenWindow() as window: 17 | a = FSArray(window.height, window.width) 18 | msg = red(on_blue(bold('Press escape to exit, space to clear.'))) 19 | a[0:1, 0:msg.width] = [msg] 20 | window.render_to_terminal(a) 21 | with Input() as input_generator: 22 | for c in input_generator: 23 | if c == '': 24 | break 25 | elif c == '': 26 | a = FSArray(window.height, window.width) 27 | else: 28 | s = repr(c) 29 | row = random.choice(range(window.height)) 30 | column = random.choice(range(window.width-len(s))) 31 | color = random.choice([red, green, on_blue, yellow]) 32 | a[row, column:column+len(s)] = [color(s)] 33 | window.render_to_terminal(a) 34 | ``` 35 | 36 | Paste it in a `something.py` file and try it out! 37 | 38 | Installation: `pip install curtsies` 39 | 40 | [Documentation](http://curtsies.readthedocs.org/en/latest/) 41 | 42 | Primer 43 | ------ 44 | 45 | [FmtStr](http://curtsies.readthedocs.org/en/latest/FmtStr.html) objects are strings formatted with 46 | colors and styles displayable in a terminal with [ANSI escape sequences](http://en.wikipedia.org/wiki/ANSI_escape_code>`_). 47 | 48 | ![](https://i.imgur.com/bRLI134.png) 49 | 50 | [FSArray](http://curtsies.readthedocs.org/en/latest/FSArray.html) objects contain multiple such strings 51 | with each formatted string on its own row, and FSArray 52 | objects can be superimposed on each other 53 | to build complex grids of colored and styled characters through composition. 54 | 55 | (the import statement shown below is outdated) 56 | 57 | ![](http://i.imgur.com/rvTRPv1.png) 58 | 59 | Such grids of characters can be rendered to the terminal in alternate screen mode 60 | (no history, like `Vim`, `top` etc.) by [FullscreenWindow](http://curtsies.readthedocs.org/en/latest/window.html#curtsies.window.FullscreenWindow) objects 61 | or normal history-preserving screen by [CursorAwareWindow](http://curtsies.readthedocs.org/en/latest/window.html#curtsies.window.CursorAwareWindow) objects. 62 | User keyboard input events like pressing the up arrow key are detected by an 63 | [Input](http://curtsies.readthedocs.org/en/latest/input.html) object. 64 | 65 | Examples 66 | -------- 67 | 68 | * [Tic-Tac-Toe](/examples/tictactoeexample.py) 69 | 70 | ![](http://i.imgur.com/AucB55B.png) 71 | 72 | * [Avoid the X's game](/examples/gameexample.py) 73 | 74 | ![](http://i.imgur.com/nv1RQd3.png) 75 | 76 | * [Bpython-curtsies uses curtsies](http://ballingt.com/2013/12/21/bpython-curtsies.html) 77 | 78 | [![](http://i.imgur.com/r7rZiBS.png)](http://www.youtube.com/watch?v=lwbpC4IJlyA) 79 | 80 | * [More examples](/examples) 81 | 82 | About 83 | ----- 84 | 85 | * [Curtsies Documentation](http://curtsies.readthedocs.org/en/latest/) 86 | * Curtsies was written to for [bpython-curtsies](http://ballingt.com/2013/12/21/bpython-curtsies.html) 87 | * `#bpython` on irc is a good place to talk about Curtsies, but feel free 88 | to open an issue if you're having a problem! 89 | * Thanks to the many contributors! 90 | * If all you need are colored strings, consider one of these [other 91 | libraries](http://curtsies.readthedocs.io/en/latest/FmtStr.html#fmtstr-rationale)! 92 | -------------------------------------------------------------------------------- /examples/gameexample.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import sys 3 | 4 | from curtsies import FullscreenWindow, Input, FSArray 5 | from curtsies.fmtfuncs import red, bold, green, on_blue, yellow, on_red 6 | 7 | 8 | class Entity: 9 | def __init__(self, display, x, y, speed=1): 10 | self.display = display 11 | self.x, self.y = x, y 12 | self.speed = speed 13 | 14 | def towards(self, entity): 15 | dx = entity.x - self.x 16 | dy = entity.y - self.y 17 | return vscale(self.speed, (sign(dx), sign(dy))) 18 | 19 | def die(self): 20 | self.speed = 0 21 | self.display = on_red(bold(yellow('o'))) 22 | 23 | def sign(n): 24 | return -1 if n < 0 else 0 if n == 0 else 1 25 | 26 | def vscale(c, v): 27 | return tuple(c*x for x in v) 28 | 29 | class World: 30 | def __init__(self, width, height): 31 | self.width = width 32 | self.height = height 33 | n = 5 34 | self.player = Entity(on_blue(green(bold('5'))), width // 2, height // 2 - 2, speed=5) 35 | self.npcs = [Entity(on_blue(red('X')), i * width // (n * 2), j * height // (n * 2)) 36 | for i in range(1, 2*n, 2) 37 | for j in range(1, 2*n, 2)] 38 | self.turn = 0 39 | 40 | entities = property(lambda self: self.npcs + [self.player]) 41 | 42 | def move_entity(self, entity, dx, dy): 43 | entity.x = max(0, min(self.width-1, entity.x + dx)) 44 | entity.y = max(0, min(self.height-1, entity.y + dy)) 45 | 46 | def process_event(self, c): 47 | """Returns a message from tick() to be displayed if game is over""" 48 | if c == "": 49 | sys.exit() 50 | elif c in key_directions: 51 | self.move_entity(self.player, *vscale(self.player.speed, key_directions[c])) 52 | else: 53 | return "try arrow keys, w, a, s, d, or ctrl-D (you pressed %r)" % c 54 | return self.tick() 55 | 56 | def tick(self): 57 | """Returns a message to be displayed if game is over, else None""" 58 | for npc in self.npcs: 59 | self.move_entity(npc, *npc.towards(self.player)) 60 | for entity1, entity2 in itertools.combinations(self.entities, 2): 61 | if (entity1.x, entity1.y) == (entity2.x, entity2.y): 62 | if self.player in (entity1, entity2): 63 | return 'you lost on turn %d' % self.turn 64 | entity1.die() 65 | entity2.die() 66 | 67 | if all(npc.speed == 0 for npc in self.npcs): 68 | return 'you won on turn %d' % self.turn 69 | self.turn += 1 70 | if self.turn % 20 == 0: 71 | self.player.speed = max(1, self.player.speed - 1) 72 | self.player.display = on_blue(green(bold(str(self.player.speed)))) 73 | 74 | def get_array(self): 75 | a = FSArray(self.height, self.width) 76 | for entity in self.entities: 77 | a[self.height - 1 - entity.y, entity.x] = entity.display 78 | return a 79 | 80 | key_directions = {'': (0, 1), 81 | '': (-1, 0), 82 | '': (0,-1), 83 | '': (1, 0), 84 | 'w': (0, 1), 85 | 'a': (-1, 0), 86 | 's': (0,-1), 87 | 'd': (1, 0)} 88 | 89 | def main(): 90 | with FullscreenWindow(sys.stdout) as window: 91 | with Input(sys.stdin) as input_generator: 92 | world = World(width=window.width, height=window.height) 93 | window.render_to_terminal(world.get_array()) 94 | for c in input_generator: 95 | msg = world.process_event(c) 96 | if msg: 97 | break 98 | window.render_to_terminal(world.get_array()) 99 | print(msg) 100 | 101 | if __name__ == '__main__': 102 | main() 103 | -------------------------------------------------------------------------------- /tests/test_input.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import sys 4 | import threading 5 | import time 6 | import unittest 7 | from unittest.mock import Mock 8 | from unittest import skip, skipUnless 9 | 10 | from curtsies import events 11 | from curtsies.input import Input 12 | 13 | 14 | class CustomEvent(events.Event): 15 | pass 16 | 17 | 18 | class CustomScheduledEvent(events.ScheduledEvent): 19 | pass 20 | 21 | 22 | @skipUnless(sys.stdin.isatty(), "stdin must be a tty") 23 | class TestInput(unittest.TestCase): 24 | def test_create(self): 25 | Input() 26 | 27 | def test_iter(self): 28 | inp = Input() 29 | inp.send = Mock() 30 | inp.send.return_value = None 31 | for i, e in zip(range(3), inp): 32 | self.assertEqual(e, None) 33 | self.assertEqual(inp.send.call_count, 3) 34 | 35 | def test_send(self): 36 | inp = Input() 37 | inp.unprocessed_bytes = [b"a"] 38 | self.assertEqual(inp.send("nonsensical value"), "a") 39 | 40 | def test_send_nonblocking_no_event(self): 41 | inp = Input() 42 | inp.unprocessed_bytes = [] 43 | self.assertEqual(inp.send(0), None) 44 | 45 | def test_nonblocking_read(self): 46 | inp = Input() 47 | self.assertEqual(inp._nonblocking_read(), 0) 48 | 49 | def test_send_paste(self): 50 | inp = Input() 51 | inp.unprocessed_bytes = [] 52 | inp._wait_for_read_ready_or_timeout = Mock() 53 | inp._wait_for_read_ready_or_timeout.return_value = (True, None) 54 | inp._nonblocking_read = Mock() 55 | n = inp.paste_threshold + 1 56 | 57 | first_time = [True] 58 | 59 | def side_effect(): 60 | if first_time: 61 | inp.unprocessed_bytes.extend([b"a"] * n) 62 | first_time.pop() 63 | return n 64 | else: 65 | return None 66 | 67 | inp._nonblocking_read.side_effect = side_effect 68 | 69 | r = inp.send(0) 70 | self.assertEqual(type(r), events.PasteEvent) 71 | self.assertEqual(r.events, ["a"] * n) 72 | 73 | def test_event_trigger(self): 74 | inp = Input() 75 | f = inp.event_trigger(CustomEvent) 76 | self.assertEqual(inp.send(0), None) 77 | f() 78 | self.assertEqual(type(inp.send(0)), CustomEvent) 79 | self.assertEqual(inp.send(0), None) 80 | 81 | def test_schedule_event_trigger(self): 82 | inp = Input() 83 | f = inp.scheduled_event_trigger(CustomScheduledEvent) 84 | self.assertEqual(inp.send(0), None) 85 | f(when=time.time()) 86 | self.assertEqual(type(inp.send(0)), CustomScheduledEvent) 87 | self.assertEqual(inp.send(0), None) 88 | f(when=time.time() + 0.01) 89 | self.assertEqual(inp.send(0), None) 90 | time.sleep(0.01) 91 | self.assertEqual(type(inp.send(0)), CustomScheduledEvent) 92 | self.assertEqual(inp.send(0), None) 93 | 94 | def test_schedule_event_trigger_blocking(self): 95 | inp = Input() 96 | f = inp.scheduled_event_trigger(CustomScheduledEvent) 97 | f(when=time.time() + 0.05) 98 | self.assertEqual(type(next(inp)), CustomScheduledEvent) 99 | 100 | def test_threadsafe_event_trigger(self): 101 | inp = Input() 102 | f = inp.threadsafe_event_trigger(CustomEvent) 103 | 104 | def check_event(): 105 | self.assertEqual(type(inp.send(1)), CustomEvent) 106 | self.assertEqual(inp.send(0), None) 107 | 108 | t = threading.Thread(target=check_event) 109 | t.start() 110 | f() 111 | t.join() 112 | 113 | def test_interrupting_sigint(self): 114 | inp = Input(sigint_event=True) 115 | 116 | def send_sigint(): 117 | os.kill(os.getpid(), signal.SIGINT) 118 | 119 | with inp: 120 | t = threading.Thread(target=send_sigint) 121 | t.start() 122 | self.assertEqual(type(inp.send(1)), events.SigIntEvent) 123 | self.assertEqual(inp.send(0), None) 124 | t.join() 125 | 126 | def test_create_in_thread_with_sigint_event(self): 127 | def create(): 128 | inp = Input(sigint_event=True) 129 | 130 | t = threading.Thread(target=create) 131 | t.start() 132 | t.join() 133 | 134 | def test_use_in_thread_with_sigint_event(self): 135 | inp = Input(sigint_event=True) 136 | 137 | def use(): 138 | with inp: 139 | pass 140 | 141 | t = threading.Thread(target=use) 142 | t.start() 143 | t.join() 144 | 145 | def test_cleanup(self): 146 | input_generator = Input() 147 | for i in range(1000): 148 | with input_generator: 149 | pass 150 | -------------------------------------------------------------------------------- /examples/tictactoeexample.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from curtsies.fmtfuncs import * 4 | from curtsies import FullscreenWindow, Input, fsarray 5 | 6 | class Board: 7 | """ 8 | >>> Board().rows 9 | ((' ', ' ', ' '), (' ', ' ', ' '), (' ', ' ', ' ')) 10 | >>> Board().columns 11 | ((' ', ' ', ' '), (' ', ' ', ' '), (' ', ' ', ' ')) 12 | >>> Board().turn 13 | 0 14 | >>> Board().whose_turn 15 | 'x' 16 | >>> b = Board().move(2); print(b) 17 | | |x 18 | ----- 19 | | | 20 | ----- 21 | | | 22 | >>> b.possible() 23 | [< Board |o.x......| >, < Board |.ox......| >, < Board |..xo.....| >, < Board |..x.o....| >, < Board |..x..o...| >, < Board |..x...o..| >, < Board |..x....o.| >, < Board |..x.....o| >] 24 | """ 25 | def __init__(self, width=3, height=3): 26 | self._rows = [[' ' for _ in range(width)] for _ in range(height)] 27 | 28 | rows = property(lambda self: tuple(tuple(row) for row in self._rows)) 29 | columns = property(lambda self: tuple(zip(*self._rows))) 30 | spots = property(lambda self: tuple(c for row in self._rows for c in row)) 31 | def __str__(self): 32 | return ('\n'+'-'*(len(self.columns)*2-1) + '\n').join(['|'.join(row) for row in self._rows]) 33 | def __repr__(self): return '< Board |'+''.join(self.spots).replace(' ','.')+'| >' 34 | @property 35 | def turn(self): 36 | return 9 - self.spots.count(' ') 37 | @property 38 | def whose_turn(self): 39 | return 'xo'[self.turn % 2] 40 | def winner(self): 41 | """Returns either x or o if one of them won, otherwise None""" 42 | for c in 'xo': 43 | for comb in [(0,3,6), (1,4,7), (2,5,8), (0,1,2), (3,4,5), (6,7,8), (0,4,8), (2,4,6)]: 44 | if all(self.spots[p] == c for p in comb): 45 | return c 46 | return None 47 | def move(self, pos): 48 | if not self.spots[pos] == ' ': raise ValueError('That spot it taken') 49 | new = Board(len(self.rows), len(self.columns)) 50 | new._rows = list(list(row) for row in self.rows) 51 | new._rows[pos // 3][pos % 3] = self.whose_turn 52 | return new 53 | def possible(self): 54 | return [self.move(p) for p in range(len(self.spots)) if self.spots[p] == ' '] 55 | def display(self): 56 | colored = {' ':' ', 'x':blue(bold('x')), 'o':red(bold('o'))} 57 | s = ('\n'+green('-')*(len(self.columns)*2-1) + '\n').join([green('|').join(colored[mark] for mark in row) for row in self._rows]) 58 | a = fsarray([bold(green('enter a number, 0-8' if self.whose_turn == 'x' else 'wait for computer...'))] + s.split('\n')) 59 | return a 60 | 61 | def opp(c): 62 | """ 63 | >>> opp('x'), opp('o') 64 | ('o', 'x') 65 | """ 66 | return 'x' if c == 'o' else 'o' 67 | 68 | def value(board, who='x'): 69 | """Returns the value of a board 70 | >>> b = Board(); b._rows = [['x', 'x', 'x'], ['x', 'x', 'x'], ['x', 'x', 'x']] 71 | >>> value(b) 72 | 1 73 | >>> b = Board(); b._rows = [['o', 'o', 'o'], ['o', 'o', 'o'], ['o', 'o', 'o']] 74 | >>> value(b) 75 | -1 76 | >>> b = Board(); b._rows = [['x', 'o', ' '], ['x', 'o', ' '], [' ', ' ', ' ']] 77 | >>> value(b) 78 | 1 79 | >>> b._rows[0][2] = 'x' 80 | >>> value(b) 81 | -1 82 | """ 83 | w = board.winner() 84 | if w == who: 85 | return 1 86 | if w == opp(who): 87 | return -1 88 | if board.turn == 9: 89 | return 0 90 | 91 | if who == board.whose_turn: 92 | return max([value(b, who) for b in board.possible()]) 93 | else: 94 | return min([value(b, who) for b in board.possible()]) 95 | 96 | def ai(board, who='x'): 97 | """ 98 | Returns best next board 99 | 100 | >>> b = Board(); b._rows = [['x', 'o', ' '], ['x', 'o', ' '], [' ', ' ', ' ']] 101 | >>> ai(b) 102 | < Board |xo.xo.x..| > 103 | """ 104 | return sorted(board.possible(), key=lambda b: value(b, who))[-1] 105 | 106 | def main(): 107 | with Input() as input: 108 | with FullscreenWindow() as window: 109 | b = Board() 110 | while True: 111 | window.render_to_terminal(b.display()) 112 | if b.turn == 9 or b.winner(): 113 | c = input.next() # hit any key 114 | sys.exit() 115 | while True: 116 | c = input.next() 117 | if c == '': 118 | sys.exit() 119 | try: 120 | if int(c) in range(9): 121 | b = b.move(int(c)) 122 | except ValueError: 123 | continue 124 | window.render_to_terminal(b.display()) 125 | break 126 | if b.turn == 9 or b.winner(): 127 | c = input.next() # hit any key 128 | sys.exit() 129 | b = ai(b, 'o') 130 | 131 | if __name__ == '__main__': 132 | main() 133 | -------------------------------------------------------------------------------- /curtsies/curtsieskeys.py: -------------------------------------------------------------------------------- 1 | """All the key sequences""" 2 | # If you add a binding, add something about your setup 3 | # if you can figure out why it's different 4 | 5 | # Special names are for multi-character keys, or key names 6 | # that would be hard to write in a config file 7 | 8 | # TODO add PAD keys hack as in bpython.cli 9 | 10 | # fmt: off 11 | CURTSIES_NAMES = { 12 | b' ': '', 13 | b'\x1b ': '', 14 | b'\t': '', 15 | b'\x1b[Z': '', 16 | b'\x1b[A': '', 17 | b'\x1b[B': '', 18 | b'\x1b[C': '', 19 | b'\x1b[D': '', 20 | b'\x1bOA': '', # in issue 92 its shown these should be normal arrows, 21 | b'\x1bOB': '', # not ctrl-arrows as we previously had them. 22 | b'\x1bOC': '', 23 | b'\x1bOD': '', 24 | 25 | b'\x1b[1;5A': '', 26 | b'\x1b[1;5B': '', 27 | b'\x1b[1;5C': '', # reported by myint 28 | b'\x1b[1;5D': '', # reported by myint 29 | 30 | b'\x1b[5A': '', # not sure about these, someone wanted them for bpython 31 | b'\x1b[5B': '', 32 | b'\x1b[5C': '', 33 | b'\x1b[5D': '', 34 | 35 | b'\x1b[1;9A': '', 36 | b'\x1b[1;9B': '', 37 | b'\x1b[1;9C': '', 38 | b'\x1b[1;9D': '', 39 | 40 | b'\x1b[1;10A': '', 41 | b'\x1b[1;10B': '', 42 | b'\x1b[1;10C': '', 43 | b'\x1b[1;10D': '', 44 | 45 | b'\x1bOP': '', 46 | b'\x1bOQ': '', 47 | b'\x1bOR': '', 48 | b'\x1bOS': '', 49 | 50 | # see bpython #626 51 | b'\x1b[11~': '', 52 | b'\x1b[12~': '', 53 | b'\x1b[13~': '', 54 | b'\x1b[14~': '', 55 | 56 | b'\x1b[15~': '', 57 | b'\x1b[17~': '', 58 | b'\x1b[18~': '', 59 | b'\x1b[19~': '', 60 | b'\x1b[20~': '', 61 | b'\x1b[21~': '', 62 | b'\x1b[23~': '', 63 | b'\x1b[24~': '', 64 | b'\x00': '', 65 | b'\x1c': '', 66 | b'\x1d': '', 67 | b'\x1e': '', 68 | b'\x1f': '', 69 | b'\x7f': '', # for some folks this is ctrl-backspace apparently 70 | b'\x1b\x7f': '', 71 | b'\xff': '', 72 | b'\x1b\x1b[A': '', # uncertain about these four 73 | b'\x1b\x1b[B': '', 74 | b'\x1b\x1b[C': '', 75 | b'\x1b\x1b[D': '', 76 | b'\x1b': '', 77 | b'\x1b[1~': '', 78 | b'\x1b[4~': '', 79 | b'\x1b\x1b[5~':'', 80 | b'\x1b\x1b[6~':'', 81 | 82 | b'\x1b[H': '', # reported by amorozov in bpython #490 83 | b'\x1b[F': '', # reported by amorozov in bpython #490 84 | 85 | b'\x1bOH': '', # reported by mixmastamyk in curtsies #78 86 | b'\x1bOF': '', # reported by mixmastamyk in curtsies #78 87 | 88 | # not fixing for back compat. 89 | # (b"\x1b[1~": u'', # find 90 | 91 | b"\x1b[2~": '', # insert (0) 92 | b"\x1b[3~": '', # delete (.), "Execute" 93 | b"\x1b[3;5~": '', 94 | 95 | # st (simple terminal) see issue #169 96 | b"\x1b[4h": '', 97 | b"\x1b[P": '', 98 | 99 | # not fixing for back compat. 100 | # (b"\x1b[4~": u'