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