struct2ui 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (45) hide show
  1. {struct2ui-0.2.0/src/struct2ui.egg-info → struct2ui-0.3.0}/PKG-INFO +57 -1
  2. {struct2ui-0.2.0 → struct2ui-0.3.0}/README.md +56 -0
  3. {struct2ui-0.2.0 → struct2ui-0.3.0}/pyproject.toml +1 -1
  4. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/__init__.py +1 -1
  5. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/editor.py +202 -5
  6. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/ui/widgets.py +42 -0
  7. {struct2ui-0.2.0 → struct2ui-0.3.0/src/struct2ui.egg-info}/PKG-INFO +57 -1
  8. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_editor.py +104 -0
  9. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_save.py +55 -0
  10. {struct2ui-0.2.0 → struct2ui-0.3.0}/LICENSE +0 -0
  11. {struct2ui-0.2.0 → struct2ui-0.3.0}/setup.cfg +0 -0
  12. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/exporters/__init__.py +0 -0
  13. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/exporters/bin_emitter.py +0 -0
  14. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/exporters/c_emitter.py +0 -0
  15. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/exporters/c_parser.py +0 -0
  16. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/exporters/elf_verifier.py +0 -0
  17. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/exporters/json_format.py +0 -0
  18. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/icons/c2j.png +0 -0
  19. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/icons/elf.png +0 -0
  20. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/icons/export_notes_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
  21. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/icons/flowchart_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
  22. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/icons/refresh_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
  23. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/icons/report_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
  24. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/icons/save_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
  25. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/icons/save_as_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
  26. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/icons/settings_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
  27. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/icons/widgets_24dp_000000_FILL0_wght400_GRAD0_opsz24.png +0 -0
  28. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/schema.py +0 -0
  29. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/ui/__init__.py +0 -0
  30. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/ui/renderers.py +0 -0
  31. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui/ui/tables.py +0 -0
  32. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui.egg-info/SOURCES.txt +0 -0
  33. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui.egg-info/dependency_links.txt +0 -0
  34. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui.egg-info/requires.txt +0 -0
  35. {struct2ui-0.2.0 → struct2ui-0.3.0}/src/struct2ui.egg-info/top_level.txt +0 -0
  36. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_bin_emitter.py +0 -0
  37. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_c_emitter.py +0 -0
  38. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_c_parser.py +0 -0
  39. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_choices.py +0 -0
  40. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_elf_verifier.py +0 -0
  41. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_float_precision.py +0 -0
  42. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_int_checkbox.py +0 -0
  43. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_json_format.py +0 -0
  44. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_load_report.py +0 -0
  45. {struct2ui-0.2.0 → struct2ui-0.3.0}/tests/test_value_readback.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: struct2ui
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Render C struct / JSON schema as editable PySide6 UI, export to C/JSON/bin
5
5
  Author: Jay
6
6
  License: MIT License
@@ -265,6 +265,62 @@ class MyWindow(QtWidgets.QMainWindow):
265
265
  | `appearance` | Button appearance overrides, `{key: {'mode': ..., 'icon': ..., 'text': ...}}` |
266
266
  | `settings_org` | QSettings organization name, defaults to `'struct2ui'` |
267
267
  | `settings_app` | QSettings application name, defaults to `'StructEditor'` |
268
+ | `toolbar_orientation` | Toolbar layout, `'horizontal'` (default) or `'vertical'` |
269
+ | `custom_button_position` | Where `add_custom_button()` drops buttons relative to the built-ins: `'end'` (after, default) or `'start'` (before / far left) |
270
+
271
+ ## Custom Toolbar Buttons
272
+
273
+ Host applications can add their own buttons to the top path bar via
274
+ `add_custom_button()`. All custom buttons form a single group, separated from
275
+ the built-in buttons by a divider that is added automatically — you only choose
276
+ which side they sit on (once, via the `custom_button_position` constructor arg).
277
+
278
+ ```python
279
+ editor = StructEditor("abc.json", "cfg_t", custom_button_position="end")
280
+
281
+ def on_send():
282
+ print(editor.current_values())
283
+
284
+ btn = editor.add_custom_button(
285
+ "Send", # tooltip / accessible label (shown when no icon)
286
+ on_send, # callable invoked on click (no arguments)
287
+ icon="icons/send.png", # optional; falls back to text when missing
288
+ checkable=False, # optional; make the button toggleable
289
+ )
290
+ # `btn` is the created QPushButton, returned so you can tweak it further.
291
+ ```
292
+
293
+ | Parameter | Description |
294
+ | --- | --- |
295
+ | `text` | Tooltip / accessible label, also shown when no icon is given. **Required.** |
296
+ | `on_click` | Callable invoked on click, takes no arguments. **Required.** |
297
+ | `icon` | Path to an icon file; falls back to `text` when the path is missing/empty. |
298
+ | `checkable` | Make the button toggleable, defaults to `False`. |
299
+
300
+ Returns the created `QPushButton`.
301
+
302
+ ## Programmatic API
303
+
304
+ Beyond the UI, `StructEditor` exposes methods to read state and drive exports
305
+ from code (e.g. wired to a custom button):
306
+
307
+ | Method | Description |
308
+ | --- | --- |
309
+ | `current_values() -> dict` | Live editor values of the active section, keyed by field name (empty dict when no parameter section is shown). |
310
+ | `current_paths() -> dict` | Current `{'modules', 'pipeline', 'elf'}` file paths; an empty string means that slot is unset. |
311
+ | `export_bin(path, show_dialogs=False) -> bool` | Write the current pipeline to a `.bin` at `path` — the programmatic equivalent of clicking Export → Binary. Returns `True` when there are no ELF layout errors (or no ELF is selected), `False` otherwise. |
312
+
313
+ `export_bin` verifies the ELF layout first when an ELF is selected (the file is
314
+ still written, matching the UI flow). With `show_dialogs=True` it pops the same
315
+ success / ELF-mismatch / failure dialogs the UI shows; with the default
316
+ `show_dialogs=False` no dialogs appear and render/write failures are raised as
317
+ exceptions for the caller to handle.
318
+
319
+ ```python
320
+ paths = editor.current_paths() # {'modules': ..., 'pipeline': ..., 'elf': ...}
321
+ name = paths['pipeline'] # derive an output name from the pipeline
322
+ ok = editor.export_bin("out/speech.bin") # True when ELF layout is clean
323
+ ```
268
324
 
269
325
  ## Export API
270
326
 
@@ -212,6 +212,62 @@ class MyWindow(QtWidgets.QMainWindow):
212
212
  | `appearance` | Button appearance overrides, `{key: {'mode': ..., 'icon': ..., 'text': ...}}` |
213
213
  | `settings_org` | QSettings organization name, defaults to `'struct2ui'` |
214
214
  | `settings_app` | QSettings application name, defaults to `'StructEditor'` |
215
+ | `toolbar_orientation` | Toolbar layout, `'horizontal'` (default) or `'vertical'` |
216
+ | `custom_button_position` | Where `add_custom_button()` drops buttons relative to the built-ins: `'end'` (after, default) or `'start'` (before / far left) |
217
+
218
+ ## Custom Toolbar Buttons
219
+
220
+ Host applications can add their own buttons to the top path bar via
221
+ `add_custom_button()`. All custom buttons form a single group, separated from
222
+ the built-in buttons by a divider that is added automatically — you only choose
223
+ which side they sit on (once, via the `custom_button_position` constructor arg).
224
+
225
+ ```python
226
+ editor = StructEditor("abc.json", "cfg_t", custom_button_position="end")
227
+
228
+ def on_send():
229
+ print(editor.current_values())
230
+
231
+ btn = editor.add_custom_button(
232
+ "Send", # tooltip / accessible label (shown when no icon)
233
+ on_send, # callable invoked on click (no arguments)
234
+ icon="icons/send.png", # optional; falls back to text when missing
235
+ checkable=False, # optional; make the button toggleable
236
+ )
237
+ # `btn` is the created QPushButton, returned so you can tweak it further.
238
+ ```
239
+
240
+ | Parameter | Description |
241
+ | --- | --- |
242
+ | `text` | Tooltip / accessible label, also shown when no icon is given. **Required.** |
243
+ | `on_click` | Callable invoked on click, takes no arguments. **Required.** |
244
+ | `icon` | Path to an icon file; falls back to `text` when the path is missing/empty. |
245
+ | `checkable` | Make the button toggleable, defaults to `False`. |
246
+
247
+ Returns the created `QPushButton`.
248
+
249
+ ## Programmatic API
250
+
251
+ Beyond the UI, `StructEditor` exposes methods to read state and drive exports
252
+ from code (e.g. wired to a custom button):
253
+
254
+ | Method | Description |
255
+ | --- | --- |
256
+ | `current_values() -> dict` | Live editor values of the active section, keyed by field name (empty dict when no parameter section is shown). |
257
+ | `current_paths() -> dict` | Current `{'modules', 'pipeline', 'elf'}` file paths; an empty string means that slot is unset. |
258
+ | `export_bin(path, show_dialogs=False) -> bool` | Write the current pipeline to a `.bin` at `path` — the programmatic equivalent of clicking Export → Binary. Returns `True` when there are no ELF layout errors (or no ELF is selected), `False` otherwise. |
259
+
260
+ `export_bin` verifies the ELF layout first when an ELF is selected (the file is
261
+ still written, matching the UI flow). With `show_dialogs=True` it pops the same
262
+ success / ELF-mismatch / failure dialogs the UI shows; with the default
263
+ `show_dialogs=False` no dialogs appear and render/write failures are raised as
264
+ exceptions for the caller to handle.
265
+
266
+ ```python
267
+ paths = editor.current_paths() # {'modules': ..., 'pipeline': ..., 'elf': ...}
268
+ name = paths['pipeline'] # derive an output name from the pipeline
269
+ ok = editor.export_bin("out/speech.bin") # True when ELF layout is clean
270
+ ```
215
271
 
216
272
  ## Export API
217
273
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "struct2ui"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Render C struct / JSON schema as editable PySide6 UI, export to C/JSON/bin"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,4 +1,4 @@
1
1
  from .editor import StructEditor
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
4
4
  __all__ = ["StructEditor"]
@@ -53,6 +53,12 @@ class _OverflowBar(QtWidgets.QWidget):
53
53
  # Buttons in display order; overflow removes them from the right end
54
54
  # (end of this list) first.
55
55
  self._buttons: list[tuple[QtWidgets.QPushButton, str, object]] = []
56
+ # Group separators keyed by the button they sit to the LEFT of.
57
+ # Keying on the button (not its index) keeps the mapping valid when
58
+ # buttons are prepended/appended. A separator follows its button's
59
+ # visibility so a collapsed group never leaves a dangling divider, and
60
+ # the layout itself suppresses any leading divider.
61
+ self._separators: dict[QtWidgets.QWidget, QtWidgets.QWidget] = {}
56
62
 
57
63
  self.overflow_btn = QtWidgets.QToolButton(self)
58
64
  self.overflow_btn.setText('\u00bb')
@@ -63,11 +69,26 @@ class _OverflowBar(QtWidgets.QWidget):
63
69
  self.overflow_btn.hide()
64
70
  self._tail: QtWidgets.QWidget = None # type: ignore[assignment]
65
71
 
66
- def set_buttons(self, buttons, tail):
67
- """buttons: list of (button, overflow_label, callback)."""
72
+ def _make_separator(self):
73
+ line = QtWidgets.QFrame(self)
74
+ line.setFrameShape(QtWidgets.QFrame.VLine)
75
+ line.setFrameShadow(QtWidgets.QFrame.Sunken)
76
+ return line
77
+
78
+ def set_buttons(self, buttons, tail, separators=None):
79
+ """buttons: list of (button, overflow_label, callback).
80
+
81
+ separators: optional set of button indices that should have a group
82
+ divider drawn to their left.
83
+ """
68
84
  self._buttons = list(buttons)
69
85
  self._tail = tail
70
- for btn, _label, _cb in self._buttons:
86
+ sep_idx = set(separators or ())
87
+ for idx, (btn, _label, _cb) in enumerate(self._buttons):
88
+ if idx in sep_idx and idx > 0:
89
+ sep = self._make_separator()
90
+ self._separators[btn] = sep
91
+ self._layout.addWidget(sep)
71
92
  self._layout.addWidget(btn)
72
93
  self._layout.addStretch(1)
73
94
  self._layout.addWidget(self.overflow_btn)
@@ -75,6 +96,51 @@ class _OverflowBar(QtWidgets.QWidget):
75
96
  self._layout.addWidget(tail)
76
97
  self._relayout()
77
98
 
99
+ def add_button(self, button, overflow_label, callback, separator=False,
100
+ at_index=None):
101
+ """Insert one button into the bar.
102
+
103
+ at_index=None (default): append to the end (before overflow/tail).
104
+ at_index=N: insert at logical button position N (0 = far left).
105
+
106
+ separator=True draws a group divider to the LEFT of this button,
107
+ unless it would land first (no leading divider).
108
+ """
109
+ n = len(self._buttons)
110
+ pos = n if at_index is None else max(0, min(at_index, n))
111
+ self._buttons.insert(pos, (button, overflow_label, callback))
112
+
113
+ # Map logical position to a layout index. The layout holds, in order:
114
+ # [maybe sep, btn] * n, stretch, overflow, [tail]. We anchor on the
115
+ # widget currently at logical position `pos`; if that button owns a
116
+ # divider, anchor on the divider so the new button lands before it
117
+ # (keeping the divider attached to its original button).
118
+ if pos < n:
119
+ anchor = self._buttons[pos + 1][0]
120
+ anchor_sep = self._separators.get(anchor)
121
+ anchor_widget = anchor_sep if anchor_sep is not None else anchor
122
+ layout_at = self._layout.indexOf(anchor_widget)
123
+ else:
124
+ layout_at = self._layout.indexOf(self.overflow_btn) - 1
125
+ layout_at = max(layout_at, 0)
126
+
127
+ if separator and pos > 0:
128
+ sep = self._make_separator()
129
+ self._separators[button] = sep
130
+ self._layout.insertWidget(layout_at, sep)
131
+ layout_at += 1
132
+ self._layout.insertWidget(layout_at, button)
133
+ self._relayout()
134
+
135
+ def ensure_separator_before(self, button):
136
+ """Add a group divider to the LEFT of `button` if it lacks one."""
137
+ if button in self._separators:
138
+ return
139
+ sep = self._make_separator()
140
+ self._separators[button] = sep
141
+ self._layout.insertWidget(self._layout.indexOf(button), sep)
142
+ self._relayout()
143
+
78
144
  def minimumSizeHint(self):
79
145
  # Report a small minimum so the parent layout can shrink the bar to
80
146
  # any width; overflow logic (not the layout) decides what stays.
@@ -117,12 +183,22 @@ class _OverflowBar(QtWidgets.QWidget):
117
183
  self.overflow_menu.clear()
118
184
  any_hidden = False
119
185
  for idx, (btn, label, callback) in enumerate(self._buttons):
186
+ has_sep = btn in self._separators
120
187
  if idx < visible_count:
121
188
  btn.show()
122
189
  else:
123
190
  btn.hide()
191
+ # Carry the group divider into the menu: a collapsed button
192
+ # that starts a group gets a menu separator before it (never
193
+ # as the very first menu item).
194
+ if has_sep and any_hidden:
195
+ self.overflow_menu.addSeparator()
124
196
  any_hidden = True
125
197
  self.overflow_menu.addAction(label, callback)
198
+ if has_sep:
199
+ # Show the inline divider only when its button stays visible
200
+ # and is not the first visible item (no leading divider).
201
+ self._separators[btn].setVisible(0 < idx < visible_count)
126
202
  self.overflow_btn.setVisible(any_hidden)
127
203
 
128
204
 
@@ -159,8 +235,17 @@ class StructEditor(QtWidgets.QWidget):
159
235
  appearance: Dict[str, Dict[str, Any]] = None,
160
236
  settings_org: str = 'struct2ui',
161
237
  settings_app: str = 'StructEditor',
162
- toolbar_orientation: str = 'horizontal'):
238
+ toolbar_orientation: str = 'horizontal',
239
+ custom_button_position: str = 'end'):
163
240
  super().__init__(parent)
241
+ # Where add_custom_button() drops new buttons relative to the built-in
242
+ # ones: 'end' (after, default) or 'start' (before / far left).
243
+ self._custom_button_position = (
244
+ 'start' if custom_button_position == 'start' else 'end')
245
+ # How many custom buttons have been added; only the first one in the
246
+ # group draws the dividing line, and 'start' mode keeps them in
247
+ # add-order by inserting each after the previous custom button.
248
+ self._custom_button_count = 0
164
249
  # Per-button appearance config (for embedding as a standalone module).
165
250
  # Shape: {key: {'mode': 'text'|'default'|'custom', 'icon': path,
166
251
  # 'text': label}}. Unspecified keys fall back to _BUTTON_DEFAULTS
@@ -317,7 +402,11 @@ class StructEditor(QtWidgets.QWidget):
317
402
  ]
318
403
 
319
404
  bar = _OverflowBar(self)
320
- bar.set_buttons(buttons, tail=self.settings_btn)
405
+ # Group dividers: [sources] | [actions] | [tools]
406
+ # sources: Modules, Pipeline, ELF, Reload (0..3)
407
+ # actions: Save, Save As, Export (4..6)
408
+ # tools: C2J, Report (7..8)
409
+ bar.set_buttons(buttons, tail=self.settings_btn, separators={4, 7})
321
410
 
322
411
  self._refresh_path_tooltips()
323
412
  return bar
@@ -367,6 +456,114 @@ class StructEditor(QtWidgets.QWidget):
367
456
  btn.setText(text) # last-resort fallback
368
457
  return btn
369
458
 
459
+ # ---- public API: custom buttons ------------------------------------- #
460
+ def add_custom_button(self, text: str, on_click, icon: str = None,
461
+ checkable: bool = False) -> QtWidgets.QPushButton:
462
+ """Add a custom button to the top path bar.
463
+
464
+ text : tooltip / accessible label (also shown when no icon).
465
+ on_click : callable invoked on click (no arguments).
466
+ icon : optional path to an icon file; falls back to text when the
467
+ path is missing or empty.
468
+ checkable: make the button toggleable.
469
+
470
+ Custom buttons form a single group. Their placement relative to the
471
+ built-in buttons is set once via the ``custom_button_position``
472
+ constructor argument ('end', default, or 'start'). A divider between
473
+ the custom group and the built-in buttons is added automatically.
474
+
475
+ Returns the created QPushButton so the host can tweak it further.
476
+ """
477
+ btn = QtWidgets.QPushButton(self)
478
+ btn.setToolTip(text)
479
+ btn.setCheckable(checkable)
480
+ if icon and os.path.exists(icon):
481
+ btn.setIcon(QtGui.QIcon(icon))
482
+ else:
483
+ btn.setText(text)
484
+ btn.clicked.connect(lambda _checked=False: on_click())
485
+
486
+ # The overflow-menu action reuses the same no-arg callback.
487
+ menu_cb = lambda: on_click()
488
+ first = self._custom_button_count == 0
489
+ if self._custom_button_position == 'start':
490
+ # Keep add-order at the far left: button N lands at index N.
491
+ self.path_bar.add_button(
492
+ btn, text, menu_cb, at_index=self._custom_button_count)
493
+ if first:
494
+ # Divide the custom group from the built-ins that follow it.
495
+ builtin = self.path_bar._buttons[1][0]
496
+ self.path_bar.ensure_separator_before(builtin)
497
+ else:
498
+ # Append; the first custom button starts the trailing group.
499
+ self.path_bar.add_button(btn, text, menu_cb, separator=first)
500
+
501
+ self._custom_button_count += 1
502
+ return btn
503
+
504
+ # ---- public API: programmatic export / paths ------------------------ #
505
+ def export_bin(self, path: str, show_dialogs: bool = False) -> bool:
506
+ """Export the current pipeline to a .bin file at ``path``.
507
+
508
+ This is the programmatic equivalent of clicking Export and choosing
509
+ the Binary (*.bin) format. When an ELF is selected its layout is
510
+ verified first (the file is still written, matching the UI flow).
511
+
512
+ path : destination .bin file path.
513
+ show_dialogs : when True, behaves like clicking Export: success, ELF
514
+ mismatch and failure all pop the same QMessageBox the UI
515
+ shows (and a mismatch jumps to the Report panel). When
516
+ False (default) no dialogs appear and errors are raised
517
+ as exceptions for the caller to handle.
518
+
519
+ Returns True when the export has no ELF layout errors (or no ELF is
520
+ selected), False when ELF layout errors were detected.
521
+
522
+ Raises on render or write failure when show_dialogs is False.
523
+ """
524
+ elf_ok = True
525
+ if self.elf_path:
526
+ self._verify_elf()
527
+ elf_ok = not (self._elf_report is not None and
528
+ self._elf_report.has_errors)
529
+
530
+ try:
531
+ content = self._render_export('bin')
532
+ with open(path, 'wb') as f:
533
+ f.write(content)
534
+ except Exception as exc:
535
+ if show_dialogs:
536
+ QtWidgets.QMessageBox.critical(
537
+ self, 'Export failed', f'Could not export {path}:\n{exc}')
538
+ return False
539
+ raise
540
+
541
+ if show_dialogs:
542
+ if not elf_ok:
543
+ n = len(self._elf_report.errors)
544
+ self._show_report()
545
+ QtWidgets.QMessageBox.warning(
546
+ self, 'Exported with ELF mismatch',
547
+ f'Exported (bin) to:\n{path}\n\n'
548
+ f'WARNING: {n} ELF layout error'
549
+ f"{'s' if n != 1 else ''} detected. See the Report panel.")
550
+ else:
551
+ QtWidgets.QMessageBox.information(
552
+ self, 'Exported', f'Exported (bin) to:\n{path}')
553
+
554
+ return elf_ok
555
+
556
+ def current_paths(self) -> Dict[str, str]:
557
+ """Return the current Modules / Pipeline / ELF file paths.
558
+
559
+ Empty strings mean nothing is selected for that slot.
560
+ """
561
+ return {
562
+ 'modules': self.struct_dir,
563
+ 'pipeline': self.speech_path,
564
+ 'elf': self.elf_path,
565
+ }
566
+
370
567
  def _refresh_path_tooltips(self) -> None:
371
568
  modules_text = self._appearance.get('modules', {}).get('text', 'Modules')
372
569
  pipeline_text = self._appearance.get('pipeline', {}).get('text', 'Pipeline')
@@ -50,6 +50,42 @@ _CHANGE_SIGNALS: Dict[type, str] = {
50
50
  }
51
51
 
52
52
 
53
+ # Widget types whose value changes on mouse wheel; we only allow that while
54
+ # the widget has focus (i.e. it is "selected").
55
+ _WHEEL_GUARDED = (
56
+ QtWidgets.QAbstractSpinBox,
57
+ QtWidgets.QSlider,
58
+ QtWidgets.QDial,
59
+ )
60
+
61
+
62
+ class _WheelGuard(QtCore.QObject):
63
+ """Event filter: swallow wheel events unless the widget has focus.
64
+
65
+ Lets the user scroll the page over a numeric editor without accidentally
66
+ changing its value; the value only reacts to the wheel once the editor is
67
+ focused (clicked/tab-selected). The unfocused wheel event is re-posted to
68
+ the parent so the surrounding scroll area still scrolls.
69
+ """
70
+
71
+ def eventFilter(self, obj: QtCore.QObject, ev: QtCore.QEvent) -> bool:
72
+ if ev.type() == QtCore.QEvent.Wheel and not obj.hasFocus():
73
+ ev.ignore()
74
+ return True
75
+ return super().eventFilter(obj, ev)
76
+
77
+
78
+ _WHEEL_GUARD = _WheelGuard()
79
+
80
+
81
+ def _install_wheel_guard(w: QtWidgets.QWidget) -> None:
82
+ """Apply the focus-only-wheel policy to a value editor (and inner slider)."""
83
+ target = getattr(w, 'slider', w)
84
+ if isinstance(target, _WHEEL_GUARDED):
85
+ target.setFocusPolicy(QtCore.Qt.StrongFocus)
86
+ target.installEventFilter(_WHEEL_GUARD)
87
+
88
+
53
89
  def _lookup(mapping: Dict[type, Any], w: QtWidgets.QWidget) -> Any:
54
90
  """Find a mapping entry honouring isinstance (so subclasses are matched).
55
91
 
@@ -75,6 +111,12 @@ class WidgetFactory:
75
111
 
76
112
  @staticmethod
77
113
  def build(f: Field, value: Any) -> QtWidgets.QWidget:
114
+ w = WidgetFactory._build(f, value)
115
+ _install_wheel_guard(w)
116
+ return w
117
+
118
+ @staticmethod
119
+ def _build(f: Field, value: Any) -> QtWidgets.QWidget:
78
120
  if isinstance(f, ArrayField):
79
121
  return _build_array_value(f, value)
80
122
  if isinstance(f, EnumField):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: struct2ui
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Render C struct / JSON schema as editable PySide6 UI, export to C/JSON/bin
5
5
  Author: Jay
6
6
  License: MIT License
@@ -265,6 +265,62 @@ class MyWindow(QtWidgets.QMainWindow):
265
265
  | `appearance` | Button appearance overrides, `{key: {'mode': ..., 'icon': ..., 'text': ...}}` |
266
266
  | `settings_org` | QSettings organization name, defaults to `'struct2ui'` |
267
267
  | `settings_app` | QSettings application name, defaults to `'StructEditor'` |
268
+ | `toolbar_orientation` | Toolbar layout, `'horizontal'` (default) or `'vertical'` |
269
+ | `custom_button_position` | Where `add_custom_button()` drops buttons relative to the built-ins: `'end'` (after, default) or `'start'` (before / far left) |
270
+
271
+ ## Custom Toolbar Buttons
272
+
273
+ Host applications can add their own buttons to the top path bar via
274
+ `add_custom_button()`. All custom buttons form a single group, separated from
275
+ the built-in buttons by a divider that is added automatically — you only choose
276
+ which side they sit on (once, via the `custom_button_position` constructor arg).
277
+
278
+ ```python
279
+ editor = StructEditor("abc.json", "cfg_t", custom_button_position="end")
280
+
281
+ def on_send():
282
+ print(editor.current_values())
283
+
284
+ btn = editor.add_custom_button(
285
+ "Send", # tooltip / accessible label (shown when no icon)
286
+ on_send, # callable invoked on click (no arguments)
287
+ icon="icons/send.png", # optional; falls back to text when missing
288
+ checkable=False, # optional; make the button toggleable
289
+ )
290
+ # `btn` is the created QPushButton, returned so you can tweak it further.
291
+ ```
292
+
293
+ | Parameter | Description |
294
+ | --- | --- |
295
+ | `text` | Tooltip / accessible label, also shown when no icon is given. **Required.** |
296
+ | `on_click` | Callable invoked on click, takes no arguments. **Required.** |
297
+ | `icon` | Path to an icon file; falls back to `text` when the path is missing/empty. |
298
+ | `checkable` | Make the button toggleable, defaults to `False`. |
299
+
300
+ Returns the created `QPushButton`.
301
+
302
+ ## Programmatic API
303
+
304
+ Beyond the UI, `StructEditor` exposes methods to read state and drive exports
305
+ from code (e.g. wired to a custom button):
306
+
307
+ | Method | Description |
308
+ | --- | --- |
309
+ | `current_values() -> dict` | Live editor values of the active section, keyed by field name (empty dict when no parameter section is shown). |
310
+ | `current_paths() -> dict` | Current `{'modules', 'pipeline', 'elf'}` file paths; an empty string means that slot is unset. |
311
+ | `export_bin(path, show_dialogs=False) -> bool` | Write the current pipeline to a `.bin` at `path` — the programmatic equivalent of clicking Export → Binary. Returns `True` when there are no ELF layout errors (or no ELF is selected), `False` otherwise. |
312
+
313
+ `export_bin` verifies the ELF layout first when an ELF is selected (the file is
314
+ still written, matching the UI flow). With `show_dialogs=True` it pops the same
315
+ success / ELF-mismatch / failure dialogs the UI shows; with the default
316
+ `show_dialogs=False` no dialogs appear and render/write failures are raised as
317
+ exceptions for the caller to handle.
318
+
319
+ ```python
320
+ paths = editor.current_paths() # {'modules': ..., 'pipeline': ..., 'elf': ...}
321
+ name = paths['pipeline'] # derive an output name from the pipeline
322
+ ok = editor.export_bin("out/speech.bin") # True when ELF layout is clean
323
+ ```
268
324
 
269
325
  ## Export API
270
326
 
@@ -20,6 +20,110 @@ def test_defaults_to_empty_when_no_args(qapp):
20
20
  assert w.elf_path == ''
21
21
 
22
22
 
23
+ def test_add_custom_button_appends_and_invokes_handler(qapp):
24
+ w = StructEditor()
25
+ calls = []
26
+ btn = w.add_custom_button('My Action', lambda: calls.append(1))
27
+
28
+ assert isinstance(btn, QtWidgets.QPushButton)
29
+ assert btn.toolTip() == 'My Action'
30
+ assert (btn, 'My Action') in [(b, lbl) for b, lbl, _ in w.path_bar._buttons]
31
+
32
+ btn.click()
33
+ assert calls == [1]
34
+
35
+
36
+ def test_add_custom_button_checkable(qapp):
37
+ w = StructEditor()
38
+ btn = w.add_custom_button('Toggle', lambda: None, checkable=True)
39
+ assert btn.isCheckable()
40
+
41
+
42
+ def _button_index(bar, btn):
43
+ return [b for b, _, _ in bar._buttons].index(btn)
44
+
45
+
46
+ def test_path_bar_has_group_separators(qapp, real_cfg_dir, real_flow):
47
+ w = StructEditor(real_flow, real_cfg_dir)
48
+ bar = w.path_bar
49
+ # Two built-in group dividers, sitting before buttons at index 4 and 7.
50
+ # First group is [Modules, Pipeline, ELF, Reload].
51
+ sep_indices = sorted(_button_index(bar, b) for b in bar._separators)
52
+ assert sep_indices == [4, 7]
53
+
54
+
55
+ def test_custom_button_first_starts_group(qapp):
56
+ w = StructEditor()
57
+ btn = w.add_custom_button('First', lambda: None)
58
+ assert btn in w.path_bar._separators
59
+
60
+
61
+ def test_custom_buttons_share_one_group(qapp):
62
+ w = StructEditor()
63
+ first = w.add_custom_button('First', lambda: None)
64
+ second = w.add_custom_button('Second', lambda: None)
65
+ # Only the first custom button carries the dividing line.
66
+ assert first in w.path_bar._separators
67
+ assert second not in w.path_bar._separators
68
+
69
+
70
+ def test_custom_button_position_start(qapp):
71
+ w = StructEditor(custom_button_position='start')
72
+ first = w.add_custom_button('First', lambda: None)
73
+ second = w.add_custom_button('Second', lambda: None)
74
+ bar = w.path_bar
75
+ labels = [lbl for _, lbl, _ in bar._buttons]
76
+ # Custom buttons lead, in add-order, before the built-ins.
77
+ assert labels[:2] == ['First', 'Second']
78
+ # The custom group has no leading divider; the divider sits right after
79
+ # the last custom button, i.e. before the first built-in (Modules).
80
+ assert first not in bar._separators
81
+ assert second not in bar._separators
82
+ modules_btn = bar._buttons[2][0]
83
+ assert modules_btn is w.cfg_btn
84
+ assert modules_btn in bar._separators
85
+
86
+ # Verify the *visual* order in the layout: First, Second, divider, Modules.
87
+ sep = bar._separators[modules_btn]
88
+ i_first = bar._layout.indexOf(first)
89
+ i_second = bar._layout.indexOf(second)
90
+ i_sep = bar._layout.indexOf(sep)
91
+ i_modules = bar._layout.indexOf(modules_btn)
92
+ assert i_first < i_second < i_sep < i_modules
93
+
94
+
95
+ def test_separator_hidden_when_its_button_overflows(qapp):
96
+ w = StructEditor()
97
+ btn = w.add_custom_button('First', lambda: None)
98
+ bar = w.path_bar
99
+ sep = bar._separators[btn]
100
+
101
+ bar.resize(4000, 40)
102
+ bar._relayout()
103
+ assert not sep.isHidden()
104
+
105
+ bar.resize(40, 40)
106
+ bar._relayout()
107
+ assert sep.isHidden()
108
+
109
+
110
+ def test_overflow_menu_carries_group_separators(qapp, real_cfg_dir, real_flow):
111
+ w = StructEditor(real_flow, real_cfg_dir)
112
+ bar = w.path_bar
113
+ bar.resize(40, 40)
114
+ bar._relayout()
115
+
116
+ acts = bar.overflow_menu.actions()
117
+ # Everything overflowed; menu mirrors the inline grouping.
118
+ assert not acts[0].isSeparator()
119
+ sep_positions = [i for i, a in enumerate(acts) if a.isSeparator()]
120
+ labels = [a.text() for a in acts if not a.isSeparator()]
121
+ assert labels == ['Modules', 'Pipeline', 'ELF', 'Reload',
122
+ 'Save', 'Save As', 'Export', 'C2J', 'Report']
123
+ # Two group dividers (sources|actions and actions|tools).
124
+ assert len(sep_positions) == 2
125
+
126
+
23
127
  def test_reload_does_not_persist_paths(qapp, real_cfg_dir, real_flow):
24
128
  w = StructEditor(real_flow, real_cfg_dir)
25
129
 
@@ -192,3 +192,58 @@ def test_pick_elf_cancel_keeps_state(qapp, real_cfg_dir, flow_copy, monkeypatch)
192
192
  w._pick_elf_file()
193
193
 
194
194
  assert w.elf_path == ''
195
+
196
+
197
+ def test_export_bin_api_writes_file(qapp, real_cfg_dir, flow_copy, tmp_path):
198
+ w = StructEditor(flow_copy, real_cfg_dir)
199
+ target = str(tmp_path / 'api.bin')
200
+
201
+ result = w.export_bin(target)
202
+
203
+ assert result is True
204
+ assert os.path.exists(target)
205
+ assert len(open(target, 'rb').read()) > 0
206
+
207
+
208
+ def test_export_bin_api_raises_on_bad_path(qapp, real_cfg_dir, flow_copy,
209
+ tmp_path):
210
+ w = StructEditor(flow_copy, real_cfg_dir)
211
+ bad = str(tmp_path / 'no_such_dir' / 'api.bin')
212
+
213
+ with pytest.raises(Exception):
214
+ w.export_bin(bad)
215
+
216
+
217
+ def test_export_bin_api_dialog_swallows_error(qapp, real_cfg_dir, flow_copy,
218
+ tmp_path, monkeypatch):
219
+ w = StructEditor(flow_copy, real_cfg_dir)
220
+ bad = str(tmp_path / 'no_such_dir' / 'api.bin')
221
+ monkeypatch.setattr(
222
+ QtWidgets.QMessageBox, 'critical', lambda *a, **k: None)
223
+
224
+ assert w.export_bin(bad, show_dialogs=True) is False
225
+
226
+
227
+ def test_export_bin_api_dialog_on_success(qapp, real_cfg_dir, flow_copy,
228
+ tmp_path, monkeypatch):
229
+ w = StructEditor(flow_copy, real_cfg_dir)
230
+ target = str(tmp_path / 'api.bin')
231
+ shown = []
232
+ monkeypatch.setattr(
233
+ QtWidgets.QMessageBox, 'information',
234
+ lambda *a, **k: shown.append(a))
235
+
236
+ result = w.export_bin(target, show_dialogs=True)
237
+
238
+ assert result is True
239
+ assert os.path.exists(target)
240
+ # Success pops the same information dialog as clicking Export.
241
+ assert len(shown) == 1
242
+
243
+
244
+ def test_current_paths_api(qapp, real_cfg_dir, flow_copy):
245
+ w = StructEditor(flow_copy, real_cfg_dir)
246
+ paths = w.current_paths()
247
+ assert paths['modules'] == real_cfg_dir
248
+ assert paths['pipeline'] == flow_copy
249
+ assert paths['elf'] == ''
File without changes
File without changes