"""Image Viewer Module for Bagheera. This module implements the main image viewing window (ImageViewer) and its associated widgets, such as the filmstrip thumbnail browser (FilmStripWidget). It provides functionalities for navigating, zooming, rotating, and presenting images in a slideshow mode. Classes: ImageViewer: A standalone window for viewing and manipulating an image. """ import os import subprocess import json from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QScrollArea, QLabel, QHBoxLayout, QListWidget, QListWidgetItem, QAbstractItemView, QMenu, QInputDialog, QDialog, QDialogButtonBox, QApplication, QMessageBox, QLineEdit, QFileDialog ) from PySide6.QtGui import ( QPixmap, QIcon, QTransform, QDrag, QPainter, QPen, QColor, QAction, QCursor, QImageReader, QMovie, QKeySequence, QPainterPath, QImage ) from PySide6.QtCore import ( Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF, QThread ) from constants import ( APP_CONFIG, DEFAULT_FACE_BOX_COLOR, DEFAULT_PET_BOX_COLOR, DEFAULT_VIEWER_SHORTCUTS, DEFAULT_OBJECT_BOX_COLOR, DEFAULT_LANDMARK_BOX_COLOR, FORCE_X11, ICON_THEME_VIEWER, ICON_THEME_VIEWER_FALLBACK, KSCREEN_DOCTOR_MARGIN, KWINOUTPUTCONFIG_PATH, VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_FORM_MARGIN, VIEWER_LABEL, VIEWER_WHEEL_SPEED_DEFAULT, XATTR_NAME, ZOOM_DESKTOP_RATIO, UITexts, ) from imagecontroller import ImageController from widgets import FaceNameInputWidget from propertiesdialog import PropertiesDialog class FaceNameDialog(QDialog): """A dialog to get a face name using the FaceNameInputWidget.""" def __init__(self, parent=None, history=None, current_name="", main_win=None, region_type="Face"): super().__init__(parent) if region_type == "Pet": self.setWindowTitle(UITexts.ADD_PET_TITLE) layout_label = UITexts.ADD_PET_LABEL elif region_type == "Object": self.setWindowTitle(UITexts.ADD_OBJECT_TITLE) layout_label = UITexts.ADD_OBJECT_LABEL elif region_type == "Landmark": self.setWindowTitle(UITexts.ADD_LANDMARK_TITLE) layout_label = UITexts.ADD_LANDMARK_LABEL else: self.setWindowTitle(UITexts.ADD_FACE_TITLE) layout_label = UITexts.ADD_FACE_LABEL self.setMinimumWidth(350) self.main_win = main_win layout = QVBoxLayout(self) layout.addWidget(QLabel(layout_label)) # Our custom widget. self.name_input = FaceNameInputWidget(self.main_win, self, region_type=region_type) self.name_input.load_data(history or []) if current_name: self.name_input.name_combo.setEditText(current_name.split('/')[-1]) self.name_input.name_combo.lineEdit().selectAll() layout.addWidget(self.name_input) # OK / Cancel buttons. self.button_box = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel) layout.addWidget(self.button_box) # Connections. self.button_box.accepted.connect(self.name_input._on_accept) self.button_box.rejected.connect(self.reject) self.name_input.name_accepted.connect(self._on_tag_selected) self.final_tag = "" self.final_history = history or [] def _on_tag_selected(self, full_tag): """Handles the signal from the input widget and closes the dialog.""" self.final_tag = full_tag self.final_history = self.name_input.get_history() super().accept() @staticmethod def get_name(parent=None, history=None, current_name="", main_win=None, region_type="Face", title=None): """Static method to show the dialog and get the result.""" dialog = FaceNameDialog(parent, history, current_name, main_win, region_type) if title: dialog.setWindowTitle(title) result = dialog.exec() if result == QDialog.Accepted: return dialog.final_tag, dialog.final_history, True # Return original history if cancelled return current_name, history, False class FilmstripLoader(QThread): """Thread to load filmstrip thumbnails asynchronously.""" thumbnail_loaded = Signal(int, QImage) def __init__(self, cache, items_to_load, icon_size): super().__init__() self.cache = cache # items_to_load is a list of (index, path) tuples self.items = items_to_load self.icon_size = icon_size self.target_index = 0 self._abort = False self._sort_needed = True def set_target_index(self, index): if self.target_index != index: self.target_index = index self._sort_needed = True def run(self): self.setPriority(QThread.IdlePriority) while self.items: if self._abort: return if self._sort_needed: # Pick the item closest to the target index (visible area) # Sorting descending allows O(1) pop from end self.items.sort( key=lambda x: abs(x[0] - self.target_index), reverse=True) self._sort_needed = False index, path = self.items.pop() # Small sleep to prevent UI freezing during heavy IO bursts self.msleep(1) try: img, _ = self.cache.get_thumbnail(path, self.icon_size) if img and not img.isNull(): self.thumbnail_loaded.emit(index, img) except Exception: pass def stop(self): self._abort = True self.wait() class FastTagManager: """Manages the creation and interaction of the fast tag menu.""" def __init__(self, viewer): self.viewer = viewer self.main_win = viewer.main_win self.controller = viewer.controller def show_menu(self): """Builds and shows a context menu for quickly adding/removing tags.""" if not self.main_win or not self.controller.get_current_path(): return current_path = self.controller.get_current_path() try: raw_tags = os.getxattr(current_path, XATTR_NAME).decode('utf-8') current_tags = {t.strip() for t in raw_tags.split(',') if t.strip()} except (OSError, AttributeError): current_tags = set() mru_tags = list(self.main_win.mru_tags) \ if hasattr(self.main_win, 'mru_tags') else [] if not mru_tags and not current_tags: return menu = FastTagMenu(self) mru_tags_to_show = [tag for tag in mru_tags if tag not in current_tags] if mru_tags_to_show: for tag in mru_tags_to_show: action = menu.addAction(tag) if '/' in tag: action.setProperty("is_hierarchical", True) menu.style().unpolish(menu) menu.style().polish(menu) action.setCheckable(True) action.setChecked(False) if mru_tags_to_show and current_tags: menu.addSeparator() if current_tags: for tag in sorted(list(current_tags)): action = menu.addAction(tag) if '/' in tag: action.setProperty("is_hierarchical", True) menu.style().unpolish(menu) menu.style().polish(menu) action.setCheckable(True) action.setChecked(True) menu.ensurePolished() actions = menu.actions() if actions: first_action = next((a for a in actions if not a.isSeparator()), None) if first_action: menu.setActiveAction(first_action) menu.exec(QCursor.pos()) def on_tag_toggled(self, action): """Handles the toggling of a tag from the fast tag menu.""" if not isinstance(action, QAction): return tag_name = action.text() is_checked = action.isChecked() current_path = self.controller.get_current_path() if not current_path: return self.controller.toggle_tag(tag_name, is_checked) self.viewer.update_status_bar() if self.main_win: if is_checked: self.main_win.add_to_mru_tags(tag_name) self.main_win.update_metadata_for_path(current_path) class FastTagMenu(QMenu): """A QMenu that allows toggling actions with the mouse or spacebar without " "closing.""" def __init__(self, manager): super().__init__(manager.viewer) self.manager = manager self.setStyleSheet(""" QMenu::item[is_hierarchical="true"] { color: #a9d0f5; /* Light blue for hierarchical tags */ padding-left: 20px; } """) def mouseReleaseEvent(self, event): action = self.actionAt(event.pos()) if action and action.isCheckable(): action.setChecked(not action.isChecked()) if self.manager: self.manager.on_tag_toggled(action) event.accept() else: super().mouseReleaseEvent(event) def keyPressEvent(self, event): if event.key() == Qt.Key_Space or event.key() == Qt.Key_Return: action = self.activeAction() if action and action.isCheckable(): # Manually toggle and trigger to keep menu open action.setChecked(not action.isChecked()) if self.manager: self.manager.on_tag_toggled(action) event.accept() if event.key() == Qt.Key_Space: return super().keyPressEvent(event) class FilmStripWidget(QListWidget): """ A horizontal, scrollable list of thumbnails for image navigation. This widget displays thumbnails for all images in the current list, allowing the user to quickly jump to an image by clicking its thumbnail. It also supports dragging files out of the application. """ def __init__(self, controller, parent=None): """ Initializes the FilmStripWidget. Args: controller (ImageController): The controller managing the image list. parent (QWidget, optional): The parent widget. Defaults to None. """ super().__init__(parent) self.controller = controller self.setDragEnabled(True) def startDrag(self, supportedActions): """ Initiates a drag-and-drop operation for the selected image(s). """ items = self.selectedItems() if not items: return urls = [] for item in items: row = self.row(item) if 0 <= row < len(self.controller.image_list): path = self.controller.image_list[row] urls.append(QUrl.fromLocalFile(path)) if not urls: return drag = QDrag(self) mime_data = QMimeData() mime_data.setUrls(urls) drag.setMimeData(mime_data) icon = items[0].icon() if not icon.isNull(): pixmap = icon.pixmap(64, 64) drag.setPixmap(pixmap) drag.setHotSpot(QPoint(pixmap.width() // 2, pixmap.height() // 2)) drag.exec(Qt.CopyAction) class FaceCanvas(QLabel): """ A custom QLabel that draws face regions on top of the image. Handles mouse interaction for creating and managing face regions. """ def __init__(self, viewer): super().__init__() self.viewer = viewer self.controller = viewer.controller self.setMouseTracking(True) self.drawing = False self.start_pos = QPoint() self.current_rect = QRect() self.dragging = False self.drag_start_pos = QPoint() self.drag_start_scroll_x = 0 self.drag_start_scroll_y = 0 self.editing = False self.edit_index = -1 self.edit_handle = None self.edit_start_rect = QRect() self.resize_margin = 8 self.crop_rect = QRect() self.crop_handle = None self.crop_start_pos = QPoint() self.crop_start_rect = QRect() def map_from_source(self, face_data): """Maps original normalized face data to current canvas QRect.""" nx = face_data.get('x', 0) ny = face_data.get('y', 0) nw = face_data.get('w', 0) nh = face_data.get('h', 0) rot = self.controller.rotation % 360 flip_h = self.controller.flip_h flip_v = self.controller.flip_v cx, cy = nx, ny cw, ch = nw, nh # 1. Rotation (applied to normalized center coordinates 0.5, 0.5) if rot == 90: cx, cy = 1.0 - cy, cx cw, ch = nh, nw elif rot == 180: cx, cy = 1.0 - cx, 1.0 - cy elif rot == 270: cx, cy = cy, 1.0 - cx cw, ch = nh, nw # 2. Flips if flip_h: cx = 1.0 - cx if flip_v: cy = 1.0 - cy w = self.width() h = self.height() # Convert Center-Normalized to Top-Left-Pixel rx = (cx - cw / 2) * w ry = (cy - ch / 2) * h rw = cw * w rh = ch * h return QRect(int(rx), int(ry), int(rw), int(rh)) def map_to_source(self, rect): """Maps a canvas QRect to original normalized coordinates (x, y, w, h).""" w = self.width() h = self.height() if w == 0 or h == 0: return 0.5, 0.5, 0.0, 0.0 # Pixel Rect to Normalized Center cx = (rect.x() + rect.width() / 2.0) / w cy = (rect.y() + rect.height() / 2.0) / h cw = rect.width() / w ch = rect.height() / h rot = self.controller.rotation % 360 flip_h = self.controller.flip_h flip_v = self.controller.flip_v # Inverse Flips if flip_h: cx = 1.0 - cx if flip_v: cy = 1.0 - cy # Inverse Rotation nx, ny = cx, cy nw, nh = cw, ch if rot == 90: # Inverse of 90 is 270: (y, 1-x) nx, ny = cy, 1.0 - cx nw, nh = ch, cw elif rot == 180: nx, ny = 1.0 - cx, 1.0 - cy elif rot == 270: # Inverse of 270 is 90: (1-y, x) nx, ny = 1.0 - cy, cx nw, nh = ch, cw return nx, ny, nw, nh def paintEvent(self, event): super().paintEvent(event) if not self.controller.show_faces and not self.viewer.crop_mode: return painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) # Draw existing faces face_color_str = APP_CONFIG.get("face_box_color", DEFAULT_FACE_BOX_COLOR) face_color = QColor(face_color_str) pet_color_str = APP_CONFIG.get("pet_box_color", DEFAULT_PET_BOX_COLOR) pet_color = QColor(pet_color_str) object_color_str = APP_CONFIG.get("object_box_color", DEFAULT_OBJECT_BOX_COLOR) object_color = QColor(object_color_str) landmark_color_str = APP_CONFIG.get("landmark_box_color", DEFAULT_LANDMARK_BOX_COLOR) landmark_color = QColor(landmark_color_str) if self.controller.show_faces: for face in self.controller.faces: rect = self.map_from_source(face) is_pet = face.get('type') == 'Pet' is_object = face.get('type') == 'Object' is_landmark = face.get('type') == 'Landmark' if is_pet: color = pet_color elif is_object: color = object_color elif is_landmark: color = landmark_color else: color = face_color painter.setPen(QPen(color, 2)) painter.setBrush(Qt.NoBrush) painter.drawRect(rect) name = face.get('name', '') if name: display_name = name.split('/')[-1] fm = painter.fontMetrics() tw = fm.horizontalAdvance(display_name) th = fm.height() bg_height = th + 4 bg_width = tw + 8 # Default position is top-left, outside the box bg_y = rect.top() - bg_height # If there is no space at the top, move it to the bottom if bg_y < 0: bg_y = rect.bottom() bg_rect = QRect(rect.left(), bg_y, bg_width, bg_height) painter.setPen(Qt.NoPen) painter.setBrush(QColor(100, 100, 100)) painter.drawRect(bg_rect) painter.setPen(QPen(color, 1)) painter.drawText(bg_rect, Qt.AlignCenter, display_name) # Draw rubber band for new face if self.drawing and not self.viewer.crop_mode: painter.setPen(QPen(QColor(0, 120, 255), 2, Qt.DashLine)) painter.setBrush(Qt.NoBrush) painter.drawRect(self.current_rect) # Draw crop rectangle if in crop mode if self.viewer.crop_mode and not self.crop_rect.isNull(): # Draw dimmed overlay outside crop rect painter.setBrush(QColor(0, 0, 0, 160)) painter.setPen(Qt.NoPen) path = QPainterPath() path.addRect(QRectF(self.rect())) path.addRect(QRectF(self.crop_rect)) path.setFillRule(Qt.OddEvenFill) painter.drawPath(path) painter.setPen(QPen(QColor(255, 255, 0), 2, Qt.DashLine)) painter.setBrush(Qt.NoBrush) painter.drawRect(self.crop_rect) # Draw rule of thirds grid if self.crop_rect.width() > 60 and self.crop_rect.height() > 60: grid_pen = QPen(QColor(255, 255, 255, 100), 1, Qt.SolidLine) painter.setPen(grid_pen) x, y = self.crop_rect.x(), self.crop_rect.y() w, h = self.crop_rect.width(), self.crop_rect.height() x1, x2 = int(x + w / 3), int(x + 2 * w / 3) y1, y2 = int(y + h / 3), int(y + 2 * h / 3) painter.drawLine(x1, y, x1, y + h) painter.drawLine(x2, y, x2, y + h) painter.drawLine(x, y1, x + w, y1) painter.drawLine(x, y2, x + w, y2) # Draw crop handles painter.setPen(QPen(QColor(0, 0, 0), 1)) painter.setBrush(QColor(255, 255, 255)) handle_size = 8 offset = handle_size // 2 handles = [ self.crop_rect.topLeft(), self.crop_rect.topRight(), self.crop_rect.bottomLeft(), self.crop_rect.bottomRight() ] for pt in handles: painter.drawRect(pt.x() - offset, pt.y() - offset, handle_size, handle_size) def _hit_test(self, pos): """Determines if the mouse is over a name, handle, or body.""" if not self.controller.show_faces: return -1, None margin = self.resize_margin fm = self.fontMetrics() # Iterate in reverse to pick top-most face if overlapping for i in range(len(self.controller.faces) - 1, -1, -1): face = self.controller.faces[i] rect = self.map_from_source(face) # Check if click is on the name label first name = face.get('name', '') if name: display_name = name.split('/')[-1] tw = fm.horizontalAdvance(display_name) th = fm.height() bg_height = th + 4 bg_width = tw + 8 bg_y = rect.top() - bg_height if bg_y < 0: bg_y = rect.bottom() bg_rect = QRect(rect.left(), bg_y, bg_width, bg_height) if bg_rect.contains(pos): return i, 'NAME' # Check outer boundary with margin outer = rect.adjusted(-margin, -margin, margin, margin) if not outer.contains(pos): continue x, y = pos.x(), pos.y() l, t, r, b = rect.left(), rect.top(), rect.right(), rect.bottom() # Determine proximity to edges on_left = abs(x - l) <= margin on_right = abs(x - r) <= margin on_top = abs(y - t) <= margin on_bottom = abs(y - b) <= margin # Check Corners if on_left and on_top: return i, 'TL' if on_right and on_top: return i, 'TR' if on_left and on_bottom: return i, 'BL' if on_right and on_bottom: return i, 'BR' # Check Edges if on_left: return i, 'L' if on_right: return i, 'R' if on_top: return i, 'T' if on_bottom: return i, 'B' if rect.contains(pos): return i, 'BODY' return -1, None def _hit_test_crop(self, pos): """Determines if mouse is over a crop handle or body.""" if self.crop_rect.isNull(): return None handle_size = 12 # Hit area slightly larger than drawn handle # margin = handle_size // 2 rects = { 'TL': QRect(0, 0, handle_size, handle_size), 'TR': QRect(0, 0, handle_size, handle_size), 'BL': QRect(0, 0, handle_size, handle_size), 'BR': QRect(0, 0, handle_size, handle_size) } rects['TL'].moveCenter(self.crop_rect.topLeft()) rects['TR'].moveCenter(self.crop_rect.topRight()) rects['BL'].moveCenter(self.crop_rect.bottomLeft()) rects['BR'].moveCenter(self.crop_rect.bottomRight()) for key, r in rects.items(): if r.contains(pos): return key if self.crop_rect.contains(pos): return 'BODY' return None def mousePressEvent(self, event): """Handles mouse press for drawing new faces or panning.""" 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: self.crop_handle = handle self.crop_start_pos = event.position().toPoint() self.crop_start_rect = self.crop_rect event.accept() return self.drawing = True self.start_pos = event.position().toPoint() self.crop_rect = QRect() self.update() event.accept() return if self.controller.show_faces and event.button() == Qt.LeftButton: self.start_pos = event.position().toPoint() # Check if we clicked on an existing face to edit idx, handle = self._hit_test(self.start_pos) if idx != -1: if handle == 'NAME': self.viewer.rename_face(self.controller.faces[idx]) event.accept() return self.editing = True self.edit_index = idx self.edit_handle = handle self.edit_start_rect = self.map_from_source(self.controller.faces[idx]) event.accept() else: self.drawing = True self.current_rect = QRect(self.start_pos, self.start_pos) event.accept() elif event.button() == Qt.LeftButton: self.dragging = True self.drag_start_pos = event.globalPosition().toPoint() self.drag_start_scroll_x = self.viewer.scroll_area.horizontalScrollBar().value() self.drag_start_scroll_y = self.viewer.scroll_area.verticalScrollBar().value() self.setCursor(Qt.ClosedHandCursor) event.accept() else: event.ignore() def mouseMoveEvent(self, event): """Handles mouse move for drawing new faces or panning.""" self.viewer.reset_inactivity_timer() if self.viewer.crop_mode: curr_pos = event.position().toPoint() if self.crop_handle: dx = curr_pos.x() - self.crop_start_pos.x() dy = curr_pos.y() - self.crop_start_pos.y() rect = QRect(self.crop_start_rect) if self.crop_handle == 'BODY': rect.translate(dx, dy) # Bounds check rect.moveLeft(max(0, rect.left())) rect.moveTop(max(0, rect.top())) if rect.right() > self.width(): rect.moveRight(self.width()) if rect.bottom() > self.height(): rect.moveBottom(self.height()) else: # Determine fixed anchor point based on handle if self.crop_handle == 'TL': fixed = self.crop_start_rect.bottomRight() moving = self.crop_start_rect.topLeft() elif self.crop_handle == 'TR': fixed = self.crop_start_rect.bottomLeft() moving = self.crop_start_rect.topRight() elif self.crop_handle == 'BL': fixed = self.crop_start_rect.topRight() moving = self.crop_start_rect.bottomLeft() elif self.crop_handle == 'BR': fixed = self.crop_start_rect.topLeft() moving = self.crop_start_rect.bottomRight() # Calculate new moving point candidate current_moving = moving + QPoint(dx, dy) # Vector from fixed to moving w = current_moving.x() - fixed.x() h = current_moving.y() - fixed.y() # Aspect ratio constraint with Shift if event.modifiers() & Qt.ShiftModifier \ and self.crop_start_rect.height() != 0: ratio = self.crop_start_rect.width() / \ self.crop_start_rect.height() if abs(w) / ratio > abs(h): h = w / ratio else: w = h * ratio rect = QRect(fixed, QPoint(int(fixed.x() + w), int(fixed.y() + h))) self.crop_rect = rect.normalized() self.update() event.accept() return elif self.drawing: if event.modifiers() & Qt.ShiftModifier: dx = curr_pos.x() - self.start_pos.x() dy = curr_pos.y() - self.start_pos.y() side = max(abs(dx), abs(dy)) curr_pos = QPoint( self.start_pos.x() + (side if dx >= 0 else -side), self.start_pos.y() + (side if dy >= 0 else -side)) self.crop_rect = QRect(self.start_pos, curr_pos).normalized() self.update() event.accept() return else: # Cursor update handle = self._hit_test_crop(curr_pos) if handle in ['TL', 'BR']: self.setCursor(Qt.SizeFDiagCursor) elif handle in ['TR', 'BL']: self.setCursor(Qt.SizeBDiagCursor) elif handle == 'BODY': self.setCursor(Qt.SizeAllCursor) else: self.setCursor(Qt.CrossCursor) event.accept() return if self.drawing: self.current_rect = QRect(self.start_pos, event.position().toPoint()).normalized() self.update() event.accept() elif self.editing: curr_pos = event.position().toPoint() dx = curr_pos.x() - self.start_pos.x() dy = curr_pos.y() - self.start_pos.y() # Calculate new rect based on handle new_rect = QRect(self.edit_start_rect) min_size = 5 if self.edit_handle == 'BODY': new_rect.translate(dx, dy) else: if 'L' in self.edit_handle: new_rect.setLeft( min(new_rect.right() - min_size, self.edit_start_rect.left() + dx)) if 'R' in self.edit_handle: new_rect.setRight( max(new_rect.left() + min_size, self.edit_start_rect.right() + dx)) if 'T' in self.edit_handle: new_rect.setTop( min(new_rect.bottom() - min_size, self.edit_start_rect.top() + dy)) if 'B' in self.edit_handle: new_rect.setBottom( max(new_rect.top() + min_size, self.edit_start_rect.bottom() + dy)) # Normalize and update face data # Convert screen rect back to normalized source coordinates nx, ny, nw, nh = self.map_to_source(new_rect) # Update the face in the controller in real-time face = self.controller.faces[self.edit_index] face['x'], face['y'], face['w'], face['h'] = nx, ny, nw, nh self.update() event.accept() elif not self.drawing and not self.dragging and self.controller.show_faces: # Update cursor based on hover _, handle = self._hit_test(event.position().toPoint()) if handle in ['TL', 'BR']: self.setCursor(Qt.SizeFDiagCursor) elif handle in ['TR', 'BL']: self.setCursor(Qt.SizeBDiagCursor) elif handle in ['T', 'B']: self.setCursor(Qt.SizeVerCursor) elif handle in ['L', 'R']: self.setCursor(Qt.SizeHorCursor) elif handle == 'BODY': self.setCursor(Qt.SizeAllCursor) elif handle == 'NAME': self.setCursor(Qt.PointingHandCursor) else: self.setCursor(Qt.CrossCursor) event.accept() elif self.dragging: delta = event.globalPosition().toPoint() - self.drag_start_pos h_bar = self.viewer.scroll_area.horizontalScrollBar() v_bar = self.viewer.scroll_area.verticalScrollBar() h_bar.setValue(self.drag_start_scroll_x - delta.x()) v_bar.setValue(self.drag_start_scroll_y - delta.y()) event.accept() else: event.ignore() def mouseReleaseEvent(self, event): """Handles mouse release for drawing new faces or panning.""" if self.viewer.crop_mode: if self.crop_handle: self.crop_handle = None self.update() elif self.drawing: self.drawing = False self.update() event.accept() return if self.drawing: self.drawing = False if self.current_rect.width() > 10 and self.current_rect.height() > 10: region_type = "Face" # Check if Control key was held down to allow selecting type if event.modifiers() & Qt.ControlModifier: menu = QMenu(self) action_face = menu.addAction(UITexts.TYPE_FACE) action_pet = menu.addAction(UITexts.TYPE_PET) action_object = menu.addAction(UITexts.TYPE_OBJECT) action_landmark = menu.addAction(UITexts.TYPE_LANDMARK) # Show menu at mouse release position res = menu.exec(event.globalPosition().toPoint()) if res == action_pet: region_type = "Pet" elif res == action_object: region_type = "Object" elif res == action_landmark: region_type = "Landmark" elif res == action_face: region_type = "Face" else: # Cancelled self.current_rect = QRect() self.update() return history_list = [] if self.viewer.main_win: if region_type == "Pet": history_list = self.viewer.main_win.pet_names_history elif region_type == "Object": history_list = self.viewer.main_win.object_names_history elif region_type == "Landmark": history_list = self.viewer.main_win.landmark_names_history else: history_list = self.viewer.main_win.face_names_history history = history_list \ if self.viewer.main_win else [] full_tag, updated_history, ok = FaceNameDialog.get_name( self.viewer, history, main_win=self.viewer.main_win, region_type=region_type) if ok and full_tag: if self.viewer.main_win: if region_type == "Pet": self.viewer.main_win.pet_names_history = updated_history elif region_type == "Object": self.viewer.main_win.object_names_history = updated_history elif region_type == "Landmark": self.viewer.main_win.landmark_names_history = \ updated_history else: self.viewer.main_win.face_names_history = updated_history center_x, center_y, norm_w, norm_h = self.map_to_source( self.current_rect) self.controller.add_face( full_tag, center_x, center_y, norm_w, norm_h, region_type=region_type) self.controller.toggle_tag(full_tag, True) self.update() # Repaint to show the new face with its name self.current_rect = QRect() self.update() event.accept() elif self.editing: # Finish editing self.editing = False self.edit_index = -1 self.edit_handle = None self.controller.save_faces() event.accept() elif self.dragging: self.dragging = False self.setCursor(Qt.ArrowCursor) event.accept() else: event.ignore() def mouseDoubleClickEvent(self, event): """Zooms to a face on double-click.""" if self.controller.show_faces and event.button() == Qt.LeftButton: # The event position is already local to the canvas clicked_face = self.viewer._get_clicked_face(event.position().toPoint()) if clicked_face: self.viewer.zoom_to_rect(clicked_face) event.accept() return # If no face was double-clicked, pass the event on super().mouseDoubleClickEvent(event) class ImageViewer(QWidget): """ A standalone window for viewing and manipulating a single image. This viewer supports navigation (next/previous) through a list of images, zooming, panning, rotation, mirroring, and a slideshow mode. It also integrates a filmstrip for quick navigation and a status bar for showing image information. Signals: index_changed(int): Emitted when the current image index changes. """ index_changed = Signal(int) activated = Signal() def __init__(self, cache, image_list, current_index, initial_tags=None, initial_rating=0, parent=None, restore_config=None, persistent=False, first_load=True): """ Initializes the ImageViewer window. Args: cache (ThumbnailCache): The thumbnail cache instance. image_list (list): The list of image paths to display. current_index (int): The starting index in the image_list. parent (QWidget, optional): The parent widget (MainWindow). Defaults to None. restore_config (dict, optional): A state dictionary to restore a previous session. Defaults to None. persistent (bool, optional): If True, the viewer is part of a saved layout. Defaults to False. """ super().__init__() self.main_win = parent self.cache = cache self.set_window_icon() self.setAttribute(Qt.WA_DeleteOnClose) # Standard window buttons self.setWindowFlags(self.windowFlags() | Qt.WindowMaximizeButtonHint | Qt.WindowMinimizeButtonHint) self._first_load = first_load self._is_persistent = persistent self.crop_mode = False 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) self.fast_tag_manager = FastTagManager(self) self._setup_shortcuts() self._setup_actions() self.inhibit_cookie = None filmstrip_position = 'bottom' # Default if self.main_win and hasattr(self.main_win, 'filmstrip_position'): filmstrip_position = self.main_win.filmstrip_position # UI Layout is_vertical_filmstrip = filmstrip_position in ('left', 'right') if is_vertical_filmstrip: self.layout = QHBoxLayout(self) else: self.layout = QVBoxLayout(self) 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) self.canvas = FaceCanvas(self) self.scroll_area.setWidget(self.canvas) self.filmstrip = FilmStripWidget(self.controller) self.filmstrip.setSpacing(2) self.filmstrip.itemClicked.connect(self.on_filmstrip_clicked) self.status_bar_container = QWidget() self.status_bar_container.setStyleSheet("background-color: #222; color: #aaa; " "font-size: 11px;") sb_layout = QHBoxLayout(self.status_bar_container) sb_layout.setContentsMargins(5, 2, 5, 2) self.sb_index_label = QLabel() self.sb_tags_label = QLabel() self.sb_info_label = QLabel() self.sb_info_label.setAlignment(Qt.AlignRight) sb_layout.addWidget(self.sb_index_label) sb_layout.addWidget(self.sb_tags_label) sb_layout.addStretch() sb_layout.addWidget(self.sb_info_label) if is_vertical_filmstrip: center_pane = 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.status_bar_container) self.filmstrip.setFixedWidth(120) self.filmstrip.setViewMode(QListWidget.IconMode) self.filmstrip.setFlow(QListWidget.TopToBottom) self.filmstrip.setWrapping(False) self.filmstrip.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.filmstrip.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.filmstrip.setIconSize(QSize(100, 100)) border_side = "border-right" if filmstrip_position == 'left' \ else "border-left" self.filmstrip.setStyleSheet(f"QListWidget {{ background-color: #222; " f"{border_side}: 1px solid #444; }} " "QListWidget::item:selected " "{ background-color: #3498db; }") if filmstrip_position == 'left': self.layout.addWidget(self.filmstrip) self.layout.addWidget(center_pane) else: self.layout.addWidget(center_pane) self.layout.addWidget(self.filmstrip) else: self.filmstrip.setFixedHeight(100) self.filmstrip.setViewMode(QListWidget.IconMode) self.filmstrip.setFlow(QListWidget.LeftToRight) self.filmstrip.setWrapping(False) self.filmstrip.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.filmstrip.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.filmstrip.setIconSize(QSize(80, 80)) border_side = "border-top" if filmstrip_position == 'bottom' \ else "border-bottom" self.filmstrip.setStyleSheet(f"QListWidget {{ background-color: #222; " f"{border_side}: 1px solid #444; }} " "QListWidget::item:selected " "{ background-color: #3498db; }") if filmstrip_position == 'top': self.layout.addWidget(self.filmstrip) self.layout.addWidget(self.scroll_area) self.layout.addWidget(self.status_bar_container) else: # bottom self.layout.addWidget(self.scroll_area) self.layout.addWidget(self.filmstrip) self.layout.addWidget(self.status_bar_container) if self.main_win: self.filmstrip.setVisible(self.main_win.show_filmstrip) else: self.filmstrip.setVisible(False) if self.main_win: self.status_bar_container.setVisible(self.main_win.show_viewer_status_bar) self.inhibit_screensaver() # Inactivity timer for fullscreen self.hide_controls_timer = QTimer(self) self.hide_controls_timer.setInterval(3000) self.hide_controls_timer.timeout.connect(self.hide_controls) # Slideshow self.slideshow_timer = QTimer(self) self.slideshow_timer.setInterval(3000) self.slideshow_timer.timeout.connect(self.next_image) # Slideshow self.slideshow_reverse_timer = QTimer(self) self.slideshow_reverse_timer.setInterval(3000) self.slideshow_reverse_timer.timeout.connect(self.prev_image) # Load image if restore_config: # If restoring a layout, don't auto-fit to screen. Instead, use # the saved geometry and state. QTimer.singleShot(1000, self.restore_image_list) self.populate_filmstrip() self.load_and_fit_image(restore_config) else: self.populate_filmstrip() self.load_and_fit_image() def reset_inactivity_timer(self): """Resets the inactivity timer and restores controls visibility.""" if self.isFullScreen(): self.unsetCursor() if self.main_win and self.main_win.show_viewer_status_bar: self.status_bar_container.show() if not self.hide_controls_timer.isActive(): self.hide_controls_timer.start() else: self.hide_controls_timer.start() # Restart triggers full interval def hide_controls(self): """Hides cursor and status bar in fullscreen mode.""" if self.isFullScreen(): self.setCursor(Qt.BlankCursor) self.status_bar_container.hide() def _setup_shortcuts(self): """Initializes the shortcut mapping from the main window or defaults.""" self.action_to_shortcut = {} if self.main_win and self.main_win.viewer_shortcuts: # The dictionary from main_win is already prepared with # (int, Qt.KeyboardModifiers) keys. The value is (action, desc). # We just need the action. self.shortcuts = {key: val[0] for key, val in self.main_win.viewer_shortcuts.items()} for key, action in self.shortcuts.items(): self.action_to_shortcut[action] = key else: # Use defaults from the new constant structure self.shortcuts = {} for action, (key, mods) in DEFAULT_VIEWER_SHORTCUTS.items(): key_combo = (int(key), Qt.KeyboardModifiers(mods)) self.shortcuts[key_combo] = action self.action_to_shortcut[action] = key_combo def _setup_actions(self): """Initializes the action map for executing shortcuts.""" self._actions = { "close": self.close_or_exit_fullscreen, "next": self.next_image, "prev": self.prev_image, "slideshow": self.toggle_slideshow, "slideshow_reverse": self.toggle_slideshow_reverse, "fullscreen": self.toggle_fullscreen, "rename": self.rename_current_image, "toggle_faces": self.toggle_faces, "toggle_statusbar": self.toggle_status_bar, "toggle_filmstrip": self.toggle_filmstrip, "flip_horizontal": self.toggle_flip_horizontal, "flip_vertical": self.toggle_flip_vertical, "detect_faces": self.run_face_detection, "detect_pets": self.run_pet_detection, "fast_tag": self.show_fast_tag_menu, "rotate_right": lambda: self.apply_rotation(90, True), "rotate_left": lambda: self.apply_rotation(-90, True), "zoom_in": lambda: self.zoom(1.1), "zoom_out": lambda: self.zoom(0.9), "reset_zoom": lambda: self.zoom(1.0, reset=True), "toggle_animation": self.toggle_animation_pause, "properties": self.show_properties, "toggle_visibility": self.toggle_main_window_visibility, "toggle_crop": self.toggle_crop_mode, "save_crop": self.save_cropped_image, } def _execute_action(self, action): """Executes the method corresponding to the action name.""" if action in self._actions: self._actions[action]() def populate_filmstrip(self): """ Populates the filmstrip widget with thumbnails from the image list. Optimized to update the existing list if possible, rather than rebuilding it entirely. """ if not self.filmstrip.isVisible(): return # --- OPTIMIZATION --- # Check if the filmstrip content is already in sync with the controller's list. # If so, just update the selection and avoid a full rebuild. new_list = self.controller.image_list if self.filmstrip.count() == len(new_list): is_synced = True # This check is fast enough for typical filmstrip sizes. for i in range(len(new_list)): # Assuming UserRole stores the path if self.filmstrip.item(i).data(Qt.UserRole) != new_list[i]: is_synced = False break if is_synced: self.sync_filmstrip_selection(self.controller.index) return # --- END OPTIMIZATION --- if self.filmstrip_loader and self.filmstrip_loader.isRunning(): self.filmstrip_loader.stop() current_count = self.filmstrip.count() fallback_icon = QIcon.fromTheme("image-x-generic") # Check if we can perform an incremental update (append) can_append = True if current_count > len(new_list): can_append = False else: for i in range(current_count): item = self.filmstrip.item(i) if item.data(Qt.UserRole) != new_list[i]: can_append = False break if can_append: # Append only new items for i in range(current_count, len(new_list)): path = new_list[i] item = QListWidgetItem(fallback_icon, "") item.setData(Qt.UserRole, path) self.filmstrip.addItem(item) else: # Smart rebuild: reuse items to preserve icons/loaded state existing_items = {} # Remove from end to beginning to avoid index shifting overhead for i in range(self.filmstrip.count() - 1, -1, -1): item = self.filmstrip.takeItem(i) existing_items[item.data(Qt.UserRole)] = item for path in new_list: if path in existing_items: item = existing_items.pop(path) else: item = QListWidgetItem(fallback_icon, "") item.setData(Qt.UserRole, path) self.filmstrip.addItem(item) # Determine which items need thumbnail loading items_to_load = [] LOADED_ROLE = Qt.UserRole + 1 for i in range(self.filmstrip.count()): item = self.filmstrip.item(i) if not item.data(LOADED_ROLE): path = item.data(Qt.UserRole) items_to_load.append((i, path)) if items_to_load: self.filmstrip_loader = FilmstripLoader( self.cache, items_to_load, self.filmstrip.iconSize().width()) self.filmstrip_loader.set_target_index(self.controller.index) self.filmstrip_loader.thumbnail_loaded.connect( self._on_filmstrip_thumb_loaded) self.filmstrip_loader.start() # Defer selection sync to ensure the list widget has updated its layout # and bounds, fixing issues where the wrong item seems selected or scrolled to. QTimer.singleShot( 0, lambda: self.sync_filmstrip_selection(self.controller.index)) @Slot(int, QImage) def _on_filmstrip_thumb_loaded(self, index, image): """Updates the filmstrip item icon once the thumbnail is loaded.""" if 0 <= index < self.filmstrip.count(): item = self.filmstrip.item(index) item.setIcon(QIcon(QPixmap.fromImage(image))) # Mark as loaded to prevent reloading on subsequent updates item.setData(Qt.UserRole + 1, True) def sync_filmstrip_selection(self, index): """ Highlights the thumbnail in the filmstrip corresponding to the given index. Args: index (int): The index of the image to select in the filmstrip. """ if self.filmstrip.count() == 0: return if 0 <= index < self.filmstrip.count(): item = self.filmstrip.item(index) self.filmstrip.setCurrentItem(item) self.filmstrip.scrollToItem(item, QAbstractItemView.PositionAtCenter) # Update loader priority if running if self.filmstrip_loader and self.filmstrip_loader.isRunning(): self.filmstrip_loader.set_target_index(index) def on_filmstrip_clicked(self, item): """ Slot that handles clicks on a filmstrip item. Args: item (QListWidgetItem): The clicked list widget item. """ idx = self.filmstrip.row(item) if idx != self.controller.index: self.controller.index = idx self.index_changed.emit(self.controller.index) self.load_and_fit_image() def restore_image_list(self): """Restores the full image list from the main window. This is used when a viewer is restored from a layout, ensuring its internal image list is synchronized with the main application's list. """ current_path = self.controller.get_current_path() image_paths = self.main_win.get_all_image_paths() if current_path and current_path in image_paths: index = image_paths.index(current_path) if index >= 0: self.controller.update_list(image_paths, index) def get_desktop_resolution(self): """ Determines the resolution of the primary desktop. """ try: """ kwinoutputconfig.json """ # We run kscreen-doctor and look for the primary monitor line. if FORCE_X11: if os.path.exists(KWINOUTPUTCONFIG_PATH): scale = 1 primary_monitor = subprocess.check_output("xrandr | grep " "' primary' | cut -d' '" " -f1", shell=True, text=True).strip() try: with open(KWINOUTPUTCONFIG_PATH, 'r', encoding='utf-8') as f: data_json = json.load(f) # Find the section where "name" is "outputs" outputs_section = next((item for item in data_json if item.get("name") == "outputs"), None) if outputs_section: # Iterate over the "data" elements within that section for device in outputs_section.get("data", []): if device.get("connectorName") == primary_monitor: scale = float(device.get("scale")) mode = device.get("mode", {}) output = f"{mode.get('width')}x{mode.get('height')}" break except json.JSONDecodeError: scale = 1 output = subprocess.check_output("xrandr | grep ' primary' | " "awk '{print $4}' | cut -d'+' " "-f1", shell=True, text=True) except Exception: scale = 1 output = subprocess.check_output("xrandr | grep ' primary' | " "awk '{print $4}' | cut -d'+' " "-f1", shell=True, text=True) else: scale = 1 output = subprocess.check_output("xrandr | grep ' primary' | " "awk '{print $4}' | cut -d'+' " "-f1", shell=True, text=True) width, height = map(int, output.split('x')) return width / scale - KSCREEN_DOCTOR_MARGIN, height / scale - \ KSCREEN_DOCTOR_MARGIN else: # This can hang on X11. output = subprocess.check_output("kscreen-doctor -o | grep -A 10 " "'priority 1' | grep 'Geometry' " "| cut -d' ' -f3", shell=True, text=True) width, height = map(int, output.split('x')) return width-KSCREEN_DOCTOR_MARGIN, height-KSCREEN_DOCTOR_MARGIN except Exception: screen_geo = self.screen().availableGeometry() return screen_geo.width(), screen_geo.height() def load_and_fit_image(self, 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. """ if self.movie: self.movie.stop() self.movie = None if not self.controller.load_image(): self.canvas.setPixmap(QPixmap()) self.update_status_bar() return path = self.controller.get_current_path() self.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() 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) 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)) else: # Calculate zoom to fit the image on the screen if self.isFullScreen(): viewport = self.scroll_area.viewport() available_w = viewport.width() available_h = viewport.height() should_resize = False else: if self._first_load: if self.main_win and self.main_win.isVisible(): # Get resolution from main windows screen_geo = self.main_win.screen().availableGeometry() screen_width = screen_geo.width() screen_height = screen_geo.height() else: # Tried to guess screen_width, screen_height = self.get_desktop_resolution() self._first_load = False else: screen_geo = self.screen().availableGeometry() screen_width = screen_geo.width() screen_height = screen_geo.height() # Calculate available screen space for the image itself available_w = screen_width * ZOOM_DESKTOP_RATIO available_h = screen_height * ZOOM_DESKTOP_RATIO filmstrip_position = self.main_win.filmstrip_position \ if self.main_win else 'bottom' if self.filmstrip.isVisible(): if filmstrip_position in ('left', 'right'): available_w -= self.filmstrip.width() else: # top, bottom available_h -= self.filmstrip.height() if self.status_bar_container.isVisible(): available_h -= self.status_bar_container.sizeHint().height() should_resize = True orig_w = self.controller.pixmap_original.width() orig_h = self.controller.pixmap_original.height() if orig_w > 0 and orig_h > 0: factor = min(available_w / orig_w, available_h / orig_h) if self.isFullScreen(): self.controller.zoom_factor = factor else: self.controller.zoom_factor = min(1.0, factor) else: self.controller.zoom_factor = 1.0 self.update_view(resize_win=should_resize) # Defer sync to ensure layout and scroll area are ready, fixing navigation sync QTimer.singleShot( 0, lambda: self.sync_filmstrip_selection(self.controller.index)) @Slot(list) def update_image_list(self, new_list): """Updates the controller's image list ensuring the current image remains selected.""" current_path = self.controller.get_current_path() # If controller is empty but we have a new list, perform initial load logic if not current_path and new_list: self.controller.update_list(new_list, 0) self.load_and_fit_image() return final_list = list(new_list) new_index = -1 if current_path: # 1. Try exact match try: new_index = final_list.index(current_path) except ValueError: # 2. Try normpath match (fixes slashes/dots issues) norm_current = os.path.normpath(current_path) abs_current = os.path.abspath(current_path) real_current = os.path.realpath(current_path) for i, path in enumerate(final_list): if os.path.normpath(path) == norm_current or \ os.path.abspath(path) == abs_current or \ os.path.realpath(path) == real_current: new_index = i break # 3. If still not found, add it to preserve context if new_index == -1: if current_path not in final_list: final_list.append(current_path) final_list.sort() try: new_index = final_list.index(current_path) except ValueError: new_index = 0 if new_index != -1: self.controller.update_list(final_list, new_index) else: # If current path lost, just update list, index defaults/clamps self.controller.update_list(final_list) # Only reload if the path actually changed effectively if self.controller.get_current_path() != current_path: self.load_and_fit_image() @Slot(int) def on_controller_list_updated(self, new_index): """Handles the controller's list_updated signal to refresh the UI.""" self.populate_filmstrip() self.update_status_bar(index=new_index) def _on_movie_frame(self): """Updates the view with the current frame from the movie.""" if self.movie and self.movie.isValid(): self.controller.pixmap_original = self.movie.currentPixmap() self.update_view(resize_win=False) def toggle_animation_pause(self): """Pauses or resumes the current animation.""" if self.movie: is_paused = self.movie.state() == QMovie.Paused self.movie.setPaused(not is_paused) self.update_title() def zoom(self, factor, reset=False): """Applies zoom to the image.""" if reset: self.controller.zoom_factor = 1.0 self.update_view(resize_win=True) else: self.controller.zoom_factor *= factor self.update_view(resize_win=True) # Notify the main window that the image (and possibly index) has changed # so it can update its selection. self.index_changed.emit(self.controller.index) self.sync_filmstrip_selection(self.controller.index) def zoom_to_rect(self, face_rect): """Zooms and pans the view to center on a given normalized rectangle.""" if self.controller.pixmap_original.isNull(): return viewport = self.scroll_area.viewport() vp_w = viewport.width() vp_h = viewport.height() # Use the original pixmap dimensions for zoom calculation transform = QTransform().rotate(self.controller.rotation) transformed_pixmap = self.controller.pixmap_original.transformed( transform, Qt.SmoothTransformation) img_w = transformed_pixmap.width() img_h = transformed_pixmap.height() if img_w == 0 or img_h == 0: return # Calculate the size of the face in original image pixels face_pixel_w = face_rect['w'] * img_w face_pixel_h = face_rect['h'] * img_h if face_pixel_w == 0 or face_pixel_h == 0: return # Calculate zoom factor to make the face fill ~70% of the viewport zoom_w = (vp_w * 0.7) / face_pixel_w zoom_h = (vp_h * 0.7) / face_pixel_h new_zoom = min(zoom_w, zoom_h) self.controller.zoom_factor = new_zoom self.update_view(resize_win=False) # Defer centering until after the view has been updated QTimer.singleShot(0, lambda: self._center_on_face(face_rect)) def _center_on_face(self, face_rect): """Scrolls the viewport to center on the face.""" canvas_w = self.canvas.width() canvas_h = self.canvas.height() viewport = self.scroll_area.viewport() vp_w = viewport.width() vp_h = viewport.height() # Face center in the newly zoomed canvas coordinates face_center_x_px = face_rect['x'] * canvas_w face_center_y_px = face_rect['y'] * canvas_h # Calculate the target scrollbar value to center the point scroll_x = face_center_x_px - (vp_w / 2) scroll_y = face_center_y_px - (vp_h / 2) self.scroll_area.horizontalScrollBar().setValue(int(scroll_x)) self.scroll_area.verticalScrollBar().setValue(int(scroll_y)) def apply_rotation(self, rotation, resize_win=False): """ Applies a rotation to the current image. Args: rotation (int): The angle in degrees to rotate by. resize_win (bool): If True, the window will be resized. Defaults to False. """ if self.controller.pixmap_original.isNull(): return self.controller.rotate(rotation) self.update_view(resize_win) def update_view(self, 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. """ pixmap = self.controller.get_display_pixmap() if pixmap.isNull(): return self.canvas.setPixmap(pixmap) self.canvas.adjustSize() 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() filmstrip_position = self.main_win.filmstrip_position \ if self.main_win else 'bottom' is_vertical_filmstrip = filmstrip_position in ('left', 'right') if self.status_bar_container.isVisible(): content_h += self.status_bar_container.sizeHint().height() if self.filmstrip.isVisible(): if is_vertical_filmstrip: content_w += self.filmstrip.width() else: # top, bottom content_h += self.filmstrip.height() target_w = content_w + VIEWER_FORM_MARGIN target_h = content_h + VIEWER_FORM_MARGIN # Use robust resolution detection for standalone mode to fix sizing issues if not self.isVisible() and ( not self.main_win or not self.main_win.isVisible()): sw, sh = self.get_desktop_resolution() target_w = min(target_w, sw) target_h = min(target_h, sh) else: screen = self.screen() if not self.isVisible() and self.main_win and self.main_win.isVisible(): screen = self.main_win.screen() avail_geo = screen.availableGeometry() target_w = min(target_w, avail_geo.width()) target_h = min(target_h, avail_geo.height()) self.resize(target_w, target_h) self.update_title() self.update_status_bar() def rename_current_image(self): """ Opens a dialog to rename the current image file. Handles the file system rename operation and updates the internal state. """ if not self.controller.image_list: return old_path = self.controller.get_current_path() if not old_path: return old_dir = os.path.dirname(old_path) old_filename = os.path.basename(old_path) base_name, extension = os.path.splitext(old_filename) new_base, ok = QInputDialog.getText( self, UITexts.RENAME_VIEWER_TITLE, UITexts.RENAME_VIEWER_TEXT.format(old_filename), QLineEdit.Normal, base_name ) if ok and new_base and new_base != base_name: new_base_name, new_extension = os.path.splitext(new_base) if new_extension == extension: new_filename = new_base else: new_filename = new_base_name + extension new_path = os.path.join(old_dir, new_filename) if self.movie: self.movie.stop() self.movie = None if os.path.exists(new_path): QMessageBox.warning(self, UITexts.ERROR, UITexts.RENAME_VIEWER_ERROR_EXISTS.format( new_filename)) return try: os.rename(old_path, new_path) self.controller.image_list[self.controller.index] = new_path if self.main_win: self.main_win.propagate_rename(old_path, new_path, self) self.update_view(resize_win=False) self.populate_filmstrip() self.update_title() except Exception as e: QMessageBox.critical(self, UITexts.RENAME_VIEWER_ERROR_SYSTEM, UITexts.RENAME_VIEWER_ERROR_TEXT.format(str(e))) 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.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() def show_crop_menu(self, global_pos): """Shows a context menu for the crop selection.""" menu = QMenu(self) save_action = menu.addAction(UITexts.VIEWER_MENU_SAVE_CROP) cancel_action = menu.addAction(UITexts.CLOSE) res = menu.exec(global_pos) if res == save_action: self.save_cropped_image() elif res == cancel_action: self.canvas.crop_rect = QRect() self.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(): return # Get normalized coordinates from the canvas rect nx, ny, nw, nh = self.canvas.map_to_source(self.canvas.crop_rect) # Use original pixmap to extract high-quality crop orig = self.controller.pixmap_original if orig.isNull(): return W, H = orig.width(), orig.height() # Convert normalized center/size back to top-left pixel coordinates # nx, ny are center coordinates x = int((nx - nw/2) * W) y = int((ny - nh/2) * H) w = int(nw * W) h = int(nh * H) # Validate boundaries x = max(0, x) y = max(0, y) w = min(w, W - x) h = min(h, H - y) if w <= 0 or h <= 0: return cropped = orig.copy(x, y, w, h) default_dir = os.path.dirname(self.controller.get_current_path()) file_name, _ = QFileDialog.getSaveFileName( self, UITexts.SAVE_CROP_TITLE, default_dir, UITexts.SAVE_CROP_FILTER) if file_name: cropped.save(file_name) # Optionally stay in crop mode or exit self.canvas.crop_rect = QRect() self.canvas.update() def update_title(self): """Updates the window title with the current image name.""" title = f"{VIEWER_LABEL} - {os.path.basename( self.controller.get_current_path())}" if self.slideshow_timer.isActive() or self.slideshow_reverse_timer.isActive(): title += UITexts.VIEWER_TITLE_SLIDESHOW if self.movie and self.movie.state() == QMovie.Paused: title += UITexts.VIEWER_TITLE_PAUSED self.setWindowTitle(title) def update_status_bar(self, metadata=None, index=None): """ Updates the status bar with image dimensions, zoom level, and tags read from extended attributes. """ total = len(self.controller.image_list) # Use provided index if available, otherwise get from controller current_idx = index if index is not None else self.controller.index idx = current_idx + 1 if total > 0 else 0 self.sb_index_label.setText(f"[{idx}/{total}]") if self.controller.pixmap_original.isNull(): self.sb_info_label.setText("") self.sb_tags_label.setText("") return 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}%") # Use tags from controller's internal state display_tags = [t.strip().split('/')[-1] for t in self.controller._current_tags if t.strip()] self.sb_tags_label.setText(", ".join(display_tags)) @Slot(str, dict) def on_metadata_changed(self, path, metadata=None): """ Slot to handle metadata changes from the controller. Updates the status bar and notifies the main window to refresh its views. """ if self.controller.get_current_path() == path: self.update_status_bar(metadata) if self.main_win: self.main_win.update_metadata_for_path(path, metadata) def restore_scroll(self, 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)) def get_state(self): """ Captures the complete state of the viewer for saving to a layout. Returns: dict: A dictionary containing geometry, zoom, rotation, scroll positions, and the current image path. """ geo = self.geometry() return { "path": self.controller.get_current_path(), "index": self.controller.index, "geometry": { "x": geo.x(), "y": geo.y(), "w": geo.width(), "h": geo.height() }, "zoom": self.controller.zoom_factor, "rotation": self.controller.rotation, "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(), "status_bar_visible": self.status_bar_container.isVisible(), "filmstrip_visible": self.filmstrip.isVisible() } def first_image(self): """Navigates to the first image in the list.""" self.controller.first() self.index_changed.emit(self.controller.index) self._is_persistent = False self.load_and_fit_image() def last_image(self): """Navigates to the last image in the list.""" self.controller.last() self.index_changed.emit(self.controller.index) self._is_persistent = False self.load_and_fit_image() def next_image(self): """Navigates to the next image in the list (wraps around).""" self.controller.next() self.index_changed.emit(self.controller.index) self._is_persistent = False self.load_and_fit_image() def prev_image(self): """Navigates to the previous image in the list (wraps around).""" self.controller.prev() self.index_changed.emit(self.controller.index) self._is_persistent = False self.load_and_fit_image() def toggle_slideshow(self): """Starts or stops the automatic slideshow timer.""" if self.slideshow_reverse_timer.isActive(): self.slideshow_reverse_timer.stop() if self.slideshow_timer.isActive(): self.slideshow_timer.stop() else: self.slideshow_timer.start() self.update_view(resize_win=False) def toggle_slideshow_reverse(self): """Starts or stops the automatic reverse slideshow timer.""" if self.slideshow_timer.isActive(): self.slideshow_timer.stop() if self.slideshow_reverse_timer.isActive(): self.slideshow_reverse_timer.stop() else: self.slideshow_reverse_timer.start() self.update_view(resize_win=False) def set_slideshow_interval(self): """Opens a dialog to set the slideshow interval in seconds.""" val, ok = QInputDialog.getInt(self, UITexts.SLIDESHOW_INTERVAL_TITLE, UITexts.SLIDESHOW_INTERVAL_TEXT, self.slideshow_timer.interval() // 1000, 1, 3600) if ok: new_interval_ms = val * 1000 self.slideshow_timer.setInterval(new_interval_ms) self.slideshow_reverse_timer.setInterval(new_interval_ms) if self.slideshow_timer.isActive(): self.slideshow_timer.start() if self.slideshow_reverse_timer.isActive(): self.slideshow_reverse_timer.start() def toggle_fullscreen(self): """Toggles the viewer window between fullscreen and normal states.""" if self.isFullScreen(): self.showNormal() else: self.showFullScreen() def close_or_exit_fullscreen(self): """Closes the viewer or exits fullscreen if active.""" if self.isFullScreen(): self.toggle_fullscreen() else: self.close() def refresh_shortcuts(self): """Re-loads shortcuts from the main window configuration.""" self._setup_shortcuts() def toggle_fit_to_screen(self): """ Toggles between fitting the image to the window and 100% actual size. """ # If close to 100%, fit to window. Otherwise 100%. if abs(self.controller.zoom_factor - 1.0) < 0.01: self.fit_to_window() else: self.controller.zoom_factor = 1.0 self.update_view(resize_win=False) def fit_to_window(self): """ Calculates the zoom factor required to make the image fit perfectly within the current viewport dimensions. """ if self.controller.pixmap_original.isNull(): return viewport = self.scroll_area.viewport() w_avail = viewport.width() h_avail = viewport.height() transform = QTransform().rotate(self.controller.rotation) transformed_pixmap = self.controller.pixmap_original.transformed( transform, Qt.SmoothTransformation) img_w = transformed_pixmap.width() img_h = transformed_pixmap.height() if img_w == 0 or img_h == 0: return self.controller.zoom_factor = min(w_avail / img_w, h_avail / img_h) self.update_view(resize_win=False) def _get_clicked_face(self, pos): """Checks if a click position is inside any face bounding box.""" for face in self.controller.faces: rect = self.canvas.map_from_source(face) if rect.contains(pos): return face return None def _show_face_context_menu(self, event): """ Shows a context menu for a clicked face region. Returns True if a menu was shown, False otherwise. """ if not self.controller.show_faces: return False pos = self.canvas.mapFromGlobal(event.globalPos()) clicked_face = self._get_clicked_face(pos) if not clicked_face: return False menu = QMenu(self) action_del = menu.addAction(UITexts.DELETE_FACE) action_ren = menu.addAction(UITexts.RENAME_FACE_TITLE) res = menu.exec(event.globalPos()) if res == action_del: face_name = clicked_face.get('name', '') self.controller.remove_face(clicked_face) if face_name: has_other = any(f.get('name') == face_name for f in self.controller.faces) if not has_other: self.controller.toggle_tag(face_name, False) self.canvas.update() elif res == action_ren: self.rename_face(clicked_face) return True def rename_face(self, face_to_rename): """Opens a dialog to rename a specific face/area.""" if not face_to_rename: return region_type = face_to_rename.get('type', 'Face') history_list = [] if self.main_win: if region_type == "Pet": history_list = self.main_win.pet_names_history elif region_type == "Object": history_list = self.main_win.object_names_history elif region_type == "Landmark": history_list = self.main_win.landmark_names_history else: # Face history_list = self.main_win.face_names_history history = history_list if self.main_win else [] current_name = face_to_rename.get('name', '') new_full_tag, updated_history, ok = FaceNameDialog.get_name( self, history, current_name, main_win=self.main_win, region_type=region_type, title=UITexts.RENAME_FACE_TITLE) if ok and new_full_tag and new_full_tag != current_name: # Remove old tag if it's not used by other faces if current_name: has_other = any(f.get('name') == current_name for f in self.controller.faces if f is not face_to_rename) if not has_other: self.controller.toggle_tag(current_name, False) # Update face and history face_to_rename['name'] = new_full_tag if self.main_win: if region_type == "Pet": self.main_win.pet_names_history = updated_history elif region_type == "Object": self.main_win.object_names_history = updated_history elif region_type == "Landmark": self.main_win.landmark_names_history = updated_history else: # Face self.main_win.face_names_history = updated_history # Save changes and add new tag self.controller.save_faces() self.controller.toggle_tag(new_full_tag, True) self.canvas.update() def toggle_main_window_visibility(self): """Toggles the visibility of the main window.""" if self.main_win: self.main_win.toggle_visibility() def show_properties(self): """Shows the properties dialog for the current image.""" path = self.controller.get_current_path() if path: tags = self.controller._current_tags rating = self.controller._current_rating dlg = PropertiesDialog( path, initial_tags=tags, initial_rating=rating, parent=self) dlg.exec() def _create_viewer_context_menu(self): """Builds and returns the general viewer context menu.""" menu = QMenu(self) # Add "Open With" submenu if self.main_win: path = self.controller.get_current_path() if path: open_submenu = menu.addMenu(QIcon.fromTheme("document-open"), UITexts.CONTEXT_MENU_OPEN) self.main_win.populate_open_with_submenu(open_submenu, path) menu.addSeparator() menu_items = [ {"text": UITexts.VIEWER_MENU_TAGS, "action": "fast_tag", "icon": "document-properties"}, "separator", {"text": UITexts.DETECT_FACES, "action": "detect_faces", "icon": "edit-image-face-recognize"}, "separator", {"text": UITexts.DETECT_PETS, "action": "detect_pets", "icon": "edit-image-face-recognize"}, "separator", {"text": UITexts.VIEWER_MENU_ROTATE, "icon": "transform-rotate", "submenu": [ {"text": UITexts.VIEWER_MENU_ROTATE_LEFT, "action": "rotate_left", "icon": "object-rotate-left"}, {"text": UITexts.VIEWER_MENU_ROTATE_RIGHT, "action": "rotate_right", "icon": "object-rotate-right"} ]}, "separator", {"text": UITexts.VIEWER_MENU_FLIP, "icon": "transform-flip", "submenu": [ {"text": UITexts.VIEWER_MENU_FLIP_H, "action": "flip_horizontal", "icon": "object-flip-horizontal"}, {"text": UITexts.VIEWER_MENU_FLIP_V, "action": "flip_vertical", "icon": "object-flip-vertical"} ]}, "separator", {"text": UITexts.VIEWER_MENU_RENAME, "action": "rename", "icon": "edit-rename"}, "separator", {"text": UITexts.VIEWER_MENU_FIT_SCREEN, "slot": self.toggle_fit_to_screen, "icon": "zoom-fit-best"}, "separator", {"text": UITexts.VIEWER_MENU_CROP, "action": "toggle_crop", "icon": "transform-crop", "checkable": True, "checked": self.crop_mode}, "separator", ] if self.movie: is_paused = self.movie.state() == QMovie.Paused_ pause_text = (UITexts.VIEWER_MENU_RESUME_ANIMATION if is_paused else UITexts.VIEWER_MENU_PAUSE_ANIMATION) pause_icon = ("media-playback-start" if is_paused else "media-playback-pause") menu_items.append({"text": pause_text, "action": "toggle_animation", "icon": pause_icon}) is_fwd_slideshow = self.slideshow_timer.isActive() is_rev_slideshow = self.slideshow_reverse_timer.isActive() slideshow_submenu = [ {"text": UITexts.VIEWER_MENU_STOP_SLIDESHOW if is_fwd_slideshow else UITexts.VIEWER_MENU_START_SLIDESHOW, "action": "slideshow", "icon": "media-playback-stop" if is_fwd_slideshow else "media-playback-start"}, {"text": UITexts.VIEWER_MENU_STOP_REVERSE_SLIDESHOW if is_rev_slideshow else UITexts.VIEWER_MENU_START_REVERSE_SLIDESHOW, "action": "slideshow_reverse", "icon": "media-playback-stop" if is_rev_slideshow else "media-seek-backward"}, {"text": UITexts.VIEWER_MENU_SET_INTERVAL, "slot": self.set_slideshow_interval, "icon": "preferences-system-time"} ] menu_items.extend([ {"text": UITexts.VIEWER_MENU_SLIDESHOW, "icon": "view-presentation", "submenu": slideshow_submenu}, "separator", {"text": UITexts.SHOW_FACES, "action": "toggle_faces", "icon": "edit-image-face-show", "checkable": True, "checked": self.controller.show_faces}, {"text": UITexts.VIEWER_MENU_SHOW_FILMSTRIP, "action": "toggle_filmstrip", "icon": "view-filmstrip", "checkable": True, "checked": self.filmstrip.isVisible()}, {"text": UITexts.VIEWER_MENU_SHOW_STATUSBAR, "action": "toggle_statusbar", "icon": "view-bottom-panel", "checkable": True, "checked": self.status_bar_container.isVisible()}, "separator", {"text": UITexts.VIEWER_MENU_EXIT_FULLSCREEN if self.isFullScreen() else UITexts.VIEWER_MENU_ENTER_FULLSCREEN, "action": "fullscreen", "icon": "view-fullscreen" if not self.isFullScreen() else "view-restore"}, "separator", {"text": "Show/hide main window", "action": "toggle_visibility", "icon": "view-restore"}, "separator", {"text": UITexts.CONTEXT_MENU_PROPERTIES, "action": "properties", "icon": "document-properties"} ]) def build_actions(target_menu, items): for item in items: if item == "separator": target_menu.addSeparator() continue action_name = item.get("action") display_text = item["text"] # Only add shortcut to final actions, not to submenus if action_name and "submenu" not in item and \ action_name in self.action_to_shortcut: key, mods = self.action_to_shortcut[action_name] # Handle both Qt.KeyboardModifier (enum) and Qt.KeyboardModifiers # (flags) by ensuring we have an integer value for the modifier # before the bitwise OR. try: mod_val = int(mods) except TypeError: mod_val = mods.value seq = QKeySequence(mod_val | key) shortcut_str = seq.toString(QKeySequence.NativeText) if shortcut_str: display_text += f"\t{shortcut_str}" icon = QIcon.fromTheme(item.get("icon", "")) if "submenu" in item: submenu = target_menu.addMenu(icon, item["text"]) build_actions(submenu, item["submenu"]) else: action = target_menu.addAction(icon, display_text) slot = item.get("slot") if action_name: action.triggered.connect( lambda checked=False, name=action_name: self._execute_action(name)) elif slot: action.triggered.connect(slot) if item.get("checkable"): action.setCheckable(True) action.setChecked(item.get("checked", False)) build_actions(menu, menu_items) return menu def _show_viewer_context_menu(self, event): """Creates and shows the general viewer context menu.""" menu = self._create_viewer_context_menu() menu.exec(event.globalPos()) def _calculate_iou(self, boxA, boxB): """Calculates Intersection over Union for two face boxes.""" # Convert from center-based (x,y,w,h) to corner-based (x1,y1,x2,y2) boxA_x1 = boxA['x'] - boxA['w'] / 2 boxA_y1 = boxA['y'] - boxA['h'] / 2 boxA_x2 = boxA['x'] + boxA['w'] / 2 boxA_y2 = boxA['y'] + boxA['h'] / 2 boxB_x1 = boxB['x'] - boxB['w'] / 2 boxB_y1 = boxB['y'] - boxB['h'] / 2 boxB_x2 = boxB['x'] + boxB['w'] / 2 boxB_y2 = boxB['y'] + boxB['h'] / 2 # Determine the coordinates of the intersection rectangle xA = max(boxA_x1, boxB_x1) yA = max(boxA_y1, boxB_y1) xB = min(boxA_x2, boxB_x2) yB = min(boxA_y2, boxB_y2) # Compute the area of intersection interArea = max(0, xB - xA) * max(0, yB - yA) # Compute the area of both bounding boxes boxAArea = boxA['w'] * boxA['h'] boxBArea = boxB['w'] * boxB['h'] # Compute the intersection over union denominator = float(boxAArea + boxBArea - interArea) iou = interArea / denominator if denominator > 0 else 0 return iou def toggle_flip_horizontal(self): """Horizontally flips the image.""" self.controller.toggle_flip_h() self.update_view(resize_win=False) def toggle_flip_vertical(self): """Vertically flips the image.""" self.controller.toggle_flip_v() self.update_view(resize_win=False) def contextMenuEvent(self, event): """Shows a context menu with viewer options. If a face region is clicked while face display is active, it shows a context menu for that face. Otherwise, it shows the general viewer context menu. 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): self.show_crop_menu(event.globalPos()) return if self._show_face_context_menu(event): return # Face menu was shown and handled # If no face was clicked or faces are not shown, show the general menu self._show_viewer_context_menu(event) def run_face_detection(self): """Runs face detection on the current image.""" QApplication.setOverrideCursor(Qt.WaitCursor) try: new_faces = self.controller.detect_faces() finally: QApplication.restoreOverrideCursor() if not new_faces: return IOU_THRESHOLD = 0.7 # If IoU is > 70%, consider it the same face added_count = 0 for new_face in new_faces: is_duplicate = False for existing_face in self.controller.faces: iou = self._calculate_iou(new_face, existing_face) if iou > IOU_THRESHOLD: is_duplicate = True break if is_duplicate: continue if not self.controller.show_faces: self.toggle_faces() self.controller.faces.append(new_face) 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) QApplication.processEvents() history = self.main_win.face_names_history if self.main_win else [] full_tag, updated_history, ok = FaceNameDialog.get_name( self, history, main_win=self.main_win) if ok and full_tag: new_face['name'] = full_tag self.controller.toggle_tag(full_tag, True) if self.main_win: self.main_win.face_names_history = updated_history added_count += 1 else: # If user cancels, remove the face that was temporarily added self.controller.faces.pop() self.canvas.update() if added_count > 0: self.controller.save_faces() def run_pet_detection(self): """Runs pet detection on the current image.""" QApplication.setOverrideCursor(Qt.WaitCursor) try: new_pets = self.controller.detect_pets() finally: QApplication.restoreOverrideCursor() if not new_pets: return IOU_THRESHOLD = 0.7 added_count = 0 for new_pet in new_pets: is_duplicate = False for existing_face in self.controller.faces: iou = self._calculate_iou(new_pet, existing_face) if iou > IOU_THRESHOLD: is_duplicate = True break if is_duplicate: continue if not self.controller.show_faces: self.toggle_faces() self.controller.faces.append(new_pet) 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) QApplication.processEvents() history = self.main_win.pet_names_history if self.main_win else [] full_tag, updated_history, ok = FaceNameDialog.get_name( self, history, main_win=self.main_win, region_type="Pet") if ok and full_tag: new_pet['name'] = full_tag self.controller.toggle_tag(full_tag, True) if self.main_win: self.main_win.pet_names_history = updated_history added_count += 1 else: self.controller.faces.pop() self.canvas.update() if added_count > 0: self.controller.save_faces() def toggle_filmstrip(self): """Shows or hides the filmstrip widget.""" visible = not self.filmstrip.isVisible() self.filmstrip.setVisible(visible) if visible: self.populate_filmstrip() if self.main_win: self.main_win.show_filmstrip = visible self.main_win.save_config() def toggle_status_bar(self): """Shows or hides the status bar widget.""" visible = not self.status_bar_container.isVisible() self.status_bar_container.setVisible(visible) if self.main_win: self.main_win.show_viewer_status_bar = visible self.main_win.save_config() def toggle_faces(self): """Toggles the display of face regions.""" self.controller.show_faces = not self.controller.show_faces if self.main_win: self.main_win.show_faces = self.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.""" self.fast_tag_manager.show_menu() def changeEvent(self, event): """ Handles window state changes to sync with the main view on activation. """ if event.type() == QEvent.ActivationChange and self.isActiveWindow(): self.activated.emit() elif event.type() == QEvent.WindowStateChange: if self.windowState() & Qt.WindowFullScreen: self.reset_inactivity_timer() else: self.hide_controls_timer.stop() self.unsetCursor() if self.main_win: self.status_bar_container.setVisible( self.main_win.show_viewer_status_bar) super().changeEvent(event) def wheelEvent(self, event): """ Handles mouse wheel events for zooming (with Ctrl) or navigation. Args: event (QWheelEvent): The mouse wheel event. """ self.reset_inactivity_timer() if event.modifiers() & Qt.ControlModifier: # Zoom with Ctrl + Wheel if event.angleDelta().y() > 0: self.controller.zoom_factor *= 1.1 else: self.controller.zoom_factor *= 0.9 self.update_view(resize_win=True) else: # Navigate next/previous based on configurable speed speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT) # A standard tick is 120. We define a threshold based on speed. # Speed 1 (slowest) requires a full 120 delta. # Speed 10 (fastest) requires 120/10 = 12 delta. threshold = 120 / speed self._wheel_scroll_accumulator += event.angleDelta().y() # Process all accumulated delta while abs(self._wheel_scroll_accumulator) >= threshold: if self._wheel_scroll_accumulator < 0: # Scrolled down -> next image self.next_image() self._wheel_scroll_accumulator += threshold else: # Scrolled up -> previous image self.prev_image() self._wheel_scroll_accumulator -= threshold # --- Keyboard Handling --- def keyPressEvent(self, event): """ Handles key press events for navigation and other shortcuts. Args: event (QKeyEvent): The key press event. """ self.reset_inactivity_timer() key_code = event.key() modifiers = event.modifiers() & (Qt.ShiftModifier | Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier) key_combo = (key_code, modifiers) action = self.shortcuts.get(key_combo) if action: self._execute_action(action) event.accept() else: super().keyPressEvent(event) # --- Delete Management --- def refresh_after_delete(self, new_list, deleted_idx=-1): """ Refreshes the viewer after an image has been deleted from the main window. Args: new_list (list): The updated list of image paths. deleted_idx (int): The index of the deleted image in the old list. """ self.controller.update_list(new_list) if not self.controller.image_list: self.close() return if 0 <= deleted_idx < self.filmstrip.count(): item = self.filmstrip.takeItem(deleted_idx) del item # Ensure the QListWidgetItem is deleted else: self.populate_filmstrip() # Fallback to full rebuild # Reload image in case the current one was deleted or index changed self.load_and_fit_image() # Notify the main window that the image (and possibly index) has changed # so it can update its selection. self.index_changed.emit(self.controller.index) # --- Window Close --- def closeEvent(self, event): """ Handles the window close event. Ensures the screensaver is uninhibited and checks if the application should exit if it's the last open viewer. Args: event (QCloseEvent): The close event. """ if self.movie: self.movie.stop() self.slideshow_timer.stop() self.slideshow_reverse_timer.stop() if self.filmstrip_loader and self.filmstrip_loader.isRunning(): self.filmstrip_loader.stop() self.uninhibit_screensaver() self.controller.cleanup() # If we close the last viewer and the main window is hidden, quit. if self.main_win and not self.main_win.isVisible(): # Check how many viewers are left viewers = [w for w in QApplication.topLevelWidgets() if isinstance( w, ImageViewer) and w.isVisible()] # 'viewers' includes 'self' as it's not fully destroyed yet if len(viewers) <= 1: self.main_win.perform_shutdown() QApplication.quit() def set_window_icon(self): """Sets the window icon from the current theme.""" icon = QIcon.fromTheme(ICON_THEME_VIEWER, QIcon.fromTheme(ICON_THEME_VIEWER_FALLBACK)) self.setWindowIcon(icon) # --- DBus Inhibition --- def inhibit_screensaver(self): """ Prevents the screensaver or power management from activating. Uses DBus to send an inhibit request to the session's screen saver service, which is common on Linux desktops. """ try: cmd = [ "dbus-send", "--session", "--print-reply", "--dest=org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver.Inhibit", "string:bagheeraview", # Application name "string:Viewing images" # Reason for inhibition ] output = subprocess.check_output(cmd, text=True) # Extract the cookie from the output (e.g., "uint32 12345") self.inhibit_cookie = int(output.split()[-1]) except Exception as e: print(f"{UITexts.ERROR} inhibiting power management: {e}") self.inhibit_cookie = None def uninhibit_screensaver(self): """ Releases the screensaver inhibit lock. Uses DBus to uninhibit the screensaver using the cookie obtained during the inhibit call. """ if hasattr(self, 'inhibit_cookie') and self.inhibit_cookie is not None: try: subprocess.Popen([ "dbus-send", "--session", "--dest=org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver.UnInhibit", f"uint32:{self.inhibit_cookie}" ]) self.inhibit_cookie = None except Exception as e: print(f"{UITexts.ERROR} uninhibiting: {e}")