-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add an experimental rendering module for client (#62)
This rendering module aim to make the output of information easy and less complicated by enforcing a single class that we can plug new "decorators" to be used. Currently, this rendering class has support for: colors, emoji, text wrap, font styles and a basic spinner to wait for responses.
- Loading branch information
Showing
20 changed files
with
575 additions
and
30 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
from abc import ABC, abstractmethod | ||
|
||
|
||
class RenderDecorator(ABC): | ||
"""Abstract base class for render decorators""" | ||
|
||
@abstractmethod | ||
def decorate(self, text: str) -> str: | ||
pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
import os | ||
from typing import Optional | ||
|
||
from colorama import Back, Fore, Style | ||
|
||
from command_line_assistant.rendering.decorators.base import RenderDecorator | ||
|
||
|
||
class ColorDecorator(RenderDecorator): | ||
"""Decorator for adding foreground and background colors to text using colorama""" | ||
|
||
# Color name mappings for better IDE support and type checking | ||
FOREGROUND_COLORS = { | ||
"black": Fore.BLACK, | ||
"red": Fore.RED, | ||
"green": Fore.GREEN, | ||
"yellow": Fore.YELLOW, | ||
"blue": Fore.BLUE, | ||
"magenta": Fore.MAGENTA, | ||
"cyan": Fore.CYAN, | ||
"white": Fore.WHITE, | ||
"reset": Fore.RESET, | ||
# Light variants | ||
"lightblack": Fore.LIGHTBLACK_EX, | ||
"lightred": Fore.LIGHTRED_EX, | ||
"lightgreen": Fore.LIGHTGREEN_EX, | ||
"lightyellow": Fore.LIGHTYELLOW_EX, | ||
"lightblue": Fore.LIGHTBLUE_EX, | ||
"lightmagenta": Fore.LIGHTMAGENTA_EX, | ||
"lightcyan": Fore.LIGHTCYAN_EX, | ||
"lightwhite": Fore.LIGHTWHITE_EX, | ||
} | ||
|
||
BACKGROUND_COLORS = { | ||
"black": Back.BLACK, | ||
"red": Back.RED, | ||
"green": Back.GREEN, | ||
"yellow": Back.YELLOW, | ||
"blue": Back.BLUE, | ||
"magenta": Back.MAGENTA, | ||
"cyan": Back.CYAN, | ||
"white": Back.WHITE, | ||
"reset": Back.RESET, | ||
# Light variants | ||
"lightblack": Back.LIGHTBLACK_EX, | ||
"lightred": Back.LIGHTRED_EX, | ||
"lightgreen": Back.LIGHTGREEN_EX, | ||
"lightyellow": Back.LIGHTYELLOW_EX, | ||
"lightblue": Back.LIGHTBLUE_EX, | ||
"lightmagenta": Back.LIGHTMAGENTA_EX, | ||
"lightcyan": Back.LIGHTCYAN_EX, | ||
"lightwhite": Back.LIGHTWHITE_EX, | ||
} | ||
|
||
def __init__( | ||
self, | ||
foreground: str = "white", | ||
background: Optional[str] = None, | ||
) -> None: | ||
""" | ||
Initialize the color decorator with the specified colors and style. | ||
Args: | ||
foreground: Foreground color name (default: "white") | ||
background: Optional background color name | ||
style: Optional style name ("dim", "normal", "bright") | ||
""" | ||
self.foreground = self._get_foreground_color(foreground) | ||
self.background = self._get_background_color(background) if background else "" | ||
|
||
def _get_foreground_color(self, color: str) -> str: | ||
"""Get the colorama foreground color code.""" | ||
color = color.lower() | ||
if color not in self.FOREGROUND_COLORS: | ||
raise ValueError( | ||
f"Invalid foreground color. Choose from: {', '.join(self.FOREGROUND_COLORS.keys())}" | ||
) | ||
return self.FOREGROUND_COLORS[color] | ||
|
||
def _get_background_color(self, color: str) -> str: | ||
"""Get the colorama background color code.""" | ||
color = color.lower() | ||
if color not in self.BACKGROUND_COLORS: | ||
raise ValueError( | ||
f"Invalid background color. Choose from: {', '.join(self.BACKGROUND_COLORS.keys())}" | ||
) | ||
return self.BACKGROUND_COLORS[color] | ||
|
||
def decorate(self, text: str) -> str: | ||
"""Apply the color formatting to the text.""" | ||
formatted_text = f"{self.background}{self.foreground}{text}{Style.RESET_ALL}" | ||
return formatted_text | ||
|
||
|
||
def should_disable_color_output(): | ||
""" | ||
Return whether NO_COLOR exists in environment parameter and is true. | ||
See https://no-color.org/ | ||
""" | ||
if "NO_COLOR" in os.environ: | ||
no_color = os.environ["NO_COLOR"] | ||
return no_color is not None and no_color != "0" and no_color.lower() != "false" | ||
|
||
return False |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
from typing import Optional | ||
|
||
from colorama import Style | ||
|
||
from command_line_assistant.rendering.decorators.base import RenderDecorator | ||
|
||
|
||
class StyleDecorator(RenderDecorator): | ||
"""Decorator for adding text styles using colorama""" | ||
|
||
STYLES = { | ||
"dim": Style.DIM, | ||
"normal": Style.NORMAL, | ||
"bright": Style.BRIGHT, | ||
"reset": Style.RESET_ALL, | ||
} | ||
|
||
def __init__(self, style: Optional[str] = None) -> None: | ||
""" | ||
Initialize the style decorator with the specified styles. | ||
Args: | ||
style: Name of a style to be applied ("dim", "normal", "bright") | ||
""" | ||
self.style = self._get_style(style) if style else None | ||
|
||
def _get_style(self, style: str) -> str: | ||
"""Get the colorama style code.""" | ||
style = style.lower() | ||
if style not in self.STYLES: | ||
raise ValueError( | ||
f"Invalid style. Choose from: {', '.join(self.STYLES.keys())}" | ||
) | ||
return self.STYLES[style] | ||
|
||
def decorate(self, text: str) -> str: | ||
"""Apply the style formatting to the text.""" | ||
if self.style: | ||
return f"{self.style}{text}{Style.RESET_ALL}" | ||
|
||
return text |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import shutil | ||
import textwrap | ||
from typing import Optional, Union | ||
|
||
from command_line_assistant.rendering.decorators.base import RenderDecorator | ||
|
||
|
||
class EmojiDecorator(RenderDecorator): | ||
def __init__(self, emoji: Union[str, int]) -> None: | ||
self._emoji = self._normalize_emoji(emoji) | ||
|
||
def _normalize_emoji(self, emoji: Union[str, int]) -> str: | ||
if isinstance(emoji, int): | ||
return chr(emoji) | ||
|
||
if isinstance(emoji, str): | ||
# If already an emoji character | ||
if len(emoji) <= 2 and ord(emoji[0]) > 127: | ||
return emoji | ||
|
||
# Convert code point to emoji | ||
code = emoji.upper().replace("U+", "").replace("0X", "") | ||
return chr(int(code, 16)) | ||
|
||
raise TypeError(f"Emoji must be string or int, not {type(emoji)}") | ||
|
||
def decorate(self, text: str) -> str: | ||
return f"{self._emoji} {text}" | ||
|
||
|
||
class TextWrapDecorator(RenderDecorator): | ||
def __init__(self, width: Optional[int] = None, indent: str = "") -> None: | ||
self._width = width or shutil.get_terminal_size().columns | ||
self._indent = indent | ||
|
||
def decorate(self, text: str) -> str: | ||
return textwrap.fill( | ||
text, | ||
width=self._width, | ||
initial_indent=self._indent, | ||
subsequent_indent=self._indent, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import shutil | ||
|
||
from command_line_assistant.rendering.decorators.base import RenderDecorator | ||
|
||
|
||
class TextRenderer: | ||
def __init__(self) -> None: | ||
# Fetch the current terminal size on initialization | ||
self.terminal_width = shutil.get_terminal_size().columns | ||
self._decorators: dict[type, RenderDecorator] = {} | ||
|
||
def update(self, decorator: RenderDecorator) -> None: | ||
"""Update or add a decorator of the same type.""" | ||
self._decorators[type(decorator)] = decorator | ||
|
||
def render(self, text: str): | ||
decorated_text = text | ||
# Apply all decorators except Spinner | ||
for decorator in self._decorators.values(): | ||
decorated_text = decorator.decorate(decorated_text) | ||
|
||
print(decorated_text) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
import itertools | ||
import sys | ||
import threading | ||
import time | ||
from contextlib import contextmanager | ||
from dataclasses import dataclass | ||
from typing import Generator, Iterator | ||
|
||
|
||
@dataclass | ||
class Frames: | ||
default: Iterator[str] = itertools.cycle(["-", "\\", "|", "/"]) | ||
braille: Iterator[str] = itertools.cycle(["⠋", "⠙", "⠸", "⠴", "⠦", "⠇"]) | ||
circular: Iterator[str] = itertools.cycle(["◐", "◓", "◑", "◒"]) | ||
dots: Iterator[str] = itertools.cycle([". ", ".. ", "...", " ..", " .", " "]) | ||
arrows: Iterator[str] = itertools.cycle(["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"]) | ||
moving: Iterator[str] = itertools.cycle( | ||
["[ ]", "[= ]", "[== ]", "[===]", "[ ==]", "[ =]", "[ ]"] | ||
) | ||
|
||
|
||
@contextmanager | ||
def ascii_spinner( | ||
message: str, | ||
clear_message: bool = False, | ||
frames: Iterator[str] = Frames.default, | ||
delay: float = 0.1, | ||
) -> Generator: | ||
done = threading.Event() | ||
|
||
def animation() -> None: | ||
while not done.is_set(): | ||
sys.stdout.write(f"\r{next(frames)} {message}") # Write the current frame | ||
sys.stdout.flush() | ||
time.sleep(delay) # Delay between frames | ||
|
||
spinner_thread = threading.Thread(target=animation) | ||
spinner_thread.start() | ||
|
||
try: | ||
yield | ||
finally: | ||
done.set() # Signal the spinner to stop | ||
spinner_thread.join() # Wait for the spinner thread to finish | ||
sys.stdout.write("\r\n") | ||
if clear_message: | ||
# Clear the message by overwriting it with spaces and resetting the cursor | ||
sys.stdout.write("\r" + " " * (len(message) + 2) + "\r") # Clear the line | ||
sys.stdout.flush() |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import pytest | ||
|
||
from command_line_assistant.rendering.decorators.colors import ( | ||
ColorDecorator, | ||
) | ||
|
||
|
||
def test_color_decorator_basic(): | ||
decorator = ColorDecorator(foreground="red") | ||
text = "Test text" | ||
result = decorator.decorate(text) | ||
assert result.startswith("\x1b[31m") # Red color code | ||
assert result.endswith("\x1b[0m") # Reset code | ||
assert "Test text" in result | ||
|
||
|
||
def test_color_decorator_with_background(): | ||
decorator = ColorDecorator(foreground="white", background="blue") | ||
text = "Test text" | ||
result = decorator.decorate(text) | ||
assert result.startswith("\x1b[44m") # Blue background code | ||
assert "\x1b[37m" in result # White foreground code | ||
assert result.endswith("\x1b[0m") | ||
assert "Test text" in result | ||
|
||
|
||
def test_color_decorator_invalid_color(): | ||
with pytest.raises(ValueError): | ||
ColorDecorator(foreground="invalid") | ||
|
||
with pytest.raises(ValueError): | ||
ColorDecorator(foreground="white", background="invalid") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import pytest | ||
|
||
from command_line_assistant.rendering.decorators.style import StyleDecorator | ||
|
||
|
||
def test_style_decorator_single_style(): | ||
decorator = StyleDecorator("bright") | ||
text = "Test text" | ||
styled_text = decorator.decorate(text) | ||
assert styled_text != text # Style was applied | ||
assert len(styled_text) > len(text) # Reset code was added | ||
|
||
|
||
def test_style_decorator_invalid_style(): | ||
with pytest.raises(ValueError): | ||
StyleDecorator("invalid_style") | ||
|
||
|
||
def test_style_decorator_empty(): | ||
decorator = StyleDecorator() | ||
text = "Test text" | ||
styled_text = decorator.decorate(text) | ||
assert styled_text.endswith(text) # Text is at the end after any styles | ||
|
||
|
||
def test_style_decorator_reset(): | ||
decorator = StyleDecorator("bright") | ||
text = "Test text" | ||
styled_text = decorator.decorate(text) | ||
assert "\x1b[1mTest text\x1b[0m" == styled_text |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
from command_line_assistant.rendering.decorators.text import ( | ||
EmojiDecorator, | ||
TextWrapDecorator, | ||
) | ||
|
||
|
||
def test_text_wrap_decorator(): | ||
decorator = TextWrapDecorator(width=10) | ||
text = "This is a long text that should be wrapped" | ||
decorated = decorator.decorate(text) | ||
assert len(max(decorated.split("\n"), key=len)) <= 10 | ||
|
||
|
||
def test_emoji_decorator(): | ||
decorator = EmojiDecorator("⭐") | ||
text = "Test text" | ||
assert decorator.decorate(text) == "⭐ Test text" | ||
|
||
|
||
def test_emoji_decorator_with_hex(): | ||
decorator = EmojiDecorator(0x2728) # Sparkles emoji | ||
text = "Test text" | ||
assert decorator.decorate(text) == "✨ Test text" | ||
|
||
|
||
def test_emoji_decorator_with_unicode(): | ||
decorator = EmojiDecorator("U+1F4A5") # Collision emoji | ||
text = "Test text" | ||
assert decorator.decorate(text) == "💥 Test text" |
Oops, something went wrong.