bubbleUI 0.4.4a1__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.
- bubble/QUI.py +4 -0
- bubble/__init__.py +8 -0
- bubble/cards.py +1503 -0
- bubble/dev_navview.py +4334 -0
- bubble/dev_titlebar.py +2900 -0
- bubble/glasskit/__init__.py +94 -0
- bubble/glasskit/config.py +77 -0
- bubble/glasskit/energy.py +202 -0
- bubble/glasskit/glass.py +1034 -0
- bubble/glasskit/gpu.py +43 -0
- bubble/glasskit/imaging.py +823 -0
- bubble/glasskit/settings.py +82 -0
- bubble/i18n.py +27 -0
- bubble/icon.py +261 -0
- bubble/levels/__init__.py +32 -0
- bubble/levels/component.py +106 -0
- bubble/levels/manager.py +334 -0
- bubble/styles.py +282 -0
- bubble/uiWindow.py +262 -0
- bubble/widgets.py +962 -0
- bubbleui-0.4.4a1.dist-info/METADATA +18 -0
- bubbleui-0.4.4a1.dist-info/RECORD +25 -0
- bubbleui-0.4.4a1.dist-info/WHEEL +5 -0
- bubbleui-0.4.4a1.dist-info/licenses/LICENSE +21 -0
- bubbleui-0.4.4a1.dist-info/top_level.txt +1 -0
bubble/glasskit/glass.py
ADDED
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
"""Core frosted-glass painting primitives.
|
|
2
|
+
|
|
3
|
+
The module is deliberately free of application chrome and demo state. It
|
|
4
|
+
provides the material model (:class:`FrostStyle`), a painter for laying the
|
|
5
|
+
material over an existing backdrop, and a pane that blurs the widget content
|
|
6
|
+
underneath it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
import shiboken6
|
|
15
|
+
|
|
16
|
+
from PySide6.QtCore import *
|
|
17
|
+
from PySide6.QtGui import *
|
|
18
|
+
from PySide6.QtWidgets import *
|
|
19
|
+
|
|
20
|
+
from .imaging import (
|
|
21
|
+
DesktopSource,
|
|
22
|
+
WallpaperSource,
|
|
23
|
+
exclude_window_from_capture,
|
|
24
|
+
blur_qimage,
|
|
25
|
+
noise_texture
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .energy import EnergyModeController
|
|
29
|
+
|
|
30
|
+
from dataclasses import fields, replace
|
|
31
|
+
from .config import * # noqa: F401
|
|
32
|
+
|
|
33
|
+
from .settings import GlassRuntimeSettings, FrostStyle
|
|
34
|
+
|
|
35
|
+
_CONFIG_FIELDS = tuple(field.name for field in fields(GlassRuntimeSettings))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ===========================================================================
|
|
39
|
+
# FrostStyle <-> QSS bridge
|
|
40
|
+
# ===========================================================================
|
|
41
|
+
# Every field of :class:`FrostStyle` is exposed as a Qt property on the glass
|
|
42
|
+
# painting widgets, so the built-in materials can be tuned from a style sheet::
|
|
43
|
+
#
|
|
44
|
+
# GlassBackdrop {
|
|
45
|
+
# qproperty-frostTint: #303030;
|
|
46
|
+
# qproperty-frostOpacity: 80;
|
|
47
|
+
# qproperty-frostBlur: 32;
|
|
48
|
+
# }
|
|
49
|
+
# GlassMenuBar[glassStyle="thin"] {
|
|
50
|
+
# qproperty-frostOpacity: 30;
|
|
51
|
+
# }
|
|
52
|
+
#
|
|
53
|
+
# The four presets from :mod:`.config` are written to the theme style sheets,
|
|
54
|
+
# which makes QSS the effective source at runtime while Python keeps working
|
|
55
|
+
# defaults (useful for the standalone library).
|
|
56
|
+
_FROST_QSS_FIELDS: tuple[tuple[str, type], ...] = (
|
|
57
|
+
("tint", QColor),
|
|
58
|
+
("opacity", int),
|
|
59
|
+
("blur", int),
|
|
60
|
+
("frost", int),
|
|
61
|
+
("noise", int),
|
|
62
|
+
("sheen", int),
|
|
63
|
+
("saturation", int),
|
|
64
|
+
("brightness", int),
|
|
65
|
+
("border", int),
|
|
66
|
+
("radius", int),
|
|
67
|
+
("vignette", int),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_FROST_INT_RANGES = {
|
|
71
|
+
"opacity": (0, 100),
|
|
72
|
+
"blur": (0, 200),
|
|
73
|
+
"frost": (0, 100),
|
|
74
|
+
"noise": (0, 100),
|
|
75
|
+
"sheen": (0, 100),
|
|
76
|
+
"saturation": (0, 300),
|
|
77
|
+
"brightness": (0, 300),
|
|
78
|
+
"border": (0, 100),
|
|
79
|
+
"radius": (0, 100),
|
|
80
|
+
"vignette": (0, 100),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_FROST_FIELD_NAMES = frozenset(name for name, _ in _FROST_QSS_FIELDS)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _coerce_frost_value(field: str, value):
|
|
87
|
+
if field == "tint":
|
|
88
|
+
color = value if isinstance(value, QColor) else QColor(str(value))
|
|
89
|
+
return color.name() if color.isValid() else "#000000"
|
|
90
|
+
number = int(value)
|
|
91
|
+
low, high = _FROST_INT_RANGES.get(field, (0, 300))
|
|
92
|
+
return max(low, min(high, number))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _frost_property(field: str, qtype: type):
|
|
96
|
+
def getter(self):
|
|
97
|
+
if qtype is QColor:
|
|
98
|
+
return QColor(self.background_style.tint)
|
|
99
|
+
return getattr(self.background_style, field)
|
|
100
|
+
|
|
101
|
+
def setter(self, value):
|
|
102
|
+
self.set_frost_style(**{field: value})
|
|
103
|
+
|
|
104
|
+
return Property(qtype, getter, setter)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _frost_properties():
|
|
108
|
+
"""Q_PROPERTY descriptors for the FrostStyle fields (assigned in class bodies)."""
|
|
109
|
+
return tuple(_frost_property(field, qtype) for field, qtype in _FROST_QSS_FIELDS)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _FrostStyleMixin:
|
|
113
|
+
"""Shared material state for widgets that paint a :class:`FrostStyle`."""
|
|
114
|
+
|
|
115
|
+
background_style: FrostStyle = BUBBLE_BACKGROUND_NORMAL
|
|
116
|
+
|
|
117
|
+
def set_frost_style(self, **fields) -> None:
|
|
118
|
+
"""Override individual material fields (used by ``qproperty-frost*``)."""
|
|
119
|
+
unknown = set(fields) - _FROST_FIELD_NAMES
|
|
120
|
+
if unknown:
|
|
121
|
+
raise TypeError("未知的 FrostStyle 字段: " + ", ".join(sorted(unknown)))
|
|
122
|
+
updates = {name: _coerce_frost_value(name, value) for name, value in fields.items()}
|
|
123
|
+
self.background_style = replace(self.background_style, **updates)
|
|
124
|
+
self.update()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def apply_glass_style(widget: QWidget, preset: str) -> None:
|
|
128
|
+
"""Switch a glass widget to a QSS preset variant such as ``"thin"``.
|
|
129
|
+
|
|
130
|
+
The theme style sheets provide ``GlassBackdrop[glassStyle="thin"]`` /
|
|
131
|
+
``GlassMenuBar[glassStyle="thin"]`` blocks; this helper sets the dynamic
|
|
132
|
+
property and re-polishes the widget so the block takes effect.
|
|
133
|
+
"""
|
|
134
|
+
widget.setProperty("glassStyle", str(preset))
|
|
135
|
+
style = widget.style()
|
|
136
|
+
style.unpolish(widget)
|
|
137
|
+
style.polish(widget)
|
|
138
|
+
widget.update()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def qcolor(value: str | QColor, alpha: int | None = None) -> QColor:
|
|
142
|
+
"""Return a QColor from a CSS string or another QColor."""
|
|
143
|
+
color = QColor(value) if not isinstance(value, QColor) else QColor(value)
|
|
144
|
+
if alpha is not None:
|
|
145
|
+
color.setAlpha(max(0, min(255, int(alpha))))
|
|
146
|
+
return color
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def mix(a: str | QColor, b: str | QColor, t: float) -> QColor:
|
|
150
|
+
"""Linear colour interpolation; ``t`` is clamped to 0..1."""
|
|
151
|
+
t = max(0.0, min(1.0, float(t)))
|
|
152
|
+
ca, cb = qcolor(a), qcolor(b)
|
|
153
|
+
return QColor(
|
|
154
|
+
int(round(ca.red() * (1 - t) + cb.red() * t)),
|
|
155
|
+
int(round(ca.green() * (1 - t) + cb.green() * t)),
|
|
156
|
+
int(round(ca.blue() * (1 - t) + cb.blue() * t)),
|
|
157
|
+
int(round(ca.alpha() * (1 - t) + cb.alpha() * t)),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def rounded_path(rect: QRectF, radius: float) -> QPainterPath:
|
|
161
|
+
path = QPainterPath()
|
|
162
|
+
radius = max(0.0, min(float(radius), min(rect.width(), rect.height()) / 2.0))
|
|
163
|
+
path.addRoundedRect(rect, radius, radius)
|
|
164
|
+
return path
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def paint_frost_overlay(
|
|
168
|
+
painter: QPainter,
|
|
169
|
+
rect: QRectF,
|
|
170
|
+
style: FrostStyle,
|
|
171
|
+
*,
|
|
172
|
+
radius: float | None = None,
|
|
173
|
+
border: bool = True,
|
|
174
|
+
vignette: int | None = None,
|
|
175
|
+
sheen: bool = True,
|
|
176
|
+
clip: bool = True,
|
|
177
|
+
texture_offset: QPoint = QPoint(0, 0),
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Paint tint, haze, grain, sheen and an optional border over a backdrop.
|
|
180
|
+
|
|
181
|
+
The caller is responsible for drawing the blurred backdrop first. This
|
|
182
|
+
function only beautifies the *background*; widgets drawn later remain
|
|
183
|
+
perfectly sharp.
|
|
184
|
+
"""
|
|
185
|
+
painter.save()
|
|
186
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
187
|
+
radius = style.radius if radius is None else radius
|
|
188
|
+
vignette = style.vignette if vignette is None else int(vignette)
|
|
189
|
+
path = rounded_path(rect, radius)
|
|
190
|
+
if clip:
|
|
191
|
+
painter.setClipPath(path)
|
|
192
|
+
|
|
193
|
+
# Tint. Saturation and brightness belong to the source image; opacity is
|
|
194
|
+
# the strength of the coloured glass layer.
|
|
195
|
+
tint = qcolor(style.tint)
|
|
196
|
+
tint_alpha = int(round(255 * (style.opacity / 100.0)))
|
|
197
|
+
tint.setAlpha(tint_alpha)
|
|
198
|
+
painter.fillRect(rect, tint)
|
|
199
|
+
|
|
200
|
+
# Frost is a soft, slightly milky veil which lowers backdrop contrast.
|
|
201
|
+
if style.frost > 0:
|
|
202
|
+
haze_color = mix(style.tint, "#F7F4EC", 0.24)
|
|
203
|
+
haze = QLinearGradient(rect.topLeft(), rect.bottomRight())
|
|
204
|
+
haze.setColorAt(0.0, qcolor(haze_color, int(style.frost * 0.90)))
|
|
205
|
+
haze.setColorAt(0.55, qcolor(haze_color, int(style.frost * 0.48)))
|
|
206
|
+
haze.setColorAt(1.0, qcolor(haze_color, int(style.frost * 0.22)))
|
|
207
|
+
painter.fillRect(rect, haze)
|
|
208
|
+
|
|
209
|
+
# Tileable procedural textures add the "frosted" tactile quality.
|
|
210
|
+
if style.frost > 0:
|
|
211
|
+
painter.save()
|
|
212
|
+
painter.translate(texture_offset)
|
|
213
|
+
painter.setOpacity(min(0.7, style.frost / 100.0 * 0.38))
|
|
214
|
+
painter.fillRect(rect.translated(-texture_offset), QBrush(noise_texture("cloud")))
|
|
215
|
+
painter.restore()
|
|
216
|
+
if style.noise > 0:
|
|
217
|
+
painter.save()
|
|
218
|
+
painter.translate(texture_offset + QPoint(73, 41))
|
|
219
|
+
painter.setOpacity(min(0.8, style.noise / 100.0 * 0.55))
|
|
220
|
+
painter.fillRect(rect.translated(-texture_offset - QPoint(73, 41)),
|
|
221
|
+
QBrush(noise_texture("grain")))
|
|
222
|
+
painter.restore()
|
|
223
|
+
|
|
224
|
+
if sheen and style.sheen > 0:
|
|
225
|
+
top = QLinearGradient(rect.topLeft(), QPointF(rect.left(), rect.top() + rect.height() * 0.48))
|
|
226
|
+
top.setColorAt(0.0, QColor(255, 255, 255, int(style.sheen * 0.34)))
|
|
227
|
+
top.setColorAt(0.5, QColor(255, 255, 255, int(style.sheen * 0.10)))
|
|
228
|
+
top.setColorAt(1.0, QColor(255, 255, 255, 0))
|
|
229
|
+
painter.fillRect(rect, top)
|
|
230
|
+
# A brighter line on the top lip catches the eye like real glass.
|
|
231
|
+
lip = QLinearGradient(rect.left(), 0, rect.right(), 0)
|
|
232
|
+
lip.setColorAt(0.0, QColor(255, 255, 255, 0))
|
|
233
|
+
lip.setColorAt(0.22, QColor(255, 255, 255, int(style.sheen * 0.7)))
|
|
234
|
+
lip.setColorAt(0.78, QColor(255, 255, 255, int(style.sheen * 0.45)))
|
|
235
|
+
lip.setColorAt(1.0, QColor(255, 255, 255, 0))
|
|
236
|
+
painter.setPen(QPen(QBrush(lip), 1.0))
|
|
237
|
+
painter.drawLine(QPointF(rect.left() + radius * 0.4, rect.top() + 0.5),
|
|
238
|
+
QPointF(rect.right() - radius * 0.4, rect.top() + 0.5))
|
|
239
|
+
|
|
240
|
+
if vignette:
|
|
241
|
+
painter.setClipPath(path)
|
|
242
|
+
radial = QRadialGradient(rect.center(), max(rect.width(), rect.height()) * 0.72)
|
|
243
|
+
radial.setColorAt(0.35, QColor(0, 0, 0, 0))
|
|
244
|
+
radial.setColorAt(0.78, QColor(0, 0, 0, int(vignette * 0.55)))
|
|
245
|
+
radial.setColorAt(1.0, QColor(0, 0, 0, int(vignette * 1.25)))
|
|
246
|
+
painter.fillRect(rect, radial)
|
|
247
|
+
|
|
248
|
+
if border and style.border > 0:
|
|
249
|
+
painter.setClipping(False)
|
|
250
|
+
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
251
|
+
edge = QLinearGradient(rect.topLeft(), rect.bottomRight())
|
|
252
|
+
edge.setColorAt(0.0, QColor(255, 255, 255, int(style.border * 1.55)))
|
|
253
|
+
edge.setColorAt(0.45, QColor(255, 255, 255, int(style.border * 0.55)))
|
|
254
|
+
edge.setColorAt(1.0, QColor(255, 255, 255, int(style.border * 0.95)))
|
|
255
|
+
painter.setPen(QPen(QBrush(edge), 1.0))
|
|
256
|
+
painter.drawPath(rounded_path(rect.adjusted(0.5, 0.5, -0.5, -0.5), radius))
|
|
257
|
+
# Inner shadow at the bottom gives the pane a little physical weight.
|
|
258
|
+
inner = QLinearGradient(rect.left(), rect.bottom() - 28, rect.left(), rect.bottom())
|
|
259
|
+
inner.setColorAt(0.0, QColor(0, 0, 0, 0))
|
|
260
|
+
inner.setColorAt(1.0, QColor(0, 0, 0, 38))
|
|
261
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
262
|
+
painter.setClipPath(rounded_path(rect.adjusted(0.5, 0.5, -0.5, -0.5), max(0.0, radius - 0.5)))
|
|
263
|
+
painter.fillRect(QRectF(rect.left(), rect.bottom() - 28, rect.width(), 28), inner)
|
|
264
|
+
|
|
265
|
+
painter.restore()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class BlurSource(Protocol):
|
|
269
|
+
def render_source_image(self) -> QImage: ...
|
|
270
|
+
def source_revision(self) -> int: ...
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class BlurredGlassPanel(QWidget):
|
|
274
|
+
"""A glass pane that blurs the content of a sibling/parent source widget.
|
|
275
|
+
|
|
276
|
+
The source widget implements ``render_source_image()`` and
|
|
277
|
+
``source_revision()``. Everything painted on the pane (labels, buttons,
|
|
278
|
+
etc.) stays crisp; only the captured scene underneath is blurred.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def __init__(self, source_widget: BlurSource, style: FrostStyle,
|
|
282
|
+
parent: QWidget | None = None):
|
|
283
|
+
super().__init__(parent or source_widget) # type: ignore[arg-type]
|
|
284
|
+
self.source_widget = source_widget
|
|
285
|
+
self.style = style
|
|
286
|
+
self._revision = -1
|
|
287
|
+
self._blurred: QImage | None = None
|
|
288
|
+
self._blurred_pos = QPointF(0, 0)
|
|
289
|
+
self._build_key: tuple | None = None
|
|
290
|
+
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
|
|
291
|
+
self.setAutoFillBackground(False)
|
|
292
|
+
self.setMouseTracking(False)
|
|
293
|
+
|
|
294
|
+
# -- public ------------------------------------------------------------
|
|
295
|
+
def set_style(self, style: FrostStyle) -> None:
|
|
296
|
+
self.style = style
|
|
297
|
+
self.update()
|
|
298
|
+
|
|
299
|
+
def refresh_style(self) -> None:
|
|
300
|
+
self.update()
|
|
301
|
+
|
|
302
|
+
def invalidate(self) -> None:
|
|
303
|
+
self._build_key = None
|
|
304
|
+
self.update()
|
|
305
|
+
|
|
306
|
+
# -- backdrop ----------------------------------------------------------
|
|
307
|
+
def _source_dpr(self) -> float:
|
|
308
|
+
try:
|
|
309
|
+
return max(1.0, float(self.source_widget.devicePixelRatioF()))
|
|
310
|
+
except Exception:
|
|
311
|
+
return 1.0
|
|
312
|
+
|
|
313
|
+
def _build_backdrop(self) -> None:
|
|
314
|
+
source_image = self.source_widget.render_source_image()
|
|
315
|
+
revision = self.source_widget.source_revision()
|
|
316
|
+
dpr = max(1.0, float(source_image.devicePixelRatio() or self._source_dpr()))
|
|
317
|
+
style = self.style
|
|
318
|
+
|
|
319
|
+
# Include the pane's position: moving it over a different part of the
|
|
320
|
+
# source must sample different pixels.
|
|
321
|
+
offset = self.mapTo(self.source_widget, QPoint(0, 0))
|
|
322
|
+
key = (revision, offset.x(), offset.y(), self.width(), self.height(), dpr,
|
|
323
|
+
style.blur, style.saturation, style.brightness)
|
|
324
|
+
if key == self._build_key and self._blurred is not None:
|
|
325
|
+
return
|
|
326
|
+
self._build_key = key
|
|
327
|
+
|
|
328
|
+
if source_image.isNull():
|
|
329
|
+
self._blurred = None
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
# Include padding so the blur kernel sees real neighbouring pixels.
|
|
333
|
+
pad = int(max(10, style.blur * 2 + 10))
|
|
334
|
+
logical = QRect(offset.x() - pad, offset.y() - pad,
|
|
335
|
+
self.width() + pad * 2, self.height() + pad * 2)
|
|
336
|
+
source_logical = self.source_widget.rect()
|
|
337
|
+
clipped = logical.intersected(source_logical)
|
|
338
|
+
if clipped.isEmpty():
|
|
339
|
+
self._blurred = None
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
pixel_rect = QRect(
|
|
343
|
+
int(round(clipped.x() * dpr)),
|
|
344
|
+
int(round(clipped.y() * dpr)),
|
|
345
|
+
max(1, int(round(clipped.width() * dpr))),
|
|
346
|
+
max(1, int(round(clipped.height() * dpr))),
|
|
347
|
+
)
|
|
348
|
+
pixel_rect = pixel_rect.intersected(source_image.rect())
|
|
349
|
+
if pixel_rect.isEmpty():
|
|
350
|
+
self._blurred = None
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
cropped = source_image.copy(pixel_rect)
|
|
354
|
+
physical_radius = style.blur * dpr
|
|
355
|
+
blurred = blur_qimage(cropped, physical_radius, style.brightness, style.saturation)
|
|
356
|
+
blurred.setDevicePixelRatio(dpr)
|
|
357
|
+
self._blurred = blurred
|
|
358
|
+
self._blurred_pos = QPointF(
|
|
359
|
+
pixel_rect.x() / dpr - offset.x(),
|
|
360
|
+
pixel_rect.y() / dpr - offset.y(),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# -- painting ----------------------------------------------------------
|
|
364
|
+
def paintEvent(self, event) -> None: # noqa: N802 (Qt naming)
|
|
365
|
+
painter = QPainter(self)
|
|
366
|
+
painter.setRenderHints(QPainter.RenderHint.Antialiasing |
|
|
367
|
+
QPainter.RenderHint.SmoothPixmapTransform)
|
|
368
|
+
self._build_backdrop()
|
|
369
|
+
radius = self.style.radius
|
|
370
|
+
rect = QRectF(self.rect())
|
|
371
|
+
path = rounded_path(rect, radius)
|
|
372
|
+
|
|
373
|
+
# A soft dark edge inside the pane gives it weight without requiring a
|
|
374
|
+
# QGraphicsEffect (which would blur the glass content too).
|
|
375
|
+
painter.save()
|
|
376
|
+
painter.setClipPath(path)
|
|
377
|
+
painter.fillRect(rect, QColor(10, 10, 10, 160))
|
|
378
|
+
if self._blurred is not None:
|
|
379
|
+
painter.drawImage(self._blurred_pos, self._blurred)
|
|
380
|
+
painter.restore()
|
|
381
|
+
|
|
382
|
+
paint_frost_overlay(painter, rect, self.style, radius=radius, vignette=6)
|
|
383
|
+
painter.end()
|
|
384
|
+
|
|
385
|
+
class GlassBackdrop(_FrostStyleMixin, QWidget):
|
|
386
|
+
"""Frosted glass layer that can sit inside any host window.
|
|
387
|
+
|
|
388
|
+
The backdrop is a plain child widget: it paints the blurred wallpaper /
|
|
389
|
+
live desktop and hosts the energy-saving cross-fade. Put it at the bottom
|
|
390
|
+
of a window (QMainWindow's central widget, a dialog's only child, ...) and
|
|
391
|
+
add the real content to :attr:`content_layout`, which is painted on top of
|
|
392
|
+
the glass.
|
|
393
|
+
|
|
394
|
+
``host`` is the top-level window that owns everything window-level: drag
|
|
395
|
+
and double-click gestures are forwarded to it, capture exclusion applies to
|
|
396
|
+
its native handle, and its activation events drive
|
|
397
|
+
``auto_energy_on_focus_loss``. Keep the backdrop at the same client-area
|
|
398
|
+
geometry as the host (as all hosts below do) so the transition snapshots
|
|
399
|
+
stay aligned.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
#: Frost material painted as the whole-window backdrop.
|
|
403
|
+
background_style: FrostStyle = BUBBLE_BACKGROUND_NORMAL
|
|
404
|
+
|
|
405
|
+
#: QSS-accessible material fields (``qproperty-frost*``).
|
|
406
|
+
(
|
|
407
|
+
frostTint, frostOpacity, frostBlur, frostFrost, frostNoise, frostSheen,
|
|
408
|
+
frostSaturation, frostBrightness, frostBorder, frostRadius, frostVignette,
|
|
409
|
+
) = _frost_properties()
|
|
410
|
+
|
|
411
|
+
def __init__(self, host: QWidget, parent: QWidget | None = None) -> None:
|
|
412
|
+
super().__init__(parent if parent is not None else host)
|
|
413
|
+
self._host = host
|
|
414
|
+
self.config = RUNTIME.copy()
|
|
415
|
+
self._drag_offset = None
|
|
416
|
+
self._capture_excluded = False
|
|
417
|
+
self._live_capture_active = False
|
|
418
|
+
self._shutdown_done = False
|
|
419
|
+
|
|
420
|
+
# The real UI of the host window is mounted here, on top of the glass.
|
|
421
|
+
self.content_layout = QVBoxLayout(self)
|
|
422
|
+
self.content_layout.setContentsMargins(0, 0, 0, 0)
|
|
423
|
+
self.content_layout.setSpacing(0)
|
|
424
|
+
|
|
425
|
+
# The backdrop paints the glass itself; the host window stays empty.
|
|
426
|
+
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
427
|
+
self.setAutoFillBackground(False)
|
|
428
|
+
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
|
|
429
|
+
|
|
430
|
+
self.desktop = DesktopSource.capture(dynamic=self.config.screen_tracking, fps=self.config.fps)
|
|
431
|
+
self.desktop.set_capture_region(self._global_rect(), CAPTURE_SCALE)
|
|
432
|
+
self.wallpaper = WallpaperSource()
|
|
433
|
+
self.source = self.wallpaper if self.config.wallpaper else self.desktop
|
|
434
|
+
self.desktop.updated.connect(self.update)
|
|
435
|
+
self.wallpaper.updated.connect(self.update)
|
|
436
|
+
|
|
437
|
+
# The controller watches the *host* window: activation events used by
|
|
438
|
+
# auto_energy_on_focus_loss are delivered to the top-level window, never
|
|
439
|
+
# to a child widget. Painting still happens in backdrop coordinates.
|
|
440
|
+
self.energy = EnergyModeController(
|
|
441
|
+
self._host,
|
|
442
|
+
self._render_background,
|
|
443
|
+
duration_ms=self.config.transition_ms,
|
|
444
|
+
auto_on_focus_loss=self.config.auto_energy_on_focus_loss,
|
|
445
|
+
)
|
|
446
|
+
self.energy.energyChanged.connect(self._on_energy_changed)
|
|
447
|
+
self.energy.transitionFinished.connect(self._on_energy_finished)
|
|
448
|
+
self.energy.set_explicit(self.config.energy_saving)
|
|
449
|
+
|
|
450
|
+
# A child never receives its host's move/hide events, so watch them.
|
|
451
|
+
self._host.installEventFilter(self)
|
|
452
|
+
self._sync_background_source()
|
|
453
|
+
|
|
454
|
+
# -- public API -------------------------------------------------------
|
|
455
|
+
def setBrush(self, style: FrostStyle) -> None:
|
|
456
|
+
"""Set the frosted material used by this backdrop.
|
|
457
|
+
|
|
458
|
+
The style's ``tint`` doubles as the opaque colour of energy-saving
|
|
459
|
+
mode, so both backgrounds follow the brush.
|
|
460
|
+
"""
|
|
461
|
+
if not isinstance(style, FrostStyle):
|
|
462
|
+
raise TypeError("setBrush 需要 FrostStyle 实例")
|
|
463
|
+
self.background_style = style
|
|
464
|
+
self.update()
|
|
465
|
+
|
|
466
|
+
def setConfig(self, *args, **kwargs) -> None:
|
|
467
|
+
"""Update this backdrop's runtime settings.
|
|
468
|
+
|
|
469
|
+
Keyword names match :class:`GlassRuntimeSettings`; omitted fields keep
|
|
470
|
+
their current values. Positional arguments may be either one
|
|
471
|
+
``GlassRuntimeSettings`` instance or values in field order::
|
|
472
|
+
|
|
473
|
+
backdrop.setConfig(fps=30, background_mode="screen")
|
|
474
|
+
backdrop.setConfig(GlassRuntimeSettings(energy_saving=True))
|
|
475
|
+
backdrop.setConfig("screen", 30) # background_mode, fps
|
|
476
|
+
"""
|
|
477
|
+
unknown = set(kwargs) - set(_CONFIG_FIELDS)
|
|
478
|
+
if unknown:
|
|
479
|
+
raise TypeError("未知配置项: " + ", ".join(sorted(unknown)))
|
|
480
|
+
|
|
481
|
+
if len(args) == 1 and isinstance(args[0], GlassRuntimeSettings):
|
|
482
|
+
config = args[0].copy()
|
|
483
|
+
else:
|
|
484
|
+
if len(args) > len(_CONFIG_FIELDS):
|
|
485
|
+
raise TypeError(f"setConfig 最多接受 {len(_CONFIG_FIELDS)} 个位置参数")
|
|
486
|
+
config = replace(self.config, **dict(zip(_CONFIG_FIELDS, args)))
|
|
487
|
+
config = replace(config, **kwargs)
|
|
488
|
+
self._apply_config(config)
|
|
489
|
+
|
|
490
|
+
def shutdown(self) -> None:
|
|
491
|
+
"""Stop background work and release the capture exclusion.
|
|
492
|
+
|
|
493
|
+
Idempotent, and safe to call on a wrapper whose C++ object has already
|
|
494
|
+
been destroyed (for example when the host window was torn down by Qt
|
|
495
|
+
before the Python attribute was released).
|
|
496
|
+
"""
|
|
497
|
+
if self._shutdown_done or not shiboken6.isValid(self):
|
|
498
|
+
return
|
|
499
|
+
self._shutdown_done = True
|
|
500
|
+
try:
|
|
501
|
+
self._set_capture_excluded(False)
|
|
502
|
+
self._live_capture_active = False
|
|
503
|
+
self.desktop.set_dynamic(False)
|
|
504
|
+
self.desktop.set_paused(True)
|
|
505
|
+
self.wallpaper.stop()
|
|
506
|
+
except RuntimeError:
|
|
507
|
+
# Some child object was already deleted; nothing left to release.
|
|
508
|
+
pass
|
|
509
|
+
try:
|
|
510
|
+
if shiboken6.isValid(self._host):
|
|
511
|
+
self._host.removeEventFilter(self)
|
|
512
|
+
except RuntimeError:
|
|
513
|
+
pass
|
|
514
|
+
|
|
515
|
+
# -- host plumbing -----------------------------------------------------
|
|
516
|
+
def _global_rect(self) -> QRect:
|
|
517
|
+
"""Backdrop rect in global (screen) coordinates."""
|
|
518
|
+
return QRect(self.mapToGlobal(QPoint(0, 0)), self.size())
|
|
519
|
+
|
|
520
|
+
def _apply_config(self, config: GlassRuntimeSettings) -> None:
|
|
521
|
+
previous = self.config
|
|
522
|
+
self.config = config
|
|
523
|
+
|
|
524
|
+
if config.background_mode != previous.background_mode:
|
|
525
|
+
# Re-select wallpaper/desktop and redo capture region + policy.
|
|
526
|
+
self._sync_background_source()
|
|
527
|
+
else:
|
|
528
|
+
if config.fps != previous.fps:
|
|
529
|
+
self.desktop.set_fps(config.fps)
|
|
530
|
+
if config.allow_capture != previous.allow_capture:
|
|
531
|
+
self._update_capture_policy()
|
|
532
|
+
|
|
533
|
+
if config.energy_saving != previous.energy_saving:
|
|
534
|
+
self.energy.set_explicit(config.energy_saving)
|
|
535
|
+
if config.auto_energy_on_focus_loss != previous.auto_energy_on_focus_loss:
|
|
536
|
+
self.energy.set_auto_on_focus_loss(config.auto_energy_on_focus_loss)
|
|
537
|
+
if config.transition_ms != previous.transition_ms:
|
|
538
|
+
self.energy.set_duration(config.transition_ms)
|
|
539
|
+
|
|
540
|
+
self._update_capture_policy()
|
|
541
|
+
self.update()
|
|
542
|
+
|
|
543
|
+
# -- background source -----------------------------------------------
|
|
544
|
+
def _sync_background_source(self) -> None:
|
|
545
|
+
selected = self.wallpaper if self.config.wallpaper else self.desktop
|
|
546
|
+
if selected is not self.source:
|
|
547
|
+
self.source = selected
|
|
548
|
+
if self.config.wallpaper:
|
|
549
|
+
self.wallpaper.reload()
|
|
550
|
+
else:
|
|
551
|
+
self.desktop.set_dynamic(True)
|
|
552
|
+
self.desktop.set_fps(self.config.fps)
|
|
553
|
+
self._sync_capture_region()
|
|
554
|
+
self._update_capture_policy()
|
|
555
|
+
self.update()
|
|
556
|
+
|
|
557
|
+
def _sync_capture_region(self) -> None:
|
|
558
|
+
if self.config.screen_tracking:
|
|
559
|
+
self.desktop.set_capture_region(self._global_rect(), CAPTURE_SCALE)
|
|
560
|
+
|
|
561
|
+
# -- energy mode -----------------------------------------------------
|
|
562
|
+
def _on_energy_changed(self, _active: bool) -> None:
|
|
563
|
+
self._update_capture_policy()
|
|
564
|
+
self.update()
|
|
565
|
+
|
|
566
|
+
def _on_energy_finished(self, _active: bool) -> None:
|
|
567
|
+
self._update_capture_policy()
|
|
568
|
+
self.update()
|
|
569
|
+
|
|
570
|
+
def _update_capture_policy(self) -> None:
|
|
571
|
+
if not self._host.isVisible():
|
|
572
|
+
return
|
|
573
|
+
live = bool(self.config.screen_tracking and not self.config.allow_capture
|
|
574
|
+
and not self.energy.active)
|
|
575
|
+
was_live = self._live_capture_active
|
|
576
|
+
if live and not self._set_capture_excluded(True):
|
|
577
|
+
live = False
|
|
578
|
+
if (was_live and not live and not self.energy.active
|
|
579
|
+
and not self.config.wallpaper):
|
|
580
|
+
self.desktop.refresh_full()
|
|
581
|
+
if not live:
|
|
582
|
+
self._set_capture_excluded(False)
|
|
583
|
+
self._live_capture_active = live
|
|
584
|
+
self.desktop.set_paused(not live)
|
|
585
|
+
|
|
586
|
+
def _set_capture_excluded(self, enabled: bool) -> bool:
|
|
587
|
+
enabled = bool(enabled)
|
|
588
|
+
if enabled == self._capture_excluded:
|
|
589
|
+
return True
|
|
590
|
+
# Capture exclusion applies to the native top-level window only.
|
|
591
|
+
hwnd = int(self._host.winId())
|
|
592
|
+
if enabled:
|
|
593
|
+
self._capture_excluded = exclude_window_from_capture(hwnd, True)
|
|
594
|
+
else:
|
|
595
|
+
if self._capture_excluded:
|
|
596
|
+
exclude_window_from_capture(hwnd, False)
|
|
597
|
+
self._capture_excluded = False
|
|
598
|
+
return True if not enabled else self._capture_excluded
|
|
599
|
+
|
|
600
|
+
def _release_capture(self) -> None:
|
|
601
|
+
self._set_capture_excluded(False)
|
|
602
|
+
self._live_capture_active = False
|
|
603
|
+
self.desktop.set_paused(True)
|
|
604
|
+
|
|
605
|
+
# -- host events -------------------------------------------------------
|
|
606
|
+
def eventFilter(self, obj, event) -> bool: # noqa: N802
|
|
607
|
+
if obj is self._host:
|
|
608
|
+
event_type = event.type()
|
|
609
|
+
if event_type in (QEvent.Type.Move, QEvent.Type.Resize):
|
|
610
|
+
self._sync_capture_region()
|
|
611
|
+
self.update()
|
|
612
|
+
elif event_type == QEvent.Type.Hide:
|
|
613
|
+
self._release_capture()
|
|
614
|
+
elif event_type == QEvent.Type.Close:
|
|
615
|
+
# Pause immediately, but defer the full teardown: it removes
|
|
616
|
+
# this very event filter, which is not safe to do while Qt is
|
|
617
|
+
# iterating its filter list for the event being dispatched.
|
|
618
|
+
# Host closeEvent() normally performs the shutdown first, in
|
|
619
|
+
# which case the deferred call is a no-op.
|
|
620
|
+
self._release_capture()
|
|
621
|
+
QTimer.singleShot(0, self._deferred_shutdown)
|
|
622
|
+
return super().eventFilter(obj, event)
|
|
623
|
+
|
|
624
|
+
def _deferred_shutdown(self) -> None:
|
|
625
|
+
if not shiboken6.isValid(self):
|
|
626
|
+
return
|
|
627
|
+
# close() followed by show() before the timer fires: keep the glass
|
|
628
|
+
# alive for the re-shown window.
|
|
629
|
+
if shiboken6.isValid(self._host) and self._host.isVisible():
|
|
630
|
+
return
|
|
631
|
+
self.shutdown()
|
|
632
|
+
|
|
633
|
+
def showEvent(self, event) -> None: # noqa: N802
|
|
634
|
+
super().showEvent(event)
|
|
635
|
+
# close() only hides a window; re-arm so the next close tears down
|
|
636
|
+
# again and restart the wallpaper watcher stopped by shutdown().
|
|
637
|
+
self._shutdown_done = False
|
|
638
|
+
self._sync_background_source()
|
|
639
|
+
if self.config.wallpaper:
|
|
640
|
+
self.wallpaper.start_watch()
|
|
641
|
+
self._update_capture_policy()
|
|
642
|
+
|
|
643
|
+
def resizeEvent(self, event) -> None: # noqa: N802
|
|
644
|
+
super().resizeEvent(event)
|
|
645
|
+
self._sync_capture_region()
|
|
646
|
+
self.update()
|
|
647
|
+
|
|
648
|
+
# -- painting --------------------------------------------------------
|
|
649
|
+
def _render_background(self, energy: bool) -> QPixmap:
|
|
650
|
+
pixmap = QPixmap(self.size())
|
|
651
|
+
pixmap.fill(Qt.GlobalColor.transparent)
|
|
652
|
+
painter = QPainter(pixmap)
|
|
653
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
654
|
+
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
|
655
|
+
if energy:
|
|
656
|
+
self._paint_energy(painter)
|
|
657
|
+
else:
|
|
658
|
+
self._paint_normal(painter)
|
|
659
|
+
painter.end()
|
|
660
|
+
return pixmap
|
|
661
|
+
|
|
662
|
+
def _paint_energy(self, painter: QPainter) -> None:
|
|
663
|
+
# Energy-saving mode is a flat fill; the colour is the brush's tint.
|
|
664
|
+
painter.fillRect(self.rect(), QColor(self.background_style.tint))
|
|
665
|
+
painter.setPen(QPen(QColor(255, 255, 255, 26), 1))
|
|
666
|
+
painter.drawRect(QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5))
|
|
667
|
+
|
|
668
|
+
def paint_material(self, painter: QPainter, target_rect: QRectF,
|
|
669
|
+
global_rect: QRect, style: FrostStyle | None = None) -> None:
|
|
670
|
+
"""Paint the frosted material for ``global_rect`` into ``target_rect``.
|
|
671
|
+
|
|
672
|
+
``target_rect`` is in the painter's coordinate system while
|
|
673
|
+
``global_rect`` is the matching screen rectangle used to sample the
|
|
674
|
+
source image. Sibling widgets that need their own brush (for example
|
|
675
|
+
:class:`GlassMenuBar`) call this to stay pixel-continuous with the
|
|
676
|
+
backdrop behind them.
|
|
677
|
+
"""
|
|
678
|
+
style = self.background_style if style is None else style
|
|
679
|
+
image, target = self.source.processed_region(
|
|
680
|
+
global_rect,
|
|
681
|
+
style.blur,
|
|
682
|
+
style.brightness,
|
|
683
|
+
style.saturation,
|
|
684
|
+
)
|
|
685
|
+
if image is None or image.isNull():
|
|
686
|
+
painter.fillRect(target_rect, QColor(style.tint))
|
|
687
|
+
else:
|
|
688
|
+
offset = target.topLeft() - QPointF(global_rect.topLeft())
|
|
689
|
+
painter.drawImage(
|
|
690
|
+
QRectF(target_rect.x() + offset.x(), target_rect.y() + offset.y(),
|
|
691
|
+
target.width(), target.height()),
|
|
692
|
+
image,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
paint_frost_overlay(
|
|
696
|
+
painter,
|
|
697
|
+
target_rect,
|
|
698
|
+
style,
|
|
699
|
+
radius=0,
|
|
700
|
+
border=False,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
def _paint_normal(self, painter: QPainter) -> None:
|
|
704
|
+
self.paint_material(painter, QRectF(self.rect()), self._global_rect(),
|
|
705
|
+
self.background_style)
|
|
706
|
+
painter.setPen(QPen(QColor(255, 255, 255, 26), 1))
|
|
707
|
+
painter.drawRect(QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5))
|
|
708
|
+
|
|
709
|
+
def _paint_glass(self, painter: QPainter) -> None:
|
|
710
|
+
if self.energy.active:
|
|
711
|
+
self._paint_energy(painter)
|
|
712
|
+
else:
|
|
713
|
+
self._paint_normal(painter)
|
|
714
|
+
|
|
715
|
+
def paintEvent(self, event) -> None: # noqa: N802
|
|
716
|
+
painter = QPainter(self)
|
|
717
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
718
|
+
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
|
719
|
+
# Start from a clean translucent canvas so semi-transparent overlay
|
|
720
|
+
# frames do not accumulate.
|
|
721
|
+
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
|
|
722
|
+
painter.fillRect(self.rect(), Qt.GlobalColor.transparent)
|
|
723
|
+
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
|
|
724
|
+
if self.energy.paint(painter):
|
|
725
|
+
painter.end()
|
|
726
|
+
return
|
|
727
|
+
self._paint_glass(painter)
|
|
728
|
+
painter.end()
|
|
729
|
+
|
|
730
|
+
# -- window gestures (forwarded to the host) ---------------------------
|
|
731
|
+
def mousePressEvent(self, event) -> None: # noqa: N802
|
|
732
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
733
|
+
self._drag_offset = (
|
|
734
|
+
event.globalPosition().toPoint() - self._host.frameGeometry().topLeft()
|
|
735
|
+
)
|
|
736
|
+
event.accept()
|
|
737
|
+
return
|
|
738
|
+
super().mousePressEvent(event)
|
|
739
|
+
|
|
740
|
+
def mouseMoveEvent(self, event) -> None: # noqa: N802
|
|
741
|
+
if self._drag_offset is not None and event.buttons() & Qt.MouseButton.LeftButton:
|
|
742
|
+
self._host.move(event.globalPosition().toPoint() - self._drag_offset)
|
|
743
|
+
event.accept()
|
|
744
|
+
return
|
|
745
|
+
super().mouseMoveEvent(event)
|
|
746
|
+
|
|
747
|
+
def mouseReleaseEvent(self, event) -> None: # noqa: N802
|
|
748
|
+
self._drag_offset = None
|
|
749
|
+
super().mouseReleaseEvent(event)
|
|
750
|
+
|
|
751
|
+
def mouseDoubleClickEvent(self, event) -> None: # noqa: N802
|
|
752
|
+
if event.button() == Qt.MouseButton.LeftButton:
|
|
753
|
+
self._host.close()
|
|
754
|
+
return
|
|
755
|
+
super().mouseDoubleClickEvent(event)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
# ===========================================================================
|
|
759
|
+
# Glass panel (frost card inside a glass window)
|
|
760
|
+
# ===========================================================================
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
class GlassPanel(_FrostStyleMixin, QWidget):
|
|
764
|
+
"""Rounded frost card painted on top of a glass window.
|
|
765
|
+
|
|
766
|
+
Unlike :class:`GlassBackdrop` it captures nothing: it simply layers a
|
|
767
|
+
:class:`FrostStyle` material over whatever is already behind it (normally a
|
|
768
|
+
backdrop), so it can be used as a header bar, sidebar card or floating
|
|
769
|
+
badge. Put the panel's widgets into :attr:`content_layout`; they stay
|
|
770
|
+
sharp on top of the frost.
|
|
771
|
+
"""
|
|
772
|
+
|
|
773
|
+
#: Frost material painted by the panel.
|
|
774
|
+
background_style: FrostStyle = BUBBLE_BACKGROUND_NORMAL
|
|
775
|
+
|
|
776
|
+
#: QSS-accessible material fields (``qproperty-frost*``).
|
|
777
|
+
(
|
|
778
|
+
frostTint, frostOpacity, frostBlur, frostFrost, frostNoise, frostSheen,
|
|
779
|
+
frostSaturation, frostBrightness, frostBorder, frostRadius, frostVignette,
|
|
780
|
+
) = _frost_properties()
|
|
781
|
+
|
|
782
|
+
def __init__(self, layout, parent: QWidget | None = None, *,
|
|
783
|
+
style: FrostStyle | None = None) -> None:
|
|
784
|
+
super().__init__(parent)
|
|
785
|
+
self.setAutoFillBackground(False)
|
|
786
|
+
# Keep the panel compact in a vertical layout: height follows content.
|
|
787
|
+
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
|
788
|
+
self.content_layout = layout
|
|
789
|
+
if style is not None:
|
|
790
|
+
self.setBrush(style)
|
|
791
|
+
|
|
792
|
+
def setBrush(self, style: FrostStyle) -> None:
|
|
793
|
+
"""Set the material painted by this panel."""
|
|
794
|
+
if not isinstance(style, FrostStyle):
|
|
795
|
+
raise TypeError("setBrush 需要 FrostStyle 实例")
|
|
796
|
+
self.background_style = style
|
|
797
|
+
self.update()
|
|
798
|
+
|
|
799
|
+
def paintEvent(self, event) -> None: # noqa: N802
|
|
800
|
+
painter = QPainter(self)
|
|
801
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
802
|
+
style = self.background_style
|
|
803
|
+
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
|
804
|
+
paint_frost_overlay(
|
|
805
|
+
painter,
|
|
806
|
+
rect,
|
|
807
|
+
style,
|
|
808
|
+
radius=style.radius,
|
|
809
|
+
border=True,
|
|
810
|
+
)
|
|
811
|
+
painter.end()
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
# ===========================================================================
|
|
815
|
+
# Host windows
|
|
816
|
+
# ===========================================================================
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
class _GlassHostMixin:
|
|
820
|
+
"""Window-level plumbing shared by all glass host classes."""
|
|
821
|
+
|
|
822
|
+
backdrop: GlassBackdrop
|
|
823
|
+
|
|
824
|
+
def _attach_backdrop(self) -> GlassBackdrop:
|
|
825
|
+
# The window itself must be translucent; otherwise the glass would be
|
|
826
|
+
# composited over an opaque client area instead of over the desktop.
|
|
827
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
|
828
|
+
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
|
|
829
|
+
self.setAutoFillBackground(False)
|
|
830
|
+
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
831
|
+
self.backdrop = GlassBackdrop(self)
|
|
832
|
+
return self.backdrop
|
|
833
|
+
|
|
834
|
+
# -- public API -------------------------------------------------------
|
|
835
|
+
@property
|
|
836
|
+
def content_layout(self) -> QVBoxLayout:
|
|
837
|
+
"""Layout of the backdrop; add application widgets here."""
|
|
838
|
+
return self.backdrop.content_layout
|
|
839
|
+
|
|
840
|
+
def setBrush(self, style: FrostStyle) -> None:
|
|
841
|
+
"""Forward to :meth:`GlassBackdrop.setBrush`."""
|
|
842
|
+
self.backdrop.setBrush(style)
|
|
843
|
+
|
|
844
|
+
def setConfig(self, *args, **kwargs) -> None:
|
|
845
|
+
"""Forward to :meth:`GlassBackdrop.setConfig`."""
|
|
846
|
+
self.backdrop.setConfig(*args, **kwargs)
|
|
847
|
+
|
|
848
|
+
# -- events -----------------------------------------------------------
|
|
849
|
+
def keyPressEvent(self, event) -> None: # noqa: N802
|
|
850
|
+
if event.key() == Qt.Key.Key_Escape:
|
|
851
|
+
self.close()
|
|
852
|
+
return
|
|
853
|
+
super().keyPressEvent(event)
|
|
854
|
+
|
|
855
|
+
def closeEvent(self, event) -> None: # noqa: N802
|
|
856
|
+
backdrop = getattr(self, 'backdrop', None)
|
|
857
|
+
if backdrop is not None and shiboken6.isValid(backdrop):
|
|
858
|
+
backdrop.shutdown()
|
|
859
|
+
super().closeEvent(event)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
class GlassWindowBase(_GlassHostMixin, QWidget):
|
|
863
|
+
"""Plain top-level QWidget whose client area is the glass backdrop."""
|
|
864
|
+
|
|
865
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
866
|
+
super().__init__(parent)
|
|
867
|
+
layout = QVBoxLayout(self)
|
|
868
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
869
|
+
layout.setSpacing(0)
|
|
870
|
+
layout.addWidget(self._attach_backdrop())
|
|
871
|
+
|
|
872
|
+
def setLayout(self, layout) -> None: # noqa: N802
|
|
873
|
+
"""Mount an application layout on the glass content area.
|
|
874
|
+
|
|
875
|
+
The host window already owns the backdrop layout, so Qt would reject a
|
|
876
|
+
second ``setLayout()``. Forward the layout to the backdrop instead.
|
|
877
|
+
"""
|
|
878
|
+
if layout is None or layout is QWidget.layout(self):
|
|
879
|
+
return
|
|
880
|
+
self.content_layout.addLayout(layout)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
class GlassMainWindow(_GlassHostMixin, QMainWindow):
|
|
884
|
+
"""QMainWindow whose whole client area is covered by the glass backdrop.
|
|
885
|
+
|
|
886
|
+
The backdrop is a plain child widget that fills the client area and is
|
|
887
|
+
stacked below the menu bar, status bar and central widget, so all of them
|
|
888
|
+
can be transparent and share the same glass material. (QMainWindow's own
|
|
889
|
+
central widget cannot be used for the backdrop: it would exclude the menu
|
|
890
|
+
bar and status bar from the glass area.) Application content stays a
|
|
891
|
+
normal central widget; ``setCentralWidget()`` keeps standard behaviour.
|
|
892
|
+
"""
|
|
893
|
+
|
|
894
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
895
|
+
super().__init__(parent)
|
|
896
|
+
backdrop = self._attach_backdrop()
|
|
897
|
+
backdrop.setGeometry(self.rect())
|
|
898
|
+
backdrop.lower()
|
|
899
|
+
|
|
900
|
+
# -- layout -----------------------------------------------------------
|
|
901
|
+
def resizeEvent(self, event) -> None: # noqa: N802
|
|
902
|
+
super().resizeEvent(event)
|
|
903
|
+
backdrop = getattr(self, 'backdrop', None)
|
|
904
|
+
if backdrop is not None and shiboken6.isValid(backdrop):
|
|
905
|
+
backdrop.setGeometry(self.rect())
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
class GlassMenuBar(_FrostStyleMixin, QMenuBar):
|
|
909
|
+
"""QMenuBar that paints the sidebar material over a :class:`GlassMainWindow`.
|
|
910
|
+
|
|
911
|
+
A QMainWindow's menu bar is laid out outside the central widget, so the
|
|
912
|
+
bar cannot be a child of the backdrop. Instead the bar paints the
|
|
913
|
+
backdrop's blurred source itself with :attr:`background_style`
|
|
914
|
+
(``BUBBLE_SIDEBAR_NORMAL`` by default), keeping the pixels continuous with
|
|
915
|
+
the window glass while giving the bar its own brush::
|
|
916
|
+
|
|
917
|
+
window = GlassMainWindow()
|
|
918
|
+
window.setMenuBar(GlassMenuBar(window))
|
|
919
|
+
|
|
920
|
+
The material fields are QSS-able (``qproperty-frost*``) and a ``"thin"``
|
|
921
|
+
preset is available through ``GlassMenuBar[glassStyle="thin"]`` in the
|
|
922
|
+
theme style sheets (see :func:`apply_glass_style`). Popup menus (``QMenu``)
|
|
923
|
+
keep their normal opaque style.
|
|
924
|
+
"""
|
|
925
|
+
|
|
926
|
+
#: Frost material painted by the bar (sidebar variant by default).
|
|
927
|
+
background_style: FrostStyle = BUBBLE_SIDEBAR_NORMAL
|
|
928
|
+
|
|
929
|
+
#: QSS-accessible material fields (``qproperty-frost*``).
|
|
930
|
+
(
|
|
931
|
+
frostTint, frostOpacity, frostBlur, frostFrost, frostNoise, frostSheen,
|
|
932
|
+
frostSaturation, frostBrightness, frostBorder, frostRadius, frostVignette,
|
|
933
|
+
) = _frost_properties()
|
|
934
|
+
|
|
935
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
936
|
+
super().__init__(parent)
|
|
937
|
+
self.background_style = self.background_style.copy()
|
|
938
|
+
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground, True)
|
|
939
|
+
self.setAutoFillBackground(False)
|
|
940
|
+
# A widget-local rule outranks the application style sheet, so an
|
|
941
|
+
# opaque ``QMenuBar { background-color: ... }`` there is neutralised
|
|
942
|
+
# while the ::item rules keep working.
|
|
943
|
+
self.setStyleSheet('QMenuBar { background: transparent; }')
|
|
944
|
+
self._watched_host: QWidget | None = None
|
|
945
|
+
self._watch_host(self.parentWidget())
|
|
946
|
+
|
|
947
|
+
# -- host tracking ----------------------------------------------------
|
|
948
|
+
def _watch_host(self, host: QWidget | None) -> None:
|
|
949
|
+
"""Re-sample the wallpaper when the host window moves/resizes."""
|
|
950
|
+
if host is self._watched_host:
|
|
951
|
+
return
|
|
952
|
+
old = self._watched_host
|
|
953
|
+
if old is not None and shiboken6.isValid(old):
|
|
954
|
+
try:
|
|
955
|
+
old.removeEventFilter(self)
|
|
956
|
+
except RuntimeError:
|
|
957
|
+
pass
|
|
958
|
+
self._watched_host = host
|
|
959
|
+
if host is not None:
|
|
960
|
+
host.installEventFilter(self)
|
|
961
|
+
|
|
962
|
+
def event(self, event) -> bool: # noqa: N802
|
|
963
|
+
if event.type() == QEvent.Type.ParentChange:
|
|
964
|
+
self._watch_host(self.parentWidget())
|
|
965
|
+
return super().event(event)
|
|
966
|
+
|
|
967
|
+
def eventFilter(self, obj, event) -> bool: # noqa: N802
|
|
968
|
+
if obj is self._watched_host and event.type() in (
|
|
969
|
+
QEvent.Type.Move, QEvent.Type.Resize
|
|
970
|
+
):
|
|
971
|
+
self.update()
|
|
972
|
+
return super().eventFilter(obj, event)
|
|
973
|
+
|
|
974
|
+
# -- painting ---------------------------------------------------------
|
|
975
|
+
def paintEvent(self, event) -> None: # noqa: N802
|
|
976
|
+
painter = QPainter(self)
|
|
977
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
978
|
+
style = self.background_style
|
|
979
|
+
backdrop = self._backdrop()
|
|
980
|
+
if backdrop is None:
|
|
981
|
+
# No glass window behind us: paint the brush over whatever is
|
|
982
|
+
# already there (best effort, e.g. inside a plain QMainWindow).
|
|
983
|
+
paint_frost_overlay(painter, QRectF(self.rect()), style,
|
|
984
|
+
radius=0, border=False)
|
|
985
|
+
elif backdrop.energy.active:
|
|
986
|
+
painter.fillRect(self.rect(), QColor(style.tint))
|
|
987
|
+
else:
|
|
988
|
+
global_rect = QRect(self.mapToGlobal(QPoint(0, 0)), self.size())
|
|
989
|
+
backdrop.paint_material(painter, QRectF(self.rect()), global_rect, style)
|
|
990
|
+
painter.end()
|
|
991
|
+
super().paintEvent(event)
|
|
992
|
+
|
|
993
|
+
def _backdrop(self) -> "GlassBackdrop | None":
|
|
994
|
+
parent = self.parentWidget()
|
|
995
|
+
backdrop = getattr(parent, 'backdrop', None) if parent is not None else None
|
|
996
|
+
if backdrop is not None and shiboken6.isValid(backdrop):
|
|
997
|
+
return backdrop
|
|
998
|
+
return None
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
class GlassDialog(_GlassHostMixin, QDialog):
|
|
1002
|
+
"""QDialog whose whole client area is glass.
|
|
1003
|
+
|
|
1004
|
+
The backdrop fills the dialog edge to edge; application content is inset
|
|
1005
|
+
by :attr:`CONTENT_MARGIN` and separated by :attr:`CONTENT_SPACING`.
|
|
1006
|
+
"""
|
|
1007
|
+
|
|
1008
|
+
#: Padding between the dialog edge and its content.
|
|
1009
|
+
CONTENT_MARGIN = 12
|
|
1010
|
+
#: Spacing between content widgets.
|
|
1011
|
+
CONTENT_SPACING = 8
|
|
1012
|
+
|
|
1013
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
1014
|
+
super().__init__(parent)
|
|
1015
|
+
layout = QVBoxLayout(self)
|
|
1016
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
1017
|
+
layout.setSpacing(0)
|
|
1018
|
+
layout.addWidget(self._attach_backdrop())
|
|
1019
|
+
margin = self.CONTENT_MARGIN
|
|
1020
|
+
self.content_layout.setContentsMargins(margin, margin, margin, margin)
|
|
1021
|
+
self.content_layout.setSpacing(self.CONTENT_SPACING)
|
|
1022
|
+
|
|
1023
|
+
def setLayout(self, layout) -> None: # noqa: N802
|
|
1024
|
+
"""Mount an application layout on the glass content area.
|
|
1025
|
+
|
|
1026
|
+
The dialog owns the outer padding, so the mounted layout loses its own
|
|
1027
|
+
margins (they would otherwise add up to more than ``CONTENT_MARGIN``)
|
|
1028
|
+
and uses ``CONTENT_SPACING`` between its widgets.
|
|
1029
|
+
"""
|
|
1030
|
+
if layout is None or layout is QWidget.layout(self):
|
|
1031
|
+
return
|
|
1032
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
1033
|
+
layout.setSpacing(self.CONTENT_SPACING)
|
|
1034
|
+
self.content_layout.addLayout(layout)
|