-
Notifications
You must be signed in to change notification settings - Fork 0
/
main.py
392 lines (344 loc) · 14.7 KB
/
main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
import ctypes
import io
import os
import re
import shutil
import subprocess
import sys
import threading
import time
import tkinter as tk
from configparser import ConfigParser
from tkinter import messagebox, filedialog
from typing import Sequence, Union
import pystray
import win32api
import win32clipboard
import win32evtlog
import win32evtlogutil
from PIL import Image, ImageFont, ImageDraw, UnidentifiedImageError
from database.db import Db
# Global variables
SPI_SETDESKWALLPAPER = 20
class PyWallpaper:
config = None
table_name = None
delay = None
error_delay = None
font = None
temp_image_filename = None
original_file_path = None
timer_id = None
def __init__(self):
self.load_config()
self.load_gui()
self.load_db()
def load_config(self):
c = ConfigParser()
if not os.path.isfile("config.ini"):
shutil.copy("config.ini.dist", "config.ini")
c.read("config.ini")
self.config = c
self.table_name = f'images_{c.get("Settings", "File list")}'
self.delay = int(self.parse_timestring(c.get("Settings", "Delay")) * 1000)
self.error_delay = int(self.parse_timestring(c.get("Settings", "Error delay")) * 1000)
font_name = c.get("Filepath", "Font name")
try:
self.font = ImageFont.truetype(
font_name,
c.getint("Filepath", "Font size")
)
except OSError:
print(f"Couldn't find font at '{font_name}'")
self.font = ImageFont.load_default()
self.temp_image_filename = os.path.join(
os.environ["TEMP"],
c.get("Advanced", "Temp image filename")
)
@staticmethod
def parse_timestring(timestring: Union[str, int, float]) -> float:
"""
Converts strings of 3m or 10s or 12m34s into number of seconds
"""
if isinstance(timestring, (int, float)):
return float(timestring)
m = re.match(r"((\d+)h)?((\d+)m)?((\d+)s)?", timestring)
seconds = 0.0
try:
seconds += int(m.group(2)) * 3600 # hours
except TypeError:
pass
try:
seconds += int(m.group(4)) * 60 # minutes
except TypeError:
pass
try:
seconds += int(m.group(6)) # seconds
except TypeError:
pass
return seconds
def load_gui(self):
self.root = tk.Tk()
self.root.title("pyWallpaper")
self.root.wm_minsize(width=200, height=100)
# Create a system tray icon
self.image = Image.open(self.config.get("Advanced", "Icon path"))
self.menu = (
pystray.MenuItem("Advance Image", self.advance_image, default=True),
pystray.MenuItem("Open Image File", self.open_image_file),
# pystray.MenuItem("Copy Image to Clipboard", self.copy_image_to_clipboard),
pystray.MenuItem("Go to Image File in Explorer", self.go_to_image_file),
pystray.MenuItem("Remove Image", self.remove_image_from_file_list),
pystray.MenuItem("Delete Image", self.delete_image),
pystray.MenuItem("", None),
pystray.MenuItem("Show Window", self.restore_from_tray),
pystray.MenuItem("Exit", self.on_exit)
)
self.icon = pystray.Icon("pywallpaper", self.image, "pyWallpaper", self.menu)
# Create GUI
self.add_files_button = tk.Button(self.root, text="Add Files to Wallpaper List", command=self.add_files_to_list)
self.add_files_button.pack()
self.add_folder_button = tk.Button(self.root, text="Add Folder to Wallpaper List", command=self.add_folder_to_list)
self.add_folder_button.pack(pady=10)
# self.show_button = tk.Button(self.root, text="Open Wallpaper List", command=self.show_file_list)
# self.show_button.pack()
self.add_filepath_to_images = tk.BooleanVar(
value=self.config.getboolean("Filepath", "Add filepath to images")
)
self.text_checkbox = tk.Checkbutton(
self.root,
text="Add Filepath to Images?",
variable=self.add_filepath_to_images,
onvalue=True,
offvalue=False
)
self.text_checkbox.pack(pady=10)
# Intercept window close event
self.root.protocol("WM_DELETE_WINDOW", self.minimize_to_tray)
# Hide main window to start
self.root.withdraw()
def load_db(self):
with Db(table=self.table_name) as db:
db.make_images_table()
# Loop functions
def run(self):
self.trigger_image_loop()
self.run_icon_loop()
self.root.mainloop()
def trigger_image_loop(self):
if self.timer_id:
self.root.after_cancel(self.timer_id)
with Db(table=self.table_name) as db:
count = db.get_all_active_count()
if not count:
print('No images have been loaded. Open the GUI and click the "Add Files to Wallpaper List" '
'button to get started')
self.timer_id = self.root.after(self.delay, self.trigger_image_loop)
return
t = threading.Thread(name="image_loop", target=self.set_new_wallpaper, daemon=True)
t.start()
def set_new_wallpaper(self):
with Db(table=self.table_name) as db:
t1 = time.perf_counter_ns()
self.original_file_path = db.get_random_image()
t2 = time.perf_counter_ns()
print(f"Time to get random image: {(t2 - t1) / 1000:,} us")
print(f"Loading {self.original_file_path}")
delay = self.error_delay
try:
file_path = self.make_image(self.original_file_path)
except (FileNotFoundError, UnidentifiedImageError):
print(f"Couldn't open image path {self.original_file_path!r}", file=sys.stderr)
except OSError:
print(f"Failed to process image file: {self.original_file_path!r}", file=sys.stderr)
else:
self.set_desktop_wallpaper(file_path)
delay = self.delay
self.timer_id = self.root.after(delay, self.trigger_image_loop)
# Spend the idle time after a wallpaper has been set to refresh ephemeral images
self.refresh_ephemeral_images()
def make_image(self, file_path: str) -> str:
# Open image
img = Image.open(file_path)
# Resize and apply to background
img = self.resize_image_to_bg(img)
# Add text
if self.add_filepath_to_images.get():
self.add_text_to_image(img, file_path)
# Write to temp file
ext = os.path.splitext(file_path)[1]
temp_file_path = self.temp_image_filename + ext
img.save(temp_file_path)
return temp_file_path
def resize_image_to_bg(self, img: Image):
# Determine aspect ratios
image_aspect_ratio = img.width / img.height
force_monitor_size = self.config.get("Settings", "Force monitor size")
if force_monitor_size:
monitor_width, monitor_height = [int(x) for x in force_monitor_size.split(", ")]
else:
monitor_width, monitor_height = win32api.GetSystemMetrics(0), win32api.GetSystemMetrics(1)
bg = Image.new("RGB", (monitor_width, monitor_height), "black")
bg_aspect_ratio = bg.width / bg.height
# Pick new image size
if image_aspect_ratio > bg_aspect_ratio:
new_img_size = (bg.width, round(bg.width / img.width * img.height))
else:
new_img_size = (round(bg.height / img.height * img.width), bg.height)
# Resize image to match bg
img = img.resize(new_img_size)
# Paste image on BG
paste_x = (bg.width - img.width) // 2
paste_y = (bg.height - img.height) // 2
bg.paste(img, (paste_x, paste_y))
return bg
def add_text_to_image(self, img: Image, text: str):
draw = ImageDraw.Draw(img)
text_x, text_y, text_width, text_height = draw.textbbox((0, 0), text, font=self.font)
text_x = img.width - text_width - 10 # 10 pixels padding from the right
text_y = img.height - text_height - 10 # 10 pixels padding from the bottom
draw.text(
(text_x, text_y),
text,
font=self.font,
fill=self.config.get("Filepath", "Text fill"),
stroke_width=self.config.getint("Filepath", "Stroke width"),
stroke_fill=self.config.get("Filepath", "Stroke fill")
)
def set_desktop_wallpaper(self, path: str) -> bool:
path = os.path.abspath(path)
# Windows doesn't return an error if we set the wallpaper to an invalid path, so do a check here first.
if not os.path.isfile(path):
self.create_windows_event_log(
"Couldn't find the file {}".format(path),
event_type=win32evtlog.EVENTLOG_ERROR_TYPE,
event_id=2
)
return False
self.create_windows_event_log("Setting wallpaper to {}".format(path))
ctypes.windll.user32.SystemParametersInfoW(SPI_SETDESKWALLPAPER, 0, path, 0)
return True
@staticmethod
def create_windows_event_log(message, event_type=win32evtlog.EVENTLOG_INFORMATION_TYPE, event_id=0):
win32evtlogutil.ReportEvent(
"Python Wallpaper Cycler",
event_id,
eventType=event_type,
strings=[message],
)
def run_icon_loop(self):
# self.root.after(1, self.icon.run)
threading.Thread(name="icon.run()", target=self.icon.run, daemon=True).start()
# GUI Functions
def add_files_to_list(self):
file_paths: Sequence[str] = filedialog.askopenfilenames(
title="Select Images",
filetypes=(
("Image Files", "*.gif;*.jpg;*.jpeg;*.png"),
("All Files", "*.*"),
)
)
# If we're adding images to the file list for the first time, pick a random image after load
with Db(table=self.table_name) as db:
advance_image_after_load = bool(not db.get_all_active_count())
db.add_images(file_paths)
if advance_image_after_load:
self.trigger_image_loop()
def add_folder_to_list(self):
dir_path = filedialog.askdirectory(
title="Select Image Folder",
)
include_subfolders = messagebox.askyesnocancel(
"Question",
f"You selected the folder {dir_path}\nDo you want to include subfolders?"
)
if include_subfolders is None:
return
with Db(table=self.table_name) as db:
# If we're adding images to the file list for the first time, pick a random image after load
advance_image_after_load = bool(not db.get_all_active_count())
db.add_directory(dir_path, include_subfolders)
file_paths = self.get_file_list_in_folder(dir_path, include_subfolders)
db.add_images(file_paths, ephemeral=True)
if advance_image_after_load:
self.trigger_image_loop()
@staticmethod
def get_file_list_in_folder(dir_path: str, include_subfolders: bool) -> Sequence[str]:
file_paths = []
for dirpath, dirnames, filenames in os.walk(dir_path):
# If we don't want to include subfolders, clearing the `dirnames` list will stop os.walk() at the
# top-level directory.
if not include_subfolders:
dirnames.clear()
for filename in filenames:
file_paths.append(os.path.join(dirpath, filename).replace("\\", "/"))
return file_paths
def refresh_ephemeral_images(self):
"""
Refresh images loaded as part of an included folder. We aren't removing old images because we want to keep
track of the per-image `active` flag, even for ephemeral images.
"""
t1 = time.perf_counter_ns()
with Db(self.table_name) as db:
folder_list = db.get_active_folders()
file_paths = []
for folder in folder_list:
file_paths += self.get_file_list_in_folder(
folder["filepath"],
folder["include_subdirectories"]
)
if file_paths:
db.add_images(file_paths, ephemeral=True)
t2 = time.perf_counter_ns()
print(f"Time to refresh ephemeral images: {(t2 - t1) / 1000:,} us")
def advance_image(self, _icon, _item):
self.trigger_image_loop()
def open_image_file(self, _icon, _item):
subprocess.run(["cmd", "/c", "start", "", os.path.abspath(self.original_file_path)])
def copy_image_to_clipboard(self, _icon, _item):
# encoded_path = urllib.parse.quote(self.original_file_path, safe="")
# file_reference = f"file:{encoded_path}"
# print(f"Copying {file_reference} to the clipboard")
#
# pyperclip.copy(file_reference)
img = Image.open(self.original_file_path)
output = io.BytesIO()
img.convert('RGB').save(output, 'BMP')
data = output.getvalue()[14:]
output.close()
win32clipboard.OpenClipboard()
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardData(win32clipboard.CF_HDROP, "\0")
win32clipboard.SetClipboardData(49159, os.path.abspath(self.original_file_path)) # FileNameW
win32clipboard.CloseClipboard()
def go_to_image_file(self, _icon, _item):
subprocess.Popen(["explorer", "/select,", os.path.abspath(self.original_file_path)])
def remove_image_from_file_list(self, _icon, _item):
with Db(table=self.table_name) as db:
db.set_image_to_inactive(self.original_file_path)
self.advance_image(_icon, _item)
def delete_image(self, _icon, _item):
path = self.original_file_path
result = messagebox.askokcancel("Delete image?", f"Are you sure you want to delete {path}")
if result:
ext = os.path.splitext(path)[1]
backup_path = self.config.get("Advanced", "Deleted image path") + ext
shutil.move(path, backup_path)
with Db(table=self.table_name) as db:
db.delete_image(path)
print(f"Moving {path} to {backup_path}")
notification = "{os.path.basename(path)} has been deleted."
if len(notification) > 64:
notification = "..." + notification[-61:]
self.icon.notify("Deleted wallpaper", notification)
self.advance_image(_icon, _item)
def minimize_to_tray(self):
self.root.withdraw() # Hide the main window
def restore_from_tray(self, _icon, _item):
self.root.deiconify() # Restore the main window
def on_exit(self, *_args):
self.icon.stop() # Remove the system tray icon
self.root.destroy()
if __name__ == "__main__":
app = PyWallpaper()
app.run()