Note

You are reading an old version of the Evennia documentation. The latest version is here

.

# 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"\|\|")

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

Note

You are reading an old version of the Evennia documentation. The latest version is here

.