Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an experimental rendering module for client #62

Merged
merged 1 commit into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

Check warning on line 9 in command_line_assistant/rendering/decorators/base.py

View check run for this annotation

Codecov / codecov/patch

command_line_assistant/rendering/decorators/base.py#L9

Added line #L9 was not covered by tests
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"

Check warning on line 103 in command_line_assistant/rendering/decorators/colors.py

View check run for this annotation

Codecov / codecov/patch

command_line_assistant/rendering/decorators/colors.py#L102-L103

Added lines #L102 - L103 were not covered by tests

return False

Check warning on line 105 in command_line_assistant/rendering/decorators/colors.py

View check run for this annotation

Codecov / codecov/patch

command_line_assistant/rendering/decorators/colors.py#L105

Added line #L105 was not covered by tests
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)}")

Check warning on line 25 in command_line_assistant/rendering/decorators/text.py

View check run for this annotation

Codecov / codecov/patch

command_line_assistant/rendering/decorators/text.py#L25

Added line #L25 was not covered by tests

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
Loading