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