qt-css-engine 0.1.0__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.
- qt_css_engine/__init__.py +23 -0
- qt_css_engine/constants.py +190 -0
- qt_css_engine/css_parser.py +397 -0
- qt_css_engine/engine.py +1035 -0
- qt_css_engine/gradients.py +321 -0
- qt_css_engine/handlers.py +402 -0
- qt_css_engine/py.typed +0 -0
- qt_css_engine/qt_compat/QtCore.py +15 -0
- qt_css_engine/qt_compat/QtGui.py +10 -0
- qt_css_engine/qt_compat/QtWidgets.py +10 -0
- qt_css_engine/qt_compat/__init__.py +22 -0
- qt_css_engine/qt_compat/_api.py +15 -0
- qt_css_engine/types.py +77 -0
- qt_css_engine/utils.py +447 -0
- qt_css_engine-0.1.0.dist-info/METADATA +187 -0
- qt_css_engine-0.1.0.dist-info/RECORD +18 -0
- qt_css_engine-0.1.0.dist-info/WHEEL +4 -0
- qt_css_engine-0.1.0.dist-info/licenses/LICENSE.md +9 -0
qt_css_engine/engine.py
ADDED
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from .constants import CURSOR_MAP, EASING_MAP, EFFECT_PROPS, PSEUDO_EVENTS, SIZE_PROPS, SUPPORTED_NUMERIC_PROPS
|
|
5
|
+
from .handlers import (
|
|
6
|
+
BoxShadowHandle,
|
|
7
|
+
ColorAnimation,
|
|
8
|
+
GenericPropertyAnimation,
|
|
9
|
+
OpacityAnimation,
|
|
10
|
+
)
|
|
11
|
+
from .qt_compat.QtCore import QAbstractAnimation, QEasingCurve, QEvent, QObject, QTimer
|
|
12
|
+
from .qt_compat.QtWidgets import QAbstractButton, QApplication, QWidget
|
|
13
|
+
from .types import Animation, EvaluationCause, InternalWriteReason, WidgetContext
|
|
14
|
+
from .utils import (
|
|
15
|
+
apply_shadow_to_widget,
|
|
16
|
+
content_box_px,
|
|
17
|
+
get_preferred_size_fallback,
|
|
18
|
+
make_cubic_bezier_curve,
|
|
19
|
+
make_steps_curve,
|
|
20
|
+
parse_css_numeric,
|
|
21
|
+
parse_css_val,
|
|
22
|
+
scoped_anim_style,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from qt_css_engine.css_parser import StyleRule, TransitionSpec
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_CUBIC_BEZIER_RE = re.compile(
|
|
30
|
+
r"cubic-bezier\(\s*([+-]?\d*\.?\d+)\s*,\s*([+-]?\d*\.?\d+)\s*,\s*([+-]?\d*\.?\d+)\s*,\s*([+-]?\d*\.?\d+)\s*\)",
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_STEPS_RE = re.compile(
|
|
35
|
+
r"steps\(\s*(\d+)(?:\s*,\s*(jump-start|jump-end|jump-none|jump-both|start|end))?\s*\)",
|
|
36
|
+
re.IGNORECASE,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TransitionEngine(QObject):
|
|
41
|
+
"""
|
|
42
|
+
Core CSS transition engine for PyQt6/PySide6.
|
|
43
|
+
|
|
44
|
+
Installed as a global event filter on QApplication. Intercepts hover, mouse,
|
|
45
|
+
and focus events to track widget pseudo-states, evaluates the CSS cascade,
|
|
46
|
+
and drives smooth property animations via Qt's animation framework.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
pseudo_priority: dict[str, int] = {"": 0, ":hover": 1, ":focus": 1, ":pressed": 2, ":checked": 1}
|
|
50
|
+
|
|
51
|
+
# Which effect wins the widget's single graphics-effect slot when both opacity and
|
|
52
|
+
# box-shadow are declared on the same widget. The loser becomes a silent no-op.
|
|
53
|
+
# "box-shadow" → QGraphicsDropShadowEffect takes priority over opacity.
|
|
54
|
+
# "opacity" → QGraphicsOpacityEffect takes priority over box-shadow.
|
|
55
|
+
effect_priority: str = "opacity"
|
|
56
|
+
|
|
57
|
+
def __init__(self, rules: list[StyleRule], parent: QObject | None = None, startup_delay_ms: int = 100) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Initialise the engine with a parsed rule set.
|
|
60
|
+
|
|
61
|
+
startup_delay_ms: animations are suppressed for this many milliseconds after
|
|
62
|
+
construction so that initial layout polish events don't trigger spurious transitions.
|
|
63
|
+
Set to 0 to enable immediately (synchronous — useful in tests).
|
|
64
|
+
"""
|
|
65
|
+
super().__init__(parent)
|
|
66
|
+
self.rules = rules
|
|
67
|
+
if startup_delay_ms <= 0:
|
|
68
|
+
self.animations_enabled = True
|
|
69
|
+
else:
|
|
70
|
+
self.animations_enabled = False
|
|
71
|
+
QTimer.singleShot(startup_delay_ms, lambda: self._on_startup_done())
|
|
72
|
+
# One source of truth for all per-widget state.
|
|
73
|
+
self._contexts: dict[int, WidgetContext] = {}
|
|
74
|
+
# Widget IDs with a destroyed-signal connection (prevents double-connect).
|
|
75
|
+
self._connected_widgets: set[int] = set()
|
|
76
|
+
# Checkable widget IDs already connected to toggled signal.
|
|
77
|
+
self._connected_checkable_ids: set[int] = set()
|
|
78
|
+
# Rule-match cache: widget id → list of matching rules.
|
|
79
|
+
self._rule_cache: dict[int, list[StyleRule]] = {}
|
|
80
|
+
# Quick filters: sets of segments[-1] parts that have transitions or effect props
|
|
81
|
+
self._animated_tags: set[str] = set()
|
|
82
|
+
self._animated_classes: set[str] = set()
|
|
83
|
+
self._animated_ids: set[str] = set()
|
|
84
|
+
# True if any rule uses effect props (opacity, box-shadow) — these need engine init at base state
|
|
85
|
+
self._has_effect_rules: bool = False
|
|
86
|
+
# True if any rule declares a cursor — Qt QSS ignores cursor, so the engine must apply it.
|
|
87
|
+
self._has_cursor_rules: bool = False
|
|
88
|
+
self._build_quick_filters()
|
|
89
|
+
|
|
90
|
+
def _on_startup_done(self) -> None:
|
|
91
|
+
"""Enable animations after the startup delay has elapsed."""
|
|
92
|
+
self.animations_enabled = True
|
|
93
|
+
|
|
94
|
+
def _ctx(self, widget: QWidget) -> WidgetContext:
|
|
95
|
+
"""Get or create the context for a widget."""
|
|
96
|
+
wid = id(widget)
|
|
97
|
+
ctx = self._contexts.get(wid)
|
|
98
|
+
if ctx is None:
|
|
99
|
+
ctx = WidgetContext()
|
|
100
|
+
self._contexts[wid] = ctx
|
|
101
|
+
self._connect_destroyed(widget)
|
|
102
|
+
return ctx
|
|
103
|
+
|
|
104
|
+
# -------------------------------------------------------------------------
|
|
105
|
+
# Event filtering and pseudo-state tracking
|
|
106
|
+
# -------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # type: ignore
|
|
109
|
+
"""Intercept widget events to track pseudo-states and trigger CSS transitions."""
|
|
110
|
+
if not isinstance(watched, QWidget):
|
|
111
|
+
return False
|
|
112
|
+
t = event.type()
|
|
113
|
+
if t == QEvent.Type.Polish:
|
|
114
|
+
self._on_polish(watched)
|
|
115
|
+
elif t == QEvent.Type.DynamicPropertyChange:
|
|
116
|
+
prop_name = getattr(event, "propertyName", lambda: None)()
|
|
117
|
+
if prop_name is not None and getattr(prop_name, "data", lambda: b"")() == b"class":
|
|
118
|
+
self._on_class_change(watched)
|
|
119
|
+
elif t == QEvent.Type.WindowDeactivate:
|
|
120
|
+
self._on_window_deactivate(watched)
|
|
121
|
+
elif t in PSEUDO_EVENTS:
|
|
122
|
+
ctx = self._ctx(watched)
|
|
123
|
+
updated = self._update_pseudos(ctx.active_pseudos, t)
|
|
124
|
+
if updated != ctx.active_pseudos:
|
|
125
|
+
ctx.active_pseudos = updated
|
|
126
|
+
self._evaluate_widget_state(watched, cause=EvaluationCause.PSEUDO_STATE)
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def _on_polish(self, widget: QWidget) -> None:
|
|
130
|
+
"""Handle Polish events — evaluate initial widget state on first polish."""
|
|
131
|
+
ctx = self._contexts.get(id(widget))
|
|
132
|
+
# Ignore Polish events triggered by our own internal style writes (e.g. during class change unpolish/polish,
|
|
133
|
+
# or natural size calculation).
|
|
134
|
+
if ctx is not None and ctx.internal_write_depth > 0:
|
|
135
|
+
return
|
|
136
|
+
# Wire up the toggled signal for checkable widgets (idempotent).
|
|
137
|
+
self._connect_checkable(widget)
|
|
138
|
+
# Deferred Polish events arrive after _get_natural_size's setStyleSheet calls restore the
|
|
139
|
+
# inline style. At that point animations are already running — snapping them would kill the
|
|
140
|
+
# transition. Any class-change or pseudo-state re-evaluation that truly matters is driven
|
|
141
|
+
# by _on_class_change / _evaluate_widget_state directly; Polish is only needed for initial
|
|
142
|
+
# widget setup (active_animations is empty then).
|
|
143
|
+
if ctx is not None and ctx.active_animations:
|
|
144
|
+
return
|
|
145
|
+
self._evaluate_widget_state(widget, cause=EvaluationCause.POLISH)
|
|
146
|
+
|
|
147
|
+
def _on_class_change(self, widget: QWidget) -> None:
|
|
148
|
+
"""Handle class property change — snapshot size, unpolish/polish, and kick off animations."""
|
|
149
|
+
ctx = self._ctx(widget)
|
|
150
|
+
# Snapshot actual size before Qt's polish snaps it to the new stylesheet values.
|
|
151
|
+
ctx.pre_polish_size = (widget.width(), widget.height())
|
|
152
|
+
# Invalidate rule cache — class changed.
|
|
153
|
+
self._rule_cache.clear()
|
|
154
|
+
# Guard the synchronous Polish so it doesn't snap animated props before we animate them.
|
|
155
|
+
ctx.internal_write_depth += 1
|
|
156
|
+
ctx.internal_write_reason = InternalWriteReason.CLASS_CHANGE
|
|
157
|
+
try:
|
|
158
|
+
style = widget.style()
|
|
159
|
+
if style is not None:
|
|
160
|
+
style.unpolish(widget)
|
|
161
|
+
style.polish(widget)
|
|
162
|
+
finally:
|
|
163
|
+
ctx.internal_write_depth -= 1
|
|
164
|
+
if ctx.internal_write_depth == 0:
|
|
165
|
+
ctx.internal_write_reason = None
|
|
166
|
+
widget.update()
|
|
167
|
+
# Fresh generation — stale finished callbacks from prior class changes become no-ops.
|
|
168
|
+
ctx.class_anim_gen += 1
|
|
169
|
+
ctx.class_anim_props.clear()
|
|
170
|
+
self._evaluate_widget_state(widget, cause=EvaluationCause.CLASS_CHANGE)
|
|
171
|
+
ctx.pre_polish_size = None
|
|
172
|
+
|
|
173
|
+
def _on_window_deactivate(self, widget: QWidget) -> None:
|
|
174
|
+
"""Clear stuck :hover/:pressed states when the window loses focus."""
|
|
175
|
+
# Qt may not deliver HoverLeave when a child dialog steals focus.
|
|
176
|
+
# Clear stuck :hover/:pressed so widgets don't remain frozen in the highlighted state.
|
|
177
|
+
_TRANSIENT_PSEUDOS = {":hover", ":pressed"}
|
|
178
|
+
for child in widget.findChildren(QWidget):
|
|
179
|
+
ctx = self._contexts.get(id(child))
|
|
180
|
+
if ctx is None:
|
|
181
|
+
continue
|
|
182
|
+
stuck = ctx.active_pseudos & _TRANSIENT_PSEUDOS
|
|
183
|
+
if stuck:
|
|
184
|
+
ctx.active_pseudos -= stuck
|
|
185
|
+
self._evaluate_widget_state(child, cause=EvaluationCause.WINDOW_DEACTIVATE)
|
|
186
|
+
|
|
187
|
+
def _connect_checkable(self, widget: QWidget) -> None:
|
|
188
|
+
"""Connect to toggled signal for checkable buttons and sync initial :checked state."""
|
|
189
|
+
if not isinstance(widget, QAbstractButton):
|
|
190
|
+
return
|
|
191
|
+
wid = id(widget)
|
|
192
|
+
if wid in self._connected_checkable_ids:
|
|
193
|
+
return
|
|
194
|
+
self._connected_checkable_ids.add(wid)
|
|
195
|
+
if widget.isChecked():
|
|
196
|
+
self._ctx(widget).active_pseudos.add(":checked")
|
|
197
|
+
|
|
198
|
+
def _on_toggle(checked: bool, w: QWidget = widget) -> None:
|
|
199
|
+
self._on_checked_changed(w, checked)
|
|
200
|
+
|
|
201
|
+
widget.toggled.connect(_on_toggle)
|
|
202
|
+
|
|
203
|
+
def _on_checked_changed(self, widget: QWidget, checked: bool) -> None:
|
|
204
|
+
"""Sync :checked pseudo-state and re-evaluate transitions on button toggle."""
|
|
205
|
+
ctx = self._ctx(widget)
|
|
206
|
+
if checked:
|
|
207
|
+
ctx.active_pseudos.add(":checked")
|
|
208
|
+
else:
|
|
209
|
+
ctx.active_pseudos.discard(":checked")
|
|
210
|
+
self._evaluate_widget_state(widget, cause=EvaluationCause.PSEUDO_STATE)
|
|
211
|
+
|
|
212
|
+
def _update_pseudos(self, pseudos: set[str], event_type: QEvent.Type) -> set[str]:
|
|
213
|
+
"""Return an updated pseudo-state set reflecting the given Qt event."""
|
|
214
|
+
updated = pseudos.copy()
|
|
215
|
+
if event_type == QEvent.Type.HoverEnter:
|
|
216
|
+
updated.add(":hover")
|
|
217
|
+
elif event_type == QEvent.Type.HoverLeave:
|
|
218
|
+
updated.discard(":hover")
|
|
219
|
+
elif event_type in (QEvent.Type.MouseButtonPress, QEvent.Type.MouseButtonDblClick):
|
|
220
|
+
updated.add(":pressed")
|
|
221
|
+
elif event_type == QEvent.Type.MouseButtonRelease:
|
|
222
|
+
updated.discard(":pressed")
|
|
223
|
+
elif event_type == QEvent.Type.FocusIn:
|
|
224
|
+
updated.add(":focus")
|
|
225
|
+
elif event_type == QEvent.Type.FocusOut:
|
|
226
|
+
updated.discard(":focus")
|
|
227
|
+
return updated
|
|
228
|
+
|
|
229
|
+
# -------------------------------------------------------------------------
|
|
230
|
+
# Quick-filter construction
|
|
231
|
+
# -------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def _build_quick_filters(self) -> None:
|
|
234
|
+
"""Rebuild animated tag/class/id sets from current rules for fast pre-filtering."""
|
|
235
|
+
self._animated_tags.clear()
|
|
236
|
+
self._animated_classes.clear()
|
|
237
|
+
self._animated_ids.clear()
|
|
238
|
+
self._has_effect_rules = False
|
|
239
|
+
self._has_cursor_rules = False
|
|
240
|
+
|
|
241
|
+
for rule in self.rules:
|
|
242
|
+
if rule.subcontrol:
|
|
243
|
+
continue
|
|
244
|
+
has_effect_props = any(p in EFFECT_PROPS for p in rule.properties)
|
|
245
|
+
has_cursor_props = "cursor" in rule.properties
|
|
246
|
+
if not rule.transitions and not has_effect_props and not has_cursor_props:
|
|
247
|
+
continue
|
|
248
|
+
if has_effect_props or any(t.prop in ("opacity", "all") for t in rule.transitions):
|
|
249
|
+
self._has_effect_rules = True
|
|
250
|
+
if has_cursor_props:
|
|
251
|
+
self._has_cursor_rules = True
|
|
252
|
+
last_segment = rule.segments[-1]
|
|
253
|
+
if last_segment.startswith("#"):
|
|
254
|
+
self._animated_ids.add(last_segment.split(".")[0][1:])
|
|
255
|
+
elif last_segment.startswith("."):
|
|
256
|
+
for cls in last_segment.split(".")[1:]:
|
|
257
|
+
self._animated_classes.add(cls)
|
|
258
|
+
else:
|
|
259
|
+
parts = last_segment.split(".")
|
|
260
|
+
if parts[0]:
|
|
261
|
+
self._animated_tags.add(parts[0])
|
|
262
|
+
for cls in parts[1:]:
|
|
263
|
+
self._animated_classes.add(cls)
|
|
264
|
+
|
|
265
|
+
# -------------------------------------------------------------------------
|
|
266
|
+
# Widget selector matching
|
|
267
|
+
# -------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def _should_evaluate(self, widget: QWidget) -> bool:
|
|
270
|
+
"""Return True if the widget could be affected by any animated CSS rule."""
|
|
271
|
+
ctx = self._contexts.get(id(widget))
|
|
272
|
+
if ctx is not None and ctx.active_animations:
|
|
273
|
+
return True
|
|
274
|
+
if self._animated_ids and widget.objectName() in self._animated_ids:
|
|
275
|
+
return True
|
|
276
|
+
if self._animated_tags and type(widget).__name__ in self._animated_tags:
|
|
277
|
+
return True
|
|
278
|
+
if self._animated_classes:
|
|
279
|
+
if any(cls in self._animated_classes for cls in self._widget_classes(widget)):
|
|
280
|
+
return True
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
@staticmethod
|
|
284
|
+
def _widget_classes(widget: QWidget) -> list[str]:
|
|
285
|
+
"""Return the CSS class tokens from the widget's 'class' property."""
|
|
286
|
+
raw: str = widget.property("class") or ""
|
|
287
|
+
return raw.split()
|
|
288
|
+
|
|
289
|
+
def _widget_matches_segment(self, widget: QWidget, segment: str) -> bool:
|
|
290
|
+
"""Return True if widget matches a single selector segment (id, class, or tag)."""
|
|
291
|
+
if segment.startswith("#"):
|
|
292
|
+
parts = segment.split(".")
|
|
293
|
+
if widget.objectName() != parts[0][1:]:
|
|
294
|
+
return False
|
|
295
|
+
if len(parts) > 1:
|
|
296
|
+
return all(cls in self._widget_classes(widget) for cls in parts[1:])
|
|
297
|
+
return True
|
|
298
|
+
if segment.startswith("."):
|
|
299
|
+
parts = segment.split(".")
|
|
300
|
+
return all(cls in self._widget_classes(widget) for cls in parts[1:])
|
|
301
|
+
parts = segment.split(".")
|
|
302
|
+
tag_name = parts[0]
|
|
303
|
+
if tag_name and type(widget).__name__ != tag_name:
|
|
304
|
+
return False
|
|
305
|
+
if len(parts) > 1:
|
|
306
|
+
return all(cls in self._widget_classes(widget) for cls in parts[1:])
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
def _matches(self, widget: QWidget, rule: StyleRule) -> bool:
|
|
310
|
+
"""Return True if widget matches a full descendant-combinator selector."""
|
|
311
|
+
segments = rule.segments
|
|
312
|
+
if not segments:
|
|
313
|
+
return False
|
|
314
|
+
if not self._widget_matches_segment(widget, segments[-1]):
|
|
315
|
+
return False
|
|
316
|
+
if len(segments) == 1:
|
|
317
|
+
return True
|
|
318
|
+
seg_idx = len(segments) - 2
|
|
319
|
+
ancestor: QObject | None = widget.parent()
|
|
320
|
+
while ancestor and seg_idx >= 0:
|
|
321
|
+
if isinstance(ancestor, QWidget) and self._widget_matches_segment(ancestor, segments[seg_idx]):
|
|
322
|
+
seg_idx -= 1
|
|
323
|
+
ancestor = ancestor.parent()
|
|
324
|
+
return seg_idx < 0
|
|
325
|
+
|
|
326
|
+
def _matching_rules(self, widget: QWidget) -> list[StyleRule]:
|
|
327
|
+
"""
|
|
328
|
+
Return rules matching widget, using per-widget cached results when possible.
|
|
329
|
+
|
|
330
|
+
Keyed by id(widget) rather than a type/class signature so that widgets with the
|
|
331
|
+
same CSS class but different ancestors each get their own correct cache entry.
|
|
332
|
+
"""
|
|
333
|
+
wid = id(widget)
|
|
334
|
+
cached = self._rule_cache.get(wid)
|
|
335
|
+
if cached is not None:
|
|
336
|
+
return cached
|
|
337
|
+
result = [rule for rule in self.rules if self._matches(widget, rule)]
|
|
338
|
+
self._rule_cache[wid] = result
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
# -------------------------------------------------------------------------
|
|
342
|
+
# Widget lifecycle tracking
|
|
343
|
+
# -------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def _connect_destroyed(self, widget: QWidget) -> None:
|
|
346
|
+
"""Connect widget.destroyed to the cleanup handler (idempotent)."""
|
|
347
|
+
if id(widget) in self._connected_widgets:
|
|
348
|
+
return
|
|
349
|
+
self._connected_widgets.add(id(widget))
|
|
350
|
+
widget.destroyed.connect(lambda: self._on_widget_destroyed(widget))
|
|
351
|
+
|
|
352
|
+
def _on_widget_destroyed(self, widget: QWidget) -> None:
|
|
353
|
+
"""Remove all engine state for a destroyed widget and stop its animations."""
|
|
354
|
+
wid = id(widget)
|
|
355
|
+
self._connected_widgets.discard(wid)
|
|
356
|
+
self._connected_checkable_ids.discard(wid)
|
|
357
|
+
self._rule_cache.pop(wid, None)
|
|
358
|
+
ctx = self._contexts.pop(wid, None)
|
|
359
|
+
if ctx is None:
|
|
360
|
+
return
|
|
361
|
+
for timer in ctx.pending_delays.values():
|
|
362
|
+
try:
|
|
363
|
+
timer.stop()
|
|
364
|
+
timer.deleteLater()
|
|
365
|
+
except RuntimeError:
|
|
366
|
+
pass
|
|
367
|
+
ctx.pending_delays.clear()
|
|
368
|
+
for prop, cb in ctx.class_anim_callbacks.items():
|
|
369
|
+
anim_obj = ctx.active_animations.get(prop)
|
|
370
|
+
if anim_obj is not None:
|
|
371
|
+
try:
|
|
372
|
+
anim_obj.anim.finished.disconnect(cb)
|
|
373
|
+
except RuntimeError, TypeError:
|
|
374
|
+
pass
|
|
375
|
+
ctx.class_anim_callbacks.clear()
|
|
376
|
+
for anim_obj in ctx.active_animations.values():
|
|
377
|
+
try:
|
|
378
|
+
anim_obj.anim.stop()
|
|
379
|
+
anim_obj.deleteLater()
|
|
380
|
+
except RuntimeError:
|
|
381
|
+
pass
|
|
382
|
+
ctx.active_animations.clear()
|
|
383
|
+
|
|
384
|
+
# -------------------------------------------------------------------------
|
|
385
|
+
# State evaluation
|
|
386
|
+
# -------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
def _evaluate_widget_state(self, widget: QWidget, cause: EvaluationCause = EvaluationCause.DIRECT) -> None:
|
|
389
|
+
"""Evaluate all animated CSS properties for widget and start, update, or snap animations."""
|
|
390
|
+
if not self._should_evaluate(widget):
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
ctx = self._ctx(widget)
|
|
394
|
+
|
|
395
|
+
# Fast path for brand-new widgets being polished in their base state:
|
|
396
|
+
# Qt's app stylesheet already has all base-state values (animated props are only
|
|
397
|
+
# stripped from pseudo-state blocks in the cleaned QSS, not from the base block).
|
|
398
|
+
# Effect props (opacity, box-shadow) need their QGraphicsEffect initialised even at
|
|
399
|
+
# base state, so skip only when there are no effect rules at all.
|
|
400
|
+
if (
|
|
401
|
+
cause.snaps_transitions
|
|
402
|
+
and not ctx.css_anim_props
|
|
403
|
+
and not ctx.active_animations
|
|
404
|
+
and not self._has_effect_rules
|
|
405
|
+
and not self._has_cursor_rules
|
|
406
|
+
):
|
|
407
|
+
return
|
|
408
|
+
(
|
|
409
|
+
base_props,
|
|
410
|
+
target_props,
|
|
411
|
+
target_transitions,
|
|
412
|
+
all_animated_props,
|
|
413
|
+
) = self._collect_rule_state(widget, ctx)
|
|
414
|
+
needs_style_update = False
|
|
415
|
+
for prop in all_animated_props:
|
|
416
|
+
if self._apply_prop_animation(widget, ctx, prop, base_props, target_props, target_transitions, cause):
|
|
417
|
+
needs_style_update = True
|
|
418
|
+
if self._cleanup_orphans(widget, ctx, all_animated_props, base_props):
|
|
419
|
+
needs_style_update = True
|
|
420
|
+
if needs_style_update:
|
|
421
|
+
widget.setStyleSheet(scoped_anim_style(widget, ctx.css_anim_props))
|
|
422
|
+
self._apply_cursor(widget, ctx, target_props)
|
|
423
|
+
|
|
424
|
+
def _collect_rule_state(
|
|
425
|
+
self, widget: QWidget, ctx: WidgetContext
|
|
426
|
+
) -> tuple[dict[str, str], dict[str, str], dict[str, TransitionSpec], set[str]]:
|
|
427
|
+
"""
|
|
428
|
+
Evaluate the CSS cascade for widget.
|
|
429
|
+
|
|
430
|
+
Returns (base_props, target_props, target_transitions, all_animated_props).
|
|
431
|
+
CSS cascade semantics: most-specific pseudo wins; equal specificity uses last-in-stylesheet order.
|
|
432
|
+
"""
|
|
433
|
+
base_props: dict[str, str] = {}
|
|
434
|
+
target_props: dict[str, str] = {}
|
|
435
|
+
target_transitions: dict[str, TransitionSpec] = {}
|
|
436
|
+
trans_priority: dict[str, int] = {}
|
|
437
|
+
all_animated_props: set[str] = set()
|
|
438
|
+
pseudos = ctx.active_pseudos
|
|
439
|
+
for rule in self._matching_rules(widget):
|
|
440
|
+
rule_in_target = not rule.pseudo_set or rule.pseudo_set <= pseudos
|
|
441
|
+
priority = sum(self.pseudo_priority.get(p, 0) for p in rule.pseudo_set) if rule_in_target else -1
|
|
442
|
+
for trans in rule.transitions:
|
|
443
|
+
all_animated_props.add(trans.prop)
|
|
444
|
+
if rule_in_target and priority >= trans_priority.get(trans.prop, -1):
|
|
445
|
+
target_transitions[trans.prop] = trans
|
|
446
|
+
trans_priority[trans.prop] = priority
|
|
447
|
+
if not rule.pseudo_set:
|
|
448
|
+
base_props.update(rule.properties)
|
|
449
|
+
if rule_in_target:
|
|
450
|
+
target_props.update(rule.properties)
|
|
451
|
+
# Expand `transition: all` to every animatable property present in base/target.
|
|
452
|
+
if "all" in all_animated_props:
|
|
453
|
+
all_spec = target_transitions.pop("all", None)
|
|
454
|
+
all_animated_props.discard("all")
|
|
455
|
+
for p in set(base_props) | set(target_props):
|
|
456
|
+
if self._is_animatable(p):
|
|
457
|
+
all_animated_props.add(p)
|
|
458
|
+
if p not in target_transitions and all_spec is not None:
|
|
459
|
+
target_transitions[p] = all_spec
|
|
460
|
+
# Engine-managed props set by a prior class-change may be absent from current rules
|
|
461
|
+
# (they're reverting to natural). `transition: all` must animate the return trip too.
|
|
462
|
+
if all_spec is not None:
|
|
463
|
+
engine_managed: set[str] = set(ctx.css_anim_props)
|
|
464
|
+
engine_managed |= set(ctx.active_animations)
|
|
465
|
+
for p in engine_managed:
|
|
466
|
+
if p not in all_animated_props and self._is_animatable(p) and p not in EFFECT_PROPS:
|
|
467
|
+
all_animated_props.add(p)
|
|
468
|
+
target_transitions[p] = all_spec
|
|
469
|
+
# Effect props need engine handling even without a transition declaration.
|
|
470
|
+
for p in EFFECT_PROPS:
|
|
471
|
+
if p in base_props or p in target_props:
|
|
472
|
+
all_animated_props.add(p)
|
|
473
|
+
return base_props, target_props, target_transitions, all_animated_props
|
|
474
|
+
|
|
475
|
+
def _apply_prop_animation(
|
|
476
|
+
self,
|
|
477
|
+
widget: QWidget,
|
|
478
|
+
ctx: WidgetContext,
|
|
479
|
+
prop: str,
|
|
480
|
+
base_props: dict[str, str],
|
|
481
|
+
target_props: dict[str, str],
|
|
482
|
+
target_transitions: dict[str, TransitionSpec],
|
|
483
|
+
cause: EvaluationCause,
|
|
484
|
+
) -> bool:
|
|
485
|
+
"""Drive the animation for a single property. Returns True if a batched style update is needed."""
|
|
486
|
+
# Cancel any pending delay for this prop unconditionally — the widget state has changed
|
|
487
|
+
# (new pseudo, class-change, window deactivate, etc.) and a fresh evaluation is in progress.
|
|
488
|
+
old_timer = ctx.pending_delays.pop(prop, None)
|
|
489
|
+
if old_timer is not None:
|
|
490
|
+
old_timer.stop()
|
|
491
|
+
old_timer.deleteLater()
|
|
492
|
+
|
|
493
|
+
# Class-change animations take priority over pseudo-state changes (hover/focus).
|
|
494
|
+
# Defer until the class-change animation finishes, then re-evaluate picks up hover.
|
|
495
|
+
if not cause.is_class_driven and prop in ctx.class_anim_props:
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
target_raw, is_natural_target = self._resolve_target_raw(widget, base_props, target_props, prop)
|
|
499
|
+
if not target_raw:
|
|
500
|
+
return False
|
|
501
|
+
|
|
502
|
+
base_raw = base_props.get(prop)
|
|
503
|
+
if not base_raw or base_raw == "auto":
|
|
504
|
+
base_raw = get_preferred_size_fallback(widget, base_props, prop) if prop in SIZE_PROPS else target_raw
|
|
505
|
+
|
|
506
|
+
anim_obj = ctx.active_animations.get(prop)
|
|
507
|
+
|
|
508
|
+
# Natural-state target with nothing running: Qt lays out at natural size without intervention.
|
|
509
|
+
# Exception: if a frozen inline constraint exists (written during a pending delay), we must
|
|
510
|
+
# animate to clear it — Qt cannot reach the natural size while the inline style constrains it.
|
|
511
|
+
if is_natural_target and not anim_obj and not ctx.pre_polish_size and prop not in ctx.css_anim_props:
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
# clean_on_finish already removed this prop — animation completed successfully and the
|
|
515
|
+
# widget is at its natural layout size. Any re-evaluation triggered by _on_class_anim_done
|
|
516
|
+
# must not restart the animation toward get_preferred_size_fallback (sizeHint), which would
|
|
517
|
+
# be wrong for stretch-fill widgets whose natural width > sizeHint.
|
|
518
|
+
if is_natural_target and anim_obj and prop not in ctx.css_anim_props:
|
|
519
|
+
return False
|
|
520
|
+
|
|
521
|
+
# No transition declared → snap.
|
|
522
|
+
if prop not in target_transitions:
|
|
523
|
+
return self._snap_prop_or_effect(widget, ctx, prop, anim_obj, target_raw, is_natural_target)
|
|
524
|
+
|
|
525
|
+
trans = target_transitions[prop]
|
|
526
|
+
if trans.duration_ms == 0 or not self.animations_enabled or cause.snaps_transitions:
|
|
527
|
+
return self._snap_prop_or_effect(widget, ctx, prop, anim_obj, target_raw, is_natural_target)
|
|
528
|
+
|
|
529
|
+
# Animated path: create or update animation object, then point it at the new target.
|
|
530
|
+
curve = self._resolve_easing_curve(trans.easing)
|
|
531
|
+
is_running = anim_obj is not None and anim_obj.anim.state() == QAbstractAnimation.State.Running
|
|
532
|
+
|
|
533
|
+
if not is_running:
|
|
534
|
+
# Delay applies on both fresh starts and re-starts of stopped/finished animations.
|
|
535
|
+
# Only re-targeting an actively running animation skips the delay.
|
|
536
|
+
if trans.delay_ms > 0 and cause is not EvaluationCause.DELAY_FIRE:
|
|
537
|
+
# Freeze the current rendered value so Qt doesn't immediately apply the new
|
|
538
|
+
# class/state values during the delay period (class-based QSS rules are not
|
|
539
|
+
# stripped, so without this the widget would visually snap to the new state).
|
|
540
|
+
# Effect props (opacity, box-shadow) are managed via QGraphicsEffect — no inline
|
|
541
|
+
# style needed, and their value is not held in css_anim_props.
|
|
542
|
+
if prop not in EFFECT_PROPS:
|
|
543
|
+
current_raw = self._resolve_current_raw(widget, ctx, prop, base_props, base_raw)
|
|
544
|
+
ctx.css_anim_props[prop] = current_raw
|
|
545
|
+
self._schedule_delayed_animation(widget, ctx, prop, trans.delay_ms)
|
|
546
|
+
return prop not in EFFECT_PROPS # needs setStyleSheet iff we wrote css_anim_props
|
|
547
|
+
if anim_obj is None:
|
|
548
|
+
current_raw = self._resolve_current_raw(widget, ctx, prop, base_props, base_raw)
|
|
549
|
+
anim_obj = self._create_animation_obj(widget, prop, current_raw, trans.duration_ms, curve)
|
|
550
|
+
if anim_obj:
|
|
551
|
+
self._register_animation(widget, ctx, prop, anim_obj)
|
|
552
|
+
else:
|
|
553
|
+
assert anim_obj is not None # is_running=False but anim_obj exists → stopped
|
|
554
|
+
# Stopped animation — update spec before re-targeting.
|
|
555
|
+
anim_obj.update_spec(trans.duration_ms, curve)
|
|
556
|
+
else:
|
|
557
|
+
assert anim_obj is not None # is_running=True implies anim_obj is not None
|
|
558
|
+
# Running: re-target mid-flight without delay.
|
|
559
|
+
anim_obj.update_spec(trans.duration_ms, curve)
|
|
560
|
+
|
|
561
|
+
if anim_obj:
|
|
562
|
+
if is_natural_target and isinstance(anim_obj, GenericPropertyAnimation):
|
|
563
|
+
# target_raw was computed by _get_natural_size (layout-assigned natural width),
|
|
564
|
+
# so use it directly rather than the stale anim_obj.natural_val.
|
|
565
|
+
anim_obj.set_target(target_raw, clean_on_finish=True)
|
|
566
|
+
else:
|
|
567
|
+
anim_obj.set_target(target_raw)
|
|
568
|
+
# Negative transition-delay: animation starts immediately but offset |delay| ms into
|
|
569
|
+
# the timeline, as if it had already been running that long (CSS spec §transition-delay).
|
|
570
|
+
# Only on fresh starts; re-targeting a running animation skips this.
|
|
571
|
+
if not is_running and trans.delay_ms < 0:
|
|
572
|
+
seek_ms = min(-trans.delay_ms, trans.duration_ms)
|
|
573
|
+
anim_obj.anim.setCurrentTime(seek_ms)
|
|
574
|
+
|
|
575
|
+
# Track class-change-initiated animations; re-evaluate on finish for deferred hover.
|
|
576
|
+
if cause.is_class_driven and anim_obj.anim.state() == QAbstractAnimation.State.Running:
|
|
577
|
+
ctx.class_anim_props.add(prop)
|
|
578
|
+
gen = ctx.class_anim_gen
|
|
579
|
+
wid = id(widget)
|
|
580
|
+
|
|
581
|
+
# Disconnect the previous callback for this prop before connecting a new one.
|
|
582
|
+
# Without this, rapid class changes accumulate closures on the finished signal.
|
|
583
|
+
old_cb = ctx.class_anim_callbacks.pop(prop, None)
|
|
584
|
+
if old_cb is not None:
|
|
585
|
+
try:
|
|
586
|
+
anim_obj.anim.finished.disconnect(old_cb)
|
|
587
|
+
except RuntimeError, TypeError:
|
|
588
|
+
pass
|
|
589
|
+
|
|
590
|
+
def _on_class_anim_done(_w: QWidget = widget, _p: str = prop, _wid: int = wid, _gen: int = gen) -> None:
|
|
591
|
+
# Do NOT pop from class_anim_callbacks here — the next class change will
|
|
592
|
+
# disconnect and replace this. Self-popping causes the next click to find
|
|
593
|
+
# no old callback to disconnect, re-connecting a second slot on the same signal.
|
|
594
|
+
c = self._contexts.get(_wid)
|
|
595
|
+
if c and _gen == c.class_anim_gen and _p in c.class_anim_props:
|
|
596
|
+
c.class_anim_props.discard(_p)
|
|
597
|
+
# Re-evaluate immediately so this prop can pick up deferred hover/focus
|
|
598
|
+
# changes as soon as its class animation unblocks it. Other props that
|
|
599
|
+
# are still class-animating stay blocked via the class_anim_props check
|
|
600
|
+
# in _apply_prop_animation, so their animations are not disturbed.
|
|
601
|
+
self._evaluate_widget_state(_w, cause=EvaluationCause.CLASS_ANIMATION_FINISH)
|
|
602
|
+
|
|
603
|
+
ctx.class_anim_callbacks[prop] = _on_class_anim_done
|
|
604
|
+
anim_obj.anim.finished.connect(_on_class_anim_done)
|
|
605
|
+
return False
|
|
606
|
+
|
|
607
|
+
def _resolve_target_raw(
|
|
608
|
+
self,
|
|
609
|
+
widget: QWidget,
|
|
610
|
+
base_props: dict[str, str],
|
|
611
|
+
target_props: dict[str, str],
|
|
612
|
+
prop: str,
|
|
613
|
+
) -> tuple[str, bool]:
|
|
614
|
+
"""
|
|
615
|
+
Resolve the CSS target value and whether it's a natural (unconstrained) target.
|
|
616
|
+
|
|
617
|
+
A "natural target" means no explicit CSS value or 'auto' — the widget should
|
|
618
|
+
return to its unconstrained layout size. For size props we animate toward sizeHint()
|
|
619
|
+
then remove the constraint via clean_on_finish=True.
|
|
620
|
+
|
|
621
|
+
Returns (target_raw, is_natural_target). target_raw is "" when there is no value
|
|
622
|
+
and the property is non-animatable (caller should return early).
|
|
623
|
+
"""
|
|
624
|
+
target_raw = target_props.get(prop) or base_props.get(prop)
|
|
625
|
+
is_natural_target = prop in SIZE_PROPS and (not target_raw or target_raw == "auto")
|
|
626
|
+
if target_raw == "auto":
|
|
627
|
+
target_raw = self._get_natural_size(widget, base_props, prop)
|
|
628
|
+
if not target_raw:
|
|
629
|
+
if prop in SIZE_PROPS:
|
|
630
|
+
target_raw = self._get_natural_size(widget, base_props, prop)
|
|
631
|
+
elif "color" in prop:
|
|
632
|
+
target_raw = "white" if prop == "color" else "transparent"
|
|
633
|
+
return target_raw or "", is_natural_target
|
|
634
|
+
|
|
635
|
+
def _refresh_natural_val(
|
|
636
|
+
self,
|
|
637
|
+
anim_obj: Animation,
|
|
638
|
+
widget: QWidget,
|
|
639
|
+
ctx: WidgetContext,
|
|
640
|
+
base_props: dict[str, str],
|
|
641
|
+
prop: str,
|
|
642
|
+
is_natural_target: bool,
|
|
643
|
+
) -> None:
|
|
644
|
+
"""
|
|
645
|
+
Update natural_val when returning to natural size after a class-change.
|
|
646
|
+
|
|
647
|
+
natural_val may hold the old constrained size from a prior hover animation.
|
|
648
|
+
Re-derive it from sizeHint() which reflects the unconstrained preferred size
|
|
649
|
+
immediately after polish, before the layout has reflowed.
|
|
650
|
+
Only applicable when pre_polish_size is set (i.e. a class-change is in progress).
|
|
651
|
+
"""
|
|
652
|
+
if not (
|
|
653
|
+
is_natural_target and isinstance(anim_obj, GenericPropertyAnimation) and ctx.pre_polish_size is not None
|
|
654
|
+
):
|
|
655
|
+
return
|
|
656
|
+
natural_str = self._get_natural_size(widget, base_props, prop)
|
|
657
|
+
natural_num = parse_css_val(natural_str)
|
|
658
|
+
if isinstance(natural_num, (int, float)):
|
|
659
|
+
anim_obj.natural_val = float(natural_num)
|
|
660
|
+
|
|
661
|
+
def _cleanup_orphans(
|
|
662
|
+
self, _widget: QWidget, ctx: WidgetContext, all_animated_props: set[str], base_props: dict[str, str]
|
|
663
|
+
) -> bool:
|
|
664
|
+
"""
|
|
665
|
+
Snap/stop animations for props no longer covered by any rule (e.g. a class was removed).
|
|
666
|
+
|
|
667
|
+
Returns True if a batched style update is needed.
|
|
668
|
+
"""
|
|
669
|
+
needs_update = False
|
|
670
|
+
# Cancel pending delay timers for props that are no longer covered by any rule.
|
|
671
|
+
for prop in list(ctx.pending_delays.keys()):
|
|
672
|
+
if prop not in all_animated_props:
|
|
673
|
+
t = ctx.pending_delays.pop(prop)
|
|
674
|
+
t.stop()
|
|
675
|
+
t.deleteLater()
|
|
676
|
+
for prop, orphan in list(ctx.active_animations.items()):
|
|
677
|
+
if prop in all_animated_props:
|
|
678
|
+
continue
|
|
679
|
+
ctx.class_anim_props.discard(prop)
|
|
680
|
+
old_cb = ctx.class_anim_callbacks.pop(prop, None)
|
|
681
|
+
if old_cb is not None:
|
|
682
|
+
try:
|
|
683
|
+
orphan.anim.finished.disconnect(old_cb)
|
|
684
|
+
except RuntimeError, TypeError:
|
|
685
|
+
pass
|
|
686
|
+
snap_target = base_props.get(prop)
|
|
687
|
+
if snap_target == "auto":
|
|
688
|
+
snap_target = None
|
|
689
|
+
is_natural_snap = not snap_target and prop in SIZE_PROPS
|
|
690
|
+
if is_natural_snap:
|
|
691
|
+
snap_target = get_preferred_size_fallback(orphan.widget, base_props, prop)
|
|
692
|
+
if snap_target:
|
|
693
|
+
if is_natural_snap and isinstance(orphan, GenericPropertyAnimation):
|
|
694
|
+
orphan.snap_to_natural()
|
|
695
|
+
else:
|
|
696
|
+
orphan.snap_to(snap_target)
|
|
697
|
+
else:
|
|
698
|
+
orphan.anim.stop()
|
|
699
|
+
if isinstance(orphan, BoxShadowHandle):
|
|
700
|
+
apply_shadow_to_widget(orphan.widget, None, self.effect_priority)
|
|
701
|
+
elif isinstance(orphan, OpacityAnimation):
|
|
702
|
+
try:
|
|
703
|
+
orphan.widget.setGraphicsEffect(None)
|
|
704
|
+
except RuntimeError:
|
|
705
|
+
pass
|
|
706
|
+
if not isinstance(orphan, (OpacityAnimation, BoxShadowHandle)):
|
|
707
|
+
needs_update = True
|
|
708
|
+
del ctx.active_animations[prop]
|
|
709
|
+
orphan.deleteLater()
|
|
710
|
+
# Also evict stale snapped props: entries in css_anim_props that are no longer
|
|
711
|
+
# in any rule and have no backing animation object (e.g. a prop was removed from
|
|
712
|
+
# CSS and the widget was re-evaluated via Polish with old rules during hot-reload).
|
|
713
|
+
stale_snapped = {
|
|
714
|
+
p for p in ctx.css_anim_props if p not in all_animated_props and p not in ctx.active_animations
|
|
715
|
+
}
|
|
716
|
+
if stale_snapped:
|
|
717
|
+
for p in stale_snapped:
|
|
718
|
+
del ctx.css_anim_props[p]
|
|
719
|
+
needs_update = True
|
|
720
|
+
return needs_update
|
|
721
|
+
|
|
722
|
+
# -------------------------------------------------------------------------
|
|
723
|
+
# Rule hot-reload
|
|
724
|
+
# -------------------------------------------------------------------------
|
|
725
|
+
|
|
726
|
+
def reload_rules(self, rules: list[StyleRule]) -> None:
|
|
727
|
+
"""
|
|
728
|
+
Hot-reload CSS rules: stop all animations, clear inline styles, update rules.
|
|
729
|
+
|
|
730
|
+
Call this before app.setStyleSheet() so that the Polish events triggered by
|
|
731
|
+
the stylesheet change already see the new rules.
|
|
732
|
+
"""
|
|
733
|
+
animated_widgets: set[QWidget] = set()
|
|
734
|
+
# Widgets with inline animations (ColorAnimation / GenericPropertyAnimation) write to
|
|
735
|
+
# css_anim_props / setStyleSheet, so widget.setStyleSheet("") triggers a Polish event —
|
|
736
|
+
# they must stay on the Polish path and must NOT be touched by the deferred callback.
|
|
737
|
+
inline_widget_ids: set[int] = set()
|
|
738
|
+
for _wid, ctx in list(self._contexts.items()):
|
|
739
|
+
if not ctx.active_animations:
|
|
740
|
+
continue
|
|
741
|
+
# Grab a live widget ref from the first animation object.
|
|
742
|
+
sample = next(iter(ctx.active_animations.values()))
|
|
743
|
+
try:
|
|
744
|
+
sample.widget.objectName()
|
|
745
|
+
animated_widgets.add(sample.widget)
|
|
746
|
+
if any(not isinstance(a, (BoxShadowHandle, OpacityAnimation)) for a in ctx.active_animations.values()):
|
|
747
|
+
inline_widget_ids.add(_wid)
|
|
748
|
+
except RuntimeError:
|
|
749
|
+
pass
|
|
750
|
+
for _wid, ctx in list(self._contexts.items()):
|
|
751
|
+
for timer in ctx.pending_delays.values():
|
|
752
|
+
try:
|
|
753
|
+
timer.stop()
|
|
754
|
+
timer.deleteLater()
|
|
755
|
+
except RuntimeError:
|
|
756
|
+
pass
|
|
757
|
+
ctx.pending_delays.clear()
|
|
758
|
+
for anim_obj in ctx.active_animations.values():
|
|
759
|
+
try:
|
|
760
|
+
anim_obj.anim.stop()
|
|
761
|
+
anim_obj.deleteLater()
|
|
762
|
+
except RuntimeError:
|
|
763
|
+
pass
|
|
764
|
+
ctx.active_animations.clear()
|
|
765
|
+
|
|
766
|
+
# Update rules *before* clearing widget state so any Polish events triggered by
|
|
767
|
+
# the setStyleSheet("") calls below already see the new rules and do not re-snap
|
|
768
|
+
# properties that were just removed from CSS.
|
|
769
|
+
self.rules = rules
|
|
770
|
+
self._build_quick_filters()
|
|
771
|
+
self._rule_cache.clear()
|
|
772
|
+
|
|
773
|
+
animated_widget_ids: set[int] = set()
|
|
774
|
+
for widget in animated_widgets:
|
|
775
|
+
try:
|
|
776
|
+
animated_widget_ids.add(id(widget))
|
|
777
|
+
ctx = self._ctx(widget)
|
|
778
|
+
ctx.css_anim_props.clear()
|
|
779
|
+
ctx.active_pseudos.clear()
|
|
780
|
+
widget.setStyleSheet("")
|
|
781
|
+
except RuntimeError:
|
|
782
|
+
pass
|
|
783
|
+
|
|
784
|
+
# Clear stale inline styles from snap-only widgets (those with css_anim_props set but no
|
|
785
|
+
# active Animation object — not included in animated_widgets above). If new rules remove
|
|
786
|
+
# transitions for such a widget, _should_evaluate returns False and it is never
|
|
787
|
+
# re-evaluated, leaving the old inline style permanently overriding the app stylesheet.
|
|
788
|
+
app_inst = QApplication.instance()
|
|
789
|
+
if isinstance(app_inst, QApplication):
|
|
790
|
+
for w in app_inst.allWidgets():
|
|
791
|
+
if id(w) in animated_widget_ids:
|
|
792
|
+
continue
|
|
793
|
+
try:
|
|
794
|
+
ctx = self._contexts.get(id(w))
|
|
795
|
+
if ctx is not None and ctx.css_anim_props:
|
|
796
|
+
ctx.css_anim_props.clear()
|
|
797
|
+
w.setStyleSheet("")
|
|
798
|
+
except RuntimeError:
|
|
799
|
+
pass
|
|
800
|
+
|
|
801
|
+
# Effect-only widgets (box-shadow / opacity with no inline animation) have no inline
|
|
802
|
+
# stylesheet, so widget.setStyleSheet("") above was a no-op for them. If the cleaned
|
|
803
|
+
# QSS is unchanged Qt won't send a Polish event, and the engine would never re-evaluate
|
|
804
|
+
# them. Defer a targeted pass.
|
|
805
|
+
effect_only_widgets = {w for w in animated_widgets if id(w) not in inline_widget_ids}
|
|
806
|
+
prev_animated_ids = {id(w) for w in animated_widgets}
|
|
807
|
+
|
|
808
|
+
QTimer.singleShot(0, lambda: self._reeval_effect_widgets_deferred(effect_only_widgets, prev_animated_ids))
|
|
809
|
+
|
|
810
|
+
def _reeval_effect_widgets_deferred(self, effect_only_widgets: set[QWidget], prev_animated_ids: set[int]) -> None:
|
|
811
|
+
"""Re-evaluate effect-only widgets after a hot-reload stylesheet change."""
|
|
812
|
+
for widget in effect_only_widgets:
|
|
813
|
+
try:
|
|
814
|
+
widget.objectName()
|
|
815
|
+
ctx = self._contexts.get(id(widget))
|
|
816
|
+
if ctx is not None and ctx.active_animations:
|
|
817
|
+
continue
|
|
818
|
+
if self._should_evaluate(widget):
|
|
819
|
+
self._evaluate_widget_state(widget, cause=EvaluationCause.RULE_RELOAD)
|
|
820
|
+
else:
|
|
821
|
+
widget.setGraphicsEffect(None)
|
|
822
|
+
except RuntimeError:
|
|
823
|
+
pass
|
|
824
|
+
if not self._has_effect_rules:
|
|
825
|
+
return
|
|
826
|
+
app = QApplication.instance()
|
|
827
|
+
if not isinstance(app, QApplication):
|
|
828
|
+
return
|
|
829
|
+
for widget in app.allWidgets():
|
|
830
|
+
ctx = self._contexts.get(id(widget))
|
|
831
|
+
if id(widget) in prev_animated_ids:
|
|
832
|
+
continue
|
|
833
|
+
if ctx is not None and ctx.active_animations:
|
|
834
|
+
continue
|
|
835
|
+
if self._should_evaluate(widget):
|
|
836
|
+
self._evaluate_widget_state(widget, cause=EvaluationCause.RULE_RELOAD)
|
|
837
|
+
|
|
838
|
+
# -------------------------------------------------------------------------
|
|
839
|
+
# Animation helpers
|
|
840
|
+
# -------------------------------------------------------------------------
|
|
841
|
+
|
|
842
|
+
def _get_natural_size(self, widget: QWidget, base_props: dict[str, str], prop: str) -> str:
|
|
843
|
+
"""
|
|
844
|
+
Return the widget's unconstrained natural size for prop.
|
|
845
|
+
|
|
846
|
+
Temporarily strips our inline size constraint, activates the parent layout so it
|
|
847
|
+
redistributes space without the constraint, then reads widget.width()/height().
|
|
848
|
+
This gives the true layout-assigned natural size (e.g. stretch-fill width), not
|
|
849
|
+
just sizeHint() which only reflects the widget's intrinsic text/content size.
|
|
850
|
+
|
|
851
|
+
A second layout.activate() in the finally block restores widget geometry to the
|
|
852
|
+
constrained size so there is no visible flash before the animation begins.
|
|
853
|
+
|
|
854
|
+
Falls back to sizeHint()-based measurement when the widget has no parent layout.
|
|
855
|
+
"""
|
|
856
|
+
ctx = self._ctx(widget)
|
|
857
|
+
axis_props = {"width", "min-width", "max-width"} if "width" in prop else {"height", "min-height", "max-height"}
|
|
858
|
+
constrained = {k for k in axis_props if k in ctx.css_anim_props}
|
|
859
|
+
if not constrained:
|
|
860
|
+
return get_preferred_size_fallback(widget, base_props, prop)
|
|
861
|
+
stripped = {k: v for k, v in ctx.css_anim_props.items() if k not in constrained}
|
|
862
|
+
parent = widget.parentWidget()
|
|
863
|
+
parent_layout = parent.layout() if parent is not None else None
|
|
864
|
+
ctx.internal_write_depth += 1
|
|
865
|
+
ctx.internal_write_reason = InternalWriteReason.MEASURE
|
|
866
|
+
try:
|
|
867
|
+
widget.setStyleSheet(scoped_anim_style(widget, stripped))
|
|
868
|
+
# setStyleSheet() calls style.polish() synchronously, which updates
|
|
869
|
+
# widget.maximumWidth/minimumWidth. activate() then assigns the natural size.
|
|
870
|
+
if parent_layout is not None:
|
|
871
|
+
parent_layout.activate()
|
|
872
|
+
raw_px = widget.width() if "width" in prop else widget.height()
|
|
873
|
+
actual = content_box_px(widget, base_props, prop, raw_px)
|
|
874
|
+
result = f"{actual}px" if actual > 0 else get_preferred_size_fallback(widget, base_props, prop)
|
|
875
|
+
else:
|
|
876
|
+
result = get_preferred_size_fallback(widget, base_props, prop)
|
|
877
|
+
finally:
|
|
878
|
+
widget.setStyleSheet(scoped_anim_style(widget, ctx.css_anim_props))
|
|
879
|
+
# Restore the constrained geometry so there is no flash before animation starts.
|
|
880
|
+
if parent_layout is not None:
|
|
881
|
+
parent_layout.activate()
|
|
882
|
+
ctx.internal_write_depth -= 1
|
|
883
|
+
if ctx.internal_write_depth == 0:
|
|
884
|
+
ctx.internal_write_reason = None
|
|
885
|
+
return result
|
|
886
|
+
|
|
887
|
+
def _is_animatable(self, prop: str) -> bool:
|
|
888
|
+
"""Return True if the engine knows how to animate this CSS property."""
|
|
889
|
+
return "color" in prop or prop in EFFECT_PROPS or prop in SUPPORTED_NUMERIC_PROPS
|
|
890
|
+
|
|
891
|
+
def _register_animation(self, widget: QWidget, ctx: WidgetContext, prop: str, anim_obj: Animation) -> None:
|
|
892
|
+
"""Register an animation object and ensure widget destroyed cleanup is wired."""
|
|
893
|
+
ctx.active_animations[prop] = anim_obj
|
|
894
|
+
self._connect_destroyed(widget)
|
|
895
|
+
|
|
896
|
+
def _schedule_delayed_animation(self, widget: QWidget, ctx: WidgetContext, prop: str, delay_ms: int) -> None:
|
|
897
|
+
"""Schedule prop's animation to start after delay_ms, wiring up the destroyed signal."""
|
|
898
|
+
# Ensure the destroyed signal is connected so _on_widget_destroyed can cancel this timer.
|
|
899
|
+
self._connect_destroyed(widget)
|
|
900
|
+
wid = id(widget)
|
|
901
|
+
timer = QTimer(self)
|
|
902
|
+
timer.setSingleShot(True)
|
|
903
|
+
|
|
904
|
+
def _fire(_w: QWidget = widget, _p: str = prop, _wid: int = wid) -> None:
|
|
905
|
+
c = self._contexts.get(_wid)
|
|
906
|
+
if c is not None:
|
|
907
|
+
c.pending_delays.pop(_p, None)
|
|
908
|
+
try:
|
|
909
|
+
self._fire_delayed_prop(_w, _p)
|
|
910
|
+
except RuntimeError:
|
|
911
|
+
pass # C++ widget destroyed between timer start and fire
|
|
912
|
+
|
|
913
|
+
timer.timeout.connect(_fire)
|
|
914
|
+
ctx.pending_delays[prop] = timer
|
|
915
|
+
timer.start(delay_ms)
|
|
916
|
+
|
|
917
|
+
def _fire_delayed_prop(self, widget: QWidget, prop: str) -> None:
|
|
918
|
+
"""Re-evaluate a single property after its transition-delay has elapsed."""
|
|
919
|
+
if not self._should_evaluate(widget):
|
|
920
|
+
return
|
|
921
|
+
ctx = self._ctx(widget)
|
|
922
|
+
base_props, target_props, target_transitions, all_animated_props = self._collect_rule_state(widget, ctx)
|
|
923
|
+
if prop not in all_animated_props:
|
|
924
|
+
return
|
|
925
|
+
needs_update = self._apply_prop_animation(
|
|
926
|
+
widget, ctx, prop, base_props, target_props, target_transitions, EvaluationCause.DELAY_FIRE
|
|
927
|
+
)
|
|
928
|
+
if needs_update:
|
|
929
|
+
widget.setStyleSheet(scoped_anim_style(widget, ctx.css_anim_props))
|
|
930
|
+
|
|
931
|
+
def _snap_prop_or_effect(
|
|
932
|
+
self,
|
|
933
|
+
widget: QWidget,
|
|
934
|
+
ctx: WidgetContext,
|
|
935
|
+
prop: str,
|
|
936
|
+
anim_obj: Animation | None,
|
|
937
|
+
target_raw: str,
|
|
938
|
+
is_natural_target: bool,
|
|
939
|
+
) -> bool:
|
|
940
|
+
"""Snap a property to its target value instantly. Returns True if a batched style update is needed."""
|
|
941
|
+
if anim_obj:
|
|
942
|
+
if is_natural_target and isinstance(anim_obj, GenericPropertyAnimation):
|
|
943
|
+
anim_obj.snap_to_natural()
|
|
944
|
+
else:
|
|
945
|
+
anim_obj.snap_to(target_raw)
|
|
946
|
+
return not isinstance(anim_obj, (OpacityAnimation, BoxShadowHandle))
|
|
947
|
+
if prop in EFFECT_PROPS:
|
|
948
|
+
new_anim = self._create_animation_obj(widget, prop, target_raw, 0, QEasingCurve.Type.Linear)
|
|
949
|
+
if new_anim:
|
|
950
|
+
self._register_animation(widget, ctx, prop, new_anim)
|
|
951
|
+
return False
|
|
952
|
+
if is_natural_target:
|
|
953
|
+
# Snap to natural = remove the inline constraint so Qt lays out at its preferred size.
|
|
954
|
+
if prop in ctx.css_anim_props:
|
|
955
|
+
del ctx.css_anim_props[prop]
|
|
956
|
+
return True
|
|
957
|
+
ctx.css_anim_props[prop] = target_raw
|
|
958
|
+
return True
|
|
959
|
+
|
|
960
|
+
def _apply_cursor(self, widget: QWidget, ctx: WidgetContext, target_props: dict[str, str]) -> None:
|
|
961
|
+
"""Apply the CSS cursor value to widget via setCursor() / unsetCursor()."""
|
|
962
|
+
cursor_val = target_props.get("cursor")
|
|
963
|
+
desired = cursor_val if cursor_val in CURSOR_MAP else None
|
|
964
|
+
if desired == ctx.applied_cursor:
|
|
965
|
+
return
|
|
966
|
+
if desired is not None:
|
|
967
|
+
widget.setCursor(CURSOR_MAP[desired])
|
|
968
|
+
else:
|
|
969
|
+
widget.unsetCursor()
|
|
970
|
+
ctx.applied_cursor = desired
|
|
971
|
+
|
|
972
|
+
def _resolve_easing_curve(self, easing: str) -> QEasingCurve:
|
|
973
|
+
"""Parse a CSS timing-function string into a QEasingCurve."""
|
|
974
|
+
if m := _CUBIC_BEZIER_RE.match(easing):
|
|
975
|
+
return make_cubic_bezier_curve(float(m[1]), float(m[2]), float(m[3]), float(m[4]))
|
|
976
|
+
if m := _STEPS_RE.match(easing):
|
|
977
|
+
return make_steps_curve(int(m[1]), m[2] or "end")
|
|
978
|
+
if easing == "step-start":
|
|
979
|
+
return make_steps_curve(1, "start")
|
|
980
|
+
if easing == "step-end":
|
|
981
|
+
return make_steps_curve(1, "end")
|
|
982
|
+
return QEasingCurve(EASING_MAP.get(easing, QEasingCurve.Type.InOutQuad))
|
|
983
|
+
|
|
984
|
+
def _resolve_current_raw(
|
|
985
|
+
self, widget: QWidget, ctx: WidgetContext, prop: str, base_props: dict[str, str], base_raw: str
|
|
986
|
+
) -> str:
|
|
987
|
+
"""Resolve the CSS value to use as the animation start point.
|
|
988
|
+
|
|
989
|
+
For size props, accounts for border/padding/margin to derive the content-area
|
|
990
|
+
pixel value from the widget's actual rendered geometry (or pre-polish snapshot).
|
|
991
|
+
Falls back to base_raw if no better source is available.
|
|
992
|
+
"""
|
|
993
|
+
current_raw = ctx.css_anim_props.get(prop)
|
|
994
|
+
if current_raw is None:
|
|
995
|
+
if prop in SIZE_PROPS:
|
|
996
|
+
pre_polish = ctx.pre_polish_size
|
|
997
|
+
if "width" in prop:
|
|
998
|
+
raw_px = pre_polish[0] if pre_polish is not None else widget.width()
|
|
999
|
+
else:
|
|
1000
|
+
raw_px = pre_polish[1] if pre_polish is not None else widget.height()
|
|
1001
|
+
actual = content_box_px(widget, base_props, prop, raw_px)
|
|
1002
|
+
current_raw = f"{actual}px" if actual > 0 else base_raw
|
|
1003
|
+
else:
|
|
1004
|
+
current_raw = base_raw
|
|
1005
|
+
return current_raw
|
|
1006
|
+
|
|
1007
|
+
def _create_animation_obj(
|
|
1008
|
+
self,
|
|
1009
|
+
widget: QWidget,
|
|
1010
|
+
prop: str,
|
|
1011
|
+
initial_raw: str,
|
|
1012
|
+
duration_ms: int,
|
|
1013
|
+
curve: QEasingCurve | QEasingCurve.Type,
|
|
1014
|
+
) -> Animation | None:
|
|
1015
|
+
"""Instantiate the correct Animation subclass for a CSS property."""
|
|
1016
|
+
ctx = self._ctx(widget)
|
|
1017
|
+
if "color" in prop:
|
|
1018
|
+
return ColorAnimation(widget, prop, initial_raw, duration_ms, curve, self, ctx=ctx)
|
|
1019
|
+
if prop == "opacity":
|
|
1020
|
+
return OpacityAnimation(
|
|
1021
|
+
widget,
|
|
1022
|
+
parse_css_val(initial_raw) or 0,
|
|
1023
|
+
duration_ms,
|
|
1024
|
+
curve,
|
|
1025
|
+
self,
|
|
1026
|
+
self.effect_priority,
|
|
1027
|
+
)
|
|
1028
|
+
if prop == "box-shadow":
|
|
1029
|
+
return BoxShadowHandle(widget, initial_raw, duration_ms, curve, self, self.effect_priority)
|
|
1030
|
+
if prop in SUPPORTED_NUMERIC_PROPS:
|
|
1031
|
+
parsed = parse_css_numeric(initial_raw)
|
|
1032
|
+
if parsed is not None:
|
|
1033
|
+
start_val, unit = parsed
|
|
1034
|
+
return GenericPropertyAnimation(widget, prop, start_val, duration_ms, curve, self, unit=unit, ctx=ctx)
|
|
1035
|
+
return None
|