├── .coveragerc ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── test_and_deploy.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── codecov.yml ├── examples ├── basic.py ├── basic_float.py ├── demo_widget.py ├── float.py ├── generic.py ├── labeled.py └── multihandle.py ├── images ├── demo_darwin10.png ├── demo_darwin11.png ├── demo_linux.png ├── demo_windows.png ├── labeled_qslider.png ├── labeled_range.png └── slider.png ├── pyproject.toml ├── qtrangeslider ├── __init__.py ├── _generic_range_slider.py ├── _generic_slider.py ├── _labeled.py ├── _range_style.py ├── _sliders.py ├── _tests │ ├── __init__.py │ ├── _testutil.py │ ├── test_float.py │ ├── test_generic_slider.py │ ├── test_range_slider.py │ ├── test_single_value_sliders.py │ └── test_slider.py └── qtcompat │ ├── QtCore.py │ ├── QtGui.py │ ├── QtWidgets.py │ └── __init__.py ├── setup.cfg ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | if TYPE_CHECKING: 5 | \.\.\. 6 | except ImportError* 7 | raise NotImplementedError() 8 | omit = 9 | qtrangeslider/_version.py 10 | qtrangeslider/qtcompat/* 11 | *_tests* 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | Screenshots and GIFS are much appreciated when reporting visual bugs. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS with version [e.g macOS 10.15.7] 28 | - Qt Backend [e.g PyQt5, PySide2] 29 | - Python version 30 | -------------------------------------------------------------------------------- /.github/workflows/test_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | tags: 9 | - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10 10 | pull_request: 11 | branches: 12 | - master 13 | - main 14 | workflow_dispatch: 15 | 16 | jobs: 17 | test: 18 | name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }} 19 | runs-on: ${{ matrix.platform }} 20 | timeout-minutes: 10 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | platform: [ubuntu-latest, windows-latest, macos-latest] 25 | python-version: [3.7, 3.8, 3.9] 26 | backend: [pyqt5, pyside2] 27 | include: 28 | # pyqt6 and pyside6 on latest platforms 29 | - python-version: 3.9 30 | platform: ubuntu-latest 31 | backend: pyside6 32 | screenshot: 1 33 | - python-version: 3.9 34 | platform: windows-latest 35 | backend: pyside6 36 | screenshot: 1 37 | - python-version: 3.9 38 | platform: macos-11.0 39 | backend: pyside6 40 | screenshot: 1 41 | - python-version: 3.9 42 | platform: ubuntu-latest 43 | backend: pyqt6 44 | - python-version: 3.9 45 | platform: windows-latest 46 | backend: pyqt6 47 | - python-version: 3.9 48 | platform: macos-11.0 49 | backend: pyqt6 50 | 51 | # big sur, 3.9 52 | - python-version: 3.9 53 | platform: macos-11.0 54 | backend: pyside2 55 | - python-version: 3.9 56 | platform: macos-11.0 57 | backend: pyqt5 58 | 59 | # legacy OS 60 | - python-version: 3.8 61 | platform: ubuntu-18.04 62 | backend: pyside2 63 | - python-version: 3.6 64 | platform: ubuntu-16.04 65 | backend: pyqt5 66 | - python-version: 3.6 67 | platform: windows-2016 68 | backend: pyqt5 69 | 70 | # legacy Qt 71 | - python-version: 3.7 72 | platform: ubuntu-latest 73 | backend: pyqt511 74 | - python-version: 3.7 75 | platform: ubuntu-latest 76 | backend: pyside511 77 | 78 | steps: 79 | - uses: actions/checkout@v2 80 | 81 | - name: Set up Python ${{ matrix.python-version }} 82 | uses: actions/setup-python@v2 83 | with: 84 | python-version: ${{ matrix.python-version }} 85 | 86 | - name: Install Linux libraries 87 | if: runner.os == 'Linux' 88 | run: | 89 | sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 \ 90 | libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \ 91 | libxcb-xinerama0 libxcb-xfixes0 92 | - name: Linux opengl 93 | if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' ) 94 | run: sudo apt-get install -y libopengl0 libegl1-mesa libxcb-xinput0 95 | 96 | - name: Install dependencies 97 | run: | 98 | python -m pip install --upgrade pip 99 | pip install setuptools tox tox-gh-actions 100 | 101 | - name: Test with tox 102 | run: tox 103 | env: 104 | PLATFORM: ${{ matrix.platform }} 105 | BACKEND: ${{ matrix.backend }} 106 | 107 | - name: Coverage 108 | uses: codecov/codecov-action@v1 109 | 110 | - name: Install for screenshots 111 | if: matrix.screenshot 112 | run: pip install . ${{ matrix.backend }} 113 | 114 | - name: Screenshots 115 | if: runner.os == 'Linux' && matrix.screenshot 116 | uses: GabrielBB/xvfb-action@v1 117 | with: 118 | run: python examples/demo_widget.py -snap 119 | 120 | - name: Screenshots 121 | if: runner.os != 'Linux' && matrix.screenshot 122 | run: python examples/demo_widget.py -snap 123 | 124 | - uses: actions/upload-artifact@v2 125 | if: matrix.screenshot 126 | with: 127 | name: screenshots ${{ runner.os }} 128 | path: screenshots 129 | 130 | 131 | deploy: 132 | # this will run when you have tagged a commit, starting with "v*" 133 | # and requires that you have put your twine API key in your 134 | # github secrets (see readme for details) 135 | needs: [test] 136 | runs-on: ubuntu-latest 137 | if: contains(github.ref, 'tags') 138 | steps: 139 | - uses: actions/checkout@v2 140 | - name: Set up Python 141 | uses: actions/setup-python@v2 142 | with: 143 | python-version: "3.x" 144 | - name: Install dependencies 145 | run: | 146 | python -m pip install --upgrade pip 147 | pip install -U setuptools setuptools_scm wheel twine 148 | - name: Build and publish 149 | env: 150 | TWINE_USERNAME: __token__ 151 | TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} 152 | run: | 153 | git tag 154 | python setup.py sdist bdist_wheel 155 | twine upload dist/* 156 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | .napari_cache 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask instance folder 58 | instance/ 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # MkDocs documentation 64 | /site/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # OS 76 | .DS_Store 77 | 78 | # written by setuptools_scm 79 | */_version.py 80 | .vscode/settings.json 81 | screenshots 82 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.0.1 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - repo: https://github.com/PyCQA/isort 8 | rev: 5.9.1 9 | hooks: 10 | - id: isort 11 | - repo: https://github.com/asottile/pyupgrade 12 | rev: v2.19.4 13 | hooks: 14 | - id: pyupgrade 15 | - repo: https://github.com/psf/black 16 | rev: 21.6b0 17 | hooks: 18 | - id: black 19 | - repo: https://github.com/PyCQA/flake8 20 | rev: 3.9.2 21 | hooks: 22 | - id: flake8 23 | pass_filenames: true 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright (c) 2021, Talley Lambert 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of QtRangeSlider nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[co] 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 🔒 archived 🔒 3 | 4 | The content of this package has moved to [`superqt`](https://github.com/napari/superqt). 5 | 6 | ```sh 7 | pip install superqt 8 | ``` 9 | 10 | The last version of qtrangeslider (v0.1.5) will remain on pypi, but you are encouraged 11 | to use superqt instead. 12 | 13 | ```python 14 | # now use: 15 | from superqt import QRangeSlider, QLabeledSlider # etc... 16 | ``` 17 | 18 | ## QtRangeSlider (archived) 19 | 20 | **The missing multi-handle range slider widget for PyQt & PySide** 21 | 22 | ![slider](images/slider.png) 23 | 24 | The goal of this package is to provide a Range Slider (a slider with 2 or more 25 | handles) that feels as "native" as possible. Styles should match the OS by 26 | default, and the slider should behave like a standard 27 | [`QSlider`](https://doc.qt.io/qt-5/qslider.html)... but with multiple handles! 28 | 29 | - `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html) 30 | and attempts to match the Qt API as closely as possible 31 | - Uses platform-specific styles (for handle, groove, & ticks) but also supports 32 | QSS style sheets. 33 | - Supports mouse wheel and keypress (soon) events 34 | - Supports PyQt5, PyQt6, PySide2 and PySide6 35 | - Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`) 36 | 37 | 38 | ------ 39 | 40 | ## API 41 | 42 | To create a slider: 43 | 44 | ```python 45 | from qtrangeslider import QRangeSlider 46 | 47 | # as usual: 48 | # you must create a QApplication before create a widget. 49 | range_slider = QRangeSlider() 50 | ``` 51 | 52 | As `QRangeSlider` inherits from `QtWidgets.QSlider`, you can use all of the 53 | same methods available in the [QSlider API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value` and `sliderPosition` are reimplemented as `tuples` of `int` (where the length of the tuple is equal to the number of handles in the slider.) 54 | 55 | ### `value: Tuple[int, ...]` 56 | 57 | This property holds the current value of all handles in the slider. 58 | 59 | The slider forces all values to be within the legal range: 60 | `minimum <= value <= maximum`. 61 | 62 | Changing the value also changes the sliderPosition. 63 | 64 | ##### Access Functions: 65 | 66 | ```python 67 | range_slider.value() -> Tuple[int, ...] 68 | ``` 69 | 70 | ```python 71 | range_slider.setValue(val: Sequence[int]) -> None 72 | ``` 73 | 74 | ##### Notifier Signal: 75 | 76 | ```python 77 | valueChanged(Tuple[int, ...]) 78 | ``` 79 | 80 | ### `sliderPosition: Tuple[int, ...]` 81 | 82 | This property holds the current slider positions. It is a `tuple` with length equal to the number of handles. 83 | 84 | If [tracking](https://doc.qt.io/qt-5/qabstractslider.html#tracking-prop) is enabled (the default), this is identical to [`value`](#value--tupleint-). 85 | 86 | ##### Access Functions: 87 | 88 | ```python 89 | range_slider.sliderPosition() -> Tuple[int, ...] 90 | ``` 91 | 92 | ```python 93 | range_slider.setSliderPosition(val: Sequence[int]) -> None 94 | ``` 95 | 96 | ##### Notifier Signal: 97 | 98 | ```python 99 | sliderMoved(Tuple[int, ...]) 100 | ``` 101 | 102 | ### Additional properties 103 | 104 | These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles. 105 | 106 | | getter | setter | type | default | description | 107 | | -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ | 108 | | `barIsVisible` | `setBarIsVisible`
`hideBar` / `showBar` | `bool` | `True` | Whether the bar between handles is visible. | 109 | | `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | Whether clicking on the bar moves all handles or just the nearest | 110 | | `barIsRigid` | `setBarIsRigid` | `bool` | `True` | Whether bar length is constant or "elastic" when dragging the bar beyond min/max. | 111 | ------ 112 | 113 | ## Examples 114 | 115 | These screenshots show `QRangeSlider` (multiple handles) next to the native `QSlider` 116 | (single handle). With no styles applied, `QRangeSlider` will match the native OS 117 | style of `QSlider` – with or without tick marks. When styles have been applied 118 | using [Qt Style Sheets](https://doc.qt.io/qt-5/stylesheet-reference.html), then 119 | `QRangeSlider` will inherit any styles applied to `QSlider` (since it inherits 120 | from QSlider). If you'd like to style `QRangeSlider` differently than `QSlider`, 121 | then you can also target it directly in your style sheet. The one "special" 122 | property for QRangeSlider is `qproperty-barColor`, which sets the color of the 123 | bar between the handles. 124 | 125 | > The code for these example widgets is [here](examples/demo_widget.py) 126 | 127 |
128 | 129 | See style sheet used for this example 130 | 131 | ```css 132 | /* 133 | Because QRangeSlider inherits from QSlider, it will also inherit styles 134 | */ 135 | QSlider { 136 | min-height: 20px; 137 | } 138 | 139 | QSlider::groove:horizontal { 140 | border: 0px; 141 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, 142 | stop:0 #777, stop:1 #aaa); 143 | height: 20px; 144 | border-radius: 10px; 145 | } 146 | 147 | QSlider::handle { 148 | background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.5, 149 | fy:0.5, stop:0 #eef, stop:1 #000); 150 | height: 20px; 151 | width: 20px; 152 | border-radius: 10px; 153 | } 154 | 155 | /* 156 | "QSlider::sub-page" is the one exception ... 157 | (it styles the area to the left of the QSlider handle) 158 | */ 159 | QSlider::sub-page:horizontal { 160 | background: #447; 161 | border-top-left-radius: 10px; 162 | border-bottom-left-radius: 10px; 163 | } 164 | 165 | /* 166 | for QRangeSlider: use "qproperty-barColor". "sub-page" will not work. 167 | */ 168 | QRangeSlider { 169 | qproperty-barColor: #447; 170 | } 171 | ``` 172 | 173 |
174 | 175 | ### macOS 176 | 177 | ##### Catalina 178 | ![mac10](images/demo_darwin10.png) 179 | 180 | ##### Big Sur 181 | ![mac11](images/demo_darwin11.png) 182 | 183 | ### Windows 184 | 185 | ![window](images/demo_windows.png) 186 | 187 | ### Linux 188 | 189 | ![linux](images/demo_linux.png) 190 | 191 | 192 | ## Labeled Sliders 193 | 194 | This package also includes two "labeled" slider variants. One for `QRangeSlider`, and one for the native `QSlider`: 195 | 196 | ### `QLabeledRangeSlider` 197 | 198 | ![labeled_range](images/labeled_range.png) 199 | 200 | ```python 201 | from qtrangeslider import QLabeledRangeSlider 202 | ``` 203 | 204 | This has the same API as `QRangeSlider` with the following additional options: 205 | 206 | #### `handleLabelPosition`/`setHandleLabelPosition` 207 | 208 | Where/whether labels are shown adjacent to slider handles. 209 | 210 | **type:** `QLabeledRangeSlider.LabelPosition` 211 | 212 | **default:** `LabelPosition.LabelsAbove` 213 | 214 | *options:* 215 | 216 | - `LabelPosition.NoLabel` (no labels shown adjacent to handles) 217 | - `LabelPosition.LabelsAbove` 218 | - `LabelPosition.LabelsBelow` 219 | - `LabelPosition.LabelsRight` (alias for `LabelPosition.LabelsAbove`) 220 | - `LabelPosition.LabelsLeft` (alias for `LabelPosition.LabelsBelow`) 221 | 222 | 223 | #### `edgeLabelMode`/`setEdgeLabelMode` 224 | 225 | **type:** `QLabeledRangeSlider.EdgeLabelMode` 226 | 227 | **default:** `EdgeLabelMode.LabelIsRange` 228 | 229 | *options:* 230 | 231 | - `EdgeLabelMode.NoLabel`: no labels shown at slider extremes 232 | - `EdgeLabelMode.LabelIsRange`: edge labels shown the min/max values 233 | - `EdgeLabelMode.LabelIsValue`: edge labels shown the slider range 234 | 235 | 236 | #### fine tuning position of labels: 237 | 238 | If you find that you need to fine tune the position of the handle labels: 239 | 240 | - `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position 241 | - `QLabeledRangeSlider.label_shift_y`: adjust vertical label position 242 | 243 | ### `QLabeledSlider` 244 | 245 | 246 | ![labeled_range](images/labeled_qslider.png) 247 | 248 | ```python 249 | from qtrangeslider import QLabeledSlider 250 | ``` 251 | 252 | (no additional options at this point) 253 | 254 | ## Issues 255 | 256 | If you encounter any problems, please [file an issue] along with a detailed 257 | description. 258 | 259 | [file an issue]: https://github.com/tlambert03/QtRangeSlider/issues 260 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - qtrangeslider/_version.py 3 | - qtrangeslider/qtcompat/* 4 | - '*_tests*' 5 | coverage: 6 | status: 7 | project: 8 | default: 9 | target: auto 10 | threshold: 1% # PR will fail if it drops coverage on the project by >1% 11 | patch: 12 | default: 13 | target: auto 14 | threshold: 40% # A given PR will fail if >40% is untested 15 | comment: 16 | require_changes: true # if true: only post the PR comment if coverage changes 17 | -------------------------------------------------------------------------------- /examples/basic.py: -------------------------------------------------------------------------------- 1 | from qtrangeslider import QRangeSlider 2 | from qtrangeslider.qtcompat.QtCore import Qt 3 | from qtrangeslider.qtcompat.QtWidgets import QApplication 4 | 5 | app = QApplication([]) 6 | 7 | slider = QRangeSlider(Qt.Horizontal) 8 | slider = QRangeSlider(Qt.Horizontal) 9 | 10 | slider.setValue((20, 80)) 11 | slider.show() 12 | 13 | app.exec_() 14 | -------------------------------------------------------------------------------- /examples/basic_float.py: -------------------------------------------------------------------------------- 1 | from qtrangeslider import QDoubleSlider 2 | from qtrangeslider.qtcompat.QtCore import Qt 3 | from qtrangeslider.qtcompat.QtWidgets import QApplication 4 | 5 | app = QApplication([]) 6 | 7 | slider = QDoubleSlider(Qt.Horizontal) 8 | slider.setRange(0, 1) 9 | slider.setValue(0.5) 10 | slider.show() 11 | 12 | app.exec_() 13 | -------------------------------------------------------------------------------- /examples/demo_widget.py: -------------------------------------------------------------------------------- 1 | from qtrangeslider import QRangeSlider 2 | from qtrangeslider.qtcompat import QtCore 3 | from qtrangeslider.qtcompat import QtWidgets as QtW 4 | 5 | QSS = """ 6 | QSlider { 7 | min-height: 20px; 8 | } 9 | 10 | QSlider::groove:horizontal { 11 | border: 0px; 12 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd); 13 | height: 20px; 14 | border-radius: 10px; 15 | } 16 | 17 | QSlider::handle { 18 | background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35, 19 | fy:0.3, stop:0 #eef, stop:1 #002); 20 | height: 20px; 21 | width: 20px; 22 | border-radius: 10px; 23 | } 24 | 25 | QSlider::sub-page:horizontal { 26 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a); 27 | border-top-left-radius: 10px; 28 | border-bottom-left-radius: 10px; 29 | } 30 | 31 | QRangeSlider { 32 | qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a); 33 | } 34 | """ 35 | 36 | 37 | class DemoWidget(QtW.QWidget): 38 | def __init__(self) -> None: 39 | super().__init__() 40 | 41 | reg_hslider = QtW.QSlider(QtCore.Qt.Horizontal) 42 | reg_hslider.setValue(50) 43 | range_hslider = QRangeSlider(QtCore.Qt.Horizontal) 44 | range_hslider.setValue((20, 80)) 45 | multi_range_hslider = QRangeSlider(QtCore.Qt.Horizontal) 46 | multi_range_hslider.setValue((11, 33, 66, 88)) 47 | multi_range_hslider.setTickPosition(QtW.QSlider.TicksAbove) 48 | 49 | styled_reg_hslider = QtW.QSlider(QtCore.Qt.Horizontal) 50 | styled_reg_hslider.setValue(50) 51 | styled_reg_hslider.setStyleSheet(QSS) 52 | styled_range_hslider = QRangeSlider(QtCore.Qt.Horizontal) 53 | styled_range_hslider.setValue((20, 80)) 54 | styled_range_hslider.setStyleSheet(QSS) 55 | 56 | reg_vslider = QtW.QSlider(QtCore.Qt.Vertical) 57 | reg_vslider.setValue(50) 58 | range_vslider = QRangeSlider(QtCore.Qt.Vertical) 59 | range_vslider.setValue((22, 77)) 60 | 61 | tick_vslider = QtW.QSlider(QtCore.Qt.Vertical) 62 | tick_vslider.setValue(55) 63 | tick_vslider.setTickPosition(QtW.QSlider.TicksRight) 64 | range_tick_vslider = QRangeSlider(QtCore.Qt.Vertical) 65 | range_tick_vslider.setValue((22, 77)) 66 | range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft) 67 | 68 | szp = QtW.QSizePolicy.Maximum 69 | left = QtW.QWidget() 70 | left.setLayout(QtW.QVBoxLayout()) 71 | left.setContentsMargins(2, 2, 2, 2) 72 | label1 = QtW.QLabel("Regular QSlider Unstyled") 73 | label2 = QtW.QLabel("QRangeSliders Unstyled") 74 | label3 = QtW.QLabel("Styled Sliders (using same stylesheet)") 75 | label1.setSizePolicy(szp, szp) 76 | label2.setSizePolicy(szp, szp) 77 | label3.setSizePolicy(szp, szp) 78 | left.layout().addWidget(label1) 79 | left.layout().addWidget(reg_hslider) 80 | left.layout().addWidget(label2) 81 | left.layout().addWidget(range_hslider) 82 | left.layout().addWidget(multi_range_hslider) 83 | left.layout().addWidget(label3) 84 | left.layout().addWidget(styled_reg_hslider) 85 | left.layout().addWidget(styled_range_hslider) 86 | 87 | right = QtW.QWidget() 88 | right.setLayout(QtW.QHBoxLayout()) 89 | right.setContentsMargins(15, 5, 5, 0) 90 | right.layout().setSpacing(30) 91 | right.layout().addWidget(reg_vslider) 92 | right.layout().addWidget(range_vslider) 93 | right.layout().addWidget(tick_vslider) 94 | right.layout().addWidget(range_tick_vslider) 95 | 96 | self.setLayout(QtW.QHBoxLayout()) 97 | self.layout().addWidget(left) 98 | self.layout().addWidget(right) 99 | self.setGeometry(600, 300, 580, 300) 100 | self.activateWindow() 101 | self.show() 102 | 103 | 104 | if __name__ == "__main__": 105 | 106 | import sys 107 | from pathlib import Path 108 | 109 | dest = Path("screenshots") 110 | dest.mkdir(exist_ok=True) 111 | 112 | app = QtW.QApplication([]) 113 | demo = DemoWidget() 114 | 115 | if "-snap" in sys.argv: 116 | import platform 117 | 118 | QtW.QApplication.processEvents() 119 | demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png")) 120 | else: 121 | app.exec_() 122 | -------------------------------------------------------------------------------- /examples/float.py: -------------------------------------------------------------------------------- 1 | from qtrangeslider import QDoubleRangeSlider, QDoubleSlider, QRangeSlider 2 | from qtrangeslider.qtcompat.QtCore import Qt 3 | from qtrangeslider.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget 4 | 5 | app = QApplication([]) 6 | 7 | w = QWidget() 8 | 9 | sld1 = QDoubleSlider(Qt.Horizontal) 10 | sld2 = QDoubleRangeSlider(Qt.Horizontal) 11 | rs = QRangeSlider(Qt.Horizontal) 12 | 13 | sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e)) 14 | 15 | sld2.setMaximum(1) 16 | sld2.setValue((0.2, 0.8)) 17 | sld2.valueChanged.connect(lambda e: print("valueChanged", e)) 18 | sld2.sliderMoved.connect(lambda e: print("sliderMoved", e)) 19 | sld2.rangeChanged.connect(lambda e, f: print("rangeChanged", (e, f))) 20 | 21 | w.setLayout(QVBoxLayout()) 22 | w.layout().addWidget(sld1) 23 | w.layout().addWidget(sld2) 24 | w.layout().addWidget(rs) 25 | w.show() 26 | w.resize(500, 150) 27 | app.exec_() 28 | -------------------------------------------------------------------------------- /examples/generic.py: -------------------------------------------------------------------------------- 1 | from qtrangeslider import QDoubleSlider 2 | from qtrangeslider.qtcompat.QtCore import Qt 3 | from qtrangeslider.qtcompat.QtWidgets import QApplication 4 | 5 | app = QApplication([]) 6 | 7 | sld = QDoubleSlider(Qt.Horizontal) 8 | sld.setRange(0, 1) 9 | sld.setValue(0.5) 10 | sld.show() 11 | 12 | app.exec_() 13 | -------------------------------------------------------------------------------- /examples/labeled.py: -------------------------------------------------------------------------------- 1 | from qtrangeslider._labeled import ( 2 | QLabeledDoubleRangeSlider, 3 | QLabeledDoubleSlider, 4 | QLabeledRangeSlider, 5 | QLabeledSlider, 6 | ) 7 | from qtrangeslider.qtcompat.QtCore import Qt 8 | from qtrangeslider.qtcompat.QtWidgets import ( 9 | QApplication, 10 | QHBoxLayout, 11 | QVBoxLayout, 12 | QWidget, 13 | ) 14 | 15 | app = QApplication([]) 16 | 17 | ORIENTATION = Qt.Horizontal 18 | 19 | w = QWidget() 20 | qls = QLabeledSlider(ORIENTATION) 21 | qls.valueChanged.connect(lambda e: print("qls valueChanged", e)) 22 | qls.setRange(0, 500) 23 | qls.setValue(300) 24 | 25 | 26 | qlds = QLabeledDoubleSlider(ORIENTATION) 27 | qlds.valueChanged.connect(lambda e: print("qlds valueChanged", e)) 28 | qlds.setRange(0, 1) 29 | qlds.setValue(0.5) 30 | qlds.setSingleStep(0.1) 31 | 32 | qlrs = QLabeledRangeSlider(ORIENTATION) 33 | qlrs.valueChanged.connect(lambda e: print("QLabeledRangeSlider valueChanged", e)) 34 | qlrs.setValue((20, 60)) 35 | 36 | qldrs = QLabeledDoubleRangeSlider(ORIENTATION) 37 | qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e)) 38 | qldrs.setRange(0, 1) 39 | qldrs.setSingleStep(0.01) 40 | qldrs.setValue((0.2, 0.7)) 41 | 42 | 43 | w.setLayout(QVBoxLayout() if ORIENTATION == Qt.Horizontal else QHBoxLayout()) 44 | w.layout().addWidget(qls) 45 | w.layout().addWidget(qlds) 46 | w.layout().addWidget(qlrs) 47 | w.layout().addWidget(qldrs) 48 | w.show() 49 | w.resize(500, 150) 50 | app.exec_() 51 | -------------------------------------------------------------------------------- /examples/multihandle.py: -------------------------------------------------------------------------------- 1 | from qtrangeslider import QRangeSlider 2 | from qtrangeslider.qtcompat.QtWidgets import QApplication 3 | 4 | app = QApplication([]) 5 | 6 | slider = QRangeSlider() 7 | slider.setMinimum(0) 8 | slider.setMaximum(200) 9 | slider.setValue((0, 40, 80, 160)) 10 | slider.show() 11 | 12 | app.exec_() 13 | -------------------------------------------------------------------------------- /images/demo_darwin10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlambert03/QtRangeSlider/8267bdb2d9e096af95dde2759ab27b615ff515b2/images/demo_darwin10.png -------------------------------------------------------------------------------- /images/demo_darwin11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlambert03/QtRangeSlider/8267bdb2d9e096af95dde2759ab27b615ff515b2/images/demo_darwin11.png -------------------------------------------------------------------------------- /images/demo_linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlambert03/QtRangeSlider/8267bdb2d9e096af95dde2759ab27b615ff515b2/images/demo_linux.png -------------------------------------------------------------------------------- /images/demo_windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlambert03/QtRangeSlider/8267bdb2d9e096af95dde2759ab27b615ff515b2/images/demo_windows.png -------------------------------------------------------------------------------- /images/labeled_qslider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlambert03/QtRangeSlider/8267bdb2d9e096af95dde2759ab27b615ff515b2/images/labeled_qslider.png -------------------------------------------------------------------------------- /images/labeled_range.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlambert03/QtRangeSlider/8267bdb2d9e096af95dde2759ab27b615ff515b2/images/labeled_range.png -------------------------------------------------------------------------------- /images/slider.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlambert03/QtRangeSlider/8267bdb2d9e096af95dde2759ab27b615ff515b2/images/slider.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # pyproject.toml 2 | [build-system] 3 | requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.0"] 4 | -------------------------------------------------------------------------------- /qtrangeslider/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._version import version as __version__ 3 | except ImportError: 4 | __version__ = "unknown" 5 | 6 | from ._labeled import ( 7 | QLabeledDoubleRangeSlider, 8 | QLabeledDoubleSlider, 9 | QLabeledRangeSlider, 10 | QLabeledSlider, 11 | ) 12 | from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider 13 | 14 | __all__ = [ 15 | "QDoubleRangeSlider", 16 | "QDoubleSlider", 17 | "QLabeledDoubleRangeSlider", 18 | "QLabeledDoubleSlider", 19 | "QLabeledRangeSlider", 20 | "QLabeledSlider", 21 | "QRangeSlider", 22 | ] 23 | -------------------------------------------------------------------------------- /qtrangeslider/_generic_range_slider.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, List, Sequence, Tuple, TypeVar, Union 2 | 3 | from ._generic_slider import CC_SLIDER, SC_GROOVE, SC_HANDLE, SC_NONE, _GenericSlider 4 | from ._range_style import RangeSliderStyle, update_styles_from_stylesheet 5 | from .qtcompat import QtGui 6 | from .qtcompat.QtCore import ( 7 | Property, 8 | QEvent, 9 | QPoint, 10 | QPointF, 11 | QRect, 12 | QRectF, 13 | Qt, 14 | Signal, 15 | ) 16 | from .qtcompat.QtWidgets import QSlider, QStyle, QStyleOptionSlider, QStylePainter 17 | 18 | _T = TypeVar("_T") 19 | 20 | 21 | SC_BAR = QStyle.SubControl.SC_ScrollBarSubPage 22 | 23 | 24 | class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]): 25 | """MultiHandle Range Slider widget. 26 | 27 | Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and 28 | `setSliderPosition` are all sequences of integers. 29 | 30 | The `valueChanged` and `sliderMoved` signals also both emit a tuple of 31 | integers. 32 | """ 33 | 34 | # Emitted when the slider value has changed, with the new slider values 35 | valueChanged = Signal(tuple) 36 | 37 | # Emitted when sliderDown is true and the slider moves 38 | # This usually happens when the user is dragging the slider 39 | # The value is the positions of *all* handles. 40 | sliderMoved = Signal(tuple) 41 | 42 | def __init__(self, *args, **kwargs): 43 | super().__init__(*args, **kwargs) 44 | 45 | # list of values 46 | self._value: List[_T] = [20, 80] 47 | 48 | # list of current positions of each handle. same length as _value 49 | # If tracking is enabled (the default) this will be identical to _value 50 | self._position: List[_T] = [20, 80] 51 | 52 | # which handle is being pressed/hovered 53 | self._pressedIndex = 0 54 | self._hoverIndex = 0 55 | 56 | # whether bar length is constant when dragging the bar 57 | # if False, the bar can shorten when dragged beyond min/max 58 | self._bar_is_rigid = True 59 | # whether clicking on the bar moves all handles, or just the nearest handle 60 | self._bar_moves_all = True 61 | self._should_draw_bar = True 62 | 63 | # color 64 | 65 | self._style = RangeSliderStyle() 66 | self.setStyleSheet("") 67 | update_styles_from_stylesheet(self) 68 | 69 | # ############### New Public API ####################### 70 | 71 | def barIsRigid(self) -> bool: 72 | """Whether bar length is constant when dragging the bar. 73 | 74 | If False, the bar can shorten when dragged beyond min/max. Default is True. 75 | """ 76 | return self._bar_is_rigid 77 | 78 | def setBarIsRigid(self, val: bool = True) -> None: 79 | """Whether bar length is constant when dragging the bar. 80 | 81 | If False, the bar can shorten when dragged beyond min/max. Default is True. 82 | """ 83 | self._bar_is_rigid = bool(val) 84 | 85 | def barMovesAllHandles(self) -> bool: 86 | """Whether clicking on the bar moves all handles (default), or just the nearest.""" 87 | return self._bar_moves_all 88 | 89 | def setBarMovesAllHandles(self, val: bool = True) -> None: 90 | """Whether clicking on the bar moves all handles (default), or just the nearest.""" 91 | self._bar_moves_all = bool(val) 92 | 93 | def barIsVisible(self) -> bool: 94 | """Whether to show the bar between the first and last handle.""" 95 | return self._should_draw_bar 96 | 97 | def setBarVisible(self, val: bool = True) -> None: 98 | """Whether to show the bar between the first and last handle.""" 99 | self._should_draw_bar = bool(val) 100 | 101 | def hideBar(self) -> None: 102 | self.setBarVisible(False) 103 | 104 | def showBar(self) -> None: 105 | self.setBarVisible(True) 106 | 107 | # ############### QtOverrides ####################### 108 | 109 | def value(self) -> Tuple[_T, ...]: 110 | """Get current value of the widget as a tuple of integers.""" 111 | return tuple(self._value) 112 | 113 | def sliderPosition(self): 114 | """Get current value of the widget as a tuple of integers. 115 | 116 | If tracking is enabled (the default) this will be identical to value(). 117 | """ 118 | return tuple(float(i) for i in self._position) 119 | 120 | def setSliderPosition(self, pos: Union[float, Sequence[float]], index=None) -> None: 121 | """Set current position of the handles with a sequence of integers. 122 | 123 | If `pos` is a sequence, it must have the same length as `value()`. 124 | If it is a scalar, index will be 125 | """ 126 | if isinstance(pos, (list, tuple)): 127 | val_len = len(self.value()) 128 | if len(pos) != val_len: 129 | msg = f"'sliderPosition' must have same length as 'value()' ({val_len})" 130 | raise ValueError(msg) 131 | pairs = list(enumerate(pos)) 132 | else: 133 | pairs = [(self._pressedIndex if index is None else index, pos)] 134 | 135 | for idx, position in pairs: 136 | self._position[idx] = self._bound(position, idx) 137 | 138 | self._doSliderMove() 139 | 140 | def setStyleSheet(self, styleSheet: str) -> None: 141 | # sub-page styles render on top of the lower sliders and don't work here. 142 | override = f""" 143 | \n{type(self).__name__}::sub-page:horizontal {{background: none}} 144 | \n{type(self).__name__}::sub-page:vertical {{background: none}} 145 | """ 146 | return super().setStyleSheet(styleSheet + override) 147 | 148 | def event(self, ev: QEvent) -> bool: 149 | if ev.type() == QEvent.StyleChange: 150 | update_styles_from_stylesheet(self) 151 | return super().event(ev) 152 | 153 | def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None: 154 | if self._pressedControl == SC_BAR: 155 | ev.accept() 156 | delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos())) 157 | self._offsetAllPositions(-delta, self._sldPosAtPress) 158 | else: 159 | super().mouseMoveEvent(ev) 160 | 161 | # ############### Implementation Details ####################### 162 | 163 | def _setPosition(self, val): 164 | self._position = list(val) 165 | 166 | def _bound(self, value, index=None): 167 | if isinstance(value, (list, tuple)): 168 | return type(value)(self._bound(v) for v in value) 169 | pos = super()._bound(value) 170 | if index is not None: 171 | pos = self._neighbor_bound(pos, index) 172 | return self._type_cast(pos) 173 | 174 | def _neighbor_bound(self, val, index): 175 | # make sure we don't go lower than any preceding index: 176 | min_dist = self.singleStep() 177 | _lst = self._position 178 | if index > 0: 179 | val = max(_lst[index - 1] + min_dist, val) 180 | # make sure we don't go higher than any following index: 181 | if index < (len(_lst) - 1): 182 | val = min(_lst[index + 1] - min_dist, val) 183 | return val 184 | 185 | def _getBarColor(self): 186 | return self._style.brush(self._styleOption) 187 | 188 | def _setBarColor(self, color): 189 | self._style.brush_active = color 190 | 191 | barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor) 192 | 193 | def _offsetAllPositions(self, offset: float, ref=None) -> None: 194 | if ref is None: 195 | ref = self._position 196 | if self._bar_is_rigid: 197 | # NOTE: This assumes monotonically increasing slider positions 198 | if offset > 0 and ref[-1] + offset > self.maximum(): 199 | offset = self.maximum() - ref[-1] 200 | elif ref[0] + offset < self.minimum(): 201 | offset = self.minimum() - ref[0] 202 | self.setSliderPosition([i + offset for i in ref]) 203 | 204 | def _fixStyleOption(self, option): 205 | pass 206 | 207 | @property 208 | def _optSliderPositions(self): 209 | return [self._to_qinteger_space(p - self._minimum) for p in self._position] 210 | 211 | # SubControl Positions 212 | 213 | def _handleRect(self, handle_index: int, opt: QStyleOptionSlider = None) -> QRect: 214 | """Return the QRect for all handles.""" 215 | opt = opt or self._styleOption 216 | opt.sliderPosition = self._optSliderPositions[handle_index] 217 | return self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self) 218 | 219 | def _barRect(self, opt: QStyleOptionSlider) -> QRect: 220 | """Return the QRect for the bar between the outer handles.""" 221 | r_groove = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self) 222 | r_bar = QRectF(r_groove) 223 | hdl_low, hdl_high = self._handleRect(0, opt), self._handleRect(-1, opt) 224 | 225 | thickness = self._style.thickness(opt) 226 | offset = self._style.offset(opt) 227 | 228 | if opt.orientation == Qt.Horizontal: 229 | r_bar.setTop(r_bar.center().y() - thickness / 2 + offset) 230 | r_bar.setHeight(thickness) 231 | r_bar.setLeft(hdl_low.center().x()) 232 | r_bar.setRight(hdl_high.center().x()) 233 | else: 234 | r_bar.setLeft(r_bar.center().x() - thickness / 2 + offset) 235 | r_bar.setWidth(thickness) 236 | r_bar.setBottom(hdl_low.center().y()) 237 | r_bar.setTop(hdl_high.center().y()) 238 | 239 | return r_bar 240 | 241 | # Painting 242 | 243 | def _drawBar(self, painter: QStylePainter, opt: QStyleOptionSlider): 244 | brush = self._style.brush(opt) 245 | r_bar = self._barRect(opt) 246 | if isinstance(brush, QtGui.QGradient): 247 | brush.setStart(r_bar.topLeft()) 248 | brush.setFinalStop(r_bar.bottomRight()) 249 | painter.setPen(self._style.pen(opt)) 250 | painter.setBrush(brush) 251 | painter.drawRect(r_bar) 252 | 253 | def _draw_handle(self, painter: QStylePainter, opt: QStyleOptionSlider): 254 | if self._should_draw_bar: 255 | self._drawBar(painter, opt) 256 | 257 | opt.subControls = SC_HANDLE 258 | pidx = self._pressedIndex if self._pressedControl == SC_HANDLE else -1 259 | hidx = self._hoverIndex if self._hoverControl == SC_HANDLE else -1 260 | for idx, pos in enumerate(self._optSliderPositions): 261 | opt.sliderPosition = pos 262 | # make pressed handles appear sunken 263 | if idx == pidx: 264 | opt.state |= QStyle.State_Sunken 265 | else: 266 | opt.state = opt.state & ~QStyle.State_Sunken 267 | opt.activeSubControls = SC_HANDLE if idx == hidx else SC_NONE 268 | painter.drawComplexControl(CC_SLIDER, opt) 269 | 270 | def _updateHoverControl(self, pos): 271 | old_hover = self._hoverControl, self._hoverIndex 272 | self._hoverControl, self._hoverIndex = self._getControlAtPos(pos) 273 | if (self._hoverControl, self._hoverIndex) != old_hover: 274 | self.update() 275 | 276 | def _updatePressedControl(self, pos): 277 | opt = self._styleOption 278 | self._pressedControl, self._pressedIndex = self._getControlAtPos(pos, opt) 279 | 280 | def _setClickOffset(self, pos): 281 | if self._pressedControl == SC_BAR: 282 | self._clickOffset = self._pixelPosToRangeValue(self._pick(pos)) 283 | self._sldPosAtPress = tuple(self._position) 284 | elif self._pressedControl == SC_HANDLE: 285 | hr = self._handleRect(self._pressedIndex) 286 | self._clickOffset = self._pick(pos - hr.topLeft()) 287 | 288 | # NOTE: this is very much tied to mousepress... not a generic "get control" 289 | def _getControlAtPos( 290 | self, pos: QPoint, opt: QStyleOptionSlider = None 291 | ) -> Tuple[QStyle.SubControl, int]: 292 | """Update self._pressedControl based on ev.pos().""" 293 | opt = opt or self._styleOption 294 | 295 | if isinstance(pos, QPointF): 296 | pos = pos.toPoint() 297 | 298 | for i in range(len(self._position)): 299 | if self._handleRect(i, opt).contains(pos): 300 | return (SC_HANDLE, i) 301 | 302 | click_pos = self._pixelPosToRangeValue(self._pick(pos)) 303 | for i, p in enumerate(self._position): 304 | if p > click_pos: 305 | if i > 0: 306 | # the click was in an internal segment 307 | if self._bar_moves_all: 308 | return (SC_BAR, i) 309 | avg = (self._position[i - 1] + self._position[i]) / 2 310 | return (SC_HANDLE, i - 1 if click_pos < avg else i) 311 | # the click was below the minimum slider 312 | return (SC_HANDLE, 0) 313 | # the click was above the maximum slider 314 | return (SC_HANDLE, len(self._position) - 1) 315 | 316 | def _execute_scroll(self, steps_to_scroll, modifiers): 317 | if modifiers & Qt.AltModifier: 318 | self._spreadAllPositions(shrink=steps_to_scroll < 0) 319 | else: 320 | self._offsetAllPositions(steps_to_scroll) 321 | self.triggerAction(QSlider.SliderMove) 322 | 323 | def _has_scroll_space_left(self, offset): 324 | return (offset > 0 and max(self._value) < self._maximum) or ( 325 | offset < 0 and min(self._value) < self._minimum 326 | ) 327 | 328 | def _spreadAllPositions(self, shrink=False, gain=1.1, ref=None) -> None: 329 | if ref is None: 330 | ref = self._position 331 | # if self._bar_is_rigid: # TODO 332 | 333 | if shrink: 334 | gain = 1 / gain 335 | center = abs(ref[-1] + ref[0]) / 2 336 | self.setSliderPosition([((i - center) * gain) + center for i in ref]) 337 | -------------------------------------------------------------------------------- /qtrangeslider/_generic_slider.py: -------------------------------------------------------------------------------- 1 | """Generic Sliders with internal python-based models 2 | 3 | This module reimplements most of the logic from qslider.cpp in python: 4 | https://code.woboq.org/qt5/qtbase/src/widgets/widgets/qslider.cpp.html 5 | 6 | This probably looks like tremendous overkill at first (and it may be!), 7 | since a it's possible to acheive a very reasonable "float slider" by 8 | scaling input float values to some internal integer range for the QSlider, 9 | and converting back to float when getting `value()`. However, one still 10 | runs into overflow limitations due to the internal integer model. 11 | 12 | In order to circumvent them, one needs to reimplement more and more of 13 | the attributes from QSliderPrivate in order to have the slider behave 14 | like a native slider (with all of the proper signals and options). 15 | So that's what `_GenericSlider` is below. 16 | 17 | `_GenericRangeSlider` is a variant that expects `value()` and 18 | `sliderPosition()` to be a sequence of scalars rather than a single 19 | scalar (with one handle per item), and it forms the basis of 20 | QRangeSlider. 21 | """ 22 | 23 | from typing import Generic, TypeVar 24 | 25 | from .qtcompat import QtGui 26 | from .qtcompat.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal 27 | from .qtcompat.QtWidgets import ( 28 | QApplication, 29 | QSlider, 30 | QStyle, 31 | QStyleOptionSlider, 32 | QStylePainter, 33 | ) 34 | 35 | _T = TypeVar("_T") 36 | 37 | SC_NONE = QStyle.SubControl.SC_None 38 | SC_HANDLE = QStyle.SubControl.SC_SliderHandle 39 | SC_GROOVE = QStyle.SubControl.SC_SliderGroove 40 | SC_TICKMARKS = QStyle.SubControl.SC_SliderTickmarks 41 | 42 | CC_SLIDER = QStyle.ComplexControl.CC_Slider 43 | QOVERFLOW = 2 ** 31 - 1 44 | 45 | 46 | class _GenericSlider(QSlider, Generic[_T]): 47 | valueChanged = Signal(float) 48 | sliderMoved = Signal(float) 49 | rangeChanged = Signal(float, float) 50 | 51 | MAX_DISPLAY = 5000 52 | 53 | def __init__(self, *args, **kwargs) -> None: 54 | 55 | self._minimum = 0.0 56 | self._maximum = 99.0 57 | self._pageStep = 10.0 58 | self._value: _T = 0.0 # type: ignore 59 | self._position: _T = 0.0 60 | self._singleStep = 1.0 61 | self._offsetAccumulated = 0.0 62 | self._blocktracking = False 63 | self._tickInterval = 0.0 64 | self._pressedControl = SC_NONE 65 | self._hoverControl = SC_NONE 66 | self._hoverRect = QRect() 67 | self._clickOffset = 0.0 68 | 69 | # for keyboard nav 70 | self._repeatMultiplier = 1 # TODO 71 | # for wheel nav 72 | self._offset_accum = 0.0 73 | # fraction of total range to scroll when holding Ctrl while scrolling 74 | self._control_fraction = 0.04 75 | 76 | super().__init__(*args, **kwargs) 77 | self.setAttribute(Qt.WA_Hover) 78 | 79 | # ############### QtOverrides ####################### 80 | 81 | def value(self) -> _T: # type: ignore 82 | return self._value 83 | 84 | def setValue(self, value: _T) -> None: 85 | value = self._bound(value) 86 | if self._value == value and self._position == value: 87 | return 88 | self._value = value 89 | if self._position != value: 90 | self._setPosition(value) 91 | if self.isSliderDown(): 92 | self.sliderMoved.emit(self.sliderPosition()) 93 | self.sliderChange(self.SliderChange.SliderValueChange) 94 | self.valueChanged.emit(self.value()) 95 | 96 | def sliderPosition(self) -> _T: # type: ignore 97 | return self._position 98 | 99 | def setSliderPosition(self, pos: _T) -> None: 100 | position = self._bound(pos) 101 | if position == self._position: 102 | return 103 | self._setPosition(position) 104 | self._doSliderMove() 105 | 106 | def singleStep(self) -> float: # type: ignore 107 | return self._singleStep 108 | 109 | def setSingleStep(self, step: float) -> None: 110 | if step != self._singleStep: 111 | self._setSteps(step, self._pageStep) 112 | 113 | def pageStep(self) -> float: # type: ignore 114 | return self._pageStep 115 | 116 | def setPageStep(self, step: float) -> None: 117 | if step != self._pageStep: 118 | self._setSteps(self._singleStep, step) 119 | 120 | def minimum(self) -> float: # type: ignore 121 | return self._minimum 122 | 123 | def setMinimum(self, min: float) -> None: 124 | self.setRange(min, max(self._maximum, min)) 125 | 126 | def maximum(self) -> float: # type: ignore 127 | return self._maximum 128 | 129 | def setMaximum(self, max: float) -> None: 130 | self.setRange(min(self._minimum, max), max) 131 | 132 | def setRange(self, min: float, max_: float) -> None: 133 | oldMin, self._minimum = self._minimum, float(min) 134 | oldMax, self._maximum = self._maximum, float(max(min, max_)) 135 | 136 | if oldMin != self._minimum or oldMax != self._maximum: 137 | self.sliderChange(self.SliderRangeChange) 138 | self.rangeChanged.emit(self._minimum, self._maximum) 139 | self.setValue(self._value) # re-bound 140 | 141 | def tickInterval(self) -> float: # type: ignore 142 | return self._tickInterval 143 | 144 | def setTickInterval(self, ts: float) -> None: 145 | self._tickInterval = max(0.0, ts) 146 | self.update() 147 | 148 | def triggerAction(self, action: QSlider.SliderAction) -> None: 149 | self._blocktracking = True 150 | # other actions here 151 | # self.actionTriggered.emit(action) # FIXME: type not working for all Qt 152 | self._blocktracking = False 153 | self.setValue(self._position) 154 | 155 | def initStyleOption(self, option: QStyleOptionSlider) -> None: 156 | option.initFrom(self) 157 | option.subControls = SC_NONE 158 | option.activeSubControls = SC_NONE 159 | option.orientation = self.orientation() 160 | option.tickPosition = self.tickPosition() 161 | option.upsideDown = ( 162 | self.invertedAppearance() != (option.direction == Qt.RightToLeft) 163 | if self.orientation() == Qt.Horizontal 164 | else not self.invertedAppearance() 165 | ) 166 | option.direction = Qt.LeftToRight # we use the upsideDown option instead 167 | # option.sliderValue = self._value # type: ignore 168 | # option.singleStep = self._singleStep # type: ignore 169 | if self.orientation() == Qt.Horizontal: 170 | option.state |= QStyle.State_Horizontal 171 | 172 | # scale style option to integer space 173 | option.minimum = 0 174 | option.maximum = self.MAX_DISPLAY 175 | option.tickInterval = self._to_qinteger_space(self._tickInterval) 176 | option.pageStep = self._to_qinteger_space(self._pageStep) 177 | option.singleStep = self._to_qinteger_space(self._singleStep) 178 | self._fixStyleOption(option) 179 | 180 | def event(self, ev: QEvent) -> bool: 181 | if ev.type() == QEvent.WindowActivate: 182 | self.update() 183 | elif ev.type() in (QEvent.HoverEnter, QEvent.HoverMove): 184 | self._updateHoverControl(_event_position(ev)) 185 | elif ev.type() == QEvent.HoverLeave: 186 | self._hoverControl = SC_NONE 187 | lastHoverRect, self._hoverRect = self._hoverRect, QRect() 188 | self.update(lastHoverRect) 189 | return super().event(ev) 190 | 191 | def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None: 192 | if self._minimum == self._maximum or ev.buttons() ^ ev.button(): 193 | ev.ignore() 194 | return 195 | 196 | ev.accept() 197 | 198 | pos = _event_position(ev) 199 | 200 | # If the mouse button used is allowed to set the value 201 | if ev.button() in (Qt.LeftButton, Qt.MiddleButton): 202 | self._updatePressedControl(pos) 203 | if self._pressedControl == SC_HANDLE: 204 | opt = self._styleOption 205 | sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self) 206 | offset = sr.center() - sr.topLeft() 207 | new_pos = self._pixelPosToRangeValue(self._pick(pos - offset)) 208 | self.setSliderPosition(new_pos) 209 | self.triggerAction(QSlider.SliderMove) 210 | self.setRepeatAction(QSlider.SliderNoAction) 211 | 212 | self.update() 213 | # elif: deal with PageSetButtons 214 | else: 215 | ev.ignore() 216 | 217 | if self._pressedControl != SC_NONE: 218 | self.setRepeatAction(QSlider.SliderNoAction) 219 | self._setClickOffset(pos) 220 | self.update() 221 | self.setSliderDown(True) 222 | 223 | def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None: 224 | # TODO: add pixelMetric(QStyle::PM_MaximumDragDistance, &opt, this); 225 | if self._pressedControl == SC_NONE: 226 | ev.ignore() 227 | return 228 | ev.accept() 229 | pos = self._pick(_event_position(ev)) 230 | newPosition = self._pixelPosToRangeValue(pos - self._clickOffset) 231 | self.setSliderPosition(newPosition) 232 | 233 | def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None: 234 | if self._pressedControl == SC_NONE or ev.buttons(): 235 | ev.ignore() 236 | return 237 | 238 | ev.accept() 239 | oldPressed = self._pressedControl 240 | self._pressedControl = SC_NONE 241 | self.setRepeatAction(QSlider.SliderNoAction) 242 | if oldPressed != SC_NONE: 243 | self.setSliderDown(False) 244 | self.update() 245 | 246 | def wheelEvent(self, e: QtGui.QWheelEvent) -> None: 247 | 248 | e.ignore() 249 | vertical = bool(e.angleDelta().y()) 250 | delta = e.angleDelta().y() if vertical else e.angleDelta().x() 251 | if e.inverted(): 252 | delta *= -1 253 | 254 | orientation = Qt.Vertical if vertical else Qt.Horizontal 255 | if self._scrollByDelta(orientation, e.modifiers(), delta): 256 | e.accept() 257 | 258 | def paintEvent(self, ev: QtGui.QPaintEvent) -> None: 259 | painter = QStylePainter(self) 260 | opt = self._styleOption 261 | 262 | # draw groove and ticks 263 | opt.subControls = SC_GROOVE 264 | if opt.tickPosition != QSlider.NoTicks: 265 | opt.subControls |= SC_TICKMARKS 266 | painter.drawComplexControl(CC_SLIDER, opt) 267 | 268 | self._draw_handle(painter, opt) 269 | 270 | # ############### Implementation Details ####################### 271 | 272 | def _type_cast(self, val): 273 | return val 274 | 275 | def _setPosition(self, val): 276 | self._position = val 277 | 278 | def _bound(self, value: _T) -> _T: 279 | return self._type_cast(max(self._minimum, min(self._maximum, value))) 280 | 281 | def _fixStyleOption(self, option): 282 | option.sliderPosition = self._to_qinteger_space(self._position - self._minimum) 283 | option.sliderValue = self._to_qinteger_space(self._value - self._minimum) 284 | 285 | def _to_qinteger_space(self, val, _max=None): 286 | _max = _max or self.MAX_DISPLAY 287 | return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max)) 288 | 289 | def _pick(self, pt: QPoint) -> int: 290 | return pt.x() if self.orientation() == Qt.Horizontal else pt.y() 291 | 292 | def _setSteps(self, single: float, page: float): 293 | self._singleStep = single 294 | self._pageStep = page 295 | self.sliderChange(QSlider.SliderStepsChange) 296 | 297 | def _doSliderMove(self): 298 | if not self.hasTracking(): 299 | self.update() 300 | if self.isSliderDown(): 301 | self.sliderMoved.emit(self.sliderPosition()) 302 | if self.hasTracking() and not self._blocktracking: 303 | self.triggerAction(QSlider.SliderMove) 304 | 305 | @property 306 | def _styleOption(self): 307 | opt = QStyleOptionSlider() 308 | self.initStyleOption(opt) 309 | return opt 310 | 311 | def _updateHoverControl(self, pos: QPoint) -> bool: 312 | lastHoverRect = self._hoverRect 313 | lastHoverControl = self._hoverControl 314 | doesHover = self.testAttribute(Qt.WA_Hover) 315 | if lastHoverControl != self._newHoverControl(pos) and doesHover: 316 | self.update(lastHoverRect) 317 | self.update(self._hoverRect) 318 | return True 319 | return not doesHover 320 | 321 | def _newHoverControl(self, pos: QPoint) -> QStyle.SubControl: 322 | opt = self._styleOption 323 | opt.subControls = QStyle.SubControl.SC_All 324 | 325 | handleRect = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self) 326 | grooveRect = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self) 327 | tickmarksRect = self.style().subControlRect(CC_SLIDER, opt, SC_TICKMARKS, self) 328 | 329 | if handleRect.contains(pos): 330 | self._hoverRect = handleRect 331 | self._hoverControl = SC_HANDLE 332 | elif grooveRect.contains(pos): 333 | self._hoverRect = grooveRect 334 | self._hoverControl = SC_GROOVE 335 | elif tickmarksRect.contains(pos): 336 | self._hoverRect = tickmarksRect 337 | self._hoverControl = SC_TICKMARKS 338 | else: 339 | self._hoverRect = QRect() 340 | self._hoverControl = SC_NONE 341 | return self._hoverControl 342 | 343 | def _setClickOffset(self, pos: QPoint): 344 | hr = self.style().subControlRect(CC_SLIDER, self._styleOption, SC_HANDLE, self) 345 | self._clickOffset = self._pick(pos - hr.topLeft()) 346 | 347 | def _updatePressedControl(self, pos: QPoint): 348 | self._pressedControl = SC_HANDLE 349 | 350 | def _draw_handle(self, painter, opt): 351 | opt.subControls = SC_HANDLE 352 | if self._pressedControl: 353 | opt.activeSubControls = self._pressedControl 354 | opt.state |= QStyle.State_Sunken 355 | else: 356 | opt.activeSubControls = self._hoverControl 357 | 358 | painter.drawComplexControl(CC_SLIDER, opt) 359 | 360 | # from QSliderPrivate.pixelPosToRangeValue 361 | def _pixelPosToRangeValue(self, pos: int) -> float: 362 | opt = self._styleOption 363 | 364 | gr = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self) 365 | sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self) 366 | 367 | if self.orientation() == Qt.Horizontal: 368 | sliderLength = sr.width() 369 | sliderMin = gr.x() 370 | sliderMax = gr.right() - sliderLength + 1 371 | else: 372 | sliderLength = sr.height() 373 | sliderMin = gr.y() 374 | sliderMax = gr.bottom() - sliderLength + 1 375 | return _sliderValueFromPosition( 376 | self._minimum, 377 | self._maximum, 378 | pos - sliderMin, 379 | sliderMax - sliderMin, 380 | opt.upsideDown, 381 | ) 382 | 383 | def _scrollByDelta(self, orientation, modifiers, delta: int) -> bool: 384 | steps_to_scroll = 0.0 385 | pg_step = self._pageStep 386 | 387 | # in Qt scrolling to the right gives negative values. 388 | if orientation == Qt.Horizontal: 389 | delta *= -1 390 | offset = delta / 120 391 | if modifiers & Qt.ShiftModifier: 392 | # Scroll one page regardless of delta: 393 | steps_to_scroll = max(-pg_step, min(pg_step, offset * pg_step)) 394 | self._offset_accum = 0 395 | elif modifiers & Qt.ControlModifier: 396 | _range = self._maximum - self._minimum 397 | steps_to_scroll = offset * _range * self._control_fraction 398 | self._offset_accum = 0 399 | else: 400 | # Calculate how many lines to scroll. Depending on what delta is (and 401 | # offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can 402 | # only scroll whole lines, so we keep the reminder until next event. 403 | wheel_scroll_lines = QApplication.wheelScrollLines() 404 | steps_to_scrollF = wheel_scroll_lines * offset * self._effectiveSingleStep() 405 | # Check if wheel changed direction since last event: 406 | if self._offset_accum != 0 and (offset / self._offset_accum) < 0: 407 | self._offset_accum = 0 408 | 409 | self._offset_accum += steps_to_scrollF 410 | 411 | # Don't scroll more than one page in any case: 412 | steps_to_scroll = max(-pg_step, min(pg_step, self._offset_accum)) 413 | self._offset_accum -= self._offset_accum 414 | 415 | if steps_to_scroll == 0: 416 | # We moved less than a line, but might still have accumulated partial 417 | # scroll, unless we already are at one of the ends. 418 | effective_offset = self._offset_accum 419 | if self.invertedControls(): 420 | effective_offset *= -1 421 | if self._has_scroll_space_left(effective_offset): 422 | return True 423 | self._offset_accum = 0 424 | return False 425 | 426 | if self.invertedControls(): 427 | steps_to_scroll *= -1 428 | 429 | prevValue = self._value 430 | self._execute_scroll(steps_to_scroll, modifiers) 431 | if prevValue == self._value: 432 | self._offset_accum = 0 433 | return False 434 | return True 435 | 436 | def _has_scroll_space_left(self, offset): 437 | return (offset > 0 and self._value < self._maximum) or ( 438 | offset < 0 and self._value < self._minimum 439 | ) 440 | 441 | def _execute_scroll(self, steps_to_scroll, modifiers): 442 | self._setPosition(self._bound(self._overflowSafeAdd(steps_to_scroll))) 443 | self.triggerAction(QSlider.SliderMove) 444 | 445 | def _effectiveSingleStep(self) -> float: 446 | return self._singleStep * self._repeatMultiplier 447 | 448 | def _overflowSafeAdd(self, add: float) -> float: 449 | newValue = self._value + add 450 | if add > 0 and newValue < self._value: 451 | newValue = self._maximum 452 | elif add < 0 and newValue > self._value: 453 | newValue = self._minimum 454 | return newValue 455 | 456 | # def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None: 457 | # return # TODO 458 | 459 | 460 | def _event_position(ev: QEvent) -> QPoint: 461 | # safe for Qt6, Qt5, and hoverEvent 462 | evp = getattr(ev, "position", getattr(ev, "pos", None)) 463 | pos = evp() if evp else QPoint() 464 | if isinstance(pos, QPointF): 465 | pos = pos.toPoint() 466 | return pos 467 | 468 | 469 | def _sliderValueFromPosition( 470 | min: float, max: float, position: int, span: int, upsideDown: bool = False 471 | ) -> float: 472 | """Converts the given pixel `position` to a value. 473 | 474 | 0 maps to the `min` parameter, `span` maps to `max` and other values are 475 | distributed evenly in-between. 476 | 477 | By default, this function assumes that the maximum value is on the right 478 | for horizontal items and on the bottom for vertical items. Set the 479 | `upsideDown` parameter to True to reverse this behavior. 480 | """ 481 | 482 | if span <= 0 or position <= 0: 483 | return max if upsideDown else min 484 | if position >= span: 485 | return min if upsideDown else max 486 | range = max - min 487 | tmp = min + position * range / span 488 | return max - tmp if upsideDown else tmp + min 489 | -------------------------------------------------------------------------------- /qtrangeslider/_labeled.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | from functools import partial 3 | 4 | from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider 5 | from .qtcompat.QtCore import QPoint, QSize, Qt, Signal 6 | from .qtcompat.QtGui import QFontMetrics, QValidator 7 | from .qtcompat.QtWidgets import ( 8 | QAbstractSlider, 9 | QApplication, 10 | QDoubleSpinBox, 11 | QHBoxLayout, 12 | QSlider, 13 | QSpinBox, 14 | QStyle, 15 | QStyleOptionSpinBox, 16 | QVBoxLayout, 17 | QWidget, 18 | ) 19 | 20 | 21 | class LabelPosition(IntEnum): 22 | NoLabel = 0 23 | LabelsAbove = 1 24 | LabelsBelow = 2 25 | LabelsRight = 1 26 | LabelsLeft = 2 27 | 28 | 29 | class EdgeLabelMode(IntEnum): 30 | NoLabel = 0 31 | LabelIsRange = 1 32 | LabelIsValue = 2 33 | 34 | 35 | class SliderProxy: 36 | _slider: QSlider 37 | 38 | def value(self): 39 | return self._slider.value() 40 | 41 | def setValue(self, value) -> None: 42 | self._slider.setValue(value) 43 | 44 | def sliderPosition(self): 45 | return self._slider.sliderPosition() 46 | 47 | def setSliderPosition(self, pos) -> None: 48 | self._slider.setSliderPosition(pos) 49 | 50 | def minimum(self): 51 | return self._slider.minimum() 52 | 53 | def setMinimum(self, minimum): 54 | self._slider.setMinimum(minimum) 55 | 56 | def maximum(self): 57 | return self._slider.maximum() 58 | 59 | def setMaximum(self, maximum): 60 | self._slider.setMaximum(maximum) 61 | 62 | def singleStep(self): 63 | return self._slider.singleStep() 64 | 65 | def setSingleStep(self, step): 66 | self._slider.setSingleStep(step) 67 | 68 | def pageStep(self): 69 | return self._slider.pageStep() 70 | 71 | def setPageStep(self, step) -> None: 72 | self._slider.setPageStep(step) 73 | 74 | def setRange(self, min, max) -> None: 75 | self._slider.setRange(min, max) 76 | 77 | def tickInterval(self): 78 | return self._slider.tickInterval() 79 | 80 | def setTickInterval(self, interval) -> None: 81 | self._slider.setTickInterval(interval) 82 | 83 | def tickPosition(self): 84 | return self._slider.tickPosition() 85 | 86 | def setTickPosition(self, pos) -> None: 87 | self._slider.setTickPosition(pos) 88 | 89 | 90 | def _handle_overloaded_slider_sig(args, kwargs): 91 | parent = None 92 | orientation = Qt.Vertical 93 | errmsg = ( 94 | "TypeError: arguments did not match any overloaded call:\n" 95 | " QSlider(parent: QWidget = None)\n" 96 | " QSlider(Qt.Orientation, parent: QWidget = None)" 97 | ) 98 | if len(args) > 2: 99 | raise TypeError(errmsg) 100 | elif len(args) == 2: 101 | if kwargs: 102 | raise TypeError(errmsg) 103 | orientation, parent = args 104 | elif args: 105 | if isinstance(args[0], QWidget): 106 | if kwargs: 107 | raise TypeError(errmsg) 108 | parent = args[0] 109 | else: 110 | orientation = args[0] 111 | parent = kwargs.get("parent", parent) 112 | return parent, orientation 113 | 114 | 115 | class QLabeledSlider(SliderProxy, QAbstractSlider): 116 | _slider_class = QSlider 117 | _slider: QSlider 118 | 119 | def __init__(self, *args, **kwargs) -> None: 120 | parent, orientation = _handle_overloaded_slider_sig(args, kwargs) 121 | 122 | super().__init__(parent) 123 | 124 | self._slider = self._slider_class() 125 | self._label = SliderLabel(self._slider, connect=self._slider.setValue) 126 | 127 | self._slider.rangeChanged.connect(self.rangeChanged.emit) 128 | self._slider.valueChanged.connect(self.valueChanged.emit) 129 | self._slider.valueChanged.connect(self._label.setValue) 130 | 131 | self.setOrientation(orientation) 132 | 133 | def setOrientation(self, orientation): 134 | """Set orientation, value will be 'horizontal' or 'vertical'.""" 135 | self._slider.setOrientation(orientation) 136 | if orientation == Qt.Vertical: 137 | layout = QVBoxLayout() 138 | layout.addWidget(self._slider, alignment=Qt.AlignHCenter) 139 | layout.addWidget(self._label, alignment=Qt.AlignHCenter) 140 | self._label.setAlignment(Qt.AlignCenter) 141 | layout.setSpacing(1) 142 | else: 143 | layout = QHBoxLayout() 144 | layout.addWidget(self._slider) 145 | layout.addWidget(self._label) 146 | self._label.setAlignment(Qt.AlignRight) 147 | layout.setSpacing(6) 148 | 149 | old_layout = self.layout() 150 | if old_layout is not None: 151 | QWidget().setLayout(old_layout) 152 | 153 | layout.setContentsMargins(0, 0, 0, 0) 154 | self.setLayout(layout) 155 | 156 | 157 | class QLabeledDoubleSlider(QLabeledSlider): 158 | _slider_class = QDoubleSlider 159 | _slider: QDoubleSlider 160 | valueChanged = Signal(float) 161 | rangeChanged = Signal(float, float) 162 | 163 | def __init__(self, *args, **kwargs) -> None: 164 | super().__init__(*args, **kwargs) 165 | self.setDecimals(2) 166 | 167 | def decimals(self) -> int: 168 | return self._label.decimals() 169 | 170 | def setDecimals(self, prec: int): 171 | self._label.setDecimals(prec) 172 | 173 | 174 | class QLabeledRangeSlider(SliderProxy, QAbstractSlider): 175 | valueChanged = Signal(tuple) 176 | LabelPosition = LabelPosition 177 | EdgeLabelMode = EdgeLabelMode 178 | _slider_class = QRangeSlider 179 | _slider: QRangeSlider 180 | 181 | def __init__(self, *args, **kwargs) -> None: 182 | parent, orientation = _handle_overloaded_slider_sig(args, kwargs) 183 | super().__init__(parent) 184 | self.setAttribute(Qt.WA_ShowWithoutActivating) 185 | self._handle_labels = [] 186 | self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove 187 | 188 | # for fine tuning label position 189 | self.label_shift_x = 0 190 | self.label_shift_y = 0 191 | 192 | self._slider = self._slider_class() 193 | self._slider.valueChanged.connect(self.valueChanged.emit) 194 | self._slider.rangeChanged.connect(self.rangeChanged.emit) 195 | 196 | self._min_label = SliderLabel( 197 | self._slider, alignment=Qt.AlignLeft, connect=self._min_label_edited 198 | ) 199 | self._max_label = SliderLabel( 200 | self._slider, alignment=Qt.AlignRight, connect=self._max_label_edited 201 | ) 202 | self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange) 203 | 204 | self._slider.valueChanged.connect(self._on_value_changed) 205 | self._slider.rangeChanged.connect(self._on_range_changed) 206 | 207 | self._on_value_changed(self._slider.value()) 208 | self._on_range_changed(self._slider.minimum(), self._slider.maximum()) 209 | self.setOrientation(orientation) 210 | 211 | def handleLabelPosition(self) -> LabelPosition: 212 | return self._handle_label_position 213 | 214 | def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition: 215 | self._handle_label_position = opt 216 | for lbl in self._handle_labels: 217 | if not opt: 218 | lbl.hide() 219 | else: 220 | lbl.show() 221 | self.setOrientation(self.orientation()) 222 | 223 | def edgeLabelMode(self) -> EdgeLabelMode: 224 | return self._edge_label_mode 225 | 226 | def setEdgeLabelMode(self, opt: EdgeLabelMode): 227 | self._edge_label_mode = opt 228 | if not self._edge_label_mode: 229 | self._min_label.hide() 230 | self._max_label.hide() 231 | else: 232 | if self.isVisible(): 233 | self._min_label.show() 234 | self._max_label.show() 235 | self._min_label.setMode(opt) 236 | self._max_label.setMode(opt) 237 | if opt == EdgeLabelMode.LabelIsValue: 238 | v0, *_, v1 = self._slider.value() 239 | self._min_label.setValue(v0) 240 | self._max_label.setValue(v1) 241 | elif opt == EdgeLabelMode.LabelIsRange: 242 | self._min_label.setValue(self._slider.minimum()) 243 | self._max_label.setValue(self._slider.maximum()) 244 | QApplication.processEvents() 245 | self._reposition_labels() 246 | 247 | def _reposition_labels(self): 248 | if not self._handle_labels: 249 | return 250 | 251 | horizontal = self.orientation() == Qt.Horizontal 252 | labels_above = self._handle_label_position == LabelPosition.LabelsAbove 253 | 254 | last_edge = None 255 | for i, label in enumerate(self._handle_labels): 256 | rect = self._slider._handleRect(i) 257 | dx = -label.width() / 2 258 | dy = -label.height() / 2 259 | if labels_above: 260 | if horizontal: 261 | dy *= 3 262 | else: 263 | dx *= -1 264 | else: 265 | if horizontal: 266 | dy *= -1 267 | else: 268 | dx *= 3 269 | pos = self._slider.mapToParent(rect.center()) 270 | pos += QPoint(int(dx + self.label_shift_x), int(dy + self.label_shift_y)) 271 | if last_edge is not None: 272 | # prevent label overlap 273 | if horizontal: 274 | pos.setX(int(max(pos.x(), last_edge.x() + label.width() / 2 + 12))) 275 | else: 276 | pos.setY(int(min(pos.y(), last_edge.y() - label.height() / 2 - 4))) 277 | label.move(pos) 278 | last_edge = pos 279 | label.clearFocus() 280 | self.update() 281 | 282 | def _min_label_edited(self, val): 283 | if self._edge_label_mode == EdgeLabelMode.LabelIsRange: 284 | self.setMinimum(val) 285 | else: 286 | v = list(self._slider.value()) 287 | v[0] = val 288 | self.setValue(v) 289 | self._reposition_labels() 290 | 291 | def _max_label_edited(self, val): 292 | if self._edge_label_mode == EdgeLabelMode.LabelIsRange: 293 | self.setMaximum(val) 294 | else: 295 | v = list(self._slider.value()) 296 | v[-1] = val 297 | self.setValue(v) 298 | self._reposition_labels() 299 | 300 | def _on_value_changed(self, v): 301 | if self._edge_label_mode == EdgeLabelMode.LabelIsValue: 302 | self._min_label.setValue(v[0]) 303 | self._max_label.setValue(v[-1]) 304 | 305 | if len(v) != len(self._handle_labels): 306 | for lbl in self._handle_labels: 307 | lbl.setParent(None) 308 | lbl.deleteLater() 309 | self._handle_labels.clear() 310 | for n, val in enumerate(self._slider.value()): 311 | _cb = partial(self._slider.setSliderPosition, index=n) 312 | s = SliderLabel(self._slider, parent=self, connect=_cb) 313 | s.setValue(val) 314 | self._handle_labels.append(s) 315 | else: 316 | for val, label in zip(v, self._handle_labels): 317 | label.setValue(val) 318 | self._reposition_labels() 319 | 320 | def _on_range_changed(self, min, max): 321 | if (min, max) != (self._slider.minimum(), self._slider.maximum()): 322 | self._slider.setRange(min, max) 323 | for lbl in self._handle_labels: 324 | lbl.setRange(min, max) 325 | if self._edge_label_mode == EdgeLabelMode.LabelIsRange: 326 | self._min_label.setValue(min) 327 | self._max_label.setValue(max) 328 | self._reposition_labels() 329 | 330 | # def setValue(self, value) -> None: 331 | # super().setValue(value) 332 | # self.sliderChange(QSlider.SliderValueChange) 333 | 334 | def setRange(self, min, max) -> None: 335 | self._on_range_changed(min, max) 336 | 337 | def setOrientation(self, orientation): 338 | """Set orientation, value will be 'horizontal' or 'vertical'.""" 339 | 340 | self._slider.setOrientation(orientation) 341 | if orientation == Qt.Vertical: 342 | layout = QVBoxLayout() 343 | layout.setSpacing(1) 344 | layout.addWidget(self._max_label) 345 | layout.addWidget(self._slider) 346 | layout.addWidget(self._min_label) 347 | # TODO: set margins based on label width 348 | if self._handle_label_position == LabelPosition.LabelsLeft: 349 | marg = (30, 0, 0, 0) 350 | elif self._handle_label_position == LabelPosition.NoLabel: 351 | marg = (0, 0, 0, 0) 352 | else: 353 | marg = (0, 0, 20, 0) 354 | layout.setAlignment(Qt.AlignCenter) 355 | else: 356 | layout = QHBoxLayout() 357 | layout.setSpacing(7) 358 | if self._handle_label_position == LabelPosition.LabelsBelow: 359 | marg = (0, 0, 0, 25) 360 | elif self._handle_label_position == LabelPosition.NoLabel: 361 | marg = (0, 0, 0, 0) 362 | else: 363 | marg = (0, 25, 0, 0) 364 | layout.addWidget(self._min_label) 365 | layout.addWidget(self._slider) 366 | layout.addWidget(self._max_label) 367 | 368 | # remove old layout 369 | old_layout = self.layout() 370 | if old_layout is not None: 371 | QWidget().setLayout(old_layout) 372 | 373 | self.setLayout(layout) 374 | layout.setContentsMargins(*marg) 375 | super().setOrientation(orientation) 376 | QApplication.processEvents() 377 | self._reposition_labels() 378 | 379 | def resizeEvent(self, a0) -> None: 380 | super().resizeEvent(a0) 381 | self._reposition_labels() 382 | 383 | 384 | class QLabeledDoubleRangeSlider(QLabeledRangeSlider): 385 | _slider_class = QDoubleRangeSlider 386 | _slider: QDoubleRangeSlider 387 | rangeChanged = Signal(float, float) 388 | 389 | def __init__(self, *args, **kwargs) -> None: 390 | super().__init__(*args, **kwargs) 391 | self.setDecimals(2) 392 | 393 | def decimals(self) -> int: 394 | return self._min_label.decimals() 395 | 396 | def setDecimals(self, prec: int): 397 | self._min_label.setDecimals(prec) 398 | self._max_label.setDecimals(prec) 399 | for lbl in self._handle_labels: 400 | lbl.setDecimals(prec) 401 | 402 | 403 | class SliderLabel(QDoubleSpinBox): 404 | def __init__( 405 | self, slider: QSlider, parent=None, alignment=Qt.AlignCenter, connect=None 406 | ) -> None: 407 | super().__init__(parent=parent) 408 | self._slider = slider 409 | self.setFocusPolicy(Qt.ClickFocus) 410 | self.setMode(EdgeLabelMode.LabelIsValue) 411 | self.setDecimals(0) 412 | 413 | self.setRange(slider.minimum(), slider.maximum()) 414 | slider.rangeChanged.connect(self._update_size) 415 | self.setAlignment(alignment) 416 | self.setButtonSymbols(QSpinBox.NoButtons) 417 | self.setStyleSheet("background:transparent; border: 0;") 418 | if connect is not None: 419 | self.editingFinished.connect(lambda: connect(self.value())) 420 | self.editingFinished.connect(self.clearFocus) 421 | self._update_size() 422 | 423 | def setDecimals(self, prec: int) -> None: 424 | super().setDecimals(prec) 425 | self._update_size() 426 | 427 | def _update_size(self, *_): 428 | # fontmetrics to measure the width of text 429 | fm = QFontMetrics(self.font()) 430 | h = self.sizeHint().height() 431 | fixed_content = self.prefix() + self.suffix() + " " 432 | 433 | if self._mode == EdgeLabelMode.LabelIsValue: 434 | # determine width based on min/max/specialValue 435 | mintext = self.textFromValue(self.minimum())[:18] + fixed_content 436 | maxtext = self.textFromValue(self.maximum())[:18] + fixed_content 437 | w = max(0, _fm_width(fm, mintext)) 438 | w = max(w, _fm_width(fm, maxtext)) 439 | if self.specialValueText(): 440 | w = max(w, _fm_width(fm, self.specialValueText())) 441 | else: 442 | w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3 443 | 444 | w += 3 # cursor blinking space 445 | # get the final size hint 446 | opt = QStyleOptionSpinBox() 447 | self.initStyleOption(opt) 448 | size = self.style().sizeFromContents(QStyle.CT_SpinBox, opt, QSize(w, h), self) 449 | self.setFixedSize(size) 450 | 451 | def setValue(self, val): 452 | super().setValue(val) 453 | if self._mode == EdgeLabelMode.LabelIsRange: 454 | self._update_size() 455 | 456 | def setMaximum(self, max: int) -> None: 457 | super().setMaximum(max) 458 | if self._mode == EdgeLabelMode.LabelIsValue: 459 | self._update_size() 460 | 461 | def setMinimum(self, min: int) -> None: 462 | super().setMinimum(min) 463 | if self._mode == EdgeLabelMode.LabelIsValue: 464 | self._update_size() 465 | 466 | def setMode(self, opt: EdgeLabelMode): 467 | # when the edge labels are controlling slider range, 468 | # we want them to have a big range, but not have a huge label 469 | self._mode = opt 470 | if opt == EdgeLabelMode.LabelIsRange: 471 | self.setMinimum(-9999999) 472 | self.setMaximum(9999999) 473 | try: 474 | self._slider.rangeChanged.disconnect(self.setRange) 475 | except Exception: 476 | pass 477 | else: 478 | self.setMinimum(self._slider.minimum()) 479 | self.setMaximum(self._slider.maximum()) 480 | self._slider.rangeChanged.connect(self.setRange) 481 | self._update_size() 482 | 483 | def validate(self, input: str, pos: int): 484 | # fake like an integer spinbox 485 | if "." in input and self.decimals() < 1: 486 | return QValidator.Invalid, input, len(input) 487 | return super().validate(input, pos) 488 | 489 | 490 | def _fm_width(fm, text): 491 | if hasattr(fm, "horizontalAdvance"): 492 | return fm.horizontalAdvance(text) 493 | return fm.width(text) 494 | -------------------------------------------------------------------------------- /qtrangeslider/_range_style.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import platform 4 | import re 5 | from dataclasses import dataclass, replace 6 | from typing import TYPE_CHECKING 7 | 8 | from .qtcompat import PYQT_VERSION 9 | from .qtcompat.QtCore import Qt 10 | from .qtcompat.QtGui import ( 11 | QBrush, 12 | QColor, 13 | QGradient, 14 | QLinearGradient, 15 | QPalette, 16 | QRadialGradient, 17 | ) 18 | from .qtcompat.QtWidgets import QApplication, QSlider, QStyleOptionSlider 19 | 20 | if TYPE_CHECKING: 21 | from ._generic_range_slider import _GenericRangeSlider 22 | 23 | 24 | @dataclass 25 | class RangeSliderStyle: 26 | brush_active: str | None = None 27 | brush_inactive: str | None = None 28 | brush_disabled: str | None = None 29 | pen_active: str | None = None 30 | pen_inactive: str | None = None 31 | pen_disabled: str | None = None 32 | vertical_thickness: float | None = None 33 | horizontal_thickness: float | None = None 34 | tick_offset: float | None = None 35 | tick_bar_alpha: float | None = None 36 | v_offset: float | None = None 37 | h_offset: float | None = None 38 | has_stylesheet: bool = False 39 | 40 | def brush(self, opt: QStyleOptionSlider) -> QBrush: 41 | cg = opt.palette.currentColorGroup() 42 | attr = { 43 | QPalette.Active: "brush_active", # 0 44 | QPalette.Disabled: "brush_disabled", # 1 45 | QPalette.Inactive: "brush_inactive", # 2 46 | }[cg] 47 | _val = getattr(self, attr) 48 | if not _val: 49 | if self.has_stylesheet: 50 | # if someone set a general style sheet but didn't specify 51 | # :active, :inactive, etc... then Qt just uses whatever they 52 | # DID specify 53 | for i in ("active", "inactive", "disabled"): 54 | _val = getattr(self, f"brush_{i}") 55 | if _val: 56 | break 57 | else: 58 | _val = getattr(SYSTEM_STYLE, attr) 59 | 60 | if _val is None: 61 | return QBrush() 62 | 63 | if isinstance(_val, str): 64 | val = QColor(_val) 65 | if not val.isValid(): 66 | val = parse_color(_val, default_attr=attr) 67 | else: 68 | val = _val 69 | 70 | if opt.tickPosition != QSlider.NoTicks: 71 | val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha) 72 | 73 | return QBrush(val) 74 | 75 | def pen(self, opt: QStyleOptionSlider) -> Qt.PenStyle | QColor: 76 | cg = opt.palette.currentColorGroup() 77 | attr = { 78 | QPalette.Active: "pen_active", # 0 79 | QPalette.Disabled: "pen_disabled", # 1 80 | QPalette.Inactive: "pen_inactive", # 2 81 | }[cg] 82 | val = getattr(self, attr) or getattr(SYSTEM_STYLE, attr) 83 | if not val: 84 | return Qt.NoPen 85 | if isinstance(val, str): 86 | val = QColor(val) 87 | if opt.tickPosition != QSlider.NoTicks: 88 | val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha) 89 | 90 | return val 91 | 92 | def offset(self, opt: QStyleOptionSlider) -> int: 93 | tp = opt.tickPosition 94 | off = 0 95 | if not self.has_stylesheet: 96 | if opt.orientation == Qt.Horizontal: 97 | off += self.h_offset or SYSTEM_STYLE.h_offset or 0 98 | else: 99 | off += self.v_offset or SYSTEM_STYLE.v_offset or 0 100 | if tp == QSlider.TicksAbove: 101 | off += self.tick_offset or SYSTEM_STYLE.tick_offset 102 | elif tp == QSlider.TicksBelow: 103 | off -= self.tick_offset or SYSTEM_STYLE.tick_offset 104 | return off 105 | 106 | def thickness(self, opt: QStyleOptionSlider) -> float: 107 | if opt.orientation == Qt.Horizontal: 108 | return self.horizontal_thickness or SYSTEM_STYLE.horizontal_thickness 109 | else: 110 | return self.vertical_thickness or SYSTEM_STYLE.vertical_thickness 111 | 112 | 113 | # ########## System-specific default styles ############ 114 | 115 | BASE_STYLE = RangeSliderStyle( 116 | brush_active="#3B88FD", 117 | brush_inactive="#8F8F8F", 118 | brush_disabled="#BBBBBB", 119 | pen_active=None, 120 | pen_inactive=None, 121 | pen_disabled=None, 122 | vertical_thickness=4, 123 | horizontal_thickness=4, 124 | tick_offset=0, 125 | tick_bar_alpha=0.3, 126 | v_offset=0, 127 | h_offset=0, 128 | has_stylesheet=False, 129 | ) 130 | 131 | CATALINA_STYLE = replace( 132 | BASE_STYLE, 133 | brush_active="#3B88FD", 134 | brush_inactive="#8F8F8F", 135 | brush_disabled="#D2D2D2", 136 | horizontal_thickness=3, 137 | vertical_thickness=3, 138 | tick_bar_alpha=0.3, 139 | tick_offset=4, 140 | ) 141 | 142 | if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6: 143 | CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2) 144 | 145 | BIG_SUR_STYLE = replace( 146 | CATALINA_STYLE, 147 | brush_active="#0A81FE", 148 | brush_inactive="#D5D5D5", 149 | brush_disabled="#E6E6E6", 150 | tick_offset=0, 151 | horizontal_thickness=4, 152 | vertical_thickness=4, 153 | h_offset=-2, 154 | tick_bar_alpha=0.2, 155 | ) 156 | 157 | if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6: 158 | BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3) 159 | 160 | WINDOWS_STYLE = replace( 161 | BASE_STYLE, 162 | brush_active="#550179D7", 163 | brush_inactive="#330179D7", 164 | brush_disabled=None, 165 | ) 166 | 167 | LINUX_STYLE = replace( 168 | BASE_STYLE, 169 | brush_active="#44A0D9", 170 | brush_inactive="#44A0D9", 171 | brush_disabled="#44A0D9", 172 | pen_active="#286384", 173 | pen_inactive="#286384", 174 | pen_disabled="#286384", 175 | ) 176 | 177 | SYSTEM = platform.system() 178 | if SYSTEM == "Darwin": 179 | if int(platform.mac_ver()[0].split(".", maxsplit=1)[0]) >= 11: 180 | SYSTEM_STYLE = BIG_SUR_STYLE 181 | else: 182 | SYSTEM_STYLE = CATALINA_STYLE 183 | elif SYSTEM == "Windows": 184 | SYSTEM_STYLE = WINDOWS_STYLE 185 | elif SYSTEM == "Linux": 186 | SYSTEM_STYLE = LINUX_STYLE 187 | else: 188 | SYSTEM_STYLE = BASE_STYLE 189 | 190 | 191 | # ################ Stylesheet parsing logic ######################## 192 | 193 | qlineargrad_pattern = re.compile( 194 | r""" 195 | qlineargradient\( 196 | x1:\s*(?P\d*\.?\d+),\s* 197 | y1:\s*(?P\d*\.?\d+),\s* 198 | x2:\s*(?P\d*\.?\d+),\s* 199 | y2:\s*(?P\d*\.?\d+),\s* 200 | stop:0\s*(?P\S+),.* 201 | stop:1\s*(?P\S+) 202 | \)""", 203 | re.X, 204 | ) 205 | 206 | qradial_pattern = re.compile( 207 | r""" 208 | qradialgradient\( 209 | cx:\s*(?P\d*\.?\d+),\s* 210 | cy:\s*(?P\d*\.?\d+),\s* 211 | radius:\s*(?P\d*\.?\d+),\s* 212 | fx:\s*(?P\d*\.?\d+),\s* 213 | fy:\s*(?P\d*\.?\d+),\s* 214 | stop:0\s*(?P\S+),.* 215 | stop:1\s*(?P\S+) 216 | \)""", 217 | re.X, 218 | ) 219 | 220 | rgba_pattern = re.compile( 221 | r""" 222 | rgba?\( 223 | (?P\d+),\s* 224 | (?P\d+),\s* 225 | (?P\d+),?\s*(?P\d+)?\) 226 | """, 227 | re.X, 228 | ) 229 | 230 | 231 | def parse_color(color: str, default_attr) -> QColor | QGradient: 232 | qc = QColor(color) 233 | if qc.isValid(): 234 | return qc 235 | 236 | match = rgba_pattern.search(color) 237 | if match: 238 | rgba = [int(x) if x else 255 for x in match.groups()] 239 | return QColor(*rgba) 240 | 241 | # try linear gradient: 242 | match = qlineargrad_pattern.search(color) 243 | if match: 244 | grad = QLinearGradient(*[float(i) for i in match.groups()[:4]]) 245 | grad.setColorAt(0, QColor(match.groupdict()["stop0"])) 246 | grad.setColorAt(1, QColor(match.groupdict()["stop1"])) 247 | return grad 248 | 249 | # try linear gradient: 250 | match = qradial_pattern.search(color) 251 | if match: 252 | grad = QRadialGradient(*[float(i) for i in match.groups()[:5]]) 253 | grad.setColorAt(0, QColor(match.groupdict()["stop0"])) 254 | grad.setColorAt(1, QColor(match.groupdict()["stop1"])) 255 | return grad 256 | 257 | # fallback to dark gray 258 | return QColor(getattr(SYSTEM_STYLE, default_attr)) 259 | 260 | 261 | def update_styles_from_stylesheet(obj: _GenericRangeSlider): 262 | qss = obj.styleSheet() 263 | 264 | parent = obj.parent() 265 | while parent is not None: 266 | qss = parent.styleSheet() + qss 267 | parent = parent.parent() 268 | qss = QApplication.instance().styleSheet() + qss 269 | if not qss: 270 | return 271 | 272 | # Find bar height/width 273 | for orient, dim in (("horizontal", "height"), ("vertical", "width")): 274 | match = re.search(rf"Slider::groove:{orient}\s*{{\s*([^}}]+)}}", qss, re.S) 275 | if match: 276 | for line in reversed(match.groups()[0].splitlines()): 277 | bgrd = re.search(rf"{dim}\s*:\s*(\d+)", line) 278 | if bgrd: 279 | thickness = float(bgrd.groups()[-1]) 280 | setattr(obj._style, f"{orient}_thickness", thickness) 281 | obj._style.has_stylesheet = True 282 | -------------------------------------------------------------------------------- /qtrangeslider/_sliders.py: -------------------------------------------------------------------------------- 1 | from ._generic_range_slider import _GenericRangeSlider 2 | from ._generic_slider import _GenericSlider 3 | from .qtcompat.QtCore import Signal 4 | 5 | 6 | class _IntMixin: 7 | def __init__(self, *args, **kwargs): 8 | super().__init__(*args, **kwargs) 9 | self._singleStep = 1 10 | 11 | def _type_cast(self, value) -> int: 12 | return int(round(value)) 13 | 14 | 15 | class _FloatMixin: 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | self._singleStep = 0.01 19 | self._pageStep = 0.1 20 | 21 | def _type_cast(self, value) -> float: 22 | return float(value) 23 | 24 | 25 | class QDoubleSlider(_FloatMixin, _GenericSlider[float]): 26 | pass 27 | 28 | 29 | class QIntSlider(_IntMixin, _GenericSlider[int]): 30 | # mostly just an example... use QSlider instead. 31 | valueChanged = Signal(int) 32 | 33 | 34 | class QRangeSlider(_IntMixin, _GenericRangeSlider): 35 | pass 36 | 37 | 38 | class QDoubleRangeSlider(_FloatMixin, QRangeSlider): 39 | pass 40 | 41 | 42 | # QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ") 43 | -------------------------------------------------------------------------------- /qtrangeslider/_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tlambert03/QtRangeSlider/8267bdb2d9e096af95dde2759ab27b615ff515b2/qtrangeslider/_tests/__init__.py -------------------------------------------------------------------------------- /qtrangeslider/_tests/_testutil.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from distutils.version import LooseVersion 3 | from platform import system 4 | 5 | import pytest 6 | 7 | from qtrangeslider.qtcompat import QT_VERSION 8 | from qtrangeslider.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt 9 | from qtrangeslider.qtcompat.QtGui import QMouseEvent, QWheelEvent 10 | 11 | QT_VERSION = LooseVersion(QT_VERSION) 12 | 13 | SYS_DARWIN = system() == "Darwin" 14 | 15 | skip_on_linux_qt6 = pytest.mark.skipif( 16 | system() == "Linux" and QT_VERSION >= LooseVersion("6.0"), 17 | reason="hover events not working on linux pyqt6", 18 | ) 19 | 20 | 21 | def _mouse_event(pos=QPointF(), type_=QEvent.MouseMove): 22 | """Create a mouse event of `type_` at `pos`.""" 23 | return QMouseEvent(type_, QPointF(pos), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier) 24 | 25 | 26 | def _wheel_event(arc): 27 | """Create a wheel event with `arc`.""" 28 | with suppress(TypeError): 29 | return QWheelEvent( 30 | QPointF(), 31 | QPointF(), 32 | QPoint(arc, arc), 33 | QPoint(arc, arc), 34 | Qt.NoButton, 35 | Qt.NoModifier, 36 | Qt.ScrollBegin, 37 | False, 38 | Qt.MouseEventSynthesizedByQt, 39 | ) 40 | with suppress(TypeError): 41 | return QWheelEvent( 42 | QPointF(), 43 | QPointF(), 44 | QPoint(-arc, -arc), 45 | QPoint(-arc, -arc), 46 | 1, 47 | Qt.Vertical, 48 | Qt.NoButton, 49 | Qt.NoModifier, 50 | Qt.ScrollBegin, 51 | False, 52 | Qt.MouseEventSynthesizedByQt, 53 | ) 54 | 55 | return QWheelEvent( 56 | QPointF(), 57 | QPointF(), 58 | QPoint(arc, arc), 59 | QPoint(arc, arc), 60 | 1, 61 | Qt.Vertical, 62 | Qt.NoButton, 63 | Qt.NoModifier, 64 | ) 65 | 66 | 67 | def _linspace(start, stop, n): 68 | h = (stop - start) / (n - 1) 69 | for i in range(n): 70 | yield start + h * i 71 | -------------------------------------------------------------------------------- /qtrangeslider/_tests/test_float.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from qtrangeslider import ( 6 | QDoubleRangeSlider, 7 | QDoubleSlider, 8 | QLabeledDoubleRangeSlider, 9 | QLabeledDoubleSlider, 10 | ) 11 | from qtrangeslider.qtcompat import API_NAME 12 | 13 | range_types = {QDoubleRangeSlider, QLabeledDoubleRangeSlider} 14 | 15 | 16 | @pytest.fixture( 17 | params=[ 18 | QDoubleSlider, 19 | QLabeledDoubleSlider, 20 | QDoubleRangeSlider, 21 | QLabeledDoubleRangeSlider, 22 | ] 23 | ) 24 | def ds(qtbot, request): 25 | # convenience fixture that converts value() and setValue() 26 | # to let us use setValue((a, b)) for both range and non-range sliders 27 | cls = request.param 28 | wdg = cls() 29 | qtbot.addWidget(wdg) 30 | 31 | def assert_val_type(): 32 | type_ = float 33 | if cls in range_types: 34 | assert all([isinstance(i, type_) for i in wdg.value()]) # sourcery skip 35 | else: 36 | assert isinstance(wdg.value(), type_) 37 | 38 | def assert_val_eq(val): 39 | assert wdg.value() == val if cls is QDoubleRangeSlider else val[0] 40 | 41 | wdg.assert_val_type = assert_val_type 42 | wdg.assert_val_eq = assert_val_eq 43 | 44 | if cls not in range_types: 45 | superset = wdg.setValue 46 | 47 | def _safe_set(val): 48 | superset(val[0] if isinstance(val, tuple) else val) 49 | 50 | wdg.setValue = _safe_set 51 | 52 | return wdg 53 | 54 | 55 | def test_double_sliders(ds): 56 | ds.setMinimum(10) 57 | ds.setMaximum(99) 58 | ds.setValue((20, 40)) 59 | ds.setSingleStep(1) 60 | assert ds.minimum() == 10 61 | assert ds.maximum() == 99 62 | ds.assert_val_eq((20, 40)) 63 | assert ds.singleStep() == 1 64 | 65 | ds.assert_val_eq((20, 40)) 66 | ds.assert_val_type() 67 | 68 | ds.setValue((20.23, 40.23)) 69 | ds.assert_val_eq((20.23, 40.23)) 70 | ds.assert_val_type() 71 | 72 | assert ds.minimum() == 10 73 | assert ds.maximum() == 99 74 | assert ds.singleStep() == 1 75 | ds.assert_val_eq((20.23, 40.23)) 76 | ds.setValue((20.2343, 40.2342)) 77 | ds.assert_val_eq((20.2343, 40.2342)) 78 | 79 | ds.assert_val_eq((20.2343, 40.2342)) 80 | assert ds.minimum() == 10 81 | assert ds.maximum() == 99 82 | assert ds.singleStep() == 1 83 | 84 | ds.assert_val_eq((20.2343, 40.2342)) 85 | assert ds.minimum() == 10 86 | assert ds.maximum() == 99 87 | assert ds.singleStep() == 1 88 | 89 | 90 | def test_double_sliders_small(ds): 91 | ds.setMaximum(1) 92 | ds.setValue((0.5, 0.9)) 93 | assert ds.minimum() == 0 94 | assert ds.maximum() == 1 95 | ds.assert_val_eq((0.5, 0.9)) 96 | 97 | ds.setValue((0.122233, 0.72644353)) 98 | ds.assert_val_eq((0.122233, 0.72644353)) 99 | 100 | 101 | def test_double_sliders_big(ds): 102 | ds.setValue((20, 80)) 103 | ds.setMaximum(5e14) 104 | assert ds.minimum() == 0 105 | assert ds.maximum() == 5e14 106 | ds.setValue((1.74e9, 1.432e10)) 107 | ds.assert_val_eq((1.74e9, 1.432e10)) 108 | 109 | 110 | @pytest.mark.skipif( 111 | os.name == "nt" and API_NAME == "PyQt6", reason="Not ready for pyqt6" 112 | ) 113 | def test_signals(ds, qtbot): 114 | with qtbot.waitSignal(ds.valueChanged): 115 | ds.setValue((10, 20)) 116 | 117 | with qtbot.waitSignal(ds.rangeChanged): 118 | ds.setMinimum(0.5) 119 | 120 | with qtbot.waitSignal(ds.rangeChanged): 121 | ds.setMaximum(3.7) 122 | 123 | with qtbot.waitSignal(ds.rangeChanged): 124 | ds.setRange(1.2, 3.3) 125 | -------------------------------------------------------------------------------- /qtrangeslider/_tests/test_generic_slider.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import pytest 4 | 5 | from qtrangeslider._generic_slider import _GenericSlider 6 | from qtrangeslider.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt 7 | from qtrangeslider.qtcompat.QtGui import QHoverEvent 8 | from qtrangeslider.qtcompat.QtWidgets import QStyle, QStyleOptionSlider 9 | 10 | from ._testutil import _linspace, _mouse_event, _wheel_event, skip_on_linux_qt6 11 | 12 | 13 | @pytest.fixture(params=[Qt.Horizontal, Qt.Vertical]) 14 | def gslider(qtbot, request): 15 | slider = _GenericSlider(request.param) 16 | qtbot.addWidget(slider) 17 | assert slider.value() == 0 18 | assert slider.minimum() == 0 19 | assert slider.maximum() == 99 20 | yield slider 21 | slider.initStyleOption(QStyleOptionSlider()) 22 | 23 | 24 | def test_change_floatslider_range(gslider: _GenericSlider, qtbot): 25 | with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]): 26 | gslider.setMinimum(10) 27 | 28 | assert gslider.value() == 10 == gslider.minimum() 29 | assert gslider.maximum() == 99 30 | 31 | with qtbot.waitSignal(gslider.rangeChanged): 32 | gslider.setMaximum(90) 33 | assert gslider.value() == 10 == gslider.minimum() 34 | assert gslider.maximum() == 90 35 | 36 | with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]): 37 | gslider.setRange(20, 40) 38 | assert gslider.value() == 20 == gslider.minimum() 39 | assert gslider.maximum() == 40 40 | 41 | with qtbot.waitSignal(gslider.valueChanged): 42 | gslider.setValue(30) 43 | assert gslider.value() == 30 44 | 45 | with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]): 46 | gslider.setMaximum(25) 47 | assert gslider.value() == 25 == gslider.maximum() 48 | assert gslider.minimum() == 20 49 | 50 | 51 | def test_float_values(gslider: _GenericSlider, qtbot): 52 | with qtbot.waitSignal(gslider.rangeChanged): 53 | gslider.setRange(0.25, 0.75) 54 | assert gslider.minimum() == 0.25 55 | assert gslider.maximum() == 0.75 56 | 57 | with qtbot.waitSignal(gslider.valueChanged): 58 | gslider.setValue(0.55) 59 | assert gslider.value() == 0.55 60 | 61 | with qtbot.waitSignal(gslider.valueChanged): 62 | gslider.setValue(1.55) 63 | assert gslider.value() == 0.75 == gslider.maximum() 64 | 65 | 66 | def test_ticks(gslider: _GenericSlider, qtbot): 67 | gslider.setTickInterval(0.3) 68 | assert gslider.tickInterval() == 0.3 69 | gslider.setTickPosition(gslider.TicksAbove) 70 | gslider.show() 71 | 72 | 73 | def test_show(gslider, qtbot): 74 | gslider.show() 75 | 76 | 77 | def test_press_move_release(gslider: _GenericSlider, qtbot): 78 | assert gslider._pressedControl == QStyle.SubControl.SC_None 79 | 80 | opt = QStyleOptionSlider() 81 | gslider.initStyleOption(opt) 82 | style = gslider.style() 83 | hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle) 84 | handle_pos = gslider.mapToGlobal(hrect.center()) 85 | 86 | with qtbot.waitSignal(gslider.sliderPressed): 87 | qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos) 88 | 89 | assert gslider._pressedControl == QStyle.SubControl.SC_SliderHandle 90 | 91 | with qtbot.waitSignals([gslider.sliderMoved, gslider.valueChanged]): 92 | shift = QPoint(0, -8) if gslider.orientation() == Qt.Vertical else QPoint(8, 0) 93 | gslider.mouseMoveEvent(_mouse_event(handle_pos + shift)) 94 | 95 | with qtbot.waitSignal(gslider.sliderReleased): 96 | qtbot.mouseRelease(gslider, Qt.LeftButton, pos=handle_pos) 97 | 98 | assert gslider._pressedControl == QStyle.SubControl.SC_None 99 | 100 | gslider.show() 101 | with qtbot.waitSignal(gslider.sliderPressed): 102 | qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos) 103 | 104 | 105 | @skip_on_linux_qt6 106 | def test_hover(gslider: _GenericSlider): 107 | 108 | opt = QStyleOptionSlider() 109 | gslider.initStyleOption(opt) 110 | style = gslider.style() 111 | hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle) 112 | handle_pos = QPointF(gslider.mapToGlobal(hrect.center())) 113 | 114 | assert gslider._hoverControl == QStyle.SubControl.SC_None 115 | 116 | gslider.event(QHoverEvent(QEvent.HoverEnter, handle_pos, QPointF())) 117 | assert gslider._hoverControl == QStyle.SubControl.SC_SliderHandle 118 | 119 | gslider.event(QHoverEvent(QEvent.HoverLeave, QPointF(-1000, -1000), handle_pos)) 120 | assert gslider._hoverControl == QStyle.SubControl.SC_None 121 | 122 | 123 | def test_wheel(gslider: _GenericSlider, qtbot): 124 | with qtbot.waitSignal(gslider.valueChanged): 125 | gslider.wheelEvent(_wheel_event(120)) 126 | 127 | gslider.wheelEvent(_wheel_event(0)) 128 | 129 | 130 | def test_position(gslider: _GenericSlider, qtbot): 131 | gslider.setSliderPosition(21.2) 132 | assert gslider.sliderPosition() == 21.2 133 | 134 | 135 | def test_steps(gslider: _GenericSlider, qtbot): 136 | gslider.setSingleStep(0.1) 137 | assert gslider.singleStep() == 0.1 138 | 139 | gslider.setSingleStep(1.5e20) 140 | assert gslider.singleStep() == 1.5e20 141 | 142 | gslider.setPageStep(0.2) 143 | assert gslider.pageStep() == 0.2 144 | 145 | gslider.setPageStep(1.5e30) 146 | assert gslider.pageStep() == 1.5e30 147 | 148 | 149 | @pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4))) 150 | def test_slider_extremes(gslider: _GenericSlider, mag, qtbot): 151 | _mag = 10 ** mag 152 | with qtbot.waitSignal(gslider.rangeChanged): 153 | gslider.setRange(-_mag, _mag) 154 | for i in _linspace(-_mag, _mag, 10): 155 | gslider.setValue(i) 156 | assert math.isclose(gslider.value(), i, rel_tol=1e-8) 157 | gslider.initStyleOption(QStyleOptionSlider()) 158 | -------------------------------------------------------------------------------- /qtrangeslider/_tests/test_range_slider.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import pytest 4 | 5 | from qtrangeslider import QDoubleRangeSlider, QRangeSlider 6 | from qtrangeslider.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt 7 | from qtrangeslider.qtcompat.QtGui import QHoverEvent 8 | from qtrangeslider.qtcompat.QtWidgets import QStyle, QStyleOptionSlider 9 | 10 | from ._testutil import _linspace, _mouse_event, _wheel_event, skip_on_linux_qt6 11 | 12 | 13 | @pytest.fixture(params=[Qt.Horizontal, Qt.Vertical]) 14 | def gslider(qtbot, request): 15 | slider = QDoubleRangeSlider(request.param) 16 | qtbot.addWidget(slider) 17 | assert slider.value() == (20, 80) 18 | assert slider.minimum() == 0 19 | assert slider.maximum() == 99 20 | yield slider 21 | slider.initStyleOption(QStyleOptionSlider()) 22 | 23 | 24 | def test_change_floatslider_range(gslider: QRangeSlider, qtbot): 25 | with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]): 26 | gslider.setMinimum(30) 27 | 28 | assert gslider.value()[0] == 30 == gslider.minimum() 29 | assert gslider.maximum() == 99 30 | 31 | with qtbot.waitSignal(gslider.rangeChanged): 32 | gslider.setMaximum(70) 33 | assert gslider.value()[0] == 30 == gslider.minimum() 34 | assert gslider.value()[1] == 70 == gslider.maximum() 35 | 36 | with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]): 37 | gslider.setRange(40, 60) 38 | assert gslider.value()[0] == 40 == gslider.minimum() 39 | assert gslider.maximum() == 60 40 | 41 | with qtbot.waitSignal(gslider.valueChanged): 42 | gslider.setValue([40, 50]) 43 | assert gslider.value()[0] == 40 == gslider.minimum() 44 | assert gslider.value()[1] == 50 45 | 46 | with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]): 47 | gslider.setMaximum(45) 48 | assert gslider.value()[0] == 40 == gslider.minimum() 49 | assert gslider.value()[1] == 45 == gslider.maximum() 50 | 51 | 52 | def test_float_values(gslider: QRangeSlider, qtbot): 53 | with qtbot.waitSignal(gslider.rangeChanged): 54 | gslider.setRange(0.1, 0.9) 55 | assert gslider.minimum() == 0.1 56 | assert gslider.maximum() == 0.9 57 | 58 | with qtbot.waitSignal(gslider.valueChanged): 59 | gslider.setValue([0.4, 0.6]) 60 | assert gslider.value() == (0.4, 0.6) 61 | 62 | with qtbot.waitSignal(gslider.valueChanged): 63 | gslider.setValue([0, 1.9]) 64 | assert gslider.value()[0] == 0.1 == gslider.minimum() 65 | assert gslider.value()[1] == 0.9 == gslider.maximum() 66 | 67 | 68 | def test_position(gslider: QRangeSlider, qtbot): 69 | gslider.setSliderPosition([10, 80]) 70 | assert gslider.sliderPosition() == (10, 80) 71 | 72 | 73 | def test_steps(gslider: QRangeSlider, qtbot): 74 | gslider.setSingleStep(0.1) 75 | assert gslider.singleStep() == 0.1 76 | 77 | gslider.setSingleStep(1.5e20) 78 | assert gslider.singleStep() == 1.5e20 79 | 80 | gslider.setPageStep(0.2) 81 | assert gslider.pageStep() == 0.2 82 | 83 | gslider.setPageStep(1.5e30) 84 | assert gslider.pageStep() == 1.5e30 85 | 86 | 87 | @pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4))) 88 | def test_slider_extremes(gslider: QRangeSlider, mag, qtbot): 89 | _mag = 10 ** mag 90 | with qtbot.waitSignal(gslider.rangeChanged): 91 | gslider.setRange(-_mag, _mag) 92 | for i in _linspace(-_mag, _mag, 10): 93 | gslider.setValue((i, _mag)) 94 | assert math.isclose(gslider.value()[0], i, rel_tol=1e-8) 95 | gslider.initStyleOption(QStyleOptionSlider()) 96 | 97 | 98 | def test_ticks(gslider: QRangeSlider, qtbot): 99 | gslider.setTickInterval(0.3) 100 | assert gslider.tickInterval() == 0.3 101 | gslider.setTickPosition(gslider.TicksAbove) 102 | gslider.show() 103 | 104 | 105 | def test_show(gslider, qtbot): 106 | gslider.show() 107 | 108 | 109 | def test_press_move_release(gslider: QRangeSlider, qtbot): 110 | assert gslider._pressedControl == QStyle.SubControl.SC_None 111 | 112 | opt = QStyleOptionSlider() 113 | gslider.initStyleOption(opt) 114 | style = gslider.style() 115 | hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle) 116 | handle_pos = gslider.mapToGlobal(hrect.center()) 117 | 118 | with qtbot.waitSignal(gslider.sliderPressed): 119 | qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos) 120 | 121 | assert gslider._pressedControl == QStyle.SubControl.SC_SliderHandle 122 | 123 | with qtbot.waitSignals([gslider.sliderMoved, gslider.valueChanged]): 124 | shift = QPoint(0, -8) if gslider.orientation() == Qt.Vertical else QPoint(8, 0) 125 | gslider.mouseMoveEvent(_mouse_event(handle_pos + shift)) 126 | 127 | with qtbot.waitSignal(gslider.sliderReleased): 128 | qtbot.mouseRelease(gslider, Qt.LeftButton, pos=handle_pos) 129 | 130 | assert gslider._pressedControl == QStyle.SubControl.SC_None 131 | 132 | gslider.show() 133 | with qtbot.waitSignal(gslider.sliderPressed): 134 | qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos) 135 | 136 | 137 | @skip_on_linux_qt6 138 | def test_hover(gslider: QRangeSlider): 139 | 140 | hrect = gslider._handleRect(0) 141 | handle_pos = QPointF(gslider.mapToGlobal(hrect.center())) 142 | 143 | assert gslider._hoverControl == QStyle.SubControl.SC_None 144 | 145 | gslider.event(QHoverEvent(QEvent.HoverEnter, handle_pos, QPointF())) 146 | assert gslider._hoverControl == QStyle.SubControl.SC_SliderHandle 147 | 148 | gslider.event(QHoverEvent(QEvent.HoverLeave, QPointF(-1000, -1000), handle_pos)) 149 | assert gslider._hoverControl == QStyle.SubControl.SC_None 150 | 151 | 152 | def test_wheel(gslider: QRangeSlider, qtbot): 153 | with qtbot.waitSignal(gslider.valueChanged): 154 | gslider.wheelEvent(_wheel_event(120)) 155 | 156 | gslider.wheelEvent(_wheel_event(0)) 157 | -------------------------------------------------------------------------------- /qtrangeslider/_tests/test_single_value_sliders.py: -------------------------------------------------------------------------------- 1 | import math 2 | from contextlib import suppress 3 | from distutils.version import LooseVersion 4 | 5 | import pytest 6 | 7 | from qtrangeslider import QDoubleSlider, QLabeledDoubleSlider, QLabeledSlider 8 | from qtrangeslider._generic_slider import _GenericSlider 9 | from qtrangeslider.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt 10 | from qtrangeslider.qtcompat.QtGui import QHoverEvent 11 | from qtrangeslider.qtcompat.QtWidgets import QSlider, QStyle, QStyleOptionSlider 12 | 13 | from ._testutil import ( 14 | QT_VERSION, 15 | SYS_DARWIN, 16 | _linspace, 17 | _mouse_event, 18 | _wheel_event, 19 | skip_on_linux_qt6, 20 | ) 21 | 22 | 23 | @pytest.fixture(params=[Qt.Horizontal, Qt.Vertical], ids=["horizontal", "vertical"]) 24 | def orientation(request): 25 | return request.param 26 | 27 | 28 | START_MI_MAX_VAL = (0, 99, 0) 29 | TEST_SLIDERS = [QDoubleSlider, QLabeledSlider, QLabeledDoubleSlider] 30 | 31 | 32 | def _assert_value_in_range(sld): 33 | val = sld.value() 34 | if isinstance(val, (int, float)): 35 | val = (val,) 36 | assert all(sld.minimum() <= v <= sld.maximum() for v in val) 37 | 38 | 39 | @pytest.fixture(params=TEST_SLIDERS) 40 | def sld(request, qtbot, orientation): 41 | Cls = request.param 42 | slider = Cls(orientation) 43 | slider.setRange(*START_MI_MAX_VAL[:2]) 44 | slider.setValue(START_MI_MAX_VAL[2]) 45 | qtbot.addWidget(slider) 46 | assert (slider.minimum(), slider.maximum(), slider.value()) == START_MI_MAX_VAL 47 | _assert_value_in_range(slider) 48 | yield slider 49 | _assert_value_in_range(slider) 50 | with suppress(AttributeError): 51 | slider.initStyleOption(QStyleOptionSlider()) 52 | 53 | 54 | def called_with(*expected_result): 55 | """Use in check_params_cbs to assert that a callback is called as expected. 56 | 57 | e.g. `called_with(20, 50)` returns a callback that checks that the callback 58 | is called with the arguments (20, 50) 59 | """ 60 | 61 | def check_emitted_values(*values): 62 | return values == expected_result 63 | 64 | return check_emitted_values 65 | 66 | 67 | def test_change_floatslider_range(sld: _GenericSlider, qtbot): 68 | BOTH = [sld.rangeChanged, sld.valueChanged] 69 | 70 | for signals, checks, funcname, args in [ 71 | (BOTH, [called_with(10, 99), called_with(10)], "setMinimum", (10,)), 72 | ([sld.rangeChanged], [called_with(10, 90)], "setMaximum", (90,)), 73 | (BOTH, [called_with(20, 40), called_with(20)], "setRange", (20, 40)), 74 | ([sld.valueChanged], [called_with(30)], "setValue", (30,)), 75 | (BOTH, [called_with(20, 25), called_with(25)], "setMaximum", (25,)), 76 | ([sld.valueChanged], [called_with(23)], "setValue", (23,)), 77 | ]: 78 | with qtbot.waitSignals(signals, check_params_cbs=checks, timeout=500): 79 | getattr(sld, funcname)(*args) 80 | _assert_value_in_range(sld) 81 | 82 | 83 | def test_float_values(sld: _GenericSlider, qtbot): 84 | if type(sld) is QLabeledSlider: 85 | pytest.skip() 86 | for signals, checks, funcname, args in [ 87 | (sld.rangeChanged, called_with(0.1, 0.9), "setRange", (0.1, 0.9)), 88 | (sld.valueChanged, called_with(0.4), "setValue", (0.4,)), 89 | (sld.valueChanged, called_with(0.1), "setValue", (0,)), 90 | (sld.valueChanged, called_with(0.9), "setValue", (1.9,)), 91 | ]: 92 | with qtbot.waitSignal(signals, check_params_cb=checks, timeout=400): 93 | getattr(sld, funcname)(*args) 94 | _assert_value_in_range(sld) 95 | 96 | 97 | def test_ticks(sld: _GenericSlider, qtbot): 98 | sld.setTickInterval(3) 99 | assert sld.tickInterval() == 3 100 | sld.setTickPosition(QSlider.TicksAbove) 101 | sld.show() 102 | 103 | 104 | # FIXME: this isn't testing labeled sliders as it needs to be ... 105 | @pytest.mark.skipif(not SYS_DARWIN, reason="mousePress only working on mac") 106 | def test_press_move_release(sld: _GenericSlider, qtbot): 107 | 108 | _real_sld = getattr(sld, "_slider", sld) 109 | 110 | with suppress(AttributeError): # for QSlider 111 | assert _real_sld._pressedControl == QStyle.SubControl.SC_None 112 | 113 | opt = QStyleOptionSlider() 114 | _real_sld.initStyleOption(opt) 115 | style = _real_sld.style() 116 | hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle) 117 | handle_pos = _real_sld.mapToGlobal(hrect.center()) 118 | 119 | with qtbot.waitSignal(_real_sld.sliderPressed, timeout=300): 120 | qtbot.mousePress(_real_sld, Qt.LeftButton, pos=handle_pos) 121 | 122 | with suppress(AttributeError): 123 | assert sld._pressedControl == QStyle.SubControl.SC_SliderHandle 124 | 125 | with qtbot.waitSignals( 126 | [_real_sld.sliderMoved, _real_sld.valueChanged], timeout=300 127 | ): 128 | shift = ( 129 | QPoint(0, -8) if _real_sld.orientation() == Qt.Vertical else QPoint(8, 0) 130 | ) 131 | _real_sld.mouseMoveEvent(_mouse_event(handle_pos + shift)) 132 | 133 | with qtbot.waitSignal(_real_sld.sliderReleased, timeout=300): 134 | qtbot.mouseRelease(_real_sld, Qt.LeftButton, pos=handle_pos) 135 | 136 | with suppress(AttributeError): 137 | assert _real_sld._pressedControl == QStyle.SubControl.SC_None 138 | 139 | sld.show() 140 | with qtbot.waitSignal(_real_sld.sliderPressed, timeout=300): 141 | qtbot.mousePress(_real_sld, Qt.LeftButton, pos=handle_pos) 142 | 143 | 144 | @skip_on_linux_qt6 145 | def test_hover(sld: _GenericSlider): 146 | 147 | _real_sld = getattr(sld, "_slider", sld) 148 | 149 | opt = QStyleOptionSlider() 150 | _real_sld.initStyleOption(opt) 151 | hrect = _real_sld.style().subControlRect( 152 | QStyle.CC_Slider, opt, QStyle.SC_SliderHandle 153 | ) 154 | handle_pos = QPointF(sld.mapToGlobal(hrect.center())) 155 | 156 | with suppress(AttributeError): # for QSlider 157 | assert _real_sld._hoverControl == QStyle.SubControl.SC_None 158 | 159 | _real_sld.event(QHoverEvent(QEvent.HoverEnter, handle_pos, QPointF())) 160 | with suppress(AttributeError): # for QSlider 161 | assert _real_sld._hoverControl == QStyle.SubControl.SC_SliderHandle 162 | 163 | _real_sld.event(QHoverEvent(QEvent.HoverLeave, QPointF(-1000, -1000), handle_pos)) 164 | with suppress(AttributeError): # for QSlider 165 | assert _real_sld._hoverControl == QStyle.SubControl.SC_None 166 | 167 | 168 | def test_wheel(sld: _GenericSlider, qtbot): 169 | 170 | if type(sld) is QLabeledSlider and QT_VERSION < LooseVersion("5.12"): 171 | pytest.skip() 172 | 173 | _real_sld = getattr(sld, "_slider", sld) 174 | with qtbot.waitSignal(sld.valueChanged, timeout=400): 175 | _real_sld.wheelEvent(_wheel_event(120)) 176 | 177 | _real_sld.wheelEvent(_wheel_event(0)) 178 | 179 | 180 | def test_position(sld: _GenericSlider, qtbot): 181 | sld.setSliderPosition(21) 182 | assert sld.sliderPosition() == 21 183 | 184 | if type(sld) is not QLabeledSlider: 185 | sld.setSliderPosition(21.5) 186 | assert sld.sliderPosition() == 21.5 187 | 188 | 189 | def test_steps(sld: _GenericSlider, qtbot): 190 | 191 | sld.setSingleStep(11) 192 | assert sld.singleStep() == 11 193 | 194 | sld.setPageStep(16) 195 | assert sld.pageStep() == 16 196 | 197 | if type(sld) is not QLabeledSlider: 198 | 199 | sld.setSingleStep(0.1) 200 | assert sld.singleStep() == 0.1 201 | 202 | sld.setSingleStep(1.5e20) 203 | assert sld.singleStep() == 1.5e20 204 | 205 | sld.setPageStep(0.2) 206 | assert sld.pageStep() == 0.2 207 | 208 | sld.setPageStep(1.5e30) 209 | assert sld.pageStep() == 1.5e30 210 | 211 | 212 | @pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4))) 213 | def test_slider_extremes(sld: _GenericSlider, mag, qtbot): 214 | if type(sld) is QLabeledSlider: 215 | pytest.skip() 216 | 217 | _mag = 10 ** mag 218 | with qtbot.waitSignal(sld.rangeChanged, timeout=400): 219 | sld.setRange(-_mag, _mag) 220 | for i in _linspace(-_mag, _mag, 10): 221 | sld.setValue(i) 222 | assert math.isclose(sld.value(), i, rel_tol=1e-8) 223 | -------------------------------------------------------------------------------- /qtrangeslider/_tests/test_slider.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | import pytest 4 | 5 | from qtrangeslider import QRangeSlider 6 | from qtrangeslider._generic_range_slider import SC_BAR, SC_HANDLE, SC_NONE 7 | from qtrangeslider.qtcompat import API_NAME 8 | from qtrangeslider.qtcompat.QtCore import Qt 9 | 10 | NOT_LINUX = platform.system() != "Linux" 11 | NOT_PYSIDE2 = API_NAME != "PySide2" 12 | 13 | skipmouse = pytest.mark.skipif(NOT_LINUX or NOT_PYSIDE2, reason="mouse tests finicky") 14 | 15 | 16 | @pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"]) 17 | def test_basic(qtbot, orientation): 18 | rs = QRangeSlider(getattr(Qt, orientation)) 19 | qtbot.addWidget(rs) 20 | 21 | 22 | @pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"]) 23 | def test_value(qtbot, orientation): 24 | rs = QRangeSlider(getattr(Qt, orientation)) 25 | qtbot.addWidget(rs) 26 | rs.setValue([10, 20]) 27 | assert rs.value() == (10, 20) 28 | 29 | 30 | @pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"]) 31 | def test_range(qtbot, orientation): 32 | rs = QRangeSlider(getattr(Qt, orientation)) 33 | qtbot.addWidget(rs) 34 | rs.setValue([10, 20]) 35 | assert rs.value() == (10, 20) 36 | rs.setRange(15, 20) 37 | assert rs.value() == (15, 20) 38 | assert rs.minimum() == 15 39 | assert rs.maximum() == 20 40 | 41 | 42 | @skipmouse 43 | def test_drag_handles(qtbot): 44 | rs = QRangeSlider(Qt.Horizontal) 45 | qtbot.addWidget(rs) 46 | rs.setRange(0, 99) 47 | rs.setValue((20, 80)) 48 | rs.setMouseTracking(True) 49 | rs.show() 50 | 51 | # press the left handle 52 | pos = rs._handleRect(0).center() 53 | with qtbot.waitSignal(rs.sliderPressed): 54 | qtbot.mousePress(rs, Qt.LeftButton, pos=pos) 55 | assert rs._pressedControl == SC_HANDLE 56 | assert rs._pressedIndex == 0 57 | 58 | # drag the left handle 59 | with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals 60 | for _ in range(15): 61 | pos.setX(pos.x() + 2) 62 | qtbot.mouseMove(rs, pos) 63 | 64 | with qtbot.waitSignal(rs.sliderReleased): 65 | qtbot.mouseRelease(rs, Qt.LeftButton) 66 | 67 | # check the values 68 | assert rs.value()[0] > 30 69 | assert rs._pressedControl == SC_NONE 70 | 71 | # press the right handle 72 | pos = rs._handleRect(1).center() 73 | with qtbot.waitSignal(rs.sliderPressed): 74 | qtbot.mousePress(rs, Qt.LeftButton, pos=pos) 75 | assert rs._pressedControl == SC_HANDLE 76 | assert rs._pressedIndex == 1 77 | 78 | # drag the right handle 79 | with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals 80 | for _ in range(15): 81 | pos.setX(pos.x() - 2) 82 | qtbot.mouseMove(rs, pos) 83 | with qtbot.waitSignal(rs.sliderReleased): 84 | qtbot.mouseRelease(rs, Qt.LeftButton) 85 | 86 | # check the values 87 | assert rs.value()[1] < 70 88 | assert rs._pressedControl == SC_NONE 89 | 90 | 91 | @skipmouse 92 | def test_drag_handles_beyond_edge(qtbot): 93 | rs = QRangeSlider(Qt.Horizontal) 94 | qtbot.addWidget(rs) 95 | rs.setRange(0, 99) 96 | rs.setValue((20, 80)) 97 | rs.setMouseTracking(True) 98 | rs.show() 99 | 100 | # press the right handle 101 | pos = rs._handleRect(1).center() 102 | with qtbot.waitSignal(rs.sliderPressed): 103 | qtbot.mousePress(rs, Qt.LeftButton, pos=pos) 104 | assert rs._pressedControl == SC_HANDLE 105 | assert rs._pressedIndex == 1 106 | 107 | # drag the handle off the right edge and make sure the value gets to the max 108 | for _ in range(7): 109 | pos.setX(pos.x() + 10) 110 | qtbot.mouseMove(rs, pos) 111 | 112 | with qtbot.waitSignal(rs.sliderReleased): 113 | qtbot.mouseRelease(rs, Qt.LeftButton) 114 | 115 | assert rs.value()[1] == 99 116 | 117 | 118 | @skipmouse 119 | def test_bar_drag_beyond_edge(qtbot): 120 | rs = QRangeSlider(Qt.Horizontal) 121 | qtbot.addWidget(rs) 122 | rs.setRange(0, 99) 123 | rs.setValue((20, 80)) 124 | rs.setMouseTracking(True) 125 | rs.show() 126 | 127 | # press the right handle 128 | pos = rs.rect().center() 129 | with qtbot.waitSignal(rs.sliderPressed): 130 | qtbot.mousePress(rs, Qt.LeftButton, pos=pos) 131 | assert rs._pressedControl == SC_BAR 132 | assert rs._pressedIndex == 1 133 | 134 | # drag the handle off the right edge and make sure the value gets to the max 135 | for _ in range(15): 136 | pos.setX(pos.x() + 10) 137 | qtbot.mouseMove(rs, pos) 138 | 139 | with qtbot.waitSignal(rs.sliderReleased): 140 | qtbot.mouseRelease(rs, Qt.LeftButton) 141 | 142 | assert rs.value()[1] == 99 143 | -------------------------------------------------------------------------------- /qtrangeslider/qtcompat/QtCore.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright © 2014-2015 Colin Duquesnoy 4 | # Copyright © 2009- The Spyder Development Team 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (see LICENSE.txt for details) 8 | 9 | """ 10 | Modified from qtpy.QtCore. 11 | Provides QtCore classes and functions. 12 | """ 13 | 14 | from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError 15 | 16 | if PYQT5: 17 | from PyQt5.QtCore import QT_VERSION_STR as __version__ 18 | from PyQt5.QtCore import * 19 | from PyQt5.QtCore import pyqtProperty as Property # noqa 20 | from PyQt5.QtCore import pyqtSignal as Signal # noqa 21 | from PyQt5.QtCore import pyqtSlot as Slot # noqa 22 | 23 | # Those are imported from `import *` 24 | del pyqtSignal, pyqtBoundSignal, pyqtSlot, pyqtProperty, QT_VERSION_STR 25 | elif PYQT6: 26 | from PyQt6.QtCore import QT_VERSION_STR as __version__ 27 | from PyQt6.QtCore import * 28 | from PyQt6.QtCore import pyqtProperty as Property # noqa 29 | from PyQt6.QtCore import pyqtSignal as Signal # noqa 30 | from PyQt6.QtCore import pyqtSlot as Slot # noqa 31 | 32 | # backwards compat with PyQt5 33 | # namespace moves: 34 | for cls in (QEvent, Qt): 35 | for attr in dir(cls): 36 | if not attr[0].isupper(): 37 | continue 38 | ns = getattr(cls, attr) 39 | for name, val in vars(ns).items(): 40 | if not name.startswith("_"): 41 | setattr(cls, name, val) 42 | 43 | # Those are imported from `import *` 44 | del pyqtSignal, pyqtBoundSignal, pyqtSlot, pyqtProperty, QT_VERSION_STR 45 | elif PYSIDE2: 46 | import PySide2.QtCore 47 | from PySide2.QtCore import * # noqa 48 | 49 | __version__ = PySide2.QtCore.__version__ 50 | elif PYSIDE6: 51 | import PySide6.QtCore 52 | from PySide6.QtCore import * # noqa 53 | 54 | __version__ = PySide6.QtCore.__version__ 55 | 56 | else: 57 | raise PythonQtError("No Qt bindings could be found") 58 | -------------------------------------------------------------------------------- /qtrangeslider/qtcompat/QtGui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright © 2014-2015 Colin Duquesnoy 4 | # Copyright © 2009- The Spyder Development Team 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (see LICENSE.txt for details) 8 | 9 | """ 10 | Modified from qtpy.QtGui 11 | Provides QtGui classes and functions. 12 | """ 13 | 14 | from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError 15 | 16 | if PYQT5: 17 | from PyQt5.QtGui import * 18 | elif PYSIDE2: 19 | from PySide2.QtGui import * 20 | elif PYQT6: 21 | from PyQt6.QtGui import * 22 | 23 | # backwards compat with PyQt5 24 | # namespace moves: 25 | for cls in (QPalette,): 26 | for attr in dir(cls): 27 | if not attr[0].isupper(): 28 | continue 29 | ns = getattr(cls, attr) 30 | for name, val in vars(ns).items(): 31 | if not name.startswith("_"): 32 | setattr(cls, name, val) 33 | 34 | def pos(self, *a): 35 | _pos = self.position(*a) 36 | return _pos.toPoint() 37 | 38 | QMouseEvent.pos = pos 39 | 40 | elif PYSIDE6: 41 | from PySide6.QtGui import * # noqa 42 | else: 43 | raise PythonQtError("No Qt bindings could be found") 44 | -------------------------------------------------------------------------------- /qtrangeslider/qtcompat/QtWidgets.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright © 2014-2015 Colin Duquesnoy 4 | # Copyright © 2009- The Spyder Developmet Team 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (see LICENSE.txt for details) 8 | 9 | """ 10 | Modified from qtpy.QtWidgets 11 | Provides widget classes and functions. 12 | """ 13 | 14 | from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError 15 | 16 | if PYQT5: 17 | from PyQt5.QtWidgets import * 18 | elif PYSIDE2: 19 | from PySide2.QtWidgets import * 20 | elif PYQT6: 21 | from PyQt6.QtWidgets import * 22 | 23 | # backwards compat with PyQt5 24 | # namespace moves: 25 | for cls in (QStyle, QSlider, QSizePolicy, QSpinBox): 26 | for attr in dir(cls): 27 | if not attr[0].isupper(): 28 | continue 29 | ns = getattr(cls, attr) 30 | for name, val in vars(ns).items(): 31 | if not name.startswith("_"): 32 | setattr(cls, name, val) 33 | 34 | def exec_(self): 35 | self.exec() 36 | 37 | QApplication.exec_ = exec_ 38 | 39 | elif PYSIDE6: 40 | from PySide6.QtWidgets import * # noqa 41 | 42 | else: 43 | raise PythonQtError("No Qt bindings could be found") 44 | -------------------------------------------------------------------------------- /qtrangeslider/qtcompat/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright © 2009- The Spyder Development Team 4 | # Copyright © 2014-2015 Colin Duquesnoy 5 | # 6 | # Licensed under the terms of the MIT License 7 | # (see LICENSE.txt for details) 8 | 9 | """ 10 | This file is borrowed from qtpy and modified to support PySide6/PyQt6 (drops PyQt4) 11 | """ 12 | 13 | import os 14 | import platform 15 | import sys 16 | import warnings 17 | from distutils.version import LooseVersion 18 | 19 | 20 | class PythonQtError(RuntimeError): 21 | """Error raise if no bindings could be selected.""" 22 | 23 | 24 | class PythonQtWarning(Warning): 25 | """Warning if some features are not implemented in a binding.""" 26 | 27 | 28 | # Qt API environment variable name 29 | QT_API = "QT_API" 30 | 31 | # Names of the expected PyQt5 api 32 | PYQT5_API = ["pyqt5"] 33 | 34 | # Names of the expected PyQt6 api 35 | PYQT6_API = ["pyqt6"] 36 | 37 | # Names of the expected PySide2 api 38 | PYSIDE2_API = ["pyside2"] 39 | 40 | # Names of the expected PySide6 api 41 | PYSIDE6_API = ["pyside6"] 42 | 43 | # Detecting if a binding was specified by the user 44 | binding_specified = QT_API in os.environ 45 | 46 | # Setting a default value for QT_API 47 | os.environ.setdefault(QT_API, "pyqt5") 48 | 49 | API = os.environ[QT_API].lower() 50 | initial_api = API 51 | assert API in (PYQT5_API + PYQT6_API + PYSIDE2_API + PYSIDE6_API) 52 | 53 | PYQT5 = True 54 | PYSIDE2 = PYQT6 = PYSIDE6 = False 55 | 56 | # When `FORCE_QT_API` is set, we disregard 57 | # any previously imported python bindings. 58 | if not os.environ.get("FORCE_QT_API"): 59 | if "PyQt5" in sys.modules: 60 | API = initial_api if initial_api in PYQT5_API else "pyqt5" 61 | elif "PySide2" in sys.modules: 62 | API = initial_api if initial_api in PYSIDE2_API else "pyside2" 63 | elif "PyQt6" in sys.modules: 64 | API = initial_api if initial_api in PYQT6_API else "pyqt6" 65 | elif "PySide6" in sys.modules: 66 | API = initial_api if initial_api in PYSIDE6_API else "pyside6" 67 | 68 | 69 | if API in PYQT5_API: 70 | try: 71 | from PyQt5.QtCore import PYQT_VERSION_STR as PYQT_VERSION # noqa 72 | from PyQt5.QtCore import QT_VERSION_STR as QT_VERSION # noqa 73 | 74 | PYSIDE_VERSION = None # noqa 75 | 76 | if sys.platform == "darwin": 77 | macos_version = LooseVersion(platform.mac_ver()[0]) 78 | if macos_version < LooseVersion("10.10"): 79 | if LooseVersion(QT_VERSION) >= LooseVersion("5.9"): 80 | raise PythonQtError( 81 | "Qt 5.9 or higher only works in " 82 | "macOS 10.10 or higher. Your " 83 | "program will fail in this " 84 | "system." 85 | ) 86 | elif macos_version < LooseVersion("10.11"): 87 | if LooseVersion(QT_VERSION) >= LooseVersion("5.11"): 88 | raise PythonQtError( 89 | "Qt 5.11 or higher only works in " 90 | "macOS 10.11 or higher. Your " 91 | "program will fail in this " 92 | "system." 93 | ) 94 | 95 | del macos_version 96 | except ImportError: 97 | API = os.environ["QT_API"] = "pyside2" 98 | 99 | if API in PYSIDE2_API: 100 | try: 101 | from PySide2 import __version__ as PYSIDE_VERSION # noqa 102 | from PySide2.QtCore import __version__ as QT_VERSION # noqa 103 | 104 | PYQT_VERSION = None # noqa 105 | PYQT5 = False 106 | PYSIDE2 = True 107 | 108 | if sys.platform == "darwin": 109 | macos_version = LooseVersion(platform.mac_ver()[0]) 110 | if macos_version < LooseVersion("10.11"): 111 | if LooseVersion(QT_VERSION) >= LooseVersion("5.11"): 112 | raise PythonQtError( 113 | "Qt 5.11 or higher only works in " 114 | "macOS 10.11 or higher. Your " 115 | "program will fail in this " 116 | "system." 117 | ) 118 | 119 | del macos_version 120 | except ImportError: 121 | API = os.environ["QT_API"] = "pyqt6" 122 | 123 | if API in PYQT6_API: 124 | try: 125 | from PyQt6.QtCore import PYQT_VERSION_STR as PYQT_VERSION # noqa 126 | from PyQt6.QtCore import QT_VERSION_STR as QT_VERSION # noqa 127 | 128 | PYSIDE_VERSION = None # noqa 129 | PYQT5 = False 130 | PYQT6 = True 131 | 132 | except ImportError: 133 | API = os.environ["QT_API"] = "pyside6" 134 | 135 | if API in PYSIDE6_API: 136 | try: 137 | from PySide6 import __version__ as PYSIDE_VERSION # noqa 138 | from PySide6.QtCore import __version__ as QT_VERSION # noqa 139 | 140 | PYQT_VERSION = None # noqa 141 | PYQT5 = False 142 | PYSIDE6 = True 143 | 144 | except ImportError: 145 | API = None 146 | 147 | if API is None: 148 | raise PythonQtError( 149 | "No Qt bindings could be found.\nYou must install one of the following packages " 150 | "to use QtRangeSlider: PyQt5, PyQt6, PySide2, or PySide6" 151 | ) 152 | 153 | # If a correct API name is passed to QT_API and it could not be found, 154 | # switches to another and informs through the warning 155 | if API != initial_api and binding_specified: 156 | warnings.warn( 157 | 'Selected binding "{}" could not be found, ' 158 | 'using "{}"'.format(initial_api, API), 159 | RuntimeWarning, 160 | ) 161 | 162 | API_NAME = { 163 | "pyqt5": "PyQt5", 164 | "pyqt6": "PyQt6", 165 | "pyside2": "PySide2", 166 | "pyside6": "PySide6", 167 | }[API] 168 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = QtRangeSlider 3 | url = https://github.com/tlambert03/QtRangeSlider 4 | license = BSD-3 5 | license_file = LICENSE 6 | description = Multi-handle range slider widget for PyQt/PySide 7 | long_description = file: README.md, CHANGELOG.md 8 | long_description_content_type = text/markdown 9 | author = Talley Lambert 10 | author_email = talley.lambert@gmail.com 11 | keywords = qt, range slider, widget 12 | project_urls = 13 | Source = https://github.com/tlambert03/QtRangeSlider 14 | Tracker = https://github.com/tlambert03/QtRangeSlider/issues 15 | Changelog = https://github.com/tlambert03/QtRangeSlider/blob/master/CHANGELOG.md 16 | classifiers = 17 | Development Status :: 4 - Beta 18 | Environment :: X11 Applications :: Qt 19 | Intended Audience :: Developers 20 | License :: OSI Approved :: BSD License 21 | Operating System :: OS Independent 22 | Programming Language :: Python 23 | Programming Language :: Python :: 3 :: Only 24 | Programming Language :: Python :: 3.6 25 | Programming Language :: Python :: 3.7 26 | Programming Language :: Python :: 3.8 27 | Programming Language :: Python :: 3.9 28 | Topic :: Desktop Environment 29 | Topic :: Software Development 30 | Topic :: Software Development :: User Interfaces 31 | Topic :: Software Development :: Widget Sets 32 | 33 | [options] 34 | zip_safe = False 35 | packages = find: 36 | python_requires = >=3.6 37 | setup_requires = setuptools_scm 38 | 39 | [options.extras_require] 40 | pyside2 = pyside2 41 | pyqt5 = pyqt5 42 | pyside6 = pyside6 43 | pyqt6 = pyqt6 44 | testing = 45 | tox 46 | tox-conda 47 | pytest 48 | pytest-qt 49 | pytest-cov 50 | dev = 51 | ipython 52 | jedi<0.18.0 53 | isort 54 | mypy 55 | pre-commit 56 | %(testing)s 57 | %(pyqt5)s 58 | 59 | [flake8] 60 | exclude = _version.py,.eggs,examples 61 | docstring-convention = numpy 62 | ignore = E203,W503,E501,C901,F403,F405 63 | 64 | [isort] 65 | profile=black 66 | 67 | [tool:pytest] 68 | addopts = -W error 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | PEP 517 doesn’t support editable installs 3 | so this file is currently here to support "pip install -e ." 4 | """ 5 | from setuptools import setup 6 | 7 | setup( 8 | use_scm_version={"write_to": "qtrangeslider/_version.py"}, 9 | setup_requires=["setuptools_scm"], 10 | ) 11 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # For more information about tox, see https://tox.readthedocs.io/en/latest/ 2 | [tox] 3 | envlist = py{37,38,39}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6},py37-{linux,macos,windows}-{pyqt511,pyside511} 4 | toxworkdir=/tmp/.tox 5 | 6 | [gh-actions] 7 | python = 8 | 3.6: py36 9 | 3.7: py37 10 | 3.8: py38 11 | 3.9: py39 12 | 13 | [gh-actions:env] 14 | PLATFORM = 15 | ubuntu-latest: linux 16 | ubuntu-16.04: linux 17 | ubuntu-18.04: linux 18 | ubuntu-20.04: linux 19 | windows-latest: windows 20 | macos-latest: macos 21 | macos-11.0: macos 22 | BACKEND = 23 | pyqt5: pyqt5 24 | pyside2: pyside2 25 | pyqt6: pyqt6 26 | pyside6: pyside6 27 | pyqt511: pyqt511 28 | pyside511: pyside511 29 | 30 | [testenv] 31 | platform = 32 | macos: darwin 33 | linux: linux 34 | windows: win32 35 | passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY 36 | deps = 37 | pytest-xvfb ; sys_platform == 'linux' 38 | pyqt511: pyqt5==5.11.* 39 | pyside511: pyside2==5.11.* 40 | extras = 41 | testing 42 | pyqt5: pyqt5 43 | pyside2: pyside2 44 | pyqt6: pyqt6 45 | pyside6: pyside6 46 | commands_pre = 47 | pyqt511,pyside511: pip install "pytest-qt<4" 48 | commands = pytest --color=yes --cov=qtrangeslider --cov-report=xml {posargs} 49 | --------------------------------------------------------------------------------