Files
BagheeraView/imageviewer.py
Ignacio Serantes 547bfbf760 A bunch of changes
2026-03-23 21:53:19 +01:00

2790 lines
109 KiB
Python

"""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_BODY_BOX_COLOR,
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 == "Body":
self.setWindowTitle(UITexts.ADD_BODY_TITLE)
layout_label = UITexts.ADD_BODY_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)
body_color_str = APP_CONFIG.get("body_box_color", DEFAULT_BODY_BOX_COLOR)
body_color = QColor(body_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_body = face.get('type') == 'Body'
is_object = face.get('type') == 'Object'
is_landmark = face.get('type') == 'Landmark'
if is_pet:
color = pet_color
elif is_body:
color = body_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_body = menu.addAction(UITexts.TYPE_BODY)
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_body:
region_type = "Body"
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 == "Body":
history_list = self.viewer.main_win.body_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 == "Body":
self.viewer.main_win.body_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,
"detect_bodies": self.run_body_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 self.filmstrip.isHidden():
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 metadata if provided (priority to avoid race conditions),
# otherwise fallback to controller's internal state.
tags_source = self.controller._current_tags
if metadata and 'tags' in metadata:
tags_source = metadata['tags']
display_tags = [t.strip().split('/')[-1]
for t in tags_source 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_AREA_TITLE)
action_ren = menu.addAction(UITexts.RENAME_AREA_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 == "Body":
history_list = self.main_win.body_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 == "Body":
self.main_win.body_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.DETECT_BODIES, "action": "detect_bodies",
"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 run_body_detection(self):
"""Runs body detection on the current image."""
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
new_bodies = self.controller.detect_bodies()
finally:
QApplication.restoreOverrideCursor()
if not new_bodies:
return
IOU_THRESHOLD = 0.7
added_count = 0
for new_body in new_bodies:
is_duplicate = False
for existing_face in self.controller.faces:
iou = self._calculate_iou(new_body, 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_body)
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)
QApplication.processEvents()
# For bodies, we typically don't ask for a name immediately unless desired
# Or we can treat it like pets/faces and ask. Let's ask.
history = self.main_win.body_names_history if self.main_win else []
full_tag, updated_history, ok = FaceNameDialog.get_name(
self, history, main_win=self.main_win, region_type="Body")
if ok and full_tag:
new_body['name'] = full_tag
self.controller.toggle_tag(full_tag, True)
if self.main_win:
self.main_win.body_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}")