diff --git a/docs/source/widgets/enum_button.rst b/docs/source/widgets/enum_button.rst new file mode 100644 index 000000000..ff826ea92 --- /dev/null +++ b/docs/source/widgets/enum_button.rst @@ -0,0 +1,6 @@ +####################### +PyDMEnumButton +####################### + +.. autoclass:: pydm.widgets.enum_button.PyDMEnumButton + :members: diff --git a/docs/source/widgets/index.rst b/docs/source/widgets/index.rst index 85416a85c..f38420519 100644 --- a/docs/source/widgets/index.rst +++ b/docs/source/widgets/index.rst @@ -23,6 +23,7 @@ Input Widgets :maxdepth: 1 checkbox.rst + enum_button.rst enum_combo_box.rst line_edit.rst pushbutton.rst diff --git a/examples/buttons/buttons.ui b/examples/buttons/buttons.ui index 03383e562..43d01ed44 100644 --- a/examples/buttons/buttons.ui +++ b/examples/buttons/buttons.ui @@ -113,7 +113,7 @@ The file to be opened - + Open Display @@ -145,7 +145,7 @@ PyDMRelatedDisplayButton - QFrame + QPushButton
pydm.widgets.related_display_button
diff --git a/examples/enum_buttons/buttons.ui b/examples/enum_buttons/buttons.ui new file mode 100644 index 000000000..2de561a6f --- /dev/null +++ b/examples/enum_buttons/buttons.ui @@ -0,0 +1,239 @@ + + + Form + + + + 0 + 0 + 583 + 438 + + + + Form + + + + + + + + + + + + A QWidget that renders buttons for every option of Enum Items. + For now three types of buttons can be rendered: + - Push Button + - Radio Button + - Check Box + + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + + Signals + ------- + send_value_signal : int, float, str, bool or np.ndarray + Emitted when the user changes the value. + + + + ca://MTEST:Run + + + + + + + + + + + A QWidget that renders buttons for every option of Enum Items. + For now three types of buttons can be rendered: + - Push Button + - Radio Button + - Check Box + + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + + Signals + ------- + send_value_signal : int, float, str, bool or np.ndarray + Emitted when the user changes the value. + + + + ca://MTEST:Run + + + PyDMEnumButton::RadioButton + + + + + + + + + + + A QWidget that renders buttons for every option of Enum Items. + For now three types of buttons can be rendered: + - Push Button + - Radio Button + - Check Box + + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + + Signals + ------- + send_value_signal : int, float, str, bool or np.ndarray + Emitted when the user changes the value. + + + + ca://MTEST:Run + + + PyDMEnumButton::CheckBox + + + + + + + + + + + + + A QWidget that renders buttons for every option of Enum Items. + For now three types of buttons can be rendered: + - Push Button + - Radio Button + - Check Box + + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + + Signals + ------- + send_value_signal : int, float, str, bool or np.ndarray + Emitted when the user changes the value. + + + + ca://MTEST:Run + + + Qt::Horizontal + + + + + + + + + + + A QWidget that renders buttons for every option of Enum Items. + For now three types of buttons can be rendered: + - Push Button + - Radio Button + - Check Box + + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + + Signals + ------- + send_value_signal : int, float, str, bool or np.ndarray + Emitted when the user changes the value. + + + + ca://MTEST:Run + + + PyDMEnumButton::RadioButton + + + Qt::Horizontal + + + + + + + + + + + A QWidget that renders buttons for every option of Enum Items. + For now three types of buttons can be rendered: + - Push Button + - Radio Button + - Check Box + + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + + Signals + ------- + send_value_signal : int, float, str, bool or np.ndarray + Emitted when the user changes the value. + + + + ca://MTEST:Run + + + PyDMEnumButton::CheckBox + + + Qt::Horizontal + + + + + + + + PyDMEnumButton + QWidget +
pydm.widgets.enum_button
+
+
+ + +
diff --git a/pydm/tests/conftest.py b/pydm/tests/conftest.py index 1eb9cd448..fefbbf07b 100644 --- a/pydm/tests/conftest.py +++ b/pydm/tests/conftest.py @@ -1,8 +1,6 @@ # coding: utf-8 # Fixtures for PyDM Unit Tests -pytest_plugins = 'pytester' - import pytest from pytestqt.qt_compat import qt_api @@ -15,8 +13,6 @@ from ..widgets.base import PyDMWidget from pydm.data_plugins import PyDMPlugin, add_plugin -pytest_plugins = 'pytester' - logger = logging.getLogger(__name__) _, file_path = tempfile.mkstemp(suffix=".log") handler = logging.FileHandler(file_path) diff --git a/pydm/tests/test_plugins_import.py b/pydm/tests/test_plugins_import.py index 8687c2b9c..aafb97652 100644 --- a/pydm/tests/test_plugins_import.py +++ b/pydm/tests/test_plugins_import.py @@ -38,6 +38,11 @@ def test_import_frame_plugin(): from ..widgets.frame import PyDMFrame PyDMFramePlugin = qtplugin_factory(PyDMFrame, is_container=True) +def test_import_enum_button_plugin(): + # Enum Button plugin + from ..widgets.enum_button import PyDMEnumButton + PyDMEnumButtonPlugin = qtplugin_factory(PyDMEnumButton) + def test_import_combobox_plugin(): # Enum Combobox plugin from ..widgets.enum_combo_box import PyDMEnumComboBox diff --git a/pydm/tests/widgets/test_enum_button.py b/pydm/tests/widgets/test_enum_button.py new file mode 100644 index 000000000..c5cbb44fd --- /dev/null +++ b/pydm/tests/widgets/test_enum_button.py @@ -0,0 +1,211 @@ +import pytest + +from qtpy.QtCore import Qt, QSize + +from ...widgets.enum_button import PyDMEnumButton, WidgetType, class_for_type +from ... import data_plugins + + +def test_construct(qtbot): + """ + Test the construction of the widget. + + Expectations: + All the default values are properly set. + + Parameters + ---------- + qtbot : fixture + pytest-qt window for widget test + """ + widget = PyDMEnumButton() + qtbot.addWidget(widget) + + assert widget._has_enums is False + assert widget.orientation == Qt.Vertical + assert widget.widgetType == WidgetType.PushButton + assert widget.minimumSizeHint() == QSize(50, 100) + + +@pytest.mark.parametrize("widget_type", [ + WidgetType.PushButton, + WidgetType.RadioButton, + WidgetType.CheckBox +]) +def test_widget_type(qtbot, widget_type): + """ + Test the widget for a change in the widget type. + + Parameters + ---------- + qtbot : fixture + pytest-qt window for widget test + widget_type : WidgetType + The type of widget to use. + """ + widget = PyDMEnumButton() + qtbot.addWidget(widget) + + assert widget.widgetType == WidgetType.PushButton + assert isinstance(widget._widgets[0], class_for_type[WidgetType.PushButton]) + + widget.widgetType = widget_type + assert widget.widgetType == widget_type + assert isinstance(widget._widgets[0], class_for_type[widget_type]) + + +@pytest.mark.parametrize("orientation", [ + Qt.Horizontal, + Qt.Vertical +]) +def test_widget_orientation(qtbot, orientation): + """ + Test the widget for a change in the orientation. + + Parameters + ---------- + qtbot : fixture + pytest-qt window for widget test + orientation : int + One of Qt.Vertical or Qt.Horizontal + """ + widget = PyDMEnumButton() + qtbot.addWidget(widget) + + assert widget.orientation == Qt.Vertical + + widget.orientation = orientation + assert widget.orientation == orientation + + if orientation == Qt.Horizontal: + row = 0 + col = 1 + else: + row = 1 + col = 0 + + item = widget.layout().itemAtPosition(row, col) + assert item is not None + w = item.widget() + assert w is not None + assert isinstance(w, class_for_type[widget.widgetType]) + + +@pytest.mark.parametrize("connected, write_access, has_enum, is_app_read_only", [ + (True, True, True, True), + (True, True, True, False), + + (True, True, False, True), + (True, True, False, False), + + (True, False, False, True), + (True, False, False, False), + + (True, False, True, True), + (True, False, True, False), + + (False, True, True, True), + (False, True, True, False), + + (False, False, True, True), + (False, False, True, False), + + (False, True, False, True), + (False, True, False, False), + + (False, False, False, True), + (False, False, False, False), +]) +def test_check_enable_state(qtbot, signals, connected, write_access, has_enum, + is_app_read_only): + """ + Test the tooltip generated depending on the channel connection, write access, + whether the widget has enum strings, + and whether the app is read-only. + + Expectations: + 1. If the data channel is disconnected, the widget's tooltip will display "PV is disconnected" + 2. If the data channel is connected, but it has no write access: + a. If the app is read-only, the tooltip will read "Running PyDM on Read-Only mode." + b. If the app is not read-only, the tooltip will read "Access denied by Channel Access Security." + 3. If the widget does not have any enum strings, the tooltip will display "Enums not available". + + Parameters + ---------- + qtbot : fixture + Window for widget testing + signals : fixture + The signals fixture, which provides access signals to be bound to the appropriate slots + connected : bool + True if the channel is connected; False otherwise + write_access : bool + True if the widget has write access to the channel; False otherwise + has_enum: bool + True if the widget has enum strings populated; False if the widget contains no enum strings (empty of choices) + is_app_read_only : bool + True if the PyDM app is read-only; False otherwise + """ + widget = PyDMEnumButton() + qtbot.addWidget(widget) + + signals.write_access_signal[bool].connect(widget.writeAccessChanged) + signals.write_access_signal[bool].emit(write_access) + + signals.connection_state_signal[bool].connect(widget.connectionStateChanged) + signals.connection_state_signal[bool].emit(connected) + + if has_enum: + signals.enum_strings_signal[tuple].connect(widget.enumStringsChanged) + signals.enum_strings_signal[tuple].emit(("START", "STOP", "PAUSE")) + assert widget._has_enums + + data_plugins.set_read_only(is_app_read_only) + + original_tooltip = "Original Tooltip" + widget.setToolTip(original_tooltip) + widget.check_enable_state() + + actual_tooltip = widget.toolTip() + if not widget._connected: + assert "Channel is disconnected." in actual_tooltip + elif not write_access: + if data_plugins.is_read_only(): + assert "Running PyDM on Read-Only mode." in actual_tooltip + else: + assert "Access denied by Channel Access Security." in actual_tooltip + elif not widget._has_enums: + assert "Enums not available" in actual_tooltip + + +def test_send_receive_value(qtbot, signals): + """ + Test the widget for round-trip data transfer. + + Parameters + ---------- + qtbot : fixture + Window for widget testing + signals : fixture + The signals fixture, which provides access signals to be bound to the appropriate slots + """ + widget = PyDMEnumButton() + qtbot.addWidget(widget) + + signals.write_access_signal[bool].connect(widget.writeAccessChanged) + signals.connection_state_signal[bool].connect(widget.connectionStateChanged) + signals.new_value_signal[int].connect(widget.channelValueChanged) + signals.enum_strings_signal[tuple].connect(widget.enumStringsChanged) + + widget.send_value_signal[int].connect(signals.receiveValue) + + signals.write_access_signal[bool].emit(True) + signals.connection_state_signal[bool].emit(True) + signals.enum_strings_signal[tuple].emit(("START", "STOP", "PAUSE")) + + signals.new_value_signal[int].emit(1) + assert widget.value == 1 + assert widget._widgets[1].isChecked() + widget._widgets[2].click() + assert not widget._widgets[1].isChecked() + assert widget._widgets[2].isChecked() + assert signals.value == 2 diff --git a/pydm/widgets/enum_button.py b/pydm/widgets/enum_button.py new file mode 100644 index 000000000..9dd9b94d3 --- /dev/null +++ b/pydm/widgets/enum_button.py @@ -0,0 +1,226 @@ +from qtpy.QtCore import (Qt, QSize, Property, Slot, Q_ENUMS) +from qtpy.QtWidgets import (QWidget, QButtonGroup, QGridLayout, QPushButton, + QRadioButton, QCheckBox) + +from .. import data_plugins +from .base import PyDMWritableWidget + + +class WidgetType(object): + PushButton = 0 + RadioButton = 1 + CheckBox = 2 + + +class_for_type = [QPushButton, QRadioButton, QCheckBox] + + +class PyDMEnumButton(QWidget, PyDMWritableWidget, WidgetType): + """ + A QWidget that renders buttons for every option of Enum Items. + For now three types of buttons can be rendered: + - Push Button + - Radio Button + - Check Box + + Parameters + ---------- + parent : QWidget + The parent widget for the Label + init_channel : str, optional + The channel to be used by the widget. + + Signals + ------- + send_value_signal : int, float, str, bool or np.ndarray + Emitted when the user changes the value. + """ + Q_ENUMS(WidgetType) + WidgetType = WidgetType + + def __init__(self, parent=None, init_channel=None): + QWidget.__init__(self, parent) + PyDMWritableWidget.__init__(self, init_channel=init_channel) + self._has_enums = False + self.setLayout(QGridLayout(self)) + self._btn_group = QButtonGroup() + self._btn_group.setExclusive(True) + self._btn_group.buttonClicked[int].connect(self.handle_button_clicked) + self._widget_type = WidgetType.PushButton + self._orientation = Qt.Vertical + self._widgets = [] + self.rebuild_widgets() + + def minimumSizeHint(self): + """ + This property holds the recommended minimum size for the widget. + + Returns + ------- + QSize + """ + # This is totally arbitrary, I just want *some* visible nonzero size + return QSize(50, 100) + + @Property(WidgetType) + def widgetType(self): + """ + The widget type to be used when composing the group. + + Returns + ------- + WidgetType + """ + return self._widget_type + + @widgetType.setter + def widgetType(self, new_type): + """ + The widget type to be used when composing the group. + + Parameters + ---------- + new_type : WidgetType + """ + if new_type != self._widget_type: + self._widget_type = new_type + self.rebuild_widgets() + + @Property(Qt.Orientation) + def orientation(self): + """ + Whether to lay out the bit indicators vertically or horizontally. + + Returns + ------- + int + """ + return self._orientation + + @orientation.setter + def orientation(self, new_orientation): + """ + Whether to lay out the bit indicators vertically or horizontally. + + Parameters + ------- + new_orientation : Qt.Orientation, int + """ + self._orientation = new_orientation + self.rebuild_layout() + + @Slot(int) + def handle_button_clicked(self, id): + """ + Handles the event of a button being clicked. + + Parameters + ---------- + id : int + The clicked button id. + """ + self.send_value_signal.emit(id) + + def clear(self): + """ + Remove all inner widgets from the layout + """ + for col in range(0, self.layout().columnCount()): + for row in range(0, self.layout().rowCount()): + item = self.layout().itemAtPosition(row, col) + if item is not None: + w = item.widget() + if w is not None: + self.layout().removeWidget(w) + + def rebuild_widgets(self): + """ + Rebuild the list of widgets based on a new enum or generates a default + list of fake strings so we can see something at Designer. + """ + def generate_widgets(items): + while len(self._widgets) != 0: + w = self._widgets.pop(0) + self._btn_group.removeButton(w) + w.deleteLater() + + for idx, entry in enumerate(items): + w = class_for_type[self._widget_type](parent=self) + w.setCheckable(True) + w.setText(entry) + self._widgets.append(w) + self._btn_group.addButton(w, idx) + + self.clear() + if self._has_enums: + generate_widgets(self.enum_strings) + else: + generate_widgets(["Item 1", "Item 2", "Item ..."]) + + self.rebuild_layout() + + def rebuild_layout(self): + """ + Method to reorganize the top-level widget and its contents + according to the layout property values. + """ + self.clear() + if self.orientation == Qt.Vertical: + for i, widget in enumerate(self._widgets): + self.layout().addWidget(widget, i, 0) + elif self.orientation == Qt.Horizontal: + for i, widget in enumerate(self._widgets): + self.layout().addWidget(widget, 0, i) + + def check_enable_state(self): + """ + Checks whether or not the widget should be disable. + This method also disables the widget and add a Tool Tip + with the reason why it is disabled. + + """ + status = self._write_access and self._connected and self._has_enums + tooltip = "" + if not self._connected: + tooltip += "Channel is disconnected." + elif not self._write_access: + if data_plugins.is_read_only(): + tooltip += "Running PyDM on Read-Only mode." + else: + tooltip += "Access denied by Channel Access Security." + elif not self._has_enums: + tooltip += "Enums not available." + + self.setToolTip(tooltip) + self.setEnabled(status) + + def value_changed(self, new_val): + """ + Callback invoked when the Channel value is changed. + + Parameters + ---------- + new_val : int + The new value from the channel. + """ + super(PyDMEnumButton, self).value_changed(new_val) + if new_val is not None: + btn = self._btn_group.button(new_val) + if btn: + btn.setChecked(True) + + def enum_strings_changed(self, new_enum_strings): + """ + Callback invoked when the Channel has new enum values. + This callback also triggers a value_changed call so the + new enum values to be broadcasted. + + Parameters + ---------- + new_enum_strings : tuple + The new list of values + """ + super(PyDMEnumButton, self).enum_strings_changed(new_enum_strings) + self._has_enums = True + self.check_enable_state() + self.rebuild_widgets() diff --git a/pydm/widgets/qtplugins.py b/pydm/widgets/qtplugins.py index 80cf574d0..bea025d34 100644 --- a/pydm/widgets/qtplugins.py +++ b/pydm/widgets/qtplugins.py @@ -16,6 +16,7 @@ PyDMDrawingPolygon) from .embedded_display import PyDMEmbeddedDisplay +from .enum_button import PyDMEnumButton from .enum_combo_box import PyDMEnumComboBox from .frame import PyDMFrame from .image import PyDMImageView @@ -106,6 +107,11 @@ group=WidgetCategory.CONTAINER, extensions=BASE_EXTENSIONS) +# Enum Button plugin +PyDMEnumButtonPlugin = qtplugin_factory(PyDMEnumButton, + group=WidgetCategory.INPUT, + extensions=BASE_EXTENSIONS) + # Enum Combobox plugin PyDMEnumComboBoxPlugin = qtplugin_factory(PyDMEnumComboBox, group=WidgetCategory.INPUT,