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