diff --git a/src/mudlib/render/colors.py b/src/mudlib/render/colors.py new file mode 100644 index 0000000..d28d527 --- /dev/null +++ b/src/mudlib/render/colors.py @@ -0,0 +1,116 @@ +"""Color markup engine for prompt templates.""" + +import re + +from mudlib.render import ansi + +# Tag to ANSI code mapping for 16-color mode +COLOR_TAGS_16 = { + "red": ansi.RED, + "green": ansi.GREEN, + "yellow": ansi.YELLOW, + "blue": ansi.BLUE, + "magenta": ansi.MAGENTA, + "cyan": ansi.CYAN, + "white": ansi.WHITE, + "black": ansi.BLACK, + "RED": "\033[91m", + "GREEN": "\033[92m", + "YELLOW": "\033[93m", + "BLUE": "\033[94m", + "MAGENTA": "\033[95m", + "CYAN": "\033[96m", + "WHITE": "\033[97m", + "BLACK": "\033[90m", + "bold": ansi.BOLD, + "dim": "\033[2m", + "/": ansi.RESET, + "reset": ansi.RESET, +} + +# Tag to ANSI code mapping for 256-color mode +COLOR_TAGS_256 = { + "red": ansi.fg_256(196), + "green": ansi.fg_256(46), + "yellow": ansi.fg_256(226), + "blue": ansi.fg_256(33), + "magenta": ansi.fg_256(201), + "cyan": ansi.fg_256(51), + "white": ansi.fg_256(231), + "black": ansi.fg_256(16), + "RED": ansi.fg_256(9), + "GREEN": ansi.fg_256(10), + "YELLOW": ansi.fg_256(11), + "BLUE": ansi.fg_256(12), + "MAGENTA": ansi.fg_256(13), + "CYAN": ansi.fg_256(14), + "WHITE": ansi.fg_256(15), + "BLACK": ansi.fg_256(8), + "bold": ansi.BOLD, + "dim": "\033[2m", + "/": ansi.RESET, + "reset": ansi.RESET, +} + +# Tag to ANSI code mapping for truecolor mode +COLOR_TAGS_TRUECOLOR = { + "red": ansi.fg_rgb(244, 67, 54), + "green": ansi.fg_rgb(76, 175, 80), + "yellow": ansi.fg_rgb(255, 235, 59), + "blue": ansi.fg_rgb(33, 150, 243), + "magenta": ansi.fg_rgb(156, 39, 176), + "cyan": ansi.fg_rgb(0, 188, 212), + "white": ansi.fg_rgb(255, 255, 255), + "black": ansi.fg_rgb(0, 0, 0), + "RED": ansi.fg_rgb(255, 82, 82), + "GREEN": ansi.fg_rgb(105, 240, 174), + "YELLOW": ansi.fg_rgb(255, 255, 130), + "BLUE": ansi.fg_rgb(68, 138, 255), + "MAGENTA": ansi.fg_rgb(224, 64, 251), + "CYAN": ansi.fg_rgb(0, 229, 255), + "WHITE": ansi.fg_rgb(255, 255, 255), + "BLACK": ansi.fg_rgb(96, 96, 96), + "bold": ansi.BOLD, + "dim": "\033[2m", + "/": ansi.RESET, + "reset": ansi.RESET, +} + + +def colorize(text: str, color_depth: str | None) -> str: + """Convert color markup tags to ANSI codes based on client color depth. + + Processes markup tags like {red}, {bold}, {/} in template strings and + converts them to ANSI escape codes. Unknown tags that don't match known + color tags are preserved (assumed to be template variables like {hp}). + + Args: + text: String with color markup tags + color_depth: Color mode - "16", "256", "truecolor", or None/empty to strip + + Returns: + Text with markup tags replaced by ANSI codes (or stripped if no color support) + """ + # If no color support, strip all known color tags + if not color_depth: + # Build pattern of all known tag names + known_tags = set(COLOR_TAGS_16.keys()) + pattern = r"\{(" + "|".join(re.escape(tag) for tag in known_tags) + r")\}" + return re.sub(pattern, "", text) + + # Select tag mapping based on color depth + if color_depth == "truecolor": + tag_map = COLOR_TAGS_TRUECOLOR + elif color_depth == "256": + tag_map = COLOR_TAGS_256 + else: # default to 16-color + tag_map = COLOR_TAGS_16 + + # Replace known color tags with ANSI codes + def replace_tag(match): + return tag_map[match.group(1)] + + # Pattern matches {tagname} where tagname is in our known tags + known_tags = "|".join(re.escape(tag) for tag in tag_map) + pattern = r"\{(" + known_tags + r")\}" + return re.sub(pattern, replace_tag, text) diff --git a/tests/test_colors.py b/tests/test_colors.py new file mode 100644 index 0000000..0addaa2 --- /dev/null +++ b/tests/test_colors.py @@ -0,0 +1,128 @@ +"""Tests for color markup engine.""" + +from mudlib.render.colors import colorize + + +class TestColorize: + """Test the colorize markup processor.""" + + def test_basic_color_16(self): + """Test basic color tag in 16-color mode.""" + result = colorize("{red}hello{/}", "16") + assert result == "\033[31mhello\033[0m" + + def test_bold_16(self): + """Test bold tag in 16-color mode.""" + result = colorize("{bold}test{/}", "16") + assert result == "\033[1mtest\033[0m" + + def test_multiple_colors_16(self): + """Test multiple color tags in sequence.""" + result = colorize("{green}hp{/} {red}sp{/}", "16") + assert result == "\033[32mhp\033[0m \033[31msp\033[0m" + + def test_no_color_support(self): + """Test stripping tags when color_depth is None.""" + result = colorize("{red}danger{/}", None) + assert result == "danger" + + def test_nested_tags(self): + """Test nested tags - bold and red together.""" + result = colorize("{bold}{red}critical{/}", "16") + assert result == "\033[1m\033[31mcritical\033[0m" + + def test_plain_text_passthrough(self): + """Test plain text without tags passes through unchanged.""" + result = colorize("plain text", "16") + assert result == "plain text" + + def test_unknown_tags_preserved(self): + """Test unknown tags are preserved for template substitution.""" + result = colorize("{fake}text{/}", "16") + assert result == "{fake}text\033[0m" + + def test_multiple_resets(self): + """Test multiple reset tags work correctly.""" + result = colorize("{red}one{/}{green}two{/}", "16") + assert result == "\033[31mone\033[0m\033[32mtwo\033[0m" + + def test_reset_always_ansi_reset(self): + """Test {/} always maps to ANSI reset code.""" + result = colorize("{/}", "16") + assert result == "\033[0m" + + def test_256_color_mode(self): + """Test color tag works in 256-color mode.""" + result = colorize("{green}hp{/}", "256") + # Should contain ANSI code (exact code may vary by implementation) + assert "\033[" in result + assert "hp" in result + assert "\033[0m" in result + + def test_template_variables_preserved(self): + """Test that non-color tags like {stamina_pct} are preserved.""" + result = colorize("{red}HP:{/} {hp_current}/{hp_max}", "16") + assert result == "\033[31mHP:\033[0m {hp_current}/{hp_max}" + + def test_all_basic_colors(self): + """Test all basic color tags work.""" + colors = ["red", "green", "yellow", "blue", "magenta", "cyan", "white", "black"] + for color in colors: + result = colorize(f"{{{color}}}text{{/}}", "16") + assert "\033[" in result + assert "text" in result + assert "\033[0m" in result + + def test_dim_style(self): + """Test dim style tag.""" + result = colorize("{dim}faded{/}", "16") + assert "\033[2m" in result + assert "faded" in result + + def test_no_closing_tag(self): + """Test unclosed tag is handled gracefully.""" + result = colorize("{red}no close", "16") + # Should process the opening tag even without close + assert "\033[31m" in result + assert "no close" in result + + def test_color_depth_empty_string(self): + """Test empty string color_depth strips tags like None.""" + result = colorize("{red}danger{/}", "") + assert result == "danger" + + def test_reset_alias(self): + """Test {reset} works the same as {/}.""" + result = colorize("{reset}", "16") + assert result == "\033[0m" + # Test in context + result2 = colorize("{red}text{reset}", "16") + assert result2 == "\033[31mtext\033[0m" + + def test_bright_red_16(self): + """Test bright red in 16-color mode.""" + result = colorize("{RED}bright{/}", "16") + assert result == "\033[91mbright\033[0m" + + def test_all_bright_colors_16(self): + """Test all bright color variants work.""" + bright_colors = [ + "RED", + "GREEN", + "YELLOW", + "BLUE", + "MAGENTA", + "CYAN", + "WHITE", + "BLACK", + ] + for color in bright_colors: + result = colorize(f"{{{color}}}text{{/}}", "16") + assert "\033[" in result + assert "text" in result + assert "\033[0m" in result + + def test_bright_colors_no_support(self): + """Test bright colors get stripped when no color support.""" + result = colorize("{RED}bright{/} {GREEN}text{/}", None) + assert result == "bright text"