pyobs-gui 2.0.0.dev18__tar.gz → 2.0.0.dev20__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 (146) hide show
  1. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/PKG-INFO +2 -2
  2. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/base.py +58 -3
  3. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/camerawidget.py +42 -10
  4. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/mainwindow.py +143 -34
  5. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/shellwidget.py +42 -6
  6. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/telescopewidget.py +22 -5
  7. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/videowidget.py +71 -10
  8. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyproject.toml +2 -2
  9. pyobs_gui-2.0.0.dev20/specs/2026-08-21-gui-widget-startup-responsiveness.md +397 -0
  10. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/specs/index.md +7 -0
  11. pyobs_gui-2.0.0.dev20/tests/test_mainwindow_startup.py +322 -0
  12. pyobs_gui-2.0.0.dev20/tests/test_videowidget.py +82 -0
  13. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/uv.lock +5 -5
  14. pyobs_gui-2.0.0.dev18/tests/test_videowidget.py +0 -38
  15. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.claude/skills/verify/SKILL.md +0 -0
  16. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.github/dependabot.yml +0 -0
  17. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.github/workflows/pypi.yml +0 -0
  18. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.github/workflows/pyrefly.yml +0 -0
  19. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.github/workflows/ruff.yml +0 -0
  20. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.gitignore +0 -0
  21. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.pre-commit-config.yaml +0 -0
  22. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.readthedocs.yml +0 -0
  23. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/CHANGELOG.rst +0 -0
  24. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/LICENSE +0 -0
  25. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/README.md +0 -0
  26. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/bin/mypyuic5 +0 -0
  27. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/Makefile +0 -0
  28. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/make.bat +0 -0
  29. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/requirements.txt +0 -0
  30. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/source/_static/pyobs.gif +0 -0
  31. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/source/conf.py +0 -0
  32. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/source/index.rst +0 -0
  33. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/mypy.ini +0 -0
  34. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/__init__.py +0 -0
  35. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/__main__.py +0 -0
  36. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/_nuitka_astropy_patch.py +0 -0
  37. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/accounts.py +0 -0
  38. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/acquisitionwidget.py +0 -0
  39. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/autofocuswidget.py +0 -0
  40. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/autoguidingwidget.py +0 -0
  41. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/commandinputwidget.py +0 -0
  42. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/compassmovewidget.py +0 -0
  43. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/coolingwidget.py +0 -0
  44. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/datadisplaywidget.py +0 -0
  45. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/eventswidget.py +0 -0
  46. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/filterwidget.py +0 -0
  47. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/fitsheaderswidget.py +0 -0
  48. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/focuswidget.py +0 -0
  49. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/gui.py +0 -0
  50. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/login.py +0 -0
  51. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/loginwindow.py +0 -0
  52. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/logmodel.py +0 -0
  53. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/modewidget.py +0 -0
  54. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/modifiedmixin.py +0 -0
  55. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/modulegui.py +0 -0
  56. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/__init__.py +0 -0
  57. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/acquisitionwidget.ui +0 -0
  58. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/acquisitionwidget_ui.py +0 -0
  59. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/autofocuswidget.ui +0 -0
  60. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/autofocuswidget_ui.py +0 -0
  61. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/autoguidingwidget.ui +0 -0
  62. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/autoguidingwidget_ui.py +0 -0
  63. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/camerawidget.ui +0 -0
  64. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/camerawidget_ui.py +0 -0
  65. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/compassmoveplugin.py +0 -0
  66. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/compassmovewidget.py +0 -0
  67. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/compassmovewidget.ui +0 -0
  68. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/compassmovewidget_ui.py +0 -0
  69. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/compile.sh +0 -0
  70. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/coolingwidget.ui +0 -0
  71. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/coolingwidget_ui.py +0 -0
  72. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/datadisplayplugin.py +0 -0
  73. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/datadisplaywidget.ui +0 -0
  74. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/datadisplaywidget_ui.py +0 -0
  75. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/designer.sh +0 -0
  76. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/eventswidget.ui +0 -0
  77. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/eventswidget_ui.py +0 -0
  78. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/filterwidget.ui +0 -0
  79. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/filterwidget_ui.py +0 -0
  80. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/fitsheaderswidget.ui +0 -0
  81. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/fitsheaderswidget_ui.py +0 -0
  82. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/focuswidget.ui +0 -0
  83. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/focuswidget_ui.py +0 -0
  84. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/mainwindow.ui +0 -0
  85. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/mainwindow_ui.py +0 -0
  86. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/modewidget.ui +0 -0
  87. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/modewidget_ui.py +0 -0
  88. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/arrow-alt-circle-down-solid.svg +0 -0
  89. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/arrow-alt-circle-left-solid.svg +0 -0
  90. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/arrow-alt-circle-right-solid.svg +0 -0
  91. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/arrow-alt-circle-up-solid.svg +0 -0
  92. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/arrow-up-right-from-square-solid.svg +0 -0
  93. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/arrows-to-eye-solid.svg +0 -0
  94. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/chart-line-solid.svg +0 -0
  95. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/circle-question-solid.svg +0 -0
  96. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/cloud-sun-solid.svg +0 -0
  97. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/edit-solid.svg +0 -0
  98. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/search-solid.svg +0 -0
  99. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/undo-solid.svg +0 -0
  100. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources.qrc +0 -0
  101. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources_rc.py +0 -0
  102. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/roofwidget.ui +0 -0
  103. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/roofwidget_ui.py +0 -0
  104. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/shellwidget.ui +0 -0
  105. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/shellwidget_ui.py +0 -0
  106. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/spectrographwidget.ui +0 -0
  107. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/spectrographwidget_ui.py +0 -0
  108. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/telescopewidget.ui +0 -0
  109. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/telescopewidget_ui.py +0 -0
  110. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/temperaturesplotwidget.ui +0 -0
  111. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/temperaturesplotwidget_ui.py +0 -0
  112. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/temperatureswidget.ui +0 -0
  113. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/temperatureswidget_ui.py +0 -0
  114. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/videowidget.ui +0 -0
  115. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/videowidget_ui.py +0 -0
  116. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/weatherwidget.ui +0 -0
  117. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/weatherwidget_ui.py +0 -0
  118. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/roofwidget.py +0 -0
  119. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/spectrographwidget.py +0 -0
  120. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/statuswidget.py +0 -0
  121. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/temperaturesplotwidget.py +0 -0
  122. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/temperatureswidget.py +0 -0
  123. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/utils.py +0 -0
  124. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/watchedlabel.py +0 -0
  125. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/weatherwidget.py +0 -0
  126. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pysidedeploy.spec +0 -0
  127. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/specs/2026-08-20-gui-remote-call-error-handling.md +0 -0
  128. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/spike_standalone/main.py +0 -0
  129. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/spike_standalone/pysidedeploy.spec +0 -0
  130. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/acquisition.yaml +0 -0
  131. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/autofocus.yaml +0 -0
  132. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/camera.yaml +0 -0
  133. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/full.yaml +0 -0
  134. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/guiding.yaml +0 -0
  135. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/mode.yaml +0 -0
  136. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/roof.yaml +0 -0
  137. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/spectrograph.yaml +0 -0
  138. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/telescope.yaml +0 -0
  139. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/telescope_acl.yaml +0 -0
  140. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/telescope_acl_denied.yaml +0 -0
  141. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/video.yaml +0 -0
  142. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/tests/__init__.py +0 -0
  143. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/tests/conftest.py +0 -0
  144. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/tests/test_accounts.py +0 -0
  145. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/tests/test_camerawidget.py +0 -0
  146. {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/tests/test_loginwindow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyobs-gui
3
- Version: 2.0.0.dev18
3
+ Version: 2.0.0.dev20
4
4
  Summary: A remote GUI for pyobs
5
5
  Author-email: Tim-Oliver Husser <thusser@uni-goettingen.de>
6
6
  License-Expression: MIT
@@ -11,7 +11,7 @@ Requires-Dist: astropy<8,>=7.0.1
11
11
  Requires-Dist: colour<0.2,>=0.1.5
12
12
  Requires-Dist: keyring<26,>=25.7.0
13
13
  Requires-Dist: matplotlib<4,>=3.10.1
14
- Requires-Dist: pyobs-core<3,>=2.0.0.dev78
14
+ Requires-Dist: pyobs-core<3,>=2.0.0.dev93
15
15
  Requires-Dist: pyside6>=6.10.1
16
16
  Requires-Dist: qasync<0.29,>=0.27.1
17
17
  Requires-Dist: qfitswidget>=0.13.1
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  import logging
5
6
  from typing import TYPE_CHECKING, Any, Callable, Type, TypeVar, overload
6
7
 
@@ -44,6 +45,13 @@ async def show_remote_error(parent: QtWidgets.QWidget, exception: Exception) ->
44
45
  await QAsyncMessageBox.warning(parent, "Error", str(exception))
45
46
 
46
47
 
48
+ async def cancel_and_drain(task: asyncio.Task[Any]) -> None:
49
+ """Cancel a task and await its unwind, swallowing the resulting CancelledError."""
50
+ task.cancel()
51
+ with contextlib.suppress(asyncio.CancelledError):
52
+ await task
53
+
54
+
47
55
  class BaseWindow:
48
56
  def __init__(self) -> None:
49
57
  """Base class for MainWindow and all widgets."""
@@ -169,6 +177,14 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
169
177
  # has it been initialized?
170
178
  self._initialized = False
171
179
 
180
+ # memoized one-shot init task -- two rapid show/hide/show cycles must not run _init()
181
+ # twice concurrently (see _showEvent)
182
+ self._init_task: asyncio.Task[Any] | None = None
183
+
184
+ # keys of _init_once() sub-steps that already completed, so a retried _init() (see
185
+ # _showEvent) skips steps that already ran instead of re-subscribing to comm state
186
+ self._init_steps_done: set[str] = set()
187
+
172
188
  # methods this GUI is permitted to invoke on self.module; None until fetched or if the
173
189
  # fetch failed, meaning "treat everything as permitted"
174
190
  self._permitted_methods: set[str] | None = None
@@ -251,6 +267,15 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
251
267
  in Comm._event_handlers forever, keeping this widget alive and still reacting to
252
268
  events for as long as the app runs.
253
269
  """
270
+ # cancel a still-in-flight one-time _init() first: comm's own disconnect-triggered
271
+ # subscription cleanup (Comm._client_disconnected) runs before this method is called,
272
+ # so a subscribe_state() call inside _init_task that resolves afterwards would leak a
273
+ # subscription bound to this now-discarded widget -- the same stale-callback crash
274
+ # class this method otherwise guards against
275
+ if self._init_task is not None:
276
+ await cancel_and_drain(self._init_task)
277
+ self._init_task = None
278
+
254
279
  for event_class, handler in self._registered_event_handlers:
255
280
  await self.comm.unregister_event(event_class, handler)
256
281
  self._registered_event_handlers.clear()
@@ -263,14 +288,44 @@ class BaseWidget(BaseWindow, QtWidgets.QWidget): # type: ignore
263
288
  asyncio.create_task(self._showEvent(event))
264
289
 
265
290
  async def _showEvent(self, event: QtGui.QShowEvent) -> None:
266
- if self._initialized is False and hasattr(self, "_init"):
267
- await self._init()
268
- self._initialized = True
291
+ if self._initialized is False:
292
+ if self._init_task is None:
293
+ self._init_task = asyncio.create_task(self._run_init())
294
+ try:
295
+ await self._init_task
296
+ except Exception:
297
+ # transient init failure (e.g. a comm RPC hiccup): log, and allow a retry on
298
+ # the next show instead of leaving the widget permanently marked initialized
299
+ log.exception("Init of %s failed; will retry on next show.", type(self).__name__)
300
+ self._init_task = None
269
301
 
270
302
  if self._update_func:
271
303
  # start update task
272
304
  self._update_task = asyncio.create_task(self._update_loop())
273
305
 
306
+ async def _run_init(self) -> None:
307
+ await self._init()
308
+ self._initialized = True
309
+
310
+ async def _init(self) -> None:
311
+ """Default no-op init. Widgets with one-time subscription/state setup override this;
312
+ _showEvent memoizes the call so it runs exactly once per widget lifetime."""
313
+
314
+ async def _init_once(self, key: str, coro_func: Callable[..., Coroutine[Any, Any, None]], *args: Any) -> None:
315
+ """Run one sub-step of _init() at most once across retries.
316
+
317
+ _showEvent retries the whole _init() after a partial failure (e.g. one of several
318
+ gathered comm.subscribe_state() calls raising while its siblings already subscribed).
319
+ comm.subscribe_state() unconditionally appends to comm's subscription list with no
320
+ deduplication, so a sub-step that already succeeded must not run again on retry --
321
+ override _init() and wrap each independent, subscribing sub-step in this instead of
322
+ calling it directly.
323
+ """
324
+ if key in self._init_steps_done:
325
+ return
326
+ await coro_func(*args)
327
+ self._init_steps_done.add(key)
328
+
274
329
  def hideEvent(self, event: QtGui.QHideEvent) -> None:
275
330
  # run in loop
276
331
  asyncio.create_task(self._hide_event(event))
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import logging
2
3
  from typing import Any
3
4
  from PySide6 import QtWidgets, QtCore # type: ignore
@@ -38,6 +39,13 @@ from .qt.camerawidget_ui import Ui_CameraWidget
38
39
  log = logging.getLogger(__name__)
39
40
 
40
41
 
42
+ # how long to wait for a module's first state value when initializing each control. The comm
43
+ # default is 10 s per interface, which would let a slow-publishing camera hold the page blank
44
+ # for ~70 s in the worst case; with this shorter timeout the control just keeps its default
45
+ # value and the subscription callback corrects it as soon as state arrives.
46
+ _WAIT_FOR_STATE_TIMEOUT = 2.0
47
+
48
+
41
49
  class CameraWidget(BaseWidget, Ui_CameraWidget):
42
50
  signal_update_gui = QtCore.Signal()
43
51
  signal_new_image = QtCore.Signal(NewImageEvent, str)
@@ -108,6 +116,26 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
108
116
  await self.add_to_sidebar(self.create_widget(TemperaturesWidget, module=self.module))
109
117
 
110
118
  async def _init(self) -> None:
119
+ # every interface is initialized independently (caps -> state -> subscribe, in that
120
+ # order per interface), so run them concurrently instead of serially: a slow-publishing
121
+ # camera used to hold the page blank for up to ~70 s (7 interfaces x the 10 s default
122
+ # wait_for_state timeout); now each interface waits at most _WAIT_FOR_STATE_TIMEOUT and
123
+ # the others fill in regardless
124
+ await asyncio.gather(
125
+ self._init_once("window", self._init_window),
126
+ self._init_once("binning", self._init_binning),
127
+ self._init_once("gain", self._init_gain),
128
+ self._init_once("image_format", self._init_image_format),
129
+ self._init_once("image_type", self._init_image_type),
130
+ self._init_once("exposure", self._init_exposure),
131
+ self._init_once("exposure_time", self._init_exposure_time),
132
+ self._init_once("data_sequence", self._init_data_sequence),
133
+ )
134
+
135
+ # update GUI
136
+ self.signal_update_gui.emit()
137
+
138
+ async def _init_window(self) -> None:
111
139
  # window
112
140
  window_caps = await self.comm.get_capabilities(self.module, IWindow)
113
141
  if window_caps is not None:
@@ -117,7 +145,7 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
117
145
  self.spinWindowHeight.setMaximum(int(window_caps.full_frame_height))
118
146
  async with self.comm.safe_proxy(self.module, IWindow) as proxy:
119
147
  if proxy is not None:
120
- state = await proxy.wait_for_state(IWindow)
148
+ state = await proxy.wait_for_state(IWindow, timeout=_WAIT_FOR_STATE_TIMEOUT)
121
149
  if state is not None:
122
150
  self.spinWindowLeft.setValue(state.x)
123
151
  self.spinWindowTop.setValue(state.y)
@@ -129,6 +157,7 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
129
157
  self.spinWindowWidth.setValue(window_caps.full_frame_width)
130
158
  self.spinWindowHeight.setValue(window_caps.full_frame_height)
131
159
 
160
+ async def _init_binning(self) -> None:
132
161
  # binning
133
162
  binning_caps = await self.comm.get_capabilities(self.module, IBinning)
134
163
  if binning_caps is not None:
@@ -137,20 +166,22 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
137
166
  self.comboBinning.addItems(binnings)
138
167
  async with self.comm.safe_proxy(self.module, IBinning) as proxy:
139
168
  if proxy is not None:
140
- state = await proxy.wait_for_state(IBinning)
169
+ state = await proxy.wait_for_state(IBinning, timeout=_WAIT_FOR_STATE_TIMEOUT)
141
170
  if state is not None:
142
171
  self.comboBinning.setCurrentText(f"{state.x}x{state.y}")
143
172
  await self.comm.subscribe_state(self.module, IBinning, self._update_binning)
144
173
 
174
+ async def _init_gain(self) -> None:
145
175
  # gain
146
176
  async with self.comm.safe_proxy(self.module, IGain) as proxy:
147
177
  if proxy is not None:
148
- state = await proxy.wait_for_state(IGain)
178
+ state = await proxy.wait_for_state(IGain, timeout=_WAIT_FOR_STATE_TIMEOUT)
149
179
  if state is not None:
150
180
  self.spinGain.setValue(state.gain)
151
181
  self.spinGainOffset.setValue(state.offset)
152
182
  await self.comm.subscribe_state(self.module, IGain, self._update_gain)
153
183
 
184
+ async def _init_image_format(self) -> None:
154
185
  # image format
155
186
  image_format_caps = await self.comm.get_capabilities(self.module, IImageFormat)
156
187
  if image_format_caps is not None:
@@ -159,44 +190,45 @@ class CameraWidget(BaseWidget, Ui_CameraWidget):
159
190
  self.comboImageFormat.addItems([f.name for f in image_formats])
160
191
  async with self.comm.safe_proxy(self.module, IImageFormat) as proxy:
161
192
  if proxy is not None:
162
- state = await proxy.wait_for_state(IImageFormat)
193
+ state = await proxy.wait_for_state(IImageFormat, timeout=_WAIT_FOR_STATE_TIMEOUT)
163
194
  if state is not None:
164
195
  self.comboImageFormat.setCurrentText(state.image_format.name)
165
196
  await self.comm.subscribe_state(self.module, IImageFormat, self._update_image_format)
166
197
 
198
+ async def _init_image_type(self) -> None:
167
199
  # image type
168
200
  async with self.comm.safe_proxy(self.module, IImageType) as proxy:
169
201
  if proxy is not None:
170
- state = await proxy.wait_for_state(IImageType)
202
+ state = await proxy.wait_for_state(IImageType, timeout=_WAIT_FOR_STATE_TIMEOUT)
171
203
  if state is not None:
172
204
  self.comboImageType.setCurrentText(state.image_type.name)
173
205
  await self.comm.subscribe_state(self.module, IImageType, self._update_image_type)
174
206
 
207
+ async def _init_exposure(self) -> None:
175
208
  # exposure (status, progress, time left)
176
209
  async with self.comm.safe_proxy(self.module, IExposure) as proxy:
177
210
  if proxy is not None:
178
- state = await proxy.wait_for_state(IExposure)
211
+ state = await proxy.wait_for_state(IExposure, timeout=_WAIT_FOR_STATE_TIMEOUT)
179
212
  if state is not None:
180
213
  self.exposure_status = state.status
181
214
  self.exposure_progress = state.progress
182
215
  self.exposure_time_left = state.exposure_time_left
183
216
  await self.comm.subscribe_state(self.module, IExposure, self._update_exposure)
184
217
 
218
+ async def _init_exposure_time(self) -> None:
185
219
  # exposure time
186
220
  async with self.comm.safe_proxy(self.module, IExposureTime) as proxy:
187
221
  if proxy is not None:
188
- state = await proxy.wait_for_state(IExposureTime)
222
+ state = await proxy.wait_for_state(IExposureTime, timeout=_WAIT_FOR_STATE_TIMEOUT)
189
223
  if state is not None:
190
224
  self.spinExpTime.setValue(state.exposure_time)
191
225
  await self.comm.subscribe_state(self.module, IExposureTime, self._update_exposure_time)
192
226
 
227
+ async def _init_data_sequence(self) -> None:
193
228
  # data sequence
194
229
  if await self.comm.has_proxy(self.module, IDataSequence):
195
230
  await self.comm.subscribe_state(self.module, IDataSequence, self._update_sequence)
196
231
 
197
- # update GUI
198
- self.signal_update_gui.emit()
199
-
200
232
  def _update_binning(self, state: BinningState):
201
233
  self.comboBinning.setCurrentText(f"{state.x}x{state.y}")
202
234
 
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import logging
2
3
  import os
3
4
  from typing import Optional, List, Any, Dict, Callable
4
5
  from PySide6 import QtWidgets, QtCore, QtGui # type: ignore
@@ -29,7 +30,7 @@ from pyobs.interfaces import (
29
30
  IModule,
30
31
  )
31
32
 
32
- from .base import BaseWindow, BaseWidget
33
+ from .base import BaseWindow, BaseWidget, cancel_and_drain
33
34
  from .acquisitionwidget import AcquisitionWidget
34
35
  from .autofocuswidget import AutoFocusWidget
35
36
  from .autoguidingwidget import AutoGuidingWidget
@@ -48,6 +49,8 @@ from .roofwidget import RoofWidget
48
49
  from .shellwidget import ShellWidget
49
50
  from .spectrographwidget import SpectrographWidget
50
51
 
52
+ log = logging.getLogger(__name__)
53
+
51
54
  DEFAULT_WIDGETS = {
52
55
  ICamera: CameraWidget,
53
56
  ITelescope: TelescopeWidget,
@@ -281,6 +284,11 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
281
284
 
282
285
  # list of widgets
283
286
  self._widgets: Dict[str, BaseWidget] = {}
287
+ # client -> current page in the stacked widget (a "Loading…" placeholder until the
288
+ # widget's open() finished, then the widget itself) -- see _add_client/_open_client
289
+ self._pages: Dict[str, QtWidgets.QWidget] = {}
290
+ # client -> in-flight background open task (see _add_client)
291
+ self._pending_opens: Dict[str, asyncio.Task[None]] = {}
284
292
  self._current_widget = None
285
293
  self.shell: Optional[ShellWidget] = None
286
294
  self.events: Optional[EventsWidget] = None
@@ -336,7 +344,7 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
336
344
  self.listPages.currentRowChanged.connect(self._change_page)
337
345
 
338
346
  # get clients
339
- await self._update_client_list()
347
+ self._update_clients_menu()
340
348
  await self._check_warnings()
341
349
 
342
350
  # subscribe to events
@@ -351,9 +359,13 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
351
359
  self.warning_task = asyncio.create_task(self._check_warning_task())
352
360
 
353
361
  async def _init_clients(self) -> None:
354
- # create other nav buttons and views
355
- for client_name in self.comm.clients:
356
- await self._client_connected(Event(), client_name)
362
+ # create other nav buttons and views -- in parallel, so a slow module (e.g. the
363
+ # telescope) no longer blocks every other module from appearing
364
+ await asyncio.gather(*(self._client_connected(Event(), c) for c in self.comm.clients))
365
+
366
+ # one fresh warning pass now that all modules are in (the periodic task keeps it
367
+ # current afterwards)
368
+ await self._check_warnings()
357
369
 
358
370
  def closeEvent(self, a0: QtGui.QCloseEvent) -> None:
359
371
  if self.warning_task is not None:
@@ -377,6 +389,15 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
377
389
  *before* this window is closed/deleted as part of a reconnect (GUI._logout()), otherwise
378
390
  a stray in-flight event/state callback can fire after Qt has already destroyed the
379
391
  widget it targets (e.g. "libshiboken: Internal C++ object already deleted")."""
392
+ # cancel and drain any in-flight background opens first, so no _open_client task is
393
+ # still mutating the stackedWidget / a widget after teardown begins -- cancel all
394
+ # before draining any, so they unwind concurrently instead of one after another
395
+ for task in self._pending_opens.values():
396
+ task.cancel()
397
+ for task in list(self._pending_opens.values()):
398
+ await cancel_and_drain(task)
399
+ self._pending_opens.clear()
400
+
380
401
  for widget in list(self._widgets.values()):
381
402
  await widget.discard()
382
403
 
@@ -433,15 +454,91 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
433
454
  self.listPages.addItem(item)
434
455
  self.listPages.sortItems()
435
456
 
436
- # open and add widget
437
- await widget.open(
438
- modules=[client] if client is not None else [], comm=self.comm, observer=self.observer, vfs=self.vfs
439
- )
440
- self.stackedWidget.addWidget(widget)
441
-
442
- # store
457
+ # register immediately, so _change_page and the shortcuts always find the client --
458
+ # even while the widget's open() is still running below
443
459
  self._widgets[client] = widget
444
460
 
461
+ # placeholder page: clickable now, explains the delay, needs no per-widget changes
462
+ placeholder = self._make_loading_page(client, icon)
463
+ self.stackedWidget.addWidget(placeholder)
464
+ self._pages[client] = placeholder
465
+
466
+ # open in the background; swap in the real widget when done
467
+ self._pending_opens[client] = asyncio.create_task(self._open_client(client, widget))
468
+
469
+ async def _open_client(self, client: str, widget: BaseWidget) -> None:
470
+ try:
471
+ await widget.open(
472
+ modules=[client] if client is not None else [], comm=self.comm, observer=self.observer, vfs=self.vfs
473
+ )
474
+ except Exception:
475
+ # open() failed (RPC errors etc.): tear the client down rather than leaving a
476
+ # permanent "Loading…" dead page behind. Note asyncio.CancelledError is NOT
477
+ # caught here (it subclasses BaseException), so a mid-open disconnect -- which
478
+ # cancels this task -- still unwinds through the finally below and never reaches
479
+ # this branch.
480
+ log.exception("Failed to open widget for %s", client)
481
+ await self._fail_open(client, widget)
482
+ return
483
+ finally:
484
+ self._pending_opens.pop(client, None)
485
+
486
+ # swap placeholder -> real widget at the same stackedWidget index
487
+ placeholder = self._pages.get(client)
488
+ if placeholder is None:
489
+ return # client disconnected while opening
490
+
491
+ # capture BEFORE removeWidget(placeholder): once the placeholder is removed from the
492
+ # stack it can never again be currentWidget(), so the check has to happen first or the
493
+ # "user is sitting on this page" branch below can never fire
494
+ was_current = self.stackedWidget.currentWidget() is placeholder
495
+
496
+ idx = self.stackedWidget.indexOf(placeholder)
497
+ self.stackedWidget.removeWidget(placeholder)
498
+ placeholder.deleteLater()
499
+ self.stackedWidget.insertWidget(idx, widget)
500
+ self._pages[client] = widget
501
+
502
+ # if the user is sitting on this page, show the real widget now -- showEvent ->
503
+ # _init() runs and the content fills in
504
+ if was_current:
505
+ self.stackedWidget.setCurrentWidget(widget)
506
+
507
+ async def _fail_open(self, client: str, widget: BaseWidget) -> None:
508
+ """Undo a failed _add_client: nav item, placeholder page, and registry entries.
509
+ No-op if the client already disconnected mid-open (its own teardown handled it)."""
510
+ if self._pages.get(client) is None:
511
+ return
512
+ self._widgets.pop(client, None)
513
+ for row in range(self.listPages.count()):
514
+ if self.listPages.item(row).text() == client:
515
+ self.listPages.takeItem(row)
516
+ break
517
+ placeholder = self._pages.pop(client, None)
518
+ if placeholder is not None:
519
+ if self.stackedWidget.currentWidget() is placeholder:
520
+ self._current_widget = None
521
+ self.stackedWidget.removeWidget(placeholder)
522
+ placeholder.deleteLater()
523
+ await widget.discard()
524
+
525
+ def _make_loading_page(self, client: str, icon: QtGui.QIcon) -> QtWidgets.QWidget:
526
+ """Returns a plain "Loading <client>…" page (module icon + grey label) that stands in
527
+ for the real widget while its open() is still running. No new dependency."""
528
+ page = QtWidgets.QWidget()
529
+ layout = QtWidgets.QVBoxLayout(page)
530
+ layout.addStretch()
531
+ icon_label = QtWidgets.QLabel()
532
+ icon_label.setPixmap(icon.pixmap(48, 48))
533
+ icon_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
534
+ layout.addWidget(icon_label)
535
+ label = QtWidgets.QLabel(f"Loading {client}…")
536
+ label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
537
+ label.setStyleSheet("color: grey;")
538
+ layout.addWidget(label)
539
+ layout.addStretch()
540
+ return page
541
+
445
542
  @QtCore.Slot(int) # type: ignore
446
543
  def _change_page(self, idx: int) -> None:
447
544
  """Change page.
@@ -454,12 +551,13 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
454
551
  item = self.listPages.item(idx)
455
552
  client = item.text() if item is not None else None
456
553
 
457
- # section headers (and an empty selection) aren't real pages
458
- if client not in self._widgets:
554
+ # section headers (and an empty selection) aren't real pages -- they are in neither
555
+ # self._pages nor self._widgets
556
+ if client not in self._pages:
459
557
  return
460
558
 
461
- # change to new page
462
- self.stackedWidget.setCurrentWidget(self._widgets[client])
559
+ # change to new page (may be a placeholder while the widget is still opening)
560
+ self.stackedWidget.setCurrentWidget(self._pages[client])
463
561
 
464
562
  # get new widget
465
563
  self._current_widget = self.stackedWidget.currentWidget()
@@ -523,8 +621,10 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
523
621
  return
524
622
  self._select_page_by_name(name)
525
623
 
526
- async def _update_client_list(self) -> None:
527
- """Updates the select-clients menu for the log."""
624
+ def _update_clients_menu(self) -> None:
625
+ """Rebuilds the select-clients menu for the log -- cheap and purely local, so it can run
626
+ per connect/disconnect. (The Shell command model is the Shell widget's own job -- it
627
+ rebuilds debounced and only while its page is visible, see shellwidget.py.)"""
528
628
 
529
629
  # rebuild the menu -- every connected client, checked (shown) by default. A checkbox
530
630
  # inside a QWidgetAction (rather than a plain checkable QAction) both lets the entry's
@@ -544,10 +644,6 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
544
644
  action.setDefaultWidget(checkbox)
545
645
  self._clients_menu.addAction(action)
546
646
 
547
- # update shell
548
- if self.shell is not None:
549
- await self.shell.update_client_list()
550
-
551
647
  def _clear_log(self) -> None:
552
648
  self.log_model.clear()
553
649
 
@@ -648,8 +744,8 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
648
744
  if client in self._widgets:
649
745
  return False
650
746
 
651
- # update client list
652
- await self._update_client_list()
747
+ # update client list (cheap menu rebuild; the shell model is the shell's own job)
748
+ self._update_clients_menu()
653
749
 
654
750
  # what do we have?
655
751
  async with self.comm.proxy(client) as proxy:
@@ -684,9 +780,6 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
684
780
  self._add_section_header("Modules")
685
781
  self._modules_header_added = True
686
782
  await self._add_client(client, icon, widget)
687
-
688
- # check mastermind
689
- await self._check_warnings()
690
783
  return True
691
784
 
692
785
  async def _client_disconnected(self, event: Event, client: str) -> bool:
@@ -696,20 +789,39 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
696
789
  client: Name of client.
697
790
  """
698
791
 
699
- # update client list
700
- await self._update_client_list()
792
+ # update client list (cheap menu rebuild; the shell model is the shell's own job)
793
+ self._update_clients_menu()
701
794
 
702
795
  # not in list?
703
796
  if client not in self._widgets:
704
797
  return False
705
798
 
799
+ # cancel a pending open BEFORE discard(), and await the cancellation: cancel() is not
800
+ # synchronous (the coroutine only unwinds at its next await inside widget.open()), so
801
+ # without this await, widget.discard() below could run concurrently with the tail of a
802
+ # not-yet-finished open() -- e.g. sidebar widgets added after discard, or handlers
803
+ # re-registered after unregister. This race is newly exposed by registering the widget
804
+ # early in _add_client: before, the widget wasn't in self._widgets mid-open, so this
805
+ # method returned early and discard() could never run while open() was in flight.
806
+ task = self._pending_opens.pop(client, None)
807
+ if task is not None:
808
+ await cancel_and_drain(task)
809
+
706
810
  # get widget
707
811
  widget = self._widgets[client]
708
812
 
709
- # is current?
710
- if self.stackedWidget.currentWidget() == widget:
813
+ # is current? (the placeholder may be the current page mid-open)
814
+ if self.stackedWidget.currentWidget() in (widget, self._pages.get(client)):
711
815
  self._current_widget = None
712
816
 
817
+ # remove placeholder page, if any -- a dict pop alone would leave a ghost QWidget in
818
+ # the stack, and if it was the current page it would stay visible after the module
819
+ # vanished
820
+ placeholder = self._pages.pop(client, None)
821
+ if placeholder is not None:
822
+ self.stackedWidget.removeWidget(placeholder)
823
+ placeholder.deleteLater()
824
+
713
825
  # remove widget
714
826
  self.stackedWidget.removeWidget(widget)
715
827
 
@@ -726,9 +838,6 @@ class MainWindow(QtWidgets.QMainWindow, BaseWindow, Ui_MainWindow): # type: ign
726
838
 
727
839
  # remove from dict
728
840
  del self._widgets[client]
729
-
730
- # check mastermind
731
- await self._check_warnings()
732
841
  return True
733
842
 
734
843
  def get_fits_headers(self, namespaces: Optional[List[str]] = None, **kwargs: Any) -> dict[str, FitsHeaderEntry]:
@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  import re
3
3
  from typing import Any
4
- from PySide6 import QtWidgets, QtCore # type: ignore
4
+ from PySide6 import QtWidgets, QtCore, QtGui # type: ignore
5
5
  import inspect
6
6
  from enum import Enum
7
7
  import logging
@@ -14,7 +14,6 @@ from pyobs.vfs import VirtualFileSystem
14
14
  from .base import BaseWidget
15
15
  from .qt.shellwidget_ui import Ui_ShellWidget
16
16
 
17
-
18
17
  log = logging.getLogger(__name__)
19
18
 
20
19
 
@@ -113,6 +112,13 @@ class ShellWidget(BaseWidget, Ui_ShellWidget):
113
112
  # commands
114
113
  self.command_model: CommandModel | None = None
115
114
  self.completer: QtWidgets.QCompleter | None = None
115
+ # True when the command model may not reflect comm.clients (module events arrived while
116
+ # the Shell page was hidden); the next showEvent triggers a rebuild
117
+ self._model_stale = False
118
+ # debounced rebuild task -- one per burst of module events (see _module_changed)
119
+ self._model_debounce_task: asyncio.Task[Any] | None = None
120
+ # serializes CommandModel.init() runs, so two rebuilds can never interleave
121
+ self._model_lock = asyncio.Lock()
116
122
  self.command_regexp = re.compile(r"(\w+)\.(\w+[_\w+]*)\(([^\)]*)\)")
117
123
  self.args_regexp = re.compile(r'(?:[^\s,"]|"(?:\\.|[^"])*")+')
118
124
 
@@ -158,12 +164,18 @@ class ShellWidget(BaseWidget, Ui_ShellWidget):
158
164
  table_view.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers)
159
165
 
160
166
  # await self.command_model.init()
161
- await self.update_client_list()
167
+ await self.update_client_list(force=True)
162
168
 
163
169
  if self.comm is not None:
164
170
  await self.comm.register_event(ModuleOpenedEvent, self._module_changed)
165
171
  await self.comm.register_event(ModuleClosedEvent, self._module_changed)
166
172
 
173
+ def showEvent(self, event: QtGui.QShowEvent) -> None:
174
+ super().showEvent(event)
175
+ # rebuild the command model lazily if module changes arrived while this page was hidden
176
+ if self._model_stale:
177
+ asyncio.create_task(self.update_client_list())
178
+
167
179
  def _add_command_log(self, msg: str, color: str | None = None) -> None:
168
180
  if color is not None:
169
181
  msg = '<span style="color:%s;">%s</span>' % (color, msg)
@@ -201,11 +213,35 @@ class ShellWidget(BaseWidget, Ui_ShellWidget):
201
213
  doc = ""
202
214
 
203
215
  async def _module_changed(self, event: Event, sender: str) -> bool:
204
- asyncio.create_task(self.update_client_list())
216
+ # debounce: restart the short delay on every module event, so a burst of
217
+ # connects/disconnects triggers exactly one rebuild
218
+ if self._model_debounce_task is not None:
219
+ self._model_debounce_task.cancel()
220
+ self._model_debounce_task = asyncio.create_task(self._debounced_rebuild())
205
221
  return True
206
222
 
207
- async def update_client_list(self) -> None:
223
+ async def _debounced_rebuild(self) -> None:
224
+ try:
225
+ await asyncio.sleep(0.5)
226
+ except asyncio.CancelledError:
227
+ return
228
+ await self.update_client_list()
229
+
230
+ async def update_client_list(self, force: bool = False) -> None:
231
+ """Rebuilds the command model from the current comm.clients.
232
+
233
+ Skipped (model marked stale) when the Shell page isn't visible, unless force=True --
234
+ open() forces the initial build, and the page's own showEvent triggers the rebuild
235
+ later. The lock serializes concurrent rebuilds so two CommandModel.init() runs can
236
+ never interleave.
237
+ """
208
238
  # create model for commands
209
- if self.command_model is not None and self.completer is not None:
239
+ if self.command_model is None or self.completer is None:
240
+ return
241
+ if not force and not self.isVisible():
242
+ self._model_stale = True
243
+ return
244
+ async with self._model_lock:
210
245
  await self.command_model.init()
211
246
  self.completer.setModel(self.command_model)
247
+ self._model_stale = False