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/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)