A bunch of changes
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
405
imageviewer.py
405
imageviewer.py
@@ -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,25 +1631,30 @@ class ImageViewer(QWidget):
|
|||||||
Args:
|
Args:
|
||||||
restore_config (dict, optional): State dictionary to restore from.
|
restore_config (dict, optional): State dictionary to restore from.
|
||||||
"""
|
"""
|
||||||
if self.movie:
|
success, reloaded = self.controller.load_image()
|
||||||
self.movie.stop()
|
|
||||||
self.movie = None
|
|
||||||
|
|
||||||
if not self.controller.load_image():
|
if not success:
|
||||||
|
if self.movie:
|
||||||
|
self.movie.stop()
|
||||||
|
self.movie = None
|
||||||
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 path:
|
if reloaded:
|
||||||
reader = QImageReader(path)
|
if self.movie:
|
||||||
if reader.supportsAnimation() and reader.imageCount() > 1:
|
self.movie.stop()
|
||||||
self.movie = QMovie(path)
|
self.movie = None
|
||||||
self.movie.setCacheMode(QMovie.CacheAll)
|
self.canvas.crop_rect = QRect() # Clear crop rect on new image
|
||||||
self.movie.frameChanged.connect(self._on_movie_frame)
|
if path:
|
||||||
self.movie.start()
|
reader = QImageReader(path)
|
||||||
|
if reader.supportsAnimation() and reader.imageCount() > 1:
|
||||||
|
self.movie = QMovie(path)
|
||||||
|
self.movie.setCacheMode(QMovie.CacheAll)
|
||||||
|
self.movie.frameChanged.connect(self._on_movie_frame)
|
||||||
|
self.movie.start()
|
||||||
|
|
||||||
self.reset_inactivity_timer()
|
self.reset_inactivity_timer()
|
||||||
if restore_config:
|
if restore_config:
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
26
settings.py
26
settings.py
@@ -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:
|
||||||
if index != -1:
|
self.search_engine_combo.setEnabled(True)
|
||||||
self.search_engine_combo.setCurrentIndex(index)
|
if search_engine != "Baloo":
|
||||||
|
index = self.search_engine_combo.findData("Bagheera")
|
||||||
|
if index != -1:
|
||||||
|
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,7 +1024,8 @@ 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()
|
||||||
APP_CONFIG["search_engine"] = self.search_engine_combo.currentData()
|
if HAVE_BAGHEERASEARCH_LIB:
|
||||||
|
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()
|
||||||
APP_CONFIG["pet_tags"] = self.pet_tags_edit.text()
|
APP_CONFIG["pet_tags"] = self.pet_tags_edit.text()
|
||||||
|
|||||||
Reference in New Issue
Block a user