ff9mapkit 1.0.0b3__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.
- ff9mapkit/__init__.py +18 -0
- ff9mapkit/__main__.py +36 -0
- ff9mapkit/_animdb.py +2994 -0
- ff9mapkit/_animdb_all.py +14125 -0
- ff9mapkit/_fieldtable.py +1516 -0
- ff9mapkit/_fieldtext.py +845 -0
- ff9mapkit/_held_poses.py +44 -0
- ff9mapkit/_itemdb.py +65 -0
- ff9mapkit/_modeldb.py +725 -0
- ff9mapkit/_narrowmap_data.py +10 -0
- ff9mapkit/_npcparams.py +634 -0
- ff9mapkit/_regen_animdb.py +72 -0
- ff9mapkit/_regen_animdb_all.py +66 -0
- ff9mapkit/_regen_fieldtable.py +95 -0
- ff9mapkit/_regen_fieldtext.py +66 -0
- ff9mapkit/_regen_modeldb.py +67 -0
- ff9mapkit/_regen_npcparams.py +123 -0
- ff9mapkit/_regen_scenedb.py +57 -0
- ff9mapkit/_scenedb.py +869 -0
- ff9mapkit/abilities.py +225 -0
- ff9mapkit/animations.py +120 -0
- ff9mapkit/archetypes.py +218 -0
- ff9mapkit/areatitle.py +76 -0
- ff9mapkit/battle/__init__.py +21 -0
- ff9mapkit/battle/abilityfeatures.py +294 -0
- ff9mapkit/battle/actiondelta.py +441 -0
- ff9mapkit/battle/aiauthor.py +305 -0
- ff9mapkit/battle/ailint.py +140 -0
- ff9mapkit/battle/aipatch.py +175 -0
- ff9mapkit/battle/battleai.py +148 -0
- ff9mapkit/battle/battlecsv.py +390 -0
- ff9mapkit/battle/battlepatch.py +395 -0
- ff9mapkit/battle/build.py +558 -0
- ff9mapkit/battle/camera_codec.py +332 -0
- ff9mapkit/battle/camera_data.py +128 -0
- ff9mapkit/battle/characterdelta.py +789 -0
- ff9mapkit/battle/event_data.py +72 -0
- ff9mapkit/battle/extract.py +540 -0
- ff9mapkit/battle/fbx.py +223 -0
- ff9mapkit/battle/reskin.py +149 -0
- ff9mapkit/battle/scene_codec.py +314 -0
- ff9mapkit/battle/scene_data.py +369 -0
- ff9mapkit/battle/scenelint.py +125 -0
- ff9mapkit/battle/seqasm.py +131 -0
- ff9mapkit/battle/seqauthor.py +220 -0
- ff9mapkit/battle/seqcodec.py +300 -0
- ff9mapkit/battle/seqdis.py +106 -0
- ff9mapkit/battle/seqpatch.py +137 -0
- ff9mapkit/battle_bgm.py +133 -0
- ff9mapkit/binutils.py +60 -0
- ff9mapkit/build.py +5445 -0
- ff9mapkit/campaign.py +1276 -0
- ff9mapkit/catalog.py +316 -0
- ff9mapkit/chain.py +358 -0
- ff9mapkit/cli.py +3114 -0
- ff9mapkit/config.py +360 -0
- ff9mapkit/content/__init__.py +13 -0
- ff9mapkit/content/areatitle.py +36 -0
- ff9mapkit/content/ate.py +118 -0
- ff9mapkit/content/camera.py +123 -0
- ff9mapkit/content/chest.py +186 -0
- ff9mapkit/content/choice.py +163 -0
- ff9mapkit/content/conductor.py +217 -0
- ff9mapkit/content/cutscene.py +290 -0
- ff9mapkit/content/encounter.py +41 -0
- ff9mapkit/content/entry_settle.py +50 -0
- ff9mapkit/content/equipment.py +93 -0
- ff9mapkit/content/event.py +191 -0
- ff9mapkit/content/gateway.py +101 -0
- ff9mapkit/content/inventory.py +59 -0
- ff9mapkit/content/itemdata.py +644 -0
- ff9mapkit/content/itemtext.py +168 -0
- ff9mapkit/content/jump.py +114 -0
- ff9mapkit/content/ladder.py +633 -0
- ff9mapkit/content/movement.py +53 -0
- ff9mapkit/content/music.py +97 -0
- ff9mapkit/content/npc.py +348 -0
- ff9mapkit/content/object.py +340 -0
- ff9mapkit/content/onentry.py +135 -0
- ff9mapkit/content/party.py +111 -0
- ff9mapkit/content/pathfind.py +138 -0
- ff9mapkit/content/platform.py +314 -0
- ff9mapkit/content/player.py +168 -0
- ff9mapkit/content/prop.py +75 -0
- ff9mapkit/content/region.py +340 -0
- ff9mapkit/content/reinit.py +59 -0
- ff9mapkit/content/savepoint.py +90 -0
- ff9mapkit/content/shop.py +178 -0
- ff9mapkit/content/sps_trigger.py +66 -0
- ff9mapkit/content/startup.py +71 -0
- ff9mapkit/content/synthesis.py +106 -0
- ff9mapkit/content/text.py +183 -0
- ff9mapkit/content/textcarry.py +290 -0
- ff9mapkit/content/verbatim.py +86 -0
- ff9mapkit/content/walkmesh_hotfix.py +38 -0
- ff9mapkit/data/__init__.py +48 -0
- ff9mapkit/data/_regen_provenance.py +142 -0
- ff9mapkit/data/provenance/blank.es.patch +1 -0
- ff9mapkit/data/provenance/blank.fr.patch +1 -0
- ff9mapkit/data/provenance/blank.gr.patch +1 -0
- ff9mapkit/data/provenance/blank.it.patch +1 -0
- ff9mapkit/data/provenance/blank.jp.patch +1 -0
- ff9mapkit/data/provenance/blank.uk.patch +1 -0
- ff9mapkit/data/provenance/blank.us.patch +1 -0
- ff9mapkit/data/provenance/manifest.json +65 -0
- ff9mapkit/data/provenance/region_template.patch +1 -0
- ff9mapkit/data/reference_arcs.toml +89 -0
- ff9mapkit/data/region_catalog.toml +593 -0
- ff9mapkit/deploystack.py +358 -0
- ff9mapkit/dialogue.py +803 -0
- ff9mapkit/eb/__init__.py +12 -0
- ff9mapkit/eb/_exprtable.py +59 -0
- ff9mapkit/eb/_membertable.py +38 -0
- ff9mapkit/eb/_optables.py +537 -0
- ff9mapkit/eb/_regen_optables.py +76 -0
- ff9mapkit/eb/cmdasm.py +323 -0
- ff9mapkit/eb/disasm.py +332 -0
- ff9mapkit/eb/edit.py +439 -0
- ff9mapkit/eb/exprasm.py +158 -0
- ff9mapkit/eb/model.py +178 -0
- ff9mapkit/eb/opcodes.py +463 -0
- ff9mapkit/eblint.py +177 -0
- ff9mapkit/editor/__init__.py +20 -0
- ff9mapkit/editor/app.py +950 -0
- ff9mapkit/editor/battle_forms.py +240 -0
- ff9mapkit/editor/breadcrumb.py +89 -0
- ff9mapkit/editor/dialogs.py +116 -0
- ff9mapkit/editor/feedback.py +208 -0
- ff9mapkit/editor/forms.py +632 -0
- ff9mapkit/editor/graphview.py +350 -0
- ff9mapkit/editor/jobs.py +342 -0
- ff9mapkit/editor/model.py +243 -0
- ff9mapkit/editor/picker.py +120 -0
- ff9mapkit/editor/theme.py +212 -0
- ff9mapkit/eventscan.py +1441 -0
- ff9mapkit/extract.py +2279 -0
- ff9mapkit/flags.py +693 -0
- ff9mapkit/forkreport.py +1383 -0
- ff9mapkit/hub.py +477 -0
- ff9mapkit/idgated.py +101 -0
- ff9mapkit/infohub.py +580 -0
- ff9mapkit/items.py +63 -0
- ff9mapkit/itemstats.py +346 -0
- ff9mapkit/journey.py +1902 -0
- ff9mapkit/keyitems.py +93 -0
- ff9mapkit/logic_add.py +632 -0
- ff9mapkit/logic_edit.py +728 -0
- ff9mapkit/logic_map.py +526 -0
- ff9mapkit/pack.py +175 -0
- ff9mapkit/playerswap.py +231 -0
- ff9mapkit/prop_archetypes.py +228 -0
- ff9mapkit/provision.py +282 -0
- ff9mapkit/refarc.py +825 -0
- ff9mapkit/save.py +337 -0
- ff9mapkit/save_items.py +1673 -0
- ff9mapkit/scene/__init__.py +11 -0
- ff9mapkit/scene/arena.py +63 -0
- ff9mapkit/scene/bgart.py +140 -0
- ff9mapkit/scene/bgi.py +732 -0
- ff9mapkit/scene/bgs.py +174 -0
- ff9mapkit/scene/bgx.py +185 -0
- ff9mapkit/scene/cam.py +345 -0
- ff9mapkit/scene/guide.py +311 -0
- ff9mapkit/scene/paint.py +506 -0
- ff9mapkit/scene/placeholder.py +107 -0
- ff9mapkit/sjbinary.py +285 -0
- ff9mapkit/sps/__init__.py +17 -0
- ff9mapkit/sps/author.py +294 -0
- ff9mapkit/sps/catalog.py +88 -0
- ff9mapkit/sps/codec.py +264 -0
- ff9mapkit/sps/edit.py +184 -0
- ff9mapkit/sps/lint.py +58 -0
- ff9mapkit/sps/render.py +116 -0
- ff9mapkit/sps/templates.py +47 -0
- ff9mapkit/sps/texture.py +131 -0
- ff9mapkit/walkmesh_hotfixes.py +163 -0
- ff9mapkit/workspace/__init__.py +18 -0
- ff9mapkit/workspace/battledoc.py +985 -0
- ff9mapkit/workspace/builddoc.py +607 -0
- ff9mapkit/workspace/forms_qt.py +586 -0
- ff9mapkit/workspace/importdoc.py +665 -0
- ff9mapkit/workspace/mapview.py +131 -0
- ff9mapkit/workspace/palette.py +85 -0
- ff9mapkit/workspace/savedoc.py +664 -0
- ff9mapkit/workspace/shell.py +6907 -0
- ff9mapkit/workspace/style.py +105 -0
- ff9mapkit/workspace/tuningdialog.py +223 -0
- ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
- ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
- ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
- ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
- ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
- ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""A generic Qt form renderer for :mod:`..editor.forms` specs (Phase 4 of the GUI makeover).
|
|
2
|
+
|
|
3
|
+
Builds a Qt form (a labelled widget per :class:`..editor.forms.Field`) + a dict of value getters from a
|
|
4
|
+
spec + flat values. Saving goes through ``forms.build_entity`` -- the SAME tk-free parser the tkinter
|
|
5
|
+
editor uses -- so a field edited in the Qt shell round-trips byte-identically to one edited in the old
|
|
6
|
+
editor. The renderer is thin; all parsing/validation stays in ``editor.forms`` (unit-tested headless).
|
|
7
|
+
|
|
8
|
+
Mapping: BOOL -> QCheckBox, PRESET -> an editable QComboBox seeded with the archetype names (a custom
|
|
9
|
+
string is still accepted), everything else -> a QLineEdit. A catalog-backed field also gets a "Browse…"
|
|
10
|
+
button wired to :class:`CatalogPicker`, which reuses the UI-agnostic ``infohub.browse`` spine (exactly
|
|
11
|
+
like the tkinter editor's picker) so the two stay in lockstep.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import collections
|
|
17
|
+
import html
|
|
18
|
+
|
|
19
|
+
from PySide6.QtCore import Qt
|
|
20
|
+
from PySide6.QtGui import QPixmap
|
|
21
|
+
from PySide6.QtWidgets import (
|
|
22
|
+
QApplication, QCheckBox, QComboBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget,
|
|
23
|
+
QPlainTextEdit, QPushButton, QSplitter, QTextEdit, QVBoxLayout, QWidget,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from .. import dialogue as _dlg
|
|
27
|
+
from .. import infohub
|
|
28
|
+
from ..content.text import DEFAULT_WRAP_WIDTH
|
|
29
|
+
from ..editor import forms
|
|
30
|
+
|
|
31
|
+
# Fields whose value is a line shown in an FF9 text window -> they get a live wrap-preview (FF9 never
|
|
32
|
+
# auto-wraps, so the kit pre-breaks long lines; this shows exactly where). Keys match editor.forms specs.
|
|
33
|
+
DIALOGUE_KEYS = {"dialogue", "message", "prompt", "reply"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _wrap_preview_panel(line_edit, get_text, palette, wrap_width):
|
|
37
|
+
"""A read-only pane under a dialogue field: how the line breaks on the FF9 screen, live as you type.
|
|
38
|
+
Reuses the exact build-time wrapper (:func:`..dialogue.wrap_preview`). ``wrap_width`` None = the field
|
|
39
|
+
set ``[dialogue] wrap = false`` (author wraps by hand) -> show the text raw, no preview break."""
|
|
40
|
+
panel = QWidget()
|
|
41
|
+
pv = QVBoxLayout(panel)
|
|
42
|
+
pv.setContentsMargins(0, 3, 0, 0)
|
|
43
|
+
pv.setSpacing(2)
|
|
44
|
+
cap = QLabel("On-screen preview — how it wraps in the FF9 window:")
|
|
45
|
+
cap.setStyleSheet(f"color:{palette['muted']};font-size:11px;")
|
|
46
|
+
pv.addWidget(cap)
|
|
47
|
+
box = QPlainTextEdit()
|
|
48
|
+
box.setReadOnly(True)
|
|
49
|
+
box.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) # show the kit's OWN break points, not Qt's
|
|
50
|
+
box.setFixedHeight(74)
|
|
51
|
+
pv.addWidget(box)
|
|
52
|
+
# The note is ALWAYS in the layout at a fixed height (it carries the warning OR a quiet "fits" line):
|
|
53
|
+
# toggling visibility would change the panel height and, inside the nested form/scroll, clip the
|
|
54
|
+
# fixed-height box on the way back. A constant-height panel can't reflow.
|
|
55
|
+
note = QLabel("")
|
|
56
|
+
note.setFixedHeight(16)
|
|
57
|
+
pv.addWidget(note)
|
|
58
|
+
|
|
59
|
+
def refresh(*_):
|
|
60
|
+
txt = get_text() or ""
|
|
61
|
+
box.setPlainText((_dlg.wrap_preview(txt, wrap_width) if wrap_width is not None else txt) or "(empty)")
|
|
62
|
+
over = _dlg.overflow(txt, wrap_width) if (txt and wrap_width is not None) else []
|
|
63
|
+
if over:
|
|
64
|
+
note.setText(f"⚠ {len(over)} line(s) may overflow the window — verify in-game.")
|
|
65
|
+
note.setStyleSheet(f"color:{palette['warn']};font-size:11px;")
|
|
66
|
+
elif txt:
|
|
67
|
+
note.setText("✓ fits the window")
|
|
68
|
+
note.setStyleSheet(f"color:{palette['muted']};font-size:11px;")
|
|
69
|
+
else:
|
|
70
|
+
note.setText("")
|
|
71
|
+
|
|
72
|
+
line_edit.textChanged.connect(refresh)
|
|
73
|
+
refresh()
|
|
74
|
+
return panel
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _changed_signal(widget):
|
|
78
|
+
"""The 'value changed' signal of a form widget (QLineEdit/QPlainTextEdit textChanged, QComboBox
|
|
79
|
+
currentTextChanged, QCheckBox toggled), or None."""
|
|
80
|
+
for attr in ("textChanged", "currentTextChanged", "toggled"):
|
|
81
|
+
sig = getattr(widget, attr, None)
|
|
82
|
+
if sig is not None:
|
|
83
|
+
return sig
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_form(spec, values: dict, palette: dict, pick=None, wrap_width=DEFAULT_WRAP_WIDTH, on_change=None):
|
|
88
|
+
"""Return ``(widget, getters)`` for ``spec`` + flat ``values`` (from ``forms.entity_to_values``).
|
|
89
|
+
|
|
90
|
+
``getters`` maps each field key to a 0-arg callable returning the widget's current value. ``pick``
|
|
91
|
+
(optional) is ``pick(catalog: str, current: str) -> str | None``; when given, catalog-backed fields
|
|
92
|
+
get a "Browse…" button that calls it and writes the chosen name back into the widget. Dialogue-bearing
|
|
93
|
+
fields (:data:`DIALOGUE_KEYS`) get a live FF9-window wrap preview at ``wrap_width`` (None = wrapping off
|
|
94
|
+
for this field -> show the line raw). ``on_change`` (optional) is called on ANY edit (for dirty
|
|
95
|
+
tracking); each field is ALSO validated live -- a bad value turns its hint red with the parse error."""
|
|
96
|
+
w = QWidget()
|
|
97
|
+
lay = QFormLayout(w)
|
|
98
|
+
lay.setLabelAlignment(Qt.AlignRight | Qt.AlignTop)
|
|
99
|
+
lay.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
|
|
100
|
+
lay.setHorizontalSpacing(14)
|
|
101
|
+
lay.setVerticalSpacing(10)
|
|
102
|
+
getters = {}
|
|
103
|
+
hints = {} # field key -> its hint QLabel (help text / live error)
|
|
104
|
+
editable = [] # (key, widget) for wiring change -> validate + on_change
|
|
105
|
+
muted_style = f"color:{palette['muted']};font-size:11px;"
|
|
106
|
+
err_style = f"color:{palette['error']};font-size:11px;"
|
|
107
|
+
|
|
108
|
+
def browse(field, getter, setter):
|
|
109
|
+
# a numeric field (e.g. the encounter battle scene, an INT) wants the picked entry's id, not its name
|
|
110
|
+
val = pick(field.catalog, getter(), want_id=field.kind in (forms.INT, forms.OPTINT))
|
|
111
|
+
if val:
|
|
112
|
+
setter(val)
|
|
113
|
+
|
|
114
|
+
for f in spec:
|
|
115
|
+
box = QWidget()
|
|
116
|
+
v = QVBoxLayout(box)
|
|
117
|
+
v.setContentsMargins(0, 0, 0, 0)
|
|
118
|
+
v.setSpacing(2)
|
|
119
|
+
setter = None
|
|
120
|
+
if f.kind == forms.BOOL:
|
|
121
|
+
cb = QCheckBox()
|
|
122
|
+
cb.setChecked(bool(values.get(f.key, f.default)))
|
|
123
|
+
widget, getters[f.key] = cb, cb.isChecked
|
|
124
|
+
elif f.kind == forms.PRESET:
|
|
125
|
+
combo = QComboBox()
|
|
126
|
+
combo.setEditable(True)
|
|
127
|
+
combo.addItems(list(forms.PRESETS))
|
|
128
|
+
combo.setCurrentText(str(values.get(f.key, "") or ""))
|
|
129
|
+
widget, getters[f.key], setter = combo, combo.currentText, combo.setCurrentText
|
|
130
|
+
elif f.key in DIALOGUE_KEYS:
|
|
131
|
+
# MULTI-LINE: dialogue carries explicit line breaks (Enter = a real \n, which is FF9's native
|
|
132
|
+
# in-window line break; type [PAGE] for a new window). QLineEdit collapses newlines -> use a
|
|
133
|
+
# plain text box. toPlainText returns real \n, preserved through build_entity/TOML/.mes. We ALSO
|
|
134
|
+
# accept a typed literal "\n" (two chars, a common habit) and normalize it to a real newline, so
|
|
135
|
+
# the preview, the saved .toml and the .mes all agree -- the getter does that normalization.
|
|
136
|
+
te = QPlainTextEdit(str(values.get(f.key, "") or ""))
|
|
137
|
+
te.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
|
|
138
|
+
te.setTabChangesFocus(True) # Tab -> next field (Enter is the line break, not Tab)
|
|
139
|
+
te.setFixedHeight(72) # ~4 lines, like the old Dialogue Editor
|
|
140
|
+
te.setToolTip("Line break: press Enter, or type \\n. New window: type [PAGE].")
|
|
141
|
+
widget, setter = te, te.setPlainText
|
|
142
|
+
getters[f.key] = lambda box=te: box.toPlainText().replace("\\n", "\n")
|
|
143
|
+
else:
|
|
144
|
+
le = QLineEdit(str(values.get(f.key, "") or ""))
|
|
145
|
+
if f.catalog:
|
|
146
|
+
le.setPlaceholderText(f"a {f.catalog.split(',')[0]} name or id")
|
|
147
|
+
widget, getters[f.key], setter = le, le.text, le.setText
|
|
148
|
+
if f.catalog and pick is not None and setter is not None:
|
|
149
|
+
row = QHBoxLayout()
|
|
150
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
151
|
+
row.addWidget(widget, 1)
|
|
152
|
+
b = QPushButton("Browse…")
|
|
153
|
+
b.clicked.connect(lambda _=False, ff=f, g=getters[f.key], st=setter: browse(ff, g, st))
|
|
154
|
+
row.addWidget(b)
|
|
155
|
+
v.addLayout(row)
|
|
156
|
+
else:
|
|
157
|
+
v.addWidget(widget)
|
|
158
|
+
hint = QLabel(f.help or "") # always present (hidden if no help) so a live error
|
|
159
|
+
hint.setWordWrap(True) # has somewhere to show
|
|
160
|
+
hint.setStyleSheet(muted_style)
|
|
161
|
+
v.addWidget(hint) # PARENT it BEFORE setVisible: setVisible(True) on a
|
|
162
|
+
hint.setVisible(bool(f.help)) # parentless widget flashes a top-level window (Windows)
|
|
163
|
+
hints[f.key] = hint
|
|
164
|
+
editable.append((f.key, widget))
|
|
165
|
+
if f.key in DIALOGUE_KEYS and hasattr(widget, "textChanged"):
|
|
166
|
+
v.addWidget(_wrap_preview_panel(widget, getters[f.key], palette, wrap_width))
|
|
167
|
+
label = QLabel(f.label + ":")
|
|
168
|
+
label.setStyleSheet("font-weight:500;")
|
|
169
|
+
lay.addRow(label, box)
|
|
170
|
+
|
|
171
|
+
def validate():
|
|
172
|
+
"""Live per-field check: a value that fails its parser turns the hint red with the error; an OK
|
|
173
|
+
field shows its normal help. Returns the count of invalid fields."""
|
|
174
|
+
bad = 0
|
|
175
|
+
for f in spec:
|
|
176
|
+
if f.kind == forms.BOOL:
|
|
177
|
+
continue
|
|
178
|
+
h = hints[f.key]
|
|
179
|
+
try:
|
|
180
|
+
forms._parse_field(f.kind, getters[f.key]())
|
|
181
|
+
except ValueError as e:
|
|
182
|
+
h.setText(f"⚠ {e}")
|
|
183
|
+
h.setStyleSheet(err_style)
|
|
184
|
+
h.setVisible(True)
|
|
185
|
+
bad += 1
|
|
186
|
+
continue
|
|
187
|
+
h.setText(f.help or "")
|
|
188
|
+
h.setStyleSheet(muted_style)
|
|
189
|
+
h.setVisible(bool(f.help))
|
|
190
|
+
return bad
|
|
191
|
+
|
|
192
|
+
def on_field_change():
|
|
193
|
+
validate()
|
|
194
|
+
if on_change:
|
|
195
|
+
on_change()
|
|
196
|
+
for _key, widget in editable:
|
|
197
|
+
sig = _changed_signal(widget)
|
|
198
|
+
if sig is not None:
|
|
199
|
+
sig.connect(on_field_change)
|
|
200
|
+
validate() # seed the initial state (loaded values are valid)
|
|
201
|
+
w.validate = validate # expose for tests / an external re-check
|
|
202
|
+
return w, getters
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def read(getters: dict) -> dict:
|
|
206
|
+
"""Collect the current ``{key: value}`` from a getters dict (call each getter)."""
|
|
207
|
+
return {k: g() for k, g in getters.items()}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class CatalogPicker(QDialog):
|
|
211
|
+
"""A modal Info-Hub catalog picker: search + a result list, returning the chosen entry NAME. Reuses
|
|
212
|
+
the same ``infohub.browse`` spine as the tkinter editor's picker (archetype/creature/item/flag/...)."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, parent, kinds, initial, plan, palette, *, browse=False, limit=300, want_id=False,
|
|
215
|
+
sps_context=None):
|
|
216
|
+
super().__init__(parent)
|
|
217
|
+
self.setWindowTitle("Browse the catalog" if browse else "Pick from the catalog")
|
|
218
|
+
self.resize(560, 460)
|
|
219
|
+
self.kinds = kinds
|
|
220
|
+
self.plan = plan
|
|
221
|
+
self.sps_context = sps_context # the open field's carried effects (for the 'sps' kind)
|
|
222
|
+
self.browse = browse # browse mode: "Use this" copies the name + stays open
|
|
223
|
+
self.limit = limit
|
|
224
|
+
self.want_id = want_id # a numeric field (e.g. encounter scene) wants the id back
|
|
225
|
+
self.result = None
|
|
226
|
+
self._entries = []
|
|
227
|
+
lay = QVBoxLayout(self)
|
|
228
|
+
self.q = QLineEdit(initial or "")
|
|
229
|
+
self.q.setPlaceholderText("Search…")
|
|
230
|
+
self.q.textChanged.connect(self._refresh)
|
|
231
|
+
self.q.returnPressed.connect(self._ok)
|
|
232
|
+
lay.addWidget(self.q)
|
|
233
|
+
self.lst = QListWidget()
|
|
234
|
+
self.lst.itemDoubleClicked.connect(lambda _i: self._ok())
|
|
235
|
+
self.lst.currentRowChanged.connect(self._describe)
|
|
236
|
+
lay.addWidget(self.lst, 1)
|
|
237
|
+
self.info = QLabel("")
|
|
238
|
+
self.info.setWordWrap(True)
|
|
239
|
+
self.info.setStyleSheet(f"color:{palette['muted']};")
|
|
240
|
+
lay.addWidget(self.info)
|
|
241
|
+
self.preview = QLabel() # a thumbnail for kinds that render one (SPS effects/templates)
|
|
242
|
+
self.preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
243
|
+
self.preview.setFixedHeight(0) # collapsed until an entry has a preview
|
|
244
|
+
lay.addWidget(self.preview)
|
|
245
|
+
bar = QHBoxLayout()
|
|
246
|
+
use = QPushButton("Copy name" if browse else "Use this")
|
|
247
|
+
use.setObjectName("accent")
|
|
248
|
+
use.clicked.connect(self._ok)
|
|
249
|
+
cancel = QPushButton("Close" if browse else "Cancel")
|
|
250
|
+
cancel.clicked.connect(self.reject)
|
|
251
|
+
bar.addWidget(use)
|
|
252
|
+
bar.addWidget(cancel)
|
|
253
|
+
bar.addStretch(1)
|
|
254
|
+
lay.addLayout(bar)
|
|
255
|
+
self._refresh()
|
|
256
|
+
self.q.setFocus()
|
|
257
|
+
|
|
258
|
+
def _refresh(self):
|
|
259
|
+
try:
|
|
260
|
+
self._entries = infohub.browse(self.q.text(), kinds=self.kinds, limit=self.limit,
|
|
261
|
+
campaign_context=self.plan, sps_context=self.sps_context)
|
|
262
|
+
except Exception: # noqa: BLE001 -- a catalog needing data we lack
|
|
263
|
+
self._entries = []
|
|
264
|
+
self.lst.clear()
|
|
265
|
+
for e in self._entries:
|
|
266
|
+
self.lst.addItem(f"{e.name} [{e.kind}]")
|
|
267
|
+
where = f" in {', '.join(self.kinds)}" if self.kinds else ""
|
|
268
|
+
capped = self.limit is not None and len(self._entries) >= self.limit
|
|
269
|
+
note = " (capped — type to narrow)" if capped else ""
|
|
270
|
+
self.info.setText(f"{len(self._entries)} match(es){where}{note}")
|
|
271
|
+
|
|
272
|
+
def _describe(self, row):
|
|
273
|
+
if not (0 <= row < len(self._entries)):
|
|
274
|
+
return
|
|
275
|
+
e = self._entries[row]
|
|
276
|
+
self.info.setText(f"{e.name} [{e.kind}] — {e.summary}")
|
|
277
|
+
png = None
|
|
278
|
+
if e.kind in ("sps", "sps_template"): # render a thumbnail for an effect / template
|
|
279
|
+
try:
|
|
280
|
+
png = infohub.detail(e, sps_context=self.sps_context).preview_png
|
|
281
|
+
except Exception: # noqa: BLE001 -- preview is best-effort
|
|
282
|
+
png = None
|
|
283
|
+
if png:
|
|
284
|
+
pm = QPixmap(png)
|
|
285
|
+
if not pm.isNull():
|
|
286
|
+
self.preview.setFixedHeight(140)
|
|
287
|
+
self.preview.setPixmap(pm.scaledToHeight(132, Qt.TransformationMode.SmoothTransformation))
|
|
288
|
+
return
|
|
289
|
+
self.preview.clear()
|
|
290
|
+
self.preview.setFixedHeight(0)
|
|
291
|
+
|
|
292
|
+
def _ok(self):
|
|
293
|
+
row = self.lst.currentRow()
|
|
294
|
+
if row < 0 and len(self._entries) == 1:
|
|
295
|
+
row = 0
|
|
296
|
+
if not (0 <= row < len(self._entries)):
|
|
297
|
+
return
|
|
298
|
+
e = self._entries[row]
|
|
299
|
+
if self.browse: # Info Hub browse: copy the name, keep browsing
|
|
300
|
+
QApplication.clipboard().setText(e.name)
|
|
301
|
+
self.info.setText(f"Copied “{e.name}” [{e.kind}] to the clipboard.")
|
|
302
|
+
return
|
|
303
|
+
# a numeric field (want_id) takes the entry's id (e.g. a battle scene #67 -> "67"); else its name
|
|
304
|
+
self.result = str(e.ident) if self.want_id and e.ident is not None else e.name
|
|
305
|
+
self.accept()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def pick_catalog(parent, catalog, initial, plan, palette, *, want_id=False, sps_context=None):
|
|
309
|
+
"""Open :class:`CatalogPicker` for a comma-separated ``catalog`` string; return the chosen NAME (or the
|
|
310
|
+
entry's numeric id as a string when ``want_id`` -- for an INT field like an encounter's battle scene),
|
|
311
|
+
or None. The shell passes this (curried with its window/plan/palette) as ``build_form``'s ``pick``.
|
|
312
|
+
``sps_context`` (the open field's carried effects) makes the ``sps`` kind browse THIS field's effects."""
|
|
313
|
+
kinds = [k.strip() for k in catalog.split(",")] if catalog else None
|
|
314
|
+
dlg = CatalogPicker(parent, kinds, initial, plan, palette, want_id=want_id, sps_context=sps_context)
|
|
315
|
+
dlg.exec()
|
|
316
|
+
return dlg.result
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# friendly section names for the Info Hub library sidebar (one per catalog 'kind').
|
|
320
|
+
_KIND_LABEL = {
|
|
321
|
+
"field": "Campaign fields", "flag": "Campaign flags", "sps": "SPS effects",
|
|
322
|
+
"sps_template": "SPS templates",
|
|
323
|
+
"archetype": "Archetypes", "creature": "Creatures", "composite": "Composites",
|
|
324
|
+
"prop": "Props", "model": "Models", "item": "Items", "scene": "Battle scenes",
|
|
325
|
+
"storyflag": "Story flags",
|
|
326
|
+
}
|
|
327
|
+
# sidebar order: the open project's OWN content first (fields/flags/SPS effects), then the static catalogs.
|
|
328
|
+
_LIBRARY_ORDER = ("field", "flag", "sps") + infohub.KINDS
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _esc(s) -> str:
|
|
332
|
+
return html.escape(str(s))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# one-line glossary per catalog kind -- the Info Hub Help button (so 'archetype' etc. is self-explanatory).
|
|
336
|
+
_HUB_HELP = {
|
|
337
|
+
"archetype": "named, NPC-ready character types (the playable cast + NPC types). Place with "
|
|
338
|
+
"<code>[[npc]] archetype = \"name\"</code> — the model + its animations/movement resolve for you.",
|
|
339
|
+
"creature": "<code>GEO_MON</code> monster field objects (also placed as an NPC, by name).",
|
|
340
|
+
"composite": "multi-part set pieces — several models posed together as one object.",
|
|
341
|
+
"prop": "single static set-dressing (chests, signs, barrels). Place with <code>[[prop]] prop = \"name\"</code>.",
|
|
342
|
+
"model": "the raw GEO models by their engine name — the lowest level, no animation join.",
|
|
343
|
+
"item": "item / equipment names (+ stats read from your install).",
|
|
344
|
+
"scene": "battle encounter scenes, by id.",
|
|
345
|
+
"storyflag": "FF9's built-in story-state registry — named engine vars, scenario beats, reserved bit regions.",
|
|
346
|
+
"field": "the fields in the OPEN campaign (this section shows only when a campaign is loaded).",
|
|
347
|
+
"flag": "the named story flags in the OPEN campaign.",
|
|
348
|
+
"sps": "the particle effects (fire/smoke/magic) a native fork carries in its <code>sps/</code> sidecar — "
|
|
349
|
+
"decode + preview them, and copy a <code>[[sps_edit]]</code> re-skin block.",
|
|
350
|
+
"sps_template": "ready-made particle effects (fire/smoke/sparkle/…) for the Tier-2 creator — preview one, "
|
|
351
|
+
"then add it to a field as an <code>[[sps]]</code> block (or pick it in the Effects form).",
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _hub_help_html() -> str:
|
|
356
|
+
"""The Info Hub help text: a one-line intro, the per-section glossary (static catalogs first, the
|
|
357
|
+
campaign-only sections last), and how Copy name / Copy snippet are used."""
|
|
358
|
+
order = list(infohub.KINDS) + ["field", "flag", "sps"]
|
|
359
|
+
rows = "".join(f'<p style="margin:4px 0;"><b>{_KIND_LABEL.get(k, k)}</b> — {_HUB_HELP[k]}</p>'
|
|
360
|
+
for k in order if k in _HUB_HELP)
|
|
361
|
+
return (
|
|
362
|
+
"<div style=\"font-family:'Segoe UI';\">"
|
|
363
|
+
'<div style="font-size:15px;"><b>Info Hub — the catalog</b></div>'
|
|
364
|
+
"<p>Everything you can place in a field or reference by <b>name</b>, grouped into sections. Pick a "
|
|
365
|
+
"section on the left, search within it, and select an entry to see its details on the right.</p>"
|
|
366
|
+
'<p style="font-size:14px;"><b>Sections</b></p>' + rows +
|
|
367
|
+
'<p style="font-size:14px;"><b>Using an entry</b></p>'
|
|
368
|
+
"<p><b>Copy name</b> — paste into a form's catalog field (an NPC's <code>archetype</code>, a prop's "
|
|
369
|
+
"<code>prop</code>, …).</p>"
|
|
370
|
+
"<p><b>Copy snippet</b> — paste a ready-to-edit <code>field.toml</code> block straight into a field.</p>"
|
|
371
|
+
"</div>")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class CatalogLibrary(QDialog):
|
|
375
|
+
"""The Info Hub as a SECTIONED LIBRARY (replacing the all-in-one browse list). Three columns: a category
|
|
376
|
+
sidebar with per-kind counts, a per-section searchable result list, and a rich DETAIL pane built from
|
|
377
|
+
``infohub.detail`` -- facts, animations, the movement set, composite parts, model aliases, and a ready
|
|
378
|
+
``field.toml`` snippet -- the data the flat browser computed and then threw away. Browse-only: 'Copy
|
|
379
|
+
name' / 'Copy snippet' put text on the clipboard; nothing is returned (the in-form picker stays
|
|
380
|
+
:class:`CatalogPicker`)."""
|
|
381
|
+
|
|
382
|
+
def __init__(self, parent, plan, palette, sps_context=None):
|
|
383
|
+
super().__init__(parent)
|
|
384
|
+
self.setWindowTitle("Info Hub — catalog library")
|
|
385
|
+
self.resize(900, 580)
|
|
386
|
+
self.plan = plan
|
|
387
|
+
self.pal = palette
|
|
388
|
+
self.sps_context = sps_context # {label: sps_dir} of the open project's carried effects
|
|
389
|
+
self._entries = []
|
|
390
|
+
self._kind = None # the selected section's kind (None = All)
|
|
391
|
+
self._cat_kinds = [] # sidebar row -> kind (or None for 'All')
|
|
392
|
+
|
|
393
|
+
root = QHBoxLayout(self)
|
|
394
|
+
split = QSplitter(Qt.Horizontal)
|
|
395
|
+
root.addWidget(split)
|
|
396
|
+
|
|
397
|
+
self.cats = QListWidget() # col 1: category sidebar (kinds + counts)
|
|
398
|
+
self.cats.setMaximumWidth(200)
|
|
399
|
+
self.cats.currentRowChanged.connect(self._on_category)
|
|
400
|
+
split.addWidget(self.cats)
|
|
401
|
+
|
|
402
|
+
mid = QWidget() # col 2: search + result list
|
|
403
|
+
mv = QVBoxLayout(mid)
|
|
404
|
+
mv.setContentsMargins(0, 0, 0, 0)
|
|
405
|
+
self.q = QLineEdit()
|
|
406
|
+
self.q.setPlaceholderText("Search…")
|
|
407
|
+
self.q.textChanged.connect(self._refresh_list)
|
|
408
|
+
mv.addWidget(self.q)
|
|
409
|
+
self.lst = QListWidget()
|
|
410
|
+
self.lst.currentRowChanged.connect(self._describe)
|
|
411
|
+
self.lst.itemDoubleClicked.connect(lambda _i: self._copy_name())
|
|
412
|
+
mv.addWidget(self.lst, 1)
|
|
413
|
+
self.count = QLabel("")
|
|
414
|
+
self.count.setStyleSheet(f"color:{palette['muted']};")
|
|
415
|
+
self.count.setWordWrap(True)
|
|
416
|
+
mv.addWidget(self.count)
|
|
417
|
+
split.addWidget(mid)
|
|
418
|
+
|
|
419
|
+
right = QWidget() # col 3: rich detail pane + copy buttons
|
|
420
|
+
rv = QVBoxLayout(right)
|
|
421
|
+
rv.setContentsMargins(0, 0, 0, 0)
|
|
422
|
+
self.detail = QTextEdit()
|
|
423
|
+
self.detail.setReadOnly(True)
|
|
424
|
+
# the app's global QSS renders QTextEdit as a monospace CONSOLE; the detail pane is PROSE -> give it a
|
|
425
|
+
# readable proportional font on the normal surface (the snippet <pre> stays monospace by its tag).
|
|
426
|
+
self.detail.setStyleSheet(
|
|
427
|
+
f"QTextEdit {{ font-family:'Segoe UI'; font-size:13px; background:{palette['surface']}; "
|
|
428
|
+
f"color:{palette['text']}; border:1px solid {palette['border']}; border-radius:8px; padding:8px; }}")
|
|
429
|
+
rv.addWidget(self.detail, 1)
|
|
430
|
+
bar = QHBoxLayout()
|
|
431
|
+
cn = QPushButton("Copy name")
|
|
432
|
+
cn.setObjectName("accent")
|
|
433
|
+
cn.clicked.connect(self._copy_name)
|
|
434
|
+
cs = QPushButton("Copy snippet")
|
|
435
|
+
cs.setToolTip("Copy a ready-to-paste field.toml block for this entry")
|
|
436
|
+
cs.clicked.connect(self._copy_snippet)
|
|
437
|
+
helpb = QPushButton("?")
|
|
438
|
+
helpb.setToolTip("What's in the Info Hub? (glossary + how to use it)")
|
|
439
|
+
helpb.setFixedSize(30, 30) # a circular violet badge -- pops out from the
|
|
440
|
+
helpb.setStyleSheet( # neutral Copy/Close buttons (a distinct 'info' hue)
|
|
441
|
+
f"QPushButton {{ background:{palette['help']}; color:{palette['accent_fg']}; border:0; "
|
|
442
|
+
f"border-radius:15px; font-weight:bold; font-size:15px; }}"
|
|
443
|
+
f"QPushButton:hover {{ background:{palette['help_hover']}; }}")
|
|
444
|
+
helpb.clicked.connect(self._show_help)
|
|
445
|
+
close = QPushButton("Close")
|
|
446
|
+
close.clicked.connect(self.reject)
|
|
447
|
+
bar.addWidget(cn)
|
|
448
|
+
bar.addWidget(cs)
|
|
449
|
+
bar.addStretch(1)
|
|
450
|
+
bar.addWidget(helpb)
|
|
451
|
+
bar.addWidget(close)
|
|
452
|
+
rv.addLayout(bar)
|
|
453
|
+
split.addWidget(right)
|
|
454
|
+
|
|
455
|
+
split.setSizes([190, 320, 390])
|
|
456
|
+
self._build_categories()
|
|
457
|
+
self.cats.setCurrentRow(0) # land on 'All'
|
|
458
|
+
self.q.setFocus()
|
|
459
|
+
|
|
460
|
+
def _build_categories(self):
|
|
461
|
+
"""One browse over the cached catalogs -> per-kind counts -> the sidebar sections (only non-empty
|
|
462
|
+
kinds; the campaign's own field/flag sections appear only when a campaign is open)."""
|
|
463
|
+
try:
|
|
464
|
+
allent = infohub.browse("", kinds=None, limit=None, campaign_context=self.plan,
|
|
465
|
+
sps_context=self.sps_context)
|
|
466
|
+
except Exception: # noqa: BLE001 -- a catalog needing data we lack
|
|
467
|
+
allent = []
|
|
468
|
+
counts = collections.Counter(e.kind for e in allent)
|
|
469
|
+
self._cat_kinds = [None]
|
|
470
|
+
self.cats.addItem(f"All ({len(allent)})")
|
|
471
|
+
for k in _LIBRARY_ORDER:
|
|
472
|
+
if counts.get(k):
|
|
473
|
+
self.cats.addItem(f"{_KIND_LABEL.get(k, k)} ({counts[k]})")
|
|
474
|
+
self._cat_kinds.append(k)
|
|
475
|
+
|
|
476
|
+
def _on_category(self, row):
|
|
477
|
+
if 0 <= row < len(self._cat_kinds):
|
|
478
|
+
self._kind = self._cat_kinds[row]
|
|
479
|
+
where = "all sections" if self._kind is None else _KIND_LABEL.get(self._kind, self._kind).lower()
|
|
480
|
+
self.q.setPlaceholderText(f"Search {where}…")
|
|
481
|
+
self._refresh_list()
|
|
482
|
+
|
|
483
|
+
def _refresh_list(self):
|
|
484
|
+
kinds = None if self._kind is None else [self._kind]
|
|
485
|
+
try:
|
|
486
|
+
self._entries = infohub.browse(self.q.text(), kinds=kinds, limit=None, campaign_context=self.plan,
|
|
487
|
+
sps_context=self.sps_context)
|
|
488
|
+
except Exception: # noqa: BLE001
|
|
489
|
+
self._entries = []
|
|
490
|
+
self.lst.clear()
|
|
491
|
+
for e in self._entries:
|
|
492
|
+
self.lst.addItem(f"{e.name} [{e.kind}]" if self._kind is None else e.name)
|
|
493
|
+
sect = "all sections" if self._kind is None else _KIND_LABEL.get(self._kind, self._kind)
|
|
494
|
+
self.count.setText(f"{len(self._entries)} in {sect}")
|
|
495
|
+
if self._entries:
|
|
496
|
+
self.lst.setCurrentRow(0)
|
|
497
|
+
else:
|
|
498
|
+
self.detail.setHtml("")
|
|
499
|
+
|
|
500
|
+
def _current(self):
|
|
501
|
+
r = self.lst.currentRow()
|
|
502
|
+
return self._entries[r] if 0 <= r < len(self._entries) else None
|
|
503
|
+
|
|
504
|
+
def _describe(self, _row=0):
|
|
505
|
+
e = self._current()
|
|
506
|
+
if e is None:
|
|
507
|
+
self.detail.setHtml("")
|
|
508
|
+
return
|
|
509
|
+
try:
|
|
510
|
+
d = infohub.detail(e, campaign_context=self.plan, sps_context=self.sps_context)
|
|
511
|
+
except Exception: # noqa: BLE001 -- degrade to the one-line summary
|
|
512
|
+
self.detail.setHtml(f"<b>{_esc(e.name)}</b> [{_esc(e.kind)}]<br>{_esc(e.summary)}")
|
|
513
|
+
return
|
|
514
|
+
self.detail.setHtml(self._render(d))
|
|
515
|
+
|
|
516
|
+
def _render(self, d) -> str:
|
|
517
|
+
muted = self.pal["muted"]
|
|
518
|
+
h = [f'<div style="font-size:15px;"><b>{_esc(d.name)}</b> '
|
|
519
|
+
f'<span style="color:{muted};">[{_esc(d.kind)}]</span></div>']
|
|
520
|
+
if d.facts:
|
|
521
|
+
h.append('<table cellspacing="0" cellpadding="2" style="margin-top:6px;">')
|
|
522
|
+
for label, val in d.facts:
|
|
523
|
+
h.append(f'<tr><td style="color:{muted};vertical-align:top;">{_esc(label)}</td>'
|
|
524
|
+
f'<td> {_esc(val)}</td></tr>')
|
|
525
|
+
h.append('</table>')
|
|
526
|
+
if d.movement:
|
|
527
|
+
mv = ", ".join(f"{k} #{v}" for k, v in d.movement.items())
|
|
528
|
+
h.append(f'<p><b>Movement</b><br><span style="color:{muted};">{_esc(mv)}</span></p>')
|
|
529
|
+
if d.anims:
|
|
530
|
+
an = ", ".join(f"{a} #{i}" for a, i in d.anims)
|
|
531
|
+
h.append(f'<p><b>Animations ({len(d.anims)})</b><br>'
|
|
532
|
+
f'<span style="color:{muted};">{_esc(an)}</span></p>')
|
|
533
|
+
if d.parts:
|
|
534
|
+
pr = "<br>".join(f"{_esc(nm)} (pose {_esc(p)}) @ ({_esc(dx)}, {_esc(dz)})"
|
|
535
|
+
for nm, p, dx, dz in d.parts)
|
|
536
|
+
h.append(f'<p><b>Parts</b><br><span style="color:{muted};">{pr}</span></p>')
|
|
537
|
+
if d.aliases:
|
|
538
|
+
h.append(f'<p><b>Also on this model</b><br>'
|
|
539
|
+
f'<span style="color:{muted};">{_esc(", ".join(d.aliases))}</span></p>')
|
|
540
|
+
if d.locations:
|
|
541
|
+
loc = ", ".join(f"{nm} ({fid})" for fid, nm in d.locations[:24])
|
|
542
|
+
h.append(f'<p><b>Appears in</b><br><span style="color:{muted};">{_esc(loc)}</span></p>')
|
|
543
|
+
if getattr(d, "preview_png", None):
|
|
544
|
+
from pathlib import Path
|
|
545
|
+
h.append(f'<p style="margin-top:6px;"><b>Preview</b><br>'
|
|
546
|
+
f'<img src="file:///{Path(d.preview_png).as_posix()}" width="220"></p>')
|
|
547
|
+
if d.snippet:
|
|
548
|
+
h.append(f'<p style="margin-top:8px;"><b>Use it</b></p>'
|
|
549
|
+
f'<pre style="background:{self.pal["surface_btn"]};padding:6px;'
|
|
550
|
+
f'border-radius:4px;white-space:pre-wrap;">{_esc(d.snippet)}</pre>')
|
|
551
|
+
return "".join(h)
|
|
552
|
+
|
|
553
|
+
def _copy_name(self):
|
|
554
|
+
e = self._current()
|
|
555
|
+
if e is not None:
|
|
556
|
+
QApplication.clipboard().setText(e.name)
|
|
557
|
+
self.count.setText(f"Copied “{e.name}” to the clipboard.")
|
|
558
|
+
|
|
559
|
+
def _copy_snippet(self):
|
|
560
|
+
e = self._current()
|
|
561
|
+
if e is not None:
|
|
562
|
+
QApplication.clipboard().setText(infohub.snippet(e))
|
|
563
|
+
self.count.setText(f"Copied the {e.kind} snippet for “{e.name}”.")
|
|
564
|
+
|
|
565
|
+
def _show_help(self):
|
|
566
|
+
"""A small modal glossary: what each section is (archetype vs creature vs model vs prop …) and how
|
|
567
|
+
Copy name / Copy snippet are used."""
|
|
568
|
+
dlg = QDialog(self)
|
|
569
|
+
dlg.setWindowTitle("Info Hub — help")
|
|
570
|
+
dlg.resize(470, 540)
|
|
571
|
+
v = QVBoxLayout(dlg)
|
|
572
|
+
body = QTextEdit()
|
|
573
|
+
body.setReadOnly(True)
|
|
574
|
+
body.setStyleSheet(
|
|
575
|
+
f"QTextEdit {{ font-family:'Segoe UI'; font-size:13px; background:{self.pal['surface']}; "
|
|
576
|
+
f"color:{self.pal['text']}; border:1px solid {self.pal['border']}; border-radius:8px; padding:10px; }}")
|
|
577
|
+
body.setHtml(_hub_help_html())
|
|
578
|
+
v.addWidget(body, 1)
|
|
579
|
+
row = QHBoxLayout()
|
|
580
|
+
row.addStretch(1)
|
|
581
|
+
ok = QPushButton("Got it")
|
|
582
|
+
ok.setObjectName("accent")
|
|
583
|
+
ok.clicked.connect(dlg.accept)
|
|
584
|
+
row.addWidget(ok)
|
|
585
|
+
v.addLayout(row)
|
|
586
|
+
dlg.exec()
|