From 2718b2fd5e17f0404b173521662d957eccbba099 Mon Sep 17 00:00:00 2001 From: Melissa Nuno Date: Thu, 21 May 2020 14:54:06 -0600 Subject: [PATCH 1/8] Start reworking the display library. --- rcli/{display.py => display/__init__.py} | 22 ++-- rcli/display/box.py | 11 ++ rcli/display/io.py | 28 +++++ rcli/display/style.py | 125 +++++++++++++++++++++++ rcli/display/terminal.py | 10 ++ 5 files changed, 186 insertions(+), 10 deletions(-) rename rcli/{display.py => display/__init__.py} (95%) create mode 100644 rcli/display/box.py create mode 100644 rcli/display/io.py create mode 100644 rcli/display/style.py create mode 100644 rcli/display/terminal.py diff --git a/rcli/display.py b/rcli/display/__init__.py similarity index 95% rename from rcli/display.py rename to rcli/display/__init__.py index 068d326..916bd35 100644 --- a/rcli/display.py +++ b/rcli/display/__init__.py @@ -30,7 +30,7 @@ from colorama import Cursor, Fore, Style from tqdm import tqdm -from .backports.get_terminal_size import get_terminal_size +from .terminal import cols as _ncols _LOGGER = logging.getLogger(__name__) @@ -159,6 +159,17 @@ def print_message(msg): print_header("completed in {:.2f}s".format(delta), False) +def line(char="\u2500", start="\u2500", end="\u2500"): + print(start, char * _ncols() - 2, end) + + +@contextlib.contextmanager +def box(): + line(start="\u250F", end="\u2513") + yield + line(start="\u2517", end="\u251B") + + def run_tasks(header, tasks): """Run a group of tasks with a header, footer and success/failure messages. @@ -186,12 +197,3 @@ def run_tasks(header, tasks): task[1]() finally: pbar.update(task[2] if len(task) > 2 else 1) - - -def _ncols(): - """Get the current number of columns on the terminal. - - Returns: - The current number of columns in the terminal or 80 if there is no tty. - """ - return get_terminal_size().columns or 80 diff --git a/rcli/display/box.py b/rcli/display/box.py new file mode 100644 index 0000000..3b3b5aa --- /dev/null +++ b/rcli/display/box.py @@ -0,0 +1,11 @@ +from . import terminal +from .io import AppendIOBase + + +class BoxIO(AppendIOBase): + def update_line(self, line): + return f"\u2503 {line: <{terminal.cols() - 4}} \u2503" + + +def line(char="\u2501", start="\u2501", end="\u2501"): + print(start, char * (terminal.cols() - 2), end, sep="") diff --git a/rcli/display/io.py b/rcli/display/io.py new file mode 100644 index 0000000..98ae1d7 --- /dev/null +++ b/rcli/display/io.py @@ -0,0 +1,28 @@ +import io +import sys + +from . import terminal + + +class AppendIOBase(io.StringIO): + def __init__(self, stdout=sys.stdout): + self._stdout = stdout + + def flush(self): + buffer = self.getvalue() + lines = buffer.split("\n") + nl = "\n".join(self.update_line(line) for line in lines if line) + self._stdout.write(nl + ("\n" if buffer.endswith("\n") else "")) + self.clear_buffer() + self._stdout.flush() + + def update_line(self, line): + return line + + def clear_buffer(self): + self.truncate(0) + self.seek(0) + + def close(self): + self.flush() + super().close() diff --git a/rcli/display/style.py b/rcli/display/style.py new file mode 100644 index 0000000..ad519a2 --- /dev/null +++ b/rcli/display/style.py @@ -0,0 +1,125 @@ +import copy +import types + +import colorama + + +class Style: + __stack = [] + + def __init__( + self, + foreground: types.Option[Color, str, int] = None, + background: types.Option[Color, str, int] = None, + bold: bool = None, + italic: bool = None, + dim: bool = None, + underlined: bool = None, + blink: bool = None, + reverse: bool = None, + hidden: bool = None, + ): + if str(foreground).startswith("\033["): + self.foreground = str(foreground).strip("\033[m") + else: + self.foreground = str(foreground) + if str(background).startswith("\033["): + self.background = str(background).strip("\033[m") + elif isinstance(background, int): + self.background = str(background) + else: + self.background = background.background() + self.bold = bold + self.dim = dim + self.italic = italic + self.underlined = underlined + self.blink = blink + self.reverse = reverse + self.hidden = hidden + + def __str__(self): + value = "{escape}{}{}{}{}{}{}{}{}{}".format( + self._value(self.foreground, None, "foreground"), + self._value(self.background, None, "background",), + self._value(1, 21, "bold"), + self._value(2, 22, "dim"), + self._value(3, 23, "italic"), + self._value(4, 24, "underlined"), + self._value(5, 25, "blink"), + self._value(7, 27, "reverse"), + self._value(8, 28, "hidden"), + escape=colorama.ansi.CSI, + ) + return f"{value[:-1]}m" + + def _value(self, on, off, param): + value = getattr(self, param, None) + if value is False and off is not None: + return f"{off};" + if value is not None and on is not None: + return f"{on};" + return "" + + def __enter__(self): + self.__stack.append(self.full_style(self)) + print(colorama.Style.RESET_ALL, end="") + print(self.current(), end="") + + def __exit__(self, *args, **kwargs): + self.__stack.pop() + print(colorama.Style.RESET_ALL, end="") + print(self.current(), end="") + + @classmethod + def full_style(cls, style): + full_style = copy.deepcopy(style) + if cls.__stack: + current = cls.current() + if full_style.foreground == None: + full_style.foreground = current.foreground + if full_style.background == None: + full_style.background = current.background + if full_style.bold == None: + full_style.bold = current.bold + if full_style.italic == None: + full_style.italic = current.italic + if full_style.dim == None: + full_style.dim = current.dim + if full_style.underlined == None: + full_style.underlined = current.underlined + if full_style.blink == None: + full_style.blink = current.blink + if full_style.reverse == None: + full_style.reverse = current.reverse + if full_style.hidden == None: + full_style.hidden = current.hidden + return full_style + + @classmethod + def current(cls): + return cls.__stack[-1] if cls.__stack else colorama.Style.RESET_ALL + + +class Color: + def __init__(self, color): + if color < 0 or color > 256: + raise AttributeError("color must be between 0 and 256 inclusive") + self._color = color + + def __str__(self): + return self.foreground() + + def foreground(self): + return f"38;5;{self._color}" + + def background(self): + return f"48;5;{self._color}" + + +bold = bright = Style(bold=True) +dim = Style(dim=True) +italic = Style(italic=True) +underlined = Style(underlined=True) +blink = Style(blink=True) +reverse = Style(reverse=True) +hidden = Style(hidden=True) diff --git a/rcli/display/terminal.py b/rcli/display/terminal.py new file mode 100644 index 0000000..0e16622 --- /dev/null +++ b/rcli/display/terminal.py @@ -0,0 +1,10 @@ +from ..backports.get_terminal_size import get_terminal_size + + +def cols(): + """Get the current number of columns on the terminal. + + Returns: + The current number of columns in the terminal or 80 if there is no tty. + """ + return get_terminal_size().columns or 80 From e0293b26956264be897767c2b08cc0dd2f0193e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Melissa=20Nu=C3=B1o?= Date: Sun, 24 May 2020 00:26:23 -0600 Subject: [PATCH 2/8] Update display functions for better boxes. --- rcli/display/box.py | 169 +++++++++++++++++++++++++++++++++++++++++- rcli/display/io.py | 16 ++-- rcli/display/style.py | 109 ++++++++++++++------------- rcli/display/util.py | 22 ++++++ 4 files changed, 257 insertions(+), 59 deletions(-) create mode 100644 rcli/display/util.py diff --git a/rcli/display/box.py b/rcli/display/box.py index 3b3b5aa..2e628bf 100644 --- a/rcli/display/box.py +++ b/rcli/display/box.py @@ -1,11 +1,172 @@ +import contextlib +import functools + from . import terminal from .io import AppendIOBase +from .util import visible_len, remove_invisible_characters +from .style import Style, Reset class BoxIO(AppendIOBase): - def update_line(self, line): - return f"\u2503 {line: <{terminal.cols() - 4}} \u2503" + def __init__(self, box_): + super().__init__() + self._box = box_ + self._style = Style.current() + self._sep = remove_invisible_characters(self._box._get_sep()) + + def write(self, s): + super().write(f"{Style.current() if s != self._sep else ''}{s}") + + def update_line(self, s): + current_style = Style.current() + cleaned_s = remove_invisible_characters(s) + if cleaned_s[:2] == self._sep[:2] and cleaned_s[-2:] == self._sep[-2:]: + return f"{self._style}{s}{current_style}" + left = " ".join(f"{box[1]}{box[0]._vertical}" for box in Box._stack) + return functools.reduce( + lambda r, b: self._get_right_append(r, b[0], *b[1]), + zip(range(len(Box._stack) - 1, -1, -1), reversed(Box._stack)), + f"{self._style}{left} {current_style}{s}{self._style}", + ) + str(current_style) + + def _get_right_append(self, current, i, box_, style): + num_spaces = ( + (box_._size or terminal.cols()) + - visible_len(current) + - visible_len(box_._vertical) + - i * 2 + ) + return f"{current}{style}{' ' * num_spaces}{box_._vertical}" + + +class Box: + _depth = 0 + _stack = [] + + def __init__( + self, + upper_left="\u250C", + upper_right="\u2510", + lower_left="\u2514", + lower_right="\u2518", + horizontal="\u2500", + vertical="\u2502", + sep_left="\u251C", + sep_horizontal="\u2500", + sep_right="\u2524", + size=None, + ): + self._upper_left = upper_left + self._upper_right = upper_right + self._lower_left = lower_left + self._lower_right = lower_right + self._horizontal = horizontal + self._vertical = vertical + self._sep_left = sep_left + self._sep_horizontal = sep_horizontal + self._sep_right = sep_right + self._size = size + + def top(self): + print( + self._line( + self._horizontal, + self._upper_left, + self._upper_right + str(Reset()), + ), + flush=True, + ) + + def sep(self): + print(self._get_sep(), sep="", flush=True) + + def bottom(self): + print( + self._line( + self._horizontal, + self._lower_left, + self._lower_right + str(Reset()), + ), + flush=True, + ) + + def _set_size(self, size): + self._size = size + + def _line(self, char, start, end): + size = self._size or terminal.cols() + width = size - 4 * (Box._depth - 1) + return f"{start}{char * (width - 2)}{end}" + + def _create_buffer(self): + return BoxIO(self) + + def _get_sep(self): + return self._line( + self._sep_horizontal, self._sep_left, self._sep_right + ) + + def __enter__(self): + Box._depth += 1 + self.top() + Box._stack.append((self, Style.current())) + return self + + def __exit__(self, *args, **kwargs): + Box._stack.pop() + self.bottom() + Box._depth -= 1 + + @staticmethod + def new_style(*args, **kwargs): + @contextlib.contextmanager + def inner(**kw): + impl = Box(*args, **kwargs) + if "size" in kw: + impl._set_size(kw["size"]) + with impl, contextlib.redirect_stdout(impl._create_buffer()): + yield impl + + return inner + +Box.simple = Box.new_style() +Box.thick = Box.new_style( + "\u250F", + "\u2513", + "\u2517", + "\u251B", + "\u2501", + "\u2503", + "\u2523", + "\u2501", + "\u252B", +) +Box.info = Box.new_style( + "\u250F", + "\u2513", + "\u2517", + "\u251B", + "\u2501", + "\u2503", + "\u2520", + "\u2500", + "\u2528", +) +Box.ascii = Box.new_style("+", "+", "+", "+", "=", "|", "+", "-", "+") +Box.star = Box.new_style("*", "*", "*", "*", "*", "*", "*", "*", "*") +Box.double = Box.new_style( + "\u2554", + "\u2557", + "\u255A", + "\u255D", + "\u2550", + "\u2551", + "\u2560", + "\u2550", + "\u2563", +) +Box.fancy = Box.new_style("\u2552", "\u2555", "\u2558", "\u255B", "\u2550") +Box.round = Box.new_style("\u256D", "\u256E", "\u2570", "\u256F") -def line(char="\u2501", start="\u2501", end="\u2501"): - print(start, char * (terminal.cols() - 2), end, sep="") +box = Box.simple diff --git a/rcli/display/io.py b/rcli/display/io.py index 98ae1d7..d0cc86a 100644 --- a/rcli/display/io.py +++ b/rcli/display/io.py @@ -1,23 +1,29 @@ import io import sys -from . import terminal +from .util import remove_invisible_characters class AppendIOBase(io.StringIO): def __init__(self, stdout=sys.stdout): + super().__init__("", None) self._stdout = stdout def flush(self): buffer = self.getvalue() lines = buffer.split("\n") - nl = "\n".join(self.update_line(line) for line in lines if line) - self._stdout.write(nl + ("\n" if buffer.endswith("\n") else "")) + nl = "\n".join( + self.update_line(line) + if remove_invisible_characters(line) + else line + for line in lines + ) + self._stdout.write(nl) self.clear_buffer() self._stdout.flush() - def update_line(self, line): - return line + def update_line(self, s): + return s def clear_buffer(self): self.truncate(0) diff --git a/rcli/display/style.py b/rcli/display/style.py index ad519a2..90f073e 100644 --- a/rcli/display/style.py +++ b/rcli/display/style.py @@ -1,16 +1,43 @@ import copy -import types +import typing import colorama +class Color: + def __init__(self, color): + if color < 0 or color > 256: + raise AttributeError("color must be between 0 and 256 inclusive") + self._color = color + + def __str__(self): + return self.foreground() + + def foreground(self): + return f"38;5;{self._color}" + + def background(self): + return f"48;5;{self._color}" + + +class Reset: + def __str__(self): + return str(colorama.Style.RESET_ALL) + + def __enter__(self): + pass + + def __exit__(self, *args, **kwargs): + pass + + class Style: __stack = [] def __init__( self, - foreground: types.Option[Color, str, int] = None, - background: types.Option[Color, str, int] = None, + foreground: typing.Union[Color, str, int] = None, + background: typing.Union[Color, str, int] = None, bold: bool = None, italic: bool = None, dim: bool = None, @@ -18,17 +45,16 @@ def __init__( blink: bool = None, reverse: bool = None, hidden: bool = None, + reset: bool = True, ): + self.foreground = str(foreground) if foreground else None + self.background = str(background) if background else None + if hasattr(background, "background"): + self.background = background.background() if str(foreground).startswith("\033["): self.foreground = str(foreground).strip("\033[m") - else: - self.foreground = str(foreground) if str(background).startswith("\033["): self.background = str(background).strip("\033[m") - elif isinstance(background, int): - self.background = str(background) - else: - self.background = background.background() self.bold = bold self.dim = dim self.italic = italic @@ -36,9 +62,12 @@ def __init__( self.blink = blink self.reverse = reverse self.hidden = hidden + self._reset = reset def __str__(self): - value = "{escape}{}{}{}{}{}{}{}{}{}".format( + value = "{}{}{}{}{}{}{}{}{}{}{}".format( + colorama.Style.RESET_ALL if self._reset else "", + colorama.ansi.CSI, self._value(self.foreground, None, "foreground"), self._value(self.background, None, "background",), self._value(1, 21, "bold"), @@ -48,10 +77,12 @@ def __str__(self): self._value(5, 25, "blink"), self._value(7, 27, "reverse"), self._value(8, 28, "hidden"), - escape=colorama.ansi.CSI, ) return f"{value[:-1]}m" + def __repr__(self): + return str(list(str(self))) + def _value(self, on, off, param): value = getattr(self, param, None) if value is False and off is not None: @@ -62,12 +93,10 @@ def _value(self, on, off, param): def __enter__(self): self.__stack.append(self.full_style(self)) - print(colorama.Style.RESET_ALL, end="") print(self.current(), end="") def __exit__(self, *args, **kwargs): self.__stack.pop() - print(colorama.Style.RESET_ALL, end="") print(self.current(), end="") @classmethod @@ -75,47 +104,27 @@ def full_style(cls, style): full_style = copy.deepcopy(style) if cls.__stack: current = cls.current() - if full_style.foreground == None: - full_style.foreground = current.foreground - if full_style.background == None: - full_style.background = current.background - if full_style.bold == None: - full_style.bold = current.bold - if full_style.italic == None: - full_style.italic = current.italic - if full_style.dim == None: - full_style.dim = current.dim - if full_style.underlined == None: - full_style.underlined = current.underlined - if full_style.blink == None: - full_style.blink = current.blink - if full_style.reverse == None: - full_style.reverse = current.reverse - if full_style.hidden == None: - full_style.hidden = current.hidden + for attr in vars(full_style): + if getattr(full_style, attr, None) is None: + setattr(full_style, attr, getattr(current, attr, None)) return full_style @classmethod def current(cls): - return cls.__stack[-1] if cls.__stack else colorama.Style.RESET_ALL - - -class Color: - def __init__(self, color): - if color < 0 or color > 256: - raise AttributeError("color must be between 0 and 256 inclusive") - self._color = color - - def __str__(self): - return self.foreground() - - def foreground(self): - return f"38;5;{self._color}" - - def background(self): - return f"48;5;{self._color}" - - + return cls.__stack[-1] if cls.__stack else Reset() + + +default = Style( + colorama.ansi.AnsiFore.RESET, + colorama.ansi.AnsiBack.RESET, + False, + False, + False, + False, + False, + False, + False, +) bold = bright = Style(bold=True) dim = Style(dim=True) italic = Style(italic=True) diff --git a/rcli/display/util.py b/rcli/display/util.py new file mode 100644 index 0000000..e331cd2 --- /dev/null +++ b/rcli/display/util.py @@ -0,0 +1,22 @@ +import re + +import colorama + + +_INVISIBLE_UNICODE = re.compile("\\[\u00BF-\uFFFF\\]") + + +def visible_len(s): + return len(remove_invisible_characters(s)) + + +def remove_invisible_characters(s): + return remove_control_characters(remove_ansi_codes(s)) + + +def remove_control_characters(s): + return re.sub(_INVISIBLE_UNICODE, "", s) + + +def remove_ansi_codes(s): + return re.sub(colorama.ansitowin32.AnsiToWin32.ANSI_CSI_RE, "", s) From 6f1b753277a6dbdc2c5500389c56b16a3d81a9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Melissa=20Nu=C3=B1o?= Date: Sun, 24 May 2020 13:33:48 -0600 Subject: [PATCH 3/8] Fix nested separators and background bleeding. --- rcli/display/box.py | 64 +++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/rcli/display/box.py b/rcli/display/box.py index 2e628bf..117e0b0 100644 --- a/rcli/display/box.py +++ b/rcli/display/box.py @@ -7,7 +7,7 @@ from .style import Style, Reset -class BoxIO(AppendIOBase): +class _BoxIO(AppendIOBase): def __init__(self, box_): super().__init__() self._box = box_ @@ -15,19 +15,29 @@ def __init__(self, box_): self._sep = remove_invisible_characters(self._box._get_sep()) def write(self, s): - super().write(f"{Style.current() if s != self._sep else ''}{s}") + super().write( + f"{self._style if self._is_sep(s) else Style.current()}{s}" + ) def update_line(self, s): + stack = Box._stack current_style = Style.current() - cleaned_s = remove_invisible_characters(s) - if cleaned_s[:2] == self._sep[:2] and cleaned_s[-2:] == self._sep[-2:]: - return f"{self._style}{s}{current_style}" - left = " ".join(f"{box[1]}{box[0]._vertical}" for box in Box._stack) + if self._is_sep(s): + stack = Box._stack[:-1] + current_style = self._style + left = " ".join(f"{box[1]}{box[0]._vertical}" for box in stack) + left += " " if left else "" return functools.reduce( lambda r, b: self._get_right_append(r, b[0], *b[1]), - zip(range(len(Box._stack) - 1, -1, -1), reversed(Box._stack)), - f"{self._style}{left} {current_style}{s}{self._style}", - ) + str(current_style) + zip(range(len(stack) - 1, -1, -1), reversed(stack)), + f"{self._style}{left}{current_style}{s}{self._style}", + ) + str(Reset()) + + def _is_sep(self, s): + cleaned_s = remove_invisible_characters(s) + return ( + cleaned_s[:2] == self._sep[:2] and cleaned_s[-2:] == self._sep[-2:] + ) def _get_right_append(self, current, i, box_, style): num_spaces = ( @@ -68,27 +78,29 @@ def __init__( self._size = size def top(self): - print( - self._line( - self._horizontal, - self._upper_left, - self._upper_right + str(Reset()), - ), - flush=True, - ) + with Style.current(): + print( + self._line( + self._horizontal, + self._upper_left, + self._upper_right + str(Reset()), + ), + flush=True, + ) def sep(self): print(self._get_sep(), sep="", flush=True) def bottom(self): - print( - self._line( - self._horizontal, - self._lower_left, - self._lower_right + str(Reset()), - ), - flush=True, - ) + with Style.current(): + print( + self._line( + self._horizontal, + self._lower_left, + self._lower_right + str(Reset()), + ), + flush=True, + ) def _set_size(self, size): self._size = size @@ -99,7 +111,7 @@ def _line(self, char, start, end): return f"{start}{char * (width - 2)}{end}" def _create_buffer(self): - return BoxIO(self) + return _BoxIO(self) def _get_sep(self): return self._line( From be6194fe14fcc17875b094102063e64cc2969714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Melissa=20Nu=C3=B1o?= Date: Sun, 24 May 2020 13:40:55 -0600 Subject: [PATCH 4/8] Boxes now inherit size. --- rcli/display/box.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/rcli/display/box.py b/rcli/display/box.py index 117e0b0..d0c2d15 100644 --- a/rcli/display/box.py +++ b/rcli/display/box.py @@ -88,7 +88,7 @@ def top(self): flush=True, ) - def sep(self): + def sep(self, text=""): print(self._get_sep(), sep="", flush=True) def bottom(self): @@ -105,7 +105,7 @@ def bottom(self): def _set_size(self, size): self._size = size - def _line(self, char, start, end): + def _line(self, char, start, end, text=""): size = self._size or terminal.cols() width = size - 4 * (Box._depth - 1) return f"{start}{char * (width - 2)}{end}" @@ -113,9 +113,9 @@ def _line(self, char, start, end): def _create_buffer(self): return _BoxIO(self) - def _get_sep(self): + def _get_sep(self, text=""): return self._line( - self._sep_horizontal, self._sep_left, self._sep_right + self._sep_horizontal, self._sep_left, self._sep_right, text ) def __enter__(self): @@ -134,6 +134,8 @@ def new_style(*args, **kwargs): @contextlib.contextmanager def inner(**kw): impl = Box(*args, **kwargs) + if Box._stack: + impl._set_size(Box._stack[-1][0]._size) if "size" in kw: impl._set_size(kw["size"]) with impl, contextlib.redirect_stdout(impl._create_buffer()): From 4fbf107edea7ae0a3b5fbe00338ca6cbb020cc67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Melissa=20Nu=C3=B1o?= Date: Sun, 24 May 2020 14:55:21 -0600 Subject: [PATCH 5/8] Finish basic boxx support. --- rcli/dispatcher.py | 3 +- rcli/display/box.py | 47 +++++++++++++++---------- rcli/display/style.py | 54 +++++++++++++++++------------ setup.py | 81 ++++++++++++++++++++----------------------- 4 files changed, 99 insertions(+), 86 deletions(-) diff --git a/rcli/dispatcher.py b/rcli/dispatcher.py index 8a8abfd..9e285f4 100644 --- a/rcli/dispatcher.py +++ b/rcli/dispatcher.py @@ -15,7 +15,6 @@ from docopt import docopt import colorama -import six from . import ( # noqa: F401 pylint: disable=unused-import exceptions as exc, @@ -36,7 +35,7 @@ def main(): If the command is 'help' then print the help message for the subcommand; if no subcommand is given, print the standard help message. """ - colorama.init(wrap=six.PY3) + colorama.init(strip=not sys.stdout.isatty()) doc = usage.get_primary_command_usage() allow_subcommands = "" in doc args = docopt( diff --git a/rcli/display/box.py b/rcli/display/box.py index d0c2d15..f669984 100644 --- a/rcli/display/box.py +++ b/rcli/display/box.py @@ -4,7 +4,7 @@ from . import terminal from .io import AppendIOBase from .util import visible_len, remove_invisible_characters -from .style import Style, Reset +from .style import Style class _BoxIO(AppendIOBase): @@ -27,11 +27,14 @@ def update_line(self, s): current_style = self._style left = " ".join(f"{box[1]}{box[0]._vertical}" for box in stack) left += " " if left else "" - return functools.reduce( - lambda r, b: self._get_right_append(r, b[0], *b[1]), - zip(range(len(stack) - 1, -1, -1), reversed(stack)), - f"{self._style}{left}{current_style}{s}{self._style}", - ) + str(Reset()) + return ( + functools.reduce( + lambda r, b: self._get_right_append(r, b[0], *b[1]), + zip(range(len(stack) - 1, -1, -1), reversed(stack)), + f"{self._style}{left}{current_style}{s}{self._style}", + ) + + Style.reset + ) def _is_sep(self, s): cleaned_s = remove_invisible_characters(s) @@ -65,6 +68,8 @@ def __init__( sep_horizontal="\u2500", sep_right="\u2524", size=None, + header="", + header_style=None, ): self._upper_left = upper_left self._upper_right = upper_right @@ -76,20 +81,23 @@ def __init__( self._sep_horizontal = sep_horizontal self._sep_right = sep_right self._size = size + self._header = header + self.header_style = header_style - def top(self): + def top(self, text=""): with Style.current(): print( self._line( self._horizontal, self._upper_left, - self._upper_right + str(Reset()), + f"{self._upper_right}{Style.reset}", + self.header_style(text) if self.header_style else text, ), flush=True, ) def sep(self, text=""): - print(self._get_sep(), sep="", flush=True) + print(self._get_sep(text), sep="", flush=True) def bottom(self): with Style.current(): @@ -97,18 +105,19 @@ def bottom(self): self._line( self._horizontal, self._lower_left, - self._lower_right + str(Reset()), + f"{self._lower_right}{Style.reset}", ), flush=True, ) - def _set_size(self, size): - self._size = size - def _line(self, char, start, end, text=""): size = self._size or terminal.cols() - width = size - 4 * (Box._depth - 1) - return f"{start}{char * (width - 2)}{end}" + vislen = visible_len(text) + if vislen: + text = f" {text} " + vislen += 2 + width = size - 4 * (Box._depth - 1) - vislen - 2 + return f"{start}{char}{text}{char * (width - 2)}{char}{end}" def _create_buffer(self): return _BoxIO(self) @@ -120,7 +129,7 @@ def _get_sep(self, text=""): def __enter__(self): Box._depth += 1 - self.top() + self.top(self._header) Box._stack.append((self, Style.current())) return self @@ -135,9 +144,10 @@ def new_style(*args, **kwargs): def inner(**kw): impl = Box(*args, **kwargs) if Box._stack: - impl._set_size(Box._stack[-1][0]._size) + impl._size = Box._stack[-1][0]._size if "size" in kw: - impl._set_size(kw["size"]) + impl._size = kw["size"] + impl._header = kw.get("header", "") with impl, contextlib.redirect_stdout(impl._create_buffer()): yield impl @@ -155,6 +165,7 @@ def inner(**kw): "\u2523", "\u2501", "\u252B", + header_style=Style.bold, ) Box.info = Box.new_style( "\u250F", diff --git a/rcli/display/style.py b/rcli/display/style.py index 90f073e..89188ba 100644 --- a/rcli/display/style.py +++ b/rcli/display/style.py @@ -20,7 +20,7 @@ def background(self): return f"48;5;{self._color}" -class Reset: +class _Reset: def __str__(self): return str(colorama.Style.RESET_ALL) @@ -30,8 +30,15 @@ def __enter__(self): def __exit__(self, *args, **kwargs): pass + def __add__(self, s): + return str(self) + s + + def __radd__(self, s): + return s + str(self) + class Style: + reset = _Reset() __stack = [] def __init__( @@ -91,6 +98,15 @@ def _value(self, on, off, param): return f"{on};" return "" + def __add__(self, s): + return str(self) + s + + def __radd__(self, s): + return s + str(self) + + def __call__(self, s): + return f"{self.reset}{self.full_style(self)}{s}{self.current()}" + def __enter__(self): self.__stack.append(self.full_style(self)) print(self.current(), end="") @@ -111,24 +127,18 @@ def full_style(cls, style): @classmethod def current(cls): - return cls.__stack[-1] if cls.__stack else Reset() - - -default = Style( - colorama.ansi.AnsiFore.RESET, - colorama.ansi.AnsiBack.RESET, - False, - False, - False, - False, - False, - False, - False, -) -bold = bright = Style(bold=True) -dim = Style(dim=True) -italic = Style(italic=True) -underlined = Style(underlined=True) -blink = Style(blink=True) -reverse = Style(reverse=True) -hidden = Style(hidden=True) + return cls.__stack[-1] if cls.__stack else cls.reset + + +Style.default = Style.reset +Style.bold = bright = Style(bold=True) +Style.dim = Style(dim=True) +Style.italic = Style(italic=True) +Style.underlined = Style(underlined=True) +Style.blink = Style(blink=True) +Style.reverse = Style(reverse=True) +Style.hidden = Style(hidden=True) + + +def styled(text, *args, **kwargs): + return Style(*args, **kwargs)(text) diff --git a/setup.py b/setup.py index 14c6f7d..09abf58 100755 --- a/setup.py +++ b/setup.py @@ -11,62 +11,55 @@ from setuptools import find_packages -if os.path.isdir('rcli.egg-info'): +if os.path.isdir("rcli.egg-info"): try: - shutil.rmtree('rcli.egg-info') + shutil.rmtree("rcli.egg-info") except IOError: pass -with open('README.rst') as readme_fp: +with open("README.rst") as readme_fp: readme = readme_fp.read() -common_requires = ['docopt >= 0.6.2, < 1', - 'six >= 1.10.0'] +common_requires = ["docopt >= 0.6.2, < 1", "six >= 1.10.0"] setup( - name='rcli', + name="rcli", use_scm_version=True, - description='A library for rapidly creating command-line tools.', + description="A library for rapidly creating command-line tools.", long_description=readme, - author='Melissa Nuno', - author_email='melissa@contains.io', - url='https://github.com/contains-io/rcli', - keywords=['docopt', 'commands', 'subcommands', 'tooling', 'cli'], - license='MIT', - packages=find_packages(exclude=['tests', 'docs']), + author="Melissa Nuno", + author_email="melissa@contains.io", + url="https://github.com/contains-io/rcli", + keywords=["docopt", "commands", "subcommands", "tooling", "cli"], + license="MIT", + packages=find_packages(exclude=["tests", "docs"]), install_requires=[ - 'typet >= 0.4, < 0.5', - 'backports.shutil_get_terminal_size', - 'colorama >= 0.3.6, < 1', - 'tqdm >= 4.9.0, < 5' - ] + common_requires, - setup_requires=[ - 'packaging', - 'appdirs', - 'pytest-runner', - 'setuptools_scm' - ] + common_requires, - tests_require=[ - 'pytest >= 3.0' - ], + "typet >= 0.4, < 0.5", + "backports.shutil_get_terminal_size", + "colorama >= 0.3.6, < 1", + "tqdm >= 4.9.0, < 5", + ] + + common_requires, + setup_requires=["packaging", "appdirs", "pytest-runner", "setuptools_scm"] + + common_requires, + tests_require=["pytest >= 3.0"], entry_points={ - 'distutils.setup_keywords': [ - 'autodetect_commands = rcli.autodetect:setup_keyword' + "distutils.setup_keywords": [ + "autodetect_commands = rcli.autodetect:setup_keyword" + ], + "egg_info.writers": [ + "rcli-config.json = rcli.autodetect:egg_info_writer" ], - 'egg_info.writers': [ - 'rcli-config.json = rcli.autodetect:egg_info_writer' - ] }, classifiers=[ - 'Development Status :: 3 - Alpha', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Intended Audience :: Developers', - 'Topic :: Utilities' - ] + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Intended Audience :: Developers", + "Topic :: Utilities", + ], ) From 3b86237d3c3adea161e58353ca3b04e79c3f203e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Melissa=20Nu=C3=B1o?= Date: Sun, 24 May 2020 15:04:30 -0600 Subject: [PATCH 6/8] Remove prototype functions and add footer support. --- rcli/display/__init__.py | 11 ----------- rcli/display/box.py | 23 ++++++++++++++++++----- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/rcli/display/__init__.py b/rcli/display/__init__.py index 916bd35..29e8a75 100644 --- a/rcli/display/__init__.py +++ b/rcli/display/__init__.py @@ -159,17 +159,6 @@ def print_message(msg): print_header("completed in {:.2f}s".format(delta), False) -def line(char="\u2500", start="\u2500", end="\u2500"): - print(start, char * _ncols() - 2, end) - - -@contextlib.contextmanager -def box(): - line(start="\u250F", end="\u2513") - yield - line(start="\u2517", end="\u251B") - - def run_tasks(header, tasks): """Run a group of tasks with a header, footer and success/failure messages. diff --git a/rcli/display/box.py b/rcli/display/box.py index f669984..edfe6b3 100644 --- a/rcli/display/box.py +++ b/rcli/display/box.py @@ -70,6 +70,9 @@ def __init__( size=None, header="", header_style=None, + footer="", + footer_style=None, + sep_style=None, ): self._upper_left = upper_left self._upper_right = upper_right @@ -82,7 +85,10 @@ def __init__( self._sep_right = sep_right self._size = size self._header = header - self.header_style = header_style + self._header_style = header_style + self._footer = footer + self._footer_style = footer_style + self._sep_style = sep_style def top(self, text=""): with Style.current(): @@ -91,7 +97,7 @@ def top(self, text=""): self._horizontal, self._upper_left, f"{self._upper_right}{Style.reset}", - self.header_style(text) if self.header_style else text, + self._header_style(text) if self._header_style else text, ), flush=True, ) @@ -99,13 +105,14 @@ def top(self, text=""): def sep(self, text=""): print(self._get_sep(text), sep="", flush=True) - def bottom(self): + def bottom(self, text=""): with Style.current(): print( self._line( self._horizontal, self._lower_left, f"{self._lower_right}{Style.reset}", + self._footer_style(text) if self._footer_style else text, ), flush=True, ) @@ -124,7 +131,10 @@ def _create_buffer(self): def _get_sep(self, text=""): return self._line( - self._sep_horizontal, self._sep_left, self._sep_right, text + self._sep_horizontal, + self._sep_left, + self._sep_right, + self._sep_style(text) if self._sep_style else text, ) def __enter__(self): @@ -135,7 +145,7 @@ def __enter__(self): def __exit__(self, *args, **kwargs): Box._stack.pop() - self.bottom() + self.bottom(self._footer) Box._depth -= 1 @staticmethod @@ -148,6 +158,7 @@ def inner(**kw): if "size" in kw: impl._size = kw["size"] impl._header = kw.get("header", "") + impl._footer = kw.get("footer", "") with impl, contextlib.redirect_stdout(impl._create_buffer()): yield impl @@ -166,6 +177,8 @@ def inner(**kw): "\u2501", "\u252B", header_style=Style.bold, + footer_style=Style.bold, + sep_style=Style.bold, ) Box.info = Box.new_style( "\u250F", From ade2e00ea2520458376f814766255a579ce254c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Melissa=20Nu=C3=B1o?= Date: Sun, 24 May 2020 16:36:12 -0600 Subject: [PATCH 7/8] Start adding testing and alignment. --- rcli/display/box.py | 48 +++++++---- rcli/display/style.py | 6 ++ tests/test_boxes.py | 189 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 14 deletions(-) create mode 100644 tests/test_boxes.py diff --git a/rcli/display/box.py b/rcli/display/box.py index edfe6b3..78246c4 100644 --- a/rcli/display/box.py +++ b/rcli/display/box.py @@ -3,8 +3,8 @@ from . import terminal from .io import AppendIOBase -from .util import visible_len, remove_invisible_characters -from .style import Style +from .util import remove_invisible_characters, visible_len +from .style import Alignment, Style class _BoxIO(AppendIOBase): @@ -69,8 +69,12 @@ def __init__( sep_right="\u2524", size=None, header="", - header_style=None, footer="", + align=Alignment.LEFT, + header_align=None, + footer_align=None, + sep_align=None, + header_style=None, footer_style=None, sep_style=None, ): @@ -85,12 +89,15 @@ def __init__( self._sep_right = sep_right self._size = size self._header = header - self._header_style = header_style self._footer = footer + self._header_align = header_align or align + self._footer_align = footer_align or align + self._sep_align = sep_align or align + self._header_style = header_style self._footer_style = footer_style self._sep_style = sep_style - def top(self, text=""): + def top(self, text="", align=None): with Style.current(): print( self._line( @@ -98,14 +105,15 @@ def top(self, text=""): self._upper_left, f"{self._upper_right}{Style.reset}", self._header_style(text) if self._header_style else text, + align, ), flush=True, ) - def sep(self, text=""): - print(self._get_sep(text), sep="", flush=True) + def sep(self, text="", align=None): + print(self._get_sep(text, align=None), sep="", flush=True) - def bottom(self, text=""): + def bottom(self, text="", align=None): with Style.current(): print( self._line( @@ -113,39 +121,45 @@ def bottom(self, text=""): self._lower_left, f"{self._lower_right}{Style.reset}", self._footer_style(text) if self._footer_style else text, + align, ), flush=True, ) - def _line(self, char, start, end, text=""): + def _line(self, char, start, end, text="", align=None): size = self._size or terminal.cols() vislen = visible_len(text) if vislen: text = f" {text} " vislen += 2 - width = size - 4 * (Box._depth - 1) - vislen - 2 - return f"{start}{char}{text}{char * (width - 2)}{char}{end}" + width = size - 4 * (Box._depth - 1) - vislen - 4 + if align == Alignment.CENTER: + pass + if align == Alignment.RIGHT: + pass + return f"{start}{char}{text}{char * width}{char}{end}" def _create_buffer(self): return _BoxIO(self) - def _get_sep(self, text=""): + def _get_sep(self, text="", align=None): return self._line( self._sep_horizontal, self._sep_left, self._sep_right, self._sep_style(text) if self._sep_style else text, + align, ) def __enter__(self): Box._depth += 1 - self.top(self._header) + self.top(self._header, self._header_align) Box._stack.append((self, Style.current())) return self def __exit__(self, *args, **kwargs): Box._stack.pop() - self.bottom(self._footer) + self.bottom(self._footer, self._footer_align) Box._depth -= 1 @staticmethod @@ -158,7 +172,13 @@ def inner(**kw): if "size" in kw: impl._size = kw["size"] impl._header = kw.get("header", "") + impl._header_align = kw.get( + "header_align", kw.get("align", impl._header_align) + ) impl._footer = kw.get("footer", "") + impl._footer_align = kw.get( + "footer_align", kw.get("align", impl._footer_align) + ) with impl, contextlib.redirect_stdout(impl._create_buffer()): yield impl diff --git a/rcli/display/style.py b/rcli/display/style.py index 89188ba..0a9b4c8 100644 --- a/rcli/display/style.py +++ b/rcli/display/style.py @@ -4,6 +4,12 @@ import colorama +class Alignment: + LEFT = 0 + CENTER = 1 + RIGHT = 2 + + class Color: def __init__(self, color): if color < 0 or color > 256: diff --git a/tests/test_boxes.py b/tests/test_boxes.py new file mode 100644 index 0000000..4ff01d8 --- /dev/null +++ b/tests/test_boxes.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 + +from rcli.display.box import Box, box +from rcli.display.style import Alignment, Style + + +def test_simple_box(): + print() + with box(): + print("Test 0") + + +def test_simple_box_sep(): + print() + with box() as b: + print("Test 1") + b.sep() + print("Test 1") + + +def test_no_flush(): + print() + with Style(43): + with box() as b: + print("Test 2") + b.sep() + print("Test", end="") + print(" 2") + + +def test_styled_sep_box(): + print() + with box() as b: + print("Test 3") + b.sep() + with Style(30, 45): + print("Test 3") + b.sep() + print("Test 3") + + +def test_background_in_row(): + print() + with Style(42): + with box() as b: + print("Test 4") + b.sep() + with Style(43): + print("Test 4") + b.sep() + print("Test 4") + + +def test_info_box(): + print() + with Box.info() as b: + print("Test 5") + b.sep() + print("Test 5") + + +def test_ascii_box(): + print() + with Box.ascii() as b: + print("Test 6") + b.sep() + print("Test 6") + + +def test_thick_box(): + print() + with Box.thick() as b: + print("Test 7") + b.sep() + print("Test 7") + + +def test_star_box(): + print() + with Box.star() as b: + print("Test 8") + b.sep() + print("Test 8") + + +def test_double_box(): + print() + with Box.double() as b: + print("Test 9") + b.sep() + print("Test 9") + + +def test_fancy_box(): + print() + with Box.fancy() as b: + print("Test 10") + b.sep() + print("Test 10") + + +def test_colored_box_white_text(): + print() + with Style(31), box() as b, Style(39): + print("Test 11") + b.sep() + print("Test 11") + + +def test_nested_boxes(): + print() + with box(), box(): + print("Test 12") + + +def test_deeply_nested_boxes(): + print() + with box(), Box.fancy(), Box.double(), Box.ascii(): + print("Test 13") + + +def test_nested_boxes_with_colors_and_rows(): + print() + with Style(31), Box.fancy(): + with Style(35), box(), Style(32), Box.thick(), Style(30, 45): + print("Test 14") + with Style(33), Box.double(), Style(39): + print("Test 14") + + +def test_round_box(): + print() + with Box.round() as b: + print("Test 15") + b.sep() + print("Test 15") + + +def test_set_width_boxes(): + print() + with box(size=40), box(size=20): + print("Test 16") + + +def test_colored_set_width_boxes(): + print() + with Style(39, 44), Box.thick(size=40): + with Style(31, 42), box(size=20), Style(30): + print("Test 17") + with Style(30, 41), box(size=30), Style(39), box(), Style(30, 47): + print("Test 17") + with Style(33, 49), box(size=35), Style(39): + print("Test 17") + + +def test_colored_nested_boxes_with_separator(): + print() + with Style(31), Box.round(), Style(34), box() as b2, Style(39): + print("Test 18") + b2.sep() + print("Test 18") + + +def test_colored_deeply_nested_boxes_with_separator(): + print() + with Style(31), Box.round() as b1, Style(39): + print("Test 19") + b1.sep() + with Style(34), box() as b2, Style(39): + print("Test 19") + b2.sep() + print("Test 19") + b2.sep() + with Style(33), box() as b3, Style(39): + print("Test 19") + b3.sep() + print("Test 19") + b1.sep() + print("Test 19") + + +def test_box_with_headers(): + print() + with Box.thick( + header="Test 20", footer="Test 20", footer_align=Alignment.RIGHT + ) as b: + print("Test 20") + b.sep("Test 20", align=Alignment.CENTER) + print("Test 20") From d64977ef8194a6da4bdf1e24ec724c6c8dea6e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Melissa=20Nu=C3=B1o?= Date: Sun, 24 May 2020 16:41:52 -0600 Subject: [PATCH 8/8] Add alignment for headers. --- rcli/display/box.py | 11 ++++++++--- tests/test_boxes.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/rcli/display/box.py b/rcli/display/box.py index 78246c4..4cf852e 100644 --- a/rcli/display/box.py +++ b/rcli/display/box.py @@ -111,7 +111,9 @@ def top(self, text="", align=None): ) def sep(self, text="", align=None): - print(self._get_sep(text, align=None), sep="", flush=True) + print( + self._get_sep(text, align or self._sep_align), sep="", flush=True + ) def bottom(self, text="", align=None): with Style.current(): @@ -134,9 +136,9 @@ def _line(self, char, start, end, text="", align=None): vislen += 2 width = size - 4 * (Box._depth - 1) - vislen - 4 if align == Alignment.CENTER: - pass + return f"{start}{char}{char * int(width / 2 + .5)}{text}{char * int(width / 2)}{char}{end}" if align == Alignment.RIGHT: - pass + return f"{start}{char}{char * width}{text}{char}{end}" return f"{start}{char}{text}{char * width}{char}{end}" def _create_buffer(self): @@ -179,6 +181,9 @@ def inner(**kw): impl._footer_align = kw.get( "footer_align", kw.get("align", impl._footer_align) ) + impl._sep_align = kw.get( + "sep_align", kw.get("align", impl._sep_align) + ) with impl, contextlib.redirect_stdout(impl._create_buffer()): yield impl diff --git a/tests/test_boxes.py b/tests/test_boxes.py index 4ff01d8..3fade20 100644 --- a/tests/test_boxes.py +++ b/tests/test_boxes.py @@ -186,4 +186,16 @@ def test_box_with_headers(): ) as b: print("Test 20") b.sep("Test 20", align=Alignment.CENTER) + b.sep(" Test 20", align=Alignment.CENTER) + b.sep("Test 20 ", align=Alignment.CENTER) print("Test 20") + + +def test_box_with_headers_single_align(): + print() + with Box.thick( + header="Test 21", footer="Test 21", align=Alignment.CENTER + ) as b: + print("Test 21") + b.sep("Test 21") + print("Test 21")