Skip to content

Commit

Permalink
Add an experimental rendering module for client (#62)
Browse files Browse the repository at this point in the history
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
r0x0d authored Dec 10, 2024
1 parent 8ad1776 commit 66016e1
Show file tree
Hide file tree
Showing 20 changed files with 575 additions and 30 deletions.
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ repos:
- requests
- tomli; python_version<"3.11"
- setuptools
- colorama

- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
Expand Down
Empty file.
Empty file.
9 changes: 9 additions & 0 deletions command_line_assistant/rendering/decorators/base.py
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
105 changes: 105 additions & 0 deletions command_line_assistant/rendering/decorators/colors.py
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
41 changes: 41 additions & 0 deletions command_line_assistant/rendering/decorators/style.py
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
42 changes: 42 additions & 0 deletions command_line_assistant/rendering/decorators/text.py
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,
)
22 changes: 22 additions & 0 deletions command_line_assistant/rendering/render.py
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)
49 changes: 49 additions & 0 deletions command_line_assistant/rendering/spinner.py
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()
2 changes: 1 addition & 1 deletion pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Empty file added tests/commands/test_commands.py
Empty file.
2 changes: 1 addition & 1 deletion tests/commands/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def test_query_command_config_validation(mock_config):
@patch("command_line_assistant.commands.query.handle_query")
def test_query_command_with_special_characters(mock_handle_query, mock_config):
"""Test QueryCommand with special characters in query"""
special_query = "test\nquery\twith\rspecial\characters"
special_query = r"test\nquery\twith\rspecial\characters"
command = QueryCommand(special_query, mock_config)
command.run()
mock_handle_query.assert_called_once_with(special_query, mock_config)
Empty file added tests/rendering/__init__.py
Empty file.
Empty file.
32 changes: 32 additions & 0 deletions tests/rendering/decorators/test_colors.py
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")
30 changes: 30 additions & 0 deletions tests/rendering/decorators/test_style.py
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
29 changes: 29 additions & 0 deletions tests/rendering/decorators/test_text.py
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"
Loading

0 comments on commit 66016e1

Please sign in to comment.