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
|
@@ -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 ""
|