diff --git a/docs/source/upcoming_release_notes/95-gui_entry_pages.rst b/docs/source/upcoming_release_notes/95-gui_entry_pages.rst new file mode 100644 index 0000000..f04091c --- /dev/null +++ b/docs/source/upcoming_release_notes/95-gui_entry_pages.rst @@ -0,0 +1,24 @@ +95 gui_entry_pages +################## + +API Breaks +---------- +- N/A + +Features +-------- +- Adds BaseParameterPage, which dynamically shows/hides edit widgets based on which fields are present. This page widget is designed for Single PV entries. +- Adds BusyCursorThread, for showing a busy cursor during potentially blocking work. +- Adds first pass at Nestible (Collection, Snapshot) view pages. + +Bugfixes +-------- +- N/A + +Maintenance +----------- +- Modifies the signature of open_page_slot to pass the client through + +Contributors +------------ +- tangkong diff --git a/superscore/tests/test_page.py b/superscore/tests/test_page.py index 0ef63ac..888af52 100644 --- a/superscore/tests/test_page.py +++ b/superscore/tests/test_page.py @@ -1,20 +1,66 @@ """Largely smoke tests for various pages""" +from unittest.mock import MagicMock + import pytest from pytestqt.qtbot import QtBot -from qtpy import QtCore +from qtpy import QtCore, QtWidgets from superscore.client import Client -from superscore.model import Collection, Parameter +from superscore.control_layers._base_shim import EpicsData +from superscore.model import (Collection, Parameter, Readback, Setpoint, + Snapshot) from superscore.widgets.page.collection_builder import CollectionBuilderPage -from superscore.widgets.page.entry import CollectionPage +from superscore.widgets.page.entry import (BaseParameterPage, CollectionPage, + ParameterPage, ReadbackPage, + SetpointPage, SnapshotPage) from superscore.widgets.page.search import SearchPage @pytest.fixture(scope='function') -def collection_page(qtbot: QtBot): +def collection_page(qtbot: QtBot, sample_client: Client): data = Collection() - page = CollectionPage(data=data) + page = CollectionPage(data=data, client=sample_client) + qtbot.addWidget(page) + yield page + + view = page.sub_pv_table_view + view._model.stop_polling() + qtbot.wait_until(lambda: not view._model._poll_thread.isRunning()) + + +@pytest.fixture(scope="function") +def snapshot_page(qtbot: QtBot, sample_client: Client): + data = Snapshot() + page = SnapshotPage(data=data, client=sample_client) + qtbot.addWidget(page) + yield page + + view = page.sub_pv_table_view + view._model.stop_polling() + qtbot.wait_until(lambda: not view._model._poll_thread.isRunning()) + + +@pytest.fixture(scope="function") +def parameter_page(qtbot: QtBot, sample_client: Client): + data = Parameter() + page = ParameterPage(data=data, client=sample_client) + qtbot.addWidget(page) + return page + + +@pytest.fixture(scope="function") +def setpoint_page(qtbot: QtBot, sample_client: Client): + data = Setpoint() + page = SetpointPage(data=data, client=sample_client) + qtbot.addWidget(page) + return page + + +@pytest.fixture(scope="function") +def readback_page(qtbot: QtBot, sample_client: Client): + data = Readback() + page = ReadbackPage(data=data, client=sample_client) qtbot.addWidget(page) return page @@ -37,7 +83,15 @@ def collection_builder_page(qtbot: QtBot, sample_client: Client): @pytest.mark.parametrize( 'page', - ["collection_page", "search_page", "collection_builder_page"] + [ + "parameter_page", + "setpoint_page", + "readback_page", + "collection_page", + "snapshot_page", + "search_page", + "collection_builder_page", + ] ) def test_page_smoke(page: str, request: pytest.FixtureRequest): """smoke test, just create each page and see if they fail""" @@ -114,3 +168,53 @@ def test_coll_builder_edit( coll_model.setData(first_index, 'anothername', role=QtCore.Qt.EditRole) qtbot.waitUntil(lambda: "anothername" in page.data.children[1].title) + + +@pytest.mark.parametrize("page_fixture,", ["parameter_page", "setpoint_page"]) +def test_open_page_slot( + page_fixture: str, + request: pytest.FixtureRequest, +): + page: BaseParameterPage = request.getfixturevalue(page_fixture) + page.open_page_slot = MagicMock() + page.open_rbv_button.clicked.emit() + assert page.open_page_slot.called + + +@pytest.mark.parametrize( + "page_fixture,", + ["parameter_page", "setpoint_page", "readback_page"] +) +def test_stored_widget_swap( + page_fixture: str, + request: pytest.FixtureRequest, + qtbot: QtBot, +): + ret_vals = { + "MY:FLOAT": EpicsData(data=0.5, precision=3, + lower_ctrl_limit=-2, upper_ctrl_limit=2), + "MY:INT": EpicsData(data=1, lower_ctrl_limit=-10, upper_ctrl_limit=10), + "MY:ENUM": EpicsData(data=0, enums=["OUT", "IN", "UNKNOWN"]) + } + + def simple_coll_return_vals(pv_name: str): + return ret_vals[pv_name] + + page: BaseParameterPage = request.getfixturevalue(page_fixture) + page.set_editable(True) + page.client.cl.get = MagicMock(side_effect=simple_coll_return_vals) + qtbot.waitUntil(lambda: not page._edata_thread.isRunning()) + page.get_edata() + qtbot.waitUntil(lambda: not page._edata_thread.isRunning()) + + for pv, expected_widget in zip( + ret_vals, + (QtWidgets.QDoubleSpinBox, QtWidgets.QSpinBox, QtWidgets.QComboBox) + ): + page.pv_edit.setText(pv) + qtbot.waitUntil(lambda: page.data.pv_name == pv) + page.refresh_button.clicked.emit() + + qtbot.waitUntil( + lambda: isinstance(page.value_stored_widget, expected_widget), + ) diff --git a/superscore/type_hints.py b/superscore/type_hints.py index 75ff074..15d52eb 100644 --- a/superscore/type_hints.py +++ b/superscore/type_hints.py @@ -1,4 +1,8 @@ -from typing import Dict, Protocol, Union +from typing import TYPE_CHECKING, Callable, Dict, Protocol, Union + +if TYPE_CHECKING: + from superscore.model import Entry + from superscore.widgets.core import DataWidget AnyEpicsType = Union[int, str, float, bool] @@ -8,3 +12,6 @@ class AnyDataclass(Protocol): Protocol stub shamelessly lifted from stackoverflow to hint at dataclass """ __dataclass_fields__: Dict + + +OpenPageSlot = Callable[["Entry"], "DataWidget"] diff --git a/superscore/ui/collection_page.ui b/superscore/ui/collection_page.ui deleted file mode 100644 index 87b2494..0000000 --- a/superscore/ui/collection_page.ui +++ /dev/null @@ -1,49 +0,0 @@ - - - Form - - - - 0 - 0 - 400 - 300 - - - - Form - - - - - - - - - - PV name - - - - - title - - - - - description - - - - - - - - - - - - - - - diff --git a/superscore/ui/nestable_page.ui b/superscore/ui/nestable_page.ui new file mode 100644 index 0000000..bf05470 --- /dev/null +++ b/superscore/ui/nestable_page.ui @@ -0,0 +1,91 @@ + + + Form + + + + 0 + 0 + 924 + 660 + + + + Form + + + + + + + 0 + 0 + + + + + + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + 1 + 0 + + + + + + + 3 + 0 + + + + Qt::Vertical + + + + + + + + + + Save + + + + + + + + LivePVTableView + QTableView +
superscore.widgets.views
+
+ + NestableTableView + QTableView +
superscore.widgets.views
+
+
+ + +
diff --git a/superscore/ui/parameter_page.ui b/superscore/ui/parameter_page.ui new file mode 100644 index 0000000..b0d1e55 --- /dev/null +++ b/superscore/ui/parameter_page.ui @@ -0,0 +1,313 @@ + + + Form + + + + 0 + 0 + 432 + 229 + + + + Form + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 16 + + + + PV Name + + + + + + + + 11 + + + + + + + + + 16 + + + + = + + + + + + + + + + + 16 + + + + (Live) + + + + + + + + 0 + 0 + + + + ... + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + Tolerance: + + + + + + + [-,-] + + + + + + + Absolute Tolerance: + + + + + + + + + + Relative Tolerance: + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + Status: + + + + + + + + + + Severity: + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + timeout + + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Readback + + + + + + + <RBV PV Name> + + + + + + + Open Readback Page + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Save + + + + + + + + diff --git a/superscore/widgets/__init__.py b/superscore/widgets/__init__.py index 46640fd..ba4ebf0 100644 --- a/superscore/widgets/__init__.py +++ b/superscore/widgets/__init__.py @@ -1,10 +1,12 @@ def _get_icon_map(): # do not pollute namespace - from superscore.model import Collection, Readback, Setpoint, Snapshot + from superscore.model import (Collection, Parameter, Readback, Setpoint, + Snapshot) # a list of qtawesome icon names icon_map = { Collection: 'mdi.file-document-multiple', + Parameter: 'mdi.file', Snapshot: 'mdi.camera', Setpoint: 'mdi.target', Readback: 'mdi.book-open-variant', diff --git a/superscore/widgets/page/__init__.py b/superscore/widgets/page/__init__.py index 8f8a8dd..d44eaee 100644 --- a/superscore/widgets/page/__init__.py +++ b/superscore/widgets/page/__init__.py @@ -1,10 +1,17 @@ def get_page_map(): # Don't pollute the namespace - from superscore.model import Collection - from superscore.widgets.page.entry import CollectionPage + from superscore.model import (Collection, Parameter, Readback, Setpoint, + Snapshot) + from superscore.widgets.page.entry import (CollectionPage, ParameterPage, + ReadbackPage, SetpointPage, + SnapshotPage) page_map = { - Collection: CollectionPage + Collection: CollectionPage, + Snapshot: SnapshotPage, + Parameter: ParameterPage, + Setpoint: SetpointPage, + Readback: ReadbackPage, } return page_map diff --git a/superscore/widgets/page/collection_builder.py b/superscore/widgets/page/collection_builder.py index 6b9b261..4d115c1 100644 --- a/superscore/widgets/page/collection_builder.py +++ b/superscore/widgets/page/collection_builder.py @@ -1,11 +1,12 @@ import logging -from typing import Callable, Optional +from typing import Optional from qtpy import QtCore, QtWidgets from qtpy.QtGui import QCloseEvent from superscore.client import Client from superscore.model import Collection, Entry, Parameter +from superscore.type_hints import OpenPageSlot from superscore.widgets.core import DataWidget, Display, NameDescTagsWidget from superscore.widgets.enhanced import FilterComboBox from superscore.widgets.manip_helpers import insert_widget @@ -47,7 +48,7 @@ def __init__( *args, client: Client, data: Optional[Collection] = None, - open_page_slot: Optional[Callable] = None, + open_page_slot: Optional[OpenPageSlot] = None, **kwargs ): if data is None: diff --git a/superscore/widgets/page/entry.py b/superscore/widgets/page/entry.py index 6a6a53b..c8c5d53 100644 --- a/superscore/widgets/page/entry.py +++ b/superscore/widgets/page/entry.py @@ -1,36 +1,382 @@ """ Widgets for visualizing and editing core model dataclasses """ +import logging +from copy import deepcopy +from typing import Optional, Union +import qtawesome as qta from qtpy import QtWidgets +from qtpy.QtGui import QCloseEvent -from superscore.model import Collection +from superscore.client import Client +from superscore.control_layers._base_shim import EpicsData +from superscore.model import (Collection, Nestable, Parameter, Readback, + Setpoint, Severity, Snapshot, Status) +from superscore.type_hints import AnyEpicsType, OpenPageSlot from superscore.widgets.core import DataWidget, Display, NameDescTagsWidget -from superscore.widgets.manip_helpers import insert_widget -from superscore.widgets.views import RootTree +from superscore.widgets.manip_helpers import (insert_widget, + match_line_edit_text_width) +from superscore.widgets.thread_helpers import BusyCursorThread +from superscore.widgets.views import (LivePVTableView, NestableTableView, + RootTree, edit_widget_from_epics_data) +logger = logging.getLogger(__name__) -class CollectionPage(Display, DataWidget): - filename = 'collection_page.ui' + +class NestablePage(Display, DataWidget): + filename = 'nestable_page.ui' meta_placeholder: QtWidgets.QWidget meta_widget: NameDescTagsWidget - child_tree_view: QtWidgets.QTreeView - pv_table: QtWidgets.QTableWidget - repr_text_edit: QtWidgets.QTextEdit + tree_view: QtWidgets.QTreeView + sub_coll_table_view: NestableTableView + sub_pv_table_view: LivePVTableView - data: Collection + save_button: QtWidgets.QPushButton + + data: Nestable - def __init__(self, *args, data: Collection, **kwargs): + def __init__( + self, + *args, + data: Nestable, + client: Client, + editable: bool = False, + open_page_slot: Optional[OpenPageSlot] = None, + **kwargs + ): super().__init__(*args, data=data, **kwargs) + self.client = client + self.editable = editable + self.open_page_slot = open_page_slot + self._last_data = deepcopy(self.data) self.setup_ui() def setup_ui(self): self.meta_widget = NameDescTagsWidget(data=self.data) insert_widget(self.meta_widget, self.meta_placeholder) - self.repr_text_edit.setText(str(self.data)) - # recurse through children and gather PVs # show tree view - self.model = RootTree(base_entry=self.data) - self.child_tree_view.setModel(self.model) + self.model = RootTree(base_entry=self.data, client=self.client) + self.tree_view.setModel(self.model) + + self.sub_pv_table_view.client = self.client + self.sub_pv_table_view.set_data(self.data) + self.sub_pv_table_view.data_updated.connect(self.track_changes) + + self.sub_coll_table_view.client = self.client + self.sub_coll_table_view.set_data(self.data) + self.sub_coll_table_view.data_updated.connect(self.track_changes) + + self.save_button.clicked.connect(self.save) + + self.set_editable(self.editable) + + def set_editable(self, editable: bool) -> None: + for col in self.sub_pv_table_view._model.header_enum: + self.sub_pv_table_view.set_editable(col, editable) + + for col in self.sub_coll_table_view._model.header_enum: + self.sub_coll_table_view.set_editable(col, editable) + + if editable: + self.save_button.show() + else: + self.save_button.hide() + + self.editable = editable + + def save(self): + self.client.save(self.data) + self._last_data = deepcopy(self.data) + + def track_changes(self): + if not self.data == self._last_data: + self.save_button.setText("Save *") + self.save_button.setEnabled(True) + else: + self.save_button.setText("Save") + self.save_button.setEnabled(False) + + def closeEvent(self, a0: QCloseEvent) -> None: + logger.debug(f"Stopping polling threads for {type(self.data)}") + self.sub_pv_table_view._model.stop_polling(wait_time=5000) + return super().closeEvent(a0) + + +class CollectionPage(NestablePage): + data: Collection + + +class SnapshotPage(NestablePage): + data: Snapshot + + +class BaseParameterPage(Display, DataWidget): + filename = 'parameter_page.ui' + + meta_placeholder: QtWidgets.QWidget + meta_widget: NameDescTagsWidget + + # Container widgets + pv_value_widget: QtWidgets.QWidget + options_hlayout: QtWidgets.QHBoxLayout + tol_widget: QtWidgets.QWidget + tol_form_layout: QtWidgets.QFormLayout + ss_widget: QtWidgets.QWidget + ss_form_layout: QtWidgets.QFormLayout + timeout_widget: QtWidgets.QWidget + timeout_form_layout: QtWidgets.QFormLayout + rbv_widget: QtWidgets.QWidget + + # dynamic display/edit widgets + pv_edit: QtWidgets.QLineEdit + value_live_label: QtWidgets.QLabel + value_stored_widget: QtWidgets.QWidget + value_stored_placeholder: QtWidgets.QWidget + refresh_button: QtWidgets.QToolButton + + tol_calc_label: QtWidgets.QLabel + abs_tol_spinbox: QtWidgets.QDoubleSpinBox + rel_tol_spinbox: QtWidgets.QDoubleSpinBox + + severity_combobox: QtWidgets.QComboBox + status_combobox: QtWidgets.QComboBox + + timeout_spinbox: QtWidgets.QDoubleSpinBox + + open_rbv_button: QtWidgets.QPushButton + rbv_pv_label: QtWidgets.QLabel + + save_button: QtWidgets.QPushButton + + _edata_thread: Optional[BusyCursorThread] + data: Union[Parameter, Setpoint, Readback] + + def __init__( + self, + *args, + client: Client, + editable: bool = False, + open_page_slot: Optional[OpenPageSlot] = None, + **kwargs + ): + super().__init__(*args, **kwargs) + self.client = client + self.editable = editable + self.open_page_slot = open_page_slot + self.value_stored_widget = None + self.edata = None + self._edata_thread: Optional[BusyCursorThread] = None + self._last_data = deepcopy(self.data) + self.setup_ui() + + def setup_ui(self): + # initialize values + self.pv_edit.setText(self.data.pv_name) + self.pv_edit.textChanged.connect(self.update_pv_name) + self.update_pv_name(self.data.pv_name) + + # setup data thread + self._edata_thread = BusyCursorThread(func=self._get_edata) + + self._edata_thread.finished.connect(self.update_stored_edit_widget) + self._edata_thread.finished.connect(self.update_live_value) + + self.refresh_button.setToolTip('refresh edit details') + self.refresh_button.setIcon(qta.icon('ei.refresh')) + self.refresh_button.clicked.connect(self.get_edata) + self.get_edata() + + self.save_button.clicked.connect(self.save) + + try: + self.bridge.data.data + except AttributeError: + self.value_stored_placeholder.hide() + else: + self.refresh_button.clicked.connect(self.update_stored_edit_widget) + self.update_stored_edit_widget() + + try: + self.bridge.status + except AttributeError: + self.ss_widget.hide() + else: + self.status_combobox.addItems([sta.name for sta in Status]) + self.severity_combobox.addItems([sta.name for sta in Severity]) + + self.status_combobox.setCurrentIndex(self.data.status.value) + self.severity_combobox.setCurrentIndex(self.data.severity.value) + + try: + # dataclasses either have all tolerances or none + self.bridge.abs_tolerance + except AttributeError: + self.tol_widget.hide() + else: + self.abs_tol_spinbox.setValue(self.data.abs_tolerance or 0.0) + self.rel_tol_spinbox.setValue(self.data.rel_tolerance or 0.0) + self.update_tol_calc() + + self.abs_tol_spinbox.valueChanged.connect(self.update_abs_tol) + self.rel_tol_spinbox.valueChanged.connect(self.update_rel_tol) + + try: + self.bridge.timeout + except AttributeError: + self.timeout_widget.hide() + else: + self.timeout_spinbox.setValue(self.data.timeout or 0.0) + self.timeout_spinbox.valueChanged.connect(self.update_timeout) + + try: + self.bridge.readback + except AttributeError: + self.rbv_widget.hide() + else: + self.setup_rbv_widget() + + self.track_changes() + self.set_editable(self.editable) + + def get_edata(self) -> None: + if self._edata_thread and self._edata_thread.isRunning(): + return + + self._edata_thread.start() + + def _get_edata(self): + self.edata = self.client.cl.get(self.data.pv_name) + + def set_editable(self, editable: bool) -> None: + self.pv_edit.setReadOnly(not editable) + if self.value_stored_widget is not None: + self.value_stored_widget.setEnabled(editable) + + for form_layout in (self.ss_form_layout, self.tol_form_layout, + self.timeout_form_layout): + for i in range(form_layout.rowCount()): + item = form_layout.itemAt(i, QtWidgets.QFormLayout.FieldRole) + + # designer can sometimes sneak blank rows or start at i=1 + if item is not None: + item.widget().setEnabled(editable) + + self.open_rbv_button.setEnabled(editable) + + if editable: + self.save_button.show() + else: + self.save_button.hide() + + self.editable = editable + + def update_stored_edit_widget(self): + data = self.edata + if not isinstance(data, EpicsData): + new_widget = QtWidgets.QToolButton() + new_widget.setIcon(qta.icon("msc.debug-disconnect")) + new_widget.setEnabled(False) + new_widget.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum + ) + else: + new_widget = edit_widget_from_epics_data(data) + + insert_widget(new_widget, self.value_stored_placeholder) + self.value_stored_widget = new_widget + + # update edit status in case new widgets created + self.set_editable(self.editable) + + def update_live_value(self): + data = self.edata + if not isinstance(data, EpicsData): + self.value_live_label.setText("(-)") + else: + self.value_live_label.setText(f"({str(data.data)})") + + def update_tol_calc(self): + if not (hasattr(self.data, "data") and hasattr(self.data, "abs_tolerance")): + self.tol_calc_label.hide() + return + + edata: AnyEpicsType = self.data.data + atol = self.data.abs_tolerance + rtol = self.data.rel_tolerance + + if not (self.data and atol and rtol): + self.tol_calc_label.setText("cannot calculate tolerances") + return + + # tolerance calculated as in np.isclose + total_tol = atol + rtol * abs(edata) + + self.tol_calc_label.setText( + f"[{edata - total_tol}, {edata + total_tol}]" + ) + + def update_pv_name(self, text: str): + if hasattr(self.data, "pv_name"): + self.bridge.pv_name.put(text) + + match_line_edit_text_width(self.pv_edit, text=text) + + def update_abs_tol(self, *args, **kwargs): + if hasattr(self.data, "abs_tolerance"): + self.bridge.abs_tolerance.put(self.abs_tol_spinbox.value()) + self.update_tol_calc() + + def update_rel_tol(self, *args, **kwargs): + if hasattr(self.data, "rel_tolerance"): + self.bridge.rel_tolerance.put(self.rel_tol_spinbox.value()) + self.update_tol_calc() + + def update_timeout(self, *args, **kwargs): + if hasattr(self.data, "timeout"): + self.bridge.timeout.put(self.timeout_spinbox.value()) + + def open_rbv_page(self) -> DataWidget: + if self.open_page_slot: + widget = self.open_page_slot(self.data.readback) + widget.bridge.pv_name.changed_value.connect(self.rbv_pv_label.setText) + + def create_rbv(self): + new_rbv = Readback(pv_name='') + self.bridge.readback.put(new_rbv) + self.open_rbv_page() + + def setup_rbv_widget(self): + if self.data.readback is None: + # Setup create-new button + self.rbv_pv_label.setText("[None]") + self.open_rbv_button.clicked.connect(self.create_rbv) + else: + self.rbv_pv_label.setText(self.data.readback.pv_name) + self.open_rbv_button.clicked.connect(self.open_rbv_page) + + def save(self): + self.client.save(self.data) + self._last_data = deepcopy(self.data) + + def track_changes(self): + if not self.data == self._last_data: + self.save_button.setText("Save *") + self.save_button.setEnabled(True) + else: + self.save_button.setText("Save") + self.save_button.setEnabled(False) + + +class ParameterPage(BaseParameterPage): + data: Parameter + + +class SetpointPage(BaseParameterPage): + data: Setpoint + + +class ReadbackPage(BaseParameterPage): + data: Readback diff --git a/superscore/widgets/page/search.py b/superscore/widgets/page/search.py index 2b73bb0..7b80ac8 100644 --- a/superscore/widgets/page/search.py +++ b/superscore/widgets/page/search.py @@ -2,7 +2,7 @@ import logging from enum import auto -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Dict, List, Optional import qtawesome as qta from dateutil import tz @@ -11,6 +11,7 @@ from superscore.backends.core import SearchTerm from superscore.client import Client from superscore.model import Collection, Entry, Readback, Setpoint, Snapshot +from superscore.type_hints import OpenPageSlot from superscore.widgets import ICON_MAP from superscore.widgets.core import Display from superscore.widgets.views import (BaseTableEntryModel, ButtonDelegate, @@ -56,7 +57,7 @@ def __init__( self, *args, client: Client, - open_page_slot: Optional[Callable] = None, + open_page_slot: Optional[OpenPageSlot] = None, **kwargs ) -> None: super().__init__(*args, **kwargs) @@ -234,7 +235,7 @@ class ResultFilterProxyModel(QtCore.QSortFilterProxyModel): def __init__( self, *args, - open_page_slot: Optional[Callable] = None, + open_page_slot: Optional[OpenPageSlot] = None, **kwargs ) -> None: super().__init__(*args, **kwargs) diff --git a/superscore/widgets/thread_helpers.py b/superscore/widgets/thread_helpers.py new file mode 100644 index 0000000..eb1ebaa --- /dev/null +++ b/superscore/widgets/thread_helpers.py @@ -0,0 +1,100 @@ +from typing import ClassVar + +from qtpy import QtCore, QtGui, QtWidgets +from qtpy.QtCore import QEvent + + +def set_wait_cursor(): + app = QtWidgets.QApplication.instance() + app.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor)) + + +def reset_cursor(): + app = QtWidgets.QApplication.instance() + app.restoreOverrideCursor() + + +def busy_cursor(func): + """ + Decorator for making the cursor busy while a function is running + Will run in the GUI thread, therefore blocking GUI interaction + """ + def wrapper(*args, **kwargs): + set_wait_cursor() + try: + func(*args, **kwargs) + finally: + reset_cursor() + + return wrapper + + +class IgnoreInteractionFilter(QtCore.QObject): + interaction_events = ( + QEvent.KeyPress, QEvent.KeyRelease, QEvent.MouseButtonPress, + QEvent.MouseButtonRelease, QEvent.MouseButtonDblClick + ) + + def eventFilter(self, a0: QtCore.QObject, a1: QEvent) -> bool: + """ignore all interaction events while this filter is installed""" + if a1.type() in self.interaction_events: + return True + else: + return super().eventFilter(a0, a1) + + +FILTER = IgnoreInteractionFilter() + + +class BusyCursorThread(QtCore.QThread): + """ + Thread to switch the cursor while a task is running. Pushes the task to a + thread, allowing GUI interaction in the main thread. + + To use, you should initialize this thread with the function/slot you want to + run in the thread. Note the .start method used to kick off this thread must + be wrapped in a function in order to run... for some reason... + + ``` python + busy_thread = BusyCursorThread(func=slot_to_run) + + def run_thread(): + busy_thread.start() + + button.clicked.connect(run_thread) + ``` + """ + task_finished: ClassVar[QtCore.Signal] = QtCore.Signal() + task_starting: ClassVar[QtCore.Signal] = QtCore.Signal() + raised_exception: ClassVar[QtCore.Signal] = QtCore.Signal(Exception) + + def __init__(self, *args, func, ignore_events: bool = False, **kwargs): + super().__init__(*args, **kwargs) + self.app = None + self.func = func + self.ignore_events = ignore_events + self.task_starting.connect(self.set_cursor_busy) + self.task_finished.connect(self.reset_cursor) + + def run(self) -> None: + # called from .start(). if called directly, will block current thread + self.task_starting.emit() + # run the attached method + try: + self.func() + except Exception as ex: + self.raised_exception.emit(ex) + finally: + self.task_finished.emit() + + def set_cursor_busy(self): + set_wait_cursor() + if self.ignore_events: + self.app = QtWidgets.QApplication.instance() + self.app.installEventFilter(FILTER) + + def reset_cursor(self): + reset_cursor() + if self.ignore_events: + self.app = QtWidgets.QApplication.instance() + self.app.removeEventFilter(FILTER) diff --git a/superscore/widgets/views.py b/superscore/widgets/views.py index 0947082..576a426 100644 --- a/superscore/widgets/views.py +++ b/superscore/widgets/views.py @@ -7,8 +7,7 @@ import logging import time from enum import Enum, IntEnum, auto -from typing import (Any, Callable, ClassVar, Dict, Generator, List, Optional, - Union) +from typing import Any, ClassVar, Dict, Generator, List, Optional, Union from uuid import UUID from weakref import WeakValueDictionary @@ -23,6 +22,7 @@ from superscore.model import (Collection, Entry, Nestable, Parameter, Readback, Root, Setpoint, Severity, Snapshot, Status) from superscore.qt_helpers import QDataclassBridge +from superscore.type_hints import OpenPageSlot from superscore.widgets import ICON_MAP logger = logging.getLogger(__name__) @@ -560,6 +560,7 @@ def setData(self, index: QtCore.QModelIndex, value: Any, role: int) -> bool: success = False self.layoutChanged.emit() + self.dataChanged.emit(index, index) return success def flags(self, index: QtCore.QModelIndex) -> QtCore.Qt.ItemFlag: @@ -1047,7 +1048,7 @@ def __init__( *args, client: Optional[Client] = None, data: Optional[Union[Entry, List[Entry]]] = None, - open_page_slot: Optional[Callable] = None, + open_page_slot: Optional[OpenPageSlot] = None, **kwargs, ) -> None: """need to set open_column, close_column in subclass""" @@ -1119,6 +1120,7 @@ def maybe_setup_model(self): **self.model_kwargs ) self.setModel(self._model) + self._model.dataChanged.connect(self.data_updated) else: self._model.set_entries(self.sub_entries) @@ -1375,31 +1377,7 @@ def createEditor( if not isinstance(data_val, EpicsData): # not yet initialized, no-op return - if isinstance(data_val.data, str): - widget = QtWidgets.QLineEdit(data_val.data, parent) - elif data_val.enums: # Catch enums before numerics, enums are ints - widget = QtWidgets.QComboBox(parent) - widget.addItems(data_val.enums) - widget.setCurrentIndex(data_val.data) - elif isinstance(data_val.data, int): - widget = QtWidgets.QSpinBox(parent) - if data_val.lower_ctrl_limit == 0 and data_val.upper_ctrl_limit == 0: - widget.setMaximum(2147483647) - widget.setMinimum(-2147483647) - else: - widget.setMaximum(data_val.upper_ctrl_limit) - widget.setMinimum(data_val.lower_ctrl_limit) - widget.setValue(data_val.data) - elif isinstance(data_val.data, float): - widget = QtWidgets.QDoubleSpinBox(parent) - if data_val.lower_ctrl_limit == 0 and data_val.upper_ctrl_limit == 0: - widget.setMaximum(2147483647) - widget.setMinimum(-2147483647) - else: - widget.setMaximum(data_val.upper_ctrl_limit) - widget.setMinimum(data_val.lower_ctrl_limit) - widget.setDecimals(data_val.precision) - widget.setValue(data_val.data) + widget = edit_widget_from_epics_data(data_val, parent) else: logger.debug(f"datatype ({dtype}) incompatible with supported edit " f"widgets: ({data_val})") @@ -1438,3 +1416,60 @@ def updateEditorGeometry( index: QtCore.QModelIndex ) -> None: return editor.setGeometry(option.rect) + + +def edit_widget_from_epics_data( + edata: EpicsData, + parent_widget: Optional[QtWidgets.QWidget] = None +) -> QtWidgets.QWidget: + """ + Returns the appropriate edit widget given an EpicsData instance. Supported + data types include: + - string -> QLineEdit + - integer -> QSpinBox + - float -> QDoubleSpinBox + - enum -> QComboBox + + When applicable, limits and enum options will be applied + + Parameters + ---------- + edata : EpicsData + Data to return an appropriate edit widget for + parent_widget : QtWidgets.QWidget + parent widget to assign to the edit widget + + Returns + ------- + QtWidgets.QWidget + The edit widget + """ + if isinstance(edata.data, str): + widget = QtWidgets.QLineEdit(edata.data, parent_widget) + elif edata.enums: # Catch enums before numerics, enums are ints + widget = QtWidgets.QComboBox(parent_widget) + widget.addItems(edata.enums) + widget.setCurrentIndex(edata.data) + elif isinstance(edata.data, int): + widget = QtWidgets.QSpinBox(parent_widget) + if edata.lower_ctrl_limit == 0 and edata.upper_ctrl_limit == 0: + widget.setMaximum(2147483647) + widget.setMinimum(-2147483647) + else: + widget.setMaximum(edata.upper_ctrl_limit) + widget.setMinimum(edata.lower_ctrl_limit) + widget.setValue(edata.data) + elif isinstance(edata.data, float): + widget = QtWidgets.QDoubleSpinBox(parent_widget) + if edata.lower_ctrl_limit == 0 and edata.upper_ctrl_limit == 0: + widget.setMaximum(2147483647) + widget.setMinimum(-2147483647) + else: + widget.setMaximum(edata.upper_ctrl_limit) + widget.setMinimum(edata.lower_ctrl_limit) + widget.setDecimals(edata.precision) + widget.setValue(edata.data) + else: + raise ValueError(f"data type ({edata}) not supported ") + + return widget diff --git a/superscore/widgets/window.py b/superscore/widgets/window.py index 62e431a..964c235 100644 --- a/superscore/widgets/window.py +++ b/superscore/widgets/window.py @@ -14,7 +14,7 @@ from superscore.client import Client from superscore.model import Entry from superscore.widgets import ICON_MAP -from superscore.widgets.core import Display +from superscore.widgets.core import DataWidget, Display from superscore.widgets.page import PAGE_MAP from superscore.widgets.page.collection_builder import CollectionBuilderPage from superscore.widgets.page.search import SearchPage @@ -87,7 +87,7 @@ def open_collection_builder(self): ) self._partial_slots.append(update_slot) - def open_page(self, entry: Entry) -> None: + def open_page(self, entry: Entry) -> DataWidget: """ Open a page for ``entry`` in a new tab. @@ -95,6 +95,11 @@ def open_page(self, entry: Entry) -> None: ---------- entry : Entry Entry subclass to open a new page for + + Returns + ------- + DataWidget + Created widget, for cross references """ logger.debug(f'attempting to open {entry}') if not isinstance(entry, Entry): @@ -110,7 +115,8 @@ def open_page(self, entry: Entry) -> None: logger.debug(f'No page widget for {type(entry)}, cannot open in tab') return - page_widget = page(data=entry) + page_widget = page(data=entry, client=self.client, + open_page_slot=self.open_page) icon = qta.icon(ICON_MAP[type(entry)]) tab_name = getattr( entry, 'title', getattr(entry, 'pv_name', f'<{type(entry).__name__}>') @@ -118,6 +124,8 @@ def open_page(self, entry: Entry) -> None: idx = self.tab_widget.addTab(page_widget, icon, tab_name) self.tab_widget.setCurrentIndex(idx) + return page_widget + def open_search_page(self) -> None: page = SearchPage(client=self.client, open_page_slot=self.open_page) self.tab_widget.addTab(page, 'search')