v0.9.19
This commit is contained in:
@@ -45,6 +45,7 @@ class DuplicateManagerDialog(QDialog):
|
||||
self.table_widget.setCurrentCell(0, 0)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Sets up the user interface components for the duplicate manager."""
|
||||
layout = QHBoxLayout(self)
|
||||
|
||||
# Left side: List of pairs
|
||||
@@ -181,15 +182,79 @@ class DuplicateManagerDialog(QDialog):
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Resizes the images to fill available space when the dialog is resized."""
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, 'left_pane') and self.left_pane and \
|
||||
hasattr(self, 'right_pane') and self.right_pane:
|
||||
self._is_syncing = True
|
||||
super().resizeEvent(event) # Call base class resizeEvent
|
||||
self._apply_linked_scaling()
|
||||
|
||||
def _apply_linked_scaling(self):
|
||||
"""Applies custom linked scaling logic to both panels."""
|
||||
if not self.left_pane or not self.right_pane:
|
||||
return
|
||||
|
||||
# Ensure images are loaded to get original dimensions.
|
||||
# This also ensures pane.controller.pixmap_original is populated.
|
||||
self.left_pane.controller.load_image()
|
||||
self.right_pane.controller.load_image()
|
||||
|
||||
p_l = self.left_pane.controller.pixmap_original
|
||||
p_r = self.right_pane.controller.pixmap_original
|
||||
|
||||
# If panels are not linked or any image is null, adjust independently
|
||||
if not self.panes_linked or p_l.isNull() or p_r.isNull():
|
||||
self._is_syncing = True # Avoid recursion in _sync_zoom
|
||||
try:
|
||||
self.load_and_fit_image_for_pane(self.left_pane)
|
||||
self.load_and_fit_image_for_pane(self.right_pane)
|
||||
finally:
|
||||
self._is_syncing = False
|
||||
return
|
||||
|
||||
self._is_syncing = True
|
||||
try:
|
||||
# Get original dimensions
|
||||
w_l_orig, h_l_orig = p_l.width(), p_l.height()
|
||||
w_r_orig, h_r_orig = p_r.width(), p_r.height()
|
||||
|
||||
# Get available viewport size for each panel
|
||||
viewport_l = self.left_pane.scroll_area.viewport()
|
||||
viewport_r = self.right_pane.scroll_area.viewport()
|
||||
vp_w_l, vp_h_l = viewport_l.width(), viewport_l.height()
|
||||
vp_w_r, vp_h_r = viewport_r.width(), viewport_r.height()
|
||||
|
||||
# Determine the highest resolution image
|
||||
res_l = w_l_orig * h_l_orig
|
||||
res_r = w_r_orig * h_r_orig
|
||||
|
||||
if res_l >= res_r:
|
||||
high_res_pane = self.left_pane
|
||||
low_res_pane = self.right_pane
|
||||
high_res_w, high_res_h = w_l_orig, h_l_orig
|
||||
low_res_w, low_res_h = w_r_orig, h_r_orig
|
||||
vp_w_high, vp_h_high = vp_w_l, vp_h_l
|
||||
else:
|
||||
high_res_pane = self.right_pane
|
||||
low_res_pane = self.left_pane
|
||||
high_res_w, high_res_h = w_r_orig, h_r_orig
|
||||
low_res_w, low_res_h = w_l_orig, h_l_orig
|
||||
vp_w_high, vp_h_high = vp_w_r, vp_h_r
|
||||
|
||||
# Calculate zoom factor for high-res image to fit its panel
|
||||
zoom_high = 1.0
|
||||
if high_res_w > 0 and high_res_h > 0:
|
||||
zoom_high = min(vp_w_high / high_res_w, vp_h_high / high_res_h)
|
||||
|
||||
high_res_pane.controller.zoom_factor = zoom_high
|
||||
high_res_pane.update_view(resize_win=False)
|
||||
|
||||
# Calculate and apply zoom for low-res image relative to high-res
|
||||
zoom_low = 1.0
|
||||
if high_res_w > 0 and high_res_h > 0:
|
||||
relative_scale_factor = min(low_res_w / high_res_w,
|
||||
low_res_h / high_res_h)
|
||||
zoom_low = zoom_high * relative_scale_factor
|
||||
low_res_pane.controller.zoom_factor = zoom_low
|
||||
low_res_pane.update_view(resize_win=False)
|
||||
finally:
|
||||
self._is_syncing = False
|
||||
|
||||
def wheelEvent(self, event):
|
||||
"""Handles mouse wheel events for zooming (with Ctrl)."""
|
||||
@@ -340,6 +405,7 @@ class DuplicateManagerDialog(QDialog):
|
||||
return widget
|
||||
|
||||
def _populate_list(self):
|
||||
"""Fills the table widget with the list of duplicate results."""
|
||||
self.table_widget.setSortingEnabled(False)
|
||||
self.table_widget.blockSignals(True)
|
||||
self.table_widget.setRowCount(0)
|
||||
@@ -508,21 +574,29 @@ class DuplicateManagerDialog(QDialog):
|
||||
self.right_pane_widget.info_lbl.setStyleSheet(
|
||||
"font-weight: bold; color: #aaa;")
|
||||
|
||||
# Force view update and proportional scaling
|
||||
self._apply_linked_scaling()
|
||||
|
||||
def _set_pane_data(self, pane_widget, path, filename_color, dir_color,
|
||||
filename_text, dir_text) -> bool:
|
||||
"""Updates an ImagePane and its labels with file data."""
|
||||
pane = pane_widget.pane
|
||||
info_lbl = pane_widget.info_lbl
|
||||
filename_lbl = pane_widget.filename_lbl
|
||||
dir_lbl = pane_widget.dir_lbl
|
||||
|
||||
if not os.path.exists(path):
|
||||
info_lbl.setText("FILE NOT FOUND")
|
||||
info_lbl.setText(UITexts.FILE_NOT_FOUND)
|
||||
pane.controller.update_list([], 0) # Clear pane
|
||||
pane.load_and_fit_image()
|
||||
pane.controller.load_image()
|
||||
filename_lbl.setText("N/A")
|
||||
dir_lbl.setText("N/A")
|
||||
return True
|
||||
|
||||
# Load image into pane's controller FIRST to get accurate pixmap state
|
||||
pane.controller.update_list([path], 0)
|
||||
pane.controller.load_image()
|
||||
|
||||
# Metadata
|
||||
size_bytes = os.path.getsize(path)
|
||||
size_str = self._format_size(size_bytes)
|
||||
@@ -534,14 +608,6 @@ class DuplicateManagerDialog(QDialog):
|
||||
not pane.controller.pixmap_original.size().isValid())
|
||||
disable_linking = is_animated or is_invalid
|
||||
|
||||
self.panes_linked = self._user_link_preference and disable_linking
|
||||
self.btn_link_panes.setEnabled(disable_linking)
|
||||
self.btn_link_panes.setChecked(self.panes_linked)
|
||||
|
||||
# Load image into pane's controller
|
||||
pane.controller.update_list([path], 0)
|
||||
pane.load_and_fit_image()
|
||||
|
||||
# Update info labels
|
||||
if not pane.controller.pixmap_original.isNull():
|
||||
info_lbl.setText(UITexts.DUPLICATE_INFO_FORMAT.format(
|
||||
@@ -559,6 +625,7 @@ class DuplicateManagerDialog(QDialog):
|
||||
return disable_linking
|
||||
|
||||
def _show_pane_context_menu(self, pos):
|
||||
"""Displays a context menu for the pane that requested it."""
|
||||
pane = self.sender()
|
||||
path = pane.controller.get_current_path()
|
||||
if not path or not os.path.exists(path):
|
||||
@@ -618,6 +685,7 @@ class DuplicateManagerDialog(QDialog):
|
||||
menu.exec(pane.mapToGlobal(pos))
|
||||
|
||||
def _handle_permanent_delete(self, path):
|
||||
"""Prompts for and executes permanent deletion of a file."""
|
||||
confirm = QMessageBox(self)
|
||||
confirm.setIcon(QMessageBox.Warning)
|
||||
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
|
||||
@@ -629,6 +697,7 @@ class DuplicateManagerDialog(QDialog):
|
||||
self._handle_action(delete_path=path, permanent=True)
|
||||
|
||||
def _show_properties(self, path, pane):
|
||||
"""Shows the file properties dialog for a pane's image."""
|
||||
tags = pane.controller._current_tags
|
||||
rating = pane.controller._current_rating
|
||||
dlg = PropertiesDialog(
|
||||
@@ -636,6 +705,7 @@ class DuplicateManagerDialog(QDialog):
|
||||
dlg.exec()
|
||||
|
||||
def _on_pane_activated(self):
|
||||
"""Handles pane activation to synchronize viewing state if linked."""
|
||||
# When a pane is activated, ensure its zoom/scroll is the reference for linking
|
||||
if self.panes_linked:
|
||||
active_pane = self.sender() # The pane that emitted activated signal
|
||||
@@ -650,6 +720,7 @@ class DuplicateManagerDialog(QDialog):
|
||||
other_pane.set_scroll_relative(x_pct, y_pct)
|
||||
|
||||
def _sync_scroll(self, x_pct, y_pct):
|
||||
"""Synchronizes scroll position between panes if linked."""
|
||||
if not self.panes_linked:
|
||||
return
|
||||
source_pane = self.sender()
|
||||
@@ -659,35 +730,65 @@ class DuplicateManagerDialog(QDialog):
|
||||
self.left_pane.set_scroll_relative(x_pct, y_pct)
|
||||
|
||||
def _sync_zoom(self, factor, source_pane=None):
|
||||
"""Synchronizes zoom factor between panes if linked."""
|
||||
if not self.panes_linked or self._is_syncing:
|
||||
return
|
||||
if source_pane is None:
|
||||
# El emisor es el ZoomManager, su padre es el ImagePane
|
||||
# Emitter is ZoomManager, its parent is ImagePane
|
||||
sender = self.sender()
|
||||
source_pane = sender.parent() if sender else None
|
||||
|
||||
if not source_pane:
|
||||
return
|
||||
|
||||
# Ensure both images are loaded before syncing zoom
|
||||
if self.left_pane.controller.pixmap_original.isNull() or \
|
||||
self.right_pane.controller.pixmap_original.isNull():
|
||||
return
|
||||
|
||||
self._is_syncing = True
|
||||
try:
|
||||
# Capture current scroll percentage from source to apply to target
|
||||
h_bar = source_pane.scroll_area.horizontalScrollBar()
|
||||
v_bar = source_pane.scroll_area.verticalScrollBar()
|
||||
x_pct = h_bar.value() / h_bar.maximum() if h_bar.maximum() > 0 else 0
|
||||
y_pct = v_bar.value() / v_bar.maximum() if v_bar.maximum() > 0 else 0
|
||||
p_l = self.left_pane.controller.pixmap_original
|
||||
p_r = self.right_pane.controller.pixmap_original
|
||||
|
||||
target_pane = self.left_pane \
|
||||
if source_pane == self.right_pane else self.right_pane
|
||||
target_pane.zoom_manager.zoom(absolute_factor=factor)
|
||||
w_l_orig, h_l_orig = p_l.width(), p_l.height()
|
||||
w_r_orig, h_r_orig = p_r.width(), p_r.height()
|
||||
|
||||
# Re-apply relative scroll after zoom changes bounds
|
||||
QTimer.singleShot(
|
||||
0, lambda p=target_pane, x=x_pct, y=y_pct: p.set_scroll_relative(x, y))
|
||||
if w_l_orig == 0 or h_l_orig == 0 or w_r_orig == 0 or h_r_orig == 0:
|
||||
return # Avoid division by zero
|
||||
|
||||
# Calculate original size relationship.
|
||||
# Use ratio of "master" (high-res) to "slave" (low-res)
|
||||
# to maintain relative size.
|
||||
res_l = w_l_orig * h_l_orig
|
||||
res_r = w_r_orig * h_r_orig
|
||||
|
||||
if res_l >= res_r: # Left is same or higher resolution
|
||||
high_res_w, high_res_h = w_l_orig, h_l_orig
|
||||
low_res_w, low_res_h = w_r_orig, h_r_orig
|
||||
high_res_pane = self.left_pane
|
||||
low_res_pane = self.right_pane
|
||||
else: # Right is higher resolution
|
||||
high_res_w, high_res_h = w_r_orig, h_r_orig
|
||||
low_res_w, low_res_h = w_l_orig, h_l_orig
|
||||
high_res_pane = self.right_pane
|
||||
low_res_pane = self.left_pane
|
||||
|
||||
# 'factor' is the new zoom factor of the source panel.
|
||||
# Apply this to the high-res panel, then calculate low-res zoom.
|
||||
if source_pane == high_res_pane:
|
||||
low_res_pane.controller.zoom_factor = factor * min(
|
||||
low_res_w / high_res_w, low_res_h / high_res_h)
|
||||
low_res_pane.update_view(resize_win=False)
|
||||
else: # source_pane == low_res_pane
|
||||
high_res_pane.controller.zoom_factor = factor / min(
|
||||
low_res_w / high_res_w, low_res_h / high_res_h)
|
||||
high_res_pane.update_view(resize_win=False)
|
||||
finally:
|
||||
self._is_syncing = False
|
||||
|
||||
def _format_size(self, size):
|
||||
"""Formats a file size in bytes to a human-readable string."""
|
||||
for unit in ['B', 'KiB', 'MiB', 'GiB']:
|
||||
if size < 1024:
|
||||
return f"{size:.1f} {unit}"
|
||||
@@ -695,16 +796,19 @@ class DuplicateManagerDialog(QDialog):
|
||||
return f"{size:.1f} TiB"
|
||||
|
||||
def _delete_left(self):
|
||||
"""Triggers deletion of the image in the left pane."""
|
||||
path_to_delete = self.left_pane.controller.get_current_path()
|
||||
if path_to_delete:
|
||||
self._handle_action(delete_path=path_to_delete)
|
||||
|
||||
def _delete_right(self):
|
||||
"""Triggers deletion of the image in the right pane."""
|
||||
path_to_delete = self.right_pane.controller.get_current_path()
|
||||
if path_to_delete:
|
||||
self._handle_action(delete_path=path_to_delete)
|
||||
|
||||
def _toggle_link_panes(self):
|
||||
"""Toggles the link state between panes."""
|
||||
self._user_link_preference = self.btn_link_panes.isChecked()
|
||||
self.panes_linked = self._user_link_preference
|
||||
if self.panes_linked:
|
||||
@@ -767,6 +871,7 @@ class DuplicateManagerDialog(QDialog):
|
||||
self.table_widget.setCurrentCell(new_row, 0)
|
||||
|
||||
def _keep_both(self):
|
||||
"""Marks the current pair as an exception to ignore in future scans."""
|
||||
if self.current_dup_pair:
|
||||
self.cache.mark_as_exception(
|
||||
self.current_dup_pair.path1,
|
||||
@@ -777,6 +882,7 @@ class DuplicateManagerDialog(QDialog):
|
||||
self._handle_action(skip=False, permanent=False)
|
||||
|
||||
def _skip(self):
|
||||
"""Skips the current pair without marking it as an exception."""
|
||||
if self.review_mode and self.current_dup_pair:
|
||||
self.cache.mark_as_exception(
|
||||
self.current_dup_pair.path1, self.current_dup_pair.path2, False)
|
||||
@@ -793,6 +899,13 @@ class DuplicateManagerDialog(QDialog):
|
||||
self._handle_action(skip=True)
|
||||
|
||||
def _handle_action(self, delete_path=None, skip=False, permanent=None):
|
||||
"""
|
||||
Handles management actions (delete, skip, keep) for duplicate pairs.
|
||||
|
||||
Args:
|
||||
delete_path: Path to delete, if any.
|
||||
skip: Whether to skip the current pair.
|
||||
"""
|
||||
current_row = self.table_widget.currentRow()
|
||||
if current_row < 0:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user