diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c5b1348 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [pipfile-experiment] + pull_request: + branches: [pipfile-experiment] + +jobs: + test: + runs-on: ubuntu-latest + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PIP_ROOT_USER_ACTION: "ignore" + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies and pytest + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + python -m pytest + + build: + runs-on: ubuntu-latest + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PIP_ROOT_USER_ACTION: "ignore" + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + python -m build diff --git a/.gitignore b/.gitignore index 2f24a10..07737f4 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,6 @@ dmypy.json # Cython debug symbols cython_debug/ + +# Project proposal file +Project_3_proposal.docx \ No newline at end of file diff --git a/DEMO_COMMANDS.md b/DEMO_COMMANDS.md new file mode 100644 index 0000000..77d64bd --- /dev/null +++ b/DEMO_COMMANDS.md @@ -0,0 +1,77 @@ +# minecraft-textcraft Demo Commands + +## Quick Start +```bash +# Basic text rendering +python -m minecraft_textcraft "HELLO" + +# With color +python -m minecraft_textcraft "HELLO" --color GREEN + +# With command +python -m minecraft_textcraft 'GET \\sword NOW' + +# With animation +python -m minecraft_textcraft "HELLO" --effect type +``` + +## Text Rendering +```bash +python -m minecraft_textcraft "HELLO" +python -m minecraft_textcraft "HELLO" --color GREEN +python -m minecraft_textcraft "HELLO" --color RED +python -m minecraft_textcraft "HELLO" --color BLUE +python -m minecraft_textcraft "HELLO" --color YELLOW +``` + +## Commands +```bash +# List all commands +python -m minecraft_textcraft --list + +# List categories +python -m minecraft_textcraft --list-categories + +# Get command ASCII art +python -m minecraft_textcraft --get sword +python -m minecraft_textcraft --get earth + +# Command with color +python -m minecraft_textcraft --get sword --color GREEN +``` + +## Text with Commands +```bash +# Text + command + color +python -m minecraft_textcraft 'GET \\sword NOW' --color RED +``` + +## Animations +```bash +# Typewriter effect +python -m minecraft_textcraft "HELLO" --effect type --color GREEN + +# Scroll effect +python -m minecraft_textcraft "HELLO" --effect scroll --color BLUE + +# Wave effect +python -m minecraft_textcraft "HELLO" --effect wave --color YELLOW +``` + +## Full Features Demo +```bash +# Text + command + animation + color + custom FPS +python -m minecraft_textcraft 'GET \\sword NOW' --effect type --color GREEN --fps 20 +python -m minecraft_textcraft 'HELLO \\sword' --effect scroll --color RED + +``` + +## Options +- **Colors**: `RED`, `GREEN`, `BLUE`, `YELLOW` +- **Effects**: `type`, `scroll`, `wave` +- **FPS**: `10-30` (default: 12) + +## Help +```bash +python -m minecraft_textcraft --help +``` diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..c03e589 --- /dev/null +++ b/Pipfile @@ -0,0 +1,14 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +minecraft-textcraft = {extras = ["dev"], file = ".", editable = true} + +[dev-packages] +pytest = "*" +build = "*" + +[requires] +python_version = "3" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..6840115 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,84 @@ +{ + "_meta": { + "hash": { + "sha256": "b34062ed12fc62058d550d1a7b5a9751dc715e4a1d210caea31ec1e1d46d22bf" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "build": { + "hashes": [ + "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", + "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4" + ], + "markers": "python_version >= '3.9'", + "version": "==1.3.0" + }, + "iniconfig": { + "hashes": [ + "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", + "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" + ], + "markers": "python_version >= '3.10'", + "version": "==2.3.0" + }, + "minecraft-textcraft": { + "editable": true, + "extras": [ + "dev" + ], + "file": "." + }, + "packaging": { + "hashes": [ + "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", + "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" + ], + "markers": "python_version >= '3.8'", + "version": "==25.0" + }, + "pluggy": { + "hashes": [ + "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", + "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" + ], + "markers": "python_version >= '3.9'", + "version": "==1.6.0" + }, + "pygments": { + "hashes": [ + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + ], + "markers": "python_version >= '3.8'", + "version": "==2.19.2" + }, + "pyproject-hooks": { + "hashes": [ + "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", + "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913" + ], + "markers": "python_version >= '3.7'", + "version": "==1.2.0" + }, + "pytest": { + "hashes": [ + "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", + "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79" + ], + "markers": "python_version >= '3.9'", + "version": "==8.4.2" + } + }, + "develop": {} +} diff --git a/README.md b/README.md index 6022e0e..b246700 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,31 @@ -# Python Package Exercise +[![CI](https://github.com/swe-students-fall2025/3-python-package-brio/actions/workflows/ci.yml/badge.svg)](https://github.com/swe-students-fall2025/3-python-package-brio/actions/workflows/ci.yml) +# minecraft-textcraft + +A Python package that converts text into blocky Minecraft-style ASCII art, with support for special inline commands like \sword, \heart, and \diamond. Commands are center-aligned with text for a professional, visually appealing output. + +# Quickstart + +To setup: +```bash +git clone git@github.com:swe-students-fall2025/3-python-package-brio.git +cd 3-python-package-brio +pipenv shell +pipenv install -e ".[dev]" +``` + +To build: +```bash +pipenv run python -m build +``` +Artifacts will be in `dist/`. + +To run: +```bash +pipenv run minecraft-textcraft +``` + +To run tests: +```bash +pipenv run pytest +``` -An exercise to create a Python package, build it, test it, distribute it, and use it. See [instructions](./instructions.md) for details. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..061c9b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=77.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "minecraft-textcraft" +version = "0.0.2" +description = "A Python package that converts text into blocky Minecraft-style ASCII art..." +readme = "README.md" +license = "GPL-3.0-or-later" +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Python :: 3", + "Intended Audience :: Education", + "Operating System :: OS Independent", +] +[project.optional-dependencies] +dev = ["pytest", "build"] +[project.scripts] +minecraft-textcraft = "minecraft_textcraft.__main__:main" diff --git a/src/minecraft_textcraft/__init__.py b/src/minecraft_textcraft/__init__.py new file mode 100644 index 0000000..4f0bc03 --- /dev/null +++ b/src/minecraft_textcraft/__init__.py @@ -0,0 +1,7 @@ +__version__ = "0.0.1" + +from .add import add +from .colorize_ascii import colorize_ascii, Color +from .commands import getCommand, getCommandByName, get_command, listCategories, listCommands +from .animate import animate_typewriter, animate_scroll, animate_wave, play_animation +from .render import render, renderTextOnly, renderCommandOnly, renderTextAndCommands diff --git a/src/minecraft_textcraft/__main__.py b/src/minecraft_textcraft/__main__.py new file mode 100644 index 0000000..71008fc --- /dev/null +++ b/src/minecraft_textcraft/__main__.py @@ -0,0 +1,170 @@ +""" +Main entry point for minecraft-textcraft package. +Provides command-line interface for all core functions. +""" +import argparse +import sys +import traceback +from .render import render +from .commands import listCommands, get_command, listCategories +from .colorize_ascii import colorize_ascii, Color + +try: + from .animate import animate_typewriter, animate_scroll, animate_wave, play_animation + _ANIM_AVAILABLE = True +except Exception: + _ANIM_AVAILABLE = False + + +def _get_color(color_string: str) -> Color: + """Get Color enum from string""" + return Color.from_string(color_string) + + +def _apply_color(text: str, color_string: str) -> str: + """Apply color to text""" + return colorize_ascii(text, _get_color(color_string)) + + +def _handle_error(message: str, exit_code: int = 1): + """Handle error with consistent formatting""" + print(f"Error: {message}", file=sys.stderr) + sys.exit(exit_code) + + +def main(): + """Main entry point for the command-line interface""" + parser = argparse.ArgumentParser( + description="Minecraft-style ASCII art text renderer. See DEMO_COMMANDS.md for examples.", + formatter_class=lambda prog: argparse.HelpFormatter(prog, max_help_position=35, width=100) + ) + + # Main arguments + parser.add_argument( + "text", + nargs="?", + default=None, + help="Text to render (use double backslash \\\\ for commands)" + ) + + # Command list operations + parser.add_argument( + "--list", + "--list-commands", + dest="list_commands", + action="store_true", + help="List all available commands" + ) + parser.add_argument( + "--list-categories", + action="store_true", + help="List all command categories" + ) + + # Get command + parser.add_argument( + "--get", + "--get-command", + dest="get_command", + metavar="NAME", + help="Get ASCII art for a specific command" + ) + parser.add_argument( + "--effect", + choices=["type", "scroll", "wave"], + help="Apply animation effect" + ) + parser.add_argument( + "--fps", + type=int, + default=12, + help="Animation frames per second (default: 12)" + ) + parser.add_argument( + "--color", + default=None, + help="Output color: RED, GREEN, YELLOW, BLUE (default: no color)" + ) + + args = parser.parse_args() + + if args.list_commands: + try: + commands = listCommands() + if commands: + print("Available commands:") + for cmd in sorted(commands): + print(f" - {cmd}") + else: + print("No commands available.") + except Exception as e: + _handle_error(f"listing commands: {e}") + return + + if args.list_categories: + try: + categories = listCategories() + if categories: + print("Available categories:") + for cat in sorted(categories): + commands = listCommands(cat) + print(f" - {cat}: {len(commands)} commands") + else: + print("No categories available.") + except Exception as e: + _handle_error(f"listing categories: {e}") + return + + if args.get_command: + try: + command_art = get_command(args.get_command) + if args.color: + print(_apply_color(command_art, args.color)) + else: + print(command_art) + except ValueError as e: + _handle_error(str(e)) + except Exception as e: + _handle_error(f"getting command: {e}") + return + + text = args.text or "HELLO" + if args.text is None: + print("Note: No text provided, using default 'HELLO'", file=sys.stderr) + print("Usage: minecraft-textcraft 'YOUR TEXT' (see DEMO_COMMANDS.md for examples)", file=sys.stderr) + print() + + try: + result = render(text) + + if args.effect and _ANIM_AVAILABLE: + color = _get_color(args.color) if args.color else Color.DEFAULT + if args.effect == "type": + frames = animate_typewriter(result, color=color, cps=12) + elif args.effect == "scroll": + frames = animate_scroll(result, color=color, cols_per_frame=1, loops=1) + elif args.effect == "wave": + frames = animate_wave(result, color=color, amplitude=2, period_cols=10, frames=36) + play_animation(frames, fps=args.fps) + else: + if args.color: + print(_apply_color(result, args.color)) + else: + print(result) + + if args.effect and not _ANIM_AVAILABLE: + print("Warning: Animation requested but animation support is not available.", file=sys.stderr) + + except ValueError as e: + _handle_error(str(e)) + except KeyboardInterrupt: + print("\nInterrupted by user.", file=sys.stderr) + sys.exit(130) + except Exception as e: + print(f"Error rendering text: {e}", file=sys.stderr) + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/minecraft_textcraft/add.py b/src/minecraft_textcraft/add.py new file mode 100644 index 0000000..e1829c3 --- /dev/null +++ b/src/minecraft_textcraft/add.py @@ -0,0 +1,2 @@ +def add(a: int, b: int) -> int: + return a + b diff --git a/src/minecraft_textcraft/animate.py b/src/minecraft_textcraft/animate.py new file mode 100644 index 0000000..58ffe27 --- /dev/null +++ b/src/minecraft_textcraft/animate.py @@ -0,0 +1,138 @@ +# src/minecraft_textcraft/animate.py +from typing import Generator, Iterable, Optional +from .colorize_ascii import colorize_ascii, Color + +def _normalize(ascii_art: str) -> list[str]: + lines = ascii_art.splitlines() + if not lines: + return [] + width = max(len(ln) for ln in lines) + return [ln.ljust(width) for ln in lines] + +def animate_typewriter( + ascii_art: str, + color: Optional[Color] = None, + cps: int = 10, # characters per second +) -> Generator[str, None, None]: + lines = _normalize(ascii_art) + if not lines: + yield "" + return + width = len(lines[0]) + # Calculate step based on fps (default 12 fps) + # Ensure step is at least 1 to avoid skipping + fps = 12 + step = max(1, int(round(cps / fps))) + shown = 0 + # Yield empty frame first for smooth start + yield colorize_ascii("", color) if color else "" + # Gradually reveal columns + while shown < width: + shown += step + if shown > width: + shown = width + frame = "\n".join(ln[:shown] for ln in lines) + yield colorize_ascii(frame, color) if color else frame + # Ensure final frame is complete + full = "\n".join(lines) + yield colorize_ascii(full, color) if color else full + +def animate_scroll( + ascii_art: str, + color: Optional[Color] = None, + cols_per_frame: int = 1, + loops: int = 1, +) -> Generator[str, None, None]: + lines = _normalize(ascii_art) + if not lines: + yield "" + return + cols_per_frame = max(1, int(cols_per_frame)) + width = len(lines[0]) + # Add padding on the left side so content scrolls in from the right + # Content is on the right, window starts at left (showing blank), moves right + # Padding should be proportional to content width, but not too small + padding_size = max(width, 10) # At least same as width or 10 columns for smooth scroll + left_pad = " " * padding_size + padded = [left_pad + ln for ln in lines] # Add padding on the left, content on the right + total_w = len(padded[0]) # Total width = padding + width + + def colorize_if_needed(s: str) -> str: + return colorize_ascii(s, color) if color else s + + def one_loop() -> Generator[str, None, None]: + # Scroll from left to right: window starts at left (showing blank), moves right + # Content scrolls in from the right side + step = max(1, cols_per_frame) + + # Window starts at leftmost position (start=0, showing only padding/blank) + # Window moves right (start increases) until full content is visible + min_start = 0 # Leftmost position (showing blank) + max_start = total_w - width # Rightmost position (showing full content) + + # Generate frames from left to right (start increases) + # Content scrolls in from the right + for start in range(min_start, max_start + 1, step): + # Extract a window of 'width' columns starting from 'start' + frame_cols = [row[start:start + width] for row in padded] + yield colorize_if_needed("\n".join(frame_cols)) + + # Ensure final frame shows the complete content + if (max_start - min_start) % step != 0: + final_frame = [row[max_start:max_start + width] for row in padded] + yield colorize_if_needed("\n".join(final_frame)) + + loops = max(1, int(loops)) + for _ in range(loops): + yield from one_loop() + + +def animate_wave( + ascii_art: str, + color: Optional[Color] = None, + amplitude: int = 2, + period_cols: int = 10, + frames: int = 24, +) -> Generator[str, None, None]: + + import math + base = _normalize(ascii_art) + if not base: + yield "" + return + h = len(base) + w = len(base[0]) + pad = amplitude + 1 + for t in range(max(1, frames)): + canvas_h = h + 2 * pad + canvas = [[" "] * w for _ in range(canvas_h)] + for r in range(h): + for c, ch in enumerate(base[r]): + phase = 2 * math.pi * (c / max(1, period_cols)) + 2 * math.pi * (t / max(1, frames)) + offset = int(round(amplitude * math.sin(phase))) + rr = r + pad + offset + if 0 <= rr < canvas_h: + canvas[rr][c] = ch + frame = "\n".join("".join(row) for row in canvas) + yield colorize_ascii(frame, color) if color else frame + +def play_animation(frames: Iterable[str], fps: int = 12) -> None: + import time + + if fps <= 0: + fps = 12 + delay = 1.0 / fps + + # Use cursor movement instead of full screen clear for smoother animation + def move_cursor_home(): + """Move cursor to top-left and clear from cursor to end of screen""" + print("\033[H\033[J", end="", flush=True) + + # Clear screen at start + move_cursor_home() + + for f in frames: + # Move cursor to home position and clear from there + move_cursor_home() + print(f, end="", flush=True) + time.sleep(delay) diff --git a/src/minecraft_textcraft/colorize_ascii.py b/src/minecraft_textcraft/colorize_ascii.py new file mode 100644 index 0000000..e982b26 --- /dev/null +++ b/src/minecraft_textcraft/colorize_ascii.py @@ -0,0 +1,24 @@ +from enum import Enum + +ESC = "\x1b" + +class Color(Enum): + RED = 31 + GREEN = 32 + YELLOW = 33 + BLUE = 34 + DEFAULT = 0 + + def __str__(self): + return f"{ESC}[{self.value}m" + + @classmethod + def from_string(cls, s: str) -> "Color": + try: + return cls[s.upper()] + except KeyError: + return cls.DEFAULT + + +def colorize_ascii(ascii: str, color: Color): + return f"{color}{ascii}{Color.DEFAULT}" diff --git a/src/minecraft_textcraft/commands.py b/src/minecraft_textcraft/commands.py new file mode 100644 index 0000000..5529f40 --- /dev/null +++ b/src/minecraft_textcraft/commands.py @@ -0,0 +1,63 @@ +from .sample_commands import SAMPLE_COMMANDS +COMMANDS = SAMPLE_COMMANDS + + +def listCategories() -> list[str]: + return list(COMMANDS.keys()) + + +def getCommand(category: str = "weapons") -> dict[str, str]: + if category not in COMMANDS: + available_categories = listCategories() + raise KeyError(f"Category '{category}' not found. Available categories: {available_categories}") + + return COMMANDS[category].copy() + + +def getCommandByName(category: str, command_name: str) -> str: + if category not in COMMANDS: + available_categories = listCategories() + raise KeyError(f"Category '{category}' not found. Available categories: {available_categories}") + + if command_name not in COMMANDS[category]: + available_commands = list(COMMANDS[category].keys()) + raise KeyError(f"Command '{command_name}' not found in category '{category}'. Available commands: {available_commands}") + + return COMMANDS[category][command_name] + + +def listCommands(category: str = None) -> list[str]: + if category is None: + all_commands = [] + for cat_commands in COMMANDS.values(): + all_commands.extend(cat_commands.keys()) + return all_commands + + if category not in COMMANDS: + available_categories = listCategories() + raise KeyError(f"Category '{category}' not found. Available categories: {available_categories}") + + return list(COMMANDS[category].keys()) + + +def get_command(name: str) -> str: + """ + Get ASCII art for a specific command by name. + Searches through all categories to find the command. + + Args:nName of the command (e.g., "sword", "heart", "earth") + + Returns ASCII art string for the command + + Raises ValueError if command is not found in any category + """ + # Search through all categories to find the command + for category, commands in COMMANDS.items(): + if name in commands: + return commands[name] + + all_commands = listCommands() + available = ", ".join(sorted(all_commands)) if all_commands else "none" + raise ValueError( + f"Command '{name}' not found. Available commands: {available}" + ) \ No newline at end of file diff --git a/src/minecraft_textcraft/font_data.py b/src/minecraft_textcraft/font_data.py new file mode 100644 index 0000000..24e2b16 --- /dev/null +++ b/src/minecraft_textcraft/font_data.py @@ -0,0 +1,724 @@ +""" +Font data for 16-block tall Minecraft-style ASCII characters. +Each character is represented as a list of strings, one per row. +Characters use block characters (█) and spaces for the blocky look. +""" + +# Height constant - all characters must be this height +CHAR_HEIGHT = 16 + +# Font data: dictionary mapping character to list of rows +FONT_DATA = { + "A": [ + " ████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + ], + "B": [ + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████ ", + " ", + " ", + ], + "C": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "D": [ + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████ ", + " ", + " ", + ], + "E": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████████ ", + " ", + " ", + ], + "F": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "G": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "H": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "I": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████████ ", + " ", + " ", + ], + "J": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ██ ", + " ██ ██ ", + " ████ ", + " ", + " ", + ], + "K": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████ ", + " ████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "L": [ + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████████ ", + " ", + " ", + ], + "M": [ + " ██ ██ ", + " ████ ████ ", + " ██ ██ ██ ██ ", + " ██ ████ ██ ", + " ██ ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "N": [ + " ██ ██ ", + " ████ ██ ", + " ██ ██ ██ ", + " ██ ██ ██ ", + " ██ ████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "O": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "P": [ + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "Q": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ██", + " ██ ██ ██", + " ██ ████", + " ██ ██ ", + " ███████ ██", + " ", + " ", + ], + "R": [ + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "S": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ██ ", + " ███████ ", + " ██ ", + " ██", + " ██", + " ██", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "T": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "U": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██████ ", + " ", + " ", + ], + "V": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "W": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ██", + " ██ ████ ██", + " ██ ██ ██ ██", + " ██ ██ ██ ██", + " ████ ████ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "X": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████ ", + " ██ ", + " ████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ", + " ", + ], + "Y": [ + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "Z": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████████ ", + " ", + " ", + ], + "0": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + ], + "1": [ + " ██ ", + " ████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██████████ ", + " ", + " ", + ], + "2": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ███████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ████████████ ", + " ", + " ", + ], + "3": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ██ ", + " ███████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "4": [ + " ██ ", + " ███ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "5": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ███████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "6": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ", + " ██ ", + " ███████████ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "7": [ + " ████████████ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ██ ", + " ", + " ", + ], + "8": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + "9": [ + " ███████ ", + " ██ ██ ", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██", + " ██ ██ ", + " ███████ ██", + " ██ ", + " ██ ", + " ██ ", + " ██ ██", + " ██ ██ ", + " ███████ ", + " ", + " ", + ], + " ": [ + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + " ", + ], +} + + +# Fixed width for all characters - must match the widest character +CHAR_WIDTH = 15 # Maximum width found in font data + + +def get_char_width(char: str) -> int: + """Get the width of a character in blocks (fixed width).""" + return CHAR_WIDTH + + +def get_char_height() -> int: + """Get the height of characters in blocks (constant).""" + return CHAR_HEIGHT + + +def get_char(char: str) -> list[str]: + """ + Get the font data for a character, padded to fixed width. + + Each character is padded to CHAR_WIDTH (15) to ensure proper alignment + when combining characters horizontally. + + Args: + char: Single character (A-Z, 0-9, or space) + + Returns: + List of strings representing the character rows, each padded to CHAR_WIDTH + + Raises: + KeyError: If character is not in the font data + """ + if char.upper() not in FONT_DATA: + raise KeyError( + f"Character '{char}' is not supported. Only A-Z, 0-9, and space are allowed." + ) + + char_rows = FONT_DATA[char.upper()] + # Pad each row to fixed width (CHAR_WIDTH) to ensure alignment + padded_rows = [] + for row in char_rows: + # Pad each row to exactly CHAR_WIDTH characters + padded_row = row.ljust(CHAR_WIDTH) + padded_rows.append(padded_row) + + return padded_rows diff --git a/src/minecraft_textcraft/render.py b/src/minecraft_textcraft/render.py new file mode 100644 index 0000000..3a4f6c5 --- /dev/null +++ b/src/minecraft_textcraft/render.py @@ -0,0 +1,382 @@ +""" +Render functions for converting text into Minecraft-style ASCII art. +Supports A-Z, 0-9 text and inline commands. +""" + +import re +from typing import List, Tuple +from .font_data import get_char, get_char_height, CHAR_HEIGHT, CHAR_WIDTH +from .commands import get_command, listCommands + + +def _validate_text(text: str) -> bool: + """ + Validate that text contains only A-Z, 0-9, and spaces. + + Args: + text: Text to validate + + Returns: + True if valid, False otherwise + """ + return bool(re.match(r"^[A-Za-z0-9\s]+$", text)) + + +def _parse_input(input_str: str) -> Tuple[List[str], List[str]]: + """ + Parse input string into text parts and command parts. + + Commands are specified with double backslash: \\command + + Args: + input_str: Input string to parse + + Returns: + Tuple of (text_parts, command_parts) + text_parts: List of text segments + command_parts: List of command names (without backslashes) + """ + # Split by double backslash pattern + parts = re.split(r"(\\\\\w+)", input_str) + + text_parts = [] + command_parts = [] + + for part in parts: + if not part: + continue + if part.startswith("\\\\"): + command_name = part[2:] + command_parts.append(command_name) + text_parts.append(None) + else: + text_parts.append(part) + command_parts.append(None) + + return text_parts, command_parts + + +def renderTextOnly(text: str) -> str: + """ + Render text only (A-Z, 0-9) into 16-block tall Minecraft-style ASCII art. + + Args: + text: Text to render (only A-Z, 0-9, spaces allowed) + + Returns: + Multi-line string representing the rendered text + + Raises: + ValueError: If text contains invalid characters + + Edge Cases Handled: + - Empty string → returns empty string + - Whitespace-only string → returns empty string (no visible output) + - Invalid characters → raises ValueError with details + """ + # Edge case: empty string + if not text: + return "" + + # Edge case: whitespace-only string + if not text.strip(): + return "" + + if not _validate_text(text): + invalid_chars = set(text) - set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 " + ) + raise ValueError( + f"Text contains invalid characters. Only A-Z, 0-9, and spaces are allowed. Invalid: {invalid_chars}" + ) + + # Convert to uppercase for rendering + text = text.upper() + + # Get character data for each character + char_data_list = [] + for char in text: + try: + char_data = get_char(char) + char_data_list.append(char_data) + except KeyError as e: + # Edge case: character not in font (shouldn't happen after validation, but defensive) + raise ValueError(f"Character '{char}' cannot be rendered: {e}") + + # Edge case: no valid characters (shouldn't happen, but defensive) + if not char_data_list: + return "" + + # All characters have the same height (16) + height = get_char_height() + result_lines = [] + + # Spacing between characters (6 spaces for better readability) + CHAR_SPACING = 6 + + for row in range(height): + line = "" + for i, char_data in enumerate(char_data_list): + # Edge case: ensure we don't go out of bounds + if row < len(char_data): + line += char_data[row] + else: + # If somehow row is out of bounds, add spaces + # Use CHAR_WIDTH since get_char() always returns padded rows + line += " " * CHAR_WIDTH + + # Add spacing between characters (except after the last character) + if i < len(char_data_list) - 1: + line += " " * CHAR_SPACING + + result_lines.append(line) + + return "\n".join(result_lines) + + +def renderCommandOnly(command_name: str) -> str: + """ + Render a single command only, padded to its natural width box. + + Each command is padded to its own maximum width so all rows align properly + within that command's box. Different commands can have different box sizes. + For example: sword might be 20 chars wide, earth might be 45 chars wide. + + Args: + command_name: Name of the command (e.g., "sword", "heart") + + Returns: + Multi-line string representing the rendered command, + with each row padded to the command's maximum width + """ + # Get command art using get_command (searches all categories automatically) + command_art = get_command(command_name) + + # Split command into lines + lines = command_art.split("\n") + if not lines: + return "" + + # Find the maximum width for THIS specific command + # Filter out empty/whitespace-only lines to find the actual content width + non_empty_lines = [line for line in lines if line.strip()] + + # Edge case: if command has no content, return empty + if not non_empty_lines: + return "" + + max_width = max(len(line) for line in non_empty_lines) + + # Pad each row to this command's maximum width (creates a box for this command) + padded_lines = [] + for line in lines: + # Pad each line to the command's natural width + padded_line = line.ljust(max_width) + padded_lines.append(padded_line) + + return "\n".join(padded_lines) + + +def renderTextAndCommands(text: str) -> str: + """ + Render text with inline commands mixed in. + Commands are specified with double backslash: \\command + + Args: + text: Text with inline commands (e.g., "HELLO \\heart WORLD") + + Returns: + Multi-line string representing the rendered text and commands + + Raises: + ValueError: If malformed commands are detected (e.g., \\\\ without command name) + """ + if not text: + return "" + + # Parse the input + parts = [] + current_text = "" + i = 0 + + while i < len(text): + # Check for double backslash + if i < len(text) - 1 and text[i] == "\\" and text[i + 1] == "\\": + # Found start of command + if current_text: + parts.append(("text", current_text)) + current_text = "" + + # Find the command name + i += 2 # Skip \\ + command_start = i + while i < len(text) and (text[i].isalnum() or text[i] == "_"): + i += 1 + command_name = text[command_start:i] + + # Edge case: malformed command (\\ followed by nothing or invalid characters) + if not command_name: + raise ValueError( + f"Malformed command detected at position {i-2}. " + "Commands must be in the format: \\\\commandname (e.g., \\\\sword)" + ) + + parts.append(("command", command_name)) + # Don't increment i here, it's already at the right position + else: + current_text += text[i] + i += 1 + + if current_text: + parts.append(("text", current_text)) + + # Edge case: no valid parts to render + if not parts: + return "" + + # Render each part + rendered_parts = [] + for part_type, part_content in parts: + if part_type == "text": + # Only render if there's actual non-whitespace text + if part_content and part_content.strip(): + rendered = renderTextOnly(part_content) + rendered_parts.append(rendered) + # Skip whitespace-only text parts + elif part_type == "command": + rendered = renderCommandOnly(part_content) + rendered_parts.append(rendered) + + # Edge case: all parts were empty/whitespace + if not rendered_parts: + return "" + + # Combine parts horizontally with proper alignment + return _combine_horizontally(rendered_parts) + + +def _combine_horizontally(parts: List[str]) -> str: + """ + Combine multiple rendered parts horizontally, center-aligning them vertically. + + Args: + parts: List of rendered ASCII art strings + + Returns: + Combined multi-line string + """ + if not parts: + return "" + + # Filter out empty parts + non_empty_parts = [p for p in parts if p and p.strip()] + if not non_empty_parts: + return "" + + # Split each part into lines + part_lines = [] + max_height = CHAR_HEIGHT + + for part in non_empty_parts: + # Split into lines, preserving all lines (including trailing empty ones) + lines = part.split("\n") + # Remove trailing empty lines but keep leading/middle empty lines + while lines and not lines[-1].strip(): + lines.pop() + if not lines: + continue + part_lines.append(lines) + max_height = max(max_height, len(lines)) + + # Edge case: all parts were empty after splitting + if not part_lines: + return "" + + # Pad all parts to max_height by adding empty lines at top/bottom for center alignment + padded_parts = [] + for lines in part_lines: + height = len(lines) + if height < max_height: + # Center align: add half padding above and below + pad_top = (max_height - height) // 2 + pad_bottom = max_height - height - pad_top + + # Get width of lines for padding - use max width to handle any variations + # Each part should already have consistent width, but we use max for safety + width = max(len(line) for line in lines) if lines else 0 + if width == 0: + continue # Skip parts with no width + padded = [" " * width] * pad_top + lines + [" " * width] * pad_bottom + padded_parts.append(padded) + else: + padded_parts.append(lines) + + # Spacing between commands (4 spaces) + COMMAND_SPACING = 4 + + # Combine horizontally + result_lines = [] + for row in range(max_height): + line = "" + for i, part in enumerate(padded_parts): + if row < len(part): + line += part[row] + else: + # Edge case: Add spaces if this part is shorter than expected + if part: + width = max(len(p) for p in part) if part else 0 + line += " " * width + + # Add spacing between commands (except after the last command) + if i < len(padded_parts) - 1: + line += " " * COMMAND_SPACING + + result_lines.append(line) + + return "\n".join(result_lines) + + +def render(input_str: str) -> str: + """ + Main render function that routes input to appropriate sub-function. + + Automatically detects if input contains: + - Text only → renderTextOnly() + - Commands only → renderCommandOnly() or handles multiple commands + - Text and commands → renderTextAndCommands() + + Args: + input_str: Input string with optional commands (\\command format) + + Returns: + Multi-line string representing the rendered output + """ + if not input_str: + return "" + + # Check if input contains double backslash (commands) + has_commands = "\\\\" in input_str + + # Check if input contains text (A-Z, 0-9) + text_only_part = re.sub(r"\\\\\w+", "", input_str).strip() + has_text = bool(text_only_part) and bool(re.search(r"[A-Za-z0-9]", text_only_part)) + + # Route to appropriate function + if has_text and has_commands: + # Mixed text and commands + return renderTextAndCommands(input_str) + elif has_commands: + # Commands only - extract all commands + commands = re.findall(r"\\\\(\w+)", input_str) + if len(commands) == 1: + return renderCommandOnly(commands[0]) + else: + # Multiple commands only - combine them + parts = [] + for cmd in commands: + rendered = renderCommandOnly(cmd) + parts.append(rendered) + return _combine_horizontally(parts) + else: + # Text only + return renderTextOnly(input_str) diff --git a/src/minecraft_textcraft/sample_commands.py b/src/minecraft_textcraft/sample_commands.py new file mode 100644 index 0000000..6769584 --- /dev/null +++ b/src/minecraft_textcraft/sample_commands.py @@ -0,0 +1,172 @@ +SAMPLE_COMMANDS = { + "weapons": { + "sword": """ () + )( + o======o + || + || + || + || + || + || + || + || + || + || + \\/ + + """, + }, + "nature": { + "earth": ( + " _-o#&&*''''?d:>b\\_\n" + " _o/\"`'' '',, dMF9MMMMMHo_\n" + " .o&#' `\"MbHMMMMMMMMMMMHo.\n" + ' .o"" \' vodM*$&&HMMMMMMMMMM?.\n' + " ,' $M&ood,~'`(&##MMMMMMH\\\n" + " / ,MMMMMMM#b?#bobMMMMHMMML\n" + " & ?MMMMMMMMMMMMMMMMM7MMM$R*Hk\n" + " ?$. :MMMMMMMMMMMMMMMMMMM/HMMM|`*L\n" + "| |MMMMMMMMMMMMMMMMMMMMbMH' T,\n" + "$H#: `*MMMMMMMMMMMMMMMMMMMMb#}' `?\n" + ']MMH# ""*""""*#MMMMMMMMMMMMM\' -\n' + "MMMMMb_ |MMMMMMMMMMMP' :\n" + "HMMMMMMMHo `MMMMMMMMMT .\n" + "?MMMMMMMMP 9MMMMMMMM} -\n" + "-?MMMMMMM |MMMMMMMMM?,d- '\n" + " :|MMMMMM- `MMMMMMMT .M|. :\n" + " .9MMM[ &MMMMM*' `' .\n" + ' :9MMk `MMM#" -\n' + " &M} ` .-\n" + " `&. .\n" + " `~, . ./\n" + " . _ .-\n" + " '`--._,dd###pp=\"\"'\n" + " \n" + " " + ), + }, + "health": { "heart": """ + ****** ****** + ********** ********** + ************* ************* +***************************** +***************************** +***************************** + *************************** + *********************** + ******************* + *************** + *********** + ******* + *** + * +""" + }, + "treasure": { "diamond": """ + . ' , + _________ + _ /_|_____|_\ _ + '. \ / .' + '.\ /.' + '.' + """ + }, + "defense": { "shield": """ + \_ _/ + ] --__________-- [ + | || | + \ || / + [ || ] + |______||______| + |------..------| + ] || [ + \ || / + [ || ] + \ || / + [ || ] + \__||__/ + -- + """ + }, + "achievement": { "star": """ + * '* + * + * + * + * + * + + . . + . ; + : - --+- - + ! . ! + | . . + |_ + + , | `. +--- --+-<#>-+- --- -- - + `._|_,' + T + | + ! + : . : + . * + """ + + }, + "rangedweapon": { "bow": """ + 4$$-. + 4 ". + 4 ^. + 4 $ + 4 'b + 4 "b. + 4 $ + 4 $r + 4 $F +-$b========4========$b====*P=- + 4 *$$F + 4 $$" + 4 .$F + 4 dP + 4 F + 4 @ + 4 .^ + J..^ + 4$$ + """ + }, + "miningtool": { "pickaxe": """ +⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⢀⣀⡿⠿⠿⠿⠿⠿⠿⢿⣀⣀⣀⣀⣀⡀⠀⠀ +⠀⠀⠀⠀⠀⠀⠸⠿⣇⣀⣀⣀⣀⣀⣀⣸⠿⢿⣿⣿⣿⡇⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠻⠿⠿⠿⠿⠿⣿⣿⣀⡸⠿⢿⣿⡇⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⣤⣿⣿⣿⣧⣤⡼⠿⢧⣤⡀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⣤⣿⣿⣿⣿⠛⢻⣿⡇⠀⢸⣿⡇ +⠀⠀⠀⠀⠀⠀⠀⠀⣤⣤⣿⣿⣿⣿⠛⠛⠀⢸⣿⡇⠀⢸⣿⡇ +⠀⠀⠀⠀⠀⠀⢠⣤⣿⣿⣿⣿⠛⠛⠀⠀⠀⢸⣿⡇⠀⢸⣿⡇ +⠀⠀⠀⠀⢰⣶⣾⣿⣿⣿⠛⠛⠀⠀⠀⠀⠀⠈⠛⢳⣶⡞⠛⠁ +⠀⠀⢰⣶⣾⣿⣿⣿⡏⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠁⠀⠀ +⢰⣶⡎⠉⢹⣿⡏⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⢸⣿⣷⣶⡎⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠉⠉⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +""" + }, + "magicitem": { "potion": """ + ██████ + ▒▒▒▒▒▒▒▒▒▒ + ▒▒██████▒▒ + ▒▒ ▒▒ + ▒▒ ▒▒ + ▒▒ ▒▒ + ▒▒ ▒▒ +▒▒ ▒▒ +▒▒ ▒▒ +▒▒ ▒▒ ▒▒ +▒▒ ▒▒ ▒▒ +▒▒ ▒▒ ▒▒▒▒ +▒▒ ▒▒ ▒▒▒▒ + ▒▒▒▒▒▒▒▒▒▒ +""" + }, +} \ No newline at end of file diff --git a/test_render_standalone.py b/test_render_standalone.py new file mode 100644 index 0000000..eaee8d0 --- /dev/null +++ b/test_render_standalone.py @@ -0,0 +1,327 @@ +""" +Standalone test script that doesn't require pipenv. +Run with: python test_render_standalone.py +Make sure you're in the 3-python-package-brio directory. +""" + +import sys +import os + +# Add the src directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) + +from minecraft_textcraft import ( + render, + renderTextOnly, + renderCommandOnly, + renderTextAndCommands, +) + + +def print_test_header(test_num, description): + """Print a formatted test header""" + print("=" * 70) + print(f"TEST {test_num}: {description}") + print("=" * 70) + print() + + +def print_input_output(input_text, output_text, input_type="Text"): + """Print what we're testing and the result""" + print(f"📥 {input_type}: {repr(input_text)}") + print() + print("📤 Output:") + print("-" * 70) + print(output_text) + print("-" * 70) + print() + + +def test_render_text_only(): + """Test rendering text only""" + print_test_header(1, "Render Text Only") + input_text = "HELLO" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Text") + print() + + +def test_render_numbers(): + """Test rendering numbers""" + print_test_header(2, "Render Numbers") + input_text = "12345" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Text") + print() + + +def test_render_command_only_sword(): + """Test rendering sword command""" + print_test_header(3, "Render Command Only - Sword") + command_name = "sword" + result = renderCommandOnly(command_name) + print_input_output(command_name, result, "Command") + print() + + +def test_render_command_only_earth(): + """Test rendering earth command""" + print_test_header(4, "Render Command Only - Earth") + command_name = "earth" + result = renderCommandOnly(command_name) + print_input_output(command_name, result, "Command") + print() + + +def test_render_mixed_text_and_sword(): + """Test rendering text with sword command""" + print_test_header(5, "Render Mixed Text and Commands - Sword") + input_text = "GET \\\\sword NOW" + result = renderTextAndCommands(input_text) + print_input_output(input_text, result, "Mixed Text") + print() + + +def test_render_mixed_text_and_earth(): + """Test rendering text with earth command""" + print_test_header(6, "Render Mixed Text and Commands - Earth") + input_text = "HELLO \\\\earth WORLD" + result = renderTextAndCommands(input_text) + print_input_output(input_text, result, "Mixed Text") + print() + + +def test_render_main_text_only(): + """Test the main render function with text only""" + print_test_header(7, "Main Render Function - Text Only") + input_text = "MINECRAFT" + result = render(input_text) + print_input_output(input_text, result, "Text") + print() + + +def test_render_main_command_only(): + """Test the main render function with command only""" + print_test_header(8, "Main Render Function - Command Only (Sword)") + input_text = "\\\\sword" + result = render(input_text) + print_input_output(input_text, result, "Command") + print() + + +def test_render_main_mixed(): + """Test the main render function with mixed input""" + print_test_header(9, "Main Render Function - Mixed (Text + Sword)") + input_text = "GET \\\\sword NOW" + result = render(input_text) + print_input_output(input_text, result, "Mixed") + print() + + +def test_render_main_multiple_commands(): + """Test the main render function with multiple commands""" + print_test_header(10, "Main Render Function - Multiple Commands") + input_text = "\\\\sword \\\\earth \\\\sword" + result = render(input_text) + print_input_output(input_text, result, "Multiple Commands") + print() + + +def test_edge_case_empty_string(): + """Test edge case: empty string""" + print_test_header(11, "Edge Case - Empty String") + input_text = "" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Empty String") + print() + + +def test_edge_case_whitespace_only(): + """Test edge case: whitespace only""" + print_test_header(12, "Edge Case - Whitespace Only") + input_text = " " + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Whitespace Only") + print() + + +def test_edge_case_invalid_characters(): + """Test edge case: invalid characters (should raise error)""" + print_test_header(13, "Edge Case - Invalid Characters") + input_text = "HELLO!" + try: + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Invalid Characters") + print("⚠️ WARNING: Should have raised ValueError!") + except ValueError as e: + print(f"📥 Input: {repr(input_text)}") + print(f"✅ Correctly raised ValueError: {e}") + print() + + +def test_edge_case_invalid_command(): + """Test edge case: invalid command (should raise error)""" + print_test_header(14, "Edge Case - Invalid Command") + command_name = "invalid_command" + try: + result = renderCommandOnly(command_name) + print_input_output(command_name, result, "Invalid Command") + print("⚠️ WARNING: Should have raised ValueError!") + except ValueError as e: + print(f"📥 Command: {repr(command_name)}") + print(f"✅ Correctly raised ValueError: {e}") + print() + + +def test_edge_case_malformed_command(): + """Test edge case: malformed command (should raise error)""" + print_test_header(15, "Edge Case - Malformed Command") + input_text = "HELLO \\\\ WORLD" + try: + result = renderTextAndCommands(input_text) + print_input_output(input_text, result, "Malformed Command") + print("⚠️ WARNING: Should have raised ValueError!") + except ValueError as e: + print(f"📥 Input: {repr(input_text)}") + print(f"✅ Correctly raised ValueError: {e}") + print() + + +def test_text_with_spaces(): + """Test text with spaces""" + print_test_header(16, "Text with Spaces") + input_text = "HELLO WORLD" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Text with Spaces") + print() + + +def test_mixed_uppercase_lowercase(): + """Test mixed case (should be converted to uppercase)""" + print_test_header(17, "Mixed Case Text (Auto-uppercase)") + input_text = "Hello World 123" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Mixed Case") + print() + + +def test_multiple_commands_only(): + """Test multiple commands without text""" + print_test_header(18, "Multiple Commands Only") + input_text = "\\\\sword \\\\earth" + result = render(input_text) + print_input_output(input_text, result, "Multiple Commands") + print() + + +def test_commands_at_start(): + """Test command at the start of text""" + print_test_header(19, "Command at Start") + input_text = "\\\\sword HELLO" + result = render(input_text) + print_input_output(input_text, result, "Command at Start") + print() + + +def test_commands_at_end(): + """Test command at the end of text""" + print_test_header(20, "Command at End") + input_text = "HELLO \\\\sword" + result = render(input_text) + print_input_output(input_text, result, "Command at End") + print() + + +def test_multiple_commands_with_text(): + """Test multiple commands interspersed with text""" + print_test_header(21, "Multiple Commands with Text") + input_text = "GET \\\\sword AND \\\\earth NOW" + result = render(input_text) + print_input_output(input_text, result, "Multiple Commands with Text") + print() + + +def test_consecutive_commands(): + """Test consecutive commands""" + print_test_header(22, "Consecutive Commands") + input_text = "\\\\sword\\\\earth\\\\sword" + result = render(input_text) + print_input_output(input_text, result, "Consecutive Commands") + print() + + +def test_single_character(): + """Test single character rendering""" + print_test_header(23, "Single Character") + input_text = "A" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Single Character") + print() + + +def test_single_digit(): + """Test single digit rendering""" + print_test_header(24, "Single Digit") + input_text = "5" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Single Digit") + print() + + +def test_long_text(): + """Test long text string""" + print_test_header(25, "Long Text String") + input_text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Long Text") + print() + + +def test_text_with_only_spaces(): + """Test text with only spaces between characters""" + print_test_header(26, "Text with Only Spaces") + input_text = "A B C D" + result = renderTextOnly(input_text) + print_input_output(input_text, result, "Text with Spaces") + print() + + +if __name__ == "__main__": + try: + test_render_text_only() + test_render_numbers() + test_render_command_only_sword() + test_render_command_only_earth() + test_render_mixed_text_and_sword() + test_render_mixed_text_and_earth() + test_render_main_text_only() + test_render_main_command_only() + test_render_main_mixed() + test_render_main_multiple_commands() + test_edge_case_empty_string() + test_edge_case_whitespace_only() + test_edge_case_invalid_characters() + test_edge_case_invalid_command() + test_edge_case_malformed_command() + test_text_with_spaces() + test_mixed_uppercase_lowercase() + test_multiple_commands_only() + test_commands_at_start() + test_commands_at_end() + test_multiple_commands_with_text() + test_consecutive_commands() + test_single_character() + test_single_digit() + test_long_text() + test_text_with_only_spaces() + + print("=" * 70) + print("✅ All tests completed successfully!") + print("=" * 70) + except Exception as e: + print("=" * 70) + print(f"❌ ERROR: {e}") + print("=" * 70) + import traceback + + traceback.print_exc() diff --git a/tests/test_add.py b/tests/test_add.py new file mode 100644 index 0000000..024ab10 --- /dev/null +++ b/tests/test_add.py @@ -0,0 +1,4 @@ +from minecraft_textcraft import add + +def test_add(): + assert add(1, 1) == 2 diff --git a/tests/test_animate.py b/tests/test_animate.py new file mode 100644 index 0000000..20f18db --- /dev/null +++ b/tests/test_animate.py @@ -0,0 +1,28 @@ +# tests/test_animate.py +import itertools +from minecraft_textcraft.animate import animate_typewriter, animate_scroll, animate_wave +from minecraft_textcraft.colorize_ascii import Color + +ASCII = "AB\nCD" + +def take(n, it): + return list(itertools.islice(it, n)) + +def test_typewriter_progresses_and_finishes(): + frames = list(animate_typewriter(ASCII, color=Color.GREEN, cps=24)) + assert len(frames) >= 2 + assert frames[0] + assert frames[-1].count("\n") in (1, 2, 3) + assert "A" in frames[-1] and "D" in frames[-1] + +def test_scroll_changes_over_time(): + frames = take(6, animate_scroll(ASCII, cols_per_frame=2, loops=1)) + assert len(frames) >= 1 # Should generate at least some frames + # Check that scroll animation produces different frames (at least blank and content) + unique_frames = set(frames) + assert len(unique_frames) > 1, f"Scroll should produce different frames, got: {unique_frames}" + +def test_wave_produces_fixed_frames(): + frames = list(animate_wave(ASCII, amplitude=1, period_cols=4, frames=8)) + assert len(frames) == 8 + assert len(set(frames)) > 1 diff --git a/tests/test_colorize_ascii.py b/tests/test_colorize_ascii.py new file mode 100644 index 0000000..427742d --- /dev/null +++ b/tests/test_colorize_ascii.py @@ -0,0 +1,14 @@ +import pytest +from minecraft_textcraft import colorize_ascii, Color + +ESC = "\x1b" + + +@pytest.mark.parametrize("color", list(Color)) +def test_colorize_outputs_correct_ansi_sequence(color): + s = colorize_ascii("Hello", color) + expected_prefix = f"{ESC}[{color.value}m" + expected_suffix = f"{ESC}[0m" + assert s.startswith(expected_prefix) + assert s.endswith(expected_suffix) + assert str(color.value) in s diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..541269c --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,51 @@ +import pytest +from minecraft_textcraft import getCommand, getCommandByName, listCategories, listCommands + + +def test_getCommand(): + weapons = getCommand("weapons") + assert "sword" in weapons + # assert "bow" in weapons + # assert "shield" in weapons + + +def test_getCommand_default(): + weapons = getCommand() + assert "sword" in weapons + + +def test_getCommandByName(): + sword_art = getCommandByName("weapons", "sword") + assert isinstance(sword_art, str) + assert len(sword_art) > 0 + + +def test_getCommandByName_invalid_category(): + with pytest.raises(KeyError): + getCommandByName("invalid", "sword") + + +def test_getCommandByName_invalid_command(): + with pytest.raises(KeyError): + getCommandByName("weapons", "invalid") + + +def test_listCategories(): + categories = listCategories() + assert "weapons" in categories + # assert "items" in categories + # assert "tools" in categories + assert "nature" in categories + + +def test_listCommands(): + all_commands = listCommands() + assert "sword" in all_commands + # assert "heart" in all_commands + + +def test_listCommands_by_category(): + weapons = listCommands("weapons") + assert "sword" in weapons + # assert "bow" in weapons + # assert "shield" in weapons diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..ae01f41 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,223 @@ +""" +Tests for the command-line interface (__main__.py). +Tests command-line argument parsing, output formatting, color, commands, render, and animation. +""" +import pytest +import sys +from unittest.mock import patch, MagicMock +from io import StringIO +from minecraft_textcraft.__main__ import main + + +def test_main_help(): + """Test --help option""" + with patch('sys.argv', ['minecraft-textcraft', '--help']): + with patch('sys.stdout', new=StringIO()) as mock_stdout: + with pytest.raises(SystemExit) as exc_info: + main() + # argparse help exits with code 0 + assert exc_info.value.code == 0 + output = mock_stdout.getvalue() + assert 'minecraft-textcraft' in output or 'Minecraft-style' in output + + +def test_main_render_text_with_color(): + """Test rendering text with color via command line""" + with patch('sys.argv', ['minecraft-textcraft', 'HELLO', '--color', 'GREEN']): + with patch('sys.stdout', new=StringIO()) as mock_stdout: + main() + output = mock_stdout.getvalue() + # Should output rendered colored text + assert len(output) > 0 + assert '\n' in output + # Should contain color codes + assert '\033[' in output or output # Color codes or successful output + + +def test_main_render_text_with_command_and_color(): + """Test rendering text with command and color - covers render, command, and color""" + with patch('sys.argv', ['minecraft-textcraft', 'GET \\\\sword NOW', '--color', 'RED']): + with patch('sys.stdout', new=StringIO()) as mock_stdout: + main() + output = mock_stdout.getvalue() + # Should output rendered text with command and color + assert len(output) > 0 + assert '\n' in output + + +def test_main_list_commands(): + """Test --list option""" + with patch('sys.argv', ['minecraft-textcraft', '--list']): + with patch('sys.stdout', new=StringIO()) as mock_stdout: + main() + output = mock_stdout.getvalue() + assert 'Available commands' in output + assert 'sword' in output or 'earth' in output + + +def test_main_list_categories(): + """Test --list-categories option""" + with patch('sys.argv', ['minecraft-textcraft', '--list-categories']): + with patch('sys.stdout', new=StringIO()) as mock_stdout: + main() + output = mock_stdout.getvalue() + assert 'Available categories' in output + assert 'weapons' in output or 'nature' in output + + +def test_main_get_command_with_color(): + """Test --get option with color - covers command handling and color""" + with patch('sys.argv', ['minecraft-textcraft', '--get', 'sword', '--color', 'YELLOW']): + with patch('sys.stdout', new=StringIO()) as mock_stdout: + main() + output = mock_stdout.getvalue() + # Should output command ASCII art with color + assert len(output) > 0 + assert '\n' in output + + +def test_main_get_command_invalid(): + """Test --get with invalid command""" + with patch('sys.argv', ['minecraft-textcraft', '--get', 'nonexistent']): + with patch('sys.stderr', new=StringIO()) as mock_stderr: + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + error_output = mock_stderr.getvalue() + assert 'Error' in error_output or 'not found' in error_output.lower() + + +def test_main_default_behavior(): + """Test default behavior (no arguments)""" + with patch('sys.argv', ['minecraft-textcraft']): + with patch('sys.stdout', new=StringIO()) as mock_stdout: + with patch('sys.stderr', new=StringIO()) as mock_stderr: + main() + output = mock_stdout.getvalue() + stderr_output = mock_stderr.getvalue() + # Should render default text or show usage + assert len(output) > 0 or 'Usage' in stderr_output or 'Note' in stderr_output + + +def test_main_invalid_text(): + """Test rendering invalid text""" + with patch('sys.argv', ['minecraft-textcraft', 'HELLO!']): + with patch('sys.stderr', new=StringIO()) as mock_stderr: + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + error_output = mock_stderr.getvalue() + assert 'Error' in error_output or 'invalid' in error_output.lower() + + +def test_main_animation_typewriter_verification(): + """Test --effect type animation - verify animation frames are generated correctly""" + with patch('sys.argv', ['minecraft-textcraft', 'HELLO', '--effect', 'type']): + captured_frames = [] + + def capture_frames(frames_iter, fps=12): + # Capture frames to verify animation works + frame_list = list(frames_iter) + captured_frames.extend(frame_list[:5]) # Capture first 5 frames + + with patch('minecraft_textcraft.__main__.play_animation', side_effect=capture_frames): + with patch('sys.stdout', new=StringIO()): + main() + + # Verify animation was called and frames were generated + assert len(captured_frames) > 0, "Animation should generate frames" + + +def test_main_animation_scroll_verification(): + """Test --effect scroll animation - verify animation works with commands""" + with patch('sys.argv', ['minecraft-textcraft', 'HELLO \\\\sword', '--effect', 'scroll']): + with patch('minecraft_textcraft.__main__.play_animation') as mock_play: + with patch('sys.stdout', new=StringIO()): + main() + # Should call play_animation + assert mock_play.called + # Verify frames were passed + call_args = mock_play.call_args + assert call_args is not None + + +def test_main_animation_wave_verification(): + """Test --effect wave animation - verify animation works""" + with patch('sys.argv', ['minecraft-textcraft', 'HELLO', '--effect', 'wave']): + with patch('minecraft_textcraft.__main__.play_animation') as mock_play: + with patch('sys.stdout', new=StringIO()): + main() + # Should call play_animation + assert mock_play.called + + +def test_main_animation_with_color_and_command(): + """Test animation with color and command - comprehensive test covering all features""" + with patch('sys.argv', ['minecraft-textcraft', 'GET \\\\sword NOW', '--effect', 'type', '--color', 'GREEN']): + with patch('minecraft_textcraft.__main__.play_animation') as mock_play: + with patch('sys.stdout', new=StringIO()): + main() + # Should call play_animation with color and command + assert mock_play.called + call_args = mock_play.call_args + assert call_args is not None + + +def test_main_animation_with_custom_fps(): + """Test animation with custom --fps option""" + with patch('sys.argv', ['minecraft-textcraft', 'HELLO', '--effect', 'type', '--fps', '24']): + with patch('minecraft_textcraft.__main__.play_animation') as mock_play: + with patch('sys.stdout', new=StringIO()): + main() + # Should call play_animation with custom fps + assert mock_play.called + call_args = mock_play.call_args + assert call_args.kwargs.get('fps') == 24 + + +def test_main_animation_all_effects_with_colors(): + """Test all animation effects work with different colors""" + effects = ['type', 'scroll', 'wave'] + colors = ['RED', 'GREEN', 'BLUE', 'YELLOW'] + + for effect in effects: + for color in colors: + with patch('sys.argv', ['minecraft-textcraft', 'TEST', '--effect', effect, '--color', color]): + with patch('minecraft_textcraft.__main__.play_animation') as mock_play: + with patch('sys.stdout', new=StringIO()): + main() + # Each combination should work + assert mock_play.called, f"Animation {effect} with color {color} should work" + + +def test_main_animation_invalid_effect(): + """Test that invalid effect is handled by argparse""" + with patch('sys.argv', ['minecraft-textcraft', 'HELLO', '--effect', 'invalid']): + with pytest.raises(SystemExit): + main() + + +def test_main_animation_verification_with_frame_check(): + """Test animation actually generates progressive frames (typewriter effect)""" + from minecraft_textcraft.animate import animate_typewriter + from minecraft_textcraft.render import render + + # Render text + rendered = render("HELLO") + + # Generate animation frames + frames = list(animate_typewriter(rendered, cps=10)) + + # Verify animation frames are progressive + assert len(frames) > 1, "Animation should generate multiple frames" + + # First frame should be shorter or empty + first_length = len(frames[0]) + last_length = len(frames[-1]) + + # Last frame should be complete (longer or equal to first) + assert last_length >= first_length, "Animation should progress from shorter to longer" + + # Verify frames are different (animation is happening) + unique_frames = set(frames) + assert len(unique_frames) > 1, "Animation frames should be different"