import os from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame, QSplitter, QWidget, QMessageBox, QApplication, QMenu, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView ) from PySide6.QtGui import QPixmap, QIcon, QImageReader, QImage, QDesktopServices from PySide6.QtCore import Qt, QSize, QTimer, QUrl from imageviewer import ImagePane from imagecontroller import ImageController from constants import UITexts, APP_CONFIG from propertiesdialog import PropertiesDialog class DuplicateManagerDialog(QDialog): """ A dialog to review and manage duplicate image pairs found by the detector. """ def __init__(self, duplicates, duplicate_cache, main_win, review_mode=False): super().__init__(main_win) self.duplicates = duplicates # List of DuplicateResult self.cache = duplicate_cache self.main_win = main_win self.review_mode = review_mode self.active_pane = None self.current_dup_pair = None # Stores the current DuplicateResult object self.panes_linked = True # Default to linked self._is_syncing = False # Guard to prevent recursion during synchronization self.setWindowTitle(UITexts.DUPLICATE_MANAGER_TITLE) self.resize(1000, 700) self._setup_ui() self._populate_list() if self.main_win and hasattr(self.main_win, 'fs_watcher'): self.main_win.fs_watcher.file_deleted.connect(self._on_file_deleted_externally) self.main_win.fs_watcher.file_moved.connect(self._on_file_moved_externally) if self.duplicates: self.table_widget.setCurrentCell(0, 0) def _setup_ui(self): layout = QHBoxLayout(self) # Left side: List of pairs left_panel = QWidget() left_layout = QVBoxLayout(left_panel) header_layout = QHBoxLayout() header_layout.addWidget(QLabel(UITexts.DUPLICATE_LIST_HEADER)) self.counter_lbl = QLabel() self.counter_lbl.setStyleSheet("color: #3498db; font-weight: bold;") header_layout.addStretch() header_layout.addWidget(self.counter_lbl) left_layout.addLayout(header_layout) self.table_widget = QTableWidget() self.table_widget.setColumnCount(2) self.table_widget.setHorizontalHeaderLabels(["%", UITexts.CONTEXT_MENU_OPEN]) # Usamos una cadena existente o genérica self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.table_widget.verticalHeader().setVisible(False) self.table_widget.setSelectionBehavior(QAbstractItemView.SelectRows) self.table_widget.setSelectionMode(QAbstractItemView.SingleSelection) self.table_widget.currentCellChanged.connect(self._on_cell_changed) self.table_widget.setSortingEnabled(True) left_layout.addWidget(self.table_widget) # Right side: Comparison area self.splitter = QSplitter(Qt.Vertical) # Top area: Side by side images self.comparison_widget = QWidget() comp_layout = QHBoxLayout(self.comparison_widget) # Left Image Panel self.left_pane_widget = self._create_comparison_pane_widget() comp_layout.addWidget(self.left_pane_widget) # Right Image Panel self.right_pane_widget = self._create_comparison_pane_widget() comp_layout.addWidget(self.right_pane_widget) # Buttons Area button_widget = QWidget() btn_layout = QHBoxLayout(button_widget) self.btn_del_left = QPushButton(QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_LEFT) self.btn_del_left.clicked.connect(self._delete_left) self.btn_del_right = QPushButton(QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_RIGHT) self.btn_del_right.clicked.connect(self._delete_right) self.btn_link_panes = QPushButton(QIcon.fromTheme("object-link"), UITexts.VIEWER_MENU_LINK_PANES) self.btn_link_panes.setCheckable(True) self.btn_link_panes.setChecked(self.panes_linked) self.btn_link_panes.clicked.connect(self._toggle_link_panes) self.btn_keep_both = QPushButton(QIcon.fromTheme("emblem-important"), UITexts.DUPLICATE_KEEP_BOTH) self.btn_keep_both.clicked.connect(self._keep_both) self.btn_skip = QPushButton(UITexts.DUPLICATE_SKIP) self.btn_skip.clicked.connect(self._skip) btn_layout.addWidget(self.btn_del_left) btn_layout.addWidget(self.btn_del_right) btn_layout.addWidget(self.btn_link_panes) btn_layout.addStretch() btn_layout.addWidget(self.btn_keep_both) btn_layout.addWidget(self.btn_skip) if self.review_mode: self.btn_keep_both.hide() self.btn_skip.setText(UITexts.DUPLICATE_REMOVE_IGNORED) self.btn_skip.setIcon(QIcon.fromTheme("list-remove")) self.similarity_lbl = QLabel() self.similarity_lbl.setAlignment(Qt.AlignCenter) self.similarity_lbl.setMinimumHeight(30) self.similarity_lbl.setStyleSheet("font-weight: bold; color: #f39c12; font-size: 15px; background-color: #222; border: 1px solid #444; border-radius: 4px;") main_right_layout = QVBoxLayout() main_right_layout.addWidget(self.comparison_widget, 1) main_right_layout.addWidget(self.similarity_lbl) main_right_layout.addWidget(button_widget) right_container = QWidget() right_container.setLayout(main_right_layout) layout.addWidget(left_panel, 1) layout.addWidget(right_container, 4) # Store references to the actual ImagePane instances self.left_pane = self.left_pane_widget.pane self.right_pane = self.right_pane_widget.pane def closeEvent(self, event): """Disconnects signals and performs cleanup when closing.""" if self.main_win and hasattr(self.main_win, 'fs_watcher'): try: self.main_win.fs_watcher.file_deleted.disconnect(self._on_file_deleted_externally) self.main_win.fs_watcher.file_moved.disconnect(self._on_file_moved_externally) except (RuntimeError, TypeError): pass super().closeEvent(event) 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 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 def wheelEvent(self, event): """Handles mouse wheel events for zooming (with Ctrl).""" if event.modifiers() & Qt.ControlModifier and self.active_pane: # Calcular el punto de enfoque relativo al pane activo focus_pos = self.active_pane.mapFromGlobal(event.globalPosition().toPoint()) if event.angleDelta().y() > 0: self.active_pane.zoom_manager.zoom(1.1, focus_point=focus_pos) else: self.active_pane.zoom_manager.zoom(0.9, focus_point=focus_pos) event.accept() else: super().wheelEvent(event) def keyPressEvent(self, event): """Handles keyboard shortcuts for zooming.""" if not self.active_pane: super().keyPressEvent(event) return if event.key() == Qt.Key_Plus or event.key() == Qt.Key_Equal: self.active_pane.zoom_manager.zoom(1.1) elif event.key() == Qt.Key_Minus: self.active_pane.zoom_manager.zoom(0.9) elif event.key() == Qt.Key_Z: self.active_pane.zoom_manager.zoom(reset=True) else: super().keyPressEvent(event) # --- Viewer API Implementation for ImagePane --- def set_active_pane(self, pane): """Sets the currently focused pane for synchronization reference.""" self.active_pane = pane self.update_highlight() def update_highlight(self): """Visual feedback for the active pane.""" for pw in [self.left_pane_widget, self.right_pane_widget]: pw.setStyleSheet("border: 2px solid #3498db;" if pw.pane == self.active_pane else "border: 2px solid transparent;") def on_metadata_changed(self, path, metadata=None): """Updates labels when image metadata (like tags) is modified.""" # Find the widget displaying this path and update its info for pw in [self.left_pane_widget, self.right_pane_widget]: if pw.pane.controller.get_current_path() == path: size_str = self._format_size(os.path.getsize(path)) pw.info_lbl.setText(UITexts.DUPLICATE_INFO_FORMAT.format( size=size_str, width=pw.pane.controller.pixmap_original.width(), height=pw.pane.controller.pixmap_original.height())) if self.main_win: self.main_win.update_metadata_for_path(path, metadata) def on_controller_list_updated(self, index): """Required by ImagePane API, no-op in dialog context.""" pass def update_view_for_pane(self, pane, resize_win=False): """Refreshes the canvas for a specific pane.""" pixmap = pane.controller.get_display_pixmap() if not pixmap.isNull(): pane.canvas.setPixmap(pixmap) pane.canvas.adjustSize() def load_and_fit_image_for_pane(self, pane, restore_config=None): """Loads and calculates initial zoom to fit the pane viewport.""" success, _ = pane.controller.load_image() if success: viewport = pane.scroll_area.viewport() w, h = viewport.width(), viewport.height() # If not yet laid out, defer to next event loop if w <= 1 or h <= 1: QTimer.singleShot(0, lambda: self.load_and_fit_image_for_pane(pane)) return pane.zoom_manager.calculate_initial_zoom(w, h, True) self.update_view_for_pane(pane) def reset_inactivity_timer(self): pass def sync_filmstrip_selection(self, index): pass def _get_clicked_face_for_pane(self, pane, pos): return None def rename_face(self, face): pass def toggle_fullscreen(self): pass def _create_comparison_pane_widget(self): widget = QWidget() v_layout = QVBoxLayout(widget) v_layout.setContentsMargins(0, 0, 0, 0) info_lbl = QLabel() info_lbl.setAlignment(Qt.AlignCenter) info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") v_layout.addWidget(info_lbl) # Create ImagePane pane = ImagePane(self, self.main_win.cache, [], 0, None, 0) pane.setContextMenuPolicy(Qt.CustomContextMenu) pane.customContextMenuRequested.connect(self._show_pane_context_menu) v_layout.addWidget(pane) # Attach references widget.info_lbl = info_lbl widget.pane = pane widget.filename_lbl = QLabel() widget.filename_lbl.setAlignment(Qt.AlignCenter) widget.filename_lbl.setStyleSheet("font-size: 11px; font-weight: bold;") v_layout.addWidget(widget.filename_lbl) widget.dir_lbl = QLabel() widget.dir_lbl.setAlignment(Qt.AlignCenter) widget.dir_lbl.setStyleSheet("font-size: 9px; color: #888;") v_layout.addWidget(widget.dir_lbl) # Connect signals for synchronization pane.scrolled.connect(self._sync_scroll) pane.zoom_manager.zoomed.connect(self._sync_zoom) pane.activated.connect(self._on_pane_activated) return widget def _populate_list(self): self.table_widget.setSortingEnabled(False) self.table_widget.blockSignals(True) self.table_widget.setRowCount(0) for i, dup in enumerate(self.duplicates): name1 = os.path.basename(dup.path1) name2 = os.path.basename(dup.path2) row = self.table_widget.rowCount() self.table_widget.insertRow(row) # Columna 0: Porcentaje (usamos DisplayRole con int para que ordene numéricamente) sim_item = QTableWidgetItem() sim_item.setData(Qt.DisplayRole, dup.similarity if dup.similarity is not None else 0) sim_item.setTextAlignment(Qt.AlignCenter) sim_item.setData(Qt.UserRole, i) # Guardamos el índice original en la lista duplicates # Columna 1: Nombres de ficheros names_item = QTableWidgetItem(f"{name1} ↔ {name2}") self.table_widget.setItem(row, 0, sim_item) self.table_widget.setItem(row, 1, names_item) self.counter_lbl.setText(str(len(self.duplicates))) self.table_widget.blockSignals(False) self.table_widget.setSortingEnabled(True) self.table_widget.sortItems(0, Qt.DescendingOrder) def _on_cell_changed(self, row, col, prev_row, prev_col): if row >= 0: self._load_pair(row) def _load_pair(self, row): if row < 0 or row >= self.table_widget.rowCount(): return # Obtenemos el índice real de la lista duplicates guardado en el UserRole del item item = self.table_widget.item(row, 0) if not item: return original_index = item.data(Qt.UserRole) dup = self.duplicates[original_index] self.current_dup_pair = dup # Store the original pair # Update similarity label similarity_color = "#f39c12" # Default (amber) if dup.similarity is not None: if dup.similarity == 100: similarity_color = "#2ecc71" # Green elif dup.similarity < 80: similarity_color = "#e74c3c" # Red self.similarity_lbl.setText(f"{dup.similarity}% Similarity") self.similarity_lbl.setStyleSheet(f"font-weight: bold; color: {similarity_color}; font-size: 12px; margin-top: 5px;") self.similarity_lbl.show() else: self.similarity_lbl.hide() # Get paths and their components path_left = dup.path1 path_right = dup.path2 filename_left = os.path.basename(path_left) dir_left = os.path.dirname(path_left) filename_right = os.path.basename(path_right) dir_right = os.path.dirname(path_right) # Determine colors for comparison green_color = "#2ecc71" # Green for match red_color = "#e74c3c" # Red for mismatch filename_color = green_color if filename_left == filename_right else red_color dir_color = green_color if dir_left == dir_right else red_color # Determine which path goes to which pane based on mtime mtime1 = os.path.getmtime(path_left) if os.path.exists(path_left) else 0 mtime2 = os.path.getmtime(path_right) if os.path.exists(path_right) else 0 # La imagen más reciente (mtime más alto) va a la izquierda if mtime1 >= mtime2: self._set_pane_data(self.left_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left) self._set_pane_data(self.right_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right) else: self._set_pane_data(self.left_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right) self._set_pane_data(self.right_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left) def _set_pane_data(self, pane_widget, path, filename_color, dir_color, filename_text, dir_text): 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") pane.controller.update_list([], 0) # Clear pane pane.load_and_fit_image() filename_lbl.setText("N/A") dir_lbl.setText("N/A") return # Metadatos size_bytes = os.path.getsize(path) size_str = self._format_size(size_bytes) # 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( size=size_str, width=pane.controller.pixmap_original.width(), height=pane.controller.pixmap_original.height())) else: info_lbl.setText(f"{size_str} - N/A") filename_lbl.setText(filename_text) filename_lbl.setStyleSheet(f"font-size: 11px; font-weight: bold; color: {filename_color};") dir_lbl.setText(dir_text) dir_lbl.setStyleSheet(f"font-size: 9px; color: {dir_color};") def _show_pane_context_menu(self, pos): pane = self.sender() path = pane.controller.get_current_path() if not path or not os.path.exists(path): return menu = QMenu(self) # Open with... open_menu = menu.addMenu(QIcon.fromTheme("document-open"), UITexts.CONTEXT_MENU_OPEN) self.main_win.populate_open_with_submenu(open_menu, path) # Open location action_open_default_app = menu.addAction( QIcon.fromTheme("system-run"), UITexts.CONTEXT_MENU_OPEN_DEFAULT_APP) action_open_default_app.triggered.connect( lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(path)))) menu.addSeparator() # Clipboard clip_menu = menu.addMenu(QIcon.fromTheme("edit-copy"), UITexts.CONTEXT_MENU_CLIPBOARD) action_copy_image = clip_menu.addAction(QIcon.fromTheme("image-x-generic"), UITexts.VIEWER_MENU_COPY_IMAGE) action_copy_image.triggered.connect(lambda: QApplication.clipboard().setImage(QImage(path))) action_copy_path = clip_menu.addAction(QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH) action_copy_path.triggered.connect(lambda: QApplication.clipboard().setText(path)) menu.addSeparator() # Trash / Delete action_trash = menu.addAction(QIcon.fromTheme("user-trash"), UITexts.CONTEXT_MENU_TRASH) action_trash.triggered.connect(lambda: self._handle_action(delete_path=path, permanent=False)) action_delete = menu.addAction(QIcon.fromTheme("edit-delete"), UITexts.CONTEXT_MENU_DELETE) action_delete.triggered.connect(lambda: self._handle_permanent_delete(path)) menu.addSeparator() # Properties action_props = menu.addAction(QIcon.fromTheme("document-properties"), UITexts.CONTEXT_MENU_PROPERTIES) action_props.triggered.connect(lambda: self._show_properties(path, pane)) menu.exec(pane.mapToGlobal(pos)) def _handle_permanent_delete(self, path): confirm = QMessageBox(self) confirm.setIcon(QMessageBox.Warning) confirm.setText(UITexts.CONFIRM_DELETE_TEXT) confirm.setInformativeText(UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path))) confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) confirm.setDefaultButton(QMessageBox.No) if confirm.exec() == QMessageBox.Yes: self._handle_action(delete_path=path, permanent=True) def _show_properties(self, path, pane): tags = pane.controller._current_tags rating = pane.controller._current_rating dlg = PropertiesDialog(path, initial_tags=tags, initial_rating=rating, parent=self) dlg.exec() def _on_pane_activated(self): # 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 other_pane = self.left_pane if active_pane == self.right_pane else self.right_pane self._sync_zoom(active_pane.controller.zoom_factor, source_pane=active_pane) # Need to get scroll position from active_pane and apply to other h_bar = active_pane.scroll_area.horizontalScrollBar() v_bar = active_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 other_pane.set_scroll_relative(x_pct, y_pct) def _sync_scroll(self, x_pct, y_pct): if not self.panes_linked: return source_pane = self.sender() if source_pane == self.left_pane: self.right_pane.set_scroll_relative(x_pct, y_pct) elif source_pane == self.right_pane: self.left_pane.set_scroll_relative(x_pct, y_pct) def _sync_zoom(self, factor, source_pane=None): 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 sender = self.sender() source_pane = sender.parent() if sender else None if not source_pane: 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 target_pane = self.left_pane if source_pane == self.right_pane else self.right_pane target_pane.zoom_manager.zoom(absolute_factor=factor) # 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)) finally: self._is_syncing = False def _format_size(self, size): for unit in ['B', 'KiB', 'MiB', 'GiB']: if size < 1024: return f"{size:.1f} {unit}" size /= 1024 return f"{size:.1f} TiB" def _delete_left(self): 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): 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): self.panes_linked = self.btn_link_panes.isChecked() if self.panes_linked: # When linking, synchronize the other pane to the active one # For simplicity, let's always sync right to left if linking is enabled active_pane = self.left_pane self._sync_zoom(active_pane.controller.zoom_factor, source_pane=active_pane) h_bar = active_pane.scroll_area.horizontalScrollBar() v_bar = active_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 self.right_pane.set_scroll_relative(x_pct, y_pct) def _on_file_deleted_externally(self, path): """Handles file deletion events from the FileSystemWatcher.""" path = os.path.abspath(path) # 1. Identify pairs to remove and clean up the pending DB pairs_to_remove = [d for d in self.duplicates if d.path1 == path or d.path2 == path] if not pairs_to_remove: return for p in pairs_to_remove: self.cache.mark_as_pending(p.path1, p.path2, False) # 2. Update the local list self.duplicates = [d for d in self.duplicates if d not in pairs_to_remove] # 3. Refresh UI self._populate_list() if not self.duplicates: self.close() else: current_row = self.table_widget.currentRow() new_row = min(max(0, current_row), self.table_widget.rowCount() - 1) self.table_widget.selectRow(new_row) self.table_widget.setCurrentCell(new_row, 0) def _on_file_moved_externally(self, old_path, new_path): """Handles file move/rename events from the FileSystemWatcher.""" old_path = os.path.abspath(old_path) new_path = os.path.abspath(new_path) updated = False for i, d in enumerate(self.duplicates): if d.path1 == old_path or d.path2 == old_path: p1 = new_path if d.path1 == old_path else d.path1 p2 = new_path if d.path2 == old_path else d.path2 # Actualizamos la tupla con nombre usando _replace self.duplicates[i] = d._replace(path1=p1, path2=p2) updated = True if updated: current_row = self.table_widget.currentRow() self._populate_list() if current_row >= 0: new_row = min(current_row, self.table_widget.rowCount() - 1) self.table_widget.selectRow(new_row) self.table_widget.setCurrentCell(new_row, 0) def _keep_both(self): if self.current_dup_pair: self.cache.mark_as_exception( self.current_dup_pair.path1, self.current_dup_pair.path2, True, similarity=self.current_dup_pair.similarity ) self._handle_action(skip=False, permanent=False) def _skip(self): 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) self._handle_action(skip=False, permanent=False) else: self._handle_action(skip=True) def _handle_action(self, delete_path=None, skip=False, permanent=None): current_row = self.table_widget.currentRow() if current_row < 0: return item = self.table_widget.item(current_row, 0) original_index = item.data(Qt.UserRole) # Get the pair before potentially popping it current_pair = self.duplicates[original_index] if delete_path: if permanent is not True: if APP_CONFIG.get("duplicate_confirm_delete", True): reply = QMessageBox.question( self, UITexts.CONFIRM_TRASH_TITLE, UITexts.CONFIRM_TRASH_TEXT, QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if reply != QMessageBox.Yes: return # Remove all pairs containing this path from the persistent pending DB # because the file will be gone. pairs_to_unmark = [d for d in self.duplicates if d.path1 == delete_path or d.path2 == delete_path] for p in pairs_to_unmark: self.cache.mark_as_pending(p.path1, p.path2, False) self.main_win.delete_file_by_path(delete_path, permanent=permanent) # Use default setting # Remove all pairs containing this path because it no longer exists self.duplicates = [d for d in self.duplicates if d.path1 != delete_path and d.path2 != delete_path] else: # Skip or KeepBoth: if not skip: # "Keep Both" case # It's no longer pending, it's an exception (already marked in _keep_both) self.cache.mark_as_pending(current_pair.path1, current_pair.path2, False) # Note: if it's "Skip", we do NOT remove it from pending DB, so it stays there for next time. if 0 <= original_index < len(self.duplicates): self.duplicates.pop(original_index) # Repopulate list widget to ensure all indices are correct and counter is updated self._populate_list() # Try to restore selection to same position (or last item) if self.duplicates: new_row = min(current_row, self.table_widget.rowCount() - 1) self.table_widget.selectRow(new_row) self.table_widget.setCurrentCell(new_row, 0) else: self.close()