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/ui/widgets.py
ADDED
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
"""Leaf-widget production and reactive enabling.
|
|
2
|
+
|
|
3
|
+
WidgetFactory - Field + value -> editor QWidget (and reverse: QWidget -> value)
|
|
4
|
+
WhenBinder - collect (dotted_name, widget, when-clause) and wire enable/disable
|
|
5
|
+
|
|
6
|
+
Adding a new editor widget type only requires adding entries to:
|
|
7
|
+
_READERS - QWidget type -> reader function
|
|
8
|
+
_CHANGE_SIGNALS - QWidget type -> change-signal name
|
|
9
|
+
plus a builder branch in WidgetFactory if needed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from decimal import Decimal, InvalidOperation
|
|
16
|
+
from typing import Any, Callable, Dict, List, Tuple
|
|
17
|
+
|
|
18
|
+
from Qt import QtCore, QtGui, QtWidgets
|
|
19
|
+
|
|
20
|
+
from ..schema import Field, ScalarField, EnumField, StructField, ArrayField
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --------------------------------------------------------------------------- #
|
|
24
|
+
# Centralised widget <-> value mappings (single source of truth)
|
|
25
|
+
# --------------------------------------------------------------------------- #
|
|
26
|
+
|
|
27
|
+
# Reader: how to extract the current value from each widget type.
|
|
28
|
+
_READERS: Dict[type, Callable[[QtWidgets.QWidget], Any]] = {
|
|
29
|
+
QtWidgets.QCheckBox: lambda w: w.isChecked(),
|
|
30
|
+
QtWidgets.QPushButton: lambda w: w.isChecked(),
|
|
31
|
+
QtWidgets.QComboBox: lambda w: w.currentText(),
|
|
32
|
+
QtWidgets.QSpinBox: lambda w: w.value(),
|
|
33
|
+
QtWidgets.QDoubleSpinBox: lambda w: w.value(),
|
|
34
|
+
QtWidgets.QSlider: lambda w: w.value(),
|
|
35
|
+
QtWidgets.QDial: lambda w: w.value(),
|
|
36
|
+
QtWidgets.QLineEdit: lambda w: w.text(),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Change signal name: used by WhenBinder to subscribe to value changes.
|
|
40
|
+
_CHANGE_SIGNALS: Dict[type, str] = {
|
|
41
|
+
QtWidgets.QCheckBox: 'toggled',
|
|
42
|
+
QtWidgets.QPushButton: 'toggled',
|
|
43
|
+
QtWidgets.QComboBox: 'currentTextChanged',
|
|
44
|
+
QtWidgets.QSpinBox: 'valueChanged',
|
|
45
|
+
QtWidgets.QDoubleSpinBox: 'valueChanged',
|
|
46
|
+
QtWidgets.QSlider: 'valueChanged',
|
|
47
|
+
QtWidgets.QDial: 'valueChanged',
|
|
48
|
+
QtWidgets.QLineEdit: 'textChanged',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _lookup(mapping: Dict[type, Any], w: QtWidgets.QWidget) -> Any:
|
|
53
|
+
"""Find a mapping entry honouring isinstance (so subclasses are matched).
|
|
54
|
+
|
|
55
|
+
Exact-type match wins over isinstance, so subclasses (e.g.
|
|
56
|
+
`_ScalarArrayLineEdit` extending `QLineEdit`) can register their own
|
|
57
|
+
reader without being shadowed by the base class entry.
|
|
58
|
+
"""
|
|
59
|
+
exact = mapping.get(type(w))
|
|
60
|
+
if exact is not None:
|
|
61
|
+
return exact
|
|
62
|
+
for cls, val in mapping.items():
|
|
63
|
+
if isinstance(w, cls):
|
|
64
|
+
return val
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --------------------------------------------------------------------------- #
|
|
69
|
+
# WidgetFactory: stateless builder
|
|
70
|
+
# --------------------------------------------------------------------------- #
|
|
71
|
+
|
|
72
|
+
class WidgetFactory:
|
|
73
|
+
"""Build an editor widget for a leaf Field (scalar/enum); read its value back."""
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def build(f: Field, value: Any) -> QtWidgets.QWidget:
|
|
77
|
+
if isinstance(f, ArrayField):
|
|
78
|
+
return _build_array_value(f, value)
|
|
79
|
+
if isinstance(f, EnumField):
|
|
80
|
+
return _build_enum(f, value)
|
|
81
|
+
if isinstance(f, ScalarField):
|
|
82
|
+
ui = f.ui_type
|
|
83
|
+
if ui == 'bool':
|
|
84
|
+
return _build_bool(f, value)
|
|
85
|
+
if ui == 'int':
|
|
86
|
+
return _build_int(f, value)
|
|
87
|
+
if ui == 'float':
|
|
88
|
+
return _build_float(f, value)
|
|
89
|
+
if ui == 'str':
|
|
90
|
+
return _build_text(value, f)
|
|
91
|
+
return _build_text(value)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def read(w: QtWidgets.QWidget) -> Any:
|
|
95
|
+
reader = _lookup(_READERS, w)
|
|
96
|
+
return reader(w) if reader else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---- per-type small builders (easy to extend) ----------------------------- #
|
|
100
|
+
|
|
101
|
+
def _build_enum(f: EnumField, value: Any) -> QtWidgets.QComboBox:
|
|
102
|
+
w = QtWidgets.QComboBox()
|
|
103
|
+
for k in f.items.keys():
|
|
104
|
+
w.addItem(k)
|
|
105
|
+
idx = w.findText(str(value))
|
|
106
|
+
if idx >= 0:
|
|
107
|
+
w.setCurrentIndex(idx)
|
|
108
|
+
return w
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _build_bool(f: ScalarField, value: Any) -> QtWidgets.QWidget:
|
|
112
|
+
if f.meta.get('widget') == 'toggle':
|
|
113
|
+
w = QtWidgets.QPushButton()
|
|
114
|
+
w.setCheckable(True)
|
|
115
|
+
w.setChecked(bool(value))
|
|
116
|
+
_toggle_refresh_text(w)
|
|
117
|
+
w.toggled.connect(lambda _=None, b=w: _toggle_refresh_text(b))
|
|
118
|
+
return w
|
|
119
|
+
w = QtWidgets.QCheckBox()
|
|
120
|
+
w.setChecked(bool(value))
|
|
121
|
+
return w
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _toggle_refresh_text(b: QtWidgets.QPushButton) -> None:
|
|
125
|
+
b.setText('ON' if b.isChecked() else 'OFF')
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _RangedSlider(QtWidgets.QSlider):
|
|
129
|
+
"""QSlider that paints only the current value, centered in its own rect.
|
|
130
|
+
|
|
131
|
+
Min/max are displayed by the surrounding `_LabeledSliderHolder` via real
|
|
132
|
+
QLabel widgets, so they are never overlapped by the handle.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, parent: QtWidgets.QWidget = None) -> None:
|
|
136
|
+
super().__init__(QtCore.Qt.Horizontal, parent)
|
|
137
|
+
f = self.font()
|
|
138
|
+
f.setPointSizeF(max(7.0, f.pointSizeF() - 1.0))
|
|
139
|
+
self.setFont(f)
|
|
140
|
+
self.valueChanged.connect(self.update)
|
|
141
|
+
|
|
142
|
+
def paintEvent(self, ev) -> None: # Qt override
|
|
143
|
+
super().paintEvent(ev)
|
|
144
|
+
painter = QtGui.QPainter(self)
|
|
145
|
+
fm = painter.fontMetrics()
|
|
146
|
+
rect = self.rect()
|
|
147
|
+
y = rect.center().y() + fm.ascent() // 2 - 1
|
|
148
|
+
painter.setPen(self.palette().color(QtGui.QPalette.WindowText))
|
|
149
|
+
cur_text = str(self.value())
|
|
150
|
+
cx = rect.center().x() - fm.horizontalAdvance(cur_text) // 2
|
|
151
|
+
painter.drawText(cx, y, cur_text)
|
|
152
|
+
painter.end()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _LabeledSliderHolder(QtWidgets.QWidget):
|
|
156
|
+
"""Horizontal composite: [min label] [QSlider] [max label].
|
|
157
|
+
|
|
158
|
+
Exposes the inner slider via `.slider` so existing isinstance-based
|
|
159
|
+
machinery (_READERS / _CHANGE_SIGNALS / WhenBinder) can still reach it.
|
|
160
|
+
Two registry entries below route reads/signals to this holder directly.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, mn: int, mx: int, parent: QtWidgets.QWidget = None) -> None:
|
|
164
|
+
super().__init__(parent)
|
|
165
|
+
self.slider = _RangedSlider(self)
|
|
166
|
+
self.slider.setRange(mn, mx)
|
|
167
|
+
|
|
168
|
+
lbl_mn = QtWidgets.QLabel(str(mn), self)
|
|
169
|
+
lbl_mx = QtWidgets.QLabel(str(mx), self)
|
|
170
|
+
for lbl in (lbl_mn, lbl_mx):
|
|
171
|
+
f = lbl.font()
|
|
172
|
+
f.setPointSizeF(max(7.0, f.pointSizeF() - 1.0))
|
|
173
|
+
lbl.setFont(f)
|
|
174
|
+
|
|
175
|
+
lay = QtWidgets.QHBoxLayout(self)
|
|
176
|
+
lay.setContentsMargins(0, 0, 0, 0)
|
|
177
|
+
lay.setSpacing(4)
|
|
178
|
+
lay.addWidget(lbl_mn)
|
|
179
|
+
lay.addWidget(self.slider, 1)
|
|
180
|
+
lay.addWidget(lbl_mx)
|
|
181
|
+
|
|
182
|
+
# Convenience pass-throughs so callers can treat the holder like a slider.
|
|
183
|
+
def value(self) -> int:
|
|
184
|
+
return self.slider.value()
|
|
185
|
+
|
|
186
|
+
def setValue(self, v: int) -> None:
|
|
187
|
+
self.slider.setValue(int(v))
|
|
188
|
+
|
|
189
|
+
def setSingleStep(self, s: int) -> None:
|
|
190
|
+
self.slider.setSingleStep(int(s))
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def valueChanged(self):
|
|
194
|
+
return self.slider.valueChanged
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Register holder so WidgetFactory.read() and WhenBinder see it natively.
|
|
198
|
+
_READERS[_LabeledSliderHolder] = lambda w: w.value()
|
|
199
|
+
_CHANGE_SIGNALS[_LabeledSliderHolder] = 'valueChanged'
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _DialPopup(QtWidgets.QWidget):
|
|
203
|
+
"""Floating QDial + centered value label, owned by `_DialSpinBox`.
|
|
204
|
+
|
|
205
|
+
Uses `Qt.ToolTip` window flag (instead of `Qt.Popup`) and `Qt.NoFocus`
|
|
206
|
+
focus policy so it never steals focus from the spin box. The spin box
|
|
207
|
+
itself decides when to show/hide it via focusIn/focusOut.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self, mn: int, mx: int, parent: QtWidgets.QWidget = None) -> None:
|
|
211
|
+
super().__init__(parent, QtCore.Qt.ToolTip | QtCore.Qt.FramelessWindowHint)
|
|
212
|
+
self.setFocusPolicy(QtCore.Qt.NoFocus)
|
|
213
|
+
self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating, True)
|
|
214
|
+
|
|
215
|
+
self.dial = QtWidgets.QDial(self)
|
|
216
|
+
self.dial.setFocusPolicy(QtCore.Qt.NoFocus)
|
|
217
|
+
self.dial.setRange(int(mn), int(mx))
|
|
218
|
+
self.dial.setNotchesVisible(True)
|
|
219
|
+
self.dial.setFixedSize(96, 96)
|
|
220
|
+
|
|
221
|
+
self.label = QtWidgets.QLabel('0', self.dial)
|
|
222
|
+
self.label.setAlignment(QtCore.Qt.AlignCenter)
|
|
223
|
+
self.label.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, True)
|
|
224
|
+
f = self.label.font()
|
|
225
|
+
f.setBold(True)
|
|
226
|
+
self.label.setFont(f)
|
|
227
|
+
|
|
228
|
+
lay = QtWidgets.QVBoxLayout(self)
|
|
229
|
+
lay.setContentsMargins(6, 6, 6, 6)
|
|
230
|
+
lay.addWidget(self.dial)
|
|
231
|
+
|
|
232
|
+
self.dial.valueChanged.connect(self._on_dial)
|
|
233
|
+
|
|
234
|
+
def _on_dial(self, v: int) -> None:
|
|
235
|
+
self.label.setText(str(v))
|
|
236
|
+
|
|
237
|
+
def resizeEvent(self, ev) -> None: # Qt override
|
|
238
|
+
super().resizeEvent(ev)
|
|
239
|
+
self.label.setGeometry(self.dial.rect())
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class _DialSpinBox(QtWidgets.QSpinBox):
|
|
243
|
+
"""QSpinBox that pops up a floating QDial while it has focus.
|
|
244
|
+
|
|
245
|
+
Read path stays trivial: outer widget IS a QSpinBox, so the existing
|
|
246
|
+
`_READERS[QSpinBox]` and `_CHANGE_SIGNALS[QSpinBox]` entries already work.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def __init__(self, mn: int, mx: int, parent: QtWidgets.QWidget = None) -> None:
|
|
250
|
+
super().__init__(parent)
|
|
251
|
+
self.setRange(int(mn), int(mx))
|
|
252
|
+
self._popup = _DialPopup(int(mn), int(mx))
|
|
253
|
+
self._guard = False
|
|
254
|
+
self.valueChanged.connect(self._sync_to_dial)
|
|
255
|
+
self._popup.dial.valueChanged.connect(self._sync_from_dial)
|
|
256
|
+
|
|
257
|
+
def _sync_to_dial(self, v: int) -> None:
|
|
258
|
+
if self._guard:
|
|
259
|
+
return
|
|
260
|
+
self._guard = True
|
|
261
|
+
try:
|
|
262
|
+
self._popup.dial.setValue(int(v))
|
|
263
|
+
self._popup.label.setText(str(int(v)))
|
|
264
|
+
finally:
|
|
265
|
+
self._guard = False
|
|
266
|
+
|
|
267
|
+
def _sync_from_dial(self, v: int) -> None:
|
|
268
|
+
if self._guard:
|
|
269
|
+
return
|
|
270
|
+
self._guard = True
|
|
271
|
+
try:
|
|
272
|
+
self.setValue(int(v))
|
|
273
|
+
finally:
|
|
274
|
+
self._guard = False
|
|
275
|
+
|
|
276
|
+
def _show_popup(self) -> None:
|
|
277
|
+
self._popup.dial.setValue(self.value())
|
|
278
|
+
self._popup.label.setText(str(self.value()))
|
|
279
|
+
self._popup.adjustSize()
|
|
280
|
+
gp = self.mapToGlobal(QtCore.QPoint(0, self.height()))
|
|
281
|
+
self._popup.move(gp)
|
|
282
|
+
self._popup.show()
|
|
283
|
+
|
|
284
|
+
def focusInEvent(self, ev) -> None: # Qt override
|
|
285
|
+
super().focusInEvent(ev)
|
|
286
|
+
self._show_popup()
|
|
287
|
+
|
|
288
|
+
def focusOutEvent(self, ev) -> None: # Qt override
|
|
289
|
+
super().focusOutEvent(ev)
|
|
290
|
+
self._safe_hide_popup()
|
|
291
|
+
|
|
292
|
+
def keyPressEvent(self, ev) -> None: # Qt override
|
|
293
|
+
if ev.key() == QtCore.Qt.Key_Escape and self._popup.isVisible():
|
|
294
|
+
self._popup.hide()
|
|
295
|
+
return
|
|
296
|
+
super().keyPressEvent(ev)
|
|
297
|
+
|
|
298
|
+
def hideEvent(self, ev) -> None: # Qt override
|
|
299
|
+
self._safe_hide_popup()
|
|
300
|
+
super().hideEvent(ev)
|
|
301
|
+
|
|
302
|
+
def _safe_hide_popup(self) -> None:
|
|
303
|
+
# Qt may have destroyed the popup during interpreter shutdown; guard.
|
|
304
|
+
try:
|
|
305
|
+
self._popup.hide()
|
|
306
|
+
except RuntimeError:
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class _ScalarArrayLineEdit(QtWidgets.QLineEdit):
|
|
311
|
+
"""Whole-array editor: a single QLineEdit holding comma-separated numbers.
|
|
312
|
+
|
|
313
|
+
Reading back via _READERS returns a Python list (parsed); subclassing
|
|
314
|
+
QLineEdit ensures the existing _READERS[QLineEdit] entry does NOT match
|
|
315
|
+
first - we register an explicit entry for this class below.
|
|
316
|
+
|
|
317
|
+
Length validation: turns the text red when the parsed count != f.count.
|
|
318
|
+
Invalid syntax also turns red. We do not block typing - the user must be
|
|
319
|
+
able to edit through transient bad states.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def __init__(self, f: ArrayField, value: Any,
|
|
323
|
+
parent: QtWidgets.QWidget = None) -> None:
|
|
324
|
+
super().__init__(parent)
|
|
325
|
+
self._field = f
|
|
326
|
+
self._is_float = bool(
|
|
327
|
+
isinstance(f.element, ScalarField) and f.element.ui_type == 'float'
|
|
328
|
+
)
|
|
329
|
+
self._is_enum = isinstance(f.element, EnumField)
|
|
330
|
+
self._enum_keys = (
|
|
331
|
+
list(f.element.items.keys()) if self._is_enum else []
|
|
332
|
+
)
|
|
333
|
+
self.setText(self._format(value))
|
|
334
|
+
self.textChanged.connect(self._revalidate)
|
|
335
|
+
self._revalidate(self.text())
|
|
336
|
+
|
|
337
|
+
def _format(self, value: Any) -> str:
|
|
338
|
+
if not isinstance(value, list):
|
|
339
|
+
return ''
|
|
340
|
+
parts = []
|
|
341
|
+
for v in value:
|
|
342
|
+
if v is None:
|
|
343
|
+
parts.append(self._enum_keys[0] if self._is_enum else '0')
|
|
344
|
+
elif self._is_enum:
|
|
345
|
+
parts.append(str(v))
|
|
346
|
+
elif self._is_float:
|
|
347
|
+
parts.append(f'{float(v):g}')
|
|
348
|
+
else:
|
|
349
|
+
try:
|
|
350
|
+
parts.append(str(int(v)))
|
|
351
|
+
except Exception:
|
|
352
|
+
parts.append(str(v))
|
|
353
|
+
return ', '.join(parts)
|
|
354
|
+
|
|
355
|
+
def parsed(self) -> Any:
|
|
356
|
+
"""Parse current text. Returns list on success, None on syntax error."""
|
|
357
|
+
txt = self.text().strip()
|
|
358
|
+
if not txt:
|
|
359
|
+
return []
|
|
360
|
+
out: List[Any] = []
|
|
361
|
+
for tok in txt.split(','):
|
|
362
|
+
tok = tok.strip()
|
|
363
|
+
if not tok:
|
|
364
|
+
return None
|
|
365
|
+
if self._is_enum:
|
|
366
|
+
if tok not in self._enum_keys:
|
|
367
|
+
return None
|
|
368
|
+
out.append(tok)
|
|
369
|
+
else:
|
|
370
|
+
try:
|
|
371
|
+
out.append(float(tok) if self._is_float else int(tok))
|
|
372
|
+
except ValueError:
|
|
373
|
+
return None
|
|
374
|
+
return out
|
|
375
|
+
|
|
376
|
+
def _revalidate(self, _txt: str) -> None:
|
|
377
|
+
parsed = self.parsed()
|
|
378
|
+
ok = isinstance(parsed, list) and len(parsed) == self._field.count
|
|
379
|
+
self.setStyleSheet('' if ok else 'QLineEdit{color:#c33;}')
|
|
380
|
+
self.setToolTip(
|
|
381
|
+
'' if ok else f'Expected {self._field.count} comma-separated values'
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
_READERS[_ScalarArrayLineEdit] = lambda w: w.parsed()
|
|
386
|
+
_CHANGE_SIGNALS[_ScalarArrayLineEdit] = 'textChanged'
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class _MultilinePopup(QtWidgets.QPlainTextEdit):
|
|
390
|
+
"""浮动多行编辑面板。
|
|
391
|
+
|
|
392
|
+
设计要点(参考 dial 的成功经验,避开 Qt.Popup 的坑):
|
|
393
|
+
|
|
394
|
+
* 使用 Qt.Tool | Qt.FramelessWindowHint —— 普通独立浮动窗口,
|
|
395
|
+
**不抓全局鼠标**,事件分发完全正常,目标控件能直接获得焦点。
|
|
396
|
+
* 与 dial 不同:multiline 必须接收键盘输入,所以不能用 NoFocus
|
|
397
|
+
+ WA_ShowWithoutActivating。允许它正常获得焦点接收键盘。
|
|
398
|
+
* 关闭策略不放在 popup 自己身上,而是由宿主 _PopupMultilineArrayEdit
|
|
399
|
+
通过 QApplication.focusChanged 决定(焦点离开本组合就收起)。
|
|
400
|
+
* Esc 仍然由 popup 本身处理。
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
closed = QtCore.Signal()
|
|
404
|
+
|
|
405
|
+
def __init__(self, parent: QtWidgets.QWidget = None) -> None:
|
|
406
|
+
super().__init__(parent)
|
|
407
|
+
self.setWindowFlags(
|
|
408
|
+
QtCore.Qt.Tool | QtCore.Qt.FramelessWindowHint
|
|
409
|
+
)
|
|
410
|
+
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
|
|
411
|
+
|
|
412
|
+
def keyPressEvent(self, ev) -> None: # Qt override
|
|
413
|
+
if ev.key() == QtCore.Qt.Key_Escape:
|
|
414
|
+
self.close()
|
|
415
|
+
return
|
|
416
|
+
super().keyPressEvent(ev)
|
|
417
|
+
|
|
418
|
+
def closeEvent(self, ev) -> None: # Qt override
|
|
419
|
+
self.closed.emit()
|
|
420
|
+
super().closeEvent(ev)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class _PopupMultilineArrayEdit(_ScalarArrayLineEdit):
|
|
424
|
+
"""单行外壳 + 弹出多行编辑器。
|
|
425
|
+
|
|
426
|
+
行为模仿 dial:聚焦时弹出 popup;popup 失焦/Esc 收回并把内容归一化
|
|
427
|
+
成 ", " 分隔的单行写回。两边内容通过 textChanged 信号双向同步,
|
|
428
|
+
用 _guard 防回环。
|
|
429
|
+
|
|
430
|
+
所有解析、校验、_READERS 注册都从 _ScalarArrayLineEdit 继承——本类
|
|
431
|
+
只负责"额外的弹出 UI",单行控件本身的语义不变。
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
def __init__(self, f: ArrayField, value: Any,
|
|
435
|
+
parent: QtWidgets.QWidget = None) -> None:
|
|
436
|
+
super().__init__(f, value, parent)
|
|
437
|
+
shape = f.meta.get('shape')
|
|
438
|
+
self._row_size = (
|
|
439
|
+
int(shape[-1]) if isinstance(shape, list) and shape else 0
|
|
440
|
+
)
|
|
441
|
+
self._popup = _MultilinePopup()
|
|
442
|
+
self._guard = False
|
|
443
|
+
self._suppress_popup = False
|
|
444
|
+
self.textChanged.connect(self._sync_to_popup)
|
|
445
|
+
self._popup.textChanged.connect(self._sync_from_popup)
|
|
446
|
+
self._popup.closed.connect(self._on_popup_closed)
|
|
447
|
+
QtWidgets.QApplication.instance().focusChanged.connect(
|
|
448
|
+
self._on_focus_changed
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
def _on_focus_changed(self, old, new) -> None:
|
|
452
|
+
if not self._popup.isVisible():
|
|
453
|
+
return
|
|
454
|
+
if new is None:
|
|
455
|
+
return
|
|
456
|
+
# 焦点跑到本控件、popup、或 popup 的子控件(viewport 等)上都算"内部"
|
|
457
|
+
if new is self or new is self._popup:
|
|
458
|
+
return
|
|
459
|
+
try:
|
|
460
|
+
if self._popup.isAncestorOf(new):
|
|
461
|
+
return
|
|
462
|
+
except RuntimeError:
|
|
463
|
+
return
|
|
464
|
+
self._popup.close()
|
|
465
|
+
|
|
466
|
+
def _format_multiline(self, flat_text: str) -> str:
|
|
467
|
+
"""把单行文本按 shape[-1] 折成多行。无 shape 时原样返回。"""
|
|
468
|
+
if self._row_size <= 0:
|
|
469
|
+
return flat_text
|
|
470
|
+
tokens = [t for t in re.split(r'[,\s]+', flat_text) if t]
|
|
471
|
+
if not tokens:
|
|
472
|
+
return ''
|
|
473
|
+
rs = self._row_size
|
|
474
|
+
lines = [
|
|
475
|
+
', '.join(tokens[i:i + rs]) for i in range(0, len(tokens), rs)
|
|
476
|
+
]
|
|
477
|
+
return '\n'.join(lines)
|
|
478
|
+
|
|
479
|
+
def _normalize_singleline(self, multi_text: str) -> str:
|
|
480
|
+
"""把多行文本归一化为 ', ' 分隔的单行。"""
|
|
481
|
+
tokens = [t for t in re.split(r'[,\s]+', multi_text) if t]
|
|
482
|
+
return ', '.join(tokens)
|
|
483
|
+
|
|
484
|
+
def _sync_to_popup(self, _txt: str = '') -> None:
|
|
485
|
+
if self._guard:
|
|
486
|
+
return
|
|
487
|
+
self._guard = True
|
|
488
|
+
try:
|
|
489
|
+
self._popup.setPlainText(self._format_multiline(self.text()))
|
|
490
|
+
finally:
|
|
491
|
+
self._guard = False
|
|
492
|
+
|
|
493
|
+
def _sync_from_popup(self) -> None:
|
|
494
|
+
if self._guard:
|
|
495
|
+
return
|
|
496
|
+
self._guard = True
|
|
497
|
+
try:
|
|
498
|
+
self.setText(self._normalize_singleline(self._popup.toPlainText()))
|
|
499
|
+
finally:
|
|
500
|
+
self._guard = False
|
|
501
|
+
|
|
502
|
+
def _show_popup(self) -> None:
|
|
503
|
+
self._guard = True
|
|
504
|
+
try:
|
|
505
|
+
self._popup.setPlainText(self._format_multiline(self.text()))
|
|
506
|
+
finally:
|
|
507
|
+
self._guard = False
|
|
508
|
+
self._popup.resize(max(self.width(), 320),
|
|
509
|
+
self.fontMetrics().lineSpacing() * 8 + 12)
|
|
510
|
+
gp = self.mapToGlobal(QtCore.QPoint(0, self.height()))
|
|
511
|
+
self._popup.move(gp)
|
|
512
|
+
self._popup.show()
|
|
513
|
+
self._popup.raise_()
|
|
514
|
+
self._popup.activateWindow()
|
|
515
|
+
self._popup.setFocus()
|
|
516
|
+
|
|
517
|
+
def _on_popup_closed(self) -> None:
|
|
518
|
+
self._suppress_popup = True
|
|
519
|
+
self.setText(self._normalize_singleline(self._popup.toPlainText()))
|
|
520
|
+
QtCore.QTimer.singleShot(0, self._reset_suppress)
|
|
521
|
+
|
|
522
|
+
def _reset_suppress(self) -> None:
|
|
523
|
+
self._suppress_popup = False
|
|
524
|
+
|
|
525
|
+
def focusInEvent(self, ev) -> None: # Qt override
|
|
526
|
+
super().focusInEvent(ev)
|
|
527
|
+
if not self._suppress_popup and not self._popup.isVisible():
|
|
528
|
+
self._show_popup()
|
|
529
|
+
|
|
530
|
+
def hideEvent(self, ev) -> None: # Qt override
|
|
531
|
+
try:
|
|
532
|
+
self._popup.hide()
|
|
533
|
+
except RuntimeError:
|
|
534
|
+
pass
|
|
535
|
+
super().hideEvent(ev)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
_READERS[_PopupMultilineArrayEdit] = lambda w: w.parsed()
|
|
539
|
+
_CHANGE_SIGNALS[_PopupMultilineArrayEdit] = 'textChanged'
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class _FileArrayWidget(QtWidgets.QWidget):
|
|
543
|
+
"""整数组占位控件:路径 label + "..." 选择按钮。
|
|
544
|
+
|
|
545
|
+
"value" 始终是文件路径字符串;文件内容不会进入配置导出,仅在内存里
|
|
546
|
+
做长度/类型校验,结果以后缀(✓/✗)形式追加显示在 label 上。
|
|
547
|
+
|
|
548
|
+
支持的文件格式(用 ``re.split(r'[,\\s]+', text)`` 分词,再过滤空 token):
|
|
549
|
+
- 单行逗号分隔:``1,2,3,4,5,6,7,8``
|
|
550
|
+
- 多行逗号分隔(行尾逗号可有可无):``1,2,3,4,\\n5,6,7,8``
|
|
551
|
+
- 每行一个值:``1\\n2\\n3\\n4``
|
|
552
|
+
|
|
553
|
+
长度校验策略:
|
|
554
|
+
- got == expected → ✓
|
|
555
|
+
- got < expected → ✓ 提示将由下游补默认值
|
|
556
|
+
- got > expected → ✗ (多余数据无处可去,让用户决定)
|
|
557
|
+
"""
|
|
558
|
+
|
|
559
|
+
pathChanged = QtCore.Signal(str)
|
|
560
|
+
|
|
561
|
+
_PLACEHOLDER_QSS = 'QLabel{color:#888;}'
|
|
562
|
+
_OK_COLOR = '#2a7d2a'
|
|
563
|
+
_ERR_COLOR = '#c33'
|
|
564
|
+
|
|
565
|
+
def __init__(self, f: ArrayField, value: Any,
|
|
566
|
+
parent: QtWidgets.QWidget = None) -> None:
|
|
567
|
+
super().__init__(parent)
|
|
568
|
+
self._field = f
|
|
569
|
+
self._path = str(value) if isinstance(value, str) else ''
|
|
570
|
+
|
|
571
|
+
self._label = QtWidgets.QLabel()
|
|
572
|
+
self._label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
|
|
573
|
+
|
|
574
|
+
self._btn = QtWidgets.QPushButton('...')
|
|
575
|
+
self._btn.setFixedWidth(28)
|
|
576
|
+
self._btn.clicked.connect(self._on_browse)
|
|
577
|
+
|
|
578
|
+
lay = QtWidgets.QHBoxLayout(self)
|
|
579
|
+
lay.setContentsMargins(0, 0, 0, 0)
|
|
580
|
+
lay.setSpacing(4)
|
|
581
|
+
lay.addWidget(self._label, 1)
|
|
582
|
+
lay.addWidget(self._btn)
|
|
583
|
+
|
|
584
|
+
self._refresh_label()
|
|
585
|
+
|
|
586
|
+
# --- 公共接口 --------------------------------------------------------- #
|
|
587
|
+
|
|
588
|
+
def value(self) -> str:
|
|
589
|
+
return self._path
|
|
590
|
+
|
|
591
|
+
# --- 私有实现 --------------------------------------------------------- #
|
|
592
|
+
|
|
593
|
+
def _on_browse(self) -> None:
|
|
594
|
+
path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
|
595
|
+
self, f'Select file for {self._field.name}', self._path or '',
|
|
596
|
+
'Text files (*.txt);;All files (*.*)')
|
|
597
|
+
if path:
|
|
598
|
+
self._path = path
|
|
599
|
+
self._refresh_label()
|
|
600
|
+
self.pathChanged.emit(path)
|
|
601
|
+
|
|
602
|
+
def _refresh_label(self) -> None:
|
|
603
|
+
"""根据当前路径刷新 label 文本/颜色/tooltip。仅内存视图,不改导出值。"""
|
|
604
|
+
if not self._path:
|
|
605
|
+
self._label.setText('(no file)')
|
|
606
|
+
self._label.setStyleSheet(self._PLACEHOLDER_QSS)
|
|
607
|
+
self._label.setToolTip('')
|
|
608
|
+
return
|
|
609
|
+
|
|
610
|
+
ok, info = self._validate_file(self._path)
|
|
611
|
+
mark = '✓' if ok else '✗'
|
|
612
|
+
color = self._OK_COLOR if ok else self._ERR_COLOR
|
|
613
|
+
path_html = _html_escape(self._path)
|
|
614
|
+
info_html = _html_escape(f'{mark} {info}')
|
|
615
|
+
self._label.setText(
|
|
616
|
+
f'{path_html} <span style="color:{color};">{info_html}</span>')
|
|
617
|
+
self._label.setStyleSheet('')
|
|
618
|
+
self._label.setToolTip(self._path)
|
|
619
|
+
|
|
620
|
+
def _validate_file(self, path: str) -> Tuple[bool, str]:
|
|
621
|
+
"""读取并分词,返回 (是否合法, 状态描述)。"""
|
|
622
|
+
try:
|
|
623
|
+
with open(path, 'r', encoding='utf-8') as fp:
|
|
624
|
+
text = fp.read()
|
|
625
|
+
except OSError as e:
|
|
626
|
+
return False, f'read error: {e.strerror or e}'
|
|
627
|
+
|
|
628
|
+
tokens = [t for t in re.split(r'[,\s]+', text) if t]
|
|
629
|
+
|
|
630
|
+
# 元素类型决定 cast;非 scalar 元素退化为只校验数量。
|
|
631
|
+
elem = self._field.element
|
|
632
|
+
ui = elem.ui_type if isinstance(elem, ScalarField) else None
|
|
633
|
+
if ui == 'int':
|
|
634
|
+
cast, type_name = int, 'ints'
|
|
635
|
+
elif ui == 'float':
|
|
636
|
+
cast, type_name = float, 'floats'
|
|
637
|
+
else:
|
|
638
|
+
cast, type_name = None, 'tokens'
|
|
639
|
+
|
|
640
|
+
if cast is not None:
|
|
641
|
+
for i, tok in enumerate(tokens):
|
|
642
|
+
try:
|
|
643
|
+
cast(tok)
|
|
644
|
+
except ValueError:
|
|
645
|
+
return False, f'token #{i + 1} {tok!r} is not {cast.__name__}'
|
|
646
|
+
|
|
647
|
+
expected = self._field.count
|
|
648
|
+
shape = self._field.meta.get('shape')
|
|
649
|
+
shape_str = ('x'.join(str(d) for d in shape)
|
|
650
|
+
if isinstance(shape, list) else str(expected))
|
|
651
|
+
got = len(tokens)
|
|
652
|
+
if expected and got > expected:
|
|
653
|
+
return False, f'expected {shape_str} ({expected}) {type_name}, got {got}'
|
|
654
|
+
if expected and got < expected:
|
|
655
|
+
return True, (f'{got}/{expected} {type_name}, '
|
|
656
|
+
f'missing {expected - got} (will fill default)')
|
|
657
|
+
return True, f'{shape_str} {type_name}'
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _html_escape(s: str) -> str:
|
|
661
|
+
"""最小 HTML 转义,足以让 QLabel 富文本不把路径里的 & < > 当成标签。"""
|
|
662
|
+
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
_READERS[_FileArrayWidget] = lambda w: w.value()
|
|
666
|
+
_CHANGE_SIGNALS[_FileArrayWidget] = 'pathChanged'
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _build_int(f: ScalarField, value: Any) -> QtWidgets.QWidget:
|
|
670
|
+
mn, mx, sp = f.meta.get('min'), f.meta.get('max'), f.meta.get('step')
|
|
671
|
+
ui = f.meta.get('widget')
|
|
672
|
+
if ui == 'slider' and mn is not None and mx is not None:
|
|
673
|
+
w = _LabeledSliderHolder(int(mn), int(mx))
|
|
674
|
+
elif ui == 'dial' and mn is not None and mx is not None:
|
|
675
|
+
w = _DialSpinBox(int(mn), int(mx))
|
|
676
|
+
else:
|
|
677
|
+
w = QtWidgets.QSpinBox()
|
|
678
|
+
w.setRange(_as_int(mn, -2147483648), _as_int(mx, 2147483647))
|
|
679
|
+
w.setSingleStep(_as_int(sp, 1))
|
|
680
|
+
w.setValue(_safe_int(value))
|
|
681
|
+
return w
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _build_float(f: ScalarField, value: Any) -> QtWidgets.QDoubleSpinBox:
|
|
685
|
+
mn, mx, sp = f.meta.get('min'), f.meta.get('max'), f.meta.get('step')
|
|
686
|
+
w = QtWidgets.QDoubleSpinBox()
|
|
687
|
+
# QDoubleSpinBox rounds its value to `decimals` places, so too few digits
|
|
688
|
+
# silently truncates the stored value (e.g. 0.0001 -> 0.0). Honour an
|
|
689
|
+
# explicit `decimals` meta, else infer enough digits from `step`, else a
|
|
690
|
+
# generous default that does not lose typical config precision.
|
|
691
|
+
w.setDecimals(_float_decimals(f.meta.get('decimals'), sp))
|
|
692
|
+
w.setRange(_as_float(mn, -1e9), _as_float(mx, 1e9))
|
|
693
|
+
w.setSingleStep(_as_float(sp, 0.1))
|
|
694
|
+
w.setValue(_safe_float(value))
|
|
695
|
+
return w
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _build_text(value: Any, f: ScalarField = None) -> QtWidgets.QLineEdit:
|
|
699
|
+
w = QtWidgets.QLineEdit()
|
|
700
|
+
w.setText('' if value is None else str(value))
|
|
701
|
+
if f is not None:
|
|
702
|
+
maxlen = f.meta.get('maxlen')
|
|
703
|
+
if isinstance(maxlen, int) and maxlen > 0:
|
|
704
|
+
w.setMaxLength(maxlen)
|
|
705
|
+
return w
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _build_array_value(f: ArrayField, value: Any) -> QtWidgets.QWidget:
|
|
709
|
+
"""把整个数组渲染为单行 leaf 控件。
|
|
710
|
+
|
|
711
|
+
三种模式(行高一致都是单行):
|
|
712
|
+
widget=file -> _FileArrayWidget (路径 + Browse 按钮)
|
|
713
|
+
widget=multiline -> _PopupMultilineArrayEdit (单行外壳,聚焦弹多行编辑器)
|
|
714
|
+
默认 -> _ScalarArrayLineEdit (逗号分隔单行)
|
|
715
|
+
|
|
716
|
+
多维 count(meta.shape)不再决定控件类型 —— 它仅在 widget=multiline
|
|
717
|
+
时被弹出面板用于折行显示。仅对 leaf 数组(scalar/enum 元素)有效;
|
|
718
|
+
struct 数组由调用方分派给 table/tree。
|
|
719
|
+
"""
|
|
720
|
+
wkind = f.meta.get('widget')
|
|
721
|
+
if wkind == 'file':
|
|
722
|
+
return _FileArrayWidget(f, value)
|
|
723
|
+
if wkind == 'multiline':
|
|
724
|
+
return _PopupMultilineArrayEdit(f, value)
|
|
725
|
+
return _ScalarArrayLineEdit(f, value)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _safe_int(v: Any) -> int:
|
|
729
|
+
try:
|
|
730
|
+
return int(v)
|
|
731
|
+
except Exception:
|
|
732
|
+
return 0
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _safe_float(v: Any) -> float:
|
|
736
|
+
try:
|
|
737
|
+
return float(v)
|
|
738
|
+
except Exception:
|
|
739
|
+
return 0.0
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _as_int(v: Any, default: int) -> int:
|
|
743
|
+
return int(v) if v is not None else default
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _as_float(v: Any, default: float) -> float:
|
|
747
|
+
return float(v) if v is not None else default
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
_DEFAULT_FLOAT_DECIMALS = 6
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _float_decimals(decimals: Any, step: Any) -> int:
|
|
754
|
+
"""Decide how many decimal places a QDoubleSpinBox should keep.
|
|
755
|
+
|
|
756
|
+
Priority: explicit `decimals` meta -> digits implied by `step` -> default.
|
|
757
|
+
Capped at 10 to stay within QDoubleSpinBox's sane range.
|
|
758
|
+
"""
|
|
759
|
+
if isinstance(decimals, int) and decimals >= 0:
|
|
760
|
+
return min(decimals, 10)
|
|
761
|
+
if step is not None:
|
|
762
|
+
try:
|
|
763
|
+
exp = Decimal(str(float(step))).normalize().as_tuple().exponent
|
|
764
|
+
except (TypeError, ValueError, InvalidOperation):
|
|
765
|
+
exp = 0
|
|
766
|
+
if isinstance(exp, int) and exp < 0:
|
|
767
|
+
return min(max(-exp, _DEFAULT_FLOAT_DECIMALS), 10)
|
|
768
|
+
return _DEFAULT_FLOAT_DECIMALS
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
# --------------------------------------------------------------------------- #
|
|
772
|
+
# Misc helpers
|
|
773
|
+
# --------------------------------------------------------------------------- #
|
|
774
|
+
|
|
775
|
+
def decorate_label(name: str, meta: Dict[str, Any]) -> str:
|
|
776
|
+
"""Append unit suffix to a field label, e.g. `gain (dB)`."""
|
|
777
|
+
unit = meta.get('unit')
|
|
778
|
+
return f'{name} ({unit})' if unit else name
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
# --------------------------------------------------------------------------- #
|
|
782
|
+
# WhenBinder: declarative enable/disable based on `when` metadata
|
|
783
|
+
# --------------------------------------------------------------------------- #
|
|
784
|
+
|
|
785
|
+
class WhenBinder:
|
|
786
|
+
"""Collect (dotted_name, widget, when-dict); after `apply()`, dependent
|
|
787
|
+
widgets are auto-enabled/disabled when their dependencies change.
|
|
788
|
+
|
|
789
|
+
Usage:
|
|
790
|
+
binder = WhenBinder()
|
|
791
|
+
binder.register('foo.bar', w, field.meta) # repeat for each leaf
|
|
792
|
+
binder.apply() # wires up signals
|
|
793
|
+
"""
|
|
794
|
+
|
|
795
|
+
def __init__(self) -> None:
|
|
796
|
+
# (dotted, widget, when_dict_or_None)
|
|
797
|
+
self._rows: List[Tuple[str, QtWidgets.QWidget, Any]] = []
|
|
798
|
+
|
|
799
|
+
def register(self, dotted: str, w: QtWidgets.QWidget, meta: Dict[str, Any]) -> None:
|
|
800
|
+
when = meta.get('when') if isinstance(meta, dict) else None
|
|
801
|
+
self._rows.append((dotted, w, when if isinstance(when, dict) else None))
|
|
802
|
+
|
|
803
|
+
def widget_map(self) -> Dict[str, QtWidgets.QWidget]:
|
|
804
|
+
"""Return a {dotted_name: widget} view of every registered leaf.
|
|
805
|
+
|
|
806
|
+
Built from the same `_rows` used for when-binding, so it covers exactly
|
|
807
|
+
the editable leaves the renderers created. Consumed by
|
|
808
|
+
`collect_values()` to read the current UI state back into a nested
|
|
809
|
+
dict/list structure (the entry point for UI -> JSON/.c/.bin export).
|
|
810
|
+
"""
|
|
811
|
+
return {dotted: w for dotted, w, _ in self._rows}
|
|
812
|
+
|
|
813
|
+
def apply(self) -> None:
|
|
814
|
+
by_name = {n: w for n, w, _ in self._rows}
|
|
815
|
+
for _name, w, cond in self._rows:
|
|
816
|
+
if cond:
|
|
817
|
+
self._wire(w, cond, by_name)
|
|
818
|
+
|
|
819
|
+
def _wire(self, target: QtWidgets.QWidget, cond: Dict[str, Any],
|
|
820
|
+
by_name: Dict[str, QtWidgets.QWidget]) -> None:
|
|
821
|
+
def evaluate() -> bool:
|
|
822
|
+
for dep, expected in cond.items():
|
|
823
|
+
dep_w = by_name.get(dep)
|
|
824
|
+
if dep_w is None:
|
|
825
|
+
return True
|
|
826
|
+
if WidgetFactory.read(dep_w) != expected:
|
|
827
|
+
return False
|
|
828
|
+
return True
|
|
829
|
+
|
|
830
|
+
def update(_=None) -> None:
|
|
831
|
+
target.setEnabled(evaluate())
|
|
832
|
+
|
|
833
|
+
update()
|
|
834
|
+
for dep in cond.keys():
|
|
835
|
+
dep_w = by_name.get(dep)
|
|
836
|
+
if dep_w is None:
|
|
837
|
+
continue
|
|
838
|
+
sig_name = _lookup(_CHANGE_SIGNALS, dep_w)
|
|
839
|
+
if sig_name:
|
|
840
|
+
getattr(dep_w, sig_name).connect(update)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
# --------------------------------------------------------------------------- #
|
|
844
|
+
# collect_values: read the live UI state back into a nested dict/list
|
|
845
|
+
# --------------------------------------------------------------------------- #
|
|
846
|
+
|
|
847
|
+
def collect_values(root: Field,
|
|
848
|
+
widget_map: Dict[str, QtWidgets.QWidget]) -> Any:
|
|
849
|
+
"""Walk the schema tree and read every leaf widget into a nested value.
|
|
850
|
+
|
|
851
|
+
`widget_map` is the {dotted_name: widget} produced by
|
|
852
|
+
`WhenBinder.widget_map()`. The dotted names are generated here exactly as
|
|
853
|
+
the renderers generate them, so the two stay in lock-step:
|
|
854
|
+
|
|
855
|
+
struct child -> '<prefix><name>'
|
|
856
|
+
scalar/enum array -> one widget under the array's own dotted name,
|
|
857
|
+
whose reader already returns a list
|
|
858
|
+
struct/nested array -> per-element '<dotted>[i]...' entries
|
|
859
|
+
|
|
860
|
+
Returns:
|
|
861
|
+
StructField -> dict of child-name -> value
|
|
862
|
+
ArrayField -> list of length count
|
|
863
|
+
leaf -> the scalar/enum value (or list, for whole-array editors)
|
|
864
|
+
|
|
865
|
+
Leaves with no widget in the map (e.g. a struct array rendered with a
|
|
866
|
+
'<nested>' placeholder) fall back to the field's default so the shape of
|
|
867
|
+
the returned structure always matches the schema.
|
|
868
|
+
"""
|
|
869
|
+
return _collect_field(root, '', widget_map)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _collect_field(f: Field, dotted: str,
|
|
873
|
+
widget_map: Dict[str, QtWidgets.QWidget]) -> Any:
|
|
874
|
+
if isinstance(f, ArrayField):
|
|
875
|
+
# Scalar/enum arrays (and file/multiline variants) are a single
|
|
876
|
+
# whole-array widget; its reader returns the full list directly.
|
|
877
|
+
if not isinstance(f.element, StructField):
|
|
878
|
+
w = widget_map.get(dotted)
|
|
879
|
+
if w is not None:
|
|
880
|
+
val = WidgetFactory.read(w)
|
|
881
|
+
return val if val is not None else _array_default(f)
|
|
882
|
+
return _array_default(f)
|
|
883
|
+
# Struct array: one entry per element.
|
|
884
|
+
return [
|
|
885
|
+
_collect_field(f.element, f'{dotted}[{i}]', widget_map)
|
|
886
|
+
for i in range(f.count)
|
|
887
|
+
]
|
|
888
|
+
|
|
889
|
+
if isinstance(f, StructField):
|
|
890
|
+
out: Dict[str, Any] = {}
|
|
891
|
+
prefix = f'{dotted}.' if dotted else ''
|
|
892
|
+
for child in f.children:
|
|
893
|
+
out[child.name] = _collect_field(
|
|
894
|
+
child, f'{prefix}{child.name}', widget_map)
|
|
895
|
+
return out
|
|
896
|
+
|
|
897
|
+
# leaf scalar / enum
|
|
898
|
+
w = widget_map.get(dotted)
|
|
899
|
+
if w is not None:
|
|
900
|
+
return WidgetFactory.read(w)
|
|
901
|
+
return f.default
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _array_default(f: ArrayField) -> List[Any]:
|
|
905
|
+
elem = f.element
|
|
906
|
+
base = elem.default if elem is not None else None
|
|
907
|
+
return [base for _ in range(f.count)]
|