Add color markup engine for prompt templates
This commit is contained in:
parent
525b2fd812
commit
9729e853e1
2 changed files with 244 additions and 0 deletions
116
src/mudlib/render/colors.py
Normal file
116
src/mudlib/render/colors.py
Normal file
|
|
@ -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)
|
||||||
128
tests/test_colors.py
Normal file
128
tests/test_colors.py
Normal file
|
|
@ -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"
|
||||||
Loading…
Reference in a new issue