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