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/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""UI package: schema → Qt widget rendering.
|
|
2
|
+
|
|
3
|
+
Layered design:
|
|
4
|
+
widgets.py - leaf editor factory (WidgetFactory) + when-binding (WhenBinder)
|
|
5
|
+
tables.py - QTableWidget rendering for arrays (style + cell wrapping)
|
|
6
|
+
renderers.py - FormRenderer / TreeRenderer + dispatcher (pick_renderer)
|
|
7
|
+
|
|
8
|
+
Public API (stable; safe to import from outside):
|
|
9
|
+
pick_renderer, FormRenderer, TreeRenderer
|
|
10
|
+
WidgetFactory, WhenBinder
|
|
11
|
+
build_array_table
|
|
12
|
+
|
|
13
|
+
Internal helpers (prefixed with _) are not part of the public API.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .widgets import (
|
|
17
|
+
WidgetFactory, WhenBinder, decorate_label, collect_values,
|
|
18
|
+
)
|
|
19
|
+
from .tables import build_array_table
|
|
20
|
+
from .renderers import (
|
|
21
|
+
FormRenderer, TreeRenderer, pick_renderer, make_collapsible,
|
|
22
|
+
collect_view_values,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
'WidgetFactory',
|
|
27
|
+
'WhenBinder',
|
|
28
|
+
'decorate_label',
|
|
29
|
+
'collect_values',
|
|
30
|
+
'build_array_table',
|
|
31
|
+
'FormRenderer',
|
|
32
|
+
'TreeRenderer',
|
|
33
|
+
'pick_renderer',
|
|
34
|
+
'make_collapsible',
|
|
35
|
+
'collect_view_values',
|
|
36
|
+
]
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Top-level renderers: turn a StructField tree into a single QWidget.
|
|
2
|
+
|
|
3
|
+
Two renderers are provided:
|
|
4
|
+
|
|
5
|
+
FormRenderer - flat QFormLayout, suitable for shallow non-nested structs.
|
|
6
|
+
Nested groups use a QToolButton ▼/▶ collapser.
|
|
7
|
+
TreeRenderer - QTreeWidget with native expand/collapse; suitable for any
|
|
8
|
+
schema, including deeply nested structs and struct arrays.
|
|
9
|
+
|
|
10
|
+
Dispatch:
|
|
11
|
+
pick_renderer() returns TreeRenderer by default. FormRenderer is opt-in via
|
|
12
|
+
speech.json: `"render": "form"` on the section spec.
|
|
13
|
+
|
|
14
|
+
Adding a new renderer:
|
|
15
|
+
1. Subclass nothing — just provide a `render(root, values) -> QWidget` method.
|
|
16
|
+
2. Extend pick_renderer() to return it under some condition (override string,
|
|
17
|
+
schema shape, etc).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Dict, Optional
|
|
23
|
+
|
|
24
|
+
from Qt import QtCore, QtWidgets
|
|
25
|
+
|
|
26
|
+
from ..schema import (
|
|
27
|
+
Field, ScalarField, EnumField, StructField, ArrayField,
|
|
28
|
+
array_dims_suffix,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from .widgets import (
|
|
32
|
+
WidgetFactory, WhenBinder, decorate_label, collect_values,
|
|
33
|
+
)
|
|
34
|
+
from .tables import build_array_table, is_flat_struct_array
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------------------- #
|
|
38
|
+
# Value read-back: let callers pull the live UI state back out of a rendered
|
|
39
|
+
# view as a nested dict/list (entry point for UI -> JSON/.c/.bin export).
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
|
|
42
|
+
_COLLECTOR_ATTR = '_params_collector'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _attach_collector(widget: QtWidgets.QWidget, root: StructField,
|
|
46
|
+
binder: WhenBinder) -> None:
|
|
47
|
+
"""Stash a zero-arg callable on the rendered widget that, when called,
|
|
48
|
+
reads the current editor values into a nested dict via collect_values()."""
|
|
49
|
+
setattr(widget, _COLLECTOR_ATTR,
|
|
50
|
+
lambda: collect_values(root, binder.widget_map()))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def collect_view_values(widget: QtWidgets.QWidget) -> Optional[Dict[str, Any]]:
|
|
54
|
+
"""Return the live values of a rendered view, or None if it carries no
|
|
55
|
+
collector (e.g. an error/summary panel rather than a parameter form)."""
|
|
56
|
+
collector = getattr(widget, _COLLECTOR_ATTR, None)
|
|
57
|
+
return collector() if collector is not None else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --------------------------------------------------------------------------- #
|
|
61
|
+
# Collapsible toolbutton (used by FormRenderer)
|
|
62
|
+
# --------------------------------------------------------------------------- #
|
|
63
|
+
|
|
64
|
+
def make_collapsible(title: str) -> tuple[QtWidgets.QWidget, QtWidgets.QFormLayout]:
|
|
65
|
+
"""Return (wrapper_widget, inner_form_layout). Click the title to collapse."""
|
|
66
|
+
wrapper = QtWidgets.QWidget()
|
|
67
|
+
v = QtWidgets.QVBoxLayout(wrapper)
|
|
68
|
+
v.setContentsMargins(0, 0, 0, 0)
|
|
69
|
+
v.setSpacing(2)
|
|
70
|
+
btn = QtWidgets.QToolButton()
|
|
71
|
+
btn.setText('\u25bc ' + title)
|
|
72
|
+
btn.setCheckable(True)
|
|
73
|
+
btn.setChecked(True)
|
|
74
|
+
btn.setAutoRaise(True)
|
|
75
|
+
btn.setToolButtonStyle(QtCore.Qt.ToolButtonTextOnly)
|
|
76
|
+
btn.setStyleSheet('QToolButton{font-weight:bold; text-align:left;}')
|
|
77
|
+
content = QtWidgets.QFrame()
|
|
78
|
+
content.setFrameShape(QtWidgets.QFrame.StyledPanel)
|
|
79
|
+
inner = QtWidgets.QFormLayout(content)
|
|
80
|
+
inner.setLabelAlignment(QtCore.Qt.AlignLeft)
|
|
81
|
+
inner.setContentsMargins(12, 4, 4, 4)
|
|
82
|
+
v.addWidget(btn)
|
|
83
|
+
v.addWidget(content)
|
|
84
|
+
|
|
85
|
+
def _on_toggled(checked: bool, b=btn, c=content, t=title) -> None:
|
|
86
|
+
b.setText(('\u25bc ' if checked else '\u25b6 ') + t)
|
|
87
|
+
c.setVisible(checked)
|
|
88
|
+
|
|
89
|
+
btn.toggled.connect(_on_toggled)
|
|
90
|
+
return wrapper, inner
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------------------------------- #
|
|
94
|
+
# FormRenderer
|
|
95
|
+
# --------------------------------------------------------------------------- #
|
|
96
|
+
|
|
97
|
+
class FormRenderer:
|
|
98
|
+
"""Render a StructField via QFormLayout. Best for shallow/flat data."""
|
|
99
|
+
|
|
100
|
+
def render(self, root: StructField, values: Dict[str, Any]) -> QtWidgets.QWidget:
|
|
101
|
+
container = QtWidgets.QWidget()
|
|
102
|
+
layout = QtWidgets.QFormLayout(container)
|
|
103
|
+
layout.setLabelAlignment(QtCore.Qt.AlignLeft)
|
|
104
|
+
binder = WhenBinder()
|
|
105
|
+
self._render_struct(layout, root, values, prefix='', binder=binder)
|
|
106
|
+
binder.apply()
|
|
107
|
+
_attach_collector(container, root, binder)
|
|
108
|
+
return container
|
|
109
|
+
|
|
110
|
+
def _render_struct(self, layout: QtWidgets.QFormLayout, node: StructField,
|
|
111
|
+
values: Dict[str, Any], prefix: str, binder: WhenBinder) -> None:
|
|
112
|
+
for child in node.children:
|
|
113
|
+
dotted = f'{prefix}{child.name}'
|
|
114
|
+
sub_val = values.get(child.name) if isinstance(values, dict) else None
|
|
115
|
+
self._render_field(layout, child, sub_val, dotted, binder)
|
|
116
|
+
|
|
117
|
+
def _render_field(self, layout: QtWidgets.QFormLayout, f: Field,
|
|
118
|
+
value: Any, dotted: str, binder: WhenBinder) -> None:
|
|
119
|
+
if isinstance(f, ArrayField):
|
|
120
|
+
if not isinstance(f.element, StructField):
|
|
121
|
+
self._add_leaf_row(layout, f, value, dotted, binder,
|
|
122
|
+
label_text=decorate_label(f.name, f.meta))
|
|
123
|
+
return
|
|
124
|
+
wrapper, inner = make_collapsible(f'{f.name}{array_dims_suffix(f)}')
|
|
125
|
+
tbl = build_array_table(f, value, dotted, binder)
|
|
126
|
+
inner.addRow(tbl)
|
|
127
|
+
layout.addRow(wrapper)
|
|
128
|
+
return
|
|
129
|
+
if isinstance(f, StructField):
|
|
130
|
+
wrapper, inner = make_collapsible(f.name)
|
|
131
|
+
self._render_struct(inner, f, value or {}, prefix=f'{dotted}.', binder=binder)
|
|
132
|
+
layout.addRow(wrapper)
|
|
133
|
+
return
|
|
134
|
+
# leaf
|
|
135
|
+
self._add_leaf_row(layout, f, value, dotted, binder,
|
|
136
|
+
label_text=decorate_label(f.name, f.meta))
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _add_leaf_row(layout: QtWidgets.QFormLayout, f: Field, value: Any,
|
|
140
|
+
dotted: str, binder: WhenBinder, label_text: str) -> None:
|
|
141
|
+
"""One QFormLayout row: label + editor widget + tooltip + binder."""
|
|
142
|
+
label = QtWidgets.QLabel(label_text)
|
|
143
|
+
w = WidgetFactory.build(f, value)
|
|
144
|
+
tip = f.meta.get('tip')
|
|
145
|
+
if tip:
|
|
146
|
+
w.setToolTip(str(tip))
|
|
147
|
+
label.setToolTip(str(tip))
|
|
148
|
+
layout.addRow(label, w)
|
|
149
|
+
binder.register(dotted, w, f.meta)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --------------------------------------------------------------------------- #
|
|
153
|
+
# TreeRenderer
|
|
154
|
+
# --------------------------------------------------------------------------- #
|
|
155
|
+
|
|
156
|
+
class TreeRenderer:
|
|
157
|
+
"""Render a StructField via QTreeWidget.
|
|
158
|
+
|
|
159
|
+
Layout:
|
|
160
|
+
col 0 = field name (with native ▶/▼ expand arrow on parent rows)
|
|
161
|
+
col 1 = value editor widget (empty for parent rows; QTableWidget for
|
|
162
|
+
flat struct arrays)
|
|
163
|
+
col 2 = unit (read-only)
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
HEADERS = ('Name', 'Value', 'Unit')
|
|
167
|
+
|
|
168
|
+
def render(self, root: StructField, values: Dict[str, Any]) -> QtWidgets.QWidget:
|
|
169
|
+
tree = QtWidgets.QTreeWidget()
|
|
170
|
+
tree.setColumnCount(len(self.HEADERS))
|
|
171
|
+
tree.setHeaderLabels(self.HEADERS)
|
|
172
|
+
tree.setAlternatingRowColors(True)
|
|
173
|
+
tree.setUniformRowHeights(False)
|
|
174
|
+
tree.header().setStretchLastSection(False)
|
|
175
|
+
tree.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
|
|
176
|
+
tree.header().setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch)
|
|
177
|
+
tree.header().setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents)
|
|
178
|
+
|
|
179
|
+
binder = WhenBinder()
|
|
180
|
+
self._populate_struct(tree.invisibleRootItem(), root, values, prefix='',
|
|
181
|
+
tree=tree, binder=binder)
|
|
182
|
+
tree.expandAll()
|
|
183
|
+
binder.apply()
|
|
184
|
+
_attach_collector(tree, root, binder)
|
|
185
|
+
return tree
|
|
186
|
+
|
|
187
|
+
def _populate_struct(self, parent_item: QtWidgets.QTreeWidgetItem,
|
|
188
|
+
node: StructField, values: Dict[str, Any], prefix: str,
|
|
189
|
+
tree: QtWidgets.QTreeWidget, binder: WhenBinder) -> None:
|
|
190
|
+
for child in node.children:
|
|
191
|
+
dotted = f'{prefix}{child.name}'
|
|
192
|
+
sub_val = values.get(child.name) if isinstance(values, dict) else None
|
|
193
|
+
self._add_field(parent_item, child, sub_val, dotted, tree, binder)
|
|
194
|
+
|
|
195
|
+
def _add_field(self, parent_item: QtWidgets.QTreeWidgetItem, f: Field,
|
|
196
|
+
value: Any, dotted: str, tree: QtWidgets.QTreeWidget,
|
|
197
|
+
binder: WhenBinder) -> None:
|
|
198
|
+
if isinstance(f, ArrayField):
|
|
199
|
+
self._add_array(parent_item, f, value, dotted, tree, binder)
|
|
200
|
+
return
|
|
201
|
+
if isinstance(f, StructField):
|
|
202
|
+
row = QtWidgets.QTreeWidgetItem(parent_item, [f.name, '', ''])
|
|
203
|
+
self._populate_struct(row, f, value or {}, prefix=f'{dotted}.',
|
|
204
|
+
tree=tree, binder=binder)
|
|
205
|
+
return
|
|
206
|
+
# leaf scalar/enum
|
|
207
|
+
self._make_leaf_row(parent_item, tree, binder,
|
|
208
|
+
label=f.name, meta=f.meta, dotted=dotted,
|
|
209
|
+
widget=WidgetFactory.build(
|
|
210
|
+
f, value if value is not None else f.default))
|
|
211
|
+
|
|
212
|
+
def _add_array(self, parent_item: QtWidgets.QTreeWidgetItem, f: ArrayField,
|
|
213
|
+
value: Any, dotted: str, tree: QtWidgets.QTreeWidget,
|
|
214
|
+
binder: WhenBinder) -> None:
|
|
215
|
+
# Scalar/enum array -> render the whole array as one leaf widget
|
|
216
|
+
# (default: comma-separated QLineEdit; widget=file -> path + Browse).
|
|
217
|
+
if not isinstance(f.element, StructField):
|
|
218
|
+
self._make_leaf_row(parent_item, tree, binder,
|
|
219
|
+
label=f'{f.name}{array_dims_suffix(f)}',
|
|
220
|
+
meta=f.meta,
|
|
221
|
+
dotted=dotted,
|
|
222
|
+
widget=WidgetFactory.build(f, value))
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
row = QtWidgets.QTreeWidgetItem(
|
|
226
|
+
parent_item, [f'{f.name}{array_dims_suffix(f)}', '', ''])
|
|
227
|
+
|
|
228
|
+
if self._should_render_as_table(f):
|
|
229
|
+
table = build_array_table(f, value if isinstance(value, list) else None,
|
|
230
|
+
dotted, binder)
|
|
231
|
+
holder = QtWidgets.QTreeWidgetItem(row, [''])
|
|
232
|
+
holder.setFirstColumnSpanned(True)
|
|
233
|
+
tree.setItemWidget(holder, 0, table)
|
|
234
|
+
# _polish_table locks maximumHeight to the exact pixel-perfect
|
|
235
|
+
# height (header + rows + border). Use it directly so the tree
|
|
236
|
+
# row reserves no extra space below the table.
|
|
237
|
+
holder.setSizeHint(0, QtCore.QSize(0, table.maximumHeight()))
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
# Otherwise expand each element as a tree branch.
|
|
241
|
+
elem = f.element
|
|
242
|
+
for i in range(f.count):
|
|
243
|
+
elem_val = value[i] if isinstance(value, list) and i < len(value) else None
|
|
244
|
+
idx_label = f'[{i}]'
|
|
245
|
+
if isinstance(elem, StructField):
|
|
246
|
+
elem_item = QtWidgets.QTreeWidgetItem(row, [idx_label, '', ''])
|
|
247
|
+
self._populate_struct(elem_item, elem, elem_val or {},
|
|
248
|
+
prefix=f'{dotted}[{i}].',
|
|
249
|
+
tree=tree, binder=binder)
|
|
250
|
+
elif isinstance(elem, ArrayField):
|
|
251
|
+
self._add_field(row, elem, elem_val, f'{dotted}[{i}]', tree, binder)
|
|
252
|
+
else:
|
|
253
|
+
self._make_leaf_row(row, tree, binder,
|
|
254
|
+
label=idx_label, meta=elem.meta,
|
|
255
|
+
dotted=f'{dotted}[{i}]',
|
|
256
|
+
widget=WidgetFactory.build(
|
|
257
|
+
elem,
|
|
258
|
+
elem_val if elem_val is not None
|
|
259
|
+
else elem.default))
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def _should_render_as_table(f: ArrayField) -> bool:
|
|
263
|
+
"""Decide table-vs-tree for a struct array.
|
|
264
|
+
|
|
265
|
+
- meta.render='table' forces table iff the array is flat
|
|
266
|
+
- meta.render='tree' forces tree
|
|
267
|
+
- otherwise auto: table iff flat
|
|
268
|
+
"""
|
|
269
|
+
render = f.meta.get('render')
|
|
270
|
+
if render == 'tree':
|
|
271
|
+
return False
|
|
272
|
+
if render == 'table':
|
|
273
|
+
return is_flat_struct_array(f)
|
|
274
|
+
return is_flat_struct_array(f)
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _make_leaf_row(parent_item: QtWidgets.QTreeWidgetItem,
|
|
278
|
+
tree: QtWidgets.QTreeWidget, binder: WhenBinder,
|
|
279
|
+
label: str, meta: Dict[str, Any], dotted: str,
|
|
280
|
+
widget: QtWidgets.QWidget) -> None:
|
|
281
|
+
"""One tree row hosting a single editor widget in column 1."""
|
|
282
|
+
unit = meta.get('unit', '') or ''
|
|
283
|
+
row = QtWidgets.QTreeWidgetItem(parent_item, [label, '', unit])
|
|
284
|
+
tip = meta.get('tip')
|
|
285
|
+
if tip:
|
|
286
|
+
row.setToolTip(0, str(tip))
|
|
287
|
+
row.setToolTip(1, str(tip))
|
|
288
|
+
tree.setItemWidget(row, 1, widget)
|
|
289
|
+
binder.register(dotted, widget, meta)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --------------------------------------------------------------------------- #
|
|
293
|
+
# Dispatcher
|
|
294
|
+
# --------------------------------------------------------------------------- #
|
|
295
|
+
|
|
296
|
+
def pick_renderer(root: StructField, override: Optional[str] = None):
|
|
297
|
+
"""Return a renderer instance.
|
|
298
|
+
|
|
299
|
+
Default: TreeRenderer (visually consistent across flat and nested data).
|
|
300
|
+
Opt-in to FormRenderer with `"render": "form"` in speech.json.
|
|
301
|
+
"""
|
|
302
|
+
if override == 'form':
|
|
303
|
+
return FormRenderer()
|
|
304
|
+
return TreeRenderer()
|
struct2ui/ui/tables.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Render an ArrayField as a QTableWidget.
|
|
2
|
+
|
|
3
|
+
Layout policy (single source of truth, encoded in two tuples):
|
|
4
|
+
|
|
5
|
+
NON_KEYBOARD_TYPES - controls that should auto-fit column to their content
|
|
6
|
+
(CheckBox, ComboBox). Other columns share remaining
|
|
7
|
+
width via Stretch.
|
|
8
|
+
|
|
9
|
+
CENTERED_TYPES - controls that look odd when stretched (CheckBox).
|
|
10
|
+
They are wrapped in a centering container so the
|
|
11
|
+
control itself keeps its sizeHint width.
|
|
12
|
+
|
|
13
|
+
Adding a new editor type:
|
|
14
|
+
- Want auto-fit column? add it to NON_KEYBOARD_TYPES
|
|
15
|
+
- Want it centered, not filled? add it to CENTERED_TYPES
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, List, Optional, TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from Qt import QtCore, QtWidgets
|
|
23
|
+
|
|
24
|
+
from ..schema import ArrayField, StructField
|
|
25
|
+
from .widgets import WidgetFactory
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from .widgets import WhenBinder
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
# Style policies (extension points)
|
|
33
|
+
# --------------------------------------------------------------------------- #
|
|
34
|
+
|
|
35
|
+
# Column-width auto-fit: ResizeToContents applied to columns whose first cell
|
|
36
|
+
# (after unwrapping any centering container) is one of these widget types.
|
|
37
|
+
NON_KEYBOARD_TYPES = (QtWidgets.QCheckBox, QtWidgets.QComboBox)
|
|
38
|
+
|
|
39
|
+
# Cells whose widget should NOT stretch to fill — instead centered in cell.
|
|
40
|
+
CENTERED_TYPES = (QtWidgets.QCheckBox,)
|
|
41
|
+
|
|
42
|
+
ROW_HEIGHT = 26
|
|
43
|
+
HEADER_BORDER_PAD = 4
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
# Public API
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
|
|
50
|
+
def build_array_table(arr: ArrayField, values: Optional[List[Any]],
|
|
51
|
+
dotted: str, binder: 'WhenBinder') -> QtWidgets.QTableWidget:
|
|
52
|
+
"""Render `arr` (ArrayField) as a QTableWidget.
|
|
53
|
+
|
|
54
|
+
- flat struct array -> multi-column (one column per struct child)
|
|
55
|
+
- scalar/enum array -> single column
|
|
56
|
+
- elements that are themselves struct/array -> '<nested>' placeholder
|
|
57
|
+
(caller should detect via `is_flat_struct_array` and avoid this path)
|
|
58
|
+
|
|
59
|
+
Used by both FormRenderer and TreeRenderer.
|
|
60
|
+
"""
|
|
61
|
+
elem = arr.element
|
|
62
|
+
if not isinstance(elem, StructField):
|
|
63
|
+
return _build_scalar_array(arr, values, dotted, binder)
|
|
64
|
+
return _build_struct_array(arr, values, dotted, binder)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_flat_struct_array(arr: ArrayField) -> bool:
|
|
68
|
+
"""True iff `arr` is a single-layer struct array (every struct child is a leaf).
|
|
69
|
+
|
|
70
|
+
This is the precondition for `build_array_table` producing a clean
|
|
71
|
+
multi-column table.
|
|
72
|
+
"""
|
|
73
|
+
elem = arr.element
|
|
74
|
+
if not isinstance(elem, StructField):
|
|
75
|
+
return False
|
|
76
|
+
return all(not isinstance(c, (StructField, ArrayField)) for c in elem.children)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- #
|
|
80
|
+
# Builders
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
|
|
83
|
+
def _build_scalar_array(arr: ArrayField, values: Optional[List[Any]],
|
|
84
|
+
dotted: str, binder: 'WhenBinder') -> QtWidgets.QTableWidget:
|
|
85
|
+
elem = arr.element
|
|
86
|
+
tbl = QtWidgets.QTableWidget(arr.count, 1)
|
|
87
|
+
tbl.setHorizontalHeaderLabels([elem.name if elem else 'value'])
|
|
88
|
+
for i in range(arr.count):
|
|
89
|
+
v = values[i] if values and i < len(values) else (
|
|
90
|
+
elem.default if elem and elem.default is not None else None)
|
|
91
|
+
cell = WidgetFactory.build(elem, v)
|
|
92
|
+
_put_cell(tbl, i, 0, cell)
|
|
93
|
+
binder.register(f'{dotted}[{i}]', cell, elem.meta if elem else {})
|
|
94
|
+
_polish_table(tbl)
|
|
95
|
+
return tbl
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _build_struct_array(arr: ArrayField, values: Optional[List[Any]],
|
|
99
|
+
dotted: str, binder: 'WhenBinder') -> QtWidgets.QTableWidget:
|
|
100
|
+
elem: StructField = arr.element # type: ignore[assignment]
|
|
101
|
+
cols = [c.name for c in elem.children]
|
|
102
|
+
tbl = QtWidgets.QTableWidget(arr.count, len(cols))
|
|
103
|
+
tbl.setHorizontalHeaderLabels(cols)
|
|
104
|
+
for i in range(arr.count):
|
|
105
|
+
row_val = values[i] if values and i < len(values) else {}
|
|
106
|
+
if not isinstance(row_val, dict):
|
|
107
|
+
row_val = {}
|
|
108
|
+
for col, child in enumerate(elem.children):
|
|
109
|
+
if isinstance(child, (StructField, ArrayField)):
|
|
110
|
+
# Caller should have used is_flat_struct_array() to avoid this.
|
|
111
|
+
tbl.setCellWidget(i, col, QtWidgets.QLabel('<nested>'))
|
|
112
|
+
continue
|
|
113
|
+
cv = row_val.get(child.name, child.default)
|
|
114
|
+
cell = WidgetFactory.build(child, cv)
|
|
115
|
+
_put_cell(tbl, i, col, cell)
|
|
116
|
+
binder.register(f'{dotted}[{i}].{child.name}', cell, child.meta)
|
|
117
|
+
_polish_table(tbl)
|
|
118
|
+
return tbl
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --------------------------------------------------------------------------- #
|
|
122
|
+
# Cell wrapping (centering policy)
|
|
123
|
+
# --------------------------------------------------------------------------- #
|
|
124
|
+
|
|
125
|
+
def _put_cell(tbl: QtWidgets.QTableWidget, row: int, col: int,
|
|
126
|
+
w: QtWidgets.QWidget) -> None:
|
|
127
|
+
"""Place `w` into a cell, applying centering policy."""
|
|
128
|
+
if isinstance(w, CENTERED_TYPES):
|
|
129
|
+
tbl.setCellWidget(row, col, _wrap_centered(w))
|
|
130
|
+
else:
|
|
131
|
+
tbl.setCellWidget(row, col, w)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _wrap_centered(w: QtWidgets.QWidget) -> QtWidgets.QWidget:
|
|
135
|
+
"""Wrap `w` in a horizontal layout with stretch on both sides.
|
|
136
|
+
|
|
137
|
+
Special case: text-less QCheckBox has a tiny sizeHint (indicator width
|
|
138
|
+
only), making it look clipped at the right edge. We bump its minimumWidth
|
|
139
|
+
using QStyle.PM_IndicatorWidth so it stays comfortably visible.
|
|
140
|
+
"""
|
|
141
|
+
if isinstance(w, QtWidgets.QCheckBox) and not w.text():
|
|
142
|
+
ind_w = w.style().pixelMetric(QtWidgets.QStyle.PM_IndicatorWidth, None, w)
|
|
143
|
+
w.setMinimumWidth(ind_w + 12)
|
|
144
|
+
|
|
145
|
+
holder = QtWidgets.QWidget()
|
|
146
|
+
lay = QtWidgets.QHBoxLayout(holder)
|
|
147
|
+
lay.setContentsMargins(4, 0, 4, 0)
|
|
148
|
+
lay.setSpacing(0)
|
|
149
|
+
w.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred)
|
|
150
|
+
lay.addStretch(1)
|
|
151
|
+
lay.addWidget(w)
|
|
152
|
+
lay.addStretch(1)
|
|
153
|
+
return holder
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------- #
|
|
157
|
+
# Table polish (column-width strategy + visual frame)
|
|
158
|
+
# --------------------------------------------------------------------------- #
|
|
159
|
+
|
|
160
|
+
def _polish_table(tbl: QtWidgets.QTableWidget) -> None:
|
|
161
|
+
"""Apply uniform appearance and column-width strategy to a fresh table."""
|
|
162
|
+
tbl.verticalHeader().setVisible(False)
|
|
163
|
+
tbl.verticalHeader().setDefaultSectionSize(ROW_HEIGHT)
|
|
164
|
+
tbl.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection)
|
|
165
|
+
tbl.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
|
|
166
|
+
|
|
167
|
+
# Force an explicit 1px solid frame: some themes draw the default
|
|
168
|
+
# StyledPanel frame too faintly, making the leftmost vertical edge
|
|
169
|
+
# appear missing.
|
|
170
|
+
tbl.setFrameShape(QtWidgets.QFrame.Box)
|
|
171
|
+
tbl.setFrameShadow(QtWidgets.QFrame.Plain)
|
|
172
|
+
tbl.setLineWidth(1)
|
|
173
|
+
tbl.setShowGrid(True)
|
|
174
|
+
tbl.setGridStyle(QtCore.Qt.SolidLine)
|
|
175
|
+
|
|
176
|
+
header = tbl.horizontalHeader()
|
|
177
|
+
header.setStretchLastSection(False)
|
|
178
|
+
for col in range(tbl.columnCount()):
|
|
179
|
+
if _column_is_keyboard(tbl, col):
|
|
180
|
+
header.setSectionResizeMode(col, QtWidgets.QHeaderView.Stretch)
|
|
181
|
+
else:
|
|
182
|
+
header.setSectionResizeMode(col, QtWidgets.QHeaderView.ResizeToContents)
|
|
183
|
+
|
|
184
|
+
# Lock height so the table never shows an internal vertical scrollbar.
|
|
185
|
+
h = header.height() + tbl.rowCount() * ROW_HEIGHT + HEADER_BORDER_PAD
|
|
186
|
+
tbl.setMinimumHeight(h)
|
|
187
|
+
tbl.setMaximumHeight(h)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _column_is_keyboard(tbl: QtWidgets.QTableWidget, col: int) -> bool:
|
|
191
|
+
"""True if the first cell in `col` looks like a keyboard-input control.
|
|
192
|
+
|
|
193
|
+
Look-up logic:
|
|
194
|
+
- ComboBox cell -> holder is the ComboBox itself (non-keyboard)
|
|
195
|
+
- CheckBox cell -> holder is a centering container; descend into
|
|
196
|
+
its children to find the CheckBox (non-keyboard)
|
|
197
|
+
- SpinBox/LineEdit/... -> keyboard column (gets Stretch)
|
|
198
|
+
"""
|
|
199
|
+
holder = tbl.cellWidget(0, col)
|
|
200
|
+
if holder is None:
|
|
201
|
+
return True # be safe: stretch
|
|
202
|
+
if isinstance(holder, NON_KEYBOARD_TYPES):
|
|
203
|
+
return False
|
|
204
|
+
for ch in holder.findChildren(QtWidgets.QWidget):
|
|
205
|
+
if isinstance(ch, NON_KEYBOARD_TYPES):
|
|
206
|
+
return False
|
|
207
|
+
return True
|