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)
- `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

View file

@ -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)

View file

@ -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):
"""

View file

@ -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))

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
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