Source code for qudi.util.widgets.scientific_spinbox

# -*- coding: utf-8 -*-

"""
This file contains a wrapper to display the SpinBox in scientific way

Copyright (c) 2021, the qudi developers. See the AUTHORS.md file at the top-level directory of this
distribution and on <https://github.com/Ulm-IQO/qudi-core/>

This file is part of qudi.

Qudi is free software: you can redistribute it and/or modify it under the terms of
the GNU Lesser General Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later version.

Qudi is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU Lesser General Public License for more details.

You should have received a copy of the GNU Lesser General Public License along with qudi.
If not, see <https://www.gnu.org/licenses/>.
"""

__all__ = ['ScienDSpinBox', 'ScienSpinBox']

from PySide2 import QtCore, QtGui, QtWidgets
import numpy as np
import re
from decimal import Decimal as D  # Use decimal to avoid accumulating floating-point errors
from decimal import ROUND_FLOOR
import math


[docs] class FloatValidator(QtGui.QValidator): """ This is a validator for float values represented as strings in scientific notation. (i.e. "1.35e-9", ".24E+8", "14e3" etc.) Also supports SI unit prefix like 'M', 'n' etc. """ float_re = re.compile( r'(\s*([+-]?)(\d+\.\d+|\.\d+|\d+\.?)([eE][+-]?\d+)?\s?([YZEPTGMkmµunpfazy]?)\s*)' ) group_map = {'match': 0, 'sign': 1, 'mantissa': 2, 'exponent': 3, 'si': 4} def validate(self, string, position): """ This is the actual validator. It checks whether the current user input is a valid string every time the user types a character. There are 3 states that are possible. 1) Invalid: The current input string is invalid. The user input will not accept the last typed character. 2) Acceptable: The user input is conforming to the regular expression and will be accepted. 3) Intermediate: The user input is not a valid string yet but on the right track. Use this return value to allow the user to type additional characters needed to complete an expression (e.g., the decimal point of a float value). Parameters ---------- string : str The current input string (from a QLineEdit, for example). position : int The current position of the text cursor. Returns ------- QValidator.State The returned validator state: QValidator.Invalid, QValidator.Acceptable, or QValidator.Intermediate. str The input string after validation. int The updated cursor position after validation. """ # Return intermediate status when empty string is passed or when incomplete "[+-]inf" if string.strip() in '+.-.' or string.strip() in list('YZEPTGMkmµunpfazy') or re.match( r'[+-]?(in$|i$)', string, re.IGNORECASE): return self.Intermediate, string, position # Accept input of [+-]inf. Not case sensitive. if re.match(r'[+-]?\binf$', string, re.IGNORECASE): return self.Acceptable, string.lower(), position group_dict = self.get_group_dict(string) if group_dict: if group_dict['match'] == string: return self.Acceptable, string, position if string.count('.') > 1: return self.Invalid, group_dict['match'], position if position > len(string): position = len(string) if string[position-1] in 'eE-+' and 'i' not in string.lower(): return self.Intermediate, string, position return self.Invalid, group_dict['match'], position else: if string[position-1] in 'eE-+.' and 'i' not in string.lower(): return self.Intermediate, string, position return self.Invalid, '', position def get_group_dict(self, string): """ This method will match the input string with the regular expression of this validator. The match groups will be put into a dictionary with string descriptors as keys describing the role of the specific group (i.e. mantissa, exponent, si-prefix etc.). Parameters ---------- string : str Input string to be matched. Returns ------- dict Dictionary containing groups as items and descriptors as keys (see: self.group_map). """ match = self.float_re.search(string) if not match: return False groups = match.groups() group_dict = dict() for group_key in self.group_map: group_dict[group_key] = groups[self.group_map[group_key]] return group_dict def fixup(self, text): match = self.float_re.search(text) if match: return match.groups()[0].strip() else: return ''
[docs] class IntegerValidator(QtGui.QValidator): """ This is a validator for int values represented as strings in scientific notation. Using engeneering notation only positive exponents are allowed (i.e. "1e9", "2E+8", "14e+3" etc.) Also supports non-fractional SI unit prefix like 'M', 'k' etc. """ int_re = re.compile(r'(([+-]?\d+)([eE]\+?\d+)?\s?([YZEPTGMk])?\s*)') group_map = {'match': 0, 'mantissa': 1, 'exponent': 2, 'si': 3 } def validate(self, string, position): """ This is the actual validator. It checks whether the current user input is a valid string every time the user types a character. There are 3 states that are possible: 1) Invalid: The current input string is invalid. The user input will not accept the last typed character. 2) Acceptable: The user input is conforming to the regular expression and will be accepted. 3) Intermediate: The user input is not a valid string yet but is on the right track. Use this return value to allow the user to type the necessary fill-characters needed to complete an expression (e.g., the decimal point of a float value). Parameters ---------- string : str The current input string (from a QLineEdit, for example). position : int The current position of the text cursor. Returns ------- QValidator.State or enum The returned validator state indicating the validity of the input. str The input string after validation. int The cursor position after validation. """ # Return intermediate status when empty string is passed or cursor is at index 0 if not string.strip() or string.strip() in list('YZEPTGMk'): return self.Intermediate, string, position group_dict = self.get_group_dict(string) if group_dict: if group_dict['match'] == string: return self.Acceptable, string, position if position > len(string): position = len(string) if string[position-1] in 'eE-+': return self.Intermediate, string, position return self.Invalid, group_dict['match'], position else: return self.Invalid, '', position def get_group_dict(self, string): """ This method matches the input string with the regular expression defined by this validator. It captures match groups into a dictionary with string descriptors as keys describing the role of each specific group (e.g., mantissa, exponent, SI-prefix). Parameters ---------- string : str The input string to be matched against the validator's regular expression. Returns ------- dict A dictionary containing matched groups as values and descriptors as keys. See `self.group_map` for details on the descriptors used. """ match = self.int_re.search(string) if not match: return False groups = match.groups() group_dict = dict() for group_key in self.group_map: group_dict[group_key] = groups[self.group_map[group_key]] return group_dict def fixup(self, text): match = self.int_re.search(text) if match: return match.groups()[0].strip() else: return ''
[docs] class ScienDSpinBox(QtWidgets.QAbstractSpinBox): """ Wrapper Class from PyQt5 (or QtPy) to display a QDoubleSpinBox in Scientific way. Fully supports prefix and suffix functionality of the QDoubleSpinBox. Has built-in functionality to invoke the displayed number precision from the user input. This class can be directly used in Qt Designer by promoting the QDoubleSpinBox to ScienDSpinBox. State the path to this file (in python style, i.e. dots are separating the directories) as the header file and use the name of the present class. """ valueChanged = QtCore.Signal(object) # The maximum number of decimals to allow. Be careful when changing this number since # the decimal package has by default a limited accuracy. __max_decimals = 20 # Dictionary mapping the si-prefix to a scaling factor as decimal.Decimal (exact value) _unit_prefix_dict = { 'y': D('1e-24'), 'z': D('1e-21'), 'a': D('1e-18'), 'f': D('1e-15'), 'p': D('1e-12'), 'n': D('1e-9'), 'µ': D('1e-6'), 'm': D('1e-3'), '': D('1'), 'k': D('1e3'), 'M': D('1e6'), 'G': D('1e9'), 'T': D('1e12'), 'P': D('1e15'), 'E': D('1e18'), 'Z': D('1e21'), 'Y': D('1e24') }
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__value = D(0) self.__minimum = -np.inf self.__maximum = np.inf self.__decimals = 2 # default in QtDesigner self.__prefix = '' self.__suffix = '' self.__singleStep = D('0.1') # must be precise Decimal always, no conversion from float self.__minimalStep = D(0) # must be precise Decimal always, no conversion from float self.__cached_value = None # a temporary variable for restore functionality self._dynamic_stepping = True self._dynamic_precision = True self._assumed_unit_prefix = None # To assume one prefix. This is only used if no prefix would be out of range self._is_valid = True # A flag property to check if the current value is valid. self.disable_wheel = False self.validator = FloatValidator() self.lineEdit().textEdited.connect(self.update_value) self.update_display()
@property def dynamic_stepping(self): """ Flag indicating whether dynamic (logarithmic) stepping should be used or fixed steps. Returns ------- bool True if dynamic stepping is enabled, False otherwise. """ return bool(self._dynamic_stepping) @dynamic_stepping.setter def dynamic_stepping(self, use_dynamic_stepping): """ Flag indicating whether dynamic (logarithmic) stepping should be used or fixed steps. Parameters ---------- use_dynamic_stepping : bool True to use dynamic stepping (logarithmic), False to use constant steps. Returns ------- bool True if dynamic stepping is enabled, False otherwise. """ use_dynamic_stepping = bool(use_dynamic_stepping) self._dynamic_stepping = use_dynamic_stepping @property def dynamic_precision(self): """ Flag indicating whether dynamic decimal precision should be used based on user input. Returns ------- bool True to use dynamic decimal precision, False to use fixed precision. """ return bool(self._dynamic_precision) @dynamic_precision.setter def dynamic_precision(self, use_dynamic_precision): """ Flag indicating whether dynamic decimal precision should be used based on user input. Parameters ---------- use_dynamic_precision : bool True to use dynamic precision (invoked from user input), False to use fixed precision. Returns ------- bool True to use dynamic precision, False to use fixed precision. """ use_dynamic_precision = bool(use_dynamic_precision) self._dynamic_precision = use_dynamic_precision @property def assumed_unit_prefix(self): """ Default unit prefix for text input. Returns ------- str or None The default unit prefix string if set, otherwise None. """ return self._assumed_unit_prefix @assumed_unit_prefix.setter def assumed_unit_prefix(self, unit_prefix): """ Default unit prefix used for text input. Parameters ---------- unit_prefix : str or None The unit prefix to set as default for text input. If None, the default prefix is cleared. """ if unit_prefix is None or unit_prefix in self._unit_prefix_dict: self._assumed_unit_prefix = unit_prefix if unit_prefix == 'u': # in case of encoding problems self._assumed_unit_prefix = 'µ' @property def is_valid(self): """ Flag indicating if the currently available value is valid. Returns ------- bool True if the current value is valid, False otherwise. Returns False if there has been an attempt to set NaN as the current value; True after a valid value has been set. """ return bool(self._is_valid) def update_value(self): """ This method will grab the currently shown text from the QLineEdit and interpret it. Range checking is performed on the value afterwards. If a valid value can be derived, it will set this value as the current value (if it has changed) and emit the valueChanged signal. Note that the comparison between old and new value is done by comparing the float representations of both values and not by comparing them as Decimals. The valueChanged signal will only emit if the actual float representation has changed since Decimals are only internally used and the rest of the program won't notice a slight change in the Decimal that can't be resolved in a float. In addition it will cache the old value provided the cache is empty to be able to restore it later on. """ text = self.cleanText() value = self.valueFromText(text) if value is False: return value, in_range = self.check_range(value) # if the value is out of range, then only use assumed unit prefix if not in_range and self._assumed_unit_prefix is not None: value = self.valueFromText(text, use_assumed_unit_prefix=True) value, in_range = self.check_range(value) # save old value to be able to restore it later on if self.__cached_value is None: self.__cached_value = self.__value if float(value) != self.value(): self.__value = value self.valueChanged.emit(self.value()) else: self.__value = value self._is_valid = True def value(self): """ Getter method to obtain the current value as float. Returns ------- float The current value of the SpinBox as a float. """ return float(self.__value) def setValue(self, value): """ Setter method to programmatically set the current value. For best robustness pass the value as string or Decimal in order to be lossless cast into Decimal. Will perform range checking and ignore NaN values. Will emit valueChanged if the new value is different from the old one. When using dynamic decimals precision, this method will also try to invoke the optimal display precision by checking for a change in the displayed text. """ try: value = D(value) except TypeError: if 'int' in type(value).__name__: value = int(value) elif 'float' in type(value).__name__: value = float(value) else: raise value = D(value) # catch NaN values and set the "is_valid" flag to False until a valid value is set again. if value.is_nan(): self._is_valid = False return value, in_range = self.check_range(value) if self.__value != value or not self.is_valid: # Try to increase decimals when the value has changed but no change in display detected. # This will only be executed when the dynamic precision flag is set if self.value() != float(value) and self.dynamic_precision and not value.is_infinite(): old_text = self.cleanText() new_text = self.textFromValue(value).strip() current_dec = self.decimals() while old_text == new_text: if self.__decimals > self.__max_decimals: self.__decimals = current_dec break self.__decimals += 1 new_text = self.textFromValue(value).strip() self.__value = value self._is_valid = True self.update_display() self.valueChanged.emit(self.value()) def setProperty(self, prop, val): """ For compatibility with QtDesigner. Initializes the value through this method. Parameters ---------- prop : type Description of the parameter 'prop'. val : type Description of the parameter 'val'. """ if prop == 'value': self.setValue(val) else: raise UserWarning('setProperty in scientific spinboxes only works for "value".') def check_range(self, value): """ Helper method to check if the passed value is within the set minimum and maximum value bounds. If outside of bounds, the returned value will be clipped to the nearest boundary. Parameters ---------- value : float or Decimal Number to be checked. Returns ------- Decimal The corrected value. bool Flag indicating if the value has been changed (`True`) or not (`False`). """ if value < self.__minimum: new_value = self.__minimum in_range = False elif value > self.__maximum: new_value = self.__maximum in_range = False else: in_range = True if not in_range: value = D(new_value) return value, in_range def minimum(self): return float(self.__minimum) def setMinimum(self, minimum): """ Setter method to set the minimum value allowed in the SpinBox. Parameters ---------- minimum : float The minimum value to be set. Input will be converted to float before being stored. """ # Ignore NaN values if self._check_nan(float(minimum)): return self.__minimum = float(minimum) if self.__minimum > self.value(): self.setValue(self.__minimum) def maximum(self): return float(self.__maximum) def setMaximum(self, maximum): """ Setter method to set the maximum value allowed in the SpinBox. Parameters ---------- maximum : float The maximum value to be set. Input will be converted to float before being stored. """ # Ignore NaN values if self._check_nan(float(maximum)): return self.__maximum = float(maximum) if self.__maximum < self.value(): self.setValue(self.__maximum) def setRange(self, minimum, maximum): """ Convenience method for compliance with Qt SpinBoxes. Essentially a wrapper to call both self.setMinimum and self.setMaximum. Parameters ---------- minimum : float The minimum value to be set. maximum : float The maximum value to be set. """ self.setMinimum(minimum) self.setMaximum(maximum) def decimals(self): return self.__decimals def setDecimals(self, decimals, dynamic_precision=True): """ Set the number of displayed digits after the decimal point and specify dynamic precision. Parameters ---------- decimals : int The number of decimals to be displayed. dynamic_precision : bool Flag indicating whether dynamic precision functionality should be used: - If True, the number of decimals will be determined dynamically from user input until explicitly set by calling this method or entering user text. - If False, the specified number of decimals will be fixed and will not change automatically. Returns ------- None """ decimals = int(decimals) # Restrict the number of fractional digits to a maximum of self.__max_decimals = 20. # Beyond that the number is not very meaningful anyways due to machine precision. if decimals < 0: decimals = 0 elif decimals > self.__max_decimals: decimals = self.__max_decimals self.__decimals = decimals # Set the flag for using dynamic precision (decimals invoked from user input) self.dynamic_precision = dynamic_precision def prefix(self): return self.__prefix def setPrefix(self, prefix): """ Set a string to be shown as non-editable prefix in the spinbox. Parameters ---------- prefix : str The prefix string to be displayed. Returns ------- None """ self.__prefix = str(prefix) self.update_display() def suffix(self): return self.__suffix def setSuffix(self, suffix): """ Set a string to be shown as non-editable suffix in the spinbox. This suffix will come right after the si-prefix. Parameters ---------- suffix : str The suffix string to be displayed after the si-prefix. Returns ------- None """ self.__suffix = str(suffix) self.update_display() def singleStep(self): return float(self.__singleStep) def setSingleStep(self, step, dynamic_stepping=True): """ Set the stepping behavior of the spinbox (e.g., when using the mouse wheel). When dynamic_stepping=True, the spinbox will perform logarithmic steps according to the current order of magnitude of the values. The step parameter then specifies the step size relative to the value's order of magnitude. For example, step=0.1 would increment the second most significant digit by one. When dynamic_stepping=False, the step parameter specifies an absolute step size. This means that each time a step is performed, this value is added or subtracted from the current value. For maximum robustness and consistency, it is strongly recommended to pass step as a Decimal or string to ensure lossless conversion to Decimal. Parameters ---------- step : Decimal or str The (relative) step size to set. For dynamic_stepping=True, this is relative to the order of magnitude of the current value. For dynamic_stepping=False, this is an absolute step size. dynamic_stepping : bool Flag indicating the use of dynamic stepping (True) or constant stepping (False). Returns ------- None """ try: step = D(step) except TypeError: if 'int' in type(step).__name__: step = int(step) elif 'float' in type(step).__name__: step = float(step) else: raise step = D(step) # ignore NaN and infinity values if not step.is_nan() and not step.is_infinite(): self.__singleStep = step self.dynamic_stepping = dynamic_stepping def minimalStep(self): return float(self.__minimalStep) def setMinimalStep(self, step): """ Method used to set a minimal step size. When the absolute step size has been calculated in either dynamic or constant step mode, this value is checked against the minimal step size. If it is smaller then the minimal step size is chosen over the calculated step size. This ensures that no step taken can be smaller than minimalStep. Set this value to 0 for no minimal step size. For maximum roboustness and consistency it is strongly recommended to pass step as Decimal or string in order to be converted lossless to Decimal. Parameters ---------- step : Decimal|str The minimal step size to be set. Returns ------- None """ try: step = D(step) except TypeError: if 'int' in type(step).__name__: step = int(step) elif 'float' in type(step).__name__: step = float(step) else: raise step = D(step) # ignore NaN and infinity values if not step.is_nan() and not step.is_infinite(): self.__minimalStep = step def cleanText(self): """ Compliance method from Qt SpinBoxes. Returns the currently shown text from the QLineEdit without prefix and suffix and stripped from leading or trailing whitespaces. Returns ------- str Currently shown text stripped from suffix and prefix. """ text = self.text().strip() if self.__prefix and text.startswith(self.__prefix): text = text[len(self.__prefix):] if self.__suffix and text.endswith(self.__suffix): text = text[:-len(self.__suffix)] return text.strip() def update_display(self): """ This helper method updates the shown text based on the current value. Because this method is only called upon finishing an editing procedure, the eventually cached value gets deleted. """ text = self.textFromValue(self.value()) text = self.__prefix + text + self.__suffix self.lineEdit().setText(text) self.__cached_value = None # clear cached value self.lineEdit().setCursorPosition(0) # Display the most significant part of the number def keyPressEvent(self, event): """ This method catches all keyboard press events triggered by the user. Can be used to alter the behaviour of certain key events from the default implementation of QAbstractSpinBox. Parameters ---------- event : QKeyEvent A Qt QKeyEvent instance holding the event information """ # Restore cached value upon pressing escape and lose focus. if event.key() == QtCore.Qt.Key_Escape: if self.__cached_value is not None: self.__value = self.__cached_value self.valueChanged.emit(self.value()) self.clearFocus() # This will also trigger editingFinished return # Update display upon pressing enter/return before processing the event in the default way. if event.key() == QtCore.Qt.Key_Enter or event.key() == QtCore.Qt.Key_Return: self.update_display() if (QtCore.Qt.ControlModifier | QtCore.Qt.MetaModifier) & event.modifiers(): super().keyPressEvent(event) return # The rest is to avoid editing suffix and prefix if len(event.text()) > 0: # Allow editing of the number or SI-prefix even if part of the prefix/suffix is selected. if self.lineEdit().selectedText(): sel_start = self.lineEdit().selectionStart() sel_end = sel_start + len(self.lineEdit().selectedText()) min_start = len(self.__prefix) max_end = len(self.__prefix) + len(self.cleanText()) if sel_start < min_start: sel_start = min_start if sel_end > max_end: sel_end = max_end self.lineEdit().setSelection(sel_start, sel_end - sel_start) else: cursor_pos = self.lineEdit().cursorPosition() begin = len(self.__prefix) end = len(self.text()) - len(self.__suffix) if cursor_pos < begin: self.lineEdit().setCursorPosition(begin) elif cursor_pos > end: self.lineEdit().setCursorPosition(end) if event.key() == QtCore.Qt.Key_Left: if self.lineEdit().cursorPosition() == len(self.__prefix): return if event.key() == QtCore.Qt.Key_Right: if self.lineEdit().cursorPosition() == len(self.text()) - len(self.__suffix): return if event.key() == QtCore.Qt.Key_Home: self.lineEdit().setCursorPosition(len(self.__prefix)) return if event.key() == QtCore.Qt.Key_End: self.lineEdit().setCursorPosition(len(self.text()) - len(self.__suffix)) return super().keyPressEvent(event) def focusInEvent(self, event): super().focusInEvent(event) self.selectAll() return def focusOutEvent(self, event): self.update_display() super().focusOutEvent(event) return def paintEvent(self, ev): """ Add drawing of a red frame around the spinbox if the is_valid flag is False """ super().paintEvent(ev) # draw red frame if is_valid = False if not self.is_valid: pen = QtGui.QPen() pen.setColor(QtGui.QColor(200, 50, 50)) pen.setWidth(2) p = QtGui.QPainter(self) p.setRenderHint(p.Antialiasing) p.setPen(pen) p.drawRoundedRect(self.rect().adjusted(2, 2, -2, -2), 4, 4) p.end() def validate(self, text, position): """ Access method to the validator. See FloatValidator class for more information. Parameters ---------- text : str String to be validated. position : int Current text cursor position. Returns ------- QValidator::State The returned validator state. str The input string. int The cursor position. """ begin = len(self.__prefix) end = len(text) - len(self.__suffix) if position < begin: position = begin elif position > end: position = end if self.__prefix and text.startswith(self.__prefix): text = text[len(self.__prefix):] if self.__suffix and text.endswith(self.__suffix): text = text[:-len(self.__suffix)] state, string, position = self.validator.validate(text, position) text = self.__prefix + string + self.__suffix end = len(text) - len(self.__suffix) if position > end: position = end return state, text, position def fixup(self, text): """ Takes an invalid string and tries to fix it in order to pass validation. The returned string is not guaranteed to pass validation. Parameters ---------- text : str A string that has not passed validation and needs to be fixed. Returns ------- str The resulting string from the fix attempt. """ return self.validator.fixup(text) def valueFromText(self, text, use_assumed_unit_prefix=False): """ Convert a string displayed in the SpinBox into a Decimal value. The input string is already stripped of prefix and suffix. Only the si-prefix may be present. Parameters ---------- text : str The display string to be converted into a numeric value. This string must conform to the validator. Returns ------- Decimal The numeric value converted from the input string. """ # Check for infinite value if 'inf' in text.lower(): if text.startswith('-'): return D('-inf') else: return D('inf') # Handle "normal" (non-infinite) input group_dict = self.validator.get_group_dict(text) if not group_dict: return False if not group_dict['mantissa']: return False si_prefix = group_dict['si'] if si_prefix is None: si_prefix = '' if si_prefix == '' and use_assumed_unit_prefix and self._assumed_unit_prefix is not None: si_prefix = self._assumed_unit_prefix si_scale = self._unit_prefix_dict[si_prefix.replace('u', 'µ')] if group_dict['sign'] is not None: unscaled_value_str = group_dict['sign'] + group_dict['mantissa'] else: unscaled_value_str = group_dict['mantissa'] if group_dict['exponent'] is not None: unscaled_value_str += group_dict['exponent'] value = D(unscaled_value_str) * si_scale # Try to extract the precision the user intends to use if self.dynamic_precision: split_mantissa = group_dict['mantissa'].split('.') if len(split_mantissa) == 2: self.setDecimals(max(len(split_mantissa[1]), 1)) else: self.setDecimals(1) # Minimum number of digits is 1 return value def textFromValue(self, value): """ This method is responsible for the mapping of the underlying value to a string to display in the SpinBox. Suffix and Prefix must not be handled here, just the si-Prefix. The main problem here is, that a scaled float with a suffix is represented by a different machine precision than the total value. This method is so complicated because it represents the actual precision of the value as float and not the precision of the scaled si float. '{:.20f}'.format(value) shows different digits than '{:.20f} {}'.format(scaled_value, si_prefix) Parameters ---------- value : float or decimal.Decimal The numeric value to be formatted into a string. Returns ------- str The formatted string representing the input value. """ # Catch infinity value if np.isinf(float(value)): if value < 0: return '-inf ' else: return 'inf ' sign = '-' if value < 0 else '' fractional, integer = math.modf(abs(value)) integer = int(integer) si_prefix = '' prefix_index = 0 if integer != 0: integer_str = str(integer) fractional_str = '' while len(integer_str) > 3: fractional_str = integer_str[-3:] + fractional_str integer_str = integer_str[:-3] if prefix_index < 8: si_prefix = 'kMGTPEZY'[prefix_index] else: si_prefix = 'e{0:d}'.format(3 * (prefix_index + 1)) prefix_index += 1 # Truncate and round to set number of decimals # Add digits from fractional if it's not already enough for set self.__decimals if self.__decimals < len(fractional_str): round_indicator = int(fractional_str[self.__decimals]) fractional_str = fractional_str[:self.__decimals] if round_indicator >= 5: if not fractional_str: fractional_str = '1' else: fractional_str = str(int(fractional_str) + 1) elif self.__decimals == len(fractional_str): if fractional >= 0.5: if fractional_str: fractional_int = int(fractional_str) + 1 fractional_str = str(fractional_int) else: fractional_str = '1' elif self.__decimals > len(fractional_str): digits_to_add = self.__decimals - len(fractional_str) # number of digits to add fractional_tmp_str = ('{0:.' + str(digits_to_add) + 'f}').format(fractional) if fractional_tmp_str.startswith('1'): if fractional_str: fractional_str = str(int(fractional_str) + 1) + '0' * digits_to_add else: fractional_str = '1' + '0' * digits_to_add else: fractional_str += fractional_tmp_str.split('.')[1] # Check if the rounding has overflown the fractional part into the integer part if len(fractional_str) > self.__decimals: integer_str = str(int(integer_str) + 1) fractional_str = '0' * self.__decimals elif fractional == 0.0: fractional_str = '0' * self.__decimals integer_str = '0' else: # determine the order of magnitude by comparing the fractional to unit values prefix_index = 1 magnitude = 1e-3 si_prefix = 'm' while magnitude > fractional: prefix_index += 1 magnitude = magnitude ** prefix_index if prefix_index <= 8: si_prefix = 'mµnpfazy'[prefix_index - 1] # use si-prefix if possible else: si_prefix = 'e-{0:d}'.format(3 * prefix_index) # use engineering notation # Get the string representation of all needed digits from the fractional part of value. digits_needed = 3 * prefix_index + self.__decimals helper_str = ('{0:.' + str(digits_needed) + 'f}').format(fractional) overflow = bool(int(helper_str.split('.')[0])) helper_str = helper_str.split('.')[1] if overflow: integer_str = '1000' fractional_str = '0' * self.__decimals elif (prefix_index - 1) > 0 and helper_str[3 * (prefix_index - 1) - 1] != '0': integer_str = '1000' fractional_str = '0' * self.__decimals else: integer_str = str(int(helper_str[:3 * prefix_index])) fractional_str = helper_str[3 * prefix_index:3 * prefix_index + self.__decimals] # Create the actual string representation of value scaled in a scientific way space = '' if si_prefix.startswith('e') else ' ' if self.__decimals > 0: string = '{0}{1}.{2}{3}{4}'.format(sign, integer_str, fractional_str, space, si_prefix) else: string = '{0}{1}{2}{3}'.format(sign, integer_str, space, si_prefix) return string def stepEnabled(self): """ Enables stepping (mouse wheel, arrow up/down, clicking, PgUp/Down) by default. """ return self.StepUpEnabled | self.StepDownEnabled def wheelEvent(self, event): """ Overwriting wheel event, such that with the class variable disable_wheel = True the stepping with the mouse wheel is turned off and the wheel event is passed to the parent widget. :param event: """ if self.disable_wheel: event.ignore() else: super().wheelEvent(event) def stepBy(self, steps): """ This method is responsible for incrementing the value of the SpinBox when the user triggers a step (by pressing PgUp/PgDown/Up/Down, MouseWheel movement or clicking on the arrows). It should handle the case when the new to-set value is out of bounds. Also, the absolute value of a single step increment should be handled here. It is essential to avoid accumulating rounding errors and/or discrepancies between self.value and the displayed text. Parameters ---------- steps : int Number of steps to increment (NOT the absolute step size). """ # Ignore stepping for infinity values if self.__value.is_infinite(): return n = D(int(steps)) # n must be integral number of steps. s = [D(-1), D(1)][n >= 0] # determine sign of step value = self.__value # working copy of current value if self.dynamic_stepping: for i in range(int(abs(n))): if value == 0: if self.__minimalStep == 0: if np.isinf(self.__minimum) or np.isinf(self.__maximum): step = D('0.01') else: step = D((self.__maximum - self.__minimum)/10000) else: step = self.__minimalStep else: vs = [D(-1), D(1)][value >= 0] fudge = D('1.01') ** (s * vs) # fudge factor. At some places, the step size # depends on the step sign. exp = abs(value * fudge).log10().quantize(1, rounding=ROUND_FLOOR) step = self.__singleStep * D(10) ** exp if self.__minimalStep > 0: step = max(step, self.__minimalStep) value += s * step else: value = value + n * max(self.__minimalStep, self.__singleStep) self.setValue(value) def selectAll(self): begin = len(self.__prefix) text = self.cleanText() if text.endswith(' '): selection_length = len(text) + 1 elif len(text) > 0 and text[-1] in self._unit_prefix_dict: selection_length = len(text) - 1 else: selection_length = len(text) self.lineEdit().setSelection(begin, selection_length) @staticmethod def _check_nan(value): """ Helper method to check if the passed float value is NaN. Makes use of the fact that NaN values will always compare to false, even with itself. Parameters ---------- value : Decimal or float Value to be checked for NaN. Returns ------- bool True if the value is NaN, False otherwise. """ return not value == value
[docs] class ScienSpinBox(QtWidgets.QAbstractSpinBox): """ Wrapper Class from PyQt5 (or QtPy) to display a QSpinBox in Scientific way. Fully supports prefix and suffix functionality of the QSpinBox. This class can be directly used in Qt Designer by promoting the QSpinBox to ScienSpinBox. State the path to this file (in python style, i.e. dots are separating the directories) as the header file and use the name of the present class. """ valueChanged = QtCore.Signal(object) # Dictionary mapping the si-prefix to a scaling factor as integer (exact value) _unit_prefix_dict = { '': 1, 'k': 10 ** 3, 'M': 10 ** 6, 'G': 10 ** 9, 'T': 10 ** 12, 'P': 10 ** 15, 'E': 10 ** 18, 'Z': 10 ** 21, 'Y': 10 ** 24 }
[docs] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__value = 0 self.__minimum = -2 ** 63 # Use a 64bit integer size by default. self.__maximum = 2 ** 63 - 1 # Use a 64bit integer size by default. self.__prefix = '' self.__suffix = '' self.__singleStep = 1 self.__minimalStep = 1 self.__cached_value = None # a temporary variable for restore functionality self._dynamic_stepping = True self.disable_wheel = False self.validator = IntegerValidator() self.lineEdit().textEdited.connect(self.update_value) self.update_display()
@property def dynamic_stepping(self): """ Property indicating whether dynamic (logarithmic) stepping is enabled or fixed steps are used. Returns ------- bool True if dynamic stepping is enabled, False if fixed steps are used. """ return bool(self._dynamic_stepping) @dynamic_stepping.setter def dynamic_stepping(self, use_dynamic_stepping): """ This property indicates whether dynamic (logarithmic) stepping should be used or fixed steps. Parameters ---------- use_dynamic_stepping : bool Flag to determine the stepping method: - True: Use dynamic (logarithmic) stepping. - False: Use fixed steps. """ use_dynamic_stepping = bool(use_dynamic_stepping) self._dynamic_stepping = use_dynamic_stepping def update_value(self): """ This method will grab the currently shown text from the QLineEdit and interpret it. Range checking is performed on the value afterwards. If a valid value can be derived, it will set this value as the current value (if it has changed) and emit the valueChanged signal. In addition it will cache the old value provided the cache is empty to be able to restore it later on. """ text = self.cleanText() value = self.valueFromText(text) if value is False: return value, in_range = self.check_range(value) # save old value to be able to restore it later on if self.__cached_value is None: self.__cached_value = self.__value if value != self.value(): self.__value = value self.valueChanged.emit(self.value()) def value(self): """ Getter method to obtain the current value as an integer. Returns ------- int The current value of the SpinBox. """ return int(self.__value) def setValue(self, value): """ Setter method to programmatically set the current value. Will perform range checking and ignore NaN values. Will emit valueChanged if the new value is different from the old one. """ if value is np.nan: return value = int(value) value, in_range = self.check_range(value) if self.__value != value: self.__value = value self.update_display() self.valueChanged.emit(self.value()) def setProperty(self, prop, val): """ For compatibility with QtDesigner. Initializes the value through this method. Parameters ---------- prop : str Property name. val : object Value to set for the property. """ if prop == 'value': self.setValue(val) else: raise UserWarning('setProperty in scientific spinboxes only works for "value".') def check_range(self, value): """ Helper method to check if the passed value is within the set minimum and maximum value bounds. If outside of bounds, the returned value will be clipped to the nearest boundary. Parameters ---------- value : int Number to be checked. Returns ------- (int, bool) The corrected value and a flag indicating if the value has been changed: - False: Value has been corrected (clipped). - True: Value remains unchanged. """ if value < self.__minimum: new_value = self.__minimum in_range = False elif value > self.__maximum: new_value = self.__maximum in_range = False else: in_range = True if not in_range: value = int(new_value) return value, in_range def minimum(self): return int(self.__minimum) def setMinimum(self, minimum): """ Setter method to set the minimum value allowed in the SpinBox. Input will be converted to int before being stored. Parameters ---------- minimum : int The minimum value to be set. """ self.__minimum = int(minimum) if self.__minimum > self.value(): self.setValue(self.__minimum) def maximum(self): return int(self.__maximum) def setMaximum(self, maximum): """ Setter method to set the maximum value allowed in the SpinBox. Input will be converted to int before being stored. Parameters ---------- maximum : int The maximum value to be set. """ self.__maximum = int(maximum) if self.__maximum < self.value(): self.setValue(self.__maximum) def setRange(self, minimum, maximum): """ Convenience method for compliance with Qt SpinBoxes. Essentially a wrapper to call both self.setMinimum and self.setMaximum. Parameters ---------- minimum : int The minimum value to be set. maximum : int The maximum value to be set. """ self.setMinimum(minimum) self.setMaximum(maximum) def prefix(self): return self.__prefix def setPrefix(self, prefix): """ Set a string to be shown as a non-editable prefix in the spinbox. Parameters ---------- prefix : str The prefix string to be set. """ self.__prefix = str(prefix) self.update_display() def suffix(self): return self.__suffix def setSuffix(self, suffix): """ Set a string to be shown as a non-editable suffix in the spinbox. This suffix will come right after the SI-prefix. Parameters ---------- suffix : str The suffix string to be set. """ self.__suffix = str(suffix) self.update_display() def singleStep(self): return int(self.__singleStep) def setSingleStep(self, step, dynamic_stepping=True): """ Method to set the stepping behavior of the spinbox (e.g., when moving the mouse wheel). Parameters ---------- step : int The absolute step size to set. Ignored if dynamic_stepping=True. dynamic_stepping : bool Flag indicating the stepping method: - True: Use dynamic stepping (logarithmic steps according to current order of magnitude). - False: Use constant stepping (step parameter specifies absolute step size). Returns ------- None Notes ----- When dynamic_stepping=True, the step parameter is ignored. The spinbox will increment the second most significant digit by one. """ if step < 1: step = 1 self.__singleStep = int(step) self.dynamic_stepping = dynamic_stepping def minimalStep(self): return int(self.__minimalStep) def setMinimalStep(self, step): """ Method used to set a minimal step size. Parameters ---------- step : int The minimal step size to be set. Returns ------- None Notes ----- When the absolute step size has been calculated in either dynamic or constant step mode, this value is checked against the minimal step size. If it is smaller, then the minimal step size is chosen over the calculated step size. This ensures that no step taken can be smaller than minimalStep. Minimal step size can't be smaller than 1 for integers. """ if step < 1: step = 1 self.__minimalStep = int(step) def cleanText(self): """ Compliance method from Qt SpinBoxes. Returns the currently shown text from the QLineEdit without prefix and suffix and stripped from leading or trailing whitespaces. Returns ------- str Currently shown text stripped from suffix and prefix. """ text = self.text().strip() if self.__prefix and text.startswith(self.__prefix): text = text[len(self.__prefix):] if self.__suffix and text.endswith(self.__suffix): text = text[:-len(self.__suffix)] return text.strip() def update_display(self): """ This helper method updates the shown text based on the current value. Because this method is only called upon finishing an editing procedure, the eventually cached value gets deleted. """ text = self.textFromValue(self.value()) text = self.__prefix + text + self.__suffix self.lineEdit().setText(text) self.__cached_value = None # clear cached value self.lineEdit().setCursorPosition(0) # Display the most significant part of the number def keyPressEvent(self, event): """ This method catches all keyboard press events triggered by the user. It can be used to alter the behavior of certain key events from the default implementation of QAbstractSpinBox. Parameters ---------- event : QKeyEvent A Qt QKeyEvent instance holding the event information. """ # Restore cached value upon pressing escape and lose focus. if event.key() == QtCore.Qt.Key_Escape: if self.__cached_value is not None: self.__value = self.__cached_value self.valueChanged.emit(self.value()) self.clearFocus() # This will also trigger editingFinished # Update display upon pressing enter/return before processing the event in the default way. if event.key() == QtCore.Qt.Key_Enter or event.key() == QtCore.Qt.Key_Return: self.update_display() if (QtCore.Qt.ControlModifier | QtCore.Qt.MetaModifier) & event.modifiers(): super().keyPressEvent(event) return # The rest is to avoid editing suffix and prefix if len(event.text()) > 0: # Allow editing of the number or SI-prefix even if part of the prefix/suffix is selected. if self.lineEdit().selectedText(): sel_start = self.lineEdit().selectionStart() sel_end = sel_start + len(self.lineEdit().selectedText()) min_start = len(self.__prefix) max_end = len(self.__prefix) + len(self.cleanText()) if sel_start < min_start: sel_start = min_start if sel_end > max_end: sel_end = max_end self.lineEdit().setSelection(sel_start, sel_end - sel_start) else: cursor_pos = self.lineEdit().cursorPosition() begin = len(self.__prefix) end = len(self.text()) - len(self.__suffix) if cursor_pos < begin: self.lineEdit().setCursorPosition(begin) return elif cursor_pos > end: self.lineEdit().setCursorPosition(end) return if event.key() == QtCore.Qt.Key_Left: if self.lineEdit().cursorPosition() == len(self.__prefix): return if event.key() == QtCore.Qt.Key_Right: if self.lineEdit().cursorPosition() == len(self.text()) - len(self.__suffix): return if event.key() == QtCore.Qt.Key_Home: self.lineEdit().setCursorPosition(len(self.__prefix)) return if event.key() == QtCore.Qt.Key_End: self.lineEdit().setCursorPosition(len(self.text()) - len(self.__suffix)) return super().keyPressEvent(event) def focusInEvent(self, event): super().focusInEvent(event) self.selectAll() return def focusOutEvent(self, event): self.update_display() super().focusOutEvent(event) return def wheelEvent(self, event): """ Overwrites the wheel event. If the class variable disable_wheel = True, stepping with the mouse wheel is turned off and the wheel event is passed to the parent widget. Parameters ---------- event : QWheelEvent A Qt QWheelEvent instance holding the wheel event information. """ if self.disable_wheel: event.ignore() else: super().wheelEvent(event) def validate(self, text, position): """ Access method to the validator. See IntegerValidator class for more information. Parameters ---------- text : str String to be validated. position : int Current text cursor position. Returns ------- (QValidator.State, str, int) - The returned validator state. - The input string. - The cursor position. """ begin = len(self.__prefix) end = len(text) - len(self.__suffix) if position < begin: position = begin elif position > end: position = end if self.__prefix and text.startswith(self.__prefix): text = text[len(self.__prefix):] if self.__suffix and text.endswith(self.__suffix): text = text[:-len(self.__suffix)] state, string, position = self.validator.validate(text, position) text = self.__prefix + string + self.__suffix end = len(text) - len(self.__suffix) if position > end: position = end return state, text, position def fixup(self, text): """ Takes an invalid string and tries to fix it in order to pass validation. The returned string is not guaranteed to pass validation. Parameters ---------- text : str A string that has not passed validation and needs to be fixed. Returns ------- str The resulting string from the fix attempt. """ return self.validator.fixup(text) def valueFromText(self, text): """ This method is responsible for converting a string displayed in the SpinBox into an integer value. The input string is already stripped of prefix and suffix. Only the SI-prefix may be present. Parameters ---------- text : str The display string to be converted into a numeric value. This string must conform to the validator. Returns ------- int The numeric value converted from the input string. """ group_dict = self.validator.get_group_dict(text) if not group_dict: return False if not group_dict['mantissa']: return False si_prefix = group_dict['si'] if si_prefix is None: si_prefix = '' si_scale = self._unit_prefix_dict[si_prefix.replace('u', 'µ')] unscaled_value = int(group_dict['mantissa']) if group_dict['exponent'] is not None: scale_factor = 10 ** int(group_dict['exponent'].replace('e', '').replace('E', '')) unscaled_value = unscaled_value * scale_factor value = unscaled_value * si_scale return value def textFromValue(self, value): """ This method is responsible for mapping the underlying value to a string to display in the SpinBox. Suffix and Prefix are not handled here, only the SI-prefix. Parameters ---------- value : int The numeric value to be formatted into a string. Returns ------- str The formatted string representing the input value. """ # Convert the integer value to a string sign = '-' if value < 0 else '' value_str = str(abs(value)) # find out the index of the least significant non-zero digit for digit_index in range(len(value_str)): if value_str[digit_index:].count('0') == len(value_str) - digit_index: break # get the engineering notation exponent (multiple of 3) missing_zeros = (len(value_str) - digit_index) % 3 exponent = len(value_str) - digit_index - missing_zeros # the scaled integer string that is still missing the order of magnitude (si-prefix or e) integer_str = value_str[:digit_index + missing_zeros] space = ' ' if self.__suffix else '' # Add si-prefix or, if the exponent is too big, add e-notation if 2 < exponent <= 24: si_prefix = ' ' + 'kMGTPEZY'[exponent // 3 - 1] elif exponent > 24: si_prefix = 'e{0:d}'.format(exponent) + space else: si_prefix = space # Assemble the string and return it return sign + integer_str + si_prefix def stepEnabled(self): """ Enables stepping (mouse wheel, arrow up/down, clicking, PgUp/Down) by default. """ return self.StepUpEnabled | self.StepDownEnabled def stepBy(self, steps): """ This method increments the value of the SpinBox when the user triggers a step (by pressing PgUp/PgDown/Up/Down, MouseWheel movement, or clicking on the arrows). It handles cases where the new value to be set is out of bounds. The absolute value of a single step increment is also managed here to avoid accumulating rounding errors or discrepancies between self.value and the displayed text. Parameters ---------- steps : int Number of steps to increment (NOT the absolute step size). """ steps = int(steps) value = self.__value # working copy of current value sign = -1 if steps < 0 else 1 # determine sign of step if self.dynamic_stepping: for i in range(abs(steps)): if value == 0: step = max(1, self.__minimalStep) else: integer_str = str(abs(value)) if len(integer_str) > 1: step = 10 ** (len(integer_str) - 2) # Handle the transition to lower order of magnitude if integer_str.startswith('10') and (sign * value) < 0: step = step // 10 else: step = 1 step = max(step, self.__minimalStep) value += sign * step else: value = value + max(self.__minimalStep * steps, self.__singleStep * steps) self.setValue(value) return def selectAll(self): begin = len(self.__prefix) text = self.cleanText() if text.endswith(' '): selection_length = len(text) + 1 elif len(text) > 0 and text[-1] in self._unit_prefix_dict: selection_length = len(text) - 1 else: selection_length = len(text) self.lineEdit().setSelection(begin, selection_length)