v0.9.20
This commit is contained in:
@@ -14,7 +14,7 @@ Classes:
|
|||||||
MainWindow: The main application window containing the thumbnail grid and docks.
|
MainWindow: The main application window containing the thumbnail grid and docks.
|
||||||
"""
|
"""
|
||||||
__appname__ = "BagheeraView"
|
__appname__ = "BagheeraView"
|
||||||
__version__ = "0.9.19"
|
__version__ = "0.9.20"
|
||||||
__author__ = "Ignacio Serantes"
|
__author__ = "Ignacio Serantes"
|
||||||
__email__ = "kde@aynoa.net"
|
__email__ = "kde@aynoa.net"
|
||||||
__license__ = "LGPL"
|
__license__ = "LGPL"
|
||||||
@@ -3998,14 +3998,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.thumbnail_view.setGridSize(QSize())
|
self.thumbnail_view.setGridSize(QSize())
|
||||||
else:
|
else:
|
||||||
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
|
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
|
||||||
self.rebuild_view()
|
self.rebuild_view(full_reset=True)
|
||||||
|
|
||||||
self.save_config()
|
self.save_config()
|
||||||
self.setFocus()
|
self.setFocus()
|
||||||
|
|
||||||
def on_sort_changed(self):
|
def on_sort_changed(self):
|
||||||
"""Callback for when the sort order dropdown changes."""
|
"""Callback for when the sort order dropdown changes."""
|
||||||
self.rebuild_view()
|
self.rebuild_view(full_reset=True)
|
||||||
self.save_config()
|
self.save_config()
|
||||||
if hasattr(self, 'history_tab'):
|
if hasattr(self, 'history_tab'):
|
||||||
self.history_tab.refresh_list()
|
self.history_tab.refresh_list()
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ if FORCE_X11:
|
|||||||
# --- CONFIGURATION ---
|
# --- CONFIGURATION ---
|
||||||
PROG_NAME = "Bagheera Image Viewer"
|
PROG_NAME = "Bagheera Image Viewer"
|
||||||
PROG_ID = "bagheeraview"
|
PROG_ID = "bagheeraview"
|
||||||
PROG_VERSION = "0.9.19-dev"
|
PROG_VERSION = "0.9.20"
|
||||||
PROG_AUTHOR = "Ignacio Serantes"
|
PROG_AUTHOR = "Ignacio Serantes"
|
||||||
|
|
||||||
# --- CACHE SETTINGS ---
|
# --- CACHE SETTINGS ---
|
||||||
|
|||||||
@@ -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
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@@ -17,6 +28,15 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
A dialog to review and manage duplicate image pairs found by the detector.
|
A dialog to review and manage duplicate image pairs found by the detector.
|
||||||
"""
|
"""
|
||||||
def __init__(self, duplicates, duplicate_cache, main_win, review_mode=False):
|
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)
|
super().__init__(main_win)
|
||||||
self.duplicates = duplicates # List of DuplicateResult
|
self.duplicates = duplicates # List of DuplicateResult
|
||||||
self.cache = duplicate_cache
|
self.cache = duplicate_cache
|
||||||
@@ -164,7 +184,12 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.right_pane = self.right_pane_widget.pane
|
self.right_pane = self.right_pane_widget.pane
|
||||||
|
|
||||||
def closeEvent(self, event):
|
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'):
|
if self.main_win and hasattr(self.main_win, 'fs_watcher'):
|
||||||
try:
|
try:
|
||||||
self.main_win.fs_watcher.file_deleted.disconnect(
|
self.main_win.fs_watcher.file_deleted.disconnect(
|
||||||
@@ -181,7 +206,12 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
def resizeEvent(self, 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
|
super().resizeEvent(event) # Call base class resizeEvent
|
||||||
self._apply_linked_scaling()
|
self._apply_linked_scaling()
|
||||||
|
|
||||||
@@ -257,7 +287,12 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self._is_syncing = False
|
self._is_syncing = False
|
||||||
|
|
||||||
def wheelEvent(self, event):
|
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:
|
if event.modifiers() & Qt.ControlModifier and self.active_pane:
|
||||||
# Calculate the focus point relative to the active pane.
|
# Calculate the focus point relative to the active pane.
|
||||||
focus_pos = self.active_pane.mapFromGlobal(event.globalPosition().toPoint())
|
focus_pos = self.active_pane.mapFromGlobal(event.globalPosition().toPoint())
|
||||||
@@ -270,7 +305,12 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
super().wheelEvent(event)
|
super().wheelEvent(event)
|
||||||
|
|
||||||
def keyPressEvent(self, 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()
|
key = event.key()
|
||||||
if key == Qt.Key_U:
|
if key == Qt.Key_U:
|
||||||
self._delete_left()
|
self._delete_left()
|
||||||
@@ -306,18 +346,29 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
# --- Viewer API Implementation for ImagePane ---
|
# --- Viewer API Implementation for ImagePane ---
|
||||||
|
|
||||||
def set_active_pane(self, pane):
|
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.active_pane = pane
|
||||||
self.update_highlight()
|
self.update_highlight()
|
||||||
|
|
||||||
def update_highlight(self):
|
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]:
|
for pw in [self.left_pane_widget, self.right_pane_widget]:
|
||||||
pw.setStyleSheet("border: 2px solid #3498db;" if pw.pane == self.active_pane
|
pw.setStyleSheet("border: 2px solid #3498db;" if pw.pane == self.active_pane
|
||||||
else "border: 2px solid transparent;")
|
else "border: 2px solid transparent;")
|
||||||
|
|
||||||
def on_metadata_changed(self, path, metadata=None):
|
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
|
# Find the widget displaying this path and update its info
|
||||||
for pw in [self.left_pane_widget, self.right_pane_widget]:
|
for pw in [self.left_pane_widget, self.right_pane_widget]:
|
||||||
if pw.pane.controller.get_current_path() == path:
|
if pw.pane.controller.get_current_path() == path:
|
||||||
@@ -331,18 +382,30 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.main_win.update_metadata_for_path(path, metadata)
|
self.main_win.update_metadata_for_path(path, metadata)
|
||||||
|
|
||||||
def on_controller_list_updated(self, index):
|
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
|
pass
|
||||||
|
|
||||||
def update_view_for_pane(self, pane, resize_win=False):
|
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()
|
pixmap = pane.controller.get_display_pixmap()
|
||||||
if not pixmap.isNull():
|
if not pixmap.isNull():
|
||||||
pane.canvas.setPixmap(pixmap)
|
pane.canvas.setPixmap(pixmap)
|
||||||
pane.canvas.adjustSize()
|
pane.canvas.adjustSize()
|
||||||
|
|
||||||
def load_and_fit_image_for_pane(self, pane, restore_config=None):
|
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()
|
success, _ = pane.controller.load_image()
|
||||||
if success:
|
if success:
|
||||||
viewport = pane.scroll_area.viewport()
|
viewport = pane.scroll_area.viewport()
|
||||||
@@ -355,21 +418,29 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.update_view_for_pane(pane)
|
self.update_view_for_pane(pane)
|
||||||
|
|
||||||
def reset_inactivity_timer(self):
|
def reset_inactivity_timer(self):
|
||||||
|
"""Required by ImagePane API, no-op in this dialog."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def sync_filmstrip_selection(self, index):
|
def sync_filmstrip_selection(self, index):
|
||||||
|
"""Required by ImagePane API, no-op in this dialog."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _get_clicked_face_for_pane(self, pane, pos):
|
def _get_clicked_face_for_pane(self, pane, pos):
|
||||||
|
"""Required by ImagePane API, no-op in this dialog."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def rename_face(self, face):
|
def rename_face(self, face):
|
||||||
|
"""Required by ImagePane API, no-op in this dialog."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def toggle_fullscreen(self):
|
def toggle_fullscreen(self):
|
||||||
|
"""Required by ImagePane API, no-op in this dialog."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _create_comparison_pane_widget(self):
|
def _create_comparison_pane_widget(self):
|
||||||
|
"""
|
||||||
|
Factory method to create a pane widget containing an ImagePane and info labels.
|
||||||
|
"""
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
v_layout = QVBoxLayout(widget)
|
v_layout = QVBoxLayout(widget)
|
||||||
v_layout.setContentsMargins(0, 0, 0, 0)
|
v_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -450,10 +521,14 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.table_widget.sortItems(0, Qt.DescendingOrder)
|
self.table_widget.sortItems(0, Qt.DescendingOrder)
|
||||||
|
|
||||||
def _on_cell_changed(self, row, col, prev_row, prev_col):
|
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:
|
if row >= 0:
|
||||||
self._load_pair(row)
|
self._load_pair(row)
|
||||||
|
|
||||||
def _load_pair(self, 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():
|
if row < 0 or row >= self.table_widget.rowCount():
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -579,7 +654,20 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
|
|
||||||
def _set_pane_data(self, pane_widget, path, filename_color, dir_color,
|
def _set_pane_data(self, pane_widget, path, filename_color, dir_color,
|
||||||
filename_text, dir_text) -> bool:
|
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
|
pane = pane_widget.pane
|
||||||
info_lbl = pane_widget.info_lbl
|
info_lbl = pane_widget.info_lbl
|
||||||
filename_lbl = pane_widget.filename_lbl
|
filename_lbl = pane_widget.filename_lbl
|
||||||
@@ -625,7 +713,12 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
return disable_linking
|
return disable_linking
|
||||||
|
|
||||||
def _show_pane_context_menu(self, pos):
|
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()
|
pane = self.sender()
|
||||||
path = pane.controller.get_current_path()
|
path = pane.controller.get_current_path()
|
||||||
if not path or not os.path.exists(path):
|
if not path or not os.path.exists(path):
|
||||||
@@ -685,7 +778,12 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
menu.exec(pane.mapToGlobal(pos))
|
menu.exec(pane.mapToGlobal(pos))
|
||||||
|
|
||||||
def _handle_permanent_delete(self, path):
|
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 = QMessageBox(self)
|
||||||
confirm.setIcon(QMessageBox.Warning)
|
confirm.setIcon(QMessageBox.Warning)
|
||||||
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
|
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
|
||||||
@@ -697,7 +795,13 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self._handle_action(delete_path=path, permanent=True)
|
self._handle_action(delete_path=path, permanent=True)
|
||||||
|
|
||||||
def _show_properties(self, path, pane):
|
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
|
tags = pane.controller._current_tags
|
||||||
rating = pane.controller._current_rating
|
rating = pane.controller._current_rating
|
||||||
dlg = PropertiesDialog(
|
dlg = PropertiesDialog(
|
||||||
@@ -705,7 +809,11 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
dlg.exec()
|
dlg.exec()
|
||||||
|
|
||||||
def _on_pane_activated(self):
|
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
|
# When a pane is activated, ensure its zoom/scroll is the reference for linking
|
||||||
if self.panes_linked:
|
if self.panes_linked:
|
||||||
active_pane = self.sender() # The pane that emitted activated signal
|
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)
|
other_pane.set_scroll_relative(x_pct, y_pct)
|
||||||
|
|
||||||
def _sync_scroll(self, 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:
|
if not self.panes_linked:
|
||||||
return
|
return
|
||||||
source_pane = self.sender()
|
source_pane = self.sender()
|
||||||
@@ -730,7 +844,13 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.left_pane.set_scroll_relative(x_pct, y_pct)
|
self.left_pane.set_scroll_relative(x_pct, y_pct)
|
||||||
|
|
||||||
def _sync_zoom(self, factor, source_pane=None):
|
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:
|
if not self.panes_linked or self._is_syncing:
|
||||||
return
|
return
|
||||||
if source_pane is None:
|
if source_pane is None:
|
||||||
@@ -788,7 +908,12 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self._is_syncing = False
|
self._is_syncing = False
|
||||||
|
|
||||||
def _format_size(self, size):
|
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']:
|
for unit in ['B', 'KiB', 'MiB', 'GiB']:
|
||||||
if size < 1024:
|
if size < 1024:
|
||||||
return f"{size:.1f} {unit}"
|
return f"{size:.1f} {unit}"
|
||||||
@@ -808,7 +933,7 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self._handle_action(delete_path=path_to_delete)
|
self._handle_action(delete_path=path_to_delete)
|
||||||
|
|
||||||
def _toggle_link_panes(self):
|
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._user_link_preference = self.btn_link_panes.isChecked()
|
||||||
self.panes_linked = self._user_link_preference
|
self.panes_linked = self._user_link_preference
|
||||||
if self.panes_linked:
|
if self.panes_linked:
|
||||||
@@ -823,7 +948,12 @@ class DuplicateManagerDialog(QDialog):
|
|||||||
self.right_pane.set_scroll_relative(x_pct, y_pct)
|
self.right_pane.set_scroll_relative(x_pct, y_pct)
|
||||||
|
|
||||||
def _on_file_deleted_externally(self, path):
|
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)
|
path = os.path.abspath(path)
|
||||||
|
|
||||||
# 1. Identify pairs to remove and clean up the pending DB
|
# 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)
|
self.table_widget.setCurrentCell(new_row, 0)
|
||||||
|
|
||||||
def _on_file_moved_externally(self, old_path, new_path):
|
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)
|
old_path = os.path.abspath(old_path)
|
||||||
new_path = os.path.abspath(new_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):
|
def _handle_action(self, delete_path=None, skip=False, permanent=None):
|
||||||
"""
|
"""
|
||||||
Handles management actions (delete, skip, keep) for duplicate pairs.
|
Handles management actions (delete, skip, keep) for duplicate pairs.
|
||||||
|
Updates the local list and the persistent databases.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
delete_path: Path to delete, if any.
|
delete_path (str, optional): Path to delete.
|
||||||
skip: Whether to skip the current pair.
|
skip (bool): Whether to skip without ignoring permanently.
|
||||||
|
permanent (bool, optional): If True, deletes without trash.
|
||||||
"""
|
"""
|
||||||
current_row = self.table_widget.currentRow()
|
current_row = self.table_widget.currentRow()
|
||||||
if current_row < 0:
|
if current_row < 0:
|
||||||
|
|||||||
@@ -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
|
import os
|
||||||
try:
|
try:
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
@@ -14,6 +25,10 @@ class FileSystemWatcher(QObject):
|
|||||||
Monitors file system events (created, deleted, modified) for specified directories.
|
Monitors file system events (created, deleted, modified) for specified directories.
|
||||||
Emits signals to notify the main application thread of changes.
|
Emits signals to notify the main application thread of changes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Signals emitted to the rest of the application
|
||||||
|
# ---------------------------------------------
|
||||||
|
|
||||||
file_created = Signal(str)
|
file_created = Signal(str)
|
||||||
file_deleted = Signal(str)
|
file_deleted = Signal(str)
|
||||||
file_modified = 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
|
directory_modified = Signal(str) # For changes that might not be specific files
|
||||||
|
|
||||||
_modified_events_queue = {} # {path: QTimer}
|
_modified_events_queue = {} # {path: QTimer}
|
||||||
|
"""Queue to manage debouncing of modification events."""
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
|
"""
|
||||||
|
Initializes the FileSystemWatcher.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parent (QObject, optional): The parent object. Defaults to None.
|
||||||
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._watched_directories = set()
|
self._watched_directories = set()
|
||||||
|
self._debounce_interval = 500 # milliseconds
|
||||||
|
|
||||||
if HAVE_WATCHDOG:
|
if HAVE_WATCHDOG:
|
||||||
self._observer = Observer()
|
self._observer = Observer()
|
||||||
@@ -36,16 +59,21 @@ class FileSystemWatcher(QObject):
|
|||||||
else:
|
else:
|
||||||
self._observer = None # Keep observer as None if watchdog is not available
|
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
|
# Connect the internal signal to the debouncing slot
|
||||||
if HAVE_WATCHDOG:
|
if HAVE_WATCHDOG:
|
||||||
self._file_modified_from_handler.connect(self._on_file_modified_debounced)
|
self._file_modified_from_handler.connect(self._on_file_modified_debounced)
|
||||||
|
|
||||||
def _on_file_modified_debounced(self, path):
|
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
|
# Debounce timer for modified events to avoid multiple signals for a single save
|
||||||
if path in self._modified_events_queue:
|
if path in self._modified_events_queue:
|
||||||
self._modified_events_queue[path].stop()
|
self._modified_events_queue[path].stop()
|
||||||
@@ -59,7 +87,12 @@ class FileSystemWatcher(QObject):
|
|||||||
self._modified_events_queue[path].start()
|
self._modified_events_queue[path].start()
|
||||||
|
|
||||||
def _emit_modified_after_debounce(self, path):
|
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)
|
self.file_modified.emit(path)
|
||||||
if path in self._modified_events_queue:
|
if path in self._modified_events_queue:
|
||||||
# Safely delete the QTimer object when done
|
# Safely delete the QTimer object when done
|
||||||
@@ -67,7 +100,16 @@ class FileSystemWatcher(QObject):
|
|||||||
del self._modified_events_queue[path]
|
del self._modified_events_queue[path]
|
||||||
|
|
||||||
def add_path(self, 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:
|
if not HAVE_WATCHDOG or self._observer is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -111,7 +153,12 @@ class FileSystemWatcher(QObject):
|
|||||||
self.monitoring_status_changed.emit(True)
|
self.monitoring_status_changed.emit(True)
|
||||||
|
|
||||||
def remove_path(self, path):
|
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:
|
if not HAVE_WATCHDOG or self._observer is None:
|
||||||
return
|
return
|
||||||
abs_path = os.path.normpath(os.path.abspath(os.path.expanduser(path)))
|
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)
|
self.monitoring_status_changed.emit(False)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stops the file system observer."""
|
"""
|
||||||
|
Stops the file system observer and cleans up active timers.
|
||||||
|
"""
|
||||||
if HAVE_WATCHDOG and self._observer:
|
if HAVE_WATCHDOG and self._observer:
|
||||||
self._observer.stop()
|
self._observer.stop()
|
||||||
self._observer.join()
|
self._observer.join()
|
||||||
@@ -150,10 +199,19 @@ class FileSystemWatcher(QObject):
|
|||||||
|
|
||||||
if HAVE_WATCHDOG:
|
if HAVE_WATCHDOG:
|
||||||
class _Handler(FileSystemEventHandler):
|
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
|
# Signal to communicate to main thread
|
||||||
file_modified_from_thread = Signal(str)
|
file_modified_from_thread = Signal(str)
|
||||||
"""Custom event handler for watchdog events."""
|
|
||||||
def __init__(self, watcher):
|
def __init__(self, watcher):
|
||||||
|
"""
|
||||||
|
Initializes the handler with a reference to the main watcher.
|
||||||
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.watcher = watcher
|
self.watcher = watcher
|
||||||
|
|
||||||
@@ -199,11 +257,21 @@ class FileSystemWatcher(QObject):
|
|||||||
self.watcher._file_modified_from_handler.emit(event.src_path)
|
self.watcher._file_modified_from_handler.emit(event.src_path)
|
||||||
|
|
||||||
def _emit_modified(self, 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)
|
self.watcher.file_modified.emit(path)
|
||||||
if path in self.watcher._modified_events_queue:
|
if path in self.watcher._modified_events_queue:
|
||||||
del self.watcher._modified_events_queue[path]
|
del self.watcher._modified_events_queue[path]
|
||||||
|
|
||||||
def _is_image_file(self, 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
|
return os.path.splitext(path)[1].lower() in IMAGE_EXTENSIONS
|
||||||
|
|||||||
@@ -3317,7 +3317,8 @@ class ImageViewer(QWidget):
|
|||||||
# A standard tick is 120. We define a threshold based on speed.
|
# A standard tick is 120. We define a threshold based on speed.
|
||||||
# Speed 1 (slowest) requires a full 120 delta.
|
# Speed 1 (slowest) requires a full 120 delta.
|
||||||
# Speed 10 (fastest) requires 120/10 = 12 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()
|
self._wheel_scroll_accumulator += event.angleDelta().y()
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "bagheeraview"
|
name = "bagheeraview"
|
||||||
version = "0.9.19"
|
version = "0.9.20"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Ignacio Serantes" }
|
{ name = "Ignacio Serantes" }
|
||||||
]
|
]
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="bagheeraview",
|
name="bagheeraview",
|
||||||
version="0.9.19",
|
version="0.9.20",
|
||||||
author="Ignacio Serantes",
|
author="Ignacio Serantes",
|
||||||
description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind",
|
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 "
|
long_description="A fast image viewer built with PySide6, featuring search and "
|
||||||
|
|||||||
Reference in New Issue
Block a user