Support 'a' (absolute) justification. Let EvForm accept EvCells as mappings. Resolve #2762

This commit is contained in:
Griatch 2022-11-05 17:59:32 +01:00
parent 158b9e2e12
commit 9709ecbc57
5 changed files with 207 additions and 51 deletions

View file

@ -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) - Made all id fields BigAutoField for all databases. (owllex)
- `EvForm` refactored. New `literals` mapping, for literal mappings into the - `EvForm` refactored. New `literals` mapping, for literal mappings into the
main template (e.g. for single-character replacements). 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 ## Evennia 0.9.5

View file

@ -149,6 +149,7 @@ import re
from copy import copy from copy import copy
from evennia.utils.ansi import ANSIString 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.evtable import EvCell, EvTable
from evennia.utils.utils import all_from_module, is_iter, to_str 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")) matrix = EvForm._to_ansi(self.literal_form.split("\n"))
maxl = max(len(line) for line in matrix) maxl = max(len(line) for line in matrix)
matrix = [line + " " * (maxl - len(line)) for line in matrix] matrix = [line + " " * (maxl - len(line)) for line in matrix]
if matrix and not matrix[0].strip(): if matrix and not matrix[0].strip():
@ -348,9 +350,8 @@ class EvForm:
return obj return obj
elif isinstance(obj, str): elif isinstance(obj, str):
# since ansi will be parsed twice (here and in the normal ansi send), we have to # 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 # escape ansi twice.
# which is not ideal for those wanting to replace/extend it ... obj = ansi_raw(obj)
obj = _ANSI_ESCAPE.sub(r"||||", obj)
if isinstance(obj, dict): if isinstance(obj, dict):
return dict( return dict(
@ -370,7 +371,7 @@ class EvForm:
""" """
formchar = self.data["formchar"] formchar = self.data["formchar"]
tablechar = self.data["tablechar"] tablechar = self.data["tablechar"]
form = self.matrix matrix = self.matrix
cell_options = copy(self.cell_options) cell_options = copy(self.cell_options)
cell_options.update(self.options) cell_options.update(self.options)
@ -378,7 +379,7 @@ class EvForm:
table_options = copy(self.table_options) table_options = copy(self.table_options)
table_options.update(self.options) table_options.update(self.options)
nform = len(form) nmatrix = len(matrix)
mapping = {} mapping = {}
@ -389,7 +390,7 @@ class EvForm:
regex = re.compile(rf"{char}+([^{INVALID_FORMCHARS}{char}]+){char}+") regex = re.compile(rf"{char}+([^{INVALID_FORMCHARS}{char}]+){char}+")
# find the start/width of rectangles for each line # 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 ix0 = 0
while True: while True:
match = regex.search(line, ix0) match = regex.search(line, ix0)
@ -405,15 +406,15 @@ class EvForm:
dy_up = 0 dy_up = 0
if iy > 0: if iy > 0:
for i in range(1, iy): 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 dy_up += 1
else: else:
break break
# find bottom edge of rectangle # find bottom edge of rectangle
dy_down = 0 dy_down = 0
if iy < nform - 1: if iy < nmatrix - 1:
for i in range(1, nform - iy - 1): for i in range(1, nmatrix - iy - 1):
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_down += 1 dy_down += 1
else: else:
break break
@ -434,7 +435,21 @@ class EvForm:
# get data to populate cell # get data to populate cell
data = self.cells_mapping.get(key, "") data = self.cells_mapping.get(key, "")
# generate Cell on the fly 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) cell = EvCell(data, width=width, height=height, **cell_options)
mapping[key] = (y, x, width, height, cell) mapping[key] = (y, x, width, height, cell)

View file

@ -120,7 +120,7 @@ from textwrap import TextWrapper
from django.conf import settings from django.conf import settings
from evennia.utils.ansi import ANSIString from evennia.utils.ansi import ANSIString
from evennia.utils.utils import display_len as d_len 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 _DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
@ -601,29 +601,8 @@ class EvCell:
align = self.align align = self.align
hfill_char = self.hfill_char hfill_char = self.hfill_char
width = self.width width = self.width
if align == "l":
lines = [ return [justify(line, width, align=align, fillchar=hfill_char) for line in data]
(
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]
def _valign(self, data): def _valign(self, data):
""" """

View file

@ -2,6 +2,8 @@
Unit tests for the EvForm text form generator Unit tests for the EvForm text form generator
""" """
from unittest import skip
from django.test import TestCase from django.test import TestCase
from evennia.utils import ansi, evform, evtable from evennia.utils import ansi, evform, evtable
@ -261,3 +263,137 @@ class TestEvFormParallelTables(TestCase):
tables={"2": self.table2, "3": self.table3}, tables={"2": self.table2, "3": self.table3},
) )
self.assertEqual(ansi.strip_ansi(str(form).strip()), _EXPECTED.strip()) 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))

View file

@ -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 Fully justify a text so that it fits inside `width`. When using
full justification (default) this will be done by padding between full justification (default) this will be done by padding between
@ -224,16 +224,17 @@ def justify(text, width=None, align="f", indent=0):
Args: Args:
text (str): Text to justify. text (str): Text to justify.
width (int, optional): The length of each line, in characters. width (int, optional): The length of each line, in characters.
align (str, optional): The alignment, 'l', 'c', 'r' or 'f' align (str, optional): The alignment, 'l', 'c', 'r', 'f' or 'a'
for left, center, right or full justification respectively. 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 indent (int, optional): Number of characters indentation of
entire justified text block. entire justified text block.
fillchar (str): The character to use to fill. Defaults to empty space.
Returns: Returns:
justified (str): The justified and indented block of text. justified (str): The justified and indented block of text.
""" """
width = width if width else settings.CLIENT_DEFAULT_WIDTH
def _process_line(line): def _process_line(line):
""" """
@ -246,29 +247,46 @@ def justify(text, width=None, align="f", indent=0):
if line_rest > 0: if line_rest > 0:
if align == "l": if align == "l":
if line[-1] == "\n\n": 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: else:
line[-1] += " " * line_rest line[-1] += sp * line_rest
elif align == "r": elif align == "r":
line[0] = " " * line_rest + line[0] line[0] = sp * line_rest + line[0]
elif align == "c": elif align == "c":
pad = " " * (line_rest // 2) pad = sp * (line_rest // 2)
line[0] = pad + line[0] line[0] = pad + line[0]
if line[-1] == "\n\n": if line[-1] == "\n\n":
line[-1] += ( line[-1] += (
pad + " " * (line_rest % 2 - 1) + "\n" + " " * width + "\n" + " " * width pad + sp * (line_rest % 2 - 1) + "\n" + sp * width + "\n" + sp * width
) )
else: else:
line[-1] = line[-1] + pad + " " * (line_rest % 2) line[-1] = line[-1] + pad + sp * (line_rest % 2)
else: # align 'f' else: # align 'f'
gap += " " * (line_rest // max(1, ngaps)) gap += sp * (line_rest // max(1, ngaps))
rest_gap = line_rest % max(1, ngaps) rest_gap = line_rest % max(1, ngaps)
for i in range(rest_gap): for i in range(rest_gap):
line[i] += " " line[i] += sp
elif not any(line): elif not any(line):
return [" " * width] return [sp * width]
return gap.join(line) 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 # split into paragraphs and words
paragraphs = re.split("\n\s*?\n", text, re.MULTILINE) paragraphs = re.split("\n\s*?\n", text, re.MULTILINE)
words = [] words = []
@ -303,7 +321,7 @@ def justify(text, width=None, align="f", indent=0):
if line: # catch any line left behind if line: # catch any line left behind
lines.append(_process_line(line)) lines.append(_process_line(line))
indentstring = " " * indent indentstring = sp * indent
return "\n".join([indentstring + line for line in lines]) 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. # result is a typeclassed entity where `.aliases` is an AliasHandler.
aliases = result.aliases.all(return_objs=True) aliases = result.aliases.all(return_objs=True)
# remove pluralization aliases # 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: else:
# result is likely a Command, where `.aliases` is a list of strings. # result is likely a Command, where `.aliases` is a list of strings.
aliases = result.aliases aliases = result.aliases