exp-runtime-creator 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- exp_runtime/creator/__init__.py +8 -0
- exp_runtime/creator/__main__.py +69 -0
- exp_runtime/creator/assets.py +56 -0
- exp_runtime/creator/catalog.py +30 -0
- exp_runtime/creator/compiler.py +448 -0
- exp_runtime/creator/debugger.py +123 -0
- exp_runtime/creator/diagnostics.py +86 -0
- exp_runtime/creator/editing.py +80 -0
- exp_runtime/creator/expressions.py +81 -0
- exp_runtime/creator/features.py +90 -0
- exp_runtime/creator/graph.py +124 -0
- exp_runtime/creator/kiwi-hammer.svg +73 -0
- exp_runtime/creator/live.py +116 -0
- exp_runtime/creator/mcp_results.py +53 -0
- exp_runtime/creator/mcp_server.py +428 -0
- exp_runtime/creator/mcp_types.py +49 -0
- exp_runtime/creator/media.py +144 -0
- exp_runtime/creator/minigames.py +149 -0
- exp_runtime/creator/model.py +377 -0
- exp_runtime/creator/native.py +174 -0
- exp_runtime/creator/operations.py +83 -0
- exp_runtime/creator/panels.py +221 -0
- exp_runtime/creator/playtest.py +185 -0
- exp_runtime/creator/preview.py +30 -0
- exp_runtime/creator/schema.py +102 -0
- exp_runtime/creator/sharing.py +169 -0
- exp_runtime/creator/ui.py +1041 -0
- exp_runtime/creator/workspace.py +271 -0
- exp_runtime_creator-0.6.0.dist-info/METADATA +75 -0
- exp_runtime_creator-0.6.0.dist-info/RECORD +34 -0
- exp_runtime_creator-0.6.0.dist-info/WHEEL +5 -0
- exp_runtime_creator-0.6.0.dist-info/entry_points.txt +5 -0
- exp_runtime_creator-0.6.0.dist-info/licenses/LICENSE +21 -0
- exp_runtime_creator-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Round-trip authored graphs or edit lossless native basic blocks.
|
|
2
|
+
|
|
3
|
+
Original bytecode is never guessed into dialogue or flattened past unknown
|
|
4
|
+
services. Fixed instruction counts preserve every native PC and callback.
|
|
5
|
+
"""
|
|
6
|
+
import base64
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
|
|
9
|
+
from exp_runtime.authored import authoring_record
|
|
10
|
+
from exp_runtime.exp import ExpArchive, Metadata, encode_exp
|
|
11
|
+
from exp_runtime.decode.bytecode import OPERAND_OPCODES, decode_program
|
|
12
|
+
from .model import Asset, Node, Project, ProjectError, project_from_record, validate_structure
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resources(project):
|
|
16
|
+
try:
|
|
17
|
+
return {int(key): base64.b64decode(value, validate=True)
|
|
18
|
+
for key, value in project.native['resources'].items()}
|
|
19
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
20
|
+
raise ProjectError('Invalid native resource bank') from error
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def import_exp(data, *, game='shs'):
|
|
24
|
+
archive = ExpArchive(data)
|
|
25
|
+
record = authoring_record(archive)
|
|
26
|
+
if record and record.get('project'):
|
|
27
|
+
document = record['project']
|
|
28
|
+
for asset in document.get('assets', []):
|
|
29
|
+
if asset.get('data') == '@resource':
|
|
30
|
+
asset['data'] = base64.b64encode(archive.read(asset['id'])).decode('ascii')
|
|
31
|
+
project = project_from_record(document)
|
|
32
|
+
project.library = ''
|
|
33
|
+
return project
|
|
34
|
+
meta = archive.metadata()
|
|
35
|
+
resources = {key: archive.read(key) for key in archive.entries}
|
|
36
|
+
project = Project(title=meta.title, game=game, pack_id=meta.pack_id, episode_id=meta.episode_id,
|
|
37
|
+
nodes=[], native={'resources': {str(key): base64.b64encode(value).decode('ascii')
|
|
38
|
+
for key, value in resources.items()}})
|
|
39
|
+
project.locales = {language: {'title': title} for language, title in
|
|
40
|
+
zip(('en', 'fr', 'it', 'de', 'es'), meta.titles)}
|
|
41
|
+
for script, program in sorted(archive.programs().items()):
|
|
42
|
+
instructions = program.instructions
|
|
43
|
+
if not instructions:
|
|
44
|
+
continue
|
|
45
|
+
boundaries = {instructions[0].pc}
|
|
46
|
+
pcs = {ins.pc for ins in instructions}
|
|
47
|
+
for ins in instructions:
|
|
48
|
+
target = ins.branch_target()
|
|
49
|
+
if target in pcs:
|
|
50
|
+
boundaries.add(target)
|
|
51
|
+
if target is not None or ins.opcode in (0x1e, 0x1f, 0x33, 0x43):
|
|
52
|
+
if ins.pc + 1 in pcs:
|
|
53
|
+
boundaries.add(ins.pc + 1)
|
|
54
|
+
starts = sorted(boundaries)
|
|
55
|
+
for index, start in enumerate(starts):
|
|
56
|
+
end = starts[index + 1] if index + 1 < len(starts) else instructions[-1].pc + 1
|
|
57
|
+
base = instructions[0].pc
|
|
58
|
+
block = instructions[start - base:end - base]
|
|
59
|
+
last = block[-1]
|
|
60
|
+
targets = []
|
|
61
|
+
if last.branch_target() in pcs:
|
|
62
|
+
targets.append(last.branch_target())
|
|
63
|
+
if last.opcode not in (0x29, 0x33, 0x43) and end in pcs:
|
|
64
|
+
targets.append(end)
|
|
65
|
+
node = Node(f'{script}:{start}', 'native', f'Script {script} · PC {start}',
|
|
66
|
+
x=index % 6 * 290, y=(index // 6 + len(project.nodes) // 6) * 160,
|
|
67
|
+
scene=script, params={'script': script, 'pc': start,
|
|
68
|
+
'instructions': [[ins.opcode, ins.operand] for ins in block],
|
|
69
|
+
'edges': [f'{script}:{pc}' for pc in dict.fromkeys(targets)]})
|
|
70
|
+
project.nodes.append(node)
|
|
71
|
+
if not project.nodes:
|
|
72
|
+
raise ProjectError('EXP has no editable KiWi programs')
|
|
73
|
+
project.start = next((n.id for n in project.nodes if n.scene == 25001), project.nodes[0].id)
|
|
74
|
+
project.assets = [Asset(key, f'Resource {key}', value) for key, value in resources.items()
|
|
75
|
+
if value.startswith((b'\x89PNG\r\n\x1a\n', b'\xff\xd8'))]
|
|
76
|
+
from .editing import auto_layout
|
|
77
|
+
auto_layout(project)
|
|
78
|
+
validate_structure(project)
|
|
79
|
+
return project
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def compile_native(project):
|
|
83
|
+
resources = _resources(project)
|
|
84
|
+
programs = {key: decode_program(value) for key, value in resources.items() if value.startswith(b'kiwi')}
|
|
85
|
+
originals = {key: {ins.pc: ins for ins in program.instructions} for key, program in programs.items()}
|
|
86
|
+
edits = {key: {} for key in programs}
|
|
87
|
+
for node in project.nodes:
|
|
88
|
+
if node.kind != 'native':
|
|
89
|
+
raise ProjectError('Native projects require native blocks; create a new project for high-level nodes')
|
|
90
|
+
p = node.params
|
|
91
|
+
try:
|
|
92
|
+
script, start, rows = p['script'], p['pc'], p['instructions']
|
|
93
|
+
if type(start) is not int or type(script) is not int or not isinstance(rows, list) or not rows:
|
|
94
|
+
raise ValueError('invalid block')
|
|
95
|
+
original = originals[script]
|
|
96
|
+
for index, row in enumerate(rows):
|
|
97
|
+
pc = start + index
|
|
98
|
+
op, arg = row
|
|
99
|
+
if pc not in original or pc in edits[script] or type(op) is not int or not 0 <= op <= 255:
|
|
100
|
+
raise ValueError('overlapping block or invalid PC/opcode')
|
|
101
|
+
if (op in OPERAND_OPCODES and (type(arg) is not int or not 0 <= arg <= 65535)
|
|
102
|
+
or op not in OPERAND_OPCODES and arg is not None):
|
|
103
|
+
raise ValueError('operand does not match opcode width')
|
|
104
|
+
edits[script][pc] = replace(original[pc], opcode=op, operand=arg)
|
|
105
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
106
|
+
raise ProjectError(f'{node.label}: invalid native block: {error}') from error
|
|
107
|
+
for script, program in programs.items():
|
|
108
|
+
if set(edits[script]) != {ins.pc for ins in program.instructions}:
|
|
109
|
+
raise ProjectError('Preserve every instruction in an imported native project')
|
|
110
|
+
resources[script] = replace(program, instructions=tuple(edits[script][ins.pc] for ins in program.instructions)).to_bytes()
|
|
111
|
+
for asset in project.assets:
|
|
112
|
+
if asset.data is not None:
|
|
113
|
+
resources[asset.id] = asset.data
|
|
114
|
+
old = ExpArchive(encode_exp(resources)).metadata()
|
|
115
|
+
titles = tuple(project.title if lang == 'en' and project.title != old.title else
|
|
116
|
+
project.locales.get(lang, {}).get('title', project.title)
|
|
117
|
+
for lang in ('en', 'fr', 'it', 'de', 'es'))
|
|
118
|
+
if (old.pack_id, old.episode_id, old.titles) != (project.pack_id, project.episode_id, titles):
|
|
119
|
+
resources[1] = Metadata(project.pack_id, project.episode_id, titles).to_bytes()
|
|
120
|
+
return encode_exp(resources)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def validate_native(project):
|
|
124
|
+
compile_native(project)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def set_data_words(project, script, words, *, extra=False):
|
|
128
|
+
"""Explicit data-bank editing; addresses and bank lengths remain stable."""
|
|
129
|
+
resources = _resources(project)
|
|
130
|
+
program = decode_program(resources[script])
|
|
131
|
+
key = 'extra_words' if extra else 'main_words'
|
|
132
|
+
if (len(words) != len(getattr(program, key))
|
|
133
|
+
or any(type(word) is not int or not 0 <= word <= 65535 for word in words)):
|
|
134
|
+
raise ProjectError('Keep the original data-bank length and use unsigned 16-bit words')
|
|
135
|
+
resources[script] = replace(program, **{key: tuple(words)}).to_bytes()
|
|
136
|
+
project.native['resources'][str(script)] = base64.b64encode(resources[script]).decode('ascii')
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def packed_strings(project, script):
|
|
140
|
+
"""Offer printable packed-text candidates with exact main-bank addresses.
|
|
141
|
+
|
|
142
|
+
Data words are untyped; this list is a viewing aid, never decompilation.
|
|
143
|
+
"""
|
|
144
|
+
import struct
|
|
145
|
+
program = decode_program(_resources(project)[script])
|
|
146
|
+
data = struct.pack(f'>{len(program.main_words)}H', *program.main_words)
|
|
147
|
+
result, position = [], 0
|
|
148
|
+
while position < len(data):
|
|
149
|
+
end = data.find(b'\0', position)
|
|
150
|
+
if end < 0: break
|
|
151
|
+
text = data[position:end].decode('latin-1')
|
|
152
|
+
if len(text) >= 2 and all(c.isprintable() or c in '\n\r\t' for c in text):
|
|
153
|
+
result.append({'address': position // 2, 'text': text})
|
|
154
|
+
position = (end + 2) // 2 * 2
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def edit_packed_text(project, script, address, text):
|
|
159
|
+
"""Replace a confirmed main-bank string without relocating other addresses."""
|
|
160
|
+
import struct
|
|
161
|
+
resources = _resources(project)
|
|
162
|
+
program = decode_program(resources[script])
|
|
163
|
+
data = bytearray(struct.pack(f'>{len(program.main_words)}H', *program.main_words))
|
|
164
|
+
if type(address) is not int or not 0 <= address < len(program.main_words):
|
|
165
|
+
raise ProjectError('Text address must identify a main-bank word')
|
|
166
|
+
start = address * 2
|
|
167
|
+
end = data.find(b'\0', start)
|
|
168
|
+
if end < 0: raise ProjectError('Text must have an existing NUL terminator')
|
|
169
|
+
try: encoded = text.encode('latin-1')
|
|
170
|
+
except UnicodeError as error: raise ProjectError('Native text uses Latin-1') from error
|
|
171
|
+
if b'\0' in encoded or len(encoded) > end - start:
|
|
172
|
+
raise ProjectError('Replacement must fit the original string allocation without NULs')
|
|
173
|
+
data[start:end] = encoded + b'\0' * (end - start - len(encoded))
|
|
174
|
+
set_data_words(project, script, list(struct.unpack(f'>{len(program.main_words)}H', data)))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""One transaction vocabulary shared by workspace edits and MCP schemas."""
|
|
2
|
+
from dataclasses import fields
|
|
3
|
+
|
|
4
|
+
from .model import Character, Choice, Node, ProjectError
|
|
5
|
+
from . import graph
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def patch_record(record, changes, allowed):
|
|
9
|
+
if not isinstance(changes, dict) or set(changes) - set(allowed):
|
|
10
|
+
raise ProjectError('Unsupported fields: ' + ', '.join(sorted(set(changes) - set(allowed)))
|
|
11
|
+
if isinstance(changes, dict) else 'Changes must be an object')
|
|
12
|
+
for key, value in changes.items():
|
|
13
|
+
if key == 'options':
|
|
14
|
+
if not isinstance(value, list): raise ProjectError('Options must be a list')
|
|
15
|
+
try: value = [Choice(**option) for option in value]
|
|
16
|
+
except TypeError as error: raise ProjectError(f'Invalid choice: {error}') from error
|
|
17
|
+
setattr(record, key, value)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def apply(workspace, project, operation):
|
|
21
|
+
"""Apply to a disposable candidate. The workspace validates and commits once."""
|
|
22
|
+
if not isinstance(operation, dict): raise ProjectError('An edit operation must be an object')
|
|
23
|
+
op = operation.get('op')
|
|
24
|
+
allowed = {
|
|
25
|
+
'project': {'changes'}, 'node': {'id', 'changes', 'remove'},
|
|
26
|
+
'character': {'id', 'changes', 'remove'}, 'rename_node': {'id', 'new_id'},
|
|
27
|
+
'connect': {'source', 'target', 'field'}, 'image': {'source'},
|
|
28
|
+
'library_image': {'library', 'resource', 'episode'}, 'asset_label': {'id', 'label'}, 'layout': set(),
|
|
29
|
+
}
|
|
30
|
+
if op not in allowed or set(operation) - {'op'} - allowed[op]:
|
|
31
|
+
raise ProjectError('Unknown edit operation or operation fields')
|
|
32
|
+
if op == 'project':
|
|
33
|
+
changes = dict(operation['changes'])
|
|
34
|
+
if 'game' in changes and changes['game'] != project.game and (project.assets or project.native or project.library):
|
|
35
|
+
raise ProjectError('Create a new project to change the game after importing resources')
|
|
36
|
+
if changes.get('library'): changes['library'] = str(workspace.path(changes['library']))
|
|
37
|
+
patch_record(project, changes, {'title', 'game', 'pack_id', 'episode_id', 'start', 'language', 'locales', 'library'})
|
|
38
|
+
elif op in ('node', 'character'):
|
|
39
|
+
key = operation['id']
|
|
40
|
+
if not isinstance(key, str) or not key: raise ProjectError('An edit needs a nonempty ID')
|
|
41
|
+
collection = project.nodes if op == 'node' else project.characters
|
|
42
|
+
record = next((r for r in collection if r.id == key), None)
|
|
43
|
+
if project.native and (op == 'character' or record is None or operation.get('remove')):
|
|
44
|
+
raise ProjectError('Native projects retain their original blocks and character definitions')
|
|
45
|
+
if operation.get('remove'):
|
|
46
|
+
if record is None: raise ProjectError(f'Unknown {op}: {key}')
|
|
47
|
+
collection.remove(record)
|
|
48
|
+
else:
|
|
49
|
+
if record is None:
|
|
50
|
+
record = Node(key) if op == 'node' else Character(key)
|
|
51
|
+
collection.append(record)
|
|
52
|
+
patch_record(record, operation.get('changes', {}), {f.name for f in fields(record)} - {'id'})
|
|
53
|
+
elif op == 'rename_node': graph.rename_node(project, operation['id'], operation['new_id'])
|
|
54
|
+
elif op == 'connect': graph.connect(project, operation['source'], operation['target'], operation.get('field', 'next'))
|
|
55
|
+
elif op == 'image':
|
|
56
|
+
from .model import MAX_ASSET_BYTES
|
|
57
|
+
path = workspace.path(operation['source'])
|
|
58
|
+
if path.stat().st_size > MAX_ASSET_BYTES: raise ProjectError('Image is too large')
|
|
59
|
+
project.add_image(path.name, path.read_bytes())
|
|
60
|
+
elif op == 'library_image':
|
|
61
|
+
from exp_runtime.content import ContentLibrary
|
|
62
|
+
from .assets import import_library_image
|
|
63
|
+
with ContentLibrary(workspace.path(operation['library'])) as content:
|
|
64
|
+
episode = operation.get('episode')
|
|
65
|
+
import_library_image(project, content, operation['resource'], content.open_episode(episode) if episode else None)
|
|
66
|
+
elif op == 'asset_label':
|
|
67
|
+
asset = next((a for a in project.assets if a.id == operation['id']), None)
|
|
68
|
+
if asset is None: raise ProjectError('Import the asset into the project before labeling it')
|
|
69
|
+
asset.name = operation['label']
|
|
70
|
+
elif op == 'layout':
|
|
71
|
+
from .editing import auto_layout
|
|
72
|
+
auto_layout(project)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_removals(before, after):
|
|
76
|
+
removed = {n.id for n in before.nodes} - {n.id for n in after.nodes}
|
|
77
|
+
references = [e for e in graph.edges(after) if e['target'] in removed]
|
|
78
|
+
if after.start in removed or references:
|
|
79
|
+
raise ProjectError('Removed node is still referenced; reconnect its incoming edges and project start in the same batch')
|
|
80
|
+
removed_characters = {c.id for c in before.characters} - {c.id for c in after.characters}
|
|
81
|
+
if any(n.character in removed_characters or n.params.get('left_character') in removed_characters or
|
|
82
|
+
n.params.get('right_character') in removed_characters for n in after.nodes):
|
|
83
|
+
raise ProjectError('Removed character is still referenced by a node')
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Structured authoring dialogs shared by character and minigame inspectors."""
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout,
|
|
6
|
+
QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, QPushButton, QScrollArea,
|
|
7
|
+
QSpinBox, QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget)
|
|
8
|
+
|
|
9
|
+
from .model import ProjectError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
LIST_TEMPLATES = {
|
|
13
|
+
'symbols': ['A', 26000],
|
|
14
|
+
'expressions': None,
|
|
15
|
+
'tutorials': dict(title='Practice', body='Connect the word.', position=0, tap_to_advance=False,
|
|
16
|
+
hide_board=False, width=3, height=3, heading='Practice', prompt='Find CAT',
|
|
17
|
+
words=['CAT'], highlight=True, fixed_board='CAT\nAAA\nAAA', show_hints=True,
|
|
18
|
+
animate=True, timed=False, failure_next=-1, success_next=-2),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
def value_field(value, parent=None, key=None):
|
|
22
|
+
"""Return a widget and a typed getter; containers have their own row editor."""
|
|
23
|
+
if isinstance(value, (dict, list)):
|
|
24
|
+
current = deepcopy(value)
|
|
25
|
+
button = QPushButton(f'Edit {len(value)} ' + ('fields…' if isinstance(value, dict) else 'items…'), parent)
|
|
26
|
+
def edit():
|
|
27
|
+
dialog = (RecordDialog(current, parent) if isinstance(current, dict) else
|
|
28
|
+
ListDialog([None] * 5 if key == 'expressions' and not current else current,
|
|
29
|
+
parent, template=LIST_TEMPLATES.get(key)))
|
|
30
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
31
|
+
current.clear(); current.update(dialog.value) if isinstance(current, dict) else current.extend(dialog.value)
|
|
32
|
+
button.setText(f'Edit {len(current)} items…')
|
|
33
|
+
button.clicked.connect(edit)
|
|
34
|
+
return button, lambda: deepcopy(current)
|
|
35
|
+
if type(value) is bool:
|
|
36
|
+
widget = QCheckBox(parent); widget.setChecked(value)
|
|
37
|
+
return widget, widget.isChecked
|
|
38
|
+
if type(value) is int:
|
|
39
|
+
widget = QSpinBox(parent); widget.setRange(-32768, 65535); widget.setValue(value)
|
|
40
|
+
return widget, widget.value
|
|
41
|
+
widget = QLineEdit('null' if value is None else str(value), parent)
|
|
42
|
+
return widget, (lambda: json.loads(widget.text())) if value is None else widget.text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RecordDialog(QDialog):
|
|
46
|
+
def __init__(self, record, parent=None, title='Edit fields'):
|
|
47
|
+
super().__init__(parent)
|
|
48
|
+
self.setWindowTitle(title); self.resize(560, 650)
|
|
49
|
+
layout = QVBoxLayout(self)
|
|
50
|
+
scroll = QScrollArea(); scroll.setWidgetResizable(True)
|
|
51
|
+
panel = QWidget(); form = QFormLayout(panel); self.getters = {}
|
|
52
|
+
for key, value in record.items():
|
|
53
|
+
field, getter = value_field(value, self, key)
|
|
54
|
+
field.setObjectName(key); self.getters[key] = getter
|
|
55
|
+
form.addRow(key.replace('_', ' ').capitalize(), field)
|
|
56
|
+
scroll.setWidget(panel); layout.addWidget(scroll)
|
|
57
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
58
|
+
buttons.accepted.connect(self.accept); buttons.rejected.connect(self.reject); layout.addWidget(buttons)
|
|
59
|
+
|
|
60
|
+
def accept(self):
|
|
61
|
+
try:
|
|
62
|
+
self.value = {key: getter() for key, getter in self.getters.items()}
|
|
63
|
+
except (ValueError, TypeError) as error:
|
|
64
|
+
QMessageBox.warning(self, 'Invalid value', str(error)); return
|
|
65
|
+
super().accept()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ListDialog(QDialog):
|
|
69
|
+
def __init__(self, values, parent=None, title='Edit items', template=None):
|
|
70
|
+
super().__init__(parent)
|
|
71
|
+
self.value = deepcopy(values); self.template = template
|
|
72
|
+
self.setWindowTitle(title); self.resize(650, 480)
|
|
73
|
+
layout = QVBoxLayout(self); self.items = QListWidget(); layout.addWidget(self.items)
|
|
74
|
+
buttons = QHBoxLayout()
|
|
75
|
+
for label, callback in (('Edit', self.edit), ('Add', self.add), ('Duplicate', self.duplicate),
|
|
76
|
+
('Remove', self.remove), ('Move up', lambda: self.move(-1)),
|
|
77
|
+
('Move down', lambda: self.move(1))):
|
|
78
|
+
button = QPushButton(label); button.clicked.connect(callback); buttons.addWidget(button)
|
|
79
|
+
layout.addLayout(buttons)
|
|
80
|
+
self.items.itemDoubleClicked.connect(self.edit)
|
|
81
|
+
done = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
82
|
+
done.accepted.connect(self.accept); done.rejected.connect(self.reject); layout.addWidget(done)
|
|
83
|
+
self.refresh()
|
|
84
|
+
|
|
85
|
+
def refresh(self, row=0):
|
|
86
|
+
self.items.clear()
|
|
87
|
+
for index, value in enumerate(self.value):
|
|
88
|
+
self.items.addItem(f'{index + 1}. ' + (value.get('name', value.get('heading', str(value)))
|
|
89
|
+
if isinstance(value, dict) else str(value)))
|
|
90
|
+
self.items.setCurrentRow(row)
|
|
91
|
+
|
|
92
|
+
def edit(self, *unused):
|
|
93
|
+
row = self.items.currentRow()
|
|
94
|
+
if row < 0: return
|
|
95
|
+
value = self.value[row]
|
|
96
|
+
if isinstance(value, list):
|
|
97
|
+
dialog = ListDialog(value, self)
|
|
98
|
+
else:
|
|
99
|
+
dialog = RecordDialog(value if isinstance(value, dict) else {'value': value}, self)
|
|
100
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
101
|
+
self.value[row] = dialog.value if isinstance(value, (list, dict)) else dialog.value['value']
|
|
102
|
+
self.refresh(row)
|
|
103
|
+
|
|
104
|
+
def add(self):
|
|
105
|
+
self.value.append(deepcopy(self.template if self.template is not None else self.value[-1] if self.value else ''))
|
|
106
|
+
if isinstance(self.value[-1], dict) and 'id' in self.value[-1] and isinstance(self.value[-1]['id'], str):
|
|
107
|
+
import uuid
|
|
108
|
+
self.value[-1]['id'] = uuid.uuid4().hex[:10]
|
|
109
|
+
self.refresh(len(self.value) - 1); self.edit()
|
|
110
|
+
|
|
111
|
+
def duplicate(self):
|
|
112
|
+
row = self.items.currentRow()
|
|
113
|
+
if row >= 0:
|
|
114
|
+
self.value.insert(row + 1, deepcopy(self.value[row]))
|
|
115
|
+
if isinstance(self.value[row + 1], dict) and isinstance(self.value[row + 1].get('id'), str):
|
|
116
|
+
import uuid
|
|
117
|
+
self.value[row + 1]['id'] = uuid.uuid4().hex[:10]
|
|
118
|
+
self.refresh(row + 1)
|
|
119
|
+
|
|
120
|
+
def remove(self):
|
|
121
|
+
row = self.items.currentRow()
|
|
122
|
+
if row >= 0:
|
|
123
|
+
self.value.pop(row); self.refresh(max(0, row - 1))
|
|
124
|
+
|
|
125
|
+
def move(self, delta):
|
|
126
|
+
row = self.items.currentRow(); other = row + delta
|
|
127
|
+
if 0 <= row < len(self.value) and 0 <= other < len(self.value):
|
|
128
|
+
self.value[row], self.value[other] = self.value[other], self.value[row]; self.refresh(other)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TranslationsDialog(QDialog):
|
|
132
|
+
def __init__(self, project, parent=None):
|
|
133
|
+
from .features import text_fields
|
|
134
|
+
super().__init__(parent)
|
|
135
|
+
self.setWindowTitle('Translations'); self.resize(1000, 600)
|
|
136
|
+
self.locales = deepcopy(project.locales); self.fields = text_fields(project)
|
|
137
|
+
layout = QVBoxLayout(self); self.language = QComboBox()
|
|
138
|
+
for name, code in (('English', 'en'), ('French', 'fr'), ('Italian', 'it'), ('German', 'de'), ('Spanish', 'es')):
|
|
139
|
+
self.language.addItem(name, code)
|
|
140
|
+
self.language.setCurrentIndex(self.language.findData(project.language))
|
|
141
|
+
layout.addWidget(self.language)
|
|
142
|
+
layout.addWidget(QLabel('Empty translations use the original text. Story text uses native Latin-1 encoding.'))
|
|
143
|
+
self.table = QTableWidget(0, 3); self.table.setHorizontalHeaderLabels(['Field', 'Original', 'Translation'])
|
|
144
|
+
layout.addWidget(self.table); self.current = self.language.currentData(); self.populate()
|
|
145
|
+
self.language.currentIndexChanged.connect(self.change)
|
|
146
|
+
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
|
147
|
+
buttons.accepted.connect(self.accept); buttons.rejected.connect(self.reject); layout.addWidget(buttons)
|
|
148
|
+
|
|
149
|
+
def populate(self):
|
|
150
|
+
from PySide6.QtCore import Qt
|
|
151
|
+
self.table.setRowCount(len(self.fields))
|
|
152
|
+
for row, (key, value) in enumerate(self.fields.items()):
|
|
153
|
+
for column, text in enumerate((key, value, self.locales.get(self.current, {}).get(key, ''))):
|
|
154
|
+
item = QTableWidgetItem(text)
|
|
155
|
+
if column < 2: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
|
156
|
+
self.table.setItem(row, column, item)
|
|
157
|
+
self.table.resizeColumnsToContents()
|
|
158
|
+
|
|
159
|
+
def store(self):
|
|
160
|
+
self.locales[self.current] = {key: self.table.item(row, 2).text() for row, key in enumerate(self.fields)
|
|
161
|
+
if self.table.item(row, 2).text()}
|
|
162
|
+
|
|
163
|
+
def change(self):
|
|
164
|
+
self.store(); self.current = self.language.currentData(); self.populate()
|
|
165
|
+
|
|
166
|
+
def accept(self):
|
|
167
|
+
self.store(); super().accept()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class DebugDialog(QDialog):
|
|
171
|
+
def __init__(self, project, parent=None, select=None, start=None):
|
|
172
|
+
from .debugger import DebugSession
|
|
173
|
+
super().__init__(parent)
|
|
174
|
+
self.debug = DebugSession(project, start=start); self.select = select
|
|
175
|
+
self.setWindowTitle('Story debugger'); self.resize(800, 650)
|
|
176
|
+
layout = QVBoxLayout(self); row = QHBoxLayout()
|
|
177
|
+
for label, callback in (('Step node', lambda: self.execute(step=True)), ('Run', self.execute)):
|
|
178
|
+
button = QPushButton(label); button.clicked.connect(callback); row.addWidget(button)
|
|
179
|
+
layout.addLayout(row)
|
|
180
|
+
self.breakpoints = QLineEdit(); self.breakpoints.setPlaceholderText('Breakpoint node IDs, separated by commas')
|
|
181
|
+
self.breakpoints.editingFinished.connect(lambda: setattr(self.debug, 'breakpoints',
|
|
182
|
+
set(value.strip() for value in self.breakpoints.text().split(',') if value.strip())))
|
|
183
|
+
layout.addWidget(self.breakpoints)
|
|
184
|
+
self.output = QTextEdit(); self.output.setReadOnly(True); layout.addWidget(self.output)
|
|
185
|
+
row = QHBoxLayout(); self.answer = QLineEdit(); self.answer.setPlaceholderText('Text, or zero-based choice/word/play index')
|
|
186
|
+
row.addWidget(self.answer)
|
|
187
|
+
button = QPushButton('Submit / continue'); button.clicked.connect(self.submit); row.addWidget(button)
|
|
188
|
+
button = QPushButton('Advance clock 1s'); button.clicked.connect(lambda: self.execute(milliseconds=1000)); row.addWidget(button)
|
|
189
|
+
layout.addLayout(row)
|
|
190
|
+
row = QHBoxLayout(); self.variable = QLineEdit(); self.variable.setPlaceholderText('Numeric variable name')
|
|
191
|
+
self.value = QSpinBox(); self.value.setRange(-32768, 32767)
|
|
192
|
+
row.addWidget(self.variable); row.addWidget(self.value)
|
|
193
|
+
button = QPushButton('Set value'); button.clicked.connect(self.set_variable); row.addWidget(button)
|
|
194
|
+
layout.addLayout(row); self.refresh()
|
|
195
|
+
|
|
196
|
+
def refresh(self):
|
|
197
|
+
state = self.debug.state()
|
|
198
|
+
self.output.setPlainText(json.dumps(state, indent=2, ensure_ascii=False))
|
|
199
|
+
if self.select and state['node']: self.select(state['node'])
|
|
200
|
+
|
|
201
|
+
def execute(self, checked=False, **kwargs):
|
|
202
|
+
try:
|
|
203
|
+
self.debug.resume(**kwargs); self.refresh()
|
|
204
|
+
except ValueError as error:
|
|
205
|
+
QMessageBox.warning(self, 'Execution stopped', str(error)); self.refresh()
|
|
206
|
+
|
|
207
|
+
def submit(self):
|
|
208
|
+
action = self.debug.state()['action']
|
|
209
|
+
value = self.answer.text()
|
|
210
|
+
if action in ('choice', 'word_game', 'football'):
|
|
211
|
+
try: value = int(value)
|
|
212
|
+
except ValueError:
|
|
213
|
+
QMessageBox.warning(self, 'Choose an answer', 'Enter a zero-based answer index.'); return
|
|
214
|
+
elif action != 'text_input': value = None
|
|
215
|
+
self.execute(answer=value, provide_answer=True)
|
|
216
|
+
|
|
217
|
+
def set_variable(self):
|
|
218
|
+
try:
|
|
219
|
+
self.debug.set_variable(self.variable.text(), self.value.value()); self.refresh()
|
|
220
|
+
except ProjectError as error:
|
|
221
|
+
QMessageBox.warning(self, 'Variable', str(error))
|