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