From 703320c17cb43040d86a74b770befe439089e93e Mon Sep 17 00:00:00 2001 From: Ales Erjavec Date: Mon, 25 Jan 2021 12:33:41 +0100 Subject: [PATCH] DoubleSpinBox: Add a DoubleSpinBox with non-fixed decimal rounding Implement `stepBy` update using decimal type. --- Orange/widgets/data/owimpute.py | 11 ++- Orange/widgets/utils/spinbox.py | 109 +++++++++++++++++++++ Orange/widgets/utils/tests/test_spinbox.py | 45 +++++++++ 3 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 Orange/widgets/utils/spinbox.py create mode 100644 Orange/widgets/utils/tests/test_spinbox.py diff --git a/Orange/widgets/data/owimpute.py b/Orange/widgets/data/owimpute.py index 08270311e4f..0cca7768745 100644 --- a/Orange/widgets/data/owimpute.py +++ b/Orange/widgets/data/owimpute.py @@ -11,7 +11,7 @@ from AnyQt.QtWidgets import ( QGroupBox, QRadioButton, QPushButton, QHBoxLayout, QGridLayout, QVBoxLayout, QStackedWidget, QComboBox, QWidget, - QButtonGroup, QStyledItemDelegate, QListView, QDoubleSpinBox, QLabel + QButtonGroup, QStyledItemDelegate, QListView, QLabel ) from AnyQt.QtCore import Qt, QThread, QModelIndex, QDateTime from AnyQt.QtCore import pyqtSlot as Slot @@ -27,6 +27,7 @@ from Orange.widgets.utils.sql import check_sql_input from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.utils.state_summary import format_summary_details +from Orange.widgets.utils.spinbox import DoubleSpinBox from Orange.widgets.widget import OWWidget, Msg, Input, Output from Orange.classification import SimpleTreeLearner @@ -209,8 +210,8 @@ def set_default_time(datetime): button.setChecked(Method.Default == self.default_method_index) hlayout.addWidget(button) - self.numeric_value_widget = QDoubleSpinBox( - minimum=DBL_MIN, maximum=DBL_MAX, singleStep=.1, decimals=5, + self.numeric_value_widget = DoubleSpinBox( + minimum=DBL_MIN, maximum=DBL_MAX, singleStep=.1, value=self.default_numeric_value, alignment=Qt.AlignRight, enabled=self.default_method_index == Method.Default, @@ -281,9 +282,9 @@ def set_default_time(datetime): sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLength, activated=self._on_value_selected ) - self.value_double = QDoubleSpinBox( + self.value_double = DoubleSpinBox( editingFinished=self._on_value_selected, - minimum=DBL_MIN, maximum=DBL_MAX, singleStep=.1, decimals=5, + minimum=DBL_MIN, maximum=DBL_MAX, singleStep=.1, ) self.value_stack = value_stack = QStackedWidget() value_stack.addWidget(self.value_combo) diff --git a/Orange/widgets/utils/spinbox.py b/Orange/widgets/utils/spinbox.py new file mode 100644 index 00000000000..74092071587 --- /dev/null +++ b/Orange/widgets/utils/spinbox.py @@ -0,0 +1,109 @@ +import math +from decimal import Decimal + +import numpy as np + +from AnyQt.QtCore import QLocale +from AnyQt.QtWidgets import QDoubleSpinBox + +DBL_MIN = float(np.finfo(float).min) +DBL_MAX = float(np.finfo(float).max) +DBL_MAX_10_EXP = math.floor(math.log10(DBL_MAX)) +DBL_DIG = math.floor(math.log10(2 ** np.finfo(float).nmant)) + + +class DoubleSpinBox(QDoubleSpinBox): + """ + A QDoubleSpinSubclass with non-fixed decimal precision/rounding. + """ + def __init__(self, parent=None, decimals=-1, minimumStep=1e-5, **kwargs): + self.__decimals = decimals + self.__minimumStep = minimumStep + stepType = kwargs.pop("stepType", DoubleSpinBox.DefaultStepType) + super().__init__(parent, **kwargs) + if decimals < 0: + super().setDecimals(DBL_MAX_10_EXP + DBL_DIG) + else: + super().setDecimals(decimals) + self.setStepType(stepType) + + def setDecimals(self, prec: int) -> None: + """ + Set the number of decimals in display/edit + + If negative value then no rounding takes place and the value is + displayed using `QLocale.FloatingPointShortest` precision. + """ + self.__decimals = prec + if prec < 0: + # disable rounding in base implementation. + super().setDecimals(DBL_MAX_10_EXP + DBL_DIG) + else: + super().setDecimals(prec) + + def decimals(self): + return self.__decimals + + def setMinimumStep(self, step): + """ + Minimum step size when `stepType() == AdaptiveDecimalStepType` + and `decimals() < 0`. + """ + self.__minimumStep = step + + def minimumStep(self): + return self.__minimumStep + + def textFromValue(self, v: float) -> str: + """Reimplemented.""" + if self.__decimals < 0: + locale = self.locale() + return locale.toString(v, 'f', QLocale.FloatingPointShortest) + else: + return super().textFromValue(v) + + def stepBy(self, steps: int) -> None: + """ + Reimplemented. + """ + # Compute up/down step using decimal type without rounding + value = self.value() + value_dec = Decimal(str(value)) + if self.stepType() == DoubleSpinBox.AdaptiveDecimalStepType: + step_dec = self.__adaptiveDecimalStep(steps) + else: + step_dec = Decimal(str(self.singleStep())) + # print(str(step_dec.fma(steps, value_dec))) + value_dec = value_dec + step_dec * steps + # print(str(value), "+", str(step_dec), "*", steps, "=", str(vinc)) + self.setValue(float(value_dec)) + + def __adaptiveDecimalStep(self, steps: int) -> Decimal: + # adapted from QDoubleSpinBoxPrivate::calculateAdaptiveDecimalStep + decValue: Decimal = Decimal(str(self.value())) + decimals = self.__decimals + if decimals < 0: + minStep = Decimal(str(self.__minimumStep)) + else: + minStep = Decimal(10) ** -decimals + + absValue = abs(decValue) + if absValue < minStep: + return minStep + valueNegative = decValue < 0 + stepsNegative = steps < 0 + if valueNegative != stepsNegative: + absValue /= Decimal("1.01") + step = Decimal(10) ** (math.floor(absValue.log10()) - 1) + return max(minStep, step) + + if not hasattr(QDoubleSpinBox, "stepType"): # pragma: no cover + DefaultStepType = 0 + AdaptiveDecimalStepType = 1 + __stepType = AdaptiveDecimalStepType + + def setStepType(self, stepType): + self.__stepType = stepType + + def stepType(self): + return self.__stepType diff --git a/Orange/widgets/utils/tests/test_spinbox.py b/Orange/widgets/utils/tests/test_spinbox.py new file mode 100644 index 00000000000..026a385bb17 --- /dev/null +++ b/Orange/widgets/utils/tests/test_spinbox.py @@ -0,0 +1,45 @@ +from orangewidget.tests.base import GuiTest +from Orange.widgets.utils.spinbox import DoubleSpinBox + + +class TestDoubleSpinBox(GuiTest): + def test_double_spin_box(self): + w = DoubleSpinBox( + minimum=-1, maximum=1, value=0, singleStep=0.1, decimals=-1, + minimumStep=1e-7, + ) + self.assertEqual(w.minimum(), -1) + self.assertEqual(w.maximum(), 1) + self.assertEqual(w.value(), 0) + self.assertEqual(w.singleStep(), 0.1) + self.assertEqual(w.decimals(), -1) + self.assertEqual(w.minimumStep(), 1e-7) + + w.setValue(2) + self.assertEqual(w.value(), 1) + w.setValue(0.999999) + self.assertEqual(w.value(), 0.999999) + w.stepBy(-1) + self.assertEqual(w.value(), 0.899999) + w.stepBy(1) + self.assertEqual(w.value(), 0.999999) + w.stepBy(1) + self.assertEqual(w.value(), 1.0) + + w.setStepType(DoubleSpinBox.AdaptiveDecimalStepType) + w.stepBy(-1) + self.assertEqual(w.value(), 0.99) + + w.setValue(0.123456789) + w.stepBy(1) + self.assertEqual(w.value(), 0.133456789) + w.stepBy(-1) + self.assertEqual(w.value(), 0.123456789) + w.setMinimumStep(0.001) + w.setValue(0.00005) + w.stepBy(1) + w.setValue(0.00105) + w.setDecimals(3) + self.assertEqual(w.value(), 0.001) + w.stepBy(1) + self.assertEqual(w.value(), 0.002)