From 82a2671729fe27dbe31c3d1a8dd981faa8ebd970 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 18 May 2014 18:52:49 +0300 Subject: [PATCH 01/60] added AppMenu and colors --- colors.py | 210 ++++++++++++++++++++ menu.py | 562 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 772 insertions(+) create mode 100644 colors.py create mode 100644 menu.py diff --git a/colors.py b/colors.py new file mode 100644 index 0000000..96952e3 --- /dev/null +++ b/colors.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python + +import re +from . import ansi + + +stylifiers_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 stylifiers_cache[name] + except KeyError: + pass + + color, background = (c and c.lower() for c in _RE_COLOR_SPEC.match(name).groups()) + if color not in ansi.COLORS: + color = "white" + if background not in ansi.COLORS: + background = None + fmt = ansi.colorize("{TEXT}", color, background) + colorizer = lambda text: fmt.format(TEXT=text) + return add_colorizer(name, colorizer) + + +def add_colorizer(name, colorizer): + stylifiers_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 + + +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, stylifier_name): + self = str.__new__(cls, text) + self.__name = stylifier_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 "%s<<%s>>" % (self.__name, str.__str__(self)) + def __repr__(self): + return repr(self.raw()) + + def __new__(cls, text): + 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) + 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 __getslice__(self, 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)) + def __getitem__(self, idx): + if isinstance(idx, slice) and idx.step is None: + return self[idx.start:idx.stop] # (__getslice__) + 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 __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 diff --git a/menu.py b/menu.py new file mode 100644 index 0000000..5a6e785 --- /dev/null +++ b/menu.py @@ -0,0 +1,562 @@ +import time +import functools +import termenu +from contextlib import contextmanager +from . import ansi +from colors import Colorized + +#=============================================================================== +# Termenu +#=============================================================================== + + +class TermenuAdapter(termenu.Termenu): + + class RefreshSignal(Exception): pass + class TimeoutSignal(Exception): pass + + FILTER_SEPARATOR = "," + EMPTY = "DARK_RED<< (Empty) >>" + + 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) + if isinstance(self.result, str): + self.result = ansi.decolorize(self.result) + + def __init__(self, timeout=None): + self.text = None + self.is_empty = True + self.dirty = False + self.timeout = (time.time() + timeout) if timeout else None + + def reset(self, title="No Title", header="", *args, **kwargs): + 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 + self.title = Colorized(title) + self.title_height = len(title.splitlines()) + with self._selection_preserved(): + super(TermenuAdapter, self).__init__(*args, **kwargs) + + def _make_option_objects(self, options): + options = super(TermenuAdapter, self)._make_option_objects(options) + self._allOptions = options[:] + return options + + def _decorate(self, option, **flags): + "Decorate the option to be displayed" + + active = flags.get("active", False) + selected = flags.get("selected", False) + moreAbove = flags.get("moreAbove", False) + moreBelow = flags.get("moreBelow", False) + + # add selection / cursor decorations + option = Colorized(("WHITE<<*>> " if selected else " ") + ("WHITE@{>}@" if active else " ") + option) + option = str(option) # convert from Colorized to ansi string + if active: + option = ansi.highlight(option, "black") + + # add more above/below indicators + if moreAbove: + option = option + " " + ansi.colorize("^", "white", bright=True) + elif moreBelow: + option = option + " " + ansi.colorize("v", "white", bright=True) + else: + option = option + " " + + return option + + @contextmanager + def _selection_preserved(self): + if self.is_empty: + yield + return + + prev_active = self._get_active_option().result + prev_selected = set(o.result for o in self.options if o.selected) + 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): + self._refilter() + self._clear_cache() + self._set_default(default) + return super(TermenuAdapter, self).show() + + 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 xrange(index): + self._on_down() + + def _adjust_width(self, option): + option = Colorized("BLACK<<\\>>").join(option.splitlines()) + l = len(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): + prevent = False + if not key == "heartbeat": + self.timeout = None + + if key == "*" and self.multiselect: + for option in self.options: + if not option.attrs.get("header"): + option.selected = not option.selected + elif len(key) == 1 and 32 < ord(key) <= 127: + if not self.text: + self.text = [] + self.text.append(key) + self._refilter() + elif self.is_empty and key == "enter": + prevent = True + elif self.text and key == "backspace": + del self.text[-1] + self._refilter() + elif self.text is not None and key == "esc": + 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 + termenu.ansi.hide_cursor() + prevent = True + self._refilter() + elif key == "end": + self._on_end() + prevent = True + elif key == "F5": + self.refresh() + + if not prevent: + return super(TermenuAdapter, self)._on_key(key) + + def _on_end(self): + height = min(self.height, len(self.options)) + self.scroll = len(self.options) - height + self.cursor = height - 1 + + def refresh(self): + if self.timeout: + now = time.time() + if now > self.timeout: + raise self.TimeoutSignal() + raise self.RefreshSignal() + + def _on_heartbeat(self): + self.refresh() + + def _print_footer(self): + if self.text is not None: + filters = "".join(self.text).split(self.FILTER_SEPARATOR) + termenu.ansi.write("/%s" % termenu.ansi.colorize(" , ", "white", bright=True).join(filters)) + termenu.ansi.show_cursor() + + def _print_menu(self): + ansi.write("\r%s\n" % self.title) + super(TermenuAdapter, self)._print_menu() + for _ in xrange(0, self.height - len(self.options)): + termenu.ansi.clear_eol() + termenu.ansi.write("\n") + self._print_footer() + + termenu.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) + termenu.ansi.restore_position() + height = self.get_total_height() + if clear: + for i in xrange(height): + termenu.ansi.clear_eol() + termenu.ansi.up() + termenu.ansi.clear_eol() + else: + termenu.ansi.up(height) + ansi.clear_eol() + ansi.write("\r") + + def _refilter(self): + with self._selection_preserved(): + self._clear_cache() + self.options = [] + texts = "".join(self.text or []).lower().split(self.FILTER_SEPARATOR) + pred = lambda option: all(text in option.text.lower() for text in texts) + # filter the matching options + for option in self._allOptions: + if option.attrs.get("showAlways") 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>>)" % " , ".join(map(repr,texts)))) + +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.iteritems(): + setattr(self, k, v) + self.params = kwargs + + +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 + + _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 termenu.get_terminal_size()[1] / 2 + + @property + def items(self): + + get_option_name = lambda sub: ( + sub.get_option_name() + if hasattr(sub, "get_option_name") + else (sub.__doc__ or sub.__name__) + ) + + # convert named submenus to submenu objects (functions/classes) + submenus = ( + getattr(self, name) if isinstance(name, basestring) else name + for name in self.submenus + ) + + return [ + sub if isinstance(sub, tuple) else (get_option_name(sub), sub) + for sub in submenus + ] + + submenus = [] + default = None + multiselect = False + 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 _menu_loop(self): + + # use the default only on the first iteration + # after that we'll default to the the last selection + menu = TermenuAdapter(timeout=self.timeout) + refresh = True + default = self.default + + try: + while True: + if refresh: + title = self.title + titles = [t() if callable(t) else t for t in self._all_titles + [title]] + banner = self.banner + if callable(banner): + banner = banner() + + menu.reset( + title=" DARK_GRAY@{>>}@ ".join(titles), + header=banner, + options=self.items, + height=self.height, + multiselect=self.multiselect, + heartbeat=self.heartbeat or (1 if self.timeout else None), + width=self.width, + ) + else: + # next time we must refresh + refresh = True + + try: + selected = menu.show(default=default) + default = None # default selection only on first show + except KeyboardInterrupt: + self.quit() + except menu.RefreshSignal: + continue + except menu.TimeoutSignal: + raise self.TimeoutSignal("Timed out waiting for selection") + + self._all_titles.append(title) + try: + self.on_selected(selected) + except self.RetrySignal, e: + refresh = e.refresh # will refresh by default unless told differently + continue + except (KeyboardInterrupt): + refresh = False # show the same menu + continue + except self.BackSignal, e: + if e.levels: + e.levels -= 1 + raise + refresh = e.refresh + continue + else: + refresh = True # refresh the menu + finally: + self._all_titles.pop(-1) + + except (self.QuitSignal, self.BackSignal): + if self.parent: + raise + + except self.ReturnSignal, e: + self.return_value = e.value + + 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 callable(item): + item = item() + if isinstance(item, self._MenuSignal): + raise item + if isinstance(item, AppMenu): + return + return item + return map(evaluate, selected) if hasattr(selected, "__iter__") else evaluate(selected) + + def on_selected(self, selected): + if not selected: + self.back() + + actions = self.get_selection_actions(selected) + + if actions is None: + ret = self.action(selected) + else: + to_submenu = lambda action: (action.__doc__ or action.__name__, functools.partial(action, selected)) + actions = [action if callable(action) else getattr(self, action) for action in actions] + ret = self.show_menu(title=self.get_selection_title(selected), options=map(to_submenu, actions)) + + 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=True): + "Refresh into the current menu" + raise cls.RetrySignal(refresh=refresh) + + @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_menu(title, options, default=None, back_on_abort=True, **kwargs): + kwargs.update(title=title, items=options, default=default, back_on_abort=back_on_abort) + menu = type("AdHocMenu", (AppMenu,), kwargs)() + return menu.return_value + + + + +def test1(): + + class TopMenu(AppMenu): + title = staticmethod(lambda: "YELLOW<<%s>>" % time.ctime()) + timeout = 15 + submenus = ["Letters", "Numbers", "Submenu", "Foo", "Bar"] + + class Letters(AppMenu): + title = "CYAN(BLUE)<>" + option_name = "BLUE<>" + multiselect = True + items = [chr(i) for i in xrange(65, 91)] + def action(self, letters): + raw_input("Selected: %s" % "".join(letters)) + + class Numbers(AppMenu): + multiselect = True + @property + def items(self): + return 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" + raw_input("Min: %s, Max: %s" % (min(numbers), max(numbers))) + self.retry() + def Add(self, numbers): + raw_input("Sum: %s" % sum(numbers)) + self.back() + def Multiply(self, numbers): + raw_input("Mult: %s" % reduce((lambda a, b: a*b), numbers)) + def Quit(self, numbers): + raw_input("%s" % numbers) + self.quit() + + + class Submenu(AppMenu): + submenus = ["Letter", "Number"] + + class Letter(AppMenu): + @property + def items(self): + return [chr(i) for i in xrange(65, 91)][int(time.time()*2) % 10:][:10] + def action(self, letter): + raw_input("Selected: %s" % letter) + self.back() + + class Number(AppMenu): + items = range(50) + def action(self, number): + raw_input("Sum: %s" % number) + + def Foo(self): + raw_input("Foo?") + + def Bar(object): + raw_input("Bar! ") + Bar.get_option_name = lambda: "Dynamic option name: (%s)" % (int(time.time()) % 20) + + TopMenu() + + +def test2(): + + def leave(): + print "Leave..." + AppMenu.quit() + + def go(): + def back(): + print "Going back." + AppMenu.back() + + def there(): + ret = AppMenu.show_menu("Where's there?", + "Spain France Albania".split() + [("Quit", AppMenu.quit)], + multiselect=True, back_on_abort=True) + print ret + return ret + + return AppMenu.show_menu("Go Where?", [ + ("YELLOW<>", back), + ("GREEN<>", there) + ]) + + return AppMenu.show_menu("Make your MAGENTA<>", [ + ("RED<>", leave), + ("BLUE<>", go) + ]) + + +if __name__ == '__main__': + import pdb + try: + ret = AppMenu.show_menu("AppMenu", [ + ("Debug", pdb.set_trace), + ("Test1", test1), + ("Test2", test2) + ], + timeout=5, heartbeat=1, + ) + print "Result is:", ret + except AppMenu.TimeoutSignal: + print "Timed out" \ No newline at end of file From c521af309bd61582280e3e88d7dec05b6f87b876 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Wed, 29 Oct 2014 00:40:17 +0200 Subject: [PATCH 02/60] updates --- ansi.py | 2 +- colors.py | 12 +++--- examples/filemenu.py | 1 - examples/sourcemenu.py | 86 ++++++++++++++++++++++++++++++++++++++++++ menu.py | 4 +- termenu.py | 2 +- 6 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 examples/sourcemenu.py diff --git a/ansi.py b/ansi.py index 90693ef..2f719c4 100644 --- a/ansi.py +++ b/ansi.py @@ -11,7 +11,7 @@ def _retry(func, *args): while True: try: func(*args) - except IOError, e: + except IOError as e: if e.errno != errno.EAGAIN: raise else: diff --git a/colors.py b/colors.py index 96952e3..21f75eb 100644 --- a/colors.py +++ b/colors.py @@ -1,10 +1,10 @@ #!/usr/bin/env python import re -from . import ansi +import ansi -stylifiers_cache = {} +colorizers_cache = {} _RE_COLOR_SPEC = re.compile( @@ -31,7 +31,7 @@ def get_colorizer(name): name = name.lower() try: - return stylifiers_cache[name] + return colorizers_cache[name] except KeyError: pass @@ -46,7 +46,7 @@ def get_colorizer(name): def add_colorizer(name, colorizer): - stylifiers_cache[name.lower()] = colorizer + colorizers_cache[name.lower()] = colorizer return colorizer @@ -78,9 +78,9 @@ def __iter__(self): yield self.copy(c) class ColoredToken(Token): - def __new__(cls, text, stylifier_name): + def __new__(cls, text, colorizer_name): self = str.__new__(cls, text) - self.__name = stylifier_name + self.__name = colorizer_name return self def __str__(self): return get_colorizer(self.__name)(str.__str__(self)) diff --git a/examples/filemenu.py b/examples/filemenu.py index 5c307fb..438cc1e 100644 --- a/examples/filemenu.py +++ b/examples/filemenu.py @@ -1,6 +1,5 @@ import os import sys -sys.path.insert(0, "..") import termenu """ diff --git a/examples/sourcemenu.py b/examples/sourcemenu.py new file mode 100644 index 0000000..d7225c6 --- /dev/null +++ b/examples/sourcemenu.py @@ -0,0 +1,86 @@ +import os +import sys +from menu import AppMenu + +""" +This example shows how to implement a file browser using multi-level menus +and custom menu item decoration. +""" + +class FilePlugin(termenu.Plugin): + # TODO go back one level using backspace + def _decorate_flags(self, index): + flags = self.parent._decorate_flags(index) + flags.update(dict( + directory = self.host.options[self.host.scroll+index].text[-1] == "/", + exe = isexe(self.host.options[self.host.scroll+index].text) + )) + return flags + + def _decorate(self, option, **flags): + directory = flags.get("directory", False) + exe = flags.get("exe", False) + active = flags.get("active", False) + selected = flags.get("selected", False) + if active: + if directory: + option = termenu.ansi.colorize(option, "blue", "white", bright=True) + elif exe: + option = termenu.ansi.colorize(option, "green", "white", bright=True) + else: + option = termenu.ansi.colorize(option, "black", "white") + elif directory: + option = termenu.ansi.colorize(option, "blue", bright=True) + elif exe: + option = termenu.ansi.colorize(option, "green", bright=True) + if selected: + option = termenu.ansi.colorize("* ", "red") + option + else: + option = " " + option + + return self.host._decorate_indicators(option, **flags) + +def isexe(path): + return os.path.isfile(path) and os.access(path, os.X_OK) + +def list_files(): + dirs = list(sorted([f+"/" for f in os.listdir(".") if os.path.isdir(f)])) + files = list(sorted([f for f in os.listdir(".") if not os.path.isdir(f)])) + entries = dirs + files + entries = [e for e in entries if e[0] != "."] + if os.getcwd() != "/": + entries = ["../"] + entries + return entries + + +def main(): + from pathlib import Path + def select_file(path): + AppMenu.show_menu(str(path), path) + + def go(): + def back(): + print "Going back." + AppMenu.back() + + def there(): + ret = AppMenu.show_menu("Where's there?", + "Spain France Albania".split() + [("Quit", AppMenu.quit)], + multiselect=True, back_on_abort=True) + print ret + return ret + + return AppMenu.show_menu("Go Where?", [ + ("YELLOW<>", back), + ("GREEN<>", there) + ]) + + return AppMenu.show_menu("Make your MAGENTA<>", [ + ("RED<>", leave), + ("BLUE<>", go) + ]) + + + +if __name__ == "__main__": + main() diff --git a/menu.py b/menu.py index 5a6e785..448551c 100644 --- a/menu.py +++ b/menu.py @@ -2,7 +2,7 @@ import functools import termenu from contextlib import contextmanager -from . import ansi +import ansi from colors import Colorized #=============================================================================== @@ -559,4 +559,4 @@ def there(): ) print "Result is:", ret except AppMenu.TimeoutSignal: - print "Timed out" \ No newline at end of file + print "Timed out" diff --git a/termenu.py b/termenu.py index 9b0f82d..3315e11 100644 --- a/termenu.py +++ b/termenu.py @@ -82,7 +82,7 @@ def __init__(self, option, **attrs): self.text, self.result = 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 From 995e8f9f86482c0301b3987af9cc86f81c3c2e32 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Wed, 29 Oct 2014 12:00:20 +0200 Subject: [PATCH 03/60] move to package --- __init__.py | 1 - termenu => termenu-cmd | 0 termenu/__init__.py | 1 + ansi.py => termenu/ansi.py | 0 menu.py => termenu/app.py | 0 colors.py => termenu/colors.py | 0 keyboard.py => termenu/keyboard.py | 0 termenu.py => termenu/termenu.py | 8 +- termenu/test.py | 348 +++++++++++++++++++++++++++++ version.py => termenu/version.py | 0 test.py | 4 +- 11 files changed, 354 insertions(+), 8 deletions(-) delete mode 100644 __init__.py rename termenu => termenu-cmd (100%) create mode 100644 termenu/__init__.py rename ansi.py => termenu/ansi.py (100%) rename menu.py => termenu/app.py (100%) rename colors.py => termenu/colors.py (100%) rename keyboard.py => termenu/keyboard.py (100%) rename termenu.py => termenu/termenu.py (99%) create mode 100644 termenu/test.py rename version.py => termenu/version.py (100%) 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/termenu b/termenu-cmd similarity index 100% rename from termenu rename to termenu-cmd 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 100% rename from ansi.py rename to termenu/ansi.py diff --git a/menu.py b/termenu/app.py similarity index 100% rename from menu.py rename to termenu/app.py diff --git a/colors.py b/termenu/colors.py similarity index 100% rename from colors.py rename to termenu/colors.py diff --git a/keyboard.py b/termenu/keyboard.py similarity index 100% rename from keyboard.py rename to termenu/keyboard.py diff --git a/termenu.py b/termenu/termenu.py similarity index 99% rename from termenu.py rename to termenu/termenu.py index 3315e11..5aabf87 100644 --- a/termenu.py +++ b/termenu/termenu.py @@ -1,6 +1,8 @@ +from __future__ import print_function + 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): """ @@ -112,7 +114,6 @@ def get_result(self): @pluggable def show(self): - import keyboard self._print_menu() ansi.save_position() ansi.hide_cursor() @@ -508,7 +509,6 @@ def __init__(self, options, default=None): self.cursor = 0 def show(self): - import keyboard ansi.hide_cursor() self._print_menu(rewind=False) try: diff --git a/termenu/test.py b/termenu/test.py new file mode 100644 index 0000000..2b768b4 --- /dev/null +++ b/termenu/test.py @@ -0,0 +1,348 @@ +import unittest +from termenu import ansi +from termenu import Termenu, Plugin, FilterPlugin + +OPTIONS = ["%02d" % i for i in xrange(1,100)] +RESULTS = ["result-%02d" % i for i in xrange(1,100)] + +def strmenu(menu): + return menu._get_debug_view() + +class Down(unittest.TestCase): + def test_cursor_top(self): + menu = Termenu(OPTIONS, height=3) + assert strmenu(menu) == "(01) 02 03" + menu._on_down() + assert strmenu(menu) == "01 (02) 03" + + def test_cursor_middle(self): + menu = Termenu(OPTIONS, height=3) + menu.cursor = 1 + assert strmenu(menu) == "01 (02) 03" + menu._on_down() + assert strmenu(menu) == "01 02 (03)" + + def test_cursor_bottom(self): + menu = Termenu(OPTIONS, height=3) + menu.cursor = 2 + assert strmenu(menu) == "01 02 (03)" + menu._on_down() + assert strmenu(menu) == "02 03 (04)" + + def test_scroll_bottom_cursor_bottom(self): + menu = Termenu(OPTIONS, height=3) + menu.scroll = len(OPTIONS) - 3 + menu.cursor = 2 + assert strmenu(menu) == "97 98 (99)" + menu._on_down() + assert strmenu(menu) == "97 98 (99)" + +class Up(unittest.TestCase): + def test_cursor_top(self): + menu = Termenu(OPTIONS, height=3) + menu.cursor = 0 + assert strmenu(menu) == "(01) 02 03" + menu._on_up() + assert strmenu(menu) == "(01) 02 03" + + def test_cursor_middle(self): + menu = Termenu(OPTIONS, height=3) + menu.cursor = 1 + assert strmenu(menu) == "01 (02) 03" + menu._on_up() + assert strmenu(menu) == "(01) 02 03" + + def test_cursor_bottom(self): + menu = Termenu(OPTIONS, height=3) + menu.cursor = 2 + assert strmenu(menu) == "01 02 (03)" + menu._on_up() + assert strmenu(menu) == "01 (02) 03" + + def test_scroll_bottom_cursor_top(self): + menu = Termenu(OPTIONS, height=3) + menu.scroll = len(OPTIONS) - 3 + menu.cursor = 0 + assert strmenu(menu) == "(97) 98 99" + menu._on_up() + assert strmenu(menu) == "(96) 97 98" + +class PageDown(unittest.TestCase): + def test_cursor_top(self): + menu = Termenu(OPTIONS, height=4) + assert strmenu(menu) == "(01) 02 03 04" + menu._on_pageDown() + assert strmenu(menu) == "01 02 03 (04)" + + def test_cursor_middle(self): + menu = Termenu(OPTIONS, height=4) + menu.cursor = 1 + assert strmenu(menu) == "01 (02) 03 04" + menu._on_pageDown() + assert strmenu(menu) == "01 02 03 (04)" + + def test_cursor_bottom(self): + menu = Termenu(OPTIONS, height=4) + menu.cursor = 3 + assert strmenu(menu) == "01 02 03 (04)" + menu._on_pageDown() + assert strmenu(menu) == "05 06 07 (08)" + + def test_scroll_bottom_cursor_bottom(self): + menu = Termenu(OPTIONS, height=4) + menu.scroll = len(OPTIONS) - 4 + menu.cursor = 3 + assert strmenu(menu) == "96 97 98 (99)" + menu._on_pageDown() + assert strmenu(menu) == "96 97 98 (99)" + + def test_scroll_almost_bottom_cursor_bottom(self): + menu = Termenu(OPTIONS, height=4) + menu.scroll = len(OPTIONS) - 5 + menu.cursor = 3 + assert strmenu(menu) == "95 96 97 (98)" + menu._on_pageDown() + assert strmenu(menu) == "96 97 98 (99)" + +class PageUp(unittest.TestCase): + def test_cursor_top(self): + menu = Termenu(OPTIONS, height=4) + assert strmenu(menu) == "(01) 02 03 04" + menu._on_pageUp() + assert strmenu(menu) == "(01) 02 03 04" + + def test_cursor_middle(self): + menu = Termenu(OPTIONS, height=4) + menu.cursor = 2 + assert strmenu(menu) == "01 02 (03) 04" + menu._on_pageUp() + assert strmenu(menu) == "(01) 02 03 04" + + def test_cursor_bottom(self): + menu = Termenu(OPTIONS, height=4) + menu.cursor = 3 + assert strmenu(menu) == "01 02 03 (04)" + menu._on_pageUp() + assert strmenu(menu) == "(01) 02 03 04" + + def test_scroll_bottom_cursor_top(self): + menu = Termenu(OPTIONS, height=4) + menu.scroll = len(OPTIONS) - 4 + assert strmenu(menu) == "(96) 97 98 99" + menu._on_pageUp() + assert strmenu(menu) == "(92) 93 94 95" + + def test_scroll_almost_top_cursor_top(self): + menu = Termenu(OPTIONS, height=4) + menu.scroll = 1 + assert strmenu(menu) == "(02) 03 04 05" + menu._on_pageUp() + assert strmenu(menu) == "(01) 02 03 04" + +class Default(unittest.TestCase): + def test_found(self): + menu = Termenu(OPTIONS, height=4, default="03") + assert strmenu(menu) == "01 02 (03) 04" + + def test_notfount(self): + menu = Termenu(OPTIONS, height=4, default="asdf") + assert strmenu(menu) == "(01) 02 03 04" + + def test_requires_scroll(self): + menu = Termenu(OPTIONS, height=4, default="55") + assert strmenu(menu) == "(55) 56 57 58" + + def test_last(self): + menu = Termenu(OPTIONS, height=4, default="99") + assert strmenu(menu) == "96 97 98 (99)" + + def test_before_last(self): + menu = Termenu(OPTIONS, height=4, default="97") + assert strmenu(menu) == "96 (97) 98 99" + + def test_multiple(self): + menu = Termenu(OPTIONS, height=4, default=["05", "17", "93"]) + assert strmenu(menu) == "(05) 06 07 08" + assert " ".join(menu.get_result()) == "05 17 93" + + def test_multiple_active(self): + menu = Termenu(OPTIONS, height=4, default=["17", "05", "93"]) + assert strmenu(menu) == "(17) 18 19 20" + assert " ".join(menu.get_result()) == "05 17 93" + + def test_multiple_empty_list(self): + menu = Termenu(OPTIONS, height=4, default=[]) + assert strmenu(menu) == "(01) 02 03 04" + assert " ".join(menu.get_result()) == "01" + +class MultiSelect(unittest.TestCase): + def test_select(self): + menu = Termenu(OPTIONS, height=4) + assert strmenu(menu) == "(01) 02 03 04" + menu._on_space() + menu._on_space() + assert strmenu(menu) == "01 02 (03) 04" + assert " ".join(menu.get_result()) == "01 02" + assert " ".join(menu.get_result()) == "01 02" + + def test_deselect(self): + menu = Termenu(OPTIONS, height=4) + assert strmenu(menu) == "(01) 02 03 04" + menu._on_space() + assert " ".join(menu.get_result()) == "01" + menu._on_up() + menu._on_space() + assert strmenu(menu) == "01 (02) 03 04" + assert " ".join(menu.get_result()) == "02" + + def test_off(self): + menu = Termenu(OPTIONS, height=4, multiselect=False) + assert strmenu(menu) == "(01) 02 03 04" + menu._on_space() + assert strmenu(menu) == "(01) 02 03 04" + assert menu.get_result() == "01" + +class Results(unittest.TestCase): + def test_single(self): + menu = Termenu(zip(OPTIONS, RESULTS), height=4) + assert strmenu(menu) == "(01) 02 03 04" + menu._on_down() + menu._on_down() + assert strmenu(menu) == "01 02 (03) 04" + assert menu.get_result() == ["result-03"] + + def test_multiple(self): + menu = Termenu(zip(OPTIONS, RESULTS), height=4) + assert strmenu(menu) == "(01) 02 03 04" + menu._on_space() + menu._on_space() + assert strmenu(menu) == "01 02 (03) 04" + assert menu.get_result() == ["result-01", "result-02"] + +def active(s): + return ansi.colorize(s, "black", "white") + +def selected(s): + return ansi.colorize(s, "red") + +def active_selected(s): + return ansi.colorize(s, "red", "white") + +def white(s): + return ansi.colorize(s, "white", bright=True) + +class Decorate(unittest.TestCase): + def test_active(self): + menu = Termenu(OPTIONS, height=4) + assert menu._decorate("text", active=True) == " " + active("text") + " " + + def test_selected(self): + menu = Termenu(OPTIONS, height=4) + assert menu._decorate("text", selected=True) == "*" + selected("text") + " " + + def test_active_selected(self): + menu = Termenu(OPTIONS, height=4) + assert menu._decorate("text", active=True, selected=True) == "*" + active_selected("text") + " " + + def test_more_above(self): + menu = Termenu(OPTIONS, height=4) + assert menu._decorate("text", active=True, selected=True, moreAbove=True) == "*" + active_selected("text") + " " + white("^") + + def test_more_below(self): + menu = Termenu(OPTIONS, height=4) + assert menu._decorate("text", active=True, selected=True, moreBelow=True) == "*" + active_selected("text") + " " + white("v") + + def test_max_opti_on_len(self): + menu = Termenu("one three fifteen twenty eleven".split(), height=4) + assert menu._decorate("three", active=True, selected=True) == "*" + active_selected("three") + " " + +class DecorateFlags(unittest.TestCase): + def test_active(self): + menu = Termenu(OPTIONS, height=4) + assert [menu._decorate_flags(i)["active"] for i in xrange(4)] == [True, False, False, False] + + def test_selected(self): + menu = Termenu(OPTIONS, height=4) + menu._on_down() + menu._on_space() + menu._on_space() + assert [menu._decorate_flags(i)["selected"] for i in xrange(4)] == [False, True, True, False] + + def test_more_above_none(self): + menu = Termenu(OPTIONS, height=4) + assert [menu._decorate_flags(i)["moreAbove"] for i in xrange(4)] == [False, False, False, False] + + def test_more_above_one(self): + menu = Termenu(OPTIONS, height=4) + menu.scroll = 1 + assert [menu._decorate_flags(i)["moreAbove"] for i in xrange(4)] == [True, False, False, False] + + def test_more_below_one(self): + menu = Termenu(OPTIONS, height=4) + assert [menu._decorate_flags(i)["moreBelow"] for i in xrange(4)] == [False, False, False, True] + + def test_more_below_none(self): + menu = Termenu(OPTIONS, height=4) + menu.scroll = len(OPTIONS) - 4 + assert [menu._decorate_flags(i)["moreBelow"] for i in xrange(4)] == [False, False, False, False] + +class Plugins(unittest.TestCase): + class SamplePlugin(Plugin): + def __init__(self, callPrev): + self.ran = False + self.callPrev = callPrev + def _on_key(self, key): + self.ran = True + if self.callPrev: + self.parent._on_key(key) + + def test_multiple_plugins_all(self): + plugins = [self.SamplePlugin(True), self.SamplePlugin(True), self.SamplePlugin(True)] + menu = Termenu(OPTIONS, height=4, plugins=plugins) + assert strmenu(menu) == "(01) 02 03 04" + menu._on_key("down") + assert strmenu(menu) == "01 (02) 03 04" + assert [p.ran for p in plugins] == [True, True, True] + + def test_multiple_plugins_no_call_prev(self): + plugins = [self.SamplePlugin(False), self.SamplePlugin(False), self.SamplePlugin(False)] + menu = Termenu(OPTIONS, height=4, plugins=plugins) + assert strmenu(menu) == "(01) 02 03 04" + menu._on_key("down") + assert strmenu(menu) == "(01) 02 03 04" + assert [p.ran for p in plugins] == [False, False, True] + +class FilterPluginTest(unittest.TestCase): + def test_filter(self): + menu = Termenu(OPTIONS, height=4, plugins=[FilterPlugin()]) + menu._on_key("4") + assert strmenu(menu) == "(04) 14 24 34" + + def test_case_insensitive(self): + menu = Termenu("ONE TWO THREE FOUR FIVE SIX SEVEN".split(), height=4, plugins=[FilterPlugin()]) + menu._on_key("e") + assert strmenu(menu) == "(ONE) THREE FIVE SEVEN" + + def test_backspace(self): + menu = Termenu("one two three four five six seven".split(), height=4, plugins=[FilterPlugin()]) + assert strmenu(menu) == "(one) two three four" + menu._on_key("e") + assert strmenu(menu) == "(one) three five seven" + menu._on_key("n") + assert strmenu(menu) == "(seven)" + menu._on_key("backspace") + assert strmenu(menu) == "(one) three five seven" + menu._on_key("backspace") + assert strmenu(menu) == "(one) two three four" + + def test_esc(self): + menu = Termenu("one two three four five six seven".split(), height=4, plugins=[FilterPlugin()]) + assert strmenu(menu) == "(one) two three four" + menu._on_key("e") + menu._on_key("n") + assert strmenu(menu) == "(seven)" + menu._on_key("esc") + assert strmenu(menu) == "(one) two three four" + +if __name__ == "__main__": + unittest.main() diff --git a/version.py b/termenu/version.py similarity index 100% rename from version.py rename to termenu/version.py diff --git a/test.py b/test.py index b72161f..2b768b4 100644 --- a/test.py +++ b/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)] From 3d547730abf91693597fd59d29ba84a5cedd892a Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Wed, 29 Oct 2014 13:55:54 +0200 Subject: [PATCH 04/60] updates for AppMenu --- examples/app1.py | 70 +++++++++++++++++++++++++++++++ examples/app2.py | 29 +++++++++++++ termenu/app.py | 105 ++--------------------------------------------- 3 files changed, 102 insertions(+), 102 deletions(-) create mode 100644 examples/app1.py create mode 100644 examples/app2.py diff --git a/examples/app1.py b/examples/app1.py new file mode 100644 index 0000000..bc82d41 --- /dev/null +++ b/examples/app1.py @@ -0,0 +1,70 @@ +import time +from termenu.app import AppMenu + +class TopMenu(AppMenu): + title = staticmethod(lambda: "YELLOW<<%s>>" % time.ctime()) + timeout = 15 + submenus = ["Letters", "Numbers", "Submenu", "Foo", "Bar"] + + class Letters(AppMenu): + title = "CYAN(BLUE)<>" + option_name = "BLUE<>" + multiselect = True + items = [chr(i) for i in xrange(65, 91)] + def action(self, letters): + raw_input("Selected: %s" % "".join(letters)) + + class Numbers(AppMenu): + multiselect = True + @property + def items(self): + return 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" + raw_input("Min: %s, Max: %s" % (min(numbers), max(numbers))) + self.retry() + def Add(self, numbers): + raw_input("Sum: %s" % sum(numbers)) + self.back() + def Multiply(self, numbers): + raw_input("Mult: %s" % reduce((lambda a, b: a*b), numbers)) + def Quit(self, numbers): + raw_input("%s" % numbers) + self.quit() + + + class Submenu(AppMenu): + submenus = ["Letter", "Number"] + + class Letter(AppMenu): + @property + def items(self): + return [chr(i) for i in xrange(65, 91)][int(time.time()*2) % 10:][:10] + def action(self, letter): + raw_input("Selected: %s" % letter) + self.back() + + class Number(AppMenu): + items = range(50) + def action(self, number): + raw_input("Sum: %s" % number) + + def Foo(self): + raw_input("Foo?") + + def Bar(object): + raw_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..1f6c7cb --- /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/termenu/app.py b/termenu/app.py index 448551c..fe77b22 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -409,7 +409,7 @@ def on_selected(self, selected): else: to_submenu = lambda action: (action.__doc__ or action.__name__, functools.partial(action, selected)) actions = [action if callable(action) else getattr(self, action) for action in actions] - ret = self.show_menu(title=self.get_selection_title(selected), options=map(to_submenu, actions)) + ret = self.show(title=self.get_selection_title(selected), options=map(to_submenu, actions)) if ret is not None: self.result(ret) @@ -442,115 +442,16 @@ def quit(cls): raise cls.QuitSignal() @staticmethod - def show_menu(title, options, default=None, back_on_abort=True, **kwargs): + def show(title, options, default=None, back_on_abort=True, **kwargs): kwargs.update(title=title, items=options, default=default, back_on_abort=back_on_abort) menu = type("AdHocMenu", (AppMenu,), kwargs)() return menu.return_value - - -def test1(): - - class TopMenu(AppMenu): - title = staticmethod(lambda: "YELLOW<<%s>>" % time.ctime()) - timeout = 15 - submenus = ["Letters", "Numbers", "Submenu", "Foo", "Bar"] - - class Letters(AppMenu): - title = "CYAN(BLUE)<>" - option_name = "BLUE<>" - multiselect = True - items = [chr(i) for i in xrange(65, 91)] - def action(self, letters): - raw_input("Selected: %s" % "".join(letters)) - - class Numbers(AppMenu): - multiselect = True - @property - def items(self): - return 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" - raw_input("Min: %s, Max: %s" % (min(numbers), max(numbers))) - self.retry() - def Add(self, numbers): - raw_input("Sum: %s" % sum(numbers)) - self.back() - def Multiply(self, numbers): - raw_input("Mult: %s" % reduce((lambda a, b: a*b), numbers)) - def Quit(self, numbers): - raw_input("%s" % numbers) - self.quit() - - - class Submenu(AppMenu): - submenus = ["Letter", "Number"] - - class Letter(AppMenu): - @property - def items(self): - return [chr(i) for i in xrange(65, 91)][int(time.time()*2) % 10:][:10] - def action(self, letter): - raw_input("Selected: %s" % letter) - self.back() - - class Number(AppMenu): - items = range(50) - def action(self, number): - raw_input("Sum: %s" % number) - - def Foo(self): - raw_input("Foo?") - - def Bar(object): - raw_input("Bar! ") - Bar.get_option_name = lambda: "Dynamic option name: (%s)" % (int(time.time()) % 20) - - TopMenu() - - -def test2(): - - def leave(): - print "Leave..." - AppMenu.quit() - - def go(): - def back(): - print "Going back." - AppMenu.back() - - def there(): - ret = AppMenu.show_menu("Where's there?", - "Spain France Albania".split() + [("Quit", AppMenu.quit)], - multiselect=True, back_on_abort=True) - print ret - return ret - - return AppMenu.show_menu("Go Where?", [ - ("YELLOW<>", back), - ("GREEN<>", there) - ]) - - return AppMenu.show_menu("Make your MAGENTA<>", [ - ("RED<>", leave), - ("BLUE<>", go) - ]) - - if __name__ == '__main__': import pdb try: - ret = AppMenu.show_menu("AppMenu", [ + ret = AppMenu.show("AppMenu", [ ("Debug", pdb.set_trace), ("Test1", test1), ("Test2", test2) From f93903fa03927ccca9e3940834403b282b383c96 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 6 Sep 2015 19:55:40 +0300 Subject: [PATCH 05/60] python3 compatibility fixes --- examples/__init__.py | 0 examples/app1.py | 32 ++-- examples/app2.py | 6 +- examples/filemenu.py | 2 +- examples/loading_menu.py | 2 +- examples/paged_menu.py | 10 +- examples/sourcemenu.py | 86 ---------- termenu/ansi.py | 4 +- termenu/app.py | 52 +++--- termenu/colors.py | 76 ++++++--- termenu/keyboard.py | 35 ++-- termenu/termenu.py | 43 +++-- termenu/test.py | 348 --------------------------------------- 13 files changed, 159 insertions(+), 537 deletions(-) create mode 100644 examples/__init__.py delete mode 100644 examples/sourcemenu.py delete mode 100644 termenu/test.py 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 index bc82d41..af294fb 100644 --- a/examples/app1.py +++ b/examples/app1.py @@ -1,5 +1,11 @@ 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()) @@ -10,15 +16,15 @@ class Letters(AppMenu): title = "CYAN(BLUE)<>" option_name = "BLUE<>" multiselect = True - items = [chr(i) for i in xrange(65, 91)] + items = [chr(i) for i in range(65, 91)] def action(self, letters): - raw_input("Selected: %s" % "".join(letters)) + input("Selected: %s" % "".join(letters)) class Numbers(AppMenu): multiselect = True @property def items(self): - return range(int(time.time()*2) % 10, 50) + return list(range(int(time.time()*2) % 10, 50)) def get_selection_actions(self, selection): yield "MinMax" yield "Add" @@ -29,15 +35,15 @@ def get_selection_title(self, selection): return ", ".join(map(str, sorted(selection))) def MinMax(self, numbers): "Min/Max" - raw_input("Min: %s, Max: %s" % (min(numbers), max(numbers))) + input("Min: %s, Max: %s" % (min(numbers), max(numbers))) self.retry() def Add(self, numbers): - raw_input("Sum: %s" % sum(numbers)) + input("Sum: %s" % sum(numbers)) self.back() def Multiply(self, numbers): - raw_input("Mult: %s" % reduce((lambda a, b: a*b), numbers)) + input("Mult: %s" % reduce((lambda a, b: a*b), numbers)) def Quit(self, numbers): - raw_input("%s" % numbers) + input("%s" % numbers) self.quit() @@ -47,21 +53,21 @@ class Submenu(AppMenu): class Letter(AppMenu): @property def items(self): - return [chr(i) for i in xrange(65, 91)][int(time.time()*2) % 10:][:10] + return [chr(i) for i in range(65, 91)][int(time.time()*2) % 10:][:10] def action(self, letter): - raw_input("Selected: %s" % letter) + input("Selected: %s" % letter) self.back() class Number(AppMenu): - items = range(50) + items = list(range(50)) def action(self, number): - raw_input("Sum: %s" % number) + input("Sum: %s" % number) def Foo(self): - raw_input("Foo?") + input("Foo?") def Bar(object): - raw_input("Bar! ") + input("Bar! ") Bar.get_option_name = lambda: "Dynamic option name: (%s)" % (int(time.time()) % 20) diff --git a/examples/app2.py b/examples/app2.py index 1f6c7cb..ea78f4b 100644 --- a/examples/app2.py +++ b/examples/app2.py @@ -2,19 +2,19 @@ from termenu.app import AppMenu def leave(): - print "Leave..." + print("Leave...") AppMenu.quit() def go(): def back(): - print "Going 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 + print(ret) return ret return AppMenu.show("Go Where?", [ diff --git a/examples/filemenu.py b/examples/filemenu.py index 438cc1e..1c5e35c 100644 --- a/examples/filemenu.py +++ b/examples/filemenu.py @@ -61,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/examples/sourcemenu.py b/examples/sourcemenu.py deleted file mode 100644 index d7225c6..0000000 --- a/examples/sourcemenu.py +++ /dev/null @@ -1,86 +0,0 @@ -import os -import sys -from menu import AppMenu - -""" -This example shows how to implement a file browser using multi-level menus -and custom menu item decoration. -""" - -class FilePlugin(termenu.Plugin): - # TODO go back one level using backspace - def _decorate_flags(self, index): - flags = self.parent._decorate_flags(index) - flags.update(dict( - directory = self.host.options[self.host.scroll+index].text[-1] == "/", - exe = isexe(self.host.options[self.host.scroll+index].text) - )) - return flags - - def _decorate(self, option, **flags): - directory = flags.get("directory", False) - exe = flags.get("exe", False) - active = flags.get("active", False) - selected = flags.get("selected", False) - if active: - if directory: - option = termenu.ansi.colorize(option, "blue", "white", bright=True) - elif exe: - option = termenu.ansi.colorize(option, "green", "white", bright=True) - else: - option = termenu.ansi.colorize(option, "black", "white") - elif directory: - option = termenu.ansi.colorize(option, "blue", bright=True) - elif exe: - option = termenu.ansi.colorize(option, "green", bright=True) - if selected: - option = termenu.ansi.colorize("* ", "red") + option - else: - option = " " + option - - return self.host._decorate_indicators(option, **flags) - -def isexe(path): - return os.path.isfile(path) and os.access(path, os.X_OK) - -def list_files(): - dirs = list(sorted([f+"/" for f in os.listdir(".") if os.path.isdir(f)])) - files = list(sorted([f for f in os.listdir(".") if not os.path.isdir(f)])) - entries = dirs + files - entries = [e for e in entries if e[0] != "."] - if os.getcwd() != "/": - entries = ["../"] + entries - return entries - - -def main(): - from pathlib import Path - def select_file(path): - AppMenu.show_menu(str(path), path) - - def go(): - def back(): - print "Going back." - AppMenu.back() - - def there(): - ret = AppMenu.show_menu("Where's there?", - "Spain France Albania".split() + [("Quit", AppMenu.quit)], - multiselect=True, back_on_abort=True) - print ret - return ret - - return AppMenu.show_menu("Go Where?", [ - ("YELLOW<>", back), - ("GREEN<>", there) - ]) - - return AppMenu.show_menu("Make your MAGENTA<>", [ - ("RED<>", leave), - ("BLUE<>", go) - ]) - - - -if __name__ == "__main__": - main() diff --git a/termenu/ansi.py b/termenu/ansi.py index 2f719c4..fea8aa6 100644 --- a/termenu/ansi.py +++ b/termenu/ansi.py @@ -1,4 +1,4 @@ -from __future__ import print_function + import errno import sys @@ -106,7 +106,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 index fe77b22..532aed8 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -1,9 +1,10 @@ import time import functools -import termenu +from . import termenu from contextlib import contextmanager -import ansi -from colors import Colorized +from . import ansi +from .colors import Colorized +import collections #=============================================================================== # Termenu @@ -109,7 +110,7 @@ def _set_default(self, default): break else: return - for i in xrange(index): + for i in range(index): self._on_down() def _adjust_width(self, option): @@ -182,7 +183,7 @@ def _print_footer(self): def _print_menu(self): ansi.write("\r%s\n" % self.title) super(TermenuAdapter, self)._print_menu() - for _ in xrange(0, self.height - len(self.options)): + for _ in range(0, self.height - len(self.options)): termenu.ansi.clear_eol() termenu.ansi.write("\n") self._print_footer() @@ -204,7 +205,7 @@ def _clear_menu(self): termenu.ansi.restore_position() height = self.get_total_height() if clear: - for i in xrange(height): + for i in range(height): termenu.ansi.clear_eol() termenu.ansi.up() termenu.ansi.clear_eol() @@ -240,7 +241,7 @@ def __init__(self, message="", *args, **kwargs): if args: message %= args self.message = message - for k, v in kwargs.iteritems(): + for k, v in kwargs.items(): setattr(self, k, v) self.params = kwargs @@ -269,7 +270,7 @@ def get_option_name(cls): @property def height(self): - return termenu.get_terminal_size()[1] / 2 + return termenu.get_terminal_size()[1] // 2 @property def items(self): @@ -282,7 +283,7 @@ def items(self): # convert named submenus to submenu objects (functions/classes) submenus = ( - getattr(self, name) if isinstance(name, basestring) else name + getattr(self, name) if isinstance(name, str) else name for name in self.submenus ) @@ -328,9 +329,9 @@ def _menu_loop(self): while True: if refresh: title = self.title - titles = [t() if callable(t) else t for t in self._all_titles + [title]] + titles = [t() if isinstance(t, collections.Callable) else t for t in self._all_titles + [title]] banner = self.banner - if callable(banner): + if isinstance(banner, collections.Callable): banner = banner() menu.reset( @@ -359,13 +360,13 @@ def _menu_loop(self): self._all_titles.append(title) try: self.on_selected(selected) - except self.RetrySignal, e: + except self.RetrySignal as e: refresh = e.refresh # will refresh by default unless told differently continue except (KeyboardInterrupt): refresh = False # show the same menu continue - except self.BackSignal, e: + except self.BackSignal as e: if e.levels: e.levels -= 1 raise @@ -380,7 +381,7 @@ def _menu_loop(self): if self.parent: raise - except self.ReturnSignal, e: + except self.ReturnSignal as e: self.return_value = e.value def action(self, selected): @@ -389,14 +390,14 @@ def evaluate(item): # 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 callable(item): + if isinstance(item, collections.Callable): item = item() if isinstance(item, self._MenuSignal): raise item if isinstance(item, AppMenu): return return item - return map(evaluate, selected) if hasattr(selected, "__iter__") else evaluate(selected) + return list(map(evaluate, selected)) if hasattr(selected, "__iter__") else evaluate(selected) def on_selected(self, selected): if not selected: @@ -408,8 +409,8 @@ def on_selected(self, selected): ret = self.action(selected) else: to_submenu = lambda action: (action.__doc__ or action.__name__, functools.partial(action, selected)) - actions = [action if callable(action) else getattr(self, action) for action in actions] - ret = self.show(title=self.get_selection_title(selected), options=map(to_submenu, actions)) + 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))) if ret is not None: self.result(ret) @@ -446,18 +447,3 @@ def show(title, options, default=None, back_on_abort=True, **kwargs): kwargs.update(title=title, items=options, default=default, back_on_abort=back_on_abort) menu = type("AdHocMenu", (AppMenu,), kwargs)() return menu.return_value - - -if __name__ == '__main__': - import pdb - try: - ret = AppMenu.show("AppMenu", [ - ("Debug", pdb.set_trace), - ("Test1", test1), - ("Test2", test2) - ], - timeout=5, heartbeat=1, - ) - print "Result is:", ret - except AppMenu.TimeoutSignal: - print "Timed out" diff --git a/termenu/colors.py b/termenu/colors.py index 21f75eb..c030a97 100644 --- a/termenu/colors.py +++ b/termenu/colors.py @@ -1,7 +1,8 @@ #!/usr/bin/env python +from __future__ import print_function import re -import ansi +from . import ansi colorizers_cache = {} @@ -12,7 +13,7 @@ ) _RE_COLOR = re.compile( # 'RED<>', 'RED(WHITE)<>', 'RED@{text}@' - r"(?ms)" # flags: mutliline/dot-all + r"(?ms)" # flags: mutliline/dot-all "([A-Z_]+" # foreground color "(?:\([^\)]+\))?" # optional background color "(?:(?:\<\<).*?(?:\>\>)" # text string inside <<...>> @@ -23,7 +24,7 @@ _RE_COLORING = re.compile( # 'RED', 'RED(WHITE)' r"(?ms)" - "([A-Z_]+(?:\([^\)]+\))?)" # foreground color and optional background color + "([A-Z_]+(?:\([^\)]+\))?)" # foreground color and optional background color "((?:\<\<.*?\>\>|\@\{.*?\}\@))" # text string inside either <<...>> or @{...}@ ) @@ -35,12 +36,16 @@ def get_colorizer(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) + fmt = ansi.colorize("{TEXT}", color, background, bright=bright) colorizer = lambda text: fmt.format(TEXT=text) return add_colorizer(name, colorizer) @@ -65,29 +70,39 @@ def colorize_by_patterns(text, no_color=False): 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) 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 "%s<<%s>>" % (self.__name, str.__str__(self)) + def __repr__(self): return repr(self.raw()) @@ -97,7 +112,7 @@ def __new__(cls, text): for text in _RE_COLOR.split(text): match = _RE_COLORING.match(text) if match: - stl = match.group(1) + stl = match.group(1).strip("_") text = match.group(2)[2:-2] for l in text.splitlines(): self.tokens.append(self.ColoredToken(l, stl)) @@ -109,8 +124,10 @@ def __new__(cls, 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 @@ -150,61 +167,82 @@ def inner(self, *args): title = withcolored(str.title) upper = withcolored(str.upper) - def __getslice__(self, 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)) def __getitem__(self, idx): if isinstance(idx, slice) and idx.step is None: - return self[idx.start:idx.stop] # (__getslice__) + cursor = 0 + tokens = [] + for token in self.tokens: + tokens.append(token[max(0, idx.start - cursor):idx.stop - cursor]) + cursor += len(token) + if cursor > idx.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) + 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 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 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 index af02b44..847ed65 100644 --- a/termenu/keyboard.py +++ b/termenu/keyboard.py @@ -1,5 +1,5 @@ -from __future__ import with_statement -from __future__ import print_function + + import os import sys @@ -35,7 +35,7 @@ F12 = '\x1b[24~', ) -KEY_NAMES = dict((v,k) for k,v in ANSI_SEQUENCES.items()) +KEY_NAMES = dict((v,k) for k,v in list(ANSI_SEQUENCES.items())) KEY_NAMES.update({ '\x1b' : 'esc', '\n' : 'enter', @@ -43,6 +43,7 @@ '\x7f' : 'backspace', }) + class RawTerminal(object): def __init__(self, blocking=True): self._blocking = blocking @@ -65,7 +66,10 @@ def close(self): fcntl.fcntl(STDIN, fcntl.F_SETFL, self._old) def get(self): - return sys.stdin.read(1) + ret = sys.stdin.read(1) + if not ret: + raise EOFError() + return ret def wait(self): select.select([STDIN], [], []) @@ -77,39 +81,42 @@ def __enter__(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) + ret, _, __ = select.select([STDIN], [], [], heartbeat) + if not ret: + yield "heartbeat" + continue + # read all available keys while True: try: - sequence = sequence + terminal.get() + 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 ANSI_SEQUENCES.values(): + for seq in list(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/termenu/termenu.py b/termenu/termenu.py index 5aabf87..846f3b0 100644 --- a/termenu/termenu.py +++ b/termenu/termenu.py @@ -1,4 +1,4 @@ -from __future__ import print_function + import sys from .version import version @@ -40,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): """ @@ -58,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 @@ -72,11 +74,13 @@ 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): class _Option(object): def __init__(self, option, **attrs): @@ -262,7 +266,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() @@ -329,6 +333,7 @@ def _decorate_indicators(self, option, **flags): return option + class FilterPlugin(Plugin): def __init__(self): self.text = None @@ -360,7 +365,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: @@ -389,6 +394,7 @@ def __init__(self, header, options): self.header = header self.options = options + class OptionGroupPlugin(Plugin): def _set_default(self, default): if default: @@ -453,6 +459,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) @@ -483,6 +490,7 @@ def _decorate(self, option, **flags): return option + class TitlePlugin(Plugin): def __init__(self, title): self.title = title @@ -500,6 +508,7 @@ def _clear_menu(self): ansi.up() ansi.clear_eol() + class Minimenu(object): def __init__(self, options, default=None): self.options = options @@ -550,6 +559,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 @@ -563,18 +573,25 @@ def redirect_std(): sys.stdout = open("/dev/tty", "w", 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:] -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 + +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 + 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/termenu/test.py b/termenu/test.py deleted file mode 100644 index 2b768b4..0000000 --- a/termenu/test.py +++ /dev/null @@ -1,348 +0,0 @@ -import unittest -from termenu import ansi -from termenu import Termenu, Plugin, FilterPlugin - -OPTIONS = ["%02d" % i for i in xrange(1,100)] -RESULTS = ["result-%02d" % i for i in xrange(1,100)] - -def strmenu(menu): - return menu._get_debug_view() - -class Down(unittest.TestCase): - def test_cursor_top(self): - menu = Termenu(OPTIONS, height=3) - assert strmenu(menu) == "(01) 02 03" - menu._on_down() - assert strmenu(menu) == "01 (02) 03" - - def test_cursor_middle(self): - menu = Termenu(OPTIONS, height=3) - menu.cursor = 1 - assert strmenu(menu) == "01 (02) 03" - menu._on_down() - assert strmenu(menu) == "01 02 (03)" - - def test_cursor_bottom(self): - menu = Termenu(OPTIONS, height=3) - menu.cursor = 2 - assert strmenu(menu) == "01 02 (03)" - menu._on_down() - assert strmenu(menu) == "02 03 (04)" - - def test_scroll_bottom_cursor_bottom(self): - menu = Termenu(OPTIONS, height=3) - menu.scroll = len(OPTIONS) - 3 - menu.cursor = 2 - assert strmenu(menu) == "97 98 (99)" - menu._on_down() - assert strmenu(menu) == "97 98 (99)" - -class Up(unittest.TestCase): - def test_cursor_top(self): - menu = Termenu(OPTIONS, height=3) - menu.cursor = 0 - assert strmenu(menu) == "(01) 02 03" - menu._on_up() - assert strmenu(menu) == "(01) 02 03" - - def test_cursor_middle(self): - menu = Termenu(OPTIONS, height=3) - menu.cursor = 1 - assert strmenu(menu) == "01 (02) 03" - menu._on_up() - assert strmenu(menu) == "(01) 02 03" - - def test_cursor_bottom(self): - menu = Termenu(OPTIONS, height=3) - menu.cursor = 2 - assert strmenu(menu) == "01 02 (03)" - menu._on_up() - assert strmenu(menu) == "01 (02) 03" - - def test_scroll_bottom_cursor_top(self): - menu = Termenu(OPTIONS, height=3) - menu.scroll = len(OPTIONS) - 3 - menu.cursor = 0 - assert strmenu(menu) == "(97) 98 99" - menu._on_up() - assert strmenu(menu) == "(96) 97 98" - -class PageDown(unittest.TestCase): - def test_cursor_top(self): - menu = Termenu(OPTIONS, height=4) - assert strmenu(menu) == "(01) 02 03 04" - menu._on_pageDown() - assert strmenu(menu) == "01 02 03 (04)" - - def test_cursor_middle(self): - menu = Termenu(OPTIONS, height=4) - menu.cursor = 1 - assert strmenu(menu) == "01 (02) 03 04" - menu._on_pageDown() - assert strmenu(menu) == "01 02 03 (04)" - - def test_cursor_bottom(self): - menu = Termenu(OPTIONS, height=4) - menu.cursor = 3 - assert strmenu(menu) == "01 02 03 (04)" - menu._on_pageDown() - assert strmenu(menu) == "05 06 07 (08)" - - def test_scroll_bottom_cursor_bottom(self): - menu = Termenu(OPTIONS, height=4) - menu.scroll = len(OPTIONS) - 4 - menu.cursor = 3 - assert strmenu(menu) == "96 97 98 (99)" - menu._on_pageDown() - assert strmenu(menu) == "96 97 98 (99)" - - def test_scroll_almost_bottom_cursor_bottom(self): - menu = Termenu(OPTIONS, height=4) - menu.scroll = len(OPTIONS) - 5 - menu.cursor = 3 - assert strmenu(menu) == "95 96 97 (98)" - menu._on_pageDown() - assert strmenu(menu) == "96 97 98 (99)" - -class PageUp(unittest.TestCase): - def test_cursor_top(self): - menu = Termenu(OPTIONS, height=4) - assert strmenu(menu) == "(01) 02 03 04" - menu._on_pageUp() - assert strmenu(menu) == "(01) 02 03 04" - - def test_cursor_middle(self): - menu = Termenu(OPTIONS, height=4) - menu.cursor = 2 - assert strmenu(menu) == "01 02 (03) 04" - menu._on_pageUp() - assert strmenu(menu) == "(01) 02 03 04" - - def test_cursor_bottom(self): - menu = Termenu(OPTIONS, height=4) - menu.cursor = 3 - assert strmenu(menu) == "01 02 03 (04)" - menu._on_pageUp() - assert strmenu(menu) == "(01) 02 03 04" - - def test_scroll_bottom_cursor_top(self): - menu = Termenu(OPTIONS, height=4) - menu.scroll = len(OPTIONS) - 4 - assert strmenu(menu) == "(96) 97 98 99" - menu._on_pageUp() - assert strmenu(menu) == "(92) 93 94 95" - - def test_scroll_almost_top_cursor_top(self): - menu = Termenu(OPTIONS, height=4) - menu.scroll = 1 - assert strmenu(menu) == "(02) 03 04 05" - menu._on_pageUp() - assert strmenu(menu) == "(01) 02 03 04" - -class Default(unittest.TestCase): - def test_found(self): - menu = Termenu(OPTIONS, height=4, default="03") - assert strmenu(menu) == "01 02 (03) 04" - - def test_notfount(self): - menu = Termenu(OPTIONS, height=4, default="asdf") - assert strmenu(menu) == "(01) 02 03 04" - - def test_requires_scroll(self): - menu = Termenu(OPTIONS, height=4, default="55") - assert strmenu(menu) == "(55) 56 57 58" - - def test_last(self): - menu = Termenu(OPTIONS, height=4, default="99") - assert strmenu(menu) == "96 97 98 (99)" - - def test_before_last(self): - menu = Termenu(OPTIONS, height=4, default="97") - assert strmenu(menu) == "96 (97) 98 99" - - def test_multiple(self): - menu = Termenu(OPTIONS, height=4, default=["05", "17", "93"]) - assert strmenu(menu) == "(05) 06 07 08" - assert " ".join(menu.get_result()) == "05 17 93" - - def test_multiple_active(self): - menu = Termenu(OPTIONS, height=4, default=["17", "05", "93"]) - assert strmenu(menu) == "(17) 18 19 20" - assert " ".join(menu.get_result()) == "05 17 93" - - def test_multiple_empty_list(self): - menu = Termenu(OPTIONS, height=4, default=[]) - assert strmenu(menu) == "(01) 02 03 04" - assert " ".join(menu.get_result()) == "01" - -class MultiSelect(unittest.TestCase): - def test_select(self): - menu = Termenu(OPTIONS, height=4) - assert strmenu(menu) == "(01) 02 03 04" - menu._on_space() - menu._on_space() - assert strmenu(menu) == "01 02 (03) 04" - assert " ".join(menu.get_result()) == "01 02" - assert " ".join(menu.get_result()) == "01 02" - - def test_deselect(self): - menu = Termenu(OPTIONS, height=4) - assert strmenu(menu) == "(01) 02 03 04" - menu._on_space() - assert " ".join(menu.get_result()) == "01" - menu._on_up() - menu._on_space() - assert strmenu(menu) == "01 (02) 03 04" - assert " ".join(menu.get_result()) == "02" - - def test_off(self): - menu = Termenu(OPTIONS, height=4, multiselect=False) - assert strmenu(menu) == "(01) 02 03 04" - menu._on_space() - assert strmenu(menu) == "(01) 02 03 04" - assert menu.get_result() == "01" - -class Results(unittest.TestCase): - def test_single(self): - menu = Termenu(zip(OPTIONS, RESULTS), height=4) - assert strmenu(menu) == "(01) 02 03 04" - menu._on_down() - menu._on_down() - assert strmenu(menu) == "01 02 (03) 04" - assert menu.get_result() == ["result-03"] - - def test_multiple(self): - menu = Termenu(zip(OPTIONS, RESULTS), height=4) - assert strmenu(menu) == "(01) 02 03 04" - menu._on_space() - menu._on_space() - assert strmenu(menu) == "01 02 (03) 04" - assert menu.get_result() == ["result-01", "result-02"] - -def active(s): - return ansi.colorize(s, "black", "white") - -def selected(s): - return ansi.colorize(s, "red") - -def active_selected(s): - return ansi.colorize(s, "red", "white") - -def white(s): - return ansi.colorize(s, "white", bright=True) - -class Decorate(unittest.TestCase): - def test_active(self): - menu = Termenu(OPTIONS, height=4) - assert menu._decorate("text", active=True) == " " + active("text") + " " - - def test_selected(self): - menu = Termenu(OPTIONS, height=4) - assert menu._decorate("text", selected=True) == "*" + selected("text") + " " - - def test_active_selected(self): - menu = Termenu(OPTIONS, height=4) - assert menu._decorate("text", active=True, selected=True) == "*" + active_selected("text") + " " - - def test_more_above(self): - menu = Termenu(OPTIONS, height=4) - assert menu._decorate("text", active=True, selected=True, moreAbove=True) == "*" + active_selected("text") + " " + white("^") - - def test_more_below(self): - menu = Termenu(OPTIONS, height=4) - assert menu._decorate("text", active=True, selected=True, moreBelow=True) == "*" + active_selected("text") + " " + white("v") - - def test_max_opti_on_len(self): - menu = Termenu("one three fifteen twenty eleven".split(), height=4) - assert menu._decorate("three", active=True, selected=True) == "*" + active_selected("three") + " " - -class DecorateFlags(unittest.TestCase): - def test_active(self): - menu = Termenu(OPTIONS, height=4) - assert [menu._decorate_flags(i)["active"] for i in xrange(4)] == [True, False, False, False] - - def test_selected(self): - menu = Termenu(OPTIONS, height=4) - menu._on_down() - menu._on_space() - menu._on_space() - assert [menu._decorate_flags(i)["selected"] for i in xrange(4)] == [False, True, True, False] - - def test_more_above_none(self): - menu = Termenu(OPTIONS, height=4) - assert [menu._decorate_flags(i)["moreAbove"] for i in xrange(4)] == [False, False, False, False] - - def test_more_above_one(self): - menu = Termenu(OPTIONS, height=4) - menu.scroll = 1 - assert [menu._decorate_flags(i)["moreAbove"] for i in xrange(4)] == [True, False, False, False] - - def test_more_below_one(self): - menu = Termenu(OPTIONS, height=4) - assert [menu._decorate_flags(i)["moreBelow"] for i in xrange(4)] == [False, False, False, True] - - def test_more_below_none(self): - menu = Termenu(OPTIONS, height=4) - menu.scroll = len(OPTIONS) - 4 - assert [menu._decorate_flags(i)["moreBelow"] for i in xrange(4)] == [False, False, False, False] - -class Plugins(unittest.TestCase): - class SamplePlugin(Plugin): - def __init__(self, callPrev): - self.ran = False - self.callPrev = callPrev - def _on_key(self, key): - self.ran = True - if self.callPrev: - self.parent._on_key(key) - - def test_multiple_plugins_all(self): - plugins = [self.SamplePlugin(True), self.SamplePlugin(True), self.SamplePlugin(True)] - menu = Termenu(OPTIONS, height=4, plugins=plugins) - assert strmenu(menu) == "(01) 02 03 04" - menu._on_key("down") - assert strmenu(menu) == "01 (02) 03 04" - assert [p.ran for p in plugins] == [True, True, True] - - def test_multiple_plugins_no_call_prev(self): - plugins = [self.SamplePlugin(False), self.SamplePlugin(False), self.SamplePlugin(False)] - menu = Termenu(OPTIONS, height=4, plugins=plugins) - assert strmenu(menu) == "(01) 02 03 04" - menu._on_key("down") - assert strmenu(menu) == "(01) 02 03 04" - assert [p.ran for p in plugins] == [False, False, True] - -class FilterPluginTest(unittest.TestCase): - def test_filter(self): - menu = Termenu(OPTIONS, height=4, plugins=[FilterPlugin()]) - menu._on_key("4") - assert strmenu(menu) == "(04) 14 24 34" - - def test_case_insensitive(self): - menu = Termenu("ONE TWO THREE FOUR FIVE SIX SEVEN".split(), height=4, plugins=[FilterPlugin()]) - menu._on_key("e") - assert strmenu(menu) == "(ONE) THREE FIVE SEVEN" - - def test_backspace(self): - menu = Termenu("one two three four five six seven".split(), height=4, plugins=[FilterPlugin()]) - assert strmenu(menu) == "(one) two three four" - menu._on_key("e") - assert strmenu(menu) == "(one) three five seven" - menu._on_key("n") - assert strmenu(menu) == "(seven)" - menu._on_key("backspace") - assert strmenu(menu) == "(one) three five seven" - menu._on_key("backspace") - assert strmenu(menu) == "(one) two three four" - - def test_esc(self): - menu = Termenu("one two three four five six seven".split(), height=4, plugins=[FilterPlugin()]) - assert strmenu(menu) == "(one) two three four" - menu._on_key("e") - menu._on_key("n") - assert strmenu(menu) == "(seven)" - menu._on_key("esc") - assert strmenu(menu) == "(one) two three four" - -if __name__ == "__main__": - unittest.main() From 4ad9c2c10abf74f4baff0993c2905257675d9075 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 4 Oct 2015 14:17:11 +0300 Subject: [PATCH 06/60] more python3 adjustments --- termenu-cmd | 2 +- termenu/colors.py | 6 ++++-- termenu/termenu.py | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/termenu-cmd b/termenu-cmd index 26181f2..77989fa 100755 --- a/termenu-cmd +++ 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/colors.py b/termenu/colors.py index c030a97..f9221ef 100644 --- a/termenu/colors.py +++ b/termenu/colors.py @@ -169,12 +169,14 @@ def inner(self, *args): def __getitem__(self, idx): if isinstance(idx, slice) and idx.step is None: + start = idx.start or 0 + stop = idx.stop or len(self) cursor = 0 tokens = [] for token in self.tokens: - tokens.append(token[max(0, idx.start - cursor):idx.stop - cursor]) + tokens.append(token[max(0, start - cursor):stop - cursor]) cursor += len(token) - if cursor > idx.stop: + if cursor > stop: break return self.__class__("".join(t.raw() for t in tokens if t)) diff --git a/termenu/termenu.py b/termenu/termenu.py index 2abf1ed..f389578 100644 --- a/termenu/termenu.py +++ b/termenu/termenu.py @@ -571,16 +571,16 @@ 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 1b3f80c67c35d0305399ab5109e2a01453ba018b Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Mon, 5 Oct 2015 21:39:11 +0300 Subject: [PATCH 07/60] return None if menu is empty --- examples/app1.py | 9 ++++++++- termenu/app.py | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/examples/app1.py b/examples/app1.py index af294fb..0b553d9 100644 --- a/examples/app1.py +++ b/examples/app1.py @@ -10,7 +10,14 @@ class TopMenu(AppMenu): title = staticmethod(lambda: "YELLOW<<%s>>" % time.ctime()) timeout = 15 - submenus = ["Letters", "Numbers", "Submenu", "Foo", "Bar"] + 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)<>" diff --git a/termenu/app.py b/termenu/app.py index 532aed8..73caedd 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -333,11 +333,14 @@ def _menu_loop(self): 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=self.items, + options=options, height=self.height, multiselect=self.multiselect, heartbeat=self.heartbeat or (1 if self.timeout else None), From c07c47d7604f34490e6b28d5a93616c9d449a551 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Mon, 12 Oct 2015 01:15:40 +0300 Subject: [PATCH 08/60] x --- termenu/app.py | 144 ++++++++++++++++++++++++++++----------------- termenu/termenu.py | 9 ++- 2 files changed, 97 insertions(+), 56 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 73caedd..81b74c2 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -6,15 +6,25 @@ from .colors import Colorized import collections -#=============================================================================== -# Termenu -#=============================================================================== + +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 +# =============================================================================== +# Termenu +# =============================================================================== class TermenuAdapter(termenu.Termenu): - class RefreshSignal(Exception): pass - class TimeoutSignal(Exception): pass + class RefreshSignal(ParamsException): pass + class TimeoutSignal(ParamsException): pass FILTER_SEPARATOR = "," EMPTY = "DARK_RED<< (Empty) >>" @@ -33,7 +43,8 @@ def __init__(self, timeout=None): self.dirty = False self.timeout = (time.time() + timeout) if timeout else None - def reset(self, title="No Title", header="", *args, **kwargs): + def reset(self, title="No Title", header="", selection=None, *args, **kwargs): + self._highlighted = False remains = self.timeout and (self.timeout - time.time()) if remains: fmt = "(%s<<%ds left>>)" @@ -48,7 +59,7 @@ def reset(self, title="No Title", header="", *args, **kwargs): title += "\n" + header self.title = Colorized(title) self.title_height = len(title.splitlines()) - with self._selection_preserved(): + with self._selection_preserved(selection): super(TermenuAdapter, self).__init__(*args, **kwargs) def _make_option_objects(self, options): @@ -56,9 +67,15 @@ def _make_option_objects(self, options): self._allOptions = options[:] return options + def _decorate_flags(self, index): + flags = super()._decorate_flags(index) + 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) moreAbove = flags.get("moreAbove", False) @@ -66,9 +83,10 @@ def _decorate(self, option, **flags): # add selection / cursor decorations option = Colorized(("WHITE<<*>> " if selected else " ") + ("WHITE@{>}@" if active else " ") + option) - option = str(option) # convert from Colorized to ansi string - if active: - option = ansi.highlight(option, "black") + 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 if moreAbove: @@ -81,13 +99,13 @@ def _decorate(self, option, **flags): return option @contextmanager - def _selection_preserved(self): + 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.options if o.selected) + prev_selected = set(o.result for o in self.options if o.selected) if selection is None else set(selection) try: yield finally: @@ -124,21 +142,29 @@ def _adjust_width(self, option): return option def _on_key(self, key): - prevent = False + 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 not option.attrs.get("header"): + if option.attrs.get("selectable", True) and not option.attrs.get("header"): option.selected = not option.selected - elif len(key) == 1 and 32 < ord(key) <= 127: - if not self.text: - self.text = [] - self.text.append(key) - self._refilter() + 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 self.is_empty and key == "enter": - prevent = True + bubble_up = False elif self.text and key == "backspace": del self.text[-1] self._refilter() @@ -148,31 +174,45 @@ def _on_key(self, key): filters.pop(-1) self.text = list(self.FILTER_SEPARATOR.join(filters)) if filters else None termenu.ansi.hide_cursor() - prevent = True + bubble_up = False self._refilter() elif key == "end": self._on_end() - prevent = True + bubble_up = False elif key == "F5": - self.refresh() + self.refresh('user') - if not prevent: + if bubble_up: return super(TermenuAdapter, self)._on_key(key) + 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) + return True # stop loop + + def _on_insert(self): + option = self._get_active_option() + if not option.attrs.get("selectable", True): + return + super()._on_space() + def _on_end(self): height = min(self.height, len(self.options)) self.scroll = len(self.options) - height self.cursor = height - 1 - def refresh(self): + def refresh(self, source): if self.timeout: now = time.time() if now > self.timeout: raise self.TimeoutSignal() - raise self.RefreshSignal() + raise self.RefreshSignal(source=source) def _on_heartbeat(self): - self.refresh() + self.refresh("heartbeat") def _print_footer(self): if self.text is not None: @@ -235,15 +275,11 @@ def _refilter(self): self.is_empty = True self.options.append(self._Option(" (No match for RED<<%s>>)" % " , ".join(map(repr,texts)))) -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 + +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): @@ -275,12 +311,6 @@ def height(self): @property def items(self): - get_option_name = lambda sub: ( - sub.get_option_name() - if hasattr(sub, "get_option_name") - else (sub.__doc__ or sub.__name__) - ) - # convert named submenus to submenu objects (functions/classes) submenus = ( getattr(self, name) if isinstance(name, str) else name @@ -288,7 +318,7 @@ def items(self): ) return [ - sub if isinstance(sub, tuple) else (get_option_name(sub), sub) + sub if isinstance(sub, tuple) else (_get_option_name(sub), sub) for sub in submenus ] @@ -322,12 +352,13 @@ def _menu_loop(self): # use the default only on the first iteration # after that we'll default to the the last selection menu = TermenuAdapter(timeout=self.timeout) - refresh = True + self.refresh = "first" + selection = None default = self.default try: while True: - if refresh: + if self.refresh: title = self.title titles = [t() if isinstance(t, collections.Callable) else t for t in self._all_titles + [title]] banner = self.banner @@ -345,17 +376,19 @@ def _menu_loop(self): multiselect=self.multiselect, heartbeat=self.heartbeat or (1 if self.timeout else None), width=self.width, + selection=selection, ) else: # next time we must refresh - refresh = True + self.refresh = "second" try: selected = menu.show(default=default) default = None # default selection only on first show except KeyboardInterrupt: self.quit() - except menu.RefreshSignal: + except menu.RefreshSignal as e: + self.refresh = e.source continue except menu.TimeoutSignal: raise self.TimeoutSignal("Timed out waiting for selection") @@ -364,19 +397,20 @@ def _menu_loop(self): try: self.on_selected(selected) except self.RetrySignal as e: - refresh = e.refresh # will refresh by default unless told differently + self.refresh = e.refresh # will refresh by default unless told differently + selection = e.selection continue except (KeyboardInterrupt): - refresh = False # show the same menu + self.refresh = False # show the same menu continue except self.BackSignal as e: if e.levels: e.levels -= 1 raise - refresh = e.refresh + self.refresh = e.refresh continue else: - refresh = True # refresh the menu + self.refresh = "second" # refresh the menu finally: self._all_titles.pop(-1) @@ -411,7 +445,7 @@ def on_selected(self, selected): if actions is None: ret = self.action(selected) else: - to_submenu = lambda action: (action.__doc__ or action.__name__, functools.partial(action, selected)) + 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))) @@ -426,9 +460,9 @@ def get_selection_title(self, selection): return "Selected %s items" % len(selection) @classmethod - def retry(cls, refresh=True): + def retry(cls, refresh="app", selection=None): "Refresh into the current menu" - raise cls.RetrySignal(refresh=refresh) + raise cls.RetrySignal(refresh=refresh, selection=selection) @classmethod def back(cls, refresh=True, levels=1): @@ -447,6 +481,8 @@ def quit(cls): @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 diff --git a/termenu/termenu.py b/termenu/termenu.py index f389578..984bd56 100644 --- a/termenu/termenu.py +++ b/termenu/termenu.py @@ -84,14 +84,19 @@ def __getattr__(self, name): class Termenu(object): 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, 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 []: From 832eee84b3a702914243a8e005b7103f8d0e6b5e Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Tue, 20 Oct 2015 17:28:40 +0300 Subject: [PATCH 09/60] prevent non-selectable items from being selected when active --- termenu/app.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 81b74c2..1329dca 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -36,6 +36,9 @@ def __init__(self, *args, **kwargs): self.text = Colorized(self.raw) if isinstance(self.result, str): self.result = ansi.decolorize(self.result) + @property + def selectable(self): + return self.attrs.get("selectable", True) def __init__(self, timeout=None): self.text = None @@ -152,7 +155,7 @@ def _on_key(self, key): if key == "*" and self.multiselect: for option in self.options: - if option.attrs.get("selectable", True) and not option.attrs.get("header"): + if option.selectable and not option.attrs.get("header"): option.selected = not option.selected elif len(key) == 1 and 32 <= ord(key) <= 127: if key == " " and not self.text: @@ -191,13 +194,16 @@ def _on_enter(self): 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 not option.attrs.get("selectable", True): - return - super()._on_space() + if not option.selectable: + super()._on_down() + else: + super()._on_space() def _on_end(self): height = min(self.height, len(self.options)) @@ -434,7 +440,7 @@ def evaluate(item): if isinstance(item, AppMenu): return return item - return list(map(evaluate, selected)) if hasattr(selected, "__iter__") else evaluate(selected) + return list(map(evaluate, selected)) if self.multiselect else evaluate(selected) def on_selected(self, selected): if not selected: From 2010bcf89697eb5dd9827f4486b808faa57d6de8 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Tue, 20 Oct 2015 21:05:41 +0300 Subject: [PATCH 10/60] bug fix on option selectability/markability --- termenu/app.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 1329dca..c1c7a05 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -40,6 +40,10 @@ def __init__(self, *args, **kwargs): 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, timeout=None): self.text = None self.is_empty = True @@ -155,7 +159,7 @@ def _on_key(self, key): if key == "*" and self.multiselect: for option in self.options: - if option.selectable and not option.attrs.get("header"): + if option.markable: option.selected = not option.selected elif len(key) == 1 and 32 <= ord(key) <= 127: if key == " " and not self.text: @@ -200,10 +204,10 @@ def _on_enter(self): def _on_insert(self): option = self._get_active_option() - if not option.selectable: - super()._on_down() - else: + if option.markable: super()._on_space() + else: + super()._on_down() def _on_end(self): height = min(self.height, len(self.options)) From e8d48949e44abad57c853ce2274adaf0fa4c4d61 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Tue, 20 Oct 2015 23:19:42 +0300 Subject: [PATCH 11/60] submenus - support for dict-based Options --- termenu/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/termenu/app.py b/termenu/app.py index c1c7a05..71db02c 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -328,7 +328,7 @@ def items(self): ) return [ - sub if isinstance(sub, tuple) else (_get_option_name(sub), sub) + sub if isinstance(sub, (dict, tuple)) else (_get_option_name(sub), sub) for sub in submenus ] From 4211624319306ae75db8af971445b4be2901a718 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Tue, 20 Oct 2015 23:20:00 +0300 Subject: [PATCH 12/60] filter - add tip on reseting when no items match --- termenu/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/termenu/app.py b/termenu/app.py index 71db02c..776a522 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -283,7 +283,7 @@ def _refilter(self): break else: self.is_empty = True - self.options.append(self._Option(" (No match for RED<<%s>>)" % " , ".join(map(repr,texts)))) + self.options.append(self._Option(" (No match for RED<<%s>>; WHITE@{}@ to reset filter)" % " , ".join(map(repr,texts)))) def _get_option_name(sub): From 8def49a000e3669a193b6eb11049416f2cb2cff6 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sat, 24 Oct 2015 20:32:23 +0300 Subject: [PATCH 13/60] added support for F1 (Help) --- termenu/app.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 776a522..5d480f9 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -1,6 +1,6 @@ import time import functools -from . import termenu +from . import termenu, keyboard from contextlib import contextmanager from . import ansi from .colors import Colorized @@ -25,6 +25,7 @@ class TermenuAdapter(termenu.Termenu): class RefreshSignal(ParamsException): pass class TimeoutSignal(ParamsException): pass + class HelpSignal(ParamsException): pass FILTER_SEPARATOR = "," EMPTY = "DARK_RED<< (Empty) >>" @@ -186,12 +187,16 @@ def _on_key(self, key): elif key == "end": self._on_end() bubble_up = False - elif key == "F5": - self.refresh('user') 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_enter(self): if any(option.selected for option in self.options): self._highlighted = True @@ -221,6 +226,9 @@ def refresh(self, source): raise self.TimeoutSignal() raise self.RefreshSignal(source=source) + def help(self): + raise self.HelpSignal() + def _on_heartbeat(self): self.refresh("heartbeat") @@ -357,6 +365,31 @@ def initialize(self, *args, **kwargs): def banner(self): pass + 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))) + keyboard.wait_for_keys() + def _menu_loop(self): # use the default only on the first iteration @@ -400,6 +433,9 @@ def _menu_loop(self): 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") @@ -496,3 +532,14 @@ def show(title, options, default=None, back_on_abort=True, **kwargs): 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: + print(Colorized(prompt), end=" ", flush=True) + + keys = set(keys) + for key in keyboard.keyboard_listener(): + if not keys or key in keys: + print() + return key From ae7a2f014291130eb02db971682e481d1d164a95 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sat, 24 Oct 2015 21:54:19 +0300 Subject: [PATCH 14/60] bug fix on showing usage help --- termenu/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/termenu/app.py b/termenu/app.py index 5d480f9..22e3e1e 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -388,7 +388,7 @@ def help(self): " * Hit WHITE@{}@ to select", ] print(Colorized("\n".join(lines))) - keyboard.wait_for_keys() + self.wait_for_keys(prompt="(Hit any key to continue)") def _menu_loop(self): From 24069ae82d410c5cdd9724ab2097ae7de1218ed5 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 6 Nov 2015 11:38:38 +0200 Subject: [PATCH 15/60] bug fix in slicing colorized string --- termenu/colors.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/termenu/colors.py b/termenu/colors.py index f9221ef..4dbc9d8 100644 --- a/termenu/colors.py +++ b/termenu/colors.py @@ -171,6 +171,8 @@ 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: From 067054c9483ffe10291894ede7f2e72e47320f04 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 8 Nov 2015 22:54:07 +0200 Subject: [PATCH 16/60] default to 'fullscreen', and clear the screen --- termenu/ansi.py | 3 +++ termenu/app.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/termenu/ansi.py b/termenu/ansi.py index fea8aa6..18c2407 100644 --- a/termenu/ansi.py +++ b/termenu/ansi.py @@ -37,6 +37,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") diff --git a/termenu/app.py b/termenu/app.py index 22e3e1e..f6ea7ca 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -343,6 +343,7 @@ def items(self): submenus = [] default = None multiselect = False + fullscreen = True heartbeat = None width = None actions = None @@ -402,6 +403,9 @@ def _menu_loop(self): try: while True: if self.refresh: + 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 From 79f3dea5e5861ce135c951b7f82515cbdbe80959 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Tue, 15 Mar 2016 14:29:35 +0200 Subject: [PATCH 17/60] auto-select the single action if get_selection_actions returns a non-iterable --- termenu/app.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index f6ea7ca..e8116e8 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -310,6 +310,9 @@ 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 = [] @@ -492,12 +495,18 @@ def on_selected(self, selected): actions = self.get_selection_actions(selected) - if actions is None: - ret = self.action(selected) - else: + 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) From c817032c966ad9709f497d39e6a1ff299fe30a4d Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Tue, 5 Apr 2016 03:45:02 +0300 Subject: [PATCH 18/60] support on_XXX key hooks --- termenu/app.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index e8116e8..728741d 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -45,11 +45,12 @@ def selectable(self): def markable(self): return self.attrs.get("markable", True) and self.selectable - def __init__(self, timeout=None): + def __init__(self, app): self.text = None self.is_empty = True self.dirty = False - self.timeout = (time.time() + timeout) if timeout else None + self.timeout = (time.time() + app.timeout) if app.timeout else None + self.app = app def reset(self, title="No Title", header="", selection=None, *args, **kwargs): self._highlighted = False @@ -187,6 +188,9 @@ def _on_key(self, key): 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) @@ -398,7 +402,7 @@ def _menu_loop(self): # use the default only on the first iteration # after that we'll default to the the last selection - menu = TermenuAdapter(timeout=self.timeout) + menu = self.menu = TermenuAdapter(app=self) self.refresh = "first" selection = None default = self.default From 344de2697b717071a111f4004e11d1db0e9f4e5e Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 13 May 2016 14:51:00 +0300 Subject: [PATCH 19/60] back on 'None' only, not any non-zero selection result --- termenu/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/termenu/app.py b/termenu/app.py index 728741d..22c6d37 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -494,7 +494,7 @@ def evaluate(item): return list(map(evaluate, selected)) if self.multiselect else evaluate(selected) def on_selected(self, selected): - if not selected: + if selected is None: self.back() actions = self.get_selection_actions(selected) From 3add7fc019c7aac57ccbf0f30c76feaf68e0ba89 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 13 May 2016 15:58:54 +0300 Subject: [PATCH 20/60] support for wrapping titles (longer than term-width); bug fix in Colorized --- termenu/app.py | 16 ++++++++++++++-- termenu/colors.py | 6 +++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 22c6d37..63b2401 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -1,5 +1,6 @@ import time import functools +from textwrap import wrap from . import termenu, keyboard from contextlib import contextmanager from . import ansi @@ -66,8 +67,19 @@ def reset(self, title="No Title", header="", selection=None, *args, **kwargs): title += fmt % (color, remains) if header: title += "\n" + header - self.title = Colorized(title) - self.title_height = len(title.splitlines()) + title = Colorized(title) + terminal_width, _ = termenu.get_terminal_size() + terminal_width -= 2 + title_lines = [] + for line in title.splitlines(): + while line: + title_lines.append(line[:terminal_width]) + line = line[terminal_width:] + if line: + title_lines[-1] += "DARK_RED<<\u21a9>>" + line = Colorized(" DARK_RED<<\u21aa>> ") + line + self.title_height = len(title_lines) + self.title = Colorized("\n".join(title_lines)) with self._selection_preserved(selection): super(TermenuAdapter, self).__init__(*args, **kwargs) diff --git a/termenu/colors.py b/termenu/colors.py index 4dbc9d8..8aafb09 100644 --- a/termenu/colors.py +++ b/termenu/colors.py @@ -91,6 +91,10 @@ 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 @@ -101,7 +105,7 @@ def copy(self, text): return self.__class__(text, self.__name) def raw(self): - return "%s<<%s>>" % (self.__name, str.__str__(self)) + return "".join((self.__name, self.__p, str.__str__(self), self.__s)) def __repr__(self): return repr(self.raw()) From 0a5c14fbcce6905927d9d4aab50c2501d5d2d9c9 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 13 May 2016 16:06:15 +0300 Subject: [PATCH 21/60] bug fix on 344de26, for empty multiselect selection --- termenu/app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/termenu/app.py b/termenu/app.py index 63b2401..0086480 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -19,6 +19,9 @@ def __init__(self, message="", *args, **kwargs): self.params = kwargs +NoneType = type(None) + + # =============================================================================== # Termenu # =============================================================================== @@ -506,7 +509,7 @@ def evaluate(item): return list(map(evaluate, selected)) if self.multiselect else evaluate(selected) def on_selected(self, selected): - if selected is None: + if not selected and isinstance(selected, (NoneType, list)): self.back() actions = self.get_selection_actions(selected) From b1c5f101470a7eac3da61281388d3ea6bb52746d Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Mon, 16 May 2016 14:23:11 +0300 Subject: [PATCH 22/60] added support for ctrl+left/right --- termenu/keyboard.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/termenu/keyboard.py b/termenu/keyboard.py index 847ed65..91d71ec 100644 --- a/termenu/keyboard.py +++ b/termenu/keyboard.py @@ -21,6 +21,8 @@ delete = '\x1b[3~', pageUp = '\x1b[5~', pageDown = '\x1b[6~', + ctrlLeft = '\x1b[1;5C', + ctrlRight = '\x1b[1;5D', F1 = '\x1bOP', F2 = '\x1bOQ', F3 = '\x1bOR', From 5f247d42de4bee3314ca4d2c6581b7d90584c024 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sat, 8 Oct 2016 17:33:57 +0300 Subject: [PATCH 23/60] move the test module out of the top-level folder, so it doesn't interfere --- test.py => termenu/test.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test.py => termenu/test.py (100%) diff --git a/test.py b/termenu/test.py similarity index 100% rename from test.py rename to termenu/test.py From dd508d5c029932006cf061dee2c06bc63b61a034 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 14 Oct 2016 21:26:34 +0300 Subject: [PATCH 24/60] uncolorize text before measuring string length --- termenu/app.py | 4 ++-- termenu/colors.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 0086480..4f8a7fe 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -4,7 +4,7 @@ from . import termenu, keyboard from contextlib import contextmanager from . import ansi -from .colors import Colorized +from .colors import Colorized, uncolorize import collections @@ -157,7 +157,7 @@ def _set_default(self, default): def _adjust_width(self, option): option = Colorized("BLACK<<\\>>").join(option.splitlines()) - l = len(option) + l = len(uncolorize(str(option))) w = max(self.width, 8) if l > w: option = termenu.shorten(option, w) diff --git a/termenu/colors.py b/termenu/colors.py index 8aafb09..89677b4 100644 --- a/termenu/colors.py +++ b/termenu/colors.py @@ -67,6 +67,10 @@ def colorize_by_patterns(text, no_color=False): return text +def uncolorize(text): + return re.sub(re.escape("\x1b") + '.+?m', "", text) + + class Colorized(str): class Token(str): From 773bb5314aadeb2d795b10916aeb4281c5d762d2 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 14 Oct 2016 21:30:59 +0300 Subject: [PATCH 25/60] no need to clear_menu when in fullscreen mode --- termenu/app.py | 10 +++++++--- termenu/termenu.py | 5 +++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 4f8a7fe..a52253b 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -138,11 +138,11 @@ def _selection_preserved(self, selection=None): option.selected = option.result in prev_selected self._set_default(prev_active) - def show(self, default=None): + def show(self, default=None, auto_clear=True): self._refilter() self._clear_cache() self._set_default(default) - return super(TermenuAdapter, self).show() + return super(TermenuAdapter, self).show(auto_clear=auto_clear) def _set_default(self, default): if default is None: @@ -452,7 +452,7 @@ def _menu_loop(self): self.refresh = "second" try: - selected = menu.show(default=default) + selected = menu.show(default=default, auto_clear=not self.fullscreen) default = None # default selection only on first show except KeyboardInterrupt: self.quit() @@ -493,6 +493,10 @@ def _menu_loop(self): 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): diff --git a/termenu/termenu.py b/termenu/termenu.py index 984bd56..0293ec5 100644 --- a/termenu/termenu.py +++ b/termenu/termenu.py @@ -122,7 +122,7 @@ def get_result(self): return selected if self.multiselect else selected[0] @pluggable - def show(self): + def show(self, auto_clear=True): self._print_menu() ansi.save_position() ansi.hide_cursor() @@ -134,7 +134,8 @@ def show(self): self._goto_top() self._print_menu() finally: - self._clear_menu() + if auto_clear: + self._clear_menu() ansi.show_cursor() @pluggable From 15242b41e63ae88add7789c8551fee3f2201edb9 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 14 Oct 2016 21:31:27 +0300 Subject: [PATCH 26/60] support updating data before clearing the screen, to prevent flickering --- termenu/app.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/termenu/app.py b/termenu/app.py index a52253b..cc2e63c 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -388,6 +388,9 @@ def initialize(self, *args, **kwargs): def banner(self): pass + def update_data(self): + pass + def help(self): lines = [ "WHITE@{Menu Usage:}@", @@ -421,10 +424,11 @@ def _menu_loop(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() From 905e5cb86a1f601cf7c6687e9bf7923f43d5e852 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sat, 15 Oct 2016 01:16:52 +0300 Subject: [PATCH 27/60] uncolorize before testing text against filters --- termenu/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/termenu/app.py b/termenu/app.py index cc2e63c..a222106 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -296,7 +296,7 @@ def _refilter(self): self._clear_cache() self.options = [] texts = "".join(self.text or []).lower().split(self.FILTER_SEPARATOR) - pred = lambda option: all(text in option.text.lower() for text in texts) + pred = lambda option: all(text in uncolorize(option.text).lower() for text in texts) # filter the matching options for option in self._allOptions: if option.attrs.get("showAlways") or pred(option): From f1ea020fb9034792720c5645fdb55f77e2da54e9 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sat, 15 Oct 2016 01:28:56 +0300 Subject: [PATCH 28/60] workaround a bug with OSx that causes the menu to explode --- termenu/ansi.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/termenu/ansi.py b/termenu/ansi.py index 18c2407..c28640c 100644 --- a/termenu/ansi.py +++ b/termenu/ansi.py @@ -6,7 +6,7 @@ COLORS = dict(black=0, red=1, green=2, yellow=3, blue=4, magenta=5, cyan=6, white=7, default=9) -def write(s): +def write(text): def _retry(func, *args): while True: try: @@ -16,7 +16,10 @@ def _retry(func, *args): raise else: break - _retry(sys.stdout.write, s) + + size = 1024 # to workaround an issue on OSx where the buffer is too big + for i in range(0, len(text), size): + _retry(sys.stdout.write, text[i:i+size]) _retry(sys.stdout.flush) def up(n=1): From 920a6b36bcd024a5f3987ede8615515a136cf021 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Thu, 27 Oct 2016 11:53:21 +0300 Subject: [PATCH 29/60] abort write retries after 5 attempts --- termenu/ansi.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/termenu/ansi.py b/termenu/ansi.py index c28640c..dfa9ae9 100644 --- a/termenu/ansi.py +++ b/termenu/ansi.py @@ -8,12 +8,14 @@ def write(text): def _retry(func, *args): - while True: + attempts = 5 + while attempts: try: func(*args) except IOError as e: if e.errno != errno.EAGAIN: raise + attempts -= 1 else: break From 01a2c950567ffdb5e877bdbabecf3ba23846cb54 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Thu, 27 Oct 2016 03:19:20 +0300 Subject: [PATCH 30/60] bug fix in access to .height attr, support for SelectSignal --- termenu/app.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/termenu/app.py b/termenu/app.py index a222106..02e0e06 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -30,6 +30,7 @@ class TermenuAdapter(termenu.Termenu): class RefreshSignal(ParamsException): pass class TimeoutSignal(ParamsException): pass class HelpSignal(ParamsException): pass + class SelectSignal(ParamsException): pass FILTER_SEPARATOR = "," EMPTY = "DARK_RED<< (Empty) >>" @@ -50,6 +51,7 @@ 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.is_empty = True self.dirty = False @@ -248,6 +250,9 @@ def refresh(self, source): def help(self): raise self.HelpSignal() + def select(self, selection): + raise self.SelectSignal(selection=selection) + def _on_heartbeat(self): self.refresh("heartbeat") @@ -468,6 +473,8 @@ def _menu_loop(self): continue except menu.TimeoutSignal: raise self.TimeoutSignal("Timed out waiting for selection") + except menu.SelectSignal as e: + selected = e.selection self._all_titles.append(title) try: From 55848444b70a3289014ebdb0dcc2ba3dadd45eb8 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 28 Oct 2016 01:04:52 +0300 Subject: [PATCH 31/60] bug fix on printing long headers --- termenu/app.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 02e0e06..1323ff0 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -77,10 +77,13 @@ def reset(self, title="No Title", header="", selection=None, *args, **kwargs): terminal_width -= 2 title_lines = [] for line in title.splitlines(): - while line: - title_lines.append(line[:terminal_width]) - line = line[terminal_width:] - if line: + if len(uncolorize(line)) <= terminal_width: + title_lines.append(line) + else: + line = uncolorize(line) + while line: + title_lines.append(line[:terminal_width]) + line = line[terminal_width:] title_lines[-1] += "DARK_RED<<\u21a9>>" line = Colorized(" DARK_RED<<\u21aa>> ") + line self.title_height = len(title_lines) From 55aef30e64ae3b37d5f79961c52193fd1fa114a2 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Wed, 2 Nov 2016 00:14:57 +0200 Subject: [PATCH 32/60] keyboard - catch ctrl-a,b,d,e... --- termenu/keyboard.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/termenu/keyboard.py b/termenu/keyboard.py index 91d71ec..fca4d8e 100644 --- a/termenu/keyboard.py +++ b/termenu/keyboard.py @@ -1,12 +1,11 @@ - - import os import sys import fcntl import termios import select import errno +import string STDIN = sys.stdin.fileno() @@ -37,6 +36,9 @@ F12 = '\x1b[24~', ) +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', @@ -121,4 +123,4 @@ def keyboard_listener(heartbeat=None): if __name__ == "__main__": for key in keyboard_listener(0.5): - print(key) + print(repr(key)) From 9e1c7cc68d0e3578a16050761e0eefe508dd7798 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 4 Nov 2016 21:35:11 +0200 Subject: [PATCH 33/60] uncolorize text before tokenzining into Colorized --- termenu/colors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/termenu/colors.py b/termenu/colors.py index 89677b4..1974007 100644 --- a/termenu/colors.py +++ b/termenu/colors.py @@ -115,6 +115,7 @@ 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): From 814e08dd7bfeb72a2b049e64af12af3a65aad9d7 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Mon, 7 Nov 2016 17:34:43 +0200 Subject: [PATCH 34/60] bug fix in overflowing menus --- termenu/app.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 1323ff0..68ccb03 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -81,11 +81,13 @@ def reset(self, title="No Title", header="", selection=None, *args, **kwargs): title_lines.append(line) else: line = uncolorize(line) + prefix = "" while line: - title_lines.append(line[:terminal_width]) + title_lines.append(prefix + line[:terminal_width]) line = line[terminal_width:] - title_lines[-1] += "DARK_RED<<\u21a9>>" - line = Colorized(" DARK_RED<<\u21aa>> ") + line + if line: + title_lines[-1] += str(Colorized("DARK_RED<<\u21a9>>")) + prefix = str(Colorized(" DARK_RED<<\u21aa>> ")) self.title_height = len(title_lines) self.title = Colorized("\n".join(title_lines)) with self._selection_preserved(selection): From ed5d00ff4b6ae7918174c9ebe8973c5252600667 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Tue, 27 Dec 2016 15:42:57 +0200 Subject: [PATCH 35/60] check terminal size for max height --- termenu/termenu.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/termenu/termenu.py b/termenu/termenu.py index 0293ec5..ce1077b 100644 --- a/termenu/termenu.py +++ b/termenu/termenu.py @@ -102,7 +102,8 @@ def __init__(self, options, default=None, height=None, width=None, multiselect=T 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 From fd103051bbfc6c09d28eb07d2f64d367996a9ee6 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Thu, 23 Mar 2017 11:38:14 +0200 Subject: [PATCH 36/60] bug fix on preserving selection --- termenu/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/termenu/app.py b/termenu/app.py index 68ccb03..3245f31 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -136,7 +136,7 @@ def _selection_preserved(self, selection=None): return prev_active = self._get_active_option().result - prev_selected = set(o.result for o in self.options if o.selected) if selection is None else set(selection) + prev_selected = set(o.result for o in self._allOptions if o.selected) if selection is None else set(selection) try: yield finally: From 5f226b54424f14c1b21be6c666815d50a35d21b4 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 9 Apr 2017 15:15:50 +0300 Subject: [PATCH 37/60] esc: clear selection before exiting --- termenu/app.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 3245f31..09fc8da 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -194,19 +194,26 @@ def _on_key(self, key): self.text.append(key) self._refilter() bubble_up = False - elif self.is_empty and key == "enter": + elif key == "enter" and self.is_empty: bubble_up = False - elif self.text and key == "backspace": + elif key == "backspace" and self.text: del self.text[-1] self._refilter() - elif self.text is not None and key == "esc": - 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 - termenu.ansi.hide_cursor() - bubble_up = False - 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 + termenu.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 From fa29bed27e38eb07f7043a84c3d171f78df041b9 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 9 Apr 2017 16:39:25 +0300 Subject: [PATCH 38/60] allow keyboard to be imported even if no stdin (pytest) --- termenu/keyboard.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/termenu/keyboard.py b/termenu/keyboard.py index fca4d8e..f2b0cde 100644 --- a/termenu/keyboard.py +++ b/termenu/keyboard.py @@ -7,7 +7,11 @@ import errno import string -STDIN = sys.stdin.fileno() +try: + STDIN = sys.stdin.fileno() +except ValueError: + STDIN = None + ANSI_SEQUENCES = dict( up = '\x1b[A', From 3c7c81a52bcab3e894273894f45740d6a5eee98f Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 9 Apr 2017 18:34:47 +0300 Subject: [PATCH 39/60] show cursor when waiting for keys --- termenu/app.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 09fc8da..a0b0721 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -596,9 +596,13 @@ def show(title, options, default=None, back_on_abort=True, **kwargs): def wait_for_keys(keys=("enter", "esc"), prompt=None): if prompt: print(Colorized(prompt), end=" ", flush=True) + ansi.show_cursor() keys = set(keys) - for key in keyboard.keyboard_listener(): - if not keys or key in keys: - print() - return key + try: + for key in keyboard.keyboard_listener(): + if not keys or key in keys: + print() + return key + finally: + ansi.hide_cursor() From eed48643d4edfa4501e8e1a8ca4df7c7881eae1b Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Mon, 19 Jun 2017 19:44:49 +0300 Subject: [PATCH 40/60] keyboard: allow releasing stdin --- termenu/app.py | 3 +++ termenu/keyboard.py | 26 ++++++++++++++++++++++++-- termenu/termenu.py | 5 ++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index a0b0721..7183cc1 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -606,3 +606,6 @@ def wait_for_keys(keys=("enter", "esc"), prompt=None): return key finally: ansi.hide_cursor() + + def terminal_released(self): + return termenu.Termenu.terminal.closed() diff --git a/termenu/keyboard.py b/termenu/keyboard.py index f2b0cde..7a46ee2 100644 --- a/termenu/keyboard.py +++ b/termenu/keyboard.py @@ -6,6 +6,7 @@ import select import errno import string +from contextlib import contextmanager try: STDIN = sys.stdin.fileno() @@ -55,8 +56,13 @@ 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) @@ -69,6 +75,9 @@ def open(self): fcntl.fcntl(STDIN, fcntl.F_SETFL, self._old | 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) @@ -89,9 +98,22 @@ def __enter__(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): - with RawTerminal(blocking=False) as terminal: +def keyboard_listener(heartbeat=None, terminal=None): + if not terminal: + terminal = RawTerminal(blocking=False) + with terminal: # return keys sequence = "" while True: diff --git a/termenu/termenu.py b/termenu/termenu.py index ce1077b..628f997 100644 --- a/termenu/termenu.py +++ b/termenu/termenu.py @@ -82,6 +82,9 @@ def __getattr__(self, name): class Termenu(object): + + terminal = keyboard.RawTerminal(blocking=False) + class _Option(object): def __init__(self, option, **attrs): self.selected = False @@ -128,7 +131,7 @@ def show(self, auto_clear=True): 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() From fc87850a695becfc19469437343ddb834ac4c875 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 15 Dec 2017 02:45:21 +0200 Subject: [PATCH 41/60] hopefully fix mac issue --- termenu/ansi.py | 36 +++++++++++++++++++++++++++++++----- termenu/app.py | 17 ++++++++++------- termenu/keyboard.py | 15 ++++++++++++--- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/termenu/ansi.py b/termenu/ansi.py index dfa9ae9..fbbfb01 100644 --- a/termenu/ansi.py +++ b/termenu/ansi.py @@ -1,11 +1,39 @@ - - +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 partition_ansi(s): + # partition to ansi escape characters and regular characters. For the regular characters write at once, for escape one by one. + 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: + yield (start - last_end, s[last_end:start]) + yield (1, s[start:end]) + last_end = end + if len(s[end:]): + yield (len(s[end:]), s[end:]) + + +def stdout_write(s): + fd = sys.stdout.fileno() + for size, text in partition_ansi(s): + written = 0 + while written < len(text): + 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): attempts = 5 @@ -19,9 +47,7 @@ def _retry(func, *args): else: break - size = 1024 # to workaround an issue on OSx where the buffer is too big - for i in range(0, len(text), size): - _retry(sys.stdout.write, text[i:i+size]) + stdout_write(text) _retry(sys.stdout.flush) def up(n=1): diff --git a/termenu/app.py b/termenu/app.py index 7183cc1..c19cf86 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -74,20 +74,22 @@ def reset(self, title="No Title", header="", selection=None, *args, **kwargs): title += "\n" + header title = Colorized(title) terminal_width, _ = termenu.get_terminal_size() - terminal_width -= 2 + PAD_WIDTH = 2 # continuation sign is 2 characters (at least on mac) + terminal_width -= PAD_WIDTH * 2 title_lines = [] for line in title.splitlines(): if len(uncolorize(line)) <= terminal_width: title_lines.append(line) else: - line = uncolorize(line) + # line = uncolorize(line) used Colorized version to handle the splits instead of raw str prefix = "" while line: - title_lines.append(prefix + line[:terminal_width]) - line = line[terminal_width:] + width = terminal_width if prefix else terminal_width + PAD_WIDTH + title_lines.append(prefix + line.expandtabs()[:width]) # count tabs length + line = line.expandtabs()[width:] if line: - title_lines[-1] += str(Colorized("DARK_RED<<\u21a9>>")) - prefix = str(Colorized(" DARK_RED<<\u21aa>> ")) + title_lines[-1] += Colorized("DARK_RED<<\u21a9>>") + prefix = Colorized("DARK_RED<<\u21aa>> ") self.title_height = len(title_lines) self.title = Colorized("\n".join(title_lines)) with self._selection_preserved(selection): @@ -595,7 +597,8 @@ def show(title, options, default=None, back_on_abort=True, **kwargs): @staticmethod def wait_for_keys(keys=("enter", "esc"), prompt=None): if prompt: - print(Colorized(prompt), end=" ", flush=True) + termenu.ansi.write(Colorized(prompt)) # Aviod bocking + termenu.ansi.write(" ") ansi.show_cursor() keys = set(keys) diff --git a/termenu/keyboard.py b/termenu/keyboard.py index 7a46ee2..cb8cad0 100644 --- a/termenu/keyboard.py +++ b/termenu/keyboard.py @@ -13,6 +13,11 @@ except ValueError: STDIN = None +try: + STDOUT = sys.stdin.fileno() +except ValueError: + STDOUT = None + ANSI_SEQUENCES = dict( up = '\x1b[A', @@ -70,9 +75,12 @@ def open(self): termios.tcsetattr(STDIN, termios.TCSANOW, newattr) # Set non-blocking IO on stdin - self._old = fcntl.fcntl(STDIN, fcntl.F_GETFL) + 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 | os.O_NONBLOCK) + 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 @@ -80,7 +88,8 @@ def close(self): return # Restore previous terminal mode termios.tcsetattr(STDIN, termios.TCSAFLUSH, self._oldterm) - fcntl.fcntl(STDIN, fcntl.F_SETFL, self._old) + 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) From a700b7951392a15025c9253a7cd6422f8ba04351 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 28 Jan 2018 12:31:45 +0200 Subject: [PATCH 42/60] keyboard: support for custom ansi escape mapping file --- termenu/ansi.py | 38 ++++++++++++++++---------- termenu/keyboard.py | 65 ++++++++++++++++++++++++++++----------------- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/termenu/ansi.py b/termenu/ansi.py index fbbfb01..3193d81 100644 --- a/termenu/ansi.py +++ b/termenu/ansi.py @@ -6,25 +6,35 @@ COLORS = dict(black=0, red=1, green=2, yellow=3, blue=4, magenta=5, cyan=6, white=7, default=9) -def partition_ansi(s): - # partition to ansi escape characters and regular characters. For the regular characters write at once, for escape one by one. - 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: - yield (start - last_end, s[last_end:start]) - yield (1, s[start:end]) - last_end = end - if len(s[end:]): - yield (len(s[end:]), s[end:]) +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 size, text in partition_ansi(s): + for text in partition_ansi(s): written = 0 - while written < len(text): + size = len(text) + while written < size: remains = text[written:written+size].encode("utf8") try: written += os.write(fd, remains) diff --git a/termenu/keyboard.py b/termenu/keyboard.py index cb8cad0..9d5bede 100644 --- a/termenu/keyboard.py +++ b/termenu/keyboard.py @@ -20,32 +20,49 @@ 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', - 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~', + 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', + 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~', ) + +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) From 8f3e3340c58e1559012e2554012f8b8666351d60 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sat, 14 Apr 2018 15:10:58 +0300 Subject: [PATCH 43/60] app: support for inverting filter --- termenu/app.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index c19cf86..31c6daa 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -53,6 +53,7 @@ def markable(self): def __init__(self, app): self.height = self.title_height = 1 self.text = None + self.invert_filter = False self.is_empty = True self.dirty = False self.timeout = (time.time() + app.timeout) if app.timeout else None @@ -232,6 +233,10 @@ def _on_F5(self): def _on_F1(self): self.help() + def _on_ctrlSlash(self): + self.invert_filter = not self.invert_filter + self._refilter() + def _on_enter(self): if any(option.selected for option in self.options): self._highlighted = True @@ -273,7 +278,11 @@ def _on_heartbeat(self): def _print_footer(self): if self.text is not None: filters = "".join(self.text).split(self.FILTER_SEPARATOR) - termenu.ansi.write("/%s" % termenu.ansi.colorize(" , ", "white", bright=True).join(filters)) + if self.invert_filter: + termenu.ansi.write(termenu.ansi.colorize("\\", "yellow", bright=True)) + else: + termenu.ansi.write(termenu.ansi.colorize("/", "cyan", bright=True)) + termenu.ansi.write(termenu.ansi.colorize(" , ", "white", bright=True).join(filters)) termenu.ansi.show_cursor() def _print_menu(self): @@ -314,11 +323,11 @@ def _refilter(self): with self._selection_preserved(): self._clear_cache() self.options = [] - texts = "".join(self.text or []).lower().split(self.FILTER_SEPARATOR) - pred = lambda option: all(text in uncolorize(option.text).lower() for text in texts) + texts = set(filter(None, "".join(self.text or []).lower().split(self.FILTER_SEPARATOR))) + pred = lambda option: self.invert_filter ^ all(text in uncolorize(option.text).lower() for text in texts) # filter the matching options for option in self._allOptions: - if option.attrs.get("showAlways") or pred(option): + 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 From f820e61f117c467b1dfc11e8d8e8ded6170e3022 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sat, 14 Apr 2018 16:40:18 +0300 Subject: [PATCH 44/60] keyboard: added mapping to ctrl/shift/fn --- termenu/keyboard.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/termenu/keyboard.py b/termenu/keyboard.py index 9d5bede..3cb18ea 100644 --- a/termenu/keyboard.py +++ b/termenu/keyboard.py @@ -34,6 +34,8 @@ ctrlRight='\x1b[1;5D', ctrlUp='\x1b[1;5A', ctrlDown='\x1b[1;5B', + ctrlSlash='\x1f', + F1='\x1bOP', F2='\x1bOQ', F3='\x1bOR', @@ -46,6 +48,30 @@ 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~', ) From a93538201c08313febe4c9cf5419d7a21441e53f Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 20 Apr 2018 00:34:33 +0300 Subject: [PATCH 45/60] app: support for 4 filter modes --- termenu/app.py | 61 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 31c6daa..264aaf8 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -33,6 +33,7 @@ class HelpSignal(ParamsException): pass class SelectSignal(ParamsException): pass FILTER_SEPARATOR = "," + FILTER_MODES = ["and", "nand", "or", "nor"] EMPTY = "DARK_RED<< (Empty) >>" class _Option(termenu.Termenu._Option): @@ -53,12 +54,16 @@ def markable(self): def __init__(self, app): self.height = self.title_height = 1 self.text = None - self.invert_filter = False + 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 + @property + def filter_mode(self): + return self.FILTER_MODES[self.filter_mode_idx] + def reset(self, title="No Title", header="", selection=None, *args, **kwargs): self._highlighted = False remains = self.timeout and (self.timeout - time.time()) @@ -208,7 +213,9 @@ def _on_key(self, key): if filters: filters.pop(-1) self.text = list(self.FILTER_SEPARATOR.join(filters)) if filters else None - termenu.ansi.hide_cursor() + if not filters: + self.filter_mode_idx = 0 + ansi.hide_cursor() bubble_up = False self._refilter() else: @@ -234,8 +241,9 @@ def _on_F1(self): self.help() def _on_ctrlSlash(self): - self.invert_filter = not self.invert_filter - self._refilter() + 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): @@ -245,7 +253,7 @@ def _on_enter(self): time.sleep(.1) elif not self._get_active_option().selectable: return False - return True # stop loop + return True # stop loop def _on_insert(self): option = self._get_active_option() @@ -278,22 +286,24 @@ def _on_heartbeat(self): def _print_footer(self): if self.text is not None: filters = "".join(self.text).split(self.FILTER_SEPARATOR) - if self.invert_filter: - termenu.ansi.write(termenu.ansi.colorize("\\", "yellow", bright=True)) + 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: - termenu.ansi.write(termenu.ansi.colorize("/", "cyan", bright=True)) - termenu.ansi.write(termenu.ansi.colorize(" , ", "white", bright=True).join(filters)) - termenu.ansi.show_cursor() + 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)): - termenu.ansi.clear_eol() - termenu.ansi.write("\n") + ansi.clear_eol() + ansi.write("\n") self._print_footer() - termenu.ansi.clear_eol() + ansi.clear_eol() def _goto_top(self): super(TermenuAdapter, self)._goto_top() @@ -307,15 +317,15 @@ def get_total_height(self): def _clear_menu(self): super(TermenuAdapter, self)._clear_menu() clear = getattr(self, "clear", True) - termenu.ansi.restore_position() + ansi.restore_position() height = self.get_total_height() if clear: for i in range(height): - termenu.ansi.clear_eol() - termenu.ansi.up() - termenu.ansi.clear_eol() + ansi.clear_eol() + ansi.up() + ansi.clear_eol() else: - termenu.ansi.up(height) + ansi.up(height) ansi.clear_eol() ansi.write("\r") @@ -324,7 +334,16 @@ def _refilter(self): self._clear_cache() self.options = [] texts = set(filter(None, "".join(self.text or []).lower().split(self.FILTER_SEPARATOR))) - pred = lambda option: self.invert_filter ^ all(text in uncolorize(option.text).lower() for text in texts) + if self.filter_mode == "and": + pred = lambda option: all(text in uncolorize(option.text).lower() for text in texts) + elif self.filter_mode == "nand": + pred = lambda option: not all(text in uncolorize(option.text).lower() for text in texts) + elif self.filter_mode == "or": + pred = lambda option: any(text in uncolorize(option.text).lower() for text in texts) + elif self.filter_mode == "nor": + pred = lambda option: not any(text in uncolorize(option.text).lower() 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): @@ -606,8 +625,8 @@ def show(title, options, default=None, back_on_abort=True, **kwargs): @staticmethod def wait_for_keys(keys=("enter", "esc"), prompt=None): if prompt: - termenu.ansi.write(Colorized(prompt)) # Aviod bocking - termenu.ansi.write(" ") + ansi.write(Colorized(prompt)) # Aviod bocking + ansi.write(" ") ansi.show_cursor() keys = set(keys) From 4ee78f33f3a100f3139bf4721705ad0defbe9584 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Mon, 16 Jul 2018 11:33:40 +0300 Subject: [PATCH 46/60] app: support alternative filter text --- termenu/app.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 264aaf8..139f1a6 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -41,6 +41,7 @@ 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) @property @@ -335,13 +336,13 @@ def _refilter(self): 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 uncolorize(option.text).lower() for text in texts) + 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 uncolorize(option.text).lower() for text in texts) + 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 uncolorize(option.text).lower() for text in texts) + 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 uncolorize(option.text).lower() for text in texts) + pred = lambda option: not any(text in option.filter_text for text in texts) else: assert False, self.filter_mode # filter the matching options From 85bafd450ee8bdff98328891d99e993592991e4d Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Thu, 26 Jul 2018 12:10:39 +0300 Subject: [PATCH 47/60] app: show the scroll markers on the left, and bolder --- termenu/app.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 139f1a6..fa90beb 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -35,6 +35,8 @@ class SelectSignal(ParamsException): pass FILTER_SEPARATOR = "," FILTER_MODES = ["and", "nand", "or", "nor"] EMPTY = "DARK_RED<< (Empty) >>" + SCROLL_UP_MARKER = "🢁" + SCROLL_DOWN_MARKER = "🢃" class _Option(termenu.Termenu._Option): def __init__(self, *args, **kwargs): @@ -96,9 +98,9 @@ def reset(self, title="No Title", header="", selection=None, *args, **kwargs): line = line.expandtabs()[width:] if line: title_lines[-1] += Colorized("DARK_RED<<\u21a9>>") - prefix = Colorized("DARK_RED<<\u21aa>> ") + prefix = Colorized("DARK_RED<<\u21aa>> ") self.title_height = len(title_lines) - self.title = Colorized("\n".join(title_lines)) + self.title = Colorized("\n".join(" " + l for l in title_lines)) with self._selection_preserved(selection): super(TermenuAdapter, self).__init__(*args, **kwargs) @@ -129,14 +131,8 @@ def _decorate(self, option, **flags): option = str(option) # convert from Colorized to ansi string # add more above/below indicators - if moreAbove: - option = option + " " + ansi.colorize("^", "white", bright=True) - elif moreBelow: - option = option + " " + ansi.colorize("v", "white", bright=True) - else: - option = option + " " - - return option + 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): From a81387176ae390c78499dd198ec842fa3eedb1eb Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Fri, 31 Aug 2018 01:37:04 +0300 Subject: [PATCH 48/60] finally fix height math for app menu; also... - replace markers with unicode chars, for better readability - make it customizeable, via ~/.termenu/app_chars.py --- termenu/app.py | 102 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 22 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index fa90beb..b13d906 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -1,5 +1,7 @@ +import re import time import functools +import signal from textwrap import wrap from . import termenu, keyboard from contextlib import contextmanager @@ -21,22 +23,55 @@ def __init__(self, message="", *args, **kwargs): NoneType = type(None) +import os + +DEFAULT_CONFIG = """ +SCROLL_UP_MARKER = "🢁" +SCROLL_DOWN_MARKER = "🢃" +ACTIVE_MARKER = " WHITE@{🞂}@" +SELECTED_MARKER = "WHITE@{⚫}@" +UNSELECTED_MARKER = "⚪" +CONTINUATION_SUFFIX = "DARK_RED@{↩}@" +CONTINUATION_PREFIX = "DARK_RED@{↪}@" +""" + +CFG_PATH = os.path.expanduser("~/.termenu/app_chars.py") + +try: + with open(CFG_PATH) as f: + app_chars = f.read() +except FileNotFoundError: + os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True) + f = open(CFG_PATH, "w") + f.write(DEFAULT_CONFIG) + app_chars = DEFAULT_CONFIG + + +APP_CHARS = {} +eval(compile(app_chars, CFG_PATH, 'exec'), {}, APP_CHARS) + # =============================================================================== # Termenu # =============================================================================== class TermenuAdapter(termenu.Termenu): - class RefreshSignal(ParamsException): pass - class TimeoutSignal(ParamsException): pass - class HelpSignal(ParamsException): pass - class SelectSignal(ParamsException): pass + 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 = "🢁" - SCROLL_DOWN_MARKER = "🢃" + SCROLL_UP_MARKER = APP_CHARS['SCROLL_UP_MARKER'] + SCROLL_DOWN_MARKER = APP_CHARS['SCROLL_DOWN_MARKER'] + ACTIVE_MARKER = APP_CHARS['ACTIVE_MARKER'] + SELECTED_MARKER = APP_CHARS['SELECTED_MARKER'] + UNSELECTED_MARKER = APP_CHARS['UNSELECTED_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): @@ -46,6 +81,7 @@ def __init__(self, *args, **kwargs): 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) @@ -62,12 +98,17 @@ def __init__(self, app): self.dirty = False self.timeout = (time.time() + app.timeout) if app.timeout else None self.app = app + signal.signal(signal.SIGWINCH, self.handle_termsize_change) + + def handle_termsize_change(self, signal, frame): + 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, **kwargs): + 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: @@ -82,35 +123,48 @@ def reset(self, title="No Title", header="", selection=None, *args, **kwargs): if header: title += "\n" + header title = Colorized(title) - terminal_width, _ = termenu.get_terminal_size() - PAD_WIDTH = 2 # continuation sign is 2 characters (at least on mac) - terminal_width -= PAD_WIDTH * 2 + 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(): - if len(uncolorize(line)) <= terminal_width: + line = line.expandtabs() + if len(line.uncolored) <= terminal_width: title_lines.append(line) else: - # line = uncolorize(line) used Colorized version to handle the splits instead of raw str - prefix = "" + indentation, line = re.match("(\\s*)(.*)", line).groups() + line = Colorized(line) + continuation_prefix = "" while line: - width = terminal_width if prefix else terminal_width + PAD_WIDTH - title_lines.append(prefix + line.expandtabs()[:width]) # count tabs length - line = line.expandtabs()[width:] + # 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] += Colorized("DARK_RED<<\u21a9>>") - prefix = Colorized("DARK_RED<<\u21aa>> ") + title_lines[-1] += self.CONTINUATION_SUFFIX + continuation_prefix = self.CONTINUATION_PREFIX + self.title_height = len(title_lines) - self.title = Colorized("\n".join(" " + l for l in 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, **kwargs) + 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 @@ -120,11 +174,15 @@ def _decorate(self, option, **flags): 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(("WHITE<<*>> " if selected else " ") + ("WHITE@{>}@" if active else " ") + option) + option = Colorized( + (" " if not markable else self.SELECTED_MARKER if selected else self.UNSELECTED_MARKER) + + (self.ACTIVE_MARKER if active else " ") + + option) if highlighted: option = ansi.colorize(option.uncolored, "cyan", bright=True) else: @@ -390,7 +448,7 @@ def get_option_name(cls): @property def height(self): - return termenu.get_terminal_size()[1] // 2 + return None # use entire terminal height @property def items(self): From ac27a55f7181397605e00200ab630729e519616a Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sat, 1 Sep 2018 15:21:24 +0300 Subject: [PATCH 49/60] app: disable resize signal handler when not in menu --- termenu/app.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index b13d906..83dc7f3 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -51,6 +51,15 @@ def __init__(self, message="", *args, **kwargs): 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 # =============================================================================== @@ -82,6 +91,7 @@ def __init__(self, *args, **kwargs): 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) @@ -98,7 +108,6 @@ def __init__(self, app): self.dirty = False self.timeout = (time.time() + app.timeout) if app.timeout else None self.app = app - signal.signal(signal.SIGWINCH, self.handle_termsize_change) def handle_termsize_change(self, signal, frame): self.refresh("signal") @@ -212,7 +221,11 @@ def show(self, default=None, auto_clear=True): self._refilter() self._clear_cache() self._set_default(default) - return super(TermenuAdapter, self).show(auto_clear=auto_clear) + 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: From 6b974e224fb4f509e4f2161a5d4b6694dca57284 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 2 Sep 2018 18:33:19 +0300 Subject: [PATCH 50/60] app: announce it when the config file is created --- termenu/app.py | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 83dc7f3..39ca45c 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -2,7 +2,7 @@ import time import functools import signal -from textwrap import wrap +from textwrap import dedent from . import termenu, keyboard from contextlib import contextmanager from . import ansi @@ -28,11 +28,11 @@ def __init__(self, message="", *args, **kwargs): DEFAULT_CONFIG = """ SCROLL_UP_MARKER = "🢁" SCROLL_DOWN_MARKER = "🢃" -ACTIVE_MARKER = " WHITE@{🞂}@" -SELECTED_MARKER = "WHITE@{⚫}@" -UNSELECTED_MARKER = "⚪" -CONTINUATION_SUFFIX = "DARK_RED@{↩}@" -CONTINUATION_PREFIX = "DARK_RED@{↪}@" +ACTIVE_ITEM_MARKER = " WHITE@{🞂}@" +SELECTED_ITEM_MARKER = "WHITE@{⚫}@" +SELECTABLE_ITEM_MARKER = "⚪" +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") @@ -41,6 +41,27 @@ def __init__(self, message="", *args, **kwargs): with open(CFG_PATH) as f: app_chars = f.read() except FileNotFoundError: + os.system("clear") + print(Colorized(dedent(""" + RED<<.-~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~-.>> + + WHITE<> has created for you this default configuration file: CYAN<<~/.termenu/app_chars.py>> + You can modify it to control which glyphs are used in termenu apps, since those glyphs + might not display properly on your terminal. + This could be helpful: CYAN<> + + ~/.termenu/app_chars.py: + ============={DEFAULT_CONFIG}============= + + DARK_YELLOW<<(Hit ctrl-C or wait 30s to proceed...)>> + + RED<<'*-~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~o~O~o~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~-*'>> + """).format(DEFAULT_CONFIG=DEFAULT_CONFIG))) + try: + time.sleep(30) + except KeyboardInterrupt: + pass + os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True) f = open(CFG_PATH, "w") f.write(DEFAULT_CONFIG) @@ -75,9 +96,9 @@ class SelectSignal(ParamsException): ... EMPTY = "DARK_RED<< (Empty) >>" SCROLL_UP_MARKER = APP_CHARS['SCROLL_UP_MARKER'] SCROLL_DOWN_MARKER = APP_CHARS['SCROLL_DOWN_MARKER'] - ACTIVE_MARKER = APP_CHARS['ACTIVE_MARKER'] - SELECTED_MARKER = APP_CHARS['SELECTED_MARKER'] - UNSELECTED_MARKER = APP_CHARS['UNSELECTED_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 = " " @@ -189,8 +210,8 @@ def _decorate(self, option, **flags): # add selection / cursor decorations option = Colorized( - (" " if not markable else self.SELECTED_MARKER if selected else self.UNSELECTED_MARKER) + - (self.ACTIVE_MARKER if active else " ") + + (" " 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) From af778c09497447bc5750144f4b6ffbac7b098ae9 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 2 Sep 2018 19:35:19 +0300 Subject: [PATCH 51/60] app: change default chars to not depend on unicode glyphs --- termenu/app.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 39ca45c..7aa2948 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -26,11 +26,12 @@ def __init__(self, message="", *args, **kwargs): import os DEFAULT_CONFIG = """ -SCROLL_UP_MARKER = "🢁" -SCROLL_DOWN_MARKER = "🢃" -ACTIVE_ITEM_MARKER = " WHITE@{🞂}@" -SELECTED_ITEM_MARKER = "WHITE@{⚫}@" -SELECTABLE_ITEM_MARKER = "⚪" +# 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 """ @@ -46,19 +47,18 @@ def __init__(self, message="", *args, **kwargs): RED<<.-~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~-.>> WHITE<> has created for you this default configuration file: CYAN<<~/.termenu/app_chars.py>> - You can modify it to control which glyphs are used in termenu apps, since those glyphs - might not display properly on your terminal. - This could be helpful: CYAN<> + 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. ~/.termenu/app_chars.py: - ============={DEFAULT_CONFIG}============= + ========================{DEFAULT_CONFIG}======================== - DARK_YELLOW<<(Hit ctrl-C or wait 30s to proceed...)>> + DARK_YELLOW<<(Hit ctrl-C or wait 15s to proceed...)>> RED<<'*-~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~o~O~o~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~-*'>> """).format(DEFAULT_CONFIG=DEFAULT_CONFIG))) try: - time.sleep(30) + time.sleep(15) except KeyboardInterrupt: pass From fac216a5a73876273c8f303f4f202c0debf09951 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Mon, 3 Sep 2018 12:02:02 +0300 Subject: [PATCH 52/60] app: fixes to app_chars config intro --- termenu/app.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 7aa2948..d9d2eee 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -26,7 +26,11 @@ def __init__(self, message="", *args, **kwargs): 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 🞂 @@ -42,30 +46,25 @@ def __init__(self, message="", *args, **kwargs): with open(CFG_PATH) as f: app_chars = f.read() except FileNotFoundError: + os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True) + f = open(CFG_PATH, "w") + f.write(DEFAULT_CONFIG) + app_chars = DEFAULT_CONFIG + os.system("clear") print(Colorized(dedent(""" - RED<<.-~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~-.>> - - WHITE<> has created for you this default configuration file: CYAN<<~/.termenu/app_chars.py>> - 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. - - ~/.termenu/app_chars.py: - ========================{DEFAULT_CONFIG}======================== - - DARK_YELLOW<<(Hit ctrl-C or wait 15s to proceed...)>> + WHITE<<~/.termenu/app_chars.py:>> + RED<<.-~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~-.>> + {DEFAULT_CONFIG} RED<<'*-~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~o~O~o~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~~~<>~~-*'>> - """).format(DEFAULT_CONFIG=DEFAULT_CONFIG))) + DARK_YELLOW<<(Hit any key to proceed...)>>""").format(DEFAULT_CONFIG=DEFAULT_CONFIG)), end="") + try: - time.sleep(15) + next(keyboard.keyboard_listener()) except KeyboardInterrupt: pass - - os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True) - f = open(CFG_PATH, "w") - f.write(DEFAULT_CONFIG) - app_chars = DEFAULT_CONFIG + print(Colorized("\rDARK_GREEN<<(Proceeding...)>>" + " " * 40)) APP_CHARS = {} From f30d0c1e425f0b3e170facbaa4f0b6d8353406be Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Wed, 5 Sep 2018 14:05:44 +0300 Subject: [PATCH 53/60] app: don't show intro if not a TTY --- termenu/app.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index d9d2eee..46e9ec1 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -1,3 +1,4 @@ +import sys import re import time import functools @@ -50,21 +51,21 @@ def __init__(self, message="", *args, **kwargs): f = open(CFG_PATH, "w") f.write(DEFAULT_CONFIG) app_chars = DEFAULT_CONFIG + if sys.__stdin__.isatty(): + os.system("clear") + print(Colorized(dedent(""" - 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="") - 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)) + try: + next(keyboard.keyboard_listener()) + except KeyboardInterrupt: + pass + print(Colorized("\rDARK_GREEN<<(Proceeding...)>>" + " " * 40)) APP_CHARS = {} From 8775529e89eae36e5f38fb177bff56e9a312d102 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Wed, 5 Sep 2018 16:23:02 +0300 Subject: [PATCH 54/60] app: don't neglect to close the config file after writing it --- termenu/app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 46e9ec1..1f9e45b 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -48,8 +48,9 @@ def __init__(self, message="", *args, **kwargs): app_chars = f.read() except FileNotFoundError: os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True) - f = open(CFG_PATH, "w") - f.write(DEFAULT_CONFIG) + with open(CFG_PATH, "w") as f: + f.write(DEFAULT_CONFIG) + app_chars = DEFAULT_CONFIG if sys.__stdin__.isatty(): os.system("clear") From 58192091051b3a1d9cb8354f935b7a7f8af2bfd5 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Thu, 6 Sep 2018 13:41:18 +0300 Subject: [PATCH 55/60] app: skip app_chars config file if PermissionError --- termenu/app.py | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index 1f9e45b..d181e8b 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -42,31 +42,36 @@ def __init__(self, message="", *args, **kwargs): """ 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: - os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True) - with open(CFG_PATH, "w") as f: - f.write(DEFAULT_CONFIG) - - app_chars = DEFAULT_CONFIG - 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)) + try: + os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True) + with open(CFG_PATH, "w") as f: + f.write(DEFAULT_CONFIG) + except 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 = {} From bf8dfa5cf652a24cb0356d2dc06d7f45b3ab855b Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 30 Sep 2018 00:13:46 +0300 Subject: [PATCH 56/60] bug fix on pushing title into the stack to support menus loaded while *inside* the menu-loop --- termenu/app.py | 75 +++++++++++++++++++++++++------------------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/termenu/app.py b/termenu/app.py index d181e8b..eea719f 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -5,7 +5,7 @@ import signal from textwrap import dedent from . import termenu, keyboard -from contextlib import contextmanager +from contextlib import contextmanager, ExitStack from . import ansi from .colors import Colorized, uncolorize import collections @@ -597,42 +597,43 @@ def _menu_loop(self): # next time we must refresh self.refresh = "second" - try: - 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 - - self._all_titles.append(title) - 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 - finally: - self._all_titles.pop(-1) + with ExitStack() as stack: + self._all_titles.append(title) + stack.callback(lambda: self._all_titles.pop(-1)) + + try: + 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: From 68531cfa2cb5f6261e24a3cea8cbbf53f2cbbffb Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 28 Oct 2018 15:14:53 +0200 Subject: [PATCH 57/60] app: support a 'showing' hook --- termenu/app.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/termenu/app.py b/termenu/app.py index eea719f..287cf34 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -533,6 +533,13 @@ def banner(self): 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:}@", @@ -602,7 +609,8 @@ def _menu_loop(self): stack.callback(lambda: self._all_titles.pop(-1)) try: - selected = menu.show(default=default, auto_clear=not self.fullscreen) + 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() From ae92aca8d42cc12ca68244963e0fc9cb9175f9be Mon Sep 17 00:00:00 2001 From: Doron Cohen Date: Sun, 2 Dec 2018 16:39:21 +0200 Subject: [PATCH 58/60] fix: make termenu installable with --editable (WEKAPP-81981) --- .gitignore | 1 + setup.py | 8 ++++---- version | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 version 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/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/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 From 54357ba3408643e85461a409a9179a88cbd9c3be Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Sun, 6 Jan 2019 20:35:33 +0200 Subject: [PATCH 59/60] handle terminal size change in main-thread only --- termenu/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/termenu/app.py b/termenu/app.py index 287cf34..8d808f5 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -137,7 +137,9 @@ def __init__(self, app): self.app = app def handle_termsize_change(self, signal, frame): - self.refresh("signal") + import threading + if threading.current_thread() == threading.main_thread(): + self.refresh("signal") @property def filter_mode(self): From 873396a3837f8c107f7ecef762cf9fea110b6222 Mon Sep 17 00:00:00 2001 From: Ofer Koren Date: Mon, 15 Apr 2019 13:56:27 +0300 Subject: [PATCH 60/60] app: don't fail on read-only fs when writing config file --- termenu/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/termenu/app.py b/termenu/app.py index 8d808f5..d8b2dfe 100644 --- a/termenu/app.py +++ b/termenu/app.py @@ -54,7 +54,7 @@ def __init__(self, message="", *args, **kwargs): os.makedirs(os.path.dirname(CFG_PATH), exist_ok=True) with open(CFG_PATH, "w") as f: f.write(DEFAULT_CONFIG) - except PermissionError: + except (OSError, PermissionError): pass else: if sys.__stdin__.isatty():