sciwidgets 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sciwidgets/__init__.py +8 -0
- sciwidgets/dialogs/__init__.py +31 -0
- sciwidgets/dialogs/__main__.py +128 -0
- sciwidgets/dialogs/message_dialogs.py +553 -0
- sciwidgets/formatting.py +161 -0
- sciwidgets/labels/__init__.py +3 -0
- sciwidgets/labels/__main__.py +124 -0
- sciwidgets/labels/math_label.py +505 -0
- sciwidgets/params/__init__.py +18 -0
- sciwidgets/params/__main__.py +81 -0
- sciwidgets/params/base.py +220 -0
- sciwidgets/params/combo_box.py +106 -0
- sciwidgets/params/line_edit.py +132 -0
- sciwidgets/params/numeric_line_edit.py +680 -0
- sciwidgets/params/sliders/__init__.py +36 -0
- sciwidgets/params/sliders/__main__.py +215 -0
- sciwidgets/params/sliders/_defaults.py +36 -0
- sciwidgets/params/sliders/_dialogs.py +174 -0
- sciwidgets/params/sliders/_right_click_overlay.py +115 -0
- sciwidgets/params/sliders/base.py +592 -0
- sciwidgets/params/sliders/multi.py +660 -0
- sciwidgets/params/sliders/range.py +1841 -0
- sciwidgets/params/sliders/single.py +2042 -0
- sciwidgets/params/toggle.py +116 -0
- sciwidgets/style.py +175 -0
- sciwidgets-0.1.2.dist-info/METADATA +173 -0
- sciwidgets-0.1.2.dist-info/RECORD +30 -0
- sciwidgets-0.1.2.dist-info/WHEEL +5 -0
- sciwidgets-0.1.2.dist-info/licenses/LICENSE +21 -0
- sciwidgets-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"""Validated numeric line-edit widgets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import numbers
|
|
7
|
+
from typing import Optional, Callable
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import Qt, Signal, QEvent, QTimer, QRegularExpression
|
|
10
|
+
from PySide6.QtGui import QMouseEvent, QValidator
|
|
11
|
+
from PySide6.QtWidgets import QApplication, QLineEdit, QWidget
|
|
12
|
+
|
|
13
|
+
from ..dialogs import show_warning
|
|
14
|
+
from ..formatting import pretty_sci_text
|
|
15
|
+
|
|
16
|
+
Num = int | float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SciNumberValidator(QValidator):
|
|
20
|
+
"""Validator for integer and floating-point numeric input.
|
|
21
|
+
|
|
22
|
+
The validator is permissive while the user is typing. Incomplete but still
|
|
23
|
+
plausible input is reported as intermediate instead of invalid so editing
|
|
24
|
+
is not blocked prematurely. For floating-point values, scientific notation
|
|
25
|
+
is supported and non-finite values such as ``inf`` and ``nan`` are rejected.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
_value_is_int: Whether the validator accepts integer values only.
|
|
29
|
+
_text_to_format_value: Callable used to parse and normalize text input.
|
|
30
|
+
_min_limit: Minimum allowed value after normalization.
|
|
31
|
+
_max_limit: Maximum allowed value after normalization.
|
|
32
|
+
_min_limit_inclusive: Whether the minimum bound is inclusive.
|
|
33
|
+
_max_limit_inclusive: Whether the maximum bound is inclusive.
|
|
34
|
+
_rx: Regular expression used for preliminary syntax validation.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
value_is_int: bool,
|
|
40
|
+
text_to_format_value: Callable[[str], float],
|
|
41
|
+
min_limit: int | float | None = None,
|
|
42
|
+
max_limit: int | float | None = None,
|
|
43
|
+
min_limit_inclusive: bool = True,
|
|
44
|
+
max_limit_inclusive: bool = True,
|
|
45
|
+
parent=None,
|
|
46
|
+
):
|
|
47
|
+
"""Initialize the numeric validator.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
value_is_int: If ``True``, accept integer-style input only. If
|
|
51
|
+
``False``, accept floating-point input including scientific
|
|
52
|
+
notation.
|
|
53
|
+
text_to_format_value: Callable that parses text and returns the
|
|
54
|
+
normalized numeric value used for range checking.
|
|
55
|
+
min_limit: Optional lower bound for valid values.
|
|
56
|
+
max_limit: Optional upper bound for valid values.
|
|
57
|
+
min_limit_inclusive: Whether the lower bound is inclusive.
|
|
58
|
+
max_limit_inclusive: Whether the upper bound is inclusive.
|
|
59
|
+
parent: Optional Qt parent object.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If both bounds are provided and ``min_limit`` is
|
|
63
|
+
greater than ``max_limit``.
|
|
64
|
+
"""
|
|
65
|
+
super().__init__(parent)
|
|
66
|
+
|
|
67
|
+
if min_limit is not None and max_limit is not None and min_limit > max_limit:
|
|
68
|
+
raise ValueError("min_limit must be <= max_limit")
|
|
69
|
+
|
|
70
|
+
self._value_is_int = value_is_int
|
|
71
|
+
self._text_to_format_value = text_to_format_value
|
|
72
|
+
self._min_limit = min_limit
|
|
73
|
+
self._max_limit = max_limit
|
|
74
|
+
self._min_limit_inclusive = min_limit_inclusive
|
|
75
|
+
self._max_limit_inclusive = max_limit_inclusive
|
|
76
|
+
|
|
77
|
+
if value_is_int:
|
|
78
|
+
self._rx = QRegularExpression(r"^[+\-]?\d*$")
|
|
79
|
+
else:
|
|
80
|
+
self._rx = QRegularExpression(r"^[+\-]?((\d+(\.\d*)?)|(\.\d*)|(\d*))([eE][+\-]?\d*)?$")
|
|
81
|
+
|
|
82
|
+
def validate(self, s: str, pos: int):
|
|
83
|
+
"""Validate the current text during editing.
|
|
84
|
+
|
|
85
|
+
Empty strings and incomplete-but-plausible numeric input are treated as
|
|
86
|
+
intermediate states so users can continue typing without immediate
|
|
87
|
+
rejection.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
s: Current input string being validated.
|
|
91
|
+
pos: Cursor position supplied by Qt.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
tuple[QValidator.State, str, int]: Qt validator state together with
|
|
95
|
+
the unchanged input string and cursor position.
|
|
96
|
+
"""
|
|
97
|
+
t = s.strip()
|
|
98
|
+
|
|
99
|
+
if t == "":
|
|
100
|
+
return (QValidator.Intermediate, s, pos)
|
|
101
|
+
|
|
102
|
+
if not self._rx.match(t).hasMatch():
|
|
103
|
+
return (QValidator.Invalid, s, pos)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
v = self._text_to_format_value(t)
|
|
107
|
+
except ValueError:
|
|
108
|
+
return (QValidator.Intermediate, s, pos)
|
|
109
|
+
|
|
110
|
+
if not self._value_is_int and not math.isfinite(v):
|
|
111
|
+
return (QValidator.Invalid, s, pos)
|
|
112
|
+
|
|
113
|
+
# Range -> Intermediate (do not block typing)
|
|
114
|
+
if self._min_limit is not None:
|
|
115
|
+
if self._min_limit_inclusive:
|
|
116
|
+
if v < self._min_limit:
|
|
117
|
+
return (QValidator.Intermediate, s, pos)
|
|
118
|
+
else:
|
|
119
|
+
if v <= self._min_limit:
|
|
120
|
+
return (QValidator.Intermediate, s, pos)
|
|
121
|
+
|
|
122
|
+
if self._max_limit is not None:
|
|
123
|
+
if self._max_limit_inclusive:
|
|
124
|
+
if v > self._max_limit:
|
|
125
|
+
return (QValidator.Intermediate, s, pos)
|
|
126
|
+
else:
|
|
127
|
+
if v >= self._max_limit:
|
|
128
|
+
return (QValidator.Intermediate, s, pos)
|
|
129
|
+
|
|
130
|
+
return (QValidator.Acceptable, s, pos)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class NumericLineEdit(QLineEdit):
|
|
134
|
+
"""Line edit for validated numeric entry with commit gating.
|
|
135
|
+
|
|
136
|
+
This widget combines a permissive validator for in-progress typing with
|
|
137
|
+
stricter checks when the user attempts to commit or leave the field. It
|
|
138
|
+
stores the last committed value, highlights invalid state, and prevents
|
|
139
|
+
focus changes until invalid input is resolved.
|
|
140
|
+
|
|
141
|
+
Signals:
|
|
142
|
+
valueChanged: Emitted with the new committed numeric value.
|
|
143
|
+
|
|
144
|
+
Attributes:
|
|
145
|
+
_value_is_int: Whether the widget stores integer values only.
|
|
146
|
+
_val_fmt: Format string used to display committed values.
|
|
147
|
+
_parse: Conversion callable used to parse values.
|
|
148
|
+
_min_limit: Optional normalized minimum bound.
|
|
149
|
+
_max_limit: Optional normalized maximum bound.
|
|
150
|
+
_min_limit_inclusive: Whether the lower bound is inclusive.
|
|
151
|
+
_max_limit_inclusive: Whether the upper bound is inclusive.
|
|
152
|
+
_filter_installed: Whether the global event filter is currently active.
|
|
153
|
+
_invalid_property: Cached invalid-state flag mirrored to a Qt property.
|
|
154
|
+
_in_refocus: Whether a refocus cycle is currently in progress.
|
|
155
|
+
_skip_next_focus_out_check: Whether the next focus-out validation should
|
|
156
|
+
be skipped.
|
|
157
|
+
_last_value: Last successfully committed numeric value.
|
|
158
|
+
|
|
159
|
+
Note:
|
|
160
|
+
The red frame shown for invalid input is drawn by
|
|
161
|
+
:class:`sciwidgets.style.SciStyle`; without that style the widget
|
|
162
|
+
still validates, but invalid input is not highlighted.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
valueChanged = Signal(object)
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
init_val: Num,
|
|
170
|
+
value_is_int: bool = False,
|
|
171
|
+
val_fmt: str = "{:0.6g}",
|
|
172
|
+
min_limit: Num | None = None,
|
|
173
|
+
max_limit: Num | None = None,
|
|
174
|
+
min_limit_inclusive: bool = True,
|
|
175
|
+
max_limit_inclusive: bool = True,
|
|
176
|
+
place_holder_text: str = "Enter a float",
|
|
177
|
+
max_length: Optional[int] = None,
|
|
178
|
+
width_chars: Optional[int] = None,
|
|
179
|
+
parent: Optional[QWidget] = None
|
|
180
|
+
):
|
|
181
|
+
"""Initialize the numeric line edit.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
init_val: Initial committed value shown in the widget.
|
|
185
|
+
value_is_int: If ``True``, restrict input to integer values.
|
|
186
|
+
val_fmt: Format string used to render committed values.
|
|
187
|
+
min_limit: Optional lower bound for accepted values.
|
|
188
|
+
max_limit: Optional upper bound for accepted values.
|
|
189
|
+
min_limit_inclusive: Whether the lower bound is inclusive.
|
|
190
|
+
max_limit_inclusive: Whether the upper bound is inclusive.
|
|
191
|
+
place_holder_text: Placeholder text shown when the field is empty.
|
|
192
|
+
max_length: Optional maximum character count for the line edit.
|
|
193
|
+
width_chars: Optional fixed width expressed as a number of ``0``
|
|
194
|
+
characters in the current font.
|
|
195
|
+
parent: Optional parent widget.
|
|
196
|
+
"""
|
|
197
|
+
super().__init__(parent)
|
|
198
|
+
|
|
199
|
+
self._value_is_int = value_is_int
|
|
200
|
+
self._val_fmt = self._normalize_val_fmt(val_fmt)
|
|
201
|
+
self._parse = int if value_is_int else float
|
|
202
|
+
|
|
203
|
+
# Store bounds
|
|
204
|
+
self._min_limit = min_limit if min_limit is None else self._value_to_formated_value(min_limit)
|
|
205
|
+
self._max_limit = max_limit if max_limit is None else self._value_to_formated_value(max_limit)
|
|
206
|
+
self._min_limit_inclusive = min_limit_inclusive
|
|
207
|
+
self._max_limit_inclusive = max_limit_inclusive
|
|
208
|
+
|
|
209
|
+
# StatusState
|
|
210
|
+
self._filter_installed = False
|
|
211
|
+
self._invalid_property = False
|
|
212
|
+
self._in_refocus = False
|
|
213
|
+
self._skip_next_focus_out_check = False
|
|
214
|
+
self._last_value: Num = init_val
|
|
215
|
+
|
|
216
|
+
self.setValidator(self._make_validator())
|
|
217
|
+
self.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
218
|
+
self.installEventFilter(self)
|
|
219
|
+
self.textEdited.connect(self._on_text_edited)
|
|
220
|
+
self.editingFinished.connect(self._on_editing_finished)
|
|
221
|
+
self.setPlaceholderText(place_holder_text)
|
|
222
|
+
if max_length is not None:
|
|
223
|
+
self.setMaxLength(max_length)
|
|
224
|
+
if width_chars is not None:
|
|
225
|
+
self.set_width_chars(width_chars)
|
|
226
|
+
|
|
227
|
+
# Initialize
|
|
228
|
+
self.set_value(init_val)
|
|
229
|
+
|
|
230
|
+
# ----- subclass hooks -----
|
|
231
|
+
def _make_validator(self) -> QValidator:
|
|
232
|
+
"""Create and configure the validator used by the widget.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
QValidator: Validator instance enforcing syntax and range rules for
|
|
236
|
+
the current configuration.
|
|
237
|
+
"""
|
|
238
|
+
return SciNumberValidator(
|
|
239
|
+
value_is_int=self._value_is_int,
|
|
240
|
+
text_to_format_value=self._text_to_formated_value,
|
|
241
|
+
min_limit=self._min_limit,
|
|
242
|
+
max_limit=self._max_limit,
|
|
243
|
+
min_limit_inclusive=self._min_limit_inclusive,
|
|
244
|
+
max_limit_inclusive=self._max_limit_inclusive,
|
|
245
|
+
parent=self,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _text_to_value(self, text: str) -> Num:
|
|
249
|
+
"""Parse raw text into a numeric value.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
text: Input text to parse.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Num: Parsed numeric value.
|
|
256
|
+
"""
|
|
257
|
+
return self._parse(text.strip())
|
|
258
|
+
|
|
259
|
+
def _value_to_text(self, value: Num) -> str:
|
|
260
|
+
"""Format a numeric value for display.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
value: Numeric value to format.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
str: Formatted text representation.
|
|
267
|
+
"""
|
|
268
|
+
return str(self._val_fmt.format(self._parse(value)))
|
|
269
|
+
|
|
270
|
+
def _value_to_formated_value(self, value: Num) -> Num:
|
|
271
|
+
"""Normalize a numeric value through the display format.
|
|
272
|
+
|
|
273
|
+
This applies the current formatting and then parses the formatted result
|
|
274
|
+
back into a numeric value.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
value: Numeric value to normalize.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Num: Value after formatting and reparsing.
|
|
281
|
+
"""
|
|
282
|
+
return self._text_to_value(self._value_to_text(value))
|
|
283
|
+
|
|
284
|
+
def _text_to_formated_value(self, text: str) -> Num:
|
|
285
|
+
"""Normalize text input through parsing and formatting.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
text: Input text to normalize.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Num: Parsed value after applying the current formatting rules.
|
|
292
|
+
"""
|
|
293
|
+
v = self._text_to_value(text)
|
|
294
|
+
return self._text_to_value(self._value_to_text(v))
|
|
295
|
+
|
|
296
|
+
def _normalize_val_fmt(self, val_fmt: str) -> str:
|
|
297
|
+
"""Normalize the format string used for display.
|
|
298
|
+
|
|
299
|
+
Integer widgets always use ``"{:d}"``. Floating-point widgets fall back
|
|
300
|
+
to ``"{:0.6g}"`` when the provided format string cannot be applied or
|
|
301
|
+
does not produce a parseable numeric result.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
val_fmt: Candidate format string.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
str: Sanitized format string suitable for display.
|
|
308
|
+
"""
|
|
309
|
+
if self._value_is_int:
|
|
310
|
+
return "{:d}"
|
|
311
|
+
try:
|
|
312
|
+
text = val_fmt.format(12345.12345)
|
|
313
|
+
except Exception:
|
|
314
|
+
return "{:0.6g}"
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
float(text.replace(",", "").strip())
|
|
318
|
+
except ValueError:
|
|
319
|
+
return "{:0.6g}"
|
|
320
|
+
|
|
321
|
+
return val_fmt.replace(",", "")
|
|
322
|
+
|
|
323
|
+
def _validate_value(self, value: Num) -> Num:
|
|
324
|
+
"""Validate and normalize a programmatically supplied value.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
value: Value to validate. Python and numpy numbers are accepted.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Num: Value rounded to the display format.
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
TypeError: If the value is not a real number, or not an integer on
|
|
334
|
+
an integer line edit. ``bool`` is rejected.
|
|
335
|
+
ValueError: If the rounded value is outside the configured bounds.
|
|
336
|
+
"""
|
|
337
|
+
if isinstance(value, bool) or not isinstance(value, numbers.Real):
|
|
338
|
+
raise TypeError(f"value must be a number, not {type(value).__name__}")
|
|
339
|
+
if self._value_is_int and not isinstance(value, numbers.Integral):
|
|
340
|
+
raise TypeError(f"value must be an int, not {type(value).__name__}")
|
|
341
|
+
|
|
342
|
+
validated_value = self._value_to_formated_value(value)
|
|
343
|
+
if not self._in_range(validated_value):
|
|
344
|
+
raise ValueError(self._invalid_input_text_error("New value"))
|
|
345
|
+
return validated_value
|
|
346
|
+
|
|
347
|
+
def _apply_value(self, value: Num) -> None:
|
|
348
|
+
"""Apply a validated value to widget state and display.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
value: Previously validated numeric value to store and display.
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
ValueError: If the value is outside the limits (checked by the
|
|
355
|
+
line edit before anything changes).
|
|
356
|
+
"""
|
|
357
|
+
self._last_value = value
|
|
358
|
+
text = self._value_to_text(value)
|
|
359
|
+
self._set_invalid_property(False)
|
|
360
|
+
self._set_text_safely(text)
|
|
361
|
+
|
|
362
|
+
# ----- range -----
|
|
363
|
+
def _in_range(self, v: Num) -> bool:
|
|
364
|
+
"""Check whether a value satisfies the configured bounds.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
v: Value to test.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
bool: ``True`` if the value is inside the allowed interval.
|
|
371
|
+
"""
|
|
372
|
+
if self._min_limit is not None:
|
|
373
|
+
if v < self._min_limit:
|
|
374
|
+
return False
|
|
375
|
+
elif v == self._min_limit:
|
|
376
|
+
return self._min_limit_inclusive
|
|
377
|
+
|
|
378
|
+
if self._max_limit is not None:
|
|
379
|
+
if v > self._max_limit:
|
|
380
|
+
return False
|
|
381
|
+
elif v == self._max_limit:
|
|
382
|
+
return self._max_limit_inclusive
|
|
383
|
+
|
|
384
|
+
return True
|
|
385
|
+
|
|
386
|
+
def _invalid_input_text_error(self, value_text: str) -> str:
|
|
387
|
+
"""Build a concise error message describing valid bounds.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
value_text: Label used to describe the invalid value in the message.
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
str: Human-readable validation error string.
|
|
394
|
+
"""
|
|
395
|
+
if self._min_limit is None and self._max_limit is None:
|
|
396
|
+
return f"{value_text} is invalid."
|
|
397
|
+
|
|
398
|
+
min_text = "-inf" if self._min_limit is None else pretty_sci_text(self._min_limit, sig_digits=3)
|
|
399
|
+
max_text = "+inf" if self._max_limit is None else pretty_sci_text(self._max_limit, sig_digits=3)
|
|
400
|
+
|
|
401
|
+
left = "[" if self._min_limit_inclusive else "("
|
|
402
|
+
right = "]" if self._max_limit_inclusive else ")"
|
|
403
|
+
return f"{value_text} must be in {left}{min_text}, {max_text}{right}"
|
|
404
|
+
|
|
405
|
+
def _invalid_input_msg_error(self) -> str:
|
|
406
|
+
"""Build a detailed message for the invalid-input warning dialog.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
str: Multi-line validation message appropriate for user display.
|
|
410
|
+
"""
|
|
411
|
+
text = self.text().strip()
|
|
412
|
+
|
|
413
|
+
if text == "":
|
|
414
|
+
return "Please enter a value."
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
formated_v = self._text_to_formated_value(text)
|
|
418
|
+
except ValueError:
|
|
419
|
+
return (f"Input text: '{text}'\n"
|
|
420
|
+
f"Please enter a valid number.")
|
|
421
|
+
|
|
422
|
+
if not self._in_range(formated_v):
|
|
423
|
+
return (f"Input: '{text}'\n"
|
|
424
|
+
f"Format: {self._val_fmt}\n"
|
|
425
|
+
f"Value after apply format: {formated_v}\n"
|
|
426
|
+
f"{self._invalid_input_text_error('Input value')}")
|
|
427
|
+
|
|
428
|
+
return "Please enter a valid value."
|
|
429
|
+
|
|
430
|
+
# ----- validation helpers -----
|
|
431
|
+
def _validation_state(self, text: str) -> QValidator.State:
|
|
432
|
+
"""Return the validator state for the provided text.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
text: Text to validate.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
QValidator.State: Validation state reported by the current validator.
|
|
439
|
+
"""
|
|
440
|
+
v = self.validator()
|
|
441
|
+
if v is None:
|
|
442
|
+
return QValidator.Acceptable
|
|
443
|
+
state, _, _ = v.validate(text, 0)
|
|
444
|
+
return state
|
|
445
|
+
|
|
446
|
+
def _can_commit(self) -> bool:
|
|
447
|
+
"""Return whether the current widget text can be committed.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
bool: ``True`` if the current text is fully acceptable.
|
|
451
|
+
"""
|
|
452
|
+
return self._validation_state(self.text()) == QValidator.Acceptable
|
|
453
|
+
|
|
454
|
+
# ----- UI state -----
|
|
455
|
+
def _set_invalid_property(self, status: bool) -> None:
|
|
456
|
+
"""Update the Qt invalid-state property.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
status: Desired invalid-state flag.
|
|
460
|
+
"""
|
|
461
|
+
if self._invalid_property != status:
|
|
462
|
+
self._invalid_property = status
|
|
463
|
+
self.setProperty("invalid", status)
|
|
464
|
+
self.update()
|
|
465
|
+
|
|
466
|
+
def _set_text_safely(self, text: str) -> None:
|
|
467
|
+
"""Set widget text while temporarily blocking signals.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
text: Text to assign to the line edit.
|
|
471
|
+
"""
|
|
472
|
+
was_blocked = self.signalsBlocked()
|
|
473
|
+
self.blockSignals(True)
|
|
474
|
+
try:
|
|
475
|
+
self.setText(text)
|
|
476
|
+
finally:
|
|
477
|
+
self.blockSignals(was_blocked)
|
|
478
|
+
|
|
479
|
+
# ----- slots -----
|
|
480
|
+
def _on_text_edited(self, text: str) -> None:
|
|
481
|
+
"""Update invalid styling while the user edits the text.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
text: Current edited text.
|
|
485
|
+
"""
|
|
486
|
+
self._set_invalid_property(self._validation_state(text) != QValidator.Acceptable)
|
|
487
|
+
|
|
488
|
+
def _on_editing_finished(self) -> None:
|
|
489
|
+
"""Commit the edited value when the current text is acceptable."""
|
|
490
|
+
if not self._can_commit():
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
new_value = self._text_to_formated_value(self.text())
|
|
494
|
+
self._set_invalid_property(False)
|
|
495
|
+
self._set_text_safely(self._value_to_text(new_value))
|
|
496
|
+
if new_value != self._last_value:
|
|
497
|
+
self._last_value = new_value
|
|
498
|
+
self.valueChanged.emit(new_value)
|
|
499
|
+
|
|
500
|
+
# ----- refocus / rejection -----
|
|
501
|
+
def _refocus(self) -> None:
|
|
502
|
+
"""Restore focus to the widget and select all text."""
|
|
503
|
+
self._in_refocus = True
|
|
504
|
+
try:
|
|
505
|
+
self.setFocus(Qt.OtherFocusReason)
|
|
506
|
+
self.selectAll()
|
|
507
|
+
finally:
|
|
508
|
+
self._in_refocus = False
|
|
509
|
+
|
|
510
|
+
def _reject_exit_ui(self) -> None:
|
|
511
|
+
"""Reject an invalid exit attempt and show a warning dialog."""
|
|
512
|
+
self._set_invalid_property(True)
|
|
513
|
+
show_warning("Invalid input", self._invalid_input_msg_error(), parent=self)
|
|
514
|
+
self.setText(self._value_to_text(self._last_value))
|
|
515
|
+
self._set_invalid_property(False)
|
|
516
|
+
QTimer.singleShot(0, self._refocus)
|
|
517
|
+
|
|
518
|
+
# ----- public API -----
|
|
519
|
+
def get_value(self) -> Num:
|
|
520
|
+
"""Return the last committed value.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
Num: Last valid value stored by the widget.
|
|
524
|
+
"""
|
|
525
|
+
return self._last_value
|
|
526
|
+
|
|
527
|
+
def set_value(self, value: Num) -> None:
|
|
528
|
+
"""Validate and assign a new current value.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
value: New value to store and display.
|
|
532
|
+
|
|
533
|
+
Raises:
|
|
534
|
+
TypeError: If the value is not a number (or not an int on an
|
|
535
|
+
integer line edit).
|
|
536
|
+
ValueError: If the value is outside the configured bounds.
|
|
537
|
+
"""
|
|
538
|
+
validated_value = self._validate_value(value)
|
|
539
|
+
self._apply_value(validated_value)
|
|
540
|
+
|
|
541
|
+
def set_width_chars(self, chars: int, pad: int = 8) -> None:
|
|
542
|
+
"""Set a fixed widget width based on character count.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
chars: Number of ``0`` characters to size the field for.
|
|
546
|
+
pad: Extra pixel padding added to the computed text width.
|
|
547
|
+
"""
|
|
548
|
+
fm = self.fontMetrics()
|
|
549
|
+
self.setFixedWidth(fm.horizontalAdvance("0" * chars) + pad)
|
|
550
|
+
|
|
551
|
+
def set_fmt(self, fmt: str) -> None:
|
|
552
|
+
"""Update the display format and re-round the committed value (no signals).
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
fmt: New format string to apply.
|
|
556
|
+
"""
|
|
557
|
+
self._val_fmt = self._normalize_val_fmt(fmt)
|
|
558
|
+
self._last_value = self._value_to_formated_value(self._last_value)
|
|
559
|
+
self._set_invalid_property(False)
|
|
560
|
+
self._set_text_safely(self._value_to_text(self._last_value))
|
|
561
|
+
|
|
562
|
+
# ----- focus/exit gating -----
|
|
563
|
+
def focusInEvent(self, event) -> None:
|
|
564
|
+
"""Handle the Qt focus-in callback.
|
|
565
|
+
|
|
566
|
+
Installs a global event filter while the widget has focus so outside
|
|
567
|
+
mouse clicks can be intercepted and treated as commit or reject events.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
event: Qt focus event.
|
|
571
|
+
"""
|
|
572
|
+
super().focusInEvent(event)
|
|
573
|
+
if not self._filter_installed:
|
|
574
|
+
QApplication.instance().installEventFilter(self)
|
|
575
|
+
self._filter_installed = True
|
|
576
|
+
|
|
577
|
+
def focusOutEvent(self, event) -> None:
|
|
578
|
+
"""Handle the Qt focus-out callback.
|
|
579
|
+
|
|
580
|
+
Invalid text prevents focus loss. Depending on the focus-change reason,
|
|
581
|
+
the event may be allowed, ignored, or converted into a rejection flow.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
event: Qt focus event.
|
|
585
|
+
"""
|
|
586
|
+
if self._skip_next_focus_out_check:
|
|
587
|
+
self._skip_next_focus_out_check = False
|
|
588
|
+
super().focusOutEvent(event)
|
|
589
|
+
if self._filter_installed:
|
|
590
|
+
QApplication.instance().removeEventFilter(self)
|
|
591
|
+
self._filter_installed = False
|
|
592
|
+
return
|
|
593
|
+
|
|
594
|
+
if self._in_refocus:
|
|
595
|
+
super().focusOutEvent(event)
|
|
596
|
+
return
|
|
597
|
+
|
|
598
|
+
if event.reason() in (Qt.PopupFocusReason, Qt.ActiveWindowFocusReason, Qt.MenuBarFocusReason):
|
|
599
|
+
super().focusOutEvent(event)
|
|
600
|
+
if self._filter_installed:
|
|
601
|
+
QApplication.instance().removeEventFilter(self)
|
|
602
|
+
self._filter_installed = False
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
if not self._can_commit():
|
|
606
|
+
event.ignore()
|
|
607
|
+
self._reject_exit_ui()
|
|
608
|
+
return
|
|
609
|
+
|
|
610
|
+
super().focusOutEvent(event)
|
|
611
|
+
if self._filter_installed:
|
|
612
|
+
QApplication.instance().removeEventFilter(self)
|
|
613
|
+
self._filter_installed = False
|
|
614
|
+
|
|
615
|
+
def keyPressEvent(self, event) -> None:
|
|
616
|
+
"""Handle the Qt key-press callback for commit-sensitive keys.
|
|
617
|
+
|
|
618
|
+
Enter, Return, Tab, and Backtab are intercepted so invalid text cannot
|
|
619
|
+
be committed or escaped accidentally.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
event: Qt key event.
|
|
623
|
+
"""
|
|
624
|
+
key = event.key()
|
|
625
|
+
|
|
626
|
+
if key in (Qt.Key_Return, Qt.Key_Enter):
|
|
627
|
+
if not self._can_commit():
|
|
628
|
+
event.accept()
|
|
629
|
+
self._reject_exit_ui()
|
|
630
|
+
return
|
|
631
|
+
super().keyPressEvent(event)
|
|
632
|
+
return
|
|
633
|
+
|
|
634
|
+
if key in (Qt.Key_Tab, Qt.Key_Backtab):
|
|
635
|
+
if not self._can_commit():
|
|
636
|
+
event.accept()
|
|
637
|
+
self._reject_exit_ui()
|
|
638
|
+
return
|
|
639
|
+
self.focusNextPrevChild(key == Qt.Key_Tab)
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
super().keyPressEvent(event)
|
|
643
|
+
|
|
644
|
+
def eventFilter(self, obj, event) -> bool:
|
|
645
|
+
"""Filter application events while the widget has focus.
|
|
646
|
+
|
|
647
|
+
Outside mouse clicks are interpreted as exit attempts. Invalid input is
|
|
648
|
+
rejected and the click is consumed; valid input is allowed to continue.
|
|
649
|
+
|
|
650
|
+
Args:
|
|
651
|
+
obj: Watched Qt object.
|
|
652
|
+
event: Incoming Qt event.
|
|
653
|
+
|
|
654
|
+
Returns:
|
|
655
|
+
bool: ``True`` if the event is consumed by this widget, otherwise
|
|
656
|
+
``False``.
|
|
657
|
+
"""
|
|
658
|
+
if not self.hasFocus() or self._in_refocus:
|
|
659
|
+
return False
|
|
660
|
+
|
|
661
|
+
if event.type() == QEvent.MouseButtonPress and isinstance(event, QMouseEvent):
|
|
662
|
+
gp = event.globalPosition().toPoint()
|
|
663
|
+
w = QApplication.widgetAt(gp)
|
|
664
|
+
if w is None:
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
if w is self or self.isAncestorOf(w):
|
|
668
|
+
return False
|
|
669
|
+
|
|
670
|
+
# Click outside: treat as exit attempt
|
|
671
|
+
if not self._can_commit():
|
|
672
|
+
self._reject_exit_ui()
|
|
673
|
+
return True # consume click
|
|
674
|
+
|
|
675
|
+
self._skip_next_focus_out_check = True
|
|
676
|
+
self.clearFocus()
|
|
677
|
+
return False
|
|
678
|
+
|
|
679
|
+
return False
|
|
680
|
+
|