├── .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 | 
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 | 
179 |
180 | ##### Big Sur
181 | 
182 |
183 | ### Windows
184 |
185 | 
186 |
187 | ### Linux
188 |
189 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------