trackerkeeper 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. trackerkeeper/__init__.py +55 -0
  2. trackerkeeper/__main__.py +3 -0
  3. trackerkeeper/_version.py +24 -0
  4. trackerkeeper/app.py +445 -0
  5. trackerkeeper/assets/__init__.py +2 -0
  6. trackerkeeper/assets/trackerkeeper.svg +12 -0
  7. trackerkeeper/async_io.py +299 -0
  8. trackerkeeper/autostart/__init__.py +52 -0
  9. trackerkeeper/autostart/_linux.py +129 -0
  10. trackerkeeper/autostart/_macos.py +163 -0
  11. trackerkeeper/autostart/_msix.py +181 -0
  12. trackerkeeper/autostart/_unsupported.py +22 -0
  13. trackerkeeper/autostart/_windows.py +96 -0
  14. trackerkeeper/bake.py +217 -0
  15. trackerkeeper/blur/__init__.py +212 -0
  16. trackerkeeper/blur/_dwm.py +348 -0
  17. trackerkeeper/blur/_faux_frost.py +143 -0
  18. trackerkeeper/blur/_kwin.py +478 -0
  19. trackerkeeper/blur/_macos.py +356 -0
  20. trackerkeeper/blur/_unsupported.py +39 -0
  21. trackerkeeper/boot_timing.py +75 -0
  22. trackerkeeper/breadboard.py +1554 -0
  23. trackerkeeper/bus.py +110 -0
  24. trackerkeeper/catalog.py +176 -0
  25. trackerkeeper/color_tokens.py +697 -0
  26. trackerkeeper/credentials.py +471 -0
  27. trackerkeeper/custom_tooltip.py +254 -0
  28. trackerkeeper/dashboard.py +704 -0
  29. trackerkeeper/deliver.py +442 -0
  30. trackerkeeper/design_tokens.py +415 -0
  31. trackerkeeper/diagnostics.py +168 -0
  32. trackerkeeper/drag_repaint/__init__.py +68 -0
  33. trackerkeeper/drag_repaint/_kwin.py +192 -0
  34. trackerkeeper/drag_repaint/_unsupported.py +41 -0
  35. trackerkeeper/drag_repaint/effect/dragrepaint/contents/code/main.js +86 -0
  36. trackerkeeper/drag_repaint/effect/dragrepaint/metadata.json +17 -0
  37. trackerkeeper/frosted_dialog.py +377 -0
  38. trackerkeeper/i18n/__init__.py +101 -0
  39. trackerkeeper/i18n/fmt.py +137 -0
  40. trackerkeeper/icon_button.py +137 -0
  41. trackerkeeper/icons.py +532 -0
  42. trackerkeeper/identity.py +135 -0
  43. trackerkeeper/item_dialog.py +185 -0
  44. trackerkeeper/kde_titlebar.py +196 -0
  45. trackerkeeper/keyboard_focus.py +353 -0
  46. trackerkeeper/log.py +112 -0
  47. trackerkeeper/macos_menubar.py +282 -0
  48. trackerkeeper/macos_window.py +172 -0
  49. trackerkeeper/metadata.py +121 -0
  50. trackerkeeper/noborder/__init__.py +61 -0
  51. trackerkeeper/noborder/_kwin.py +247 -0
  52. trackerkeeper/noborder/_unsupported.py +42 -0
  53. trackerkeeper/notifications/__init__.py +84 -0
  54. trackerkeeper/notifications/_linux.py +68 -0
  55. trackerkeeper/notifications/_macos.py +159 -0
  56. trackerkeeper/notifications/_unsupported.py +22 -0
  57. trackerkeeper/notifications/_windows.py +115 -0
  58. trackerkeeper/platform_compat.py +149 -0
  59. trackerkeeper/power/__init__.py +84 -0
  60. trackerkeeper/power/_linux.py +79 -0
  61. trackerkeeper/power/_unsupported.py +18 -0
  62. trackerkeeper/power/_windows.py +52 -0
  63. trackerkeeper/rig.py +338 -0
  64. trackerkeeper/scaffold.py +310 -0
  65. trackerkeeper/selector.py +529 -0
  66. trackerkeeper/settings.py +226 -0
  67. trackerkeeper/settings_dialog.py +307 -0
  68. trackerkeeper/settings_migration.py +101 -0
  69. trackerkeeper/single_instance.py +330 -0
  70. trackerkeeper/smooth_scroll.py +155 -0
  71. trackerkeeper/sources.py +318 -0
  72. trackerkeeper/system_accent.py +363 -0
  73. trackerkeeper/terminal.py +423 -0
  74. trackerkeeper/test_bridge.py +514 -0
  75. trackerkeeper/theme.py +725 -0
  76. trackerkeeper/top_bar.py +154 -0
  77. trackerkeeper/tray.py +184 -0
  78. trackerkeeper/ui_helpers.py +2294 -0
  79. trackerkeeper/update_chip.py +119 -0
  80. trackerkeeper/updates.py +216 -0
  81. trackerkeeper/win_frameless.py +364 -0
  82. trackerkeeper/window.py +609 -0
  83. trackerkeeper/windows_shortcut.py +355 -0
  84. trackerkeeper-0.1.0.dist-info/METADATA +144 -0
  85. trackerkeeper-0.1.0.dist-info/RECORD +89 -0
  86. trackerkeeper-0.1.0.dist-info/WHEEL +5 -0
  87. trackerkeeper-0.1.0.dist-info/entry_points.txt +8 -0
  88. trackerkeeper-0.1.0.dist-info/licenses/LICENSE +338 -0
  89. trackerkeeper-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,143 @@
1
+ """Faux frosted-glass backdrop — the no-real-blur fallback.
2
+
3
+ When a frosted theme can't get genuine compositor blur, a translucent
4
+ surface would otherwise be a flat near-opaque panel (``fallback_body_alpha``).
5
+ The cases where no compositor blur is available:
6
+
7
+ * GNOME / Wayland — no "blur-behind" Wayland protocol exists, and Mutter
8
+ doesn't implement KDE's private one.
9
+ * Windows without Mica (Windows 10, or transparency disabled).
10
+ * KDE with the Blur desktop effect switched off.
11
+ * macOS with Reduce Transparency turned on (no vibrancy).
12
+
13
+ Instead of a flat rectangle we paint a *self-contained frosted texture* that
14
+ reads as real frosted glass rather than dead paint: a few soft lighter blooms
15
+ over the theme's body colour, melted together by a smooth upscale (cheap blur)
16
+ and finished with a faint film grain so it looks diffused. Neutral (no hue
17
+ tint) and fully deterministic — it needs zero compositor support and stays
18
+ stable across repaints. Cached per (size, base colour); rebuilt only on resize
19
+ or theme change, so any surface that shares a body colour falls back
20
+ identically. The base colour comes from :func:`trackerkeeper.theme.body_color_for`;
21
+ whether a real backdrop was verified behind the window is reported by
22
+ :func:`trackerkeeper.blur.status`.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import random
27
+ from typing import Optional, Tuple
28
+
29
+ from PySide6.QtCore import QPointF, QRect, QRectF, QSize, Qt
30
+ from PySide6.QtGui import (
31
+ QBrush,
32
+ QColor,
33
+ QImage,
34
+ QPainter,
35
+ QPainterPath,
36
+ QPixmap,
37
+ QRadialGradient,
38
+ )
39
+
40
+
41
+ class FauxFrost:
42
+ """Generates and caches a frosted-glass texture sized to a surface.
43
+ Cheap to keep one per frosted surface."""
44
+
45
+ # Build the blooms at 1/_SCALE then smooth-upscale — a cheap blur that
46
+ # melts them into soft cloud and erases banding. Grain is added AFTER the
47
+ # upscale (full-res) so it stays crisp.
48
+ _SCALE = 6
49
+ # Soft blooms over the base: (cx, cy, radius_frac, lighten%, alpha).
50
+ # Bigger radii + higher alpha read as more pronounced, lighter frost.
51
+ _BLOOMS = (
52
+ (0.20, 0.14, 1.05, 134, 66),
53
+ (0.84, 0.84, 1.18, 120, 56),
54
+ (0.58, 0.46, 0.72, 142, 40),
55
+ (0.92, 0.22, 0.55, 126, 30),
56
+ )
57
+ # Faint monochrome film grain, tiled. Fixed seed → deterministic so the
58
+ # cached texture is stable across rebuilds. Sparse + low-alpha = fine film
59
+ # grain rather than a haze. Tuned DOWN 2026-07-08 (walkthrough verdict:
60
+ # the grain read as noise once you'd seen real blur next to it) — barely
61
+ # there, just enough to break the flat fill.
62
+ _GRAIN_TILE = 64
63
+ _GRAIN_ALPHA_MAX = 5
64
+ _GRAIN_DENSITY = 0.11 # fraction of pixels that get any speck
65
+ _GRAIN_SEED = 0xF0057
66
+ _grain: Optional[QPixmap] = None
67
+
68
+ def __init__(self) -> None:
69
+ self._cache: Optional[QPixmap] = None
70
+ self._key: Optional[Tuple[int, int, int]] = None
71
+
72
+ @classmethod
73
+ def _grain_tile(cls) -> QPixmap:
74
+ if cls._grain is not None:
75
+ return cls._grain
76
+ n = cls._GRAIN_TILE
77
+ img = QImage(n, n, QImage.Format.Format_ARGB32_Premultiplied)
78
+ img.fill(0)
79
+ rnd = random.Random(cls._GRAIN_SEED)
80
+ for y in range(n):
81
+ for x in range(n):
82
+ if rnd.random() > cls._GRAIN_DENSITY:
83
+ continue
84
+ a = rnd.randint(2, cls._GRAIN_ALPHA_MAX)
85
+ v = rnd.randint(185, 235)
86
+ img.setPixelColor(x, y, QColor(v, v, v, a))
87
+ cls._grain = QPixmap.fromImage(img)
88
+ return cls._grain
89
+
90
+ def _ensure(self, size: QSize, base: QColor) -> Optional[QPixmap]:
91
+ if size.width() <= 0 or size.height() <= 0:
92
+ return None
93
+ key = (size.width(), size.height(), base.rgba())
94
+ if self._cache is not None and self._key == key:
95
+ return self._cache
96
+ sw = max(2, size.width() // self._SCALE)
97
+ sh = max(2, size.height() // self._SCALE)
98
+ small = QPixmap(sw, sh)
99
+ small.fill(base) # near-opaque base — keeps the body's own alpha
100
+ p = QPainter(small)
101
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
102
+ p.setPen(Qt.PenStyle.NoPen)
103
+ reach = float(max(sw, sh))
104
+ for cx, cy, rfrac, lighten, alpha in self._BLOOMS:
105
+ grad = QRadialGradient(QPointF(cx * sw, cy * sh), rfrac * reach)
106
+ center = base.lighter(lighten)
107
+ center.setAlpha(alpha)
108
+ edge = QColor(center)
109
+ edge.setAlpha(0)
110
+ grad.setColorAt(0.0, center)
111
+ grad.setColorAt(1.0, edge)
112
+ p.fillRect(small.rect(), grad)
113
+ p.end()
114
+ big = small.scaled(
115
+ size,
116
+ Qt.AspectRatioMode.IgnoreAspectRatio,
117
+ Qt.TransformationMode.SmoothTransformation,
118
+ )
119
+ # Film grain on top (full-res, tiled) for the frosted diffusion feel.
120
+ gp = QPainter(big)
121
+ gp.fillRect(big.rect(), QBrush(self._grain_tile()))
122
+ gp.end()
123
+ self._cache = big
124
+ self._key = key
125
+ return big
126
+
127
+ def paint(self, painter: QPainter, rect: QRect, base: QColor, radius: int = 0) -> bool:
128
+ """Paint the frost texture to fill ``rect`` (clipped to a rounded
129
+ rect of ``radius`` when > 0), built from ``base`` (the body colour).
130
+ Saves/restores painter state. Returns True if it painted."""
131
+ pm = self._ensure(rect.size(), base)
132
+ if pm is None:
133
+ return False
134
+ painter.save()
135
+ try:
136
+ if radius > 0:
137
+ path = QPainterPath()
138
+ path.addRoundedRect(QRectF(rect), radius, radius)
139
+ painter.setClipPath(path)
140
+ painter.drawPixmap(rect.topLeft(), pm)
141
+ finally:
142
+ painter.restore()
143
+ return True
@@ -0,0 +1,478 @@
1
+ """KWindowSystem blur backend — `KWindowEffects::enableBlurBehind()`
2
+ reached through ctypes against ``libKF6WindowSystem``.
3
+
4
+ Why ctypes: KF6 ships no Python binding, and `QtWaylandClient` (which
5
+ would let us marshal the blur protocol ourselves) isn't bundled with
6
+ PySide6. ``enableBlurBehind`` is a plain — if mangled — C++ symbol, so
7
+ ctypes is the clean route. KWindowSystem itself does the hard parts:
8
+ it speaks `ext-background-effect-v1` where the compositor advertises it
9
+ and falls back to the legacy `org_kde_kwin_blur`, translates an empty
10
+ QRegion to "blur the whole window", and re-applies blur via its own
11
+ event filter whenever the Wayland surface is recreated — so callers
12
+ just call once per show / theme change.
13
+
14
+ Everything is best-effort: a missing library or symbol, a window with
15
+ no platform surface yet, a compositor with no blur protocol — all
16
+ resolve to a silent no-op. Blur is pure progressive enhancement.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ctypes
22
+
23
+ # KWindowEffects::enableBlurBehind(QWindow *, bool, QRegion const &)
24
+ # Itanium-mangled. ABI-stable for KF6's lifetime; guarded anyway.
25
+ _SYMBOL = "_ZN14KWindowEffects16enableBlurBehindEP7QWindowbRK7QRegion"
26
+ # KWindowEffects::isEffectAvailable(KWindowEffects::Effect) — the verify
27
+ # half. Static method (no implicit this), takes the Effect enum by value.
28
+ # Length prefix 17 vs 16 above; confirmed present in libKF6WindowSystem.so.6.
29
+ _AVAIL_SYMBOL = "_ZN14KWindowEffects17isEffectAvailableENS_6EffectE"
30
+ # KWindowEffects::Effect::BlurBehind, from kwindoweffects.h (KF6). Verified
31
+ # both against the header and at runtime (isEffectAvailable(7) -> True on a
32
+ # blur-capable KWin, isEffectAvailable(<bogus>) -> False).
33
+ _BLUR_BEHIND = 7
34
+ _SONAMES = ("libKF6WindowSystem.so.6", "libKF6WindowSystem.so")
35
+
36
+ _fn = None # resolved ctypes callable, or None if unavailable
37
+ _resolved = False # resolution attempted yet?
38
+
39
+ _avail_fn = None # resolved isEffectAvailable callable, or None
40
+ _avail_resolved = False
41
+
42
+ # KWindowSystem's real work happens in its per-platform integration plugin
43
+ # (kf6/kwindowsystem/ — the Wayland one speaks the blur protocols). It's discovered through the RUNNING Qt's library paths — and a
44
+ # pip-installed PySide6 bundles its own Qt, which searches only the venv's
45
+ # plugin dir, so the distro's plugin is invisible: KWindowSystem logs "Could
46
+ # not find any platform plugin", isEffectAvailable() reports False on a
47
+ # blur-capable KWin, and enableBlurBehind() no-ops. (Distro PySide6 shares the
48
+ # system Qt prefix, which is why this never showed there.)
49
+ _KF_PLUGIN_SUBDIR = "kf6/kwindowsystem"
50
+ _SYSTEM_PLUGIN_ROOTS = (
51
+ "/usr/lib/qt6/plugins", # Arch and family
52
+ "/usr/lib/x86_64-linux-gnu/qt6/plugins", # Debian/Ubuntu
53
+ "/usr/lib64/qt6/plugins", # Fedora/openSUSE
54
+ )
55
+ _plugin_path_ensured = False
56
+
57
+
58
+ def _ensure_platform_plugin() -> None:
59
+ """Make the KF6WindowSystem platform plugin discoverable to the running Qt.
60
+
61
+ Exposes ONLY the kwindowsystem plugin family: a throwaway shim dir holding
62
+ a single kf6/… symlink is added to the library paths — never the whole
63
+ system plugin tree, which would let a second Qt build's platform/image
64
+ plugins shadow PySide6's own. Best-effort and cached; never raises."""
65
+ global _plugin_path_ensured
66
+ if _plugin_path_ensured:
67
+ return
68
+ _plugin_path_ensured = True
69
+ try:
70
+ from pathlib import Path
71
+
72
+ from PySide6.QtCore import QCoreApplication
73
+
74
+ for p in QCoreApplication.libraryPaths():
75
+ if (Path(p) / _KF_PLUGIN_SUBDIR).is_dir():
76
+ return # already discoverable (distro PySide6, or a prior shim)
77
+ for root in _SYSTEM_PLUGIN_ROOTS:
78
+ src = Path(root) / _KF_PLUGIN_SUBDIR
79
+ if src.is_dir():
80
+ import tempfile
81
+
82
+ shim = Path(tempfile.mkdtemp(prefix="kf6-windowsystem-shim-"))
83
+ link = shim / _KF_PLUGIN_SUBDIR
84
+ link.parent.mkdir(parents=True)
85
+ link.symlink_to(src, target_is_directory=True)
86
+ QCoreApplication.addLibraryPath(str(shim))
87
+ return
88
+ except Exception:
89
+ pass # progressive enhancement — worst case blur stays a no-op
90
+
91
+
92
+ def _resolve():
93
+ """Load libKF6WindowSystem and bind the enableBlurBehind symbol.
94
+ Cached — the result is stable for the process lifetime."""
95
+ global _fn, _resolved
96
+ if _resolved:
97
+ return _fn
98
+ _resolved = True
99
+ for soname in _SONAMES:
100
+ try:
101
+ lib = ctypes.CDLL(soname)
102
+ except OSError:
103
+ continue
104
+ try:
105
+ fn = lib[_SYMBOL]
106
+ except (AttributeError, KeyError):
107
+ continue
108
+ # (QWindow*, bool, QRegion*) — pointers passed as void*.
109
+ fn.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_void_p]
110
+ fn.restype = None
111
+ _fn = fn
112
+ return _fn
113
+ return None
114
+
115
+
116
+ def _resolve_avail():
117
+ """Load libKF6WindowSystem and bind isEffectAvailable. Cached, guarded
118
+ exactly like _resolve() — a missing symbol (ABI drift) yields None so
119
+ probe() degrades to REQUESTED_UNVERIFIABLE rather than crashing."""
120
+ global _avail_fn, _avail_resolved
121
+ if _avail_resolved:
122
+ return _avail_fn
123
+ _avail_resolved = True
124
+ for soname in _SONAMES:
125
+ try:
126
+ lib = ctypes.CDLL(soname)
127
+ except OSError:
128
+ continue
129
+ try:
130
+ fn = lib[_AVAIL_SYMBOL]
131
+ except (AttributeError, KeyError):
132
+ continue
133
+ fn.argtypes = [ctypes.c_int] # Effect enum, passed by value
134
+ fn.restype = ctypes.c_bool
135
+ _avail_fn = fn
136
+ return _avail_fn
137
+ return None
138
+
139
+
140
+ def is_supported() -> bool:
141
+ return _resolve() is not None
142
+
143
+
144
+ def _blur_effect_active():
145
+ """KDE-only confirmatory cross-check via in-process QtDBus. Returns True
146
+ if KWin reports the Blur effect loaded (and compositing not explicitly
147
+ off), False if either is explicitly off, None if inconclusive (DBus
148
+ unavailable, not KDE, or any error). Best-effort; never raises — a None
149
+ means "trust the isEffectAvailable capability bit alone"."""
150
+ try:
151
+ from PySide6.QtDBus import QDBusConnection, QDBusInterface
152
+ except Exception:
153
+ return None
154
+ try:
155
+ bus = QDBusConnection.sessionBus()
156
+ if not bus.isConnected():
157
+ return None
158
+ eff = QDBusInterface("org.kde.KWin", "/Effects", "org.kde.kwin.Effects", bus)
159
+ if not eff.isValid():
160
+ return None # not Plasma / KWin not on the bus
161
+ reply = eff.call("isEffectLoaded", "blur")
162
+ args = reply.arguments()
163
+ loaded = bool(args[0]) if args else None
164
+ if loaded is False:
165
+ return False # user disabled the Blur desktop effect
166
+ comp = QDBusInterface(
167
+ "org.kde.KWin", "/Compositor", "org.kde.kwin.Compositing", bus
168
+ )
169
+ if comp.isValid() and comp.property("active") is False:
170
+ return False # compositing suspended
171
+ return loaded # True, or None if the /Effects reply was empty
172
+ except Exception:
173
+ return None
174
+
175
+
176
+ _delivery_ok = None # tri-state self-test result: True / False / None
177
+ _delivery_tested = False
178
+
179
+
180
+ def _blur_request_reaches_compositor():
181
+ """Does ``enableBlurBehind`` actually reach the compositor here?
182
+
183
+ KWindowSystem talks to Wayland through Qt's native interface, and that
184
+ interface is REVISION-CHECKED: a KF6 compiled against one Qt minor
185
+ cannot obtain it from another. When the check fails, Qt hands back
186
+ nullptr, and ``enableBlurBehind`` returns normally WITHOUT ever sending
187
+ ``org_kde_kwin_blur.create`` — the request is dropped on the floor and
188
+ nothing blurs. Meanwhile ``isEffectAvailable()`` still answers True (it
189
+ needs no native interface), so the capability gate in probe() sails
190
+ through and we paint full-transparency glass over an UNBLURRED desktop.
191
+ That is exactly jellytoast's 0.2.0 flatpak bug (its #229): the sandbox
192
+ shipped PySide6's bundled Qt over the runtime's, so KF6 — built against
193
+ the runtime's Qt — was handed a Qt it couldn't speak to.
194
+
195
+ The failure is silent by design (blur is fire-and-forget; KWin sends no
196
+ ack), and Qt reports it ONLY by logging. So we run the call once, for
197
+ real, on a throwaway QWindow — created but never shown, so there is no
198
+ visible artifact — with a message handler installed, and watch for the
199
+ complaint.
200
+
201
+ Returns True if the call ran clean (the request is being delivered),
202
+ False if Qt refused the native interface (blur silently no-ops here),
203
+ and None if the test couldn't be run at all (no QGuiApplication yet, no
204
+ symbol, any error). Callers must NOT demote on None — absence of a
205
+ verdict is not evidence of failure. Cached: the answer is a per-process
206
+ fact (it depends on which Qt got loaded), and the window churn is not
207
+ worth repeating. Never raises.
208
+ """
209
+ global _delivery_ok, _delivery_tested
210
+ if _delivery_tested:
211
+ return _delivery_ok
212
+ _delivery_tested = True
213
+ try:
214
+ import ctypes as _ct
215
+
216
+ import shiboken6
217
+ from PySide6.QtCore import qInstallMessageHandler
218
+ from PySide6.QtGui import QGuiApplication, QRegion, QWindow
219
+
220
+ fn = _resolve()
221
+ if fn is None or QGuiApplication.instance() is None:
222
+ return _delivery_ok # None — nothing to test against (yet)
223
+
224
+ complained = []
225
+
226
+ def _catch(mode, ctx, msg):
227
+ # Qt logs the refusal as e.g. "Native interface revision mismatch
228
+ # (requested 1 / available 2) for interface QWaylandApplication"
229
+ # under the qt.nativeinterface category.
230
+ if "revision mismatch" in msg or "native interface" in msg.lower():
231
+ complained.append(msg)
232
+
233
+ win = QWindow()
234
+ win.create() # platform window only — never shown, never mapped
235
+ prev = qInstallMessageHandler(_catch)
236
+ try:
237
+ region = QRegion()
238
+ fn(
239
+ _ct.c_void_p(shiboken6.getCppPointer(win)[0]),
240
+ True,
241
+ _ct.c_void_p(shiboken6.getCppPointer(region)[0]),
242
+ )
243
+ finally:
244
+ qInstallMessageHandler(prev)
245
+ win.destroy()
246
+
247
+ _delivery_ok = not complained
248
+ except Exception:
249
+ _delivery_ok = None # inconclusive — never demote on a failed test
250
+ return _delivery_ok
251
+
252
+
253
+ def _kreadconfig_bin():
254
+ import shutil
255
+
256
+ for cand in ("kreadconfig6", "kreadconfig5"):
257
+ path = shutil.which(cand)
258
+ if path:
259
+ return path
260
+ return None
261
+
262
+
263
+ def _blur_disabled_in_kwinrc():
264
+ """True iff kwinrc explicitly disables the Blur effect — `[Plugins]
265
+ blurEnabled=false`. A static config read via kreadconfig (no live D-Bus),
266
+ so it catches a user who turned Blur off even where ``PySide6.QtDBus``
267
+ isn't importable (it's a separate package on some distros). False when
268
+ the effect is enabled, the key is absent (KWin default-on), it's not a
269
+ KDE box (no kwinrc → empty), or kreadconfig is missing — we only ever use
270
+ this to DEMOTE, never to grant ACTIVE."""
271
+ bin_ = _kreadconfig_bin()
272
+ if not bin_:
273
+ return False
274
+ try:
275
+ import subprocess
276
+
277
+ out = subprocess.run(
278
+ [bin_, "--file", "kwinrc", "--group", "Plugins", "--key", "blurEnabled"],
279
+ capture_output=True,
280
+ text=True,
281
+ timeout=2,
282
+ )
283
+ return out.stdout.strip().lower() == "false"
284
+ except Exception:
285
+ return False
286
+
287
+
288
+ def _blur_disabled():
289
+ """A POSITIVE "blur won't render" signal, used only to demote a True
290
+ capability bit on Wayland. Either source saying "off" is enough:
291
+
292
+ * kwinrc `[Plugins] blurEnabled=false` (config read; survives a missing
293
+ QtDBus — the failure mode the cross-check alone didn't cover), OR
294
+ * KWin's D-Bus reports the Blur effect unloaded / compositing suspended.
295
+
296
+ Both are KDE-only by construction (kwinrc + org.kde.KWin), so on a non-KDE
297
+ Wayland compositor that genuinely offers app blur (niri's
298
+ ext-background-effect-v1) neither fires → the honest capability bit stands
299
+ and the surface stays full-glass. Absence of a signal is NOT taken as
300
+ "disabled" — we never gamble a False here."""
301
+ if _blur_disabled_in_kwinrc():
302
+ return True
303
+ if _blur_effect_active() is False:
304
+ return True
305
+ return False
306
+
307
+
308
+ def probe():
309
+ """Return the verified BlurStatus for this session. See
310
+ trackerkeeper/blur/__init__.py and docs/research/portable_blur.md.
311
+
312
+ Strategy:
313
+ 1. If we can't even load enableBlurBehind → UNSUPPORTED.
314
+ 2. If the isEffectAvailable symbol won't bind → REQUESTED_UNVERIFIABLE
315
+ (we issued blur but can't verify it).
316
+ 3. isEffectAvailable(BlurBehind) is the capability gate: on Wayland it
317
+ reflects the ext-background-effect-v1 / org_kde_kwin_blur global; on
318
+ X11 it requires compositing + the blur atom. False → UNSUPPORTED.
319
+ 4. KDE X11 honours the blur atom but can still silently skip the render
320
+ pass (GPU mis-detect), invisible to the client → stay conservative
321
+ (REQUESTED_UNVERIFIABLE). Only Wayland earns ACTIVE.
322
+ 5. On Wayland the capability bit can stay True even when KWin's Blur
323
+ desktop effect is toggled OFF (the bit advertises the protocol, not
324
+ the effect's enabled state). So demote to UNSUPPORTED on any positive
325
+ "blur is disabled" signal (kwinrc config OR D-Bus); otherwise ACTIVE.
326
+ The demotion signals are KDE-only, so a non-KDE compositor that
327
+ honestly advertises blur (niri) keeps ACTIVE.
328
+ """
329
+ from trackerkeeper.blur import BlurStatus
330
+
331
+ if _resolve() is None:
332
+ return BlurStatus.UNSUPPORTED
333
+ _ensure_platform_plugin()
334
+ avail = _resolve_avail()
335
+ if avail is None:
336
+ return BlurStatus.REQUESTED_UNVERIFIABLE
337
+ try:
338
+ available = bool(avail(_BLUR_BEHIND))
339
+ except Exception:
340
+ return BlurStatus.REQUESTED_UNVERIFIABLE
341
+ if not available:
342
+ return BlurStatus.UNSUPPORTED
343
+
344
+ from trackerkeeper.platform_compat import is_x11
345
+
346
+ if is_x11():
347
+ # Atom honoured but render-skip is undetectable from the client.
348
+ return BlurStatus.REQUESTED_UNVERIFIABLE
349
+ if _blur_disabled():
350
+ return BlurStatus.UNSUPPORTED
351
+ import os
352
+
353
+ if os.environ.get("FLATPAK_ID"):
354
+ from trackerkeeper.platform_compat import is_kde_desktop
355
+
356
+ if is_kde_desktop() and _blur_effect_active() is None:
357
+ # KDE inside a flatpak with an INCONCLUSIVE effect check: the
358
+ # sandbox likely can't reach org.kde.KWin on the session bus (a
359
+ # bundle built without a --talk-name=org.kde.KWin grant), so a
360
+ # host with the Blur effect OFF is indistinguishable from one
361
+ # with it on — while the Wayland capability bit stays True
362
+ # either way. Trusting the bit paints full-transparency glass
363
+ # over an UNBLURRED desktop (jellytoast's 0.2.0 Steam Deck
364
+ # report). Claim only what we can verify; the near-opaque
365
+ # frosted fallback is the honest render. Outside the sandbox an
366
+ # inconclusive check is rare (missing QtDBus) and host
367
+ # behaviour is unchanged.
368
+ return BlurStatus.REQUESTED_UNVERIFIABLE
369
+
370
+ # Last honesty gate. Everything above asks the COMPOSITOR whether it can
371
+ # blur; none of it checks whether our request ever gets there. On a Qt /
372
+ # KWindowSystem version skew enableBlurBehind silently drops it while
373
+ # every capability signal above still says yes — jellytoast's #229
374
+ # failure. Only a False demotes: an inconclusive test (None) leaves
375
+ # behaviour as it was.
376
+ if _blur_request_reaches_compositor() is False:
377
+ return BlurStatus.REQUESTED_UNVERIFIABLE
378
+ return BlurStatus.ACTIVE
379
+
380
+
381
+ # Non-KDE Linux desktops with NO app-controllable window-blur protocol — so
382
+ # the right user message is "your desktop can't do this", not "install / enable
383
+ # something". (Deliberately excludes KDE, and niri/COSMIC which DO speak
384
+ # ext-background-effect-v1.)
385
+ _NO_BLUR_DESKTOPS = (
386
+ "gnome",
387
+ "cinnamon",
388
+ "xfce",
389
+ "mate",
390
+ "lxqt",
391
+ "lxde",
392
+ "unity",
393
+ "pantheon",
394
+ )
395
+
396
+
397
+ def reason(status):
398
+ """A short human explanation for the given BlurStatus on this box — used
399
+ by the boot log + the Settings hint. Never raises."""
400
+ from trackerkeeper.blur import BlurStatus
401
+ from trackerkeeper.platform_compat import desktop_name, is_kde_desktop, is_x11
402
+
403
+ if status is BlurStatus.ACTIVE:
404
+ return "KWin blur active"
405
+ de = desktop_name()
406
+ if not is_kde_desktop() and any(k in de.lower() for k in _NO_BLUR_DESKTOPS):
407
+ return f"{de or 'this desktop'} has no app-controllable window blur — using a near-opaque body"
408
+ if is_x11():
409
+ return "X11 session — blur can't be verified; using a near-opaque body"
410
+ if _resolve() is None:
411
+ return "KWindowSystem missing — install kwindowsystem for Frosted glass blur on KDE"
412
+ if is_kde_desktop() and _blur_disabled():
413
+ return "KWin's Blur effect is off — enable System Settings → Desktop Effects → Blur"
414
+ if _blur_request_reaches_compositor() is False:
415
+ # The compositor is willing; we're the ones who can't ask it.
416
+ return (
417
+ "KWindowSystem can't drive this Qt build (version skew) — blur "
418
+ "requests are dropped; using a near-opaque body"
419
+ )
420
+ return "compositor blur unavailable here — using a near-opaque body"
421
+
422
+
423
+ def _rounded_region(widget, radius: int):
424
+ """A QRegion shaped to a rounded rect matching ``widget``'s current
425
+ (logical) size. Rasterised through a monochrome QBitmap mask —
426
+ QRegion has no rounded-rect constructor. KWindowSystem scales the
427
+ region by the window's DPR, so logical coordinates are correct."""
428
+ from PySide6.QtCore import QRectF, Qt
429
+ from PySide6.QtGui import QBitmap, QPainter, QPainterPath, QRegion
430
+
431
+ w, h = widget.width(), widget.height()
432
+ if w <= 0 or h <= 0:
433
+ return QRegion() # not laid out yet — fall back to whole-window
434
+ bmp = QBitmap(w, h)
435
+ bmp.fill(Qt.GlobalColor.color0) # color0 = outside the region
436
+ path = QPainterPath()
437
+ path.addRoundedRect(QRectF(0, 0, w, h), radius, radius)
438
+ p = QPainter(bmp)
439
+ # No AA — a region is a hard 1-bit mask; antialiased edge pixels
440
+ # would just become ragged region boundary.
441
+ p.setRenderHint(QPainter.RenderHint.Antialiasing, False)
442
+ p.fillPath(path, Qt.GlobalColor.color1) # color1 = inside the region
443
+ p.end()
444
+ return QRegion(bmp)
445
+
446
+
447
+ def apply(
448
+ widget,
449
+ enabled: bool,
450
+ corner_radius: int = 0,
451
+ dark: bool = True,
452
+ elevated: bool = False,
453
+ ) -> bool:
454
+ """Issue enableBlurBehind for ``widget``'s QWindow. ``corner_radius``
455
+ > 0 shapes the blur region to a rounded rect; 0 = whole window.
456
+ Returns False (no-op) if the lib is missing or the widget has no
457
+ platform window yet."""
458
+ fn = _resolve()
459
+ if fn is None:
460
+ return False
461
+ _ensure_platform_plugin()
462
+ try:
463
+ import shiboken6
464
+ from PySide6.QtGui import QRegion
465
+
466
+ qwindow = widget.windowHandle()
467
+ if qwindow is None:
468
+ return False # not shown yet — no platform window to blur
469
+ if corner_radius > 0:
470
+ region = _rounded_region(widget, corner_radius)
471
+ else:
472
+ region = QRegion() # empty == KWindowSystem blurs whole window
473
+ win_ptr = shiboken6.getCppPointer(qwindow)[0]
474
+ reg_ptr = shiboken6.getCppPointer(region)[0]
475
+ fn(ctypes.c_void_p(win_ptr), bool(enabled), ctypes.c_void_p(reg_ptr))
476
+ return True
477
+ except Exception:
478
+ return False