diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..9109cd45 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*.json] +charset = utf-8 +tab_width = 4 +indent_size = tab +indent_space = space +trim_trailing_whitespace = true +insert_final_newline = true + +[*.py] +charset = utf-8 +tab_width = 4 +indent_size = tab +indent_space = space +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.gitignore b/.gitignore index 94748175..66b09dc4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,18 @@ +.coverage +._coverage.* +.coverage.* +.cache .tox *.egg-info *.egg *.pyc +results*.xml build dist -docs/_build \ No newline at end of file +docs/_build +htmlcov +.coveralls.yml +.DS_Store +.*.sw? +.vscode +.python-version diff --git a/.landscape.yml b/.landscape.yml new file mode 100644 index 00000000..578295ea --- /dev/null +++ b/.landscape.yml @@ -0,0 +1,100 @@ +inherits: + - strictness_veryhigh + +ignore-patterns: + - (^|/)\..+ + - ^docs/ + - ^build/ + # ignore these, their quality does not so much matter. + - ^blessed/tests/ + - ^tools/ + +test-warnings: true + +output-format: grouped + +dodgy: + # Looks at Python code to search for things which look "dodgy" + # such as passwords or git conflict artifacts + run: true + +pyflakes: + # preferring 'frosted' instead (a fork of) + run: true + disable: + # Terminal imported but unused (false) + - 'E101' + +frosted: + run: false + +mccabe: + # complexity checking. We know of only one offense of the + # default of 10, which is Terminal.inkey() at exactly 10. + # It is not easily avoided, likely removing deprecation + # warnings in a future release will help reduce it. + run: false + options: + max-complexity: 11 + +pep257: + # docstring checking + run: true + disable: + # 1 blank line required before class docstring + - 'D203' + # 1 blank line required after class docstring + - 'D204' + # Multi-line docstring summary should start at the first line + - 'D212' + # First line should be in imperative mood + - 'D401' + +pep8: + # style checking + run: true + +pylint: + # static analysis and then some + run: true + options: + # pytest module has dynamically assigned functions, + # raising errors such as: E1101: Module 'pytest' has + # no 'mark' member + ignored-classes: pytest + # List of builtins function names that should not be used, separated by a comma + # bad-functions=map,filter,input + bad-functions: input + persistent: no + # 'ks' is often-cited variable representing Keystroke instance. + # 'fd' is a common shorthand term for file descriptor (as int). + good-names: _,ks,fd + + + disable: + - protected-access + - too-few-public-methods + - star-args + - wrong-import-order + - wrong-import-position + - ungrouped-imports + - useless-object-inheritance + +pyroma: + # checks setup.py + run: true + disable: + # > Setuptools and Distribute support running tests. By specifying a test + # > suite, it's easy to find and run tests both for automated tools and humans. + # + # https://pytest.org/latest/goodpractises.html#integration-with-setuptools-test-commands + # > Most often it is better to use tox instead + # + # we agree. + - 'PYR15' + +vulture: + # this tool does a good job of finding unused code. + run: false + +# vim: noai:ts=4:sw=4 diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..9714cf85 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,48 @@ +[MASTER] +load-plugins= + pylint.extensions.mccabe, + pylint.extensions.check_elif, + pylint.extensions.docparams, + pylint.extensions.overlapping_exceptions, + pylint.extensions.redefined_variable_type + +persistent = no +jobs = 0 +unsafe-load-any-extension = yes + +[MESSAGES CONTROL] +disable= + I, + fixme, + c-extension-no-member, + ungrouped-imports + +[FORMAT] +max-line-length: 100 +good-names=ks,fd,_ + +[PARAMETER_DOCUMENTATION] +default-docstring-type=sphinx +accept-no-raise-doc=no +accept-no-param-doc=yes +accept-no-return-doc=yes + +[DESIGN] +max-args=10 +max-attributes=7 +max-branches=12 +max-complexity=11 +max-locals=15 +max-module-lines=1300 +max-parents=7 +max-public-methods=20 +max-returns=6 +max-statements=50 + +[SIMILARITIES] +ignore-imports=yes +min-similarity-lines=8 + +[REPORTS] +reports=no +msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} diff --git a/.travis.yml b/.travis.yml index 41bab6be..5f6e9c19 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,46 @@ language: python - -python: - - 2.6 - - 2.7 - - 3.2 - - pypy - matrix: + fast_finish: true + include: + - python: 2.7 + env: TOXENV=py27,codecov TEST_QUICK=1 COVERAGE_ID=travis-ci + - python: 3.4 + env: TOXENV=py34,codecov TEST_QUICK=1 COVERAGE_ID=travis-ci + - python: 3.5 + env: TOXENV=py35,codecov TEST_QUICK=1 COVERAGE_ID=travis-ci + - python: 3.6 + env: TOXENV=py36,codecov TEST_QUICK=1 COVERAGE_ID=travis-ci + - python: 3.7 + env: TOXENV=py37,codecov TEST_QUICK=1 COVERAGE_ID=travis-ci + - python: 3.8 + env: TOXENV=py38,codecov COVERAGE_ID=travis-ci + - python: 3.9-dev + env: TOXENV=py39,codecov TEST_QUICK=1 COVERAGE_ID=travis-ci + - python: 3.8 + env: TOXENV=about,pylint,flake8,flake8_tests,sphinx COVERAGE_ID=travis-ci + - python: 3.8 + os: windows + language: shell + before_install: + - choco install python --version 3.8.0 + - python -m pip install --upgrade pip + - python -m pip install tox + env: PATH=/c/Python38:/c/Python38/Scripts:$PATH TOXENV=py38,codecov COVERAGE_ID=travis-ci TEST_KEYBOARD=no + +jobs: allow_failures: - - python: 3.2 # Not a new enough 3.2 for blessings - - python: pypy # PyPy doesn't have _curses. + - env: TOXENV=py39,codecov TEST_QUICK=1 COVERAGE_ID=travis-ci + - env: TOXENV=about,pylint,flake8,flake8_tests,sphinx COVERAGE_ID=travis-ci +install: + - pip install tox script: - - pip install -q --use-mirrors nose - - python setup.py install - - nosetests -w /tmp blessings.tests + - tox +sudo: false + +notifications: + email: + recipients: + - contact@jeffquast.com + on_success: change + on_failure: change diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 00000000..af76aaf3 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,47 @@ +Contributing +============ + +We welcome contributions via GitHub pull requests: + +- `Fork a Repo `_ +- `Creating a pull request + `_ + +Developing +---------- + +Prepare a developer environment. Then, from the blessed code folder:: + + pip install --editable . + +Any changes made in this project folder are then made available to the python +interpreter as the 'blessed' package from any working directory. + +Running Tests +~~~~~~~~~~~~~ + +Install and run tox + +:: + + pip install --upgrade tox + tox + +Py.test is used as the test runner, supporting positional arguments, you may +for example use `looponfailing +`_ +with python 3.5, stopping at the first failing test case, and looping +(retrying) after a filesystem save is detected:: + + tox -epy35 -- -fx + +The test runner (``tox``) ensures all code and documentation complies with +standard python style guides, pep8 and pep257, as well as various static +analysis tools. + +Test Coverage +~~~~~~~~~~~~~ + +When you contribute a new feature, make sure it is covered by tests. + +Likewise, a bug fix should include a test demonstrating the bug. diff --git a/LICENSE b/LICENSE index 3d3a44e6..4b071328 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ +Copyright (c) 2014 Jeff Quast Copyright (c) 2011 Erik Rose Permission is hereby granted, free of charge, to any person obtaining a copy of diff --git a/MANIFEST.in b/MANIFEST.in index 3f4fbd70..1ce192c3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,6 @@ -include README.rst +include docs/*.rst include LICENSE +include version.json +include *.txt include tox.ini +recursive-include tests *.py *.ans diff --git a/README.rst b/README.rst deleted file mode 100644 index f6fc8789..00000000 --- a/README.rst +++ /dev/null @@ -1,520 +0,0 @@ -========= -Blessings -========= - -Coding with Blessings looks like this... :: - - from blessings import Terminal - - t = Terminal() - - print t.bold('Hi there!') - print t.bold_red_on_bright_green('It hurts my eyes!') - - with t.location(0, t.height - 1): - print 'This is at the bottom.' - -Or, for byte-level control, you can drop down and play with raw terminal -capabilities:: - - print '{t.bold}All your {t.red}bold and red base{t.normal}'.format(t=t) - print t.wingo(2) - -The Pitch -========= - -Blessings lifts several of curses_' limiting assumptions, and it makes your -code pretty, too: - -* Use styles, color, and maybe a little positioning without necessarily - clearing the whole - screen first. -* Leave more than one screenful of scrollback in the buffer after your program - exits, like a well-behaved command-line app should. -* Get rid of all those noisy, C-like calls to ``tigetstr`` and ``tparm``, so - your code doesn't get crowded out by terminal bookkeeping. -* Act intelligently when somebody redirects your output to a file, omitting the - terminal control codes the user doesn't want to see (optional). - -.. _curses: http://docs.python.org/library/curses.html - -Before And After ----------------- - -Without Blessings, this is how you'd print some underlined text at the bottom -of the screen:: - - from curses import tigetstr, setupterm, tparm - from fcntl import ioctl - from os import isatty - import struct - import sys - from termios import TIOCGWINSZ - - # If we want to tolerate having our output piped to other commands or - # files without crashing, we need to do all this branching: - if hasattr(sys.stdout, 'fileno') and isatty(sys.stdout.fileno()): - setupterm() - sc = tigetstr('sc') - cup = tigetstr('cup') - rc = tigetstr('rc') - underline = tigetstr('smul') - normal = tigetstr('sgr0') - else: - sc = cup = rc = underline = normal = '' - print sc # Save cursor position. - if cup: - # tigetnum('lines') doesn't always update promptly, hence this: - height = struct.unpack('hhhh', ioctl(0, TIOCGWINSZ, '\000' * 8))[0] - print tparm(cup, height - 1, 0) # Move cursor to bottom. - print 'This is {under}underlined{normal}!'.format(under=underline, - normal=normal) - print rc # Restore cursor position. - -That was long and full of incomprehensible trash! Let's try it again, this time -with Blessings:: - - from blessings import Terminal - - term = Terminal() - with term.location(0, term.height - 1): - print 'This is', term.underline('pretty!') - -Much better. - -What It Provides -================ - -Blessings provides just one top-level object: ``Terminal``. Instantiating a -``Terminal`` figures out whether you're on a terminal at all and, if so, does -any necessary terminal setup. After that, you can proceed to ask it all sorts -of things about the terminal. Terminal terminal terminal. - -Simple Formatting ------------------ - -Lots of handy formatting codes ("capabilities" in low-level parlance) are -available as attributes on a ``Terminal``. For example:: - - from blessings import Terminal - - term = Terminal() - print 'I am ' + term.bold + 'bold' + term.normal + '!' - -Though they are strings at heart, you can also use them as callable wrappers so -you don't have to say ``normal`` afterward:: - - print 'I am', term.bold('bold') + '!' - -Or, if you want fine-grained control while maintaining some semblance of -brevity, you can combine it with Python's string formatting, which makes -attributes easy to access:: - - print 'All your {t.red}base {t.underline}are belong to us{t.normal}'.format(t=term) - -Simple capabilities of interest include... - -* ``bold`` -* ``reverse`` -* ``underline`` -* ``no_underline`` (which turns off underlining) -* ``blink`` -* ``normal`` (which turns off everything, even colors) - -Here are a few more which are less likely to work on all terminals: - -* ``dim`` -* ``italic`` and ``no_italic`` -* ``shadow`` and ``no_shadow`` -* ``standout`` and ``no_standout`` -* ``subscript`` and ``no_subscript`` -* ``superscript`` and ``no_superscript`` -* ``flash`` (which flashes the screen once) - -Note that, while the inverse of ``underline`` is ``no_underline``, the only way -to turn off ``bold`` or ``reverse`` is ``normal``, which also cancels any -custom colors. This is because there's no portable way to tell the terminal to -undo certain pieces of formatting, even at the lowest level. - -You might also notice that the above aren't the typical incomprehensible -terminfo capability names; we alias a few of the harder-to-remember ones for -readability. However, you aren't limited to these: you can reference any -string-returning capability listed on the `terminfo man page`_ by the name -under the "Cap-name" column: for example, ``term.rum``. - -.. _`terminfo man page`: http://www.manpagez.com/man/5/terminfo/ - -Color ------ - -16 colors, both foreground and background, are available as easy-to-remember -attributes:: - - from blessings import Terminal - - term = Terminal() - print term.red + term.on_green + 'Red on green? Ick!' + term.normal - print term.bright_red + term.on_bright_blue + 'This is even worse!' + term.normal - -You can also call them as wrappers, which sets everything back to normal at the -end:: - - print term.red_on_green('Red on green? Ick!') - print term.yellow('I can barely see it.') - -The available colors are... - -* ``black`` -* ``red`` -* ``green`` -* ``yellow`` -* ``blue`` -* ``magenta`` -* ``cyan`` -* ``white`` - -You can set the background color instead of the foreground by prepending -``on_``, as in ``on_blue``. There is also a ``bright`` version of each color: -for example, ``on_bright_blue``. - -There is also a numerical interface to colors, which takes an integer from -0-15:: - - term.color(5) + 'Hello' + term.normal - term.on_color(3) + 'Hello' + term.normal - - term.color(5)('Hello') - term.on_color(3)('Hello') - -If some color is unsupported (for instance, if only the normal colors are -available, not the bright ones), trying to use it will, on most terminals, have -no effect: the foreground and background colors will stay as they were. You can -get fancy and do different things depending on the supported colors by checking -`number_of_colors`_. - -.. _`number_of_colors`: http://packages.python.org/blessings/#blessings.Terminal.number_of_colors - -Compound Formatting -------------------- - -If you want to do lots of crazy formatting all at once, you can just mash it -all together:: - - from blessings import Terminal - - term = Terminal() - print term.bold_underline_green_on_yellow + 'Woo' + term.normal - -Or you can use your newly coined attribute as a wrapper, which implicitly sets -everything back to normal afterward:: - - print term.bold_underline_green_on_yellow('Woo') - -This compound notation comes in handy if you want to allow users to customize -the formatting of your app: just have them pass in a format specifier like -"bold_green" on the command line, and do a quick ``getattr(term, -that_option)('Your text')`` when you do your formatting. - -I'd be remiss if I didn't credit couleur_, where I probably got the idea for -all this mashing. - -.. _couleur: http://pypi.python.org/pypi/couleur - -Moving The Cursor ------------------ - -When you want to move the cursor to output text at a specific spot, you have -a few choices. - -Moving Temporarily -~~~~~~~~~~~~~~~~~~ - -Most often, you'll need to flit to a certain location, print something, and -then return: for example, when updating a progress bar at the bottom of the -screen. ``Terminal`` provides a context manager for doing this concisely:: - - from blessings import Terminal - - term = Terminal() - with term.location(0, term.height - 1): - print 'Here is the bottom.' - print 'This is back where I came from.' - -Parameters to ``location()`` are ``x`` and then ``y``, but you can also pass -just one of them, leaving the other alone. For example... :: - - with term.location(y=10): - print 'We changed just the row.' - -If you're doing a series of ``move`` calls (see below) and want to return the -cursor to its original position afterward, call ``location()`` with no -arguments, and it will do only the position restoring:: - - with term.location(): - print term.move(1, 1) + 'Hi' - print term.move(9, 9) + 'Mom' - -Note that, since ``location()`` uses the terminal's built-in -position-remembering machinery, you can't usefully nest multiple calls. Use -``location()`` at the outermost spot, and use simpler things like ``move`` -inside. - -Moving Permanently -~~~~~~~~~~~~~~~~~~ - -If you just want to move and aren't worried about returning, do something like -this:: - - from blessings import Terminal - - term = Terminal() - print term.move(10, 1) + 'Hi, mom!' - -``move`` - Position the cursor elsewhere. Parameters are y coordinate, then x - coordinate. -``move_x`` - Move the cursor to the given column. -``move_y`` - Move the cursor to the given row. - -How does all this work? These are simply more terminal capabilities, wrapped to -give them nicer names. The added wrinkle--that they take parameters--is also -given a pleasant treatment: rather than making you dig up ``tparm()`` all the -time, we simply make these capabilities into callable strings. You'd get the -raw capability strings if you were to just print them, but they're fully -parametrized if you pass params to them as if they were functions. - -Consequently, you can also reference any other string-returning capability -listed on the `terminfo man page`_ by its name under the "Cap-name" column. - -.. _`terminfo man page`: http://www.manpagez.com/man/5/terminfo/ - -One-Notch Movement -~~~~~~~~~~~~~~~~~~ - -Finally, there are some parameterless movement capabilities that move the -cursor one character in various directions: - -* ``move_left`` -* ``move_right`` -* ``move_up`` -* ``move_down`` - -For example... :: - - print term.move_up + 'Howdy!' - -Height And Width ----------------- - -It's simple to get the height and width of the terminal, in characters:: - - from blessings import Terminal - - term = Terminal() - height = term.height - width = term.width - -These are newly updated each time you ask for them, so they're safe to use from -SIGWINCH handlers. - -Clearing The Screen -------------------- - -Blessings provides syntactic sugar over some screen-clearing capabilities: - -``clear`` - Clear the whole screen. -``clear_eol`` - Clear to the end of the line. -``clear_bol`` - Clear backward to the beginning of the line. -``clear_eos`` - Clear to the end of screen. - -Full-Screen Mode ----------------- - -Perhaps you have seen a full-screen program, such as an editor, restore the -exact previous state of the terminal upon exiting, including, for example, the -command-line prompt from which it was launched. Curses pretty much forces you -into this behavior, but Blessings makes it optional. If you want to do the -state-restoration thing, use these capabilities: - -``enter_fullscreen`` - Switch to the terminal mode where full-screen output is sanctioned. Print - this before you do any output. -``exit_fullscreen`` - Switch back to normal mode, restoring the exact state from before - ``enter_fullscreen`` was used. - -Using ``exit_fullscreen`` will wipe away any trace of your program's output, so -reserve it for when you don't want to leave anything behind in the scrollback. - -There's also a context manager you can use as a shortcut:: - - from blessings import Terminal - - term = Terminal() - with term.fullscreen(): - # Print some stuff. - -Besides brevity, another advantage is that it switches back to normal mode even -if an exception is raised in the ``with`` block. - -Pipe Savvy ----------- - -If your program isn't attached to a terminal, like if it's being piped to -another command or redirected to a file, all the capability attributes on -``Terminal`` will return empty strings. You'll get a nice-looking file without -any formatting codes gumming up the works. - -If you want to override this--like if you anticipate your program being piped -through ``less -r``, which handles terminal escapes just fine--pass -``force_styling=True`` to the ``Terminal`` constructor. - -In any case, there is a ``does_styling`` attribute on ``Terminal`` that lets -you see whether your capabilities will return actual, working formatting codes. -If it's false, you should refrain from drawing progress bars and other frippery -and just stick to content, since you're apparently headed into a pipe:: - - from blessings import Terminal - - term = Terminal() - if term.does_styling: - with term.location(0, term.height - 1): - print 'Progress: [=======> ]' - print term.bold('Important stuff') - -Shopping List -============= - -There are decades of legacy tied up in terminal interaction, so attention to -detail and behavior in edge cases make a difference. Here are some ways -Blessings has your back: - -* Uses the terminfo database so it works with any terminal type -* Provides up-to-the-moment terminal height and width, so you can respond to - terminal size changes (SIGWINCH signals). (Most other libraries query the - ``COLUMNS`` and ``LINES`` environment variables or the ``cols`` or ``lines`` - terminal capabilities, which don't update promptly, if at all.) -* Avoids making a mess if the output gets piped to a non-terminal -* Works great with standard Python string templating -* Provides convenient access to all terminal capabilities, not just a sugared - few -* Outputs to any file-like object, not just stdout -* Keeps a minimum of internal state, so you can feel free to mix and match with - calls to curses or whatever other terminal libraries you like - -Blessings does not provide... - -* Native color support on the Windows command prompt. However, it should work - when used in concert with colorama_. - -.. _colorama: http://pypi.python.org/pypi/colorama/0.2.4 - -Bugs -==== - -Bugs or suggestions? Visit the `issue tracker`_. - -.. _`issue tracker`: https://github.com/erikrose/blessings/issues/ - -Blessings tests are run automatically by `Travis CI`_. - -.. _`Travis CI`: https://travis-ci.org/erikrose/blessings/ - -.. image:: https://secure.travis-ci.org/erikrose/blessings.png - - -License -======= - -Blessings is under the MIT License. See the LICENSE file. - -Version History -=============== - -1.6 - * Add ``does_styling`` property. This takes ``force_styling`` into account - and should replace most uses of ``is_a_tty``. - * Make ``is_a_tty`` a read-only property, like ``does_styling``. Writing to - it never would have done anything constructive. - * Add ``fullscreen()`` and ``hidden_cursor()`` to the auto-generated docs. - * Fall back to ``LINES`` and ``COLUMNS`` environment vars to find height and - width. (jquast) - * Support terminal types, such as kermit and avatar, that use bytes 127-255 - in their escape sequences. (jquast) - -1.5.1 - * Clean up fabfile, removing the redundant ``test`` command. - * Add Travis support. - * Make ``python setup.py test`` work without spurious errors on 2.6. - * Work around a tox parsing bug in its config file. - * Make context managers clean up after themselves even if there's an - exception. (Vitja Makarov) - * Parametrizing a capability no longer crashes when there is no tty. (Vitja - Makarov) - -1.5 - * Add syntactic sugar and documentation for ``enter_fullscreen`` and - ``exit_fullscreen``. - * Add context managers ``fullscreen()`` and ``hidden_cursor()``. - * Now you can force a ``Terminal`` never to emit styles by passing - ``force_styling=None``. - -1.4 - * Add syntactic sugar for cursor visibility control and single-space-movement - capabilities. - * Endorse the ``location()`` idiom for restoring cursor position after a - series of manual movements. - * Fix a bug in which ``location()`` wouldn't do anything when passed zeroes. - * Allow tests to be run with ``python setup.py test``. - -1.3 - * Added ``number_of_colors``, which tells you how many colors the terminal - supports. - * Made ``color(n)`` and ``on_color(n)`` callable to wrap a string, like the - named colors can. Also, make them both fall back to the ``setf`` and - ``setb`` capabilities (like the named colors do) if the ANSI ``setaf`` and - ``setab`` aren't available. - * Allowed ``color`` attr to act as an unparametrized string, not just a - callable. - * Made ``height`` and ``width`` examine any passed-in stream before falling - back to stdout. (This rarely if ever affects actual behavior; it's mostly - philosophical.) - * Made caching simpler and slightly more efficient. - * Got rid of a reference cycle between Terminals and FormattingStrings. - * Updated docs to reflect that terminal addressing (as in ``location()``) is - 0-based. - -1.2 - * Added support for Python 3! We need 3.2.3 or greater, because the curses - library couldn't decide whether to accept strs or bytes before that - (http://bugs.python.org/issue10570). - * Everything that comes out of the library is now unicode. This lets us - support Python 3 without making a mess of the code, and Python 2 should - continue to work unless you were testing types (and badly). Please file a - bug if this causes trouble for you. - * Changed to the MIT License for better world domination. - * Added Sphinx docs. - -1.1 - * Added nicely named attributes for colors. - * Introduced compound formatting. - * Added wrapper behavior for styling and colors. - * Let you force capabilities to be non-empty, even if the output stream is - not a terminal. - * Added the ``is_a_tty`` attribute for telling whether the output stream is a - terminal. - * Sugared the remaining interesting string capabilities. - * Let ``location()`` operate on just an x *or* y coordinate. - -1.0 - * Extracted Blessings from nose-progressive, my `progress-bar-having, - traceback-shortcutting, rootin', tootin' testrunner`_. It provided the - tootin' functionality. - -.. _`progress-bar-having, traceback-shortcutting, rootin', tootin' testrunner`: http://pypi.python.org/pypi/nose-progressive/ diff --git a/README.rst b/README.rst new file mode 120000 index 00000000..9a716adc --- /dev/null +++ b/README.rst @@ -0,0 +1 @@ +docs/intro.rst \ No newline at end of file diff --git a/bin/colorchart.py b/bin/colorchart.py new file mode 100644 index 00000000..57d247b6 --- /dev/null +++ b/bin/colorchart.py @@ -0,0 +1,92 @@ +# encoding: utf-8 +"""Utility to show X11 colors in 24-bit and down-converted to 256, 16, and 8 color The time to +generate the table is displayed to give an indication of how long each algorithm takes compared to +the others.""" +# std imports +import sys +import timeit +import colorsys + +# local +import blessed +from blessed.color import COLOR_DISTANCE_ALGORITHMS +from blessed.colorspace import X11_COLORNAMES_TO_RGB + + +def sort_colors(): + """Sort colors by HSV value and remove duplicates.""" + colors = {} + for color_name, rgb_color in X11_COLORNAMES_TO_RGB.items(): + if rgb_color not in colors: + colors[rgb_color] = color_name + + return sorted(colors.items(), + key=lambda rgb: colorsys.rgb_to_hsv(*rgb[0]), + reverse=True) + + +ALGORITHMS = tuple(sorted(COLOR_DISTANCE_ALGORITHMS)) +SORTED_COLORS = sort_colors() + + +def draw_chart(term): + """Draw a chart of each X11 color represented as in 24-bit and as down-converted to 256, 16, and + 8 color with the currently configured algorithm.""" + sys.stdout.write(term.home) + width = term.width + line = '' + line_len = 0 + + start = timeit.default_timer() + for color in SORTED_COLORS: + + chart = '' + for noc in (1 << 24, 256, 16, 8): + term.number_of_colors = noc + chart += getattr(term, color[1])(u'█') + + if line_len + 5 > width: + line += '\n' + line_len = 0 + + line += ' %s' % chart + line_len += 5 + + elapsed = round((timeit.default_timer() - start) * 1000) + print(line) + + left_text = '[] to select, q to quit' + center_text = f'{term.color_distance_algorithm}' + right_text = f'{elapsed:d} ms\n' + + sys.stdout.write(term.clear_eos + left_text + + term.center(center_text, term.width - + term.length(left_text) - term.length(right_text)) + + right_text) + + +def color_chart(term): + """Main color chart application.""" + term = blessed.Terminal() + algo_idx = 0 + dirty = True + with term.cbreak(), term.hidden_cursor(), term.fullscreen(): + while True: + if dirty: + draw_chart(term) + inp = term.inkey() + dirty = True + if inp in '[]': + algo_idx += 1 if inp == ']' else -1 + algo_idx = algo_idx % len(ALGORITHMS) + term.color_distance_algorithm = ALGORITHMS[algo_idx] + elif inp == '\x0c': + pass + elif inp in 'qQ': + break + else: + dirty = False + + +if __name__ == '__main__': + color_chart(blessed.Terminal()) diff --git a/bin/detect-multibyte.py b/bin/detect-multibyte.py new file mode 100755 index 00000000..acd79222 --- /dev/null +++ b/bin/detect-multibyte.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +# coding: utf-8 +""" +Determines whether the attached terminal supports multibyte encodings. + +Problem: A screen drawing application wants to detect whether the terminal +client is capable of rendering utf-8. Some transports, such as a serial link, +often cannot forward their ``LANG`` environment preference, or protocols such +as telnet and rlogin often assume mutual agreement by manual configuration. + +We can interactively determine whether the connecting terminal emulator is +rendering in utf8 by making an inquiry of their cursor position: + + - request cursor position (p0). + + - display multibyte character. + + - request cursor position (p1). + +If the horizontal distance of (p0, p1) is 1 cell, we know the connecting +client is certainly matching our intended encoding. + +As a (tough!) exercise, it may be possible to use this technique to accurately +determine the remote encoding without protocol negotiation using cursor +positioning alone through a complex state decision tree, as demonstrated +by the following diagram: + +.. image:: _static/soulburner-ru-family-encodings.jpg + :alt: Cyrillic encodings flowchart +""" + + +# pylint: disable=invalid-name +# Invalid module name "detect-multibyte" +from __future__ import print_function + +# std imports +import sys +import collections + +# local +from blessed import Terminal + + +def get_pos(term): + """Get cursor position, calling os.exit(2) if not determined.""" + # pylint: disable=invalid-name + # Invalid variable name "Position" + Position = collections.namedtuple('Position', ('row', 'column')) + + pos = Position(*term.get_location(timeout=5.0)) + + if -1 in pos: + print('stdin: not a human', file=sys.stderr) + exit(2) + + return pos + + +def main(): + """Program entry point.""" + term = Terminal() + + # move to bottom of screen, temporarily, where we're likely to do + # the least damage, as we are performing something of a "destructive + # write and erase" onto this screen location. + with term.cbreak(), term.location(y=term.height - 1, x=0): + + # store first position + pos0 = get_pos(term) + + # display multibyte character + print(u'⦰', end='') + + # store second position + pos1 = get_pos(term) + + # determine distance + horizontal_distance = pos1.column - pos0.column + multibyte_capable = bool(horizontal_distance == 1) + + # rubout character(s) + print('\b \b' * horizontal_distance, end='') + + # returned to our original starting position, + if not multibyte_capable: + print('multibyte encoding failed, horizontal distance is {0}, ' + 'expected 1 for unicode point https://codepoints.net/U+29B0' + .format(horizontal_distance), file=sys.stderr) + exit(1) + + print('{checkbox} multibyte encoding supported!' + .format(checkbox=term.bold_green(u'✓'))) + + +if __name__ == '__main__': + exit(main()) diff --git a/bin/display-fpathconf.py b/bin/display-fpathconf.py new file mode 100755 index 00000000..34ca525a --- /dev/null +++ b/bin/display-fpathconf.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +"""Displays os.fpathconf values related to terminals.""" +# pylint: disable=invalid-name +# Invalid module name "display-sighandlers" +from __future__ import print_function + +# std imports +import os +import sys + + +def display_fpathconf(): + """Program entry point.""" + disp_values = ( + ('PC_MAX_CANON', ('Max no. of bytes in a ' + 'terminal canonical input line.')), + ('PC_MAX_INPUT', ('Max no. of bytes for which ' + 'space is available in a terminal input queue.')), + ('PC_PIPE_BUF', ('Max no. of bytes which will ' + 'be written atomically to a pipe.')), + + # to explain in more detail: PC_VDISABLE is the reference character in + # the pairing output for bin/display-terminalinfo.py: if the value + # matches (\xff), then that special control character is disabled, fe: + # + # Index Name Special Character Default Value + # VEOF EOF ^D + # VEOL EOL _POSIX_VDISABLE + # + # irregardless, this value is almost always \xff. + ('PC_VDISABLE', 'Terminal character disabling value.') + ) + fmt = '{name:<13} {value:<10} {description:<11}' + + # column header + print(fmt.format(name='name', value='value', description='description')) + print(fmt.replace('<', '-<').format(name='-', value='-', description='-')) + + fd = sys.stdin.fileno() + for name, description in disp_values: + key = os.pathconf_names.get(name, None) + if key is None: + value = 'UNDEF' + else: + try: + value = os.fpathconf(fd, name) + if name == 'PC_VDISABLE': + value = r'\x{0:02x}'.format(value) + except OSError as err: + value = 'OSErrno {0.errno}'.format(err) + + print(fmt.format(name=name, value=value, description=description)) + print() + + +if __name__ == '__main__': + display_fpathconf() diff --git a/bin/display-maxcanon.py b/bin/display-maxcanon.py new file mode 100755 index 00000000..09a1a7f5 --- /dev/null +++ b/bin/display-maxcanon.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python +""" +A tool which uses pexpect to test expected Canonical mode length. + +All systems use the value of MAX_CANON which can be found using +fpathconf(3) value PC_MAX_CANON -- with the exception of Linux +and FreeBSD. + +Linux, though defining a value of 255, actually honors the value +of 4096 from linux kernel include file tty.h definition +N_TTY_BUF_SIZE. + +Linux also does not honor IMAXBEL. termios(3) states, "Linux does not +implement this bit, and acts as if it is always set." Although these +tests ensure it is enabled, this is a non-op for Linux. + +FreeBSD supports neither, and instead uses a fraction (1/5) of the tty +speed which is always 9600. Therefor, the maximum limited input line +length is 9600 / 5 = 1920. + +In other words, the only way to determine the true MAX_CANON in a +cross-platform manner is through this systems integrated test: the given +system definitions are misleading on some operating systems. +""" +# pylint: disable=invalid-name +# Invalid module name "display-sighandlers" +from __future__ import print_function + +# std imports +import os +import sys + + +def detect_maxcanon(): + """Program entry point.""" + import pexpect + bashrc = os.path.join( + # re-use pexpect/replwrap.py's bashrc file, + os.path.dirname(__file__), os.path.pardir, 'pexpect', 'bashrc.sh') + + child = pexpect.spawn('bash', ['--rcfile', bashrc], + echo=True, encoding='utf8', + timeout=3) + + child.sendline(u'echo -n READY_; echo GO') + child.expect_exact(u'READY_GO') + + child.sendline(u'stty icanon imaxbel erase ^H; echo -n retval: $?') + child.expect_exact(u'retval: 0') + + child.sendline(u'echo -n GO_; echo AGAIN') + child.expect_exact(u'GO_AGAIN') + child.sendline(u'cat') + + child.delaybeforesend = 0 + + column, blocksize = 0, 64 + ch_marker = u'_' + + print('auto-detecting MAX_CANON: ', end='') + sys.stdout.flush() + + while True: + child.send(ch_marker * blocksize) + result = child.expect([ch_marker * blocksize, u'\a', pexpect.TIMEOUT]) + if result == 0: + # entire block fit without emitting bel + column += blocksize + elif result == 1: + # an '\a' was emitted, count the number of ch_markers + # found since last blocksize, determining our MAX_CANON + column += child.before.count(ch_marker) + break + elif result == 3: + print('Undetermined (Timeout) !') + print(('child.before: ', child.before)) + print(column) + + +if __name__ == '__main__': + try: + detect_maxcanon() + except ImportError: + # we'd like to use this with CI -- but until we integrate + # with tox, we can't determine a period in testing when + # the pexpect module has been installed + print('warning: pexpect not in module path, MAX_CANON ' + 'could not be determined by systems test.', + file=sys.stderr) diff --git a/bin/display-sighandlers.py b/bin/display-sighandlers.py new file mode 100755 index 00000000..aaf09e2f --- /dev/null +++ b/bin/display-sighandlers.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +"""Displays all signals, their values, and their handlers to stdout.""" +# pylint: disable=invalid-name +# Invalid module name "display-sighandlers" +from __future__ import print_function + +# std imports +import signal + + +def main(): + """Program entry point.""" + fmt = '{name:<10} {value:<5} {description}' + + # header + print(fmt.format(name='name', value='value', description='description')) + print('-' * (33)) + + for name, value in [(signal_name, getattr(signal, signal_name)) + for signal_name in dir(signal) + if signal_name.startswith('SIG') + and not signal_name.startswith('SIG_')]: + try: + handler = signal.getsignal(value) + except ValueError: + # FreeBSD: signal number out of range + handler = 'out of range' + description = { + signal.SIG_IGN: "ignored(SIG_IGN)", + signal.SIG_DFL: "default(SIG_DFL)" + }.get(handler, handler) + print(fmt.format(name=name, value=value, description=description)) + + +if __name__ == '__main__': + main() diff --git a/bin/display-terminalinfo.py b/bin/display-terminalinfo.py new file mode 100755 index 00000000..1f581c09 --- /dev/null +++ b/bin/display-terminalinfo.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python +"""Display known information about our terminal.""" +# pylint: disable=invalid-name +# Invalid module name "display-terminalinfo" +from __future__ import print_function + +# std imports +import os +import sys +import locale +import platform + +BITMAP_IFLAG = { + 'IGNBRK': 'ignore BREAK condition', + 'BRKINT': 'map BREAK to SIGINTR', + 'IGNPAR': 'ignore (discard) parity errors', + 'PARMRK': 'mark parity and framing errors', + 'INPCK': 'enable checking of parity errors', + 'ISTRIP': 'strip 8th bit off chars', + 'INLCR': 'map NL into CR', + 'IGNCR': 'ignore CR', + 'ICRNL': 'map CR to NL (ala CRMOD)', + 'IXON': 'enable output flow control', + 'IXOFF': 'enable input flow control', + 'IXANY': 'any char will restart after stop', + 'IMAXBEL': 'ring bell on input queue full', + 'IUCLC': 'translate upper case to lower case', +} + +BITMAP_OFLAG = { + 'OPOST': 'enable following output processing', + 'ONLCR': 'map NL to CR-NL (ala CRMOD)', + 'OXTABS': 'expand tabs to spaces', + 'ONOEOT': 'discard EOT\'s `^D\' on output)', + 'OCRNL': 'map CR to NL', + 'OLCUC': 'translate lower case to upper case', + 'ONOCR': 'No CR output at column 0', + 'ONLRET': 'NL performs CR function', +} + +BITMAP_CFLAG = { + 'CSIZE': 'character size mask', + 'CS5': '5 bits (pseudo)', + 'CS6': '6 bits', + 'CS7': '7 bits', + 'CS8': '8 bits', + 'CSTOPB': 'send 2 stop bits', + 'CREAD': 'enable receiver', + 'PARENB': 'parity enable', + 'PARODD': 'odd parity, else even', + 'HUPCL': 'hang up on last close', + 'CLOCAL': 'ignore modem status lines', + 'CCTS_OFLOW': 'CTS flow control of output', + 'CRTSCTS': 'same as CCTS_OFLOW', + 'CRTS_IFLOW': 'RTS flow control of input', + 'MDMBUF': 'flow control output via Carrier', +} + +BITMAP_LFLAG = { + 'ECHOKE': 'visual erase for line kill', + 'ECHOE': 'visually erase chars', + 'ECHO': 'enable echoing', + 'ECHONL': 'echo NL even if ECHO is off', + 'ECHOPRT': 'visual erase mode for hardcopy', + 'ECHOCTL': 'echo control chars as ^(Char)', + 'ISIG': 'enable signals INTR, QUIT, [D]SUSP', + 'ICANON': 'canonicalize input lines', + 'ALTWERASE': 'use alternate WERASE algorithm', + 'IEXTEN': 'enable DISCARD and LNEXT', + 'EXTPROC': 'external processing', + 'TOSTOP': 'stop background jobs from output', + 'FLUSHO': 'output being flushed (state)', + 'NOKERNINFO': 'no kernel output from VSTATUS', + 'PENDIN': 'XXX retype pending input (state)', + 'NOFLSH': 'don\'t flush after interrupt', +} + +CTLCHAR_INDEX = { + 'VEOF': 'EOF', + 'VEOL': 'EOL', + 'VEOL2': 'EOL2', + 'VERASE': 'ERASE', + 'VWERASE': 'WERASE', + 'VKILL': 'KILL', + 'VREPRINT': 'REPRINT', + 'VINTR': 'INTR', + 'VQUIT': 'QUIT', + 'VSUSP': 'SUSP', + 'VDSUSP': 'DSUSP', + 'VSTART': 'START', + 'VSTOP': 'STOP', + 'VLNEXT': 'LNEXT', + 'VDISCARD': 'DISCARD', + 'VMIN': '---', + 'VTIME': '---', + 'VSTATUS': 'STATUS', +} + + +def display_bitmask(kind, bitmap, value): + """Display all matching bitmask values for ``value`` given ``bitmap``.""" + import termios + col1_width = max(map(len, list(bitmap.keys()) + [kind])) + col2_width = 7 + fmt = '{name:>{col1_width}} {value:>{col2_width}} {description}' + print(fmt.format(name=kind, + value='Value', + description='Description', + col1_width=col1_width, + col2_width=col2_width)) + print('{0} {1} {2}'.format('-' * col1_width, + '-' * col2_width, + '-' * max(map(len, bitmap.values())))) + for flag_name, description in bitmap.items(): + try: + bitmask = getattr(termios, flag_name) + bit_val = 'on' if bool(value & bitmask) else 'off' + except AttributeError: + bit_val = 'undef' + print(fmt.format(name=flag_name, + value=bit_val, + description=description, + col1_width=col1_width, + col2_width=col2_width)) + print() + + +def display_ctl_chars(index, ctlc): + """Display all control character indicies, names, and values.""" + import termios + title = 'Special Character' + col1_width = len(title) + col2_width = max(map(len, index.values())) + fmt = '{idx:<{col1_width}} {name:<{col2_width}} {value}' + print('Special line Characters'.center(40).rstrip()) + print(fmt.format(idx='Index', + name='Name', + value='Value', + col1_width=col1_width, + col2_width=col2_width)) + print('{0} {1} {2}'.format('-' * col1_width, + '-' * col2_width, + '-' * 10)) + for index_name, name in index.items(): + try: + index = getattr(termios, index_name) + value = ctlc[index] + if value == b'\xff': + value = '_POSIX_VDISABLE' + else: + value = repr(value) + except AttributeError: + value = 'undef' + print(fmt.format(idx=index_name, + name=name, + value=value, + col1_width=col1_width, + col2_width=col2_width)) + print() + + +def display_pathconf(names, getter): + """Helper displays results of os.pathconf_names values.""" + col1_width = max(map(len, names)) + fmt = '{name:>{col1_width}} {value}' + print(fmt.format(name='pathconf'.ljust(col1_width), value='value', + col1_width=col1_width)) + print('{0} {1}'.format('-' * col1_width, '-' * 27)) + for name in names: + try: + value = getter(name) + except OSError as err: + value = 'OSErrno {err.errno}'.format(err=err) + print(fmt.format(name=name, value=value, col1_width=col1_width)) + print() + + +def main(): + """Program entry point.""" + if platform.system() == 'Windows': + print('No terminal on windows systems!') + exit(0) + + import termios + fd = sys.stdin.fileno() + locale.setlocale(locale.LC_ALL, '') + encoding = locale.getpreferredencoding() + + print('os.isatty({0}) => {1}'.format(fd, os.isatty(fd))) + print('locale.getpreferredencoding() => {0}'.format(encoding)) + + display_pathconf(names=os.pathconf_names, + getter=lambda name: os.fpathconf(fd, name)) + + try: + (iflag, oflag, cflag, lflag, + _, _, # input / output speed (bps macros) + ctlc) = termios.tcgetattr(fd) + except termios.error as err: + print('stdin is not a typewriter: {0}'.format(err)) + else: + display_bitmask(kind=' Input Mode', + bitmap=BITMAP_IFLAG, + value=iflag) + display_bitmask(kind=' Output Mode', + bitmap=BITMAP_OFLAG, + value=oflag) + display_bitmask(kind='Control Mode', + bitmap=BITMAP_CFLAG, + value=cflag) + display_bitmask(kind=' Local Mode', + bitmap=BITMAP_LFLAG, + value=lflag) + display_ctl_chars(index=CTLCHAR_INDEX, + ctlc=ctlc) + print('os.ttyname({0}) => {1}'.format(fd, os.ttyname(fd))) + print('os.ctermid() => {0}'.format(os.ttyname(fd))) + + +if __name__ == '__main__': + main() diff --git a/bin/editor.py b/bin/editor.py new file mode 100755 index 00000000..2963fce5 --- /dev/null +++ b/bin/editor.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python +""" +A Dumb full-screen editor. + +This example program makes use of many context manager methods: +:meth:`~.Terminal.hidden_cursor`, :meth:`~.Terminal.raw`, +:meth:`~.Terminal.location`, :meth:`~.Terminal.fullscreen`, and +:meth:`~.Terminal.keypad`. + +Early curses work focused namely around writing screen editors, naturally +any serious editor would make liberal use of special modes. + +``Ctrl - L`` + refresh + +``Ctrl - C`` + quit + +``Ctrl - S`` + save +""" +from __future__ import division, print_function + +# std imports +import functools +import collections + +# local +from blessed import Terminal + +# python 2/3 compatibility, provide 'echo' function as an +# alias for "print without newline and flush" +try: + # pylint: disable=invalid-name + # Invalid constant name "echo" + echo = functools.partial(print, end='', flush=True) + echo(u'') +except TypeError: + # TypeError: 'flush' is an invalid keyword argument for this function + import sys + + def echo(text): + """Display ``text`` and flush output.""" + sys.stdout.write(u'{}'.format(text)) + sys.stdout.flush() + + +def input_filter(keystroke): + """ + For given keystroke, return whether it should be allowed as input. + + This somewhat requires that the interface use special application keys to perform functions, as + alphanumeric input intended for persisting could otherwise be interpreted as a command sequence. + """ + if keystroke.is_sequence: + # Namely, deny multi-byte sequences (such as '\x1b[A'), + return False + if ord(keystroke) < ord(u' '): + # or control characters (such as ^L), + return False + return True + + +def echo_yx(cursor, text): + """Move to ``cursor`` and display ``text``.""" + echo(cursor.term.move_yx(cursor.y, cursor.x) + text) + + +Cursor = collections.namedtuple('Cursor', ('y', 'x', 'term')) + + +def readline(term, width=20): + """A rudimentary readline implementation.""" + text = u'' + while True: + inp = term.inkey() + if inp.code == term.KEY_ENTER: + break + elif inp.code == term.KEY_ESCAPE or inp == chr(3): + text = None + break + elif not inp.is_sequence and len(text) < width: + text += inp + echo(inp) + elif inp.code in (term.KEY_BACKSPACE, term.KEY_DELETE): + text = text[:-1] + # https://utcc.utoronto.ca/~cks/space/blog/unix/HowUnixBackspaces + # + # "When you hit backspace, the kernel tty line discipline rubs out + # your previous character by printing (in the simple case) + # Ctrl-H, a space, and then another Ctrl-H." + echo(u'\b \b') + return text + + +def save(screen, fname): + """Save screen contents to file.""" + if not fname: + return + with open(fname, 'w') as fout: + cur_row = cur_col = 0 + for (row, col) in sorted(screen): + char = screen[(row, col)] + while row != cur_row: + cur_row += 1 + cur_col = 0 + fout.write(u'\n') + while col > cur_col: + cur_col += 1 + fout.write(u' ') + fout.write(char) + cur_col += 1 + fout.write(u'\n') + + +def redraw(term, screen, start=None, end=None): + """Redraw the screen.""" + if start is None and end is None: + echo(term.clear) + start, end = (Cursor(y=min([y for (y, x) in screen or [(0, 0)]]), + x=min([x for (y, x) in screen or [(0, 0)]]), + term=term), + Cursor(y=max([y for (y, x) in screen or [(0, 0)]]), + x=max([x for (y, x) in screen or [(0, 0)]]), + term=term)) + lastcol, lastrow = -1, -1 + for row, col in sorted(screen): + if start.y <= row <= end.y and start.x <= col <= end.x: + if col >= term.width or row >= term.height: + # out of bounds + continue + if not (row == lastrow and col == lastcol + 1): + # use cursor movement + echo_yx(Cursor(row, col, term), screen[row, col]) + else: + # just write past last one + echo(screen[row, col]) + + +def main(): + """Program entry point.""" + def above(csr, offset): + return Cursor(y=max(0, csr.y - offset), + x=csr.x, + term=csr.term) + + def below(csr, offset): + return Cursor(y=min(csr.term.height - 1, csr.y + offset), + x=csr.x, + term=csr.term) + + def right_of(csr, offset): + return Cursor(y=csr.y, + x=min(csr.term.width - 1, csr.x + offset), + term=csr.term) + + def left_of(csr, offset): + return Cursor(y=csr.y, + x=max(0, csr.x - offset), + term=csr.term) + + def home(csr): + return Cursor(y=csr.y, + x=0, + term=csr.term) + + def end(csr): + return Cursor(y=csr.y, + x=csr.term.width - 1, + term=csr.term) + + def bottom(csr): + return Cursor(y=csr.term.height - 1, + x=csr.x, + term=csr.term) + + def center(csr): + return Cursor(csr.term.height // 2, + csr.term.width // 2, + csr.term) + + def lookup_move(inp_code, csr): + return { + # arrows, including angled directionals + csr.term.KEY_END: below(left_of(csr, 1), 1), + csr.term.KEY_KP_1: below(left_of(csr, 1), 1), + + csr.term.KEY_DOWN: below(csr, 1), + csr.term.KEY_KP_2: below(csr, 1), + + csr.term.KEY_PGDOWN: below(right_of(csr, 1), 1), + csr.term.KEY_LR: below(right_of(csr, 1), 1), + csr.term.KEY_KP_3: below(right_of(csr, 1), 1), + + csr.term.KEY_LEFT: left_of(csr, 1), + csr.term.KEY_KP_4: left_of(csr, 1), + + csr.term.KEY_CENTER: center(csr), + csr.term.KEY_KP_5: center(csr), + + csr.term.KEY_RIGHT: right_of(csr, 1), + csr.term.KEY_KP_6: right_of(csr, 1), + + csr.term.KEY_HOME: above(left_of(csr, 1), 1), + csr.term.KEY_KP_7: above(left_of(csr, 1), 1), + + csr.term.KEY_UP: above(csr, 1), + csr.term.KEY_KP_8: above(csr, 1), + + csr.term.KEY_PGUP: above(right_of(csr, 1), 1), + csr.term.KEY_KP_9: above(right_of(csr, 1), 1), + + # shift + arrows + csr.term.KEY_SLEFT: left_of(csr, 10), + csr.term.KEY_SRIGHT: right_of(csr, 10), + csr.term.KEY_SDOWN: below(csr, 10), + csr.term.KEY_SUP: above(csr, 10), + + # carriage return + csr.term.KEY_ENTER: home(below(csr, 1)), + }.get(inp_code, csr) + + term = Terminal() + csr = Cursor(0, 0, term) + screen = {} + with term.hidden_cursor(), \ + term.raw(), \ + term.location(), \ + term.fullscreen(), \ + term.keypad(): + inp = None + while True: + echo_yx(csr, term.reverse(screen.get((csr.y, csr.x), u' '))) + inp = term.inkey() + + if inp == chr(3): + # ^c exits + break + + elif inp == chr(19): + # ^s saves + echo_yx(home(bottom(csr)), + term.ljust(term.bold_white(u'Filename: '))) + echo_yx(right_of(home(bottom(csr)), len(u'Filename: ')), u'') + save(screen, readline(term)) + echo_yx(home(bottom(csr)), term.clear_eol) + redraw(term=term, screen=screen, + start=home(bottom(csr)), + end=end(bottom(csr))) + continue + + elif inp == chr(12): + # ^l refreshes + redraw(term=term, screen=screen) + + else: + n_csr = lookup_move(inp.code, csr) + + if n_csr != csr: + # erase old cursor, + echo_yx(csr, screen.get((csr.y, csr.x), u' ')) + csr = n_csr + + elif input_filter(inp): + echo_yx(csr, inp) + screen[(csr.y, csr.x)] = inp.__str__() + n_csr = right_of(csr, 1) + if n_csr == csr: + # wrap around margin + n_csr = home(below(csr, 1)) + csr = n_csr + + +if __name__ == '__main__': + main() diff --git a/bin/keymatrix.py b/bin/keymatrix.py new file mode 100755 index 00000000..18753f3e --- /dev/null +++ b/bin/keymatrix.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python +""" +A simple "game": hit all application keys to win. + +Display all known key capabilities that may match the terminal. As each key is pressed on input, it +is lit up and points are scored. +""" +from __future__ import division, print_function + +# std imports +import sys +import functools + +# local +from blessed import Terminal + +# python 2/3 compatibility, provide 'echo' function as an +# alias for "print without newline and flush" +try: + # pylint: disable=invalid-name + # Invalid constant name "echo" + echo = functools.partial(print, end='', flush=True) + echo(u'') +except TypeError: + # TypeError: 'flush' is an invalid keyword argument for this function + + def echo(text): + """Display ``text`` and flush output.""" + sys.stdout.write(u'{}'.format(text)) + sys.stdout.flush() + + +def refresh(term, board, level, score, inps): + """Refresh the game screen.""" + echo(term.home + term.clear) + level_color = level % 7 + if level_color == 0: + level_color = 4 + bottom = 0 + for keycode, attr in board.items(): + echo(u''.join(( + term.move_yx(attr['row'], attr['column']), + term.color(level_color), + (term.reverse if attr['hit'] else term.bold), + keycode, + term.normal))) + bottom = max(bottom, attr['row']) + echo(term.move_yx(term.height, 0) + 'level: %s score: %s' % (level, score,)) + if bottom >= (term.height - 5): + sys.stderr.write( + ('\n' * (term.height // 2)) + + term.center(term.red_underline('cheater!')) + '\n') + sys.stderr.write( + term.center("(use a larger screen)") + + ('\n' * (term.height // 2))) + sys.exit(1) + echo(term.move_yx(bottom + 1, 0)) + echo('Press ^C to exit.') + for row, inp in enumerate(inps[(term.height - (bottom + 3)) * -1:], 1): + echo(term.move_yx(bottom + row + 1, 0)) + echo('{0!r}, {1}, {2}'.format( + inp.__str__() if inp.is_sequence else inp, + inp.code, + inp.name)) + echo(term.clear_eol) + + +def build_gameboard(term): + """Build the gameboard layout.""" + column, row = 0, 0 + board = dict() + spacing = 2 + for keycode in sorted(term._keycodes.values()): + if (keycode.startswith('KEY_F') + and keycode[-1].isdigit() + and int(keycode[len('KEY_F'):]) > 24): + continue + if column + len(keycode) + (spacing * 2) >= term.width: + column = 0 + row += 1 + board[keycode] = {'column': column, + 'row': row, + 'hit': 0, + } + column += len(keycode) + (spacing * 2) + return board + + +def add_score(score, pts, level): + """Add points to score, determine and return new score and level.""" + lvl_multiplier = 10 + score += pts + if (score % (pts * lvl_multiplier)) == 0: + level += 1 + return score, level + + +def main(): + """Program entry point.""" + term = Terminal() + score = level = hit_highbit = hit_unicode = 0 + dirty = True + + gameboard = build_gameboard(term) + inps = [] + + with term.raw(), term.keypad(), term.location(): + inp = term.inkey(timeout=0) + while inp != chr(3): + if dirty: + refresh(term, gameboard, level, score, inps) + dirty = False + inp = term.inkey(timeout=5.0) + dirty = True + if (inp.is_sequence and + inp.name in gameboard and + gameboard[inp.name]['hit'] == 0): + gameboard[inp.name]['hit'] = 1 + score, level = add_score(score, 100, level) + elif inp and not inp.is_sequence and 128 <= ord(inp) <= 255: + hit_highbit += 1 + if hit_highbit < 5: + score, level = add_score(score, 100, level) + elif inp and not inp.is_sequence and ord(inp) > 256: + hit_unicode += 1 + if hit_unicode < 5: + score, level = add_score(score, 100, level) + inps.append(inp) + + with term.cbreak(): + echo(term.move_yx(term.height)) + echo( + u'{term.clear_eol}Your final score was {score} ' + u'at level {level}{term.clear_eol}\n' + u'{term.clear_eol}\n' + u'{term.clear_eol}You hit {hit_highbit} ' + u' 8-bit characters\n{term.clear_eol}\n' + u'{term.clear_eol}You hit {hit_unicode} ' + u' unicode characters.\n{term.clear_eol}\n' + u'{term.clear_eol}press any key\n'.format( + term=term, + score=score, level=level, + hit_highbit=hit_highbit, + hit_unicode=hit_unicode) + ) + term.inkey() + + +if __name__ == '__main__': + main() diff --git a/bin/on_resize.py b/bin/on_resize.py new file mode 100755 index 00000000..8749139c --- /dev/null +++ b/bin/on_resize.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +""" +Example application for the 'blessed' Terminal library for python. + +Window size changes are caught by the 'on_resize' function using a traditional signal handler. +Meanwhile, blocking keyboard input is displayed to stdout. If a resize event is discovered, an empty +string is returned by term.inkey(). +""" +from __future__ import print_function + +# std imports +import signal + +# local +from blessed import Terminal + + +def main(): + """Program entry point.""" + term = Terminal() + + def on_resize(*args): + # pylint: disable=unused-argument + # Unused argument 'args' + + # Its generally not a good idea to put blocking functions (such as + # print) within a signal handler -- if another SIGWINCH is received + # while this function blocks, an error will occur. + + # In most programs, you'll want to set some kind of 'dirty' flag, + # perhaps by a Semaphore like threading.Event or (thanks to the GIL) + # a simple global variable will suffice. + print('height={t.height}, width={t.width}\r'.format(t=term)) + + signal.signal(signal.SIGWINCH, on_resize) + + # display initial size + on_resize(term) + + with term.cbreak(): + print("press 'X' to stop.") + inp = None + while inp != 'X': + inp = term.inkey() + print(repr(inp)) + + +if __name__ == '__main__': + main() diff --git a/bin/plasma.py b/bin/plasma.py new file mode 100755 index 00000000..5adf0b51 --- /dev/null +++ b/bin/plasma.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python +# std imports +import sys +import math +import time +import timeit +import colorsys +import contextlib + +# local +import blessed + + +def scale_255(val): return int(round(val * 255)) + + +def rgb_at_xy(term, x, y, t): + h, w = term.height, term.width + hue = 4.0 + ( + math.sin(x / 16.0) + + math.sin(y / 32.0) + + math.sin(math.sqrt( + ((x - w / 2.0) * (x - w / 2.0) + + (y - h / 2.0) * (y - h / 2.0)) + ) / 8.0 + t * 3) + ) + math.sin(math.sqrt((x * x + y * y)) / 8.0) + saturation = y / h + lightness = x / w + return tuple(map(scale_255, colorsys.hsv_to_rgb(hue / 8.0, saturation, lightness))) + + +def screen_plasma(term, plasma_fn, t): + result = '' + for y in range(term.height - 1): + for x in range(term.width): + result += term.on_color_rgb(*plasma_fn(term, x, y, t)) + ' ' + return result + + +@contextlib.contextmanager +def elapsed_timer(): + """Timer pattern, from https://stackoverflow.com/a/30024601.""" + start = timeit.default_timer() + + def elapser(): + return timeit.default_timer() - start + + # pylint: disable=unnecessary-lambda + yield lambda: elapser() + + +def show_please_wait(term): + txt_wait = 'please wait ...' + outp = term.move_yx(term.height - 1, 0) + term.clear_eol + term.center(txt_wait) + print(outp, end='') + sys.stdout.flush() + + +def show_paused(term): + txt_paused = 'paused' + outp = term.move_yx(term.height - 1, int(term.width / 2 - len(txt_paused) / 2)) + outp += txt_paused + print(outp, end='') + sys.stdout.flush() + + +def next_algo(algo, forward): + algos = tuple(sorted(blessed.color.COLOR_DISTANCE_ALGORITHMS)) + next_index = algos.index(algo) + (1 if forward else -1) + if next_index == len(algos): + next_index = 0 + return algos[next_index] + + +def next_color(color, forward): + colorspaces = (4, 8, 16, 256, 1 << 24) + next_index = colorspaces.index(color) + (1 if forward else -1) + if next_index == len(colorspaces): + next_index = 0 + return colorspaces[next_index] + + +def status(term, elapsed): + left_txt = (f'{term.number_of_colors} colors - ' + f'{term.color_distance_algorithm} - ?: help ') + right_txt = f'fps: {1 / elapsed:2.2f}' + return ('\n' + term.normal + + term.white_on_blue + term.clear_eol + left_txt + + term.rjust(right_txt, term.width - len(left_txt))) + + +def main(term): + with term.cbreak(), term.hidden_cursor(), term.fullscreen(): + pause, dirty = False, True + t = time.time() + while True: + if dirty or not pause: + if not pause: + t = time.time() + with elapsed_timer() as elapsed: + outp = term.home + screen_plasma(term, rgb_at_xy, t) + outp += status(term, elapsed()) + print(outp, end='') + sys.stdout.flush() + dirty = False + if pause: + show_paused(term) + + inp = term.inkey(timeout=0.01 if not pause else None) + if inp == '?': + assert False, "don't panic" + if inp == '\x0c': + dirty = True + if inp in ('[', ']'): + term.color_distance_algorithm = next_algo( + term.color_distance_algorithm, inp == '[') + show_please_wait(term) + dirty = True + if inp == ' ': + pause = not pause + if inp.code in (term.KEY_TAB, term.KEY_BTAB): + term.number_of_colors = next_color( + term.number_of_colors, inp.code == term.KEY_TAB) + show_please_wait(term) + dirty = True + + +if __name__ == "__main__": + exit(main(blessed.Terminal())) diff --git a/bin/progress_bar.py b/bin/progress_bar.py new file mode 100755 index 00000000..e8b0a8d8 --- /dev/null +++ b/bin/progress_bar.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +""" +Example application for the 'blessed' Terminal library for python. + +This isn't a real progress bar, just a sample "animated prompt" of sorts that +demonstrates the separate move_x() and move_yx() capabilities, made mainly to +test the `hpa' compatibility for 'screen' terminal type which fails to provide +one, but blessed recognizes that it actually does, and provides a proxy. +""" +from __future__ import print_function + +# std imports +import sys + +# local +from blessed import Terminal + + +def main(): + """Program entry point.""" + term = Terminal() + assert term.hpa(1) != u'', ( + 'Terminal does not support hpa (Horizontal position absolute)') + + col, offset = 1, 1 + with term.cbreak(): + inp = None + print("press 'X' to stop.") + sys.stderr.write(term.move_yx(term.height, 0) + u'[') + sys.stderr.write(term.move_x(term.width - 1) + u']' + term.move_x(1)) + while inp != 'X': + if col >= (term.width - 2): + offset = -1 + elif col <= 1: + offset = 1 + sys.stderr.write(term.move_x(col)) + if offset == -1: + sys.stderr.write(u'.') + else: + sys.stderr.write(u'=') + col += offset + sys.stderr.write(term.move_x(col)) + sys.stderr.write(u'|\b') + sys.stderr.flush() + inp = term.inkey(0.04) + print() + + +if __name__ == '__main__': + main() diff --git a/bin/resize.py b/bin/resize.py new file mode 100755 index 00000000..8a3a98a8 --- /dev/null +++ b/bin/resize.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +""" +Determines and prints COLUMNS and LINES of the attached window width. + +A strange problem: programs that perform screen addressing incorrectly +determine the screen margins. Calls to reset(1) do not resolve the +issue. + +This may often happen because the transport is incapable of communicating +the terminal size, such as over a serial line. This demonstration program +determines true screen dimensions and produces output suitable for evaluation +by a bourne-like shell:: + + $ eval `./resize.py` + +The following remote login protocols communicate window size: + + - ssh: notifies on dedicated session channel, see for example, + ``paramiko.ServerInterface.check_channel_window_change_request``. + + - telnet: sends window size through NAWS (negotiate about window + size, RFC 1073), see for example, + ``telnetlib3.TelnetServer.naws_receive``. + + - rlogin: protocol sends only initial window size, and does not notify + about size changes. + +This is a simplified version of `resize.c +`_ provided by the +xterm package. +""" +from __future__ import print_function + +# std imports +import sys +import collections + +# local +from blessed import Terminal + + +def main(): + """Program entry point.""" + # pylint: disable=invalid-name + # Invalid variable name "Position" + Position = collections.namedtuple('Position', ('row', 'column')) + + # particularly strange, we use sys.stderr as our output stream device, + # this 'stream' file descriptor is only used for side effects, of which + # this application uses two: the term.location() has an implied write, + # as does get_position(). + # + # the reason we chose stderr is to ensure that the terminal emulator + # receives our bytes even when this program is wrapped by shell eval + # `resize.py`; backticks gather stdout but not stderr in this case. + term = Terminal(stream=sys.stderr) + + # Move the cursor to the farthest lower-right hand corner that is + # reasonable. Due to word size limitations in older protocols, 999,999 + # is our most reasonable and portable edge boundary. Telnet NAWS is just + # two unsigned shorts: ('!HH' in python struct module format). + with term.location(999, 999): + + # We're not likely at (999, 999), but a well behaved terminal emulator + # will do its best to accommodate our request, positioning the cursor + # to the farthest lower-right corner. By requesting the current + # position, we may negotiate about the window size directly with the + # terminal emulator connected at the distant end. + pos = Position(*term.get_location(timeout=5.0)) + + if -1 not in pos: + # true size was determined + lines, columns = pos.row, pos.column + + else: + # size could not be determined. Oh well, the built-in blessed + # properties will use termios if available, falling back to + # existing environment values if it has to. + lines, columns = term.height, term.width + + print("COLUMNS={columns};\nLINES={lines};\nexport COLUMNS LINES;" + .format(columns=columns, lines=lines)) + + +if __name__ == '__main__': + exit(main()) diff --git a/bin/strip.py b/bin/strip.py new file mode 100755 index 00000000..1229ab73 --- /dev/null +++ b/bin/strip.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Example scrip that strips input of terminal sequences.""" +# std imports +import sys + +# local +import blessed + + +def main(): + """Program entry point.""" + term = blessed.Terminal() + for line in sys.stdin: + print(term.strip_seqs(line)) + + +if __name__ == '__main__': + main() diff --git a/bin/tprint.py b/bin/tprint.py new file mode 100755 index 00000000..3bffde6d --- /dev/null +++ b/bin/tprint.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +""" +A simple cmd-line tool for displaying FormattingString capabilities. + +For example:: + + $ python tprint.py bold A rather bold statement. +""" +# std +from __future__ import print_function + +# std imports +import argparse + +# local +from blessed import Terminal + + +def parse_args(): + """Parse sys.argv, returning dict suitable for main().""" + parser = argparse.ArgumentParser( + description='displays argument as specified style') + + parser.add_argument('style', type=str, help='style formatter') + parser.add_argument('text', type=str, nargs='+') + + return dict(parser.parse_args()._get_kwargs()) + + +def main(style, text): + """Program entry point.""" + term = Terminal() + style = getattr(term, style) + print(style(' '.join(text))) + + +if __name__ == '__main__': + exit(main(**parse_args())) diff --git a/bin/worms.py b/bin/worms.py new file mode 100755 index 00000000..233a29ac --- /dev/null +++ b/bin/worms.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python +""" +Example application for the 'blessed' Terminal library for python. + +It is also an experiment in functional programming. +""" + +from __future__ import division, print_function + +# std imports +from random import randrange +from functools import partial +from collections import namedtuple + +# local +from blessed import Terminal + +# python 2/3 compatibility, provide 'echo' function as an +# alias for "print without newline and flush" +try: + # pylint: disable=invalid-name + # Invalid constant name "echo" + echo = partial(print, end='', flush=True) + echo(u'') +except TypeError: + # TypeError: 'flush' is an invalid keyword argument for this function + import sys + + def echo(text): + """Python 2 version of print(end='', flush=True).""" + sys.stdout.write(u'{0}'.format(text)) + sys.stdout.flush() + +# a worm is a list of (y, x) segments Locations +Location = namedtuple('Point', ('y', 'x',)) + +# a nibble is a (x,y) Location and value +Nibble = namedtuple('Nibble', ('location', 'value')) + +# A direction is a bearing, fe. +# y=0, x=-1 = move right +# y=1, x=0 = move down +Direction = namedtuple('Direction', ('y', 'x',)) + +# these functions return a new Location instance, given +# the direction indicated by their name. +LEFT = (0, -1) +RIGHT = (0, 1) +UP = (-1, 0) +DOWN = (1, 0) + + +def left_of(segment, term): + """Return Location left-of given segment.""" + # pylint: disable=unused-argument + # Unused argument 'term' + return Location(y=segment.y, + x=max(0, segment.x - 1)) + + +def right_of(segment, term): + """Return Location right-of given segment.""" + return Location(y=segment.y, + x=min(term.width - 1, segment.x + 1)) + + +def above(segment, term): + """Return Location above given segment.""" + # pylint: disable=unused-argument + # Unused argument 'term' + return Location( + y=max(0, segment.y - 1), + x=segment.x) + + +def below(segment, term): + """Return Location below given segment.""" + return Location( + y=min(term.height - 1, segment.y + 1), + x=segment.x) + + +def next_bearing(term, inp_code, bearing): + """ + Return direction function for new bearing by inp_code. + + If no inp_code matches a bearing direction, return a function for the current bearing. + """ + return { + term.KEY_LEFT: left_of, + term.KEY_RIGHT: right_of, + term.KEY_UP: above, + term.KEY_DOWN: below, + }.get(inp_code, + # direction function given the current bearing + {LEFT: left_of, + RIGHT: right_of, + UP: above, + DOWN: below}[(bearing.y, bearing.x)]) + + +def change_bearing(f_mov, segment, term): + """Return new bearing given the movement f(x).""" + return Direction( + f_mov(segment, term).y - segment.y, + f_mov(segment, term).x - segment.x) + + +def bearing_flipped(dir1, dir2): + """ + direction-flipped check. + + Return true if dir2 travels in opposite direction of dir1. + """ + return (0, 0) == (dir1.y + dir2.y, dir1.x + dir2.x) + + +def hit_any(loc, segments): + """Return True if `loc' matches any (y, x) coordinates within segments.""" + # `segments' -- a list composing a worm. + return loc in segments + + +def hit_vany(locations, segments): + """Return True if any locations are found within any segments.""" + return any(hit_any(loc, segments) + for loc in locations) + + +def hit(src, dst): + """Return True if segments are same position (hit detection).""" + return src.x == dst.x and src.y == dst.y + + +def next_wormlength(nibble, head, worm_length): + """Return new worm_length if current nibble is hit.""" + if hit(head, nibble.location): + return worm_length + nibble.value + return worm_length + + +def next_speed(nibble, head, speed, modifier): + """Return new speed if current nibble is hit.""" + if hit(head, nibble.location): + return speed * modifier + return speed + + +def head_glyph(direction): + """Return character for worm head depending on horiz/vert orientation.""" + if direction in (left_of, right_of): + return u':' + return u'"' + + +def next_nibble(term, nibble, head, worm): + """ + Provide the next nibble. + + continuously generate a random new nibble so long as the current nibble hits any location of the + worm. Otherwise, return a nibble of the same location and value as provided. + """ + loc, val = nibble.location, nibble.value + while hit_vany([head] + worm, nibble_locations(loc, val)): + loc = Location(x=randrange(1, term.width - 1), + y=randrange(1, term.height - 1)) + val = nibble.value + 1 + return Nibble(loc, val) + + +def nibble_locations(nibble_location, nibble_value): + """Return array of locations for the current "nibble".""" + # generate an array of locations for the current nibble's location + # -- a digit such as '123' may be hit at 3 different (y, x) coordinates. + return [Location(x=nibble_location.x + offset, + y=nibble_location.y) + for offset in range(0, 1 + len('{}'.format(nibble_value)) - 1)] + + +def main(): + """Program entry point.""" + # pylint: disable=too-many-locals + # Too many local variables (20/15) + term = Terminal() + worm = [Location(x=term.width // 2, y=term.height // 2)] + worm_length = 2 + bearing = Direction(*LEFT) + direction = left_of + nibble = Nibble(location=worm[0], value=0) + color_nibble = term.black_on_green + color_worm = term.yellow_reverse + color_head = term.red_reverse + color_bg = term.on_blue + echo(term.move_yx(1, 1)) + echo(color_bg(term.clear)) + + # speed is actually a measure of time; the shorter, the faster. + speed = 0.1 + modifier = 0.93 + inp = None + + echo(term.move_yx(term.height, 0)) + with term.hidden_cursor(), term.cbreak(), term.location(): + while inp not in (u'q', u'Q'): + + # delete the tail of the worm at worm_length + if len(worm) > worm_length: + echo(term.move_yx(*worm.pop(0))) + echo(color_bg(u' ')) + + # compute head location + head = worm.pop() + + # check for hit against self; hitting a wall results in the (y, x) + # location being clipped, -- and death by hitting self (not wall). + if hit_any(head, worm): + break + + # get the next nibble, which may be equal to ours unless this + # nibble has been struck by any portion of our worm body. + n_nibble = next_nibble(term, nibble, head, worm) + + # get the next worm_length and speed, unless unchanged. + worm_length = next_wormlength(nibble, head, worm_length) + speed = next_speed(nibble, head, speed, modifier) + + if n_nibble != nibble: + # erase the old one, careful to redraw the nibble contents + # with a worm color for those portions that overlay. + for (yloc, xloc) in nibble_locations(*nibble): + echo(u''.join(( + term.move_yx(yloc, xloc), + (color_worm if (yloc, xloc) == head + else color_bg)(u' '), + term.normal))) + # and draw the new, + echo(term.move_yx(*n_nibble.location) + ( + color_nibble('{}'.format(n_nibble.value)))) + + # display new worm head + echo(term.move_yx(*head) + color_head(head_glyph(direction))) + + # and its old head (now, a body piece) + if worm: + echo(term.move_yx(*(worm[-1]))) + echo(color_worm(u' ')) + echo(term.move_yx(*head)) + + # wait for keyboard input, which may indicate + # a new direction (up/down/left/right) + inp = term.inkey(timeout=speed) + + # discover new direction, given keyboard input and/or bearing. + nxt_direction = next_bearing(term, inp.code, bearing) + + # discover new bearing, given new direction compared to prev + nxt_bearing = change_bearing(nxt_direction, head, term) + + # disallow new bearing/direction when flipped: running into + # oneself, for example traveling left while traveling right. + if not bearing_flipped(bearing, nxt_bearing): + direction = nxt_direction + bearing = nxt_bearing + + # append the prior `head' onto the worm, then + # a new `head' for the given direction. + worm.extend([head, direction(head, term)]) + + # re-assign new nibble, + nibble = n_nibble + + echo(term.normal) + score = (worm_length - 1) * 100 + echo(u''.join((term.move_yx(term.height - 1, 1), term.normal))) + echo(u''.join((u'\r\n', u'score: {}'.format(score), u'\r\n'))) + + +if __name__ == '__main__': + main() diff --git a/bin/x11_colorpicker.py b/bin/x11_colorpicker.py new file mode 100644 index 00000000..ccb24bf5 --- /dev/null +++ b/bin/x11_colorpicker.py @@ -0,0 +1,97 @@ +# std imports +import colorsys + +# local +import blessed + + +def sort_colors(): + colors = {} + for color_name, rgb_color in blessed.colorspace.X11_COLORNAMES_TO_RGB.items(): + if rgb_color in colors: + colors[rgb_color].append(color_name) + else: + colors[rgb_color] = [color_name] + + return sorted(colors.items(), + key=lambda rgb: colorsys.rgb_to_hsv(*rgb[0]), + reverse=True) + + +HSV_SORTED_COLORS = sort_colors() + + +def render(term, idx): + rgb_color, color_names = HSV_SORTED_COLORS[idx] + result = term.home + term.normal + ''.join( + getattr(term, HSV_SORTED_COLORS[i][1][0]) + '◼' + for i in range(len(HSV_SORTED_COLORS)) + ) + result += term.clear_eos + '\n' + result += getattr(term, 'on_' + color_names[0]) + term.clear_eos + '\n' + result += term.normal + \ + term.center(f'{" | ".join(color_names)}: {rgb_color}') + '\n' + result += term.normal + term.center( + f'{term.number_of_colors} colors - ' + f'{term.color_distance_algorithm}') + + result += term.move_yx(idx // term.width, idx % term.width) + result += term.on_color_rgb(*rgb_color)(' \b') + return result + + +def next_algo(algo, forward): + algos = tuple(sorted(blessed.color.COLOR_DISTANCE_ALGORITHMS)) + next_index = algos.index(algo) + (1 if forward else -1) + if next_index == len(algos): + next_index = 0 + return algos[next_index] + + +def next_color(color, forward): + colorspaces = (4, 8, 16, 256, 1 << 24) + next_index = colorspaces.index(color) + (1 if forward else -1) + if next_index == len(colorspaces): + next_index = 0 + return colorspaces[next_index] + + +def main(): + term = blessed.Terminal() + with term.cbreak(), term.hidden_cursor(), term.fullscreen(): + idx = len(HSV_SORTED_COLORS) // 2 + dirty = True + while True: + if dirty: + outp = render(term, idx) + print(outp, end='', flush=True) + with term.hidden_cursor(): + inp = term.inkey() + dirty = True + if inp.code == term.KEY_LEFT or inp == 'h': + idx -= 1 + elif inp.code == term.KEY_DOWN or inp == 'j': + idx += term.width + elif inp.code == term.KEY_UP or inp == 'k': + idx -= term.width + elif inp.code == term.KEY_RIGHT or inp == 'l': + idx += 1 + elif inp.code in (term.KEY_TAB, term.KEY_BTAB): + term.number_of_colors = next_color( + term.number_of_colors, inp.code == term.KEY_TAB) + elif inp in ('[', ']'): + term.color_distance_algorithm = next_algo( + term.color_distance_algorithm, inp == '[') + elif inp == '\x0c': + pass + else: + dirty = False + + while idx < 0: + idx += len(HSV_SORTED_COLORS) + while idx >= len(HSV_SORTED_COLORS): + idx -= len(HSV_SORTED_COLORS) + + +if __name__ == '__main__': + main() diff --git a/blessed/__init__.py b/blessed/__init__.py new file mode 100644 index 00000000..ad307dfa --- /dev/null +++ b/blessed/__init__.py @@ -0,0 +1,21 @@ +""" +A thin, practical wrapper around terminal capabilities in Python. + +http://pypi.python.org/pypi/blessed +""" +# std imports +import platform as _platform + +if _platform.system() == 'Windows': + from blessed.win_terminal import Terminal +else: + from blessed.terminal import Terminal + +if ('3', '0', '0') <= _platform.python_version_tuple() < ('3', '2', '2+'): + # Good till 3.2.10 + # Python 3.x < 3.2.3 has a bug in which tparm() erroneously takes a string. + raise ImportError('Blessed needs Python 3.2.3 or greater for Python 3 ' + 'support due to http://bugs.python.org/issue10570.') + +__all__ = ('Terminal',) +__version__ = '1.17.0' diff --git a/blessed/_capabilities.py b/blessed/_capabilities.py new file mode 100644 index 00000000..13ce9a63 --- /dev/null +++ b/blessed/_capabilities.py @@ -0,0 +1,173 @@ +"""Terminal capability builder patterns.""" +# std imports +import re + +try: + from collections import OrderedDict +except ImportError: + # python 2.6 requires 3rd party library (backport) + # + # pylint: disable=import-error + # Unable to import 'ordereddict' + from ordereddict import OrderedDict + +__all__ = ( + 'CAPABILITY_DATABASE', + 'CAPABILITIES_RAW_MIXIN', + 'CAPABILITIES_ADDITIVES', + 'CAPABILITIES_CAUSE_MOVEMENT', +) + +CAPABILITY_DATABASE = OrderedDict(( + ('bell', ('bel', {})), + ('carriage_return', ('cr', {})), + ('change_scroll_region', ('csr', {'nparams': 2})), + ('clear_all_tabs', ('tbc', {})), + ('clear_screen', ('clear', {})), + ('clr_bol', ('el1', {})), + ('clr_eol', ('el', {})), + ('clr_eos', ('clear_eos', {})), + ('column_address', ('hpa', {'nparams': 1})), + ('cursor_address', ('cup', {'nparams': 2, 'match_grouped': True})), + ('cursor_down', ('cud1', {})), + ('cursor_home', ('home', {})), + ('cursor_invisible', ('civis', {})), + ('cursor_left', ('cub1', {})), + ('cursor_normal', ('cnorm', {})), + ('cursor_report', ('u6', {'nparams': 2, 'match_grouped': True})), + ('cursor_right', ('cuf1', {})), + ('cursor_up', ('cuu1', {})), + ('cursor_visible', ('cvvis', {})), + ('delete_character', ('dch1', {})), + ('delete_line', ('dl1', {})), + ('enter_blink_mode', ('blink', {})), + ('enter_bold_mode', ('bold', {})), + ('enter_dim_mode', ('dim', {})), + ('enter_fullscreen', ('smcup', {})), + ('enter_standout_mode', ('standout', {})), + ('enter_superscript_mode', ('superscript', {})), + ('enter_susimpleript_mode', ('susimpleript', {})), + ('enter_underline_mode', ('underline', {})), + ('erase_chars', ('ech', {'nparams': 1})), + ('exit_alt_charset_mode', ('rmacs', {})), + ('exit_am_mode', ('rmam', {})), + ('exit_attribute_mode', ('sgr0', {})), + ('exit_ca_mode', ('rmcup', {})), + ('exit_fullscreen', ('rmcup', {})), + ('exit_insert_mode', ('rmir', {})), + ('exit_standout_mode', ('rmso', {})), + ('exit_underline_mode', ('rmul', {})), + ('flash_hook', ('hook', {})), + ('flash_screen', ('flash', {})), + ('insert_line', ('il1', {})), + ('keypad_local', ('rmkx', {})), + ('keypad_xmit', ('smkx', {})), + ('meta_off', ('rmm', {})), + ('meta_on', ('smm', {})), + ('orig_pair', ('op', {})), + ('parm_down_cursor', ('cud', {'nparams': 1})), + ('parm_left_cursor', ('cub', {'nparams': 1, 'match_grouped': True})), + ('parm_dch', ('dch', {'nparams': 1})), + ('parm_delete_line', ('dl', {'nparams': 1})), + ('parm_ich', ('ich', {'nparams': 1})), + ('parm_index', ('indn', {'nparams': 1})), + ('parm_insert_line', ('il', {'nparams': 1})), + ('parm_right_cursor', ('cuf', {'nparams': 1, 'match_grouped': True})), + ('parm_rindex', ('rin', {'nparams': 1})), + ('parm_up_cursor', ('cuu', {'nparams': 1})), + ('print_screen', ('mc0', {})), + ('prtr_off', ('mc4', {})), + ('prtr_on', ('mc5', {})), + ('reset_1string', ('r1', {})), + ('reset_2string', ('r2', {})), + ('reset_3string', ('r3', {})), + ('restore_cursor', ('rc', {})), + ('row_address', ('vpa', {'nparams': 1})), + ('save_cursor', ('sc', {})), + ('scroll_forward', ('ind', {})), + ('scroll_reverse', ('rev', {})), + ('set0_des_seq', ('s0ds', {})), + ('set1_des_seq', ('s1ds', {})), + ('set2_des_seq', ('s2ds', {})), + ('set3_des_seq', ('s3ds', {})), + # this 'color' is deceiving, but often matching, and a better match + # than set_a_attributes1 or set_a_foreground. + ('color', ('_foreground_color', {'nparams': 1, 'match_any': True, + 'numeric': 1})), + ('set_a_foreground', ('color', {'nparams': 1, 'match_any': True, + 'numeric': 1})), + ('set_a_background', ('on_color', {'nparams': 1, 'match_any': True, + 'numeric': 1})), + ('set_tab', ('hts', {})), + ('tab', ('ht', {})), + ('italic', ('sitm', {})), + ('no_italic', ('sitm', {})), +)) + +CAPABILITIES_RAW_MIXIN = { + 'bell': re.escape('\a'), + 'carriage_return': re.escape('\r'), + 'cursor_left': re.escape('\b'), + 'cursor_report': re.escape('\x1b') + r'\[(\d+)\;(\d+)R', + 'cursor_right': re.escape('\x1b') + r'\[C', + 'exit_attribute_mode': re.escape('\x1b') + r'\[m', + 'parm_left_cursor': re.escape('\x1b') + r'\[(\d+)D', + 'parm_right_cursor': re.escape('\x1b') + r'\[(\d+)C', + 'restore_cursor': re.escape(r'\x1b\[u'), + 'save_cursor': re.escape(r'\x1b\[s'), + 'scroll_forward': re.escape('\n'), + 'set0_des_seq': re.escape('\x1b(B'), + 'tab': re.escape('\t'), + # one could get carried away, such as by adding '\x1b#8' (dec tube + # alignment test) by reversing basic vt52, ansi, and xterm sequence + # parsers. There is plans to do just that for ANSI.SYS support. +} + + +CAPABILITIES_ADDITIVES = { + 'color256': ('color', re.escape('\x1b') + r'\[38;5;\d+m'), + 'shift_in': ('', re.escape('\x0f')), + 'shift_out': ('', re.escape('\x0e')), + # sgr(...) outputs strangely, use the basic ANSI/EMCA-48 codes here. + 'set_a_attributes1': ( + 'sgr', re.escape('\x1b') + r'\[\d+m'), + 'set_a_attributes2': ( + 'sgr', re.escape('\x1b') + r'\[\d+\;\d+m'), + 'set_a_attributes3': ( + 'sgr', re.escape('\x1b') + r'\[\d+\;\d+\;\d+m'), + 'set_a_attributes4': ( + 'sgr', re.escape('\x1b') + r'\[\d+\;\d+\;\d+\;\d+m'), + # this helps where xterm's sgr0 includes set0_des_seq, we'd + # rather like to also match this immediate substring. + 'sgr0': ('sgr0', re.escape('\x1b') + r'\[m'), + 'backspace': ('', re.escape('\b')), + 'ascii_tab': ('', re.escape('\t')), + 'clr_eol': ('', re.escape('\x1b[K')), + 'clr_eol0': ('', re.escape('\x1b[0K')), + 'clr_bol': ('', re.escape('\x1b[1K')), + 'clr_eosK': ('', re.escape('\x1b[2K')), +} + +CAPABILITIES_CAUSE_MOVEMENT = ( + 'ascii_tab', + 'backspace', + 'carriage_return', + 'clear_screen', + 'column_address', + 'cursor_address', + 'cursor_down', + 'cursor_home', + 'cursor_left', + 'cursor_right', + 'cursor_up', + 'enter_fullscreen', + 'exit_fullscreen', + 'parm_down_cursor', + 'parm_left_cursor', + 'parm_right_cursor', + 'parm_up_cursor', + 'restore_cursor', + 'row_address', + 'scroll_forward', + 'tab', +) diff --git a/blessed/color.py b/blessed/color.py new file mode 100644 index 00000000..9a42be4f --- /dev/null +++ b/blessed/color.py @@ -0,0 +1,261 @@ +# encoding: utf-8 +""" +Sub-module providing color functions. + +References, +- https://en.wikipedia.org/wiki/Color_difference +- http://www.easyrgb.com/en/math.php +- Measuring Colour by R.W.G. Hunt and M.R. Pointer +""" + +# std imports +from math import cos, exp, sin, sqrt, atan2 + +try: + from functools import lru_cache +except ImportError: + from backports.functools_lru_cache import lru_cache + + +def rgb_to_xyz(red, green, blue): + """ + Convert standard RGB color to XYZ color. + + :arg red: RGB value of Red. + :arg green: RGB value of Green. + :arg blue: RGB value of Blue. + :returns: Tuple (X, Y, Z) representing XYZ color + :rtype: tuple + + D65/2° standard illuminant + """ + rgb = [] + for val in red, green, blue: + val /= 255 + if val > 0.04045: + val = pow((val + 0.055) / 1.055, 2.4) + else: + val /= 12.92 + val *= 100 + rgb.append(val) + + red, green, blue = rgb # pylint: disable=unbalanced-tuple-unpacking + x_val = red * 0.4124 + green * 0.3576 + blue * 0.1805 + y_val = red * 0.2126 + green * 0.7152 + blue * 0.0722 + z_val = red * 0.0193 + green * 0.1192 + blue * 0.9505 + + return x_val, y_val, z_val + + +def xyz_to_lab(x_val, y_val, z_val): + """ + Convert XYZ color to CIE-Lab color. + + :arg x_val: XYZ value of X. + :arg y_val: XYZ value of Y. + :arg z_val: XYZ value of Z. + :returns: Tuple (L, a, b) representing CIE-Lab color + :rtype: tuple + + D65/2° standard illuminant + """ + xyz = [] + for val, ref in (x_val, 95.047), (y_val, 100.0), (z_val, 108.883): + val /= ref + if val > 0.008856: + val = pow(val, 1 / 3) + else: + val = 7.787 * val + 16 / 116 + xyz.append(val) + + x_val, y_val, z_val = xyz # pylint: disable=unbalanced-tuple-unpacking + cie_l = 116 * y_val - 16 + cie_a = 500 * (x_val - y_val) + cie_b = 200 * (y_val - z_val) + + return cie_l, cie_a, cie_b + + +@lru_cache(maxsize=256) +def rgb_to_lab(red, green, blue): + """ + Convert RGB color to CIE-Lab color. + + :arg red: RGB value of Red. + :arg green: RGB value of Green. + :arg blue: RGB value of Blue. + :returns: Tuple (L, a, b) representing CIE-Lab color + :rtype: tuple + + D65/2° standard illuminant + """ + return xyz_to_lab(*rgb_to_xyz(red, green, blue)) + + +def dist_rgb(rgb1, rgb2): + """ + Determine distance between two rgb colors. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + This works by treating RGB colors as coordinates in three dimensional + space and finding the closest point within the configured color range + using the formula:: + + d^2 = (r2 - r1)^2 + (g2 - g1)^2 + (b2 - b1)^2 + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + return sum(pow(rgb1[idx] - rgb2[idx], 2) for idx in (0, 1, 2)) + + +def dist_rgb_weighted(rgb1, rgb2): + """ + Determine the weighted distance between two rgb colors. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + Similar to a standard distance formula, the values are weighted + to approximate human perception of color differences + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + red_mean = (rgb1[0] + rgb2[0]) / 2 + + return ((2 + red_mean / 256) * pow(rgb1[0] - rgb2[0], 2) + + 4 * pow(rgb1[1] - rgb2[1], 2) + + (2 + (255 - red_mean) / 256) * pow(rgb1[2] - rgb2[2], 2)) + + +def dist_cie76(rgb1, rgb2): + """ + Determine distance between two rgb colors using the CIE94 algorithm. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + l_1, a_1, b_1 = rgb_to_lab(*rgb1) + l_2, a_2, b_2 = rgb_to_lab(*rgb2) + return pow(l_1 - l_2, 2) + pow(a_1 - a_2, 2) + pow(b_1 - b_2, 2) + + +def dist_cie94(rgb1, rgb2): + # pylint: disable=too-many-locals + """ + Determine distance between two rgb colors using the CIE94 algorithm. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + l_1, a_1, b_1 = rgb_to_lab(*rgb1) + l_2, a_2, b_2 = rgb_to_lab(*rgb2) + + s_l = k_l = k_c = k_h = 1 + k_1 = 0.045 + k_2 = 0.015 + + delta_l = l_1 - l_2 + delta_a = a_1 - a_2 + delta_b = b_1 - b_2 + c_1 = sqrt(a_1 ** 2 + b_1 ** 2) + c_2 = sqrt(a_2 ** 2 + b_2 ** 2) + delta_c = c_1 - c_2 + delta_h = sqrt(delta_a ** 2 + delta_b ** 2 + delta_c ** 2) + s_c = 1 + k_1 * c_1 + s_h = 1 + k_2 * c_1 + + return ((delta_l / (k_l * s_l)) ** 2 + + (delta_c / (k_c * s_c)) ** 2 + + (delta_h / (k_h * s_h)) ** 2) + + +def dist_cie2000(rgb1, rgb2): + # pylint: disable=too-many-locals + """ + Determine distance between two rgb colors using the CIE2000 algorithm. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + s_l = k_l = k_c = k_h = 1 + + l_1, a_1, b_1 = rgb_to_lab(*rgb1) + l_2, a_2, b_2 = rgb_to_lab(*rgb2) + + delta_l = l_2 - l_1 + l_mean = (l_1 + l_2) / 2 + + c_1 = sqrt(a_1 ** 2 + b_1 ** 2) + c_2 = sqrt(a_2 ** 2 + b_2 ** 2) + c_mean = (c_1 + c_2) / 2 + delta_c = c_1 - c_2 + + g_x = sqrt(c_mean ** 7 / (c_mean ** 7 + 25 ** 7)) + h_1 = atan2(b_1, a_1 + (a_1 / 2) * (1 - g_x)) % 360 + h_2 = atan2(b_2, a_2 + (a_2 / 2) * (1 - g_x)) % 360 + + if 0 in (c_1, c_2): + delta_h_prime = 0 + h_mean = h_1 + h_2 + else: + delta_h_prime = h_2 - h_1 + if abs(delta_h_prime) <= 180: + h_mean = (h_1 + h_2) / 2 + else: + if h_2 <= h_1: + delta_h_prime += 360 + else: + delta_h_prime -= 360 + if h_1 + h_2 < 360: + h_mean = (h_1 + h_2 + 360) / 2 + else: + h_mean = (h_1 + h_2 - 360) / 2 + + delta_h = 2 * sqrt(c_1 * c_2) * sin(delta_h_prime / 2) + + t_x = (1 - + 0.17 * cos(h_mean - 30) + + 0.24 * cos(2 * h_mean) + + 0.32 * cos(3 * h_mean + 6) - + 0.20 * cos(4 * h_mean - 63)) + + s_l = 1 + (0.015 * (l_mean - 50) ** 2) / sqrt(20 + (l_mean - 50) ** 2) + s_c = 1 + 0.045 * c_mean + s_h = 1 + 0.015 * c_mean * t_x + r_t = -2 * g_x * sin(abs(60 * exp(-1 * abs((delta_h - 275) / 25) ** 2))) + + delta_l = delta_l / (k_l * s_l) + delta_c = delta_c / (k_c * s_c) + delta_h = delta_h / (k_h * s_h) + + return delta_l ** 2 + delta_c ** 2 + delta_h ** 2 + r_t * delta_c * delta_h + + +COLOR_DISTANCE_ALGORITHMS = {'rgb': dist_rgb, + 'rgb-weighted': dist_rgb_weighted, + 'cie76': dist_cie76, + 'cie94': dist_cie94, + 'cie2000': dist_cie2000} diff --git a/blessed/colorspace.py b/blessed/colorspace.py new file mode 100644 index 00000000..0b0e972f --- /dev/null +++ b/blessed/colorspace.py @@ -0,0 +1,969 @@ +""" +References, + +- https://github.com/freedesktop/xorg-rgb/blob/master/rgb.txt +- https://github.com/ThomasDickey/xterm-snapshots/blob/master/256colres.h +- https://github.com/ThomasDickey/xterm-snapshots/blob/master/XTerm-col.ad +- https://en.wikipedia.org/wiki/ANSI_escape_code#Colors +- https://gist.github.com/XVilka/8346728 +- https://devblogs.microsoft.com/commandline/24-bit-color-in-the-windows-console/ +- http://jdebp.eu/Softwares/nosh/guide/TerminalCapabilities.html +""" +# std imports +import collections + +__all__ = ( + 'CGA_COLORS', + 'RGBColor', + 'RGB_256TABLE', + 'X11_COLORNAMES_TO_RGB', +) + +CGA_COLORS = set( + ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')) + + +class RGBColor(collections.namedtuple("RGBColor", ["red", "green", "blue"])): + def __str__(self): + return '#{0:02x}{1:02x}{2:02x}'.format(*self) + + +#: X11 Color names to (XTerm-defined) RGB values from xorg-rgb/rgb.txt +X11_COLORNAMES_TO_RGB = { + 'aliceblue': RGBColor(240, 248, 255), + 'antiquewhite': RGBColor(250, 235, 215), + 'antiquewhite1': RGBColor(255, 239, 219), + 'antiquewhite2': RGBColor(238, 223, 204), + 'antiquewhite3': RGBColor(205, 192, 176), + 'antiquewhite4': RGBColor(139, 131, 120), + 'aqua': RGBColor(0, 255, 255), + 'aquamarine': RGBColor(127, 255, 212), + 'aquamarine1': RGBColor(127, 255, 212), + 'aquamarine2': RGBColor(118, 238, 198), + 'aquamarine3': RGBColor(102, 205, 170), + 'aquamarine4': RGBColor(69, 139, 116), + 'azure': RGBColor(240, 255, 255), + 'azure1': RGBColor(240, 255, 255), + 'azure2': RGBColor(224, 238, 238), + 'azure3': RGBColor(193, 205, 205), + 'azure4': RGBColor(131, 139, 139), + 'beige': RGBColor(245, 245, 220), + 'bisque': RGBColor(255, 228, 196), + 'bisque1': RGBColor(255, 228, 196), + 'bisque2': RGBColor(238, 213, 183), + 'bisque3': RGBColor(205, 183, 158), + 'bisque4': RGBColor(139, 125, 107), + 'black': RGBColor(0, 0, 0), + 'blanchedalmond': RGBColor(255, 235, 205), + 'blue': RGBColor(0, 0, 255), + 'blue1': RGBColor(0, 0, 255), + 'blue2': RGBColor(0, 0, 238), + 'blue3': RGBColor(0, 0, 205), + 'blue4': RGBColor(0, 0, 139), + 'blueviolet': RGBColor(138, 43, 226), + 'brown': RGBColor(165, 42, 42), + 'brown1': RGBColor(255, 64, 64), + 'brown2': RGBColor(238, 59, 59), + 'brown3': RGBColor(205, 51, 51), + 'brown4': RGBColor(139, 35, 35), + 'burlywood': RGBColor(222, 184, 135), + 'burlywood1': RGBColor(255, 211, 155), + 'burlywood2': RGBColor(238, 197, 145), + 'burlywood3': RGBColor(205, 170, 125), + 'burlywood4': RGBColor(139, 115, 85), + 'cadetblue': RGBColor(95, 158, 160), + 'cadetblue1': RGBColor(152, 245, 255), + 'cadetblue2': RGBColor(142, 229, 238), + 'cadetblue3': RGBColor(122, 197, 205), + 'cadetblue4': RGBColor(83, 134, 139), + 'chartreuse': RGBColor(127, 255, 0), + 'chartreuse1': RGBColor(127, 255, 0), + 'chartreuse2': RGBColor(118, 238, 0), + 'chartreuse3': RGBColor(102, 205, 0), + 'chartreuse4': RGBColor(69, 139, 0), + 'chocolate': RGBColor(210, 105, 30), + 'chocolate1': RGBColor(255, 127, 36), + 'chocolate2': RGBColor(238, 118, 33), + 'chocolate3': RGBColor(205, 102, 29), + 'chocolate4': RGBColor(139, 69, 19), + 'coral': RGBColor(255, 127, 80), + 'coral1': RGBColor(255, 114, 86), + 'coral2': RGBColor(238, 106, 80), + 'coral3': RGBColor(205, 91, 69), + 'coral4': RGBColor(139, 62, 47), + 'cornflowerblue': RGBColor(100, 149, 237), + 'cornsilk': RGBColor(255, 248, 220), + 'cornsilk1': RGBColor(255, 248, 220), + 'cornsilk2': RGBColor(238, 232, 205), + 'cornsilk3': RGBColor(205, 200, 177), + 'cornsilk4': RGBColor(139, 136, 120), + 'crimson': RGBColor(220, 20, 60), + 'cyan': RGBColor(0, 255, 255), + 'cyan1': RGBColor(0, 255, 255), + 'cyan2': RGBColor(0, 238, 238), + 'cyan3': RGBColor(0, 205, 205), + 'cyan4': RGBColor(0, 139, 139), + 'darkblue': RGBColor(0, 0, 139), + 'darkcyan': RGBColor(0, 139, 139), + 'darkgoldenrod': RGBColor(184, 134, 11), + 'darkgoldenrod1': RGBColor(255, 185, 15), + 'darkgoldenrod2': RGBColor(238, 173, 14), + 'darkgoldenrod3': RGBColor(205, 149, 12), + 'darkgoldenrod4': RGBColor(139, 101, 8), + 'darkgray': RGBColor(169, 169, 169), + 'darkgreen': RGBColor(0, 100, 0), + 'darkgrey': RGBColor(169, 169, 169), + 'darkkhaki': RGBColor(189, 183, 107), + 'darkmagenta': RGBColor(139, 0, 139), + 'darkolivegreen': RGBColor(85, 107, 47), + 'darkolivegreen1': RGBColor(202, 255, 112), + 'darkolivegreen2': RGBColor(188, 238, 104), + 'darkolivegreen3': RGBColor(162, 205, 90), + 'darkolivegreen4': RGBColor(110, 139, 61), + 'darkorange': RGBColor(255, 140, 0), + 'darkorange1': RGBColor(255, 127, 0), + 'darkorange2': RGBColor(238, 118, 0), + 'darkorange3': RGBColor(205, 102, 0), + 'darkorange4': RGBColor(139, 69, 0), + 'darkorchid': RGBColor(153, 50, 204), + 'darkorchid1': RGBColor(191, 62, 255), + 'darkorchid2': RGBColor(178, 58, 238), + 'darkorchid3': RGBColor(154, 50, 205), + 'darkorchid4': RGBColor(104, 34, 139), + 'darkred': RGBColor(139, 0, 0), + 'darksalmon': RGBColor(233, 150, 122), + 'darkseagreen': RGBColor(143, 188, 143), + 'darkseagreen1': RGBColor(193, 255, 193), + 'darkseagreen2': RGBColor(180, 238, 180), + 'darkseagreen3': RGBColor(155, 205, 155), + 'darkseagreen4': RGBColor(105, 139, 105), + 'darkslateblue': RGBColor(72, 61, 139), + 'darkslategray': RGBColor(47, 79, 79), + 'darkslategray1': RGBColor(151, 255, 255), + 'darkslategray2': RGBColor(141, 238, 238), + 'darkslategray3': RGBColor(121, 205, 205), + 'darkslategray4': RGBColor(82, 139, 139), + 'darkslategrey': RGBColor(47, 79, 79), + 'darkturquoise': RGBColor(0, 206, 209), + 'darkviolet': RGBColor(148, 0, 211), + 'deeppink': RGBColor(255, 20, 147), + 'deeppink1': RGBColor(255, 20, 147), + 'deeppink2': RGBColor(238, 18, 137), + 'deeppink3': RGBColor(205, 16, 118), + 'deeppink4': RGBColor(139, 10, 80), + 'deepskyblue': RGBColor(0, 191, 255), + 'deepskyblue1': RGBColor(0, 191, 255), + 'deepskyblue2': RGBColor(0, 178, 238), + 'deepskyblue3': RGBColor(0, 154, 205), + 'deepskyblue4': RGBColor(0, 104, 139), + 'dimgray': RGBColor(105, 105, 105), + 'dimgrey': RGBColor(105, 105, 105), + 'dodgerblue': RGBColor(30, 144, 255), + 'dodgerblue1': RGBColor(30, 144, 255), + 'dodgerblue2': RGBColor(28, 134, 238), + 'dodgerblue3': RGBColor(24, 116, 205), + 'dodgerblue4': RGBColor(16, 78, 139), + 'firebrick': RGBColor(178, 34, 34), + 'firebrick1': RGBColor(255, 48, 48), + 'firebrick2': RGBColor(238, 44, 44), + 'firebrick3': RGBColor(205, 38, 38), + 'firebrick4': RGBColor(139, 26, 26), + 'floralwhite': RGBColor(255, 250, 240), + 'forestgreen': RGBColor(34, 139, 34), + 'fuchsia': RGBColor(255, 0, 255), + 'gainsboro': RGBColor(220, 220, 220), + 'ghostwhite': RGBColor(248, 248, 255), + 'gold': RGBColor(255, 215, 0), + 'gold1': RGBColor(255, 215, 0), + 'gold2': RGBColor(238, 201, 0), + 'gold3': RGBColor(205, 173, 0), + 'gold4': RGBColor(139, 117, 0), + 'goldenrod': RGBColor(218, 165, 32), + 'goldenrod1': RGBColor(255, 193, 37), + 'goldenrod2': RGBColor(238, 180, 34), + 'goldenrod3': RGBColor(205, 155, 29), + 'goldenrod4': RGBColor(139, 105, 20), + 'gray': RGBColor(190, 190, 190), + 'gray0': RGBColor(0, 0, 0), + 'gray1': RGBColor(3, 3, 3), + 'gray10': RGBColor(26, 26, 26), + 'gray100': RGBColor(255, 255, 255), + 'gray11': RGBColor(28, 28, 28), + 'gray12': RGBColor(31, 31, 31), + 'gray13': RGBColor(33, 33, 33), + 'gray14': RGBColor(36, 36, 36), + 'gray15': RGBColor(38, 38, 38), + 'gray16': RGBColor(41, 41, 41), + 'gray17': RGBColor(43, 43, 43), + 'gray18': RGBColor(46, 46, 46), + 'gray19': RGBColor(48, 48, 48), + 'gray2': RGBColor(5, 5, 5), + 'gray20': RGBColor(51, 51, 51), + 'gray21': RGBColor(54, 54, 54), + 'gray22': RGBColor(56, 56, 56), + 'gray23': RGBColor(59, 59, 59), + 'gray24': RGBColor(61, 61, 61), + 'gray25': RGBColor(64, 64, 64), + 'gray26': RGBColor(66, 66, 66), + 'gray27': RGBColor(69, 69, 69), + 'gray28': RGBColor(71, 71, 71), + 'gray29': RGBColor(74, 74, 74), + 'gray3': RGBColor(8, 8, 8), + 'gray30': RGBColor(77, 77, 77), + 'gray31': RGBColor(79, 79, 79), + 'gray32': RGBColor(82, 82, 82), + 'gray33': RGBColor(84, 84, 84), + 'gray34': RGBColor(87, 87, 87), + 'gray35': RGBColor(89, 89, 89), + 'gray36': RGBColor(92, 92, 92), + 'gray37': RGBColor(94, 94, 94), + 'gray38': RGBColor(97, 97, 97), + 'gray39': RGBColor(99, 99, 99), + 'gray4': RGBColor(10, 10, 10), + 'gray40': RGBColor(102, 102, 102), + 'gray41': RGBColor(105, 105, 105), + 'gray42': RGBColor(107, 107, 107), + 'gray43': RGBColor(110, 110, 110), + 'gray44': RGBColor(112, 112, 112), + 'gray45': RGBColor(115, 115, 115), + 'gray46': RGBColor(117, 117, 117), + 'gray47': RGBColor(120, 120, 120), + 'gray48': RGBColor(122, 122, 122), + 'gray49': RGBColor(125, 125, 125), + 'gray5': RGBColor(13, 13, 13), + 'gray50': RGBColor(127, 127, 127), + 'gray51': RGBColor(130, 130, 130), + 'gray52': RGBColor(133, 133, 133), + 'gray53': RGBColor(135, 135, 135), + 'gray54': RGBColor(138, 138, 138), + 'gray55': RGBColor(140, 140, 140), + 'gray56': RGBColor(143, 143, 143), + 'gray57': RGBColor(145, 145, 145), + 'gray58': RGBColor(148, 148, 148), + 'gray59': RGBColor(150, 150, 150), + 'gray6': RGBColor(15, 15, 15), + 'gray60': RGBColor(153, 153, 153), + 'gray61': RGBColor(156, 156, 156), + 'gray62': RGBColor(158, 158, 158), + 'gray63': RGBColor(161, 161, 161), + 'gray64': RGBColor(163, 163, 163), + 'gray65': RGBColor(166, 166, 166), + 'gray66': RGBColor(168, 168, 168), + 'gray67': RGBColor(171, 171, 171), + 'gray68': RGBColor(173, 173, 173), + 'gray69': RGBColor(176, 176, 176), + 'gray7': RGBColor(18, 18, 18), + 'gray70': RGBColor(179, 179, 179), + 'gray71': RGBColor(181, 181, 181), + 'gray72': RGBColor(184, 184, 184), + 'gray73': RGBColor(186, 186, 186), + 'gray74': RGBColor(189, 189, 189), + 'gray75': RGBColor(191, 191, 191), + 'gray76': RGBColor(194, 194, 194), + 'gray77': RGBColor(196, 196, 196), + 'gray78': RGBColor(199, 199, 199), + 'gray79': RGBColor(201, 201, 201), + 'gray8': RGBColor(20, 20, 20), + 'gray80': RGBColor(204, 204, 204), + 'gray81': RGBColor(207, 207, 207), + 'gray82': RGBColor(209, 209, 209), + 'gray83': RGBColor(212, 212, 212), + 'gray84': RGBColor(214, 214, 214), + 'gray85': RGBColor(217, 217, 217), + 'gray86': RGBColor(219, 219, 219), + 'gray87': RGBColor(222, 222, 222), + 'gray88': RGBColor(224, 224, 224), + 'gray89': RGBColor(227, 227, 227), + 'gray9': RGBColor(23, 23, 23), + 'gray90': RGBColor(229, 229, 229), + 'gray91': RGBColor(232, 232, 232), + 'gray92': RGBColor(235, 235, 235), + 'gray93': RGBColor(237, 237, 237), + 'gray94': RGBColor(240, 240, 240), + 'gray95': RGBColor(242, 242, 242), + 'gray96': RGBColor(245, 245, 245), + 'gray97': RGBColor(247, 247, 247), + 'gray98': RGBColor(250, 250, 250), + 'gray99': RGBColor(252, 252, 252), + 'green': RGBColor(0, 255, 0), + 'green1': RGBColor(0, 255, 0), + 'green2': RGBColor(0, 238, 0), + 'green3': RGBColor(0, 205, 0), + 'green4': RGBColor(0, 139, 0), + 'greenyellow': RGBColor(173, 255, 47), + 'grey': RGBColor(190, 190, 190), + 'grey0': RGBColor(0, 0, 0), + 'grey1': RGBColor(3, 3, 3), + 'grey10': RGBColor(26, 26, 26), + 'grey100': RGBColor(255, 255, 255), + 'grey11': RGBColor(28, 28, 28), + 'grey12': RGBColor(31, 31, 31), + 'grey13': RGBColor(33, 33, 33), + 'grey14': RGBColor(36, 36, 36), + 'grey15': RGBColor(38, 38, 38), + 'grey16': RGBColor(41, 41, 41), + 'grey17': RGBColor(43, 43, 43), + 'grey18': RGBColor(46, 46, 46), + 'grey19': RGBColor(48, 48, 48), + 'grey2': RGBColor(5, 5, 5), + 'grey20': RGBColor(51, 51, 51), + 'grey21': RGBColor(54, 54, 54), + 'grey22': RGBColor(56, 56, 56), + 'grey23': RGBColor(59, 59, 59), + 'grey24': RGBColor(61, 61, 61), + 'grey25': RGBColor(64, 64, 64), + 'grey26': RGBColor(66, 66, 66), + 'grey27': RGBColor(69, 69, 69), + 'grey28': RGBColor(71, 71, 71), + 'grey29': RGBColor(74, 74, 74), + 'grey3': RGBColor(8, 8, 8), + 'grey30': RGBColor(77, 77, 77), + 'grey31': RGBColor(79, 79, 79), + 'grey32': RGBColor(82, 82, 82), + 'grey33': RGBColor(84, 84, 84), + 'grey34': RGBColor(87, 87, 87), + 'grey35': RGBColor(89, 89, 89), + 'grey36': RGBColor(92, 92, 92), + 'grey37': RGBColor(94, 94, 94), + 'grey38': RGBColor(97, 97, 97), + 'grey39': RGBColor(99, 99, 99), + 'grey4': RGBColor(10, 10, 10), + 'grey40': RGBColor(102, 102, 102), + 'grey41': RGBColor(105, 105, 105), + 'grey42': RGBColor(107, 107, 107), + 'grey43': RGBColor(110, 110, 110), + 'grey44': RGBColor(112, 112, 112), + 'grey45': RGBColor(115, 115, 115), + 'grey46': RGBColor(117, 117, 117), + 'grey47': RGBColor(120, 120, 120), + 'grey48': RGBColor(122, 122, 122), + 'grey49': RGBColor(125, 125, 125), + 'grey5': RGBColor(13, 13, 13), + 'grey50': RGBColor(127, 127, 127), + 'grey51': RGBColor(130, 130, 130), + 'grey52': RGBColor(133, 133, 133), + 'grey53': RGBColor(135, 135, 135), + 'grey54': RGBColor(138, 138, 138), + 'grey55': RGBColor(140, 140, 140), + 'grey56': RGBColor(143, 143, 143), + 'grey57': RGBColor(145, 145, 145), + 'grey58': RGBColor(148, 148, 148), + 'grey59': RGBColor(150, 150, 150), + 'grey6': RGBColor(15, 15, 15), + 'grey60': RGBColor(153, 153, 153), + 'grey61': RGBColor(156, 156, 156), + 'grey62': RGBColor(158, 158, 158), + 'grey63': RGBColor(161, 161, 161), + 'grey64': RGBColor(163, 163, 163), + 'grey65': RGBColor(166, 166, 166), + 'grey66': RGBColor(168, 168, 168), + 'grey67': RGBColor(171, 171, 171), + 'grey68': RGBColor(173, 173, 173), + 'grey69': RGBColor(176, 176, 176), + 'grey7': RGBColor(18, 18, 18), + 'grey70': RGBColor(179, 179, 179), + 'grey71': RGBColor(181, 181, 181), + 'grey72': RGBColor(184, 184, 184), + 'grey73': RGBColor(186, 186, 186), + 'grey74': RGBColor(189, 189, 189), + 'grey75': RGBColor(191, 191, 191), + 'grey76': RGBColor(194, 194, 194), + 'grey77': RGBColor(196, 196, 196), + 'grey78': RGBColor(199, 199, 199), + 'grey79': RGBColor(201, 201, 201), + 'grey8': RGBColor(20, 20, 20), + 'grey80': RGBColor(204, 204, 204), + 'grey81': RGBColor(207, 207, 207), + 'grey82': RGBColor(209, 209, 209), + 'grey83': RGBColor(212, 212, 212), + 'grey84': RGBColor(214, 214, 214), + 'grey85': RGBColor(217, 217, 217), + 'grey86': RGBColor(219, 219, 219), + 'grey87': RGBColor(222, 222, 222), + 'grey88': RGBColor(224, 224, 224), + 'grey89': RGBColor(227, 227, 227), + 'grey9': RGBColor(23, 23, 23), + 'grey90': RGBColor(229, 229, 229), + 'grey91': RGBColor(232, 232, 232), + 'grey92': RGBColor(235, 235, 235), + 'grey93': RGBColor(237, 237, 237), + 'grey94': RGBColor(240, 240, 240), + 'grey95': RGBColor(242, 242, 242), + 'grey96': RGBColor(245, 245, 245), + 'grey97': RGBColor(247, 247, 247), + 'grey98': RGBColor(250, 250, 250), + 'grey99': RGBColor(252, 252, 252), + 'honeydew': RGBColor(240, 255, 240), + 'honeydew1': RGBColor(240, 255, 240), + 'honeydew2': RGBColor(224, 238, 224), + 'honeydew3': RGBColor(193, 205, 193), + 'honeydew4': RGBColor(131, 139, 131), + 'hotpink': RGBColor(255, 105, 180), + 'hotpink1': RGBColor(255, 110, 180), + 'hotpink2': RGBColor(238, 106, 167), + 'hotpink3': RGBColor(205, 96, 144), + 'hotpink4': RGBColor(139, 58, 98), + 'indianred': RGBColor(205, 92, 92), + 'indianred1': RGBColor(255, 106, 106), + 'indianred2': RGBColor(238, 99, 99), + 'indianred3': RGBColor(205, 85, 85), + 'indianred4': RGBColor(139, 58, 58), + 'indigo': RGBColor(75, 0, 130), + 'ivory': RGBColor(255, 255, 240), + 'ivory1': RGBColor(255, 255, 240), + 'ivory2': RGBColor(238, 238, 224), + 'ivory3': RGBColor(205, 205, 193), + 'ivory4': RGBColor(139, 139, 131), + 'khaki': RGBColor(240, 230, 140), + 'khaki1': RGBColor(255, 246, 143), + 'khaki2': RGBColor(238, 230, 133), + 'khaki3': RGBColor(205, 198, 115), + 'khaki4': RGBColor(139, 134, 78), + 'lavender': RGBColor(230, 230, 250), + 'lavenderblush': RGBColor(255, 240, 245), + 'lavenderblush1': RGBColor(255, 240, 245), + 'lavenderblush2': RGBColor(238, 224, 229), + 'lavenderblush3': RGBColor(205, 193, 197), + 'lavenderblush4': RGBColor(139, 131, 134), + 'lawngreen': RGBColor(124, 252, 0), + 'lemonchiffon': RGBColor(255, 250, 205), + 'lemonchiffon1': RGBColor(255, 250, 205), + 'lemonchiffon2': RGBColor(238, 233, 191), + 'lemonchiffon3': RGBColor(205, 201, 165), + 'lemonchiffon4': RGBColor(139, 137, 112), + 'lightblue': RGBColor(173, 216, 230), + 'lightblue1': RGBColor(191, 239, 255), + 'lightblue2': RGBColor(178, 223, 238), + 'lightblue3': RGBColor(154, 192, 205), + 'lightblue4': RGBColor(104, 131, 139), + 'lightcoral': RGBColor(240, 128, 128), + 'lightcyan': RGBColor(224, 255, 255), + 'lightcyan1': RGBColor(224, 255, 255), + 'lightcyan2': RGBColor(209, 238, 238), + 'lightcyan3': RGBColor(180, 205, 205), + 'lightcyan4': RGBColor(122, 139, 139), + 'lightgoldenrod': RGBColor(238, 221, 130), + 'lightgoldenrod1': RGBColor(255, 236, 139), + 'lightgoldenrod2': RGBColor(238, 220, 130), + 'lightgoldenrod3': RGBColor(205, 190, 112), + 'lightgoldenrod4': RGBColor(139, 129, 76), + 'lightgoldenrodyellow': RGBColor(250, 250, 210), + 'lightgray': RGBColor(211, 211, 211), + 'lightgreen': RGBColor(144, 238, 144), + 'lightgrey': RGBColor(211, 211, 211), + 'lightpink': RGBColor(255, 182, 193), + 'lightpink1': RGBColor(255, 174, 185), + 'lightpink2': RGBColor(238, 162, 173), + 'lightpink3': RGBColor(205, 140, 149), + 'lightpink4': RGBColor(139, 95, 101), + 'lightsalmon': RGBColor(255, 160, 122), + 'lightsalmon1': RGBColor(255, 160, 122), + 'lightsalmon2': RGBColor(238, 149, 114), + 'lightsalmon3': RGBColor(205, 129, 98), + 'lightsalmon4': RGBColor(139, 87, 66), + 'lightseagreen': RGBColor(32, 178, 170), + 'lightskyblue': RGBColor(135, 206, 250), + 'lightskyblue1': RGBColor(176, 226, 255), + 'lightskyblue2': RGBColor(164, 211, 238), + 'lightskyblue3': RGBColor(141, 182, 205), + 'lightskyblue4': RGBColor(96, 123, 139), + 'lightslateblue': RGBColor(132, 112, 255), + 'lightslategray': RGBColor(119, 136, 153), + 'lightslategrey': RGBColor(119, 136, 153), + 'lightsteelblue': RGBColor(176, 196, 222), + 'lightsteelblue1': RGBColor(202, 225, 255), + 'lightsteelblue2': RGBColor(188, 210, 238), + 'lightsteelblue3': RGBColor(162, 181, 205), + 'lightsteelblue4': RGBColor(110, 123, 139), + 'lightyellow': RGBColor(255, 255, 224), + 'lightyellow1': RGBColor(255, 255, 224), + 'lightyellow2': RGBColor(238, 238, 209), + 'lightyellow3': RGBColor(205, 205, 180), + 'lightyellow4': RGBColor(139, 139, 122), + 'lime': RGBColor(0, 255, 0), + 'limegreen': RGBColor(50, 205, 50), + 'linen': RGBColor(250, 240, 230), + 'magenta': RGBColor(255, 0, 255), + 'magenta1': RGBColor(255, 0, 255), + 'magenta2': RGBColor(238, 0, 238), + 'magenta3': RGBColor(205, 0, 205), + 'magenta4': RGBColor(139, 0, 139), + 'maroon': RGBColor(176, 48, 96), + 'maroon1': RGBColor(255, 52, 179), + 'maroon2': RGBColor(238, 48, 167), + 'maroon3': RGBColor(205, 41, 144), + 'maroon4': RGBColor(139, 28, 98), + 'mediumaquamarine': RGBColor(102, 205, 170), + 'mediumblue': RGBColor(0, 0, 205), + 'mediumorchid': RGBColor(186, 85, 211), + 'mediumorchid1': RGBColor(224, 102, 255), + 'mediumorchid2': RGBColor(209, 95, 238), + 'mediumorchid3': RGBColor(180, 82, 205), + 'mediumorchid4': RGBColor(122, 55, 139), + 'mediumpurple': RGBColor(147, 112, 219), + 'mediumpurple1': RGBColor(171, 130, 255), + 'mediumpurple2': RGBColor(159, 121, 238), + 'mediumpurple3': RGBColor(137, 104, 205), + 'mediumpurple4': RGBColor(93, 71, 139), + 'mediumseagreen': RGBColor(60, 179, 113), + 'mediumslateblue': RGBColor(123, 104, 238), + 'mediumspringgreen': RGBColor(0, 250, 154), + 'mediumturquoise': RGBColor(72, 209, 204), + 'mediumvioletred': RGBColor(199, 21, 133), + 'midnightblue': RGBColor(25, 25, 112), + 'mintcream': RGBColor(245, 255, 250), + 'mistyrose': RGBColor(255, 228, 225), + 'mistyrose1': RGBColor(255, 228, 225), + 'mistyrose2': RGBColor(238, 213, 210), + 'mistyrose3': RGBColor(205, 183, 181), + 'mistyrose4': RGBColor(139, 125, 123), + 'moccasin': RGBColor(255, 228, 181), + 'navajowhite': RGBColor(255, 222, 173), + 'navajowhite1': RGBColor(255, 222, 173), + 'navajowhite2': RGBColor(238, 207, 161), + 'navajowhite3': RGBColor(205, 179, 139), + 'navajowhite4': RGBColor(139, 121, 94), + 'navy': RGBColor(0, 0, 128), + 'navyblue': RGBColor(0, 0, 128), + 'oldlace': RGBColor(253, 245, 230), + 'olive': RGBColor(128, 128, 0), + 'olivedrab': RGBColor(107, 142, 35), + 'olivedrab1': RGBColor(192, 255, 62), + 'olivedrab2': RGBColor(179, 238, 58), + 'olivedrab3': RGBColor(154, 205, 50), + 'olivedrab4': RGBColor(105, 139, 34), + 'orange': RGBColor(255, 165, 0), + 'orange1': RGBColor(255, 165, 0), + 'orange2': RGBColor(238, 154, 0), + 'orange3': RGBColor(205, 133, 0), + 'orange4': RGBColor(139, 90, 0), + 'orangered': RGBColor(255, 69, 0), + 'orangered1': RGBColor(255, 69, 0), + 'orangered2': RGBColor(238, 64, 0), + 'orangered3': RGBColor(205, 55, 0), + 'orangered4': RGBColor(139, 37, 0), + 'orchid': RGBColor(218, 112, 214), + 'orchid1': RGBColor(255, 131, 250), + 'orchid2': RGBColor(238, 122, 233), + 'orchid3': RGBColor(205, 105, 201), + 'orchid4': RGBColor(139, 71, 137), + 'palegoldenrod': RGBColor(238, 232, 170), + 'palegreen': RGBColor(152, 251, 152), + 'palegreen1': RGBColor(154, 255, 154), + 'palegreen2': RGBColor(144, 238, 144), + 'palegreen3': RGBColor(124, 205, 124), + 'palegreen4': RGBColor(84, 139, 84), + 'paleturquoise': RGBColor(175, 238, 238), + 'paleturquoise1': RGBColor(187, 255, 255), + 'paleturquoise2': RGBColor(174, 238, 238), + 'paleturquoise3': RGBColor(150, 205, 205), + 'paleturquoise4': RGBColor(102, 139, 139), + 'palevioletred': RGBColor(219, 112, 147), + 'palevioletred1': RGBColor(255, 130, 171), + 'palevioletred2': RGBColor(238, 121, 159), + 'palevioletred3': RGBColor(205, 104, 137), + 'palevioletred4': RGBColor(139, 71, 93), + 'papayawhip': RGBColor(255, 239, 213), + 'peachpuff': RGBColor(255, 218, 185), + 'peachpuff1': RGBColor(255, 218, 185), + 'peachpuff2': RGBColor(238, 203, 173), + 'peachpuff3': RGBColor(205, 175, 149), + 'peachpuff4': RGBColor(139, 119, 101), + 'peru': RGBColor(205, 133, 63), + 'pink': RGBColor(255, 192, 203), + 'pink1': RGBColor(255, 181, 197), + 'pink2': RGBColor(238, 169, 184), + 'pink3': RGBColor(205, 145, 158), + 'pink4': RGBColor(139, 99, 108), + 'plum': RGBColor(221, 160, 221), + 'plum1': RGBColor(255, 187, 255), + 'plum2': RGBColor(238, 174, 238), + 'plum3': RGBColor(205, 150, 205), + 'plum4': RGBColor(139, 102, 139), + 'powderblue': RGBColor(176, 224, 230), + 'purple': RGBColor(160, 32, 240), + 'purple1': RGBColor(155, 48, 255), + 'purple2': RGBColor(145, 44, 238), + 'purple3': RGBColor(125, 38, 205), + 'purple4': RGBColor(85, 26, 139), + 'rebeccapurple': RGBColor(102, 51, 153), + 'red': RGBColor(255, 0, 0), + 'red1': RGBColor(255, 0, 0), + 'red2': RGBColor(238, 0, 0), + 'red3': RGBColor(205, 0, 0), + 'red4': RGBColor(139, 0, 0), + 'rosybrown': RGBColor(188, 143, 143), + 'rosybrown1': RGBColor(255, 193, 193), + 'rosybrown2': RGBColor(238, 180, 180), + 'rosybrown3': RGBColor(205, 155, 155), + 'rosybrown4': RGBColor(139, 105, 105), + 'royalblue': RGBColor(65, 105, 225), + 'royalblue1': RGBColor(72, 118, 255), + 'royalblue2': RGBColor(67, 110, 238), + 'royalblue3': RGBColor(58, 95, 205), + 'royalblue4': RGBColor(39, 64, 139), + 'saddlebrown': RGBColor(139, 69, 19), + 'salmon': RGBColor(250, 128, 114), + 'salmon1': RGBColor(255, 140, 105), + 'salmon2': RGBColor(238, 130, 98), + 'salmon3': RGBColor(205, 112, 84), + 'salmon4': RGBColor(139, 76, 57), + 'sandybrown': RGBColor(244, 164, 96), + 'seagreen': RGBColor(46, 139, 87), + 'seagreen1': RGBColor(84, 255, 159), + 'seagreen2': RGBColor(78, 238, 148), + 'seagreen3': RGBColor(67, 205, 128), + 'seagreen4': RGBColor(46, 139, 87), + 'seashell': RGBColor(255, 245, 238), + 'seashell1': RGBColor(255, 245, 238), + 'seashell2': RGBColor(238, 229, 222), + 'seashell3': RGBColor(205, 197, 191), + 'seashell4': RGBColor(139, 134, 130), + 'sienna': RGBColor(160, 82, 45), + 'sienna1': RGBColor(255, 130, 71), + 'sienna2': RGBColor(238, 121, 66), + 'sienna3': RGBColor(205, 104, 57), + 'sienna4': RGBColor(139, 71, 38), + 'silver': RGBColor(192, 192, 192), + 'skyblue': RGBColor(135, 206, 235), + 'skyblue1': RGBColor(135, 206, 255), + 'skyblue2': RGBColor(126, 192, 238), + 'skyblue3': RGBColor(108, 166, 205), + 'skyblue4': RGBColor(74, 112, 139), + 'slateblue': RGBColor(106, 90, 205), + 'slateblue1': RGBColor(131, 111, 255), + 'slateblue2': RGBColor(122, 103, 238), + 'slateblue3': RGBColor(105, 89, 205), + 'slateblue4': RGBColor(71, 60, 139), + 'slategray': RGBColor(112, 128, 144), + 'slategray1': RGBColor(198, 226, 255), + 'slategray2': RGBColor(185, 211, 238), + 'slategray3': RGBColor(159, 182, 205), + 'slategray4': RGBColor(108, 123, 139), + 'slategrey': RGBColor(112, 128, 144), + 'snow': RGBColor(255, 250, 250), + 'snow1': RGBColor(255, 250, 250), + 'snow2': RGBColor(238, 233, 233), + 'snow3': RGBColor(205, 201, 201), + 'snow4': RGBColor(139, 137, 137), + 'springgreen': RGBColor(0, 255, 127), + 'springgreen1': RGBColor(0, 255, 127), + 'springgreen2': RGBColor(0, 238, 118), + 'springgreen3': RGBColor(0, 205, 102), + 'springgreen4': RGBColor(0, 139, 69), + 'steelblue': RGBColor(70, 130, 180), + 'steelblue1': RGBColor(99, 184, 255), + 'steelblue2': RGBColor(92, 172, 238), + 'steelblue3': RGBColor(79, 148, 205), + 'steelblue4': RGBColor(54, 100, 139), + 'tan': RGBColor(210, 180, 140), + 'tan1': RGBColor(255, 165, 79), + 'tan2': RGBColor(238, 154, 73), + 'tan3': RGBColor(205, 133, 63), + 'tan4': RGBColor(139, 90, 43), + 'teal': RGBColor(0, 128, 128), + 'thistle': RGBColor(216, 191, 216), + 'thistle1': RGBColor(255, 225, 255), + 'thistle2': RGBColor(238, 210, 238), + 'thistle3': RGBColor(205, 181, 205), + 'thistle4': RGBColor(139, 123, 139), + 'tomato': RGBColor(255, 99, 71), + 'tomato1': RGBColor(255, 99, 71), + 'tomato2': RGBColor(238, 92, 66), + 'tomato3': RGBColor(205, 79, 57), + 'tomato4': RGBColor(139, 54, 38), + 'turquoise': RGBColor(64, 224, 208), + 'turquoise1': RGBColor(0, 245, 255), + 'turquoise2': RGBColor(0, 229, 238), + 'turquoise3': RGBColor(0, 197, 205), + 'turquoise4': RGBColor(0, 134, 139), + 'violet': RGBColor(238, 130, 238), + 'violetred': RGBColor(208, 32, 144), + 'violetred1': RGBColor(255, 62, 150), + 'violetred2': RGBColor(238, 58, 140), + 'violetred3': RGBColor(205, 50, 120), + 'violetred4': RGBColor(139, 34, 82), + 'webgray': RGBColor(128, 128, 128), + 'webgreen': RGBColor(0, 128, 0), + 'webgrey': RGBColor(128, 128, 128), + 'webmaroon': RGBColor(128, 0, 0), + 'webpurple': RGBColor(128, 0, 128), + 'wheat': RGBColor(245, 222, 179), + 'wheat1': RGBColor(255, 231, 186), + 'wheat2': RGBColor(238, 216, 174), + 'wheat3': RGBColor(205, 186, 150), + 'wheat4': RGBColor(139, 126, 102), + 'white': RGBColor(255, 255, 255), + 'whitesmoke': RGBColor(245, 245, 245), + 'x11gray': RGBColor(190, 190, 190), + 'x11green': RGBColor(0, 255, 0), + 'x11grey': RGBColor(190, 190, 190), + 'x11maroon': RGBColor(176, 48, 96), + 'x11purple': RGBColor(160, 32, 240), + 'yellow': RGBColor(255, 255, 0), + 'yellow1': RGBColor(255, 255, 0), + 'yellow2': RGBColor(238, 238, 0), + 'yellow3': RGBColor(205, 205, 0), + 'yellow4': RGBColor(139, 139, 0), + 'yellowgreen': RGBColor(154, 205, 50) +} + +#: Curses color indices of 8, 16, and 256-color terminals +RGB_256TABLE = ( + RGBColor(0, 0, 0), + RGBColor(205, 0, 0), + RGBColor(0, 205, 0), + RGBColor(205, 205, 0), + RGBColor(0, 0, 238), + RGBColor(205, 0, 205), + RGBColor(0, 205, 205), + RGBColor(229, 229, 229), + RGBColor(127, 127, 127), + RGBColor(255, 0, 0), + RGBColor(0, 255, 0), + RGBColor(255, 255, 0), + RGBColor(92, 92, 255), + RGBColor(255, 0, 255), + RGBColor(0, 255, 255), + RGBColor(255, 255, 255), + RGBColor(0, 0, 0), + RGBColor(0, 0, 95), + RGBColor(0, 0, 135), + RGBColor(0, 0, 175), + RGBColor(0, 0, 215), + RGBColor(0, 0, 255), + RGBColor(0, 95, 0), + RGBColor(0, 95, 95), + RGBColor(0, 95, 135), + RGBColor(0, 95, 175), + RGBColor(0, 95, 215), + RGBColor(0, 95, 255), + RGBColor(0, 135, 0), + RGBColor(0, 135, 95), + RGBColor(0, 135, 135), + RGBColor(0, 135, 175), + RGBColor(0, 135, 215), + RGBColor(0, 135, 255), + RGBColor(0, 175, 0), + RGBColor(0, 175, 95), + RGBColor(0, 175, 135), + RGBColor(0, 175, 175), + RGBColor(0, 175, 215), + RGBColor(0, 175, 255), + RGBColor(0, 215, 0), + RGBColor(0, 215, 95), + RGBColor(0, 215, 135), + RGBColor(0, 215, 175), + RGBColor(0, 215, 215), + RGBColor(0, 215, 255), + RGBColor(0, 255, 0), + RGBColor(0, 255, 95), + RGBColor(0, 255, 135), + RGBColor(0, 255, 175), + RGBColor(0, 255, 215), + RGBColor(0, 255, 255), + RGBColor(95, 0, 0), + RGBColor(95, 0, 95), + RGBColor(95, 0, 135), + RGBColor(95, 0, 175), + RGBColor(95, 0, 215), + RGBColor(95, 0, 255), + RGBColor(95, 95, 0), + RGBColor(95, 95, 95), + RGBColor(95, 95, 135), + RGBColor(95, 95, 175), + RGBColor(95, 95, 215), + RGBColor(95, 95, 255), + RGBColor(95, 135, 0), + RGBColor(95, 135, 95), + RGBColor(95, 135, 135), + RGBColor(95, 135, 175), + RGBColor(95, 135, 215), + RGBColor(95, 135, 255), + RGBColor(95, 175, 0), + RGBColor(95, 175, 95), + RGBColor(95, 175, 135), + RGBColor(95, 175, 175), + RGBColor(95, 175, 215), + RGBColor(95, 175, 255), + RGBColor(95, 215, 0), + RGBColor(95, 215, 95), + RGBColor(95, 215, 135), + RGBColor(95, 215, 175), + RGBColor(95, 215, 215), + RGBColor(95, 215, 255), + RGBColor(95, 255, 0), + RGBColor(95, 255, 95), + RGBColor(95, 255, 135), + RGBColor(95, 255, 175), + RGBColor(95, 255, 215), + RGBColor(95, 255, 255), + RGBColor(135, 0, 0), + RGBColor(135, 0, 95), + RGBColor(135, 0, 135), + RGBColor(135, 0, 175), + RGBColor(135, 0, 215), + RGBColor(135, 0, 255), + RGBColor(135, 95, 0), + RGBColor(135, 95, 95), + RGBColor(135, 95, 135), + RGBColor(135, 95, 175), + RGBColor(135, 95, 215), + RGBColor(135, 95, 255), + RGBColor(135, 135, 0), + RGBColor(135, 135, 95), + RGBColor(135, 135, 135), + RGBColor(135, 135, 175), + RGBColor(135, 135, 215), + RGBColor(135, 135, 255), + RGBColor(135, 175, 0), + RGBColor(135, 175, 95), + RGBColor(135, 175, 135), + RGBColor(135, 175, 175), + RGBColor(135, 175, 215), + RGBColor(135, 175, 255), + RGBColor(135, 215, 0), + RGBColor(135, 215, 95), + RGBColor(135, 215, 135), + RGBColor(135, 215, 175), + RGBColor(135, 215, 215), + RGBColor(135, 215, 255), + RGBColor(135, 255, 0), + RGBColor(135, 255, 95), + RGBColor(135, 255, 135), + RGBColor(135, 255, 175), + RGBColor(135, 255, 215), + RGBColor(135, 255, 255), + RGBColor(175, 0, 0), + RGBColor(175, 0, 95), + RGBColor(175, 0, 135), + RGBColor(175, 0, 175), + RGBColor(175, 0, 215), + RGBColor(175, 0, 255), + RGBColor(175, 95, 0), + RGBColor(175, 95, 95), + RGBColor(175, 95, 135), + RGBColor(175, 95, 175), + RGBColor(175, 95, 215), + RGBColor(175, 95, 255), + RGBColor(175, 135, 0), + RGBColor(175, 135, 95), + RGBColor(175, 135, 135), + RGBColor(175, 135, 175), + RGBColor(175, 135, 215), + RGBColor(175, 135, 255), + RGBColor(175, 175, 0), + RGBColor(175, 175, 95), + RGBColor(175, 175, 135), + RGBColor(175, 175, 175), + RGBColor(175, 175, 215), + RGBColor(175, 175, 255), + RGBColor(175, 215, 0), + RGBColor(175, 215, 95), + RGBColor(175, 215, 135), + RGBColor(175, 215, 175), + RGBColor(175, 215, 215), + RGBColor(175, 215, 255), + RGBColor(175, 255, 0), + RGBColor(175, 255, 95), + RGBColor(175, 255, 135), + RGBColor(175, 255, 175), + RGBColor(175, 255, 215), + RGBColor(175, 255, 255), + RGBColor(215, 0, 0), + RGBColor(215, 0, 95), + RGBColor(215, 0, 135), + RGBColor(215, 0, 175), + RGBColor(215, 0, 215), + RGBColor(215, 0, 255), + RGBColor(215, 95, 0), + RGBColor(215, 95, 95), + RGBColor(215, 95, 135), + RGBColor(215, 95, 175), + RGBColor(215, 95, 215), + RGBColor(215, 95, 255), + RGBColor(215, 135, 0), + RGBColor(215, 135, 95), + RGBColor(215, 135, 135), + RGBColor(215, 135, 175), + RGBColor(215, 135, 215), + RGBColor(215, 135, 255), + RGBColor(215, 175, 0), + RGBColor(215, 175, 95), + RGBColor(215, 175, 135), + RGBColor(215, 175, 175), + RGBColor(215, 175, 215), + RGBColor(215, 175, 255), + RGBColor(215, 215, 0), + RGBColor(215, 215, 95), + RGBColor(215, 215, 135), + RGBColor(215, 215, 175), + RGBColor(215, 215, 215), + RGBColor(215, 215, 255), + RGBColor(215, 255, 0), + RGBColor(215, 255, 95), + RGBColor(215, 255, 135), + RGBColor(215, 255, 175), + RGBColor(215, 255, 215), + RGBColor(215, 255, 255), + RGBColor(255, 0, 0), + RGBColor(255, 0, 135), + RGBColor(255, 0, 95), + RGBColor(255, 0, 175), + RGBColor(255, 0, 215), + RGBColor(255, 0, 255), + RGBColor(255, 95, 0), + RGBColor(255, 95, 95), + RGBColor(255, 95, 135), + RGBColor(255, 95, 175), + RGBColor(255, 95, 215), + RGBColor(255, 95, 255), + RGBColor(255, 135, 0), + RGBColor(255, 135, 95), + RGBColor(255, 135, 135), + RGBColor(255, 135, 175), + RGBColor(255, 135, 215), + RGBColor(255, 135, 255), + RGBColor(255, 175, 0), + RGBColor(255, 175, 95), + RGBColor(255, 175, 135), + RGBColor(255, 175, 175), + RGBColor(255, 175, 215), + RGBColor(255, 175, 255), + RGBColor(255, 215, 0), + RGBColor(255, 215, 95), + RGBColor(255, 215, 135), + RGBColor(255, 215, 175), + RGBColor(255, 215, 215), + RGBColor(255, 215, 255), + RGBColor(255, 255, 0), + RGBColor(255, 255, 95), + RGBColor(255, 255, 135), + RGBColor(255, 255, 175), + RGBColor(255, 255, 215), + RGBColor(255, 255, 255), + RGBColor(8, 8, 8), + RGBColor(18, 18, 18), + RGBColor(28, 28, 28), + RGBColor(38, 38, 38), + RGBColor(48, 48, 48), + RGBColor(58, 58, 58), + RGBColor(68, 68, 68), + RGBColor(78, 78, 78), + RGBColor(88, 88, 88), + RGBColor(98, 98, 98), + RGBColor(108, 108, 108), + RGBColor(118, 118, 118), + RGBColor(128, 128, 128), + RGBColor(138, 138, 138), + RGBColor(148, 148, 148), + RGBColor(158, 158, 158), + RGBColor(168, 168, 168), + RGBColor(178, 178, 178), + RGBColor(188, 188, 188), + RGBColor(198, 198, 198), + RGBColor(208, 208, 208), + RGBColor(218, 218, 218), + RGBColor(228, 228, 228), + RGBColor(238, 238, 238), +) diff --git a/blessed/formatters.py b/blessed/formatters.py new file mode 100644 index 00000000..83bee881 --- /dev/null +++ b/blessed/formatters.py @@ -0,0 +1,442 @@ +"""Sub-module providing sequence-formatting functions.""" +# std imports +import platform + +# 3rd party +import six + +# local +from blessed.colorspace import CGA_COLORS, X11_COLORNAMES_TO_RGB + +# curses +if platform.system() == 'Windows': + import jinxed as curses # pylint: disable=import-error +else: + import curses + + +def _make_colors(): + """ + Return set of valid colors and their derivatives. + + :rtype: set + """ + colors = set() + # basic CGA foreground color, background, high intensity, and bold + # background ('iCE colors' in my day). + for cga_color in CGA_COLORS: + colors.add(cga_color) + colors.add('on_' + cga_color) + colors.add('bright_' + cga_color) + colors.add('on_bright_' + cga_color) + + # foreground and background VGA color + for vga_color in X11_COLORNAMES_TO_RGB: + colors.add(vga_color) + colors.add('on_' + vga_color) + return colors + + +#: Valid colors and their background (on), bright, and bright-background +#: derivatives. +COLORS = _make_colors() + +#: Attributes that may be compounded with colors, by underscore, such as +#: 'reverse_indigo'. +COMPOUNDABLES = set('bold underline reverse blink italic standout'.split()) + + +class ParameterizingString(six.text_type): + r""" + A Unicode string which can be called as a parameterizing termcap. + + For example:: + + >>> term = Terminal() + >>> color = ParameterizingString(term.color, term.normal, 'color') + >>> color(9)('color #9') + u'\x1b[91mcolor #9\x1b(B\x1b[m' + """ + + def __new__(cls, *args): + """ + Class constructor accepting 3 positional arguments. + + :arg cap: parameterized string suitable for curses.tparm() + :arg normal: terminating sequence for this capability (optional). + :arg name: name of this terminal capability (optional). + """ + assert args and len(args) < 4, args + new = six.text_type.__new__(cls, args[0]) + new._normal = args[1] if len(args) > 1 else u'' + new._name = args[2] if len(args) > 2 else u'' + return new + + def __call__(self, *args): + """ + Returning :class:`FormattingString` instance for given parameters. + + Return evaluated terminal capability (self), receiving arguments + ``*args``, followed by the terminating sequence (self.normal) into + a :class:`FormattingString` capable of being called. + + :rtype: :class:`FormattingString` or :class:`NullCallableString` + """ + try: + # Re-encode the cap, because tparm() takes a bytestring in Python + # 3. However, appear to be a plain Unicode string otherwise so + # concats work. + attr = curses.tparm(self.encode('latin1'), *args).decode('latin1') + return FormattingString(attr, self._normal) + except TypeError as err: + # If the first non-int (i.e. incorrect) arg was a string, suggest + # something intelligent: + if args and isinstance(args[0], six.string_types): + raise TypeError( + "A native or nonexistent capability template, %r received" + " invalid argument %r: %s. You probably misspelled a" + " formatting call like `bright_red'" % ( + self._name, args, err)) + # Somebody passed a non-string; I don't feel confident + # guessing what they were trying to do. + raise + except curses.error as err: + # ignore 'tparm() returned NULL', you won't get any styling, + # even if does_styling is True. This happens on win32 platforms + # with http://www.lfd.uci.edu/~gohlke/pythonlibs/#curses installed + if "tparm() returned NULL" not in six.text_type(err): + raise + return NullCallableString() + + +class ParameterizingProxyString(six.text_type): + r""" + A Unicode string which can be called to proxy missing termcap entries. + + This class supports the function :func:`get_proxy_string`, and mirrors + the behavior of :class:`ParameterizingString`, except that instead of + a capability name, receives a format string, and callable to filter the + given positional ``*args`` of :meth:`ParameterizingProxyString.__call__` + into a terminal sequence. + + For example:: + + >>> from blessed import Terminal + >>> term = Terminal('screen') + >>> hpa = ParameterizingString(term.hpa, term.normal, 'hpa') + >>> hpa(9) + u'' + >>> fmt = u'\x1b[{0}G' + >>> fmt_arg = lambda *arg: (arg[0] + 1,) + >>> hpa = ParameterizingProxyString((fmt, fmt_arg), term.normal, 'hpa') + >>> hpa(9) + u'\x1b[10G' + """ + + def __new__(cls, *args): + """ + Class constructor accepting 4 positional arguments. + + :arg fmt: format string suitable for displaying terminal sequences. + :arg callable: receives __call__ arguments for formatting fmt. + :arg normal: terminating sequence for this capability (optional). + :arg name: name of this terminal capability (optional). + """ + assert args and len(args) < 4, args + assert isinstance(args[0], tuple), args[0] + assert callable(args[0][1]), args[0][1] + new = six.text_type.__new__(cls, args[0][0]) + new._fmt_args = args[0][1] + new._normal = args[1] if len(args) > 1 else u'' + new._name = args[2] if len(args) > 2 else u'' + return new + + def __call__(self, *args): + """ + Returning :class:`FormattingString` instance for given parameters. + + Arguments are determined by the capability. For example, ``hpa`` + (move_x) receives only a single integer, whereas ``cup`` (move) + receives two integers. See documentation in terminfo(5) for the + given capability. + + :rtype: FormattingString + """ + return FormattingString(self.format(*self._fmt_args(*args)), + self._normal) + + +def get_proxy_string(term, attr): + """ + Proxy and return callable string for proxied attributes. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str attr: terminal capability name that may be proxied. + :rtype: None or :class:`ParameterizingProxyString`. + :returns: :class:`ParameterizingProxyString` for some attributes + of some terminal types that support it, where the terminfo(5) + database would otherwise come up empty, such as ``move_x`` + attribute for ``term.kind`` of ``screen``. Otherwise, None. + """ + # normalize 'screen-256color', or 'ansi.sys' to its basic names + term_kind = next(iter(_kind for _kind in ('screen', 'ansi',) + if term.kind.startswith(_kind)), term) + _proxy_table = { # pragma: no cover + 'screen': { + # proxy move_x/move_y for 'screen' terminal type, used by tmux(1). + 'hpa': ParameterizingProxyString( + (u'\x1b[{0}G', lambda *arg: (arg[0] + 1,)), term.normal, attr), + 'vpa': ParameterizingProxyString( + (u'\x1b[{0}d', lambda *arg: (arg[0] + 1,)), term.normal, attr), + }, + 'ansi': { + # proxy show/hide cursor for 'ansi' terminal type. There is some + # demand for a richly working ANSI terminal type for some reason. + 'civis': ParameterizingProxyString( + (u'\x1b[?25l', lambda *arg: ()), term.normal, attr), + 'cnorm': ParameterizingProxyString( + (u'\x1b[?25h', lambda *arg: ()), term.normal, attr), + 'hpa': ParameterizingProxyString( + (u'\x1b[{0}G', lambda *arg: (arg[0] + 1,)), term.normal, attr), + 'vpa': ParameterizingProxyString( + (u'\x1b[{0}d', lambda *arg: (arg[0] + 1,)), term.normal, attr), + 'sc': '\x1b[s', + 'rc': '\x1b[u', + } + } + return _proxy_table.get(term_kind, {}).get(attr, None) + + +class FormattingString(six.text_type): + r""" + A Unicode string which doubles as a callable. + + This is used for terminal attributes, so that it may be used both + directly, or as a callable. When used directly, it simply emits + the given terminal sequence. When used as a callable, it wraps the + given (string) argument with the 2nd argument used by the class + constructor:: + + >>> style = FormattingString(term.bright_blue, term.normal) + >>> print(repr(style)) + u'\x1b[94m' + >>> style('Big Blue') + u'\x1b[94mBig Blue\x1b(B\x1b[m' + """ + + def __new__(cls, *args): + """ + Class constructor accepting 2 positional arguments. + + :arg sequence: terminal attribute sequence. + :arg normal: terminating sequence for this attribute (optional). + """ + assert 1 <= len(args) <= 2, args + new = six.text_type.__new__(cls, args[0]) + new._normal = args[1] if len(args) > 1 else u'' + return new + + def __call__(self, *args): + """Return ``text`` joined by ``sequence`` and ``normal``.""" + # Jim Allman brings us this convenience of allowing existing + # unicode strings to be joined as a call parameter to a formatting + # string result, allowing nestation: + # + # >>> t.red('This is ', t.bold('extremely'), ' dangerous!') + for idx, ucs_part in enumerate(args): + if not isinstance(ucs_part, six.string_types): + raise TypeError("Positional argument #{idx} is {is_type} " + "expected any of {expected_types}: " + "{ucs_part!r}".format( + idx=idx, ucs_part=ucs_part, + is_type=type(ucs_part), + expected_types=six.string_types, + )) + postfix = u'' + if self and self._normal: + postfix = self._normal + _refresh = self._normal + self + args = [_refresh.join(ucs_part.split(self._normal)) + for ucs_part in args] + + return self + u''.join(args) + postfix + + +class NullCallableString(six.text_type): + """ + A dummy callable Unicode alternative to :class:`FormattingString`. + + This is used for colors on terminals that do not support colors, it is just a basic form of + unicode that may also act as a callable. + """ + + def __new__(cls): + """Class constructor.""" + new = six.text_type.__new__(cls, u'') + return new + + def __call__(self, *args): + """ + Allow empty string to be callable, returning given string, if any. + + When called with an int as the first arg, return an empty Unicode. An + int is a good hint that I am a :class:`ParameterizingString`, as there + are only about half a dozen string-returning capabilities listed in + terminfo(5) which accept non-int arguments, they are seldom used. + + When called with a non-int as the first arg (no no args at all), return + the first arg, acting in place of :class:`FormattingString` without + any attributes. + """ + if not args or isinstance(args[0], int): + # As a NullCallableString, even when provided with a parameter, + # such as t.color(5), we must also still be callable, fe: + # + # >>> t.color(5)('shmoo') + # + # is actually simplified result of NullCallable()() on terminals + # without color support, so turtles all the way down: we return + # another instance. + return NullCallableString() + return u''.join(args) + + +def split_compound(compound): + """ + Split compound formating string into segments. + + >>> split_compound('bold_underline_bright_blue_on_red') + ['bold', 'underline', 'bright_blue', 'on_red'] + + :arg str compound: a string that may contain compounds, separated by + underline (``_``). + :rtype: list + """ + merged_segs = [] + # These occur only as prefixes, so they can always be merged: + mergeable_prefixes = ['on', 'bright', 'on_bright'] + for segment in compound.split('_'): + if merged_segs and merged_segs[-1] in mergeable_prefixes: + merged_segs[-1] += '_' + segment + else: + merged_segs.append(segment) + return merged_segs + + +def resolve_capability(term, attr): + """ + Resolve a raw terminal capability using :func:`tigetstr`. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str attr: terminal capability name. + :returns: string of the given terminal capability named by ``attr``, + which may be empty (u'') if not found or not supported by the + given :attr:`~.Terminal.kind`. + :rtype: str + """ + # Decode sequences as latin1, as they are always 8-bit bytes, so when + # b'\xff' is returned, this must be decoded to u'\xff'. + if not term.does_styling: + return u'' + val = curses.tigetstr(term._sugar.get(attr, attr)) + return u'' if val is None else val.decode('latin1') + + +def resolve_color(term, color): + """ + Resolve a simple color name to a callable capability. + + This function supports :func:`resolve_attribute`. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str color: any string found in set :const:`COLORS`. + :returns: a string class instance which emits the terminal sequence + for the given color, and may be used as a callable to wrap the + given string with such sequence. + :returns: :class:`NullCallableString` when + :attr:`~.Terminal.number_of_colors` is 0, + otherwise :class:`FormattingString`. + :rtype: :class:`NullCallableString` or :class:`FormattingString` + """ + if term.number_of_colors == 0: + return NullCallableString() + + # fg/bg capabilities terminals that support 0-256+ colors. + vga_color_cap = (term._background_color if 'on_' in color else + term._foreground_color) + + base_color = color.rsplit('_', 1)[-1] + if base_color in CGA_COLORS: + # curses constants go up to only 7, so add an offset to get at the + # bright colors at 8-15: + offset = 8 if 'bright_' in color else 0 + base_color = color.rsplit('_', 1)[-1] + attr = 'COLOR_%s' % (base_color.upper(),) + fmt_attr = vga_color_cap(getattr(curses, attr) + offset) + return FormattingString(fmt_attr, term.normal) + + assert base_color in X11_COLORNAMES_TO_RGB, ( + 'color not known', base_color) + rgb = X11_COLORNAMES_TO_RGB[base_color] + + # downconvert X11 colors to CGA, EGA, or VGA color spaces + if term.number_of_colors <= 256: + fmt_attr = vga_color_cap(term.rgb_downconvert(*rgb)) + return FormattingString(fmt_attr, term.normal) + + # Modern 24-bit color terminals are written pretty basically. The + # foreground and background sequences are: + # - ^[38;2;;;m + # - ^[48;2;;;m + fgbg_seq = ('48' if 'on_' in color else '38') + assert term.number_of_colors == 1 << 24 + fmt_attr = u'\x1b[' + fgbg_seq + ';2;{0};{1};{2}m' + return FormattingString(fmt_attr.format(*rgb), term.normal) + + +def resolve_attribute(term, attr): + """ + Resolve a terminal attribute name into a capability class. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str attr: Sugary, ordinary, or compound formatted terminal + capability, such as "red_on_white", "normal", "red", or + "bold_on_black", respectively. + :returns: a string class instance which emits the terminal sequence + for the given terminal capability, or may be used as a callable to + wrap the given string with such sequence. + :returns: :class:`NullCallableString` when + :attr:`~.Terminal.number_of_colors` is 0, + otherwise :class:`FormattingString`. + :rtype: :class:`NullCallableString` or :class:`FormattingString` + """ + if attr in COLORS: + return resolve_color(term, attr) + + # A direct compoundable, such as `bold' or `on_red'. + if attr in COMPOUNDABLES: + sequence = resolve_capability(term, attr) + return FormattingString(sequence, term.normal) + + # Given `bold_on_red', resolve to ('bold', 'on_red'), RECURSIVE + # call for each compounding section, joined and returned as + # a completed completed FormattingString. + formatters = split_compound(attr) + if all(fmt in (COLORS | COMPOUNDABLES) for fmt in formatters): + resolution = (resolve_attribute(term, fmt) for fmt in formatters) + return FormattingString(u''.join(resolution), term.normal) + + # otherwise, this is our end-game: given a sequence such as 'csr' + # (change scrolling region), return a ParameterizingString instance, + # that when called, performs and returns the final string after curses + # capability lookup is performed. + tparm_capseq = resolve_capability(term, attr) + if not tparm_capseq: + # and, for special terminals, such as 'screen', provide a Proxy + # ParameterizingString for attributes they do not claim to support, + # but actually do! (such as 'hpa' and 'vpa'). + proxy = get_proxy_string(term, term._sugar.get(attr, attr)) + if proxy is not None: + return proxy + + return ParameterizingString(tparm_capseq, term.normal, attr) diff --git a/blessed/keyboard.py b/blessed/keyboard.py new file mode 100644 index 00000000..d1ff45a4 --- /dev/null +++ b/blessed/keyboard.py @@ -0,0 +1,459 @@ +"""Sub-module providing 'keyboard awareness'.""" + +# std imports +import re +import time +import platform + +# 3rd party +import six + +try: + from collections import OrderedDict +except ImportError: + # python 2.6 requires 3rd party library (backport) + # + # pylint: disable=import-error + # Unable to import 'ordereddict' + from ordereddict import OrderedDict + +# curses +if platform.system() == 'Windows': + # pylint: disable=import-error + import jinxed as curses + from jinxed.has_key import _capability_names as capability_names +else: + import curses + from curses.has_key import _capability_names as capability_names + + +class Keystroke(six.text_type): + """ + A unicode-derived class for describing a single keystroke. + + A class instance describes a single keystroke received on input, + which may contain multiple characters as a multibyte sequence, + which is indicated by properties :attr:`is_sequence` returning + ``True``. + + When the string is a known sequence, :attr:`code` matches terminal + class attributes for comparison, such as ``term.KEY_LEFT``. + + The string-name of the sequence, such as ``u'KEY_LEFT'`` is accessed + by property :attr:`name`, and is used by the :meth:`__repr__` method + to display a human-readable form of the Keystroke this class + instance represents. It may otherwise by joined, split, or evaluated + just as as any other unicode string. + """ + + def __new__(cls, ucs='', code=None, name=None): + """Class constructor.""" + new = six.text_type.__new__(cls, ucs) + new._name = name + new._code = code + return new + + @property + def is_sequence(self): + """Whether the value represents a multibyte sequence (bool).""" + return self._code is not None + + def __repr__(self): + """Docstring overwritten.""" + return (six.text_type.__repr__(self) if self._name is None else + self._name) + __repr__.__doc__ = six.text_type.__doc__ + + @property + def name(self): + """String-name of key sequence, such as ``u'KEY_LEFT'`` (str).""" + return self._name + + @property + def code(self): + """Integer keycode value of multibyte sequence (int).""" + return self._code + + +def get_curses_keycodes(): + """ + Return mapping of curses key-names paired by their keycode integer value. + + :rtype: dict + + Returns dictionary of (name, code) pairs for curses keyboard constant + values and their mnemonic name. Such as code ``260``, with the value of + its key-name identity, ``u'KEY_LEFT'``. + """ + _keynames = [attr for attr in dir(curses) + if attr.startswith('KEY_')] + return dict((keyname, getattr(curses, keyname)) + for keyname in _keynames) + + +def get_keyboard_codes(): + """ + Return mapping of keycode integer values paired by their curses key-name. + + :rtype: dict + + Returns dictionary of (code, name) pairs for curses keyboard constant + values and their mnemonic name. Such as key ``260``, with the value of + its identity, ``u'KEY_LEFT'``. These are derived from the attributes by + the same of the curses module, with the following exceptions: + + * ``KEY_DELETE`` in place of ``KEY_DC`` + * ``KEY_INSERT`` in place of ``KEY_IC`` + * ``KEY_PGUP`` in place of ``KEY_PPAGE`` + * ``KEY_PGDOWN`` in place of ``KEY_NPAGE`` + * ``KEY_ESCAPE`` in place of ``KEY_EXIT`` + * ``KEY_SUP`` in place of ``KEY_SR`` + * ``KEY_SDOWN`` in place of ``KEY_SF`` + + This function is the inverse of :func:`get_curses_keycodes`. With the + given override "mixins" listed above, the keycode for the delete key will + map to our imaginary ``KEY_DELETE`` mnemonic, effectively erasing the + phrase ``KEY_DC`` from our code vocabulary for anyone that wishes to use + the return value to determine the key-name by keycode. + """ + keycodes = OrderedDict(get_curses_keycodes()) + keycodes.update(CURSES_KEYCODE_OVERRIDE_MIXIN) + # merge _CURSES_KEYCODE_ADDINS added to our module space + keycodes.update({ + name: value + for name, value in globals().items() + if name.startswith('KEY_') + }) + + # invert dictionary (key, values) => (values, key), preferring the + # last-most inserted value ('KEY_DELETE' over 'KEY_DC'). + return dict(zip(keycodes.values(), keycodes.keys())) + + +def _alternative_left_right(term): + r""" + Determine and return mapping of left and right arrow keys sequences. + + :arg blessed.Terminal term: :class:`~.Terminal` instance. + :rtype: dict + + This function supports :func:`get_terminal_sequences` to discover + the preferred input sequence for the left and right application keys. + + Return dict of sequences ``term._cuf1``, and ``term._cub1``, + valued as ``KEY_RIGHT``, ``KEY_LEFT`` (when appropriate). It is + necessary to check the value of these sequences to ensure we do not + use ``u' '`` and ``u'\b'`` for ``KEY_RIGHT`` and ``KEY_LEFT``, + preferring their true application key sequence, instead. + """ + keymap = dict() + if term._cuf1 and term._cuf1 != u' ': + keymap[term._cuf1] = curses.KEY_RIGHT + if term._cub1 and term._cub1 != u'\b': + keymap[term._cub1] = curses.KEY_LEFT + return keymap + + +def get_keyboard_sequences(term): + r""" + Return mapping of keyboard sequences paired by keycodes. + + :arg blessed.Terminal term: :class:`~.Terminal` instance. + :returns: mapping of keyboard unicode sequences paired by keycodes + as integer. This is used as the argument ``mapper`` to + the supporting function :func:`resolve_sequence`. + :rtype: OrderedDict + + Initialize and return a keyboard map and sequence lookup table, + (sequence, keycode) from :class:`~.Terminal` instance ``term``, + where ``sequence`` is a multibyte input sequence of unicode + characters, such as ``u'\x1b[D'``, and ``keycode`` is an integer + value, matching curses constant such as term.KEY_LEFT. + + The return value is an OrderedDict instance, with their keys + sorted longest-first. + """ + # A small gem from curses.has_key that makes this all possible, + # _capability_names: a lookup table of terminal capability names for + # keyboard sequences (fe. kcub1, key_left), keyed by the values of + # constants found beginning with KEY_ in the main curses module + # (such as KEY_LEFT). + # + # latin1 encoding is used so that bytes in 8-bit range of 127-255 + # have equivalent chr() and unichr() values, so that the sequence + # of a kermit or avatar terminal, for example, remains unchanged + # in its byte sequence values even when represented by unicode. + # + sequence_map = dict(( + (seq.decode('latin1'), val) + for (seq, val) in ( + (curses.tigetstr(cap), val) + for (val, cap) in capability_names.items() + ) if seq + ) if term.does_styling else ()) + + sequence_map.update(_alternative_left_right(term)) + sequence_map.update(DEFAULT_SEQUENCE_MIXIN) + + # This is for fast lookup matching of sequences, preferring + # full-length sequence such as ('\x1b[D', KEY_LEFT) + # over simple sequences such as ('\x1b', KEY_EXIT). + return OrderedDict(( + (seq, sequence_map[seq]) for seq in sorted( + sequence_map.keys(), key=len, reverse=True))) + + +def get_leading_prefixes(sequences): + """ + Return a set of proper prefixes for given sequence of strings. + + :arg iterable sequences + :rtype: set + + Given an iterable of strings, all textparts leading up to the final + string is returned as a unique set. This function supports the + :meth:`~.Terminal.inkey` method by determining whether the given + input is a sequence that **may** lead to a final matching pattern. + + >>> prefixes(['abc', 'abdf', 'e', 'jkl']) + set([u'a', u'ab', u'abd', u'j', u'jk']) + """ + return set(seq[:i] for seq in sequences for i in range(1, len(seq))) + + +def resolve_sequence(text, mapper, codes): + r""" + Return :class:`Keystroke` instance for given sequence ``text``. + + The given ``text`` may extend beyond a matching sequence, such as + ``u\x1b[Dxxx`` returns a :class:`Keystroke` instance of attribute + :attr:`Keystroke.sequence` valued only ``u\x1b[D``. It is up to + determine that ``xxx`` remains unresolved. + + :arg text: string of characters received from terminal input stream. + :arg OrderedDict mapper: unicode multibyte sequences, such as ``u'\x1b[D'`` + paired by their integer value (260) + :arg dict codes: a :type:`dict` of integer values (such as 260) paired + by their mnemonic name, such as ``'KEY_LEFT'``. + :rtype: Keystroke + """ + for sequence, code in mapper.items(): + if text.startswith(sequence): + return Keystroke(ucs=sequence, code=code, name=codes[code]) + return Keystroke(ucs=text and text[0] or u'') + + +def _time_left(stime, timeout): + """ + Return time remaining since ``stime`` before given ``timeout``. + + This function assists determining the value of ``timeout`` for + class method :meth:`~.Terminal.kbhit` and similar functions. + + :arg float stime: starting time for measurement + :arg float timeout: timeout period, may be set to None to + indicate no timeout (where None is always returned). + :rtype: float or int + :returns: time remaining as float. If no time is remaining, + then the integer ``0`` is returned. + """ + return max(0, timeout - (time.time() - stime)) if timeout else timeout + + +def _read_until(term, pattern, timeout): + """ + Convenience read-until-pattern function, supporting :meth:`~.get_location`. + + :arg blessed.Terminal term: :class:`~.Terminal` instance. + :arg float timeout: timeout period, may be set to None to indicate no + timeout (where 0 is always returned). + :arg str pattern: target regular expression pattern to seek. + :rtype: tuple + :returns: tuple in form of ``(match, str)``, *match* + may be :class:`re.MatchObject` if pattern is discovered + in input stream before timeout has elapsed, otherwise + None. ``str`` is any remaining text received exclusive + of the matching pattern). + + The reason a tuple containing non-matching data is returned, is that the + consumer should push such data back into the input buffer by + :meth:`~.Terminal.ungetch` if any was received. + + For example, when a user is performing rapid input keystrokes while its + terminal emulator surreptitiously responds to this in-band sequence, we + must ensure any such keyboard data is well-received by the next call to + term.inkey() without delay. + """ + stime = time.time() + match, buf = None, u'' + + # first, buffer all pending data. pexpect library provides a + # 'searchwindowsize' attribute that limits this memory region. We're not + # concerned about OOM conditions: only (human) keyboard input and terminal + # response sequences are expected. + + while True: + # block as long as necessary to ensure at least one character is + # received on input or remaining timeout has elapsed. + ucs = term.inkey(timeout=_time_left(stime, timeout)) + if ucs: + buf += ucs + # while the keyboard buffer is "hot" (has input), we continue to + # aggregate all awaiting data. We do this to ensure slow I/O + # calls do not unnecessarily give up within the first 'while' loop + # for short timeout periods. + while True: + ucs = term.inkey(timeout=0) + if not ucs: + break + buf += ucs + + match = re.search(pattern=pattern, string=buf) + if match is not None: + # match + break + + if timeout is not None: + if not _time_left(stime, timeout): + # timeout + break + + return match, buf + + +#: Though we may determine *keynames* and codes for keyboard input that +#: generate multibyte sequences, it is also especially useful to aliases +#: a few basic ASCII characters such as ``KEY_TAB`` instead of ``u'\t'`` for +#: uniformity. +#: +#: Furthermore, many key-names for application keys enabled only by context +#: manager :meth:`~.Terminal.keypad` are surprisingly absent. We inject them +#: here directly into the curses module. +_CURSES_KEYCODE_ADDINS = ( + 'TAB', + 'KP_MULTIPLY', + 'KP_ADD', + 'KP_SEPARATOR', + 'KP_SUBTRACT', + 'KP_DECIMAL', + 'KP_DIVIDE', + 'KP_EQUAL', + 'KP_0', + 'KP_1', + 'KP_2', + 'KP_3', + 'KP_4', + 'KP_5', + 'KP_6', + 'KP_7', + 'KP_8', + 'KP_9') + +_lastval = max(get_curses_keycodes().values()) +for keycode_name in _CURSES_KEYCODE_ADDINS: + _lastval += 1 + globals()['KEY_' + keycode_name] = _lastval + +#: In a perfect world, terminal emulators would always send exactly what +#: the terminfo(5) capability database plans for them, accordingly by the +#: value of the ``TERM`` name they declare. +#: +#: But this isn't a perfect world. Many vt220-derived terminals, such as +#: those declaring 'xterm', will continue to send vt220 codes instead of +#: their native-declared codes, for backwards-compatibility. +#: +#: This goes for many: rxvt, putty, iTerm. +#: +#: These "mixins" are used for *all* terminals, regardless of their type. +#: +#: Furthermore, curses does not provide sequences sent by the keypad, +#: at least, it does not provide a way to distinguish between keypad 0 +#: and numeric 0. +DEFAULT_SEQUENCE_MIXIN = ( + # these common control characters (and 127, ctrl+'?') mapped to + # an application key definition. + (six.unichr(10), curses.KEY_ENTER), + (six.unichr(13), curses.KEY_ENTER), + (six.unichr(8), curses.KEY_BACKSPACE), + (six.unichr(9), KEY_TAB), # noqa + (six.unichr(27), curses.KEY_EXIT), + (six.unichr(127), curses.KEY_BACKSPACE), + + (u"\x1b[A", curses.KEY_UP), + (u"\x1b[B", curses.KEY_DOWN), + (u"\x1b[C", curses.KEY_RIGHT), + (u"\x1b[D", curses.KEY_LEFT), + (u"\x1b[F", curses.KEY_END), + (u"\x1b[H", curses.KEY_HOME), + # not sure where these are from .. please report + (u"\x1b[K", curses.KEY_END), + (u"\x1b[U", curses.KEY_NPAGE), + (u"\x1b[V", curses.KEY_PPAGE), + + # keys sent after term.smkx (keypad_xmit) is emitted, source: + # http://www.xfree86.org/current/ctlseqs.html#PC-Style%20Function%20Keys + # http://fossies.org/linux/rxvt/doc/rxvtRef.html#KeyCodes + # + # keypad, numlock on + (u"\x1bOM", curses.KEY_ENTER), # noqa return + (u"\x1bOj", KEY_KP_MULTIPLY), # noqa * + (u"\x1bOk", KEY_KP_ADD), # noqa + + (u"\x1bOl", KEY_KP_SEPARATOR), # noqa , + (u"\x1bOm", KEY_KP_SUBTRACT), # noqa - + (u"\x1bOn", KEY_KP_DECIMAL), # noqa . + (u"\x1bOo", KEY_KP_DIVIDE), # noqa / + (u"\x1bOX", KEY_KP_EQUAL), # noqa = + (u"\x1bOp", KEY_KP_0), # noqa 0 + (u"\x1bOq", KEY_KP_1), # noqa 1 + (u"\x1bOr", KEY_KP_2), # noqa 2 + (u"\x1bOs", KEY_KP_3), # noqa 3 + (u"\x1bOt", KEY_KP_4), # noqa 4 + (u"\x1bOu", KEY_KP_5), # noqa 5 + (u"\x1bOv", KEY_KP_6), # noqa 6 + (u"\x1bOw", KEY_KP_7), # noqa 7 + (u"\x1bOx", KEY_KP_8), # noqa 8 + (u"\x1bOy", KEY_KP_9), # noqa 9 + + # keypad, numlock off + (u"\x1b[1~", curses.KEY_FIND), # find + (u"\x1b[2~", curses.KEY_IC), # insert (0) + (u"\x1b[3~", curses.KEY_DC), # delete (.), "Execute" + (u"\x1b[4~", curses.KEY_SELECT), # select + (u"\x1b[5~", curses.KEY_PPAGE), # pgup (9) + (u"\x1b[6~", curses.KEY_NPAGE), # pgdown (3) + (u"\x1b[7~", curses.KEY_HOME), # home + (u"\x1b[8~", curses.KEY_END), # end + (u"\x1b[OA", curses.KEY_UP), # up (8) + (u"\x1b[OB", curses.KEY_DOWN), # down (2) + (u"\x1b[OC", curses.KEY_RIGHT), # right (6) + (u"\x1b[OD", curses.KEY_LEFT), # left (4) + (u"\x1b[OF", curses.KEY_END), # end (1) + (u"\x1b[OH", curses.KEY_HOME), # home (7) + + # The vt220 placed F1-F4 above the keypad, in place of actual + # F1-F4 were local functions (hold screen, print screen, + # set up, data/talk, break). + (u"\x1bOP", curses.KEY_F1), + (u"\x1bOQ", curses.KEY_F2), + (u"\x1bOR", curses.KEY_F3), + (u"\x1bOS", curses.KEY_F4), +) + +#: Override mixins for a few curses constants with easier +#: mnemonics: there may only be a 1:1 mapping when only a +#: keycode (int) is given, where these phrases are preferred. +CURSES_KEYCODE_OVERRIDE_MIXIN = ( + ('KEY_DELETE', curses.KEY_DC), + ('KEY_INSERT', curses.KEY_IC), + ('KEY_PGUP', curses.KEY_PPAGE), + ('KEY_PGDOWN', curses.KEY_NPAGE), + ('KEY_ESCAPE', curses.KEY_EXIT), + ('KEY_SUP', curses.KEY_SR), + ('KEY_SDOWN', curses.KEY_SF), + ('KEY_UP_LEFT', curses.KEY_A1), + ('KEY_UP_RIGHT', curses.KEY_A3), + ('KEY_CENTER', curses.KEY_B2), + ('KEY_BEGIN', curses.KEY_BEG), +) + +__all__ = ('Keystroke', 'get_keyboard_codes', 'get_keyboard_sequences',) diff --git a/blessed/sequences.py b/blessed/sequences.py new file mode 100644 index 00000000..dd15040c --- /dev/null +++ b/blessed/sequences.py @@ -0,0 +1,433 @@ +# encoding: utf-8 +"""Module providing 'sequence awareness'.""" +# std imports +import re +import math +import textwrap +import functools + +# 3rd party +import six +import wcwidth + +# local +from blessed._capabilities import CAPABILITIES_CAUSE_MOVEMENT + +__all__ = ('Sequence', 'SequenceTextWrapper', 'iter_parse', 'measure_length') + + +class Termcap(object): + """Terminal capability of given variable name and pattern.""" + + def __init__(self, name, pattern, attribute): + """ + Class initializer. + + :arg str name: name describing capability. + :arg str pattern: regular expression string. + :arg str attribute: :class:`~.Terminal` attribute used to build + this terminal capability. + """ + self.name = name + self.pattern = pattern + self.attribute = attribute + self._re_compiled = None + + def __repr__(self): + # pylint: disable=redundant-keyword-arg + return ''.format(self=self) + + @property + def named_pattern(self): + # pylint: disable=redundant-keyword-arg + return '(?P<{self.name}>{self.pattern})'.format(self=self) + + @property + def re_compiled(self): + if self._re_compiled is None: + self._re_compiled = re.compile(self.pattern) + return self._re_compiled + + @property + def will_move(self): + """Whether capability causes cursor movement.""" + return self.name in CAPABILITIES_CAUSE_MOVEMENT + + def horizontal_distance(self, text): + """ + Horizontal carriage adjusted by capability, may be negative. + + :rtype: int + :arg str text: for capabilities *parm_left_cursor*, + *parm_right_cursor*, provide the matching sequence + text, its interpreted distance is returned. + + :returns: 0 except for matching ' + """ + value = { + 'cursor_left': -1, + 'backspace': -1, + 'cursor_right': 1, + 'tab': 8, + 'ascii_tab': 8, + }.get(self.name, None) + if value is not None: + return value + + unit = { + 'parm_left_cursor': -1, + 'parm_right_cursor': 1 + }.get(self.name, None) + if unit is not None: + value = int(self.re_compiled.match(text).group(1)) + return unit * value + + return 0 + + # pylint: disable=too-many-arguments + @classmethod + def build(cls, name, capability, attribute, nparams=0, + numeric=99, match_grouped=False, match_any=False, + match_optional=False): + r""" + Class factory builder for given capability definition. + + :arg str name: Variable name given for this pattern. + :arg str capability: A unicode string representing a terminal + capability to build for. When ``nparams`` is non-zero, it + must be a callable unicode string (such as the result from + ``getattr(term, 'bold')``. + :arg attribute: The terminfo(5) capability name by which this + pattern is known. + :arg int nparams: number of positional arguments for callable. + :arg bool match_grouped: If the numeric pattern should be + grouped, ``(\d+)`` when ``True``, ``\d+`` default. + :arg bool match_any: When keyword argument ``nparams`` is given, + *any* numeric found in output is suitable for building as + pattern ``(\d+)``. Otherwise, only the first matching value of + range *(numeric - 1)* through *(numeric + 1)* will be replaced by + pattern ``(\d+)`` in builder. + :arg bool match_optional: When ``True``, building of numeric patterns + containing ``(\d+)`` will be built as optional, ``(\d+)?``. + """ + _numeric_regex = r'\d+' + if match_grouped: + _numeric_regex = r'(\d+)' + if match_optional: + _numeric_regex = r'(\d+)?' + numeric = 99 if numeric is None else numeric + + # basic capability attribute, not used as a callable + if nparams == 0: + return cls(name, re.escape(capability), attribute) + + # a callable capability accepting numeric argument + _outp = re.escape(capability(*(numeric,) * nparams)) + if not match_any: + for num in range(numeric - 1, numeric + 2): + if str(num) in _outp: + pattern = _outp.replace(str(num), _numeric_regex) + return cls(name, pattern, attribute) + + if match_grouped: + pattern = re.sub(r'(\d+)', lambda x: _numeric_regex, _outp) + else: + pattern = re.sub(r'\d+', lambda x: _numeric_regex, _outp) + return cls(name, pattern, attribute) + + +class SequenceTextWrapper(textwrap.TextWrapper): + """Docstring overridden.""" + + def __init__(self, width, term, **kwargs): + """ + Class initializer. + + This class supports the :meth:`~.Terminal.wrap` method. + """ + self.term = term + textwrap.TextWrapper.__init__(self, width, **kwargs) + + def _wrap_chunks(self, chunks): + """ + Sequence-aware variant of :meth:`textwrap.TextWrapper._wrap_chunks`. + + This simply ensures that word boundaries are not broken mid-sequence, as standard python + textwrap would incorrectly determine the length of a string containing sequences, and may + also break consider sequences part of a "word" that may be broken by hyphen (``-``), where + this implementation corrects both. + """ + lines = [] + if self.width <= 0 or not isinstance(self.width, int): + raise ValueError( + "invalid width {0!r}({1!r}) (must be integer > 0)" + .format(self.width, type(self.width))) + + term = self.term + drop_whitespace = not hasattr(self, 'drop_whitespace' + ) or self.drop_whitespace + chunks.reverse() + while chunks: + cur_line = [] + cur_len = 0 + if lines: + indent = self.subsequent_indent + else: + indent = self.initial_indent + width = self.width - len(indent) + if drop_whitespace and ( + Sequence(chunks[-1], term).strip() == '' and lines): + del chunks[-1] + while chunks: + chunk_len = Sequence(chunks[-1], term).length() + if cur_len + chunk_len <= width: + cur_line.append(chunks.pop()) + cur_len += chunk_len + else: + break + if chunks and Sequence(chunks[-1], term).length() > width: + self._handle_long_word(chunks, cur_line, cur_len, width) + if drop_whitespace and ( + cur_line and Sequence(cur_line[-1], term).strip() == ''): + del cur_line[-1] + if cur_line: + lines.append(indent + u''.join(cur_line)) + return lines + + def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): + """ + Sequence-aware :meth:`textwrap.TextWrapper._handle_long_word`. + + This simply ensures that word boundaries are not broken mid-sequence, as standard python + textwrap would incorrectly determine the length of a string containing sequences, and may + also break consider sequences part of a "word" that may be broken by hyphen (``-``), where + this implementation corrects both. + """ + # Figure out when indent is larger than the specified width, and make + # sure at least one character is stripped off on every pass + if width < 1: + space_left = 1 + else: + space_left = width - cur_len + + # If we're allowed to break long words, then do so: put as much + # of the next chunk onto the current line as will fit. + + if self.break_long_words: + term = self.term + chunk = reversed_chunks[-1] + idx = nxt = 0 + for text, _ in iter_parse(term, chunk): + nxt += len(text) + if Sequence(chunk[:nxt], term).length() > space_left: + break + idx = nxt + cur_line.append(chunk[:idx]) + reversed_chunks[-1] = chunk[idx:] + + # Otherwise, we have to preserve the long word intact. Only add + # it to the current line if there's nothing already there -- + # that minimizes how much we violate the width constraint. + elif not cur_line: + cur_line.append(reversed_chunks.pop()) + + # If we're not allowed to break long words, and there's already + # text on the current line, do nothing. Next time through the + # main loop of _wrap_chunks(), we'll wind up here again, but + # cur_len will be zero, so the next line will be entirely + # devoted to the long word that we can't handle right now. + + +SequenceTextWrapper.__doc__ = textwrap.TextWrapper.__doc__ + + +class Sequence(six.text_type): + """ + A "sequence-aware" version of the base :class:`str` class. + + This unicode-derived class understands the effect of escape sequences + of printable length, allowing a properly implemented :meth:`rjust`, + :meth:`ljust`, :meth:`center`, and :meth:`length`. + """ + + def __new__(cls, sequence_text, term): + """ + Class constructor. + + :arg sequence_text: A string that may contain sequences. + :arg blessed.Terminal term: :class:`~.Terminal` instance. + """ + new = six.text_type.__new__(cls, sequence_text) + new._term = term + return new + + def ljust(self, width, fillchar=u' '): + """ + Return string containing sequences, left-adjusted. + + :arg int width: Total width given to right-adjust ``text``. If + unspecified, the width of the attached terminal is used (default). + :arg str fillchar: String for padding right-of ``text``. + :returns: String of ``text``, right-aligned by ``width``. + :rtype: str + """ + rightside = fillchar * int( + (max(0.0, float(width - self.length()))) / float(len(fillchar))) + return u''.join((self, rightside)) + + def rjust(self, width, fillchar=u' '): + """ + Return string containing sequences, right-adjusted. + + :arg int width: Total width given to right-adjust ``text``. If + unspecified, the width of the attached terminal is used (default). + :arg str fillchar: String for padding left-of ``text``. + :returns: String of ``text``, right-aligned by ``width``. + :rtype: str + """ + leftside = fillchar * int( + (max(0.0, float(width - self.length()))) / float(len(fillchar))) + return u''.join((leftside, self)) + + def center(self, width, fillchar=u' '): + """ + Return string containing sequences, centered. + + :arg int width: Total width given to center ``text``. If + unspecified, the width of the attached terminal is used (default). + :arg str fillchar: String for padding left and right-of ``text``. + :returns: String of ``text``, centered by ``width``. + :rtype: str + """ + split = max(0.0, float(width) - self.length()) / 2 + leftside = fillchar * int( + (max(0.0, math.floor(split))) / float(len(fillchar))) + rightside = fillchar * int( + (max(0.0, math.ceil(split))) / float(len(fillchar))) + return u''.join((leftside, self, rightside)) + + def length(self): + r""" + Return the printable length of string containing sequences. + + Strings containing ``term.left`` or ``\b`` will cause "overstrike", + but a length less than 0 is not ever returned. So ``_\b+`` is a + length of 1 (displays as ``+``), but ``\b`` alone is simply a + length of 0. + + Some characters may consume more than one cell, mainly those CJK + Unified Ideographs (Chinese, Japanese, Korean) defined by Unicode + as half or full-width characters. + """ + # because control characters may return -1, "clip" their length to 0. + clip = functools.partial(max, 0) + return sum(clip(wcwidth.wcwidth(w_char)) + for w_char in self.strip_seqs()) + + # we require ur"" for the docstring, but it is not supported by all + # python versions. + if length.__doc__ is not None: + length.__doc__ += ( + u""" + For example: + + >>> from blessed import Terminal + >>> from blessed.sequences import Sequence + >>> term = Terminal() + >>> msg = term.clear + term.red(u'コンニチハ'), term + >>> Sequence(msg).length() + 10 + + .. note:: Although accounted for, strings containing sequences such + as ``term.clear`` will not give accurate returns, it is not + considered lengthy (a length of 0). + """) + + def strip(self, chars=None): + """ + Return string of sequences, leading, and trailing whitespace removed. + + :arg str chars: Remove characters in chars instead of whitespace. + :rtype: str + """ + return self.strip_seqs().strip(chars) + + def lstrip(self, chars=None): + """ + Return string of all sequences and leading whitespace removed. + + :arg str chars: Remove characters in chars instead of whitespace. + :rtype: str + """ + return self.strip_seqs().lstrip(chars) + + def rstrip(self, chars=None): + """ + Return string of all sequences and trailing whitespace removed. + + :arg str chars: Remove characters in chars instead of whitespace. + :rtype: str + """ + return self.strip_seqs().rstrip(chars) + + def strip_seqs(self): + """ + Return ``text`` stripped of only its terminal sequences. + + :rtype: str + """ + gen = iter_parse(self._term, self.padd()) + return u''.join(text for text, cap in gen if not cap) + + def padd(self): + """ + Return non-destructive horizontal movement as destructive spacing. + + :rtype: str + """ + outp = '' + for text, cap in iter_parse(self._term, self): + if not cap: + outp += text + continue + + value = cap.horizontal_distance(text) + if value > 0: + outp += ' ' * value + elif value < 0: + outp = outp[:value] + else: + outp += text + return outp + + +def iter_parse(term, text): + """ + Generator yields (text, capability) for characters of ``text``. + + value for ``capability`` may be ``None``, where ``text`` is + :class:`str` of length 1. Otherwise, ``text`` is a full + matching sequence of given capability. + """ + for match in re.finditer(term._caps_compiled_any, text): + name = match.lastgroup + value = match.group(name) + if name == 'MISMATCH': + yield (value, None) + else: + yield value, term.caps[name] + + +def measure_length(text, term): + """ + .. deprecated:: 1.12.0. + + :rtype: int + """ + try: + text, capability = next(iter_parse(term, text)) + if capability: + return len(text) + except StopIteration: + return 0 + return 0 diff --git a/blessed/terminal.py b/blessed/terminal.py new file mode 100644 index 00000000..7e47e8aa --- /dev/null +++ b/blessed/terminal.py @@ -0,0 +1,1296 @@ +# encoding: utf-8 +"""Module containing :class:`Terminal`, the primary API entry point.""" +# std imports +import io +import os +import re +import sys +import time +import codecs +import locale +import select +import struct +import platform +import warnings +import functools +import contextlib +import collections + +# local +from .color import COLOR_DISTANCE_ALGORITHMS +from .keyboard import (_time_left, + _read_until, + resolve_sequence, + get_keyboard_codes, + get_leading_prefixes, + get_keyboard_sequences) +from .sequences import Termcap, Sequence, SequenceTextWrapper +from .colorspace import RGB_256TABLE +from .formatters import (COLORS, + FormattingString, + NullCallableString, + ParameterizingString, + resolve_attribute, + resolve_capability) +from ._capabilities import CAPABILITY_DATABASE, CAPABILITIES_ADDITIVES, CAPABILITIES_RAW_MIXIN + +try: + InterruptedError +except NameError: + # alias py2 exception to py3 + # pylint: disable=redefined-builtin + InterruptedError = select.error + +try: + from collections import OrderedDict +except ImportError: + # python 2.6 requires 3rd party library (backport) + # + # pylint: disable=import-error + # Unable to import 'ordereddict' + from ordereddict import OrderedDict + + +HAS_TTY = True +if platform.system() == 'Windows': + import jinxed as curses # pylint: disable=import-error +else: + import curses + + try: + import termios + import fcntl + import tty + except ImportError: + _TTY_METHODS = ('setraw', 'cbreak', 'kbhit', 'height', 'width') + _MSG_NOSUPPORT = ( + "One or more of the modules: 'termios', 'fcntl', and 'tty' " + "are not found on your platform '{platform}'. " + "The following methods of Terminal are dummy/no-op " + "unless a deriving class overrides them: {tty_methods}." + .format(platform=platform.system(), + tty_methods=', '.join(_TTY_METHODS))) + warnings.warn(_MSG_NOSUPPORT) + HAS_TTY = False + +_CUR_TERM = None # See comments at end of file + + +class Terminal(object): + """ + An abstraction for color, style, positioning, and input in the terminal. + + This keeps the endless calls to ``tigetstr()`` and ``tparm()`` out of your code, acts + intelligently when somebody pipes your output to a non-terminal, and abstracts over the + complexity of unbuffered keyboard input. It uses the terminfo database to remain portable across + terminal types. + """ + # pylint: disable=too-many-instance-attributes,too-many-public-methods + # Too many public methods (28/20) + # Too many instance attributes (12/7) + + #: Sugary names for commonly-used capabilities + _sugar = dict( + save='sc', + restore='rc', + clear_eol='el', + clear_bol='el1', + clear_eos='ed', + enter_fullscreen='smcup', + exit_fullscreen='rmcup', + move='cup', + move_yx='cup', + move_x='hpa', + move_y='vpa', + move_left='cub1', + move_right='cuf1', + move_up='cuu1', + move_down='cud1', + hide_cursor='civis', + normal_cursor='cnorm', + reset_colors='op', + normal='sgr0', + reverse='rev', + italic='sitm', + no_italic='ritm', + shadow='sshm', + no_shadow='rshm', + standout='smso', + no_standout='rmso', + subscript='ssubm', + no_subscript='rsubm', + superscript='ssupm', + no_superscript='rsupm', + underline='smul', + no_underline='rmul', + cursor_report='u6', + cursor_request='u7', + terminal_answerback='u8', + terminal_enquire='u9', + ) + + def __init__(self, kind=None, stream=None, force_styling=False): + """ + Initialize the terminal. + + :arg str kind: A terminal string as taken by :func:`curses.setupterm`. + Defaults to the value of the ``TERM`` environment variable. + + .. note:: Terminals withing a single process must share a common + ``kind``. See :obj:`_CUR_TERM`. + + :arg file stream: A file-like object representing the Terminal output. + Defaults to the original value of :obj:`sys.__stdout__`, like + :func:`curses.initscr` does. + + If ``stream`` is not a tty, empty Unicode strings are returned for + all capability values, so things like piping your program output to + a pipe or file does not emit terminal sequences. + + :arg bool force_styling: Whether to force the emission of capabilities + even if :obj:`sys.__stdout__` does not seem to be connected to a + terminal. If you want to force styling to not happen, use + ``force_styling=None``. + + This comes in handy if users are trying to pipe your output through + something like ``less -r`` or build systems which support decoding + of terminal sequences. + """ + # pylint: disable=global-statement,too-many-branches + global _CUR_TERM + self._keyboard_fd = None + + # Default stream is stdout, keyboard valid as stdin only when + # output stream is stdout or stderr and is a tty. + if stream is None: + stream = sys.__stdout__ + if stream in (sys.__stdout__, sys.__stderr__): + self._keyboard_fd = sys.__stdin__.fileno() + + # we assume our input stream to be line-buffered until either the + # cbreak of raw context manager methods are entered with an + # attached tty. + self._line_buffered = True + + try: + stream_fd = (stream.fileno() if hasattr(stream, 'fileno') and + callable(stream.fileno) else None) + except (io.UnsupportedOperation, ValueError): + # The stream is not a file, such as the case of StringIO, or, when it has been + # "detached", such as might be the case of stdout in some test scenarios. + stream_fd = None + + self._stream = stream + self._is_a_tty = stream_fd is not None and os.isatty(stream_fd) + self._does_styling = ((self.is_a_tty or force_styling) and + force_styling is not None) + + # _keyboard_fd only non-None if both stdin and stdout is a tty. + self._keyboard_fd = (self._keyboard_fd + if self._keyboard_fd is not None and + self.is_a_tty and os.isatty(self._keyboard_fd) + else None) + self._normal = None # cache normal attr, preventing recursive lookups + + # The descriptor to direct terminal initialization sequences to. + self._init_descriptor = (sys.__stdout__.fileno() if stream_fd is None + else stream_fd) + + if platform.system() == 'Windows': + self._kind = kind or curses.get_term(self._init_descriptor) + else: + self._kind = kind or os.environ.get('TERM', 'unknown') + + if self.does_styling: + # Initialize curses (call setupterm), so things like tigetstr() work. + try: + curses.setupterm(self._kind, open(os.devnull).fileno()) + except curses.error as err: + warnings.warn('Failed to setupterm(kind={0!r}): {1}' + .format(self._kind, err)) + self._kind = None + self._does_styling = False + else: + if _CUR_TERM is None or self._kind == _CUR_TERM: + _CUR_TERM = self._kind + else: + # termcap 'kind' is immutable in a python process! Once + # initialized by setupterm, it is unsupported by the + # 'curses' module to change the terminal type again. If you + # are a downstream developer and you need this + # functionality, consider sub-processing, instead. + warnings.warn( + 'A terminal of kind "%s" has been requested; due to an' + ' internal python curses bug, terminal capabilities' + ' for a terminal of kind "%s" will continue to be' + ' returned for the remainder of this process.' % ( + self._kind, _CUR_TERM,)) + + self.__init__color_capabilities() + self.__init__capabilities() + self.__init__keycodes() + + def __init__color_capabilities(self): + self._color_distance_algorithm = 'cie2000' + if not self.does_styling: + self.number_of_colors = 0 + elif platform.system() == 'Windows' or ( + os.environ.get('COLORTERM') in ('truecolor', '24bit') + ): + self.number_of_colors = 1 << 24 + else: + self.number_of_colors = max(0, curses.tigetnum('colors') or -1) + + def __clear_color_capabilities(self): + for cached_color_cap in set(dir(self)) & COLORS: + delattr(self, cached_color_cap) + + def __init__capabilities(self): + # important that we lay these in their ordered direction, so that our + # preferred, 'color' over 'set_a_attributes1', for example. + self.caps = OrderedDict() + + # some static injected patterns, esp. without named attribute access. + for name, (attribute, pattern) in CAPABILITIES_ADDITIVES.items(): + self.caps[name] = Termcap(name, pattern, attribute) + + for name, (attribute, kwds) in CAPABILITY_DATABASE.items(): + if self.does_styling: + # attempt dynamic lookup + cap = getattr(self, attribute) + if cap: + self.caps[name] = Termcap.build( + name, cap, attribute, **kwds) + continue + + # fall-back + pattern = CAPABILITIES_RAW_MIXIN.get(name) + if pattern: + self.caps[name] = Termcap(name, pattern, attribute) + + # make a compiled named regular expression table + self.caps_compiled = re.compile( + '|'.join(cap.pattern for name, cap in self.caps.items())) + + # for tokenizer, the '.lastgroup' is the primary lookup key for + # 'self.caps', unless 'MISMATCH'; then it is an unmatched character. + self._caps_compiled_any = re.compile('|'.join( + cap.named_pattern for name, cap in self.caps.items() + ) + '|(?P.)') + self._caps_unnamed_any = re.compile('|'.join( + '({0})'.format(cap.pattern) for name, cap in self.caps.items() + ) + '|(.)') + + def __init__keycodes(self): + # Initialize keyboard data determined by capability. + # Build database of int code <=> KEY_NAME. + self._keycodes = get_keyboard_codes() + + # Store attributes as: self.KEY_NAME = code. + for key_code, key_name in self._keycodes.items(): + setattr(self, key_name, key_code) + + # Build database of sequence <=> KEY_NAME. + self._keymap = get_keyboard_sequences(self) + + # build set of prefixes of sequences + self._keymap_prefixes = get_leading_prefixes(self._keymap) + + # keyboard stream buffer + self._keyboard_buf = collections.deque() + + if self._keyboard_fd is not None: + # set input encoding and initialize incremental decoder + if platform.system() == 'Windows' and sys.version_info[0] < 3: + # Default for setlocale() has side effects for PY2 on Windows + pass + else: + locale.setlocale(locale.LC_ALL, '') + self._encoding = locale.getpreferredencoding() or 'ascii' + + try: + self._keyboard_decoder = codecs.getincrementaldecoder( + self._encoding)() + + except LookupError as err: + # encoding is illegal or unsupported, use 'ascii' + warnings.warn('LookupError: {0}, fallback to ASCII for ' + 'keyboard.'.format(err)) + self._encoding = 'ascii' + self._keyboard_decoder = codecs.getincrementaldecoder( + self._encoding)() + + def __getattr__(self, attr): + r""" + Return a terminal capability as Unicode string. + + For example, ``term.bold`` is a unicode string that may be prepended + to text to set the video attribute for bold, which should also be + terminated with the pairing :attr:`normal`. This capability + returns a callable, so you can use ``term.bold("hi")`` which + results in the joining of ``(term.bold, "hi", term.normal)``. + + Compound formatters may also be used. For example:: + + >>> term.bold_blink_red_on_green("merry x-mas!") + + For a parameterized capability such as ``move`` (or ``cup``), pass the + parameters as positional arguments:: + + >>> term.move(line, column) + + See the manual page `terminfo(5) + `_ for a + complete list of capabilities and their arguments. + """ + if not self.does_styling: + return NullCallableString() + # Fetch the missing 'attribute' into some kind of curses-resolved + # capability, and cache by attaching to this Terminal class instance. + # + # Note that this will prevent future calls to __getattr__(), but + # that's precisely the idea of the cache! + val = resolve_attribute(self, attr) + setattr(self, attr, val) + return val + + @property + def kind(self): + """ + Read-only property: Terminal kind determined on class initialization. + + :rtype: str + """ + return self._kind + + @property + def does_styling(self): + """ + Read-only property: Whether this class instance may emit sequences. + + :rtype: bool + """ + return self._does_styling + + @property + def is_a_tty(self): + """ + Read-only property: Whether :attr:`~.stream` is a terminal. + + :rtype: bool + """ + return self._is_a_tty + + @property + def height(self): + """ + Read-only property: Height of the terminal (in number of lines). + + :rtype: int + """ + return self._height_and_width().ws_row + + @property + def width(self): + """ + Read-only property: Width of the terminal (in number of columns). + + :rtype: int + """ + return self._height_and_width().ws_col + + @staticmethod + def _winsize(fd): + """ + Return named tuple describing size of the terminal by ``fd``. + + If the given platform does not have modules :mod:`termios`, + :mod:`fcntl`, or :mod:`tty`, window size of 80 columns by 25 + rows is always returned. + + :arg int fd: file descriptor queries for its window size. + :raises IOError: the file descriptor ``fd`` is not a terminal. + :rtype: WINSZ + + WINSZ is a :class:`collections.namedtuple` instance, whose structure + directly maps to the return value of the :const:`termios.TIOCGWINSZ` + ioctl return value. The return parameters are: + + - ``ws_row``: width of terminal by its number of character cells. + - ``ws_col``: height of terminal by its number of character cells. + - ``ws_xpixel``: width of terminal by pixels (not accurate). + - ``ws_ypixel``: height of terminal by pixels (not accurate). + """ + if HAS_TTY: + data = fcntl.ioctl(fd, termios.TIOCGWINSZ, WINSZ._BUF) + return WINSZ(*struct.unpack(WINSZ._FMT, data)) + return WINSZ(ws_row=25, ws_col=80, ws_xpixel=0, ws_ypixel=0) + + def _height_and_width(self): + """ + Return a tuple of (terminal height, terminal width). + + If :attr:`stream` or :obj:`sys.__stdout__` is not a tty or does not + support :func:`fcntl.ioctl` of :const:`termios.TIOCGWINSZ`, a window + size of 80 columns by 25 rows is returned for any values not + represented by environment variables ``LINES`` and ``COLUMNS``, which + is the default text mode of IBM PC compatibles. + + :rtype: WINSZ + + WINSZ is a :class:`collections.namedtuple` instance, whose structure + directly maps to the return value of the :const:`termios.TIOCGWINSZ` + ioctl return value. The return parameters are: + + - ``ws_row``: width of terminal by its number of character cells. + - ``ws_col``: height of terminal by its number of character cells. + - ``ws_xpixel``: width of terminal by pixels (not accurate). + - ``ws_ypixel``: height of terminal by pixels (not accurate). + """ + for fd in (self._init_descriptor, sys.__stdout__): + try: + if fd is not None: + return self._winsize(fd) + except (IOError, OSError, ValueError, TypeError): + pass + + return WINSZ(ws_row=int(os.getenv('LINES', '25')), + ws_col=int(os.getenv('COLUMNS', '80')), + ws_xpixel=None, + ws_ypixel=None) + + @contextlib.contextmanager + def location(self, x=None, y=None): + """ + Context manager for temporarily moving the cursor. + + Move the cursor to a certain position on entry, let you print stuff + there, then return the cursor to its original position:: + + term = Terminal() + with term.location(2, 5): + for x in xrange(10): + print('I can do it %i times!' % x) + print('We're back to the original location.') + + Specify ``x`` to move to a certain column, ``y`` to move to a certain + row, both, or neither. If you specify neither, only the saving and + restoration of cursor position will happen. This can be useful if you + simply want to restore your place after doing some manual cursor + movement. + + .. note:: The store- and restore-cursor capabilities used internally + provide no stack. This means that :meth:`location` calls cannot be + nested: only one should be entered at a time. + """ + # pylint: disable=invalid-name + # Invalid argument name "x" + + # Save position and move to the requested column, row, or both: + self.stream.write(self.save) + if x is not None and y is not None: + self.stream.write(self.move(y, x)) + elif x is not None: + self.stream.write(self.move_x(x)) + elif y is not None: + self.stream.write(self.move_y(y)) + try: + self.stream.flush() + yield + finally: + # Restore original cursor position: + self.stream.write(self.restore) + self.stream.flush() + + def get_location(self, timeout=None): + r""" + Return tuple (row, column) of cursor position. + + :arg float timeout: Return after time elapsed in seconds with value + ``(-1, -1)`` indicating that the remote end did not respond. + :rtype: tuple + :returns: cursor position as tuple in form of (row, column). + + The location of the cursor is determined by emitting the ``u7`` + terminal capability, or VT100 `Query Cursor Position + `_ when such + capability is undefined, which elicits a response from a reply string + described by capability ``u6``, or again VT100's definition of + ``\x1b[%i%d;%dR`` when undefined. + + The ``(row, col)`` return value matches the parameter order of the + ``move`` capability, so that the following sequence should cause the + cursor to not move at all:: + + >>> term = Terminal() + >>> term.move(*term.get_location())) + + .. warning:: You might first test that a terminal is capable of + informing you of its location, while using a timeout, before + later calling. When a timeout is specified, always ensure the + return value is conditionally checked for ``(-1, -1)``. + """ + # Local lines attached by termios and remote login protocols such as + # ssh and telnet both provide a means to determine the window + # dimensions of a connected client, but **no means to determine the + # location of the cursor**. + # + # from http://invisible-island.net/ncurses/terminfo.src.html, + # + # > The System V Release 4 and XPG4 terminfo format defines ten string + # > capabilities for use by applications, .... In this file, + # > we use certain of these capabilities to describe functions which + # > are not covered by terminfo. The mapping is as follows: + # > + # > u9 terminal enquire string (equiv. to ANSI/ECMA-48 DA) + # > u8 terminal answerback description + # > u7 cursor position request (equiv. to VT100/ANSI/ECMA-48 DSR 6) + # > u6 cursor position report (equiv. to ANSI/ECMA-48 CPR) + query_str = self.u7 or u'\x1b[6n' + + # determine response format as a regular expression + response_re = self.caps['cursor_report'].re_compiled + + # Avoid changing user's desired raw or cbreak mode if already entered, + # by entering cbreak mode ourselves. This is necessary to receive user + # input without awaiting a human to press the return key. This mode + # also disables echo, which we should also hide, as our input is an + # sequence that is not meaningful for display as an output sequence. + + ctx = None + try: + if self._line_buffered: + ctx = self.cbreak() + ctx.__enter__() + + # emit the 'query cursor position' sequence, + self.stream.write(query_str) + self.stream.flush() + + # expect a response, + match, data = _read_until(term=self, + pattern=response_re, + timeout=timeout) + + # ensure response sequence is excluded from subsequent input, + if match: + data = (data[:match.start()] + data[match.end():]) + + # re-buffer keyboard data, if any + self.ungetch(data) + + if match: + # return matching sequence response, the cursor location. + row, col = (int(val) for val in match.groups()) + + # Per https://invisible-island.net/ncurses/terminfo.src.html + # The cursor position report () string must contain two + # scanf(3)-style %d format elements. The first of these must + # correspond to the Y coordinate and the second to the %d. + # If the string contains the sequence %i, it is taken as an + # instruction to decrement each value after reading it (this is + # the inverse sense from the cup string). + if '%i' in self.cursor_report: + row -= 1 + col -= 1 + return row, col + + finally: + if ctx is not None: + ctx.__exit__(None, None, None) + + # We chose to return an illegal value rather than an exception, + # favoring that users author function filters, such as max(0, y), + # rather than crowbarring such logic into an exception handler. + return -1, -1 + + @contextlib.contextmanager + def fullscreen(self): + """ + Context manager that switches to secondary screen, restoring on exit. + + Under the hood, this switches between the primary screen buffer and + the secondary one. The primary one is saved on entry and restored on + exit. Likewise, the secondary contents are also stable and are + faithfully restored on the next entry:: + + with term.fullscreen(): + main() + + .. note:: There is only one primary and one secondary screen buffer. + :meth:`fullscreen` calls cannot be nested, only one should be + entered at a time. + """ + self.stream.write(self.enter_fullscreen) + self.stream.flush() + try: + yield + finally: + self.stream.write(self.exit_fullscreen) + self.stream.flush() + + @contextlib.contextmanager + def hidden_cursor(self): + """ + Context manager that hides the cursor, setting visibility on exit. + + with term.hidden_cursor(): + main() + + .. note:: :meth:`hidden_cursor` calls cannot be nested: only one + should be entered at a time. + """ + self.stream.write(self.hide_cursor) + self.stream.flush() + try: + yield + finally: + self.stream.write(self.normal_cursor) + self.stream.flush() + + @property + def color(self): + """ + A callable string that sets the foreground color. + + :arg int num: The foreground color index. This should be within the + bounds of :attr:`~.number_of_colors`. + :rtype: ParameterizingString + + The capability is unparameterized until called and passed a number, + 0-15, at which point it returns another string which represents a + specific color change. This second string can further be called to + color a piece of text and set everything back to normal afterward. + """ + if not self.does_styling: + return NullCallableString() + return ParameterizingString(self._foreground_color, + self.normal, 'color') + + def move_xy(self, x, y): + """ + A callable string that moves the cursor to the given ``(x, y)`` screen coordinates. + + :arg int x: horizontal position. + :arg int y: vertical position. + :rtype: ParameterizingString + """ + # this is just a convenience alias to the built-in, but hidden 'move' + # attribute -- we encourage folks to use only (x, y) positional + # arguments, or, if they must use (y, x), then use the 'move_yx' + # alias. + return self.move(y, x) + + def move_yx(self, y, x): + """ + A callable string that moves the cursor to the given ``(y, x)`` screen coordinates. + + :arg int x: horizontal position. + :arg int y: vertical position. + :rtype: ParameterizingString + """ + return self.move(y, x) + + def color_rgb(self, red, green, blue): + if self.number_of_colors == 1 << 24: + # "truecolor" 24-bit + fmt_attr = u'\x1b[38;2;{0};{1};{2}m'.format(red, green, blue) + return FormattingString(fmt_attr, self.normal) + + # color by approximation to 256 or 16-color terminals + color_idx = self.rgb_downconvert(red, green, blue) + return FormattingString(self._foreground_color(color_idx), self.normal) + + @property + def on_color(self): + """ + A callable capability that sets the background color. + + :arg int num: The background color index. + :rtype: ParameterizingString + """ + if not self.does_styling: + return NullCallableString() + return ParameterizingString(self._background_color, + self.normal, 'on_color') + + def on_color_rgb(self, red, green, blue): + if self.number_of_colors == 1 << 24: + fmt_attr = u'\x1b[48;2;{0};{1};{2}m'.format(red, green, blue) + return FormattingString(fmt_attr, self.normal) + + color_idx = self.rgb_downconvert(red, green, blue) + return FormattingString(self._background_color(color_idx), self.normal) + + def rgb_downconvert(self, red, green, blue): + """ + Translate an RGB color to a color code of the terminal's color depth. + + :arg int red: RGB value of Red (0-255). + :arg int green: RGB value of Green (0-255). + :arg int blue: RGB value of Blue (0-255). + :rtype: int + """ + # Though pre-computing all 1 << 24 options is memory-intensive, a pre-computed + # "k-d tree" of 256 (x,y,z) vectors of a colorspace in 3 dimensions, such as a + # cone of HSV, or simply 255x255x255 RGB square, any given rgb value is just a + # nearest-neighbor search of 256 points, which k-d should be much faster by + # sub-dividing / culling search points, rather than our "search all 256 points + # always" approach. + fn_distance = COLOR_DISTANCE_ALGORITHMS[self.color_distance_algorithm] + color_idx = 7 + shortest_distance = None + for cmp_depth, cmp_rgb in enumerate(RGB_256TABLE): + cmp_distance = fn_distance(cmp_rgb, (red, green, blue)) + if shortest_distance is None or cmp_distance < shortest_distance: + shortest_distance = cmp_distance + color_idx = cmp_depth + if cmp_depth >= self.number_of_colors: + break + return color_idx + + @property + def normal(self): + """ + A capability that resets all video attributes. + + :rtype: str + + ``normal`` is an alias for ``sgr0`` or ``exit_attribute_mode``. Any + styling attributes previously applied, such as foreground or + background colors, reverse video, or bold are reset to defaults. + """ + if self._normal: + return self._normal + self._normal = resolve_capability(self, 'normal') + return self._normal + + @property + def stream(self): + """ + Read-only property: stream the terminal outputs to. + + This is a convenience attribute. It is used internally for implied + writes performed by context managers :meth:`~.hidden_cursor`, + :meth:`~.fullscreen`, :meth:`~.location`, and :meth:`~.keypad`. + """ + return self._stream + + @property + def number_of_colors(self): + """ + Number of colors supported by terminal. + + Common return values are 0, 8, 16, 256, or 1 << 24. + + This may be used to test whether the terminal supports colors, + and at what depth, if that's a concern. + """ + return self._number_of_colors + + @number_of_colors.setter + def number_of_colors(self, value): + assert value in (0, 4, 8, 16, 256, 1 << 24) + self._number_of_colors = value + self.__clear_color_capabilities() + + @property + def color_distance_algorithm(self): + """ + Color distance algorithm used by :meth:`rgb_downconvert`. + + The slowest, but most accurate, 'cie2000', is default. Other available options are 'rgb', + 'rgb-weighted', 'cie76', and 'cie94'. + """ + return self._color_distance_algorithm + + @color_distance_algorithm.setter + def color_distance_algorithm(self, value): + assert value in COLOR_DISTANCE_ALGORITHMS + self._color_distance_algorithm = value + self.__clear_color_capabilities() + + @property + def _foreground_color(self): + """ + Convenience capability to support :attr:`~.on_color`. + + Prefers returning sequence for capability ``setaf``, "Set foreground color to #1, using ANSI + escape". If the given terminal does not support such sequence, fallback to returning + attribute ``setf``, "Set foreground color #1". + """ + return self.setaf or self.setf + + @property + def _background_color(self): + """ + Convenience capability to support :attr:`~.on_color`. + + Prefers returning sequence for capability ``setab``, "Set background color to #1, using ANSI + escape". If the given terminal does not support such sequence, fallback to returning + attribute ``setb``, "Set background color #1". + """ + return self.setab or self.setb + + def ljust(self, text, width=None, fillchar=u' '): + """ + Left-align ``text``, which may contain terminal sequences. + + :arg str text: String to be aligned + :arg int width: Total width to fill with aligned text. If + unspecified, the whole width of the terminal is filled. + :arg str fillchar: String for padding the right of ``text`` + :rtype: str + """ + # Left justification is different from left alignment, but we continue + # the vocabulary error of the str method for polymorphism. + if width is None: + width = self.width + return Sequence(text, self).ljust(width, fillchar) + + def rjust(self, text, width=None, fillchar=u' '): + """ + Right-align ``text``, which may contain terminal sequences. + + :arg str text: String to be aligned + :arg int width: Total width to fill with aligned text. If + unspecified, the whole width of the terminal is used. + :arg str fillchar: String for padding the left of ``text`` + :rtype: str + """ + if width is None: + width = self.width + return Sequence(text, self).rjust(width, fillchar) + + def center(self, text, width=None, fillchar=u' '): + """ + Center ``text``, which may contain terminal sequences. + + :arg str text: String to be centered + :arg int width: Total width in which to center text. If + unspecified, the whole width of the terminal is used. + :arg str fillchar: String for padding the left and right of ``text`` + :rtype: str + """ + if width is None: + width = self.width + return Sequence(text, self).center(width, fillchar) + + def length(self, text): + u""" + Return printable length of a string containing sequences. + + :arg str text: String to measure. May contain terminal sequences. + :rtype: int + :returns: The number of terminal character cells the string will occupy + when printed + + Wide characters that consume 2 character cells are supported: + + >>> term = Terminal() + >>> term.length(term.clear + term.red(u'コンニチハ')) + 10 + + .. note:: Sequences such as 'clear', which is considered as a + "movement sequence" because it would move the cursor to + (y, x)(0, 0), are evaluated as a printable length of + *0*. + """ + return Sequence(text, self).length() + + def strip(self, text, chars=None): + r""" + Return ``text`` without sequences and leading or trailing whitespace. + + :rtype: str + + >>> term.strip(u' \x1b[0;3m xyz ') + u'xyz' + """ + return Sequence(text, self).strip(chars) + + def rstrip(self, text, chars=None): + r""" + Return ``text`` without terminal sequences or trailing whitespace. + + :rtype: str + + >>> term.rstrip(u' \x1b[0;3m xyz ') + u' xyz' + """ + return Sequence(text, self).rstrip(chars) + + def lstrip(self, text, chars=None): + r""" + Return ``text`` without terminal sequences or leading whitespace. + + :rtype: str + + >>> term.lstrip(u' \x1b[0;3m xyz ') + u'xyz ' + """ + return Sequence(text, self).lstrip(chars) + + def strip_seqs(self, text): + r""" + Return ``text`` stripped of only its terminal sequences. + + :rtype: str + + >>> term.strip_seqs(u'\x1b[0;3mxyz') + u'xyz' + >>> term.strip_seqs(term.cuf(5) + term.red(u'test')) + u' test' + + .. note:: Non-destructive sequences that adjust horizontal distance + (such as ``\b`` or ``term.cuf(5)``) are replaced by destructive + space or erasing. + """ + return Sequence(text, self).strip_seqs() + + def split_seqs(self, text, **kwds): + r""" + Return ``text`` split by individual character elements and sequences. + + :arg kwds: remaining keyword arguments for :func:`re.split`. + :rtype: list[str] + + >>> term.split_seqs(term.underline(u'xyz')) + ['\x1b[4m', 'x', 'y', 'z', '\x1b(B', '\x1b[m'] + """ + pattern = self._caps_unnamed_any + return list(filter(None, re.split(pattern, text, **kwds))) + + def wrap(self, text, width=None, **kwargs): + """ + Text-wrap a string, returning a list of wrapped lines. + + :arg str text: Unlike :func:`textwrap.wrap`, ``text`` may contain + terminal sequences, such as colors, bold, or underline. By + default, tabs in ``text`` are expanded by + :func:`string.expandtabs`. + :arg int width: Unlike :func:`textwrap.wrap`, ``width`` will + default to the width of the attached terminal. + :rtype: list + + See :class:`textwrap.TextWrapper` for keyword arguments that can + customize wrapping behaviour. + """ + width = self.width if width is None else width + lines = [] + for line in text.splitlines(): + lines.extend( + (_linewrap for _linewrap in SequenceTextWrapper( + width=width, term=self, **kwargs).wrap(line)) + if line.strip() else (u'',)) + + return lines + + def getch(self): + """ + Read, decode, and return the next byte from the keyboard stream. + + :rtype: unicode + :returns: a single unicode character, or ``u''`` if a multi-byte + sequence has not yet been fully received. + + This method name and behavior mimics curses ``getch(void)``, and + it supports :meth:`inkey`, reading only one byte from + the keyboard string at a time. This method should always return + without blocking if called after :meth:`kbhit` has returned True. + + Implementors of alternate input stream methods should override + this method. + """ + assert self._keyboard_fd is not None + byte = os.read(self._keyboard_fd, 1) + return self._keyboard_decoder.decode(byte, final=False) + + def ungetch(self, text): + """ + Buffer input data to be discovered by next call to :meth:`~.inkey`. + + :arg str ucs: String to be buffered as keyboard input. + """ + self._keyboard_buf.extendleft(text) + + def kbhit(self, timeout=None): + """ + Return whether a keypress has been detected on the keyboard. + + This method is used by :meth:`inkey` to determine if a byte may + be read using :meth:`getch` without blocking. The standard + implementation simply uses the :func:`select.select` call on stdin. + + :arg float timeout: When ``timeout`` is 0, this call is + non-blocking, otherwise blocking indefinitely until keypress + is detected when None (default). When ``timeout`` is a + positive number, returns after ``timeout`` seconds have + elapsed (float). + :rtype: bool + :returns: True if a keypress is awaiting to be read on the keyboard + attached to this terminal. When input is not a terminal, False is + always returned. + """ + stime = time.time() + ready_r = [None, ] + check_r = [self._keyboard_fd] if self._keyboard_fd is not None else [] + + while HAS_TTY and True: + try: + ready_r, _, _ = select.select(check_r, [], [], timeout) + except InterruptedError: + # Beginning with python3.5, IntrruptError is no longer thrown + # https://www.python.org/dev/peps/pep-0475/ + # + # For previous versions of python, we take special care to + # retry select on InterruptedError exception, namely to handle + # a custom SIGWINCH handler. When installed, it would cause + # select() to be interrupted with errno 4 (EAGAIN). + # + # Just as in python3.5, it is ignored, and a new timeout value + # is derived from the previous unless timeout becomes negative. + # because the signal handler has blocked beyond timeout, then + # False is returned. Otherwise, when timeout is None, we + # continue to block indefinitely (default). + if timeout is not None: + # subtract time already elapsed, + timeout -= time.time() - stime + if timeout > 0: + continue + # no time remains after handling exception (rare) + ready_r = [] # pragma: no cover + break # pragma: no cover + else: + break + + return False if self._keyboard_fd is None else check_r == ready_r + + @contextlib.contextmanager + def cbreak(self): + """ + Allow each keystroke to be read immediately after it is pressed. + + This is a context manager for :func:`tty.setcbreak`. + + This context manager activates 'rare' mode, the opposite of 'cooked' + mode: On entry, :func:`tty.setcbreak` mode is activated disabling + line-buffering of keyboard input and turning off automatic echo of + input as output. + + .. note:: You must explicitly print any user input you would like + displayed. If you provide any kind of editing, you must handle + backspace and other line-editing control functions in this mode + as well! + + **Normally**, characters received from the keyboard cannot be read + by Python until the *Return* key is pressed. Also known as *cooked* or + *canonical input* mode, it allows the tty driver to provide + line-editing before shuttling the input to your program and is the + (implicit) default terminal mode set by most unix shells before + executing programs. + + Technically, this context manager sets the :mod:`termios` attributes + of the terminal attached to :obj:`sys.__stdin__`. + + .. note:: :func:`tty.setcbreak` sets ``VMIN = 1`` and ``VTIME = 0``, + see http://www.unixwiz.net/techtips/termios-vmin-vtime.html + """ + if HAS_TTY and self._keyboard_fd is not None: + # Save current terminal mode: + save_mode = termios.tcgetattr(self._keyboard_fd) + save_line_buffered = self._line_buffered + tty.setcbreak(self._keyboard_fd, termios.TCSANOW) + try: + self._line_buffered = False + yield + finally: + # Restore prior mode: + termios.tcsetattr(self._keyboard_fd, + termios.TCSAFLUSH, + save_mode) + self._line_buffered = save_line_buffered + else: + yield + + @contextlib.contextmanager + def raw(self): + r""" + A context manager for :func:`tty.setraw`. + + Although both :meth:`break` and :meth:`raw` modes allow each keystroke + to be read immediately after it is pressed, Raw mode disables + processing of input and output. + + In cbreak mode, special input characters such as ``^C`` or ``^S`` are + interpreted by the terminal driver and excluded from the stdin stream. + In raw mode these values are receive by the :meth:`inkey` method. + + Because output processing is not done, the newline ``'\n'`` is not + enough, you must also print carriage return to ensure that the cursor + is returned to the first column:: + + with term.raw(): + print("printing in raw mode", end="\r\n") + """ + if HAS_TTY and self._keyboard_fd is not None: + # Save current terminal mode: + save_mode = termios.tcgetattr(self._keyboard_fd) + save_line_buffered = self._line_buffered + tty.setraw(self._keyboard_fd, termios.TCSANOW) + try: + self._line_buffered = False + yield + finally: + # Restore prior mode: + termios.tcsetattr(self._keyboard_fd, + termios.TCSAFLUSH, + save_mode) + self._line_buffered = save_line_buffered + else: + yield + + @contextlib.contextmanager + def keypad(self): + r""" + Context manager that enables directional keypad input. + + On entrying, this puts the terminal into "keyboard_transmit" mode by + emitting the keypad_xmit (smkx) capability. On exit, it emits + keypad_local (rmkx). + + On an IBM-PC keyboard with numeric keypad of terminal-type *xterm*, + with numlock off, the lower-left diagonal key transmits sequence + ``\\x1b[F``, translated to :class:`~.Terminal` attribute + ``KEY_END``. + + However, upon entering :meth:`keypad`, ``\\x1b[OF`` is transmitted, + translating to ``KEY_LL`` (lower-left key), allowing you to determine + diagonal direction keys. + """ + try: + self.stream.write(self.smkx) + self.stream.flush() + yield + finally: + self.stream.write(self.rmkx) + self.stream.flush() + + def inkey(self, timeout=None, esc_delay=0.35): + """ + Read and return the next keyboard event within given timeout. + + Generally, this should be used inside the :meth:`raw` context manager. + + :arg float timeout: Number of seconds to wait for a keystroke before + returning. When ``None`` (default), this method may block + indefinitely. + :arg float esc_delay: To distinguish between the keystroke of + ``KEY_ESCAPE``, and sequences beginning with escape, the parameter + ``esc_delay`` specifies the amount of time after receiving escape + (``chr(27)``) to seek for the completion of an application key + before returning a :class:`~.Keystroke` instance for + ``KEY_ESCAPE``. + :rtype: :class:`~.Keystroke`. + :returns: :class:`~.Keystroke`, which may be empty (``u''``) if + ``timeout`` is specified and keystroke is not received. + + .. note:: When used without the context manager :meth:`cbreak`, or + :meth:`raw`, :obj:`sys.__stdin__` remains line-buffered, and this + function will block until the return key is pressed! + """ + resolve = functools.partial(resolve_sequence, + mapper=self._keymap, + codes=self._keycodes) + + stime = time.time() + + # re-buffer previously received keystrokes, + ucs = u'' + while self._keyboard_buf: + ucs += self._keyboard_buf.pop() + + # receive all immediately available bytes + while self.kbhit(timeout=0): + ucs += self.getch() + + # decode keystroke, if any + ks = resolve(text=ucs) + + # so long as the most immediately received or buffered keystroke is + # incomplete, (which may be a multibyte encoding), block until until + # one is received. + while not ks and self.kbhit(timeout=_time_left(stime, timeout)): + ucs += self.getch() + ks = resolve(text=ucs) + + # handle escape key (KEY_ESCAPE) vs. escape sequence (like those + # that begin with \x1b[ or \x1bO) up to esc_delay when + # received. This is not optimal, but causes least delay when + # "meta sends escape" is used, or when an unsupported sequence is + # sent. + # + # The statement, "ucs in self._keymap_prefixes" has an effect on + # keystrokes such as Alt + Z ("\x1b[z" with metaSendsEscape): because + # no known input sequences begin with such phrasing to allow it to be + # returned more quickly than esc_delay otherwise blocks for. + if ks.code == self.KEY_ESCAPE: + esctime = time.time() + while (ks.code == self.KEY_ESCAPE and + ucs in self._keymap_prefixes and + self.kbhit(timeout=_time_left(esctime, esc_delay))): + ucs += self.getch() + ks = resolve(text=ucs) + + # buffer any remaining text received + self.ungetch(ucs[len(ks):]) + return ks + + +class WINSZ(collections.namedtuple('WINSZ', ( + 'ws_row', 'ws_col', 'ws_xpixel', 'ws_ypixel'))): + """ + Structure represents return value of :const:`termios.TIOCGWINSZ`. + + .. py:attribute:: ws_row + + rows, in characters + + .. py:attribute:: ws_col + + columns, in characters + + .. py:attribute:: ws_xpixel + + horizontal size, pixels + + .. py:attribute:: ws_ypixel + + vertical size, pixels + """ + #: format of termios structure + _FMT = 'hhhh' + #: buffer of termios structure appropriate for ioctl argument + _BUF = '\x00' * struct.calcsize(_FMT) + + +#: _CUR_TERM = None +#: From libcurses/doc/ncurses-intro.html (ESR, Thomas Dickey, et. al):: +#: +#: "After the call to setupterm(), the global variable cur_term is set to +#: point to the current structure of terminal capabilities. By calling +#: setupterm() for each terminal, and saving and restoring cur_term, it +#: is possible for a program to use two or more terminals at once." +#: +#: However, if you study Python's ``./Modules/_cursesmodule.c``, you'll find:: +#: +#: if (!initialised_setupterm && setupterm(termstr,fd,&err) == ERR) { +#: +#: Python - perhaps wrongly - will not allow for re-initialisation of new +#: terminals through :func:`curses.setupterm`, so the value of cur_term cannot +#: be changed once set: subsequent calls to :func:`curses.setupterm` have no +#: effect. +#: +#: Therefore, the :attr:`Terminal.kind` of each :class:`Terminal` is +#: essentially a singleton. This global variable reflects that, and a warning +#: is emitted if somebody expects otherwise. diff --git a/blessed/win_terminal.py b/blessed/win_terminal.py new file mode 100644 index 00000000..68cdabe2 --- /dev/null +++ b/blessed/win_terminal.py @@ -0,0 +1,161 @@ +# encoding: utf-8 +"""Module containing Windows version of :class:`Terminal`.""" + +from __future__ import absolute_import + +# std imports +import time +import msvcrt # pylint: disable=import-error +import contextlib + +# 3rd party +import jinxed.win32 as win32 # pylint: disable=import-error + +# local +from .terminal import WINSZ +from .terminal import Terminal as _Terminal + + +class Terminal(_Terminal): + """Windows subclass of :class:`Terminal`.""" + + def getch(self): + r""" + Read, decode, and return the next byte from the keyboard stream. + + :rtype: unicode + :returns: a single unicode character, or ``u''`` if a multi-byte + sequence has not yet been fully received. + + For versions of Windows 10.0.10586 and later, the console is expected + to be in ENABLE_VIRTUAL_TERMINAL_INPUT mode and the default method is + called. + + For older versions of Windows, msvcrt.getwch() is used. If the received + character is ``\x00`` or ``\xe0``, the next character is + automatically retrieved. + """ + if win32.VTMODE_SUPPORTED: + return super(Terminal, self).getch() + + rtn = msvcrt.getwch() + if rtn in ('\x00', '\xe0'): + rtn += msvcrt.getwch() + return rtn + + def kbhit(self, timeout=None, **_kwargs): + """ + Return whether a keypress has been detected on the keyboard. + + This method is used by :meth:`inkey` to determine if a byte may + be read using :meth:`getch` without blocking. This is implemented + by wrapping msvcrt.kbhit() in a timeout. + + :arg float timeout: When ``timeout`` is 0, this call is + non-blocking, otherwise blocking indefinitely until keypress + is detected when None (default). When ``timeout`` is a + positive number, returns after ``timeout`` seconds have + elapsed (float). + :rtype: bool + :returns: True if a keypress is awaiting to be read on the keyboard + attached to this terminal. + """ + end = time.time() + (timeout or 0) + while True: + + if msvcrt.kbhit(): + return True + + if timeout is not None and end < time.time(): + break + + return False + + @staticmethod + def _winsize(fd): + """ + Return named tuple describing size of the terminal by ``fd``. + + :arg int fd: file descriptor queries for its window size. + :rtype: WINSZ + + WINSZ is a :class:`collections.namedtuple` instance, whose structure + directly maps to the return value of the :const:`termios.TIOCGWINSZ` + ioctl return value. The return parameters are: + + - ``ws_row``: width of terminal by its number of character cells. + - ``ws_col``: height of terminal by its number of character cells. + - ``ws_xpixel``: width of terminal by pixels (not accurate). + - ``ws_ypixel``: height of terminal by pixels (not accurate). + """ + window = win32.get_terminal_size(fd) + return WINSZ(ws_row=window.lines, ws_col=window.columns, + ws_xpixel=0, ws_ypixel=0) + + @contextlib.contextmanager + def cbreak(self): + """ + Allow each keystroke to be read immediately after it is pressed. + + This is a context manager for ``jinxed.w32.setcbreak()``. + + .. note:: You must explicitly print any user input you would like + displayed. If you provide any kind of editing, you must handle + backspace and other line-editing control functions in this mode + as well! + + **Normally**, characters received from the keyboard cannot be read + by Python until the *Return* key is pressed. Also known as *cooked* or + *canonical input* mode, it allows the tty driver to provide + line-editing before shuttling the input to your program and is the + (implicit) default terminal mode set by most unix shells before + executing programs. + """ + if self._keyboard_fd is not None: + + filehandle = msvcrt.get_osfhandle(self._keyboard_fd) + + # Save current terminal mode: + save_mode = win32.get_console_mode(filehandle) + save_line_buffered = self._line_buffered + win32.setcbreak(filehandle) + try: + self._line_buffered = False + yield + finally: + win32.set_console_mode(filehandle, save_mode) + self._line_buffered = save_line_buffered + + else: + yield + + @contextlib.contextmanager + def raw(self): + """ + A context manager for ``jinxed.w32.setcbreak()``. + + Although both :meth:`break` and :meth:`raw` modes allow each keystroke + to be read immediately after it is pressed, Raw mode disables + processing of input and output. + + In cbreak mode, special input characters such as ``^C`` are + interpreted by the terminal driver and excluded from the stdin stream. + In raw mode these values are receive by the :meth:`inkey` method. + """ + if self._keyboard_fd is not None: + + filehandle = msvcrt.get_osfhandle(self._keyboard_fd) + + # Save current terminal mode: + save_mode = win32.get_console_mode(filehandle) + save_line_buffered = self._line_buffered + win32.setraw(filehandle) + try: + self._line_buffered = False + yield + finally: + win32.set_console_mode(filehandle, save_mode) + self._line_buffered = save_line_buffered + + else: + yield diff --git a/blessings/__init__.py b/blessings/__init__.py deleted file mode 100644 index a2254b1b..00000000 --- a/blessings/__init__.py +++ /dev/null @@ -1,560 +0,0 @@ -"""A thin, practical wrapper around terminal coloring, styling, and -positioning""" - -from contextlib import contextmanager -import curses -from curses import setupterm, tigetnum, tigetstr, tparm -from fcntl import ioctl - -try: - from io import UnsupportedOperation as IOUnsupportedOperation -except ImportError: - class IOUnsupportedOperation(Exception): - """A dummy exception to take the place of Python 3's - ``io.UnsupportedOperation`` in Python 2""" - -from os import isatty, environ -from platform import python_version_tuple -import struct -import sys -from termios import TIOCGWINSZ - - -__all__ = ['Terminal'] - - -if ('3', '0', '0') <= python_version_tuple() < ('3', '2', '2+'): # Good till - # 3.2.10 - # Python 3.x < 3.2.3 has a bug in which tparm() erroneously takes a string. - raise ImportError('Blessings needs Python 3.2.3 or greater for Python 3 ' - 'support due to http://bugs.python.org/issue10570.') - - -class Terminal(object): - """An abstraction around terminal capabilities - - Unlike curses, this doesn't require clearing the screen before doing - anything, and it's friendlier to use. It keeps the endless calls to - ``tigetstr()`` and ``tparm()`` out of your code, and it acts intelligently - when somebody pipes your output to a non-terminal. - - Instance attributes: - - ``stream`` - The stream the terminal outputs to. It's convenient to pass the stream - around with the terminal; it's almost always needed when the terminal - is and saves sticking lots of extra args on client functions in - practice. - - """ - def __init__(self, kind=None, stream=None, force_styling=False): - """Initialize the terminal. - - If ``stream`` is not a tty, I will default to returning an empty - Unicode string for all capability values, so things like piping your - output to a file won't strew escape sequences all over the place. The - ``ls`` command sets a precedent for this: it defaults to columnar - output when being sent to a tty and one-item-per-line when not. - - :arg kind: A terminal string as taken by ``setupterm()``. Defaults to - the value of the ``TERM`` environment variable. - :arg stream: A file-like object representing the terminal. Defaults to - the original value of stdout, like ``curses.initscr()`` does. - :arg force_styling: Whether to force the emission of capabilities, even - if we don't seem to be in a terminal. This comes in handy if users - are trying to pipe your output through something like ``less -r``, - which supports terminal codes just fine but doesn't appear itself - to be a terminal. Just expose a command-line option, and set - ``force_styling`` based on it. Terminal initialization sequences - will be sent to ``stream`` if it has a file descriptor and to - ``sys.__stdout__`` otherwise. (``setupterm()`` demands to send them - somewhere, and stdout is probably where the output is ultimately - headed. If not, stderr is probably bound to the same terminal.) - - If you want to force styling to not happen, pass - ``force_styling=None``. - - """ - if stream is None: - stream = sys.__stdout__ - try: - stream_descriptor = (stream.fileno() if hasattr(stream, 'fileno') - and callable(stream.fileno) - else None) - except IOUnsupportedOperation: - stream_descriptor = None - - self._is_a_tty = (stream_descriptor is not None and - isatty(stream_descriptor)) - self._does_styling = ((self.is_a_tty or force_styling) and - force_styling is not None) - - # The descriptor to direct terminal initialization sequences to. - # sys.__stdout__ seems to always have a descriptor of 1, even if output - # is redirected. - self._init_descriptor = (sys.__stdout__.fileno() - if stream_descriptor is None - else stream_descriptor) - if self.does_styling: - # Make things like tigetstr() work. Explicit args make setupterm() - # work even when -s is passed to nosetests. Lean toward sending - # init sequences to the stream if it has a file descriptor, and - # send them to stdout as a fallback, since they have to go - # somewhere. - setupterm(kind or environ.get('TERM', 'unknown'), - self._init_descriptor) - - self.stream = stream - - # Sugary names for commonly-used capabilities, intended to help avoid trips - # to the terminfo man page and comments in your code: - _sugar = dict( - # Don't use "on" or "bright" as an underscore-separated chunk in any of - # these (e.g. on_cology or rock_on) so we don't interfere with - # __getattr__. - save='sc', - restore='rc', - - clear_eol='el', - clear_bol='el1', - clear_eos='ed', - # 'clear' clears the whole screen. - position='cup', # deprecated - enter_fullscreen='smcup', - exit_fullscreen='rmcup', - move='cup', - move_x='hpa', - move_y='vpa', - move_left='cub1', - move_right='cuf1', - move_up='cuu1', - move_down='cud1', - - hide_cursor='civis', - normal_cursor='cnorm', - - reset_colors='op', # oc doesn't work on my OS X terminal. - - normal='sgr0', - reverse='rev', - # 'bold' is just 'bold'. Similarly... - # blink - # dim - # flash - italic='sitm', - no_italic='ritm', - shadow='sshm', - no_shadow='rshm', - standout='smso', - no_standout='rmso', - subscript='ssubm', - no_subscript='rsubm', - superscript='ssupm', - no_superscript='rsupm', - underline='smul', - no_underline='rmul') - - def __getattr__(self, attr): - """Return a terminal capability, like bold. - - For example, you can say ``term.bold`` to get the string that turns on - bold formatting and ``term.normal`` to get the string that turns it off - again. Or you can take a shortcut: ``term.bold('hi')`` bolds its - argument and sets everything to normal afterward. You can even combine - things: ``term.bold_underline_red_on_bright_green('yowzers!')``. - - For a parametrized capability like ``cup``, pass the parameters too: - ``some_term.cup(line, column)``. - - ``man terminfo`` for a complete list of capabilities. - - Return values are always Unicode. - - """ - resolution = (self._resolve_formatter(attr) if self.does_styling - else NullCallableString()) - setattr(self, attr, resolution) # Cache capability codes. - return resolution - - @property - def does_styling(self): - """Whether attempt to emit capabilities - - This is influenced by the ``is_a_tty`` property and by the - ``force_styling`` argument to the constructor. You can examine - this value to decide whether to draw progress bars or other frippery. - - """ - return self._does_styling - - @property - def is_a_tty(self): - """Whether my ``stream`` appears to be associated with a terminal""" - return self._is_a_tty - - @property - def height(self): - """The height of the terminal in characters - - If no stream or a stream not representing a terminal was passed in at - construction, return the dimension of the controlling terminal so - piping to things that eventually display on the terminal (like ``less - -R``) work. If a stream representing a terminal was passed in, return - the dimensions of that terminal. If there somehow is no controlling - terminal, return ``None``. (Thus, you should check that the property - ``is_a_tty`` is true before doing any math on the result.) - - """ - return self._height_and_width()[0] - - @property - def width(self): - """The width of the terminal in characters - - See ``height()`` for some corner cases. - - """ - return self._height_and_width()[1] - - def _height_and_width(self): - """Return a tuple of (terminal height, terminal width). - - Start by trying TIOCGWINSZ (Terminal I/O-Control: Get Window Size), - falling back to environment variables (LINES, COLUMNS), and returning - (None, None) if those are unavailable or invalid. - - """ - # tigetnum('lines') and tigetnum('cols') update only if we call - # setupterm() again. - for descriptor in self._init_descriptor, sys.__stdout__: - try: - return struct.unpack( - 'hhhh', ioctl(descriptor, TIOCGWINSZ, '\000' * 8))[0:2] - except IOError: - # when the output stream or init descriptor is not a tty, such - # as when when stdout is piped to another program, fe. tee(1), - # these ioctls will raise IOError - pass - try: - return int(environ.get('LINES')), int(environ.get('COLUMNS')) - except TypeError: - return None, None - - @contextmanager - def location(self, x=None, y=None): - """Return a context manager for temporarily moving the cursor. - - Move the cursor to a certain position on entry, let you print stuff - there, then return the cursor to its original position:: - - term = Terminal() - with term.location(2, 5): - print 'Hello, world!' - for x in xrange(10): - print 'I can do it %i times!' % x - - Specify ``x`` to move to a certain column, ``y`` to move to a certain - row, both, or neither. If you specify neither, only the saving and - restoration of cursor position will happen. This can be useful if you - simply want to restore your place after doing some manual cursor - movement. - - """ - # Save position and move to the requested column, row, or both: - self.stream.write(self.save) - if x is not None and y is not None: - self.stream.write(self.move(y, x)) - elif x is not None: - self.stream.write(self.move_x(x)) - elif y is not None: - self.stream.write(self.move_y(y)) - try: - yield - finally: - # Restore original cursor position: - self.stream.write(self.restore) - - @contextmanager - def fullscreen(self): - """Return a context manager that enters fullscreen mode while inside it - and restores normal mode on leaving.""" - self.stream.write(self.enter_fullscreen) - try: - yield - finally: - self.stream.write(self.exit_fullscreen) - - @contextmanager - def hidden_cursor(self): - """Return a context manager that hides the cursor while inside it and - makes it visible on leaving.""" - self.stream.write(self.hide_cursor) - try: - yield - finally: - self.stream.write(self.normal_cursor) - - @property - def color(self): - """Return a capability that sets the foreground color. - - The capability is unparametrized until called and passed a number - (0-15), at which point it returns another string which represents a - specific color change. This second string can further be called to - color a piece of text and set everything back to normal afterward. - - :arg num: The number, 0-15, of the color - - """ - return ParametrizingString(self._foreground_color, self.normal) - - @property - def on_color(self): - """Return a capability that sets the background color. - - See ``color()``. - - """ - return ParametrizingString(self._background_color, self.normal) - - @property - def number_of_colors(self): - """Return the number of colors the terminal supports. - - Common values are 0, 8, 16, 88, and 256. - - Though the underlying capability returns -1 when there is no color - support, we return 0. This lets you test more Pythonically:: - - if term.number_of_colors: - ... - - We also return 0 if the terminal won't tell us how many colors it - supports, which I think is rare. - - """ - # This is actually the only remotely useful numeric capability. We - # don't name it after the underlying capability, because we deviate - # slightly from its behavior, and we might someday wish to give direct - # access to it. - colors = tigetnum('colors') # Returns -1 if no color support, -2 if no - # such cap. - # self.__dict__['colors'] = ret # Cache it. It's not changing. - # (Doesn't work.) - return colors if colors >= 0 else 0 - - def _resolve_formatter(self, attr): - """Resolve a sugary or plain capability name, color, or compound - formatting function name into a callable capability. - - Return a ``ParametrizingString`` or a ``FormattingString``. - - """ - if attr in COLORS: - return self._resolve_color(attr) - elif attr in COMPOUNDABLES: - # Bold, underline, or something that takes no parameters - return self._formatting_string(self._resolve_capability(attr)) - else: - formatters = split_into_formatters(attr) - if all(f in COMPOUNDABLES for f in formatters): - # It's a compound formatter, like "bold_green_on_red". Future - # optimization: combine all formatting into a single escape - # sequence. - return self._formatting_string( - u''.join(self._resolve_formatter(s) for s in formatters)) - else: - return ParametrizingString(self._resolve_capability(attr)) - - def _resolve_capability(self, atom): - """Return a terminal code for a capname or a sugary name, or an empty - Unicode. - - The return value is always Unicode, because otherwise it is clumsy - (especially in Python 3) to concatenate with real (Unicode) strings. - - """ - code = tigetstr(self._sugar.get(atom, atom)) - if code: - # See the comment in ParametrizingString for why this is latin1. - return code.decode('latin1') - return u'' - - def _resolve_color(self, color): - """Resolve a color like red or on_bright_green into a callable - capability.""" - # TODO: Does curses automatically exchange red and blue and cyan and - # yellow when a terminal supports setf/setb rather than setaf/setab? - # I'll be blasted if I can find any documentation. The following - # assumes it does. - color_cap = (self._background_color if 'on_' in color else - self._foreground_color) - # curses constants go up to only 7, so add an offset to get at the - # bright colors at 8-15: - offset = 8 if 'bright_' in color else 0 - base_color = color.rsplit('_', 1)[-1] - return self._formatting_string( - color_cap(getattr(curses, 'COLOR_' + base_color.upper()) + offset)) - - @property - def _foreground_color(self): - return self.setaf or self.setf - - @property - def _background_color(self): - return self.setab or self.setb - - def _formatting_string(self, formatting): - """Return a new ``FormattingString`` which implicitly receives my - notion of "normal".""" - return FormattingString(formatting, self.normal) - - -def derivative_colors(colors): - """Return the names of valid color variants, given the base colors.""" - return set([('on_' + c) for c in colors] + - [('bright_' + c) for c in colors] + - [('on_bright_' + c) for c in colors]) - - -COLORS = set(['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', - 'white']) -COLORS.update(derivative_colors(COLORS)) -COMPOUNDABLES = (COLORS | - set(['bold', 'underline', 'reverse', 'blink', 'dim', 'italic', - 'shadow', 'standout', 'subscript', 'superscript'])) - - -class ParametrizingString(unicode): - """A Unicode string which can be called to parametrize it as a terminal - capability""" - - def __new__(cls, formatting, normal=None): - """Instantiate. - - :arg normal: If non-None, indicates that, once parametrized, this can - be used as a ``FormattingString``. The value is used as the - "normal" capability. - - """ - new = unicode.__new__(cls, formatting) - new._normal = normal - return new - - def __call__(self, *args): - try: - # Re-encode the cap, because tparm() takes a bytestring in Python - # 3. However, appear to be a plain Unicode string otherwise so - # concats work. - # - # We use *latin1* encoding so that bytes emitted by tparm are - # encoded to their native value: some terminal kinds, such as - # 'avatar' or 'kermit', emit 8-bit bytes in range 0x7f to 0xff. - # latin1 leaves these values unmodified in their conversion to - # unicode byte values. The terminal emulator will "catch" and - # handle these values, even if emitting utf8-encoded text, where - # these bytes would otherwise be illegal utf8 start bytes. - parametrized = tparm(self.encode('latin1'), *args).decode('latin1') - return (parametrized if self._normal is None else - FormattingString(parametrized, self._normal)) - except curses.error: - # Catch "must call (at least) setupterm() first" errors, as when - # running simply `nosetests` (without progressive) on nose- - # progressive. Perhaps the terminal has gone away between calling - # tigetstr and calling tparm. - return u'' - except TypeError: - # If the first non-int (i.e. incorrect) arg was a string, suggest - # something intelligent: - if len(args) == 1 and isinstance(args[0], basestring): - raise TypeError( - 'A native or nonexistent capability template received ' - '%r when it was expecting ints. You probably misspelled a ' - 'formatting call like bright_red_on_white(...).' % args) - else: - # Somebody passed a non-string; I don't feel confident - # guessing what they were trying to do. - raise - - -class FormattingString(unicode): - """A Unicode string which can be called upon a piece of text to wrap it in - formatting""" - - def __new__(cls, formatting, normal): - new = unicode.__new__(cls, formatting) - new._normal = normal - return new - - def __call__(self, text): - """Return a new string that is ``text`` formatted with my contents. - - At the beginning of the string, I prepend the formatting that is my - contents. At the end, I append the "normal" sequence to set everything - back to defaults. The return value is always a Unicode. - - """ - return self + text + self._normal - - -class NullCallableString(unicode): - """A dummy callable Unicode to stand in for ``FormattingString`` and - ``ParametrizingString`` - - We use this when there is no tty and thus all capabilities should be blank. - - """ - def __new__(cls): - new = unicode.__new__(cls, u'') - return new - - def __call__(self, *args): - """Return a Unicode or whatever you passed in as the first arg - (hopefully a string of some kind). - - When called with an int as the first arg, return an empty Unicode. An - int is a good hint that I am a ``ParametrizingString``, as there are - only about half a dozen string-returning capabilities on OS X's - terminfo man page which take any param that's not an int, and those are - seldom if ever used on modern terminal emulators. (Most have to do with - programming function keys. Blessings' story for supporting - non-string-returning caps is undeveloped.) And any parametrized - capability in a situation where all capabilities themselves are taken - to be blank are, of course, themselves blank. - - When called with a non-int as the first arg (no no args at all), return - the first arg. I am acting as a ``FormattingString``. - - """ - if len(args) != 1 or isinstance(args[0], int): - # I am acting as a ParametrizingString. - - # tparm can take not only ints but also (at least) strings as its - # second...nth args. But we don't support callably parametrizing - # caps that take non-ints yet, so we can cheap out here. TODO: Go - # through enough of the motions in the capability resolvers to - # determine which of 2 special-purpose classes, - # NullParametrizableString or NullFormattingString, to return, and - # retire this one. - return u'' - return args[0] # Should we force even strs in Python 2.x to be - # unicodes? No. How would I know what encoding to use - # to convert it? - - -def split_into_formatters(compound): - """Split a possibly compound format string into segments. - - >>> split_into_formatters('bold_underline_bright_blue_on_red') - ['bold', 'underline', 'bright_blue', 'on_red'] - - """ - merged_segs = [] - # These occur only as prefixes, so they can always be merged: - mergeable_prefixes = ['on', 'bright', 'on_bright'] - for s in compound.split('_'): - if merged_segs and merged_segs[-1] in mergeable_prefixes: - merged_segs[-1] += '_' + s - else: - merged_segs.append(s) - return merged_segs diff --git a/blessings/tests.py b/blessings/tests.py deleted file mode 100644 index aff3a2f2..00000000 --- a/blessings/tests.py +++ /dev/null @@ -1,270 +0,0 @@ -# -*- coding: utf-8 -*- -"""Automated tests (as opposed to human-verified test patterns) - -It was tempting to mock out curses to get predictable output from ``tigetstr``, -but there are concrete integration-testing benefits in not doing so. For -instance, ``tigetstr`` changed its return type in Python 3.2.3. So instead, we -simply create all our test ``Terminal`` instances with a known terminal type. -All we require from the host machine is that a standard terminfo definition of -xterm-256color exists. - -""" -from __future__ import with_statement # Make 2.5-compatible -from curses import tigetstr, tparm -from functools import partial -from StringIO import StringIO -import sys - -from nose import SkipTest -from nose.tools import eq_ - -# This tests that __all__ is correct, since we use below everything that should -# be imported: -from blessings import * - - -TestTerminal = partial(Terminal, kind='xterm-256color') - - -def unicode_cap(cap): - """Return the result of ``tigetstr`` except as Unicode.""" - return tigetstr(cap).decode('latin1') - - -def unicode_parm(cap, *parms): - """Return the result of ``tparm(tigetstr())`` except as Unicode.""" - return tparm(tigetstr(cap), *parms).decode('latin1') - - -def test_capability(): - """Check that a capability lookup works. - - Also test that Terminal grabs a reasonable default stream. This test - assumes it will be run from a tty. - - """ - t = TestTerminal() - sc = unicode_cap('sc') - eq_(t.save, sc) - eq_(t.save, sc) # Make sure caching doesn't screw it up. - - -def test_capability_without_tty(): - """Assert capability templates are '' when stream is not a tty.""" - t = TestTerminal(stream=StringIO()) - eq_(t.save, u'') - eq_(t.red, u'') - - -def test_capability_with_forced_tty(): - """If we force styling, capabilities had better not (generally) be - empty.""" - t = TestTerminal(stream=StringIO(), force_styling=True) - eq_(t.save, unicode_cap('sc')) - - -def test_parametrization(): - """Test parametrizing a capability.""" - eq_(TestTerminal().cup(3, 4), unicode_parm('cup', 3, 4)) - - -def test_height_and_width(): - """Assert that ``height_and_width()`` returns ints.""" - t = TestTerminal() # kind shouldn't matter. - assert isinstance(t.height, int) - assert isinstance(t.width, int) - - -def test_stream_attr(): - """Make sure Terminal exposes a ``stream`` attribute that defaults to - something sane.""" - eq_(Terminal().stream, sys.__stdout__) - - -def test_location(): - """Make sure ``location()`` does what it claims.""" - t = TestTerminal(stream=StringIO(), force_styling=True) - - with t.location(3, 4): - t.stream.write(u'hi') - - eq_(t.stream.getvalue(), unicode_cap('sc') + - unicode_parm('cup', 4, 3) + - u'hi' + - unicode_cap('rc')) - - -def test_horizontal_location(): - """Make sure we can move the cursor horizontally without changing rows.""" - t = TestTerminal(stream=StringIO(), force_styling=True) - with t.location(x=5): - pass - eq_(t.stream.getvalue(), unicode_cap('sc') + - unicode_parm('hpa', 5) + - unicode_cap('rc')) - - -def test_null_location(): - """Make sure ``location()`` with no args just does position restoration.""" - t = TestTerminal(stream=StringIO(), force_styling=True) - with t.location(): - pass - eq_(t.stream.getvalue(), unicode_cap('sc') + - unicode_cap('rc')) - - -def test_zero_location(): - """Make sure ``location()`` pays attention to 0-valued args.""" - t = TestTerminal(stream=StringIO(), force_styling=True) - with t.location(0, 0): - pass - eq_(t.stream.getvalue(), unicode_cap('sc') + - unicode_parm('cup', 0, 0) + - unicode_cap('rc')) - - -def test_null_fileno(): - """Make sure ``Terminal`` works when ``fileno`` is ``None``. - - This simulates piping output to another program. - - """ - out = StringIO() - out.fileno = None - t = TestTerminal(stream=out) - eq_(t.save, u'') - - -def test_mnemonic_colors(): - """Make sure color shortcuts work.""" - def color(num): - return unicode_parm('setaf', num) - - def on_color(num): - return unicode_parm('setab', num) - - # Avoid testing red, blue, yellow, and cyan, since they might someday - # change depending on terminal type. - t = TestTerminal() - eq_(t.white, color(7)) - eq_(t.green, color(2)) # Make sure it's different than white. - eq_(t.on_black, on_color(0)) - eq_(t.on_green, on_color(2)) - eq_(t.bright_black, color(8)) - eq_(t.bright_green, color(10)) - eq_(t.on_bright_black, on_color(8)) - eq_(t.on_bright_green, on_color(10)) - - -def test_callable_numeric_colors(): - """``color(n)`` should return a formatting wrapper.""" - t = TestTerminal() - eq_(t.color(5)('smoo'), t.magenta + 'smoo' + t.normal) - eq_(t.color(5)('smoo'), t.color(5) + 'smoo' + t.normal) - eq_(t.on_color(2)('smoo'), t.on_green + 'smoo' + t.normal) - eq_(t.on_color(2)('smoo'), t.on_color(2) + 'smoo' + t.normal) - - -def test_null_callable_numeric_colors(): - """``color(n)`` should be a no-op on null terminals.""" - t = TestTerminal(stream=StringIO()) - eq_(t.color(5)('smoo'), 'smoo') - eq_(t.on_color(6)('smoo'), 'smoo') - - -def test_naked_color_cap(): - """``term.color`` should return a stringlike capability.""" - t = TestTerminal() - eq_(t.color + '', t.setaf + '') - - -def test_number_of_colors_without_tty(): - """``number_of_colors`` should return 0 when there's no tty.""" - # Hypothesis: once setupterm() has run and decided the tty supports 256 - # colors, it never changes its mind. - raise SkipTest - - t = TestTerminal(stream=StringIO()) - eq_(t.number_of_colors, 0) - t = TestTerminal(stream=StringIO(), force_styling=True) - eq_(t.number_of_colors, 0) - - -def test_number_of_colors_with_tty(): - """``number_of_colors`` should work.""" - t = TestTerminal() - eq_(t.number_of_colors, 256) - - -def test_formatting_functions(): - """Test crazy-ass formatting wrappers, both simple and compound.""" - t = TestTerminal() - # By now, it should be safe to use sugared attributes. Other tests test - # those. - eq_(t.bold(u'hi'), t.bold + u'hi' + t.normal) - eq_(t.green('hi'), t.green + u'hi' + t.normal) # Plain strs for Python 2.x - # Test some non-ASCII chars, probably not necessary: - eq_(t.bold_green(u'boö'), t.bold + t.green + u'boö' + t.normal) - eq_(t.bold_underline_green_on_red('boo'), - t.bold + t.underline + t.green + t.on_red + u'boo' + t.normal) - # Don't spell things like this: - eq_(t.on_bright_red_bold_bright_green_underline('meh'), - t.on_bright_red + t.bold + t.bright_green + t.underline + u'meh' + - t.normal) - - -def test_formatting_functions_without_tty(): - """Test crazy-ass formatting wrappers when there's no tty.""" - t = TestTerminal(stream=StringIO()) - eq_(t.bold(u'hi'), u'hi') - eq_(t.green('hi'), u'hi') - # Test non-ASCII chars, no longer really necessary: - eq_(t.bold_green(u'boö'), u'boö') - eq_(t.bold_underline_green_on_red('loo'), u'loo') - eq_(t.on_bright_red_bold_bright_green_underline('meh'), u'meh') - - -def test_nice_formatting_errors(): - """Make sure you get nice hints if you misspell a formatting wrapper.""" - t = TestTerminal() - try: - t.bold_misspelled('hey') - except TypeError, e: - assert 'probably misspelled' in e.args[0] - - try: - t.bold_misspelled(u'hey') # unicode - except TypeError, e: - assert 'probably misspelled' in e.args[0] - - try: - t.bold_misspelled(None) # an arbitrary non-string - except TypeError, e: - assert 'probably misspelled' not in e.args[0] - - try: - t.bold_misspelled('a', 'b') # >1 string arg - except TypeError, e: - assert 'probably misspelled' not in e.args[0] - - -def test_init_descriptor_always_initted(): - """We should be able to get a height and width even on no-tty Terminals.""" - t = Terminal(stream=StringIO()) - eq_(type(t.height), int) - - -def test_force_styling_none(): - """If ``force_styling=None`` is passed to the constructor, don't ever do - styling.""" - t = TestTerminal(force_styling=None) - eq_(t.save, '') - - -def test_null_callable_string(): - """Make sure NullCallableString tolerates all numbers and kinds of args it - might receive.""" - t = TestTerminal(stream=StringIO()) - eq_(t.clear, '') - eq_(t.move(1, 2), '') - eq_(t.move_x(1), '') diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index c1d668a3..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,130 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/blessings.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/blessings.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/blessings" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/blessings" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - make -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/_static/soulburner-ru-family-encodings.jpg b/docs/_static/soulburner-ru-family-encodings.jpg new file mode 100644 index 00000000..ecb964f8 Binary files /dev/null and b/docs/_static/soulburner-ru-family-encodings.jpg differ diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..77f24cec --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,59 @@ +API Documentation +================= + +color.py +-------- + +.. automodule:: blessed.color + :members: + :undoc-members: + :private-members: + +colorspace.py +------------- + +.. automodule:: blessed.colorspace + :members: + :undoc-members: + :private-members: + +formatters.py +------------- + +.. automodule:: blessed.formatters + :members: + :undoc-members: + :private-members: + :special-members: __call__ +.. autodata:: COLORS +.. autodata:: COMPOUNDABLES + +keyboard.py +----------- + +.. automodule:: blessed.keyboard + :members: + :undoc-members: + :private-members: + :special-members: __new__ +.. autofunction:: _alternative_left_right +.. autodata:: DEFAULT_SEQUENCE_MIXIN +.. autodata:: CURSES_KEYCODE_OVERRIDE_MIXIN +.. autodata:: _CURSES_KEYCODE_ADDINS + +sequences.py +------------ + +.. automodule:: blessed.sequences + :members: + :undoc-members: + :private-members: + +terminal.py +----------- + +.. automodule:: blessed.terminal + :members: + :undoc-members: + :special-members: __getattr__ +.. autodata:: _CUR_TERM diff --git a/docs/conf.py b/docs/conf.py index e9ed9ebf..1af36bc9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,33 +1,71 @@ -# -*- coding: utf-8 -*- -# -# blessings documentation build configuration file, created by -# sphinx-quickstart on Thu Mar 31 13:40:27 2011. +# std imports +import os +import sys +import json +import functools + +# local +import sphinx_rtd_theme +import sphinx.environment +from docutils.utils import get_source_line + +# for github.py +HERE = os.path.dirname(__file__) +sys.path.insert(0, os.path.abspath('sphinxext')) +github_project_url = "https://github.com/jquast/blessed" + + +# ! Monkey Patching! # -# This file is execfile()d with the current directory set to its containing dir. +# Seems many folks beside ourselves would like to see external image urls +# **not** generated as a warning. "nonlocal image URI found": we want our +# "badge icons" on pypi and github, but we don't want to miss out on any +# other "warnings as errors" which we wish to fail the build if it happens. # -# Note that not all possible configuration values are present in this -# autogenerated file. +# https://github.com/SuperCowPowers/workbench/issues/172 +# https://groups.google.com/forum/#!topic/sphinx-users/GNx7PVXoZIU +# http://stackoverflow.com/a/28778969 # -# All configuration values have a default; values that are commented out -# serve to show the default. +def _warn_node(self, msg, node): + if not msg.startswith('nonlocal image URI found:'): + self._warnfunc(msg, '%s:%s' % get_source_line(node)) + + +sphinx.environment.BuildEnvironment.warn_node = _warn_node -import sys, os -import blessings +def no_op_wraps(func): + """Replaces functools.wraps in order to undo wrapping when generating Sphinx documentation.""" + if func.__module__ is None or 'blessed' not in func.__module__: + return functools.orig_wraps(func) -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) + def wrapper(decorator): + sys.stderr.write('patched for function signature: {0!r}\n'.format(func)) + return func + return wrapper -# -- General configuration ----------------------------------------------------- + +# Monkey-patch functools.wraps and contextlib.wraps +# https://github.com/sphinx-doc/sphinx/issues/1711#issuecomment-93126473 +functools.orig_wraps = functools.wraps +functools.wraps = no_op_wraps +import contextlib # isort:skip # noqa +contextlib.wraps = no_op_wraps +from blessed.terminal import * # isort:skip # noqa + +# -- General configuration ---------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ['sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', + 'github', + 'sphinx_paramlinks', + ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -36,183 +74,199 @@ source_suffix = '.rst' # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. -project = u'Blessings' -copyright = u'2011, Erik Rose' +project = u'Blessed' +copyright = u'2011 Erik Rose, Jeff Quast' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '1.6' +version = json.load( + open(os.path.join(HERE, os.pardir, 'version.json'), 'r') +)['version'] + # The full version, including alpha/beta/rc tags. release = version -# The language for content autogenerated by Sphinx. Refer to documentation +# The language for content auto-generated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] -# The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# The reST default role (used for this markup: `text`) to use for all +# documents. +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +add_module_names = False # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] -# -- Options for HTML output --------------------------------------------------- +# -- Options for HTML output -------------------------------------------------- # 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 # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # 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 = [] +# html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +html_split_index = True # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +html_show_sphinx = False # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'blessingsdoc' +htmlhelp_basename = 'blesseddoc' -# -- Options for LaTeX output -------------------------------------------------- +# -- Options for LaTeX output ------------------------------------------------- # The paper size ('letter' or 'a4'). -#latex_paper_size = 'letter' +# latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). -#latex_font_size = '10pt' +# latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). +# (source start file, target name, title, author, documentclass +# [howto/manual]). latex_documents = [ - ('index', 'blessings.tex', u'Blessings Documentation', - u'Erik Rose', 'manual'), + ('index', 'blessed.tex', u'Blessed Documentation', + u'Erik Rose, Jeff Quast', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Additional stuff for the LaTeX preamble. -#latex_preamble = '' +# latex_preamble = '' # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True -# -- Options for manual page output -------------------------------------------- +# -- Options for manual page output ------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'Blessings', u'Blessings Documentation', - [u'Erik Rose'], 1) + ('index', 'Blessed', u'Blessed Documentation', + [u'Erik Rose, Jeff Quast'], 1) ] + +# sort order of API documentation is by their appearance in source code +autodoc_member_order = 'bysource' + +# when linking to standard python library, use and prefer python 3 +# documentation. +intersphinx_mapping = {'https://docs.python.org/3/': None} + +# Both the class’ and the __init__ method’s docstring are concatenated and +# inserted. +autoclass_content = "both" diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 120000 index 00000000..798f2aa2 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1 @@ +../CONTRIBUTING.rst \ No newline at end of file diff --git a/docs/examples.rst b/docs/examples.rst new file mode 100644 index 00000000..8092a257 --- /dev/null +++ b/docs/examples.rst @@ -0,0 +1,89 @@ +Examples +======== + +A few programs are provided with blessed to help interactively +test the various API features, but also serve as examples of using +blessed to develop applications. + +These examples are not distributed with the package -- they are +only available in the github repository. You can retrieve them +by cloning the repository, or simply downloading the "raw" file +link. + +editor.py +--------- +https://github.com/jquast/blessed/blob/master/bin/editor.py + +This program demonstrates using the directional keys and noecho input +mode. It acts as a (very dumb) fullscreen editor, with support for +saving a file, as well as including a rudimentary line-editor. + +keymatrix.py +------------ +https://github.com/jquast/blessed/blob/master/bin/keymatrix.py + +This program displays a "gameboard" of all known special KEY_NAME +constants. When the key is depressed, it is highlighted, as well +as displaying the unicode sequence, integer code, and friendly-name +of any key pressed. + +on_resize.py +------------ +https://github.com/jquast/blessed/blob/master/bin/on_resize.py + +This program installs a SIGWINCH signal handler, which detects +screen resizes while also polling for input, displaying keypresses. + +This demonstrates how a program can react to screen resize events. + +progress_bar.py +--------------- +https://github.com/jquast/blessed/blob/master/bin/progress_bar.py + +This program demonstrates a simple progress bar. All text is written +to stderr, to avoid the need to "flush" or emit newlines, and makes +use of the move_x (hpa) capability to "overstrike" the display a +scrolling progress bar. + +.. _tprint.py: + +tprint.py +--------- +https://github.com/jquast/blessed/blob/master/bin/tprint.py + +This program demonstrates how users may customize FormattingString +styles. Accepting a string style, such as "bold" or "bright_red" +as the first argument, all subsequent arguments are displayed by +the given style. This shows how a program could provide +user-customizable compound formatting names to configure a program's +styling. + +worms.py +-------- +https://github.com/jquast/blessed/blob/master/bin/worms.py + +This program demonstrates how an interactive game could be made +with blessed. It is similar to `NIBBLES.BAS +`_ +or "snake" of early mobile platforms. + +resize.py +--------- +https://github.com/jquast/blessed/blob/master/bin/resize.py + +This program demonstrates the :meth:`~.get_location` method, +behaving similar to `resize(1) +`_ +: set environment and terminal settings to current window size. +The window size is determined by eliciting an answerback +sequence from the connecting terminal emulator. + +.. _detect-multibyte.py: + +detect-multibyte.py +------------------- +https://github.com/jquast/blessed/blob/master/bin/detect-multibyte.py + +This program also demonstrates how the :meth:`~.get_location` method +can be used to reliably test whether the terminal emulator of the connecting +client is capable of rendering multibyte characters as a single cell. diff --git a/docs/further.rst b/docs/further.rst new file mode 100644 index 00000000..9e93d078 --- /dev/null +++ b/docs/further.rst @@ -0,0 +1,103 @@ +Further Reading +=============== + +As a developer's API, blessed is often bundled with frameworks and toolsets +that dive deeper into Terminal I/O programming than :class:`~.Terminal` offers. +Here are some recommended readings to help you along: + +- `terminfo(5) + `_ + manpage of your preferred posix-like operating system. The capabilities + available as attributes of :class:`~.Terminal` are directly mapped to those + listed in the column **Cap-name**. + +- `termios(4) + `_ + of your preferred posix-like operating system. + +- `The TTY demystified + `_ + by Linus Åkesson. + +- `A Brief Introduction to Termios + `_ by + Nelson Elhage. + +- Richard Steven's `Advance Unix Programming + `_ + ("AUP") provides two very good chapters, "Terminal I/O" and + "Pseudo Terminals". + +- GNU's `The Termcap Manual + `_ + by Richard M. Stallman. + +- `Chapter 4 `_ + of CUNY's course material for *Introduction to System Programming*, by + `Stewart Weiss `_ + +- `Chapter 11 + `_ + of the IEEE Open Group Base Specifications Issue 7, "General Terminal + Interface" + +- The GNU C Library documentation, section `Low-Level Terminal Interface + `_ + +- The source code of many popular terminal emulators. If there is ever any + question of "the meaning of a terminal capability", or whether or not your + preferred terminal emulator actually handles them, read the source! + + These are often written in the C language, and directly map the + "Control Sequence Inducers" (CSI, literally ``\x1b[`` for most modern + terminal types) emitted by most terminal capabilities to an action in a + series of ``case`` switch statements. + + - Many modern libraries are now based on `libvte + `_ (or just 'vte'): Gnome Terminal, + sakura, Terminator, Lilyterm, ROXTerm, evilvte, Termit, Termite, Tilda, + tinyterm, lxterminal. + - xterm, urxvt, SyncTerm, and EtherTerm. + - There are far too many to name, Chose one you like! + + +- The source code of the tty(4), pty(4), and the given "console driver" for + any posix-like operating system. If you search thoroughly enough, you will + eventually discover a terminal sequence decoder, usually a ``case`` switch + that translates ``\x1b[0m`` into a "reset color" action towards the video + driver. Though ``tty.c`` is linked here (the only kernel file common among + them), it is probably not the most interesting, but it can get you started: + + - `FreeBSD `_ + - `OpenBSD `_ + - `Illumos (Solaris) `_ + - `Minix `_ + - `Linux `_ + + The TTY driver is a great introduction to Kernel and Systems programming, + because familiar components may be discovered and experimented with. It is + available on all operating systems, and because of its critical nature, + examples of efficient file I/O, character buffers (often implemented as + "ring buffers") and even fine-grained kernel locking can be found. + +- `Thomas E. Dickey `_ has been maintaining + `xterm `_, as well as a + primary maintainer of many related packages such as `ncurses + `_ for quite a long + while. + +- `termcap & terminfo (O'Reilly Nutshell) + `_ + by Linda Mui, Tim O'Reilly, and John Strang. + +- Note that System-V systems, also known as `Unix98 + `_ (SunOS, HP-UX, + AIX and others) use a `Streams `_ + interface. On these systems, the `ioctl(2) + `_ + interface provides the ``PUSH`` and ``POP`` parameters to communicate with + a Streams device driver, which differs significantly from Linux and BSD. + + Many of these systems provide compatible interfaces for Linux, but they may + not always be as complete as the counterpart they emulate, most especially + in regards to managing pseudo-terminals. diff --git a/docs/history.rst b/docs/history.rst new file mode 100644 index 00000000..9a5aa750 --- /dev/null +++ b/docs/history.rst @@ -0,0 +1,281 @@ +Version History +=============== +1.17 + * introduced: 24-bit color support, detected by ``term.number_of_colors == 1 << 24``, and 24-bit + color foreground method :meth:`~Terminal.color_rgb` and background method + :meth:`~Terminal.on_color_rgb`, as well as 676 common X11 color attribute names are now + possible, such as ``term.aquamarine_on_wheat``. + * bugfix: prevent error condition, ``ValueError: underlying buffer has been detached`` in rare + conditions where sys.__stdout__ has been detached in test frameworks. :ghissue:`126`. + * bugfix: off-by-one error in :meth:`~.Terminal.get_location`, now accounts for ``%i`` in + cursor_report, :ghissue:`94`. + * bugfix :meth:`~Terminal.split_seqs` and related functions failed to match when the color index + was greater than 15, :ghissue:`101`. + * bugfix: Context Managers, :meth:`~.Terminal.fullscreen`, :meth:`~.Terminal.hidden_cursor`, and + :meth:`~Terminal.keypad` now flush the stream after writing their sequences. + * bugfix: ``chr(127)``, ``\x7f`` has changed from keycode ``term.DELETE`` to the more common + match, ``term.BACKSPACE``, :ghissue:115` by :ghuser:`jwezel`. + * deprecated: the direct curses ``move()`` capability is no longer recommended, suggest to use + :meth:`~.Terminal.move_xy()`, which matches the return value of :meth:`~.Terminal.get_location`. + * deprecated: ``superscript``, ``subscript``, ``shadow``, and ``dim`` are no longer "compoundable" + with colors, such as in phrase ``Terminal.blue_subscript('a')``. These attributes are not + typically supported, anyway. Use Unicode text or 256 or 24-bit color codes instead. + * deprecated: additional key names, such as ``KEY_TAB``, are no longer "injected" into the curses + module namespace. + * deprecated: :func:`curses.setupterm` is now called with :attr:`os.devnull` as the file + descriptor, let us know if this causes any issues. :ghissue:`59`. + * deprecated: :meth:`~Terminal.inkey` no longer raises RuntimeError when :attr:`~Terminal.stream` + is not a terminal, programs using :meth:`~Terminal.inkey` to block indefinitely if a keyboard is + not attached. :ghissue:`69`. + * deprecated: using argument ``_intr_continue`` to method :meth:`~Terminal.kbhit`, behavior is as + though such value is always True since 1.9. + +1.16 + * introduced: Windows support?! :ghissue:`110` by :ghuser:`avylove`. + +1.15 + * enhancement: disable timing integration tests for keyboard routines. + + They work perfectly fine for regression testing for contributing + developers, but people run our tests on build farms and open issues when + they fail. So we comment out these useful tests. :ghissue:`100`. + * enhancement: Support python 3.7. :ghissue:`102`. + * enhancement: Various fixes to test automation :ghissue:`108` + +1.14 + * bugfix: :meth:`~.Terminal.wrap` misbehaved for text containing newlines, + :ghissue:`74`. + * bugfix: TypeError when using ``PYTHONOPTIMIZE=2`` environment variable, + :ghissue:`84`. + * bugfix: define ``blessed.__version__`` value, + :ghissue:`92`. + * bugfix: detect sequences ``\x1b[0K`` and ``\x1b2K``, + :ghissue:`95`. + +1.13 + * enhancement: :meth:`~.Terminal.split_seqs` introduced, and 4x cost + reduction in related sequence-aware functions, :ghissue:`29`. + * deprecated: ``blessed.sequences.measure_length`` function superseded by + :func:`~.iter_parse` if necessary. + * deprecated: warnings about "binary-packed capabilities" are no longer + emitted on strange terminal types, making best effort. + +1.12 + * enhancement: :meth:`~.Terminal.get_location` returns the ``(row, col)`` + position of the cursor at the time of call for attached terminal. + * enhancement: a keyboard now detected as *stdin* when + :paramref:`~.Terminal.__init__.stream` is :obj:`sys.stderr`. + +1.11 + * enhancement: :meth:`~.Terminal.inkey` can return more quickly for + combinations such as ``Alt + Z`` when ``MetaSendsEscape`` is enabled, + :ghissue:`30`. + * enhancement: :class:`~.FormattingString` may now be nested, such as + ``t.red('red', t.underline('rum'))``, :ghissue:`61` + +1.10 + * workaround: provide ``sc`` and ``rc`` for Terminals of ``kind='ansi'``, + repairing :meth:`~.Terminal.location` :ghissue:`44`. + * bugfix: length of simple SGR reset sequence ``\x1b[m`` was not correctly + determined on all terminal types, :ghissue:`45`. + * deprecated: ``_intr_continue`` arguments introduced in 1.8 are now marked + deprecated in 1.10: beginning with python 3.5, the default behavior is as + though this argument is always True, `PEP-475 + `_, blessed does the same. + +1.9 + * enhancement: :paramref:`~.Terminal.wrap.break_long_words` now supported by + :meth:`Terminal.wrap` + * Ignore :class:`curses.error` message ``'tparm() returned NULL'``: + this occurs on win32 or other platforms using a limited curses + implementation, such as PDCurses_, where :func:`curses.tparm` is + not implemented, or no terminal capability database is available. + * Context manager :meth:`~.keypad` emits sequences that enable + "application keys" such as the diagonal keys on the numpad. + This is equivalent to :meth:`curses.window.keypad`. + * bugfix: translate keypad application keys correctly. + * enhancement: no longer depend on the '2to3' tool for python 3 support. + * enhancement: allow ``civis`` and ``cnorm`` (*hide_cursor*, *normal_hide*) + to work with terminal-type *ansi* by emulating support by proxy. + * enhancement: new public attribute: :attr:`~.kind`: the very same as given + :paramref:`Terminal.__init__.kind` keyword argument. Or, when not given, + determined by and equivalent to the ``TERM`` Environment variable. + +1.8 + * enhancement: export keyboard-read function as public method ``getch()``, + so that it may be overridden by custom terminal implementers. + * enhancement: allow :meth:`~.inkey` and :meth:`~.kbhit` to return early + when interrupted by signal by passing argument ``_intr_continue=False``. + * enhancement: allow ``hpa`` and ``vpa`` (*move_x*, *move_y*) to work on + tmux(1) or screen(1) by emulating support by proxy. + * enhancement: add :meth:`~.Terminal.rstrip` and :meth:`~.Terminal.lstrip`, + strips both sequences and trailing or leading whitespace, respectively. + * enhancement: include wcwidth_ library support for + :meth:`~.Terminal.length`: the printable width of many kinds of CJK + (Chinese, Japanese, Korean) ideographs and various combining characters + may now be determined. + * enhancement: better support for detecting the length or sequences of + externally-generated *ecma-48* codes when using ``xterm`` or ``aixterm``. + * bugfix: when :func:`locale.getpreferredencoding` returns empty string or + an encoding that is not valid for ``codecs.getincrementaldecoder``, + fallback to ASCII and emit a warning. + * bugfix: ensure :class:`~.FormattingString` and + :class:`~.ParameterizingString` may be pickled. + * bugfix: allow `~.inkey` and related to be called without a keyboard. + * **change**: ``term.keyboard_fd`` is set ``None`` if ``stream`` or + ``sys.stdout`` is not a tty, making ``term.inkey()``, ``term.cbreak()``, + ``term.raw()``, no-op. + * bugfix: ``\x1bOH`` (KEY_HOME) was incorrectly mapped as KEY_LEFT. + +1.7 + * Forked github project `erikrose/blessings`_ to `jquast/blessed`_, this + project was previously known as **blessings** version 1.6 and prior. + * introduced: context manager :meth:`~.cbreak`, which is equivalent to + entering terminal state by :func:`tty.setcbreak` and returning + on exit, as well as the lesser recommended :meth:`~.raw`, + pairing from :func:`tty.setraw`. + * introduced: :meth:`~.inkey`, which will return one or more characters + received by the keyboard as a unicode sequence, with additional attributes + :attr:`~.Keystroke.code` and :attr:`~.Keystroke.name`. This allows + application keys (such as the up arrow, or home key) to be detected. + Optional value :paramref:`~.inkey.timeout` allows for timed poll. + * introduced: :meth:`~.Terminal.center`, :meth:`~.Terminal.rjust`, + :meth:`~.Terminal.ljust`, allowing text containing sequences to be aligned + to detected horizontal screen width, or by + :paramref:`~.Terminal.center.width` specified. + * introduced: :meth:`~.wrap` method. Allows text containing sequences to be + word-wrapped without breaking mid-sequence, honoring their printable width. + * introduced: :meth:`~.Terminal.strip`, strips all sequences *and* + whitespace. + * introduced: :meth:`~.Terminal.strip_seqs` strip only sequences. + * introduced: :meth:`~.Terminal.rstrip` and :meth:`~.Terminal.lstrip` strips + both sequences and trailing or leading whitespace, respectively. + * bugfix: cannot call :func:`curses.setupterm` more than once per process + (from :meth:`Terminal.__init__`): Previously, blessed pretended + to support several instances of different Terminal :attr:`~.kind`, but was + actually using the :attr:`~.kind` specified by the first instantiation of + :class:`~.Terminal`. A warning is now issued. Although this is + misbehavior is still allowed, a :class:`warnings.WarningMessage` is now + emitted to notify about subsequent terminal misbehavior. + * bugfix: resolved issue where :attr:`~.number_of_colors` fails when + :attr:`~.does_styling` is ``False``. Resolves issue where piping tests + output would fail. + * bugfix: warn and set :attr:`~.does_styling` to ``False`` when the given + :attr:`~.kind` is not found in the terminal capability database. + * bugfix: allow unsupported terminal capabilities to be callable just as + supported capabilities, so that the return value of + :attr:`~.color`\(n) may be called on terminals without color + capabilities. + * bugfix: for terminals without underline, such as vt220, + ``term.underline('text')`` would emit ``'text' + term.normal``. + Now it emits only ``'text'``. + * enhancement: some attributes are now properties, raise exceptions when + assigned. + * enhancement: pypy is now a supported python platform implementation. + * enhancement: removed pokemon ``curses.error`` exceptions. + * enhancement: do not ignore :class:`curses.error` exceptions, unhandled + curses errors are legitimate errors and should be reported as a bug. + * enhancement: converted nose tests to pytest, merged travis and tox. + * enhancement: pytest fixtures, paired with a new ``@as_subprocess`` + decorator + are used to test a multitude of terminal types. + * enhancement: test accessories ``@as_subprocess`` resolves various issues + with different terminal types that previously went untested. + * deprecation: python2.5 is no longer supported (as tox does not supported). + +1.6 + * Add :attr:`~.does_styling`. This takes :attr:`~.force_styling` + into account and should replace most uses of :attr:`~.is_a_tty`. + * Make :attr:`~.is_a_tty` a read-only property like :attr:`~.does_styling`. + Writing to it never would have done anything constructive. + * Add :meth:`~.fullscreen`` and :meth:`hidden_cursor` to the + auto-generated docs. + +1.5.1 + * Clean up fabfile, removing the redundant ``test`` command. + * Add Travis support. + * Make ``python setup.py test`` work without spurious errors on 2.6. + * Work around a tox parsing bug in its config file. + * Make context managers clean up after themselves even if there's an + exception (Vitja Makarov :ghpull:`29`). + * Parameterizing a capability no longer crashes when there is no tty + (Vitja Makarov :ghpull:`31`) + +1.5 + * Add syntactic sugar and documentation for ``enter_fullscreen`` + and ``exit_fullscreen``. + * Add context managers :meth:`~.fullscreen` and :meth:`~.hidden_cursor`. + * Now you can force a :class:`~.Terminal` to never to emit styles by + passing keyword argument ``force_styling=None``. + +1.4 + * Add syntactic sugar for cursor visibility control and single-space-movement + capabilities. + * Endorse the :meth:`~.location` context manager for restoring cursor + position after a series of manual movements. + * Fix a bug in which :meth:`~.location` that wouldn't do anything when + passed zeros. + * Allow tests to be run with ``python setup.py test``. + +1.3 + * Added :attr:`~.number_of_colors`, which tells you how many colors the + terminal supports. + * Made :attr:`~.color`\(n) and :attr:`~.on_color`\(n) callable to wrap a + string, like the named colors can. Also, make them both fall back to the + ``setf`` and ``setb`` capabilities (like the named colors do) if the + termcap entries for ``setaf`` and ``setab`` are not available. + * Allowed :attr:`~.color` to act as an unparametrized string, not just a + callable. + * Made :attr:`~.height` and :attr:`~.width` examine any passed-in stream + before falling back to stdout (This rarely if ever affects actual behavior; + it's mostly philosophical). + * Made caching simpler and slightly more efficient. + * Got rid of a reference cycle between :class:`~.Terminal` and + :class:`~.FormattingString`. + * Updated docs to reflect that terminal addressing (as in :meth:`~location`) + is 0-based. + +1.2 + * Added support for Python 3! We need 3.2.3 or greater, because the curses + library couldn't decide whether to accept strs or bytes before that + (http://bugs.python.org/issue10570). + * Everything that comes out of the library is now unicode. This lets us + support Python 3 without making a mess of the code, and Python 2 should + continue to work unless you were testing types (and badly). Please file a + bug if this causes trouble for you. + * Changed to the MIT License for better world domination. + * Added Sphinx docs. + +1.1 + * Added nicely named attributes for colors. + * Introduced compound formatting. + * Added wrapper behavior for styling and colors. + * Let you force capabilities to be non-empty, even if the output stream is + not a terminal. + * Added :attr:`~.is_a_tty` to determine whether the output stream is a + terminal. + * Sugared the remaining interesting string capabilities. + * Allow :meth:`~.location` to operate on just an x *or* y coordinate. + +1.0 + * Extracted Blessed from `nose-progressive`_. + + +.. _`nose-progressive`: http://pypi.python.org/pypi/nose-progressive/ +.. _`erikrose/blessings`: https://github.com/erikrose/blessings +.. _`jquast/blessed`: https://github.com/jquast/blessed +.. _`issue tracker`: https://github.com/jquast/blessed/issues/ +.. _curses: https://docs.python.org/library/curses.html +.. _couleur: https://pypi.python.org/pypi/couleur +.. _colorama: https://pypi.python.org/pypi/colorama +.. _wcwidth: https://pypi.python.org/pypi/wcwidth +.. _`cbreak(3)`: http://www.openbsd.org/cgi-bin/man.cgi?query=cbreak&apropos=0&sektion=3 +.. _`curs_getch(3)`: http://www.openbsd.org/cgi-bin/man.cgi?query=curs_getch&apropos=0&sektion=3 +.. _`termios(4)`: http://www.openbsd.org/cgi-bin/man.cgi?query=termios&apropos=0&sektion=4 +.. _`terminfo(5)`: http://www.openbsd.org/cgi-bin/man.cgi?query=terminfo&apropos=0&sektion=5 +.. _tigetstr: http://www.openbsd.org/cgi-bin/man.cgi?query=tigetstr&sektion=3 +.. _tparm: http://www.openbsd.org/cgi-bin/man.cgi?query=tparm&sektion=3 +.. _SIGWINCH: https://en.wikipedia.org/wiki/SIGWINCH +.. _`API Documentation`: http://blessed.rtfd.org +.. _`PDCurses`: http://www.lfd.uci.edu/~gohlke/pythonlibs/#curses +.. _`ansi`: https://github.com/tehmaze/ansi diff --git a/docs/index.rst b/docs/index.rst index 63ee5be9..b0ee657d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,20 +1,25 @@ -======================= -Blessings API Reference -======================= +================================= +Welcome to Blessed documentation! +================================= -Read The Readme First -===================== +Contents: -This is the API documentation for the Blessings terminal library. Because -Blessings uses quite a bit of dynamism, you should `read the readme first`_, as -not all the useful stuff shows up in these autogenerated docs. However, if -you're looking for seldom-used keyword args or a peek at the internals, you're -in the right place. +.. toctree:: + :maxdepth: 3 + :glob: -.. _`read the readme first`: http://pypi.python.org/pypi/blessings/ + intro + overview + examples + further + pains + api + contributing + history -Then Read This If You Want -========================== +======= +Indexes +======= -.. autoclass:: blessings.Terminal - :members: __init__, __getattr__, location, height, width, color, on_color, number_of_colors, fullscreen, hidden_cursor +* :ref:`genindex` +* :ref:`modindex` diff --git a/docs/intro.rst b/docs/intro.rst new file mode 100644 index 00000000..e1275137 --- /dev/null +++ b/docs/intro.rst @@ -0,0 +1,224 @@ +| |docs| |travis| |codecov| +| |pypi| |downloads| |gitter| +| |linux| |windows| |mac| |bsd| + +.. |docs| image:: https://img.shields.io/readthedocs/blessed.svg?logo=read-the-docs + :target: https://blessed.readthedocs.org + :alt: Documentation Status + +.. |travis| image:: https://img.shields.io/travis/jquast/blessed/master.svg?logo=travis + :alt: Travis Continuous Integration + :target: https://travis-ci.org/jquast/blessed/ + +.. |codecov| image:: https://codecov.io/gh/jquast/blessed/branch/master/graph/badge.svg + :alt: codecov.io Code Coverage + :target: https://codecov.io/gh/jquast/blessed + +.. |pypi| image:: https://img.shields.io/pypi/v/blessed.svg?logo=pypi + :alt: Latest Version + :target: https://pypi.python.org/pypi/blessed + +.. |downloads| image:: https://img.shields.io/pypi/dm/blessed.svg?logo=pypi + :alt: Downloads + :target: https://pypi.python.org/pypi/blessed + +.. |gitter| image:: https://img.shields.io/badge/gitter-Join%20Chat-mediumaquamarine?logo=gitter + :alt: Join Chat + :target: https://gitter.im/jquast/blessed + +.. |linux| image:: https://img.shields.io/badge/Linux-yes-success?logo=linux + :alt: Linux supported + :target: https://pypi.python.org/pypi/blessed + +.. |windows| image:: https://img.shields.io/badge/Windows-NEW-success?logo=windows + :alt: Windows supported + :target: https://pypi.python.org/pypi/blessed + +.. |mac| image:: https://img.shields.io/badge/MacOS-yes-success?logo=apple + :alt: MacOS supported + :target: https://pypi.python.org/pypi/blessed + +.. |bsd| image:: https://img.shields.io/badge/BSD-yes-success?logo=freebsd + :alt: BSD supported + :target: https://pypi.python.org/pypi/blessed + +Introduction +============ + +Blessed is a thin, practical wrapper around terminal capabilities in Python. + +Coding with *Blessed* looks like this... + +.. code-block:: python + + from blessed import Terminal + + t = Terminal() + + print(t.bold('Hi there!')) + print(t.bold_red_on_bright_green('It hurts my eyes!')) + + with t.location(0, t.height - 1): + print(t.center(t.blink('press any key to continue.'))) + + with t.cbreak(): + inp = t.inkey() + print('You pressed ' + repr(inp)) + + +Brief Overview +-------------- + +*Blessed* is a more simplified wrapper around curses_, providing : + +* Styles, color, and maybe a little positioning without necessarily + clearing the whole screen first. +* Works great with standard Python string formatting. +* Provides up-to-the-moment terminal height and width, so you can respond to + terminal size changes. +* Avoids making a mess if the output gets piped to a non-terminal: + outputs to any file-like object such as *StringIO*, files, or pipes. +* Uses the `terminfo(5)`_ database so it works with any terminal type + and supports any terminal capability: No more C-like calls to tigetstr_ + and tparm_. +* Keeps a minimum of internal state, so you can feel free to mix and match with + calls to curses or whatever other terminal libraries you like. +* Provides plenty of context managers to safely express terminal modes, + automatically restoring the terminal to a safe state on exit. +* Act intelligently when somebody redirects your output to a file, omitting + all of the terminal sequences such as styling, colors, or positioning. +* Dead-simple keyboard handling: safely decoding unicode input in your + system's preferred locale and supports application/arrow keys. +* Allows the printable length of strings containing sequences to be + determined. + +Before And After +---------------- + +With the built-in curses_ module, this is how you would typically +print some underlined text at the bottom of the screen: + +.. code-block:: python + + from curses import tigetstr, setupterm, tparm + from fcntl import ioctl + from os import isatty + import struct + import sys + from termios import TIOCGWINSZ + + # If we want to tolerate having our output piped to other commands or + # files without crashing, we need to do all this branching: + if hasattr(sys.stdout, 'fileno') and isatty(sys.stdout.fileno()): + setupterm() + sc = tigetstr('sc') + cup = tigetstr('cup') + rc = tigetstr('rc') + underline = tigetstr('smul') + normal = tigetstr('sgr0') + else: + sc = cup = rc = underline = normal = '' + + # Save cursor position. + print(sc) + + if cup: + # tigetnum('lines') doesn't always update promptly, hence this: + height = struct.unpack('hhhh', ioctl(0, TIOCGWINSZ, '\000' * 8))[0] + + # Move cursor to bottom. + print(tparm(cup, height - 1, 0)) + + print('This is {under}underlined{normal}!' + .format(under=underline, normal=normal)) + + # Restore cursor position. + print(rc) + +The same program with *Blessed* is simply: + +.. code-block:: python + + from blessed import Terminal + + term = Terminal() + with term.location(0, term.height - 1): + print('This is' + term.underline('underlined') + '!') + +Requirements +------------ + +*Blessed* is tested with Python 2.7, 3.4, 3.5, 3.6, and 3.7 on Linux, Mac, and +FreeBSD. Windows support was just added in October 2019, give it a try, and +please report any strange issues! + +Further Documentation +--------------------- + +More documentation can be found at http://blessed.readthedocs.org/en/latest/ + +Bugs, Contributing, Support +--------------------------- + +**Bugs** or suggestions? Visit the `issue tracker`_ and file an issue. +We welcome your bug reports and feature suggestions! + +Would you like to **contribute**? That's awesome! We've written a +`guide `_ +to help you. + +Are you stuck and need **support**? Give `stackoverflow`_ a try. If +you're still having trouble, we'd like to hear about it! Open an issue +in the `issue tracker`_ with a well-formed question. + +License +------- + +*Blessed* is under the MIT License. See the LICENSE file. + +Forked +------ + +*Blessed* is a fork of `blessings `_. +Changes since 1.7 have all been proposed but unaccepted upstream. + +Enhancements only in *Blessed*: + * 24-bit color support with `Terminal.color_rgb()`_ and `Terminal.on_color_rgb()`_ methods + * X11 color name attributes + * Windows support + * `Terminal.length()`_ to determine printable length of text containing sequences + * `Terminal.strip()`_, `Terminal.rstrip()`_, `Terminal.lstrip()`_, + and `Terminal.strip_seqs()`_ for removing sequences from text + * `Terminal.wrap()`_ for wrapping text containing sequences at a specified width + * `Terminal.center()`_, `Terminal.rjust()`_, and `Terminal.ljust()`_ + for alignment of text containing sequences + * `Terminal.cbreak()`_ and `Terminal.raw()`_ context managers for keyboard input + * `Terminal.inkey()`_ for keyboard event detection + +Furthermore, a project in the node.js language of the `same name +`_ is **not** related, or a fork +of each other in any way. + +.. _`issue tracker`: https://github.com/jquast/blessed/issues/ +.. _curses: https://docs.python.org/3/library/curses.html +.. _tigetstr: http://www.openbsd.org/cgi-bin/man.cgi/OpenBSD-current/man3/tigetstr.3 +.. _tparm: http://www.openbsd.org/cgi-bin/man.cgi/OpenBSD-current/man3/tparm.3 +.. _ansi: https://github.com/tehmaze/ansi +.. _colorama: https://pypi.python.org/pypi/colorama +.. _PDCurses: http://www.lfd.uci.edu/~gohlke/pythonlibs/#curses +.. _`terminfo(5)`: http://invisible-island.net/ncurses/man/terminfo.5.html +.. _`stackoverflow`: http://stackoverflow.com/ +.. _`Terminal.color_rgb()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.color_rgb +.. _`Terminal.on_color_rgb()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.on_color_rgb +.. _`Terminal.length()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.length +.. _`Terminal.strip()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.strip +.. _`Terminal.rstrip()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.rstrip +.. _`Terminal.lstrip()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.lstrip +.. _`Terminal.strip_seqs()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.strip_seqs +.. _`Terminal.wrap()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.wrap +.. _`Terminal.center()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.center +.. _`Terminal.rjust()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.rjust +.. _`Terminal.ljust()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.ljust +.. _`Terminal.cbreak()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.cbreak +.. _`Terminal.raw()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.raw +.. _`Terminal.inkey()`: https://blessed.readthedocs.io/en/stable/api.html#blessed.terminal.Terminal.inkey diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index c24653f0..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,170 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. changes to make an overview over all changed/added/deprecated items - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\blessings.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\blessings.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -:end diff --git a/docs/overview.rst b/docs/overview.rst new file mode 100644 index 00000000..4e85fb90 --- /dev/null +++ b/docs/overview.rst @@ -0,0 +1,574 @@ +Overview +======== + +Blessed provides just **one** top-level object: :class:`~.Terminal`. +Instantiating a :class:`~.Terminal` figures out whether you're on a terminal at +all and, if so, does any necessary setup: + + >>> term = Terminal() + +After that, you can proceed to ask it all sorts of things about the terminal, +such as its size: + + >>> term.height, term.width + (34, 102) + +Its color support: + + >>> term.number_of_colors + 256 + +And use construct strings containing color and styling: + + >>> term.green_reverse('ALL SYSTEMS GO') + '\x1b[32m\x1b[7mALL SYSTEMS GO\x1b[m' + +Furthermore, the special sequences inserted with application keys +(arrow and function keys) are understood and decoded, as well as your +locale-specific encoded multibyte input, such as utf-8 characters. + + +Styling and Formatting +---------------------- + +Lots of handy formatting codes are available as attributes on a +:class:`~.Terminal` class instance. For example:: + + from blessed import Terminal + + term = Terminal() + + print('I am ' + term.bold + 'bold' + term.normal + '!') + +These capabilities (*bold*, *normal*) are translated to their sequences, which +when displayed simply change the video attributes. And, when used as a +callable, automatically wraps the given string with this sequence, and +terminates it with *normal*. + +The same can be written as:: + + print('I am' + term.bold('bold') + '!') + +You may also use the :class:`~.Terminal` instance as an argument for +the :meth:`str.format`` method, so that capabilities can be displayed in-line +for more complex strings:: + + print('{t.red_on_yellow}Candy corn{t.normal} for everyone!'.format(t=term)) + + +Capabilities +~~~~~~~~~~~~ + +Capabilities supported by most terminals are: + +``bold`` + Turn on 'extra bright' mode. +``reverse`` + Switch fore and background attributes. +``blink`` + Turn on blinking. +``normal`` + Reset attributes to default. +``underline`` + Enable underline mode. + +Note that, while the inverse of *underline* is *no_underline*, the only way +to turn off *bold* or *reverse* is *normal*, which also cancels any custom +colors. + +Many of these are aliases, their true capability names (such as 'smul' for +'begin underline mode') may still be used. Any capability in the `terminfo(5)`_ +manual, under column **Cap-name**, may be used as an attribute of a +:class:`~.Terminal` instance. If it is not a supported capability for the +current terminal, or a non-tty is used as output stream, an empty string is +always returned. + +Colors +~~~~~~ + +XXX TODO XXX + +all X11 colors are available, +rgb(int, int, int), +truecolor is automatically detected, +or, downsampled to 256 or 16, 8, etc colorspace. + +Color terminals are capable of at least 8 basic colors. + +* ``black`` +* ``red`` +* ``green`` +* ``yellow`` +* ``blue`` +* ``magenta`` +* ``cyan`` +* ``white`` + +The same colors, prefixed with *bright_* (synonymous with *bold_*), +such as *bright_blue*, provides 16 colors in total. + +Prefixed with *on_*, the given color is used as the background color. +Some terminals also provide an additional 8 high-intensity versions using +*on_bright*, some example compound formats:: + + from blessed import Terminal + + term = Terminal() + + print(term.on_bright_blue('Blue skies!')) + + print(term.bright_red_on_bright_yellow('Pepperoni Pizza!')) + +You may also specify the :meth:`~.Terminal.color` index by number, which +should be within the bounds of value returned by +:attr:`~.Terminal.number_of_colors`:: + + from blessed import Terminal + + term = Terminal() + + for idx in range(term.number_of_colors): + print(term.color(idx)('Color {0}'.format(idx))) + +You can check whether the terminal definition used supports colors, and how +many, using the :attr:`~.Terminal.number_of_colors` property, which returns +any of *0*, *8* or *256* for terminal types such as *vt220*, *ansi*, and +*xterm-256color*, respectively. + +Colorless Terminals +~~~~~~~~~~~~~~~~~~~ + +If the terminal defined by the Environment variable **TERM** does not support +colors, these simply return empty strings. When used as a callable, the string +passed as an argument is returned as-is. Most sequences emitted to a terminal +that does not support them are usually harmless and have no effect. + +Colorless terminals (such as the amber or green monochrome *vt220*) do not +support colors but do support reverse video. For this reason, it may be +desirable in some applications to simply select a foreground color, followed +by reverse video to achieve the desired background color effect:: + + from blessed import Terminal + + term = Terminal() + + print(term.green_reverse('some terminals standout more than others')) + +Which appears as *black on green* on color terminals, but *black text +on amber or green* on monochrome terminals. Whereas the more declarative +formatter *black_on_green* would remain colorless. + +.. note:: On most color terminals, *bright_black* is not invisible -- it is + actually a very dark shade of grey! + +Compound Formatting +~~~~~~~~~~~~~~~~~~~ + +If you want to do lots of crazy formatting all at once, you can just mash it +all together:: + + from blessed import Terminal + + term = Terminal() + + print(term.bold_underline_green_on_yellow('Woo')) + +I'd be remiss if I didn't credit couleur_, where I probably got the idea for +all this mashing. + +This compound notation comes in handy if you want to allow users to customize +formatting, just allow compound formatters, like *bold_green*, as a command +line argument or configuration item such as in the :ref:`tprint.py` +demonstration script. + +Moving The Cursor +----------------- + +If you just want to move and aren't worried about returning, do something like +this:: + + from blessed import Terminal + + term = Terminal() + print(term.move_xy(10, 1) + 'Hi, mom!') + +There are three basic movement capabilities: + +``move_xy(x, y)`` + Position cursor at given **x**, **y**. +``move_x(x)`` + Position cursor at column **x**. +``move_y(y)`` + Position cursor at row **y**. + +A context manager, :meth:`~.Terminal.location` is provided to move the cursor +to an *(x, y)* screen position and restore the previous position upon exit:: + + from blessed import Terminal + + term = Terminal() + + with term.location(0, term.height - 1): + print('Here is the bottom.') + + print('This is back where I came from.') + +Parameters to :meth:`~.Terminal.location` are the **optional** *x* and/or *y* +keyword arguments:: + + with term.location(y=10): + print('We changed just the row.') + +When omitted, it saves the current cursor position, and restore it upon exit:: + + with term.location(): + print(term.move_xy(1, 1) + 'Hi') + print(term.move_xy(9, 9) + 'Mom') + +.. note:: calls to :meth:`~.Terminal.location` may not be nested. + +Finding The Cursor +------------------ + +We can determine the cursor's current position at anytime using +:meth:`~.get_location`, returning the current (y, x) location. This uses a +kind of "answer back" sequence that your terminal emulator responds to. If +the terminal may not respond, the :paramref:`~.get_location.timeout` keyword +argument can be specified to return coordinates (-1, -1) after a blocking +timeout:: + + from blessed import Terminal + + term = Terminal() + + row, col = term.get_location(timeout=5) + + if row < term.height: + print(term.move_y(term.height) + 'Get down there!') + +One-Notch Movement +~~~~~~~~~~~~~~~~~~ + +Finally, there are some parameterless movement capabilities that move the +cursor one character in various directions: + +* ``move_left`` +* ``move_right`` +* ``move_up`` +* ``move_down`` + +.. note:: *move_down* is often valued as *\\n*, which additionally returns + the carriage to column 0, depending on your terminal emulator, and may + also destructively destroy any characters at the given position to the + end of margin. + + +Height And Width +---------------- + +Use the :attr:`~.Terminal.height` and :attr:`~.Terminal.width` properties to +determine the size of the window:: + + from blessed import Terminal + + term = Terminal() + height, width = term.height, term.width + with term.location(x=term.width / 3, y=term.height / 3): + print('1/3 ways in!') + +These values are always current. To detect when the size of the window +changes, you may author a callback for SIGWINCH_ signals:: + + import signal + from blessed import Terminal + + term = Terminal() + + def on_resize(sig, action): + print('height={t.height}, width={t.width}'.format(t=term)) + + signal.signal(signal.SIGWINCH, on_resize) + + # wait for keypress + term.inkey() + + +Clearing The Screen +------------------- + +Blessed provides syntactic sugar over some screen-clearing capabilities: + +``clear`` + Clear the whole screen. +``clear_eol`` + Clear to the end of the line. +``clear_bol`` + Clear backward to the beginning of the line. +``clear_eos`` + Clear to the end of screen. + + +Full-Screen Mode +---------------- + +If you've ever noticed a program, such as an editor, restores the previous +screen (such as your shell prompt) after exiting, you're seeing the +*enter_fullscreen* and *exit_fullscreen* attributes in effect. + +``enter_fullscreen`` + Switch to alternate screen, previous screen is stored by terminal driver. +``exit_fullscreen`` + Switch back to standard screen, restoring the same terminal state. + +There's also a context manager you can use as a shortcut:: + + from __future__ import division + from blessed import Terminal + + term = Terminal() + with term.fullscreen(): + print(term.move_y(term.height // 2) + + term.center('press any key').rstrip()) + term.inkey() + + +Pipe Savvy +---------- + +If your program isn't attached to a terminal, such as piped to a program +like *less(1)* or redirected to a file, all the capability attributes on +:class:`~.Terminal` will return empty strings. You'll get a nice-looking +file without any formatting codes gumming up the works. + +If you want to override this, such as when piping output to *less -r*, pass +argument value *True* to the :paramref:`~.Terminal.force_styling` parameter. + +In any case, there is a :attr:`~.Terminal.does_styling` attribute that lets +you see whether the terminal attached to the output stream is capable of +formatting. If it is *False*, you may refrain from drawing progress +bars and other frippery and just stick to content:: + + from blessed import Terminal + + term = Terminal() + if term.does_styling: + with term.location(x=0, y=term.height - 1): + print('Progress: [=======> ]') + print(term.bold("60%")) + + +Sequence Awareness +------------------ + +Blessed may measure the printable width of strings containing sequences, +providing :meth:`~.Terminal.center`, :meth:`~.Terminal.ljust`, and +:meth:`~.Terminal.rjust` methods, using the terminal screen's width as +the default *width* value:: + + from __future__ import division + from blessed import Terminal + + term = Terminal() + with term.location(y=term.height // 2): + print(term.center(term.bold('bold and centered'))) + +Any string containing sequences may have its printable length measured using +the :meth:`~.Terminal.length` method. + +Additionally, a sequence-aware version of :func:`textwrap.wrap` is supplied as +class as method :meth:`~.Terminal.wrap` that is also sequence-aware, so now you +may word-wrap strings containing sequences. The following example displays a +poem word-wrapped to 25 columns:: + + from blessed import Terminal + + term = Terminal() + + poem = (term.bold_cyan('Plan difficult tasks'), + term.cyan('through the simplest tasks'), + term.bold_cyan('Achieve large tasks'), + term.cyan('through the smallest tasks')) + + for line in poem: + print('\n'.join(term.wrap(line, width=25, subsequent_indent=' ' * 4))) + +Sometimes it is necessary to make sense of sequences, and to distinguish them +from plain text. The :meth:`~.Terminal.split_seqs` method can allow us to +iterate over a terminal string by its characters or sequences:: + + from blessed import Terminal + + term = Terminal() + + phrase = term.bold('bbq') + print(term.split_seqs(phrase)) + +Will display something like, ``['\x1b[1m', 'b', 'b', 'q', '\x1b(B', '\x1b[m']`` + +Similarly, the method :meth:`~.Terminal.strip_seqs` may be used on a string to +remove all occurrences of terminal sequences:: + + from blessed import Terminal + + term = Terminal() + phrase = term.bold_black('coffee') + print(repr(term.strip_seqs(phrase))) + +Will display only ``'coffee'`` + +Keyboard Input +-------------- + +The built-in python function :func:`raw_input` does not return a value until +the return key is pressed, and is not suitable for detecting each individual +keypress, much less arrow or function keys. + +Furthermore, when calling :func:`os.read` on input stream, only bytes are +received, which must be decoded to unicode using the locale-preferred encoding. +Finally, multiple bytes may be emitted which must be paired with some verb like +``KEY_LEFT``: blessed handles all of these special cases for you! + +cbreak +~~~~~~ + +The context manager :meth:`~.Terminal.cbreak` can be used to enter +*key-at-a-time* mode: Any keypress by the user is immediately consumed by read +calls:: + + from blessed import Terminal + import sys + + term = Terminal() + + with term.cbreak(): + # block until any single key is pressed. + sys.stdin.read(1) + +The mode entered using :meth:`~.Terminal.cbreak` is called +`cbreak(3)`_ in curses: + + The cbreak routine disables line buffering and erase/kill + character-processing (interrupt and flow control characters are unaffected), + making characters typed by the user immediately available to the program. + +raw +~~~ + +:meth:`~.Terminal.raw` is similar to cbreak, except that control-C and +other keystrokes are "ignored", and received as their keystroke value +rather than interrupting the program with signals. + +Output processing is also disabled, you must print phrases with carriage +return after newline. Without raw mode:: + + print("hello, world.") + +With raw mode:: + + print("hello, world.", endl="\r\n") + +inkey +~~~~~ + +The method :meth:`~.Terminal.inkey` combined with cbreak_ +completes the circle of providing key-at-a-time keyboard input with multibyte +encoding and awareness of application keys. + +:meth:`~.Terminal.inkey` resolves many issues with terminal input by +returning a unicode-derived :class:`~.Keystroke` instance. Its return value +may be printed, joined with, or compared like any other unicode strings, it +also provides the special attributes :attr:`~.Keystroke.is_sequence`, +:attr:`~.Keystroke.code`, and :attr:`~.Keystroke.name`:: + + from blessed import Terminal + + term = Terminal() + + print("press 'q' to quit.") + with term.cbreak(): + val = '' + while val.lower() != 'q': + val = term.inkey(timeout=5) + if not val: + # timeout + print("It sure is quiet in here ...") + elif val.is_sequence: + print("got sequence: {0}.".format((str(val), val.name, val.code))) + elif val: + print("got {0}.".format(val)) + print('bye!') + +Its output might appear as:: + + got sequence: ('\x1b[A', 'KEY_UP', 259). + got sequence: ('\x1b[1;2A', 'KEY_SUP', 337). + got sequence: ('\x1b[17~', 'KEY_F6', 270). + got sequence: ('\x1b', 'KEY_ESCAPE', 361). + got sequence: ('\n', 'KEY_ENTER', 343). + got /. + It sure is quiet in here ... + got sequence: ('\x1bOP', 'KEY_F1', 265). + It sure is quiet in here ... + got q. + bye! + +A :paramref:`~.Terminal.inkey.timeout` value of *None* (default) will block +forever until a keypress is received. Any other value specifies the length of +time to poll for input: if no input is received after the given time has +elapsed, an empty string is returned. A :paramref:`~.Terminal.inkey.timeout` +value of *0* is non-blocking. + +keyboard codes +~~~~~~~~~~~~~~ + +When the :attr:`~.Keystroke.is_sequence` property tests *True*, the value +is a special application key of the keyboard. The :attr:`~.Keystroke.code` +attribute may then be compared with attributes of :class:`~.Terminal`, +which are duplicated from those found in `curs_getch(3)`_, or those +`constants `_ +in :mod:`curses` beginning with phrase *KEY_*. + +Some of these mnemonics are shorthand or predate modern PC terms and +are difficult to recall. The following helpful aliases are provided +instead: + +=================== ============= ==================== +blessed curses note +=================== ============= ==================== +``KEY_DELETE`` ``KEY_DC`` chr(127). +``KEY_TAB`` chr(9) +``KEY_INSERT`` ``KEY_IC`` +``KEY_PGUP`` ``KEY_PPAGE`` +``KEY_PGDOWN`` ``KEY_NPAGE`` +``KEY_ESCAPE`` ``KEY_EXIT`` +``KEY_SUP`` ``KEY_SR`` (shift + up) +``KEY_SDOWN`` ``KEY_SF`` (shift + down) +``KEY_DOWN_LEFT`` ``KEY_C1`` (keypad lower-left) +``KEY_UP_RIGHT`` ``KEY_A1`` (keypad upper-left) +``KEY_DOWN_RIGHT`` ``KEY_C3`` (keypad lower-left) +``KEY_UP_RIGHT`` ``KEY_A3`` (keypad lower-right) +``KEY_CENTER`` ``KEY_B2`` (keypad center) +``KEY_BEGIN`` ``KEY_BEG`` +=================== ============= ==================== + +The :attr:`~.Keystroke.name` property will prefer these +aliases over the built-in :mod:`curses` names. + +The following are **not** available in the :mod:`curses` module, but are +provided for keypad support, especially where the :meth:`~.Terminal.keypad` +context manager is used with numlock on: + +* ``KEY_KP_MULTIPLY`` +* ``KEY_KP_ADD`` +* ``KEY_KP_SEPARATOR`` +* ``KEY_KP_SUBTRACT`` +* ``KEY_KP_DECIMAL`` +* ``KEY_KP_DIVIDE`` +* ``KEY_KP_0`` through ``KEY_KP_9`` + +.. _couleur: https://pypi.python.org/pypi/couleur +.. _`cbreak(3)`: http://www.openbsd.org/cgi-bin/man.cgi/OpenBSD-current/man3/cbreak.3 +.. _`raw(3)`: http://www.openbsd.org/cgi-bin/man.cgi/OpenBSD-current/man3/raw.3 +.. _`curs_getch(3)`: http://www.openbsd.org/cgi-bin/man.cgi/OpenBSD-current/man3/curs_getch.3 +.. _`terminfo(5)`: http://www.openbsd.org/cgi-bin/man.cgi/OpenBSD-current/man4/termios.3 +.. _SIGWINCH: https://en.wikipedia.org/wiki/SIGWINCH diff --git a/docs/pains.rst b/docs/pains.rst new file mode 100644 index 00000000..ac7454d6 --- /dev/null +++ b/docs/pains.rst @@ -0,0 +1,351 @@ +Growing Pains +============= + +When making terminal applications, there are a surprisingly number of +portability issues and edge cases. Although Blessed provides an abstraction +for the full curses capability database, it is not sufficient to secure +you from several considerations shared here. + +8 and 16 colors +--------------- + +XXX + +https://en.wikipedia.org/wiki/ANSI_escape_code#3/4_bit + +As a curses library, I think we should safely assume to map our colorspace +to rgb values that match xterm. + +XXX + +Where 8 and 16 colors are used, they should be assumed to be the +`CGA Color Palette`_. Though there is no terminal standard that proclaims +that the CGA colors are used, their values are the best approximations +across all common hardware terminals and terminal emulators. + +A recent phenomenon of users is to customize their base 16 colors to provide +(often, more "washed out") color schemes. Furthermore, we are only recently +getting LCD displays of colorspaces that achieve close approximation to the +original video terminals. Some find these values uncomfortably intense: in +their original CRT form, their contrast and brightness was lowered by hardware +dials, whereas today's LCD's typically display colors well near full intensity. + +Though we may not *detect* the colorspace of the remote terminal, **we can**: + +- Trust that a close approximation of the `CGA Color Palette`_ for the base + 16 colors will be displayed for **most** users. + +- Trust that users who have made the choice to adjust their palette have made + the choice to do so, and are able to re-adjust such palettes as necessary + to accommodate different programs (such as through the use of "Themes"). + +.. note:: + + It has become popular to use dynamic system-wide color palette adjustments + in software such as `f.lux`_, "Dark Mode", "Night Mode", and others, + which adjust the system-wide "Color Profile" of the entire graphics display + depending on the time of day. One might assume that ``term.blue("text")`` + may become **completely** invisible to such users during the night! + + +Where is brown, purple, or grey? +-------------------------------- + +There are **only 8 color names** on a 16-color terminal: The second set of +eight colors are "high intensity" versions of the first in direct series. + +The colors *brown*, *purple*, and *grey* are not named in the first series, +though they are available: + +- **brown**: *yellow is brown*, only high-intensity yellow + (``bright_yellow``) is yellow! + +- **purple**: *magenta is purple*. In earlier, 4-bit color spaces, there + were only black, cyan, magenta, and white of low and high intensity, such + as found on common home computers like the `ZX Spectrum`_. + + Additional "colors" were only possible through dithering. The color names + cyan and magenta on later graphics adapters are carried over from its + predecessors. Although the color cyan remained true in RGB value on + 16-color to its predecessor, magenta shifted farther towards blue from red + becoming purple (as true red was introduced as one of the new base 8 + colors). + +- **grey**: there are actually **three shades of grey** (or American spelling, + 'gray'), though the color attribute named 'grey' does not exist! + + In ascending order of intensity, the shades of grey are: + + - ``bold_black``: in lieu of the uselessness of an "intense black", this is + color is instead mapped to "dark grey". + - ``white``: white is actually mild compared to the true color 'white': this + is more officially mapped to "common grey", and is often the default + foreground color. + - ``bright_white``: is pure white (``#ffffff``). + + +white-on-black +~~~~~~~~~~~~~~ + +The default foreground and background should be assumed as *white-on-black*. + +For quite some time, the families of terminals produced by DEC, IBM, and +Tektronix dominated the computing world with the default color scheme of +*green-on-black* and less commonly *amber-on-black* monochrome displays: +The inverse was a non-default configuration. The IBM 3270 clients exclusively +used *green-on-black* in both hardware and software emulators, and is likely +a driving factor of the default *white-on-black* appearance of the first IBM +Personal Computer. + +The less common *black-on-white* "ink paper" style of emulators is a valid +concern for those designing terminal interfaces. The color scheme of +*black-on-white* directly conflicts with the intention of `bold is bright`_, +where ``term.bright_red('ATTENTION!')`` may become difficult to read, +as it appears as *pink on white*! + + +History of ink-paper inspired black-on-white +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Early home computers with color video adapters, such as the Commodore 64 +provided *white-on-blue* as their basic video terminal configuration. One can +only assume such appearances were provided to demonstrate their color +capabilities over competitors (such as the Apple ][). + +More common, X11's xterm and the software HyperTerm bundle with MS Windows +provided an "ink on paper" *black-on-white* appearance as their default +configuration. Two popular emulators continue to supply *black-on-white* by +default to this day: Xorg's xterm and Apple's Terminal.app. + +.. note:: Windows no longer supplies a terminal emulator: the "command prompt" + as we know it now uses the MSVCRT API routines to interact and does not + make use of terminal sequences, even ignoring those sequences that MS-DOS + family of systems previously interpreted through the ANSI.SYS driver, + though it continues to default to *white-on-black*. + + +Bold is bright +-------------- + +**Where Bold is used, it should be assumed to be *Bright***. + +Due to the influence of early graphics adapters providing a set of 8 +"low-intensity" and 8 "high intensity" versions of the first, the term +"bold" for terminals sequences is synonymous with "high intensity" in +almost all circumstances. + + +History of bold as "wide stroke" +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In typography, the true translation of "bold" is that a font should be +displayed *with emphasis*. In classical terms, this would be achieved by +pen be re-writing over the same letters. On a teletype or printer, this was +similarly achieved by writing a character, backspacing, then repeating the +same character in a form called **overstriking**. + +To bold a character, ``C``, one would emit the sequence ``C^HC`` where +``^H`` is backspace (0x08). To underline ``C``, one would would emit +``C^H_``. + +**Video terminals do not support overstriking**. Though the mdoc format for +manual pages continue to emit overstriking sequences for bold and underline, +translators such as mandoc will instead emit an appropriate terminal sequence. + +Many characters previously displayable by combining using overstriking of +ASCII characters on teletypes, such as: ±, ≠, or ⩝ were delegated to a +`code page`_ or lost entirely until the introduction of multibyte encodings. + +Much like the "ink paper" introduction in windowing systems for terminal +emulators, "wide stroke" bold was introduced only much later when combined +with operating systems that provided font routines such as TrueType. + + +Enforcing white-on-black +~~~~~~~~~~~~~~~~~~~~~~~~ + +In conclusion, *white-on-black* should be considered the default. If there is +a need to **enforce** *white-on-black* for terminal clients suspected to be +defaulted as *black-on-white*, one would want to trust that a combination of +``term.home + term.white_on_black + term.clear`` should repaint the entire +emulator's window with the desired effect. + +However, this cannot be trusted to **all** terminal emulators to perform +correctly! Depending on your audience, you may instead ensure that the +entire screen (including whitespace) is painted using the ``on_black`` +mnemonic. + +Beware of customized color schemes +---------------------------------- + +A recent phenomenon is for users to customize these first 16 colors of their +preferred emulator to colors of their own liking. Though this has always been +possible with ``~/.XResources``, the introduction of PuTTy and iTerm2 to +interactively adjustment these colors have made this much more common. + +This may cause your audience to see your intended interface in a wildly +different form. Your intended presentation may appear mildly unreadable. + +Users are certainly free to customize their colors however they like, but it +should be known that displaying ``term.black_on_red("DANGER!")`` may appear +as "grey on pastel red" to your audience, reducing the intended effect of +intensity. + + +256 colors can avoid customization +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The first instinct of a user who aliases ls(1) to ``ls -G`` or ``colorls``, +when faced with the particularly low intensity of the default ``blue`` +attribute is **to adjust their terminal emulator's color scheme of the base +16 colors**. + +This is not necessary: the environment variable ``LSCOLORS`` may be redefined +to map an alternative color for blue, or to use ``bright_blue`` in its place. + +Furthermore, all common terminal text editors such as emacs or vim may be +configured with "colorschemes" to make use of the 256-color support found in +most modern emulators. Many readable shades of blue are available, and many +programs that emit such colors can be configured to emit a higher or lower +intensity variant from the full 256 color space through program configuration. + + +Monochrome and reverse +---------------------- + +Note that ``reverse`` takes the current foreground and background colors and +reverses them. In contrast, the compound formatter ``black_on_red`` would +fail to set the background *or* foreground color on a monochrome display, +resulting in the same stylization as ``normal`` -- it would not appear any +different! + +If your userbase consists of monochrome terminals, you may wish to provide +"lightbars" and other such effects using the compound formatter +``red_reverse``. In the literal sense of "set foreground color to red, then +swap foreground and background", this produces a similar effect on +**both** color and monochrome displays. + +For text, very few ``{color}_on_{color}`` formatters are visible with the +base 16 colors, so you should generally wish for ``black_on_{color}`` +anyway. By using ``{color}_reverse`` you may be portable with monochrome +displays as well. + + +Multibyte Encodings and Code pages +---------------------------------- + +A terminal that supports both multibyte encodings (UTF-8) and legacy 8-bit +code pages (ISO 2022) may instruct the terminal to switch between both +modes using the following sequences: + + - ``\x1b%G`` activates UTF-8 with an unspecified implementation level + from ISO 2022 in a way that allows to go back to ISO 2022 again. + - ``\x1b%@`` goes back from UTF-8 to ISO 2022 in case UTF-8 had been + entered via ``\x1b%G``. + - ``\x1b%/G`` switches to UTF-8 Level 1 with no return. + - ``\x1b%/H`` switches to UTF-8 Level 2 with no return. + - ``\x1b%/I`` switches to UTF-8 Level 3 with no return. + +When a terminal is in ISO 2022 mode, you may use a sequence +to request a terminal to change its `code page`_. It begins by ``\x1b(``, +followed by an ASCII character representing a code page selection. For +example ``\x1b(U`` on the legacy VGA Linux console switches to the `IBM CP437`_ +`code page`_, allowing North American MS-DOS artwork to be displayed in its +natural 8-bit byte encoding. A list of standard codes and the expected code +page may be found on Thomas E. Dickey's xterm control sequences section on +sequences following the `Control-Sequence-Inducer`_. + +For more information, see `What are the issues related to UTF-8 terminal +emulators? `_ by +`Markus Kuhn `_ of the University of +Cambridge. + +One can be assured that the connecting client is capable of representing +UTF-8 and other multibyte character encodings by the Environment variable +``LANG``. If this is not possible or reliable, there is an intrusive detection +method demonstrated in the example program :ref:`detect-multibyte.py`. + +Alt or meta sends Escape +------------------------ + +Programs using GNU readline such as bash continue to provide default mappings +such as *ALT+u* to uppercase the word after cursor. This is achieved +by the configuration option altSendsEscape or `metaSendsEscape +`_ + +The default for most terminals, however, is that the meta key is bound by +the operating system (such as *META + F* for find), and that *ALT* is used +for inserting international keys (where the combination *ALT+u, a* is used +to insert the character ``ä``). + +It is therefore a recommendation to **avoid alt or meta keys entirely** in +applications, and instead prefer the ctrl-key combinations, so as to avoid +instructing your users to configure their terminal emulators to communicate +such sequences. + +If you wish to allow them optionally (such as through readline), the ability +to detect alt or meta key combinations is achieved by prefacing the combining +character with escape, so that *ALT+z* becomes *Escape + z* (or, in raw form +``\x1bz``). Blessings currently provides no further assistance in detecting +these key combinations. + + +Backspace sends delete +---------------------- + +Typically, backspace is ``^H`` (8, or 0x08) and delete is ^? (127, or 0x7f). + +On some systems however, the key for backspace is actually labeled and +transmitted as "delete", though its function in the operating system behaves +just as backspace. + +It is highly recommend to accept **both** ``KEY_DELETE`` and ``KEY_BACKSPACE`` +as having the same meaning except when implementing full screen editors, +and provide a choice to enable the delete mode by configuration. + +The misnomer of ANSI +-------------------- + +When people say 'ANSI Sequence', they are discussing: + +- Standard `ECMA-48`_: Control Functions for Coded Character Sets + +- `ANSI X3.64 `_ from + 1981, when the `American National Standards Institute + `_ adopted the `ECMA-48`_ as standard, which was later + withdrawn in 1997 (so in this sense it is *not* an ANSI standard). + +- The `ANSI.SYS`_ driver provided in MS-DOS and + clones. The popularity of the IBM Personal Computer and MS-DOS of the era, + and its ability to display colored text further populated the idea that such + text "is ANSI". + +- The various code pages used in MS-DOS Personal Computers, + providing "block art" characters in the 8th bit (int 127-255), paired + with `ECMA-48`_ sequences supported by the MS-DOS `ANSI.SYS`_ driver + to create artwork, known as `ANSI art `_. + +- The ANSI terminal database entry and its many descendants in the + `terminfo database + `_. This is mostly + due to terminals compatible with SCO UNIX, which was the successor of + Microsoft's Xenix, which brought some semblance of the Microsoft DOS + `ANSI.SYS`_ driver capabilities. + +- `Select Graphics Rendition (SGR) `_ + on vt100 clones, which include many of the common sequences in `ECMA-48`_. + +- Any sequence started by the `Control-Sequence-Inducer`_ is often + mistakenly termed as an "ANSI Escape Sequence" though not appearing in + `ECMA-48`_ or interpreted by the `ANSI.SYS`_ driver. The adjoining phrase + "Escape Sequence" is so termed because it follows the ASCII character + for the escape key (ESC, ``\x1b``). + +.. _code page: https://en.wikipedia.org/wiki/Code_page +.. _IBM CP437: https://en.wikipedia.org/wiki/Code_page_437 +.. _CGA Color Palette: https://en.wikipedia.org/wiki/Color_Graphics_Adapter#With_an_RGBI_monitor +.. _f.lux: https://justgetflux.com/ +.. _ZX Spectrum: https://en.wikipedia.org/wiki/List_of_8-bit_computer_hardware_palettes#ZX_Spectrum +.. _Control-Sequence-Inducer: http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Controls-beginning-with-ESC +.. _ANSI.SYS: http://www.kegel.com/nansi/ +.. _ECMA-48: http://www.ecma-international.org/publications/standards/Ecma-048.htm diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..1eaa2ed3 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +Sphinx==2.3.1 +sphinx_rtd_theme +sphinx-paramlinks +jinxed>=0.5.4 diff --git a/docs/sphinxext/github.py b/docs/sphinxext/github.py new file mode 100644 index 00000000..40ae5339 --- /dev/null +++ b/docs/sphinxext/github.py @@ -0,0 +1,160 @@ +"""Define text roles for GitHub + +* ghissue - Issue +* ghpull - Pull Request +* ghuser - User + +Adapted from bitbucket example here: +https://bitbucket.org/birkenfeld/sphinx-contrib/src/tip/bitbucket/sphinxcontrib/bitbucket.py + +Authors +------- + +* Doug Hellmann +* Min RK +""" +# +# Original Copyright (c) 2010 Doug Hellmann. All rights reserved. +# + +# local +from docutils import nodes, utils +from sphinx.util import logging +from docutils.parsers.rst.roles import set_classes + +LOGGER = logging.getLogger(__name__) + + +def make_link_node(rawtext, app, type, slug, options): + """Create a link to a github resource. + + :param rawtext: Text being replaced with link node. + :param app: Sphinx application context + :param type: Link type (issues, changeset, etc.) + :param slug: ID of the thing to link to + :param options: Options dictionary passed to role func. + """ + + try: + base = app.config.github_project_url + if not base: + raise AttributeError + if not base.endswith('/'): + base += '/' + except AttributeError as err: + raise ValueError('github_project_url configuration value is not set (%s)' % str(err)) + + ref = base + type + '/' + slug + '/' + set_classes(options) + prefix = "#" + if type == 'pull': + prefix = "PR " + prefix + node = nodes.reference(rawtext, prefix + utils.unescape(slug), refuri=ref, + **options) + return node + +def ghissue_role(name, rawtext, text, lineno, inliner, options={}, content=[]): + """Link to a GitHub issue. + + Returns 2 part tuple containing list of nodes to insert into the + document and a list of system messages. Both are allowed to be + empty. + + :param name: The role name used in the document. + :param rawtext: The entire markup snippet, with role. + :param text: The text marked with the role. + :param lineno: The line number where rawtext appears in the input. + :param inliner: The inliner instance that called us. + :param options: Directive options for customization. + :param content: The directive content for customization. + """ + + try: + issue_num = int(text) + if issue_num <= 0: + raise ValueError + except ValueError: + msg = inliner.reporter.error( + 'GitHub issue number must be a number greater than or equal to 1; ' + '"%s" is invalid.' % text, line=lineno) + prb = inliner.problematic(rawtext, rawtext, msg) + return [prb], [msg] + app = inliner.document.settings.env.app + #LOGGER.info('issue %r' % text) + if 'pull' in name.lower(): + category = 'pull' + elif 'issue' in name.lower(): + category = 'issues' + else: + msg = inliner.reporter.error( + 'GitHub roles include "ghpull" and "ghissue", ' + '"%s" is invalid.' % name, line=lineno) + prb = inliner.problematic(rawtext, rawtext, msg) + return [prb], [msg] + node = make_link_node(rawtext, app, category, str(issue_num), options) + return [node], [] + +def ghuser_role(name, rawtext, text, lineno, inliner, options={}, content=[]): + """Link to a GitHub user. + + Returns 2 part tuple containing list of nodes to insert into the + document and a list of system messages. Both are allowed to be + empty. + + :param name: The role name used in the document. + :param rawtext: The entire markup snippet, with role. + :param text: The text marked with the role. + :param lineno: The line number where rawtext appears in the input. + :param inliner: The inliner instance that called us. + :param options: Directive options for customization. + :param content: The directive content for customization. + """ + app = inliner.document.settings.env.app + #LOGGER.info('user link %r' % text) + ref = 'https://github.com/' + text + node = nodes.reference(rawtext, text, refuri=ref, **options) + return [node], [] + +def ghcommit_role(name, rawtext, text, lineno, inliner, options={}, content=[]): + """Link to a GitHub commit. + + Returns 2 part tuple containing list of nodes to insert into the + document and a list of system messages. Both are allowed to be + empty. + + :param name: The role name used in the document. + :param rawtext: The entire markup snippet, with role. + :param text: The text marked with the role. + :param lineno: The line number where rawtext appears in the input. + :param inliner: The inliner instance that called us. + :param options: Directive options for customization. + :param content: The directive content for customization. + """ + app = inliner.document.settings.env.app + #LOGGER.info('user link %r' % text) + try: + base = app.config.github_project_url + if not base: + raise AttributeError + if not base.endswith('/'): + base += '/' + except AttributeError as err: + raise ValueError('github_project_url configuration value is not set (%s)' % str(err)) + + ref = base + text + node = nodes.reference(rawtext, text[:6], refuri=ref, **options) + return [node], [] + + +def setup(app): + """Install the plugin. + + :param app: Sphinx application context. + """ + LOGGER.info('Initializing GitHub plugin') + app.add_role('ghissue', ghissue_role) + app.add_role('ghpull', ghissue_role) + app.add_role('ghuser', ghuser_role) + app.add_role('ghcommit', ghcommit_role) + app.add_config_value('github_project_url', None, 'env') + return diff --git a/fabfile.py b/fabfile.py deleted file mode 100644 index af98c0d8..00000000 --- a/fabfile.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Run this using ``fabric``. - -I can't remember any of this syntax on my own. - -""" -from functools import partial -from os import environ -from os.path import abspath, dirname - -from fabric.api import local, cd - - -local = partial(local, capture=False) - -ROOT = abspath(dirname(__file__)) - -environ['PYTHONPATH'] = (((environ['PYTHONPATH'] + ':') if - environ.get('PYTHONPATH') else '') + ROOT) - - -def doc(kind='html'): - """Build Sphinx docs. - - Requires Sphinx to be installed. - - """ - with cd('docs'): - local('make clean %s' % kind) - - -def updoc(): - """Build Sphinx docs and upload them to packages.python.org. - - Requires Sphinx-PyPI-upload to be installed. - - """ - doc('html') - local('python setup.py upload_sphinx --upload-dir=docs/_build/html') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..917d2a45 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +wcwidth>=0.1.4 +six>=1.9.0 +# support python2.6 by using backport of 'orderedict' +ordereddict==1.1; python_version < "2.7" +# support python2.7 by using backport of 'functools.lru_cache' +backports.functools-lru-cache>=1.2.1; python_version < "3.2" +# Windows requires jinxed +jinxed>=0.5.4; platform_system == "Windows" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..2a9acf13 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index ec4cfd10..16600b5e --- a/setup.py +++ b/setup.py @@ -1,32 +1,49 @@ -import sys +#!/usr/bin/env python +"""Distutils setup script.""" +# std imports +import os -# Prevent spurious errors during `python setup.py test`, a la -# http://www.eby-sarna.com/pipermail/peak/2010-May/003357.html: -try: - import multiprocessing -except ImportError: - pass +# 3rd party +import setuptools -from setuptools import setup, find_packages +def _get_install_requires(fname): + result = [req_line.strip() for req_line in open(fname) + if req_line.strip() and not req_line.startswith('#')] -extra_setup = {} -if sys.version_info >= (3,): - extra_setup['use_2to3'] = True + return result -setup( - name='blessings', - version='1.6', - description='A thin, practical wrapper around terminal coloring, styling, and positioning', - long_description=open('README.rst').read(), - author='Erik Rose', - author_email='erikrose@grinchcentral.com', + +def _get_version(fname): + import json + return json.load(open(fname, 'r'))['version'] + + +def _get_long_description(fname): + import codecs + return codecs.open(fname, 'r', 'utf8').read() + + +HERE = os.path.dirname(__file__) + +setuptools.setup( + name='blessed', + version=_get_version( + fname=os.path.join(HERE, 'version.json')), + install_requires=_get_install_requires( + fname=os.path.join(HERE, 'requirements.txt')), + long_description=_get_long_description( + fname=os.path.join(HERE, 'docs', 'intro.rst')), + description=('A thin, practical wrapper around terminal styling, ' + 'screen positioning, and keyboard input.'), + author='Jeff Quast, Erik Rose', + author_email='contact@jeffquast.com', license='MIT', - packages=find_packages(exclude=['ez_setup']), - tests_require=['nose'], - test_suite='nose.collector', - url='https://github.com/erikrose/blessings', + packages=['blessed', ], + url='https://github.com/jquast/blessed', + project_urls={'Documentation': 'https://blessed.readthedocs.io'}, include_package_data=True, + zip_safe=True, classifiers=[ 'Intended Audience :: Developers', 'Natural Language :: English', @@ -35,16 +52,22 @@ 'Environment :: Console :: Curses', 'License :: OSI Approved :: MIT License', 'Operating System :: POSIX', + 'Operating System :: Microsoft :: Windows', 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.5', 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.2', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Topic :: Software Development :: Libraries', 'Topic :: Software Development :: User Interfaces', 'Topic :: Terminals' - ], - keywords=['terminal', 'tty', 'curses', 'ncurses', 'formatting', 'style', 'color', 'console'], - **extra_setup + ], + keywords=['terminal', 'sequences', 'tty', 'curses', 'ncurses', + 'formatting', 'style', 'color', 'console', 'keyboard', + 'ansi', 'xterm'], ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/accessories.py b/tests/accessories.py new file mode 100644 index 00000000..73a6eedd --- /dev/null +++ b/tests/accessories.py @@ -0,0 +1,268 @@ +# -*- coding: utf-8 -*- +"""Accessories for automated py.test runner.""" +# standard imports +from __future__ import print_function, with_statement + +# std imports +import os +import sys +import codecs +import platform +import functools +import traceback +import contextlib +import subprocess + +# 3rd party +import six +import pytest + +# local +from blessed import Terminal + +if platform.system() != "Windows": + import curses + import pty + import termios +else: + import jinxed as curses + + +test_kind = 'xterm-256color' +if platform.system() == 'Windows': + test_kind = 'vtwin10' +TestTerminal = functools.partial(Terminal, kind=test_kind) +SEND_SEMAPHORE = SEMAPHORE = b'SEMAPHORE\n' +RECV_SEMAPHORE = b'SEMAPHORE\r\n' +many_lines_params = [40, 80] +# we must test a '1' column for conditional in _handle_long_word +many_columns_params = [1, 10] + +if os.environ.get('TEST_QUICK'): + many_lines_params = [80, ] + many_columns_params = [25, ] + +all_terms_params = 'xterm screen ansi vt220 rxvt cons25 linux'.split() + +if os.environ.get('TEST_FULL'): + try: + all_terms_params = [ + # use all values of the first column of data in output of 'toe -a' + _term.split(None, 1)[0] for _term in + subprocess.Popen(('toe', '-a'), + stdout=subprocess.PIPE, + close_fds=True) + .communicate()[0].splitlines()] + except OSError: + pass +elif platform.system() == 'Windows': + all_terms_params = ['vtwin10', ] +elif os.environ.get('TEST_QUICK'): + all_terms_params = 'xterm screen ansi linux'.split() + + +def init_subproc_coverage(run_note): + try: + import coverage + except ImportError: + return None + _coveragerc = os.path.join( + os.path.dirname(__file__), + os.pardir, 'tox.ini') + cov = coverage.Coverage(config_file=_coveragerc) + cov.start() + return cov + + +class as_subprocess(object): + """This helper executes test cases in a child process, avoiding a python-internal bug of + _curses: setupterm() may not be called more than once per process.""" + _CHILD_PID = 0 + encoding = 'utf8' + + def __init__(self, func): + self.func = func + + def __call__(self, *args, **kwargs): + if platform.system() == 'Windows': + self.func(*args, **kwargs) + return + + pid_testrunner = os.getpid() + pid, master_fd = pty.fork() + if pid == self._CHILD_PID: + # child process executes function, raises exception + # if failed, causing a non-zero exit code, using the + # protected _exit() function of ``os``; to prevent the + # 'SystemExit' exception from being thrown. + cov = init_subproc_coverage( + "@as_subprocess-{pid};{func_name}(*{args}, **{kwargs})" + .format(pid=os.getpid(), func_name=self.func, + args=args, kwargs=kwargs)) + try: + self.func(*args, **kwargs) + except Exception: + e_type, e_value, e_tb = sys.exc_info() + o_err = list() + for line in traceback.format_tb(e_tb): + o_err.append(line.rstrip().encode('utf-8')) + o_err.append(('-=' * 20).encode('ascii')) + o_err.extend([_exc.rstrip().encode('utf-8') for _exc in + traceback.format_exception_only( + e_type, e_value)]) + os.write(sys.__stdout__.fileno(), b'\n'.join(o_err)) + os.close(sys.__stdout__.fileno()) + os.close(sys.__stderr__.fileno()) + os.close(sys.__stdin__.fileno()) + if cov is not None: + cov.stop() + cov.save() + os._exit(1) + else: + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + # detect rare fork in test runner, when bad bugs happen + if pid_testrunner != os.getpid(): + print('TEST RUNNER HAS FORKED, {0}=>{1}: EXIT' + .format(pid_testrunner, os.getpid()), file=sys.stderr) + os._exit(1) + + exc_output = six.text_type() + decoder = codecs.getincrementaldecoder(self.encoding)() + while True: + try: + _exc = os.read(master_fd, 65534) + except OSError: + # linux EOF + break + if not _exc: + # bsd EOF + break + exc_output += decoder.decode(_exc) + + # parent process asserts exit code is 0, causing test + # to fail if child process raised an exception/assertion + pid, status = os.waitpid(pid, 0) + os.close(master_fd) + + # Display any output written by child process + # (esp. any AssertionError exceptions written to stderr). + exc_output_msg = 'Output in child process:\n%s\n%s\n%s' % ( + u'=' * 40, exc_output, u'=' * 40,) + assert exc_output == '', exc_output_msg + + # Also test exit status is non-zero + assert os.WEXITSTATUS(status) == 0 + + +def read_until_semaphore(fd, semaphore=RECV_SEMAPHORE, + encoding='utf8', timeout=10): + """ + Read file descriptor ``fd`` until ``semaphore`` is found. + + Used to ensure the child process is awake and ready. For timing tests; without a semaphore, the + time to fork() would be (incorrectly) included in the duration of the test, which can be very + length on continuous integration servers (such as Travis-CI). + """ + # note that when a child process writes xyz\\n, the parent + # process will read xyz\\r\\n -- this is how pseudo terminals + # behave; a virtual terminal requires both carriage return and + # line feed, it is only for convenience that \\n does both. + outp = six.text_type() + decoder = codecs.getincrementaldecoder(encoding)() + semaphore = semaphore.decode('ascii') + while not outp.startswith(semaphore): + try: + _exc = os.read(fd, 1) + except OSError: # Linux EOF + break + if not _exc: # BSD EOF + break + outp += decoder.decode(_exc, final=False) + assert outp.startswith(semaphore), ( + 'Semaphore not recv before EOF ' + '(expected: %r, got: %r)' % (semaphore, outp,)) + return outp[len(semaphore):] + + +def read_until_eof(fd, encoding='utf8'): + """ + Read file descriptor ``fd`` until EOF. + + Return decoded string. + """ + decoder = codecs.getincrementaldecoder(encoding)() + outp = six.text_type() + while True: + try: + _exc = os.read(fd, 100) + except OSError: # linux EOF + break + if not _exc: # bsd EOF + break + outp += decoder.decode(_exc, final=False) + return outp + + +@contextlib.contextmanager +def echo_off(fd): + """Ensure any bytes written to pty fd are not duplicated as output.""" + if platform.system() != 'Windows': + try: + attrs = termios.tcgetattr(fd) + attrs[3] = attrs[3] & ~termios.ECHO + termios.tcsetattr(fd, termios.TCSANOW, attrs) + yield + finally: + attrs[3] = attrs[3] | termios.ECHO + termios.tcsetattr(fd, termios.TCSANOW, attrs) + else: + yield + + +def unicode_cap(cap): + """Return the result of ``tigetstr`` except as Unicode.""" + try: + val = curses.tigetstr(cap) + except curses.error: + val = None + if val: + return val.decode('latin1') + return u'' + + +def unicode_parm(cap, *parms): + """Return the result of ``tparm(tigetstr())`` except as Unicode.""" + try: + cap = curses.tigetstr(cap) + except curses.error: + cap = None + if cap: + try: + val = curses.tparm(cap, *parms) + except curses.error: + val = None + if val: + return val.decode('latin1') + return u'' + + +@pytest.fixture(params=all_terms_params) +def all_terms(request): + """Common kind values for all kinds of terminals.""" + return request.param + + +@pytest.fixture(params=many_lines_params) +def many_lines(request): + """Various number of lines for screen height.""" + return request.param + + +@pytest.fixture(params=many_columns_params) +def many_columns(request): + """Various number of columns for screen width.""" + return request.param diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 00000000..a939aea7 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,498 @@ +# -*- coding: utf-8 -*- +"""Core blessed Terminal() tests.""" + +# std imports +import io +import os +import sys +import math +import time +import platform +import warnings +import collections + +# 3rd party +import six +import mock +import pytest +from six.moves import reload_module + +# local +from .accessories import TestTerminal, all_terms, unicode_cap, as_subprocess + + +def test_export_only_Terminal(): + "Ensure only Terminal instance is exported for import * statements." + import blessed + assert blessed.__all__ == ('Terminal',) + + +def test_null_location(all_terms): + """Make sure ``location()`` with no args just does position restoration.""" + @as_subprocess + def child(kind): + t = TestTerminal(stream=six.StringIO(), force_styling=True) + with t.location(): + pass + expected_output = u''.join( + (unicode_cap('sc'), unicode_cap('rc'))) + assert (t.stream.getvalue() == expected_output) + + child(all_terms) + + +def test_location_to_move_xy(all_terms): + """``location()`` and ``move_xy()`` receive complimentary arguments.""" + @as_subprocess + def child(kind): + buf = six.StringIO() + t = TestTerminal(stream=buf, force_styling=True) + x, y = 12, 34 + with t.location(y, x): + xy_val_from_move_xy = t.move_xy(y, x) + xy_val_from_location = buf.getvalue()[len(t.sc):] + assert xy_val_from_move_xy == xy_val_from_location + + child(all_terms) + + +def test_yield_keypad(): + """Ensure ``keypad()`` writes keyboard_xmit and keyboard_local.""" + @as_subprocess + def child(kind): + # given, + t = TestTerminal(stream=six.StringIO(), force_styling=True) + expected_output = u''.join((t.smkx, t.rmkx)) + + # exercise, + with t.keypad(): + pass + + # verify. + assert (t.stream.getvalue() == expected_output) + + child(kind='xterm') + + +def test_null_fileno(): + """Make sure ``Terminal`` works when ``fileno`` is ``None``.""" + @as_subprocess + def child(): + # This simulates piping output to another program. + out = six.StringIO() + out.fileno = None + t = TestTerminal(stream=out) + assert (t.save == u'') + + child() + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="requires more than 1 tty") +def test_number_of_colors_without_tty(): + """``number_of_colors`` should return 0 when there's no tty.""" + @as_subprocess + def child_256_nostyle(): + t = TestTerminal(stream=six.StringIO()) + assert (t.number_of_colors == 0) + + @as_subprocess + def child_256_forcestyle(): + t = TestTerminal(stream=six.StringIO(), force_styling=True) + assert (t.number_of_colors == 256) + + @as_subprocess + def child_8_forcestyle(): + kind = 'ansi' + if platform.system().lower() == 'freebsd': + # 'ansi' on freebsd returns 0 colors, we use the 'cons25' driver, + # compatible with its kernel tty.c + kind = 'cons25' + t = TestTerminal(kind=kind, stream=six.StringIO(), + force_styling=True) + assert (t.number_of_colors == 8) + + @as_subprocess + def child_0_forcestyle(): + t = TestTerminal(kind='vt220', stream=six.StringIO(), + force_styling=True) + assert (t.number_of_colors == 0) + + child_0_forcestyle() + child_8_forcestyle() + child_256_forcestyle() + child_256_nostyle() + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="requires more than 1 tty") +def test_number_of_colors_with_tty(): + """test ``number_of_colors`` 0, 8, and 256.""" + @as_subprocess + def child_256(): + t = TestTerminal() + assert (t.number_of_colors == 256) + + @as_subprocess + def child_8(): + kind = 'ansi' + if platform.system().lower() == 'freebsd': + # 'ansi' on freebsd returns 0 colors, we use the 'cons25' driver, + # compatible with its kernel tty.c + kind = 'cons25' + t = TestTerminal(kind=kind) + assert (t.number_of_colors == 8) + + @as_subprocess + def child_0(): + t = TestTerminal(kind='vt220') + assert (t.number_of_colors == 0) + + child_0() + child_8() + child_256() + + +def test_init_descriptor_always_initted(all_terms): + """Test height and width with non-tty Terminals.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=six.StringIO()) + assert t._init_descriptor == sys.__stdout__.fileno() + assert (isinstance(t.height, int)) + assert (isinstance(t.width, int)) + assert t.height == t._height_and_width()[0] + assert t.width == t._height_and_width()[1] + + child(all_terms) + + +def test_force_styling_none(all_terms): + """If ``force_styling=None`` is used, don't ever do styling.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, force_styling=None) + assert (t.save == '') + assert (t.color(9) == '') + assert (t.bold('oi') == 'oi') + + child(all_terms) + + +def test_setupterm_singleton_issue_33(): + """A warning is emitted if a new terminal ``kind`` is used per process.""" + @as_subprocess + def child(): + warnings.filterwarnings("error", category=UserWarning) + + # instantiate first terminal, of type xterm-256color + term = TestTerminal(force_styling=True) + first_kind = term.kind + next_kind = 'xterm' + + try: + # a second instantiation raises UserWarning + term = TestTerminal(kind=next_kind, force_styling=True) + except UserWarning: + err = sys.exc_info()[1] + assert (err.args[0].startswith( + 'A terminal of kind "' + next_kind + '" has been requested') + ), err.args[0] + assert ('a terminal of kind "' + first_kind + '" will ' + 'continue to be returned' in err.args[0]), err.args[0] + else: + # unless term is not a tty and setupterm() is not called + assert not term.is_a_tty or False, 'Should have thrown exception' + warnings.resetwarnings() + + child() + + +def test_setupterm_invalid_issue39(): + """A warning is emitted if TERM is invalid.""" + # https://bugzilla.mozilla.org/show_bug.cgi?id=878089 + # + # if TERM is unset, defaults to 'unknown', which should + # fail to lookup and emit a warning on *some* systems. + # freebsd actually has a termcap entry for 'unknown' + @as_subprocess + def child(): + warnings.filterwarnings("error", category=UserWarning) + + try: + term = TestTerminal(kind='unknown', force_styling=True) + except UserWarning: + err = sys.exc_info()[1] + assert err.args[0] in ( + "Failed to setupterm(kind='unknown'): " + "setupterm: could not find terminal", + "Failed to setupterm(kind='unknown'): " + "Could not find terminal unknown", + ) + else: + if platform.system().lower() != 'freebsd': + assert not term.is_a_tty and not term.does_styling, ( + 'Should have thrown exception') + warnings.resetwarnings() + + child() + + +def test_setupterm_invalid_has_no_styling(): + """An unknown TERM type does not perform styling.""" + # https://bugzilla.mozilla.org/show_bug.cgi?id=878089 + + # if TERM is unset, defaults to 'unknown', which should + # fail to lookup and emit a warning, only. + @as_subprocess + def child(): + warnings.filterwarnings("ignore", category=UserWarning) + + term = TestTerminal(kind='xxXunknownXxx', force_styling=True) + assert term.kind is None + assert not term.does_styling + assert term.number_of_colors == 0 + warnings.resetwarnings() + + child() + + +def test_missing_ordereddict_uses_module(monkeypatch): + """ordereddict module is imported when without collections.OrderedDict.""" + import blessed.keyboard + + if hasattr(collections, 'OrderedDict'): + monkeypatch.delattr('collections.OrderedDict') + + try: + reload_module(blessed.keyboard) + except ImportError as err: + assert err.args[0] in ("No module named ordereddict", # py2 + "No module named 'ordereddict'") # py3 + sys.modules['ordereddict'] = mock.Mock() + sys.modules['ordereddict'].OrderedDict = -1 + reload_module(blessed.keyboard) + assert blessed.keyboard.OrderedDict == -1 + del sys.modules['ordereddict'] + monkeypatch.undo() + reload_module(blessed.keyboard) + else: + assert platform.python_version_tuple() < ('2', '7') # reached by py2.6 + + +def test_python3_2_raises_exception(monkeypatch): + """Test python version 3.0 through 3.2 raises an exception.""" + import blessed + + monkeypatch.setattr('platform.python_version_tuple', + lambda: ('3', '2', '2')) + + try: + reload_module(blessed) + except ImportError as err: + assert err.args[0] == ( + 'Blessed needs Python 3.2.3 or greater for Python 3 ' + 'support due to http://bugs.python.org/issue10570.') + monkeypatch.undo() + reload_module(blessed) + else: + assert False, 'Exception should have been raised' + + +def test_without_dunder(): + """Ensure dunder does not remain in module (py2x InterruptedError test.""" + import blessed.terminal + assert '_' not in dir(blessed.terminal) + + +def test_IOUnsupportedOperation(): + """Ensure stream that throws IOUnsupportedOperation results in non-tty.""" + @as_subprocess + def child(): + import blessed.terminal + + def side_effect(): + raise io.UnsupportedOperation + + mock_stream = mock.Mock() + mock_stream.fileno = side_effect + + term = TestTerminal(stream=mock_stream) + assert term.stream == mock_stream + assert not term.does_styling + assert not term.is_a_tty + assert term.number_of_colors == 0 + + child() + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="has process-wide side-effects") +def test_winsize_IOError_returns_environ(): + """When _winsize raises IOError, defaults from os.environ given.""" + @as_subprocess + def child(): + def side_effect(fd): + raise IOError + + term = TestTerminal() + term._winsize = side_effect + os.environ['COLUMNS'] = '1984' + os.environ['LINES'] = '1888' + assert term._height_and_width() == (1888, 1984, None, None) + + child() + + +def test_yield_fullscreen(all_terms): + """Ensure ``fullscreen()`` writes enter_fullscreen and exit_fullscreen.""" + @as_subprocess + def child(kind): + t = TestTerminal(stream=six.StringIO(), force_styling=True) + t.enter_fullscreen = u'BEGIN' + t.exit_fullscreen = u'END' + with t.fullscreen(): + pass + expected_output = u''.join((t.enter_fullscreen, t.exit_fullscreen)) + assert (t.stream.getvalue() == expected_output) + + child(all_terms) + + +def test_yield_hidden_cursor(all_terms): + """Ensure ``hidden_cursor()`` writes hide_cursor and normal_cursor.""" + @as_subprocess + def child(kind): + t = TestTerminal(stream=six.StringIO(), force_styling=True) + t.hide_cursor = u'BEGIN' + t.normal_cursor = u'END' + with t.hidden_cursor(): + pass + expected_output = u''.join((t.hide_cursor, t.normal_cursor)) + assert (t.stream.getvalue() == expected_output) + + child(all_terms) + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="windows doesn't work like this") +def test_no_preferredencoding_fallback_ascii(): + """Ensure empty preferredencoding value defaults to ascii.""" + @as_subprocess + def child(): + with mock.patch('locale.getpreferredencoding') as get_enc: + get_enc.return_value = u'' + t = TestTerminal() + assert t._encoding == 'ascii' + + child() + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="requires fcntl") +def test_unknown_preferredencoding_warned_and_fallback_ascii(): + """Ensure a locale without a codec emits a warning.""" + @as_subprocess + def child(): + with mock.patch('locale.getpreferredencoding') as get_enc: + get_enc.return_value = '---unknown--encoding---' + with pytest.warns(UserWarning, match=( + 'LookupError: unknown encoding: ---unknown--encoding---, ' + 'fallback to ASCII for keyboard.')): + t = TestTerminal() + assert t._encoding == 'ascii' + + child() + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="requires fcntl") +def test_win32_missing_tty_modules(monkeypatch): + """Ensure dummy exception is used when io is without UnsupportedOperation.""" + @as_subprocess + def child(): + OLD_STYLE = False + try: + original_import = getattr(__builtins__, '__import__') + OLD_STYLE = True + except AttributeError: + original_import = __builtins__['__import__'] + + tty_modules = ('termios', 'fcntl', 'tty') + + def __import__(name, *args, **kwargs): + if name in tty_modules: + raise ImportError + return original_import(name, *args, **kwargs) + + for module in tty_modules: + sys.modules.pop(module, None) + + warnings.filterwarnings("error", category=UserWarning) + try: + if OLD_STYLE: + __builtins__.__import__ = __import__ + else: + __builtins__['__import__'] = __import__ + try: + import blessed.terminal + reload_module(blessed.terminal) + except UserWarning: + err = sys.exc_info()[1] + assert err.args[0] == blessed.terminal._MSG_NOSUPPORT + + warnings.filterwarnings("ignore", category=UserWarning) + import blessed.terminal + reload_module(blessed.terminal) + assert not blessed.terminal.HAS_TTY + term = blessed.terminal.Terminal('ansi') + # https://en.wikipedia.org/wiki/VGA-compatible_text_mode + # see section '#PC_common_text_modes' + assert term.height == 25 + assert term.width == 80 + + finally: + if OLD_STYLE: + setattr(__builtins__, '__import__', original_import) + else: + __builtins__['__import__'] = original_import + warnings.resetwarnings() + import blessed.terminal + reload_module(blessed.terminal) + + child() + + +def test_time_left(): + """test '_time_left' routine returns correct positive delta difference.""" + from blessed.keyboard import _time_left + + # given stime =~ "10 seconds ago" + stime = (time.time() - 10) + + # timeleft(now, 15s) = 5s remaining + timeout = 15 + result = _time_left(stime=stime, timeout=timeout) + + # we expect roughly 4.999s remain + assert math.ceil(result) == 5.0 + + +def test_time_left_infinite_None(): + """keyboard '_time_left' routine returns None when given None.""" + from blessed.keyboard import _time_left + assert _time_left(stime=time.time(), timeout=None) is None + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="cant multiprocess") +def test_termcap_repr(): + """Ensure ``hidden_cursor()`` writes hide_cursor and normal_cursor.""" + + given_ttype = 'vt220' + given_capname = 'cursor_up' + expected = [r"", + r"", + r""] + + @as_subprocess + def child(): + import blessed + term = blessed.Terminal(given_ttype) + given = repr(term.caps[given_capname]) + assert given in expected + + child() diff --git a/tests/test_formatters.py b/tests/test_formatters.py new file mode 100644 index 00000000..cfc01475 --- /dev/null +++ b/tests/test_formatters.py @@ -0,0 +1,442 @@ +# -*- coding: utf-8 -*- +"""Tests string formatting functions.""" +# std imports +import platform + +# 3rd party +import mock +import pytest + +if platform.system() != 'Windows': + import curses +else: + import jinxed as curses + + +def fn_tparm(*args): + return u'~'.join( + arg.decode('latin1') if not num else '%s' % (arg,) + for num, arg in enumerate(args) + ).encode('latin1') + + +def test_parameterizing_string_args_unspecified(monkeypatch): + """Test default args of formatters.ParameterizingString.""" + from blessed.formatters import ParameterizingString, FormattingString + # first argument to tparm() is the sequence name, returned as-is; + # subsequent arguments are usually Integers. + monkeypatch.setattr(curses, 'tparm', fn_tparm) + + # given, + pstr = ParameterizingString(u'') + + # exercise __new__ + assert str(pstr) == u'' + assert pstr._normal == u'' + assert pstr._name == u'' + + # exercise __call__ + zero = pstr(0) + assert isinstance(zero, FormattingString) + assert zero == u'~0' + assert zero('text') == u'~0text' + + # exercise __call__ with multiple args + onetwo = pstr(1, 2) + assert isinstance(onetwo, FormattingString) + assert onetwo == u'~1~2' + assert onetwo('text') == u'~1~2text' + + +def test_parameterizing_string_args(monkeypatch): + """Test basic formatters.ParameterizingString.""" + from blessed.formatters import ParameterizingString, FormattingString + + # first argument to tparm() is the sequence name, returned as-is; + # subsequent arguments are usually Integers. + monkeypatch.setattr(curses, 'tparm', fn_tparm) + + # given, + pstr = ParameterizingString(u'cap', u'norm', u'seq-name') + + # exercise __new__ + assert str(pstr) == u'cap' + assert pstr._normal == u'norm' + assert pstr._name == u'seq-name' + + # exercise __call__ + zero = pstr(0) + assert isinstance(zero, FormattingString) + assert zero == u'cap~0' + assert zero('text') == u'cap~0textnorm' + + # exercise __call__ with multiple args + onetwo = pstr(1, 2) + assert isinstance(onetwo, FormattingString) + assert onetwo == u'cap~1~2' + assert onetwo('text') == u'cap~1~2textnorm' + + +def test_parameterizing_string_type_error(monkeypatch): + """Test formatters.ParameterizingString raising TypeError.""" + from blessed.formatters import ParameterizingString + + def tparm_raises_TypeError(*args): + raise TypeError('custom_err') + + monkeypatch.setattr(curses, 'tparm', tparm_raises_TypeError) + + # given, + pstr = ParameterizingString(u'cap', u'norm', u'cap-name') + + # ensure TypeError when given a string raises custom exception + try: + pstr('XYZ') + assert False, "previous call should have raised TypeError" + except TypeError as err: + assert (err.args[0] == ( # py3x + "A native or nonexistent capability template, " + "'cap-name' received invalid argument ('XYZ',): " + "custom_err. You probably misspelled a " + "formatting call like `bright_red'") or + err.args[0] == ( + "A native or nonexistent capability template, " + "u'cap-name' received invalid argument ('XYZ',): " + "custom_err. You probably misspelled a " + "formatting call like `bright_red'")) + + # ensure TypeError when given an integer raises its natural exception + try: + pstr(0) + assert False, "previous call should have raised TypeError" + except TypeError as err: + assert err.args[0] == "custom_err" + + +def test_formattingstring(monkeypatch): + """Test simple __call__ behavior of formatters.FormattingString.""" + from blessed.formatters import FormattingString + + # given, with arg + pstr = FormattingString(u'attr', u'norm') + + # exercise __call__, + assert pstr._normal == u'norm' + assert str(pstr) == u'attr' + assert pstr('text') == u'attrtextnorm' + + # given, with empty attribute + pstr = FormattingString(u'', u'norm') + assert pstr('text') == u'text' + + +def test_nested_formattingstring(monkeypatch): + """Test nested __call__ behavior of formatters.FormattingString.""" + from blessed.formatters import FormattingString + + # given, with arg + pstr = FormattingString(u'a1-', u'n-') + zstr = FormattingString(u'a2-', u'n-') + + # exercise __call__ + assert pstr('x-', zstr('f-'), 'q-') == 'a1-x-a2-f-n-a1-q-n-' + + +def test_nested_formattingstring_type_error(monkeypatch): + """Test formatters.FormattingString raising TypeError.""" + from blessed.formatters import FormattingString + + # given, + pstr = FormattingString(u'a-', u'n-') + expected_msg = ( + "Positional argument #1 is {0} expected any of " + .format(type(1))) + + # exercise, + with pytest.raises(TypeError) as err: + pstr('text', 1, '...') + + # verify, + assert expected_msg in str(err.value) + + +def test_nullcallablestring(monkeypatch): + """Test formatters.NullCallableString.""" + from blessed.formatters import (NullCallableString) + + # given, with arg + pstr = NullCallableString() + + # exercise __call__, + assert str(pstr) == u'' + assert pstr('text') == u'text' + assert pstr('text', 'moretext') == u'textmoretext' + assert pstr(99, 1) == u'' + assert pstr() == u'' + assert pstr(0) == u'' + + +def test_split_compound(): + """Test formatters.split_compound.""" + from blessed.formatters import split_compound + + assert split_compound(u'') == [u''] + assert split_compound(u'a_b_c') == [u'a', u'b', u'c'] + assert split_compound(u'a_on_b_c') == [u'a', u'on_b', u'c'] + assert split_compound(u'a_bright_b_c') == [u'a', u'bright_b', u'c'] + assert split_compound(u'a_on_bright_b_c') == [u'a', u'on_bright_b', u'c'] + + +def test_resolve_capability(monkeypatch): + """Test formatters.resolve_capability and term sugaring.""" + from blessed.formatters import resolve_capability + + # given, always returns a b'seq' + def tigetstr(attr): + return ('seq-%s' % (attr,)).encode('latin1') + monkeypatch.setattr(curses, 'tigetstr', tigetstr) + term = mock.Mock() + term._sugar = dict(mnemonic='xyz') + + # exercise + assert resolve_capability(term, 'mnemonic') == u'seq-xyz' + assert resolve_capability(term, 'natural') == u'seq-natural' + + # given, where tigetstr returns None + def tigetstr_none(attr): + return None + monkeypatch.setattr(curses, 'tigetstr', tigetstr_none) + + # exercise, + assert resolve_capability(term, 'natural') == u'' + + # given, where does_styling is False + def raises_exception(*args): + assert False, "Should not be called" + term.does_styling = False + monkeypatch.setattr(curses, 'tigetstr', raises_exception) + + # exercise, + assert resolve_capability(term, 'natural') == u'' + + +def test_resolve_color(monkeypatch): + """Test formatters.resolve_color.""" + from blessed.formatters import (resolve_color, + FormattingString, + NullCallableString) + + def color_cap(digit): + return 'seq-%s' % (digit,) + monkeypatch.setattr(curses, 'COLOR_RED', 1984) + + # given, terminal with color capabilities + term = mock.Mock() + term._background_color = color_cap + term._foreground_color = color_cap + term.number_of_colors = -1 + term.normal = 'seq-normal' + + # exercise, + red = resolve_color(term, 'red') + assert isinstance(red, FormattingString) + assert red == u'seq-1984' + assert red('text') == u'seq-1984textseq-normal' + + # exercise bold, +8 + bright_red = resolve_color(term, 'bright_red') + assert isinstance(bright_red, FormattingString) + assert bright_red == u'seq-1992' + assert bright_red('text') == u'seq-1992textseq-normal' + + # given, terminal without color + term.number_of_colors = 0 + + # exercise, + red = resolve_color(term, 'red') + assert isinstance(red, NullCallableString) + assert red == u'' + assert red('text') == u'text' + + # exercise bold, + bright_red = resolve_color(term, 'bright_red') + assert isinstance(bright_red, NullCallableString) + assert bright_red == u'' + assert bright_red('text') == u'text' + + +def test_resolve_attribute_as_color(monkeypatch): + """Test simple resolve_attribte() given color name.""" + import blessed + from blessed.formatters import resolve_attribute + + def resolve_color(term, digit): + return 'seq-%s' % (digit,) + COLORS = set(['COLORX', 'COLORY']) + COMPOUNDABLES = set(['JOINT', 'COMPOUND']) + monkeypatch.setattr(blessed.formatters, 'resolve_color', resolve_color) + monkeypatch.setattr(blessed.formatters, 'COLORS', COLORS) + monkeypatch.setattr(blessed.formatters, 'COMPOUNDABLES', COMPOUNDABLES) + term = mock.Mock() + assert resolve_attribute(term, 'COLORX') == u'seq-COLORX' + + +def test_resolve_attribute_as_compoundable(monkeypatch): + """Test simple resolve_attribte() given a compoundable.""" + import blessed + from blessed.formatters import resolve_attribute, FormattingString + + def resolve_cap(term, digit): + return 'seq-%s' % (digit,) + COMPOUNDABLES = set(['JOINT', 'COMPOUND']) + monkeypatch.setattr(blessed.formatters, + 'resolve_capability', + resolve_cap) + monkeypatch.setattr(blessed.formatters, 'COMPOUNDABLES', COMPOUNDABLES) + term = mock.Mock() + term.normal = 'seq-normal' + + compound = resolve_attribute(term, 'JOINT') + assert isinstance(compound, FormattingString) + assert str(compound) == u'seq-JOINT' + assert compound('text') == u'seq-JOINTtextseq-normal' + + +def test_resolve_attribute_non_compoundables(monkeypatch): + """Test recursive compounding of resolve_attribute().""" + import blessed + from blessed.formatters import resolve_attribute, ParameterizingString + + def uncompoundables(attr): + return ['split', 'compound'] + + def resolve_cap(term, digit): + return 'seq-%s' % (digit,) + + monkeypatch.setattr(blessed.formatters, + 'split_compound', + uncompoundables) + monkeypatch.setattr(blessed.formatters, + 'resolve_capability', + resolve_cap) + monkeypatch.setattr(curses, 'tparm', fn_tparm) + + term = mock.Mock() + term.normal = 'seq-normal' + + # given + pstr = resolve_attribute(term, 'not-a-compoundable') + assert isinstance(pstr, ParameterizingString) + assert str(pstr) == u'seq-not-a-compoundable' + # this is like calling term.move_x(3) + assert pstr(3) == u'seq-not-a-compoundable~3' + # this is like calling term.move_x(3)('text') + assert pstr(3)('text') == u'seq-not-a-compoundable~3textseq-normal' + + +def test_resolve_attribute_recursive_compoundables(monkeypatch): + """Test recursive compounding of resolve_attribute().""" + import blessed + from blessed.formatters import resolve_attribute, FormattingString + + # patch, + def resolve_cap(term, digit): + return 'seq-%s' % (digit,) + monkeypatch.setattr(blessed.formatters, + 'resolve_capability', + resolve_cap) + monkeypatch.setattr(curses, 'tparm', fn_tparm) + monkeypatch.setattr(curses, 'COLOR_RED', 6502) + monkeypatch.setattr(curses, 'COLOR_BLUE', 6800) + + def color_cap(digit): + return 'seq-%s' % (digit,) + term = mock.Mock() + term._background_color = color_cap + term._foreground_color = color_cap + term.normal = 'seq-normal' + + # given, + pstr = resolve_attribute(term, 'bright_blue_on_red') + + # exercise, + assert isinstance(pstr, FormattingString) + assert str(pstr) == 'seq-6808seq-6502' + assert pstr('text') == 'seq-6808seq-6502textseq-normal' + + +def test_pickled_parameterizing_string(monkeypatch): + """Test pickle-ability of a formatters.ParameterizingString.""" + from blessed.formatters import ParameterizingString + + # simply send()/recv() over multiprocessing Pipe, a simple + # pickle.loads(dumps(...)) did not reproduce this issue, + from multiprocessing import Pipe + import pickle + + # first argument to tparm() is the sequence name, returned as-is; + # subsequent arguments are usually Integers. + monkeypatch.setattr(curses, 'tparm', fn_tparm) + + # given, + pstr = ParameterizingString(u'seqname', u'norm', u'cap-name') + + # multiprocessing Pipe implicitly pickles. + r, w = Pipe() + + # exercise picklability of ParameterizingString + for proto_num in range(pickle.HIGHEST_PROTOCOL): + assert pstr == pickle.loads(pickle.dumps(pstr, protocol=proto_num)) + w.send(pstr) + assert r.recv() == pstr + + # exercise picklability of FormattingString + # -- the return value of calling ParameterizingString + zero = pstr(0) + for proto_num in range(pickle.HIGHEST_PROTOCOL): + assert zero == pickle.loads(pickle.dumps(zero, protocol=proto_num)) + w.send(zero) + assert r.recv() == zero + + +def test_tparm_returns_null(monkeypatch): + """Test 'tparm() returned NULL' is caught (win32 PDCurses systems).""" + # on win32, any calls to tparm raises curses.error with message, + # "tparm() returned NULL", function PyCurses_tparm of _cursesmodule.c + from blessed.formatters import ParameterizingString, NullCallableString + + def tparm(*args): + raise curses.error("tparm() returned NULL") + + monkeypatch.setattr(curses, 'tparm', tparm) + + term = mock.Mock() + term.normal = 'seq-normal' + + pstr = ParameterizingString(u'cap', u'norm', u'seq-name') + + value = pstr(u'x') + assert isinstance(value, NullCallableString) + + +def test_tparm_other_exception(monkeypatch): + """Test 'tparm() returned NULL' is caught (win32 PDCurses systems).""" + # on win32, any calls to tparm raises curses.error with message, + # "tparm() returned NULL", function PyCurses_tparm of _cursesmodule.c + from blessed.formatters import ParameterizingString + + def tparm(*args): + raise curses.error("unexpected error in tparm()") + + monkeypatch.setattr(curses, 'tparm', tparm) + + term = mock.Mock() + term.normal = 'seq-normal' + + pstr = ParameterizingString(u'cap', u'norm', u'seq-name') + + try: + pstr(u'x') + assert False, "previous call should have raised curses.error" + except curses.error: + pass diff --git a/tests/test_full_keyboard.py b/tests/test_full_keyboard.py new file mode 100644 index 00000000..c805fe66 --- /dev/null +++ b/tests/test_full_keyboard.py @@ -0,0 +1,672 @@ +# -*- coding: utf-8 -*- +# std imports +import os +import sys +import math +import time +import signal +import platform + +# 3rd party +import six +import pytest + +# local +from .accessories import (SEMAPHORE, + RECV_SEMAPHORE, + SEND_SEMAPHORE, + TestTerminal, + echo_off, + as_subprocess, + read_until_eof, + read_until_semaphore, + init_subproc_coverage) + +pytestmark = pytest.mark.skipif( + os.environ.get('TEST_KEYBOARD', None) != 'yes' or platform.system() == 'Windows', + reason="Timing-sensitive tests please do not run on build farms.") + +@pytest.mark.skipif(os.environ.get('TEST_QUICK', None) is not None, + reason="TEST_QUICK specified") +def test_kbhit_interrupted(): + """kbhit() should not be interrupted with a signal handler.""" + import pty + pid, master_fd = pty.fork() + if pid == 0: + cov = init_subproc_coverage('test_kbhit_interrupted') + + # child pauses, writes semaphore and begins awaiting input + global got_sigwinch + got_sigwinch = False + + def on_resize(sig, action): + global got_sigwinch + got_sigwinch = True + + term = TestTerminal() + signal.signal(signal.SIGWINCH, on_resize) + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.raw(): + assert term.inkey(timeout=1.05) == u'' + os.write(sys.__stdout__.fileno(), b'complete') + assert got_sigwinch + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + read_until_semaphore(master_fd) + stime = time.time() + os.kill(pid, signal.SIGWINCH) + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'complete' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 1.0 + + +@pytest.mark.skipif(os.environ.get('TEST_QUICK', None) is not None, + reason="TEST_QUICK specified") +def test_kbhit_interrupted_nonetype(): + """kbhit() should also allow interruption with timeout of None.""" + import pty + pid, master_fd = pty.fork() + if pid == 0: + cov = init_subproc_coverage('test_kbhit_interrupted_nonetype') + + # child pauses, writes semaphore and begins awaiting input + global got_sigwinch + got_sigwinch = False + + def on_resize(sig, action): + global got_sigwinch + got_sigwinch = True + + term = TestTerminal() + signal.signal(signal.SIGWINCH, on_resize) + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.raw(): + term.inkey(timeout=1) + os.write(sys.__stdout__.fileno(), b'complete') + assert got_sigwinch + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + read_until_semaphore(master_fd) + stime = time.time() + time.sleep(0.05) + os.kill(pid, signal.SIGWINCH) + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'complete' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 1.0 + + +def test_kbhit_no_kb(): + """kbhit() always immediately returns False without a keyboard.""" + @as_subprocess + def child(): + term = TestTerminal(stream=six.StringIO()) + stime = time.time() + assert term._keyboard_fd is None + assert not term.kbhit(timeout=1.1) + assert math.floor(time.time() - stime) == 1.0 + child() + + +def test_keystroke_0s_cbreak_noinput(): + """0-second keystroke without input; '' should be returned.""" + @as_subprocess + def child(): + term = TestTerminal() + with term.cbreak(): + stime = time.time() + inp = term.inkey(timeout=0) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 0.0) + child() + + +def test_keystroke_0s_cbreak_noinput_nokb(): + """0-second keystroke without data in input stream and no keyboard/tty.""" + @as_subprocess + def child(): + term = TestTerminal(stream=six.StringIO()) + with term.cbreak(): + stime = time.time() + inp = term.inkey(timeout=0) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 0.0) + child() + + +@pytest.mark.skipif(os.environ.get('TEST_QUICK', None) is not None, + reason="TEST_QUICK specified") +def test_keystroke_1s_cbreak_noinput(): + """1-second keystroke without input; '' should be returned after ~1 second.""" + @as_subprocess + def child(): + term = TestTerminal() + with term.cbreak(): + stime = time.time() + inp = term.inkey(timeout=1) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 1.0) + child() + + +@pytest.mark.skipif(os.environ.get('TEST_QUICK', None) is not None, + reason="TEST_QUICK specified") +def test_keystroke_1s_cbreak_noinput_nokb(): + """1-second keystroke without input or keyboard.""" + @as_subprocess + def child(): + term = TestTerminal(stream=six.StringIO()) + with term.cbreak(): + stime = time.time() + inp = term.inkey(timeout=1) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 1.0) + child() + + +def test_keystroke_0s_cbreak_with_input(): + """0-second keystroke with input; Keypress should be immediately returned.""" + import pty + pid, master_fd = pty.fork() + if pid == 0: + cov = init_subproc_coverage('test_keystroke_0s_cbreak_with_input') + # child pauses, writes semaphore and begins awaiting input + term = TestTerminal() + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.cbreak(): + inp = term.inkey(timeout=0) + os.write(sys.__stdout__.fileno(), inp.encode('utf-8')) + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + os.write(master_fd, u'x'.encode('ascii')) + read_until_semaphore(master_fd) + stime = time.time() + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'x' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +def test_keystroke_cbreak_with_input_slowly(): + """0-second keystroke with input; Keypress should be immediately returned.""" + import pty + pid, master_fd = pty.fork() + if pid == 0: + cov = init_subproc_coverage('test_keystroke_cbreak_with_input_slowly') + # child pauses, writes semaphore and begins awaiting input + term = TestTerminal() + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.cbreak(): + while True: + inp = term.inkey(timeout=0.5) + os.write(sys.__stdout__.fileno(), inp.encode('utf-8')) + if inp == 'X': + break + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + os.write(master_fd, u'a'.encode('ascii')) + time.sleep(0.1) + os.write(master_fd, u'b'.encode('ascii')) + time.sleep(0.1) + os.write(master_fd, u'cdefgh'.encode('ascii')) + time.sleep(0.1) + os.write(master_fd, u'X'.encode('ascii')) + read_until_semaphore(master_fd) + stime = time.time() + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'abcdefghX' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +def test_keystroke_0s_cbreak_multibyte_utf8(): + """0-second keystroke with multibyte utf-8 input; should decode immediately.""" + # utf-8 bytes represent "latin capital letter upsilon". + import pty + pid, master_fd = pty.fork() + if pid == 0: # child + cov = init_subproc_coverage('test_keystroke_0s_cbreak_multibyte_utf8') + term = TestTerminal() + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.cbreak(): + inp = term.inkey(timeout=0) + os.write(sys.__stdout__.fileno(), inp.encode('utf-8')) + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + os.write(master_fd, u'\u01b1'.encode('utf-8')) + read_until_semaphore(master_fd) + stime = time.time() + output = read_until_eof(master_fd) + pid, status = os.waitpid(pid, 0) + assert output == u'Ʊ' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +@pytest.mark.skipif(os.environ.get('TRAVIS', None) is not None, + reason="travis-ci does not handle ^C very well.") +def test_keystroke_0s_raw_input_ctrl_c(): + """0-second keystroke with raw allows receiving ^C.""" + import pty + pid, master_fd = pty.fork() + if pid == 0: # child + cov = init_subproc_coverage('test_keystroke_0s_raw_input_ctrl_c') + term = TestTerminal() + read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) + with term.raw(): + os.write(sys.__stdout__.fileno(), RECV_SEMAPHORE) + inp = term.inkey(timeout=0) + os.write(sys.__stdout__.fileno(), inp.encode('latin1')) + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, SEND_SEMAPHORE) + # ensure child is in raw mode before sending ^C, + read_until_semaphore(master_fd) + os.write(master_fd, u'\x03'.encode('latin1')) + stime = time.time() + output = read_until_eof(master_fd) + pid, status = os.waitpid(pid, 0) + assert (output == u'\x03' or + output == u'' and not os.isatty(0)) + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +def test_keystroke_0s_cbreak_sequence(): + """0-second keystroke with multibyte sequence; should decode immediately.""" + import pty + pid, master_fd = pty.fork() + if pid == 0: # child + cov = init_subproc_coverage('test_keystroke_0s_cbreak_sequence') + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.cbreak(): + inp = term.inkey(timeout=0) + os.write(sys.__stdout__.fileno(), inp.name.encode('ascii')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, u'\x1b[D'.encode('ascii')) + read_until_semaphore(master_fd) + stime = time.time() + output = read_until_eof(master_fd) + pid, status = os.waitpid(pid, 0) + assert output == u'KEY_LEFT' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + + +@pytest.mark.skipif(os.environ.get('TEST_QUICK', None) is not None, + reason="TEST_QUICK specified") +def test_keystroke_1s_cbreak_with_input(): + """1-second keystroke w/multibyte sequence; should return after ~1 second.""" + import pty + pid, master_fd = pty.fork() + if pid == 0: # child + cov = init_subproc_coverage('test_keystroke_1s_cbreak_with_input') + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.cbreak(): + inp = term.inkey(timeout=3) + os.write(sys.__stdout__.fileno(), inp.name.encode('utf-8')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + read_until_semaphore(master_fd) + stime = time.time() + time.sleep(1) + os.write(master_fd, u'\x1b[C'.encode('ascii')) + output = read_until_eof(master_fd) + + pid, status = os.waitpid(pid, 0) + assert output == u'KEY_RIGHT' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 1.0 + + +@pytest.mark.skipif(os.environ.get('TEST_QUICK', None) is not None, + reason="TEST_QUICK specified") +def test_esc_delay_cbreak_035(): + """esc_delay will cause a single ESC (\\x1b) to delay for 0.35.""" + import pty + pid, master_fd = pty.fork() + if pid == 0: # child + cov = init_subproc_coverage('test_esc_delay_cbreak_035') + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.cbreak(): + stime = time.time() + inp = term.inkey(timeout=5) + measured_time = (time.time() - stime) * 100 + os.write(sys.__stdout__.fileno(), ( + '%s %i' % (inp.name, measured_time,)).encode('ascii')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + read_until_semaphore(master_fd) + stime = time.time() + os.write(master_fd, u'\x1b'.encode('ascii')) + key_name, duration_ms = read_until_eof(master_fd).split() + + pid, status = os.waitpid(pid, 0) + assert key_name == u'KEY_ESCAPE' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + assert 34 <= int(duration_ms) <= 45, duration_ms + + +@pytest.mark.skipif(os.environ.get('TEST_QUICK', None) is not None, + reason="TEST_QUICK specified") +def test_esc_delay_cbreak_135(): + """esc_delay=1.35 will cause a single ESC (\\x1b) to delay for 1.35.""" + import pty + pid, master_fd = pty.fork() + if pid == 0: # child + cov = init_subproc_coverage('test_esc_delay_cbreak_135') + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.cbreak(): + stime = time.time() + inp = term.inkey(timeout=5, esc_delay=1.35) + measured_time = (time.time() - stime) * 100 + os.write(sys.__stdout__.fileno(), ( + '%s %i' % (inp.name, measured_time,)).encode('ascii')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + read_until_semaphore(master_fd) + stime = time.time() + os.write(master_fd, u'\x1b'.encode('ascii')) + key_name, duration_ms = read_until_eof(master_fd).split() + + pid, status = os.waitpid(pid, 0) + assert key_name == u'KEY_ESCAPE' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 1.0 + assert 134 <= int(duration_ms) <= 145, int(duration_ms) + + +def test_esc_delay_cbreak_timout_0(): + """esc_delay still in effect with timeout of 0 ("nonblocking").""" + import pty + pid, master_fd = pty.fork() + if pid == 0: # child + cov = init_subproc_coverage('test_esc_delay_cbreak_timout_0') + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.cbreak(): + stime = time.time() + inp = term.inkey(timeout=0) + measured_time = (time.time() - stime) * 100 + os.write(sys.__stdout__.fileno(), ( + '%s %i' % (inp.name, measured_time,)).encode('ascii')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + os.write(master_fd, u'\x1b'.encode('ascii')) + read_until_semaphore(master_fd) + stime = time.time() + key_name, duration_ms = read_until_eof(master_fd).split() + + pid, status = os.waitpid(pid, 0) + assert key_name == u'KEY_ESCAPE' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + assert 34 <= int(duration_ms) <= 45, int(duration_ms) + + +def test_esc_delay_cbreak_nonprefix_sequence(): + """ESC a (\\x1ba) will return an ESC immediately.""" + import pty + pid, master_fd = pty.fork() + if pid == 0: # child + cov = init_subproc_coverage('test_esc_delay_cbreak_nonprefix_sequence') + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.cbreak(): + stime = time.time() + esc = term.inkey(timeout=5) + inp = term.inkey(timeout=5) + measured_time = (time.time() - stime) * 100 + os.write(sys.__stdout__.fileno(), ( + '%s %s %i' % (esc.name, inp, measured_time,)).encode('ascii')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + read_until_semaphore(master_fd) + stime = time.time() + os.write(master_fd, u'\x1ba'.encode('ascii')) + key1_name, key2, duration_ms = read_until_eof(master_fd).split() + + pid, status = os.waitpid(pid, 0) + assert key1_name == u'KEY_ESCAPE' + assert key2 == u'a' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + assert -1 <= int(duration_ms) <= 15, duration_ms + + +def test_esc_delay_cbreak_prefix_sequence(): + """An unfinished multibyte sequence (\\x1b[) will delay an ESC by .35.""" + import pty + pid, master_fd = pty.fork() + if pid == 0: # child + cov = init_subproc_coverage('test_esc_delay_cbreak_prefix_sequence') + term = TestTerminal() + os.write(sys.__stdout__.fileno(), SEMAPHORE) + with term.cbreak(): + stime = time.time() + esc = term.inkey(timeout=5) + inp = term.inkey(timeout=5) + measured_time = (time.time() - stime) * 100 + os.write(sys.__stdout__.fileno(), ( + '%s %s %i' % (esc.name, inp, measured_time,)).encode('ascii')) + sys.stdout.flush() + if cov is not None: + cov.stop() + cov.save() + os._exit(0) + + with echo_off(master_fd): + read_until_semaphore(master_fd) + stime = time.time() + os.write(master_fd, u'\x1b['.encode('ascii')) + key1_name, key2, duration_ms = read_until_eof(master_fd).split() + + pid, status = os.waitpid(pid, 0) + assert key1_name == u'KEY_ESCAPE' + assert key2 == u'[' + assert os.WEXITSTATUS(status) == 0 + assert math.floor(time.time() - stime) == 0.0 + assert 34 <= int(duration_ms) <= 45, duration_ms + + +def test_get_location_0s(): + """0-second get_location call without response.""" + @as_subprocess + def child(): + term = TestTerminal(stream=six.StringIO()) + stime = time.time() + y, x = term.get_location(timeout=0) + assert (math.floor(time.time() - stime) == 0.0) + assert (y, x) == (-1, -1) + child() + + +def test_get_location_0s_under_raw(): + """0-second get_location call without response under raw mode.""" + @as_subprocess + def child(): + term = TestTerminal(stream=six.StringIO()) + with term.raw(): + stime = time.time() + y, x = term.get_location(timeout=0) + assert (math.floor(time.time() - stime) == 0.0) + assert (y, x) == (-1, -1) + child() + + +def test_get_location_0s_reply_via_ungetch(): + """0-second get_location call with response.""" + @as_subprocess + def child(): + term = TestTerminal(stream=six.StringIO()) + stime = time.time() + # monkey patch in an invalid response ! + term.ungetch(u'\x1b[10;10R') + + y, x = term.get_location(timeout=0.01) + assert (math.floor(time.time() - stime) == 0.0) + assert (y, x) == (10, 10) + child() + + +def test_get_location_0s_reply_via_ungetch_under_raw(): + """0-second get_location call with response under raw mode.""" + @as_subprocess + def child(): + term = TestTerminal(stream=six.StringIO()) + with term.raw(): + stime = time.time() + # monkey patch in an invalid response ! + term.ungetch(u'\x1b[10;10R') + + y, x = term.get_location(timeout=0.01) + assert (math.floor(time.time() - stime) == 0.0) + assert (y, x) == (10, 10) + child() + + +def test_kbhit_no_kb(): + """kbhit() always immediately returns False without a keyboard.""" + @as_subprocess + def child(): + term = TestTerminal(stream=six.StringIO()) + stime = time.time() + assert term._keyboard_fd is None + assert not term.kbhit(timeout=1.1) + assert math.floor(time.time() - stime) == 1.0 + child() + + +def test_keystroke_0s_cbreak_noinput(): + """0-second keystroke without input; '' should be returned.""" + @as_subprocess + def child(): + term = TestTerminal() + with term.cbreak(): + stime = time.time() + inp = term.inkey(timeout=0) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 0.0) + child() + + +def test_keystroke_0s_cbreak_noinput_nokb(): + """0-second keystroke without data in input stream and no keyboard/tty.""" + @as_subprocess + def child(): + term = TestTerminal(stream=six.StringIO()) + with term.cbreak(): + stime = time.time() + inp = term.inkey(timeout=0) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 0.0) + child() + + +@pytest.mark.skipif(os.environ.get('TEST_QUICK', None) is not None, + reason="TEST_QUICK specified") +def test_keystroke_1s_cbreak_noinput(): + """1-second keystroke without input; '' should be returned after ~1 second.""" + @as_subprocess + def child(): + term = TestTerminal() + with term.cbreak(): + stime = time.time() + inp = term.inkey(timeout=1) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 1.0) + child() + + +@pytest.mark.skipif(os.environ.get('TEST_QUICK', None) is not None, + reason="TEST_QUICK specified") +def test_keystroke_1s_cbreak_noinput_nokb(): + """1-second keystroke without input or keyboard.""" + @as_subprocess + def child(): + term = TestTerminal(stream=six.StringIO()) + with term.cbreak(): + stime = time.time() + inp = term.inkey(timeout=1) + assert (inp == u'') + assert (math.floor(time.time() - stime) == 1.0) + child() diff --git a/tests/test_keyboard.py b/tests/test_keyboard.py new file mode 100644 index 00000000..b604293a --- /dev/null +++ b/tests/test_keyboard.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- +"""Tests for keyboard support.""" +# std imports +import sys +import platform +import tempfile +import functools + +# 3rd party +import mock +import pytest + +# local +from .accessories import TestTerminal, all_terms, as_subprocess + +if platform.system() != 'Windows': + import curses + import tty # NOQA +else: + import jinxed as curses + +if sys.version_info[0] == 3: + unichr = chr + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="?") +def test_break_input_no_kb(): + """cbreak() should not call tty.setcbreak() without keyboard.""" + @as_subprocess + def child(): + with tempfile.NamedTemporaryFile() as stream: + term = TestTerminal(stream=stream) + with mock.patch("tty.setcbreak") as mock_setcbreak: + with term.cbreak(): + assert not mock_setcbreak.called + assert term._keyboard_fd is None + child() + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="?") +def test_raw_input_no_kb(): + """raw should not call tty.setraw() without keyboard.""" + @as_subprocess + def child(): + with tempfile.NamedTemporaryFile() as stream: + term = TestTerminal(stream=stream) + with mock.patch("tty.setraw") as mock_setraw: + with term.raw(): + assert not mock_setraw.called + assert term._keyboard_fd is None + child() + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="?") +def test_raw_input_with_kb(): + """raw should call tty.setraw() when with keyboard.""" + @as_subprocess + def child(): + term = TestTerminal() + assert term._keyboard_fd is not None + with mock.patch("tty.setraw") as mock_setraw: + with term.raw(): + assert mock_setraw.called + child() + + +def test_notty_kb_is_None(): + """term._keyboard_fd should be None when os.isatty returns False.""" + # in this scenerio, stream is sys.__stdout__, + # but os.isatty(0) is False, + # such as when piping output to less(1) + @as_subprocess + def child(): + with mock.patch("os.isatty") as mock_isatty: + mock_isatty.return_value = False + term = TestTerminal() + assert term._keyboard_fd is None + child() + + +def test_keystroke_default_args(): + """Test keyboard.Keystroke constructor with default arguments.""" + from blessed.keyboard import Keystroke + ks = Keystroke() + assert ks._name is None + assert ks.name == ks._name + assert ks._code is None + assert ks.code == ks._code + assert u'x' == u'x' + ks + assert not ks.is_sequence + assert repr(ks) in ("u''", # py26, 27 + "''",) # py33 + + +def test_a_keystroke(): + """Test keyboard.Keystroke constructor with set arguments.""" + from blessed.keyboard import Keystroke + ks = Keystroke(ucs=u'x', code=1, name=u'the X') + assert ks._name == u'the X' + assert ks.name == ks._name + assert ks._code == 1 + assert ks.code == ks._code + assert u'xx' == u'x' + ks + assert ks.is_sequence + assert repr(ks) == "the X" + + +def test_get_keyboard_codes(): + """Test all values returned by get_keyboard_codes are from curses.""" + import blessed.keyboard + exemptions = dict(blessed.keyboard.CURSES_KEYCODE_OVERRIDE_MIXIN) + for value, keycode in blessed.keyboard.get_keyboard_codes().items(): + if keycode in exemptions: + assert value == exemptions[keycode] + continue + if keycode[4:] in blessed.keyboard._CURSES_KEYCODE_ADDINS: + assert not hasattr(curses, keycode) + assert hasattr(blessed.keyboard, keycode) + assert getattr(blessed.keyboard, keycode) == value + else: + assert hasattr(curses, keycode) + assert getattr(curses, keycode) == value + + +def test_alternative_left_right(): + """Test _alternative_left_right behavior for space/backspace.""" + from blessed.keyboard import _alternative_left_right + term = mock.Mock() + term._cuf1 = u'' + term._cub1 = u'' + assert not bool(_alternative_left_right(term)) + term._cuf1 = u' ' + term._cub1 = u'\b' + assert not bool(_alternative_left_right(term)) + term._cuf1 = u'seq-right' + term._cub1 = u'seq-left' + assert (_alternative_left_right(term) == { + u'seq-right': curses.KEY_RIGHT, + u'seq-left': curses.KEY_LEFT}) + + +def test_cuf1_and_cub1_as_RIGHT_LEFT(all_terms): + """Test that cuf1 and cub1 are assigned KEY_RIGHT and KEY_LEFT.""" + from blessed.keyboard import get_keyboard_sequences + + @as_subprocess + def child(kind): + term = TestTerminal(kind=kind, force_styling=True) + keymap = get_keyboard_sequences(term) + if term._cuf1: + assert term._cuf1 in keymap + assert keymap[term._cuf1] == term.KEY_RIGHT + if term._cub1: + assert term._cub1 in keymap + if term._cub1 == '\b': + assert keymap[term._cub1] == term.KEY_BACKSPACE + else: + assert keymap[term._cub1] == term.KEY_LEFT + + child(all_terms) + + +def test_get_keyboard_sequences_sort_order(): + """ordereddict ensures sequences are ordered longest-first.""" + @as_subprocess + def child(kind): + term = TestTerminal(kind=kind, force_styling=True) + maxlen = None + for sequence, code in term._keymap.items(): + if maxlen is not None: + assert len(sequence) <= maxlen + assert sequence + maxlen = len(sequence) + kind = 'xterm-256color' + if platform.system() == 'Windows': + kind = 'vtwin10' + child(kind) + + +def test_get_keyboard_sequence(monkeypatch): + """Test keyboard.get_keyboard_sequence.""" + import blessed.keyboard + + (KEY_SMALL, KEY_LARGE, KEY_MIXIN) = range(3) + (CAP_SMALL, CAP_LARGE) = 'cap-small cap-large'.split() + (SEQ_SMALL, SEQ_LARGE, SEQ_MIXIN, SEQ_ALT_CUF1, SEQ_ALT_CUB1) = ( + b'seq-small-a', + b'seq-large-abcdefg', + b'seq-mixin', + b'seq-alt-cuf1', + b'seq-alt-cub1_') + + # patch curses functions + monkeypatch.setattr(curses, 'tigetstr', + lambda cap: {CAP_SMALL: SEQ_SMALL, + CAP_LARGE: SEQ_LARGE}[cap]) + + monkeypatch.setattr(blessed.keyboard, 'capability_names', + dict(((KEY_SMALL, CAP_SMALL,), + (KEY_LARGE, CAP_LARGE,)))) + + # patch global sequence mix-in + monkeypatch.setattr(blessed.keyboard, + 'DEFAULT_SEQUENCE_MIXIN', ( + (SEQ_MIXIN.decode('latin1'), KEY_MIXIN),)) + + # patch for _alternative_left_right + term = mock.Mock() + term._cuf1 = SEQ_ALT_CUF1.decode('latin1') + term._cub1 = SEQ_ALT_CUB1.decode('latin1') + keymap = blessed.keyboard.get_keyboard_sequences(term) + + assert list(keymap.items()) == [ + (SEQ_LARGE.decode('latin1'), KEY_LARGE), + (SEQ_ALT_CUB1.decode('latin1'), curses.KEY_LEFT), + (SEQ_ALT_CUF1.decode('latin1'), curses.KEY_RIGHT), + (SEQ_SMALL.decode('latin1'), KEY_SMALL), + (SEQ_MIXIN.decode('latin1'), KEY_MIXIN)] + + +def test_resolve_sequence(): + """Test resolve_sequence for order-dependent mapping.""" + from blessed.keyboard import resolve_sequence, OrderedDict + mapper = OrderedDict(((u'SEQ1', 1), + (u'SEQ2', 2), + # takes precedence over LONGSEQ, first-match + (u'KEY_LONGSEQ_longest', 3), + (u'LONGSEQ', 4), + # wont match, LONGSEQ is first-match in this order + (u'LONGSEQ_longer', 5), + # falls through for L{anything_else} + (u'L', 6))) + codes = {1: u'KEY_SEQ1', + 2: u'KEY_SEQ2', + 3: u'KEY_LONGSEQ_longest', + 4: u'KEY_LONGSEQ', + 5: u'KEY_LONGSEQ_longer', + 6: u'KEY_L'} + ks = resolve_sequence(u'', mapper, codes) + assert ks == u'' + assert ks.name is None + assert ks.code is None + assert not ks.is_sequence + assert repr(ks) in ("u''", # py26, 27 + "''",) # py33 + + ks = resolve_sequence(u'notfound', mapper=mapper, codes=codes) + assert ks == u'n' + assert ks.name is None + assert ks.code is None + assert not ks.is_sequence + assert repr(ks) in (u"u'n'", "'n'",) + + ks = resolve_sequence(u'SEQ1', mapper, codes) + assert ks == u'SEQ1' + assert ks.name == u'KEY_SEQ1' + assert ks.code == 1 + assert ks.is_sequence + assert repr(ks) in (u"KEY_SEQ1", "KEY_SEQ1") + + ks = resolve_sequence(u'LONGSEQ_longer', mapper, codes) + assert ks == u'LONGSEQ' + assert ks.name == u'KEY_LONGSEQ' + assert ks.code == 4 + assert ks.is_sequence + assert repr(ks) in (u"KEY_LONGSEQ", "KEY_LONGSEQ") + + ks = resolve_sequence(u'LONGSEQ', mapper, codes) + assert ks == u'LONGSEQ' + assert ks.name == u'KEY_LONGSEQ' + assert ks.code == 4 + assert ks.is_sequence + assert repr(ks) in (u"KEY_LONGSEQ", "KEY_LONGSEQ") + + ks = resolve_sequence(u'Lxxxxx', mapper, codes) + assert ks == u'L' + assert ks.name == u'KEY_L' + assert ks.code == 6 + assert ks.is_sequence + assert repr(ks) in (u"KEY_L", "KEY_L") + + +def test_keyboard_prefixes(): + """Test keyboard.prefixes.""" + from blessed.keyboard import get_leading_prefixes + keys = ['abc', 'abdf', 'e', 'jkl'] + pfs = get_leading_prefixes(keys) + assert pfs == set([u'a', u'ab', u'abd', u'j', u'jk']) + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="no multiprocess") +def test_keypad_mixins_and_aliases(): + """Test PC-Style function key translations when in ``keypad`` mode.""" + # Key plain app modified + # Up ^[[A ^[OA ^[[1;mA + # Down ^[[B ^[OB ^[[1;mB + # Right ^[[C ^[OC ^[[1;mC + # Left ^[[D ^[OD ^[[1;mD + # End ^[[F ^[OF ^[[1;mF + # Home ^[[H ^[OH ^[[1;mH + @as_subprocess + def child(kind): + term = TestTerminal(kind=kind, force_styling=True) + from blessed.keyboard import resolve_sequence + + resolve = functools.partial(resolve_sequence, + mapper=term._keymap, + codes=term._keycodes) + + assert resolve(unichr(10)).name == "KEY_ENTER" + assert resolve(unichr(13)).name == "KEY_ENTER" + assert resolve(unichr(8)).name == "KEY_BACKSPACE" + assert resolve(unichr(9)).name == "KEY_TAB" + assert resolve(unichr(27)).name == "KEY_ESCAPE" + assert resolve(unichr(127)).name == "KEY_BACKSPACE" + assert resolve(u"\x1b[A").name == "KEY_UP" + assert resolve(u"\x1b[B").name == "KEY_DOWN" + assert resolve(u"\x1b[C").name == "KEY_RIGHT" + assert resolve(u"\x1b[D").name == "KEY_LEFT" + assert resolve(u"\x1b[U").name == "KEY_PGDOWN" + assert resolve(u"\x1b[V").name == "KEY_PGUP" + assert resolve(u"\x1b[H").name == "KEY_HOME" + assert resolve(u"\x1b[F").name == "KEY_END" + assert resolve(u"\x1b[K").name == "KEY_END" + assert resolve(u"\x1bOM").name == "KEY_ENTER" + assert resolve(u"\x1bOj").name == "KEY_KP_MULTIPLY" + assert resolve(u"\x1bOk").name == "KEY_KP_ADD" + assert resolve(u"\x1bOl").name == "KEY_KP_SEPARATOR" + assert resolve(u"\x1bOm").name == "KEY_KP_SUBTRACT" + assert resolve(u"\x1bOn").name == "KEY_KP_DECIMAL" + assert resolve(u"\x1bOo").name == "KEY_KP_DIVIDE" + assert resolve(u"\x1bOX").name == "KEY_KP_EQUAL" + assert resolve(u"\x1bOp").name == "KEY_KP_0" + assert resolve(u"\x1bOq").name == "KEY_KP_1" + assert resolve(u"\x1bOr").name == "KEY_KP_2" + assert resolve(u"\x1bOs").name == "KEY_KP_3" + assert resolve(u"\x1bOt").name == "KEY_KP_4" + assert resolve(u"\x1bOu").name == "KEY_KP_5" + assert resolve(u"\x1bOv").name == "KEY_KP_6" + assert resolve(u"\x1bOw").name == "KEY_KP_7" + assert resolve(u"\x1bOx").name == "KEY_KP_8" + assert resolve(u"\x1bOy").name == "KEY_KP_9" + assert resolve(u"\x1b[1~").name == "KEY_FIND" + assert resolve(u"\x1b[2~").name == "KEY_INSERT" + assert resolve(u"\x1b[3~").name == "KEY_DELETE" + assert resolve(u"\x1b[4~").name == "KEY_SELECT" + assert resolve(u"\x1b[5~").name == "KEY_PGUP" + assert resolve(u"\x1b[6~").name == "KEY_PGDOWN" + assert resolve(u"\x1b[7~").name == "KEY_HOME" + assert resolve(u"\x1b[8~").name == "KEY_END" + assert resolve(u"\x1b[OA").name == "KEY_UP" + assert resolve(u"\x1b[OB").name == "KEY_DOWN" + assert resolve(u"\x1b[OC").name == "KEY_RIGHT" + assert resolve(u"\x1b[OD").name == "KEY_LEFT" + assert resolve(u"\x1b[OF").name == "KEY_END" + assert resolve(u"\x1b[OH").name == "KEY_HOME" + assert resolve(u"\x1bOP").name == "KEY_F1" + assert resolve(u"\x1bOQ").name == "KEY_F2" + assert resolve(u"\x1bOR").name == "KEY_F3" + assert resolve(u"\x1bOS").name == "KEY_F4" + + child('xterm') diff --git a/tests/test_length_sequence.py b/tests/test_length_sequence.py new file mode 100644 index 00000000..33bea28c --- /dev/null +++ b/tests/test_length_sequence.py @@ -0,0 +1,437 @@ +# encoding: utf-8 +# std imports +import os +import sys +import struct +import platform +import itertools + +# 3rd party +import six +import pytest + +from .accessories import ( # isort:skip + TestTerminal, as_subprocess, all_terms, many_lines, many_columns) + +if platform.system() != 'Windows': + import fcntl + import termios + + +def test_length_cjk(): + @as_subprocess + def child(): + term = TestTerminal() + + # given, + given = term.bold_red(u'コンニチハ, セカイ!') + expected = sum((2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 1,)) + + # exercise, + assert term.length(given) == expected + + child() + + +def test_length_ansiart(): + @as_subprocess + def child(kind): + import codecs + term = TestTerminal(kind=kind) + # this 'ansi' art contributed by xzip!impure for another project, + # unlike most CP-437 DOS ansi art, this is actually utf-8 encoded. + fname = os.path.join(os.path.dirname(__file__), 'wall.ans') + lines = codecs.open(fname, 'r', 'utf-8').readlines() + assert term.length(lines[0]) == 67 # ^[[64C^[[34m▄▓▄ + assert term.length(lines[1]) == 75 + assert term.length(lines[2]) == 78 + assert term.length(lines[3]) == 78 + assert term.length(lines[4]) == 78 + assert term.length(lines[5]) == 78 + assert term.length(lines[6]) == 77 + kind = 'xterm-256color' + if platform.system() == 'Windows': + kind = 'vtwin10' + child(kind) + + +def test_sequence_length(all_terms): + """Ensure T.length(string containing sequence) is correcterm.""" + @as_subprocess + def child(kind): + term = TestTerminal(kind=kind, force_styling=True) + # Create a list of ascii characters, to be separated + # by word, to be zipped up with a cycling list of + # terminal sequences. Then, compare the length of + # each, the basic plain_texterm.__len__ vs. the Terminal + # method length. They should be equal. + plain_text = (u'The softest things of the world ' + u'Override the hardest things of the world ' + u'That which has no substance ' + u'Enters into that which has no openings') + if term.bold: + assert (term.length(term.bold) == 0) + assert (term.length(term.bold(u'x')) == 1) + assert (term.length(term.bold_red) == 0) + assert (term.length(term.bold_red(u'x')) == 1) + assert (term.strip(term.bold) == u'') + assert (term.rstrip(term.bold) == u'') + assert (term.lstrip(term.bold) == u'') + assert (term.strip(term.bold(u' x ')) == u'x') + assert (term.strip(term.bold(u'z x q'), 'zq') == u' x ') + assert (term.rstrip(term.bold(u' x ')) == u' x') + assert (term.lstrip(term.bold(u' x ')) == u'x ') + assert (term.strip(term.bold_red) == u'') + assert (term.rstrip(term.bold_red) == u'') + assert (term.lstrip(term.bold_red) == u'') + assert (term.strip(term.bold_red(u' x ')) == u'x') + assert (term.rstrip(term.bold_red(u' x ')) == u' x') + assert (term.lstrip(term.bold_red(u' x ')) == u'x ') + assert (term.strip_seqs(term.bold) == u'') + assert (term.strip_seqs(term.bold(u' x ')) == u' x ') + assert (term.strip_seqs(term.bold_red) == u'') + assert (term.strip_seqs(term.bold_red(u' x ')) == u' x ') + + if term.underline: + assert (term.length(term.underline) == 0) + assert (term.length(term.underline(u'x')) == 1) + assert (term.length(term.underline_red) == 0) + assert (term.length(term.underline_red(u'x')) == 1) + assert (term.strip(term.underline) == u'') + assert (term.strip(term.underline(u' x ')) == u'x') + assert (term.strip(term.underline_red) == u'') + assert (term.strip(term.underline_red(u' x ')) == u'x') + assert (term.rstrip(term.underline_red(u' x ')) == u' x') + assert (term.lstrip(term.underline_red(u' x ')) == u'x ') + assert (term.strip_seqs(term.underline) == u'') + assert (term.strip_seqs(term.underline(u' x ')) == u' x ') + assert (term.strip_seqs(term.underline_red) == u'') + assert (term.strip_seqs(term.underline_red(u' x ')) == u' x ') + + if term.reverse: + assert (term.length(term.reverse) == 0) + assert (term.length(term.reverse(u'x')) == 1) + assert (term.length(term.reverse_red) == 0) + assert (term.length(term.reverse_red(u'x')) == 1) + assert (term.strip(term.reverse) == u'') + assert (term.strip(term.reverse(u' x ')) == u'x') + assert (term.strip(term.reverse_red) == u'') + assert (term.strip(term.reverse_red(u' x ')) == u'x') + assert (term.rstrip(term.reverse_red(u' x ')) == u' x') + assert (term.lstrip(term.reverse_red(u' x ')) == u'x ') + assert (term.strip_seqs(term.reverse) == u'') + assert (term.strip_seqs(term.reverse(u' x ')) == u' x ') + assert (term.strip_seqs(term.reverse_red) == u'') + assert (term.strip_seqs(term.reverse_red(u' x ')) == u' x ') + + if term.blink: + assert (term.length(term.blink) == 0) + assert (term.length(term.blink(u'x')) == 1) + assert (term.length(term.blink_red) == 0) + assert (term.length(term.blink_red(u'x')) == 1) + assert (term.strip(term.blink) == u'') + assert (term.strip(term.blink(u' x ')) == u'x') + assert (term.strip(term.blink(u'z x q'), u'zq') == u' x ') + assert (term.strip(term.blink_red) == u'') + assert (term.strip(term.blink_red(u' x ')) == u'x') + assert (term.strip_seqs(term.blink) == u'') + assert (term.strip_seqs(term.blink(u' x ')) == u' x ') + assert (term.strip_seqs(term.blink_red) == u'') + assert (term.strip_seqs(term.blink_red(u' x ')) == u' x ') + + if term.home: + assert (term.length(term.home) == 0) + assert (term.strip(term.home) == u'') + if term.clear_eol: + assert (term.length(term.clear_eol) == 0) + assert (term.strip(term.clear_eol) == u'') + if term.enter_fullscreen: + assert (term.length(term.enter_fullscreen) == 0) + assert (term.strip(term.enter_fullscreen) == u'') + if term.exit_fullscreen: + assert (term.length(term.exit_fullscreen) == 0) + assert (term.strip(term.exit_fullscreen) == u'') + + # horizontally, we decide move_down and move_up are 0, + assert (term.length(term.move_down) == 0) + assert (term.length(term.move_down(2)) == 0) + assert (term.length(term.move_up) == 0) + assert (term.length(term.move_up(2)) == 0) + + # other things aren't so simple, somewhat edge cases, + # moving backwards and forwards horizontally must be + # accounted for as a "length", as + # will result in a printed column length of 12 (even + # though columns 2-11 are non-destructive space + assert (term.length(u'x\b') == 0) + assert (term.strip(u'x\b') == u'') + + # XXX why are some terminals width of 9 here ?? + assert (term.length(u'\t') in (8, 9)) + assert (term.strip(u'\t') == u'') + + assert (term.length(u'_' + term.move_left) == 0) + assert (term.length(term.move_right) == 1) + + if term.cub: + assert (term.length((u'_' * 10) + term.cub(10)) == 0) + + if term.cuf: + assert (term.length(term.cuf(10)) == 10) + + # vertical spacing is unaccounted as a 'length' + assert (term.length(term.move_up) == 0) + assert (term.length(term.cuu(10)) == 0) + assert (term.length(term.move_down) == 0) + assert (term.length(term.cud(10)) == 0) + + # this is how manpages perform underlining, this is done + # with the 'overstrike' capability of teletypes, and aparently + # less(1), '123' -> '1\b_2\b_3\b_' + text_wseqs = u''.join(itertools.chain( + *zip(plain_text, itertools.cycle(['\b_'])))) + assert (term.length(text_wseqs) == len(plain_text)) + + child(all_terms) + + +def test_env_winsize(): + """Test height and width is appropriately queried in a pty.""" + @as_subprocess + def child(): + # set the pty's virtual window size + os.environ['COLUMNS'] = '99' + os.environ['LINES'] = '11' + term = TestTerminal(stream=six.StringIO()) + save_init = term._init_descriptor + save_stdout = sys.__stdout__ + try: + term._init_descriptor = None + sys.__stdout__ = None + winsize = term._height_and_width() + width = term.width + height = term.height + finally: + term._init_descriptor = save_init + sys.__stdout__ = save_stdout + assert winsize.ws_col == width == 99 + assert winsize.ws_row == height == 11 + + child() + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="requires fcntl") +def test_winsize(many_lines, many_columns): + """Test height and width is appropriately queried in a pty.""" + @as_subprocess + def child(lines=25, cols=80): + # set the pty's virtual window size + val = struct.pack('HHHH', lines, cols, 0, 0) + fcntl.ioctl(sys.__stdout__.fileno(), termios.TIOCSWINSZ, val) + term = TestTerminal() + winsize = term._height_and_width() + assert term.width == cols + assert term.height == lines + assert winsize.ws_col == cols + assert winsize.ws_row == lines + + child(lines=many_lines, cols=many_columns) + + +def test_Sequence_alignment_fixed_width(): + @as_subprocess + def child(kind): + term = TestTerminal(kind=kind) + pony_msg = 'pony express, all aboard, choo, choo!' + pony_len = len(pony_msg) + pony_colored = u''.join( + ['%s%s' % (term.color(n % 7), ch,) + for n, ch in enumerate(pony_msg)]) + pony_colored += term.normal + ladjusted = term.ljust(pony_colored, 88) + radjusted = term.rjust(pony_colored, 88) + centered = term.center(pony_colored, 88) + assert (term.length(pony_colored) == pony_len) + assert (term.length(centered.strip()) == pony_len) + assert (term.length(centered) == len(pony_msg.center(88))) + assert (term.length(ladjusted.strip()) == pony_len) + assert (term.length(ladjusted) == len(pony_msg.ljust(88))) + assert (term.length(radjusted.strip()) == pony_len) + assert (term.length(radjusted) == len(pony_msg.rjust(88))) + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="requires fcntl") +def test_Sequence_alignment(all_terms): + """Tests methods related to Sequence class, namely ljust, rjust, center.""" + @as_subprocess + def child(kind, lines=25, cols=80): + # set the pty's virtual window size + val = struct.pack('HHHH', lines, cols, 0, 0) + fcntl.ioctl(sys.__stdout__.fileno(), termios.TIOCSWINSZ, val) + term = TestTerminal(kind=kind) + + pony_msg = 'pony express, all aboard, choo, choo!' + pony_len = len(pony_msg) + pony_colored = u''.join( + ['%s%s' % (term.color(n % 7), ch,) + for n, ch in enumerate(pony_msg)]) + pony_colored += term.normal + ladjusted = term.ljust(pony_colored) + radjusted = term.rjust(pony_colored) + centered = term.center(pony_colored) + assert (term.length(pony_colored) == pony_len) + assert (term.length(centered.strip()) == pony_len) + assert (term.length(centered) == len(pony_msg.center(term.width))) + assert (term.length(ladjusted.strip()) == pony_len) + assert (term.length(ladjusted) == len(pony_msg.ljust(term.width))) + assert (term.length(radjusted.strip()) == pony_len) + assert (term.length(radjusted) == len(pony_msg.rjust(term.width))) + + child(kind=all_terms) + + +def test_sequence_is_movement_false(all_terms): + """Test parser about sequences that do not move the cursor.""" + @as_subprocess + def child(kind): + from blessed.sequences import measure_length + term = TestTerminal(kind=kind) + assert (0 == measure_length(u'', term)) + # not even a mbs + assert (0 == measure_length(u'xyzzy', term)) + # negative numbers, though printable as %d, do not result + # in movement; just garbage. Also not a valid sequence. + assert (0 == measure_length(term.cuf(-333), term)) + assert (len(term.clear_eol) == measure_length(term.clear_eol, term)) + # various erases don't *move* + assert (len(term.clear_bol) == measure_length(term.clear_bol, term)) + assert (len(term.clear_eos) == measure_length(term.clear_eos, term)) + assert (len(term.bold) == measure_length(term.bold, term)) + # various paints don't move + assert (len(term.red) == measure_length(term.red, term)) + assert (len(term.civis) == measure_length(term.civis, term)) + if term.cvvis: + assert (len(term.cvvis) == measure_length(term.cvvis, term)) + assert (len(term.underline) == measure_length(term.underline, term)) + assert (len(term.reverse) == measure_length(term.reverse, term)) + for _num in (0, term.number_of_colors): + expected = len(term.color(_num)) + given = measure_length(term.color(_num), term) + assert (expected == given) + assert (len(term.normal_cursor) == measure_length(term.normal_cursor, term)) + assert (len(term.hide_cursor) == measure_length(term.hide_cursor, term)) + assert (len(term.save) == measure_length(term.save, term)) + assert (len(term.italic) == measure_length(term.italic, term)) + assert (len(term.standout) == measure_length(term.standout, term) + ), (term.standout, term._wont_move) + + child(all_terms) + + +def test_termcap_will_move_false(all_terms): + """Test parser about sequences that do not move the cursor.""" + @as_subprocess + def child(kind): + from blessed.sequences import iter_parse + term = TestTerminal(kind=kind) + if term.clear_eol: + assert not next(iter_parse(term, term.clear_eol))[1].will_move + if term.clear_bol: + assert not next(iter_parse(term, term.clear_bol))[1].will_move + if term.clear_eos: + assert not next(iter_parse(term, term.clear_eos))[1].will_move + if term.bold: + assert not next(iter_parse(term, term.bold))[1].will_move + if term.red: + assert not next(iter_parse(term, term.red))[1].will_move + if term.civis: + assert not next(iter_parse(term, term.civis))[1].will_move + if term.cvvis: + assert not next(iter_parse(term, term.cvvis))[1].will_move + if term.underline: + assert not next(iter_parse(term, term.underline))[1].will_move + if term.reverse: + assert not next(iter_parse(term, term.reverse))[1].will_move + if term.color(0): + assert not next(iter_parse(term, term.color(0)))[1].will_move + if term.normal_cursor: + assert not next(iter_parse(term, term.normal_cursor))[1].will_move + if term.save: + assert not next(iter_parse(term, term.save))[1].will_move + if term.italic: + assert not next(iter_parse(term, term.italic))[1].will_move + if term.standout: + assert not next(iter_parse(term, term.standout))[1].will_move + + child(all_terms) + + +def test_sequence_is_movement_true(all_terms): + """Test parsers about sequences that move the cursor.""" + @as_subprocess + def child(kind): + from blessed.sequences import measure_length + term = TestTerminal(kind=kind) + # movements + assert (len(term.move(98, 76)) == + measure_length(term.move(98, 76), term)) + assert (len(term.move(54)) == + measure_length(term.move(54), term)) + assert (len(term.move_xy(1, 2)) == + measure_length(term.move(1, 2), term)) + assert (len(term.move_yx(3, 4)) == + measure_length(term.move(3, 4), term)) + assert not term.cud1 or (len(term.cud1) == + measure_length(term.cud1, term)) + assert not term.cub1 or (len(term.cub1) == + measure_length(term.cub1, term)) + assert not term.cuf1 or (len(term.cuf1) == + measure_length(term.cuf1, term)) + assert not term.cuu1 or (len(term.cuu1) == + measure_length(term.cuu1, term)) + assert not term.cub or (len(term.cub(333)) == + measure_length(term.cub(333), term)) + assert not term.cuf or (len(term.cuf(333)) == + measure_length(term.cuf(333), term)) + assert not term.home or (len(term.home) == + measure_length(term.home, term)) + assert not term.restore or (len(term.restore) == + measure_length(term.restore, term)) + assert not term.clear or (len(term.clear) == + measure_length(term.clear, term)) + + child(all_terms) + + +def test_termcap_will_move_true(all_terms): + """Test parser about sequences that move the cursor.""" + @as_subprocess + def child(kind): + from blessed.sequences import iter_parse + term = TestTerminal(kind=kind, force_styling=True) + assert next(iter_parse(term, term.move(98, 76)))[1].will_move + assert next(iter_parse(term, term.move_yx(8, 76)))[1].will_move + assert next(iter_parse(term, term.move_xy(98, 7)))[1].will_move + assert next(iter_parse(term, term.move(54)))[1].will_move + assert next(iter_parse(term, term.cud1))[1].will_move + assert next(iter_parse(term, term.cub1))[1].will_move + assert next(iter_parse(term, term.cuf1))[1].will_move + assert next(iter_parse(term, term.cuu1))[1].will_move + if term.cub(333): + assert next(iter_parse(term, term.cub(333)))[1].will_move + if term.cuf(333): + assert next(iter_parse(term, term.cuf(333)))[1].will_move + assert next(iter_parse(term, term.home))[1].will_move + assert next(iter_parse(term, term.restore))[1].will_move + assert next(iter_parse(term, term.clear))[1].will_move + child(all_terms) + + +def test_foreign_sequences(): + """Test parsers about sequences received from foreign sources.""" + @as_subprocess + def child(kind): + from blessed.sequences import measure_length + term = TestTerminal(kind=kind) + assert measure_length(u'\x1b[m', term) == len('\x1b[m') + child(kind='ansi') diff --git a/tests/test_sequences.py b/tests/test_sequences.py new file mode 100644 index 00000000..b4af8bc5 --- /dev/null +++ b/tests/test_sequences.py @@ -0,0 +1,514 @@ +# -*- coding: utf-8 -*- +"""Tests for Terminal() sequences and sequence-awareness.""" +# std imports +import sys +import platform + +# 3rd party +import six +import pytest + +# local +from .accessories import TestTerminal, all_terms, unicode_cap, unicode_parm, as_subprocess + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="requires real tty") +def test_capability(): + """Check that capability lookup works.""" + @as_subprocess + def child(): + # Also test that Terminal grabs a reasonable default stream. + t = TestTerminal() + sc = unicode_cap('sc') + assert t.save == sc + assert t.save == sc # Make sure caching doesn't screw it up. + + child() + + +def test_capability_without_tty(): + """Assert capability templates are '' when stream is not a tty.""" + @as_subprocess + def child(): + t = TestTerminal(stream=six.StringIO()) + assert t.save == u'' + assert t.red == u'' + + child() + + +def test_capability_with_forced_tty(): + """force styling should return sequences even for non-ttys.""" + @as_subprocess + def child(): + t = TestTerminal(stream=six.StringIO(), force_styling=True) + assert t.save == unicode_cap('sc') + + child() + + +def test_parametrization(): + """Test parameterizing a capability.""" + @as_subprocess + def child(): + term = TestTerminal(force_styling=True) + assert term.cup(3, 4) == unicode_parm('cup', 3, 4) + + child() + + +def test_height_and_width(): + """Assert that ``height_and_width()`` returns full integers.""" + @as_subprocess + def child(): + t = TestTerminal() # kind shouldn't matter. + assert isinstance(t.height, int) + assert isinstance(t.width, int) + + child() + + +def test_stream_attr(): + """Make sure Terminal ``stream`` is stdout by default.""" + @as_subprocess + def child(): + assert TestTerminal().stream == sys.__stdout__ + + child() + + +def test_location_with_styling(all_terms): + """Make sure ``location()`` works on all terminals.""" + @as_subprocess + def child_with_styling(kind): + t = TestTerminal(kind=kind, stream=six.StringIO(), force_styling=True) + with t.location(3, 4): + t.stream.write(u'hi') + expected_output = u''.join( + (unicode_cap('sc') or u'\x1b[s', + unicode_parm('cup', 4, 3), + u'hi', + unicode_cap('rc') or u'\x1b[u')) + assert (t.stream.getvalue() == expected_output) + + child_with_styling(all_terms) + + +def test_location_without_styling(): + """Make sure ``location()`` silently passes without styling.""" + @as_subprocess + def child_without_styling(): + """No side effect for location as a context manager without styling.""" + t = TestTerminal(stream=six.StringIO(), force_styling=None) + + with t.location(3, 4): + t.stream.write(u'hi') + + assert t.stream.getvalue() == u'hi' + + child_without_styling() + + +def test_horizontal_location(all_terms): + """Make sure we can move the cursor horizontally without changing rows.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=six.StringIO(), force_styling=True) + with t.location(x=5): + pass + _hpa = unicode_parm('hpa', 5) + if not _hpa and (kind.startswith('screen') or + kind.startswith('ansi')): + _hpa = u'\x1b[6G' + expected_output = u''.join( + (unicode_cap('sc') or u'\x1b[s', + _hpa, + unicode_cap('rc') or u'\x1b[u')) + assert (t.stream.getvalue() == expected_output), ( + repr(t.stream.getvalue()), repr(expected_output)) + + child(all_terms) + + +def test_vertical_location(all_terms): + """Make sure we can move the cursor horizontally without changing rows.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=six.StringIO(), force_styling=True) + with t.location(y=5): + pass + _vpa = unicode_parm('vpa', 5) + if not _vpa and (kind.startswith('screen') or + kind.startswith('ansi')): + _vpa = u'\x1b[6d' + + expected_output = u''.join( + (unicode_cap('sc') or u'\x1b[s', + _vpa, + unicode_cap('rc') or u'\x1b[u')) + assert (t.stream.getvalue() == expected_output) + + child(all_terms) + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="requires multiprocess") +def test_inject_move_x(): + """Test injection of hpa attribute for screen/ansi (issue #55).""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=six.StringIO(), force_styling=True) + COL = 5 + with t.location(x=COL): + pass + expected_output = u''.join( + (unicode_cap('sc') or u'\x1b[s', + u'\x1b[{0}G'.format(COL + 1), + unicode_cap('rc') or u'\x1b[u')) + assert (t.stream.getvalue() == expected_output) + assert (t.move_x(COL) == u'\x1b[{0}G'.format(COL + 1)) + + child('screen') + child('screen-256color') + child('ansi') + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="requires multiprocess") +def test_inject_move_y(): + """Test injection of vpa attribute for screen/ansi (issue #55).""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=six.StringIO(), force_styling=True) + ROW = 5 + with t.location(y=ROW): + pass + expected_output = u''.join( + (unicode_cap('sc') or u'\x1b[s', + u'\x1b[{0}d'.format(ROW + 1), + unicode_cap('rc') or u'\x1b[u')) + assert (t.stream.getvalue() == expected_output) + assert (t.move_y(ROW) == u'\x1b[{0}d'.format(ROW + 1)) + + child('screen') + child('screen-256color') + child('ansi') + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="requires multiprocess") +def test_inject_civis_and_cnorm_for_ansi(): + """Test injection of cvis attribute for ansi.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=six.StringIO(), force_styling=True) + with t.hidden_cursor(): + pass + expected_output = u'\x1b[?25l\x1b[?25h' + assert (t.stream.getvalue() == expected_output) + + child('ansi') + + +@pytest.mark.skipif(platform.system() == 'Windows', reason="requires multiprocess") +def test_inject_sc_and_rc_for_ansi(): + """Test injection of sc and rc (save and restore cursor) for ansi.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=six.StringIO(), force_styling=True) + with t.location(): + pass + expected_output = u'\x1b[s\x1b[u' + assert (t.stream.getvalue() == expected_output) + + child('ansi') + + +def test_zero_location(all_terms): + """Make sure ``location()`` pays attention to 0-valued args.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=six.StringIO(), force_styling=True) + with t.location(0, 0): + pass + expected_output = u''.join( + (unicode_cap('sc') or u'\x1b[s', + unicode_parm('cup', 0, 0), + unicode_cap('rc') or u'\x1b[u')) + assert (t.stream.getvalue() == expected_output) + + child(all_terms) + + +def test_mnemonic_colors(all_terms): + """Make sure color shortcuts work.""" + @as_subprocess + def child(kind): + def color(t, num): + return t.number_of_colors and unicode_parm('setaf', num) or '' + + def on_color(t, num): + return t.number_of_colors and unicode_parm('setab', num) or '' + + # Avoid testing red, blue, yellow, and cyan, since they might someday + # change depending on terminal type. + t = TestTerminal(kind=kind) + assert (t.white == color(t, 7)) + assert (t.green == color(t, 2)) # Make sure it's different than white. + assert (t.on_black == on_color(t, 0)) + assert (t.on_green == on_color(t, 2)) + assert (t.bright_black == color(t, 8)) + assert (t.bright_green == color(t, 10)) + assert (t.on_bright_black == on_color(t, 8)) + assert (t.on_bright_green == on_color(t, 10)) + + child(all_terms) + + +def test_callable_numeric_colors(all_terms): + """``color(n)`` should return a formatting wrapper.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + if t.magenta: + assert t.color(5)('smoo') == t.magenta + 'smoo' + t.normal + else: + assert t.color(5)('smoo') == 'smoo' + + if t.on_magenta: + assert t.on_color(5)('smoo') == t.on_magenta + 'smoo' + t.normal + else: + assert t.color(5)(u'smoo') == 'smoo' + + if t.color(4): + assert t.color(4)(u'smoo') == t.color(4) + u'smoo' + t.normal + else: + assert t.color(4)(u'smoo') == 'smoo' + + if t.on_green: + assert t.on_color(2)('smoo') == t.on_green + u'smoo' + t.normal + else: + assert t.on_color(2)('smoo') == 'smoo' + + if t.on_color(6): + assert t.on_color(6)('smoo') == t.on_color(6) + u'smoo' + t.normal + else: + assert t.on_color(6)('smoo') == 'smoo' + + child(all_terms) + + +def test_null_callable_numeric_colors(all_terms): + """``color(n)`` should be a no-op on null terminals.""" + @as_subprocess + def child(kind): + t = TestTerminal(stream=six.StringIO(), kind=kind) + assert (t.color(5)('smoo') == 'smoo') + assert (t.on_color(6)('smoo') == 'smoo') + + child(all_terms) + + +def test_naked_color_cap(all_terms): + """``term.color`` should return a stringlike capability.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + assert (t.color + '' == t.setaf + '') + + child(all_terms) + + +def test_formatting_functions(all_terms): + """Test simple and compound formatting wrappers.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + # test simple sugar, + if t.bold: + expected_output = u''.join((t.bold, u'hi', t.normal)) + else: + expected_output = u'hi' + assert t.bold(u'hi') == expected_output + # Plain strs for Python 2.x + if t.green: + expected_output = u''.join((t.green, 'hi', t.normal)) + else: + expected_output = u'hi' + assert t.green('hi') == expected_output + # Test unicode + if t.underline: + expected_output = u''.join((t.underline, u'boö', t.normal)) + else: + expected_output = u'boö' + assert (t.underline(u'boö') == expected_output) + + child(all_terms) + + +def test_compound_formatting(all_terms): + """Test simple and compound formatting wrappers.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + if any((t.bold, t.green)): + expected_output = u''.join((t.bold, t.green, u'boö', t.normal)) + else: + expected_output = u'boö' + assert t.bold_green(u'boö') == expected_output + + if any((t.on_bright_red, t.bold, t.bright_green, t.underline)): + expected_output = u''.join( + (t.on_bright_red, t.bold, t.bright_green, t.underline, u'meh', + t.normal)) + else: + expected_output = u'meh' + very_long_cap = t.on_bright_red_bold_bright_green_underline + assert (very_long_cap('meh') == expected_output) + + child(all_terms) + + +def test_nested_formatting(all_terms): + """Test complex nested compound formatting, wow!""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + + # Test deeply nested styles + given = t.green('-a-', t.bold('-b-', t.underline('-c-'), + '-d-'), + '-e-') + expected = u''.join(( + t.green, '-a-', t.bold, '-b-', t.underline, '-c-', t.normal, + t.green, t.bold, '-d-', + t.normal, t.green, '-e-', t.normal)) + assert given == expected + + # Test off-and-on nested styles + given = t.green('off ', t.underline('ON'), + ' off ', t.underline('ON'), + ' off') + expected = u''.join(( + t.green, 'off ', t.underline, 'ON', + t.normal, t.green, ' off ', t.underline, 'ON', + t.normal, t.green, ' off', t.normal)) + assert given == expected + + +def test_formatting_functions_without_tty(all_terms): + """Test crazy-ass formatting wrappers when there's no tty.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=six.StringIO(), force_styling=False) + assert (t.bold(u'hi') == u'hi') + assert (t.green('hi') == u'hi') + # Test non-ASCII chars, no longer really necessary: + assert (t.bold_green(u'boö') == u'boö') + assert (t.bold_underline_green_on_red('loo') == u'loo') + + # Test deeply nested styles + given = t.green('-a-', t.bold('-b-', t.underline('-c-'), + '-d-'), + '-e-') + expected = u'-a--b--c--d--e-' + assert given == expected + + # Test off-and-on nested styles + given = t.green('off ', t.underline('ON'), + ' off ', t.underline('ON'), + ' off') + expected = u'off ON off ON off' + assert given == expected + assert (t.on_bright_red_bold_bright_green_underline('meh') == u'meh') + + child(all_terms) + + +def test_nice_formatting_errors(all_terms): + """Make sure you get nice hints if you misspell a formatting wrapper.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind) + try: + t.bold_misspelled('hey') + assert not t.is_a_tty or False, 'Should have thrown exception' + except TypeError: + e = sys.exc_info()[1] + assert 'probably misspelled' in e.args[0] + try: + t.bold_misspelled(u'hey') # unicode + assert not t.is_a_tty or False, 'Should have thrown exception' + except TypeError: + e = sys.exc_info()[1] + assert 'probably misspelled' in e.args[0] + + try: + t.bold_misspelled(None) # an arbitrary non-string + assert not t.is_a_tty or False, 'Should have thrown exception' + except TypeError: + e = sys.exc_info()[1] + assert 'probably misspelled' not in e.args[0] + + if platform.python_implementation() != 'PyPy': + # PyPy fails to toss an exception, Why?! + try: + t.bold_misspelled('a', 'b') # >1 string arg + assert not t.is_a_tty or False, 'Should have thrown exception' + except TypeError: + e = sys.exc_info()[1] + assert 'probably misspelled' in e.args[0], e.args + + child(all_terms) + + +def test_null_callable_string(all_terms): + """Make sure NullCallableString tolerates all kinds of args.""" + @as_subprocess + def child(kind): + t = TestTerminal(stream=six.StringIO(), kind=kind) + assert (t.clear == '') + assert (t.move(1 == 2) == '') + assert (t.move_x(1) == '') + assert (t.bold() == '') + assert (t.bold('', 'x', 'huh?') == 'xhuh?') + assert (t.clear('x') == 'x') + + child(all_terms) + + +def test_padd(): + """Test Terminal.padd(seq).""" + @as_subprocess + def child(kind): + from blessed.sequences import Sequence + from blessed import Terminal + term = Terminal(kind) + assert Sequence('xyz\b', term).padd() == u'xy' + assert Sequence('xyz\b-', term).padd() == u'xy-' + assert Sequence('xxxx\x1b[3Dzz', term).padd() == u'xzz' + assert Sequence('\x1b[3D', term).padd() == u'' # "Trim left" + + kind = 'xterm-256color' + if platform.system() == 'Windows': + kind = 'vtwin10' + child(kind) + + +def test_split_seqs(all_terms): + """Test Terminal.split_seqs.""" + @as_subprocess + def child(kind): + from blessed import Terminal + term = Terminal(kind) + + if term.sc and term.rc: + given_text = term.sc + 'AB' + term.rc + 'CD' + expected = [term.sc, 'A', 'B', term.rc, 'C', 'D'] + result = list(term.split_seqs(given_text)) + assert result == expected + + if term.bold: + given_text = term.bold + 'bbq' + expected = [term.bold, 'b', 'b', 'q'] + result = list(term.split_seqs(given_text)) + assert result == expected + + child(all_terms) diff --git a/tests/test_wrap.py b/tests/test_wrap.py new file mode 100644 index 00000000..29537596 --- /dev/null +++ b/tests/test_wrap.py @@ -0,0 +1,132 @@ +# std imports +import os +import textwrap + +# 3rd party +import pytest + +# local +from .accessories import TestTerminal, many_columns, as_subprocess + +TEXTWRAP_KEYWORD_COMBINATIONS = [ + dict(break_long_words=False, + drop_whitespace=False, + subsequent_indent=''), + dict(break_long_words=False, + drop_whitespace=True, + subsequent_indent=''), + dict(break_long_words=False, + drop_whitespace=False, + subsequent_indent=' '), + dict(break_long_words=False, + drop_whitespace=True, + subsequent_indent=' '), + dict(break_long_words=True, + drop_whitespace=False, + subsequent_indent=''), + dict(break_long_words=True, + drop_whitespace=True, + subsequent_indent=''), + dict(break_long_words=True, + drop_whitespace=False, + subsequent_indent=' '), + dict(break_long_words=True, + drop_whitespace=True, + subsequent_indent=' '), +] +if os.environ.get('TEST_QUICK', None) is not None: + # test only one feature: everything on + TEXTWRAP_KEYWORD_COMBINATIONS = [ + dict(break_long_words=True, + drop_whitespace=True, + subsequent_indent=' ') + ] + + +def test_SequenceWrapper_invalid_width(): + """Test exception thrown from invalid width.""" + WIDTH = -3 + + @as_subprocess + def child(): + term = TestTerminal() + try: + my_wrapped = term.wrap(u'------- -------------', WIDTH) + except ValueError as err: + assert err.args[0] == ( + "invalid width %r(%s) (must be integer > 0)" % ( + WIDTH, type(WIDTH))) + else: + assert False, 'Previous stmt should have raised exception.' + del my_wrapped # assigned but never used + + child() + + +@pytest.mark.parametrize("kwargs", TEXTWRAP_KEYWORD_COMBINATIONS) +def test_SequenceWrapper(many_columns, kwargs): + """Test that text wrapping matches internal extra options.""" + @as_subprocess + def child(width, pgraph, kwargs): + # build a test paragraph, along with a very colorful version + term = TestTerminal() + attributes = ('bright_red', 'on_bright_blue', 'underline', 'reverse', + 'red_reverse', 'red_on_white', 'on_bright_white') + term.bright_red('x') + term.on_bright_blue('x') + term.underline('x') + term.reverse('x') + term.red_reverse('x') + term.red_on_white('x') + term.on_bright_white('x') + + pgraph_colored = u''.join([ + getattr(term, (attributes[idx % len(attributes)]))(char) + if char != u' ' else u' ' + for idx, char in enumerate(pgraph)]) + + internal_wrapped = textwrap.wrap(pgraph, width=width, **kwargs) + my_wrapped = term.wrap(pgraph, width=width, **kwargs) + my_wrapped_colored = term.wrap(pgraph_colored, width=width, **kwargs) + + # ensure we textwrap ascii the same as python + assert internal_wrapped == my_wrapped + + # ensure content matches for each line, when the sequences are + # stripped back off of each line + for line_no, (left, right) in enumerate( + zip(internal_wrapped, my_wrapped_colored)): + assert left == term.strip_seqs(right) + + # ensure our colored textwrap is the same paragraph length + assert (len(internal_wrapped) == len(my_wrapped_colored)) + + child(width=many_columns, kwargs=kwargs, + pgraph=u' Z! a bc defghij klmnopqrstuvw<<>>xyz012345678900 ' * 2) + child(width=many_columns, kwargs=kwargs, pgraph=u'a bb ccc') + + +def test_multiline(): + """Test that text wrapping matches internal extra options.""" + + @as_subprocess + def child(): + # build a test paragraph, along with a very colorful version + term = TestTerminal() + given_string = ('\n' + (32 * 'A') + '\n' + + (32 * 'B') + '\n' + + (32 * 'C') + '\n\n') + expected = [ + '', + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'AA', + 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + 'BB', + 'CCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + 'CC', + '', + ] + result = term.wrap(given_string, width=30) + assert expected == result + + child() diff --git a/tests/wall.ans b/tests/wall.ans new file mode 100644 index 00000000..081b4d28 --- /dev/null +++ b/tests/wall.ans @@ -0,0 +1,7 @@ +▄▓▄ + ░░ █▀▀█▀██▀████████▀█▀▀█ █▀▀█▀██▀▀█▀█xz(imp) + ▄▄▄ ▓█████ ▄▄ █▀▀█░ ▀▀▀▀▀ █████▓ ▓█████ ░▓ █▀▀█░ ▓█████ ░▓ █▀▀█░ ░▄ ░░ + ▐▄░▄▀ ▀▀▀▀▀▀ █▓ ▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀ ██ ▀▀▀▀▀ ▀▀▀▀▀▀ ██ ▀▀▀▀▀ █▄▌▌▄▄ + █▓██ ░▓████ ▐ ████░ ░████ ▄█ ████▓░ ░▓████ ██ ████░ ░▓████ ██ ████░ █▐▄▄█░ + ██▓▀ ░▓████ █ ████░ ░████ ▐ ████▓░ ░▓████ ▄█▌████░ ░▓████ ▄█▌████░ ▌█████ + ▀ ░▓████▄█▄▄███ ░ ░ ███▄▄▄▄████▓░ ░▓████▄▄▄▄███ ░ ░▓████▄▄▄▄███ ░ ▀▀▀▓ diff --git a/tox.ini b/tox.ini index 06bd4fd6..fb2b6282 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,189 @@ [tox] -envlist = py25, py26, py27, py32, py33 +envlist = about + autopep8 + docformatter + isort + pylint + flake8 + flake8_tests + pydocstyle + sphinx + py{26,27,34,35,36,37,38} +skip_missing_interpreters = true [testenv] -commands = nosetests blessings -deps = nose -# So Python 3 runs don't pick up incompatible, un-2to3'd source from the cwd: -changedir = .tox \ No newline at end of file +basepython = python3.8 +deps = pytest==5.3.2 + pytest-cov==2.8.1 + pytest-xdist==1.31.0 + mock==3.0.5 +commands = {envbindir}/py.test --cov-config={toxinidir}/tox.ini {posargs:\ + --strict --verbose \ + --junit-xml=.tox/results.{envname}.xml \ + --durations=3 \ + } \ + tests + +[pytest] +looponfailroots = blessed +norecursedirs = .git .tox build +addopts = --disable-pytest-warnings + --cov-append --cov-report=html --color=yes --ignore=setup.py --ignore=.tox + --log-format='%(levelname)s %(relativeCreated)2.2f %(filename)s:%(lineno)d %(message)s' + --cov=blessed +filterwarnings = + error +junit_family = xunit1 + +[coverage:run] +branch = True +source = blessed +parallel = True + +[coverage:report] +omit = tests/* +exclude_lines = pragma: no cover +precision = 1 + +[coverage:paths] +source = blessed/ + +[isort] +line_length = 100 +indent = ' ' +multi_line_output = 1 +length_sort = 1 +import_heading_stdlib = std imports +import_heading_thirdparty = 3rd party +import_heading_firstparty = local +import_heading_localfolder = local +sections=FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +no_lines_before=LOCALFOLDER +known_third_party = jinxed +atomic = true + +[flake8] +max-line-length = 100 +exclude = .tox,build +deps = flake8==3.7.9 + +[doc8] +max-line-length = 100 + +[testenv:py26] +setenv = TEST_QUICK=1 +basepython = python2.6 + +[testenv:py27] +basepython = python2.7 +deps = pytest==4.6.9 + pytest-cov==2.8.1 + pytest-xdist==1.31.0 + mock==3.0.5 + +[testenv:py34] +setenv = TEST_QUICK=1 +basepython = python3.4 +deps = pytest==4.6.9 + pytest-cov==2.8.1 + pytest-xdist==1.31.0 + mock==3.0.5 + +[testenv:py35] +setenv = TEST_QUICK=1 +basepython = python3.5 + +[testenv:py36] +setenv = TEST_QUICK=1 +basepython = python3.6 + +[testenv:py37] +setenv = TEST_QUICK=1 +basepython = python3.7 + +[testenv:py38] +basepython = python3.8 +deps = {[testenv]deps} + pytest-rerunfailures==8.0 +setenv = TEST_KEYBOARD = {env:TEST_KEYBOARD:yes} +commands = {envbindir}/py.test --cov-config={toxinidir}/tox.ini \ + {posargs:\ + --reruns 5 \ + --strict --verbose \ + --junit-xml=.tox/results.{envname}.xml \ + --durations=3 \ + } \ + tests + +[testenv:develop] +commands = {posargs} + +[testenv:about] +commands = python {toxinidir}/bin/display-sighandlers.py + python {toxinidir}/bin/display-terminalinfo.py + python {toxinidir}/bin/display-fpathconf.py + +[testenv:autopep8] +deps = autopep8==1.4.4 +commands = + {envbindir}/autopep8 \ + --in-place \ + --recursive \ + --aggressive \ + --aggressive \ + blessed/ bin/ setup.py + +[testenv:docformatter] +deps = + docformatter==1.3.1 + untokenize==0.1.1 +commands = + {envbindir}/docformatter \ + --in-place \ + --recursive \ + --pre-summary-newline \ + --wrap-summaries=100 \ + --wrap-descriptions=100 \ + {toxinidir}/blessed/ \ + {toxinidir}/bin \ + {toxinidir}/setup.py \ + {toxinidir}/docs/conf.py + +[testenv:isort] +deps = {[testenv]deps} + isort==4.3.21 +commands = {envbindir}/isort --quiet --apply --recursive + +[testenv:pylint] +deps = pylint==2.4.4 +commands = {envbindir}/pylint --rcfile={toxinidir}/.pylintrc \ + --ignore=tests,docs,setup.py,conf.py,build,distutils,.pyenv,.git,.tox \ + {posargs:{toxinidir}}/blessed + +[testenv:flake8] +deps = {[flake8]deps} +commands = {envbindir}/flake8 --exclude=tests,docs/sphinxext/github.py setup.py docs/ blessed/ bin/ + +[testenv:flake8_tests] +deps = {[flake8]deps} +commands = {envbindir}/flake8 --ignore=W504,F811,F401 tests/ + +[testenv:pydocstyle] +deps = pydocstyle==3.0.0 + restructuredtext_lint + doc8 +commands = {envbindir}/pydocstyle --source --explain \ + --ignore=D101,D212,D203,D204,D401 \ + {toxinidir}/blessed + {envbindir}/rst-lint README.rst + {envbindir}/doc8 --ignore-path docs/_build --ignore D000 docs + +[testenv:sphinx] +deps = -r docs/requirements.txt +commands = {envbindir}/sphinx-build \ + {posargs:-v -W -d {toxinidir}/docs/_build/doctrees -b html docs {toxinidir}/docs/_build/html} + +[testenv:codecov] +passenv = TOXENV CI TRAVIS TRAVIS_* CODECOV_* +deps = codecov>=1.4.0 +commands = codecov -e TOXENV diff --git a/version.json b/version.json new file mode 100644 index 00000000..8c3caff7 --- /dev/null +++ b/version.json @@ -0,0 +1 @@ +{"version": "1.17.0"}