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