Issues (265)

qtwidgets/scientific_spinbox.py (68 issues)

1
# -*- coding: utf-8 -*-
2
3
"""
4
This file contains a wrapper to display the SpinBox in scientific way
5
6
Qudi is free software: you can redistribute it and/or modify
7
it under the terms of the GNU General Public License as published by
8
the Free Software Foundation, either version 3 of the License, or
9
(at your option) any later version.
10
11
Qudi is distributed in the hope that it will be useful,
12
but WITHOUT ANY WARRANTY; without even the implied warranty of
13
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
GNU General Public License for more details.
15
16
You should have received a copy of the GNU General Public License
17
along with Qudi. If not, see <http://www.gnu.org/licenses/>.
18
"""
19
20
from qtpy import QtCore, QtGui, QtWidgets
21
import numpy as np
22
import re
23
from decimal import Decimal as D  # Use decimal to avoid accumulating floating-point errors
24
from decimal import ROUND_FLOOR
25
import math
26
27
__all__ = ['ScienDSpinBox', 'ScienSpinBox']
28
29
30
class FloatValidator(QtGui.QValidator):
31
    """
32
    This is a validator for float values represented as strings in scientific notation.
33
    (i.e. "1.35e-9", ".24E+8", "14e3" etc.)
34
    Also supports SI unit prefix like 'M', 'n' etc.
35
    """
36
37
    float_re = re.compile(r'(\s*([+-]?)(\d+\.\d+|\.\d+|\d+\.?)([eE][+-]?\d+)?\s?([YZEPTGMkmµunpfazy]?)\s*)')
38
    group_map = {'match': 0,
39
                 'sign': 1,
40
                 'mantissa': 2,
41
                 'exponent': 3,
42
                 'si': 4}
43
44
    def validate(self, string, position):
45
        """
46
        This is the actual validator. It checks whether the current user input is a valid string
47
        every time the user types a character. There are 3 states that are possible.
48
        1) Invalid: The current input string is invalid. The user input will not accept the last
49
                    typed character.
50
        2) Acceptable: The user input in conform with the regular expression and will be accepted.
51
        3) Intermediate: The user input is not a valid string yet but on the right track. Use this
52
                         return value to allow the user to type fill-characters needed in order to
53
                         complete an expression (i.e. the decimal point of a float value).
54
        @param string: The current input string (from a QLineEdit for example)
55
        @param position: The current position of the text cursor
56
        @return: enum QValidator::State: the returned validator state,
57
                 str: the input string, int: the cursor position
58
        """
59
        # Return intermediate status when empty string is passed or when incomplete "[+-]inf"
60
        if string.strip() in '+.-.' or re.match(r'[+-]?(in$|i$)', string, re.IGNORECASE):
61
            return self.Intermediate, string, position
0 ignored issues
show
The Instance of FloatValidator does not seem to have a member named Intermediate.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
62
63
        # Accept input of [+-]inf. Not case sensitive.
64
        if re.match(r'[+-]?\binf$', string, re.IGNORECASE):
65
            return self.Acceptable, string.lower(), position
0 ignored issues
show
The Instance of FloatValidator does not seem to have a member named Acceptable.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
66
67
        group_dict = self.get_group_dict(string)
68
        if group_dict:
69
            if group_dict['match'] == string:
70
                return self.Acceptable, string, position
0 ignored issues
show
The Instance of FloatValidator does not seem to have a member named Acceptable.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
71
            if string.count('.') > 1:
72
                return self.Invalid, group_dict['match'], position
0 ignored issues
show
The Instance of FloatValidator does not seem to have a member named Invalid.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
73
            if position > len(string):
74
                position = len(string)
75
            if string[position-1] in 'eE-+' and 'i' not in string.lower():
76
                return self.Intermediate, string, position
0 ignored issues
show
The Instance of FloatValidator does not seem to have a member named Intermediate.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
77
            return self.Invalid, group_dict['match'], position
0 ignored issues
show
The Instance of FloatValidator does not seem to have a member named Invalid.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
78
        else:
79
            if string[position-1] in 'eE-+.' and 'i' not in string.lower():
80
                return self.Intermediate, string, position
0 ignored issues
show
The Instance of FloatValidator does not seem to have a member named Intermediate.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
81
            return self.Invalid, '', position
0 ignored issues
show
The Instance of FloatValidator does not seem to have a member named Invalid.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
82
83
    def get_group_dict(self, string):
84
        """
85
        This method will match the input string with the regular expression of this validator.
86
        The match groups will be put into a dictionary with string descriptors as keys describing
87
        the role of the specific group (i.e. mantissa, exponent, si-prefix etc.)
88
89
        @param string: str, input string to be matched
90
        @return: dictionary containing groups as items and descriptors as keys (see: self.group_map)
91
        """
92
        match = self.float_re.search(string)
93
        if not match:
94
            return False
95
        groups = match.groups()
96
        group_dict = dict()
97
        for group_key in self.group_map:
98
            group_dict[group_key] = groups[self.group_map[group_key]]
99
        return group_dict
100
101
    def fixup(self, text):
102
        match = self.float_re.search(text)
103
        if match:
104
            return match.groups()[0].strip()
105
        else:
106
            return ''
107
108
109
class IntegerValidator(QtGui.QValidator):
110
    """
111
    This is a validator for int values represented as strings in scientific notation.
112
    Using engeneering notation only positive exponents are allowed
113
    (i.e. "1e9", "2E+8", "14e+3" etc.)
114
    Also supports non-fractional SI unit prefix like 'M', 'k' etc.
115
    """
116
117
    int_re = re.compile(r'(([+-]?\d+)([eE]\+?\d+)?\s?([YZEPTGMk])?\s*)')
118
    group_map = {'match': 0,
119
                 'mantissa': 1,
120
                 'exponent': 2,
121
                 'si': 3
122
                 }
123
124
    def validate(self, string, position):
125
        """
126
        This is the actual validator. It checks whether the current user input is a valid string
127
        every time the user types a character. There are 3 states that are possible.
128
        1) Invalid: The current input string is invalid. The user input will not accept the last
129
                    typed character.
130
        2) Acceptable: The user input in conform with the regular expression and will be accepted.
131
        3) Intermediate: The user input is not a valid string yet but on the right track. Use this
132
                         return value to allow the user to type fill-characters needed in order to
133
                         complete an expression (i.e. the decimal point of a float value).
134
        @param string: The current input string (from a QLineEdit for example)
135
        @param position: The current position of the text cursor
136
        @return: enum QValidator::State: the returned validator state,
137
                 str: the input string, int: the cursor position
138
        """
139
        # Return intermediate status when empty string is passed or cursor is at index 0
140
        if not string.strip():
141
            return self.Intermediate, string, position
0 ignored issues
show
The Instance of IntegerValidator does not seem to have a member named Intermediate.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
142
143
        group_dict = self.get_group_dict(string)
144
        if group_dict:
145
            if group_dict['match'] == string:
146
                return self.Acceptable, string, position
0 ignored issues
show
The Instance of IntegerValidator does not seem to have a member named Acceptable.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
147
148
            if position > len(string):
149
                position = len(string)
150
            if string[position-1] in 'eE-+':
151
                return self.Intermediate, string, position
0 ignored issues
show
The Instance of IntegerValidator does not seem to have a member named Intermediate.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
152
153
            return self.Invalid, group_dict['match'], position
0 ignored issues
show
The Instance of IntegerValidator does not seem to have a member named Invalid.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
154
        else:
155
            return self.Invalid, '', position
0 ignored issues
show
The Instance of IntegerValidator does not seem to have a member named Invalid.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
156
157
    def get_group_dict(self, string):
158
        """
159
        This method will match the input string with the regular expression of this validator.
160
        The match groups will be put into a dictionary with string descriptors as keys describing
161
        the role of the specific group (i.e. mantissa, exponent, si-prefix etc.)
162
163
        @param string: str, input string to be matched
164
        @return: dictionary containing groups as items and descriptors as keys (see: self.group_map)
165
        """
166
        match = self.int_re.search(string)
167
        if not match:
168
            return False
169
        groups = match.groups()
170
        group_dict = dict()
171
        for group_key in self.group_map:
172
            group_dict[group_key] = groups[self.group_map[group_key]]
173
        return group_dict
174
175
    def fixup(self, text):
176
        match = self.int_re.search(text)
177
        if match:
178
            return match.groups()[0].strip()
179
        else:
180
            return ''
181
182
183
class ScienDSpinBox(QtWidgets.QAbstractSpinBox):
184
    """
185
    Wrapper Class from PyQt5 (or QtPy) to display a QDoubleSpinBox in Scientific way.
186
    Fully supports prefix and suffix functionality of the QDoubleSpinBox.
187
    Has built-in functionality to invoke the displayed number precision from the user input.
188
189
    This class can be directly used in Qt Designer by promoting the QDoubleSpinBox to ScienDSpinBox.
190
    State the path to this file (in python style, i.e. dots are separating the directories) as the
191
    header file and use the name of the present class.
192
    """
193
194
    valueChanged = QtCore.Signal(object)
195
196
    # The maximum number of decimals to allow. Be careful when changing this number since
197
    # the decimal package has by default a limited accuracy.
198
    __max_decimals = 20
199
    # Dictionary mapping the si-prefix to a scaling factor as decimal.Decimal (exact value)
200
    _unit_prefix_dict = {
201
        'y': D('1e-24'),
202
        'z': D('1e-21'),
203
        'a': D('1e-18'),
204
        'f': D('1e-15'),
205
        'p': D('1e-12'),
206
        'n': D('1e-9'),
207
        'µ': D('1e-6'),
208
        'm': D('1e-3'),
209
        '': D('1'),
210
        'k': D('1e3'),
211
        'M': D('1e6'),
212
        'G': D('1e9'),
213
        'T': D('1e12'),
214
        'P': D('1e15'),
215
        'E': D('1e18'),
216
        'Z': D('1e21'),
217
        'Y': D('1e24')
218
    }
219
220
    def __init__(self, *args, **kwargs):
221
        super().__init__(*args, **kwargs)
222
        self.__value = D(0)
223
        self.__minimum = -np.inf
224
        self.__maximum = np.inf
225
        self.__decimals = 2  # default in QtDesigner
226
        self.__prefix = ''
227
        self.__suffix = ''
228
        self.__singleStep = D('0.1')  # must be precise Decimal always, no conversion from float
229
        self.__minimalStep = D(0)  # must be precise Decimal always, no conversion from float
230
        self.__cached_value = None  # a temporary variable for restore functionality
231
        self._dynamic_stepping = True
232
        self._dynamic_precision = True
233
        self._is_valid = True  # A flag property to check if the current value is valid.
234
        self.validator = FloatValidator()
235
        self.lineEdit().textEdited.connect(self.update_value)
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
236
        self.update_display()
237
238
    @property
239
    def dynamic_stepping(self):
240
        """
241
        This property is a flag indicating if the dynamic (logarithmic) stepping should be used or
242
        not (fixed steps).
243
244
        @return: bool, use dynamic stepping (True) or constant steps (False)
245
        """
246
        return bool(self._dynamic_stepping)
247
248
    @dynamic_stepping.setter
249
    def dynamic_stepping(self, use_dynamic_stepping):
250
        """
251
        This property is a flag indicating if the dynamic (logarithmic) stepping should be used or
252
        not (fixed steps).
253
254
        @param use_dynamic_stepping: bool, use dynamic stepping (True) or constant steps (False)
255
        """
256
        use_dynamic_stepping = bool(use_dynamic_stepping)
257
        self._dynamic_stepping = use_dynamic_stepping
258
259
    @property
260
    def dynamic_precision(self):
261
        """
262
        This property is a flag indicating if the dynamic (invoked from user input) decimal
263
        precision should be used or not (fixed number of digits).
264
265
        @return: bool, use dynamic precision (True) or fixed precision (False)
266
        """
267
        return bool(self._dynamic_precision)
268
269
    @dynamic_precision.setter
270
    def dynamic_precision(self, use_dynamic_precision):
271
        """
272
        This property is a flag indicating if the dynamic (invoked from user input) decimal
273
        precision should be used or not (fixed number of digits).
274
275
        @param use_dynamic_precision: bool, use dynamic precision (True) or fixed precision (False)
276
        """
277
        use_dynamic_precision = bool(use_dynamic_precision)
278
        self._dynamic_precision = use_dynamic_precision
279
280
    @property
281
    def is_valid(self):
282
        """
283
        This property is a flag indicating if the currently available value is valid.
284
        It will return False if there has been an attempt to set NaN as current value.
285
        Will return True after a valid value has been set.
286
287
        @return: bool, current value invalid (False) or current value valid (True)
288
        """
289
        return bool(self._is_valid)
290
291
    def update_value(self):
292
        """
293
        This method will grab the currently shown text from the QLineEdit and interpret it.
294
        Range checking is performed on the value afterwards.
295
        If a valid value can be derived, it will set this value as the current value
296
        (if it has changed) and emit the valueChanged signal.
297
        Note that the comparison between old and new value is done by comparing the float
298
        representations of both values and not by comparing them as Decimals.
299
        The valueChanged signal will only emit if the actual float representation has changed since
300
        Decimals are only internally used and the rest of the program won't notice a slight change
301
        in the Decimal that can't be resolved in a float.
302
        In addition it will cache the old value provided the cache is empty to be able to restore
303
        it later on.
304
        """
305
        text = self.cleanText()
306
        value = self.valueFromText(text)
307
        if value is False:
308
            return
309
        value, in_range = self.check_range(value)
0 ignored issues
show
The variable in_range seems to be unused.
Loading history...
310
311
        # save old value to be able to restore it later on
312
        if self.__cached_value is None:
313
            self.__cached_value = self.__value
314
315
        if float(value) != self.value():
316
            self.__value = value
317
            self.valueChanged.emit(self.value())
318
        else:
319
            self.__value = value
320
        self._is_valid = True
321
322
    def value(self):
323
        """
324
        Getter method to obtain the current value as float.
325
326
        @return: float, the current value of the SpinBox
327
        """
328
        return float(self.__value)
329
330
    def setValue(self, value):
331
        """
332
        Setter method to programmatically set the current value. For best robustness pass the value
333
        as string or Decimal in order to be lossless cast into Decimal.
334
        Will perform range checking and ignore NaN values.
335
        Will emit valueChanged if the new value is different from the old one.
336
        When using dynamic decimals precision, this method will also try to invoke the optimal
337
        display precision by checking for a change in the displayed text.
338
        """
339
        try:
340
            value = D(value)
341
        except TypeError:
342
            if 'int' in type(value).__name__:
343
                value = int(value)
344
            elif 'float' in type(value).__name__:
345
                value = float(value)
346
            else:
347
                raise
348
            value = D(value)
349
350
        # catch NaN values and set the "is_valid" flag to False until a valid value is set again.
351
        if value.is_nan():
352
            self._is_valid = False
353
            return
354
355
        value, in_range = self.check_range(value)
0 ignored issues
show
The variable in_range seems to be unused.
Loading history...
356
357
        if self.__value != value or not self.is_valid:
358
            # Try to increase decimals when the value has changed but no change in display detected.
359
            # This will only be executed when the dynamic precision flag is set
360
            if self.value() != float(value) and self.dynamic_precision and not value.is_infinite():
361
                old_text = self.cleanText()
362
                new_text = self.textFromValue(value).strip()
363
                current_dec = self.decimals()
364
                while old_text == new_text:
365
                    if self.__decimals > self.__max_decimals:
366
                        self.__decimals = current_dec
367
                        break
368
                    self.__decimals += 1
369
                    new_text = self.textFromValue(value).strip()
370
            self.__value = value
371
            self._is_valid = True
372
            self.update_display()
373
            self.valueChanged.emit(self.value())
374
375
    def setProperty(self, prop, val):
376
        """
377
        For compatibility with QtDesigner. Somehow the value gets initialized through this method.
378
        @param prop:
379
        @param val:
380
        """
381
        if prop == 'value':
382
            self.setValue(val)
383
        else:
384
            raise UserWarning('setProperty in scientific spinboxes only works for "value".')
385
386
    def check_range(self, value):
387
        """
388
        Helper method to check if the passed value is within the set minimum and maximum value
389
        bounds.
390
        If outside of bounds the returned value will be clipped to the nearest boundary.
391
392
        @param value: float|Decimal, number to be checked
393
        @return: (Decimal, bool), the corrected value and a flag indicating if the value has been
394
                                  changed (False) or not (True)
395
        """
396
397
        if value < self.__minimum:
398
            new_value = self.__minimum
399
            in_range = False
400
        elif value > self.__maximum:
401
            new_value = self.__maximum
402
            in_range = False
403
        else:
404
            in_range = True
405
        if not in_range:
406
            value = D(new_value)
407
        return value, in_range
408
409
    def minimum(self):
410
        return float(self.__minimum)
411
412
    def setMinimum(self, minimum):
413
        """
414
        Setter method to set the minimum value allowed in the SpinBox.
415
        Input will be converted to float before being stored.
416
417
        @param minimum: float, the minimum value to be set
418
        """
419
        # Ignore NaN values
420
        if self._check_nan(float(minimum)):
421
            return
422
423
        self.__minimum = float(minimum)
424
        if self.__minimum > self.value():
425
            self.setValue(self.__minimum)
426
427
    def maximum(self):
428
        return float(self.__maximum)
429
430
    def setMaximum(self, maximum):
431
        """
432
        Setter method to set the maximum value allowed in the SpinBox.
433
        Input will be converted to float before being stored.
434
435
        @param maximum: float, the maximum value to be set
436
        """
437
        # Ignore NaN values
438
        if self._check_nan(float(maximum)):
439
            return
440
441
        self.__maximum = float(maximum)
442
        if self.__maximum < self.value():
443
            self.setValue(self.__maximum)
444
445
    def setRange(self, minimum, maximum):
446
        """
447
        Convenience method for compliance with Qt SpinBoxes.
448
        Essentially a wrapper to call both self.setMinimum and self.setMaximum.
449
450
        @param minimum: float, the minimum value to be set
451
        @param maximum: float, the maximum value to be set
452
        """
453
        self.setMinimum(minimum)
454
        self.setMaximum(maximum)
455
456
    def decimals(self):
457
        return self.__decimals
458
459
    def setDecimals(self, decimals, dynamic_precision=True):
460
        """
461
        Method to set the number of displayed digits after the decimal point.
462
        Also specifies if the dynamic precision functionality should be used or not.
463
        If dynamic_precision=True the number of decimals will be invoked from the number of
464
        decimals entered by the user in the QLineEdit of this spinbox. The set decimal value will
465
        only be used before the first explicit user text input or call to self.setValue.
466
        If dynamic_precision=False the specified number of decimals will be fixed and will not be
467
        changed except by calling this method.
468
469
        @param decimals: int, the number of decimals to be displayed
470
        @param dynamic_precision: bool, flag indicating the use of dynamic_precision
471
        """
472
        decimals = int(decimals)
473
        # Restrict the number of fractional digits to a maximum of self.__max_decimals = 20.
474
        # Beyond that the number is not very meaningful anyways due to machine precision.
475
        if decimals < 0:
476
            decimals = 0
477
        elif decimals > self.__max_decimals:
478
            decimals = self.__max_decimals
479
        self.__decimals = decimals
480
        # Set the flag for using dynamic precision (decimals invoked from user input)
481
        self.dynamic_precision = dynamic_precision
482
483
    def prefix(self):
484
        return self.__prefix
485
486
    def setPrefix(self, prefix):
487
        """
488
        Set a string to be shown as non-editable prefix in the spinbox.
489
490
        @param prefix: str, the prefix string to be set
491
        """
492
        self.__prefix = str(prefix)
493
        self.update_display()
494
495
    def suffix(self):
496
        return self.__suffix
497
498
    def setSuffix(self, suffix):
499
        """
500
        Set a string to be shown as non-editable suffix in the spinbox.
501
        This suffix will come right after the si-prefix.
502
503
        @param suffix: str, the suffix string to be set
504
        """
505
        self.__suffix = str(suffix)
506
        self.update_display()
507
508
    def singleStep(self):
509
        return float(self.__singleStep)
510
511
    def setSingleStep(self, step, dynamic_stepping=True):
512
        """
513
        Method to set the stepping behaviour of the spinbox (e.g. when moving the mouse wheel).
514
515
        When dynamic_stepping=True the spinbox will perform logarithmic steps according to the
516
        values' current order of magnitude. The step parameter is then referring to the step size
517
        relative to the values order of magnitude. Meaning step=0.1 would step increment the second
518
        most significant digit by one etc.
519
520
        When dynamic_stepping=False the step parameter specifies an absolute step size. Meaning each
521
        time a step is performed this value is added/substracted from the current value.
522
523
        For maximum roboustness and consistency it is strongly recommended to pass step as Decimal
524
        or string in order to be converted lossless to Decimal.
525
526
        @param step: Decimal|str, the (relative) step size to set
527
        @param dynamic_stepping: bool, flag indicating the use of dynamic stepping (True) or
528
                                       constant stepping (False)
529
        """
530
        try:
531
            step = D(step)
532
        except TypeError:
533
            if 'int' in type(step).__name__:
534
                step = int(step)
535
            elif 'float' in type(step).__name__:
536
                step = float(step)
537
            else:
538
                raise
539
            step = D(step)
540
541
        # ignore NaN and infinity values
542
        if not step.is_nan() and not step.is_infinite():
543
            self.__singleStep = step
544
545
        self.dynamic_stepping = dynamic_stepping
546
547
    def minimalStep(self):
548
        return float(self.__minimalStep)
549
550
    def setMinimalStep(self, step):
551
        """
552
        Method used to set a minimal step size.
553
        When the absolute step size has been calculated in either dynamic or constant step mode
554
        this value is checked against the minimal step size. If it is smaller then the minimal step
555
        size is chosen over the calculated step size. This ensures that no step taken can be
556
        smaller than minimalStep.
557
        Set this value to 0 for no minimal step size.
558
559
        For maximum roboustness and consistency it is strongly recommended to pass step as Decimal
560
        or string in order to be converted lossless to Decimal.
561
562
        @param step: Decimal|str, the minimal step size to be set
563
        """
564
        try:
565
            step = D(step)
566
        except TypeError:
567
            if 'int' in type(step).__name__:
568
                step = int(step)
569
            elif 'float' in type(step).__name__:
570
                step = float(step)
571
            else:
572
                raise
573
            step = D(step)
574
575
        # ignore NaN and infinity values
576
        if not step.is_nan() and not step.is_infinite():
577
            self.__minimalStep = step
578
579
    def cleanText(self):
580
        """
581
        Compliance method from Qt SpinBoxes.
582
        Returns the currently shown text from the QLineEdit without prefix and suffix and stripped
583
        from leading or trailing whitespaces.
584
585
        @return: str, currently shown text stripped from suffix and prefix
586
        """
587
        text = self.text().strip()
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named text.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
588
        if self.__prefix and text.startswith(self.__prefix):
589
            text = text[len(self.__prefix):]
590
        if self.__suffix and text.endswith(self.__suffix):
591
            text = text[:-len(self.__suffix)]
592
        return text.strip()
593
594
    def update_display(self):
595
        """
596
        This helper method updates the shown text based on the current value.
597
        Because this method is only called upon finishing an editing procedure, the eventually
598
        cached value gets deleted.
599
        """
600
        text = self.textFromValue(self.value())
601
        text = self.__prefix + text + self.__suffix
602
        self.lineEdit().setText(text)
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
603
        self.__cached_value = None  # clear cached value
604
        self.lineEdit().setCursorPosition(0)  # Display the most significant part of the number
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
605
606
    def keyPressEvent(self, event):
607
        """
608
        This method catches all keyboard press events triggered by the user. Can be used to alter
609
        the behaviour of certain key events from the default implementation of QAbstractSpinBox.
610
611
        @param event: QKeyEvent, a Qt QKeyEvent instance holding the event information
612
        """
613
        # Restore cached value upon pressing escape and lose focus.
614
        if event.key() == QtCore.Qt.Key_Escape:
615
            if self.__cached_value is not None:
616
                self.__value = self.__cached_value
617
                self.valueChanged.emit(self.value())
618
            self.clearFocus()  # This will also trigger editingFinished
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named clearFocus.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
619
            return
620
621
        # Update display upon pressing enter/return before processing the event in the default way.
622
        if event.key() == QtCore.Qt.Key_Enter or event.key() == QtCore.Qt.Key_Return:
623
            self.update_display()
624
625
        if (QtCore.Qt.ControlModifier | QtCore.Qt.MetaModifier) & event.modifiers():
626
            super().keyPressEvent(event)
627
            return
628
629
        # The rest is to avoid editing suffix and prefix
630
        if len(event.text()) > 0:
631
            # Allow editing of the number or SI-prefix even if part of the prefix/suffix is selected.
632
            if self.lineEdit().selectedText():
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
633
                sel_start = self.lineEdit().selectionStart()
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
634
                sel_end = sel_start + len(self.lineEdit().selectedText())
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
635
                min_start = len(self.__prefix)
636
                max_end = len(self.__prefix) + len(self.cleanText())
637
                if sel_start < min_start:
638
                    sel_start = min_start
639
                if sel_end > max_end:
640
                    sel_end = max_end
641
                self.lineEdit().setSelection(sel_start, sel_end - sel_start)
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
642
            else:
643
                cursor_pos = self.lineEdit().cursorPosition()
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
644
                begin = len(self.__prefix)
645
                end = len(self.text()) - len(self.__suffix)
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named text.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
646
                if cursor_pos < begin:
647
                    self.lineEdit().setCursorPosition(begin)
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
648
                elif cursor_pos > end:
649
                    self.lineEdit().setCursorPosition(end)
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
650
651
        if event.key() == QtCore.Qt.Key_Left:
652
            if self.lineEdit().cursorPosition() == len(self.__prefix):
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
653
                return
654
        if event.key() == QtCore.Qt.Key_Right:
655
            if self.lineEdit().cursorPosition() == len(self.text()) - len(self.__suffix):
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
The Instance of ScienDSpinBox does not seem to have a member named text.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
656
                return
657
        if event.key() == QtCore.Qt.Key_Home:
658
            self.lineEdit().setCursorPosition(len(self.__prefix))
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
659
            return
660
        if event.key() == QtCore.Qt.Key_End:
661
            self.lineEdit().setCursorPosition(len(self.text()) - len(self.__suffix))
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
The Instance of ScienDSpinBox does not seem to have a member named text.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
662
            return
663
664
        super().keyPressEvent(event)
665
666
    def focusInEvent(self, event):
667
        super().focusInEvent(event)
668
        self.selectAll()
669
        return
670
671
    def focusOutEvent(self, event):
672
        self.update_display()
673
        super().focusOutEvent(event)
674
        return
675
676
    def paintEvent(self, ev):
677
        """
678
        Add drawing of a red frame around the spinbox if the is_valid flag is False
679
        """
680
        super().paintEvent(ev)
681
682
        # draw red frame if is_valid = False
683
        if not self.is_valid:
684
            pen = QtGui.QPen()
685
            pen.setColor(QtGui.QColor(200, 50, 50))
686
            pen.setWidth(2)
687
688
            p = QtGui.QPainter(self)
689
            p.setRenderHint(p.Antialiasing)
690
            p.setPen(pen)
691
            p.drawRoundedRect(self.rect().adjusted(2, 2, -2, -2), 4, 4)
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named rect.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
692
            p.end()
693
694
    def validate(self, text, position):
695
        """
696
        Access method to the validator. See FloatValidator class for more information.
697
698
        @param text: str, string to be validated.
699
        @param position: int, current text cursor position
700
        @return: (enum QValidator::State) the returned validator state,
701
                 (str) the input string, (int) the cursor position
702
        """
703
        begin = len(self.__prefix)
704
        end = len(text) - len(self.__suffix)
705
        if position < begin:
706
            position = begin
707
        elif position > end:
708
            position = end
709
710
        if self.__prefix and text.startswith(self.__prefix):
711
            text = text[len(self.__prefix):]
712
        if self.__suffix and text.endswith(self.__suffix):
713
            text = text[:-len(self.__suffix)]
714
715
        state, string, position = self.validator.validate(text, position)
716
717
        text = self.__prefix + string + self.__suffix
718
719
        end = len(text) - len(self.__suffix)
720
        if position > end:
721
            position = end
722
723
        return state, text, position
724
725
    def fixup(self, text):
726
        """
727
        Takes an invalid string and tries to fix it in order to pass validation.
728
        The returned string is not guaranteed to pass validation.
729
730
        @param text: str, a string that has not passed validation in need to be fixed.
731
        @return: str, the resulting string from the fix attempt
732
        """
733
        return self.validator.fixup(text)
734
735
    def valueFromText(self, text):
736
        """
737
        This method is responsible for converting a string displayed in the SpinBox into a Decimal.
738
739
        The input string is already stripped of prefix and suffix.
740
        Just the si-prefix may be present.
741
742
        @param text: str, the display string to be converted into a numeric value.
743
                          This string must be conform with the validator.
744
        @return: Decimal, the numeric value converted from the input string.
745
        """
746
        # Check for infinite value
747
        if 'inf' in text.lower():
748
            if text.startswith('-'):
749
                return D('-inf')
750
            else:
751
                return D('inf')
752
753
        # Handle "normal" (non-infinite) input
754
        group_dict = self.validator.get_group_dict(text)
755
        if not group_dict:
756
            return False
757
758
        if not group_dict['mantissa']:
759
            return False
760
761
        si_prefix = group_dict['si']
762
        if si_prefix is None:
763
            si_prefix = ''
764
        si_scale = self._unit_prefix_dict[si_prefix.replace('u', 'µ')]
765
766
        if group_dict['sign'] is not None:
767
            unscaled_value_str = group_dict['sign'] + group_dict['mantissa']
768
        else:
769
            unscaled_value_str = group_dict['mantissa']
770
        if group_dict['exponent'] is not None:
771
            unscaled_value_str += group_dict['exponent']
772
773
        value = D(unscaled_value_str) * si_scale
774
775
        # Try to extract the precision the user intends to use
776
        if self.dynamic_precision:
777
            split_mantissa = group_dict['mantissa'].split('.')
778
            if len(split_mantissa) == 2:
779
                self.setDecimals(max(len(split_mantissa[1]), 1))
780
            else:
781
                self.setDecimals(1)  # Minimum number of digits is 1
782
783
        return value
784
785
    def textFromValue(self, value):
786
        """
787
        This method is responsible for the mapping of the underlying value to a string to display
788
        in the SpinBox.
789
        Suffix and Prefix must not be handled here, just the si-Prefix.
790
791
        The main problem here is, that a scaled float with a suffix is represented by a different
792
        machine precision than the total value.
793
        This method is so complicated because it represents the actual precision of the value as
794
        float and not the precision of the scaled si float.
795
        '{:.20f}'.format(value) shows different digits than
796
        '{:.20f} {}'.format(scaled_value, si_prefix)
797
798
        @param value: float|decimal.Decimal, the numeric value to be formatted into a string
799
        @return: str, the formatted string representing the input value
800
        """
801
        # Catch infinity value
802
        if np.isinf(float(value)):
803
            if value < 0:
804
                return '-inf '
805
            else:
806
                return 'inf '
807
808
        sign = '-' if value < 0 else ''
809
        fractional, integer = math.modf(abs(value))
810
        integer = int(integer)
811
        si_prefix = ''
812
        prefix_index = 0
813
        if integer != 0:
814
            integer_str = str(integer)
815
            fractional_str = ''
816
            while len(integer_str) > 3:
817
                fractional_str = integer_str[-3:] + fractional_str
818
                integer_str = integer_str[:-3]
819
                if prefix_index < 8:
820
                    si_prefix = 'kMGTPEZY'[prefix_index]
821
                else:
822
                    si_prefix = 'e{0:d}'.format(3 * (prefix_index + 1))
823
                prefix_index += 1
824
            # Truncate and round to set number of decimals
825
            # Add digits from fractional if it's not already enough for set self.__decimals
826
            if self.__decimals < len(fractional_str):
827
                round_indicator = int(fractional_str[self.__decimals])
828
                fractional_str = fractional_str[:self.__decimals]
829
                if round_indicator >= 5:
830
                    if not fractional_str:
831
                        fractional_str = '1'
832
                    else:
833
                        fractional_str = str(int(fractional_str) + 1)
834
            elif self.__decimals == len(fractional_str):
835
                if fractional >= 0.5:
836
                    if fractional_str:
837
                        fractional_int = int(fractional_str) + 1
838
                        fractional_str = str(fractional_int)
839
                    else:
840
                        fractional_str = '1'
841
            elif self.__decimals > len(fractional_str):
842
                digits_to_add = self.__decimals - len(fractional_str) # number of digits to add
843
                fractional_tmp_str = ('{0:.' + str(digits_to_add) + 'f}').format(fractional)
844
                if fractional_tmp_str.startswith('1'):
845
                    if fractional_str:
846
                        fractional_str = str(int(fractional_str) + 1) + '0' * digits_to_add
847
                    else:
848
                        fractional_str = '1' + '0' * digits_to_add
849
                else:
850
                    fractional_str += fractional_tmp_str.split('.')[1]
851
            # Check if the rounding has overflown the fractional part into the integer part
852
            if len(fractional_str) > self.__decimals:
853
                integer_str = str(int(integer_str) + 1)
854
                fractional_str = '0' * self.__decimals
855
        elif fractional == 0.0:
856
            fractional_str = '0' * self.__decimals
857
            integer_str = '0'
858
        else:
859
            # determine the order of magnitude by comparing the fractional to unit values
860
            prefix_index = 1
861
            magnitude = 1e-3
862
            si_prefix = 'm'
863
            while magnitude > fractional:
864
                prefix_index += 1
865
                magnitude = magnitude ** prefix_index
866
                if prefix_index <= 8:
867
                    si_prefix = 'mµnpfazy'[prefix_index - 1]  # use si-prefix if possible
868
                else:
869
                    si_prefix = 'e-{0:d}'.format(3 * prefix_index)  # use engineering notation
870
            # Get the string representation of all needed digits from the fractional part of value.
871
            digits_needed = 3 * prefix_index + self.__decimals
872
            helper_str = ('{0:.' + str(digits_needed) + 'f}').format(fractional)
873
            overflow = bool(int(helper_str.split('.')[0]))
874
            helper_str = helper_str.split('.')[1]
875
            if overflow:
876
                integer_str = '1000'
877
                fractional_str = '0' * self.__decimals
878
            elif (prefix_index - 1) > 0 and helper_str[3 * (prefix_index - 1) - 1] != '0':
879
                integer_str = '1000'
880
                fractional_str = '0' * self.__decimals
881
            else:
882
                integer_str = str(int(helper_str[:3 * prefix_index]))
883
                fractional_str = helper_str[3 * prefix_index:3 * prefix_index + self.__decimals]
884
885
        # Create the actual string representation of value scaled in a scientific way
886
        space = '' if si_prefix.startswith('e') else ' '
887
        if self.__decimals > 0:
888
            string = '{0}{1}.{2}{3}{4}'.format(sign, integer_str, fractional_str, space, si_prefix)
889
        else:
890
            string = '{0}{1}{2}{3}'.format(sign, integer_str, space, si_prefix)
891
        return string
892
893
    def stepEnabled(self):
894
        """
895
        Enables stepping (mouse wheel, arrow up/down, clicking, PgUp/Down) by default.
896
        """
897
        return self.StepUpEnabled | self.StepDownEnabled
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named StepUpEnabled.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
The Instance of ScienDSpinBox does not seem to have a member named StepDownEnabled.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
898
899
    def stepBy(self, steps):
900
        """
901
        This method is incrementing the value of the SpinBox when the user triggers a step
902
        (by pressing PgUp/PgDown/Up/Down, MouseWheel movement or clicking on the arrows).
903
        It should handle the case when the new to-set value is out of bounds.
904
        Also the absolute value of a single step increment should be handled here.
905
        It is absolutely necessary to avoid accumulating rounding errors and/or discrepancy between
906
        self.value and the displayed text.
907
908
        @param steps: int, Number of steps to increment (NOT the absolute step size)
909
        """
910
        # Ignore stepping for infinity values
911
        if self.__value.is_infinite():
912
            return
913
914
        n = D(int(steps))  # n must be integral number of steps.
915
        s = [D(-1), D(1)][n >= 0]  # determine sign of step
916
        value = self.__value  # working copy of current value
917
        if self.dynamic_stepping:
918
            for i in range(int(abs(n))):
0 ignored issues
show
The variable i seems to be unused.
Loading history...
919
                if value == 0:
920
                    step = self.__minimalStep
921
                else:
922
                    vs = [D(-1), D(1)][value >= 0]
923
                    fudge = D('1.01') ** (s * vs)  # fudge factor. At some places, the step size
924
                                                   # depends on the step sign.
925
                    exp = abs(value * fudge).log10().quantize(1, rounding=ROUND_FLOOR)
926
                    step = self.__singleStep * D(10) ** exp
927
                    if self.__minimalStep > 0:
928
                        step = max(step, self.__minimalStep)
929
                value += s * step
930
        else:
931
            value = value + max(self.__minimalStep * n, self.__singleStep * n)
932
        self.setValue(value)
933
        return
934
935
    def selectAll(self):
936
        begin = len(self.__prefix)
937
        text = self.cleanText()
938
        if text.endswith(' '):
939
            selection_length = len(text) + 1
940
        elif len(text) > 0 and text[-1] in self._unit_prefix_dict:
941
            selection_length = len(text) - 1
942
        else:
943
            selection_length = len(text)
944
        self.lineEdit().setSelection(begin, selection_length)
0 ignored issues
show
The Instance of ScienDSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
945
946
    @staticmethod
947
    def _check_nan(value):
948
        """
949
        Helper method to check if the passed float value is NaN.
950
        Makes use of the fact that NaN values will always compare to false, even with itself.
951
952
        @param value: Decimal|float, value to be checked for NaN
953
        @return: (bool) is NaN (True), is no NaN (False)
954
        """
955
        return not value == value
956
957
958
class ScienSpinBox(QtWidgets.QAbstractSpinBox):
959
    """
960
    Wrapper Class from PyQt5 (or QtPy) to display a QSpinBox in Scientific way.
961
    Fully supports prefix and suffix functionality of the QSpinBox.
962
963
    This class can be directly used in Qt Designer by promoting the QSpinBox to ScienSpinBox.
964
    State the path to this file (in python style, i.e. dots are separating the directories) as the
965
    header file and use the name of the present class.
966
    """
967
968
    valueChanged = QtCore.Signal(object)
969
    # Dictionary mapping the si-prefix to a scaling factor as integer (exact value)
970
    _unit_prefix_dict = {
971
        '': 1,
972
        'k': 10 ** 3,
973
        'M': 10 ** 6,
974
        'G': 10 ** 9,
975
        'T': 10 ** 12,
976
        'P': 10 ** 15,
977
        'E': 10 ** 18,
978
        'Z': 10 ** 21,
979
        'Y': 10 ** 24
980
    }
981
982
    def __init__(self, *args, **kwargs):
983
        super().__init__(*args, **kwargs)
984
        self.__value = 0
985
        self.__minimum = 2 ** 31 - 1  # Use a 32bit integer size by default. Same as QSpinBox.
986
        self.__maximum = -2 ** 31  # Use a 32bit integer size by default. Same as QSpinBox.
987
        self.__prefix = ''
988
        self.__suffix = ''
989
        self.__singleStep = 1
990
        self.__minimalStep = 1
991
        self.__cached_value = None  # a temporary variable for restore functionality
992
        self._dynamic_stepping = True
993
        self.validator = IntegerValidator()
994
        self.lineEdit().textEdited.connect(self.update_value)
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
995
        self.update_display()
996
997
    @property
998
    def dynamic_stepping(self):
999
        """
1000
        This property is a flag indicating if the dynamic (logarithmic) stepping should be used or
1001
        not (fixed steps).
1002
1003
        @return: bool, use dynamic stepping (True) or constant steps (False)
1004
        """
1005
        return bool(self._dynamic_stepping)
1006
1007
    @dynamic_stepping.setter
1008
    def dynamic_stepping(self, use_dynamic_stepping):
1009
        """
1010
        This property is a flag indicating if the dynamic (logarithmic) stepping should be used or
1011
        not (fixed steps).
1012
1013
        @param use_dynamic_stepping: bool, use dynamic stepping (True) or constant steps (False)
1014
        """
1015
        use_dynamic_stepping = bool(use_dynamic_stepping)
1016
        self._dynamic_stepping = use_dynamic_stepping
1017
1018
    def update_value(self):
1019
        """
1020
        This method will grab the currently shown text from the QLineEdit and interpret it.
1021
        Range checking is performed on the value afterwards.
1022
        If a valid value can be derived, it will set this value as the current value
1023
        (if it has changed) and emit the valueChanged signal.
1024
        In addition it will cache the old value provided the cache is empty to be able to restore
1025
        it later on.
1026
        """
1027
        text = self.cleanText()
1028
        value = self.valueFromText(text)
1029
        if value is False:
1030
            return
1031
        value, in_range = self.check_range(value)
0 ignored issues
show
The variable in_range seems to be unused.
Loading history...
1032
1033
        # save old value to be able to restore it later on
1034
        if self.__cached_value is None:
1035
            self.__cached_value = self.__value
1036
1037
        if value != self.value():
1038
            self.__value = value
1039
            self.valueChanged.emit(self.value())
1040
1041
    def value(self):
1042
        """
1043
        Getter method to obtain the current value as int.
1044
1045
        @return: int, the current value of the SpinBox
1046
        """
1047
        return int(self.__value)
1048
1049
    def setValue(self, value):
1050
        """
1051
        Setter method to programmatically set the current value.
1052
        Will perform range checking and ignore NaN values.
1053
        Will emit valueChanged if the new value is different from the old one.
1054
        """
1055
        if value is np.nan:
1056
            return
1057
1058
        value = int(value)
1059
1060
        value, in_range = self.check_range(value)
0 ignored issues
show
The variable in_range seems to be unused.
Loading history...
1061
1062
        if self.__value != value:
1063
            self.__value = value
1064
            self.update_display()
1065
            self.valueChanged.emit(self.value())
1066
1067
    def setProperty(self, prop, val):
1068
        """
1069
        For compatibility with QtDesigner. Somehow the value gets initialized through this method.
1070
        @param prop:
1071
        @param val:
1072
        """
1073
        if prop == 'value':
1074
            self.setValue(val)
1075
        else:
1076
            raise UserWarning('setProperty in scientific spinboxes only works for "value".')
1077
1078
    def check_range(self, value):
1079
        """
1080
        Helper method to check if the passed value is within the set minimum and maximum value
1081
        bounds.
1082
        If outside of bounds the returned value will be clipped to the nearest boundary.
1083
1084
        @param value: int, number to be checked
1085
        @return: (int, bool), the corrected value and a flag indicating if the value has been
1086
                              changed (False) or not (True)
1087
        """
1088
        if value < self.__minimum:
1089
            new_value = self.__minimum
1090
            in_range = False
1091
        elif value > self.__maximum:
1092
            new_value = self.__maximum
1093
            in_range = False
1094
        else:
1095
            in_range = True
1096
        if not in_range:
1097
            value = int(new_value)
1098
        return value, in_range
1099
1100
    def minimum(self):
1101
        return int(self.__minimum)
1102
1103
    def setMinimum(self, minimum):
1104
        """
1105
        Setter method to set the minimum value allowed in the SpinBox.
1106
        Input will be converted to int before being stored.
1107
1108
        @param minimum: int, the minimum value to be set
1109
        """
1110
        self.__minimum = int(minimum)
1111
        if self.__minimum > self.value():
1112
            self.setValue(self.__minimum)
1113
1114
    def maximum(self):
1115
        return int(self.__maximum)
1116
1117
    def setMaximum(self, maximum):
1118
        """
1119
        Setter method to set the maximum value allowed in the SpinBox.
1120
        Input will be converted to int before being stored.
1121
1122
        @param maximum: int, the maximum value to be set
1123
        """
1124
        self.__maximum = int(maximum)
1125
        if self.__maximum < self.value():
1126
            self.setValue(self.__maximum)
1127
1128
    def setRange(self, minimum, maximum):
1129
        """
1130
        Convenience method for compliance with Qt SpinBoxes.
1131
        Essentially a wrapper to call both self.setMinimum and self.setMaximum.
1132
1133
        @param minimum: int, the minimum value to be set
1134
        @param maximum: int, the maximum value to be set
1135
        """
1136
        self.setMinimum(minimum)
1137
        self.setMaximum(maximum)
1138
1139
    def prefix(self):
1140
        return self.__prefix
1141
1142
    def setPrefix(self, prefix):
1143
        """
1144
        Set a string to be shown as non-editable prefix in the spinbox.
1145
1146
        @param prefix: str, the prefix string to be set
1147
        """
1148
        self.__prefix = str(prefix)
1149
        self.update_display()
1150
1151
    def suffix(self):
1152
        return self.__suffix
1153
1154
    def setSuffix(self, suffix):
1155
        """
1156
        Set a string to be shown as non-editable suffix in the spinbox.
1157
        This suffix will come right after the si-prefix.
1158
1159
        @param suffix: str, the suffix string to be set
1160
        """
1161
        self.__suffix = str(suffix)
1162
        self.update_display()
1163
1164
    def singleStep(self):
1165
        return int(self.__singleStep)
1166
1167
    def setSingleStep(self, step, dynamic_stepping=True):
1168
        """
1169
        Method to set the stepping behaviour of the spinbox (e.g. when moving the mouse wheel).
1170
1171
        When dynamic_stepping=True the spinbox will perform logarithmic steps according to the
1172
        values' current order of magnitude. The step parameter is then ignored.
1173
        Will always increment the second most significant digit by one.
1174
1175
        When dynamic_stepping=False the step parameter specifies an absolute step size. Meaning each
1176
        time a step is performed this value is added/substracted from the current value.
1177
1178
        @param step: int, the absolute step size to set
1179
        @param dynamic_stepping: bool, flag indicating the use of dynamic stepping (True) or
1180
                                       constant stepping (False)
1181
        """
1182
        if step < 1:
1183
            step = 1
1184
        self.__singleStep = int(step)
1185
        self.dynamic_stepping = dynamic_stepping
1186
1187
    def minimalStep(self):
1188
        return int(self.__minimalStep)
1189
1190
    def setMinimalStep(self, step):
1191
        """
1192
        Method used to set a minimal step size.
1193
        When the absolute step size has been calculated in either dynamic or constant step mode
1194
        this value is checked against the minimal step size. If it is smaller then the minimal step
1195
        size is chosen over the calculated step size. This ensures that no step taken can be
1196
        smaller than minimalStep.
1197
        Minimal step size can't be smaller than 1 for integer.
1198
1199
        @param step: int, the minimal step size to be set
1200
        """
1201
        if step < 1:
1202
            step = 1
1203
        self.__minimalStep = int(step)
1204
1205
    def cleanText(self):
1206
        """
1207
        Compliance method from Qt SpinBoxes.
1208
        Returns the currently shown text from the QLineEdit without prefix and suffix and stripped
1209
        from leading or trailing whitespaces.
1210
1211
        @return: str, currently shown text stripped from suffix and prefix
1212
        """
1213
        text = self.text().strip()
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named text.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1214
        if self.__prefix and text.startswith(self.__prefix):
1215
            text = text[len(self.__prefix):]
1216
        if self.__suffix and text.endswith(self.__suffix):
1217
            text = text[:-len(self.__suffix)]
1218
        return text.strip()
1219
1220
    def update_display(self):
1221
        """
1222
        This helper method updates the shown text based on the current value.
1223
        Because this method is only called upon finishing an editing procedure, the eventually
1224
        cached value gets deleted.
1225
        """
1226
        text = self.textFromValue(self.value())
1227
        text = self.__prefix + text + self.__suffix
1228
        self.lineEdit().setText(text)
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1229
        self.__cached_value = None  # clear cached value
1230
        self.lineEdit().setCursorPosition(0)  # Display the most significant part of the number
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1231
1232
    def keyPressEvent(self, event):
1233
        """
1234
        This method catches all keyboard press events triggered by the user. Can be used to alter
1235
        the behaviour of certain key events from the default implementation of QAbstractSpinBox.
1236
1237
        @param event: QKeyEvent, a Qt QKeyEvent instance holding the event information
1238
        """
1239
        # Restore cached value upon pressing escape and lose focus.
1240
        if event.key() == QtCore.Qt.Key_Escape:
1241
            if self.__cached_value is not None:
1242
                self.__value = self.__cached_value
1243
                self.valueChanged.emit(self.value())
1244
            self.clearFocus()  # This will also trigger editingFinished
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named clearFocus.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1245
1246
        # Update display upon pressing enter/return before processing the event in the default way.
1247
        if event.key() == QtCore.Qt.Key_Enter or event.key() == QtCore.Qt.Key_Return:
1248
            self.update_display()
1249
1250
        if (QtCore.Qt.ControlModifier | QtCore.Qt.MetaModifier) & event.modifiers():
1251
            super().keyPressEvent(event)
1252
            return
1253
1254
        # The rest is to avoid editing suffix and prefix
1255
        if len(event.text()) > 0:
1256
            # Allow editing of the number or SI-prefix even if part of the prefix/suffix is selected.
1257
            if self.lineEdit().selectedText():
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1258
                sel_start = self.lineEdit().selectionStart()
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1259
                sel_end = sel_start + len(self.lineEdit().selectedText())
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1260
                min_start = len(self.__prefix)
1261
                max_end = len(self.__prefix) + len(self.cleanText())
1262
                if sel_start < min_start:
1263
                    sel_start = min_start
1264
                if sel_end > max_end:
1265
                    sel_end = max_end
1266
                self.lineEdit().setSelection(sel_start, sel_end - sel_start)
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1267
            else:
1268
                cursor_pos = self.lineEdit().cursorPosition()
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1269
                begin = len(self.__prefix)
1270
                end = len(self.text()) - len(self.__suffix)
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named text.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1271
                if cursor_pos < begin:
1272
                    self.lineEdit().setCursorPosition(begin)
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1273
                    return
1274
                elif cursor_pos > end:
1275
                    self.lineEdit().setCursorPosition(end)
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1276
                    return
1277
1278
        if event.key() == QtCore.Qt.Key_Left:
1279
            if self.lineEdit().cursorPosition() == len(self.__prefix):
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1280
                return
1281
        if event.key() == QtCore.Qt.Key_Right:
1282
            if self.lineEdit().cursorPosition() == len(self.text()) - len(self.__suffix):
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
The Instance of ScienSpinBox does not seem to have a member named text.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1283
                return
1284
        if event.key() == QtCore.Qt.Key_Home:
1285
            self.lineEdit().setCursorPosition(len(self.__prefix))
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1286
            return
1287
        if event.key() == QtCore.Qt.Key_End:
1288
            self.lineEdit().setCursorPosition(len(self.text()) - len(self.__suffix))
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
The Instance of ScienSpinBox does not seem to have a member named text.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1289
            return
1290
1291
        super().keyPressEvent(event)
1292
1293
    def focusInEvent(self, event):
1294
        super().focusInEvent(event)
1295
        self.selectAll()
1296
        return
1297
1298
    def focusOutEvent(self, event):
1299
        self.update_display()
1300
        super().focusOutEvent(event)
1301
        return
1302
1303
    def validate(self, text, position):
1304
        """
1305
        Access method to the validator. See IntegerValidator class for more information.
1306
1307
        @param text: str, string to be validated.
1308
        @param position: int, current text cursor position
1309
        @return: (enum QValidator::State) the returned validator state,
1310
                 (str) the input string, (int) the cursor position
1311
        """
1312
        begin = len(self.__prefix)
1313
        end = len(text) - len(self.__suffix)
1314
        if position < begin:
1315
            position = begin
1316
        elif position > end:
1317
            position = end
1318
1319
        if self.__prefix and text.startswith(self.__prefix):
1320
            text = text[len(self.__prefix):]
1321
        if self.__suffix and text.endswith(self.__suffix):
1322
            text = text[:-len(self.__suffix)]
1323
1324
        state, string, position = self.validator.validate(text, position)
1325
1326
        text = self.__prefix + string + self.__suffix
1327
1328
        end = len(text) - len(self.__suffix)
1329
        if position > end:
1330
            position = end
1331
1332
        return state, text, position
1333
1334
    def fixup(self, text):
1335
        """
1336
        Takes an invalid string and tries to fix it in order to pass validation.
1337
        The returned string is not guaranteed to pass validation.
1338
1339
        @param text: str, a string that has not passed validation in need to be fixed.
1340
        @return: str, the resulting string from the fix attempt
1341
        """
1342
        return self.validator.fixup(text)
1343
1344
    def valueFromText(self, text):
1345
        """
1346
        This method is responsible for converting a string displayed in the SpinBox into an int
1347
        value.
1348
        The input string is already stripped of prefix and suffix.
1349
        Just the si-prefix may be present.
1350
1351
        @param text: str, the display string to be converted into a numeric value.
1352
                          This string must be conform with the validator.
1353
        @return: int, the numeric value converted from the input string.
1354
        """
1355
        group_dict = self.validator.get_group_dict(text)
1356
        if not group_dict:
1357
            return False
1358
1359
        if not group_dict['mantissa']:
1360
            return False
1361
1362
        si_prefix = group_dict['si']
1363
        if si_prefix is None:
1364
            si_prefix = ''
1365
        si_scale = self._unit_prefix_dict[si_prefix.replace('u', 'µ')]
1366
1367
        unscaled_value = int(group_dict['mantissa'])
1368
        if group_dict['exponent'] is not None:
1369
            scale_factor = 10 ** int(group_dict['exponent'].replace('e', '').replace('E', ''))
1370
            unscaled_value = unscaled_value * scale_factor
1371
1372
        value = unscaled_value * si_scale
1373
        return value
1374
1375
    def textFromValue(self, value):
0 ignored issues
show
This method could be written as a function/class method.

If a method does not access any attributes of the class, it could also be implemented as a function or static method. This can help improve readability. For example

class Foo:
    def some_method(self, x, y):
        return x + y;

could be written as

class Foo:
    @classmethod
    def some_method(cls, x, y):
        return x + y;
Loading history...
1376
        """
1377
        This method is responsible for the mapping of the underlying value to a string to display
1378
        in the SpinBox.
1379
        Suffix and Prefix must not be handled here, just the si-Prefix.
1380
1381
        @param value: int, the numeric value to be formatted into a string
1382
        @return: str, the formatted string representing the input value
1383
        """
1384
        # Convert the integer value to a string
1385
        sign = '-' if value < 0 else ''
1386
        value_str = str(abs(value))
1387
1388
        # find out the index of the least significant non-zero digit
1389
        for digit_index in range(len(value_str)):
1390
            if value_str[digit_index:].count('0') == len(value_str) - digit_index:
1391
                break
1392
1393
        # get the engineering notation exponent (multiple of 3)
1394
        missing_zeros = (len(value_str) - digit_index) % 3
0 ignored issues
show
The loop variable digit_index might not be defined here.
Loading history...
1395
        exponent = len(value_str) - digit_index - missing_zeros
0 ignored issues
show
The loop variable digit_index might not be defined here.
Loading history...
1396
1397
        # the scaled integer string that is still missing the order of magnitude (si-prefix or e)
1398
        integer_str = value_str[:digit_index + missing_zeros]
0 ignored issues
show
The loop variable digit_index might not be defined here.
Loading history...
1399
1400
        # Add si-prefix or, if the exponent is too big, add e-notation
1401
        if 2 < exponent <= 24:
1402
            si_prefix = ' ' + 'kMGTPEZY'[exponent // 3 - 1]
1403
        elif exponent > 24:
1404
            si_prefix = 'e{0:d}'.format(exponent)
1405
        else:
1406
            si_prefix = ''
1407
1408
        # Assemble the string and return it
1409
        return sign + integer_str + si_prefix
1410
1411
    def stepEnabled(self):
1412
        """
1413
        Enables stepping (mouse wheel, arrow up/down, clicking, PgUp/Down) by default.
1414
        """
1415
        return self.StepUpEnabled | self.StepDownEnabled
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named StepUpEnabled.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
The Instance of ScienSpinBox does not seem to have a member named StepDownEnabled.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1416
1417
    def stepBy(self, steps):
1418
        """
1419
        This method is incrementing the value of the SpinBox when the user triggers a step
1420
        (by pressing PgUp/PgDown/Up/Down, MouseWheel movement or clicking on the arrows).
1421
        It should handle the case when the new to-set value is out of bounds.
1422
        Also the absolute value of a single step increment should be handled here.
1423
        It is absolutely necessary to avoid accumulating rounding errors and/or discrepancy between
1424
        self.value and the displayed text.
1425
1426
        @param steps: int, Number of steps to increment (NOT the absolute step size)
1427
        """
1428
        steps = int(steps)
1429
        value = self.__value  # working copy of current value
1430
        sign = -1 if steps < 0 else 1  # determine sign of step
1431
        if self.dynamic_stepping:
1432
            for i in range(abs(steps)):
0 ignored issues
show
The variable i seems to be unused.
Loading history...
1433
                if value == 0:
1434
                    step = max(1, self.__minimalStep)
1435
                else:
1436
                    integer_str = str(abs(value))
1437
                    if len(integer_str) > 1:
1438
                        step = 10 ** (len(integer_str) - 2)
1439
                        # Handle the transition to lower order of magnitude
1440
                        if integer_str.startswith('10') and (sign * value) < 0:
1441
                            step = step // 10
1442
                    else:
1443
                        step = 1
1444
1445
                    step = max(step, self.__minimalStep)
1446
1447
                value += sign * step
1448
        else:
1449
            value = value + max(self.__minimalStep * steps, self.__singleStep * steps)
1450
1451
        self.setValue(value)
1452
        return
1453
1454
    def selectAll(self):
1455
        begin = len(self.__prefix)
1456
        text = self.cleanText()
1457
        if text.endswith(' '):
1458
            selection_length = len(text) + 1
1459
        elif len(text) > 0 and text[-1] in self._unit_prefix_dict:
1460
            selection_length = len(text) - 1
1461
        else:
1462
            selection_length = len(text)
1463
        self.lineEdit().setSelection(begin, selection_length)
0 ignored issues
show
The Instance of ScienSpinBox does not seem to have a member named lineEdit.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
1464