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.
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/PKG-INFO +1 -1
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/css_parser.py +8 -2
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/engine.py +102 -19
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/handlers.py +212 -24
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/types.py +7 -0
- qt_css_engine-0.1.3/tests/conftest.py +48 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_anim.py +25 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_css_parser.py +26 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_engine_coverage.py +185 -0
- qt_css_engine-0.1.2/tests/conftest.py +0 -14
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.github/CODE_OF_CONDUCT.md +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.github/CONTRIBUTING.md +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.github/workflows/pr-check.yml +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.github/workflows/publish.yml +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.github/workflows/test-publish.yml +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/.gitignore +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/LICENSE.md +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/README.md +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/demo/layout_test.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/demo/main.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/demo/styles.css +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/pyproject.toml +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/__init__.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/constants.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/gradients.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/py.typed +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/qt_compat/QtCore.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/qt_compat/QtGui.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/qt_compat/QtWidgets.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/qt_compat/__init__.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/qt_compat/_api.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/qt_css_engine/utils.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_gradients.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_interactions.py +0 -0
- {qt_css_engine-0.1.2 → qt_css_engine-0.1.3}/tests/test_layouts.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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.
|
|
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
|
|
347
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
-
#
|
|
438
|
-
#
|
|
439
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|