create-mekanism-modular/tools/MATERIAL_EDITOR.py

481 lines
18 KiB
Python

#!/usr/bin/env python3
"""Edit the material definitions in `materials.py` without opening it.
The materials are Python, not data: an entry is an `M(...)` call, its
ingredients are usually `ingots(...)` rather than a list, and its effects are
`speed(-0.12)` rather than a dictionary. A generated JSON file is downstream of
all that and gets overwritten by the next `generate_materials.py` run, so this
edits the source instead.
It does that by rewriting one argument at a time, in place. Each field shows
the *source text* of the argument it stands for rather than a rendering of its
value, and saving replaces exactly that span of the file - so `ingots("mekanism",
"ingot_tin", ...)` stays a call, the comments under every material stay where
they were, and a diff shows the number that changed and nothing else.
tools/MATERIAL_EDITOR.py
Materials built by a loop rather than written out - the dragon scales, the gem
families - have no literal `M(...)` to edit and are shown read-only, because the
thing to change for those is the loop.
"""
from __future__ import annotations
import argparse
import ast
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import guiplatform
guiplatform.configure(prefer=os.environ.get('ARMOUR_GUI_PLATFORM'), quiet=True)
from PySide6 import QtCore, QtGui, QtWidgets # noqa: E402
TOOLS = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(TOOLS)
SOURCE = os.path.join(TOOLS, 'materials.py')
# The signature of M(), in order, with the type each argument carries. The type
# is shown beside the field because these are Python literals being edited as
# text: "3" and "3.0" are not the same thing to a stat that is declared a float,
# and a tuple of one string needs its trailing comma.
POSITIONAL = ['name', 'pack', 'group', 'translation', 'palette_from', 'items',
'tier', 'hardness', 'density', 'flexibility', 'durability',
'enchantability', 'mining_speed']
FIELDS = [
('name', 'str'), ('pack', 'str'), ('group', 'str'),
('translation', 'str'), ('palette_from', 'str'), ('icon', 'str | None'),
('items', 'list[dict]'),
('tier', 'int'), ('hardness', 'float'), ('density', 'float'),
('flexibility', 'float'), ('durability', 'int'), ('enchantability', 'int'),
('mining_speed', 'int'), ('mining_level', 'str | None'),
('toughness', 'int'), ('armor_durability', 'int | None'),
('armor_toughness', 'float | None'), ('knockback_resistance', 'float | None'),
('groups', 'tuple[str] | None'), ('hidden_groups', 'tuple[str] | None'),
('textures', 'tuple[str]'), ('properties', 'dict | None'),
]
# The ones worth more than a single line to look at.
TALL = {'items', 'properties', 'groups', 'hidden_groups', 'textures'}
DEFAULTS = {'toughness': '0', 'textures': '("metallic",)', 'properties': '{}',
'groups': 'None', 'hidden_groups': 'None', 'icon': 'None',
'mining_level': 'None', 'armor_durability': 'None',
'armor_toughness': 'None', 'knockback_resistance': 'None'}
# ------------------------------------------------------------------ the source
class MaterialSource:
"""`materials.py`, parsed so that a single argument can be replaced.
Everything is done in bytes. `ast` reports column offsets as byte offsets
into the encoded line, so working in characters would put every span one
place out the first time somebody writes a material with an accent in its
name.
"""
def __init__(self, path=SOURCE):
self.path = path
self.reload()
def reload(self):
with open(self.path, 'rb') as fh:
self.data = fh.read()
self.lines = self.data.split(b'\n')
self.starts, at = [], 0
for line in self.lines:
self.starts.append(at)
at += len(line) + 1
tree = ast.parse(self.data.decode('utf-8'), self.path)
self.calls = {}
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'M' and node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)):
self.calls[node.args[0].value] = node
# Where a new material can be appended: just before the `]` that closes
# the MATERIALS list.
self.list_end = None
for node in ast.walk(tree):
if (isinstance(node, ast.Assign) and node.targets
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == 'MATERIALS'
and isinstance(node.value, ast.List)):
self.list_end = self._offset(node.value.end_lineno,
node.value.end_col_offset) - 1
def _offset(self, lineno, col):
return self.starts[lineno - 1] + col
def span(self, node):
return (self._offset(node.lineno, node.col_offset),
self._offset(node.end_lineno, node.end_col_offset))
def names(self):
return set(self.calls)
def argument(self, name, field):
"""The node for one argument of one material, or None if not passed."""
call = self.calls.get(name)
if call is None:
return None
for kw in call.keywords:
if kw.arg == field:
return kw.value
if field in POSITIONAL:
index = POSITIONAL.index(field)
if index < len(call.args):
return call.args[index]
return None
def text(self, name, field):
node = self.argument(name, field)
if node is None:
return None
start, end = self.span(node)
return self.data[start:end].decode('utf-8')
# ------------------------------------------------------------- writing
def apply(self, edits):
"""Replace argument spans. `edits` is [(material, field, source)].
Applied back to front so that an earlier edit never moves a later
one's offsets, and re-parsed afterwards so the next edit is measured
against what is now on disk.
"""
patches = []
for name, field, new in edits:
node = self.argument(name, field)
if node is not None:
start, end = self.span(node)
patches.append((start, end, new.encode('utf-8')))
else:
patches.append(self._insert_keyword(name, field, new))
data = self.data
for start, end, blob in sorted(patches, key=lambda p: -p[0]):
data = data[:start] + blob + data[end:]
with open(self.path, 'wb') as fh:
fh.write(data)
self.reload()
def _insert_keyword(self, name, field, new):
"""A keyword the call does not pass yet, added before its closing paren."""
call = self.calls[name]
last = call.keywords[-1].value if call.keywords else call.args[-1]
_, end = self.span(last)
return (end, end, f', {field}={new}'.encode('utf-8'))
def add_material(self, source):
"""Append a whole `M(...)` call to the end of MATERIALS."""
if self.list_end is None:
raise RuntimeError('could not find the end of MATERIALS')
blob = ('\n ' + source.strip().rstrip(',') + ',\n').encode('utf-8')
data = self.data[:self.list_end] + blob + self.data[self.list_end:]
with open(self.path, 'wb') as fh:
fh.write(data)
self.reload()
TEMPLATE = '''M("{name}", "{pack}", "metal", "{title}", "{pack}:ingot_{name}",
ingots("{pack}", "ingot_{name}"),
tier=3, hardness=5.0, density=4.0, flexibility=1, durability=300,
enchantability=12, mining_speed=6)'''
# ----------------------------------------------------------------------- window
class MaterialEditor(QtWidgets.QMainWindow):
def __init__(self, source, materials):
super().__init__()
self.source = source
self.materials = materials
self.pending = {}
self.current = None
self.setWindowTitle('materials')
splitter = QtWidgets.QSplitter()
splitter.addWidget(self._left())
splitter.addWidget(self._right())
splitter.setSizes([320, 780])
self.setCentralWidget(splitter)
self._fill_list()
self._status()
# -------------------------------------------------------------- panes
def _left(self):
box = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(box)
self.filter = QtWidgets.QLineEdit()
self.filter.setPlaceholderText('filter by name, pack or group')
self.filter.textChanged.connect(self._fill_list)
lay.addWidget(self.filter)
self.list = QtWidgets.QListWidget()
self.list.currentItemChanged.connect(self._select)
lay.addWidget(self.list, 1)
row = QtWidgets.QHBoxLayout()
for label, slot in (('Add', self.on_add), ('Save', self.on_save),
('Revert', self.on_revert)):
button = QtWidgets.QPushButton(label)
button.clicked.connect(slot)
row.addWidget(button)
lay.addLayout(row)
self.jars = QtWidgets.QLineEdit(os.path.expanduser('~/.cache/abdelpak-jars'))
lay.addWidget(QtWidgets.QLabel('mod jars, for regenerating the JSON'))
lay.addWidget(self.jars)
regen = QtWidgets.QPushButton('Regenerate material JSON')
regen.clicked.connect(self.on_regenerate)
lay.addWidget(regen)
return box
def _right(self):
outer = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(outer)
self.heading = QtWidgets.QLabel('-')
font = self.heading.font()
font.setBold(True)
self.heading.setFont(font)
lay.addWidget(self.heading)
self.note = QtWidgets.QLabel('')
self.note.setWordWrap(True)
self.note.setStyleSheet('color: #b08;')
lay.addWidget(self.note)
scroll = QtWidgets.QScrollArea()
scroll.setWidgetResizable(True)
inner = QtWidgets.QWidget()
form = QtWidgets.QGridLayout(inner)
form.setColumnStretch(1, 1)
self.editors = {}
for row, (field, kind) in enumerate(FIELDS):
form.addWidget(QtWidgets.QLabel(field), row, 0)
if field in TALL:
widget = QtWidgets.QPlainTextEdit()
widget.setFixedHeight(58)
widget.textChanged.connect(lambda f=field: self._changed(f))
else:
widget = QtWidgets.QLineEdit()
widget.textChanged.connect(lambda _=None, f=field: self._changed(f))
widget.setFont(QtGui.QFont('monospace'))
form.addWidget(widget, row, 1)
# The type sits at the end of the field, because these are literals
# typed by hand and "3" against "3.0" is a real difference.
kind_label = QtWidgets.QLabel(kind)
kind_label.setStyleSheet('color: #888;')
form.addWidget(kind_label, row, 2)
self.editors[field] = widget
scroll.setWidget(inner)
lay.addWidget(scroll, 1)
return outer
# ------------------------------------------------------------ contents
def _fill_list(self):
want = self.filter.text().strip().lower()
self.list.blockSignals(True)
self.list.clear()
for mat in self.materials:
label = f"{mat['name']} [{mat['pack']} / {mat['group']}]"
if want and want not in label.lower():
continue
item = QtWidgets.QListWidgetItem(label)
item.setData(QtCore.Qt.UserRole, mat['name'])
if mat['name'] not in self.source.names():
item.setForeground(QtGui.QColor('#888'))
self.list.addItem(item)
self.list.blockSignals(False)
if self.list.count():
self.list.setCurrentRow(0)
def _select(self, item, _previous=None):
if item is None:
return
self.current = item.data(QtCore.Qt.UserRole)
editable = self.current in self.source.names()
self.heading.setText(self.current)
self.note.setText('' if editable else
'Built by a loop rather than written out - read only '
'here; edit the loop that makes it.')
self._loading = True
for field, _kind in FIELDS:
text = self.source.text(self.current, field) if editable else None
key = (self.current, field)
if key in self.pending:
text = self.pending[key]
shown = text if text is not None else DEFAULTS.get(field, '')
widget = self.editors[field]
widget.setReadOnly(not editable)
if isinstance(widget, QtWidgets.QPlainTextEdit):
widget.setPlainText(shown)
else:
widget.setText(shown)
self._loading = False
self._status()
def _value(self, field):
widget = self.editors[field]
if isinstance(widget, QtWidgets.QPlainTextEdit):
return widget.toPlainText().strip()
return widget.text().strip()
def _changed(self, field):
if getattr(self, '_loading', False) or self.current is None:
return
if self.current not in self.source.names():
return
new = self._value(field)
old = self.source.text(self.current, field)
key = (self.current, field)
# An untouched optional argument stays untouched: writing `toughness=0`
# into every material that never mentioned it would be a diff of noise.
if new == (old if old is not None else DEFAULTS.get(field, '')):
self.pending.pop(key, None)
else:
self.pending[key] = new
self._mark(field, ok=self._parses(new))
self._status()
@staticmethod
def _parses(text):
if not text:
return False
try:
ast.parse(text, mode='eval')
return True
except SyntaxError:
return False
def _mark(self, field, ok):
widget = self.editors[field]
widget.setStyleSheet('' if ok else 'background: #5a2230;')
def _status(self):
bad = [f'{n}.{f}' for (n, f), v in self.pending.items() if not self._parses(v)]
msg = f'{len(self.pending)} unsaved change(s)' if self.pending else 'no changes'
if bad:
msg += f' - will not parse: {", ".join(bad)}'
self.statusBar().showMessage(msg)
# ------------------------------------------------------------- actions
def on_save(self):
bad = [k for k, v in self.pending.items() if not self._parses(v)]
if bad:
QtWidgets.QMessageBox.warning(
self, 'materials',
'These are not valid Python and were not saved:\n\n'
+ '\n'.join(f'{n}.{f}' for n, f in bad))
return
if not self.pending:
return
edits = [(n, f, v) for (n, f), v in self.pending.items()]
self.source.apply(edits)
self.pending.clear()
self._select(self.list.currentItem())
self.statusBar().showMessage(
f'wrote {len(edits)} change(s) to {_short(self.source.path)}')
def on_revert(self):
self.pending.clear()
self._select(self.list.currentItem())
def on_add(self):
name, ok = QtWidgets.QInputDialog.getText(
self, 'new material', 'id, lowercase with underscores:')
if not ok or not name.strip():
return
name = name.strip()
if name in self.source.names():
QtWidgets.QMessageBox.warning(self, 'materials',
f'{name} already exists.')
return
pack, ok = QtWidgets.QInputDialog.getText(self, 'new material',
'pack:', text='mekanism')
if not ok:
return
self.source.add_material(TEMPLATE.format(
name=name, pack=pack.strip() or 'mekanism',
title=name.replace('_', ' ').title()))
self.materials = load_materials(self.source.path)
self._fill_list()
for row in range(self.list.count()):
if self.list.item(row).data(QtCore.Qt.UserRole) == name:
self.list.setCurrentRow(row)
break
self.statusBar().showMessage(f'added {name} - it still needs real items '
f'and stats')
def on_regenerate(self):
folder = self.jars.text().strip()
if not os.path.isdir(folder):
QtWidgets.QMessageBox.warning(self, 'materials',
f'not a folder: {folder}')
return
self.statusBar().showMessage('regenerating...')
QtWidgets.QApplication.processEvents()
run = subprocess.run([sys.executable,
os.path.join(TOOLS, 'generate_materials.py'),
'--jars', folder],
capture_output=True, text=True, cwd=REPO)
tail = (run.stdout + run.stderr).strip().splitlines()
self.statusBar().showMessage(tail[-1] if tail else 'done')
if run.returncode:
QtWidgets.QMessageBox.warning(self, 'generate_materials.py',
'\n'.join(tail[-25:]))
def _short(path):
"""Repo-relative when it is in the repo, absolute when it is not."""
rel = os.path.relpath(path, REPO)
return path if rel.startswith(os.pardir) else rel
def load_materials(path=SOURCE):
"""The evaluated list, which is the only thing that knows the full set.
Loaded from the same file that is being edited, and freshly each time - a
material added during the session has to appear in the list, and a listing
taken from a different file than the edits go to would be a quiet lie.
"""
import importlib.util
spec = importlib.util.spec_from_file_location('_materials_under_edit', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return list(module.MATERIALS)
def main(argv=None):
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--source', default=SOURCE, help='materials.py to edit')
args = ap.parse_args(argv)
app = QtWidgets.QApplication(sys.argv[:1])
window = MaterialEditor(MaterialSource(args.source),
load_materials(args.source))
window.resize(1180, 760)
window.show()
return app.exec()
if __name__ == '__main__':
sys.exit(main())