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
|
|||
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
|
|||
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
|
|||
71 | if string.count('.') > 1: |
||
72 | return self.Invalid, group_dict['match'], position |
||
0 ignored issues
–
show
|
|||
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
|
|||
77 | return self.Invalid, group_dict['match'], position |
||
0 ignored issues
–
show
|
|||
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
|
|||
81 | return self.Invalid, '', position |
||
0 ignored issues
–
show
|
|||
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
|
|||
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
|
|||
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
|
|||
152 | |||
153 | return self.Invalid, group_dict['match'], position |
||
0 ignored issues
–
show
|
|||
154 | else: |
||
155 | return self.Invalid, '', position |
||
0 ignored issues
–
show
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
633 | sel_start = self.lineEdit().selectionStart() |
||
0 ignored issues
–
show
|
|||
634 | sel_end = sel_start + len(self.lineEdit().selectedText()) |
||
0 ignored issues
–
show
|
|||
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
|
|||
642 | else: |
||
643 | cursor_pos = self.lineEdit().cursorPosition() |
||
0 ignored issues
–
show
|
|||
644 | begin = len(self.__prefix) |
||
645 | end = len(self.text()) - len(self.__suffix) |
||
0 ignored issues
–
show
|
|||
646 | if cursor_pos < begin: |
||
647 | self.lineEdit().setCursorPosition(begin) |
||
0 ignored issues
–
show
|
|||
648 | elif cursor_pos > end: |
||
649 | self.lineEdit().setCursorPosition(end) |
||
0 ignored issues
–
show
|
|||
650 | |||
651 | if event.key() == QtCore.Qt.Key_Left: |
||
652 | if self.lineEdit().cursorPosition() == len(self.__prefix): |
||
0 ignored issues
–
show
|
|||
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
|
|||
656 | return |
||
657 | if event.key() == QtCore.Qt.Key_Home: |
||
658 | self.lineEdit().setCursorPosition(len(self.__prefix)) |
||
0 ignored issues
–
show
|
|||
659 | return |
||
660 | if event.key() == QtCore.Qt.Key_End: |
||
661 | self.lineEdit().setCursorPosition(len(self.text()) - len(self.__suffix)) |
||
0 ignored issues
–
show
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
1258 | sel_start = self.lineEdit().selectionStart() |
||
0 ignored issues
–
show
|
|||
1259 | sel_end = sel_start + len(self.lineEdit().selectedText()) |
||
0 ignored issues
–
show
|
|||
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
|
|||
1267 | else: |
||
1268 | cursor_pos = self.lineEdit().cursorPosition() |
||
0 ignored issues
–
show
|
|||
1269 | begin = len(self.__prefix) |
||
1270 | end = len(self.text()) - len(self.__suffix) |
||
0 ignored issues
–
show
|
|||
1271 | if cursor_pos < begin: |
||
1272 | self.lineEdit().setCursorPosition(begin) |
||
0 ignored issues
–
show
|
|||
1273 | return |
||
1274 | elif cursor_pos > end: |
||
1275 | self.lineEdit().setCursorPosition(end) |
||
0 ignored issues
–
show
|
|||
1276 | return |
||
1277 | |||
1278 | if event.key() == QtCore.Qt.Key_Left: |
||
1279 | if self.lineEdit().cursorPosition() == len(self.__prefix): |
||
0 ignored issues
–
show
|
|||
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
|
|||
1283 | return |
||
1284 | if event.key() == QtCore.Qt.Key_Home: |
||
1285 | self.lineEdit().setCursorPosition(len(self.__prefix)) |
||
0 ignored issues
–
show
|
|||
1286 | return |
||
1287 | if event.key() == QtCore.Qt.Key_End: |
||
1288 | self.lineEdit().setCursorPosition(len(self.text()) - len(self.__suffix)) |
||
0 ignored issues
–
show
|
|||
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;
![]() |
|||
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
|
|||
1395 | exponent = len(value_str) - digit_index - missing_zeros |
||
0 ignored issues
–
show
|
|||
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
|
|||
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
|
|||
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
|
|||
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
|
|||
1464 |
This check looks for calls to members that are non-existent. These calls will fail.
The member could have been renamed or removed.