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