diff --git a/CHANGELOG.md b/CHANGELOG.md index 527a5ea5ee..e828aab54c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,10 @@ Up requirements to Django 4.0+, Twisted 22+, Python 3.9 or 3.10 - Made all id fields BigAutoField for all databases. (owllex) - `EvForm` refactored. New `literals` mapping, for literal mappings into the main template (e.g. for single-character replacements). +- `EvForm` `cells` kwarg now accepts `EvCells` with custom formatting options + (mainly for custom align/valign). `EvCells` now makes use of `utils.justify`. +- `utils.justify` now supports `align="a"` (absolute alignments. This keeps + the given left indent but crops/fills to the width. Used in EvCells. ## Evennia 0.9.5 diff --git a/evennia/utils/evform.py b/evennia/utils/evform.py index bc725f9693..409627a323 100644 --- a/evennia/utils/evform.py +++ b/evennia/utils/evform.py @@ -149,6 +149,7 @@ import re from copy import copy from evennia.utils.ansi import ANSIString +from evennia.utils.ansi import raw as ansi_raw from evennia.utils.evtable import EvCell, EvTable from evennia.utils.utils import all_from_module, is_iter, to_str @@ -333,6 +334,7 @@ class EvForm: """ matrix = EvForm._to_ansi(self.literal_form.split("\n")) + maxl = max(len(line) for line in matrix) matrix = [line + " " * (maxl - len(line)) for line in matrix] if matrix and not matrix[0].strip(): @@ -348,9 +350,8 @@ class EvForm: return obj elif isinstance(obj, str): # since ansi will be parsed twice (here and in the normal ansi send), we have to - # escape the |-structure twice. TODO: This is tied to the default color-tag syntax - # which is not ideal for those wanting to replace/extend it ... - obj = _ANSI_ESCAPE.sub(r"||||", obj) + # escape ansi twice. + obj = ansi_raw(obj) if isinstance(obj, dict): return dict( @@ -370,7 +371,7 @@ class EvForm: """ formchar = self.data["formchar"] tablechar = self.data["tablechar"] - form = self.matrix + matrix = self.matrix cell_options = copy(self.cell_options) cell_options.update(self.options) @@ -378,7 +379,7 @@ class EvForm: table_options = copy(self.table_options) table_options.update(self.options) - nform = len(form) + nmatrix = len(matrix) mapping = {} @@ -389,7 +390,7 @@ class EvForm: regex = re.compile(rf"{char}+([^{INVALID_FORMCHARS}{char}]+){char}+") # find the start/width of rectangles for each line - for iy, line in enumerate(EvForm._to_ansi(form, regexable=True)): + for iy, line in enumerate(EvForm._to_ansi(matrix, regexable=True)): ix0 = 0 while True: match = regex.search(line, ix0) @@ -405,15 +406,15 @@ class EvForm: dy_up = 0 if iy > 0: for i in range(1, iy): - if all(form[iy - i][ix] == char for ix in range(leftix, rightix)): + if all(matrix[iy - i][ix] == char for ix in range(leftix, rightix)): dy_up += 1 else: break # find bottom edge of rectangle dy_down = 0 - if iy < nform - 1: - for i in range(1, nform - iy - 1): - if all(form[iy + i][ix] == char for ix in range(leftix, rightix)): + if iy < nmatrix - 1: + for i in range(1, nmatrix - iy - 1): + if all(matrix[iy + i][ix] == char for ix in range(leftix, rightix)): dy_down += 1 else: break @@ -434,8 +435,22 @@ class EvForm: # get data to populate cell data = self.cells_mapping.get(key, "") - # generate Cell on the fly - cell = EvCell(data, width=width, height=height, **cell_options) + if isinstance(data, EvCell): + # mapping already provides the cell. We need to override some + # of the cell's options to make it work in the evform rectangle. + # We retain the align/valign since this may be interesting to + # play with within the rectangle. + cell = data + custom_align = cell.align + custom_valign = cell.valign + cell.reformat( + width=width, + height=height, + **{**cell_options, **{"align": custom_align, "valign": custom_valign}}, + ) + else: + # generating cell on the fly + cell = EvCell(data, width=width, height=height, **cell_options) mapping[key] = (y, x, width, height, cell) diff --git a/evennia/utils/evtable.py b/evennia/utils/evtable.py index 75749a639b..2e5c1797d6 100644 --- a/evennia/utils/evtable.py +++ b/evennia/utils/evtable.py @@ -120,7 +120,7 @@ from textwrap import TextWrapper from django.conf import settings from evennia.utils.ansi import ANSIString from evennia.utils.utils import display_len as d_len -from evennia.utils.utils import is_iter +from evennia.utils.utils import is_iter, justify _DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH @@ -601,29 +601,8 @@ class EvCell: align = self.align hfill_char = self.hfill_char width = self.width - if align == "l": - lines = [ - ( - line.lstrip(" ") + " " - if line.startswith(" ") and not line.startswith(" ") - else line - ) - + hfill_char * (width - d_len(line)) - for line in data - ] - return lines - elif align == "r": - return [ - hfill_char * (width - d_len(line)) - + ( - " " + line.rstrip(" ") - if line.endswith(" ") and not line.endswith(" ") - else line - ) - for line in data - ] - else: # center, 'c' - return [self._center(line, self.width, self.hfill_char) for line in data] + + return [justify(line, width, align=align, fillchar=hfill_char) for line in data] def _valign(self, data): """ diff --git a/evennia/utils/tests/test_evform.py b/evennia/utils/tests/test_evform.py index a9a6453b0b..f8a613c449 100644 --- a/evennia/utils/tests/test_evform.py +++ b/evennia/utils/tests/test_evform.py @@ -2,6 +2,8 @@ Unit tests for the EvForm text form generator """ +from unittest import skip + from django.test import TestCase from evennia.utils import ansi, evform, evtable @@ -261,3 +263,137 @@ class TestEvFormParallelTables(TestCase): tables={"2": self.table2, "3": self.table3}, ) self.assertEqual(ansi.strip_ansi(str(form).strip()), _EXPECTED.strip()) + + +class TestEvFormErrors(TestCase): + """ + Tests of EvForm errors found for v1.0-dev + + """ + + maxDiff = None + + def _form(self, form, **kwargs): + + formdict = { + "form": form, + "formchar": "x", + "tablechar": "c", + } + form = evform.EvForm(formdict, **kwargs) + # this is necessary since editors/black tend to strip lines spaces + # from the end of lines for the comparison strings. + form = ansi.strip_ansi(str(form)) + form = "\n".join(line.rstrip() for line in form.split("\n")) + + return form + + def _validate(self, expected, result): + """easier debug""" + err = f"\n{'expected':-^60}\n{expected}\n{'result':-^60}\n{result}\n{'':-^60}" + self.assertEqual(expected.lstrip(), result.lstrip(), err) + + @skip("Pending rebuild of markup") + def test_2757(self): + """ + Testing https://github.com/evennia/evennia/issues/2757 + + Using || ansi escaping messes with rectangle width + + This should be delayed until refactor of markup. + + """ + form = """ + xxxxxx +||---| xx1xxx + xxxxxx + """ + cell_mapping = {1: "Monty"} + + expected = """ + +|---| Monty + + """ + self._validate(expected, self._form(form, cells=cell_mapping)) + + def test_2759(self): + """ + Testing https://github.com/evennia/evennia/issues/2759 + + Leading space in EvCell is stripped + + """ + # testing the underlying problem + + cell = evtable.EvCell(" Hi", align="l") + self.assertEqual(cell._align(cell.data), [ansi.ANSIString("Hi ")]) + + cell = evtable.EvCell(" Hi", align="l") + self.assertEqual(cell._align(cell.data), [ansi.ANSIString("Hi ")]) + + cell = evtable.EvCell(" Hi", align="a") + self.assertEqual(cell._align(cell.data), [ansi.ANSIString(" Hi")]) + + form = """ +.-----------------------. +| Test Form | +| | +|.xxxxx .xxxxxxxxxxxxx | +|.xx1xx .xxxxxx2xxxxxx | +|.xxxxx .xxxxxxxxxxxxx | +| .xxxxxxxxxxxxx | +| | + ----------------------- +""" + + cell1 = " Hi." + cell2 = " Single space\n Double\n Single again" + + cell_mapping = {1: cell1, 2: cell2} + + # default is left-aligned cells + expected = """ +.-----------------------. +| Test Form | +| | +|.Hi. .Single space | +|. .Double | +|. .Single again | +| . | +| | + ----------------------- +""" + self._validate(expected, self._form(form, cells=cell_mapping)) + + # test with absolute alignment (pass cells directly) + cell_mapping = { + 1: evtable.EvCell(cell1, align="a", valign="t"), + 2: evtable.EvCell(cell2, align="a", valign="t"), + } + + expected = """ +.-----------------------. +| Test Form | +| | +|. Hi. . Single space | +|. . Double | +|. . Single again | +| . | +| | + ----------------------- +""" + self._validate(expected, self._form(form, cells=cell_mapping)) + + def test_2763(self): + """ + Testing https://github.com/evennia/evennia/issues/2763 + + Duplication of ANSI sequences in evform + """ + + formdict = {"form": "|R A |n _ x1xx"} + cell_mapping = {1: "test"} + expected = f"{ansi.ANSI_RED} A {ansi.ANSI_NORMAL} _ test" + form = evform.EvForm(formdict, cells=cell_mapping) + self._validate(expected, str(form)) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 733c658d86..1cd5471de8 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -214,7 +214,7 @@ def dedent(text, baseline_index=None, indent=None): ) -def justify(text, width=None, align="f", indent=0): +def justify(text, width=None, align="f", indent=0, fillchar=" "): """ Fully justify a text so that it fits inside `width`. When using full justification (default) this will be done by padding between @@ -224,16 +224,17 @@ def justify(text, width=None, align="f", indent=0): Args: text (str): Text to justify. width (int, optional): The length of each line, in characters. - align (str, optional): The alignment, 'l', 'c', 'r' or 'f' - for left, center, right or full justification respectively. + align (str, optional): The alignment, 'l', 'c', 'r', 'f' or 'a' + for left, center, right, full justification. The 'a' stands for + 'absolute' and means the text will be returned unmodified. indent (int, optional): Number of characters indentation of entire justified text block. + fillchar (str): The character to use to fill. Defaults to empty space. Returns: justified (str): The justified and indented block of text. """ - width = width if width else settings.CLIENT_DEFAULT_WIDTH def _process_line(line): """ @@ -246,29 +247,46 @@ def justify(text, width=None, align="f", indent=0): if line_rest > 0: if align == "l": if line[-1] == "\n\n": - line[-1] = " " * (line_rest - 1) + "\n" + " " * width + "\n" + " " * width + line[-1] = sp * (line_rest - 1) + "\n" + sp * width + "\n" + sp * width else: - line[-1] += " " * line_rest + line[-1] += sp * line_rest elif align == "r": - line[0] = " " * line_rest + line[0] + line[0] = sp * line_rest + line[0] elif align == "c": - pad = " " * (line_rest // 2) + pad = sp * (line_rest // 2) line[0] = pad + line[0] if line[-1] == "\n\n": line[-1] += ( - pad + " " * (line_rest % 2 - 1) + "\n" + " " * width + "\n" + " " * width + pad + sp * (line_rest % 2 - 1) + "\n" + sp * width + "\n" + sp * width ) else: - line[-1] = line[-1] + pad + " " * (line_rest % 2) + line[-1] = line[-1] + pad + sp * (line_rest % 2) else: # align 'f' - gap += " " * (line_rest // max(1, ngaps)) + gap += sp * (line_rest // max(1, ngaps)) rest_gap = line_rest % max(1, ngaps) for i in range(rest_gap): - line[i] += " " + line[i] += sp elif not any(line): - return [" " * width] + return [sp * width] return gap.join(line) + width = width if width else settings.CLIENT_DEFAULT_WIDTH + sp = fillchar + + if align == "a": + # absolute mode - just crop or fill to width + abs_lines = [] + for line in text.split("\n"): + nlen = len(line) + if len(line) < width: + line += sp * (width - nlen) + else: + line = crop(line, width=width, suffix="") + abs_lines.append(line) + return "\n".join(abs_lines) + + # all other aligns requires splitting into paragraphs and words + # split into paragraphs and words paragraphs = re.split("\n\s*?\n", text, re.MULTILINE) words = [] @@ -303,7 +321,7 @@ def justify(text, width=None, align="f", indent=0): if line: # catch any line left behind lines.append(_process_line(line)) - indentstring = " " * indent + indentstring = sp * indent return "\n".join([indentstring + line for line in lines]) @@ -2293,7 +2311,11 @@ def at_search_result(matches, caller, query="", quiet=False, **kwargs): # result is a typeclassed entity where `.aliases` is an AliasHandler. aliases = result.aliases.all(return_objs=True) # remove pluralization aliases - aliases = [alias for alias in aliases if hasattr(alias, "category") and alias.category not in ("plural_key",)] + aliases = [ + alias + for alias in aliases + if hasattr(alias, "category") and alias.category not in ("plural_key",) + ] else: # result is likely a Command, where `.aliases` is a list of strings. aliases = result.aliases