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.19 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
self.stack.set_transition_duration(self.transition_ms)
250
if self.time_per_image * 1000 < self.transition_ms:
251
self.time_per_image = self.transition_ms / 500
252
self.change_image()
253
254
def change_image(self):
255
now = GLib.DateTime.new_now_local()
256
# Ping pong between images is required for transition
257
if self.pic1:
258
self.picture1.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % len(self.all_sources)]))
259
self.stack.set_visible_child(self.picture1)
260
else:
261
self.picture2.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % len(self.all_sources)]))
262
self.stack.set_visible_child(self.picture2)
263
self.pic1 = not self.pic1
264
time_until_next = self.time_per_image - now.to_unix() % self.time_per_image
265
self.current_timeout = GLib.timeout_add_seconds(time_until_next, self.change_image)
266
return False
267
268
269
@Gtk.Template(filename="settings-window.ui")
270
class SettingsWindow(Gtk.Window):
271
__gtype_name__ = "SettingsWindow"
272
273
monitor_list: Gtk.ListBox = Gtk.Template.Child()
274
275
def __init__(self, application: Gtk.Application, **kwargs):
276
super().__init__(application=application, **kwargs)
277
278
monitors = application.display.get_monitors()
279
for monitor in monitors:
280
monitor_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
281
monitor_box.append(Gtk.Label.new(monitor.get_description()))
282
283
monitor_time_adjustment = Gtk.Adjustment(lower=1, upper=86400, value=monitor.background_window.time_per_image, step_increment=5)
284
monitor_seconds_spinbutton = Gtk.SpinButton(adjustment=monitor_time_adjustment, digits=0)
285
monitor_time_adjustment.connect("value-changed", self.display_time_changed, monitor)
286
monitor_box.append(monitor_seconds_spinbutton)
287
288
monitor_row = Gtk.ListBoxRow(activatable=False, child=monitor_box)
289
290
formats = Gdk.ContentFormats.new_for_gtype(Gdk.FileList)
291
drop_target = Gtk.DropTarget(formats=formats, actions=Gdk.DragAction.COPY)
292
drop_target.connect("drop", self.on_drop, monitor)
293
monitor_row.add_controller(drop_target)
294
295
self.monitor_list.append(monitor_row)
296
297
self.connect("close-request", lambda *args: self.destroy())
298
299
def display_time_changed(self, adjustment: Gtk.Adjustment, monitor: Gdk.Monitor):
300
if hasattr(monitor, "background_window"):
301
if monitor.background_window.current_timeout is not None:
302
GLib.source_remove(monitor.background_window.current_timeout)
303
304
monitor.background_window.time_per_image = int(adjustment.get_value())
305
306
monitor.background_window.change_image()
307
308
self.get_application().write_config()
309
310
def on_drop(self, drop_target: Gtk.DropTarget, value: Gdk.FileList, x, y, monitor: Gdk.Monitor):
311
if hasattr(monitor, "background_window"):
312
files = value.get_files()
313
if not files:
314
return False
315
316
paths = []
317
for file in files:
318
paths.append(file.get_path())
319
320
if monitor.background_window.current_timeout is not None:
321
GLib.source_remove(monitor.background_window.current_timeout)
322
323
monitor.background_window.put_sources(paths)
324
325
monitor.background_window.change_image()
326
327
self.get_application().write_config()
328
329
return False
330
331
332
if __name__ == "__main__":
333
app = PanoramaBG()
334
app.run(sys.argv)
335
336