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.
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/PKG-INFO +2 -2
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/base.py +58 -3
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/camerawidget.py +42 -10
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/mainwindow.py +143 -34
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/shellwidget.py +42 -6
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/telescopewidget.py +22 -5
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/videowidget.py +71 -10
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyproject.toml +2 -2
- pyobs_gui-2.0.0.dev20/specs/2026-08-21-gui-widget-startup-responsiveness.md +397 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/specs/index.md +7 -0
- pyobs_gui-2.0.0.dev20/tests/test_mainwindow_startup.py +322 -0
- pyobs_gui-2.0.0.dev20/tests/test_videowidget.py +82 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/uv.lock +5 -5
- pyobs_gui-2.0.0.dev18/tests/test_videowidget.py +0 -38
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.claude/skills/verify/SKILL.md +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.github/dependabot.yml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.github/workflows/pypi.yml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.github/workflows/pyrefly.yml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.github/workflows/ruff.yml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.gitignore +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.pre-commit-config.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/.readthedocs.yml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/CHANGELOG.rst +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/LICENSE +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/README.md +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/bin/mypyuic5 +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/Makefile +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/make.bat +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/requirements.txt +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/source/_static/pyobs.gif +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/source/conf.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/docs/source/index.rst +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/mypy.ini +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/__init__.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/__main__.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/_nuitka_astropy_patch.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/accounts.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/acquisitionwidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/autofocuswidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/autoguidingwidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/commandinputwidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/compassmovewidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/coolingwidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/datadisplaywidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/eventswidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/filterwidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/fitsheaderswidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/focuswidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/gui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/login.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/loginwindow.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/logmodel.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/modewidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/modifiedmixin.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/modulegui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/__init__.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/acquisitionwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/acquisitionwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/autofocuswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/autofocuswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/autoguidingwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/autoguidingwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/camerawidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/camerawidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/compassmoveplugin.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/compassmovewidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/compassmovewidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/compassmovewidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/compile.sh +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/coolingwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/coolingwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/datadisplayplugin.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/datadisplaywidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/datadisplaywidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/designer.sh +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/eventswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/eventswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/filterwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/filterwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/fitsheaderswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/fitsheaderswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/focuswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/focuswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/mainwindow.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/mainwindow_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/modewidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/modewidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/arrow-alt-circle-down-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/arrow-alt-circle-left-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/arrow-alt-circle-right-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/arrow-alt-circle-up-solid.svg +0 -0
- {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
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/arrows-to-eye-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/chart-line-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/circle-question-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/cloud-sun-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/edit-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/search-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources/undo-solid.svg +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources.qrc +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/resources_rc.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/roofwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/roofwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/shellwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/shellwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/spectrographwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/spectrographwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/telescopewidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/telescopewidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/temperaturesplotwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/temperaturesplotwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/temperatureswidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/temperatureswidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/videowidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/videowidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/weatherwidget.ui +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/qt/weatherwidget_ui.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/roofwidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/spectrographwidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/statuswidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/temperaturesplotwidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/temperatureswidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/utils.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/watchedlabel.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pyobs_gui/weatherwidget.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/pysidedeploy.spec +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/specs/2026-08-20-gui-remote-call-error-handling.md +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/spike_standalone/main.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/spike_standalone/pysidedeploy.spec +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/acquisition.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/autofocus.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/camera.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/full.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/guiding.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/mode.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/roof.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/spectrograph.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/telescope.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/telescope_acl.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/telescope_acl_denied.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/test/video.yaml +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/tests/__init__.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/tests/conftest.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/tests/test_accounts.py +0 -0
- {pyobs_gui-2.0.0.dev18 → pyobs_gui-2.0.0.dev20}/tests/test_camerawidget.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
#
|
|
437
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
527
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|