Added xterm truecolor support and tests.
This commit is contained in:
parent
a1023ebc7e
commit
a552bf6fd4
3 changed files with 284 additions and 6 deletions
|
|
@ -69,6 +69,9 @@ from django.conf import settings
|
||||||
|
|
||||||
from evennia.utils import logger, utils
|
from evennia.utils import logger, utils
|
||||||
from evennia.utils.utils import to_str
|
from evennia.utils.utils import to_str
|
||||||
|
from evennia.utils.hex_colors import HexToTruecolor
|
||||||
|
|
||||||
|
hex_sub = HexToTruecolor.hex_sub
|
||||||
|
|
||||||
MXP_ENABLED = settings.MXP_ENABLED
|
MXP_ENABLED = settings.MXP_ENABLED
|
||||||
|
|
||||||
|
|
@ -431,7 +434,7 @@ class ANSIParser(object):
|
||||||
"""
|
"""
|
||||||
return self.unsafe_tokens.sub("", string)
|
return self.unsafe_tokens.sub("", string)
|
||||||
|
|
||||||
def parse_ansi(self, string, strip_ansi=False, xterm256=False, mxp=False):
|
def parse_ansi(self, string, strip_ansi=False, xterm256=False, mxp=False, truecolor=False):
|
||||||
"""
|
"""
|
||||||
Parses a string, subbing color codes according to the stored
|
Parses a string, subbing color codes according to the stored
|
||||||
mapping.
|
mapping.
|
||||||
|
|
@ -458,13 +461,18 @@ class ANSIParser(object):
|
||||||
|
|
||||||
# check cached parsings
|
# check cached parsings
|
||||||
global _PARSE_CACHE
|
global _PARSE_CACHE
|
||||||
cachekey = "%s-%s-%s-%s" % (string, strip_ansi, xterm256, mxp)
|
cachekey = f"{string}-{strip_ansi}-{xterm256}-{mxp}-{truecolor}"
|
||||||
|
|
||||||
if cachekey in _PARSE_CACHE:
|
if cachekey in _PARSE_CACHE:
|
||||||
return _PARSE_CACHE[cachekey]
|
return _PARSE_CACHE[cachekey]
|
||||||
|
|
||||||
# pre-convert bright colors to xterm256 color tags
|
# pre-convert bright colors to xterm256 color tags
|
||||||
string = self.brightbg_sub.sub(self.sub_brightbg, string)
|
string = self.brightbg_sub.sub(self.sub_brightbg, string)
|
||||||
|
|
||||||
|
def do_truecolor(part: re.Match, truecolor=truecolor):
|
||||||
|
hex2truecolor = HexToTruecolor()
|
||||||
|
return hex2truecolor.sub_truecolor(part, truecolor)
|
||||||
|
|
||||||
def do_xterm256_fg(part):
|
def do_xterm256_fg(part):
|
||||||
return self.sub_xterm256(part, xterm256, "fg")
|
return self.sub_xterm256(part, xterm256, "fg")
|
||||||
|
|
||||||
|
|
@ -483,7 +491,8 @@ class ANSIParser(object):
|
||||||
parsed_string = []
|
parsed_string = []
|
||||||
parts = self.ansi_escapes.split(in_string) + [" "]
|
parts = self.ansi_escapes.split(in_string) + [" "]
|
||||||
for part, sep in zip(parts[::2], parts[1::2]):
|
for part, sep in zip(parts[::2], parts[1::2]):
|
||||||
pstring = self.xterm256_fg_sub.sub(do_xterm256_fg, part)
|
pstring = hex_sub.sub(do_truecolor, part)
|
||||||
|
pstring = self.xterm256_fg_sub.sub(do_xterm256_fg, pstring)
|
||||||
pstring = self.xterm256_bg_sub.sub(do_xterm256_bg, pstring)
|
pstring = self.xterm256_bg_sub.sub(do_xterm256_bg, pstring)
|
||||||
pstring = self.xterm256_gfg_sub.sub(do_xterm256_gfg, pstring)
|
pstring = self.xterm256_gfg_sub.sub(do_xterm256_gfg, pstring)
|
||||||
pstring = self.xterm256_gbg_sub.sub(do_xterm256_gbg, pstring)
|
pstring = self.xterm256_gbg_sub.sub(do_xterm256_gbg, pstring)
|
||||||
|
|
@ -515,11 +524,12 @@ ANSI_PARSER = ANSIParser()
|
||||||
#
|
#
|
||||||
|
|
||||||
|
|
||||||
def parse_ansi(string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp=False):
|
def parse_ansi(string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp=False, truecolor=False):
|
||||||
"""
|
"""
|
||||||
Parses a string, subbing color codes as needed.
|
Parses a string, subbing color codes as needed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
truecolor:
|
||||||
string (str): The string to parse.
|
string (str): The string to parse.
|
||||||
strip_ansi (bool, optional): Strip all ANSI sequences.
|
strip_ansi (bool, optional): Strip all ANSI sequences.
|
||||||
parser (ansi.AnsiParser, optional): A parser instance to use.
|
parser (ansi.AnsiParser, optional): A parser instance to use.
|
||||||
|
|
@ -531,7 +541,7 @@ def parse_ansi(string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp
|
||||||
|
|
||||||
"""
|
"""
|
||||||
string = string or ""
|
string = string or ""
|
||||||
return parser.parse_ansi(string, strip_ansi=strip_ansi, xterm256=xterm256, mxp=mxp)
|
return parser.parse_ansi(string, strip_ansi=strip_ansi, xterm256=xterm256, mxp=mxp, truecolor=truecolor)
|
||||||
|
|
||||||
|
|
||||||
def strip_ansi(string, parser=ANSI_PARSER):
|
def strip_ansi(string, parser=ANSI_PARSER):
|
||||||
|
|
|
||||||
145
evennia/utils/hex_colors.py
Normal file
145
evennia/utils/hex_colors.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class HexToTruecolor:
|
||||||
|
"""
|
||||||
|
This houses a method for converting hex codes to xterm truecolor codes
|
||||||
|
or falls back to evennia xterm256 codes to be handled by sub_xterm256
|
||||||
|
|
||||||
|
Based on code from @InspectorCaracal
|
||||||
|
"""
|
||||||
|
|
||||||
|
_RE_FG = '\|#'
|
||||||
|
_RE_BG = '\|\[#'
|
||||||
|
_RE_FG_OR_BG = '\|\[?#'
|
||||||
|
_RE_HEX_LONG = '[0-9a-fA-F]{6}'
|
||||||
|
_RE_HEX_SHORT = '[0-9a-fA-F]{3}'
|
||||||
|
_RE_BYTE = '[0-2][0-9][0-9]'
|
||||||
|
_RE_3_BYTES = f'({_RE_BYTE})({_RE_BYTE})({_RE_BYTE})'
|
||||||
|
|
||||||
|
# Used in hex_sub
|
||||||
|
_RE_HEX_PATTERN = f'({_RE_FG_OR_BG})({_RE_HEX_LONG}|{_RE_HEX_SHORT})'
|
||||||
|
|
||||||
|
# Used for truecolor_sub
|
||||||
|
_RE_24_BIT_RGB_FG = f'{_RE_FG}{_RE_3_BYTES}'
|
||||||
|
_RE_24_BIT_RGB_BG = f'{_RE_BG}{_RE_3_BYTES}'
|
||||||
|
|
||||||
|
# Used for greyscale
|
||||||
|
_GREYS = "abcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
|
# Our matchers for use with ANSIParser and ANSIString
|
||||||
|
hex_sub = re.compile(rf'{_RE_HEX_PATTERN}', re.DOTALL)
|
||||||
|
|
||||||
|
def sub_truecolor(self, match: re.Match, truecolor=False) -> str:
|
||||||
|
"""
|
||||||
|
Converts a hex string to xterm truecolor code, greyscale, or
|
||||||
|
falls back to evennia xterm256 to be handled by sub_xterm256
|
||||||
|
|
||||||
|
Args:
|
||||||
|
match (re.match): first group is the leading indicator,
|
||||||
|
second is the tag
|
||||||
|
truecolor (bool): return xterm truecolor or fallback
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Newly formatted indicator and tag (str)
|
||||||
|
|
||||||
|
"""
|
||||||
|
indicator, tag = match.groups()
|
||||||
|
|
||||||
|
# Remove the # sign
|
||||||
|
indicator = indicator.replace('#', '')
|
||||||
|
|
||||||
|
r, g, b = self._hex_to_rgb_24_bit(tag)
|
||||||
|
|
||||||
|
# Is it greyscale?
|
||||||
|
if r == g and g == b:
|
||||||
|
return f"{indicator}=" + self._GREYS[self._grey_int(r)]
|
||||||
|
|
||||||
|
else:
|
||||||
|
if not truecolor:
|
||||||
|
# Fallback to xterm256 syntax
|
||||||
|
r, g, b = self._rgb_24_bit_to_256(r, g, b)
|
||||||
|
return f"{indicator}{r}{g}{b}"
|
||||||
|
|
||||||
|
else:
|
||||||
|
xtag = f"\033["
|
||||||
|
if '[' in indicator:
|
||||||
|
# Background Color
|
||||||
|
xtag += '4'
|
||||||
|
|
||||||
|
else:
|
||||||
|
xtag += '3'
|
||||||
|
|
||||||
|
xtag += f"8;2;{r};{g};{b}m"
|
||||||
|
return xtag
|
||||||
|
|
||||||
|
def _split_hex_to_bytes(self, tag: str) -> tuple[str, str, str]:
|
||||||
|
"""
|
||||||
|
Splits hex string into separate bytes:
|
||||||
|
#00FF00 -> ('00', 'FF', '00')
|
||||||
|
#CF3 -> ('CC', 'FF', '33')
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag (str): the tag to convert
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: the text with converted tags
|
||||||
|
"""
|
||||||
|
strip_leading = re.compile(rf'{self._RE_FG_OR_BG}')
|
||||||
|
tag = strip_leading.sub("", tag)
|
||||||
|
|
||||||
|
if len(tag) == 6:
|
||||||
|
# 6 digits
|
||||||
|
r, g, b = (tag[i:i + 2] for i in range(0, 6, 2))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 3 digits
|
||||||
|
r, g, b = (tag[i:i + 1] * 2 for i in range(0, 3, 1))
|
||||||
|
|
||||||
|
return r, g, b
|
||||||
|
|
||||||
|
def _grey_int(self, num: int) -> int:
|
||||||
|
"""
|
||||||
|
Returns a grey greyscale integer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
"""
|
||||||
|
return round(max((int(num) - 8), 0) / 10)
|
||||||
|
|
||||||
|
def _hue_int(self, num: int) -> int:
|
||||||
|
return round(max((int(num) - 45), 0) / 40)
|
||||||
|
|
||||||
|
def _hex_to_rgb_24_bit(self, hex_code: str) -> tuple[int, int, int]:
|
||||||
|
"""
|
||||||
|
Converts a hex color code (#000 or #000000) into
|
||||||
|
a 3-int tuple (0, 255, 90)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hex_code (str): HTML hex color code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
24-bit rgb tuple: (int, int, int)
|
||||||
|
"""
|
||||||
|
# Strip the leading indicator if present
|
||||||
|
hex_code = re.sub(rf'{self._RE_FG_OR_BG}', '', hex_code)
|
||||||
|
|
||||||
|
r, g, b = self._split_hex_to_bytes(hex_code)
|
||||||
|
|
||||||
|
return int(r, 16), int(g, 16), int(b, 16)
|
||||||
|
|
||||||
|
def _rgb_24_bit_to_256(self, r: int, g: int, b: int) -> tuple[int, int, int]:
|
||||||
|
"""
|
||||||
|
converts 0-255 hex color codes to 0-5
|
||||||
|
|
||||||
|
Args:
|
||||||
|
r (int): red
|
||||||
|
g (int): green
|
||||||
|
b (int): blue
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
256 color rgb tuple: (int, int, int)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self._hue_int(r), self._hue_int(g), self._hue_int(b)
|
||||||
|
|
@ -8,7 +8,9 @@ Test of the ANSI parsing and ANSIStrings.
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from evennia.utils.ansi import ANSIString as AN
|
from evennia.utils.ansi import ANSIString as AN, ANSIParser
|
||||||
|
|
||||||
|
parser = ANSIParser().parse_ansi
|
||||||
|
|
||||||
|
|
||||||
class TestANSIString(TestCase):
|
class TestANSIString(TestCase):
|
||||||
|
|
@ -52,3 +54,124 @@ class TestANSIString(TestCase):
|
||||||
self.assertEqual(split2, split3, "Split 2 and 3 differ")
|
self.assertEqual(split2, split3, "Split 2 and 3 differ")
|
||||||
self.assertEqual(split1, split2, "Split 1 and 2 differ")
|
self.assertEqual(split1, split2, "Split 1 and 2 differ")
|
||||||
self.assertEqual(split1, split3, "Split 1 and 3 differ")
|
self.assertEqual(split1, split3, "Split 1 and 3 differ")
|
||||||
|
|
||||||
|
# TODO: Better greyscale testing
|
||||||
|
|
||||||
|
class TestANSIStringHex(TestCase):
|
||||||
|
"""
|
||||||
|
Tests the conversion of html hex colors
|
||||||
|
to xterm-style colors
|
||||||
|
"""
|
||||||
|
def setUp(self):
|
||||||
|
self.str = 'test '
|
||||||
|
self.output1 = '\x1b[38;5;16mtest \x1b[0m'
|
||||||
|
self.output2 = '\x1b[48;5;16mtest \x1b[0m'
|
||||||
|
self.output3 = '\x1b[38;5;46mtest \x1b[0m'
|
||||||
|
self.output4 = '\x1b[48;5;46mtest \x1b[0m'
|
||||||
|
|
||||||
|
def test_long_grayscale_fg(self):
|
||||||
|
raw = f'|#000000{self.str}|n'
|
||||||
|
ansi = AN(raw)
|
||||||
|
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||||
|
self.assertEqual(ansi.raw(), self.output1, "Output")
|
||||||
|
|
||||||
|
def test_long_grayscale_bg(self):
|
||||||
|
raw = f'|[#000000{self.str}|n'
|
||||||
|
ansi = AN(raw)
|
||||||
|
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||||
|
self.assertEqual(ansi.raw(), self.output2, "Output")
|
||||||
|
|
||||||
|
def test_short_grayscale_fg(self):
|
||||||
|
raw = f'|#000{self.str}|n'
|
||||||
|
ansi = AN(raw)
|
||||||
|
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||||
|
self.assertEqual(ansi.raw(), self.output1, "Output")
|
||||||
|
|
||||||
|
def test_short_grayscale_bg(self):
|
||||||
|
raw = f'|[#000{self.str}|n'
|
||||||
|
ansi = AN(raw)
|
||||||
|
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||||
|
self.assertEqual(ansi.raw(), self.output2, "Output")
|
||||||
|
|
||||||
|
def test_short_color_fg(self):
|
||||||
|
raw = f'|#0F0{self.str}|n'
|
||||||
|
ansi = AN(raw)
|
||||||
|
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||||
|
self.assertEqual(ansi.raw(), self.output3, "Output")
|
||||||
|
|
||||||
|
def test_short_color_bg(self):
|
||||||
|
raw = f'|[#0f0{self.str}|n'
|
||||||
|
ansi = AN(raw)
|
||||||
|
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||||
|
self.assertEqual(ansi.raw(), self.output4, "Output")
|
||||||
|
|
||||||
|
def test_long_color_fg(self):
|
||||||
|
raw = f'|#00ff00{self.str}|n'
|
||||||
|
ansi = AN(raw)
|
||||||
|
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||||
|
self.assertEqual(ansi.raw(), self.output3, "Output")
|
||||||
|
|
||||||
|
def test_long_color_bg(self):
|
||||||
|
raw = f'|[#00FF00{self.str}|n'
|
||||||
|
ansi = AN(raw)
|
||||||
|
self.assertEqual(ansi.clean(), self.str, "Cleaned")
|
||||||
|
self.assertEqual(ansi.raw(), self.output4, "Output")
|
||||||
|
|
||||||
|
|
||||||
|
class TestANSIParser(TestCase):
|
||||||
|
"""
|
||||||
|
Tests the ansi fallback of the hex color conversion and
|
||||||
|
truecolor conversion
|
||||||
|
"""
|
||||||
|
def setUp(self):
|
||||||
|
self.parser = ANSIParser().parse_ansi
|
||||||
|
self.str = 'test '
|
||||||
|
|
||||||
|
# ANSI FALLBACK
|
||||||
|
# Red
|
||||||
|
self.output1 = '\x1b[1m\x1b[31mtest \x1b[0m'
|
||||||
|
# White
|
||||||
|
self.output2 = '\x1b[1m\x1b[37mtest \x1b[0m'
|
||||||
|
# Red BG
|
||||||
|
self.output3 = '\x1b[41mtest \x1b[0m'
|
||||||
|
# Blue FG, Red BG
|
||||||
|
self.output4 = '\x1b[41m\x1b[1m\x1b[34mtest \x1b[0m'
|
||||||
|
|
||||||
|
def test_hex_color(self):
|
||||||
|
raw = f'|#F00{self.str}|n'
|
||||||
|
ansi = parser(raw)
|
||||||
|
# self.assertEqual(ansi, self.str, "Cleaned")
|
||||||
|
self.assertEqual(ansi, self.output1, "Output")
|
||||||
|
|
||||||
|
def test_hex_greyscale(self):
|
||||||
|
raw = f'|#FFF{self.str}|n'
|
||||||
|
ansi = parser(raw)
|
||||||
|
self.assertEqual(ansi, self.output2, "Output")
|
||||||
|
|
||||||
|
def test_hex_color_bg(self):
|
||||||
|
raw = f'|[#Ff0000{self.str}|n'
|
||||||
|
ansi = parser(raw)
|
||||||
|
self.assertEqual(ansi, self.output3, "Output")
|
||||||
|
|
||||||
|
def test_hex_color_fg_bg(self):
|
||||||
|
raw = f'|[#Ff0000|#00f{self.str}|n'
|
||||||
|
ansi = parser(raw)
|
||||||
|
self.assertEqual(ansi, self.output4, "Output")
|
||||||
|
|
||||||
|
def test_truecolor_fg(self):
|
||||||
|
raw = f'|#00c700{self.str}|n'
|
||||||
|
ansi = parser(raw, truecolor=True)
|
||||||
|
output = f'\x1b[38;2;0;199;0m{self.str}\x1b[0m'
|
||||||
|
self.assertEqual(ansi, output, "Output")
|
||||||
|
|
||||||
|
def test_truecolor_bg(self):
|
||||||
|
raw = f'|[#00c700{self.str}|n'
|
||||||
|
ansi = parser(raw, truecolor=True)
|
||||||
|
output = f'\x1b[48;2;0;199;0m{self.str}\x1b[0m'
|
||||||
|
self.assertEqual(ansi, output, "Output")
|
||||||
|
|
||||||
|
def test_truecolor_fg_bg(self):
|
||||||
|
raw = f'|[#00c700|#880000{self.str}|n'
|
||||||
|
ansi = parser(raw, truecolor=True)
|
||||||
|
output = f'\x1b[48;2;0;199;0m\x1b[38;2;136;0;0m{self.str}\x1b[0m'
|
||||||
|
self.assertEqual(ansi, output, "Output")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue