Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add QLineEdit support to UISliderWidget #168

Merged
merged 70 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
3423fb0
Rewrite UISliderWidget class to take a QDoubleSpinBox as input
jcornall Dec 16, 2024
5c8dca9
Revert changes to dialog_example_3_save_default.py
jcornall Dec 16, 2024
fe05eae
Add dialog_example_uislider.py
jcornall Dec 16, 2024
ca10630
Update CHANGELOG.md
jcornall Dec 16, 2024
2de3c5b
Add minimum and maximum value parameters and logic to UISliderWidget.py
jcornall Dec 18, 2024
c3f39dc
Update CHANGELOG.md
jcornall Dec 18, 2024
68e58ea
Add UISliderLineEditWidget class, update comments
jcornall Dec 18, 2024
d86f1f7
Replace QDoubleSpinBox in UISliderWidget class with QLineEdit
jcornall Dec 19, 2024
d255d49
Replace QDoubleSpinBox in UISliderLEditWidget class with QLineEdit
jcornall Dec 19, 2024
9916abe
Merge branch 'main' into uislider-dspinbox
jcornall Dec 20, 2024
e93a971
Update CHANGELOG.md
jcornall Dec 20, 2024
a375e9b
Add QLabel to UISliderWidget to display max slider value
jcornall Dec 20, 2024
1313217
Add tick_interval attribute to UISliderWidget class
jcornall Dec 23, 2024
f7517ab
Reformat method names to use camel case, add value getters/setters
jcornall Jan 2, 2025
5c595bf
Add UISliderLEditWidget case to getWidgetState() and applyWidgetState()
jcornall Jan 2, 2025
1545425
Update examples and tests to include UISliderLEditWidget
jcornall Jan 3, 2025
0545d59
Update tests and change defaults for UISlider classes
jcornall Jan 3, 2025
9e6f42b
Replace all instances of UISliderLEditWidget with UISliderEditWidget,
jcornall Jan 3, 2025
c4c9faf
Update CHANGELOG.md
jcornall Jan 3, 2025
a767b0d
Add float support to UISliderWidget value getter and setter
jcornall Jan 3, 2025
8136350
Connect QApplication focusChanged signal to UISlider QLineEdits
jcornall Jan 6, 2025
80e3f6a
Add additional UISliderEditWidget tests
jcornall Jan 7, 2025
9add01a
Merge branch 'main' into uislider-dspinbox
jcornall Jan 8, 2025
d76d8a3
Replace Pyside2 imports with pyqt
jcornall Jan 8, 2025
0d7be4f
Update UISliderWidget, remove UISliderEditWidget, implement scaling
jcornall Jan 9, 2025
c0fa449
Add minimum/maximum argument check to __init__()
jcornall Jan 9, 2025
9f347ac
Correct UISliderWidget minimum/maximum values
jcornall Jan 9, 2025
3114d02
Correct formatting of docstrings
jcornall Jan 10, 2025
162d9a0
Add UISlider tests
jcornall Jan 17, 2025
da49b61
Add checks for non-default UISlider inputs
jcornall Jan 17, 2025
72c14db
Replace references to Pyside2 with qtpy
jcornall Jan 17, 2025
a88d5df
Add parameterized to list of optional dependencies
jcornall Jan 17, 2025
8c6de56
Remove setUp()
jcornall Jan 17, 2025
706db47
Remove parameterized from optional dependencies, add skip_ci decorator
jcornall Jan 17, 2025
150fdbf
Add parameterized to optional dependencies
jcornall Jan 17, 2025
a975d7f
Add returnPressed signal to UISliderWidget
jcornall Jan 17, 2025
112eecf
Fix error when QLineEdit return an empty string
jcornall Jan 17, 2025
e0141c1
Modify UISliderWidget default number_of_ticks, add _updateSlider() call
jcornall Jan 17, 2025
737f93c
Correct recursive method call in setValue()
jcornall Jan 17, 2025
df480fc
Modify UISliderWidget default number_of_ticks
jcornall Jan 17, 2025
b25d151
Correct expected value in parameterized unit test
jcornall Jan 20, 2025
c3431fd
Add additional docstrings to UISliderWidget methods and tests
jcornall Jan 21, 2025
b062bab
Add additional parameterized test cases
jcornall Jan 22, 2025
edd28e0
Update docstrings
jcornall Jan 22, 2025
f7b5d05
Update UISliderWidget docstrings, methods and tests
jcornall Jan 23, 2025
8bf7f3e
Add additional UISliderWidget methods, update tests, docstrings and
jcornall Jan 27, 2025
1775e94
Add UISlider to insert_widgets_examply.py, fix onCancel() behaviour
jcornall Jan 27, 2025
e9e4eab
Update docstrings
jcornall Jan 28, 2025
88014e9
Add UISliderWidget to remove_widgets_example.py, update docstrings
jcornall Jan 29, 2025
1432a80
Modify setValue() to also update the value of the UISlider's QSlider
jcornall Jan 29, 2025
bdee17a
Remove skip_ci decorator from TestUISliderWidget, update recipe
jcornall Jan 29, 2025
4053cde
Modify tearDown() to quit the current QApplication instance after each
jcornall Jan 29, 2025
546aa1b
Patch QApplication in TestUISliderWidget tests
jcornall Jan 29, 2025
fbd0c7c
Modify QApplication patch
jcornall Jan 29, 2025
94fdb7f
Isolate QApplication dependency within UISliderWidget by adding
jcornall Jan 29, 2025
4521810
Add pyqt to qtbindings
jcornall Jan 29, 2025
1c83b6e
Correct 'pyqt' to 'qtpy' in qtbindings
jcornall Jan 29, 2025
3b641d3
Remove qtpy from qtbindings
jcornall Jan 29, 2025
96cb86f
Test adding QApplication to setUp() and tearDown() methods
jcornall Jan 29, 2025
ae3fe75
Remove QApplication from setUp() and tearDown() methods
jcornall Jan 29, 2025
e1f7199
Test empty qtbindings
jcornall Jan 29, 2025
3a999a0
Test adding qtbindings
jcornall Jan 29, 2025
43c2f31
Move pip install of qtbindings
jcornall Jan 29, 2025
604bc08
Reset changes to test.yml
jcornall Jan 29, 2025
edc832a
Add @skip_ci decorator to TestUISliderWidget class
jcornall Jan 30, 2025
a0a074d
Test bare minimum UISlider object instantiation with GitHub Actions
jcornall Jan 30, 2025
3497399
Update testing information in CONTRIBUTING.md
jcornall Jan 30, 2025
d535b10
Update CHANGELOG.md
jcornall Jan 30, 2025
30a8fb0
Add @skip_ci decorator to TestUISliderWidget class
jcornall Jan 30, 2025
11f0ae5
Update CHANGELOG.md, remove redundant test, correct docstring
jcornall Jan 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
- Use `qtpy` as virtual Qt binding package. GHA unit tests are run with PySide2 and PyQt5 (#146)
- Add `pyqt_env.yml` and `pyside_env.yml` environment files (#146)
- Update `CONTRIBUTING.md`, `README.md` and add documentation file (#146)
- Refactor `UISliderWidget` class to support `QLineEdit` and layouts, update tests and examples (#168)
+ Breaks backwards compatability as `UISliderWidget` no longer accepts a `QLabel`

# Version 1.0.2
- Upgrade python to 3.8 in `test.yml` (#171)
Expand Down
13 changes: 11 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@ mamba activate eqt_env

5. Install the dependencies:
```sh
# Install test dependencies
pip install .[dev]
```
The following developer-specific dependencies will be installed:
- pytest
- pytest-cov
- pytest-timeout
- unittest_parametrize

### Merge the `main` Branch
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:
Expand All @@ -63,6 +67,8 @@ Before merging a pull request, all tests must pass. These can be run locally fro
```sh
pytest
```
> [!NOTE]
> 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.

### Install and Run `pre-commit`
Adhere to our styling guide by installing [`pre-commit`](https://pre-commit.com) in your local eqt environment:
Expand All @@ -85,6 +91,8 @@ The [`.pre-commit-config.yaml`](./.pre-commit-config.yaml) config file indicates

## Continuous Integration
GitHub Actions automatically runs a subset of the unit tests on every commit via [`test.yml`](.github/workflows/test.yml).
> [!NOTE]
> 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.

### Testing

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

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

: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.:
> [!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.:

```sh
git tag v1.33.7 -a
Expand Down
8 changes: 6 additions & 2 deletions eqt/ui/UIFormWidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,9 @@ def getWidgetState(self, widget, role=None):
widget_state['value'] = widget.isChecked()
elif isinstance(widget, QtWidgets.QComboBox):
widget_state['value'] = widget.currentIndex()
elif isinstance(widget, UISliderWidget) or isinstance(widget, QtWidgets.QSlider):
elif isinstance(widget, QtWidgets.QSlider):
widget_state['value'] = widget.value()
elif isinstance(widget, UISliderWidget):
widget_state['value'] = widget.value()
elif isinstance(widget, (QtWidgets.QDoubleSpinBox, QtWidgets.QSpinBox)):
widget_state['value'] = widget.value()
Expand Down Expand Up @@ -408,7 +410,9 @@ def applyWidgetState(self, name, state, role=None):
widget.setChecked(value)
elif isinstance(widget, QtWidgets.QComboBox):
widget.setCurrentIndex(value)
elif isinstance(widget, (UISliderWidget, QtWidgets.QSlider)):
elif isinstance(widget, (QtWidgets.QSlider)):
widget.setValue(value)
elif isinstance(widget, (UISliderWidget)):
widget.setValue(value)
elif isinstance(widget, (QtWidgets.QDoubleSpinBox, QtWidgets.QSpinBox)):
widget.setValue(value)
Expand Down
322 changes: 299 additions & 23 deletions eqt/ui/UISliderWidget.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,299 @@
from qtpy import QtCore
from qtpy.QtWidgets import QSlider


class UISliderWidget(QSlider):
'''Creates a Slider widget which updates
a QLabel with its value (which may be scaled
to a non-integer value by setting the scale_factor)'''
def __init__(self, label, scale_factor=1):
QSlider.__init__(self)
self.label = label
self.scale_factor = scale_factor
self.setOrientation(QtCore.Qt.Horizontal)
self.setFocusPolicy(QtCore.Qt.StrongFocus)
self.setTickPosition(QSlider.TicksBelow)
self.valueChanged.connect(self.show_slider_value)

def get_slider_value(self):
return self.value() * self.scale_factor

def show_slider_value(self):
value = self.get_slider_value()
self.label.setText(str(value))
from qtpy import QtCore, QtGui
from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QLineEdit, QSlider, QWidget


class UISliderWidget(QWidget):
'''Creates a QSlider widget with an attached QLineEdit, displaying minimum, maximum
and median values.

This class creates a QGridLayout that includes a QSlider, min/median/max QLabels and
a QLineEdit. The QSlider value changes with the QLineEdit value and vice versa.

The value() and setValue() methods provide an interface for external widgets to
get and set the value of the widget. Some private methods exist (e.g. _setDecimals())
that are responsible for validating and setting arguments.
'''
def __init__(self, minimum, maximum, decimals=2, number_of_steps=2000, number_of_ticks=10,
is_application=True):
'''Creates the QGridLayout and the widgets that populate it (QSlider, QLineEdit,
QLabels). Also sets some attributes.

The arrangement of the widgets is configured.

Signals from the QSlider and QLineEdit are connected, linking user interactions
with these widgets. The update methods call the scaling methods so that value
changes are accurately represented in the other.

Parameters
----------
minimum : float
- Minimum value of the QLineEdit, must be less than the maximum.
maximum : float
- Maximum value of the QLineEdit, must be greater than the minimum.
decimals : int
- Number of decimal places that the QLabels, QLineEdit and QSlider steps can display.
number_of_steps : int
- Number of steps in the QSlider.
number_of_ticks : int
- Number of ticks visualised under the QSlider, determines tick interval.
is_application : bool
- Whether the UISlider has a QApplication that it can reference.
'''
QWidget.__init__(self)

self._setDecimals(decimals)
self._setMinimumMaximum(minimum, maximum)
self.median = round((self.maximum - self.minimum) / 2 + self.minimum, self.decimals)

self._setNumberOfSteps(number_of_steps)
self._setNumberOfTicks(number_of_ticks)

self.slider_minimum = 0
self.slider_maximum = self.number_of_steps
self.scale_factor = (self.slider_maximum - self.slider_minimum) / (self.maximum -
self.minimum)

self.step_size = float((self.maximum - self.minimum) / self.number_of_steps)
self.tick_interval = round(
jcornall marked this conversation as resolved.
Show resolved Hide resolved
(self.slider_maximum - self.slider_minimum) / self.number_of_ticks)

self._setUpQSlider()
self._setUpQValidator()
self._setUpQLineEdit()
self._connectFocusChangedSignal(is_application)
self._setUpQLabels()
self._setUpQGridLayout()

self.setLayout(self.widget_layout)

def value(self):
'''Defines the value of the UISliderWidget using the current float value of the QLineEdit.
This method exists to remain consistent with other QWidgets.
'''
return float(self._getQLineEditValue())

def setValue(self, value):
'''Sets the value of the UISliderWidget using the current float value of the QLineEdit.
To avoid the updated QSlider overwriting the QLineEdit value, QSlider is
updated first with the scaled value.
This method exists to remain consistent with other QWidgets.

Parameters
----------
value : float
'''
self.slider.setValue(self._scaleLineEditToSlider(value))
self.line_edit.setText(str(value))

def _setMinimumMaximum(self, minimum, maximum):
'''Sets the widget's minimum and maximum attributes. Checks that the minimum
is less than the maximum. If the minimum is greater than or equal to the maximum,
jcornall marked this conversation as resolved.
Show resolved Hide resolved
a ValueError is raised.
'''
if minimum >= maximum:
raise ValueError("'minimum' argument must be less than 'maximum'")
else:
self.minimum = round(minimum, self.decimals)
self.maximum = round(maximum, self.decimals)

def _setDecimals(self, decimals):
'''Sets the number of decimal places that the QLabels, QLineEdit and
QSlider steps can display. Checks that the argument provided is valid,
i.e. that it is a positive integer value - an invalid value raises
a ValueError during object instantiation.

Parameters
----------
decimals : int
'''
if decimals <= 0:
raise ValueError("'decimals' value must be greater than 0")
elif isinstance(decimals, int) is not True:
raise TypeError("'decimals' value type must be int")
else:
self.decimals = decimals

def _setNumberOfSteps(self, number_of_steps):
'''Sets the number of steps in the QSlider. Steps are each subdivision of the
QSlider's range. Also checks that the argument provided is valid, i.e. that
it is a positive integer value - an invalid value raises
a ValueError during object instantiation.

Parameters
----------
number_of_steps : int
'''
if number_of_steps <= 0:
raise ValueError("'number_of_steps' value must be greater than 0")
elif isinstance(number_of_steps, int) is not True:
raise TypeError("'number_of_steps' value type must be int")
else:
self.number_of_steps = number_of_steps

def _setNumberOfTicks(self, number_of_ticks):
'''Sets the number of ticks that the QSlider displays. Ticks are the notches
displayed underneath the QSlider. Also checks that the argument provided is
valid, i.e. that it is a positive integer value - an invalid value raises
a ValueError during object instantiation.

Parameters
----------
number_of_ticks : int
'''
if number_of_ticks <= 0:
raise ValueError("'number_of_ticks' value must be greater than 0")
elif isinstance(number_of_ticks, int) is not True:
raise TypeError("'number_of_ticks' value type must be int")
else:
self.number_of_ticks = number_of_ticks

def _setUpQSlider(self):
'''Creates and configures the UISlider's QSlider widget.
A signal from the QSlider is connected to the method that
updates the QLineEdit widget.
'''
self.slider = QSlider()
self.slider.setRange(self.slider_minimum, self.slider_maximum)
self.slider.setOrientation(QtCore.Qt.Horizontal)
self.slider.setFocusPolicy(QtCore.Qt.StrongFocus)
self.slider.setTickPosition(QSlider.TicksBelow)
jcornall marked this conversation as resolved.
Show resolved Hide resolved
self.slider.setTickInterval(self.tick_interval)

self.slider.valueChanged.connect(self._updateQLineEdit)

def _setUpQValidator(self):
'''Creates and configures the UISlider's QValidator widget.
The QValidator validates user input from the QLineEdit.
The locale is set to "en_US" to enforce the correct
decimal format (i.e. '3.14', instead of '3,14').
'''
self.validator = QtGui.QDoubleValidator()
self.validator.setBottom(self.minimum)
self.validator.setTop(self.maximum)
self.validator.setNotation(QtGui.QDoubleValidator.StandardNotation)
self.validator.setLocale(QtCore.QLocale("en_US"))

jcornall marked this conversation as resolved.
Show resolved Hide resolved
def _setUpQLineEdit(self):
'''Creates and configures the UISlider's QLineEdit widget.
Signals from the QLineEdit are connected to the method that
updates the QSlider widget.
'''
self.line_edit = QLineEdit()
self.line_edit.setValidator(self.validator)
self.line_edit.setText(str(self.minimum))
self.line_edit.setClearButtonEnabled(True)
self.line_edit.setPlaceholderText(str(self.minimum))

self.line_edit.editingFinished.connect(self._updateQSlider)
self.line_edit.returnPressed.connect(self._updateQSlider)

def _connectFocusChangedSignal(self, is_application):
'''If the 'is_application' attribute is True, connects the existing
QApplication instance's focusChanged signal to the method that updates
the QSlider. If the focus changes, i.e. the QLineEdit loses focus,
the inputted value is validated and the QSlider will be updated.

If 'is_application' is False, the signal is not connected. The UISlider
will not automatically update when focus is changed while invalid
values have been entered.
'''
if is_application:
QApplication.instance().focusChanged.connect(self._updateQSlider)

def _setUpQLabels(self):
'''Creates and configures the UISlider's QLabel widgets.
The QLabels display the minimum, median and maximum values underneath
the QSlider.
'''
self.min_label = QLabel()
self.min_label.setText(str(self.minimum))
self.median_label = QLabel()
self.median_label.setText(str(self.median))
self.max_label = QLabel()
self.max_label.setText(str(self.maximum))

def _setUpQGridLayout(self):
'''Creates a QGridLayout. Also adds the UISlider's widgets to the QGridLayout.
The QSlider is added to the first row of the QGridLayout and spans the entire row.
The QLabels are added to the second row and are aligned to the left, right and center
of the QSlider.
The QLineEdit is added to the third row and also spans the entire row.
'''
self.widget_layout = QGridLayout()
self.widget_layout.addWidget(self.slider, 0, 0, 1, -1)
self.widget_layout.addWidget(self.min_label, 1, 0, QtCore.Qt.AlignLeft)
self.widget_layout.addWidget(self.median_label, 1, 1, QtCore.Qt.AlignCenter)
self.widget_layout.addWidget(self.max_label, 1, 2, QtCore.Qt.AlignRight)
self.widget_layout.addWidget(self.line_edit, 2, 0, 1, -1)

def _getQSliderValue(self):
'''Gets the current value of the QSlider, returning either 0 or a positive integer.
'''
return self.slider.value()

def _getQLineEditValue(self):
'''Gets the current value of the QLineEdit. Returns a string value between the
UISliderWidget's minimum and maximum values.
'''
return self.line_edit.text()

def _updateQSlider(self):
'''Updates the QSlider to reflect the current value of the QLineEdit.
The method uses the state of the QValidator to check that the QLineEdit
value is valid - if it is valid, it sets the value of the QSlider to the
scaled value of the QLineEdit. Otherwise, it will update the QSlider with
either the scaled value of the QLineEdit's minimum or maximum. Values
outside the range will raise a ValueError.
'''
if self._getQLineEditValue() == '':
self.line_edit.setText(str(self.minimum))

line_edit_value = float(self._getQLineEditValue())
state = self.validator.validate(self.line_edit.text(), 0)
if state[0] == QtGui.QDoubleValidator.Acceptable:
scaled_value = self._scaleLineEditToSlider(line_edit_value)
self.slider.setValue(scaled_value)
self.setValue(line_edit_value)
elif line_edit_value > self.maximum:
self.line_edit.setText(str(self.maximum))
self.slider.setValue(self.slider_maximum)
jcornall marked this conversation as resolved.
Show resolved Hide resolved
self.setValue(self.maximum)
raise ValueError("range exceeded: resetting to 'maximum'")
elif line_edit_value < self.minimum:
self.line_edit.setText(str(self.minimum))
self.slider.setValue(self.slider_minimum)
self.setValue(self.minimum)
raise ValueError("range exceeded: resetting to 'minimum'")

def _updateQLineEdit(self):
'''Updates the QLineEdit to reflect the current value of the QSlider.
The method sets the value of the QLineEdit to the scaled value of the QSlider.
'''
slider_value = self._getQSliderValue()
self.line_edit.setText(str(self._scaleSliderToLineEdit(slider_value)))

def _scaleLineEditToSlider(self, value):
'''Converts a QLineEdit value to a scaled QSlider value. The method calculates
the scale factor for the conversion using the minimum and maximum
values of the QSlider and QLineEdit.
Returns the scaled value.

Parameters
----------
value : float
'''
value = self.slider_minimum + (self.scale_factor * (value - self.minimum))
return int(value)

def _scaleSliderToLineEdit(self, value):
'''Converts a QSlider value to a scaled QLineEdit value. The method calculates
the scale factor for the conversion using the minimum and maximum
values of the QSlider and QLineEdit.
Returns the scaled value, rounded as per the decimals property.

Parameters
----------
value : integer
'''
value = self.minimum + (1 / self.scale_factor * (value - self.slider_minimum))
return round(float(value), self.decimals)
Loading
Loading