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.
@@ -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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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)]