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