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 23import sys 24import locale 25 26import ruamel.yaml as yaml 27from pywayland.client import Display, EventQueue 28from pywayland.protocol.wayland import WlOutput 29from pathlib import Path 30 31locale.bindtextdomain("panorama-bg", "locale") 32locale.textdomain("panorama-bg") 33locale.setlocale(locale.LC_ALL) 34_ = locale.gettext 35 36faulthandler.enable() 37 38os.environ["GI_TYPELIB_PATH"] = "/usr/local/lib/x86_64-linux-gnu/girepository-1.0" 39 40from ctypes import CDLL 41CDLL("libgtk4-layer-shell.so") 42 43import gi 44 45gi.require_version("Gtk", "4.0") 46gi.require_version("Gtk4LayerShell", "1.0") 47 48from gi.repository import Gtk, GLib, Gtk4LayerShell, Gdk, Gio, GObject 49 50import ctypes 51from cffi import FFI 52ffi = FFI() 53ffi.cdef(""" 54void * gdk_wayland_display_get_wl_display (void * display); 55void * gdk_wayland_surface_get_wl_surface (void * surface); 56void * gdk_wayland_monitor_get_wl_output (void * monitor); 57""") 58gtk = ffi.dlopen("libgtk-4.so.1") 59 60 61def get_config_file(): 62config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) 63 64return config_home / "panorama-bg" / "config.yaml" 65 66 67FITTING_MODES = { 68"cover": Gtk.ContentFit.COVER, 69"contain": Gtk.ContentFit.CONTAIN, 70"fill": Gtk.ContentFit.FILL, 71"scale_down": Gtk.ContentFit.SCALE_DOWN, 72} 73 74FITTING_MODES_REVERSE = { 75Gtk.ContentFit.COVER: "cover", 76Gtk.ContentFit.CONTAIN: "contain", 77Gtk.ContentFit.FILL: "fill", 78Gtk.ContentFit.SCALE_DOWN: "scale_down", 79} 80 81 82class PanoramaBG(Gtk.Application): 83def __init__(self): 84super().__init__( 85application_id="com.roundabout_host.roundabout.PanoramaBG", 86flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE 87) 88self.wl_output_ids: dict[int, WlOutput] = {} 89self.wl_display = None 90self.display = None 91self.settings_window = None 92 93self.add_main_option( 94"open-settings", 95ord("s"), 96GLib.OptionFlags.NONE, 97GLib.OptionArg.NONE, 98_("Open the settings window") 99) 100 101def make_backgrounds(self): 102for window in self.get_windows(): 103window.destroy() 104 105with open(get_config_file(), "r") as config_file: 106yaml_loader = yaml.YAML(typ="rt") 107yaml_file = yaml_loader.load(config_file) 108 109monitors = self.display.get_monitors() 110 111for monitor in monitors: 112background_window = BackgroundWindow(self, monitor) 113monitor.background_window = background_window 114 115if monitor.get_connector() in yaml_file["monitors"]: 116background_window.put_sources(yaml_file["monitors"][monitor.get_connector()]["sources"]) 117background_window.time_per_image = yaml_file["monitors"][monitor.get_connector()]["time_per_image"] 118background_window.change_image() 119background_window.picture.set_content_fit(FITTING_MODES[yaml_file["monitors"][monitor.get_connector()]["fitting_mode"]]) 120 121background_window.present() 122 123def do_startup(self): 124Gtk.Application.do_startup(self) 125self.hold() 126 127ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 128ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 129 130self.display = Gdk.Display.get_default() 131 132self.wl_display = Display() 133wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 134ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None))) 135self.wl_display._ptr = wl_display_ptr 136self.registry = self.wl_display.get_registry() 137self.registry.dispatcher["global"] = self.on_global 138self.registry.dispatcher["global_remove"] = self.on_global_remove 139self.wl_display.roundtrip() 140 141def receive_output_name(self, output: WlOutput, name: str): 142output.name = name 143 144def on_global(self, registry, name, interface, version): 145if interface == "wl_output": 146output = registry.bind(name, WlOutput, version) 147output.id = name 148output.name = None 149output.dispatcher["name"] = self.receive_output_name 150while not output.name: 151self.wl_display.dispatch(block=True) 152if not output.name.startswith("live-preview"): 153self.make_backgrounds() 154self.wl_output_ids[name] = output 155 156def on_global_remove(self, registry, name): 157if name in self.wl_output_ids: 158self.wl_output_ids[name].destroy() 159del self.wl_output_ids[name] 160 161def do_activate(self): 162Gio.Application.do_activate(self) 163 164def open_settings(self): 165if self.settings_window: 166self.settings_window.present() 167return 168 169self.settings_window = SettingsWindow(self) 170self.settings_window.connect("close-request", self.reset_settings_window) 171self.settings_window.present() 172 173def reset_settings_window(self, *args): 174self.settings_window = None 175 176def do_command_line(self, command_line: Gio.ApplicationCommandLine): 177options = command_line.get_options_dict() 178args = command_line.get_arguments()[1:] 179 180open_settings = options.contains("open-settings") 181if open_settings: 182self.open_settings() 183 184return 0 185 186def write_config(self): 187with open(get_config_file(), "w") as config_file: 188yaml_writer = yaml.YAML(typ="rt") 189data = {"monitors": {}} 190for window in self.get_windows(): 191if isinstance(window, BackgroundWindow): 192monitor = Gtk4LayerShell.get_monitor(window) 193data["monitors"][monitor.get_connector()] = { 194"sources": window.sources, 195"time_per_image": window.time_per_image, 196"fitting_mode": FITTING_MODES_REVERSE[window.picture.get_content_fit()], 197} 198 199yaml_writer.dump(data, config_file) 200 201 202class BackgroundWindow(Gtk.Window): 203def __init__(self, application: Gtk.Application, monitor: Gdk.Monitor): 204super().__init__(application=application) 205self.set_decorated(False) 206 207self.all_sources = [] 208self.sources = [] 209self.time_per_image = 0 210self.current_timeout = None 211 212Gtk4LayerShell.init_for_window(self) 213Gtk4LayerShell.set_namespace(self, "com.roundabout_host.panorama.background") 214Gtk4LayerShell.set_monitor(self, monitor) 215Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.BACKGROUND) 216 217Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 218Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 219Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True) 220Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 221 222self.picture = Gtk.Picture() 223self.set_child(self.picture) 224 225def put_sources(self, sources): 226self.all_sources = [] 227self.sources = sources 228for source in sources: 229path = pathlib.Path(source) 230if path.is_dir(): 231self.all_sources.extend(f for f in path.iterdir() if f.is_file()) 232else: 233self.all_sources.append(path) 234self.all_sources = self.all_sources 235 236def change_image(self): 237now = GLib.DateTime.new_now_local() 238self.picture.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % len(self.all_sources)])) 239time_until_next = self.time_per_image - now.to_unix() % self.time_per_image 240self.current_timeout = GLib.timeout_add_seconds(time_until_next, self.change_image) 241return False 242 243 244@Gtk.Template(filename="settings-window.ui") 245class SettingsWindow(Gtk.Window): 246__gtype_name__ = "SettingsWindow" 247 248monitor_list: Gtk.ListBox = Gtk.Template.Child() 249 250def __init__(self, application: Gtk.Application, **kwargs): 251super().__init__(application=application, **kwargs) 252 253monitors = application.display.get_monitors() 254for monitor in monitors: 255monitor_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) 256monitor_box.append(Gtk.Label.new(monitor.get_description())) 257 258monitor_time_adjustment = Gtk.Adjustment(lower=1, upper=86400, value=monitor.background_window.time_per_image, step_increment=5) 259monitor_seconds_spinbutton = Gtk.SpinButton(adjustment=monitor_time_adjustment, digits=0) 260monitor_time_adjustment.connect("value-changed", self.display_time_changed, monitor) 261monitor_box.append(monitor_seconds_spinbutton) 262 263monitor_row = Gtk.ListBoxRow(activatable=False, child=monitor_box) 264 265formats = Gdk.ContentFormats.new_for_gtype(Gdk.FileList) 266drop_target = Gtk.DropTarget(formats=formats, actions=Gdk.DragAction.COPY) 267drop_target.connect("drop", self.on_drop, monitor) 268monitor_row.add_controller(drop_target) 269 270self.monitor_list.append(monitor_row) 271 272self.connect("close-request", lambda *args: self.destroy()) 273 274def display_time_changed(self, adjustment: Gtk.Adjustment, monitor: Gdk.Monitor): 275if hasattr(monitor, "background_window"): 276if monitor.background_window.current_timeout is not None: 277GLib.source_remove(monitor.background_window.current_timeout) 278 279monitor.background_window.time_per_image = int(adjustment.get_value()) 280 281monitor.background_window.change_image() 282 283self.get_application().write_config() 284 285def on_drop(self, drop_target: Gtk.DropTarget, value: Gdk.FileList, x, y, monitor: Gdk.Monitor): 286if hasattr(monitor, "background_window"): 287files = value.get_files() 288if not files: 289return False 290 291paths = [] 292for file in files: 293paths.append(file.get_path()) 294 295if monitor.background_window.current_timeout is not None: 296GLib.source_remove(monitor.background_window.current_timeout) 297 298monitor.background_window.put_sources(paths) 299 300monitor.background_window.change_image() 301 302self.get_application().write_config() 303 304return False 305 306 307if __name__ == "__main__": 308app = PanoramaBG() 309app.run(sys.argv) 310 311