diff --git a/.gitignore b/.gitignore index 8fd1a44..51c6824 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.swp *.pyc +*.egg-info MANIFEST dist/ diff --git a/__init__.py b/__init__.py deleted file mode 100644 index a202138..0000000 --- a/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from termenu import * diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/app1.py b/examples/app1.py new file mode 100644 index 0000000..0b553d9 --- /dev/null +++ b/examples/app1.py @@ -0,0 +1,83 @@ +import time +from termenu.app import AppMenu +from functools import reduce +try: + input = raw_input +except NameError: + pass + + +class TopMenu(AppMenu): + title = staticmethod(lambda: "YELLOW<<%s>>" % time.ctime()) + timeout = 15 + submenus = ["Empty", "Letters", "Numbers", "Submenu", "Foo", "Bar"] + + class Empty(AppMenu): + title = "CYAN(BLUE)<>" + option_name = "BLUE<>" + items = [] + def action(self, letters): + input("Selected: %s" % "".join(letters)) + + class Letters(AppMenu): + title = "CYAN(BLUE)<>" + option_name = "BLUE<>" + multiselect = True + items = [chr(i) for i in range(65, 91)] + def action(self, letters): + input("Selected: %s" % "".join(letters)) + + class Numbers(AppMenu): + multiselect = True + @property + def items(self): + return list(range(int(time.time()*2) % 10, 50)) + def get_selection_actions(self, selection): + yield "MinMax" + yield "Add" + if min(selection) > 0: + yield "Multiply" + yield "Quit" + def get_selection_title(self, selection): + return ", ".join(map(str, sorted(selection))) + def MinMax(self, numbers): + "Min/Max" + input("Min: %s, Max: %s" % (min(numbers), max(numbers))) + self.retry() + def Add(self, numbers): + input("Sum: %s" % sum(numbers)) + self.back() + def Multiply(self, numbers): + input("Mult: %s" % reduce((lambda a, b: a*b), numbers)) + def Quit(self, numbers): + input("%s" % numbers) + self.quit() + + + class Submenu(AppMenu): + submenus = ["Letter", "Number"] + + class Letter(AppMenu): + @property + def items(self): + return [chr(i) for i in range(65, 91)][int(time.time()*2) % 10:][:10] + def action(self, letter): + input("Selected: %s" % letter) + self.back() + + class Number(AppMenu): + items = list(range(50)) + def action(self, number): + input("Sum: %s" % number) + + def Foo(self): + input("Foo?") + + def Bar(object): + input("Bar! ") + + Bar.get_option_name = lambda: "Dynamic option name: (%s)" % (int(time.time()) % 20) + + +if __name__ == "__main__": + TopMenu() diff --git a/examples/app2.py b/examples/app2.py new file mode 100644 index 0000000..ea78f4b --- /dev/null +++ b/examples/app2.py @@ -0,0 +1,29 @@ +import time +from termenu.app import AppMenu + +def leave(): + print("Leave...") + AppMenu.quit() + +def go(): + def back(): + print("Going back.") + AppMenu.back() + + def there(): + ret = AppMenu.show("Where's there?", + "Spain France Albania".split() + [("Quit", AppMenu.quit)], + multiselect=True, back_on_abort=True) + print(ret) + return ret + + return AppMenu.show("Go Where?", [ + ("YELLOW<>", back), + ("GREEN<>", there) + ]) + +if __name__ == "__main__": + AppMenu.show("Make your MAGENTA<>", [ + ("RED<>", leave), + ("BLUE<>", go) + ]) \ No newline at end of file diff --git a/examples/filemenu.py b/examples/filemenu.py index 5c307fb..1c5e35c 100644 --- a/examples/filemenu.py +++ b/examples/filemenu.py @@ -1,6 +1,5 @@ import os import sys -sys.path.insert(0, "..") import termenu """ @@ -62,7 +61,7 @@ def main(): os.chdir(selected[0]) else: for file in selected: - print >>redirectedStdout, os.path.abspath(file) + print(os.path.abspath(file), file=redirectedStdout) return else: return diff --git a/examples/loading_menu.py b/examples/loading_menu.py index 5c6414f..56a909d 100644 --- a/examples/loading_menu.py +++ b/examples/loading_menu.py @@ -56,7 +56,7 @@ def _print_menu(self): return super(TitleCounterPlugin, self)._print_menu() def data(size): - for i in xrange(size): + for i in range(size): yield i time.sleep(0.05) diff --git a/examples/paged_menu.py b/examples/paged_menu.py index ff85d4c..ee9ff72 100644 --- a/examples/paged_menu.py +++ b/examples/paged_menu.py @@ -13,16 +13,18 @@ def __init__(self, iter): self._list = [] def __getitem__(self, index): + if isinstance(index, slice): + return self.__slice__(index.start, index.stop, index.step) try: while index >= len(self._list): - self._list.append(self._iter.next()) + self._list.append(next(self._iter)) except StopIteration: pass return self._list[index] - def __slice__(self, i, j): + def __slice__(self, i, j, k=None): self[j] - return self._list[i:j] + return self._list[i:j:k] def show_long_menu(optionsIter, pagesize=30): Next = object() @@ -48,4 +50,4 @@ def show_long_menu(optionsIter, pagesize=30): return result if __name__ == "__main__": - show_long_menu(xrange(500)) + show_long_menu(range(500)) diff --git a/keyboard.py b/keyboard.py deleted file mode 100644 index af02b44..0000000 --- a/keyboard.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import with_statement -from __future__ import print_function - -import os -import sys -import fcntl -import termios -import select -import errno - -STDIN = sys.stdin.fileno() - -ANSI_SEQUENCES = dict( - up = '\x1b[A', - down = '\x1b[B', - right = '\x1b[C', - left = '\x1b[D', - home = '\x1bOH', - end = '\x1bOF', - insert = '\x1b[2~', - delete = '\x1b[3~', - pageUp = '\x1b[5~', - pageDown = '\x1b[6~', - F1 = '\x1bOP', - F2 = '\x1bOQ', - F3 = '\x1bOR', - F4 = '\x1bOS', - F5 = '\x1b[15~', - F6 = '\x1b[17~', - F7 = '\x1b[18~', - F8 = '\x1b[19~', - F9 = '\x1b[20~', - F10 = '\x1b[21~', - F11 = '\x1b[23~', - F12 = '\x1b[24~', -) - -KEY_NAMES = dict((v,k) for k,v in ANSI_SEQUENCES.items()) -KEY_NAMES.update({ - '\x1b' : 'esc', - '\n' : 'enter', - ' ' : 'space', - '\x7f' : 'backspace', -}) - -class RawTerminal(object): - def __init__(self, blocking=True): - self._blocking = blocking - - def open(self): - # Set raw mode - self._oldterm = termios.tcgetattr(STDIN) - newattr = termios.tcgetattr(STDIN) - newattr[3] = newattr[3] & ~termios.ICANON & ~termios.ECHO - termios.tcsetattr(STDIN, termios.TCSANOW, newattr) - - # Set non-blocking IO on stdin - self._old = fcntl.fcntl(STDIN, fcntl.F_GETFL) - if not self._blocking: - fcntl.fcntl(STDIN, fcntl.F_SETFL, self._old | os.O_NONBLOCK) - - def close(self): - # Restore previous terminal mode - termios.tcsetattr(STDIN, termios.TCSAFLUSH, self._oldterm) - fcntl.fcntl(STDIN, fcntl.F_SETFL, self._old) - - def get(self): - return sys.stdin.read(1) - - def wait(self): - select.select([STDIN], [], []) - - def __enter__(self): - self.open() - return self - - def __exit__(self, *args): - self.close() - -def keyboard_listener(heartbeat=None): - with RawTerminal(blocking=False) as terminal: - # return keys - sequence = "" - while True: - yielded = False - # wait for keys to become available - select.select([STDIN], [], [], heartbeat) - # read all available keys - while True: - try: - sequence = sequence + terminal.get() - except IOError as e: - if e.errno == errno.EAGAIN: - break - # handle ANSI key sequences - while sequence: - for seq in ANSI_SEQUENCES.values(): - if sequence[:len(seq)] == seq: - yield KEY_NAMES[seq] - yielded = True - sequence = sequence[len(seq):] - break - # handle normal keys - else: - for key in sequence: - yield KEY_NAMES.get(key, key) - yielded = True - sequence = "" - if not yielded: - yield "heartbeat" - -if __name__ == "__main__": - for key in keyboard_listener(0.5): - print(key) - diff --git a/setup.py b/setup.py index 631d0f6..fcbc0cb 100755 --- a/setup.py +++ b/setup.py @@ -16,8 +16,8 @@ # You should have received a copy of the GNU General Public License # along with termenu. If not, see . -from distutils.core import setup -from version import version +from setuptools import setup +from os import path DESCRIPTION = """ Termenu is a command line utility and Python library for displaying console @@ -29,6 +29,8 @@ allow a modicum of interactivity in regular command line utilities. """ +version = open(path.join(path.dirname(path.abspath(__file__)), 'version'), 'r').read().strip() + setup( name='termenu', version=version, @@ -40,7 +42,6 @@ url='https://github.com/gooli/termenu', package_dir={'termenu':'.'}, packages=['termenu'], - scripts=['termenu'], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', @@ -52,4 +53,3 @@ 'Topic :: Terminals' ] ) - diff --git a/termenu b/termenu-cmd similarity index 99% rename from termenu rename to termenu-cmd index 26181f2..77989fa 100755 --- a/termenu +++ b/termenu-cmd @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import re import sys import termenu diff --git a/termenu/__init__.py b/termenu/__init__.py new file mode 100644 index 0000000..a0df2d2 --- /dev/null +++ b/termenu/__init__.py @@ -0,0 +1 @@ +from .termenu import * diff --git a/ansi.py b/termenu/ansi.py similarity index 65% rename from ansi.py rename to termenu/ansi.py index 90693ef..3193d81 100644 --- a/ansi.py +++ b/termenu/ansi.py @@ -1,22 +1,63 @@ -from __future__ import print_function - +import os import errno import sys import re COLORS = dict(black=0, red=1, green=2, yellow=3, blue=4, magenta=5, cyan=6, white=7, default=9) -def write(s): + +if sys.platform == "darwin": + # On Mac, partition to ansi escape characters and regular characters. + # For the regular characters write at once, for escape one by one. + def partition_ansi(s): + ansi_escape = re.compile(r'(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]') + spans = (m.span() for m in ansi_escape.finditer(s)) + last_end = end = 0 + for start, end in spans: + if start > last_end: + chunk = s[last_end:start] + yield chunk + for c in s[start:end]: + yield c + last_end = end + + remainder = s[end:] + if remainder: + yield remainder +else: + def partition_ansi(s): + yield s + + +def stdout_write(s): + fd = sys.stdout.fileno() + for text in partition_ansi(s): + written = 0 + size = len(text) + while written < size: + remains = text[written:written+size].encode("utf8") + try: + written += os.write(fd, remains) + except OSError as e: + if e.errno != errno.EAGAIN: + raise + pass + + +def write(text): def _retry(func, *args): - while True: + attempts = 5 + while attempts: try: func(*args) - except IOError, e: + except IOError as e: if e.errno != errno.EAGAIN: raise + attempts -= 1 else: break - _retry(sys.stdout.write, s) + + stdout_write(text) _retry(sys.stdout.flush) def up(n=1): @@ -37,6 +78,9 @@ def move_horizontal(column=1): def move(row, column): write("\x1b[%d;%dH" % (row, column)) +def home(): + write("\x1b[H") + def clear_screen(): write("\x1b[2J") @@ -106,7 +150,7 @@ def decolorize(self): if __name__ == "__main__": # Print all colors - colors = [name for name, color in sorted(COLORS.items(), key=lambda v: v[1])] + colors = [name for name, color in sorted(list(COLORS.items()), key=lambda v: v[1])] for bright in [False, True]: for background in colors: for color in colors: diff --git a/termenu/app.py b/termenu/app.py new file mode 100644 index 0000000..d8b2dfe --- /dev/null +++ b/termenu/app.py @@ -0,0 +1,748 @@ +import sys +import re +import time +import functools +import signal +from textwrap import dedent +from . import termenu, keyboard +from contextlib import contextmanager, ExitStack +from . import ansi +from .colors import Colorized, uncolorize +import collections + + +class ParamsException(Exception): + "An exception object that accepts arbitrary params as attributes" + def __init__(self, message="", *args, **kwargs): + if args: + message %= args + self.message = message + for k, v in kwargs.items(): + setattr(self, k, v) + self.params = kwargs + + +NoneType = type(None) + +import os + +DEFAULT_CONFIG = """ +# WHITE<> has created for you this default configuration file. +# You can modify it to control which glyphs are used in termenu apps, to improve the readability +# and usuability of these apps. This depends on the terminal you use. +# This could be helpful: CYAN<> + +SCROLL_UP_MARKER = "^" # consider 🢁 +SCROLL_DOWN_MARKER = "V" # consider 🢃 +ACTIVE_ITEM_MARKER = " WHITE@{>}@" # consider 🞂 +SELECTED_ITEM_MARKER = "WHITE@{*}@" # consider ⚫ +SELECTABLE_ITEM_MARKER = "-" # consider ⚪ +CONTINUATION_SUFFIX = "DARK_RED@{↩}@" # for when a line overflows +CONTINUATION_PREFIX = "DARK_RED@{↪}@" # for when a line overflows +""" + +CFG_PATH = os.path.expanduser("~/.termenu/app_chars.py") +app_chars = DEFAULT_CONFIG + +try: + with open(CFG_PATH) as f: + app_chars = f.read() +except PermissionError: + pass +except FileNotFoundError: + try: + os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True) + with open(CFG_PATH, "w") as f: + f.write(DEFAULT_CONFIG) + except (OSError, PermissionError): + pass + else: + if sys.__stdin__.isatty(): + os.system("clear") + print(Colorized(dedent(""" + + WHITE<<~/.termenu/app_chars.py:>> + RED<<.-~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~-.>> + {DEFAULT_CONFIG} + RED<<'*-~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~o~O~o~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~-*'>> + DARK_YELLOW<<(Hit any key to proceed...)>>""").format(DEFAULT_CONFIG=DEFAULT_CONFIG)), end="") + + try: + next(keyboard.keyboard_listener()) + except KeyboardInterrupt: + pass + print(Colorized("\rDARK_GREEN<<(Proceeding...)>>" + " " * 40)) + + +APP_CHARS = {} +eval(compile(app_chars, CFG_PATH, 'exec'), {}, APP_CHARS) + + +@contextmanager +def _no_resize_handler(): + handler = signal.signal(signal.SIGWINCH, signal.SIG_DFL) + try: + yield + finally: + signal.signal(signal.SIGWINCH, handler) + + +# =============================================================================== +# Termenu +# =============================================================================== +class TermenuAdapter(termenu.Termenu): + + class RefreshSignal(ParamsException): ... + class TimeoutSignal(ParamsException): ... + class HelpSignal(ParamsException): ... + class SelectSignal(ParamsException): ... + + FILTER_SEPARATOR = "," + FILTER_MODES = ["and", "nand", "or", "nor"] + EMPTY = "DARK_RED<< (Empty) >>" + SCROLL_UP_MARKER = APP_CHARS['SCROLL_UP_MARKER'] + SCROLL_DOWN_MARKER = APP_CHARS['SCROLL_DOWN_MARKER'] + ACTIVE_ITEM_MARKER = APP_CHARS['ACTIVE_ITEM_MARKER'] + SELECTED_ITEM_MARKER = APP_CHARS['SELECTED_ITEM_MARKER'] + SELECTABLE_ITEM_MARKER = APP_CHARS['SELECTABLE_ITEM_MARKER'] + CONTINUATION_SUFFIX = Colorized(APP_CHARS['CONTINUATION_SUFFIX']) + CONTINUATION_PREFIX = Colorized(APP_CHARS['CONTINUATION_PREFIX']) + TITLE_PAD = " " + + class _Option(termenu.Termenu._Option): + def __init__(self, *args, **kwargs): + super(TermenuAdapter._Option, self).__init__(*args, **kwargs) + self.raw = self.text + self.text = Colorized(self.raw) + self.filter_text = (self.attrs.get('filter_text') or self.text.uncolored).lower() + if isinstance(self.result, str): + self.result = ansi.decolorize(self.result) + self.menu = None # will get filled up later + + @property + def selectable(self): + return self.attrs.get("selectable", True) + + @property + def markable(self): + return self.attrs.get("markable", True) and self.selectable + + def __init__(self, app): + self.height = self.title_height = 1 + self.text = None + self.filter_mode_idx = 0 + self.is_empty = True + self.dirty = False + self.timeout = (time.time() + app.timeout) if app.timeout else None + self.app = app + + def handle_termsize_change(self, signal, frame): + import threading + if threading.current_thread() == threading.main_thread(): + self.refresh("signal") + + @property + def filter_mode(self): + return self.FILTER_MODES[self.filter_mode_idx] + + def reset(self, title="No Title", header="", selection=None, *args, height, **kwargs): + + self._highlighted = False + remains = self.timeout and (self.timeout - time.time()) + if remains: + fmt = "(%s<<%ds left>>)" + if remains <= 5: + color, fmt = "RED", "(%s<<%.1fs left>>)" + elif remains < 11: + color = "YELLOW" + else: + color = "DARK_YELLOW" + title += fmt % (color, remains) + if header: + title += "\n" + header + title = Colorized(title) + terminal_width, terminal_height = termenu.get_terminal_size() + if not height: + height = terminal_height - 2 # leave a margine + terminal_width -= len(self.TITLE_PAD) + title_lines = [] + + for line in title.splitlines(): + line = line.expandtabs() + if len(line.uncolored) <= terminal_width: + title_lines.append(line) + else: + indentation, line = re.match("(\\s*)(.*)", line).groups() + line = Colorized(line) + continuation_prefix = "" + while line: + # we have to keep space for a possible contat the end + width = terminal_width - len(indentation) - len(self.CONTINUATION_SUFFIX.uncolored) + if continuation_prefix: + width -= len(continuation_prefix.uncolored) + line = self.CONTINUATION_PREFIX + line + title_lines.append(indentation + line[:width]) + line = line[width:] + if line: + title_lines[-1] += self.CONTINUATION_SUFFIX + continuation_prefix = self.CONTINUATION_PREFIX + + self.title_height = len(title_lines) + self.title = Colorized("\n".join(self.TITLE_PAD + l for l in title_lines)) + height -= self.title_height + with self._selection_preserved(selection): + super(TermenuAdapter, self).__init__(*args, height=height, **kwargs) + + def _make_option_objects(self, options): + options = super(TermenuAdapter, self)._make_option_objects(options) + for opt in options: + opt.menu = self + self._allOptions = options[:] + return options + + def _decorate_flags(self, index): + flags = super()._decorate_flags(index) + flags["markable"] = self.options[self.scroll + index].attrs.get("markable", self.multiselect) + flags['highlighted'] = self._highlighted and flags['selected'] + return flags + + def _decorate(self, option, **flags): + "Decorate the option to be displayed" + + highlighted = flags.get("highlighted", True) + active = flags.get("active", False) + selected = flags.get("selected", False) + markable = flags.get("markable", False) + moreAbove = flags.get("moreAbove", False) + moreBelow = flags.get("moreBelow", False) + + # add selection / cursor decorations + option = Colorized( + (" " if not markable else self.SELECTED_ITEM_MARKER if selected else self.SELECTABLE_ITEM_MARKER) + + (self.ACTIVE_ITEM_MARKER if active else " ") + + option) + if highlighted: + option = ansi.colorize(option.uncolored, "cyan", bright=True) + else: + option = str(option) # convert from Colorized to ansi string + + # add more above/below indicators + marker = self.SCROLL_UP_MARKER if moreAbove else self.SCROLL_DOWN_MARKER if moreBelow else " " + return ansi.colorize(marker, "white", bright=True) + " " + option + + @contextmanager + def _selection_preserved(self, selection=None): + if self.is_empty: + yield + return + + prev_active = self._get_active_option().result + prev_selected = set(o.result for o in self._allOptions if o.selected) if selection is None else set(selection) + try: + yield + finally: + if prev_selected: + for option in self.options: + option.selected = option.result in prev_selected + self._set_default(prev_active) + + def show(self, default=None, auto_clear=True): + self._refilter() + self._clear_cache() + self._set_default(default) + orig_handler = signal.signal(signal.SIGWINCH, self.handle_termsize_change) + try: + return super(TermenuAdapter, self).show(auto_clear=auto_clear) + finally: + signal.signal(signal.SIGWINCH, orig_handler) + + def _set_default(self, default): + if default is None: + return + for index, o in enumerate(self.options): + if o.result == default: + break + else: + return + for i in range(index): + self._on_down() + + def _adjust_width(self, option): + option = Colorized("BLACK<<\\>>").join(option.splitlines()) + l = len(uncolorize(str(option))) + w = max(self.width, 8) + if l > w: + option = termenu.shorten(option, w) + if l < w: + option += " " * (w - l) + return option + + def _on_key(self, key): + bubble_up = True + if not key == "heartbeat": + self.timeout = None + if key == "space": + key = " " + elif key == "`": + key = "insert" + + if key == "*" and self.multiselect: + for option in self.options: + if option.markable: + option.selected = not option.selected + elif len(key) == 1 and 32 <= ord(key) <= 127: + if key == " " and not self.text: + pass + else: + if not self.text: + self.text = [] + self.text.append(key) + self._refilter() + bubble_up = False + elif key == "enter" and self.is_empty: + bubble_up = False + elif key == "backspace" and self.text: + del self.text[-1] + self._refilter() + elif key == "esc": + if self.text is not None: + filters = "".join(self.text or []).split(self.FILTER_SEPARATOR) + if filters: + filters.pop(-1) + self.text = list(self.FILTER_SEPARATOR.join(filters)) if filters else None + if not filters: + self.filter_mode_idx = 0 + ansi.hide_cursor() + bubble_up = False + self._refilter() + else: + found_selected = False + for option in self.options: + found_selected = found_selected or option.selected + option.selected = False + bubble_up = not found_selected + elif key == "end": + self._on_end() + bubble_up = False + elif callable(getattr(self.app, "on_%s" % key, None)): + getattr(self.app, "on_%s" % key)(self) + bubble_up = False + + if bubble_up: + return super(TermenuAdapter, self)._on_key(key) + + def _on_F5(self): + self.refresh('user') + + def _on_F1(self): + self.help() + + def _on_ctrlSlash(self): + if self.text: + self.filter_mode_idx = (self.filter_mode_idx + 1) % len(self.FILTER_MODES) + self._refilter() + + def _on_enter(self): + if any(option.selected for option in self.options): + self._highlighted = True + self._goto_top() + self._print_menu() + time.sleep(.1) + elif not self._get_active_option().selectable: + return False + return True # stop loop + + def _on_insert(self): + option = self._get_active_option() + if option.markable: + super()._on_space() + else: + super()._on_down() + + def _on_end(self): + height = min(self.height, len(self.options)) + self.scroll = len(self.options) - height + self.cursor = height - 1 + + def refresh(self, source): + if self.timeout: + now = time.time() + if now > self.timeout: + raise self.TimeoutSignal() + raise self.RefreshSignal(source=source) + + def help(self): + raise self.HelpSignal() + + def select(self, selection): + raise self.SelectSignal(selection=selection) + + def _on_heartbeat(self): + self.refresh("heartbeat") + + def _print_footer(self): + if self.text is not None: + filters = "".join(self.text).split(self.FILTER_SEPARATOR) + mode = self.filter_mode + mode_mark = ansi.colorize("\\", "yellow", bright=True) if mode.startswith("n") else ansi.colorize("/", "cyan", bright=True) + if mode == "and": + ansi.write("%s " % mode_mark) + else: + ansi.write("(%s) %s " % (mode, mode_mark)) + ansi.write(ansi.colorize(" , ", "white", bright=True).join(filters)) + ansi.show_cursor() + + def _print_menu(self): + ansi.write("\r%s\n" % self.title) + super(TermenuAdapter, self)._print_menu() + for _ in range(0, self.height - len(self.options)): + ansi.clear_eol() + ansi.write("\n") + self._print_footer() + + ansi.clear_eol() + + def _goto_top(self): + super(TermenuAdapter, self)._goto_top() + ansi.up(self.title_height) + + def get_total_height(self): + return (self.title_height + # header + self.height # options + ) + + def _clear_menu(self): + super(TermenuAdapter, self)._clear_menu() + clear = getattr(self, "clear", True) + ansi.restore_position() + height = self.get_total_height() + if clear: + for i in range(height): + ansi.clear_eol() + ansi.up() + ansi.clear_eol() + else: + ansi.up(height) + ansi.clear_eol() + ansi.write("\r") + + def _refilter(self): + with self._selection_preserved(): + self._clear_cache() + self.options = [] + texts = set(filter(None, "".join(self.text or []).lower().split(self.FILTER_SEPARATOR))) + if self.filter_mode == "and": + pred = lambda option: all(text in option.filter_text for text in texts) + elif self.filter_mode == "nand": + pred = lambda option: not all(text in option.filter_text for text in texts) + elif self.filter_mode == "or": + pred = lambda option: any(text in option.filter_text for text in texts) + elif self.filter_mode == "nor": + pred = lambda option: not any(text in option.filter_text for text in texts) + else: + assert False, self.filter_mode + # filter the matching options + for option in self._allOptions: + if option.attrs.get("showAlways") or not texts or pred(option): + self.options.append(option) + # select the first matching element (showAlways elements might not match) + self.scroll = 0 + for i, option in enumerate(self.options): + if not option.attrs.get("showAlways") and pred(option): + self.cursor = i + self.is_empty = False + break + else: + self.is_empty = True + self.options.append(self._Option(" (No match for RED<<%s>>; WHITE@{}@ to reset filter)" % " , ".join(map(repr,texts)))) + + +def _get_option_name(sub): + if hasattr(sub, "get_option_name"): + return sub.get_option_name() + return sub.__doc__ or sub.__name__ + + +class AppMenu(object): + + class _MenuSignal(ParamsException): pass + class RetrySignal(_MenuSignal): pass + class AbortedSignal(KeyboardInterrupt, _MenuSignal): pass + class QuitSignal(_MenuSignal): pass + class BackSignal(_MenuSignal): pass + class ReturnSignal(_MenuSignal): pass + class TimeoutSignal(_MenuSignal): pass + + # yield this to add a separator + SEPARATOR = dict(text="BLACK<<%s>>" % ("-"*80), result=True, selectable=False) + + _all_titles = [] + _all_menus = [] + + @property + def title(self): + return self.__class__.__name__ + + option_name = None + @classmethod + def get_option_name(cls): + return cls.option_name or cls.__name__ + + @property + def height(self): + return None # use entire terminal height + + @property + def items(self): + + # convert named submenus to submenu objects (functions/classes) + submenus = ( + getattr(self, name) if isinstance(name, str) else name + for name in self.submenus + ) + + return [ + sub if isinstance(sub, (dict, tuple)) else (_get_option_name(sub), sub) + for sub in submenus + ] + + submenus = [] + default = None + multiselect = False + fullscreen = True + heartbeat = None + width = None + actions = None + timeout = None + + def __init__(self, *args, **kwargs): + parent = self._all_menus[-1] if self._all_menus else None + self._all_menus.append(self) + self.parent = parent + self.return_value = None + self.initialize(*args, **kwargs) + try: + self._menu_loop() + finally: + self._all_menus.pop(-1) + + def initialize(self, *args, **kwargs): + pass + + def banner(self): + pass + + def update_data(self): + pass + + @contextmanager + def showing(self): + """ + Allow subclasses to run something before and after the menu is shown + """ + yield + + def help(self): + lines = [ + "WHITE@{Menu Usage:}@", + "", + " * Use the WHITE@{}@ arrow keys to navigate the menu", + " * Hit WHITE@{}@ to return to the parent menu (or exit)", + " * Hit WHITE@{}@ to quit", + " * Hit WHITE@{}@ to refresh/redraw", + " * Hit WHITE@{}@ this help screen", + " * Use any other key to filter the current selection (WHITE@{}@ to clear the filter)", + "", "", + ] + if self.multiselect: + lines[3:3] = [ + " * Use WHITE@{`}@ or WHITE@{}@ to select/deselect the currently active item", + " * Use WHITE@{*}@ to toggle selection on all items", + " * Hit WHITE@{}@ to proceed with currently selected items, or with the active item if nothing is selected", + ] + else: + lines[3:3] = [ + " * Hit WHITE@{}@ to select", + ] + print(Colorized("\n".join(lines))) + self.wait_for_keys(prompt="(Hit any key to continue)") + + def _menu_loop(self): + + # use the default only on the first iteration + # after that we'll default to the the last selection + menu = self.menu = TermenuAdapter(app=self) + self.refresh = "first" + selection = None + default = self.default + try: + while True: + if self.refresh: + self.update_data() + + if self.fullscreen: + ansi.clear_screen() + ansi.home() + title = self.title + titles = [t() if isinstance(t, collections.Callable) else t for t in self._all_titles + [title]] + banner = self.banner + if isinstance(banner, collections.Callable): + banner = banner() + options = list(self.items) + if not options: + return self.result(None) + + menu.reset( + title=" DARK_GRAY@{>>}@ ".join(titles), + header=banner, + options=options, + height=self.height, + multiselect=self.multiselect, + heartbeat=self.heartbeat or (1 if self.timeout else None), + width=self.width, + selection=selection, + ) + else: + # next time we must refresh + self.refresh = "second" + + with ExitStack() as stack: + self._all_titles.append(title) + stack.callback(lambda: self._all_titles.pop(-1)) + + try: + with self.showing(): + selected = menu.show(default=default, auto_clear=not self.fullscreen) + default = None # default selection only on first show + except KeyboardInterrupt: + self.quit() + except menu.RefreshSignal as e: + self.refresh = e.source + continue + except menu.HelpSignal: + self.help() + continue + except menu.TimeoutSignal: + raise self.TimeoutSignal("Timed out waiting for selection") + except menu.SelectSignal as e: + selected = e.selection + + try: + self.on_selected(selected) + except self.RetrySignal as e: + self.refresh = e.refresh # will refresh by default unless told differently + selection = e.selection + continue + except (KeyboardInterrupt): + self.refresh = False # show the same menu + continue + except self.BackSignal as e: + if e.levels: + e.levels -= 1 + raise + self.refresh = e.refresh + continue + else: + self.refresh = "second" # refresh the menu + + except (self.QuitSignal, self.BackSignal): + if self.parent: + raise + + except self.ReturnSignal as e: + self.return_value = e.value + + finally: + if self.fullscreen: + menu._clear_menu() + + def action(self, selected): + def evaluate(item): + if isinstance(item, type): + # we don't want the instance of the class to be returned + # as the a result from the menu. (See 'HitMe' class below) + item, _ = None, item() + if isinstance(item, collections.Callable): + item = item() + if isinstance(item, self._MenuSignal): + raise item + if isinstance(item, AppMenu): + return + return item + return list(map(evaluate, selected)) if self.multiselect else evaluate(selected) + + def on_selected(self, selected): + if not selected and isinstance(selected, (NoneType, list)): + self.back() + + actions = self.get_selection_actions(selected) + + if isinstance(actions, (list, tuple)): + to_submenu = lambda action: (_get_option_name(action), functools.partial(action, selected)) + actions = [action if isinstance(action, collections.Callable) else getattr(self, action) for action in actions] + ret = self.show(title=self.get_selection_title(selected), options=list(map(to_submenu, actions))) + else: + if actions is None: + action = self.action + elif isinstance(actions, str): + action = getattr(self, actions) + else: + action = actions + ret = action(selected) + + if ret is not None: + self.result(ret) + + def get_selection_actions(self, selection): + # override this to change available actions per selection + return self.actions + + def get_selection_title(self, selection): + return "Selected %s items" % len(selection) + + @classmethod + def retry(cls, refresh="app", selection=None): + "Refresh into the current menu" + raise cls.RetrySignal(refresh=refresh, selection=selection) + + @classmethod + def back(cls, refresh=True, levels=1): + "Go back to the parent menu" + raise cls.BackSignal(refresh=refresh, levels=levels) + + @classmethod + def result(cls, value): + "Return result back to the parent menu" + raise cls.ReturnSignal(value=value) + + @classmethod + def quit(cls): + "Quit the whole menu system" + raise cls.QuitSignal() + + @staticmethod + def show(title, options, default=None, back_on_abort=True, **kwargs): + if callable(options): + options = property(options) + kwargs.update(title=title, items=options, default=default, back_on_abort=back_on_abort) + menu = type("AdHocMenu", (AppMenu,), kwargs)() + return menu.return_value + + @staticmethod + def wait_for_keys(keys=("enter", "esc"), prompt=None): + if prompt: + ansi.write(Colorized(prompt)) # Aviod bocking + ansi.write(" ") + ansi.show_cursor() + + keys = set(keys) + try: + for key in keyboard.keyboard_listener(): + if not keys or key in keys: + print() + return key + finally: + ansi.hide_cursor() + + def terminal_released(self): + return termenu.Termenu.terminal.closed() diff --git a/termenu/colors.py b/termenu/colors.py new file mode 100644 index 0000000..1974007 --- /dev/null +++ b/termenu/colors.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python +from __future__ import print_function + +import re +from . import ansi + + +colorizers_cache = {} + + +_RE_COLOR_SPEC = re.compile( + "([\w]+)(?:\((.*)\))?" # 'red', 'red(white)' + ) + +_RE_COLOR = re.compile( # 'RED<>', 'RED(WHITE)<>', 'RED@{text}@' + r"(?ms)" # flags: mutliline/dot-all + "([A-Z_]+" # foreground color + "(?:\([^\)]+\))?" # optional background color + "(?:(?:\<\<).*?(?:\>\>)" # text string inside <<...>> + "|" + "(?:\@\{).*?(?:\}\@)))" # text string inside @{...}@ + ) + +_RE_COLORING = re.compile( + # 'RED', 'RED(WHITE)' + r"(?ms)" + "([A-Z_]+(?:\([^\)]+\))?)" # foreground color and optional background color + "((?:\<\<.*?\>\>|\@\{.*?\}\@))" # text string inside either <<...>> or @{...}@ + ) + + +def get_colorizer(name): + name = name.lower() + try: + return colorizers_cache[name] + except KeyError: + pass + + bright = True + color, background = (c and c.lower() for c in _RE_COLOR_SPEC.match(name).groups()) + dark, _, color = color.rpartition("_") + if dark == 'dark': + bright = False + if color not in ansi.COLORS: + color = "white" + if background not in ansi.COLORS: + background = None + fmt = ansi.colorize("{TEXT}", color, background, bright=bright) + colorizer = lambda text: fmt.format(TEXT=text) + return add_colorizer(name, colorizer) + + +def add_colorizer(name, colorizer): + colorizers_cache[name.lower()] = colorizer + return colorizer + + +def colorize_by_patterns(text, no_color=False): + if no_color: + _subfunc = lambda match_obj: match_obj.group(2)[2:-2] + else: + _subfunc = lambda match_obj: get_colorizer(match_obj.group(1))(match_obj.group(2)[2:-2]) + + text = _RE_COLORING.sub(_subfunc, text) + if no_color: + text = ansi.decolorize(text) + return text + + +def uncolorize(text): + return re.sub(re.escape("\x1b") + '.+?m', "", text) + + +class Colorized(str): + + class Token(str): + + def raw(self): + return self + + def copy(self, text): + return self.__class__(text) + + def __getslice__(self, start, stop): + return self[start:stop:] + + def __getitem__(self, *args): + return self.copy(str.__getitem__(self, *args)) + + def __iter__(self): + for c in str.__str__(self): + yield self.copy(c) + + class ColoredToken(Token): + + def __new__(cls, text, colorizer_name): + self = str.__new__(cls, text) + if ">>" in text or "<<" in text: + self.__p, self.__s = "@{", "}@" + else: + self.__p, self.__s = "<<", ">>" + self.__name = colorizer_name + return self + + def __str__(self): + return get_colorizer(self.__name)(str.__str__(self)) + + def copy(self, text): + return self.__class__(text, self.__name) + + def raw(self): + return "".join((self.__name, self.__p, str.__str__(self), self.__s)) + + def __repr__(self): + return repr(self.raw()) + + def __new__(cls, text): + text = uncolorize(text) # remove exiting colors + self = str.__new__(cls, text) + self.tokens = [] + for text in _RE_COLOR.split(text): + match = _RE_COLORING.match(text) + if match: + stl = match.group(1).strip("_") + text = match.group(2)[2:-2] + for l in text.splitlines(): + self.tokens.append(self.ColoredToken(l, stl)) + self.tokens.append(self.Token("\n")) + if not text.endswith("\n"): + del self.tokens[-1] + else: + self.tokens.append(self.Token(text)) + self.uncolored = "".join(str.__str__(token) for token in self.tokens) + self.colored = "".join(str(token) for token in self.tokens) + return self + + def raw(self): + return str.__str__(self) + + def __str__(self): + return self.colored + + def withuncolored(func): + def inner(self, *args): + return func(self.uncolored, *args) + return inner + + __len__ = withuncolored(len) + count = withuncolored(str.count) + endswith = withuncolored(str.endswith) + find = withuncolored(str.find) + index = withuncolored(str.index) + isalnum = withuncolored(str.isalnum) + isalpha = withuncolored(str.isalpha) + isdigit = withuncolored(str.isdigit) + islower = withuncolored(str.islower) + isspace = withuncolored(str.isspace) + istitle = withuncolored(str.istitle) + isupper = withuncolored(str.isupper) + rfind = withuncolored(str.rfind) + rindex = withuncolored(str.rindex) + + def withcolored(func): + def inner(self, *args): + return self.__class__("".join(t.copy(func(t, *args)).raw() for t in self.tokens if t)) + return inner + + #capitalize = withcolored(str.capitalize) + expandtabs = withcolored(str.expandtabs) + lower = withcolored(str.lower) + replace = withcolored(str.replace) + + # decode = withcolored(str.decode) + # encode = withcolored(str.encode) + swapcase = withcolored(str.swapcase) + title = withcolored(str.title) + upper = withcolored(str.upper) + + def __getitem__(self, idx): + if isinstance(idx, slice) and idx.step is None: + start = idx.start or 0 + stop = idx.stop or len(self) + if start < 0: + start += stop + cursor = 0 + tokens = [] + for token in self.tokens: + tokens.append(token[max(0, start - cursor):stop - cursor]) + cursor += len(token) + if cursor > stop: + break + return self.__class__("".join(t.raw() for t in tokens if t)) + + tokens = [c for token in self.tokens for c in token].__getitem__(idx) + return self.__class__("".join(t.raw() for t in tokens if t)) + + def __getslice__(self, *args): + return self.__getitem__(slice(*args)) + + def __add__(self, other): + return self.__class__("".join(map(str.__str__, (self, other)))) + + def __mod__(self, other): + return self.__class__(self.raw() % other) + + def format(self, *args, **kwargs): + return self.__class__(self.raw().format(*args, **kwargs)) + + def rjust(self, *args): + padding = self.uncolored.rjust(*args)[:-len(self.uncolored)] + return self.__class__(padding + self.raw()) + + def ljust(self, *args): + padding = self.uncolored.ljust(*args)[len(self.uncolored):] + return self.__class__(self.raw() + padding) + + def center(self, *args): + padded = self.uncolored.center(*args) + return self.__class__(padded.replace(self.uncolored, self.raw())) + + def join(self, *args): + return self.__class__(self.raw().join(*args)) + + def _iter_parts(self, parts): + last_cursor = 0 + for part in parts: + pos = self.uncolored.find(part, last_cursor) + yield self[pos:pos + len(part)] + last_cursor = pos + len(part) + + def withiterparts(func): + def inner(self, *args): + return list(self._iter_parts(func(self.uncolored, *args))) + return inner + + split = withiterparts(str.split) + rsplit = withiterparts(str.rsplit) + splitlines = withiterparts(str.splitlines) + partition = withiterparts(str.partition) + rpartition = withiterparts(str.rpartition) + + def withsingleiterparts(func): + def inner(self, *args): + return next(self._iter_parts([func(self.uncolored, *args)])) + return inner + + strip = withsingleiterparts(str.strip) + lstrip = withsingleiterparts(str.lstrip) + rstrip = withsingleiterparts(str.rstrip) + + def zfill(self, *args): + padding = self.uncolored.zfill(*args)[:-len(self.uncolored)] + return self.__class__(padding + self.raw()) + +C = Colorized + + +if __name__ == '__main__': + import fileinput + for line in fileinput.input(): + print(colorize_by_patterns(line), end="") diff --git a/termenu/keyboard.py b/termenu/keyboard.py new file mode 100644 index 0000000..3cb18ea --- /dev/null +++ b/termenu/keyboard.py @@ -0,0 +1,204 @@ + +import os +import sys +import fcntl +import termios +import select +import errno +import string +from contextlib import contextmanager + +try: + STDIN = sys.stdin.fileno() +except ValueError: + STDIN = None + +try: + STDOUT = sys.stdin.fileno() +except ValueError: + STDOUT = None + + +ANSI_SEQUENCES = dict( + up='\x1b[A', + down='\x1b[B', + right='\x1b[C', + left='\x1b[D', + home='\x1bOH', + end='\x1bOF', + insert='\x1b[2~', + delete='\x1b[3~', + pageUp='\x1b[5~', + pageDown='\x1b[6~', + ctrlLeft='\x1b[1;5C', + ctrlRight='\x1b[1;5D', + ctrlUp='\x1b[1;5A', + ctrlDown='\x1b[1;5B', + ctrlSlash='\x1f', + + F1='\x1bOP', + F2='\x1bOQ', + F3='\x1bOR', + F4='\x1bOS', + F5='\x1b[15~', + F6='\x1b[17~', + F7='\x1b[18~', + F8='\x1b[19~', + F9='\x1b[20~', + F10='\x1b[21~', + F11='\x1b[23~', + F12='\x1b[24~', + + ctrlF2='\x1bO1;5Q', + ctrlF3='\x1bO1;5R', + ctrlF4='\x1bO1;5S', + ctrlF5='\x1b[15;5~', + ctrlF6='\x1b[17;5~', + ctrlF7='\x1b[18;5~', + ctrlF8='\x1b[19;5~', + ctrlF9='\x1b[20;5~', + ctrlF10='\x1b[21;5~', + ctrlF11='\x1b[23;5~', + ctrlF12='\x1b[24;5~', + + shiftF1='\x1bO1;2P', + shiftF2='\x1bO1;2Q', + shiftF3='\x1bO1;2R', + shiftF4='\x1bO1;2S', + shiftF5='\x1b[15;2~', + shiftF6='\x1b[17;2~', + shiftF7='\x1b[18;2~', + shiftF8='\x1b[19;2~', + shiftF9='\x1b[20;2~', + shiftF11='\x1b[23;2~', + shiftF12='\x1b[24;2~', +) + + +try: + for line in open(os.path.expanduser("~/.termenu/ansi_mapping")): + if not line or line.startswith("#"): + continue + name, sep, sequence = line.replace(" ", "").replace("\t", "").strip().partition(":") + if not sep: + continue + if not sequence: + continue + ANSI_SEQUENCES[name] = sequence = eval("'%s'" % sequence) +except FileNotFoundError: + pass + + +for c in string.ascii_lowercase: + ANSI_SEQUENCES['ctrl_%s' % c] = chr(ord(c) - ord('a')+1) + +KEY_NAMES = dict((v,k) for k,v in list(ANSI_SEQUENCES.items())) +KEY_NAMES.update({ + '\x1b' : 'esc', + '\n' : 'enter', + ' ' : 'space', + '\x7f' : 'backspace', +}) + + +class RawTerminal(object): + def __init__(self, blocking=True): + self._blocking = blocking + self._opened = 0 + + def open(self): + self._opened += 1 + if self._opened > 1: + return + + # Set raw mode + self._oldterm = termios.tcgetattr(STDIN) + newattr = termios.tcgetattr(STDIN) + newattr[3] = newattr[3] & ~termios.ICANON & ~termios.ECHO + termios.tcsetattr(STDIN, termios.TCSANOW, newattr) + + # Set non-blocking IO on stdin + self._old_in = fcntl.fcntl(STDIN, fcntl.F_GETFL) + self._old_out = fcntl.fcntl(STDOUT, fcntl.F_GETFL) + + if not self._blocking: + fcntl.fcntl(STDIN, fcntl.F_SETFL, self._old_in | os.O_NONBLOCK) + fcntl.fcntl(STDOUT, fcntl.F_SETFL, self._old_out | os.O_NONBLOCK) + + def close(self): + self._opened -= 1 + if self._opened > 0: + return + # Restore previous terminal mode + termios.tcsetattr(STDIN, termios.TCSAFLUSH, self._oldterm) + fcntl.fcntl(STDIN, fcntl.F_SETFL, self._old_in) + fcntl.fcntl(STDOUT, fcntl.F_SETFL, self._old_out) + + def get(self): + ret = sys.stdin.read(1) + if not ret: + raise EOFError() + return ret + + def wait(self): + select.select([STDIN], [], []) + + def __enter__(self): + self.open() + return self + + def __exit__(self, *args): + self.close() + + @contextmanager + def closed(self): + self.close() + try: + yield + finally: + self.open() + + def listen(self, **kw): + return keyboard_listener(terminal=self, **kw) + + +def keyboard_listener(heartbeat=None, terminal=None): + if not terminal: + terminal = RawTerminal(blocking=False) + with terminal: + # return keys + sequence = "" + while True: + # wait for keys to become available + ret, _, __ = select.select([STDIN], [], [], heartbeat) + if not ret: + yield "heartbeat" + continue + + # read all available keys + while True: + try: + sequence += terminal.get() + except EOFError: + break + except IOError as e: + if e.errno == errno.EAGAIN: + break + + # handle ANSI key sequences + while sequence: + for seq in list(ANSI_SEQUENCES.values()): + if sequence[:len(seq)] == seq: + yield KEY_NAMES[seq] + sequence = sequence[len(seq):] + break + # handle normal keys + else: + for key in sequence: + yield KEY_NAMES.get(key, key) + sequence = "" + + +if __name__ == "__main__": + for key in keyboard_listener(0.5): + print(repr(key)) diff --git a/termenu.py b/termenu/termenu.py similarity index 92% rename from termenu.py rename to termenu/termenu.py index 2afcd92..628f997 100644 --- a/termenu.py +++ b/termenu/termenu.py @@ -1,6 +1,8 @@ + + import sys -import ansi -from version import version +from .version import version +from . import keyboard, ansi def show_menu(title, options, default=None, height=None, width=None, multiselect=False, precolored=False): """ @@ -38,9 +40,10 @@ def show_menu(title, options, default=None, height=None, width=None, multiselect return menu.show() try: - xrange() -except: - xrange = range + range = xrange +except NameError: + pass + def pluggable(method): """ @@ -56,6 +59,7 @@ def wrapped(self, *args, **kwargs): wrapped.original = method return wrapped + def register_plugin(host, plugin): """ Register a plugin with a host object. Some @pluggable methods in the host @@ -70,28 +74,39 @@ def __getattr__(self, name): plugin.host = host host._plugins.append(plugin) + class Plugin(object): def __getattr__(self, name): # allow calls to fall through to parent plugins if a method isn't defined return getattr(self.parent, name) + class Termenu(object): + + terminal = keyboard.RawTerminal(blocking=False) + class _Option(object): def __init__(self, option, **attrs): + self.selected = False + self.attrs = attrs if isinstance(option, tuple) and len(option) == 2: self.text, self.result = option + elif isinstance(option, dict) and 'text' in option: + self.text = option['text'] + self.result = option.get("result", self.text) + self.selected = option.get("selected", self.selected) + self.attrs.update(option) else: self.text = self.result = option - if not isinstance(self.text, basestring): + if not isinstance(self.text, str): self.text = str(self.text) - self.selected = False - self.attrs = attrs def __init__(self, options, default=None, height=None, width=None, multiselect=True, heartbeat=None, plugins=None): for plugin in plugins or []: register_plugin(self, plugin) self.options = self._make_option_objects(options) - self.height = min(height or 10, len(self.options)) + max_height = get_terminal_size()[1] - 1 # one for the title + self.height = min(height or 10, len(self.options), max_height) self.width = self._compute_width(width, self.options) self.multiselect = multiselect self.cursor = 0 @@ -111,20 +126,20 @@ def get_result(self): return selected if self.multiselect else selected[0] @pluggable - def show(self): - import keyboard + def show(self, auto_clear=True): self._print_menu() ansi.save_position() ansi.hide_cursor() try: - for key in keyboard.keyboard_listener(self._heartbeat): + for key in self.terminal.listen(heartbeat=self._heartbeat): stop = self._on_key(key) if stop: return self.get_result() self._goto_top() self._print_menu() finally: - self._clear_menu() + if auto_clear: + self._clear_menu() ansi.show_cursor() @pluggable @@ -261,7 +276,7 @@ def _clear_cache(self): @pluggable def _clear_menu(self): ansi.restore_position() - for i in xrange(self.height): + for i in range(self.height): ansi.clear_eol() ansi.up() ansi.clear_eol() @@ -328,6 +343,7 @@ def _decorate_indicators(self, option, **flags): return option + class FilterPlugin(Plugin): def __init__(self): self.text = None @@ -361,7 +377,7 @@ def _on_key(self, key): def _print_menu(self): self.parent._print_menu() - for i in xrange(0, self.host.height - len(self.host.options)): + for i in range(0, self.host.height - len(self.host.options)): ansi.clear_eol() ansi.write("\n") if self.text is not None: @@ -391,6 +407,7 @@ def __init__(self, header, options): self.header = header self.options = options + class OptionGroupPlugin(Plugin): def _set_default(self, default): if default: @@ -455,6 +472,7 @@ def _decorate(self, option, **flags): else: return self.parent._decorate(option, **flags) + class PrecoloredPlugin(Plugin): def _make_option_objects(self, options): options = self.parent._make_option_objects(options) @@ -485,6 +503,7 @@ def _decorate(self, option, **flags): return option + class TitlePlugin(Plugin): def __init__(self, title): self.title = title @@ -502,6 +521,7 @@ def _clear_menu(self): ansi.up() ansi.clear_eol() + class Minimenu(object): def __init__(self, options, default=None): self.options = options @@ -511,7 +531,6 @@ def __init__(self, options, default=None): self.cursor = 0 def show(self): - import keyboard ansi.hide_cursor() self._print_menu(rewind=False) try: @@ -553,6 +572,7 @@ def _clear_menu(self): menu = self._make_menu(_decorate=False) ansi.write("\b"*len(menu)+" "*len(menu)+"\b"*len(menu)) + def redirect_std(): """ Connect stdin/stdout to controlling terminal even if the scripts input and output @@ -561,23 +581,30 @@ def redirect_std(): stdin = sys.stdin stdout = sys.stdout if not sys.stdin.isatty(): - sys.stdin = open("/dev/tty", "r", 0) + sys.stdin = open("/dev/tty", "rb", 0) if not sys.stdout.isatty(): - sys.stdout = open("/dev/tty", "w", 0) + sys.stdout = open("/dev/tty", "wb", 0) return stdin, stdout + def shorten(s, l=100): if len(s) <= l or l < 3: return s - return s[:l/2-2] + "..." + s[-l/2+1:] + return s[:l//2-2] + "..." + s[-l//2+1:] + + +try: + from os import get_terminal_size +except ImportError: + def get_terminal_size(): + import fcntl, termios, struct + h, w, hp, wp = struct.unpack( + 'HHHH', + fcntl.ioctl(sys.stdin, termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))) + return w, h -def get_terminal_size(): - import fcntl, termios, struct - h, w, hp, wp = struct.unpack('HHHH', fcntl.ioctl(sys.stdin, - termios.TIOCGWINSZ, struct.pack('HHHH', 0, 0, 0, 0))) - return w, h if __name__ == "__main__": - odds = OptionGroup("Odd Numbers", [("%06d" % i, i) for i in xrange(1, 10, 2)]) - evens = OptionGroup("Even Numbers", [("%06d" % i, i) for i in xrange(2, 10, 2)]) + odds = OptionGroup("Odd Numbers", [("%06d" % i, i) for i in range(1, 10, 2)]) + evens = OptionGroup("Even Numbers", [("%06d" % i, i) for i in range(2, 10, 2)]) print(show_menu("List Of Numbers", [odds, evens], multiselect=True)) diff --git a/test.py b/termenu/test.py similarity index 99% rename from test.py rename to termenu/test.py index b72161f..2b768b4 100644 --- a/test.py +++ b/termenu/test.py @@ -1,7 +1,5 @@ -import sys -sys.path.append("..") import unittest -import ansi +from termenu import ansi from termenu import Termenu, Plugin, FilterPlugin OPTIONS = ["%02d" % i for i in xrange(1,100)] diff --git a/version.py b/termenu/version.py similarity index 100% rename from version.py rename to termenu/version.py diff --git a/version b/version new file mode 100644 index 0000000..ab67981 --- /dev/null +++ b/version @@ -0,0 +1 @@ +1.1.6 \ No newline at end of file