initial commit

This commit is contained in:
Talley Lambert
2021-04-24 09:00:08 -04:00
commit 6129ab2304
10 changed files with 816 additions and 0 deletions

78
.github/workflows/test_and_deploy.yml vendored Normal file
View File

@@ -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/*

79
.gitignore vendored Normal file
View File

@@ -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

28
LICENSE Normal file
View File

@@ -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.

5
MANIFEST.in Normal file
View File

@@ -0,0 +1,5 @@
include LICENSE
include README.md
recursive-exclude * __pycache__
recursive-exclude * *.py[co]

38
README.md Normal file
View File

@@ -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

8
qrangeslider/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
try:
from ._version import version as __version__
except ImportError:
__version__ = "unknown"
from ._qrangeslider import QRangeSlider
__all__ = ["QRangeSlider"]

View File

@@ -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]))

63
setup.cfg Normal file
View File

@@ -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

10
setup.py Normal file
View File

@@ -0,0 +1,10 @@
"""
PEP 517 doesnt 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"],
)

36
tox.ini Normal file
View File

@@ -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