481 lines
18 KiB
Python
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())
|