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,240 @@
1
+ """Form specs for the Battle document (ENCOUNTER-FIRST), reusing the field editor's tk-free spec machinery
2
+ (:mod:`ff9mapkit.editor.forms` -- the :class:`~ff9mapkit.editor.forms.Field` dataclass, the field kinds, and
3
+ ``build_entity`` / ``entity_to_values``).
4
+
5
+ A ``battle.toml`` is authored as an ENCOUNTER, not a loose enemy (the engine itself enforces this: a scene IS
6
+ a formation, and per-slot edits only apply once ``[scene] monster_count`` composes the formation). The three
7
+ specs here mirror that:
8
+
9
+ * :data:`BATTLEMAP_SPEC` -- ``[battlemap]``: the map's identity (the BBG slot it ships as, its geometry, and
10
+ the mint/repoint scene wiring).
11
+ * :data:`SCENE_SPEC` -- ``[scene]``: the FORMATION (how many enemies, the opening camera, the AP reward).
12
+ * :data:`ENEMY_SPEC` -- ``[[scene.enemy]]``: one formation SLOT's enemy -- identity & stats, element/status
13
+ affinities, rewards, placement, and a model re-skin.
14
+
15
+ The scalar stats are int fields; the element/status/drop/flags lists are :data:`~forms.STRLIST` (the same
16
+ comma-separated name list ``[party]`` uses); placement is a :data:`~forms.COORD`. The player-side CSV tuning
17
+ tables (``[[battle_action]]`` / ``[[status]]`` / ``[[character]]`` / ...) are a SEPARATE, scene-independent
18
+ spec set (now wired -- see :data:`PLAYER_TABLES`); they don't belong on a per-enemy slot. The high-level AI
19
+ branch (:data:`AI_PHASE_SPEC`) and the SAME-LENGTH constant patches (:data:`AI_PATCH_SPEC` /
20
+ :data:`SEQ_PATCH_SPEC`, the "cite an offset" tier) have flat specs here; the length-CHANGING authoring
21
+ (``ai_function`` / ``ai_insert`` / ``seq_replace`` / ``seq_insert``) stays CLI-only (the disassemble/splice tier).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from .forms import COORD, FLOAT, INT, OPTINT, STR, STRLIST, Field
26
+
27
+ # [[scene.ai_phase]] -- a high-level "enrage below X% stat" AI branch (aiauthor.apply_ai_phases GENERATES the
28
+ # HP-threshold branch + splices it before the function's Attack). Flat: the attack-index variable is inferred,
29
+ # so the author only gives the entry/function + the threshold + the two attack indices.
30
+ AI_PHASE_SPEC = [
31
+ Field("entry", "Enemy AI entry", INT,
32
+ "the .eb AI entry for the enemy type (often 1 + the slot's type; `battle-ai <donor>` lists them)"),
33
+ Field("tag", "AI function", INT, "which AI function has the single Attack — the readout's 'Enrage-able' line "
34
+ "shows it (usually 5, the per-turn attack executor; 1=Main, 6=Counter, 7=ATB, 9=Dying)"),
35
+ Field("stat", "Threshold stat", STR, "hp / mp / at (default hp)"),
36
+ Field("below", "Enrage below", FLOAT, "a unit fraction 1/N: 0.5 = half, 0.25 = quarter, 0.2 = a fifth (default 0.5)"),
37
+ Field("then", "Attack when below", INT, "enemy_attack[] index used WHILE below the threshold (the enrage move)"),
38
+ Field("else", "Attack when above", INT, "enemy_attack[] index used while ABOVE the threshold (the normal move)"),
39
+ ]
40
+
41
+ # [[scene.ai_patch]] -- a SAME-LENGTH enemy-AI constant patch (aipatch.apply_ai_patches): rewrite ONE numeric
42
+ # literal in the AI bytecode in place -- an HP threshold a phase compares, the attack index a turn selects, a
43
+ # `Wait` count -- with no byte movement. Addressed by BYTE OFFSET (from `battle-ai --sites`, or the form's
44
+ # "Browse sites…" picker) + an OLD-value guard so a stale offset fails LOUD instead of corrupting a byte. The
45
+ # bytecode is language-identical, so one patch hits every language's eb.
46
+ AI_PATCH_SPEC = [
47
+ Field("at", "Offset", INT, "the byte offset of the AI constant — use 'Browse sites…' (or `battle-ai --sites`)"),
48
+ Field("old", "Current value", INT, "the value the eb has there NOW — the guard (Browse sites… fills it)"),
49
+ Field("new", "New value", INT, "the value to write (must fit the SAME byte width — a literal patch can't widen)"),
50
+ ]
51
+
52
+ # [[scene.seq_patch]] -- a SAME-LENGTH raw17 attack-CHOREOGRAPHY operand patch (seqpatch.apply_seq_patches):
53
+ # retime a `Wait` / `MoveTo*` frame count, swap an `Anim` code or a `SetCamera` id, in place. Byte OFFSET (from
54
+ # `battle-seq --sites`, or "Browse sites…") + an OLD guard; `seq` is the optional owning attack/sub index (a
55
+ # cross-check the picker pre-fills, NOT required). raw17 is language-independent, so the patch applies once.
56
+ SEQ_PATCH_SPEC = [
57
+ Field("at", "Offset", INT, "the byte offset of the sequence operand — use 'Browse sites…' (or `battle-seq --sites`)"),
58
+ Field("old", "Current value", INT, "the value the raw17 has there NOW — the guard (Browse sites… fills it)"),
59
+ Field("new", "New value", INT, "the value to write (must fit the SAME field width + signedness)"),
60
+ Field("seq", "Owning attack", OPTINT, "optional cross-check: the attack/sub index that owns this operand"),
61
+ ]
62
+
63
+ # [battlemap] -- the map identity (validate_battle: bbg is required + must look like BBG_B013; scene_id needs
64
+ # scene_name; scene_id (mint) and repoint_scene are mutually exclusive; char_tint/shadow are cosmetic).
65
+ BATTLEMAP_SPEC = [
66
+ Field("bbg", "Background slot", STR,
67
+ "the BBG_* slot this map ships as, e.g. BBG_B013 (= the forked slot to OVERRIDE that real map)"),
68
+ Field("fbx", "Geometry (.fbx)", STR, "the FBX geometry file in this folder (default <bbg>.fbx)"),
69
+ Field("repoint_scene", "Repoint scene id", OPTINT,
70
+ "point an EXISTING battle scene's background at this map (mutually exclusive with a mint)"),
71
+ Field("scene_id", "Mint scene id", OPTINT, "advanced: a NEW battle-scene id to mint (needs a scene name)"),
72
+ Field("scene_name", "Mint scene name", STR, "advanced: the new scene's name (pair with the mint id)"),
73
+ Field("char_tint", "Char tint (r, g, b)", STRLIST,
74
+ "RGB the engine lights party/enemies with on this map (0-255 each; default 128, 128, 128)"),
75
+ Field("shadow", "Shadow", OPTINT, "shadow intensity 0-255 (default 32)"),
76
+ ]
77
+
78
+ # [scene] -- the FORMATION. monster_count is the keystone: it recomposes every pattern and unlocks per-slot
79
+ # editing, so it reads first in the form. `flags` are the encounter RULES (header scene_flags); the camera_*
80
+ # floats nudge the OPENING-camera pose (raw17, in place). The AI / sequence edits live in their own sibling
81
+ # tables ([[scene.ai_phase]] / [[scene.ai_patch]] / [[scene.seq_patch]]), not on this form; full camera
82
+ # keyframes + the length-changing ai_*/seq_* authoring stay CLI-only.
83
+ SCENE_SPEC = [
84
+ Field("monster_count", "Monster count", OPTINT,
85
+ "how many enemies spawn (1-4) -- SET THIS to compose the formation + unlock per-slot edits"),
86
+ Field("camera", "Camera", OPTINT, "opening camera: 0-2 = a fixed PSX pose, >=3 = random"),
87
+ Field("ap", "AP reward", OPTINT, "the gameplay AP this fight awards"),
88
+ Field("pattern", "Pattern", OPTINT, "which formation pattern to tune (default 0)"),
89
+ Field("flags", "Encounter rules", STRLIST,
90
+ "scene RULES (any of): back_attack, preemptive, no_escape, no_exp -- absent keeps the donor's"),
91
+ Field("camera_yaw", "Camera yaw °", FLOAT, "rotate the opening camera by this many degrees (+/-, default 0)"),
92
+ Field("camera_pitch", "Camera pitch °", FLOAT, "tilt the opening camera by this many degrees (+/-, default 0)"),
93
+ Field("camera_zoom", "Camera zoom ×", FLOAT, "magnify the opening camera (>1 zooms IN, <1 zooms OUT; 1.0 = unchanged)"),
94
+ ]
95
+
96
+ # [[scene.enemy]] -- one formation slot's enemy. Stats are per-TYPE: two slots sharing a type share ALL stats.
97
+ ENEMY_SPEC = [
98
+ Field("slot", "Slot", INT, "the formation slot 0-3 (required)"),
99
+ Field("type", "Type", OPTINT, "the enemy TYPE to place here (must already exist in the scene)"),
100
+ # identity & stats (all per-type, 0-255 unless noted)
101
+ Field("hp", "HP", OPTINT, "max HP (0-65535)"),
102
+ Field("mp", "MP", OPTINT, "max MP (0-65535)"),
103
+ Field("speed", "Speed", OPTINT, "0-255"),
104
+ Field("strength", "Strength", OPTINT, "0-255"),
105
+ Field("magic", "Magic", OPTINT, "0-255"),
106
+ Field("spirit", "Spirit", OPTINT, "0-255"),
107
+ Field("level", "Level", OPTINT, "0-255 (drives variance, steal, Level-N spells)"),
108
+ Field("category", "Category", OPTINT, "race/killer/flight/undead category bits (0-255)"),
109
+ Field("hit_rate", "Hit rate", OPTINT, "physical accuracy (0-255)"),
110
+ Field("phys_def", "Phys. defence", OPTINT, "0-255"),
111
+ Field("phys_evade", "Phys. evade", OPTINT, "0-255"),
112
+ Field("mag_def", "Mag. defence", OPTINT, "0-255"),
113
+ Field("mag_evade", "Mag. evade", OPTINT, "0-255"),
114
+ Field("blue_magic", "Blue magic id", OPTINT, "the Quina Eat / Blue-magic learn id"),
115
+ # affinities (element / status NAME lists)
116
+ Field("null", "Null elements", STRLIST, "elements this enemy is IMMUNE to, e.g. Fire, Ice"),
117
+ Field("absorb", "Absorb elements", STRLIST, "elements this enemy ABSORBS (heals from)"),
118
+ Field("half", "Halve elements", STRLIST, "elements this enemy takes HALF from"),
119
+ Field("weak", "Weak elements", STRLIST, "elements this enemy is WEAK to"),
120
+ Field("resist_status", "Resist status", STRLIST, "statuses this enemy resists, e.g. Poison, Sleep"),
121
+ Field("auto_status", "Auto status", STRLIST, "statuses always active on this enemy"),
122
+ Field("initial_status", "Initial status", STRLIST, "statuses on this enemy at battle start"),
123
+ # rewards
124
+ Field("gil", "Gil", OPTINT, "gil awarded (0-65535)"),
125
+ Field("exp", "EXP", OPTINT, "EXP awarded (0-65535)"),
126
+ Field("drop", "Drops (4 items)", STRLIST, 'win items: 4 entries (name/id; "none" for an empty slot)'),
127
+ Field("steal", "Steals (4 items)", STRLIST, 'stealable items: 4 entries (name/id; "none" for empty)'),
128
+ Field("win_card", "Win card", OPTINT, "the Tetra Master card id awarded"),
129
+ Field("flags", "Flags", STRLIST, "behaviour flags: die_atk, die_dmg, non_dying_boss"),
130
+ # placement
131
+ Field("pos", "Position (x, z)", COORD, "where this enemy stands in the formation"),
132
+ Field("y", "Height (y)", OPTINT, "vertical placement offset"),
133
+ Field("rot", "Rotation", OPTINT, "facing rotation"),
134
+ # re-skin (visual transplant)
135
+ Field("model", "Re-skin model id", OPTINT, "advanced: borrow another enemy TYPE's model + animations"),
136
+ Field("model_scene", "Re-skin donor scene", STR, "advanced: a donor battle scene to borrow a model from"),
137
+ Field("model_type", "Re-skin donor type", OPTINT, "advanced: which type in the donor scene to borrow"),
138
+ Field("ai_entry", "AI entry", OPTINT, "advanced: explicit AI entry for this slot (needs monster_count)"),
139
+ ]
140
+
141
+
142
+ # ===== PLAYER / ABILITY tuning ==========================================================================
143
+ # Mod-GLOBAL CSV deltas a battle.toml may ALSO carry (the same blocks a field.toml can -- see
144
+ # ``ff9mapkit.battle.build.player_csv_problems`` / ``_emit_player_data``), so a battle fork tunes the PARTY
145
+ # that fights it in the SAME deployable doc. Each spec is FLAT over the existing field kinds; the FIRST field
146
+ # is the row "selector" (the tree label). The values name->id + base-CSV merge happens at build (which has the
147
+ # install); these specs only shape the override. Nested / multiline / list tables -- ``[[learn]]`` (sub-tables),
148
+ # ``[[ability_feature]]`` (a code body), ``[[status_set]]`` / ``[[magic_sword_set]]`` (offline list bundles) --
149
+ # stay build-supported + hand-authorable; they're out of the v1 forms (a later sub-increment).
150
+
151
+ # [[character]] -> BaseStats.csv (per-character base stats). The canonical CHARACTER_FIELDS keys.
152
+ CHARACTER_SPEC = [
153
+ Field("character", "Character", STR, "name (Zidane..Beatrix) or a 0-11 id"),
154
+ Field("strength", "Strength", OPTINT, "base Strength 0-255"),
155
+ Field("magic", "Magic", OPTINT, "base Magic 0-255"),
156
+ Field("dexterity", "Dexterity", OPTINT, "base Dexterity 0-255"),
157
+ Field("will", "Will (Spirit)", OPTINT, "base Will / Spirit 0-255"),
158
+ Field("gems", "Magic stones", OPTINT, "starting Gems / magic-stone count"),
159
+ ]
160
+
161
+ # [[battle_action]] -> Actions.csv (rebalance a shared player ability). The common scalar levers; the
162
+ # targeting BOOLEANS + vfx ids stay hand-authorable (a delta omits an unchecked bool, which can't express
163
+ # "turn this OFF", so the form would silently fail to override it -- see build_entity's BOOL rule).
164
+ BATTLE_ACTION_SPEC = [
165
+ Field("action", "Ability", STR, "name (e.g. Fire) or a 0-191 id"),
166
+ Field("power", "Power", OPTINT, "base damage/heal power"),
167
+ Field("element", "Elements", STRLIST, "element names, e.g. Fire, Ice (sets the element bitmask)"),
168
+ Field("rate", "Rate / accuracy", OPTINT, "the action's hit/status rate"),
169
+ Field("mp", "MP cost", OPTINT, "MP the ability costs"),
170
+ Field("category", "Category", OPTINT, "ability category bits (0-255)"),
171
+ Field("type", "Type", OPTINT, "ability type (0-255)"),
172
+ Field("status_index", "Status set", OPTINT, "the StatusSets.csv row this action inflicts/cures"),
173
+ ]
174
+
175
+ # [[status]] -> StatusData.csv (retune an ailment).
176
+ STATUS_SPEC = [
177
+ Field("status", "Status", STR, "name (e.g. Poison) or a 0-32 id"),
178
+ Field("tick", "Per-tick effect", OPTINT, "OprCount: the per-tick magnitude 0-255"),
179
+ Field("duration", "Duration", OPTINT, "ContiCount: 0 = until cured (0-65535)"),
180
+ Field("clear_on_apply", "Clears on apply", STRLIST, "statuses applying this one CLEARS, e.g. Sleep"),
181
+ Field("immunity_provided", "Grants immunity to", STRLIST, "statuses this one blocks while active"),
182
+ ]
183
+
184
+ # [[ability_gem]] -> AbilityGems.csv (the support-ability gem COST).
185
+ ABILITY_GEM_SPEC = [
186
+ Field("ability", "Support ability", STR, "name (e.g. Auto-Haste / AutoHaste) or a 0-63 id"),
187
+ Field("gems", "Gem cost", OPTINT, "magic stones to equip it"),
188
+ ]
189
+
190
+ # [[character_param]] -> CharacterParameters.csv (per-character menu/row/preset wiring).
191
+ CHARACTER_PARAM_SPEC = [
192
+ Field("character", "Character", STR, "name (Zidane..Beatrix) or a 0-11 id"),
193
+ Field("row", "Front/back row", OPTINT, "0 = front, 1 = back (0-255)"),
194
+ Field("win_pose", "Win pose", OPTINT, "victory-pose id (0-255)"),
195
+ Field("category", "Category", OPTINT, "category bits (0-255)"),
196
+ Field("menu_type", "Menu preset", STR, "a CharacterPresetId name (e.g. Steiner) or a 0-19 id"),
197
+ Field("equipment_set", "Equipment set", OPTINT, "which equipment set governs this character (0-255)"),
198
+ Field("serial_formula", "Serial formula", STR, "advanced: the serial-stat formula string"),
199
+ Field("name_keyword", "Name keyword", STR, "advanced: the name-resolution keyword string"),
200
+ ]
201
+
202
+ # [[command_set]] -> CommandSets.csv (re-point a character's battle-menu command SLOTS to BattleCommandIds).
203
+ COMMAND_SET_SPEC = [
204
+ Field("preset", "Character preset", STR, "a CharacterPresetId name (e.g. Zidane) or a 0-19 id"),
205
+ Field("attack", "Attack", OPTINT, "BattleCommandId 0-47"),
206
+ Field("defend", "Defend", OPTINT, "BattleCommandId 0-47"),
207
+ Field("ability1", "Ability 1", OPTINT, "BattleCommandId 0-47"),
208
+ Field("ability2", "Ability 2", OPTINT, "BattleCommandId 0-47"),
209
+ Field("item", "Item", OPTINT, "BattleCommandId 0-47"),
210
+ Field("change", "Change", OPTINT, "BattleCommandId 0-47"),
211
+ Field("attack_trance", "Attack (Trance)", OPTINT, "BattleCommandId 0-47"),
212
+ Field("defend_trance", "Defend (Trance)", OPTINT, "BattleCommandId 0-47"),
213
+ Field("ability1_trance", "Ability 1 (Trance)", OPTINT, "BattleCommandId 0-47"),
214
+ Field("ability2_trance", "Ability 2 (Trance)", OPTINT, "BattleCommandId 0-47"),
215
+ Field("item_trance", "Item (Trance)", OPTINT, "BattleCommandId 0-47"),
216
+ Field("change_trance", "Change (Trance)", OPTINT, "BattleCommandId 0-47"),
217
+ ]
218
+
219
+ # [[leveling]] -> Leveling.csv (the per-level growth curve; WHOLE-FILE re-emit, patched by level).
220
+ LEVELING_SPEC = [
221
+ Field("level", "Level", INT, "1-99 (the level this row tunes)"),
222
+ Field("exp", "EXP to next", OPTINT, "experience to the NEXT level (UInt32)"),
223
+ Field("bonus_hp", "Bonus HP", OPTINT, "HP grows BonusHP*Strength/50 (UInt16)"),
224
+ Field("bonus_mp", "Bonus MP", OPTINT, "MP grows BonusMP*Magic/100 (UInt16)"),
225
+ ]
226
+
227
+ # the v1 "Party & abilities" tree branch: ordered (key, label, spec, selector_key, default_entry).
228
+ PLAYER_TABLES = [
229
+ ("character", "Character stats", CHARACTER_SPEC, "character", {"character": "Zidane"}),
230
+ ("battle_action", "Ability rebalance", BATTLE_ACTION_SPEC, "action", {"action": "Fire"}),
231
+ ("status", "Status ailment", STATUS_SPEC, "status", {"status": "Poison"}),
232
+ ("ability_gem", "Ability gem cost", ABILITY_GEM_SPEC, "ability", {"ability": "Auto-Haste"}),
233
+ ("character_param", "Character params", CHARACTER_PARAM_SPEC, "character", {"character": "Zidane"}),
234
+ ("command_set", "Battle command set", COMMAND_SET_SPEC, "preset", {"preset": "Zidane"}),
235
+ ("leveling", "Leveling curve", LEVELING_SPEC, "level", {"level": 1}),
236
+ ]
237
+ PLAYER_SPECS = {k: spec for (k, _l, spec, _s, _d) in PLAYER_TABLES}
238
+ PLAYER_LABEL = {k: lbl for (k, lbl, _sp, _s, _d) in PLAYER_TABLES}
239
+ PLAYER_SELECTOR = {k: sel for (k, _l, _sp, sel, _d) in PLAYER_TABLES}
240
+ PLAYER_DEFAULT = {k: dict(d) for (k, _l, _sp, _s, d) in PLAYER_TABLES}
@@ -0,0 +1,89 @@
1
+ """A clickable breadcrumb -- the GUI's "you are here" across the game-data hierarchy.
2
+
3
+ The kit's data forms a containment hierarchy -- a JOURNEY (a playable arc) contains CAMPAIGNS, a
4
+ campaign contains FIELDS, a field contains OBJECTS (NPCs/gateways/events/the player & party). Today
5
+ that depth is split across windows; this widget renders the full resolved path as one line of
6
+ clickable segments (``◆ Dali Arc ▸ ▣ Dali chain ▸ ● DALI_INN ▸ ▸ NPC: Innkeeper``) so a user
7
+ always reads, in plain words, where they are -- and clicking any ancestor segment navigates up to it.
8
+
9
+ Same discipline as :mod:`.theme` / :mod:`.feedback`: the data layer (``Crumb`` + :func:`trail`) is
10
+ tk-FREE and unit-testable; the only Tk lives in :class:`Breadcrumb`, which imports tkinter lazily.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ # the four containment-spine levels, outermost -> innermost, with a leading glyph (matches the navigator
18
+ # badges). BATTLE / SAVE are the two OFF-spine doc families -- a battle.toml is a referenced SIBLING of a
19
+ # field, a save doc is ORTHOGONAL game state -- so the breadcrumb can name them on their own tabs too.
20
+ JOURNEY, CAMPAIGN, FIELD, OBJECT = "journey", "campaign", "field", "object"
21
+ HUB = "hub" # the journeys.toml root (the CONTAINER of journeys, above a journey)
22
+ BATTLE, SAVE = "battle", "save"
23
+ GLYPH = {HUB: "⌂", JOURNEY: "◆", CAMPAIGN: "▣", FIELD: "●", OBJECT: "▸", BATTLE: "⚔", SAVE: "◈"}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Crumb:
28
+ """One breadcrumb segment: its hierarchy ``level``, the ``label`` shown, and a ``key`` the click
29
+ handler uses to navigate (a member name for a field, a tree iid for an object, a sentinel for the
30
+ journey/campaign roots)."""
31
+
32
+ level: str
33
+ label: str
34
+ key: str = ""
35
+
36
+
37
+ def trail(journey=None, campaign=None, field=None, obj_label=None, obj_key="") -> list:
38
+ """Build the ordered :class:`Crumb` list from whatever levels are currently resolved (each is
39
+ optional; an unopened level is simply omitted, so the trail grows as the user drills in)."""
40
+ out = []
41
+ if journey:
42
+ out.append(Crumb(JOURNEY, journey, "@journey"))
43
+ if campaign:
44
+ out.append(Crumb(CAMPAIGN, campaign, "@campaign"))
45
+ if field:
46
+ out.append(Crumb(FIELD, field, field)) # key = the campaign member name
47
+ if obj_label:
48
+ out.append(Crumb(OBJECT, obj_label, obj_key)) # key = the editor tree iid (e.g. "npc:2")
49
+ return out
50
+
51
+
52
+ class Breadcrumb:
53
+ """A one-line clickable path bar themed from a palette dict. ``on_navigate(crumb)`` fires when an
54
+ ANCESTOR segment is clicked (the leaf -- where you already are -- is inert)."""
55
+
56
+ def __init__(self, parent, palette, *, on_navigate=None):
57
+ import tkinter as tk
58
+
59
+ self.pal = palette
60
+ self.on_navigate = on_navigate
61
+ self.frame = tk.Frame(parent, background=palette["surface"],
62
+ highlightthickness=1, highlightbackground=palette["border"])
63
+ self._empty = "No campaign open -- Open a campaign.toml to navigate it."
64
+ self.set([])
65
+
66
+ def set(self, crumbs):
67
+ """Render ``crumbs`` (a :func:`trail` list); an empty list shows a muted placeholder."""
68
+ import tkinter as tk
69
+
70
+ for w in self.frame.winfo_children():
71
+ w.destroy()
72
+ if not crumbs:
73
+ tk.Label(self.frame, text=self._empty, background=self.pal["surface"],
74
+ foreground=self.pal["muted"], font=("Segoe UI", 10)).pack(side="left", padx=10, pady=5)
75
+ return
76
+ last = len(crumbs) - 1
77
+ for i, c in enumerate(crumbs):
78
+ if i:
79
+ tk.Label(self.frame, text="▸", background=self.pal["surface"],
80
+ foreground=self.pal["muted"], font=("Segoe UI", 10)).pack(side="left", padx=1)
81
+ leaf = (i == last)
82
+ lbl = tk.Label(self.frame, text=f"{GLYPH.get(c.level, '')} {c.label}",
83
+ background=self.pal["surface"],
84
+ foreground=self.pal["text"] if leaf else self.pal["accent"],
85
+ font=("Segoe UI", 10, "bold") if leaf else ("Segoe UI", 10),
86
+ cursor="arrow" if leaf else "hand2", padx=5, pady=4)
87
+ lbl.pack(side="left", pady=1)
88
+ if not leaf and self.on_navigate is not None:
89
+ lbl.bind("<Button-1>", lambda _e, cc=c: self.on_navigate(cc))
@@ -0,0 +1,116 @@
1
+ """Small THEMED modal input dialogs -- a drop-in for :mod:`tkinter.simpledialog`.
2
+
3
+ tkinter's ``simpledialog`` builds its Toplevel from CLASSIC tk widgets with OS-default colours, so it
4
+ ignores the app's ttk theme and renders light against the dark editor. These replacements use ttk
5
+ widgets on a Toplevel whose background matches the themed app, so an input prompt looks native to the
6
+ editor. :func:`ask_string` returns the entered text (or ``None`` if cancelled); :func:`ask_integer`
7
+ parses + range-checks an int, re-prompting inline on a bad value. The dialog is a small class
8
+ (mirroring :mod:`.picker`) so it's headless-testable -- ``ask_*`` just wrap it with ``wait_window``.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import tkinter as tk
13
+ from tkinter import ttk
14
+
15
+
16
+ class _InputDialog:
17
+ def __init__(self, parent, title, prompt, *, initial="", integer=False, minvalue=None, maxvalue=None):
18
+ self.integer = integer
19
+ self.minvalue, self.maxvalue = minvalue, maxvalue
20
+ self.result = None
21
+
22
+ win = self.win = tk.Toplevel(parent)
23
+ win.title(title)
24
+ win.transient(parent)
25
+ win.resizable(False, False)
26
+ try: # match the themed app (a bare Toplevel is OS-gray)
27
+ win.configure(background=parent.winfo_toplevel()["background"])
28
+ except tk.TclError:
29
+ pass
30
+
31
+ ttk.Label(win, text=prompt, wraplength=380, justify="left").pack(anchor="w", padx=14, pady=(14, 6))
32
+ self.var = tk.StringVar(value="" if initial is None else str(initial))
33
+ ent = self.ent = ttk.Entry(win, textvariable=self.var, width=46)
34
+ ent.pack(fill="x", padx=14)
35
+ self.err = ttk.Label(win, text="", foreground="#ff6b6b", wraplength=380, justify="left")
36
+ self.err.pack(anchor="w", padx=14, pady=(2, 0))
37
+
38
+ bar = ttk.Frame(win, padding=12)
39
+ bar.pack(fill="x")
40
+ ttk.Button(bar, text="OK", style="Accent.TButton", command=self._ok).pack(side="right")
41
+ ttk.Button(bar, text="Cancel", command=self._cancel).pack(side="right", padx=6)
42
+ ent.bind("<Return>", lambda e: self._ok())
43
+ ent.bind("<Escape>", lambda e: self._cancel())
44
+ ent.focus_set()
45
+ ent.select_range(0, "end")
46
+ win.grab_set()
47
+
48
+ def _ok(self):
49
+ v = self.var.get().strip()
50
+ if self.integer:
51
+ try:
52
+ n = int(v)
53
+ except ValueError:
54
+ self.err.config(text="Enter a whole number.")
55
+ return
56
+ if self.minvalue is not None and n < self.minvalue:
57
+ self.err.config(text=f"Must be at least {self.minvalue}.")
58
+ return
59
+ if self.maxvalue is not None and n > self.maxvalue:
60
+ self.err.config(text=f"Must be at most {self.maxvalue}.")
61
+ return
62
+ self.result = n
63
+ else:
64
+ self.result = v # the caller treats "" as cancel (if not name: ...)
65
+ self.win.destroy()
66
+
67
+ def _cancel(self):
68
+ self.result = None
69
+ self.win.destroy()
70
+
71
+
72
+ def ask_string(parent, title, prompt, *, initial=""):
73
+ """Modal themed text prompt. Returns the entered string, or None if cancelled."""
74
+ dlg = _InputDialog(parent, title, prompt, initial=initial)
75
+ parent.wait_window(dlg.win)
76
+ return dlg.result
77
+
78
+
79
+ def ask_integer(parent, title, prompt, *, initial=None, minvalue=None, maxvalue=None):
80
+ """Modal themed integer prompt (re-prompts inline on a non-int / out-of-range). Returns int or None."""
81
+ dlg = _InputDialog(parent, title, prompt, initial=initial, integer=True,
82
+ minvalue=minvalue, maxvalue=maxvalue)
83
+ parent.wait_window(dlg.win)
84
+ return dlg.result
85
+
86
+
87
+ def _smoke():
88
+ """Headless self-test: build the dialog, drive _ok/_cancel, and check string + int parsing/ranging."""
89
+ from .theme import apply_theme
90
+ root = tk.Tk()
91
+ root.withdraw()
92
+ apply_theme(root)
93
+
94
+ d = _InputDialog(root, "t", "name?", initial="seed")
95
+ assert d.var.get() == "seed"
96
+ d.var.set("boss_dead"); d._ok()
97
+ assert d.result == "boss_dead", d.result
98
+
99
+ bad = _InputDialog(root, "t", "n?", integer=True, minvalue=4000, maxvalue=32767)
100
+ bad.var.set("nope"); bad._ok()
101
+ assert bad.result is None and bad.err["text"], "a non-int stays open with an error"
102
+ bad.var.set("10"); bad._ok()
103
+ assert bad.result is None, "below minvalue stays open"
104
+ bad.var.set("5000"); bad._ok()
105
+ assert bad.result == 5000, bad.result
106
+
107
+ cx = _InputDialog(root, "t", "x?"); cx._cancel()
108
+ assert cx.result is None
109
+ print("dialogs smoke ok: string + integer (parse/range) + cancel")
110
+ root.destroy()
111
+
112
+
113
+ if __name__ == "__main__":
114
+ import sys
115
+ if "--smoke" in sys.argv:
116
+ _smoke()
@@ -0,0 +1,208 @@
1
+ """A shared *result* surface for the GUI apps: a verdict banner + a structured problems list.
2
+
3
+ The kit's apps used to dump raw subprocess/traceback text into a scrolling log, leaving the user to
4
+ read tea leaves for "did it work, and what do I do next?". This module replaces that with two pieces:
5
+
6
+ * a :class:`Verdict` -- a one-line outcome (ok / passed-with-warnings / failed / running) plus an
7
+ optional next-action line ("Relaunch once, then F6 -> Warp -> 2640"), rendered as a coloured banner;
8
+ * a flat list of :class:`Problem` rows (errors + warnings), rendered as a compact, colour-coded,
9
+ selectable list -- the structured replacement for ``ERROR ...`` / ``warn ...`` log spam.
10
+
11
+ Following the same discipline as :mod:`.theme` / :mod:`.forms` / :mod:`.model`, the data layer
12
+ (``Verdict``/``Problem`` + the ``classify``/``from_returncode``/``problems`` builders) is **tk-FREE**
13
+ and unit-testable on a headless machine; the only Tk lives in :class:`FeedbackPanel`, which imports
14
+ tkinter lazily in ``__init__`` so importing this module never needs a display. The panel takes a
15
+ palette dict from :func:`.theme.apply_theme`, so it matches whatever app hosts it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+
22
+ # --- the four outcome levels (also the problem severities, minus "ok"/"running") -----------------
23
+ OK = "ok"
24
+ WARN = "warn"
25
+ ERROR = "error"
26
+ RUNNING = "running"
27
+
28
+ # glyphs read fine in Segoe UI (the themed default font); kept ASCII-safe-ish for any console echo.
29
+ _GLYPH = {OK: "✓", WARN: "⚠", ERROR: "✕", RUNNING: "…"} # ✓ ⚠ ✕ …
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Problem:
34
+ """One row in the problems list: an error or a warning, with an optional location label."""
35
+
36
+ severity: str # ERROR | WARN
37
+ message: str
38
+ where: str = "" # optional: a field/member/line the problem belongs to (for a future jump-to)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Verdict:
43
+ """A one-line outcome to show in the banner."""
44
+
45
+ level: str # OK | WARN | ERROR | RUNNING
46
+ headline: str
47
+ next_action: str = "" # the single most useful next step (e.g. an in-game warp), shown under the banner
48
+
49
+
50
+ def _n(count: int, word: str) -> str:
51
+ """``2, 'error' -> '2 errors'`` / ``1, 'warning' -> '1 warning'`` (naive English pluralisation)."""
52
+ return f"{count} {word}" + ("" if count == 1 else "s")
53
+
54
+
55
+ def classify(errors, warnings, *, subject="", clean_headline=None, next_action="") -> Verdict:
56
+ """Turn two message lists into a :class:`Verdict`.
57
+
58
+ ``subject`` prefixes the headline ("Build", "Check", "Campaign lint"). Errors win over warnings:
59
+ any error -> a failed verdict; warnings only -> passed-with-warnings; neither -> ``clean_headline``
60
+ (default "<subject> -- all clear")."""
61
+ ne, nw = len(errors), len(warnings)
62
+ subj = subject.strip()
63
+ if ne:
64
+ tail = _n(ne, "problem") + (f", {_n(nw, 'warning')}" if nw else "") + " to fix"
65
+ head = f"{subj} -- {tail}" if subj else tail
66
+ return Verdict(ERROR, head, next_action)
67
+ if nw:
68
+ head = f"{subj} -- passed with {_n(nw, 'warning')}" if subj else f"passed with {_n(nw, 'warning')}"
69
+ return Verdict(WARN, head, next_action)
70
+ head = clean_headline or (f"{subj} -- all clear" if subj else "all clear")
71
+ return Verdict(OK, head, next_action)
72
+
73
+
74
+ def from_returncode(code, *, subject="", ok_headline=None, ok_next="", fail_hint="") -> Verdict:
75
+ """A :class:`Verdict` for a subprocess result (the import/deploy shell-outs that have no structured
76
+ error list -- only an exit code + a streamed log). ``code == 0`` -> ok; anything else -> failed,
77
+ pointing the user at the streamed details."""
78
+ subj = subject.strip()
79
+ if code == 0:
80
+ return Verdict(OK, ok_headline or (f"{subj} -- done" if subj else "done"), ok_next)
81
+ head = f"{subj} -- failed (exit {code})" if subj else f"failed (exit {code})"
82
+ return Verdict(ERROR, head, fail_hint or "See the details below.")
83
+
84
+
85
+ def problems(errors=(), warnings=()) -> list:
86
+ """Flatten ``(errors, warnings)`` string lists into a severity-tagged :class:`Problem` list
87
+ (errors first, then warnings -- the natural read order)."""
88
+ rows = [Problem(ERROR, str(m)) for m in errors]
89
+ rows += [Problem(WARN, str(m)) for m in warnings]
90
+ return rows
91
+
92
+
93
+ # --- the Tk widget (lazy import keeps the data layer above headless-importable) ------------------
94
+ class FeedbackPanel:
95
+ """A coloured verdict banner + a structured problems list, themed from a palette dict.
96
+
97
+ Construct it on a ttk parent and ``.frame.pack(...)`` it where the old log used to dominate. Drive
98
+ it from the UI thread: ``running(headline)`` when a job starts, then ``show(verdict, problems)``
99
+ when it finishes. ``on_select(problem)`` (optional) fires when a problem row is clicked -- the seam
100
+ a future unified shell will use to jump to the offending node.
101
+ """
102
+
103
+ def __init__(self, parent, palette, *, on_select=None):
104
+ import tkinter as tk
105
+ from tkinter import ttk
106
+
107
+ self.pal = palette
108
+ self.on_select = on_select
109
+ self._rows: list = []
110
+
111
+ self.frame = ttk.Frame(parent)
112
+
113
+ # the banner: a coloured status stripe + a glyph + the headline, and a next-action line beneath.
114
+ self._banner = tk.Frame(self.frame, background=palette["surface"],
115
+ highlightthickness=1, highlightbackground=palette["border"])
116
+ self._stripe = tk.Frame(self._banner, width=4, background=palette["muted"])
117
+ self._stripe.pack(side="left", fill="y")
118
+ inner = tk.Frame(self._banner, background=palette["surface"])
119
+ inner.pack(side="left", fill="both", expand=True, padx=10, pady=7)
120
+ self._glyph = tk.Label(inner, text="", background=palette["surface"], foreground=palette["muted"],
121
+ font=("Segoe UI", 13, "bold"))
122
+ self._glyph.pack(side="left", padx=(0, 8))
123
+ headwrap = tk.Frame(inner, background=palette["surface"])
124
+ headwrap.pack(side="left", fill="x", expand=True)
125
+ self._headline = tk.Label(headwrap, text="", background=palette["surface"],
126
+ foreground=palette["text"], font=("Segoe UI", 11, "bold"),
127
+ anchor="w", justify="left")
128
+ self._headline.pack(fill="x", anchor="w")
129
+ self._next = tk.Label(headwrap, text="", background=palette["surface"],
130
+ foreground=palette["accent"], font=("Segoe UI", 10), anchor="w",
131
+ justify="left", wraplength=560)
132
+ # _next is packed only when there's a next-action string.
133
+
134
+ # the problems list: a compact tree (severity glyph + message), colour-coded, selectable.
135
+ self._plist_wrap = ttk.Frame(self.frame)
136
+ self._plist = ttk.Treeview(self._plist_wrap, show="tree", selectmode="browse", height=5)
137
+ self._plist.column("#0", width=560, stretch=True)
138
+ self._plist.pack(side="left", fill="both", expand=True)
139
+ psb = ttk.Scrollbar(self._plist_wrap, orient="vertical", command=self._plist.yview)
140
+ psb.pack(side="right", fill="y")
141
+ self._plist.configure(yscrollcommand=psb.set)
142
+ self._plist.tag_configure(ERROR, foreground=palette["error"])
143
+ self._plist.tag_configure(WARN, foreground=palette["warn"])
144
+ self._plist.bind("<<TreeviewSelect>>", self._on_row_select)
145
+
146
+ # both pieces start hidden; show() / running() reveal them.
147
+
148
+ # -- public API (call on the UI thread) --
149
+ def running(self, headline="Working…"):
150
+ """Show a neutral 'in progress' banner and clear any prior problems."""
151
+ self._set_banner(Verdict(RUNNING, headline))
152
+ self._set_problems([])
153
+
154
+ def show(self, verdict, problem_rows=()):
155
+ """Render a finished :class:`Verdict` + its (possibly empty) :class:`Problem` rows."""
156
+ self._set_banner(verdict)
157
+ self._set_problems(list(problem_rows))
158
+
159
+ def clear(self):
160
+ """Hide the banner + problems entirely (back to the resting state)."""
161
+ self._banner.pack_forget()
162
+ self._plist_wrap.pack_forget()
163
+
164
+ # -- internals --
165
+ def _color(self, level):
166
+ return {OK: self.pal["success"], WARN: self.pal["warn"], ERROR: self.pal["error"],
167
+ RUNNING: self.pal["muted"]}.get(level, self.pal["muted"])
168
+
169
+ def _set_banner(self, verdict):
170
+ col = self._color(verdict.level)
171
+ self._stripe.configure(background=col)
172
+ self._glyph.configure(text=_GLYPH.get(verdict.level, ""), foreground=col)
173
+ self._headline.configure(text=verdict.headline)
174
+ if verdict.next_action:
175
+ self._next.configure(text=verdict.next_action)
176
+ self._next.pack(fill="x", anchor="w", pady=(2, 0))
177
+ else:
178
+ self._next.pack_forget()
179
+ if not self._banner.winfo_ismapped():
180
+ kw = {"fill": "x", "padx": 10, "pady": (8, 4)}
181
+ if self._plist_wrap.winfo_ismapped(): # keep the banner above an already-shown problems list
182
+ kw["before"] = self._plist_wrap
183
+ self._banner.pack(**kw)
184
+
185
+ def _set_problems(self, rows):
186
+ self._rows = rows
187
+ self._plist.delete(*self._plist.get_children())
188
+ if not rows:
189
+ self._plist_wrap.pack_forget()
190
+ return
191
+ for i, p in enumerate(rows):
192
+ label = f"{_GLYPH.get(p.severity, '')} {p.message}"
193
+ if p.where:
194
+ label += f" ({p.where})"
195
+ self._plist.insert("", "end", iid=str(i), text=label, tags=(p.severity,))
196
+ # size the list to its contents (capped), so a single problem isn't a tall empty box.
197
+ self._plist.configure(height=max(2, min(len(rows), 8)))
198
+ if not self._plist_wrap.winfo_ismapped():
199
+ self._plist_wrap.pack(fill="both", expand=True, padx=10, pady=(0, 6))
200
+
201
+ def _on_row_select(self, _evt=None):
202
+ if not self.on_select:
203
+ return
204
+ sel = self._plist.selection()
205
+ if sel and sel[0].isdigit():
206
+ idx = int(sel[0])
207
+ if 0 <= idx < len(self._rows):
208
+ self.on_select(self._rows[idx])