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() 110for monitor in monitors: 111background_window = BackgroundWindow(self, monitor) 112monitor.background_window = background_window 113if monitor.get_connector() in yaml_file["monitors"]: 114background_window.transition_ms = yaml_file["monitors"][monitor.get_connector()]["transition_ms"] 115background_window.time_per_image = yaml_file["monitors"][monitor.get_connector()]["time_per_image"] 116background_window.put_sources(yaml_file["monitors"][monitor.get_connector()]["sources"]) 117background_window.picture1.set_content_fit(FITTING_MODES[yaml_file["monitors"][monitor.get_connector()]["fitting_mode"]]) 118background_window.picture2.set_content_fit(FITTING_MODES[yaml_file["monitors"][monitor.get_connector()]["fitting_mode"]]) 119 120background_window.present() 121 122def do_startup(self): 123Gtk.Application.do_startup(self) 124self.hold() 125 126ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 127ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 128 129self.display = Gdk.Display.get_default() 130 131self.wl_display = Display() 132wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 133ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None))) 134self.wl_display._ptr = wl_display_ptr 135self.registry = self.wl_display.get_registry() 136self.registry.dispatcher["global"] = self.on_global 137self.registry.dispatcher["global_remove"] = self.on_global_remove 138self.wl_display.roundtrip() 139 140def receive_output_name(self, output: WlOutput, name: str): 141output.name = name 142 143def on_global(self, registry, name, interface, version): 144if interface == "wl_output": 145output = registry.bind(name, WlOutput, version) 146output.id = name 147output.name = None 148output.dispatcher["name"] = self.receive_output_name 149while not output.name: 150self.wl_display.dispatch(block=True) 151if not output.name.startswith("live-preview"): 152self.make_backgrounds() 153self.wl_output_ids[name] = output 154 155def on_global_remove(self, registry, name): 156if name in self.wl_output_ids: 157self.wl_output_ids[name].destroy() 158del self.wl_output_ids[name] 159 160def do_activate(self): 161Gio.Application.do_activate(self) 162 163def open_settings(self): 164if self.settings_window: 165self.settings_window.present() 166return 167 168self.settings_window = SettingsWindow(self) 169self.settings_window.connect("close-request", self.reset_settings_window) 170self.settings_window.present() 171 172def reset_settings_window(self, *args): 173self.settings_window = None 174 175def do_command_line(self, command_line: Gio.ApplicationCommandLine): 176options = command_line.get_options_dict() 177args = command_line.get_arguments()[1:] 178 179open_settings = options.contains("open-settings") 180if open_settings: 181self.open_settings() 182 183return 0 184 185def write_config(self): 186with open(get_config_file(), "w") as config_file: 187yaml_writer = yaml.YAML(typ="rt") 188data = {"monitors": {}} 189for window in self.get_windows(): 190if isinstance(window, BackgroundWindow): 191monitor = Gtk4LayerShell.get_monitor(window) 192data["monitors"][monitor.get_connector()] = { 193"sources": window.sources, 194"time_per_image": window.time_per_image, 195"fitting_mode": FITTING_MODES_REVERSE[window.picture.get_content_fit()], 196} 197 198yaml_writer.dump(data, config_file) 199 200 201class BackgroundWindow(Gtk.Window): 202def __init__(self, application: Gtk.Application, monitor: Gdk.Monitor): 203super().__init__(application=application) 204self.set_decorated(False) 205 206self.all_sources = [] 207self.sources = [] 208self.time_per_image = 0 209self.transition_ms = 0 210self.current_timeout = None 211self.pic1 = True 212 213Gtk4LayerShell.init_for_window(self) 214Gtk4LayerShell.set_namespace(self, "com.roundabout_host.panorama.background") 215Gtk4LayerShell.set_monitor(self, monitor) 216Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.BACKGROUND) 217 218Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 219Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 220Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True) 221Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 222 223self.picture1 = Gtk.Picture() 224self.picture2 = Gtk.Picture() 225self.stack = Gtk.Stack() 226self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) 227 228self.stack.add_child(self.picture1) 229self.stack.add_child(self.picture2) 230 231self.stack.set_visible_child(self.picture1) 232self.set_child(self.stack) 233 234 235def put_sources(self, sources): 236self.all_sources = [] 237self.sources = sources 238for source in sources: 239path = pathlib.Path(source) 240if path.is_dir(): 241self.all_sources.extend(f for f in path.iterdir() if f.is_file()) 242else: 243self.all_sources.append(path) 244self.all_sources = self.all_sources 245self.stack.set_transition_duration(self.transition_ms) 246self.change_image() 247 248def change_image(self): 249now = GLib.DateTime.new_now_local() 250# Ping pong between images is required for transition 251if self.pic1: 252self.picture1.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % len(self.all_sources)])) 253self.stack.set_visible_child(self.picture1) 254else: 255self.picture2.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % len(self.all_sources)])) 256self.stack.set_visible_child(self.picture2) 257self.pic1 = not self.pic1 258time_until_next = self.time_per_image - now.to_unix() % self.time_per_image 259self.current_timeout = GLib.timeout_add_seconds(time_until_next, self.change_image) 260return False 261 262 263@Gtk.Template(filename="settings-window.ui") 264class SettingsWindow(Gtk.Window): 265__gtype_name__ = "SettingsWindow" 266 267monitor_list: Gtk.ListBox = Gtk.Template.Child() 268 269def __init__(self, application: Gtk.Application, **kwargs): 270super().__init__(application=application, **kwargs) 271 272monitors = application.display.get_monitors() 273for monitor in monitors: 274monitor_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) 275monitor_box.append(Gtk.Label.new(monitor.get_description())) 276 277monitor_time_adjustment = Gtk.Adjustment(lower=1, upper=86400, value=monitor.background_window.time_per_image, step_increment=5) 278monitor_seconds_spinbutton = Gtk.SpinButton(adjustment=monitor_time_adjustment, digits=0) 279monitor_time_adjustment.connect("value-changed", self.display_time_changed, monitor) 280monitor_box.append(monitor_seconds_spinbutton) 281 282monitor_row = Gtk.ListBoxRow(activatable=False, child=monitor_box) 283 284formats = Gdk.ContentFormats.new_for_gtype(Gdk.FileList) 285drop_target = Gtk.DropTarget(formats=formats, actions=Gdk.DragAction.COPY) 286drop_target.connect("drop", self.on_drop, monitor) 287monitor_row.add_controller(drop_target) 288 289self.monitor_list.append(monitor_row) 290 291self.connect("close-request", lambda *args: self.destroy()) 292 293def display_time_changed(self, adjustment: Gtk.Adjustment, monitor: Gdk.Monitor): 294if hasattr(monitor, "background_window"): 295if monitor.background_window.current_timeout is not None: 296GLib.source_remove(monitor.background_window.current_timeout) 297 298monitor.background_window.time_per_image = int(adjustment.get_value()) 299 300monitor.background_window.change_image() 301 302self.get_application().write_config() 303 304def on_drop(self, drop_target: Gtk.DropTarget, value: Gdk.FileList, x, y, monitor: Gdk.Monitor): 305if hasattr(monitor, "background_window"): 306files = value.get_files() 307if not files: 308return False 309 310paths = [] 311for file in files: 312paths.append(file.get_path()) 313 314if monitor.background_window.current_timeout is not None: 315GLib.source_remove(monitor.background_window.current_timeout) 316 317monitor.background_window.put_sources(paths) 318 319monitor.background_window.change_image() 320 321self.get_application().write_config() 322 323return False 324 325 326if __name__ == "__main__": 327app = PanoramaBG() 328app.run(sys.argv) 329 330