main.py
Python script, ASCII text executable
1""" 2Panorama background: desktop wallpaper using Wayland layer-shell 3protocol. 4Copyright 2025, roundabout-host.com <vlad@roundabout-host.com> 5 6This program is free software: you can redistribute it and/or modify 7it under the terms of the GNU General Public Licence as published by 8the Free Software Foundation, either version 3 of the Licence, or 9(at your option) any later version. 10 11This program is distributed in the hope that it will be useful, 12but WITHOUT ANY WARRANTY; without even the implied warranty of 13MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14GNU General Public Licence for more details. 15 16You should have received a copy of the GNU General Public Licence 17along with this program. If not, see <https://www.gnu.org/licenses/>. 18""" 19 20import os 21import faulthandler 22import pathlib 23 24import ruamel.yaml as yaml 25from pywayland.client import Display, EventQueue 26from pywayland.protocol.wayland import WlOutput 27from pathlib import Path 28 29faulthandler.enable() 30 31os.environ["GI_TYPELIB_PATH"] = "/usr/local/lib/x86_64-linux-gnu/girepository-1.0" 32 33from ctypes import CDLL 34CDLL("libgtk4-layer-shell.so") 35 36import gi 37 38gi.require_version("Gtk", "4.0") 39gi.require_version("Gtk4LayerShell", "1.0") 40 41from gi.repository import Gtk, GLib, Gtk4LayerShell, Gdk, Gio, GObject 42 43import ctypes 44from cffi import FFI 45ffi = FFI() 46ffi.cdef(""" 47void * gdk_wayland_display_get_wl_display (void * display); 48void * gdk_wayland_surface_get_wl_surface (void * surface); 49void * gdk_wayland_monitor_get_wl_output (void * monitor); 50""") 51gtk = ffi.dlopen("libgtk-4.so.1") 52 53 54def get_config_file(): 55config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) 56 57return config_home / "panorama-bg" / "config.yaml" 58 59 60FITTING_MODES = { 61"cover": Gtk.ContentFit.COVER, 62"contain": Gtk.ContentFit.CONTAIN, 63"fill": Gtk.ContentFit.FILL, 64"scale_down": Gtk.ContentFit.SCALE_DOWN 65} 66 67 68class PanoramaBG(Gtk.Application): 69def __init__(self): 70super().__init__( 71application_id="com.roundabout_host.roundabout.PanoramaBG" 72) 73self.wl_output_ids: dict[int, WlOutput] = {} 74self.wl_display = None 75self.display = None 76 77def make_backgrounds(self): 78for window in self.get_windows(): 79window.destroy() 80 81with open(get_config_file(), "r") as config_file: 82yaml_loader = yaml.YAML(typ="rt") 83yaml_file = yaml_loader.load(config_file) 84 85monitors = self.display.get_monitors() 86 87for monitor in monitors: 88if monitor.get_connector() in yaml_file["monitors"]: 89all_sources = [] 90for source in yaml_file["monitors"][monitor.get_connector()]["sources"]: 91path = pathlib.Path(source) 92if path.is_dir(): 93all_sources.extend(f for f in path.iterdir() if f.is_file()) 94else: 95all_sources.append(path) 96 97background_window = BackgroundWindow(self, monitor) 98background_window.all_sources = all_sources 99background_window.time_per_image = yaml_file["monitors"][monitor.get_connector()]["time_per_image"] 100background_window.change_image() 101background_window.picture.set_content_fit(FITTING_MODES[yaml_file["monitors"][monitor.get_connector()]["fitting_mode"]]) 102background_window.present() 103 104def do_startup(self): 105Gtk.Application.do_startup(self) 106self.hold() 107 108ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 109ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 110 111self.display = Gdk.Display.get_default() 112 113self.wl_display = Display() 114wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 115ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None))) 116self.wl_display._ptr = wl_display_ptr 117self.registry = self.wl_display.get_registry() 118self.registry.dispatcher["global"] = self.on_global 119self.registry.dispatcher["global_remove"] = self.on_global_remove 120self.wl_display.roundtrip() 121 122def receive_output_name(self, output: WlOutput, name: str): 123output.name = name 124 125def on_global(self, registry, name, interface, version): 126if interface == "wl_output": 127output = registry.bind(name, WlOutput, version) 128output.id = name 129output.name = None 130output.dispatcher["name"] = self.receive_output_name 131while not output.name: 132self.wl_display.dispatch(block=True) 133if not output.name.startswith("live-preview"): 134self.make_backgrounds() 135self.wl_output_ids[name] = output 136 137def on_global_remove(self, registry, name): 138if name in self.wl_output_ids: 139self.wl_output_ids[name].destroy() 140del self.wl_output_ids[name] 141 142 143class BackgroundWindow(Gtk.Window): 144def __init__(self, application: Gtk.Application, monitor: Gdk.Monitor): 145super().__init__(application=application) 146self.set_decorated(False) 147 148self.all_sources = [] 149self.time_per_image = 0 150 151Gtk4LayerShell.init_for_window(self) 152Gtk4LayerShell.set_namespace(self, "com.roundabout_host.panorama.background") 153Gtk4LayerShell.set_monitor(self, monitor) 154Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.BACKGROUND) 155 156Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 157Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 158Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True) 159Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 160 161self.picture = Gtk.Picture() 162self.set_child(self.picture) 163 164def change_image(self): 165now = GLib.DateTime.new_now_local() 166self.picture.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % len(self.all_sources)])) 167time_until_next = self.time_per_image - now.to_unix() % self.time_per_image 168GLib.timeout_add_seconds(time_until_next, self.change_image) 169 170 171if __name__ == "__main__": 172app = PanoramaBG() 173app.run() 174 175