Source code for evennia.utils.evform
# coding=utf-8
"""
EvForm - a way to create advanced ASCII forms
This is intended for creating advanced ASCII game forms, such as a large pretty character sheet or
info document.
The system works on the basis of a readin template that is given in a separate Python file imported
into the handler. This file contains some optional settings and a string mapping out the form. The
template has markers in it to denounce fields to fill. The markers map the absolute size of the
field and will be filled with an `evtable.EvCell` object when displaying the form.
Example of input file `testform.py`:
```python
FORMCHAR = "x"
TABLECHAR = "c"
FORM = '''
.------------------------------------------------.
| |
| Name: xxxxx1xxxxx Player: xxxxxxx2xxxxxxx |
| xxxxxxxxxxx |
| |
>----------------------------------------------<
| |
| Desc: xxxxxxxxxxx STR: x4x DEX: x5x |
| xxxxx3xxxxx INT: x6x STA: x7x |
| xxxxxxxxxxx LUC: x8x MAG: x9x |
| |
>----------------------------------------------<
| | |
| cccccccc | ccccccccccccccccccccccccccccccccccc |
| cccccccc | ccccccccccccccccccccccccccccccccccc |
| cccAcccc | ccccccccccccccccccccccccccccccccccc |
| cccccccc | ccccccccccccccccccccccccccccccccccc |
| cccccccc | cccccccccccccccccBccccccccccccccccc |
| | |
| v& |
-------------------------------------------------
'''
```
The first line of the `FORM` string is ignored if empty. The forms and table markers must mark out
complete, unbroken rectangles, each containing one embedded single-character identifier (so the
smallest element possible is a 3-character wide form). The identifier can be any character except
for the `FORM_CHAR` and `TABLE_CHAR` and some of the common ASCII-art elements, like space, `_` `|`
`*` etc (see `INVALID_FORMCHARS` in this module). Form Rectangles can have any size, but must be
separated from each other by at least one other character's width.
The form can also replace literal markers not abiding by these rules. For example, the `v&` in the
bottom right corner could be such literal marker. If a literal-mapping for 'v&' is provided, all
occurrences of this marker will be replaced. This will happen *before* any other parsing, so in
principle this could be used to inject new fields/tables into the form dynamically. This literal
mapping does not consider width, but it will affect to total width of the form, so make sure what
you inject does not break things. Using literal markers is the only way to inject 1 or 2-character
replacements.
Usage
```python
from evennia import EvForm, EvTable
# create a new form from the template - using the python path
form = EvForm("path.to.testform")
# alteratively, you can supply the template as a dict:
form = EvForm({"FORM": "....", "TABLECHAR": "c", "FORMCHAR": "x"})
# EvForm can also take a dictionary instead of a filepath, as long
# as the dict contains the keys FORMCHAR, TABLECHAR and FORM
# form = EvForm(form=form_dict)
# add data to each tagged form cell
form.map(cells={1: "Tom the Bouncer",
2: "Griatch",
3: "A sturdy fellow",
4: 12,
5: 10,
6: 5,
7: 18,
8: 10,
9: 3})
# create the EvTables
tableA = EvTable("HP","MV","MP",
table=[["**"], ["*****"], ["***"]],
border="incols")
tableB = EvTable("Skill", "Value", "Exp",
table=[["Shooting", "Herbalism", "Smithing"],
[12,14,9],["550/1200", "990/1400", "205/900"]],
border="incols")
# map 'literal' replacents (here, a version string)
custom_mapping = {"v&", "v2"}
# add the tables to the proper ids in the form
form.map(tables={"A": tableA,
"B": tableB})
print(form)
```
This produces the following result:
::
.------------------------------------------------.
| |
| Name: Tom the Player: Griatch |
| Bouncer |
| |
>----------------------------------------------<
| |
| Desc: A sturdy STR: 12 DEX: 10 |
| fellow INT: 5 STA: 18 |
| LUC: 10 MAG: 3 |
| |
>----------------------------------------------<
| | |
| HP|MV|MP | Skill |Value |Exp |
| ~~+~~+~~ | ~~~~~~~~~~~+~~~~~~~~~~~+~~~~~~~~~~~ |
| **|**|** | Shooting |12 |550/1200 |
| |**|* | Herbalism |14 |990/1400 |
| |* | | Smithing |9 |205/900 |
| | |
| v2 |
------------------------------------------------
The marked forms have been replaced with EvCells of text and with EvTables. The literal marker `v&`
was replaced with `v2`.
If you change the form layout on disk, you can use `form.reload()` to re-read it from disk without
creating a new form.
If you want to update the data of an existing form, you can use `form.map()` with the changes - the
mappings will be updated, keeping the things you want. You can also update the template itself this
way, by supplying it as a dict.
Each component (except literal mappings) is restrained to the width and height specified by the
template, so it will resize to fit (or crop text if the area is too small for it). If you try to fit
a table into an area it cannot fit into (when including its borders and at least one line of text),
the form will raise an error.
----
"""
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
# non-valid form-identifying characters (which can thus be
# used as separators between forms without being detected
# as an identifier). These should be listed in regex form.
INVALID_FORMCHARS = r"\s\/\|\\\*\_\-\#\<\>\~\^\:\;\.\,"
# if there is an ansi-escape (||) we have to replace this with ||| to make sure
# to properly escape down the line
_ANSI_ESCAPE = re.compile(r"\|\|")
[docs]class EvForm:
"""
This object is instantiated with a text file and parses
it for rectangular form fields. It can then be fed a
mapping so as to populate the fields with fixed-width
EvCell or Tables.
"""
# cell option defaults
cell_options = {
"pad_left": 0,
"pad_right": 0,
"pad_top": 0,
"pad_bottom": 0,
"align": "l",
"valign": "t",
"enforce_size": True,
}
# table option defaults
table_options = {
"pad_left": 0,
"pad_right": 0,
"pad_top": 0,
"pad_bottom": 0,
"align": "l",
"valign": "t",
"enforce_size": True,
}
[docs] def __init__(self, data=None, cells=None, tables=None, literals=None, **kwargs):
"""
Initiate the form
Keyword Args:
data (str or dict): Path to template file or a dict with
"formchar", "tablechar" and "form" keys (not case sensitive, so FORM etc
also works, to stay compatible with the in-file names). While "form/FORM"
is required, if FORMCHAR/TABLECHAR are not given, they will default to
'x' and 'c' respectively.
cells (dict): A dictionary mapping `{id: str}` or `{id: evennia.utils.evtable.EvCell}`.
By pre-creating the `EvCell` like `EvCell("text", align="c", valign="t")` you can
control the cell contents' alignment. Note that cell width/height will be enforced
by the form so will be overridden even if specified.
tables (dict): A dictionary mapping `{id: EvTable}`.
literals (dict): A dictionary mapping `{id: str}`. Will be replaced
after width of form is calculated, but before cells/tables are mapped.
All occurrences of the identifier on the form will be replaced. Note
that there is no length-restriction on the remap, you are responsible
for not breaking the form.
Notes:
Other kwargs are fed as options to the EvCells and EvTables
(see `evtable.EvCell` and `evtable.EvTable` for more info).
"""
self.indata = data # storing here so we can reload later in case of a filename
self.options = self._parse_inkwargs(**kwargs)
self.cells_mapping = (
dict((to_str(key), value) for key, value in cells.items()) if cells else {}
)
self.tables_mapping = (
dict((to_str(key), value) for key, value in tables.items()) if tables else {}
)
self.literals_mapping = (
dict((to_str(key), to_str(value)) for key, value in literals.items())
if literals
else {}
)
# work arrays
self.literal_form = ""
self.mapping = {}
self.matrix = []
self.form = []
# will parse and build the form
self.reload()
def _parse_indata(self):
"""
Parse and validate the `self.indata` property. We do this in order to be able to
re-load the evform module if indata is a filename and catch any on-file changes.
Returns:
dict: The data dict parsed/generated from the in-data.
"""
data = self.indata
default_formchar = "x"
default_tablechar = "c"
if isinstance(data, str):
# a module path - read all variables from it
data = all_from_module(data)
if isinstance(data, dict):
data = {
"form": str(data.get("form", data.get("FORM", None))),
"formchar": str(data.get("formchar", data.get("FORMCHAR", default_formchar))),
"tablechar": str(data.get("tablechar", data.get("TABLECHAR", default_tablechar))),
}
else:
raise RuntimeError(f"EvForm invalid input: {data}.")
if not data or data["form"] is None:
raise RuntimeError("Evform data must specify a valid 'form' or 'FORM'.")
# handle empty or multi-character form/tablechars (not supported)
data["formchar"] = data["formchar"][0] if data["formchar"] else default_formchar
data["tablechar"] = data["tablechar"][0] if data["tablechar"] else default_tablechar
if re.match(rf"[{INVALID_FORMCHARS}]", data["formchar"]):
raise RuntimeError(f"Invalid formchar: {data['formchar']}")
if re.match(rf"[{INVALID_FORMCHARS}]", data["tablechar"]):
raise RuntimeError(f"Invalid tablechar: {data['tablechar']}")
return data
def _parse_inkwargs(self, **kwargs):
"""
Validate incoming kwargs that will be passed on to become cell/table options.
Keyword Args:
any: Kwargs to process.
Returns:
dict: A validated/cleaned kwarg to use for options.
"""
if "filename" in kwargs:
raise DeprecationWarning(
"EvForm's 'filename' kwarg was renamed to 'data' and can now accept both "
"a python path and a dict with 'FORMCHAR', 'TABLECHAR' and 'FORM' keys."
)
if "form" in kwargs:
raise DeprecationWarning(
"EvForms's 'form' kwarg was renamed to 'data' and can now accept both "
"a python path and a dict detailing the form."
)
# clean cell kwarg options (these cannot be overridden on the cell but must be controlled
# by the evform itself)
kwargs.pop("enforce_size", None)
kwargs.pop("width", None)
kwargs.pop("height", None)
return kwargs
def _do_literal_mapping(self):
"""
Do literal replacement in the EvForm.
"""
literal_form = copy(self.data["form"])
for key, repl in self.literals_mapping.items():
literal_form = literal_form.replace(key, repl)
return literal_form
def _parse_to_matrix(self):
"""
Forces all lines to be as long as the longest line, filling with whitespace.
Args:
lines (list): list of `ANSIString`s
Returns:
(list): list of `ANSIString`s of
same length as the longest input line
"""
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():
# the first line is normally empty, we strip it.
matrix = matrix[1:]
return matrix
@staticmethod
def _to_ansi(obj, regexable=False):
"convert anything to ANSIString"
if isinstance(obj, ANSIString):
return obj
elif isinstance(obj, str):
# since ansi will be parsed twice (here and in the normal ansi send), we have to
# escape ansi twice.
obj = ansi_raw(obj)
if isinstance(obj, dict):
return dict(
(key, EvForm._to_ansi(value, regexable=regexable)) for key, value in obj.items()
)
# regular _to_ansi (from EvTable)
elif is_iter(obj):
return [EvForm._to_ansi(o) for o in obj]
else:
return ANSIString(obj, regexable=regexable)
def _rectangles_to_mapping(self):
"""
Parse a form for rectangular formfields identified by formchar/tablechar enclosing an
identifier.
"""
formchar = self.data["formchar"]
tablechar = self.data["tablechar"]
matrix = self.matrix
cell_options = copy(self.cell_options)
cell_options.update(self.options)
table_options = copy(self.table_options)
table_options.update(self.options)
nmatrix = len(matrix)
mapping = {}
def _get_rectangles(char):
"""Find all identified rectangles marked with given char"""
rects = []
coords = {}
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(matrix, regexable=True)):
ix0 = 0
while True:
match = regex.search(line, ix0)
if match:
# get the width of the rectangle directly from the match
coords[match.group(1)] = [iy, match.start(), match.end()]
ix0 = match.end()
else:
break
for key, (iy, leftix, rightix) in coords.items():
# scan up to find top of rectangle
dy_up = 0
if iy > 0:
for i in range(1, iy):
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 < 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
# we have our rectangle. Calculate size
iyup = iy - dy_up
iydown = iy + dy_down
width = rightix - leftix
height = abs(iyup - iydown) + 1
# store (key, y, x, width, height) of triangle
rects.append((key, iyup, leftix, width, height))
return rects
# Map EvCells into form rectangles
for key, y, x, width, height in _get_rectangles(formchar):
# get data to populate cell
data = self.cells_mapping.get(key, "")
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)
# Map EvTables into form rectangles
for key, y, x, width, height in _get_rectangles(tablechar):
# get EvTable from mapping
table = self.tables_mapping.get(key, None)
if table:
table.reformat(width=width, height=height, **table_options)
else:
table = EvTable(width=width, height=height, **table_options)
mapping[key] = (y, x, width, height, table)
return mapping
def _build_form(self):
"""
Insert cell/table contents into form at given locations to create
the final result.
"""
form = copy(self.matrix)
mapping = self.mapping
for key, (y, x, width, height, cell_or_table) in mapping.items():
# rect is a list of <height> lines, each <width> wide
rect = cell_or_table.get()
for il, rectline in enumerate(rect):
formline = form[y + il]
# insert new content, replacing old
form[y + il] = formline[:x] + rectline + formline[x + width :]
return form
[docs] def reload(self):
"""
Creates the form from a filename or data structure.
Args:
data (str or dict): Can be used to update an existing form using
the same cells/tables provided on initialization or using `.map()`.
Notes:
Kwargs are passed through to Cel creation.
"""
self.data = self._parse_indata()
# Map any literals into the string
self.literal_form = self._do_literal_mapping()
# Create raw form matrix, indexable with (y, x) coords
self.matrix = self._parse_to_matrix()
# parse and identify all rectangles in the form
self.mapping = self._rectangles_to_mapping()
# combine mapping with form template into a final result
self.form = self._build_form()
[docs] def map(self, cells=None, tables=None, data=None, literals=None, **kwargs):
"""
Add mapping for form. This allows for updating an existing
evform.
Args:
cells (dict): A dictionary of {identifier:celltext}. These
will be appended to the existing mappings.
tables (dict): A dictionary of {identifier:table}. Will
be appended to the existing mapping.
data (str or dict): A path to a evform module or a dict with
the needed "FORM", "TABLE/FORMCHAR" keys. Will replace
the originally initialized form.
literals
Keyword Args:
These will be appended to the existing cell/table options.
Notes:
kwargs will be forwarded to tables/cells. See
`evtable.EvCell` and `evtable.EvTable` for info.
"""
if data:
# storing so ._parse_indata will find it during reload
self.indata = data
new_cells = dict((to_str(key), value) for key, value in cells.items()) if cells else {}
self.cells_mapping.update(new_cells)
new_tables = dict((to_str(key), value) for key, value in tables.items()) if tables else {}
self.tables_mapping.update(new_tables)
new_literals = (
dict((to_str(key), to_str(value)) for key, value in literals.items())
if literals
else {}
)
self.literals_mapping.update(new_literals)
self.options.update(self._parse_inkwargs(**kwargs))
# parse and build the form
self.reload()
def __str__(self):
"Prints the form"
return str(ANSIString("\n").join([line for line in self.form]))