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 ADDED
@@ -0,0 +1,3 @@
1
+ """panescape: physically accurate spanned wallpapers for mixed multi-monitor setups."""
2
+
3
+ __version__ = "0.1.0"
panescape/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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