panescape 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.
- panescape/__init__.py +3 -0
- panescape/__main__.py +5 -0
- panescape/backends/__init__.py +27 -0
- panescape/backends/base.py +221 -0
- panescape/backends/gnome.py +246 -0
- panescape/backends/kde.py +191 -0
- panescape/backends/wlroots.py +221 -0
- panescape/calibrate.py +184 -0
- panescape/cli.py +512 -0
- panescape/config.py +322 -0
- panescape/gui/__init__.py +76 -0
- panescape/gui/apply_tab.py +819 -0
- panescape/gui/common.py +75 -0
- panescape/gui/desk_view.py +326 -0
- panescape/gui/layout_editor.py +1188 -0
- panescape/gui/main_window.py +227 -0
- panescape/gui/pictures_tab.py +654 -0
- panescape/gui/unsplash_tab.py +510 -0
- panescape/gui/widgets.py +154 -0
- panescape/layout.py +140 -0
- panescape/models.py +60 -0
- panescape/project.py +1065 -0
- panescape/py.typed +0 -0
- panescape/render.py +181 -0
- panescape/skeleton.py +118 -0
- panescape/tags.py +223 -0
- panescape/units.py +98 -0
- panescape/unsplash.py +528 -0
- panescape/yamledit.py +86 -0
- panescape-0.1.0.dist-info/METADATA +619 -0
- panescape-0.1.0.dist-info/RECORD +35 -0
- panescape-0.1.0.dist-info/WHEEL +5 -0
- panescape-0.1.0.dist-info/entry_points.txt +6 -0
- panescape-0.1.0.dist-info/licenses/LICENSE +21 -0
- panescape-0.1.0.dist-info/top_level.txt +1 -0
panescape/__init__.py
ADDED
panescape/__main__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Desktop backends that know how to apply wallpapers.
|
|
2
|
+
|
|
3
|
+
Two kinds:
|
|
4
|
+
|
|
5
|
+
* ``KIND = "spanned"`` (GNOME): one combined image for the whole desktop,
|
|
6
|
+
built with ``compose()`` and set with ``set_wallpaper()``; native slideshows.
|
|
7
|
+
* ``KIND = "per_output"`` (sway, Hyprland, KDE Plasma): one image per output, set with
|
|
8
|
+
``set_outputs()``; panescape runs the slideshow itself.
|
|
9
|
+
|
|
10
|
+
Both provide ``available()``, ``get_layout()`` and ``match_monitors()``.
|
|
11
|
+
PRs for more desktops (niri, Cinnamon, ...) welcome.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from . import gnome, kde, wlroots
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect():
|
|
20
|
+
# compositor sockets are more specific than XDG_CURRENT_DESKTOP
|
|
21
|
+
if wlroots.available():
|
|
22
|
+
return wlroots
|
|
23
|
+
if kde.available():
|
|
24
|
+
return kde
|
|
25
|
+
if gnome.available():
|
|
26
|
+
return gnome
|
|
27
|
+
return None
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Things every desktop backend shares: the desktop's monitor list and matching
|
|
2
|
+
it to the monitors in a configuration."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from itertools import permutations
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# wl_output / Mutter transform -> rotation in degrees (GNOME Settings sense:
|
|
14
|
+
# 90 = "Portrait Right"). 4-7 are the flipped variants.
|
|
15
|
+
TRANSFORM_ROTATION = {0: 0, 1: 90, 2: 180, 3: 270, 4: 0, 5: 90, 6: 180, 7: 270}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BackendError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def link_dir(kind: str) -> Path:
|
|
23
|
+
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
|
|
24
|
+
return base / "panescape" / kind
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def unique_link(path: Path, kind: str) -> Path:
|
|
28
|
+
"""A link whose name changes whenever the file changes, for wallpaper tools that
|
|
29
|
+
cache images by path (Plasma, hyprpaper)."""
|
|
30
|
+
import shutil
|
|
31
|
+
st = path.stat()
|
|
32
|
+
d = link_dir(kind)
|
|
33
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
link = d / f"{path.parent.name}-{path.stem}-{st.st_mtime_ns:x}-{st.st_size:x}{path.suffix}"
|
|
35
|
+
if not link.exists():
|
|
36
|
+
try:
|
|
37
|
+
link.symlink_to(path)
|
|
38
|
+
except OSError:
|
|
39
|
+
shutil.copy2(path, link)
|
|
40
|
+
return link
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def prune_links(kind: str, keep: set[Path], max_links: int = 64) -> None:
|
|
44
|
+
"""Drop old links, keeping the newest few (a slideshow cycles through them)."""
|
|
45
|
+
d = link_dir(kind)
|
|
46
|
+
if not d.is_dir():
|
|
47
|
+
return
|
|
48
|
+
links = sorted(d.iterdir(), key=lambda p: p.lstat().st_mtime, reverse=True)
|
|
49
|
+
for old in links[max_links:]:
|
|
50
|
+
if old not in keep:
|
|
51
|
+
old.unlink(missing_ok=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def running(name: str) -> bool:
|
|
55
|
+
"""Is a process with this name running for this user? (/proc, no psutil needed)"""
|
|
56
|
+
proc = Path("/proc")
|
|
57
|
+
if not proc.is_dir():
|
|
58
|
+
return False
|
|
59
|
+
uid = os.getuid()
|
|
60
|
+
for d in proc.iterdir():
|
|
61
|
+
if not d.name.isdigit():
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
if d.stat().st_uid == uid and (d / "comm").read_text().strip() == name[:15]:
|
|
65
|
+
return True
|
|
66
|
+
except OSError:
|
|
67
|
+
continue
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _hydrapaper_daemon_enabled() -> bool:
|
|
72
|
+
"""hydrapaperd keeps running after it is switched off; its config says whether it acts."""
|
|
73
|
+
home = Path.home()
|
|
74
|
+
for cfg in (Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config") / "org.gabmus.hydrapaper.json",
|
|
75
|
+
home / ".var/app/org.gabmus.hydrapaper/config/org.gabmus.hydrapaper.json"):
|
|
76
|
+
try:
|
|
77
|
+
return bool(json.loads(cfg.read_text()).get("enable_daemon", False))
|
|
78
|
+
except (OSError, ValueError):
|
|
79
|
+
continue
|
|
80
|
+
return True # running but no readable config: assume it acts
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Programs that set the wallpaper on their own and so replace panescape's.
|
|
84
|
+
# name -> (process name, extra check, advice)
|
|
85
|
+
WALLPAPER_TOOLS = {
|
|
86
|
+
"HydraPaper": ("hydrapaperd", _hydrapaper_daemon_enabled,
|
|
87
|
+
"its background service re-applies HydraPaper's wallpaper whenever the displays change "
|
|
88
|
+
"(e.g. after the monitors sleep). Switch the daemon off in HydraPaper's preferences."),
|
|
89
|
+
"Variety": ("variety", None, "it changes the wallpaper on its own schedule. Quit Variety, or turn off "
|
|
90
|
+
"its automatic changes."),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def competing_tools() -> list[str]:
|
|
95
|
+
"""Warnings about other running programs that will replace panescape's wallpaper."""
|
|
96
|
+
out = []
|
|
97
|
+
for name, (proc, check, advice) in WALLPAPER_TOOLS.items():
|
|
98
|
+
try:
|
|
99
|
+
if running(proc) and (check is None or check()):
|
|
100
|
+
out.append(f"{name} is running and will replace panescape's wallpaper: {advice}")
|
|
101
|
+
except Exception: # noqa: BLE001 - a warning must never break applying
|
|
102
|
+
continue
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class OsMonitor:
|
|
108
|
+
connector: str
|
|
109
|
+
vendor: str
|
|
110
|
+
product: str
|
|
111
|
+
serial: str
|
|
112
|
+
display_name: str
|
|
113
|
+
x: int # logical position
|
|
114
|
+
y: int
|
|
115
|
+
width: int # logical size (as rotated)
|
|
116
|
+
height: int
|
|
117
|
+
scale: float
|
|
118
|
+
rotation: int # 0/90/180/270, GNOME Settings sense
|
|
119
|
+
mode: tuple[int, int] # native mode pixels (landscape as reported)
|
|
120
|
+
primary: bool
|
|
121
|
+
builtin: bool
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def center_x(self) -> float:
|
|
125
|
+
return self.x + self.width / 2
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class OsLayout:
|
|
130
|
+
monitors: list[OsMonitor]
|
|
131
|
+
logical: bool # True: logical layout mode (Wayland), False: physical px
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def bbox(self) -> tuple[int, int, int, int]:
|
|
135
|
+
x0 = min(m.x for m in self.monitors)
|
|
136
|
+
y0 = min(m.y for m in self.monitors)
|
|
137
|
+
x1 = max(m.x + m.width for m in self.monitors)
|
|
138
|
+
y1 = max(m.y + m.height for m in self.monitors)
|
|
139
|
+
return x0, y0, x1, y1
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def render_scale(self) -> float:
|
|
143
|
+
return max(m.scale for m in self.monitors) if self.logical else 1.0
|
|
144
|
+
|
|
145
|
+
def fingerprint(self) -> str:
|
|
146
|
+
key = [(m.connector, m.serial, m.x, m.y, m.width, m.height, m.scale) for m in self.monitors]
|
|
147
|
+
return hashlib.sha1(json.dumps(key).encode()).hexdigest()[:10]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def match_monitors(config_monitors, os_layout: OsLayout, centres: dict | None = None) -> dict:
|
|
151
|
+
"""Return {config monitor name: OsMonitor}.
|
|
152
|
+
|
|
153
|
+
Explicit ``connector`` / ``serial`` / ``product`` keys win. The rest are
|
|
154
|
+
paired by arrangement: ``centres`` ({name: (x, y)} in mm, from the physical
|
|
155
|
+
layout) is compared with GNOME's layout after normalising both to their
|
|
156
|
+
bounding boxes, so rows, columns and grids pair up. Without ``centres``
|
|
157
|
+
they are paired left to right.
|
|
158
|
+
"""
|
|
159
|
+
remaining = sorted(os_layout.monitors, key=lambda m: (m.center_x, m.y))
|
|
160
|
+
result = {}
|
|
161
|
+
pending = []
|
|
162
|
+
for cm in config_monitors:
|
|
163
|
+
if cm.match:
|
|
164
|
+
hit = [om for om in remaining if all(
|
|
165
|
+
str(getattr(om, k, "")).lower() == str(v).lower() for k, v in cm.match.items())]
|
|
166
|
+
if not hit:
|
|
167
|
+
raise BackendError(f"monitor {cm.name!r}: no monitor on the desktop matches {cm.match}")
|
|
168
|
+
result[cm.name] = hit[0]
|
|
169
|
+
remaining.remove(hit[0])
|
|
170
|
+
else:
|
|
171
|
+
pending.append(cm)
|
|
172
|
+
if not pending:
|
|
173
|
+
return result
|
|
174
|
+
if len(remaining) < len(pending):
|
|
175
|
+
raise BackendError(f"configuration has {len(config_monitors)} monitors but the desktop has "
|
|
176
|
+
f"{len(os_layout.monitors)} active; add connector/serial keys or fix the layout")
|
|
177
|
+
builtin_free = [om for om in remaining if not om.builtin]
|
|
178
|
+
pool = builtin_free if len(builtin_free) >= len(pending) else remaining
|
|
179
|
+
if centres and len(pending) > 1:
|
|
180
|
+
pairs = _match_by_arrangement(pending, pool, centres)
|
|
181
|
+
else:
|
|
182
|
+
pairs = list(zip(pending, pool, strict=False)) # pool may be larger
|
|
183
|
+
for cm, om in pairs:
|
|
184
|
+
result[cm.name] = om
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _normalise(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
|
189
|
+
"""Scale to the bounding box, keeping proportions (one factor for both axes),
|
|
190
|
+
so a small vertical offset in a row stays small."""
|
|
191
|
+
xs, ys = [p[0] for p in points], [p[1] for p in points]
|
|
192
|
+
span = max(max(xs) - min(xs), max(ys) - min(ys)) or 1.0
|
|
193
|
+
return [((x - min(xs)) / span, (y - min(ys)) / span) for x, y in points]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _match_by_arrangement(pending, pool, centres) -> list:
|
|
197
|
+
"""Assign config monitors to OS monitors minimising the normalised distance."""
|
|
198
|
+
|
|
199
|
+
cfg_pts = _normalise([centres[cm.name] for cm in pending])
|
|
200
|
+
os_pts = _normalise([(om.center_x, om.y + om.height / 2) for om in pool])
|
|
201
|
+
|
|
202
|
+
def cost(i: int, j: int) -> float:
|
|
203
|
+
return (cfg_pts[i][0] - os_pts[j][0]) ** 2 + (cfg_pts[i][1] - os_pts[j][1]) ** 2
|
|
204
|
+
|
|
205
|
+
n = len(pending)
|
|
206
|
+
if len(pool) <= 8:
|
|
207
|
+
best = min(permutations(range(len(pool)), n),
|
|
208
|
+
key=lambda perm: sum(cost(i, j) for i, j in enumerate(perm)))
|
|
209
|
+
return [(pending[i], pool[j]) for i, j in enumerate(best)]
|
|
210
|
+
used, pairs = set(), [] # large setups: greedy
|
|
211
|
+
for i in range(n):
|
|
212
|
+
j = min((j for j in range(len(pool)) if j not in used), key=lambda j: cost(i, j))
|
|
213
|
+
used.add(j)
|
|
214
|
+
pairs.append((pending[i], pool[j]))
|
|
215
|
+
return pairs
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def native_size(om: OsMonitor) -> tuple[int, int]:
|
|
219
|
+
"""Pixel size of the output as the wallpaper sees it (rotated)."""
|
|
220
|
+
w, h = om.mode
|
|
221
|
+
return (h, w) if om.rotation in (90, 270) else (w, h)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""GNOME / Mutter backend (Wayland and X11).
|
|
2
|
+
|
|
3
|
+
GNOME has one wallpaper for the whole desktop. With ``picture-options =
|
|
4
|
+
spanned`` Mutter stretches that image over the bounding box of the *logical*
|
|
5
|
+
monitor layout (Settings > Displays) and each monitor shows its own part, at
|
|
6
|
+
``scale`` texels per logical pixel. So we build one composite image in that
|
|
7
|
+
logical space, at the largest monitor scale so HiDPI screens get native
|
|
8
|
+
resolution, and put each monitor's physically correct slice into its rectangle.
|
|
9
|
+
|
|
10
|
+
Slideshows use GNOME's native background XML format, so no daemon is needed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
import xml.etree.ElementTree as ET
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from PIL import Image
|
|
24
|
+
|
|
25
|
+
from .base import ( # noqa: F401 - re-exported for backwards compatibility
|
|
26
|
+
TRANSFORM_ROTATION,
|
|
27
|
+
BackendError,
|
|
28
|
+
OsLayout,
|
|
29
|
+
OsMonitor,
|
|
30
|
+
match_monitors,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
SCHEMA = "org.gnome.desktop.background"
|
|
34
|
+
KIND = "spanned" # one combined image for the whole desktop
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def available() -> bool:
|
|
38
|
+
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
|
39
|
+
if not ("gnome" in desktop or "unity" in desktop):
|
|
40
|
+
return False
|
|
41
|
+
try:
|
|
42
|
+
_system_gsettings()
|
|
43
|
+
except BackendError:
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _display_state_json() -> list:
|
|
49
|
+
if shutil.which("busctl"):
|
|
50
|
+
out = subprocess.run(
|
|
51
|
+
["busctl", "--user", "--json=short", "call", "org.gnome.Mutter.DisplayConfig",
|
|
52
|
+
"/org/gnome/Mutter/DisplayConfig", "org.gnome.Mutter.DisplayConfig", "GetCurrentState"],
|
|
53
|
+
capture_output=True, text=True)
|
|
54
|
+
if out.returncode == 0:
|
|
55
|
+
return json.loads(out.stdout)["data"]
|
|
56
|
+
err = out.stderr.strip()
|
|
57
|
+
else:
|
|
58
|
+
err = "busctl not found"
|
|
59
|
+
raise BackendError(f"could not query GNOME display layout ({err})")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _variant(v):
|
|
63
|
+
return v.get("data") if isinstance(v, dict) and "data" in v else v
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_layout() -> OsLayout:
|
|
67
|
+
_serial, monitors, logical_monitors, props = _display_state_json()
|
|
68
|
+
info = {}
|
|
69
|
+
for (spec, modes, mprops) in monitors:
|
|
70
|
+
cur = next((md for md in modes if _variant(md[6].get("is-current", {})) is True), None)
|
|
71
|
+
info[tuple(spec)] = {
|
|
72
|
+
"mode": (cur[1], cur[2]) if cur else None,
|
|
73
|
+
"display_name": _variant(mprops.get("display-name", {})) or spec[0],
|
|
74
|
+
"builtin": bool(_variant(mprops.get("is-builtin", {}))),
|
|
75
|
+
}
|
|
76
|
+
layout_mode = _variant(props.get("layout-mode", {"data": 1}))
|
|
77
|
+
logical = layout_mode != 2
|
|
78
|
+
result = []
|
|
79
|
+
for (x, y, scale, transform, primary, specs, _p) in logical_monitors:
|
|
80
|
+
spec = tuple(specs[0])
|
|
81
|
+
meta = info.get(spec, {})
|
|
82
|
+
mw, mh = meta.get("mode") or (0, 0)
|
|
83
|
+
rot = TRANSFORM_ROTATION.get(int(transform), 0)
|
|
84
|
+
w, h = (mh, mw) if rot in (90, 270) else (mw, mh)
|
|
85
|
+
if logical:
|
|
86
|
+
w, h = int(round(w / scale)), int(round(h / scale))
|
|
87
|
+
result.append(OsMonitor(
|
|
88
|
+
connector=spec[0], vendor=spec[1], product=spec[2], serial=spec[3],
|
|
89
|
+
display_name=meta.get("display_name", spec[0]), x=int(x), y=int(y), width=w, height=h,
|
|
90
|
+
scale=float(scale), rotation=rot, mode=(mw, mh), primary=bool(primary),
|
|
91
|
+
builtin=meta.get("builtin", False)))
|
|
92
|
+
if not result:
|
|
93
|
+
raise BackendError("GNOME reports no active monitors")
|
|
94
|
+
return OsLayout(result, logical)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def compose(os_layout: OsLayout, render_for, background="#000000") -> Image.Image:
|
|
98
|
+
"""Build the spanned image. ``render_for(os_monitor, (w, h))`` returns an image or None."""
|
|
99
|
+
k = os_layout.render_scale
|
|
100
|
+
x0, y0, x1, y1 = os_layout.bbox
|
|
101
|
+
W, H = int(round((x1 - x0) * k)), int(round((y1 - y0) * k))
|
|
102
|
+
canvas = Image.new("RGB", (W, H), background)
|
|
103
|
+
for om in os_layout.monitors:
|
|
104
|
+
rx0, ry0 = int(round((om.x - x0) * k)), int(round((om.y - y0) * k))
|
|
105
|
+
rx1, ry1 = int(round((om.x + om.width - x0) * k)), int(round((om.y + om.height - y0) * k))
|
|
106
|
+
tile = render_for(om, (rx1 - rx0, ry1 - ry0))
|
|
107
|
+
if tile is not None:
|
|
108
|
+
canvas.paste(tile, (rx0, ry0))
|
|
109
|
+
return canvas
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------- gsettings
|
|
113
|
+
|
|
114
|
+
def _system_gsettings() -> str:
|
|
115
|
+
"""The desktop's own gsettings binary.
|
|
116
|
+
|
|
117
|
+
Conda/Anaconda (and some venvs/Flatpak SDKs) ship their own GLib whose
|
|
118
|
+
gsettings has no dconf backend: it "succeeds" but writes to a throwaway
|
|
119
|
+
memory store. Prefer the system binary and ignore ones inside an env.
|
|
120
|
+
"""
|
|
121
|
+
for cand in ("/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"):
|
|
122
|
+
if os.access(cand, os.X_OK):
|
|
123
|
+
return cand
|
|
124
|
+
for d in os.environ.get("PATH", "").split(os.pathsep):
|
|
125
|
+
cand = os.path.join(d, "gsettings")
|
|
126
|
+
low = cand.lower()
|
|
127
|
+
if os.access(cand, os.X_OK) and not any(s in low for s in ("conda", "mamba", "venv", "/envs/")):
|
|
128
|
+
return cand
|
|
129
|
+
found = shutil.which("gsettings")
|
|
130
|
+
if found:
|
|
131
|
+
return found
|
|
132
|
+
raise BackendError("gsettings not found")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _env() -> dict:
|
|
136
|
+
env = dict(os.environ)
|
|
137
|
+
for var in ("GSETTINGS_BACKEND", "GIO_MODULE_DIR", "GSETTINGS_SCHEMA_DIR", "GIO_EXTRA_MODULES"):
|
|
138
|
+
env.pop(var, None)
|
|
139
|
+
return env
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _gset(key: str, value: str) -> None:
|
|
143
|
+
subprocess.run([_system_gsettings(), "set", SCHEMA, key, value], check=True, env=_env())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _gget(key: str) -> str:
|
|
147
|
+
out = subprocess.run([_system_gsettings(), "get", SCHEMA, key], capture_output=True, text=True,
|
|
148
|
+
check=True, env=_env())
|
|
149
|
+
return out.stdout.strip().strip("'")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _dconf_read(key: str) -> str | None:
|
|
153
|
+
"""Read straight from dconf (what GNOME Shell actually uses), if dconf is installed."""
|
|
154
|
+
dconf = "/usr/bin/dconf" if os.access("/usr/bin/dconf", os.X_OK) else shutil.which("dconf")
|
|
155
|
+
if not dconf:
|
|
156
|
+
return None
|
|
157
|
+
out = subprocess.run([dconf, "read", f"/org/gnome/desktop/background/{key}"],
|
|
158
|
+
capture_output=True, text=True, env=_env())
|
|
159
|
+
return out.stdout.strip().strip("'") if out.returncode == 0 else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def set_wallpaper(path: Path, color: str = "#000000") -> None:
|
|
163
|
+
uri = Path(path).resolve().as_uri()
|
|
164
|
+
_gset("picture-options", "spanned")
|
|
165
|
+
_gset("primary-color", color)
|
|
166
|
+
_gset("color-shading-type", "solid")
|
|
167
|
+
_gset("picture-uri", uri)
|
|
168
|
+
_gset("picture-uri-dark", uri)
|
|
169
|
+
stored = _dconf_read("picture-uri")
|
|
170
|
+
if stored is None:
|
|
171
|
+
stored = _gget("picture-uri")
|
|
172
|
+
if stored != uri:
|
|
173
|
+
raise BackendError(f"GNOME did not accept the wallpaper (picture-uri is {stored!r}). "
|
|
174
|
+
f"Is {_system_gsettings()} the desktop's gsettings?")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def current_uri() -> str:
|
|
178
|
+
return _gget("picture-uri")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
SNAPSHOT_KEYS = ("picture-uri", "picture-uri-dark", "picture-options", "primary-color", "color-shading-type")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def snapshot() -> dict:
|
|
185
|
+
"""Current background settings, as GVariant text, for restore()."""
|
|
186
|
+
out = {}
|
|
187
|
+
for key in SNAPSHOT_KEYS:
|
|
188
|
+
r = subprocess.run([_system_gsettings(), "get", SCHEMA, key], capture_output=True, text=True,
|
|
189
|
+
check=True, env=_env())
|
|
190
|
+
out[key] = r.stdout.strip()
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def restore(snap: dict) -> None:
|
|
195
|
+
for key in SNAPSHOT_KEYS:
|
|
196
|
+
if key in snap:
|
|
197
|
+
_gset(key, snap[key])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --------------------------------------------------------------------------- slideshows
|
|
201
|
+
|
|
202
|
+
def write_slideshow(xml_path: Path, files: list[Path], interval: float, transition: float,
|
|
203
|
+
start: float | None = None) -> Path:
|
|
204
|
+
"""GNOME background XML. ``interval`` is the total time per image (incl. transition).
|
|
205
|
+
|
|
206
|
+
GNOME shows image ``((now - start) mod cycle) // interval``, so ``start``
|
|
207
|
+
controls which image is on screen; it defaults to now (first image).
|
|
208
|
+
"""
|
|
209
|
+
transition = max(0.0, min(transition, interval / 2))
|
|
210
|
+
root = ET.Element("background")
|
|
211
|
+
st = ET.SubElement(root, "starttime")
|
|
212
|
+
t = time.localtime(time.time() if start is None else start)
|
|
213
|
+
for tag, val in (("year", t.tm_year), ("month", t.tm_mon), ("day", t.tm_mday),
|
|
214
|
+
("hour", t.tm_hour), ("minute", t.tm_min), ("second", t.tm_sec)):
|
|
215
|
+
ET.SubElement(st, tag).text = str(val)
|
|
216
|
+
files = [Path(f).resolve() for f in files]
|
|
217
|
+
if len(files) == 1:
|
|
218
|
+
files = files * 2 # GNOME needs at least one transition loop to be happy
|
|
219
|
+
for i, f in enumerate(files):
|
|
220
|
+
static = ET.SubElement(root, "static")
|
|
221
|
+
ET.SubElement(static, "duration").text = f"{interval - transition:.1f}"
|
|
222
|
+
ET.SubElement(static, "file").text = str(f)
|
|
223
|
+
if transition > 0:
|
|
224
|
+
tr = ET.SubElement(root, "transition", type="overlay")
|
|
225
|
+
ET.SubElement(tr, "duration").text = f"{transition:.1f}"
|
|
226
|
+
ET.SubElement(tr, "from").text = str(f)
|
|
227
|
+
ET.SubElement(tr, "to").text = str(files[(i + 1) % len(files)])
|
|
228
|
+
ET.indent(root)
|
|
229
|
+
xml_path.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
ET.ElementTree(root).write(xml_path, encoding="utf-8", xml_declaration=True)
|
|
231
|
+
return xml_path
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def read_slideshow(xml_path: Path) -> tuple[list[Path], float, float]:
|
|
235
|
+
root = ET.parse(xml_path).getroot()
|
|
236
|
+
files, interval, transition = [], None, 0.0
|
|
237
|
+
for el in root:
|
|
238
|
+
if el.tag == "static":
|
|
239
|
+
files.append(Path(el.findtext("file")))
|
|
240
|
+
interval = interval or float(el.findtext("duration", "0"))
|
|
241
|
+
elif el.tag == "transition":
|
|
242
|
+
transition = float(el.findtext("duration", "0"))
|
|
243
|
+
# undo the single-image duplication
|
|
244
|
+
if len(files) == 2 and files[0] == files[1]:
|
|
245
|
+
files = files[:1]
|
|
246
|
+
return files, (interval or 0) + transition, transition
|