mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-12-15 18:50:05 +01:00
initial commit
This commit is contained in:
78
.github/workflows/test_and_deploy.yml
vendored
Normal file
78
.github/workflows/test_and_deploy.yml
vendored
Normal 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
79
.gitignore
vendored
Normal 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
28
LICENSE
Normal 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
5
MANIFEST.in
Normal file
@@ -0,0 +1,5 @@
|
||||
include LICENSE
|
||||
include README.md
|
||||
|
||||
recursive-exclude * __pycache__
|
||||
recursive-exclude * *.py[co]
|
||||
38
README.md
Normal file
38
README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# PyQRangeSlider
|
||||
|
||||
[](https://github.com/tlambert03/PyQRangeSlider/raw/master/LICENSE)
|
||||
[](https://pypi.org/project/PyQRangeSlider)
|
||||
[](https://python.org)
|
||||
[](https://github.com/tlambert03/PyQRangeSlider/actions)
|
||||
[](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
8
qrangeslider/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
try:
|
||||
from ._version import version as __version__
|
||||
except ImportError:
|
||||
__version__ = "unknown"
|
||||
|
||||
from ._qrangeslider import QRangeSlider
|
||||
|
||||
__all__ = ["QRangeSlider"]
|
||||
471
qrangeslider/_qrangeslider.py
Normal file
471
qrangeslider/_qrangeslider.py
Normal 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
63
setup.cfg
Normal 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
10
setup.py
Normal file
@@ -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"],
|
||||
)
|
||||
36
tox.ini
Normal file
36
tox.ini
Normal 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
|
||||
Reference in New Issue
Block a user