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