From a552bf6fd41a032cbd0301365a3d2ca3cbf38371 Mon Sep 17 00:00:00 2001 From: mike Date: Sat, 6 Apr 2024 16:03:55 -0700 Subject: [PATCH 1/7] Added xterm truecolor support and tests. --- evennia/utils/ansi.py | 20 +++-- evennia/utils/hex_colors.py | 145 +++++++++++++++++++++++++++++++ evennia/utils/tests/test_ansi.py | 125 +++++++++++++++++++++++++- 3 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 evennia/utils/hex_colors.py diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index 2cc03f4aec..5d95c402ed 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -69,6 +69,9 @@ from django.conf import settings from evennia.utils import logger, utils from evennia.utils.utils import to_str +from evennia.utils.hex_colors import HexToTruecolor + +hex_sub = HexToTruecolor.hex_sub MXP_ENABLED = settings.MXP_ENABLED @@ -431,7 +434,7 @@ class ANSIParser(object): """ 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 mapping. @@ -458,13 +461,18 @@ class ANSIParser(object): # check cached parsings 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: return _PARSE_CACHE[cachekey] # pre-convert bright colors to xterm256 color tags 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): return self.sub_xterm256(part, xterm256, "fg") @@ -483,7 +491,8 @@ class ANSIParser(object): parsed_string = [] parts = self.ansi_escapes.split(in_string) + [" "] 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_gfg_sub.sub(do_xterm256_gfg, 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. Args: + truecolor: string (str): The string to parse. strip_ansi (bool, optional): Strip all ANSI sequences. 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 "" - 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): diff --git a/evennia/utils/hex_colors.py b/evennia/utils/hex_colors.py new file mode 100644 index 0000000000..4118c179c3 --- /dev/null +++ b/evennia/utils/hex_colors.py @@ -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) diff --git a/evennia/utils/tests/test_ansi.py b/evennia/utils/tests/test_ansi.py index 4ff9d468c6..0ecc8d1b04 100644 --- a/evennia/utils/tests/test_ansi.py +++ b/evennia/utils/tests/test_ansi.py @@ -8,7 +8,9 @@ Test of the ANSI parsing and ANSIStrings. 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): @@ -52,3 +54,124 @@ class TestANSIString(TestCase): self.assertEqual(split2, split3, "Split 2 and 3 differ") self.assertEqual(split1, split2, "Split 1 and 2 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") From de09f7a71caa5bd648554f838a55e73ded76cab9 Mon Sep 17 00:00:00 2001 From: mike Date: Sat, 6 Apr 2024 17:45:37 -0700 Subject: [PATCH 2/7] Added basic terminal detection for truecolor support. --- evennia/server/portal/telnet.py | 5 +++++ evennia/server/portal/ttype.py | 26 ++++++++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index cb577f3835..1b1e27db62 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -437,6 +437,9 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS): xterm256 = options.get( "xterm256", flags.get("XTERM256", False) if flags.get("TTYPE", False) else True ) + truecolor = options.get( + "truecolor", flags.get("TRUECOLOR", False) if flags.get("TTYPE", False) else True + ) useansi = options.get( "ansi", flags.get("ANSI", False) if flags.get("TTYPE", False) else True ) @@ -460,6 +463,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS): _RE_N.sub("", prompt) + ("||n" if prompt.endswith("|") else "|n"), strip_ansi=nocolor, xterm256=xterm256, + truecolor=truecolor ) if mxp: prompt = mxp_parse(prompt) @@ -496,6 +500,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS): strip_ansi=nocolor, xterm256=xterm256, mxp=mxp, + truecolor=truecolor ) if mxp: linetosend = mxp_parse(linetosend) diff --git a/evennia/server/portal/ttype.py b/evennia/server/portal/ttype.py index 532d43f8a4..3a6be64c72 100644 --- a/evennia/server/portal/ttype.py +++ b/evennia/server/portal/ttype.py @@ -130,10 +130,10 @@ class Ttype: self.protocol.protocol_flags["NOPROMPTGOAHEAD"] = False if ( - clientname.startswith("XTERM") - or clientname.endswith("-256COLOR") - or clientname - in ( + clientname.startswith("XTERM") + or clientname.endswith("-256COLOR") + or clientname + in ( "ATLANTIS", # > 0.9.9.0 (aug 2009) "CMUD", # > 3.04 (mar 2009) "KILDCLIENT", # > 2.2.0 (sep 2005) @@ -143,13 +143,23 @@ class Ttype: "BEIP", # > 2.00.206 (late 2009) (BeipMu) "POTATO", # > 2.00 (maybe earlier) "TINYFUGUE", # > 4.x (maybe earlier) - ) + ) ): xterm256 = True + # use name to identify support for xterm truecolor + truecolor = False + if (clientname.endswith("-TRUECOLOR") or + clientname in ( + "AXMUD", + "TINTIN" + )): + truecolor = True + # all clients supporting TTYPE at all seem to support ANSI self.protocol.protocol_flags["ANSI"] = True self.protocol.protocol_flags["XTERM256"] = xterm256 + self.protocol.protocol_flags["TRUECOLOR"] = truecolor self.protocol.protocol_flags["CLIENTNAME"] = clientname self.protocol.requestNegotiation(TTYPE, SEND) @@ -159,9 +169,9 @@ class Ttype: tupper = term.upper() # identify xterm256 based on flag xterm256 = ( - tupper.endswith("-256COLOR") - or tupper.endswith("XTERM") # Apple Terminal, old Tintin - and not tupper.endswith("-COLOR") # old Tintin, Putty + tupper.endswith("-256COLOR") + or tupper.endswith("XTERM") # Apple Terminal, old Tintin + and not tupper.endswith("-COLOR") # old Tintin, Putty ) if xterm256: self.protocol.protocol_flags["ANSI"] = True From 0cac9bf8720ad0f7f601750b2e477f8efc09a171 Mon Sep 17 00:00:00 2001 From: mike Date: Sun, 7 Apr 2024 00:16:59 -0700 Subject: [PATCH 3/7] Added truecolor support to web portal. --- evennia/utils/ansi.py | 6 +-- evennia/utils/hex_colors.py | 42 ++++++++++++++++---- evennia/utils/tests/test_text2html.py | 8 ++++ evennia/utils/text2html.py | 55 ++++++++++++++++++++++----- 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index 5d95c402ed..2c07ccd7f1 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -69,9 +69,9 @@ from django.conf import settings from evennia.utils import logger, utils from evennia.utils.utils import to_str -from evennia.utils.hex_colors import HexToTruecolor +from evennia.utils.hex_colors import HexColors -hex_sub = HexToTruecolor.hex_sub +hex_sub = HexColors.hex_sub MXP_ENABLED = settings.MXP_ENABLED @@ -470,7 +470,7 @@ class ANSIParser(object): string = self.brightbg_sub.sub(self.sub_brightbg, string) def do_truecolor(part: re.Match, truecolor=truecolor): - hex2truecolor = HexToTruecolor() + hex2truecolor = HexColors() return hex2truecolor.sub_truecolor(part, truecolor) def do_xterm256_fg(part): diff --git a/evennia/utils/hex_colors.py b/evennia/utils/hex_colors.py index 4118c179c3..dcea3531a7 100644 --- a/evennia/utils/hex_colors.py +++ b/evennia/utils/hex_colors.py @@ -1,7 +1,7 @@ import re -class HexToTruecolor: +class HexColors: """ 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 @@ -14,19 +14,18 @@ class HexToTruecolor: _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})' + _RE_BYTE = '[0-2]?[0-9]?[0-9]' + _RE_XTERM_TRUECOLOR = rf'\[([34])8;2;({_RE_BYTE});({_RE_BYTE});({_RE_BYTE})m' # 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" + TRUECOLOR_FG = f'\x1b\[38;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m' + TRUECOLOR_BG = f'\x1b\[48;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m' + # Our matchers for use with ANSIParser and ANSIString hex_sub = re.compile(rf'{_RE_HEX_PATTERN}', re.DOTALL) @@ -73,6 +72,35 @@ class HexToTruecolor: xtag += f"8;2;{r};{g};{b}m" return xtag + def xterm_truecolor_to_html_style(self, fg="", bg="") -> str: + """ + Converts xterm truecolor to an html style property + + Args: + fg: xterm truecolor + bg: xterm truecolor + + Returns: style='color and or background-color' + + """ + prop = 'style="' + if fg != '': + res = re.search(self._RE_XTERM_TRUECOLOR, fg, re.DOTALL) + fg_bg, r, g, b = res.groups() + r = hex(int(r))[2:].zfill(2) + g = hex(int(g))[2:].zfill(2) + b = hex(int(b))[2:].zfill(2) + prop += f"color: #{r}{g}{b};" + if bg != '': + res = re.search(self._RE_XTERM_TRUECOLOR, bg, re.DOTALL) + fg_bg, r, g, b = res.groups() + r = hex(int(r))[2:].zfill(2) + g = hex(int(g))[2:].zfill(2) + b = hex(int(b))[2:].zfill(2) + prop += f"background-color: #{r}{g}{b};" + prop += f'"' + return prop + def _split_hex_to_bytes(self, tag: str) -> tuple[str, str, str]: """ Splits hex string into separate bytes: diff --git a/evennia/utils/tests/test_text2html.py b/evennia/utils/tests/test_text2html.py index aea0cb87e7..74bd61bf18 100644 --- a/evennia/utils/tests/test_text2html.py +++ b/evennia/utils/tests/test_text2html.py @@ -46,6 +46,14 @@ class TestText2Html(TestCase): parser.format_styles("a " + ansi.ANSI_INVERSE + "red" + ansi.ANSI_NORMAL + "foo"), ) + # True Color + self.assertEqual( + 'redfoo', + parser.format_styles( + f'\x1b[38;2;255;0;0m' + "red" + ansi.ANSI_NORMAL + "foo" + ), + ) + def test_remove_bells(self): parser = text2html.HTML_PARSER self.assertEqual("foo", parser.remove_bells("foo")) diff --git a/evennia/utils/text2html.py b/evennia/utils/text2html.py index 1effdd3266..e8000a7f81 100644 --- a/evennia/utils/text2html.py +++ b/evennia/utils/text2html.py @@ -13,11 +13,15 @@ from html import escape as html_escape from .ansi import * +from .hex_colors import HexColors + # All xterm256 RGB equivalents XTERM256_FG = "\033[38;5;{}m" XTERM256_BG = "\033[48;5;{}m" +hex_colors = HexColors() + class TextToHTMLparser(object): """ @@ -67,13 +71,11 @@ class TextToHTMLparser(object): ] xterm_bg_codes = [XTERM256_BG.format(i + 16) for i in range(240)] - re_style = re.compile( - r"({})".format( + r"({}|{})".format( "|".join( style_codes + ansi_color_codes + xterm_fg_codes + ansi_bg_codes + xterm_bg_codes - ).replace("[", r"\[") - ) + ).replace("[", r"\["), "|".join([HexColors.TRUECOLOR_FG, HexColors.TRUECOLOR_BG])) ) colorlist = ( @@ -244,6 +246,7 @@ class TextToHTMLparser(object): # split out the ANSI codes and clean out any empty items str_list = [substr for substr in self.re_style.split(text) if substr] + # initialize all the flags and classes classes = [] clean = True @@ -253,6 +256,8 @@ class TextToHTMLparser(object): fg = ANSI_WHITE # default bg is black bg = ANSI_BACK_BLACK + truecolor_fg = '' + truecolor_bg = '' for i, substr in enumerate(str_list): # reset all current styling @@ -266,6 +271,8 @@ class TextToHTMLparser(object): hilight = ANSI_UNHILITE fg = ANSI_WHITE bg = ANSI_BACK_BLACK + truecolor_fg = '' + truecolor_bg = '' # change color elif substr in self.ansi_color_codes + self.xterm_fg_codes: @@ -281,6 +288,14 @@ class TextToHTMLparser(object): # set new bg bg = substr + elif re.match(hex_colors.TRUECOLOR_FG, substr): + str_list[i] = '' + truecolor_fg = substr + + elif re.match(hex_colors.TRUECOLOR_BG, substr): + str_list[i] = "" + truecolor_bg = substr + # non-color codes elif substr in self.style_codes: # erase ANSI code from output @@ -319,9 +334,23 @@ class TextToHTMLparser(object): color_index = self.colorlist.index(fg) if inverse: - # inverse means swap fg and bg indices - bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0")) - color_class = "color-{}".format(str(bg_index).rjust(3, "0")) + if truecolor_fg != '' and truecolor_bg != '': + # True startcolor only + truecolor_fg, truecolor_bg = truecolor_bg, truecolor_fg + elif truecolor_fg != '' and truecolor_bg == '': + # Truecolor fg, class based bg + truecolor_bg = truecolor_fg + truecolor_fg = '' + color_class = "color-{}".format(str(bg_index).rjust(3, "0")) + elif truecolor_fg == '' and truecolor_bg != '': + # Truecolor bg, class based fg + truecolor_fg = truecolor_bg + truecolor_bg = '' + bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0")) + else: + # inverse means swap fg and bg indices + bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0")) + color_class = "color-{}".format(str(bg_index).rjust(3, "0")) else: # use fg and bg indices for classes bg_class = "bgcolor-{}".format(str(bg_index).rjust(3, "0")) @@ -333,8 +362,16 @@ class TextToHTMLparser(object): # light grey text is the default, don't explicitly style if color_class != "color-007": classes.append(color_class) + # define the new style span - prefix = ''.format(" ".join(classes)) + if truecolor_fg == '' and truecolor_bg == '': + prefix = f'' + else: + # Classes can't be used for true color + prefix = (f'') + # close any prior span if not clean: prefix = "" + prefix @@ -366,7 +403,7 @@ class TextToHTMLparser(object): """ # parse everything to ansi first - text = parse_ansi(text, strip_ansi=strip_ansi, xterm256=True, mxp=True) + text = parse_ansi(text, strip_ansi=strip_ansi, xterm256=True, mxp=True, truecolor=True) # convert all ansi to html result = re.sub(self.re_string, self.sub_text, text) result = re.sub(self.re_mxplink, self.sub_mxp_links, result) From 686e8195802e4a58491cc10dc3a4efa078253881 Mon Sep 17 00:00:00 2001 From: mike Date: Sun, 7 Apr 2024 17:56:36 -0700 Subject: [PATCH 4/7] Added validation for the TRUECOLOR option to support manual toggling. --- evennia/commands/default/account.py | 1 + 1 file changed, 1 insertion(+) diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index f51b079e17..dd3878b080 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -665,6 +665,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): "INPUTDEBUG": validate_bool, "FORCEDENDLINE": validate_bool, "LOCALECHO": validate_bool, + "TRUECOLOR": validate_bool } name = self.lhs.upper() From 5554946721b4a96d3c1dfd6b81e71a63415cf3a6 Mon Sep 17 00:00:00 2001 From: mike Date: Mon, 8 Apr 2024 16:28:41 -0700 Subject: [PATCH 5/7] Fixed typo in ttype and added truecolor to help text in telnet. --- evennia/server/portal/telnet.py | 1 + evennia/server/portal/ttype.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/server/portal/telnet.py b/evennia/server/portal/telnet.py index 1b1e27db62..d5e9118837 100644 --- a/evennia/server/portal/telnet.py +++ b/evennia/server/portal/telnet.py @@ -419,6 +419,7 @@ class TelnetProtocol(Telnet, StatefulTelnetProtocol, _BASE_SESSION_CLASS): - xterm256: Enforce xterm256 colors, regardless of TTYPE. - noxterm256: Enforce no xterm256 color support, regardless of TTYPE. - nocolor: Strip all Color, regardless of ansi/xterm256 setting. + - truecolor: Enforce truecolor, regardless of TTYPE. - raw: Pass string through without any ansi processing (i.e. include Evennia ansi markers but do not convert them into ansi tokens) diff --git a/evennia/server/portal/ttype.py b/evennia/server/portal/ttype.py index 3a6be64c72..58705eb12d 100644 --- a/evennia/server/portal/ttype.py +++ b/evennia/server/portal/ttype.py @@ -44,7 +44,7 @@ class Ttype: def __init__(self, protocol): """ Initialize ttype by storing protocol on ourselves and calling - the client to see if it supporst ttype. + the client to see if it supports ttype. Args: protocol (Protocol): The protocol instance. From 5bec1a29d6e51b83488221b82ebd219d85c8fb47 Mon Sep 17 00:00:00 2001 From: mike Date: Sat, 27 Apr 2024 14:01:44 -0700 Subject: [PATCH 6/7] =?UTF-8?q?Changes=20per=20Griatch:=20-Reordered=20met?= =?UTF-8?q?hods=20in=20HexColors=20-Separated=20truecolor=20tests=20-Clari?= =?UTF-8?q?fied=20comment=20re:=20classes=20and=20styles=20in=20text2html.?= =?UTF-8?q?py=20-Changed=20ansi.py=20to=20only=20instatiate=20HexColors=20?= =?UTF-8?q?once=20=F0=9F=A4=A6=E2=80=8D=E2=99=82=EF=B8=8F=20-Fixed=20missi?= =?UTF-8?q?ng=20docsctring=20in=20parse=5Fansi=20re:=20truecolor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- evennia/utils/ansi.py | 12 +- evennia/utils/hex_colors.py | 174 +++++++++++++------------- evennia/utils/tests/test_ansi.py | 125 +----------------- evennia/utils/tests/test_truecolor.py | 127 +++++++++++++++++++ evennia/utils/text2html.py | 36 +++--- 5 files changed, 243 insertions(+), 231 deletions(-) create mode 100644 evennia/utils/tests/test_truecolor.py diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index 7bb6643e5f..68dbc0c103 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -72,6 +72,7 @@ from evennia.utils import logger, utils from evennia.utils.utils import to_str from evennia.utils.hex_colors import HexColors +hex2truecolor = HexColors() hex_sub = HexColors.hex_sub MXP_ENABLED = settings.MXP_ENABLED @@ -471,7 +472,6 @@ class ANSIParser(object): string = self.brightbg_sub.sub(self.sub_brightbg, string) def do_truecolor(part: re.Match, truecolor=truecolor): - hex2truecolor = HexColors() return hex2truecolor.sub_truecolor(part, truecolor) def do_xterm256_fg(part): @@ -525,24 +525,28 @@ ANSI_PARSER = ANSIParser() # -def parse_ansi(string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp=False, truecolor=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. Args: - truecolor: string (str): The string to parse. strip_ansi (bool, optional): Strip all ANSI sequences. parser (ansi.AnsiParser, optional): A parser instance to use. xterm256 (bool, optional): Support xterm256 or not. mxp (bool, optional): Support MXP markup or not. + truecolor (bool, optional): Support for truecolor or not. Returns: string (str): The parsed string. """ string = string or "" - return parser.parse_ansi(string, strip_ansi=strip_ansi, xterm256=xterm256, mxp=mxp, truecolor=truecolor) + return parser.parse_ansi( + string, strip_ansi=strip_ansi, xterm256=xterm256, mxp=mxp, truecolor=truecolor + ) def strip_ansi(string, parser=ANSI_PARSER): diff --git a/evennia/utils/hex_colors.py b/evennia/utils/hex_colors.py index dcea3531a7..781496ac71 100644 --- a/evennia/utils/hex_colors.py +++ b/evennia/utils/hex_colors.py @@ -9,97 +9,25 @@ class HexColors: 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_XTERM_TRUECOLOR = rf'\[([34])8;2;({_RE_BYTE});({_RE_BYTE});({_RE_BYTE})m' + _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_XTERM_TRUECOLOR = rf"\[([34])8;2;({_RE_BYTE});({_RE_BYTE});({_RE_BYTE})m" # Used in hex_sub - _RE_HEX_PATTERN = f'({_RE_FG_OR_BG})({_RE_HEX_LONG}|{_RE_HEX_SHORT})' + _RE_HEX_PATTERN = f"({_RE_FG_OR_BG})({_RE_HEX_LONG}|{_RE_HEX_SHORT})" # Used for greyscale _GREYS = "abcdefghijklmnopqrstuvwxyz" - TRUECOLOR_FG = f'\x1b\[38;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m' - TRUECOLOR_BG = f'\x1b\[48;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m' + TRUECOLOR_FG = f"\x1b\[38;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m" + TRUECOLOR_BG = f"\x1b\[48;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m" # 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 xterm_truecolor_to_html_style(self, fg="", bg="") -> str: - """ - Converts xterm truecolor to an html style property - - Args: - fg: xterm truecolor - bg: xterm truecolor - - Returns: style='color and or background-color' - - """ - prop = 'style="' - if fg != '': - res = re.search(self._RE_XTERM_TRUECOLOR, fg, re.DOTALL) - fg_bg, r, g, b = res.groups() - r = hex(int(r))[2:].zfill(2) - g = hex(int(g))[2:].zfill(2) - b = hex(int(b))[2:].zfill(2) - prop += f"color: #{r}{g}{b};" - if bg != '': - res = re.search(self._RE_XTERM_TRUECOLOR, bg, re.DOTALL) - fg_bg, r, g, b = res.groups() - r = hex(int(r))[2:].zfill(2) - g = hex(int(g))[2:].zfill(2) - b = hex(int(b))[2:].zfill(2) - prop += f"background-color: #{r}{g}{b};" - prop += f'"' - return prop + hex_sub = re.compile(rf"{_RE_HEX_PATTERN}", re.DOTALL) def _split_hex_to_bytes(self, tag: str) -> tuple[str, str, str]: """ @@ -113,16 +41,16 @@ class HexColors: Returns: str: the text with converted tags """ - strip_leading = re.compile(rf'{self._RE_FG_OR_BG}') + 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)) + 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)) + r, g, b = (tag[i : i + 1] * 2 for i in range(0, 3, 1)) return r, g, b @@ -150,7 +78,7 @@ class HexColors: 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) + hex_code = re.sub(rf"{self._RE_FG_OR_BG}", "", hex_code) r, g, b = self._split_hex_to_bytes(hex_code) @@ -171,3 +99,75 @@ class HexColors: """ return self._hue_int(r), self._hue_int(g), self._hue_int(b) + + 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 xterm_truecolor_to_html_style(self, fg="", bg="") -> str: + """ + Converts xterm truecolor to an html style property + + Args: + fg: xterm truecolor + bg: xterm truecolor + + Returns: style='color and or background-color' + + """ + prop = 'style="' + if fg != "": + res = re.search(self._RE_XTERM_TRUECOLOR, fg, re.DOTALL) + fg_bg, r, g, b = res.groups() + r = hex(int(r))[2:].zfill(2) + g = hex(int(g))[2:].zfill(2) + b = hex(int(b))[2:].zfill(2) + prop += f"color: #{r}{g}{b};" + if bg != "": + res = re.search(self._RE_XTERM_TRUECOLOR, bg, re.DOTALL) + fg_bg, r, g, b = res.groups() + r = hex(int(r))[2:].zfill(2) + g = hex(int(g))[2:].zfill(2) + b = hex(int(b))[2:].zfill(2) + prop += f"background-color: #{r}{g}{b};" + prop += f'"' + return prop diff --git a/evennia/utils/tests/test_ansi.py b/evennia/utils/tests/test_ansi.py index 0ecc8d1b04..4ff9d468c6 100644 --- a/evennia/utils/tests/test_ansi.py +++ b/evennia/utils/tests/test_ansi.py @@ -8,9 +8,7 @@ Test of the ANSI parsing and ANSIStrings. from django.test import TestCase -from evennia.utils.ansi import ANSIString as AN, ANSIParser - -parser = ANSIParser().parse_ansi +from evennia.utils.ansi import ANSIString as AN class TestANSIString(TestCase): @@ -54,124 +52,3 @@ class TestANSIString(TestCase): self.assertEqual(split2, split3, "Split 2 and 3 differ") self.assertEqual(split1, split2, "Split 1 and 2 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") diff --git a/evennia/utils/tests/test_truecolor.py b/evennia/utils/tests/test_truecolor.py new file mode 100644 index 0000000000..33fddeca16 --- /dev/null +++ b/evennia/utils/tests/test_truecolor.py @@ -0,0 +1,127 @@ +from django.test import TestCase + +from evennia.utils.ansi import ANSIString as AN, ANSIParser + +parser = ANSIParser().parse_ansi + + +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") diff --git a/evennia/utils/text2html.py b/evennia/utils/text2html.py index e8000a7f81..bb9d642915 100644 --- a/evennia/utils/text2html.py +++ b/evennia/utils/text2html.py @@ -75,7 +75,9 @@ class TextToHTMLparser(object): r"({}|{})".format( "|".join( style_codes + ansi_color_codes + xterm_fg_codes + ansi_bg_codes + xterm_bg_codes - ).replace("[", r"\["), "|".join([HexColors.TRUECOLOR_FG, HexColors.TRUECOLOR_BG])) + ).replace("[", r"\["), + "|".join([HexColors.TRUECOLOR_FG, HexColors.TRUECOLOR_BG]), + ) ) colorlist = ( @@ -256,8 +258,8 @@ class TextToHTMLparser(object): fg = ANSI_WHITE # default bg is black bg = ANSI_BACK_BLACK - truecolor_fg = '' - truecolor_bg = '' + truecolor_fg = "" + truecolor_bg = "" for i, substr in enumerate(str_list): # reset all current styling @@ -271,8 +273,8 @@ class TextToHTMLparser(object): hilight = ANSI_UNHILITE fg = ANSI_WHITE bg = ANSI_BACK_BLACK - truecolor_fg = '' - truecolor_bg = '' + truecolor_fg = "" + truecolor_bg = "" # change color elif substr in self.ansi_color_codes + self.xterm_fg_codes: @@ -289,7 +291,7 @@ class TextToHTMLparser(object): bg = substr elif re.match(hex_colors.TRUECOLOR_FG, substr): - str_list[i] = '' + str_list[i] = "" truecolor_fg = substr elif re.match(hex_colors.TRUECOLOR_BG, substr): @@ -334,18 +336,18 @@ class TextToHTMLparser(object): color_index = self.colorlist.index(fg) if inverse: - if truecolor_fg != '' and truecolor_bg != '': + if truecolor_fg != "" and truecolor_bg != "": # True startcolor only truecolor_fg, truecolor_bg = truecolor_bg, truecolor_fg - elif truecolor_fg != '' and truecolor_bg == '': + elif truecolor_fg != "" and truecolor_bg == "": # Truecolor fg, class based bg truecolor_bg = truecolor_fg - truecolor_fg = '' + truecolor_fg = "" color_class = "color-{}".format(str(bg_index).rjust(3, "0")) - elif truecolor_fg == '' and truecolor_bg != '': + elif truecolor_fg == "" and truecolor_bg != "": # Truecolor bg, class based fg truecolor_fg = truecolor_bg - truecolor_bg = '' + truecolor_bg = "" bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0")) else: # inverse means swap fg and bg indices @@ -364,13 +366,15 @@ class TextToHTMLparser(object): classes.append(color_class) # define the new style span - if truecolor_fg == '' and truecolor_bg == '': + if truecolor_fg == "" and truecolor_bg == "": prefix = f'' else: - # Classes can't be used for true color - prefix = (f'') + # Classes can't be used for truecolor--but they can be extras such as 'blink' + prefix = ( + f"" + ) # close any prior span if not clean: From dff3c3f82c8e84ce3dca17c5c09ae5d40a84f813 Mon Sep 17 00:00:00 2001 From: mike Date: Sun, 28 Apr 2024 12:04:00 -0700 Subject: [PATCH 7/7] Extended the colortest command for truecolor. Changed its output style because of the number of colors it supports (style borrowed from the termstandard repo's test) --- evennia/commands/default/account.py | 37 +++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py index dd3878b080..c6ef103678 100644 --- a/evennia/commands/default/account.py +++ b/evennia/commands/default/account.py @@ -665,7 +665,7 @@ class CmdOption(COMMAND_DEFAULT_CLASS): "INPUTDEBUG": validate_bool, "FORCEDENDLINE": validate_bool, "LOCALECHO": validate_bool, - "TRUECOLOR": validate_bool + "TRUECOLOR": validate_bool, } name = self.lhs.upper() @@ -789,12 +789,12 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS): testing which colors your client support Usage: - color ansi | xterm256 + color ansi | xterm256 | truecolor Prints a color map along with in-mud color codes to use to produce them. It also tests what is supported in your client. Choices are - 16-color ansi (supported in most muds) or the 256-color xterm256 - standard. No checking is done to determine your client supports + 16-color ansi (supported in most muds), the 256-color xterm256 + standard, or truecolor. No checking is done to determine your client supports color - if not you will see rubbish appear. """ @@ -833,6 +833,18 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS): ) return ftable + def make_hex_color_from_column(self, column_number): + r = 255 - column_number * 255 / 76 + g = column_number * 510 / 76 + b = column_number * 255 / 76 + + if g > 255: + g = 510 - g + + return ( + f"#{hex(round(r))[2:].zfill(2)}{hex(round(g))[2:].zfill(2)}{hex(round(b))[2:].zfill(2)}" + ) + def func(self): """Show color tables""" @@ -911,9 +923,24 @@ class CmdColorTest(COMMAND_DEFAULT_CLASS): table = self.table_format(table) string += "\n" + "\n".join("".join(row) for row in table) self.msg(string) + + elif self.args.startswith("t"): + # show abbreviated truecolor sample (16.7 million colors in truecolor) + string = "" + for i in range(76): + string += f"|[{self.make_hex_color_from_column(i)} |n" + + string += ( + "\n" + + "some of the truecolor colors (if not all hues show, your client might not report that it can" + " handle trucolor.):" + ) + + self.msg(string) + else: # malformed input - self.msg("Usage: color ansi||xterm256") + self.msg("Usage: color ansi || xterm256 || truecolor") class CmdQuell(COMMAND_DEFAULT_CLASS):