A bunch of changes
This commit is contained in:
@@ -2,7 +2,12 @@ v0.9.11 -
|
|||||||
· Filmstrip fixed
|
· Filmstrip fixed
|
||||||
· Añadida una nueva área llamada Body.
|
· Añadida una nueva área llamada Body.
|
||||||
· Refactorizaciones, optimizaciones y cambios a saco.
|
· Refactorizaciones, optimizaciones y cambios a saco.
|
||||||
|
· Image viewer tiene comparisonb
|
||||||
|
|
||||||
|
Implement a bulk rename feature for the selected pet or face tags.
|
||||||
|
Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text.
|
||||||
|
|
||||||
|
Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text.
|
||||||
Add a `shutdown` signal or method to `ScannerWorker` to allow cleaner cancellation of long-running tasks like `generate_thumbnail`.
|
Add a `shutdown` signal or method to `ScannerWorker` to allow cleaner cancellation of long-running tasks like `generate_thumbnail`.
|
||||||
Implement a mechanism to dynamically adjust the thread pool size based on system load or user activity.
|
Implement a mechanism to dynamically adjust the thread pool size based on system load or user activity.
|
||||||
|
|
||||||
|
|||||||
25
constants.py
25
constants.py
@@ -167,7 +167,7 @@ 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
|
HAVE_BAGHEERASEARCH_LIB = True
|
||||||
|
|
||||||
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")
|
||||||
@@ -291,6 +291,10 @@ VIEWER_ACTIONS = {
|
|||||||
"toggle_visibility": ("Show/Hide Main Window", "Window"),
|
"toggle_visibility": ("Show/Hide Main Window", "Window"),
|
||||||
"toggle_crop": ("Toggle Crop Mode", "Edit"),
|
"toggle_crop": ("Toggle Crop Mode", "Edit"),
|
||||||
"save_crop": ("Save Cropped Image", "File"),
|
"save_crop": ("Save Cropped Image", "File"),
|
||||||
|
"compare_1": ("Single View", "View"),
|
||||||
|
"compare_2": ("Compare 2 Images", "View"),
|
||||||
|
"compare_4": ("Compare 4 Images", "View"),
|
||||||
|
"link_panes": ("Link Panes", "View"),
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_VIEWER_SHORTCUTS = {
|
DEFAULT_VIEWER_SHORTCUTS = {
|
||||||
@@ -319,6 +323,10 @@ DEFAULT_VIEWER_SHORTCUTS = {
|
|||||||
"toggle_visibility": (Qt.Key_H, Qt.ControlModifier),
|
"toggle_visibility": (Qt.Key_H, Qt.ControlModifier),
|
||||||
"toggle_crop": (Qt.Key_C, Qt.NoModifier),
|
"toggle_crop": (Qt.Key_C, Qt.NoModifier),
|
||||||
"save_crop": (Qt.Key_S, Qt.ControlModifier),
|
"save_crop": (Qt.Key_S, Qt.ControlModifier),
|
||||||
|
"compare_1": (Qt.Key_1, Qt.AltModifier),
|
||||||
|
"compare_2": (Qt.Key_2, Qt.AltModifier),
|
||||||
|
"compare_4": (Qt.Key_4, Qt.AltModifier),
|
||||||
|
"link_panes": (Qt.Key_L, Qt.AltModifier),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -735,6 +743,11 @@ _UI_TEXTS = {
|
|||||||
"VIEWER_MENU_CROP": "Crop Mode",
|
"VIEWER_MENU_CROP": "Crop Mode",
|
||||||
"VIEWER_MENU_SAVE_CROP": "Save Selection...",
|
"VIEWER_MENU_SAVE_CROP": "Save Selection...",
|
||||||
"SAVE_CROP_TITLE": "Save Cropped Image",
|
"SAVE_CROP_TITLE": "Save Cropped Image",
|
||||||
|
"VIEWER_MENU_COMPARE": "Comparison Mode",
|
||||||
|
"VIEWER_MENU_COMPARE_1": "Single View",
|
||||||
|
"VIEWER_MENU_COMPARE_2": "2 Images",
|
||||||
|
"VIEWER_MENU_COMPARE_4": "4 Images",
|
||||||
|
"VIEWER_MENU_LINK_PANES": "Link Panes",
|
||||||
"SAVE_CROP_FILTER": "Images (*.jpg *.jpeg *.png *.bmp *.webp)",
|
"SAVE_CROP_FILTER": "Images (*.jpg *.jpeg *.png *.bmp *.webp)",
|
||||||
"SLIDESHOW_INTERVAL_TITLE": "Slideshow Interval",
|
"SLIDESHOW_INTERVAL_TITLE": "Slideshow Interval",
|
||||||
"SLIDESHOW_INTERVAL_TEXT": "Seconds:",
|
"SLIDESHOW_INTERVAL_TEXT": "Seconds:",
|
||||||
@@ -1164,6 +1177,11 @@ _UI_TEXTS = {
|
|||||||
"VIEWER_MENU_TAGS": "Etiquetas rápidas",
|
"VIEWER_MENU_TAGS": "Etiquetas rápidas",
|
||||||
"VIEWER_MENU_CROP": "Modo Recorte",
|
"VIEWER_MENU_CROP": "Modo Recorte",
|
||||||
"VIEWER_MENU_SAVE_CROP": "Guardar Selección...",
|
"VIEWER_MENU_SAVE_CROP": "Guardar Selección...",
|
||||||
|
"VIEWER_MENU_COMPARE": "Modo Comparación",
|
||||||
|
"VIEWER_MENU_COMPARE_1": "Vista Única",
|
||||||
|
"VIEWER_MENU_COMPARE_2": "2 Imágenes",
|
||||||
|
"VIEWER_MENU_COMPARE_4": "4 Imágenes",
|
||||||
|
"VIEWER_MENU_LINK_PANES": "Vincular Paneles",
|
||||||
"SAVE_CROP_TITLE": "Guardar Imagen Recortada",
|
"SAVE_CROP_TITLE": "Guardar Imagen Recortada",
|
||||||
"SAVE_CROP_FILTER": "Imágenes (*.jpg *.jpeg *.png *.bmp *.webp)",
|
"SAVE_CROP_FILTER": "Imágenes (*.jpg *.jpeg *.png *.bmp *.webp)",
|
||||||
"SLIDESHOW_INTERVAL_TITLE": "Intervalo de Presentación",
|
"SLIDESHOW_INTERVAL_TITLE": "Intervalo de Presentación",
|
||||||
@@ -1597,6 +1615,11 @@ _UI_TEXTS = {
|
|||||||
"VIEWER_MENU_TAGS": "Etiquetas rápidas",
|
"VIEWER_MENU_TAGS": "Etiquetas rápidas",
|
||||||
"VIEWER_MENU_CROP": "Modo Recorte",
|
"VIEWER_MENU_CROP": "Modo Recorte",
|
||||||
"VIEWER_MENU_SAVE_CROP": "Gardar Selección...",
|
"VIEWER_MENU_SAVE_CROP": "Gardar Selección...",
|
||||||
|
"VIEWER_MENU_COMPARE": "Modo Comparación",
|
||||||
|
"VIEWER_MENU_COMPARE_1": "Vista Única",
|
||||||
|
"VIEWER_MENU_COMPARE_2": "2 Imaxes",
|
||||||
|
"VIEWER_MENU_COMPARE_4": "4 Imaxes",
|
||||||
|
"VIEWER_MENU_LINK_PANES": "Vincular Paneis",
|
||||||
"SAVE_CROP_TITLE": "Gardar Imaxe Recortada",
|
"SAVE_CROP_TITLE": "Gardar Imaxe Recortada",
|
||||||
"SAVE_CROP_FILTER": "Imaxes (*.jpg *.jpeg *.png *.bmp *.webp)",
|
"SAVE_CROP_FILTER": "Imaxes (*.jpg *.jpeg *.png *.bmp *.webp)",
|
||||||
"SLIDESHOW_INTERVAL_TITLE": "Intervalo da Presentación",
|
"SLIDESHOW_INTERVAL_TITLE": "Intervalo da Presentación",
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ from imageviewer import ImageViewer
|
|||||||
from metadatamanager import XattrManager
|
from metadatamanager import XattrManager
|
||||||
|
|
||||||
if HAVE_BAGHEERASEARCH_LIB:
|
if HAVE_BAGHEERASEARCH_LIB:
|
||||||
from bagheera_search_lib import BagheeraSearcher
|
try:
|
||||||
|
from bagheera_search_lib import BagheeraSearcher
|
||||||
|
except ImportError:
|
||||||
|
HAVE_BAGHEERASEARCH_LIB = False
|
||||||
|
pass
|
||||||
|
|
||||||
# Set up logging for better debugging
|
# Set up logging for better debugging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -371,11 +375,8 @@ class CacheWriter(QThread):
|
|||||||
# Gather a batch of items
|
# Gather a batch of items
|
||||||
# Adaptive batch size: if queue is backing up, increase transaction size
|
# Adaptive batch size: if queue is backing up, increase transaction size
|
||||||
# to improve throughput.
|
# to improve throughput.
|
||||||
if not self._running:
|
# Respect max size even during shutdown to avoid OOM or huge transactions
|
||||||
# Flush everything if stopping
|
batch_limit = self._max_size
|
||||||
batch_limit = len(self._queue)
|
|
||||||
else:
|
|
||||||
batch_limit = self._max_size
|
|
||||||
|
|
||||||
batch = []
|
batch = []
|
||||||
while self._queue and len(batch) < batch_limit:
|
while self._queue and len(batch) < batch_limit:
|
||||||
|
|||||||
610
imageviewer.py
610
imageviewer.py
@@ -15,7 +15,7 @@ import json
|
|||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QScrollArea, QLabel, QHBoxLayout, QListWidget,
|
QWidget, QVBoxLayout, QScrollArea, QLabel, QHBoxLayout, QListWidget,
|
||||||
QListWidgetItem, QAbstractItemView, QMenu, QInputDialog, QDialog, QDialogButtonBox,
|
QListWidgetItem, QAbstractItemView, QMenu, QInputDialog, QDialog, QDialogButtonBox, QGridLayout,
|
||||||
QApplication, QMessageBox, QLineEdit, QFileDialog
|
QApplication, QMessageBox, QLineEdit, QFileDialog
|
||||||
)
|
)
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
@@ -39,6 +39,13 @@ from imagecontroller import ImageController
|
|||||||
from widgets import FaceNameInputWidget
|
from widgets import FaceNameInputWidget
|
||||||
from propertiesdialog import PropertiesDialog
|
from propertiesdialog import PropertiesDialog
|
||||||
|
|
||||||
|
class HighlightWidget(QWidget):
|
||||||
|
"""Widget to show a highlight border around the active pane."""
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||||
|
self.setStyleSheet("border: 2px solid #3498db; background: transparent;")
|
||||||
|
self.hide()
|
||||||
|
|
||||||
class FaceNameDialog(QDialog):
|
class FaceNameDialog(QDialog):
|
||||||
"""A dialog to get a face name using the FaceNameInputWidget."""
|
"""A dialog to get a face name using the FaceNameInputWidget."""
|
||||||
@@ -646,7 +653,8 @@ class FaceCanvas(QLabel):
|
|||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
"""Handles mouse press for drawing new faces or panning."""
|
"""Handles mouse press for drawing new faces or panning."""
|
||||||
self.viewer.reset_inactivity_timer()
|
if hasattr(self.viewer, 'reset_inactivity_timer'):
|
||||||
|
self.viewer.reset_inactivity_timer()
|
||||||
if self.viewer.crop_mode and event.button() == Qt.LeftButton:
|
if self.viewer.crop_mode and event.button() == Qt.LeftButton:
|
||||||
handle = self._hit_test_crop(event.position().toPoint())
|
handle = self._hit_test_crop(event.position().toPoint())
|
||||||
if handle:
|
if handle:
|
||||||
@@ -663,6 +671,10 @@ class FaceCanvas(QLabel):
|
|||||||
event.accept()
|
event.accept()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Activate the pane on click
|
||||||
|
if hasattr(self.viewer, 'activate'):
|
||||||
|
self.viewer.activate()
|
||||||
|
|
||||||
if self.controller.show_faces and event.button() == Qt.LeftButton:
|
if self.controller.show_faces and event.button() == Qt.LeftButton:
|
||||||
self.start_pos = event.position().toPoint()
|
self.start_pos = event.position().toPoint()
|
||||||
|
|
||||||
@@ -697,7 +709,8 @@ class FaceCanvas(QLabel):
|
|||||||
|
|
||||||
def mouseMoveEvent(self, event):
|
def mouseMoveEvent(self, event):
|
||||||
"""Handles mouse move for drawing new faces or panning."""
|
"""Handles mouse move for drawing new faces or panning."""
|
||||||
self.viewer.reset_inactivity_timer()
|
if hasattr(self.viewer, 'reset_inactivity_timer'):
|
||||||
|
self.viewer.reset_inactivity_timer()
|
||||||
if self.viewer.crop_mode:
|
if self.viewer.crop_mode:
|
||||||
curr_pos = event.position().toPoint()
|
curr_pos = event.position().toPoint()
|
||||||
|
|
||||||
@@ -963,6 +976,10 @@ class FaceCanvas(QLabel):
|
|||||||
self.viewer.zoom_manager.zoom_to_rect(clicked_face)
|
self.viewer.zoom_manager.zoom_to_rect(clicked_face)
|
||||||
event.accept()
|
event.accept()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Double click to toggle fullscreen if handled by viewer/pane
|
||||||
|
if hasattr(self.viewer, 'toggle_fullscreen'):
|
||||||
|
self.viewer.toggle_fullscreen()
|
||||||
# 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)
|
||||||
|
|
||||||
@@ -1033,6 +1050,8 @@ class ZoomManager(QObject):
|
|||||||
"""
|
"""
|
||||||
Manages zoom calculations and state for the ImageViewer.
|
Manages zoom calculations and state for the ImageViewer.
|
||||||
"""
|
"""
|
||||||
|
zoomed = Signal(float)
|
||||||
|
|
||||||
def __init__(self, viewer):
|
def __init__(self, viewer):
|
||||||
super().__init__(viewer)
|
super().__init__(viewer)
|
||||||
self.viewer = viewer
|
self.viewer = viewer
|
||||||
@@ -1049,7 +1068,9 @@ class ZoomManager(QObject):
|
|||||||
# so it can update its selection.
|
# so it can update its selection.
|
||||||
self.viewer.index_changed.emit(self.viewer.controller.index)
|
self.viewer.index_changed.emit(self.viewer.controller.index)
|
||||||
|
|
||||||
self.viewer.sync_filmstrip_selection(self.viewer.controller.index)
|
self.zoomed.emit(self.viewer.controller.zoom_factor)
|
||||||
|
if hasattr(self.viewer, 'sync_filmstrip_selection'):
|
||||||
|
self.viewer.sync_filmstrip_selection(self.viewer.controller.index)
|
||||||
|
|
||||||
def toggle_fit_to_screen(self):
|
def toggle_fit_to_screen(self):
|
||||||
"""
|
"""
|
||||||
@@ -1083,6 +1104,7 @@ class ZoomManager(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.viewer.controller.zoom_factor = min(w_avail / img_w, h_avail / img_h)
|
self.viewer.controller.zoom_factor = min(w_avail / img_w, h_avail / img_h)
|
||||||
|
self.zoomed.emit(self.viewer.controller.zoom_factor)
|
||||||
self.viewer.update_view(resize_win=False)
|
self.viewer.update_view(resize_win=False)
|
||||||
|
|
||||||
def calculate_initial_zoom(self, available_w, available_h, is_fullscreen):
|
def calculate_initial_zoom(self, available_w, available_h, is_fullscreen):
|
||||||
@@ -1099,6 +1121,8 @@ class ZoomManager(QObject):
|
|||||||
else:
|
else:
|
||||||
self.viewer.controller.zoom_factor = 1.0
|
self.viewer.controller.zoom_factor = 1.0
|
||||||
|
|
||||||
|
self.zoomed.emit(self.viewer.controller.zoom_factor)
|
||||||
|
|
||||||
def zoom_to_rect(self, face_rect):
|
def zoom_to_rect(self, face_rect):
|
||||||
"""Zooms and pans the view to center on a given normalized rectangle."""
|
"""Zooms and pans the view to center on a given normalized rectangle."""
|
||||||
if self.viewer.controller.pixmap_original.isNull():
|
if self.viewer.controller.pixmap_original.isNull():
|
||||||
@@ -1131,6 +1155,7 @@ class ZoomManager(QObject):
|
|||||||
new_zoom = min(zoom_w, zoom_h)
|
new_zoom = min(zoom_w, zoom_h)
|
||||||
|
|
||||||
self.viewer.controller.zoom_factor = new_zoom
|
self.viewer.controller.zoom_factor = new_zoom
|
||||||
|
self.zoomed.emit(new_zoom)
|
||||||
self.viewer.update_view(resize_win=False)
|
self.viewer.update_view(resize_win=False)
|
||||||
|
|
||||||
# Defer centering until after the view has been updated
|
# Defer centering until after the view has been updated
|
||||||
@@ -1157,6 +1182,153 @@ class ZoomManager(QObject):
|
|||||||
self.viewer.scroll_area.verticalScrollBar().setValue(int(scroll_y))
|
self.viewer.scroll_area.verticalScrollBar().setValue(int(scroll_y))
|
||||||
|
|
||||||
|
|
||||||
|
class ImagePane(QWidget):
|
||||||
|
"""
|
||||||
|
A single image viewport containing the canvas, scroll area, and controller.
|
||||||
|
Used within ImageViewer to support comparison modes.
|
||||||
|
"""
|
||||||
|
activated = Signal()
|
||||||
|
index_changed = Signal(int)
|
||||||
|
scrolled = Signal(float, float)
|
||||||
|
|
||||||
|
def __init__(self, parent_viewer, cache, image_list, index, initial_tags=None,
|
||||||
|
initial_rating=0):
|
||||||
|
super().__init__(parent_viewer)
|
||||||
|
self.viewer = parent_viewer # Reference to main ImageViewer
|
||||||
|
self.main_win = parent_viewer.main_win
|
||||||
|
self.cache = cache
|
||||||
|
|
||||||
|
self.controller = ImageController(image_list, index, initial_tags,
|
||||||
|
initial_rating)
|
||||||
|
if self.main_win:
|
||||||
|
self.controller.show_faces = self.main_win.show_faces
|
||||||
|
|
||||||
|
# Connect signals
|
||||||
|
self.controller.metadata_changed.connect(self.viewer.on_metadata_changed)
|
||||||
|
self.controller.list_updated.connect(self.viewer.on_controller_list_updated)
|
||||||
|
|
||||||
|
self.zoom_manager = ZoomManager(self)
|
||||||
|
self.canvas = FaceCanvas(self)
|
||||||
|
self.movie = None
|
||||||
|
self.crop_mode = False
|
||||||
|
|
||||||
|
# Layout
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
self.scroll_area = QScrollArea()
|
||||||
|
self.scroll_area.setAlignment(Qt.AlignCenter)
|
||||||
|
self.scroll_area.setStyleSheet("background-color: #000; border: none;")
|
||||||
|
# self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||||
|
# self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||||
|
self.scroll_area.setWidget(self.canvas)
|
||||||
|
layout.addWidget(self.scroll_area)
|
||||||
|
|
||||||
|
self.scroll_area.horizontalScrollBar().valueChanged.connect(self._on_scroll)
|
||||||
|
self.scroll_area.verticalScrollBar().valueChanged.connect(self._on_scroll)
|
||||||
|
self._suppress_scroll_signal = False
|
||||||
|
|
||||||
|
def activate(self):
|
||||||
|
"""Sets this pane as the active one in the viewer."""
|
||||||
|
self.viewer.set_active_pane(self)
|
||||||
|
self.activated.emit()
|
||||||
|
|
||||||
|
def reset_inactivity_timer(self):
|
||||||
|
"""Delegates to parent viewer."""
|
||||||
|
self.viewer.reset_inactivity_timer()
|
||||||
|
|
||||||
|
def sync_filmstrip_selection(self, index):
|
||||||
|
"""Delegates to parent viewer if this is the active pane."""
|
||||||
|
if self.viewer.active_pane == self:
|
||||||
|
self.viewer.sync_filmstrip_selection(index)
|
||||||
|
|
||||||
|
def load_and_fit_image(self, restore_config=None):
|
||||||
|
"""Loads image using shared logic, adapted for Pane."""
|
||||||
|
# reuse logic from ImageViewer (now moved/adapted)
|
||||||
|
self.viewer.load_and_fit_image_for_pane(self, restore_config)
|
||||||
|
|
||||||
|
def update_view(self, resize_win=False):
|
||||||
|
"""Updates this pane's view."""
|
||||||
|
self.viewer.update_view_for_pane(self, resize_win)
|
||||||
|
|
||||||
|
def _on_scroll(self):
|
||||||
|
if self._suppress_scroll_signal:
|
||||||
|
return
|
||||||
|
h_bar = self.scroll_area.horizontalScrollBar()
|
||||||
|
v_bar = self.scroll_area.verticalScrollBar()
|
||||||
|
|
||||||
|
h_max = h_bar.maximum()
|
||||||
|
v_max = v_bar.maximum()
|
||||||
|
|
||||||
|
x_pct = h_bar.value() / h_max if h_max > 0 else 0
|
||||||
|
y_pct = v_bar.value() / v_max if v_max > 0 else 0
|
||||||
|
|
||||||
|
self.scrolled.emit(x_pct, y_pct)
|
||||||
|
|
||||||
|
def set_scroll_relative(self, x_pct, y_pct):
|
||||||
|
self._suppress_scroll_signal = True
|
||||||
|
h_bar = self.scroll_area.horizontalScrollBar()
|
||||||
|
v_bar = self.scroll_area.verticalScrollBar()
|
||||||
|
h_bar.setValue(int(x_pct * h_bar.maximum()))
|
||||||
|
v_bar.setValue(int(y_pct * v_bar.maximum()))
|
||||||
|
self._suppress_scroll_signal = False
|
||||||
|
|
||||||
|
def toggle_fullscreen(self):
|
||||||
|
self.viewer.toggle_fullscreen()
|
||||||
|
|
||||||
|
def next_image(self):
|
||||||
|
self.controller.next()
|
||||||
|
self.index_changed.emit(self.controller.index)
|
||||||
|
self.load_and_fit_image()
|
||||||
|
|
||||||
|
def prev_image(self):
|
||||||
|
self.controller.prev()
|
||||||
|
self.index_changed.emit(self.controller.index)
|
||||||
|
self.load_and_fit_image()
|
||||||
|
|
||||||
|
def first_image(self):
|
||||||
|
self.controller.first()
|
||||||
|
self.index_changed.emit(self.controller.index)
|
||||||
|
self.load_and_fit_image()
|
||||||
|
|
||||||
|
def last_image(self):
|
||||||
|
self.controller.last()
|
||||||
|
self.index_changed.emit(self.controller.index)
|
||||||
|
self.load_and_fit_image()
|
||||||
|
|
||||||
|
def _get_clicked_face(self, pos):
|
||||||
|
return self.viewer._get_clicked_face_for_pane(self, pos)
|
||||||
|
|
||||||
|
def rename_face(self, face):
|
||||||
|
self.viewer.rename_face(face)
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
if self.movie:
|
||||||
|
self.movie.stop()
|
||||||
|
self.controller.cleanup()
|
||||||
|
|
||||||
|
# Event handlers specific to the pane surface (e.g. drop) can go here
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
self.activate()
|
||||||
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
|
def update_image_list(self, new_list):
|
||||||
|
# Logic similar to ImageViewer.update_image_list but for this controller
|
||||||
|
current_path = self.controller.get_current_path()
|
||||||
|
if not current_path and new_list:
|
||||||
|
self.controller.update_list(new_list, 0)
|
||||||
|
self.load_and_fit_image()
|
||||||
|
return
|
||||||
|
|
||||||
|
if current_path in new_list:
|
||||||
|
idx = new_list.index(current_path)
|
||||||
|
self.controller.update_list(new_list, idx)
|
||||||
|
else:
|
||||||
|
self.controller.update_list(new_list)
|
||||||
|
if self.controller.get_current_path() != current_path:
|
||||||
|
self.load_and_fit_image()
|
||||||
|
|
||||||
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.
|
||||||
@@ -1204,13 +1376,11 @@ class ImageViewer(QWidget):
|
|||||||
self._wheel_scroll_accumulator = 0
|
self._wheel_scroll_accumulator = 0
|
||||||
self.filmstrip_loader = None
|
self.filmstrip_loader = None
|
||||||
|
|
||||||
self.movie = None
|
# Pane management
|
||||||
self.controller = ImageController(image_list, current_index,
|
self.panes = []
|
||||||
initial_tags, initial_rating)
|
self.active_pane = None
|
||||||
if self.main_win:
|
self.panes_linked = True
|
||||||
self.controller.show_faces = self.main_win.show_faces
|
|
||||||
self.controller.metadata_changed.connect(self.on_metadata_changed)
|
|
||||||
self.controller.list_updated.connect(self.on_controller_list_updated)
|
|
||||||
self.fast_tag_manager = FastTagManager(self)
|
self.fast_tag_manager = FastTagManager(self)
|
||||||
self._setup_shortcuts()
|
self._setup_shortcuts()
|
||||||
self._setup_actions()
|
self._setup_actions()
|
||||||
@@ -1230,14 +1400,16 @@ class ImageViewer(QWidget):
|
|||||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||||
self.layout.setSpacing(0)
|
self.layout.setSpacing(0)
|
||||||
|
|
||||||
self.scroll_area = QScrollArea()
|
# Container for panes (Grid)
|
||||||
self.scroll_area.setAlignment(Qt.AlignCenter)
|
self.view_container = QWidget()
|
||||||
self.scroll_area.setStyleSheet("background-color: #000; border: none;")
|
self.grid_layout = QGridLayout(self.view_container)
|
||||||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
self.grid_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
self.grid_layout.setSpacing(2)
|
||||||
|
|
||||||
|
# self.scroll_area = QScrollArea() ... Moved to ImagePane
|
||||||
|
# self.canvas = FaceCanvas(self) ... Moved to ImagePane
|
||||||
|
# self.scroll_area.setWidget(self.canvas)
|
||||||
|
|
||||||
self.canvas = FaceCanvas(self)
|
|
||||||
self.scroll_area.setWidget(self.canvas)
|
|
||||||
|
|
||||||
self.filmstrip = FilmStripWidget(self.controller)
|
self.filmstrip = FilmStripWidget(self.controller)
|
||||||
self.filmstrip.setSpacing(2)
|
self.filmstrip.setSpacing(2)
|
||||||
@@ -1262,7 +1434,7 @@ class ImageViewer(QWidget):
|
|||||||
center_layout = QVBoxLayout(center_pane)
|
center_layout = QVBoxLayout(center_pane)
|
||||||
center_layout.setContentsMargins(0, 0, 0, 0)
|
center_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
center_layout.setSpacing(0)
|
center_layout.setSpacing(0)
|
||||||
center_layout.addWidget(self.scroll_area)
|
center_layout.addWidget(self.view_container)
|
||||||
center_layout.addWidget(self.status_bar_container)
|
center_layout.addWidget(self.status_bar_container)
|
||||||
|
|
||||||
self.filmstrip.setFixedWidth(120)
|
self.filmstrip.setFixedWidth(120)
|
||||||
@@ -1304,10 +1476,10 @@ class ImageViewer(QWidget):
|
|||||||
|
|
||||||
if filmstrip_position == 'top':
|
if filmstrip_position == 'top':
|
||||||
self.layout.addWidget(self.filmstrip)
|
self.layout.addWidget(self.filmstrip)
|
||||||
self.layout.addWidget(self.scroll_area)
|
self.layout.addWidget(self.view_container)
|
||||||
self.layout.addWidget(self.status_bar_container)
|
self.layout.addWidget(self.status_bar_container)
|
||||||
else: # bottom
|
else: # bottom
|
||||||
self.layout.addWidget(self.scroll_area)
|
self.layout.addWidget(self.view_container)
|
||||||
self.layout.addWidget(self.filmstrip)
|
self.layout.addWidget(self.filmstrip)
|
||||||
self.layout.addWidget(self.status_bar_container)
|
self.layout.addWidget(self.status_bar_container)
|
||||||
|
|
||||||
@@ -1330,6 +1502,16 @@ class ImageViewer(QWidget):
|
|||||||
self.slideshow_manager = SlideshowManager(self)
|
self.slideshow_manager = SlideshowManager(self)
|
||||||
self.zoom_manager = ZoomManager(self)
|
self.zoom_manager = ZoomManager(self)
|
||||||
|
|
||||||
|
# Connect viewer-level zoom manager (triggered by shortcuts) to sync
|
||||||
|
self.zoom_manager.zoomed.connect(self._sync_zoom)
|
||||||
|
|
||||||
|
# Highlight frame for active pane
|
||||||
|
self.highlight = HighlightWidget(self.view_container)
|
||||||
|
|
||||||
|
# Initialize first pane
|
||||||
|
self.add_pane(image_list, current_index, initial_tags, initial_rating)
|
||||||
|
self.set_active_pane(self.panes[0])
|
||||||
|
|
||||||
# Load image
|
# Load image
|
||||||
if restore_config:
|
if restore_config:
|
||||||
# If restoring a layout, don't auto-fit to screen. Instead, use
|
# If restoring a layout, don't auto-fit to screen. Instead, use
|
||||||
@@ -1341,6 +1523,140 @@ class ImageViewer(QWidget):
|
|||||||
self.populate_filmstrip()
|
self.populate_filmstrip()
|
||||||
self.load_and_fit_image()
|
self.load_and_fit_image()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def controller(self):
|
||||||
|
return self.active_pane.controller if self.active_pane else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def canvas(self):
|
||||||
|
return self.active_pane.canvas if self.active_pane else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scroll_area(self):
|
||||||
|
return self.active_pane.scroll_area if self.active_pane else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def movie(self):
|
||||||
|
return self.active_pane.movie if self.active_pane else None
|
||||||
|
|
||||||
|
def add_pane(self, image_list, index, initial_tags, initial_rating):
|
||||||
|
pane = ImagePane(self, self.cache, image_list, index, initial_tags, initial_rating)
|
||||||
|
self.panes.append(pane)
|
||||||
|
self.update_grid_layout()
|
||||||
|
return pane
|
||||||
|
|
||||||
|
def set_active_pane(self, pane):
|
||||||
|
if pane in self.panes:
|
||||||
|
# Disconnect signals from previous active pane to avoid double-syncing
|
||||||
|
if self.active_pane:
|
||||||
|
try:
|
||||||
|
self.active_pane.scrolled.disconnect(self._sync_scroll)
|
||||||
|
self.active_pane.zoom_manager.zoomed.disconnect(self._sync_zoom)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # Signal wasn't connected
|
||||||
|
|
||||||
|
self.active_pane = pane
|
||||||
|
|
||||||
|
# Connect new active pane signals
|
||||||
|
pane.scrolled.connect(self._sync_scroll)
|
||||||
|
pane.zoom_manager.zoomed.connect(self._sync_zoom)
|
||||||
|
|
||||||
|
self.filmstrip.controller = pane.controller
|
||||||
|
self.populate_filmstrip()
|
||||||
|
self.sync_filmstrip_selection(pane.controller.index)
|
||||||
|
self.update_status_bar()
|
||||||
|
self.update_highlight()
|
||||||
|
|
||||||
|
def _sync_scroll(self, x_pct, y_pct):
|
||||||
|
if len(self.panes) > 1 and self.panes_linked:
|
||||||
|
for pane in self.panes:
|
||||||
|
if pane != self.active_pane:
|
||||||
|
pane.set_scroll_relative(x_pct, y_pct)
|
||||||
|
|
||||||
|
def _sync_zoom(self, factor):
|
||||||
|
if len(self.panes) > 1 and self.panes_linked:
|
||||||
|
for pane in self.panes:
|
||||||
|
if pane != self.active_pane:
|
||||||
|
pane.controller.zoom_factor = factor
|
||||||
|
pane.update_view(resize_win=False)
|
||||||
|
# Re-apply relative scroll after zoom changes bounds
|
||||||
|
if self.active_pane:
|
||||||
|
h_bar = self.active_pane.scroll_area.horizontalScrollBar()
|
||||||
|
v_bar = self.active_pane.scroll_area.verticalScrollBar()
|
||||||
|
h_max = h_bar.maximum()
|
||||||
|
v_max = v_bar.maximum()
|
||||||
|
if h_max > 0 or v_max > 0:
|
||||||
|
x_pct = h_bar.value() / h_max if h_max > 0 else 0
|
||||||
|
y_pct = v_bar.value() / v_max if v_max > 0 else 0
|
||||||
|
pane.set_scroll_relative(x_pct, y_pct)
|
||||||
|
|
||||||
|
def update_grid_layout(self):
|
||||||
|
# Clear layout
|
||||||
|
for i in reversed(range(self.grid_layout.count())):
|
||||||
|
self.grid_layout.itemAt(i).widget().setParent(None)
|
||||||
|
|
||||||
|
count = len(self.panes)
|
||||||
|
if count == 1:
|
||||||
|
self.grid_layout.addWidget(self.panes[0], 0, 0)
|
||||||
|
elif count == 2:
|
||||||
|
self.grid_layout.addWidget(self.panes[0], 0, 0)
|
||||||
|
self.grid_layout.addWidget(self.panes[1], 0, 1)
|
||||||
|
elif count >= 3:
|
||||||
|
# 2x2 grid
|
||||||
|
for i, pane in enumerate(self.panes):
|
||||||
|
row = i // 2
|
||||||
|
col = i % 2
|
||||||
|
self.grid_layout.addWidget(pane, row, col)
|
||||||
|
|
||||||
|
self.update_highlight()
|
||||||
|
|
||||||
|
def set_comparison_mode(self, count):
|
||||||
|
current_panes = len(self.panes)
|
||||||
|
if count == current_panes:
|
||||||
|
return
|
||||||
|
|
||||||
|
if count > current_panes:
|
||||||
|
# Add panes
|
||||||
|
base_controller = self.active_pane.controller
|
||||||
|
start_idx = base_controller.index
|
||||||
|
img_list = base_controller.image_list
|
||||||
|
for i in range(count - current_panes):
|
||||||
|
new_idx = (start_idx + i + 1) % len(img_list)
|
||||||
|
pane = self.add_pane(img_list, new_idx, None, 0) # Metadata will load
|
||||||
|
pane.load_and_fit_image()
|
||||||
|
else:
|
||||||
|
# Remove panes (keep active if possible, else keep first)
|
||||||
|
while len(self.panes) > count:
|
||||||
|
# Remove the last one
|
||||||
|
pane = self.panes.pop()
|
||||||
|
pane.cleanup()
|
||||||
|
if pane == self.active_pane:
|
||||||
|
self.set_active_pane(self.panes[0])
|
||||||
|
self.update_grid_layout()
|
||||||
|
|
||||||
|
# Restore default behavior (auto-resize) if we go back to single view
|
||||||
|
if count == 1 and self.active_pane:
|
||||||
|
# Allow layout to settle before resizing window to ensure accurate sizing
|
||||||
|
QTimer.singleShot(0, lambda: self.active_pane.update_view(resize_win=True))
|
||||||
|
|
||||||
|
def toggle_link_panes(self):
|
||||||
|
"""Toggles the synchronized zoom/scroll for comparison mode."""
|
||||||
|
self.panes_linked = not self.panes_linked
|
||||||
|
self.update_status_bar()
|
||||||
|
|
||||||
|
def update_highlight(self):
|
||||||
|
if len(self.panes) > 1 and self.active_pane:
|
||||||
|
self.highlight.show()
|
||||||
|
self.highlight.raise_()
|
||||||
|
# Adjust geometry to active pane
|
||||||
|
self.highlight.setGeometry(self.active_pane.geometry())
|
||||||
|
else:
|
||||||
|
self.highlight.hide()
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self.update_highlight()
|
||||||
|
|
||||||
def reset_inactivity_timer(self):
|
def reset_inactivity_timer(self):
|
||||||
"""Resets the inactivity timer and restores controls visibility."""
|
"""Resets the inactivity timer and restores controls visibility."""
|
||||||
if self.isFullScreen():
|
if self.isFullScreen():
|
||||||
@@ -1406,6 +1722,10 @@ class ImageViewer(QWidget):
|
|||||||
"toggle_visibility": self.toggle_main_window_visibility,
|
"toggle_visibility": self.toggle_main_window_visibility,
|
||||||
"toggle_crop": self.toggle_crop_mode,
|
"toggle_crop": self.toggle_crop_mode,
|
||||||
"save_crop": self.save_cropped_image,
|
"save_crop": self.save_cropped_image,
|
||||||
|
"compare_1": lambda: self.set_comparison_mode(1),
|
||||||
|
"compare_2": lambda: self.set_comparison_mode(2),
|
||||||
|
"compare_4": lambda: self.set_comparison_mode(4),
|
||||||
|
"link_panes": self.toggle_link_panes,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _execute_action(self, action):
|
def _execute_action(self, action):
|
||||||
@@ -1563,6 +1883,7 @@ class ImageViewer(QWidget):
|
|||||||
"""
|
"""
|
||||||
kwinoutputconfig.json
|
kwinoutputconfig.json
|
||||||
"""
|
"""
|
||||||
|
return self.screen().availableGeometry().width(), self.screen().availableGeometry().height()
|
||||||
# We run kscreen-doctor and look for the primary monitor line.
|
# We run kscreen-doctor and look for the primary monitor line.
|
||||||
if FORCE_X11:
|
if FORCE_X11:
|
||||||
if os.path.exists(KWINOUTPUTCONFIG_PATH):
|
if os.path.exists(KWINOUTPUTCONFIG_PATH):
|
||||||
@@ -1621,59 +1942,55 @@ class ImageViewer(QWidget):
|
|||||||
screen_geo = self.screen().availableGeometry()
|
screen_geo = self.screen().availableGeometry()
|
||||||
return screen_geo.width(), screen_geo.height()
|
return screen_geo.width(), screen_geo.height()
|
||||||
|
|
||||||
def load_and_fit_image(self, restore_config=None):
|
def load_and_fit_image_for_pane(self, pane, restore_config=None):
|
||||||
"""
|
"""
|
||||||
Loads the current image and calculates an appropriate initial zoom level.
|
Logic for loading image into a specific pane.
|
||||||
|
|
||||||
If restoring from a config, it applies the saved zoom and scroll.
|
|
||||||
Otherwise, it fits the image to the screen, respecting a defined ratio.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
restore_config (dict, optional): State dictionary to restore from.
|
|
||||||
"""
|
"""
|
||||||
success, reloaded = self.controller.load_image()
|
success, reloaded = pane.controller.load_image()
|
||||||
|
|
||||||
if not success:
|
if not success:
|
||||||
if self.movie:
|
if pane.movie:
|
||||||
self.movie.stop()
|
pane.movie.stop()
|
||||||
self.movie = None
|
pane.movie = None
|
||||||
self.canvas.setPixmap(QPixmap())
|
pane.canvas.setPixmap(QPixmap())
|
||||||
self.update_status_bar()
|
if pane == self.active_pane:
|
||||||
|
self.update_status_bar()
|
||||||
return
|
return
|
||||||
|
|
||||||
path = self.controller.get_current_path()
|
path = pane.controller.get_current_path()
|
||||||
|
|
||||||
if reloaded:
|
if reloaded:
|
||||||
if self.movie:
|
if pane.movie:
|
||||||
self.movie.stop()
|
pane.movie.stop()
|
||||||
self.movie = None
|
pane.movie = None
|
||||||
self.canvas.crop_rect = QRect() # Clear crop rect on new image
|
pane.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:
|
||||||
self.movie = QMovie(path)
|
pane.movie = QMovie(path)
|
||||||
self.movie.setCacheMode(QMovie.CacheAll)
|
pane.movie.setCacheMode(QMovie.CacheAll)
|
||||||
self.movie.frameChanged.connect(self._on_movie_frame)
|
pane.movie.frameChanged.connect(
|
||||||
self.movie.start()
|
lambda: self._on_movie_frame_for_pane(pane))
|
||||||
|
pane.movie.start()
|
||||||
|
|
||||||
self.reset_inactivity_timer()
|
self.reset_inactivity_timer()
|
||||||
if restore_config:
|
if restore_config:
|
||||||
self.controller.zoom_factor = restore_config.get("zoom", 1.0)
|
pane.controller.zoom_factor = restore_config.get("zoom", 1.0)
|
||||||
self.controller.rotation = restore_config.get("rotation", 0)
|
pane.controller.rotation = restore_config.get("rotation", 0)
|
||||||
self.controller.show_faces = restore_config.get(
|
pane.controller.show_faces = restore_config.get(
|
||||||
"show_faces", self.controller.show_faces)
|
"show_faces", pane.controller.show_faces)
|
||||||
self.status_bar_container.setVisible(
|
self.status_bar_container.setVisible(
|
||||||
restore_config.get("status_bar_visible", False))
|
restore_config.get("status_bar_visible", False))
|
||||||
self.filmstrip.setVisible(
|
self.filmstrip.setVisible(
|
||||||
restore_config.get("filmstrip_visible", False))
|
restore_config.get("filmstrip_visible", False))
|
||||||
if self.filmstrip.isVisible():
|
if self.filmstrip.isVisible():
|
||||||
self.populate_filmstrip()
|
self.populate_filmstrip()
|
||||||
self.update_view(resize_win=False)
|
pane.update_view(resize_win=False)
|
||||||
QTimer.singleShot(0, lambda: self.restore_scroll(restore_config))
|
QTimer.singleShot(0, lambda: self.restore_scroll_for_pane(pane, restore_config))
|
||||||
elif reloaded:
|
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 = pane.scroll_area.viewport()
|
||||||
available_w = viewport.width()
|
available_w = viewport.width()
|
||||||
available_h = viewport.height()
|
available_h = viewport.height()
|
||||||
should_resize = False
|
should_resize = False
|
||||||
@@ -1687,7 +2004,7 @@ class ImageViewer(QWidget):
|
|||||||
else:
|
else:
|
||||||
# Tried to guess
|
# Tried to guess
|
||||||
screen_width, screen_height = self.get_desktop_resolution()
|
screen_width, screen_height = self.get_desktop_resolution()
|
||||||
self._first_load = False
|
if pane == self.panes[0]: self._first_load = False
|
||||||
else:
|
else:
|
||||||
screen_geo = self.screen().availableGeometry()
|
screen_geo = self.screen().availableGeometry()
|
||||||
screen_width = screen_geo.width()
|
screen_width = screen_geo.width()
|
||||||
@@ -1720,8 +2037,14 @@ class ImageViewer(QWidget):
|
|||||||
self.update_view(resize_win=False)
|
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(
|
if pane == self.active_pane:
|
||||||
0, lambda: self.sync_filmstrip_selection(self.controller.index))
|
QTimer.singleShot(
|
||||||
|
0, lambda: self.sync_filmstrip_selection(pane.controller.index))
|
||||||
|
|
||||||
|
def load_and_fit_image(self, restore_config=None):
|
||||||
|
"""Proxy method for active pane."""
|
||||||
|
if self.active_pane:
|
||||||
|
self.active_pane.load_and_fit_image(restore_config)
|
||||||
|
|
||||||
@Slot(list)
|
@Slot(list)
|
||||||
def update_image_list(self, new_list):
|
def update_image_list(self, new_list):
|
||||||
@@ -1803,30 +2126,29 @@ class ImageViewer(QWidget):
|
|||||||
if self.controller.pixmap_original.isNull():
|
if self.controller.pixmap_original.isNull():
|
||||||
return
|
return
|
||||||
self.controller.rotate(rotation)
|
self.controller.rotate(rotation)
|
||||||
self.update_view(resize_win)
|
if self.active_pane:
|
||||||
|
self.active_pane.update_view(resize_win)
|
||||||
|
|
||||||
def update_view(self, resize_win=False):
|
def update_view_for_pane(self, pane, resize_win=False):
|
||||||
"""
|
"""
|
||||||
Updates the canvas with the current pixmap, applying zoom and rotation.
|
Updates the canvas with the current pixmap for a specific pane.
|
||||||
|
|
||||||
This is the main rendering method. It gets the transformed pixmap from
|
|
||||||
the controller and displays it.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
resize_win (bool): If True, the window resizes to fit the image.
|
|
||||||
"""
|
"""
|
||||||
pixmap = self.controller.get_display_pixmap()
|
pixmap = pane.controller.get_display_pixmap()
|
||||||
if pixmap.isNull():
|
if pixmap.isNull():
|
||||||
return
|
return
|
||||||
|
|
||||||
self.canvas.setPixmap(pixmap)
|
pane.canvas.setPixmap(pixmap)
|
||||||
self.canvas.adjustSize()
|
pane.canvas.adjustSize()
|
||||||
|
|
||||||
|
# Disable resizing window in comparison mode (more than 1 pane)
|
||||||
|
if len(self.panes) > 1:
|
||||||
|
resize_win = False
|
||||||
|
|
||||||
if resize_win and APP_CONFIG.get("viewer_auto_resize_window",
|
if resize_win and APP_CONFIG.get("viewer_auto_resize_window",
|
||||||
VIEWER_AUTO_RESIZE_WINDOW_DEFAULT):
|
VIEWER_AUTO_RESIZE_WINDOW_DEFAULT):
|
||||||
# Adjust window size to content
|
# Adjust window size to content
|
||||||
content_w = self.canvas.width()
|
content_w = pane.canvas.width()
|
||||||
content_h = self.canvas.height()
|
content_h = pane.canvas.height()
|
||||||
|
|
||||||
filmstrip_position = self.main_win.filmstrip_position \
|
filmstrip_position = self.main_win.filmstrip_position \
|
||||||
if self.main_win else 'bottom'
|
if self.main_win else 'bottom'
|
||||||
@@ -1859,8 +2181,13 @@ class ImageViewer(QWidget):
|
|||||||
target_h = min(target_h, avail_geo.height())
|
target_h = min(target_h, avail_geo.height())
|
||||||
self.resize(target_w, target_h)
|
self.resize(target_w, target_h)
|
||||||
|
|
||||||
self.update_title()
|
if pane == self.active_pane:
|
||||||
self.update_status_bar()
|
self.update_title()
|
||||||
|
self.update_status_bar()
|
||||||
|
|
||||||
|
def update_view(self, resize_win=False):
|
||||||
|
if self.active_pane:
|
||||||
|
self.active_pane.update_view(resize_win)
|
||||||
|
|
||||||
def rename_current_image(self):
|
def rename_current_image(self):
|
||||||
"""
|
"""
|
||||||
@@ -1919,16 +2246,17 @@ class ImageViewer(QWidget):
|
|||||||
|
|
||||||
def toggle_crop_mode(self):
|
def toggle_crop_mode(self):
|
||||||
"""Toggles the crop selection mode."""
|
"""Toggles the crop selection mode."""
|
||||||
self.crop_mode = not self.crop_mode
|
if self.active_pane:
|
||||||
self.canvas.crop_rect = QRect()
|
self.active_pane.crop_mode = not self.active_pane.crop_mode
|
||||||
self.canvas.update()
|
self.active_pane.canvas.crop_rect = QRect()
|
||||||
|
self.active_pane.canvas.update()
|
||||||
|
|
||||||
if self.crop_mode:
|
if self.active_pane.crop_mode:
|
||||||
self.setCursor(Qt.CrossCursor)
|
self.active_pane.canvas.setCursor(Qt.CrossCursor)
|
||||||
self.sb_info_label.setText(f"{self.sb_info_label.text()} [CROP]")
|
self.sb_info_label.setText(f"{self.sb_info_label.text()} [CROP]")
|
||||||
else:
|
else:
|
||||||
self.setCursor(Qt.ArrowCursor)
|
self.active_pane.canvas.setCursor(Qt.ArrowCursor)
|
||||||
self.update_status_bar()
|
self.update_status_bar()
|
||||||
|
|
||||||
def show_crop_menu(self, global_pos):
|
def show_crop_menu(self, global_pos):
|
||||||
"""Shows a context menu for the crop selection."""
|
"""Shows a context menu for the crop selection."""
|
||||||
@@ -1940,19 +2268,20 @@ class ImageViewer(QWidget):
|
|||||||
if res == save_action:
|
if res == save_action:
|
||||||
self.save_cropped_image()
|
self.save_cropped_image()
|
||||||
elif res == cancel_action:
|
elif res == cancel_action:
|
||||||
self.canvas.crop_rect = QRect()
|
if self.active_pane:
|
||||||
self.canvas.update()
|
self.active_pane.canvas.crop_rect = QRect()
|
||||||
|
self.active_pane.canvas.update()
|
||||||
|
|
||||||
def save_cropped_image(self):
|
def save_cropped_image(self):
|
||||||
"""Saves the area currently selected in crop mode as a new image."""
|
"""Saves the area currently selected in crop mode as a new image."""
|
||||||
if not self.crop_mode or self.canvas.crop_rect.isNull():
|
if not self.active_pane or not self.active_pane.crop_mode or self.active_pane.canvas.crop_rect.isNull():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get normalized coordinates from the canvas rect
|
# Get normalized coordinates from the canvas rect
|
||||||
nx, ny, nw, nh = self.canvas.map_to_source(self.canvas.crop_rect)
|
nx, ny, nw, nh = self.active_pane.canvas.map_to_source(self.active_pane.canvas.crop_rect)
|
||||||
|
|
||||||
# Use original pixmap to extract high-quality crop
|
# Use original pixmap to extract high-quality crop
|
||||||
orig = self.controller.pixmap_original
|
orig = self.active_pane.controller.pixmap_original
|
||||||
if orig.isNull():
|
if orig.isNull():
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1983,8 +2312,8 @@ class ImageViewer(QWidget):
|
|||||||
if file_name:
|
if file_name:
|
||||||
cropped.save(file_name)
|
cropped.save(file_name)
|
||||||
# Optionally stay in crop mode or exit
|
# Optionally stay in crop mode or exit
|
||||||
self.canvas.crop_rect = QRect()
|
self.active_pane.canvas.crop_rect = QRect()
|
||||||
self.canvas.update()
|
self.active_pane.canvas.update()
|
||||||
|
|
||||||
def update_title(self):
|
def update_title(self):
|
||||||
"""Updates the window title with the current image name."""
|
"""Updates the window title with the current image name."""
|
||||||
@@ -2016,7 +2345,12 @@ class ImageViewer(QWidget):
|
|||||||
w = self.controller.pixmap_original.width()
|
w = self.controller.pixmap_original.width()
|
||||||
h = self.controller.pixmap_original.height()
|
h = self.controller.pixmap_original.height()
|
||||||
zoom = int(self.controller.zoom_factor * 100)
|
zoom = int(self.controller.zoom_factor * 100)
|
||||||
self.sb_info_label.setText(f"{w} x {h} px | {zoom}%")
|
info_text = f"{w} x {h} px | {zoom}%"
|
||||||
|
|
||||||
|
if len(self.panes) > 1:
|
||||||
|
info_text += " [Linked]" if self.panes_linked else " [Unlinked]"
|
||||||
|
|
||||||
|
self.sb_info_label.setText(info_text)
|
||||||
|
|
||||||
# Use tags from metadata if provided (priority to avoid race conditions),
|
# Use tags from metadata if provided (priority to avoid race conditions),
|
||||||
# otherwise fallback to controller's internal state.
|
# otherwise fallback to controller's internal state.
|
||||||
@@ -2039,15 +2373,15 @@ class ImageViewer(QWidget):
|
|||||||
if self.main_win:
|
if self.main_win:
|
||||||
self.main_win.update_metadata_for_path(path, metadata)
|
self.main_win.update_metadata_for_path(path, metadata)
|
||||||
|
|
||||||
def restore_scroll(self, config):
|
def restore_scroll_for_pane(self, pane, config):
|
||||||
"""
|
"""
|
||||||
Applies the saved scrollbar positions from a layout configuration.
|
Applies the saved scrollbar positions from a layout configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config (dict): The layout configuration dictionary.
|
config (dict): The layout configuration dictionary.
|
||||||
"""
|
"""
|
||||||
self.scroll_area.horizontalScrollBar().setValue(config.get("scroll_x", 0))
|
pane.scroll_area.horizontalScrollBar().setValue(config.get("scroll_x", 0))
|
||||||
self.scroll_area.verticalScrollBar().setValue(config.get("scroll_y", 0))
|
pane.scroll_area.verticalScrollBar().setValue(config.get("scroll_y", 0))
|
||||||
|
|
||||||
def get_state(self):
|
def get_state(self):
|
||||||
"""
|
"""
|
||||||
@@ -2069,8 +2403,8 @@ class ImageViewer(QWidget):
|
|||||||
"show_faces": self.controller.show_faces,
|
"show_faces": self.controller.show_faces,
|
||||||
"flip_h": self.controller.flip_h,
|
"flip_h": self.controller.flip_h,
|
||||||
"flip_v": self.controller.flip_v,
|
"flip_v": self.controller.flip_v,
|
||||||
"scroll_x": self.scroll_area.horizontalScrollBar().value(),
|
"scroll_x": self.scroll_area.horizontalScrollBar().value() if self.scroll_area else 0,
|
||||||
"scroll_y": self.scroll_area.verticalScrollBar().value(),
|
"scroll_y": self.scroll_area.verticalScrollBar().value() if self.scroll_area else 0,
|
||||||
"status_bar_visible": self.status_bar_container.isVisible(),
|
"status_bar_visible": self.status_bar_container.isVisible(),
|
||||||
"filmstrip_visible": self.filmstrip.isVisible()
|
"filmstrip_visible": self.filmstrip.isVisible()
|
||||||
}
|
}
|
||||||
@@ -2142,10 +2476,10 @@ 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 _get_clicked_face(self, pos):
|
def _get_clicked_face_for_pane(self, pane, 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 pane.controller.faces:
|
||||||
rect = self.canvas.map_from_source(face)
|
rect = pane.canvas.map_from_source(face)
|
||||||
if rect.contains(pos):
|
if rect.contains(pos):
|
||||||
return face
|
return face
|
||||||
return None
|
return None
|
||||||
@@ -2158,8 +2492,8 @@ class ImageViewer(QWidget):
|
|||||||
if not self.controller.show_faces:
|
if not self.controller.show_faces:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
pos = self.canvas.mapFromGlobal(event.globalPos())
|
pos = self.canvas.mapFromGlobal(event.globalPos()) if self.canvas else QPoint()
|
||||||
clicked_face = self._get_clicked_face(pos)
|
clicked_face = self.active_pane._get_clicked_face(pos) if self.active_pane else None
|
||||||
|
|
||||||
if not clicked_face:
|
if not clicked_face:
|
||||||
return False
|
return False
|
||||||
@@ -2177,7 +2511,8 @@ class ImageViewer(QWidget):
|
|||||||
for f in self.controller.faces)
|
for f in self.controller.faces)
|
||||||
if not has_other:
|
if not has_other:
|
||||||
self.controller.toggle_tag(face_name, False)
|
self.controller.toggle_tag(face_name, False)
|
||||||
self.canvas.update()
|
if self.canvas:
|
||||||
|
self.canvas.update()
|
||||||
elif res == action_ren:
|
elif res == action_ren:
|
||||||
self.rename_face(clicked_face)
|
self.rename_face(clicked_face)
|
||||||
return True
|
return True
|
||||||
@@ -2234,7 +2569,8 @@ class ImageViewer(QWidget):
|
|||||||
# Save changes and add new tag
|
# Save changes and add new tag
|
||||||
self.controller.save_faces()
|
self.controller.save_faces()
|
||||||
self.controller.toggle_tag(new_full_tag, True)
|
self.controller.toggle_tag(new_full_tag, True)
|
||||||
self.canvas.update()
|
if self.canvas:
|
||||||
|
self.canvas.update()
|
||||||
|
|
||||||
def toggle_main_window_visibility(self):
|
def toggle_main_window_visibility(self):
|
||||||
"""Toggles the visibility of the main window."""
|
"""Toggles the visibility of the main window."""
|
||||||
@@ -2301,8 +2637,15 @@ class ImageViewer(QWidget):
|
|||||||
"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",
|
||||||
"icon": "transform-crop", "checkable": True,
|
"icon": "transform-crop", "checkable": True}, # checked updated later
|
||||||
"checked": self.crop_mode},
|
"separator",
|
||||||
|
{"text": UITexts.VIEWER_MENU_COMPARE, "icon": "view-grid", "submenu": [
|
||||||
|
{"text": UITexts.VIEWER_MENU_COMPARE_1, "action": "compare_1", "icon": "view-restore"},
|
||||||
|
{"text": UITexts.VIEWER_MENU_COMPARE_2, "action": "compare_2", "icon": "view-split-left-right"},
|
||||||
|
{"text": UITexts.VIEWER_MENU_COMPARE_4, "action": "compare_4", "icon": "view-grid"},
|
||||||
|
"separator",
|
||||||
|
{"text": UITexts.VIEWER_MENU_LINK_PANES, "action": "link_panes", "icon": "object-link", "checkable": True, "checked": self.panes_linked}
|
||||||
|
]},
|
||||||
"separator",
|
"separator",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2465,9 +2808,9 @@ class ImageViewer(QWidget):
|
|||||||
Args:
|
Args:
|
||||||
event (QContextMenuEvent): The context menu event.
|
event (QContextMenuEvent): The context menu event.
|
||||||
"""
|
"""
|
||||||
if self.crop_mode and not self.canvas.crop_rect.isNull():
|
if self.active_pane and self.active_pane.crop_mode and not self.active_pane.canvas.crop_rect.isNull():
|
||||||
pos = self.canvas.mapFromGlobal(event.globalPos())
|
pos = self.active_pane.canvas.mapFromGlobal(event.globalPos())
|
||||||
if self.canvas.crop_rect.contains(pos):
|
if self.active_pane.canvas.crop_rect.contains(pos):
|
||||||
self.show_crop_menu(event.globalPos())
|
self.show_crop_menu(event.globalPos())
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -2505,12 +2848,14 @@ class ImageViewer(QWidget):
|
|||||||
self.toggle_faces()
|
self.toggle_faces()
|
||||||
|
|
||||||
self.controller.faces.append(new_face)
|
self.controller.faces.append(new_face)
|
||||||
self.canvas.update()
|
if self.canvas:
|
||||||
|
self.canvas.update()
|
||||||
|
|
||||||
w = self.canvas.width()
|
w = self.canvas.width() if self.canvas else 0
|
||||||
h = self.canvas.height()
|
h = self.canvas.height() if self.canvas else 0
|
||||||
self.scroll_area.ensureVisible(int(new_face.get('x', 0) * w),
|
if self.scroll_area:
|
||||||
int(new_face.get('y', 0) * h), 50, 50)
|
self.scroll_area.ensureVisible(int(new_face.get('x', 0) * w),
|
||||||
|
int(new_face.get('y', 0) * h), 50, 50)
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
|
|
||||||
history = self.main_win.face_names_history if self.main_win else []
|
history = self.main_win.face_names_history if self.main_win else []
|
||||||
@@ -2526,7 +2871,8 @@ class ImageViewer(QWidget):
|
|||||||
else:
|
else:
|
||||||
# If user cancels, remove the face that was temporarily added
|
# If user cancels, remove the face that was temporarily added
|
||||||
self.controller.faces.pop()
|
self.controller.faces.pop()
|
||||||
self.canvas.update()
|
if self.canvas:
|
||||||
|
self.canvas.update()
|
||||||
|
|
||||||
if added_count > 0:
|
if added_count > 0:
|
||||||
self.controller.save_faces()
|
self.controller.save_faces()
|
||||||
@@ -2559,12 +2905,14 @@ class ImageViewer(QWidget):
|
|||||||
self.toggle_faces()
|
self.toggle_faces()
|
||||||
|
|
||||||
self.controller.faces.append(new_pet)
|
self.controller.faces.append(new_pet)
|
||||||
self.canvas.update()
|
if self.canvas:
|
||||||
|
self.canvas.update()
|
||||||
|
|
||||||
w = self.canvas.width()
|
w = self.canvas.width() if self.canvas else 0
|
||||||
h = self.canvas.height()
|
h = self.canvas.height() if self.canvas else 0
|
||||||
self.scroll_area.ensureVisible(int(new_pet.get('x', 0) * w),
|
if self.scroll_area:
|
||||||
int(new_pet.get('y', 0) * h), 50, 50)
|
self.scroll_area.ensureVisible(int(new_pet.get('x', 0) * w),
|
||||||
|
int(new_pet.get('y', 0) * h), 50, 50)
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
|
|
||||||
history = self.main_win.pet_names_history if self.main_win else []
|
history = self.main_win.pet_names_history if self.main_win else []
|
||||||
@@ -2579,7 +2927,8 @@ class ImageViewer(QWidget):
|
|||||||
added_count += 1
|
added_count += 1
|
||||||
else:
|
else:
|
||||||
self.controller.faces.pop()
|
self.controller.faces.pop()
|
||||||
self.canvas.update()
|
if self.canvas:
|
||||||
|
self.canvas.update()
|
||||||
|
|
||||||
if added_count > 0:
|
if added_count > 0:
|
||||||
self.controller.save_faces()
|
self.controller.save_faces()
|
||||||
@@ -2612,12 +2961,14 @@ class ImageViewer(QWidget):
|
|||||||
self.toggle_faces()
|
self.toggle_faces()
|
||||||
|
|
||||||
self.controller.faces.append(new_body)
|
self.controller.faces.append(new_body)
|
||||||
self.canvas.update()
|
if self.canvas:
|
||||||
|
self.canvas.update()
|
||||||
|
|
||||||
w = self.canvas.width()
|
w = self.canvas.width() if self.canvas else 0
|
||||||
h = self.canvas.height()
|
h = self.canvas.height() if self.canvas else 0
|
||||||
self.scroll_area.ensureVisible(int(new_body.get('x', 0) * w),
|
if self.scroll_area:
|
||||||
int(new_body.get('y', 0) * h), 50, 50)
|
self.scroll_area.ensureVisible(int(new_body.get('x', 0) * w),
|
||||||
|
int(new_body.get('y', 0) * h), 50, 50)
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
|
|
||||||
# For bodies, we typically don't ask for a name immediately unless desired
|
# For bodies, we typically don't ask for a name immediately unless desired
|
||||||
@@ -2634,7 +2985,8 @@ class ImageViewer(QWidget):
|
|||||||
added_count += 1
|
added_count += 1
|
||||||
else:
|
else:
|
||||||
self.controller.faces.pop()
|
self.controller.faces.pop()
|
||||||
self.canvas.update()
|
if self.canvas:
|
||||||
|
self.canvas.update()
|
||||||
|
|
||||||
if added_count > 0:
|
if added_count > 0:
|
||||||
self.controller.save_faces()
|
self.controller.save_faces()
|
||||||
@@ -2659,11 +3011,14 @@ class ImageViewer(QWidget):
|
|||||||
|
|
||||||
def toggle_faces(self):
|
def toggle_faces(self):
|
||||||
"""Toggles the display of face regions."""
|
"""Toggles the display of face regions."""
|
||||||
self.controller.show_faces = not self.controller.show_faces
|
for pane in self.panes:
|
||||||
|
pane.controller.show_faces = not pane.controller.show_faces
|
||||||
|
pane.canvas.update()
|
||||||
|
|
||||||
if self.main_win:
|
if self.main_win:
|
||||||
self.main_win.show_faces = self.controller.show_faces
|
if self.active_pane:
|
||||||
|
self.main_win.show_faces = self.active_pane.controller.show_faces
|
||||||
self.main_win.save_config()
|
self.main_win.save_config()
|
||||||
self.canvas.update()
|
|
||||||
|
|
||||||
def show_fast_tag_menu(self):
|
def show_fast_tag_menu(self):
|
||||||
"""Shows a context menu for quickly adding/removing tags."""
|
"""Shows a context menu for quickly adding/removing tags."""
|
||||||
@@ -2697,10 +3052,9 @@ class ImageViewer(QWidget):
|
|||||||
if event.modifiers() & Qt.ControlModifier:
|
if event.modifiers() & Qt.ControlModifier:
|
||||||
# Zoom with Ctrl + Wheel
|
# Zoom with Ctrl + Wheel
|
||||||
if event.angleDelta().y() > 0:
|
if event.angleDelta().y() > 0:
|
||||||
self.controller.zoom_factor *= 1.1
|
self.zoom_manager.zoom(1.1)
|
||||||
else:
|
else:
|
||||||
self.controller.zoom_factor *= 0.9
|
self.zoom_manager.zoom(0.9)
|
||||||
self.update_view(resize_win=True)
|
|
||||||
else:
|
else:
|
||||||
# Navigate next/previous based on configurable speed
|
# Navigate next/previous based on configurable speed
|
||||||
speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT)
|
speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT)
|
||||||
|
|||||||
Reference in New Issue
Block a user