diff options
Diffstat (limited to 'blessed/terminal.py')
-rw-r--r-- | blessed/terminal.py | 931 |
1 files changed, 578 insertions, 353 deletions
diff --git a/blessed/terminal.py b/blessed/terminal.py index a2254b1..1e17d73 100644 --- a/blessed/terminal.py +++ b/blessed/terminal.py @@ -1,42 +1,72 @@ -"""A thin, practical wrapper around terminal coloring, styling, and -positioning""" - -from contextlib import contextmanager +"This primary module provides the Terminal class." +# standard modules +import collections +import contextlib +import functools +import warnings +import platform +import codecs import curses -from curses import setupterm, tigetnum, tigetstr, tparm -from fcntl import ioctl +import locale +import select +import struct +import time +import sys +import os + +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 '{0}'. The following methods " + "of Terminal are dummy/no-op unless a deriving class overrides " + "them: {1}".format(sys.platform.lower(), ', '.join(tty_methods))) + warnings.warn(msg_nosupport) + HAS_TTY = False +else: + HAS_TTY = True 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 - + ``io.UnsupportedOperation`` in Python 2.5""" -__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.') +try: + _ = InterruptedError + del _ +except NameError: + # alias py2 exception to py3 + InterruptedError = select.error + +# local imports +from .formatters import ( + ParameterizingString, + NullCallableString, + resolve_capability, + resolve_attribute, +) + +from .sequences import ( + init_sequence_patterns, + SequenceTextWrapper, + Sequence, +) + +from .keyboard import ( + get_keyboard_sequences, + get_keyboard_codes, + resolve_sequence, +) 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. + """A wrapper for curses and related terminfo(5) terminal capabilities. Instance attributes: @@ -45,8 +75,44 @@ class Terminal(object): 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. - """ + + #: Sugary names for commonly-used capabilities + _sugar = dict( + save='sc', + restore='rc', + # 'clear' clears the whole screen. + clear_eol='el', + clear_bol='el1', + clear_eos='ed', + 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', + 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 __init__(self, kind=None, stream=None, force_styling=False): """Initialize the terminal. @@ -75,172 +141,186 @@ class Terminal(object): ``force_styling=None``. """ - if stream is None: + global _CUR_TERM + self.keyboard_fd = None + + # default stream is stdout, keyboard only valid as stdin when + # output stream is stdout and output stream is a tty + if stream is None or stream == sys.__stdout__: stream = sys.__stdout__ + self.keyboard_fd = sys.__stdin__.fileno() + try: - stream_descriptor = (stream.fileno() if hasattr(stream, 'fileno') - and callable(stream.fileno) - else None) + stream_fd = (stream.fileno() if hasattr(stream, 'fileno') + and callable(stream.fileno) else None) except IOUnsupportedOperation: - stream_descriptor = None + stream_fd = None - self._is_a_tty = (stream_descriptor is not None and - isatty(stream_descriptor)) + 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. # 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) + self._init_descriptor = (stream_fd is None and sys.__stdout__.fileno() + or stream_fd) + self._kind = kind or os.environ.get('TERM', 'unknown') + 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) + try: + if (platform.python_implementation() == 'PyPy' and + isinstance(self._kind, unicode)): + # pypy/2.4.0_2/libexec/lib_pypy/_curses.py, line 1131 + # TypeError: initializer for ctype 'char *' must be a str + curses.setupterm(self._kind.encode('ascii'), self._init_descriptor) + else: + curses.setupterm(self._kind, self._init_descriptor) + 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: + 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,)) + + for re_name, re_val in init_sequence_patterns(self).items(): + setattr(self, re_name, re_val) + + # 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) + + self._keyboard_buf = collections.deque() + if self.keyboard_fd is not None: + locale.setlocale(locale.LC_ALL, '') + self._encoding = locale.getpreferredencoding() or 'ascii' + try: + self._keyboard_decoder = codecs.getincrementaldecoder( + self._encoding)() + except LookupError as err: + warnings.warn('%s, fallback to ASCII for keyboard.' % (err,)) + self._encoding = 'ascii' + self._keyboard_decoder = codecs.getincrementaldecoder( + self._encoding)() 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!')``. + """Return a terminal capability as Unicode string. - For a parametrized capability like ``cup``, pass the parameters too: - ``some_term.cup(line, column)``. + 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 ``term.normal``. - ``man terminfo`` for a complete list of capabilities. + This capability is also callable, so you can use ``term.bold("hi")`` + which results in the joining of (term.bold, "hi", term.normal). - Return values are always Unicode. + Compound formatters may also be used, for example: + ``term.bold_blink_red_on_green("merry x-mas!")``. + For a parametrized capability such as ``cup`` (cursor_address), pass + the parameters as arguments ``some_term.cup(line, column)``. See + manual page terminfo(5) for a complete list of capabilities. """ - resolution = (self._resolve_formatter(attr) if self.does_styling - else NullCallableString()) - setattr(self, attr, resolution) # Cache capability codes. - return resolution + if not self.does_styling: + return NullCallableString() + val = resolve_attribute(self, attr) + # Cache capability codes. + setattr(self, attr, val) + return val @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. + def kind(self): + """Name of this terminal type as string.""" + return self._kind - """ + @property + def does_styling(self): + """Whether this instance will emit terminal sequences (bool).""" return self._does_styling @property def is_a_tty(self): - """Whether my ``stream`` appears to be associated with a terminal""" + """Whether the ``stream`` associated with this instance is a terminal + (bool).""" 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.) + """T.height -> int + The height of the terminal in characters. """ - return self._height_and_width()[0] + return self._height_and_width().ws_row @property def width(self): - """The width of the terminal in characters + """T.width -> int + + The width of the terminal in characters. + """ + return self._height_and_width().ws_col + + @staticmethod + def _winsize(fd): + """T._winsize -> WINSZ(ws_row, ws_col, ws_xpixel, ws_ypixel) - See ``height()`` for some corner cases. + The tty connected by file desriptor fd is queried for its window size, + and returned as a collections.namedtuple instance WINSZ. + May raise exception IOError. """ - return self._height_and_width()[1] + if HAS_TTY: + data = fcntl.ioctl(fd, termios.TIOCGWINSZ, WINSZ._BUF) + return WINSZ(*struct.unpack(WINSZ._FMT, data)) + return WINSZ(ws_row=24, ws_col=80, ws_xpixel=0, ws_ypixel=0) 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__: + # TODO(jquast): hey kids, even if stdout is redirected to a file, + # we can still query sys.__stdin__.fileno() for our terminal size. + # -- of course, if both are redirected, we have no use for this fd. + for fd in (self._init_descriptor, sys.__stdout__): try: - return struct.unpack( - 'hhhh', ioctl(descriptor, TIOCGWINSZ, '\000' * 8))[0:2] + if fd is not None: + return self._winsize(fd) 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 + 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): """Return a context manager for temporarily moving the cursor. @@ -274,20 +354,29 @@ class Terminal(object): # Restore original cursor position: self.stream.write(self.restore) - @contextmanager + @contextlib.contextmanager def fullscreen(self): """Return a context manager that enters fullscreen mode while inside it - and restores normal mode on leaving.""" + and restores normal mode on leaving. + + Fullscreen mode is characterized by instructing the terminal emulator + to store and save the current screen state (all screen output), switch + to "alternate screen". Upon exiting, the previous screen state is + returned. + + This call may not be tested; only one screen state may be saved at a + time. + """ self.stream.write(self.enter_fullscreen) try: yield finally: self.stream.write(self.exit_fullscreen) - @contextmanager + @contextlib.contextmanager def hidden_cursor(self): - """Return a context manager that hides the cursor while inside it and - makes it visible on leaving.""" + """Return a context manager that hides the cursor upon entering, + and makes it visible again upon exiting.""" self.stream.write(self.hide_cursor) try: yield @@ -296,9 +385,9 @@ class Terminal(object): @property def color(self): - """Return a capability that sets the foreground color. + """Returns capability that sets the foreground color. - The capability is unparametrized until called and passed a number + 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. @@ -306,95 +395,39 @@ class Terminal(object): :arg num: The number, 0-15, of the color """ - return ParametrizingString(self._foreground_color, self.normal) + if not self.does_styling: + return NullCallableString() + return ParameterizingString(self._foreground_color, + self.normal, 'color') @property def on_color(self): - """Return a capability that sets the background color. - - See ``color()``. + "Returns capability that sets the background color." + if not self.does_styling: + return NullCallableString() + return ParameterizingString(self._background_color, + self.normal, 'on_color') - """ - return ParametrizingString(self._background_color, self.normal) + @property + def normal(self): + "Returns sequence that resets video attribute." + if self._normal: + return self._normal + self._normal = resolve_capability(self, 'normal') + return 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:: + Common values are 0, 8, 16, 88, and 256. Most commonly + this may be used to test color capabilities at all:: 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)) + ...""" + # trim value to 0, as tigetnum('colors') returns -1 if no support, + # -2 if no such capability. + return max(0, self.does_styling and curses.tigetnum('colors') or -1) @property def _foreground_color(self): @@ -404,157 +437,349 @@ class Terminal(object): 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 ljust(self, text, width=None, fillchar=u' '): + """T.ljust(text, [width], [fillchar]) -> unicode + + Return string ``text``, left-justified by printable length ``width``. + Padding is done using the specified fill character (default is a + space). Default ``width`` is the attached terminal's width. ``text`` + may contain terminal sequences.""" + if width is None: + width = self.width + return Sequence(text, self).ljust(width, fillchar) + + def rjust(self, text, width=None, fillchar=u' '): + """T.rjust(text, [width], [fillchar]) -> unicode + + Return string ``text``, right-justified by printable length ``width``. + Padding is done using the specified fill character (default is a + space). Default ``width`` is the attached terminal's width. ``text`` + may contain terminal sequences.""" + if width is None: + width = self.width + return Sequence(text, self).rjust(width, fillchar) + + def center(self, text, width=None, fillchar=u' '): + """T.center(text, [width], [fillchar]) -> unicode + + Return string ``text``, centered by printable length ``width``. + Padding is done using the specified fill character (default is a + space). Default ``width`` is the attached terminal's width. ``text`` + may contain terminal sequences.""" + if width is None: + width = self.width + return Sequence(text, self).center(width, fillchar) + + def length(self, text): + """T.length(text) -> int + + Return the printable length of string ``text``, which may contain + terminal sequences. Strings containing sequences such as 'clear', + which repositions the cursor, does not give accurate results, and + their printable length is evaluated *0*.. + """ + return Sequence(text, self).length() + + def strip(self, text, chars=None): + """T.strip(text) -> unicode + Return string ``text`` with terminal sequences removed, and leading + and trailing whitespace removed. + """ + return Sequence(text, self).strip(chars) -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]) + def rstrip(self, text, chars=None): + """T.rstrip(text) -> unicode + Return string ``text`` with terminal sequences and trailing whitespace + removed. + """ + return Sequence(text, self).rstrip(chars) -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'])) + def lstrip(self, text, chars=None): + """T.lstrip(text) -> unicode + + Return string ``text`` with terminal sequences and leading whitespace + removed. + """ + return Sequence(text, self).lstrip(chars) + def strip_seqs(self, text): + """T.strip_seqs(text) -> unicode -class ParametrizingString(unicode): - """A Unicode string which can be called to parametrize it as a terminal - capability""" + Return string ``text`` stripped only of its sequences. + """ + return Sequence(text, self).strip_seqs() - def __new__(cls, formatting, normal=None): - """Instantiate. + def wrap(self, text, width=None, **kwargs): + """T.wrap(text, [width=None, **kwargs ..]) -> list[unicode] - :arg normal: If non-None, indicates that, once parametrized, this can - be used as a ``FormattingString``. The value is used as the - "normal" capability. + Wrap paragraphs containing escape sequences ``text`` to the full + ``width`` of Terminal instance *T*, unless ``width`` is specified. + Wrapped by the virtual printable length, irregardless of the video + attribute sequences it may contain, allowing text containing colors, + bold, underline, etc. to be wrapped. + Returns a list of strings that may contain escape sequences. See + ``textwrap.TextWrapper`` for all available additional kwargs to + customize wrapping behavior such as ``subsequent_indent``. """ - new = unicode.__new__(cls, formatting) - new._normal = normal - return new + 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(text)) + if line.strip() else (u'',)) - 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 + return lines + def getch(self): + """T.getch() -> unicode -class FormattingString(unicode): - """A Unicode string which can be called upon a piece of text to wrap it in - formatting""" + Read and decode next byte from keyboard stream. May return u'' + if decoding is not yet complete, or completed unicode character. + Should always return bytes when self.kbhit() returns True. + + Implementors of input streams other than os.read() on the stdin fd + should derive and 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 __new__(cls, formatting, normal): - new = unicode.__new__(cls, formatting) - new._normal = normal - return new + def kbhit(self, timeout=None, _intr_continue=True): + """T.kbhit([timeout=None]) -> bool - def __call__(self, text): - """Return a new string that is ``text`` formatted with my contents. + Returns True if a keypress has been detected on keyboard. - 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. + When ``timeout`` is 0, this call is non-blocking, Otherwise blocking + until keypress is detected (default). When ``timeout`` is a positive + number, returns after ``timeout`` seconds have elapsed. + If input is not a terminal, False is always returned. """ - return self + text + self._normal + # Special care is taken to handle a custom SIGWINCH handler, which + # causes select() to be interrupted with errno 4 (EAGAIN) -- + # it is ignored, and a new timeout value is derived from the previous, + # unless timeout becomes negative, because signal handler has blocked + # beyond timeout, then False is returned. Otherwise, when timeout is 0, + # we continue to block indefinitely (default). + stime = time.time() + check_w, check_x, ready_r = [], [], [None, ] + check_r = [self.keyboard_fd] if self.keyboard_fd is not None else [] + + while HAS_TTY and True: + try: + ready_r, ready_w, ready_x = select.select( + check_r, check_w, check_x, timeout) + except InterruptedError: + if not _intr_continue: + return u'' + 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 = [] + break + else: + break + return False if self.keyboard_fd is None else check_r == ready_r -class NullCallableString(unicode): - """A dummy callable Unicode to stand in for ``FormattingString`` and - ``ParametrizingString`` + @contextlib.contextmanager + def cbreak(self): + """Return a context manager that enters 'cbreak' mode: disabling line + buffering of keyboard input, making characters typed by the user + immediately available to the program. Also referred to as 'rare' + mode, this is the opposite of 'cooked' mode, the default for most + shells. - We use this when there is no tty and thus all capabilities should be blank. + In 'cbreak' mode, echo of input is also disabled: the application must + explicitly print any input received, if they so wish. - """ - 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``. + More information can be found in the manual page for curses.h, + http://www.openbsd.org/cgi-bin/man.cgi?query=cbreak + The python manual for curses, + http://docs.python.org/2/library/curses.html + + Note also that setcbreak sets VMIN = 1 and VTIME = 0, + http://www.unixwiz.net/techtips/termios-vmin-vtime.html """ - if len(args) != 1 or isinstance(args[0], int): - # I am acting as a ParametrizingString. + if HAS_TTY and self.keyboard_fd is not None: + # save current terminal mode, + save_mode = termios.tcgetattr(self.keyboard_fd) + tty.setcbreak(self.keyboard_fd, termios.TCSANOW) + try: + yield + finally: + # restore prior mode, + termios.tcsetattr(self.keyboard_fd, + termios.TCSAFLUSH, + save_mode) + else: + yield - # 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? + @contextlib.contextmanager + def raw(self): + """Return a context manager that enters *raw* mode. Raw mode is + similar to *cbreak* mode, in that characters typed are immediately + available to ``inkey()`` with one exception: the interrupt, quit, + suspend, and flow control characters are all passed through as their + raw character values instead of generating a signal. + """ + if HAS_TTY and self.keyboard_fd is not None: + # save current terminal mode, + save_mode = termios.tcgetattr(self.keyboard_fd) + tty.setraw(self.keyboard_fd, termios.TCSANOW) + try: + yield + finally: + # restore prior mode, + termios.tcsetattr(self.keyboard_fd, + termios.TCSAFLUSH, + save_mode) + else: + yield + @contextlib.contextmanager + def keypad(self): + """ + Context manager that enables keypad input (*keyboard_transmit* mode). -def split_into_formatters(compound): - """Split a possibly compound format string into segments. + This enables the effect of calling the curses function keypad(3x): + display terminfo(5) capability `keypad_xmit` (smkx) upon entering, + and terminfo(5) capability `keypad_local` (rmkx) upon exiting. - >>> split_into_formatters('bold_underline_bright_blue_on_red') - ['bold', 'underline', 'bright_blue', 'on_red'] + On an IBM-PC keypad of ttype *xterm*, with numlock off, the + lower-left diagonal key transmits sequence ``\\x1b[F``, ``KEY_END``. - """ - 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 + However, upon entering keypad mode, ``\\x1b[OF`` is transmitted, + translating to ``KEY_LL`` (lower-left key), allowing diagonal + direction keys to be determined. + """ + try: + self.stream.write(self.smkx) + yield + finally: + self.stream.write(self.rmkx) + + def inkey(self, timeout=None, esc_delay=0.35, _intr_continue=True): + """T.inkey(timeout=None, [esc_delay, [_intr_continue]]) -> Keypress() + + Receive next keystroke from keyboard (stdin), blocking until a + keypress is received or ``timeout`` elapsed, if specified. + + When used without the context manager ``cbreak``, stdin remains + line-buffered, and this function will block until return is pressed, + even though only one unicode character is returned at a time.. + + The value returned is an instance of ``Keystroke``, with properties + ``is_sequence``, and, when True, non-None values for attributes + ``code`` and ``name``. The value of ``code`` may be compared against + attributes of this terminal beginning with *KEY*, such as + ``KEY_ESCAPE``. + + To distinguish between ``KEY_ESCAPE``, and sequences beginning with + escape, the ``esc_delay`` specifies the amount of time after receiving + the escape character (chr(27)) to seek for the completion + of other application keys before returning ``KEY_ESCAPE``. + + Normally, when this function is interrupted by a signal, such as the + installment of SIGWINCH, this function will ignore this interruption + and continue to poll for input up to the ``timeout`` specified. If + you'd rather this function return ``u''`` early, specify a value + of ``False`` for ``_intr_continue``. + """ + # TODO(jquast): "meta sends escape", where alt+1 would send '\x1b1', + # what do we do with that? Surely, something useful. + # comparator to term.KEY_meta('x') ? + # TODO(jquast): Ctrl characters, KEY_CTRL_[A-Z], and the rest; + # KEY_CTRL_\, KEY_CTRL_{, etc. are not legitimate + # attributes. comparator to term.KEY_ctrl('z') ? + def _timeleft(stime, timeout): + """_timeleft(stime, timeout) -> float + + Returns time-relative time remaining before ``timeout`` + after time elapsed since ``stime``. + """ + if timeout is not None: + if timeout is 0: + return 0 + return max(0, timeout - (time.time() - stime)) + + 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(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(_timeleft(stime, timeout), _intr_continue): + ucs += self.getch() + ks = resolve(text=ucs) + + # handle escape key (KEY_ESCAPE) vs. escape sequence (which begins + # with KEY_ESCAPE, \x1b[, \x1bO, or \x1b?), up to esc_delay when + # received. This is not optimal, but causes least delay when + # (currently unhandled, and rare) "meta sends escape" is used, + # or when an unsupported sequence is sent. + if ks.code is self.KEY_ESCAPE: + esctime = time.time() + while (ks.code is self.KEY_ESCAPE and + self.kbhit(_timeleft(esctime, esc_delay))): + ucs += self.getch() + ks = resolve(text=ucs) + + # buffer any remaining text received + self._keyboard_buf.extendleft(ucs[len(ks):]) + return ks + +# 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 setupterm(), so the value of cur_term cannot be changed +# once set: subsequent calls to setupterm() have no effect. +# +# Therefore, the ``kind`` of each Terminal() is, in essence, a singleton. +# This global variable reflects that, and a warning is emitted if somebody +# expects otherwise. + +_CUR_TERM = None + +WINSZ = collections.namedtuple('WINSZ', ( + 'ws_row', # /* rows, in characters */ + 'ws_col', # /* columns, in characters */ + 'ws_xpixel', # /* horizontal size, pixels */ + 'ws_ypixel', # /* vertical size, pixels */ +)) +#: format of termios structure +WINSZ._FMT = 'hhhh' +#: buffer of termios structure appropriate for ioctl argument +WINSZ._BUF = '\x00' * struct.calcsize(WINSZ._FMT) |