main.py
    
    Python script, ASCII text executable
        
            1
            """ 
        
            2
            Panorama background: desktop wallpaper using Wayland layer-shell 
        
            3
            protocol. 
        
            4
            Copyright 2025, roundabout-host.com <vlad@roundabout-host.com> 
        
            5
             
        
            6
            This program is free software: you can redistribute it and/or modify 
        
            7
            it under the terms of the GNU General Public Licence as published by 
        
            8
            the Free Software Foundation, either version 3 of the Licence, or 
        
            9
            (at your option) any later version. 
        
            10
             
        
            11
            This program is distributed in the hope that it will be useful, 
        
            12
            but WITHOUT ANY WARRANTY; without even the implied warranty of 
        
            13
            MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 
        
            14
            GNU General Public Licence for more details. 
        
            15
             
        
            16
            You should have received a copy of the GNU General Public Licence 
        
            17
            along with this program. If not, see <https://www.gnu.org/licenses/>. 
        
            18
            """ 
        
            19
             
        
            20
            import os 
        
            21
            import faulthandler 
        
            22
            import pathlib 
        
            23
            import sys 
        
            24
            import locale 
        
            25
             
        
            26
            import ruamel.yaml as yaml 
        
            27
            from pywayland.client import Display, EventQueue 
        
            28
            from pywayland.protocol.wayland import WlOutput 
        
            29
            from pathlib import Path 
        
            30
             
        
            31
            locale.bindtextdomain("panorama-bg", "locale") 
        
            32
            locale.textdomain("panorama-bg") 
        
            33
            locale.setlocale(locale.LC_ALL) 
        
            34
            _ = locale.gettext 
        
            35
             
        
            36
            faulthandler.enable() 
        
            37
             
        
            38
            os.environ["GI_TYPELIB_PATH"] = "/usr/local/lib/x86_64-linux-gnu/girepository-1.0" 
        
            39
             
        
            40
            from ctypes import CDLL 
        
            41
            CDLL("libgtk4-layer-shell.so") 
        
            42
             
        
            43
            import gi 
        
            44
             
        
            45
            gi.require_version("Gtk", "4.0") 
        
            46
            gi.require_version("Gtk4LayerShell", "1.0") 
        
            47
             
        
            48
            from gi.repository import Gtk, GLib, Gtk4LayerShell, Gdk, Gio, GObject 
        
            49
             
        
            50
            import ctypes 
        
            51
            from cffi import FFI 
        
            52
            ffi = FFI() 
        
            53
            ffi.cdef(""" 
        
            54
            void * gdk_wayland_display_get_wl_display (void * display); 
        
            55
            void * gdk_wayland_surface_get_wl_surface (void * surface); 
        
            56
            void * gdk_wayland_monitor_get_wl_output (void * monitor); 
        
            57
            """) 
        
            58
            gtk = ffi.dlopen("libgtk-4.so.1") 
        
            59
             
        
            60
            module_directory = Path(__file__).resolve().parent 
        
            61
             
        
            62
             
        
            63
            def get_config_file(): 
        
            64
                config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) 
        
            65
             
        
            66
                return config_home / "panorama-bg" / "config.yaml" 
        
            67
             
        
            68
             
        
            69
            FITTING_MODES = { 
        
            70
                "cover": Gtk.ContentFit.COVER, 
        
            71
                "contain": Gtk.ContentFit.CONTAIN, 
        
            72
                "fill": Gtk.ContentFit.FILL, 
        
            73
                "scale_down": Gtk.ContentFit.SCALE_DOWN, 
        
            74
            } 
        
            75
             
        
            76
            FITTING_MODES_REVERSE = { 
        
            77
                Gtk.ContentFit.COVER: "cover", 
        
            78
                Gtk.ContentFit.CONTAIN: "contain", 
        
            79
                Gtk.ContentFit.FILL: "fill", 
        
            80
                Gtk.ContentFit.SCALE_DOWN: "scale_down", 
        
            81
            } 
        
            82
             
        
            83
            TRANSITION_MODES = { 
        
            84
                "fade": Gtk.StackTransitionType.CROSSFADE, 
        
            85
                "stack_down": Gtk.StackTransitionType.OVER_DOWN, 
        
            86
                "stack_up": Gtk.StackTransitionType.OVER_UP, 
        
            87
                "stack_left": Gtk.StackTransitionType.OVER_LEFT, 
        
            88
                "stack_right": Gtk.StackTransitionType.OVER_RIGHT, 
        
            89
                "take_down": Gtk.StackTransitionType.UNDER_DOWN, 
        
            90
                "take_up": Gtk.StackTransitionType.UNDER_UP, 
        
            91
                "take_left": Gtk.StackTransitionType.UNDER_LEFT, 
        
            92
                "take_right": Gtk.StackTransitionType.UNDER_RIGHT, 
        
            93
                "push_down": Gtk.StackTransitionType.SLIDE_DOWN, 
        
            94
                "push_up": Gtk.StackTransitionType.SLIDE_UP, 
        
            95
                "push_left": Gtk.StackTransitionType.SLIDE_LEFT, 
        
            96
                "push_right": Gtk.StackTransitionType.SLIDE_RIGHT, 
        
            97
                "cube_left": Gtk.StackTransitionType.ROTATE_LEFT, 
        
            98
                "cube_right": Gtk.StackTransitionType.ROTATE_RIGHT, 
        
            99
            } 
        
            100
             
        
            101
            TRANSITION_MODES_REVERSE = { 
        
            102
                Gtk.StackTransitionType.CROSSFADE: "fade", 
        
            103
                Gtk.StackTransitionType.OVER_DOWN: "stack_down", 
        
            104
                Gtk.StackTransitionType.OVER_UP: "stack_up", 
        
            105
                Gtk.StackTransitionType.OVER_LEFT: "stack_left", 
        
            106
                Gtk.StackTransitionType.OVER_RIGHT: "stack_right", 
        
            107
                Gtk.StackTransitionType.UNDER_DOWN: "take_down", 
        
            108
                Gtk.StackTransitionType.UNDER_UP: "take_up", 
        
            109
                Gtk.StackTransitionType.UNDER_LEFT: "take_left", 
        
            110
                Gtk.StackTransitionType.UNDER_RIGHT: "take_right", 
        
            111
                Gtk.StackTransitionType.SLIDE_DOWN: "push_down", 
        
            112
                Gtk.StackTransitionType.SLIDE_UP: "push_up", 
        
            113
                Gtk.StackTransitionType.SLIDE_LEFT: "push_left", 
        
            114
                Gtk.StackTransitionType.SLIDE_RIGHT: "push_right", 
        
            115
                Gtk.StackTransitionType.ROTATE_LEFT: "cube_left", 
        
            116
                Gtk.StackTransitionType.ROTATE_RIGHT: "cube_right", 
        
            117
            } 
        
            118
             
        
            119
             
        
            120
            class PanoramaBG(Gtk.Application): 
        
            121
                def __init__(self): 
        
            122
                    super().__init__( 
        
            123
                            application_id="com.roundabout_host.roundabout.PanoramaBG", 
        
            124
                            flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE 
        
            125
                    ) 
        
            126
                    self.wl_output_ids: dict[int, WlOutput] = {} 
        
            127
                    self.wl_display = None 
        
            128
                    self.display = None 
        
            129
                    self.settings_window = None 
        
            130
             
        
            131
                    self.add_main_option( 
        
            132
                        "open-settings", 
        
            133
                        ord("s"), 
        
            134
                        GLib.OptionFlags.NONE, 
        
            135
                        GLib.OptionArg.NONE, 
        
            136
                        _("Open the settings window") 
        
            137
                    ) 
        
            138
             
        
            139
                def make_backgrounds(self): 
        
            140
                    for window in self.get_windows(): 
        
            141
                        window.destroy() 
        
            142
             
        
            143
                    with open(get_config_file(), "r") as config_file: 
        
            144
                        yaml_loader = yaml.YAML(typ="rt") 
        
            145
                        yaml_file = yaml_loader.load(config_file) 
        
            146
             
        
            147
                        monitors = self.display.get_monitors() 
        
            148
                        for monitor in monitors: 
        
            149
                            background_window = BackgroundWindow(self, monitor) 
        
            150
                            monitor.background_window = background_window 
        
            151
                            if monitor.get_connector() in yaml_file["monitors"]: 
        
            152
                                background_window.transition_ms = yaml_file["monitors"][monitor.get_connector()]["transition_ms"] 
        
            153
                                background_window.time_per_image = yaml_file["monitors"][monitor.get_connector()]["time_per_image"] 
        
            154
                                background_window.put_sources(yaml_file["monitors"][monitor.get_connector()]["sources"]) 
        
            155
                                background_window.picture1.set_content_fit(FITTING_MODES[yaml_file["monitors"][monitor.get_connector()]["fitting_mode"]]) 
        
            156
                                background_window.picture2.set_content_fit(FITTING_MODES[yaml_file["monitors"][monitor.get_connector()]["fitting_mode"]]) 
        
            157
                                background_window.stack.set_transition_type(TRANSITION_MODES[yaml_file["monitors"][monitor.get_connector()]["transition_mode"]]) 
        
            158
             
        
            159
                            background_window.present() 
        
            160
             
        
            161
                def do_startup(self): 
        
            162
                    Gtk.Application.do_startup(self) 
        
            163
                    self.hold() 
        
            164
             
        
            165
                    ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 
        
            166
                    ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 
        
            167
             
        
            168
                    self.display = Gdk.Display.get_default() 
        
            169
             
        
            170
                    self.wl_display = Display() 
        
            171
                    wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 
        
            172
                            ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None))) 
        
            173
                    self.wl_display._ptr = wl_display_ptr 
        
            174
                    self.registry = self.wl_display.get_registry() 
        
            175
                    self.registry.dispatcher["global"] = self.on_global 
        
            176
                    self.registry.dispatcher["global_remove"] = self.on_global_remove 
        
            177
                    self.wl_display.roundtrip() 
        
            178
             
        
            179
                def receive_output_name(self, output: WlOutput, name: str): 
        
            180
                    output.name = name 
        
            181
             
        
            182
                def on_global(self, registry, name, interface, version): 
        
            183
                    if interface == "wl_output": 
        
            184
                        output = registry.bind(name, WlOutput, version) 
        
            185
                        output.id = name 
        
            186
                        output.name = None 
        
            187
                        output.dispatcher["name"] = self.receive_output_name 
        
            188
                        while not output.name: 
        
            189
                            self.wl_display.dispatch(block=True) 
        
            190
                        if not output.name.startswith("live-preview"): 
        
            191
                            self.make_backgrounds() 
        
            192
                            self.wl_output_ids[name] = output 
        
            193
             
        
            194
                def on_global_remove(self, registry, name): 
        
            195
                    if name in self.wl_output_ids: 
        
            196
                        self.wl_output_ids[name].destroy() 
        
            197
                        del self.wl_output_ids[name] 
        
            198
             
        
            199
                def do_activate(self): 
        
            200
                    Gio.Application.do_activate(self) 
        
            201
             
        
            202
                def open_settings(self): 
        
            203
                    if self.settings_window: 
        
            204
                        self.settings_window.present() 
        
            205
                        return 
        
            206
             
        
            207
                    self.settings_window = SettingsWindow(self) 
        
            208
                    self.settings_window.connect("close-request", self.reset_settings_window) 
        
            209
                    self.settings_window.present() 
        
            210
             
        
            211
                def reset_settings_window(self, *args): 
        
            212
                    self.settings_window = None 
        
            213
             
        
            214
                def do_command_line(self, command_line: Gio.ApplicationCommandLine): 
        
            215
                    options = command_line.get_options_dict() 
        
            216
                    args = command_line.get_arguments()[1:] 
        
            217
             
        
            218
                    open_settings = options.contains("open-settings") 
        
            219
                    if open_settings: 
        
            220
                        self.open_settings() 
        
            221
             
        
            222
                    return 0 
        
            223
             
        
            224
                def write_config(self): 
        
            225
                    with open(get_config_file(), "w") as config_file: 
        
            226
                        yaml_writer = yaml.YAML(typ="rt") 
        
            227
                        data = {"monitors": {}} 
        
            228
                        for window in self.get_windows(): 
        
            229
                            if isinstance(window, BackgroundWindow): 
        
            230
                                monitor = Gtk4LayerShell.get_monitor(window) 
        
            231
                                data["monitors"][monitor.get_connector()] = { 
        
            232
                                    "sources": window.sources, 
        
            233
                                    "time_per_image": window.time_per_image, 
        
            234
                                    "fitting_mode": FITTING_MODES_REVERSE[window.picture1.get_content_fit()], 
        
            235
                                    "transition_ms": window.stack.get_transition_duration(), 
        
            236
                                    "transition_mode": TRANSITION_MODES_REVERSE[window.stack.get_transition_type()], 
        
            237
                                } 
        
            238
             
        
            239
                        yaml_writer.dump(data, config_file) 
        
            240
             
        
            241
             
        
            242
            class BackgroundWindow(Gtk.Window): 
        
            243
                def __init__(self, application: Gtk.Application, monitor: Gdk.Monitor): 
        
            244
                    super().__init__(application=application) 
        
            245
                    self.set_decorated(False) 
        
            246
             
        
            247
                    self.all_sources = [] 
        
            248
                    self.sources = [] 
        
            249
                    self.time_per_image = 0 
        
            250
                    self.transition_ms = 0 
        
            251
                    self.current_timeout = None 
        
            252
                    self.pic1 = True 
        
            253
             
        
            254
                    Gtk4LayerShell.init_for_window(self) 
        
            255
                    Gtk4LayerShell.set_namespace(self, "com.roundabout_host.panorama.background") 
        
            256
                    Gtk4LayerShell.set_monitor(self, monitor) 
        
            257
                    Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.BACKGROUND) 
        
            258
             
        
            259
                    Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True) 
        
            260
                    Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True) 
        
            261
                    Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True) 
        
            262
                    Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True) 
        
            263
             
        
            264
                    self.picture1 = Gtk.Picture() 
        
            265
                    self.picture2 = Gtk.Picture() 
        
            266
                    self.stack = Gtk.Stack() 
        
            267
             
        
            268
                    self.stack.add_child(self.picture1) 
        
            269
                    self.stack.add_child(self.picture2) 
        
            270
             
        
            271
                    self.stack.set_visible_child(self.picture1) 
        
            272
                    self.set_child(self.stack) 
        
            273
             
        
            274
             
        
            275
                def put_sources(self, sources): 
        
            276
                    self.all_sources = [] 
        
            277
                    self.sources = sources 
        
            278
                    for source in sources: 
        
            279
                        path = pathlib.Path(source) 
        
            280
                        if path.is_dir(): 
        
            281
                            self.all_sources.extend(f for f in path.iterdir() if f.is_file()) 
        
            282
                        else: 
        
            283
                            self.all_sources.append(path) 
        
            284
                    self.all_sources = self.all_sources 
        
            285
                    if self.time_per_image <= 0: 
        
            286
                        self.time_per_image = 120 
        
            287
                    if self.transition_ms <= 0: 
        
            288
                        self.transition_ms = 1000 
        
            289
                    if len(self.all_sources) <= 1: 
        
            290
                        self.transition_ms = 0 
        
            291
                    self.stack.set_transition_duration(self.transition_ms) 
        
            292
                    if self.time_per_image * 1000 < self.transition_ms: 
        
            293
                        self.time_per_image = self.transition_ms / 500 
        
            294
                    self.change_image() 
        
            295
             
        
            296
                def change_image(self): 
        
            297
                    now = GLib.DateTime.new_now_local() 
        
            298
                    num_images = len(self.all_sources) 
        
            299
                    # Ping pong between images is required for transition 
        
            300
                    if self.pic1: 
        
            301
                        self.picture1.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % num_images])) 
        
            302
                        self.stack.set_visible_child(self.picture1) 
        
            303
                    else: 
        
            304
                        self.picture2.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % num_images])) 
        
            305
                        self.stack.set_visible_child(self.picture2) 
        
            306
                    self.pic1 = not self.pic1 
        
            307
                    time_until_next = self.time_per_image - now.to_unix() % self.time_per_image 
        
            308
                    if num_images > 1: 
        
            309
                        self.current_timeout = GLib.timeout_add_seconds(time_until_next, self.change_image) 
        
            310
                    return False 
        
            311
             
        
            312
             
        
            313
            @Gtk.Template(filename=str(module_directory / "settings-window.ui")) 
        
            314
            class SettingsWindow(Gtk.Window): 
        
            315
                __gtype_name__ = "SettingsWindow" 
        
            316
             
        
            317
                monitor_list: Gtk.ListBox = Gtk.Template.Child() 
        
            318
             
        
            319
                def __init__(self, application: Gtk.Application, **kwargs): 
        
            320
                    super().__init__(application=application, **kwargs) 
        
            321
             
        
            322
                    monitors = application.display.get_monitors() 
        
            323
                    for monitor in monitors: 
        
            324
                        monitor_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) 
        
            325
                        monitor_box.append(Gtk.Label.new(monitor.get_description())) 
        
            326
             
        
            327
                        monitor_time_adjustment = Gtk.Adjustment(lower=1, upper=86400, value=monitor.background_window.time_per_image, step_increment=5) 
        
            328
                        monitor_seconds_spinbutton = Gtk.SpinButton(adjustment=monitor_time_adjustment, digits=0) 
        
            329
                        monitor_time_adjustment.connect("value-changed", self.display_time_changed, monitor) 
        
            330
                        monitor_box.append(monitor_seconds_spinbutton) 
        
            331
             
        
            332
                        monitor_row = Gtk.ListBoxRow(activatable=False, child=monitor_box) 
        
            333
             
        
            334
                        formats = Gdk.ContentFormats.new_for_gtype(Gdk.FileList) 
        
            335
                        drop_target = Gtk.DropTarget(formats=formats, actions=Gdk.DragAction.COPY) 
        
            336
                        drop_target.connect("drop", self.on_drop, monitor) 
        
            337
                        monitor_row.add_controller(drop_target) 
        
            338
             
        
            339
                        self.monitor_list.append(monitor_row) 
        
            340
             
        
            341
                    self.connect("close-request", lambda *args: self.destroy()) 
        
            342
             
        
            343
                def display_time_changed(self, adjustment: Gtk.Adjustment, monitor: Gdk.Monitor): 
        
            344
                    if hasattr(monitor, "background_window"): 
        
            345
                        if monitor.background_window.current_timeout is not None: 
        
            346
                            GLib.source_remove(monitor.background_window.current_timeout) 
        
            347
             
        
            348
                        monitor.background_window.time_per_image = int(adjustment.get_value()) 
        
            349
             
        
            350
                        monitor.background_window.change_image() 
        
            351
             
        
            352
                    self.get_application().write_config() 
        
            353
             
        
            354
                def on_drop(self, drop_target: Gtk.DropTarget, value: Gdk.FileList, x, y, monitor: Gdk.Monitor): 
        
            355
                    if hasattr(monitor, "background_window"): 
        
            356
                        files = value.get_files() 
        
            357
                        if not files: 
        
            358
                            return False 
        
            359
             
        
            360
                        paths = [] 
        
            361
                        for file in files: 
        
            362
                            paths.append(file.get_path()) 
        
            363
             
        
            364
                        if monitor.background_window.current_timeout is not None: 
        
            365
                            GLib.source_remove(monitor.background_window.current_timeout) 
        
            366
             
        
            367
                        monitor.background_window.put_sources(paths) 
        
            368
             
        
            369
                        monitor.background_window.change_image() 
        
            370
             
        
            371
                    self.get_application().write_config() 
        
            372
             
        
            373
                    return False 
        
            374
             
        
            375
             
        
            376
            if __name__ == "__main__": 
        
            377
                app = PanoramaBG() 
        
            378
                app.run(sys.argv) 
        
            379
             
        
            380