mirror of
https://github.com/evennia/evennia.git
synced 2026-04-06 07:57:16 +02:00
560 lines
21 KiB
Python
560 lines
21 KiB
Python
# 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"\|\|")
|
|
|
|
|
|
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,
|
|
}
|
|
|
|
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}`
|
|
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
|
|
|
|
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()
|
|
|
|
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]))
|