By using this site, you agree to have cookies stored on your device, strictly for functional purposes, such as storing your session and preferences.

Dismiss

 main.py

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