Source code for ccpn.ui.gui.widgets.DoubleSpinbox

"""Module Documentation here

"""
#=========================================================================================
# Licence, Reference and Credits
#=========================================================================================
__copyright__ = "Copyright (C) CCPN project (http://www.ccpn.ac.uk) 2014 - 2021"
__credits__ = ("Ed Brooksbank, Joanna Fox, Victoria A Higman, Luca Mureddu, Eliza Płoskoń",
               "Timothy J Ragan, Brian O Smith, Gary S Thompson & Geerten W Vuister")
__licence__ = ("CCPN licence. See http://www.ccpn.ac.uk/v3-software/downloads/license")
__reference__ = ("Skinner, S.P., Fogh, R.H., Boucher, W., Ragan, T.J., Mureddu, L.G., & Vuister, G.W.",
                 "CcpNmr AnalysisAssign: a flexible platform for integrated NMR analysis",
                 "J.Biomol.Nmr (2016), 66, 111-124, http://doi.org/10.1007/s10858-016-0060-y")
#=========================================================================================
# Last code modification
#=========================================================================================
__modifiedBy__ = "$modifiedBy: Geerten Vuister $"
__dateModified__ = "$dateModified: 2021-12-23 11:27:18 +0000 (Thu, December 23, 2021) $"
__version__ = "$Revision: 3.0.4 $"
#=========================================================================================
# Created
#=========================================================================================
__author__ = "$Author: Geerten Vuister $"
__date__ = "$Date: 2017-04-07 10:28:41 +0000 (Fri, April 07, 2017) $"
#=========================================================================================
# Start of code
#=========================================================================================

import sys
import re
import typing
from functools import partial
from contextlib import contextmanager
from PyQt5 import QtGui, QtWidgets, QtCore
from PyQt5.QtCore import pyqtSignal
from ccpn.ui.gui.widgets.Base import Base
from math import floor, log10


DOUBLESPINBOXSTEP = 10
SCIENTIFICSPINBOXSTEP = 5
KEYVALIDATELIST = (QtCore.Qt.Key_Return,
                   QtCore.Qt.Key_Enter,
                   QtCore.Qt.Key_Tab,
                   QtCore.Qt.Key_Up,
                   QtCore.Qt.Key_Down,
                   QtCore.Qt.Key_Left,
                   QtCore.Qt.Key_Right,
                   QtCore.Qt.Key_0,
                   QtCore.Qt.Key_1,
                   QtCore.Qt.Key_2,
                   QtCore.Qt.Key_3,
                   QtCore.Qt.Key_4,
                   QtCore.Qt.Key_5,
                   QtCore.Qt.Key_6,
                   QtCore.Qt.Key_7,
                   QtCore.Qt.Key_8,
                   QtCore.Qt.Key_9,
                   # QtCore.Qt.Key_Minus,
                   # QtCore.Qt.Key_Plus,
                   # QtCore.Qt.Key_E,
                   # QtCore.Qt.Key_Period,
                   )
KEYVALIDATEDECIMAL = (QtCore.Qt.Key_0,)  # need to discard if after decimal
KEYVALIDATEDIGIT = (QtCore.Qt.Key_0,
                    QtCore.Qt.Key_1,
                    QtCore.Qt.Key_2,
                    QtCore.Qt.Key_3,
                    QtCore.Qt.Key_4,
                    QtCore.Qt.Key_5,
                    QtCore.Qt.Key_6,
                    QtCore.Qt.Key_7,
                    QtCore.Qt.Key_8,
                    QtCore.Qt.Key_9,
                    )


[docs]class DoubleSpinbox(QtWidgets.QDoubleSpinBox, Base): # # To be done more rigorously later # _styleSheet = """ # DoubleSpinbox { # background-color: #f7ffff; # color: #122043; # margin: 0px 0px 0px 0px; # padding: 3px 3px 3px 3px; # border: 1px solid #182548; # } # # DoubleSpinbox::hover { # background-color: #e4e15b; # } # """ returnPressed = pyqtSignal(float) wheelChanged = pyqtSignal(float) _showValidation = True _validationIntermediate = QtGui.QColor('lightpink') _validationInvalid = QtGui.QColor('lightpink') # red is a bit harsh def __init__(self, parent, value=None, min=None, max=None, step=None, prefix=None, suffix=None, showButtons=True, decimals=None, callback=None, editable=True, locale=None, **kwds): """ From the QTdocumentation Constructs a spin box with a step value of 1.0 and a precision of 2 decimal places. Change the default 0.0 minimum value to -sys.float_info.max Change the default 99.99 maximum value to sys.float_info.max The value is default set to 0.00. The spin box has the given parent. """ self._keyPressed = None self.validator = QtGui.QDoubleValidator() self.validator.Notation = 1 super().__init__(parent) Base._init(self, **kwds) self._qLocale = locale or QtCore.QLocale() self.setLocale(self._qLocale) if min is not None: self.setMinimum(min) else: self.setMinimum(-1.0 * sys.float_info.max) if max is not None: self.setMaximum(max) else: self.setMaximum(sys.float_info.max) self.isSelected = False self._internalWheelEvent = True if step is not None: self.setSingleStep(step) if decimals is not None: self.setDecimals(decimals) if showButtons is False: self.setButtonSymbols(QtWidgets.QAbstractSpinBox.NoButtons) if prefix: self.setPrefix(prefix + ' ') if suffix: self.setSuffix(' ' + suffix) if value is not None: value = value self.setValue(value) lineEdit = self.lineEdit() lineEdit.returnPressed.connect(self._returnPressed) self.baseColour = self.lineEdit().palette().color(QtGui.QPalette.Base) # must be set after setting value/limits self._callback = None self.setCallback(callback) # change focusPolicy so that spinboxes don't grab focus unless selected self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setStyleSheet('DoubleSpinbox { padding: 3px 3px 3px 3px; }' 'DoubleSpinbox:disabled { background-color: whitesmoke; }')
[docs] def wheelEvent(self, event: QtGui.QWheelEvent) -> None: """Process the wheelEvent for the doubleSpinBox """ # emit the value when wheel event has occurred, only when hasFocus if self.hasFocus() or not self._internalWheelEvent: super().wheelEvent(event) value = self.value() self.wheelChanged.emit(value) else: event.ignore()
@contextmanager def _useExternalWheelEvent(self): try: self._internalWheelEvent = False yield finally: self._internalWheelEvent = True def _externalWheelEvent(self, event): with self._useExternalWheelEvent(): self.wheelEvent(event)
[docs] def stepBy(self, steps: int) -> None: if self._internalWheelEvent: super().stepBy(min(steps, DOUBLESPINBOXSTEP) if steps > 0 else max(steps, -DOUBLESPINBOXSTEP)) else: # disable multiple stepping for wheelMouse events in a spectrumDisplay super().stepBy(1 if steps > 0 else -1 if steps < 0 else steps)
def _returnPressed(self, *args): """emit the value when return has been pressed """ self.returnPressed.emit(self.value())
[docs] def get(self): return self.value()
[docs] def set(self, value): self.setValue(value)
[docs] def setSelected(self): self.isSelected = True
[docs] def focusInEvent(self, QFocusEvent): self.setSelected() super(DoubleSpinbox, self).focusInEvent(QFocusEvent)
[docs] def setCallback(self, callback): """Sets callback; disconnects if callback=None """ if self._callback is not None: self.valueChanged.disconnect() if callback: self.valueChanged.connect(callback) self._callback = callback
[docs] def textFromValue(self, v: typing.Union[float, int]) -> str: """Subclass to remove extra zeroes """ if isinstance(v, int): return super(DoubleSpinbox, self).textFromValue(v) else: return self._qLocale.toString(round(v, self.decimals()), 'g', QtCore.QLocale.FloatingPointShortest)
[docs] def validate(self, text: str, pos: int) -> typing.Tuple[QtGui.QValidator.State, str, int]: _state = super().validate(text, pos) if self._showValidation: self._checkState(_state) return _state
def _checkState(self, _state): try: state, text, position = _state for obj in [self, self.lineEdit()]: # set the validate colours for the object palette = obj.palette() if state == QtGui.QValidator.Acceptable: palette.setColor(QtGui.QPalette.Active, QtGui.QPalette.Base, self.baseColour) elif state == QtGui.QValidator.Intermediate: palette.setColor(QtGui.QPalette.Active, QtGui.QPalette.Base, self._validationIntermediate) else: # Invalid palette.setColor(QtGui.QPalette.Active, QtGui.QPalette.Base, self._validationInvalid) obj.setPalette(palette) except Exception as es: pass
[docs] def keyPressEvent(self, event): # allow the typing of other stuff into the box and validate only when required self._keyPressed = event.key() if event.key() in KEYVALIDATELIST else 0 super().keyPressEvent(event)
[docs] def keyReleaseEvent(self, event): self._keyPressed = None super().keyReleaseEvent(event)
[docs] def setMinimumCharacters(self, value): from ccpn.ui.gui.widgets.Font import getTextDimensionsFromFont _, maxDim = getTextDimensionsFromFont(textList=['_' * value]) self.setMinimumWidth(maxDim.width())
def _getSaveState(self): """ Internal. Called for saving/restoring the widget state. """ return self.get() def _setSavedState(self, value): """ Internal. Called for saving/restoring the widget state. """ return self.set(value) def _flashColour(self, colour): try: # set the background colour for the object for obj in [self, self.lineEdit()]: # set the colours for the object palette = obj.palette() palette.setColor(QtGui.QPalette.Active, QtGui.QPalette.Base, colour) obj.setPalette(palette) except Exception as es: pass def _flashError(self, timer=300): # set the warning colour and then set back to background colour self._flashColour(self._validationInvalid) QtCore.QTimer.singleShot(timer, partial(self._flashColour, self.baseColour))
# Regular expression to find floats. Match groups are the whole string, the # whole coefficient, the decimal part of the coefficient, and the exponent # part. (decimal point, not decimal comma) _float_re = re.compile(r'(([+-]?\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?)')
[docs]def fexp(f): return int(floor(log10(abs(f)))) if f != 0 else 0
# def validFloat(string): # match = _float_re.search(string) # return match.groups()[0] == string if match else False
[docs]class ScientificDoubleSpinBox(DoubleSpinbox): """Constructs a spinbox in which the values can be set using Sci notation """ def __init__(self, *args, **kwargs): super(ScientificDoubleSpinBox, self).__init__(*args, **kwargs) self.setDecimals(1000) _decs = kwargs.get('step') # step if defined by 'step' relative to current power self._decimalStep = 0.1 if _decs is None else _decs
[docs] def valueFromText(self, text): """Values in the spinbox are constrained to the correct sign if the min/max values are either both positive or both negative """ val = super().valueFromText(text) if self.minimum() <= self.maximum() <= 0: # check for maximum _lineEdit = self.lineEdit() val = min(-abs(val), self.maximum()) # keep the cursor position so it doesn't jump to the end; same below _lastPos = _lineEdit.cursorPosition() _lineEdit.setText(self.textFromValue(val)) _lineEdit.setCursorPosition(_lastPos) elif 0 <= self.minimum() <= self.maximum(): # check for minimum _lineEdit = self.lineEdit() val = max(abs(val), self.minimum()) _lastPos = _lineEdit.cursorPosition() _lineEdit.setText(self.textFromValue(val)) _lineEdit.setCursorPosition(_lastPos) return val
[docs] def textFromValue(self, value): return self.formatFloat(value)
[docs] def stepBy(self, steps): """Increment the current value. Step if 1/10th of the current rounded value * step Now defined by 'step' in kwargs, i.e., step=0.1, 0.01 """ # clip number of steps to SCIENTIFICSPINBOXSTEP as 10* will go directly to zero from 1 when Ctrl/Cmd pressed steps = min(steps, SCIENTIFICSPINBOXSTEP) if steps > 0 else max(steps, -SCIENTIFICSPINBOXSTEP) text = self.cleanText() decimal, valid = self._qLocale.toFloat(text) if valid: decimal += steps * 10 ** fexp(decimal * self._decimalStep) new_string = self.formatFloat(decimal) self.lineEdit().setText(new_string)
[docs] def formatFloat(self, value): """Modified form of the 'g' format specifier. """ string = self._qLocale.toString(float(value), 'g', 6).replace("e+", "e") string = re.sub("e(-?)0*(\d+)", r"e\1\2", string) return string
def _getSaveState(self): """ Internal. Called for saving/restoring the widget state. """ return self.get() def _setSavedState(self, value): """ Internal. Called for saving/restoring the widget state. """ return self.set(value)
[docs] def validate(self, text, position): # activate the validator when if self._keyPressed == 0: _state = self.validator.Intermediate, text, position return _state if self._keyPressed in KEYVALIDATEDECIMAL: ii = position - 1 while ii > 0: # allow the user to enter zeroes after the decimal point/decimal comma if text[ii] in ['.', ',']: _state = self.validator.Intermediate, text, position return _state if text[ii] in ['e']: _state = self.validator.validate(text, position) return _state ii -= 1 _state = self.validator.validate(text, position) return _state else: _state = self.validator.validate(text, position) return _state
v = float("{0:.3f}".format(0.024)) v1 = 24678.45 if __name__ == '__main__': from ccpn.ui.gui.widgets.Application import TestApplication from ccpn.ui.gui.popups.Dialog import CcpnDialog from ccpn.ui.gui.widgets.Frame import Frame from PyQt5 import QtWidgets, QtCore, QtGui app = TestApplication() popup = CcpnDialog() # test setting the dialog to French (different float format) # QtCore.QLocale.setDefault(QtCore.QLocale(QtCore.QLocale.French)) fr = Frame(popup, setLayout=True) sb = DoubleSpinbox(fr, value=v1, decimals=3, step=0.01, grid=(0, 0)) sb2 = ScientificDoubleSpinBox(fr, value=v1, decimals=3, grid=(1, 0), min=0.001, max=1e32) sb3 = ScientificDoubleSpinBox(fr, value=v1, decimals=3, grid=(2, 0), max=-0.001, min=-1e32) popup.show() popup.raise_() app.start()