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,321 @@
|
|
|
1
|
+
"""CSS gradient → Qt gradient translation.
|
|
2
|
+
|
|
3
|
+
Qt's QSS engine supports gradients via its own proprietary syntax which is not
|
|
4
|
+
compatible with standard CSS gradient functions. This module bridges that gap so
|
|
5
|
+
users can write standard CSS and have it automatically rewritten to Qt form:
|
|
6
|
+
|
|
7
|
+
linear-gradient() → qlineargradient()
|
|
8
|
+
radial-gradient() → qradialgradient()
|
|
9
|
+
conic-gradient() → qconicalgradient()
|
|
10
|
+
|
|
11
|
+
Note: gradient values are static — they are translated and passed through to Qt as-is.
|
|
12
|
+
Animating a gradient ``background-color`` with ``transition:`` is not supported; the
|
|
13
|
+
animation will be silently skipped and the gradient rendered without interpolation.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
# --- Direction helpers -------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
# fmt: off
|
|
22
|
+
_DIRECTION_MAP: dict[str, tuple[float, float, float, float]] = {
|
|
23
|
+
"to right": (0.0, 0.0, 1.0, 0.0),
|
|
24
|
+
"to left": (1.0, 0.0, 0.0, 0.0),
|
|
25
|
+
"to bottom": (0.0, 0.0, 0.0, 1.0),
|
|
26
|
+
"to top": (0.0, 1.0, 0.0, 0.0),
|
|
27
|
+
"to bottom right": (0.0, 0.0, 1.0, 1.0),
|
|
28
|
+
"to right bottom": (0.0, 0.0, 1.0, 1.0),
|
|
29
|
+
"to bottom left": (1.0, 0.0, 0.0, 1.0),
|
|
30
|
+
"to left bottom": (1.0, 0.0, 0.0, 1.0),
|
|
31
|
+
"to top right": (0.0, 1.0, 1.0, 0.0),
|
|
32
|
+
"to right top": (0.0, 1.0, 1.0, 0.0),
|
|
33
|
+
"to top left": (1.0, 1.0, 0.0, 0.0),
|
|
34
|
+
"to left top": (1.0, 1.0, 0.0, 0.0),
|
|
35
|
+
}
|
|
36
|
+
# fmt: on
|
|
37
|
+
|
|
38
|
+
_DEFAULT_DIRECTION: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0) # to bottom
|
|
39
|
+
|
|
40
|
+
_ANGLE_RE = re.compile(r"^(-?[\d.]+)deg$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_direction(arg: str) -> tuple[float, float, float, float] | None:
|
|
44
|
+
"""Return (x1, y1, x2, y2) for a direction token, or None if not a direction."""
|
|
45
|
+
d = arg.strip().lower()
|
|
46
|
+
if d in _DIRECTION_MAP:
|
|
47
|
+
return _DIRECTION_MAP[d]
|
|
48
|
+
m = _ANGLE_RE.match(d)
|
|
49
|
+
if m:
|
|
50
|
+
# CSS angles: 0deg = to top, 90deg = to right, clockwise.
|
|
51
|
+
angle_rad = math.radians(float(m.group(1)))
|
|
52
|
+
x2 = 0.5 + 0.5 * math.sin(angle_rad)
|
|
53
|
+
y2 = 0.5 - 0.5 * math.cos(angle_rad)
|
|
54
|
+
return round(1.0 - x2, 6), round(1.0 - y2, 6), round(x2, 6), round(y2, 6)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- Argument splitting ------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _split_args(s: str) -> list[str]:
|
|
62
|
+
"""Split a string by top-level commas (ignores commas inside nested parentheses)."""
|
|
63
|
+
parts: list[str] = []
|
|
64
|
+
depth = 0
|
|
65
|
+
current: list[str] = []
|
|
66
|
+
for ch in s:
|
|
67
|
+
if ch == "(":
|
|
68
|
+
depth += 1
|
|
69
|
+
current.append(ch)
|
|
70
|
+
elif ch == ")":
|
|
71
|
+
depth -= 1
|
|
72
|
+
current.append(ch)
|
|
73
|
+
elif ch == "," and depth == 0:
|
|
74
|
+
parts.append("".join(current).strip())
|
|
75
|
+
current = []
|
|
76
|
+
else:
|
|
77
|
+
current.append(ch)
|
|
78
|
+
if current:
|
|
79
|
+
parts.append("".join(current).strip())
|
|
80
|
+
return parts
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- Stop parsing ------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
_STOP_POSITION_RE = re.compile(r"\s+([\d.]+)(%?)\s*$")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_stop(s: str) -> tuple[float | None, str]:
|
|
89
|
+
"""Parse a color-stop string into (position_0_to_1_or_None, color_str).
|
|
90
|
+
|
|
91
|
+
Examples:
|
|
92
|
+
"red" → (None, "red")
|
|
93
|
+
"red 25%" → (0.25, "red")
|
|
94
|
+
"#ff0000 0.5" → (0.5, "#ff0000")
|
|
95
|
+
"""
|
|
96
|
+
s = s.strip()
|
|
97
|
+
m = _STOP_POSITION_RE.search(s)
|
|
98
|
+
if m:
|
|
99
|
+
val = float(m.group(1))
|
|
100
|
+
position = val / 100.0 if m.group(2) == "%" else val
|
|
101
|
+
return position, s[: m.start()].strip()
|
|
102
|
+
return None, s
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _fill_positions(raw: list[tuple[float | None, str]]) -> list[tuple[float, str]]:
|
|
106
|
+
"""Fill missing stop positions by linear interpolation between defined neighbours."""
|
|
107
|
+
n = len(raw)
|
|
108
|
+
positions: list[float | None] = [p for p, _ in raw]
|
|
109
|
+
|
|
110
|
+
# Anchor first and last
|
|
111
|
+
if positions[0] is None:
|
|
112
|
+
positions[0] = 0.0
|
|
113
|
+
if positions[-1] is None:
|
|
114
|
+
positions[-1] = 1.0
|
|
115
|
+
|
|
116
|
+
# Fill interior gaps
|
|
117
|
+
i = 0
|
|
118
|
+
while i < n:
|
|
119
|
+
if positions[i] is None:
|
|
120
|
+
j = i + 1
|
|
121
|
+
while j < n and positions[j] is None:
|
|
122
|
+
j += 1
|
|
123
|
+
_ps = positions[i - 1]
|
|
124
|
+
p_start: float = _ps if _ps is not None else 0.0
|
|
125
|
+
_pe = positions[j]
|
|
126
|
+
p_end: float = _pe if _pe is not None else 1.0
|
|
127
|
+
count = j - i + 1
|
|
128
|
+
for k in range(i, j):
|
|
129
|
+
positions[k] = p_start + (p_end - p_start) * (k - i + 1) / count
|
|
130
|
+
i += 1
|
|
131
|
+
|
|
132
|
+
return [(float(p) if p is not None else 0.0, color) for p, (_, color) in zip(positions, raw)]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- Per-type inner translators ----------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _translate_linear_inner(inner: str) -> str | None:
|
|
139
|
+
"""Translate the interior of a ``linear-gradient(...)`` call to ``qlineargradient``."""
|
|
140
|
+
args = _split_args(inner)
|
|
141
|
+
if not args:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
direction = _parse_direction(args[0])
|
|
145
|
+
stop_args = args[1:] if direction is not None else args
|
|
146
|
+
if direction is None:
|
|
147
|
+
direction = _DEFAULT_DIRECTION
|
|
148
|
+
|
|
149
|
+
if len(stop_args) < 2:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
x1, y1, x2, y2 = direction
|
|
153
|
+
stops = _fill_positions([_parse_stop(s) for s in stop_args])
|
|
154
|
+
stop_strs = [f"stop:{pos:.4g} {color}" for pos, color in stops]
|
|
155
|
+
return f"qlineargradient(x1:{x1:.4g}, y1:{y1:.4g}, x2:{x2:.4g}, y2:{y2:.4g}, " + ", ".join(stop_strs) + ")"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Keywords that identify the optional first argument of radial-gradient() as a
|
|
159
|
+
# shape/size/position descriptor rather than a color stop.
|
|
160
|
+
_RADIAL_DESCRIPTOR_KEYWORDS: frozenset[str] = frozenset(
|
|
161
|
+
{"circle", "ellipse", "at", "closest-side", "farthest-side", "closest-corner", "farthest-corner"}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
_POS_VALUE_RE = re.compile(r"^(-?[\d.]+)(%?)$")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _parse_pos_value(s: str) -> float:
|
|
168
|
+
"""Parse a CSS position token (``50%`` or ``0.5``) to a [0, 1] float."""
|
|
169
|
+
m = _POS_VALUE_RE.match(s.strip())
|
|
170
|
+
if not m:
|
|
171
|
+
return 0.5
|
|
172
|
+
val = float(m.group(1))
|
|
173
|
+
return val / 100.0 if m.group(2) == "%" else val
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _translate_radial_inner(inner: str) -> str | None:
|
|
177
|
+
"""
|
|
178
|
+
Translate the interior of a ``radial-gradient(...)`` call to ``qradialgradient``.
|
|
179
|
+
|
|
180
|
+
Supported descriptor syntax (optional first argument):
|
|
181
|
+
[circle|ellipse] [<size>] [at <cx> <cy>]
|
|
182
|
+
|
|
183
|
+
where ``<size>`` may be a percentage, a plain number (0–1), or a size keyword
|
|
184
|
+
(``closest-side`` etc.; keywords are accepted but ignored — Qt has no equivalent).
|
|
185
|
+
Defaults: cx=0.5, cy=0.5, radius=0.5. The focal point (fx, fy) is set to the
|
|
186
|
+
centre so the gradient behaves like a standard CSS radial-gradient.
|
|
187
|
+
"""
|
|
188
|
+
args = _split_args(inner)
|
|
189
|
+
if not args:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
cx, cy, radius = 0.5, 0.5, 0.5
|
|
193
|
+
stop_start = 0
|
|
194
|
+
|
|
195
|
+
first_words = set(re.findall(r"[a-z-]+", args[0].lower()))
|
|
196
|
+
if first_words & _RADIAL_DESCRIPTOR_KEYWORDS:
|
|
197
|
+
stop_start = 1
|
|
198
|
+
descriptor = args[0]
|
|
199
|
+
|
|
200
|
+
# Extract 'at <cx> <cy>'
|
|
201
|
+
at_m = re.search(r"\bat\s+([\d.]+%?)\s+([\d.]+%?)", descriptor, re.IGNORECASE)
|
|
202
|
+
if at_m:
|
|
203
|
+
cx = _parse_pos_value(at_m.group(1))
|
|
204
|
+
cy = _parse_pos_value(at_m.group(2))
|
|
205
|
+
descriptor = descriptor[: at_m.start()]
|
|
206
|
+
|
|
207
|
+
# Extract explicit radius: strip shape/size keywords, look for a number
|
|
208
|
+
cleaned = re.sub(
|
|
209
|
+
r"\b(circle|ellipse|closest-side|farthest-side|closest-corner|farthest-corner)\b",
|
|
210
|
+
"",
|
|
211
|
+
descriptor,
|
|
212
|
+
flags=re.IGNORECASE,
|
|
213
|
+
).strip()
|
|
214
|
+
size_m = re.search(r"([\d.]+)(%?)", cleaned)
|
|
215
|
+
if size_m:
|
|
216
|
+
val = float(size_m.group(1))
|
|
217
|
+
radius = val / 100.0 if size_m.group(2) == "%" else val
|
|
218
|
+
|
|
219
|
+
stop_args = args[stop_start:]
|
|
220
|
+
if len(stop_args) < 2:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
stops = _fill_positions([_parse_stop(s) for s in stop_args])
|
|
224
|
+
stop_strs = [f"stop:{pos:.4g} {color}" for pos, color in stops]
|
|
225
|
+
return (
|
|
226
|
+
f"qradialgradient(cx:{cx:.4g}, cy:{cy:.4g}, radius:{radius:.4g}, "
|
|
227
|
+
f"fx:{cx:.4g}, fy:{cy:.4g}, " + ", ".join(stop_strs) + ")"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _translate_conic_inner(inner: str) -> str | None:
|
|
232
|
+
"""
|
|
233
|
+
Translate the interior of a ``conic-gradient(...)`` call to ``qconicalgradient``.
|
|
234
|
+
|
|
235
|
+
Supported descriptor syntax (optional first argument):
|
|
236
|
+
[from <angle>] [at <cx> <cy>]
|
|
237
|
+
|
|
238
|
+
Defaults: cx=0.5, cy=0.5, angle=0.
|
|
239
|
+
"""
|
|
240
|
+
args = _split_args(inner)
|
|
241
|
+
if not args:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
cx, cy, angle = 0.5, 0.5, 0.0
|
|
245
|
+
stop_start = 0
|
|
246
|
+
|
|
247
|
+
first_lower = args[0].lower()
|
|
248
|
+
if "from" in first_lower.split() or "at" in first_lower.split():
|
|
249
|
+
stop_start = 1
|
|
250
|
+
descriptor = args[0]
|
|
251
|
+
|
|
252
|
+
from_m = re.search(r"\bfrom\s+(-?[\d.]+)deg\b", descriptor, re.IGNORECASE)
|
|
253
|
+
if from_m:
|
|
254
|
+
angle = float(from_m.group(1))
|
|
255
|
+
|
|
256
|
+
at_m = re.search(r"\bat\s+([\d.]+%?)\s+([\d.]+%?)", descriptor, re.IGNORECASE)
|
|
257
|
+
if at_m:
|
|
258
|
+
cx = _parse_pos_value(at_m.group(1))
|
|
259
|
+
cy = _parse_pos_value(at_m.group(2))
|
|
260
|
+
|
|
261
|
+
stop_args = args[stop_start:]
|
|
262
|
+
if len(stop_args) < 2:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
stops = _fill_positions([_parse_stop(s) for s in stop_args])
|
|
266
|
+
stop_strs = [f"stop:{pos:.4g} {color}" for pos, color in stops]
|
|
267
|
+
return f"qconicalgradient(cx:{cx:.4g}, cy:{cy:.4g}, angle:{angle:.4g}, " + ", ".join(stop_strs) + ")"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --- Main translation function -----------------------------------------------
|
|
271
|
+
|
|
272
|
+
_GRADIENT_RE = re.compile(r"\b(linear|radial|conic)-gradient\s*\(", re.IGNORECASE)
|
|
273
|
+
|
|
274
|
+
_GRADIENT_TRANSLATORS = {
|
|
275
|
+
"linear": _translate_linear_inner,
|
|
276
|
+
"radial": _translate_radial_inner,
|
|
277
|
+
"conic": _translate_conic_inner,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def translate_gradients(value: str) -> str:
|
|
282
|
+
"""
|
|
283
|
+
Translate all CSS gradient functions in *value* to their Qt equivalents.
|
|
284
|
+
|
|
285
|
+
Handles ``linear-gradient()``, ``radial-gradient()``, and ``conic-gradient()``.
|
|
286
|
+
Returns *value* unchanged for calls that cannot be parsed (rather than raising).
|
|
287
|
+
"""
|
|
288
|
+
result = value
|
|
289
|
+
offset = 0
|
|
290
|
+
|
|
291
|
+
for m in _GRADIENT_RE.finditer(value):
|
|
292
|
+
gradient_type = m.group(1).lower()
|
|
293
|
+
fn_start = m.start() + offset
|
|
294
|
+
paren_open = m.end() - 1 + offset
|
|
295
|
+
|
|
296
|
+
# Find matching closing paren
|
|
297
|
+
depth = 0
|
|
298
|
+
paren_close = -1
|
|
299
|
+
for i in range(paren_open, len(result)):
|
|
300
|
+
if result[i] == "(":
|
|
301
|
+
depth += 1
|
|
302
|
+
elif result[i] == ")":
|
|
303
|
+
depth -= 1
|
|
304
|
+
if depth == 0:
|
|
305
|
+
paren_close = i
|
|
306
|
+
break
|
|
307
|
+
|
|
308
|
+
if paren_close == -1:
|
|
309
|
+
continue # unmatched parenthesis — skip
|
|
310
|
+
|
|
311
|
+
inner = result[paren_open + 1 : paren_close]
|
|
312
|
+
translator = _GRADIENT_TRANSLATORS[gradient_type]
|
|
313
|
+
qt_gradient = translator(inner)
|
|
314
|
+
if qt_gradient is None:
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
replacement_len = paren_close - fn_start + 1
|
|
318
|
+
result = result[:fn_start] + qt_gradient + result[paren_close + 1 :]
|
|
319
|
+
offset += len(qt_gradient) - replacement_len
|
|
320
|
+
|
|
321
|
+
return result
|