diff --git a/changelog.txt b/changelog.txt index 7acac87..289f046 100644 --- a/changelog.txt +++ b/changelog.txt @@ -2,7 +2,12 @@ v0.9.11 - · Filmstrip fixed · Añadida una nueva área llamada Body. · 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`. Implement a mechanism to dynamically adjust the thread pool size based on system load or user activity. diff --git a/constants.py b/constants.py index 52a5d65..cb7d5ea 100644 --- a/constants.py +++ b/constants.py @@ -167,7 +167,7 @@ if importlib.util.find_spec("mediapipe") is not None: pass 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, "blaze_face_short_range.tflite") @@ -291,6 +291,10 @@ VIEWER_ACTIONS = { "toggle_visibility": ("Show/Hide Main Window", "Window"), "toggle_crop": ("Toggle Crop Mode", "Edit"), "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 = { @@ -319,6 +323,10 @@ DEFAULT_VIEWER_SHORTCUTS = { "toggle_visibility": (Qt.Key_H, Qt.ControlModifier), "toggle_crop": (Qt.Key_C, Qt.NoModifier), "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_SAVE_CROP": "Save Selection...", "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)", "SLIDESHOW_INTERVAL_TITLE": "Slideshow Interval", "SLIDESHOW_INTERVAL_TEXT": "Seconds:", @@ -1164,6 +1177,11 @@ _UI_TEXTS = { "VIEWER_MENU_TAGS": "Etiquetas rápidas", "VIEWER_MENU_CROP": "Modo Recorte", "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_FILTER": "Imágenes (*.jpg *.jpeg *.png *.bmp *.webp)", "SLIDESHOW_INTERVAL_TITLE": "Intervalo de Presentación", @@ -1597,6 +1615,11 @@ _UI_TEXTS = { "VIEWER_MENU_TAGS": "Etiquetas rápidas", "VIEWER_MENU_CROP": "Modo Recorte", "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_FILTER": "Imaxes (*.jpg *.jpeg *.png *.bmp *.webp)", "SLIDESHOW_INTERVAL_TITLE": "Intervalo da Presentación", diff --git a/imagescanner.py b/imagescanner.py index 89c4fa3..86fc82c 100644 --- a/imagescanner.py +++ b/imagescanner.py @@ -44,7 +44,11 @@ from imageviewer import ImageViewer from metadatamanager import XattrManager 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 logger = logging.getLogger(__name__) @@ -371,11 +375,8 @@ class CacheWriter(QThread): # Gather a batch of items # Adaptive batch size: if queue is backing up, increase transaction size # to improve throughput. - if not self._running: - # Flush everything if stopping - batch_limit = len(self._queue) - else: - batch_limit = self._max_size + # Respect max size even during shutdown to avoid OOM or huge transactions + batch_limit = self._max_size batch = [] while self._queue and len(batch) < batch_limit: diff --git a/imageviewer.py b/imageviewer.py index 0ccf1a7..82f9ba2 100644 --- a/imageviewer.py +++ b/imageviewer.py @@ -15,7 +15,7 @@ import json from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QScrollArea, QLabel, QHBoxLayout, QListWidget, - QListWidgetItem, QAbstractItemView, QMenu, QInputDialog, QDialog, QDialogButtonBox, + QListWidgetItem, QAbstractItemView, QMenu, QInputDialog, QDialog, QDialogButtonBox, QGridLayout, QApplication, QMessageBox, QLineEdit, QFileDialog ) from PySide6.QtGui import ( @@ -39,6 +39,13 @@ from imagecontroller import ImageController from widgets import FaceNameInputWidget 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): """A dialog to get a face name using the FaceNameInputWidget.""" @@ -646,7 +653,8 @@ class FaceCanvas(QLabel): def mousePressEvent(self, event): """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: handle = self._hit_test_crop(event.position().toPoint()) if handle: @@ -663,6 +671,10 @@ class FaceCanvas(QLabel): event.accept() return + # Activate the pane on click + if hasattr(self.viewer, 'activate'): + self.viewer.activate() + if self.controller.show_faces and event.button() == Qt.LeftButton: self.start_pos = event.position().toPoint() @@ -697,7 +709,8 @@ class FaceCanvas(QLabel): def mouseMoveEvent(self, event): """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: curr_pos = event.position().toPoint() @@ -963,6 +976,10 @@ class FaceCanvas(QLabel): self.viewer.zoom_manager.zoom_to_rect(clicked_face) event.accept() 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 super().mouseDoubleClickEvent(event) @@ -1033,6 +1050,8 @@ class ZoomManager(QObject): """ Manages zoom calculations and state for the ImageViewer. """ + zoomed = Signal(float) + def __init__(self, viewer): super().__init__(viewer) self.viewer = viewer @@ -1049,7 +1068,9 @@ class ZoomManager(QObject): # so it can update its selection. 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): """ @@ -1083,6 +1104,7 @@ class ZoomManager(QObject): return 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) def calculate_initial_zoom(self, available_w, available_h, is_fullscreen): @@ -1099,6 +1121,8 @@ class ZoomManager(QObject): else: self.viewer.controller.zoom_factor = 1.0 + self.zoomed.emit(self.viewer.controller.zoom_factor) + 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(): @@ -1131,6 +1155,7 @@ class ZoomManager(QObject): new_zoom = min(zoom_w, zoom_h) self.viewer.controller.zoom_factor = new_zoom + self.zoomed.emit(new_zoom) self.viewer.update_view(resize_win=False) # 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)) +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): """ A standalone window for viewing and manipulating a single image. @@ -1204,13 +1376,11 @@ class ImageViewer(QWidget): self._wheel_scroll_accumulator = 0 self.filmstrip_loader = None - self.movie = None - self.controller = ImageController(image_list, current_index, - initial_tags, initial_rating) - if self.main_win: - 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) + # Pane management + self.panes = [] + self.active_pane = None + self.panes_linked = True + self.fast_tag_manager = FastTagManager(self) self._setup_shortcuts() self._setup_actions() @@ -1230,14 +1400,16 @@ class ImageViewer(QWidget): self.layout.setContentsMargins(0, 0, 0, 0) self.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) + # Container for panes (Grid) + self.view_container = QWidget() + self.grid_layout = QGridLayout(self.view_container) + self.grid_layout.setContentsMargins(0, 0, 0, 0) + 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.setSpacing(2) @@ -1262,7 +1434,7 @@ class ImageViewer(QWidget): center_layout = QVBoxLayout(center_pane) center_layout.setContentsMargins(0, 0, 0, 0) center_layout.setSpacing(0) - center_layout.addWidget(self.scroll_area) + center_layout.addWidget(self.view_container) center_layout.addWidget(self.status_bar_container) self.filmstrip.setFixedWidth(120) @@ -1304,10 +1476,10 @@ class ImageViewer(QWidget): if filmstrip_position == 'top': self.layout.addWidget(self.filmstrip) - self.layout.addWidget(self.scroll_area) + self.layout.addWidget(self.view_container) self.layout.addWidget(self.status_bar_container) else: # bottom - self.layout.addWidget(self.scroll_area) + self.layout.addWidget(self.view_container) self.layout.addWidget(self.filmstrip) self.layout.addWidget(self.status_bar_container) @@ -1330,6 +1502,16 @@ class ImageViewer(QWidget): self.slideshow_manager = SlideshowManager(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 if restore_config: # If restoring a layout, don't auto-fit to screen. Instead, use @@ -1341,6 +1523,140 @@ class ImageViewer(QWidget): self.populate_filmstrip() 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): """Resets the inactivity timer and restores controls visibility.""" if self.isFullScreen(): @@ -1406,6 +1722,10 @@ class ImageViewer(QWidget): "toggle_visibility": self.toggle_main_window_visibility, "toggle_crop": self.toggle_crop_mode, "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): @@ -1563,6 +1883,7 @@ class ImageViewer(QWidget): """ kwinoutputconfig.json """ + return self.screen().availableGeometry().width(), self.screen().availableGeometry().height() # We run kscreen-doctor and look for the primary monitor line. if FORCE_X11: if os.path.exists(KWINOUTPUTCONFIG_PATH): @@ -1621,59 +1942,55 @@ class ImageViewer(QWidget): screen_geo = self.screen().availableGeometry() 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. - - 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. + Logic for loading image into a specific pane. """ - success, reloaded = self.controller.load_image() + success, reloaded = pane.controller.load_image() if not success: - if self.movie: - self.movie.stop() - self.movie = None - self.canvas.setPixmap(QPixmap()) - self.update_status_bar() + if pane.movie: + pane.movie.stop() + pane.movie = None + pane.canvas.setPixmap(QPixmap()) + if pane == self.active_pane: + self.update_status_bar() return - path = self.controller.get_current_path() + path = pane.controller.get_current_path() if reloaded: - if self.movie: - self.movie.stop() - self.movie = None - self.canvas.crop_rect = QRect() # Clear crop rect on new image + if pane.movie: + pane.movie.stop() + pane.movie = None + pane.canvas.crop_rect = QRect() # Clear crop rect on new image if path: 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() + pane.movie = QMovie(path) + pane.movie.setCacheMode(QMovie.CacheAll) + pane.movie.frameChanged.connect( + lambda: self._on_movie_frame_for_pane(pane)) + pane.movie.start() self.reset_inactivity_timer() if restore_config: - self.controller.zoom_factor = restore_config.get("zoom", 1.0) - self.controller.rotation = restore_config.get("rotation", 0) - self.controller.show_faces = restore_config.get( - "show_faces", self.controller.show_faces) + pane.controller.zoom_factor = restore_config.get("zoom", 1.0) + pane.controller.rotation = restore_config.get("rotation", 0) + pane.controller.show_faces = restore_config.get( + "show_faces", pane.controller.show_faces) self.status_bar_container.setVisible( restore_config.get("status_bar_visible", False)) self.filmstrip.setVisible( restore_config.get("filmstrip_visible", False)) if self.filmstrip.isVisible(): self.populate_filmstrip() - self.update_view(resize_win=False) - QTimer.singleShot(0, lambda: self.restore_scroll(restore_config)) + pane.update_view(resize_win=False) + QTimer.singleShot(0, lambda: self.restore_scroll_for_pane(pane, restore_config)) elif reloaded: # Calculate zoom to fit the image on the screen if self.isFullScreen(): - viewport = self.scroll_area.viewport() + viewport = pane.scroll_area.viewport() available_w = viewport.width() available_h = viewport.height() should_resize = False @@ -1687,7 +2004,7 @@ class ImageViewer(QWidget): else: # Tried to guess screen_width, screen_height = self.get_desktop_resolution() - self._first_load = False + if pane == self.panes[0]: self._first_load = False else: screen_geo = self.screen().availableGeometry() screen_width = screen_geo.width() @@ -1720,8 +2037,14 @@ class ImageViewer(QWidget): self.update_view(resize_win=False) # Defer sync to ensure layout and scroll area are ready, fixing navigation sync - QTimer.singleShot( - 0, lambda: self.sync_filmstrip_selection(self.controller.index)) + if pane == self.active_pane: + 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) def update_image_list(self, new_list): @@ -1803,30 +2126,29 @@ class ImageViewer(QWidget): if self.controller.pixmap_original.isNull(): return 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. - - 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. + Updates the canvas with the current pixmap for a specific pane. """ - pixmap = self.controller.get_display_pixmap() + pixmap = pane.controller.get_display_pixmap() if pixmap.isNull(): return - self.canvas.setPixmap(pixmap) - self.canvas.adjustSize() + pane.canvas.setPixmap(pixmap) + 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", VIEWER_AUTO_RESIZE_WINDOW_DEFAULT): # Adjust window size to content - content_w = self.canvas.width() - content_h = self.canvas.height() + content_w = pane.canvas.width() + content_h = pane.canvas.height() filmstrip_position = self.main_win.filmstrip_position \ if self.main_win else 'bottom' @@ -1859,8 +2181,13 @@ class ImageViewer(QWidget): target_h = min(target_h, avail_geo.height()) self.resize(target_w, target_h) - self.update_title() - self.update_status_bar() + if pane == self.active_pane: + 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): """ @@ -1919,16 +2246,17 @@ class ImageViewer(QWidget): def toggle_crop_mode(self): """Toggles the crop selection mode.""" - self.crop_mode = not self.crop_mode - self.canvas.crop_rect = QRect() - self.canvas.update() + if self.active_pane: + self.active_pane.crop_mode = not self.active_pane.crop_mode + self.active_pane.canvas.crop_rect = QRect() + self.active_pane.canvas.update() - if self.crop_mode: - self.setCursor(Qt.CrossCursor) - self.sb_info_label.setText(f"{self.sb_info_label.text()} [CROP]") - else: - self.setCursor(Qt.ArrowCursor) - self.update_status_bar() + if self.active_pane.crop_mode: + self.active_pane.canvas.setCursor(Qt.CrossCursor) + self.sb_info_label.setText(f"{self.sb_info_label.text()} [CROP]") + else: + self.active_pane.canvas.setCursor(Qt.ArrowCursor) + self.update_status_bar() def show_crop_menu(self, global_pos): """Shows a context menu for the crop selection.""" @@ -1940,19 +2268,20 @@ class ImageViewer(QWidget): if res == save_action: self.save_cropped_image() elif res == cancel_action: - self.canvas.crop_rect = QRect() - self.canvas.update() + if self.active_pane: + self.active_pane.canvas.crop_rect = QRect() + self.active_pane.canvas.update() def save_cropped_image(self): """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 # 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 - orig = self.controller.pixmap_original + orig = self.active_pane.controller.pixmap_original if orig.isNull(): return @@ -1983,8 +2312,8 @@ class ImageViewer(QWidget): if file_name: cropped.save(file_name) # Optionally stay in crop mode or exit - self.canvas.crop_rect = QRect() - self.canvas.update() + self.active_pane.canvas.crop_rect = QRect() + self.active_pane.canvas.update() def update_title(self): """Updates the window title with the current image name.""" @@ -2016,7 +2345,12 @@ class ImageViewer(QWidget): w = self.controller.pixmap_original.width() h = self.controller.pixmap_original.height() 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), # otherwise fallback to controller's internal state. @@ -2039,15 +2373,15 @@ class ImageViewer(QWidget): if self.main_win: 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. Args: config (dict): The layout configuration dictionary. """ - self.scroll_area.horizontalScrollBar().setValue(config.get("scroll_x", 0)) - self.scroll_area.verticalScrollBar().setValue(config.get("scroll_y", 0)) + pane.scroll_area.horizontalScrollBar().setValue(config.get("scroll_x", 0)) + pane.scroll_area.verticalScrollBar().setValue(config.get("scroll_y", 0)) def get_state(self): """ @@ -2069,8 +2403,8 @@ class ImageViewer(QWidget): "show_faces": self.controller.show_faces, "flip_h": self.controller.flip_h, "flip_v": self.controller.flip_v, - "scroll_x": self.scroll_area.horizontalScrollBar().value(), - "scroll_y": self.scroll_area.verticalScrollBar().value(), + "scroll_x": self.scroll_area.horizontalScrollBar().value() if self.scroll_area else 0, + "scroll_y": self.scroll_area.verticalScrollBar().value() if self.scroll_area else 0, "status_bar_visible": self.status_bar_container.isVisible(), "filmstrip_visible": self.filmstrip.isVisible() } @@ -2142,10 +2476,10 @@ class ImageViewer(QWidget): """Re-loads shortcuts from the main window configuration.""" 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.""" - for face in self.controller.faces: - rect = self.canvas.map_from_source(face) + for face in pane.controller.faces: + rect = pane.canvas.map_from_source(face) if rect.contains(pos): return face return None @@ -2158,8 +2492,8 @@ class ImageViewer(QWidget): if not self.controller.show_faces: return False - pos = self.canvas.mapFromGlobal(event.globalPos()) - clicked_face = self._get_clicked_face(pos) + pos = self.canvas.mapFromGlobal(event.globalPos()) if self.canvas else QPoint() + clicked_face = self.active_pane._get_clicked_face(pos) if self.active_pane else None if not clicked_face: return False @@ -2177,7 +2511,8 @@ class ImageViewer(QWidget): for f in self.controller.faces) if not has_other: self.controller.toggle_tag(face_name, False) - self.canvas.update() + if self.canvas: + self.canvas.update() elif res == action_ren: self.rename_face(clicked_face) return True @@ -2234,7 +2569,8 @@ class ImageViewer(QWidget): # Save changes and add new tag self.controller.save_faces() self.controller.toggle_tag(new_full_tag, True) - self.canvas.update() + if self.canvas: + self.canvas.update() def toggle_main_window_visibility(self): """Toggles the visibility of the main window.""" @@ -2301,8 +2637,15 @@ class ImageViewer(QWidget): "icon": "zoom-fit-best"}, "separator", {"text": UITexts.VIEWER_MENU_CROP, "action": "toggle_crop", - "icon": "transform-crop", "checkable": True, - "checked": self.crop_mode}, + "icon": "transform-crop", "checkable": True}, # checked updated later + "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", ] @@ -2465,9 +2808,9 @@ class ImageViewer(QWidget): Args: event (QContextMenuEvent): The context menu event. """ - if self.crop_mode and not self.canvas.crop_rect.isNull(): - pos = self.canvas.mapFromGlobal(event.globalPos()) - if self.canvas.crop_rect.contains(pos): + if self.active_pane and self.active_pane.crop_mode and not self.active_pane.canvas.crop_rect.isNull(): + pos = self.active_pane.canvas.mapFromGlobal(event.globalPos()) + if self.active_pane.canvas.crop_rect.contains(pos): self.show_crop_menu(event.globalPos()) return @@ -2505,12 +2848,14 @@ class ImageViewer(QWidget): self.toggle_faces() self.controller.faces.append(new_face) - self.canvas.update() + if self.canvas: + self.canvas.update() - w = self.canvas.width() - h = self.canvas.height() - self.scroll_area.ensureVisible(int(new_face.get('x', 0) * w), - int(new_face.get('y', 0) * h), 50, 50) + w = self.canvas.width() if self.canvas else 0 + h = self.canvas.height() if self.canvas else 0 + if self.scroll_area: + self.scroll_area.ensureVisible(int(new_face.get('x', 0) * w), + int(new_face.get('y', 0) * h), 50, 50) QApplication.processEvents() history = self.main_win.face_names_history if self.main_win else [] @@ -2526,7 +2871,8 @@ class ImageViewer(QWidget): else: # If user cancels, remove the face that was temporarily added self.controller.faces.pop() - self.canvas.update() + if self.canvas: + self.canvas.update() if added_count > 0: self.controller.save_faces() @@ -2559,12 +2905,14 @@ class ImageViewer(QWidget): self.toggle_faces() self.controller.faces.append(new_pet) - self.canvas.update() + if self.canvas: + self.canvas.update() - w = self.canvas.width() - h = self.canvas.height() - self.scroll_area.ensureVisible(int(new_pet.get('x', 0) * w), - int(new_pet.get('y', 0) * h), 50, 50) + w = self.canvas.width() if self.canvas else 0 + h = self.canvas.height() if self.canvas else 0 + if self.scroll_area: + self.scroll_area.ensureVisible(int(new_pet.get('x', 0) * w), + int(new_pet.get('y', 0) * h), 50, 50) QApplication.processEvents() history = self.main_win.pet_names_history if self.main_win else [] @@ -2579,7 +2927,8 @@ class ImageViewer(QWidget): added_count += 1 else: self.controller.faces.pop() - self.canvas.update() + if self.canvas: + self.canvas.update() if added_count > 0: self.controller.save_faces() @@ -2612,12 +2961,14 @@ class ImageViewer(QWidget): self.toggle_faces() self.controller.faces.append(new_body) - self.canvas.update() + if self.canvas: + self.canvas.update() - w = self.canvas.width() - h = self.canvas.height() - self.scroll_area.ensureVisible(int(new_body.get('x', 0) * w), - int(new_body.get('y', 0) * h), 50, 50) + w = self.canvas.width() if self.canvas else 0 + h = self.canvas.height() if self.canvas else 0 + if self.scroll_area: + self.scroll_area.ensureVisible(int(new_body.get('x', 0) * w), + int(new_body.get('y', 0) * h), 50, 50) QApplication.processEvents() # For bodies, we typically don't ask for a name immediately unless desired @@ -2634,7 +2985,8 @@ class ImageViewer(QWidget): added_count += 1 else: self.controller.faces.pop() - self.canvas.update() + if self.canvas: + self.canvas.update() if added_count > 0: self.controller.save_faces() @@ -2659,11 +3011,14 @@ class ImageViewer(QWidget): def toggle_faces(self): """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: - 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.canvas.update() def show_fast_tag_menu(self): """Shows a context menu for quickly adding/removing tags.""" @@ -2697,10 +3052,9 @@ class ImageViewer(QWidget): if event.modifiers() & Qt.ControlModifier: # Zoom with Ctrl + Wheel if event.angleDelta().y() > 0: - self.controller.zoom_factor *= 1.1 + self.zoom_manager.zoom(1.1) else: - self.controller.zoom_factor *= 0.9 - self.update_view(resize_win=True) + self.zoom_manager.zoom(0.9) else: # Navigate next/previous based on configurable speed speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT)