From 1508e629c040d866d34d383c75dfebd3e1a3972b Mon Sep 17 00:00:00 2001 From: Ignacio Serantes Date: Sun, 12 Apr 2026 08:39:07 +0200 Subject: [PATCH] v0.9.20 --- bagheeraview.py | 6 +- constants.py | 2 +- duplicatedialog.py | 184 +++++++++++++++++++++++++++++++++++++------ filesystemwatcher.py | 92 +++++++++++++++++++--- imageviewer.py | 3 +- pyproject.toml | 2 +- setup.py | 2 +- 7 files changed, 249 insertions(+), 42 deletions(-) diff --git a/bagheeraview.py b/bagheeraview.py index 26c772b..a9605c2 100755 --- a/bagheeraview.py +++ b/bagheeraview.py @@ -14,7 +14,7 @@ Classes: MainWindow: The main application window containing the thumbnail grid and docks. """ __appname__ = "BagheeraView" -__version__ = "0.9.19" +__version__ = "0.9.20" __author__ = "Ignacio Serantes" __email__ = "kde@aynoa.net" __license__ = "LGPL" @@ -3998,14 +3998,14 @@ class MainWindow(QMainWindow): self.thumbnail_view.setGridSize(QSize()) else: self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None)) - self.rebuild_view() + self.rebuild_view(full_reset=True) self.save_config() self.setFocus() def on_sort_changed(self): """Callback for when the sort order dropdown changes.""" - self.rebuild_view() + self.rebuild_view(full_reset=True) self.save_config() if hasattr(self, 'history_tab'): self.history_tab.refresh_list() diff --git a/constants.py b/constants.py index b70fea1..daa037c 100644 --- a/constants.py +++ b/constants.py @@ -29,7 +29,7 @@ if FORCE_X11: # --- CONFIGURATION --- PROG_NAME = "Bagheera Image Viewer" PROG_ID = "bagheeraview" -PROG_VERSION = "0.9.19-dev" +PROG_VERSION = "0.9.20" PROG_AUTHOR = "Ignacio Serantes" # --- CACHE SETTINGS --- diff --git a/duplicatedialog.py b/duplicatedialog.py index 650fe67..963a955 100644 --- a/duplicatedialog.py +++ b/duplicatedialog.py @@ -1,3 +1,14 @@ +""" +Duplicate Management Dialog Module for Bagheera. + +This module implements the DuplicateManagerDialog, a specialized interface +for comparing and resolving duplicate image pairs identified by the +DuplicateDetector. It provides side-by-side viewing with synchronized +zooming and scrolling. + +Classes: + DuplicateManagerDialog: A dialog to review and manage duplicate image pairs. +""" import os from datetime import datetime from PySide6.QtWidgets import ( @@ -17,6 +28,15 @@ 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): + """ + Initializes the DuplicateManagerDialog. + + Args: + duplicates (list): List of DuplicateResult tuples. + duplicate_cache (DuplicateCache): The persistent hash/exception cache. + main_win (MainWindow): Reference to the main application window. + review_mode (bool, optional): If True, shows previously ignored duplicates. + """ super().__init__(main_win) self.duplicates = duplicates # List of DuplicateResult self.cache = duplicate_cache @@ -164,7 +184,12 @@ class DuplicateManagerDialog(QDialog): self.right_pane = self.right_pane_widget.pane def closeEvent(self, event): - """Disconnects signals and performs cleanup when closing.""" + """ + Handles the dialog close event. + + Disconnects external signals from the file system watcher and + performs cleanup on the image panes to free resources. + """ if self.main_win and hasattr(self.main_win, 'fs_watcher'): try: self.main_win.fs_watcher.file_deleted.disconnect( @@ -181,7 +206,12 @@ class DuplicateManagerDialog(QDialog): super().closeEvent(event) def resizeEvent(self, event): - """Resizes the images to fill available space when the dialog is resized.""" + """ + Handles the dialog resize event. + + Triggers a recalculation of the image scaling to ensure they fit + optimally within the new available space. + """ super().resizeEvent(event) # Call base class resizeEvent self._apply_linked_scaling() @@ -257,7 +287,12 @@ class DuplicateManagerDialog(QDialog): self._is_syncing = False def wheelEvent(self, event): - """Handles mouse wheel events for zooming (with Ctrl).""" + """ + Handles mouse wheel events for zooming. + + If the Control modifier is held, zooms the active image pane + centered on the mouse cursor position. + """ if event.modifiers() & Qt.ControlModifier and self.active_pane: # Calculate the focus point relative to the active pane. focus_pos = self.active_pane.mapFromGlobal(event.globalPosition().toPoint()) @@ -270,7 +305,12 @@ class DuplicateManagerDialog(QDialog): super().wheelEvent(event) def keyPressEvent(self, event): - """Handles keyboard shortcuts for zooming and duplicate management.""" + """ + Handles keyboard shortcuts for navigation and actions. + + Provides quick access to deletion (U/I), ignore (O), and skip (P), + as well as standard zoom controls (Z/+/ -). + """ key = event.key() if key == Qt.Key_U: self._delete_left() @@ -306,18 +346,29 @@ class DuplicateManagerDialog(QDialog): # --- Viewer API Implementation for ImagePane --- def set_active_pane(self, pane): - """Sets the currently focused pane for synchronization reference.""" + """ + Sets the currently focused pane for synchronization reference. + + Args: + pane (ImagePane): The pane that gained focus. + """ self.active_pane = pane self.update_highlight() def update_highlight(self): - """Visual feedback for the active pane.""" + """Applies visual feedback (border) to 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.""" + """ + Updates labels when image metadata is modified. + + Args: + path (str): The file path. + metadata (dict, optional): The updated metadata. + """ # 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: @@ -331,18 +382,30 @@ class DuplicateManagerDialog(QDialog): 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.""" + """Required by ImagePane API, no-op in this dialog.""" pass def update_view_for_pane(self, pane, resize_win=False): - """Refreshes the canvas for a specific pane.""" + """ + Refreshes the canvas for a specific pane. + + Args: + pane (ImagePane): The pane to update. + resize_win (bool): Ignored in this context. + """ 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.""" + """ + Loads and calculates initial zoom to fit the pane viewport. + + Args: + pane (ImagePane): The target pane. + restore_config (dict, optional): Unused here. + """ success, _ = pane.controller.load_image() if success: viewport = pane.scroll_area.viewport() @@ -355,21 +418,29 @@ class DuplicateManagerDialog(QDialog): self.update_view_for_pane(pane) def reset_inactivity_timer(self): + """Required by ImagePane API, no-op in this dialog.""" pass def sync_filmstrip_selection(self, index): + """Required by ImagePane API, no-op in this dialog.""" pass def _get_clicked_face_for_pane(self, pane, pos): + """Required by ImagePane API, no-op in this dialog.""" return None def rename_face(self, face): + """Required by ImagePane API, no-op in this dialog.""" pass def toggle_fullscreen(self): + """Required by ImagePane API, no-op in this dialog.""" pass def _create_comparison_pane_widget(self): + """ + Factory method to create a pane widget containing an ImagePane and info labels. + """ widget = QWidget() v_layout = QVBoxLayout(widget) v_layout.setContentsMargins(0, 0, 0, 0) @@ -450,10 +521,14 @@ class DuplicateManagerDialog(QDialog): self.table_widget.sortItems(0, Qt.DescendingOrder) def _on_cell_changed(self, row, col, prev_row, prev_col): + """Slot triggered when the selected row in the pairs table changes.""" if row >= 0: self._load_pair(row) def _load_pair(self, row): + """ + Loads the duplicate pair corresponding to the specified table row. + """ if row < 0 or row >= self.table_widget.rowCount(): return @@ -579,7 +654,20 @@ class DuplicateManagerDialog(QDialog): 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.""" + """ + Updates an ImagePane and its labels with file data. + + Args: + pane_widget (QWidget): The container widget for the pane. + path (str): File path to load. + filename_color (str): Hex color for filename label. + dir_color (str): Hex color for directory label. + filename_text (str): Name to display. + dir_text (str): Directory path to display. + + Returns: + bool: True if linked scaling should be disabled (e.g., animation). + """ pane = pane_widget.pane info_lbl = pane_widget.info_lbl filename_lbl = pane_widget.filename_lbl @@ -625,7 +713,12 @@ class DuplicateManagerDialog(QDialog): return disable_linking def _show_pane_context_menu(self, pos): - """Displays a context menu for the pane that requested it.""" + """ + Displays a context menu for the pane that requested it. + + Args: + pos (QPoint): Local coordinates of the request. + """ pane = self.sender() path = pane.controller.get_current_path() if not path or not os.path.exists(path): @@ -685,7 +778,12 @@ class DuplicateManagerDialog(QDialog): menu.exec(pane.mapToGlobal(pos)) def _handle_permanent_delete(self, path): - """Prompts for and executes permanent deletion of a file.""" + """ + Prompts for and executes permanent deletion of a file. + + Args: + path (str): File path to delete. + """ confirm = QMessageBox(self) confirm.setIcon(QMessageBox.Warning) confirm.setText(UITexts.CONFIRM_DELETE_TEXT) @@ -697,7 +795,13 @@ 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.""" + """ + Shows the file properties dialog for a pane's image. + + Args: + path (str): File path. + pane (ImagePane): The pane containing the image. + """ tags = pane.controller._current_tags rating = pane.controller._current_rating dlg = PropertiesDialog( @@ -705,7 +809,11 @@ class DuplicateManagerDialog(QDialog): dlg.exec() def _on_pane_activated(self): - """Handles pane activation to synchronize viewing state if linked.""" + """ + Handles pane activation to synchronize viewing state if linked. + + Ensures that the activated pane becomes the leader for synchronization. + """ # 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 @@ -720,7 +828,13 @@ 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.""" + """ + Synchronizes scroll position between panes if linked. + + Args: + x_pct (float): Horizontal scroll percentage. + y_pct (float): Vertical scroll percentage. + """ if not self.panes_linked: return source_pane = self.sender() @@ -730,7 +844,13 @@ 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.""" + """ + Synchronizes zoom factor between panes if linked. + + Args: + factor (float): New zoom factor. + source_pane (ImagePane, optional): The leader pane. + """ if not self.panes_linked or self._is_syncing: return if source_pane is None: @@ -788,7 +908,12 @@ class DuplicateManagerDialog(QDialog): self._is_syncing = False def _format_size(self, size): - """Formats a file size in bytes to a human-readable string.""" + """ + Formats a file size in bytes to a human-readable string. + + Args: + size (int): Size in bytes. + """ for unit in ['B', 'KiB', 'MiB', 'GiB']: if size < 1024: return f"{size:.1f} {unit}" @@ -808,7 +933,7 @@ class DuplicateManagerDialog(QDialog): self._handle_action(delete_path=path_to_delete) def _toggle_link_panes(self): - """Toggles the link state between panes.""" + """Toggles the synchronized viewing state between panes.""" self._user_link_preference = self.btn_link_panes.isChecked() self.panes_linked = self._user_link_preference if self.panes_linked: @@ -823,7 +948,12 @@ class DuplicateManagerDialog(QDialog): self.right_pane.set_scroll_relative(x_pct, y_pct) def _on_file_deleted_externally(self, path): - """Handles file deletion events from the FileSystemWatcher.""" + """ + Handles file deletion events from the FileSystemWatcher. + + Args: + path (str): The deleted path. + """ path = os.path.abspath(path) # 1. Identify pairs to remove and clean up the pending DB @@ -849,7 +979,13 @@ class DuplicateManagerDialog(QDialog): 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.""" + """ + Handles file move/rename events from the FileSystemWatcher. + + Args: + old_path (str): Original path. + new_path (str): Target path. + """ old_path = os.path.abspath(old_path) new_path = os.path.abspath(new_path) @@ -901,10 +1037,12 @@ class DuplicateManagerDialog(QDialog): def _handle_action(self, delete_path=None, skip=False, permanent=None): """ Handles management actions (delete, skip, keep) for duplicate pairs. + Updates the local list and the persistent databases. Args: - delete_path: Path to delete, if any. - skip: Whether to skip the current pair. + delete_path (str, optional): Path to delete. + skip (bool): Whether to skip without ignoring permanently. + permanent (bool, optional): If True, deletes without trash. """ current_row = self.table_widget.currentRow() if current_row < 0: diff --git a/filesystemwatcher.py b/filesystemwatcher.py index 28a2458..deb5032 100644 --- a/filesystemwatcher.py +++ b/filesystemwatcher.py @@ -1,3 +1,14 @@ +""" +File System Watcher Module for Bagheera Image Viewer. + +This module provides functionality to monitor file system changes in real-time +using the watchdog library. It notifies the application about new, deleted, or +modified image files within watched directories, handling debouncing to ensure +stability during rapid file operations. + +Classes: + FileSystemWatcher: Coordinates file system monitoring and emits Qt signals. +""" import os try: from watchdog.observers import Observer @@ -14,6 +25,10 @@ class FileSystemWatcher(QObject): Monitors file system events (created, deleted, modified) for specified directories. Emits signals to notify the main application thread of changes. """ + + # Signals emitted to the rest of the application + # --------------------------------------------- + file_created = Signal(str) file_deleted = Signal(str) file_modified = Signal(str) @@ -24,10 +39,18 @@ class FileSystemWatcher(QObject): directory_modified = Signal(str) # For changes that might not be specific files _modified_events_queue = {} # {path: QTimer} + """Queue to manage debouncing of modification events.""" def __init__(self, parent=None): + """ + Initializes the FileSystemWatcher. + + Args: + parent (QObject, optional): The parent object. Defaults to None. + """ super().__init__(parent) self._watched_directories = set() + self._debounce_interval = 500 # milliseconds if HAVE_WATCHDOG: self._observer = Observer() @@ -36,16 +59,21 @@ class FileSystemWatcher(QObject): else: self._observer = None # Keep observer as None if watchdog is not available - # Debounce timer for modified events to avoid multiple signals for a single save - self._debounce_interval = 500 # milliseconds - # Connect the internal signal to the debouncing slot if HAVE_WATCHDOG: self._file_modified_from_handler.connect(self._on_file_modified_debounced) def _on_file_modified_debounced(self, path): - """Slot to handle modified events from the watchdog thread, debounced in the - main thread.""" + """ + Slot to handle modified events from the watchdog thread. + + Implements a debouncing mechanism: if multiple modification events + arrive for the same path within the interval, previous timers are + reset to avoid redundant UI updates or heavy disk operations. + + Args: + path (str): The path of the modified file. + """ # Debounce timer for modified events to avoid multiple signals for a single save if path in self._modified_events_queue: self._modified_events_queue[path].stop() @@ -59,7 +87,12 @@ class FileSystemWatcher(QObject): self._modified_events_queue[path].start() def _emit_modified_after_debounce(self, path): - """Emits the file_modified signal after the debounce period.""" + """ + Emits the file_modified signal after the debounce period. + + Args: + path (str): The path of the modified file. + """ self.file_modified.emit(path) if path in self._modified_events_queue: # Safely delete the QTimer object when done @@ -67,7 +100,16 @@ class FileSystemWatcher(QObject): del self._modified_events_queue[path] def add_path(self, path): - """Adds a directory to be monitored.""" + """ + Adds a directory to be monitored. + + This method ensures that redundant watches are avoided by checking if + the path is already covered by an existing watch or if it should + consolidate multiple sub-watches into a single parent watch. + + Args: + path (str): The directory path to monitor. + """ if not HAVE_WATCHDOG or self._observer is None: return @@ -111,7 +153,12 @@ class FileSystemWatcher(QObject): self.monitoring_status_changed.emit(True) def remove_path(self, path): - """Removes a directory from monitoring.""" + """ + Removes a directory from monitoring. + + Args: + path (str): The directory path to stop monitoring. + """ if not HAVE_WATCHDOG or self._observer is None: return abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path))) @@ -138,7 +185,9 @@ class FileSystemWatcher(QObject): self.monitoring_status_changed.emit(False) def stop(self): - """Stops the file system observer.""" + """ + Stops the file system observer and cleans up active timers. + """ if HAVE_WATCHDOG and self._observer: self._observer.stop() self._observer.join() @@ -150,10 +199,19 @@ class FileSystemWatcher(QObject): if HAVE_WATCHDOG: class _Handler(FileSystemEventHandler): + """ + Custom event handler for watchdog events. + + Translates low-level file system events into high-level application + signals, filtering for supported image types. + """ # Signal to communicate to main thread file_modified_from_thread = Signal(str) - """Custom event handler for watchdog events.""" + def __init__(self, watcher): + """ + Initializes the handler with a reference to the main watcher. + """ super().__init__() self.watcher = watcher @@ -199,11 +257,21 @@ class FileSystemWatcher(QObject): self.watcher._file_modified_from_handler.emit(event.src_path) def _emit_modified(self, path): - """Internal helper to emit the modified signal.""" + """ + Internal helper to emit the modified signal. + + Args: + path (str): The modified path. + """ self.watcher.file_modified.emit(path) if path in self.watcher._modified_events_queue: del self.watcher._modified_events_queue[path] def _is_image_file(self, path): - """Checks if a given path has a supported image extension.""" + """ + Checks if a given path has a supported image extension. + + Args: + path (str): The file path to check. + """ return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS diff --git a/imageviewer.py b/imageviewer.py index 7d1bd6f..ed3fbd2 100644 --- a/imageviewer.py +++ b/imageviewer.py @@ -3317,7 +3317,8 @@ class ImageViewer(QWidget): # 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 + # Still too fast so speed / 2. + threshold = 120 / speed / 2 self._wheel_scroll_accumulator += event.angleDelta().y() diff --git a/pyproject.toml b/pyproject.toml index 3cafa46..391b200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bagheeraview" -version = "0.9.19" +version = "0.9.20" authors = [ { name = "Ignacio Serantes" } ] diff --git a/setup.py b/setup.py index 7e28ebf..8059e7d 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="bagheeraview", - version="0.9.19", + version="0.9.20", author="Ignacio Serantes", description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind", long_description="A fast image viewer built with PySide6, featuring search and "