Skip to content

Commit

Permalink
Fix extreme float values for slider and spinbox (#178)
Browse files Browse the repository at this point in the history
  • Loading branch information
tlambert03 committed Mar 12, 2021
1 parent 6338127 commit dfd4e1b
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 12 deletions.
7 changes: 7 additions & 0 deletions magicgui/backends/_qtpy/widgets.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Widget implementations (adaptors) for the Qt backend."""
from __future__ import annotations

import math
from typing import TYPE_CHECKING, Any, Iterable, Sequence

import qtpy
Expand Down Expand Up @@ -414,6 +415,12 @@ def __init__(self):
def _mgui_set_value(self, value) -> None:
super()._mgui_set_value(float(value))

def _mgui_set_step(self, value: float):
"""Set the step size."""
if value and value < 1 * 10 ** -self._qwidget.decimals():
self._qwidget.setDecimals(math.ceil(abs(math.log10(value))))
self._qwidget.setSingleStep(value)


class Slider(QBaseRangedWidget, _protocols.SupportsOrientation):
_qwidget: QtW.QSlider
Expand Down
20 changes: 15 additions & 5 deletions magicgui/widgets/_bases/ranged_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ def __init__(self, min: float = 0, max: float = 1000, step: float = 1, **kwargs)
max = kwargs.pop(key)
else:
min = kwargs.pop(key)

# value should be set *after* min max is set
val = kwargs.pop("value", None)
super().__init__(**kwargs)

self.step = step
self.min = min
self.max = max
self.step = step
if kwargs.get("value") is not None:
# value may need to be reset *after* min max is set
self.value = kwargs["value"]
if val is not None:
self.value = val

@property
def options(self) -> dict:
Expand All @@ -51,6 +51,16 @@ def options(self) -> dict:
d.update({"min": self.min, "max": self.max, "step": self.step})
return d

@ValueWidget.value.setter # type: ignore
def value(self, value):
"""Set widget value, will raise Value error if not within min/max."""
if not (self.min <= float(value) <= self.max):
raise ValueError(
f"value {value} is outside of the allowed range: "
f"({self.min}, {self.max})"
)
ValueWidget.value.fset(self, value) # type: ignore

@property
def min(self) -> float:
"""Minimum allowable value for the widget."""
Expand Down
74 changes: 72 additions & 2 deletions magicgui/widgets/_concrete.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
ValueWidget,
Widget,
)
from ._transforms import make_float, make_literal_eval
from ._transforms import make_literal_eval

BUILDING_DOCS = sys.argv[-2:] == ["build", "docs"]

Expand Down Expand Up @@ -286,10 +286,80 @@ class Slider(SliderWidget):
"""A slider widget to adjust an integer value within a range."""


@backend_widget(widget_name="Slider", transform=make_float)
def _int_widget_to_float(name):
app = use_app()
assert app.native
cls = app.get_obj(name)
import builtins

def update_precision(self, min=None, max=None, step=None):
orig = self._precision

if min is not None or max is not None:
min = min or self._mgui_get_min()
max = max or self._mgui_get_max()

# make sure val * precision is within int32 overflow limit for Qt
val = builtins.max([abs(min), abs(max)])
while abs(self._precision * val) >= 2 ** 32 // 2:
self._precision *= 0.1
elif step:
while step < (1 / self._precision):
self._precision *= 10

ratio = self._precision / orig
if ratio != 1:
self._mgui_set_value(self._mgui_get_value() * ratio)
if not step:
self._mgui_set_max(self._mgui_get_max() * ratio)
self._mgui_set_min(self._mgui_get_min() * ratio)
# self._mgui_set_step(self._mgui_get_step() * ratio)

new_cls = type(
f"Float{cls.__name__}",
(cls,),
{
"__module__": __name__,
"_precision": 1e6,
"_update_precision": update_precision,
},
)

# patch the backend widget to convert between float/int
for attr in ["value", "max", "min", "step"]:
get_meth_name = f"_mgui_get_{attr}"
set_meth_name = f"_mgui_set_{attr}"

def new_getter(self, o_getter=getattr(new_cls, get_meth_name)):
return o_getter(self) / self._precision

def new_setter(self, val, o_setter=getattr(new_cls, set_meth_name), attr=attr):
if attr in ("step", "max", "min"):
self._update_precision(**{attr: val})
o_setter(self, int(val * self._precision))

setattr(new_cls, get_meth_name, new_getter)
setattr(new_cls, set_meth_name, new_setter)

return new_cls


@merge_super_sigs
class FloatSlider(SliderWidget):
"""A slider widget to adjust a float value within a range."""

def __init__(self, **kwargs):
kwargs["widget_type"] = _int_widget_to_float("Slider")
super().__init__(**kwargs)

def _post_init(self):
from magicgui.events import EventEmitter

self.changed = EventEmitter(source=self, type="changed")
self._widget._mgui_bind_change_callback(
lambda *x: self.changed(value=self.value)
)


@merge_super_sigs
class LogSlider(TransformedRangedWidget):
Expand Down
6 changes: 5 additions & 1 deletion tests/test_persistence.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import os
import time
from unittest.mock import patch

import pytest

from magicgui._util import debounce, user_cache_dir
from magicgui.widgets import FunctionGui

Expand Down Expand Up @@ -41,6 +44,7 @@ def _my_func(x: int, y="hello"):
assert fg2 is not fg


@pytest.mark.skipif(bool(os.getenv("CI")), reason="debounce test too brittle on CI")
def test_debounce():
store = []

Expand All @@ -53,5 +57,5 @@ def func(x):
time.sleep(0.034)
time.sleep(0.15)

# assert len(store) <= 7 # exact timing will vary on CI ... fails too much
assert len(store) <= 7 # exact timing will vary on CI ... fails too much
assert store[-1] == 9
32 changes: 28 additions & 4 deletions tests/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,18 +473,24 @@ def test_range_widget():

def test_range_widget_max():
# max will override and restrict the possible values
rw = widgets.RangeEdit(-100, 1000, 2, max=(0, 500, 1))
rw = widgets.RangeEdit(-100, 250, 1, max=(0, 500, 1))
v = rw.value
assert isinstance(v, range)
assert (v.start, v.stop, v.step) == (-100, 500, 1)
assert (rw.start.max, rw.stop.max, rw.step.max) == (0, 500, 1)

with pytest.raises(ValueError):
rw = widgets.RangeEdit(100, 300, 5, max=(0, 500, 5))


def test_range_widget_min():
# max will override and restrict the possible values
rw = widgets.RangeEdit(-100, 1000, 2, min=(0, 500, 5))
rw = widgets.RangeEdit(2, 1000, 5, min=(0, 500, 5))
v = rw.value
assert isinstance(v, range)
assert (v.start, v.stop, v.step) == (0, 1000, 5)
assert (rw.start.min, rw.stop.min, rw.step.min) == (0, 500, 5)

with pytest.raises(ValueError):
rw = widgets.RangeEdit(-100, 1000, 5, min=(0, 500, 5))


def test_containers_show_nested_containers():
Expand All @@ -509,3 +515,21 @@ def test_file_dialog_events():
fe.changed = MagicMock(wraps=fe.changed)
fe.line_edit.value = "world"
fe.changed.assert_called_once_with(value=Path("world"))


@pytest.mark.parametrize("WdgClass", [widgets.FloatSlider, widgets.FloatSpinBox])
@pytest.mark.parametrize("value", [1, 1e6, 1e12, 1e16, 1e22])
def test_extreme_floats(WdgClass, value):
wdg = WdgClass(value=value, max=value * 10)
assert round(wdg.value / value, 4) == 1
assert round(wdg.max / value, 4) == 10

wdg.changed = MagicMock(wraps=wdg.changed)
wdg.value = value * 2
wdg.changed.assert_called_once()
a, k = wdg.changed.call_args
assert round(k["value"] / value, 4) == 2

_value = 1 / value
wdg2 = WdgClass(value=_value, step=_value / 10, max=_value * 100)
assert round(wdg2.value / _value, 4) == 1.0

0 comments on commit dfd4e1b

Please sign in to comment.