diff --git a/CHANGELOG.md b/CHANGELOG.md index c4662f7f68..cde27d5277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ without arguments starts a full interactive Python console. to `spawn` command to extract the raw prototype dict for manual editing. - `list_to_string` is now `iter_to_string` (but old name still works as legacy alias). It will now accept any input, including generators and single values. +- EvTable should now correctly handle columns with wider asian-characters in them. diff --git a/evennia/utils/evtable.py b/evennia/utils/evtable.py index 5a0244189b..940692ed43 100644 --- a/evennia/utils/evtable.py +++ b/evennia/utils/evtable.py @@ -117,7 +117,7 @@ appear on both sides of the table string. from django.conf import settings from textwrap import TextWrapper from copy import deepcopy, copy -from evennia.utils.utils import m_len, is_iter +from evennia.utils.utils import is_iter, display_len as d_len from evennia.utils.ansi import ANSIString _DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH @@ -231,7 +231,7 @@ class ANSITextWrapper(TextWrapper): indent = self.initial_indent # Maximum width for this line. - width = self.width - m_len(indent) + width = self.width - d_len(indent) # First chunk on line is whitespace -- drop it, unless this # is the very beginning of the text (ie. no lines started yet). @@ -239,7 +239,7 @@ class ANSITextWrapper(TextWrapper): del chunks[-1] while chunks: - l = m_len(chunks[-1]) + l = d_len(chunks[-1]) # Can at least squeeze this chunk onto the current line. if cur_len + l <= width: @@ -252,7 +252,7 @@ class ANSITextWrapper(TextWrapper): # The current line is full, and the next chunk is too big to # fit on *any* line (not just this one). - if chunks and m_len(chunks[-1]) > width: + if chunks and d_len(chunks[-1]) > width: self._handle_long_word(chunks, cur_line, cur_len, width) # If the last chunk on this line is all whitespace, drop it. @@ -442,7 +442,7 @@ class EvCell(object): self.valign = kwargs.get("valign", "c") self.data = self._split_lines(_to_ansi(data)) - self.raw_width = max(m_len(line) for line in self.data) + self.raw_width = max(d_len(line) for line in self.data) self.raw_height = len(self.data) # this is extra trimming required for cels in the middle of a table only @@ -481,9 +481,9 @@ class EvCell(object): width (int): The width to crop `text` to. """ - if m_len(text) > width: + if d_len(text) > width: crop_string = self.crop_string - return text[: width - m_len(crop_string)] + crop_string + return text[: width - d_len(crop_string)] + crop_string return text def _reformat(self): @@ -524,7 +524,7 @@ class EvCell(object): width = self.width adjusted_data = [] for line in data: - if 0 < width < m_len(line): + if 0 < width < d_len(line): # replace_whitespace=False, expand_tabs=False is a # fix for ANSIString not supporting expand_tabs/translate adjusted_data.extend( @@ -567,7 +567,7 @@ class EvCell(object): text (str): Centered text. """ - excess = width - m_len(text) + excess = width - d_len(text) if excess <= 0: return text if excess % 2: @@ -606,13 +606,13 @@ class EvCell(object): if line.startswith(" ") and not line.startswith(" ") else line ) - + hfill_char * (width - m_len(line)) + + hfill_char * (width - d_len(line)) for line in data ] return lines elif align == "r": return [ - hfill_char * (width - m_len(line)) + hfill_char * (width - d_len(line)) + ( " " + line.rstrip(" ") if line.endswith(" ") and not line.endswith(" ") @@ -753,7 +753,7 @@ class EvCell(object): natural_width (int): Width of cell. """ - return m_len(self.formatted[0]) # if self.formatted else 0 + return d_len(self.formatted[0]) # if self.formatted else 0 def replace_data(self, data, **kwargs): """ @@ -768,7 +768,7 @@ class EvCell(object): """ self.data = self._split_lines(_to_ansi(data)) - self.raw_width = max(m_len(line) for line in self.data) + self.raw_width = max(d_len(line) for line in self.data) self.raw_height = len(self.data) self.reformat(**kwargs) diff --git a/evennia/utils/tests/test_utils.py b/evennia/utils/tests/test_utils.py index 4cbfbe41dd..b74668845d 100644 --- a/evennia/utils/tests/test_utils.py +++ b/evennia/utils/tests/test_utils.py @@ -99,6 +99,58 @@ class TestMLen(TestCase): self.assertEqual(utils.m_len({"hello": True, "Goodbye": False}), 2) +class TestDisplayLen(TestCase): + """ + Verifies that display_len behaves like m_len in all situations except those + where asian characters are involved. + """ + + def test_non_mxp_string(self): + self.assertEqual(utils.display_len("Test_string"), 11) + + def test_mxp_string(self): + self.assertEqual(utils.display_len("|lclook|ltat|le"), 2) + + def test_mxp_ansi_string(self): + self.assertEqual(utils.display_len(ANSIString("|lcl|gook|ltat|le|n")), 2) + + def test_non_mxp_ansi_string(self): + self.assertEqual(utils.display_len(ANSIString("|gHello|n")), 5) + + def test_list(self): + self.assertEqual(utils.display_len([None, None]), 2) + + def test_dict(self): + self.assertEqual(utils.display_len({"hello": True, "Goodbye": False}), 2) + + def test_east_asian(self): + self.assertEqual(utils.display_len("서서서"), 6) + + +class TestANSIString(TestCase): + """ + Verifies that ANSIString's string-API works as intended. + """ + + def setUp(self): + self.example_raw = "|relectric |cboogaloo|n" + self.example_ansi = ANSIString(self.example_raw) + self.example_str = "electric boogaloo" + self.example_output = "\x1b[1m\x1b[31melectric \x1b[1m\x1b[36mboogaloo\x1b[0m" + + def test_length(self): + self.assertEqual(len(self.example_ansi), 17) + + def test_clean(self): + self.assertEqual(self.example_ansi.clean(), self.example_str) + + def test_raw(self): + self.assertEqual(self.example_ansi.raw(), self.example_output) + + def test_format(self): + self.assertEqual(f"{self.example_ansi:0<20}", self.example_output + "000") + + class TestTimeformat(TestCase): """ Default function header from utils.py: diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index aaf6641320..8ac06d8ffe 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -9,7 +9,7 @@ be of use when designing your own game. import os import gc import sys -import copy +import copy import types import math import re @@ -20,6 +20,7 @@ import traceback import importlib import importlib.util import importlib.machinery +from unicodedata import east_asian_width from twisted.internet.task import deferLater from twisted.internet.defer import returnValue # noqa - used as import target from os.path import join as osjoin @@ -2002,7 +2003,7 @@ def m_len(target): back to normal len for other objects. Args: - target (string): A string with potential MXP components + target (str): A string with potential MXP components to search. Returns: @@ -2017,6 +2018,35 @@ def m_len(target): return len(target) +def display_len(target): + """ + Calculate the 'visible width' of text. This is not necessarily the same as the + number of characters in the case of certain asian characters. This will also + strip MXP patterns. + + Args: + target (string): A string with potential MXP components + to search. + + Return: + int: The visible width of the target. + + """ + # Would create circular import if in module root. + from evennia.utils.ansi import ANSI_PARSER + + if inherits_from(target, str): + # str or ANSIString + target = ANSI_PARSER.strip_mxp(target) + target = ANSI_PARSER.parse_ansi(target, strip_ansi=True) + extra_wide = ("F", "W") + return sum(2 if east_asian_width(char) in extra_wide else 1 for char in target) + else: + return len(target) + + + + # ------------------------------------------------------------------- # Search handler function # -------------------------------------------------------------------