Skip to content

Commit

Permalink
Merge pull request #126 from pozitronik/fix_thumbnail_loading
Browse files Browse the repository at this point in the history
ThumbnailWidget loads thumbnails in threads => faster, fixes tkinter …
  • Loading branch information
pozitronik authored Nov 1, 2024
2 parents 393ea09 + e68407b commit 88bdbe4
Show file tree
Hide file tree
Showing 3 changed files with 89 additions and 28 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: ci

on: [ push, pull_request ]
on: [ push ]

jobs:
not_supported_warn:
Expand Down
6 changes: 2 additions & 4 deletions sinner/gui/GUIForm.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from argparse import Namespace
from threading import Thread
from tkinter import filedialog, LEFT, Button, Frame, BOTH, StringVar, NW, X, Event, TOP, CENTER, Menu, CASCADE, COMMAND, RADIOBUTTON, CHECKBUTTON, SEPARATOR, BooleanVar, RIDGE, BOTTOM, NE
from tkinter.ttk import Spinbox, Label
from typing import List
Expand Down Expand Up @@ -412,11 +411,10 @@ def add_image(image_path: str) -> None:

for path in paths:
if is_image(path):
# Start a new thread for each image
Thread(target=add_image, args=(path,)).start()
add_image(path)
elif is_dir(path):
for dir_file in get_directory_file_list(path, is_image):
Thread(target=add_image, args=(dir_file,)).start()
add_image(dir_file)

def add_files(self) -> None:
image_extensions = get_type_extensions('image/')
Expand Down
109 changes: 86 additions & 23 deletions sinner/gui/controls/ThumbnailWidget.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import hashlib
import os
import tempfile
import threading
from concurrent.futures import ThreadPoolExecutor, Future
from multiprocessing import cpu_count
from tkinter import Canvas, Frame, Misc, NSEW, Scrollbar, Label, N, UNITS, ALL, Event, NW, LEFT, Y, BOTH
from typing import List, Tuple, Callable

Expand All @@ -24,6 +27,12 @@ def __init__(self, master: Misc, **kwargs): # type: ignore[no-untyped-def]
os.makedirs(self.temp_dir, exist_ok=True)
super().__init__(master, **kwargs)
self.thumbnails = []

self._executor = ThreadPoolExecutor(max_workers=cpu_count())
self._pending_futures: List[Future[Tuple[Image.Image, str, str | bool, Callable[[str], None] | None]]] = []
self._processing_lock = threading.Lock()
self._is_processing = False

self._canvas = Canvas(self)
self._canvas.pack(side=LEFT, expand=True, fill=BOTH)
# self._canvas.grid(row=0, column=0, sticky=NSEW)
Expand Down Expand Up @@ -85,33 +94,84 @@ def add_thumbnail(self, image_path: str, caption: str | bool = True, click_callb
:param click_callback: on thumbnail click callback
"""
if is_image(image_path):
img = self.get_cached_thumbnail(image_path)
if not img:
img = self.get_thumbnail(Image.open(image_path), self.thumbnail_size)
self.set_cached_thumbnail(image_path, img)
photo = PhotoImage(img)

thumbnail_label = Label(self.frame, image=photo)
thumbnail_label.image = photo # type: ignore[attr-defined]
thumbnail_label.grid()

# Create a label for the caption and set its width to match the thumbnail width
caption_label = Label(self.frame, wraplength=self.thumbnail_size)
if caption is not False:
if caption is True:
caption = get_file_name(image_path)
caption_label.configure(text=caption)
caption_label.grid(sticky=N)

if click_callback:
thumbnail_label.bind("<Button-1>", lambda event, path=image_path: click_callback(path)) # type: ignore[misc] #/mypy/issues/4226
caption_label.bind("<Button-1>", lambda event, path=image_path: click_callback(path)) # type: ignore[misc] #/mypy/issues/4226

self.thumbnails.append((thumbnail_label, caption_label, image_path))
# Подготавливаем параметры для обработки
params = (image_path, caption, click_callback)

# Создаём задачу для обработки изображения
future = self._executor.submit(self._prepare_thumbnail_data, *params)

with self._processing_lock:
self._pending_futures.append(future)
if not self._is_processing:
self._is_processing = True
self.after(100, self._process_pending)

def _prepare_thumbnail_data(self, image_path: str, caption: str | bool,
click_callback: Callable[[str], None] | None) -> Tuple[Image.Image, str, str | bool, Callable[[str], None] | None]:
"""
Prepare thumbnail data in background thread
"""
img = self.get_cached_thumbnail(image_path)
if not img:
img = self.get_thumbnail(Image.open(image_path), self.thumbnail_size)
self.set_cached_thumbnail(image_path, img)
return img, image_path, caption, click_callback

def _process_pending(self) -> None:
"""
Process completed thumbnail preparations and update GUI when all are done
"""
completed = []
ongoing = []

# Проверяем завершённые задачи
with self._processing_lock:
for future in self._pending_futures:
if future.done():
completed.append(future)
else:
ongoing.append(future)
self._pending_futures = ongoing

# Обрабатываем завершённые
for future in completed:
try:
img, image_path, caption, click_callback = future.result()
photo = PhotoImage(img)

thumbnail_label = Label(self.frame, image=photo)
thumbnail_label.image = photo # type: ignore[attr-defined]
thumbnail_label.grid()

# Create a label for the caption and set its width to match the thumbnail width
caption_label = Label(self.frame, wraplength=self.thumbnail_size)
if caption is not False:
if caption is True:
caption = get_file_name(image_path)
caption_label.configure(text=caption)
caption_label.grid(sticky=N)

if click_callback:
thumbnail_label.bind("<Button-1>", lambda event, path=image_path: click_callback(path)) # type: ignore[misc]
caption_label.bind("<Button-1>", lambda event, path=image_path: click_callback(path)) # type: ignore[misc]

self.thumbnails.append((thumbnail_label, caption_label, image_path))
except Exception as e:
print(f"Error processing thumbnail {image_path}: {e}")

# Если есть завершённые задачи, обновляем layout
if completed:
self.sort_thumbnails()
self.update()
self.master.update()

# Продолжаем обработку, если есть незавершённые задачи
with self._processing_lock:
if self._pending_futures:
self.after(100, self._process_pending)
else:
self._is_processing = False

def sort_thumbnails(self, asc: bool = True) -> None:
# Sort the thumbnails list by the image path
if asc:
Expand Down Expand Up @@ -163,3 +223,6 @@ def clear_thumbnails(self) -> None:
caption.grid_forget()
self.thumbnails = []
self._canvas.configure(scrollregion=self._canvas.bbox(ALL))
with self._processing_lock:
self._pending_futures.clear()
self._is_processing = False

0 comments on commit 88bdbe4

Please sign in to comment.