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