curtsies-0.3.1/0000755000076500000240000000000013604023772013444 5ustar tombstaff00000000000000curtsies-0.3.1/PKG-INFO0000644000076500000240000001200013604023772014532 0ustar tombstaff00000000000000Metadata-Version: 2.1 Name: curtsies Version: 0.3.1 Summary: Curses-like terminal wrapper, with colored strings! Home-page: https://github.com/bpython/curtsies Author: Thomas Ballinger Author-email: thomasballinger@gmail.com License: MIT Description: [![Build Status](https://travis-ci.org/bpython/curtsies.svg?branch=master)](https://travis-ci.org/bpython/curtsies) [![Documentation Status](https://readthedocs.org/projects/curtsies/badge/?version=latest)](https://readthedocs.org/projects/curtsies/?badge=latest) ![Curtsies Logo](http://ballingt.com/assets/curtsiestitle.png) Curtsies is a Python 2.7 & 3.4+ compatible library for interacting with the terminal. This is what using (nearly every feature of) curtsies looks like: ```python from __future__ import unicode_literals # convenient for Python 2 import random from curtsies import FullscreenWindow, Input, FSArray from curtsies.fmtfuncs import red, bold, green, on_blue, yellow print(yellow('this prints normally, not to the alternate screen')) with FullscreenWindow() as window: with Input() as input_generator: msg = red(on_blue(bold('Press escape to exit'))) a = FSArray(window.height, window.width) a[0:1, 0:msg.width] = [msg] for c in input_generator: if c == '': break elif c == '': a = FSArray(window.height, window.width) else: s = repr(c).decode() row = random.choice(range(window.height)) column = random.choice(range(window.width-len(s))) color = random.choice([red, green, on_blue, yellow]) a[row, column:column+len(s)] = [color(s)] window.render_to_terminal(a) ``` Paste it in a `something.py` file and try it out! Installation: `pip install curtsies` [Documentation](http://curtsies.readthedocs.org/en/latest/) Primer ------ [FmtStr](http://curtsies.readthedocs.org/en/latest/FmtStr.html) objects are strings formatted with colors and styles displayable in a terminal with [ANSI escape sequences](http://en.wikipedia.org/wiki/ANSI_escape_code>`_). (the import statement shown below is outdated) ![](http://i.imgur.com/7lFaxsz.png) [FSArray](http://curtsies.readthedocs.org/en/latest/FSArray.html) objects contain multiple such strings with each formatted string on its own row, and FSArray objects can be superimposed on each other to build complex grids of colored and styled characters through composition. (the import statement shown below is outdated) ![](http://i.imgur.com/rvTRPv1.png) Such grids of characters can be rendered to the terminal in alternate screen mode (no history, like `Vim`, `top` etc.) by [FullscreenWindow](http://curtsies.readthedocs.org/en/latest/window.html#curtsies.window.FullscreenWindow) objects or normal history-preserving screen by [CursorAwareWindow](http://curtsies.readthedocs.org/en/latest/window.html#curtsies.window.CursorAwareWindow) objects. User keyboard input events like pressing the up arrow key are detected by an [Input](http://curtsies.readthedocs.org/en/latest/input.html) object. Examples -------- * [Tic-Tac-Toe](/examples/tictactoeexample.py) ![](http://i.imgur.com/AucB55B.png) * [Avoid the X's game](/examples/gameexample.py) ![](http://i.imgur.com/nv1RQd3.png) * [Bpython-curtsies uses curtsies](http://ballingt.com/2013/12/21/bpython-curtsies.html) [![](http://i.imgur.com/r7rZiBS.png)](http://www.youtube.com/watch?v=lwbpC4IJlyA) * [More examples](/examples) About ----- * [Curtsies Documentation](http://curtsies.readthedocs.org/en/latest/) * Curtsies was written to for [bpython-curtsies](http://ballingt.com/2013/12/21/bpython-curtsies.html) * `#bpython` on irc is a good place to talk about Curtsies, but feel free to open an issue if you're having a problem! * Thanks to the many contributors! * If all you need are colored strings, consider one of these [other libraries](http://curtsies.readthedocs.io/en/latest/FmtStr.html#fmtstr-rationale)! Platform: UNKNOWN Classifier: Development Status :: 3 - Alpha Classifier: Environment :: Console Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: POSIX Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Description-Content-Type: text/markdown curtsies-0.3.1/LICENSE0000644000076500000240000000207313437060273014454 0ustar tombstaff00000000000000The MIT License (MIT) Copyright (c) 2014 Thomas Ballinger Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. curtsies-0.3.1/tests/0000755000076500000240000000000013604023772014606 5ustar tombstaff00000000000000curtsies-0.3.1/tests/test_input.py0000644000076500000240000001016713264721022017356 0ustar tombstaff00000000000000import os import signal import sys import threading import time import unittest from mock import Mock try: from unittest import skip, skipUnless except ImportError: def skip(f): return lambda self: None def skipUnless(condition, reason): if condition: return lambda x: x else: return lambda x: None from curtsies import events from curtsies.input import Input class CustomEvent(events.Event): pass class CustomScheduledEvent(events.ScheduledEvent): pass @skipUnless(sys.stdin.isatty(), "stdin must be a tty") class TestInput(unittest.TestCase): def test_create(self): Input() def test_iter(self): inp = Input() inp.send = Mock() inp.send.return_value = None for i, e in zip(range(3), inp): self.assertEqual(e, None) self.assertEqual(inp.send.call_count, 3) def test_send(self): inp = Input() inp.unprocessed_bytes = [b'a'] self.assertEqual(inp.send('nonsensical value'), u'a') def test_send_nonblocking_no_event(self): inp = Input() inp.unprocessed_bytes = [] self.assertEqual(inp.send(0), None) def test_nonblocking_read(self): inp = Input() self.assertEqual(inp._nonblocking_read(), 0) def test_send_paste(self): inp = Input() inp.unprocessed_bytes = [] inp._wait_for_read_ready_or_timeout = Mock() inp._wait_for_read_ready_or_timeout.return_value = (True, None) inp._nonblocking_read = Mock() n = inp.paste_threshold + 1 first_time = [True] def side_effect(): if first_time: inp.unprocessed_bytes.extend([b'a']*n) first_time.pop() return n else: return None inp._nonblocking_read.side_effect = side_effect r = inp.send(0) self.assertEqual(type(r), events.PasteEvent) self.assertEqual(r.events, [u'a'] * n) def test_event_trigger(self): inp = Input() f = inp.event_trigger(CustomEvent) self.assertEqual(inp.send(0), None) f() self.assertEqual(type(inp.send(0)), CustomEvent) self.assertEqual(inp.send(0), None) def test_schedule_event_trigger(self): inp = Input() f = inp.scheduled_event_trigger(CustomScheduledEvent) self.assertEqual(inp.send(0), None) f(when=time.time()) self.assertEqual(type(inp.send(0)), CustomScheduledEvent) self.assertEqual(inp.send(0), None) f(when=time.time()+0.01) self.assertEqual(inp.send(0), None) time.sleep(0.01) self.assertEqual(type(inp.send(0)), CustomScheduledEvent) self.assertEqual(inp.send(0), None) def test_schedule_event_trigger_blocking(self): inp = Input() f = inp.scheduled_event_trigger(CustomScheduledEvent) f(when=time.time()+0.05) self.assertEqual(type(next(inp)), CustomScheduledEvent) def test_threadsafe_event_trigger(self): inp = Input() f = inp.threadsafe_event_trigger(CustomEvent) def check_event(): self.assertEqual(type(inp.send(1)), CustomEvent) self.assertEqual(inp.send(0), None) t = threading.Thread(target=check_event) t.start() f() t.join() def test_interrupting_sigint(self): inp = Input(sigint_event=True) def send_sigint(): os.kill(os.getpid(), signal.SIGINT) with inp: t = threading.Thread(target=send_sigint) t.start() self.assertEqual(type(inp.send(1)), events.SigIntEvent) self.assertEqual(inp.send(0), None) t.join() def test_create_in_thread_with_sigint_event(self): def create(): inp = Input(sigint_event=True) t = threading.Thread(target=create) t.start() t.join() def test_use_in_thread_with_sigint_event(self): inp = Input(sigint_event=True) def use(): with inp: pass t = threading.Thread(target=use) t.start() t.join() curtsies-0.3.1/tests/test_fmtstr.py0000644000076500000240000005263113437060273017546 0ustar tombstaff00000000000000# -*- coding: utf-8 -*- from __future__ import unicode_literals import sys import unittest from curtsies.formatstring import ( FmtStr, fmtstr, Chunk, linesplit, normalize_slice, width_aware_slice, ) from curtsies.fmtfuncs import (blue, red, green, on_blue, on_red, on_green, underline, blink, bold) from curtsies.termformatconstants import FG_COLORS from curtsies.formatstringarray import fsarray, FSArray, FormatStringTest try: from unittest import skip except ImportError: def skip(f): return lambda self: None PY2 = sys.version_info[0] == 2 try: unicode = unicode except: unicode = str def repr_without_leading_u(s): assert isinstance(s, type(u'')) if PY2: r = repr(s) assert r[0] == 'u' return r[1:] else: return repr(s) class TestFmtStrInitialization(unittest.TestCase): def test_bad(self): # Can't specify fg or bg color two ways self.assertRaises(ValueError, fmtstr, 'hello', 'blue', {'fg': 30}) self.assertRaises(ValueError, fmtstr, 'hello', 'on_blue', {'bg': 40}) # Only specific fg and bg colors are allowed self.assertRaises(ValueError, fmtstr, 'hello', {'bg': 30}) self.assertRaises(ValueError, fmtstr, 'hello', {'fg': 40}) # Only existing xforms can be used in kwargs self.assertRaises(ValueError, fmtstr, 'hello', 'make it big') def test_actual_init(self): FmtStr() class TestFmtStrParsing(unittest.TestCase): def test_no_escapes(self): self.assertEqual(str(fmtstr('abc')), 'abc') def test_simple_escapes(self): self.assertEqual(str(fmtstr('\x1b[33mhello\x1b[0m')), '\x1b[33mhello\x1b[39m') self.assertEqual(str(fmtstr('\x1b[33mhello\x1b[39m')), '\x1b[33mhello\x1b[39m') self.assertEqual(str(fmtstr('\x1b[33mhello')), '\x1b[33mhello\x1b[39m') self.assertEqual(str(fmtstr('\x1b[43mhello\x1b[49m')), '\x1b[43mhello\x1b[49m') self.assertEqual(str(fmtstr('\x1b[43mhello\x1b[0m')), '\x1b[43mhello\x1b[49m') self.assertEqual(str(fmtstr('\x1b[43mhello')), '\x1b[43mhello\x1b[49m') self.assertEqual(str(fmtstr('\x1b[32;1mhello')), '\x1b[32m\x1b[1mhello\x1b[0m\x1b[39m') self.assertEqual(str(fmtstr('\x1b[2mhello')), '\x1b[2mhello\x1b[0m') self.assertEqual(str(fmtstr('\x1b[32;2mhello')), '\x1b[32m\x1b[2mhello\x1b[0m\x1b[39m') self.assertEqual(str(fmtstr('\x1b[33m\x1b[43mhello\x1b[0m')), '\x1b[33m\x1b[43mhello\x1b[49m\x1b[39m') def test_out_of_order(self): self.assertEqual(str(fmtstr('\x1b[33m\x1b[43mhello\x1b[39m\x1b[49m')), '\x1b[33m\x1b[43mhello\x1b[49m\x1b[39m') def test_noncurtsies_output(self): fmtstr('\x1b[35mx\x1b[m') self.assertEqual(fmtstr('\x1b[Ahello'), 'hello') self.assertEqual(fmtstr('\x1b[20Ahello'), 'hello') self.assertEqual(fmtstr('\x1b[20mhello'), 'hello') class TestImmutability(unittest.TestCase): def test_fmt_strings_remain_unchanged_when_used_to_construct_other_ones(self): a = fmtstr('hi', 'blue') b = fmtstr('there', 'red') c = a + b green(c) self.assertEqual(a.shared_atts['fg'], FG_COLORS['blue']) self.assertEqual(b.shared_atts['fg'], FG_COLORS['red']) def test_immutibility_of_FmtStr(self): a = fmtstr('hi', 'blue') b = green(a) self.assertEqual(a.shared_atts['fg'], FG_COLORS['blue']) self.assertEqual(b.shared_atts['fg'], FG_COLORS['green']) class TestFmtStrSplice(unittest.TestCase): def test_simple_beginning_splice(self): self.assertEqual(fmtstr('abc').splice('d', 0), fmtstr('dabc')) self.assertEqual(fmtstr('abc').splice('d', 0), 'd'+fmtstr('abc')) def test_various_splices(self): a = blue('hi') b = a + green('bye') c = b + red('!') self.assertEqual(c.splice('asdfg', 1), blue('h')+'asdfg'+blue('i')+green('bye')+red('!')) self.assertEqual(c.splice('asdfg', 1, 4), blue('h')+'asdfg'+green('e')+red('!')) self.assertEqual(c.splice('asdfg', 1, 5), blue('h')+'asdfg'+red('!')) def test_splice_of_empty_fmtstr(self): self.assertEqual(fmtstr('ab').splice('', 1), fmtstr('ab')) def test_splice_with_multiple_chunks(self): a = fmtstr('notion') b = a.splice('te', 2, 6) c = b.splice('de', 0) self.assertEqual(a.s, "notion") self.assertEqual(b.s, "note") self.assertEqual(c.s, "denote") self.assertEqual(len(c.chunks), 3) def test_splice_fmtstr_with_end_without_atts(self): a = fmtstr('notion') b = a.splice('te', 2, 6) self.assertEqual(a.s, "notion") self.assertEqual(b.s, "note") self.assertEqual(len(b.chunks), 2) def test_splice_fmtstr_with_end_with_atts(self): # Need to test with fmtstr consisting of multiple chunks # and with attributes a = fmtstr('notion', 'blue') b = a.splice('te', 2, 6) self.assertEqual(a.s, "notion") self.assertEqual(a.chunks[0].atts, {'fg': 34}) self.assertEqual(len(a.chunks), 1) self.assertEqual(b.s, 'note') self.assertEqual(b.chunks[0].atts, {'fg': 34}) self.assertEqual(b.chunks[1].atts, {}) self.assertEqual(len(b.chunks), 2) def test_splice_fmtstr_without_end(self): a = fmtstr('notion') b = a.splice(fmtstr('ta'), 2) self.assertEqual(a.s, 'notion') self.assertEqual(b.s, 'notation') self.assertEqual(len(b.chunks), 3) def test_splice_string_without_end(self): a = fmtstr('notion') b = a.splice('ta', 2) self.assertEqual(a.s, 'notion') self.assertEqual(b.s, 'notation') self.assertEqual(len(b.chunks), 3) def test_multiple_bfs_splice(self): self.assertEqual(fmtstr('a') + blue('b'), on_blue(' '*2).splice(fmtstr('a')+blue('b'), 0, 2)) self.assertEqual(on_red('yo') + on_blue(' '), on_blue(' '*5).splice(on_red('yo'), 0, 2)) self.assertEqual(' ' + on_red('yo') + on_blue(' '), on_blue(' '*6).splice(' ' + on_red('yo'), 0, 3)) self.assertEqual(on_blue("hey") + ' ' + on_red('yo') + on_blue(' '), on_blue(' '*9).splice(on_blue("hey") + ' ' + on_red('yo'), 0, 6)) self.assertEqual(on_blue(' '*5) + on_blue("hey") + ' ' + on_red('yo') + on_blue(' '), on_blue(' '*14).splice(on_blue("hey") + ' ' + on_red('yo'), 5, 11)) class TestFmtStr(unittest.TestCase): def test_copy_with_new_atts(self): a = fmtstr('hello') b = a.copy_with_new_atts(bold=True) self.assertEqual(a.shared_atts, {}) self.assertEqual(b.shared_atts, {'bold': True}) def test_copy_with_new_str(self): # Change string but not attributes a = fmtstr('hello', 'blue') b = a.copy_with_new_str('bye') self.assertEqual(a.s, 'hello') self.assertEqual(b.s, 'bye') self.assertEqual(a.chunks[0].atts, b.chunks[0].atts) def test_append_without_atts(self): a = fmtstr('no') b = a.append('te') self.assertEqual(a.s, 'no') self.assertEqual(b.s, 'note') self.assertEqual(len(b.chunks), 2) def test_shared_atts(self): a = fmtstr('hi', 'blue') b = fmtstr('there', 'blue') c = a + b self.assertTrue('fg' in a.shared_atts) self.assertTrue('fg' in c.shared_atts) def test_new_with_atts_removed(self): a = fmtstr('hi', 'blue', 'on_green') b = fmtstr('there', 'blue', 'on_red') c = a + b self.assertEqual(c.new_with_atts_removed('fg'), on_green('hi')+on_red('there')) def setUp(self): self.s = fmtstr('hello!', 'on_blue', fg='red') def test_length(self): self.assertEqual(len(self.s), len(self.s.s)) def test_split(self): self.assertEqual(blue('hello there').split(' '), [blue('hello'), blue('there')]) s = blue('hello there') self.assertEqual(s.split(' '), [s[:5], s[6:]]) # split shouldn't create fmtstrs without chunks self.assertEqual(fmtstr('a').split('a')[0].chunks, fmtstr('').chunks) self.assertEqual(fmtstr('a').split('a')[1].chunks, fmtstr('').chunks) self.assertEqual((fmtstr('imp') + ' ').split('i'), [fmtstr(''), fmtstr('mp') + ' ']) self.assertEqual(blue('abcbd').split('b'), [blue('a'), blue('c'), blue('d')]) def test_split_with_spaces(self): self.assertEqual(blue('a\nb').split(), [blue('a'), blue('b')]) self.assertEqual(blue('a \t\n\nb').split(), [blue('a'), blue('b')]) self.assertEqual(blue('hello \t\n\nthere').split(), [blue('hello'), blue('there')]) def test_ljust_rjust(self): """""" b = fmtstr(u'ab', 'blue', 'on_red', 'bold') g = fmtstr(u'cd', 'green', 'on_red', 'bold') s = b + g self.assertEqual(s.ljust(6), b + g + on_red(' ')) self.assertEqual(s.rjust(6), on_red(' ') + b + g) # doesn't add empties to end self.assertEqual(s.ljust(4), b + g) self.assertEqual(s.rjust(4), b + g) # behavior if background different s = on_blue('a') + on_green('b') self.assertEqual(s.ljust(3), fmtstr('ab ')) self.assertEqual(s.rjust(3), fmtstr(' ab')) s = blue(on_blue('a')) + green(on_green('b')) self.assertEqual(s.ljust(3), blue('a') + green('b') + fmtstr(' ')) self.assertEqual(s.rjust(3), fmtstr(' ') + blue('a') + green('b')) #using fillchar self.assertEqual(s.ljust(3, '*'), fmtstr('ab*')) self.assertEqual(s.rjust(3, '*'), fmtstr('*ab')) #TODO: (not tested or implemented) # formatted string passed in # preserve some non-uniform styles (bold, dark, blink) but not others (underline, invert) def test_linessplit(self): text = blue('the sum of the squares of the sideways') result = [blue('the')+blue(' ')+blue('sum'), blue('of')+blue(' ')+blue('the'), blue('squares'), blue('of')+blue(' ')+blue('the'), blue('sideway'), blue('s') ] self.assertEqual(linesplit(text, 7), result) def test_mul(self): self.assertEqual(fmtstr('heyhey'), fmtstr('hey')*2) pass #TODO raise common attributes when doing equality or when # doing multiplication, addition etc. to make these pass #self.assertEqual(blue('hellohellohello'), blue('hello')*3) #self.assertEqual( # bold(blue('hey')+green('there')+blue('hey')+green('there')), # bold(blue('hey')+green('there'))*2) def test_change_color(self): a = blue(red('hello')) self.assertEqual(a, blue('hello')) def test_repr(self): self.assertEqual(fmtstr('hello', 'red', bold=False), red('hello')) self.assertEqual(fmtstr('hello', 'red', bold=True), bold(red('hello'))) class TestDoubleUnders(unittest.TestCase): def test_equality(self): x = fmtstr("adfs") self.assertEqual(x, x) self.assertTrue(fmtstr("adfs"), fmtstr("adfs")) self.assertTrue(fmtstr("adfs", 'blue'), fmtstr("adfs", fg='blue')) class TestConvenience(unittest.TestCase): def test_fg(self): red('asdf') blue('asdf') self.assertTrue(True) def test_bg(self): on_red('asdf') on_blue('asdf') self.assertTrue(True) def test_styles(self): underline('asdf') blink('asdf') self.assertTrue(True) class TestSlicing(unittest.TestCase): def test_index(self): self.assertEqual(fmtstr('Hi!', 'blue')[0], fmtstr('H', 'blue')) self.assertRaises(IndexError, fmtstr('Hi!', 'blue').__getitem__, 5) def test_slice(self): self.assertEqual(fmtstr('Hi!', 'blue')[1:2], fmtstr('i', 'blue')) self.assertEqual(fmtstr('Hi!', 'blue')[1:], fmtstr('i!', 'blue')) s = fmtstr('imp') + ' ' self.assertEqual(s[1:], fmtstr('mp')+' ') self.assertEqual(blue('a\nb')[0:1], blue('a')) # considering changing behavior so that this doens't work # self.assertEqual(fmtstr('Hi!', 'blue')[15:18], fmtstr('', 'blue')) class TestComposition(unittest.TestCase): def test_simple_composition(self): a = fmtstr('hello ', 'underline', 'on_blue') b = fmtstr('there', 'red', 'on_blue') c = a + b fmtstr(c, bg='red') self.assertTrue(True) class TestUnicode(unittest.TestCase): def test_output_type(self): self.assertEqual(type(str(fmtstr('hello', 'blue'))), str) self.assertEqual(type(unicode(fmtstr('hello', 'blue'))), unicode) def test_normal_chars(self): fmtstr('a', 'blue') str(fmtstr('a', 'blue')) unicode(fmtstr('a', 'blue')) self.assertTrue(True) def test_funny_chars(self): fmtstr('⁇', 'blue') str(Chunk('⁇', {'fg': 'blue'})) str(fmtstr('⁇', 'blue')) unicode(fmtstr('⁇', 'blue')) self.assertTrue(True) def test_right_sequence_in_py3(self): red_on_blue = fmtstr('hello', 'red', 'on_blue') blue_on_red = fmtstr('there', fg='blue', bg='red') green_s = fmtstr('!', 'green') full = red_on_blue + ' ' + blue_on_red + green_s self.assertEqual(full, on_blue(red("hello"))+" "+on_red(blue("there"))+green("!")) self.assertEqual(str(full), '\x1b[31m\x1b[44mhello\x1b[49m\x1b[39m \x1b[34m\x1b[41mthere\x1b[49m\x1b[39m\x1b[32m!\x1b[39m') def test_len_of_unicode(self): self.assertEqual(len(fmtstr('┌─')), 2) lines = ['┌─', 'an', '┌─'] r = fsarray(lines) self.assertEqual(r.shape, (3, 2)) self.assertEqual(len(fmtstr(fmtstr('┌─'))), len(fmtstr('┌─'))) self.assertEqual(fmtstr(fmtstr('┌─')), fmtstr('┌─')) #TODO should we make this one work? # always coerce everything to unicode? #self.assertEqual(len(fmtstr('┌─')), 2) def test_len_of_unicode_in_fsarray(self): fsa = FSArray(3, 2) fsa.rows[0] = fsa.rows[0].setslice_with_length(0, 2, '┌─', 2) self.assertEqual(fsa.shape, (3, 2)) fsa.rows[0] = fsa.rows[0].setslice_with_length(0, 2, fmtstr('┌─', 'blue'), 2) self.assertEqual(fsa.shape, (3, 2)) def test_add_unicode_to_byte(self): fmtstr('┌') + fmtstr('a') fmtstr('a') + fmtstr('┌') '┌' + fmtstr('┌') '┌' + fmtstr('a') fmtstr('┌') + '┌' fmtstr('a') + '┌' def test_unicode_slicing(self): self.assertEqual(fmtstr('┌adfs', 'blue')[:2], fmtstr('┌a', 'blue')) self.assertEqual(type(fmtstr('┌adfs', 'blue')[:2].s), type(fmtstr('┌a', 'blue').s)) self.assertEqual(len(fmtstr('┌adfs', 'blue')[:2]), 2) def test_unicode_repr(self): repr(Chunk('–')) self.assertEqual(repr(fmtstr('–')), repr_without_leading_u('–')) class TestCharacterWidth(unittest.TestCase): def test_doublewide_width(self): self.assertEqual(len(fmtstr('E', 'blue')), 1) self.assertEqual(fmtstr('E', 'blue').width, 2) self.assertEqual(len(fmtstr('hi')), 2) self.assertEqual(fmtstr('hi').width, 4) def test_multi_width(self): self.assertEqual(len(fmtstr('a\u0300')), 2) self.assertEqual(fmtstr('a\u0300').width, 1) def test_width_aware_slice(self): self.assertEqual(fmtstr('E').width_aware_slice(slice(None, 1, None)).s, ' ') self.assertEqual(fmtstr('E').width_aware_slice(slice(None, 2, None)).s, 'E') self.assertEqual(fmtstr('HE!', 'blue').width_aware_slice(slice(1, 2, None)), fmtstr(' ', 'blue')) self.assertEqual(fmtstr('HE!', 'blue').width_aware_slice(slice(1, 3, None)), fmtstr('E', 'blue')) def test_width_aware_splitlines(self): s = fmtstr('abcd') self.assertEqual(list(s.width_aware_splitlines(2)), [fmtstr('ab'), fmtstr('cd')]) s = fmtstr('HE!') self.assertEqual(list(s.width_aware_splitlines(2)), [fmtstr('H '), fmtstr('E'), fmtstr('!')]) s = fmtstr('He\u0300llo') self.assertEqual(list(s.width_aware_splitlines(2)), [fmtstr('He\u0300'), fmtstr('ll'), fmtstr('o')]) with self.assertRaises(ValueError): s.width_aware_splitlines(1) def test_width_at_offset(self): self.assertEqual(fmtstr('abEcdef').width_at_offset(0), 0) self.assertEqual(fmtstr('abEcdef').width_at_offset(2), 2) self.assertEqual(fmtstr('abEcdef').width_at_offset(3), 4) self.assertEqual(fmtstr('a\u0300b\u3000c').width_at_offset(0), 0) self.assertEqual(fmtstr('a\u0300b\u3000c').width_at_offset(1), 1) self.assertEqual(fmtstr('a\u0300b\u3000c').width_at_offset(2), 1) self.assertEqual(fmtstr('a\u0300b\u3000c').width_at_offset(3), 2) self.assertEqual(len(fmtstr('a\u0300')), 2) self.assertEqual(fmtstr('a\u0300').width, 1) class TestWidthHelpers(unittest.TestCase): def test_combining_char_aware_slice(self): self.assertEqual(width_aware_slice('abc', 0, 2), 'ab') self.assertEqual(width_aware_slice('abc', 1, 3), 'bc') self.assertEqual(width_aware_slice('abc', 0, 3), 'abc') self.assertEqual(width_aware_slice('ab\u0300c', 0, 3), 'ab\u0300c') self.assertEqual(width_aware_slice('ab\u0300c', 0, 2), 'ab\u0300') self.assertEqual(width_aware_slice('ab\u0300c', 1, 3), 'b\u0300c') self.assertEqual(width_aware_slice('ab\u0300\u0300c', 1, 3), 'b\u0300\u0300c') self.assertEqual(width_aware_slice('ab\u0300\u0300c', 0, 2), 'ab\u0300\u0300') self.assertEqual(width_aware_slice('ab\u0300\u0300c', 2, 3), 'c') def test_char_width_aware_slice(self): self.assertEqual(width_aware_slice('abc', 1, 2), 'b') self.assertEqual(width_aware_slice('aEbc', 0, 4), 'aEb') self.assertEqual(width_aware_slice('aEbc', 1, 4), 'Eb') self.assertEqual(width_aware_slice('aEbc', 2, 4), ' b') self.assertEqual(width_aware_slice('aEbc', 0, 2), 'a ') class TestChunk(unittest.TestCase): def test_repr(self): c = Chunk('a', {'fg': 32}) if PY2: self.assertEqual(repr(c), """Chunk(u'a', {'fg': 32})""") else: self.assertEqual(repr(c), """Chunk('a', {'fg': 32})""") class TestChunkSplitter(unittest.TestCase): def test_chunk_splitter(self): splitter = Chunk('asdf', {'fg': 32}).splitter() self.assertEqual(splitter.request(1), (1, Chunk('a', {'fg': 32}))) self.assertEqual(splitter.request(4), (3, Chunk('sdf', {'fg': 32}))) self.assertEqual(splitter.request(4), None) def test_reusing_same_splitter(self): c = Chunk('asdf', {'fg': 32}) s1 = c.splitter() self.assertEqual(s1.request(3), (3, Chunk('asd', {'fg': 32}))) s1.reinit(c) self.assertEqual(s1.request(3), (3, Chunk('asd', {'fg': 32}))) s1.reinit(c) self.assertEqual(s1.request(3), (3, Chunk('asd', {'fg': 32}))) c2 = Chunk('abcdef', {}) s1.reinit(c2) self.assertEqual(s1.request(3), (3, Chunk('abc'))) def test_width_awareness(self): s = Chunk('asdf') self.assertEqual(Chunk('ab\u0300c').splitter().request(3), (3, Chunk('ab\u0300c'))) self.assertEqual(Chunk('ab\u0300c').splitter().request(2), (2, Chunk('ab\u0300'))) s = Chunk('ab\u0300c').splitter() self.assertEqual(s.request(1), (1, Chunk('a'))) self.assertEqual(s.request(2), (2, Chunk('b\u0300c'))) c = Chunk('aEbc') self.assertEqual(c.splitter().request(4), (4, Chunk('aEb'))) s = c.splitter() self.assertEqual(s.request(2), (2, Chunk('a '))) self.assertEqual(s.request(2), (2, Chunk('E'))) self.assertEqual(s.request(2), (2, Chunk('bc'))) self.assertEqual(s.request(2), None) class TestFSArray(unittest.TestCase): def test_no_hanging_space(self): a = FSArray(4, 2) self.assertEqual(len(a.rows[0]), 0) def test_assignment_working(self): t = FSArray(10, 10) t[2, 2] = 'a' t[2, 2] == 'a' def test_normalize_slice(self): class SliceBuilder(object): def __getitem__(self, slice): return slice Slice = SliceBuilder() self.assertEqual(normalize_slice(10, Slice[:3]), slice(0, 3, None)) self.assertEqual(normalize_slice(11, Slice[3:]), slice(3, 11, None)) @skip('TODO') def test_oomerror(self): a = FSArray(10, 40) a[2:-2, 2:-2] = fsarray(['asdf', 'zxcv']) class TestFSArrayWithDiff(FormatStringTest): def test_diff_testing(self): a = fsarray(['abc', 'def']) b = fsarray(['abc', 'dqf']) self.assertRaises(AssertionError, self.assertFSArraysEqual, a, b) a = fsarray([blue('abc'), red('def')]) b = fsarray([blue('abc'), red('dqf')]) self.assertRaises(AssertionError, self.assertFSArraysEqual, a, b) a = fsarray([blue('abc'), red('def')]) b = fsarray([blue('abc'), red('d')+blue('e')+red('f')]) self.assertRaises(AssertionError, self.assertFSArraysEqual, a, b) a = fsarray(['abc', 'def']) b = fsarray(['abc', 'def']) self.assertFSArraysEqual(a, b) a = fsarray([blue('abc'), red('def')]) b = fsarray([blue('abc'), red('def')]) self.assertFSArraysEqual(a, b) if __name__ == '__main__': import fmtstr.fmtstr unittest.main() curtsies-0.3.1/tests/test_terminal.py0000644000076500000240000001603013264721022020025 0ustar tombstaff00000000000000from __future__ import unicode_literals import functools import locale import os import sys import unittest if sys.version_info[0] == 3: from io import StringIO else: from StringIO import StringIO import blessings import pyte from pyte import control as ctrl, Stream, Screen from curtsies.window import BaseWindow, FullscreenWindow, CursorAwareWindow # a few tests fail on TravisCI that have something to do with # stdin not being able to be set to nonblocking # (and still reporting isatty as True) IS_TRAVIS = bool(os.environ.get("TRAVIS")) try: from unittest import skipUnless, skipIf, skipFailure except ImportError: def skipUnless(condition, reason): if condition: return lambda x: x else: return lambda x: None def skipIf(condition, reason): if condition: return lambda x: None else: return lambda x: x import nose def skipFailure(reason): def dec(func): @functools.wraps(func) def inner(*args, **kwargs): try: func(*args, **kwargs) except Exception: raise nose.SkipTest else: raise AssertionError('Failure expected') return inner return dec class FakeStdin(StringIO): encoding = 'ascii' # thanks superbobry for this code: https://github.com/selectel/pyte/issues/13 class ReportingStream(Stream): report_escape = { "6": "report_cursor_position" } def _arguments(self, char): if char == "n": # DSR command looks like 'CSIn'. So all we need to do # is wait for the 'n' argument. return self.dispatch(self.report_escape[self.current]) else: return super(ReportingStream, self)._arguments(char) class ReportingScreen(Screen): def __init__(self, *args, **kwargs): self._report_file = FakeStdin() super(ReportingScreen, self).__init__(*args, **kwargs) def report_cursor_position(self): # cursor position is 1-indexed in the ANSI escape sequence API s = ctrl.CSI + "%d;%sR" % (self.cursor.y + 1, self.cursor.x + 1) self._report_file.seek(0) self._report_file.write(s) self._report_file.seek(0) class ReportingScreenWithExtra(ReportingScreen): def report_cursor_position(self): # cursor position is 1-indexed in the ANSI escape sequence API extra = 'qwerty\nasdf\nzxcv' s = ctrl.CSI + "%d;%sR" % (self.cursor.y + 1, self.cursor.x + 1) self._report_file.seek(0) self._report_file.write(extra + s) self._report_file.seek(0) class Bugger(object): __before__ = __after__ = lambda *args: None def __getattr__(self, event): to = sys.stdout def inner(*args, **flags): to.write(event.upper() + " ") to.write("; ".join(map(repr, args))) to.write(" ") to.write(", ".join("{0}: {1}".format(name, repr(arg)) for name, arg in flags.items())) to.write(os.linesep) return inner class ScreenStdout(object): def __init__(self, stream): self.stream = stream def write(self, s): if sys.version_info[0] == 3: self.stream.feed(s) else: self.stream.feed(s.decode(locale.getpreferredencoding())) def flush(self): pass @skipUnless(sys.stdin.isatty(), 'blessings Terminal needs streams open') class TestFullscreenWindow(unittest.TestCase): def setUp(self): self.screen = pyte.Screen(10, 3) self.stream = pyte.Stream() self.stream.attach(self.screen) stdout = ScreenStdout(self.stream) self.window = FullscreenWindow(stdout) def test_render(self): with self.window: self.window.render_to_terminal([u'hi', u'there']) self.assertEqual(self.screen.display, [u'hi ', u'there ', u' ']) def test_scroll(self): with self.window: self.window.render_to_terminal([u'hi', u'there']) self.window.scroll_down() self.assertEqual(self.screen.display, [u'there ', u' ', u' ']) class NopContext(object): def __enter__(*args): pass def __exit__(*args): pass @skipIf(IS_TRAVIS, 'Travis stdin behaves strangely, see issue 89') @skipUnless(sys.stdin.isatty(), 'blessings Terminal needs streams open') class TestCursorAwareWindow(unittest.TestCase): def setUp(self): self.screen = ReportingScreen(6, 3) self.stream = ReportingStream() self.stream.attach(self.screen) self.stream.attach(Bugger()) stdout = ScreenStdout(self.stream) self.window = CursorAwareWindow(out_stream=stdout, in_stream=self.screen._report_file) self.window.cbreak = NopContext() blessings.Terminal.height = 3 blessings.Terminal.width = 6 @skipFailure("This isn't passing locally for me anymore :/") def test_render(self): with self.window: self.assertEqual(self.window.top_usable_row, 0) self.window.render_to_terminal([u'hi', u'there']) self.assertEqual(self.screen.display, [u'hi ', u'there ', u' ']) @skipFailure("This isn't passing locally for me anymore :/") def test_cursor_position(self): with self.window: self.window.render_to_terminal([u'hi', u'there'], cursor_pos=(2, 4)) self.assertEqual(self.window.get_cursor_position(), (2, 4)) @skipFailure("This isn't passing locally for me anymore :/") def test_inital_cursor_position(self): self.screen.cursor.y += 1 with self.window: self.assertEqual(self.window.top_usable_row, 1) self.window.render_to_terminal([u'hi', u'there']) self.assertEqual(self.screen.display, [u' ', u'hi ', u'there ']) @skipIf(IS_TRAVIS, 'Travis stdin behaves strangely, see issue 89') @skipUnless(sys.stdin.isatty(), 'blessings Terminal needs streams open') class TestCursorAwareWindowWithExtraInput(unittest.TestCase): def setUp(self): self.screen = ReportingScreenWithExtra(6, 3) self.stream = ReportingStream() self.stream.attach(self.screen) self.stream.attach(Bugger()) stdout = ScreenStdout(self.stream) self.extra_bytes = [] self.window = CursorAwareWindow(out_stream=stdout, in_stream=self.screen._report_file, extra_bytes_callback=self.extra_bytes_callback) self.window.cbreak = NopContext() blessings.Terminal.height = 3 blessings.Terminal.width = 6 def extra_bytes_callback(self, bytes): self.extra_bytes.append(bytes) @skipFailure("This isn't passing locally for me anymore :/") def test_report_extra_bytes(self): with self.window: pass # should have triggered getting initial cursor position self.assertEqual(b''.join(self.extra_bytes), b'qwerty\nasdf\nzxcv') curtsies-0.3.1/tests/test_window.py0000644000076500000240000000400013002223251017502 0ustar tombstaff00000000000000# -*- coding: utf-8 -*- from __future__ import unicode_literals import unittest import sys from curtsies.window import BaseWindow, FullscreenWindow, CursorAwareWindow if sys.version_info[0] == 3: from io import StringIO else: from cStringIO import StringIO try: from unittest import skipIf except ImportError: def skipIf(condition, reason): if condition: return lambda x: x else: return lambda x: None fds_closed = not sys.stdin.isatty() or not sys.stdout.isatty() class FakeFullscreenWindow(FullscreenWindow): width = property(lambda self: 10) height = property(lambda self: 4) @skipIf(fds_closed, "blessings Terminal needs streams open") class TestBaseWindow(unittest.TestCase): """Pretty pathetic tests for window""" def test_window(self): fakestdout = StringIO() window = BaseWindow(fakestdout) window.write('hi') fakestdout.seek(0) self.assertEqual(fakestdout.read(), 'hi') def test_array_from_text(self): window = BaseWindow() a = window.array_from_text('.\n.\n.') self.assertEqual(a.height, 3) self.assertEqual(a[0], '.') self.assertEqual(a[1], '.') def test_array_from_text_rc(self): a = BaseWindow.array_from_text_rc('asdfe\nzx\n\n123', 3, 4) self.assertEqual(a.height, 3) self.assertEqual(a.width, 4) self.assertEqual(a[0], 'asdf') self.assertEqual(a[1], 'e') self.assertEqual(a[2], 'zx') def test_fullscreen_window(self): fakestdout = StringIO() window = FullscreenWindow(fakestdout) window.write('hi') fakestdout.seek(0) self.assertEqual(fakestdout.read(), 'hi') def test_fullscreen_render_to_terminal(self): fakestdout = StringIO() window = FakeFullscreenWindow(fakestdout) window.render_to_terminal(['hello', 'hello', 'hello']) fakestdout.seek(0) output = fakestdout.read() self.assertEqual(output.count('hello'), 3) curtsies-0.3.1/tests/test_configfile_keynames.py0000644000076500000240000000164212664351345022230 0ustar tombstaff00000000000000# -*- coding: UTF8 -*- import unittest from functools import partial from curtsies.configfile_keynames import keymap from curtsies.events import CURTSIES_NAMES class TestKeymap(unittest.TestCase): def config(self, mapping, curtsies): curtsies_names = keymap[mapping] self.assertTrue(curtsies in CURTSIES_NAMES.values(), "%r is not a curtsies name" % curtsies) self.assertTrue(curtsies in curtsies_names, "config name %r does not contain %r, just %r" % (mapping, curtsies, curtsies_names)) def test_simple(self): self.config('M-m', u'') self.config('M-m', u'') self.config('C-m', u'') self.config('C-[', u'') self.config('C-\\',u'') self.config('C-]', u'') self.config('C-^', u'') self.config('C-_', u'') #??? for bpython compatibility self.config('F1', u'') curtsies-0.3.1/tests/test_events.py0000644000076500000240000001070513002223251017510 0ustar tombstaff00000000000000# -*- coding: UTF8 -*- import unittest from functools import partial from curtsies import events class TestChrByte(unittest.TestCase): def test_simple(self): """0-255 can be turned into bytes""" for i in range(256): s = eval('b"\\x%s"' % ('0' + hex(i)[2:])[-2:]) self.assertEqual(s, events.chr_byte(i)) def test_helpers(self): self.assertEqual(events.chr_byte(97), b'a') self.assertEqual(events.chr_uni(97), u'a') class TestCurtsiesNames(unittest.TestCase): def spot_check(self): self.assertEqual(events.CURTSIES_NAMES[b'\x1b\x08'], u'') self.assertEqual(events.CURTSIES_NAMES[b'\x00'], u'') self.assertEqual(events.CURTSIES_NAMES[b'\xea'], u'') def test_all_values_unicode(self): for seq, e in events.CURTSIES_NAMES.items(): self.assertEqual(type(seq), bytes) def test_all_keys_bytes(self): for seq, e in events.CURTSIES_NAMES.items(): self.assertEqual(type(e), type(u'')) class TestDecodable(unittest.TestCase): def test_simple(self): self.assertTrue(events.decodable(b'd', 'utf-8')) self.assertFalse(events.decodable(b'\xfe', 'utf-8')) # 254 is off limits class TestGetKey(unittest.TestCase): def test_utf8_full(self): get_utf_full = partial(events.get_key, encoding='utf-8', keynames='curtsies', full=True) self.assertEqual(get_utf_full([b'h']), u'h') self.assertEqual(get_utf_full([b'\x1b', b'[']), u'') self.assertRaises(UnicodeDecodeError, get_utf_full, [b'\xfe\xfe']) self.assertRaises(ValueError, get_utf_full, u'a') def test_utf8(self): get_utf = partial(events.get_key, encoding='utf-8', keynames='curtsies', full=False) self.assertEqual(get_utf([b'h']), u'h') self.assertEqual(get_utf([b'\x1b', b'[']), None) self.assertEqual(get_utf([b'\xe2']), None) self.assertRaises(ValueError, get_utf, u'a') def test_multibyte_utf8(self): get_utf = partial(events.get_key, encoding='utf-8', keynames='curtsies', full=False) self.assertEqual(get_utf([b'\xc3']), None) self.assertEqual(get_utf([b'\xe2']), None) self.assertEqual(get_utf([b'\xe2'], full=True), u'') self.assertEqual(get_utf([b'\xc3', b'\x9f']), u'ß') self.assertEqual(get_utf([b'\xe2']), None) self.assertEqual(get_utf([b'\xe2', b'\x88']), None) self.assertEqual(get_utf([b'\xe2', b'\x88', b'\x82']), u'∂') def test_sequences_without_names(self): get_utf = partial(events.get_key, encoding='utf-8', keynames='curtsies', full=False) self.assertEqual(get_utf([b'\xc3'], full=True), '') self.assertEqual(get_utf([b'\xc3'], full=True, keynames='curses'), 'xC3') def test_key_names(self): "Every key sequence with a Curses name should have a Curtsies name too." self.assertTrue(set(events.CURTSIES_NAMES).issuperset(set(events.CURSES_NAMES)), set(events.CURSES_NAMES) - set(events.CURTSIES_NAMES)) class TestGetKeyAscii(unittest.TestCase): def test_full(self): get_ascii_full = partial(events.get_key, encoding='ascii', keynames='curtsies', full=True) self.assertEqual(get_ascii_full([b'a']), u'a') self.assertEqual(get_ascii_full([b'\xe1']), u'') self.assertEqual(get_ascii_full([b'\xe1'], keynames='curses'), u'xE1') def test_simple(self): get_ascii_full = partial(events.get_key, encoding='ascii', keynames='curtsies') self.assertEqual(get_ascii_full([b'a']), u'a') self.assertEqual(get_ascii_full([b'\xe1']), u'') self.assertEqual(get_ascii_full([b'\xe1'], keynames='curses'), u'xE1') class TestUnknownEncoding(unittest.TestCase): def test_simple(self): get_utf16 = partial(events.get_key, encoding='utf16', keynames='curtsies') self.assertEqual(get_utf16([b'a']), None) self.assertEqual(get_utf16([b'a'], full=True), None) self.assertEqual(get_utf16([b'\xe1']), None) self.assertEqual(get_utf16([b'\xe1'], full=True), u'') class TestSpecialKeys(unittest.TestCase): def test_simple(self): seq = [b'\x1b', b'[', b'1', b';', b'9', b'C'] self.assertEqual([events.get_key(seq[:i], encoding='utf8') for i in range(1, len(seq)+1)], [None, None, None, None, None, u'']) class TestPPEvent(unittest.TestCase): def test(self): self.assertEqual(events.pp_event(u'a'), 'a') curtsies-0.3.1/MANIFEST.in0000644000076500000240000000011313437060273015176 0ustar tombstaff00000000000000include LICENSE include tests/*.py include examples/*.py include readme.md curtsies-0.3.1/readme.md0000644000076500000240000000727313437060273015235 0ustar tombstaff00000000000000[![Build Status](https://travis-ci.org/bpython/curtsies.svg?branch=master)](https://travis-ci.org/bpython/curtsies) [![Documentation Status](https://readthedocs.org/projects/curtsies/badge/?version=latest)](https://readthedocs.org/projects/curtsies/?badge=latest) ![Curtsies Logo](http://ballingt.com/assets/curtsiestitle.png) Curtsies is a Python 2.7 & 3.4+ compatible library for interacting with the terminal. This is what using (nearly every feature of) curtsies looks like: ```python from __future__ import unicode_literals # convenient for Python 2 import random from curtsies import FullscreenWindow, Input, FSArray from curtsies.fmtfuncs import red, bold, green, on_blue, yellow print(yellow('this prints normally, not to the alternate screen')) with FullscreenWindow() as window: with Input() as input_generator: msg = red(on_blue(bold('Press escape to exit'))) a = FSArray(window.height, window.width) a[0:1, 0:msg.width] = [msg] for c in input_generator: if c == '': break elif c == '': a = FSArray(window.height, window.width) else: s = repr(c).decode() row = random.choice(range(window.height)) column = random.choice(range(window.width-len(s))) color = random.choice([red, green, on_blue, yellow]) a[row, column:column+len(s)] = [color(s)] window.render_to_terminal(a) ``` Paste it in a `something.py` file and try it out! Installation: `pip install curtsies` [Documentation](http://curtsies.readthedocs.org/en/latest/) Primer ------ [FmtStr](http://curtsies.readthedocs.org/en/latest/FmtStr.html) objects are strings formatted with colors and styles displayable in a terminal with [ANSI escape sequences](http://en.wikipedia.org/wiki/ANSI_escape_code>`_). (the import statement shown below is outdated) ![](http://i.imgur.com/7lFaxsz.png) [FSArray](http://curtsies.readthedocs.org/en/latest/FSArray.html) objects contain multiple such strings with each formatted string on its own row, and FSArray objects can be superimposed on each other to build complex grids of colored and styled characters through composition. (the import statement shown below is outdated) ![](http://i.imgur.com/rvTRPv1.png) Such grids of characters can be rendered to the terminal in alternate screen mode (no history, like `Vim`, `top` etc.) by [FullscreenWindow](http://curtsies.readthedocs.org/en/latest/window.html#curtsies.window.FullscreenWindow) objects or normal history-preserving screen by [CursorAwareWindow](http://curtsies.readthedocs.org/en/latest/window.html#curtsies.window.CursorAwareWindow) objects. User keyboard input events like pressing the up arrow key are detected by an [Input](http://curtsies.readthedocs.org/en/latest/input.html) object. Examples -------- * [Tic-Tac-Toe](/examples/tictactoeexample.py) ![](http://i.imgur.com/AucB55B.png) * [Avoid the X's game](/examples/gameexample.py) ![](http://i.imgur.com/nv1RQd3.png) * [Bpython-curtsies uses curtsies](http://ballingt.com/2013/12/21/bpython-curtsies.html) [![](http://i.imgur.com/r7rZiBS.png)](http://www.youtube.com/watch?v=lwbpC4IJlyA) * [More examples](/examples) About ----- * [Curtsies Documentation](http://curtsies.readthedocs.org/en/latest/) * Curtsies was written to for [bpython-curtsies](http://ballingt.com/2013/12/21/bpython-curtsies.html) * `#bpython` on irc is a good place to talk about Curtsies, but feel free to open an issue if you're having a problem! * Thanks to the many contributors! * If all you need are colored strings, consider one of these [other libraries](http://curtsies.readthedocs.io/en/latest/FmtStr.html#fmtstr-rationale)! curtsies-0.3.1/setup.py0000644000076500000240000000351613604023741015157 0ustar tombstaff00000000000000from setuptools import setup import ast import os import io def version(): """Return version string.""" with open(os.path.join('curtsies', '__init__.py')) as input_file: for line in input_file: if line.startswith('__version__'): return ast.parse(line).body[0].value.s def get_long_description(): with io.open('readme.md', encoding="utf-8") as f: long_description = f.read() try: import pypandoc except ImportError: print('pypandoc not installed, using file contents.') return long_description try: long_description = pypandoc.convert('readme.md', 'rst') except OSError: print("Pandoc not found. Long_description conversion failure.") return long_description else: long_description = long_description.replace("\r", "") return long_description setup(name='curtsies', version=version(), description='Curses-like terminal wrapper, with colored strings!', long_description=get_long_description(), long_description_content_type='text/markdown', url='https://github.com/bpython/curtsies', author='Thomas Ballinger', author_email='thomasballinger@gmail.com', license='MIT', packages=['curtsies'], install_requires = [ 'blessings>=1.5', 'wcwidth>=0.1.4', 'typing; python_version<"3.5"', ], tests_require = [ 'mock', 'pyte', 'nose', ], classifiers=[ 'Development Status :: 3 - Alpha', 'Environment :: Console', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: POSIX', 'Programming Language :: Python', 'Programming Language :: Python :: 3', ], zip_safe=False) curtsies-0.3.1/curtsies.egg-info/0000755000076500000240000000000013604023772016777 5ustar tombstaff00000000000000curtsies-0.3.1/curtsies.egg-info/PKG-INFO0000644000076500000240000001200013604023772020065 0ustar tombstaff00000000000000Metadata-Version: 2.1 Name: curtsies Version: 0.3.1 Summary: Curses-like terminal wrapper, with colored strings! Home-page: https://github.com/bpython/curtsies Author: Thomas Ballinger Author-email: thomasballinger@gmail.com License: MIT Description: [![Build Status](https://travis-ci.org/bpython/curtsies.svg?branch=master)](https://travis-ci.org/bpython/curtsies) [![Documentation Status](https://readthedocs.org/projects/curtsies/badge/?version=latest)](https://readthedocs.org/projects/curtsies/?badge=latest) ![Curtsies Logo](http://ballingt.com/assets/curtsiestitle.png) Curtsies is a Python 2.7 & 3.4+ compatible library for interacting with the terminal. This is what using (nearly every feature of) curtsies looks like: ```python from __future__ import unicode_literals # convenient for Python 2 import random from curtsies import FullscreenWindow, Input, FSArray from curtsies.fmtfuncs import red, bold, green, on_blue, yellow print(yellow('this prints normally, not to the alternate screen')) with FullscreenWindow() as window: with Input() as input_generator: msg = red(on_blue(bold('Press escape to exit'))) a = FSArray(window.height, window.width) a[0:1, 0:msg.width] = [msg] for c in input_generator: if c == '': break elif c == '': a = FSArray(window.height, window.width) else: s = repr(c).decode() row = random.choice(range(window.height)) column = random.choice(range(window.width-len(s))) color = random.choice([red, green, on_blue, yellow]) a[row, column:column+len(s)] = [color(s)] window.render_to_terminal(a) ``` Paste it in a `something.py` file and try it out! Installation: `pip install curtsies` [Documentation](http://curtsies.readthedocs.org/en/latest/) Primer ------ [FmtStr](http://curtsies.readthedocs.org/en/latest/FmtStr.html) objects are strings formatted with colors and styles displayable in a terminal with [ANSI escape sequences](http://en.wikipedia.org/wiki/ANSI_escape_code>`_). (the import statement shown below is outdated) ![](http://i.imgur.com/7lFaxsz.png) [FSArray](http://curtsies.readthedocs.org/en/latest/FSArray.html) objects contain multiple such strings with each formatted string on its own row, and FSArray objects can be superimposed on each other to build complex grids of colored and styled characters through composition. (the import statement shown below is outdated) ![](http://i.imgur.com/rvTRPv1.png) Such grids of characters can be rendered to the terminal in alternate screen mode (no history, like `Vim`, `top` etc.) by [FullscreenWindow](http://curtsies.readthedocs.org/en/latest/window.html#curtsies.window.FullscreenWindow) objects or normal history-preserving screen by [CursorAwareWindow](http://curtsies.readthedocs.org/en/latest/window.html#curtsies.window.CursorAwareWindow) objects. User keyboard input events like pressing the up arrow key are detected by an [Input](http://curtsies.readthedocs.org/en/latest/input.html) object. Examples -------- * [Tic-Tac-Toe](/examples/tictactoeexample.py) ![](http://i.imgur.com/AucB55B.png) * [Avoid the X's game](/examples/gameexample.py) ![](http://i.imgur.com/nv1RQd3.png) * [Bpython-curtsies uses curtsies](http://ballingt.com/2013/12/21/bpython-curtsies.html) [![](http://i.imgur.com/r7rZiBS.png)](http://www.youtube.com/watch?v=lwbpC4IJlyA) * [More examples](/examples) About ----- * [Curtsies Documentation](http://curtsies.readthedocs.org/en/latest/) * Curtsies was written to for [bpython-curtsies](http://ballingt.com/2013/12/21/bpython-curtsies.html) * `#bpython` on irc is a good place to talk about Curtsies, but feel free to open an issue if you're having a problem! * Thanks to the many contributors! * If all you need are colored strings, consider one of these [other libraries](http://curtsies.readthedocs.io/en/latest/FmtStr.html#fmtstr-rationale)! Platform: UNKNOWN Classifier: Development Status :: 3 - Alpha Classifier: Environment :: Console Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: POSIX Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Description-Content-Type: text/markdown curtsies-0.3.1/curtsies.egg-info/not-zip-safe0000644000076500000240000000000113437060667021235 0ustar tombstaff00000000000000 curtsies-0.3.1/curtsies.egg-info/SOURCES.txt0000644000076500000240000000223213604023772020662 0ustar tombstaff00000000000000LICENSE MANIFEST.in readme.md setup.cfg setup.py curtsies/__init__.py curtsies/configfile_keynames.py curtsies/curtsieskeys.py curtsies/escseqparse.py curtsies/events.py curtsies/fmtfuncs.py curtsies/formatstring.py curtsies/formatstringarray.py curtsies/input.py curtsies/termformatconstants.py curtsies/termhelpers.py curtsies/window.py curtsies.egg-info/PKG-INFO curtsies.egg-info/SOURCES.txt curtsies.egg-info/dependency_links.txt curtsies.egg-info/not-zip-safe curtsies.egg-info/requires.txt curtsies.egg-info/top_level.txt examples/chat.py examples/curses_keys.py examples/demo_fullscreen_window.py examples/demo_fullscreen_with_input.py examples/demo_input_paste.py examples/demo_input_timeout.py examples/demo_scrolling.py examples/demo_window.py examples/fps.py examples/gameexample.py examples/initial_input.py examples/initial_input_with_cursor.py examples/quickstart.py examples/realtime.py examples/simple.py examples/snake.py examples/sumtest.py examples/testcache.py examples/tictactoeexample.py examples/tttplaybitboard.py tests/test_configfile_keynames.py tests/test_events.py tests/test_fmtstr.py tests/test_input.py tests/test_terminal.py tests/test_window.pycurtsies-0.3.1/curtsies.egg-info/requires.txt0000644000076500000240000000010013604023772021366 0ustar tombstaff00000000000000blessings>=1.5 wcwidth>=0.1.4 [:python_version < "3.5"] typing curtsies-0.3.1/curtsies.egg-info/top_level.txt0000644000076500000240000000001113604023772021521 0ustar tombstaff00000000000000curtsies curtsies-0.3.1/curtsies.egg-info/dependency_links.txt0000644000076500000240000000000113604023772023045 0ustar tombstaff00000000000000 curtsies-0.3.1/examples/0000755000076500000240000000000013604023772015262 5ustar tombstaff00000000000000curtsies-0.3.1/examples/initial_input_with_cursor.py0000644000076500000240000000111312664351343023133 0ustar tombstaff00000000000000import time from curtsies import * def main(): """Ideally we shouldn't lose the first second of events""" with Input() as input_generator: def extra_bytes_callback(string): print('got extra bytes', repr(string)) print('type:', type(string)) input_generator.unget_bytes(string) time.sleep(1) with CursorAwareWindow(extra_bytes_callback=extra_bytes_callback) as window: window.get_cursor_position() for e in input_generator: print(repr(e)) if __name__ == '__main__': main() curtsies-0.3.1/examples/realtime.py0000644000076500000240000000421212664351343017440 0ustar tombstaff00000000000000import itertools import random import time from curtsies import FullscreenWindow, Input, FSArray from curtsies.fmtfuncs import red, bold, green, on_blue, yellow, on_red MAX_FPS = 1000 time_per_frame = 1. / MAX_FPS class FrameCounter(object): def __init__(self): self.render_times = [] self.dt = .5 def frame(self): self.render_times.append(time.time()) def fps(self): now = time.time() while self.render_times and self.render_times[0] < now - self.dt: self.render_times.pop(0) return len(self.render_times) / max(self.dt, now - self.render_times[0] if self.render_times else self.dt) def main(): counter = FrameCounter() with FullscreenWindow() as window: print('Press escape to exit') with Input() as input_generator: a = FSArray(window.height, window.width) c = None for framenum in itertools.count(0): t0 = time.time() while True: t = time.time() temp_c = input_generator.send(max(0, t - (t0 + time_per_frame))) if temp_c is not None: c = temp_c if c is None: pass elif c == '': return elif c == '': a = FSArray(window.height, window.width) else: row = random.choice(range(window.height)) column = random.choice(range(window.width-len(c))) a[row:row+1, column:column+len(c)] = [c] c = None if time_per_frame < t - t0: break row = random.choice(range(window.height)) column = random.choice(range(window.width)) a[row:row+1, column:column+1] = [random.choice(".,-'`~")] fps = 'FPS: %.1f' % counter.fps() a[0:1, 0:len(fps)] = [fps] window.render_to_terminal(a) counter.frame() if __name__ == '__main__': main() curtsies-0.3.1/examples/fps.py0000644000076500000240000000210212664351345016424 0ustar tombstaff00000000000000from __future__ import unicode_literals, division import time from curtsies import FullscreenWindow, Input, FSArray from curtsies.fmtfuncs import red, bold, green, on_blue, yellow, on_red import curtsies.events class Frame(curtsies.events.ScheduledEvent): pass class World(object): def __init__(self): self.s = 'Hello' def tick(self): self.s += '|' self.s = self.s[max(1, len(self.s)-80):] def process_event(self, e): self.s += str(e) def realtime(fps=15): world = World() dt = 1/fps reactor = Input() schedule_next_frame = reactor.scheduled_event_trigger(Frame) schedule_next_frame(when=time.time()) with reactor: for e in reactor: if isinstance(e, Frame): world.tick() print(world.s) when = e.when + dt while when < time.time(): when += dt schedule_next_frame(when) elif e == u'': break else: world.process_event(e) realtime() curtsies-0.3.1/examples/initial_input.py0000644000076500000240000000042012664351343020503 0ustar tombstaff00000000000000import time from curtsies.input import * def main(): """Ideally we shouldn't lose the first second of events""" time.sleep(1) with Input() as input_generator: for e in input_generator: print(repr(e)) if __name__ == '__main__': main() curtsies-0.3.1/examples/tictactoeexample.py0000644000076500000240000001057612664351343021203 0ustar tombstaff00000000000000import sys from curtsies.fmtfuncs import * from curtsies import FullscreenWindow, Input, fsarray class Board(object): """ >>> Board().rows ((' ', ' ', ' '), (' ', ' ', ' '), (' ', ' ', ' ')) >>> Board().columns ((' ', ' ', ' '), (' ', ' ', ' '), (' ', ' ', ' ')) >>> Board().turn 0 >>> Board().whose_turn 'x' >>> b = Board().move(2); print(b) | |x ----- | | ----- | | >>> b.possible() [< Board |o.x......| >, < Board |.ox......| >, < Board |..xo.....| >, < Board |..x.o....| >, < Board |..x..o...| >, < Board |..x...o..| >, < Board |..x....o.| >, < Board |..x.....o| >] """ def __init__(self, width=3, height=3): self._rows = [[' ' for _ in range(width)] for _ in range(height)] rows = property(lambda self: tuple(tuple(row) for row in self._rows)) columns = property(lambda self: tuple(zip(*self._rows))) spots = property(lambda self: tuple(c for row in self._rows for c in row)) def __str__(self): return ('\n'+'-'*(len(self.columns)*2-1) + '\n').join(['|'.join(row) for row in self._rows]) def __repr__(self): return '< Board |'+''.join(self.spots).replace(' ','.')+'| >' @property def turn(self): return 9 - self.spots.count(' ') @property def whose_turn(self): return 'xo'[self.turn % 2] def winner(self): """Returns either x or o if one of them won, otherwise None""" for c in 'xo': for comb in [(0,3,6), (1,4,7), (2,5,8), (0,1,2), (3,4,5), (6,7,8), (0,4,8), (2,4,6)]: if all(self.spots[p] == c for p in comb): return c return None def move(self, pos): if not self.spots[pos] == ' ': raise ValueError('That spot it taken') new = Board(len(self.rows), len(self.columns)) new._rows = list(list(row) for row in self.rows) new._rows[pos // 3][pos % 3] = self.whose_turn return new def possible(self): return [self.move(p) for p in range(len(self.spots)) if self.spots[p] == ' '] def display(self): colored = {' ':' ', 'x':blue(bold('x')), 'o':red(bold('o'))} s = ('\n'+green('-')*(len(self.columns)*2-1) + '\n').join([green('|').join(colored[mark] for mark in row) for row in self._rows]) a = fsarray([bold(green('enter a number, 0-8' if self.whose_turn == 'x' else 'wait for computer...'))] + s.split('\n')) return a def opp(c): """ >>> opp('x'), opp('o') ('o', 'x') """ return 'x' if c == 'o' else 'o' def value(board, who='x'): """Returns the value of a board >>> b = Board(); b._rows = [['x', 'x', 'x'], ['x', 'x', 'x'], ['x', 'x', 'x']] >>> value(b) 1 >>> b = Board(); b._rows = [['o', 'o', 'o'], ['o', 'o', 'o'], ['o', 'o', 'o']] >>> value(b) -1 >>> b = Board(); b._rows = [['x', 'o', ' '], ['x', 'o', ' '], [' ', ' ', ' ']] >>> value(b) 1 >>> b._rows[0][2] = 'x' >>> value(b) -1 """ w = board.winner() if w == who: return 1 if w == opp(who): return -1 if board.turn == 9: return 0 if who == board.whose_turn: return max([value(b, who) for b in board.possible()]) else: return min([value(b, who) for b in board.possible()]) def ai(board, who='x'): """ Returns best next board >>> b = Board(); b._rows = [['x', 'o', ' '], ['x', 'o', ' '], [' ', ' ', ' ']] >>> ai(b) < Board |xo.xo.x..| > """ return sorted(board.possible(), key=lambda b: value(b, who))[-1] def main(): with Input() as input: with FullscreenWindow() as window: b = Board() while True: window.render_to_terminal(b.display()) if b.turn == 9 or b.winner(): c = input.next() # hit any key sys.exit() while True: c = input.next() if c == '': sys.exit() try: if int(c) in range(9): b = b.move(int(c)) except ValueError: continue window.render_to_terminal(b.display()) break if b.turn == 9 or b.winner(): c = input.next() # hit any key sys.exit() b = ai(b, 'o') if __name__ == '__main__': main() curtsies-0.3.1/examples/demo_input_paste.py0000644000076500000240000000050512664351343021176 0ustar tombstaff00000000000000from curtsies.input import * def paste(): with Input() as input_generator: print("If more than %d chars read in same read a paste event is generated" % input_generator.paste_threshold) for e in input_generator: print(repr(e)) time.sleep(1) if __name__ == '__main__': paste() curtsies-0.3.1/examples/snake.py0000644000076500000240000000547412664351345016754 0ustar tombstaff00000000000000import itertools import random import time from curtsies import FullscreenWindow, Input, FSArray from curtsies.fmtfuncs import red, bold, green, on_blue, yellow, on_red key_directions = { '': (-1, 0), '': (0,-1), '': (1, 0), '': (0, 1), } class Snake(object): def __init__(self, height, width): self.height = height self.width = width self.snake_parts = [self.random_spot()] self.direction = (1, 0) self.new_apple() def random_spot(self): return random.choice(range(self.height)), random.choice(range(self.width)) def new_apple(self): while True: self.apple = self.random_spot() if self.apple not in self.snake_parts: break def advance_snake(self): self.snake_parts.insert(0, (self.snake_parts[0][0]+self.direction[0], self.snake_parts[0][1]+self.direction[1])) def render(self): a = FSArray(self.height, self.width) for row, col in self.snake_parts: a[row, col] = u'x' a[self.apple[0], self.apple[1]] = u'o' return a def tick(self, e): if (e in key_directions and abs(key_directions[e][0]) + abs(self.direction[0]) < 2 and abs(key_directions[e][1]) + abs(self.direction[1]) < 2): self.direction = key_directions[e] self.advance_snake() if self.snake_parts[0] == self.apple: self.new_apple() elif ((not (0 <= self.snake_parts[0][0] < self.height and 0 <= self.snake_parts[0][1] < self.width)) or self.snake_parts[0] in self.snake_parts[1:]): return True else: self.snake_parts.pop() def main(): MAX_FPS = 20 time_per_frame = lambda: 1. / MAX_FPS with FullscreenWindow() as window: with Input() as input_generator: snake = Snake(window.height, window.width) while True: c = None t0 = time.time() while True: t = time.time() temp_c = input_generator.send(max(0, t - (t0 + time_per_frame()))) if temp_c == '': return elif temp_c == '+': MAX_FPS += 1 elif temp_c == '-': MAX_FPS = max(1, MAX_FPS - 1) elif temp_c is not None: c = temp_c # save this keypress to be used on next tick if time_per_frame() < t - t0: break if snake.tick(c): return window.render_to_terminal(snake.render()) if __name__ == '__main__': main() curtsies-0.3.1/examples/demo_fullscreen_window.py0000644000076500000240000000052012664351343022371 0ustar tombstaff00000000000000import sys import signal import logging from curtsies import input, fmtstr, FullscreenWindow, CursorAwareWindow, Cbreak from curtsies import events from demo_window import array_size_test if __name__ == '__main__': logging.basicConfig(filename='display.log',level=logging.DEBUG) array_size_test(FullscreenWindow(sys.stdout)) curtsies-0.3.1/examples/gameexample.py0000644000076500000240000000722413264721022020121 0ustar tombstaff00000000000000from __future__ import unicode_literals import itertools import sys from curtsies import FullscreenWindow, Input, FSArray from curtsies.fmtfuncs import red, bold, green, on_blue, yellow, on_red PY2 = sys.version_info[0] == 2 def unicode_str(obj): """Get unicode str in Python 2 or 3""" if PY2: return str(obj).decode('utf8') return str(obj) class Entity(object): def __init__(self, display, x, y, speed=1): self.display = display self.x, self.y = x, y self.speed = speed def towards(self, entity): dx = entity.x - self.x dy = entity.y - self.y return vscale(self.speed, (sign(dx), sign(dy))) def die(self): self.speed = 0 self.display = on_red(bold(yellow('o'))) def sign(n): return -1 if n < 0 else 0 if n == 0 else 1 def vscale(c, v): return tuple(c*x for x in v) class World(object): def __init__(self, width, height): self.width = width self.height = height n = 5 self.player = Entity(on_blue(green(bold(u'5'))), width // 2, height // 2 - 2, speed=5) self.npcs = [Entity(on_blue(red(u'X')), i * width // (n * 2), j * height // (n * 2)) for i in range(1, 2*n, 2) for j in range(1, 2*n, 2)] self.turn = 0 entities = property(lambda self: self.npcs + [self.player]) def move_entity(self, entity, dx, dy): entity.x = max(0, min(self.width-1, entity.x + dx)) entity.y = max(0, min(self.height-1, entity.y + dy)) def process_event(self, c): """Returns a message from tick() to be displayed if game is over""" if c == "": sys.exit() elif c in key_directions: self.move_entity(self.player, *vscale(self.player.speed, key_directions[c])) else: return "try arrow keys, w, a, s, d, or ctrl-D (you pressed %r)" % c return self.tick() def tick(self): """Returns a message to be displayed if game is over, else None""" for npc in self.npcs: self.move_entity(npc, *npc.towards(self.player)) for entity1, entity2 in itertools.combinations(self.entities, 2): if (entity1.x, entity1.y) == (entity2.x, entity2.y): if self.player in (entity1, entity2): return 'you lost on turn %d' % self.turn entity1.die() entity2.die() if all(npc.speed == 0 for npc in self.npcs): return 'you won on turn %d' % self.turn self.turn += 1 if self.turn % 20 == 0: self.player.speed = max(1, self.player.speed - 1) self.player.display = on_blue(green(bold(unicode_str(self.player.speed)))) def get_array(self): a = FSArray(self.height, self.width) for entity in self.entities: a[self.height - 1 - entity.y, entity.x] = entity.display return a key_directions = {'': (0, 1), '': (-1, 0), '': (0,-1), '': (1, 0), 'w': (0, 1), 'a': (-1, 0), 's': (0,-1), 'd': (1, 0)} def main(): with FullscreenWindow(sys.stdout) as window: with Input(sys.stdin) as input_generator: world = World(width=window.width, height=window.height) window.render_to_terminal(world.get_array()) for c in input_generator: msg = world.process_event(c) if msg: break window.render_to_terminal(world.get_array()) print(msg) if __name__ == '__main__': main() curtsies-0.3.1/examples/chat.py0000644000076500000240000000500012664351345016553 0ustar tombstaff00000000000000from __future__ import unicode_literals """A more realtime netcat""" import sys import select import socket from curtsies import FullscreenWindow, Input, FSArray from curtsies.formatstring import linesplit from curtsies.fmtfuncs import blue, red, green class Connection(object): def __init__(self, sock): self.sock = sock self.received = [] def fileno(self): return self.sock.fileno() def on_read(self): self.received.append(self.sock.recv(50)) def render(self): return linesplit(green(''.join(s.decode('latin-1') for s in self.received)), 80) if self.received else [''] def main(host, port): client = socket.socket() client.connect((host, port)) client.setblocking(False) conn = Connection(client) keypresses = [] with FullscreenWindow() as window: with Input() as input_generator: while True: a = FSArray(10, 80) in_text = ''.join(keypresses)[:80] a[9:10, 0:len(in_text)] = [red(in_text)] for i, line in zip(reversed(range(2,7)), reversed(conn.render())): a[i:i+1, 0:len(line)] = [line] text = 'connected to %s:%d' % (host if len(host) < 50 else host[:50]+'...', port) a[0:1, 0:len(text)] = [blue(text)] window.render_to_terminal(a) ready_to_read, _, _ = select.select([conn, input_generator], [], []) for r in ready_to_read: if r is conn: r.on_read() else: e = input_generator.send(0) if e == '': return elif e == '': keypresses.append('\n') client.send((''.join(keypresses)).encode('latin-1')) keypresses = [] elif e == '': keypresses.append(' ') elif e in ('', ''): keypresses = keypresses[:-1] elif e is not None: keypresses.append(e) if __name__ == '__main__': try: host, port = sys.argv[1:3] except ValueError: print('usage: python chat.py google.com 80') print('(if you use this example, try typing') print('GET /') print('and then hitting enter)') else: main(host, int(port)) curtsies-0.3.1/examples/demo_fullscreen_with_input.py0000644000076500000240000000133112664351343023255 0ustar tombstaff00000000000000import sys import signal from curtsies import input, Cbreak, FullscreenWindow, fmtstr def fullscreen_winch_with_input(): print('this should be just off-screen') w = FullscreenWindow(sys.stdout) def sigwinch_handler(signum, frame): print('sigwinch! Changed from %r to %r' % ((rows, columns), (w.height, w.width))) signal.signal(signal.SIGWINCH, sigwinch_handler) with w: with Cbreak(sys.stdin): for e in input.Input(): rows, columns = w.height, w.width a = [fmtstr((('.%sx%s.%r.' % (rows, columns, e)) * rows)[:columns]) for row in range(rows)] w.render_to_terminal(a) if __name__ == '__main__': fullscreen_winch_with_input() curtsies-0.3.1/examples/demo_input_timeout.py0000644000076500000240000000055012664351343021550 0ustar tombstaff00000000000000from curtsies.input import * def main(): with Input() as input_generator: print(repr(input_generator.send(2))) print(repr(input_generator.send(1))) print(repr(input_generator.send(.5))) print(repr(input_generator.send(.2))) for e in input_generator: print(repr(e)) if __name__ == '__main__': main() curtsies-0.3.1/examples/tttplaybitboard.py0000755000076500000240000001521512436431370021052 0ustar tombstaff00000000000000#!/usr/bin/env python """ Adapted from https://github.com/darius/circuitexpress/blob/master/sketchpad/tttplaybitboard.py Super-fancy console tic-tac-toe. Derived from tttplay.py Bitboards from https://gist.github.com/pnf/5924614 grid_format from https://github.com/gigamonkey/gigamonkey-tic-tac-toe/blob/master/search.py """ import sys from curtsies.fmtfuncs import * # XXX boo hiss * from curtsies import FullscreenWindow, Input, fsarray def main(argv): pool = dict((name[:-5], play) for name, play in globals().items() if name.endswith('_play')) faceoff = [human_play, max_play] try: if len(argv) == 1: pass elif len(argv) == 2: faceoff[1] = pool[argv[1]] elif len(argv) == 3: faceoff = [pool[argv[1]], pool[argv[2]]] else: raise KeyError except KeyError: print("Usage: %s [player] [player]" % argv[0]) print("where a player is one of:", ', '.join(sorted(pool))) return 1 else: with Input() as i: with FullscreenWindow() as w: tictactoe(w, i, *faceoff) return 0 def tictactoe(w, i, player, opponent, grid=None): "Put two strategies to a classic battle of wits." grid = grid or empty_grid while True: w.render_to_terminal(w.array_from_text(view(grid))) if is_won(grid): print(whose_move(grid), "wins.") break if not successors(grid): print("A draw.") break grid = player(w, i, grid) player, opponent = opponent, player # Utilities def average(ns): return float(sum(ns)) / len(ns) def memo(f): "Return a function like f that remembers and reuses results of past calls." table = {} def memo_f(*args): try: return table[args] except KeyError: table[args] = value = f(*args) return value return memo_f # Strategies. They all presume the game's not over. def human_play(w, i, grid): "Just ask for a move." plaint = '' prompt = whose_move(grid) + " move? [1-9] " while True: w.render_to_terminal(w.array_from_text(view(grid) + '\n\n' + plaint + prompt)) key = c = i.next() try: move = int(key) except ValueError: pass else: if 1 <= move <= 9: successor = apply_move(grid, from_human_move(move)) if successor: return successor plaint = ("Hey, that's illegal. Give me one of these digits:\n\n" + (grid_format % tuple(move if apply_move(grid, from_human_move(move)) else '-' for move in range(1, 10)) + '\n\n')) grid_format = '\n'.join([' %s %s %s'] * 3) def drunk_play(w, i, grid): "Beatable, but not so stupid it seems mindless." return min(successors(grid), key=drunk_value) def spock_play(w, i, grid): "Play supposing both players are rational." return min(successors(grid), key=evaluate) def max_play(w, i, grid): "Play like Spock, except breaking ties by drunk_value." return min(successors(grid), key=lambda succ: (evaluate(succ), drunk_value(succ))) @memo def drunk_value(grid): "Return the expected value to the player if both players play at random." if is_won(grid): return -1 succs = successors(grid) return -average(map(drunk_value, succs)) if succs else 0 @memo def evaluate(grid): "Return the value for the player to move, assuming perfect play." if is_won(grid): return -1 succs = successors(grid) return -min(map(evaluate, succs)) if succs else 0 # We represent a tic-tac-toe grid as a pair of bit-vectors (p, q), p # for the player to move, q for their opponent. So p has 9 # bit-positions, one for each square in the grid, with a 1 in the # positions where the player has already moved; and likewise for the # other player's moves in q. The least significant bit is the # lower-right square; the most significant is upper-left. # (Some scaffolding to view examples inline, below:) ## def multiview(grids): print('\n'.join(reduce(beside, [view(g).split('\n') for g in grids])), end=" ") ## def beside(block1, block2): return map(' '.join, zip(block1, block2)) empty_grid = 0, 0 def is_won(grid): "Did the latest move win the game?" p, q = grid return any(way == (way & q) for way in ways_to_win) # Numbers starting with 0 are in octal: 3 bits/digit, thus one row per digit. ways_to_win = (0o700, 0o070, 0o007, 0o444, 0o222, 0o111, 0o421, 0o124) ## multiview((0, way) for way in ways_to_win) #. X X X . . . . . . X . . . X . . . X X . . . . X #. . . . X X X . . . X . . . X . . . X . X . . X . #. . . . . . . X X X X . . . X . . . X . . X X . . def successors(grid): "Return the possible grids resulting from p's moves." return filter(None, (apply_move(grid, move) for move in range(9))) ## multiview(successors(empty_grid)) #. . . . . . . . . . . . . . . . . . . . . X . X . X . . #. . . . . . . . . . . . X . X . X . . . . . . . . . . . #. . . X . X . X . . . . . . . . . . . . . . . . . . . . def apply_move(grid, move): "Try to move: return a new grid, or None if illegal." p, q = grid bit = 1 << move return (q, p | bit) if 0 == (bit & (p | q)) else None ## example = ((0112, 0221)) ## multiview([example, apply_move(example, 2)]) #. . O X . O X #. . O X . O X #. . X O X X O def from_human_move(n): "Convert from a move numbered 1..9 in top-left..bottom-right order." return 9 - n def whose_move(grid): "Return the mark of the player to move." return player_marks(grid)[0] def player_marks(grid): "Return two results: the player's mark and their opponent's." p, q = grid return 'XO' if sum(player_bits(p)) == sum(player_bits(q)) else 'OX' def player_bits(bits): return ((bits >> i) & 1 for i in reversed(range(9))) def view(grid): "Show a grid human-readably." p_mark, q_mark = player_marks(grid) return grid_format % tuple(p_mark if by_p else q_mark if by_q else '.' for by_p, by_q in zip(*map(player_bits, grid))) # Starting from this board: ## print(view((0610, 0061)), end="") #. X X . #. O O X #. . . O # Spock examines these choices: ## multiview(successors((0610, 0061))) #. X X . X X . X X X #. O O X O O X O O X #. . X O X . O . . O # and picks the win: ## print(view(spock_play((0610, 0061))), end="") #. X X X #. O O X #. . . O if __name__ == '__main__': import sys sys.exit(main(sys.argv)) curtsies-0.3.1/examples/sumtest.py0000644000076500000240000000057012664351343017345 0ustar tombstaff00000000000000from curtsies.formatstring import FmtStr, Chunk import time def add_things(n): part = Chunk('hi', {'fg':36}) whole = FmtStr(part) return sum([whole for _ in range(n)], FmtStr()) def timeit(n): t0 = time.time() add_things(n) t1 = time.time() print(n, ':', t1 - t0) return (t1 - t0) ns = range(100, 2000, 100) times = [timeit(i) for i in ns] curtsies-0.3.1/examples/testcache.py0000644000076500000240000000246112664351343017605 0ustar tombstaff00000000000000import sys import os from curtsies.fmtfuncs import blue, red, bold, on_red from curtsies.window import FullscreenWindow import time if __name__ == '__main__': print(blue('hey') + ' ' + red('there') + ' ' + red(bold('you'))) n = int(sys.argv[1]) if len(sys.argv) > 1 else 100 with FullscreenWindow() as window: rows, columns = window.get_term_hw() t0 = time.time() for i in range(n): a = [blue(on_red('qwertyuiop'[i%10]*columns)) for _ in range(rows)] window.render_to_terminal(a) t1 = time.time() t2 = time.time() for i in range(n): a = [blue(on_red('q'[i%1]*columns)) for _ in range(rows)] window.render_to_terminal(a) t3 = time.time() t4 = time.time() a = [blue(on_red('q'*columns)) for _ in range(rows)] arrays = [] for i in range(n): a[i // columns] = a[i // columns].setitem(i % columns, 'x') arrays.append([fs.copy() for fs in a]) for i in range(n): window.render_to_terminal(arrays[i]) t5 = time.time() s = """ all different: %f\tall identical: %f\tchange on character %f\t%d iterations\t""" % (t1 - t0, t3 - t2, t5 - t4, n) os.system('echo `git log --pretty=oneline -n 1` '+s+' >> times.txt') print(s) curtsies-0.3.1/examples/curses_keys.py0000644000076500000240000000030212664351343020171 0ustar tombstaff00000000000000from curtsies import Input def main(): with Input(keynames='curses') as input_generator: for e in input_generator: print(repr(e)) if __name__ == '__main__': main() curtsies-0.3.1/examples/demo_scrolling.py0000644000076500000240000000212212664351345020636 0ustar tombstaff00000000000000import sys import signal from curtsies import CursorAwareWindow, input, fmtstr rows, columns = '??' def cursor_winch(): global rows, columns # instead of closure for Python 2 compatibility print('this should be just off-screen') w = CursorAwareWindow(sys.stdout, sys.stdin, keep_last_line=False, hide_cursor=False) def sigwinch_handler(signum, frame): global rows, columns dy = w.get_cursor_vertical_diff() old_rows, old_columns = rows, columns rows, columns = w.height, w.width print('sigwinch! Changed from %r to %r' % ((old_rows, old_columns), (rows, columns))) print('cursor moved %d lines down' % dy) w.write(w.t.move_up) w.write(w.t.move_up) signal.signal(signal.SIGWINCH, sigwinch_handler) with w: for e in input.Input(): rows, columns = w.height, w.width a = [fmtstr(((u'.%sx%s.' % (rows, columns)) * rows)[:columns]) for row in range(rows)] w.render_to_terminal(a) if e == u'': break if __name__ == '__main__': cursor_winch() curtsies-0.3.1/examples/simple.py0000644000076500000240000000137012664351343017131 0ustar tombstaff00000000000000import random from curtsies import FullscreenWindow, Input, FSArray def main(): with FullscreenWindow() as window: print('Press escape to exit') with Input() as input_generator: a = FSArray(window.height, window.width) for c in input_generator: if c == '': break elif c == '': a = FSArray(window.height, window.width) else: row = random.choice(range(window.height)) column = random.choice(range(window.width-len(repr(c)))) a[row, column:column+len(repr(c))] = [repr(c)] window.render_to_terminal(a) if __name__ == '__main__': main() curtsies-0.3.1/examples/demo_window.py0000644000076500000240000000407212664351345020157 0ustar tombstaff00000000000000from __future__ import unicode_literals import sys import signal import logging from curtsies import input, fmtstr, events, FullscreenWindow, CursorAwareWindow from curtsies import events def array_size_test(window): """Tests arrays one row to small or too large""" with window as w: print('a displays a screen worth of input, s one line less, and d one line more') with input.Input(sys.stdin) as input_generator: while True: c = input_generator.next() rows, columns = w.height, w.width if c == "": sys.exit() # same as raise SystemExit() elif c == "h": a = w.array_from_text("a for small array") elif c == "a": a = [fmtstr(c*columns) for _ in range(rows)] elif c == "s": a = [fmtstr(c*columns) for _ in range(rows-1)] elif c == "d": a = [fmtstr(c*columns) for _ in range(rows+1)] elif c == "f": a = [fmtstr(c*columns) for _ in range(rows-2)] elif c == "q": a = [fmtstr(c*columns) for _ in range(1)] elif c == "w": a = [fmtstr(c*columns) for _ in range(1)] elif c == "e": a = [fmtstr(c*columns) for _ in range(1)] elif c == "c": w.write(w.t.move(w.t.height-1, 0)) w.scroll_down() elif isinstance(c, events.WindowChangeEvent): a = w.array_from_text("window just changed to %d rows and %d columns" % (c.rows, c.columns)) elif c == '\x0c': # ctrl-L [w.write('\n') for _ in range(rows)] continue else: a = w.array_from_text("unknown command") w.render_to_terminal(a) if __name__ == '__main__': logging.basicConfig(filename='display.log',level=logging.DEBUG) array_size_test(FullscreenWindow(sys.stdout)) curtsies-0.3.1/examples/quickstart.py0000644000076500000240000000201013002223251020001 0ustar tombstaff00000000000000from __future__ import unicode_literals # convenient for Python 2 import random from curtsies import FullscreenWindow, Input, FSArray from curtsies.fmtfuncs import red, bold, green, on_blue, yellow print(yellow('this prints normally, not to the alternate screen')) with FullscreenWindow() as window: with Input() as input_generator: msg = red(on_blue(bold('Press escape to exit'))) a = FSArray(window.height, window.width) a[0:1, 0:msg.width] = [msg] window.render_to_terminal(a) for c in input_generator: if c == '': break elif c == '': a = FSArray(window.height, window.width) else: s = repr(c) row = random.choice(range(window.height)) column = random.choice(range(window.width-len(s))) color = random.choice([red, green, on_blue, yellow]) a[row, column:column+len(s)] = [color(s)] window.render_to_terminal(a) curtsies-0.3.1/setup.cfg0000644000076500000240000000021113604023772015257 0ustar tombstaff00000000000000[nosetests] with-doctest = 1 cover-package = curtsies cover-html = 1 [bdist_wheel] universal = 1 [egg_info] tag_build = tag_date = 0 curtsies-0.3.1/curtsies/0000755000076500000240000000000013604023772015305 5ustar tombstaff00000000000000curtsies-0.3.1/curtsies/fmtfuncs.py0000644000076500000240000000202513437060273017504 0ustar tombstaff00000000000000from functools import partial as _partial from .formatstring import fmtstr black = _partial(fmtstr, style='black') red = _partial(fmtstr, style='red') green = _partial(fmtstr, style='green') yellow = _partial(fmtstr, style='yellow') blue = _partial(fmtstr, style='blue') magenta = _partial(fmtstr, style='magenta') cyan = _partial(fmtstr, style='cyan') gray = _partial(fmtstr, style='gray') on_black = _partial(fmtstr, style='on_black') on_dark = on_black # deprecated, old name of on_black on_red = _partial(fmtstr, style='on_red') on_green = _partial(fmtstr, style='on_green') on_yellow = _partial(fmtstr, style='on_yellow') on_blue = _partial(fmtstr, style='on_blue') on_magenta = _partial(fmtstr, style='on_magenta') on_cyan = _partial(fmtstr, style='on_cyan') on_gray = _partial(fmtstr, style='on_gray') bold = _partial(fmtstr, style='bold') dark = _partial(fmtstr, style='dark') underline = _partial(fmtstr, style='underline') blink = _partial(fmtstr, style='blink') invert = _partial(fmtstr, style='invert') plain = _partial(fmtstr) curtsies-0.3.1/curtsies/formatstring.py0000644000076500000240000007132413437060273020406 0ustar tombstaff00000000000000from __future__ import unicode_literals from typing import Iterator, Text, Tuple, List, Union, Optional r"""Colored strings that behave mostly like strings >>> s = fmtstr("Hey there!", 'red') >>> s red('Hey there!') >>> s[4:7] red('the') >>> red_on_blue = fmtstr('hello', 'red', 'on_blue') >>> blue_on_red = fmtstr('there', fg='blue', bg='red') >>> green = fmtstr('!', 'green') >>> full = red_on_blue + ' ' + blue_on_red + green >>> full on_blue(red('hello'))+' '+on_red(blue('there'))+green('!') >>> str(full) '\x1b[31m\x1b[44mhello\x1b[49m\x1b[39m \x1b[34m\x1b[41mthere\x1b[49m\x1b[39m\x1b[32m!\x1b[39m' >>> fmtstr(', ').join(['a', fmtstr('b'), fmtstr('c', 'blue')]) 'a'+', '+'b'+', '+blue('c') >>> fmtstr(u'hello', u'red', bold=False) red('hello') """ import itertools import re import sys from wcwidth import wcswidth from .escseqparse import parse, remove_ansi from .termformatconstants import (FG_COLORS, BG_COLORS, STYLES, FG_NUMBER_TO_COLOR, BG_NUMBER_TO_COLOR, RESET_ALL, RESET_BG, RESET_FG, seq) PY3 = sys.version_info[0] >= 3 if PY3: unicode = str xforms = { 'fg' : lambda s, v: '%s%s%s' % (seq(v), s, seq(RESET_FG)), 'bg' : lambda s, v: seq(v)+s+seq(RESET_BG), 'bold' : lambda s: seq(STYLES['bold']) +s+seq(RESET_ALL), 'dark' : lambda s: seq(STYLES['dark']) +s+seq(RESET_ALL), 'underline' : lambda s: seq(STYLES['underline'])+s+seq(RESET_ALL), 'blink' : lambda s: seq(STYLES['blink']) +s+seq(RESET_ALL), 'invert' : lambda s: seq(STYLES['invert']) +s+seq(RESET_ALL), } class FrozenDict(dict): """Immutable dictionary class""" def __setitem__(self, key, value): raise Exception("Cannot change value.") def update(self, dictlike): raise Exception("Cannot change value.") def extend(self, dictlike): return FrozenDict(itertools.chain(self.items(), dictlike.items())) def remove(self, *keys): return FrozenDict((k, v) for k, v in self.items() if k not in keys) def stable_format_dict(d): """A sorted, python2/3 stable formatting of a dictionary. Does not work for dicts with unicode strings as values.""" inner = ', '.join('{}: {}'.format(repr(k)[1:] if repr(k).startswith(u"u'") or repr(k).startswith(u'u"') else repr(k), v) for k, v in sorted(d.items())) return '{%s}' % inner class Chunk(object): """A string with a single set of formatting attributes Subject to change, not part of the API""" def __init__(self, string, atts=()): if not isinstance(string, unicode): raise ValueError("unicode string required, got %r" % string) self._s = string self._atts = FrozenDict(atts) s = property(lambda self: self._s) # resist changes to s and atts atts = property(lambda self: self._atts, None, None, "Attributes, e.g. {'fg': 34, 'bold': True} where 34 is the escape code for ...") def __len__(self): return len(self._s) @property def width(self): width = wcswidth(self._s) if len(self._s) > 0 and width < 1: raise ValueError("Can't calculate width of string %r" % self._s) return width #TODO cache this @property def color_str(self): "Return an escape-coded string to write to the terminal." s = self.s for k, v in sorted(self.atts.items()): # (self.atts sorted for the sake of always acting the same.) if k not in xforms: # Unsupported SGR code continue elif v is False: continue elif v is True: s = xforms[k](s) else: s = xforms[k](s, v) return s def __unicode__(self): value = self.color_str if isinstance(value, bytes): return value.decode('utf8', 'replace') return value def __eq__(self, other): return self.s == other.s and self.atts == other.atts # TODO: corresponding hash method if PY3: __str__ = __unicode__ else: def __str__(self): return unicode(self).encode('utf8') def __repr__(self): return 'Chunk({s}{sep}{atts})'.format( s=repr(self.s), sep=', ' if self.atts else '', atts = stable_format_dict(self.atts) if self.atts else '') def repr_part(self): """FmtStr repr is build by concatenating these.""" def pp_att(att): if att == 'fg': return FG_NUMBER_TO_COLOR[self.atts[att]] elif att == 'bg': return 'on_' + BG_NUMBER_TO_COLOR[self.atts[att]] else: return att atts_out = dict((k, v) for (k, v) in self.atts.items() if v) return (''.join(pp_att(att)+'(' for att in sorted(atts_out)) + (repr(self.s) if PY3 else repr(self.s)[1:]) + ')'*len(atts_out)) def splitter(self): """ Returns a view of this Chunk from which new Chunks can be requested. """ return ChunkSplitter(self) class ChunkSplitter(object): """ View of a Chunk for breaking it into smaller Chunks. """ def __init__(self, chunk): self.chunk = chunk self.internal_offset = 0 # index into chunk.s self.internal_width = 0 # width of chunks.s[:self.internal_offset] divides = [0] for c in self.chunk.s: divides.append(divides[-1] + wcswidth(c)) self.divides = divides def reinit(self, chunk): """Reuse an existing Splitter instance for speed.""" # TODO benchmark to prove this is worthwhile self.__init__(chunk) def request(self, max_width): # type: (int) -> Optional[Tuple[int, Chunk]] """Requests a sub-chunk of max_width or shorter. Returns None if no chunks left.""" if max_width < 1: raise ValueError('requires positive integer max_width') s = self.chunk.s length = len(s) if self.internal_offset == len(s): return None width = 0 start_offset = i = self.internal_offset replacement_char = u' ' while True: w = wcswidth(s[i]) # If adding a character puts us over the requested width, return what we've got so far if width + w > max_width: self.internal_offset = i # does not include ith character self.internal_width += width # if not adding it us makes us short, this must have been a double-width character if width < max_width: assert width + 1 == max_width, 'unicode character width of more than 2!?!' assert w == 2, 'unicode character of width other than 2?' return (width + 1, Chunk(s[start_offset:self.internal_offset] + replacement_char, atts=self.chunk.atts)) return (width, Chunk(s[start_offset:self.internal_offset], atts=self.chunk.atts)) # otherwise add this width width += w # If one more char would put us over, return whatever we've got if i + 1 == length: self.internal_offset = i + 1 # beware the fencepost, i is an index not an offset self.internal_width += width return (width, Chunk(s[start_offset:self.internal_offset], atts=self.chunk.atts)) # otherwise attempt to add the next character i += 1 class FmtStr(object): """A string whose substrings carry attributes.""" def __init__(self, *components): # These assertions below could be useful for debugging, but slow things down considerably #assert all([len(x) > 0 for x in components]) #self.chunks = [x for x in components if len(x) > 0] self.chunks = list(components) # caching these leads tom a significant speedup self._str = None self._unicode = None self._len = None self._s = None self._width = None @classmethod def from_str(cls, s): # type: (Union[Text, bytes]) -> FmtStr r""" Return a FmtStr representing input. The str() of a FmtStr is guaranteed to produced the same FmtStr. Other input with escape sequences may not be preserved. >>> fmtstr("|"+fmtstr("hey", fg='red', bg='blue')+"|") '|'+on_blue(red('hey'))+'|' >>> fmtstr('|\x1b[31m\x1b[44mhey\x1b[49m\x1b[39m|') '|'+on_blue(red('hey'))+'|' """ if '\x1b[' in s: try: tokens_and_strings = parse(s) except ValueError: return FmtStr(Chunk(remove_ansi(s))) else: chunks = [] cur_fmt = {} for x in tokens_and_strings: if isinstance(x, dict): cur_fmt.update(x) elif isinstance(x, (bytes, unicode)): atts = parse_args('', dict((k, v) for k, v in cur_fmt.items() if v is not None)) chunks.append(Chunk(x, atts=atts)) else: raise Exception("logic error") return FmtStr(*chunks) else: return FmtStr(Chunk(s)) def copy_with_new_str(self, new_str): """Copies the current FmtStr's attributes while changing its string.""" # What to do when there are multiple Chunks with conflicting atts? old_atts = dict((att, value) for bfs in self.chunks for (att, value) in bfs.atts.items()) return FmtStr(Chunk(new_str, old_atts)) def setitem(self, startindex, fs): """Shim for easily converting old __setitem__ calls""" return self.setslice_with_length(startindex, startindex+1, fs, len(self)) def setslice_with_length(self, startindex, endindex, fs, length): """Shim for easily converting old __setitem__ calls""" if len(self) < startindex: fs = ' '*(startindex - len(self)) + fs if len(self) > endindex: fs = fs + ' '*(endindex - startindex - len(fs)) assert len(fs) == endindex - startindex, (len(fs), startindex, endindex) result = self.splice(fs, startindex, endindex) assert len(result) <= length return result def splice(self, new_str, start, end=None): """Returns a new FmtStr with the input string spliced into the the original FmtStr at start and end. If end is provided, new_str will replace the substring self.s[start:end-1]. """ if len(new_str) == 0: return self new_fs = new_str if isinstance(new_str, FmtStr) else fmtstr(new_str) assert len(new_fs.chunks) > 0, (new_fs.chunks, new_fs) new_components = [] inserted = False if end is None: end = start tail = None for bfs, bfs_start, bfs_end in zip(self.chunks, self.divides[:-1], self.divides[1:]): if end == bfs_start == 0: new_components.extend(new_fs.chunks) new_components.append(bfs) inserted = True elif bfs_start <= start < bfs_end: divide = start - bfs_start head = Chunk(bfs.s[:divide], atts=bfs.atts) tail = Chunk(bfs.s[end - bfs_start:], atts=bfs.atts) new_components.extend([head] + new_fs.chunks) inserted = True if bfs_start < end < bfs_end: tail = Chunk(bfs.s[end - bfs_start:], atts=bfs.atts) new_components.append(tail) elif bfs_start < end < bfs_end: divide = start - bfs_start tail = Chunk(bfs.s[end - bfs_start:], atts=bfs.atts) new_components.append(tail) elif bfs_start >= end or bfs_end <= start: new_components.append(bfs) if not inserted: new_components.extend(new_fs.chunks) inserted = True return FmtStr(*[s for s in new_components if s.s]) def append(self, string): return self.splice(string, len(self.s)) def copy_with_new_atts(self, **attributes): """Returns a new FmtStr with the same content but new formatting""" return FmtStr(*[Chunk(bfs.s, bfs.atts.extend(attributes)) for bfs in self.chunks]) def join(self, iterable): """Joins an iterable yielding strings or FmtStrs with self as separator""" before = [] chunks = [] for i, s in enumerate(iterable): chunks.extend(before) before = self.chunks if isinstance(s, FmtStr): chunks.extend(s.chunks) elif isinstance(s, (bytes, unicode)): chunks.extend(fmtstr(s).chunks) #TODO just make a chunk directly else: raise TypeError("expected str or FmtStr, %r found" % type(s)) return FmtStr(*chunks) # TODO make this split work like str.split def split(self, sep=None, maxsplit=None, regex=False): """Split based on seperator, optionally using a regex Capture groups are ignored in regex, the whole pattern is matched and used to split the original FmtStr.""" if maxsplit is not None: raise NotImplementedError('no maxsplit yet') s = self.s if sep is None: sep = r'\s+' elif not regex: sep = re.escape(sep) matches = list(re.finditer(sep, s)) return [self[start:end] for start, end in zip( [0] + [m.end() for m in matches], [m.start() for m in matches] + [len(s)])] def splitlines(self, keepends=False): """Return a list of lines, split on newline characters, include line boundaries, if keepends is true.""" lines = self.split('\n') return [line+'\n' for line in lines] if keepends else ( lines if lines[-1] else lines[:-1]) # proxying to the string via __getattr__ is insufficient # because we shouldn't drop foreground or formatting info def ljust(self, width, fillchar=None): """S.ljust(width[, fillchar]) -> string If a fillchar is provided, less formatting information will be preserved """ if fillchar is not None: return fmtstr(self.s.ljust(width, fillchar), **self.shared_atts) to_add = ' ' * (width - len(self.s)) shared = self.shared_atts if 'bg' in shared: return self + fmtstr(to_add, bg=shared[str('bg')]) if to_add else self else: uniform = self.new_with_atts_removed('bg') return uniform + fmtstr(to_add, **self.shared_atts) if to_add else uniform def rjust(self, width, fillchar=None): """S.rjust(width[, fillchar]) -> string If a fillchar is provided, less formatting information will be preserved """ if fillchar is not None: return fmtstr(self.s.rjust(width, fillchar), **self.shared_atts) to_add = ' ' * (width - len(self.s)) shared = self.shared_atts if 'bg' in shared: return fmtstr(to_add, bg=shared[str('bg')]) + self if to_add else self else: uniform = self.new_with_atts_removed('bg') return fmtstr(to_add, **self.shared_atts) + uniform if to_add else uniform def __unicode__(self): if self._unicode is not None: return self._unicode self._unicode = ''.join(unicode(fs) for fs in self.chunks) return self._unicode if PY3: __str__ = __unicode__ else: def __str__(self): if self._str is not None: return self._str self._str = str('').join(str(fs) for fs in self.chunks) return self._str def __len__(self): if self._len is not None: return self._len self._len = sum(len(fs) for fs in self.chunks) return self._len @property def width(self): """The number of columns it would take to display this string""" if self._width is not None: return self._width self._width = sum(fs.width for fs in self.chunks) return self._width def width_at_offset(self, n): """Returns the horizontal position of character n of the string""" #TODO make more efficient? width = wcswidth(self.s[:n]) assert width != -1 return width def __repr__(self): return '+'.join(fs.repr_part() for fs in self.chunks) def __eq__(self, other): if isinstance(other, (unicode, bytes, FmtStr)): return str(self) == str(other) return False # TODO corresponding hash method def __add__(self, other): if isinstance(other, FmtStr): return FmtStr(*(self.chunks + other.chunks)) elif isinstance(other, (bytes, unicode)): return FmtStr(*(self.chunks + [Chunk(other)])) else: raise TypeError('Can\'t add %r and %r' % (self, other)) def __radd__(self, other): if isinstance(other, FmtStr): return FmtStr(*(x for x in (other.chunks + self.chunks))) elif isinstance(other, (bytes, unicode)): return FmtStr(*(x for x in ([Chunk(other)] + self.chunks))) else: raise TypeError('Can\'t add those') def __mul__(self, other): if isinstance(other, int): return sum([self for _ in range(other)], FmtStr()) raise TypeError('Can\'t mulitply those') #TODO ensure emtpy FmtStr isn't a problem @property def shared_atts(self): """Gets atts shared among all nonzero length component Chunk""" #TODO cache this, could get ugly for large FmtStrs atts = {} first = self.chunks[0] for att in sorted(first.atts): #TODO how to write this without the '???'? if all(fs.atts.get(att, '???') == first.atts[att] for fs in self.chunks if len(fs) > 0): atts[att] = first.atts[att] return atts def new_with_atts_removed(self, *attributes): """Returns a new FmtStr with the same content but some attributes removed""" return FmtStr(*[Chunk(bfs.s, bfs.atts.remove(*attributes)) for bfs in self.chunks]) def __getattr__(self, att): # thanks to @aerenchyma/@jczett if not hasattr(self.s, att): raise AttributeError("No attribute %r" % (att,)) def func_help(*args, **kwargs): result = getattr(self.s, att)(*args, **kwargs) if isinstance(result, (bytes, unicode)): return fmtstr(result, **self.shared_atts) elif isinstance(result, list): return [fmtstr(x, **self.shared_atts) for x in result] else: return result return func_help @property def divides(self): """List of indices of divisions between the constituent chunks.""" acc = [0] for s in self.chunks: acc.append(acc[-1] + len(s)) return acc @property def s(self): if self._s is not None: return self._s self._s = "".join(fs.s for fs in self.chunks) return self._s def __getitem__(self, index): index = normalize_slice(len(self), index) counter = 0 parts = [] for chunk in self.chunks: if index.start < counter + len(chunk) and index.stop > counter: start = max(0, index.start - counter) end = min(index.stop - counter, len(chunk)) if end - start == len(chunk): parts.append(chunk) else: s_part = chunk.s[max(0, index.start - counter): index.stop - counter] parts.append(Chunk(s_part, chunk.atts)) counter += len(chunk) if index.stop < counter: break return FmtStr(*parts) if parts else fmtstr('') def width_aware_slice(self, index): """Slice based on the number of columns it would take to display the substring.""" if wcswidth(self.s) == -1: raise ValueError('bad values for width aware slicing') index = normalize_slice(self.width, index) counter = 0 parts = [] for chunk in self.chunks: if index.start < counter + chunk.width and index.stop > counter: start = max(0, index.start - counter) end = min(index.stop - counter, chunk.width) if end - start == chunk.width: parts.append(chunk) else: s_part = width_aware_slice(chunk.s, max(0, index.start - counter), index.stop - counter) parts.append(Chunk(s_part, chunk.atts)) counter += chunk.width if index.stop < counter: break return FmtStr(*parts) if parts else fmtstr('') def width_aware_splitlines(self, columns): # type: (int) -> Iterator[FmtStr] """Split into lines, pushing doublewidth characters at the end of a line to the next line. When a double-width character is pushed to the next line, a space is added to pad out the line. """ if columns < 2: raise ValueError("Column width %s is too narrow." % columns) if wcswidth(self.s) == -1: raise ValueError('bad values for width aware slicing') return self._width_aware_splitlines(columns) def _width_aware_splitlines(self, columns): # type: (int) -> Iterator[FmtStr] splitter = self.chunks[0].splitter() chunks_of_line = [] width_of_line = 0 for source_chunk in self.chunks: splitter.reinit(source_chunk) while True: request = splitter.request(columns - width_of_line) if request is None: break # done with this source_chunk w, new_chunk = request chunks_of_line.append(new_chunk) width_of_line += w if width_of_line == columns: yield FmtStr(*chunks_of_line) del chunks_of_line[:] width_of_line = 0 if chunks_of_line: yield FmtStr(*chunks_of_line) def _getitem_normalized(self, index): """Builds the more compact fmtstrs by using fromstr( of the control sequences)""" index = normalize_slice(len(self), index) counter = 0 output = '' for fs in self.chunks: if index.start < counter + len(fs) and index.stop > counter: s_part = fs.s[max(0, index.start - counter):index.stop - counter] piece = Chunk(s_part, fs.atts).color_str output += piece counter += len(fs) if index.stop < counter: break return fmtstr(output) def __setitem__(self, index, value): raise Exception("No!") def copy(self): return FmtStr(*self.chunks) def interval_overlap(a, b, x, y): """Returns by how much two intervals overlap assumed that a <= b and x <= y""" if b <= x or a >= y: return 0 elif x <= a <= y: return min(b, y) - a elif x <= b <= y: return b - max(a, x) elif a >= x and b <= y: return b - a else: assert False def width_aware_slice(s, start, end, replacement_char=u' '): # type: (Text, int, int, Text) """ >>> width_aware_slice(u'a\uff25iou', 0, 2)[1] == u' ' True """ divides = [0] for c in s: divides.append(divides[-1] + wcswidth(c)) new_chunk_chars = [] for char, char_start, char_end in zip(s, divides[:-1], divides[1:]): if char_start == start and char_end == start: continue # don't use zero-width characters at the beginning of a slice # (combining characters combine with the chars before themselves) elif char_start >= start and char_end <= end: new_chunk_chars.append(char) else: new_chunk_chars.extend(replacement_char * interval_overlap(char_start, char_end, start, end)) return ''.join(new_chunk_chars) def linesplit(string, columns): # type: (Union[Text, FmtStr], int) -> List[FmtStr] """Returns a list of lines, split on the last possible space of each line. Split spaces will be removed. Whitespaces will be normalized to one space. Spaces will be the color of the first whitespace character of the normalized whitespace. If a word extends beyond the line, wrap it anyway. >>> linesplit(fmtstr(" home is where the heart-eating mummy is", 'blue'), 10) [blue('home')+blue(' ')+blue('is'), blue('where')+blue(' ')+blue('the'), blue('heart-eati'), blue('ng')+blue(' ')+blue('mummy'), blue('is')] """ if not isinstance(string, FmtStr): string = fmtstr(string) string_s = string.s matches = list(re.finditer(r'\s+', string_s)) spaces = [string[m.start():m.end()] for m in matches if m.start() != 0 and m.end() != len(string_s)] words = [string[start:end] for start, end in zip( [0] + [m.end() for m in matches], [m.start() for m in matches] + [len(string_s)]) if start != end] word_to_lines = lambda word: [word[columns*i:columns*(i+1)] for i in range((len(word) - 1) // columns + 1)] lines = word_to_lines(words[0]) for word, space in zip(words[1:], spaces): if len(lines[-1]) + len(word) < columns: lines[-1] += fmtstr(' ', **space.shared_atts) lines[-1] += word else: lines.extend(word_to_lines(word)) return lines def normalize_slice(length, index): "Fill in the Nones in a slice." is_int = False if isinstance(index, int): is_int = True index = slice(index, index+1) if index.start is None: index = slice(0, index.stop, index.step) if index.stop is None: index = slice(index.start, length, index.step) if index.start < -1: # XXX why must this be -1? index = slice(length - index.start, index.stop, index.step) if index.stop < -1: # XXX why must this be -1? index = slice(index.start, length - index.stop, index.step) if index.step is not None: raise NotImplementedError("You can't use steps with slicing yet") if is_int: if index.start < 0 or index.start > length: raise IndexError("index out of bounds: %r for length %s" % (index, length)) return index def parse_args(args, kwargs): """Returns a kwargs dictionary by turning args into kwargs""" if 'style' in kwargs: args += (kwargs['style'],) del kwargs['style'] for arg in args: if not isinstance(arg, (bytes, unicode)): raise ValueError("args must be strings:" + repr(args)) if arg.lower() in FG_COLORS: if 'fg' in kwargs: raise ValueError("fg specified twice") kwargs['fg'] = FG_COLORS[arg] elif arg.lower().startswith('on_') and arg[3:].lower() in BG_COLORS: if 'bg' in kwargs: raise ValueError("fg specified twice") kwargs['bg'] = BG_COLORS[arg[3:]] elif arg.lower() in STYLES: kwargs[arg] = True else: raise ValueError("couldn't process arg: "+repr(arg)) for k in kwargs: if k not in ['fg', 'bg'] + list(STYLES.keys()): raise ValueError("Can't apply that transformation") if 'fg' in kwargs: if kwargs['fg'] in FG_COLORS: kwargs['fg'] = FG_COLORS[kwargs['fg']] if kwargs['fg'] not in list(FG_COLORS.values()): raise ValueError("Bad fg value: %r" % kwargs['fg']) if 'bg' in kwargs: if kwargs['bg'] in BG_COLORS: kwargs['bg'] = BG_COLORS[kwargs['bg']] if kwargs['bg'] not in list(BG_COLORS.values()): raise ValueError("Bad bg value: %r" % kwargs['bg']) return kwargs def fmtstr(string, *args, **kwargs): # type: (Union[Text, bytes, FmtStr], *Any, **Any) -> FmtStr """ Convenience function for creating a FmtStr >>> fmtstr('asdf', 'blue', 'on_red', 'bold') on_red(bold(blue('asdf'))) >>> fmtstr('blarg', fg='blue', bg='red', bold=True) on_red(bold(blue('blarg'))) """ atts = parse_args(args, kwargs) if isinstance(string, FmtStr): pass elif isinstance(string, (bytes, unicode)): string = FmtStr.from_str(string) else: raise ValueError("Bad Args: %r (of type %s), %r, %r" % (string, type(string), args, kwargs)) return string.copy_with_new_atts(**atts) curtsies-0.3.1/curtsies/window.py0000644000076500000240000004666613437060273017211 0ustar tombstaff00000000000000# All windows write only unicode to the terminal - # that's what blessings does, so we match it. from __future__ import unicode_literals import locale import logging import re import sys import blessings from .formatstring import fmtstr from .formatstringarray import FSArray from .termhelpers import Cbreak logger = logging.getLogger(__name__) SCROLL_DOWN = u"\x1bD" FIRST_COLUMN = u"\x1b[1G" class BaseWindow(object): def __init__(self, out_stream=None, hide_cursor=True): logger.debug('-------initializing Window object %r------' % self) if out_stream is None: out_stream = sys.__stdout__ self.t = blessings.Terminal(stream=out_stream, force_styling=True) self.out_stream = out_stream self.hide_cursor = hide_cursor self._last_lines_by_row = {} self._last_rendered_width = None self._last_rendered_height = None def scroll_down(self): logger.debug('sending scroll down message w/ cursor on bottom line') # since scroll-down only moves the screen if cursor is at bottom with self.t.location(x=0, y=1000000): self.write(SCROLL_DOWN) # TODO will blessings do this? def write(self, msg): self.out_stream.write(msg) self.out_stream.flush() def __enter__(self): logger.debug("running BaseWindow.__enter__") if self.hide_cursor: self.write(self.t.hide_cursor) return self def __exit__(self, type, value, traceback): logger.debug("running BaseWindow.__exit__") if self.hide_cursor: self.write(self.t.normal_cursor) def on_terminal_size_change(self, height, width): # Changing the terminal size breaks the cache, because it # is unknown how the window size change affected scrolling / the cursor self._last_lines_by_row = {} self._last_rendered_width = width self._last_rendered_height = height def render_to_terminal(self, array, cursor_pos=(0, 0)): raise NotImplementedError def get_term_hw(self): """Returns current terminal height and width""" return self.t.height, self.t.width width = property(lambda self: self.t.width, None, None, "The current width of the terminal window") height = property(lambda self: self.t.height, None, None, "The current height of the terminal window") def array_from_text(self, msg): """Returns a FSArray of the size of the window containing msg""" rows, columns = self.t.height, self.t.width return self.array_from_text_rc(msg, rows, columns) @classmethod def array_from_text_rc(cls, msg, rows, columns): arr = FSArray(0, columns) i = 0 for c in msg: if i >= rows * columns: return arr elif c in '\r\n': i = ((i // columns) + 1) * columns - 1 else: arr[i // arr.width, i % arr.width] = [fmtstr(c)] i += 1 return arr def fmtstr_to_stdout_xform(self): if sys.version_info[0] == 2: if hasattr(self.out_stream, 'encoding'): encoding = self.out_stream.encoding else: encoding = locale.getpreferredencoding() def for_stdout(s): return unicode(s).encode(encoding, 'replace') else: def for_stdout(s): return str(s) return for_stdout class FullscreenWindow(BaseWindow): """2D-text rendering window that dissappears when its context is left FullscreenWindow will only render arrays the size of the terminal or smaller, and leaves no trace on exit (like top or vim). It never scrolls the terminal. Changing the terminal size doesn't do anything, but rendered arrays need to fit on the screen. Note: The context of the FullscreenWindow object must be entered before calling any of its methods. Within the context of CursorAwareWindow, refrain from writing to its out_stream; cached writes will be inaccurate. """ def __init__(self, out_stream=None, hide_cursor=True): """Constructs a FullscreenWindow Args: out_stream (file): Defaults to sys.__stdout__ hide_cursor (bool): Hides cursor while in context """ BaseWindow.__init__(self, out_stream=out_stream, hide_cursor=hide_cursor) self.fullscreen_ctx = self.t.fullscreen() def __enter__(self): self.fullscreen_ctx.__enter__() return BaseWindow.__enter__(self) def __exit__(self, type, value, traceback): self.fullscreen_ctx.__exit__(type, value, traceback) BaseWindow.__exit__(self, type, value, traceback) def render_to_terminal(self, array, cursor_pos=(0, 0)): """Renders array to terminal and places (0-indexed) cursor Args: array (FSArray): Grid of styled characters to be rendered. * If array received is of width too small, render it anyway * If array received is of width too large, * render the renderable portion * If array received is of height too small, render it anyway * If array received is of height too large, * render the renderable portion (no scroll) """ # TODO there's a race condition here - these height and widths are # super fresh - they might change between the array being constructed # and rendered # Maybe the right behavior is to throw away the render # in the signal handler? height, width = self.height, self.width for_stdout = self.fmtstr_to_stdout_xform() if not self.hide_cursor: self.write(self.t.hide_cursor) if (height != self._last_rendered_height or width != self._last_rendered_width): self.on_terminal_size_change(height, width) current_lines_by_row = {} rows = list(range(height)) rows_for_use = rows[:len(array)] rest_of_rows = rows[len(array):] # rows which we have content for and don't require scrolling for row, line in zip(rows_for_use, array): current_lines_by_row[row] = line if line == self._last_lines_by_row.get(row, None): continue self.write(self.t.move(row, 0)) self.write(for_stdout(line)) if len(line) < width: self.write(self.t.clear_eol) # rows onscreen that we don't have content for for row in rest_of_rows: if self._last_lines_by_row and row not in self._last_lines_by_row: continue self.write(self.t.move(row, 0)) self.write(self.t.clear_eol) self.write(self.t.clear_bol) current_lines_by_row[row] = None logger.debug( 'lines in last lines by row: %r' % self._last_lines_by_row.keys() ) logger.debug( 'lines in current lines by row: %r' % current_lines_by_row.keys() ) self.write(self.t.move(*cursor_pos)) self._last_lines_by_row = current_lines_by_row if not self.hide_cursor: self.write(self.t.normal_cursor) class CursorAwareWindow(BaseWindow): """ Renders to the normal terminal screen and can find the location of the cursor. Note: The context of the CursorAwareWindow object must be entered before calling any of its methods. Within the context of CursorAwareWindow, refrain from writing to its out_stream; cached writes will be inaccurate and calculating cursor depends on cursor not having moved since the last render. Only use the render_to_terminal interface for moving the cursor. """ def __init__(self, out_stream=None, in_stream=None, keep_last_line=False, hide_cursor=True, extra_bytes_callback=None): """Constructs a CursorAwareWindow Args: out_stream (file): Defaults to sys.__stdout__ in_stream (file): Defaults to sys.__stdin__ keep_last_line (bool): Causes the cursor to be moved down one line on leaving context hide_cursor (bool): Hides cursor while in context extra_bytes_callback (f(bytes) -> None): Will be called with extra bytes inadvertantly read in get_cursor_position(). If not provided, a ValueError will be raised when this occurs. """ BaseWindow.__init__(self, out_stream=out_stream, hide_cursor=hide_cursor) if in_stream is None: in_stream = sys.__stdin__ self.in_stream = in_stream self._last_cursor_column = None self._last_cursor_row = None self.keep_last_line = keep_last_line self.cbreak = Cbreak(self.in_stream) self.extra_bytes_callback = extra_bytes_callback # whether another SIGWINCH is queued up self.another_sigwinch = False # in the cursor query code of cursor diff self.in_get_cursor_diff = False def __enter__(self): self.cbreak.__enter__() self.top_usable_row, _ = self.get_cursor_position() self._orig_top_usable_row = self.top_usable_row logger.debug('initial top_usable_row: %d' % self.top_usable_row) return BaseWindow.__enter__(self) def __exit__(self, type, value, traceback): if self.keep_last_line: # just moves cursor down if not on last line self.write(SCROLL_DOWN) self.write(FIRST_COLUMN) self.write(self.t.clear_eos) self.write(self.t.clear_eol) self.cbreak.__exit__(type, value, traceback) BaseWindow.__exit__(self, type, value, traceback) def get_cursor_position(self): """Returns the terminal (row, column) of the cursor 0-indexed, like blessings cursor positions""" # TODO would this be cleaner as a parameter? in_stream = self.in_stream query_cursor_position = u"\x1b[6n" self.write(query_cursor_position) def retrying_read(): while True: try: c = in_stream.read(1) if c == '': raise ValueError("Stream should be blocking - should't" " return ''. Returned %r so far", (resp,)) return c except IOError: raise ValueError( 'cursor get pos response read interrupted' ) # find out if this ever really happens - if so, continue resp = '' while True: c = retrying_read() resp += c m = re.search('(?P.*)' '(?P\x1b\[|\x9b)' '(?P\\d+);(?P\\d+)R', resp, re.DOTALL) if m: row = int(m.groupdict()['row']) col = int(m.groupdict()['column']) extra = m.groupdict()['extra'] if extra: if self.extra_bytes_callback: self.extra_bytes_callback( extra.encode(in_stream.encoding) ) else: raise ValueError(("Bytes preceding cursor position " "query response thrown out:\n%r\n" "Pass an extra_bytes_callback to " "CursorAwareWindow to prevent this") % (extra,)) return (row - 1, col - 1) def get_cursor_vertical_diff(self): """Returns the how far down the cursor moved since last render. Note: If another get_cursor_vertical_diff call is already in progress, immediately returns zero. (This situation is likely if get_cursor_vertical_diff is called from a SIGWINCH signal handler, since sigwinches can happen in rapid succession and terminal emulators seem not to respond to cursor position queries before the next sigwinch occurs.) """ # Probably called by a SIGWINCH handler, and therefore # will do cursor querying until a SIGWINCH doesn't happen during # the query. Calls to the function from a signal handler COULD STILL # HAPPEN out of order - # they just can't interrupt the actual cursor query. if self.in_get_cursor_diff: self.another_sigwinch = True return 0 cursor_dy = 0 while True: self.in_get_cursor_diff = True self.another_sigwinch = False cursor_dy += self._get_cursor_vertical_diff_once() self.in_get_cursor_diff = False if not self.another_sigwinch: return cursor_dy def _get_cursor_vertical_diff_once(self): """Returns the how far down the cursor moved.""" old_top_usable_row = self.top_usable_row row, col = self.get_cursor_position() if self._last_cursor_row is None: cursor_dy = 0 else: cursor_dy = row - self._last_cursor_row logger.info('cursor moved %d lines down' % cursor_dy) while self.top_usable_row > -1 and cursor_dy > 0: self.top_usable_row += 1 cursor_dy -= 1 while self.top_usable_row > 1 and cursor_dy < 0: self.top_usable_row -= 1 cursor_dy += 1 logger.info('top usable row changed from %d to %d', old_top_usable_row, self.top_usable_row) logger.info('returning cursor dy of %d from curtsies' % cursor_dy) self._last_cursor_row = row return cursor_dy def render_to_terminal(self, array, cursor_pos=(0, 0)): """Renders array to terminal, returns the number of lines scrolled offscreen Returns: Number of times scrolled Args: array (FSArray): Grid of styled characters to be rendered. If array received is of width too small, render it anyway if array received is of width too large, render it anyway if array received is of height too small, render it anyway if array received is of height too large, render it, scroll down, and render the rest of it, then return how much we scrolled down """ for_stdout = self.fmtstr_to_stdout_xform() # caching of write and tc (avoiding the self. lookups etc) made # no significant performance difference here if not self.hide_cursor: self.write(self.t.hide_cursor) # TODO race condition here? height, width = self.t.height, self.t.width if (height != self._last_rendered_height or width != self._last_rendered_width): self.on_terminal_size_change(height, width) current_lines_by_row = {} rows_for_use = list(range(self.top_usable_row, height)) # rows which we have content for and don't require scrolling # TODO rename shared shared = min(len(array), len(rows_for_use)) for row, line in zip(rows_for_use[:shared], array[:shared]): current_lines_by_row[row] = line if line == self._last_lines_by_row.get(row, None): continue self.write(self.t.move(row, 0)) self.write(for_stdout(line)) if len(line) < width: self.write(self.t.clear_eol) # rows already on screen that we don't have content for rest_of_lines = array[shared:] rest_of_rows = rows_for_use[shared:] for row in rest_of_rows: # if array too small if self._last_lines_by_row and row not in self._last_lines_by_row: continue self.write(self.t.move(row, 0)) self.write(self.t.clear_eol) # TODO probably not necessary - is first char cleared? self.write(self.t.clear_bol) current_lines_by_row[row] = None # lines for which we need to scroll down to render offscreen_scrolls = 0 for line in rest_of_lines: # if array too big self.scroll_down() if self.top_usable_row > 0: self.top_usable_row -= 1 else: offscreen_scrolls += 1 current_lines_by_row = dict( (k - 1, v) for k, v in current_lines_by_row.items() ) logger.debug('new top_usable_row: %d' % self.top_usable_row) # since scrolling moves the cursor self.write(self.t.move(height - 1, 0)) self.write(for_stdout(line)) current_lines_by_row[height - 1] = line logger.debug( 'lines in last lines by row: %r' % self._last_lines_by_row.keys() ) logger.debug( 'lines in current lines by row: %r' % current_lines_by_row.keys() ) self._last_cursor_row = max( 0, cursor_pos[0] - offscreen_scrolls + self.top_usable_row ) self._last_cursor_column = cursor_pos[1] self.write( self.t.move(self._last_cursor_row, self._last_cursor_column) ) self._last_lines_by_row = current_lines_by_row if not self.hide_cursor: self.write(self.t.normal_cursor) return offscreen_scrolls def demo(): handler = logging.FileHandler(filename='display.log') logging.getLogger(__name__).setLevel(logging.DEBUG) logging.getLogger(__name__).addHandler(handler) from . import input with FullscreenWindow(sys.stdout) as w: with input.Input(sys.stdin) as input_generator: rows, columns = w.t.height, w.t.width while True: c = input_generator.next() if c == "": sys.exit() # same as raise SystemExit() elif c == "h": a = w.array_from_text("a for small array") elif c == "a": a = [fmtstr(c * columns) for _ in range(rows)] elif c == "s": a = [fmtstr(c * columns) for _ in range(rows - 1)] elif c == "d": a = [fmtstr(c * columns) for _ in range(rows + 1)] elif c == "f": a = [fmtstr(c * columns) for _ in range(rows - 2)] elif c == "q": a = [fmtstr(c * columns) for _ in range(1)] elif c == "w": a = [fmtstr(c * columns) for _ in range(1)] elif c == "e": a = [fmtstr(c * columns) for _ in range(1)] elif c == '\x0c': # ctrl-L [w.write('\n') for _ in range(rows)] continue else: a = w.array_from_text("unknown command") w.render_to_terminal(a) def main(): handler = logging.FileHandler(filename='display.log', level=logging.DEBUG) logging.getLogger(__name__).setLevel(logging.DEBUG) logging.getLogger(__name__).addHandler(handler) print('this should be just off-screen') w = FullscreenWindow(sys.stdout) rows, columns = w.t.height, w.t.width with w: a = [fmtstr( (('.row%r.' % (row,)) * rows)[:columns] ) for row in range(rows)] w.render_to_terminal(a) if __name__ == '__main__': demo() curtsies-0.3.1/curtsies/events.py0000644000076500000240000003014313264721022017157 0ustar tombstaff00000000000000"""Events for keystrokes and other input events""" import sys import time import encodings from functools import wraps PY3 = sys.version_info[0] >= 3 if PY3: raw_input = input unicode = str chr_byte = lambda i: chr(i).encode('latin-1') if PY3 else chr(i) chr_uni = lambda i: chr(i) if PY3 else chr(i).decode('latin-1') CURTSIES_NAMES = {} control_chars = dict((chr_byte(i), u'' % chr(i + 0x60)) for i in range(0x00, 0x1b)) CURTSIES_NAMES.update(control_chars) for i in range(0x00, 0x80): CURTSIES_NAMES[b'\x1b'+chr_byte(i)] = u'' % chr(i) for i in range(0x00, 0x1b): # Overwrite the control keys with better labels CURTSIES_NAMES[b'\x1b'+chr_byte(i)] = u'' % chr(i + 0x40) for i in range(0x00, 0x80): CURTSIES_NAMES[chr_byte(i + 0x80)] = u'' % chr(i) for i in range(0x00, 0x1b): # Overwrite the control keys with better labels CURTSIES_NAMES[chr_byte(i + 0x80)] = u'' % chr(i + 0x40) from .curtsieskeys import CURTSIES_NAMES as special_curtsies_names CURTSIES_NAMES.update(special_curtsies_names) CURSES_NAMES = {} CURSES_NAMES[b'\x1bOP'] = u'KEY_F(1)' CURSES_NAMES[b'\x1bOQ'] = u'KEY_F(2)' CURSES_NAMES[b'\x1bOR'] = u'KEY_F(3)' CURSES_NAMES[b'\x1bOS'] = u'KEY_F(4)' CURSES_NAMES[b'\x1b[15~'] = u'KEY_F(5)' CURSES_NAMES[b'\x1b[17~'] = u'KEY_F(6)' CURSES_NAMES[b'\x1b[18~'] = u'KEY_F(7)' CURSES_NAMES[b'\x1b[19~'] = u'KEY_F(8)' CURSES_NAMES[b'\x1b[20~'] = u'KEY_F(9)' CURSES_NAMES[b'\x1b[21~'] = u'KEY_F(10)' CURSES_NAMES[b'\x1b[23~'] = u'KEY_F(11)' CURSES_NAMES[b'\x1b[24~'] = u'KEY_F(12)' # see bpython #626 CURSES_NAMES[b'\x1b[11~'] = u'KEY_F(1)' CURSES_NAMES[b'\x1b[12~'] = u'KEY_F(2)' CURSES_NAMES[b'\x1b[13~'] = u'KEY_F(3)' CURSES_NAMES[b'\x1b[14~'] = u'KEY_F(4)' CURSES_NAMES[b'\x1b[A'] = u'KEY_UP' CURSES_NAMES[b'\x1b[B'] = u'KEY_DOWN' CURSES_NAMES[b'\x1b[C'] = u'KEY_RIGHT' CURSES_NAMES[b'\x1b[D'] = u'KEY_LEFT' CURSES_NAMES[b'\x1b[F'] = u'KEY_END' # https://github.com/bpython/bpython/issues/490 CURSES_NAMES[b'\x1b[H'] = u'KEY_HOME' # https://github.com/bpython/bpython/issues/490 CURSES_NAMES[b'\x08'] = u'KEY_BACKSPACE' CURSES_NAMES[b'\x1b[Z'] = u'KEY_BTAB' # see curtsies #78 - taken from https://github.com/jquast/blessed/blob/e9ad7b85dfcbbba49010ab8c13e3a5920d81b010/blessed/keyboard.py#L409 CURSES_NAMES[b'\x1b[1~'] = u'KEY_FIND' # find CURSES_NAMES[b'\x1b[2~'] = u'KEY_IC' # insert (0) CURSES_NAMES[b'\x1b[3~'] = u'KEY_DC' # delete (.), "Execute" CURSES_NAMES[b'\x1b[4~'] = u'KEY_SELECT' # select CURSES_NAMES[b'\x1b[5~'] = u'KEY_PPAGE' # pgup (9) CURSES_NAMES[b'\x1b[6~'] = u'KEY_NPAGE' # pgdown (3) CURSES_NAMES[b'\x1b[7~'] = u'KEY_HOME' # home CURSES_NAMES[b'\x1b[8~'] = u'KEY_END' # end CURSES_NAMES[b'\x1b[OA'] = u'KEY_UP' # up (8) CURSES_NAMES[b'\x1b[OB'] = u'KEY_DOWN' # down (2) CURSES_NAMES[b'\x1b[OC'] = u'KEY_RIGHT' # right (6) CURSES_NAMES[b'\x1b[OD'] = u'KEY_LEFT' # left (4) CURSES_NAMES[b'\x1b[OF'] = u'KEY_END' # end (1) CURSES_NAMES[b'\x1b[OH'] = u'KEY_HOME' # home (7) KEYMAP_PREFIXES = set() for table in (CURSES_NAMES, CURTSIES_NAMES): for k in table: if k.startswith(b'\x1b'): for i in range(1, len(k)): KEYMAP_PREFIXES.add(k[:i]) MAX_KEYPRESS_SIZE = max(len(seq) for seq in (list(CURSES_NAMES.keys()) + list(CURTSIES_NAMES.keys()))) class Event(object): pass class ScheduledEvent(Event): """Event scheduled for a future time. args: when (float): unix time in seconds for which this event is scheduled Custom events that occur at a specific time in the future should be subclassed from ScheduledEvent.""" def __init__(self, when): self.when = when class WindowChangeEvent(Event): def __init__(self, rows, columns, cursor_dy=None): self.rows = rows self.columns = columns self.cursor_dy = cursor_dy x = width = property(lambda self: self.columns) y = height = property(lambda self: self.rows) def __repr__(self): return "" % (self.rows, self.columns, '' if self.cursor_dy is None else " cursor_dy: %d" % self.cursor_dy) @property def name(self): return '' class SigIntEvent(Event): """Event signifying a SIGINT""" def __repr__(self): return "" @property def name(self): return repr(self) class PasteEvent(Event): """Multiple keypress events combined, likely from copy/paste. The events attribute contains a list of keypress event strings. """ def __init__(self): self.events = [] def __repr__(self): return "" % self.events @property def name(self): return repr(self) def decodable(seq, encoding): try: u = seq.decode(encoding) except UnicodeDecodeError: return False else: return True def get_key(bytes_, encoding, keynames='curtsies', full=False): """Return key pressed from bytes_ or None Return a key name or None meaning it's an incomplete sequence of bytes (more bytes needed to determine the key pressed) encoding is how the bytes should be translated to unicode - it should match the terminal encoding. keynames is a string describing how keys should be named: * curtsies uses unicode strings like * curses uses unicode strings similar to those returned by the Python ncurses window.getkey function, like KEY_F(8), plus a nonstandard representation of meta keys (bytes 128-255) because returning the corresponding unicode code point would be indistinguishable from the multibyte sequence that encodes that character in the current encoding * bytes returns the original bytes from stdin (NOT unicode) if full, match a key even if it could be a prefix to another key (useful for detecting a plain escape key for instance, since escape is also a prefix to a bunch of char sequences for other keys) Events are subclasses of Event, or unicode strings Precondition: get_key(prefix, keynames) is None for all proper prefixes of bytes. This means get_key should be called on progressively larger inputs (for 'asdf', first on 'a', then on 'as', then on 'asd' - until a non-None value is returned) """ if not all(isinstance(c, type(b'')) for c in bytes_): raise ValueError("get key expects bytes, got %r" % bytes_) # expects raw bytes if keynames not in ['curtsies', 'curses', 'bytes']: raise ValueError("keynames must be one of 'curtsies', 'curses' or 'bytes'") seq = b''.join(bytes_) if len(seq) > MAX_KEYPRESS_SIZE: raise ValueError('unable to decode bytes %r' % seq) def key_name(): if keynames == 'curses': if seq in CURSES_NAMES: # may not be here (and still not decodable) curses names incomplete return CURSES_NAMES[seq] # Otherwise, there's no special curses name for this try: return seq.decode(encoding) # for normal decodable text or a special curtsies sequence with bytes that can be decoded except UnicodeDecodeError: # this sequence can't be decoded with this encoding, so we need to represent the bytes if len(seq) == 1: return u'x%02X' % ord(seq) #TODO figure out a better thing to return here else: raise NotImplementedError("are multibyte unnameable sequences possible?") return u'bytes: ' + u'-'.join(u'x%02X' % ord(seq[i:i+1]) for i in range(len(seq))) #TODO if this isn't possible, return multiple meta keys as a paste event if paste events enabled elif keynames == 'curtsies': if seq in CURTSIES_NAMES: return CURTSIES_NAMES[seq] return seq.decode(encoding) #assumes that curtsies names are a subset of curses ones else: assert keynames == 'bytes' return seq key_known = seq in CURTSIES_NAMES or seq in CURSES_NAMES or decodable(seq, encoding) if full and key_known: return key_name() elif seq in KEYMAP_PREFIXES or could_be_unfinished_char(seq, encoding): return None # need more input to make up a full keypress elif key_known: return key_name() else: seq.decode(encoding) # this will raise a unicode error (they're annoying to raise ourselves) assert False, 'should have raised an unicode decode error' def could_be_unfinished_char(seq, encoding): """Whether seq bytes might create a char in encoding if more bytes were added""" if decodable(seq, encoding): return False # any sensible encoding surely doesn't require lookahead (right?) # (if seq bytes encoding a character, adding another byte shouldn't also encode something) if encodings.codecs.getdecoder('utf8') is encodings.codecs.getdecoder(encoding): return could_be_unfinished_utf8(seq) elif encodings.codecs.getdecoder('ascii') is encodings.codecs.getdecoder(encoding): return False else: return True # We don't know, it could be def could_be_unfinished_utf8(seq): # http://en.wikipedia.org/wiki/UTF-8#Description if ord(seq[0:1]) & 0b10000000 == 0b10000000 and len(seq) < 1: return True elif ord(seq[0:1]) & 0b11100000 == 0b11000000 and len(seq) < 2: return True elif ord(seq[0:1]) & 0b11110000 == 0b11100000 and len(seq) < 3: return True elif ord(seq[0:1]) & 0b11111000 == 0b11110000 and len(seq) < 4: return True elif ord(seq[0:1]) & 0b11111100 == 0b11111000 and len(seq) < 5: return True elif ord(seq[0:1]) & 0b11111110 == 0b11111100 and len(seq) < 6: return True else: return False def pp_event(seq): """Returns pretty representation of an Event or keypress""" if isinstance(seq, Event): return str(seq) # Get the original sequence back if seq is a pretty name already rev_curses = dict((v, k) for k, v in CURSES_NAMES.items()) rev_curtsies = dict((v, k) for k, v in CURTSIES_NAMES.items()) if seq in rev_curses: seq = rev_curses[seq] elif seq in rev_curtsies: seq = rev_curtsies[seq] pretty = curtsies_name(seq) if pretty != seq: return pretty return repr(seq).lstrip('u')[1:-1] def curtsies_name(seq): return CURTSIES_NAMES.get(seq, seq) def try_keys(): print('press a bunch of keys (not at the same time, but you can hit them pretty quickly)') import tty import termios import fcntl import os from .termhelpers import Cbreak def ask_what_they_pressed(seq, Normal): print('Unidentified character sequence!') with Normal: while True: r = raw_input("type 'ok' to prove you're not pounding keys ") if r.lower().strip() == 'ok': break while True: print('Press the key that produced %r again please' % (seq,)) retry = os.read(sys.stdin.fileno(), 1000) if seq == retry: break print("nope, that wasn't it") with Normal: name = raw_input('Describe in English what key you pressed: ') f = open('keylog.txt', 'a') f.write("%r is called %s\n" % (seq, name)) f.close() print('Thanks! Please open an issue at https://github.com/bpython/curtsies/issues') print('or email thomasballinger@gmail.com. Include this terminal history or keylog.txt.') print('You can keep pressing keys') with Cbreak(sys.stdin) as NoCbreak: while True: try: chars = os.read(sys.stdin.fileno(), 1000) print('---') print(repr(chars)) if chars in CURTSIES_NAMES: print(CURTSIES_NAMES[chars]) elif len(chars) == 1: print('literal') else: print('unknown!!!') ask_what_they_pressed(chars, NoCbreak) except OSError: pass if __name__ == '__main__': try_keys() curtsies-0.3.1/curtsies/formatstringarray.py0000644000076500000240000002004612664351345021444 0ustar tombstaff00000000000000from __future__ import unicode_literals """ Format String 2D array 2d array for compositing term-formated strings -autoexpanding vertically -interesting get_item behavior (renders fmtstrs) -caching behavior eventually >>> a = FSArray(10, 14) >>> a.shape (10, 14) >>> a[1] = 'i' >>> a[3:4, :] = ['i' * 14] >>> a[16:17, :] = ['j' * 14] >>> a.shape, a[16, 0] ((17, 14), ['j']) >>> a[200, 1] = ['i'] >>> a[200, 1] ['i'] """ import sys import logging import unittest from .formatstring import fmtstr from .formatstring import normalize_slice from .formatstring import FmtStr logger = logging.getLogger(__name__) #TODO check that strings used in arrays don't have tabs or spaces in them! def slicesize(s): return int((s.stop - s.start) / (s.step if s.step else 1)) def fsarray(strings, *args, **kwargs): """fsarray(list_of_FmtStrs_or_strings, width=None) -> FSArray Returns a new FSArray of width of the maximum size of the provided strings, or width provided, and height of the number of strings provided. If a width is provided, raises a ValueError if any of the strings are of length greater than this width""" strings = list(strings) if 'width' in kwargs: width = kwargs['width'] del kwargs['width'] if strings and max(len(s) for s in strings) > width: raise ValueError("Those strings won't fit for width %d" % width) else: width = max(len(s) for s in strings) if strings else 0 fstrings = [s if isinstance(s, FmtStr) else fmtstr(s, *args, **kwargs) for s in strings] arr = FSArray(len(fstrings), width, *args, **kwargs) rows = [fs.setslice_with_length(0, len(s), s, width) for fs, s in zip(arr.rows, fstrings)] arr.rows = rows return arr class FSArray(object): """A 2D array of colored text. Internally represented by a list of FmtStrs of identical size.""" #TODO add constructor that takes fmtstrs instead of dims def __init__(self, num_rows, num_columns, *args, **kwargs): self.saved_args, self.saved_kwargs = args, kwargs self.rows = [fmtstr('', *args, **kwargs) for _ in range(num_rows)] self.num_columns = num_columns def __getitem__(self, slicetuple): if isinstance(slicetuple, int): if slicetuple < 0: slicetuple = len(self.rows) - slicetuple if slicetuple < 0 or slicetuple >= len(self.rows): raise IndexError('out of bounds') return self.rows[slicetuple] if isinstance(slicetuple, slice): rowslice = normalize_slice(len(self.rows), slicetuple) return self.rows[rowslice] rowslice, colslice = slicetuple rowslice = normalize_slice(len(self.rows), rowslice) colslice = normalize_slice(self.num_columns, colslice) #TODO clean up slices return [fs[colslice] for fs in self.rows[rowslice]] def __len__(self): return len(self.rows) @property def shape(self): """tuple of (len(rows, len(num_columns)) numpy-style shape""" return len(self.rows), self.num_columns height = property(lambda self: len(self.rows), None, None, """The number of rows""") width = property(lambda self: self.num_columns, None, None, """The number of columns""") def __setitem__(self, slicetuple, value): """Place a FSArray in a FSArray""" logger.debug('slice: %r', slicetuple) if isinstance(slicetuple, slice): rowslice, colslice = slicetuple, slice(None) if isinstance(value, (bytes, unicode)): raise ValueError('if slice is 2D, value must be 2D') elif isinstance(slicetuple, int): normalize_slice(self.height, slicetuple) self.rows[slicetuple] = value return else: rowslice, colslice = slicetuple # temp shim to allow numpy arrays as values if value.__class__.__name__ == 'ndarray': value = [fmtstr(''.join(line)) for line in value] rowslice = normalize_slice(sys.maxsize, rowslice) additional_rows = max(0, rowslice.stop - len(self.rows)) self.rows.extend([fmtstr('', *self.saved_args, **self.saved_kwargs) for _ in range(additional_rows)]) logger.debug('num columns: %r', self.num_columns) logger.debug('colslice: %r', colslice) colslice = normalize_slice(self.num_columns, colslice) if slicesize(colslice) == 0 or slicesize(rowslice) == 0: return if slicesize(rowslice) != len(value): raise ValueError('row dimensions do not match: %r, %r' % (len(value), rowslice)) self.rows = (self.rows[:rowslice.start] + [fs.setslice_with_length(colslice.start, colslice.stop, v, self.num_columns) for fs, v in zip(self.rows[rowslice], value)] + self.rows[rowslice.stop:]) def dumb_display(self): """Prints each row followed by a newline without regard for the terminal window size""" for line in self.rows: print(line) @classmethod def diff(cls, a, b, ignore_formatting=False): """Returns two FSArrays with differences underlined""" def underline(x): return u'\x1b[4m%s\x1b[0m' % (x,) def blink(x): return u'\x1b[5m%s\x1b[0m' % (x,) a_rows = [] b_rows = [] max_width = max([len(row) for row in a] + [len(row) for row in b]) a_lengths = [] b_lengths = [] for a_row, b_row in zip(a, b): a_lengths.append(len(a_row)) b_lengths.append(len(b_row)) extra_a = u'`' * (max_width - len(a_row)) extra_b = u'`' * (max_width - len(b_row)) a_line = u'' b_line = u'' for a_char, b_char in zip(a_row + extra_a, b_row + extra_b): if ignore_formatting: a_char_for_eval = a_char.s if isinstance(a_char, FmtStr) else a_char b_char_for_eval = b_char.s if isinstance(b_char, FmtStr) else b_char else: a_char_for_eval = a_char b_char_for_eval = b_char if a_char_for_eval == b_char_for_eval: a_line += actualize(a_char) b_line += actualize(b_char) else: a_line += underline(blink(actualize(a_char))) b_line += underline(blink(actualize(b_char))) a_rows.append(a_line) b_rows.append(b_line) hdiff = '\n'.join(a_line + u' %3d | %3d ' % (a_len, b_len) + b_line for a_line, b_line, a_len, b_len in zip(a_rows, b_rows, a_lengths, b_lengths)) return hdiff actualize = str if sys.version_info[0] == 3 else unicode def simple_format(x): return '\n'.join(actualize(l) for l in x) class FormatStringTest(unittest.TestCase): def assertFSArraysEqual(self, a, b): self.assertEqual(type(a), FSArray) self.assertEqual(type(b), FSArray) self.assertEqual((a.width, b.height), (a.width, b.height), 'fsarray dimensions do not match: %s %s' % (a.shape, b.shape)) for i, (a_row, b_row) in enumerate(zip(a, b)): self.assertEqual(a_row, b_row, 'FSArrays differ first on line %s:\n%s' % (i, FSArray.diff(a, b))) def assertFSArraysEqualIgnoringFormatting(self, a, b): """Also accepts arrays of strings""" self.assertEqual(len(a), len(b), 'fsarray heights do not match: %s %s \n%s \n%s' % (len(a), len(b), simple_format(a), simple_format(b))) for i, (a_row, b_row) in enumerate(zip(a, b)): a_row = a_row.s if isinstance(a_row, FmtStr) else a_row b_row = b_row.s if isinstance(b_row, FmtStr) else b_row self.assertEqual(a_row, b_row, 'FSArrays differ first on line %s:\n%s' % (i, FSArray.diff(a, b, ignore_formatting=True))) if __name__ == '__main__': a = FSArray(3, 14, bg='blue') a[0:2, 5:11] = fmtstr("hey", 'on_blue') + ' ' + fmtstr('yo', 'on_red'), fmtstr('qwe qw') a.dumb_display() a = fsarray(['hey', 'there'], bg='cyan') a.dumb_display() print(FSArray.diff(a, fsarray(['hey', 'there ']), ignore_formatting=True)) curtsies-0.3.1/curtsies/termhelpers.py0000644000076500000240000000224512664351343020217 0ustar tombstaff00000000000000import tty import termios import fcntl import os class Nonblocking(object): def __init__(self, stream): self.stream = stream self.fd = self.stream.fileno() def __enter__(self): self.orig_fl = fcntl.fcntl(self.fd, fcntl.F_GETFL) fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl | os.O_NONBLOCK) def __exit__(self, *args): fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl) class Cbreak(object): def __init__(self, stream): self.stream = stream def __enter__(self): self.original_stty = termios.tcgetattr(self.stream) tty.setcbreak(self.stream, termios.TCSANOW) return Termmode(self.stream, self.original_stty) def __exit__(self, *args): termios.tcsetattr(self.stream, termios.TCSANOW, self.original_stty) class Termmode(object): def __init__(self, stream, attrs): self.stream = stream self.attrs = attrs def __enter__(self): self.original_stty = termios.tcgetattr(self.stream) termios.tcsetattr(self.stream, termios.TCSANOW, self.attrs) def __exit__(self, *args): termios.tcsetattr(self.stream, termios.TCSANOW, self.original_stty) curtsies-0.3.1/curtsies/__init__.py0000644000076500000240000000043013604021125017401 0ustar tombstaff00000000000000"""Terminal-formatted strings""" __version__='0.3.1' from .window import FullscreenWindow, CursorAwareWindow from .input import Input from .termhelpers import Nonblocking, Cbreak, Termmode from .formatstring import FmtStr, fmtstr from .formatstringarray import FSArray, fsarray curtsies-0.3.1/curtsies/configfile_keynames.py0000644000076500000240000000165312664351345021672 0ustar tombstaff00000000000000"""Mapping of config file names of keys to curtsies names In the style of bpython config files and keymap""" SPECIALS = { 'C-[': u'', 'C-^': u'', 'C-_': u'', } #TODO make a precalculated version of this class KeyMap(object): """Maps config file key syntax to Curtsies names""" def __getitem__(self, key): if not key: # Unbound key return () elif key in SPECIALS: return (SPECIALS[key],) elif key[1:] and key[:2] == 'C-': return (u'' % key[2:],) elif key[1:] and key[:2] == 'M-': return (u'' % key[2:], u'' % key[2:],) elif key[0] == 'F' and key[1:].isdigit(): return (u'' % int(key[1:]),) else: raise KeyError('Configured keymap (%s)' % key + ' does not exist in bpython.keys') keymap = KeyMap() curtsies-0.3.1/curtsies/input.py0000644000076500000240000002766313437060273017035 0ustar tombstaff00000000000000 import locale import os import signal import select import sys import termios import threading import time import tty import logging logger = logging.getLogger(__name__) from .termhelpers import Nonblocking from . import events PY3 = sys.version_info[0] >= 3 READ_SIZE = 1024 assert READ_SIZE >= events.MAX_KEYPRESS_SIZE # if a keypress could require more bytes than we read to be identified, # the paste logic that reads more data as needed might not work. def is_main_thread(): return isinstance(threading.current_thread(), threading._MainThread) class ReplacedSigIntHandler(object): def __init__(self, handler): self.handler = handler def __enter__(self): self.orig_sigint_handler = signal.getsignal(signal.SIGINT) signal.signal(signal.SIGINT, self.handler) def __exit__(self, type, value, traceback): signal.signal(signal.SIGINT, self.orig_sigint_handler) class Input(object): """Keypress and control event generator""" def __init__(self, in_stream=None, keynames='curtsies', paste_threshold=events.MAX_KEYPRESS_SIZE+1, sigint_event=False, signint_callback_provider=None, disable_terminal_start_stop=False): """Returns an Input instance. Args: in_stream (file): Defaults to sys.__stdin__ keynames (string): How keypresses should be named - one of 'curtsies', 'curses', or 'plain'. paste_threshold (int): How many bytes must be read in one os.read on the in_stream to trigger the keypresses they represent to be combined into a single paste event sigint_event (bool): Whether SIGINT signals from the OS should be intercepted and returned as SigIntEvent objects disable_terminal_start_stop (bool): If True, disable terminal start/stop using Ctrl-s/Ctrl-q, thus enabling these keys to be read as input by curtsies """ if in_stream is None: in_stream = sys.__stdin__ self.in_stream = in_stream self.unprocessed_bytes = [] # leftover from stdin, unprocessed yet self.keynames = keynames self.paste_threshold = paste_threshold self.sigint_event = sigint_event self.disable_terminal_start_stop = disable_terminal_start_stop self.sigints = [] self.readers = [] self.queued_interrupting_events = [] self.queued_events = [] self.queued_scheduled_events = [] # prospective: this could be useful for an external select loop def fileno(self): return self.in_stream.fileno() def __enter__(self): self.original_stty = termios.tcgetattr(self.in_stream) tty.setcbreak(self.in_stream, termios.TCSANOW) if self.disable_terminal_start_stop: attrs = termios.tcgetattr(self.in_stream) attrs[-1][termios.VSTOP] = 0 # Ctrl-s attrs[-1][termios.VSTART] = 0 # Ctrl-q termios.tcsetattr(self.in_stream, termios.TCSANOW, attrs) if sys.platform == 'darwin': attrs = termios.tcgetattr(self.in_stream) VDSUSP = termios.VSUSP + 1 attrs[-1][VDSUSP] = 0 termios.tcsetattr(self.in_stream, termios.TCSANOW, attrs) if self.sigint_event and is_main_thread(): self.orig_sigint_handler = signal.getsignal(signal.SIGINT) signal.signal(signal.SIGINT, self.sigint_handler) return self def __exit__(self, type, value, traceback): if self.sigint_event and is_main_thread(): signal.signal(signal.SIGINT, self.orig_sigint_handler) termios.tcsetattr(self.in_stream, termios.TCSANOW, self.original_stty) def sigint_handler(self, signum, frame): self.sigints.append(events.SigIntEvent()) def __iter__(self): return self def next(self): return self.send(None) __next__ = next def unget_bytes(self, string): """Adds bytes to be internal buffer to be read This method is for reporting bytes from an in_stream read not initiated by this Input object""" self.unprocessed_bytes.extend(string[i:i + 1] for i in range(len(string))) def _wait_for_read_ready_or_timeout(self, timeout): """Returns tuple of whether stdin is ready to read and an event. If an event is returned, that event is more pressing than reading bytes on stdin to create a keyboard input event. If stdin is ready, either there are bytes to read or a SIGTSTP triggered by dsusp has been received""" remaining_timeout = timeout t0 = time.time() while True: try: (rs, _, _) = select.select( [self.in_stream.fileno()] + self.readers, [], [], remaining_timeout) if not rs: return False, None r = rs[0] # if there's more than one, get it in the next loop if r == self.in_stream.fileno(): return True, None else: os.read(r, 1024) if self.queued_interrupting_events: return False, self.queued_interrupting_events.pop(0) elif remaining_timeout is not None: remaining_timeout = max(0, t0 + timeout - time.time()) continue else: continue except select.error: if self.sigints: return False, self.sigints.pop() if remaining_timeout is not None: remaining_timeout = max(timeout - (time.time() - t0), 0) def send(self, timeout=None): """Returns an event or None if no events occur before timeout.""" if self.sigint_event and is_main_thread(): with ReplacedSigIntHandler(self.sigint_handler): return self._send(timeout) else: return self._send(timeout) def _send(self, timeout): def find_key(): """Returns keypress identified by adding unprocessed bytes or None""" current_bytes = [] while self.unprocessed_bytes: current_bytes.append(self.unprocessed_bytes.pop(0)) e = events.get_key(current_bytes, getpreferredencoding(), keynames=self.keynames, full=len(self.unprocessed_bytes)==0) if e is not None: self.current_bytes = [] return e if current_bytes: # incomplete keys shouldn't happen raise ValueError("Couldn't identify key sequence: %r" % self.current_bytes) if self.sigints: return self.sigints.pop() if self.queued_events: return self.queued_events.pop(0) if self.queued_interrupting_events: return self.queued_interrupting_events.pop(0) if self.queued_scheduled_events: self.queued_scheduled_events.sort() #TODO use a data structure that inserts sorted when, _ = self.queued_scheduled_events[0] if when < time.time(): logger.warning('popping an event! %r %r', self.queued_scheduled_events[0], self.queued_scheduled_events[1:]) return self.queued_scheduled_events.pop(0)[1] else: time_until_check = min(max(0, when - time.time()), timeout if timeout is not None else sys.maxsize) else: time_until_check = timeout # try to find an already pressed key from prev input e = find_key() if e is not None: return e stdin_ready_for_read, event = self._wait_for_read_ready_or_timeout(time_until_check) if event: return event if self.queued_scheduled_events and when < time.time(): # when should always be defined # because queued_scheduled_events should not be modified during this time logger.warning('popping an event! %r %r', self.queued_scheduled_events[0], self.queued_scheduled_events[1:]) return self.queued_scheduled_events.pop(0)[1] if not stdin_ready_for_read: return None num_bytes = self._nonblocking_read() if num_bytes == 0: # thought stdin was ready, but not bytes to read is triggered # when SIGTSTP was send by dsusp return None if self.paste_threshold is not None and num_bytes > self.paste_threshold: paste = events.PasteEvent() while True: if len(self.unprocessed_bytes) < events.MAX_KEYPRESS_SIZE: self._nonblocking_read() # may need to read to get the rest of a keypress e = find_key() if e is None: return paste else: paste.events.append(e) else: e = find_key() assert e is not None return e def _nonblocking_read(self): """Returns the number of characters read and adds them to self.unprocessed_bytes""" with Nonblocking(self.in_stream): if PY3: try: data = os.read(self.in_stream.fileno(), READ_SIZE) except BlockingIOError: return 0 if data: self.unprocessed_bytes.extend(data[i:i+1] for i in range(len(data))) return len(data) else: return 0 else: try: data = os.read(self.in_stream.fileno(), READ_SIZE) except OSError: return 0 else: self.unprocessed_bytes.extend(data) return len(data) def event_trigger(self, event_type): """Returns a callback that creates events. Returned callback function will add an event of type event_type to a queue which will be checked the next time an event is requested.""" def callback(**kwargs): self.queued_events.append(event_type(**kwargs)) return callback def scheduled_event_trigger(self, event_type): """Returns a callback that schedules events for the future. Returned callback function will add an event of type event_type to a queue which will be checked the next time an event is requested.""" def callback(when, **kwargs): self.queued_scheduled_events.append((when, event_type(when=when, **kwargs))) return callback def threadsafe_event_trigger(self, event_type): """Returns a callback to creates events, interrupting current event requests. Returned callback function will create an event of type event_type which will interrupt an event request if one is concurrently occuring, otherwise adding the event to a queue that will be checked on the next event request.""" readfd, writefd = os.pipe() self.readers.append(readfd) def callback(**kwargs): self.queued_interrupting_events.append(event_type(**kwargs)) #TODO use a threadsafe queue for this logger.warning('added event to events list %r', self.queued_interrupting_events) os.write(writefd, b'interrupting event!') return callback def getpreferredencoding(): return locale.getpreferredencoding() or sys.getdefaultencoding() def main(): with Input() as input_generator: print(repr(input_generator.send(2))) print(repr(input_generator.send(1))) print(repr(input_generator.send(.5))) print(repr(input_generator.send(.2))) for e in input_generator: print(repr(e)) if __name__ == '__main__': main() curtsies-0.3.1/curtsies/termformatconstants.py0000644000076500000240000000113213437060273021772 0ustar tombstaff00000000000000"""Constants for terminal formatting""" colors = 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'gray' FG_COLORS = dict(list(zip(colors, list(range(30, 38))))) BG_COLORS = dict(list(zip(colors, list(range(40, 48))))) STYLES = dict(list(zip(('bold', 'dark', 'underline', 'blink', 'invert'), [1,2,4,5,7]))) FG_NUMBER_TO_COLOR = dict(zip(FG_COLORS.values(), FG_COLORS.keys())) BG_NUMBER_TO_COLOR = dict(zip(BG_COLORS.values(), BG_COLORS.keys())) NUMBER_TO_STYLE = dict(zip(STYLES.values(), STYLES.keys())) RESET_ALL = 0 RESET_FG = 39 RESET_BG = 49 def seq(num): return '[%sm' % num curtsies-0.3.1/curtsies/curtsieskeys.py0000644000076500000240000000742613264721022020420 0ustar tombstaff00000000000000"""All the key sequences""" # If you add a binding, add something about your setup # if you can figure out why it's different # Special names are for multi-character keys, or key names # that would be hard to write in a config file #TODO add PAD keys hack as in bpython.cli CURTSIES_NAMES = dict([ (b' ', u''), (b'\x1b ', u''), (b'\t', u''), (b'\x1b[Z', u''), (b'\x1b[A', u''), (b'\x1b[B', u''), (b'\x1b[C', u''), (b'\x1b[D', u''), (b'\x1bOA', u''), # in issue 92 its shown these should be normal arrows, (b'\x1bOB', u''), # not ctrl-arrows as we previously had them. (b'\x1bOC', u''), (b'\x1bOD', u''), (b'\x1b[1;5A', u''), (b'\x1b[1;5B', u''), (b'\x1b[1;5C', u''), # reported by myint (b'\x1b[1;5D', u''), # reported by myint (b'\x1b[5A', u''), # not sure about these, someone wanted them for bpython (b'\x1b[5B', u''), (b'\x1b[5C', u''), (b'\x1b[5D', u''), (b'\x1b[1;9A', u''), (b'\x1b[1;9B', u''), (b'\x1b[1;9C', u''), (b'\x1b[1;9D', u''), (b'\x1b[1;10A', u''), (b'\x1b[1;10B', u''), (b'\x1b[1;10C', u''), (b'\x1b[1;10D', u''), (b'\x1bOP', u''), (b'\x1bOQ', u''), (b'\x1bOR', u''), (b'\x1bOS', u''), # see bpython #626 (b'\x1b[11~', u''), (b'\x1b[12~', u''), (b'\x1b[13~', u''), (b'\x1b[14~', u''), (b'\x1b[15~', u''), (b'\x1b[17~', u''), (b'\x1b[18~', u''), (b'\x1b[19~', u''), (b'\x1b[20~', u''), (b'\x1b[21~', u''), (b'\x1b[23~', u''), (b'\x1b[24~', u''), (b'\x00', u''), (b'\x1c', u''), (b'\x1d', u''), (b'\x1e', u''), (b'\x1f', u''), (b'\x7f', u''), # for some folks this is ctrl-backspace apparently (b'\x1b\x7f', u''), (b'\xff', u''), (b'\x1b\x1b[A', u''), # uncertain about these four (b'\x1b\x1b[B', u''), (b'\x1b\x1b[C', u''), (b'\x1b\x1b[D', u''), (b'\x1b', u''), (b'\x1b[1~', u''), (b'\x1b[4~', u''), (b'\x1b\x1b[5~',u''), (b'\x1b\x1b[6~',u''), (b'\x1b[H', u''), # reported by amorozov in bpython #490 (b'\x1b[F', u''), # reported by amorozov in bpython #490 (b'\x1bOH', u''), # reported by mixmastamyk in curtsies #78 (b'\x1bOF', u''), # reported by mixmastamyk in curtsies #78 # not fixing for back compat. # (b"\x1b[1~", u''), # find (b"\x1b[2~", u''), # insert (0) (b"\x1b[3~", u''), # delete (.), "Execute" (b"\x1b[3;5~", u''), # not fixing for back compat. # (b"\x1b[4~", u'