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,402 @@
1
+ from qt_css_engine.constants import NON_NEGATIVE_PROPS
2
+
3
+ from .qt_compat.QtCore import QEasingCurve, QObject, QVariantAnimation
4
+ from .qt_compat.QtGui import QColor
5
+ from .qt_compat.QtWidgets import QWidget
6
+ from .types import ShadowParams, WidgetContext
7
+ from .utils import (
8
+ apply_opacity_to_widget,
9
+ apply_shadow_to_widget,
10
+ interpolate_oklab,
11
+ lerp_shadow,
12
+ parse_box_shadow,
13
+ parse_color,
14
+ parse_css_numeric,
15
+ parse_css_val,
16
+ scoped_anim_style,
17
+ shadow_as_transparent,
18
+ )
19
+
20
+
21
+ class BoxShadowHandle(QObject):
22
+ """
23
+ Animates box-shadow via a QGraphicsDropShadowEffect on the widget.
24
+
25
+ Stored in active_animations like other animation types so orphan cleanup (class
26
+ removal, rule changes) correctly clears the shadow. Transitions between shadow
27
+ states — including None (no shadow) — interpolate all fields and alpha.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ widget: QWidget,
33
+ initial_raw: str,
34
+ duration_ms: int,
35
+ easing_curve: QEasingCurve | QEasingCurve.Type,
36
+ parent: QObject | None = None,
37
+ effect_priority: str = "opacity",
38
+ ) -> None:
39
+ super().__init__(parent)
40
+ self.widget = widget
41
+ self.effect_priority = effect_priority
42
+ self._current: ShadowParams | None = parse_box_shadow(initial_raw)
43
+ self._start: ShadowParams | None = None
44
+ self._end: ShadowParams | None = None
45
+ self._anim_origin: ShadowParams | None = self._current
46
+ apply_shadow_to_widget(widget, self._current, self.effect_priority)
47
+
48
+ self.anim = QVariantAnimation(self)
49
+ self.anim.setDuration(duration_ms)
50
+ self.anim.setEasingCurve(easing_curve)
51
+ self.anim.setStartValue(0.0)
52
+ self.anim.setEndValue(1.0)
53
+ self.anim.valueChanged.connect(self._on_tick)
54
+ self.anim.finished.connect(self._on_finished)
55
+
56
+ def _on_tick(self, t: float) -> None:
57
+ """Interpolate the shadow between _start and _end at progress t and apply it."""
58
+ # Resolve None as a transparent copy of the other end for fade in/out.
59
+ if self._start is None and self._end is None:
60
+ return
61
+ start = (
62
+ self._start if self._start is not None else shadow_as_transparent(self._end) # type: ignore
63
+ )
64
+ end = (
65
+ self._end if self._end is not None else shadow_as_transparent(self._start) # type: ignore
66
+ )
67
+ self._current = lerp_shadow(start, end, t)
68
+ apply_shadow_to_widget(self.widget, self._current, self.effect_priority)
69
+
70
+ def _on_finished(self) -> None:
71
+ """Snap to exact target on completion to clear floating-point residuals."""
72
+ self._current = self._end
73
+ self._start = None
74
+ try:
75
+ apply_shadow_to_widget(self.widget, self._current, self.effect_priority)
76
+ except RuntimeError:
77
+ pass
78
+
79
+ def update_spec(self, duration_ms: int, easing_curve: QEasingCurve) -> None:
80
+ """Update duration and easing curve without restarting the animation."""
81
+ self.anim.setDuration(duration_ms)
82
+ self.anim.setEasingCurve(easing_curve)
83
+
84
+ def snap_to(self, value_raw: str) -> None:
85
+ """Instantly apply a CSS box-shadow value without animation."""
86
+ self.anim.stop()
87
+ self._current = parse_box_shadow(value_raw)
88
+ self._start = None
89
+ self._end = None
90
+ self._anim_origin = self._current
91
+ apply_shadow_to_widget(self.widget, self._current, self.effect_priority)
92
+
93
+ def set_target(self, target_raw: str) -> None:
94
+ """Start or re-target the animation toward a new CSS box-shadow value."""
95
+ target = parse_box_shadow(target_raw)
96
+
97
+ is_running = self.anim.state() == self.anim.State.Running
98
+ if is_running and target == self._end:
99
+ return
100
+ if not is_running and target == self._current:
101
+ return
102
+
103
+ if is_running and self._anim_origin is not None and target == self._anim_origin and self._end is not None:
104
+ # Reversing to origin — swap start/end and seek so steps() retraces original path.
105
+ dur = max(1, self.anim.duration())
106
+ raw_p = min(self.anim.currentTime(), dur) / dur
107
+ seek_ms = int((1.0 - raw_p) * dur)
108
+ old_end = self._end
109
+ self._anim_origin = old_end
110
+ self._start = old_end
111
+ self._end = target
112
+ self.anim.stop()
113
+ self.anim.setStartValue(0.0)
114
+ self.anim.setEndValue(1.0)
115
+ self.anim.start()
116
+ self.anim.setCurrentTime(seek_ms)
117
+ # Force tick: step-start always returns 1.0 so Qt suppresses valueChanged.
118
+ self._on_tick(self.anim.easingCurve().valueForProgress(min(seek_ms, dur) / dur))
119
+ return
120
+
121
+ self._anim_origin = self._current
122
+ self._start = self._current
123
+ self._end = target
124
+ self.anim.stop()
125
+ self.anim.setStartValue(0.0)
126
+ self.anim.setEndValue(1.0)
127
+ self.anim.start()
128
+ self._on_tick(self.anim.easingCurve().valueForProgress(0.0))
129
+
130
+
131
+ class ColorAnimation(QObject):
132
+ """
133
+ Animates QSS color properties (background-color, color, border-color, etc.).
134
+ Uses OKLab interpolation for perceptually uniform color transitions.
135
+ """
136
+
137
+ def __init__(
138
+ self,
139
+ widget: QWidget,
140
+ prop: str,
141
+ initial_raw: str | QColor,
142
+ duration_ms: int,
143
+ easing_curve: QEasingCurve | QEasingCurve.Type,
144
+ parent: QObject | None = None,
145
+ ctx: WidgetContext | None = None,
146
+ ) -> None:
147
+ super().__init__(parent)
148
+ self.widget = widget
149
+ self.prop = prop
150
+ self._ctx = ctx
151
+ self.current_color = parse_color(initial_raw) if isinstance(initial_raw, str) else QColor(initial_raw)
152
+ self.start_color = self.current_color
153
+ self.end_color = self.current_color
154
+ self._anim_origin_color: QColor | None = QColor(self.current_color)
155
+
156
+ self.anim = QVariantAnimation(self)
157
+ self.anim.setDuration(duration_ms)
158
+ self.anim.setEasingCurve(easing_curve)
159
+ self.anim.valueChanged.connect(self._on_tick)
160
+
161
+ @property
162
+ def _props(self) -> dict[str, str]:
163
+ """Live css_anim_props dict from context, or widget fallback for standalone use."""
164
+ if self._ctx is not None:
165
+ return self._ctx.css_anim_props
166
+ # Fallback for standalone usage (tests without engine context).
167
+ props: dict[str, str] = getattr(self.widget, "_css_anim_props", {})
168
+ return props
169
+
170
+ def _on_tick(self, t: float) -> None:
171
+ """Write interpolated color to css_anim_props and refresh the widget stylesheet."""
172
+ self.current_color = interpolate_oklab(self.start_color, self.end_color, t)
173
+ props = self._props
174
+ props[self.prop] = self.current_color.name(QColor.NameFormat.HexArgb)
175
+ self.widget.setStyleSheet(scoped_anim_style(self.widget, props))
176
+
177
+ def update_spec(self, duration_ms: int, easing_curve: QEasingCurve) -> None:
178
+ """Update duration and easing curve without restarting the animation."""
179
+ self.anim.setDuration(duration_ms)
180
+ self.anim.setEasingCurve(easing_curve)
181
+
182
+ def snap_to(self, value_raw: str) -> None:
183
+ """Instantly apply a CSS color value without animation."""
184
+ self.anim.stop()
185
+ self.current_color = parse_color(value_raw)
186
+ self.start_color = self.current_color
187
+ self.end_color = self.current_color
188
+ self._anim_origin_color = QColor(self.current_color)
189
+ self._props[self.prop] = self.current_color.name(QColor.NameFormat.HexArgb)
190
+
191
+ def set_target(self, target_raw: str) -> None:
192
+ """Start or re-target the animation toward a new CSS color value."""
193
+ target_color = parse_color(target_raw)
194
+ is_running = self.anim.state() == self.anim.State.Running
195
+ if is_running and target_color == self.end_color:
196
+ return
197
+
198
+ if is_running and self._anim_origin_color is not None and target_color == self._anim_origin_color:
199
+ # Reversing to origin — swap start/end and seek so steps() retraces original path.
200
+ dur = max(1, self.anim.duration())
201
+ raw_p = min(self.anim.currentTime(), dur) / dur
202
+ seek_ms = int((1.0 - raw_p) * dur)
203
+ old_end = QColor(self.end_color)
204
+ self._anim_origin_color = old_end
205
+ self.start_color = old_end
206
+ self.end_color = target_color
207
+ self.anim.stop()
208
+ self.anim.setStartValue(0.0)
209
+ self.anim.setEndValue(1.0)
210
+ self.anim.start()
211
+ self.anim.setCurrentTime(seek_ms)
212
+ # Force tick: step-start always returns 1.0 so Qt suppresses valueChanged
213
+ # (new value == previous value from forward trip) — manually ensure the color updates.
214
+ self._on_tick(self.anim.easingCurve().valueForProgress(min(seek_ms, dur) / dur))
215
+ return
216
+
217
+ self._anim_origin_color = QColor(self.current_color)
218
+ self.start_color = self.current_color
219
+ self.end_color = target_color
220
+ self.anim.stop()
221
+ self.anim.setStartValue(0.0)
222
+ self.anim.setEndValue(1.0)
223
+ self.anim.start()
224
+ # Force first frame: Qt suppresses valueChanged when eased value at t=0 equals the
225
+ # previous currentValue. step-start (n=1) always returns 1.0, so forward and return
226
+ # trips both produce 1.0 — the signal is silenced on the return and the color never
227
+ # updates. Explicit tick ensures current_color is applied regardless.
228
+ self._on_tick(self.anim.easingCurve().valueForProgress(0.0))
229
+
230
+
231
+ class GenericPropertyAnimation(QObject):
232
+ """
233
+ Animates numeric QSS properties (widths, heights, margins, padding, etc.).
234
+ Writes interpolated values directly into the widget's scoped inline stylesheet.
235
+ """
236
+
237
+ def __init__(
238
+ self,
239
+ widget: QWidget,
240
+ prop: str,
241
+ initial_val: int | float,
242
+ duration_ms: int,
243
+ easing_curve: QEasingCurve | QEasingCurve.Type,
244
+ parent: QObject | None = None,
245
+ unit: str = "px",
246
+ ctx: WidgetContext | None = None,
247
+ ) -> None:
248
+ super().__init__(parent)
249
+ self.widget = widget
250
+ self.prop = prop
251
+ self.unit = unit
252
+ self._ctx = ctx
253
+ self.current_val = float(initial_val)
254
+ # Captured at creation time (before any inline constraint is applied).
255
+ # Used as the animation target when returning to natural/unconstrained state,
256
+ # so we never call sizeHint() while min-width/max-width are still active.
257
+ self.natural_val: float = float(initial_val)
258
+ self._clean_on_finish = False
259
+ self._anim_origin_val: float | None = float(initial_val)
260
+
261
+ self.anim = QVariantAnimation(self)
262
+ self.anim.setDuration(duration_ms)
263
+ self.anim.setEasingCurve(easing_curve)
264
+ self.anim.valueChanged.connect(self._on_tick)
265
+ self.anim.finished.connect(self._on_finished)
266
+
267
+ @property
268
+ def _props(self) -> dict[str, str]:
269
+ """Live css_anim_props dict from context, or widget fallback for standalone use."""
270
+ if self._ctx is not None:
271
+ return self._ctx.css_anim_props
272
+ props: dict[str, str] = getattr(self.widget, "_css_anim_props", {})
273
+ return props
274
+
275
+ def _on_tick(self, val: int | float) -> None:
276
+ """Write interpolated numeric value to css_anim_props and refresh the widget stylesheet."""
277
+ self.current_val = float(val)
278
+ written = max(0.0, self.current_val) if self.prop in NON_NEGATIVE_PROPS else self.current_val
279
+ props = self._props
280
+ props[self.prop] = f"{written:.3f}{self.unit}"
281
+ self.widget.setStyleSheet(scoped_anim_style(self.widget, props))
282
+
283
+ def _on_finished(self) -> None:
284
+ """Remove the inline size constraint when targeting the natural layout size."""
285
+ if not self._clean_on_finish:
286
+ return
287
+ self._clean_on_finish = False
288
+ props = self._props
289
+ if self.prop in props:
290
+ del props[self.prop]
291
+ try:
292
+ self.widget.setStyleSheet(scoped_anim_style(self.widget, props))
293
+ except RuntimeError:
294
+ pass
295
+
296
+ def update_spec(self, duration_ms: int, easing_curve: QEasingCurve) -> None:
297
+ """Update duration and easing curve without restarting the animation."""
298
+ self.anim.setDuration(duration_ms)
299
+ self.anim.setEasingCurve(easing_curve)
300
+
301
+ def snap_to(self, value_raw: str) -> None:
302
+ """Instantly apply a CSS numeric value without animation."""
303
+ self.anim.stop()
304
+ self._clean_on_finish = False
305
+ parsed = parse_css_numeric(value_raw)
306
+ if parsed is not None:
307
+ self.current_val = parsed[0]
308
+ self._anim_origin_val = self.current_val
309
+ self._props[self.prop] = f"{self.current_val:.3f}{self.unit}"
310
+
311
+ def snap_to_natural(self) -> None:
312
+ """Stop animation and remove this prop from css_anim_props (returns widget to natural layout)."""
313
+ self.anim.stop()
314
+ self._clean_on_finish = False
315
+ props = self._props
316
+ if self.prop in props:
317
+ del props[self.prop]
318
+
319
+ def set_target(self, target_raw: str, clean_on_finish: bool = False) -> None:
320
+ """Start or re-target the animation toward a new CSS numeric value."""
321
+ parsed = parse_css_numeric(target_raw)
322
+ if parsed is None:
323
+ return
324
+ t_val = float(parsed[0])
325
+ is_running = self.anim.state() == self.anim.State.Running
326
+ if is_running and t_val == self.anim.endValue():
327
+ return
328
+
329
+ if is_running and self._anim_origin_val is not None and abs(t_val - self._anim_origin_val) < 1e-6:
330
+ # Reversing to origin — swap start/end and seek so steps() retraces original path.
331
+ dur = max(1, self.anim.duration())
332
+ raw_p = min(self.anim.currentTime(), dur) / dur
333
+ seek_ms = int((1.0 - raw_p) * dur)
334
+ old_end = float(self.anim.endValue())
335
+ self._anim_origin_val = old_end
336
+ self._clean_on_finish = clean_on_finish
337
+ self.anim.stop()
338
+ self.anim.setStartValue(old_end)
339
+ self.anim.setEndValue(t_val)
340
+ self.anim.start()
341
+ self.anim.setCurrentTime(seek_ms)
342
+ return
343
+
344
+ self._anim_origin_val = self.current_val
345
+ self._clean_on_finish = clean_on_finish
346
+ self.anim.stop()
347
+ self.anim.setStartValue(self.current_val)
348
+ self.anim.setEndValue(t_val)
349
+ self.anim.start()
350
+
351
+
352
+ class OpacityAnimation(QObject):
353
+ """Animates widget opacity via a QGraphicsOpacityEffect."""
354
+
355
+ def __init__(
356
+ self,
357
+ widget: QWidget,
358
+ initial_val: float | int | str,
359
+ duration_ms: int,
360
+ easing_curve: QEasingCurve | QEasingCurve.Type,
361
+ parent: QObject | None = None,
362
+ effect_priority: str = "opacity",
363
+ ) -> None:
364
+ super().__init__(parent)
365
+ self.widget = widget
366
+ self.effect_priority = effect_priority
367
+ self._current_val = float(initial_val)
368
+ apply_opacity_to_widget(widget, self._current_val, self.effect_priority)
369
+
370
+ self.anim = QVariantAnimation(self)
371
+ self.anim.setDuration(duration_ms)
372
+ self.anim.setEasingCurve(easing_curve)
373
+ self.anim.valueChanged.connect(self._on_tick)
374
+
375
+ def _on_tick(self, val: float) -> None:
376
+ """Apply interpolated opacity to the widget's QGraphicsOpacityEffect."""
377
+ self._current_val = val
378
+ apply_opacity_to_widget(self.widget, val, self.effect_priority)
379
+
380
+ def update_spec(self, duration_ms: int, easing_curve: QEasingCurve) -> None:
381
+ """Update duration and easing curve without restarting the animation."""
382
+ self.anim.setDuration(duration_ms)
383
+ self.anim.setEasingCurve(easing_curve)
384
+
385
+ def snap_to(self, value_raw: str) -> None:
386
+ """Instantly apply a CSS opacity value without animation."""
387
+ self.anim.stop()
388
+ t_val = parse_css_val(value_raw)
389
+ if isinstance(t_val, (int, float)):
390
+ self._current_val = float(t_val)
391
+ apply_opacity_to_widget(self.widget, self._current_val, self.effect_priority)
392
+
393
+ def set_target(self, target_raw: str) -> None:
394
+ """Start or re-target the animation toward a new opacity value."""
395
+ t_val = parse_css_val(target_raw)
396
+ if not isinstance(t_val, (int, float)):
397
+ return
398
+
399
+ self.anim.stop()
400
+ self.anim.setStartValue(self._current_val)
401
+ self.anim.setEndValue(float(t_val))
402
+ self.anim.start()
qt_css_engine/py.typed ADDED
File without changes
@@ -0,0 +1,15 @@
1
+ # pyright: reportMissingImports = false
2
+ # pyright: reportUnknownVariableType = false
3
+ # pyright: reportUnusedImport = false
4
+ # pyright: reportWildcardImportFromLibrary = false
5
+ # ty: ignore[unresolved-import]
6
+
7
+ from ._api import USE_PYSIDE6
8
+
9
+ if USE_PYSIDE6:
10
+ from PySide6.QtCore import *
11
+ from PySide6.QtCore import Signal, Slot # noqa: F401
12
+ else:
13
+ from PyQt6.QtCore import *
14
+ from PyQt6.QtCore import pyqtSignal as Signal # noqa: F401
15
+ from PyQt6.QtCore import pyqtSlot as Slot # noqa: F401
@@ -0,0 +1,10 @@
1
+ # pyright: reportMissingImports = false
2
+ # pyright: reportWildcardImportFromLibrary = false
3
+ # ty: ignore[unresolved-import]
4
+
5
+ from ._api import USE_PYSIDE6
6
+
7
+ if USE_PYSIDE6:
8
+ from PySide6.QtGui import *
9
+ else:
10
+ from PyQt6.QtGui import *
@@ -0,0 +1,10 @@
1
+ # pyright: reportMissingImports = false
2
+ # pyright: reportWildcardImportFromLibrary = false
3
+ # ty: ignore[unresolved-import]
4
+
5
+ from ._api import USE_PYSIDE6
6
+
7
+ if USE_PYSIDE6:
8
+ from PySide6.QtWidgets import *
9
+ else:
10
+ from PyQt6.QtWidgets import *
@@ -0,0 +1,22 @@
1
+ # pyright: reportMissingImports = false
2
+ # pyright: reportWildcardImportFromLibrary = false
3
+ # pyright: reportUnknownVariableType = false
4
+ # pyright: reportMissingTypeStubs = false
5
+ # ty: ignore[unresolved-import]
6
+ # ty: ignore[unused-ignore-comment]
7
+
8
+ from typing import Any
9
+
10
+ from ._api import USE_PYSIDE6
11
+
12
+
13
+ def qt_delete(obj: Any) -> None:
14
+ """Synchronously delete a Qt C++ object, equivalent to C++ delete."""
15
+ if USE_PYSIDE6:
16
+ from shiboken6 import Shiboken
17
+
18
+ Shiboken.delete(obj) # type: ignore
19
+ else:
20
+ from PyQt6 import sip
21
+
22
+ sip.delete(obj) # type: ignore
@@ -0,0 +1,15 @@
1
+ """Qt binding detection. Import USE_PYSIDE6 to branch on the active binding."""
2
+
3
+ import importlib.util
4
+ import os
5
+ import sys
6
+
7
+ _qt_api = os.environ.get("QT_API", "").lower()
8
+ _pyside6_loaded = "PySide6" in sys.modules
9
+ _pyqt6_loaded = "PyQt6" in sys.modules
10
+
11
+ USE_PYSIDE6: bool = (
12
+ _qt_api == "pyside6"
13
+ or (not _qt_api and _pyside6_loaded and not _pyqt6_loaded)
14
+ or (not _qt_api and not _pyside6_loaded and not _pyqt6_loaded and bool(importlib.util.find_spec("PySide6")))
15
+ )
qt_css_engine/types.py ADDED
@@ -0,0 +1,77 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field
3
+ from enum import Enum, auto
4
+ from typing import TYPE_CHECKING, Union
5
+
6
+ from .qt_compat.QtCore import QTimer
7
+ from .qt_compat.QtGui import QColor
8
+
9
+ if TYPE_CHECKING:
10
+ from .handlers import BoxShadowHandle, ColorAnimation, GenericPropertyAnimation, OpacityAnimation
11
+
12
+ Animation = Union["ColorAnimation", "OpacityAnimation", "GenericPropertyAnimation", "BoxShadowHandle"]
13
+
14
+
15
+ class EvaluationCause(Enum):
16
+ """Why the engine is evaluating a widget's CSS state right now."""
17
+
18
+ DIRECT = auto()
19
+ POLISH = auto()
20
+ PSEUDO_STATE = auto()
21
+ CLASS_CHANGE = auto()
22
+ CLASS_ANIMATION_FINISH = auto()
23
+ WINDOW_DEACTIVATE = auto()
24
+ RULE_RELOAD = auto()
25
+ DELAY_FIRE = auto()
26
+
27
+ @property
28
+ def snaps_transitions(self) -> bool:
29
+ """True when animations should snap to target immediately rather than run."""
30
+ return self is EvaluationCause.POLISH
31
+
32
+ @property
33
+ def is_class_driven(self) -> bool:
34
+ """True when the evaluation was triggered by a class property change."""
35
+ return self is EvaluationCause.CLASS_CHANGE
36
+
37
+
38
+ class InternalWriteReason(Enum):
39
+ """Why the engine is temporarily suppressing event evaluation during internal mutations."""
40
+
41
+ CLASS_CHANGE = auto()
42
+ MEASURE = auto()
43
+
44
+
45
+ @dataclass
46
+ class WidgetContext:
47
+ """Per-widget state tracked by the transition engine."""
48
+
49
+ active_pseudos: set[str] = field(default_factory=lambda: set[str]())
50
+ css_anim_props: dict[str, str] = field(default_factory=lambda: dict[str, str]())
51
+ active_animations: dict[str, Animation] = field(default_factory=lambda: dict[str, Animation]())
52
+ internal_write_depth: int = 0
53
+ internal_write_reason: InternalWriteReason | None = None
54
+ pre_polish_size: tuple[int, int] | None = None
55
+ # Properties with in-flight class-change-initiated animations.
56
+ # Pseudo-state changes (hover/focus) defer to these until they finish.
57
+ class_anim_props: set[str] = field(default_factory=lambda: set[str]())
58
+ class_anim_gen: int = 0
59
+ # Per-property delay timers scheduled by transition-delay declarations.
60
+ # Cancelled whenever the widget state changes before the timer fires.
61
+ pending_delays: dict[str, QTimer] = field(default_factory=lambda: dict[str, QTimer]())
62
+ # Last cursor value applied via setCursor() — None means unsetCursor() (Qt default).
63
+ applied_cursor: str | None = None
64
+ # Per-property class-anim finished callbacks stored so they can be disconnected before
65
+ # reconnecting — prevents accumulation of stale closures on rapid class changes.
66
+ class_anim_callbacks: dict[str, Callable[[], None]] = field(default_factory=lambda: dict[str, Callable[[], None]]())
67
+
68
+
69
+ @dataclass
70
+ class ShadowParams:
71
+ """Decomposed CSS box-shadow parameters used for interpolation."""
72
+
73
+ offset_x: float = 0.0
74
+ offset_y: float = 4.0
75
+ blur: float = 8.0
76
+ spread: float = 0.0
77
+ color: QColor = field(default_factory=lambda: QColor(0, 0, 0, 80))