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.
- trackerkeeper/__init__.py +55 -0
- trackerkeeper/__main__.py +3 -0
- trackerkeeper/_version.py +24 -0
- trackerkeeper/app.py +445 -0
- trackerkeeper/assets/__init__.py +2 -0
- trackerkeeper/assets/trackerkeeper.svg +12 -0
- trackerkeeper/async_io.py +299 -0
- trackerkeeper/autostart/__init__.py +52 -0
- trackerkeeper/autostart/_linux.py +129 -0
- trackerkeeper/autostart/_macos.py +163 -0
- trackerkeeper/autostart/_msix.py +181 -0
- trackerkeeper/autostart/_unsupported.py +22 -0
- trackerkeeper/autostart/_windows.py +96 -0
- trackerkeeper/bake.py +217 -0
- trackerkeeper/blur/__init__.py +212 -0
- trackerkeeper/blur/_dwm.py +348 -0
- trackerkeeper/blur/_faux_frost.py +143 -0
- trackerkeeper/blur/_kwin.py +478 -0
- trackerkeeper/blur/_macos.py +356 -0
- trackerkeeper/blur/_unsupported.py +39 -0
- trackerkeeper/boot_timing.py +75 -0
- trackerkeeper/breadboard.py +1554 -0
- trackerkeeper/bus.py +110 -0
- trackerkeeper/catalog.py +176 -0
- trackerkeeper/color_tokens.py +697 -0
- trackerkeeper/credentials.py +471 -0
- trackerkeeper/custom_tooltip.py +254 -0
- trackerkeeper/dashboard.py +704 -0
- trackerkeeper/deliver.py +442 -0
- trackerkeeper/design_tokens.py +415 -0
- trackerkeeper/diagnostics.py +168 -0
- trackerkeeper/drag_repaint/__init__.py +68 -0
- trackerkeeper/drag_repaint/_kwin.py +192 -0
- trackerkeeper/drag_repaint/_unsupported.py +41 -0
- trackerkeeper/drag_repaint/effect/dragrepaint/contents/code/main.js +86 -0
- trackerkeeper/drag_repaint/effect/dragrepaint/metadata.json +17 -0
- trackerkeeper/frosted_dialog.py +377 -0
- trackerkeeper/i18n/__init__.py +101 -0
- trackerkeeper/i18n/fmt.py +137 -0
- trackerkeeper/icon_button.py +137 -0
- trackerkeeper/icons.py +532 -0
- trackerkeeper/identity.py +135 -0
- trackerkeeper/item_dialog.py +185 -0
- trackerkeeper/kde_titlebar.py +196 -0
- trackerkeeper/keyboard_focus.py +353 -0
- trackerkeeper/log.py +112 -0
- trackerkeeper/macos_menubar.py +282 -0
- trackerkeeper/macos_window.py +172 -0
- trackerkeeper/metadata.py +121 -0
- trackerkeeper/noborder/__init__.py +61 -0
- trackerkeeper/noborder/_kwin.py +247 -0
- trackerkeeper/noborder/_unsupported.py +42 -0
- trackerkeeper/notifications/__init__.py +84 -0
- trackerkeeper/notifications/_linux.py +68 -0
- trackerkeeper/notifications/_macos.py +159 -0
- trackerkeeper/notifications/_unsupported.py +22 -0
- trackerkeeper/notifications/_windows.py +115 -0
- trackerkeeper/platform_compat.py +149 -0
- trackerkeeper/power/__init__.py +84 -0
- trackerkeeper/power/_linux.py +79 -0
- trackerkeeper/power/_unsupported.py +18 -0
- trackerkeeper/power/_windows.py +52 -0
- trackerkeeper/rig.py +338 -0
- trackerkeeper/scaffold.py +310 -0
- trackerkeeper/selector.py +529 -0
- trackerkeeper/settings.py +226 -0
- trackerkeeper/settings_dialog.py +307 -0
- trackerkeeper/settings_migration.py +101 -0
- trackerkeeper/single_instance.py +330 -0
- trackerkeeper/smooth_scroll.py +155 -0
- trackerkeeper/sources.py +318 -0
- trackerkeeper/system_accent.py +363 -0
- trackerkeeper/terminal.py +423 -0
- trackerkeeper/test_bridge.py +514 -0
- trackerkeeper/theme.py +725 -0
- trackerkeeper/top_bar.py +154 -0
- trackerkeeper/tray.py +184 -0
- trackerkeeper/ui_helpers.py +2294 -0
- trackerkeeper/update_chip.py +119 -0
- trackerkeeper/updates.py +216 -0
- trackerkeeper/win_frameless.py +364 -0
- trackerkeeper/window.py +609 -0
- trackerkeeper/windows_shortcut.py +355 -0
- trackerkeeper-0.1.0.dist-info/METADATA +144 -0
- trackerkeeper-0.1.0.dist-info/RECORD +89 -0
- trackerkeeper-0.1.0.dist-info/WHEEL +5 -0
- trackerkeeper-0.1.0.dist-info/entry_points.txt +8 -0
- trackerkeeper-0.1.0.dist-info/licenses/LICENSE +338 -0
- 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
|