pyobs-gui 2.3.1__tar.gz → 2.4.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 (168) hide show
  1. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/PKG-INFO +1 -1
  2. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/structuredconfigwidget.py +83 -36
  3. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyproject.toml +1 -1
  4. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/test_structuredconfigwidget.py +67 -18
  5. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/uv.lock +1 -1
  6. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/.claude/skills/verify/SKILL.md +0 -0
  7. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/.github/dependabot.yml +0 -0
  8. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/.github/workflows/dependabot-automerge.yml +0 -0
  9. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/.github/workflows/pypi.yml +0 -0
  10. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/.github/workflows/pyrefly.yml +0 -0
  11. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/.github/workflows/ruff.yml +0 -0
  12. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/.gitignore +0 -0
  13. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/.pre-commit-config.yaml +0 -0
  14. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/.readthedocs.yml +0 -0
  15. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/CHANGELOG.rst +0 -0
  16. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/LICENSE +0 -0
  17. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/README.md +0 -0
  18. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/bin/mypyuic5 +0 -0
  19. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/docs/Makefile +0 -0
  20. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/docs/make.bat +0 -0
  21. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/docs/requirements.txt +0 -0
  22. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/docs/source/_static/pyobs.gif +0 -0
  23. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/docs/source/conf.py +0 -0
  24. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/docs/source/index.rst +0 -0
  25. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/mypy.ini +0 -0
  26. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/__init__.py +0 -0
  27. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/__main__.py +0 -0
  28. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/_nuitka_astropy_patch.py +0 -0
  29. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/accounts.py +0 -0
  30. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/acquisitionwidget.py +0 -0
  31. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/autofocuswidget.py +0 -0
  32. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/autoguidingwidget.py +0 -0
  33. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/base.py +0 -0
  34. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/camerawidget.py +0 -0
  35. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/commandinputwidget.py +0 -0
  36. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/compassmovewidget.py +0 -0
  37. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/coolingwidget.py +0 -0
  38. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/datadisplaywidget.py +0 -0
  39. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/eventswidget.py +0 -0
  40. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/filterwidget.py +0 -0
  41. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/fitsheaderswidget.py +0 -0
  42. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/focuswidget.py +0 -0
  43. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/gui.py +0 -0
  44. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/login.py +0 -0
  45. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/loginwindow.py +0 -0
  46. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/logmodel.py +0 -0
  47. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/mainwindow.py +0 -0
  48. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/modewidget.py +0 -0
  49. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/modifiedmixin.py +0 -0
  50. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/modulegui.py +0 -0
  51. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/__init__.py +0 -0
  52. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/acquisitionwidget.ui +0 -0
  53. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/acquisitionwidget_ui.py +0 -0
  54. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/autofocuswidget.ui +0 -0
  55. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/autofocuswidget_ui.py +0 -0
  56. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/autoguidingwidget.ui +0 -0
  57. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/autoguidingwidget_ui.py +0 -0
  58. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/camerawidget.ui +0 -0
  59. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/camerawidget_ui.py +0 -0
  60. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/compassmoveplugin.py +0 -0
  61. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/compassmovewidget.py +0 -0
  62. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/compassmovewidget.ui +0 -0
  63. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/compassmovewidget_ui.py +0 -0
  64. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/compile.sh +0 -0
  65. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/coolingwidget.ui +0 -0
  66. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/coolingwidget_ui.py +0 -0
  67. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/datadisplayplugin.py +0 -0
  68. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/datadisplaywidget.ui +0 -0
  69. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/datadisplaywidget_ui.py +0 -0
  70. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/designer.sh +0 -0
  71. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/eventswidget.ui +0 -0
  72. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/eventswidget_ui.py +0 -0
  73. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/filterwidget.ui +0 -0
  74. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/filterwidget_ui.py +0 -0
  75. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/fitsheaderswidget.ui +0 -0
  76. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/fitsheaderswidget_ui.py +0 -0
  77. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/focuswidget.ui +0 -0
  78. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/focuswidget_ui.py +0 -0
  79. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/mainwindow.ui +0 -0
  80. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/mainwindow_ui.py +0 -0
  81. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/modewidget.ui +0 -0
  82. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/modewidget_ui.py +0 -0
  83. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/arrow-alt-circle-down-solid.svg +0 -0
  84. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/arrow-alt-circle-left-solid.svg +0 -0
  85. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/arrow-alt-circle-right-solid.svg +0 -0
  86. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/arrow-alt-circle-up-solid.svg +0 -0
  87. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/arrow-up-right-from-square-solid.svg +0 -0
  88. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/arrows-to-eye-solid.svg +0 -0
  89. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/chart-line-solid.svg +0 -0
  90. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/circle-question-solid.svg +0 -0
  91. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/cloud-sun-solid.svg +0 -0
  92. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/edit-solid.svg +0 -0
  93. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/search-solid.svg +0 -0
  94. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources/undo-solid.svg +0 -0
  95. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources.qrc +0 -0
  96. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/resources_rc.py +0 -0
  97. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/roboticwidget.ui +0 -0
  98. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/roboticwidget_ui.py +0 -0
  99. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/roofwidget.ui +0 -0
  100. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/roofwidget_ui.py +0 -0
  101. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/schedulewidget.ui +0 -0
  102. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/schedulewidget_ui.py +0 -0
  103. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/shellwidget.ui +0 -0
  104. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/shellwidget_ui.py +0 -0
  105. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/spectrographwidget.ui +0 -0
  106. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/spectrographwidget_ui.py +0 -0
  107. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/telescopewidget.ui +0 -0
  108. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/telescopewidget_ui.py +0 -0
  109. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/temperaturesplotwidget.ui +0 -0
  110. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/temperaturesplotwidget_ui.py +0 -0
  111. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/temperatureswidget.ui +0 -0
  112. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/temperatureswidget_ui.py +0 -0
  113. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/videograbwidget.ui +0 -0
  114. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/videograbwidget_ui.py +0 -0
  115. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/videowidget.ui +0 -0
  116. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/videowidget_ui.py +0 -0
  117. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/weatherwidget.ui +0 -0
  118. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/qt/weatherwidget_ui.py +0 -0
  119. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/roboticwidget.py +0 -0
  120. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/roofwidget.py +0 -0
  121. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/schedulewidget.py +0 -0
  122. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/shellwidget.py +0 -0
  123. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/spectrographwidget.py +0 -0
  124. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/statuswidget.py +0 -0
  125. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/telescopewidget.py +0 -0
  126. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/temperaturesplotwidget.py +0 -0
  127. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/temperatureswidget.py +0 -0
  128. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/utils.py +0 -0
  129. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/videograbwidget.py +0 -0
  130. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/videowidget.py +0 -0
  131. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/watchedlabel.py +0 -0
  132. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pyobs_gui/weatherwidget.py +0 -0
  133. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/pysidedeploy.spec +0 -0
  134. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/specs/2026-08-20-gui-remote-call-error-handling.md +0 -0
  135. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/specs/2026-08-21-gui-widget-startup-responsiveness.md +0 -0
  136. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/specs/2026-08-28-gui-main-vs-sidebar-widgets.md +0 -0
  137. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/specs/2026-08-28-structuredconfig-widget.md +0 -0
  138. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/specs/2026-08-31-irobotic-widgets.md +0 -0
  139. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/specs/2026-09-01-gui-video-widget-split.md +0 -0
  140. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/specs/index.md +0 -0
  141. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/spike_standalone/main.py +0 -0
  142. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/spike_standalone/pysidedeploy.spec +0 -0
  143. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/acquisition.yaml +0 -0
  144. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/autofocus.yaml +0 -0
  145. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/camera.yaml +0 -0
  146. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/full.yaml +0 -0
  147. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/guiding.yaml +0 -0
  148. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/mode.yaml +0 -0
  149. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/robotic.yaml +0 -0
  150. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/roof.yaml +0 -0
  151. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/spectrograph.yaml +0 -0
  152. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/structuredconfig.yaml +0 -0
  153. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/telescope.yaml +0 -0
  154. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/telescope_acl.yaml +0 -0
  155. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/telescope_acl_denied.yaml +0 -0
  156. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/test/video.yaml +0 -0
  157. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/__init__.py +0 -0
  158. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/conftest.py +0 -0
  159. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/test_accounts.py +0 -0
  160. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/test_camerawidget.py +0 -0
  161. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/test_loginwindow.py +0 -0
  162. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/test_mainwindow_startup.py +0 -0
  163. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/test_modewidget.py +0 -0
  164. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/test_multiwidget_pages.py +0 -0
  165. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/test_roboticwidget.py +0 -0
  166. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/test_schedulewidget.py +0 -0
  167. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/test_videograbwidget.py +0 -0
  168. {pyobs_gui-2.3.1 → pyobs_gui-2.4.0}/tests/test_videowidget.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyobs-gui
3
- Version: 2.3.1
3
+ Version: 2.4.0
4
4
  Summary: A remote GUI for pyobs
5
5
  Author-email: Tim-Oliver Husser <thusser@uni-goettingen.de>
6
6
  License-Expression: MIT
@@ -29,15 +29,48 @@ class _FieldEditor:
29
29
  set_value: Callable[[ConfigValue], None]
30
30
  children: Optional[Dict[str, "_FieldEditor"]] = None
31
31
  level: AccessLevel = AccessLevel.BASIC
32
-
33
-
34
- def _collect_by_level(editor: "_FieldEditor", level: AccessLevel) -> List[QtWidgets.QWidget]:
35
- """Recursively gather every editor widget (including nested-object children) tagged at
36
- exactly the given level, for the basic/expert toggle to enable/disable as a group."""
37
- widgets = [editor.widget] if editor.level == level else []
32
+ # the row's label, so the basic/expert toggle can hide it alongside `display_widget` --
33
+ # addRow(str, widget) auto-creates one, but we build it explicitly at each row-adding call
34
+ # site instead so we have a handle on it (QFormLayout.labelForField exists but is keyed
35
+ # per-layout-instance, awkward to thread through recursion; a direct reference is simpler)
36
+ row_label: Optional[QtWidgets.QLabel] = None
37
+ # set only when the field has a description: a small container with `widget` on top and the
38
+ # description label below it. `widget` itself (get_value/set_value's target) never changes
39
+ # shape, so a description doesn't affect how values round-trip -- only what goes in the row.
40
+ row_widget: Optional[QtWidgets.QWidget] = None
41
+
42
+ @property
43
+ def display_widget(self) -> QtWidgets.QWidget:
44
+ return self.row_widget if self.row_widget is not None else self.widget
45
+
46
+
47
+ def _collect_by_level(editor: "_FieldEditor", level: AccessLevel) -> List["_FieldEditor"]:
48
+ """Recursively gather every editor (including nested-object children) tagged at exactly the
49
+ given level, for the basic/expert toggle to show/hide as a group."""
50
+ editors = [editor] if editor.level == level else []
38
51
  for child in (editor.children or {}).values():
39
- widgets.extend(_collect_by_level(child, level))
40
- return widgets
52
+ editors.extend(_collect_by_level(child, level))
53
+ return editors
54
+
55
+
56
+ def _wrap_with_description(widget: QtWidgets.QWidget, description: Optional[str]) -> Optional[QtWidgets.QWidget]:
57
+ """Stack `widget` over a dimmed, word-wrapped description label, for the field's row to show
58
+ the description below the input rather than only as e.g. a tooltip. Returns None (meaning:
59
+ just use `widget` directly, no wrapping) when there's no description."""
60
+ if not description:
61
+ return None
62
+ container = QtWidgets.QWidget()
63
+ layout = QtWidgets.QVBoxLayout(container)
64
+ layout.setContentsMargins(0, 0, 0, 0)
65
+ layout.setSpacing(2)
66
+ layout.addWidget(widget)
67
+ description_label = QtWidgets.QLabel(description)
68
+ description_label.setWordWrap(True)
69
+ # palette(mid) is a 3D-effect shade (borders/shadows), too dark to read comfortably as text;
70
+ # placeholder-text is Qt's actual semantic role for secondary-but-legible text
71
+ description_label.setStyleSheet("color: palette(placeholder-text);")
72
+ layout.addWidget(description_label)
73
+ return container
41
74
 
42
75
 
43
76
  def _nested_get(children: Dict[str, _FieldEditor]) -> Dict[str, ConfigValue]:
@@ -60,9 +93,13 @@ def _build_editor(name: str, schema: ConfigFieldSchema, on_change: Callable[[],
60
93
  if schema.default is not None:
61
94
  line_edit.setText(str(schema.default))
62
95
  line_edit.textChanged.connect(lambda _: on_change())
63
- if schema.level == AccessLevel.EXPERT:
64
- line_edit.setEnabled(False)
65
- return _FieldEditor(line_edit, line_edit.text, lambda value: line_edit.setText(str(value)), level=schema.level)
96
+ return _FieldEditor(
97
+ line_edit,
98
+ line_edit.text,
99
+ lambda value: line_edit.setText(str(value)),
100
+ level=schema.level,
101
+ row_widget=_wrap_with_description(line_edit, schema.description),
102
+ )
66
103
 
67
104
  if schema.type == "int":
68
105
  spin_box = QtWidgets.QSpinBox()
@@ -70,10 +107,12 @@ def _build_editor(name: str, schema: ConfigFieldSchema, on_change: Callable[[],
70
107
  if schema.default is not None:
71
108
  spin_box.setValue(int(schema.default))
72
109
  spin_box.valueChanged.connect(lambda _: on_change())
73
- if schema.level == AccessLevel.EXPERT:
74
- spin_box.setEnabled(False)
75
110
  return _FieldEditor(
76
- spin_box, spin_box.value, lambda value: spin_box.setValue(int(cast("int", value))), level=schema.level
111
+ spin_box,
112
+ spin_box.value,
113
+ lambda value: spin_box.setValue(int(cast("int", value))),
114
+ level=schema.level,
115
+ row_widget=_wrap_with_description(spin_box, schema.description),
77
116
  )
78
117
 
79
118
  if schema.type == "float":
@@ -85,13 +124,12 @@ def _build_editor(name: str, schema: ConfigFieldSchema, on_change: Callable[[],
85
124
  if schema.default is not None:
86
125
  double_spin_box.setValue(float(schema.default))
87
126
  double_spin_box.valueChanged.connect(lambda _: on_change())
88
- if schema.level == AccessLevel.EXPERT:
89
- double_spin_box.setEnabled(False)
90
127
  return _FieldEditor(
91
128
  double_spin_box,
92
129
  double_spin_box.value,
93
130
  lambda value: double_spin_box.setValue(float(cast("float", value))),
94
131
  level=schema.level,
132
+ row_widget=_wrap_with_description(double_spin_box, schema.description),
95
133
  )
96
134
 
97
135
  if schema.type == "bool":
@@ -99,10 +137,12 @@ def _build_editor(name: str, schema: ConfigFieldSchema, on_change: Callable[[],
99
137
  if schema.default is not None:
100
138
  check_box.setChecked(bool(schema.default))
101
139
  check_box.toggled.connect(lambda _: on_change())
102
- if schema.level == AccessLevel.EXPERT:
103
- check_box.setEnabled(False)
104
140
  return _FieldEditor(
105
- check_box, check_box.isChecked, lambda value: check_box.setChecked(bool(value)), level=schema.level
141
+ check_box,
142
+ check_box.isChecked,
143
+ lambda value: check_box.setChecked(bool(value)),
144
+ level=schema.level,
145
+ row_widget=_wrap_with_description(check_box, schema.description),
106
146
  )
107
147
 
108
148
  if schema.type == "enum":
@@ -112,13 +152,12 @@ def _build_editor(name: str, schema: ConfigFieldSchema, on_change: Callable[[],
112
152
  if schema.default is not None and str(schema.default) in options:
113
153
  combo_box.setCurrentText(str(schema.default))
114
154
  combo_box.currentTextChanged.connect(lambda _: on_change())
115
- if schema.level == AccessLevel.EXPERT:
116
- combo_box.setEnabled(False)
117
155
  return _FieldEditor(
118
156
  combo_box,
119
157
  combo_box.currentText,
120
158
  lambda value: combo_box.setCurrentText(str(value)),
121
159
  level=schema.level,
160
+ row_widget=_wrap_with_description(combo_box, schema.description),
122
161
  )
123
162
 
124
163
  if schema.type == "object" and schema.nested is not None:
@@ -130,16 +169,17 @@ def _build_editor(name: str, schema: ConfigFieldSchema, on_change: Callable[[],
130
169
  if child_schema.level == AccessLevel.HIDDEN:
131
170
  continue
132
171
  child_editor = _build_editor(child_name, child_schema, on_change)
133
- form_layout.addRow(child_name, child_editor.widget)
172
+ child_label = QtWidgets.QLabel(child_name)
173
+ form_layout.addRow(child_label, child_editor.display_widget)
174
+ child_editor.row_label = child_label
134
175
  children[child_name] = child_editor
135
- if schema.level == AccessLevel.EXPERT:
136
- group_box.setEnabled(False)
137
176
  return _FieldEditor(
138
177
  group_box,
139
178
  lambda: _nested_get(children),
140
179
  lambda value: _nested_set(children, value),
141
180
  children=children,
142
181
  level=schema.level,
182
+ row_widget=_wrap_with_description(group_box, schema.description),
143
183
  )
144
184
 
145
185
  # object without a nested schema (pydantic freeform dict), or any other schema type this
@@ -158,9 +198,11 @@ def _build_editor(name: str, schema: ConfigFieldSchema, on_change: Callable[[],
158
198
  placeholder.setReadOnly(True)
159
199
  placeholder.setEnabled(False)
160
200
  placeholder.setToolTip(f"Field type {schema.type!r} has no schema-driven editor for this widget.")
161
- # always BASIC regardless of schema.level: this widget is permanently disabled (nothing to
162
- # edit), so it must never be swept into the expert-toggle's re-enable group
163
- return _FieldEditor(placeholder, _get_raw, _set_raw)
201
+ # always BASIC regardless of schema.level: this is a loud "unsupported type" flag, meant to
202
+ # stay visible so a developer notices it, never swept into the expert-toggle's hide group
203
+ return _FieldEditor(
204
+ placeholder, _get_raw, _set_raw, row_widget=_wrap_with_description(placeholder, schema.description)
205
+ )
164
206
 
165
207
 
166
208
  class StructuredConfigWidget(BaseWidget):
@@ -182,11 +224,12 @@ class StructuredConfigWidget(BaseWidget):
182
224
  # itself register as a user edit (which would otherwise re-enable Apply/Reset for a
183
225
  # change that came from the module, not from the operator)
184
226
  self._applying_state = False
185
- # every EXPERT-level editor widget across the whole (possibly nested) form, collected
186
- # once in _build_form; toggled together by checkBoxAdvanced. No password gate, unlike
187
- # pyftscontrol's Expert Mode button -- operators here are already authenticated via the
188
- # comm layer, so it's just a basic/expert visibility toggle, not an access control.
189
- self._expert_widgets: List[QtWidgets.QWidget] = []
227
+ # every EXPERT-level editor across the whole (possibly nested) form, collected once in
228
+ # _build_form; their rows (label + widget) are shown/hidden together by checkBoxAdvanced.
229
+ # No password gate, unlike pyftscontrol's Expert Mode button -- operators here are
230
+ # already authenticated via the comm layer, so it's just a visibility toggle, not access
231
+ # control.
232
+ self._expert_editors: List[_FieldEditor] = []
190
233
 
191
234
  outer_layout = QtWidgets.QVBoxLayout()
192
235
  self.setLayout(outer_layout)
@@ -238,7 +281,9 @@ class StructuredConfigWidget(BaseWidget):
238
281
  if field_schema.level == AccessLevel.HIDDEN:
239
282
  continue
240
283
  editor = _build_editor(name, field_schema, self._on_editor_changed)
241
- self._form_layout.addRow(name, editor.widget)
284
+ label = QtWidgets.QLabel(name)
285
+ self._form_layout.addRow(label, editor.display_widget)
286
+ editor.row_label = label
242
287
  children[name] = editor
243
288
  self._root = _FieldEditor(
244
289
  self._form_container,
@@ -246,7 +291,7 @@ class StructuredConfigWidget(BaseWidget):
246
291
  lambda value: _nested_set(children, value),
247
292
  children=children,
248
293
  )
249
- self._expert_widgets = _collect_by_level(self._root, AccessLevel.EXPERT)
294
+ self._expert_editors = _collect_by_level(self._root, AccessLevel.EXPERT)
250
295
  self._on_advanced_toggled(self.checkBoxAdvanced.isChecked())
251
296
  # a state may already have arrived (and been cached) before the form existed to receive
252
297
  # it -- apply it now instead of waiting for the next update
@@ -254,8 +299,10 @@ class StructuredConfigWidget(BaseWidget):
254
299
  self.signal_update_gui.emit()
255
300
 
256
301
  def _on_advanced_toggled(self, checked: bool) -> None:
257
- for widget in self._expert_widgets:
258
- widget.setEnabled(checked)
302
+ for editor in self._expert_editors:
303
+ editor.display_widget.setVisible(checked)
304
+ if editor.row_label is not None:
305
+ editor.row_label.setVisible(checked)
259
306
 
260
307
  def _on_state(self, state: ConfigAppliedState) -> None:
261
308
  self._last_applied = dict(state.config)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyobs-gui"
3
- version = "2.3.1"
3
+ version = "2.4.0"
4
4
  description = "A remote GUI for pyobs"
5
5
  authors = [{ name = "Tim-Oliver Husser", email = "thusser@uni-goettingen.de" }]
6
6
  requires-python = ">=3.11"
@@ -153,23 +153,21 @@ def test_unsupported_field_type_is_also_a_disabled_placeholder() -> None:
153
153
 
154
154
 
155
155
  # -- _build_editor / _build_form: basic/expert level ----------------------------------------
156
+ #
157
+ # _build_editor itself only *tags* each editor with its level; it doesn't touch visibility --
158
+ # that's StructuredConfigWidget's job (_build_form/_on_advanced_toggled), tested below.
156
159
 
157
160
 
158
- def test_basic_field_starts_enabled() -> None:
159
- editor = _build_editor("count", ConfigFieldSchema(type="int", default=3, level=AccessLevel.BASIC), lambda: None)
160
- assert editor.widget.isEnabled() is True
161
- assert editor.level == AccessLevel.BASIC
162
-
163
-
164
- def test_expert_field_starts_disabled() -> None:
165
- editor = _build_editor("count", ConfigFieldSchema(type="int", default=3, level=AccessLevel.EXPERT), lambda: None)
166
- assert editor.widget.isEnabled() is False
167
- assert editor.level == AccessLevel.EXPERT
161
+ def test_editor_is_tagged_with_its_schema_level() -> None:
162
+ basic = _build_editor("count", ConfigFieldSchema(type="int", default=3, level=AccessLevel.BASIC), lambda: None)
163
+ expert = _build_editor("count", ConfigFieldSchema(type="int", default=3, level=AccessLevel.EXPERT), lambda: None)
164
+ assert basic.level == AccessLevel.BASIC
165
+ assert expert.level == AccessLevel.EXPERT
168
166
 
169
167
 
170
168
  def test_unsupported_type_placeholder_ignores_expert_level_for_toggle_grouping() -> None:
171
169
  """A permanently non-editable placeholder must never be swept into the expert-toggle's
172
- re-enable group, even if the underlying field is tagged EXPERT -- see the comment in
170
+ hide/show group, even if the underlying field is tagged EXPERT -- see the comment in
173
171
  _build_editor's fallback branch."""
174
172
  editor = _build_editor(
175
173
  "items", ConfigFieldSchema(type="list", default=[1, 2], level=AccessLevel.EXPERT), lambda: None
@@ -178,7 +176,7 @@ def test_unsupported_type_placeholder_ignores_expert_level_for_toggle_grouping()
178
176
  assert editor.widget.isEnabled() is False
179
177
 
180
178
 
181
- def test_nested_object_skips_hidden_children_and_disables_whole_group_when_expert() -> None:
179
+ def test_nested_object_skips_hidden_children() -> None:
182
180
  schema = ConfigFieldSchema(
183
181
  type="object",
184
182
  level=AccessLevel.EXPERT,
@@ -189,7 +187,7 @@ def test_nested_object_skips_hidden_children_and_disables_whole_group_when_exper
189
187
  )
190
188
  editor = _build_editor("pointing", schema, lambda: None)
191
189
  assert editor.children is not None and set(editor.children) == {"az"}
192
- assert editor.widget.isEnabled() is False
190
+ assert editor.level == AccessLevel.EXPERT
193
191
 
194
192
 
195
193
  def _sample_schema_with_levels() -> ConfigSchema:
@@ -212,19 +210,70 @@ def test_build_form_skips_hidden_fields_entirely() -> None:
212
210
  widget.close()
213
211
 
214
212
 
215
- def test_advanced_checkbox_toggles_expert_fields_enabled() -> None:
213
+ def test_expert_field_row_starts_hidden_basic_field_row_does_not() -> None:
216
214
  widget = StructuredConfigWidget()
217
215
  widget._build_form(_sample_schema_with_levels())
218
216
  assert widget._root is not None and widget._root.children is not None
219
- expert_widget = widget._root.children["gain"].widget
217
+ basic_editor = widget._root.children["name"]
218
+ expert_editor = widget._root.children["gain"]
219
+
220
+ assert basic_editor.widget.isHidden() is False
221
+ assert basic_editor.row_label is not None and basic_editor.row_label.isHidden() is False
222
+ assert expert_editor.widget.isHidden() is True
223
+ assert expert_editor.row_label is not None and expert_editor.row_label.isHidden() is True
224
+ widget.close()
220
225
 
221
- assert expert_widget.isEnabled() is False # starts locked
226
+
227
+ def test_advanced_checkbox_shows_and_hides_expert_field_row() -> None:
228
+ widget = StructuredConfigWidget()
229
+ widget._build_form(_sample_schema_with_levels())
230
+ assert widget._root is not None and widget._root.children is not None
231
+ expert_editor = widget._root.children["gain"]
222
232
 
223
233
  widget.checkBoxAdvanced.setChecked(True)
224
- assert expert_widget.isEnabled() is True
234
+ assert expert_editor.widget.isHidden() is False
235
+ assert expert_editor.row_label is not None and expert_editor.row_label.isHidden() is False
225
236
 
226
237
  widget.checkBoxAdvanced.setChecked(False)
227
- assert expert_widget.isEnabled() is False
238
+ assert expert_editor.widget.isHidden() is True
239
+ assert expert_editor.row_label is not None and expert_editor.row_label.isHidden() is True
240
+
241
+
242
+ # -- _build_editor: description rendered below the widget -----------------------------------
243
+
244
+
245
+ def test_field_without_description_is_not_wrapped() -> None:
246
+ editor = _build_editor("count", ConfigFieldSchema(type="int", default=3), lambda: None)
247
+ assert editor.row_widget is None
248
+ assert editor.display_widget is editor.widget
249
+
250
+
251
+ def test_field_with_description_wraps_widget_with_label_below() -> None:
252
+ schema = ConfigFieldSchema(type="int", default=3, description="Number of exposures to take")
253
+ editor = _build_editor("count", schema, lambda: None)
254
+
255
+ assert editor.row_widget is not None
256
+ assert editor.display_widget is editor.row_widget
257
+ container_layout = editor.row_widget.layout()
258
+ assert container_layout.count() == 2
259
+ assert container_layout.itemAt(0).widget() is editor.widget
260
+ description_label = container_layout.itemAt(1).widget()
261
+ assert isinstance(description_label, QtWidgets.QLabel)
262
+ assert description_label.text() == "Number of exposures to take"
263
+
264
+
265
+ def test_build_form_places_description_wrapper_in_the_row() -> None:
266
+ widget = StructuredConfigWidget()
267
+ schema = ConfigSchema(
268
+ fields={"count": ConfigFieldSchema(type="int", default=3, description="Number of exposures to take")}
269
+ )
270
+ widget._build_form(schema)
271
+
272
+ assert widget._root is not None and widget._root.children is not None
273
+ editor = widget._root.children["count"]
274
+ row_field_item = widget._form_layout.itemAt(0, QtWidgets.QFormLayout.ItemRole.FieldRole)
275
+ assert row_field_item.widget() is editor.row_widget
276
+ widget.close()
228
277
  widget.close()
229
278
 
230
279
 
@@ -3295,7 +3295,7 @@ wheels = [
3295
3295
 
3296
3296
  [[package]]
3297
3297
  name = "pyobs-gui"
3298
- version = "2.3.1"
3298
+ version = "2.4.0"
3299
3299
  source = { editable = "." }
3300
3300
  dependencies = [
3301
3301
  { name = "astroplan" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes