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.
@@ -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
+