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.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. 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>&nbsp;&nbsp;{_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()