commit 6129ab2304f5dcd7b283f31592f786398ce71406 Author: Talley Lambert Date: Sat Apr 24 09:00:08 2021 -0400 initial commit diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml new file mode 100644 index 0000000..6258f88 --- /dev/null +++ b/.github/workflows/test_and_deploy.yml @@ -0,0 +1,78 @@ +name: Test + +on: + push: + branches: + - master + - main + tags: + - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 + pull_request: + branches: + - master + - main + workflow_dispatch: + +jobs: + test: + name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }} + runs-on: ${{ matrix.platform }} + strategy: + matrix: + platform: [ubuntu-latest, windows-latest, macos-latest] + python-version: [3.7, 3.8, 3.9] + backend: [pyqt, pyside] + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Linux libraries + if: runner.os == 'Linux' + run: | + sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 \ + libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \ + libxcb-xinerama0 libxcb-xinput0 libxcb-xfixes0 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools tox tox-gh-actions + + - name: Test with tox + run: tox + env: + PLATFORM: ${{ matrix.platform }} + BACKEND: ${{ matrix.backend }} + + - name: Coverage + uses: codecov/codecov-action@v1 + + deploy: + # this will run when you have tagged a commit, starting with "v*" + # and requires that you have put your twine API key in your + # github secrets (see readme for details) + needs: [test] + runs-on: ubuntu-latest + if: contains(github.ref, 'tags') + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -U setuptools setuptools_scm wheel twine + - name: Build and publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} + run: | + git tag + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69e5c1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,79 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ +.napari_cache + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask instance folder +instance/ + +# Sphinx documentation +docs/_build/ + +# MkDocs documentation +/site/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# OS +.DS_Store + +# written by setuptools_scm +*/_version.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..48c8a64 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ + +Copyright (c) 2021, Talley Lambert +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of pyqrangeslider nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f3155af --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include LICENSE +include README.md + +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] diff --git a/README.md b/README.md new file mode 100644 index 0000000..56a41e3 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# PyQRangeSlider + +[![License](https://img.shields.io/pypi/l/PyQRangeSlider.svg?color=green)](https://github.com/tlambert03/PyQRangeSlider/raw/master/LICENSE) +[![PyPI](https://img.shields.io/pypi/v/PyQRangeSlider.svg?color=green)](https://pypi.org/project/PyQRangeSlider) +[![Python Version](https://img.shields.io/pypi/pyversions/PyQRangeSlider.svg?color=green)](https://python.org) +[![tests](https://github.com/tlambert03/PyQRangeSlider/workflows/tests/badge.svg)](https://github.com/tlambert03/PyQRangeSlider/actions) +[![codecov](https://codecov.io/gh/tlambert03/PyQRangeSlider/branch/master/graph/badge.svg)](https://codecov.io/gh/tlambert03/PyQRangeSlider) + +Multi-handle range slider widget for PyQt/PySide + +The goal of this package is to provide a QRangeSlider that feels as "native" +as possible. Styles should match the OS by default, and the slider should +behave like a standard QSlider... just with multiple handles. + +- Attempts to match QSlider API as closely as possible +- Uses platform-specific Qt styles +- Supports style sheets +- Supports mouse wheel and keypress (soon) events +- Supports both PyQt5 and PySide2 (via qtpy) + + +## Installation + +You can install `PyQRangeSlider` via [pip]: + + pip install pyqrangeslider + +## License + +Distributed under the terms of the [BSD-3] license, +"PyQRangeSlider" is free and open source software + +## Issues + +If you encounter any problems, please [file an issue] along with a detailed description. + + +[file an issue]: https://github.com/tlambert03/PyQRangeSlider/issues diff --git a/qrangeslider/__init__.py b/qrangeslider/__init__.py new file mode 100644 index 0000000..5d49a5a --- /dev/null +++ b/qrangeslider/__init__.py @@ -0,0 +1,8 @@ +try: + from ._version import version as __version__ +except ImportError: + __version__ = "unknown" + +from ._qrangeslider import QRangeSlider + +__all__ = ["QRangeSlider"] diff --git a/qrangeslider/_qrangeslider.py b/qrangeslider/_qrangeslider.py new file mode 100644 index 0000000..370d61d --- /dev/null +++ b/qrangeslider/_qrangeslider.py @@ -0,0 +1,471 @@ +from typing import List, Sequence, Tuple + +from qtpy import QtGui +from qtpy.QtCore import QEvent, QPoint, QRect, QRectF, Qt, Signal +from qtpy.QtWidgets import ( + QApplication, + QSlider, + QStyle, + QStyleOptionSlider, + QStylePainter, + QWidget, +) + +Control = Tuple[str, int] + + +def _bound(min_: int, max_: int, value: int) -> int: + """Return value bounded by min_ and max_.""" + return max(min_, min(max_, value)) + + +class QRangeSlider(QSlider): + # Emitted when the slider value has changed, with the new slider values + valueChanged = Signal(tuple) + # Emitted when sliderDown is true and the slider moves + # This usually happens when the user is dragging the slider + # The value is the positions of *all* handles. + sliderMoved = Signal(tuple) + + NULL_CTRL = ("None", -1) + + def __init__(self, orientation=Qt.Horizontal, parent: QWidget = None): + super().__init__(orientation, parent) + + # list of values + self._value: List[int] = [20, 80] + # list of current positions of each handle. Must be same length as _value + # If tracking is enabled (the default) this will be identical to _value + self._position: List[int] = [20, 80] + self._pressedControl: Control = self.NULL_CTRL + self._hoverControl: Control = self.NULL_CTRL + + # whether bar length is constant when dragging the bar + # if False, the bar can shorten when dragged beyond min/max + self._bar_is_stiff = True + # whether clicking on the bar moves all handles, or just the nearest handle. + self._bar_moves_all = True + + # for keyboard nav + self._repeatMultiplier = 1 # TODO + # for wheel nav + self._offset_accum = 0 + # color + self._bar_color_active = "#3B88FD" + self._bar_color_inactive = "#8F8F8F" + self._bar_color_disabled = "#BBBBBB" + self._bar_height = 3 + self._bar_width = 3 + + def value(self) -> Tuple[int, ...]: + return tuple(self._value) + + def setValue(self, val: Sequence[int]) -> None: + if not isinstance(val, Sequence) and len(val) >= 2: + raise ValueError("value must be iterable of len >=2") + val = [self._min_max_bound(v) for v in val] + if self._value == val and self._position == val: + return + + self._value[:] = val[:] + if self._position != val: + self._position = val + if self.isSliderDown(): + self.sliderMoved.emit(tuple(self._position)) + + self.sliderChange(QSlider.SliderValueChange) + self.valueChanged.emit(tuple(self._value)) + + def sliderPosition(self) -> Tuple[int, ...]: + return tuple(self._position) + + def setSliderPosition(self, sld_idx: int, pos: int) -> None: + pos = self._min_max_bound(pos) + # prevent sliders from moving beyond their neighbors + pos = self._neighbor_bound(pos, sld_idx, self._position) + if pos == self._position[sld_idx]: + return + self._position[sld_idx] = pos + if not self.hasTracking(): + self.update() + if self.isSliderDown(): + self.sliderMoved.emit(tuple(self._position)) + if self.hasTracking(): + self.triggerAction(QSlider.SliderMove) + + def _getStyleOption(self) -> QStyleOptionSlider: + opt = QStyleOptionSlider() + self.initStyleOption(opt) + opt.sliderValue = 0 + opt.sliderPosition = 0 + return opt + + def paintEvent(self, a0: QtGui.QPaintEvent) -> None: + """Paint the slider.""" + # initialize painter and options + painter = QStylePainter(self) + opt = self._getStyleOption() + + # draw groove and ticks + opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks + painter.drawComplexControl(QStyle.CC_Slider, opt) + + # draw bar + r_bar = self._barRect(opt) + if self._bar_color_active is not None: # FIXME + if self.palette().currentColorGroup() == QtGui.QPalette.Active: + color = QtGui.QColor(self._bar_color_active) + elif self.palette().currentColorGroup() == QtGui.QPalette.Inactive: + color = QtGui.QColor(self._bar_color_inactive) + else: + color = QtGui.QColor(self._bar_color_disabled) + else: + color = self.palette().color(QtGui.QPalette.Highlight) + painter.fillRect(r_bar, color) + + # draw handles + opt.subControls = QStyle.SC_SliderHandle + hidx = -1 + pidx = -1 + if self._pressedControl[0] == "handle": + pidx = self._pressedControl[1] + elif self._hoverControl[0] == "handle": + hidx = self._hoverControl[1] + for idx, pos in enumerate(self._position): + opt.sliderPosition = pos + if idx == pidx: # make pressed handles appear sunken + opt.state |= QStyle.State_Sunken + else: + opt.state = opt.state & ~QStyle.State_Sunken + if idx == hidx: + opt.activeSubControls = QStyle.SC_SliderHandle + else: + opt.activeSubControls = QStyle.SC_None + painter.drawComplexControl(QStyle.CC_Slider, opt) + + def event(self, ev: QEvent) -> bool: + if ev.type() == QEvent.StyleChange: + self._traverseStyleSheet() + if ev.type() in (QEvent.HoverEnter, QEvent.HoverLeave, QEvent.HoverMove): + old_hover = self._hoverControl + self._hoverControl = self._getControlAtPos(ev.pos()) + if self._hoverControl != old_hover: + self.update() # TODO: restrict to the rect of old_hover + return super().event(ev) + + def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None: + if self.minimum() == self.maximum() or ev.buttons() ^ ev.button(): + ev.ignore() + return + + ev.accept() + # FIXME: why not working on other styles? + # set_buttons = self.style().styleHint(QStyle.SH_Slider_AbsoluteSetButtons) + set_buttons = Qt.LeftButton | Qt.MiddleButton + + # If the mouse button used is allowed to set the value + if ev.buttons() & set_buttons == ev.button(): + opt = self._getStyleOption() + + self._pressedControl = self._getControlAtPos(ev.pos(), opt, True) + + if self._pressedControl[0] == "handle": + offset = self._handle_offset(opt) + new_pos = self._pixelPosToRangeValue(self._pick(ev.pos() - offset)) + self.setSliderPosition(self._pressedControl[1], new_pos) + self.triggerAction(QSlider.SliderMove) + self.setRepeatAction(QSlider.SliderNoAction) + self.update() + + if self._pressedControl[0] == "handle": + self.setRepeatAction(QSlider.SliderNoAction) # why again? + sr = self._handleRects(opt, self._pressedControl[1]) + self._clickOffset = self._pick(ev.pos() - sr.topLeft()) + self.update() + self.setSliderDown(True) + elif self._pressedControl[0] == "bar": + self._clickOffset = self._pixelPosToRangeValue(self._pick(ev.pos())) + self._sldPosAtPress = tuple(self._position) + + def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None: + # TODO: add pixelMetric(QStyle::PM_MaximumDragDistance, &opt, this); + if self._pressedControl[0] == "handle": + ev.accept() + new = self._pixelPosToRangeValue(self._pick(ev.pos()) - self._clickOffset) + self.setSliderPosition(self._pressedControl[1], new) + elif self._pressedControl[0] == "bar": + ev.accept() + delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos())) + self._offsetAllPositions(delta, self._sldPosAtPress) + else: + ev.ignore() + return + + def _offsetAllPositions(self, offset: int, ref=None) -> None: + if ref is None: + ref = self._position + if self._bar_is_stiff: + _new = [i - offset for i in ref] + # FIXME: if there is an overflow ... it should still hit the edge. + if all(self.minimum() <= i <= self.maximum() for i in _new): + for i, n in enumerate(_new): + self.setSliderPosition(i, n) # TODO: without for loop + else: + for i, n in enumerate(ref): + self.setSliderPosition(i, n - offset) # TODO: without for loop + + def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None: + if self._pressedControl[0] == "None" or ev.buttons(): + ev.ignore() + return + ev.accept() + old_pressed = self._pressedControl + self._pressedControl = self.NULL_CTRL + self.setRepeatAction(QSlider.SliderNoAction) + if old_pressed[0] == "handle": + self.setSliderDown(False) + self.update() # TODO: restrict to the rect of old_pressed + + def triggerAction(self, action: QSlider.SliderAction) -> None: + super().triggerAction(action) # TODO: probably need to override. + self.setValue(self._position) + + def setRange(self, min: int, max: int) -> None: + super().setRange(min, max) + self.setValue(self._value) # re-bound + + def _handleRects(self, opt: QStyleOptionSlider, handle_index: int = None) -> QRect: + """Return the QRect for all handles.""" + style = self.style().proxy() + + if handle_index is not None: # get specific handle rect + opt.sliderPosition = self._position[handle_index] + return style.subControlRect( + QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self + ) + else: + rects = [] + for p in self._position: + opt.sliderPosition = p + r = style.subControlRect( + QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self + ) + rects.append(r) + return rects + + def _grooveRect(self, opt: QStyleOptionSlider) -> QRect: + """Return the QRect for the slider groove.""" + style = self.style().proxy() + return style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderGroove, self) + + def _barRect(self, opt: QStyleOptionSlider, r_groove: QRect = None) -> QRect: + """Return the QRect for the bar between the outer handles.""" + if r_groove is None: + r_groove = self._grooveRect(opt) + r_bar = QRectF(r_groove) + hdl_low, *_, hdl_high = self._handleRects(opt) + + if opt.orientation == Qt.Horizontal: + r_bar.setTop(r_bar.center().y() - self._bar_height / 2) + r_bar.setHeight(self._bar_height) + r_bar.setLeft(hdl_low.center().x()) + r_bar.setRight(hdl_high.center().x()) + else: + r_bar.setLeft(r_bar.center().x() - self._bar_width / 2) + r_bar.setWidth(self._bar_width) + r_bar.setBottom(hdl_low.center().y()) + r_bar.setTop(hdl_high.center().y()) + + return r_bar + + def _getControlAtPos( + self, pos: QPoint, opt: QStyleOptionSlider = None, closest_handle=False + ) -> Control: + """Update self._pressedControl based on ev.pos().""" + if not opt: + opt = self._getStyleOption() + + event_position = self._pick(pos) + bar_idx = 0 + hdl_idx = 0 + dist = float("inf") + + # TODO: this should be reversed, to prefer higher value handles + for i, hdl in enumerate(self._handleRects(opt)): + if pos in hdl: + return ("handle", i) # TODO: use enum for 'handle' + hdl_center = self._pick(hdl.center()) + abs_dist = abs(event_position - hdl_center) + if abs_dist < dist: + dist = abs_dist + hdl_idx = i + if event_position > hdl_center: + bar_idx += 1 + else: + if closest_handle: + if bar_idx == 0: + # the click was below the minimum slider + return ("handle", 0) + elif bar_idx == len(self._position): + # the click was above the maximum slider + return ("handle", len(self._position) - 1) + if self._bar_moves_all: + # the click was in an internal segment + return ("bar", bar_idx) + elif closest_handle: + return ("handle", hdl_idx) + + return self.NULL_CTRL + + def _handle_offset(self, opt: QStyleOptionSlider) -> QPoint: + # to take half of the slider off for the setSliderPosition call we use the + # center - topLeft + handle_rect = self._handleRects(opt, 0) + return handle_rect.center() - handle_rect.topLeft() + + # from QSliderPrivate::pixelPosToRangeValue + def _pixelPosToRangeValue(self, pos: int, opt: QStyleOptionSlider = None) -> int: + if not opt: + opt = self._getStyleOption() + + groove_rect = self._grooveRect(opt) + handle_rect = self._handleRects(opt, 0) + if self.orientation() == Qt.Horizontal: + sliderLength = handle_rect.width() + sliderMin = groove_rect.x() + sliderMax = groove_rect.right() - sliderLength + 1 + else: + sliderLength = handle_rect.height() + sliderMin = groove_rect.y() + sliderMax = groove_rect.bottom() - sliderLength + 1 + return QStyle.sliderValueFromPosition( + self.minimum(), + self.maximum(), + pos - sliderMin, + sliderMax - sliderMin, + opt.upsideDown, + ) + + def _pick(self, pt: QPoint) -> int: + return pt.x() if self.orientation() == Qt.Horizontal else pt.y() + + def _min_max_bound(self, val: int) -> int: + return _bound(self.minimum(), self.maximum(), val) + + def _neighbor_bound(self, val: int, index: int, _lst: List[int]) -> int: + # make sure we don't go lower than any preceding index: + if index > 0: + val = max(_lst[index - 1], val) + # make sure we don't go higher than any following index: + if index < len(_lst) - 1: + val = min(_lst[index + 1], val) + return val + + def wheelEvent(self, e: QtGui.QWheelEvent) -> None: + e.ignore() + vertical = bool(e.angleDelta().y()) + delta = e.angleDelta().y() if vertical else e.angleDelta().x() + if e.inverted(): + delta *= -1 + + orientation = Qt.Vertical if vertical else Qt.Horizontal + if self._scrollByDelta(orientation, e.modifiers(), delta): + e.accept() + + def _scrollByDelta( + self, orientation: Qt.Orientation, modifiers: Qt.KeyboardModifiers, delta: int + ) -> bool: + steps_to_scroll = 0 + pg_step = self.pageStep() + + # in Qt scrolling to the right gives negative values. + if orientation == Qt.Horizontal: + delta *= -1 + offset = delta / 120 + if modifiers & Qt.ControlModifier or modifiers & Qt.ShiftModifier: + # Scroll one page regardless of delta: + steps_to_scroll = _bound(-pg_step, pg_step, int(offset * pg_step)) + self._offset_accum = 0 + else: + # Calculate how many lines to scroll. Depending on what delta is (and + # offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can + # only scroll whole lines, so we keep the reminder until next event. + wheel_scroll_lines = QApplication.wheelScrollLines() + steps_to_scrollF = wheel_scroll_lines * offset * self._effectiveSingleStep() + + # Check if wheel changed direction since last event: + if self._offset_accum != 0 and (offset / self._offset_accum) < 0: + self._offset_accum = 0 + + self._offset_accum += steps_to_scrollF + + # Don't scroll more than one page in any case: + steps_to_scroll = _bound(-pg_step, pg_step, int(self._offset_accum)) + + self._offset_accum -= int(self._offset_accum) + + if steps_to_scroll == 0: + # We moved less than a line, but might still have accumulated partial + # scroll, unless we already are at one of the ends. + effective_offset = self._offset_accum + if self.invertedControls(): + effective_offset *= -1 + if effective_offset > 0 and max(self._value) < self.maximum(): + return True + if effective_offset < 0 and min(self._value) < self.minimum(): + return True + self._offset_accum = 0 + return False + + if self.invertedControls(): + steps_to_scroll *= -1 + + _prev_value = self.value() + + self._offsetAllPositions(-steps_to_scroll) + self.triggerAction(QSlider.SliderMove) + + if _prev_value == self.value(): + self._offset_accum = 0 + return False + return True + + def _effectiveSingleStep(self) -> int: + return self.singleStep() * self._repeatMultiplier + + def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None: + return # TODO + + def _traverseStyleSheet(self): + import re + + qss = self.styleSheet() + p = self + while p.parent(): + qss = p.styleSheet() + qss + p = p.parent() + qss = QApplication.instance().styleSheet() + qss + + # Find bar color + # TODO: optional horizontal or vertical + match = re.search(r"Slider::sub-page[^{]*{\s*([^}]+)}", qss, re.S) + if match: + for line in match.groups()[0].splitlines(): + bgrd = re.search(r"background(-color)?:\s*([^;]+)", line) + if bgrd: + self._bar_color_active = bgrd.groups()[-1] + # TODO: bar color inactive? + # TODO: bar color disabled? + # TODO: block double event? + self.setStyleSheet( + f"{type(self).__name__}::sub-page{{background: none}}" + ) + + # Find bar height/width + for orient, dim in (("horizontal", "height"), ("vertical", "width")): + match = re.search(rf"Slider::groove:{orient}\s*{{\s*([^}}]+)}}", qss, re.S) + if match: + for line in match.groups()[0].splitlines(): + bgrd = re.search(rf"{dim}\s*:\s*(\d+)", line) + if bgrd: + setattr(self, f"_bar_{dim}", float(bgrd.groups()[-1])) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..c6eda50 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,63 @@ +[metadata] +name = PyQRangeSlider +url = https://github.com/tlambert03/PyQRangeSlider +license = BSD-3 +license_file = LICENSE +description = Multi-handle range slider widget for PyQt/PySide +long_description = file: README.md, CHANGELOG.md +long_description_content_type = text/markdown +author = Talley Lambert +author_email = talley.lambert@gmail.com +keywords = qt, range slider, widget +project_urls = + Source = https://github.com/tlambert03/PyQRangeSlider + Tracker = https://github.com/tlambert03/PyQRangeSlider/issues + Changelog = https://github.com/tlambert03/PyQRangeSlider/blob/master/CHANGELOG.md +classifiers = + Development Status :: 4 - Beta + Environment :: X11 Applications :: Qt + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Topic :: Desktop Environment + Topic :: Software Development + Topic :: Software Development :: User Interfaces + Topic :: Software Development :: Widget Sets + +[options] +zip_safe = False +packages = find: +python_requires = >=3.6 +setup_requires = setuptools_scm +install_requires = + qtpy + +[options.extras_require] +pyside2 = pyside2 +pyqt5 = pyqt5 +testing = + tox + tox-conda + pytest + pytest-qt + pytest-cov +dev = + ipython + jedi<0.18.0 + isort + mypy + pre-commit + %(testing)s + %(pyqt5)s + +[flake8] +exclude = _version.py,.eggs,examples +max-line-length = 79 +docstring-convention = numpy +ignore = E203,W503,E501,C901 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1163027 --- /dev/null +++ b/setup.py @@ -0,0 +1,10 @@ +""" +PEP 517 doesn’t support editable installs +so this file is currently here to support "pip install -e ." +""" +from setuptools import setup + +setup( + use_scm_version={"write_to": "qrangeslider/_version.py"}, + setup_requires=["setuptools_scm"], +) \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..1ad9162 --- /dev/null +++ b/tox.ini @@ -0,0 +1,36 @@ +# For more information about tox, see https://tox.readthedocs.io/en/latest/ +[tox] +envlist = py{37,38,39}-{linux,macos,windows}-{pyqt,pyside} + +[gh-actions] +python = + 3.7: py37 + 3.8: py38 + 3.9: py39 + +[gh-actions:env] +PLATFORM = + ubuntu-latest: linux + ubuntu-16.04: linux + ubuntu-18.04: linux + ubuntu-20.04: linux + windows-latest: windows + macos-latest: macos + macos-11.0: macos +BACKEND = + pyqt: pyqt + pyside: pyside + +[testenv] +platform = + macos: darwin + linux: linux + windows: win32 +passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY +deps = + pytest-xvfb ; sys_platform == 'linux' +extras = + testing + pyqt: pyqt5 + pyside: pyside2 +commands = pytest -v --color=yes --cov=qrangeslider --cov-report=xml