Skip to content

Add feature to allow editing a slider's value outside of its current range - #353

Open
jules-vanaret wants to merge 2 commits into
pyapp-kit:mainfrom
jules-vanaret:label-extents
Open

jules-vanaret wants to merge 2 commits into
pyapp-kit:mainfrom
jules-vanaret:label-extents

Conversation

@jules-vanaret

@jules-vanaret jules-vanaret commented Sep 3, 2026 •

Copy link
Copy Markdown

PR description

This is a small UX tweak for labeled range sliders.

The problem addressed is that if you edit a slider value label (the text box just above the slider knob) and type a value outside the current slider range, that value gets clamped at the slider bounds. This could be cumbersome for things like contrast-limit controls, where users might expect to be able to type a new endpoint directly instead of first adjusting the min/max edge labels (see napari/napari#9310 ).

Changes

SliderLabel.setValue() normally clamps values to the slider bounds when it refreshes labels. It now accepts a clamp_values parameter, which defaults to True to retain its existing bounds-clamping behavior for normal label updates. SliderLabel._editing_finished() passes clamp_values=False before emitting valueEdited, allowing the owning range slider to receive the raw out-of-range value and decide whether to expand its bounds.

I added an opt-in flag to the range slider itself to trigger the new behaviour:

  • expandRangeOnHandleEdit()
  • setExpandRangeOnHandleEdit(bool)

How to use it in practice

slider = QLabeledRangeSlider() # or QLabeledDoubleRangeSlider()
slider.setExpandRangeOnHandleEdit(True)

With that enabled, typing 110 into the upper handle label will expand the max to 110 and set the handle there instead of being stuck at the old bound.

Reproducer

The small example below illustrates simple- and double-sliders with and without the new range-expansion feature triggered. Try to modify e.g the value of one of the slider above 100 and observe the behavior.

import sys
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget, QLabel
from superqt import QLabeledDoubleRangeSlider, QLabeledRangeSlider


class Reproducer(QWidget):
    def __init__(self):
        super().__init__()

        layout = QVBoxLayout(self)

        self.default_slider = QLabeledRangeSlider()
        self.default_slider.setRange(0, 100)
        self.default_slider.setValue((50,))

        self.default_doubleslider = QLabeledDoubleRangeSlider()
        self.default_doubleslider.setRange(0, 100)
        self.default_doubleslider.setValue((20, 80))

        self.expanding_slider = QLabeledRangeSlider()
        self.expanding_slider.setRange(0, 100)
        self.expanding_slider.setValue((50,))
        self.expanding_slider.setExpandRangeOnHandleEdit(True)

        self.expanding_doubleslider = QLabeledDoubleRangeSlider()
        self.expanding_doubleslider.setRange(0, 100)
        self.expanding_doubleslider.setValue((20, 80))
        self.expanding_doubleslider.setExpandRangeOnHandleEdit(True)

        layout.addWidget(QLabel("This is a simple slider without the new feature:"))
        layout.addWidget(self.default_slider)
        layout.addWidget(QLabel("This is a double slider without the new feature:"))
        layout.addWidget(self.default_doubleslider)
        layout.addWidget(QLabel("This is a simple slider with the new feature:"))
        layout.addWidget(self.expanding_slider)
        layout.addWidget(QLabel("This is a double slider with the new feature:"))
        layout.addWidget(self.expanding_doubleslider)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    win = Reproducer()
    win.show()
    sys.exit(app.exec())

@codecov

codecov Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.77%. Comparing base (a175047) to head (d60e0ef).

Files with missing lines Patch % Lines
src/superqt/sliders/_labeled.py 50.00% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #353      +/-   ##
==========================================
- Coverage   84.90%   84.77%   -0.14%     
==========================================
  Files          49       49              
  Lines        3928     3940      +12     
==========================================
+ Hits         3335     3340       +5     
- Misses        593      600       +7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jules-vanaret

Copy link
Copy Markdown
Author

@Czaki @brisvag @psobolewskiPhD would you have input on this ?

@brisvag brisvag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me, and I like that this is settable and false by default 👍

Comment thread src/superqt/sliders/_labeled.py Outdated
Comment on lines +753 to +758
def setValue(self, val: Any, clamp_values: bool = True) -> None:
if clamp_values:
if val < self._min:
val = self._min
elif val > self._max:
val = self._max

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is not needed right? The range should be expanded before the value is set.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're probably right, I put this more as a fail-safe because I did not properly take the time to see where this function was used elsewhere in the codebase 😅

@tlambert03 tlambert03 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the PR @jules-vanaret ... still some issues to iron out before we can include this though

@@ -608,6 +622,12 @@ def _on_value_changed(self, v: tuple[int, ...]) -> None:

def _on_slider_label_edited(self, pos: float) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for this @jules-vanaret. there's one weird thing still:

if I initialize the slider with range (0,100), value (20,80), and expand on, then enter 150 into the lower handle. What I end up with is this:

image

as before this PR, the lower handle doesn't go past the upper handle (at 80) ... but the range of the upper handle has now been extended to 150. It's subtle... but I would expect that the range should only grow when the handle can actually end up at the typed value?

that is: expand the range only on the side the handle is actually allowed to move to.

def _on_slider_label_edited(self, pos: float) -> None:
    idx = getattr(self.sender(), "_index", 0)
    if self._expand_range_on_handle_edit:
        min_, max_ = self._slider.minimum(), self._slider.maximum()
        if idx == 0 and pos < min_:
            self._slider.setRange(pos, max_)
        elif idx == len(self._slider.value()) - 1 and pos > max_:
            self._slider.setRange(min_, pos)
    self._slider.setSliderPosition(pos, idx)

Comment on lines +723 to +725
value = float(self.text())
self.setValue(value)
self.valueEdited.emit(value)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this introduces a buggy change in behavior?

Before (main):

self.setValue(float(self.text()))   # label clamps to its _min/_max
self.valueEdited.emit(self.value()) # emits the clamped value

Type 500 into a 0–100 slider: the label clamps it to 100 and emits 100.0.

After (PR):

value = float(self.text())
self.setValue(value)                # label still clamps its display
self.valueEdited.emit(value)        # emits the raw 500.0

The display still shows the clamped 100, but the signal now carries 500.0.

... and more importantly: that change hits every labeled slider, whether or not the new flag is on. So this PR isn't entirely opt-in: plain sliders are affected. Here's a test that passes on main but fails here:

from __future__ import annotations

import pytest

from superqt import QLabeledDoubleSlider, QLabeledRangeSlider, QLabeledSlider


@pytest.mark.parametrize("cls", [QLabeledSlider, QLabeledDoubleSlider])
def test_label_value_edited_is_clamped(cls, qtbot):
    slider = cls()
    qtbot.addWidget(slider)
    slider.setRange(0, 100)

    with qtbot.waitSignal(slider._label.valueEdited) as blocker:
        slider._label.setText("500")
        slider._label.editingFinished.emit()

    assert blocker.args == [100.0]


def test_range_handle_label_value_edited_is_clamped(qtbot):
    slider = QLabeledRangeSlider()
    qtbot.addWidget(slider)
    slider.setRange(0, 100)
    slider.setValue((20, 80))
    label = slider._handle_labels[-1]

    with qtbot.waitSignal(label.valueEdited) as blocker:
        label.setText("500")
        label.editingFinished.emit()

    assert blocker.args == [100.0]

moreover, the raw self.valueEdited.emit(value) risks overflow errors: on a plain QLabeledSlider (the int version) with range 0–100 and the new flag off, type 2147483648 into the label and press Enter. you'll see OverflowError: argument 1 overflowed

so, we need to keep _editing_finished as it is on main, and only stop clamping for handle labels when the expand flag is on.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants