diff --git a/pydm/tests/widgets/test_slider.py b/pydm/tests/widgets/test_slider.py index ba12cbb77..1cea688bd 100644 --- a/pydm/tests/widgets/test_slider.py +++ b/pydm/tests/widgets/test_slider.py @@ -1,19 +1,202 @@ -# Unit Tests for the PyDMSlider Widget - import pytest from logging import ERROR import numpy as np -from qtpy.QtWidgets import QLabel, QVBoxLayout, QHBoxLayout, QSizePolicy -from qtpy.QtCore import Qt, QMargins - +from qtpy.QtWidgets import QLabel, QVBoxLayout, QHBoxLayout, QSizePolicy, QApplication +from qtpy.QtCore import Qt, QMargins, QPoint, QEvent, QRect, QSize +from qtpy.QtGui import QMouseEvent from ...widgets.slider import PyDMSlider, PyDMPrimitiveSlider from ...widgets.base import PyDMWidget +# Unit Tests for the PyDMPrimitiveSlider class + + +@pytest.fixture(scope="module") +def app(): + """Fixture to create a QApplication instance.""" + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app + + +@pytest.fixture +def horizontal_slider(app): + """Fixture to create a PyDMPrimitiveSlider instance for each test.""" + test_slider = PyDMPrimitiveSlider(Qt.Horizontal) + test_slider.setMinimum(0) + test_slider.setMaximum(100) + test_slider.setValue(50) + test_slider.setSingleStep(1) + test_slider.resize(200, 30) + test_slider.show() + return test_slider + + +@pytest.fixture +def vertical_slider(app): + """Fixture to create a vertical PyDMPrimitiveSlider instance for each test.""" + test_slider = PyDMPrimitiveSlider(Qt.Vertical) + test_slider.setMinimum(0) + test_slider.setMaximum(100) + test_slider.setValue(50) + test_slider.setSingleStep(1) + test_slider.resize(30, 200) + test_slider.show() + return test_slider + + +@pytest.mark.parametrize("slider_fixture", ["horizontal_slider", "vertical_slider"]) +def test_mousePressEvent(slider_fixture, qtbot, request): + """Test mousePressEvent when clicking off and on the handle""" + test_slider = request.getfixturevalue(slider_fixture) + handle_rect = test_slider.getHandleRect() + + if test_slider.orientation() == Qt.Horizontal: + pos_off_handle = QPoint(handle_rect.right() + 10, handle_rect.center().y()) + increment = 1 + else: # Vertical + pos_off_handle = QPoint(handle_rect.center().x(), handle_rect.bottom() + 10) + increment = -1 + + qtbot.mouseClick(test_slider, Qt.LeftButton, pos=pos_off_handle) + assert not test_slider.isDraggingHandle + assert test_slider.value() == 50 + increment + + pos_on_handle = handle_rect.center() + qtbot.mousePress(test_slider, Qt.LeftButton, pos=pos_on_handle) + assert test_slider.isDraggingHandle + assert test_slider.dragStartValue == test_slider.value() + + +@pytest.mark.parametrize("slider_fixture", ["horizontal_slider", "vertical_slider"]) +def test_mouseMoveEvent(slider_fixture, qtbot, request): + """Test the mouseMoveEvent method by posting QMouseEvent instances.""" + test_slider = request.getfixturevalue(slider_fixture) + handle_rect = test_slider.getHandleRect() + start_pos = handle_rect.center() + drag_distance = 100 + + if test_slider.orientation() == Qt.Horizontal: + end_pos = QPoint(start_pos.x() + drag_distance, start_pos.y()) + else: + end_pos = QPoint(start_pos.x(), start_pos.y() - drag_distance) -# -------------------- -# POSITIVE TEST CASES -# -------------------- + initial_value = test_slider.value() + + press_event = QMouseEvent(QEvent.MouseButtonPress, start_pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + QApplication.postEvent(test_slider, press_event) + QApplication.processEvents() + + move_event = QMouseEvent(QEvent.MouseMove, end_pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + QApplication.postEvent(test_slider, move_event) + QApplication.processEvents() + + release_event = QMouseEvent(QEvent.MouseButtonRelease, end_pos, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + QApplication.postEvent(test_slider, release_event) + QApplication.processEvents() + + actual_value = test_slider.value() + + assert actual_value != initial_value + assert actual_value == 100 + + +@pytest.mark.parametrize("slider_fixture", ["horizontal_slider", "vertical_slider"]) +def test_mouseReleaseEvent(slider_fixture, qtbot, request): + """Test mouseReleaseEvent to stop dragging.""" + test_slider = request.getfixturevalue(slider_fixture) + handle_rect = test_slider.getHandleRect() + pos_on_handle = handle_rect.center() + qtbot.mousePress(test_slider, Qt.LeftButton, pos=pos_on_handle) + assert test_slider.isDraggingHandle + + qtbot.mouseRelease(test_slider, Qt.LeftButton, pos=pos_on_handle) + + assert not test_slider.isDraggingHandle + assert test_slider.cursor().shape() == Qt.ArrowCursor + + +@pytest.mark.parametrize("slider_fixture", ["horizontal_slider", "vertical_slider"]) +def test_getHandleRect(slider_fixture, request): + """Test getHandleRect method.""" + test_slider = request.getfixturevalue(slider_fixture) + handle_rect = test_slider.getHandleRect() + assert isinstance(handle_rect, QRect) + assert test_slider.rect().contains(handle_rect) + + +@pytest.mark.parametrize("slider_fixture", ["horizontal_slider", "vertical_slider"]) +def test_getPositions(slider_fixture, request): + """Test getPositions method.""" + test_slider = request.getfixturevalue(slider_fixture) + event = QMouseEvent(QEvent.MouseButtonPress, QPoint(50, 10), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) + handle_pos, click_pos = test_slider.getPositions(event) + assert isinstance(handle_pos, float) + assert isinstance(click_pos, int) + if test_slider.orientation() == Qt.Horizontal: + assert 0 <= click_pos <= test_slider.width() + else: + assert 0 <= click_pos <= test_slider.height() + + +@pytest.mark.parametrize("slider_fixture", ["horizontal_slider", "vertical_slider"]) +def test_shouldIncrement(slider_fixture, request): + """Test shouldIncrement method.""" + test_slider = request.getfixturevalue(slider_fixture) + + if test_slider.orientation() == Qt.Horizontal: + # Click is to the right of the handle + assert test_slider.shouldIncrement(50, 70) is True + # Click is to the left of the handle + assert test_slider.shouldIncrement(70, 50) is False + else: + # Click is above the handle (smaller y) + assert test_slider.shouldIncrement(70, 50) is True + # Click is below the handle (larger y) + assert test_slider.shouldIncrement(50, 70) is False + + +@pytest.mark.parametrize("slider_fixture", ["horizontal_slider", "vertical_slider"]) +def test_getHandleSize(slider_fixture, request): + """Test getHandleSize method.""" + test_slider = request.getfixturevalue(slider_fixture) + handle_size = test_slider.getHandleSize() + assert handle_size is not None + assert isinstance(handle_size.width(), int) + assert isinstance(handle_size.height(), int) + + if test_slider.orientation() == Qt.Horizontal: + assert handle_size == QSize(20, test_slider.height() // 2) + else: + assert handle_size == QSize(test_slider.width() // 2, 20) + + +@pytest.mark.parametrize("slider_fixture", ["horizontal_slider", "vertical_slider"]) +def test_getSliderLength(slider_fixture, request): + """Test getSliderLength method.""" + test_slider = request.getfixturevalue(slider_fixture) + slider_length = test_slider.getSliderLength() + assert isinstance(slider_length, int) + if test_slider.orientation() == Qt.Horizontal: + assert slider_length <= test_slider.width() + else: + assert slider_length <= test_slider.height() + + +@pytest.mark.parametrize("slider_fixture", ["horizontal_slider", "vertical_slider"]) +def test_getSliderPosition(slider_fixture, request): + """Test getSliderPosition method.""" + test_slider = request.getfixturevalue(slider_fixture) + slider_position = test_slider.getSliderPosition() + assert isinstance(slider_position, float) + if test_slider.orientation() == Qt.Horizontal: + assert 0 <= slider_position <= test_slider.width() + else: + assert 0 <= slider_position <= test_slider.height() + + +# Unit Tests for the PyDMSlider Widget def test_construct(qtbot): diff --git a/pydm/widgets/slider.py b/pydm/widgets/slider.py index 85ed080d4..041120bad 100644 --- a/pydm/widgets/slider.py +++ b/pydm/widgets/slider.py @@ -1,7 +1,7 @@ import logging import numpy as np from decimal import Decimal -from qtpy.QtCore import Qt, Signal, Slot, Property +from qtpy.QtCore import Qt, Signal, Slot, Property, QRect, QPoint, QSize from qtpy.QtWidgets import ( QFrame, QLabel, @@ -26,41 +26,209 @@ class PyDMPrimitiveSlider(QSlider): + def __init__(self, orientation=Qt.Horizontal, parent=None): + super().__init__(orientation, parent) + self.isDraggingHandle = False + self.dragStartPos = None + self.dragStartValue = None + def mousePressEvent(self, event): + """ + Handle mouse press events on the slider. + + Parameters + ---------- + event : QMouseEvent + The mouse event containing information about the press. + """ if event.button() == Qt.MiddleButton: return if event.button() == Qt.RightButton: super().mousePressEvent(event) + return if event.button() == Qt.LeftButton: - if self.orientation() == Qt.Horizontal: - handle_pos = self.value() * (self.width() - self.handleWidth()) / (self.maximum() - self.minimum()) - click_pos = event.pos().x() + handle_rect = self.getHandleRect() + + if handle_rect.contains(event.pos()): + self.isDraggingHandle = True + self.dragStartPos = event.pos() + self.dragStartValue = self.value() + self.setCursor(Qt.ClosedHandCursor) + event.accept() else: - handle_pos = ( - (self.maximum() - self.value()) - * (self.height() - self.handleWidth()) - / (self.maximum() - self.minimum()) - ) - click_pos = event.pos().y() + handle_pos, click_pos = self.getPositions(event) - if self.orientation() == Qt.Horizontal: - if click_pos > handle_pos + self.handleWidth() / 2: + if self.shouldIncrement(handle_pos, click_pos): self.setValue(self.value() + self.singleStep()) else: self.setValue(self.value() - self.singleStep()) + + def mouseMoveEvent(self, event): + """ + Handle mouse move events to update the slider value during dragging. + + Parameters + ---------- + event : QMouseEvent + The mouse event containing information about the movement. + """ + if self.isDraggingHandle: + delta = event.pos() - self.dragStartPos + + if self.orientation() == Qt.Horizontal: + delta_value = (delta.x() / self.getSliderLength()) * (self.maximum() - self.minimum()) else: - if click_pos < handle_pos + self.handleWidth() / 2: - self.setValue(self.value() + self.singleStep()) - else: - self.setValue(self.value() - self.singleStep()) + delta_value = (-delta.y() / self.getSliderLength()) * (self.maximum() - self.minimum()) + + new_value = self.dragStartValue + delta_value + new_value = min(max(self.minimum(), new_value), self.maximum()) + self.setValue(new_value) + event.accept() + else: + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + """ + Handle mouse release events to stop dragging. + + Parameters + ---------- + event : QMouseEvent + The mouse event containing information about the release. + """ + if event.button() == Qt.LeftButton and self.isDraggingHandle: + self.isDraggingHandle = False + self.setCursor(Qt.ArrowCursor) + event.accept() + else: + super().mouseReleaseEvent(event) + + def getHandleRect(self): + """ + Calculate the rectangle representing the slider handle's position and size. + + Returns + ------- + QRect + The rectangle of the slider handle. + """ + handle_size = self.getHandleSize() + slider_pos = self.getSliderPosition() - def handleWidth(self): if self.orientation() == Qt.Horizontal: - return self.style().pixelMetric(self.style().PM_SliderLength, None, self) + x = slider_pos - handle_size.width() / 2 + y = (self.height() - handle_size.height()) / 2 + rect = QRect(QPoint(int(x), int(y)), handle_size) else: - return self.style().pixelMetric(self.style().PM_SliderThickness, None, self) + x = (self.width() - handle_size.width()) / 2 + y = slider_pos - handle_size.height() / 2 + rect = QRect(QPoint(int(x), int(y)), handle_size) + + return rect + + def getPositions(self, event): + """ + Retrieve the handle position and the click position based on the orientation. + + Parameters + ---------- + event : QMouseEvent + The mouse event containing information about the press. + + Returns + ------- + tuple of float + A tuple containing the handle position and the click position. + """ + slider_pos = self.getSliderPosition() + handle_pos = slider_pos + + if self.orientation() == Qt.Horizontal: + click_pos = event.pos().x() + else: + click_pos = event.pos().y() + + return handle_pos, click_pos + + def shouldIncrement(self, handle_pos, click_pos): + """ + Determine whether the slider value should be incremented based on positions. + + Parameters + ---------- + handle_pos : float + The position of the slider handle. + click_pos : float + The position where the user clicked. + + Returns + ------- + bool + True if the slider value should be incremented, False otherwise. + """ + if self.orientation() == Qt.Horizontal: + return click_pos > handle_pos + else: + return click_pos < handle_pos + + def getHandleSize(self): + """ + Compute the size of the slider handle. + + Returns + ------- + QSize + The size of the slider handle. + """ + handle_length = 20 # Fixed length for the handle + if self.orientation() == Qt.Horizontal: + return QSize(handle_length, self.height() // 2) + else: + return QSize(self.width() // 2, handle_length) + + def getSliderLength(self): + """ + Calculate the usable length of the slider track excluding the handle size. + + Returns + ------- + float + The length of the slider track. + """ + handle_size = self.getHandleSize() + if self.orientation() == Qt.Horizontal: + return self.width() - handle_size.width() + else: + return self.height() - handle_size.height() + + def getSliderPosition(self): + """ + Compute the position of the slider handle along the track. + + Returns + ------- + float + The position of the slider handle along the slider track. + """ + slider_min = self.minimum() + slider_max = self.maximum() + slider_range = slider_max - slider_min + slider_value = self.value() - slider_min + + if slider_range == 0: + proportion = 0 + else: + proportion = slider_value / slider_range + + if self.orientation() == Qt.Horizontal: + slider_length = self.getSliderLength() + return proportion * slider_length + self.getHandleSize().width() / 2 + else: + slider_length = self.getSliderLength() + # 1 - proportion, because the largest positional value is at the bottom of the slider. + return (1 - proportion) * slider_length + self.getHandleSize().height() / 2 class PyDMSlider(QFrame, TextFormatter, PyDMWritableWidget, new_properties=_step_size_properties): @@ -118,6 +286,11 @@ def __init__(self, parent=None, init_channel=None): self.high_lim_label.setSizePolicy(label_size_policy) self.high_lim_label.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter) self._slider = PyDMPrimitiveSlider(parent=self) + + # Pass PyDMPrimitiveWidget eventfilter to self._slider so it will have the tooltip display + # the address name from middle clicking on the widget + self._slider.installEventFilter(self) + self._slider.setOrientation(Qt.Horizontal) self._orig_wheel_event = self._slider.wheelEvent @@ -127,7 +300,7 @@ def __init__(self, parent=None, init_channel=None): self._slider.sliderPressed.connect(self.internal_slider_pressed) self._slider.sliderReleased.connect(self.internal_slider_released) self._slider.valueChanged.connect(self.internal_slider_value_changed) - # self.vertical_layout.addWidget(self._slider) + # Other internal variables and final setup steps self._slider_position_to_value_map = None self._mute_internal_slider_changes = False