A bunch of changes

This commit is contained in:
Ignacio Serantes
2026-03-23 22:50:02 +01:00
parent 547bfbf760
commit 291f2f9e47
8 changed files with 401 additions and 250 deletions

View File

@@ -24,7 +24,7 @@ from PySide6.QtGui import (
)
from PySide6.QtCore import (
Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF,
QThread
QThread, QObject
)
from constants import (
@@ -960,13 +960,203 @@ class FaceCanvas(QLabel):
# 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)
self.viewer.zoom_manager.zoom_to_rect(clicked_face)
event.accept()
return
# If no face was double-clicked, pass the event on
super().mouseDoubleClickEvent(event)
class SlideshowManager(QObject):
"""
Manages the slideshow functionality for the ImageViewer.
Separates the timing logic from the UI logic.
"""
def __init__(self, viewer):
super().__init__(viewer)
self.viewer = viewer
self.timer = QTimer(self)
self.timer.setInterval(3000)
self.timer.timeout.connect(self._on_timeout)
self._reverse = False
def _on_timeout(self):
"""Called when the timer fires to advance the slideshow."""
if self._reverse:
self.viewer.prev_image()
else:
self.viewer.next_image()
def start(self, reverse=False):
"""Starts the slideshow in the specified direction."""
self._reverse = reverse
self.timer.start()
def stop(self):
"""Stops the slideshow."""
self.timer.stop()
def toggle(self, reverse=False):
"""Toggles the slideshow on/off or changes direction."""
if self.timer.isActive():
if self._reverse == reverse:
self.stop()
else:
self.start(reverse)
else:
self.start(reverse)
def is_running(self):
"""Returns whether the slideshow is currently active."""
return self.timer.isActive()
def is_forward(self):
"""Returns whether the slideshow is running in forward mode."""
return self.timer.isActive() and not self._reverse
def is_reverse(self):
"""Returns whether the slideshow is running in reverse mode."""
return self.timer.isActive() and self._reverse
def set_interval(self, ms):
"""Sets the interval in milliseconds."""
self.timer.setInterval(ms)
if self.timer.isActive():
self.timer.start()
def get_interval(self):
"""Returns the current interval in milliseconds."""
return self.timer.interval()
class ZoomManager(QObject):
"""
Manages zoom calculations and state for the ImageViewer.
"""
def __init__(self, viewer):
super().__init__(viewer)
self.viewer = viewer
def zoom(self, factor, reset=False):
"""Applies zoom to the image."""
if reset:
self.viewer.controller.zoom_factor = 1.0
self.viewer.update_view(resize_win=True)
else:
self.viewer.controller.zoom_factor *= factor
self.viewer.update_view(resize_win=True)
# Notify the main window that the image (and possibly index) has changed
# so it can update its selection.
self.viewer.index_changed.emit(self.viewer.controller.index)
self.viewer.sync_filmstrip_selection(self.viewer.controller.index)
def toggle_fit_to_screen(self):
"""
Toggles between fitting the image to the window and 100% actual size.
"""
# If close to 100%, fit to window. Otherwise 100%.
if abs(self.viewer.controller.zoom_factor - 1.0) < 0.01:
self.fit_to_window()
else:
self.zoom(1.0, reset=True)
def fit_to_window(self):
"""
Calculates the zoom factor required to make the image fit perfectly
within the current viewport dimensions.
"""
if self.viewer.controller.pixmap_original.isNull():
return
viewport = self.viewer.scroll_area.viewport()
w_avail = viewport.width()
h_avail = viewport.height()
transform = QTransform().rotate(self.viewer.controller.rotation)
transformed_pixmap = self.viewer.controller.pixmap_original.transformed(
transform, Qt.SmoothTransformation)
img_w = transformed_pixmap.width()
img_h = transformed_pixmap.height()
if img_w == 0 or img_h == 0:
return
self.viewer.controller.zoom_factor = min(w_avail / img_w, h_avail / img_h)
self.viewer.update_view(resize_win=False)
def calculate_initial_zoom(self, available_w, available_h, is_fullscreen):
"""Calculates and sets the initial zoom factor when loading an image."""
orig_w = self.viewer.controller.pixmap_original.width()
orig_h = self.viewer.controller.pixmap_original.height()
if orig_w > 0 and orig_h > 0:
factor = min(available_w / orig_w, available_h / orig_h)
if is_fullscreen:
self.viewer.controller.zoom_factor = factor
else:
self.viewer.controller.zoom_factor = min(1.0, factor)
else:
self.viewer.controller.zoom_factor = 1.0
def zoom_to_rect(self, face_rect):
"""Zooms and pans the view to center on a given normalized rectangle."""
if self.viewer.controller.pixmap_original.isNull():
return
viewport = self.viewer.scroll_area.viewport()
vp_w = viewport.width()
vp_h = viewport.height()
# Use the original pixmap dimensions for zoom calculation
transform = QTransform().rotate(self.viewer.controller.rotation)
transformed_pixmap = self.viewer.controller.pixmap_original.transformed(
transform, Qt.SmoothTransformation)
img_w = transformed_pixmap.width()
img_h = transformed_pixmap.height()
if img_w == 0 or img_h == 0:
return
# Calculate the size of the face in original image pixels
face_pixel_w = face_rect['w'] * img_w
face_pixel_h = face_rect['h'] * img_h
if face_pixel_w == 0 or face_pixel_h == 0:
return
# Calculate zoom factor to make the face fill ~70% of the viewport
zoom_w = (vp_w * 0.7) / face_pixel_w
zoom_h = (vp_h * 0.7) / face_pixel_h
new_zoom = min(zoom_w, zoom_h)
self.viewer.controller.zoom_factor = new_zoom
self.viewer.update_view(resize_win=False)
# Defer centering until after the view has been updated
QTimer.singleShot(0, lambda: self._center_on_face(face_rect))
def _center_on_face(self, face_rect):
"""Scrolls the viewport to center on the face."""
canvas_w = self.viewer.canvas.width()
canvas_h = self.viewer.canvas.height()
viewport = self.viewer.scroll_area.viewport()
vp_w = viewport.width()
vp_h = viewport.height()
# Face center in the newly zoomed canvas coordinates
face_center_x_px = face_rect['x'] * canvas_w
face_center_y_px = face_rect['y'] * canvas_h
# Calculate the target scrollbar value to center the point
scroll_x = face_center_x_px - (vp_w / 2)
scroll_y = face_center_y_px - (vp_h / 2)
self.viewer.scroll_area.horizontalScrollBar().setValue(int(scroll_x))
self.viewer.scroll_area.verticalScrollBar().setValue(int(scroll_y))
class ImageViewer(QWidget):
"""
A standalone window for viewing and manipulating a single image.
@@ -1137,14 +1327,8 @@ class ImageViewer(QWidget):
self.hide_controls_timer.timeout.connect(self.hide_controls)
# 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)
self.slideshow_manager = SlideshowManager(self)
self.zoom_manager = ZoomManager(self)
# Load image
if restore_config:
@@ -1214,9 +1398,9 @@ class ImageViewer(QWidget):
"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),
"zoom_in": lambda: self.zoom_manager.zoom(1.1),
"zoom_out": lambda: self.zoom_manager.zoom(0.9),
"reset_zoom": lambda: self.zoom_manager.zoom(1.0, reset=True),
"toggle_animation": self.toggle_animation_pause,
"properties": self.show_properties,
"toggle_visibility": self.toggle_main_window_visibility,
@@ -1447,25 +1631,30 @@ class ImageViewer(QWidget):
Args:
restore_config (dict, optional): State dictionary to restore from.
"""
if self.movie:
self.movie.stop()
self.movie = None
success, reloaded = self.controller.load_image()
if not self.controller.load_image():
if not success:
if self.movie:
self.movie.stop()
self.movie = None
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()
if reloaded:
if self.movie:
self.movie.stop()
self.movie = None
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:
@@ -1481,7 +1670,7 @@ class ImageViewer(QWidget):
self.populate_filmstrip()
self.update_view(resize_win=False)
QTimer.singleShot(0, lambda: self.restore_scroll(restore_config))
else:
elif reloaded:
# Calculate zoom to fit the image on the screen
if self.isFullScreen():
viewport = self.scroll_area.viewport()
@@ -1521,19 +1710,14 @@ class ImageViewer(QWidget):
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.zoom_manager.calculate_initial_zoom(available_w, available_h,
self.isFullScreen())
self.update_view(resize_win=should_resize)
else:
# Image was reused and no restore config; just refresh the view to ensure
# metadata/faces are up to date without resetting zoom/pan.
self.update_view(resize_win=False)
# Defer sync to ensure layout and scroll area are ready, fixing navigation sync
QTimer.singleShot(
@@ -1608,78 +1792,6 @@ class ImageViewer(QWidget):
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.
@@ -1878,7 +1990,7 @@ class ImageViewer(QWidget):
"""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():
if self.slideshow_manager.is_running():
title += UITexts.VIEWER_TITLE_SLIDESHOW
if self.movie and self.movie.state() == QMovie.Paused:
title += UITexts.VIEWER_TITLE_PAUSED
@@ -1993,24 +2105,12 @@ class ImageViewer(QWidget):
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.slideshow_manager.toggle(reverse=False)
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.slideshow_manager.toggle(reverse=True)
self.update_view(resize_win=False)
def set_slideshow_interval(self):
@@ -2018,15 +2118,11 @@ class ImageViewer(QWidget):
val, ok = QInputDialog.getInt(self,
UITexts.SLIDESHOW_INTERVAL_TITLE,
UITexts.SLIDESHOW_INTERVAL_TEXT,
self.slideshow_timer.interval() // 1000, 1, 3600)
self.slideshow_manager.get_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()
self.slideshow_manager.set_interval(new_interval_ms)
def toggle_fullscreen(self):
"""Toggles the viewer window between fullscreen and normal states."""
@@ -2046,41 +2142,6 @@ class ImageViewer(QWidget):
"""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:
@@ -2145,7 +2206,7 @@ class ImageViewer(QWidget):
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)
region_type=region_type, title=UITexts.RENAME_AREA_TITLE)
if ok and new_full_tag and new_full_tag != current_name:
# Remove old tag if it's not used by other faces
@@ -2235,7 +2296,8 @@ class ImageViewer(QWidget):
{"text": UITexts.VIEWER_MENU_RENAME, "action": "rename",
"icon": "edit-rename"},
"separator",
{"text": UITexts.VIEWER_MENU_FIT_SCREEN, "slot": self.toggle_fit_to_screen,
{"text": UITexts.VIEWER_MENU_FIT_SCREEN,
"slot": self.zoom_manager.toggle_fit_to_screen,
"icon": "zoom-fit-best"},
"separator",
{"text": UITexts.VIEWER_MENU_CROP, "action": "toggle_crop",
@@ -2253,8 +2315,8 @@ class ImageViewer(QWidget):
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()
is_fwd_slideshow = self.slideshow_manager.is_forward()
is_rev_slideshow = self.slideshow_manager.is_reverse()
slideshow_submenu = [
{"text": UITexts.VIEWER_MENU_STOP_SLIDESHOW if is_fwd_slideshow
@@ -2722,8 +2784,7 @@ class ImageViewer(QWidget):
"""
if self.movie:
self.movie.stop()
self.slideshow_timer.stop()
self.slideshow_reverse_timer.stop()
self.slideshow_manager.stop()
if self.filmstrip_loader and self.filmstrip_loader.isRunning():
self.filmstrip_loader.stop()
self.uninhibit_screensaver()