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