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 81TRANSITION_MODES = { 82"fade": Gtk.StackTransitionType.CROSSFADE, 83"stack_down": Gtk.StackTransitionType.OVER_DOWN, 84"stack_up": Gtk.StackTransitionType.OVER_UP, 85"stack_left": Gtk.StackTransitionType.OVER_LEFT, 86"stack_right": Gtk.StackTransitionType.OVER_RIGHT, 87"take_down": Gtk.StackTransitionType.UNDER_DOWN, 88"take_up": Gtk.StackTransitionType.UNDER_UP, 89"take_left": Gtk.StackTransitionType.UNDER_LEFT, 90"take_right": Gtk.StackTransitionType.UNDER_RIGHT, 91"push_down": Gtk.StackTransitionType.SLIDE_DOWN, 92"push_up": Gtk.StackTransitionType.SLIDE_UP, 93"push_left": Gtk.StackTransitionType.SLIDE_LEFT, 94"push_right": Gtk.StackTransitionType.SLIDE_RIGHT, 95"cube_left": Gtk.StackTransitionType.ROTATE_LEFT, 96"cube_right": Gtk.StackTransitionType.ROTATE_RIGHT, 97} 98 99TRANSITION_MODES_REVERSE = { 100Gtk.StackTransitionType.CROSSFADE: "fade", 101Gtk.StackTransitionType.OVER_DOWN: "stack_down", 102Gtk.StackTransitionType.OVER_UP: "stack_up", 103Gtk.StackTransitionType.OVER_LEFT: "stack_left", 104Gtk.StackTransitionType.OVER_RIGHT: "stack_right", 105Gtk.StackTransitionType.UNDER_DOWN: "take_down", 106Gtk.StackTransitionType.UNDER_UP: "take_up", 107Gtk.StackTransitionType.UNDER_LEFT: "take_left", 108Gtk.StackTransitionType.UNDER_RIGHT: "take_right", 109Gtk.StackTransitionType.SLIDE_DOWN: "push_down", 110Gtk.StackTransitionType.SLIDE_UP: "push_up", 111Gtk.StackTransitionType.SLIDE_LEFT: "push_left", 112Gtk.StackTransitionType.SLIDE_RIGHT: "push_right", 113Gtk.StackTransitionType.ROTATE_LEFT: "cube_left", 114Gtk.StackTransitionType.ROTATE_RIGHT: "cube_right", 115} 116 117 118class PanoramaBG(Gtk.Application): 119def __init__(self): 120super().__init__( 121application_id="com.roundabout_host.roundabout.PanoramaBG", 122flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE 123) 124self.wl_output_ids: dict[int, WlOutput] = {} 125self.wl_display = None 126self.display = None 127self.settings_window = None 128 129self.add_main_option( 130"open-settings", 131ord("s"), 132GLib.OptionFlags.NONE, 133GLib.OptionArg.NONE, 134_("Open the settings window") 135) 136 137def make_backgrounds(self): 138for window in self.get_windows(): 139window.destroy() 140 141with open(get_config_file(), "r") as config_file: 142yaml_loader = yaml.YAML(typ="rt") 143yaml_file = yaml_loader.load(config_file) 144 145monitors = self.display.get_monitors() 146for monitor in monitors: 147background_window = BackgroundWindow(self, monitor) 148monitor.background_window = background_window 149if monitor.get_connector() in yaml_file["monitors"]: 150background_window.transition_ms = yaml_file["monitors"][monitor.get_connector()]["transition_ms"] 151background_window.time_per_image = yaml_file["monitors"][monitor.get_connector()]["time_per_image"] 152background_window.put_sources(yaml_file["monitors"][monitor.get_connector()]["sources"]) 153background_window.picture1.set_content_fit(FITTING_MODES[yaml_file["monitors"][monitor.get_connector()]["fitting_mode"]]) 154background_window.picture2.set_content_fit(FITTING_MODES[yaml_file["monitors"][monitor.get_connector()]["fitting_mode"]]) 155background_window.stack.set_transition_type(TRANSITION_MODES[yaml_file["monitors"][monitor.get_connector()]["transition_mode"]]) 156 157background_window.present() 158 159def do_startup(self): 160Gtk.Application.do_startup(self) 161self.hold() 162 163ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 164ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 165 166self.display = Gdk.Display.get_default() 167 168self.wl_display = Display() 169wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 170ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None))) 171self.wl_display._ptr = wl_display_ptr 172self.registry = self.wl_display.get_registry() 173self.registry.dispatcher["global"] = self.on_global 174self.registry.dispatcher["global_remove"] = self.on_global_remove 175self.wl_display.roundtrip() 176 177def receive_output_name(self, output: WlOutput, name: str): 178output.name = name 179 180def on_global(self, registry, name, interface, version): 181if interface == "wl_output": 182output = registry.bind(name, WlOutput, version) 183output.id = name 184output.name = None 185output.dispatcher["name"] = self.receive_output_name 186while not output.name: 187self.wl_display.dispatch(block=True) 188if not output.name.startswith("live-preview"): 189self.make_backgrounds() 190self.wl_output_ids[name] = output 191 192def on_global_remove(self, registry, name): 193if name in self.wl_output_ids: 194self.wl_output_ids[name].destroy() 195del self.wl_output_ids[name] 196 197def do_activate(self): 198Gio.Application.do_activate(self) 199 200def open_settings(self): 201if self.settings_window: 202self.settings_window.present() 203return 204 205self.settings_window = SettingsWindow(self) 206self.settings_window.connect("close-request", self.reset_settings_window) 207self.settings_window.present() 208 209def reset_settings_window(self, *args): 210self.settings_window = None 211 212def do_command_line(self, command_line: Gio.ApplicationCommandLine): 213options = command_line.get_options_dict() 214args = command_line.get_arguments()[1:] 215 216open_settings = options.contains("open-settings") 217if open_settings: 218self.open_settings() 219 220return 0 221 222def write_config(self): 223with open(get_config_file(), "w") as config_file: 224yaml_writer = yaml.YAML(typ="rt") 225data = {"monitors": {}} 226for window in self.get_windows(): 227if isinstance(window, BackgroundWindow): 228monitor = Gtk4LayerShell.get_monitor(window) 229data["monitors"][monitor.get_connector()] = { 230"sources": window.sources, 231"time_per_image": window.time_per_image, 232"fitting_mode": FITTING_MODES_REVERSE[window.picture1.get_content_fit()], 233"transition_ms": window.stack.get_transition_duration(), 234"transition_mode": TRANSITION_MODES_REVERSE[window.stack.get_transition_type()], 235} 236 237yaml_writer.dump(data, config_file) 238 239 240class BackgroundWindow(Gtk.Window): 241def __init__(self, application: Gtk.Application, monitor: Gdk.Monitor): 242super().__init__(application=application) 243self.set_decorated(False) 244 245self.all_sources = [] 246self.sources = [] 247self.time_per_image = 0 248self.transition_ms = 0 249self.current_timeout = None 250self.pic1 = True 251 252Gtk4LayerShell.init_for_window(self) 253Gtk4LayerShell.set_namespace(self, "com.roundabout_host.panorama.background") 254Gtk4LayerShell.set_monitor(self, monitor) 255Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.BACKGROUND) 256 257Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 258Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 259Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True) 260Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 261 262self.picture1 = Gtk.Picture() 263self.picture2 = Gtk.Picture() 264self.stack = Gtk.Stack() 265 266self.stack.add_child(self.picture1) 267self.stack.add_child(self.picture2) 268 269self.stack.set_visible_child(self.picture1) 270self.set_child(self.stack) 271 272 273def put_sources(self, sources): 274self.all_sources = [] 275self.sources = sources 276for source in sources: 277path = pathlib.Path(source) 278if path.is_dir(): 279self.all_sources.extend(f for f in path.iterdir() if f.is_file()) 280else: 281self.all_sources.append(path) 282self.all_sources = self.all_sources 283if self.time_per_image <= 0: 284self.time_per_image = 120 285if self.transition_ms <= 0: 286self.transition_ms = 1000 287if len(self.all_sources) <= 1: 288self.transition_ms = 0 289self.stack.set_transition_duration(self.transition_ms) 290if self.time_per_image * 1000 < self.transition_ms: 291self.time_per_image = self.transition_ms / 500 292self.change_image() 293 294def change_image(self): 295now = GLib.DateTime.new_now_local() 296num_images = len(self.all_sources) 297# Ping pong between images is required for transition 298if self.pic1: 299self.picture1.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % num_images])) 300self.stack.set_visible_child(self.picture1) 301else: 302self.picture2.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % num_images])) 303self.stack.set_visible_child(self.picture2) 304self.pic1 = not self.pic1 305time_until_next = self.time_per_image - now.to_unix() % self.time_per_image 306if num_images > 1: 307self.current_timeout = GLib.timeout_add_seconds(time_until_next, self.change_image) 308return False 309 310 311@Gtk.Template(filename="settings-window.ui") 312class SettingsWindow(Gtk.Window): 313__gtype_name__ = "SettingsWindow" 314 315monitor_list: Gtk.ListBox = Gtk.Template.Child() 316 317def __init__(self, application: Gtk.Application, **kwargs): 318super().__init__(application=application, **kwargs) 319 320monitors = application.display.get_monitors() 321for monitor in monitors: 322monitor_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) 323monitor_box.append(Gtk.Label.new(monitor.get_description())) 324 325monitor_time_adjustment = Gtk.Adjustment(lower=1, upper=86400, value=monitor.background_window.time_per_image, step_increment=5) 326monitor_seconds_spinbutton = Gtk.SpinButton(adjustment=monitor_time_adjustment, digits=0) 327monitor_time_adjustment.connect("value-changed", self.display_time_changed, monitor) 328monitor_box.append(monitor_seconds_spinbutton) 329 330monitor_row = Gtk.ListBoxRow(activatable=False, child=monitor_box) 331 332formats = Gdk.ContentFormats.new_for_gtype(Gdk.FileList) 333drop_target = Gtk.DropTarget(formats=formats, actions=Gdk.DragAction.COPY) 334drop_target.connect("drop", self.on_drop, monitor) 335monitor_row.add_controller(drop_target) 336 337self.monitor_list.append(monitor_row) 338 339self.connect("close-request", lambda *args: self.destroy()) 340 341def display_time_changed(self, adjustment: Gtk.Adjustment, monitor: Gdk.Monitor): 342if hasattr(monitor, "background_window"): 343if monitor.background_window.current_timeout is not None: 344GLib.source_remove(monitor.background_window.current_timeout) 345 346monitor.background_window.time_per_image = int(adjustment.get_value()) 347 348monitor.background_window.change_image() 349 350self.get_application().write_config() 351 352def on_drop(self, drop_target: Gtk.DropTarget, value: Gdk.FileList, x, y, monitor: Gdk.Monitor): 353if hasattr(monitor, "background_window"): 354files = value.get_files() 355if not files: 356return False 357 358paths = [] 359for file in files: 360paths.append(file.get_path()) 361 362if monitor.background_window.current_timeout is not None: 363GLib.source_remove(monitor.background_window.current_timeout) 364 365monitor.background_window.put_sources(paths) 366 367monitor.background_window.change_image() 368 369self.get_application().write_config() 370 371return False 372 373 374if __name__ == "__main__": 375app = PanoramaBG() 376app.run(sys.argv) 377 378