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.23 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
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