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
+
+
+
+ NestableTableView
+ QTableView
+
+
+
+
+
+
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')