diff --git a/CHANGELOG.md b/CHANGELOG.md index ad913c14da..527a5ea5ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -210,7 +210,9 @@ Up requirements to Django 4.0+, Twisted 22+, Python 3.9 or 3.10 - Improve search performance when having many DB-based prototypes via caching. - Remove the `return_parents` kwarg of `evennia.prototypes.spawner.spawn` since it was inefficient and unused. -- Made all id fields BigAutoField for all databases. +- 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). ## Evennia 0.9.5 diff --git a/evennia/utils/evform.py b/evennia/utils/evform.py index 6f3e97e57d..bc725f9693 100644 --- a/evennia/utils/evform.py +++ b/evennia/utils/evform.py @@ -2,15 +2,13 @@ """ 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. +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. +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`: @@ -38,56 +36,68 @@ FORM = ''' | cccccccc | ccccccccccccccccccccccccccccccccccc | | cccccccc | cccccccccccccccccBccccccccccccccccc | | | | +| v& | ------------------------------------------------- ''' ``` -The first line of the `FORM` string is ignored. 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 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. -Use as follows: +Usage ```python - from evennia import EvForm, EvTable +from evennia import EvForm, EvTable - # create a new form from the template - form = EvForm("path/to/testform.py") +# create a new form from the template +form = EvForm("path/to/testform.py") - # 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) +# alteratively, you can supply the template as a 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") - # add the tables to the proper ids in the form - form.map(tables={"A": tableA, - "B": tableB}) +form = EvForm({"FORM": "....", "TABLECHAR": "c", "FORMCHAR": "x"}) - print(form) +# 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: @@ -113,27 +123,30 @@ This produces the following result: | |**|* | Herbalism |14 |990/1400 | | |* | | Smithing |9 |205/900 | | | | + | v2 | ------------------------------------------------ -The marked forms have been replaced with EvCells of text and with -EvTables. The form can be updated by simply re-applying `form.map()` -with the updated data. +The marked forms have been replaced with EvCells of text and with EvTables. The literal marker `v&` +was replaced with `v2`. -When working with the template ASCII file, you can use `form.reload()` -to re-read the template and re-apply all existing mappings. +If you change the form layout on disk, you can use `form.reload()` to re-read it from disk without +creating a new form. -Each component 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. +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 copy import re +from copy import copy from evennia.utils.ansi import ANSIString from evennia.utils.evtable import EvCell, EvTable @@ -142,7 +155,6 @@ 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 @@ -180,7 +192,7 @@ class EvForm: "enforce_size": True, } - def __init__(self, data=None, cells=None, tables=None, **kwargs): + def __init__(self, data=None, cells=None, tables=None, literals=None, **kwargs): """ Initiate the form @@ -190,8 +202,13 @@ class EvForm: 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: text}` + 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 @@ -207,10 +224,16 @@ class EvForm: 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.raw_form = [] + self.matrix = [] self.form = [] # will parse and build the form @@ -286,7 +309,18 @@ class EvForm: return kwargs - def _parse_to_raw_form(self): + 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. @@ -298,13 +332,13 @@ class EvForm: same length as the longest input line """ - raw_form = EvForm._to_ansi(self.data["form"].split("\n")) - maxl = max(len(line) for line in raw_form) - raw_form = [line + " " * (maxl - len(line)) for line in raw_form] - if raw_form and not raw_form[0].strip(): + 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. - raw_form = raw_form[1:] - return raw_form + matrix = matrix[1:] + return matrix @staticmethod def _to_ansi(obj, regexable=False): @@ -336,12 +370,12 @@ class EvForm: """ formchar = self.data["formchar"] tablechar = self.data["tablechar"] - form = self.raw_form + form = self.matrix - cell_options = copy.copy(self.cell_options) + cell_options = copy(self.cell_options) cell_options.update(self.options) - table_options = copy.copy(self.table_options) + table_options = copy(self.table_options) table_options.update(self.options) nform = len(form) @@ -426,7 +460,7 @@ class EvForm: the final result. """ - form = copy.copy(self.raw_form) + form = copy(self.matrix) mapping = self.mapping for key, (y, x, width, height, cell_or_table) in mapping.items(): @@ -454,14 +488,16 @@ class EvForm: """ 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.raw_form = self._parse_to_raw_form() + 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, **kwargs): + def map(self, cells=None, tables=None, data=None, literals=None, **kwargs): """ Add mapping for form. This allows for updating an existing evform. @@ -474,6 +510,7 @@ class EvForm: 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. @@ -488,9 +525,15 @@ class EvForm: self.indata = data new_cells = dict((to_str(key), value) for key, value in cells.items()) if cells else {} - new_tables = dict((to_str(key), value) for key, value in tables.items()) if tables 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)) diff --git a/evennia/utils/tests/test_evform.py b/evennia/utils/tests/test_evform.py index 5c825ae33f..a9a6453b0b 100644 --- a/evennia/utils/tests/test_evform.py +++ b/evennia/utils/tests/test_evform.py @@ -48,10 +48,10 @@ class TestEvForm(TestCase): form.map(tables={"A": tableA, "B": tableB}) return str(form) - def _simple_form(self, form): + def _simple_form(self, form, literals=None): cellsdict = {1: "Apple", 2: "Banana", 3: "Citrus", 4: "Durian"} formdict = {"FORMCHAR": "x", "TABLECHAR": "c", "FORM": form} - form = evform.EvForm(formdict) + form = evform.EvForm(formdict, literals=literals) form.map(cells=cellsdict) form = ansi.strip_ansi(str(form)) # this is necessary since editors/black tend to strip lines spaces @@ -166,6 +166,18 @@ Durian result = self._simple_form(form) self.assertEqual(expected, result) + def test_literal_replacement(self): + form = """ +xxxx1xxxx xxxx2xxxx xxxx3xxxx +xxxx4xxxx v& + """ + expected = """ +Apple Banana Citrus +Durian v2 +""".lstrip() + result = self._simple_form(form, literals={"v&": "v2"}) + self.assertEqual(expected, result) + # test of issue #2308