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,823 @@
1
+ """Image plumbing for the frosted-glass surfaces.
2
+
3
+ The toolkit composites frost itself instead of relying on an OS backdrop
4
+ material. That gives independent, smoothly adjustable blur radii for each
5
+ glass region as well as colour/saturation controls.
6
+
7
+ Two backdrop sources are provided: :class:`DesktopSource` for live screen
8
+ tracking and :class:`WallpaperSource` for a Mica-like static system wallpaper
9
+ that only reloads when Windows reports a wallpaper change.
10
+
11
+ Pillow is used only for Gaussian blur and noise generation; all UI drawing
12
+ stays in Qt. When Pillow or screen capture is unavailable the library falls
13
+ back gracefully to a generated background.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ctypes
19
+ import ctypes.wintypes as wt
20
+ import os
21
+ import random
22
+ import sys
23
+ from collections import OrderedDict
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+
27
+ from PySide6.QtCore import (
28
+ QFileSystemWatcher,
29
+ QObject,
30
+ QPoint,
31
+ QRect,
32
+ QRectF,
33
+ QSize,
34
+ Qt,
35
+ QTimer,
36
+ Signal,
37
+ )
38
+ from PySide6.QtGui import (
39
+ QColor,
40
+ QGuiApplication,
41
+ QImage,
42
+ QLinearGradient,
43
+ QPainter,
44
+ QPixmap,
45
+ QRadialGradient,
46
+ )
47
+
48
+ try: # Pillow is optional at import time; the UI degrades to no blur.
49
+ from PIL import Image, ImageEnhance, ImageFilter, ImageGrab
50
+ _PIL_AVAILABLE = True
51
+ except Exception: # pragma: no cover - only on minimal machines
52
+ _PIL_AVAILABLE = False
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Qt <-> Pillow bridges
57
+ # ---------------------------------------------------------------------------
58
+
59
+ def qimage_to_pil(image: QImage) -> "Image.Image":
60
+ converted = image.convertToFormat(QImage.Format.Format_RGBA8888)
61
+ width, height = converted.width(), converted.height()
62
+ stride = converted.bytesPerLine()
63
+ data = bytes(converted.constBits())
64
+ return Image.frombuffer("RGBA", (width, height), data, "raw", "RGBA", stride, 1).copy()
65
+
66
+
67
+ def pil_to_qimage(image: "Image.Image") -> QImage:
68
+ image = image.convert("RGBA")
69
+ data = image.tobytes("raw", "RGBA")
70
+ qimage = QImage(data, image.width, image.height, image.width * 4,
71
+ QImage.Format.Format_RGBA8888)
72
+ return qimage.copy()
73
+
74
+
75
+ def blur_qimage(image: QImage, radius: float, brightness: int = 100,
76
+ saturation: int = 100) -> QImage:
77
+ """Colour grade then Gaussian-blur a QImage.
78
+
79
+ ``radius`` is in image pixels. All adjustments are optional and returning
80
+ a copy keeps Qt's implicit sharing detached from Pillow's buffers.
81
+ """
82
+ if image.isNull():
83
+ return image
84
+ if not _PIL_AVAILABLE:
85
+ return image.copy()
86
+ if radius <= 0.35 and brightness == 100 and saturation == 100:
87
+ return image.copy()
88
+
89
+ pil = qimage_to_pil(image)
90
+ if brightness != 100:
91
+ pil = ImageEnhance.Brightness(pil).enhance(brightness / 100.0)
92
+ if saturation != 100:
93
+ pil = ImageEnhance.Color(pil).enhance(saturation / 100.0)
94
+ if radius > 0.35:
95
+ pil = pil.filter(ImageFilter.GaussianBlur(float(radius)))
96
+ return pil_to_qimage(pil)
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Procedural textures
101
+ # ---------------------------------------------------------------------------
102
+
103
+ _TEXTURE_CACHE: dict[tuple, QPixmap] = {}
104
+
105
+
106
+ def _noise_alpha_layer(size: int, sigma: float, gain: float, seed: int) -> QImage:
107
+ random.seed(seed)
108
+ noise = Image.effect_noise((size, size), sigma).convert("L")
109
+ # Gaussian noise centres around 128. Turn it into sparse high/lowlights.
110
+ lut = []
111
+ for value in range(256):
112
+ delta = abs(value - 128) / 128.0
113
+ lut.append(int(max(0, min(255, (delta ** 1.7) * 255 * gain))))
114
+ alpha = noise.point(lut)
115
+ layer = Image.new("RGBA", (size, size), (255, 255, 255, 0))
116
+ layer.putalpha(alpha)
117
+ return pil_to_qimage(layer)
118
+
119
+
120
+ def noise_texture(kind: str = "grain", size: int = 768) -> QPixmap:
121
+ """Return a tileable white-alpha product used for frost / grain overlays.
122
+
123
+ ``grain`` is high-frequency film grain, ``cloud`` is a soft frosted haze.
124
+ Textures are cached for the lifetime of the process.
125
+ """
126
+ key = (kind, size)
127
+ cached = _TEXTURE_CACHE.get(key)
128
+ if cached is not None:
129
+ return cached
130
+ if not _PIL_AVAILABLE:
131
+ pix = QPixmap(size, size)
132
+ pix.fill(Qt.GlobalColor.transparent)
133
+ _TEXTURE_CACHE[key] = pix
134
+ return pix
135
+
136
+ if kind == "cloud":
137
+ random.seed(41)
138
+ tiny = Image.effect_noise((96, 96), 72).convert("L").filter(ImageFilter.GaussianBlur(2.2))
139
+ cloud = tiny.resize((size, size), Image.Resampling.BICUBIC)
140
+ cloud = cloud.filter(ImageFilter.GaussianBlur(10))
141
+ lut = [int(max(0, min(255, (abs(v - 128) / 128.0) ** 1.4 * 135))) for v in range(256)]
142
+ alpha = cloud.point(lut)
143
+ layer = Image.new("RGBA", (size, size), (255, 255, 255, 0))
144
+ layer.putalpha(alpha)
145
+ image = pil_to_qimage(layer)
146
+ else:
147
+ image = _noise_alpha_layer(size, 82, 0.85, 9)
148
+ pix = QPixmap.fromImage(image)
149
+ _TEXTURE_CACHE[key] = pix
150
+ return pix
151
+
152
+
153
+ def clear_texture_cache() -> None:
154
+ _TEXTURE_CACHE.clear()
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Desktop capture
159
+ # ---------------------------------------------------------------------------
160
+
161
+ def _fallback_background(size: QSize) -> QImage:
162
+ size = size if size.isValid() and size.width() > 10 else QSize(1920, 1080)
163
+ image = QImage(size, QImage.Format.Format_RGBA8888)
164
+ image.fill(QColor("#202329"))
165
+ painter = QPainter(image)
166
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
167
+ gradient = QLinearGradient(0, 0, size.width(), size.height())
168
+ gradient.setColorAt(0.0, QColor("#32363E"))
169
+ gradient.setColorAt(0.45, QColor("#1D2026"))
170
+ gradient.setColorAt(1.0, QColor("#0E1014"))
171
+ painter.fillRect(image.rect(), gradient)
172
+ for cx, cy, radius, color in (
173
+ (0.18, 0.22, 0.34, QColor(127, 149, 190, 92)),
174
+ (0.82, 0.30, 0.30, QColor(219, 189, 132, 66)),
175
+ (0.60, 0.86, 0.40, QColor(84, 157, 165, 44)),
176
+ ):
177
+ rad = QRadialGradient(QPoint(int(size.width() * cx), int(size.height() * cy)),
178
+ int(size.width() * radius))
179
+ rad.setColorAt(0.0, color)
180
+ rad.setColorAt(1.0, QColor(0, 0, 0, 0))
181
+ painter.setBrush(rad)
182
+ painter.setPen(Qt.PenStyle.NoPen)
183
+ painter.drawEllipse(QPoint(int(size.width() * cx), int(size.height() * cy)),
184
+ int(size.width() * radius), int(size.width() * radius))
185
+ painter.end()
186
+ return image
187
+
188
+
189
+ def _grab_screen() -> QImage | None:
190
+ """Capture the primary screen as a QImage.
191
+
192
+ Qt's screen grab is used first because it is noticeably faster than
193
+ Pillow's ImageGrab on Windows. Pillow remains a fallback.
194
+ """
195
+ screen = QGuiApplication.primaryScreen()
196
+ if screen is not None:
197
+ try:
198
+ pixmap = screen.grabWindow(0)
199
+ if not pixmap.isNull():
200
+ image = pixmap.toImage()
201
+ if not image.isNull():
202
+ return image
203
+ except Exception:
204
+ pass
205
+
206
+ if _PIL_AVAILABLE:
207
+ try:
208
+ shot = ImageGrab.grab(all_screens=False)
209
+ if shot is not None and shot.width > 0:
210
+ return pil_to_qimage(shot.convert("RGBA"))
211
+ except Exception:
212
+ pass
213
+ return None
214
+
215
+
216
+ WDA_EXCLUDEFROMCAPTURE = 0x00000011
217
+
218
+ _GDI_AVAILABLE = sys.platform == "win32"
219
+ if _GDI_AVAILABLE:
220
+ _user32 = ctypes.WinDLL("user32", use_last_error=True)
221
+ _gdi32 = ctypes.WinDLL("gdi32")
222
+ _user32.GetDC.restype = wt.HDC
223
+ _user32.GetDC.argtypes = [wt.HWND]
224
+ _user32.ReleaseDC.argtypes = [wt.HWND, wt.HDC]
225
+ _gdi32.CreateCompatibleDC.restype = wt.HDC
226
+ _gdi32.CreateCompatibleDC.argtypes = [wt.HDC]
227
+ _gdi32.CreateCompatibleBitmap.restype = wt.HBITMAP
228
+ _gdi32.CreateCompatibleBitmap.argtypes = [wt.HDC, ctypes.c_int, ctypes.c_int]
229
+ _gdi32.SelectObject.restype = wt.HGDIOBJ
230
+ _gdi32.SelectObject.argtypes = [wt.HDC, wt.HGDIOBJ]
231
+ _gdi32.DeleteObject.argtypes = [wt.HGDIOBJ]
232
+ _gdi32.DeleteDC.argtypes = [wt.HDC]
233
+ _gdi32.StretchBlt.argtypes = [
234
+ wt.HDC, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
235
+ wt.HDC, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int,
236
+ wt.DWORD,
237
+ ]
238
+
239
+
240
+ class _BitmapInfoHeader(ctypes.Structure):
241
+ _fields_ = [
242
+ ("biSize", wt.DWORD),
243
+ ("biWidth", wt.LONG),
244
+ ("biHeight", wt.LONG),
245
+ ("biPlanes", wt.WORD),
246
+ ("biBitCount", wt.WORD),
247
+ ("biCompression", wt.DWORD),
248
+ ("biSizeImage", wt.DWORD),
249
+ ("biXPelsPerMeter", wt.LONG),
250
+ ("biYPelsPerMeter", wt.LONG),
251
+ ("biClrUsed", wt.DWORD),
252
+ ("biClrImportant", wt.DWORD),
253
+ ]
254
+
255
+
256
+ class _BitmapInfo(ctypes.Structure):
257
+ _fields_ = [("bmiHeader", _BitmapInfoHeader), ("bmiColors", wt.DWORD * 3)]
258
+
259
+
260
+ if _GDI_AVAILABLE:
261
+ _gdi32.SetStretchBltMode.argtypes = [wt.HDC, ctypes.c_int]
262
+ _gdi32.GetDIBits.argtypes = [
263
+ wt.HDC, wt.HBITMAP, wt.UINT, wt.UINT, ctypes.c_void_p,
264
+ ctypes.POINTER(_BitmapInfo), wt.UINT,
265
+ ]
266
+ _gdi32.GetDIBits.restype = ctypes.c_int
267
+
268
+
269
+ def _grab_region_gdi(global_rect: QRect, out_width: int, out_height: int) -> QImage | None:
270
+ """Capture one logical screen region through GDI at a reduced size.
271
+
272
+ Only the window area is captured, which is both faster and cheaper than
273
+ grabbing the full 2560x1440 desktop every frame. Windows excluded with
274
+ :func:`exclude_window_from_capture` are omitted by the compositor just as
275
+ they are for a full-screen grab.
276
+ """
277
+ if not _GDI_AVAILABLE:
278
+ return None
279
+ screen = QGuiApplication.primaryScreen()
280
+ if screen is None:
281
+ return None
282
+ dpr = max(1.0, float(screen.devicePixelRatio()))
283
+ geo = screen.geometry()
284
+ src_x = int(round((global_rect.x() - geo.x()) * dpr))
285
+ src_y = int(round((global_rect.y() - geo.y()) * dpr))
286
+ src_w = max(1, int(round(global_rect.width() * dpr)))
287
+ src_h = max(1, int(round(global_rect.height() * dpr)))
288
+ out_width = max(2, min(4096, int(out_width)))
289
+ out_height = max(2, min(4096, int(out_height)))
290
+
291
+ hdc_screen = _user32.GetDC(None)
292
+ if not hdc_screen:
293
+ return None
294
+ hdc_mem = _gdi32.CreateCompatibleDC(hdc_screen)
295
+ bitmap = _gdi32.CreateCompatibleBitmap(hdc_screen, out_width, out_height)
296
+ if not hdc_mem or not bitmap:
297
+ if bitmap:
298
+ _gdi32.DeleteObject(bitmap)
299
+ if hdc_mem:
300
+ _gdi32.DeleteDC(hdc_mem)
301
+ _user32.ReleaseDC(None, hdc_screen)
302
+ return None
303
+
304
+ old = _gdi32.SelectObject(hdc_mem, bitmap)
305
+ _gdi32.SetStretchBltMode(hdc_mem, 4) # HALFTONE
306
+ ok = _gdi32.StretchBlt(
307
+ hdc_mem, 0, 0, out_width, out_height,
308
+ hdc_screen, src_x, src_y, src_w, src_h,
309
+ 0x00CC0020, # SRCCOPY
310
+ )
311
+
312
+ info = _BitmapInfo()
313
+ info.bmiHeader.biSize = ctypes.sizeof(_BitmapInfoHeader)
314
+ info.bmiHeader.biWidth = out_width
315
+ info.bmiHeader.biHeight = -out_height
316
+ info.bmiHeader.biPlanes = 1
317
+ info.bmiHeader.biBitCount = 32
318
+
319
+ image = QImage(out_width, out_height, QImage.Format.Format_RGB32)
320
+ bits = image.bits()
321
+ bits_ptr = ctypes.addressof(ctypes.c_char.from_buffer(bits))
322
+ got = _gdi32.GetDIBits(hdc_mem, bitmap, 0, out_height,
323
+ ctypes.c_void_p(bits_ptr),
324
+ ctypes.byref(info), 0)
325
+
326
+ _gdi32.SelectObject(hdc_mem, old)
327
+ _gdi32.DeleteObject(bitmap)
328
+ _gdi32.DeleteDC(hdc_mem)
329
+ _user32.ReleaseDC(None, hdc_screen)
330
+
331
+ if not ok or got != out_height:
332
+ return None
333
+ return image
334
+
335
+
336
+ def exclude_window_from_capture(hwnd: int, enabled: bool = True) -> bool:
337
+ """Exclude a native window from screen captures (Windows 10 2004+).
338
+
339
+ Dynamic desktop capture must not see the application's own glass windows,
340
+ otherwise the blur would feed on itself. The helper is a no-op on other
341
+ platforms and returns whether the system accepted the request.
342
+ """
343
+ if sys.platform != "win32":
344
+ return False
345
+ try:
346
+ affinity = WDA_EXCLUDEFROMCAPTURE if enabled else 0
347
+ return bool(ctypes.windll.user32.SetWindowDisplayAffinity(int(hwnd), affinity))
348
+ except Exception:
349
+ return False
350
+
351
+
352
+ _SPI_GETDESKWALLPAPER = 0x0073
353
+
354
+
355
+ def system_wallpaper_path() -> str:
356
+ """Return the best available path to the current Windows wallpaper.
357
+
358
+ ``SPI_GETDESKWALLPAPER`` points at the source image selected by the user.
359
+ Some themes and slideshow configurations leave it empty; in that case the
360
+ active ``TranscodedWallpaper`` file is used instead.
361
+ """
362
+ if sys.platform != "win32":
363
+ return ""
364
+ path = ""
365
+ try:
366
+ buffer = ctypes.create_unicode_buffer(1024)
367
+ if ctypes.windll.user32.SystemParametersInfoW(
368
+ _SPI_GETDESKWALLPAPER, len(buffer), buffer, 0
369
+ ):
370
+ path = buffer.value.strip()
371
+ except Exception:
372
+ path = ""
373
+ if path and Path(path).exists():
374
+ return path
375
+
376
+ appdata = os.environ.get("APPDATA")
377
+ if appdata:
378
+ transcoded = Path(appdata) / "Microsoft" / "Windows" / "Themes" / "TranscodedWallpaper"
379
+ if transcoded.exists():
380
+ return str(transcoded)
381
+ return ""
382
+
383
+
384
+ def _wallpaper_state(geometry: QRect | None = None, dpr: float = 1.0) -> tuple:
385
+ """Cheap signature used to detect wallpaper file or screen changes."""
386
+ appdata = os.environ.get("APPDATA", "")
387
+ transcoded = (Path(appdata) / "Microsoft" / "Windows" / "Themes" / "TranscodedWallpaper"
388
+ if appdata else None)
389
+ files = []
390
+ for kind, path in (("source", system_wallpaper_path()), ("transcoded", str(transcoded) if transcoded else "")):
391
+ if not path:
392
+ files.append((kind, ""))
393
+ continue
394
+ try:
395
+ stat = Path(path).stat()
396
+ files.append((kind, path, stat.st_mtime_ns, stat.st_size))
397
+ except OSError:
398
+ files.append((kind, path, None, None))
399
+ geo = geometry or QRect()
400
+ return (geo.x(), geo.y(), geo.width(), geo.height(), round(float(dpr), 3), tuple(files))
401
+
402
+
403
+ def _load_wallpaper_image(path: str, geometry: QRect, dpr: float) -> QImage | None:
404
+ """Load and cover-fit the wallpaper for the primary screen."""
405
+ if not path:
406
+ return None
407
+ try:
408
+ image = QImage(path)
409
+ except Exception:
410
+ image = QImage()
411
+ if image.isNull() and _PIL_AVAILABLE:
412
+ try:
413
+ pil = Image.open(path).convert("RGBA")
414
+ image = pil_to_qimage(pil)
415
+ except Exception:
416
+ image = QImage()
417
+ if image.isNull():
418
+ return None
419
+
420
+ target = QSize(max(2, int(round(geometry.width() * dpr))),
421
+ max(2, int(round(geometry.height() * dpr))))
422
+ if image.size() != target:
423
+ image = image.scaled(
424
+ target,
425
+ Qt.AspectRatioMode.KeepAspectRatioByExpanding,
426
+ Qt.TransformationMode.SmoothTransformation,
427
+ )
428
+ if image.width() > target.width() or image.height() > target.height():
429
+ x = max(0, (image.width() - target.width()) // 2)
430
+ y = max(0, (image.height() - target.height()) // 2)
431
+ image = image.copy(x, y, target.width(), target.height())
432
+ return image
433
+
434
+
435
+ class DesktopSource(QObject):
436
+ """A desktop capture with cached blur variants and optional live refresh.
437
+
438
+ ``dynamic`` is enabled by default: a precise timer re-captures the primary
439
+ screen at ``fps`` frames per second (15 by default) and emits ``updated``.
440
+ Windows belonging to the application should be passed through
441
+ :func:`exclude_window_from_capture` so they are not captured into the
442
+ backdrop.
443
+
444
+ Static use remains identical: construct once, call :meth:`processed` (or
445
+ :meth:`processed_region`) and paint the result.
446
+ """
447
+
448
+ updated = Signal()
449
+
450
+ def __init__(self, image: QImage | None = None, screen_geometry: QRect | None = None,
451
+ dpr: float = 1.0, captured_at: datetime | None = None,
452
+ dynamic: bool = True, fps: int = 15,
453
+ parent: QObject | None = None):
454
+ super().__init__(parent)
455
+ screen = QGuiApplication.primaryScreen()
456
+ geometry = QRect(screen_geometry or (screen.geometry() if screen else QRect(0, 0, 1920, 1080)))
457
+ self._dynamic = False
458
+ self._fps = self._clamp_fps(fps)
459
+ self._revision = 0
460
+ self._cache: "OrderedDict[tuple, QImage]" = OrderedDict()
461
+ self._region_cache: "OrderedDict[tuple, tuple[QImage, QRectF]]" = OrderedDict()
462
+ self._capture_region: QRect | None = None
463
+ self._capture_scale = 0.5
464
+ self._paused = False
465
+
466
+ self._timer = QTimer(self)
467
+ self._timer.setTimerType(Qt.TimerType.PreciseTimer)
468
+ self._timer.timeout.connect(self._capture_tick)
469
+
470
+ self._apply(image, geometry, dpr, captured_at)
471
+ if dynamic:
472
+ self.set_dynamic(True)
473
+
474
+ # -- construction ------------------------------------------------------
475
+ @classmethod
476
+ def capture(cls, *, dynamic: bool = True, fps: int = 15) -> "DesktopSource":
477
+ screen = QGuiApplication.primaryScreen()
478
+ geometry = QRect(screen.geometry()) if screen else QRect(0, 0, 1920, 1080)
479
+ dpr = float(screen.devicePixelRatio()) if screen else 1.0
480
+ image = _grab_screen()
481
+ return cls(image, geometry, dpr, dynamic=dynamic, fps=fps)
482
+
483
+ # -- dynamic capture ---------------------------------------------------
484
+ @property
485
+ def dynamic(self) -> bool:
486
+ return self._dynamic
487
+
488
+ @property
489
+ def fps(self) -> int:
490
+ return self._fps
491
+
492
+ @property
493
+ def revision(self) -> int:
494
+ """Increases whenever the captured image changes."""
495
+ return self._revision
496
+
497
+ @staticmethod
498
+ def _clamp_fps(fps: int) -> int:
499
+ try:
500
+ value = int(fps)
501
+ except (TypeError, ValueError):
502
+ value = 15
503
+ return max(1, min(30, value))
504
+
505
+ def _interval_ms(self) -> int:
506
+ return max(1, int(round(1000.0 / self._fps)))
507
+
508
+ def set_dynamic(self, enabled: bool) -> None:
509
+ enabled = bool(enabled)
510
+ if enabled == self._dynamic:
511
+ return
512
+ self._dynamic = enabled
513
+ if enabled:
514
+ # An explicit user enable overrides a stale interaction pause.
515
+ self._paused = False
516
+ self._timer.start(self._interval_ms())
517
+ self._capture_tick()
518
+ else:
519
+ self._timer.stop()
520
+ self._paused = False
521
+
522
+ def set_paused(self, paused: bool) -> None:
523
+ """Temporarily pause live capture without changing the user switch.
524
+
525
+ Window moves/resizes use this so the native move loop stays responsive.
526
+ """
527
+ paused = bool(paused)
528
+ if paused == self._paused:
529
+ return
530
+ self._paused = paused
531
+ if not self._dynamic:
532
+ return
533
+ if paused:
534
+ self._timer.stop()
535
+ else:
536
+ self._timer.start(self._interval_ms())
537
+ self._capture_tick()
538
+
539
+ def set_capture_region(self, region: QRect | None, scale: float = 0.5) -> None:
540
+ """Limit live capture to one window region at a reduced size."""
541
+ self._capture_region = QRect(region) if region is not None and not region.isNull() else None
542
+ self._capture_scale = max(0.25, min(1.0, float(scale)))
543
+
544
+ def set_fps(self, fps: int) -> None:
545
+ value = self._clamp_fps(fps)
546
+ if value == self._fps:
547
+ return
548
+ self._fps = value
549
+ if self._dynamic:
550
+ self._timer.setInterval(self._interval_ms())
551
+
552
+ def refresh_full(self) -> None:
553
+ """Capture one full-screen frame, regardless of the region setting.
554
+
555
+ Used when live sampling is turned off for capture compatibility, so the
556
+ static glass still has a complete desktop image while the window moves.
557
+ """
558
+ screen = QGuiApplication.primaryScreen()
559
+ geometry = QRect(screen.geometry()) if screen else self.screen_geometry
560
+ dpr = float(screen.devicePixelRatio()) if screen else self.dpr
561
+ image = _grab_screen()
562
+ if image is None or image.isNull():
563
+ return
564
+ self._apply(image, geometry, dpr, datetime.now())
565
+ self.updated.emit()
566
+
567
+ def _capture_tick(self) -> None:
568
+ if not self._dynamic or self._paused:
569
+ return
570
+ screen = QGuiApplication.primaryScreen()
571
+ geometry = QRect(screen.geometry()) if screen else self.screen_geometry
572
+ dpr = float(screen.devicePixelRatio()) if screen else self.dpr
573
+
574
+ if self._capture_region is not None and _GDI_AVAILABLE:
575
+ region = self._capture_region.intersected(geometry)
576
+ if not region.isEmpty():
577
+ scale = min(self._capture_scale, 1280.0 / max(1, max(region.width(), region.height())))
578
+ scale = max(0.2, scale)
579
+ out_w = max(2, int(round(region.width() * scale)))
580
+ out_h = max(2, int(round(region.height() * scale)))
581
+ image = _grab_region_gdi(region, out_w, out_h)
582
+ if image is not None and not image.isNull():
583
+ self._apply(image, region, dpr, datetime.now())
584
+ self.updated.emit()
585
+ return
586
+
587
+ image = _grab_screen()
588
+ if image is None or image.isNull():
589
+ return
590
+ self._apply(image, geometry, dpr, datetime.now())
591
+ self.updated.emit()
592
+
593
+ # -- image state -------------------------------------------------------
594
+ def _apply(self, image: QImage | None, screen_geometry: QRect, dpr: float,
595
+ captured_at: datetime | None) -> None:
596
+ self.screen_geometry = QRect(screen_geometry)
597
+ self.dpr = float(dpr or 1.0)
598
+ if image is None or image.isNull():
599
+ width = max(16, int(round(self.screen_geometry.width() * self.dpr)))
600
+ height = max(16, int(round(self.screen_geometry.height() * self.dpr)))
601
+ image = _fallback_background(QSize(width, height))
602
+ self.captured_at = captured_at
603
+ self.is_fallback = True
604
+ else:
605
+ self.captured_at = captured_at or datetime.now()
606
+ self.is_fallback = False
607
+
608
+ self.image = image
609
+ self.scale_x = image.width() / max(1, self.screen_geometry.width())
610
+ self.scale_y = image.height() / max(1, self.screen_geometry.height())
611
+ self._cache.clear()
612
+ self._region_cache.clear()
613
+ self._revision += 1
614
+
615
+ # -- geometry ----------------------------------------------------------
616
+ def source_rect(self, global_rect: QRect) -> QRectF:
617
+ """Map a logical global rectangle to pixels in the stored capture."""
618
+ x = (global_rect.x() - self.screen_geometry.x()) * self.scale_x
619
+ y = (global_rect.y() - self.screen_geometry.y()) * self.scale_y
620
+ return QRectF(x, y, max(1.0, global_rect.width() * self.scale_x),
621
+ max(1.0, global_rect.height() * self.scale_y))
622
+
623
+ # -- processing --------------------------------------------------------
624
+ def processed(self, radius: int, brightness: int = 100, saturation: int = 100,
625
+ max_dimension: int = 1280) -> QImage:
626
+ """Return a blurred variant, cached by its settings.
627
+
628
+ Large desktop blurs are computed on a reduced copy. A frosted pane has
629
+ no high-frequency detail to preserve, so the saving is essentially free
630
+ visually and makes live slider feedback feel responsive.
631
+ """
632
+ key = (int(radius), int(brightness), int(saturation), int(max_dimension))
633
+ found = self._cache.get(key)
634
+ if found is not None:
635
+ self._cache.move_to_end(key)
636
+ return found
637
+ source = self.image
638
+ largest = max(source.width(), source.height())
639
+ factor = min(1.0, max_dimension / max(1, largest))
640
+ if factor < 0.999:
641
+ working = source.scaled(
642
+ max(8, int(round(source.width() * factor))),
643
+ max(8, int(round(source.height() * factor))),
644
+ Qt.AspectRatioMode.IgnoreAspectRatio,
645
+ Qt.TransformationMode.SmoothTransformation,
646
+ )
647
+ else:
648
+ working = source
649
+ physical_radius = max(0.0, float(radius) * factor * ((self.scale_x + self.scale_y) / 2.0))
650
+ result = blur_qimage(working, physical_radius, brightness, saturation)
651
+ self._cache[key] = result
652
+ while len(self._cache) > 6:
653
+ self._cache.popitem(last=False)
654
+ return result
655
+
656
+ def processed_region(self, global_rect: QRect, radius: int, brightness: int = 100,
657
+ saturation: int = 100,
658
+ max_dimension: int = 1280) -> tuple[QImage | None, QRectF]:
659
+ """Blur only one screen region.
660
+
661
+ Returns ``(image, target_rect)`` where ``target_rect`` is in logical
662
+ screen coordinates. This keeps live capture cheap: the full desktop is
663
+ never re-blurred for a single window.
664
+ """
665
+ key = (global_rect.x(), global_rect.y(), global_rect.width(), global_rect.height(),
666
+ int(radius), int(brightness), int(saturation), int(max_dimension))
667
+ found = self._region_cache.get(key)
668
+ if found is not None:
669
+ self._region_cache.move_to_end(key)
670
+ return found
671
+
672
+ source = self.image
673
+ if source.isNull():
674
+ return None, QRectF()
675
+
676
+ mapped = self.source_rect(global_rect)
677
+ pixel_rect = QRect(
678
+ int(round(mapped.x())),
679
+ int(round(mapped.y())),
680
+ max(1, int(round(mapped.width()))),
681
+ max(1, int(round(mapped.height()))),
682
+ )
683
+ pixel_rect = pixel_rect.intersected(source.rect())
684
+ if pixel_rect.isEmpty():
685
+ return None, QRectF()
686
+
687
+ crop = source.copy(pixel_rect)
688
+ factor = min(1.0, max_dimension / max(1, max(crop.width(), crop.height())))
689
+ if factor < 0.999:
690
+ crop = crop.scaled(
691
+ max(1, int(round(crop.width() * factor))),
692
+ max(1, int(round(crop.height() * factor))),
693
+ Qt.AspectRatioMode.IgnoreAspectRatio,
694
+ Qt.TransformationMode.SmoothTransformation,
695
+ )
696
+
697
+ physical_radius = max(0.0, float(radius) * factor * ((self.scale_x + self.scale_y) / 2.0))
698
+ result = blur_qimage(crop, physical_radius, brightness, saturation)
699
+ result.setDevicePixelRatio(1.0)
700
+ target = QRectF(
701
+ self.screen_geometry.x() + pixel_rect.x() / self.scale_x,
702
+ self.screen_geometry.y() + pixel_rect.y() / self.scale_y,
703
+ pixel_rect.width() / self.scale_x,
704
+ pixel_rect.height() / self.scale_y,
705
+ )
706
+ self._region_cache[key] = (result, target)
707
+ while len(self._region_cache) > 12:
708
+ self._region_cache.popitem(last=False)
709
+ return result, target
710
+
711
+
712
+ class WallpaperSource(DesktopSource):
713
+ """Static glass backdrop built from the current system wallpaper.
714
+
715
+ The source behaves like :class:`DesktopSource` for painting and caching, but
716
+ it never samples the screen. A filesystem watcher (with a slow polling
717
+ fallback) reloads the image only when the wallpaper file or the primary
718
+ screen geometry changes. That keeps idle CPU cost near zero while still
719
+ following Windows theme/slideshow changes.
720
+ """
721
+
722
+ def __init__(self, parent: QObject | None = None, *, watch: bool = True):
723
+ screen = QGuiApplication.primaryScreen()
724
+ geometry = QRect(screen.geometry()) if screen else QRect(0, 0, 1920, 1080)
725
+ dpr = float(screen.devicePixelRatio()) if screen else 1.0
726
+ path = system_wallpaper_path()
727
+ image = _load_wallpaper_image(path, geometry, dpr)
728
+ super().__init__(image, geometry, dpr, datetime.now(), dynamic=False, fps=1,
729
+ parent=parent)
730
+ self._wallpaper_path = path
731
+ self._wallpaper_state = _wallpaper_state(geometry, dpr)
732
+ self._watcher: QFileSystemWatcher | None = None
733
+ self._watch_timer = QTimer(self)
734
+ self._watch_timer.setInterval(1500)
735
+ self._watch_timer.timeout.connect(self.reload)
736
+ if watch:
737
+ self.start_watch()
738
+
739
+ @property
740
+ def wallpaper_path(self) -> str:
741
+ return self._wallpaper_path
742
+
743
+ # Wallpaper mode is deliberately static: never start screen sampling even
744
+ # if a host still calls the DesktopSource compatibility API.
745
+ def set_dynamic(self, enabled: bool) -> None:
746
+ return
747
+
748
+ def set_fps(self, fps: int) -> None:
749
+ return
750
+
751
+ def refresh_full(self) -> None:
752
+ self.reload(force=True)
753
+
754
+ def reload(self, force: bool = False) -> bool:
755
+ """Reload the wallpaper when it (or the screen geometry) changed."""
756
+ screen = QGuiApplication.primaryScreen()
757
+ geometry = QRect(screen.geometry()) if screen else self.screen_geometry
758
+ dpr = float(screen.devicePixelRatio()) if screen else self.dpr
759
+ state = _wallpaper_state(geometry, dpr)
760
+ if not force and state == self._wallpaper_state:
761
+ return False
762
+
763
+ path = system_wallpaper_path()
764
+ image = _load_wallpaper_image(path, geometry, dpr)
765
+ if image is None or image.isNull():
766
+ # Keep the previous frame during transient transcoding failures.
767
+ return False
768
+
769
+ self._apply(image, geometry, dpr, datetime.now())
770
+ self._wallpaper_path = path
771
+ self._wallpaper_state = state
772
+ self._refresh_watcher_paths()
773
+ self.updated.emit()
774
+ return True
775
+
776
+ def stop(self) -> None:
777
+ """Stop watching for wallpaper changes."""
778
+ self._watch_timer.stop()
779
+ if self._watcher is not None:
780
+ self._watcher.deleteLater()
781
+ self._watcher = None
782
+
783
+ def start_watch(self) -> None:
784
+ """Start (or restart) watching for wallpaper changes.
785
+
786
+ ``stop()`` is called when a glass host is closed; the same host may be
787
+ shown again later, so the watcher has to be restartable.
788
+ """
789
+ if self._watcher is None:
790
+ self._watcher = QFileSystemWatcher(self)
791
+ self._watcher.fileChanged.connect(self._on_watch_changed)
792
+ self._watcher.directoryChanged.connect(self._on_watch_changed)
793
+ self._refresh_watcher_paths()
794
+ self._watch_timer.start()
795
+
796
+ def _on_watch_changed(self, _path: str) -> None:
797
+ self.reload()
798
+
799
+ def _refresh_watcher_paths(self) -> None:
800
+ if self._watcher is None:
801
+ return
802
+ wanted: set[str] = set()
803
+ for path in (self._wallpaper_path, self._transcoded_path()):
804
+ if not path:
805
+ continue
806
+ candidate = Path(path)
807
+ wanted.add(str(candidate))
808
+ wanted.add(str(candidate.parent))
809
+ current = set(self._watcher.files()) | set(self._watcher.directories())
810
+ stale = current - wanted
811
+ if stale:
812
+ self._watcher.removePaths(sorted(stale))
813
+ missing = wanted - current
814
+ if missing:
815
+ self._watcher.addPaths(sorted(missing))
816
+
817
+ @staticmethod
818
+ def _transcoded_path() -> str:
819
+ appdata = os.environ.get("APPDATA")
820
+ if not appdata:
821
+ return ""
822
+ path = Path(appdata) / "Microsoft" / "Windows" / "Themes" / "TranscodedWallpaper"
823
+ return str(path) if path.exists() else ""