A bunch of changes

This commit is contained in:
Ignacio Serantes
2026-03-23 22:50:02 +01:00
parent 547bfbf760
commit 291f2f9e47
8 changed files with 401 additions and 250 deletions

View File

@@ -1,13 +1,14 @@
v0.9.11 - v0.9.11 -
· Filmstrip fixed · Filmstrip fixed
· Añadida una nueva área llamada Body.
· Refactorizaciones, optimizaciones y cambios a saco.
HAVE_BAGHEERASEARCH_LIB
Refactor `load_image` to check if `pixmap_original` is already valid before reloading to optimize performance.
Check if the `ImagePreloader` handles file deletion correctly if the file is deleted while being preloaded.
Me gustaría implementar un modo de "Comparación" para ver 2 o 4 imágenes lado a lado en el visor. ¿Cómo podría abordarlo? Refactor the `ImageScanner` to use a thread pool for parallel thumbnail generation for faster loading.
Implement a "Comparison" mode to view 2 or 4 images side-by-side in the viewer.
· La instalación no debe usar Bagheera como motor a no ser que esté instalado. · La instalación no debe usar Bagheera como motor a no ser que esté instalado.
· Hacer que el image viewer standalone admita múltiples sort · Hacer que el image viewer standalone admita múltiples sort

View File

@@ -167,6 +167,13 @@ if importlib.util.find_spec("mediapipe") is not None:
pass pass
HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None
HAVE_BAGHEERASEARCH_LIB = False
try:
import bagheera_search_lib
HAVE_BAGHEERASEARCH_LIB = True
except ImportError:
pass
MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR, MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR,
"blaze_face_short_range.tflite") "blaze_face_short_range.tflite")
MEDIAPIPE_FACE_MODEL_URL = ( MEDIAPIPE_FACE_MODEL_URL = (

View File

@@ -11,6 +11,7 @@ Classes:
interacts with the ImagePreloader. interacts with the ImagePreloader.
""" """
import os import os
import logging
import math import math
from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt
from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform
@@ -22,6 +23,8 @@ from constants import (
) )
from metadatamanager import XattrManager, load_common_metadata from metadatamanager import XattrManager, load_common_metadata
logger = logging.getLogger(__name__)
class ImagePreloader(QThread): class ImagePreloader(QThread):
""" """
@@ -111,8 +114,8 @@ class ImagePreloader(QThread):
# Load tags and rating here to avoid re-reading in main thread # Load tags and rating here to avoid re-reading in main thread
tags, rating = load_common_metadata(path) tags, rating = load_common_metadata(path)
self.image_ready.emit(idx, path, img, tags, rating) self.image_ready.emit(idx, path, img, tags, rating)
except Exception: except Exception as e:
pass logger.warning(f"ImagePreloader failed to load {path}: {e}")
class ImageController(QObject): class ImageController(QObject):
@@ -156,6 +159,12 @@ class ImageController(QObject):
def cleanup(self): def cleanup(self):
"""Stops the background preloader thread.""" """Stops the background preloader thread."""
self.preloader.stop() self.preloader.stop()
self._current_metadata_path = None
self._loaded_path = None
self._current_tags = []
self._current_rating = 0
self._cached_next_image = None
self._cached_next_index = -1
def _trigger_preload(self): def _trigger_preload(self):
"""Identifies the next image in the list and asks the preloader to load it.""" """Identifies the next image in the list and asks the preloader to load it."""
@@ -209,19 +218,13 @@ class ImageController(QObject):
# Optimization: Check if image is already loaded # Optimization: Check if image is already loaded
if path and self._loaded_path == path and not self.pixmap_original.isNull(): if path and self._loaded_path == path and not self.pixmap_original.isNull():
self.rotation = 0
self.flip_h = False
self.flip_v = False
self.faces = []
# Ensure metadata is consistent with current path # Ensure metadata is consistent with current path
if self._current_metadata_path != path: if self._current_metadata_path != path:
self._current_tags, self._current_rating = load_common_metadata(path) self._current_tags, self._current_rating = load_common_metadata(path)
self._current_metadata_path = path self._current_metadata_path = path
self.load_faces()
self._trigger_preload() self._trigger_preload()
return True return True, False
self.pixmap_original = QPixmap() self.pixmap_original = QPixmap()
self._loaded_path = None self._loaded_path = None
@@ -231,7 +234,7 @@ class ImageController(QObject):
self.faces = [] self.faces = []
if not path: if not path:
return False return False, False
# Check cache # Check cache
if self.index == self._cached_next_index and self._cached_next_image: if self.index == self._cached_next_index and self._cached_next_image:
@@ -250,7 +253,7 @@ class ImageController(QObject):
image = reader.read() image = reader.read()
if image.isNull(): if image.isNull():
self._trigger_preload() self._trigger_preload()
return False return False, False
self.pixmap_original = QPixmap.fromImage(image) self.pixmap_original = QPixmap.fromImage(image)
# Load tags and rating if not already set for this path # Load tags and rating if not already set for this path
@@ -261,7 +264,7 @@ class ImageController(QObject):
self._loaded_path = path self._loaded_path = path
self.load_faces() self.load_faces()
self._trigger_preload() self._trigger_preload()
return True return True, True
def load_faces(self): def load_faces(self):
""" """

View File

@@ -36,18 +36,14 @@ from PySide6.QtGui import QImage, QImageReader, QImageIOHandler
from constants import ( from constants import (
APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES, APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES,
IMAGE_EXTENSIONS, SEARCH_CMD, THUMBNAIL_SIZES, RATING_XATTR_NAME, XATTR_NAME, IMAGE_EXTENSIONS, SEARCH_CMD, THUMBNAIL_SIZES, RATING_XATTR_NAME, XATTR_NAME,
UITexts, SCANNER_SETTINGS_DEFAULTS UITexts, SCANNER_SETTINGS_DEFAULTS, HAVE_BAGHEERASEARCH_LIB
) )
from imageviewer import ImageViewer from imageviewer import ImageViewer
from metadatamanager import XattrManager from metadatamanager import XattrManager
try: if HAVE_BAGHEERASEARCH_LIB:
# Attempt to import bagheerasearch for direct integration
from bagheera_search_lib import BagheeraSearcher from bagheera_search_lib import BagheeraSearcher
HAVE_BAGHEERASEARCH_LIB = True
except ImportError:
HAVE_BAGHEERASEARCH_LIB = False
# Set up logging for better debugging # Set up logging for better debugging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -130,6 +126,10 @@ class CacheWriter(QThread):
if not self._running: if not self._running:
return return
# Ensure we don't accept new items if stopping, especially when block=False
if not self._running:
return
# --- Soft Cleaning: Deduplication --- # --- Soft Cleaning: Deduplication ---
# Remove redundant pending updates for the same image/size (e.g. # Remove redundant pending updates for the same image/size (e.g.
# rapid rotations) # rapid rotations)
@@ -154,7 +154,7 @@ class CacheWriter(QThread):
def stop(self): def stop(self):
self._mutex.lock() self._mutex.lock()
self._running = False self._running = False
self._queue.clear() # Do not clear the queue here; let the run loop drain it to prevent data loss.
self._condition_new_data.wakeAll() self._condition_new_data.wakeAll()
self._condition_space_available.wakeAll() self._condition_space_available.wakeAll()
self._mutex.unlock() self._mutex.unlock()

View File

@@ -24,7 +24,7 @@ from PySide6.QtGui import (
) )
from PySide6.QtCore import ( from PySide6.QtCore import (
Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF, Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF,
QThread QThread, QObject
) )
from constants import ( from constants import (
@@ -960,13 +960,203 @@ class FaceCanvas(QLabel):
# The event position is already local to the canvas # The event position is already local to the canvas
clicked_face = self.viewer._get_clicked_face(event.position().toPoint()) clicked_face = self.viewer._get_clicked_face(event.position().toPoint())
if clicked_face: if clicked_face:
self.viewer.zoom_to_rect(clicked_face) self.viewer.zoom_manager.zoom_to_rect(clicked_face)
event.accept() event.accept()
return return
# If no face was double-clicked, pass the event on # If no face was double-clicked, pass the event on
super().mouseDoubleClickEvent(event) super().mouseDoubleClickEvent(event)
class SlideshowManager(QObject):
"""
Manages the slideshow functionality for the ImageViewer.
Separates the timing logic from the UI logic.
"""
def __init__(self, viewer):
super().__init__(viewer)
self.viewer = viewer
self.timer = QTimer(self)
self.timer.setInterval(3000)
self.timer.timeout.connect(self._on_timeout)
self._reverse = False
def _on_timeout(self):
"""Called when the timer fires to advance the slideshow."""
if self._reverse:
self.viewer.prev_image()
else:
self.viewer.next_image()
def start(self, reverse=False):
"""Starts the slideshow in the specified direction."""
self._reverse = reverse
self.timer.start()
def stop(self):
"""Stops the slideshow."""
self.timer.stop()
def toggle(self, reverse=False):
"""Toggles the slideshow on/off or changes direction."""
if self.timer.isActive():
if self._reverse == reverse:
self.stop()
else:
self.start(reverse)
else:
self.start(reverse)
def is_running(self):
"""Returns whether the slideshow is currently active."""
return self.timer.isActive()
def is_forward(self):
"""Returns whether the slideshow is running in forward mode."""
return self.timer.isActive() and not self._reverse
def is_reverse(self):
"""Returns whether the slideshow is running in reverse mode."""
return self.timer.isActive() and self._reverse
def set_interval(self, ms):
"""Sets the interval in milliseconds."""
self.timer.setInterval(ms)
if self.timer.isActive():
self.timer.start()
def get_interval(self):
"""Returns the current interval in milliseconds."""
return self.timer.interval()
class ZoomManager(QObject):
"""
Manages zoom calculations and state for the ImageViewer.
"""
def __init__(self, viewer):
super().__init__(viewer)
self.viewer = viewer
def zoom(self, factor, reset=False):
"""Applies zoom to the image."""
if reset:
self.viewer.controller.zoom_factor = 1.0
self.viewer.update_view(resize_win=True)
else:
self.viewer.controller.zoom_factor *= factor
self.viewer.update_view(resize_win=True)
# Notify the main window that the image (and possibly index) has changed
# so it can update its selection.
self.viewer.index_changed.emit(self.viewer.controller.index)
self.viewer.sync_filmstrip_selection(self.viewer.controller.index)
def toggle_fit_to_screen(self):
"""
Toggles between fitting the image to the window and 100% actual size.
"""
# If close to 100%, fit to window. Otherwise 100%.
if abs(self.viewer.controller.zoom_factor - 1.0) < 0.01:
self.fit_to_window()
else:
self.zoom(1.0, reset=True)
def fit_to_window(self):
"""
Calculates the zoom factor required to make the image fit perfectly
within the current viewport dimensions.
"""
if self.viewer.controller.pixmap_original.isNull():
return
viewport = self.viewer.scroll_area.viewport()
w_avail = viewport.width()
h_avail = viewport.height()
transform = QTransform().rotate(self.viewer.controller.rotation)
transformed_pixmap = self.viewer.controller.pixmap_original.transformed(
transform, Qt.SmoothTransformation)
img_w = transformed_pixmap.width()
img_h = transformed_pixmap.height()
if img_w == 0 or img_h == 0:
return
self.viewer.controller.zoom_factor = min(w_avail / img_w, h_avail / img_h)
self.viewer.update_view(resize_win=False)
def calculate_initial_zoom(self, available_w, available_h, is_fullscreen):
"""Calculates and sets the initial zoom factor when loading an image."""
orig_w = self.viewer.controller.pixmap_original.width()
orig_h = self.viewer.controller.pixmap_original.height()
if orig_w > 0 and orig_h > 0:
factor = min(available_w / orig_w, available_h / orig_h)
if is_fullscreen:
self.viewer.controller.zoom_factor = factor
else:
self.viewer.controller.zoom_factor = min(1.0, factor)
else:
self.viewer.controller.zoom_factor = 1.0
def zoom_to_rect(self, face_rect):
"""Zooms and pans the view to center on a given normalized rectangle."""
if self.viewer.controller.pixmap_original.isNull():
return
viewport = self.viewer.scroll_area.viewport()
vp_w = viewport.width()
vp_h = viewport.height()
# Use the original pixmap dimensions for zoom calculation
transform = QTransform().rotate(self.viewer.controller.rotation)
transformed_pixmap = self.viewer.controller.pixmap_original.transformed(
transform, Qt.SmoothTransformation)
img_w = transformed_pixmap.width()
img_h = transformed_pixmap.height()
if img_w == 0 or img_h == 0:
return
# Calculate the size of the face in original image pixels
face_pixel_w = face_rect['w'] * img_w
face_pixel_h = face_rect['h'] * img_h
if face_pixel_w == 0 or face_pixel_h == 0:
return
# Calculate zoom factor to make the face fill ~70% of the viewport
zoom_w = (vp_w * 0.7) / face_pixel_w
zoom_h = (vp_h * 0.7) / face_pixel_h
new_zoom = min(zoom_w, zoom_h)
self.viewer.controller.zoom_factor = new_zoom
self.viewer.update_view(resize_win=False)
# Defer centering until after the view has been updated
QTimer.singleShot(0, lambda: self._center_on_face(face_rect))
def _center_on_face(self, face_rect):
"""Scrolls the viewport to center on the face."""
canvas_w = self.viewer.canvas.width()
canvas_h = self.viewer.canvas.height()
viewport = self.viewer.scroll_area.viewport()
vp_w = viewport.width()
vp_h = viewport.height()
# Face center in the newly zoomed canvas coordinates
face_center_x_px = face_rect['x'] * canvas_w
face_center_y_px = face_rect['y'] * canvas_h
# Calculate the target scrollbar value to center the point
scroll_x = face_center_x_px - (vp_w / 2)
scroll_y = face_center_y_px - (vp_h / 2)
self.viewer.scroll_area.horizontalScrollBar().setValue(int(scroll_x))
self.viewer.scroll_area.verticalScrollBar().setValue(int(scroll_y))
class ImageViewer(QWidget): class ImageViewer(QWidget):
""" """
A standalone window for viewing and manipulating a single image. A standalone window for viewing and manipulating a single image.
@@ -1137,14 +1327,8 @@ class ImageViewer(QWidget):
self.hide_controls_timer.timeout.connect(self.hide_controls) self.hide_controls_timer.timeout.connect(self.hide_controls)
# Slideshow # Slideshow
self.slideshow_timer = QTimer(self) self.slideshow_manager = SlideshowManager(self)
self.slideshow_timer.setInterval(3000) self.zoom_manager = ZoomManager(self)
self.slideshow_timer.timeout.connect(self.next_image)
# Slideshow
self.slideshow_reverse_timer = QTimer(self)
self.slideshow_reverse_timer.setInterval(3000)
self.slideshow_reverse_timer.timeout.connect(self.prev_image)
# Load image # Load image
if restore_config: if restore_config:
@@ -1214,9 +1398,9 @@ class ImageViewer(QWidget):
"fast_tag": self.show_fast_tag_menu, "fast_tag": self.show_fast_tag_menu,
"rotate_right": lambda: self.apply_rotation(90, True), "rotate_right": lambda: self.apply_rotation(90, True),
"rotate_left": lambda: self.apply_rotation(-90, True), "rotate_left": lambda: self.apply_rotation(-90, True),
"zoom_in": lambda: self.zoom(1.1), "zoom_in": lambda: self.zoom_manager.zoom(1.1),
"zoom_out": lambda: self.zoom(0.9), "zoom_out": lambda: self.zoom_manager.zoom(0.9),
"reset_zoom": lambda: self.zoom(1.0, reset=True), "reset_zoom": lambda: self.zoom_manager.zoom(1.0, reset=True),
"toggle_animation": self.toggle_animation_pause, "toggle_animation": self.toggle_animation_pause,
"properties": self.show_properties, "properties": self.show_properties,
"toggle_visibility": self.toggle_main_window_visibility, "toggle_visibility": self.toggle_main_window_visibility,
@@ -1447,18 +1631,23 @@ class ImageViewer(QWidget):
Args: Args:
restore_config (dict, optional): State dictionary to restore from. restore_config (dict, optional): State dictionary to restore from.
""" """
success, reloaded = self.controller.load_image()
if not success:
if self.movie: if self.movie:
self.movie.stop() self.movie.stop()
self.movie = None self.movie = None
if not self.controller.load_image():
self.canvas.setPixmap(QPixmap()) self.canvas.setPixmap(QPixmap())
self.update_status_bar() self.update_status_bar()
return return
path = self.controller.get_current_path() path = self.controller.get_current_path()
self.canvas.crop_rect = QRect() # Clear crop rect on new image
if reloaded:
if self.movie:
self.movie.stop()
self.movie = None
self.canvas.crop_rect = QRect() # Clear crop rect on new image
if path: if path:
reader = QImageReader(path) reader = QImageReader(path)
if reader.supportsAnimation() and reader.imageCount() > 1: if reader.supportsAnimation() and reader.imageCount() > 1:
@@ -1481,7 +1670,7 @@ class ImageViewer(QWidget):
self.populate_filmstrip() self.populate_filmstrip()
self.update_view(resize_win=False) self.update_view(resize_win=False)
QTimer.singleShot(0, lambda: self.restore_scroll(restore_config)) QTimer.singleShot(0, lambda: self.restore_scroll(restore_config))
else: elif reloaded:
# Calculate zoom to fit the image on the screen # Calculate zoom to fit the image on the screen
if self.isFullScreen(): if self.isFullScreen():
viewport = self.scroll_area.viewport() viewport = self.scroll_area.viewport()
@@ -1521,19 +1710,14 @@ class ImageViewer(QWidget):
available_h -= self.status_bar_container.sizeHint().height() available_h -= self.status_bar_container.sizeHint().height()
should_resize = True should_resize = True
orig_w = self.controller.pixmap_original.width() self.zoom_manager.calculate_initial_zoom(available_w, available_h,
orig_h = self.controller.pixmap_original.height() self.isFullScreen())
if orig_w > 0 and orig_h > 0:
factor = min(available_w / orig_w, available_h / orig_h)
if self.isFullScreen():
self.controller.zoom_factor = factor
else:
self.controller.zoom_factor = min(1.0, factor)
else:
self.controller.zoom_factor = 1.0
self.update_view(resize_win=should_resize) self.update_view(resize_win=should_resize)
else:
# Image was reused and no restore config; just refresh the view to ensure
# metadata/faces are up to date without resetting zoom/pan.
self.update_view(resize_win=False)
# Defer sync to ensure layout and scroll area are ready, fixing navigation sync # Defer sync to ensure layout and scroll area are ready, fixing navigation sync
QTimer.singleShot( QTimer.singleShot(
@@ -1608,78 +1792,6 @@ class ImageViewer(QWidget):
self.movie.setPaused(not is_paused) self.movie.setPaused(not is_paused)
self.update_title() self.update_title()
def zoom(self, factor, reset=False):
"""Applies zoom to the image."""
if reset:
self.controller.zoom_factor = 1.0
self.update_view(resize_win=True)
else:
self.controller.zoom_factor *= factor
self.update_view(resize_win=True)
# Notify the main window that the image (and possibly index) has changed
# so it can update its selection.
self.index_changed.emit(self.controller.index)
self.sync_filmstrip_selection(self.controller.index)
def zoom_to_rect(self, face_rect):
"""Zooms and pans the view to center on a given normalized rectangle."""
if self.controller.pixmap_original.isNull():
return
viewport = self.scroll_area.viewport()
vp_w = viewport.width()
vp_h = viewport.height()
# Use the original pixmap dimensions for zoom calculation
transform = QTransform().rotate(self.controller.rotation)
transformed_pixmap = self.controller.pixmap_original.transformed(
transform, Qt.SmoothTransformation)
img_w = transformed_pixmap.width()
img_h = transformed_pixmap.height()
if img_w == 0 or img_h == 0:
return
# Calculate the size of the face in original image pixels
face_pixel_w = face_rect['w'] * img_w
face_pixel_h = face_rect['h'] * img_h
if face_pixel_w == 0 or face_pixel_h == 0:
return
# Calculate zoom factor to make the face fill ~70% of the viewport
zoom_w = (vp_w * 0.7) / face_pixel_w
zoom_h = (vp_h * 0.7) / face_pixel_h
new_zoom = min(zoom_w, zoom_h)
self.controller.zoom_factor = new_zoom
self.update_view(resize_win=False)
# Defer centering until after the view has been updated
QTimer.singleShot(0, lambda: self._center_on_face(face_rect))
def _center_on_face(self, face_rect):
"""Scrolls the viewport to center on the face."""
canvas_w = self.canvas.width()
canvas_h = self.canvas.height()
viewport = self.scroll_area.viewport()
vp_w = viewport.width()
vp_h = viewport.height()
# Face center in the newly zoomed canvas coordinates
face_center_x_px = face_rect['x'] * canvas_w
face_center_y_px = face_rect['y'] * canvas_h
# Calculate the target scrollbar value to center the point
scroll_x = face_center_x_px - (vp_w / 2)
scroll_y = face_center_y_px - (vp_h / 2)
self.scroll_area.horizontalScrollBar().setValue(int(scroll_x))
self.scroll_area.verticalScrollBar().setValue(int(scroll_y))
def apply_rotation(self, rotation, resize_win=False): def apply_rotation(self, rotation, resize_win=False):
""" """
Applies a rotation to the current image. Applies a rotation to the current image.
@@ -1878,7 +1990,7 @@ class ImageViewer(QWidget):
"""Updates the window title with the current image name.""" """Updates the window title with the current image name."""
title = f"{VIEWER_LABEL} - {os.path.basename( title = f"{VIEWER_LABEL} - {os.path.basename(
self.controller.get_current_path())}" self.controller.get_current_path())}"
if self.slideshow_timer.isActive() or self.slideshow_reverse_timer.isActive(): if self.slideshow_manager.is_running():
title += UITexts.VIEWER_TITLE_SLIDESHOW title += UITexts.VIEWER_TITLE_SLIDESHOW
if self.movie and self.movie.state() == QMovie.Paused: if self.movie and self.movie.state() == QMovie.Paused:
title += UITexts.VIEWER_TITLE_PAUSED title += UITexts.VIEWER_TITLE_PAUSED
@@ -1993,24 +2105,12 @@ class ImageViewer(QWidget):
def toggle_slideshow(self): def toggle_slideshow(self):
"""Starts or stops the automatic slideshow timer.""" """Starts or stops the automatic slideshow timer."""
if self.slideshow_reverse_timer.isActive(): self.slideshow_manager.toggle(reverse=False)
self.slideshow_reverse_timer.stop()
if self.slideshow_timer.isActive():
self.slideshow_timer.stop()
else:
self.slideshow_timer.start()
self.update_view(resize_win=False) self.update_view(resize_win=False)
def toggle_slideshow_reverse(self): def toggle_slideshow_reverse(self):
"""Starts or stops the automatic reverse slideshow timer.""" """Starts or stops the automatic reverse slideshow timer."""
if self.slideshow_timer.isActive(): self.slideshow_manager.toggle(reverse=True)
self.slideshow_timer.stop()
if self.slideshow_reverse_timer.isActive():
self.slideshow_reverse_timer.stop()
else:
self.slideshow_reverse_timer.start()
self.update_view(resize_win=False) self.update_view(resize_win=False)
def set_slideshow_interval(self): def set_slideshow_interval(self):
@@ -2018,15 +2118,11 @@ class ImageViewer(QWidget):
val, ok = QInputDialog.getInt(self, val, ok = QInputDialog.getInt(self,
UITexts.SLIDESHOW_INTERVAL_TITLE, UITexts.SLIDESHOW_INTERVAL_TITLE,
UITexts.SLIDESHOW_INTERVAL_TEXT, UITexts.SLIDESHOW_INTERVAL_TEXT,
self.slideshow_timer.interval() // 1000, 1, 3600) self.slideshow_manager.get_interval() // 1000,
1, 3600)
if ok: if ok:
new_interval_ms = val * 1000 new_interval_ms = val * 1000
self.slideshow_timer.setInterval(new_interval_ms) self.slideshow_manager.set_interval(new_interval_ms)
self.slideshow_reverse_timer.setInterval(new_interval_ms)
if self.slideshow_timer.isActive():
self.slideshow_timer.start()
if self.slideshow_reverse_timer.isActive():
self.slideshow_reverse_timer.start()
def toggle_fullscreen(self): def toggle_fullscreen(self):
"""Toggles the viewer window between fullscreen and normal states.""" """Toggles the viewer window between fullscreen and normal states."""
@@ -2046,41 +2142,6 @@ class ImageViewer(QWidget):
"""Re-loads shortcuts from the main window configuration.""" """Re-loads shortcuts from the main window configuration."""
self._setup_shortcuts() self._setup_shortcuts()
def toggle_fit_to_screen(self):
"""
Toggles between fitting the image to the window and 100% actual size.
"""
# If close to 100%, fit to window. Otherwise 100%.
if abs(self.controller.zoom_factor - 1.0) < 0.01:
self.fit_to_window()
else:
self.controller.zoom_factor = 1.0
self.update_view(resize_win=False)
def fit_to_window(self):
"""
Calculates the zoom factor required to make the image fit perfectly
within the current viewport dimensions.
"""
if self.controller.pixmap_original.isNull():
return
viewport = self.scroll_area.viewport()
w_avail = viewport.width()
h_avail = viewport.height()
transform = QTransform().rotate(self.controller.rotation)
transformed_pixmap = self.controller.pixmap_original.transformed(
transform, Qt.SmoothTransformation)
img_w = transformed_pixmap.width()
img_h = transformed_pixmap.height()
if img_w == 0 or img_h == 0:
return
self.controller.zoom_factor = min(w_avail / img_w, h_avail / img_h)
self.update_view(resize_win=False)
def _get_clicked_face(self, pos): def _get_clicked_face(self, pos):
"""Checks if a click position is inside any face bounding box.""" """Checks if a click position is inside any face bounding box."""
for face in self.controller.faces: for face in self.controller.faces:
@@ -2145,7 +2206,7 @@ class ImageViewer(QWidget):
new_full_tag, updated_history, ok = FaceNameDialog.get_name( new_full_tag, updated_history, ok = FaceNameDialog.get_name(
self, history, current_name, main_win=self.main_win, self, history, current_name, main_win=self.main_win,
region_type=region_type, title=UITexts.RENAME_FACE_TITLE) region_type=region_type, title=UITexts.RENAME_AREA_TITLE)
if ok and new_full_tag and new_full_tag != current_name: if ok and new_full_tag and new_full_tag != current_name:
# Remove old tag if it's not used by other faces # Remove old tag if it's not used by other faces
@@ -2235,7 +2296,8 @@ class ImageViewer(QWidget):
{"text": UITexts.VIEWER_MENU_RENAME, "action": "rename", {"text": UITexts.VIEWER_MENU_RENAME, "action": "rename",
"icon": "edit-rename"}, "icon": "edit-rename"},
"separator", "separator",
{"text": UITexts.VIEWER_MENU_FIT_SCREEN, "slot": self.toggle_fit_to_screen, {"text": UITexts.VIEWER_MENU_FIT_SCREEN,
"slot": self.zoom_manager.toggle_fit_to_screen,
"icon": "zoom-fit-best"}, "icon": "zoom-fit-best"},
"separator", "separator",
{"text": UITexts.VIEWER_MENU_CROP, "action": "toggle_crop", {"text": UITexts.VIEWER_MENU_CROP, "action": "toggle_crop",
@@ -2253,8 +2315,8 @@ class ImageViewer(QWidget):
menu_items.append({"text": pause_text, "action": "toggle_animation", menu_items.append({"text": pause_text, "action": "toggle_animation",
"icon": pause_icon}) "icon": pause_icon})
is_fwd_slideshow = self.slideshow_timer.isActive() is_fwd_slideshow = self.slideshow_manager.is_forward()
is_rev_slideshow = self.slideshow_reverse_timer.isActive() is_rev_slideshow = self.slideshow_manager.is_reverse()
slideshow_submenu = [ slideshow_submenu = [
{"text": UITexts.VIEWER_MENU_STOP_SLIDESHOW if is_fwd_slideshow {"text": UITexts.VIEWER_MENU_STOP_SLIDESHOW if is_fwd_slideshow
@@ -2722,8 +2784,7 @@ class ImageViewer(QWidget):
""" """
if self.movie: if self.movie:
self.movie.stop() self.movie.stop()
self.slideshow_timer.stop() self.slideshow_manager.stop()
self.slideshow_reverse_timer.stop()
if self.filmstrip_loader and self.filmstrip_loader.isRunning(): if self.filmstrip_loader and self.filmstrip_loader.isRunning():
self.filmstrip_loader.stop() self.filmstrip_loader.stop()
self.uninhibit_screensaver() self.uninhibit_screensaver()

View File

@@ -155,3 +155,33 @@ class XattrManager:
except Exception as e: except Exception as e:
raise IOError(f"Could not save xattr '{attr_name}' " raise IOError(f"Could not save xattr '{attr_name}' "
"for {file_path}: {e}") from e "for {file_path}: {e}") from e
@staticmethod
def get_all_attributes(path):
"""
Gets all extended attributes for a file as a dictionary.
Args:
path (str): The path to the file.
Returns:
dict: A dictionary mapping attribute names to values.
"""
attributes = {}
if not path:
return attributes
try:
keys = os.listxattr(path)
for key in keys:
try:
val = os.getxattr(path, key)
try:
val_str = val.decode('utf-8')
except UnicodeDecodeError:
val_str = str(val)
attributes[key] = val_str
except (OSError, AttributeError):
pass
except (OSError, AttributeError):
pass
return attributes

View File

@@ -9,7 +9,6 @@ Classes:
PropertiesDialog: A QDialog that presents file properties in a tabbed PropertiesDialog: A QDialog that presents file properties in a tabbed
interface. interface.
""" """
import os
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog, QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog,
QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
@@ -18,14 +17,40 @@ from PySide6.QtWidgets import (
from PySide6.QtGui import ( from PySide6.QtGui import (
QImageReader, QIcon, QColor QImageReader, QIcon, QColor
) )
from PySide6.QtCore import ( from PySide6.QtCore import (QThread, Signal, Qt, QFileInfo, QLocale)
Qt, QFileInfo, QLocale
)
from constants import ( from constants import (
RATING_XATTR_NAME, XATTR_NAME, UITexts RATING_XATTR_NAME, XATTR_NAME, UITexts
) )
from metadatamanager import MetadataManager, HAVE_EXIV2, notify_baloo from metadatamanager import MetadataManager, HAVE_EXIV2, XattrManager
from utils import preserve_mtime
class PropertiesLoader(QThread):
"""Background thread to load metadata (xattrs and EXIF) asynchronously."""
loaded = Signal(dict, dict)
def __init__(self, path, parent=None):
super().__init__(parent)
self.path = path
self._abort = False
def stop(self):
"""Signals the thread to stop and waits for it."""
self._abort = True
self.wait()
def run(self):
# Xattrs
if self._abort:
return
xattrs = XattrManager.get_all_attributes(self.path)
if self._abort:
return
# EXIF
exif_data = MetadataManager.read_all_metadata(self.path)
if not self._abort:
self.loaded.emit(xattrs, exif_data)
class PropertiesDialog(QDialog): class PropertiesDialog(QDialog):
@@ -51,6 +76,7 @@ class PropertiesDialog(QDialog):
self.setWindowTitle(UITexts.PROPERTIES_TITLE) self.setWindowTitle(UITexts.PROPERTIES_TITLE)
self._initial_tags = initial_tags if initial_tags is not None else [] self._initial_tags = initial_tags if initial_tags is not None else []
self._initial_rating = initial_rating self._initial_rating = initial_rating
self.loader = None
self.resize(400, 500) self.resize(400, 500)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
@@ -128,7 +154,8 @@ class PropertiesDialog(QDialog):
self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self.show_context_menu) self.table.customContextMenuRequested.connect(self.show_context_menu)
self.load_metadata() # Initial partial load (synchronous, just passed args)
self.update_metadata_table({}, initial_only=True)
meta_layout.addWidget(self.table) meta_layout.addWidget(self.table)
tabs.addTab(meta_widget, QIcon.fromTheme("document-properties"), tabs.addTab(meta_widget, QIcon.fromTheme("document-properties"),
UITexts.PROPERTIES_METADATA_TAB) UITexts.PROPERTIES_METADATA_TAB)
@@ -159,7 +186,8 @@ class PropertiesDialog(QDialog):
# This is a disk read. # This is a disk read.
self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu) self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu)
self.load_exif_data() # Placeholder for EXIF
self.update_exif_table(None)
exif_layout.addWidget(self.exif_table) exif_layout.addWidget(self.exif_table)
tabs.addTab(exif_widget, QIcon.fromTheme("view-details"), tabs.addTab(exif_widget, QIcon.fromTheme("view-details"),
@@ -173,10 +201,18 @@ class PropertiesDialog(QDialog):
btn_box.rejected.connect(self.close) btn_box.rejected.connect(self.close)
layout.addWidget(btn_box) layout.addWidget(btn_box)
def load_metadata(self): # Start background loading
self.reload_metadata()
def closeEvent(self, event):
if self.loader and self.loader.isRunning():
self.loader.stop()
super().closeEvent(event)
def update_metadata_table(self, disk_xattrs, initial_only=False):
""" """
Loads metadata from the file's text keys (via QImageReader) and Updates the metadata table with extended attributes.
extended attributes (xattrs) into the metadata table. Merges initial tags/rating with loaded xattrs.
""" """
self.table.blockSignals(True) self.table.blockSignals(True)
self.table.setRowCount(0) self.table.setRowCount(0)
@@ -188,26 +224,11 @@ class PropertiesDialog(QDialog):
if self._initial_rating > 0: if self._initial_rating > 0:
preloaded_xattrs[RATING_XATTR_NAME] = str(self._initial_rating) preloaded_xattrs[RATING_XATTR_NAME] = str(self._initial_rating)
# Read other xattrs from disk
xattrs = {}
try:
for xkey in os.listxattr(self.path):
# Avoid re-reading already known attributes
if xkey not in preloaded_xattrs:
try:
val = os.getxattr(self.path, xkey) # This is a disk read
try:
val_str = val.decode('utf-8')
except UnicodeDecodeError:
val_str = str(val)
xattrs[xkey] = val_str
except Exception:
pass
except Exception:
pass
# Combine preloaded and newly read xattrs # Combine preloaded and newly read xattrs
all_xattrs = {**preloaded_xattrs, **xattrs} all_xattrs = preloaded_xattrs.copy()
if not initial_only and disk_xattrs:
# Disk data takes precedence or adds to it
all_xattrs.update(disk_xattrs)
self.table.setRowCount(len(all_xattrs)) self.table.setRowCount(len(all_xattrs))
@@ -224,11 +245,34 @@ class PropertiesDialog(QDialog):
row += 1 row += 1
self.table.blockSignals(False) self.table.blockSignals(False)
def load_exif_data(self): def reload_metadata(self):
"""Loads EXIF, XMP, and IPTC metadata using the MetadataManager.""" """Starts the background thread to load metadata."""
if self.loader and self.loader.isRunning():
# Already running
return
self.loader = PropertiesLoader(self.path, self)
self.loader.loaded.connect(self.on_data_loaded)
self.loader.start()
def on_data_loaded(self, xattrs, exif_data):
"""Slot called when metadata is loaded from the thread."""
self.update_metadata_table(xattrs, initial_only=False)
self.update_exif_table(exif_data)
def update_exif_table(self, exif_data):
"""Updates the EXIF table with loaded data."""
self.exif_table.blockSignals(True) self.exif_table.blockSignals(True)
self.exif_table.setRowCount(0) self.exif_table.setRowCount(0)
if exif_data is None:
# Loading state
self.exif_table.setRowCount(1)
item = QTableWidgetItem("Loading data...")
item.setFlags(Qt.ItemIsEnabled)
self.exif_table.setItem(0, 0, item)
self.exif_table.blockSignals(False)
return
if not HAVE_EXIV2: if not HAVE_EXIV2:
self.exif_table.setRowCount(1) self.exif_table.setRowCount(1)
error_color = QColor("red") error_color = QColor("red")
@@ -243,8 +287,6 @@ class PropertiesDialog(QDialog):
self.exif_table.blockSignals(False) self.exif_table.blockSignals(False)
return return
exif_data = MetadataManager.read_all_metadata(self.path)
if not exif_data: if not exif_data:
self.exif_table.setRowCount(1) self.exif_table.setRowCount(1)
item = QTableWidgetItem(UITexts.INFO) item = QTableWidgetItem(UITexts.INFO)
@@ -291,16 +333,11 @@ class PropertiesDialog(QDialog):
if item.column() == 1: if item.column() == 1:
key = self.table.item(item.row(), 0).text() key = self.table.item(item.row(), 0).text()
val = item.text() val = item.text()
# Treat empty or whitespace-only values as removal to match previous
# behavior
val_to_set = val if val.strip() else None
try: try:
with preserve_mtime(self.path): XattrManager.set_attribute(self.path, key, val_to_set)
if not val.strip():
try:
os.removexattr(self.path, key)
except OSError:
pass
else:
os.setxattr(self.path, key, val.encode('utf-8'))
notify_baloo(self.path)
except Exception as e: except Exception as e:
QMessageBox.warning(self, UITexts.ERROR, QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_SET_ATTR.format(e)) UITexts.PROPERTIES_ERROR_SET_ATTR.format(e))
@@ -361,10 +398,8 @@ class PropertiesDialog(QDialog):
key)) key))
if ok2: if ok2:
try: try:
with preserve_mtime(self.path): XattrManager.set_attribute(self.path, key, val)
os.setxattr(self.path, key, val.encode('utf-8')) self.reload_metadata()
notify_baloo(self.path)
self.load_metadata()
except Exception as e: except Exception as e:
QMessageBox.warning(self, UITexts.ERROR, QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_ADD_ATTR.format(e)) UITexts.PROPERTIES_ERROR_ADD_ATTR.format(e))
@@ -378,9 +413,7 @@ class PropertiesDialog(QDialog):
""" """
key = self.table.item(row, 0).text() key = self.table.item(row, 0).text()
try: try:
with preserve_mtime(self.path): XattrManager.set_attribute(self.path, key, None)
os.removexattr(self.path, key)
notify_baloo(self.path)
self.table.removeRow(row) self.table.removeRow(row)
except Exception as e: except Exception as e:
QMessageBox.warning(self, UITexts.ERROR, QMessageBox.warning(self, UITexts.ERROR,

View File

@@ -35,7 +35,7 @@ from constants import (
THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT, THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT,
THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT, THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT,
VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_WHEEL_SPEED_DEFAULT, VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_WHEEL_SPEED_DEFAULT,
UITexts, save_app_config, UITexts, save_app_config, HAVE_BAGHEERASEARCH_LIB
) )
@@ -741,9 +741,24 @@ class SettingsDialog(QDialog):
self.threads_spin.setValue(scan_threads) self.threads_spin.setValue(scan_threads)
# Set search engine # Set search engine
index = self.search_engine_combo.findData(search_engine) if HAVE_BAGHEERASEARCH_LIB:
self.search_engine_combo.setEnabled(True)
if search_engine != "Baloo":
index = self.search_engine_combo.findData("Bagheera")
if index != -1: if index != -1:
self.search_engine_combo.setCurrentIndex(index) self.search_engine_combo.setCurrentIndex(index)
else:
index = self.search_engine_combo.findData("Baloo")
if index != -1:
self.search_engine_combo.setCurrentIndex(index)
else:
self.search_engine_combo.setEnabled(False)
if SEARCH_CMD:
index = self.search_engine_combo.findData("Baloo")
if index != -1:
self.search_engine_combo.setCurrentIndex(index)
else:
self.search_engine_combo.setCurrentIndex(-1)
self.scan_full_on_start_checkbox.setChecked(scan_full_on_start) self.scan_full_on_start_checkbox.setChecked(scan_full_on_start)
@@ -1009,6 +1024,7 @@ class SettingsDialog(QDialog):
APP_CONFIG["scan_max_level"] = self.scan_max_level_spin.value() APP_CONFIG["scan_max_level"] = self.scan_max_level_spin.value()
APP_CONFIG["generation_threads"] = self.threads_spin.value() APP_CONFIG["generation_threads"] = self.threads_spin.value()
APP_CONFIG["scan_batch_size"] = self.scan_batch_size_spin.value() APP_CONFIG["scan_batch_size"] = self.scan_batch_size_spin.value()
if HAVE_BAGHEERASEARCH_LIB:
APP_CONFIG["search_engine"] = self.search_engine_combo.currentData() APP_CONFIG["search_engine"] = self.search_engine_combo.currentData()
APP_CONFIG["scan_full_on_start"] = self.scan_full_on_start_checkbox.isChecked() APP_CONFIG["scan_full_on_start"] = self.scan_full_on_start_checkbox.isChecked()
APP_CONFIG["person_tags"] = self.person_tags_edit.text() APP_CONFIG["person_tags"] = self.person_tags_edit.text()