Skip to content

Commit 877db42

Browse files
authored
Add QLineEdit support to UISliderWidget (#168)
- Refactor `UISliderWidget` class to support `QLineEdit` and layouts, update tests and examples + Breaks backwards compatability as `UISliderWidget` no longer accepts a `QLabel`
1 parent 19be963 commit 877db42

14 files changed

+1170
-192
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
- Use `qtpy` as virtual Qt binding package. GHA unit tests are run with PySide2 and PyQt5 (#146)
33
- Add `pyqt_env.yml` and `pyside_env.yml` environment files (#146)
44
- Update `CONTRIBUTING.md`, `README.md` and add documentation file (#146)
5+
- Refactor `UISliderWidget` class to support `QLineEdit` and layouts, update tests and examples (#168)
6+
+ Breaks backwards compatability as `UISliderWidget` no longer accepts a `QLabel`
57

68
# Version 1.0.2
79
- Upgrade python to 3.8 in `test.yml` (#171)

CONTRIBUTING.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,13 @@ mamba activate eqt_env
4848

4949
5. Install the dependencies:
5050
```sh
51-
# Install test dependencies
5251
pip install .[dev]
5352
```
53+
The following developer-specific dependencies will be installed:
54+
- pytest
55+
- pytest-cov
56+
- pytest-timeout
57+
- unittest_parametrize
5458

5559
### Merge the `main` Branch
5660
Conflicts may exist if your branch is behind the `main` branch. To resolve conflicts between branches, merge the `main` branch into your current working branch:
@@ -63,6 +67,8 @@ Before merging a pull request, all tests must pass. These can be run locally fro
6367
```sh
6468
pytest
6569
```
70+
> [!NOTE]
71+
> For files that test the GUI elements, the `@skip_ci` decorator has been included to skip these tests when the GitHub Actions are executed after pushing/merging. Without the decorator, these GUI test files will cause the `pytest` GitHub Action to fail.
6672
6773
### Install and Run `pre-commit`
6874
Adhere to our styling guide by installing [`pre-commit`](https://pre-commit.com) in your local eqt environment:
@@ -85,6 +91,8 @@ The [`.pre-commit-config.yaml`](./.pre-commit-config.yaml) config file indicates
8591

8692
## Continuous Integration
8793
GitHub Actions automatically runs a subset of the unit tests on every commit via [`test.yml`](.github/workflows/test.yml).
94+
> [!NOTE]
95+
> GitHub Actions does not currently support unit tests that test GUI elements. These tests should include the `@skip_ci` decorator so that they are skipped when the GitHub Actions are executed.
8896
8997
### Testing
9098

@@ -102,7 +110,8 @@ Runs automatically -- when an annotated tag is pushed -- after builds (above) su
102110

103111
Publishes to [PyPI](https://pypi.org/project/eqt).
104112

105-
:warning: The annotated tag's `title` must be `Version <number without v-prefix>` (separated by a blank line) and the `body` must contain release notes, e.g.:
113+
> [!WARNING]
114+
> The annotated tag's `title` must be `Version <number without v-prefix>` (separated by a blank line) and the `body` must contain release notes, e.g.:
106115
107116
```sh
108117
git tag v1.33.7 -a

eqt/ui/UIFormWidget.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,9 @@ def getWidgetState(self, widget, role=None):
325325
widget_state['value'] = widget.isChecked()
326326
elif isinstance(widget, QtWidgets.QComboBox):
327327
widget_state['value'] = widget.currentIndex()
328-
elif isinstance(widget, UISliderWidget) or isinstance(widget, QtWidgets.QSlider):
328+
elif isinstance(widget, QtWidgets.QSlider):
329+
widget_state['value'] = widget.value()
330+
elif isinstance(widget, UISliderWidget):
329331
widget_state['value'] = widget.value()
330332
elif isinstance(widget, (QtWidgets.QDoubleSpinBox, QtWidgets.QSpinBox)):
331333
widget_state['value'] = widget.value()
@@ -408,7 +410,9 @@ def applyWidgetState(self, name, state, role=None):
408410
widget.setChecked(value)
409411
elif isinstance(widget, QtWidgets.QComboBox):
410412
widget.setCurrentIndex(value)
411-
elif isinstance(widget, (UISliderWidget, QtWidgets.QSlider)):
413+
elif isinstance(widget, (QtWidgets.QSlider)):
414+
widget.setValue(value)
415+
elif isinstance(widget, (UISliderWidget)):
412416
widget.setValue(value)
413417
elif isinstance(widget, (QtWidgets.QDoubleSpinBox, QtWidgets.QSpinBox)):
414418
widget.setValue(value)

eqt/ui/UISliderWidget.py

Lines changed: 299 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,299 @@
1-
from qtpy import QtCore
2-
from qtpy.QtWidgets import QSlider
3-
4-
5-
class UISliderWidget(QSlider):
6-
'''Creates a Slider widget which updates
7-
a QLabel with its value (which may be scaled
8-
to a non-integer value by setting the scale_factor)'''
9-
def __init__(self, label, scale_factor=1):
10-
QSlider.__init__(self)
11-
self.label = label
12-
self.scale_factor = scale_factor
13-
self.setOrientation(QtCore.Qt.Horizontal)
14-
self.setFocusPolicy(QtCore.Qt.StrongFocus)
15-
self.setTickPosition(QSlider.TicksBelow)
16-
self.valueChanged.connect(self.show_slider_value)
17-
18-
def get_slider_value(self):
19-
return self.value() * self.scale_factor
20-
21-
def show_slider_value(self):
22-
value = self.get_slider_value()
23-
self.label.setText(str(value))
1+
from qtpy import QtCore, QtGui
2+
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QLineEdit, QSlider, QWidget
3+
4+
5+
class UISliderWidget(QWidget):
6+
'''Creates a QSlider widget with an attached QLineEdit, displaying minimum, maximum
7+
and median values.
8+
9+
This class creates a QGridLayout that includes a QSlider, min/median/max QLabels and
10+
a QLineEdit. The QSlider value changes with the QLineEdit value and vice versa.
11+
12+
The value() and setValue() methods provide an interface for external widgets to
13+
get and set the value of the widget. Some private methods exist (e.g. _setDecimals())
14+
that are responsible for validating and setting arguments.
15+
'''
16+
def __init__(self, minimum, maximum, decimals=2, number_of_steps=2000, number_of_ticks=10,
17+
is_application=True):
18+
'''Creates the QGridLayout and the widgets that populate it (QSlider, QLineEdit,
19+
QLabels). Also sets some attributes.
20+
21+
The arrangement of the widgets is configured.
22+
23+
Signals from the QSlider and QLineEdit are connected, linking user interactions
24+
with these widgets. The update methods call the scaling methods so that value
25+
changes are accurately represented in the other.
26+
27+
Parameters
28+
----------
29+
minimum : float
30+
- Minimum value of the QLineEdit, must be less than the maximum.
31+
maximum : float
32+
- Maximum value of the QLineEdit, must be greater than the minimum.
33+
decimals : int
34+
- Number of decimal places that the QLabels, QLineEdit and QSlider steps can display.
35+
number_of_steps : int
36+
- Number of steps in the QSlider.
37+
number_of_ticks : int
38+
- Number of ticks visualised under the QSlider, determines tick interval.
39+
is_application : bool
40+
- Whether the UISlider has a QApplication that it can reference.
41+
'''
42+
QWidget.__init__(self)
43+
44+
self._setDecimals(decimals)
45+
self._setMinimumMaximum(minimum, maximum)
46+
self.median = round((self.maximum - self.minimum) / 2 + self.minimum, self.decimals)
47+
48+
self._setNumberOfSteps(number_of_steps)
49+
self._setNumberOfTicks(number_of_ticks)
50+
51+
self.slider_minimum = 0
52+
self.slider_maximum = self.number_of_steps
53+
self.scale_factor = (self.slider_maximum - self.slider_minimum) / (self.maximum -
54+
self.minimum)
55+
56+
self.step_size = float((self.maximum - self.minimum) / self.number_of_steps)
57+
self.tick_interval = round(
58+
(self.slider_maximum - self.slider_minimum) / self.number_of_ticks)
59+
60+
self._setUpQSlider()
61+
self._setUpQValidator()
62+
self._setUpQLineEdit()
63+
self._connectFocusChangedSignal(is_application)
64+
self._setUpQLabels()
65+
self._setUpQGridLayout()
66+
67+
self.setLayout(self.widget_layout)
68+
69+
def value(self):
70+
'''Defines the value of the UISliderWidget using the current float value of the QLineEdit.
71+
This method exists to remain consistent with other QWidgets.
72+
'''
73+
return float(self._getQLineEditValue())
74+
75+
def setValue(self, value):
76+
'''Sets the value of the UISliderWidget using the current float value of the QLineEdit.
77+
To avoid the updated QSlider overwriting the QLineEdit value, QSlider is
78+
updated first with the scaled value.
79+
This method exists to remain consistent with other QWidgets.
80+
81+
Parameters
82+
----------
83+
value : float
84+
'''
85+
self.slider.setValue(self._scaleLineEditToSlider(value))
86+
self.line_edit.setText(str(value))
87+
88+
def _setMinimumMaximum(self, minimum, maximum):
89+
'''Sets the widget's minimum and maximum attributes. Checks that the minimum
90+
is less than the maximum. If the minimum is greater than or equal to the maximum,
91+
a ValueError is raised.
92+
'''
93+
if minimum >= maximum:
94+
raise ValueError("'minimum' argument must be less than 'maximum'")
95+
else:
96+
self.minimum = round(minimum, self.decimals)
97+
self.maximum = round(maximum, self.decimals)
98+
99+
def _setDecimals(self, decimals):
100+
'''Sets the number of decimal places that the QLabels, QLineEdit and
101+
QSlider steps can display. Checks that the argument provided is valid,
102+
i.e. that it is a positive integer value - an invalid value raises
103+
a ValueError during object instantiation.
104+
105+
Parameters
106+
----------
107+
decimals : int
108+
'''
109+
if decimals <= 0:
110+
raise ValueError("'decimals' value must be greater than 0")
111+
elif isinstance(decimals, int) is not True:
112+
raise TypeError("'decimals' value type must be int")
113+
else:
114+
self.decimals = decimals
115+
116+
def _setNumberOfSteps(self, number_of_steps):
117+
'''Sets the number of steps in the QSlider. Steps are each subdivision of the
118+
QSlider's range. Also checks that the argument provided is valid, i.e. that
119+
it is a positive integer value - an invalid value raises
120+
a ValueError during object instantiation.
121+
122+
Parameters
123+
----------
124+
number_of_steps : int
125+
'''
126+
if number_of_steps <= 0:
127+
raise ValueError("'number_of_steps' value must be greater than 0")
128+
elif isinstance(number_of_steps, int) is not True:
129+
raise TypeError("'number_of_steps' value type must be int")
130+
else:
131+
self.number_of_steps = number_of_steps
132+
133+
def _setNumberOfTicks(self, number_of_ticks):
134+
'''Sets the number of ticks that the QSlider displays. Ticks are the notches
135+
displayed underneath the QSlider. Also checks that the argument provided is
136+
valid, i.e. that it is a positive integer value - an invalid value raises
137+
a ValueError during object instantiation.
138+
139+
Parameters
140+
----------
141+
number_of_ticks : int
142+
'''
143+
if number_of_ticks <= 0:
144+
raise ValueError("'number_of_ticks' value must be greater than 0")
145+
elif isinstance(number_of_ticks, int) is not True:
146+
raise TypeError("'number_of_ticks' value type must be int")
147+
else:
148+
self.number_of_ticks = number_of_ticks
149+
150+
def _setUpQSlider(self):
151+
'''Creates and configures the UISlider's QSlider widget.
152+
A signal from the QSlider is connected to the method that
153+
updates the QLineEdit widget.
154+
'''
155+
self.slider = QSlider()
156+
self.slider.setRange(self.slider_minimum, self.slider_maximum)
157+
self.slider.setOrientation(QtCore.Qt.Horizontal)
158+
self.slider.setFocusPolicy(QtCore.Qt.StrongFocus)
159+
self.slider.setTickPosition(QSlider.TicksBelow)
160+
self.slider.setTickInterval(self.tick_interval)
161+
162+
self.slider.valueChanged.connect(self._updateQLineEdit)
163+
164+
def _setUpQValidator(self):
165+
'''Creates and configures the UISlider's QValidator widget.
166+
The QValidator validates user input from the QLineEdit.
167+
The locale is set to "en_US" to enforce the correct
168+
decimal format (i.e. '3.14', instead of '3,14').
169+
'''
170+
self.validator = QtGui.QDoubleValidator()
171+
self.validator.setBottom(self.minimum)
172+
self.validator.setTop(self.maximum)
173+
self.validator.setNotation(QtGui.QDoubleValidator.StandardNotation)
174+
self.validator.setLocale(QtCore.QLocale("en_US"))
175+
176+
def _setUpQLineEdit(self):
177+
'''Creates and configures the UISlider's QLineEdit widget.
178+
Signals from the QLineEdit are connected to the method that
179+
updates the QSlider widget.
180+
'''
181+
self.line_edit = QLineEdit()
182+
self.line_edit.setValidator(self.validator)
183+
self.line_edit.setText(str(self.minimum))
184+
self.line_edit.setClearButtonEnabled(True)
185+
self.line_edit.setPlaceholderText(str(self.minimum))
186+
187+
self.line_edit.editingFinished.connect(self._updateQSlider)
188+
self.line_edit.returnPressed.connect(self._updateQSlider)
189+
190+
def _connectFocusChangedSignal(self, is_application):
191+
'''If the 'is_application' attribute is True, connects the existing
192+
QApplication instance's focusChanged signal to the method that updates
193+
the QSlider. If the focus changes, i.e. the QLineEdit loses focus,
194+
the inputted value is validated and the QSlider will be updated.
195+
196+
If 'is_application' is False, the signal is not connected. The UISlider
197+
will not automatically update when focus is changed while invalid
198+
values have been entered.
199+
'''
200+
if is_application:
201+
QApplication.instance().focusChanged.connect(self._updateQSlider)
202+
203+
def _setUpQLabels(self):
204+
'''Creates and configures the UISlider's QLabel widgets.
205+
The QLabels display the minimum, median and maximum values underneath
206+
the QSlider.
207+
'''
208+
self.min_label = QLabel()
209+
self.min_label.setText(str(self.minimum))
210+
self.median_label = QLabel()
211+
self.median_label.setText(str(self.median))
212+
self.max_label = QLabel()
213+
self.max_label.setText(str(self.maximum))
214+
215+
def _setUpQGridLayout(self):
216+
'''Creates a QGridLayout. Also adds the UISlider's widgets to the QGridLayout.
217+
The QSlider is added to the first row of the QGridLayout and spans the entire row.
218+
The QLabels are added to the second row and are aligned to the left, right and center
219+
of the QSlider.
220+
The QLineEdit is added to the third row and also spans the entire row.
221+
'''
222+
self.widget_layout = QGridLayout()
223+
self.widget_layout.addWidget(self.slider, 0, 0, 1, -1)
224+
self.widget_layout.addWidget(self.min_label, 1, 0, QtCore.Qt.AlignLeft)
225+
self.widget_layout.addWidget(self.median_label, 1, 1, QtCore.Qt.AlignCenter)
226+
self.widget_layout.addWidget(self.max_label, 1, 2, QtCore.Qt.AlignRight)
227+
self.widget_layout.addWidget(self.line_edit, 2, 0, 1, -1)
228+
229+
def _getQSliderValue(self):
230+
'''Gets the current value of the QSlider, returning either 0 or a positive integer.
231+
'''
232+
return self.slider.value()
233+
234+
def _getQLineEditValue(self):
235+
'''Gets the current value of the QLineEdit. Returns a string value between the
236+
UISliderWidget's minimum and maximum values.
237+
'''
238+
return self.line_edit.text()
239+
240+
def _updateQSlider(self):
241+
'''Updates the QSlider to reflect the current value of the QLineEdit.
242+
The method uses the state of the QValidator to check that the QLineEdit
243+
value is valid - if it is valid, it sets the value of the QSlider to the
244+
scaled value of the QLineEdit. Otherwise, it will update the QSlider with
245+
either the scaled value of the QLineEdit's minimum or maximum. Values
246+
outside the range will raise a ValueError.
247+
'''
248+
if self._getQLineEditValue() == '':
249+
self.line_edit.setText(str(self.minimum))
250+
251+
line_edit_value = float(self._getQLineEditValue())
252+
state = self.validator.validate(self.line_edit.text(), 0)
253+
if state[0] == QtGui.QDoubleValidator.Acceptable:
254+
scaled_value = self._scaleLineEditToSlider(line_edit_value)
255+
self.slider.setValue(scaled_value)
256+
self.setValue(line_edit_value)
257+
elif line_edit_value > self.maximum:
258+
self.line_edit.setText(str(self.maximum))
259+
self.slider.setValue(self.slider_maximum)
260+
self.setValue(self.maximum)
261+
raise ValueError("range exceeded: resetting to 'maximum'")
262+
elif line_edit_value < self.minimum:
263+
self.line_edit.setText(str(self.minimum))
264+
self.slider.setValue(self.slider_minimum)
265+
self.setValue(self.minimum)
266+
raise ValueError("range exceeded: resetting to 'minimum'")
267+
268+
def _updateQLineEdit(self):
269+
'''Updates the QLineEdit to reflect the current value of the QSlider.
270+
The method sets the value of the QLineEdit to the scaled value of the QSlider.
271+
'''
272+
slider_value = self._getQSliderValue()
273+
self.line_edit.setText(str(self._scaleSliderToLineEdit(slider_value)))
274+
275+
def _scaleLineEditToSlider(self, value):
276+
'''Converts a QLineEdit value to a scaled QSlider value. The method calculates
277+
the scale factor for the conversion using the minimum and maximum
278+
values of the QSlider and QLineEdit.
279+
Returns the scaled value.
280+
281+
Parameters
282+
----------
283+
value : float
284+
'''
285+
value = self.slider_minimum + (self.scale_factor * (value - self.minimum))
286+
return int(value)
287+
288+
def _scaleSliderToLineEdit(self, value):
289+
'''Converts a QSlider value to a scaled QLineEdit value. The method calculates
290+
the scale factor for the conversion using the minimum and maximum
291+
values of the QSlider and QLineEdit.
292+
Returns the scaled value, rounded as per the decimals property.
293+
294+
Parameters
295+
----------
296+
value : integer
297+
'''
298+
value = self.minimum + (1 / self.scale_factor * (value - self.slider_minimum))
299+
return round(float(value), self.decimals)

0 commit comments

Comments
 (0)