struct2ui 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- struct2ui/__init__.py +4 -0
- struct2ui/editor.py +1171 -0
- struct2ui/exporters/__init__.py +15 -0
- struct2ui/exporters/bin_emitter.py +254 -0
- struct2ui/exporters/c_emitter.py +233 -0
- struct2ui/exporters/c_parser.py +204 -0
- struct2ui/exporters/elf_verifier.py +341 -0
- struct2ui/exporters/json_format.py +137 -0
- struct2ui/icons/c2j.png +0 -0
- struct2ui/icons/elf.png +0 -0
- struct2ui/icons/export_notes_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
- struct2ui/icons/flowchart_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
- struct2ui/icons/refresh_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
- struct2ui/icons/report_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
- struct2ui/icons/save_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
- struct2ui/icons/save_as_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
- struct2ui/icons/settings_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
- struct2ui/icons/widgets_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
- struct2ui/schema.py +1118 -0
- struct2ui/ui/__init__.py +36 -0
- struct2ui/ui/renderers.py +304 -0
- struct2ui/ui/tables.py +207 -0
- struct2ui/ui/widgets.py +907 -0
- struct2ui-0.1.0.dist-info/METADATA +167 -0
- struct2ui-0.1.0.dist-info/RECORD +28 -0
- struct2ui-0.1.0.dist-info/WHEEL +5 -0
- struct2ui-0.1.0.dist-info/licenses/LICENSE +21 -0
- struct2ui-0.1.0.dist-info/top_level.txt +1 -0
struct2ui/editor.py
ADDED
|
@@ -0,0 +1,1171 @@
|
|
|
1
|
+
"""Top-level widget: flow buttons + a content area driven by a Renderer.
|
|
2
|
+
|
|
3
|
+
Responsibilities (intentionally narrow):
|
|
4
|
+
1. Load the speech/flow JSON file.
|
|
5
|
+
2. Build flow buttons.
|
|
6
|
+
3. On selection, build a schema tree and hand off to the appropriate renderer.
|
|
7
|
+
|
|
8
|
+
Schema parsing lives in schema.py. UI rendering lives in the ui/ package.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any, Dict, List
|
|
16
|
+
|
|
17
|
+
from Qt import QtCore, QtGui, QtWidgets
|
|
18
|
+
|
|
19
|
+
from .schema import (
|
|
20
|
+
SchemaRegistry, FlowValidator, merge_instance,
|
|
21
|
+
StructField, ArrayField,
|
|
22
|
+
)
|
|
23
|
+
from .ui import pick_renderer, collect_view_values
|
|
24
|
+
from .exporters import (
|
|
25
|
+
emit_c, dumps_json, emit_bin, merge_abi, verify_sections,
|
|
26
|
+
parse_c_source, build_schema_dict,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StructEditor(QtWidgets.QWidget):
|
|
31
|
+
|
|
32
|
+
# Button keys and their built-in defaults. Each entry: the fallback text
|
|
33
|
+
# label, the custom icon filename under icons/, and the Qt standard icon
|
|
34
|
+
# used when the custom file is missing. appearance overrides per key.
|
|
35
|
+
_BUTTON_DEFAULTS = {
|
|
36
|
+
'modules': ('Modules', 'widgets_24dp_000000_FILL0_wght400_GRAD0_opsz24.png',
|
|
37
|
+
'SP_DirOpenIcon'),
|
|
38
|
+
'pipeline': ('Pipeline', 'flowchart_24dp_000000_FILL0_wght400_GRAD0_opsz24.png',
|
|
39
|
+
'SP_FileIcon'),
|
|
40
|
+
'reload': ('Reload', 'refresh_24dp_000000_FILL0_wght400_GRAD0_opsz24.png',
|
|
41
|
+
'SP_BrowserReload'),
|
|
42
|
+
'save': ('Save', 'save_24dp_000000_FILL0_wght400_GRAD0_opsz24.png',
|
|
43
|
+
'SP_DialogSaveButton'),
|
|
44
|
+
'save_as': ('Save As', 'save_as_24dp_000000_FILL0_wght400_GRAD0_opsz24.png',
|
|
45
|
+
'SP_DriveFDIcon'),
|
|
46
|
+
'export': ('Export', 'export_notes_24dp_000000_FILL0_wght400_GRAD0_opsz24.png',
|
|
47
|
+
'SP_ArrowDown'),
|
|
48
|
+
'elf': ('ELF', 'elf.png',
|
|
49
|
+
'SP_FileDialogContentsView'),
|
|
50
|
+
'report': ('Report', 'report_24dp_000000_FILL0_wght400_GRAD0_opsz24.png',
|
|
51
|
+
'SP_MessageBoxWarning'),
|
|
52
|
+
'c2j': ('C2J', 'c2j.png',
|
|
53
|
+
'SP_FileDialogDetailedView'),
|
|
54
|
+
'settings': ('Settings', 'settings_24dp_000000_FILL0_wght400_GRAD0_opsz24.png',
|
|
55
|
+
'SP_FileDialogDetailedView'),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def __init__(self, flow_file: str, cfg_dir: str, parent=None,
|
|
59
|
+
appearance: Dict[str, Dict[str, Any]] = None,
|
|
60
|
+
settings_org: str = 'struct2ui',
|
|
61
|
+
settings_app: str = 'StructEditor'):
|
|
62
|
+
super().__init__(parent)
|
|
63
|
+
# Per-button appearance config (for embedding as a standalone module).
|
|
64
|
+
# Shape: {key: {'mode': 'text'|'default'|'custom', 'icon': path,
|
|
65
|
+
# 'text': label}}. Unspecified keys fall back to _BUTTON_DEFAULTS
|
|
66
|
+
# (custom icon, then standard icon, then text).
|
|
67
|
+
self._appearance: Dict[str, Dict[str, Any]] = appearance or {}
|
|
68
|
+
self._icons_dir = os.path.join(
|
|
69
|
+
os.path.dirname(os.path.abspath(__file__)), 'icons')
|
|
70
|
+
# Remember the last paths the user loaded. The caller's
|
|
71
|
+
# arguments act as the seed/default; a previously saved value (if
|
|
72
|
+
# any) overrides them so each launch reopens what was last used.
|
|
73
|
+
self._settings = QtCore.QSettings(settings_org, settings_app)
|
|
74
|
+
self.speech_path = self._settings.value('flow_path', flow_file)
|
|
75
|
+
self.struct_dir = self._settings.value('cfg_dir', cfg_dir)
|
|
76
|
+
# Optional ELF the user picks for later .bin layout verification.
|
|
77
|
+
# Empty string means no ELF selected (verification stays off).
|
|
78
|
+
self.elf_path = self._settings.value('elf_path', '') or ''
|
|
79
|
+
|
|
80
|
+
# Flow data (populated by _reload via _load_flow).
|
|
81
|
+
self.registry: SchemaRegistry = None # type: ignore[assignment]
|
|
82
|
+
self._group_key: str = ''
|
|
83
|
+
self._flow: List[str] = []
|
|
84
|
+
self._section_specs: Dict[str, Dict[str, Any]] = {}
|
|
85
|
+
# Raw loaded flow document; Save patches and writes it back.
|
|
86
|
+
self._raw_data: Dict[str, Any] = {}
|
|
87
|
+
# Label of the section currently shown in the content area (None when
|
|
88
|
+
# an error/summary panel is shown). current_values() reads it back.
|
|
89
|
+
self._active_label: str = ''
|
|
90
|
+
# ELF layout verification report (strictest policy). Rebuilt on every
|
|
91
|
+
# reload / pipeline change; consulted by the Report panel and before
|
|
92
|
+
# exporting .bin. Stays empty when no ELF is selected.
|
|
93
|
+
self._elf_report = None
|
|
94
|
+
# Step 4b: errors that pin to a specific stage in the pipeline.
|
|
95
|
+
# Filled in _reload(); consumed by show_section() so a stage with
|
|
96
|
+
# errors paints an error panel instead of its (possibly broken)
|
|
97
|
+
# parameter UI. Errors that are not stage-scoped (cfg_t keyword
|
|
98
|
+
# mistakes, S1/S2/S4 declaration issues) stay console-only here.
|
|
99
|
+
self._stage_errors: Dict[str, List[Any]] = {}
|
|
100
|
+
|
|
101
|
+
# C2J tool state: last picked .c/.h path and the parse result it
|
|
102
|
+
# produced. Lives outside _reload() because the C->JSON converter is a
|
|
103
|
+
# standalone side tool that does not touch the pipeline/schema flow.
|
|
104
|
+
self._c2j_path: str = ''
|
|
105
|
+
|
|
106
|
+
# ---- UI skeleton ------------------------------------------------- #
|
|
107
|
+
# Step 4a: top path bar lets the user point at any cfg_t directory
|
|
108
|
+
# and any flow JSON. Edits do not auto-apply; the user clicks
|
|
109
|
+
# Reload to rebuild schema + flow (matches the staged reload model
|
|
110
|
+
# we use elsewhere - one explicit user action, one full rebuild).
|
|
111
|
+
self.path_bar = self._build_path_bar()
|
|
112
|
+
|
|
113
|
+
self.buttons_bar = QtWidgets.QWidget(self)
|
|
114
|
+
self.buttons_layout = QtWidgets.QHBoxLayout(self.buttons_bar)
|
|
115
|
+
self.buttons_layout.setContentsMargins(0, 0, 0, 0)
|
|
116
|
+
self.buttons_layout.setSpacing(12)
|
|
117
|
+
|
|
118
|
+
self.content_area = QtWidgets.QScrollArea(self)
|
|
119
|
+
self.content_area.setWidgetResizable(True)
|
|
120
|
+
self._content_widget: QtWidgets.QWidget = QtWidgets.QWidget()
|
|
121
|
+
self.content_area.setWidget(self._content_widget)
|
|
122
|
+
|
|
123
|
+
main_layout = QtWidgets.QVBoxLayout(self)
|
|
124
|
+
main_layout.addWidget(self.path_bar)
|
|
125
|
+
main_layout.addWidget(self.buttons_bar)
|
|
126
|
+
main_layout.addWidget(self.content_area)
|
|
127
|
+
|
|
128
|
+
self._reload()
|
|
129
|
+
|
|
130
|
+
# ---- path bar (Step 4a) --------------------------------------------- #
|
|
131
|
+
def _build_path_bar(self) -> QtWidgets.QWidget:
|
|
132
|
+
bar = QtWidgets.QWidget(self)
|
|
133
|
+
layout = QtWidgets.QHBoxLayout(bar)
|
|
134
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
135
|
+
layout.setSpacing(6)
|
|
136
|
+
|
|
137
|
+
# All buttons on one row. Appearance (text / default icon / custom
|
|
138
|
+
# icon) is resolved per key via _make_button so a host application
|
|
139
|
+
# can override any of them through the `appearance` argument.
|
|
140
|
+
self.cfg_btn = self._make_button('modules', self._pick_cfg_dir)
|
|
141
|
+
self.flow_btn = self._make_button('pipeline', self._pick_flow_file)
|
|
142
|
+
reload_btn = self._make_button('reload', self._on_reload_clicked)
|
|
143
|
+
save_btn = self._make_button('save', self._on_save_clicked)
|
|
144
|
+
save_as_btn = self._make_button('save_as', self._on_save_as_clicked)
|
|
145
|
+
export_btn = self._make_button('export', self._on_export_clicked)
|
|
146
|
+
self.elf_btn = self._make_button('elf', self._pick_elf_file)
|
|
147
|
+
self.elf_btn.setCheckable(True)
|
|
148
|
+
self.report_btn = self._make_button('report', self._on_report_clicked)
|
|
149
|
+
self.c2j_btn = self._make_button('c2j', self._on_c2j_clicked)
|
|
150
|
+
self.settings_btn = self._make_button('settings', self._on_settings_clicked)
|
|
151
|
+
|
|
152
|
+
# Group 1: inputs (Modules / Pipeline / ELF) plus Reload, which
|
|
153
|
+
# applies the selected sources by rebuilding. Separator divides it
|
|
154
|
+
# from group 2: outputs (Save / Save As / Export). Separator then
|
|
155
|
+
# divides group 3: tools (C2J / Report).
|
|
156
|
+
layout.addWidget(self.cfg_btn)
|
|
157
|
+
layout.addWidget(self.flow_btn)
|
|
158
|
+
layout.addWidget(self.elf_btn)
|
|
159
|
+
layout.addWidget(reload_btn)
|
|
160
|
+
layout.addWidget(self._make_separator())
|
|
161
|
+
layout.addWidget(save_btn)
|
|
162
|
+
layout.addWidget(save_as_btn)
|
|
163
|
+
layout.addWidget(export_btn)
|
|
164
|
+
layout.addWidget(self._make_separator())
|
|
165
|
+
layout.addWidget(self.c2j_btn)
|
|
166
|
+
layout.addWidget(self.report_btn)
|
|
167
|
+
# Stretch pushes Settings to the far right of the button row.
|
|
168
|
+
layout.addStretch(1)
|
|
169
|
+
layout.addWidget(self.settings_btn)
|
|
170
|
+
|
|
171
|
+
self._refresh_path_tooltips()
|
|
172
|
+
return bar
|
|
173
|
+
|
|
174
|
+
def _make_separator(self) -> QtWidgets.QFrame:
|
|
175
|
+
line = QtWidgets.QFrame(self)
|
|
176
|
+
line.setFrameShape(QtWidgets.QFrame.VLine)
|
|
177
|
+
line.setFrameShadow(QtWidgets.QFrame.Sunken)
|
|
178
|
+
return line
|
|
179
|
+
|
|
180
|
+
def _make_button(self, key: str, on_click) -> QtWidgets.QPushButton:
|
|
181
|
+
"""Build one path-bar button honouring per-key appearance config.
|
|
182
|
+
|
|
183
|
+
Resolution order for the visual:
|
|
184
|
+
mode 'text' -> show the label text, no icon
|
|
185
|
+
mode 'custom' -> use cfg['icon'] (or the bundled icons/ file)
|
|
186
|
+
mode 'default' -> use the Qt standard icon
|
|
187
|
+
Default mode is 'custom'; if the custom icon file is missing it
|
|
188
|
+
falls back to the standard icon, then to text. The label is always
|
|
189
|
+
kept as the accessible name / tooltip base.
|
|
190
|
+
"""
|
|
191
|
+
label, icon_file, std_name = self._BUTTON_DEFAULTS[key]
|
|
192
|
+
cfg = self._appearance.get(key, {})
|
|
193
|
+
text = cfg.get('text', label)
|
|
194
|
+
mode = cfg.get('mode', 'custom')
|
|
195
|
+
|
|
196
|
+
btn = QtWidgets.QPushButton(self)
|
|
197
|
+
btn.clicked.connect(on_click)
|
|
198
|
+
btn.setToolTip(text)
|
|
199
|
+
|
|
200
|
+
if mode == 'text':
|
|
201
|
+
btn.setText(text)
|
|
202
|
+
return btn
|
|
203
|
+
|
|
204
|
+
if mode == 'custom':
|
|
205
|
+
icon_path = cfg.get('icon') or os.path.join(
|
|
206
|
+
self._icons_dir, icon_file)
|
|
207
|
+
if os.path.exists(icon_path):
|
|
208
|
+
btn.setIcon(QtGui.QIcon(icon_path))
|
|
209
|
+
return btn
|
|
210
|
+
mode = 'default' # fall through to standard icon
|
|
211
|
+
|
|
212
|
+
std = getattr(QtWidgets.QStyle, std_name, None)
|
|
213
|
+
if std is not None:
|
|
214
|
+
btn.setIcon(self.style().standardIcon(std))
|
|
215
|
+
else:
|
|
216
|
+
btn.setText(text) # last-resort fallback
|
|
217
|
+
return btn
|
|
218
|
+
|
|
219
|
+
def _refresh_path_tooltips(self) -> None:
|
|
220
|
+
modules_text = self._appearance.get('modules', {}).get('text', 'Modules')
|
|
221
|
+
pipeline_text = self._appearance.get('pipeline', {}).get('text', 'Pipeline')
|
|
222
|
+
self.cfg_btn.setToolTip(
|
|
223
|
+
f'{modules_text}: {self.struct_dir}' if self.struct_dir
|
|
224
|
+
else f'{modules_text}: (select modules directory)')
|
|
225
|
+
self.flow_btn.setToolTip(
|
|
226
|
+
f'{pipeline_text}: {self.speech_path}' if self.speech_path
|
|
227
|
+
else f'{pipeline_text}: (select pipeline JSON)')
|
|
228
|
+
self._refresh_elf_tooltip()
|
|
229
|
+
|
|
230
|
+
def _refresh_elf_tooltip(self) -> None:
|
|
231
|
+
elf_text = self._appearance.get('elf', {}).get('text', 'ELF')
|
|
232
|
+
if self.elf_path:
|
|
233
|
+
self.elf_btn.setToolTip(
|
|
234
|
+
f'{elf_text}: {self.elf_path}\n(click to change, or pick the '
|
|
235
|
+
'same file to clear)')
|
|
236
|
+
else:
|
|
237
|
+
self.elf_btn.setToolTip(
|
|
238
|
+
f'{elf_text}: (none - pick an ELF to enable .bin layout '
|
|
239
|
+
'verification)')
|
|
240
|
+
self.elf_btn.setChecked(bool(self.elf_path))
|
|
241
|
+
|
|
242
|
+
def _pick_cfg_dir(self) -> None:
|
|
243
|
+
start = self.struct_dir or os.getcwd()
|
|
244
|
+
chosen = QtWidgets.QFileDialog.getExistingDirectory(
|
|
245
|
+
self, 'Select modules directory', start)
|
|
246
|
+
if chosen:
|
|
247
|
+
self.struct_dir = chosen
|
|
248
|
+
self._save_paths()
|
|
249
|
+
self._refresh_path_tooltips()
|
|
250
|
+
self._reload()
|
|
251
|
+
|
|
252
|
+
def _pick_flow_file(self) -> None:
|
|
253
|
+
start = self.speech_path or os.getcwd()
|
|
254
|
+
chosen, _ = QtWidgets.QFileDialog.getOpenFileName(
|
|
255
|
+
self, 'Select pipeline JSON', start, 'JSON files (*.json)')
|
|
256
|
+
if chosen:
|
|
257
|
+
self.speech_path = chosen
|
|
258
|
+
self._save_paths()
|
|
259
|
+
self._refresh_path_tooltips()
|
|
260
|
+
self._reload()
|
|
261
|
+
|
|
262
|
+
def _pick_elf_file(self) -> None:
|
|
263
|
+
# Picking the currently-selected ELF again clears the selection,
|
|
264
|
+
# turning verification back off.
|
|
265
|
+
start = self.elf_path or os.getcwd()
|
|
266
|
+
chosen, _ = QtWidgets.QFileDialog.getOpenFileName(
|
|
267
|
+
self, 'Select ELF for layout verification', start,
|
|
268
|
+
'ELF files (*.elf *.axf *.out);;All files (*)')
|
|
269
|
+
if not chosen:
|
|
270
|
+
self._refresh_elf_tooltip()
|
|
271
|
+
return
|
|
272
|
+
self.elf_path = '' if chosen == self.elf_path else chosen
|
|
273
|
+
self._settings.setValue('elf_path', self.elf_path)
|
|
274
|
+
self._refresh_elf_tooltip()
|
|
275
|
+
# Re-run the full load so ELF verification re-runs and the landing
|
|
276
|
+
# panel reflects any new layout issues.
|
|
277
|
+
self._reload()
|
|
278
|
+
|
|
279
|
+
def _on_reload_clicked(self) -> None:
|
|
280
|
+
self._save_paths()
|
|
281
|
+
self._refresh_path_tooltips()
|
|
282
|
+
self._reload()
|
|
283
|
+
|
|
284
|
+
def _save_paths(self) -> None:
|
|
285
|
+
self._settings.setValue('cfg_dir', self.struct_dir)
|
|
286
|
+
self._settings.setValue('flow_path', self.speech_path)
|
|
287
|
+
|
|
288
|
+
def closeEvent(self, ev) -> None: # Qt override
|
|
289
|
+
self._save_paths()
|
|
290
|
+
super().closeEvent(ev)
|
|
291
|
+
|
|
292
|
+
# ---- reload (build schema + flow + UI from current paths) ----------- #
|
|
293
|
+
def _reload(self) -> None:
|
|
294
|
+
# Schema registry (loads cfg_t/*.json once).
|
|
295
|
+
self.registry = SchemaRegistry(self.struct_dir)
|
|
296
|
+
|
|
297
|
+
# Cross-validate abc.json against the loaded schema (Step 2/3).
|
|
298
|
+
flow_report = FlowValidator(self.registry, self.speech_path).validate()
|
|
299
|
+
self.registry.report.extend(flow_report)
|
|
300
|
+
|
|
301
|
+
# Print load report to console (Step 1~3: console only).
|
|
302
|
+
print(self.registry.report.format(), end='')
|
|
303
|
+
|
|
304
|
+
# Reset flow data and rebuild UI from the new sources. Clearing
|
|
305
|
+
# _active_label first is essential: the upcoming _on_button_clicked
|
|
306
|
+
# would otherwise call _capture_active_section() against the previous
|
|
307
|
+
# pipeline's still-live widget and write its stale values into the
|
|
308
|
+
# freshly loaded document (overwriting same-named sections, so the
|
|
309
|
+
# new file appears not to load).
|
|
310
|
+
self._active_label = ''
|
|
311
|
+
self._group_key = ''
|
|
312
|
+
self._flow = []
|
|
313
|
+
self._section_specs = {}
|
|
314
|
+
self._raw_data = {}
|
|
315
|
+
self._stage_errors = {}
|
|
316
|
+
self._elf_report = None
|
|
317
|
+
self._load_flow()
|
|
318
|
+
self._index_stage_errors()
|
|
319
|
+
# Strictest ELF layout verification. Runs on every reload / pipeline
|
|
320
|
+
# change; skipped (left empty) when no ELF is selected.
|
|
321
|
+
self._verify_elf()
|
|
322
|
+
self._build_buttons()
|
|
323
|
+
# Step 4c: when the load report has any issue, land on a summary
|
|
324
|
+
# panel instead of auto-selecting the first stage. Lets the user
|
|
325
|
+
# see what's wrong before clicking into individual tabs. A clean
|
|
326
|
+
# load skips the summary and goes straight to the first stage.
|
|
327
|
+
report = self.registry.report
|
|
328
|
+
elf_failed = self._elf_report is not None and (
|
|
329
|
+
self._elf_report.has_errors or self._elf_report.has_warnings)
|
|
330
|
+
if report.has_errors or report.has_warnings or elf_failed:
|
|
331
|
+
self._show_report()
|
|
332
|
+
elif self._flow:
|
|
333
|
+
# Clean load: mirror a user click on the first stage so the
|
|
334
|
+
# button reflects the active tab. Going through the click
|
|
335
|
+
# handler keeps button-checked state and content in sync.
|
|
336
|
+
self._on_button_clicked(self._flow[0])
|
|
337
|
+
else:
|
|
338
|
+
self._set_content(QtWidgets.QWidget())
|
|
339
|
+
|
|
340
|
+
def _index_stage_errors(self) -> None:
|
|
341
|
+
"""Bucket pipeline errors by stage name.
|
|
342
|
+
|
|
343
|
+
Only errors raised against the active pipeline file and whose path
|
|
344
|
+
starts with '<group_key> -> <stage>' are recognised as stage-scoped.
|
|
345
|
+
Cfg_t-side errors (declaration mistakes, S1/S2/S4) and pipeline
|
|
346
|
+
errors at the group/Flow level fall through and remain visible only
|
|
347
|
+
in the console report.
|
|
348
|
+
"""
|
|
349
|
+
self._stage_errors = {label: [] for label in self._flow}
|
|
350
|
+
if not self._group_key:
|
|
351
|
+
return
|
|
352
|
+
prefix = f'{self._group_key} -> '
|
|
353
|
+
for issue in self.registry.report.errors:
|
|
354
|
+
if issue.file != self.speech_path:
|
|
355
|
+
continue
|
|
356
|
+
if not issue.path.startswith(prefix):
|
|
357
|
+
continue
|
|
358
|
+
tail = issue.path[len(prefix):]
|
|
359
|
+
stage = tail.split(' -> ', 1)[0]
|
|
360
|
+
if stage in self._stage_errors:
|
|
361
|
+
self._stage_errors[stage].append(issue)
|
|
362
|
+
|
|
363
|
+
def _other_errors(self) -> List[Any]:
|
|
364
|
+
"""Errors not pinned to any stage tab.
|
|
365
|
+
|
|
366
|
+
These are cfg_t-side problems (keyword typos, S1/S2/S4 semantic
|
|
367
|
+
audit) and pipeline errors at the group/Flow level. They have no
|
|
368
|
+
stage tab to live in, so the summary panel (Step 5 B) shows them
|
|
369
|
+
in full instead of leaving them console-only.
|
|
370
|
+
"""
|
|
371
|
+
pinned = {id(e) for errs in self._stage_errors.values() for e in errs}
|
|
372
|
+
return [e for e in self.registry.report.errors if id(e) not in pinned]
|
|
373
|
+
|
|
374
|
+
# ---- ELF layout verification ---------------------------------------- #
|
|
375
|
+
def _flow_sections(self) -> List[Any]:
|
|
376
|
+
"""(label, type_name, None) for every stage used by the selected
|
|
377
|
+
pipeline file. Only structs referenced by the pipeline are returned;
|
|
378
|
+
the modules library (struct_dir) is never enumerated here. Unlike
|
|
379
|
+
_export_sections this does not touch live editors, so it is safe to
|
|
380
|
+
call during reload before the UI exists.
|
|
381
|
+
"""
|
|
382
|
+
out: List[Any] = []
|
|
383
|
+
for label in self._flow:
|
|
384
|
+
spec = self._section_specs.get(label)
|
|
385
|
+
if not isinstance(spec, dict):
|
|
386
|
+
continue
|
|
387
|
+
type_name = spec.get('type') or ''
|
|
388
|
+
if type_name:
|
|
389
|
+
out.append((label, type_name, None))
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
def _verify_elf(self) -> None:
|
|
393
|
+
"""Cross-check the cfg structs *used by the pipeline file* against the
|
|
394
|
+
selected ELF's DWARF (strictest policy). Module-only mode (no pipeline
|
|
395
|
+
loaded) is not verified. No ELF -> no verification.
|
|
396
|
+
"""
|
|
397
|
+
self._elf_report = None
|
|
398
|
+
if not self.elf_path or self.registry is None:
|
|
399
|
+
return
|
|
400
|
+
# Pipeline-only: nothing to check without a loaded flow.
|
|
401
|
+
sections = self._flow_sections()
|
|
402
|
+
if not sections:
|
|
403
|
+
return
|
|
404
|
+
abi = merge_abi(self._raw_data.get('abi'))
|
|
405
|
+
self._elf_report = verify_sections(
|
|
406
|
+
sections, self.registry, self.elf_path, abi)
|
|
407
|
+
print(self._elf_report.format(), end='')
|
|
408
|
+
|
|
409
|
+
# ---- flow loading ---------------------------------------------------- #
|
|
410
|
+
def _load_flow(self) -> None:
|
|
411
|
+
if not os.path.exists(self.speech_path):
|
|
412
|
+
return
|
|
413
|
+
try:
|
|
414
|
+
with open(self.speech_path, 'r', encoding='utf-8') as f:
|
|
415
|
+
data = json.load(f)
|
|
416
|
+
except Exception:
|
|
417
|
+
return
|
|
418
|
+
if not isinstance(data, dict) or not data:
|
|
419
|
+
return
|
|
420
|
+
self._group_key = next(iter(data.keys()))
|
|
421
|
+
group = data.get(self._group_key, {})
|
|
422
|
+
if not isinstance(group, dict):
|
|
423
|
+
return
|
|
424
|
+
# Keep the raw loaded document so Save can write it back verbatim,
|
|
425
|
+
# patching only the 'items' of edited sections. This preserves group
|
|
426
|
+
# structure, render hints and any keys the UI does not render.
|
|
427
|
+
self._raw_data = data
|
|
428
|
+
self._flow = list(group.get('Flow', []) or [])
|
|
429
|
+
self._section_specs = {
|
|
430
|
+
label: spec for label, spec in group.items()
|
|
431
|
+
if label != 'Flow' and isinstance(spec, dict)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
# ---- buttons --------------------------------------------------------- #
|
|
435
|
+
def _build_buttons(self) -> None:
|
|
436
|
+
while self.buttons_layout.count():
|
|
437
|
+
item = self.buttons_layout.takeAt(0)
|
|
438
|
+
w = item.widget()
|
|
439
|
+
if w is not None:
|
|
440
|
+
w.setParent(None)
|
|
441
|
+
for label in self._flow:
|
|
442
|
+
btn = QtWidgets.QPushButton(label)
|
|
443
|
+
btn.setCheckable(True)
|
|
444
|
+
btn.clicked.connect(lambda _checked=False, lb=label: self._on_button_clicked(lb))
|
|
445
|
+
self.buttons_layout.addWidget(btn)
|
|
446
|
+
|
|
447
|
+
def _on_button_clicked(self, label: str) -> None:
|
|
448
|
+
# Preserve edits made in the section we are leaving before its widgets
|
|
449
|
+
# are destroyed by show_section().
|
|
450
|
+
self._capture_active_section()
|
|
451
|
+
for i in range(self.buttons_layout.count()):
|
|
452
|
+
w = self.buttons_layout.itemAt(i).widget()
|
|
453
|
+
if isinstance(w, QtWidgets.QPushButton):
|
|
454
|
+
w.setChecked(w.text() == label)
|
|
455
|
+
self.show_section(label)
|
|
456
|
+
|
|
457
|
+
# ---- section rendering ---------------------------------------------- #
|
|
458
|
+
def show_section(self, label: str) -> None:
|
|
459
|
+
# Step 4b: if this stage has pipeline errors, paint the error panel
|
|
460
|
+
# in place of the parameter UI. The parameter renderer assumes the
|
|
461
|
+
# stage spec is well-formed; rendering it with broken inputs would
|
|
462
|
+
# either crash or silently hide the cause from the user.
|
|
463
|
+
stage_errors = self._stage_errors.get(label) or []
|
|
464
|
+
if stage_errors:
|
|
465
|
+
self._active_label = ''
|
|
466
|
+
self._set_content(self._make_error_panel(label, stage_errors))
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
spec = self._section_specs.get(label)
|
|
470
|
+
if not isinstance(spec, dict):
|
|
471
|
+
self._active_label = ''
|
|
472
|
+
self._set_content(QtWidgets.QWidget())
|
|
473
|
+
return
|
|
474
|
+
type_name = spec.get('type') or ''
|
|
475
|
+
provided = spec.get('items', {}) or {}
|
|
476
|
+
|
|
477
|
+
root = self.registry.build(type_name)
|
|
478
|
+
values = merge_instance(root, provided)
|
|
479
|
+
renderer = pick_renderer(root, override=spec.get('render'))
|
|
480
|
+
widget = renderer.render(root, values)
|
|
481
|
+
self._active_label = label
|
|
482
|
+
self._set_content(widget)
|
|
483
|
+
|
|
484
|
+
def current_values(self) -> Dict[str, Any]:
|
|
485
|
+
"""Read the live editor values of the active section back into a dict.
|
|
486
|
+
|
|
487
|
+
Returns the section's values keyed by field name (matching the schema
|
|
488
|
+
of its `type`), or an empty dict when no parameter section is shown
|
|
489
|
+
(e.g. an error/summary panel is active). This is the read-back entry
|
|
490
|
+
point that downstream file-format export (UI -> JSON/.c/.bin) builds on.
|
|
491
|
+
"""
|
|
492
|
+
values = collect_view_values(self._content_widget)
|
|
493
|
+
return values if isinstance(values, dict) else {}
|
|
494
|
+
|
|
495
|
+
# ---- save (UI -> JSON) ---------------------------------------------- #
|
|
496
|
+
def _capture_active_section(self) -> None:
|
|
497
|
+
"""Read the live editors of the active section back into the raw
|
|
498
|
+
document, so navigating away or saving keeps the user's edits.
|
|
499
|
+
|
|
500
|
+
No-op when no parameter section is active (e.g. an error/summary
|
|
501
|
+
panel) or when the read-back yields nothing.
|
|
502
|
+
"""
|
|
503
|
+
if not self._active_label:
|
|
504
|
+
return
|
|
505
|
+
values = collect_view_values(self._content_widget)
|
|
506
|
+
if not isinstance(values, dict):
|
|
507
|
+
return
|
|
508
|
+
spec = self._section_specs.get(self._active_label)
|
|
509
|
+
if isinstance(spec, dict):
|
|
510
|
+
spec['items'] = values
|
|
511
|
+
group = self._raw_data.get(self._group_key)
|
|
512
|
+
if isinstance(group, dict) and isinstance(
|
|
513
|
+
group.get(self._active_label), dict):
|
|
514
|
+
group[self._active_label]['items'] = values
|
|
515
|
+
|
|
516
|
+
def _on_save_clicked(self) -> None:
|
|
517
|
+
if not self._raw_data:
|
|
518
|
+
return
|
|
519
|
+
self._save_to(self.speech_path)
|
|
520
|
+
|
|
521
|
+
def _on_save_as_clicked(self) -> None:
|
|
522
|
+
if not self._raw_data:
|
|
523
|
+
return
|
|
524
|
+
start = self.speech_path or os.getcwd()
|
|
525
|
+
chosen, _ = QtWidgets.QFileDialog.getSaveFileName(
|
|
526
|
+
self, 'Save pipeline JSON as', start, 'JSON files (*.json)')
|
|
527
|
+
if chosen:
|
|
528
|
+
self._save_to(chosen)
|
|
529
|
+
|
|
530
|
+
def _multiline_wrap_map(self) -> Dict[tuple, int]:
|
|
531
|
+
"""Map JSON path -> values-per-row for every widget=multiline scalar
|
|
532
|
+
array reachable from a pipeline stage. Path mirrors the saved document
|
|
533
|
+
layout: (group_key, stage_label, 'items', field, [nested...]).
|
|
534
|
+
"""
|
|
535
|
+
wrap: Dict[tuple, int] = {}
|
|
536
|
+
if not self._group_key or self.registry is None:
|
|
537
|
+
return wrap
|
|
538
|
+
|
|
539
|
+
def row_size(f: ArrayField) -> int:
|
|
540
|
+
if f.meta.get('widget') != 'multiline':
|
|
541
|
+
return 0
|
|
542
|
+
shape = f.meta.get('shape')
|
|
543
|
+
if isinstance(shape, (list, tuple)) and shape:
|
|
544
|
+
return int(shape[-1])
|
|
545
|
+
return 0
|
|
546
|
+
|
|
547
|
+
def walk(node, prefix: tuple) -> None:
|
|
548
|
+
if isinstance(node, StructField):
|
|
549
|
+
for child in node.children:
|
|
550
|
+
walk(child, prefix + (child.name,))
|
|
551
|
+
elif isinstance(node, ArrayField):
|
|
552
|
+
rs = row_size(node)
|
|
553
|
+
if rs > 0 and not isinstance(node.element, StructField):
|
|
554
|
+
wrap[prefix] = rs
|
|
555
|
+
elif isinstance(node.element, StructField):
|
|
556
|
+
for i in range(node.count):
|
|
557
|
+
walk(node.element, prefix + (i,))
|
|
558
|
+
|
|
559
|
+
for label in self._flow:
|
|
560
|
+
spec = self._section_specs.get(label)
|
|
561
|
+
if not isinstance(spec, dict):
|
|
562
|
+
continue
|
|
563
|
+
type_name = spec.get('type') or ''
|
|
564
|
+
if not type_name:
|
|
565
|
+
continue
|
|
566
|
+
root = self.registry.build(type_name)
|
|
567
|
+
base = (self._group_key, label, 'items')
|
|
568
|
+
for child in root.children:
|
|
569
|
+
walk(child, base + (child.name,))
|
|
570
|
+
return wrap
|
|
571
|
+
|
|
572
|
+
def _save_to(self, path: str) -> None:
|
|
573
|
+
# Fold the active section's live values in before serialising.
|
|
574
|
+
self._capture_active_section()
|
|
575
|
+
wrap_map = self._multiline_wrap_map()
|
|
576
|
+
resolver = (lambda p: wrap_map.get(p, 0)) if wrap_map else None
|
|
577
|
+
try:
|
|
578
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
579
|
+
f.write(dumps_json(self._raw_data, resolver))
|
|
580
|
+
except Exception as exc:
|
|
581
|
+
QtWidgets.QMessageBox.critical(
|
|
582
|
+
self, 'Save failed', f'Could not write {path}:\n{exc}')
|
|
583
|
+
return
|
|
584
|
+
# Saving As redirects subsequent Save/Reload to the new file.
|
|
585
|
+
if path != self.speech_path:
|
|
586
|
+
self.speech_path = path
|
|
587
|
+
self._refresh_path_tooltips()
|
|
588
|
+
self._save_paths()
|
|
589
|
+
QtWidgets.QMessageBox.information(
|
|
590
|
+
self, 'Saved', f'Pipeline saved to:\n{path}')
|
|
591
|
+
|
|
592
|
+
# ---- export (UI -> .c / .bin) --------------------------------------- #
|
|
593
|
+
_EXPORT_FILTERS = {
|
|
594
|
+
'c': 'C source (*.c)',
|
|
595
|
+
'bin': 'Binary (*.bin)',
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
def _on_export_clicked(self) -> None:
|
|
599
|
+
# Remember the last chosen export type; default to .c. The remembered
|
|
600
|
+
# type pre-selects the matching filter so the next export defaults to
|
|
601
|
+
# what was used before.
|
|
602
|
+
last_type = self._settings.value('export_type', 'c')
|
|
603
|
+
if last_type not in self._EXPORT_FILTERS:
|
|
604
|
+
last_type = 'c'
|
|
605
|
+
filters = ';;'.join(self._EXPORT_FILTERS.values())
|
|
606
|
+
start = self.speech_path or os.getcwd()
|
|
607
|
+
if start:
|
|
608
|
+
start = os.path.splitext(start)[0] + '.' + last_type
|
|
609
|
+
|
|
610
|
+
chosen, selected_filter = QtWidgets.QFileDialog.getSaveFileName(
|
|
611
|
+
self, 'Export as', start, filters,
|
|
612
|
+
self._EXPORT_FILTERS[last_type])
|
|
613
|
+
if not chosen:
|
|
614
|
+
return
|
|
615
|
+
|
|
616
|
+
ext = os.path.splitext(chosen)[1].lstrip('.').lower()
|
|
617
|
+
export_type = ext if ext in self._EXPORT_FILTERS else (
|
|
618
|
+
'bin' if selected_filter == self._EXPORT_FILTERS['bin'] else 'c')
|
|
619
|
+
if not ext:
|
|
620
|
+
chosen = f'{chosen}.{export_type}'
|
|
621
|
+
self._settings.setValue('export_type', export_type)
|
|
622
|
+
|
|
623
|
+
# Strictest ELF check before a .bin export. The .bin is a raw image
|
|
624
|
+
# of the target struct memory, so a layout mismatch with the ELF is
|
|
625
|
+
# the case that matters most. We still write the file (per design),
|
|
626
|
+
# but jump to the Report panel afterwards so the mismatch is seen.
|
|
627
|
+
elf_failed = False
|
|
628
|
+
if export_type == 'bin' and self.elf_path:
|
|
629
|
+
self._verify_elf()
|
|
630
|
+
elf_failed = self._elf_report is not None and \
|
|
631
|
+
self._elf_report.has_errors
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
content = self._render_export(export_type)
|
|
635
|
+
except Exception as exc:
|
|
636
|
+
QtWidgets.QMessageBox.critical(
|
|
637
|
+
self, 'Export failed', f'Could not build export:\n{exc}')
|
|
638
|
+
return
|
|
639
|
+
try:
|
|
640
|
+
if isinstance(content, bytes):
|
|
641
|
+
with open(chosen, 'wb') as f:
|
|
642
|
+
f.write(content)
|
|
643
|
+
else:
|
|
644
|
+
with open(chosen, 'w', encoding='utf-8') as f:
|
|
645
|
+
f.write(content)
|
|
646
|
+
except Exception as exc:
|
|
647
|
+
QtWidgets.QMessageBox.critical(
|
|
648
|
+
self, 'Export failed', f'Could not write {chosen}:\n{exc}')
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
if elf_failed:
|
|
652
|
+
n = len(self._elf_report.errors)
|
|
653
|
+
self._show_report()
|
|
654
|
+
QtWidgets.QMessageBox.warning(
|
|
655
|
+
self, 'Exported with ELF mismatch',
|
|
656
|
+
f'Exported ({export_type}) to:\n{chosen}\n\n'
|
|
657
|
+
f'WARNING: {n} ELF layout error'
|
|
658
|
+
f"{'s' if n != 1 else ''} detected. See the Report panel.")
|
|
659
|
+
return
|
|
660
|
+
QtWidgets.QMessageBox.information(
|
|
661
|
+
self, 'Exported', f'Exported ({export_type}) to:\n{chosen}')
|
|
662
|
+
|
|
663
|
+
def _export_sections(self) -> List[Any]:
|
|
664
|
+
"""Collect every pipeline section as (label, type_name, values).
|
|
665
|
+
|
|
666
|
+
The active section's live editor values are folded back first so its
|
|
667
|
+
latest edits are included; all sections are then read uniformly from
|
|
668
|
+
the raw document.
|
|
669
|
+
"""
|
|
670
|
+
self._capture_active_section()
|
|
671
|
+
sections: List[Any] = []
|
|
672
|
+
for label in self._flow:
|
|
673
|
+
spec = self._section_specs.get(label)
|
|
674
|
+
if not isinstance(spec, dict):
|
|
675
|
+
continue
|
|
676
|
+
type_name = spec.get('type') or ''
|
|
677
|
+
if not type_name:
|
|
678
|
+
continue
|
|
679
|
+
sections.append((label, type_name, spec.get('items', {}) or {}))
|
|
680
|
+
return sections
|
|
681
|
+
|
|
682
|
+
def _render_export(self, export_type: str):
|
|
683
|
+
sections = self._export_sections()
|
|
684
|
+
if export_type == 'bin':
|
|
685
|
+
abi = merge_abi(self._raw_data.get('abi'))
|
|
686
|
+
return emit_bin(sections, self.registry, abi)
|
|
687
|
+
return emit_c(sections, self.registry)
|
|
688
|
+
|
|
689
|
+
def _make_error_panel(self, label: str,
|
|
690
|
+
issues: List[Any]) -> QtWidgets.QWidget:
|
|
691
|
+
panel = QtWidgets.QWidget()
|
|
692
|
+
layout = QtWidgets.QVBoxLayout(panel)
|
|
693
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
694
|
+
|
|
695
|
+
header = QtWidgets.QLabel(
|
|
696
|
+
f"Stage '{label}' has {len(issues)} error"
|
|
697
|
+
f"{'s' if len(issues) != 1 else ''}:")
|
|
698
|
+
header.setStyleSheet('font-weight: bold; color: #b00020;')
|
|
699
|
+
layout.addWidget(header)
|
|
700
|
+
|
|
701
|
+
list_widget = QtWidgets.QListWidget()
|
|
702
|
+
list_widget.setWordWrap(True)
|
|
703
|
+
for issue in issues:
|
|
704
|
+
text_lines = [f'at {issue.path}', issue.message]
|
|
705
|
+
if issue.suggestion:
|
|
706
|
+
text_lines.append(issue.suggestion)
|
|
707
|
+
list_widget.addItem('\n'.join(text_lines))
|
|
708
|
+
layout.addWidget(list_widget)
|
|
709
|
+
return panel
|
|
710
|
+
|
|
711
|
+
def _make_summary_panel(self) -> QtWidgets.QWidget:
|
|
712
|
+
"""Landing panel shown after load when the report has any issue.
|
|
713
|
+
|
|
714
|
+
Errors are summarised by stage so the user can pick which tab to
|
|
715
|
+
open; the per-stage details live in the error panel rendered by
|
|
716
|
+
show_section() (Step 4b). Warnings are listed in full because they
|
|
717
|
+
are not pinned to any tab.
|
|
718
|
+
"""
|
|
719
|
+
report = self.registry.report
|
|
720
|
+
panel = QtWidgets.QWidget()
|
|
721
|
+
layout = QtWidgets.QVBoxLayout(panel)
|
|
722
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
723
|
+
layout.setSpacing(8)
|
|
724
|
+
|
|
725
|
+
header = QtWidgets.QLabel('Configuration loaded with issues')
|
|
726
|
+
header.setStyleSheet('font-weight: bold; font-size: 14px;')
|
|
727
|
+
layout.addWidget(header)
|
|
728
|
+
|
|
729
|
+
if report.has_errors:
|
|
730
|
+
stages_with_errors = [
|
|
731
|
+
(s, len(errs)) for s, errs in self._stage_errors.items()
|
|
732
|
+
if errs
|
|
733
|
+
]
|
|
734
|
+
other_errors = self._other_errors()
|
|
735
|
+
|
|
736
|
+
err_label = QtWidgets.QLabel(f'Errors: {len(report.errors)}')
|
|
737
|
+
err_label.setStyleSheet('font-weight: bold; color: #b00020;')
|
|
738
|
+
layout.addWidget(err_label)
|
|
739
|
+
|
|
740
|
+
if stages_with_errors:
|
|
741
|
+
err_list = QtWidgets.QListWidget()
|
|
742
|
+
err_list.setWordWrap(True)
|
|
743
|
+
for stage, count in stages_with_errors:
|
|
744
|
+
noun = 'error' if count == 1 else 'errors'
|
|
745
|
+
item = QtWidgets.QListWidgetItem(
|
|
746
|
+
f"Stage '{stage}': {count} {noun} "
|
|
747
|
+
f"(double-click to open)")
|
|
748
|
+
item.setData(QtCore.Qt.UserRole, stage)
|
|
749
|
+
err_list.addItem(item)
|
|
750
|
+
err_list.itemActivated.connect(
|
|
751
|
+
self._on_summary_item_activated)
|
|
752
|
+
layout.addWidget(err_list)
|
|
753
|
+
|
|
754
|
+
# Step 5 (B): errors not pinned to any stage (cfg_t-side typos,
|
|
755
|
+
# S1/S2/S4 audit, group/Flow-level problems) have no tab to open,
|
|
756
|
+
# so list them in full here instead of pointing at the console.
|
|
757
|
+
if other_errors:
|
|
758
|
+
other_label = QtWidgets.QLabel(
|
|
759
|
+
f'Other errors (not pinned to any stage): '
|
|
760
|
+
f'{len(other_errors)}')
|
|
761
|
+
other_label.setStyleSheet(
|
|
762
|
+
'font-weight: bold; color: #b00020;')
|
|
763
|
+
layout.addWidget(other_label)
|
|
764
|
+
|
|
765
|
+
other_list = QtWidgets.QListWidget()
|
|
766
|
+
other_list.setWordWrap(True)
|
|
767
|
+
for issue in other_errors:
|
|
768
|
+
lines = [f'{issue.file}']
|
|
769
|
+
if issue.path:
|
|
770
|
+
lines.append(f'at {issue.path}')
|
|
771
|
+
lines.append(issue.message)
|
|
772
|
+
if issue.suggestion:
|
|
773
|
+
lines.append(issue.suggestion)
|
|
774
|
+
other_list.addItem('\n'.join(lines))
|
|
775
|
+
layout.addWidget(other_list)
|
|
776
|
+
|
|
777
|
+
if report.has_warnings:
|
|
778
|
+
warn_label = QtWidgets.QLabel(
|
|
779
|
+
f'Warnings: {len(report.warnings)}')
|
|
780
|
+
warn_label.setStyleSheet('font-weight: bold; color: #a05a00;')
|
|
781
|
+
layout.addWidget(warn_label)
|
|
782
|
+
|
|
783
|
+
warn_list = QtWidgets.QListWidget()
|
|
784
|
+
warn_list.setWordWrap(True)
|
|
785
|
+
for issue in report.warnings:
|
|
786
|
+
lines = [f'at {issue.path}', issue.message]
|
|
787
|
+
if issue.suggestion:
|
|
788
|
+
lines.append(issue.suggestion)
|
|
789
|
+
warn_list.addItem('\n'.join(lines))
|
|
790
|
+
layout.addWidget(warn_list)
|
|
791
|
+
|
|
792
|
+
return panel
|
|
793
|
+
|
|
794
|
+
def _on_summary_item_activated(
|
|
795
|
+
self, item: QtWidgets.QListWidgetItem) -> None:
|
|
796
|
+
# Step 5 (A): summary rows for stages carry their stage name in
|
|
797
|
+
# UserRole; activating one opens that stage tab (and checks its
|
|
798
|
+
# button) by reusing the normal click handler. The "Other" row has
|
|
799
|
+
# no stage name, so it stays inert.
|
|
800
|
+
stage = item.data(QtCore.Qt.UserRole)
|
|
801
|
+
if stage in self._stage_errors:
|
|
802
|
+
self._on_button_clicked(stage)
|
|
803
|
+
|
|
804
|
+
# ---- C2J tool (standalone C -> cfg_t JSON converter) ---------------- #
|
|
805
|
+
def _on_c2j_clicked(self) -> None:
|
|
806
|
+
self._capture_active_section()
|
|
807
|
+
for i in range(self.buttons_layout.count()):
|
|
808
|
+
w = self.buttons_layout.itemAt(i).widget()
|
|
809
|
+
if isinstance(w, QtWidgets.QPushButton):
|
|
810
|
+
w.setChecked(False)
|
|
811
|
+
self._active_label = ''
|
|
812
|
+
self._set_content(self._make_c2j_panel())
|
|
813
|
+
|
|
814
|
+
def _make_c2j_panel(self) -> QtWidgets.QWidget:
|
|
815
|
+
"""Standalone converter: pick a .c/.h file, parse its
|
|
816
|
+
typedef struct / enum / #define into the cfg_t schema shape, let the
|
|
817
|
+
user tune scalar extension attributes (min/max/step/unit/tip/value),
|
|
818
|
+
then export one .json for the whole file.
|
|
819
|
+
|
|
820
|
+
Intentionally isolated from the pipeline: it never reads or writes
|
|
821
|
+
self._raw_data / self.registry, so it cannot disturb the main flow.
|
|
822
|
+
"""
|
|
823
|
+
panel = QtWidgets.QWidget()
|
|
824
|
+
layout = QtWidgets.QVBoxLayout(panel)
|
|
825
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
826
|
+
layout.setSpacing(8)
|
|
827
|
+
|
|
828
|
+
header = QtWidgets.QLabel('C \u2192 JSON \u8f6c\u6362')
|
|
829
|
+
header.setStyleSheet('font-weight: bold; font-size: 14px;')
|
|
830
|
+
layout.addWidget(header)
|
|
831
|
+
|
|
832
|
+
pick_row = QtWidgets.QHBoxLayout()
|
|
833
|
+
pick_btn = QtWidgets.QPushButton('\u9009\u62e9 .c / .h \u6587\u4ef6\u2026')
|
|
834
|
+
pick_btn.clicked.connect(self._c2j_pick_file)
|
|
835
|
+
self._c2j_path_label = QtWidgets.QLabel(self._c2j_path or '\uff08\u672a\u9009\u62e9\u6587\u4ef6\uff09')
|
|
836
|
+
self._c2j_path_label.setWordWrap(True)
|
|
837
|
+
pick_row.addWidget(pick_btn)
|
|
838
|
+
pick_row.addWidget(self._c2j_path_label, 1)
|
|
839
|
+
layout.addLayout(pick_row)
|
|
840
|
+
|
|
841
|
+
layout.addWidget(self._make_separator())
|
|
842
|
+
|
|
843
|
+
# Field-edit table for scalar extension attributes. Populated by
|
|
844
|
+
# _c2j_load_file(); empty until a file is parsed.
|
|
845
|
+
self._c2j_table = QtWidgets.QTableWidget()
|
|
846
|
+
self._c2j_table.setColumnCount(8)
|
|
847
|
+
self._c2j_table.setHorizontalHeaderLabels(
|
|
848
|
+
['struct', 'name', 'type', 'value', 'min', 'max', 'step', 'unit/tip'])
|
|
849
|
+
self._c2j_table.verticalHeader().setVisible(False)
|
|
850
|
+
# Match the array tables' grid and force an explicit header divider:
|
|
851
|
+
# when the table has no rows yet, some themes draw the header's bottom
|
|
852
|
+
# edge too faintly to see, so paint it via the section style.
|
|
853
|
+
self._c2j_table.setShowGrid(True)
|
|
854
|
+
self._c2j_table.setGridStyle(QtCore.Qt.SolidLine)
|
|
855
|
+
c2j_header = self._c2j_table.horizontalHeader()
|
|
856
|
+
c2j_header.setHighlightSections(False)
|
|
857
|
+
c2j_header.setStyleSheet(
|
|
858
|
+
'QHeaderView::section{border:0px;border-bottom:1px solid #b0b0b0;'
|
|
859
|
+
'border-right:1px solid #d0d0d0;}')
|
|
860
|
+
# Empty / narrow content: stretch columns to fill the page (no
|
|
861
|
+
# horizontal scrollbar). _c2j_fit_columns() switches to per-content
|
|
862
|
+
# widths once a file is loaded and the content is wider than the view.
|
|
863
|
+
c2j_header.setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
|
|
864
|
+
layout.addWidget(self._c2j_table, 1)
|
|
865
|
+
|
|
866
|
+
self._c2j_warn_list = QtWidgets.QListWidget()
|
|
867
|
+
self._c2j_warn_list.setWordWrap(True)
|
|
868
|
+
self._c2j_warn_list.setMaximumHeight(120)
|
|
869
|
+
layout.addWidget(self._c2j_warn_list)
|
|
870
|
+
|
|
871
|
+
export_row = QtWidgets.QHBoxLayout()
|
|
872
|
+
export_row.addStretch(1)
|
|
873
|
+
self._c2j_export_btn = QtWidgets.QPushButton('\u5bfc\u51fa JSON\u2026')
|
|
874
|
+
self._c2j_export_btn.setEnabled(False)
|
|
875
|
+
self._c2j_export_btn.clicked.connect(self._c2j_export)
|
|
876
|
+
export_row.addWidget(self._c2j_export_btn)
|
|
877
|
+
layout.addLayout(export_row)
|
|
878
|
+
|
|
879
|
+
# Reparse the previously picked file so reopening the panel restores
|
|
880
|
+
# the table instead of showing it empty.
|
|
881
|
+
if self._c2j_path and os.path.isfile(self._c2j_path):
|
|
882
|
+
self._c2j_load_file(self._c2j_path)
|
|
883
|
+
return panel
|
|
884
|
+
|
|
885
|
+
def _c2j_pick_file(self) -> None:
|
|
886
|
+
start = os.path.dirname(self._c2j_path) if self._c2j_path else self.struct_dir
|
|
887
|
+
path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
|
888
|
+
self, 'Select C source', start, 'C source (*.c *.h);;All files (*)')
|
|
889
|
+
if not path:
|
|
890
|
+
return
|
|
891
|
+
self._c2j_path = path
|
|
892
|
+
self._c2j_path_label.setText(path)
|
|
893
|
+
self._c2j_load_file(path)
|
|
894
|
+
|
|
895
|
+
def _c2j_load_file(self, path: str) -> None:
|
|
896
|
+
try:
|
|
897
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
898
|
+
text = f.read()
|
|
899
|
+
except OSError as e:
|
|
900
|
+
QtWidgets.QMessageBox.warning(self, 'C2J', f'cannot read file: {e}')
|
|
901
|
+
return
|
|
902
|
+
|
|
903
|
+
result = parse_c_source(text)
|
|
904
|
+
self._c2j_result = result
|
|
905
|
+
|
|
906
|
+
# One table row per scalar field across all parsed structs. Only
|
|
907
|
+
# scalars get editable extension attributes; enum / struct / array
|
|
908
|
+
# members are shown read-only (greyed) so the user sees the full shape
|
|
909
|
+
# but cannot type meaningless ranges into them.
|
|
910
|
+
rows: List[tuple] = []
|
|
911
|
+
for sname, block in result.schema.items():
|
|
912
|
+
if block.get('type') != 'struct':
|
|
913
|
+
continue
|
|
914
|
+
for field in block.get('items', []):
|
|
915
|
+
is_scalar = (
|
|
916
|
+
'count' not in field
|
|
917
|
+
and field['type'] not in result.schema
|
|
918
|
+
and field['type'] != 'char'
|
|
919
|
+
)
|
|
920
|
+
rows.append((sname, field, is_scalar))
|
|
921
|
+
|
|
922
|
+
table = self._c2j_table
|
|
923
|
+
table.setRowCount(len(rows))
|
|
924
|
+
self._c2j_rows = rows
|
|
925
|
+
for r, (sname, field, is_scalar) in enumerate(rows):
|
|
926
|
+
self._c2j_set_ro(table, r, 0, sname)
|
|
927
|
+
self._c2j_set_ro(table, r, 1, field['name'])
|
|
928
|
+
self._c2j_set_ro(table, r, 2, field['type'])
|
|
929
|
+
if is_scalar:
|
|
930
|
+
for c in (3, 4, 5, 6, 7):
|
|
931
|
+
table.setItem(r, c, QtWidgets.QTableWidgetItem(''))
|
|
932
|
+
else:
|
|
933
|
+
for c in (3, 4, 5, 6, 7):
|
|
934
|
+
self._c2j_set_ro(table, r, c, '')
|
|
935
|
+
self._c2j_fit_columns()
|
|
936
|
+
|
|
937
|
+
self._c2j_warn_list.clear()
|
|
938
|
+
for w in result.warnings:
|
|
939
|
+
self._c2j_warn_list.addItem(w)
|
|
940
|
+
|
|
941
|
+
self._c2j_export_btn.setEnabled(result.has_content)
|
|
942
|
+
|
|
943
|
+
def _c2j_fit_columns(self) -> None:
|
|
944
|
+
"""Pick a column-sizing strategy based on content width:
|
|
945
|
+
|
|
946
|
+
- content fits the viewport -> Stretch (fill page, no scrollbar)
|
|
947
|
+
- content wider than viewport -> Interactive at content widths,
|
|
948
|
+
letting the horizontal scrollbar appear.
|
|
949
|
+
"""
|
|
950
|
+
table = self._c2j_table
|
|
951
|
+
header = table.horizontalHeader()
|
|
952
|
+
header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
|
|
953
|
+
natural = sum(header.sectionSize(c) for c in range(table.columnCount()))
|
|
954
|
+
avail = table.viewport().width()
|
|
955
|
+
if avail <= 0 or natural <= avail:
|
|
956
|
+
header.setSectionResizeMode(QtWidgets.QHeaderView.Stretch)
|
|
957
|
+
else:
|
|
958
|
+
widths = [header.sectionSize(c) for c in range(table.columnCount())]
|
|
959
|
+
header.setSectionResizeMode(QtWidgets.QHeaderView.Interactive)
|
|
960
|
+
for c, w in enumerate(widths):
|
|
961
|
+
table.setColumnWidth(c, w)
|
|
962
|
+
|
|
963
|
+
@staticmethod
|
|
964
|
+
def _c2j_set_ro(table: QtWidgets.QTableWidget, row: int, col: int,
|
|
965
|
+
text: str) -> None:
|
|
966
|
+
item = QtWidgets.QTableWidgetItem(text)
|
|
967
|
+
item.setFlags(item.flags() & ~QtCore.Qt.ItemIsEditable)
|
|
968
|
+
item.setForeground(QtGui.QColor('#666'))
|
|
969
|
+
table.setItem(row, col, item)
|
|
970
|
+
|
|
971
|
+
@staticmethod
|
|
972
|
+
def _c2j_parse_num(text: str):
|
|
973
|
+
text = (text or '').strip()
|
|
974
|
+
if not text:
|
|
975
|
+
return None
|
|
976
|
+
try:
|
|
977
|
+
return int(text)
|
|
978
|
+
except ValueError:
|
|
979
|
+
pass
|
|
980
|
+
try:
|
|
981
|
+
return float(text)
|
|
982
|
+
except ValueError:
|
|
983
|
+
return text
|
|
984
|
+
|
|
985
|
+
def _c2j_apply_edits(self) -> None:
|
|
986
|
+
"""Fold the table's scalar-attribute edits back into the parsed field
|
|
987
|
+
dicts so the exported JSON carries them."""
|
|
988
|
+
table = self._c2j_table
|
|
989
|
+
for r, (_sname, field, is_scalar) in enumerate(self._c2j_rows):
|
|
990
|
+
if not is_scalar:
|
|
991
|
+
continue
|
|
992
|
+
value = self._c2j_parse_num(self._c2j_cell(table, r, 3))
|
|
993
|
+
vmin = self._c2j_parse_num(self._c2j_cell(table, r, 4))
|
|
994
|
+
vmax = self._c2j_parse_num(self._c2j_cell(table, r, 5))
|
|
995
|
+
step = self._c2j_parse_num(self._c2j_cell(table, r, 6))
|
|
996
|
+
unit = (self._c2j_cell(table, r, 7) or '').strip()
|
|
997
|
+
for key in ('value', 'min', 'max', 'step', 'unit', 'tip'):
|
|
998
|
+
field.pop(key, None)
|
|
999
|
+
if value is not None:
|
|
1000
|
+
field['value'] = value
|
|
1001
|
+
if vmin is not None:
|
|
1002
|
+
field['min'] = vmin
|
|
1003
|
+
if vmax is not None:
|
|
1004
|
+
field['max'] = vmax
|
|
1005
|
+
if step is not None:
|
|
1006
|
+
field['step'] = step
|
|
1007
|
+
if unit:
|
|
1008
|
+
field['unit'] = unit
|
|
1009
|
+
|
|
1010
|
+
@staticmethod
|
|
1011
|
+
def _c2j_cell(table: QtWidgets.QTableWidget, row: int, col: int) -> str:
|
|
1012
|
+
item = table.item(row, col)
|
|
1013
|
+
return item.text() if item is not None else ''
|
|
1014
|
+
|
|
1015
|
+
def _c2j_export(self) -> None:
|
|
1016
|
+
if not getattr(self, '_c2j_result', None) or not self._c2j_result.has_content:
|
|
1017
|
+
return
|
|
1018
|
+
self._c2j_apply_edits()
|
|
1019
|
+
out = build_schema_dict(self._c2j_result)
|
|
1020
|
+
|
|
1021
|
+
base = os.path.splitext(os.path.basename(self._c2j_path))[0]
|
|
1022
|
+
default = os.path.join(self.struct_dir, base + '.json')
|
|
1023
|
+
path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
|
1024
|
+
self, 'Export JSON', default, 'JSON (*.json);;All files (*)')
|
|
1025
|
+
if not path:
|
|
1026
|
+
return
|
|
1027
|
+
try:
|
|
1028
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
1029
|
+
f.write(dumps_json(out))
|
|
1030
|
+
except OSError as e:
|
|
1031
|
+
QtWidgets.QMessageBox.warning(self, 'C2J', f'cannot write file: {e}')
|
|
1032
|
+
return
|
|
1033
|
+
QtWidgets.QMessageBox.information(self, 'C2J', f'\u5df2\u5bfc\u51fa\uff1a{path}')
|
|
1034
|
+
|
|
1035
|
+
# ---- settings panel (placeholder, reserved for future options) ------ #
|
|
1036
|
+
def _on_settings_clicked(self) -> None:
|
|
1037
|
+
self._capture_active_section()
|
|
1038
|
+
for i in range(self.buttons_layout.count()):
|
|
1039
|
+
w = self.buttons_layout.itemAt(i).widget()
|
|
1040
|
+
if isinstance(w, QtWidgets.QPushButton):
|
|
1041
|
+
w.setChecked(False)
|
|
1042
|
+
self._active_label = ''
|
|
1043
|
+
self._set_content(self._make_settings_panel())
|
|
1044
|
+
|
|
1045
|
+
def _make_settings_panel(self) -> QtWidgets.QWidget:
|
|
1046
|
+
"""Reserved settings page. No options yet; kept as an extension point
|
|
1047
|
+
so future global preferences land here without touching the button
|
|
1048
|
+
wiring again."""
|
|
1049
|
+
panel = QtWidgets.QWidget()
|
|
1050
|
+
layout = QtWidgets.QVBoxLayout(panel)
|
|
1051
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
1052
|
+
layout.setSpacing(8)
|
|
1053
|
+
|
|
1054
|
+
header = QtWidgets.QLabel('Settings')
|
|
1055
|
+
header.setStyleSheet('font-weight: bold; font-size: 14px;')
|
|
1056
|
+
layout.addWidget(header)
|
|
1057
|
+
|
|
1058
|
+
placeholder = QtWidgets.QLabel('\u6682\u65e0\u8bbe\u7f6e\u9879\u3002')
|
|
1059
|
+
placeholder.setStyleSheet('color: #666;')
|
|
1060
|
+
layout.addWidget(placeholder)
|
|
1061
|
+
layout.addStretch(1)
|
|
1062
|
+
return panel
|
|
1063
|
+
|
|
1064
|
+
# ---- report panel (config issues + ELF layout verification) --------- #
|
|
1065
|
+
def _on_report_clicked(self) -> None:
|
|
1066
|
+
self._capture_active_section()
|
|
1067
|
+
self._show_report()
|
|
1068
|
+
|
|
1069
|
+
def _show_report(self) -> None:
|
|
1070
|
+
for i in range(self.buttons_layout.count()):
|
|
1071
|
+
w = self.buttons_layout.itemAt(i).widget()
|
|
1072
|
+
if isinstance(w, QtWidgets.QPushButton):
|
|
1073
|
+
w.setChecked(False)
|
|
1074
|
+
self._active_label = ''
|
|
1075
|
+
self._set_content(self._make_report_panel())
|
|
1076
|
+
|
|
1077
|
+
def _make_report_panel(self) -> QtWidgets.QWidget:
|
|
1078
|
+
"""Unified overview: configuration load issues (cfg_t + pipeline)
|
|
1079
|
+
followed by ELF layout verification results. Always renders, showing
|
|
1080
|
+
an all-clear line for each section that has no problems.
|
|
1081
|
+
"""
|
|
1082
|
+
panel = QtWidgets.QWidget()
|
|
1083
|
+
layout = QtWidgets.QVBoxLayout(panel)
|
|
1084
|
+
layout.setContentsMargins(8, 8, 8, 8)
|
|
1085
|
+
layout.setSpacing(8)
|
|
1086
|
+
|
|
1087
|
+
report = self.registry.report
|
|
1088
|
+
if report.has_errors or report.has_warnings:
|
|
1089
|
+
layout.addWidget(self._make_summary_panel())
|
|
1090
|
+
else:
|
|
1091
|
+
ok = QtWidgets.QLabel('Configuration: no issues')
|
|
1092
|
+
ok.setStyleSheet('font-weight: bold; color: #1b7f3b;')
|
|
1093
|
+
layout.addWidget(ok)
|
|
1094
|
+
|
|
1095
|
+
layout.addWidget(self._make_separator())
|
|
1096
|
+
layout.addWidget(self._make_elf_section())
|
|
1097
|
+
layout.addStretch(1)
|
|
1098
|
+
return panel
|
|
1099
|
+
|
|
1100
|
+
def _make_elf_section(self) -> QtWidgets.QWidget:
|
|
1101
|
+
box = QtWidgets.QWidget()
|
|
1102
|
+
layout = QtWidgets.QVBoxLayout(box)
|
|
1103
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
1104
|
+
layout.setSpacing(6)
|
|
1105
|
+
|
|
1106
|
+
title = QtWidgets.QLabel('ELF layout verification')
|
|
1107
|
+
title.setStyleSheet('font-weight: bold; font-size: 14px;')
|
|
1108
|
+
layout.addWidget(title)
|
|
1109
|
+
|
|
1110
|
+
if not self.elf_path:
|
|
1111
|
+
note = QtWidgets.QLabel(
|
|
1112
|
+
'No ELF selected - layout verification is off. '
|
|
1113
|
+
'Pick an ELF to enable strict layout checks.')
|
|
1114
|
+
note.setWordWrap(True)
|
|
1115
|
+
note.setStyleSheet('color: #666;')
|
|
1116
|
+
layout.addWidget(note)
|
|
1117
|
+
return box
|
|
1118
|
+
|
|
1119
|
+
src = QtWidgets.QLabel(f'ELF: {self.elf_path}')
|
|
1120
|
+
src.setWordWrap(True)
|
|
1121
|
+
src.setStyleSheet('color: #666;')
|
|
1122
|
+
layout.addWidget(src)
|
|
1123
|
+
|
|
1124
|
+
rep = self._elf_report
|
|
1125
|
+
if rep is None or (not rep.has_errors and not rep.has_warnings):
|
|
1126
|
+
ok = QtWidgets.QLabel('Layout matches the ELF (no issues)')
|
|
1127
|
+
ok.setStyleSheet('font-weight: bold; color: #1b7f3b;')
|
|
1128
|
+
layout.addWidget(ok)
|
|
1129
|
+
return box
|
|
1130
|
+
|
|
1131
|
+
if rep.has_errors:
|
|
1132
|
+
err_label = QtWidgets.QLabel(f'Errors: {len(rep.errors)}')
|
|
1133
|
+
err_label.setStyleSheet('font-weight: bold; color: #b00020;')
|
|
1134
|
+
layout.addWidget(err_label)
|
|
1135
|
+
err_list = QtWidgets.QListWidget()
|
|
1136
|
+
err_list.setWordWrap(True)
|
|
1137
|
+
for issue in rep.errors:
|
|
1138
|
+
lines = [f'{issue.file}']
|
|
1139
|
+
if issue.path:
|
|
1140
|
+
lines.append(f'at {issue.path}')
|
|
1141
|
+
lines.append(issue.message)
|
|
1142
|
+
if issue.suggestion:
|
|
1143
|
+
lines.append(issue.suggestion)
|
|
1144
|
+
err_list.addItem('\n'.join(lines))
|
|
1145
|
+
layout.addWidget(err_list)
|
|
1146
|
+
|
|
1147
|
+
if rep.has_warnings:
|
|
1148
|
+
warn_label = QtWidgets.QLabel(f'Warnings: {len(rep.warnings)}')
|
|
1149
|
+
warn_label.setStyleSheet('font-weight: bold; color: #a05a00;')
|
|
1150
|
+
layout.addWidget(warn_label)
|
|
1151
|
+
warn_list = QtWidgets.QListWidget()
|
|
1152
|
+
warn_list.setWordWrap(True)
|
|
1153
|
+
for issue in rep.warnings:
|
|
1154
|
+
lines = []
|
|
1155
|
+
if issue.path:
|
|
1156
|
+
lines.append(f'at {issue.path}')
|
|
1157
|
+
lines.append(issue.message)
|
|
1158
|
+
if issue.suggestion:
|
|
1159
|
+
lines.append(issue.suggestion)
|
|
1160
|
+
warn_list.addItem('\n'.join(lines))
|
|
1161
|
+
layout.addWidget(warn_list)
|
|
1162
|
+
|
|
1163
|
+
return box
|
|
1164
|
+
|
|
1165
|
+
def _set_content(self, widget: QtWidgets.QWidget) -> None:
|
|
1166
|
+
old = self._content_widget
|
|
1167
|
+
self.content_area.takeWidget()
|
|
1168
|
+
if old is not None:
|
|
1169
|
+
old.deleteLater()
|
|
1170
|
+
self._content_widget = widget
|
|
1171
|
+
self.content_area.setWidget(widget)
|