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,23 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from .css_parser import extract_rules
5
+ from .engine import TransitionEngine
6
+
7
+ __all__ = [
8
+ "TransitionEngine",
9
+ "extract_rules",
10
+ ]
11
+
12
+
13
+ # Lazy-load the TransitionEngine and extract_rules to avoid importing the wrong Qt wrapper too early
14
+ def __getattr__(name: str) -> object:
15
+ if name == "TransitionEngine":
16
+ from .engine import TransitionEngine
17
+
18
+ return TransitionEngine
19
+ if name == "extract_rules":
20
+ from .css_parser import extract_rules
21
+
22
+ return extract_rules
23
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,190 @@
1
+ # CSS / engine lookup tables — shared by css_parser and engine.
2
+ # Pure data: no logic, minimal imports.
3
+
4
+ from .qt_compat.QtCore import QEasingCurve, QEvent, Qt
5
+
6
+ # ---------------------------------------------------------------------------
7
+ # CSS property normalisation
8
+ # ---------------------------------------------------------------------------
9
+
10
+ # Canonical property name for aliased shorthand forms.
11
+ PROP_ALIASES: dict[str, str] = {"background": "background-color"}
12
+
13
+ # Shorthand properties that expand to four longhands (top, right, bottom, left order).
14
+ SHORTHAND_SIDES: dict[str, list[str]] = {
15
+ "padding": ["padding-top", "padding-right", "padding-bottom", "padding-left"],
16
+ "margin": ["margin-top", "margin-right", "margin-bottom", "margin-left"],
17
+ "border-width": ["border-top-width", "border-right-width", "border-bottom-width", "border-left-width"],
18
+ "border-color": ["border-top-color", "border-right-color", "border-bottom-color", "border-left-color"],
19
+ # border-radius order: top-left, top-right, bottom-right, bottom-left
20
+ "border-radius": [
21
+ "border-top-left-radius",
22
+ "border-top-right-radius",
23
+ "border-bottom-right-radius",
24
+ "border-bottom-left-radius",
25
+ ],
26
+ }
27
+
28
+ BORDER_STYLE_KEYWORDS: frozenset[str] = frozenset(
29
+ {"none", "hidden", "dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset"}
30
+ )
31
+ BORDER_WIDTH_KEYWORDS: frozenset[str] = frozenset({"thin", "medium", "thick"})
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # CSS pseudo-class tables
35
+ # ---------------------------------------------------------------------------
36
+
37
+ # Qt QSS pseudo-classes that map to a canonical pseudo-class tracked by the engine.
38
+ PSEUDO_ALIASES: dict[str, str] = {}
39
+
40
+ # Animation pseudo-classes the engine knows about, in descending priority order.
41
+ ANIMATION_PSEUDOS: frozenset[str] = frozenset({":pressed", ":hover", ":focus", ":checked"})
42
+ ANIMATION_PSEUDO_PRIORITY: tuple[str, ...] = (":pressed", ":hover", ":focus", ":checked")
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Engine property sets
46
+ # ---------------------------------------------------------------------------
47
+
48
+ # Properties handled via QGraphicsEffect regardless of whether a transition is defined.
49
+ EFFECT_PROPS: frozenset[str] = frozenset({"opacity", "box-shadow"})
50
+
51
+ # Size properties that fall back to widget.sizeHint() when no explicit CSS value exists.
52
+ SIZE_PROPS: frozenset[str] = frozenset({"width", "height", "min-width", "max-width", "min-height", "max-height"})
53
+
54
+ # Qt events that can trigger a pseudo-state change.
55
+ PSEUDO_EVENTS: frozenset[QEvent.Type] = frozenset(
56
+ {
57
+ QEvent.Type.HoverEnter,
58
+ QEvent.Type.HoverLeave,
59
+ QEvent.Type.MouseButtonPress,
60
+ QEvent.Type.MouseButtonRelease,
61
+ QEvent.Type.MouseButtonDblClick,
62
+ QEvent.Type.FocusIn,
63
+ QEvent.Type.FocusOut,
64
+ }
65
+ )
66
+
67
+ EASING_MAP: dict[str, QEasingCurve.Type] = {
68
+ "linear": QEasingCurve.Type.Linear,
69
+ "ease": QEasingCurve.Type.InOutQuad,
70
+ "ease-in": QEasingCurve.Type.InCubic,
71
+ "ease-out": QEasingCurve.Type.OutCubic,
72
+ "ease-in-out": QEasingCurve.Type.InOutCubic,
73
+ }
74
+
75
+ SUPPORTED_NUMERIC_PROPS: frozenset[str] = frozenset(
76
+ {
77
+ "max-height",
78
+ "max-width",
79
+ "min-height",
80
+ "min-width",
81
+ "width",
82
+ "height",
83
+ "border-width",
84
+ "border-top-width",
85
+ "border-right-width",
86
+ "border-bottom-width",
87
+ "border-left-width",
88
+ "border-radius",
89
+ "border-top-left-radius",
90
+ "border-top-right-radius",
91
+ "border-bottom-left-radius",
92
+ "border-bottom-right-radius",
93
+ "margin",
94
+ "margin-top",
95
+ "margin-right",
96
+ "margin-bottom",
97
+ "margin-left",
98
+ "padding",
99
+ "padding-top",
100
+ "padding-right",
101
+ "padding-bottom",
102
+ "padding-left",
103
+ "font-size",
104
+ "font-weight",
105
+ "letter-spacing",
106
+ "word-spacing",
107
+ "spacing",
108
+ "bottom",
109
+ "left",
110
+ "right",
111
+ "top",
112
+ }
113
+ )
114
+
115
+ # Props where Qt rejects negative values (cubic-bezier overshoot can produce them).
116
+ # Clamped to >= 0 when writing to the stylesheet; current_val is left unclamped so
117
+ # the animation trajectory is unaffected.
118
+ NON_NEGATIVE_PROPS: frozenset[str] = frozenset(
119
+ {
120
+ "width",
121
+ "height",
122
+ "min-width",
123
+ "min-height",
124
+ "max-width",
125
+ "max-height",
126
+ "border-width",
127
+ "border-top-width",
128
+ "border-right-width",
129
+ "border-bottom-width",
130
+ "border-left-width",
131
+ "border-radius",
132
+ "border-top-left-radius",
133
+ "border-top-right-radius",
134
+ "border-bottom-left-radius",
135
+ "border-bottom-right-radius",
136
+ "padding",
137
+ "padding-top",
138
+ "padding-right",
139
+ "padding-bottom",
140
+ "padding-left",
141
+ "font-size",
142
+ "font-weight",
143
+ "spacing",
144
+ }
145
+ )
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Cursor map
149
+ # ---------------------------------------------------------------------------
150
+
151
+ # CSS cursor values → Qt cursor shapes.
152
+ # Omitted (no Qt equivalent): auto, url(), context-menu, vertical-text, zoom-in, zoom-out.
153
+ CURSOR_MAP: dict[str, Qt.CursorShape] = {
154
+ # Basic
155
+ "default": Qt.CursorShape.ArrowCursor,
156
+ "none": Qt.CursorShape.BlankCursor,
157
+ "pointer": Qt.CursorShape.PointingHandCursor,
158
+ "crosshair": Qt.CursorShape.CrossCursor,
159
+ "text": Qt.CursorShape.IBeamCursor,
160
+ "wait": Qt.CursorShape.WaitCursor,
161
+ "progress": Qt.CursorShape.BusyCursor,
162
+ "help": Qt.CursorShape.WhatsThisCursor,
163
+ "move": Qt.CursorShape.SizeAllCursor,
164
+ "all-scroll": Qt.CursorShape.SizeAllCursor,
165
+ "cell": Qt.CursorShape.CrossCursor,
166
+ # Resize — cardinal and diagonal
167
+ "n-resize": Qt.CursorShape.SizeVerCursor,
168
+ "s-resize": Qt.CursorShape.SizeVerCursor,
169
+ "ns-resize": Qt.CursorShape.SizeVerCursor,
170
+ "e-resize": Qt.CursorShape.SizeHorCursor,
171
+ "w-resize": Qt.CursorShape.SizeHorCursor,
172
+ "ew-resize": Qt.CursorShape.SizeHorCursor,
173
+ "ne-resize": Qt.CursorShape.SizeBDiagCursor, # / diagonal (NE–SW)
174
+ "sw-resize": Qt.CursorShape.SizeBDiagCursor,
175
+ "nesw-resize": Qt.CursorShape.SizeBDiagCursor,
176
+ "nw-resize": Qt.CursorShape.SizeFDiagCursor, # \ diagonal (NW–SE)
177
+ "se-resize": Qt.CursorShape.SizeFDiagCursor,
178
+ "nwse-resize": Qt.CursorShape.SizeFDiagCursor,
179
+ # Split (between rows/columns)
180
+ "row-resize": Qt.CursorShape.SplitVCursor,
181
+ "col-resize": Qt.CursorShape.SplitHCursor,
182
+ # Drag
183
+ "grab": Qt.CursorShape.OpenHandCursor,
184
+ "grabbing": Qt.CursorShape.ClosedHandCursor,
185
+ "copy": Qt.CursorShape.DragCopyCursor,
186
+ "alias": Qt.CursorShape.DragLinkCursor,
187
+ # Forbidden
188
+ "not-allowed": Qt.CursorShape.ForbiddenCursor,
189
+ "no-drop": Qt.CursorShape.ForbiddenCursor,
190
+ }
@@ -0,0 +1,397 @@
1
+ import re
2
+ from dataclasses import dataclass, field
3
+ from typing import Any
4
+
5
+ import tinycss2
6
+ from tinycss2.ast import Node
7
+
8
+ from .constants import (
9
+ ANIMATION_PSEUDOS,
10
+ BORDER_STYLE_KEYWORDS,
11
+ BORDER_WIDTH_KEYWORDS,
12
+ PROP_ALIASES,
13
+ PSEUDO_ALIASES,
14
+ SHORTHAND_SIDES,
15
+ )
16
+ from .gradients import translate_gradients
17
+
18
+
19
+ def _normalize_prop(name: str) -> str:
20
+ """Map an aliased property name to its canonical form."""
21
+ return PROP_ALIASES.get(name, name)
22
+
23
+
24
+ def _classify_border_token(token: str) -> str:
25
+ """Classify a token from a `border:` value as 'width', 'style', or 'color'."""
26
+ if token in BORDER_STYLE_KEYWORDS:
27
+ return "style"
28
+ if token in BORDER_WIDTH_KEYWORDS or re.match(r"^\d", token):
29
+ return "width"
30
+ return "color"
31
+
32
+
33
+ def _expand_border(value: str) -> dict[str, str]:
34
+ """Parse `border: <width> <style> <color>` (any token order) into component properties."""
35
+ result: dict[str, str] = {}
36
+ for token in value.split():
37
+ kind = _classify_border_token(token)
38
+ if kind == "width":
39
+ result["border-width"] = token
40
+ elif kind == "style":
41
+ result["border-style"] = token
42
+ else:
43
+ result["border-color"] = token
44
+ return result
45
+
46
+
47
+ def _expand_shorthand(prop: str, value: str) -> dict[str, str]:
48
+ """Expand a shorthand property to its longhand equivalents (recursively for compound shorthands)."""
49
+ if prop == "border":
50
+ result: dict[str, str] = {}
51
+ for sub_prop, sub_val in _expand_border(value).items():
52
+ result.update(_expand_shorthand(sub_prop, sub_val))
53
+ return result
54
+
55
+ longhands = SHORTHAND_SIDES.get(prop)
56
+ if not longhands:
57
+ return {prop: value}
58
+ parts = value.split()
59
+ n = len(parts)
60
+ if n == 1:
61
+ expanded = [parts[0]] * 4
62
+ elif n == 2:
63
+ expanded = [parts[0], parts[1], parts[0], parts[1]]
64
+ elif n == 3:
65
+ expanded = [parts[0], parts[1], parts[2], parts[1]]
66
+ else:
67
+ expanded = parts[:4]
68
+ return dict(zip(longhands, expanded))
69
+
70
+
71
+ def _transition_longhands(prop: str) -> list[str]:
72
+ """Animatable longhands to register when `transition: <prop>` is declared."""
73
+ if prop == "border":
74
+ # border-style is not animatable
75
+ return [*SHORTHAND_SIDES["border-width"], *SHORTHAND_SIDES["border-color"]]
76
+ longhands = SHORTHAND_SIDES.get(prop)
77
+ if longhands:
78
+ return list(longhands)
79
+ return [prop]
80
+
81
+
82
+ def _should_strip_prop(prop: str, animated_props: set[str]) -> bool:
83
+ """True if this property (or any of its longhands) is being animated."""
84
+ if prop in animated_props:
85
+ return True
86
+ if prop == "border":
87
+ border_longhands = [
88
+ *SHORTHAND_SIDES["border-width"],
89
+ "border-style",
90
+ "border-color",
91
+ *SHORTHAND_SIDES["border-color"],
92
+ ]
93
+ return any(lh in animated_props for lh in border_longhands)
94
+ longhands: list[str] | None = SHORTHAND_SIDES.get(prop)
95
+ return bool(longhands and any(lh in animated_props for lh in longhands))
96
+
97
+
98
+ def _split_selector(selector: str) -> tuple[str, frozenset[str]]:
99
+ """
100
+ Return ``(base_selector, pseudo_set)`` for a single selector string.
101
+
102
+ ``base_selector`` has all trailing pseudo-classes stripped (used for widget matching).
103
+ ``pseudo_set`` contains every recognized animation pseudo-class in the trailing
104
+ position after aliasing any alternate names (see ``PSEUDO_ALIASES``).
105
+ Compound selectors like ``:checked:pressed`` yield a set with both members.
106
+
107
+ ``::subcontrol`` pseudo-elements (e.g. ``::item``, ``::handle``) are treated
108
+ as part of the base selector and are never mistaken for pseudo-classes. The
109
+ negative lookbehind ``(?<!:)`` ensures only single-colon pseudo-classes match.
110
+ """
111
+ m = re.search(r"((?:(?<!:):[a-z-]+)+)$", selector)
112
+ if not m:
113
+ return selector, frozenset()
114
+ base = selector[: m.start()]
115
+ found = [PSEUDO_ALIASES.get(p, p) for p in re.findall(r":[a-z-]+", m.group(1))]
116
+ return base, frozenset(p for p in found if p in ANIMATION_PSEUDOS)
117
+
118
+
119
+ def _serialize_value(tokens: list[Node]) -> str:
120
+ """Serialize a tinycss2 token list to a CSS value string."""
121
+ return tinycss2.serialize(tokens).strip()
122
+
123
+
124
+ def _split_by_comma(tokens: list[Any]) -> list[list[Any]]:
125
+ """Split a tinycss2 token list by comma literals into segments."""
126
+ parts: list[list[Any]] = []
127
+ current: list[Any] = []
128
+ for tok in tokens:
129
+ if tok.type == "literal" and tok.value == ",":
130
+ parts.append(current)
131
+ current = []
132
+ else:
133
+ current.append(tok)
134
+ parts.append(current)
135
+ return parts
136
+
137
+
138
+ def _parse_transition_property_list(tokens: list[Any]) -> list[str]:
139
+ """Parse `transition-property` value → list of property names."""
140
+ result: list[str] = []
141
+ for segment in _split_by_comma(tokens):
142
+ significant = [t for t in segment if t.type != "whitespace"]
143
+ if not significant or significant[0].type != "ident":
144
+ continue
145
+ val = significant[0].value.lower()
146
+ if val == "none":
147
+ return [] # transition-property: none → no transitions
148
+ result.append(significant[0].value)
149
+ return result
150
+
151
+
152
+ def _parse_time_list(tokens: list[Any]) -> list[int]:
153
+ """Parse comma-separated `<time>` list → milliseconds integers."""
154
+ result: list[int] = []
155
+ for segment in _split_by_comma(tokens):
156
+ significant = [t for t in segment if t.type != "whitespace"]
157
+ if not significant:
158
+ continue
159
+ tok = significant[0]
160
+ if tok.type == "dimension" and tok.unit in ("ms", "s"):
161
+ result.append(int(tok.value * (1000 if tok.unit == "s" else 1)))
162
+ elif tok.type == "number" and tok.value == 0:
163
+ result.append(0)
164
+ return result
165
+
166
+
167
+ def _parse_easing_list(tokens: list[Any]) -> list[str]:
168
+ """Parse comma-separated timing-function list → easing strings."""
169
+ result: list[str] = []
170
+ for segment in _split_by_comma(tokens):
171
+ significant = [t for t in segment if t.type != "whitespace"]
172
+ if not significant:
173
+ continue
174
+ tok = significant[0]
175
+ if tok.type == "ident":
176
+ result.append(tok.value)
177
+ elif tok.type == "function" and tok.name.lower() in ("cubic-bezier", "steps"):
178
+ result.append(_serialize_value([tok]))
179
+ return result
180
+
181
+
182
+ def _combine_transition_longhands(
183
+ props: list[str] | None,
184
+ durations: list[int] | None,
185
+ easings: list[str] | None,
186
+ delays: list[int] | None,
187
+ ) -> list[TransitionSpec]:
188
+ """Build TransitionSpec list from longhand lists using CSS positional/cycling rules."""
189
+ if not props or not durations:
190
+ return []
191
+
192
+ def _get(lst: list[Any] | None, i: int, default: Any) -> Any:
193
+ if not lst:
194
+ return default
195
+ return lst[i % len(lst)]
196
+
197
+ result: list[TransitionSpec] = []
198
+ for i, prop in enumerate(props):
199
+ dur: int = _get(durations, i, 0)
200
+ easing: str = _get(easings, i, "ease")
201
+ delay: int = _get(delays, i, 0)
202
+ norm_prop = _normalize_prop(prop)
203
+ for lh in _transition_longhands(norm_prop):
204
+ result.append(TransitionSpec(lh, dur, easing, delay))
205
+ return result
206
+
207
+
208
+ def _parse_transition_segment(tokens: list[Any]) -> tuple[str, int, str, int] | None:
209
+ """
210
+ Parse one transition segment's tokens into (property, duration_ms, easing).
211
+
212
+ Expected token order (ignoring whitespace): <ident> <dimension> [<ident>]
213
+ Returns None if the segment is missing the required property or duration.
214
+ """
215
+ significant = [t for t in tokens if t.type != "whitespace"]
216
+ if len(significant) < 2:
217
+ return None
218
+ prop_tok = significant[0]
219
+ dur_tok = significant[1]
220
+
221
+ if prop_tok.type != "ident":
222
+ return None
223
+ if dur_tok.type != "dimension" or dur_tok.unit not in ("ms", "s"):
224
+ return None
225
+
226
+ duration_ms = int(dur_tok.value * (1000 if dur_tok.unit == "s" else 1))
227
+
228
+ # Parse optional timing-function and/or delay from remaining tokens.
229
+ # CSS spec `<single-transition>` syntax: each of <easing-function> and <time> (delay) is
230
+ # optional and may appear in any order after the duration. Scan all remaining tokens and
231
+ # classify each independently so we handle both `duration easing delay` and
232
+ # `duration delay easing` without dropping the easing when delay precedes it.
233
+ easing = "ease"
234
+ delay_ms = 0
235
+ for tok in significant[2:]:
236
+ if tok.type == "dimension" and tok.unit in ("ms", "s"):
237
+ delay_ms = int(tok.value * (1000 if tok.unit == "s" else 1))
238
+ elif tok.type == "ident" and tok.value not in ("normal", "allow-discrete"):
239
+ easing = tok.value
240
+ elif tok.type == "function" and tok.name.lower() in ("cubic-bezier", "steps"):
241
+ easing = _serialize_value([tok])
242
+
243
+ return prop_tok.value, duration_ms, easing, delay_ms
244
+
245
+
246
+ @dataclass
247
+ class TransitionSpec:
248
+ """Parsed CSS transition declaration for one property."""
249
+
250
+ prop: str
251
+ duration_ms: int
252
+ easing: str = "ease"
253
+ delay_ms: int = 0
254
+
255
+
256
+ @dataclass
257
+ class StyleRule:
258
+ """One parsed CSS rule block with selector metadata and transition specs."""
259
+
260
+ selector: str
261
+ base_selector: str
262
+ properties: dict[str, str]
263
+ pseudo_set: frozenset[str] = field(default_factory=frozenset) # All pseudos in compound selector
264
+ transitions: list[TransitionSpec] = field(default_factory=list)
265
+ segments: list[str] = field(default_factory=list)
266
+ subcontrol: bool = False # True when selector targets a ::subcontrol (::item, ::handle, …)
267
+
268
+
269
+ def extract_rules(stylesheet: str) -> tuple[str, list[StyleRule]]:
270
+ """Parse a stylesheet into cleaned QSS (transitions stripped) and a StyleRule list."""
271
+ raw_rules = tinycss2.parse_stylesheet(stylesheet, skip_comments=True, skip_whitespace=True)
272
+
273
+ rules: list[StyleRule] = []
274
+
275
+ # First pass: Parse everything into StyleRule objects
276
+ for raw_rule in raw_rules:
277
+ if raw_rule.type != "qualified-rule":
278
+ continue
279
+
280
+ raw_selector: str = tinycss2.serialize(raw_rule.prelude).strip()
281
+ decls = tinycss2.parse_blocks_contents(raw_rule.content, skip_comments=True, skip_whitespace=True)
282
+
283
+ transitions: list[TransitionSpec] = []
284
+ props: dict[str, str] = {}
285
+
286
+ # Accumulated longhand transition state (None = not declared in this block).
287
+ # If any longhand is present after parsing, they override the shorthand channel.
288
+ _t_props: list[str] | None = None
289
+ _t_durations: list[int] | None = None
290
+ _t_easings: list[str] | None = None
291
+ _t_delays: list[int] | None = None
292
+
293
+ for decl in decls:
294
+ if decl.type != "declaration":
295
+ continue
296
+ name = decl.name.lower()
297
+
298
+ if name == "transition":
299
+ for segment in _split_by_comma(decl.value):
300
+ result = _parse_transition_segment(segment)
301
+ if result:
302
+ prop, duration_ms, easing, delay_ms = result
303
+ norm_prop = _normalize_prop(prop)
304
+ for lh in _transition_longhands(norm_prop):
305
+ transitions.append(TransitionSpec(lh, duration_ms, easing, delay_ms))
306
+ elif name == "transition-property":
307
+ _t_props = _parse_transition_property_list(decl.value)
308
+ elif name == "transition-duration":
309
+ _t_durations = _parse_time_list(decl.value)
310
+ elif name == "transition-timing-function":
311
+ _t_easings = _parse_easing_list(decl.value)
312
+ elif name == "transition-delay":
313
+ _t_delays = _parse_time_list(decl.value)
314
+ else:
315
+ norm = _normalize_prop(name)
316
+ props.update(_expand_shorthand(norm, translate_gradients(_serialize_value(decl.value))))
317
+
318
+ # If any longhand was declared, build transitions from them (overrides shorthand).
319
+ if _t_props is not None or _t_durations is not None or _t_easings is not None or _t_delays is not None:
320
+ transitions = _combine_transition_longhands(_t_props, _t_durations, _t_easings, _t_delays)
321
+
322
+ for selector in (s.strip() for s in raw_selector.split(",")):
323
+ base, pseudo_set = _split_selector(selector)
324
+ is_subcontrol = "::" in base
325
+
326
+ rules.append(
327
+ StyleRule(
328
+ selector=selector,
329
+ base_selector=base,
330
+ properties=props,
331
+ pseudo_set=pseudo_set,
332
+ # Subcontrol items (::item, ::handle, …) are not real widgets; the engine
333
+ # cannot intercept their hover events, so we never animate them. Zeroing
334
+ # transitions here also prevents the second pass from stripping their
335
+ # pseudo-state properties, so Qt renders the native styles correctly.
336
+ transitions=[] if is_subcontrol else transitions,
337
+ segments=base.split(),
338
+ subcontrol=is_subcontrol,
339
+ )
340
+ )
341
+
342
+ # Identify which properties are animated for each base selector
343
+ animated_map: dict[str, set[str]] = {} # base_selector -> set(props)
344
+ for rule in rules:
345
+ if rule.transitions:
346
+ if rule.base_selector not in animated_map:
347
+ animated_map[rule.base_selector] = set()
348
+ for t in rule.transitions:
349
+ animated_map[rule.base_selector].add(t.prop)
350
+
351
+ # Second pass: Rebuild the stylesheet stripping transitions and animated pseudo-props
352
+ cleaned_parts: list[str] = []
353
+ for raw_rule in raw_rules:
354
+ if raw_rule.type != "qualified-rule":
355
+ continue
356
+
357
+ selector: str = tinycss2.serialize(raw_rule.prelude).strip()
358
+ decls = tinycss2.parse_declaration_list(raw_rule.content, skip_comments=True, skip_whitespace=True)
359
+
360
+ # For comma-grouped selectors, derive pseudo_set from the first part and union animated props.
361
+ _, pseudo_set = _split_selector(selector.split(",")[0].strip())
362
+
363
+ animated_props: set[str] = set()
364
+ for sel_part in (s.strip() for s in selector.split(",")):
365
+ base_part, _ = _split_selector(sel_part)
366
+ animated_props |= animated_map.get(base_part, set())
367
+
368
+ new_body_lines: list[str] = []
369
+ for decl in decls:
370
+ if decl.type != "declaration":
371
+ continue
372
+ name = decl.name.lower()
373
+ if name == "transition" or name.startswith("transition-"):
374
+ continue # Strip transition declarations and longhands
375
+
376
+ p_name = _normalize_prop(name)
377
+ p_val = translate_gradients(_serialize_value(decl.value))
378
+
379
+ # Properties the engine handles out-of-band — Qt doesn't know them and would warn.
380
+ if p_name in ("box-shadow", "cursor"):
381
+ continue
382
+
383
+ # Strip if pseudo-state block AND (transition: all covers everything, or prop is animated)
384
+ if pseudo_set and ("all" in animated_props or _should_strip_prop(p_name, animated_props)):
385
+ continue # Strip!
386
+
387
+ new_body_lines.append(f" {p_name}: {p_val};")
388
+
389
+ if new_body_lines:
390
+ cleaned_parts.append(f"{selector} {{\n" + "\n".join(new_body_lines) + "\n}")
391
+ else:
392
+ # If the block is now empty (e.g. only had an animated property), keep the selector but empty body
393
+ # Or we could omit it entirely if it's not needed for other things.
394
+ # Keeping it empty is safer for specificity/structure.
395
+ cleaned_parts.append(f"{selector} {{ }}")
396
+
397
+ return "\n\n".join(cleaned_parts), rules