qt-css-engine 0.1.2__tar.gz → 0.1.3__tar.gz

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.
Files changed (36) hide show
  1. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/PKG-INFO +1 -1
  2. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/css_parser.py +8 -2
  3. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/engine.py +102 -19
  4. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/handlers.py +212 -24
  5. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/types.py +7 -0
  6. qt_css_engine-0.1.3/tests/conftest.py +48 -0
  7. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_anim.py +25 -0
  8. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_css_parser.py +26 -0
  9. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_engine_coverage.py +185 -0
  10. qt_css_engine-0.1.2/tests/conftest.py +0 -14
  11. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.github/CODE_OF_CONDUCT.md +0 -0
  12. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.github/CONTRIBUTING.md +0 -0
  13. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.github/workflows/pr-check.yml +0 -0
  14. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.github/workflows/publish.yml +0 -0
  15. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.github/workflows/test-publish.yml +0 -0
  16. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.gitignore +0 -0
  17. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/LICENSE.md +0 -0
  18. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/README.md +0 -0
  19. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/demo/layout_test.py +0 -0
  20. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/demo/main.py +0 -0
  21. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/demo/styles.css +0 -0
  22. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/pyproject.toml +0 -0
  23. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/__init__.py +0 -0
  24. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/constants.py +0 -0
  25. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/gradients.py +0 -0
  26. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/py.typed +0 -0
  27. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/qt_compat/QtCore.py +0 -0
  28. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/qt_compat/QtGui.py +0 -0
  29. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/qt_compat/QtWidgets.py +0 -0
  30. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/qt_compat/__init__.py +0 -0
  31. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/qt_compat/_api.py +0 -0
  32. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/utils.py +0 -0
  33. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_gradients.py +0 -0
  34. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_interactions.py +0 -0
  35. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_layouts.py +0 -0
  36. {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_size_layout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qt-css-engine
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: CSS transition animations for PyQt6/PySide6 — extends static QSS with transition and other properties
5
5
  Project-URL: Homepage, https://github.com/Video-Nomad/qt-css-engine
6
6
  Project-URL: Repository, https://github.com/Video-Nomad/qt-css-engine
@@ -30,10 +30,16 @@ def _classify_border_token(token: str) -> str:
30
30
  return "color"
31
31
 
32
32
 
33
+ def _split_css_components(value: str) -> list[str]:
34
+ """Split a CSS value on top-level component boundaries, preserving function arguments."""
35
+ tokens = tinycss2.parse_component_value_list(value, skip_comments=True)
36
+ return [_serialize_value([tok]) for tok in tokens if tok.type != "whitespace"]
37
+
38
+
33
39
  def _expand_border(value: str) -> dict[str, str]:
34
40
  """Parse `border: <width> <style> <color>` (any token order) into component properties."""
35
41
  result: dict[str, str] = {}
36
- for token in value.split():
42
+ for token in _split_css_components(value):
37
43
  kind = _classify_border_token(token)
38
44
  if kind == "width":
39
45
  result["border-width"] = token
@@ -55,7 +61,7 @@ def _expand_shorthand(prop: str, value: str) -> dict[str, str]:
55
61
  longhands = SHORTHAND_SIDES.get(prop)
56
62
  if not longhands:
57
63
  return {prop: value}
58
- parts = value.split()
64
+ parts = _split_css_components(value)
59
65
  n = len(parts)
60
66
  if n == 1:
61
67
  expanded = [parts[0]] * 4
@@ -21,6 +21,7 @@ from .handlers import (
21
21
  GenericPropertyAnimation,
22
22
  OpacityAnimation,
23
23
  clamp_border_radius,
24
+ target_border_radius_box_size,
24
25
  )
25
26
  from .qt_compat.QtCore import QAbstractAnimation, QEasingCurve, QEvent, QObject, Qt, QTimer
26
27
  from .qt_compat.QtGui import QMouseEvent
@@ -565,7 +566,7 @@ class TransitionEngine(QObject):
565
566
  ctx = self._contexts.pop(wid, None)
566
567
  if ctx is None:
567
568
  return
568
- event_logger.debug("On widget destroyed: %s", widget)
569
+ event_logger.debug("On widget destroyed: %s, %s", widget.__class__, wid)
569
570
  for timer in ctx.pending_delays.values():
570
571
  try:
571
572
  timer.stop()
@@ -630,17 +631,70 @@ class TransitionEngine(QObject):
630
631
  target_transitions,
631
632
  all_animated_props,
632
633
  ) = self._collect_rule_state(widget, ctx)
633
- needs_style_update = False
634
- for prop in all_animated_props:
635
- if self._apply_prop_animation(widget, ctx, prop, base_props, target_props, target_transitions, cause):
634
+ ctx.style_box_props = dict(target_props)
635
+ old_immediate = ctx.style_flush_immediate
636
+ ctx.style_flush_immediate = old_immediate or cause.is_class_driven
637
+ try:
638
+ needs_style_update = False
639
+ for prop in all_animated_props:
640
+ if self._apply_prop_animation(widget, ctx, prop, base_props, target_props, target_transitions, cause):
641
+ needs_style_update = True
642
+ if self._cleanup_orphans(widget, ctx, all_animated_props, base_props):
636
643
  needs_style_update = True
637
- if self._cleanup_orphans(widget, ctx, all_animated_props, base_props):
638
- needs_style_update = True
639
- if needs_style_update:
640
- event_logger.debug("Updating style: %s", widget)
641
- widget.setStyleSheet(scoped_anim_style(widget, ctx.css_anim_props))
642
- update_shadow_ancestor(widget)
643
- self._apply_cursor(widget, ctx, target_props)
644
+ if needs_style_update:
645
+ event_logger.debug("Updating style: %s", widget)
646
+ self._flush_widget_style_now(widget, ctx)
647
+ self._apply_cursor(widget, ctx, target_props)
648
+ finally:
649
+ ctx.style_flush_immediate = old_immediate
650
+
651
+ def _schedule_style_flush(self, widget: QWidget, ctx: WidgetContext) -> None:
652
+ """Queue one stylesheet write for this widget after the current burst of animation ticks."""
653
+ if ctx.style_flush_immediate or ctx.class_anim_props:
654
+ self._flush_widget_style_now(widget, ctx)
655
+ return
656
+ if ctx.style_flush_pending:
657
+ return
658
+ ctx.style_flush_pending = True
659
+ wid = id(widget)
660
+ QTimer.singleShot(0, lambda: self._flush_scheduled_widget_style(widget, wid))
661
+
662
+ def _flush_scheduled_widget_style(self, widget: QWidget, wid: int) -> None:
663
+ ctx = self._contexts.get(wid)
664
+ if ctx is None or not ctx.style_flush_pending:
665
+ return
666
+ try:
667
+ self._flush_widget_style_now(widget, ctx)
668
+ except RuntimeError:
669
+ ctx.style_flush_pending = False
670
+
671
+ def _flush_widget_style_now(self, widget: QWidget, ctx: WidgetContext) -> None:
672
+ """Normalize interdependent inline props and apply them as one scoped stylesheet."""
673
+ ctx.style_flush_pending = False
674
+ self._normalize_dependent_anim_props(widget, ctx)
675
+ widget.setStyleSheet(scoped_anim_style(widget, ctx.css_anim_props))
676
+ update_shadow_ancestor(widget)
677
+
678
+ def _normalize_dependent_anim_props(self, widget: QWidget, ctx: WidgetContext) -> None:
679
+ """
680
+ Clamp radius values against the same pending box model that is about to be applied.
681
+
682
+ Overshooting easing curves can push radius beyond the target box's safe cap while
683
+ width/height/radius animations tick separately. Normalizing immediately before the
684
+ batched stylesheet write prevents Qt from seeing an invalid radius and snapping square.
685
+ """
686
+ props = ctx.css_anim_props
687
+ box_props = {**ctx.style_box_props, **props}
688
+ box_size = target_border_radius_box_size(widget, box_props)
689
+ for prop in BORDER_RADIUS_PROPS:
690
+ raw = props.get(prop)
691
+ parsed = parse_css_numeric(raw)
692
+ if parsed is None:
693
+ continue
694
+ value, unit = parsed
695
+ clamped = clamp_border_radius(widget, prop, max(0.0, value), unit, box_props, box_size)
696
+ if clamped != value:
697
+ props[prop] = f"{clamped:.3f}{unit}"
644
698
 
645
699
  def _collect_rule_state(
646
700
  self, widget: QWidget, ctx: WidgetContext
@@ -706,7 +760,8 @@ class TransitionEngine(QObject):
706
760
  if parsed is None:
707
761
  return False
708
762
  value, unit = parsed
709
- return clamp_border_radius(widget, prop, max(0.0, value), unit, target_props) != value
763
+ box_size = target_border_radius_box_size(widget, target_props)
764
+ return clamp_border_radius(widget, prop, max(0.0, value), unit, target_props, box_size) != value
710
765
 
711
766
  def _apply_prop_animation(
712
767
  self,
@@ -909,13 +964,16 @@ class TransitionEngine(QObject):
909
964
  if not anim_obj:
910
965
  return False
911
966
 
967
+ box_size = target_border_radius_box_size(widget, target_props) if prop in BORDER_RADIUS_PROPS else None
912
968
  if is_natural_target and isinstance(anim_obj, GenericPropertyAnimation):
913
969
  anim_obj.update_box_props(target_props)
914
- anim_obj.set_target(target_raw, clean_on_finish=True)
970
+ anim_obj.set_target(target_raw, clean_on_finish=True, box_size=box_size)
915
971
  else:
916
972
  if isinstance(anim_obj, GenericPropertyAnimation):
917
973
  anim_obj.update_box_props(target_props)
918
- anim_obj.set_target(target_raw)
974
+ anim_obj.set_target(target_raw, box_size=box_size)
975
+ else:
976
+ anim_obj.set_target(target_raw)
919
977
 
920
978
  # Negative transition-delay: start immediately but seek |delay| ms into the timeline,
921
979
  # as if the animation had already been running that long (CSS spec §transition-delay).
@@ -1351,13 +1409,14 @@ class TransitionEngine(QObject):
1351
1409
  return
1352
1410
  ctx = self._ctx(widget)
1353
1411
  base_props, target_props, target_transitions, all_animated_props = self._collect_rule_state(widget, ctx)
1412
+ ctx.style_box_props = dict(target_props)
1354
1413
  if prop not in all_animated_props:
1355
1414
  return
1356
1415
  needs_update = self._apply_prop_animation(
1357
1416
  widget, ctx, prop, base_props, target_props, target_transitions, EvaluationCause.DELAY_FIRE
1358
1417
  )
1359
1418
  if needs_update:
1360
- widget.setStyleSheet(scoped_anim_style(widget, ctx.css_anim_props))
1419
+ self._flush_widget_style_now(widget, ctx)
1361
1420
 
1362
1421
  def _snap_prop_or_effect(
1363
1422
  self,
@@ -1376,7 +1435,12 @@ class TransitionEngine(QObject):
1376
1435
  else:
1377
1436
  if isinstance(anim_obj, GenericPropertyAnimation):
1378
1437
  anim_obj.update_box_props(target_props)
1379
- anim_obj.snap_to(target_raw)
1438
+ if prop in BORDER_RADIUS_PROPS:
1439
+ anim_obj.snap_to(target_raw, target_border_radius_box_size(widget, target_props))
1440
+ else:
1441
+ anim_obj.snap_to(target_raw)
1442
+ else:
1443
+ anim_obj.snap_to(target_raw)
1380
1444
  return not isinstance(anim_obj, (OpacityAnimation, BoxShadowHandle))
1381
1445
  if prop in EFFECT_PROPS:
1382
1446
  new_anim = self._create_animation_obj(widget, prop, target_raw, 0, QEasingCurve.Type.Linear, target_props)
@@ -1391,7 +1455,8 @@ class TransitionEngine(QObject):
1391
1455
  parsed = parse_css_numeric(target_raw)
1392
1456
  if parsed is not None:
1393
1457
  value, unit = parsed
1394
- clamped = clamp_border_radius(widget, prop, max(0.0, value), unit, target_props)
1458
+ box_size = target_border_radius_box_size(widget, target_props)
1459
+ clamped = clamp_border_radius(widget, prop, max(0.0, value), unit, target_props, box_size)
1395
1460
  if clamped != value and prop in NON_NEGATIVE_PROPS:
1396
1461
  ctx.css_anim_props[prop] = f"{clamped:.3f}{unit}"
1397
1462
  return True
@@ -1457,7 +1522,16 @@ class TransitionEngine(QObject):
1457
1522
  """Instantiate the correct Animation subclass for a CSS property."""
1458
1523
  ctx = self._ctx(widget)
1459
1524
  if "color" in prop:
1460
- return ColorAnimation(widget, prop, initial_raw, duration_ms, curve, self, ctx=ctx)
1525
+ return ColorAnimation(
1526
+ widget,
1527
+ prop,
1528
+ initial_raw,
1529
+ duration_ms,
1530
+ curve,
1531
+ self,
1532
+ ctx=ctx,
1533
+ style_flush_callback=self._schedule_style_flush,
1534
+ )
1461
1535
  if prop == "opacity":
1462
1536
  return OpacityAnimation(
1463
1537
  widget,
@@ -1474,6 +1548,15 @@ class TransitionEngine(QObject):
1474
1548
  if parsed is not None:
1475
1549
  start_val, unit = parsed
1476
1550
  return GenericPropertyAnimation(
1477
- widget, prop, start_val, duration_ms, curve, self, unit=unit, ctx=ctx, box_props=box_props
1551
+ widget,
1552
+ prop,
1553
+ start_val,
1554
+ duration_ms,
1555
+ curve,
1556
+ self,
1557
+ unit=unit,
1558
+ ctx=ctx,
1559
+ box_props=box_props,
1560
+ style_flush_callback=self._schedule_style_flush,
1478
1561
  )
1479
1562
  return None
@@ -1,4 +1,5 @@
1
1
  import math
2
+ from collections.abc import Callable
2
3
 
3
4
  from qt_css_engine.constants import BORDER_RADIUS_PROPS, NON_NEGATIVE_PROPS, SIZE_PROPS
4
5
 
@@ -13,22 +14,113 @@ from .utils import (
13
14
  interpolate_oklab,
14
15
  lerp_shadow,
15
16
  margin_side_px,
17
+ padding_side_px,
16
18
  parse_box_shadow,
17
19
  parse_color,
18
20
  parse_css_numeric,
19
21
  parse_css_val,
20
22
  scoped_anim_style,
21
23
  shadow_as_transparent,
24
+ total_border_px,
22
25
  update_shadow_ancestor,
23
26
  )
24
27
 
25
28
 
29
+ def _numeric_px(raw: str | None) -> float | None:
30
+ parsed = parse_css_numeric(raw)
31
+ if parsed is None:
32
+ return None
33
+ value, unit = parsed
34
+ if unit != "px":
35
+ return None
36
+ return max(0.0, value)
37
+
38
+
39
+ def _axis_sides(axis: str) -> tuple[str, str]:
40
+ return ("left", "right") if axis == "width" else ("top", "bottom")
41
+
42
+
43
+ def _axis_margin_px(props: dict[str, str], axis: str) -> int:
44
+ side_a, side_b = _axis_sides(axis)
45
+ return margin_side_px(props, side_a) + margin_side_px(props, side_b)
46
+
47
+
48
+ def _axis_padding_px(props: dict[str, str], axis: str) -> int:
49
+ side_a, side_b = _axis_sides(axis)
50
+ return padding_side_px(props, side_a) + padding_side_px(props, side_b)
51
+
52
+
53
+ def _axis_border_px(widget: QWidget, props: dict[str, str], axis: str) -> int:
54
+ side_a, side_b = _axis_sides(axis)
55
+ return total_border_px(widget, props, side_a) + total_border_px(widget, props, side_b)
56
+
57
+
58
+ def _target_content_axis_px(
59
+ widget: QWidget,
60
+ props: dict[str, str],
61
+ axis: str,
62
+ current_margin_box_px: float,
63
+ ) -> float | None:
64
+ explicit = _numeric_px(props.get(axis))
65
+ if explicit is not None:
66
+ return explicit
67
+
68
+ min_px = _numeric_px(props.get(f"min-{axis}"))
69
+ max_px = _numeric_px(props.get(f"max-{axis}"))
70
+ if min_px is None and max_px is None:
71
+ return None
72
+
73
+ extras = _axis_margin_px(props, axis) + _axis_padding_px(props, axis) + _axis_border_px(widget, props, axis)
74
+ current_content = max(0.0, current_margin_box_px - extras)
75
+ target = current_content
76
+ if min_px is not None:
77
+ target = max(target, min_px)
78
+ if max_px is not None:
79
+ target = min(target, max_px)
80
+ return max(0.0, target)
81
+
82
+
83
+ def _target_margin_box_axis_px(
84
+ widget: QWidget,
85
+ props: dict[str, str],
86
+ axis: str,
87
+ current_margin_box_px: float,
88
+ ) -> float | None:
89
+ content_px = _target_content_axis_px(widget, props, axis, current_margin_box_px)
90
+ if content_px is None:
91
+ return None
92
+ extras = _axis_margin_px(props, axis) + _axis_padding_px(props, axis) + _axis_border_px(widget, props, axis)
93
+ return content_px + extras
94
+
95
+
96
+ def target_border_radius_box_size(widget: QWidget, box_props: dict[str, str]) -> tuple[float, float] | None:
97
+ """Return target margin-box size implied by target size props, if any."""
98
+ width = widget.width()
99
+ height = widget.height()
100
+ if width <= 0 or height <= 0:
101
+ hint = widget.sizeHint()
102
+ if width <= 0:
103
+ width = hint.width()
104
+ if height <= 0:
105
+ height = hint.height()
106
+
107
+ target_width = _target_margin_box_axis_px(widget, box_props, "width", float(width))
108
+ target_height = _target_margin_box_axis_px(widget, box_props, "height", float(height))
109
+ if target_width is None and target_height is None:
110
+ return None
111
+ return (
112
+ target_width if target_width is not None else float(width),
113
+ target_height if target_height is not None else float(height),
114
+ )
115
+
116
+
26
117
  def clamp_border_radius(
27
118
  widget: QWidget,
28
119
  prop: str,
29
120
  value: float,
30
121
  unit: str,
31
122
  box_props: dict[str, str] | None = None,
123
+ box_size: tuple[float, float] | None = None,
32
124
  ) -> float:
33
125
  """
34
126
  Clamp pixel border-radius values to Qt's maximum supported corner radius.
@@ -39,8 +131,11 @@ def clamp_border_radius(
39
131
  if unit != "px" or prop not in BORDER_RADIUS_PROPS:
40
132
  return value
41
133
  props = box_props or {}
42
- width = widget.width()
43
- height = widget.height()
134
+ if box_size is not None:
135
+ width, height = box_size
136
+ else:
137
+ width = widget.width()
138
+ height = widget.height()
44
139
  if width <= 0 or height <= 0:
45
140
  hint = widget.sizeHint()
46
141
  if width <= 0:
@@ -51,7 +146,7 @@ def clamp_border_radius(
51
146
  height -= margin_side_px(props, "top") + margin_side_px(props, "bottom")
52
147
  if width <= 0 or height <= 0:
53
148
  return value
54
- return math.floor(min(value, min(width, height) / 2.0))
149
+ return float(math.floor(min(value, min(width, height) / 2.0)))
55
150
 
56
151
 
57
152
  class BoxShadowHandle(QObject):
@@ -189,11 +284,13 @@ class ColorAnimation(QObject):
189
284
  easing_curve: QEasingCurve | QEasingCurve.Type,
190
285
  parent: QObject | None = None,
191
286
  ctx: WidgetContext | None = None,
287
+ style_flush_callback: Callable[[QWidget, WidgetContext], None] | None = None,
192
288
  ) -> None:
193
289
  super().__init__(parent)
194
290
  self.widget = widget
195
291
  self.prop = prop
196
292
  self._ctx = ctx
293
+ self._style_flush_callback = style_flush_callback
197
294
  self.current_color = parse_color(initial_raw) if isinstance(initial_raw, str) else QColor(initial_raw)
198
295
  self.start_color = self.current_color
199
296
  self.end_color = self.current_color
@@ -213,17 +310,23 @@ class ColorAnimation(QObject):
213
310
  props: dict[str, str] = getattr(self.widget, "_css_anim_props", {})
214
311
  return props
215
312
 
313
+ def _request_style_flush(self, props: dict[str, str], *, update_shadow: bool = False) -> None:
314
+ if self._ctx is not None and self._style_flush_callback is not None:
315
+ self._style_flush_callback(self.widget, self._ctx)
316
+ return
317
+ self.widget.setStyleSheet(scoped_anim_style(self.widget, props))
318
+ if update_shadow:
319
+ update_shadow_ancestor(self.widget)
320
+
216
321
  def _on_tick(self, t: float) -> None:
217
- """Write interpolated color to css_anim_props and refresh the widget stylesheet."""
322
+ """Write interpolated color to css_anim_props and request a stylesheet refresh."""
218
323
  if not is_qobject_alive(self.widget):
219
324
  self.anim.stop()
220
325
  return
221
326
  self.current_color = interpolate_oklab(self.start_color, self.end_color, t)
222
327
  props = self._props
223
328
  props[self.prop] = self.current_color.name(QColor.NameFormat.HexArgb)
224
- self.widget.setStyleSheet(scoped_anim_style(self.widget, props))
225
- if self.start_color.alpha() != 255 or self.end_color.alpha() != 255:
226
- update_shadow_ancestor(self.widget)
329
+ self._request_style_flush(props, update_shadow=self.start_color.alpha() != 255 or self.end_color.alpha() != 255)
227
330
 
228
331
  def update_spec(self, duration_ms: int, easing_curve: QEasingCurve) -> None:
229
332
  """Update duration and easing curve without restarting the animation."""
@@ -299,14 +402,17 @@ class GenericPropertyAnimation(QObject):
299
402
  unit: str = "px",
300
403
  ctx: WidgetContext | None = None,
301
404
  box_props: dict[str, str] | None = None,
405
+ style_flush_callback: Callable[[QWidget, WidgetContext], None] | None = None,
302
406
  ) -> None:
303
407
  super().__init__(parent)
304
408
  self.widget = widget
305
409
  self.prop = prop
306
410
  self.unit = unit
307
411
  self._ctx = ctx
412
+ self._style_flush_callback = style_flush_callback
308
413
  self._box_props = dict(box_props or {})
309
414
  self.current_val = self._effective_anim_value(float(initial_val), unit)
415
+ self._target_box_size: tuple[float, float] | None = None
310
416
  # Captured at creation time (before any inline constraint is applied).
311
417
  # Used as the animation target when returning to natural/unconstrained state,
312
418
  # so we never call sizeHint() while min-width/max-width are still active.
@@ -317,6 +423,8 @@ class GenericPropertyAnimation(QObject):
317
423
  self.anim = QVariantAnimation(self)
318
424
  self.anim.setDuration(duration_ms)
319
425
  self.anim.setEasingCurve(easing_curve)
426
+ self.anim.setStartValue(self.current_val)
427
+ self.anim.setEndValue(self.current_val)
320
428
  self.anim.valueChanged.connect(self._on_tick)
321
429
  self.anim.finished.connect(self._on_finished)
322
430
 
@@ -332,6 +440,14 @@ class GenericPropertyAnimation(QObject):
332
440
  props: dict[str, str] = getattr(self.widget, "_css_anim_props", {})
333
441
  return props
334
442
 
443
+ def _request_style_flush(self, props: dict[str, str], *, update_shadow: bool = False) -> None:
444
+ if self._ctx is not None and self._style_flush_callback is not None:
445
+ self._style_flush_callback(self.widget, self._ctx)
446
+ return
447
+ self.widget.setStyleSheet(scoped_anim_style(self.widget, props))
448
+ if update_shadow:
449
+ update_shadow_ancestor(self.widget)
450
+
335
451
  def _effective_anim_value(self, value: float, unit: str | None = None) -> float:
336
452
  """Normalize values used as animation endpoints/current state for Qt-limited props."""
337
453
  resolved_unit = self.unit if unit is None else unit
@@ -339,26 +455,82 @@ class GenericPropertyAnimation(QObject):
339
455
  return clamp_border_radius(self.widget, self.prop, max(0.0, value), resolved_unit, self._box_props)
340
456
  return value
341
457
 
458
+ def _effective_target_value(
459
+ self,
460
+ value: float,
461
+ unit: str | None = None,
462
+ box_size: tuple[float, float] | None = None,
463
+ ) -> float:
464
+ """Normalize animation targets for Qt-limited props."""
465
+ resolved_unit = self.unit if unit is None else unit
466
+ if self.prop in BORDER_RADIUS_PROPS:
467
+ return clamp_border_radius(
468
+ self.widget,
469
+ self.prop,
470
+ max(0.0, value),
471
+ resolved_unit,
472
+ self._box_props,
473
+ box_size,
474
+ )
475
+ return value
476
+
342
477
  def update_box_props(self, box_props: dict[str, str]) -> None:
343
478
  """Update box-model props used for border-radius clamping."""
344
479
  self._box_props = dict(box_props)
345
480
 
346
- def _on_tick(self, val: int | float) -> None:
347
- """Write interpolated numeric value to css_anim_props and refresh the widget stylesheet."""
481
+ def _write_current_style_value_if_needed(self, target_raw: str, box_size: tuple[float, float] | None) -> None:
482
+ props = self._props
483
+ parsed_target = parse_css_numeric(target_raw)
484
+ target_needs_clamp = False
485
+ if parsed_target is not None and self.prop in BORDER_RADIUS_PROPS:
486
+ target_val, target_unit = parsed_target
487
+ target_needs_clamp = self._effective_target_value(target_val, target_unit, box_size) != target_val
488
+
489
+ written = max(0.0, self.current_val) if self.prop in NON_NEGATIVE_PROPS else self.current_val
490
+ written = clamp_border_radius(self.widget, self.prop, written, self.unit, self._box_props, box_size)
491
+ current_raw = props.get(self.prop)
492
+ current_parsed = parse_css_numeric(current_raw)
493
+ stored_matches = (
494
+ current_parsed is not None and current_parsed[1] == self.unit and abs(current_parsed[0] - written) < 1e-6
495
+ )
496
+ if stored_matches or (current_raw is None and not target_needs_clamp):
497
+ return
498
+ props[self.prop] = f"{written:.3f}{self.unit}"
499
+ self._request_style_flush(props, update_shadow=self.prop in SIZE_PROPS)
500
+
501
+ def _on_tick(self, val: int | float | None) -> None:
502
+ """Write interpolated numeric value to css_anim_props and request a stylesheet refresh."""
348
503
  if not is_qobject_alive(self.widget):
349
504
  self.anim.stop()
350
505
  return
351
- self.current_val = self._effective_anim_value(float(val))
506
+ if val is None:
507
+ self.anim.stop()
508
+ return
509
+ final_box_size = (
510
+ self._target_box_size
511
+ if self.prop in BORDER_RADIUS_PROPS
512
+ and self._target_box_size is not None
513
+ and self.anim.currentTime() >= self.anim.duration()
514
+ else None
515
+ )
516
+ if final_box_size is not None:
517
+ self.current_val = self._effective_target_value(val, box_size=final_box_size)
518
+ else:
519
+ self.current_val = self._effective_anim_value(val)
352
520
  written = max(0.0, self.current_val) if self.prop in NON_NEGATIVE_PROPS else self.current_val
353
- written = clamp_border_radius(self.widget, self.prop, written, self.unit, self._box_props)
521
+ written = clamp_border_radius(self.widget, self.prop, written, self.unit, self._box_props, final_box_size)
354
522
  props = self._props
355
523
  props[self.prop] = f"{written:.3f}{self.unit}"
356
- self.widget.setStyleSheet(scoped_anim_style(self.widget, props))
357
- if self.prop in SIZE_PROPS:
358
- update_shadow_ancestor(self.widget)
524
+ self._request_style_flush(props, update_shadow=self.prop in SIZE_PROPS)
359
525
 
360
526
  def _on_finished(self) -> None:
361
527
  """Remove the inline size constraint when targeting the natural layout size."""
528
+ if self.prop in BORDER_RADIUS_PROPS and self._target_box_size is not None:
529
+ try:
530
+ self._on_tick(self.anim.endValue())
531
+ except RuntimeError:
532
+ pass
533
+ self._target_box_size = None
362
534
  if not self._clean_on_finish:
363
535
  return
364
536
  self._clean_on_finish = False
@@ -366,8 +538,7 @@ class GenericPropertyAnimation(QObject):
366
538
  if self.prop in props:
367
539
  del props[self.prop]
368
540
  try:
369
- self.widget.setStyleSheet(scoped_anim_style(self.widget, props))
370
- update_shadow_ancestor(self.widget)
541
+ self._request_style_flush(props, update_shadow=True)
371
542
  except RuntimeError:
372
543
  pass
373
544
 
@@ -376,41 +547,58 @@ class GenericPropertyAnimation(QObject):
376
547
  self.anim.setDuration(duration_ms)
377
548
  self.anim.setEasingCurve(easing_curve)
378
549
 
379
- def snap_to(self, value_raw: str) -> None:
550
+ def snap_to(self, value_raw: str, box_size: tuple[float, float] | None = None) -> None:
380
551
  """Instantly apply a CSS numeric value without animation."""
552
+ self._target_box_size = None
381
553
  self.anim.stop()
382
554
  self._clean_on_finish = False
383
555
  parsed = parse_css_numeric(value_raw)
384
556
  if parsed is not None:
385
557
  raw_val, unit = parsed
386
558
  self.unit = unit
387
- self.current_val = self._effective_anim_value(raw_val, unit)
559
+ self.current_val = self._effective_target_value(raw_val, unit, box_size)
388
560
  self._anim_origin_val = self.current_val
389
561
  written = self.current_val
390
562
  if self.prop in BORDER_RADIUS_PROPS:
391
- written = clamp_border_radius(self.widget, self.prop, max(0.0, written), self.unit, self._box_props)
563
+ written = clamp_border_radius(
564
+ self.widget,
565
+ self.prop,
566
+ max(0.0, written),
567
+ self.unit,
568
+ self._box_props,
569
+ box_size,
570
+ )
392
571
  self._props[self.prop] = f"{written:.3f}{self.unit}"
393
572
 
394
573
  def snap_to_natural(self) -> None:
395
574
  """Stop animation and remove this prop from css_anim_props (returns widget to natural layout)."""
575
+ self._target_box_size = None
396
576
  self.anim.stop()
397
577
  self._clean_on_finish = False
398
578
  props = self._props
399
579
  if self.prop in props:
400
580
  del props[self.prop]
401
581
 
402
- def set_target(self, target_raw: str, clean_on_finish: bool = False) -> None:
582
+ def set_target(
583
+ self,
584
+ target_raw: str,
585
+ clean_on_finish: bool = False,
586
+ box_size: tuple[float, float] | None = None,
587
+ ) -> None:
403
588
  """Start or re-target the animation toward a new CSS numeric value."""
404
589
  parsed = parse_css_numeric(target_raw)
405
590
  if parsed is None:
406
591
  return
407
- t_val = self._effective_anim_value(float(parsed[0]), parsed[1])
592
+ t_val = self._effective_target_value(parsed[0], parsed[1], box_size)
408
593
  is_running = self.anim.state() == self.anim.State.Running
409
594
  if is_running and t_val == self.anim.endValue():
410
595
  return
411
596
  if not is_running and abs(t_val - self.current_val) < 1e-6:
597
+ self._target_box_size = box_size
598
+ self._write_current_style_value_if_needed(target_raw, box_size)
412
599
  return
413
600
 
601
+ self._target_box_size = box_size
414
602
  # Specific for handling steps()
415
603
  is_steps = self.anim.easingCurve().type() == QEasingCurve.Type.Custom
416
604
  if is_steps and is_running and self._anim_origin_val is not None and abs(t_val - self._anim_origin_val) < 1e-6:
@@ -434,9 +622,9 @@ class GenericPropertyAnimation(QObject):
434
622
  self.anim.setStartValue(self.current_val)
435
623
  self.anim.setEndValue(t_val)
436
624
  self.anim.start()
437
- # Explicitly tick so the initial value is immediately written to the scoped stylesheet
438
- # TODO: test if needed
439
- # self._on_tick(self.current_val)
625
+ # Force the start frame into the scoped stylesheet immediately. Qt may defer the
626
+ # first valueChanged tick, which lets freshly polished class styles flash through.
627
+ self._on_tick(self.current_val)
440
628
 
441
629
 
442
630
  class OpacityAnimation(QObject):
@@ -76,6 +76,13 @@ class WidgetContext:
76
76
  clicked_anim_callbacks: dict[str, Callable[[], None]] = field(
77
77
  default_factory=lambda: dict[str, Callable[[], None]]()
78
78
  )
79
+ # Last target rule props seen during evaluation. Batched flushes merge these with
80
+ # css_anim_props so radius clamping can still see unchanged margin/border values.
81
+ style_box_props: dict[str, str] = field(default_factory=lambda: dict[str, str]())
82
+ # True while an inline stylesheet flush is queued for this widget.
83
+ style_flush_pending: bool = False
84
+ # True while class-change evaluation must synchronously restore inline animation styles.
85
+ style_flush_immediate: bool = False
79
86
 
80
87
 
81
88
  @dataclass
@@ -0,0 +1,48 @@
1
+ import gc
2
+ import sys
3
+ from collections.abc import Generator
4
+ from typing import Any
5
+
6
+ import pytest
7
+
8
+ from qt_css_engine import TransitionEngine
9
+ from qt_css_engine.qt_compat.QtCore import QCoreApplication, QEvent
10
+ from qt_css_engine.qt_compat.QtWidgets import QApplication
11
+
12
+
13
+ @pytest.fixture(scope="session")
14
+ def _app() -> QApplication: # type: ignore[reportUnusedFunction]
15
+ instance = QApplication.instance()
16
+ if instance is None:
17
+ instance = QApplication(sys.argv)
18
+ assert isinstance(instance, QApplication)
19
+ return instance
20
+
21
+
22
+ @pytest.fixture(autouse=True)
23
+ def cleanup_deferred_deletes(monkeypatch: pytest.MonkeyPatch) -> Generator[None]:
24
+ """
25
+ Prevents PySide6 test crashes by keeping the TransitionEngine alive while flushing
26
+ pending DeferredDelete events. This avoids a race condition where Shiboken garbage
27
+ collects the C++ parent engine before the event loop deletes its child animations.
28
+ """
29
+
30
+ engines_in_test: list[Any] = []
31
+ old_init = TransitionEngine.__init__
32
+
33
+ def new_init(self: Any, *args: Any, **kwargs: Any) -> None:
34
+ old_init(self, *args, **kwargs)
35
+ engines_in_test.append(self)
36
+
37
+ monkeypatch.setattr(TransitionEngine, "__init__", new_init)
38
+
39
+ yield
40
+
41
+ # Force processing of any pending DeferredDelete events while engines are still alive
42
+ QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
43
+ # Process other events
44
+ QCoreApplication.processEvents()
45
+
46
+ # Clear references to allow GC
47
+ engines_in_test.clear()
48
+ gc.collect()
@@ -1317,6 +1317,31 @@ def test_generic_border_radius_animation_clamped_to_half_min_side(_app: QApplica
1317
1317
  destroy(widget)
1318
1318
 
1319
1319
 
1320
+ def test_generic_border_radius_steps_reverse_keeps_qvariant_endpoints_float(_app: QApplication) -> None:
1321
+ widget = QWidget()
1322
+ widget.resize(100, 100)
1323
+ ctx = WidgetContext()
1324
+ anim = GenericPropertyAnimation(
1325
+ widget,
1326
+ "border-bottom-left-radius",
1327
+ 0.0,
1328
+ 100,
1329
+ make_steps_curve(1, "jump-end"),
1330
+ ctx=ctx,
1331
+ )
1332
+
1333
+ anim.set_target("20px")
1334
+ anim.anim.setCurrentTime(50)
1335
+ anim.set_target("0px")
1336
+ anim.anim.setCurrentTime(50)
1337
+
1338
+ assert isinstance(anim.anim.startValue(), float)
1339
+ assert isinstance(anim.anim.endValue(), float)
1340
+ assert anim.anim.currentValue() is not None
1341
+
1342
+ destroy(widget)
1343
+
1344
+
1320
1345
  def test_generic_border_radius_uses_size_hint_when_geometry_unset(_app: QApplication) -> None:
1321
1346
  widget = FixedHintWidget()
1322
1347
  widget.resize(0, 0)
@@ -514,6 +514,32 @@ def test_border_shorthand_keyword_width() -> None:
514
514
  assert rule.properties["border-bottom-color"] == "blue"
515
515
 
516
516
 
517
+ def test_border_shorthand_rgba_color() -> None:
518
+ css = ".box { border: 3px solid rgba(255, 0, 0, 0.5); }"
519
+ _, rules = extract_rules(css)
520
+ rule = get_rule(rules, ".box")
521
+ expected = "rgba(255, 0, 0, 0.5)"
522
+ assert rule.properties["border-top-width"] == "3px"
523
+ assert rule.properties["border-style"] == "solid"
524
+ assert rule.properties["border-top-color"] == expected
525
+ assert rule.properties["border-right-color"] == expected
526
+ assert rule.properties["border-bottom-color"] == expected
527
+ assert rule.properties["border-left-color"] == expected
528
+
529
+
530
+ def test_border_color_shorthand_rgba_color() -> None:
531
+ css = ".box { border-color: rgba(255, 0, 0, 0.5); }"
532
+ _, rules = extract_rules(css)
533
+ rule = get_rule(rules, ".box")
534
+ expected = "rgba(255, 0, 0, 0.5)"
535
+ assert rule.properties == {
536
+ "border-top-color": expected,
537
+ "border-right-color": expected,
538
+ "border-bottom-color": expected,
539
+ "border-left-color": expected,
540
+ }
541
+
542
+
517
543
  def test_transition_border_expands_to_animatable_longhands() -> None:
518
544
  """transition: border should cover width sides + border-color, but NOT border-style."""
519
545
  css = ".box { transition: border 200ms ease; }"
@@ -429,6 +429,49 @@ def test_transition_all_return_trip_engine_managed_props(_app: QApplication) ->
429
429
  destroy(widget)
430
430
 
431
431
 
432
+ def test_class_change_initial_tick_flushes_inline_style_immediately(_app: QApplication) -> None:
433
+ engine = make_engine("""
434
+ .box { background-color: red; transition: background-color 300ms; }
435
+ .box.on { background-color: blue; }
436
+ """)
437
+ widget = TrackedWidget()
438
+ widget.setProperty("class", "box on")
439
+ ctx = engine._ctx(widget)
440
+ ctx.css_anim_props["background-color"] = "red"
441
+
442
+ count_before = widget.setStyleSheet_count
443
+ engine._evaluate_widget_state(widget, cause=EvaluationCause.CLASS_CHANGE)
444
+
445
+ assert widget.setStyleSheet_count > count_before
446
+ assert not ctx.style_flush_pending
447
+ assert ctx.css_anim_props["background-color"] == QColor("red").name(QColor.NameFormat.HexArgb)
448
+ destroy(widget)
449
+
450
+
451
+ def test_class_change_numeric_initial_tick_flushes_start_size_immediately(_app: QApplication) -> None:
452
+ engine = make_engine("""
453
+ .box { width: 22px; transition: width 300ms; }
454
+ .box.focused { width: 50px; }
455
+ """)
456
+ widget = TrackedWidget()
457
+ widget.setProperty("class", "box")
458
+ widget.resize(22, 22)
459
+ ctx = engine._ctx(widget)
460
+
461
+ widget.setProperty("class", "box focused")
462
+ count_before = widget.setStyleSheet_count
463
+ engine._on_class_change(widget)
464
+
465
+ assert widget.setStyleSheet_count > count_before
466
+ assert not ctx.style_flush_pending
467
+ anim = _get_anim(engine, widget, "width")
468
+ assert isinstance(anim, GenericPropertyAnimation)
469
+ assert ctx.css_anim_props["width"] == f"{float(anim.anim.startValue()):.3f}px"
470
+ assert anim.anim.state() == QAbstractAnimation.State.Running
471
+ assert anim.anim.endValue() == pytest.approx(50.0)
472
+ destroy(widget)
473
+
474
+
432
475
  # ---------------------------------------------------------------------------
433
476
  # :focus animation end-to-end
434
477
  # ---------------------------------------------------------------------------
@@ -589,6 +632,148 @@ def test_border_radius_animation_target_clamped_to_half_min_side(_app: QApplicat
589
632
  destroy(widget)
590
633
 
591
634
 
635
+ def test_border_radius_target_clamp_uses_target_min_max_size(_app: QApplication, qtbot: QtBot) -> None:
636
+ engine = make_engine("""
637
+ .ws-btn {
638
+ margin: 2px 4px;
639
+ min-width: 20px;
640
+ max-width: 20px;
641
+ min-height: 20px;
642
+ max-height: 20px;
643
+ border: 2px solid #5e666a;
644
+ border-radius: 13px;
645
+ transition: all 300ms ease;
646
+ }
647
+ .ws-btn:hover {
648
+ margin: 2px 4px;
649
+ min-width: 40px;
650
+ max-width: 40px;
651
+ min-height: 40px;
652
+ max-height: 40px;
653
+ border-radius: 99px;
654
+ border: 2px solid #a7c080;
655
+ }
656
+ """)
657
+ widget = QPushButton()
658
+ widget.setProperty("class", "ws-btn")
659
+ # Base margin-box: content 20 + border 4 + horizontal margin 8; height uses vertical margin 4.
660
+ widget.resize(32, 28)
661
+ hover_widget(engine, widget)
662
+
663
+ for corner in (
664
+ "border-top-left-radius",
665
+ "border-top-right-radius",
666
+ "border-bottom-right-radius",
667
+ "border-bottom-left-radius",
668
+ ):
669
+ anim = _get_anim(engine, widget, corner)
670
+ assert isinstance(anim, GenericPropertyAnimation)
671
+ assert anim.anim.endValue() == pytest.approx(22.0), f"{corner} should clamp against the 40px target size"
672
+
673
+ qtbot.wait(350)
674
+ ctx = engine._ctx(widget)
675
+ for corner in (
676
+ "border-top-left-radius",
677
+ "border-top-right-radius",
678
+ "border-bottom-right-radius",
679
+ "border-bottom-left-radius",
680
+ ):
681
+ assert ctx.css_anim_props.get(corner) == "22.000px", f"{corner} should finish at the target-size clamp"
682
+ destroy(widget)
683
+
684
+
685
+ def test_batched_flush_clamps_overshooting_radius_against_pending_size(_app: QApplication) -> None:
686
+ engine = make_engine("""
687
+ .box {
688
+ width: 50px;
689
+ height: 50px;
690
+ border-radius: 13px;
691
+ transition: all 200ms cubic-bezier(0.175, 0.885, 0.32, 1.75);
692
+ }
693
+ .box:hover {
694
+ width: 40px;
695
+ height: 40px;
696
+ border-radius: 99px;
697
+ }
698
+ """)
699
+ widget = TrackedWidget()
700
+ widget.setProperty("class", "box")
701
+ widget.resize(50, 50)
702
+ hover_widget(engine, widget)
703
+
704
+ ctx = engine._ctx(widget)
705
+ radius_anim = _get_anim(engine, widget, "border-top-left-radius")
706
+ width_anim = _get_anim(engine, widget, "width")
707
+ height_anim = _get_anim(engine, widget, "height")
708
+ assert isinstance(radius_anim, GenericPropertyAnimation)
709
+ assert isinstance(width_anim, GenericPropertyAnimation)
710
+ assert isinstance(height_anim, GenericPropertyAnimation)
711
+
712
+ count_before = widget.setStyleSheet_count
713
+ radius_anim._on_tick(24.6) # live 50px geometry allows this, but pending 40px target does not.
714
+ width_anim._on_tick(40.0)
715
+ height_anim._on_tick(40.0)
716
+
717
+ assert float(ctx.css_anim_props["border-top-left-radius"].removesuffix("px")) > 20.0
718
+ assert ctx.style_flush_pending
719
+
720
+ _app.processEvents()
721
+
722
+ assert widget.setStyleSheet_count == count_before + 1
723
+ assert ctx.css_anim_props["border-top-left-radius"] == "20.000px"
724
+ assert not ctx.style_flush_pending
725
+ destroy(widget)
726
+
727
+
728
+ def test_class_change_noop_radius_rewrites_stale_larger_clamp(_app: QApplication) -> None:
729
+ engine = make_engine("""
730
+ .ws-btn {
731
+ margin: 2px 4px;
732
+ width: 22px;
733
+ height: 22px;
734
+ border: 2px solid #5e666a;
735
+ border-radius: 99px;
736
+ transition: all 200ms cubic-bezier(0.175, 0.885, 0.32, 1.75);
737
+ }
738
+ .ws-btn:hover {
739
+ width: 40px;
740
+ height: 40px;
741
+ border-radius: 99px;
742
+ }
743
+ .ws-btn.active_populated {
744
+ border-radius: 99px;
745
+ }
746
+ .ws-btn.focused_populated {
747
+ width: 50px;
748
+ border-radius: 99px;
749
+ }
750
+ """)
751
+ widget = QPushButton()
752
+ widget.setProperty("class", "ws-btn active_populated focused_populated")
753
+ widget.resize(50, 30)
754
+ ctx = engine._ctx(widget)
755
+ for corner in (
756
+ "border-top-left-radius",
757
+ "border-top-right-radius",
758
+ "border-bottom-right-radius",
759
+ "border-bottom-left-radius",
760
+ ):
761
+ ctx.css_anim_props[corner] = "22.000px"
762
+
763
+ widget.setProperty("class", "ws-btn active_populated")
764
+ engine._on_class_change(widget)
765
+
766
+ for corner in (
767
+ "border-top-left-radius",
768
+ "border-top-right-radius",
769
+ "border-bottom-right-radius",
770
+ "border-bottom-left-radius",
771
+ ):
772
+ assert ctx.css_anim_props[corner] == "13.000px"
773
+ assert ctx.active_animations[corner].anim.state() != QAbstractAnimation.State.Running
774
+ destroy(widget)
775
+
776
+
592
777
  def test_border_radius_sidebar_qframe_clamps_to_margin_box(_app: QApplication) -> None:
593
778
  css = """
594
779
  .sidebar {
@@ -1,14 +0,0 @@
1
- import sys
2
-
3
- import pytest
4
-
5
- from qt_css_engine.qt_compat.QtWidgets import QApplication
6
-
7
-
8
- @pytest.fixture(scope="session")
9
- def _app() -> QApplication: # type: ignore[reportUnusedFunction]
10
- instance = QApplication.instance()
11
- if instance is None:
12
- instance = QApplication(sys.argv)
13
- assert isinstance(instance, QApplication)
14
- return instance
File without changes
File without changes
File without changes