Add color markup engine for prompt templates

This commit is contained in:
Jared Miller 2026-02-13 22:29:10 -05:00
parent 525b2fd812
commit 9729e853e1
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 244 additions and 0 deletions

116
src/mudlib/render/colors.py Normal file
View 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
View 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"