From 2ab924c3b11b197b9fab0897299be442542e8557 Mon Sep 17 00:00:00 2001 From: jquast Date: Sun, 27 Apr 2014 22:16:16 -0700 Subject: [PATCH] 1.8.6: Support hpa/vpa for 'screen' by proxy also, some fixes to worm example program, and a new progress_bar that makes heavy use of the move_x sequence. --- README.rst | 16 +-- bin/on_resize.py | 28 ++++- bin/progress_bar.py | 40 +++++++ bin/worms.py | 199 +++++++++++++++++++------------- blessed/formatters.py | 81 ++++++++++++- blessed/terminal.py | 1 + blessed/tests/test_keyboard.py | 4 +- blessed/tests/test_sequences.py | 34 +++++- docs/conf.py | 2 +- setup.py | 2 +- 10 files changed, 303 insertions(+), 104 deletions(-) create mode 100755 bin/progress_bar.py diff --git a/README.rst b/README.rst index 2c878f09..f09b8acf 100644 --- a/README.rst +++ b/README.rst @@ -1,20 +1,20 @@ -.. image:: https://secure.travis-ci.org/jquast/blessed.png +.. image:: http://img.shields.io/travis/jquast/blessed.svg :target: https://travis-ci.org/jquast/blessed - :alt: travis continous integration + :alt: Travis Continous Integration -.. image:: http://coveralls.io/repos/jquast/blessed/badge.png +.. image:: http://img.shields.io/coveralls/jquast/blessed/badge.svg :target: http://coveralls.io/r/jquast/blessed - :alt: coveralls code coveraage + :alt: Coveralls Code Coveraage -.. image:: https://pypip.in/v/blessed/badge.png +.. image:: http://img.shields.io/pypi/v/blessed/badge.svg :target: https://pypi.python.org/pypi/blessed/ :alt: Latest Version -.. image:: https://pypip.in/license/blessed/badge.png +.. image:: https://pypip.in/license/blessed/badge.svg :target: https://pypi.python.org/pypi/blessed/ :alt: License -.. image:: https://pypip.in/d/blessed/badge.png +.. image:: http://img.shields.io/pypi/dm/blessed/badge.svg :target: https://pypi.python.org/pypi/blessed/ :alt: Downloads @@ -674,6 +674,8 @@ Version History that it may be overridden by custom terminal implementers. * enhancement: allow ``inkey()`` and ``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 forcibly emulating their support by a proxy. * bugfix: if ``locale.getpreferredencoding()`` returns empty string or an encoding that is not a valid codec for ``codecs.getincrementaldecoder``, fallback to ascii and emit a warning. diff --git a/bin/on_resize.py b/bin/on_resize.py index 5d37f521..7398260c 100755 --- a/bin/on_resize.py +++ b/bin/on_resize.py @@ -1,4 +1,12 @@ #!/usr/bin/env python +""" +This is an 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() +when _intr_continue is False, as it is here. +""" import signal from blessed import Terminal @@ -6,10 +14,22 @@ def on_resize(sig, action): - print('height={t.height}, width={t.width}'.format(t=term)) + # Its generally not a good idea to put blocking functions (such as print) + # within a signal handler -- if another SIGWINCH is recieved 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 or global variable. + print('height={t.height}, width={t.width}\r'.format(t=term)) signal.signal(signal.SIGWINCH, on_resize) -with term.cbreak(): - while True: - print(repr(term.inkey(_intr_continue=False))) +# note that, a terminal driver actually writes '\r\n' when '\n' is found, but +# in raw mode, we are allowed to write directly to the terminal without the +# interference of such driver -- so we must write \r\n ourselves; as python +# will append '\n' to our print statements, we simply end our statements with +# \r. +with term.raw(): + print("press 'X' to stop.\r") + inp = None + while inp != 'X': + inp = term.inkey(_intr_continue=False) + print(repr(inp) + u'\r') diff --git a/bin/progress_bar.py b/bin/progress_bar.py new file mode 100755 index 00000000..4fd4ca09 --- /dev/null +++ b/bin/progress_bar.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +""" +This is an 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_y() functions, 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 +from blessed import Terminal +import sys + + +def main(): + 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(term.height, 0) + u'[') + sys.stderr.write(term.move_x(term.width) + 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) + u'.' if offset == -1 else '=') + col += offset + sys.stderr.write(term.move_x(col) + u'|\b') + sys.stderr.flush() + inp = term.inkey(0.04) + print() + +if __name__ == '__main__': + main() diff --git a/bin/worms.py b/bin/worms.py index f1a7efbc..6e669313 100755 --- a/bin/worms.py +++ b/bin/worms.py @@ -1,11 +1,29 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python +""" +This is an example application for the 'blessed' Terminal library for python. + +It is also an experiment in functional programming. +""" + from __future__ import division, print_function from collections import namedtuple from random import randrange from functools import partial from blessed import Terminal -term = Terminal() + +# python 2/3 compatibility, provide 'echo' function as an +# alias for "print without newline and flush" +try: + echo = partial(print, end='', flush=True) + echo('begin.') +except TypeError: + # TypeError: 'flush' is an invalid keyword argument for this function + import sys + + def echo(object): + sys.stdout.write(u'{}'.format(object)) + sys.stdout.flush() # a worm is a list of (y, x) segments Locations Location = namedtuple('Point', ('y', 'x',)) @@ -20,60 +38,59 @@ # these functions return a new Location instance, given # the direction indicated by their name. -left_of = lambda s: Location( - y=s.y, x=max(0, s.x - 1)) - -right_of = lambda s: Location( - y=s.y, x=min(term.width - 1, s.x + 1)) - -below = lambda s: Location( - y=min(term.height - 1, s.y + 1), x=s.x) - -above = lambda s: Location( - y=max(0, s.y - 1), x=s.x) - -# returns a function providing the new location for the -# given `bearing' - a (y,x) difference of (src, dst). -move_given = lambda bearing: { - (0, -1): left_of, - (0, 1): right_of, - (-1, 0): above, - (1, 0): below}[(bearing.y, bearing.x)] - -# return function that defines the new bearing for any matching -# keyboard code, otherwise the function for the current bearing. -next_bearing = lambda inp_code, bearing: { +LEFT = (0, -1) +left_of = lambda segment, term: Location( + y=segment.y, + x=max(0, segment.x - 1)) + +RIGHT = (0, 1) +right_of = lambda segment, term: Location( + y=segment.y, + x=min(term.width - 1, segment.x + 1)) + +UP = (-1, 0) +above = lambda segment, term: Location( + y=max(0, segment.y - 1), + x=segment.x) + +DOWN = (1, 0) +below = lambda segment, term: Location( + y=min(term.height - 1, segment.y + 1), + x=segment.x) + +# return a direction function that defines the new bearing for any matching +# keyboard code of inp_code; otherwise, the function for the current bearing. +next_bearing = lambda term, inp_code, bearing: { term.KEY_LEFT: left_of, term.KEY_RIGHT: right_of, - term.KEY_DOWN: below, term.KEY_UP: above, -}.get(inp_code, move_given(bearing)) + 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)]) # return new bearing given the movement f(x). -change_bearing = lambda f_mov, segment: Direction( - f_mov(segment).y - segment.y, - f_mov(segment).x - segment.x) +change_bearing = lambda f_mov, segment, term: Direction( + f_mov(segment, term).y - segment.y, + f_mov(segment, term).x - segment.x) # direction-flipped check, reject traveling in opposite direction. bearing_flipped = lambda dir1, dir2: ( (0, 0) == (dir1.y + dir2.y, dir1.x + dir2.x) ) -echo = partial(print, end='', flush=True) - -# generate a new 'nibble' (number for worm bite) -new_nibble = lambda t, v: Nibble( - # create new random (x, y) location - location=Location(x=randrange(1, t.width - 1), - y=randrange(1, t.height - 1)), - # increase given value by 1 - value=v + 1) - # returns True if `loc' matches any (y, x) coordinates, # within list `segments' -- such as a list composing a worm. hit_any = lambda loc, segments: loc in segments +# same as above, but `locations' is also an array of (y, x) coordinates. +hit_vany = lambda locations, segments: any( + hit_any(loc, segments) for loc in locations) + # returns True if segments are same position (hit detection) hit = lambda src, dst: src.x == dst.x and src.y == dst.y @@ -87,95 +104,119 @@ speed * modifier if hit(head, nibble.location) else speed) +# when displaying worm head, show a different glyph for horizontal/vertical +head_glyph = lambda direction: (u':' if direction in (left_of, right_of) + else u'"') + + +# 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. +def next_nibble(term, nibble, head, worm): + l, v = nibble.location, nibble.value + while hit_vany([head] + worm, nibble_locations(l, v)): + l = Location(x=randrange(1, term.width - 1), + y=randrange(1, term.height - 1)) + v = nibble.value + 1 + return Nibble(l, v) + + +# 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. +def nibble_locations(nibble_location, nibble_value): + return [Location(x=nibble_location.x + offset, + y=nibble_location.y) + for offset in range(0, 1 + len('{}'.format(nibble_value)) - 1)] + def main(): + term = Terminal() worm = [Location(x=term.width // 2, y=term.height // 2)] worm_length = 2 - bearing = Direction(0, -1) + bearing = Direction(*LEFT) + direction = left_of nibble = Nibble(location=worm[0], value=0) color_nibble = term.black_on_green - color_worm = term.on_yellow - color_head = term.on_red + color_worm = term.yellow_reverse + color_head = term.red_reverse color_bg = term.on_blue echo(term.move(1, 1)) echo(color_bg(term.clear)) + + # speed is actually a measure of time; the shorter, the faster. speed = 0.1 - modifier = 0.95 - direction = next_bearing(None, bearing) + modifier = 0.93 + inp = None with term.hidden_cursor(), term.raw(): - inp = None 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(*worm.pop(0))) echo(color_bg(u' ')) +# print(worm_length) + # 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 - # check for nibble hit (new Nibble returned). - n_nibble = (new_nibble(term, nibble.value) - if hit(head, nibble.location) else nibble) - - # ensure new nibble is regenerated outside of worm - while hit_any(n_nibble, worm): - n_nibble = new_nibble(term, nibble, head, worm) + # 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) - # new worm_length & speed, if hit. + # get the next worm_length and speed, unless unchanged. worm_length = next_wormlength(nibble, head, worm_length) speed = next_speed(nibble, head, speed, modifier) - # display next nibble if a new one was generated, - # and erase the old one if n_nibble != nibble: - echo(term.move(*n_nibble.location)) - echo(color_nibble('{0}'.format(n_nibble.value))) - # erase '7' from nibble '17', using ' ' for empty space, - # or the worm body parts for a worm chunk - for offset in range(1, 1 + len(str(nibble.value)) - 1): - x = nibble.location.x + offset - y = nibble.location.y - echo(term.move(y, x)) - if hit_any((y, x), worm): - echo(color_worm(u'\u2689')) - else: - echo(color_bg(u' ')) - - # display new worm head each turn, regardless. - echo(term.move(*head)) - echo(color_head(u' ')) - + # erase the old one, careful to redraw the nibble contents + # with a worm color for those portions that overlay. + for (y, x) in nibble_locations(*nibble): + echo(term.move(y, x) + (color_worm if (y, x) == head + else color_bg)(u' ')) + echo(term.normal) + # and draw the new, + echo(term.move(*n_nibble.location) + ( + color_nibble('{}'.format(n_nibble.value)))) + + # display new worm head + echo(term.move(*head) + color_head(head_glyph(direction))) + + # and its old head (now, a body piece) if worm: - # and its old head (now, a body piece) - echo(term.move(*worm[-1])) + echo(term.move(*(worm[-1]))) echo(color_worm(u' ')) + echo(term.move(*head)) # wait for keyboard input, which may indicate # a new direction (up/down/left/right) inp = term.inkey(speed) # discover new direction, given keyboard input and/or bearing. - nxt_direction = next_bearing(inp.code, 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) + nxt_bearing = change_bearing(nxt_direction, head, term) # disallow new bearing/direction when flipped (running into - # oneself, fe. traveling left while traveling right) + # oneself, fe. travelling 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)]) + 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(term.height - 1, 1), term.normal))) echo(u''.join((u'\r\n', u'score: {}'.format(score), u'\r\n'))) diff --git a/blessed/formatters.py b/blessed/formatters.py index 97bc7450..b33ca215 100644 --- a/blessed/formatters.py +++ b/blessed/formatters.py @@ -67,6 +67,67 @@ def __call__(self, *args): raise +class ParameterizingProxyString(unicode): + """A Unicode string which can be called to proxy missing termcap entries. + + 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): + """P.__new__(cls, (fmt, callable), [normal, [name]]) + + :arg fmt: format string suitable for displaying terminal sequences. + :arg callable: receives __call__ arguments for formatting fmt. + :arg normal: terminating sequence for this capability. + :arg name: name of this terminal capability. + """ + assert len(args) and len(args) < 4, args + assert type(args[0]) is tuple, args[0] + assert callable(args[0][1]), args[0][1] + new = unicode.__new__(cls, args[0][0]) + new._fmt_args = args[0][1] + new._normal = len(args) > 1 and args[1] or u'' + new._name = len(args) > 2 and args[2] or u'' + return new + + def __call__(self, *args): + """P(*args) -> FormattingString() + + Return evaluated terminal capability format, (self), using callable + ``self._fmt_args`` receiving arguments ``*args``, followed by the + terminating sequence (self.normal) into a FormattingString capable + of being called. + """ + return FormattingString(self.format(*self._fmt_args(*args)), + self._normal) + + +def get_proxy_string(term, attr): + """ Returns an instance of ParameterizingProxyString + for (some kinds) of terminals and attributes. + """ + if term._kind == 'screen' and attr in ('hpa', 'vpa'): + if attr == 'hpa': + fmt = u'\x1b[{0}G' + elif attr == 'vpa': + fmt = u'\x1b[{0}d' + fmt_arg = lambda *arg: (arg[0] + 1,) + return ParameterizingProxyString((fmt, fmt_arg), + term.normal, 'hpa') + return None + + class FormattingString(unicode): """A Unicode string which can be called using ``text``, returning a new string, ``attr`` + ``text`` + ``normal``:: @@ -124,18 +185,20 @@ def __call__(self, *args): # tparm can take not only ints but also (at least) strings as its # 2nd...nth argument. But we don't support callable parameterizing # capabilities that take non-ints yet, so we can cheap out here. - # + # TODO(erikrose): Go through enough of the motions in the # capability resolvers to determine which of 2 special-purpose # classes, NullParameterizableString or NullFormattingString, # to return, and retire this one. - # + # 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()(), so - # turtles all the way down: we return another instance. + # is actually simplified result of NullCallable()() on terminals + # without color support, so turtles all the way down: we return + # another instance. return NullCallableString() return args[0] @@ -221,5 +284,15 @@ def resolve_attribute(term, attr): resolution = (resolve_attribute(term, fmt) for fmt in formatters) return FormattingString(u''.join(resolution), term.normal) else: + # 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 + # 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) return ParameterizingString(tparm_capseq, term.normal, attr) diff --git a/blessed/terminal.py b/blessed/terminal.py index b9207447..6ce23cff 100644 --- a/blessed/terminal.py +++ b/blessed/terminal.py @@ -539,6 +539,7 @@ def kbhit(self, timeout=None, _intr_continue=True): timeout -= time.time() - stime if timeout > 0: continue + # no time remains after handling exception (rare) ready_r = [] break else: diff --git a/blessed/tests/test_keyboard.py b/blessed/tests/test_keyboard.py index 428f58b1..839dac74 100644 --- a/blessed/tests/test_keyboard.py +++ b/blessed/tests/test_keyboard.py @@ -92,7 +92,7 @@ def on_resize(sig, action): read_until_semaphore(sys.__stdin__.fileno(), semaphore=SEMAPHORE) os.write(sys.__stdout__.fileno(), SEMAPHORE) with term.raw(): - term.inkey(timeout=None) + term.inkey(timeout=1) os.write(sys.__stdout__.fileno(), b'complete') assert got_sigwinch is True if cov is not None: @@ -106,8 +106,6 @@ def on_resize(sig, action): stime = time.time() time.sleep(0.05) os.kill(pid, signal.SIGWINCH) - time.sleep(1.0) - os.write(master_fd, b'X') output = read_until_eof(master_fd) pid, status = os.waitpid(pid, 0) diff --git a/blessed/tests/test_sequences.py b/blessed/tests/test_sequences.py index ad9f63f1..bd8a7a54 100644 --- a/blessed/tests/test_sequences.py +++ b/blessed/tests/test_sequences.py @@ -183,7 +183,27 @@ def child(kind): unicode_cap('rc'))) assert (t.stream.getvalue() == expected_output) - child(all_standard_terms) + # skip 'screen', hpa is proxied (see later tests) + if all_standard_terms != 'screen': + child(all_standard_terms) + + +def test_vertical_location(all_standard_terms): + """Make sure we can move the cursor horizontally without changing rows.""" + @as_subprocess + def child(kind): + t = TestTerminal(kind=kind, stream=StringIO(), force_styling=True) + with t.location(y=5): + pass + expected_output = u''.join( + (unicode_cap('sc'), + unicode_parm('vpa', 5), + unicode_cap('rc'))) + assert (t.stream.getvalue() == expected_output) + + # skip 'screen', vpa is proxied (see later tests) + if all_standard_terms != 'screen': + child(all_standard_terms) def test_inject_move_x_for_screen(): @@ -191,10 +211,12 @@ def test_inject_move_x_for_screen(): @as_subprocess def child(kind): t = TestTerminal(kind=kind, stream=StringIO(), force_styling=True) - with t.location(x=5): + COL = 5 + with t.location(x=COL): pass expected_output = u''.join( - (unicode_cap('sc'), t.hpa(5), + (unicode_cap('sc'), + u'\x1b[{0}G'.format(COL + 1), unicode_cap('rc'))) assert (t.stream.getvalue() == expected_output) @@ -206,10 +228,12 @@ def test_inject_move_y_for_screen(): @as_subprocess def child(kind): t = TestTerminal(kind=kind, stream=StringIO(), force_styling=True) - with t.location(y=5): + ROW = 5 + with t.location(y=ROW): pass expected_output = u''.join( - (unicode_cap('sc'), t.vpa(5), + (unicode_cap('sc'), + u'\x1b[{0}d'.format(ROW + 1), unicode_cap('rc'))) assert (t.stream.getvalue() == expected_output) diff --git a/docs/conf.py b/docs/conf.py index 8b7385c4..245cee5a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -53,7 +53,7 @@ # built documents. # # The short X.Y version. -version = '1.8.4' +version = '1.8.5' # The full version, including alpha/beta/rc tags. release = version diff --git a/setup.py b/setup.py index 1ed0deb7..2f95b320 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ here = os.path.dirname(__file__) setup( name='blessed', - version='1.8.4', + version='1.8.5', description="A feature-filled fork of Erik Rose's blessings project", long_description=open(os.path.join(here, 'README.rst')).read(), author='Jeff Quast',