diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9e435d5..cef127b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,11 +4,6 @@ repos: hooks: - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/myint/autoflake - rev: v1.4 - hooks: - - id: autoflake - args: ["--in-place", "--remove-all-unused-imports"] - repo: https://github.com/PyCQA/isort rev: 5.8.0 hooks: diff --git a/README.md b/README.md index 56a41e3..c933935 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ behave like a standard QSlider... just with multiple handles. - Uses platform-specific Qt styles - Supports style sheets - Supports mouse wheel and keypress (soon) events -- Supports both PyQt5 and PySide2 (via qtpy) +- Supports PyQt5, PyQt6, PySide2 and PySide6 ## Installation diff --git a/examples/basic.py b/examples/basic.py new file mode 100644 index 0000000..21ed7f0 --- /dev/null +++ b/examples/basic.py @@ -0,0 +1,14 @@ +from pyqrangeslider import QRangeSlider +from pyqrangeslider.qtcompat import API_NAME +from pyqrangeslider.qtcompat.QtWidgets import QApplication + +print(API_NAME) +app = QApplication([]) + +slider = QRangeSlider() +slider.setMinimum(0) +slider.setMaximum(100) +slider.setValue((20, 80)) +slider.show() + +app.exec_() diff --git a/pyqrangeslider/_qrangeslider.py b/pyqrangeslider/_qrangeslider.py index 370d61d..b7ccd1e 100644 --- a/pyqrangeslider/_qrangeslider.py +++ b/pyqrangeslider/_qrangeslider.py @@ -1,8 +1,8 @@ from typing import List, Sequence, Tuple -from qtpy import QtGui -from qtpy.QtCore import QEvent, QPoint, QRect, QRectF, Qt, Signal -from qtpy.QtWidgets import ( +from .qtcompat import QtGui +from .qtcompat.QtCore import QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal +from .qtcompat.QtWidgets import ( QApplication, QSlider, QStyle, @@ -290,9 +290,11 @@ class QRangeSlider(QSlider): hdl_idx = 0 dist = float("inf") + if isinstance(pos, QPointF): + pos = QPoint(pos.x(), pos.y()) # TODO: this should be reversed, to prefer higher value handles for i, hdl in enumerate(self._handleRects(opt)): - if pos in hdl: + if hdl.contains(pos): return ("handle", i) # TODO: use enum for 'handle' hdl_center = self._pick(hdl.center()) abs_dist = abs(event_position - hdl_center) @@ -373,7 +375,7 @@ class QRangeSlider(QSlider): e.accept() def _scrollByDelta( - self, orientation: Qt.Orientation, modifiers: Qt.KeyboardModifiers, delta: int + self, orientation, modifiers: Qt.KeyboardModifiers, delta: int ) -> bool: steps_to_scroll = 0 pg_step = self.pageStep() diff --git a/pyqrangeslider/_tests/test_slider.py b/pyqrangeslider/_tests/test_slider.py index 8eb492c..588a035 100644 --- a/pyqrangeslider/_tests/test_slider.py +++ b/pyqrangeslider/_tests/test_slider.py @@ -1,7 +1,7 @@ import pytest -from qtpy.QtCore import Qt from pyqrangeslider import QRangeSlider +from pyqrangeslider.qtcompat.QtCore import Qt @pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"]) diff --git a/pyqrangeslider/qtcompat/QtCore.py b/pyqrangeslider/qtcompat/QtCore.py new file mode 100644 index 0000000..bfc56d5 --- /dev/null +++ b/pyqrangeslider/qtcompat/QtCore.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# +# Copyright © 2014-2015 Colin Duquesnoy +# Copyright © 2009- The Spyder Development Team +# +# Licensed under the terms of the MIT License +# (see LICENSE.txt for details) + +""" +Modified from qtpy.QtCore. +Provides QtCore classes and functions. +""" +# flake8: noqa + +from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError + +if PYQT5: + from PyQt5.QtCore import QT_VERSION_STR as __version__ + from PyQt5.QtCore import * + from PyQt5.QtCore import pyqtBoundSignal as SignalInstance + from PyQt5.QtCore import pyqtProperty as Property + from PyQt5.QtCore import pyqtSignal as Signal + from PyQt5.QtCore import pyqtSlot as Slot + + # Those are imported from `import *` + del pyqtSignal, pyqtBoundSignal, pyqtSlot, pyqtProperty, QT_VERSION_STR +elif PYQT6: + from PyQt6.QtCore import QT_VERSION_STR as __version__ + from PyQt6.QtCore import * + from PyQt6.QtCore import pyqtBoundSignal as SignalInstance + from PyQt6.QtCore import pyqtProperty as Property + from PyQt6.QtCore import pyqtSignal as Signal + from PyQt6.QtCore import pyqtSlot as Slot + + # backwards compat with PyQt5 + # namespace moves: + for cls in (QEvent, Qt): + for attr in dir(cls): + if not attr[0].isupper(): + continue + ns = getattr(cls, attr) + for name, val in vars(ns).items(): + if not name.startswith("_"): + setattr(cls, name, val) + + # Those are imported from `import *` + del pyqtSignal, pyqtBoundSignal, pyqtSlot, pyqtProperty, QT_VERSION_STR +elif PYSIDE2: + import PySide2.QtCore + + __version__ = PySide2.QtCore.__version__ +elif PYSIDE6: + import PySide6.QtCore + + __version__ = PySide6.QtCore.__version__ + +else: + raise PythonQtError("No Qt bindings could be found") diff --git a/pyqrangeslider/qtcompat/QtGui.py b/pyqrangeslider/qtcompat/QtGui.py new file mode 100644 index 0000000..84b8046 --- /dev/null +++ b/pyqrangeslider/qtcompat/QtGui.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# Copyright © 2014-2015 Colin Duquesnoy +# Copyright © 2009- The Spyder Development Team +# +# Licensed under the terms of the MIT License +# (see LICENSE.txt for details) + +""" +Modified from qtpy.QtGui +Provides QtGui classes and functions. +""" + +from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError + +if PYQT5: + from PyQt5.QtGui import * +elif PYSIDE2: + from PySide2.QtGui import * +elif PYQT6: + from PyQt6.QtGui import * + + # backwards compat with PyQt5 + # namespace moves: + for cls in (QPalette,): + for attr in dir(cls): + if not attr[0].isupper(): + continue + ns = getattr(cls, attr) + for name, val in vars(ns).items(): + if not name.startswith("_"): + setattr(cls, name, val) + + def pos(self, *a): + _pos = self.position(*a) + return _pos.toPoint() + + QMouseEvent.pos = pos + +elif PYSIDE6: + pass +else: + raise PythonQtError("No Qt bindings could be found") diff --git a/pyqrangeslider/qtcompat/QtWidgets.py b/pyqrangeslider/qtcompat/QtWidgets.py new file mode 100644 index 0000000..c52e015 --- /dev/null +++ b/pyqrangeslider/qtcompat/QtWidgets.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# +# Copyright © 2014-2015 Colin Duquesnoy +# Copyright © 2009- The Spyder Developmet Team +# +# Licensed under the terms of the MIT License +# (see LICENSE.txt for details) + +""" +Modified from qtpy.QtWidgets +Provides widget classes and functions. +""" + +from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError + +if PYQT5: + from PyQt5.QtWidgets import * +elif PYSIDE2: + from PySide2.QtWidgets import * +elif PYQT6: + from PyQt6.QtWidgets import * + + # backwards compat with PyQt5 + # namespace moves: + for cls in (QStyle, QSlider): + for attr in dir(cls): + if not attr[0].isupper(): + continue + ns = getattr(cls, attr) + for name, val in vars(ns).items(): + if not name.startswith("_"): + setattr(cls, name, val) + + def exec_(self): + self.exec() + + QApplication.exec_ = exec_ + +elif PYSIDE6: + pass + + +else: + raise PythonQtError("No Qt bindings could be found") diff --git a/pyqrangeslider/qtcompat/__init__.py b/pyqrangeslider/qtcompat/__init__.py new file mode 100644 index 0000000..ed73f9e --- /dev/null +++ b/pyqrangeslider/qtcompat/__init__.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +# +# Copyright © 2009- The Spyder Development Team +# Copyright © 2014-2015 Colin Duquesnoy +# +# Licensed under the terms of the MIT License +# (see LICENSE.txt for details) + +""" +This file is borrowed from qtpy and modified to support PySide6/PyQt6 (drops PyQt4) +""" + +import os +import platform +import sys +import warnings +from distutils.version import LooseVersion + + +class PythonQtError(RuntimeError): + """Error raise if no bindings could be selected.""" + + +class PythonQtWarning(Warning): + """Warning if some features are not implemented in a binding.""" + + +# Qt API environment variable name +QT_API = "QT_API" + +# Names of the expected PyQt5 api +PYQT5_API = ["pyqt5"] + +# Names of the expected PyQt5 api +PYQT6_API = ["pyqt6"] + +# Names of the expected PySide2 api +PYSIDE2_API = ["pyside2"] + +# Names of the expected PySide2 api +PYSIDE6_API = ["pyside6"] + +# Detecting if a binding was specified by the user +binding_specified = QT_API in os.environ + +# Setting a default value for QT_API +os.environ.setdefault(QT_API, "pyqt5") + +API = os.environ[QT_API].lower() +initial_api = API +assert API in (PYQT5_API + PYQT6_API + PYSIDE2_API + PYSIDE6_API) + +PYQT5 = True +PYSIDE2 = PYQT6 = PYSIDE6 = False + +# When `FORCE_QT_API` is set, we disregard +# any previously imported python bindings. +if os.environ.get("FORCE_QT_API") is not None: + if "PyQt5" in sys.modules: + API = initial_api if initial_api in PYQT5_API else "pyqt5" + elif "PySide2" in sys.modules: + API = initial_api if initial_api in PYSIDE2_API else "pyside2" + elif "PyQt6" in sys.modules: + API = initial_api if initial_api in PYQT6_API else "pyqt6" + elif "PySide6" in sys.modules: + API = initial_api if initial_api in PYSIDE6_API else "pyside6" + + +if API in PYQT5_API: + try: + from PyQt5.QtCore import PYQT_VERSION_STR as PYQT_VERSION # noqa + from PyQt5.QtCore import QT_VERSION_STR as QT_VERSION # noqa + + PYSIDE_VERSION = None # noqa + + if sys.platform == "darwin": + macos_version = LooseVersion(platform.mac_ver()[0]) + if macos_version < LooseVersion("10.10"): + if LooseVersion(QT_VERSION) >= LooseVersion("5.9"): + raise PythonQtError( + "Qt 5.9 or higher only works in " + "macOS 10.10 or higher. Your " + "program will fail in this " + "system." + ) + elif macos_version < LooseVersion("10.11"): + if LooseVersion(QT_VERSION) >= LooseVersion("5.11"): + raise PythonQtError( + "Qt 5.11 or higher only works in " + "macOS 10.11 or higher. Your " + "program will fail in this " + "system." + ) + + del macos_version + except ImportError: + API = os.environ["QT_API"] = "pyside2" + +if API in PYSIDE2_API: + try: + from PySide2 import __version__ as PYSIDE_VERSION # noqa + from PySide2.QtCore import __version__ as QT_VERSION # noqa + + PYQT_VERSION = None # noqa + PYQT5 = False + PYSIDE2 = True + + if sys.platform == "darwin": + macos_version = LooseVersion(platform.mac_ver()[0]) + if macos_version < LooseVersion("10.11"): + if LooseVersion(QT_VERSION) >= LooseVersion("5.11"): + raise PythonQtError( + "Qt 5.11 or higher only works in " + "macOS 10.11 or higher. Your " + "program will fail in this " + "system." + ) + + del macos_version + except ImportError: + API = os.environ["QT_API"] = "pyqt6" + +if API in PYQT6_API: + try: + from PyQt6.QtCore import PYQT_VERSION_STR as PYQT_VERSION # noqa + from PyQt6.QtCore import QT_VERSION_STR as QT_VERSION # noqa + + PYSIDE_VERSION = None # noqa + PYQT5 = False + PYQT6 = True + + except ImportError: + API = os.environ["QT_API"] = "pyside6" + +if API in PYSIDE6_API: + try: + from PySide6 import __version__ as PYSIDE_VERSION # noqa + from PySide6.QtCore import __version__ as QT_VERSION # noqa + + PYQT_VERSION = None # noqa + PYQT5 = False + PYSIDE6 = True + + except ImportError: + raise PythonQtError("No Qt bindings could be found") + +# If a correct API name is passed to QT_API and it could not be found, +# switches to another and informs through the warning +if API != initial_api and binding_specified: + warnings.warn( + 'Selected binding "{}" could not be found, ' + 'using "{}"'.format(initial_api, API), + RuntimeWarning, + ) + +API_NAME = { + "pyqt5": "PyQt5", + "pyqt6": "PyQt6", + "pyside2": "PySide2", + "pyside6": "PySide6", +}[API] diff --git a/setup.cfg b/setup.cfg index 1be57b2..1671f72 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,8 +35,6 @@ zip_safe = False packages = find: python_requires = >=3.6 setup_requires = setuptools_scm -install_requires = - qtpy [options.extras_require] pyside2 = pyside2 @@ -60,7 +58,7 @@ dev = exclude = _version.py,.eggs,examples max-line-length = 79 docstring-convention = numpy -ignore = E203,W503,E501,C901 +ignore = E203,W503,E501,C901,F403,F405 [isort] profile=black diff --git a/tox.ini b/tox.ini index 4ee6577..82d2aae 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ # For more information about tox, see https://tox.readthedocs.io/en/latest/ [tox] envlist = py{37,38,39}-{linux,macos,windows}-{pyqt,pyside} +toxworkdir=/tmp/.tox [gh-actions] python =