diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..6ab0a67 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,19 @@ +# This file contains a list of commits that are not likely what you +# are looking for in a blame, such as mass reformatting or renaming. +# +# Use case: +# $ git blame --ignore-revs-file .git-blame-ignore-revs +# +# You can also set this file as the default ignore file for blame +# by running: +# $ git config blame.ignoreRevsFile .git-blame-ignore-revs +# +# When adding commits, write a comment describing its contents +# followed by the 40-character commit ID on a new line. + +# Initial formatting with Black +dbbff8f74453a878fce0de7c7716efbf11e4586c +# Formatting with Black +2b0522f4b9e38aff93a2a4d66cd388ec6b4ad448 +# Formatting with Black +9280100fc45e3c2dba48d3f63a07be6477b25e33 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8c139c7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..a174770 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + pull_request: + schedule: + # run at 7:00 on the first of every month + - cron: '0 7 1 * *' + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "pypy-3.10" + steps: + - uses: actions/checkout@v6 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build setuptools wheel "blessed>=1.5" cwcwidth pyte pytest + - name: Build with Python ${{ matrix.python-version }} + run: | + python -m build -n . + - name: Test with pytest + run: | + pytest -s --doctest-modules ./curtsies ./tests diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..4d119e8 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,41 @@ +name: Linters + +on: + push: + pull_request: + +jobs: + black: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black + - name: Check with black + run: black --check curtsies + + codespell: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: codespell-project/actions-codespell@master + with: + skip: '*.po' + ignore_words_list: te,ot,Manuel + + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install mypy + pip install "blessed>=1.5" cwcwidth pyte + - name: Check with mypy + run: python -m mypy diff --git a/.github/workflows/publish-twine.yaml b/.github/workflows/publish-twine.yaml new file mode 100644 index 0000000..d0c3d51 --- /dev/null +++ b/.github/workflows/publish-twine.yaml @@ -0,0 +1,27 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build setuptools wheel "blessed>=1.5" cwcwidth + - name: Build sdist + run: | + python -m build -n -s -w + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_PASSWORD }} diff --git a/.gitignore b/.gitignore index 74647b1..f032c36 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ venv* .coverage cover tox.ini +.mypy_cache keylog diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index efb5212..0000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -notifications: - webhooks: - - secure: "SWXDWxa1mNV6Wr6fPP8MxYO69rOiMMilp475Z9a3V9B3S/xds+d0ye8msUD+D1W9lzhwrw9NSMUjuBrm0llAvBV2MVCjYeuZ4ACLuK7fqhh4+2V9OrhYCKQrjNmEicmqwe1H5Z09JGKnMEJGUxrqApkCucoY8xOJu1/jfS7R63I=" - -language: python - -python: - - "2.6" - - "2.7" - - "3.3" - - "3.4" - - "pypy" - - "pypy3" - -matrix: - allow_failures: - - python: "pypy" - - python: "pypy3" - -install: - - "python setup.py install" - - "pip install pyte coverage" - -script: - - nosetests tests diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d5b39f7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,81 @@ +# Changelog + +## [0.4.3] - 2025-06-06 +- Drop support for Python 3.7, 3.8, and 3.9. +- Add support for italic. + +## [0.4.2] - 2023-07-31 +- Small type annotation clean ups. +- Publish wheels. + +## [0.4.1] - 2022-10-05 +- Unbreak process suspension with blessed +- Remove xforms. + +## [0.4.0] - 2022-08-28 +- Clean up both `wakeup_fds` +- Drop support for Python 3.6 +- Switch to blessed +- Typing: add more annotations + +## [0.3.10] - 2021-10-08 +- Typing: more specify return types for event triggers +- Typing: don't allow Event instances in PasteEvent contents + +## [0.3.9] - 2021-10-07 +- Change typing of `event_trigger(event_type)` to allow a function that returns None + +## [0.3.7] - 2021-09-27 +- Fixed ctrl-c not being reported until another key was pressed in Python 3.5+ + +## [0.3.5] - 2021-01-24 +- Drop supported for Python 2, 3.4 and 3.5. +- Migrate to pytest. Thanks to Paolo Stivanin +- Add new examples. Thanks to rybarczykj +- Improve error messages. Thanks to Etienne Richart +- Replace wcwidth with cwcwidth + +## [0.3.4] - 2020-07-15 +- Prevent crash when embedding in situations including the lldb debugger. Thanks Nathan Lanza! + +## [0.3.3] - 2020-07-06 +- Revert backslash removal, since this broke bpython in 0.3.2 + +## [0.3.2] - 2020-07-04 +- Migrate doc generation to Python 3 +- Add MyPy typing +- Remove logging level message. Thanks Jack Rybarczyk! +- Assorted fixes: Thanks Armira Nance, Etienne Richart, Evan Allgood, Nathan Lanza, and Vilhelm Prytz! + +## [0.3.1] - 2020-01-03 +- Add "dark" format function +- Add Input option to disable terminal start/stop. Thanks George Kettleborough! +- Fix Py3.6 compatibility. Thanks Po-Chuan Hsieh! +- Assorted fixes, thanks Jakub Wilk and Manuel Mendez! + +## [0.3.0] - 2018-02-13 +- Change name of "dark" color to "black" +- Drop support for Python 2.6 and 3.3 +- New FmtStr method width_aware_splitlines which cuts up a FmtStr in linear time + +## [0.2.12] - 2018-02-12 +- Fix accidentally quadratic `width_aware_slice` behavior (fixes bpython #729) + This bug causes bpython to hang on large output. Thanks Ben Wiederhake! +- Allow curtsies to be run on non-main threads (useful for bpython #555) + This should allow bpython to be run in a variety of situations like Django's runserver +- Add function keys for some keyboard/terminal setups + +## [0.2.11] - 2016-10-22 +- Handle unsupported SGR codes (fixes bpython #657) +- Add Ctrl-Delete for some keyboard/terminal setups +- Many doc fixes. Thanks Dan Puttick! + +## [0.2.10] - 2016-10-10 +- Add sequences for home and end (fixes Curtsies #78) + +## [0.2.9] - 2016-09-07 +- Fix #90 again +- Strip ansi escape sequences if parsing fmtstr input fails +- Prevent invalid negative cursor positions in CursorAwareWindow (fixes bpython #607) +- '\x1bOA' changed from ctrl-arrow key to arrow key (fixes bpython #621) +- Alternate codes for F1-F4 (fixes bpython #626) diff --git a/LICENSE b/LICENSE index ec648d6..ead977f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ The MIT License (MIT) Copyright (c) 2014 Thomas Ballinger +Copyright (c) 2020-2023 Sebastian Ramacher Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index a7452a3..584721a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ include LICENSE include tests/*.py include examples/*.py -include readme.md +include curtsies/py.typed diff --git a/readme.md b/README.md similarity index 77% rename from readme.md rename to README.md index 6924e62..381823a 100644 --- a/readme.md +++ b/README.md @@ -1,30 +1,31 @@ -[![Build Status](https://travis-ci.org/thomasballinger/curtsies.svg?branch=master)](https://travis-ci.org/thomasballinger/curtsies) [![Documentation Status](https://readthedocs.org/projects/curtsies/badge/?version=latest)](https://readthedocs.org/projects/curtsies/?badge=latest) ![Curtsies Logo](http://ballingt.com/assets/curtsiestitle.png) -Curtsies is a library for interacting with the terminal. +Curtsies is a Python 3.6+ compatible library for interacting with the terminal. This is what using (nearly every feature of) curtsies looks like: ```python -from __future__ import unicode_literals # convenient for Python 2 import random +import sys from curtsies import FullscreenWindow, Input, FSArray from curtsies.fmtfuncs import red, bold, green, on_blue, yellow print(yellow('this prints normally, not to the alternate screen')) + with FullscreenWindow() as window: + a = FSArray(window.height, window.width) + msg = red(on_blue(bold('Press escape to exit, space to clear.'))) + a[0:1, 0:msg.width] = [msg] + window.render_to_terminal(a) with Input() as input_generator: - msg = red(on_blue(bold('Press escape to exit'))) - a = FSArray(window.height, window.width) - a[0:1, 0:msg.width] = [msg] for c in input_generator: if c == '': break elif c == '': a = FSArray(window.height, window.width) else: - s = repr(c).decode() + s = repr(c) row = random.choice(range(window.height)) column = random.choice(range(window.width-len(s))) color = random.choice([red, green, on_blue, yellow]) @@ -44,8 +45,7 @@ Primer [FmtStr](http://curtsies.readthedocs.org/en/latest/FmtStr.html) objects are strings formatted with colors and styles displayable in a terminal with [ANSI escape sequences](http://en.wikipedia.org/wiki/ANSI_escape_code>`_). -(the import statement shown below is outdated) -![fmtstr example screenshot](http://i.imgur.com/7lFaxsz.png) +![](https://i.imgur.com/bRLI134.png) [FSArray](http://curtsies.readthedocs.org/en/latest/FSArray.html) objects contain multiple such strings with each formatted string on its own row, and FSArray @@ -53,7 +53,8 @@ objects can be superimposed on each other to build complex grids of colored and styled characters through composition. (the import statement shown below is outdated) -![fsarray example screenshot](http://i.imgur.com/rvTRPv1.png) + +![](http://i.imgur.com/rvTRPv1.png) Such grids of characters can be rendered to the terminal in alternate screen mode (no history, like `Vim`, `top` etc.) by [FullscreenWindow](http://curtsies.readthedocs.org/en/latest/window.html#curtsies.window.FullscreenWindow) objects @@ -66,15 +67,15 @@ Examples * [Tic-Tac-Toe](/examples/tictactoeexample.py) -![screenshot](http://i.imgur.com/AucB55B.png) +![](http://i.imgur.com/AucB55B.png) * [Avoid the X's game](/examples/gameexample.py) -![screenshot](http://i.imgur.com/nv1RQd3.png) +![](http://i.imgur.com/nv1RQd3.png) * [Bpython-curtsies uses curtsies](http://ballingt.com/2013/12/21/bpython-curtsies.html) -[![ScreenShot](http://i.imgur.com/r7rZiBS.png)](http://www.youtube.com/watch?v=lwbpC4IJlyA) +[![](http://i.imgur.com/r7rZiBS.png)](http://www.youtube.com/watch?v=lwbpC4IJlyA) * [More examples](/examples) @@ -86,4 +87,5 @@ About * `#bpython` on irc is a good place to talk about Curtsies, but feel free to open an issue if you're having a problem! * Thanks to the many contributors! -* If all you need are colored strings, consider one of these [other libraries](http://curtsies.readthedocs.org/en/latest/FmtStr.html#rationale)! +* If all you need are colored strings, consider one of these [other + libraries](http://curtsies.readthedocs.io/en/latest/FmtStr.html#fmtstr-rationale)! diff --git a/bootstrap.py b/bootstrap.py deleted file mode 100644 index 9dc2c87..0000000 --- a/bootstrap.py +++ /dev/null @@ -1,382 +0,0 @@ -#!python -"""Bootstrap setuptools installation - -If you want to use setuptools in your package's setup.py, just include this -file in the same directory with it, and add this to the top of your setup.py:: - - from ez_setup import use_setuptools - use_setuptools() - -If you want to require a specific version of setuptools, set a download -mirror, or use an alternate download directory, you can do so by supplying -the appropriate options to ``use_setuptools()``. - -This file can also be run as a script to install or upgrade setuptools. -""" -import os -import shutil -import sys -import tempfile -import tarfile -import optparse -import subprocess -import platform - -from distutils import log - -try: - from site import USER_SITE -except ImportError: - USER_SITE = None - -DEFAULT_VERSION = "1.4.2" -DEFAULT_URL = "https://pypi.python.org/packages/source/s/setuptools/" - -def _python_cmd(*args): - args = (sys.executable,) + args - return subprocess.call(args) == 0 - -def _check_call_py24(cmd, *args, **kwargs): - res = subprocess.call(cmd, *args, **kwargs) - class CalledProcessError(Exception): - pass - if not res == 0: - msg = "Command '%s' return non-zero exit status %d" % (cmd, res) - raise CalledProcessError(msg) -vars(subprocess).setdefault('check_call', _check_call_py24) - -def _install(tarball, install_args=()): - # extracting the tarball - tmpdir = tempfile.mkdtemp() - log.warn('Extracting in %s', tmpdir) - old_wd = os.getcwd() - try: - os.chdir(tmpdir) - tar = tarfile.open(tarball) - _extractall(tar) - tar.close() - - # going in the directory - subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) - os.chdir(subdir) - log.warn('Now working in %s', subdir) - - # installing - log.warn('Installing Setuptools') - if not _python_cmd('setup.py', 'install', *install_args): - log.warn('Something went wrong during the installation.') - log.warn('See the error message above.') - # exitcode will be 2 - return 2 - finally: - os.chdir(old_wd) - shutil.rmtree(tmpdir) - - -def _build_egg(egg, tarball, to_dir): - # extracting the tarball - tmpdir = tempfile.mkdtemp() - log.warn('Extracting in %s', tmpdir) - old_wd = os.getcwd() - try: - os.chdir(tmpdir) - tar = tarfile.open(tarball) - _extractall(tar) - tar.close() - - # going in the directory - subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) - os.chdir(subdir) - log.warn('Now working in %s', subdir) - - # building an egg - log.warn('Building a Setuptools egg in %s', to_dir) - _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) - - finally: - os.chdir(old_wd) - shutil.rmtree(tmpdir) - # returning the result - log.warn(egg) - if not os.path.exists(egg): - raise IOError('Could not build the egg.') - - -def _do_download(version, download_base, to_dir, download_delay): - egg = os.path.join(to_dir, 'setuptools-%s-py%d.%d.egg' - % (version, sys.version_info[0], sys.version_info[1])) - if not os.path.exists(egg): - tarball = download_setuptools(version, download_base, - to_dir, download_delay) - _build_egg(egg, tarball, to_dir) - sys.path.insert(0, egg) - - # Remove previously-imported pkg_resources if present (see - # https://bitbucket.org/pypa/setuptools/pull-request/7/ for details). - if 'pkg_resources' in sys.modules: - del sys.modules['pkg_resources'] - - import setuptools - setuptools.bootstrap_install_from = egg - - -def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, - to_dir=os.curdir, download_delay=15): - # making sure we use the absolute path - to_dir = os.path.abspath(to_dir) - was_imported = 'pkg_resources' in sys.modules or \ - 'setuptools' in sys.modules - try: - import pkg_resources - except ImportError: - return _do_download(version, download_base, to_dir, download_delay) - try: - pkg_resources.require("setuptools>=" + version) - return - except pkg_resources.VersionConflict: - e = sys.exc_info()[1] - if was_imported: - sys.stderr.write( - "The required version of setuptools (>=%s) is not available,\n" - "and can't be installed while this script is running. Please\n" - "install a more recent version first, using\n" - "'easy_install -U setuptools'." - "\n\n(Currently using %r)\n" % (version, e.args[0])) - sys.exit(2) - else: - del pkg_resources, sys.modules['pkg_resources'] # reload ok - return _do_download(version, download_base, to_dir, - download_delay) - except pkg_resources.DistributionNotFound: - return _do_download(version, download_base, to_dir, - download_delay) - -def _clean_check(cmd, target): - """ - Run the command to download target. If the command fails, clean up before - re-raising the error. - """ - try: - subprocess.check_call(cmd) - except subprocess.CalledProcessError: - if os.access(target, os.F_OK): - os.unlink(target) - raise - -def download_file_powershell(url, target): - """ - Download the file at url to target using Powershell (which will validate - trust). Raise an exception if the command cannot complete. - """ - target = os.path.abspath(target) - cmd = [ - 'powershell', - '-Command', - "(new-object System.Net.WebClient).DownloadFile(%(url)r, %(target)r)" % vars(), - ] - _clean_check(cmd, target) - -def has_powershell(): - if platform.system() != 'Windows': - return False - cmd = ['powershell', '-Command', 'echo test'] - devnull = open(os.path.devnull, 'wb') - try: - try: - subprocess.check_call(cmd, stdout=devnull, stderr=devnull) - except: - return False - finally: - devnull.close() - return True - -download_file_powershell.viable = has_powershell - -def download_file_curl(url, target): - cmd = ['curl', url, '--silent', '--output', target] - _clean_check(cmd, target) - -def has_curl(): - cmd = ['curl', '--version'] - devnull = open(os.path.devnull, 'wb') - try: - try: - subprocess.check_call(cmd, stdout=devnull, stderr=devnull) - except: - return False - finally: - devnull.close() - return True - -download_file_curl.viable = has_curl - -def download_file_wget(url, target): - cmd = ['wget', url, '--quiet', '--output-document', target] - _clean_check(cmd, target) - -def has_wget(): - cmd = ['wget', '--version'] - devnull = open(os.path.devnull, 'wb') - try: - try: - subprocess.check_call(cmd, stdout=devnull, stderr=devnull) - except: - return False - finally: - devnull.close() - return True - -download_file_wget.viable = has_wget - -def download_file_insecure(url, target): - """ - Use Python to download the file, even though it cannot authenticate the - connection. - """ - try: - from urllib.request import urlopen - except ImportError: - from urllib2 import urlopen - src = dst = None - try: - src = urlopen(url) - # Read/write all in one block, so we don't create a corrupt file - # if the download is interrupted. - data = src.read() - dst = open(target, "wb") - dst.write(data) - finally: - if src: - src.close() - if dst: - dst.close() - -download_file_insecure.viable = lambda: True - -def get_best_downloader(): - downloaders = [ - download_file_powershell, - download_file_curl, - download_file_wget, - download_file_insecure, - ] - - for dl in downloaders: - if dl.viable(): - return dl - -def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, - to_dir=os.curdir, delay=15, - downloader_factory=get_best_downloader): - """Download setuptools from a specified location and return its filename - - `version` should be a valid setuptools version number that is available - as an egg for download under the `download_base` URL (which should end - with a '/'). `to_dir` is the directory where the egg will be downloaded. - `delay` is the number of seconds to pause before an actual download - attempt. - - ``downloader_factory`` should be a function taking no arguments and - returning a function for downloading a URL to a target. - """ - # making sure we use the absolute path - to_dir = os.path.abspath(to_dir) - tgz_name = "setuptools-%s.tar.gz" % version - url = download_base + tgz_name - saveto = os.path.join(to_dir, tgz_name) - if not os.path.exists(saveto): # Avoid repeated downloads - log.warn("Downloading %s", url) - downloader = downloader_factory() - downloader(url, saveto) - return os.path.realpath(saveto) - - -def _extractall(self, path=".", members=None): - """Extract all members from the archive to the current working - directory and set owner, modification time and permissions on - directories afterwards. `path' specifies a different directory - to extract to. `members' is optional and must be a subset of the - list returned by getmembers(). - """ - import copy - import operator - from tarfile import ExtractError - directories = [] - - if members is None: - members = self - - for tarinfo in members: - if tarinfo.isdir(): - # Extract directories with a safe mode. - directories.append(tarinfo) - tarinfo = copy.copy(tarinfo) - tarinfo.mode = 448 # decimal for oct 0700 - self.extract(tarinfo, path) - - # Reverse sort directories. - if sys.version_info < (2, 4): - def sorter(dir1, dir2): - return cmp(dir1.name, dir2.name) - directories.sort(sorter) - directories.reverse() - else: - directories.sort(key=operator.attrgetter('name'), reverse=True) - - # Set correct owner, mtime and filemode on directories. - for tarinfo in directories: - dirpath = os.path.join(path, tarinfo.name) - try: - self.chown(tarinfo, dirpath) - self.utime(tarinfo, dirpath) - self.chmod(tarinfo, dirpath) - except ExtractError: - e = sys.exc_info()[1] - if self.errorlevel > 1: - raise - else: - self._dbg(1, "tarfile: %s" % e) - - -def _build_install_args(options): - """ - Build the arguments to 'python setup.py install' on the setuptools package - """ - install_args = [] - if options.user_install: - if sys.version_info < (2, 6): - log.warn("--user requires Python 2.6 or later") - raise SystemExit(1) - install_args.append('--user') - return install_args - -def _parse_args(): - """ - Parse the command line for options - """ - parser = optparse.OptionParser() - parser.add_option( - '--user', dest='user_install', action='store_true', default=False, - help='install in user site package (requires Python 2.6 or later)') - parser.add_option( - '--download-base', dest='download_base', metavar="URL", - default=DEFAULT_URL, - help='alternative URL from where to download the setuptools package') - parser.add_option( - '--insecure', dest='downloader_factory', action='store_const', - const=lambda: download_file_insecure, default=get_best_downloader, - help='Use internal, non-validating downloader' - ) - options, args = parser.parse_args() - # positional arguments are ignored - return options - -def main(version=DEFAULT_VERSION): - """Install or upgrade setuptools and EasyInstall""" - options = _parse_args() - tarball = download_setuptools(download_base=options.download_base, - downloader_factory=options.downloader_factory) - return _install(tarball, _build_install_args(options)) - -if __name__ == '__main__': - sys.exit(main()) diff --git a/curtsies/__init__.py b/curtsies/__init__.py index 6ae98b2..598a077 100644 --- a/curtsies/__init__.py +++ b/curtsies/__init__.py @@ -1,9 +1,9 @@ """Terminal-formatted strings""" -__version__='0.2.6' + +__version__ = "0.4.3" from .window import FullscreenWindow, CursorAwareWindow from .input import Input from .termhelpers import Nonblocking, Cbreak, Termmode from .formatstring import FmtStr, fmtstr from .formatstringarray import FSArray, fsarray - diff --git a/curtsies/configfile_keynames.py b/curtsies/configfile_keynames.py index f451e9c..fd9916c 100644 --- a/curtsies/configfile_keynames.py +++ b/curtsies/configfile_keynames.py @@ -2,28 +2,37 @@ In the style of bpython config files and keymap""" +from typing import Tuple + SPECIALS = { - 'C-[': u'', - 'C-^': u'', - 'C-_': u'', - } + "C-[": "", + "C-^": "", + "C-_": "", +} + -#TODO make a precalculated version of this -class KeyMap(object): +# TODO make a precalculated version of this +class KeyMap: """Maps config file key syntax to Curtsies names""" - def __getitem__(self, key): - if not key: # Unbound key + + def __getitem__(self, key: str) -> tuple[str, ...]: + if not key: # Unbound key return () elif key in SPECIALS: return (SPECIALS[key],) - elif key[1:] and key[:2] == 'C-': - return (u'' % key[2:],) - elif key[1:] and key[:2] == 'M-': - return (u'' % key[2:], u'' % key[2:],) - elif key[0] == 'F' and key[1:].isdigit(): - return (u'' % int(key[1:]),) + elif key[1:] and key[:2] == "C-": + return ("" % key[2:],) + elif key[1:] and key[:2] == "M-": + return ( + "" % key[2:], + "" % key[2:], + ) + elif key[0] == "F" and key[1:].isdigit(): + return ("" % int(key[1:]),) else: - raise KeyError('Configured keymap (%s)' % key + - ' does not exist in bpython.keys') + raise KeyError( + "Configured keymap (%s)" % key + " does not exist in bpython.keys" + ) + keymap = KeyMap() diff --git a/curtsies/curtsieskeys.py b/curtsies/curtsieskeys.py index 8039de6..e8a179e 100644 --- a/curtsies/curtsieskeys.py +++ b/curtsies/curtsieskeys.py @@ -1,99 +1,142 @@ """All the key sequences""" + # If you add a binding, add something about your setup # if you can figure out why it's different # Special names are for multi-character keys, or key names # that would be hard to write in a config file -#TODO add PAD keys hack as in bpython.cli - -CURTSIES_NAMES = dict([ - (b' ', u''), - (b'\x1b ', u''), - (b'\t', u''), - (b'\x1b[Z', u''), - (b'\x1b[A', u''), - (b'\x1b[B', u''), - (b'\x1b[C', u''), - (b'\x1b[D', u''), - (b'\x1bOA', u''), - (b'\x1bOB', u''), - (b'\x1bOC', u''), - (b'\x1bOD', u''), - - (b'\x1b[1;5A', u''), - (b'\x1b[1;5B', u''), - (b'\x1b[1;5C', u''), # reported by myint - (b'\x1b[1;5D', u''), # reported by myint - - (b'\x1b[5A', u''), # not sure about these, someone wanted them for bpython - (b'\x1b[5B', u''), - (b'\x1b[5C', u''), - (b'\x1b[5D', u''), - - (b'\x1b[1;9A', u''), - (b'\x1b[1;9B', u''), - (b'\x1b[1;9C', u''), - (b'\x1b[1;9D', u''), - - (b'\x1b[1;10A', u''), - (b'\x1b[1;10B', u''), - (b'\x1b[1;10C', u''), - (b'\x1b[1;10D', u''), - - (b'\x1bOP', u''), - (b'\x1bOQ', u''), - (b'\x1bOR', u''), - (b'\x1bOS', u''), - (b'\x1b[15~', u''), - (b'\x1b[17~', u''), - (b'\x1b[18~', u''), - (b'\x1b[19~', u''), - (b'\x1b[20~', u''), - (b'\x1b[21~', u''), - (b'\x1b[23~', u''), - (b'\x1b[24~', u''), - (b'\x00', u''), - (b'\x1c', u''), - (b'\x1d', u''), - (b'\x1e', u''), - (b'\x1f', u''), - (b'\x7f', u''), # for some folks this is ctrl-backspace apparently - (b'\x1b\x7f', u''), - (b'\xff', u''), - (b'\x1b\x1b[A', u''), # uncertain about these four - (b'\x1b\x1b[B', u''), - (b'\x1b\x1b[C', u''), - (b'\x1b\x1b[D', u''), - (b'\x1b', u''), - (b'\x1b[1~', u''), - (b'\x1b[4~', u''), - (b'\x1b\x1b[5~',u''), - (b'\x1b\x1b[6~',u''), - - (b'\x1b[H', u''), # reported by amorozov in bpython #490 - (b'\x1b[F', u''), # reported by amorozov in bpython #490 - - # see curtsies #78 - taken from https://github.com/jquast/blessed/blob/e9ad7b85dfcbbba49010ab8c13e3a5920d81b010/blessed/keyboard.py#L409 - - # not fixing for back compat. - # (b"\x1b[1~", u''), # find - - (b"\x1b[2~", u''), # insert (0) - (b"\x1b[3~", u''), # delete (.), "Execute" - - # not fixing for back compat. - # (b"\x1b[4~", u'', # select + + b"\x1b[5~": '', # pgup (9) + b"\x1b[6~": '', # pgdown (3) + b"\x1b[7~": '', # home + b"\x1b[8~": '', # end + b"\x1b[OA": '', # up (8) + b"\x1b[OB": '', # down (2) + b"\x1b[OC": '', # right (6) + b"\x1b[OD": '', # left (4) + b"\x1b[OF": '', # end (1) + b"\x1b[OH": '', # home (7) + + # reported by cool-RR + b"\x1b[[A": '', + b"\x1b[[B": '', + b"\x1b[[C": '', + b"\x1b[[D": '', + b"\x1b[[E": '', + # cool-RR says the rest were good: see issue #99 + + # reported by alethiophile see issue #119 + b"\x1b[1;3C": '', # alt-right + b"\x1b[1;3B": '', # alt-down + b"\x1b[1;3D": '', # alt-left + b"\x1b[1;3A": '', # alt-up + b"\x1b[5;3~": '', # alt-pageup + b"\x1b[6;3~": '', # alt-pagedown + b"\x1b[1;3H": '', # alt-home + b"\x1b[1;3F": '', # alt-end + b"\x1b[1;2C": '', + b"\x1b[1;2B": '', + b"\x1b[1;2D": '', + b"\x1b[1;2A": '', + b"\x1b[3;2~": '', + b"\x1b[5;2~": '', + b"\x1b[6;2~": '', + b"\x1b[1;2H": '', + b"\x1b[1;2F": '', + # end of keys reported by alethiophile +} +# fmt: on diff --git a/curtsies/escseqparse.py b/curtsies/escseqparse.py index 3468100..5c4e4fe 100644 --- a/curtsies/escseqparse.py +++ b/curtsies/escseqparse.py @@ -9,19 +9,50 @@ True """ -from .termformatconstants import (FG_NUMBER_TO_COLOR, BG_NUMBER_TO_COLOR, - NUMBER_TO_STYLE, RESET_ALL, RESET_FG, - RESET_BG, STYLES) +from typing import ( + List, + Union, + Tuple, + cast, + Dict, + Any, + Optional, +) +from collections.abc import Mapping +from re import Match + import re +from .termformatconstants import ( + FG_NUMBER_TO_COLOR, + BG_NUMBER_TO_COLOR, + NUMBER_TO_STYLE, + RESET_ALL, + RESET_FG, + RESET_BG, + STYLES, +) + + +Token = dict[str, Union[str, list[int]]] + + +def remove_ansi(s: str) -> str: + return re.sub(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]", "", s) + -def parse(s): +def parse(s: str) -> list[str | dict[str, str | bool | None]]: r""" + Returns a list of strings or format dictionaries to describe the strings. + + May raise a ValueError if it can't be parsed. + >>> parse(">>> []") ['>>> []'] - >>> #parse("\x1b[33m[\x1b[39m\x1b[33m]\x1b[39m\x1b[33m[\x1b[39m\x1b[33m]\x1b[39m\x1b[33m[\x1b[39m\x1b[33m]\x1b[39m\x1b[33m[\x1b[39m") + >>> parse("\x1b[33m[\x1b[39m\x1b[33m]\x1b[39m\x1b[33m[\x1b[39m\x1b[33m]\x1b[39m\x1b[33m[\x1b[39m\x1b[33m]\x1b[39m\x1b[33m[\x1b[39m") + [{'fg': 'yellow'}, '[', {'fg': None}, {'fg': 'yellow'}, ']', {'fg': None}, {'fg': 'yellow'}, '[', {'fg': None}, {'fg': 'yellow'}, ']', {'fg': None}, {'fg': 'yellow'}, '[', {'fg': None}, {'fg': 'yellow'}, ']', {'fg': None}, {'fg': 'yellow'}, '[', {'fg': None}] """ - stuff = [] + stuff: list[str | dict[str, str | bool | None]] = [] rest = s while True: front, token, rest = peel_off_esc_code(rest) @@ -31,15 +62,18 @@ def parse(s): try: tok = token_type(token) if tok: - stuff.append(tok) + stuff.extend(tok) except ValueError: - raise ValueError("Can't parse escape sequence: %r %r %r %r" % (s, repr(front), token, repr(rest))) + raise ValueError( + "Can't parse escape sequence: %r %r %r %r" + % (s, repr(front), token, repr(rest)) + ) if not rest: break return stuff -def peel_off_esc_code(s): +def peel_off_esc_code(s: str) -> tuple[str, Token | None, str]: r"""Returns processed text, the next token, and unprocessed text >>> front, d, rest = peel_off_esc_code('somestuff') @@ -48,6 +82,7 @@ def peel_off_esc_code(s): >>> d == {'numbers': [2], 'command': 'A', 'intermed': '', 'private': '', 'csi': '\x1b[', 'seq': '\x1b[2A'} True """ + # fmt: off p = r"""(?P.*?) (?P (?P @@ -61,49 +96,70 @@ def peel_off_esc_code(s): (?P""" + '[\x20-\x2f]*)' + r""" (?P""" + '[\x40-\x7e]))' + r""" (?P.*)""" + # fmt: on m1 = re.match(p, s, re.VERBOSE) # multibyte esc seq - m2 = re.match('(?P.*?)(?P(?P)(?P[\x40-\x5f]))(?P.*)', s) # 2 byte escape sequence + m2 = re.match( + "(?P.*?)(?P(?P)(?P[\x40-\x5f]))(?P.*)", s + ) # 2 byte escape sequence + m = None # Optional[Match[str]] if m1 and m2: - m = m1 if len(m1.groupdict()['front']) <= len(m2.groupdict()['front']) else m2 + m = m1 if len(m1.groupdict()["front"]) <= len(m2.groupdict()["front"]) else m2 # choose the match which has less processed text in order to get the # first escape sequence - elif m1: m = m1 - elif m2: m = m2 - else: m = None + elif m1: + m = m1 + elif m2: + m = m2 + else: + m = None if m: - d = m.groupdict() - del d['front'] - del d['rest'] - if 'numbers' in d and d['numbers'].split(';'): - d['numbers'] = [int(x) for x in d['numbers'].split(';')] + d: dict[str, Any] = m.groupdict() + del d["front"] + del d["rest"] + if "numbers" in d and all(d["numbers"].split(";")): + d["numbers"] = [int(x) for x in d["numbers"].split(";")] - return m.groupdict()['front'], d, m.groupdict()['rest'] + return m.groupdict()["front"], cast(Token, d), m.groupdict()["rest"] else: - return s, None, '' + return s, None, "" -def token_type(info): - """ - """ - if info['command'] == 'm': + +def token_type(info: Token) -> list[dict[str, str | bool | None]] | None: + if info["command"] == "m": # The default action for ESC[m is to act like ESC[0m # Ref: https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_codes - value, = info['numbers'] if len(info['numbers']) else [0] - if value in FG_NUMBER_TO_COLOR: return {'fg':FG_NUMBER_TO_COLOR[value]} - if value in BG_NUMBER_TO_COLOR: return {'bg':BG_NUMBER_TO_COLOR[value]} - if value in NUMBER_TO_STYLE: return {NUMBER_TO_STYLE[value]:True} - if value == RESET_ALL: return dict(dict((k, None) for k in STYLES), **{'fg':None, 'bg':None}) - if value == RESET_FG: return {'fg':None} - if value == RESET_BG: return {'bg':None} + values = cast(list[int], info["numbers"]) if len(info["numbers"]) else [0] + tokens: list[dict[str, str | bool | None]] = [] + for value in values: + if value in FG_NUMBER_TO_COLOR: + tokens.append({"fg": FG_NUMBER_TO_COLOR[value]}) + if value in BG_NUMBER_TO_COLOR: + tokens.append({"bg": BG_NUMBER_TO_COLOR[value]}) + if value in NUMBER_TO_STYLE: + tokens.append({NUMBER_TO_STYLE[value]: True}) + if value == RESET_ALL: + tokens.append( + dict({k: None for k in STYLES}, **{"fg": None, "bg": None}) + ) + if value == RESET_FG: + tokens.append({"fg": None}) + if value == RESET_BG: + tokens.append({"bg": None}) - elif info['command'] == 'H': # fix for bpython #76 - return {} + if tokens: + return tokens + else: + raise ValueError("Can't parse escape seq %r" % info) + elif info["command"] == "H": # fix for bpython #76 + return [{}] + return None - raise ValueError("Can't parse escape seq %r" % info) -if __name__ == '__main__': - import doctest; doctest.testmod() - #print(peel_off_esc_code('stuff')) - #print(peel_off_esc_code('Amore')) - print((repr(parse('stuff is the bestyay')))) +if __name__ == "__main__": + import doctest + doctest.testmod() + # print(peel_off_esc_code('stuff')) + # print(peel_off_esc_code('Amore')) + print(repr(parse("stuff is the bestyay"))) diff --git a/curtsies/events.py b/curtsies/events.py index c582c68..c205261 100644 --- a/curtsies/events.py +++ b/curtsies/events.py @@ -1,83 +1,96 @@ """Events for keystrokes and other input events""" + +import codecs +import itertools import sys -import time -import encodings -from functools import wraps +from enum import Enum, auto +from typing import Optional, List, Union +from collections.abc import Sequence -PY3 = sys.version_info[0] >= 3 +from .termhelpers import Termmode +from .curtsieskeys import CURTSIES_NAMES as special_curtsies_names -if PY3: - raw_input = input - unicode = str +chr_byte = lambda i: chr(i).encode("latin-1") +chr_uni = chr -chr_byte = lambda i: chr(i).encode('latin-1') if PY3 else chr(i) -chr_uni = lambda i: chr(i) if PY3 else chr(i).decode('latin-1') -CURTSIES_NAMES = {} -control_chars = dict((chr_byte(i), u'' % chr(i + 0x60)) for i in range(0x00, 0x1b)) -CURTSIES_NAMES.update(control_chars) +CURTSIES_NAMES = {chr_byte(i): "" % chr(i + 0x60) for i in range(0x00, 0x1B)} for i in range(0x00, 0x80): - CURTSIES_NAMES[b'\x1b'+chr_byte(i)] = u'' % chr(i) -for i in range(0x00, 0x1b): # Overwrite the control keys with better labels - CURTSIES_NAMES[b'\x1b'+chr_byte(i)] = u'' % chr(i + 0x40) + CURTSIES_NAMES[b"\x1b" + chr_byte(i)] = "" % chr(i) +for i in range(0x00, 0x1B): # Overwrite the control keys with better labels + CURTSIES_NAMES[b"\x1b" + chr_byte(i)] = "" % chr(i + 0x40) for i in range(0x00, 0x80): - CURTSIES_NAMES[chr_byte(i + 0x80)] = u'' % chr(i) -for i in range(0x00, 0x1b): # Overwrite the control keys with better labels - CURTSIES_NAMES[chr_byte(i + 0x80)] = u'' % chr(i + 0x40) + CURTSIES_NAMES[chr_byte(i + 0x80)] = "" % chr(i) +for i in range(0x00, 0x1B): # Overwrite the control keys with better labels + CURTSIES_NAMES[chr_byte(i + 0x80)] = "" % chr(i + 0x40) -from .curtsieskeys import CURTSIES_NAMES as special_curtsies_names CURTSIES_NAMES.update(special_curtsies_names) -CURSES_NAMES = {} -CURSES_NAMES[b'\x1bOP'] = u'KEY_F(1)' -CURSES_NAMES[b'\x1bOQ'] = u'KEY_F(2)' -CURSES_NAMES[b'\x1bOR'] = u'KEY_F(3)' -CURSES_NAMES[b'\x1bOS'] = u'KEY_F(4)' -CURSES_NAMES[b'\x1b[15~'] = u'KEY_F(5)' -CURSES_NAMES[b'\x1b[17~'] = u'KEY_F(6)' -CURSES_NAMES[b'\x1b[18~'] = u'KEY_F(7)' -CURSES_NAMES[b'\x1b[19~'] = u'KEY_F(8)' -CURSES_NAMES[b'\x1b[20~'] = u'KEY_F(9)' -CURSES_NAMES[b'\x1b[21~'] = u'KEY_F(10)' -CURSES_NAMES[b'\x1b[23~'] = u'KEY_F(11)' -CURSES_NAMES[b'\x1b[24~'] = u'KEY_F(12)' -CURSES_NAMES[b'\x1b[A'] = u'KEY_UP' -CURSES_NAMES[b'\x1b[B'] = u'KEY_DOWN' -CURSES_NAMES[b'\x1b[C'] = u'KEY_RIGHT' -CURSES_NAMES[b'\x1b[D'] = u'KEY_LEFT' -CURSES_NAMES[b'\x1b[F'] = u'KEY_END' # https://github.com/bpython/bpython/issues/490 -CURSES_NAMES[b'\x1b[H'] = u'KEY_HOME' # https://github.com/bpython/bpython/issues/490 -CURSES_NAMES[b'\x08'] = u'KEY_BACKSPACE' -CURSES_NAMES[b'\x1b[Z'] = u'KEY_BTAB' - -# see curtsies #78 - taken from https://github.com/jquast/blessed/blob/e9ad7b85dfcbbba49010ab8c13e3a5920d81b010/blessed/keyboard.py#L409 -CURSES_NAMES[b'\x1b[1~'] = u'KEY_FIND' # find -CURSES_NAMES[b'\x1b[2~'] = u'KEY_IC' # insert (0) -CURSES_NAMES[b'\x1b[3~'] = u'KEY_DC' # delete (.), "Execute" -CURSES_NAMES[b'\x1b[4~'] = u'KEY_SELECT' # select -CURSES_NAMES[b'\x1b[5~'] = u'KEY_PPAGE' # pgup (9) -CURSES_NAMES[b'\x1b[6~'] = u'KEY_NPAGE' # pgdown (3) -CURSES_NAMES[b'\x1b[7~'] = u'KEY_HOME' # home -CURSES_NAMES[b'\x1b[8~'] = u'KEY_END' # end -CURSES_NAMES[b'\x1b[OA'] = u'KEY_UP' # up (8) -CURSES_NAMES[b'\x1b[OB'] = u'KEY_DOWN' # down (2) -CURSES_NAMES[b'\x1b[OC'] = u'KEY_RIGHT' # right (6) -CURSES_NAMES[b'\x1b[OD'] = u'KEY_LEFT' # left (4) -CURSES_NAMES[b'\x1b[OF'] = u'KEY_END' # end (1) -CURSES_NAMES[b'\x1b[OH'] = u'KEY_HOME' # home (7) +CURSES_NAMES = { + b"\x1bOP": "KEY_F(1)", + b"\x1bOQ": "KEY_F(2)", + b"\x1bOR": "KEY_F(3)", + b"\x1bOS": "KEY_F(4)", + b"\x1b[15~": "KEY_F(5)", + b"\x1b[17~": "KEY_F(6)", + b"\x1b[18~": "KEY_F(7)", + b"\x1b[19~": "KEY_F(8)", + b"\x1b[20~": "KEY_F(9)", + b"\x1b[21~": "KEY_F(10)", + b"\x1b[23~": "KEY_F(11)", + b"\x1b[24~": "KEY_F(12)", + # see bpython #626 + b"\x1b[11~": "KEY_F(1)", + b"\x1b[12~": "KEY_F(2)", + b"\x1b[13~": "KEY_F(3)", + b"\x1b[14~": "KEY_F(4)", + b"\x1b[A": "KEY_UP", + b"\x1b[B": "KEY_DOWN", + b"\x1b[C": "KEY_RIGHT", + b"\x1b[D": "KEY_LEFT", + b"\x1b[F": "KEY_END", # https://github.com/bpython/bpython/issues/490 + b"\x1b[H": "KEY_HOME", # https://github.com/bpython/bpython/issues/490 + b"\x08": "KEY_BACKSPACE", + b"\x1b[Z": "KEY_BTAB", + # see curtsies #78 - taken from https://github.com/jquast/blessed/blob/e9ad7b85dfcbbba49010ab8c13e3a5920d81b010/blessed/keyboard.py#L409 + b"\x1b[1~": "KEY_FIND", # find + b"\x1b[2~": "KEY_IC", # insert (0) + b"\x1b[3~": "KEY_DC", # delete (.), "Execute" + b"\x1b[4~": "KEY_SELECT", # select + b"\x1b[5~": "KEY_PPAGE", # pgup (9) + b"\x1b[6~": "KEY_NPAGE", # pgdown (3) + b"\x1b[7~": "KEY_HOME", # home + b"\x1b[8~": "KEY_END", # end + b"\x1b[OA": "KEY_UP", # up (8) + b"\x1b[OB": "KEY_DOWN", # down (2) + b"\x1b[OC": "KEY_RIGHT", # right (6) + b"\x1b[OD": "KEY_LEFT", # left (4) + b"\x1b[OF": "KEY_END", # end (1) + b"\x1b[OH": "KEY_HOME", # home (7) +} KEYMAP_PREFIXES = set() for table in (CURSES_NAMES, CURTSIES_NAMES): for k in table: - if k.startswith(b'\x1b'): + if k.startswith(b"\x1b"): for i in range(1, len(k)): KEYMAP_PREFIXES.add(k[:i]) -MAX_KEYPRESS_SIZE = max(len(seq) for seq in (list(CURSES_NAMES.keys()) + list(CURTSIES_NAMES.keys()))) +MAX_KEYPRESS_SIZE = max( + len(seq) for seq in itertools.chain(CURSES_NAMES.keys(), CURTSIES_NAMES.keys()) +) + + +class Keynames(Enum): + CURTSIES = auto() + CURSES = auto() + BYTES = auto() -class Event(object): + +class Event: pass + class ScheduledEvent(Event): """Event scheduled for a future time. @@ -86,45 +99,61 @@ class ScheduledEvent(Event): Custom events that occur at a specific time in the future should be subclassed from ScheduledEvent.""" - def __init__(self, when): + + def __init__(self, when: float) -> None: self.when = when + class WindowChangeEvent(Event): - def __init__(self, rows, columns, cursor_dy=None): + def __init__(self, rows: int, columns: int, cursor_dy: int | None = None) -> None: self.rows = rows self.columns = columns self.cursor_dy = cursor_dy + x = width = property(lambda self: self.columns) y = height = property(lambda self: self.rows) - def __repr__(self): - return "" % (self.rows, self.columns, - '' if self.cursor_dy is None else " cursor_dy: %d" % self.cursor_dy) + + def __repr__(self) -> str: + return "" % ( + self.rows, + self.columns, + "" if self.cursor_dy is None else " cursor_dy: %d" % self.cursor_dy, + ) + @property - def name(self): - return '' + def name(self) -> str: + return "" + class SigIntEvent(Event): """Event signifying a SIGINT""" - def __repr__(self): + + def __repr__(self) -> str: return "" + @property - def name(self): + def name(self) -> str: return repr(self) + class PasteEvent(Event): """Multiple keypress events combined, likely from copy/paste. The events attribute contains a list of keypress event strings. """ - def __init__(self): - self.events = [] - def __repr__(self): + + def __init__(self) -> None: + self.events: list[str] = [] + + def __repr__(self) -> str: return "" % self.events + @property - def name(self): + def name(self) -> str: return repr(self) -def decodable(seq, encoding): + +def decodable(seq: bytes, encoding: str) -> bool: try: u = seq.decode(encoding) except UnicodeDecodeError: @@ -132,7 +161,46 @@ def decodable(seq, encoding): else: return True -def get_key(bytes_, encoding, keynames='curtsies', full=False): + +def _key_name(seq: bytes, encoding: str, keynames: Keynames) -> str: + if keynames == Keynames.CURSES: + # may not be here (and still not decodable) curses names incomplete + if seq in CURSES_NAMES: + return CURSES_NAMES[seq] + + # Otherwise, there's no special curses name for this + try: + # for normal decodable text or a special curtsies sequence with bytes that can be decoded + return seq.decode(encoding) + except UnicodeDecodeError: + # this sequence can't be decoded with this encoding, so we need to represent the bytes + if len(seq) == 1: + return "x%02X" % ord(seq) + # TODO figure out a better thing to return here + else: + raise NotImplementedError( + "are multibyte unnameable sequences possible?" + ) + return "bytes: " + "-".join( + "x%02X" % ord(seq[i : i + 1]) for i in range(len(seq)) + ) + # TODO if this isn't possible, return multiple meta keys as a paste event if paste events enabled + elif keynames == Keynames.CURTSIES: + if seq in CURTSIES_NAMES: + return CURTSIES_NAMES[seq] + # assumes that curtsies names are a subset of curses ones + return seq.decode(encoding) + else: + assert keynames == Keynames.BYTES + return seq # type: ignore + + +def get_key( + bytes_: Sequence[bytes], + encoding: str, + keynames: Keynames = Keynames.CURTSIES, + full: bool = False, +) -> str | None: """Return key pressed from bytes_ or None Return a key name or None meaning it's an incomplete sequence of bytes @@ -165,141 +233,127 @@ def get_key(bytes_, encoding, keynames='curtsies', full=False): (for 'asdf', first on 'a', then on 'as', then on 'asd' - until a non-None value is returned) """ - if not all(isinstance(c, type(b'')) for c in bytes_): - raise ValueError("get key expects bytes, got %r" % bytes_) # expects raw bytes - if keynames not in ['curtsies', 'curses', 'bytes']: - raise ValueError("keynames must be one of 'curtsies', 'curses' or 'bytes'") - seq = b''.join(bytes_) + if not all(isinstance(c, bytes) for c in bytes_): + raise TypeError("get key expects bytes, got %r" % bytes_) # expects raw bytes + seq = b"".join(bytes_) if len(seq) > MAX_KEYPRESS_SIZE: - raise ValueError('unable to decode bytes %r' % seq) - - def key_name(): - if keynames == 'curses': - if seq in CURSES_NAMES: # may not be here (and still not decodable) curses names incomplete - return CURSES_NAMES[seq] - - # Otherwise, there's no special curses name for this - try: - return seq.decode(encoding) # for normal decodable text or a special curtsies sequence with bytes that can be decoded - except UnicodeDecodeError: - # this sequence can't be decoded with this encoding, so we need to represent the bytes - if len(seq) == 1: - return u'x%02X' % ord(seq) - #TODO figure out a better thing to return here - else: - raise NotImplementedError("are multibyte unnameable sequences possible?") - return u'bytes: ' + u'-'.join(u'x%02X' % ord(seq[i:i+1]) for i in range(len(seq))) - #TODO if this isn't possible, return multiple meta keys as a paste event if paste events enabled - elif keynames == 'curtsies': - if seq in CURTSIES_NAMES: - return CURTSIES_NAMES[seq] - return seq.decode(encoding) #assumes that curtsies names are a subset of curses ones - else: - assert keynames == 'bytes' - return seq + raise ValueError("unable to decode bytes %r" % seq) key_known = seq in CURTSIES_NAMES or seq in CURSES_NAMES or decodable(seq, encoding) if full and key_known: - return key_name() + return _key_name(seq, encoding, keynames) elif seq in KEYMAP_PREFIXES or could_be_unfinished_char(seq, encoding): - return None # need more input to make up a full keypress + return None # need more input to make up a full keypress elif key_known: - return key_name() + return _key_name(seq, encoding, keynames) else: - seq.decode(encoding) # this will raise a unicode error (they're annoying to raise ourselves) - assert False, 'should have raised an unicode decode error' + # this will raise a unicode error (they're annoying to raise ourselves) + seq.decode(encoding) + assert False, "should have raised an unicode decode error" + -def could_be_unfinished_char(seq, encoding): +def could_be_unfinished_char(seq: bytes, encoding: str) -> bool: """Whether seq bytes might create a char in encoding if more bytes were added""" if decodable(seq, encoding): - return False # any sensible encoding surely doesn't require lookahead (right?) + return False # any sensible encoding surely doesn't require lookahead (right?) # (if seq bytes encoding a character, adding another byte shouldn't also encode something) - if encodings.codecs.getdecoder('utf8') is encodings.codecs.getdecoder(encoding): + if codecs.getdecoder("utf8") is codecs.getdecoder(encoding): return could_be_unfinished_utf8(seq) - elif encodings.codecs.getdecoder('ascii') is encodings.codecs.getdecoder(encoding): + elif codecs.getdecoder("ascii") is codecs.getdecoder(encoding): return False else: - return True # We don't know, it could be + return True # We don't know, it could be -def could_be_unfinished_utf8(seq): + +def could_be_unfinished_utf8(seq: bytes) -> bool: # http://en.wikipedia.org/wiki/UTF-8#Description - if ord(seq[0:1]) & 0b10000000 == 0b10000000 and len(seq) < 1: return True - elif ord(seq[0:1]) & 0b11100000 == 0b11000000 and len(seq) < 2: return True - elif ord(seq[0:1]) & 0b11110000 == 0b11100000 and len(seq) < 3: return True - elif ord(seq[0:1]) & 0b11111000 == 0b11110000 and len(seq) < 4: return True - elif ord(seq[0:1]) & 0b11111100 == 0b11111000 and len(seq) < 5: return True - elif ord(seq[0:1]) & 0b11111110 == 0b11111100 and len(seq) < 6: return True - else: return False - -def pp_event(seq): + o = ord(seq[0:1]) + return ( + (o & 0b11100000 == 0b11000000 and len(seq) < 2) + or (o & 0b11110000 == 0b11100000 and len(seq) < 3) + or (o & 0b11111000 == 0b11110000 and len(seq) < 4) + or (o & 0b11111100 == 0b11111000 and len(seq) < 5) + or (o & 0b11111110 == 0b11111100 and len(seq) < 6) + ) + + +def pp_event(seq: Event | str) -> str | bytes: """Returns pretty representation of an Event or keypress""" if isinstance(seq, Event): return str(seq) # Get the original sequence back if seq is a pretty name already - rev_curses = dict((v, k) for k, v in CURSES_NAMES.items()) - rev_curtsies = dict((v, k) for k, v in CURTSIES_NAMES.items()) + rev_curses = {v: k for k, v in CURSES_NAMES.items()} + rev_curtsies = {v: k for k, v in CURTSIES_NAMES.items()} + bytes_seq: bytes | None = None if seq in rev_curses: - seq = rev_curses[seq] + bytes_seq = rev_curses[seq] elif seq in rev_curtsies: - seq = rev_curtsies[seq] + bytes_seq = rev_curtsies[seq] + + if bytes_seq: + pretty = curtsies_name(bytes_seq) + if pretty != seq: + return pretty + return repr(seq).lstrip("u")[1:-1] - pretty = curtsies_name(seq) - if pretty != seq: - return pretty - return repr(seq).lstrip('u')[1:-1] -def curtsies_name(seq): +def curtsies_name(seq: bytes) -> str | bytes: return CURTSIES_NAMES.get(seq, seq) -def try_keys(): - print('press a bunch of keys (not at the same time, but you can hit them pretty quickly)') - import tty - import termios - import fcntl + +def try_keys() -> None: + print( + "press a bunch of keys (not at the same time, but you can hit them pretty quickly)" + ) import os from .termhelpers import Cbreak - def ask_what_they_pressed(seq, Normal): - print('Unidentified character sequence!') + def ask_what_they_pressed(seq: bytes, Normal: Termmode) -> None: + print("Unidentified character sequence!") with Normal: while True: - r = raw_input("type 'ok' to prove you're not pounding keys ") - if r.lower().strip() == 'ok': + r = input("type 'ok' to prove you're not pounding keys ") + if r.lower().strip() == "ok": break while True: - print('Press the key that produced %r again please' % (seq,)) + print(f"Press the key that produced {seq!r} again please") retry = os.read(sys.stdin.fileno(), 1000) if seq == retry: break print("nope, that wasn't it") with Normal: - name = raw_input('Describe in English what key you pressed: ') - f = open('keylog.txt', 'a') - f.write("%r is called %s\n" % (seq, name)) + name = input("Describe in English what key you pressed: ") + f = open("keylog.txt", "a") + f.write(f"{seq!r} is called {name}\n") f.close() - print('Thanks! Please open an issue at https://github.com/thomasballinger/curtsies/issues') - print('or email thomasballinger@gmail.com. Include this terminal history or keylog.txt.') - print('You can keep pressing keys') + print( + "Thanks! Please open an issue at https://github.com/bpython/curtsies/issues" + ) + print( + "or email thomasballinger@gmail.com. Include this terminal history or keylog.txt." + ) + print("You can keep pressing keys") with Cbreak(sys.stdin) as NoCbreak: while True: try: chars = os.read(sys.stdin.fileno(), 1000) - print('---') + print("---") print(repr(chars)) if chars in CURTSIES_NAMES: print(CURTSIES_NAMES[chars]) elif len(chars) == 1: - print('literal') + print("literal") else: - print('unknown!!!') + print("unknown!!!") ask_what_they_pressed(chars, NoCbreak) except OSError: pass -if __name__ == '__main__': + +if __name__ == "__main__": try_keys() diff --git a/curtsies/fmtfuncs.py b/curtsies/fmtfuncs.py index 6f15d5b..1bcf15f 100644 --- a/curtsies/fmtfuncs.py +++ b/curtsies/fmtfuncs.py @@ -1,26 +1,30 @@ from functools import partial as _partial -from itertools import chain as _chain -from .termformatconstants import FG_COLORS, BG_COLORS, STYLES from .formatstring import fmtstr -for att in _chain(FG_COLORS, ('on_'+x for x in BG_COLORS), STYLES): - locals()[att] = _partial(fmtstr, style=att) -plain = _partial(fmtstr) +black = _partial(fmtstr, style="black") +red = _partial(fmtstr, style="red") +green = _partial(fmtstr, style="green") +yellow = _partial(fmtstr, style="yellow") +blue = _partial(fmtstr, style="blue") +magenta = _partial(fmtstr, style="magenta") +cyan = _partial(fmtstr, style="cyan") +gray = _partial(fmtstr, style="gray") + +on_black = _partial(fmtstr, style="on_black") +on_dark = on_black # deprecated, old name of on_black +on_red = _partial(fmtstr, style="on_red") +on_green = _partial(fmtstr, style="on_green") +on_yellow = _partial(fmtstr, style="on_yellow") +on_blue = _partial(fmtstr, style="on_blue") +on_magenta = _partial(fmtstr, style="on_magenta") +on_cyan = _partial(fmtstr, style="on_cyan") +on_gray = _partial(fmtstr, style="on_gray") -if __name__ == '__main__': - import doctest - doctest.testmod() - print((blue('adf'))) - print((blue(on_red('ad')))) - print((blue('asdf') + on_red('adsf'))) - print(((blue('asdf') + on_red('adsf'))[3:7])) - f = blue('hey there') + on_red(' Tom!') - print(f) - f[1:3] = 'ot' - print((repr(f))) - print(f) - f = on_blue(red('stuff')) - print((repr(f))) - print((repr(str(f)))) - print(f) - print(((f + '!')[0:6] + '?')) +bold = _partial(fmtstr, style="bold") +dark = _partial(fmtstr, style="dark") +italic = _partial(fmtstr, style="italic") +underline = _partial(fmtstr, style="underline") +blink = _partial(fmtstr, style="blink") +invert = _partial(fmtstr, style="invert") + +plain = _partial(fmtstr) diff --git a/curtsies/formatstring.py b/curtsies/formatstring.py index eddb8f9..e24d69c 100644 --- a/curtsies/formatstring.py +++ b/curtsies/formatstring.py @@ -1,4 +1,3 @@ -from __future__ import unicode_literals r"""Colored strings that behave mostly like strings >>> s = fmtstr("Hey there!", 'red') @@ -19,178 +18,336 @@ >>> fmtstr(u'hello', u'red', bold=False) red('hello') """ -#TODO add a way to composite text without losing original formatting information -import itertools import re -import sys -import wcwidth - -from .escseqparse import parse -from .termformatconstants import (FG_COLORS, BG_COLORS, STYLES, - FG_NUMBER_TO_COLOR, BG_NUMBER_TO_COLOR, - RESET_ALL, RESET_BG, RESET_FG, - seq) - -PY3 = sys.version_info[0] >= 3 - -if PY3: - unicode = str - -xforms = { - 'fg' : lambda s, v: '%s%s%s' % (seq(v), s, seq(RESET_FG)), - 'bg' : lambda s, v: seq(v)+s+seq(RESET_BG), - 'bold' : lambda s: seq(STYLES['bold']) +s+seq(RESET_ALL), - 'underline' : lambda s: seq(STYLES['underline'])+s+seq(RESET_ALL), - 'blink' : lambda s: seq(STYLES['blink']) +s+seq(RESET_ALL), - 'invert' : lambda s: seq(STYLES['invert']) +s+seq(RESET_ALL), +from cwcwidth import wcswidth, wcwidth +from functools import cached_property +from itertools import chain +from typing import ( + Any, + Dict, + List, + Optional, + Tuple, + Union, + cast, + no_type_check, +) +from collections.abc import Callable, Iterable, Iterator, Mapping, MutableMapping + +from .escseqparse import parse, remove_ansi +from .termformatconstants import ( + FG_COLORS, + BG_COLORS, + STYLES, + FG_NUMBER_TO_COLOR, + BG_NUMBER_TO_COLOR, + RESET_ALL, + RESET_BG, + RESET_FG, + seq, +) + +one_arg_xforms: Mapping[str, Callable[[str], str]] = { + "bold": lambda s: seq(STYLES["bold"]) + s + seq(RESET_ALL), + "dark": lambda s: seq(STYLES["dark"]) + s + seq(RESET_ALL), + "italic": lambda s: seq(STYLES["italic"]) + s + seq(RESET_ALL), + "underline": lambda s: seq(STYLES["underline"]) + s + seq(RESET_ALL), + "blink": lambda s: seq(STYLES["blink"]) + s + seq(RESET_ALL), + "invert": lambda s: seq(STYLES["invert"]) + s + seq(RESET_ALL), +} + +two_arg_xforms: Mapping[str, Callable[[str, int], str]] = { + "fg": lambda s, v: f"{seq(v)}{s}{seq(RESET_FG)}", + "bg": lambda s, v: seq(v) + s + seq(RESET_BG), } -class FrozenDict(dict): - """Immutable dictionary class""" - def __setitem__(self, key, value): +class FrozenAttributes(dict[str, Union[int, bool]]): + """Immutable dictionary class for format string attributes""" + + def __setitem__(self, key: str, value: int | bool) -> None: raise Exception("Cannot change value.") - def update(self, dictlike): + + def update(self, *args: Any, **kwds: Any) -> None: raise Exception("Cannot change value.") - def extend(self, dictlike): - return FrozenDict(itertools.chain(self.items(), dictlike.items())) + def extend(self, dictlike: Mapping[str, int | bool]) -> "FrozenAttributes": + return FrozenAttributes(chain(self.items(), dictlike.items())) - def remove(self, *keys): - return FrozenDict((k, v) for k, v in self.items() if k not in keys) + def remove(self, *keys: str) -> "FrozenAttributes": + return FrozenAttributes((k, v) for k, v in self.items() if k not in keys) -class Chunk(object): +def stable_format_dict(d: Mapping) -> str: + """A sorted, python2/3 stable formatting of a dictionary. + + Does not work for dicts with unicode strings as values.""" + inner = ", ".join( + "{}: {}".format( + ( + repr(k)[1:] + if repr(k).startswith("u'") or repr(k).startswith('u"') + else repr(k) + ), + v, + ) + for k, v in sorted(d.items()) + ) + return "{%s}" % inner + + +class Chunk: """A string with a single set of formatting attributes Subject to change, not part of the API""" - def __init__(self, string, atts=()): - if not isinstance(string, unicode): + + def __init__(self, string: str, atts: Mapping[str, int | bool] | None = None): + if not isinstance(string, str): raise ValueError("unicode string required, got %r" % string) self._s = string - self._atts = FrozenDict(atts) + self._atts = FrozenAttributes(atts if atts else {}) - s = property(lambda self: self._s) # resist changes to s and atts - atts = property(lambda self: self._atts, - None, None, - "Attributes, e.g. {'fg': 34, 'bold': True} where 34 is the escape code for ...") + @property + def s(self) -> str: + return self._s + + @property + def atts(self) -> FrozenAttributes: + "Attributes, e.g. {'fg': 34, 'bold': True} where 34 is the escape code for ..." + return self._atts - def __len__(self): + def __len__(self) -> int: return len(self._s) @property - def width(self): - width = wcwidth.wcswidth(self._s) + def width(self) -> int: + width = wcswidth(self._s) if len(self._s) > 0 and width < 1: raise ValueError("Can't calculate width of string %r" % self._s) return width - #TODO cache this - @property - def color_str(self): + @cached_property + def color_str(self) -> str: "Return an escape-coded string to write to the terminal." - s = self.s - for k, v in sorted(self.atts.items()): + s = self._s + for k, v in sorted(self._atts.items()): # (self.atts sorted for the sake of always acting the same.) - assert k in xforms, "XXX Do we actually get cases like this?" - if v is False: + if k not in one_arg_xforms and k not in two_arg_xforms: + # Unsupported SGR code + continue + elif v is False: continue - elif v is True: - s = xforms[k](s) + elif k in one_arg_xforms: + s = one_arg_xforms[k](s) else: - s = xforms[k](s, v) + s = two_arg_xforms[k](s, v) return s - def __unicode__(self): + def __str__(self) -> str: value = self.color_str if isinstance(value, bytes): - return value.decode('utf8', 'replace') + return value.decode("utf8", "replace") return value - def __eq__(self, other): - return self.s == other.s and self.atts == other.atts - # TODO: corresponding hash method + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Chunk): + return NotImplemented + return self._s == other._s and self._atts == other._atts + + def __hash__(self) -> int: + return hash((self._s, self._atts)) + + def __repr__(self) -> str: + return "Chunk({s}{sep}{atts})".format( + s=repr(self._s), + sep=", " if self._atts else "", + atts=stable_format_dict(self._atts) if self._atts else "", + ) + + def repr_part(self) -> str: + """FmtStr repr is build by concatenating these.""" + + def pp_att(att: str) -> str: + if att == "fg": + return FG_NUMBER_TO_COLOR[self.atts[att]] + elif att == "bg": + return "on_" + BG_NUMBER_TO_COLOR[self.atts[att]] + else: + return att - if PY3: - __str__ = __unicode__ - else: - def __str__(self): - return unicode(self).encode('utf8') - - def __repr__(self): - def pp_att(att): - if att == 'fg': return FG_NUMBER_TO_COLOR[self.atts[att]] - elif att == 'bg': return 'on_' + BG_NUMBER_TO_COLOR[self.atts[att]] - else: return att - atts_out = dict((k, v) for (k, v) in self.atts.items() if v) - return (''.join(pp_att(att)+'(' for att in sorted(atts_out)) - + (repr(self.s) if PY3 else repr(self.s)[1:]) + ')'*len(atts_out)) - - -class FmtStr(object): - """ A string whose substrings carry attributes (which may be different from one to the next). """ - def __init__(self, *components): + atts_out = {k: v for (k, v) in self._atts.items() if v} + return ( + "".join(pp_att(att) + "(" for att in sorted(atts_out)) + + repr(self._s) + + ")" * len(atts_out) + ) + + def splitter(self) -> "ChunkSplitter": + """ + Returns a view of this Chunk from which new Chunks can be requested. + """ + return ChunkSplitter(self) + + +class ChunkSplitter: + """ + View of a Chunk for breaking it into smaller Chunks. + """ + + def __init__(self, chunk: Chunk) -> None: + self.reinit(chunk) + + def reinit(self, chunk: Chunk) -> None: + """Reuse an existing Splitter instance for speed.""" + # TODO benchmark to prove this is worthwhile + self.chunk = chunk + self.internal_offset = 0 # index into chunk.s + self.internal_width = 0 # width of chunks.s[:self.internal_offset] + divides = [0] + for c in self.chunk.s: + divides.append(divides[-1] + wcwidth(c)) + self.divides = divides + + def request(self, max_width: int) -> tuple[int, Chunk] | None: + """Requests a sub-chunk of max_width or shorter. Returns None if no chunks left.""" + if max_width < 1: + raise ValueError("requires positive integer max_width") + + s = self.chunk.s + length = len(s) + + if self.internal_offset == len(s): + return None + + width = 0 + start_offset = i = self.internal_offset + replacement_char = " " + + while True: + w = wcswidth(s[i], None) + + # If adding a character puts us over the requested width, return what we've got so far + if width + w > max_width: + self.internal_offset = i # does not include ith character + self.internal_width += width + + # if not adding it us makes us short, this must have been a double-width character + if width < max_width: + assert ( + width + 1 == max_width + ), "unicode character width of more than 2!?!" + assert w == 2, "unicode character of width other than 2?" + return ( + width + 1, + Chunk( + s[start_offset : self.internal_offset] + replacement_char, + atts=self.chunk.atts, + ), + ) + return ( + width, + Chunk(s[start_offset : self.internal_offset], atts=self.chunk.atts), + ) + # otherwise add this width + width += w + + # If one more char would put us over, return whatever we've got + if i + 1 == length: + self.internal_offset = ( + i + 1 + ) # beware the fencepost, i is an index not an offset + self.internal_width += width + return ( + width, + Chunk(s[start_offset : self.internal_offset], atts=self.chunk.atts), + ) + # otherwise attempt to add the next character + i += 1 + + +class FmtStr: + """A string whose substrings carry attributes.""" + + def __init__(self, *components: Chunk) -> None: # These assertions below could be useful for debugging, but slow things down considerably - #assert all([len(x) > 0 for x in components]) - #self.basefmtstrs = [x for x in components if len(x) > 0] - self.basefmtstrs = list(components) - - # caching these leads tom a significant speedup - self._str = None - self._unicode = None - self._len = None - self._s = None - self._width = None - - @classmethod - def from_str(cls, s): + # assert all([len(x) > 0 for x in components]) + # self.chunks = [x for x in components if len(x) > 0] + self.chunks = list(components) + + # caching these leads to a significant speedup + self._unicode: str | None = None + self._len: int | None = None + self._s: str | None = None + self._width: int | None = None + + @staticmethod + def from_str(s: str) -> "FmtStr": r""" + Return a FmtStr representing input. + + The str() of a FmtStr is guaranteed to produced the same FmtStr. + Other input with escape sequences may not be preserved. + >>> fmtstr("|"+fmtstr("hey", fg='red', bg='blue')+"|") '|'+on_blue(red('hey'))+'|' >>> fmtstr('|\x1b[31m\x1b[44mhey\x1b[49m\x1b[39m|') '|'+on_blue(red('hey'))+'|' """ - if '\x1b[' in s: - tokens_and_strings = parse(s) - bases = [] - cur_fmt = {} - for x in tokens_and_strings: - if isinstance(x, dict): - cur_fmt.update(x) - elif isinstance(x, (bytes, unicode)): - atts = parse_args('', dict((k, v) for k,v in cur_fmt.items() if v is not None)) - bases.append(Chunk(x, atts=atts)) - else: - raise Exception("logic error") - return FmtStr(*bases) + if "\x1b[" in s: + try: + tokens_and_strings = parse(s) + except ValueError: + return FmtStr(Chunk(remove_ansi(s))) + else: + chunks = [] + cur_fmt = {} + for x in tokens_and_strings: + if isinstance(x, dict): + cur_fmt.update(x) + elif isinstance(x, str): + atts = parse_args( + (), {k: v for k, v in cur_fmt.items() if v is not None} + ) + chunks.append(Chunk(x, atts=atts)) + else: + raise TypeError(f"Expected dict or str, not {type(x)}") + return FmtStr(*chunks) else: return FmtStr(Chunk(s)) - def copy_with_new_str(self, new_str): + def copy_with_new_str(self, new_str: str) -> "FmtStr": """Copies the current FmtStr's attributes while changing its string.""" # What to do when there are multiple Chunks with conflicting atts? - old_atts = dict((att, value) for bfs in self.basefmtstrs - for (att, value) in bfs.atts.items()) + old_atts = { + att: value for bfs in self.chunks for (att, value) in bfs.atts.items() + } return FmtStr(Chunk(new_str, old_atts)) - def setitem(self, startindex, fs): + def setitem(self, startindex: int, fs: Union[str, "FmtStr"]) -> "FmtStr": """Shim for easily converting old __setitem__ calls""" - return self.setslice_with_length(startindex, startindex+1, fs, len(self)) + return self.setslice_with_length(startindex, startindex + 1, fs, len(self)) - def setslice_with_length(self, startindex, endindex, fs, length): + def setslice_with_length( + self, startindex: int, endindex: int, fs: Union[str, "FmtStr"], length: int + ) -> "FmtStr": """Shim for easily converting old __setitem__ calls""" if len(self) < startindex: - fs = ' '*(startindex - len(self)) + fs + fs = " " * (startindex - len(self)) + fs if len(self) > endindex: - fs = fs + ' '*(endindex - startindex - len(fs)) + fs = fs + " " * (endindex - startindex - len(fs)) assert len(fs) == endindex - startindex, (len(fs), startindex, endindex) result = self.splice(fs, startindex, endindex) - assert len(result) <= length + if len(result) > length: + raise ValueError( + "Your change is resulting in a longer fmtstr than the original length and this is not supported." + ) return result - def splice(self, new_str, start, end=None): + def splice( + self, new_str: Union[str, "FmtStr"], start: int, end: int | None = None + ) -> "FmtStr": """Returns a new FmtStr with the input string spliced into the the original FmtStr at start and end. If end is provided, new_str will replace the substring self.s[start:end-1]. @@ -198,280 +355,343 @@ def splice(self, new_str, start, end=None): if len(new_str) == 0: return self new_fs = new_str if isinstance(new_str, FmtStr) else fmtstr(new_str) - assert len(new_fs.basefmtstrs) > 0, (new_fs.basefmtstrs, new_fs) + assert len(new_fs.chunks) > 0, (new_fs.chunks, new_fs) new_components = [] inserted = False if end is None: end = start tail = None - for bfs, bfs_start, bfs_end in zip(self.basefmtstrs, - self.divides[:-1], - self.divides[1:]): + for bfs, bfs_start, bfs_end in zip( + self.chunks, self.divides[:-1], self.divides[1:] + ): if end == bfs_start == 0: - new_components.extend(new_fs.basefmtstrs) + new_components.extend(new_fs.chunks) new_components.append(bfs) inserted = True elif bfs_start <= start < bfs_end: divide = start - bfs_start head = Chunk(bfs.s[:divide], atts=bfs.atts) - tail = Chunk(bfs.s[end - bfs_start:], atts=bfs.atts) - new_components.extend([head] + new_fs.basefmtstrs) + tail = Chunk(bfs.s[end - bfs_start :], atts=bfs.atts) + new_components.extend([head] + new_fs.chunks) inserted = True if bfs_start < end < bfs_end: - tail = Chunk(bfs.s[end - bfs_start:], atts=bfs.atts) + tail = Chunk(bfs.s[end - bfs_start :], atts=bfs.atts) new_components.append(tail) elif bfs_start < end < bfs_end: divide = start - bfs_start - tail = Chunk(bfs.s[end - bfs_start:], atts=bfs.atts) + tail = Chunk(bfs.s[end - bfs_start :], atts=bfs.atts) new_components.append(tail) elif bfs_start >= end or bfs_end <= start: new_components.append(bfs) if not inserted: - new_components.extend(new_fs.basefmtstrs) + new_components.extend(new_fs.chunks) inserted = True - return FmtStr(*[s for s in new_components if s.s]) + return FmtStr(*(s for s in new_components if s.s)) - def append(self, string): + def append(self, string: Union[str, "FmtStr"]) -> "FmtStr": return self.splice(string, len(self.s)) - def copy_with_new_atts(self, **attributes): + def copy_with_new_atts(self, **attributes: bool | int) -> "FmtStr": """Returns a new FmtStr with the same content but new formatting""" - return FmtStr(*[Chunk(bfs.s, bfs.atts.extend(attributes)) - for bfs in self.basefmtstrs]) - def join(self, iterable): + return FmtStr( + *(Chunk(bfs.s, bfs.atts.extend(attributes)) for bfs in self.chunks) + ) + + def join(self, iterable: Iterable[Union[str, "FmtStr"]]) -> "FmtStr": """Joins an iterable yielding strings or FmtStrs with self as separator""" - before = [] - basefmtstrs = [] - for i, s in enumerate(iterable): - basefmtstrs.extend(before) - before = self.basefmtstrs + before: list[Chunk] = [] + chunks: list[Chunk] = [] + for s in iterable: + chunks.extend(before) + before = self.chunks if isinstance(s, FmtStr): - basefmtstrs.extend(s.basefmtstrs) - elif isinstance(s, (bytes, unicode)): - basefmtstrs.extend(fmtstr(s).basefmtstrs) #TODO just make a basefmtstr directly + chunks.extend(s.chunks) + elif isinstance(s, (bytes, str)): + chunks.extend(fmtstr(s).chunks) # TODO just make a chunk directly else: raise TypeError("expected str or FmtStr, %r found" % type(s)) - return FmtStr(*basefmtstrs) + return FmtStr(*chunks) # TODO make this split work like str.split - def split(self, sep=None, maxsplit=None, regex=False): - """Split based on seperator, optionally using a regex + def split( + self, + sep: str | None = None, + maxsplit: int | None = None, + regex: bool = False, + ) -> list["FmtStr"]: + """Split based on separator, optionally using a regex. Capture groups are ignored in regex, the whole pattern is matched and used to split the original FmtStr.""" if maxsplit is not None: - raise NotImplementedError('no maxsplit yet') + raise NotImplementedError("no maxsplit yet") s = self.s if sep is None: - sep = r'\s+' + sep = r"\s+" elif not regex: sep = re.escape(sep) matches = list(re.finditer(sep, s)) - return [self[start:end] for start, end in zip( - [0] + [m.end() for m in matches], - [m.start() for m in matches] + [len(s)])] + return [ + self[start:end] + for start, end in zip( + chain((0,), (m.end() for m in matches)), + chain((m.start() for m in matches), (len(s),)), + ) + ] + + def splitlines(self, keepends: bool = False) -> list["FmtStr"]: + """Return a list of lines, split on newline characters, + include line boundaries, if keepends is true.""" + lines = self.split("\n") + return ( + [line + "\n" for line in lines] + if keepends + else (lines if lines[-1] else lines[:-1]) + ) # proxying to the string via __getattr__ is insufficient # because we shouldn't drop foreground or formatting info - def ljust(self, width, fillchar=None): + def ljust(self, width: int, fillchar: str | None = None) -> "FmtStr": """S.ljust(width[, fillchar]) -> string If a fillchar is provided, less formatting information will be preserved """ if fillchar is not None: return fmtstr(self.s.ljust(width, fillchar), **self.shared_atts) - to_add = ' ' * (width - len(self.s)) + to_add = " " * (width - len(self.s)) shared = self.shared_atts - if 'bg' in shared: - return self + fmtstr(to_add, bg=shared[str('bg')]) if to_add else self + if "bg" in shared: + return self + fmtstr(to_add, bg=shared["bg"]) if to_add else self else: - uniform = self.new_with_atts_removed('bg') + uniform = self.new_with_atts_removed("bg") return uniform + fmtstr(to_add, **self.shared_atts) if to_add else uniform - def rjust(self, width, fillchar=None): + def rjust(self, width: int, fillchar: str | None = None) -> "FmtStr": """S.rjust(width[, fillchar]) -> string If a fillchar is provided, less formatting information will be preserved """ if fillchar is not None: return fmtstr(self.s.rjust(width, fillchar), **self.shared_atts) - to_add = ' ' * (width - len(self.s)) + to_add = " " * (width - len(self.s)) shared = self.shared_atts - if 'bg' in shared: - return fmtstr(to_add, bg=shared[str('bg')]) + self if to_add else self + if "bg" in shared: + return fmtstr(to_add, bg=shared["bg"]) + self if to_add else self else: - uniform = self.new_with_atts_removed('bg') + uniform = self.new_with_atts_removed("bg") return fmtstr(to_add, **self.shared_atts) + uniform if to_add else uniform - def __unicode__(self): + def __str__(self) -> str: if self._unicode is not None: return self._unicode - self._unicode = ''.join(unicode(fs) for fs in self.basefmtstrs) + self._unicode = "".join(str(fs) for fs in self.chunks) return self._unicode - if PY3: - __str__ = __unicode__ - else: - def __str__(self): - if self._str is not None: - return self._str - self._str = str('').join(str(fs) for fs in self.basefmtstrs) - return self._str - - def __len__(self): + def __len__(self) -> int: if self._len is not None: return self._len - self._len = sum(len(fs) for fs in self.basefmtstrs) - return self._len + value = sum(len(fs) for fs in self.chunks) + self._len = value + return value @property - def width(self): - """The number of columns it would take to display this string""" + def width(self) -> int: + """The number of columns it would take to display this string.""" if self._width is not None: return self._width - self._width = sum(fs.width for fs in self.basefmtstrs) - return self._width + value = sum(fs.width for fs in self.chunks) + self._width = value + return value - def width_at_offset(self, n): + def width_at_offset(self, n: int) -> int: """Returns the horizontal position of character n of the string""" - #TODO make more efficient? - width = wcwidth.wcswidth(self.s[:n]) + # TODO make more efficient? + width = wcswidth(self.s, n) assert width != -1 return width + def __repr__(self) -> str: + return "+".join(fs.repr_part() for fs in self.chunks) - def __repr__(self): - return '+'.join(repr(fs) for fs in self.basefmtstrs) - - def __eq__(self, other): - if isinstance(other, (unicode, bytes, FmtStr)): + def __eq__(self, other: Any) -> bool: + if isinstance(other, (str, bytes, FmtStr)): return str(self) == str(other) - return False - # TODO corresponding hash method + return NotImplemented - def __add__(self, other): + def __hash__(self) -> int: + return hash(str(self)) + + def __add__(self, other: Union["FmtStr", str]) -> "FmtStr": if isinstance(other, FmtStr): - return FmtStr(*(self.basefmtstrs + other.basefmtstrs)) - elif isinstance(other, (bytes, unicode)): - return FmtStr(*(self.basefmtstrs + [Chunk(other)])) - else: - raise TypeError('Can\'t add %r and %r' % (self, other)) + return FmtStr(*(self.chunks + other.chunks)) + elif isinstance(other, (bytes, str)): + return FmtStr(*(self.chunks + [Chunk(other)])) - def __radd__(self, other): + return NotImplemented + + def __radd__(self, other: Union["FmtStr", str]) -> "FmtStr": if isinstance(other, FmtStr): - return FmtStr(*(x for x in (other.basefmtstrs + self.basefmtstrs))) - elif isinstance(other, (bytes, unicode)): - return FmtStr(*(x for x in ([Chunk(other)] + self.basefmtstrs))) - else: - raise TypeError('Can\'t add those') + return FmtStr(*(x for x in (other.chunks + self.chunks))) + elif isinstance(other, (bytes, str)): + return FmtStr(*(x for x in ([Chunk(other)] + self.chunks))) - def __mul__(self, other): + return NotImplemented + + def __mul__(self, other: int) -> "FmtStr": if isinstance(other, int): - return sum([self for _ in range(other)], FmtStr()) - raise TypeError('Can\'t mulitply those') - #TODO ensure emtpy FmtStr isn't a problem + return sum((self for _ in range(other)), FmtStr()) + + return NotImplemented + + # TODO ensure empty FmtStr isn't a problem @property - def shared_atts(self): - """Gets atts shared among all nonzero length component Chunk""" - #TODO cache this, could get ugly for large FmtStrs + def shared_atts(self) -> dict[str, int | bool]: + """Gets atts shared among all nonzero length component Chunks""" + # TODO cache this, could get ugly for large FmtStrs atts = {} - first = self.basefmtstrs[0] + first = self.chunks[0] for att in sorted(first.atts): - #TODO how to write this without the '???'? - if all(fs.atts.get(att, '???') == first.atts[att] for fs in self.basefmtstrs if len(fs) > 0): + # TODO how to write this without the '???'? + if all( + fs.atts.get(att, "???") == first.atts[att] + for fs in self.chunks + if len(fs) > 0 + ): atts[att] = first.atts[att] return atts - def new_with_atts_removed(self, *attributes): + def new_with_atts_removed(self, *attributes: str) -> "FmtStr": """Returns a new FmtStr with the same content but some attributes removed""" - return FmtStr(*[Chunk(bfs.s, bfs.atts.remove(*attributes)) - for bfs in self.basefmtstrs]) - def __getattr__(self, att): + result = FmtStr(*(Chunk(bfs.s, bfs.atts.remove(*attributes)) for bfs in self.chunks)) # type: ignore + return result + + @no_type_check + def __getattr__(self, att: str): # thanks to @aerenchyma/@jczett if not hasattr(self.s, att): - raise AttributeError("No attribute %r" % (att,)) + raise AttributeError(f"No attribute {att!r}") + + @no_type_check def func_help(*args, **kwargs): - result = getattr(self.s, att)(*args, **kwargs) - if isinstance(result, (bytes, unicode)): - return fmtstr(result, **self.shared_atts) - elif isinstance(result, list): - return [fmtstr(x, **self.shared_atts) for x in result] - else: - return result + result = getattr(self.s, att)(*args, **kwargs) + if isinstance(result, (bytes, str)): + return fmtstr(result, **self.shared_atts) + elif isinstance(result, list): + return [fmtstr(x, **self.shared_atts) for x in result] + else: + return result + return func_help @property - def divides(self): - """List of indices of divisions between the constituent basefmtstrs""" + def divides(self) -> list[int]: + """List of indices of divisions between the constituent chunks.""" acc = [0] - for s in self.basefmtstrs: + for s in self.chunks: acc.append(acc[-1] + len(s)) return acc @property - def s(self): + def s(self) -> str: if self._s is not None: return self._s - self._s = "".join(fs.s for fs in self.basefmtstrs) + self._s = "".join(fs.s for fs in self.chunks) return self._s - def __getitem__(self, index): + def __getitem__(self, index: int | slice) -> "FmtStr": index = normalize_slice(len(self), index) counter = 0 parts = [] - for chunk in self.basefmtstrs: + for chunk in self.chunks: if index.start < counter + len(chunk) and index.stop > counter: start = max(0, index.start - counter) end = min(index.stop - counter, len(chunk)) if end - start == len(chunk): parts.append(chunk) else: - s_part = chunk.s[max(0, index.start - counter): index.stop - counter] + s_part = chunk.s[ + max(0, index.start - counter) : index.stop - counter + ] parts.append(Chunk(s_part, chunk.atts)) counter += len(chunk) if index.stop < counter: break - return FmtStr(*parts) if parts else fmtstr('') + return FmtStr(*parts) if parts else fmtstr("") - def width_aware_slice(self, index): - """Slice based on the number of columns it would take to display the substring""" - if wcwidth.wcswidth(self.s) == -1: - raise ValueError('bad values for width aware slicing') + def width_aware_slice(self, index: int | slice) -> "FmtStr": + """Slice based on the number of columns it would take to display the substring.""" + if wcswidth(self.s, None) == -1: + raise ValueError("bad values for width aware slicing") index = normalize_slice(self.width, index) counter = 0 parts = [] - for chunk in self.basefmtstrs: + for chunk in self.chunks: if index.start < counter + chunk.width and index.stop > counter: start = max(0, index.start - counter) end = min(index.stop - counter, chunk.width) if end - start == chunk.width: parts.append(chunk) else: - s_part = width_aware_slice(chunk.s, max(0, index.start - counter), index.stop - counter) + s_part = width_aware_slice( + chunk.s, max(0, index.start - counter), index.stop - counter + ) parts.append(Chunk(s_part, chunk.atts)) counter += chunk.width if index.stop < counter: break - return FmtStr(*parts) if parts else fmtstr('') + return FmtStr(*parts) if parts else fmtstr("") + + def width_aware_splitlines(self, columns: int) -> Iterator["FmtStr"]: + """Split into lines, pushing doublewidth characters at the end of a line to the next line. - def _getitem_normalized(self, index): + When a double-width character is pushed to the next line, a space is added to pad out the line. + """ + if columns < 2: + raise ValueError("Column width %s is too narrow." % columns) + if wcswidth(self.s, None) == -1: + raise ValueError("bad values for width aware slicing") + return self._width_aware_splitlines(columns) + + def _width_aware_splitlines(self, columns: int) -> Iterator["FmtStr"]: + splitter = self.chunks[0].splitter() + chunks_of_line = [] + width_of_line = 0 + for source_chunk in self.chunks: + splitter.reinit(source_chunk) + while True: + request = splitter.request(columns - width_of_line) + if request is None: + break # done with this source_chunk + w, new_chunk = request + chunks_of_line.append(new_chunk) + width_of_line += w + + if width_of_line == columns: + yield FmtStr(*chunks_of_line) + del chunks_of_line[:] + width_of_line = 0 + + if chunks_of_line: + yield FmtStr(*chunks_of_line) + + def _getitem_normalized(self, index: int | slice) -> "FmtStr": """Builds the more compact fmtstrs by using fromstr( of the control sequences)""" index = normalize_slice(len(self), index) counter = 0 - output = '' - for fs in self.basefmtstrs: + output = "" + for fs in self.chunks: if index.start < counter + len(fs) and index.stop > counter: - s_part = fs.s[max(0, index.start - counter):index.stop - counter] + s_part = fs.s[max(0, index.start - counter) : index.stop - counter] piece = Chunk(s_part, fs.atts).color_str output += piece counter += len(fs) @@ -479,14 +699,14 @@ def _getitem_normalized(self, index): break return fmtstr(output) - def __setitem__(self, index, value): + def __setitem__(self, index: int, value: Any) -> None: raise Exception("No!") - def copy(self): - return FmtStr(*self.basefmtstrs) + def copy(self) -> "FmtStr": + return FmtStr(*self.chunks) -def interval_overlap(a, b, x, y): +def interval_overlap(a: int, b: int, x: int, y: int) -> int: """Returns by how much two intervals overlap assumed that a <= b and x <= y""" @@ -502,20 +722,31 @@ def interval_overlap(a, b, x, y): assert False -def width_aware_slice(s, start, end, replacement_char=u' '): - divides = [wcwidth.wcswidth(s, i) for i in range(len(s)+1)] +def width_aware_slice(s: str, start: int, end: int, replacement_char: str = " ") -> str: + """ + >>> width_aware_slice(u'a\uff25iou', 0, 2)[1] == u' ' + True + """ + divides = [0] + for c in s: + divides.append(divides[-1] + wcwidth(c)) new_chunk_chars = [] for char, char_start, char_end in zip(s, divides[:-1], divides[1:]): - if char_start >= start and char_end <= end: + if char_start == start and char_end == start: + continue # don't use zero-width characters at the beginning of a slice + # (combining characters combine with the chars before themselves) + elif char_start >= start and char_end <= end: new_chunk_chars.append(char) else: - new_chunk_chars.extend(replacement_char * interval_overlap(char_start, char_end, start, end)) + new_chunk_chars.extend( + replacement_char * interval_overlap(char_start, char_end, start, end) + ) - return ''.join(new_chunk_chars) + return "".join(new_chunk_chars) -def linesplit(string, columns): +def linesplit(string: str | FmtStr, columns: int) -> list[FmtStr]: """Returns a list of lines, split on the last possible space of each line. Split spaces will be removed. Whitespaces will be normalized to one space. @@ -530,78 +761,98 @@ def linesplit(string, columns): string = fmtstr(string) string_s = string.s - matches = list(re.finditer(r'\s+', string_s)) - spaces = [string[m.start():m.end()] for m in matches if m.start() != 0 and m.end() != len(string_s)] - words = [string[start:end] for start, end in zip( + matches = list(re.finditer(r"\s+", string_s)) + spaces = [ + string[m.start() : m.end()] + for m in matches + if m.start() != 0 and m.end() != len(string_s) + ] + words = [ + string[start:end] + for start, end in zip( [0] + [m.end() for m in matches], - [m.start() for m in matches] + [len(string_s)]) if start != end] + [m.start() for m in matches] + [len(string_s)], + ) + if start != end + ] - word_to_lines = lambda word: [word[columns*i:columns*(i+1)] for i in range((len(word) - 1) // columns + 1)] + word_to_lines = lambda word: [ + word[columns * i : columns * (i + 1)] + for i in range((len(word) - 1) // columns + 1) + ] lines = word_to_lines(words[0]) for word, space in zip(words[1:], spaces): if len(lines[-1]) + len(word) < columns: - lines[-1] += fmtstr(' ', **space.shared_atts) + lines[-1] += fmtstr(" ", **space.shared_atts) lines[-1] += word else: lines.extend(word_to_lines(word)) return lines -def normalize_slice(length, index): + +def normalize_slice(length: int, index: int | slice) -> slice: "Fill in the Nones in a slice." is_int = False if isinstance(index, int): is_int = True - index = slice(index, index+1) + index = slice(index, index + 1) if index.start is None: index = slice(0, index.stop, index.step) if index.stop is None: index = slice(index.start, length, index.step) - if index.start < -1: # XXX why must this be -1? + if index.start < -1: # XXX why must this be -1? index = slice(length - index.start, index.stop, index.step) - if index.stop < -1: # XXX why must this be -1? + if index.stop < -1: # XXX why must this be -1? index = slice(index.start, length - index.stop, index.step) if index.step is not None: raise NotImplementedError("You can't use steps with slicing yet") if is_int: if index.start < 0 or index.start > length: - raise IndexError("index out of bounds: %r for length %s" % (index, length)) + raise IndexError(f"index out of bounds: {index!r} for length {length}") return index -def parse_args(args, kwargs): + +def parse_args( + args: tuple[str, ...], + kwargs: MutableMapping[str, int | bool | str], +) -> MutableMapping[str, int | bool]: """Returns a kwargs dictionary by turning args into kwargs""" - if 'style' in kwargs: - args += (kwargs['style'],) - del kwargs['style'] + if "style" in kwargs: + args += (cast(str, kwargs["style"]),) + del kwargs["style"] for arg in args: - if not isinstance(arg, (bytes, unicode)): - raise ValueError("args must be strings:" + repr(args)) + if not isinstance(arg, str): + raise ValueError(f"args must be strings: {arg!r}") if arg.lower() in FG_COLORS: - if 'fg' in kwargs: raise ValueError("fg specified twice") - kwargs['fg'] = FG_COLORS[arg] - elif arg.lower().startswith('on_') and arg[3:].lower() in BG_COLORS: - if 'bg' in kwargs: raise ValueError("fg specified twice") - kwargs['bg'] = BG_COLORS[arg[3:]] + if "fg" in kwargs: + raise ValueError("fg specified twice") + kwargs["fg"] = FG_COLORS[cast(str, arg)] + elif arg.lower().startswith("on_") and arg[3:].lower() in BG_COLORS: + if "bg" in kwargs: + raise ValueError("fg specified twice") + kwargs["bg"] = BG_COLORS[cast(str, arg[3:])] elif arg.lower() in STYLES: kwargs[arg] = True else: - raise ValueError("couldn't process arg: "+repr(arg)) + raise ValueError(f"couldn't process arg: {args!r}") for k in kwargs: - if k not in ['fg', 'bg'] + list(STYLES.keys()): + if k not in ("fg", "bg") and k not in STYLES.keys(): raise ValueError("Can't apply that transformation") - if 'fg' in kwargs: - if kwargs['fg'] in FG_COLORS: - kwargs['fg'] = FG_COLORS[kwargs['fg']] - if kwargs['fg'] not in list(FG_COLORS.values()): - raise ValueError("Bad fg value: %r" % kwargs['fg']) - if 'bg' in kwargs: - if kwargs['bg'] in BG_COLORS: - kwargs['bg'] = BG_COLORS[kwargs['bg']] - if kwargs['bg'] not in list(BG_COLORS.values()): - raise ValueError("Bad bg value: %r" % kwargs['bg']) - return kwargs - -def fmtstr(string, *args, **kwargs): + if "fg" in kwargs: + if kwargs["fg"] in FG_COLORS: + kwargs["fg"] = FG_COLORS[cast(str, kwargs["fg"])] + if kwargs["fg"] not in list(FG_COLORS.values()): + raise ValueError(f"Bad fg value: {kwargs['fg']!r}") + if "bg" in kwargs: + if kwargs["bg"] in BG_COLORS: + kwargs["bg"] = BG_COLORS[cast(str, kwargs["bg"])] + if kwargs["bg"] not in list(BG_COLORS.values()): + raise ValueError(f"Bad bg value: {kwargs['bg']!r}") + return cast(MutableMapping[str, Union[int, bool]], kwargs) + + +def fmtstr(string: str | FmtStr, *args: Any, **kwargs: Any) -> FmtStr: """ Convenience function for creating a FmtStr @@ -611,19 +862,10 @@ def fmtstr(string, *args, **kwargs): on_red(bold(blue('blarg'))) """ atts = parse_args(args, kwargs) - if isinstance(string, FmtStr): - pass - elif isinstance(string, (bytes, unicode)): + if isinstance(string, str): string = FmtStr.from_str(string) - else: - raise ValueError("Bad Args: %r (of type %s), %r, %r" % (string, type(string), args, kwargs)) + elif not isinstance(string, FmtStr): + raise ValueError( + f"Bad Args: {string!r} (of type {type(string)}), {args!r}, {kwargs!r}" + ) return string.copy_with_new_atts(**atts) - -if __name__ == '__main__': - import doctest - doctest.testmod(verbose=True) - #f = FmtStr.from_str(str(fmtstr('tom', 'blue'))) - #print((repr(f))) - #f = fmtstr('stuff', fg='blue', bold=True) - #print((repr(f))) - diff --git a/curtsies/formatstringarray.py b/curtsies/formatstringarray.py index fac6387..24c906a 100644 --- a/curtsies/formatstringarray.py +++ b/curtsies/formatstringarray.py @@ -1,4 +1,3 @@ -from __future__ import unicode_literals """ Format String 2D array 2d array for compositing term-formated strings @@ -22,88 +21,107 @@ ['i'] """ +import itertools import sys import logging -import unittest from .formatstring import fmtstr from .formatstring import normalize_slice from .formatstring import FmtStr -logger = logging.getLogger(__name__) +from typing import ( + Any, + Optional, + Union, + List, + overload, + Tuple, + cast, + no_type_check, +) +from collections.abc import Sequence -#TODO check that strings used in arrays don't have tabs or spaces in them! +logger = logging.getLogger(__name__) -def slicesize(s): - return int((s.stop - s.start) / (s.step if s.step else 1)) +# TODO check that strings used in arrays don't have tabs or spaces in them! -def fsarray(strings, *args, **kwargs): - """fsarray(list_of_FmtStrs_or_strings, width=None) -> FSArray - Returns a new FSArray of width of the maximum size of the provided - strings, or width provided, and height of the number of strings provided. - If a width is provided, raises a ValueError if any of the strings - are of length greater than this width""" +def slicesize(s: slice) -> int: + return int((s.stop - s.start) / (s.step if s.step else 1)) - strings = list(strings) - if 'width' in kwargs: - width = kwargs['width'] - del kwargs['width'] - if strings and max(len(s) for s in strings) > width: - raise ValueError("Those strings won't fit for width %d" % width) - else: - width = max(len(s) for s in strings) if strings else 0 - fstrings = [s if isinstance(s, FmtStr) else fmtstr(s, *args, **kwargs) for s in strings] - arr = FSArray(len(fstrings), width, *args, **kwargs) - rows = [fs.setslice_with_length(0, len(s), s, width) for fs, s in zip(arr.rows, fstrings)] - arr.rows = rows - return arr -class FSArray(object): +class FSArray(Sequence): """A 2D array of colored text. Internally represented by a list of FmtStrs of identical size.""" - #TODO add constructor that takes fmtstrs instead of dims - def __init__(self, num_rows, num_columns, *args, **kwargs): + # TODO add constructor that takes fmtstrs instead of dims + def __init__( + self, num_rows: int, num_columns: int, *args: Any, **kwargs: Any + ) -> None: self.saved_args, self.saved_kwargs = args, kwargs - self.rows = [fmtstr('', *args, **kwargs) for _ in range(num_rows)] + self.rows: list[FmtStr] = [fmtstr("", *args, **kwargs) for _ in range(num_rows)] self.num_columns = num_columns - def __getitem__(self, slicetuple): + @overload + def __getitem__(self, slicetuple: int) -> FmtStr: + pass + + @overload + def __getitem__(self, slicetuple: slice) -> list[FmtStr]: + pass + + @overload + def __getitem__(self, slicetuple: tuple[slice | int, slice | int]) -> list[FmtStr]: + pass + + def __getitem__( + self, slicetuple: int | slice | tuple[int | slice, int | slice] + ) -> FmtStr | list[FmtStr]: if isinstance(slicetuple, int): if slicetuple < 0: slicetuple = len(self.rows) - slicetuple if slicetuple < 0 or slicetuple >= len(self.rows): - raise IndexError('out of bounds') + raise IndexError("out of bounds") return self.rows[slicetuple] if isinstance(slicetuple, slice): rowslice = normalize_slice(len(self.rows), slicetuple) return self.rows[rowslice] - rowslice, colslice = slicetuple - rowslice = normalize_slice(len(self.rows), rowslice) - colslice = normalize_slice(self.num_columns, colslice) - #TODO clean up slices + row_slice_or_int, col_slice_or_int = slicetuple + rowslice = normalize_slice(len(self.rows), row_slice_or_int) + colslice = normalize_slice(self.num_columns, col_slice_or_int) + # TODO clean up slices return [fs[colslice] for fs in self.rows[rowslice]] - def __len__(self): + def __len__(self) -> int: return len(self.rows) @property - def shape(self): - """tuple of (len(rows, len(num_columns)) numpy-style shape""" + def shape(self) -> tuple[int, int]: + """Tuple of (len(rows, len(num_columns)) numpy-style shape""" return len(self.rows), self.num_columns - height = property(lambda self: len(self.rows), None, None, """The number of rows""") - width = property(lambda self: self.num_columns, None, None, """The number of columns""") + @property + def height(self) -> int: + """The number of rows""" + return len(self.rows) + + @property + def width(self) -> int: + """The number of columns""" + return self.num_columns + # TODO rework this next major version bump + @no_type_check def __setitem__(self, slicetuple, value): """Place a FSArray in a FSArray""" - logger.debug('slice: %r', slicetuple) + logger.debug("slice: %r", slicetuple) if isinstance(slicetuple, slice): rowslice, colslice = slicetuple, slice(None) - if isinstance(value, (bytes, unicode)): - raise ValueError('if slice is 2D, value must be 2D') + if isinstance(value, str): + raise ValueError( + "if slice is 2D, value must be 2D as in of list type []" + ) elif isinstance(slicetuple, int): normalize_slice(self.height, slicetuple) self.rows[slicetuple] = value @@ -112,46 +130,106 @@ def __setitem__(self, slicetuple, value): rowslice, colslice = slicetuple # temp shim to allow numpy arrays as values - if value.__class__.__name__ == 'ndarray': - value = [fmtstr(''.join(line)) for line in value] + if value.__class__.__name__ == "ndarray": + value = [fmtstr("".join(line)) for line in value] rowslice = normalize_slice(sys.maxsize, rowslice) additional_rows = max(0, rowslice.stop - len(self.rows)) - self.rows.extend([fmtstr('', *self.saved_args, **self.saved_kwargs) - for _ in range(additional_rows)]) - logger.debug('num columns: %r', self.num_columns) - logger.debug('colslice: %r', colslice) + self.rows.extend( + [ + fmtstr("", *self.saved_args, **self.saved_kwargs) + for _ in range(additional_rows) + ] + ) + logger.debug("num columns: %r", self.num_columns) + logger.debug("colslice: %r", colslice) colslice = normalize_slice(self.num_columns, colslice) if slicesize(colslice) == 0 or slicesize(rowslice) == 0: return + if slicesize(colslice) > 1 and isinstance(value, str): + raise ValueError( + """You cannot replace a multi column slice with a + string please use a list [] with strings for the + contents of each row""" + ) + if slicesize(colslice) > 1 and isinstance(value, FmtStr): + raise ValueError( + """You cannot replace a multi column slice with a + formatted string (FmtStr), please use a list [] with strings for the + contents of each row""" + ) if slicesize(rowslice) != len(value): - raise ValueError('row dimensions do not match: %r, %r' % (len(value), rowslice)) - self.rows = (self.rows[:rowslice.start] + - [fs.setslice_with_length(colslice.start, colslice.stop, v, self.num_columns) for fs, v in zip(self.rows[rowslice], value)] + - self.rows[rowslice.stop:]) + area = slicesize(rowslice) * slicesize(colslice) + val_len = sum(len(i) for i in value) + grid_value = [fmtstr(" ", bg="cyan") * slicesize(colslice)] * slicesize( + rowslice + ) + grid_fsarray = ( + self.rows[: rowslice.start] + + [ + fs.setslice_with_length( + colslice.start, colslice.stop, v, self.num_columns + ) + for fs, v in zip(self.rows[rowslice], grid_value) + ] + + self.rows[rowslice.stop :] + ) + msg = "You are trying to fit this value {} into the region {}: {}".format( + fmtstr("".join(value), bg="cyan"), + fmtstr("").join(grid_value), + "\n ".join(grid_fsarray[x] for x in range(len(self.rows))), + ) + raise ValueError( + """Error you are trying to replace a region of {} rows by {} + columns for and area of {} with a value of len {}. The value + used to replace the region must equal the area of the region + replace. + {}""".format( + rowslice.stop - rowslice.start, + colslice.stop - colslice.start, + area, + val_len, + msg, + ) + ) + self.rows = ( + self.rows[: rowslice.start] + + [ + fs.setslice_with_length( + colslice.start, colslice.stop, v, self.num_columns + ) + for fs, v in zip(self.rows[rowslice], value) + ] + + self.rows[rowslice.stop :] + ) - def dumb_display(self): + def dumb_display(self) -> None: """Prints each row followed by a newline without regard for the terminal window size""" for line in self.rows: print(line) @classmethod - def diff(cls, a, b, ignore_formatting=False): + def diff(cls, a: "FSArray", b: "FSArray", ignore_formatting: bool = False) -> str: """Returns two FSArrays with differences underlined""" - def underline(x): return u'\x1b[4m%s\x1b[0m' % (x,) - def blink(x): return u'\x1b[5m%s\x1b[0m' % (x,) + + def underline(x: str) -> str: + return f"\x1b[4m{x}\x1b[0m" + + def blink(x: str) -> str: + return f"\x1b[5m{x}\x1b[0m" + a_rows = [] b_rows = [] - max_width = max([len(row) for row in a] + [len(row) for row in b]) + max_width = max(len(row) for row in itertools.chain(a, b)) a_lengths = [] b_lengths = [] for a_row, b_row in zip(a, b): a_lengths.append(len(a_row)) b_lengths.append(len(b_row)) - extra_a = u'`' * (max_width - len(a_row)) - extra_b = u'`' * (max_width - len(b_row)) - a_line = u'' - b_line = u'' + extra_a = "`" * (max_width - len(a_row)) + extra_b = "`" * (max_width - len(b_row)) + a_line = "" + b_line = "" for a_char, b_char in zip(a_row + extra_a, b_row + extra_b): if ignore_formatting: a_char_for_eval = a_char.s if isinstance(a_char, FmtStr) else a_char @@ -160,43 +238,96 @@ def blink(x): return u'\x1b[5m%s\x1b[0m' % (x,) a_char_for_eval = a_char b_char_for_eval = b_char if a_char_for_eval == b_char_for_eval: - a_line += actualize(a_char) - b_line += actualize(b_char) + a_line += str(a_char) + b_line += str(b_char) else: - a_line += underline(blink(actualize(a_char))) - b_line += underline(blink(actualize(b_char))) + a_line += underline(blink(str(a_char))) + b_line += underline(blink(str(b_char))) a_rows.append(a_line) b_rows.append(b_line) - hdiff = '\n'.join(a_line + u' %3d | %3d ' % (a_len, b_len) + b_line for a_line, b_line, a_len, b_len in zip(a_rows, b_rows, a_lengths, b_lengths)) - return hdiff - -actualize = str if sys.version_info[0] == 3 else unicode - -def simple_format(x): - return '\n'.join(actualize(l) for l in x) - -class FormatStringTest(unittest.TestCase): - def assertFSArraysEqual(self, a, b): - self.assertEqual(type(a), FSArray) - self.assertEqual(type(b), FSArray) - self.assertEqual((a.width, b.height), (a.width, b.height), 'fsarray dimensions do not match: %s %s' % (a.shape, b.shape)) - for i, (a_row, b_row) in enumerate(zip(a, b)): - self.assertEqual(a_row, b_row, 'FSArrays differ first on line %s:\n%s' % (i, FSArray.diff(a, b))) - - def assertFSArraysEqualIgnoringFormatting(self, a, b): - """Also accepts arrays of strings""" - self.assertEqual(len(a), len(b), 'fsarray heights do not match: %s %s \n%s \n%s' % (len(a), len(b), simple_format(a), simple_format(b))) - for i, (a_row, b_row) in enumerate(zip(a, b)): - a_row = a_row.s if isinstance(a_row, FmtStr) else a_row - b_row = b_row.s if isinstance(b_row, FmtStr) else b_row - self.assertEqual(a_row, b_row, 'FSArrays differ first on line %s:\n%s' % (i, FSArray.diff(a, b, ignore_formatting=True))) - - -if __name__ == '__main__': - a = FSArray(3, 14, bg='blue') - a[0:2, 5:11] = fmtstr("hey", 'on_blue') + ' ' + fmtstr('yo', 'on_red'), fmtstr('qwe qw') + return "\n".join( + f"{a_line} {a_len:3d} | {b_len:3d} {b_line}" + for a_line, b_line, a_len, b_len in zip( + a_rows, b_rows, a_lengths, b_lengths + ) + ) + + +def fsarray( + strings: Sequence[FmtStr | str], + width: int | None = None, + *args: Any, + **kwargs: Any, +) -> FSArray: + """fsarray(list_of_FmtStrs_or_strings, width=None) -> FSArray + + Returns a new FSArray of width of the maximum size of the provided + strings, or width provided, and height of the number of strings provided. + If a width is provided, raises a ValueError if any of the strings + are of length greater than this width""" + + strings = list(strings) + if width is not None: + if strings and any(len(s) > width for s in strings): + raise ValueError(f"Those strings won't fit for width {width}") + else: + width = max(len(s) for s in strings) if strings else 0 + arr = FSArray(len(strings), width, *args, **kwargs) + rows = [ + fs.setslice_with_length(0, len(s), s, width) + for fs, s in zip( + arr.rows, + ( + s if isinstance(s, FmtStr) else fmtstr(s, *args, **kwargs) + for s in strings + ), + ) + ] + arr.rows = rows + return arr + + +def simple_format(x: FSArray | Sequence[FmtStr]) -> str: + return "\n".join(str(l) for l in x) + + +def assertFSArraysEqual(a: FSArray, b: FSArray) -> None: + assert isinstance(a, FSArray) + assert isinstance(b, FSArray) + assert ( + a.width == b.width and a.height == b.height + ), f"fsarray dimensions do not match: {a.shape} {b.shape}" + for i, (a_row, b_row) in enumerate(zip(a, b)): + assert a_row == b_row, "FSArrays differ first on line {}:\n{}".format( + i, FSArray.diff(a, b) + ) + + +def assertFSArraysEqualIgnoringFormatting(a: FSArray, b: FSArray) -> None: + """Also accepts arrays of strings""" + assert len(a) == len(b), "fsarray heights do not match: {} {} \n{} \n{}".format( + len(a), + len(b), + simple_format(a), + simple_format(b), + ) + for i, (a_row, b_row) in enumerate(zip(a, b)): + a_row = a_row.s if isinstance(a_row, FmtStr) else a_row + b_row = b_row.s if isinstance(b_row, FmtStr) else b_row + assert a_row == b_row, "FSArrays differ first on line {}:\n{}".format( + i, + FSArray.diff(a, b, ignore_formatting=True), + ) + + +if __name__ == "__main__": + a = FSArray(3, 14, bg="blue") + a[0:2, 5:11] = cast( + tuple[FmtStr, ...], + (fmtstr("hey", "on_blue") + " " + fmtstr("yo", "on_red"), fmtstr("qwe qw")), + ) a.dumb_display() - a = fsarray(['hey', 'there'], bg='cyan') + a = fsarray(["hey", "there"], bg="cyan") a.dumb_display() - print(FSArray.diff(a, fsarray(['hey', 'there ']), ignore_formatting=True)) + print(FSArray.diff(a, fsarray(["hey", "there "]), ignore_formatting=True)) diff --git a/curtsies/input.py b/curtsies/input.py index 3a439f4..6eb85ec 100644 --- a/curtsies/input.py +++ b/curtsies/input.py @@ -1,44 +1,72 @@ - import locale +import logging import os -import signal import select +import signal import sys import termios +import threading import time import tty -import logging -logger = logging.getLogger(__name__) - - from .termhelpers import Nonblocking from . import events -PY3 = sys.version_info[0] >= 3 +from typing import ( + ContextManager, + Type, + TextIO, + Optional, + List, + Union, + cast, + Tuple, + Any, +) +from collections.abc import Callable +from types import TracebackType, FrameType + +logger = logging.getLogger(__name__) READ_SIZE = 1024 assert READ_SIZE >= events.MAX_KEYPRESS_SIZE # if a keypress could require more bytes than we read to be identified, # the paste logic that reads more data as needed might not work. -class ReplacedSigIntHandler(object): - def __init__(self, handler): +def is_main_thread() -> bool: + return threading.current_thread() == threading.main_thread() + + +class ReplacedSigIntHandler(ContextManager): + def __init__(self, handler: Callable) -> None: self.handler = handler - def __enter__(self): - self.orig_sigint_handler = signal.getsignal(signal.SIGINT) - signal.signal(signal.SIGINT, self.handler) + def __enter__(self) -> None: + self.orig_sigint_handler = signal.signal(signal.SIGINT, self.handler) - def __exit__(self, type, value, traceback): + def __exit__( + self, + type: type[BaseException] | None = None, + value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: signal.signal(signal.SIGINT, self.orig_sigint_handler) -class Input(object): +class Input(ContextManager["Input"]): """Keypress and control event generator""" - def __init__(self, in_stream=None, keynames='curtsies', - paste_threshold=events.MAX_KEYPRESS_SIZE+1, sigint_event=False): + + in_stream: TextIO + + def __init__( + self, + in_stream: TextIO | None = None, + keynames: events.Keynames | str = events.Keynames.CURTSIES, + paste_threshold: int | None = events.MAX_KEYPRESS_SIZE + 1, + sigint_event: bool = False, + disable_terminal_start_stop: bool = False, + ) -> None: """Returns an Input instance. Args: @@ -50,66 +78,116 @@ def __init__(self, in_stream=None, keynames='curtsies', represent to be combined into a single paste event sigint_event (bool): Whether SIGINT signals from the OS should be intercepted and returned as SigIntEvent objects + disable_terminal_start_stop (bool): If True, disable terminal + start/stop using Ctrl-s/Ctrl-q, thus enabling these keys + to be read as input by curtsies """ if in_stream is None: in_stream = sys.__stdin__ + assert in_stream is not None self.in_stream = in_stream - self.unprocessed_bytes = [] # leftover from stdin, unprocessed yet - self.keynames = keynames + self.unprocessed_bytes: list[bytes] = [] # leftover from stdin, unprocessed yet + if isinstance(keynames, str): + # TODO: Remove this block with the next API breaking release. + if keynames == "curtsies": + self.keynames = events.Keynames.CURTSIES + elif keynames == "curses": + self.keynames = events.Keynames.CURSES + elif keynames == "bytes": + self.keynames = events.Keynames.BYTES + else: + raise ValueError("keyname is invalid") + else: + self.keynames = keynames self.paste_threshold = paste_threshold self.sigint_event = sigint_event - self.sigints = [] + self.disable_terminal_start_stop = disable_terminal_start_stop + self.sigints: list[events.SigIntEvent] = [] + self.wakeup_read_fd: int | None = None + self.wakeup_write_fd: int | None = None - self.readers = [] - self.queued_interrupting_events = [] - self.queued_events = [] - self.queued_scheduled_events = [] + self.readers: list[int] = [] + self.queued_interrupting_events: list[events.Event | str] = [] + self.queued_events: list[events.Event | None] = [] + self.queued_scheduled_events: list[tuple[float, events.ScheduledEvent]] = [] # prospective: this could be useful for an external select loop - def fileno(self): + def fileno(self) -> int: return self.in_stream.fileno() - def __enter__(self): + def __enter__(self) -> "Input": self.original_stty = termios.tcgetattr(self.in_stream) tty.setcbreak(self.in_stream, termios.TCSANOW) - if sys.platform == 'darwin': + if self.disable_terminal_start_stop: + attrs = termios.tcgetattr(self.in_stream) + tty_cc = cast(list[Union[bytes, int]], attrs[-1]) + tty_cc[termios.VSTOP] = 0 # Ctrl-s + tty_cc[termios.VSTART] = 0 # Ctrl-q + termios.tcsetattr(self.in_stream, termios.TCSANOW, attrs) + + if sys.platform == "darwin": attrs = termios.tcgetattr(self.in_stream) VDSUSP = termios.VSUSP + 1 - attrs[-1][VDSUSP] = 0 + tty_cc = cast(list[Union[bytes, int]], attrs[-1]) + tty_cc[VDSUSP] = 0 termios.tcsetattr(self.in_stream, termios.TCSANOW, attrs) - if self.sigint_event: + if self.sigint_event and is_main_thread(): self.orig_sigint_handler = signal.getsignal(signal.SIGINT) signal.signal(signal.SIGINT, self.sigint_handler) + + # Non-main threads don't receive signals + if is_main_thread(): + self.wakeup_read_fd, self.wakeup_write_fd = os.pipe() + wfd = self.wakeup_write_fd + os.set_blocking(wfd, False) + signal.set_wakeup_fd(wfd, warn_on_full_buffer=False) + return self - def __exit__(self, type, value, traceback): - if self.sigint_event: + def __exit__( + self, + type: type[BaseException] | None = None, + value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: + if ( + self.sigint_event + and is_main_thread() + and self.orig_sigint_handler is not None + ): signal.signal(signal.SIGINT, self.orig_sigint_handler) + if is_main_thread(): + signal.set_wakeup_fd(-1) + if self.wakeup_read_fd is not None: + os.close(self.wakeup_read_fd) + if self.wakeup_write_fd is not None: + os.close(self.wakeup_write_fd) termios.tcsetattr(self.in_stream, termios.TCSANOW, self.original_stty) - def sigint_handler(self, signum, frame): + def sigint_handler( + self, signum: signal.Signals | int, frame: FrameType | None + ) -> None: self.sigints.append(events.SigIntEvent()) - def __iter__(self): + def __iter__(self) -> "Input": return self - def next(self): + def __next__(self) -> None | str | events.Event: return self.send(None) - __next__ = next - - def unget_bytes(self, string): + def unget_bytes(self, string: bytes) -> None: """Adds bytes to be internal buffer to be read This method is for reporting bytes from an in_stream read not initiated by this Input object""" - self.unprocessed_bytes.extend(string[i:i + 1] - for i in range(len(string))) + self.unprocessed_bytes.extend(string[i : i + 1] for i in range(len(string))) - def _wait_for_read_ready_or_timeout(self, timeout): + def _wait_for_read_ready_or_timeout( + self, timeout: float | int | None + ) -> tuple[bool, events.Event | str | None]: """Returns tuple of whether stdin is ready to read and an event. If an event is returned, that event is more pressing than reading @@ -121,52 +199,64 @@ def _wait_for_read_ready_or_timeout(self, timeout): while True: try: (rs, _, _) = select.select( - [self.in_stream.fileno()] + self.readers, - [], [], remaining_timeout) + [self.in_stream.fileno()] + + ([] if self.wakeup_read_fd is None else [self.wakeup_read_fd]) + + self.readers, + [], + [], + remaining_timeout, + ) if not rs: return False, None r = rs[0] # if there's more than one, get it in the next loop if r == self.in_stream.fileno(): return True, None + elif r == self.wakeup_read_fd: + # In Python >=3.5 select won't raise this signal handler + signal_number = ord(os.read(r, 1)) + if signal_number == signal.SIGINT: + raise InterruptedError() else: os.read(r, 1024) if self.queued_interrupting_events: return False, self.queued_interrupting_events.pop(0) elif remaining_timeout is not None: - remaining_timeout = max(0, t0 + timeout - time.time()) + remaining_timeout = max(0, t0 + remaining_timeout - time.time()) continue else: continue - except select.error: + except OSError: if self.sigints: return False, self.sigints.pop() if remaining_timeout is not None: - remaining_timeout = max(timeout - (time.time() - t0), 0) + remaining_timeout = max(remaining_timeout - (time.time() - t0), 0) - def send(self, timeout=None): + def send(self, timeout: float | None | None = None) -> None | str | events.Event: """Returns an event or None if no events occur before timeout.""" - if self.sigint_event: + if self.sigint_event and is_main_thread(): with ReplacedSigIntHandler(self.sigint_handler): return self._send(timeout) else: return self._send(timeout) - def _send(self, timeout): - def find_key(): + def _send(self, timeout: float | int | None) -> None | str | events.Event: + def find_key() -> str | None: """Returns keypress identified by adding unprocessed bytes or None""" current_bytes = [] while self.unprocessed_bytes: current_bytes.append(self.unprocessed_bytes.pop(0)) - e = events.get_key(current_bytes, - getpreferredencoding(), - keynames=self.keynames, - full=len(self.unprocessed_bytes)==0) + e = events.get_key( + current_bytes, + getpreferredencoding(), + keynames=self.keynames, + full=len(self.unprocessed_bytes) == 0, + ) if e is not None: - self.current_bytes = [] return e if current_bytes: # incomplete keys shouldn't happen - raise ValueError("Couldn't identify key sequence: %r" % self.current_bytes) + raise ValueError("Couldn't identify key sequence: %r" % current_bytes) + return None if self.sigints: return self.sigints.pop() @@ -176,15 +266,20 @@ def find_key(): return self.queued_interrupting_events.pop(0) if self.queued_scheduled_events: - self.queued_scheduled_events.sort() #TODO use a data structure that inserts sorted + self.queued_scheduled_events.sort() when, _ = self.queued_scheduled_events[0] if when < time.time(): - logger.warning('popping an event! %r %r', - self.queued_scheduled_events[0], - self.queued_scheduled_events[1:]) + logger.debug( + "popping an event! %r %r", + self.queued_scheduled_events[0], + self.queued_scheduled_events[1:], + ) return self.queued_scheduled_events.pop(0)[1] else: - time_until_check = min(max(0, when - time.time()), timeout if timeout is not None else sys.maxsize) + time_until_check = min( + max(0, when - time.time()), + timeout if timeout is not None else sys.maxsize, + ) # type: Union[float, int, None] else: time_until_check = timeout @@ -193,13 +288,20 @@ def find_key(): if e is not None: return e - stdin_ready_for_read, event = self._wait_for_read_ready_or_timeout(time_until_check) + stdin_ready_for_read, event = self._wait_for_read_ready_or_timeout( + time_until_check + ) if event: return event - if self.queued_scheduled_events and when < time.time(): # when should always be defined + if ( + self.queued_scheduled_events and when < time.time() + ): # when should always be defined # because queued_scheduled_events should not be modified during this time - logger.warning('popping an event! %r %r', self.queued_scheduled_events[0], - self.queued_scheduled_events[1:]) + logger.debug( + "popping an event! %r %r", + self.queued_scheduled_events[0], + self.queued_scheduled_events[1:], + ) return self.queued_scheduled_events.pop(0)[1] if not stdin_ready_for_read: return None @@ -225,75 +327,81 @@ def find_key(): assert e is not None return e - def _nonblocking_read(self): + def _nonblocking_read(self) -> int: """Returns the number of characters read and adds them to self.unprocessed_bytes""" with Nonblocking(self.in_stream): - if PY3: - try: - data = os.read(self.in_stream.fileno(), READ_SIZE) - except BlockingIOError: - return 0 - if data: - self.unprocessed_bytes.extend(data[i:i+1] for i in range(len(data))) - return len(data) - else: - return 0 + try: + data = os.read(self.in_stream.fileno(), READ_SIZE) + except BlockingIOError: + return 0 + if data: + self.unprocessed_bytes.extend(data[i : i + 1] for i in range(len(data))) + return len(data) else: - try: - data = os.read(self.in_stream.fileno(), READ_SIZE) - except OSError: - return 0 - else: - self.unprocessed_bytes.extend(data) - return len(data) + return 0 - def event_trigger(self, event_type): + def event_trigger( + self, event_type: type[events.Event] | Callable[..., None] + ) -> Callable[..., None]: """Returns a callback that creates events. Returned callback function will add an event of type event_type to a queue which will be checked the next time an event is requested.""" - def callback(**kwargs): - self.queued_events.append(event_type(**kwargs)) + + def callback(**kwargs: Any) -> None: + self.queued_events.append(event_type(**kwargs)) # type: ignore + return callback - def scheduled_event_trigger(self, event_type): + def scheduled_event_trigger( + self, event_type: type[events.ScheduledEvent] + ) -> Callable[[float], None]: """Returns a callback that schedules events for the future. Returned callback function will add an event of type event_type to a queue which will be checked the next time an event is requested.""" - def callback(when, **kwargs): - self.queued_scheduled_events.append((when, event_type(when=when, **kwargs))) + + def callback(when: float) -> None: + self.queued_scheduled_events.append((when, event_type(when=when))) + return callback - def threadsafe_event_trigger(self, event_type): + def threadsafe_event_trigger( + self, event_type: type[events.Event] | Callable[..., None] + ) -> Callable[..., None]: """Returns a callback to creates events, interrupting current event requests. Returned callback function will create an event of type event_type which will interrupt an event request if one - is concurrently occuring, otherwise adding the event to a queue + is concurrently occurring, otherwise adding the event to a queue that will be checked on the next event request.""" readfd, writefd = os.pipe() self.readers.append(readfd) - def callback(**kwargs): - self.queued_interrupting_events.append(event_type(**kwargs)) #TODO use a threadsafe queue for this - logger.warning('added event to events list %r', self.queued_interrupting_events) - os.write(writefd, b'interrupting event!') + def callback(**kwargs: Any) -> None: + # TODO use a threadsafe queue for this + self.queued_interrupting_events.append(event_type(**kwargs)) # type: ignore + logger.debug( + "added event to events list %r", self.queued_interrupting_events + ) + os.write(writefd, b"interrupting event!") + return callback -def getpreferredencoding(): +def getpreferredencoding() -> str: return locale.getpreferredencoding() or sys.getdefaultencoding() -def main(): +def main() -> None: with Input() as input_generator: print(repr(input_generator.send(2))) print(repr(input_generator.send(1))) - print(repr(input_generator.send(.5))) - print(repr(input_generator.send(.2))) + print(repr(input_generator.send(0.5))) + print(repr(input_generator.send(0.2))) for e in input_generator: print(repr(e)) -if __name__ == '__main__': + +if __name__ == "__main__": main() diff --git a/curtsies/py.typed b/curtsies/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/curtsies/termformatconstants.py b/curtsies/termformatconstants.py index 2d9b4fd..726aaf7 100644 --- a/curtsies/termformatconstants.py +++ b/curtsies/termformatconstants.py @@ -1,16 +1,20 @@ """Constants for terminal formatting""" -colors = 'dark', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'gray' -FG_COLORS = dict(list(zip(colors, list(range(30, 38))))) -BG_COLORS = dict(list(zip(colors, list(range(40, 48))))) -STYLES = dict(list(zip(('bold', 'dark', 'underline', 'blink', 'invert'), [1,2,4,5,7]))) -FG_NUMBER_TO_COLOR = dict(zip(FG_COLORS.values(), FG_COLORS.keys())) -BG_NUMBER_TO_COLOR = dict(zip(BG_COLORS.values(), BG_COLORS.keys())) +from collections.abc import Mapping + +colors = "black", "red", "green", "yellow", "blue", "magenta", "cyan", "gray" +FG_COLORS: Mapping[str, int] = dict(zip(colors, range(30, 38))) +BG_COLORS: Mapping[str, int] = dict(zip(colors, range(40, 48))) +STYLES: Mapping[str, int] = dict( + zip(("bold", "dark", "italic", "underline", "blink", "invert"), (1, 2, 3, 4, 5, 7)) +) +FG_NUMBER_TO_COLOR: Mapping[int, str] = dict(zip(FG_COLORS.values(), FG_COLORS.keys())) +BG_NUMBER_TO_COLOR: Mapping[int, str] = dict(zip(BG_COLORS.values(), BG_COLORS.keys())) NUMBER_TO_STYLE = dict(zip(STYLES.values(), STYLES.keys())) RESET_ALL = 0 RESET_FG = 39 RESET_BG = 49 -def seq(num): - return '[%sm' % num +def seq(num: int) -> str: + return f"[{num}m" diff --git a/curtsies/termhelpers.py b/curtsies/termhelpers.py index bb04f7e..55e4bdd 100644 --- a/curtsies/termhelpers.py +++ b/curtsies/termhelpers.py @@ -3,32 +3,65 @@ import fcntl import os -class Nonblocking(object): - def __init__(self, stream): +from typing import IO, ContextManager, Type, List, Union, Optional +from types import TracebackType + +_Attr = list[Union[int, list[Union[bytes, int]]]] + + +class Nonblocking(ContextManager): + """ + A context manager for making an input stream nonblocking. + """ + + def __init__(self, stream: IO) -> None: self.stream = stream self.fd = self.stream.fileno() - def __enter__(self): + + def __enter__(self) -> None: self.orig_fl = fcntl.fcntl(self.fd, fcntl.F_GETFL) fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl | os.O_NONBLOCK) - def __exit__(self, *args): + + def __exit__( + self, + type: type[BaseException] | None = None, + value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl) -class Cbreak(object): - def __init__(self, stream): + +class Termmode(ContextManager): + def __init__(self, stream: IO, attrs: _Attr) -> None: self.stream = stream - def __enter__(self): + self.attrs = attrs + + def __enter__(self) -> None: self.original_stty = termios.tcgetattr(self.stream) - tty.setcbreak(self.stream, termios.TCSANOW) - return Termmode(self.stream, self.original_stty) - def __exit__(self, *args): + termios.tcsetattr(self.stream, termios.TCSANOW, self.attrs) + + def __exit__( + self, + type: type[BaseException] | None = None, + value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: termios.tcsetattr(self.stream, termios.TCSANOW, self.original_stty) -class Termmode(object): - def __init__(self, stream, attrs): + +class Cbreak(ContextManager[Termmode]): + def __init__(self, stream: IO) -> None: self.stream = stream - self.attrs = attrs - def __enter__(self): + + def __enter__(self) -> Termmode: self.original_stty = termios.tcgetattr(self.stream) - termios.tcsetattr(self.stream, termios.TCSANOW, self.attrs) - def __exit__(self, *args): + tty.setcbreak(self.stream, termios.TCSANOW) + return Termmode(self.stream, self.original_stty) + + def __exit__( + self, + type: type[BaseException] | None = None, + value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: termios.tcsetattr(self.stream, termios.TCSANOW, self.original_stty) diff --git a/curtsies/window.py b/curtsies/window.py index 80c93e0..41ce4e1 100644 --- a/curtsies/window.py +++ b/curtsies/window.py @@ -1,114 +1,133 @@ # All windows write only unicode to the terminal - -# that's what blessings does, so we match it. +# that's what blessed does, so we match it. + + +from typing import ( + ContextManager, + Optional, + IO, + Dict, + TypeVar, + Type, + Tuple, + cast, + TextIO, + Union, + List, +) +from collections.abc import Callable, Sequence +from types import TracebackType -from __future__ import unicode_literals - -import locale import logging import re import sys -import blessings +import blessed -from .formatstring import fmtstr +from .formatstring import fmtstr, FmtStr from .formatstringarray import FSArray from .termhelpers import Cbreak logger = logging.getLogger(__name__) -SCROLL_DOWN = u"\x1bD" -FIRST_COLUMN = u"\x1b[1G" + +T = TypeVar("T", bound="BaseWindow") -class BaseWindow(object): - def __init__(self, out_stream=None, hide_cursor=True): - logger.debug('-------initializing Window object %r------' % self) +class BaseWindow(ContextManager): + def __init__(self, out_stream: IO | None = None, hide_cursor: bool = True) -> None: + logger.debug("-------initializing Window object %r------" % self) if out_stream is None: out_stream = sys.__stdout__ - self.t = blessings.Terminal(stream=out_stream, force_styling=True) + assert out_stream is not None + self.t = blessed.Terminal(stream=out_stream, force_styling=True) self.out_stream = out_stream self.hide_cursor = hide_cursor - self._last_lines_by_row = {} - self._last_rendered_width = None - self._last_rendered_height = None + self._last_lines_by_row: dict[int, FmtStr | None] = {} + self._last_rendered_width: int | None = None + self._last_rendered_height: int | None = None - def scroll_down(self): - logger.debug('sending scroll down message w/ cursor on bottom line') + def scroll_down(self) -> None: + logger.debug("sending scroll down message w/ cursor on bottom line") # since scroll-down only moves the screen if cursor is at bottom with self.t.location(x=0, y=1000000): - self.write(SCROLL_DOWN) # TODO will blessings do this? + self.write(self.t.move_down) - def write(self, msg): + def write(self, msg: str) -> None: self.out_stream.write(msg) self.out_stream.flush() - def __enter__(self): + def __enter__(self: T) -> T: logger.debug("running BaseWindow.__enter__") if self.hide_cursor: self.write(self.t.hide_cursor) return self - def __exit__(self, type, value, traceback): + def __exit__( + self, + type: type[BaseException] | None = None, + value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: logger.debug("running BaseWindow.__exit__") if self.hide_cursor: self.write(self.t.normal_cursor) - def on_terminal_size_change(self, height, width): + def on_terminal_size_change(self, height: int, width: int) -> None: # Changing the terminal size breaks the cache, because it # is unknown how the window size change affected scrolling / the cursor self._last_lines_by_row = {} self._last_rendered_width = width self._last_rendered_height = height - def render_to_terminal(self, array, cursor_pos=(0, 0)): - raise NotImplemented + def render_to_terminal( + self, array: FSArray | list[FmtStr], cursor_pos: tuple[int, int] = (0, 0) + ) -> int | None: + raise NotImplementedError - def get_term_hw(self): + def get_term_hw(self) -> tuple[int, int]: """Returns current terminal height and width""" return self.t.height, self.t.width - width = property(lambda self: self.t.width, None, None, - "The current width of the terminal window") - height = property(lambda self: self.t.height, None, None, - "The current height of the terminal window") + @property + def width(self) -> int: + "The current width of the terminal window" + return self.t.width + + @property + def height(self) -> int: + "The current width of the terminal window" + return self.t.height - def array_from_text(self, msg): + def array_from_text(self, msg: str) -> FSArray: """Returns a FSArray of the size of the window containing msg""" rows, columns = self.t.height, self.t.width return self.array_from_text_rc(msg, rows, columns) @classmethod - def array_from_text_rc(cls, msg, rows, columns): + def array_from_text_rc(cls, msg: str, rows: int, columns: int) -> FSArray: arr = FSArray(0, columns) i = 0 for c in msg: if i >= rows * columns: return arr - elif c in '\r\n': + elif c in "\r\n": i = ((i // columns) + 1) * columns - 1 else: arr[i // arr.width, i % arr.width] = [fmtstr(c)] i += 1 return arr - def fmtstr_to_stdout_xform(self): - if sys.version_info[0] == 2: - if hasattr(self.out_stream, 'encoding'): - encoding = self.out_stream.encoding - else: - encoding = locale.getpreferredencoding() + def fmtstr_to_stdout_xform(self) -> Callable[[FmtStr], str]: + def for_stdout(s: FmtStr) -> str: + return str(s) - def for_stdout(s): - return unicode(s).encode(encoding, 'replace') - else: - def for_stdout(s): - return str(s) return for_stdout -class FullscreenWindow(BaseWindow): - """2D-text rendering window that dissappears when its context is left +class FullscreenWindow(BaseWindow, ContextManager["FullscreenWindow"]): + """2D-text rendering window that disappears when its context is left FullscreenWindow will only render arrays the size of the terminal or smaller, and leaves no trace on exit (like top or vim). It never @@ -122,26 +141,33 @@ class FullscreenWindow(BaseWindow): Within the context of CursorAwareWindow, refrain from writing to its out_stream; cached writes will be inaccurate. """ - def __init__(self, out_stream=None, hide_cursor=True): + + def __init__(self, out_stream: IO | None = None, hide_cursor: bool = True) -> None: """Constructs a FullscreenWindow Args: out_stream (file): Defaults to sys.__stdout__ hide_cursor (bool): Hides cursor while in context """ - BaseWindow.__init__(self, out_stream=out_stream, - hide_cursor=hide_cursor) + super().__init__(out_stream=out_stream, hide_cursor=hide_cursor) self.fullscreen_ctx = self.t.fullscreen() - def __enter__(self): + def __enter__(self) -> "FullscreenWindow": self.fullscreen_ctx.__enter__() - return BaseWindow.__enter__(self) - - def __exit__(self, type, value, traceback): + return super().__enter__() + + def __exit__( + self, + type: type[BaseException] | None = None, + value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: self.fullscreen_ctx.__exit__(type, value, traceback) - BaseWindow.__exit__(self, type, value, traceback) + super().__exit__(type, value, traceback) - def render_to_terminal(self, array, cursor_pos=(0, 0)): + def render_to_terminal( + self, array: FSArray | list[FmtStr], cursor_pos: tuple[int, int] = (0, 0) + ) -> None: """Renders array to terminal and places (0-indexed) cursor Args: @@ -164,17 +190,13 @@ def render_to_terminal(self, array, cursor_pos=(0, 0)): for_stdout = self.fmtstr_to_stdout_xform() if not self.hide_cursor: self.write(self.t.hide_cursor) - if (height != self._last_rendered_height or - width != self._last_rendered_width): + if height != self._last_rendered_height or width != self._last_rendered_width: self.on_terminal_size_change(height, width) - current_lines_by_row = {} - rows = list(range(height)) - rows_for_use = rows[:len(array)] - rest_of_rows = rows[len(array):] + current_lines_by_row: dict[int, FmtStr | None] = {} # rows which we have content for and don't require scrolling - for row, line in zip(rows_for_use, array): + for row, line in enumerate(array): current_lines_by_row[row] = line if line == self._last_lines_by_row.get(row, None): continue @@ -184,7 +206,7 @@ def render_to_terminal(self, array, cursor_pos=(0, 0)): self.write(self.t.clear_eol) # rows onscreen that we don't have content for - for row in rest_of_rows: + for row in range(len(array), height): if self._last_lines_by_row and row not in self._last_lines_by_row: continue self.write(self.t.move(row, 0)) @@ -192,19 +214,15 @@ def render_to_terminal(self, array, cursor_pos=(0, 0)): self.write(self.t.clear_bol) current_lines_by_row[row] = None - logger.debug( - 'lines in last lines by row: %r' % self._last_lines_by_row.keys() - ) - logger.debug( - 'lines in current lines by row: %r' % current_lines_by_row.keys() - ) + logger.debug("lines in last lines by row: %r" % self._last_lines_by_row.keys()) + logger.debug("lines in current lines by row: %r" % current_lines_by_row.keys()) self.write(self.t.move(*cursor_pos)) self._last_lines_by_row = current_lines_by_row if not self.hide_cursor: self.write(self.t.normal_cursor) -class CursorAwareWindow(BaseWindow): +class CursorAwareWindow(BaseWindow, ContextManager["CursorAwareWindow"]): """ Renders to the normal terminal screen and can find the location of the cursor. @@ -218,9 +236,17 @@ class CursorAwareWindow(BaseWindow): cursor depends on cursor not having moved since the last render. Only use the render_to_terminal interface for moving the cursor. """ - def __init__(self, out_stream=None, in_stream=None, - keep_last_line=False, hide_cursor=True, - extra_bytes_callback=None): + + in_stream: TextIO + + def __init__( + self, + out_stream: TextIO | None = None, + in_stream: TextIO | None = None, + keep_last_line: bool = False, + hide_cursor: bool = True, + extra_bytes_callback: Callable[[bytes], None] | None = None, + ): """Constructs a CursorAwareWindow Args: @@ -230,18 +256,21 @@ def __init__(self, out_stream=None, in_stream=None, on leaving context hide_cursor (bool): Hides cursor while in context extra_bytes_callback (f(bytes) -> None): Will be called with extra - bytes inadvertantly read in get_cursor_position(). If not + bytes inadvertently read in get_cursor_position(). If not provided, a ValueError will be raised when this occurs. """ - BaseWindow.__init__(self, out_stream=out_stream, - hide_cursor=hide_cursor) + super().__init__(out_stream=out_stream, hide_cursor=hide_cursor) if in_stream is None: in_stream = sys.__stdin__ + assert in_stream is not None self.in_stream = in_stream - self._last_cursor_column = None - self._last_cursor_row = None + # whether we can use blessed to handle some operations + self._use_blessed = ( + self.out_stream == sys.__stdout__ and self.in_stream == sys.__stdin__ + ) + self._last_cursor_column: int | None = None + self._last_cursor_row: int | None = None self.keep_last_line = keep_last_line - self.cbreak = Cbreak(self.in_stream) self.extra_bytes_callback = extra_bytes_callback # whether another SIGWINCH is queued up @@ -250,74 +279,98 @@ def __init__(self, out_stream=None, in_stream=None, # in the cursor query code of cursor diff self.in_get_cursor_diff = False - def __enter__(self): + def __enter__(self) -> "CursorAwareWindow": + self.cbreak = ( + Cbreak(self.in_stream) if not self._use_blessed else self.t.cbreak() + ) self.cbreak.__enter__() self.top_usable_row, _ = self.get_cursor_position() self._orig_top_usable_row = self.top_usable_row - logger.debug('initial top_usable_row: %d' % self.top_usable_row) - return BaseWindow.__enter__(self) - - def __exit__(self, type, value, traceback): + logger.debug("initial top_usable_row: %d" % self.top_usable_row) + return super().__enter__() + + def __exit__( + self, + type: type[BaseException] | None = None, + value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: if self.keep_last_line: # just moves cursor down if not on last line - self.write(SCROLL_DOWN) + self.write(self.t.move_down) - self.write(FIRST_COLUMN) + self.write(self.t.move_x(0)) self.write(self.t.clear_eos) self.write(self.t.clear_eol) self.cbreak.__exit__(type, value, traceback) - BaseWindow.__exit__(self, type, value, traceback) + super().__exit__(type, value, traceback) - def get_cursor_position(self): + def get_cursor_position(self) -> tuple[int, int]: """Returns the terminal (row, column) of the cursor - 0-indexed, like blessings cursor positions""" + 0-indexed, like blessed cursor positions""" + + if self._use_blessed: + return self.t.get_location() + # TODO would this be cleaner as a parameter? in_stream = self.in_stream - query_cursor_position = u"\x1b[6n" + query_cursor_position = "\x1b[6n" self.write(query_cursor_position) - def retrying_read(): + def retrying_read() -> str: while True: try: c = in_stream.read(1) - if c == '': - raise ValueError("Stream should be blocking - should't" - " return ''. Returned %r so far", - (resp,)) + if c == "": + raise ValueError( + "Stream should be blocking - shouldn't" + " return ''. Returned %r so far", + (resp,), + ) return c - except IOError: - raise ValueError( - 'cursor get pos response read interrupted' - ) - # find out if this ever really happens - if so, continue + except OSError: + # apparently sometimes this happens: the only documented + # case is Terminal on a Ubuntu 17.10 VM on osx 10.13. + # see issue #732 + logger.info("stdin.read(1) that should never error just errored.") + continue - resp = '' + resp = "" while True: c = retrying_read() resp += c - m = re.search('(?P.*)' - '(?P\x1b\[|\x9b)' - '(?P\\d+);(?P\\d+)R', resp, re.DOTALL) + m = re.search( + r"(?P.*)" + r"(?P\x1b\[|\x9b)" + r"(?P\d+);(?P\d+)R", + resp, + re.DOTALL, + ) if m: - row = int(m.groupdict()['row']) - col = int(m.groupdict()['column']) - extra = m.groupdict()['extra'] + row = int(m.groupdict()["row"]) + col = int(m.groupdict()["column"]) + extra = m.groupdict()["extra"] if extra: - if self.extra_bytes_callback: + if self.extra_bytes_callback is not None: self.extra_bytes_callback( - extra.encode(in_stream.encoding) + # TODO how do we know that this works? + extra.encode(cast(TextIO, in_stream).encoding) ) else: - raise ValueError(("Bytes preceding cursor position " - "query response thrown out:\n%r\n" - "Pass an extra_bytes_callback to " - "CursorAwareWindow to prevent this") - % (extra,)) + raise ValueError( + ( + "Bytes preceding cursor position " + "query response thrown out:\n%r\n" + "Pass an extra_bytes_callback to " + "CursorAwareWindow to prevent this" + ) + % (extra,) + ) return (row - 1, col - 1) - def get_cursor_vertical_diff(self): + def get_cursor_vertical_diff(self) -> int: """Returns the how far down the cursor moved since last render. Note: @@ -346,7 +399,7 @@ def get_cursor_vertical_diff(self): if not self.another_sigwinch: return cursor_dy - def _get_cursor_vertical_diff_once(self): + def _get_cursor_vertical_diff_once(self) -> int: """Returns the how far down the cursor moved.""" old_top_usable_row = self.top_usable_row row, col = self.get_cursor_position() @@ -354,20 +407,27 @@ def _get_cursor_vertical_diff_once(self): cursor_dy = 0 else: cursor_dy = row - self._last_cursor_row - logger.info('cursor moved %d lines down' % cursor_dy) + logger.info("cursor moved %d lines down" % cursor_dy) while self.top_usable_row > -1 and cursor_dy > 0: self.top_usable_row += 1 cursor_dy -= 1 while self.top_usable_row > 1 and cursor_dy < 0: self.top_usable_row -= 1 cursor_dy += 1 - logger.info('top usable row changed from %d to %d', old_top_usable_row, - self.top_usable_row) - logger.info('returning cursor dy of %d from curtsies' % cursor_dy) + logger.info( + "top usable row changed from %d to %d", + old_top_usable_row, + self.top_usable_row, + ) + logger.info("returning cursor dy of %d from curtsies" % cursor_dy) self._last_cursor_row = row return cursor_dy - def render_to_terminal(self, array, cursor_pos=(0, 0)): + def render_to_terminal( + self, + array: FSArray | Sequence[FmtStr], + cursor_pos: tuple[int, int] = (0, 0), + ) -> int: """Renders array to terminal, returns the number of lines scrolled offscreen Returns: @@ -394,11 +454,10 @@ def render_to_terminal(self, array, cursor_pos=(0, 0)): # TODO race condition here? height, width = self.t.height, self.t.width - if (height != self._last_rendered_height or - width != self._last_rendered_width): + if height != self._last_rendered_height or width != self._last_rendered_width: self.on_terminal_size_change(height, width) - current_lines_by_row = {} + current_lines_by_row: dict[int, FmtStr | None] = {} rows_for_use = list(range(self.top_usable_row, height)) # rows which we have content for and don't require scrolling @@ -433,48 +492,41 @@ def render_to_terminal(self, array, cursor_pos=(0, 0)): self.top_usable_row -= 1 else: offscreen_scrolls += 1 - current_lines_by_row = dict( - (k - 1, v) for k, v in current_lines_by_row.items() - ) - logger.debug('new top_usable_row: %d' % self.top_usable_row) + current_lines_by_row = {k - 1: v for k, v in current_lines_by_row.items()} + logger.debug("new top_usable_row: %d" % self.top_usable_row) # since scrolling moves the cursor self.write(self.t.move(height - 1, 0)) self.write(for_stdout(line)) current_lines_by_row[height - 1] = line - logger.debug( - 'lines in last lines by row: %r' % self._last_lines_by_row.keys() - ) - logger.debug( - 'lines in current lines by row: %r' % current_lines_by_row.keys() - ) - self._last_cursor_row = ( - cursor_pos[0] - offscreen_scrolls + self.top_usable_row + logger.debug("lines in last lines by row: %r" % self._last_lines_by_row.keys()) + logger.debug("lines in current lines by row: %r" % current_lines_by_row.keys()) + self._last_cursor_row = max( + 0, cursor_pos[0] - offscreen_scrolls + self.top_usable_row ) self._last_cursor_column = cursor_pos[1] - self.write( - self.t.move(self._last_cursor_row, self._last_cursor_column) - ) + self.write(self.t.move(self._last_cursor_row, self._last_cursor_column)) self._last_lines_by_row = current_lines_by_row if not self.hide_cursor: self.write(self.t.normal_cursor) return offscreen_scrolls -def demo(): - handler = logging.FileHandler(filename='display.log') +def demo() -> None: + handler = logging.FileHandler(filename="display.log") logging.getLogger(__name__).setLevel(logging.DEBUG) logging.getLogger(__name__).addHandler(handler) from . import input + with FullscreenWindow(sys.stdout) as w: with input.Input(sys.stdin) as input_generator: rows, columns = w.t.height, w.t.width - while True: - c = input_generator.next() + for c in input_generator: + assert isinstance(c, str) if c == "": sys.exit() # same as raise SystemExit() elif c == "h": - a = w.array_from_text("a for small array") + a: list[FmtStr] | FSArray = w.array_from_text("a for small array") elif c == "a": a = [fmtstr(c * columns) for _ in range(rows)] elif c == "s": @@ -489,26 +541,26 @@ def demo(): a = [fmtstr(c * columns) for _ in range(1)] elif c == "e": a = [fmtstr(c * columns) for _ in range(1)] - elif c == '\x0c': # ctrl-L - [w.write('\n') for _ in range(rows)] + elif c == "\x0c": # ctrl-L + for _ in range(rows): + w.write("\n") continue else: a = w.array_from_text("unknown command") w.render_to_terminal(a) -def main(): - handler = logging.FileHandler(filename='display.log', level=logging.DEBUG) +def main() -> None: + handler = logging.FileHandler(filename="display.log") logging.getLogger(__name__).setLevel(logging.DEBUG) logging.getLogger(__name__).addHandler(handler) - print('this should be just off-screen') + print("this should be just off-screen") w = FullscreenWindow(sys.stdout) rows, columns = w.t.height, w.t.width with w: - a = [fmtstr( - (('.row%r.' % (row,)) * rows)[:columns] - ) for row in range(rows)] + a = [fmtstr(((f".row{row!r}.") * rows)[:columns]) for row in range(rows)] w.render_to_terminal(a) -if __name__ == '__main__': + +if __name__ == "__main__": demo() diff --git a/docs/FSArray.rst b/docs/FSArray.rst index f138f9a..a4229ae 100644 --- a/docs/FSArray.rst +++ b/docs/FSArray.rst @@ -1,7 +1,7 @@ FSArray ^^^^^^^ -:py:class:`~curtsies.formatstringarray.FSArray` is a two dimensional grid of colored and styled characters. +:py:class:`~curtsies.FSArray` is a two dimensional grid of colored and styled characters. FSArray - Example ================= @@ -16,12 +16,12 @@ FSArray - Example blue(on_green(u'hey!'))]) a.dumb_display() -`fsarray` is a convenience function returning a FSArray constructed from its arguments. +:py:class:`~curtsies.fsarray` is a convenience function returning a :py:class:`~curtsies.FSArray` constructed from its arguments. FSArray - Using =============== -Arrays can be composed to build up complex text interfaces:: +:py:class:`~curtsies.FSArray` objects can be composed to build up complex text interfaces:: >>> import time >>> from curtsies import FSArray, fsarray, fmtstr @@ -52,24 +52,21 @@ Arrays can be composed to build up complex text interfaces:: ++++++++++++++++++++++++++++++++++++++++ ++++++++++++++++++++++++++++++++++++++++ -An array like shown above might be repeatedly constructed and rendered with a :py:mod:`curtsies.window` object. +An array like the above might be repeatedly constructed and rendered with a :py:mod:`curtsies.window` object. -Slicing works like it does with FmtStrs, but in two dimensions. -FSArrays are *mutable*, so array assignment syntax can be used for natural -compositing as in the above exaple. +Slicing works like it does with a :py:class:`~curtsies.FmtStr`, but in two dimensions. +:py:class:`~curtsies.FSArray` are *mutable*, so array assignment syntax can be used for natural +compositing as in the above example. If you're dealing with terminal output, the *width* of a string becomes more important than it's *length* (see :ref:`len-vs-width`). -In the future FSArrays will do slicing and array assignment based on width -instead of number of characters, but this is not currently implemented. +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. FSArray - API docs ================== -.. automodule:: curtsies.formatstringarray - :members: FSArray, fsarray +.. autofunction:: curtsies.fsarray .. autoclass:: curtsies.FSArray :members: - diff --git a/docs/FmtStr.rst b/docs/FmtStr.rst index 78437f7..cc48a5d 100644 --- a/docs/FmtStr.rst +++ b/docs/FmtStr.rst @@ -1,8 +1,8 @@ FmtStr ^^^^^^ -:py:class:`~curtsies.formatstring.FmtStr` is a string with each character colored -and styled in ways representable by `ANSI escape codes `_. +:py:class:`~curtsies.FmtStr` is a string with each character colored +and styled in ways representable by `ANSI escape codes `_. .. automodule:: curtsies.formatstring @@ -20,52 +20,82 @@ FmtStr - Example str(full) print(full) -We start here with such a complicated example because it you only need something simple like +We start here with such a complicated example because if you only need something simple like: .. python_terminal_session:: from curtsies.fmtfuncs import * print(blue(bold(u'Deep blue sea'))) -another library may be a better fit than Curtsies. -Unlink other libraries, Curtsies allows these colored strings to be further manipulated after +then another library may be a better fit than Curtsies. +Unlike other libraries, Curtsies allows these colored strings to be further manipulated after they are created. +Available colours and styles +---------------------------- + +The following colours are available with corresponding foreground and background functions: + +======= ============= ================ +Name Foreground Background +======= ============= ================ +black ``black()`` ``on_black()`` +red ``red()`` ``on_red()`` +green ``green()`` ``on_green()`` +yellow ``yellow()`` ``on_yellow()`` +blue ``blue()`` ``on_blue()`` +magenta ``magenta()`` ``on_magenta()`` +cyan ``cyan()`` ``on_cyan()`` +gray ``gray()`` ``on_gray()`` +======= ============= ================ + +And the following styles with their corresponding functions: + +========= =============== +Style Function +========= =============== +bold ``bold()`` +dark ``dark()`` +underline ``underline()`` +blink ``blink()`` +invert ``invert()`` +========= =============== + FmtStr - Rationale -================ +================== -If all you need is to print colored text, many other libraries also make `ANSI escape codes `_ easy to use. +If all you need is to print colored text, many other libraries also make `ANSI escape codes `_ easy to use. -* `Blessings `_ (``pip install blessings``) - As of version 0.1.0, Curtsies uses Blessings for terminal capabilities other +* `Blessed `_ (``pip install blessed``) + As of version 0.1.0, Curtsies uses Blessed for terminal capabilities other than colored output. -* `termcolor `_ (``pip install termcolor``) +* `termcolor `_ (``pip install termcolor``) * `Clint `_ (``pip install clint``) -* `colors `_ (``pip install colors``) +* `colors `_ (``pip install colors``) -In all of the libraries listed above the expression ``blue('hi') + ' ' + green('there)`` +In all of the libraries listed above, the expression ``blue('hi') + ' ' + green('there)`` or equivalent evaluates to a Python string, not a colored string object. If all you plan -to do with this string is print it, this is great. But if you need to +to do with this string is print it, this is great. But, if you need to do more formatting with this colored string later, the length will be something like 29 instead of 9; structured formatting information is lost. Methods like :py:meth:`center ` and :py:meth:`ljust ` won't properly format the string for display. ->>> import blessings ->>> term = blessings.Terminal() +>>> import blessed +>>> term = blessed.Terminal() >>> message = term.red_on_green('Red on green?') + ' ' + term.yellow('Ick!') >>> len(message) 41 # ?! >>> message.center(50) u' \x1b[31m\x1b[42mRed on green?\x1b[m\x0f \x1b[33mIck!\x1b[m\x0f ' -:py:class:`FmtStr` objects can be combined and composited to create more complicated -:py:class:`FmtStr` object, +:py:class:`~curtsies.FmtStr` objects can be combined and composited to create more complicated +:py:class:`~curtsies.FmtStr` objects, useful for building flashy terminal interfaces with overlapping -windows/widgets than can change size and depend on each others sizes. -One :py:class:`FmtStr` can have several kinds of formatting applied to different parts of it. +windows/widgets that can change size and depend on each other's sizes. +One :py:class:`~curtsies.FmtStr` can have several kinds of formatting applied to different parts of it. >>> from curtsies.fmtfuncs import * >>> blue('asdf') + on_red('adsf') @@ -74,13 +104,13 @@ blue("asdf")+on_red("adsf") FmtStr - Using ============== -A :py:class:`FmtStr` can be sliced to produce a new :py:class:`FmtStr` object: +A :py:class:`~curtsies.FmtStr` can be sliced to produce a new :py:class:`~curtsies.FmtStr` objects: >>> from curtsies.fmtfuncs import * >>> (blue('asdf') + on_red('adsf'))[3:7] blue("f")+on_red("ads") -:py:class:`FmtStr` are *immutable* - but you can create new ones with :py:meth:`FmtStr.splice`: +:py:class:`~curtsies.FmtStr` are *immutable* - but you can create new ones with :py:meth:`~curtsies.FmtStr.splice`: >>> from curtsies.fmtfuncs import * >>> f = blue('hey there') + on_red(' Tom!') @@ -90,7 +120,7 @@ A :py:class:`FmtStr` can be sliced to produce a new :py:class:`FmtStr` object: >>> f.splice('something longer', 2) blue("h")+"something longer"+blue("ot")+blue(" there")+on_red(" Tom!") -:py:class:`FmtStr` greedily absorb strings, but no formatting is applied to this added text +:py:class:`~curtsies.FmtStr` greedily absorb strings, but no formatting is applied to this added text: >>> from curtsies.fmtfuncs import * >>> f = blue("The story so far:") + "In the beginning..." @@ -99,7 +129,7 @@ A :py:class:`FmtStr` can be sliced to produce a new :py:class:`FmtStr` object: >>> f blue("The story so far:")+"In the beginning..." -It's easy to turn ANSI terminal formatted strings into :py:class:`FmtStr` +It's easy to turn ANSI terminal formatted strings into :py:class:`~curtsies.FmtStr`: >>> from curtsies.fmtfuncs import * >>> from curtsies import FmtStr @@ -113,8 +143,8 @@ FmtStr - Using str methods -------------------------- All sorts of `string methods `_ -can be used on a :py:class:`FmtStr`, so you can often -use :py:class:`FmtStr` objects where you had strings in your program before:: +can be used on a :py:class:`~curtsies.FmtStr`, so you can often +use :py:class:`~curtsies.FmtStr` objects where you had strings in your program before:: >>> from curtsies.fmtfuncs import * >>> f = blue(underline('As you like it')) @@ -125,7 +155,7 @@ use :py:class:`FmtStr` objects where you had strings in your program before:: >>> blue(', ').join(['a', red('b')]) "a"+blue(", ")+red("b") -If :py:class:`FmtStr` doesn't implement a method, it tries its best to use the string +If :py:class:`~curtsies.FmtStr` doesn't implement a method, it tries its best to use the string method, which often works pretty well:: >>> from curtsies.fmtfuncs import * @@ -163,7 +193,7 @@ In Python 2, you might run into something like this: >>> red('hi') ValueError: unicode string required, got 'hi' -:py:class:`FmtStr` requires unicode strings, so in Python 2 it is convenient to use the unicode_literals compiler directive: +:py:class:`~curtsies.FmtStr` requires unicode strings, so in Python 2 it is convenient to use the unicode_literals compiler directive: >>> from __future__ import unicode_literals >>> from curtsies.fmtfuncs import * @@ -176,7 +206,7 @@ FmtStr - len vs width --------------------- The amount of horizontal space a string takes up in a terminal may differ from the length of the string returned by ``len()``. -:py:class:`FmtStr` objects have a width property useful when writing layout code: +To access this information, :py:class:`~curtsies.FmtStr` objects have a :py:class:`~curtsies.FmtStr.width` property, which can be useful when writing layout code: >>> #encoding: utf8 ... @@ -188,18 +218,17 @@ The amount of horizontal space a string takes up in a terminal may differ from t >>> len(combined), combined.width, combined.s (2, 1, u'a\u0324') -As shown above, `full width characters `_ can take up two columns, and `combining characters `_ may be combined with the previous character to form a single grapheme. Curtsies uses a `Python implementation of wcwidth `_ to do this calculation. +As shown above, `full width characters `_ can take up two columns, and `combining characters `_ may be combined with the previous character to form a single grapheme. Curtsies uses `Python bindings of wcwidth `_ to do this calculation. FmtStr - API Docs ================= -.. automodule:: curtsies.formatstring - :members: fmtstr +.. autofunction:: curtsies.fmtstr -.. autoclass:: FmtStr - :members: width, splice, copy_with_new_atts, copy_with_new_str, join, split, width_aware_slice +.. autoclass:: curtsies.FmtStr + :members: width, splice, copy_with_new_atts, copy_with_new_str, join, split, splitlines, width_aware_slice .. automodule:: curtsies.fmtfuncs -:py:class:`FmtStr` instances respond to most :class:`str` methods as you might expect, but the result +:py:class:`~curtsies.FmtStr` instances respond to most :class:`str` methods as you might expect, but the result of these methods sometimes loses its formatting. diff --git a/docs/Input.rst b/docs/Input.rst index c8e3339..21aa989 100644 --- a/docs/Input.rst +++ b/docs/Input.rst @@ -1,12 +1,12 @@ Input ^^^^^ -.. automodule:: curtsies.input +.. automodule:: curtsies.Input -:py:class:`~curtsies.input.Input` objects provide user keypress events +:py:class:`~curtsies.Input` objects provide user keypress events and other control events. Input - Example -======= +=============== >>> from curtsies import Input >>> with Input(keynames='curtsies') as input_generator: @@ -19,93 +19,96 @@ Input - Example Input - Getting Keyboard Events =============================== -The simplest way to use an :class:`~curtsies.input.Input` object is to +The simplest way to use an :py:class:`~curtsies.Input` object is to iterate over it in a for loop: each time a keypress is detected or other event occurs, an event is produced and can be acted upon. Since it's iterable, ``next()`` can be used to wait for a single event. -:meth:`~curtsies.input.Input.send` works like ``next()`` but takes a timeout +:py:meth:`~curtsies.Input.send` works like ``next()`` but takes a timeout in seconds, which if reached will cause None to be returned signalling -that no keypress or other event occured within the timeout. +that no keypress or other event occurred within the timeout. Key events are unicode strings, but sometimes event objects -(see :mod:`curtsies.events`) are returned instead. -Built-in events signal SigInt events from the OS and PasteEvents consisting +(see :class:`~curtsies.events.Event`) are returned instead. +Built-in events signal :py:class:`~curtsies.events.SigIntEvent` +events from the OS and :py:class:`~curtsies.events.PasteEvent` consisting of multiple keypress events if reporting of these types of events was enabled -in instatiation of the :py:class:`~curtsies.input.Input` object. +in instantiation of the :py:class:`~curtsies.Input` object. Input - Using as a Reactor ========================== Custom events can also be scheduled to be returned from -:py:class:`~curtsies.input.Input` with callback functions +:py:class:`~curtsies.Input` with callback functions created by the event trigger methods. Each of these methods returns a callback that will schedule an instance of the desired event type: -* Using a callback created by :py:meth:`~curtsies.input.Input.event_trigger` +* Using a callback created by :py:meth:`~curtsies.Input.event_trigger` schedules an event to be returned the next time an event is requested, but not if an event has already been requested (if called from another thread). -* :py:meth:`~curtsies.input.Input.threadsafe_event_trigger` does the same, +* :py:meth:`~curtsies.Input.threadsafe_event_trigger` does the same, but may notify a concurrent request for an event so that the custom event is immediately returned. -* :py:meth:`~curtsies.input.Input.scheduled_event_trigger` schedules an event +* :py:meth:`~curtsies.Input.scheduled_event_trigger` schedules an event to be returned at some point in the future. Input - Context =============== -``next()`` and :meth:`~curtsies.input.Input.send()`` -must be used within the context of that :class:`~curtsies.input.Input` object. +``next()`` and :meth:`~curtsies.Input.send()` +must be used within the context of that :class:`~curtsies.Input` object. Within the (context-manager) context of an Input generator, an in-stream is put in raw mode or cbreak mode, and keypresses are stored to be reported -later. Original tty attribute are recorded to be restored on exiting +later. Original tty attributes are recorded to be restored on exiting the context. The SigInt signal handler may be replaced if this behavior was -specified on creation of the :class:`~curtsies.input.Input` object +specified on creation of the :class:`~curtsies.Input` object. Input - Notes ============= -``Input`` takes an optional argument for how to name -keypress events, which is 'curtsies' by default. -For compatibility with curses code, you can use 'curses' names, +:py:class:`~curtsies.Input` takes an optional argument ``keynames`` for how to name +keypress events, which is ``'curtsies'`` by default. +For compatibility with curses code, you can use ``'curses'`` names, but note that curses doesn't have nice key names for many key combinations so you'll be putting up with names like ``u'\xe1'`` for -option-j and ``'\x86'`` for ctrl-option-f. -Pass 'plain' for this parameter to return a simple unicode representation. +``option-j`` and ``'\x86'`` for ``ctrl-option-f``. +Pass ``'plain'`` for this parameter to return a simple unicode representation. -PasteEvent objects representing multple keystrokes in very rapid succession +:py:class:`~curtsies.events.PasteEvent` objects representing multiple +keystrokes in very rapid succession (typically because the user pasted in text, but possibly because they typed -two keys simultaneously. How many bytes must occur together to trigger such -an event is customizable via the paste_threshold argument to the ``Input()`` -- by default it's one greater than the maximum possible keypress +two keys simultaneously). How many bytes must occur together to trigger such +an event is customizable via the ``paste_threshold`` argument to the :py:class:`~curtsies.Input` +object - by default it's one greater than the maximum possible keypress length in bytes. -If ``sigint_event=True`` is passed to ``Input()``, SIGINT signals from the -operating system (which usually raise a KeyboardInterrupt exception) -will be returned as ``SigIntEvent()`` instances. +If ``sigint_event=True`` is passed to :py:class:`~curtsies.Input`, ``SIGINT`` signals from the +operating system (which usually raises a ``KeyboardInterrupt`` exception) +will be returned as :py:class:`~curtsies.events.SigIntEvent` instances. To set a timeout on the blocking get, treat it like a generator and call -``.send(timeout)``. The call will return None if no event is available. +``.send(timeout)``. The call will return ``None`` if no event is available. Input - Events ============== -.. automodule:: curtsies.events To see what a given keypress is called (what unicode string is returned by ``Terminal.next()``), try -``python -m curtsies.terminal`` and play around. -Events returned by :py:class:`~curtsies.input.Input` fall into two categories: -instances of subclasses of :class:`curtsies.event.Event` and +``python -m curtsies.events`` and play around. +Events returned by :py:class:`~curtsies.Input` fall into two categories: +instances of subclasses of :py:class:`~curtsies.events.Event` and Keypress strings. Input - Event Objects --------------------- +.. autoclass:: curtsies.events.Event + .. autoclass:: curtsies.events.SigIntEvent .. autoclass:: curtsies.events.PasteEvent @@ -130,7 +133,7 @@ Keypress events are Unicode strings in both Python 2 and 3 like: Likely points of confusion for keypress strings: * Enter is ```` -* Modern meta (the escape-prepending version) key is ```` while control and shift keys are is ```` (note the + vs -) +* Modern meta (the escape-prepending version) key is ```` while control and shift keys are ```` (note the + vs -) * Letter keys are capitalized in ```` while they are lowercase in ```` (this should be fixed in the next api-breaking release) * Some special characters lose their special names when used with modifier keys, for example: diff --git a/docs/about.rst b/docs/about.rst index f64f23b..e6fab1b 100644 --- a/docs/about.rst +++ b/docs/about.rst @@ -4,17 +4,17 @@ About Resources ========= -I've written a little bit about Curtsies on `my blog `_. +I've written a little bit about Curtsies on `my blog `_. -The source and issue tracker for Curtsies are on `Github `_. +The source and issue tracker for Curtsies are on `Github `_. A good place to ask questions about Curtsies is `#bpython on irc.freenode.net `_. Authors ======= -Curtsies was written by `Thomas Ballinger `_ to create -a frontend for `bpython `_ that preserved terminal history. +Curtsies was written by `Thomas Ballinger `_ to create +a frontend for `bpython `_ that preserved terminal history. Thanks so much to the many people that have contributed to it! @@ -23,7 +23,7 @@ Thanks so much to the many people that have contributed to it! * Fei Dong - work on making FmtStr and Chunk immutable * Julia Evans - help with Python 3 compatibility * Lea Albaugh - beautiful Curtsies logo -* Rachel King - several bugfixes on blessings use +* Rachel King - several bugfixes on blessed use * Scott Feeney - inspiration for this project - the original title of the project was "scott was right" * Zach Allaun, Mary Rose Cook, Alex Clemmer - early code review of input and window * Chase Lambert - API redesign conversation diff --git a/docs/ansi.py b/docs/ansi.py index c2c7e34..8bb92d5 100644 --- a/docs/ansi.py +++ b/docs/ansi.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright (c) 2010, Sebastian Wiesner # All rights reserved. @@ -48,36 +47,37 @@ class ansi_literal_block(nodes.literal_block): """ Represent a literal block, that contains ANSI color codes. """ + pass #: the pattern to find ANSI color codes -COLOR_PATTERN = re.compile('\x1b\\[([^m]+)m') +COLOR_PATTERN = re.compile("\x1b\\[([^m]+)m") #: map ANSI color codes to class names CODE_CLASS_MAP = { - 1: 'bold', - 4: 'underscore', - 30: 'black', - 31: 'red', - 32: 'green', - 33: 'yellow', - 34: 'blue', - 35: 'magenta', - 36: 'cyan', - 37: 'white', - 40: 'bg_black', - 41: 'bg_red', - 42: 'bg_green', - 43: 'bg_yellow', - 44: 'bg_blue', - 45: 'bg_magenta', - 46: 'bg_cyan', - 47: 'bg_white', - } - - -class ANSIColorParser(object): + 1: "bold", + 4: "underscore", + 30: "black", + 31: "red", + 32: "green", + 33: "yellow", + 34: "blue", + 35: "magenta", + 36: "cyan", + 37: "white", + 40: "bg_black", + 41: "bg_red", + 42: "bg_green", + 43: "bg_yellow", + 44: "bg_blue", + 45: "bg_magenta", + 46: "bg_cyan", + 47: "bg_white", +} + + +class ANSIColorParser: """ Traverse a document, look for ansi_literal_block nodes, parse these nodes, and replace them with literal blocks, containing proper child @@ -110,7 +110,7 @@ def _colorize_block_contents(self, block): # create the "super" node, which contains to while block and all it # sub nodes, and replace the old block with it literal_node = nodes.literal_block() - literal_node['classes'].append('ansi-block') + literal_node["classes"].append("ansi-block") block.replace_self(literal_node) # this contains "pending" nodes. A node representing an ANSI @@ -123,13 +123,13 @@ def _colorize_block_contents(self, block): last_end = 0 # iterate over all color codes for match in COLOR_PATTERN.finditer(raw): - # add any text preceeding this match - head = raw[last_end:match.start()] + # add any text preceding this match + head = raw[last_end : match.start()] self._add_text(head) # update the match end last_end = match.end() # get the single format codes - codes = [int(c) for c in match.group(1).split(';')] + codes = [int(c) for c in match.group(1).split(";")] if codes[-1] == 0: # the last code is a reset, so finalize all pending # nodes. @@ -140,8 +140,7 @@ def _colorize_block_contents(self, block): self.pending_nodes.append(code_node) # and set the classes for its colors for code in codes: - code_node['classes'].append( - 'ansi-%s' % CODE_CLASS_MAP[code]) + code_node["classes"].append("ansi-%s" % CODE_CLASS_MAP[code]) # add any trailing text tail = raw[last_end:] self._add_text(tail) @@ -151,7 +150,7 @@ def _colorize_block_contents(self, block): literal_node.extend(self.new_nodes) def _strip_color_from_block_content(self, block): - content = COLOR_PATTERN.sub('', block.rawsource) + content = COLOR_PATTERN.sub("", block.rawsource) literal_node = nodes.literal_block(content, content) block.replace_self(literal_node) @@ -160,7 +159,7 @@ def __call__(self, app, doctree, docname): Extract and parse all ansi escapes in ansi_literal_block nodes. """ handler = self._colorize_block_contents - if app.builder.name != 'html': + if app.builder.name != "html": # strip all color codes in non-html output handler = self._strip_color_from_block_content for ansi_block in doctree.traverse(ansi_literal_block): @@ -169,19 +168,19 @@ def __call__(self, app, doctree, docname): def add_stylesheet(app): if app.config.html_ansi_stylesheet: - app.add_stylesheet('ansi.css') + app.add_stylesheet("ansi.css") def copy_stylesheet(app, exception): - if app.builder.name != 'html' or exception: + if app.builder.name != "html" or exception: return stylesheet = app.config.html_ansi_stylesheet if stylesheet: - app.info(bold('Copying ansi stylesheet... '), nonl=True) - dest = path.join(app.builder.outdir, '_static', 'ansi.css') + app.info(bold("Copying ansi stylesheet... "), nonl=True) + dest = path.join(app.builder.outdir, "_static", "ansi.css") source = path.abspath(path.dirname(__file__)) copyfile(path.join(source, stylesheet), dest) - app.info('done') + app.info("done") class ANSIBlockDirective(rst.Directive): @@ -198,16 +197,16 @@ class ANSIBlockDirective(rst.Directive): option_spec = dict(string_escape=flag) def run(self): - text = '\n'.join(self.content) - if 'string_escape' in self.options: - text = text.decode('string-escape') + text = "\n".join(self.content) + if "string_escape" in self.options: + text = text.decode("string-escape") return [ansi_literal_block(text, text)] def setup(app): - app.require_sphinx('1.0') - app.add_config_value('html_ansi_stylesheet', None, 'env') - app.add_directive('ansi-block', ANSIBlockDirective) - app.connect('builder-inited', add_stylesheet) - app.connect('build-finished', copy_stylesheet) - app.connect('doctree-resolved', ANSIColorParser()) + app.require_sphinx("1.0") + app.add_config_value("html_ansi_stylesheet", None, "env") + app.add_directive("ansi-block", ANSIBlockDirective) + app.connect("builder-inited", add_stylesheet) + app.connect("build-finished", copy_stylesheet) + app.connect("doctree-resolved", ANSIColorParser()) diff --git a/docs/conf.py b/docs/conf.py index 1b3e030..ed8583a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Curtsies documentation build configuration file, created by # sphinx-quickstart on Fri Nov 14 23:02:10 2014. @@ -31,8 +30,8 @@ # ones. extensions = [ 'sphinx.ext.autodoc', + 'sphinx.ext.napoleon', 'terminal_output', - 'sphinxcontrib.napoleon', ] # Add any paths that contain templates here, relative to this directory. @@ -50,8 +49,8 @@ master_doc = 'index' # General information about the project. -project = u'Curtsies' -copyright = u'2014, Thomas Ballinger' +project = 'Curtsies' +copyright = '2014, Thomas Ballinger' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -105,7 +104,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -134,7 +133,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +# html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied @@ -203,8 +202,8 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ('index', 'Curtsies.tex', u'Curtsies Documentation', - u'Thomas Ballinger', 'manual'), + ('index', 'Curtsies.tex', 'Curtsies Documentation', + 'Thomas Ballinger', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -233,8 +232,8 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'curtsies', u'Curtsies Documentation', - [u'Thomas Ballinger'], 1) + ('index', 'curtsies', 'Curtsies Documentation', + ['Thomas Ballinger'], 1) ] # If true, show URL addresses after external links. @@ -247,8 +246,8 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'Curtsies', u'Curtsies Documentation', - u'Thomas Ballinger', 'Curtsies', 'One line description of project.', + ('index', 'Curtsies', 'Curtsies Documentation', + 'Thomas Ballinger', 'Curtsies', 'One line description of project.', 'Miscellaneous'), ] diff --git a/docs/examples.rst b/docs/examples.rst index f956128..192b99a 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -1,15 +1,15 @@ Examples ^^^^^^^^ -* `Tic-Tac-Toe `_ +* `Tic-Tac-Toe `_ -.. image:: http://i.imgur.com/AucB55B.png +.. image:: images/tic-tac-toe.png -* `Avoid the X's game `_ +* `Avoid the X's game `_ -.. image:: http://i.imgur.com/nv1RQd3.png +.. image:: images/avoid-the-x.png -* `Bpython-curtsies uses curtsies `_ +* `Bpython-curtsies uses curtsies `_ -.. image:: http://i.imgur.com/r7rZiBS.png - :target: http://www.youtube.com/watch?v=lwbpC4IJlyA +.. image:: images/bpython-video.png + :target: https://www.youtube.com/watch?v=lwbpC4IJlyA diff --git a/docs/gameloop.rst b/docs/gameloop.rst index 08385c3..154c25a 100644 --- a/docs/gameloop.rst +++ b/docs/gameloop.rst @@ -1,7 +1,7 @@ Gameloop Example ^^^^^^^^^^^^^^^^ -Use scheduled events for realtime interative programs: +Use scheduled events for realtime interactive programs: .. literalinclude:: ../examples/fps.py diff --git a/docs/images/avoid-the-x.png b/docs/images/avoid-the-x.png new file mode 100644 index 0000000..3cd25a1 Binary files /dev/null and b/docs/images/avoid-the-x.png differ diff --git a/docs/images/bpython-video.png b/docs/images/bpython-video.png new file mode 100644 index 0000000..cda54e9 Binary files /dev/null and b/docs/images/bpython-video.png differ diff --git a/docs/images/logo.png b/docs/images/logo.png new file mode 100644 index 0000000..9821b6c Binary files /dev/null and b/docs/images/logo.png differ diff --git a/docs/images/shoes.png b/docs/images/shoes.png new file mode 100644 index 0000000..7d27d25 Binary files /dev/null and b/docs/images/shoes.png differ diff --git a/docs/images/tic-tac-toe.png b/docs/images/tic-tac-toe.png new file mode 100644 index 0000000..cc0077e Binary files /dev/null and b/docs/images/tic-tac-toe.png differ diff --git a/docs/index.rst b/docs/index.rst index 4c51360..2699643 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,24 +1,24 @@ Curtsies documentation ^^^^^^^^^^^^^^^^^^^^^^ -.. |shoes| image:: http://ballingt.com/assets/curtsies-tritone-small.png -.. |curtsiestitle| image:: http://ballingt.com/assets/curtsiestitle.png +.. |shoes| image:: images/shoes.png +.. |curtsiestitle| image:: images/logo.png |curtsiestitle| -Curtsies is a library for interacting with the terminal. +Curtsies is a Python 2.6+ & 3.3+ compatible library for interacting with the terminal. -:py:class:`~curtsies.formatstring.FmtStr` objects are strings formatted with -colors and styles displayable in a terminal with `ANSI escape sequences `_. -:py:class:`~curtsies.formatstringarray.FSArray` objects contain multiple such strings +:py:class:`~curtsies.FmtStr` objects are strings formatted with +colors and styles displayable in a terminal with `ANSI escape sequences `_. +:py:class:`~curtsies.FSArray` objects contain multiple such strings with each formatted string on its own row, and can be superimposed onto each other to build complex grids of colored and styled characters. Such grids of characters can be efficiently rendered to the terminal in alternate screen mode -(no scrollback history, like ``Vim``, ``top`` etc.) by :py:class:`~curtsies.window.FullscreenWindow` objects -or to the normal history-preserving screen by :py:class:`~curtsies.window.CursorAwareWindow` objects. +(no scrollback history, like ``Vim``, ``top`` etc.) by :py:class:`~curtsies.FullscreenWindow` objects +or to the normal history-preserving screen by :py:class:`~curtsies.CursorAwareWindow` objects. User keyboard input events like pressing the up arrow key are detected by an -:py:class:`~curtsies.input.Input` object. See the :doc:`quickstart` to get started using +:py:class:`~curtsies.Input` object. See the :doc:`quickstart` to get started using all of these classes. .. toctree:: @@ -29,6 +29,7 @@ all of these classes. FSArray window Input + game gameloop examples about diff --git a/docs/requirements.txt b/docs/requirements.txt index 6465003..6b435b0 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ -ansi2html==1.0.7 -pexpect==3.3 -git+git://github.com/thomasballinger/wcwidth.git@fixes -git+git://github.com/thomasballinger/curtsies.git@master -sphinxcontrib-napoleon +ansi2html==1.9.2 +pexpect==4.9.0 +sphinx==8.1.3 +sphinx-rtd-theme==3.0.1 +-e .. diff --git a/docs/terminal_output.py b/docs/terminal_output.py index 8f8bd06..861dd45 100644 --- a/docs/terminal_output.py +++ b/docs/terminal_output.py @@ -1,94 +1,111 @@ -# -*- coding: utf-8 -*- """Sphinx directive for ansi-formatted output sphinxcontrib-ansi seems to be the right thing to use, but it's missing sequences. It does the right thing and remove color when output format isn't html. This just always outputs raw html. """ + import re import sys from textwrap import dedent try: - from StringIO import StringIO + # python2 + from StringIO import StringIO as BytesIO except ImportError: - from io import StringIO + from io import BytesIO -from sphinx.util.compat import Directive +from docutils.parsers.rst import Directive from docutils import nodes import pexpect from ansi2html import Ansi2HTMLConverter + class python_terminal_block(nodes.literal_block): pass + def htmlize(ansi): conv = Ansi2HTMLConverter(inline=True, dark_bg=True) return conv.convert(ansi, full=False) -class ANSIHTMLParser(object): + +class ANSIHTMLParser: def __call__(self, app, doctree, docname): handler = self._format_it - if app.builder.name not in ['html', 'readthedocs']: + if app.builder.name not in ["html", "readthedocs"]: # strip all color codes in non-html output handler = self._strip_color_from_block_content for ansi_block in doctree.traverse(python_terminal_block): handler(ansi_block) def _strip_color_from_block_content(self, block): - content = re.sub('\x1b\\[([^m]+)m', '', block.rawsource) + content = re.sub("\x1b\\[([^m]+)m", "", block.rawsource) literal_node = nodes.literal_block(content, content) block.replace_self(literal_node) def _format_it(self, block): source = block.rawsource content = htmlize(source) - formatted = "
%s
" % (content,) - raw_node = nodes.raw(formatted, formatted, format='html') + formatted = "
{}
".format(content) + raw_node = nodes.raw(formatted, formatted, format="html") block.replace_self(raw_node) def default_colors_to_resets(s): """Hack to make sphinxcontrib.ansi recognized sequences""" - return s.replace('[39m', '[0m').replace('[49m', '[0m') + return s.replace(b"[39m", b"[0m").replace(b"[49m", b"[0m") + def run_lines(lines): - child = pexpect.spawn(sys.executable + ' -i') - out = StringIO() + child = pexpect.spawn(sys.executable + " -i") + out = BytesIO() # TODO make this a string? child.logfile_read = out - #TODO make this detect `...` when it shouldn't be there, forgot a ) + # TODO make this detect `...` when it shouldn't be there, forgot a ) for line in lines: - child.expect(['>>> ', '... ']) + child.expect([">>> ", "... "]) child.sendline(line) child.sendeof() child.read() out.seek(0) output = out.read() - return output[output.index('>>>'):output.rindex('>>>')] + return output[output.index(b">>>") : output.rindex(b">>>")] + def get_lines(multiline_string): - lines = dedent(multiline_string).split('\n') + lines = dedent(multiline_string).split("\n") while lines and not lines[0]: lines.pop(0) while lines and not lines[-1]: lines.pop() return lines + class PythonTerminalDirective(Directive): """Execute the specified python code and insert the output into the document""" + has_content = True def run(self): - text = default_colors_to_resets(run_lines(get_lines('\n'.join(self.content)))) - return [python_terminal_block(text.decode('utf8'), text.decode('utf8'))] + text = default_colors_to_resets(run_lines(get_lines("\n".join(self.content)))) + return [python_terminal_block(text.decode("utf8"), text.decode("utf8"))] + def setup(app): - app.add_directive('python_terminal_session', PythonTerminalDirective) - app.connect('doctree-resolved', ANSIHTMLParser()) + app.add_directive("python_terminal_session", PythonTerminalDirective) + app.connect("doctree-resolved", ANSIHTMLParser()) -if __name__ == '__main__': - print htmlize(run_lines(get_lines(""" + +if __name__ == "__main__": + print( + htmlize( + run_lines( + get_lines( + """ from curtsies.fmtfuncs import blue blue('hello') print blue('hello') - """))) - + """ + ) + ) + ) + ) diff --git a/docs/window.rst b/docs/window.rst index d74fcfd..bd70bd2 100644 --- a/docs/window.rst +++ b/docs/window.rst @@ -3,16 +3,16 @@ Window Objects .. automodule:: curtsies.window -Windows successively render 2D grids of text (usually instances of :py:class:`~curtsies.formatstringarray.FSArray`) +Windows successively render 2D grids of text (usually instances of :py:class:`~curtsies.FSArray`) to the terminal. A window owns its output stream - it is assumed (but not enforced) that no additional data is written to this stream between renders, -an assumption which allowing for example portions of the screen which do not change between renderings not to be redrawn during a rendering. +an assumption which allows for example portions of the screen which do not change between renderings not to be redrawn during a rendering. -There are two useful window classes, both subclasses of :py:class:`~curtsies.window.BaseWindow`: :py:class:`~curtsies.window.FullscreenWindow` -renders to the terminal's `alternate screen buffer `_ +There are two useful window classes, both subclasses of :py:class:`~curtsies.window.BaseWindow`. :py:class:`~curtsies.FullscreenWindow` +renders to the terminal's `alternate screen buffer `_ (no history preserved, like command line tools ``Vim`` and ``top``) -while :py:class:`~curtsies.window.CursorAwareWindow` renders to the normal screen. +while :py:class:`~curtsies.CursorAwareWindow` renders to the normal screen. It is also is capable of querying the terminal for the cursor location, and uses this functionality to detect how a terminal moves its contents around during a window size change. @@ -39,22 +39,22 @@ it's important not to write to the stream the window is using (usually ``sys.std Terminal window contents and even cursor position are assumed not to change between renders. Any change that does occur in cursor position is attributed to movement of content in response to a window size change and is used to calculate how this content has moved, -necessary because this behavior differs between terminal emulators. +which is necessary because this behavior differs between terminal emulators. -Entering the context of a FullscreenWindow object hides the cursor and switches to -the alternate terminal screen. Entering the context of a CursorAwareWindow hides +Entering the context of a :py:class:`~curtsies.FullscreenWindow` object hides the cursor and switches to +the alternate terminal screen. Entering the context of a :py:class:`~curtsies.CursorAwareWindow` hides the cursor, turns on cbreak mode, and records the cursor position. Leaving the context does more or less the inverse. Window Objects - API Docs ========================= -.. autoclass:: BaseWindow +.. autoclass:: curtsies.window.BaseWindow :members: -.. autoclass:: FullscreenWindow +.. autoclass:: curtsies.FullscreenWindow :members: -.. autoclass:: CursorAwareWindow +.. autoclass:: curtsies.CursorAwareWindow :members: diff --git a/examples/chat.py b/examples/chat.py index 99e508d..8d3f7b0 100644 --- a/examples/chat.py +++ b/examples/chat.py @@ -1,4 +1,3 @@ -from __future__ import unicode_literals """A more realtime netcat""" import sys import select @@ -8,7 +7,7 @@ from curtsies.formatstring import linesplit from curtsies.fmtfuncs import blue, red, green -class Connection(object): +class Connection: def __init__(self, sock): self.sock = sock self.received = [] @@ -65,6 +64,6 @@ def main(host, port): print('usage: python chat.py google.com 80') print('(if you use this example, try typing') print('GET /') - print('and then hitting enter)') + print('and then hitting enter twice.)') else: main(host, int(port)) diff --git a/examples/demo_fullscreen_window.py b/examples/demo_fullscreen_window.py index 32bc481..a657209 100644 --- a/examples/demo_fullscreen_window.py +++ b/examples/demo_fullscreen_window.py @@ -6,6 +6,10 @@ from curtsies import events from demo_window import array_size_test +""" +Reads input from user and prints an entire screen, one line less than a full screen, +or one line more than the full screen +""" if __name__ == '__main__': logging.basicConfig(filename='display.log',level=logging.DEBUG) diff --git a/examples/demo_fullscreen_with_input.py b/examples/demo_fullscreen_with_input.py index cc64e33..bb91bc3 100644 --- a/examples/demo_fullscreen_with_input.py +++ b/examples/demo_fullscreen_with_input.py @@ -4,18 +4,24 @@ from curtsies import input, Cbreak, FullscreenWindow, fmtstr def fullscreen_winch_with_input(): + """ + Monitors user input as well as screen size and acknowledges changes to both. + """ print('this should be just off-screen') w = FullscreenWindow(sys.stdout) def sigwinch_handler(signum, frame): - print('sigwinch! Changed from %r to %r' % ((rows, columns), (w.height, w.width))) + print(f'sigwinch! Changed from {(rows, columns)!r} to {(w.height, w.width)!r}') signal.signal(signal.SIGWINCH, sigwinch_handler) with w: with Cbreak(sys.stdin): for e in input.Input(): rows, columns = w.height, w.width - a = [fmtstr((('.%sx%s.%r.' % (rows, columns, e)) * rows)[:columns]) for row in range(rows)] + a = [fmtstr(((f'.{rows}x{columns}.{e!r}.') * rows)[:columns]) for row in range(rows)] w.render_to_terminal(a) + if e == '': + break + if __name__ == '__main__': fullscreen_winch_with_input() diff --git a/examples/demo_input_paste.py b/examples/demo_input_paste.py index 49f2980..0c24106 100644 --- a/examples/demo_input_paste.py +++ b/examples/demo_input_paste.py @@ -1,10 +1,19 @@ from curtsies.input import * def paste(): + """ + Returns user input, delayed by one second, as a string; creates a paste event for + strings longer than the paste threshold and returns a list of the characters in + the string separated by commas. + """ with Input() as input_generator: print("If more than %d chars read in same read a paste event is generated" % input_generator.paste_threshold) for e in input_generator: print(repr(e)) + + if e == '': + break + time.sleep(1) if __name__ == '__main__': diff --git a/examples/demo_input_timeout.py b/examples/demo_input_timeout.py index 325a1ac..2b9d9b2 100644 --- a/examples/demo_input_timeout.py +++ b/examples/demo_input_timeout.py @@ -1,6 +1,11 @@ from curtsies.input import * def main(): + """ + Reads and returns user input; after 2 seconds, 1 second, .5 second + and .2 second, respectively, an event -- user input -- is printed, + or "None" is printed if no user input is received. + """ with Input() as input_generator: print(repr(input_generator.send(2))) print(repr(input_generator.send(1))) @@ -8,5 +13,7 @@ def main(): print(repr(input_generator.send(.2))) for e in input_generator: print(repr(e)) + if e == '': + break if __name__ == '__main__': main() diff --git a/examples/demo_scrolling.py b/examples/demo_scrolling.py index 35acc30..6a8bc24 100644 --- a/examples/demo_scrolling.py +++ b/examples/demo_scrolling.py @@ -5,6 +5,10 @@ rows, columns = '??' def cursor_winch(): + """ + Reports (signals) change in window dimensions; reports change in position + of cursor + """ global rows, columns # instead of closure for Python 2 compatibility print('this should be just off-screen') w = CursorAwareWindow(sys.stdout, sys.stdin, keep_last_line=False, hide_cursor=False) @@ -13,7 +17,7 @@ def sigwinch_handler(signum, frame): dy = w.get_cursor_vertical_diff() old_rows, old_columns = rows, columns rows, columns = w.height, w.width - print('sigwinch! Changed from %r to %r' % ((old_rows, old_columns), (rows, columns))) + print(f'sigwinch! Changed from {(old_rows, old_columns)!r} to {(rows, columns)!r}') print('cursor moved %d lines down' % dy) w.write(w.t.move_up) w.write(w.t.move_up) @@ -21,9 +25,9 @@ def sigwinch_handler(signum, frame): with w: for e in input.Input(): rows, columns = w.height, w.width - a = [fmtstr(((u'.%sx%s.' % (rows, columns)) * rows)[:columns]) for row in range(rows)] + a = [fmtstr(((f'.{rows}x{columns}.') * rows)[:columns]) for row in range(rows)] w.render_to_terminal(a) - if e == u'': + if e == '': break if __name__ == '__main__': cursor_winch() diff --git a/examples/demo_window.py b/examples/demo_window.py index 6ccef2d..a163f38 100644 --- a/examples/demo_window.py +++ b/examples/demo_window.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import sys import signal import logging @@ -8,7 +6,7 @@ from curtsies import events def array_size_test(window): - """Tests arrays one row to small or too large""" + """Tests arrays one row too small or too large""" with window as w: print('a displays a screen worth of input, s one line less, and d one line more') with input.Input(sys.stdin) as input_generator: @@ -38,6 +36,8 @@ def array_size_test(window): w.scroll_down() elif isinstance(c, events.WindowChangeEvent): a = w.array_from_text("window just changed to %d rows and %d columns" % (c.rows, c.columns)) + elif c == '': # allows exit without keyboard interrupt + break elif c == '\x0c': # ctrl-L [w.write('\n') for _ in range(rows)] continue diff --git a/examples/fps.py b/examples/fps.py index 8704aaa..ce24896 100644 --- a/examples/fps.py +++ b/examples/fps.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals, division - import time from curtsies import FullscreenWindow, Input, FSArray @@ -9,7 +7,7 @@ class Frame(curtsies.events.ScheduledEvent): pass -class World(object): +class World: def __init__(self): self.s = 'Hello' def tick(self): @@ -35,9 +33,10 @@ def realtime(fps=15): while when < time.time(): when += dt schedule_next_frame(when) - elif e == u'': + elif e == '': break else: world.process_event(e) -realtime() +if __name__ == "__main__": + realtime() diff --git a/examples/gameexample.py b/examples/gameexample.py index 3c2a587..6852000 100644 --- a/examples/gameexample.py +++ b/examples/gameexample.py @@ -1,12 +1,11 @@ -from __future__ import unicode_literals - import itertools import sys from curtsies import FullscreenWindow, Input, FSArray from curtsies.fmtfuncs import red, bold, green, on_blue, yellow, on_red -class Entity(object): + +class Entity: def __init__(self, display, x, y, speed=1): self.display = display self.x, self.y = x, y @@ -27,13 +26,13 @@ def sign(n): def vscale(c, v): return tuple(c*x for x in v) -class World(object): +class World: def __init__(self, width, height): self.width = width self.height = height n = 5 - self.player = Entity(on_blue(green(bold(u'5'))), width // 2, height // 2 - 2, speed=5) - self.npcs = [Entity(on_blue(red(u'X')), i * width // (n * 2), j * height // (n * 2)) + self.player = Entity(on_blue(green(bold('5'))), width // 2, height // 2 - 2, speed=5) + self.npcs = [Entity(on_blue(red('X')), i * width // (n * 2), j * height // (n * 2)) for i in range(1, 2*n, 2) for j in range(1, 2*n, 2)] self.turn = 0 @@ -70,7 +69,7 @@ def tick(self): self.turn += 1 if self.turn % 20 == 0: self.player.speed = max(1, self.player.speed - 1) - self.player.display = on_blue(green(bold(str(self.player.speed).decode('utf8')))) + self.player.display = on_blue(green(bold(str(self.player.speed)))) def get_array(self): a = FSArray(self.height, self.width) diff --git a/examples/initial_input.py b/examples/initial_input.py index 381c870..b8ebd7f 100644 --- a/examples/initial_input.py +++ b/examples/initial_input.py @@ -8,5 +8,7 @@ def main(): with Input() as input_generator: for e in input_generator: print(repr(e)) + if e == '': + break if __name__ == '__main__': main() diff --git a/examples/initial_input_with_cursor.py b/examples/initial_input_with_cursor.py index 5e6fdd3..66df9e5 100644 --- a/examples/initial_input_with_cursor.py +++ b/examples/initial_input_with_cursor.py @@ -14,6 +14,8 @@ def extra_bytes_callback(string): window.get_cursor_position() for e in input_generator: print(repr(e)) + if e == '': + break if __name__ == '__main__': main() diff --git a/examples/quickstart.py b/examples/quickstart.py index 7094d8f..a841ab3 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -1,25 +1,25 @@ -from __future__ import unicode_literals # convenient for Python 2 import random from curtsies import FullscreenWindow, Input, FSArray from curtsies.fmtfuncs import red, bold, green, on_blue, yellow -print(yellow('this prints normally, not to the alternate screen')) -with FullscreenWindow() as window: - with Input() as input_generator: - msg = red(on_blue(bold('Press escape to exit'))) - a = FSArray(window.height, window.width) - a[0:1, 0:msg.width] = [msg] - window.render_to_terminal(a) - for c in input_generator: - if c == '': - break - elif c == '': - a = FSArray(window.height, window.width) - else: - s = repr(c) - row = random.choice(range(window.height)) - column = random.choice(range(window.width-len(s))) - color = random.choice([red, green, on_blue, yellow]) - a[row, column:column+len(s)] = [color(s)] +if __name__ == '__main__': + print(yellow('this prints normally, not to the alternate screen')) + with FullscreenWindow() as window: + with Input() as input_generator: + msg = red(on_blue(bold('Press escape to exit'))) + a = FSArray(window.height, window.width) + a[0:1, 0:msg.width] = [msg] window.render_to_terminal(a) + for c in input_generator: + if c == '': + break + elif c == '': + a = FSArray(window.height, window.width) + else: + s = repr(c) + row = random.choice(range(window.height)) + column = random.choice(range(window.width-len(s))) + color = random.choice([red, green, on_blue, yellow]) + a[row, column:column+len(s)] = [color(s)] + window.render_to_terminal(a) diff --git a/examples/realtime.py b/examples/realtime.py index 60bb0e4..7492044 100644 --- a/examples/realtime.py +++ b/examples/realtime.py @@ -8,7 +8,7 @@ MAX_FPS = 1000 time_per_frame = 1. / MAX_FPS -class FrameCounter(object): +class FrameCounter: def __init__(self): self.render_times = [] self.dt = .5 diff --git a/examples/simple.py b/examples/simple.py index 0c4efb4..59a047e 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -3,6 +3,7 @@ from curtsies import FullscreenWindow, Input, FSArray def main(): + """Returns user input placed randomly on the screen""" with FullscreenWindow() as window: print('Press escape to exit') with Input() as input_generator: diff --git a/examples/snake.py b/examples/snake.py index 22b20ed..922554d 100644 --- a/examples/snake.py +++ b/examples/snake.py @@ -12,7 +12,8 @@ '': (0, 1), } -class Snake(object): +class Snake: + """Creates a Snake (game) object""" def __init__(self, height, width): self.height = height self.width = width @@ -21,25 +22,30 @@ def __init__(self, height, width): self.new_apple() def random_spot(self): + """Creates a random spot for characters""" return random.choice(range(self.height)), random.choice(range(self.width)) def new_apple(self): + """Places a new apple in the window randomly for the snake to find""" while True: self.apple = self.random_spot() if self.apple not in self.snake_parts: break def advance_snake(self): + """Adds to snake once it obtains an apple""" self.snake_parts.insert(0, (self.snake_parts[0][0]+self.direction[0], self.snake_parts[0][1]+self.direction[1])) def render(self): + a = FSArray(self.height, self.width) for row, col in self.snake_parts: - a[row, col] = u'x' - a[self.apple[0], self.apple[1]] = u'o' + a[row, col] = 'x' + a[self.apple[0], self.apple[1]] = 'o' return a def tick(self, e): + if (e in key_directions and abs(key_directions[e][0]) + abs(self.direction[0]) < 2 and abs(key_directions[e][1]) + abs(self.direction[1]) < 2): @@ -55,8 +61,10 @@ def tick(self, e): self.snake_parts.pop() def main(): - MAX_FPS = 20 + """Sets speed for snake and begins game upon receiving input from user""" + MAX_FPS = 4 time_per_frame = lambda: 1. / MAX_FPS + input("Press enter to start") with FullscreenWindow() as window: with Input() as input_generator: diff --git a/examples/sumtest.py b/examples/sumtest.py index 0ead41d..9a6da54 100644 --- a/examples/sumtest.py +++ b/examples/sumtest.py @@ -13,6 +13,7 @@ def timeit(n): print(n, ':', t1 - t0) return (t1 - t0) -ns = range(100, 2000, 100) +if __name__ == '__main__': + ns = range(100, 2000, 100) -times = [timeit(i) for i in ns] + times = [timeit(i) for i in ns] diff --git a/examples/tictactoeexample.py b/examples/tictactoeexample.py index 11caef8..e8bfbb2 100644 --- a/examples/tictactoeexample.py +++ b/examples/tictactoeexample.py @@ -3,7 +3,7 @@ from curtsies.fmtfuncs import * from curtsies import FullscreenWindow, Input, fsarray -class Board(object): +class Board: """ >>> Board().rows ((' ', ' ', ' '), (' ', ' ', ' '), (' ', ' ', ' ')) diff --git a/examples/tron.py b/examples/tron.py new file mode 100644 index 0000000..d6bd19e --- /dev/null +++ b/examples/tron.py @@ -0,0 +1,233 @@ +import time +import sys + +from curtsies import FullscreenWindow, Input, FSArray, fmtstr, fsarray +from curtsies.fmtfuncs import ( + bold, + yellow, + on_blue, + cyan, + on_yellow, + on_red, + black, +) +import curtsies.events + + +class Cycle: + def __init__(self, attr_dict): + self.appearance = attr_dict["appearance"] + self.x, self.y = attr_dict["x"], attr_dict["y"] + self.keylist = attr_dict["keys"] + self.dir = 0 + + def move(self, grid): + d = self.dir + if d == 0: + self.x += 1 + elif d == 90: + self.y -= 1 + elif d == 180: + self.x -= 1 + elif d == 270: + self.y += 1 + + def face(self, newdir): + """ turn to face the given direction """ + if not (newdir % 180 == self.dir % 180): + self.dir = newdir + + def paint(self, grid): + """ given a grid object, adds self to the grid and returns new grid + """ + grid[self.y, self.x] = self.appearance + return grid + + +class Bot(Cycle): + """ Dumb bot that only turns right when it's about to crash + """ + + def move(self, grid): + + a = self.getNextSquareCoords() + if grid[a[0], a[1]] != [" "]: + self.turnLeft() + super().move(grid) + + def turnRight(self): + self.dir = (self.dir - 90) % 360 + + def turnLeft(self): + self.dir = (self.dir + 90) % 360 + + def getNextSquareCoords(self, n=2): + """ Returns the x, y coords of the box n spots in front of the bot. + """ + if self.dir == 0: + return (self.y, self.x + n) + elif self.dir == 90: + return (self.y - n, self.x) + elif self.dir == 180: + return (self.y, self.x - n) + else: + return (self.y + n, self.x) + + +class gameboard: + def __init__(self, width, height, players): + self.width = width + self.height = height + self.grid = FSArray(height, width) + + self.players = players + self.numplayers = len(self.players) + + def draw_border(self): + """ draw outer border """ + box = on_yellow(black(bold("!"))) + for x in range(1, self.width): + self.grid[1, x] = box + self.grid[self.height - 1, x] = box + for y in range(1, self.height): + self.grid[y, 1] = box + self.grid[y, self.width - 1] = box + + def process_event(self, key): + if key == " ": + sys.exit() + + for player in self.players: + if key in player.keylist: + player.face(player.keylist[key]) + return False + + def tick(self): + """ Do one frame of work. Returns the winner if there + is a crash + """ + # list of players alive this frame + temp_players = self.players[:] + + for bike in self.players: + bike.move(self.grid) + + if self.grid[bike.y, bike.x] == [" "]: + self.grid[bike.y, bike.x] = bike.appearance + else: # crashed + temp_players.remove(bike) + self.grid[bike.y, bike.x] = fmtstr("X", "red", "bold", "on_black") + + if len(temp_players) == 0: + return "tie" + elif len(temp_players) < len(self.players): + return temp_players + + def winner_msg(self, tick): + if len(tick) != 1: + msg = fmtstr("it's a tie!", "red") + else: + winner = tick[0] + winner_name = winner.appearance + msg = fmtstr("winner:", "yellow") + msg += " " + (winner_name * 5) + return msg + + +class Frame(curtsies.events.ScheduledEvent): + pass + + +def do_introduction(window): + h, w = window.height, window.width + + messages = [ + "two player tron", + fmtstr("player 1:", "on_blue", "cyan") + " wasd", + fmtstr("player 2:", "on_red", "yellow") + " arrow keys", + ] + + billboard = FSArray(h, w) + msg_row = h // 2 - 2 + for msg in messages: + billboard[ + msg_row, w // 2 - len(msg) // 2 : w // 2 + len(msg) // 2 + 1 + ] = fsarray([msg]) + msg_row += 1 + window.render_to_terminal(billboard) + + # countdown msg + for i in range(3, 0, -1): + billboard[msg_row, w // 2] = fmtstr(str(i), "red") + window.render_to_terminal(billboard) + time.sleep(1) + + +def mainloop(window, p2_bot=False): + p1_attrs = { + "appearance": on_blue(cyan("1")), + "x": window.width // 4, + "y": window.height // 2, + "keys": {"w": 90, "a": 180, "s": 270, "d": 0}, + } + + p2_attrs = { + "appearance": on_red(yellow("2")), + "x": 3 * window.width // 4, + "y": window.height // 2, + "keys": {"": 90, "": 180, "": 270, "": 0}, + } + + FPS = 15 + + players = [Cycle(p1_attrs), Cycle(p2_attrs)] + if p2_bot: # make p2 a bot + players[1] = Bot(p2_attrs) + + world = gameboard(window.width, window.height, players) + dt = 1 / FPS + world.draw_border() + window.render_to_terminal(world.grid) + + reactor = Input() + schedule_next_frame = reactor.scheduled_event_trigger(Frame) + schedule_next_frame(when=time.time()) + with reactor: + for c in reactor: + if isinstance(c, Frame): + tick = world.tick() + window.render_to_terminal(world.grid) + if not tick: # if no crashes + when = c.when + dt + while when < time.time(): + when += dt + schedule_next_frame(when) + else: # if crashed + world.grid[0:4, 0:25] = fsarray( + [ + world.winner_msg(tick), + "r to restart", + "q to quit", + "b to make player 2 a bot", + ] + ) + window.render_to_terminal(world.grid) + elif c.lower() in ["r", "q", "b"]: + break + else: # common case + world.process_event(c) + if c.lower() == "r": + mainloop(window, p2_bot) + elif c.lower() == "b": + mainloop(window, True) + + +def main(): + with FullscreenWindow(sys.stdout) as window: + do_introduction(window) + + mainloop(window) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/tttplaybitboard.py b/examples/tttplaybitboard.py index d1f8c1e..a29d278 100755 --- a/examples/tttplaybitboard.py +++ b/examples/tttplaybitboard.py @@ -15,8 +15,8 @@ from curtsies import FullscreenWindow, Input, fsarray def main(argv): - pool = dict((name[:-5], play) for name, play in globals().items() - if name.endswith('_play')) + pool = {name[:-5]: play for name, play in globals().items() + if name.endswith('_play')} faceoff = [human_play, max_play] try: if len(argv) == 1: diff --git a/timing_notes.txt b/notes/timing_notes.txt similarity index 100% rename from timing_notes.txt rename to notes/timing_notes.txt diff --git a/window_resize_notes.txt b/notes/window_resize_notes.txt similarity index 100% rename from window_resize_notes.txt rename to notes/window_resize_notes.txt diff --git a/notesfromdarius.txt b/notesfromdarius.txt deleted file mode 100644 index 9021b0a..0000000 --- a/notesfromdarius.txt +++ /dev/null @@ -1,195 +0,0 @@ -insert should prob. be "splice" - is it an exposed api? check bpython, if not then change this. - -fmtstr is kind of like racket graphics primitives - change the world, use immutable building blocks - look into "racket worlds" - -* look at "how to design programs!!!" Maybe try to get a beginner that's super interested to do this? - -read this: http://www.cs.utexas.edu/~wcook/Drafts/2009/essay.pdf - -* play with smalltalk: make a bouncing ball etc, changing the code while it's bouncing, - stop it etc. - - (erlang was also cool like that, maybe look at it again) - -played at all with sounds fairly diff, cool in that syate very explicty very cool - - i assume you've seen how objc- does it? - -sum-of a and b paramname arg paramname arg - -I was guessing it would use font, but I was wrong - -or font or something - -check out Darius's halp - for editor <-> repl interaction without the repl - -I going to get some food - -re you saying the emssages would be things like "do A" - - -this could also be pass this message - -right now it's not realtime it blocks on getting an event, and has no timer events or - butit'd be nice to change - -I thoguht the simpler model was perfect for a repl - wait until user does somethign - but code can do anything -which way? - -I guess I don't wan tto deal with threads unless each thread is an event driven loop <- I just don't hvae this yet - -right now it's a single thread of execution - -UI -UI -UI -UI -block on input -got input -it was a return key - so run code -run code -run code -oh code wants to read on stdin - so this is a greenet, suspend execution and - -the realization I had was that in bptuhon-curtiess I 'm also emulating the terminal - I'm responsible for colelcting that the user hit a key at *any* time, not jsut during input - -so it really feels like an evented system? - at least queues and messaging and things - -My problem was I was trying to be like vanilla python - but it was a terminal to collect input in the meantime - - - - - - - - - - - - - - - -MOre thoughts on Curtsies on May 28 - - - - - - -I dont' know why, but it's nice for right consuming other input i guess - though I don't do a good enough job - -the structure is the BaseFmtStrs that are immutable strings with properties like blue, and then FmtStrs are lists of those - -frozen dict - add hash - -originally fmtstr and basefmtstrs were mutable and to keep myself honest I wasnted - -could you only have i - -deeplly immutable - E -not obvious what this does -then it's a boolean thing, so presence is enought -shoudl be documentation of that - -self.atts is a dictionary of {fg:blue, bg:red, bold:Trueo - -I've not been working on this much for ahwile, but I could get excited about it - it's messy now - -I'm actually paying lea A. (hs'er) to make a logo! So if I ha - -Also if bpython starts to use this as ath main thing, I'lll need to work on cleanign it up. - -I guess so I don't have release things set up - I could give you github comit access for this and just put it on another brnach for now? - -git pull upstream - - -the readme is the only docs right now - -mostly tests - -I wrote the whole thing tried to do it clearly, then had performnace problems and went - -just python slice bookkeeping - -it's poorly named, it doens't accomplish that - it's just - -asdf[1:] -normalize_slice(4, slice(1, None, None)) -> slice(1, 4, 1) - -It shouldn't be called normalize, all it does is get rid of the None's in a slice object -and replace them with the real numbers based on the length of the things we're slicing - - -right we read from in_buffer to chars - - -chars in_buffer - - ESC [ \ 1 2 - - - - - -you're not worrying about paste that's fine, i just had speed concerns - -it's a hack - you have no - -"we can't keep up - so speed up, user program!" - - -paste_mode means to things probably -init(paste_mode=True) means allow pastes to be detected, not that w'eer ecurrently in a - - -meaning that sequence isn't recognizd or isn't valid - -this depends on every input having - -if ab is passed it assumes it must have alrady been called with a - - - ther'e no speed concertn, we could assert that too - -char is always exactly what we pass to get_key( - -so we have the event for that - -we maybe should, but we don't clear chars - - -C and chars + self.in_buffer, but one of C and chars is empty / None - -tha'ts not used and just getting in the way - -fake_input - -just for a demo or something - -t's not used for anything - - -better code - -it's used to crawl the fs for importable python modules in bpython - -cleaner probably is what you've got with a timeout, where you just get_event(timeout=0), then do your stuff, then - -keys_in_buffer? - -(in_buffer or non_blocking_read) - -chars_available - - -sigints that happen in this curtsies cdoe to generate the events, but if we not in that, they dont' have to - specifically for bpython - - -in cbreak ctrl-c still causes a handler to happen, so maybe I don't need custon ones at all, just let bpython do thatk - -take sigwinch out of bit loop? - - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ef7878 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = [ + "setuptools >= 46.4.0", +] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 88 +target_version = ["py310"] +exclude = ''' +( + /( + \.git + | build + | curtsies.egg-info + | dist + | examples + | notes + | stubs + )/ + | bootstrap.py + | docs/conf.py +) +''' diff --git a/setup.cfg b/setup.cfg index 4d0274e..a208fa0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,40 @@ -[nosetests] -with-doctest=1 -cover-package=curtsies -cover-html=1 -[bdist_wheel] -universal = 1 +[metadata] +name = curtsies +version = attr: curtsies.__version__ +description = Curses-like terminal wrapper, with colored strings! +long_description = file: README.md, +long_description_content_type = text/markdown +url = https://github.com/bpython/curtsies +author = Thomas Ballinger +author_email = thomasballinger@gmail.com +license = MIT +license_files = LICENSE +classifiers = + Development Status :: 3 - Alpha + Environment :: Console + Intended Audience :: Developers + License :: OSI Approved :: MIT License + Operating System :: POSIX + Programming Language :: Python + Programming Language :: Python :: 3 + +[options] +python_requires = >=3.10 +zip_safe = False +packages = curtsies +install_requires = + blessed>=1.5 + cwcwidth +tests_require = + pyte + pytest + +[options.package_data] +curtsies = py.typed + +[mypy] +warn_return_any = True +warn_unused_configs = True +mypy_path=stubs +files=curtsies +disallow_untyped_defs = True diff --git a/setup.md b/setup.md new file mode 100644 index 0000000..483b0d8 --- /dev/null +++ b/setup.md @@ -0,0 +1,48 @@ +# Development Setup +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! + +## Set up for development +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 + + $ git clone https://github.com/bpython/curtsies.git + $ cd curtsies + $ pip install -e . + +## Running Tests +Tests are written using the unittest framework and are run using using [nosetests](https://nose.readthedocs.io/en/latest/). To run all tests, do: + + $ pip install pyte coverage mock nose + $ nosetests . + +## Style / Formatting +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. + +To auto-format a modified file to curtsies' formatting specifications (which are specified in `pyproject.toml`), run + + $ pip install black + $ black {source_file_or_directory} + +If you are working on VS code, follow these steps to auto format from inside VS code: + 1. Make sure the python extension is installed + 2. Then got to File → Preferences → Settings + 3. Search for “python.formatting.provider” + 4. Change it to 'black' + 5. Optional - Format onSave + - Still in settings search for “editor.formatOnSave” and check the box + - This will auto format your code whenever you save + 6. If you choose not to auto-format on save + - Use Command+Shift+P (on Mac) or Ctrl+Shift+P (Windows and Linux) to open the command palette. + - Type in Format Document and select it to run the auto-formatter + +## Migrating format changes without ruining git blame +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. + +Then, to see a clean and meaningful blame history of a file: + + $ git blame --ignore-revs-file .git-blame-ignore-revs + +You can also configure git (locally) to automatically ignore revision changes with every call to `git blame`: + + $ git config blame.ignoreRevsFile .git-blame-ignore-revs + + diff --git a/setup.py b/setup.py deleted file mode 100644 index 1d5c5a2..0000000 --- a/setup.py +++ /dev/null @@ -1,37 +0,0 @@ -from setuptools import setup -import ast -import os - -def version(): - """Return version string.""" - with open(os.path.join('curtsies', '__init__.py')) as input_file: - for line in input_file: - if line.startswith('__version__'): - return ast.parse(line).body[0].value.s - -setup(name='curtsies', - version=version(), - description='Curses-like terminal wrapper, with colored strings!', - url='https://github.com/thomasballinger/curtsies', - author='Thomas Ballinger', - author_email='thomasballinger@gmail.com', - license='MIT', - packages=['curtsies'], - install_requires = [ - 'blessings>=1.5', - 'wcwidth>=0.1.4', - ], - tests_require = [ - 'mock', - 'pyte', - 'nose', - ], - classifiers=[ - 'Development Status :: 3 - Alpha', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: POSIX', - 'Programming Language :: Python', - ], - zip_safe=False) diff --git a/tests/test_configfile_keynames.py b/tests/test_configfile_keynames.py index 3b6014d..2a09059 100644 --- a/tests/test_configfile_keynames.py +++ b/tests/test_configfile_keynames.py @@ -1,23 +1,29 @@ -# -*- coding: UTF8 -*- import unittest from functools import partial from curtsies.configfile_keynames import keymap from curtsies.events import CURTSIES_NAMES + class TestKeymap(unittest.TestCase): def config(self, mapping, curtsies): curtsies_names = keymap[mapping] - self.assertTrue(curtsies in CURTSIES_NAMES.values(), "%r is not a curtsies name" % curtsies) - self.assertTrue(curtsies in curtsies_names, "config name %r does not contain %r, just %r" % (mapping, curtsies, curtsies_names)) + self.assertTrue( + curtsies in CURTSIES_NAMES.values(), "%r is not a curtsies name" % curtsies + ) + self.assertTrue( + curtsies in curtsies_names, + "config name %r does not contain %r, just %r" + % (mapping, curtsies, curtsies_names), + ) def test_simple(self): - self.config('M-m', u'') - self.config('M-m', u'') - self.config('C-m', u'') - self.config('C-[', u'') - self.config('C-\\',u'') - self.config('C-]', u'') - self.config('C-^', u'') - self.config('C-_', u'') #??? for bpython compatibility - self.config('F1', u'') + self.config("M-m", "") + self.config("M-m", "") + self.config("C-m", "") + self.config("C-[", "") + self.config("C-\\", "") + self.config("C-]", "") + self.config("C-^", "") + self.config("C-_", "") # ??? for bpython compatibility + self.config("F1", "") diff --git a/tests/test_events.py b/tests/test_events.py index cff333f..522dce1 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,25 +1,26 @@ -# -*- coding: UTF8 -*- import unittest from functools import partial from curtsies import events + class TestChrByte(unittest.TestCase): def test_simple(self): """0-255 can be turned into bytes""" for i in range(256): - s = eval('b"\\x%s"' % ('0' + hex(i)[2:])[-2:]) + s = eval('b"\\x%s"' % ("0" + hex(i)[2:])[-2:]) self.assertEqual(s, events.chr_byte(i)) def test_helpers(self): - self.assertEqual(events.chr_byte(97), b'a') - self.assertEqual(events.chr_uni(97), u'a') + self.assertEqual(events.chr_byte(97), b"a") + self.assertEqual(events.chr_uni(97), "a") + class TestCurtsiesNames(unittest.TestCase): def spot_check(self): - self.assertEqual(events.CURTSIES_NAMES[b'\x1b\x08'], u'') - self.assertEqual(events.CURTSIES_NAMES[b'\x00'], u'') - self.assertEqual(events.CURTSIES_NAMES[b'\xea'], u'') + self.assertEqual(events.CURTSIES_NAMES[b"\x1b\x08"], "") + self.assertEqual(events.CURTSIES_NAMES[b"\x00"], "") + self.assertEqual(events.CURTSIES_NAMES[b"\xea"], "") def test_all_values_unicode(self): for seq, e in events.CURTSIES_NAMES.items(): @@ -27,72 +28,152 @@ def test_all_values_unicode(self): def test_all_keys_bytes(self): for seq, e in events.CURTSIES_NAMES.items(): - self.assertEqual(type(e), type(u'')) + self.assertEqual(type(e), str) + class TestDecodable(unittest.TestCase): def test_simple(self): - self.assertTrue(events.decodable(b'd', 'utf-8')) - self.assertFalse(events.decodable(b'\xfe', 'utf-8')) # 254 is off limits + self.assertTrue(events.decodable(b"d", "utf-8")) + self.assertFalse(events.decodable(b"\xfe", "utf-8")) # 254 is off limits + class TestGetKey(unittest.TestCase): def test_utf8_full(self): - get_utf_full = partial(events.get_key, encoding='utf-8', keynames='curtsies', full=True) - self.assertEqual(get_utf_full([b'h']), u'h') - self.assertEqual(get_utf_full([b'\x1b', b'[']), u'') - self.assertRaises(UnicodeDecodeError, get_utf_full, [b'\xfe\xfe']) - self.assertRaises(ValueError, get_utf_full, u'a') + get_utf_full = partial( + events.get_key, + encoding="utf-8", + keynames=events.Keynames.CURTSIES, + full=True, + ) + self.assertEqual(get_utf_full([b"h"]), "h") + self.assertEqual(get_utf_full([b"\x1b", b"["]), "") + self.assertRaises(UnicodeDecodeError, get_utf_full, [b"\xfe\xfe"]) + self.assertRaises(TypeError, get_utf_full, "a") def test_utf8(self): - get_utf = partial(events.get_key, encoding='utf-8', keynames='curtsies', full=False) - self.assertEqual(get_utf([b'h']), u'h') - self.assertEqual(get_utf([b'\x1b', b'[']), None) - self.assertEqual(get_utf([b'\xe2']), None) - self.assertRaises(ValueError, get_utf, u'a') + get_utf = partial( + events.get_key, + encoding="utf-8", + keynames=events.Keynames.CURTSIES, + full=False, + ) + self.assertEqual(get_utf([b"h"]), "h") + self.assertEqual(get_utf([b"\x1b", b"["]), None) + self.assertEqual(get_utf([b"\xe2"]), None) + self.assertRaises(TypeError, get_utf, "a") def test_multibyte_utf8(self): - get_utf = partial(events.get_key, encoding='utf-8', keynames='curtsies', full=False) - self.assertEqual(get_utf([b'\xc3']), None) - self.assertEqual(get_utf([b'\xe2']), None) - self.assertEqual(get_utf([b'\xe2'], full=True), u'') - self.assertEqual(get_utf([b'\xc3', b'\x9f']), u'ß') - self.assertEqual(get_utf([b'\xe2']), None) - self.assertEqual(get_utf([b'\xe2', b'\x88']), None) - self.assertEqual(get_utf([b'\xe2', b'\x88', b'\x82']), u'∂') + get_utf = partial( + events.get_key, + encoding="utf-8", + keynames=events.Keynames.CURTSIES, + full=False, + ) + self.assertEqual(get_utf([b"\xc3"]), None) + self.assertEqual(get_utf([b"\xe2"]), None) + self.assertEqual(get_utf([b"\xe2"], full=True), "") + self.assertEqual(get_utf([b"\xc3", b"\x9f"]), "ß") + self.assertEqual(get_utf([b"\xe2"]), None) + self.assertEqual(get_utf([b"\xe2", b"\x88"]), None) + self.assertEqual(get_utf([b"\xe2", b"\x88", b"\x82"]), "∂") def test_sequences_without_names(self): - get_utf = partial(events.get_key, encoding='utf-8', keynames='curtsies', full=False) - self.assertEqual(get_utf([b'\xc3'], full=True), '') - self.assertEqual(get_utf([b'\xc3'], full=True, keynames='curses'), 'xC3') + get_utf = partial( + events.get_key, + encoding="utf-8", + keynames=events.Keynames.CURTSIES, + full=False, + ) + self.assertEqual(get_utf([b"\xc3"], full=True), "") + self.assertEqual( + get_utf([b"\xc3"], full=True, keynames=events.Keynames.CURSES), "xC3" + ) def test_key_names(self): - self.assertTrue(set(events.CURTSIES_NAMES).issuperset(set(events.CURSES_NAMES)), set(events.CURSES_NAMES) - set(events.CURTSIES_NAMES)) + "Every key sequence with a Curses name should have a Curtsies name too." + self.assertTrue( + set(events.CURTSIES_NAMES).issuperset(set(events.CURSES_NAMES)), + set(events.CURSES_NAMES) - set(events.CURTSIES_NAMES), + ) + class TestGetKeyAscii(unittest.TestCase): def test_full(self): - get_ascii_full = partial(events.get_key, encoding='ascii', keynames='curtsies', full=True) - self.assertEqual(get_ascii_full([b'a']), u'a') - self.assertEqual(get_ascii_full([b'\xe1']), u'') - self.assertEqual(get_ascii_full([b'\xe1'], keynames='curses'), u'xE1') + get_ascii_full = partial( + events.get_key, + encoding="ascii", + keynames=events.Keynames.CURTSIES, + full=True, + ) + self.assertEqual(get_ascii_full([b"a"]), "a") + self.assertEqual(get_ascii_full([b"\xe1"]), "") + self.assertEqual( + get_ascii_full([b"\xe1"], keynames=events.Keynames.CURSES), "xE1" + ) def test_simple(self): - get_ascii_full = partial(events.get_key, encoding='ascii', keynames='curtsies') - self.assertEqual(get_ascii_full([b'a']), u'a') - self.assertEqual(get_ascii_full([b'\xe1']), u'') - self.assertEqual(get_ascii_full([b'\xe1'], keynames='curses'), u'xE1') + get_ascii_full = partial( + events.get_key, encoding="ascii", keynames=events.Keynames.CURTSIES + ) + self.assertEqual(get_ascii_full([b"a"]), "a") + self.assertEqual(get_ascii_full([b"\xe1"]), "") + self.assertEqual( + get_ascii_full([b"\xe1"], keynames=events.Keynames.CURSES), "xE1" + ) + class TestUnknownEncoding(unittest.TestCase): def test_simple(self): - get_utf16 = partial(events.get_key, encoding='utf16', keynames='curtsies') - self.assertEqual(get_utf16([b'a']), None) - self.assertEqual(get_utf16([b'a'], full=True), None) - self.assertEqual(get_utf16([b'\xe1']), None) - self.assertEqual(get_utf16([b'\xe1'], full=True), u'') + get_utf16 = partial( + events.get_key, encoding="utf16", keynames=events.Keynames.CURTSIES + ) + self.assertEqual(get_utf16([b"a"]), None) + self.assertEqual(get_utf16([b"a"], full=True), None) + self.assertEqual(get_utf16([b"\xe1"]), None) + self.assertEqual(get_utf16([b"\xe1"], full=True), "") + class TestSpecialKeys(unittest.TestCase): def test_simple(self): - seq = [b'\x1b', b'[', b'1', b';', b'9', b'C'] - self.assertEqual([events.get_key(seq[:i], encoding='utf8') for i in range(1, len(seq)+1)], [None, None, None, None, None, u'']) + seq = [b"\x1b", b"[", b"1", b";", b"9", b"C"] + self.assertEqual( + [events.get_key(seq[:i], encoding="utf8") for i in range(1, len(seq) + 1)], + [None, None, None, None, None, ""], + ) + class TestPPEvent(unittest.TestCase): def test(self): - self.assertEqual(events.pp_event(u'a'), 'a') + self.assertEqual(events.pp_event("a"), "a") + + +class TestShiftArrowMappings(unittest.TestCase): + def test_curtsies_names(self): + self.assertEqual(events.CURTSIES_NAMES[b"\x1b[1;2A"], "") + self.assertEqual(events.CURTSIES_NAMES[b"\x1b[1;2B"], "") + self.assertEqual(events.CURTSIES_NAMES[b"\x1b[1;2C"], "") + self.assertEqual(events.CURTSIES_NAMES[b"\x1b[1;2D"], "") + + def test_get_key_sequences(self): + # Ensure get_key resolves complete Shift+Arrow sequences + seq_up = [b"\x1b", b"[", b"1", b";", b"2", b"A"] + seq_down = [b"\x1b", b"[", b"1", b";", b"2", b"B"] + seq_right = [b"\x1b", b"[", b"1", b";", b"2", b"C"] + seq_left = [b"\x1b", b"[", b"1", b";", b"2", b"D"] + + self.assertEqual( + [events.get_key(seq_up[:i], encoding="utf8") for i in range(1, len(seq_up) + 1)], + [None, None, None, None, None, ""] + ) + self.assertEqual( + [events.get_key(seq_down[:i], encoding="utf8") for i in range(1, len(seq_down) + 1)], + [None, None, None, None, None, ""] + ) + self.assertEqual( + [events.get_key(seq_right[:i], encoding="utf8") for i in range(1, len(seq_right) + 1)], + [None, None, None, None, None, ""] + ) + self.assertEqual( + [events.get_key(seq_left[:i], encoding="utf8") for i in range(1, len(seq_left) + 1)], + [None, None, None, None, None, ""] + ) diff --git a/tests/test_fmtstr.py b/tests/test_fmtstr.py index 4ab868d..a568780 100644 --- a/tests/test_fmtstr.py +++ b/tests/test_fmtstr.py @@ -1,473 +1,633 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals -import sys import unittest -from curtsies.formatstring import (FmtStr, fmtstr, Chunk, linesplit, - normalize_slice, width_aware_slice) -from curtsies.fmtfuncs import * +from curtsies.formatstring import ( + FmtStr, + fmtstr, + Chunk, + linesplit, + normalize_slice, + width_aware_slice, +) +from curtsies.fmtfuncs import ( + blue, + red, + green, + on_blue, + on_red, + on_green, + underline, + blink, + bold, +) from curtsies.termformatconstants import FG_COLORS -from curtsies.formatstringarray import fsarray, FSArray, FormatStringTest +from curtsies.formatstringarray import fsarray, FSArray, simple_format -try: - from unittest import skip -except ImportError: - def skip(f): - return lambda self: None +from unittest import skip -PY2 = sys.version_info[0] == 2 -try: - unicode = unicode -except: - unicode = str - - -def repr_without_leading_u(s): - assert isinstance(s, type(u'')) - if PY2: - r = repr(s) - assert r[0] == 'u' - return r[1:] - else: - return repr(s) +def repr_without_leading_u(s: str) -> str: + assert isinstance(s, str) + return repr(s) class TestFmtStrInitialization(unittest.TestCase): - def test_bad(self): + def test_bad(self) -> None: # Can't specify fg or bg color two ways - self.assertRaises(ValueError, fmtstr, 'hello', 'blue', {'fg': 30}) - self.assertRaises(ValueError, fmtstr, 'hello', 'on_blue', {'bg': 40}) + self.assertRaises(ValueError, fmtstr, "hello", "blue", {"fg": 30}) + self.assertRaises(ValueError, fmtstr, "hello", "on_blue", {"bg": 40}) # Only specific fg and bg colors are allowed - self.assertRaises(ValueError, fmtstr, 'hello', {'bg': 30}) - self.assertRaises(ValueError, fmtstr, 'hello', {'fg': 40}) + self.assertRaises(ValueError, fmtstr, "hello", {"bg": 30}) + self.assertRaises(ValueError, fmtstr, "hello", {"fg": 40}) # Only existing xforms can be used in kwargs - self.assertRaises(ValueError, fmtstr, 'hello', 'make it big') + self.assertRaises(ValueError, fmtstr, "hello", "make it big") - def test_actual_init(self): + def test_actual_init(self) -> None: FmtStr() -class TestImmutability(unittest.TestCase): +class TestFmtStrParsing(unittest.TestCase): + def test_no_escapes(self) -> None: + self.assertEqual(str(fmtstr("abc")), "abc") + + def test_simple_escapes(self) -> None: + self.assertEqual(str(fmtstr("\x1b[33mhello\x1b[0m")), "\x1b[33mhello\x1b[39m") + self.assertEqual(str(fmtstr("\x1b[33mhello\x1b[39m")), "\x1b[33mhello\x1b[39m") + self.assertEqual(str(fmtstr("\x1b[33mhello")), "\x1b[33mhello\x1b[39m") + self.assertEqual(str(fmtstr("\x1b[43mhello\x1b[49m")), "\x1b[43mhello\x1b[49m") + self.assertEqual(str(fmtstr("\x1b[43mhello\x1b[0m")), "\x1b[43mhello\x1b[49m") + self.assertEqual(str(fmtstr("\x1b[43mhello")), "\x1b[43mhello\x1b[49m") + self.assertEqual( + str(fmtstr("\x1b[32;1mhello")), "\x1b[32m\x1b[1mhello\x1b[0m\x1b[39m" + ) + self.assertEqual(str(fmtstr("\x1b[2mhello")), "\x1b[2mhello\x1b[0m") + self.assertEqual( + str(fmtstr("\x1b[32;2mhello")), "\x1b[32m\x1b[2mhello\x1b[0m\x1b[39m" + ) + self.assertEqual( + str(fmtstr("\x1b[33m\x1b[43mhello\x1b[0m")), + "\x1b[33m\x1b[43mhello\x1b[49m\x1b[39m", + ) + + def test_out_of_order(self) -> None: + self.assertEqual( + str(fmtstr("\x1b[33m\x1b[43mhello\x1b[39m\x1b[49m")), + "\x1b[33m\x1b[43mhello\x1b[49m\x1b[39m", + ) + + def test_noncurtsies_output(self) -> None: + fmtstr("\x1b[35mx\x1b[m") + self.assertEqual(fmtstr("\x1b[Ahello"), "hello") + self.assertEqual(fmtstr("\x1b[20Ahello"), "hello") + self.assertEqual(fmtstr("\x1b[20mhello"), "hello") + - def test_fmt_strings_remain_unchanged_when_used_to_construct_other_ones(self): - a = fmtstr('hi', 'blue') - b = fmtstr('there', 'red') +class TestImmutability(unittest.TestCase): + def test_fmt_strings_remain_unchanged_when_used_to_construct_other_ones( + self, + ) -> None: + a = fmtstr("hi", "blue") + b = fmtstr("there", "red") c = a + b green(c) - self.assertEqual(a.shared_atts['fg'], FG_COLORS['blue']) - self.assertEqual(b.shared_atts['fg'], FG_COLORS['red']) + self.assertEqual(a.shared_atts["fg"], FG_COLORS["blue"]) + self.assertEqual(b.shared_atts["fg"], FG_COLORS["red"]) - def test_immutibility_of_FmtStr(self): - a = fmtstr('hi', 'blue') + def test_immutibility_of_FmtStr(self) -> None: + a = fmtstr("hi", "blue") b = green(a) - self.assertEqual(a.shared_atts['fg'], FG_COLORS['blue']) - self.assertEqual(b.shared_atts['fg'], FG_COLORS['green']) + self.assertEqual(a.shared_atts["fg"], FG_COLORS["blue"]) + self.assertEqual(b.shared_atts["fg"], FG_COLORS["green"]) class TestFmtStrSplice(unittest.TestCase): - - def test_simple_beginning_splice(self): - self.assertEqual(fmtstr('abc').splice('d', 0), fmtstr('dabc')) - self.assertEqual(fmtstr('abc').splice('d', 0), 'd'+fmtstr('abc')) - - def test_various_splices(self): - a = blue('hi') - b = a + green('bye') - c = b + red('!') - self.assertEqual(c.splice('asdfg', 1), - blue('h')+'asdfg'+blue('i')+green('bye')+red('!')) - self.assertEqual(c.splice('asdfg', 1, 4), - blue('h')+'asdfg'+green('e')+red('!')) - self.assertEqual(c.splice('asdfg', 1, 5), - blue('h')+'asdfg'+red('!')) - - def test_splice_of_empty_fmtstr(self): - self.assertEqual(fmtstr('ab').splice('', 1), fmtstr('ab')) - - def test_splice_with_multiple_basefmtstrs(self): - a = fmtstr('notion') - b = a.splice('te', 2, 6) - c = b.splice('de', 0) + def test_simple_beginning_splice(self) -> None: + self.assertEqual(fmtstr("abc").splice("d", 0), fmtstr("dabc")) + self.assertEqual(fmtstr("abc").splice("d", 0), "d" + fmtstr("abc")) + + def test_various_splices(self) -> None: + a = blue("hi") + b = a + green("bye") + c = b + red("!") + self.assertEqual( + c.splice("asdfg", 1), + blue("h") + "asdfg" + blue("i") + green("bye") + red("!"), + ) + self.assertEqual( + c.splice("asdfg", 1, 4), blue("h") + "asdfg" + green("e") + red("!") + ) + self.assertEqual(c.splice("asdfg", 1, 5), blue("h") + "asdfg" + red("!")) + + def test_splice_of_empty_fmtstr(self) -> None: + self.assertEqual(fmtstr("ab").splice("", 1), fmtstr("ab")) + + def test_splice_with_multiple_chunks(self) -> None: + a = fmtstr("notion") + b = a.splice("te", 2, 6) + c = b.splice("de", 0) self.assertEqual(a.s, "notion") self.assertEqual(b.s, "note") self.assertEqual(c.s, "denote") - self.assertEqual(len(c.basefmtstrs), 3) + self.assertEqual(len(c.chunks), 3) - def test_splice_fmtstr_with_end_without_atts(self): - a = fmtstr('notion') - b = a.splice('te', 2, 6) + def test_splice_fmtstr_with_end_without_atts(self) -> None: + a = fmtstr("notion") + b = a.splice("te", 2, 6) self.assertEqual(a.s, "notion") self.assertEqual(b.s, "note") - self.assertEqual(len(b.basefmtstrs), 2) + self.assertEqual(len(b.chunks), 2) - def test_splice_fmtstr_with_end_with_atts(self): - # Need to test with fmtstr consisting of multiple basefmtstrs + def test_splice_fmtstr_with_end_with_atts(self) -> None: + # Need to test with fmtstr consisting of multiple chunks # and with attributes - a = fmtstr('notion', 'blue') - b = a.splice('te', 2, 6) + a = fmtstr("notion", "blue") + b = a.splice("te", 2, 6) self.assertEqual(a.s, "notion") - self.assertEqual(a.basefmtstrs[0].atts, {'fg': 34}) - self.assertEqual(len(a.basefmtstrs), 1) - - self.assertEqual(b.s, 'note') - self.assertEqual(b.basefmtstrs[0].atts, {'fg': 34}) - self.assertEqual(b.basefmtstrs[1].atts, {}) - self.assertEqual(len(b.basefmtstrs), 2) - - def test_splice_fmtstr_without_end(self): - a = fmtstr('notion') - b = a.splice(fmtstr('ta'), 2) - self.assertEqual(a.s, 'notion') - self.assertEqual(b.s, 'notation') - self.assertEqual(len(b.basefmtstrs), 3) - - def test_splice_string_without_end(self): - a = fmtstr('notion') - b = a.splice('ta', 2) - self.assertEqual(a.s, 'notion') - self.assertEqual(b.s, 'notation') - self.assertEqual(len(b.basefmtstrs), 3) - - def test_multiple_bfs_splice(self): - self.assertEqual(fmtstr('a') + blue('b'), - on_blue(' '*2).splice(fmtstr('a')+blue('b'), 0, 2)) - self.assertEqual(on_red('yo') + on_blue(' '), - on_blue(' '*5).splice(on_red('yo'), 0, 2)) - self.assertEqual(' ' + on_red('yo') + on_blue(' '), - on_blue(' '*6).splice(' ' + on_red('yo'), 0, 3)) - self.assertEqual(on_blue("hey") + ' ' + on_red('yo') + on_blue(' '), - on_blue(' '*9).splice(on_blue("hey") + ' ' + on_red('yo'), 0, 6)) - self.assertEqual(on_blue(' '*5) + on_blue("hey") + ' ' + on_red('yo') + on_blue(' '), - on_blue(' '*14).splice(on_blue("hey") + ' ' + on_red('yo'), 5, 11)) + self.assertEqual(a.chunks[0].atts, {"fg": 34}) + self.assertEqual(len(a.chunks), 1) -class TestFmtStr(unittest.TestCase): + self.assertEqual(b.s, "note") + self.assertEqual(b.chunks[0].atts, {"fg": 34}) + self.assertEqual(b.chunks[1].atts, {}) + self.assertEqual(len(b.chunks), 2) - def test_copy_with_new_atts(self): - a = fmtstr('hello') + def test_splice_fmtstr_without_end(self) -> None: + a = fmtstr("notion") + b = a.splice(fmtstr("ta"), 2) + self.assertEqual(a.s, "notion") + self.assertEqual(b.s, "notation") + self.assertEqual(len(b.chunks), 3) + + def test_splice_string_without_end(self) -> None: + a = fmtstr("notion") + b = a.splice("ta", 2) + self.assertEqual(a.s, "notion") + self.assertEqual(b.s, "notation") + self.assertEqual(len(b.chunks), 3) + + def test_multiple_bfs_splice(self) -> None: + self.assertEqual( + fmtstr("a") + blue("b"), + on_blue(" " * 2).splice(fmtstr("a") + blue("b"), 0, 2), + ) + self.assertEqual( + on_red("yo") + on_blue(" "), on_blue(" " * 5).splice(on_red("yo"), 0, 2) + ) + self.assertEqual( + " " + on_red("yo") + on_blue(" "), + on_blue(" " * 6).splice(" " + on_red("yo"), 0, 3), + ) + self.assertEqual( + on_blue("hey") + " " + on_red("yo") + on_blue(" "), + on_blue(" " * 9).splice(on_blue("hey") + " " + on_red("yo"), 0, 6), + ) + self.assertEqual( + on_blue(" " * 5) + on_blue("hey") + " " + on_red("yo") + on_blue(" "), + on_blue(" " * 14).splice(on_blue("hey") + " " + on_red("yo"), 5, 11), + ) + + +class TestFmtStr(unittest.TestCase): + def test_copy_with_new_atts(self) -> None: + a = fmtstr("hello") b = a.copy_with_new_atts(bold=True) self.assertEqual(a.shared_atts, {}) - self.assertEqual(b.shared_atts, {'bold': True}) + self.assertEqual(b.shared_atts, {"bold": True}) - def test_copy_with_new_str(self): + def test_copy_with_new_str(self) -> None: # Change string but not attributes - a = fmtstr('hello', 'blue') - b = a.copy_with_new_str('bye') - self.assertEqual(a.s, 'hello') - self.assertEqual(b.s, 'bye') - self.assertEqual(a.basefmtstrs[0].atts, b.basefmtstrs[0].atts) - - def test_append_without_atts(self): - a = fmtstr('no') - b = a.append('te') - self.assertEqual(a.s, 'no') - self.assertEqual(b.s, 'note') - self.assertEqual(len(b.basefmtstrs), 2) - - def test_shared_atts(self): - a = fmtstr('hi', 'blue') - b = fmtstr('there', 'blue') + a = fmtstr("hello", "blue") + b = a.copy_with_new_str("bye") + self.assertEqual(a.s, "hello") + self.assertEqual(b.s, "bye") + self.assertEqual(a.chunks[0].atts, b.chunks[0].atts) + + def test_append_without_atts(self) -> None: + a = fmtstr("no") + b = a.append("te") + self.assertEqual(a.s, "no") + self.assertEqual(b.s, "note") + self.assertEqual(len(b.chunks), 2) + + def test_shared_atts(self) -> None: + a = fmtstr("hi", "blue") + b = fmtstr("there", "blue") c = a + b - self.assertTrue('fg' in a.shared_atts) - self.assertTrue('fg' in c.shared_atts) + self.assertTrue("fg" in a.shared_atts) + self.assertTrue("fg" in c.shared_atts) - def test_new_with_atts_removed(self): - a = fmtstr('hi', 'blue', 'on_green') - b = fmtstr('there', 'blue', 'on_red') + def test_new_with_atts_removed(self) -> None: + a = fmtstr("hi", "blue", "on_green") + b = fmtstr("there", "blue", "on_red") c = a + b - self.assertEqual(c.new_with_atts_removed('fg'), on_green('hi')+on_red('there')) + self.assertEqual( + c.new_with_atts_removed("fg"), on_green("hi") + on_red("there") + ) - def setUp(self): - self.s = fmtstr('hello!', 'on_blue', fg='red') + def setUp(self) -> None: + self.s = fmtstr("hello!", "on_blue", fg="red") - def test_length(self): + def test_length(self) -> None: self.assertEqual(len(self.s), len(self.s.s)) - def test_split(self): - self.assertEqual(blue('hello there').split(' '), [blue('hello'), blue('there')]) - s = blue('hello there') - self.assertEqual(s.split(' '), [s[:5], s[6:]]) - - # split shouldn't create fmtstrs without basefmtstrs - self.assertEqual(fmtstr('a').split('a')[0].basefmtstrs, fmtstr('').basefmtstrs) - self.assertEqual(fmtstr('a').split('a')[1].basefmtstrs, fmtstr('').basefmtstrs) - - self.assertEqual((fmtstr('imp') + ' ').split('i'), [fmtstr(''), fmtstr('mp') + ' ']) - self.assertEqual(blue('abcbd').split('b'), [blue('a'), blue('c'), blue('d')]) - - def test_split_with_spaces(self): - self.assertEqual(blue('a\nb').split(), [blue('a'), blue('b')]) - self.assertEqual(blue('a \t\n\nb').split(), [blue('a'), blue('b')]) - self.assertEqual(blue('hello \t\n\nthere').split(), [blue('hello'), blue('there')]) - - def test_ljust_rjust(self): - """""" - b = fmtstr(u'ab', 'blue', 'on_red', 'bold') - g = fmtstr(u'cd', 'green', 'on_red', 'bold') + def test_split(self) -> None: + self.assertEqual(blue("hello there").split(" "), [blue("hello"), blue("there")]) + s = blue("hello there") + self.assertEqual(s.split(" "), [s[:5], s[6:]]) + + # split shouldn't create fmtstrs without chunks + self.assertEqual(fmtstr("a").split("a")[0].chunks, fmtstr("").chunks) + self.assertEqual(fmtstr("a").split("a")[1].chunks, fmtstr("").chunks) + + self.assertEqual( + (fmtstr("imp") + " ").split("i"), [fmtstr(""), fmtstr("mp") + " "] + ) + self.assertEqual(blue("abcbd").split("b"), [blue("a"), blue("c"), blue("d")]) + + def test_split_with_spaces(self) -> None: + self.assertEqual(blue("a\nb").split(), [blue("a"), blue("b")]) + self.assertEqual(blue("a \t\n\nb").split(), [blue("a"), blue("b")]) + self.assertEqual( + blue("hello \t\n\nthere").split(), [blue("hello"), blue("there")] + ) + + def test_ljust_rjust(self) -> None: + b = fmtstr("ab", "blue", "on_red", "bold") + g = fmtstr("cd", "green", "on_red", "bold") s = b + g - self.assertEqual(s.ljust(6), b + g + on_red(' ')) - self.assertEqual(s.rjust(6), on_red(' ') + b + g) + self.assertEqual(s.ljust(6), b + g + on_red(" ")) + self.assertEqual(s.rjust(6), on_red(" ") + b + g) # doesn't add empties to end self.assertEqual(s.ljust(4), b + g) self.assertEqual(s.rjust(4), b + g) # behavior if background different - s = on_blue('a') + on_green('b') - self.assertEqual(s.ljust(3), fmtstr('ab ')) - self.assertEqual(s.rjust(3), fmtstr(' ab')) - s = blue(on_blue('a')) + green(on_green('b')) - self.assertEqual(s.ljust(3), blue('a') + green('b') + fmtstr(' ')) - self.assertEqual(s.rjust(3), fmtstr(' ') + blue('a') + green('b')) - - #using fillchar - self.assertEqual(s.ljust(3, '*'), fmtstr('ab*')) - self.assertEqual(s.rjust(3, '*'), fmtstr('*ab')) - - #TODO: (not tested or implemented) + s = on_blue("a") + on_green("b") + self.assertEqual(s.ljust(3), fmtstr("ab ")) + self.assertEqual(s.rjust(3), fmtstr(" ab")) + s = blue(on_blue("a")) + green(on_green("b")) + self.assertEqual(s.ljust(3), blue("a") + green("b") + fmtstr(" ")) + self.assertEqual(s.rjust(3), fmtstr(" ") + blue("a") + green("b")) + + # using fillchar + self.assertEqual(s.ljust(3, "*"), fmtstr("ab*")) + self.assertEqual(s.rjust(3, "*"), fmtstr("*ab")) + + # TODO: (not tested or implemented) # formatted string passed in # preserve some non-uniform styles (bold, dark, blink) but not others (underline, invert) - def test_linessplit(self): - text = blue('the sum of the squares of the sideways') - result = [blue('the')+blue(' ')+blue('sum'), - blue('of')+blue(' ')+blue('the'), - blue('squares'), - blue('of')+blue(' ')+blue('the'), - blue('sideway'), - blue('s') - ] + def test_linessplit(self) -> None: + text = blue("the sum of the squares of the sideways") + result = [ + blue("the") + blue(" ") + blue("sum"), + blue("of") + blue(" ") + blue("the"), + blue("squares"), + blue("of") + blue(" ") + blue("the"), + blue("sideway"), + blue("s"), + ] self.assertEqual(linesplit(text, 7), result) - def test_mul(self): - self.assertEqual(fmtstr('heyhey'), fmtstr('hey')*2) + def test_mul(self) -> None: + self.assertEqual(fmtstr("heyhey"), fmtstr("hey") * 2) pass - #TODO raise common attributes when doing equality or when + # TODO raise common attributes when doing equality or when # doing multiplication, addition etc. to make these pass - #self.assertEqual(blue('hellohellohello'), blue('hello')*3) - #self.assertEqual( + # self.assertEqual(blue('hellohellohello'), blue('hello')*3) + # self.assertEqual( # bold(blue('hey')+green('there')+blue('hey')+green('there')), # bold(blue('hey')+green('there'))*2) - def test_change_color(self): - a = blue(red('hello')) - self.assertEqual(a, blue('hello')) + def test_change_color(self) -> None: + a = blue(red("hello")) + self.assertEqual(a, blue("hello")) - def test_repr(self): - self.assertEqual(fmtstr('hello', 'red', bold=False), red('hello')) - self.assertEqual(fmtstr('hello', 'red', bold=True), bold(red('hello'))) + def test_repr(self) -> None: + self.assertEqual(fmtstr("hello", "red", bold=False), red("hello")) + self.assertEqual(fmtstr("hello", "red", bold=True), bold(red("hello"))) class TestDoubleUnders(unittest.TestCase): - def test_equality(self): + def test_equality(self) -> None: x = fmtstr("adfs") self.assertEqual(x, x) self.assertTrue(fmtstr("adfs"), fmtstr("adfs")) - self.assertTrue(fmtstr("adfs", 'blue'), fmtstr("adfs", fg='blue')) + self.assertTrue(fmtstr("adfs", "blue"), fmtstr("adfs", fg="blue")) class TestConvenience(unittest.TestCase): - def test_fg(self): - red('asdf') - blue('asdf') + def test_fg(self) -> None: + red("asdf") + blue("asdf") self.assertTrue(True) - def test_bg(self): - on_red('asdf') - on_blue('asdf') + def test_bg(self) -> None: + on_red("asdf") + on_blue("asdf") self.assertTrue(True) - def test_styles(self): - underline('asdf') - blink('asdf') + def test_styles(self) -> None: + underline("asdf") + blink("asdf") self.assertTrue(True) class TestSlicing(unittest.TestCase): - - def test_index(self): - self.assertEqual(fmtstr('Hi!', 'blue')[0], fmtstr('H', 'blue')) - self.assertRaises(IndexError, fmtstr('Hi!', 'blue').__getitem__, 5) - - def test_slice(self): - self.assertEqual(fmtstr('Hi!', 'blue')[1:2], fmtstr('i', 'blue')) - self.assertEqual(fmtstr('Hi!', 'blue')[1:], fmtstr('i!', 'blue')) - s = fmtstr('imp') + ' ' - self.assertEqual(s[1:], fmtstr('mp')+' ') - self.assertEqual(blue('a\nb')[0:1], blue('a')) - - # considering changing behavior so that this doens't work + def test_index(self) -> None: + self.assertEqual(fmtstr("Hi!", "blue")[0], fmtstr("H", "blue")) + self.assertRaises(IndexError, fmtstr("Hi!", "blue").__getitem__, 5) + + def test_slice(self) -> None: + self.assertEqual(fmtstr("Hi!", "blue")[1:2], fmtstr("i", "blue")) + self.assertEqual(fmtstr("Hi!", "blue")[1:], fmtstr("i!", "blue")) + s = fmtstr("imp") + " " + self.assertEqual(s[1:], fmtstr("mp") + " ") + self.assertEqual(blue("a\nb")[0:1], blue("a")) + + # considering changing behavior so that this doesn't work # self.assertEqual(fmtstr('Hi!', 'blue')[15:18], fmtstr('', 'blue')) class TestComposition(unittest.TestCase): - - def test_simple_composition(self): - a = fmtstr('hello ', 'underline', 'on_blue') - b = fmtstr('there', 'red', 'on_blue') + def test_simple_composition(self) -> None: + a = fmtstr("hello ", "underline", "on_blue") + b = fmtstr("there", "red", "on_blue") c = a + b - fmtstr(c, bg='red') + fmtstr(c, bg="red") self.assertTrue(True) class TestUnicode(unittest.TestCase): + def test_output_type(self) -> None: + self.assertEqual(type(str(fmtstr("hello", "blue"))), str) - def test_output_type(self): - self.assertEqual(type(str(fmtstr('hello', 'blue'))), str) - self.assertEqual(type(unicode(fmtstr('hello', 'blue'))), unicode) - - def test_normal_chars(self): - fmtstr('a', 'blue') - str(fmtstr('a', 'blue')) - unicode(fmtstr('a', 'blue')) + def test_normal_chars(self) -> None: + fmtstr("a", "blue") + str(fmtstr("a", "blue")) self.assertTrue(True) - def test_funny_chars(self): - fmtstr('⁇', 'blue') - str(Chunk('⁇', {'fg': 'blue'})) - str(fmtstr('⁇', 'blue')) - unicode(fmtstr('⁇', 'blue')) + def test_funny_chars(self) -> None: + fmtstr("⁇", "blue") + str(Chunk("⁇", {"fg": 34})) + str(fmtstr("⁇", "blue")) self.assertTrue(True) - def test_right_sequence_in_py3(self): - red_on_blue = fmtstr('hello', 'red', 'on_blue') - blue_on_red = fmtstr('there', fg='blue', bg='red') - green_s = fmtstr('!', 'green') - full = red_on_blue + ' ' + blue_on_red + green_s - self.assertEqual(full, on_blue(red("hello"))+" "+on_red(blue("there"))+green("!")) - self.assertEqual(str(full), '\x1b[31m\x1b[44mhello\x1b[49m\x1b[39m \x1b[34m\x1b[41mthere\x1b[49m\x1b[39m\x1b[32m!\x1b[39m') - - def test_len_of_unicode(self): - self.assertEqual(len(fmtstr('┌─')), 2) - lines = ['┌─', 'an', '┌─'] + def test_right_sequence_in_py3(self) -> None: + red_on_blue = fmtstr("hello", "red", "on_blue") + blue_on_red = fmtstr("there", fg="blue", bg="red") + green_s = fmtstr("!", "green") + full = red_on_blue + " " + blue_on_red + green_s + self.assertEqual( + full, on_blue(red("hello")) + " " + on_red(blue("there")) + green("!") + ) + self.assertEqual( + str(full), + "\x1b[31m\x1b[44mhello\x1b[49m\x1b[39m \x1b[34m\x1b[41mthere\x1b[49m\x1b[39m\x1b[32m!\x1b[39m", + ) + + def test_len_of_unicode(self) -> None: + self.assertEqual(len(fmtstr("┌─")), 2) + lines = ["┌─", "an", "┌─"] r = fsarray(lines) self.assertEqual(r.shape, (3, 2)) - self.assertEqual(len(fmtstr(fmtstr('┌─'))), len(fmtstr('┌─'))) - self.assertEqual(fmtstr(fmtstr('┌─')), fmtstr('┌─')) - #TODO should we make this one work? + self.assertEqual(len(fmtstr(fmtstr("┌─"))), len(fmtstr("┌─"))) + self.assertEqual(fmtstr(fmtstr("┌─")), fmtstr("┌─")) + # TODO should we make this one work? # always coerce everything to unicode? - #self.assertEqual(len(fmtstr('┌─')), 2) - - def test_len_of_unicode_in_fsarray(self): + # self.assertEqual(len(fmtstr('┌─')), 2) + def test_len_of_unicode_in_fsarray(self) -> None: fsa = FSArray(3, 2) - fsa.rows[0] = fsa.rows[0].setslice_with_length(0, 2, '┌─', 2) + fsa.rows[0] = fsa.rows[0].setslice_with_length(0, 2, "┌─", 2) self.assertEqual(fsa.shape, (3, 2)) - fsa.rows[0] = fsa.rows[0].setslice_with_length(0, 2, fmtstr('┌─', 'blue'), 2) + fsa.rows[0] = fsa.rows[0].setslice_with_length(0, 2, fmtstr("┌─", "blue"), 2) self.assertEqual(fsa.shape, (3, 2)) - def test_add_unicode_to_byte(self): - fmtstr('┌') + fmtstr('a') - fmtstr('a') + fmtstr('┌') - '┌' + fmtstr('┌') - '┌' + fmtstr('a') - fmtstr('┌') + '┌' - fmtstr('a') + '┌' + def test_add_unicode_to_byte(self) -> None: + fmtstr("┌") + fmtstr("a") + fmtstr("a") + fmtstr("┌") + "┌" + fmtstr("┌") + "┌" + fmtstr("a") + fmtstr("┌") + "┌" + fmtstr("a") + "┌" - def test_unicode_slicing(self): - self.assertEqual(fmtstr('┌adfs', 'blue')[:2], fmtstr('┌a', 'blue')) - self.assertEqual(type(fmtstr('┌adfs', 'blue')[:2].s), type(fmtstr('┌a', 'blue').s)) - self.assertEqual(len(fmtstr('┌adfs', 'blue')[:2]), 2) + def test_unicode_slicing(self) -> None: + self.assertEqual(fmtstr("┌adfs", "blue")[:2], fmtstr("┌a", "blue")) + self.assertEqual( + type(fmtstr("┌adfs", "blue")[:2].s), type(fmtstr("┌a", "blue").s) + ) + self.assertEqual(len(fmtstr("┌adfs", "blue")[:2]), 2) - def test_unicode_repr(self): - repr(Chunk('–')) - self.assertEqual(repr(fmtstr('–')), repr_without_leading_u('–')) + def test_unicode_repr(self) -> None: + repr(Chunk("–")) + self.assertEqual(repr(fmtstr("–")), repr_without_leading_u("–")) class TestCharacterWidth(unittest.TestCase): + def test_doublewide_width(self) -> None: + self.assertEqual(len(fmtstr("E", "blue")), 1) + self.assertEqual(fmtstr("E", "blue").width, 2) + self.assertEqual(len(fmtstr("hi")), 2) + self.assertEqual(fmtstr("hi").width, 4) + + def test_multi_width(self) -> None: + self.assertEqual(len(fmtstr("a\u0300")), 2) + self.assertEqual(fmtstr("a\u0300").width, 1) + + def test_width_aware_slice(self) -> None: + self.assertEqual(fmtstr("E").width_aware_slice(slice(None, 1, None)).s, " ") + self.assertEqual(fmtstr("E").width_aware_slice(slice(None, 2, None)).s, "E") + self.assertEqual( + fmtstr("HE!", "blue").width_aware_slice(slice(1, 2, None)), + fmtstr(" ", "blue"), + ) + self.assertEqual( + fmtstr("HE!", "blue").width_aware_slice(slice(1, 3, None)), + fmtstr("E", "blue"), + ) + + def test_width_aware_splitlines(self) -> None: + s = fmtstr("abcd") + self.assertEqual( + list(s.width_aware_splitlines(2)), [fmtstr("ab"), fmtstr("cd")] + ) + + s = fmtstr("HE!") + self.assertEqual( + list(s.width_aware_splitlines(2)), [fmtstr("H "), fmtstr("E"), fmtstr("!")] + ) + + s = fmtstr("He\u0300llo") + self.assertEqual( + list(s.width_aware_splitlines(2)), + [fmtstr("He\u0300"), fmtstr("ll"), fmtstr("o")], + ) + + with self.assertRaises(ValueError): + s.width_aware_splitlines(1) + + def test_width_at_offset(self) -> None: + self.assertEqual(fmtstr("abEcdef").width_at_offset(0), 0) + self.assertEqual(fmtstr("abEcdef").width_at_offset(2), 2) + self.assertEqual(fmtstr("abEcdef").width_at_offset(3), 4) + self.assertEqual(fmtstr("a\u0300b\u3000c").width_at_offset(0), 0) + self.assertEqual(fmtstr("a\u0300b\u3000c").width_at_offset(1), 1) + self.assertEqual(fmtstr("a\u0300b\u3000c").width_at_offset(2), 1) + self.assertEqual(fmtstr("a\u0300b\u3000c").width_at_offset(3), 2) + self.assertEqual(len(fmtstr("a\u0300")), 2) + self.assertEqual(fmtstr("a\u0300").width, 1) - def test_doublewide_width(self): - self.assertEqual(len(fmtstr('E', 'blue')), 1) - self.assertEqual(fmtstr('E', 'blue').width, 2) - self.assertEqual(len(fmtstr('hi')), 2) - self.assertEqual(fmtstr('hi').width, 4) - - def test_multi_width(self): - self.assertEqual(len(fmtstr('a\u0300')), 2) - self.assertEqual(fmtstr('a\u0300').width, 1) - - def test_width_aware_slice(self): - self.assertEqual(fmtstr('E').width_aware_slice(slice(None, 1, None)).s, ' ') - self.assertEqual(fmtstr('E').width_aware_slice(slice(None, 2, None)).s, 'E') - self.assertEqual(fmtstr('HE!', 'blue').width_aware_slice(slice(1, 2, None)), fmtstr(' ', 'blue')) - self.assertEqual(fmtstr('HE!', 'blue').width_aware_slice(slice(1, 3, None)), fmtstr('E', 'blue')) - - def test_width_at_offset(self): - self.assertEqual(fmtstr('abEcdef').width_at_offset(0), 0) - self.assertEqual(fmtstr('abEcdef').width_at_offset(2), 2) - self.assertEqual(fmtstr('abEcdef').width_at_offset(3), 4) - self.assertEqual(fmtstr('a\u0300b\u3000c').width_at_offset(0), 0) - self.assertEqual(fmtstr('a\u0300b\u3000c').width_at_offset(1), 1) - self.assertEqual(fmtstr('a\u0300b\u3000c').width_at_offset(2), 1) - self.assertEqual(fmtstr('a\u0300b\u3000c').width_at_offset(3), 2) - self.assertEqual(len(fmtstr('a\u0300')), 2) - self.assertEqual(fmtstr('a\u0300').width, 1) class TestWidthHelpers(unittest.TestCase): - - def test_combining_char_aware_slice(self): - self.assertEqual(width_aware_slice('abc', 0, 2), 'ab') - self.assertEqual(width_aware_slice('abc', 1, 3), 'bc') - self.assertEqual(width_aware_slice('abc', 0, 3), 'abc') - self.assertEqual(width_aware_slice('ab\u0300c', 0, 3), 'ab\u0300c') - self.assertEqual(width_aware_slice('ab\u0300c', 0, 2), 'ab\u0300') - self.assertEqual(width_aware_slice('ab\u0300c', 1, 3), 'b\u0300c') - self.assertEqual(width_aware_slice('ab\u0300\u0300c', 1, 3), 'b\u0300\u0300c') - - def test_char_width_aware_slice(self): - self.assertEqual(width_aware_slice('abc', 1, 2), 'b') - self.assertEqual(width_aware_slice('aEbc', 0, 4), 'aEb') - self.assertEqual(width_aware_slice('aEbc', 1, 4), 'Eb') - self.assertEqual(width_aware_slice('aEbc', 2, 4), ' b') - self.assertEqual(width_aware_slice('aEbc', 0, 2), 'a ') + def test_combining_char_aware_slice(self) -> None: + self.assertEqual(width_aware_slice("abc", 0, 2), "ab") + self.assertEqual(width_aware_slice("abc", 1, 3), "bc") + self.assertEqual(width_aware_slice("abc", 0, 3), "abc") + self.assertEqual(width_aware_slice("ab\u0300c", 0, 3), "ab\u0300c") + self.assertEqual(width_aware_slice("ab\u0300c", 0, 2), "ab\u0300") + self.assertEqual(width_aware_slice("ab\u0300c", 1, 3), "b\u0300c") + self.assertEqual(width_aware_slice("ab\u0300\u0300c", 1, 3), "b\u0300\u0300c") + self.assertEqual(width_aware_slice("ab\u0300\u0300c", 0, 2), "ab\u0300\u0300") + self.assertEqual(width_aware_slice("ab\u0300\u0300c", 2, 3), "c") + + def test_char_width_aware_slice(self) -> None: + self.assertEqual(width_aware_slice("abc", 1, 2), "b") + self.assertEqual(width_aware_slice("aEbc", 0, 4), "aEb") + self.assertEqual(width_aware_slice("aEbc", 1, 4), "Eb") + self.assertEqual(width_aware_slice("aEbc", 2, 4), " b") + self.assertEqual(width_aware_slice("aEbc", 0, 2), "a ") + + +class TestChunk(unittest.TestCase): + def test_repr(self) -> None: + c = Chunk("a", {"fg": 32}) + self.assertEqual(repr(c), """Chunk('a', {'fg': 32})""") + + +class TestChunkSplitter(unittest.TestCase): + def test_chunk_splitter(self) -> None: + splitter = Chunk("asdf", {"fg": 32}).splitter() + self.assertEqual(splitter.request(1), (1, Chunk("a", {"fg": 32}))) + self.assertEqual(splitter.request(4), (3, Chunk("sdf", {"fg": 32}))) + self.assertEqual(splitter.request(4), None) + + def test_reusing_same_splitter(self) -> None: + c = Chunk("asdf", {"fg": 32}) + s1 = c.splitter() + self.assertEqual(s1.request(3), (3, Chunk("asd", {"fg": 32}))) + s1.reinit(c) + self.assertEqual(s1.request(3), (3, Chunk("asd", {"fg": 32}))) + s1.reinit(c) + self.assertEqual(s1.request(3), (3, Chunk("asd", {"fg": 32}))) + + c2 = Chunk("abcdef", {}) + s1.reinit(c2) + self.assertEqual(s1.request(3), (3, Chunk("abc"))) + + def test_width_awareness(self) -> None: + Chunk("asdf") + self.assertEqual( + Chunk("ab\u0300c").splitter().request(3), (3, Chunk("ab\u0300c")) + ) + self.assertEqual( + Chunk("ab\u0300c").splitter().request(2), (2, Chunk("ab\u0300")) + ) + + s = Chunk("ab\u0300c").splitter() + self.assertEqual(s.request(1), (1, Chunk("a"))) + self.assertEqual(s.request(2), (2, Chunk("b\u0300c"))) + + c = Chunk("aEbc") + self.assertEqual(c.splitter().request(4), (4, Chunk("aEb"))) + s = c.splitter() + self.assertEqual(s.request(2), (2, Chunk("a "))) + self.assertEqual(s.request(2), (2, Chunk("E"))) + self.assertEqual(s.request(2), (2, Chunk("bc"))) + self.assertEqual(s.request(2), None) class TestFSArray(unittest.TestCase): - def test_no_hanging_space(self): + def test_no_hanging_space(self) -> None: a = FSArray(4, 2) self.assertEqual(len(a.rows[0]), 0) - def test_assignment_working(self): + def test_assignment_working(self) -> None: t = FSArray(10, 10) - t[2, 2] = 'a' - t[2, 2] == 'a' + t[2, 2] = "a" + self.assertEqual(t[2, 2], ["a"]) - def test_normalize_slice(self): - class SliceBuilder(object): - def __getitem__(self, slice): + def test_normalize_slice(self) -> None: + class SliceBuilder: + def __getitem__(self, slice: slice) -> slice: return slice + Slice = SliceBuilder() self.assertEqual(normalize_slice(10, Slice[:3]), slice(0, 3, None)) self.assertEqual(normalize_slice(11, Slice[3:]), slice(3, 11, None)) - @skip('TODO') - def test_oomerror(self): + @skip("TODO") + def test_oomerror(self) -> None: a = FSArray(10, 40) - a[2:-2, 2:-2] = fsarray(['asdf', 'zxcv']) + a[2:-2, 2:-2] = fsarray(["asdf", "zxcv"]) + + +class FormatStringTest(unittest.TestCase): + def assertFSArraysEqual(self, a: FSArray, b: FSArray) -> None: + self.assertIsInstance(a, FSArray) + self.assertIsInstance(b, FSArray) + self.assertEqual( + (a.width, a.height), + (b.width, b.height), + f"fsarray dimensions do not match: {a.shape} {b.shape}", + ) + for i, (a_row, b_row) in enumerate(zip(a, b)): + self.assertEqual( + a_row, + b_row, + "FSArrays differ first on line {}:\n{}".format(i, FSArray.diff(a, b)), + ) + + def assertFSArraysEqualIgnoringFormatting(self, a: FSArray, b: FSArray) -> None: + """Also accepts arrays of strings""" + self.assertEqual( + len(a), + len(b), + "fsarray heights do not match: %s %s \n%s \n%s" + % (len(a), len(b), simple_format(a), simple_format(b)), + ) + for i, (a_row, b_row) in enumerate(zip(a, b)): + a_row = a_row.s if isinstance(a_row, FmtStr) else a_row + b_row = b_row.s if isinstance(b_row, FmtStr) else b_row + self.assertEqual( + a_row, + b_row, + "FSArrays differ first on line %s:\n%s" + % (i, FSArray.diff(a, b, ignore_formatting=True)), + ) class TestFSArrayWithDiff(FormatStringTest): - - def test_diff_testing(self): - a = fsarray(['abc', 'def']) - b = fsarray(['abc', 'dqf']) + def test_diff_testing(self) -> None: + a = fsarray(["abc", "def"]) + b = fsarray(["abc", "dqf"]) self.assertRaises(AssertionError, self.assertFSArraysEqual, a, b) - a = fsarray([blue('abc'), red('def')]) - b = fsarray([blue('abc'), red('dqf')]) + a = fsarray([blue("abc"), red("def")]) + b = fsarray([blue("abc"), red("dqf")]) self.assertRaises(AssertionError, self.assertFSArraysEqual, a, b) - a = fsarray([blue('abc'), red('def')]) - b = fsarray([blue('abc'), red('d')+blue('e')+red('f')]) + a = fsarray([blue("abc"), red("def")]) + b = fsarray([blue("abc"), red("d") + blue("e") + red("f")]) self.assertRaises(AssertionError, self.assertFSArraysEqual, a, b) - a = fsarray(['abc', 'def']) - b = fsarray(['abc', 'def']) + a = fsarray(["abc", "def"]) + b = fsarray(["abc", "def"]) self.assertFSArraysEqual(a, b) - a = fsarray([blue('abc'), red('def')]) - b = fsarray([blue('abc'), red('def')]) + a = fsarray([blue("abc"), red("def")]) + b = fsarray([blue("abc"), red("def")]) self.assertFSArraysEqual(a, b) -if __name__ == '__main__': - import fmtstr.fmtstr + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_input.py b/tests/test_input.py index f895df7..172009b 100644 --- a/tests/test_input.py +++ b/tests/test_input.py @@ -4,20 +4,8 @@ import threading import time import unittest -from mock import Mock - -try: - from unittest import skip, skipUnless -except ImportError: - - def skip(f): - return lambda self: None - - def skipUnless(condition, reason): - if condition: - return lambda x: x - else: - return lambda x: None +from unittest.mock import Mock +from unittest import skip, skipUnless from curtsies import events from curtsies.input import Input @@ -46,8 +34,8 @@ def test_iter(self): def test_send(self): inp = Input() - inp.unprocessed_bytes = [b'a'] - self.assertEqual(inp.send('nonsensical value'), u'a') + inp.unprocessed_bytes = [b"a"] + self.assertEqual(inp.send("nonsensical value"), "a") def test_send_nonblocking_no_event(self): inp = Input() @@ -70,16 +58,17 @@ def test_send_paste(self): def side_effect(): if first_time: - inp.unprocessed_bytes.extend([b'a']*n) + inp.unprocessed_bytes.extend([b"a"] * n) first_time.pop() return n else: return None + inp._nonblocking_read.side_effect = side_effect r = inp.send(0) self.assertEqual(type(r), events.PasteEvent) - self.assertEqual(r.events, [u'a'] * n) + self.assertEqual(r.events, ["a"] * n) def test_event_trigger(self): inp = Input() @@ -96,7 +85,7 @@ def test_schedule_event_trigger(self): f(when=time.time()) self.assertEqual(type(inp.send(0)), CustomScheduledEvent) self.assertEqual(inp.send(0), None) - f(when=time.time()+0.01) + f(when=time.time() + 0.01) self.assertEqual(inp.send(0), None) time.sleep(0.01) self.assertEqual(type(inp.send(0)), CustomScheduledEvent) @@ -105,12 +94,13 @@ def test_schedule_event_trigger(self): def test_schedule_event_trigger_blocking(self): inp = Input() f = inp.scheduled_event_trigger(CustomScheduledEvent) - f(when=time.time()+0.05) + f(when=time.time() + 0.05) self.assertEqual(type(next(inp)), CustomScheduledEvent) def test_threadsafe_event_trigger(self): inp = Input() f = inp.threadsafe_event_trigger(CustomEvent) + def check_event(): self.assertEqual(type(inp.send(1)), CustomEvent) self.assertEqual(inp.send(0), None) @@ -132,3 +122,28 @@ def send_sigint(): self.assertEqual(type(inp.send(1)), events.SigIntEvent) self.assertEqual(inp.send(0), None) t.join() + + def test_create_in_thread_with_sigint_event(self): + def create(): + inp = Input(sigint_event=True) + + t = threading.Thread(target=create) + t.start() + t.join() + + def test_use_in_thread_with_sigint_event(self): + inp = Input(sigint_event=True) + + def use(): + with inp: + pass + + t = threading.Thread(target=use) + t.start() + t.join() + + def test_cleanup(self): + input_generator = Input() + for i in range(1000): + with input_generator: + pass diff --git a/tests/test_terminal.py b/tests/test_terminal.py index f053dd6..d1aa4d9 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -1,51 +1,25 @@ -from __future__ import unicode_literals - import locale import os import sys import unittest -if sys.version_info[0] == 3: - from io import StringIO -else: - from StringIO import StringIO +from io import StringIO +from unittest import skipUnless, skipIf, expectedFailure -import blessings +import blessed import pyte from pyte import control as ctrl, Stream, Screen from curtsies.window import BaseWindow, FullscreenWindow, CursorAwareWindow -# a few tests fail on TravisCI that have something to do with -# stdin not being able to be set to nonblocking -# (and still reporting isatty as True) -IS_TRAVIS = bool(os.environ.get("TRAVIS")) - -try: - from unittest import skipUnless, skipIf -except ImportError: - def skipUnless(condition, reason): - if condition: - return lambda x: x - else: - return lambda x: None - def skipIf(condition, reason): - if condition: - return lambda x: None - else: - return lambda x: x - - class FakeStdin(StringIO): - encoding = 'ascii' + encoding = "ascii" # thanks superbobry for this code: https://github.com/selectel/pyte/issues/13 class ReportingStream(Stream): - report_escape = { - "6": "report_cursor_position" - } + report_escape = {"6": "report_cursor_position"} def _arguments(self, char): if char == "n": @@ -53,13 +27,13 @@ def _arguments(self, char): # is wait for the 'n' argument. return self.dispatch(self.report_escape[self.current]) else: - return super(ReportingStream, self)._arguments(char) + return super()._arguments(char) class ReportingScreen(Screen): def __init__(self, *args, **kwargs): self._report_file = FakeStdin() - super(ReportingScreen, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def report_cursor_position(self): # cursor position is 1-indexed in the ANSI escape sequence API @@ -72,40 +46,44 @@ def report_cursor_position(self): class ReportingScreenWithExtra(ReportingScreen): def report_cursor_position(self): # cursor position is 1-indexed in the ANSI escape sequence API - extra = 'qwerty\nasdf\nzxcv' + extra = "qwerty\nasdf\nzxcv" s = ctrl.CSI + "%d;%sR" % (self.cursor.y + 1, self.cursor.x + 1) self._report_file.seek(0) self._report_file.write(extra + s) self._report_file.seek(0) -class Bugger(object): +class Bugger: __before__ = __after__ = lambda *args: None def __getattr__(self, event): to = sys.stdout + def inner(*args, **flags): to.write(event.upper() + " ") to.write("; ".join(map(repr, args))) to.write(" ") - to.write(", ".join("{0}: {1}".format(name, repr(arg)) - for name, arg in flags.items())) + to.write(", ".join(f"{name}: {repr(arg)}" for name, arg in flags.items())) to.write(os.linesep) + return inner -class ScreenStdout(object): +class ScreenStdout: def __init__(self, stream): self.stream = stream + def write(self, s): if sys.version_info[0] == 3: self.stream.feed(s) else: self.stream.feed(s.decode(locale.getpreferredencoding())) - def flush(self): pass + def flush(self): + pass -@skipUnless(sys.stdin.isatty(), 'blessings Terminal needs streams open') + +@skipUnless(sys.stdin.isatty(), "blessed Terminal needs streams open") class TestFullscreenWindow(unittest.TestCase): def setUp(self): self.screen = pyte.Screen(10, 3) @@ -116,23 +94,29 @@ def setUp(self): def test_render(self): with self.window: - self.window.render_to_terminal([u'hi', u'there']) - self.assertEqual(self.screen.display, [u'hi ', u'there ', u' ']) + self.window.render_to_terminal(["hi", "there"]) + self.assertEqual( + self.screen.display, ["hi ", "there ", " "] + ) def test_scroll(self): with self.window: - self.window.render_to_terminal([u'hi', u'there']) + self.window.render_to_terminal(["hi", "there"]) self.window.scroll_down() - self.assertEqual(self.screen.display, [u'there ', u' ', u' ']) + self.assertEqual( + self.screen.display, ["there ", " ", " "] + ) + +class NopContext: + def __enter__(*args): + pass -class NopContext(object): - def __enter__(*args): pass - def __exit__(*args): pass + def __exit__(*args): + pass -@skipIf(IS_TRAVIS, 'Travis stdin behaves strangely, see issue 89') -@skipUnless(sys.stdin.isatty(), 'blessings Terminal needs streams open') +@skipUnless(sys.stdin.isatty(), "blessed Terminal needs streams open") class TestCursorAwareWindow(unittest.TestCase): def setUp(self): self.screen = ReportingScreen(6, 3) @@ -140,34 +124,39 @@ def setUp(self): self.stream.attach(self.screen) self.stream.attach(Bugger()) stdout = ScreenStdout(self.stream) - self.window = CursorAwareWindow(out_stream=stdout, - in_stream=self.screen._report_file) + self.window = CursorAwareWindow( + out_stream=stdout, in_stream=self.screen._report_file + ) self.window.cbreak = NopContext() - blessings.Terminal.height = 3 - blessings.Terminal.width = 6 + blessed.Terminal.height = 3 + blessed.Terminal.width = 6 + # This isn't passing locally for me anymore :/ + @expectedFailure def test_render(self): with self.window: self.assertEqual(self.window.top_usable_row, 0) - self.window.render_to_terminal([u'hi', u'there']) - self.assertEqual(self.screen.display, [u'hi ', u'there ', u' ']) + self.window.render_to_terminal(["hi", "there"]) + self.assertEqual(self.screen.display, ["hi ", "there ", " "]) + # This isn't passing locally for me anymore :/ + @expectedFailure def test_cursor_position(self): with self.window: - self.window.render_to_terminal([u'hi', u'there'], cursor_pos=(2, 4)) + self.window.render_to_terminal(["hi", "there"], cursor_pos=(2, 4)) self.assertEqual(self.window.get_cursor_position(), (2, 4)) + # This isn't passing locally for me anymore :/ + @expectedFailure def test_inital_cursor_position(self): - self.screen.cursor.y += 1 with self.window: self.assertEqual(self.window.top_usable_row, 1) - self.window.render_to_terminal([u'hi', u'there']) - self.assertEqual(self.screen.display, [u' ', u'hi ', u'there ']) + self.window.render_to_terminal(["hi", "there"]) + self.assertEqual(self.screen.display, [" ", "hi ", "there "]) -@skipIf(IS_TRAVIS, 'Travis stdin behaves strangely, see issue 89') -@skipUnless(sys.stdin.isatty(), 'blessings Terminal needs streams open') +@skipUnless(sys.stdin.isatty(), "blessed Terminal needs streams open") class TestCursorAwareWindowWithExtraInput(unittest.TestCase): def setUp(self): self.screen = ReportingScreenWithExtra(6, 3) @@ -176,17 +165,21 @@ def setUp(self): self.stream.attach(Bugger()) stdout = ScreenStdout(self.stream) self.extra_bytes = [] - self.window = CursorAwareWindow(out_stream=stdout, - in_stream=self.screen._report_file, - extra_bytes_callback=self.extra_bytes_callback) + self.window = CursorAwareWindow( + out_stream=stdout, + in_stream=self.screen._report_file, + extra_bytes_callback=self.extra_bytes_callback, + ) self.window.cbreak = NopContext() - blessings.Terminal.height = 3 - blessings.Terminal.width = 6 + blessed.Terminal.height = 3 + blessed.Terminal.width = 6 def extra_bytes_callback(self, bytes): self.extra_bytes.append(bytes) + # This isn't passing locally for me anymore :/ + @expectedFailure def test_report_extra_bytes(self): with self.window: - pass # should have triggered getting initial cursor position - self.assertEqual(b''.join(self.extra_bytes), b'qwerty\nasdf\nzxcv') + pass # should have triggered getting initial cursor position + self.assertEqual(b"".join(self.extra_bytes), b"qwerty\nasdf\nzxcv") diff --git a/tests/test_window.py b/tests/test_window.py index 184dea3..07f8073 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1,23 +1,9 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals import unittest import sys from curtsies.window import BaseWindow, FullscreenWindow, CursorAwareWindow - -if sys.version_info[0] == 3: - from io import StringIO -else: - from cStringIO import StringIO - -try: - from unittest import skipIf -except ImportError: - def skipIf(condition, reason): - if condition: - return lambda x: x - else: - return lambda x: None +from io import StringIO +from unittest import skipIf fds_closed = not sys.stdin.isatty() or not sys.stdout.isatty() @@ -27,43 +13,44 @@ class FakeFullscreenWindow(FullscreenWindow): width = property(lambda self: 10) height = property(lambda self: 4) -@skipIf(fds_closed, "blessings Terminal needs streams open") + +@skipIf(fds_closed, "blessed Terminal needs streams open") class TestBaseWindow(unittest.TestCase): """Pretty pathetic tests for window""" + def test_window(self): fakestdout = StringIO() window = BaseWindow(fakestdout) - window.write('hi') + window.write("hi") fakestdout.seek(0) - self.assertEqual(fakestdout.read(), 'hi') + self.assertEqual(fakestdout.read(), "hi") def test_array_from_text(self): window = BaseWindow() - a = window.array_from_text('.\n.\n.') + a = window.array_from_text(".\n.\n.") self.assertEqual(a.height, 3) - self.assertEqual(a[0], '.') - self.assertEqual(a[1], '.') + self.assertEqual(a[0], ".") + self.assertEqual(a[1], ".") def test_array_from_text_rc(self): - a = BaseWindow.array_from_text_rc('asdfe\nzx\n\n123', 3, 4) + a = BaseWindow.array_from_text_rc("asdfe\nzx\n\n123", 3, 4) self.assertEqual(a.height, 3) self.assertEqual(a.width, 4) - self.assertEqual(a[0], 'asdf') - self.assertEqual(a[1], 'e') - self.assertEqual(a[2], 'zx') + self.assertEqual(a[0], "asdf") + self.assertEqual(a[1], "e") + self.assertEqual(a[2], "zx") def test_fullscreen_window(self): fakestdout = StringIO() window = FullscreenWindow(fakestdout) - window.write('hi') + window.write("hi") fakestdout.seek(0) - self.assertEqual(fakestdout.read(), 'hi') + self.assertEqual(fakestdout.read(), "hi") def test_fullscreen_render_to_terminal(self): fakestdout = StringIO() window = FakeFullscreenWindow(fakestdout) - window.render_to_terminal(['hello', 'hello', 'hello']) + window.render_to_terminal(["hello", "hello", "hello"]) fakestdout.seek(0) output = fakestdout.read() - self.assertEqual(output.count('hello'), 3) - + self.assertEqual(output.count("hello"), 3) diff --git a/todo b/todo deleted file mode 100644 index e7fd192..0000000 --- a/todo +++ /dev/null @@ -1,10 +0,0 @@ -temporary todos not yet in GitHub issues, which is the official place for this kind of thing - -soon ----- - -* bugfix release bpython to use 0.0.32 and not higher - * make a bugfix 0.0.* branch -* release a new version of curtsies - * do a bugfix release of bpython first -* more tests! Even ones that just cover surface area! Everything py2/3 different that's testable