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,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